Files
History/shared/protocol.ts
kamilkrzywda fdf6eb236a feat: sub-region-level territory — ownership, LLM and map at region granularity (v0.19.0)
Move territory from whole-country (ISO_A3) to individual sub-region id
granularity (PR 3 of #16):

- new server/regions.ts (turf-free): loadRegions (boot), expandTerritory
  (game creation), migrateTerritoryToRegions (legacy games on db.load),
  regionNamesFor / regionLabel (LLM prompt), graceful ISO_A3 fallback
- game creation expands each preset nation's ISO_A3 list to its sub-region
  ids; pre-v0.19 saved games migrate transparently on read (idempotent)
- LLM contract: territoryChanges gain/lose sub-region ids (one region at a
  time; gain moves out of the prior owner; lost-without-gainer = unowned);
  user prompt carries a top-level regions reference table
  ({id: 'name (parent)'} over the state's territory ids)
- WorldMap: choropleth fill per sub-region (countries can split between
  nations), ownership via properties.regionId through the shared
  mapFeatureOwner, flash lights exactly the changed features
  (featuresByRegionIds)
- deterministic stub: one region-level move per non-empty round (player
  annexes the lexicographically-first region of the first other nation)
- tests: new server/regions.test.ts (real geo + fallback + seams);
  map/engine tests re-pinned to region ids; scripts/regions.test.ts stays green

Measured full wwii-1939 user prompt (411 regions): ~24.7k chars / ~6.2k
tokens — far under the 80k frontier limit, so the full regions table is
always sent (no frontier-limited mode needed).

Part of #16
2026-10-13 22:07:18 +00:00

247 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Client <-> server protocol types + a few shared pure helpers (single source of
// truth). The LLM is asked to return exactly the `TurnResult` shape; the engine
// validates/clamps it before applying it to a GameState.
import type {
EventEntry,
EventOutcome,
EventType,
GameState,
Nation,
RelationStatus,
StatKey,
} from './types';
/** A numeric change to one of a nation's 0–100 stats. */
export type StateChange =
| { nation: string; stat: StatKey; op: 'set'; value: number }
| { nation: string; stat: StatKey; op: 'add'; value: number };
/** Set the bilateral relation between nations `a` and `b` to `set` (symmetric). */
export interface RelationChange {
a: string;
b: string;
set: RelationStatus;
}
/**
* A nation gains/loses **sub-region ids** this turn (e.g. `FR-57`), not
* whole-country ISO_A3 codes. Each id identifies a single sub-region, so a
* border can shift one region at a time; a gained region is moved out of
* whichever nation currently owns it, and a lost region with no matching
* gainer simply becomes unowned (no-man's-land).
*/
export interface TerritoryChange {
nation: string;
gain: string[];
lose: string[];
}
/** One narrative event the LLM proposes for this turn. */
export interface TurnEventSpec {
nation: string;
type: EventType;
title: string;
text: string;
outcome: EventOutcome;
}
/** The exact JSON object the world-simulator LLM must return for a turn. */
export interface TurnResult {
date: string;
events: TurnEventSpec[];
stateChanges: StateChange[];
relations: RelationChange[];
territoryChanges: TerritoryChange[];
}
/** Per-region map payload: **sub-region id** -> owning nation + its stable color. */
export interface RegionOwner {
color: string;
owner: string;
ownerId: string;
}
/**
* The authoritative region→owner map, **keyed by sub-region id** (e.g. `FR-57`).
* Every sub-region any nation owns appears here; a sub-region in no nation's
* territory is absent (unowned / no-man's-land) and rendered grey by the map.
*/
export type RegionMap = Record<string, RegionOwner>;
/**
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
* The RegionMap is keyed by the **sub-region's own id** (e.g. `FR-57` — never a
* bare ISO_A3 like `FRA`, and never the repacked numeric `feature.id`), so `key`
* is a sub-region id such as `FR-57`. Returns `undefined` for an empty/missing
* key or an unowned region (one in no nation's territory).
*
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner}
* below, which reads the feature's `properties.regionId` — never the repacked
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug
* #6).
*/
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
if (!key) return undefined;
return regions[key];
}
/**
* Resolve the owning-nation record for a **rendered map feature** from the
* authoritative RegionMap. Ownership is now at **sub-region** granularity, so a
* feature's owner is found via its **own sub-region id** — the client stamps
* the feature's id into `properties.regionId` (see WorldMap `applyRegions`), and
* we look that up with `regionOwner`. This is the single shared source of truth
* for that resolution, so the "use `properties.regionId`, never the repacked
* top-level `feature.id` (a MapLibre numeric index, the "no owner" bug #6)" rule
* lives in one tested place rather than in the React component.
*
* Returns `undefined` for a missing feature/regionId or an unowned region.
*/
export function mapFeatureOwner(
regions: RegionMap,
feature: { properties?: { regionId?: unknown } } | null | undefined,
): RegionOwner | undefined {
const regionId = feature?.properties?.regionId;
return regionOwner(regions, typeof regionId === 'string' ? regionId : '');
}
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
export type SseMessage =
| { type: 'state'; state: GameState }
| { type: 'turn'; state: GameState; result: TurnResult }
| { type: 'error'; message: string };
// --- API request / response shapes (REST) ---
export interface CreateGameRequest {
presetId: string;
playerNationId: string;
}
export interface CreateGameResponse {
game: GameState;
}
/** Queue a free-text player action onto the pending round (no turn advance). */
export interface AddActionRequest {
text: string;
}
export interface AddActionResponse {
game: GameState;
}
/** Queue a diplomatic message onto the pending round (no turn advance). */
export interface AddDiplomacyRequest {
to: string;
message: string;
}
export interface AddDiplomacyResponse {
game: GameState;
}
/**
* "Jump forward" — commit the whole pending round to the model in a single
* turn. The body is empty; everything being committed lives in `game.pending`.
*/
export type JumpRequest = Record<string, never>;
export interface TurnResponse {
state: GameState;
result: TurnResult;
}
/** Remove one queued item from the pending round, by its index in `game.pending`. */
export interface RemoveQueueItemRequest {
index: number;
}
export interface RemoveQueueItemResponse {
game: GameState;
}
export interface AdvisorRequest {
/** Optional prompt to steer the brainstorm. */
prompt?: string;
}
export interface AdvisorResponse {
suggestions: string[];
}
export interface GameSummary {
id: string;
presetId: string;
presetTitle: string;
playerNationId: string;
turn: number;
date: string;
}
/** Response for deleting a saved game (see `DELETE /api/games/:id`). */
export interface DeleteGameResponse {
deleted: boolean;
}
/**
* Deterministic, stable per-nation color for the choropleth. Hashes the nation id
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
* the server's RegionMap and any client-side coloring always agree.
*/
export function nationColor(id: string): string {
let h = 2166136261;
for (let i = 0; i < id.length; i++) {
h = Math.imul(h ^ id.charCodeAt(i), 16777619) >>> 0;
}
const hue = h % 360;
return `hsl(${hue} 58% 46%)`;
}
/** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */
export function makeEvent(
spec: TurnEventSpec,
turn: number,
date: string,
): EventEntry {
return {
id: `${turn}-${spec.nation}-${spec.type}-${Math.random().toString(36).slice(2, 8)}`,
turn,
date,
nation: spec.nation,
type: spec.type,
title: spec.title,
text: spec.text,
outcome: spec.outcome,
};
}
/** Defensive helpers used by both the engine (server) and tests. */
export function clampStat(v: number): number {
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(100, Math.round(v)));
}
/** True if the string looks like a `YYYY-MM-DD` date. */
export function isDateLike(s: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s));
}
/** Advance a `YYYY-MM-DD` string by N days (UTC). Invalid input returns as-is. */
export function addDays(date: string, days: number): string {
if (!isDateLike(date)) return date;
const d = new Date(`${date}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export function deepClone<T>(v: T): T {
return JSON.parse(JSON.stringify(v)) as T;
}
/** The subset of a Nation the prompt needs (keeps the LLM context small). */
export function nationForPrompt(n: Nation) {
return {
id: n.id,
name: n.name,
type: n.type,
stats: n.stats,
territory: n.territory,
relations: n.relations,
};
}