Files
otivm/src/screens/Ledger.jsx

269 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="game">
<header className="game-header">
<h1 className="game-title">OTIVM</h1>
<p className="game-subtitle">mercator romanus · anno DCCXL ab urbe condita</p>
</header>
<div className="journey">
{CHAPTERS.map((id, i) => {
const wp = WAYPOINTS[id]
const ch = i + 1
const cls = ch < chapter ? 'reached' : ch === chapter ? 'current' : ''
return (
<span key={id}>
<span className={`journey-node ${cls}`}>{wp.name}</span>
{i < CHAPTERS.length - 1 && (
<span className="journey-arrow"> </span>
)}
</span>
)
})}
</div>
<div className="stats">
<div className="stat">
<div className="stat-label">Denarii</div>
<div className="stat-value">{Math.floor(state.den).toLocaleString()}</div>
<div className="stat-sub">in the strongbox</div>
</div>
<div className="stat">
<div className="stat-label">Auctoritas</div>
<div className="stat-value">{Math.floor(state.aut)}</div>
<div className="stat-sub">reputation</div>
</div>
<div className="stat">
<div className="stat-label">Dispatches</div>
<div className="stat-value">{state.dispatches}</div>
<div className="stat-sub">completed</div>
</div>
<div className="stat">
<div className="stat-label">Location</div>
<div className="stat-value" style={{ fontSize: '0.95rem', paddingTop: '4px' }}>
{currentLocation.name}
</div>
<div className="stat-sub">chapter {['I','II','III','IV','V'][chapter - 1]}</div>
</div>
</div>
{(dispatch || otium) && (
<div className="progress-wrap">
<div className="progress-label">
<span>{progressLabel}</span>
<span>{progressSub}</span>
</div>
<div className="progress-track">
<div
className={`progress-fill${isOtium ? ' otium' : ''}`}
style={{ width: `${progressPct.toFixed(1)}%` }}
/>
</div>
</div>
)}
<div className="message">{message}</div>
<p className="section-title">Trade routes</p>
<div className="route-grid">
{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 (
<div
key={route.id}
className={`route-card${!unlocked ? ' locked' : ''}${selected ? ' selected' : ''}`}
onClick={() => handleSelectRoute(route.id)}
>
<div className="route-name">{fromWp.name} {toWp.name}</div>
<div className="route-goods">{route.goods}</div>
<div className="route-desc">{route.desc}</div>
<div className="route-meta">
<span className="route-profit">+{route.profit - route.cost} dn</span>
<span className="route-time">{Math.round(route.duration_ms / 1000)}s</span>
</div>
{!unlocked && (
<span className="route-lock">
{state.den < route.unlock_den
? `${route.unlock_den.toLocaleString()} dn required`
: `${route.unlock_aut} auctoritas required`}
</span>
)}
</div>
)
})}
</div>
<div className="actions">
<button
className="btn btn-dispatch"
onClick={handleDispatch}
disabled={busy || !selectedRoute}
>
Dispatch galley
</button>
<button
className="btn btn-otium"
onClick={handleOtium}
disabled={busy}
>
Take otium
</button>
</div>
<p className="section-title">Merchant's journal</p>
<div className="journal">
{journal.length === 0 && (
<div className="journal-entry">
<div className="journal-date">Day 1 · Ostia</div>
<div className="journal-text">
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.
</div>
</div>
)}
{journal.map((entry, i) => {
const key = `${entry.day}-${i}`
const isNew = newEntryKey && i === 0
return (
<div key={key} className={`journal-entry${isNew ? ' new' : ''}`}>
<div className="journal-date">{entry.day}</div>
<div className="journal-text">{entry.text}</div>
</div>
)
})}
</div>
<div className="token-bar">
Your save code: <span className="token-code">{state.token}</span>
<button className="btn-new-game" onClick={handleNewGame}>
New game
</button>
</div>
</div>
)
}