diff --git a/src/App.jsx b/src/App.jsx index 169eb51..d18049e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -49,11 +49,7 @@ export default function App() { async function onSelectBackground(backgroundId) { const bg = BACKGROUNDS.find(b => b.id === backgroundId) if (!bg) return - const updated = { - ...state, - background_id: backgroundId, - den: bg.starting_den, - } + const updated = { ...state, background_id: backgroundId, den: bg.starting_den } setState(updated) await saveState(token, updated) setContext('forum') @@ -83,40 +79,9 @@ export default function App() { return
Consulting the ledger...
} - // Active context config for Shell - const activeCtx = contextsJson.find(c => c.id === context) || contextsJson[0] - const subitems = activeCtx.subitems || [] - - // Context header text - const ctxHeader = ( -
-
- Context - {activeCtx.name} — {activeCtx.subtitle} -
-
- ) - - // Screen content - let screen - if (context === 'actor') { - screen = ( - - ) - } else if (context === 'forum') { - screen = ( - - ) - } else if (context === 'map') { - screen = - } + const activeCtx = contextsJson.find(c => c.id === context) || contextsJson[0] + const layout = activeCtx.layout + const subitems = activeCtx.subitems || [] return ( - {ctxHeader} -
- {context === 'actor' && ( -
- {activeCtx.layout === 'three-col' ? ( - <> -
{filterCol(screen, 'left')}
-
{filterCol(screen, 'center')}
-
{filterCol(screen, 'right')}
- - ) : screen} -
- )} - {context === 'forum' && ( -
- {activeCtx.layout === 'two-col' ? ( - <> -
{filterCol(screen, 'left')}
-
{filterCol(screen, 'right')}
- - ) : screen} -
- )} - {context === 'map' && screen} -
+ {/* ACTOR */} + {context === 'actor' && ( + + )} + + {/* FORUM */} + {context === 'forum' && ( + + )} + + {/* MAP */} + {context === 'map' && ( + + )}
) } - -// Extract children from a screen component by their col prop. -// Actor and Forum return arrays of Section elements with col props. -function filterCol(screen, col) { - if (!screen) return null - const children = screen.props?.children - if (!children) return null - const arr = Array.isArray(children) ? children : [children] - return arr.filter(c => c?.props?.col === col) -} diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx index 9a8e9d6..9522729 100644 --- a/src/components/Shell.jsx +++ b/src/components/Shell.jsx @@ -1,18 +1,7 @@ // Shell.jsx — OTIVM-IV -// Persistent sidebar with context dropdown and layout grid renderer. +// Persistent sidebar with context dropdown. +// Wraps screen content in the correct layout grid div. // Built once. Never changes when new contexts are added. -// Reads src/config/contexts.json for the dropdown. -// Loads context-{id}.json and renders the appropriate screen component. -// -// Props: -// contexts — array from contexts.json -// activeId — current context id -// onContext — fn(id) called when dropdown changes -// subitems — array of subitem labels for active context -// layout — layout string from active context JSON -// token — player save token -// onNewGame — fn() called when new game is requested -// children — the screen component(s) to render in the layout grid import { useState } from 'react' @@ -24,6 +13,8 @@ export default function Shell({ layout = 'single', token, onNewGame, + ctxName, + ctxSubtitle, children, }) { const [activeSubitem, setActiveSubitem] = useState(0) @@ -33,10 +24,15 @@ export default function Shell({ setActiveSubitem(0) } + const gridClass = { + 'two-col': 'otivm-layout-two-col', + 'three-col': 'otivm-layout-three-col', + 'map': 'otivm-layout-map', + }[layout] || '' + return (
- {/* ── Sidebar ── */} - {/* ── Main area ── */}
- - {children} - +
+
+ Context + {ctxName}{ctxSubtitle ? ` — ${ctxSubtitle}` : ''} +
+
+
+ {gridClass + ?
{children}
+ : children + } +
) } - -// LayoutGrid — applies the correct CSS grid based on context JSON "layout" value. -// Children are React elements. Each child declares its column via a "col" prop. -// Shell.jsx reads the col prop and places the child in the correct grid column. -// -// Layout types: -// single — one full-width column (col prop ignored) -// two-col — left (2fr) + right (3fr) -// three-col — left (1fr) + center (2fr) + right (1fr) -// map — sidebar (280px) + canvas (1fr) - -function LayoutGrid({ layout, children }) { - const childArray = Array.isArray(children) ? children : [children] - - if (layout === 'single') { - return ( -
- {childArray} -
- ) - } - - if (layout === 'two-col') { - const left = childArray.filter(c => c?.props?.col === 'left') - const right = childArray.filter(c => c?.props?.col === 'right') - return ( -
-
-
{left}
-
{right}
-
-
- ) - } - - if (layout === 'three-col') { - const left = childArray.filter(c => c?.props?.col === 'left') - const center = childArray.filter(c => c?.props?.col === 'center') - const right = childArray.filter(c => c?.props?.col === 'right') - return ( -
-
-
{left}
-
{center}
-
{right}
-
-
- ) - } - - if (layout === 'map') { - const sidebar = childArray.filter(c => c?.props?.col === 'sidebar') - const canvas = childArray.filter(c => c?.props?.col === 'canvas') - return ( -
-
-
{sidebar}
-
{canvas}
-
-
- ) - } - - // Fallback — render all children in a single column - return ( -
- {childArray} -
- ) -} diff --git a/src/screens/Actor.jsx b/src/screens/Actor.jsx index 137eb59..3828675 100644 --- a/src/screens/Actor.jsx +++ b/src/screens/Actor.jsx @@ -1,31 +1,27 @@ // 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 +// ACTOR context screen. +// Renders directly into Shell's layout grid (three-col). +// Each top-level div is one grid column — left, center, right. +// Background selection mode renders a single full-width panel. 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 + return ( +
+ +
+ ) } - // Instrument view — build section data from state - const bg = BACKGROUNDS.find(b => b.id === state.background_id) + 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: '' }, @@ -33,28 +29,22 @@ export default function Actor({ state, onSelectBackground }) { { 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: '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' }, + { key: 'Next otium cost', value: '−8 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 auctoritasFaces = [ + { label: 'Value True', value: autBand, discrepant: false }, + { label: 'Perceived', value: autBand, discrepant: false }, + { label: 'Social', value: autBand, discrepant: false }, + ] - const parameters = buildParameterItems(state) + const parameterItems = 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: '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 }, ] @@ -63,48 +53,42 @@ export default function Actor({ state, onSelectBackground }) { 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' @@ -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.den100?'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 */} +
+
+
+
+
) }