CI / build + unit + e2e (pull_request) Successful in 2m32s
Promote the bare 'war' relation to a named War entity in GameState.wars:
- War: { id, sides[], declaredTurn, declaredDate?, aims?, status, front? }
- TurnResult.wars: { declare: WarDeclareOp[], end: WarEndOp[] }
- syncWarRelations: derive 'war' from active wars, fallback to 'hostile' on end
- Mutual-defense drag-in: belligerent allies added to sides (one level)
- Bare 'war' back-compat: never downgraded, never force-created
- Stub: empty wars (e2e unaffected); ~30 new unit tests
v0.33.0
462 lines
17 KiB
TypeScript
462 lines
17 KiB
TypeScript
// 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),
|
||
// (v0.33.0, #56) No wars on a fresh (dynamic) game; any preset-level `war`
|
||
// relation stays a bare/implicit one, not an entity (see syncWarRelations).
|
||
wars: [],
|
||
};
|
||
return state;
|
||
}
|