diff --git a/src/screens/Actor.jsx b/src/screens/Actor.jsx new file mode 100644 index 0000000..137eb59 --- /dev/null +++ b/src/screens/Actor.jsx @@ -0,0 +1,219 @@ +// Actor.jsx — OTIVM-IV +// ACTOR context screen. Thin — delegates all rendering to Section.jsx. +// Two modes: +// 1. background_id = null → background selection UI +// 2. background_id set → parameter instrument view +// +// Props: +// state — current game state from server +// onSelectBackground — fn(id) called when player confirms a background +// col — passed through to Section for layout grid placement + +import { useState } from 'react' +import Section from '../components/Section.jsx' +import { BACKGROUNDS } from '../constants.js' + +export default function Actor({ state, onSelectBackground }) { + // Background selection mode + if (!state?.background_id || state.background_id === 'unknown') { + return + } + + // Instrument view — build section data from state + const bg = BACKGROUNDS.find(b => b.id === state.background_id) + const den = state.den ?? 0 + const aut = state.aut ?? 0 + const autBand = autToBand(aut) + + // LEFT column — identity + liquiditas + const identityItems = [ + { key: 'Background', value: bg?.name || state.background_id, cls: '' }, + { key: 'Latin', value: bg?.latin || '—', cls: '' }, + { key: 'Chapter', value: `${toRoman(state.chapter || 1)} · ${chapterCity(state.chapter)}`, cls: '' }, + { key: 'Dispatches', value: `${state.dispatches || 0} complete`, cls: state.dispatches > 0 ? 'good' : '' }, + ] + + const nextOtiumCost = 8 // OTIUM_CYCLE_TOTAL_DN — from cost-calibration-model.md + const liquiditasItems = [ + { key: 'Available', value: `${den} dn`, cls: den > 50 ? 'good' : 'warn' }, + { key: 'Committed', value: state.active_dispatch ? `${routeCost(state.active_dispatch.route_id)} dn` : '0 dn', cls: '' }, + { key: 'Next otium cost', value: `−${nextOtiumCost} dn`, cls: 'bad' }, + ] + + // CENTER column — auctoritas + parameters + const auctoritas = { + faces: [ + { label: 'Value True', value: autBand, discrepant: false }, + { label: 'Perceived', value: autBand, discrepant: false }, + { label: 'Social', value: autBand, discrepant: false }, + ], + gap_note: '', + } + + const parameters = buildParameterItems(state) + + // RIGHT column — obligations + drift log + const obligationItems = [ + { label: 'OTIVM access', amount: '2.00 dn', period: 'per otium cycle', source: 'cost-calibration-model.md', conf: 'LOW', debit: true }, + { label: 'Personal maintenance', amount: '4.00 dn', period: 'per otium cycle', source: 'cost-calibration-model.md', conf: 'MEDIUM', debit: true }, + { label: 'Officia obligations', amount: '2.00 dn', period: 'per otium cycle', source: 'cost-calibration-model.md', conf: 'LOW', debit: true }, + ] + + const driftItems = buildDriftItems(state) + + return ( + <> + {/* LEFT */} +
+
+ + {/* CENTER */} +
+
+ + {/* RIGHT */} +
+
+ + ) +} + +// ── Background selection UI ─────────────────────────────────────────────── + +function BackgroundSelection({ onSelectBackground }) { + const [selected, setSelected] = useState(null) + + return ( +
+
+
Before the first dispatch
+

+ Who were you? +

+

+ Your background shapes your starting parameters. This choice is permanent. +

+
+ +
+ {BACKGROUNDS.map(bg => ( + + ))} +
+ +
+ +
+
+ ) +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function autToBand(aut) { + if (aut >= 30) return 'Distinguished' + if (aut >= 15) return 'High' + if (aut >= 5) return 'Medium' + return 'Low' +} + +function toRoman(n) { + return ['I','II','III','IV','V'][Math.max(0, Math.min(4, (n || 1) - 1))] +} + +function chapterCity(chapter) { + return ['Ostia','Capua','Brundisium','Carthago','Alexandria'][(chapter || 1) - 1] || 'Ostia' +} + +// Approximate route cost for committed display +function routeCost(routeId) { + const costs = { olive: 8, wine: 14, grain: 24, linen: 38 } + return costs[routeId] || 0 +} + +// Build parameter list from game state. +// In OTIVM-III+ state the server returns a schema_version and parameters are +// seeded from background_starting_values. Until full parameter API is exposed +// we derive display values from the background seed and current den/aut. +function buildParameterItems(state) { + const bg = BACKGROUNDS.find(b => b.id === state.background_id) + if (!bg) return [] + + // Background-derived parameter display — from BACKGROUNDS constant summaries. + // When the server exposes actor_parameters directly these will be replaced + // by real value_perceived and confidence_tag from the database. + const autBand = autToBand(state.aut || 0) + + return [ + { token: 'auctoritas', name: 'Auctoritas', true_val: autBand, perceived: autBand, conf: 'indicated' }, + { token: 'liquiditas', name: 'Capital', true_val: `${state.den} dn`, perceived: `${state.den} dn`, conf: 'measured' }, + { token: 'disciplina', name: 'Discipline', true_val: bgParam(bg.id, 'disciplina'), perceived: bgParam(bg.id, 'disciplina'), conf: 'indicated' }, + { token: 'itineris_scientia', name: 'Route Knowledge', true_val: bgParam(bg.id, 'itineris'), perceived: bgParam(bg.id, 'itineris'), conf: 'indicated' }, + { token: 'mercatus_scientia', name: 'Market Knowledge', true_val: bgParam(bg.id, 'mercatus'), perceived: bgParam(bg.id, 'mercatus'), conf: 'indicated' }, + { token: 'negotiatio', name: 'Negotiation', true_val: bgParam(bg.id, 'negotiatio'), perceived: bgParam(bg.id, 'negotiatio'), conf: 'indicated' }, + { token: 'clientela', name: 'Network', true_val: bgParam(bg.id, 'clientela'), perceived: bgParam(bg.id, 'clientela'), conf: 'indicated' }, + { token: 'periculum_tolerantia', name: 'Risk Tolerance', true_val: bgParam(bg.id, 'periculum'), perceived: bgParam(bg.id, 'periculum'), conf: 'indicated' }, + { token: 'ius_accessus', name: 'Legal Standing', true_val: bgParam(bg.id, 'ius'), perceived: bgParam(bg.id, 'ius'), conf: 'indicated' }, + { token: 'officia_burden', name: 'Social Obligations', true_val: bgParam(bg.id, 'officia'), perceived: bgParam(bg.id, 'officia'), conf: 'estimated' }, + ] +} + +// Background starting parameter values — derived from background_starting_values seed data. +// Keyed by background_id and a short token alias. +// When the server exposes actor_parameters directly, this lookup is replaced. +const BG_PARAMS = { + former_legionary: { disciplina: 'High', itineris: 'High', mercatus: 'Low', negotiatio: 'Low', clientela: 'Low', periculum: 'High', ius: 'Medium', officia: 'Low' }, + freedman_trader: { disciplina: 'Medium', itineris: 'Medium', mercatus: 'High', negotiatio: 'High', clientela: 'Medium', periculum: 'Medium', ius: 'Low', officia: 'Low' }, + noble_younger_son: { disciplina: 'Low', itineris: 'Low', mercatus: 'Low', negotiatio: 'Medium', clientela: 'High', periculum: 'Low', ius: 'High', officia: 'High' }, + failed_magistrate: { disciplina: 'Medium', itineris: 'Low', mercatus: 'Low', negotiatio: 'Medium', clientela: 'Medium', periculum: 'Low', ius: 'High', officia: 'High' }, + camp_logistician: { disciplina: 'High', itineris: 'High', mercatus: 'High', negotiatio: 'Medium', clientela: 'Low', periculum: 'Medium', ius: 'Medium', officia: 'Low' }, + guild_scribe: { disciplina: 'Medium', itineris: 'Low', mercatus: 'Medium', negotiatio: 'Low', clientela: 'Low', periculum: 'Low', ius: 'Medium', officia: 'Low' }, +} + +function bgParam(bgId, alias) { + return BG_PARAMS[bgId]?.[alias] || 'Low' +} + +// Build drift log display items from state.events +function buildDriftItems(state) { + const events = state.events || [] + const recent = events + .filter(e => ['dispatch_complete', 'otium', 'venture_complete'].includes(e.type)) + .slice(-4) + .reverse() + + return recent.map(e => { + if (e.type === 'dispatch_complete') { + return { param: 'Liquiditas', delta: `+${routeNet(e.route_id)} dn`, trigger: 'venture_complete', note: e.route_id, positive: true } + } + if (e.type === 'otium') { + return { param: 'Auctoritas', delta: '+1', trigger: 'interval_complete', note: 'otium rest', positive: true } + } + return { param: 'Event', delta: '—', trigger: e.type, note: null, positive: true } + }) +} + +function routeNet(routeId) { + const nets = { olive: 4, wine: 8, grain: 16, linen: 32 } + return nets[routeId] || '?' +} diff --git a/src/screens/Forum.jsx b/src/screens/Forum.jsx new file mode 100644 index 0000000..39fff1b --- /dev/null +++ b/src/screens/Forum.jsx @@ -0,0 +1,245 @@ +// Forum.jsx — OTIVM-IV +// FORUM context screen. Replaces Ledger.jsx. +// Thin wrapper — all game logic migrated from Ledger.jsx. +// Rendering delegated to Section.jsx components. +// +// Props: +// state — current game state +// onStateChange — fn(newState) saves state to server +// onNewGame — fn() called when new game is requested + +import { useState, useEffect, useRef } from 'react' +import Section from '../components/Section.jsx' +import { ROUTES, WAYPOINTS, OTIUM_DURATION_MS } from '../constants.js' +import { + applyDispatch, + applyDispatchCost, + applyOtium, + getNewJournalEntry, + getSeenJournalEntries, + isRouteUnlocked, + getChapter, +} from '../gameState.js' + +// Periodic expenditure constants — from docs/economy/cost-calibration-model.md +const OTIUM_ACCESS_FEE_DN = 2.00 +const PERSONAL_MAINTENANCE_DN = 4.00 +const OFFICIA_OBLIGATION_DN = 2.00 +const OTIUM_CYCLE_TOTAL_DN = 8.00 + +export default function Forum({ 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) + } + + // Tick — dispatch and otium completion + 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) + showMessage('Otium complete. Auctoritas recorded.') + onStateChange(newState) + } + } + }, 250) + + return () => clearInterval(tickRef.current) + }, [dispatch, otium, state, onStateChange]) + + const busy = !!dispatch || !!otium + + function handleSelectRoute(routeId) { + if (busy) 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 ${WAYPOINTS[route.from].name} → ${WAYPOINTS[route.to].name}.`) + } + + 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() + } + + // Progress values for active dispatch or otium + let progressPct = 0 + let progressLabel = '' + let progressSub = '' + + 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)}%` + } + + // ── Section data ───────────────────────────────────────────────────────── + + // Active venture panel + const activeVentureItems = dispatch + ? (() => { + const route = ROUTES.find(r => r.id === dispatch.routeId) + return [ + { key: 'Route', value: `${WAYPOINTS[route.from].name} → ${WAYPOINTS[route.to].name}`, cls: '' }, + { key: 'Cargo', value: route.goods, cls: '' }, + { key: 'Progress', value: `${Math.round(progressPct)}%`, cls: 'good' }, + { key: 'Net', value: `+${route.profit - route.cost} dn expected`, cls: '' }, + ] + })() + : otium + ? [ + { key: 'Status', value: 'Otium in progress', cls: 'warn' }, + { key: 'Progress', value: `${Math.round(progressPct)}%`, cls: '' }, + { key: 'Cost', value: `−${OTIUM_CYCLE_TOTAL_DN} dn`, cls: 'bad' }, + ] + : [{ key: 'Status', value: 'No galley at sea', cls: '' }] + + // Route list for Section + const routeItems = ROUTES.map(route => { + const unlocked = isRouteUnlocked(state, route) + const vectura = Math.round(route.cost * 0.60 * 10) / 10 + const portoria = Math.round(route.cost * 0.25 * 10) / 10 + const other = Math.round((route.cost - vectura - portoria) * 10) / 10 + return { + id: route.id, + name: `${WAYPOINTS[route.from].name} → ${WAYPOINTS[route.to].name}`, + goods: route.goods, + mode: route.id === 'grain' || route.id === 'linen' ? 'sea' : 'road', + days: Math.round(route.duration_ms / 3000), + cost: route.cost, + profit: route.profit, + vectura, + portoria, + other, + locked: !unlocked, + lock_reason: !unlocked + ? (state.den < route.unlock_den + ? `${route.unlock_den.toLocaleString()} dn` + : `Auctoritas ${route.unlock_aut}`) + : null, + } + }) + + // Standing block + const chapter = getChapter(state.den, state.aut) + const standingItems = [ + { key: 'Denarii', value: `${Math.floor(state.den)} dn`, cls: state.den > 100 ? 'good' : 'warn' }, + { key: 'Auctoritas', value: `${Math.floor(state.aut)}`, cls: '' }, + { key: 'Chapter', value: `${['I','II','III','IV','V'][chapter - 1]} · ${['Ostia','Capua','Brundisium','Carthago','Alexandria'][chapter - 1]}`, cls: '' }, + { key: 'Dispatches', value: `${state.dispatches || 0} complete`, cls: '' }, + ] + + // Expenditures (right column) + const expenditureItems = [ + { label: 'OTIVM access', amount: `${OTIUM_ACCESS_FEE_DN} dn`, period: 'per otium cycle', source: 'cost-calibration-model.md', conf: 'LOW', debit: true }, + { label: 'Personal maintenance', amount: `${PERSONAL_MAINTENANCE_DN} dn`, period: 'per otium cycle', source: 'cost-calibration-model.md', conf: 'MEDIUM', debit: true }, + { label: 'Officia obligations', amount: `${OFFICIA_OBLIGATION_DN} dn`, period: 'per otium cycle', source: 'cost-calibration-model.md', conf: 'LOW', debit: true }, + ] + + // Journal entries + const journalEntries = journal.length > 0 ? journal : [{ + day: 'Day 1 · Ostia', + 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.', + }] + + // Action buttons + const actionButtons = [ + { label: 'Dispatch galley', style: 'otivm-btn-dispatch', action: 'dispatch', disabled: busy || !selectedRoute }, + { label: 'Take otium', style: 'otivm-btn-otium', action: 'otium', disabled: busy }, + ] + + function handleAction(action) { + if (action === 'dispatch') handleDispatch() + if (action === 'otium') handleOtium() + } + + return ( + <> + {/* Notification message */} + {message && ( +
+ {message} +
+ )} + + {/* LEFT column */} +
+
+
({ date: e.day, text: e.text }))} + collapsible={true} + /> + + {/* RIGHT column */} +
+
+
+ + ) +}