// 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:
- 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.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.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 ──────────────────────────── */}
{/* ── 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 });
})();