iv: fix layout grid rendering — screens render column divs directly
This commit is contained in:
105
src/App.jsx
105
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 <div className="otivm-loading"><span>Consulting the ledger...</span></div>
|
||||
}
|
||||
|
||||
// Active context config for Shell
|
||||
const activeCtx = contextsJson.find(c => c.id === context) || contextsJson[0]
|
||||
const subitems = activeCtx.subitems || []
|
||||
|
||||
// Context header text
|
||||
const ctxHeader = (
|
||||
<div className="otivm-ctx-header">
|
||||
<div>
|
||||
<span className="otivm-ctx-eyebrow">Context</span>
|
||||
<span className="otivm-ctx-name">{activeCtx.name} — {activeCtx.subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Screen content
|
||||
let screen
|
||||
if (context === 'actor') {
|
||||
screen = (
|
||||
<Actor
|
||||
state={state}
|
||||
onSelectBackground={onSelectBackground}
|
||||
/>
|
||||
)
|
||||
} else if (context === 'forum') {
|
||||
screen = (
|
||||
<Forum
|
||||
state={state}
|
||||
onStateChange={onStateChange}
|
||||
onNewGame={onNewGame}
|
||||
/>
|
||||
)
|
||||
} else if (context === 'map') {
|
||||
screen = <Map state={state} />
|
||||
}
|
||||
const activeCtx = contextsJson.find(c => c.id === context) || contextsJson[0]
|
||||
const layout = activeCtx.layout
|
||||
const subitems = activeCtx.subitems || []
|
||||
|
||||
return (
|
||||
<Shell
|
||||
@@ -124,45 +89,35 @@ export default function App() {
|
||||
activeId={context}
|
||||
onContext={setContext}
|
||||
subitems={subitems}
|
||||
layout={activeCtx.layout}
|
||||
layout={layout}
|
||||
token={token}
|
||||
onNewGame={onNewGame}
|
||||
ctxName={activeCtx.name}
|
||||
ctxSubtitle={activeCtx.subtitle}
|
||||
>
|
||||
{ctxHeader}
|
||||
<div className="otivm-layout-wrap">
|
||||
{context === 'actor' && (
|
||||
<div className={activeCtx.layout === 'three-col' ? 'otivm-layout-three-col' : ''}>
|
||||
{activeCtx.layout === 'three-col' ? (
|
||||
<>
|
||||
<div>{filterCol(screen, 'left')}</div>
|
||||
<div>{filterCol(screen, 'center')}</div>
|
||||
<div>{filterCol(screen, 'right')}</div>
|
||||
</>
|
||||
) : screen}
|
||||
</div>
|
||||
)}
|
||||
{context === 'forum' && (
|
||||
<div className={activeCtx.layout === 'two-col' ? 'otivm-layout-two-col' : ''}>
|
||||
{activeCtx.layout === 'two-col' ? (
|
||||
<>
|
||||
<div>{filterCol(screen, 'left')}</div>
|
||||
<div>{filterCol(screen, 'right')}</div>
|
||||
</>
|
||||
) : screen}
|
||||
</div>
|
||||
)}
|
||||
{context === 'map' && screen}
|
||||
</div>
|
||||
{/* ACTOR */}
|
||||
{context === 'actor' && (
|
||||
<Actor
|
||||
state={state}
|
||||
onSelectBackground={onSelectBackground}
|
||||
layout={layout}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FORUM */}
|
||||
{context === 'forum' && (
|
||||
<Forum
|
||||
state={state}
|
||||
onStateChange={onStateChange}
|
||||
onNewGame={onNewGame}
|
||||
layout={layout}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MAP */}
|
||||
{context === 'map' && (
|
||||
<Map state={state} layout={layout} />
|
||||
)}
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex' }}>
|
||||
|
||||
{/* ── Sidebar ── */}
|
||||
<aside className="otivm-sidebar">
|
||||
<div className="otivm-sidebar-brand">
|
||||
<div className="otivm-sidebar-title">OTIVM</div>
|
||||
@@ -45,21 +41,13 @@ export default function Shell({
|
||||
|
||||
<div className="otivm-sidebar-nav">
|
||||
<span className="otivm-nav-label">Context</span>
|
||||
|
||||
{/* Context dropdown — reads contexts.json */}
|
||||
<select
|
||||
className="otivm-context-select"
|
||||
value={activeId}
|
||||
onChange={handleContextChange}
|
||||
>
|
||||
<select className="otivm-context-select" value={activeId} onChange={handleContextChange}>
|
||||
{contexts.map(ctx => (
|
||||
<option key={ctx.id} value={ctx.id} disabled={ctx.disabled}>
|
||||
{ctx.name}{ctx.disabled ? ' ⟨future⟩' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Sub-items for the active context */}
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
{subitems.map((item, i) => (
|
||||
<button
|
||||
@@ -79,91 +67,27 @@ export default function Shell({
|
||||
{token || '—'}
|
||||
{onNewGame && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<button className="otivm-new-game-btn" onClick={onNewGame}>
|
||||
new game
|
||||
</button>
|
||||
<button className="otivm-new-game-btn" onClick={onNewGame}>new game</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Main area ── */}
|
||||
<main className="otivm-main" style={{ flex: 1 }}>
|
||||
<LayoutGrid layout={layout}>
|
||||
{children}
|
||||
</LayoutGrid>
|
||||
<div className="otivm-ctx-header">
|
||||
<div>
|
||||
<span className="otivm-ctx-eyebrow">Context</span>
|
||||
<span className="otivm-ctx-name">{ctxName}{ctxSubtitle ? ` — ${ctxSubtitle}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="otivm-layout-wrap">
|
||||
{gridClass
|
||||
? <div className={gridClass}>{children}</div>
|
||||
: children
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="otivm-layout-wrap">
|
||||
{childArray}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layout === 'two-col') {
|
||||
const left = childArray.filter(c => c?.props?.col === 'left')
|
||||
const right = childArray.filter(c => c?.props?.col === 'right')
|
||||
return (
|
||||
<div className="otivm-layout-wrap">
|
||||
<div className="otivm-layout-two-col">
|
||||
<div>{left}</div>
|
||||
<div>{right}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="otivm-layout-wrap">
|
||||
<div className="otivm-layout-three-col">
|
||||
<div>{left}</div>
|
||||
<div>{center}</div>
|
||||
<div>{right}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layout === 'map') {
|
||||
const sidebar = childArray.filter(c => c?.props?.col === 'sidebar')
|
||||
const canvas = childArray.filter(c => c?.props?.col === 'canvas')
|
||||
return (
|
||||
<div className="otivm-layout-wrap">
|
||||
<div className="otivm-layout-map">
|
||||
<div>{sidebar}</div>
|
||||
<div>{canvas}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback — render all children in a single column
|
||||
return (
|
||||
<div className="otivm-layout-wrap">
|
||||
{childArray}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <BackgroundSelection onSelectBackground={onSelectBackground} />
|
||||
return (
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<BackgroundSelection onSelectBackground={onSelectBackground} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 */}
|
||||
<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} />
|
||||
<div>
|
||||
<Section title="Identity" type="status-block" items={identityItems} />
|
||||
<Section title="Liquiditas" type="status-block" items={liquiditasItems} />
|
||||
</div>
|
||||
<div>
|
||||
<Section title="Auctoritas — three faces" type="auctoritas"
|
||||
faces={auctoritasFaces}
|
||||
gap_note="True and perceived standing match. The record is young." />
|
||||
<Section title="Parameters" type="parameter-list" items={parameterItems} />
|
||||
</div>
|
||||
<div>
|
||||
<Section title="Periodic obligations" type="cost-table"
|
||||
note="Debited per otium cycle. Source: docs/economy/cost-calibration-model.md"
|
||||
items={obligationItems} />
|
||||
<Section title="Drift log · recent" type="drift-log" items={driftItems} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Background selection UI ───────────────────────────────────────────────
|
||||
|
||||
function BackgroundSelection({ onSelectBackground }) {
|
||||
const [selected, setSelected] = useState(null)
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '640px', margin: '0 auto', padding: '28px 0' }}>
|
||||
<div style={{ maxWidth: '640px', margin: '0 auto', padding: '8px 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' }}>
|
||||
<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)' }}>
|
||||
<p style={{ fontSize: '0.82rem', fontStyle: 'italic', color: 'var(--otivm-ink-faint)', margin: 0 }}>
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
@@ -112,24 +96,15 @@ function BackgroundSelection({ onSelectBackground }) {
|
||||
</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'}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}>
|
||||
<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'
|
||||
@@ -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}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<route.unlock_den ? `${route.unlock_den.toLocaleString()} dn` : `Auctoritas ${route.unlock_aut}`) : null,
|
||||
}
|
||||
})
|
||||
|
||||
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:''},
|
||||
]
|
||||
|
||||
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 && (
|
||||
<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,
|
||||
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,
|
||||
gridColumn:'1/-1',
|
||||
}}>
|
||||
{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}
|
||||
/>
|
||||
{/* LEFT */}
|
||||
<div>
|
||||
<Section title="Active venture" type="status-block" items={activeVentureItems} />
|
||||
<Section title="Actions" type="action-bar" buttons={actionButtons} onAction={handleAction} />
|
||||
<Section title="Journal" type="text-block" entries={journalEntries} collapsible={true} />
|
||||
</div>
|
||||
|
||||
{/* 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} />
|
||||
{/* RIGHT */}
|
||||
<div>
|
||||
<Section title="Trade routes" type="route-list" routes={routeItems} selectedRoute={selectedRoute} onSelect={handleSelectRoute} />
|
||||
<Section title="Standing" type="status-block" items={standingItems} />
|
||||
<Section title="Periodic expenditures" type="cost-table" items={expenditureItems} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user