// screens_shipyard.jsx — Shipyard Screen (Option A: Split Dashboard) window.S = window.S || {}; (() => { const { useState, useEffect, useMemo } = React; const { SHIPS, EQUIPMENT, PORTS } = window.D; const L = window.L; const A = window.E.A; const { T, panelStyle, Bar, Pill, Btn, StatBlock, SectionTitle, EmptyState, TutorialPopup, BackButton, IconShield, IconCannon, IconSailboat, IconSparkles, IconChest, IconHammer, IconCog, IconShip,ShipSideSprite, } = window.UI; const { shouldShowTutorial, markTutorialSeen } = window.L; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // CONSTANTS // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ── Visual equipment helper ────────────────────────────────────────── // Only these equipment items have a visible effect on the ship sprite. // When more equipment gets visual effects, add the keys here. const VISUAL_EQUIPMENT = ["war_pennants", "extra_sails", "lateen_rig"]; const getVisualEquipment = (state) => { const allEquipped = [ ...(state.ship.equipment?.hull || []), ...(state.ship.equipment?.armament || []), ...(state.ship.equipment?.rigging || []), ...(state.ship.equipment?.special || []), ]; return allEquipped.filter(key => VISUAL_EQUIPMENT.includes(key)); }; const SLOT_LABELS = { hull: { label: "Hull", Icon: IconShield }, armament: { label: "Armament", Icon: IconCannon }, rigging: { label: "Rigging", Icon: IconSailboat }, special: { label: "Special", Icon: IconSparkles }, }; const TABS = { EQUIP: "equip", SHIPS: "ships", LOCKER: "locker" }; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // STAT PREVIEW HELPER // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ function StatDelta({ label, before, after }) { const diff = after - before; const arrow = diff > 0 ? " ↑" : diff < 0 ? " ↓" : " ="; const color = diff > 0 ? T.greenBr : diff < 0 ? T.redBr : T.textDim; return (
{label} {before} → {after}{arrow}
); } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // MAIN COMPONENT // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ function ShipyardScreen({ state, dispatch }) { // --- Rep / services check --- const perk = L.getRepPerk(state.reputation[state.currentPort] ?? 50); if (perk.servicesBlocked) { return (
); } // --- Core data --- const repCost = Math.floor(L.shipRepairCost(state) * (perk.repairMult || 1)); const currentShip = SHIPS[state.ship.type]; const effectiveStats = L.getShipStats(state); // --- UI state --- const [activeTab, setActiveTab] = useState(TABS.SHIPS); const [slotFilter, setSlotFilter] = useState("all"); const [selectedEquip, setSelectedEquip] = useState(null); const [selectedShip, setSelectedShip] = useState(null); const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(state,"shipyard")); // --- Responsive --- const [isNarrow, setIsNarrow] = useState(window.innerWidth < 700); useEffect(() => { const h = () => setIsNarrow(window.innerWidth < 700); window.addEventListener("resize", h); return () => window.removeEventListener("resize", h); }, []); // --- Accordion (mobile) --- const [equippedOpen, setEquippedOpen] = useState(!isNarrow); // Clear selection on tab switch const switchTab = (tab) => { setActiveTab(tab); setSelectedEquip(null); setSelectedShip(null); setSlotFilter("all"); }; // --- Locker items --- const lockerItems = useMemo(() => (state.equipmentInventory || []).map(key => { const item = EQUIPMENT[key]; if (!item) return null; const validation = L.canInstallEquipment(state, key); return { key, ...item, validation, canAfford: state.gold >= item.installFee }; }).filter(Boolean), [state]); const hasLocker = lockerItems.length > 0; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // PREVIEW PANEL (equipment or ship) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ const previewEquipStats = (equipmentKey) => { const newEquip = { ...state.ship.equipment }; const item = EQUIPMENT[equipmentKey]; if (!item) return effectiveStats; newEquip[item.slot] = [...(newEquip[item.slot] || []), equipmentKey]; const tempState = { ...state, ship: { ...state.ship, equipment: newEquip } }; return L.getShipStats(tempState); }; const renderPreviewPanel = () => { // --- Equipment preview --- if (selectedEquip) { const item = EQUIPMENT[selectedEquip]; if (!item) return null; const after = previewEquipStats(selectedEquip); const validation = L.canInstallEquipment(state, selectedEquip); const isFromLocker = (state.equipmentInventory || []).includes(selectedEquip); const totalCost = isFromLocker ? item.installFee : item.cost + item.installFee; const canAfford = state.gold >= totalCost; return (
{isFromLocker ? React.createElement(React.Fragment, null, React.createElement(IconChest, { size: 12, color: T.gold }), " Install from Locker" ) : React.createElement(React.Fragment, null, React.createElement(IconHammer, { size: 12, color: T.gold }), " Preview: Buy & Install" ) } setSelectedEquip(null)} style={{ flexShrink: 0 }}>✕
{item.name}
{item.desc}{item.downsideDesc ? ` ${item.downsideDesc}` : ""}
{!validation.ok ? (
🔒 {validation.reason}
) : !canAfford ? (
Need {totalCost - state.gold}g more
) : ( { if (isFromLocker) { dispatch({ type: A.INSTALL_EQUIPMENT, equipmentKey: selectedEquip }); } else { dispatch({ type: A.BUY_EQUIPMENT, equipmentKey: selectedEquip }); } setSelectedEquip(null); }}> {isFromLocker ? `Install (${item.installFee}g)` : `Buy & Install (${totalCost}g)`} )}
); } // --- Ship comparison preview --- if (selectedShip && selectedShip !== state.ship.type) { const s = SHIPS[selectedShip]; const cur = currentShip; const shipReq = L.meetsRequirement(state, s); const canBuy = shipReq.allowed && state.gold >= s.cost; const lack = shipReq.allowed && state.gold < s.cost ? s.cost - state.gold : 0; return (
Compare: {s.name} vs {cur.name} setSelectedShip(null)} style={{ flexShrink: 0 }}>✕
{/* Selected ship sprite */}
⚠ Buying a new ship clears all installed equipment. Remove items to your locker first.
{!shipReq.allowed ? (
🔒 {shipReq.reason}
) : lack > 0 ? (
Need {lack.toLocaleString()}g more
) : ( { dispatch({ type: A.BUY_SHIP, shipType: selectedShip }); setSelectedShip(null); }}> Purchase ({s.cost.toLocaleString()}g) )}
); } return null; }; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // LEFT PANEL — Ship Stats + Equipped // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ const renderLeftPanel = () => (
{/* Ship sprite */}
{/* Current Vessel Stats */}
CURRENT VESSEL — {currentShip.name}
{/* Repair */}
Hull: {state.ship.hull} / {effectiveStats.maxHull}
= 0.6 ? T.greenBr : state.ship.hull / effectiveStats.maxHull >= 0.3 ? T.gold : T.redBr } h={8} />
dispatch({ type: A.REPAIR })} disabled={state.ship.hull >= effectiveStats.maxHull || state.gold < repCost}> Full Repair ({repCost}g)
{/* Equipped Items */}
isNarrow && setEquippedOpen(v => !v)} > EQUIPPED {isNarrow && {equippedOpen ? "▾" : "▸"}}
{(isNarrow ? equippedOpen : true) && (
{Object.entries(SLOT_LABELS).map(([slotKey, slotInfo]) => { const SlotIcon = slotInfo.Icon; const slotMax = currentShip.slots?.[slotKey] || 0; if (slotMax === 0) return null; const installed = state.ship.equipment?.[slotKey] || []; return (
{slotInfo.label} ({installed.length}/{slotMax})
{installed.length === 0 ? (
empty
) : installed.map(key => { const item = EQUIPMENT[key]; if (!item) return null; return (
{item.name} {!item.removable && (Struct.)}
{item.removable && ( dispatch({ type: A.REMOVE_EQUIPMENT, equipmentKey: key })} disabled={state.gold < item.installFee} style={{ fontSize: 9, padding: "2px 5px", minHeight: 24 }}> −{item.installFee}g )}
); })}
); })}
)}
); // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // RIGHT PANEL — Tabs, Preview, Grid // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ const renderEquipmentTab = () => { // Slot filter buttons – using icon components const filterButtons = [ { key: "all", label: "All" }, ...Object.entries(SLOT_LABELS).map(([k, v]) => ({ key: k, label: React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center' } }, React.createElement(v.Icon, { size: 10, color: T.textDim, style: { marginRight: 3 } }), v.label ) })), ]; const shopItems = Object.entries(EQUIPMENT).map(([key, item]) => { const validation = L.canInstallEquipment(state, key); const totalCost = item.cost + item.installFee; const canAfford = state.gold >= totalCost; return { key, ...item, validation, canAfford, totalCost }; }).filter(item => slotFilter === "all" || item.slot === slotFilter); return (
{/* Slot filters */}
{filterButtons.map(f => ( { setSlotFilter(f.key); setSelectedEquip(null); }}> {f.label} ))}
{/* Equipment grid */}
{shopItems.map(item => { const isSelected = selectedEquip === item.key; const SlotIcon = SLOT_LABELS[item.slot] ? SLOT_LABELS[item.slot].Icon : null; return (
setSelectedEquip(isSelected ? null : item.key)} style={{ ...panelStyle({ background: isSelected ? T.panelAlt : T.panel, borderColor: isSelected ? T.gold : T.border, cursor: "pointer", transition: "border-color 0.15s", }), opacity: item.validation.ok ? 1 : 0.55, }}>
{item.name} {item.totalCost}g
{item.desc}{item.downsideDesc ? ` ${item.downsideDesc}` : ""}
{SlotIcon && ( )} {SLOT_LABELS[item.slot]?.label || item.slot} {item.requiredFame > 0 || item.requiredHull > 0 ? ( · Fame {item.requiredFame} · Hull {item.requiredHull}+ ) : null}
{!item.validation.ok && (
🔒 {item.validation.reason}
)}
); })}
); }; const renderShipsTab = () => { return (
{Object.entries(SHIPS).map(([key, s]) => { const isCur = key === state.ship.type; const shipReq = L.meetsRequirement(state, s); const isSelected = selectedShip === key; return (
{ if (!isCur) setSelectedShip(isSelected ? null : key); }} style={{ ...panelStyle({ background: isCur ? T.greenBg : (isSelected ? T.panelAlt : T.panel), borderColor: isCur ? T.greenBr : (isSelected ? T.gold : T.border), cursor: isCur ? "default" : "pointer", transition: "border-color 0.15s", }), opacity: shipReq.allowed ? 1 : 0.55, }}>
{s.name} {isCur ? : {s.cost.toLocaleString()}g }

{s.desc}

{[["Crew", s.maxCrew], ["Guns", s.cannons], ["Spd", s.speed], ["Hull", s.maxHull]].map(([l, v]) => )}
{!shipReq.allowed && (
🔒 {shipReq.reason}
)}
); })}
); }; const renderLockerTab = () => { if (lockerItems.length === 0) { return ; } return (
{lockerItems.map(item => { const isSelected = selectedEquip === item.key; const SlotIcon = SLOT_LABELS[item.slot] ? SLOT_LABELS[item.slot].Icon : null; return (
setSelectedEquip(isSelected ? null : item.key)} style={{ ...panelStyle({ background: isSelected ? T.panelAlt : T.panel, borderColor: isSelected ? T.gold : T.border, cursor: "pointer", transition: "border-color 0.15s", }), opacity: item.validation.ok ? 1 : 0.55, }}>
{item.name} Install: {item.installFee}g
{item.desc}{item.downsideDesc ? ` ${item.downsideDesc}` : ""}
{SlotIcon && ( )} {SLOT_LABELS[item.slot]?.label || item.slot}
{!item.validation.ok && (
🔒 {item.validation.reason}
)}
); })}
); }; const renderRightPanel = () => (
{/* Tab bar */}
switchTab(TABS.EQUIP)}> Equipment switchTab(TABS.SHIPS)}> Ships {hasLocker && ( switchTab(TABS.LOCKER)}> Locker ({lockerItems.length}) )}
{/* Stat preview panel */} {renderPreviewPanel()} {/* Tab content */} {activeTab === TABS.EQUIP && renderEquipmentTab()} {activeTab === TABS.SHIPS && renderShipsTab()} {activeTab === TABS.LOCKER && renderLockerTab()}
); // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ROOT RENDER // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ return (
{/* Tutorial */} {showTutorial && ( { markTutorialSeen("shipyard", disableAll); setShowTutorial(false); }} >

This is where you upgrade your ship — or buy a new one entirely.

  • Left panel shows your current ship stats and equipped items
  • Use the tabs to browse Equipment, Ships, or your Locker
  • Click any item to see a stat preview before buying
  • Ships are locked behind fame requirements
  • Buying a new ship clears all equipment — remove items first

A bigger ship means more crew, more cargo, more firepower — but also higher wages.

)} {/* Main layout */}
{renderLeftPanel()} {renderRightPanel()}
); } Object.assign(window.S, { ShipyardScreen }); })();