// App.jsx — ROOT COMPONENT class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, info) { console.error("Broadside render error:", error, info); } render() { if (this.state.hasError) { const { T } = window.UI; return (
⚠ Something went wrong
{this.state.error?.message || "An unexpected error occurred."}
Open the browser console for details.
); } return this.props.children; } } // ── HUD COMPONENT (separate so flash hook works) ───────────────── const HUD = ({ state, dispatch, debugOpen, setDebugOpen, isDebug }) => { const { T, Bar, useFlashOnChange, IconStar, IconSkull, IconShield, IconHeart, IconCrew, IconCrate, IconFood, IconWater, IconGold, Tooltip, IconBarrel, IconCalendar, IconPirate } = window.UI; const L = window.L; const { PORTS, FACTIONS } = window.D; const { screen } = state; if (screen === "newgame" || screen === "title") return null; const currentPort = PORTS[state.currentPort]; const stats = L.getShipStats(state); const morale = L.getEffectiveMorale(state); const holdUsed = Object.values(state.hold?.items || {}).reduce((s, q) => s + q, 0); const holdCap = L.getHoldCapacity(state); const food = state.hold?.items?.food ?? 0; const water = state.hold?.items?.water ?? 0; const alerts = state.factionAlerts || {}; const topHeat = Object.entries(alerts).reduce((best, [f, lv]) => lv > best.level ? { faction: f, level: lv } : best, { faction: null, level: 0 }); const start = state.startDate || { day: 1, month: 6, year: 1695 }; const calendarDate = new Date(start.year, start.month - 1, start.day + state.day - 1) .toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); const formatGold = (g) => { if (g === null || g === undefined) return "0g"; return g >= 1000000 ? (g / 1000000).toFixed(3) + "M g" : g.toLocaleString() + "g"; }; const goldFlash = useFlashOnChange(state.gold, { direction: null }); const moraleFlash = useFlashOnChange(morale, { direction: null }); const fameFlash = useFlashOnChange(state.fame, { direction: null }); const infamyFlash = useFlashOnChange(state.infamy ?? 0, { invert: true }); const crewFlash = useFlashOnChange(state.crew.roster.length, { direction: null }); const hullFlash = useFlashOnChange(state.ship.hull, { direction: null }); const foodFlash = useFlashOnChange(food, { direction: null }); const waterFlash = useFlashOnChange(water, { direction: null }); const holdUsedFlash = useFlashOnChange(holdUsed, { direction: null }); const [isNarrowHUD, setIsNarrowHUD] = React.useState(window.innerWidth < 600); React.useEffect(() => { const handle = () => setIsNarrowHUD(window.innerWidth < 600); window.addEventListener("resize", handle); return () => window.removeEventListener("resize", handle); }, []); // ── Rough border for each cell (single stroke, like Btn/Pill) ── const Cell = ({ label, tip, children }) => { const [dims, setDims] = React.useState({ w: 0, h: 0 }); const ref = React.useRef(null); const jitter = React.useRef(null); if (!jitter.current) { jitter.current = Array.from({ length: 8 }, () => (Math.random() - 0.5) * 2.5); } const j = (i) => jitter.current[i]; React.useLayoutEffect(() => { const el = ref.current; if (!el) return; const obs = new ResizeObserver(() => { setDims({ w: el.offsetWidth, h: el.offsetHeight }); }); obs.observe(el); return () => obs.disconnect(); }, []); const { w, h } = dims; const pad = 4; const path = w > 0 && h > 0 ? [ `M ${j(0)} ${j(1)}`, `L ${w + j(2)} ${j(3)}`, `L ${w + j(4)} ${h + j(5)}`, `L ${j(6)} ${h + j(7)}`, `Z` ].join(' ') : ''; return (
{path && ( )}
{label}
{children}
); }; // ── Value component ────────────────────────────────────────────── const Val = ({ children, color, small, className = "" }) => (
{children}
); const TOOLTIPS = { gold: "Your gold. Spent on repairs, crew wages, provisions, and equipment.", day: "Days elapsed since campaign start.", crew: "Crew aboard / maximum. More crew = higher wages and faster combat.", hull: "Hull integrity / maximum. Reaches 0 = defeat.", morale: "Crew morale. Below 50 slows travel. Below 30 increases wages. At 0 crew desert.", fame: "Fame — your permanent reputation. Gates ships, equipment, and missions.", infamy: "Infamy — your criminal notoriety. Reaches 50 = bribe blocked.", hold: "Cargo hold: used / capacity. Over 50% full slows your ship.", food: "Food in hold. Crew consumes food daily at sea. Runs out = morale drops.", water: "Water in hold. Consumed daily at sea alongside food.", heat: "Faction Alert Level. High heat means more patrols. Decays every 2 days.", }; const cells = [ { label: Gold, tip: TOOLTIPS.gold, content: {formatGold(state.gold)} }, { label: Day, tip: TOOLTIPS.day, content: <>{state.day}
{calendarDate}
}, { label: Crew, tip: TOOLTIPS.crew, content: <>{state.crew.roster.length}/{state.crew.max} }, { label: Hull,tip: TOOLTIPS.hull, content: <>{state.ship.hull}/{stats.maxHull}= 0.6 ? T.greenBr :state.ship.hull / stats.maxHull >= 0.3 ? T.gold :T.redBr} h={4} /> }, { label: Morale,tip: TOOLTIPS.morale, content: <>{morale}% }, { label: Fame, tip: TOOLTIPS.fame, content: <>{state.fame}
{L.getFameInfo(state.fame).label}
}, { label: Infamy, tip: TOOLTIPS.infamy, content: <> 0 ? T.redBr : T.textFaint} className={infamyFlash}>{state.infamy ?? 0}
{L.getInfamyLabel(state.infamy ?? 0)}
}, { label: Hold, tip: TOOLTIPS.hold, content: <>{holdUsed}/{holdCap} }, { label: Food, tip: TOOLTIPS.food, content: {food} }, { label: Water,tip: TOOLTIPS.water, content: {water} }, ]; if (topHeat.level > 0) { cells.push({ label: "Heat", tip: TOOLTIPS.heat, content: {FACTIONS[topHeat.faction]?.label?.substring(0,3) || "?"} {topHeat.level} }); } const renderCellRow = (cellSlice, columns) => (
{cellSlice.map((c, i) => {c.content})}
); const wideGridStyle = { display: "grid", gridTemplateColumns: topHeat.level > 0 ? (isDebug ? "1fr 1.1fr .75fr .75fr .7fr .7fr .7fr .75fr .55fr .55fr .6fr auto" : "1fr 1.1fr .75fr .75fr .7fr .7fr .7fr .75fr .55fr .55fr .6fr") : (isDebug ? "1fr 1.1fr .8fr .8fr .75fr .75fr .75fr .8fr .6fr .6fr auto" : "1fr 1.1fr .8fr .8fr .75fr .75fr .75fr .8fr .6fr .6fr"), gap: 4, }; return (
{/* Inner container with background and content */}
{isNarrowHUD ? ( <> {renderCellRow(cells.slice(0, 5), 5)} {renderCellRow(cells.slice(5), cells.length - 5)} {isDebug && (
)} ) : (
{cells.map((c, i) => ( {c.content} ))} {isDebug && (
)}
)}
{currentPort && ( {currentPort.name} )}
{/* ── Bottom double‑stroke border (hand‑drawn style) ──────────── */}
{/* First stroke: horizontal, solid, full opacity */}
{/* Second stroke: sloped and offset, slightly lower opacity */}
); }; // ── APP COMPONENT ────────────────────────────────────────────────── const App = () => { const [state, dispatch] = React.useReducer(window.E.reducer, window.E.initialState); const { T, Btn } = window.UI; const { OnboardingPopup } = window.S; const [savedFlash, setSavedFlash] = React.useState(false); React.useEffect(() => { setSavedFlash(true); const t = setTimeout(() => setSavedFlash(false), 1500); return () => clearTimeout(t); }, [state.currentPort, state.missions.length]); const isDebug = new URLSearchParams(window.location.search).get('debug') === '1'; const [debugOpen, setDebugOpen] = React.useState(false); // ── Hidden port discovery popup ────────────────────────────────── const SEEN_DISCOVERY_KEY = "BroadsideSeenDiscoveries"; const getSeenDiscoveries = () => { try { const raw = localStorage.getItem(SEEN_DISCOVERY_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } }; const markDiscoverySeen = (portName) => { try { const seen = getSeenDiscoveries(); if (!seen.includes(portName)) { seen.push(portName); localStorage.setItem(SEEN_DISCOVERY_KEY, JSON.stringify(seen)); } } catch {} }; const prevDiscoveredRef = React.useRef(state.discoveredPorts || []); const [discoveryPopup, setDiscoveryPopup] = React.useState(null); const seededForSessionRef = React.useRef(false); // ── Seed the seen list once, when the game first reaches a port screen ── React.useEffect(() => { if (!seededForSessionRef.current && state.screen === "port") { const seen = getSeenDiscoveries(); const discoveredHidden = state.discoveredPorts.filter(p => window.D.PORTS[p]?.hidden); let changed = false; for (const portKey of discoveredHidden) { const portName = window.D.PORTS[portKey]?.name || portKey; if (!seen.includes(portName)) { seen.push(portName); changed = true; } } if (changed) { localStorage.setItem(SEEN_DISCOVERY_KEY, JSON.stringify(seen)); } seededForSessionRef.current = true; prevDiscoveredRef.current = state.discoveredPorts || []; } }, [state.screen, state.discoveredPorts]); // ── Detect new discoveries (only runs after seeding) ────────────── React.useEffect(() => { if (!seededForSessionRef.current) return; // wait for seeding const prev = prevDiscoveredRef.current || []; const current = state.discoveredPorts || []; const newPorts = current.filter(p => !prev.includes(p)); if (newPorts.length > 0) { const seen = getSeenDiscoveries(); const hiddenNew = newPorts.filter(p => window.D.PORTS[p]?.hidden && !seen.includes(p)); if (hiddenNew.length > 0) { const portName = window.D.PORTS[hiddenNew[0]]?.name || hiddenNew[0]; setDiscoveryPopup(portName); } } prevDiscoveredRef.current = current; }, [state.discoveredPorts]); if (isDebug) { window.__b = { gold: (n) => dispatch({ type: window.E.A.DEBUG_ADD_GOLD, amount: n }), fame: (n) => dispatch({ type: window.E.A.DEBUG_SET_FAME, fame: n }), infamy: (n) => dispatch({ type: window.E.A.DEBUG_SET_INFAMY, infamy: n }), ship: (t) => dispatch({ type: window.E.A.DEBUG_SET_SHIP, shipType: t }), }; } const renderScreen = () => { const { S } = window; switch (state.screen) { case "title": return ; case "newgame": return ; case "port": return ; case "map": return ; case "sailing": return ; case "shipyard": return ; case "crew": return ; case "status": return ; case "event": return ; case "intercept": return ; case "battle": return ; case "plunder": return ; case "market": return ; case "journal": return ; case "gameover": return ; default: return
Unknown screen: {state.screen}
; } }; return (
{renderScreen()}
{discoveryPopup && (
New Port Discovered!
You can now sail to {discoveryPopup}. Check your map.
{ markDiscoverySeen(discoveryPopup); setDiscoveryPopup(null); }}>Chart it
)} {isDebug && debugOpen && }
); }; // ── DEBUG PANEL ────────────────────────────────────────────────── const DebugPanel = ({ state, dispatch }) => { const { T, panelStyle } = window.UI; const A = window.E.A; const { FACTIONS } = window.D; const btnStyle = { background: T.panel, border: `1px solid ${T.border}`, color: T.textDim, padding: "3px 6px", borderRadius: 2, cursor: "pointer", fontSize: T.captionFontSize, fontFamily: T.fontMono, }; const [combatFaction, setCombatFaction] = React.useState("pirate"); const [combatRisk, setCombatRisk] = React.useState("medium"); return (
⚙ DEBUG PANEL
Gold
{[1000, 10000, 100000, 1000000].map(n => ())}
Fame
{[50, 100, 200, 350].map(n => ())}
Infamy
{[0, 25, 50, 100].map(n => ())}
Ship
{["dinghy","sloop","brigantine","frigate","galleon"].map(t => ())}
Rep (current port)
{[5, 10, 50, 65, 85].map(n => ())}
Heat (per faction)
{["english","spanish","french","dutch"].map(faction => { const fac = FACTIONS[faction]; return (
{fac?.label || faction} {[5, 10].map(n => ())}
); })}
Morale
{[10, 50, 80, 100].map(n => ())}
{/* ── Debug Combat ── */}
Debug Combat
Faction: {Object.entries(FACTIONS).map(([key, fac]) => (
setCombatFaction(key)} style={{ width: 16, height: 16, borderRadius: "50%", background: fac.color, border: combatFaction === key ? `2px solid ${T.gold}` : `1px solid ${T.border}`, cursor: "pointer", opacity: combatFaction === key ? 1 : 0.5, }} title={fac.label} /> ))}
Risk: {["low","medium","high"].map(r => { const color = T.riskColor[r] || T.textDim; return (
setCombatRisk(r)} style={{ padding: "2px 8px", borderRadius: 12, background: combatRisk === r ? color : T.panel, color: combatRisk === r ? "#000" : T.textDim, border: combatRisk === r ? `1px solid ${color}` : `1px solid ${T.border}`, cursor: "pointer", fontSize: T.captionFontSize, }} > {r.charAt(0).toUpperCase() + r.slice(1)}
); })}
); }; const root = ReactDOM.createRoot(document.getElementById("root")); root.render();