@@ -51,10 +51,10 @@ history/
|
||||
│ └── countries.geo.json # 177 features, id=ISO_A3 (committed; Vite copies to dist/)
|
||||
├── shared/ # NO runtime deps — the single source of truth
|
||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/Jump request types + pure helpers
|
||||
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers
|
||||
├── server/
|
||||
│ ├── index.ts # Express: /api/* (action|diplomacy|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
|
||||
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / advisor
|
||||
│ ├── index.ts # Express: /api/* (action|diplomacy|queue/remove|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
|
||||
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / removeRoundItem / advisor
|
||||
│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config
|
||||
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER
|
||||
│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color)
|
||||
@@ -74,7 +74,7 @@ history/
|
||||
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
||||
├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight
|
||||
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
||||
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list + "Jump forward"
|
||||
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
|
||||
├── EventFeed.tsx # scrolling event log (newest first)
|
||||
├── StatBar.tsx
|
||||
└── ui/ # 14 shadcn/ui primitives
|
||||
@@ -87,7 +87,7 @@ history/
|
||||
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
||||
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
||||
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
||||
| `npm test` | vitest unit tests in `server/` (19 tests) |
|
||||
| `npm test` | vitest unit tests in `server/` (39 tests) |
|
||||
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||
| `npm run lint` | ESLint flat config |
|
||||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
||||
@@ -117,6 +117,17 @@ history/
|
||||
sends the entire `pending` array to the model, applies the `TurnResult`, clears `pending`, and
|
||||
advances the turn. An empty round (no items queued) is allowed and lets the world advance on its
|
||||
own. This design reduces LLM call frequency and makes each call a coherent batch of work.
|
||||
Queued items can be removed again via `POST /queue/remove` (by index — the client offers a
|
||||
trash button per row with a confirm dialog); like the adds it never advances the turn.
|
||||
- **Monotonic `GameState.version` — clients "never go backwards".** Every mutation (queue
|
||||
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
|
||||
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
|
||||
and legacy games without the field are treated as version 0. ALL state ingestion on the client —
|
||||
SSE `state`/`turn` events, REST responses (add/remove/jump), and game loads — flows through the
|
||||
single `applyState` in `App.tsx`: a *different* game id is always adopted, the *same* game only
|
||||
when the incoming version is not older than the current one (compared inside the functional
|
||||
`setGame` updater, so it can't act on a stale closure). This is what stops a late-arriving stale
|
||||
SSE snapshot from clobbering the UI (see Gotchas, #10).
|
||||
- **Defensive turn handling, never crash.** `engine.runTurn` strips fences, extracts the first
|
||||
balanced `{...}`, `JSON.parse`s, validates (ignores invalid ops, clamps stats to 0–100), retries
|
||||
once with a nudge if the result is empty/invalid, and if it's still unusable emits a generic
|
||||
@@ -170,6 +181,16 @@ history/
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Stale SSE snapshots can arrive *after* a fresher REST response — the version guard is what
|
||||
keeps the UI honest (#10).** The SSE `state` event sent on connect/resync is a snapshot taken at
|
||||
the moment the request is handled, and the SSE stream and the REST responses ride different
|
||||
connections (Vite proxy in dev, Traefik in prod) with no delivery-order guarantee. A snapshot
|
||||
taken just before a mutation is saved (e.g. the on-connect resync racing a quick "Add action")
|
||||
can reach the browser *after* that mutation's REST response. Pre-v0.4.0 the client applied any
|
||||
incoming state unconditionally, so a just-queued item could vanish ("add one: nothing happened,
|
||||
add another: both appears" — issue #10). Now every state carries a monotonic `version` and
|
||||
`App.tsx`'s `applyState` drops same-game states whose version is older than the current one.
|
||||
Don't add unguarded `setGame` calls or remove the version comparison.
|
||||
- **502s were the reasoning model exhausting its output budget.** The original client sent only
|
||||
`max_tokens: 4000`; `qwen3.8-27b`'s chain-of-thought alone often exceeded 4000 tokens, so
|
||||
`content` came back `null`, which the client treated as fatal → HTTP 502 (~half of turns),
|
||||
|
||||
Reference in New Issue
Block a user