// screens_port.jsx — Port-zone screens (responsive) window.S = window.S || {}; (() => { const { useState } = React; const { PORTS, SHIPS, FACTIONS, EQUIPMENT, STARTS, RESOURCES, QM_DIALOGUE } = 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, Panel, IconMap, IconBarChart, IconMarket, IconJournal, IconAnchor, IconCrew, IconFloppy, IconFileTransfer, IconTalking, IconGold, IconSkull, IconHandshake, IconSearch, PortSilhouette, IconCoins, IconAttention, IconSailboat, SubPanel } = 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 [qmPopupMessage, setQmPopupMessage] = useState(null); const [menuOpen, setMenuOpen] = useState(false); 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); }, []); // ── Sailing & mission acceptance gates ────────────────────────────── const FIGHT_TYPES = ["combat", "patrol", "assault", "escort"]; const isDinghy = state.ship.type === "dinghy"; const minCrew = L.getMinViableCrew(state.ship.type); const isHullBlocked = state.ship.hull === 0; const isCrewBlocked = !isDinghy && state.crew.roster.length < minCrew; const sailDisabled = isHullBlocked || isCrewBlocked; let sailTooltip = ""; if (isHullBlocked) sailTooltip = "Hull is destroyed – repair needed."; else if (isCrewBlocked) sailTooltip = `Need at least ${minCrew} crew to sail.`; // ── 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'); // ── Helper: find the most negatively impacted faction ──────────── const getHarmedFaction = (mission) => { const repImpact = mission.repImpact || {}; let worstFaction = null; let worstDelta = 0; for (const [faction, delta] of Object.entries(repImpact)) { if (delta < worstDelta) { worstDelta = delta; worstFaction = faction; } } return worstFaction ? { faction: worstFaction, delta: worstDelta } : null; }; // ── Helper to render the mission details box (used by both active and listed missions) ── const renderMissionDetailsBox = (mission) => { const res = mission.requiredGood ? window.D.RESOURCES[mission.requiredGood] : null; const inHold = state.hold?.items?.[mission.requiredGood] || 0; const hasGoods = inHold >= mission.requiredQty; const partialHave = inHold > 0 && inHold < mission.requiredQty; const isIllegal = res?.illegal; const holdFree = (L.getHoldCapacity(state) || 0) - L.getHoldUsed(state.hold?.items || {}); const canFit = holdFree >= (mission.requiredQty - inHold); const harmed = getHarmedFaction(mission); const harmedColor = FACTIONS[harmed?.faction]?.color || T.redBr; // ── Already hunted message ────────────────────────────────────────────── if (mission.type === "combat" && state.completedCombatThisVisit) { return ( {mission.requiredGood && (
{isIllegal ? : null} {mission.type === "smuggle" ? "Contraband required" : "Cargo required"}
{mission.requiredQty} × {res?.name || mission.requiredGood} {isIllegal && (Illegal)}
{hasGoods ? ✓ In hold ({inHold} — ready) : partialHave ? {inHold}/{mission.requiredQty} in hold — need {mission.requiredQty - inHold} more : Not yet sourced — check market or source elsewhere }
{!hasGoods && !canFit && (
⚠ Only {holdFree} hold space free — sell cargo first
)} {mission.type === "smuggle" && res?.sourceHint && (
{res.sourceHint}
)} {mission.type === "trade" && (
Est. cost: ~{res?.basePrice * mission.requiredQty}g · Payment on delivery: {mission.gold}g · Est. profit: ~{mission.gold - res?.basePrice * mission.requiredQty}g
)} {mission.type === "smuggle" && (
+{mission.infamyGain} infamy on completion {mission.requiredGood === "slaves" ? " · +1 infamy on purchase" : ""}
)}
)} {mission.enemy && (
Enemy
{mission.enemy.name} ({FACTIONS[mission.enemy.faction]?.label || mission.enemy.faction}) — {mission.enemy.cannons} cannons, hull {mission.enemy.hull}, crew {mission.enemy.crew}
)} {harmed && (
Will impact negatively the {FACTIONS[harmed.faction]?.label || harmed.faction}
)} {mission.type === "patrol" && (
Sail near {PORTS[mission.targetPort]?.name || "the target port"} and advance days. The enemy will appear with time.
)}
✗ You have already hunted here. Sail to another port for new prey.
); } // ── Normal mission details (no "already hunted" message) ───────────────── if (!mission.enemy && !mission.requiredGood && !harmed && mission.type !== "patrol") return null; return ( {mission.requiredGood && (
{isIllegal ? : null} {mission.type === "smuggle" ? "Contraband required" : "Cargo required"}
{mission.requiredQty} × {res?.name || mission.requiredGood} {isIllegal && (Illegal)}
{hasGoods ? ✓ In hold ({inHold} — ready) : partialHave ? {inHold}/{mission.requiredQty} in hold — need {mission.requiredQty - inHold} more : Not yet sourced — check market or source elsewhere }
{!hasGoods && !canFit && (
⚠ Only {holdFree} hold space free — sell cargo first
)} {mission.type === "smuggle" && res?.sourceHint && (
{res.sourceHint}
)} {mission.type === "trade" && (
Est. cost: ~{res?.basePrice * mission.requiredQty}g · Payment on delivery: {mission.gold}g · Est. profit: ~{mission.gold - res?.basePrice * mission.requiredQty}g
)} {mission.type === "smuggle" && (
+{mission.infamyGain} infamy on completion {mission.requiredGood === "slaves" ? " · +1 infamy on purchase" : ""}
)}
)} {mission.enemy && (
Enemy
{mission.enemy.name} ({FACTIONS[mission.enemy.faction]?.label || mission.enemy.faction}) — {mission.enemy.cannons} cannons, hull {mission.enemy.hull}, crew {mission.enemy.crew}
)} {harmed && (
Will impact negatively the {FACTIONS[harmed.faction]?.label || harmed.faction}
)} {mission.type === "patrol" && (
Sail near {PORTS[mission.targetPort]?.name || "the target port"} and advance days. The enemy will appear with time.
)}
); }; return (
{showTutorial && ( { markTutorialSeen("port", disableAll); setShowTutorial(false); }} >

This is where you'll plan your next move. From here you can:

Your first mission is already accepted. Open the Map to set sail.

)} {/* ── Column 1: Atmosphere, Actions & Missions ─────────── */}
{/* Port header + description + gossip */}
{port.name}

{port.desc}

{state.portGossip?.length > 0 && ( WORD ON THE DOCKS} variant="gossip"> {state.portGossip.map((line, i) => ( {line} ))} )} {perk.servicesBlocked && ( )}
{/* Action buttons */} setMenuOpen(true)}>Game Menu }> ACTIONS
{canNavigation && (
dispatch({ type: A.NAVIGATE, screen: "map" })} disabled={sailDisabled}> World Map {sailDisabled && (
⚠ {sailTooltip}
)}
)} 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 && ( <>
dispatch({ type: A.NAVIGATE, screen: "shipyard" })}> Shipyard dispatch({ type: A.NAVIGATE, screen: "crew" })}> Crew
{state.ship.hull < L.getShipStats(state).maxHull && ( dispatch({ type: A.REPAIR })} disabled={state.gold < repCost}> Quick Repair ({repCost}g) )} )}
{/* 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}
{/* Unified details box */} {renderMissionDetailsBox(state.activeMission)}
{canFinish && ( dispatch({ type: A.COMPLETE_MISSION })} disabled={state.activeMission.requiredGood && (state.hold?.items?.[state.activeMission.requiredGood] || 0) < state.activeMission.requiredQty}> Complete Mission )} { if (state.activeMission?.tutorial && state.onboarding?.enabled && !state.onboarding?.completed) { const qm = state.crew?.roster?.find(m => (m.tags || []).includes('quartermaster')); const qmName = qm ? `${qm.firstName} ${qm.lastName}` : 'Quartermaster'; const msg = QM_DIALOGUE?.tutorialAbandonRefuse ? QM_DIALOGUE.tutorialAbandonRefuse(qmName) : `${qmName} tells you firmly that you can't abandon the opening contract.`; setQmPopupMessage(msg); } else { dispatch({ type: A.ABANDON_MISSION }); } }}>Abandon
{!canFinish && (
Sail to {PORTS[state.activeMission.targetPort]?.name} to complete.
)}
)} {/* Mission board is now always available (no service check) */} {state.missions.length === 0 ? ( ) : (
{state.missions.map((m, i) => { const isFight = FIGHT_TYPES.includes(m.type); const alreadyHuntedHere = m.type === "combat" && state.completedCombatThisVisit; const acceptDisabled = !!state.activeMission || (state.ship.hull === 0 && isFight) || alreadyHuntedHere; const acceptTooltip = acceptDisabled ? (state.ship.hull === 0 && isFight ? "Your ship is unfit for a fight." : alreadyHuntedHere ? "You've already hunted here. Sail to another port to find new prey." : "You already have an active mission.") : "Take this mission as your active objective."; return (
{m.name}

{m.description || m.desc}

{/* Unified details box */} {renderMissionDetailsBox(m)}
{m.gold} ★ {m.fame} → {PORTS[m.targetPort]?.name} dispatch({ type: A.TAKE_MISSION, mission: m })}> Accept
); })}
)}
{/* ── Column 2: Captain's Log ──────────────────────────── */}
CAPTAIN'S LOG
{/* ── QM Popup for tutorial abandon refusal ───────────────── */} {qmPopupMessage && (
{state.crew?.roster?.find(m => (m.tags || []).includes('quartermaster'))?.firstName + " " + state.crew?.roster?.find(m => (m.tags || []).includes('quartermaster'))?.lastName || "Quartermaster"}
{qmPopupMessage}
setQmPopupMessage(null)}>Got it
setQmPopupMessage(null)} style={{ color: T.textFaint, fontSize: T.captionFontSize, cursor: "pointer", textDecoration: "underline", alignSelf: "center" }}> I'll take it from here
)} {menuOpen && ( setMenuOpen(false)} /> )}
); } Object.assign(window.S, { PortScreen }); })();