Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c56024bc | ||
|
|
3c187c6901 | ||
|
|
8eea17148b | ||
|
|
073e916767 | ||
|
|
396702b9da | ||
|
|
832957c635 | ||
|
|
68402694c5 | ||
|
|
2191514db1 | ||
|
|
5d194636d0 | ||
|
|
a93ece9474 |
@@ -2,6 +2,43 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.32.0 — 2026-10-15 10:56
|
||||
- **#54: non-military takeovers become a visible regime change — a `Takeover` in the turn renames the nation.** The v0.30.0 stats work made stability a *soft* lever: the system prompt told the model that a low-stability nation (`stability < 30`) hosting a `gathering` marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with an event and a stat shock — but the model had no actual *field* to express the regime change itself, so the nation's name never changed and the takeover was invisible in the world. This version closes that gap with a new `TurnResult.takeovers` field: `Takeover = { nationRef, newRegimeName }`. Each entry is an **automatic, in-world** regime change applied directly by `applyTurnResult` (unlike `nationOps`, which are player-confirmed proposals) — the target nation's display `name` becomes `newRegimeName`, while its stable `id`, territory, relations (keyed by id), stats, and `playerNationId` are all preserved, so a player whose nation is overthrown keeps leading it under the new regime.
|
||||
- **Protocol (`shared/protocol.ts`).** New `Takeover` interface (`nationRef` + `newRegimeName`, both strings). `TurnResult.takeovers: Takeover[]` is **required** (default `[]`), placed between `nationOps` and `markers`.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateTakeovers(raw)` — new exported, defensive validator (same style as `validateNationOps`, never throws): non-array → `[]`; each entry must be an object with a non-empty (whitespace-trimmed) `nationRef` **and** a non-empty `newRegimeName`, each capped at 200 chars; anything else is dropped. Resolution of `nationRef` (id first, then a case-insensitive unique name) happens at apply time via the existing `resolveNationRef` — an unknown or ambiguous ref is a no-op at apply, never a throw.
|
||||
- `validateTurnResult` populates `takeovers` from the validated array (and the generic "unclear" fallback in `runTurn` carries `takeovers: []`).
|
||||
- `applyTurnResult` applies takeovers **after** the per-region stats recompute and **before** the markers block (the rename is order-independent of territory/stats; marker owners are nation **ids**, not names, so the rename never disturbs the owner recompute).
|
||||
- `isMeaningful` — a turn that only performs a takeover is meaningful (it survives the retry/nudge path).
|
||||
- `buildSystemPrompt` — the JSON shape gains `"takeovers": [{…}]` and a new HARD CONSTRAINTS bullet documents the field: **automatic, NOT player-confirmed**; fire it when a low-stability nation hosting a `gathering` marker is overthrown (the v0.30 stability/gathering mechanic now has a concrete consequence), narrate it with a political/social success event plus a stability/economy shock, and use `nationOps` rename only for the player-confirmed kind of name change.
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `takeovers: []` (the stub never performs a takeover) — its one-conquest behavior and every e2e assertion are untouched; the regime-rename path is covered by unit tests only.
|
||||
- **Client.** No change — a takeover is just a nation `name` change, which the existing dossier / event feed / map rendering already show (the nation keeps its stable color, since that is keyed by id).
|
||||
- **Unit tests (`server/engine.test.ts`).** 15 new tests: the apply suite (a takeover renames the target with id/territory/relations/stats preserved; a takeover of the player's own nation preserves `playerNationId`; unknown ref → no-op; ambiguous ref → no-op; empty/whitespace `newRegimeName` dropped at validation; multiple takeovers in one turn all apply; a takeover turn bumps the version exactly once; case-insensitive name resolution); `isMeaningful` with takeovers; the system-prompt contract (both modes document the field + the automatic/not-player-confirmed rule + the stability/gathering tie-in + the distinction from `nationOps` rename); `validateTakeovers` (valid entries trimmed/capped, invalid dropped, non-array → `[]`, `validateTurnResult` carry-through + default); the stub returns `takeovers: []`. All existing `TurnResult` literals in the suite gained the new required field. Totals: **322 unit tests (14 files)**; typecheck, lint, and build green.
|
||||
|
||||
## v0.31.0 — 2026-10-15 10:22
|
||||
- **#52: dynamic scenario generation — the "Create scenario" path.** An additive second way to start a game alongside the 5 static presets: the user picks one of **3 light era templates** (`ww2` 1939, `present` 2026, `near-future` 2035) and writes a free-text **description** (plus an editable start year), and the LLM **generates** a draft world + an advisor **checkpoint list** (step 1). The player reviews the checkpoints (approve/remove) and picks a nation to lead, and a second LLM call **finalizes** the world — constrained to the *approved* checkpoints — into a saved, playable `GameState` (step 2): a normal game at `#/game/<id>` that plays with the existing turn engine, map, and panels untouched. The 5 static presets and the turn stub (`server/stub.ts`) are 100% unchanged; a new deterministic **scenario stub** (a distinct transport discriminating the two calls by marker) makes the whole path reproducible under `LLM_STUB=1`.
|
||||
- **Era templates (`presets/templates/`).** Three small JSON seeds + an `index.json` manifest — each `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [] }` (e.g. near-future suggests a North American Union/USA, a Chinese Federation/CHN, a European Confederation/FRA, a Russian Union/RUS, with tensions like "autonomous weapons and the new arms race"). `server/templates.ts` is a tiny store mirroring `createPresetStore` (manifest + per-file load; a missing/broken file degrades to absent).
|
||||
- **Shared contract (`shared/scenario.ts`, dependency-free).** `EraTemplate` / `TemplateMeta` / `SuggestedPower`; `WorldDraft = { nations: WorldNation[]; events? }` where `WorldNation.territory` holds **ISO_A3 country codes** (not region keys) and `relations` are numeric sentiments (−100..100); `Checkpoint = string`; the four message shapes (`ScenarioDraftRequest/Response`, `ScenarioCommitRequest/Response`). Pure transforms: `validateWorldDraft(raw, { validCountryCodes })` — defensive parse/clamp/normalize that never throws (name-required, id kept-or-slug fallback, duplicate ids suffixed, territory upper-cased/deduped and filtered to known ISO_A3 codes when a valid set is given, relations re-keyed to assigned ids via id or case-insensitive name match with self/unknown refs dropped, optional backstory events kept only when they name a known nation; returns `null` when the result is unusable — no nations, or no valid territory to render); `relationFromScore` (≥60 ally · 0..59 neutral · −40..−1 rival · −80..−41 hostile · ≤−81 war); `worldDraftToGameState(draft, expand, playerNationId, opts)` — expands ISO_A3 territory to region keys via the injected `expand` (`expandCountriesToKeys` in production), seeds `regionStats` (so each owned region starts with its owner's stats and the derived aggregate equals them on turn 1), a clean turn-1 state (`version:1`, `turn:1`, empty `unowned`/`markers`/`pendingNationOps`/`pending`), `presetId: 'dynamic'`, and a guard that re-points a dangling `playerNationId` to the first nation so the game is always playable.
|
||||
- **Scenario LLM steps (`server/scenario.ts`).** Two plain `chat()` calls (no tools, no separate advisor endpoint — the advisor is embodied in step 1's checkpoint list): `generateDraft({ template, description, year })` → `{ world, checkpoints }` (the prompt carries the era description, suggested powers, tensions, the user's description, the year, and the valid ISO_A3 reference), and `finalizeWorld({ …, approvedCheckpoints, draft, playerNationId })` → final `GameState` (re-asked to produce the final world constrained to the approved checkpoints, based on the step-1 draft the client re-sends so the chosen nation survives; falls back to the draft itself when the model returns nothing usable). Same defensive parsing as the engine (strip fences → first balanced object → `JSON.parse` → `validateWorldDraft`), one nudge-retry when the answer is unusable; a transport failure (model down) throws → HTTP 502, a bad *answer* degrades to the fallback and never crashes.
|
||||
- **Scenario stub (`server/stubScenario.ts`).** Deterministic transport for the two scenario calls only (discriminated by `SCENARIO_GENERATE_MARKER` / `SCENARIO_REGENERATE_MARKER` in the system message, mirroring how the turn stub keys off `ADVISOR_MARKER`): a fixed 3-nation world (Alba/FRA 70·60·65·55, Boreas/RUS 55·70·50·60, Gamma/ESP 30·45·70·40) + one backstory event ("The new order takes shape") + a fixed 3-checkpoint list; the finalize step refines the world deterministically from the approved checkpoints (each naming a nation by its 4-letter name prefix raises that nation's stability +5, clamped) — so "finalize respects the approved checkpoints" is observable end-to-end. The turn stub stays 100% unchanged.
|
||||
- **Routes (`server/index.ts`).** `GET /api/scenarios/templates` (the 3 manifest entries), `POST /api/scenarios/draft` (`{ templateId, description, year? }` → `{ world, checkpoints }`), `POST /api/scenarios/commit` (`{ templateId, description, year?, draft?, approvedCheckpoints, playerNationId }` → `{ id }` — the finalized world is saved to SQLite with `startDate = <year>-01-01` and title = the template name). A separate `scenarioLlm` client (the scenario stub under `LLM_STUB=1`, the same live `qwen3.8-27b` client otherwise) — the turn `llm` is untouched. `POST /api/games/:id/jump` synthesizes a minimal `Preset` (`dynamicPresetFromGame`) for a saved dynamic game (`presetId: 'dynamic'` has no static file on disk — `runTurn` only consumes title/startDate/worldBackground/rules) so a created scenario plays with the normal turn engine; a genuinely-missing static preset still 404s.
|
||||
- **Client.** New `ScenarioForm` (3 template cards, a pre-filled editable year, a description textarea, Generate → `/draft`; a 502 toasts and stays on the form) and `CheckpointReview` (the draft world as nation cards with stats, the checkpoint checklist with per-item approve/remove, a pick-a-nation-to-lead step, Finalize → `/commit` → navigates to `#/game/<id>`; a 502 toasts and stays on review). `PresetPicker` gains a "Create scenario" card, and `App.tsx` wires the `home → form → review → navigate` flow (a distinct `scenarioBusy` flag that never gates "Jump forward" or the turn flow).
|
||||
- **Tests.** Unit: the template store (manifest + all 3 files' shape); `validateWorldDraft` / `relationFromScore` / `worldDraftToGameState` (valid → clean, stat/score clamping, bad country codes dropped, garbage → null without throwing, id dedup, territory expansion, `regionStats` seeding, player fallback, turn-1 invariants); `generateDraft` / `finalizeWorld` against the scenario stub (deterministic output, finalize respecting the approved checkpoints, a saved playable game). E2E: a new `e2e/scenario.spec.ts` (2 tests — the templates manifest over the API, and the full deterministic flow under `LLM_STUB=1`: near-future (2035) + description → draft → remove one checkpoint → lead Boreas → finalize (checkpoints shift stats) → a playable game at the `#/game/<id>` deep link with turn 1, correct date, dossier stats, map legend, and the backstory event; plus an empty jump proving the dynamic game runs the normal turn loop). The existing `e2e/sim.spec.ts` is unchanged. Totals: **307 unit tests (14 files)**, **16 e2e tests (2 scenario + 14 sim)**; typecheck, lint, and build green.
|
||||
|
||||
## v0.30.0 — 2026-10-15 08:22
|
||||
- **#50: make the four 0–100 stats consequential — a hard military conquest gate, soft stability takeovers, and economy growth/decay.** The four stats now actually drive outcomes instead of being decorative. **Military is a hard gate:** a contested invasion (a region's sole owner changing hands) is repelled whenever the attacker's military is **more than `CONQUEST_MILITARY_MARGIN = 15`** below the defender's — equal or within 15 still wins. The engine enforces this in `applyTurnResult` and emits a new `repelled` event when it fires; **re-claims of unowned (no man's land) regions are not gated** (there is no defender). **Stability is a soft lever:** the system prompt documents that a nation whose stability is below 30 *and* that hosts a `gathering` marker can suffer a **non-military takeover** (coup / revolution / faction seizure) — narrated by the LLM with a political/social success event, a gathering marker, and a downward stability/economy shock. **Economy is a soft lever:** a strong (rising) economy trends the other stats up (growth) and a collapsing economy drags them down (decay), reflected via `stateChanges` / `regionChanges`. A **frontier military-balance hint** and a **takeover-risks** hint are emitted in the user payload (each only when present) so the model — and the player, via the same data — sees the levers before acting. The stub stays 100% deterministic and untouched; e2e is unaffected (the UK/wwii-1939 e2e has an empty frontier, so the gate is vacuous there).
|
||||
- **Protocol (`shared/protocol.ts`).** New `CONQUEST_MILITARY_MARGIN = 15` and a pure `canConquer(attackerMil, defenderMil, margin = CONQUEST_MILITARY_MARGIN)` — `!(attackerMil < defenderMil - margin)` (proceeds unless the attacker is strictly more than the margin below the defender).
|
||||
- **Types (`shared/types.ts`).** `EventOutcome` gains `'repelled'` (engine-only; the LLM never produces it).
|
||||
- **Colors (`src/lib/colors.ts`).** `OUTCOME_COLORS.repelled = '#9f1239'` — a muted rose-800, distinct from `failure` (`#ef4444`) and `neutral` (`#94a3b8`).
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `applyTurnResult` gains an optional `regionNameOf` (5th optional param, after `geoOf`) and the **military hard gate** inside the snapshot-first claims loop: for each contested key (a gainer's `gain` whose current sole owner is a *different* nation), it compares the gainer's start-of-turn military to the owner's via `canConquer`; on a fail it keeps the key with the owner, leaves both nations' military untouched, and pushes one `repelled` military event (`"<Attacker>'s invasion of <region name> was repelled by <Defender>."`, using the region's geo display name when known). Re-claims (`owner === undefined`) bypass the gate.
|
||||
- `buildSystemPrompt` gains a **"HOW THE FOUR 0–100 STATS DRIVE OUTCOMES"** section: the enforced military gate (incl. the no-man's-land carve-out), the soft stability takeover, and the economy growth/decay lever — present in both tool and plain modes.
|
||||
- `buildUserPrompt` computes and emits two new, bounded payload fields (each only when non-empty, the rest of the payload untouched): `frontierMilitary` (one line per frontier neighbour: `[Your military: X] vs [Name: Y] — a direct invasion could succeed | would be repelled`, decided by `canConquer`) and `takeoverRisks` (one line per nation with stability < 30 *and* a `gathering` marker: `Name (stability N, a gathering is in progress)`).
|
||||
- `runTurn` threads `regionNameOf` through to `applyTurnResult`.
|
||||
- **Server route (`server/index.ts`).** The `/jump` route wires `regionNameOf: (k) => regionName(k)` so repelled invasions name the region properly.
|
||||
- **Stub (`server/stub.ts`).** Unchanged (100%) — it never releases, never proposes a nation op, and its one-conquest behavior is what e2e relies on; the new gate / takeover / economy paths are covered by unit tests only.
|
||||
- **Unit tests.** `server/protocol.test.ts`: `canConquer` edges (equal, within margin, over margin, gap == margin, gap == margin + 1, margin = 0). `server/engine.test.ts`: the hard gate (proceed within margin / proceed when stronger / **repel when over margin** with a correct `repelled` event + region display name + attacker untouched / **re-claim ungated** / boundary at exactly the margin and one past it); the system prompt documents all three mechanics in both modes; the user prompt emits `frontierMilitary` (could-succeed vs repelled per neighbour) and `takeoverRisks` (and omits both fields when no frontier / no markers).
|
||||
|
||||
## v0.29.0 — 2026-10-15 01:05
|
||||
- **#46 (PR 2 of 2): per-region stat lever — the LLM can now target individual regions' stats, and the map hover shows them.** The LLM gains a fine-grained `TurnResult.regionChanges` field (alongside the existing nation-wide `stateChanges`): each entry targets **one** region's own stats by its region key (preferred) or case-insensitive name. A `set` replaces the value; an `add` increments it (both clamped 0–100 at apply time). Region-level changes are applied **after** nation-wide `stateChanges` (so they override the nation-level value for that one region) and **before** the weighted-median recompute, which propagates every region's final value into its owning nation's derived `stats`. A region with no stats entry (unowned / no man's land) is a no-op. The map hover tooltip now shows each region's own four stats (military / economy / stability / population) alongside the region name and owner chip; the `NationPanel` keeps showing the nation-level derived aggregate. The stub stays fully deterministic (`regionChanges: []`) — e2e assertions are untouched.
|
||||
- **Protocol (`shared/protocol.ts`).** New `RegionStatChange = { region: string; stat: StatKey; op: 'set'|'add'; value: number }`. `TurnResult.regionChanges: RegionStatChange[]` is **required** (default `[]`); sits right after `stateChanges`.
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Dynamic scenario generation — end to end (v0.31, #52). Fully deterministic
|
||||
* under the SCENARIO stub (the Playwright webServer boots with LLM_STUB=1, so
|
||||
* the two generation calls ride the deterministic transport in
|
||||
* server/stubScenario.ts — the turn stub in server/stub.ts is untouched).
|
||||
*
|
||||
* The stub returns a FIXED 3-nation world and a FIXED 3-checkpoint list for
|
||||
* the generate step, and — on the finalize step — refines that world in a
|
||||
* deterministic way that depends on which checkpoints the player approved
|
||||
* (each approved checkpoint naming one of the fixed nations, by its first 4
|
||||
* letters, raises that nation's stability by 5). So the whole two-step
|
||||
* "Create scenario" path plus the resulting game is reproducible:
|
||||
*
|
||||
* home → "Create scenario" → pick the Near Future era (2035) + a description
|
||||
* → generate (draft: alba/boreas/gamma + 3 checkpoints) → review (remove the
|
||||
* 3rd checkpoint, keep the first two) → lead Boreas → finalize → a saved,
|
||||
* playable game (turn 1, date 2035-01-01, player Boreas) → one empty jump
|
||||
* (turn 2, date 2035-01-03, Boreas military 55→60, allied with Alba, and no
|
||||
* territory change — Russia shares no land border with France or Spain).
|
||||
*
|
||||
* The e2e webServer shares one SQLite (data/e2e) across tests, so the test
|
||||
* captures the *specific* id of the game it creates (from the POST
|
||||
* /api/scenarios/commit response) rather than assuming "the only game".
|
||||
*/
|
||||
|
||||
/** The saved-game shape the assertions read back from GET /api/games/<id>. */
|
||||
interface SavedGame {
|
||||
id: string;
|
||||
presetId: string;
|
||||
presetTitle: string;
|
||||
playerNationId: string;
|
||||
turn: number;
|
||||
date: string;
|
||||
version: number;
|
||||
pending: unknown[];
|
||||
unowned: string[];
|
||||
pendingNationOps: unknown[];
|
||||
markers: unknown[];
|
||||
events: Array<{ type: string; outcome: string; title: string }>;
|
||||
nations: Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
stats: { military: number; economy: number; stability: number; population: number };
|
||||
territory: string[];
|
||||
relations: Record<string, string>;
|
||||
}
|
||||
>;
|
||||
regionStats: Record<
|
||||
string,
|
||||
{ military: number; economy: number; stability: number; population: number }
|
||||
>;
|
||||
}
|
||||
|
||||
/** Poll until `location.hash` equals `expected` (hash writes happen in effects). */
|
||||
async function waitHash(page: Page, expected: string): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(exp) => window.location.hash === exp,
|
||||
expected,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the "Create scenario" form from the home screen. */
|
||||
async function openScenarioForm(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
// Home: the create-scenario card is additive (first in the grid); the five
|
||||
// static presets are still all present alongside it.
|
||||
await expect(page.getByTestId('create-scenario-card')).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByTestId('preset-card')).toHaveCount(5);
|
||||
await page.getByTestId('create-scenario').click();
|
||||
await expect(page.getByTestId('scenario-form')).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('History — dynamic scenario generation (LLM_STUB=1)', () => {
|
||||
test('the era template manifest renders three light templates', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
await openScenarioForm(page);
|
||||
|
||||
// Exactly three era-template cards, each carrying its id and default year.
|
||||
const cards = page.getByTestId('template-card');
|
||||
await expect(cards).toHaveCount(3, { timeout: 15000 });
|
||||
await expect(page.locator('[data-template-id="ww2"]')).toContainText('1939');
|
||||
await expect(page.locator('[data-template-id="present"]')).toContainText('2026');
|
||||
await expect(page.locator('[data-template-id="near-future"]')).toContainText('2035');
|
||||
});
|
||||
|
||||
test('full create → review → finalize → play flow is deterministic', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await openScenarioForm(page);
|
||||
|
||||
// --- 1 · Pick the Near Future era (its default year pre-fills the field).
|
||||
const nf = page.locator('[data-template-id="near-future"]');
|
||||
await nf.click();
|
||||
await expect(nf).toHaveAttribute('data-selected', 'true');
|
||||
// The year field is pre-filled from the era (editable, a real value).
|
||||
await expect(page.getByTestId('scenario-year')).toHaveValue('2035');
|
||||
|
||||
// --- 2 · Describe the world.
|
||||
const description =
|
||||
'A cold space race decides the new cold war: two orbital blocs hold the arctic shipping lanes at knife-edge.';
|
||||
await page.getByTestId('scenario-description').fill(description);
|
||||
|
||||
// --- Step 1 · Generate the draft world + the advisor's checkpoint list.
|
||||
const generate = page.getByTestId('generate-scenario');
|
||||
await expect(generate).toBeEnabled();
|
||||
const [draftResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/draft',
|
||||
),
|
||||
generate.click(),
|
||||
]);
|
||||
expect(draftResp.status()).toBe(200);
|
||||
const draft = (await draftResp.json()) as {
|
||||
world: {
|
||||
nations: Array<{ id: string; name: string; territory: string[] }>;
|
||||
};
|
||||
checkpoints: string[];
|
||||
};
|
||||
// The stub's fixed 3-nation world (FRA / RUS / ESP) and 3 checkpoints.
|
||||
expect(draft.world.nations.map((n) => n.id).sort()).toEqual(['alba', 'boreas', 'gamma']);
|
||||
expect(draft.world.nations.find((n) => n.id === 'boreas')?.territory).toEqual(['RUS']);
|
||||
expect(draft.checkpoints).toHaveLength(3);
|
||||
|
||||
// --- Review screen: the generated world + the checkpoint checklist.
|
||||
await expect(page.getByTestId('scenario-review')).toBeVisible({ timeout: 15000 });
|
||||
// Header shows the era's name + year and the description.
|
||||
await expect(page.getByRole('heading', { name: 'Near Future' })).toBeVisible();
|
||||
await expect(page.getByTestId('scenario-review')).toContainText('2035');
|
||||
await expect(page.getByTestId('scenario-review')).toContainText(description);
|
||||
// The three draft nations (with their ISO_A3 territory) and the backstory.
|
||||
// Scope to the draft-nation cards (the nation-pick cards share the
|
||||
// data-nation-id attribute but carry a different testid).
|
||||
await expect(page.getByTestId('draft-nation')).toHaveCount(3);
|
||||
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="alba"]')).toContainText('FRA');
|
||||
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="boreas"]')).toContainText('RUS');
|
||||
await expect(page.locator('[data-testid="draft-nation"][data-nation-id="gamma"]')).toContainText('ESP');
|
||||
await expect(page.getByTestId('draft-event')).toHaveCount(1);
|
||||
// Three checkpoints, all approved by default, in the stub's fixed order.
|
||||
const cps = page.getByTestId('checkpoint-item');
|
||||
await expect(cps).toHaveCount(3);
|
||||
await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true');
|
||||
await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true');
|
||||
await expect(cps.nth(2)).toHaveAttribute('data-approved', 'true');
|
||||
await expect(cps.nth(0)).toContainText('Alba dominates');
|
||||
await expect(cps.nth(1)).toContainText('Boreas fortifies');
|
||||
await expect(cps.nth(2)).toContainText('Gamma brokers');
|
||||
// Three nation-pick cards; finalize is disabled until a nation is chosen.
|
||||
await expect(page.getByTestId('scenario-nation-card')).toHaveCount(3);
|
||||
await expect(page.getByTestId('finalize-scenario')).toBeDisabled();
|
||||
|
||||
// --- Review: remove checkpoint 3, keep 1 + 2.
|
||||
await cps.nth(2).getByTestId('checkpoint-toggle').click();
|
||||
await expect(cps.nth(2)).toHaveAttribute('data-approved', 'false');
|
||||
await expect(cps.nth(0)).toHaveAttribute('data-approved', 'true');
|
||||
await expect(cps.nth(1)).toHaveAttribute('data-approved', 'true');
|
||||
await expect(page.getByTestId('scenario-review')).toContainText('2 of 3 approved');
|
||||
|
||||
// --- Review: choose Boreas (Russia) to lead.
|
||||
const boreasCard = page.locator('[data-testid="scenario-nation-card"][data-nation-id="boreas"]');
|
||||
await boreasCard.getByTestId('scenario-lead-nation').click();
|
||||
await expect(boreasCard).toHaveAttribute('data-selected', 'true');
|
||||
await expect(page.getByTestId('finalize-scenario')).toBeEnabled();
|
||||
|
||||
// --- Step 2 · Finalize: commit the approved draft into a saved game.
|
||||
const [commitResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/scenarios/commit',
|
||||
),
|
||||
page.getByTestId('finalize-scenario').click(),
|
||||
]);
|
||||
expect(commitResp.status()).toBe(200);
|
||||
const { id } = (await commitResp.json()) as { id: string };
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
// --- The new game mounts at turn 1, leading Boreas, dated 2035-01-01.
|
||||
await expect(page.getByTestId('world-map')).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('game-date')).toHaveText('2035-01-01');
|
||||
await expect(page.getByTestId('player-nation')).toHaveText('Boreas');
|
||||
// The scenario title (the era name) is shown in the top bar.
|
||||
await expect(page.getByTestId('top-bar')).toContainText('Near Future');
|
||||
// Boreas's stats: the finalized world (its approved checkpoint raised
|
||||
// stability 50→55); per-region stats are seeded to equal the derived.
|
||||
await expect(page.getByTestId('stat-military')).toHaveText('55');
|
||||
await expect(page.getByTestId('stat-economy')).toHaveText('70');
|
||||
await expect(page.getByTestId('stat-stability')).toHaveText('55');
|
||||
await expect(page.getByTestId('stat-population')).toHaveText('60');
|
||||
// One backstory event; three nations on the legend.
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 });
|
||||
await expect(page.getByTestId('map-legend-item').filter({ hasText: 'Boreas' })).toBeVisible();
|
||||
// The deep link lands on this exact game.
|
||||
await waitHash(page, `#/game/${id}`);
|
||||
|
||||
// --- One empty jump: the world advances on its own (the turn stub).
|
||||
// The button sits at the bottom of a scrollable window body and may be
|
||||
// outside the viewport; a native DOM click bypasses the viewport check.
|
||||
await page.getByTestId('jump-forward').evaluate((el) => (el as HTMLElement).click());
|
||||
|
||||
// Turn 2, date +2 days, Boreas military 55→60.
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
|
||||
await expect(page.getByTestId('stat-military')).toHaveText('60', { timeout: 20000 });
|
||||
await expect(page.getByTestId('game-date')).toHaveText('2035-01-03', { timeout: 20000 });
|
||||
// Two events now (the empty-round "world moves on" on top, then the
|
||||
// backstory); both political/neutral.
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(2);
|
||||
await expect(page.getByTestId('event-item').first()).toContainText('The world moves on');
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('political');
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('neutral');
|
||||
await expect(page.getByTestId('event-item').nth(1)).toContainText('The new order takes shape');
|
||||
// No territory change: Russia shares no land border with France or Spain,
|
||||
// so the frontier is empty and no conquest is emitted.
|
||||
await expect(page.getByTestId('map-legend-item')).toHaveCount(3, { timeout: 15000 });
|
||||
|
||||
// --- The saved game is a coherent dynamic GameState on the server.
|
||||
const saved = await page.evaluate(async (gid) => {
|
||||
const r = await fetch(`/api/games/${gid}`);
|
||||
return { status: r.status, body: (await r.json()) as { game: SavedGame } };
|
||||
}, id);
|
||||
expect(saved.status).toBe(200);
|
||||
const g = saved.body.game;
|
||||
expect(g.presetId).toBe('dynamic');
|
||||
expect(g.presetTitle).toBe('Near Future');
|
||||
expect(g.playerNationId).toBe('boreas');
|
||||
expect(g.turn).toBe(2);
|
||||
expect(g.date).toBe('2035-01-03');
|
||||
expect(g.version).toBe(2); // created at 1, bumped to 2 by the jump
|
||||
expect(g.pending).toEqual([]);
|
||||
expect(g.unowned).toEqual([]);
|
||||
expect(g.pendingNationOps).toEqual([]);
|
||||
expect(g.markers).toEqual([]);
|
||||
expect(g.events).toHaveLength(2);
|
||||
// Boreas after the jump: military bumped to 60, the rest untouched.
|
||||
expect(g.nations.boreas.stats).toEqual({
|
||||
military: 60,
|
||||
economy: 70,
|
||||
stability: 55,
|
||||
population: 60,
|
||||
});
|
||||
// The stub allies the player with the first other nation (Alba).
|
||||
expect(g.nations.boreas.relations.alba).toBe('ally');
|
||||
// Every owned region key carries a seeded per-region stats entry.
|
||||
const allKeys = new Set<string>();
|
||||
for (const n of Object.values(g.nations)) for (const k of n.territory) allKeys.add(k);
|
||||
expect(allKeys.size).toBeGreaterThan(0);
|
||||
for (const k of allKeys) {
|
||||
expect(g.regionStats[k]).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.29.0",
|
||||
"version": "0.32.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "ww2",
|
||||
"name": "World War II",
|
||||
"year": 1939,
|
||||
"eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength."
|
||||
},
|
||||
{
|
||||
"id": "present",
|
||||
"name": "Present Day",
|
||||
"year": 2026,
|
||||
"eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, AI, and great-power rivalry shape every decision."
|
||||
},
|
||||
{
|
||||
"id": "near-future",
|
||||
"name": "Near Future",
|
||||
"year": 2035,
|
||||
"eraDescription": "Autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "near-future",
|
||||
"name": "Near Future",
|
||||
"year": 2035,
|
||||
"eraDescription": "A near-future world: autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances as the great powers reach for the next horizon.",
|
||||
"suggestedPowers": [
|
||||
{ "name": "North American Union", "region": "USA", "note": "The industrial-technological core of the West." },
|
||||
{ "name": "Chinese Federation", "region": "CHN", "note": "The integrated superpower of the Pacific." },
|
||||
{ "name": "European Confederation", "region": "FRA", "note": "The united European bloc." },
|
||||
{ "name": "Russian Union", "region": "RUS", "note": "The resource frontier state." }
|
||||
],
|
||||
"tensions": [
|
||||
"Autonomous weapons and the new arms race",
|
||||
"Climate migration and resource wars",
|
||||
"The contest for space and orbital dominance"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "present",
|
||||
"name": "Present Day",
|
||||
"year": 2026,
|
||||
"eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, artificial intelligence, and great-power rivalry shape every decision the leaders of the age must make.",
|
||||
"suggestedPowers": [
|
||||
{ "name": "United States", "region": "USA", "note": "The military-technological leader of the Western bloc." },
|
||||
{ "name": "People's Republic of China", "region": "CHN", "note": "The industrial giant pressing on the Western Pacific." },
|
||||
{ "name": "Russian Federation", "region": "RUS", "note": "A resource-rich power testing its neighbors' resolve." },
|
||||
{ "name": "European Union", "region": "FRA", "note": "A large, internally divided economic bloc." }
|
||||
],
|
||||
"tensions": [
|
||||
"US–China tech and trade rivalry",
|
||||
"The war in Europe and the eastern flank",
|
||||
"AI and cyber dominance"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "ww2",
|
||||
"name": "World War II",
|
||||
"year": 1939,
|
||||
"eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength, and the great powers watch — and arm — from across the oceans.",
|
||||
"suggestedPowers": [
|
||||
{ "name": "German Reich", "region": "DEU", "note": "A militarized Reich at the heart of the continent, its army the most battle-hardened in Europe." },
|
||||
{ "name": "United Kingdom", "region": "GBR", "note": "An island power that commands the seas but is cut off from the continent." },
|
||||
{ "name": "Soviet Union", "region": "RUS", "note": "A vast, fragile industrial giant on the eastern frontier." },
|
||||
{ "name": "United States", "region": "USA", "note": "An industrial colossus, still politically divided over entry into the war." }
|
||||
],
|
||||
"tensions": [
|
||||
"The Axis versus the Allies",
|
||||
"The race for continental resources and sea lanes",
|
||||
"The uneasy balance before the bomb"
|
||||
]
|
||||
}
|
||||
+506
-21
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GameState, MarkerType, Nation, NationStats, Preset, RegionStats, RoundItem } from '../shared/types';
|
||||
import { makeEvent, markerId, MAX_MARKERS, seedRegionStats, type MarkerSpec, type NationOp, type TurnResult } from '../shared/protocol';
|
||||
import {
|
||||
CONQUEST_MILITARY_MARGIN,
|
||||
makeEvent,
|
||||
markerId,
|
||||
MAX_MARKERS,
|
||||
seedRegionStats,
|
||||
type MarkerInfo,
|
||||
type MarkerSpec,
|
||||
type NationOp,
|
||||
type TurnResult,
|
||||
} from '../shared/protocol';
|
||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||
import { deterministicTurn, makeStubLlmClient } from './stub';
|
||||
import {
|
||||
@@ -25,6 +35,7 @@ import {
|
||||
validateMarkerOps,
|
||||
validateNationOps,
|
||||
validateRegionChanges,
|
||||
validateTakeovers,
|
||||
validateTurnResult,
|
||||
} from './engine';
|
||||
|
||||
@@ -532,7 +543,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withOps).not.toBeNull();
|
||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||
@@ -554,7 +565,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
@@ -575,7 +586,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], markers: { add: [], remove: [] }, // new proposal this turn
|
||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], markers: { add: [], remove: [] }, // new proposal this turn
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([
|
||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||
@@ -594,7 +605,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
});
|
||||
@@ -1051,7 +1062,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
@@ -1068,7 +1079,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
@@ -1091,7 +1102,7 @@ describe('applyTurnResult', () => {
|
||||
// is not a real key at all. Both are proposed as a gain by DE.
|
||||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
||||
@@ -1117,7 +1128,7 @@ describe('applyTurnResult', () => {
|
||||
// released (not in state.unowned), so it does not enter GameState.unowned.
|
||||
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
@@ -1135,7 +1146,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||
@@ -1225,7 +1236,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||
@@ -1340,7 +1351,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
||||
|
||||
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
||||
expect(
|
||||
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], markers: { add: [], remove: [] } }),
|
||||
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], markers: { add: [], remove: [] } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1647,9 +1658,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
|
||||
|
||||
describe('isMeaningful', () => {
|
||||
it('is true with an event, false when everything is empty', () => {
|
||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(false);
|
||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(false);
|
||||
});
|
||||
|
||||
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
|
||||
@@ -1662,7 +1673,7 @@ describe('isMeaningful', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -1678,10 +1689,202 @@ describe('isMeaningful', () => {
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('(v0.32.0 #54) a turn that only performs a takeover is meaningful (it survives the retry/nudge path)', () => {
|
||||
expect(
|
||||
isMeaningful({
|
||||
date: 'x',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
it('a takeover renames the target nation; id, territory, relations, and stats are preserved', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.de.name).toBe('The Reich');
|
||||
// id is preserved
|
||||
expect(next.nations.de.id).toBe('de');
|
||||
// territory is preserved
|
||||
expect(next.nations.de.territory).toEqual(['DEU']);
|
||||
// relations are preserved (keyed by id, not name)
|
||||
expect(next.nations.de.relations).toEqual({});
|
||||
// stats are preserved
|
||||
expect(next.nations.de.stats.military).toBe(95);
|
||||
// other nations are untouched
|
||||
expect(next.nations.uk.name).toBe('UK');
|
||||
expect(next.nations.us.name).toBe('US');
|
||||
});
|
||||
|
||||
it('a takeover of the player\'s own nation preserves playerNationId (player keeps leading it)', () => {
|
||||
const state = baseState(); // playerNationId is 'uk'
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.playerNationId).toBe('uk'); // player still leads it
|
||||
expect(next.nations.uk.name).toBe('The Commonwealth');
|
||||
expect(next.nations.uk.id).toBe('uk');
|
||||
});
|
||||
|
||||
it('an unknown nationRef → no-op (nation unchanged, no throw)', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
// no nation was renamed
|
||||
expect(next.nations.uk.name).toBe('UK');
|
||||
expect(next.nations.us.name).toBe('US');
|
||||
expect(next.nations.de.name).toBe('DE');
|
||||
});
|
||||
|
||||
it('an ambiguous nationRef (two nations share the name) → no-op', () => {
|
||||
const state = baseState();
|
||||
// make two nations share the same name
|
||||
state.nations.us.name = 'UK'; // now both 'uk' and 'us' are named 'UK'
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
// neither was renamed (ambiguous)
|
||||
expect(next.nations.uk.name).toBe('UK');
|
||||
expect(next.nations.us.name).toBe('UK');
|
||||
});
|
||||
|
||||
it('an empty/whitespace newRegimeName → no-op', () => {
|
||||
const state = baseState();
|
||||
// validateTakeovers drops entries with empty newRegimeName, so the validated
|
||||
// result has no takeovers → applyTurnResult is a no-op.
|
||||
const validated = validateTurnResult({
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(validated!.takeovers).toEqual([]);
|
||||
const next = applyTurnResult(state, validated!);
|
||||
expect(next.nations.de.name).toBe('DE');
|
||||
});
|
||||
|
||||
it('multiple takeovers in one turn all apply', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [
|
||||
{ nationRef: 'uk', newRegimeName: 'The Commonwealth' },
|
||||
{ nationRef: 'de', newRegimeName: 'The Reich' },
|
||||
],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.uk.name).toBe('The Commonwealth');
|
||||
expect(next.nations.de.name).toBe('The Reich');
|
||||
expect(next.nations.us.name).toBe('US'); // untouched
|
||||
});
|
||||
|
||||
it('a takeover turn bumps the version once (same as any other turn)', () => {
|
||||
const state = baseState(); // version 1
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.version).toBe(2); // exactly one bump for the whole turn
|
||||
});
|
||||
|
||||
it('a takeover via case-insensitive name resolution works', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.de.name).toBe('The Reich');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markers (point placement, v0.27 #44)', () => {
|
||||
@@ -1706,6 +1909,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||
@@ -1730,13 +1934,13 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
||||
const first = applyTurnResult(state, {
|
||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||
unowned: [], nationOps: [], markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], markers: { add, remove: [] },
|
||||
}, undefined, centroidOf);
|
||||
expect(first.markers).toHaveLength(1);
|
||||
// a second turn proposes the exact same marker again
|
||||
const second = applyTurnResult(first, {
|
||||
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||
unowned: [], nationOps: [], markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], markers: { add, remove: [] },
|
||||
}, undefined, centroidOf);
|
||||
expect(second.markers).toHaveLength(1);
|
||||
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
||||
@@ -1758,6 +1962,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||
@@ -1800,6 +2005,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||
@@ -1825,7 +2031,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
];
|
||||
const next = applyTurnResult(state, {
|
||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||
unowned: [], nationOps: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||
unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||
}, undefined, centroidOf);
|
||||
expect(next.markers).toHaveLength(1);
|
||||
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
||||
@@ -1841,14 +2047,14 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
// 1) uk releases the region → the marker becomes ownerless
|
||||
const released = applyTurnResult(state, {
|
||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||
unowned: ['moselle-rygy'], nationOps: [], markers: { add: [], remove: [] },
|
||||
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
}, undefined, centroidOf);
|
||||
expect(released.markers[0].ownerNationId).toBeNull();
|
||||
// 2) de re-claims the region → the marker's owner follows to de
|
||||
const reclaimed = applyTurnResult(released, {
|
||||
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
|
||||
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
||||
unowned: [], nationOps: [], markers: { add: [], remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
}, undefined, centroidOf);
|
||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||
});
|
||||
@@ -1996,6 +2202,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
||||
territoryChanges: partial.territoryChanges ?? [],
|
||||
unowned: partial.unowned ?? [],
|
||||
nationOps: partial.nationOps ?? [],
|
||||
takeovers: partial.takeovers ?? [],
|
||||
markers: partial.markers ?? { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
@@ -2470,3 +2677,281 @@ describe('stub — regionChanges (v0.29, #46 PR 2)', () => {
|
||||
expect(result).toHaveProperty('markers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTurnResult — military hard gate (v0.30 #50)', () => {
|
||||
it('an invasion whose attacker is MORE than the margin below the defender is REPELLED (key stays, a repelled event is emitted)', () => {
|
||||
// uk owns 'a' (military 80). de (military 50) tries to gain 'a'.
|
||||
// gap = 80 - 50 = 30 > 15 → repelled.
|
||||
const state = mkTurnState(
|
||||
{
|
||||
uk: mkNation('uk', NS(80, 70, 60, 50), ['a']),
|
||||
de: mkNation('de', NS(50, 50, 50, 50), ['b']),
|
||||
},
|
||||
{ a: RS(80, 70, 60, 50), b: RS(50, 50, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
(k) => (k === 'a' ? 'Region A' : undefined),
|
||||
);
|
||||
// the key stays with uk; de gains nothing
|
||||
expect(next.nations.uk.territory).toEqual(['a']);
|
||||
expect(next.nations.de.territory).toEqual(['b']);
|
||||
// exactly one repelled event, concerning the attacker (de), military type
|
||||
expect(next.events).toHaveLength(1);
|
||||
expect(next.events[0]).toMatchObject({
|
||||
nation: 'de',
|
||||
type: 'military',
|
||||
outcome: 'repelled',
|
||||
title: 'Invasion repelled',
|
||||
});
|
||||
// the text names the attacker, the defender, and the region's display name
|
||||
expect(next.events[0].text).toBe("DE's invasion of Region A was repelled by UK.");
|
||||
// the attacker's military is NOT weakened by the failed attack
|
||||
expect(next.nations.de.stats.military).toBe(50);
|
||||
});
|
||||
|
||||
it('an invasion within the margin (gap <= 15) PROCEEDS (no repelled event)', () => {
|
||||
// uk owns 'a' (military 80). de (military 66) tries to gain 'a'. gap = 14.
|
||||
const state = mkTurnState(
|
||||
{
|
||||
uk: mkNation('uk', NS(80, 70, 60, 50), ['a']),
|
||||
de: mkNation('de', NS(66, 50, 50, 50), ['b']),
|
||||
},
|
||||
{ a: RS(80, 70, 60, 50), b: RS(66, 50, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
|
||||
);
|
||||
expect(next.nations.de.territory).toContain('a');
|
||||
expect(next.nations.uk.territory).not.toContain('a');
|
||||
expect(next.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a STRONGER attacker (negative gap) proceeds', () => {
|
||||
// uk owns 'a' (military 50). de (military 80) gains 'a'.
|
||||
const state = mkTurnState(
|
||||
{
|
||||
uk: mkNation('uk', NS(50, 70, 60, 50), ['a']),
|
||||
de: mkNation('de', NS(80, 50, 50, 50), ['b']),
|
||||
},
|
||||
{ a: RS(50, 70, 60, 50), b: RS(80, 50, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
|
||||
);
|
||||
expect(next.nations.de.territory).toContain('a');
|
||||
expect(next.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a re-claim of an UNOWNED key is NOT gated (it always proceeds)', () => {
|
||||
// 'a' is in no man's land. de (military 10) re-claims it even though uk
|
||||
// (military 80) is far stronger — there is no defender, so the gate is bypassed.
|
||||
const state = mkTurnState(
|
||||
{
|
||||
uk: mkNation('uk', NS(80, 70, 60, 50), ['b']),
|
||||
de: mkNation('de', NS(10, 50, 50, 50), ['c']),
|
||||
},
|
||||
{ b: RS(80, 70, 60, 50), c: RS(10, 50, 50, 50) },
|
||||
['a'],
|
||||
);
|
||||
const next = applyTurnResult(
|
||||
state,
|
||||
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
|
||||
);
|
||||
expect(next.nations.de.territory).toContain('a');
|
||||
expect(next.unowned).toEqual([]); // 'a' leaves no man's land
|
||||
expect(next.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('boundary: gap exactly CONQUEST_MILITARY_MARGIN proceeds; one more point is repelled', () => {
|
||||
expect(CONQUEST_MILITARY_MARGIN).toBe(15);
|
||||
// uk 65 vs de 50 → gap 15 (== margin) → proceeds
|
||||
{
|
||||
const state = mkTurnState(
|
||||
{ uk: mkNation('uk', NS(65, 70, 60, 50), ['a']), de: mkNation('de', NS(50, 50, 50, 50), ['b']) },
|
||||
{ a: RS(65, 70, 60, 50), b: RS(50, 50, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }));
|
||||
expect(next.nations.de.territory).toContain('a');
|
||||
expect(next.events).toHaveLength(0);
|
||||
}
|
||||
// uk 66 vs de 50 → gap 16 (margin + 1) → repelled
|
||||
{
|
||||
const state = mkTurnState(
|
||||
{ uk: mkNation('uk', NS(66, 70, 60, 50), ['a']), de: mkNation('de', NS(50, 50, 50, 50), ['b']) },
|
||||
{ a: RS(66, 70, 60, 50), b: RS(50, 50, 50, 50) },
|
||||
);
|
||||
const next = applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }));
|
||||
expect(next.nations.uk.territory).toContain('a');
|
||||
expect(next.events.some((e) => e.outcome === 'repelled')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt — stats mechanics (v0.30 #50)', () => {
|
||||
it('both tool and plain modes document the hard gate, the stability takeover, and economy growth/decay', () => {
|
||||
for (const useTools of [true, false]) {
|
||||
const prompt = buildSystemPrompt(preset, useTools);
|
||||
// section header + the military hard gate
|
||||
expect(prompt).toMatch(/HOW THE FOUR 0-100 STATS DRIVE OUTCOMES/);
|
||||
expect(prompt).toMatch(/MILITARY/);
|
||||
expect(prompt).toMatch(/the engine ENFORCES/);
|
||||
expect(prompt).toMatch(/MORE than 15 below the defender is REPELLED/);
|
||||
expect(prompt).toMatch(/no man's land/);
|
||||
// the soft stability takeover
|
||||
expect(prompt).toMatch(/STABILITY/);
|
||||
expect(prompt).toMatch(/stability is below 30/);
|
||||
expect(prompt).toMatch(/gathering/);
|
||||
expect(prompt).toMatch(/NON-military takeover/i);
|
||||
// the economy growth / decay lever
|
||||
expect(prompt).toMatch(/ECONOMY/);
|
||||
expect(prompt).toMatch(/trend UP/);
|
||||
expect(prompt).toMatch(/DECAY/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserPrompt — frontier balance + takeover risks (v0.30 #50)', () => {
|
||||
const pending: RoundItem[] = [{ kind: 'action', text: 'x' }];
|
||||
|
||||
it('emits a military-balance hint per frontier neighbour (repelled vs could succeed)', () => {
|
||||
// player uk (military 50) borders de (60, within 15 → could succeed) and
|
||||
// us (90, 40 above → repelled).
|
||||
const state = mkTurnState(
|
||||
{
|
||||
uk: mkNation('uk', NS(50, 70, 60, 50), ['a']),
|
||||
de: mkNation('de', NS(60, 70, 60, 50), ['b']),
|
||||
us: mkNation('us', NS(90, 70, 60, 50), ['c']),
|
||||
},
|
||||
{ a: RS(50, 70, 60, 50), b: RS(60, 70, 60, 50), c: RS(90, 70, 60, 50) },
|
||||
);
|
||||
const prompt = buildUserPrompt(state, pending, undefined, ['b', 'c']);
|
||||
expect(prompt).toMatch(/\[Your military: 50\] vs \[DE: 60\] — a direct invasion could succeed/);
|
||||
expect(prompt).toMatch(/\[Your military: 50\] vs \[US: 90\] — a direct invasion would be repelled/);
|
||||
});
|
||||
|
||||
it('does NOT emit the frontier military balance when no frontier is supplied', () => {
|
||||
const state = mkTurnState({ uk: mkNation('uk', NS(50, 70, 60, 50), ['a']) }, { a: RS(50, 70, 60, 50) });
|
||||
const prompt = buildUserPrompt(state, pending);
|
||||
expect(prompt).not.toMatch(/frontierMilitary/);
|
||||
});
|
||||
|
||||
it('emits takeover risks for low-stability nations that host a gathering (and not for others)', () => {
|
||||
// de has stability 20 + a gathering marker → at risk. uk has stability 50 → not.
|
||||
const markers: MarkerInfo[] = [
|
||||
{ id: 'g1', type: 'gathering', name: 'Revolt', regionKey: 'b', owner: 'de' },
|
||||
{ id: 'g2', type: 'gathering', name: 'Rally', regionKey: 'a', owner: 'uk' },
|
||||
];
|
||||
const state = mkTurnState(
|
||||
{
|
||||
uk: mkNation('uk', NS(50, 70, 50, 50), ['a']),
|
||||
de: mkNation('de', NS(50, 70, 20, 50), ['b']),
|
||||
},
|
||||
{ a: RS(50, 70, 50, 50), b: RS(50, 70, 20, 50) },
|
||||
);
|
||||
const prompt = buildUserPrompt(state, pending, undefined, undefined, undefined, markers);
|
||||
expect(prompt).toMatch(/takeoverRisks/);
|
||||
expect(prompt).toMatch(/DE \(stability 20, a gathering is in progress\)/);
|
||||
expect(prompt).not.toMatch(/UK \(stability/);
|
||||
});
|
||||
|
||||
it('does NOT emit takeover risks when no markers exist', () => {
|
||||
const state = mkTurnState({ uk: mkNation('uk', NS(50, 70, 20, 50), ['a']) }, { a: RS(50, 70, 20, 50) });
|
||||
const prompt = buildUserPrompt(state, pending);
|
||||
expect(prompt).not.toMatch(/takeoverRisks/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt — takeovers contract (v0.32.0 #54)', () => {
|
||||
it('both modes document the takeovers field and the automatic (NOT player-confirmed) rule', () => {
|
||||
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||||
// the JSON shape carries the field in both modes
|
||||
expect(p).toMatch(
|
||||
/"takeovers": \[\{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"\}\]/,
|
||||
);
|
||||
// the rule: a takeover is an automatic in-world regime change, not a proposal
|
||||
expect(p).toMatch(/Takeover \(automatic, NOT player-confirmed\)/);
|
||||
// and it is tied to the stability + gathering mechanic
|
||||
expect(p).toMatch(/low-stability nation/);
|
||||
expect(p).toMatch(/gathering/);
|
||||
// and it is distinguished from the player-confirmed nationOps rename
|
||||
expect(p).toMatch(/use "nationOps" rename only for a diplomatic\/political name change/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTakeovers (v0.32.0 #54)', () => {
|
||||
it('parses valid entries (trimmed, capped) and drops invalid ones', () => {
|
||||
const takeovers = validateTakeovers([
|
||||
{ nationRef: ' de ', newRegimeName: 'The Reich' },
|
||||
{ nationRef: 'uk', newRegimeName: ' ' }, // empty name -> dropped
|
||||
{ nationRef: '', newRegimeName: 'X' }, // empty ref -> dropped
|
||||
{ newRegimeName: 'no ref' }, // missing nationRef -> dropped
|
||||
{ nationRef: 'us' }, // missing newRegimeName -> dropped
|
||||
'not-an-object',
|
||||
null,
|
||||
42,
|
||||
]);
|
||||
expect(takeovers).toEqual([{ nationRef: 'de', newRegimeName: 'The Reich' }]);
|
||||
});
|
||||
|
||||
it('caps both fields at 200 characters', () => {
|
||||
const long = 'x'.repeat(250);
|
||||
const takeovers = validateTakeovers([{ nationRef: long, newRegimeName: long }]);
|
||||
expect(takeovers).toEqual([{ nationRef: 'x'.repeat(200), newRegimeName: 'x'.repeat(200) }]);
|
||||
});
|
||||
|
||||
it('returns [] for non-array input and is never thrown by', () => {
|
||||
expect(validateTakeovers(undefined)).toEqual([]);
|
||||
expect(validateTakeovers('nope')).toEqual([]);
|
||||
expect(validateTakeovers({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('validateTurnResult carries validated takeovers through (and defaults to [] when absent)', () => {
|
||||
const withTakeovers = validateTurnResult({
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withTakeovers).not.toBeNull();
|
||||
// the valid one survives, the empty-name one is dropped
|
||||
expect(withTakeovers!.takeovers).toEqual([{ nationRef: 'de', newRegimeName: 'The Reich' }]);
|
||||
|
||||
const without = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
|
||||
expect(without).not.toBeNull();
|
||||
expect(without!.takeovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stub — takeovers (v0.32.0 #54)', () => {
|
||||
it('the deterministic stub returns takeovers: [] (it never performs a takeover)', () => {
|
||||
const payload = {
|
||||
state: {
|
||||
turn: 1,
|
||||
date: '1939-09-01',
|
||||
playerNationId: 'uk',
|
||||
nations: {
|
||||
uk: { stats: { military: 50, economy: 50, stability: 50, population: 50 }, territory: ['a'] },
|
||||
de: { stats: { military: 60, economy: 60, stability: 60, population: 60 }, territory: ['b'] },
|
||||
},
|
||||
},
|
||||
pending: [{ kind: 'action' as const, text: 'test' }],
|
||||
};
|
||||
const result = deterministicTurn(payload);
|
||||
expect(result.takeovers).toEqual([]);
|
||||
expect(result).toHaveProperty('takeovers');
|
||||
});
|
||||
});
|
||||
|
||||
+169
-2
@@ -19,6 +19,7 @@ import type {
|
||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||
import {
|
||||
addDays,
|
||||
canConquer,
|
||||
clampStat,
|
||||
deriveNationStats,
|
||||
fnv1a32,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type MarkerSpec,
|
||||
type NationOp,
|
||||
type RegionStatChange,
|
||||
type Takeover,
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
} from '../shared/protocol';
|
||||
@@ -198,9 +200,15 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
||||
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||||
' ],',
|
||||
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
|
||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||
'}',
|
||||
'',
|
||||
'HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)',
|
||||
'- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender\'s military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man\'s land) region is never blocked this way.',
|
||||
'- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.',
|
||||
'- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation\'s other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.',
|
||||
'',
|
||||
'HARD CONSTRAINTS',
|
||||
'- Use only nation ids and region keys that exist in the provided state.',
|
||||
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
|
||||
@@ -210,6 +218,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
||||
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
||||
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
||||
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.',
|
||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||
'- Keep every stat value between 0 and 100.',
|
||||
@@ -280,6 +289,63 @@ export function buildUserPrompt(
|
||||
}
|
||||
}
|
||||
|
||||
// (v0.30, #50) A per-neighbor military-balance hint for the player's frontier,
|
||||
// so the model — and, via the same data, the player — sees the hard gate
|
||||
// before it fires: for each frontier neighbor, compare the player's derived
|
||||
// military to the neighbor's and state whether a direct invasion would be
|
||||
// repelled (via canConquer). Computed here (pure) from `state` + `frontier`;
|
||||
// emitted only when a non-empty frontier is present, and the rest of the
|
||||
// payload is left untouched.
|
||||
let frontierMilitary: string[] | undefined;
|
||||
if (frontier !== undefined && frontier.length > 0) {
|
||||
const playerMil = player?.stats.military ?? 0;
|
||||
const ownerByKey = new Map<string, string>(); // region key -> owning nation id
|
||||
for (const n of Object.values(state.nations)) {
|
||||
for (const k of n.territory) ownerByKey.set(k, n.id);
|
||||
}
|
||||
const byNeighbor = new Map<string, number>(); // neighbor id -> its military (deduped)
|
||||
for (const k of frontier) {
|
||||
const ownerId = ownerByKey.get(k);
|
||||
if (!ownerId || ownerId === state.playerNationId) continue; // unowned / self: skip
|
||||
const owner = state.nations[ownerId];
|
||||
if (!owner) continue;
|
||||
if (!byNeighbor.has(ownerId)) byNeighbor.set(ownerId, owner.stats.military);
|
||||
}
|
||||
if (byNeighbor.size > 0) {
|
||||
frontierMilitary = [...byNeighbor.entries()].map(([ownerId, mil]) => {
|
||||
const o = state.nations[ownerId];
|
||||
const wins = canConquer(playerMil, mil);
|
||||
return `[Your military: ${playerMil}] vs [${o?.name ?? ownerId}: ${mil}] — ${
|
||||
wins ? 'a direct invasion could succeed' : 'a direct invasion would be repelled'
|
||||
}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30
|
||||
// AND that currently have a "gathering" marker on one of their regions are
|
||||
// the ones ripe for a NON-military takeover (a coup / revolution / faction
|
||||
// seizure) — the model can act on them this turn. One line per matching
|
||||
// nation, emitted only when there is at least one; the rest of the payload
|
||||
// is left untouched. A soft nudge only — there is no hard gate behind it.
|
||||
let takeoverRisks: string[] | undefined;
|
||||
if (markers !== undefined && markers.length > 0) {
|
||||
const risks = new Set<string>();
|
||||
for (const m of markers) {
|
||||
if (m.type !== 'gathering') continue;
|
||||
const ownerId = m.owner;
|
||||
if (!ownerId) continue;
|
||||
const n = state.nations[ownerId];
|
||||
if (n && n.stats.stability < 30) risks.add(n.id);
|
||||
}
|
||||
if (risks.size > 0) {
|
||||
takeoverRisks = [...risks].map((id) => {
|
||||
const n = state.nations[id];
|
||||
return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
state: {
|
||||
turn: state.turn,
|
||||
@@ -305,6 +371,8 @@ export function buildUserPrompt(
|
||||
};
|
||||
if (regionReference) payload.regions = regionReference;
|
||||
if (frontier !== undefined) payload.frontier = frontier;
|
||||
if (frontierMilitary !== undefined) payload.frontierMilitary = frontierMilitary;
|
||||
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
|
||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||
if (markers !== undefined) payload.markers = markers;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
@@ -444,6 +512,7 @@ export function validateTurnResult(
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
@@ -534,6 +603,10 @@ export function validateTurnResult(
|
||||
// list for the player to confirm/reject.
|
||||
result.nationOps = validateNationOps(o.nationOps);
|
||||
|
||||
// (v0.32.0, #54) Non-military regime changes (takeovers) — applied directly
|
||||
// by applyTurnResult (automatic, not player-confirmed).
|
||||
result.takeovers = validateTakeovers(o.takeovers);
|
||||
|
||||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||
// removals by stable id. Applied by applyTurnResult after the territory
|
||||
// moves (a new marker's owner is the region's post-turn owner).
|
||||
@@ -640,6 +713,29 @@ export function validateRegionChanges(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
|
||||
* {@link Takeover} list — the non-military regime-change lever (a coup or
|
||||
* revolution that renames a nation to its new regime name). Defensive like
|
||||
* the rest of the parser — never throws. Each entry needs a non-empty
|
||||
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
|
||||
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
|
||||
* to a concrete nation id happens at apply time (`resolveNationRef`), not
|
||||
* here. Entries with either field missing or whitespace-only are dropped.
|
||||
*/
|
||||
export function validateTakeovers(raw: unknown): Takeover[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: Takeover[] = [];
|
||||
for (const e of raw) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const o = e as Record<string, unknown>;
|
||||
const nationRef = str(o.nationRef, 200);
|
||||
const newRegimeName = str(o.newRegimeName, 200);
|
||||
if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
||||
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||
@@ -656,6 +752,8 @@ export function isMeaningful(result: TurnResult): boolean {
|
||||
result.regionChanges.length > 0 ||
|
||||
result.unowned.length > 0 ||
|
||||
result.nationOps.length > 0 ||
|
||||
// (v0.32.0, #54) a takeover is a real world change (regime rename).
|
||||
result.takeovers.length > 0 ||
|
||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||
result.markers.add.length > 0 ||
|
||||
result.markers.remove.length > 0
|
||||
@@ -695,6 +793,11 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
||||
* area×population-weighted median of its regions' stats. Without it the
|
||||
* recompute degrades to an unweighted median (every region weight 1) — still
|
||||
* region-consistent, just unweighted. The engine stays pure either way.
|
||||
*
|
||||
* `regionNameOf` (v0.30, #50) resolves a region key to its human display name
|
||||
* (from the geo `properties.name`, via server/regions.ts) for the text of the
|
||||
* engine-emitted "invasion repelled" event; without it the event falls back to
|
||||
* the raw key. Pure either way.
|
||||
*/
|
||||
export function applyTurnResult(
|
||||
state: GameState,
|
||||
@@ -702,6 +805,7 @@ export function applyTurnResult(
|
||||
validKeys?: ReadonlySet<string>,
|
||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||
regionNameOf?: (key: string) => string | undefined,
|
||||
): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||
@@ -809,6 +913,34 @@ export function applyTurnResult(
|
||||
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
||||
if (owner === '') continue; // pre-existing double ownership: never touched
|
||||
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
|
||||
// (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a
|
||||
// different sole owner) only proceeds when the attacker's derived military
|
||||
// is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A
|
||||
// re-claim of an unowned key (owner === undefined) is never gated. On
|
||||
// repel, the key stays with its owner, a "repelled" event is emitted (the
|
||||
// engine's own narrative — the model never sees it as an input), and the
|
||||
// gainer is untouched; the engine never throws.
|
||||
if (
|
||||
owner !== undefined &&
|
||||
!canConquer(next.nations[gainer].stats.military, next.nations[owner].stats.military)
|
||||
) {
|
||||
next.events.push(
|
||||
makeEvent(
|
||||
{
|
||||
nation: gainer,
|
||||
type: 'military',
|
||||
title: 'Invasion repelled',
|
||||
text: `${next.nations[gainer].name}'s invasion of ${
|
||||
regionNameOf?.(key) ?? key
|
||||
} was repelled by ${next.nations[owner].name}.`,
|
||||
outcome: 'repelled',
|
||||
},
|
||||
actingTurn,
|
||||
next.date,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const n of Object.values(next.nations)) {
|
||||
const i = n.territory.indexOf(key);
|
||||
if (i !== -1) n.territory.splice(i, 1);
|
||||
@@ -874,6 +1006,30 @@ export function applyTurnResult(
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
|
||||
// --- Takeovers (v0.32.0, #54): non-military regime changes (coups /
|
||||
// revolutions) that rename a nation to its new regime. Applied AFTER the
|
||||
// per-region recompute (the rename doesn't depend on territory or stats,
|
||||
// so it is order-independent with respect to those steps) and BEFORE the
|
||||
// markers block (a marker's owner is a nation id, not a name, so the
|
||||
// rename does not affect the owner recompute).
|
||||
//
|
||||
// For each takeover:
|
||||
// 1. Resolve the target nation via `resolveNationRef` (id first, then
|
||||
// case-insensitive unique name). Skip (no-op) if `null` — an
|
||||
// unknown or ambiguous ref is silently dropped, never thrown.
|
||||
// 2. Rename the resolved nation: set `n.name = newRegimeName`. Only
|
||||
// the display name changes — the stable id, territory, relations
|
||||
// (keyed by id), stats, and `playerNationId` (which references the
|
||||
// id, not the name) are all preserved. The player keeps leading an
|
||||
// overthrown nation under its new regime.
|
||||
for (const t of result.takeovers) {
|
||||
const id = resolveNationRef(next, t.nationRef);
|
||||
if (id === null) continue; // unknown / ambiguous ref: no-op
|
||||
const n = next.nations[id];
|
||||
if (!n) continue; // defensive: id resolved but nation missing (corrupt state)
|
||||
n.name = t.newRegimeName;
|
||||
}
|
||||
|
||||
// --- Map markers (v0.27, #44) ---
|
||||
//
|
||||
// Markers are anchored to a region key and are processed against the FINAL
|
||||
@@ -1325,8 +1481,15 @@ export async function runTurn(opts: {
|
||||
* applyTurnResult's regionStats lookup).
|
||||
*/
|
||||
regionKeyResolver?: (ref: string) => string | null;
|
||||
/**
|
||||
* (v0.30, #50) Region-key -> human display name resolver (the geo
|
||||
* `properties.name`, from server/regions.ts). Present, the engine's
|
||||
* "invasion repelled" event names the region by its display name; absent,
|
||||
* it falls back to the raw region key. Pure either way.
|
||||
*/
|
||||
regionNameOf?: (key: string) => string | undefined;
|
||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver } = opts;
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf } = opts;
|
||||
const chatWithTools = llm.chatWithTools;
|
||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||
const system = buildSystemPrompt(preset, useTools);
|
||||
@@ -1394,11 +1557,15 @@ export async function runTurn(opts: {
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
|
||||
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf), result };
|
||||
return {
|
||||
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||
|
||||
+133
-5
@@ -17,13 +17,17 @@ import type {
|
||||
RejectNationOpRequest,
|
||||
RemoveQueueItemRequest,
|
||||
} from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
import type { GameState, Preset } from '../shared/types';
|
||||
import type { WorldDraft } from '../shared/scenario';
|
||||
import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { makeScenarioStubLlmClient } from './stubScenario';
|
||||
import { finalizeWorld, generateDraft } from './scenario';
|
||||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
|
||||
import { buildRegionMap } from './map';
|
||||
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validKeys } from './regions';
|
||||
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validCountryCodes, validKeys } from './regions';
|
||||
import { createPresetStore } from './presets';
|
||||
import { createTemplateStore } from './templates';
|
||||
import { openDb, type Db } from './db';
|
||||
|
||||
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||
@@ -52,6 +56,8 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
||||
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
||||
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
||||
const presetStore = createPresetStore(presetsDir);
|
||||
// (v0.31, #52) Era templates for the "Create scenario" path (presets/templates/).
|
||||
const templateStore = createTemplateStore(path.join(root, 'presets', 'templates'));
|
||||
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
|
||||
// just means region expansion/reference degrade to the raw keys (server still boots).
|
||||
loadRegions(root);
|
||||
@@ -59,6 +65,10 @@ const db: Db = openDb(path.join(dataDir, 'history.db'));
|
||||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||
const useStub = process.env.LLM_STUB === '1';
|
||||
const llm = useStub ? makeStubLlmClient() : createLlmClient();
|
||||
// (v0.31, #52) The scenario-generation LLM client: the deterministic scenario
|
||||
// stub under LLM_STUB=1 (a distinct transport from the turn stub, which stays
|
||||
// untouched), and the live client otherwise.
|
||||
const scenarioLlm = useStub ? makeScenarioStubLlmClient() : llm;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -95,6 +105,34 @@ function broadcast(id: string, event: string, data: unknown): void {
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
/**
|
||||
* (v0.31, #52) Synthesize the minimal `Preset` the turn engine needs for a
|
||||
* dynamically generated game (`presetId: 'dynamic'` — there is no static preset
|
||||
* file on disk). `runTurn` only consumes `title` / `startDate` /
|
||||
* `worldBackground` / `simulationRules` (never `preset.nations`), so a generic
|
||||
* alternate-history background plus the standard rules is sufficient; `nations`
|
||||
* is carried from the saved world so the type is complete. `startDate` uses the
|
||||
* game's current date (GameState stores no separate start date) — it only feeds
|
||||
* the static "WORLD: <title> — <date>" header of the system prompt.
|
||||
*/
|
||||
function dynamicPresetFromGame(game: GameState): Preset {
|
||||
return {
|
||||
id: game.presetId,
|
||||
title: game.presetTitle,
|
||||
startDate: game.date,
|
||||
summary: game.presetTitle,
|
||||
worldBackground: `${game.presetTitle} is a dynamically generated alternate-history world. Keep every nation's stats, borders and relations internally consistent as the world evolves.`,
|
||||
simulationRules: [
|
||||
"Advance the shared world for ALL nations, not only the player's.",
|
||||
'Keep every stat value between 0 and 100.',
|
||||
'Only change borders when war or a decisive political shift justifies it.',
|
||||
'Prefer 1-4 events per turn; keep each narrative to 1-3 sentences.',
|
||||
'Use only nation ids and region keys that exist in the provided state.',
|
||||
],
|
||||
nations: Object.values(game.nations),
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true, stub: useStub, port: PORT });
|
||||
});
|
||||
@@ -278,8 +316,14 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const game = db.load(id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
const preset = presetStore.get(game.presetId);
|
||||
if (!preset) return res.status(404).json({ error: 'preset_missing' });
|
||||
let preset = presetStore.get(game.presetId);
|
||||
if (!preset) {
|
||||
// (v0.31, #52) A dynamically generated game has no static preset file:
|
||||
// synthesize one from the saved world so the turn engine can run. A
|
||||
// genuinely-missing static preset still 404s.
|
||||
if (game.presetId !== 'dynamic') return res.status(404).json({ error: 'preset_missing' });
|
||||
preset = dynamicPresetFromGame(game);
|
||||
}
|
||||
try {
|
||||
const { state, result } = await runTurn({
|
||||
llm,
|
||||
@@ -314,6 +358,10 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
||||
// pure (validKeys + regionName) and degrades to a key-only match when
|
||||
// the manifest is absent.
|
||||
regionKeyResolver: (ref) => resolveRegionKey(ref, validKeys(), (k) => regionName(k)),
|
||||
// (v0.30, #50) region display name for the engine's "invasion repelled"
|
||||
// event (geo properties.name, else the raw key). Always on — the
|
||||
// resolver is pure and degrades to the key when the manifest is absent.
|
||||
regionNameOf: (k) => regionName(k),
|
||||
});
|
||||
db.save(state);
|
||||
broadcast(id, 'turn', { state, result });
|
||||
@@ -343,6 +391,86 @@ app.post('/api/games/:id/advisor', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Dynamic scenario generation (v0.31, #52) ---
|
||||
//
|
||||
// The additive "Create scenario" path. Step 1 (POST /draft) designs a draft
|
||||
// world + an advisor checkpoint list; the client shows both, the player
|
||||
// approves/removes checkpoints and picks a nation; step 2 (POST /commit)
|
||||
// finalizes the world (constrained to the approved checkpoints) into a saved,
|
||||
// playable game. Both steps ride the scenario LLM client (the deterministic
|
||||
// scenario stub under LLM_STUB=1, the live model otherwise). All existing
|
||||
// routes are untouched.
|
||||
|
||||
app.get('/api/scenarios/templates', (_req, res) => {
|
||||
res.json({ templates: templateStore.listMeta() });
|
||||
});
|
||||
|
||||
app.post('/api/scenarios/draft', async (req, res) => {
|
||||
const body = (req.body ?? {}) as { templateId?: string; description?: string; year?: number };
|
||||
const template = templateStore.get(body.templateId ?? '');
|
||||
if (!template) return res.status(400).json({ error: 'unknown template' });
|
||||
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
||||
const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year;
|
||||
const codes = validCountryCodes();
|
||||
try {
|
||||
const { world, checkpoints } = await generateDraft({
|
||||
llm: scenarioLlm,
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
// No region data → no code filtering (the draft keeps whatever codes the
|
||||
// model emitted; expansion degrades to the raw keys, like presets do).
|
||||
validCountryCodes: codes.size > 0 ? codes : undefined,
|
||||
});
|
||||
if (!world) return res.status(502).json({ error: 'generate: the model returned no usable world' });
|
||||
res.json({ world, checkpoints });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(502).json({ error: `generate failed: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/scenarios/commit', async (req, res) => {
|
||||
const body = (req.body ?? {}) as {
|
||||
templateId?: string;
|
||||
description?: string;
|
||||
year?: number;
|
||||
draft?: WorldDraft;
|
||||
approvedCheckpoints?: string[];
|
||||
playerNationId?: string;
|
||||
};
|
||||
const template = templateStore.get(body.templateId ?? '');
|
||||
if (!template) return res.status(400).json({ error: 'unknown template' });
|
||||
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
||||
const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year;
|
||||
const approvedCheckpoints = Array.isArray(body.approvedCheckpoints)
|
||||
? body.approvedCheckpoints.filter((c): c is string => typeof c === 'string' && c.trim() !== '')
|
||||
: [];
|
||||
const playerNationId = typeof body.playerNationId === 'string' ? body.playerNationId : '';
|
||||
const codes = validCountryCodes();
|
||||
try {
|
||||
const state = await finalizeWorld({
|
||||
llm: scenarioLlm,
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
draft: body.draft,
|
||||
playerNationId,
|
||||
validCountryCodes: codes.size > 0 ? codes : undefined,
|
||||
expand: (cs) => expandCountriesToKeys(cs),
|
||||
id: randomUUID(),
|
||||
title: template.name,
|
||||
startDate: `${year}-01-01`,
|
||||
});
|
||||
db.save(state);
|
||||
res.json({ id: state.id });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(502).json({ error: `commit failed: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSE stream ---
|
||||
app.get('/sse/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
@@ -380,4 +508,4 @@ app.listen(PORT, () => {
|
||||
});
|
||||
|
||||
// Exported for tests (createApp is implicit via app; here we just expose handles).
|
||||
export { app, db, llm };
|
||||
export { app, db, llm, scenarioLlm, templateStore };
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canConquer,
|
||||
CONQUEST_MILITARY_MARGIN,
|
||||
deriveNationStats,
|
||||
migrateRegionStats,
|
||||
seedRegionStats,
|
||||
@@ -218,3 +220,40 @@ describe('migrateRegionStats', () => {
|
||||
expect('uk' in (out.regionStats ?? {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- canConquer (v0.30, #50: the military hard gate) -------------------------
|
||||
|
||||
describe('canConquer', () => {
|
||||
it('the default margin constant is 15', () => {
|
||||
expect(CONQUEST_MILITARY_MARGIN).toBe(15);
|
||||
});
|
||||
|
||||
it('an attacker within the margin of the defender proceeds (gap == margin)', () => {
|
||||
// attacker 10, defender 25 → gap of 15 == margin → win (not more than 15 below)
|
||||
expect(canConquer(10, 25)).toBe(true);
|
||||
});
|
||||
|
||||
it('an attacker more than the margin below the defender is repelled (gap == margin+1)', () => {
|
||||
// attacker 9, defender 25 → gap of 16 > margin → repel
|
||||
expect(canConquer(9, 25)).toBe(false);
|
||||
});
|
||||
|
||||
it('an equal attacker proceeds', () => {
|
||||
expect(canConquer(50, 50)).toBe(true);
|
||||
});
|
||||
|
||||
it('an attacker within the margin (gap < margin) proceeds', () => {
|
||||
expect(canConquer(30, 40)).toBe(true); // gap 10
|
||||
expect(canConquer(14, 25)).toBe(true); // gap 11
|
||||
});
|
||||
|
||||
it('an attacker ahead of the defender always proceeds', () => {
|
||||
expect(canConquer(90, 10)).toBe(true);
|
||||
});
|
||||
|
||||
it('a margin of 0 means only an equal-or-ahead attacker proceeds', () => {
|
||||
expect(canConquer(50, 50, 0)).toBe(true); // equal
|
||||
expect(canConquer(51, 50, 0)).toBe(true); // ahead
|
||||
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,6 +259,18 @@ export function validKeys(): ReadonlySet<string> {
|
||||
return data ? data.validKeys : new Set<string>();
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of ISO_A3 country codes that own at least one sub-region in the
|
||||
* manifest (the valid `territory` codes for a world draft, v0.31 #52). These
|
||||
* are the keys of `keysByCountry` — i.e. the distinct `properties.parent`
|
||||
* values in regions.geo.json. Empty when no region data is loaded; callers
|
||||
* pass it (or `undefined` to skip filtering) to `validateWorldDraft` so a
|
||||
* draft's territory is checked against real countries.
|
||||
*/
|
||||
export function validCountryCodes(): ReadonlySet<string> {
|
||||
return data ? new Set(Object.keys(data.keysByCountry)) : new Set<string>();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the region-adjacency manifest loaded with at least one entry.
|
||||
* Callers (the server's jump route) use this to decide whether to hand the
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
relationFromScore,
|
||||
validateWorldDraft,
|
||||
worldDraftToGameState,
|
||||
type WorldDraft,
|
||||
} from '../shared/scenario';
|
||||
import type { NationStats } from '../shared/types';
|
||||
|
||||
// A deterministic fake ISO_A3 -> region-key expander (two sub-regions per
|
||||
// country), standing in for server/regions.expandCountriesToKeys in these
|
||||
// pure-function tests.
|
||||
const fakeExpand = (countries: string[]): string[] =>
|
||||
countries.flatMap((c) => [`${c}-a`, `${c}-b`]);
|
||||
|
||||
const S = (m: number, e: number, s: number, p: number): NationStats => ({
|
||||
military: m,
|
||||
economy: e,
|
||||
stability: s,
|
||||
population: p,
|
||||
});
|
||||
|
||||
describe('relationFromScore (v0.31.0, #52)', () => {
|
||||
it('maps the five sentiment bands to RelationStatus', () => {
|
||||
expect(relationFromScore(100)).toBe('ally');
|
||||
expect(relationFromScore(60)).toBe('ally');
|
||||
expect(relationFromScore(0)).toBe('neutral');
|
||||
expect(relationFromScore(30)).toBe('neutral');
|
||||
expect(relationFromScore(-1)).toBe('rival');
|
||||
expect(relationFromScore(-40)).toBe('rival');
|
||||
expect(relationFromScore(-41)).toBe('hostile');
|
||||
expect(relationFromScore(-80)).toBe('hostile');
|
||||
expect(relationFromScore(-81)).toBe('war');
|
||||
expect(relationFromScore(-100)).toBe('war');
|
||||
});
|
||||
|
||||
it('clamps out-of-range scores before banding', () => {
|
||||
expect(relationFromScore(250)).toBe('ally');
|
||||
expect(relationFromScore(-250)).toBe('war');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWorldDraft (v0.31.0, #52)', () => {
|
||||
it('returns a clean draft from a valid payload', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ id: 'de', name: 'German Reich', type: 'major', stats: S(95, 70, 75, 90), territory: ['DEU'], relations: { uk: 80 } },
|
||||
{ id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: 80 } },
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw);
|
||||
expect(d).not.toBeNull();
|
||||
const draft = d as WorldDraft;
|
||||
expect(draft.nations).toHaveLength(2);
|
||||
expect(draft.nations[0]).toMatchObject({ id: 'de', name: 'German Reich', type: 'major' });
|
||||
expect(draft.nations[0].stats).toEqual(S(95, 70, 75, 90));
|
||||
expect(draft.nations[0].territory).toEqual(['DEU']);
|
||||
expect(draft.nations[0].relations).toEqual({ uk: 80 });
|
||||
expect(draft.events).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clamps out-of-range stat values to 0-100 and defaults missing to 50', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ name: 'Over', stats: { military: 150, economy: -20, stability: 7 }, territory: ['DEU'] },
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
expect(d.nations[0].stats).toEqual(S(100, 0, 7, 50)); // population missing -> 50
|
||||
});
|
||||
|
||||
it('upper-cases, de-dupes, and (given valid codes) filters territory to known ISO_A3', () => {
|
||||
const codes = new Set(['DEU', 'GBR', 'FRA']);
|
||||
const raw = {
|
||||
nations: [
|
||||
{
|
||||
name: 'Mixed',
|
||||
// lower-case, duplicated, and one unknown code (USA) to be dropped.
|
||||
territory: ['deu', 'DEU', 'usa', 'gbr'],
|
||||
},
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw, { validCountryCodes: codes }) as WorldDraft;
|
||||
expect(d.nations[0].territory).toEqual(['DEU', 'GBR']);
|
||||
});
|
||||
|
||||
it('keeps unknown codes when no validCountryCodes set is supplied', () => {
|
||||
const raw = { nations: [{ name: 'X', territory: ['AAA', 'BBB'] }] };
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
expect(d.nations[0].territory).toEqual(['AAA', 'BBB']);
|
||||
});
|
||||
|
||||
it('resolves relation keys by id or (case-insensitive) name and drops self/unknown refs', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ id: 'de', name: 'Germany', territory: ['DEU'], relations: { 'United Kingdom': 55, de: 99, ghost: 10 } },
|
||||
{ id: 'uk', name: 'United Kingdom', territory: ['GBR'], relations: { de: -70 } },
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
// de: the name-keyed ref resolves to uk, self (de) and unknown (ghost) dropped.
|
||||
expect(d.nations[0].relations).toEqual({ uk: 55 });
|
||||
// uk: the id-keyed ref to de is kept, clamped (already in range).
|
||||
expect(d.nations[1].relations).toEqual({ de: -70 });
|
||||
});
|
||||
|
||||
it('clamps relation scores to -100..100', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ id: 'a', name: 'A', territory: ['AAA'], relations: { b: 500 } },
|
||||
{ id: 'b', name: 'B', territory: ['BBB'], relations: { a: -500 } },
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
expect(d.nations[0].relations).toEqual({ b: 100 });
|
||||
expect(d.nations[1].relations).toEqual({ a: -100 });
|
||||
});
|
||||
|
||||
it('suffices duplicate ids until unique and derives ids from names', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ id: 'x', name: 'First', territory: ['AAA'] },
|
||||
{ id: 'x', name: 'Second', territory: ['BBB'] },
|
||||
{ name: 'United Kingdom', territory: ['GBR'] }, // no id -> slug
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
const ids = d.nations.map((n) => n.id);
|
||||
expect(ids).toEqual(['x', 'x-2', 'united-kingdom']);
|
||||
});
|
||||
|
||||
it('keeps backstory events that name a known nation and drops the rest', () => {
|
||||
const raw = {
|
||||
nations: [
|
||||
{ id: 'de', name: 'Germany', territory: ['DEU'] },
|
||||
{ id: 'uk', name: 'United Kingdom', territory: ['GBR'] },
|
||||
],
|
||||
events: [
|
||||
{ nation: 'de', type: 'military', title: 'Remilitarization', text: 't', outcome: 'success' },
|
||||
{ nation: 'United Kingdom', type: 'political', title: 'By-elections', text: 't', outcome: 'neutral' },
|
||||
{ nation: 'ghost', type: 'political', title: 'No owner', text: 't', outcome: 'neutral' },
|
||||
{ nation: 'de', type: 'political' }, // no title -> dropped
|
||||
],
|
||||
};
|
||||
const d = validateWorldDraft(raw) as WorldDraft;
|
||||
expect(d.events).toHaveLength(2);
|
||||
expect(d.events?.[0]).toMatchObject({ nation: 'de', type: 'military', title: 'Remilitarization', outcome: 'success' });
|
||||
expect(d.events?.[1]).toMatchObject({ nation: 'uk', type: 'political', title: 'By-elections', outcome: 'neutral' });
|
||||
});
|
||||
|
||||
it('returns null (never throws) for unusable / garbage payloads', () => {
|
||||
expect(validateWorldDraft(null)).toBeNull();
|
||||
expect(validateWorldDraft('nope')).toBeNull();
|
||||
expect(validateWorldDraft([])).toBeNull();
|
||||
expect(validateWorldDraft({})).toBeNull();
|
||||
expect(validateWorldDraft({ nations: [] })).toBeNull();
|
||||
expect(validateWorldDraft({ nations: [{ name: 'No territory' }] })).toBeNull();
|
||||
expect(validateWorldDraft({ nations: [{}] })).toBeNull(); // no name
|
||||
expect(validateWorldDraft({ nations: [42] })).toBeNull(); // not an object
|
||||
// no nation owns a *valid* territory -> unusable.
|
||||
expect(
|
||||
validateWorldDraft(
|
||||
{ nations: [{ name: 'X', territory: ['ZZZ'] }] },
|
||||
{ validCountryCodes: new Set(['DEU']) },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('worldDraftToGameState (v0.31.0, #52)', () => {
|
||||
function draft(): WorldDraft {
|
||||
return {
|
||||
nations: [
|
||||
{ id: 'de', name: 'Germany', type: 'major', stats: S(90, 70, 75, 90), territory: ['DEU'], relations: { uk: 70 } },
|
||||
{ id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: -90 } },
|
||||
],
|
||||
events: [{ nation: 'de', type: 'political', title: 'The new order', text: 'A fresh map is drawn.', outcome: 'neutral' }],
|
||||
};
|
||||
}
|
||||
|
||||
it('expands ISO_A3 territory to region keys and seeds per-region stats', () => {
|
||||
const g = worldDraftToGameState(draft(), fakeExpand, 'uk', {
|
||||
id: 'g1',
|
||||
title: 'Custom World',
|
||||
startDate: '1939-09-01',
|
||||
});
|
||||
expect(g.id).toBe('g1');
|
||||
expect(g.presetTitle).toBe('Custom World');
|
||||
expect(g.date).toBe('1939-09-01');
|
||||
expect(g.turn).toBe(1);
|
||||
expect(g.version).toBe(1);
|
||||
expect(g.playerNationId).toBe('uk');
|
||||
expect(g.unowned).toEqual([]);
|
||||
expect(g.markers).toEqual([]);
|
||||
expect(g.pendingNationOps).toEqual([]);
|
||||
expect(g.pending).toEqual([]);
|
||||
|
||||
// de owns DEU -> two region keys; uk owns GBR+IRL -> four.
|
||||
expect(g.nations.de.territory).toEqual(['DEU-a', 'DEU-b']);
|
||||
expect(g.nations.uk.territory).toEqual(['GBR-a', 'GBR-b', 'IRL-a', 'IRL-b']);
|
||||
|
||||
// Every owned region is seeded with a copy of its owner's stats.
|
||||
expect(g.regionStats['DEU-a']).toEqual(g.nations.de.stats);
|
||||
expect(g.regionStats['IRL-b']).toEqual(g.nations.uk.stats);
|
||||
// No entry for unowned regions.
|
||||
expect(g.regionStats['ZZZ-a']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps numeric relations to RelationStatus', () => {
|
||||
const g = worldDraftToGameState(draft(), fakeExpand, 'de');
|
||||
expect(g.nations.de.relations).toEqual({ uk: 'ally' }); // 70 -> ally
|
||||
expect(g.nations.uk.relations).toEqual({ de: 'war' }); // -90 -> war
|
||||
});
|
||||
|
||||
it('seeds the event feed from backstory events at turn 1 / startDate', () => {
|
||||
const g = worldDraftToGameState(draft(), fakeExpand, 'de', { startDate: '2035-01-01' });
|
||||
expect(g.events).toHaveLength(1);
|
||||
expect(g.events[0]).toMatchObject({
|
||||
turn: 1,
|
||||
date: '2035-01-01',
|
||||
nation: 'de',
|
||||
type: 'political',
|
||||
title: 'The new order',
|
||||
outcome: 'neutral',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the defaults (empty id, Custom World, 1970-01-01) when options are omitted', () => {
|
||||
const g = worldDraftToGameState(draft(), fakeExpand, 'de');
|
||||
expect(g.id).toBe('');
|
||||
expect(g.presetTitle).toBe('Custom World');
|
||||
expect(g.date).toBe('1970-01-01');
|
||||
expect(g.presetId).toBe('dynamic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
// Unit tests for the dynamic scenario-generation LLM steps (v0.31, #52):
|
||||
// the prompt builders, the defensive parsers, generateDraft / finalizeWorld
|
||||
// over an injected LlmClient, and the deterministic scenario stub.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildGeneratePrompt,
|
||||
buildRegeneratePrompt,
|
||||
finalizeWorld,
|
||||
generateDraft,
|
||||
parseGenerate,
|
||||
parseRegenerate,
|
||||
SCENARIO_GENERATE_MARKER,
|
||||
SCENARIO_REGENERATE_MARKER,
|
||||
} from './scenario';
|
||||
import { makeScenarioStubLlmClient, stubWorldFor } from './stubScenario';
|
||||
import type { EraTemplate, WorldDraft } from '../shared/scenario';
|
||||
import type { ChatMessage, LlmClient } from './llm';
|
||||
|
||||
const TEMPLATE: EraTemplate = {
|
||||
id: 'near-future',
|
||||
name: 'Near Future',
|
||||
year: 2035,
|
||||
eraDescription: 'A fractured, multipolar 2035.',
|
||||
suggestedPowers: [
|
||||
{ name: 'United States', region: 'USA', note: 'declining hegemon' },
|
||||
{ name: 'China', region: 'CHN', note: 'rising industrial power' },
|
||||
],
|
||||
tensions: ['Indo-Pacific standoff', 'Climate migration'],
|
||||
};
|
||||
|
||||
const VALID_CODES = new Set(['FRA', 'RUS', 'ESP', 'USA', 'CHN']);
|
||||
|
||||
/** A minimal LlmClient double whose chat() returns a canned string (or calls a handler). */
|
||||
function fakeClient(canned?: string | ((messages: ChatMessage[]) => string)): LlmClient {
|
||||
return {
|
||||
async chat(messages) {
|
||||
if (typeof canned === 'function') return canned(messages);
|
||||
return canned ?? '{"world":{"nations":[]},"checkpoints":[]}';
|
||||
},
|
||||
async listModels() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const GOOD_GENERATE = JSON.stringify({
|
||||
world: {
|
||||
nations: [
|
||||
{
|
||||
id: 'alba',
|
||||
name: 'Alba',
|
||||
type: 'major',
|
||||
stats: { military: 70, economy: 60, stability: 65, population: 55 },
|
||||
territory: ['FRA'],
|
||||
relations: { boreas: -60 },
|
||||
},
|
||||
{
|
||||
id: 'boreas',
|
||||
name: 'Boreas',
|
||||
type: 'major',
|
||||
stats: { military: 55, economy: 70, stability: 50, population: 60 },
|
||||
territory: ['RUS'],
|
||||
relations: { alba: -60 },
|
||||
},
|
||||
],
|
||||
},
|
||||
checkpoints: ['Alba presses its advantage.', 'Boreas fortifies the north.'],
|
||||
});
|
||||
|
||||
const GOOD_REGENERATE = JSON.stringify({
|
||||
world: {
|
||||
nations: [
|
||||
{
|
||||
id: 'alba',
|
||||
name: 'Alba',
|
||||
type: 'major',
|
||||
stats: { military: 70, economy: 60, stability: 70, population: 55 },
|
||||
territory: ['FRA'],
|
||||
relations: { boreas: -70 },
|
||||
},
|
||||
{
|
||||
id: 'boreas',
|
||||
name: 'Boreas',
|
||||
type: 'major',
|
||||
stats: { military: 55, economy: 70, stability: 55, population: 60 },
|
||||
territory: ['RUS'],
|
||||
relations: { alba: -70 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('buildGeneratePrompt', () => {
|
||||
it('embeds the era, description, year and the valid country codes', () => {
|
||||
const { system, user } = buildGeneratePrompt(TEMPLATE, 'a cold war in space', 2041, VALID_CODES);
|
||||
expect(system).toContain(SCENARIO_GENERATE_MARKER);
|
||||
expect(system).toContain('"checkpoints"');
|
||||
expect(user).toContain('near-future');
|
||||
expect(user).toContain('a cold war in space');
|
||||
expect(user).toContain('2041');
|
||||
expect(user).toContain('CHN');
|
||||
expect(user).toContain('validCountryCodes');
|
||||
});
|
||||
|
||||
it('omits the code list when no valid codes are supplied', () => {
|
||||
const { system, user } = buildGeneratePrompt(TEMPLATE, 'desc', 2035, undefined);
|
||||
expect(system).toContain('standard ISO 3166-1 alpha-3');
|
||||
expect(user).not.toContain('validCountryCodes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRegeneratePrompt', () => {
|
||||
it('embeds the approved checkpoints and the draft when present', () => {
|
||||
const draft: WorldDraft = {
|
||||
nations: [
|
||||
{
|
||||
id: 'alba',
|
||||
name: 'Alba',
|
||||
stats: { military: 70, economy: 60, stability: 65, population: 55 },
|
||||
territory: ['FRA'],
|
||||
relations: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const { system, user } = buildRegeneratePrompt(
|
||||
TEMPLATE,
|
||||
'desc',
|
||||
2035,
|
||||
['Alba presses its advantage.'],
|
||||
VALID_CODES,
|
||||
draft,
|
||||
);
|
||||
expect(system).toContain(SCENARIO_REGENERATE_MARKER);
|
||||
expect(user).toContain('approvedCheckpoints');
|
||||
expect(user).toContain('Alba presses its advantage.');
|
||||
expect(user).toContain('draft');
|
||||
expect(user).toContain('alba');
|
||||
});
|
||||
|
||||
it('omits the draft field when none is supplied', () => {
|
||||
const { user } = buildRegeneratePrompt(TEMPLATE, 'desc', 2035, [], VALID_CODES, undefined);
|
||||
expect(user).not.toContain('draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGenerate / parseRegenerate (defensive)', () => {
|
||||
it('parses a valid generate answer into a world + checkpoints', () => {
|
||||
const { world, checkpoints } = parseGenerate(GOOD_GENERATE, VALID_CODES);
|
||||
expect(world).not.toBeNull();
|
||||
expect(world!.nations).toHaveLength(2);
|
||||
expect(world!.nations[0].id).toBe('alba');
|
||||
expect(world!.nations[0].territory).toEqual(['FRA']);
|
||||
expect(checkpoints).toEqual(['Alba presses its advantage.', 'Boreas fortifies the north.']);
|
||||
});
|
||||
|
||||
it('strips code fences and drops unknown country codes', () => {
|
||||
const raw = '```json\n' + GOOD_GENERATE + '\n```';
|
||||
const { world } = parseGenerate(raw, new Set(['FRA'])); // RUS not in the set
|
||||
expect(world).not.toBeNull();
|
||||
expect(world!.nations.find((n) => n.id === 'boreas')?.territory).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null world + empty checkpoints for garbage', () => {
|
||||
const { world, checkpoints } = parseGenerate('not json at all', VALID_CODES);
|
||||
expect(world).toBeNull();
|
||||
expect(checkpoints).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses a valid regenerate answer (no checkpoints echoed)', () => {
|
||||
const world = parseRegenerate(GOOD_REGENERATE, VALID_CODES);
|
||||
expect(world).not.toBeNull();
|
||||
expect(world!.nations[0].stats.stability).toBe(70);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDraft (injected client)', () => {
|
||||
it('returns a validated world + checkpoints from the model', async () => {
|
||||
const { world, checkpoints } = await generateDraft({
|
||||
llm: fakeClient(GOOD_GENERATE),
|
||||
template: TEMPLATE,
|
||||
description: 'a cold war in space',
|
||||
year: 2041,
|
||||
validCountryCodes: VALID_CODES,
|
||||
});
|
||||
expect(world).not.toBeNull();
|
||||
expect(world!.nations).toHaveLength(2);
|
||||
expect(checkpoints).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('nudges once when the first answer is unusable', async () => {
|
||||
let call = 0;
|
||||
const client: LlmClient = {
|
||||
async chat() {
|
||||
call += 1;
|
||||
return call === 1 ? 'total garbage' : GOOD_GENERATE;
|
||||
},
|
||||
async listModels() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const { world, checkpoints } = await generateDraft({
|
||||
llm: client,
|
||||
template: TEMPLATE,
|
||||
description: 'x',
|
||||
year: 2035,
|
||||
validCountryCodes: VALID_CODES,
|
||||
});
|
||||
expect(call).toBe(2);
|
||||
expect(world).not.toBeNull();
|
||||
expect(checkpoints).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns null world when even the nudge fails', async () => {
|
||||
const { world } = await generateDraft({
|
||||
llm: fakeClient('still no json'),
|
||||
template: TEMPLATE,
|
||||
description: 'x',
|
||||
year: 2035,
|
||||
validCountryCodes: VALID_CODES,
|
||||
});
|
||||
expect(world).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeWorld (injected client)', () => {
|
||||
const expand = (countries: string[]) => countries.flatMap((c) => [`${c}-a`, `${c}-b`]);
|
||||
|
||||
it('converts the finalized world to a playable GameState', async () => {
|
||||
const state = await finalizeWorld({
|
||||
llm: fakeClient(GOOD_REGENERATE),
|
||||
template: TEMPLATE,
|
||||
description: 'x',
|
||||
year: 2035,
|
||||
approvedCheckpoints: ['Alba presses its advantage.'],
|
||||
playerNationId: 'alba',
|
||||
validCountryCodes: VALID_CODES,
|
||||
expand,
|
||||
id: 'g-1',
|
||||
title: 'Custom World',
|
||||
startDate: '2035-01-01',
|
||||
});
|
||||
expect(state.id).toBe('g-1');
|
||||
expect(state.presetId).toBe('dynamic');
|
||||
expect(state.playerNationId).toBe('alba');
|
||||
expect(state.turn).toBe(1);
|
||||
expect(state.date).toBe('2035-01-01');
|
||||
// FRA expands to two region keys; the per-region stats are seeded.
|
||||
expect(state.nations.alba.territory).toEqual(['FRA-a', 'FRA-b']);
|
||||
expect(state.regionStats['FRA-a']).toEqual(state.nations.alba.stats);
|
||||
});
|
||||
|
||||
it('falls back to the first nation when the model drops the player nation', async () => {
|
||||
// The regenerated world has no 'alba' — only 'boreas'.
|
||||
const dropped = JSON.stringify({
|
||||
world: {
|
||||
nations: [
|
||||
{
|
||||
id: 'boreas',
|
||||
name: 'Boreas',
|
||||
stats: { military: 55, economy: 70, stability: 55, population: 60 },
|
||||
territory: ['RUS'],
|
||||
relations: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const state = await finalizeWorld({
|
||||
llm: fakeClient(dropped),
|
||||
template: TEMPLATE,
|
||||
description: 'x',
|
||||
year: 2035,
|
||||
approvedCheckpoints: [],
|
||||
playerNationId: 'alba', // not in the world
|
||||
validCountryCodes: VALID_CODES,
|
||||
expand,
|
||||
});
|
||||
expect(state.playerNationId).toBe('boreas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scenario stub (deterministic, LLM_STUB=1)', () => {
|
||||
it('is deterministic: same input → same generate output', async () => {
|
||||
const llm = makeScenarioStubLlmClient();
|
||||
const a = await generateDraft({
|
||||
llm,
|
||||
template: TEMPLATE,
|
||||
description: 'd',
|
||||
year: 2035,
|
||||
validCountryCodes: VALID_CODES,
|
||||
});
|
||||
const b = await generateDraft({
|
||||
llm,
|
||||
template: TEMPLATE,
|
||||
description: 'completely different description',
|
||||
year: 1999,
|
||||
validCountryCodes: VALID_CODES,
|
||||
});
|
||||
expect(a.world).toEqual(b.world);
|
||||
expect(a.checkpoints).toEqual(b.checkpoints);
|
||||
expect(a.world!.nations).toHaveLength(3);
|
||||
expect(a.checkpoints).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('finalize respects the approved checkpoints (deterministically)', async () => {
|
||||
const baseline = stubWorldFor([]);
|
||||
const withAlba = stubWorldFor(['Alba dominates the western sea lanes.']);
|
||||
const albaBase = baseline.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0;
|
||||
const boreasBase = baseline.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0;
|
||||
const albaWith = withAlba.nations.find((n) => n.id === 'alba')?.stats.stability ?? 0;
|
||||
const boreasWith = withAlba.nations.find((n) => n.id === 'boreas')?.stats.stability ?? 0;
|
||||
// Approving a checkpoint that names Alba raises Alba's stability only.
|
||||
expect(albaWith).toBe(albaBase + 5);
|
||||
expect(boreasWith).toBe(boreasBase);
|
||||
});
|
||||
|
||||
it('finalizeWorld over the stub returns a playable game honoring approvals', async () => {
|
||||
const llm = makeScenarioStubLlmClient();
|
||||
const state = await finalizeWorld({
|
||||
llm,
|
||||
template: TEMPLATE,
|
||||
description: 'd',
|
||||
year: 2035,
|
||||
approvedCheckpoints: ['Boreas fortifies its northern frontier.'],
|
||||
playerNationId: 'boreas',
|
||||
validCountryCodes: VALID_CODES,
|
||||
expand: (cs) => cs.flatMap((c) => [`${c}-a`]),
|
||||
id: 'stub-1',
|
||||
});
|
||||
expect(state.id).toBe('stub-1');
|
||||
expect(state.playerNationId).toBe('boreas');
|
||||
expect(state.nations.boreas.territory).toEqual(['RUS-a']);
|
||||
// The approved Boreas checkpoint raised its stability by 5 (50 → 55).
|
||||
expect(state.nations.boreas.stats.stability).toBe(55);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
// Dynamic scenario generation — the two LLM steps (v0.31, #52).
|
||||
//
|
||||
// This is the additive "Create scenario" path. Two plain `chat()` calls (no
|
||||
// tools, no separate advisor endpoint — the advisor is embodied in step 1's
|
||||
// checkpoint list):
|
||||
//
|
||||
// 1. `generateDraft` — from an era template + a free-text description + a
|
||||
// year, the model designs a draft {@link WorldDraft} (nations with ISO_A3
|
||||
// territory, stats, relations, optional backstory) AND an opening
|
||||
// checkpoint list (the advisor's briefing of the world's live tensions).
|
||||
//
|
||||
// 2. `finalizeWorld` — on commit, the model is re-asked to produce the
|
||||
// final world, constrained to the player's APPROVED checkpoints (and,
|
||||
// when the client re-sends the step-1 draft, based on it so the player's
|
||||
// chosen nation survives). The result is converted to a playable
|
||||
// {@link GameState} (region-key territory via the injected `expand`
|
||||
// resolver, per-region stats seeded) and returned for the caller to
|
||||
// persist — a normal game at `#/game/<id>`.
|
||||
//
|
||||
// Both steps are defensive like the rest of the engine: raw model output is
|
||||
// fence-stripped, the first balanced object is extracted, JSON.parsed, and
|
||||
// validated via the pure `validateWorldDraft` (which never throws and returns
|
||||
// `null` for unusable input). A transport-level failure (the model being down)
|
||||
// throws and is mapped to an HTTP 502 by the route; a bad *answer* degrades to
|
||||
// a null world, never a crash. The `LlmClient` is injected so unit tests and
|
||||
// the deterministic scenario stub (server/stubScenario.ts) can replace the
|
||||
// network call exactly like the turn path does.
|
||||
import type { LlmClient } from './llm';
|
||||
import { DEFAULT_MAX_TOKENS } from './llm';
|
||||
import type { EraTemplate, WorldDraft } from '../shared/scenario';
|
||||
import { validateWorldDraft } from '../shared/scenario';
|
||||
import type { GameState } from '../shared/types';
|
||||
import { worldDraftToGameState } from '../shared/scenario';
|
||||
|
||||
// --- Defensive parsing (shared with the engine's approach) --------------------
|
||||
|
||||
/** The system-prompt markers the deterministic scenario stub keys off. */
|
||||
export const SCENARIO_GENERATE_MARKER = '<<SCENARIO_GENERATE>>';
|
||||
export const SCENARIO_REGENERATE_MARKER = '<<SCENARIO_REGENERATE>>';
|
||||
|
||||
/** Strip a leading/trailing ``` code fence (mirrors engine.stripFence). */
|
||||
function stripFence(text: string): string {
|
||||
let t = text.trim();
|
||||
const lead = t.match(/^```[a-zA-Z]*\s*\n?/);
|
||||
if (lead) t = t.slice(lead[0].length);
|
||||
const tail = t.match(/\n?\s*```\s*$/);
|
||||
if (tail) t = t.slice(0, t.length - tail[0].length);
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
/** Extract the first balanced `{ ... }` substring (string-aware). */
|
||||
function extractBalancedObject(text: string): string | null {
|
||||
const start = text.indexOf('{');
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
let esc = false;
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (inStr) {
|
||||
if (esc) esc = false;
|
||||
else if (c === '\\') esc = true;
|
||||
else if (c === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse a raw top-level JSON object, or `null` when none is recoverable. */
|
||||
function parseObject(raw: string): Record<string, unknown> | null {
|
||||
const jsonText = extractBalancedObject(stripFence(raw));
|
||||
if (!jsonText) return null;
|
||||
try {
|
||||
const obj: unknown = JSON.parse(jsonText);
|
||||
return obj && typeof obj === 'object' && !Array.isArray(obj)
|
||||
? (obj as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prompt building ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The shared nation JSON contract text, embedded in both system prompts so the
|
||||
* model sees the exact shape it must emit. Mirrors the engine's strict "ONLY a
|
||||
* valid JSON object" style.
|
||||
*/
|
||||
const WORLD_SHAPE = [
|
||||
'{',
|
||||
' "world": {',
|
||||
' "nations": [',
|
||||
' {',
|
||||
' "id": "short-stable-id",',
|
||||
' "name": "Human readable name",',
|
||||
' "type": "major|minor|power|alliance|faction",',
|
||||
' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},',
|
||||
' "territory": ["ISO_A3","ISO_A3"],',
|
||||
' "relations": {"<other nation id or name>": -100..100}',
|
||||
' }',
|
||||
' ],',
|
||||
' "events": [',
|
||||
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
|
||||
' ]',
|
||||
' },',
|
||||
' "checkpoints": ["short advisor checkpoint", "..."]',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* The system prompt for step 1 (generate). Tells the model to design a fresh
|
||||
* world for the era AND to emit an opening advisor checkpoint list.
|
||||
*/
|
||||
export function buildGeneratePrompt(
|
||||
template: EraTemplate,
|
||||
description: string,
|
||||
year: number,
|
||||
validCountryCodes: ReadonlySet<string> | undefined,
|
||||
): { system: string; user: string } {
|
||||
const codes = validCountryCodes ? [...validCountryCodes].sort() : null;
|
||||
const system = [
|
||||
`${SCENARIO_GENERATE_MARKER} You are a world-builder for an alternate-history strategy game.`,
|
||||
'Given an era, a starting year, and the designer\'s description, create a plausible, self-consistent world of 3 to 8 nations for that moment in (or diverged from) history.',
|
||||
'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:',
|
||||
WORLD_SHAPE,
|
||||
'',
|
||||
'RULES',
|
||||
'- "nations": 3 to 8 nations, each with a short stable id, a human name, a type, and four 0-100 stats (military, economy, stability, population).',
|
||||
codes
|
||||
? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' +
|
||||
codes.join(', ') +
|
||||
'. A nation may control several codes; together the nations should cover a plausible portion of the world.'
|
||||
: '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).',
|
||||
'- "relations": a nation\'s sentiment toward each other nation, an integer from -100 (warring) to +100 (allied). Omit or set 0 for neutrality.',
|
||||
'- "events" (optional): 1 to 4 short backstory events that already happened and set up the current tensions; each names one of the nations above.',
|
||||
'- "checkpoints": 3 to 6 short strings — the advisor\'s opening briefing of the world\'s live strategic questions (the pressures, rivalries, and opportunities the player should know before leading a nation). Keep each under 20 words.',
|
||||
'- Keep every stat value between 0 and 100. Do not invent nations outside the "nations" array.',
|
||||
].join('\n');
|
||||
const user = JSON.stringify(
|
||||
{
|
||||
era: {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
year,
|
||||
description: template.eraDescription,
|
||||
},
|
||||
suggestedPowers: template.suggestedPowers,
|
||||
tensions: template.tensions,
|
||||
description,
|
||||
year,
|
||||
...(codes ? { validCountryCodes: codes } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
/**
|
||||
* The system prompt for step 2 (regenerate/finalize). Same world contract, but
|
||||
* the model must now respect the player's APPROVED checkpoints (and, when a
|
||||
* draft is supplied, stay close to it so the player's chosen nation survives).
|
||||
*/
|
||||
export function buildRegeneratePrompt(
|
||||
template: EraTemplate,
|
||||
description: string,
|
||||
year: number,
|
||||
approvedCheckpoints: string[],
|
||||
validCountryCodes: ReadonlySet<string> | undefined,
|
||||
draft?: WorldDraft,
|
||||
): { system: string; user: string } {
|
||||
const codes = validCountryCodes ? [...validCountryCodes].sort() : null;
|
||||
const system = [
|
||||
`${SCENARIO_REGENERATE_MARKER} You are finalizing a world for an alternate-history strategy game.`,
|
||||
'A draft world was generated and the player has APPROVED a set of checkpoints that the final world MUST respect. Produce the final, playable world.',
|
||||
'Respond with ONLY a valid JSON object of exactly this shape (same as the draft, but the "checkpoints" field is NOT repeated — omit it). No prose, no commentary, no markdown fences, no trailing commas:',
|
||||
[
|
||||
'{',
|
||||
' "world": {',
|
||||
' "nations": [',
|
||||
' {',
|
||||
' "id": "short-stable-id",',
|
||||
' "name": "Human readable name",',
|
||||
' "type": "major|minor|power|alliance|faction",',
|
||||
' "stats": {"military":0-100,"economy":0-100,"stability":0-100,"population":0-100},',
|
||||
' "territory": ["ISO_A3","ISO_A3"],',
|
||||
' "relations": {"<other nation id or name>": -100..100}',
|
||||
' }',
|
||||
' ],',
|
||||
' "events": [',
|
||||
' {"nation":"<id or name>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences","outcome":"<success|failure|partial|neutral>"}',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n'),
|
||||
'',
|
||||
'RULES',
|
||||
'- Base the final world on the provided draft (the "draft" field of the user message) and keep the same nations — do not rename, drop, or invent nations beyond what the draft established, UNLESS an approved checkpoint demands it.',
|
||||
'- You MUST respect EVERY approved checkpoint (the "approvedCheckpoints" field of the user message): reflect each one in the nations\' stats, borders, relations, or backstory events.',
|
||||
codes
|
||||
? '- Each nation\'s "territory" MUST use ONLY ISO 3166-1 alpha-3 country codes from this list: ' +
|
||||
codes.join(', ') +
|
||||
'.'
|
||||
: '- Each nation\'s "territory" uses standard ISO 3166-1 alpha-3 country codes (e.g. FRA, RUS, USA).',
|
||||
'- Keep every stat value between 0 and 100.',
|
||||
].join('\n');
|
||||
const user = JSON.stringify(
|
||||
{
|
||||
era: {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
year,
|
||||
description: template.eraDescription,
|
||||
},
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
...(draft ? { draft } : {}),
|
||||
...(codes ? { validCountryCodes: codes } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
// --- Parsing the model's answers ----------------------------------------------
|
||||
|
||||
/** Parse the step-1 (generate) answer into a validated world + checkpoint list. */
|
||||
export function parseGenerate(
|
||||
raw: string,
|
||||
validCountryCodes?: ReadonlySet<string>,
|
||||
): { world: WorldDraft | null; checkpoints: string[] } {
|
||||
const obj = parseObject(raw);
|
||||
if (!obj) return { world: null, checkpoints: [] };
|
||||
const world = validateWorldDraft(obj.world, { validCountryCodes });
|
||||
const checkpoints: string[] = [];
|
||||
if (Array.isArray(obj.checkpoints)) {
|
||||
const seen = new Set<string>();
|
||||
for (const c of obj.checkpoints) {
|
||||
if (typeof c !== 'string') continue;
|
||||
const t = c.trim();
|
||||
if (t.length > 2 && !seen.has(t)) {
|
||||
seen.add(t);
|
||||
checkpoints.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { world, checkpoints: checkpoints.slice(0, 12) };
|
||||
}
|
||||
|
||||
/** Parse the step-2 (finalize) answer into a validated world (or null). */
|
||||
export function parseRegenerate(
|
||||
raw: string,
|
||||
validCountryCodes?: ReadonlySet<string>,
|
||||
): WorldDraft | null {
|
||||
const obj = parseObject(raw);
|
||||
if (!obj) return null;
|
||||
return validateWorldDraft(obj.world, { validCountryCodes });
|
||||
}
|
||||
|
||||
// --- Orchestration -------------------------------------------------------------
|
||||
|
||||
export interface GenerateDraftOptions {
|
||||
llm: LlmClient;
|
||||
template: EraTemplate;
|
||||
description: string;
|
||||
year: number;
|
||||
/** ISO_A3 codes to accept (undefined = no filtering). */
|
||||
validCountryCodes?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: ask the model to design a draft world + an advisor checkpoint list.
|
||||
* Throws only on a transport-level failure (the model is down) — a bad answer
|
||||
* yields `{ world: null, checkpoints: [] }` and is the caller's to surface as
|
||||
* an error, never a crash.
|
||||
*/
|
||||
export async function generateDraft(
|
||||
opts: GenerateDraftOptions,
|
||||
): Promise<{ world: WorldDraft | null; checkpoints: string[] }> {
|
||||
const { llm, template, description, year, validCountryCodes } = opts;
|
||||
const { system, user } = buildGeneratePrompt(template, description, year, validCountryCodes);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.';
|
||||
const out = await llm.chat(
|
||||
[{ role: 'system', content: system }, { role: 'user', content: user }],
|
||||
{ temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
const parsed = parseGenerate(out, validCountryCodes);
|
||||
if (parsed.world) return parsed;
|
||||
// One nudge re-send with a fresh history, mirroring the engine's retry.
|
||||
const retry = await llm.chat(
|
||||
[
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
],
|
||||
{ temperature: 0.7, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
return parseGenerate(retry, validCountryCodes);
|
||||
}
|
||||
|
||||
export interface FinalizeWorldOptions {
|
||||
llm: LlmClient;
|
||||
template: EraTemplate;
|
||||
description: string;
|
||||
year: number;
|
||||
approvedCheckpoints: string[];
|
||||
/** The step-1 draft (re-sent by the client) — the base the final world refines. */
|
||||
draft?: WorldDraft;
|
||||
/** The nation id the player will lead (kept in the final world when present). */
|
||||
playerNationId: string;
|
||||
/** ISO_A3 codes to accept (undefined = no filtering). */
|
||||
validCountryCodes?: ReadonlySet<string>;
|
||||
/** Expand ISO_A3 territory to sub-region keys (injected from server/regions). */
|
||||
expand: (countries: string[]) => string[];
|
||||
/** The new game's id (the route sets randomUUID()). */
|
||||
id?: string;
|
||||
/** The scenario title (becomes presetTitle; default 'Custom World'). */
|
||||
title?: string;
|
||||
/** The game's start date (YYYY-MM-DD, from the chosen year). */
|
||||
startDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: ask the model to produce the final world constrained to the approved
|
||||
* checkpoints, convert it to a playable {@link GameState}, and return it for the
|
||||
* caller to persist. Throws only on a transport-level failure; a bad answer
|
||||
* that still yields a usable world is committed, and if the final world drops
|
||||
* the player's chosen nation the conversion falls back to the first nation so
|
||||
* the game is always playable.
|
||||
*/
|
||||
export async function finalizeWorld(opts: FinalizeWorldOptions): Promise<GameState> {
|
||||
const {
|
||||
llm,
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
draft,
|
||||
playerNationId,
|
||||
validCountryCodes,
|
||||
expand,
|
||||
id,
|
||||
title,
|
||||
startDate,
|
||||
} = opts;
|
||||
const { system, user } = buildRegeneratePrompt(
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
approvedCheckpoints,
|
||||
validCountryCodes,
|
||||
draft,
|
||||
);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.';
|
||||
const out = await llm.chat(
|
||||
[{ role: 'system', content: system }, { role: 'user', content: user }],
|
||||
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
let world = parseRegenerate(out, validCountryCodes);
|
||||
if (!world) {
|
||||
const retry = await llm.chat(
|
||||
[
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
],
|
||||
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
|
||||
);
|
||||
world = parseRegenerate(retry, validCountryCodes);
|
||||
}
|
||||
if (!world) {
|
||||
// The model could not produce a usable final world. Rather than fail the
|
||||
// whole commit, fall back to the draft the player already approved (if any)
|
||||
// so the commit still yields a playable game.
|
||||
if (draft) world = draft;
|
||||
else throw new Error('finalize: the model returned no usable world');
|
||||
}
|
||||
return worldDraftToGameState(world, expand, playerNationId, { id, title, startDate });
|
||||
}
|
||||
@@ -77,6 +77,10 @@ function truncate(s: string, n: number): string {
|
||||
* always returns `regionChanges: []`) — the per-region lever is covered by
|
||||
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
|
||||
* untouched.
|
||||
*
|
||||
* (v0.32.0, #54) The stub likewise NEVER performs a non-military takeover
|
||||
* (it always returns `takeovers: []`) — the regime-rename path is covered by
|
||||
* unit tests only, keeping the e2e assertions untouched.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -143,6 +147,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
territoryChanges,
|
||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||
takeovers: [], // the stub never performs a takeover (see doc comment)
|
||||
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Deterministic SCENARIO-generation stub (v0.31, #52).
|
||||
//
|
||||
// Distinct from the turn stub (server/stub.ts, which stays untouched): this one
|
||||
// answers the two scenario-generation LLM calls (see server/scenario.ts) so the
|
||||
// whole "Create scenario" path is reproducible under LLM_STUB=1 (unit tests and
|
||||
// the Playwright e2e). It speaks the OpenAI chat-completions wire shape, so it
|
||||
// plugs in as a Transport, and it discriminates the two calls by the markers
|
||||
// scenario.ts embeds in each system message (SCENARIO_GENERATE_MARKER for the
|
||||
// draft step, SCENARIO_REGENERATE_MARKER for the finalize step) — mirroring how
|
||||
// the turn stub discriminates the advisor call via ADVISOR_MARKER.
|
||||
//
|
||||
// The stub returns a fixed 3-nation world and a fixed checkpoint list for the
|
||||
// generate step, and — for the finalize step — the same world refined in a
|
||||
// simple, deterministic way that depends on the approved checkpoints, so the
|
||||
// "finalize respects approved checkpoints" behavior is observable end to end.
|
||||
import { createLlmClient, type ChatMessage, type LlmClient, type Transport } from './llm';
|
||||
import { SCENARIO_REGENERATE_MARKER } from './scenario';
|
||||
import type { WorldDraft } from '../shared/scenario';
|
||||
|
||||
/** A small fixed 3-nation world (ISO_A3 territory — FRA / RUS / ESP). */
|
||||
const WORLD: WorldDraft = {
|
||||
nations: [
|
||||
{
|
||||
id: 'alba',
|
||||
name: 'Alba',
|
||||
type: 'major',
|
||||
stats: { military: 70, economy: 60, stability: 65, population: 55 },
|
||||
territory: ['FRA'],
|
||||
relations: { boreas: -60, gamma: 20 },
|
||||
},
|
||||
{
|
||||
id: 'boreas',
|
||||
name: 'Boreas',
|
||||
type: 'major',
|
||||
stats: { military: 55, economy: 70, stability: 50, population: 60 },
|
||||
territory: ['RUS'],
|
||||
relations: { alba: -60, gamma: 10 },
|
||||
},
|
||||
{
|
||||
id: 'gamma',
|
||||
name: 'Gamma',
|
||||
type: 'minor',
|
||||
stats: { military: 30, economy: 45, stability: 70, population: 40 },
|
||||
territory: ['ESP'],
|
||||
relations: { alba: 20, boreas: 10 },
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
nation: 'alba',
|
||||
type: 'political',
|
||||
title: 'The new order takes shape',
|
||||
text: 'The three powers settle their borders after the great realignment.',
|
||||
outcome: 'neutral',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** The advisor's opening checkpoint list (fixed, in order). */
|
||||
const CHECKPOINTS: string[] = [
|
||||
'Alba dominates the western sea lanes and presses its advantage.',
|
||||
'Boreas fortifies its northern frontier against a long winter.',
|
||||
'Gamma brokers a neutral corridor between the two rivals.',
|
||||
];
|
||||
|
||||
/**
|
||||
* Deterministically refine the fixed world to "respect" the approved
|
||||
* checkpoints: each approved checkpoint that names one of the fixed nations (by
|
||||
* the first 4 letters of the nation's name, case-insensitive) raises that
|
||||
* nation's stability by 5 (clamped at 100). Same input → same output, and the
|
||||
* result visibly depends on which checkpoints were approved.
|
||||
*/
|
||||
export function stubWorldFor(approved: string[]): WorldDraft {
|
||||
const nations = WORLD.nations.map((n) => ({
|
||||
...n,
|
||||
stats: { ...n.stats },
|
||||
relations: { ...n.relations },
|
||||
}));
|
||||
const lower = approved.map((c) => c.toLowerCase());
|
||||
for (const n of nations) {
|
||||
const key = n.name.toLowerCase().slice(0, 4);
|
||||
if (lower.some((c) => c.includes(key))) {
|
||||
n.stats.stability = Math.min(100, n.stats.stability + 5);
|
||||
}
|
||||
}
|
||||
return { nations, events: WORLD.events };
|
||||
}
|
||||
|
||||
export function makeScenarioStubTransport(): Transport {
|
||||
return async (_url, init) => {
|
||||
let content: string;
|
||||
try {
|
||||
const req = JSON.parse(String(init?.body ?? '{}')) as { messages?: ChatMessage[] };
|
||||
const sys = req.messages?.find((m) => m.role === 'system')?.content ?? '';
|
||||
const user = req.messages?.find((m) => m.role === 'user');
|
||||
if (sys.includes(SCENARIO_REGENERATE_MARKER)) {
|
||||
// Finalize step: read the approved checkpoints out of the user payload
|
||||
// and refine the fixed world accordingly (no checkpoints field echoed).
|
||||
const payload = JSON.parse(user?.content ?? '{}') as {
|
||||
approvedCheckpoints?: string[];
|
||||
};
|
||||
content = JSON.stringify({ world: stubWorldFor(payload.approvedCheckpoints ?? []) });
|
||||
} else {
|
||||
// Generate step (and any unrecognized prompt): the fixed world + list.
|
||||
content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS });
|
||||
}
|
||||
} catch {
|
||||
content = JSON.stringify({ world: WORLD, checkpoints: CHECKPOINTS });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content } }] }),
|
||||
text: async () => content,
|
||||
} as unknown as Response;
|
||||
};
|
||||
}
|
||||
|
||||
/** A ready-made LlmClient backed by the deterministic scenario transport. */
|
||||
export function makeScenarioStubLlmClient(): LlmClient {
|
||||
return createLlmClient({ transport: makeScenarioStubTransport() });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createTemplateStore } from './templates';
|
||||
|
||||
// The project root is the parent of server/ (mirrors how server/index.ts and
|
||||
// server/regions.test.ts locate it via import.meta.url).
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const templatesDir = path.join(root, 'presets', 'templates');
|
||||
|
||||
const store = createTemplateStore(templatesDir);
|
||||
|
||||
/** A well-formed ISO_A3 country code: exactly three uppercase letters. */
|
||||
function isIsoA3(code: string): boolean {
|
||||
return /^[A-Z]{3}$/.test(code);
|
||||
}
|
||||
|
||||
describe('era templates store (v0.31.0, #52)', () => {
|
||||
it('the manifest lists exactly the three era templates', () => {
|
||||
const meta = store.listMeta();
|
||||
expect(meta).toHaveLength(3);
|
||||
const ids = meta.map((m) => m.id);
|
||||
expect(ids).toEqual(['ww2', 'present', 'near-future']);
|
||||
for (const m of meta) {
|
||||
expect(m.id).toBeTruthy();
|
||||
expect(m.name).toBeTruthy();
|
||||
expect(typeof m.year).toBe('number');
|
||||
expect(m.eraDescription).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('each template file loads and is well-formed', () => {
|
||||
for (const m of store.listMeta()) {
|
||||
const t = store.get(m.id);
|
||||
expect(t, `template ${m.id} should load`).not.toBeNull();
|
||||
const tpl = t as NonNullable<typeof t>;
|
||||
expect(tpl.id).toBe(m.id);
|
||||
expect(tpl.name).toBe(m.name);
|
||||
expect(tpl.year).toBe(m.year);
|
||||
expect(tpl.eraDescription).toBeTruthy();
|
||||
// suggested powers: non-empty, each a named power with an ISO_A3 core region + note.
|
||||
expect(tpl.suggestedPowers.length).toBeGreaterThan(0);
|
||||
for (const p of tpl.suggestedPowers) {
|
||||
expect(p.name).toBeTruthy();
|
||||
expect(isIsoA3(p.region)).toBe(true);
|
||||
expect(p.note).toBeTruthy();
|
||||
}
|
||||
// tensions: a non-empty list of non-empty strings.
|
||||
expect(tpl.tensions.length).toBeGreaterThan(0);
|
||||
for (const s of tpl.tensions) expect(typeof s).toBe('string');
|
||||
for (const s of tpl.tensions) expect(s.trim()).not.toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('get returns null for an unknown id', () => {
|
||||
expect(store.get('does-not-exist')).toBeNull();
|
||||
expect(store.get('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// Load era templates for the "Create scenario" path from disk (v0.31.0, #52).
|
||||
// Mirrors the preset store (server/presets.ts): each template is a JSON file
|
||||
// (presets/templates/<id>.json) and index.json is the lightweight manifest used
|
||||
// to render the picker cards. The store is created with the resolved templates
|
||||
// directory so it works from repo root (dev) and /app (Docker) alike.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { EraTemplate, TemplateMeta } from '../shared/scenario';
|
||||
|
||||
export interface TemplateStore {
|
||||
listMeta(): TemplateMeta[];
|
||||
get(id: string): EraTemplate | null;
|
||||
}
|
||||
|
||||
export function createTemplateStore(dir: string): TemplateStore {
|
||||
const manifest = JSON.parse(readFileSync(`${dir}/index.json`, 'utf8')) as TemplateMeta[];
|
||||
return {
|
||||
listMeta() {
|
||||
return manifest;
|
||||
},
|
||||
get(id: string) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(`${dir}/${id}.json`, 'utf8')) as EraTemplate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,25 @@ export interface TerritoryChange {
|
||||
* engine's `resolveNationRef`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* (v0.32.0, #54) A **non-military regime change** — a coup or revolution that
|
||||
* renames a nation to the name of the new regime. Unlike a {@link RenameOp}
|
||||
* (a player-confirmed diplomatic/political name change), a takeover is
|
||||
* **automatic and in-world**: `applyTurnResult` applies it directly (no
|
||||
* pending queue, no player confirm). Only `Nation.name` changes — the stable
|
||||
* `id`, territory, relations (keyed by id), stats, and `playerNationId`
|
||||
* (which references the id) all stay intact, so the player keeps leading the
|
||||
* nation under its new regime. `nationRef` is resolved exactly like the
|
||||
* `nationRef` of a NationOp (id first, then a case-insensitive unique name
|
||||
* match — see `resolveNationRef`).
|
||||
*/
|
||||
export interface Takeover {
|
||||
/** The target nation's id or current name (resolved by `resolveNationRef`). */
|
||||
nationRef: string;
|
||||
/** The name of the new regime to set as the nation's display name. */
|
||||
newRegimeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A **rename** proposal: change a nation's human-readable `name`. Only the
|
||||
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
||||
@@ -267,6 +286,17 @@ export interface TurnResult {
|
||||
* survivor. Empty on turns where the model proposes none.
|
||||
*/
|
||||
nationOps: NationOp[];
|
||||
/**
|
||||
* (v0.32.0, #54) Non-military regime changes (coups / revolutions) that take
|
||||
* effect **this turn** — each renames the target nation to `newRegimeName`.
|
||||
* Unlike `nationOps` (player-confirmed proposals), a takeover is **automatic
|
||||
* and in-world**: it is applied directly by `applyTurnResult` (like territory
|
||||
* / state changes), not queued to `pendingNationOps`. Only the nation's
|
||||
* `name` changes; the id, territory, relations, stats, and `playerNationId`
|
||||
* are all preserved. Empty on turns where no takeover occurs. Required
|
||||
* (default `[]`).
|
||||
*/
|
||||
takeovers: Takeover[];
|
||||
/**
|
||||
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||
* (the engine positions each at its region's centroid + a small
|
||||
@@ -452,6 +482,42 @@ export function nationColor(id: string): string {
|
||||
return `hsl(${hue} 58% 46%)`;
|
||||
}
|
||||
|
||||
// --- Combat: the military hard gate (v0.30, #50) ------------------------------
|
||||
//
|
||||
// The one rule the engine enforces on the player's behalf instead of trusting
|
||||
// the LLM: a contested conquest only succeeds when the attacker is not MORE
|
||||
// than `CONQUEST_MILITARY_MARGIN` below the defender. The LLM still proposes
|
||||
// the transfer; the engine decides whether it holds (see `applyTurnResult`).
|
||||
// Re-claims of unowned / no-man's-land regions are never gated.
|
||||
|
||||
/**
|
||||
* (v0.30, #50) The margin of military superiority an attacker must NOT be more
|
||||
* than below the defender for a contested conquest to succeed. A gap of exactly
|
||||
* this many points is still enough to win; a gap one larger is repelled.
|
||||
*/
|
||||
export const CONQUEST_MILITARY_MARGIN = 15;
|
||||
|
||||
/**
|
||||
* (v0.30, #50) Hard military gate for a **contested** conquest (a region
|
||||
* currently owned by a different nation). Returns `true` — the invasion
|
||||
* proceeds — unless the attacker's military is MORE than `margin` below the
|
||||
* defender's: equal, or within `margin` (default {@link CONQUEST_MILITARY_MARGIN}),
|
||||
* is enough to win, while a gap larger than `margin` is repelled.
|
||||
*
|
||||
* The comparison is intentionally one-sided (it only protects the *defender*
|
||||
* from an overwhelming odds gap, not the attacker from a small disadvantage):
|
||||
* `attacker < defender - margin` → repel; everything else (equal, within the
|
||||
* margin, or ahead) → proceed. Pure + shared so the engine, the tests, and the
|
||||
* prompt's frontier-balance hint all agree on the outcome.
|
||||
*/
|
||||
export function canConquer(
|
||||
attackerMil: number,
|
||||
defenderMil: number,
|
||||
margin: number = CONQUEST_MILITARY_MARGIN,
|
||||
): boolean {
|
||||
return !(attackerMil < defenderMil - margin);
|
||||
}
|
||||
|
||||
// --- No man's land (v0.22, #16 P3) — the map presentation contract ---
|
||||
//
|
||||
// An unowned region has two distinct looks, and the difference is NOT in the
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
// Dynamic scenario generation — the shared (dependency-free) contract.
|
||||
//
|
||||
// This is the additive "Create scenario" path: instead of picking one of the
|
||||
// static presets, the user picks a light **era template**, writes a free-text
|
||||
// description, and lets the LLM generate a fresh world (a {@link WorldDraft})
|
||||
// plus an advisor **checkpoint** list. The player reviews the checkpoints,
|
||||
// approves/removes some, and picks a nation to lead; a second LLM call then
|
||||
// finalizes the draft into a playable {@link GameState} (a normal game).
|
||||
//
|
||||
// Everything here is pure and has no runtime dependencies beyond the other
|
||||
// shared modules: it may import types from `./types` and helpers from
|
||||
// `./protocol`, but it never imports the engine, the LLM client, or any
|
||||
// external package. The two LLM calls (`generateDraft` / `finalizeWorld`)
|
||||
// live in `server/scenario.ts`; only the *shapes* and the pure transforms
|
||||
// live here.
|
||||
|
||||
import type {
|
||||
EventEntry,
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
Nation,
|
||||
NationStats,
|
||||
NationType,
|
||||
RelationStatus,
|
||||
} from './types';
|
||||
import { clampStat, makeEvent, seedRegionStats, type TurnEventSpec } from './protocol';
|
||||
|
||||
// --- Era templates -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A suggested starting power for an era template: a display `name`, an ISO_A3
|
||||
* core `region` (the country the power is rooted in), and a short `note` the
|
||||
* LLM is shown when it designs the world.
|
||||
*/
|
||||
export interface SuggestedPower {
|
||||
name: string;
|
||||
/** ISO_A3 country code the power's core territory sits in. */
|
||||
region: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A light **era template** the user can start from on the "Create scenario"
|
||||
* path (one JSON file per template in `presets/templates/`, plus an
|
||||
* `index.json` manifest). It is deliberately small: a name, a default `year`,
|
||||
* a short `eraDescription`, a few `suggestedPowers`, and a few `tensions` —
|
||||
* a seed the LLM generator expands into a full {@link WorldDraft}.
|
||||
*/
|
||||
export interface EraTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Default starting year (the user may override it in the form). */
|
||||
year: number;
|
||||
/** A short description of the era, shown on the card and fed to the LLM. */
|
||||
eraDescription: string;
|
||||
/** A few suggested starting powers (name + ISO_A3 core region + a note). */
|
||||
suggestedPowers: SuggestedPower[];
|
||||
/** A few suggested tensions / conflicts to seed the world. */
|
||||
tensions: string[];
|
||||
}
|
||||
|
||||
/** The lightweight manifest entry for an era template (the picker card). */
|
||||
export interface TemplateMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
year: number;
|
||||
eraDescription: string;
|
||||
}
|
||||
|
||||
// --- World draft -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single nation in a generated world **draft** (before it is committed to a
|
||||
* playable {@link GameState}). Its `territory` holds **ISO_A3 country codes**
|
||||
* (not region keys) — the engine expands them to sub-region keys at commit
|
||||
* time via the injected `expand` resolver. `relations` are the LLM's
|
||||
* numeric sentiment toward other nations (−100..100); the engine maps them to
|
||||
* a {@link RelationStatus} on commit.
|
||||
*/
|
||||
export interface WorldNation {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: NationType;
|
||||
stats: NationStats;
|
||||
/** ISO_A3 country codes the nation controls. */
|
||||
territory: string[];
|
||||
/** Bilateral sentiment: other nation id -> −100..100. */
|
||||
relations: Record<string, number>;
|
||||
}
|
||||
|
||||
/** An optional narrative backstory event carried by a world draft (turn 0). */
|
||||
export interface ScenarioEvent {
|
||||
nation: string;
|
||||
type: EventType;
|
||||
title: string;
|
||||
text: string;
|
||||
outcome: EventOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft world the LLM generator returns before the player confirms it.
|
||||
* `nations` is required and non-empty; `events` is optional backstory that
|
||||
* seeds the new game's event feed on commit.
|
||||
*/
|
||||
export interface WorldDraft {
|
||||
nations: WorldNation[];
|
||||
events?: ScenarioEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A single **advisor checkpoint** — a short string the generator proposes for
|
||||
* the player to approve (or remove) before the world is finalized. The
|
||||
* approved checkpoints are handed to the finalize call as constraints.
|
||||
*/
|
||||
export type Checkpoint = string;
|
||||
|
||||
// --- Client ↔ server message shapes (the two LLM steps) -----------------------
|
||||
|
||||
/** The body of `POST /api/scenarios/draft` (step 1: design the world). */
|
||||
export interface ScenarioDraftRequest {
|
||||
templateId: string;
|
||||
description: string;
|
||||
/** The chosen start year (the server falls back to the template's year). */
|
||||
year?: number;
|
||||
}
|
||||
|
||||
/** The response of `POST /api/scenarios/draft` — the draft world + checkpoints. */
|
||||
export interface ScenarioDraftResponse {
|
||||
world: WorldDraft;
|
||||
checkpoints: string[];
|
||||
}
|
||||
|
||||
/** The body of `POST /api/scenarios/commit` (step 2: finalize + save the game). */
|
||||
export interface ScenarioCommitRequest {
|
||||
templateId: string;
|
||||
description: string;
|
||||
/** The chosen start year (the server falls back to the template's year). */
|
||||
year?: number;
|
||||
/** The step-1 draft, re-sent so the final world stays close to it. */
|
||||
draft?: WorldDraft;
|
||||
/** The checkpoints the player approved (the finalize step's constraints). */
|
||||
approvedCheckpoints: string[];
|
||||
/** The nation id the player will lead (kept in the final world when present). */
|
||||
playerNationId: string;
|
||||
}
|
||||
|
||||
/** The response of `POST /api/scenarios/commit` — the saved game's id. */
|
||||
export interface ScenarioCommitResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// --- Validation + transforms (pure) ------------------------------------------
|
||||
|
||||
/** The 8 known event types (a local copy so this module needs no runtime import). */
|
||||
const EVENT_TYPES: readonly string[] = [
|
||||
'military',
|
||||
'diplomatic',
|
||||
'economic',
|
||||
'political',
|
||||
'social',
|
||||
'disaster',
|
||||
'scientific',
|
||||
'cultural',
|
||||
];
|
||||
|
||||
/** The 5 known nation types. */
|
||||
const NATION_TYPES: readonly string[] = ['major', 'minor', 'power', 'alliance', 'faction'];
|
||||
|
||||
/** True when `t` is one of the eight known event types. */
|
||||
function isEventType(t: unknown): t is EventType {
|
||||
return typeof t === 'string' && (EVENT_TYPES as string[]).includes(t);
|
||||
}
|
||||
|
||||
/** True when `t` is one of the five known event outcomes. */
|
||||
function isEventOutcome(o: unknown): o is EventOutcome {
|
||||
return o === 'success' || o === 'failure' || o === 'partial' || o === 'neutral';
|
||||
}
|
||||
|
||||
/** True when `t` is one of the five known nation types. */
|
||||
function isNationType(t: unknown): t is NationType {
|
||||
return typeof t === 'string' && (NATION_TYPES as string[]).includes(t);
|
||||
}
|
||||
|
||||
/** Clamp a number to [lo, hi] and round (non-finite → lo). */
|
||||
function clampRange(v: number, lo: number, hi: number): number {
|
||||
if (!Number.isFinite(v)) return lo;
|
||||
return Math.max(lo, Math.min(hi, Math.round(v)));
|
||||
}
|
||||
|
||||
/** Parse a raw (unknown) stats object into a clamped NationStats (missing → 50). */
|
||||
function parseStats(raw: unknown): NationStats {
|
||||
const o =
|
||||
raw && typeof raw === 'object' && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: {};
|
||||
const get = (k: keyof NationStats): number => {
|
||||
const v = o[k];
|
||||
return typeof v === 'number' && Number.isFinite(v) ? clampStat(v) : 50;
|
||||
};
|
||||
return {
|
||||
military: get('military'),
|
||||
economy: get('economy'),
|
||||
stability: get('stability'),
|
||||
population: get('population'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, stable, lowercase slug for a nation name (used as a fallback id):
|
||||
* "United Kingdom" → "united-kingdom". Diacritics stripped, runs of
|
||||
* non-alphanumerics collapsed to a single dash, length capped at 20.
|
||||
*/
|
||||
function slugFromName(name: string): string {
|
||||
const s = name
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
if (!s) return '';
|
||||
return s.length > 20 ? s.slice(0, 20).replace(/-+$/, '') : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a numeric sentiment score (−100..100) to a {@link RelationStatus}.
|
||||
* Five bands: ally (≥60), neutral (0..59), rival (−40..−1), hostile (−80..−41),
|
||||
* war (≤−81). Pure.
|
||||
*/
|
||||
export function relationFromScore(v: number): RelationStatus {
|
||||
const c = clampRange(v, -100, 100);
|
||||
if (c >= 60) return 'ally';
|
||||
if (c >= 0) return 'neutral';
|
||||
if (c >= -40) return 'rival';
|
||||
if (c >= -80) return 'hostile';
|
||||
return 'war';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalize a raw LLM world-draft payload into a clean
|
||||
* {@link WorldDraft} (or `null` when it is unusable). Never throws:
|
||||
* - the top level must be an object with a non-empty `nations` array;
|
||||
* - each nation needs a non-empty `name`; its `id` is kept when a valid
|
||||
* non-empty string, else derived from a name slug (else `n<index>`);
|
||||
* duplicate ids are suffixed until unique;
|
||||
* - each nation's `type`/stats are clamped (missing stats → 50);
|
||||
* - each nation's `territory` is upper-cased, deduped, and (when
|
||||
* `validCountryCodes` is given) filtered to only known ISO_A3 codes;
|
||||
* - a nation's `relations` are clamped to −100..100 and re-keyed to the
|
||||
* assigned ids (a key that is a known id, or a case-insensitive name
|
||||
* match, is kept; unknown / self references are dropped);
|
||||
* - optional `events` are kept when they name a known nation (by id or
|
||||
* name) and have a non-empty title;
|
||||
* - the result is `null` when there are no nations, or no nation ends up
|
||||
* owning a valid territory (nothing to render on the map).
|
||||
*/
|
||||
export function validateWorldDraft(
|
||||
raw: unknown,
|
||||
opts: { validCountryCodes?: ReadonlySet<string> } = {},
|
||||
): WorldDraft | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const rawNations = Array.isArray(obj.nations) ? obj.nations : null;
|
||||
if (!rawNations || rawNations.length === 0) return null;
|
||||
|
||||
const validCodes = opts.validCountryCodes;
|
||||
const usedIds = new Set<string>();
|
||||
const nameToId = new Map<string, string>();
|
||||
|
||||
interface Tmp {
|
||||
id: string;
|
||||
name: string;
|
||||
type: NationType;
|
||||
stats: NationStats;
|
||||
territory: string[];
|
||||
rawRelations: Record<string, number>;
|
||||
}
|
||||
const tmp: Tmp[] = [];
|
||||
|
||||
for (const rn of rawNations) {
|
||||
if (!rn || typeof rn !== 'object') continue;
|
||||
const o = rn as Record<string, unknown>;
|
||||
const name = typeof o.name === 'string' ? o.name.trim() : '';
|
||||
if (name === '') continue; // a nation needs a name
|
||||
|
||||
// id: keep a valid non-empty string, else derive from the name slug, else n<index>.
|
||||
let id = typeof o.id === 'string' && o.id.trim() !== '' ? o.id.trim() : '';
|
||||
if (id === '') id = slugFromName(name) || `n${tmp.length + 1}`;
|
||||
let candidate = id;
|
||||
let k = 2;
|
||||
while (usedIds.has(candidate)) {
|
||||
candidate = `${id}-${k}`;
|
||||
k += 1;
|
||||
}
|
||||
id = candidate;
|
||||
usedIds.add(id);
|
||||
const lowerName = name.toLowerCase();
|
||||
if (!nameToId.has(lowerName)) nameToId.set(lowerName, id);
|
||||
|
||||
const type = isNationType(o.type) ? o.type : 'major';
|
||||
const stats = parseStats(o.stats);
|
||||
|
||||
// territory: upper-case, de-duped, and (optionally) filtered to known codes.
|
||||
const territory: string[] = [];
|
||||
if (Array.isArray(o.territory)) {
|
||||
for (const t of o.territory) {
|
||||
if (typeof t !== 'string') continue;
|
||||
const code = t.trim().toUpperCase();
|
||||
if (code === '') continue;
|
||||
if (validCodes && !validCodes.has(code)) continue; // drop unknown country codes
|
||||
if (!territory.includes(code)) territory.push(code);
|
||||
}
|
||||
}
|
||||
|
||||
// relations: keep only finite numeric values (clamped later on re-key).
|
||||
const rawRelations: Record<string, number> = {};
|
||||
if (o.relations && typeof o.relations === 'object' && !Array.isArray(o.relations)) {
|
||||
for (const [key, v] of Object.entries(o.relations as Record<string, unknown>)) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) rawRelations[key] = v;
|
||||
}
|
||||
}
|
||||
|
||||
tmp.push({ id, name, type, stats, territory, rawRelations });
|
||||
}
|
||||
|
||||
if (tmp.length === 0) return null;
|
||||
// Unusable when no nation owns a valid territory (there is nothing to render).
|
||||
if (!tmp.some((t) => t.territory.length > 0)) return null;
|
||||
|
||||
const idSet = new Set(tmp.map((t) => t.id));
|
||||
const nations: WorldNation[] = tmp.map((t) => {
|
||||
const relations: Record<string, number> = {};
|
||||
for (const [key, val] of Object.entries(t.rawRelations)) {
|
||||
// Resolve the key: a known id, else a case-insensitive name match.
|
||||
let target = key;
|
||||
if (!idSet.has(target)) {
|
||||
const mapped = nameToId.get(key.toLowerCase());
|
||||
if (mapped) target = mapped;
|
||||
}
|
||||
if (target === t.id) continue; // drop self-references
|
||||
if (!idSet.has(target)) continue; // drop references to unknown nations
|
||||
relations[target] = clampRange(val, -100, 100);
|
||||
}
|
||||
return { id: t.id, name: t.name, type: t.type, stats: t.stats, territory: t.territory, relations };
|
||||
});
|
||||
|
||||
// Optional backstory events (kept when they name a known nation).
|
||||
let events: ScenarioEvent[] | undefined;
|
||||
if (Array.isArray(obj.events)) {
|
||||
const evs: ScenarioEvent[] = [];
|
||||
for (const re of obj.events) {
|
||||
if (!re || typeof re !== 'object') continue;
|
||||
const e = re as Record<string, unknown>;
|
||||
const title = typeof e.title === 'string' ? e.title.trim() : '';
|
||||
if (title === '') continue;
|
||||
const text = typeof e.text === 'string' ? e.text : '';
|
||||
const type = isEventType(e.type) ? e.type : 'political';
|
||||
const outcome = isEventOutcome(e.outcome) ? e.outcome : 'neutral';
|
||||
let nation = typeof e.nation === 'string' ? e.nation : '';
|
||||
if (nation !== '' && !idSet.has(nation)) {
|
||||
nation = nameToId.get(nation.toLowerCase()) ?? '';
|
||||
}
|
||||
if (nation === '' || !idSet.has(nation)) continue; // drop events with no known owner
|
||||
evs.push({ nation, type, title, text, outcome });
|
||||
}
|
||||
events = evs;
|
||||
}
|
||||
|
||||
return { nations, events };
|
||||
}
|
||||
|
||||
/** Options for {@link worldDraftToGameState} (all optional, sensible defaults). */
|
||||
export interface WorldDraftToGameOptions {
|
||||
/** The new game's id (the server sets `randomUUID()`; default `''`). */
|
||||
id?: string;
|
||||
/** The scenario title (becomes `GameState.presetTitle`; default 'Custom World'). */
|
||||
title?: string;
|
||||
/** The game's start date (`YYYY-MM-DD`, from the chosen year; default '1970-01-01'). */
|
||||
startDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a validated {@link WorldDraft} into a fresh, playable {@link
|
||||
* GameState} (a normal game at `#/game/<id>`). Mirrors `POST /api/games`
|
||||
* exactly, except the draft's nations carry **ISO_A3** territory which is
|
||||
* expanded to sub-region keys by the injected `expand` resolver, and
|
||||
* `regionStats` is seeded from the expanded territories via
|
||||
* {@link seedRegionStats} (so every owned region starts with its owner's
|
||||
* stats and the derived aggregate equals them on turn 1).
|
||||
*
|
||||
* The result is a clean turn-1 state: `version: 1`, `turn: 1`, `unowned: []`,
|
||||
* `markers: []`, `pendingNationOps: []`, `pending: []`, the given
|
||||
* `playerNationId`, and a `startDate` from the chosen year. Backstory events
|
||||
* (if any) seed the event feed at turn 1. Pure — no side effects, no I/O.
|
||||
*/
|
||||
export function worldDraftToGameState(
|
||||
draft: WorldDraft,
|
||||
expand: (countries: string[]) => string[],
|
||||
playerNationId: string,
|
||||
opts: WorldDraftToGameOptions = {},
|
||||
): GameState {
|
||||
const id = opts.id ?? '';
|
||||
const title = opts.title ?? 'Custom World';
|
||||
const startDate = opts.startDate ?? '1970-01-01';
|
||||
|
||||
const nations: Record<string, Nation> = {};
|
||||
for (const wn of draft.nations) {
|
||||
const relations: Record<string, RelationStatus> = {};
|
||||
for (const [otherId, score] of Object.entries(wn.relations)) {
|
||||
if (otherId === wn.id) continue; // no self-relations
|
||||
relations[otherId] = relationFromScore(score);
|
||||
}
|
||||
nations[wn.id] = {
|
||||
id: wn.id,
|
||||
name: wn.name,
|
||||
type: wn.type ?? 'major',
|
||||
stats: wn.stats,
|
||||
territory: expand(wn.territory),
|
||||
relations,
|
||||
};
|
||||
}
|
||||
|
||||
// Guard: a finalize step (or a bad model answer) might drop the nation the
|
||||
// player chose to lead. Rather than produce a game with a dangling
|
||||
// playerNationId, fall back to the first nation (deterministic insertion
|
||||
// order) so the resulting game is always playable.
|
||||
const effectivePlayer =
|
||||
nations[playerNationId] !== undefined
|
||||
? playerNationId
|
||||
: draft.nations.length > 0
|
||||
? draft.nations[0].id
|
||||
: '';
|
||||
|
||||
// Seed the event feed from the draft's optional backstory (turn 1, startDate).
|
||||
const events: EventEntry[] = (draft.events ?? []).map((e) =>
|
||||
makeEvent(e as unknown as TurnEventSpec, 1, startDate),
|
||||
);
|
||||
|
||||
const state: GameState = {
|
||||
id,
|
||||
presetId: 'dynamic',
|
||||
presetTitle: title,
|
||||
playerNationId: effectivePlayer,
|
||||
turn: 1,
|
||||
date: startDate,
|
||||
nations,
|
||||
events,
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
pendingNationOps: [],
|
||||
markers: [],
|
||||
// Seed AFTER the territories hold real region keys: each owned region gets
|
||||
// a copy of its owner's stats, so the derived aggregate equals them.
|
||||
regionStats: seedRegionStats(nations),
|
||||
};
|
||||
return state;
|
||||
}
|
||||
+9
-1
@@ -79,7 +79,15 @@ export const EVENT_TYPES: EventType[] = [
|
||||
'cultural',
|
||||
];
|
||||
|
||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
|
||||
/**
|
||||
* The outcome of a narrative event. `success` / `failure` / `partial` /
|
||||
* `neutral` are the four the LLM is asked to emit. `repelled` (v0.30, #50) is
|
||||
* reserved for the engine: it is emitted automatically when the military hard
|
||||
* gate rejects a contested conquest (an invasion whose attacker was more than
|
||||
* `CONQUEST_MILITARY_MARGIN` below the defender) — the LLM never produces it,
|
||||
* and the client renders it in a distinct muted-red failure tone.
|
||||
*/
|
||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral' | 'repelled';
|
||||
|
||||
// --- Map markers (v0.27, #44) -------------------------------------------------
|
||||
//
|
||||
|
||||
+128
-2
@@ -16,6 +16,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
||||
import { cn } from './lib/utils';
|
||||
import PresetPicker from './components/PresetPicker';
|
||||
import NationSelect from './components/NationSelect';
|
||||
import ScenarioForm from './components/ScenarioForm';
|
||||
import CheckpointReview from './components/CheckpointReview';
|
||||
import WorldMap from './components/WorldMap';
|
||||
import FloatingWindow from './components/FloatingWindow';
|
||||
import MapLegend from './components/MapLegend';
|
||||
@@ -26,9 +28,27 @@ import { api } from './lib/api';
|
||||
import { useSse } from './lib/sse';
|
||||
import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol';
|
||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
||||
import type { TemplateMeta, WorldDraft } from '@shared/scenario';
|
||||
|
||||
/** Which top-level screen the app is showing. */
|
||||
type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' };
|
||||
/**
|
||||
* Which top-level screen the app is showing. The dynamic path (v0.31, #52) adds
|
||||
* two: 'scenario-form' (era + description + year) and 'scenario-review' (the
|
||||
* generated world + checkpoint checklist + nation pick), the latter carrying
|
||||
* the step-1 draft + chosen template so the finalize step can re-send them.
|
||||
*/
|
||||
type Screen =
|
||||
| { name: 'home' }
|
||||
| { name: 'pick'; preset: Preset }
|
||||
| { name: 'game' }
|
||||
| { name: 'scenario-form' }
|
||||
| {
|
||||
name: 'scenario-review';
|
||||
template: TemplateMeta;
|
||||
description: string;
|
||||
year: number;
|
||||
world: WorldDraft;
|
||||
checkpoints: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Surface a turn failure. A 502 (live model down / timeout) gets a friendly,
|
||||
@@ -181,6 +201,10 @@ export default function App() {
|
||||
// non-LLM) request. It gates only the nation-op dialog controls, never the
|
||||
// turn flow — "Jump forward" stays usable while it is in flight.
|
||||
const [opBusy, setOpBusy] = useState(false);
|
||||
// (v0.31, #52) A scenario-generation LLM call (generate or finalize) is
|
||||
// in flight. Distinct from `busy`/`advisorBusy` — it only gates the
|
||||
// scenario-form/review screens (those screens never coexist with a game).
|
||||
const [scenarioBusy, setScenarioBusy] = useState(false);
|
||||
// The id of the game the user is/was last in (mirrors localStorage) and the
|
||||
// list of saved games (newest first) shown on the home screen.
|
||||
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
||||
@@ -359,6 +383,77 @@ export default function App() {
|
||||
[screen],
|
||||
);
|
||||
|
||||
// --- (v0.31, #52) Dynamic scenario generation: form → generate → review →
|
||||
// finalize → game. Both LLM steps run on the server (the scenario stub under
|
||||
// LLM_STUB=1, the live model otherwise); on failure we toast and stay on the
|
||||
// current screen (the form's / review's local selections are preserved).
|
||||
|
||||
// Home screen's "Create scenario" card → the scenario form.
|
||||
const openScenarioForm = useCallback(() => {
|
||||
setScreen({ name: 'scenario-form' });
|
||||
}, []);
|
||||
|
||||
// Step 1 (generate): design the draft world + checkpoint list. On success
|
||||
// the app moves to the review screen (carrying the draft, template, year,
|
||||
// and description so the finalize step can re-send them).
|
||||
const generateScenarioDraft = useCallback(
|
||||
async (template: TemplateMeta, description: string, year: number) => {
|
||||
setScenarioBusy(true);
|
||||
try {
|
||||
const { world, checkpoints } = await api.draftScenario({
|
||||
templateId: template.id,
|
||||
description,
|
||||
year,
|
||||
});
|
||||
setScreen({ name: 'scenario-review', template, description, year, world, checkpoints });
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
setScenarioBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Step 2 (finalize): commit the approved draft into a saved, playable game
|
||||
// and enter it. The step-1 draft is re-sent so the final world stays close
|
||||
// to it (and the chosen nation survives); the new game's id is then fetched
|
||||
// and adopted exactly like a normal created game (active-game persistence,
|
||||
// applyState, screen → game — the URL-sync effect writes the deep link).
|
||||
const finalizeScenario = useCallback(
|
||||
async (
|
||||
template: TemplateMeta,
|
||||
description: string,
|
||||
year: number,
|
||||
world: WorldDraft,
|
||||
approvedCheckpoints: string[],
|
||||
playerNationId: string,
|
||||
) => {
|
||||
setScenarioBusy(true);
|
||||
try {
|
||||
const { id } = await api.commitScenario({
|
||||
templateId: template.id,
|
||||
description,
|
||||
year,
|
||||
draft: world,
|
||||
approvedCheckpoints,
|
||||
playerNationId,
|
||||
});
|
||||
const { game } = await api.getGame(id);
|
||||
persistActiveGameId(id);
|
||||
setActiveGameId(id);
|
||||
applyState(game);
|
||||
setAdvisors([]);
|
||||
setScreen({ name: 'game' });
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
setScenarioBusy(false);
|
||||
}
|
||||
},
|
||||
[applyState],
|
||||
);
|
||||
|
||||
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
|
||||
// list updates from the response and the SSE `state` event. Both are
|
||||
// version-gated via applyState, so the item can never be clobbered by a
|
||||
@@ -573,6 +668,36 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.name === 'scenario-form') {
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<ScenarioForm busy={scenarioBusy} onBack={goHome} onGenerate={generateScenarioDraft} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.name === 'scenario-review') {
|
||||
const { template, description, year, world, checkpoints } = screen;
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<CheckpointReview
|
||||
template={template}
|
||||
description={description}
|
||||
year={year}
|
||||
world={world}
|
||||
checkpoints={checkpoints}
|
||||
busy={scenarioBusy}
|
||||
onBack={openScenarioForm}
|
||||
onFinalize={(approved, nationId) =>
|
||||
void finalizeScenario(template, description, year, world, approved, nationId)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
@@ -584,6 +709,7 @@ export default function App() {
|
||||
activeGameId={activeGameId}
|
||||
onResume={resumeGame}
|
||||
onDelete={runDeleteGame}
|
||||
onCreateScenario={openScenarioForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, ListChecks, Loader2, Sparkles } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import StatBar from './StatBar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EVENT_COLORS, RELATION_COLORS } from '@/lib/colors';
|
||||
import { relationFromScore, type TemplateMeta, type WorldDraft, type WorldNation } from '@shared/scenario';
|
||||
|
||||
interface Props {
|
||||
/** The era template the draft was generated from (name/year for the header). */
|
||||
template: TemplateMeta;
|
||||
/** The free-text description the user wrote (shown as the world's context). */
|
||||
description: string;
|
||||
/** The effective start year (template default or the user's override). */
|
||||
year: number;
|
||||
/** The step-1 draft world to review and finalize. */
|
||||
world: WorldDraft;
|
||||
/** The advisor's proposed checkpoints (all approved by default). */
|
||||
checkpoints: string[];
|
||||
/** A finalize (LLM) call is in flight — disables the controls. */
|
||||
busy?: boolean;
|
||||
/** Back to the scenario form (a fresh attempt — the form's state resets). */
|
||||
onBack: () => void;
|
||||
/** Step 2: finalize with the approved checkpoints + the chosen nation to lead. */
|
||||
onFinalize: (approvedCheckpoints: string[], playerNationId: string) => void;
|
||||
}
|
||||
|
||||
const STAT_BARS: Array<{ key: keyof WorldNation['stats']; label: string }> = [
|
||||
{ key: 'military', label: 'Military' },
|
||||
{ key: 'economy', label: 'Economy' },
|
||||
{ key: 'stability', label: 'Stability' },
|
||||
{ key: 'population', label: 'Population' },
|
||||
];
|
||||
|
||||
/**
|
||||
* "Create scenario" — step 2 of the dynamic path (v0.31, #52). Shows the
|
||||
* generated world (each nation's stats, territory country codes, and
|
||||
* relations; plus any backstory events) and the advisor's proposed
|
||||
* checkpoints as an approve/remove checklist (approved by default — the
|
||||
* approved ones bind the finalize step). The player then picks which nation
|
||||
* to lead (the nation cards mirror NationSelect's pick screen; the draft's
|
||||
* nations are not a static Preset, so they are rendered here directly) and
|
||||
* finalizes. On success the app navigates into the new game; on failure it
|
||||
* toasts and stays here with the current selections intact.
|
||||
*/
|
||||
export default function CheckpointReview({
|
||||
template,
|
||||
description,
|
||||
year,
|
||||
world,
|
||||
checkpoints,
|
||||
busy = false,
|
||||
onBack,
|
||||
onFinalize,
|
||||
}: Props) {
|
||||
// Approve/remove per checkpoint (approved by default — the advisor's list is
|
||||
// a starting point the player prunes, not a form they fill in).
|
||||
const [approved, setApproved] = useState<boolean[]>(() => checkpoints.map(() => true));
|
||||
// The nation the player will lead (none until explicitly picked).
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const nameOf = (id: string) => world.nations.find((n) => n.id === id)?.name ?? id;
|
||||
const approvedCount = approved.filter(Boolean).length;
|
||||
const selected = selectedId ? world.nations.find((n) => n.id === selectedId) : undefined;
|
||||
|
||||
const toggle = (i: number) =>
|
||||
setApproved((prev) => prev.map((v, j) => (j === i ? !v : v)));
|
||||
|
||||
const finalize = () => {
|
||||
if (!selectedId) return;
|
||||
onFinalize(
|
||||
checkpoints.filter((_, i) => approved[i]),
|
||||
selectedId,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-8" data-testid="scenario-review">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||
← Back to form
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{template.name}</h1>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{year}
|
||||
</Badge>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-2 max-w-3xl whitespace-pre-line text-sm leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* --- The generated world --- */}
|
||||
<h2 className="mb-3 mt-8 text-lg font-semibold">The generated world</h2>
|
||||
<div className="space-y-3">
|
||||
{world.nations.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className="rounded-lg border border-border/60 bg-card/40 p-3"
|
||||
data-testid="draft-nation"
|
||||
data-nation-id={n.id}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{n.name}</span>
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{n.type ?? 'major'}
|
||||
</Badge>
|
||||
{n.territory.length > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{n.territory.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 grid gap-x-6 gap-y-1 sm:grid-cols-2">
|
||||
{STAT_BARS.map(({ key, label }) => (
|
||||
<StatBar key={key} label={label} value={n.stats[key]} />
|
||||
))}
|
||||
</div>
|
||||
{Object.keys(n.relations).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{Object.entries(n.relations).map(([otherId, score]) => {
|
||||
const status = relationFromScore(score);
|
||||
return (
|
||||
<span
|
||||
key={otherId}
|
||||
className="rounded-full border px-2 py-0.5 text-[11px] font-medium"
|
||||
style={{
|
||||
color: RELATION_COLORS[status],
|
||||
borderColor: `${RELATION_COLORS[status]}55`,
|
||||
}}
|
||||
>
|
||||
{nameOf(otherId)} {score > 0 ? `+${score}` : score}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(world.events ?? []).length > 0 && (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-3">
|
||||
<div className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Backstory
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{(world.events ?? []).map((e, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm" data-testid="draft-event">
|
||||
<span
|
||||
className="mt-1.5 inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ background: EVENT_COLORS[e.type] }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-medium">{nameOf(e.nation)}:</span>{' '}
|
||||
{e.title} — {e.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- Advisor checkpoints (approve / remove) --- */}
|
||||
<div className="mt-8 flex items-center gap-2">
|
||||
<ListChecks className="size-4 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Advisor checkpoints</h2>
|
||||
<Badge variant="secondary" className="ml-auto text-xs tabular-nums">
|
||||
{approvedCount} of {checkpoints.length} approved
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mb-3 mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
The advisor’s opening read of this world’s live strategic threads. Keep the ones the final
|
||||
world should respect — removed checkpoints are dropped.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{checkpoints.map((c, i) => (
|
||||
<li
|
||||
key={`${i}-${c}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border p-2.5 text-sm',
|
||||
approved[i]
|
||||
? 'border-amber-500/30 bg-amber-500/10'
|
||||
: 'border-border/60 bg-card/30 text-muted-foreground',
|
||||
)}
|
||||
data-testid="checkpoint-item"
|
||||
data-approved={approved[i] ? 'true' : 'false'}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-4 shrink-0 items-center justify-center rounded-full border',
|
||||
approved[i] ? 'border-amber-500/50 bg-amber-500/20' : 'border-border',
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{approved[i] && <Check className="size-3 text-amber-400" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 leading-snug">{c}</span>
|
||||
<Button
|
||||
variant={approved[i] ? 'ghost' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => toggle(i)}
|
||||
disabled={busy}
|
||||
data-testid="checkpoint-toggle"
|
||||
>
|
||||
{approved[i] ? 'Remove' : 'Keep'}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* --- Choose your nation (cards mirror NationSelect's pick screen) --- */}
|
||||
<h2 className="mb-3 mt-8 text-lg font-semibold">Choose your nation</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{world.nations.map((n) => {
|
||||
const isSel = n.id === selectedId;
|
||||
return (
|
||||
<Card
|
||||
key={n.id}
|
||||
className={cn(
|
||||
'flex flex-col transition',
|
||||
isSel ? 'ring-2 ring-primary/60' : 'hover:ring-1 hover:ring-primary/40',
|
||||
)}
|
||||
data-testid="scenario-nation-card"
|
||||
data-nation-id={n.id}
|
||||
data-selected={isSel ? 'true' : 'false'}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{n.name}
|
||||
{isSel && <Check className="size-4 shrink-0 text-primary" />}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{n.type ?? 'major'} · {n.territory.length} territory unit
|
||||
{n.territory.length === 1 ? '' : 's'} ({n.territory.join(', ')})
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto space-y-1.5">
|
||||
{STAT_BARS.map(({ key, label }) => (
|
||||
<StatBar key={key} label={label} value={n.stats[key]} />
|
||||
))}
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
variant={isSel ? 'default' : 'secondary'}
|
||||
onClick={() => setSelectedId(n.id)}
|
||||
disabled={busy}
|
||||
data-testid="scenario-lead-nation"
|
||||
>
|
||||
{isSel ? 'Leading' : `Play as ${n.name}`}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* --- Finalize --- */}
|
||||
<div
|
||||
className="mt-8 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3"
|
||||
data-testid="finalize-bar"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-sm text-muted-foreground">
|
||||
{selected
|
||||
? `Finalizing as ${selected.name} with ${approvedCount} of ${checkpoints.length} checkpoints approved.`
|
||||
: 'Pick the nation you will lead, then finalize the scenario.'}
|
||||
</span>
|
||||
<Button className="shrink-0" onClick={finalize} disabled={busy || !selected} data-testid="finalize-scenario">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||
{busy ? 'Finalizing…' : 'Finalize scenario'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Play, Trash2 } from 'lucide-react';
|
||||
import { Play, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
@@ -26,6 +26,12 @@ interface Props {
|
||||
onResume?: (id: string) => void;
|
||||
/** Delete a saved game by id (confirmed by a dialog in this component). */
|
||||
onDelete?: (id: string) => void;
|
||||
/**
|
||||
* (v0.31, #52) Open the "Create scenario" path (era template + description →
|
||||
* AI-generated world → checkpoint review → a normal game). Omitted = the
|
||||
* create-scenario card is hidden.
|
||||
*/
|
||||
onCreateScenario?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +47,7 @@ export default function PresetPicker({
|
||||
activeGameId,
|
||||
onResume,
|
||||
onDelete,
|
||||
onCreateScenario,
|
||||
}: Props) {
|
||||
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
||||
// The game awaiting a delete confirmation (null = dialog closed).
|
||||
@@ -83,6 +90,51 @@ export default function PresetPicker({
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(
|
||||
onCreateScenario
|
||||
? [
|
||||
{
|
||||
// (v0.31, #52) The dynamic path, first in the grid: it is the
|
||||
// "any world you can describe" card, distinct from the static presets.
|
||||
id: 'create',
|
||||
title: 'Create scenario',
|
||||
summary:
|
||||
'Design your own world — pick an era, describe it, and the AI drafts the world and the advisor’s opening checkpoints for your review before you pick a nation to lead.',
|
||||
badge: 'custom world',
|
||||
},
|
||||
]
|
||||
: []
|
||||
).map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
className="flex flex-col border-primary/40 bg-primary/5 transition hover:ring-1 hover:ring-primary/50"
|
||||
data-testid="create-scenario-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Sparkles className="size-4 shrink-0 text-primary" />
|
||||
{c.title}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{c.badge}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-sm leading-relaxed">{c.summary}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => onCreateScenario?.()}
|
||||
disabled={loading}
|
||||
data-testid="create-scenario"
|
||||
>
|
||||
Create scenario
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{presets.map((p) => (
|
||||
<Card
|
||||
key={p.id}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TemplateMeta } from '@shared/scenario';
|
||||
|
||||
interface Props {
|
||||
/** A generate (LLM) call is in flight — disables the form's controls. */
|
||||
busy?: boolean;
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Step 1: design the draft world + checkpoint list. The form hands over the
|
||||
* selected template's full metadata (the review screen shows its name/year)
|
||||
* plus the description and the effective start year.
|
||||
*/
|
||||
onGenerate: (template: TemplateMeta, description: string, year: number) => void;
|
||||
}
|
||||
|
||||
/** The year input is an empty string ("use the template's default") or a number. */
|
||||
const YEAR_MIN = 1000;
|
||||
const YEAR_MAX = 3000;
|
||||
|
||||
/**
|
||||
* "Create scenario" — step 1 of the dynamic path (v0.31, #52). The user picks
|
||||
* one of the light era templates (a card each: name, default year, blurb),
|
||||
* writes a free-text description of the world they want, and may override the
|
||||
* start year (the field is pre-filled from the selected template; leaving it
|
||||
* empty keeps the template's default). "Generate world" posts the draft
|
||||
* request; on success the app moves to the checkpoint review screen, on
|
||||
* failure it toasts and stays here (the form's local state is preserved).
|
||||
*/
|
||||
export default function ScenarioForm({ busy = false, onBack, onGenerate }: Props) {
|
||||
const [templates, setTemplates] = useState<TemplateMeta[]>([]);
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
// Pre-filled from the selected template's default year (the user can edit).
|
||||
const [yearText, setYearText] = useState('');
|
||||
|
||||
// Load the era-template manifest on mount (the form is the only consumer).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.listScenarioTemplates()
|
||||
.then((r) => alive && setTemplates(r.templates))
|
||||
.catch((e: unknown) => alive && setTemplatesError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selected = templates.find((t) => t.id === selectedId) ?? null;
|
||||
const effectiveYear =
|
||||
yearText.trim() === '' ? NaN : Number.parseInt(yearText.trim(), 10);
|
||||
const yearValid =
|
||||
Number.isInteger(effectiveYear) && effectiveYear >= YEAR_MIN && effectiveYear <= YEAR_MAX;
|
||||
|
||||
const canGenerate = !busy && selected !== null && description.trim() !== '' && yearValid;
|
||||
|
||||
const selectTemplate = (t: TemplateMeta) => {
|
||||
setSelectedId(t.id);
|
||||
// Selecting an era pre-fills the year with that era's default (the user
|
||||
// can still edit it afterwards).
|
||||
setYearText(String(t.year));
|
||||
};
|
||||
|
||||
const generate = () => {
|
||||
if (!canGenerate || !selected) return;
|
||||
onGenerate(selected, description.trim(), effectiveYear);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-8" data-testid="scenario-form">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||
← Back to scenarios
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Create a scenario</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
Design your own world: pick an era, describe what should be true, and the AI will draft the
|
||||
world and an opening set of advisor checkpoints for your review. Nothing is saved until you
|
||||
finalize.
|
||||
</p>
|
||||
|
||||
{/* 1 — the era template (a card each). */}
|
||||
<h2 className="mb-3 mt-8 text-lg font-semibold">
|
||||
1 · Pick an era <span className="font-normal text-muted-foreground">(required)</span>
|
||||
</h2>
|
||||
{templatesError ? (
|
||||
<p className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
Could not load the era templates: {templatesError}
|
||||
</p>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="rounded-lg border border-border/60 bg-card/40 p-3 text-sm text-muted-foreground">
|
||||
Loading era templates…
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{templates.map((t) => {
|
||||
const isSel = t.id === selectedId;
|
||||
return (
|
||||
<Card
|
||||
key={t.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col transition',
|
||||
isSel ? 'ring-2 ring-primary/60' : 'hover:ring-1 hover:ring-primary/40',
|
||||
)}
|
||||
data-testid="template-card"
|
||||
data-template-id={t.id}
|
||||
data-selected={isSel ? 'true' : 'false'}
|
||||
onClick={() => selectTemplate(t)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base">{t.name}</CardTitle>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{t.year}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-sm leading-relaxed">
|
||||
{t.eraDescription}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-xs',
|
||||
isSel ? 'font-medium text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
{isSel ? 'Selected' : 'Start from this era'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2 — the description (+ optional year override). */}
|
||||
<h2 className="mb-3 mt-8 text-lg font-semibold">
|
||||
2 · Describe your world <span className="font-normal text-muted-foreground">(required)</span>
|
||||
</h2>
|
||||
<div className="space-y-4 rounded-lg border border-border/60 bg-card/40 p-4">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g. “A cold-war world where the space race decided the Cold War — two blocs orbit, and the arctic shipping lanes are the new flashpoint.”"
|
||||
rows={4}
|
||||
disabled={busy}
|
||||
data-testid="scenario-description"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="scenario-year-input" className="shrink-0 text-sm">
|
||||
Start year
|
||||
</Label>
|
||||
<Input
|
||||
id="scenario-year-input"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={yearText}
|
||||
onChange={(e) => setYearText(e.target.value)}
|
||||
placeholder={selected ? `default ${selected.year}` : 'pick an era first'}
|
||||
min={YEAR_MIN}
|
||||
max={YEAR_MAX}
|
||||
disabled={busy || !selected}
|
||||
className="w-32"
|
||||
data-testid="scenario-year"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{yearValid ? `The game begins on ${effectiveYear}-01-01.` : 'Leave empty for the era default, or use a year between 1000 and 3000.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate. */}
|
||||
<div className="mt-8">
|
||||
<Button className="h-11 w-full" onClick={generate} disabled={!canGenerate} data-testid="generate-scenario">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||
{busy ? 'Generating world…' : 'Generate world'}
|
||||
</Button>
|
||||
{busy && (
|
||||
<p className="mt-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||||
The world-builder is thinking — this can take a few seconds.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,13 @@ import type {
|
||||
RemoveQueueItemResponse,
|
||||
TurnResponse,
|
||||
} from '@shared/protocol';
|
||||
import type {
|
||||
ScenarioCommitRequest,
|
||||
ScenarioCommitResponse,
|
||||
ScenarioDraftRequest,
|
||||
ScenarioDraftResponse,
|
||||
TemplateMeta,
|
||||
} from '@shared/scenario';
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
@@ -95,4 +102,24 @@ export const api = {
|
||||
req<{ regions: Record<string, { color: string; owner: string; ownerId: string }> }>(
|
||||
`/api/games/${id}/map`,
|
||||
),
|
||||
// --- Dynamic scenario generation (v0.31, #52) ---------------------------
|
||||
// The additive "Create scenario" path: a two-step LLM flow (design a draft
|
||||
// world + checkpoint list, then finalize it into a saved, playable game).
|
||||
// The era-template manifest for the form's cards (id / name / year / blurb).
|
||||
listScenarioTemplates: () => req<{ templates: TemplateMeta[] }>('/api/scenarios/templates'),
|
||||
// Step 1: design a draft world + an advisor checkpoint list from an era
|
||||
// template + a free-text description. An LLM call — can be slow; a 502
|
||||
// means the model is down or returned nothing usable (the caller toasts).
|
||||
draftScenario: (body: ScenarioDraftRequest) =>
|
||||
req<ScenarioDraftResponse>('/api/scenarios/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
// Step 2: finalize the approved draft (constrained to the approved
|
||||
// checkpoints) into a new, playable game and save it. Returns the id.
|
||||
commitScenario: (body: ScenarioCommitRequest) =>
|
||||
req<ScenarioCommitResponse>('/api/scenarios/commit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ export const OUTCOME_COLORS: Record<EventOutcome, string> = {
|
||||
failure: '#ef4444',
|
||||
partial: '#f59e0b',
|
||||
neutral: '#94a3b8',
|
||||
// (v0.30, #50) engine-emitted "invasion repelled" — a muted, darker red so it
|
||||
// reads as a defensive failure distinct from a bright outright `failure`.
|
||||
repelled: '#9f1239',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user