CI / build + unit + e2e (pull_request) Successful in 2m11s
- scripts/build-region-resources.mjs: deterministic, idempotent builder →
public/region-resources.json (1,800 entries; FNV-1a hash × area factor, 0–1, 2dp)
- server/regions.ts: hasResources()/resourceOf() optional-manifest loader
- shared/types.ts: RegionResources, IndustryType/INDUSTRY_TYPES, IndustryProject,
GameState.industry
- shared/protocol.ts: IndustryOps, TurnResult.industry, deriveNationResources
(area×population-weighted average, 2dp)
- server/engine.ts: isIndustryType, validateIndustryOps, applyIndustryOps
(per-region SET, first-declared-type wins), applyIndustryEffects
(INDUSTRY_ECONOMY_BONUS=1, +1 when region industry≥0.5; sole-owner only;
0/2+ owner regions kept but no boost; re-derive after), system+user prompt
contract lines, runTurn resourceOf opt, nationResources in user payload
- server/db.ts: legacy-row industry default migration
- server/stub.ts: industry:{projects:[]} in stub result; resources/industry
in StubPayload (never read)
- server/index.ts: resourceOf wired to jump route
- All test fixtures updated; new describe blocks in engine.test.ts,
protocol.test.ts, regions.test.ts, db.test.ts
- CHANGELOG.md v0.36.0, AGENTS.md docs, package.json 0.36.0
Fixes #59
441 lines
18 KiB
TypeScript
441 lines
18 KiB
TypeScript
// Domain types — the single source of truth shared by the frontend (src/), the
|
||
// backend (server/), and the LLM prompt contract. No runtime dependencies here.
|
||
// (The single `import type` below erases at compile time, so no runtime
|
||
// dependency on protocol.ts is introduced — see the GameState.pendingNationOps
|
||
// doc comment.)
|
||
import type { NationOp } from './protocol';
|
||
|
||
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
||
export interface NationStats {
|
||
military: number;
|
||
economy: number;
|
||
stability: number;
|
||
population: number;
|
||
}
|
||
|
||
/**
|
||
* (v0.28, #46) The 0–100 status indices of a **single region**. Per-region stats
|
||
* are the unit of record: a nation's aggregate {@link NationStats} is derived as
|
||
* the area×population-weighted median of the stats of the regions it owns (see
|
||
* `protocol.deriveNationStats`). Every value is clamped to 0–100 by the engine.
|
||
*/
|
||
export interface RegionStats {
|
||
military: number;
|
||
economy: number;
|
||
stability: number;
|
||
population: number;
|
||
}
|
||
|
||
/**
|
||
* (v0.36, #59) A region's synthesized four-value resource profile — each value
|
||
* a finite fraction in [0, 1]. Built once per region key by
|
||
* `scripts/build-region-resources.mjs` into the committed
|
||
* `public/region-resources.json` (the engine reads it via `regions.resourceOf`).
|
||
* The engine scales a region's per-turn industry boost by its `industry` value,
|
||
* and a nation's aggregate profile (the area×population-weighted mean of its
|
||
* owned regions' profiles, see `protocol.deriveNationResources`) is what the
|
||
* LLM prompt carries so the model can reason about where to build.
|
||
*/
|
||
export interface RegionResources {
|
||
oil: number;
|
||
iron: number;
|
||
food: number;
|
||
industry: number;
|
||
}
|
||
|
||
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
||
export type StatKey = (typeof STAT_KEYS)[number];
|
||
|
||
export type NationType = 'major' | 'minor' | 'power' | 'alliance' | 'faction';
|
||
|
||
/** Direction of a bilateral diplomatic relationship. */
|
||
export type RelationStatus = 'ally' | 'neutral' | 'rival' | 'hostile' | 'war';
|
||
|
||
export const RELATION_STATUSES: RelationStatus[] = [
|
||
'ally',
|
||
'neutral',
|
||
'rival',
|
||
'hostile',
|
||
'war',
|
||
];
|
||
|
||
/**
|
||
* A single nation in the world. `territory` holds **region keys** (see
|
||
* `public/region-keys.json`) — one per owned sub-region, so the choropleth can
|
||
* color individual sub-regions, not whole countries (v0.19, #16 P1). The map
|
||
* resolves each sub-region's owner by its region key (see protocol.mapFeatureOwner).
|
||
*/
|
||
export interface Nation {
|
||
id: string;
|
||
name: string;
|
||
type: NationType;
|
||
stats: NationStats;
|
||
territory: string[];
|
||
relations: Record<string, RelationStatus>;
|
||
}
|
||
|
||
/** Categories a narrative event can take. */
|
||
export type EventType =
|
||
| 'military'
|
||
| 'diplomatic'
|
||
| 'economic'
|
||
| 'political'
|
||
| 'social'
|
||
| 'disaster'
|
||
| 'scientific'
|
||
| 'cultural';
|
||
|
||
export const EVENT_TYPES: EventType[] = [
|
||
'military',
|
||
'diplomatic',
|
||
'economic',
|
||
'political',
|
||
'social',
|
||
'disaster',
|
||
'scientific',
|
||
'cultural',
|
||
];
|
||
|
||
/**
|
||
* 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) -------------------------------------------------
|
||
//
|
||
// Named point markers the LLM can place on the map (cities, factories, armies,
|
||
// ports, capitals, fleets, rocket launch sites, political/social gatherings).
|
||
// A marker is anchored to a **region key** (see `public/region-keys.json`): the
|
||
// engine positions it at that region's precomputed centroid (with a small
|
||
// deterministic offset) and its `ownerNationId` follows the region's current
|
||
// owner (recomputed on every turn; `null` when the region is unowned / no
|
||
// man's land). See protocol.ts for the LLM-facing `TurnResult.markers`
|
||
// contract and the `markerId` derivation.
|
||
|
||
/** The eight marker categories the model may place (v0.27, #44). */
|
||
export type MarkerType =
|
||
| 'city'
|
||
| 'factory'
|
||
| 'army'
|
||
| 'port'
|
||
| 'capital'
|
||
| 'fleet'
|
||
| 'rocket'
|
||
| 'gathering';
|
||
|
||
/** All marker types, in canonical order (used by the LLM contract + map layer). */
|
||
export const MARKER_TYPES: MarkerType[] = [
|
||
'city',
|
||
'factory',
|
||
'army',
|
||
'port',
|
||
'capital',
|
||
'fleet',
|
||
'rocket',
|
||
'gathering',
|
||
];
|
||
|
||
/**
|
||
* A named point marker on the map (v0.27, #44). `id` is a stable,
|
||
* deterministic 4-character code derived from `type + name + regionKey`
|
||
* (see protocol.markerId) — re-adding the same marker is idempotent and
|
||
* removing by id is unambiguous. `lat`/`lon` are filled in by the engine from
|
||
* the region's centroid manifest (never by the model); `ownerNationId` is the
|
||
* marker's region's current owner (`null` when unowned) and is recomputed by
|
||
* the engine on every committed turn, so a marker always follows its region.
|
||
*/
|
||
export interface MapMarker {
|
||
id: string;
|
||
type: MarkerType;
|
||
name: string;
|
||
regionKey: string;
|
||
lat: number;
|
||
lon: number;
|
||
ownerNationId: string | null;
|
||
placedTurn: number;
|
||
}
|
||
|
||
/**
|
||
* (v0.35.0, #58) An active BILATERAL TRADE agreement between two distinct
|
||
* nations. Declared by the LLM via `TurnResult.economy.trade` (a no-`lift`
|
||
* op) and ended by a `lift: true` op; while active it yields a small,
|
||
* deterministic economy gain to BOTH parties every committed turn. The
|
||
* engine suspends (removes) the trade automatically when the two parties end
|
||
* up on opposite sides of an active war (v0.33, #56).
|
||
*/
|
||
export interface TradeAgreement {
|
||
/** First party's nation id. */
|
||
a: string;
|
||
/** Second party's nation id (distinct from `a`; the pair is symmetric). */
|
||
b: string;
|
||
}
|
||
|
||
/**
|
||
* (v0.35.0, #58) An active UNILATERAL EMBARGO: `from` blocks trade with
|
||
* `target` (which must differ from `from`). Declared by the LLM via
|
||
* `TurnResult.economy.embargo` (a no-`lift` op) and lifted by a `lift: true`
|
||
* op; while active it applies a small, deterministic economy dip to the
|
||
* TARGET only, every committed turn. The engine suspends (removes) the
|
||
* embargo automatically when the two nations end up on opposite sides of an
|
||
* active war (v0.33, #56).
|
||
*/
|
||
export interface Embargo {
|
||
/** The imposing nation's id. */
|
||
from: string;
|
||
/** The embargoed nation's id (distinct from `from`). */
|
||
target: string;
|
||
}
|
||
|
||
// --- Resources & industry (v0.36, #59) ---------------------------------------
|
||
//
|
||
// The LLM can build per-region INDUSTRY PROJECTS (an oil facility, an iron
|
||
// mine, a food operation, or a factory) on regions it controls; each active
|
||
// project gives that region's sole owner a small, deterministic economy boost
|
||
// every committed turn, scaled by the region's `industry` resource value from
|
||
// the committed resource manifest. The active projects live in
|
||
// `GameState.industry` — the LLM's "industry" lever.
|
||
|
||
/** The four industry project types a region can host (v0.36, #59). */
|
||
export type IndustryType = 'oil' | 'iron' | 'food' | 'industry';
|
||
|
||
/** All industry project types, in canonical order (the LLM contract + validator). */
|
||
export const INDUSTRY_TYPES: IndustryType[] = ['oil', 'iron', 'food', 'industry'];
|
||
|
||
/**
|
||
* (v0.36, #59) One active industry project: a building of a given
|
||
* {@link IndustryType} in a specific region. The engine keys the per-turn
|
||
* economy boost by the region's SOLE owner (a region with no owner or 2+
|
||
* owners yields no boost) and scales it by the region's `industry` resource
|
||
* value. A region hosts at most ONE project — re-declaring on a region that
|
||
* already has one is a no-op (the first declared type wins).
|
||
*/
|
||
export interface IndustryProject {
|
||
/** The region key the project sits in (see `public/region-keys.json`). */
|
||
regionKey: string;
|
||
/** The kind of project built there (see {@link IndustryType}). */
|
||
type: IndustryType;
|
||
}
|
||
|
||
/**
|
||
* (v0.33.0, #56) A first-class **war** — a named, ongoing armed conflict
|
||
* between two or more nations. Unlike the boolean `war` relation (which a
|
||
* preset or a bare `relations` entry can carry without any structure behind
|
||
* it), a `War` is an explicit world entity the engine tracks: the LLM
|
||
* declares it (a turn's `wars.declare`), the engine assigns it a stable id
|
||
* (`w1`, `w2`, …), the turn and in-game date it was declared, and it stays in
|
||
* `GameState.wars` for the rest of the game as history after it ends.
|
||
*
|
||
* **Semantics of `sides`:** the flat list of every belligerent in the war
|
||
* (≥2 distinct nation ids). Any two distinct members of an active war's
|
||
* sides are at war with each other — the engine derives the symmetric `war`
|
||
* relation for every such pair on every turn (see the engine's
|
||
* `syncWarRelations`), and downgrades the relation to `hostile` when the war
|
||
* ends. A mutual-defense ally of a belligerent is dragged into the same list
|
||
* on declaration (the engine does this automatically).
|
||
*/
|
||
export interface War {
|
||
/** Stable id (`w1`, `w2`, …) assigned by the engine on declaration. */
|
||
id: string;
|
||
/** The belligerent nations (≥2 distinct ids; see the note above). */
|
||
sides: string[];
|
||
/** The turn number the war was declared on. */
|
||
declaredTurn: number;
|
||
/** The in-game date the war took effect on (`YYYY-MM-DD`). */
|
||
declaredDate?: string;
|
||
/** Optional short war aims the LLM names at declaration. */
|
||
aims?: string[];
|
||
/**
|
||
* Lifecycle: `active` until a turn ends the war, then the end outcome —
|
||
* `armistice` (a negotiated stop) or `capitulation` (one side surrenders) —
|
||
* or the generic `ended`. Ended wars stay in `GameState.wars` as history.
|
||
*/
|
||
status: 'active' | 'armistice' | 'capitulation' | 'ended';
|
||
/** Optional region keys the war's front line runs through (reserved). */
|
||
front?: string[];
|
||
}
|
||
|
||
/** A narrative event the simulation emitted on some turn. */
|
||
export interface EventEntry {
|
||
id: string;
|
||
turn: number;
|
||
date: string;
|
||
nation: string; // nation id the event concerns
|
||
type: EventType;
|
||
title: string;
|
||
text: string;
|
||
outcome: EventOutcome;
|
||
}
|
||
|
||
/**
|
||
* A single item queued in the current round, waiting to be committed by a
|
||
* "Jump forward". `kind: 'action'` is a free-text player move; `kind:
|
||
* 'diplomacy'` is a message to a specific nation (`to`).
|
||
*/
|
||
export type RoundItem = {
|
||
kind: 'action' | 'diplomacy';
|
||
text: string;
|
||
to?: string;
|
||
};
|
||
|
||
/**
|
||
* Monotonic revision counter for a game, bumped by the server on every
|
||
* mutation (queue add/remove, turn commit) and persisted with the state.
|
||
* Clients use it to tell a fresh state from a stale one: an SSE `state`/`turn`
|
||
* snapshot (e.g. the on-connect resync) that arrives *after* a fresher REST
|
||
* response carries a lower version and must be ignored, never applied
|
||
* (see the client's "never go backwards" rule in src/App.tsx). Legacy games
|
||
* saved before this field existed are treated as version 0 by consumers.
|
||
*/
|
||
export type GameVersion = number;
|
||
|
||
/** The full, persisted state of one game. Saved to SQLite by id. */
|
||
export interface GameState {
|
||
id: string;
|
||
presetId: string;
|
||
presetTitle: string;
|
||
playerNationId: string;
|
||
turn: number;
|
||
date: string;
|
||
nations: Record<string, Nation>;
|
||
events: EventEntry[];
|
||
/**
|
||
* Actions + diplomatic messages queued this round but not yet committed.
|
||
* Adding an item never advances the turn; a "Jump forward" processes the whole
|
||
* batch in a single turn and clears this back to `[]`.
|
||
*/
|
||
pending: RoundItem[];
|
||
/**
|
||
* Monotonic revision counter (see GameVersion). Bumped on every mutation so
|
||
* clients can reject stale SSE snapshots; see the "never go backwards" rule
|
||
* in src/App.tsx. Starts at 1 on game creation.
|
||
*/
|
||
version: GameVersion;
|
||
/**
|
||
* (v0.22, #16 P3) Region keys that became **unowned** — "no man's land" —
|
||
* during play: a region that was owned and was released to no owner by a
|
||
* committed turn (the LLM narrates the cause in that turn's events). This is
|
||
* deliberately distinct from the many regions a preset never assigns, which
|
||
* are also unowned but were never *released* mid-game; only the keys in this
|
||
* list render as the distinct visible no-man's-land grey (see
|
||
* protocol.regionFillStyle). A region leaves the list when any nation
|
||
* re-claims it (an ordinary `gain`). Always `[]` on a fresh game — no
|
||
* man's land can only arise mid-game. Rows saved before v0.22 load with
|
||
* this defaulted to `[]` (see server/db.ts).
|
||
*/
|
||
unowned: string[];
|
||
/**
|
||
* (v0.23, #16 P4a; v0.24, #16 P4b) Nation-management ops the LLM has
|
||
* proposed but the player has not yet resolved: a *pending proposal* list,
|
||
* as opposed to `pending` (the current round's queued player actions). A
|
||
* turn NEVER applies one — `applyTurnResult` appends the turn's
|
||
* `nationOps` to this list, and each proposal survives across turns until
|
||
* the player resolves it: confirming applies it (a rename changes
|
||
* `Nation.name` only; a split partitions the nation's region-key territory
|
||
* into the new nations' territories; a merge folds the inputs' territory
|
||
* into the survivor) and removes it; rejecting removes it without
|
||
* applying. Always `[]` on a fresh game; rows saved before v0.23 load with
|
||
* this defaulted to `[]` (see server/db.ts). (The `NationOp` type lives in
|
||
* protocol.ts; the import there is type-only, so this file still has no
|
||
* runtime dependencies.)
|
||
*/
|
||
pendingNationOps: NationOp[];
|
||
/**
|
||
* (v0.27, #44) Named point markers on the map: the LLM places them (via a
|
||
* turn's `markers.add`) and removes them (`markers.remove`, by id). The
|
||
* engine positions each at its region's centroid (deterministic small
|
||
* offset) and recomputes `ownerNationId` from the region's current owner on
|
||
* EVERY committed turn (a released region → `null`). Always `[]` on a fresh
|
||
* game; rows saved before v0.27 load with this defaulted to `[]` (see
|
||
* server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS.
|
||
*/
|
||
markers: MapMarker[];
|
||
/**
|
||
* (v0.28, #46) Per-region stats, keyed by **region key** (see
|
||
* `public/region-keys.json`): each owned region carries its own four 0–100
|
||
* stats, and a nation's aggregate `Nation.stats` is the area×population-
|
||
* weighted median of the regions it owns (see `protocol.deriveNationStats`).
|
||
* A region that ends a turn **unowned** (released to no man's land) has NO
|
||
* entry, and a preset-unassigned region (never owned) has none until first
|
||
* claimed (re-claiming re-inits it to the gainer's stats). Always present on a
|
||
* fresh game (every owned region seeded with its owner's initial stats); a
|
||
* legacy SQLite row without the field is migrated at load (see server/db.ts)
|
||
* — additive, no data wipe.
|
||
*/
|
||
regionStats: Record<string, RegionStats>;
|
||
/**
|
||
* (v0.33.0, #56) First-class war entities (see {@link War}): declared by a
|
||
* turn's `wars.declare` (the engine assigns the id, declared turn/date, and
|
||
* drags in mutual-defense allies) and closed by `wars.end` (armistice /
|
||
* capitulation). The engine DERIVES the `war` relation from the ACTIVE
|
||
* wars on every turn (two nations are in a `war` relation iff they are both
|
||
* belligerents of ≥1 active war), so the boolean relation and the war
|
||
* entities can never drift apart. A bare preset `war` relation with no
|
||
* active war behind it stays as-is (an implicit, unnamed war — never
|
||
* force-created into an entity). Always `[]` on a fresh game; rows saved
|
||
* before v0.33 load with this defaulted to `[]` (see server/db.ts) —
|
||
* additive, no data wipe.
|
||
*/
|
||
wars: War[];
|
||
/**
|
||
* (v0.35.0, #58) Active bilateral trade agreements (see {@link TradeAgreement}):
|
||
* declared/ended by a turn's `economy.trade` ops (a no-`lift` op declares,
|
||
* `lift: true` ends). Every committed turn the engine applies a small,
|
||
* deterministic economy gain to BOTH parties of each active agreement, and
|
||
* suspends (removes) any agreement whose two parties are on opposite sides
|
||
* of an active war (v0.33, #56). Always `[]` on a fresh game; rows saved
|
||
* before v0.35 load with this defaulted to `[]` (see server/db.ts) —
|
||
* additive, no data wipe.
|
||
*/
|
||
trades: TradeAgreement[];
|
||
/**
|
||
* (v0.35.0, #58) Active unilateral embargoes (see {@link Embargo}):
|
||
* imposed/lifted by a turn's `economy.embargo` ops (a no-`lift` op imposes,
|
||
* `lift: true` lifts). Every committed turn the engine applies a small,
|
||
* deterministic economy dip to each embargo's TARGET only, and suspends
|
||
* (removes) any embargo whose two nations are on opposite sides of an
|
||
* active war (v0.33, #56). Always `[]` on a fresh game; rows saved before
|
||
* v0.35 load with this defaulted to `[]` (see server/db.ts) — additive,
|
||
* no data wipe.
|
||
*/
|
||
embargoes: Embargo[];
|
||
/**
|
||
* (v0.36, #59) Active industry projects (see {@link IndustryProject}): built
|
||
* by a turn's `industry.projects` ops (the LLM names a region key + a
|
||
* project type). Each region hosts at most one project — re-declaring on a
|
||
* region that already has one is a no-op. Every committed turn the engine
|
||
* gives each project's region's SOLE owner a small, deterministic economy
|
||
* boost scaled by that region's `industry` resource value. A project on a
|
||
* region that ends a turn with no owner (or 2+ owners) yields no boost but
|
||
* is NOT removed. Always `[]` on a fresh game; rows saved before v0.36 load
|
||
* with this defaulted to `[]` (see server/db.ts) — additive, no data wipe.
|
||
*/
|
||
industry: IndustryProject[];
|
||
}
|
||
|
||
/**
|
||
* A scenario the player can start. One JSON file per preset (presets/<id>.json).
|
||
* `worldBackground` + `simulationRules` become the LLM system prompt.
|
||
*/
|
||
export interface Preset {
|
||
id: string;
|
||
title: string;
|
||
startDate: string;
|
||
summary: string;
|
||
worldBackground: string;
|
||
simulationRules: string[];
|
||
nations: Nation[];
|
||
}
|
||
|
||
/** The lightweight manifest entry used to render the preset picker cards. */
|
||
export interface PresetMeta {
|
||
id: string;
|
||
title: string;
|
||
startDate: string;
|
||
summary: string;
|
||
}
|