batched round: queue actions/diplomacy, jump forward commits one turn (v0.2.0)
CI / build + unit + e2e (pull_request) Successful in 1m51s

This commit is contained in:
2026-10-11 14:36:35 +00:00
parent 3297658c81
commit 9bce14254d
16 changed files with 563 additions and 246 deletions
+31 -22
View File
@@ -6,19 +6,20 @@ Guidance for AI agents (and humans) working in this repo.
A self-contained, AI-driven **alternate-history simulator** in the style of _Pax Historia_:
the LLM is the world engine. You pick a scenario (one of 5 seed presets) and a nation to lead,
then take actions / send diplomatic messages / ask an advisor; each move is sent to the model,
which returns a structured `TurnResult` (events + stat/relation/territory deltas) that the engine
validates, clamps, and applies to the world state. A MapLibre choropleth renders territorial
ownership; the whole thing persists to SQLite and streams over SSE on a single port.
then queue actions and diplomatic messages into a pending round; a single "Jump forward" sends
the whole batch to the model, which returns a structured `TurnResult` (events + stat/relation/territory
deltas) that the engine validates, clamps, and applies to the world state. A MapLibre choropleth
renders territorial ownership; the whole thing persists to SQLite and streams over SSE on a single port.
## Change process (mandatory)
1. **Small, incremental, well-tested changes** — work is done one change at a time, in smaller parts; each part is tested before moving on. Connected tasks that touch the same area of code MAY be done together on one branch/PR with a single version bump (e.g. two tweaks to the same panel).
2. **Feature branch + PR** — every change lands on a feature branch off `master` (no direct pushes to `master`). Open a pull request against `master` on Gitea (`https://git.kkhost.pl`, repo `OpenCode/History`).
3. **Changelog entry** — every change is listed in `CHANGELOG.md` with a version, date and time (format: `## v<version> — YYYY-MM-DD HH:MM`, then bullet points of what changed).
4. **Version bump** — the version is updated in `package.json`.
5. **CI must pass** — branch protection on `master` requires the CI status check to be green before merge. Merge via auto-merge (`POST /repos/OpenCode/History/pulls/{n}/merge` with body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`) so the PR merges by itself once CI is green. **CI never deploys to production.**
6. **Tag + manual deploy after merge** — once merged, tag the version on `master` (e.g. `v0.1.0`). Production (`https://history.dev.kkhost.pl`) is updated manually afterwards on the host: `docker compose up -d --build`, then verify the site.
2. **Gitea issue first** — file an issue on Gitea (`https://git.kkhost.pl`, repo `OpenCode/History`) describing the change (title, scope, acceptance criteria) before or alongside creating the branch. The issue is the single source of intent; the PR references it.
3. **Feature branch + PR** — every change lands on a feature branch off `master` (no direct pushes to `master`). Open a pull request against `master` on Gitea; reference the issue in the PR description.
4. **Changelog entry** — every change is listed in `CHANGELOG.md` with a version, date and time (format: `## v<version> — YYYY-MM-DD HH:MM`, then bullet points of what changed).
5. **Version bump** — the version is updated in `package.json`.
6. **CI must pass** — branch protection on `master` requires the CI status check to be green before merge. Merge via auto-merge (`POST /repos/OpenCode/History/pulls/{n}/merge` with body `{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}`) so the PR merges by itself once CI is green. **CI never deploys to production.**
7. **Tag + manual deploy after merge** — once merged, tag the version on `master` (e.g. `v0.2.0`). Production (`https://history.dev.kkhost.pl`) is updated manually afterwards on the host: `docker compose up -d --build`, then verify the site.
## Stack
@@ -49,11 +50,11 @@ history/
├── public/
│ └── 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, Preset, …)
│ └── protocol.ts # LLM TurnResult schema + client↔server messages + pure helpers
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/Jump request types + pure helpers
├── server/
│ ├── index.ts # Express: /api/*, /sse/:id, static+SPA; reads PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / advisor
│ ├── 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
│ ├── 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)
@@ -61,7 +62,7 @@ history/
│ ├── db.ts # SQLite open/save/load
│ └── *.test.ts # unit tests (vitest)
└── src/
├── App.tsx # screen router: home → pick → game; owns game/regions/advisors state
├── App.tsx # screen router: home → pick → game; owns game/regions/advisors/busy state
├── main.tsx # React root + ThemeProvider(dark)
├── index.css # Tailwind v4 + dark "command-center" theme
├── lib/
@@ -73,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 + "Jump forward"
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list + "Jump forward"
├── EventFeed.tsx # scrolling event log (newest first)
├── StatBar.tsx
└── ui/ # 14 shadcn/ui primitives
@@ -97,12 +98,18 @@ history/
## Conventions
- **The server is authoritative.** All world state lives in SQLite (`server/db.ts`). The client
renders state and sends intents (`/api/games/:id/turn|jump|advisor`); it never computes outcomes.
`shared/protocol.ts` is the single source of truth for the LLM `TurnResult` and the
renders state and sends intents (`/api/games/:id/action|diplomacy|jump|advisor`); it never computes
outcomes. `shared/protocol.ts` is the single source of truth for the LLM `TurnResult` and the
client↔server message shapes — change it there first, then update both sides.
- **No hardcoded origins.** The client uses same-origin relative paths (`/api/...`, `/sse/...`).
In dev, Vite proxies them to :4321; in prod/Traefik they're the same origin. So the app works
unchanged behind the proxy — never bake in `http://host:port`.
- **Batched round model.** Actions and diplomatic messages are queued in `game.pending` via
`POST /action` and `POST /diplomacy` (fast, no LLM call, no turn advance). A single
`POST /jump` ("Jump forward") commits the whole pending batch in one LLM turn: `engine.runTurn`
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.
- **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
@@ -110,11 +117,13 @@ history/
(model down) surfaces as an HTTP 502 to the client (shown as a toast).
- **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the
server's `RegionMap` and any client coloring so the choropleth always agrees.
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically: the player
nation's `military` is set to `(current + 5)` (clamped) each turn, one event is emitted
(action→military, chat→diplomatic, jump→political "The world moves on"), it allies the player with
the first other nation, and the date advances 2 days. Unit + e2e assertions rely on these exact
values, so keep the stub deterministic.
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per
queued round item: each `action` item → one military/success event, each `diplomacy` item → one
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
nation's `military` is set to `(current + 5)` (clamped) once per committed turn (not per item),
the player is allied with the first other nation (by key order in the `nations` record), and the
date advances 2 days. Unit + e2e assertions rely on these exact values, so keep the stub
deterministic.
- Styling: Tailwind utility classes + shadcn/ui only; dark "command-center" theme (slate bg, amber
primary, cyan accent). No CSS beyond the Tailwind import in `src/index.css`.