// screens_port.jsx — Port-zone screens (responsive)
window.S = window.S || {};
(() => {
const { useState } = React;
const { PORTS, SHIPS, FACTIONS, EQUIPMENT, STARTS, RESOURCES } = window.D;
const L = window.L;
const A = window.E.A;
const { T, panelStyle, Bar, Pill, Btn, PulseBtn, StatBlock, SectionTitle, ScreenHeader, LogList, Divider, EmptyState, NarrativePanel, NarrativeLine, TutorialPopup, BackButton, Tooltip, QMPopup,
IconMap, IconBarChart, IconMarket, IconJournal, IconAnchor, IconCrew, IconFloppy, IconFileTransfer, IconTalking, IconGold, IconSkull, IconHandshake, IconSearch, PortSilhouette } = window.UI;
const { FactionPill, RepPill, ShipSprite } = window.UI;
const { shouldShowTutorial, markTutorialSeen } = window.L;
// ── PORT SCREEN ──────────────────────────────────────────────────────
function PortScreen({ state, dispatch }) {
const port = PORTS[state.currentPort];
const rep = state.reputation[state.currentPort] ?? 0;
const perk = L.getRepPerk(rep);
const repCost = Math.floor(L.shipRepairCost(state) * (perk.repairMult || 1));
const canFinish = state.activeMission && (!state.activeMission.targetPort || state.currentPort === state.activeMission.targetPort);
const importRef = React.useRef(null);
const handleImport = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => dispatch({ type: A.IMPORT_SAVE, fileContent: reader.result });
reader.readAsText(file);
e.target.value = "";
};
const [showTutorial, setShowTutorial] = React.useState(() => shouldShowTutorial(state,"port"));
const [isNarrow, setIsNarrow] = React.useState(window.innerWidth < 700);
React.useEffect(() => {
const handleResize = () => setIsNarrow(window.innerWidth < 700);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// ── Feature unlocking gates ──────────────────────────────────────
const canContracts = true; // always available
const canMarket = L.isFeatureUnlocked(state, 'market');
const canNavigation = L.isFeatureUnlocked(state, 'navigation');
const canCrew = L.isFeatureUnlocked(state, 'crew');
const canShipyard = L.isFeatureUnlocked(state, 'shipyard');
const canJournal = L.isFeatureUnlocked(state, 'journal');
return (
{showTutorial && (
{
markTutorialSeen("port", disableAll);
setShowTutorial(false);
}}
>
This is where you'll plan your next move. From here you can:
Accept missions from the Mission Board — they pay gold and build your fame
Buy and sell goods at the Market — buy cheap, sell dear
Hire crew and buy them drinks to keep morale up
Repair your ship at the Shipyard
Read the gossip — the locals know more than they let on
Your first mission is already accepted. Open the Map to set sail.
)}
{/* ── Column 1: Atmosphere, Actions & Missions ─────────── */}
{/* Port header + description + gossip */}
{port.name}
{FACTIONS[port.faction]?.label.toUpperCase()} PORT
{port.desc}
{state.portGossip?.length > 0 && (
WORD ON THE DOCKS>} variant="gossip"> {state.portGossip.map((line, i) => (
{line}
))}
)}
{perk.servicesBlocked && (
)}
{/* Action buttons */}
ACTIONS
{canNavigation && (
dispatch({ type: A.NAVIGATE, screen: "map" })}> World Map
)}
dispatch({ type: A.NAVIGATE, screen: "status" })}> Status
{canMarket && (
dispatch({ type: A.NAVIGATE, screen: "market" })}> Market
)}
{canJournal && (
dispatch({ type: A.NAVIGATE, screen: "journal" })}> Journal
)}
{!perk.servicesBlocked && (
<>
{port.services.includes("shipyard") && (
dispatch({ type: A.NAVIGATE, screen: "shipyard" })}> Shipyard
)}
{port.services.includes("crew") && (
dispatch({ type: A.NAVIGATE, screen: "crew" })}> Crew
)}
{port.services.includes("shipyard") && state.ship.hull < L.getShipStats(state).maxHull && (
dispatch({ type: A.REPAIR })} disabled={state.gold < repCost}>
Quick Repair ({repCost}g)
)}
>
)}
{/* Save/Load/Export/Import — not gated, normal Btn */}
dispatch({ type: A.SAVE_GAME })}> Save Game
{L.hasSave() && (
dispatch({ type: A.LOAD_GAME })} style={{ marginTop: 4 }}>
Load Game
)}
dispatch({ type: A.EXPORT_SAVE })}> Export Save
importRef.current?.click()}> Import Save
{/* Mission board */}
dispatch({ type: A.REFRESH_MISSIONS })}>Refresh
}>
MISSION BOARD
{perk.tier !== "neutral" && (
1 ? T.greenBr : T.gold, fontSize: T.captionFontSize, marginBottom: 8 }}>
{perk.missionMult > 1
? `★ ${perk.tier} standing: +${Math.round((perk.missionMult - 1) * 100)}% mission rewards`
: `⚠ Hostile standing: −${Math.round((1 - perk.missionMult) * 100)}% mission rewards`}
)}
{state.activeMission && (
ACTIVE: {state.activeMission.name}
{state.activeMission.description}
Destination: {PORTS[state.activeMission.targetPort]?.name || "At sea"}
💰 {state.activeMission.gold}
★ {state.activeMission.fame}
{state.activeMission.requiredGood && state.activeMission.requiredQty && (() => {
const res = window.D.RESOURCES[state.activeMission.requiredGood];
const inHold = state.hold?.items?.[state.activeMission.requiredGood] || 0;
const hasGoods = inHold >= state.activeMission.requiredQty;
const goodName = res?.name || state.activeMission.requiredGood;
return (
{hasGoods
? `✓ ${inHold} ${goodName} in hold — ready`
: `✗ ${inHold}/${state.activeMission.requiredQty} ${goodName} — visit market`}
);
})()}
{canFinish && (
dispatch({ type: A.COMPLETE_MISSION })}
disabled={state.activeMission.requiredGood && (state.hold?.items?.[state.activeMission.requiredGood] || 0) < state.activeMission.requiredQty}>
Complete Mission
)}
dispatch({ type: A.ABANDON_MISSION })}>Abandon
{!canFinish && (
Sail to {PORTS[state.activeMission.targetPort]?.name} to complete.
)}
)}
{!port.services.includes("missions") ? (
) : state.missions.length === 0 ? (
) : (
{state.missions.map((m, i) => (
{m.description || m.desc}
{m.enemy && (
Enemy: {m.enemy.name} — {m.enemy.cannons} cannons, hull {m.enemy.hull}, crew {m.enemy.crew}
)}
{(m.requiredGood && m.requiredQty) && (() => {
const res = window.D.RESOURCES[m.requiredGood];
const inHold = state.hold?.items?.[m.requiredGood] || 0;
const alreadyHave = inHold >= m.requiredQty;
const partialHave = inHold > 0 && inHold < m.requiredQty;
const isIllegal = res?.illegal;
const holdFree = (L.getHoldCapacity(state) || 0) - L.getHoldUsed(state.hold?.items || {});
const canFit = holdFree >= (m.requiredQty - inHold);
return (
{m.type === "smuggle" ? "⚠ Contraband required" : "Cargo required"}
{m.requiredQty} × {res?.name || m.requiredGood}
{isIllegal && (Illegal) }
{alreadyHave
? ✓ In hold ({inHold} — ready to deliver)
: partialHave
? {inHold}/{m.requiredQty} in hold — need {m.requiredQty - inHold} more
: Not yet sourced — check market or source elsewhere
}
{!alreadyHave && !canFit && (
⚠ Only {holdFree} hold space free — sell cargo first
)}
{m.type === "smuggle" && res?.sourceHint && (
{res.sourceHint}
)}
{m.type === "trade" && (
Est. cost: ~{res?.basePrice * m.requiredQty}g
· Payment on delivery: {m.gold}g
· Est. profit: ~{m.gold - res?.basePrice * m.requiredQty}g
)}
{m.type === "smuggle" && (
+{m.infamyGain} infamy on completion
{m.requiredGood === "slaves" ? " · +1 infamy on purchase" : ""}
)}
);
})()}
💰 {m.gold}
★ {m.fame}
→ {PORTS[m.targetPort]?.name}
dispatch({ type: A.TAKE_MISSION, mission: m })}>Accept
))}
)}
{/* ── Column 2: Captain's Log ──────────────────────────── */}
);
}
// ── STATUS SCREEN ────────────────────────────────────────────────────
// ── STATUS SCREEN ────────────────────────────────────────────────────
function StatusScreen({ state, dispatch }) {
const [showTutorial, setShowTutorial] = React.useState(() => shouldShowTutorial(state, "status"));
const [showFullLedger, setShowFullLedger] = React.useState(false);
const [isNarrowStatus, setIsNarrowStatus] = React.useState(window.innerWidth < 700);
React.useEffect(() => {
const handle = () => setIsNarrowStatus(window.innerWidth < 700);
window.addEventListener("resize", handle);
return () => window.removeEventListener("resize", handle);
}, []);
// ── Derived values ─────────────────────────────────────────────
const career = state.career || {};
const daysSurvived = state.day;
const portsTotal = Object.keys(PORTS).length;
const portsVisitedCount = (career.portsVisited || []).length;
const totalBattles = (career.battles?.won || 0) + (career.battles?.lost || 0) + (career.battles?.fled || 0);
const totalCrewLost = (career.crewLost?.inBattle || 0) + (career.crewLost?.inStorm || 0)
+ (career.crewLost?.deserted || 0) + (career.crewLost?.other || 0);
// Headline identity tag
const getCaptainTag = () => {
const fame = state.fame || 0;
const infamy = state.infamy || 0;
if (infamy >= 100) return { text: "Legendary Outlaw of the Caribbean", color: T.redBr };
if (infamy >= 50) return { text: "Notorious Across the Caribbean", color: T.redBr };
if (fame >= 200) return { text: "A Legend of the Caribbean", color: T.gold };
if (fame >= 100) return { text: "A Notorious Captain", color: T.gold };
if (fame >= 50) return { text: "A Recognised Captain", color: T.gold };
if (infamy >= 25) return { text: "Wanted by the Law", color: T.redBr };
if (infamy >= 10) return { text: "A Suspect in Several Ports", color: T.gold };
return { text: "An Unknown Captain", color: T.textDim };
};
const captainTag = getCaptainTag();
// ── Career narrative highlights ────────────────────────────────
const getHighlights = () => {
const lines = [];
// Sea time
lines.push(`You have sailed for ${daysSurvived} day${daysSurvived === 1 ? "" : "s"}.`);
// Combat summary
if (totalBattles > 0) {
const won = career.battles?.won || 0;
const lost = career.battles?.lost || 0;
const fled = career.battles?.fled || 0;
const parts = [];
if (won > 0) parts.push(`won ${won}`);
if (lost > 0) parts.push(`lost ${lost}`);
if (fled > 0) parts.push(`fled ${fled}`);
lines.push(
`Across ${totalBattles} battle${totalBattles === 1 ? "" : "s"}, you have ${parts.join(", ")}.`
);
const sunk = career.shipsSunk || 0;
const plundered = career.shipsPlundered || 0;
if (sunk > 0 || plundered > 0) {
const detailParts = [];
if (sunk > 0) detailParts.push(`sunk ${sunk}`);
if (plundered > 0) detailParts.push(`boarded and plundered ${plundered}`);
lines.push(`Of those, you ${detailParts.join(" and ")}.`);
}
}
// Crew losses (the human cost)
if (totalCrewLost > 0) {
const inBattle = career.crewLost?.inBattle || 0;
const inStorm = career.crewLost?.inStorm || 0;
const deserted = career.crewLost?.deserted || 0;
const parts = [];
if (inBattle > 0) parts.push(`${inBattle} to combat`);
if (inStorm > 0) parts.push(`${inStorm} to the storms`);
if (deserted > 0) parts.push(`${deserted} who walked away`);
if (parts.length > 0) {
lines.push(`You have lost ${totalCrewLost} crew: ${parts.join(", ")}.`);
}
}
// Longest tenure
if (career.longestCrewTenure && career.longestCrewTenure >= 50) {
lines.push(`Your longest-serving crew member sailed with you for ${career.longestCrewTenure} days.`);
}
// Exploration
if (portsVisitedCount > 0) {
lines.push(`You have made landfall at ${portsVisitedCount} of ${portsTotal} ports across the Caribbean.`);
}
// Economic
const earned = career.goldEarned || 0;
const spent = career.goldSpent || 0;
if (earned > 0 || spent > 0) {
lines.push(`You have earned ${earned.toLocaleString()}g and spent ${spent.toLocaleString()}g.`);
}
// Storms
if (career.stormsSurvived > 0) {
lines.push(`You have weathered ${career.stormsSurvived} storm${career.stormsSurvived === 1 ? "" : "s"}.`);
}
// Ships
const ships = (career.shipsOwned || []).length;
if (ships > 1) {
lines.push(`You have commanded ${ships} ship${ships === 1 ? "" : "s"} over your career.`);
}
// Contraband caught
if (career.contrabandSeized > 0) {
lines.push(`You have been caught smuggling contraband ${career.contrabandSeized} time${career.contrabandSeized === 1 ? "" : "s"}.`);
}
return lines;
};
const highlights = getHighlights();
// ── Per-faction summary ────────────────────────────────────────
const getFactionSummary = (factionKey) => {
const ports = Object.entries(PORTS).filter(([_, p]) => p.faction === factionKey);
if (ports.length === 0) return null;
const avgRep = Math.round(
ports.reduce((sum, [k]) => sum + (state.reputation[k] ?? 50), 0) / ports.length
);
const repLabel = L.reputationLabel(avgRep);
const heat = state.factionAlerts?.[factionKey] || 0;
const heatLabel = L.getHeatLabel(heat);
// Crew alignment
const crewOfFaction = (state.crew?.roster || []).filter(m => m.faction === factionKey).length;
const totalCrew = (state.crew?.roster || []).length;
const crewPct = totalCrew > 0 ? Math.round((crewOfFaction / totalCrew) * 100) : 0;
return { avgRep, repLabel, heat, heatLabel, crewOfFaction, totalCrew, crewPct };
};
// Service description from rep
const getServiceNote = (rep) => {
if (rep >= 80) return "−20% repair · +20% missions";
if (rep >= 50) return "−10% repair · +10% missions";
if (rep >= 30) return "Standard prices";
if (rep >= 10) return "−25% missions";
return "No services available";
};
// ── Render ─────────────────────────────────────────────────────
return (
{showTutorial && (
{
markTutorialSeen("status", disableAll);
setShowTutorial(false);
}}
>
This is where your career is tracked — your identity, your deeds, and your standing with the powers of the Caribbean.
Fame — earned through missions. Gates better ships, equipment, and hidden ports.
Infamy — earned through crime. High infamy blocks bribes and attracts bounty hunters.
Career — what you've actually done at sea.
Factions — how each rival power sees you, and how your crew aligns.
The Caribbean keeps a ledger. Your name is written in it.
)}
{/* ═══════════════════════════════════════════════════════════════ */}
{/* SECTION 1: CAPTAIN IDENTITY (Hero panel) */}
{/* ═══════════════════════════════════════════════════════════════ */}
{/* Left: Captain name + faction + tag */}
Captain
{state.captainName || "Unknown"}
{FACTIONS[state.faction]?.label || "No faction"}
{captainTag.text}
{/* Right: Fame / Infamy / Days */}
★ {state.fame}
{L.getFameInfo(state.fame).label}
Fame
0 ? T.red : T.textFaint, fontSize: 22, fontWeight: "bold" }}>
0 ? T.red : T.textFaint} /> {state.infamy ?? 0}
{L.getInfamyLabel(state.infamy ?? 0)}
Infamy
{daysSurvived}
days at sea
Tenure
{/* ═══════════════════════════════════════════════════════════════ */}
{/* SECTION 2: CAREER HIGHLIGHTS (Narrative) */}
{/* ═══════════════════════════════════════════════════════════════ */}
CAREER
{highlights.map((line, i) => (
{line}
))}
{/* Toggle for full ledger */}
setShowFullLedger(v => !v)}
style={{
color: T.textFaint,
fontSize: T.captionFontSize,
cursor: "pointer",
marginTop: 4,
padding: 4,
borderTop: `1px solid ${T.borderFaint}`,
}}
>
{showFullLedger ? "▾ Hide full ledger" : "▸ Show full ledger"}
{showFullLedger && (
{/* Economic */}
Economic
{/* Combat */}
Combat
{/* Crew */}
Crew
{/* World */}
World
)}
{/* ═══════════════════════════════════════════════════════════════ */}
{/* SECTION 3: THE WORLD'S VIEW (Per-faction unified) */}
{/* ═══════════════════════════════════════════════════════════════ */}
THE WORLD'S VIEW
How each faction sees you, and how your crew aligns with them.
{Object.entries(FACTIONS).map(([factionKey, fac]) => {
const summary = getFactionSummary(factionKey);
if (!summary) return null;
const { avgRep, repLabel, heat, heatLabel, crewOfFaction, totalCrew, crewPct } = summary;
const repColor = avgRep >= 60 ? T.greenBr : avgRep >= 30 ? T.gold : T.redBr;
return (
{/* Header: faction name + rivals */}
{fac.label}
{fac.rivalFactions?.length
? `Rivals: ${fac.rivalFactions.map(r => FACTIONS[r]?.label ?? r).join(", ")}`
: "No known rivals"}
{/* Reputation bar */}
Standing
{repLabel} ({avgRep})
{getServiceNote(avgRep)}
{/* Heat (only if non-zero) */}
{heat > 0 && (
Heat
{heatLabel} ({heat}/10)
)}
{/* Crew alignment */}
{totalCrew > 0 && (
{crewOfFaction === 0
? `None of your crew are ${fac.label}.`
: crewOfFaction === totalCrew
? `Your entire crew is ${fac.label}.`
: `${crewOfFaction} of ${totalCrew} crew (${crewPct}%) are ${fac.label}.`
}
)}
);
})}
Reputation decays slowly toward neutral (50) over time. Complete missions, aid distressed ships, or parley with faction vessels to improve standing. Attacking their ships will anger all ports of that faction. Heat decays naturally as you stay clear of trouble.
);
}
// ── JOURNAL SCREEN ──────────────────────────────────────────────────
function JournalScreen({ state, dispatch }) {
const [filterTab, setFilterTab] = useState("all");
const [search, setSearch] = useState("");
const [showTutorial, setShowTutorial] = React.useState(() => shouldShowTutorial(state,"journal"));
// Parse log entries
const parsed = state.log.map(entry => {
const match = entry.match(/^\[(\d+)\]\s*(.*)/);
const day = match ? parseInt(match[1], 10) : null;
const text = match ? match[2] : entry;
return { day, text, raw: entry, tab: L.getLogTabCategory(text) };
});
// Apply category filter
let filtered = parsed;
if (filterTab !== "all") {
filtered = filtered.filter(e => e.tab === filterTab);
}
// Apply search filter
if (search.trim()) {
const query = search.toLowerCase();
filtered = filtered.filter(e => e.text.toLowerCase().includes(query));
}
// Reverse chronological order (most recent first)
filtered = [...filtered].reverse();
// Group by day for rendering
let lastDay = null;
const tabs = [
{ key: "all", label: "All" },
{ key: "crew", label: "Crew" },
{ key: "combat", label: "Combat" },
{ key: "ports", label: "Ports" },
{ key: "missions", label: "Missions" },
{ key: "trade", label: "Trade" },
];
return (
{showTutorial && (
{
markTutorialSeen("journal", disableAll);
setShowTutorial(false);
}}
>
Everything that has happened on this voyage is recorded here — battles, arrivals, crew events, trades, and discoveries.
Use the tabs to filter by category: Crew, Combat, Ports, Missions, or Trade.
Use the search bar to find a specific crew member, port, or event by name.
Entries are grouped by day — scroll back to relive the story of your run.
The journal is the story of your career. The longer you sail, the richer it becomes.
)}
CAPTAIN'S JOURNAL
Every storm, every battle, every whispered secret—recorded here for posterity.
{/* Filter tabs */}
{tabs.map(tab => (
setFilterTab(tab.key)}
>
{tab.label}
))}
{/* Search bar */}
setSearch(e.target.value)}
style={{
width: "100%",
padding: "6px 10px",
background: T.panel,
border: `1px solid ${T.border}`,
color: T.text,
borderRadius: 3,
fontSize: T.metadataFontSize,
fontFamily: T.font,
outline: "none",
}}
/>
{search && (
{filtered.length} entr{filtered.length === 1 ? "y" : "ies"} found
)}
{/* Entries */}
{filtered.length === 0 ? (
) : (
filtered.map((entry, i) => {
const showDay = entry.day !== null && entry.day !== lastDay;
lastDay = entry.day;
return (
{showDay && (
Day {entry.day}
)}
{(() => {
const categoryKey = L.classifyLogLine(entry.text);
const LOG_ICONS = window.UI.LOG_ICONS || {};
const IconComponent = categoryKey ? LOG_ICONS[categoryKey] : null;
return IconComponent ? (
) : null;
})()}
{entry.text}
);
})
)}
);
}
// ── EXPORT ALL SCREENS ──────────────────────────────────────────────
Object.assign(window.S, {
PortScreen,
StatusScreen,
JournalScreen,
});
})();