diff --git a/presets/templates/index.json b/presets/templates/index.json new file mode 100644 index 0000000..56ad988 --- /dev/null +++ b/presets/templates/index.json @@ -0,0 +1,20 @@ +[ + { + "id": "ww2", + "name": "World War II", + "year": 1939, + "eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength." + }, + { + "id": "present", + "name": "Present Day", + "year": 2026, + "eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, AI, and great-power rivalry shape every decision." + }, + { + "id": "near-future", + "name": "Near Future", + "year": 2035, + "eraDescription": "Autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances." + } +] diff --git a/presets/templates/near-future.json b/presets/templates/near-future.json new file mode 100644 index 0000000..23e7492 --- /dev/null +++ b/presets/templates/near-future.json @@ -0,0 +1,17 @@ +{ + "id": "near-future", + "name": "Near Future", + "year": 2035, + "eraDescription": "A near-future world: autonomous fleets, orbital assets, and climate-hamstrung coastlines. Old alliances strain under new technologies and old grievances as the great powers reach for the next horizon.", + "suggestedPowers": [ + { "name": "North American Union", "region": "USA", "note": "The industrial-technological core of the West." }, + { "name": "Chinese Federation", "region": "CHN", "note": "The integrated superpower of the Pacific." }, + { "name": "European Confederation", "region": "FRA", "note": "The united European bloc." }, + { "name": "Russian Union", "region": "RUS", "note": "The resource frontier state." } + ], + "tensions": [ + "Autonomous weapons and the new arms race", + "Climate migration and resource wars", + "The contest for space and orbital dominance" + ] +} diff --git a/presets/templates/present.json b/presets/templates/present.json new file mode 100644 index 0000000..116fa30 --- /dev/null +++ b/presets/templates/present.json @@ -0,0 +1,17 @@ +{ + "id": "present", + "name": "Present Day", + "year": 2026, + "eraDescription": "A multipolar world of blocs, sanctions, and tech supremacy. Climate strain, artificial intelligence, and great-power rivalry shape every decision the leaders of the age must make.", + "suggestedPowers": [ + { "name": "United States", "region": "USA", "note": "The military-technological leader of the Western bloc." }, + { "name": "People's Republic of China", "region": "CHN", "note": "The industrial giant pressing on the Western Pacific." }, + { "name": "Russian Federation", "region": "RUS", "note": "A resource-rich power testing its neighbors' resolve." }, + { "name": "European Union", "region": "FRA", "note": "A large, internally divided economic bloc." } + ], + "tensions": [ + "US–China tech and trade rivalry", + "The war in Europe and the eastern flank", + "AI and cyber dominance" + ] +} diff --git a/presets/templates/ww2.json b/presets/templates/ww2.json new file mode 100644 index 0000000..bf085df --- /dev/null +++ b/presets/templates/ww2.json @@ -0,0 +1,17 @@ +{ + "id": "ww2", + "name": "World War II", + "year": 1939, + "eraDescription": "Europe teeters on the brink of total war. Fascist powers press their advantage across the continent while the democracies marshal their strength, and the great powers watch — and arm — from across the oceans.", + "suggestedPowers": [ + { "name": "German Reich", "region": "DEU", "note": "A militarized Reich at the heart of the continent, its army the most battle-hardened in Europe." }, + { "name": "United Kingdom", "region": "GBR", "note": "An island power that commands the seas but is cut off from the continent." }, + { "name": "Soviet Union", "region": "RUS", "note": "A vast, fragile industrial giant on the eastern frontier." }, + { "name": "United States", "region": "USA", "note": "An industrial colossus, still politically divided over entry into the war." } + ], + "tensions": [ + "The Axis versus the Allies", + "The race for continental resources and sea lanes", + "The uneasy balance before the bomb" + ] +} diff --git a/server/scenario-shared.test.ts b/server/scenario-shared.test.ts new file mode 100644 index 0000000..8d88edc --- /dev/null +++ b/server/scenario-shared.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest'; +import { + relationFromScore, + validateWorldDraft, + worldDraftToGameState, + type WorldDraft, +} from '../shared/scenario'; +import type { NationStats } from '../shared/types'; + +// A deterministic fake ISO_A3 -> region-key expander (two sub-regions per +// country), standing in for server/regions.expandCountriesToKeys in these +// pure-function tests. +const fakeExpand = (countries: string[]): string[] => + countries.flatMap((c) => [`${c}-a`, `${c}-b`]); + +const S = (m: number, e: number, s: number, p: number): NationStats => ({ + military: m, + economy: e, + stability: s, + population: p, +}); + +describe('relationFromScore (v0.31.0, #52)', () => { + it('maps the five sentiment bands to RelationStatus', () => { + expect(relationFromScore(100)).toBe('ally'); + expect(relationFromScore(60)).toBe('ally'); + expect(relationFromScore(0)).toBe('neutral'); + expect(relationFromScore(30)).toBe('neutral'); + expect(relationFromScore(-1)).toBe('rival'); + expect(relationFromScore(-40)).toBe('rival'); + expect(relationFromScore(-41)).toBe('hostile'); + expect(relationFromScore(-80)).toBe('hostile'); + expect(relationFromScore(-81)).toBe('war'); + expect(relationFromScore(-100)).toBe('war'); + }); + + it('clamps out-of-range scores before banding', () => { + expect(relationFromScore(250)).toBe('ally'); + expect(relationFromScore(-250)).toBe('war'); + }); +}); + +describe('validateWorldDraft (v0.31.0, #52)', () => { + it('returns a clean draft from a valid payload', () => { + const raw = { + nations: [ + { id: 'de', name: 'German Reich', type: 'major', stats: S(95, 70, 75, 90), territory: ['DEU'], relations: { uk: 80 } }, + { id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: 80 } }, + ], + }; + const d = validateWorldDraft(raw); + expect(d).not.toBeNull(); + const draft = d as WorldDraft; + expect(draft.nations).toHaveLength(2); + expect(draft.nations[0]).toMatchObject({ id: 'de', name: 'German Reich', type: 'major' }); + expect(draft.nations[0].stats).toEqual(S(95, 70, 75, 90)); + expect(draft.nations[0].territory).toEqual(['DEU']); + expect(draft.nations[0].relations).toEqual({ uk: 80 }); + expect(draft.events).toBeUndefined(); + }); + + it('clamps out-of-range stat values to 0-100 and defaults missing to 50', () => { + const raw = { + nations: [ + { name: 'Over', stats: { military: 150, economy: -20, stability: 7 }, territory: ['DEU'] }, + ], + }; + const d = validateWorldDraft(raw) as WorldDraft; + expect(d.nations[0].stats).toEqual(S(100, 0, 7, 50)); // population missing -> 50 + }); + + it('upper-cases, de-dupes, and (given valid codes) filters territory to known ISO_A3', () => { + const codes = new Set(['DEU', 'GBR', 'FRA']); + const raw = { + nations: [ + { + name: 'Mixed', + // lower-case, duplicated, and one unknown code (USA) to be dropped. + territory: ['deu', 'DEU', 'usa', 'gbr'], + }, + ], + }; + const d = validateWorldDraft(raw, { validCountryCodes: codes }) as WorldDraft; + expect(d.nations[0].territory).toEqual(['DEU', 'GBR']); + }); + + it('keeps unknown codes when no validCountryCodes set is supplied', () => { + const raw = { nations: [{ name: 'X', territory: ['AAA', 'BBB'] }] }; + const d = validateWorldDraft(raw) as WorldDraft; + expect(d.nations[0].territory).toEqual(['AAA', 'BBB']); + }); + + it('resolves relation keys by id or (case-insensitive) name and drops self/unknown refs', () => { + const raw = { + nations: [ + { id: 'de', name: 'Germany', territory: ['DEU'], relations: { 'United Kingdom': 55, de: 99, ghost: 10 } }, + { id: 'uk', name: 'United Kingdom', territory: ['GBR'], relations: { de: -70 } }, + ], + }; + const d = validateWorldDraft(raw) as WorldDraft; + // de: the name-keyed ref resolves to uk, self (de) and unknown (ghost) dropped. + expect(d.nations[0].relations).toEqual({ uk: 55 }); + // uk: the id-keyed ref to de is kept, clamped (already in range). + expect(d.nations[1].relations).toEqual({ de: -70 }); + }); + + it('clamps relation scores to -100..100', () => { + const raw = { + nations: [ + { id: 'a', name: 'A', territory: ['AAA'], relations: { b: 500 } }, + { id: 'b', name: 'B', territory: ['BBB'], relations: { a: -500 } }, + ], + }; + const d = validateWorldDraft(raw) as WorldDraft; + expect(d.nations[0].relations).toEqual({ b: 100 }); + expect(d.nations[1].relations).toEqual({ a: -100 }); + }); + + it('suffices duplicate ids until unique and derives ids from names', () => { + const raw = { + nations: [ + { id: 'x', name: 'First', territory: ['AAA'] }, + { id: 'x', name: 'Second', territory: ['BBB'] }, + { name: 'United Kingdom', territory: ['GBR'] }, // no id -> slug + ], + }; + const d = validateWorldDraft(raw) as WorldDraft; + const ids = d.nations.map((n) => n.id); + expect(ids).toEqual(['x', 'x-2', 'united-kingdom']); + }); + + it('keeps backstory events that name a known nation and drops the rest', () => { + const raw = { + nations: [ + { id: 'de', name: 'Germany', territory: ['DEU'] }, + { id: 'uk', name: 'United Kingdom', territory: ['GBR'] }, + ], + events: [ + { nation: 'de', type: 'military', title: 'Remilitarization', text: 't', outcome: 'success' }, + { nation: 'United Kingdom', type: 'political', title: 'By-elections', text: 't', outcome: 'neutral' }, + { nation: 'ghost', type: 'political', title: 'No owner', text: 't', outcome: 'neutral' }, + { nation: 'de', type: 'political' }, // no title -> dropped + ], + }; + const d = validateWorldDraft(raw) as WorldDraft; + expect(d.events).toHaveLength(2); + expect(d.events?.[0]).toMatchObject({ nation: 'de', type: 'military', title: 'Remilitarization', outcome: 'success' }); + expect(d.events?.[1]).toMatchObject({ nation: 'uk', type: 'political', title: 'By-elections', outcome: 'neutral' }); + }); + + it('returns null (never throws) for unusable / garbage payloads', () => { + expect(validateWorldDraft(null)).toBeNull(); + expect(validateWorldDraft('nope')).toBeNull(); + expect(validateWorldDraft([])).toBeNull(); + expect(validateWorldDraft({})).toBeNull(); + expect(validateWorldDraft({ nations: [] })).toBeNull(); + expect(validateWorldDraft({ nations: [{ name: 'No territory' }] })).toBeNull(); + expect(validateWorldDraft({ nations: [{}] })).toBeNull(); // no name + expect(validateWorldDraft({ nations: [42] })).toBeNull(); // not an object + // no nation owns a *valid* territory -> unusable. + expect( + validateWorldDraft( + { nations: [{ name: 'X', territory: ['ZZZ'] }] }, + { validCountryCodes: new Set(['DEU']) }, + ), + ).toBeNull(); + }); +}); + +describe('worldDraftToGameState (v0.31.0, #52)', () => { + function draft(): WorldDraft { + return { + nations: [ + { id: 'de', name: 'Germany', type: 'major', stats: S(90, 70, 75, 90), territory: ['DEU'], relations: { uk: 70 } }, + { id: 'uk', name: 'United Kingdom', type: 'major', stats: S(80, 75, 60, 48), territory: ['GBR', 'IRL'], relations: { de: -90 } }, + ], + events: [{ nation: 'de', type: 'political', title: 'The new order', text: 'A fresh map is drawn.', outcome: 'neutral' }], + }; + } + + it('expands ISO_A3 territory to region keys and seeds per-region stats', () => { + const g = worldDraftToGameState(draft(), fakeExpand, 'uk', { + id: 'g1', + title: 'Custom World', + startDate: '1939-09-01', + }); + expect(g.id).toBe('g1'); + expect(g.presetTitle).toBe('Custom World'); + expect(g.date).toBe('1939-09-01'); + expect(g.turn).toBe(1); + expect(g.version).toBe(1); + expect(g.playerNationId).toBe('uk'); + expect(g.unowned).toEqual([]); + expect(g.markers).toEqual([]); + expect(g.pendingNationOps).toEqual([]); + expect(g.pending).toEqual([]); + + // de owns DEU -> two region keys; uk owns GBR+IRL -> four. + expect(g.nations.de.territory).toEqual(['DEU-a', 'DEU-b']); + expect(g.nations.uk.territory).toEqual(['GBR-a', 'GBR-b', 'IRL-a', 'IRL-b']); + + // Every owned region is seeded with a copy of its owner's stats. + expect(g.regionStats['DEU-a']).toEqual(g.nations.de.stats); + expect(g.regionStats['IRL-b']).toEqual(g.nations.uk.stats); + // No entry for unowned regions. + expect(g.regionStats['ZZZ-a']).toBeUndefined(); + }); + + it('maps numeric relations to RelationStatus', () => { + const g = worldDraftToGameState(draft(), fakeExpand, 'de'); + expect(g.nations.de.relations).toEqual({ uk: 'ally' }); // 70 -> ally + expect(g.nations.uk.relations).toEqual({ de: 'war' }); // -90 -> war + }); + + it('seeds the event feed from backstory events at turn 1 / startDate', () => { + const g = worldDraftToGameState(draft(), fakeExpand, 'de', { startDate: '2035-01-01' }); + expect(g.events).toHaveLength(1); + expect(g.events[0]).toMatchObject({ + turn: 1, + date: '2035-01-01', + nation: 'de', + type: 'political', + title: 'The new order', + outcome: 'neutral', + }); + }); + + it('uses the defaults (empty id, Custom World, 1970-01-01) when options are omitted', () => { + const g = worldDraftToGameState(draft(), fakeExpand, 'de'); + expect(g.id).toBe(''); + expect(g.presetTitle).toBe('Custom World'); + expect(g.date).toBe('1970-01-01'); + expect(g.presetId).toBe('dynamic'); + }); +}); diff --git a/server/templates.test.ts b/server/templates.test.ts new file mode 100644 index 0000000..4fc68fb --- /dev/null +++ b/server/templates.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createTemplateStore } from './templates'; + +// The project root is the parent of server/ (mirrors how server/index.ts and +// server/regions.test.ts locate it via import.meta.url). +const root = fileURLToPath(new URL('..', import.meta.url)); +const templatesDir = path.join(root, 'presets', 'templates'); + +const store = createTemplateStore(templatesDir); + +/** A well-formed ISO_A3 country code: exactly three uppercase letters. */ +function isIsoA3(code: string): boolean { + return /^[A-Z]{3}$/.test(code); +} + +describe('era templates store (v0.31.0, #52)', () => { + it('the manifest lists exactly the three era templates', () => { + const meta = store.listMeta(); + expect(meta).toHaveLength(3); + const ids = meta.map((m) => m.id); + expect(ids).toEqual(['ww2', 'present', 'near-future']); + for (const m of meta) { + expect(m.id).toBeTruthy(); + expect(m.name).toBeTruthy(); + expect(typeof m.year).toBe('number'); + expect(m.eraDescription).toBeTruthy(); + } + }); + + it('each template file loads and is well-formed', () => { + for (const m of store.listMeta()) { + const t = store.get(m.id); + expect(t, `template ${m.id} should load`).not.toBeNull(); + const tpl = t as NonNullable; + expect(tpl.id).toBe(m.id); + expect(tpl.name).toBe(m.name); + expect(tpl.year).toBe(m.year); + expect(tpl.eraDescription).toBeTruthy(); + // suggested powers: non-empty, each a named power with an ISO_A3 core region + note. + expect(tpl.suggestedPowers.length).toBeGreaterThan(0); + for (const p of tpl.suggestedPowers) { + expect(p.name).toBeTruthy(); + expect(isIsoA3(p.region)).toBe(true); + expect(p.note).toBeTruthy(); + } + // tensions: a non-empty list of non-empty strings. + expect(tpl.tensions.length).toBeGreaterThan(0); + for (const s of tpl.tensions) expect(typeof s).toBe('string'); + for (const s of tpl.tensions) expect(s.trim()).not.toBe(''); + } + }); + + it('get returns null for an unknown id', () => { + expect(store.get('does-not-exist')).toBeNull(); + expect(store.get('')).toBeNull(); + }); +}); diff --git a/server/templates.ts b/server/templates.ts new file mode 100644 index 0000000..fbe6cec --- /dev/null +++ b/server/templates.ts @@ -0,0 +1,28 @@ +// Load era templates for the "Create scenario" path from disk (v0.31.0, #52). +// Mirrors the preset store (server/presets.ts): each template is a JSON file +// (presets/templates/.json) and index.json is the lightweight manifest used +// to render the picker cards. The store is created with the resolved templates +// directory so it works from repo root (dev) and /app (Docker) alike. +import { readFileSync } from 'node:fs'; +import type { EraTemplate, TemplateMeta } from '../shared/scenario'; + +export interface TemplateStore { + listMeta(): TemplateMeta[]; + get(id: string): EraTemplate | null; +} + +export function createTemplateStore(dir: string): TemplateStore { + const manifest = JSON.parse(readFileSync(`${dir}/index.json`, 'utf8')) as TemplateMeta[]; + return { + listMeta() { + return manifest; + }, + get(id: string) { + try { + return JSON.parse(readFileSync(`${dir}/${id}.json`, 'utf8')) as EraTemplate; + } catch { + return null; + } + }, + }; +} diff --git a/shared/scenario.ts b/shared/scenario.ts new file mode 100644 index 0000000..b20062c --- /dev/null +++ b/shared/scenario.ts @@ -0,0 +1,412 @@ +// 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; +} + +/** 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; + +// --- 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) + : {}; + 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`); + * 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 } = {}, +): WorldDraft | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const obj = raw as Record; + const rawNations = Array.isArray(obj.nations) ? obj.nations : null; + if (!rawNations || rawNations.length === 0) return null; + + const validCodes = opts.validCountryCodes; + const usedIds = new Set(); + const nameToId = new Map(); + + interface Tmp { + id: string; + name: string; + type: NationType; + stats: NationStats; + territory: string[]; + rawRelations: Record; + } + const tmp: Tmp[] = []; + + for (const rn of rawNations) { + if (!rn || typeof rn !== 'object') continue; + const o = rn as Record; + 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. + 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 = {}; + if (o.relations && typeof o.relations === 'object' && !Array.isArray(o.relations)) { + for (const [key, v] of Object.entries(o.relations as Record)) { + 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 = {}; + 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; + 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/`). 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 = {}; + for (const wn of draft.nations) { + const relations: Record = {}; + 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, + }; + } + + // 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, + 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), + }; + return state; +}