import { useState, useEffect, useRef } from 'react' import { ROUTES, WAYPOINTS, OTIUM_DURATION_MS } from '../constants.js' import { applyDispatch, applyDispatchCost, applyOtium, getNewJournalEntry, getSeenJournalEntries, isRouteUnlocked, getChapter, } from '../gameState.js' const CHAPTERS = ['ostia', 'capua', 'brundisium', 'carthago', 'alexandria'] export default function Game({ state, onStateChange, onNewGame }) { const [selectedRoute, setSelectedRoute] = useState(null) const [dispatch, setDispatch] = useState(null) const [otium, setOtium] = useState(null) const [message, setMessage] = useState('') const [journal, setJournal] = useState(getSeenJournalEntries(state)) const [newEntryKey, setNewEntryKey] = useState(null) const tickRef = useRef(null) const msgRef = useRef(null) function showMessage(text, dur = 3500) { setMessage(text) clearTimeout(msgRef.current) msgRef.current = setTimeout(() => setMessage(''), dur) } useEffect(() => { tickRef.current = setInterval(() => { const now = Date.now() if (dispatch) { const elapsed = now - dispatch.startMs if (elapsed >= dispatch.durationMs) { setDispatch(null) const entry = getNewJournalEntry(state, dispatch.routeId) const newState = applyDispatch(state, dispatch.routeId) const route = ROUTES.find((r) => r.id === dispatch.routeId) showMessage(`Galley returned. +${route.profit} denarii.`) if (entry) { setJournal((j) => [entry, ...j]) setNewEntryKey(`${dispatch.routeId}-${newState.route_dispatches[dispatch.routeId]}`) setTimeout(() => setNewEntryKey(null), 5000) } setSelectedRoute(null) onStateChange(newState) } } if (otium) { const elapsed = now - otium.startMs if (elapsed >= OTIUM_DURATION_MS) { setOtium(null) const newState = applyOtium(state) const gain = newState.aut - state.aut showMessage(`Otium complete. +${gain} auctoritas.`) onStateChange(newState) } } }, 250) return () => clearInterval(tickRef.current) }, [dispatch, otium, state, onStateChange]) function handleSelectRoute(routeId) { if (dispatch || otium) return if (!isRouteUnlocked(state, routeId)) return setSelectedRoute((prev) => prev === routeId ? null : routeId) } function handleDispatch() { if (!selectedRoute) { showMessage('Select a trade route first.'); return } if (dispatch) { showMessage('A galley is already at sea.'); return } if (otium) { showMessage('Finish your otium first.'); return } const route = ROUTES.find((r) => r.id === selectedRoute) if (state.den < route.cost) { showMessage('Not enough denarii for this voyage.'); return } const newState = applyDispatchCost(state, selectedRoute) onStateChange(newState) setDispatch({ routeId: selectedRoute, startMs: Date.now(), durationMs: route.duration_ms }) showMessage(`Galley dispatched on ${route.from} → ${route.to}.`) } function handleOtium() { if (dispatch) { showMessage('Your galley is at sea. Wait for it to return.'); return } if (otium) return setOtium({ startMs: Date.now() }) showMessage('You rest. The harbour sounds fade.') } function handleNewGame() { if (!window.confirm('Abandon this ledger and begin a new one?')) return onNewGame() } let progressPct = 0 let progressLabel = '' let progressSub = '' let isOtium = false if (dispatch) { const elapsed = Date.now() - dispatch.startMs progressPct = Math.min((elapsed / dispatch.durationMs) * 100, 100) const route = ROUTES.find((r) => r.id === dispatch.routeId) progressLabel = `${WAYPOINTS[route.from].name} → ${WAYPOINTS[route.to].name}` progressSub = `${Math.round(progressPct)}%` } else if (otium) { const elapsed = Date.now() - otium.startMs progressPct = Math.min((elapsed / OTIUM_DURATION_MS) * 100, 100) progressLabel = 'resting...' progressSub = `${Math.round(progressPct)}%` isOtium = true } const chapter = getChapter(state.den, state.aut) const currentLocation = WAYPOINTS[CHAPTERS[chapter - 1]] const busy = !!dispatch || !!otium return (

OTIVM

mercator romanus · anno DCCXL ab urbe condita

{CHAPTERS.map((id, i) => { const wp = WAYPOINTS[id] const ch = i + 1 const cls = ch < chapter ? 'reached' : ch === chapter ? 'current' : '' return ( {wp.name} {i < CHAPTERS.length - 1 && ( )} ) })}
Denarii
{Math.floor(state.den).toLocaleString()}
in the strongbox
Auctoritas
{Math.floor(state.aut)}
reputation
Dispatches
{state.dispatches}
completed
Location
{currentLocation.name}
chapter {['I','II','III','IV','V'][chapter - 1]}
{(dispatch || otium) && (
{progressLabel} {progressSub}
)}
{message}

Trade routes

{ROUTES.map((route) => { const unlocked = isRouteUnlocked(state, route.id) const selected = selectedRoute === route.id const fromWp = WAYPOINTS[route.from] const toWp = WAYPOINTS[route.to] return (
handleSelectRoute(route.id)} >
{fromWp.name} → {toWp.name}
{route.goods}
{route.desc}
+{route.profit - route.cost} dn {Math.round(route.duration_ms / 1000)}s
{!unlocked && ( {state.den < route.unlock_den ? `${route.unlock_den.toLocaleString()} dn required` : `${route.unlock_aut} auctoritas required`} )}
) })}

Merchant's journal

{journal.length === 0 && (
Day 1 · Ostia
The harbour smells of pitch and ambition. I have fifty denarii and a single battered ledger. The factor tells me the olive route to Capua is open. I shall begin there, as merchants have begun since the first ships crossed the Tyrrhenian.
)} {journal.map((entry, i) => { const key = `${entry.day}-${i}` const isNew = newEntryKey && i === 0 return (
{entry.day}
{entry.text}
) })}
Your save code: {state.token}
) }