iv: retire Prologue.jsx and Ledger.jsx — replaced by Actor.jsx and Forum.jsx
This commit is contained in:
@@ -1,268 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { BACKGROUNDS } from '../constants.js'
|
|
||||||
|
|
||||||
// Prologue screen — shown on the 'Prologue' tab.
|
|
||||||
// If background_id is null: shows background selection UI.
|
|
||||||
// If background_id is set: shows read-only summary of chosen background.
|
|
||||||
// Props:
|
|
||||||
// state — current game state
|
|
||||||
// onSelectBackground(id) — called when player confirms a background
|
|
||||||
|
|
||||||
export default function Prologue({ state, onSelectBackground }) {
|
|
||||||
const [selected, setSelected] = useState(null)
|
|
||||||
const chosen = BACKGROUNDS.find(b => b.id === state.background_id) || null
|
|
||||||
|
|
||||||
// Read-only view — background already chosen
|
|
||||||
if (chosen) {
|
|
||||||
return (
|
|
||||||
<div className="prologue-screen prologue-chosen">
|
|
||||||
<div className="prologue-header">
|
|
||||||
<span className="prologue-label">Your origin</span>
|
|
||||||
<h2 className="prologue-chosen-name">{chosen.name}</h2>
|
|
||||||
<span className="prologue-chosen-latin">{chosen.latin}</span>
|
|
||||||
</div>
|
|
||||||
<p className="prologue-chosen-summary">{chosen.summary}</p>
|
|
||||||
<div className="prologue-chosen-den">
|
|
||||||
Starting funds: <strong>{chosen.starting_den} denarii</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selection view — background_id is null
|
|
||||||
return (
|
|
||||||
<div className="prologue-screen prologue-select">
|
|
||||||
<div className="prologue-header">
|
|
||||||
<span className="prologue-label">Before the first dispatch</span>
|
|
||||||
<h2 className="prologue-title">Who were you?</h2>
|
|
||||||
<p className="prologue-subtitle">
|
|
||||||
Your background shapes your starting parameters. This choice is permanent.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="prologue-grid">
|
|
||||||
{BACKGROUNDS.map(bg => (
|
|
||||||
<button
|
|
||||||
key={bg.id}
|
|
||||||
className={`prologue-card${selected === bg.id ? ' prologue-card--selected' : ''}`}
|
|
||||||
onClick={() => setSelected(bg.id)}
|
|
||||||
>
|
|
||||||
<span className="prologue-card-latin">{bg.latin}</span>
|
|
||||||
<span className="prologue-card-name">{bg.name}</span>
|
|
||||||
<span className="prologue-card-summary">{bg.summary}</span>
|
|
||||||
<span className="prologue-card-den">{bg.starting_den} dn.</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="prologue-confirm-row">
|
|
||||||
<button
|
|
||||||
className="prologue-confirm"
|
|
||||||
disabled={!selected}
|
|
||||||
onClick={() => selected && onSelectBackground(selected)}
|
|
||||||
>
|
|
||||||
{selected
|
|
||||||
? `Begin as ${BACKGROUNDS.find(b => b.id === selected)?.name}`
|
|
||||||
: 'Select a background'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user