107 lines
5.3 KiB
Markdown
107 lines
5.3 KiB
Markdown
# History
|
||
|
||
A self-contained, AI-driven **alternate-history simulator** in the spirit of _Pax Historia_ — the
|
||
LLM is the world engine. Pick one of five seed scenarios, choose a nation to lead, 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
|
||
who owns what; everything persists to SQLite and streams over SSE on a single port.
|
||
|
||
```
|
||
┌────────────┐ /api + /sse ┌──────────────────────────────┐ OpenAI chat ┌──────────────┐
|
||
│ React 19 │ ───────────────▶│ Express (one process/port) │ ───────────────▶│ LLM / stub │
|
||
│ client │ (same-origin) │ /api · /sse · static · SPA │ ◀───────────────│ 27B model │
|
||
└────────────┘ │ SQLite (better-sqlite3) │ TurnResult └──────────────┘
|
||
└──────────────────────────────┘
|
||
```
|
||
|
||
## The five scenarios
|
||
|
||
| Id | Name | Year | Nations |
|
||
|---|---|---|---|
|
||
| `wwii-1939` | World War II | 1939 | 7 (Allies vs Axis) |
|
||
| `coldwar-1955` | Cold War | 1955 | 7 (bloc + non-aligned) |
|
||
| `modern-2025` | Modern World | 2025 | 8 (great powers) |
|
||
| `no-wwii-1940` | No World War II | 1940 | 6 (divided Europe) |
|
||
| `aegis-2400` | Aegis | 2400 | 6 (far-future star systems) |
|
||
|
||
## Quickstart (dev)
|
||
|
||
```bash
|
||
npm install
|
||
npm run dev # Express on :4321 + Vite on :5173 (proxies /api and /sse)
|
||
```
|
||
|
||
Open <http://localhost:5173>.
|
||
|
||
The dev server talks to a live LLM by default (`LLM_API_BASE` / `LLM_MODEL`). To run fully
|
||
offline with the deterministic stub, set `LLM_STUB=1`:
|
||
|
||
```bash
|
||
LLM_STUB=1 npm run dev
|
||
```
|
||
|
||
## Quickstart (Docker + Traefik)
|
||
|
||
```bash
|
||
docker compose up -d --build
|
||
```
|
||
|
||
The single container listens on **4321** and is routed by the shared Traefik to
|
||
**<https://history.dev.kkhost.pl>**. Game state persists on the `history-data` volume at
|
||
`/app/data`. To make a deployed instance playable without a live model:
|
||
|
||
```bash
|
||
docker compose up -d --build --force-recreate # after uncommenting LLM_STUB=1 in docker-compose.yml
|
||
```
|
||
|
||
## Configuration (env)
|
||
|
||
| Var | Default | Notes |
|
||
|---|---|---|
|
||
| `LLM_API_BASE` | `http://192.168.1.70:1111/v1` | OpenAI-compatible base URL |
|
||
| `LLM_MODEL` | `qwen3.8-27b` | model id sent in the request body |
|
||
| `LLM_TIMEOUT_MS` | `180000` | a local 27B model can be slow |
|
||
| `LLM_STUB` | _(unset = live)_ | `1` → deterministic stub transport (no model required) |
|
||
| `PORT` | `4321` | the single server port (client + /api + /sse) |
|
||
| `DATA_DIR` | `./data` | where `history.db` lives |
|
||
| `CLIENT_DIST` | _(auto)_ | built client to serve (auto-found next to the bundle) |
|
||
| `VITE_PORT` | `5173` | dev-only: Vite client port (e2e uses it to dodge collisions) |
|
||
| `HISTORY_API_PORT` | `4321` | dev-only: the port the Vite proxy targets |
|
||
|
||
## Scripts
|
||
|
||
| Command | What it does |
|
||
|---|---|
|
||
| `npm run dev` | Express (`tsx watch`, :4321) + Vite (:5173) concurrently |
|
||
| `npm run build` | `vite build` → `dist/`, then `tsup server/index.ts` → `server/dist/index.js` |
|
||
| `npm start` | run the compiled server (`node server/dist/index.js`) |
|
||
| `npm test` | vitest unit tests in `server/` (19) |
|
||
| `npm run typecheck` | `tsc --noEmit` (strict) across the whole tree |
|
||
| `npm run lint` | ESLint (flat config) |
|
||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); runs in CI on every PR |
|
||
|
||
## Change process
|
||
|
||
Changes follow a mandatory PR-based workflow (full rules in [AGENTS.md](./AGENTS.md#change-process-mandatory)):
|
||
work on a feature branch off `master` (no direct pushes), add a `CHANGELOG.md` entry and bump the
|
||
`package.json` version, open a PR to `master`, and let **CI pass** (typecheck → lint → unit → build → e2e)
|
||
before auto-merging. After merge, tag the version and deploy manually (`docker compose up -d --build`).
|
||
|
||
## Design notes
|
||
|
||
- **Server is authoritative.** All world state lives in SQLite; the client only renders state and
|
||
sends intents (`action` / `diplomacy` / `jump` / `advisor`). Actions and diplomatic messages are
|
||
queued into `game.pending` (no LLM call, no turn advance); a single `jump` ("Jump forward")
|
||
commits the whole batch in one LLM turn. The engine never trusts raw model output — it strips
|
||
fences, extracts the first balanced `{…}`, `JSON.parse`s, ignores invalid ops, clamps stats to
|
||
0–100, retries once with a nudge, and if still unusable emits a generic "unclear" event and
|
||
advances. Bad model output never crashes; only a network failure (model down) surfaces as a
|
||
`502` toast.
|
||
- **No hardcoded origins.** The client uses same-origin relative paths, so the identical build runs
|
||
in dev (Vite proxy), in Docker, and behind Traefik without change.
|
||
- **One source of truth.** `shared/protocol.ts` defines the LLM `TurnResult`, the client↔server
|
||
messages, and the pure helpers (`nationColor`, `clampStat`, `addDays`, …).
|
||
|
||
See [AGENTS.md](./AGENTS.md) for the full layout, conventions, and gotchas.
|