// 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) => g >= 1000000 ? (g / 1000000).toFixed(3) + "M g" : g.toLocaleString() + "g"; const goldFlash = useFlashOnChange(state.gold, { direction: null }); // auto detect 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 }); // ── Responsive HUD breakpoint ────────────────────────────── 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); }, []); const Cell = ({ label, tip, children }) => (
{label}
{children}
); 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 (
{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} )}
); }; // ── APP COMPONENT ────────────────────────────────────────────────── const App = () => { const [state, dispatch] = React.useReducer(window.E.reducer, window.E.initialState); const { T } = window.UI; const { screen } = state; 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); 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 ; default: return
Unknown screen: {state.screen}
; } }; return (
{renderScreen()}
{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, }; 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 => ())}
); }; const root = ReactDOM.createRoot(document.getElementById("root")); root.render();