Files
History/shared/protocol.ts
T
kamilkrzywda 764f24fc55 No man's land: TurnResult.unowned + release/re-claim in applyTurnResult + distinct grey map rendering (#16 P3)
- shared/protocol.ts: TurnResult.unowned (keys released this turn) + the map
  presentation contract (regionFillStyle / unownedTooltipLabel, hue-less grey)
- shared/types.ts: required GameState.unowned (cumulative mid-game releases;
  [] at init, distinct from preset-unassigned regions)
- server/engine.ts: applyTurnResult applies releases first (sole-owner only,
  never fabricating no man's land from unassigned keys), then claims against
  the post-release snapshot (gain on an ownerless key = re-claim); lone
  release is isMeaningful; prompts + user payload carry the unowned field
- server/stub.ts: unchanged behavior (never releases; e2e stays stable)
- server/db.ts: legacy rows load with unowned defaulted to []
- server/index.ts: create-game sets []; jump reference table includes
  released keys so the model can name them for re-claim
- WorldMap/App: unowned prop -> visible grey + 'No man's land' tooltip
- unit tests: 105 -> 127 (release/re-claim suites, presentation contract,
  legacy-row normalization)
2026-10-14 12:04:03 +00:00

294 lines
10 KiB
TypeScript
Raw 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 region keys (see `public/region-keys.json`) this turn. */
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[];
/**
* (v0.22, #16 P3) Region keys that become **unowned** — "no man's land" —
* this turn. The engine removes each key from its current (sole) owner so it
* ends the turn ownerless and marks it in `GameState.unowned`; the LLM
* narrates the cause (war, cataclysm, collapse) in `events`. Re-claiming is
* NOT expressed here: a nation re-claims an unowned region with an ordinary
* `gain` on that key. Only keys a nation currently owns can be released;
* empty on normal turns.
*/
unowned: string[];
}
/** Per-region map payload: region key -> owning nation + its stable color. */
export interface RegionOwner {
color: string;
owner: string;
ownerId: string;
}
/**
* The authoritative ownership map: a **region key** (see `public/region-keys.json`)
* -> the owning nation + its stable color. Owned at sub-region granularity (v0.19,
* #16 P1); a key absent from this map is unowned (rendered grey).
*/
export type RegionMap = Record<string, RegionOwner>;
/**
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
* The RegionMap is keyed by **region key** (see `public/region-keys.json`) — one
* per owned sub-region (v0.19, #16 P1) — so `key` is a region key such as
* `moselle-rygy`, never a raw feature id such as `FR-57` and never a
* parent-country ISO_A3. Returns `undefined` for an empty/missing key or an
* unowned region.
*
* 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 stamped `properties.regionKey` — never the repacked
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug #6)
* and never the sub-region id.
*/
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. The choropleth renders sub-national regions, and the
* RegionMap is now keyed by **region key** (one per sub-region, v0.19 #16 P1),
* so a feature's owner is found via its `properties.regionKey` — the region key
* the client stamps from `public/region-keys.json` by the feature's stable id
* (see WorldMap) — and then `regionOwner`. This is the single shared source of
* truth for that resolution, so the "use `regionKey`, never the sub-region `id`
* / repacked `feature.id`" rule lives in one tested place rather than in the
* React component.
*
* Returns `undefined` for a missing feature/regionKey or an unowned region.
*/
export function mapFeatureOwner(
regions: RegionMap,
feature: { properties?: { regionKey?: unknown } } | null | undefined,
): RegionOwner | undefined {
const regionKey = feature?.properties?.regionKey;
return regionOwner(regions, typeof regionKey === 'string' ? regionKey : '');
}
/** 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%)`;
}
// --- 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
// RegionMap (a key absent there is simply unowned): it is decided by the
// client from `GameState.unowned` (the keys released mid-game). The constants
// and the one decision function live here — shared and pure — so the
// client's choropleth is the only consumer and the color contract is testable
// from the server-side tests (shared/*.test.ts is not in the vitest glob).
/** The distinct, visible neutral grey for a **no man's land** region (released mid-game). Hue-less by construction, so it can never collide with a nation's `nationColor` (`hsl(h 58% 46%)`, always chromatic). */
export const NO_MANS_LAND_FILL = '#3f3f46';
/** The border of a no-man's-land region (brighter than a plain unowned one). */
export const NO_MANS_LAND_STROKE = 'rgba(255,255,255,0.25)';
/** The faint neutral fill for a region no nation owns that was never released mid-game (a preset never assigned it). */
export const UNOWNED_FILL = 'rgba(148,163,184,0.05)';
/** The border of a plain unowned region. */
export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)';
/** The border of an owned region (brighter than unowned). */
export const OWNED_STROKE = 'rgba(255,255,255,0.30)';
/** The hover-tooltip sub-line for a region with no owning nation. */
export function unownedTooltipLabel(isNoMansLand: boolean): string {
return isNoMansLand ? "No man's land" : 'No owner';
}
/**
* The fill + border for one sub-region, given its RegionMap entry
* (`undefined` = unowned) and whether its key is no man's land (released
* mid-game, i.e. in `GameState.unowned`). Pure + shared; see the section
* comment above for why the distinction is not encoded in the RegionMap.
*/
export function regionFillStyle(
owner: RegionOwner | undefined,
isNoMansLand: boolean,
): { fill: string; stroke: string } {
if (owner) return { fill: owner.color, stroke: OWNED_STROKE };
return isNoMansLand
? { fill: NO_MANS_LAND_FILL, stroke: NO_MANS_LAND_STROKE }
: { fill: UNOWNED_FILL, stroke: UNOWNED_STROKE };
}
/** 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,
};
}