iv: add Actor.jsx and Forum.jsx screen components
This commit is contained in:
219
src/screens/Actor.jsx
Normal file
219
src/screens/Actor.jsx
Normal file
@@ -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 <BackgroundSelection onSelectBackground={onSelectBackground} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 */}
|
||||||
|
<Section col="left" title="Identity" type="status-block" items={identityItems} />
|
||||||
|
<Section col="left" title="Liquiditas" type="status-block" items={liquiditasItems} />
|
||||||
|
|
||||||
|
{/* CENTER */}
|
||||||
|
<Section col="center" title="Auctoritas — three faces" type="auctoritas"
|
||||||
|
faces={auctoritas.faces} gap_note={auctoritas.gap_note} />
|
||||||
|
<Section col="center" title="Parameters" type="parameter-list" items={parameters} />
|
||||||
|
|
||||||
|
{/* RIGHT */}
|
||||||
|
<Section col="right" title="Periodic obligations" type="cost-table"
|
||||||
|
note="Debited per otium cycle. Source: docs/economy/cost-calibration-model.md"
|
||||||
|
items={obligationItems} />
|
||||||
|
<Section col="right" title="Drift log · recent" type="drift-log" items={driftItems} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Background selection UI ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BackgroundSelection({ onSelectBackground }) {
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '640px', margin: '0 auto', padding: '28px 0' }}>
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<div className="otivm-nav-label">Before the first dispatch</div>
|
||||||
|
<h2 style={{ fontFamily: 'IM Fell English, serif', fontWeight: 'normal', fontSize: '1.5rem', margin: '4px 0 6px' }}>
|
||||||
|
Who were you?
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '0.82rem', fontStyle: 'italic', color: 'var(--otivm-ink-faint)' }}>
|
||||||
|
Your background shapes your starting parameters. This choice is permanent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="otivm-bg-grid">
|
||||||
|
{BACKGROUNDS.map(bg => (
|
||||||
|
<button
|
||||||
|
key={bg.id}
|
||||||
|
className={`otivm-bg-card${selected === bg.id ? ' selected' : ''}`}
|
||||||
|
onClick={() => setSelected(bg.id)}
|
||||||
|
>
|
||||||
|
<span className="otivm-bg-card-latin">{bg.latin}</span>
|
||||||
|
<span className="otivm-bg-card-name">{bg.name}</span>
|
||||||
|
<span className="otivm-bg-card-summary">{bg.summary}</span>
|
||||||
|
<span className="otivm-bg-card-den">{bg.starting_den} dn starting</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
className="btn otivm-btn-dispatch"
|
||||||
|
disabled={!selected}
|
||||||
|
onClick={() => selected && onSelectBackground(selected)}
|
||||||
|
>
|
||||||
|
{selected
|
||||||
|
? `Begin as ${BACKGROUNDS.find(b => b.id === selected)?.name}`
|
||||||
|
: 'Select a background'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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] || '?'
|
||||||
|
}
|
||||||
245
src/screens/Forum.jsx
Normal file
245
src/screens/Forum.jsx
Normal file
@@ -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 && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', bottom: '20px', right: '20px',
|
||||||
|
background: 'var(--otivm-ink)', color: 'var(--otivm-parch)',
|
||||||
|
padding: '10px 18px', borderRadius: '3px',
|
||||||
|
fontSize: '0.8rem', fontStyle: 'italic', zIndex: 300,
|
||||||
|
}}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LEFT column */}
|
||||||
|
<Section col="left" title="Active venture" type="status-block" items={activeVentureItems} />
|
||||||
|
<Section col="left" title="Actions" type="action-bar" buttons={actionButtons} onAction={handleAction} />
|
||||||
|
<Section col="left" title="Journal" type="text-block"
|
||||||
|
entries={journalEntries.map(e => ({ date: e.day, text: e.text }))}
|
||||||
|
collapsible={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* RIGHT column */}
|
||||||
|
<Section col="right" title="Trade routes" type="route-list"
|
||||||
|
routes={routeItems}
|
||||||
|
selectedRoute={selectedRoute}
|
||||||
|
onSelect={handleSelectRoute}
|
||||||
|
/>
|
||||||
|
<Section col="right" title="Standing" type="status-block" items={standingItems} />
|
||||||
|
<Section col="right" title="Periodic expenditures" type="cost-table" items={expenditureItems} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user