Before the first dispatch
-
+
Who were you?
-
+
Your background shapes your starting parameters. This choice is permanent.
-
{BACKGROUNDS.map(bg => (
-
-
-
-
selected && onSelectBackground(selected)}
- >
- {selected
- ? `Begin as ${BACKGROUNDS.find(b => b.id === selected)?.name}`
- : 'Select a background'}
+
+ selected && onSelectBackground(selected)}>
+ {selected ? `Begin as ${BACKGROUNDS.find(b => b.id === selected)?.name}` : 'Select a background'}
)
}
-// ── Helpers ───────────────────────────────────────────────────────────────
-
function autToBand(aut) {
if (aut >= 30) return 'Distinguished'
if (aut >= 15) return 'High'
@@ -137,83 +112,45 @@ function autToBand(aut) {
return 'Low'
}
-function toRoman(n) {
- return ['I','II','III','IV','V'][Math.max(0, Math.min(4, (n || 1) - 1))]
-}
+function toRoman(n) { return ['I','II','III','IV','V'][Math.max(0,Math.min(4,(n||1)-1))] }
+function chapterCity(c) { return ['Ostia','Capua','Brundisium','Carthago','Alexandria'][(c||1)-1]||'Ostia' }
+function routeCost(id) { return {olive:8,wine:14,grain:24,linen:38}[id]||0 }
+function routeNet(id) { return {olive:4,wine:8,grain:16,linen:32}[id]||'?' }
-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)
-
+ const id = state.background_id
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' },
+ { token:'auctoritas', name:'Auctoritas', true_val:autToBand(state.aut||0), perceived:autToBand(state.aut||0), conf:'indicated' },
+ { token:'liquiditas', name:'Capital', true_val:`${state.den} dn`, perceived:`${state.den} dn`, conf:'measured' },
+ { token:'disciplina', name:'Discipline', true_val:bgParam(id,'disciplina'), perceived:bgParam(id,'disciplina'), conf:'indicated' },
+ { token:'itineris_scientia', name:'Route Knowledge', true_val:bgParam(id,'itineris'), perceived:bgParam(id,'itineris'), conf:'indicated' },
+ { token:'mercatus_scientia', name:'Market Knowledge', true_val:bgParam(id,'mercatus'), perceived:bgParam(id,'mercatus'), conf:'indicated' },
+ { token:'negotiatio', name:'Negotiation', true_val:bgParam(id,'negotiatio'), perceived:bgParam(id,'negotiatio'), conf:'indicated' },
+ { token:'clientela', name:'Network', true_val:bgParam(id,'clientela'), perceived:bgParam(id,'clientela'), conf:'indicated' },
+ { token:'periculum_tolerantia', name:'Risk Tolerance', true_val:bgParam(id,'periculum'), perceived:bgParam(id,'periculum'), conf:'indicated' },
+ { token:'ius_accessus', name:'Legal Standing', true_val:bgParam(id,'ius'), perceived:bgParam(id,'ius'), conf:'indicated' },
+ { token:'officia_burden', name:'Social Obligations', true_val:bgParam(id,'officia'), perceived:bgParam(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' },
+ 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'
-}
+function bgParam(id, alias) { return BG_PARAMS[id]?.[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] || '?'
+ return (state.events||[])
+ .filter(e=>['dispatch_complete','otium','venture_complete'].includes(e.type))
+ .slice(-4).reverse()
+ .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}
+ })
}
diff --git a/src/screens/Forum.jsx b/src/screens/Forum.jsx
index 39fff1b..5b60115 100644
--- a/src/screens/Forum.jsx
+++ b/src/screens/Forum.jsx
@@ -1,12 +1,7 @@
// 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
+// Renders two divs directly into Shell's two-col layout grid.
+// All game logic migrated from Ledger.jsx.
import { useState, useEffect, useRef } from 'react'
import Section from '../components/Section.jsx'
@@ -21,11 +16,10 @@ import {
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
+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)
@@ -43,11 +37,9 @@ export default function Forum({ state, onStateChange, onNewGame }) {
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) {
@@ -65,7 +57,6 @@ export default function Forum({ state, onStateChange, onNewGame }) {
onStateChange(newState)
}
}
-
if (otium) {
const elapsed = now - otium.startMs
if (elapsed >= OTIUM_DURATION_MS) {
@@ -76,7 +67,6 @@ export default function Forum({ state, onStateChange, onNewGame }) {
}
}
}, 250)
-
return () => clearInterval(tickRef.current)
}, [dispatch, otium, state, onStateChange])
@@ -107,139 +97,109 @@ export default function Forum({ state, onStateChange, onNewGame }) {
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()
}
+ // Progress
+ let progressPct = 0, progressLabel = '', progressSub = ''
+ if (dispatch) {
+ progressPct = Math.min(((Date.now()-dispatch.startMs)/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) {
+ progressPct = Math.min(((Date.now()-otium.startMs)/OTIUM_DURATION_MS)*100,100)
+ progressLabel = 'resting...'
+ progressSub = `${Math.round(progressPct)}%`
+ }
+
+ // Section data
+ 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:''}]
+
+ 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
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:''},
+ ]
+
+ 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},
+ ]
+
+ 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.',
+ }]).map(e=>({date:e.day, text:e.text}))
+
+ 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},
+ ]
+
return (
<>
- {/* Notification message */}
{message && (
{message}
)}
- {/* LEFT column */}
-
-
- ({ date: e.day, text: e.text }))}
- collapsible={true}
- />
+ {/* LEFT */}
+
+
+
+
+
- {/* RIGHT column */}
-
-
-
+ {/* RIGHT */}
+
+
+
+
+
>
)
}