CI / build + unit + e2e (pull_request) Successful in 2m8s
- Add a hard military conquest gate (canConquer, CONQUEST_MILITARY_MARGIN=15) enforced in applyTurnResult: a contested invasion is repelled when the attacker's military is more than 15 below the defender's; re-claims of unowned regions are never gated. Emits a new 'repelled' event. - Document the soft levers in the system prompt: stability<30 + gathering marker -> non-military takeover; economy growth/decay drives other stats. - Emit bounded frontierMilitary (per-neighbour balance) and takeoverRisks hints in the user payload (only when present). - repelled outcome color (#9f1239); full unit test coverage; stub untouched. - Bump to v0.30.0. Fixes #50
277 lines
10 KiB
TypeScript
277 lines
10 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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/** 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>;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|