From 283f9e0306eb9e6000cd3929b14631d13d011208 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 17 Oct 2026 16:54:28 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20#80=20+=20#81=20alliances=20&=20space?= =?UTF-8?q?=20program=20=E2=80=94=2013th=20&=2014th=20abilities=20(v0.45.0?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #80: TurnResult.alliances — form/break durable mutual-defense pacts (GameState.alliances; pair relation → ally/neutral, an active war overrides). #81: TurnResult.space — launch/deorbit satellites (GameState.satellites; +1 stability per active satellite per turn). Both appended last to the ABILITIES registry (entries 13 & 14, after contested); durable in SQLite via the stateFields loop, mirrored to regionStats, stats re-derived. System + user prompt goldens updated; 738 unit tests passing. --- CHANGELOG.md | 11 + package.json | 2 +- server/abilities/alliances.test.ts | 380 ++++++++++++++++++ server/abilities/alliances.ts | 172 ++++++++ server/abilities/contested.test.ts | 4 + server/abilities/disasters.test.ts | 4 + server/abilities/goldens/system-no-tool.txt | 4 + server/abilities/goldens/system-tool.txt | 4 + server/abilities/goldens/user.txt | 4 +- server/abilities/index.ts | 24 ++ server/abilities/migration.test.ts | 4 + server/abilities/registry.test.ts | 26 +- server/abilities/sea.test.ts | 4 + server/abilities/space.test.ts | 413 ++++++++++++++++++++ server/abilities/space.ts | 215 ++++++++++ server/abilities/types.ts | 4 +- server/db.test.ts | 4 + server/engine.test.ts | 86 ++-- server/engine.ts | 4 + server/index.ts | 4 + server/map.test.ts | 4 + server/protocol.test.ts | 4 + server/regions.test.ts | 4 + shared/protocol.ts | 123 ++++++ shared/scenario.ts | 4 + shared/types.ts | 30 +- 26 files changed, 1493 insertions(+), 49 deletions(-) create mode 100644 server/abilities/alliances.test.ts create mode 100644 server/abilities/alliances.ts create mode 100644 server/abilities/space.test.ts create mode 100644 server/abilities/space.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eedc99f..14dde1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to **History** are listed here, newest first. +## v0.45.0 — 2026-10-17 08:35 +- **#80: Alliances / mutual-defense treaties — a `TurnResult.alliances` lever letting the LLM form (`form`) and break (`break`) durable, symmetric pacts between two distinct nations.** A `form` op creates a pact and sets the pair's relation to `ally` (unless an active war overrides — the pact still forms but `war` stays in force); a `break` op removes the pact and sets the pair back to `neutral` (again unless an active war still binds them). A pair holds at most one pact (re-forming is a no-op). Pacts live in `GameState.alliances` and the user payload **always** carries them (even when empty). It lands as the **13th ability** of the v0.40.0 modular system, in a new `server/abilities/alliances.ts` module, **registered last** in the `ABILITIES` registry (before `space`). Like `sea` / `wars` / `industry` it is **durable**: `GameState.alliances` persists across turns and in SQLite. Alliances never move borders (sole ownership is untouched). +- **#81: Space program / satellites — a `TurnResult.space` lever letting the LLM launch (`launch`) satellites over regions it owns and deorbit (`deorbit`) active ones by id.** Each satellite is one of four types (`recon`, `comms`, `navigation`, `weather`), gets a fresh stable id (`s1`, `s2`, …), and is stamped with the turn and date. Every active satellite grants its owner +1 stability per committed turn (`SATELLITE_STABILITY_BONUS`), mirrored to every region the owner holds and re-derived — a nation with several satellites stacks the bonus. Launches/deorbits never move borders (sole ownership is untouched). It lands as the **14th ability** of the v0.40.0 modular system, in a new `server/abilities/space.ts` module, **registered last** in the `ABILITIES` registry (after `alliances`). Like `sea` / `wars` / `industry` it is **durable**: `GameState.satellites` persists across turns and in SQLite, and the user payload **always** carries the current satellite list. +- **Types (`shared/protocol.ts`).** New `Alliance` (`{ id, parties, formedTurn, formedDate }`), `AllianceFormOp`, `AllianceBreakOp`, `AllianceOps` (`{ form, break }`); `SatelliteType` (`recon` | `comms` | `navigation` | `weather`), `SATELLITE_TYPES`, `SatelliteLaunchOp`, `SatelliteDeorbitOp`, `SpaceOps` (`{ launch, deorbit }`), `Satellite` (`{ id, by, regionKey, type, launchedTurn, launchedDate }`); `TurnResult.alliances` (required, between `contested` and `space`) and `TurnResult.space` (required, between `alliances` and `markers`). +- **Types (`shared/types.ts`).** `GameState.alliances: Alliance[]` (after `contested`) and `GameState.satellites: Satellite[]` (after `alliances`) — both required, `[]` at creation, migrated on legacy rows via the generic `ABILITIES` `stateFields` loop in `db.ts` (no `db.ts` code change needed). +- **Ability modules.** `server/abilities/alliances.ts` exports `validateAlliancesOps`, `applyAlliances`, `freshAllianceId`, and the `alliances: Ability<'alliances'>` const (`stateFields: ['alliances']`, payload always emits `{ alliances }`). `server/abilities/space.ts` exports `validateSpaceOps`, `applySpaceControl`, `applySatelliteBonuses`, `SATELLITE_STABILITY_BONUS`, `SATELLITE_TYPES`, `isSatelliteType`, `freshSatelliteId`, and the `space: Ability<'space'>` const (`stateFields: ['satellites']`, payload always emits `{ satellites }`). +- **Engine (`server/engine.ts`).** `buildUserPrompt` merges `ABILITIES[12].payload()` (alliances → `alliances`) and `ABILITIES[13].payload()` (space → `satellites`) after the contested payload merge (both always emitted, like `sea`). +- **Registry & prompt.** `alliances` and `space` appended **last** to `ABILITIES` (existing order untouched — entries 13 and 14); the system prompt gains both schema lines and constraint bullets; the user prompt gains `"alliances"` and `"satellites"` keys. The two system-prompt **goldens** and `user.txt` are regenerated. +- **Tests.** New `server/abilities/alliances.test.ts` (26 tests) and `server/abilities/space.test.ts` (30 tests), mirroring `sea.test.ts` style: `default()` / `stub()` freshness, `validate` (valid/invalid/field caps/malformed top-level), `apply` (form creates pact + `ally` relation; break removes + `neutral`; idempotent re-form; break-then-re-form reuses id; war overrides neutral/ally; unresolvable / ambiguous refs dropped; never touches `territory` / `unowned`), `isMeaningful`, `payload` (always emits), `stateFields`, and `applySatelliteBonuses` (+1 stability per satellite, clamped, mirrored, re-derived, stacks). `registry.test.ts` updated for the **14-ability** contract (order, distinct fields, `fullDefaultTurn()`, `meaningfulFor`, durable `stateFields` now 8 entries, composes 15 / `extended[14]`). +- **Version** bumped `0.44.0 → 0.45.0`. + ## v0.44.0 — 2026-10-16 15:10 - **#66: Contested regions / frontlines — a `TurnResult.contested` lever letting the LLM declare a region a **contested** war frontline (a **third region state**: in **no** nation's `territory` and **not** in `unowned` / no-man's-land) and resolve existing frontlines.** It lands as the **12th ability** of the v0.40.0 modular system, in a new `server/abilities/contested.ts` module, **registered last** in the `ABILITIES` registry (after `sea`). Like the `sea` and `industry` layers it is **durable**: `GameState.contested` persists across turns and in SQLite. It is a **pure territory-state** layer — the contested key simply *leaves* ownership (sole ownership is never broken: a key is in at most one `territory`), it is never placed into two territories, and it never touches borders directly. - **Types (`shared/protocol.ts`).** New `ContestOp` (`{ regionKey; parties }` — `regionKey` is a region key or resolvable name, `parties` are two or more nation refs (ids or names)), `ContestedOps` (`{ open: ContestOp[]; resolve: string[] }`), and `ContestedRegion` (`{ regionKey; parties }` — `parties` store the **resolved** nation ids); `TurnResult.contested` is **required**, placed between `sea` and `markers`, default `{ open: [], resolve: [] }`. New `CONTESTED_FILL = '#f97316'` / `CONTESTED_STROKE` (the distinct visible frontline fill/stroke for the map — a reddish/orange "hot" frontline on the dark basemap, clearly distinct from a nation color and from the no-man's-land grey) and `contestedTooltipLabel(parties)` (a short `"Contested: A vs B"` hover sub-line; a missing party name degrades to a dash, never crashes). `ContestedRegion` is imported into `shared/types.ts`; `GameState.contested: ContestedRegion[]` is **required** (`[]` at creation; a legacy SQLite row without the field loads as `[]` — additive, never wiped, via the generic `ABILITIES` `stateFields` loop in `db.ts` — no `db.ts` code change needed). diff --git a/package.json b/package.json index 0f460ae..5125e39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.44.0", + "version": "0.45.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/abilities/alliances.test.ts b/server/abilities/alliances.test.ts new file mode 100644 index 0000000..b0d206f --- /dev/null +++ b/server/abilities/alliances.test.ts @@ -0,0 +1,380 @@ +// Unit tests for the ALLIANCES / mutual-defense treaties ability (v0.45.0, #80) — +// the modular, DURABLE LLM-ability that lets nations form symmetric pacts +// (`alliances.form`) and break them (`alliances.break`): a pact sets the pair's +// relation to `ally` (unless an active war overrides), breaking sets it back to +// `neutral` (unless an active war still binds them), a pair holds at most one +// pact, and the pacts live in `GameState.alliances`. The layer never touches +// `territory` / `unowned`. +// This is the ability's own test file (the registry's cross-cutting guarantees +// — order, distinct fields, the system/user goldens — are covered by +// ./registry.test.ts). The stub never proposes an alliance op, so the apply +// path is exercised here, not by the e2e suite. + +import { describe, expect, it } from 'vitest'; + +import { + applyAlliances, + alliances, + freshAllianceId, + validateAlliancesOps, +} from './alliances'; + +import type { Alliance } from '../../shared/protocol'; +import type { GameState, Nation, NationStats } from '../../shared/types'; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +function NS(military: number, economy: number, stability: number, population: number): NationStats { + return { military, economy, stability, population }; +} + +function mkNation( + id: string, + name: string, + stats: NationStats, + territory: string[], + relations: Record = {}, +): Nation { + return { id, name, type: 'major', stats, territory, relations }; +} + +function mkState( + nations: Record, + alliances: Alliance[] = [], +): GameState { + return { + id: 'g1', + presetId: 'test', + presetTitle: 'Test', + playerNationId: Object.keys(nations)[0] ?? 'a1', + turn: 1, + date: '1939-09-01', + nations, + events: [], + pending: [], + version: 1, + unowned: [], + pendingNationOps: [], + markers: [], + regionStats: {}, + wars: [], + trades: [], + embargoes: [], + industry: [], + seaZones: [], + contested: [], + alliances, + satellites: [], + }; +} + +const ctx = (state: GameState) => ({ previous: state, actingTurn: state.turn }); + +// --------------------------------------------------------------------------- +// freshAllianceId +// --------------------------------------------------------------------------- + +describe('freshAllianceId', () => { + it('returns the first free alN id', () => { + expect(freshAllianceId(new Set())).toBe('al1'); + expect(freshAllianceId(new Set(['al1']))).toBe('al2'); + expect(freshAllianceId(new Set(['al1', 'al2']))).toBe('al3'); + }); + + it('skips ids already taken (a removed pact\'s id is never reused within a turn)', () => { + expect(freshAllianceId(new Set(['al1', 'al3']))).toBe('al2'); + }); +}); + +// --------------------------------------------------------------------------- +// validateAlliancesOps +// --------------------------------------------------------------------------- + +describe('validateAlliancesOps', () => { + it('accepts the { form, break } object shape', () => { + expect( + validateAlliancesOps({ + form: [{ a: 'a1', b: 'b2' }], + break: [{ a: 'b2', b: 'c3' }], + }), + ).toEqual({ + form: [{ a: 'a1', b: 'b2' }], + break: [{ a: 'b2', b: 'c3' }], + }); + }); + + it('drops ops with empty / missing a or b, or a === b (self-pairing)', () => { + expect(validateAlliancesOps({ form: [{ a: 'a1', b: '' }], break: [] })).toEqual({ + form: [], + break: [], + }); + expect(validateAlliancesOps({ form: [{ a: '', b: 'b2' }], break: [] })).toEqual({ + form: [], + break: [], + }); + expect(validateAlliancesOps({ form: [{ a: 'a1', b: 'a1' }], break: [] })).toEqual({ + form: [], + break: [], + }); + }); + + it('drops ops with non-object entries', () => { + expect( + validateAlliancesOps({ + form: [42, 'nope', null, { a: 'a1', b: 'b2' }], + break: [], + }), + ).toEqual({ form: [{ a: 'a1', b: 'b2' }], break: [] }); + }); + + it('a missing / malformed top-level value yields { form: [], break: [] }', () => { + expect(validateAlliancesOps(undefined)).toEqual({ form: [], break: [] }); + expect(validateAlliancesOps(null)).toEqual({ form: [], break: [] }); + expect(validateAlliancesOps('nope')).toEqual({ form: [], break: [] }); + expect(validateAlliancesOps({ form: 'nope' })).toEqual({ form: [], break: [] }); + expect(validateAlliancesOps({ break: 'nope' })).toEqual({ form: [], break: [] }); + }); + + it('caps `a` and `b` at 200 chars', () => { + const longA = 'f'.repeat(500); + const longB = 'g'.repeat(500); // different string so a !== b after capping + const out = validateAlliancesOps({ form: [{ a: longA, b: longB }], break: [] }); + expect(out.form).toHaveLength(1); + expect(out.form[0].a).toHaveLength(200); + expect(out.form[0].b).toHaveLength(200); + }); +}); + +// --------------------------------------------------------------------------- +// applyAlliances +// --------------------------------------------------------------------------- + +describe('applyAlliances', () => { + it('form creates a pact and sets the pair to ally', () => { + const state = mkState({ + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), + }); + applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state)); + + expect(state.alliances).toEqual([ + { id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }, + ]); + expect(state.nations.a1.relations.b2).toBe('ally'); + expect(state.nations.b2.relations.a1).toBe('ally'); + }); + + it('resolves `a`/`b` by unique name (not just id)', () => { + const state = mkState({ + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), + }); + applyAlliances(state, { form: [{ a: 'Alpha', b: 'Bravo' }], break: [] }, ctx(state)); + expect(state.alliances).toHaveLength(1); + expect(state.alliances[0].parties).toEqual(['a1', 'b2']); + }); + + it('an unresolvable ref drops the op', () => { + const state = mkState({ + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), + }); + applyAlliances(state, { form: [{ a: 'a1', b: 'ghost' }], break: [] }, ctx(state)); + expect(state.alliances).toEqual([]); + expect(state.nations.a1.relations).toEqual({}); + }); + + it('an ambiguous name (two nations share it) drops the op', () => { + const state = mkState({ + a1: mkNation('a1', 'Twin', NS(60, 55, 70, 80), []), + a2: mkNation('a2', 'Twin', NS(20, 20, 20, 20), []), + }); + applyAlliances(state, { form: [{ a: 'Twin', b: 'a1' }], break: [] }, ctx(state)); + // 'Twin' is ambiguous → dropped + expect(state.alliances).toEqual([]); + }); + + it('break removes an existing pact and sets the pair to neutral', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'ally' }), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'ally' }), + }, + [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], + ); + applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state)); + + expect(state.alliances).toEqual([]); + expect(state.nations.a1.relations.b2).toBe('neutral'); + expect(state.nations.b2.relations.a1).toBe('neutral'); + }); + + it('breaking a non-existent pact is a no-op', () => { + const state = mkState({ + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), + }); + applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state)); + expect(state.alliances).toEqual([]); + expect(state.nations.a1.relations).toEqual({}); + }); + + it('re-forming an existing pact is idempotent (no duplicate)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), + }, + [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], + ); + applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state)); + expect(state.alliances).toHaveLength(1); + expect(state.alliances[0].formedTurn).toBe(1); // unchanged + }); + + it('break then re-form the same pair in one turn ends with a fresh pact', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), + }, + [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], + ); + applyAlliances( + state, + { form: [{ a: 'a1', b: 'b2' }], break: [{ a: 'a1', b: 'b2' }] }, + ctx(state), + ); + expect(state.alliances).toHaveLength(1); + expect(state.alliances[0].id).toBe('al1'); // id reused (al1 was freed by break) + expect(state.alliances[0].formedTurn).toBe(1); + }); + + it('form when the pair is at war: the pact forms but the relation stays war', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'war' }), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'war' }), + }, + [], + // wire up an active war so atWar(a1, b2) is true + ); + state.wars = [{ id: 'w1', sides: ['a1', 'b2'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }]; + applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state)); + + expect(state.alliances).toHaveLength(1); // pact still formed + expect(state.alliances[0].parties).toEqual(['a1', 'b2']); + // the relation stays 'war' (war overrides ally) + expect(state.nations.a1.relations.b2).toBe('war'); + expect(state.nations.b2.relations.a1).toBe('war'); + }); + + it('break when the pair is at war: the pact breaks but the relation stays war', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), [], { b2: 'war' }), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), [], { a1: 'war' }), + }, + [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], + ); + state.wars = [{ id: 'w1', sides: ['a1', 'b2'], declaredTurn: 1, declaredDate: '1939-09-01', status: 'active' }]; + applyAlliances(state, { form: [], break: [{ a: 'a1', b: 'b2' }] }, ctx(state)); + + expect(state.alliances).toEqual([]); // pact removed + // the relation stays 'war' (war overrides neutral) + expect(state.nations.a1.relations.b2).toBe('war'); + expect(state.nations.b2.relations.a1).toBe('war'); + }); + + it('pairs are order-independent: form {a: b2, b: a1} matches an existing {a1, b2} pact', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), + }, + [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], + ); + applyAlliances(state, { form: [{ a: 'b2', b: 'a1' }], break: [] }, ctx(state)); + // already allied (order-independent) → no duplicate + expect(state.alliances).toHaveLength(1); + }); + + it('never touches `territory` or `unowned`', () => { + const state = mkState({ + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), ['b-r1']), + }); + applyAlliances(state, { form: [{ a: 'a1', b: 'b2' }], break: [] }, ctx(state)); + expect(state.nations.a1.territory).toEqual(['a-r1']); + expect(state.nations.b2.territory).toEqual(['b-r1']); + expect(state.unowned).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// The Ability object contract +// --------------------------------------------------------------------------- + +describe('alliances ability', () => { + it('owns the "alliances" field; default() is a fresh empty { form: [], break: [] }', () => { + expect(alliances.field).toBe('alliances'); + expect(alliances.default()).toEqual({ form: [], break: [] }); + expect(alliances.default()).not.toBe(alliances.default()); + }); + + it('default() is not meaningful; a populated value is', () => { + expect(alliances.isMeaningful(alliances.default())).toBe(false); + expect(alliances.isMeaningful({ form: [{ a: 'a1', b: 'b2' }], break: [] })).toBe(true); + expect(alliances.isMeaningful({ form: [], break: [{ a: 'a1', b: 'b2' }] })).toBe(true); + }); + + it('validate() wraps the validator into the AllianceOps shape', () => { + expect(alliances.validate({ form: [{ a: 'a1', b: 'b2' }], break: [] })).toEqual({ + form: [{ a: 'a1', b: 'b2' }], + break: [], + }); + expect(alliances.validate(undefined)).toEqual({ form: [], break: [] }); + }); + + it('the stub value equals default() (the stub never proposes an alliance op)', () => { + expect(alliances.stub()).toEqual({ form: [], break: [] }); + }); + + it('stateFields is [alliances] (the durable GameState field)', () => { + expect(alliances.stateFields).toEqual(['alliances']); + }); + + it('payload() emits alliances (even when empty / undefined)', () => { + expect(alliances.payload({ state: { alliances: [] } } as never)).toEqual({ alliances: [] }); + expect(alliances.payload({ state: {} as never })).toEqual({ alliances: [] }); + expect( + alliances.payload({ + state: { alliances: [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }] }, + } as never), + ).toEqual({ + alliances: [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], + }); + }); + + it('apply() runs form then break (break processed first)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(60, 55, 70, 80), []), + b2: mkNation('b2', 'Bravo', NS(75, 40, 20, 30), []), + }, + [{ id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }], + ); + const c = ctx(state); + alliances.apply( + state, + { form: [{ a: 'a1', b: 'b2' }], break: [{ a: 'a1', b: 'b2' }] }, + c, + ); + // break first removes al1 (freeing the id), then form creates al1 again (id reused) + expect(state.alliances).toEqual([ + { id: 'al1', parties: ['a1', 'b2'], formedTurn: 1, formedDate: '1939-09-01' }, + ]); + }); +}); diff --git a/server/abilities/alliances.ts b/server/abilities/alliances.ts new file mode 100644 index 0000000..56c47f7 --- /dev/null +++ b/server/abilities/alliances.ts @@ -0,0 +1,172 @@ +// The ALLIANCES / mutual-defense treaties ability (v0.45.0, #80) as a modular +// LLM-ability (v0.40.0, #67). A durable, symmetric pact between two distinct +// nations: the LLM proposes `form` / `break` ops each turn and the engine +// applies them directly (against the post-territory `next` state) and persists +// the active pacts in `GameState.alliances`. +// +// A pair holds at most one pact (re-forming is a no-op; breaking a non- +// existent pact is a no-op). Forming a pact sets the pair's relation to +// `ally`; breaking one sets it back to `neutral`. In both cases an ACTIVE WAR +// between the pair overrides: the pact still forms / still breaks, but the +// `war` relation stays in force — the layer never makes peace on its own. +// Alliances never move borders (sole ownership is untouched) and are +// deterministic (no hidden randomness — the LLM declares, the engine applies +// fixed rules). +// +// This is a DURABLE ability (unlike the turn-transient hybrid / disasters / +// migration layers): it owns the `GameState.alliances` field, and the user +// payload ALWAYS carries the current pacts (even when empty) so the model +// knows who is allied with whom. + +import { atWar } from './apply-helpers'; +import type { Alliance, AllianceOps } from '../../shared/protocol'; +import type { GameState } from '../../shared/types'; +import { resolveNationRef, str } from './shared'; +import type { Ability, AbilityCtx } from './types'; + +/** + * (v0.45.0, #80) Generate a fresh, unused alliance id: the first free of + * `al1`, `al2`, .... The caller adds each id it creates to `taken` as it goes, + * so ids stay unique within a turn and are never reused from a removed pact. + */ +export function freshAllianceId(taken: ReadonlySet): string { + for (let i = 1; ; i++) { + const id = `al${i}`; + if (!taken.has(id)) return id; + } +} + +/** + * (v0.45.0, #80) The two nation ids of an alliance entry, sorted (so a pair + * can be looked up regardless of the order the LLM names them in). + */ +function sortedParties(a: string, b: string): [string, string] { + return a < b ? [a, b] : [b, a]; +} + +/** + * (v0.45.0, #80) Find the index of the pact (if any) covering the pair `a` / + * `b`, order-independent. Returns -1 when no such pact exists. + */ +function pactIndex(alliances: Alliance[], a: string, b: string): number { + const [x, y] = sortedParties(a, b); + return alliances.findIndex((c) => c.parties[0] === x && c.parties[1] === y); +} + +/** + * (v0.45.0, #80) Validate the raw `alliances` field of a turn into a safe + * {@link AllianceOps}. Defensive like the rest of the parser — never throws. + * Accepts the object `{ form: [...], break: [...] }` (lenient: a bare object + * with `form`/`break` arrays). Each op `{a, b}` needs non-empty `a` and `b` + * (each capped at 200 chars) with `a !== b`; a malformed op is dropped. + * Resolution of `a`/`b` to concrete nation ids happens at apply time + * (`applyAlliances`), not here. A missing / malformed top-level value yields + * `{ form: [], break: [] }`. + */ +export function validateAlliancesOps(raw: unknown): AllianceOps { + const out: AllianceOps = { form: [], break: [] }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + const o = raw as Record; + for (const key of ['form', 'break'] as const) { + const list = o[key]; + if (!Array.isArray(list)) continue; + for (const e of list) { + if (!e || typeof e !== 'object') continue; + const p = e as Record; + const a = str(p.a, 200); + const b = str(p.b, 200); + if (!a || !b || a === b) continue; // empty / missing / self-pairing: drop + out[key].push({ a, b }); + } + } + return out; +} + +/** + * (v0.45.0, #80) Apply the turn's alliance ops to `next`, mutating in place + * and never throwing. `break` is processed FIRST, then `form`, so a turn that + * breaks and re-forms the same pair ends with a fresh pact. An unresolvable + * ref (unknown / ambiguous nation) drops that op. + * + * (1) BREAK: for each op, if both refs resolve to distinct nations and a pact + * covers the pair, remove the pact and set a↔b to `neutral` — UNLESS the + * pair is at war (an active war overrides: the pact breaks but the + * `war` relation stays). + * (2) FORM: for each op, if both refs resolve to distinct nations and no pact + * covers the pair, create `{ id, parties: [sorted a, b], formedTurn, + * formedDate }` and set a↔b to `ally` — UNLESS the pair is at war (the + * pact still forms, but the `war` relation stays in force). Re-forming an + * existing pact is a no-op. + * + * Sole ownership is never touched; only `alliances` and the pair's mutual + * relation change. + */ +export function applyAlliances( + next: GameState, + ops: AllianceOps, + ctx: AbilityCtx, +): void { + const alliances = next.alliances ?? []; + const wars = next.wars ?? []; + + // (1) BREAK first: end existing pacts. + for (const op of ops?.break ?? []) { + const a = resolveNationRef(next, op.a); + const b = resolveNationRef(next, op.b); + if (!a || !b || a === b) continue; + const i = pactIndex(alliances, a, b); + if (i === -1) continue; // no pact to break: no-op + alliances.splice(i, 1); + // Set the pair back to neutral, unless an active war still binds them. + if (!atWar(wars, a, b)) { + const na = next.nations[a]; + const nb = next.nations[b]; + if (na) na.relations[b] = 'neutral'; + if (nb) nb.relations[a] = 'neutral'; + } + } + + // (2) FORM: create new pacts (idempotent — re-forming is a no-op). + const taken = new Set(alliances.map((c) => c.id)); + for (const op of ops?.form ?? []) { + const a = resolveNationRef(next, op.a); + const b = resolveNationRef(next, op.b); + if (!a || !b || a === b) continue; + if (pactIndex(alliances, a, b) !== -1) continue; // already allied: no-op + const [x, y] = sortedParties(a, b); + alliances.push({ + id: freshAllianceId(taken), + parties: [x, y], + formedTurn: ctx.actingTurn, + formedDate: next.date, + }); + taken.add(alliances[alliances.length - 1].id); + // Set the pair to ally, unless an active war still binds them. + if (!atWar(wars, a, b)) { + const na = next.nations[a]; + const nb = next.nations[b]; + if (na) na.relations[b] = 'ally'; + if (nb) nb.relations[a] = 'ally'; + } + } + + next.alliances = alliances; +} + +export const alliances: Ability<'alliances'> = { + field: 'alliances', + default: () => ({ form: [], break: [] }), + validate: (raw) => validateAlliancesOps(raw), + apply: (state, data, ctx) => applyAlliances(state, data, ctx), + schemaLine: () => + ' "alliances": {"form":[{"a":"","b":""}],"break":[{"a":"","b":""}]},', + constraint: () => + '- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"","b":""}) creates a pact and sets the pair\'s relation to `ally` — unless they are at war, in which case the pact still forms but the `war` relation stays in force (war overrides neutral). A "break" op (same shape) removes an existing pact and sets the pair back to `neutral` — again unless an active war still binds them, in which case the `war` relation stays. A pair holds at most one pact (re-forming an existing pact, and breaking a non-existent one, are no-ops; an unresolvable nation ref drops the op). The user message\'s "alliances" field lists the active pacts (id + parties + formed turn/date). Propose at most 1-2 alliance changes per turn and narrate each one in "events" (a "diplomatic" event).', + isMeaningful: (data) => data.form.length > 0 || data.break.length > 0, + stateFields: ['alliances'], + stub: () => ({ form: [], break: [] }), + // Alliances are DURABLE (like sea / wars / industry): the user payload + // ALWAYS carries the current pacts (even when empty) so the model knows who + // is allied with whom and can `break` a pact or avoid re-forming one. + payload: (ctx) => ({ alliances: ctx.state.alliances ?? [] }), +}; diff --git a/server/abilities/contested.test.ts b/server/abilities/contested.test.ts index d81261d..37eca47 100644 --- a/server/abilities/contested.test.ts +++ b/server/abilities/contested.test.ts @@ -101,6 +101,10 @@ function mkState( industry: [], seaZones: [], contested: opts.contested ?? [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; } diff --git a/server/abilities/disasters.test.ts b/server/abilities/disasters.test.ts index e9ba795..35f9430 100644 --- a/server/abilities/disasters.test.ts +++ b/server/abilities/disasters.test.ts @@ -74,6 +74,10 @@ function mkState( seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; } diff --git a/server/abilities/goldens/system-no-tool.txt b/server/abilities/goldens/system-no-tool.txt index 6ff191d..b971207 100644 --- a/server/abilities/goldens/system-no-tool.txt +++ b/server/abilities/goldens/system-no-tool.txt @@ -43,6 +43,8 @@ Respond with ONLY a valid JSON object of exactly this shape. No prose, no commen "migration": {"ops":[{"kind":"","from":"","to":"","scale":"","detail":""}]}, "sea": {"control":[{"zone":"","by":""}]}, "contested": {"open":[{"regionKey":"","parties":["",""]}],"resolve":[""]}, + "alliances": {"form":[{"a":"","b":""}],"break":[{"a":"","b":""}]}, + "space": {"launch":[{"by":"","regionKey":"","type":""}],"deorbit":[""]}, "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]} } @@ -72,6 +74,8 @@ HARD CONSTRAINTS - Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event). - Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"","by":""}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event). - Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man's land, contested by two or more nations at war. An "open" op ({"regionKey":"","parties":["",""]}) makes a region a frontline — it leaves its owner's territory (or no-man's-land) and is contested: the region's sole owner must be at war with at least one of the parties, or (for a no-man's-land region) two of the parties must be at war with each other. A "resolve" op ([""]) ends a frontline, releasing the region to no man's land — unless a party's concurrent claim re-owns it. A contested region is in NO nation's territory (never two) and is never no man's land while contested. The user message's "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" event). +- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"","b":""}) creates a pact and sets the pair's relation to `ally` — unless they are at war, in which case the pact still forms but the `war` relation stays in force (war overrides neutral). A "break" op (same shape) removes an existing pact and sets the pair back to `neutral` — again unless an active war still binds them, in which case the `war` relation stays. A pair holds at most one pact (re-forming an existing pact, and breaking a non-existent one, are no-ops; an unresolvable nation ref drops the op). The user message's "alliances" field lists the active pacts (id + parties + formed turn/date). Propose at most 1-2 alliance changes per turn and narrate each one in "events" (a "diplomatic" event). +- Space program ("space") is the orbital layer: a "launch" op ({"by":"","regionKey":"","type":""}) puts a satellite over a region the launching nation owns (it must own the region; an unowned or invalid key, or an unresolvable launcher, drops the op), and a "deorbit" op ([""]) removes an active satellite by its stable id (an unknown id is a no-op). Each active satellite grants its owner +1 stability per committed turn, mirrored to its regions and re-derived, and a nation with several satellites stacks the bonus. Launches/deorbits NEVER move borders (sole ownership is untouched). At most 1-2 launches per turn, narrated as scientific/cultural events; deorbits as military/political events. The user message's "satellites" field lists the active satellites (id, launcher, region key, type, launched turn/date). - Prefer 1-4 events per turn; keep each text 1-3 sentences. - Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state. - Keep every stat value between 0 and 100. diff --git a/server/abilities/goldens/system-tool.txt b/server/abilities/goldens/system-tool.txt index 53477e0..134ce36 100644 --- a/server/abilities/goldens/system-tool.txt +++ b/server/abilities/goldens/system-tool.txt @@ -43,6 +43,8 @@ You have a getNeighbours tool: given region keys it returns the region keys that "migration": {"ops":[{"kind":"","from":"","to":"","scale":"","detail":""}]}, "sea": {"control":[{"zone":"","by":""}]}, "contested": {"open":[{"regionKey":"","parties":["",""]}],"resolve":[""]}, + "alliances": {"form":[{"a":"","b":""}],"break":[{"a":"","b":""}]}, + "space": {"launch":[{"by":"","regionKey":"","type":""}],"deorbit":[""]}, "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]} } @@ -72,6 +74,8 @@ HARD CONSTRAINTS - Migration & refugees ("migration.ops") are people moving between nations this turn: a "kind" ("migration", "refugees", "diaspora", or "exodus"), a "from" (the source nation — id or name), a "to" (the destination nation — required for every kind except "exodus", which has no destination), and a "scale" (small / moderate / large). The engine applies a DETERMINISTIC effect (no hidden randomness): the source LOSES a small amount of population and economy, the destination GAINS a small amount of population and economy (a "large" influx additionally strains the destination's stability by 2), and the from↔to relation moves one step toward the positive end of the ally/neutral/rival/hostile ladder — capped at "ally" for "refugees" (hosting refugees builds the strongest goodwill), capped at "neutral" for "migration"/"diaspora" (milder ties), and not at all for "exodus"; the nudge never creates or escalates a "war" (a warring pair is left untouched). The layer is turn-transient (no memory): a continuing flow is simply RE-DECLARED each turn it persists. It never moves borders — sole ownership is untouched. Propose at most 1-2 per turn and narrate each one in "events" (a "social" or "diplomatic" event). - Sea control ("sea") is the naval layer, fully parallel to but INDEPENDENT of land: each of the 12 fixed sea zones — "north-atlantic" (North Atlantic), "south-atlantic" (South Atlantic), "pacific" (Pacific Ocean), "indian" (Indian Ocean), "mediterranean" (Mediterranean Sea), "north-sea" (North Sea), "baltic" (Baltic Sea), "arctic" (Arctic Ocean), "black-sea" (Black Sea), "gulf-mexico" (Gulf of Mexico), "red-sea" (Red Sea), "sargasso" (Sargasso Sea) — is under the SOLE control of at most one nation. A control op ({"zone":"","by":""}) sets or replaces that zone's controller (the user message's "seaZones" field lists who controls what now); if two or more ops this turn claim the SAME zone for DIFFERENT nations, the zone is CONTESTED and the higher current "military" wins (a tie goes to the earlier-listed op). Every committed turn each zone's controller earns a small deterministic +1 military and +1 economy. Sea control NEVER moves land (no territory changes) — it is a parallel track. Propose at most 1-2 claims per turn and narrate each one in "events" (a "military" or "diplomatic" event). - Contested regions ("contested") are WAR FRONTLINES: a region that no nation controls and that is not no man's land, contested by two or more nations at war. An "open" op ({"regionKey":"","parties":["",""]}) makes a region a frontline — it leaves its owner's territory (or no-man's-land) and is contested: the region's sole owner must be at war with at least one of the parties, or (for a no-man's-land region) two of the parties must be at war with each other. A "resolve" op ([""]) ends a frontline, releasing the region to no man's land — unless a party's concurrent claim re-owns it. A contested region is in NO nation's territory (never two) and is never no man's land while contested. The user message's "contested" field lists the current frontlines (region key + party names). Propose at most 1-2 contested-region changes per turn and narrate each one in "events" (a "military" event). +- Alliances ("alliances") are durable, symmetric mutual-defense pacts between two distinct nations. A "form" op ({"a":"","b":""}) creates a pact and sets the pair's relation to `ally` — unless they are at war, in which case the pact still forms but the `war` relation stays in force (war overrides neutral). A "break" op (same shape) removes an existing pact and sets the pair back to `neutral` — again unless an active war still binds them, in which case the `war` relation stays. A pair holds at most one pact (re-forming an existing pact, and breaking a non-existent one, are no-ops; an unresolvable nation ref drops the op). The user message's "alliances" field lists the active pacts (id + parties + formed turn/date). Propose at most 1-2 alliance changes per turn and narrate each one in "events" (a "diplomatic" event). +- Space program ("space") is the orbital layer: a "launch" op ({"by":"","regionKey":"","type":""}) puts a satellite over a region the launching nation owns (it must own the region; an unowned or invalid key, or an unresolvable launcher, drops the op), and a "deorbit" op ([""]) removes an active satellite by its stable id (an unknown id is a no-op). Each active satellite grants its owner +1 stability per committed turn, mirrored to its regions and re-derived, and a nation with several satellites stacks the bonus. Launches/deorbits NEVER move borders (sole ownership is untouched). At most 1-2 launches per turn, narrated as scientific/cultural events; deorbits as military/political events. The user message's "satellites" field lists the active satellites (id, launcher, region key, type, launched turn/date). - Prefer 1-4 events per turn; keep each text 1-3 sentences. - Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state. - Keep every stat value between 0 and 100. diff --git a/server/abilities/goldens/user.txt b/server/abilities/goldens/user.txt index e378b8f..358c10c 100644 --- a/server/abilities/goldens/user.txt +++ b/server/abilities/goldens/user.txt @@ -215,5 +215,7 @@ "Bravo" ] } - ] + ], + "alliances": [], + "satellites": [] } \ No newline at end of file diff --git a/server/abilities/index.ts b/server/abilities/index.ts index 8973970..7df2903 100644 --- a/server/abilities/index.ts +++ b/server/abilities/index.ts @@ -26,6 +26,8 @@ import { disasters } from './disasters'; import { migration } from './migration'; import { sea } from './sea'; import { contested } from './contested'; +import { alliances } from './alliances'; +import { space } from './space'; import type { Ability, AbilityField } from './types'; /** @@ -49,6 +51,8 @@ export const ABILITIES: readonly Ability[] = [ migration, sea, contested, + alliances, + space, ]; // ── type re-exports ──────────────────────────────────────────────────────────── @@ -185,3 +189,23 @@ export { CONTESTED_STROKE, contestedTooltipLabel, } from './contested'; + +// ── alliances (mutual-defense treaties) ────────────────────────────────────── + +export { + validateAlliancesOps, + applyAlliances, + freshAllianceId, +} from './alliances'; + +// ── space (satellites) ─────────────────────────────────────────────────────── + +export { + validateSpaceOps, + applySpaceControl, + applySatelliteBonuses, + SATELLITE_STABILITY_BONUS, + SATELLITE_TYPES, + isSatelliteType, + freshSatelliteId, +} from './space'; diff --git a/server/abilities/migration.test.ts b/server/abilities/migration.test.ts index b005f74..eedc141 100644 --- a/server/abilities/migration.test.ts +++ b/server/abilities/migration.test.ts @@ -86,6 +86,10 @@ function mkState( seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; } diff --git a/server/abilities/registry.test.ts b/server/abilities/registry.test.ts index b1973fe..f88718e 100644 --- a/server/abilities/registry.test.ts +++ b/server/abilities/registry.test.ts @@ -173,6 +173,10 @@ const state: GameState = { seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; // --- buildUserPrompt arguments: every optional branch exercised. --- @@ -254,6 +258,8 @@ function fullDefaultTurn(): TurnResult { migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, + alliances: { form: [], break: [] }, + space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; } @@ -285,6 +291,10 @@ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] { return { control: [{ zone: 'pacific', by: 'a1' }] }; case 'contested': return { open: [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }], resolve: [] }; + case 'alliances': + return { form: [{ a: 'a1', b: 'b2' }], break: [] }; + case 'space': + return { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }; } } @@ -303,13 +313,15 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { 'migration', 'sea', 'contested', + 'alliances', + 'space', ]); }); - it('lists 12 distinct fields, each a key of TurnResult', () => { + it('lists 14 distinct fields, each a key of TurnResult', () => { const fields = ABILITIES.map((a) => a.field); - expect(fields).toHaveLength(12); - expect(new Set(fields).size).toBe(12); + expect(fields).toHaveLength(14); + expect(new Set(fields).size).toBe(14); const all = fullDefaultTurn(); for (const f of fields) expect(f in all).toBe(true); }); @@ -370,7 +382,7 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { it('declares exactly the durable GameState fields the db migration must default', () => { const owned: string[] = []; for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields); - expect([...owned].sort()).toEqual(['contested', 'embargoes', 'industry', 'seaZones', 'trades', 'wars']); + expect([...owned].sort()).toEqual(['alliances', 'contested', 'embargoes', 'industry', 'satellites', 'seaZones', 'trades', 'wars']); }); it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => { @@ -395,9 +407,9 @@ describe('ABILITIES registry (v0.40.0, #67)', () => { 'custom', ]); // every uniform loop runs over all N abilities, the appended one included: - expect(extended.map((a) => a.schemaLine())).toHaveLength(13); - expect(extended.map((a) => a.constraint())).toHaveLength(13); - expect(extended[12].schemaLine()).toBe(' "custom": [],'); + expect(extended.map((a) => a.schemaLine())).toHaveLength(15); + expect(extended.map((a) => a.constraint())).toHaveLength(15); + expect(extended[14].schemaLine()).toBe(' "custom": [],'); }); it('the engine still re-exports every name its callers import from it', () => { diff --git a/server/abilities/sea.test.ts b/server/abilities/sea.test.ts index 988d24d..a3ce8da 100644 --- a/server/abilities/sea.test.ts +++ b/server/abilities/sea.test.ts @@ -87,6 +87,10 @@ function mkState( seaZones, // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; } diff --git a/server/abilities/space.test.ts b/server/abilities/space.test.ts new file mode 100644 index 0000000..e30e874 --- /dev/null +++ b/server/abilities/space.test.ts @@ -0,0 +1,413 @@ +// Unit tests for the SPACE PROGRAM / satellites ability (v0.45.0, #81) — the +// modular, DURABLE LLM-ability that lets nations launch satellites +// (`space.launch`) over regions they own and deorbit them (`space.deorbit`): +// each active satellite grants its owner +1 stability per committed turn +// (`SATELLITE_STABILITY_BONUS`), mirrored to its regions and re-derived, and a +// nation with several satellites stacks the bonus. The layer never touches +// `territory` / `unowned`. +// This is the ability's own test file (the registry's cross-cutting guarantees +// — order, distinct fields, the system/user goldens — are covered by +// ./registry.test.ts). The stub never proposes a space op, so the apply path is +// exercised here, not by the e2e suite. + +import { describe, expect, it } from 'vitest'; + +import { + applySatelliteBonuses, + applySpaceControl, + isSatelliteType, + space, + validateSpaceOps, + SATELLITE_STABILITY_BONUS, + SATELLITE_TYPES, +} from './space'; + +import type { GameState, Nation, NationStats, RegionStats } from '../../shared/types'; +import type { Satellite } from '../../shared/protocol'; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +function NS(military: number, economy: number, stability: number, population: number): NationStats { + return { military, economy, stability, population }; +} + +function RS(military: number, economy: number, stability: number, population: number): RegionStats { + return { military, economy, stability, population }; +} + +function mkNation( + id: string, + name: string, + stats: NationStats, + territory: string[], +): Nation { + return { id, name, type: 'major', stats, territory, relations: {} }; +} + +function mkState( + nations: Record, + regionStats: Record, + satellites: Satellite[] = [], +): GameState { + return { + id: 'g1', + presetId: 'test', + presetTitle: 'Test', + playerNationId: Object.keys(nations)[0] ?? 'a1', + turn: 1, + date: '1939-09-01', + nations, + events: [], + pending: [], + version: 1, + unowned: [], + pendingNationOps: [], + markers: [], + regionStats, + wars: [], + trades: [], + embargoes: [], + industry: [], + seaZones: [], + contested: [], + alliances: [], + satellites, + }; +} + +const ctx = (state: GameState) => ({ previous: state, actingTurn: state.turn, validRegionKeys: new Set(['a-r1', 'b-r1', 'c-r1']) }); + +// --------------------------------------------------------------------------- +// SATELLITE_TYPES / isSatelliteType +// --------------------------------------------------------------------------- + +describe('SATELLITE_TYPES / isSatelliteType', () => { + it('exposes exactly the 4 satellite types', () => { + expect(SATELLITE_TYPES).toEqual(['recon', 'comms', 'navigation', 'weather']); + }); + + it('isSatelliteType accepts each known type and rejects unknown / non-strings', () => { + for (const t of SATELLITE_TYPES) expect(isSatelliteType(t)).toBe(true); + expect(isSatelliteType('weapon')).toBe(false); + expect(isSatelliteType('')).toBe(false); + expect(isSatelliteType(42 as unknown as string)).toBe(false); + }); + + it('SATELLITE_STABILITY_BONUS is +1', () => { + expect(SATELLITE_STABILITY_BONUS).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// validateSpaceOps +// --------------------------------------------------------------------------- + +describe('validateSpaceOps', () => { + it('accepts the { launch, deorbit } object shape', () => { + const op = { by: 'a1', regionKey: 'a-r1', type: 'recon' }; + expect(validateSpaceOps({ launch: [op], deorbit: ['s1'] })).toEqual({ + launch: [op], + deorbit: ['s1'], + }); + }); + + it('drops a launch op with an unknown / missing type', () => { + expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'weapon' }], deorbit: [] })).toEqual({ + launch: [], + deorbit: [], + }); + expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: 'a-r1' }], deorbit: [] })).toEqual({ + launch: [], + deorbit: [], + }); + }); + + it('drops a launch op with an empty / missing `by` or `regionKey`', () => { + expect(validateSpaceOps({ launch: [{ by: '', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toEqual({ + launch: [], + deorbit: [], + }); + expect(validateSpaceOps({ launch: [{ by: 'a1', regionKey: '', type: 'recon' }], deorbit: [] })).toEqual({ + launch: [], + deorbit: [], + }); + }); + + it('drops a deorbit entry that is empty / non-string', () => { + expect(validateSpaceOps({ launch: [], deorbit: ['', 42, null, 's1'] as unknown as string[] })).toEqual({ + launch: [], + deorbit: ['s1'], + }); + }); + + it('dedupes deorbit ids', () => { + expect(validateSpaceOps({ launch: [], deorbit: ['s1', 's1', 's2', 's2'] })).toEqual({ + launch: [], + deorbit: ['s1', 's2'], + }); + }); + + it('caps `by` at 200 and `regionKey` at 120 chars', () => { + const longBy = 'f'.repeat(500); + const longKey = 'g'.repeat(500); + const out = validateSpaceOps({ launch: [{ by: longBy, regionKey: longKey, type: 'recon' }], deorbit: [] }); + expect(out.launch).toHaveLength(1); + expect(out.launch[0].by).toHaveLength(200); + expect(out.launch[0].regionKey).toHaveLength(120); + }); + + it('a missing / malformed top-level value yields { launch: [], deorbit: [] }', () => { + expect(validateSpaceOps(undefined)).toEqual({ launch: [], deorbit: [] }); + expect(validateSpaceOps(null)).toEqual({ launch: [], deorbit: [] }); + expect(validateSpaceOps('nope')).toEqual({ launch: [], deorbit: [] }); + expect(validateSpaceOps({ launch: 'nope' })).toEqual({ launch: [], deorbit: [] }); + expect(validateSpaceOps({ deorbit: 'nope' })).toEqual({ launch: [], deorbit: [] }); + }); +}); + +// --------------------------------------------------------------------------- +// applySpaceControl (deorbit first, then launch) +// --------------------------------------------------------------------------- + +describe('applySpaceControl', () => { + it('a launch adds a fresh satellite stamped with the turn and date', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + ); + applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state)); + + expect(state.satellites).toEqual([ + { id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }, + ]); + }); + + it('resolves `by` by unique name (not just id)', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + ); + applySpaceControl(state, { launch: [{ by: 'Alpha', regionKey: 'a-r1', type: 'comms' }], deorbit: [] }, ctx(state)); + expect(state.satellites).toHaveLength(1); + expect(state.satellites[0].by).toBe('a1'); + }); + + it('an unresolvable `by` drops the launch', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + ); + applySpaceControl(state, { launch: [{ by: 'ghost', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state)); + expect(state.satellites).toEqual([]); + }); + + it('an unowned or invalid regionKey drops the launch', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + ); + // a-r2 is not owned by a1 + applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r2', type: 'recon' }], deorbit: [] }, ctx(state)); + expect(state.satellites).toEqual([]); + + // not-a-key is not a valid key + applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'not-a-key', type: 'recon' }], deorbit: [] }, ctx(state)); + expect(state.satellites).toEqual([]); + }); + + it('deorbit removes satellites by id; an absent id is a no-op', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }], + ); + applySpaceControl(state, { launch: [], deorbit: ['s1', 's999'] }, ctx(state)); + expect(state.satellites).toEqual([]); + }); + + it('deorbit is processed before launch in the same turn', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }], + ); + applySpaceControl( + state, + { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'comms' }], deorbit: ['s1'] }, + ctx(state), + ); + // s1 deorbited (freeing the id), then a new s1 (comms) launched over a-r1 — id reused + expect(state.satellites).toEqual([ + { id: 's1', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' }, + ]); + }); + + it('never touches `territory` or `unowned`', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + ); + applySpaceControl(state, { launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] }, ctx(state)); + expect(state.nations.a1.territory).toEqual(['a-r1']); + expect(state.unowned).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// applySatelliteBonuses +// --------------------------------------------------------------------------- + +describe('applySatelliteBonuses', () => { + it('each active satellite grants its owner +1 stability (clamped, mirrored, re-derived)', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }], + ); + applySatelliteBonuses(state); + + // +1 stability, mirrored to the region, re-derived (single region → back to region's value) + expect(state.nations.a1.stats).toEqual(NS(50, 50, 51, 50)); + expect(state.regionStats['a-r1']).toEqual(RS(50, 50, 51, 50)); + }); + + it('clamps to 0 and 100', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 99, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 99, 50) }, + [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }], + ); + applySatelliteBonuses(state); + expect(state.nations.a1.stats.stability).toBe(100); + expect(state.regionStats['a-r1'].stability).toBe(100); + }); + + it('a nation with several satellites stacks the bonus (+1 per satellite)', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [ + { id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }, + { id: 's2', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' }, + { id: 's3', by: 'a1', regionKey: 'a-r1', type: 'weather', launchedTurn: 1, launchedDate: '1939-09-01' }, + ], + ); + applySatelliteBonuses(state); + expect(state.nations.a1.stats.stability).toBe(53); + expect(state.regionStats['a-r1'].stability).toBe(53); + }); + + it('a 0-region nation keeps its directly-mutated stats (no re-derive)', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), []) }, + {}, + [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }], + ); + applySatelliteBonuses(state); + // direct mutation, no re-derive (no regions) + expect(state.nations.a1.stats.stability).toBe(51); + }); + + it('a stale satellite (nation no longer in `nations`) is skipped; the satellite is kept', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [{ id: 's1', by: 'ghost', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }], + ); + applySatelliteBonuses(state); + // ghost is not in nations → skipped; a1 is unaffected + expect(state.nations.a1.stats.stability).toBe(50); + // the satellite is kept (not removed) + expect(state.satellites).toHaveLength(1); + }); + + it('an empty satellites list is a no-op', () => { + const state = mkState( + { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, + { 'a-r1': RS(50, 50, 50, 50) }, + [], + ); + applySatelliteBonuses(state); + expect(state.nations.a1.stats.stability).toBe(50); + }); +}); + +// --------------------------------------------------------------------------- +// The Ability object contract +// --------------------------------------------------------------------------- + +describe('space ability', () => { + it('owns the "space" field; default() is a fresh empty { launch: [], deorbit: [] }', () => { + expect(space.field).toBe('space'); + expect(space.default()).toEqual({ launch: [], deorbit: [] }); + expect(space.default()).not.toBe(space.default()); + }); + + it('default() is not meaningful; a populated value is', () => { + expect(space.isMeaningful(space.default())).toBe(false); + expect(space.isMeaningful({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toBe(true); + expect(space.isMeaningful({ launch: [], deorbit: ['s1'] })).toBe(true); + }); + + it('validate() wraps the validator into the SpaceOps shape', () => { + expect(space.validate({ launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], deorbit: [] })).toEqual({ + launch: [{ by: 'a1', regionKey: 'a-r1', type: 'recon' }], + deorbit: [], + }); + expect(space.validate(undefined)).toEqual({ launch: [], deorbit: [] }); + }); + + it('the stub value equals default() (the stub never proposes a space op)', () => { + expect(space.stub()).toEqual({ launch: [], deorbit: [] }); + }); + + it('stateFields is [satellites] (the durable GameState field)', () => { + expect(space.stateFields).toEqual(['satellites']); + }); + + it('payload() emits satellites (even when empty / undefined)', () => { + expect(space.payload({ state: { satellites: [] } } as never)).toEqual({ satellites: [] }); + expect(space.payload({ state: {} as never })).toEqual({ satellites: [] }); + expect( + space.payload({ + state: { satellites: [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }] }, + } as never), + ).toEqual({ + satellites: [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }], + }); + }); + + it('apply() runs deorbit, then launch, then the per-turn bonus (no throw on a bad ref)', () => { + const state = mkState( + { + a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']), + b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), + }, + { 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) }, + [{ id: 's1', by: 'a1', regionKey: 'a-r1', type: 'recon', launchedTurn: 1, launchedDate: '1939-09-01' }], + ); + const c = ctx(state); + space.apply( + state, + { + launch: [ + { by: 'a1', regionKey: 'a-r1', type: 'comms' }, + // an unresolvable ref — must be dropped, not throw + { by: 'ghost', regionKey: 'c-r1', type: 'weather' }, + ], + deorbit: ['s1'], + }, + c, + ); + // s1 deorbited (freeing the id), then a new s1 (comms) launched over a-r1 + expect(state.satellites).toEqual([ + { id: 's1', by: 'a1', regionKey: 'a-r1', type: 'comms', launchedTurn: 1, launchedDate: '1939-09-01' }, + ]); + // a1 gets +1 stability (one satellite) + expect(state.nations.a1.stats.stability).toBe(51); + // b2 is unaffected + expect(state.nations.b2.stats.stability).toBe(50); + }); +}); diff --git a/server/abilities/space.ts b/server/abilities/space.ts new file mode 100644 index 0000000..c07a7d0 --- /dev/null +++ b/server/abilities/space.ts @@ -0,0 +1,215 @@ +// The SPACE PROGRAM / satellites ability (v0.45.0, #81) as a modular +// LLM-ability (v0.40.0, #67). Durable per-turn space ops: the LLM launches +// satellites (one of four `SatelliteType`s) over regions it owns and deorbits +// active ones by id. Every active satellite grants its owner a deterministic +// +1 stability per committed turn (`SATELLITE_STABILITY_BONUS`), mirrored to +// every region the owner holds (`regionStats`), then the owner's derived +// `stats` are re-derived — so a nation with several satellites stacks the +// bonus. Launches/deorbits never move borders (sole ownership is untouched). +// +// Each turn the LLM may `launch` new satellites (each op names `by`, a valid +// region key owned by `by`, and a type) and/or `deorbit` active satellites +// (by their stable id). A launch is dropped when `by` does not resolve, when +// `regionKey` is not a valid key owned by `by`, or when `type` is not a known +// `SatelliteType`; a deorbit of an unknown id is a no-op. New satellites get +// a fresh id (`s1`, `s2`, …) and are stamped with `launchedTurn` / +// `launchedDate`. +// +// This is a DURABLE ability (unlike the turn-transient hybrid / disasters / +// migration layers): it owns the `GameState.satellites` field, and the user +// payload ALWAYS carries the current satellite list (even when empty) so the +// model knows what is in orbit and can deorbit it. + +import { clampStat, deriveNationStats, isSatelliteType } from '../../shared/protocol'; +import type { SpaceOps } from '../../shared/protocol'; +export { SATELLITE_TYPES, isSatelliteType } from '../../shared/protocol'; +import type { GameState, Nation } from '../../shared/types'; +import { resolveNationRef, str } from './shared'; +import type { Ability, AbilityCtx } from './types'; + +/** + * (v0.45.0, #81) The deterministic per-turn stability bonus each active + * satellite grants its owner (clamped to 0–100, mirrored to every region the + * owner holds, then the owner's derived `stats` re-derived). A nation with + * several satellites stacks the bonus (one `+1` per satellite). + */ +export const SATELLITE_STABILITY_BONUS = 1; + +/** + * (v0.45.0, #81) Generate a fresh, unused satellite id: the first free of + * `s1`, `s2`, .... The caller adds each id it creates to `taken` as it goes. + */ +export function freshSatelliteId(taken: ReadonlySet): string { + for (let i = 1; ; i++) { + const id = `s${i}`; + if (!taken.has(id)) return id; + } +} + +/** + * (v0.45.0, #81) Validate the raw `space` field of a turn into a safe + * {@link SpaceOps}. Defensive like the rest of the parser — never throws. + * Accepts the object `{ launch: [...], deorbit: [...] }`. Each launch op + * needs a non-empty `by` (capped at 200 chars), a non-empty `regionKey` + * (capped at 120 chars), and a `type` that is one of {@link SATELLITE_TYPES}; + * an invalid op is dropped. Each deorbit entry needs a non-empty id (capped + * at 120 chars); deorbit ids are deduped. Resolution of `by` to a concrete + * nation and the ownership check on `regionKey` happen at apply time + * (`applySpaceControl`), not here. A missing / malformed top-level value + * yields `{ launch: [], deorbit: [] }`. + */ +export function validateSpaceOps(raw: unknown): SpaceOps { + const out: SpaceOps = { launch: [], deorbit: [] }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + const o = raw as Record; + + if (Array.isArray(o.launch)) { + const seenLaunch = new Set(); + for (const e of o.launch) { + if (!e || typeof e !== 'object') continue; + const p = e as Record; + const by = str(p.by, 200); + const regionKey = str(p.regionKey, 120); + if (!by || !regionKey) continue; + if (!isSatelliteType(p.type)) continue; + const id = `${by}\u0000${regionKey}\u0000${p.type}`; + if (seenLaunch.has(id)) continue; // dedupe identical launch ops + seenLaunch.add(id); + out.launch.push({ by, regionKey, type: p.type }); + } + } + + if (Array.isArray(o.deorbit)) { + const seenDeorbit = new Set(); + for (const e of o.deorbit) { + const id = str(e, 120); + if (!id) continue; // empty / missing / non-string: drop + if (seenDeorbit.has(id)) continue; // dedupe + seenDeorbit.add(id); + out.deorbit.push(id); + } + } + + return out; +} + +/** + * (v0.45.0, #81) Remove the satellites whose ids are in `deorbit` from + * `next.satellites`. An absent id is a no-op. + */ +function deorbitSatellites(next: GameState, deorbit: string[]): void { + if (deorbit.length === 0) return; + const ids = new Set(deorbit); + next.satellites = (next.satellites ?? []).filter((s) => !ids.has(s.id)); +} + +/** + * (v0.45.0, #81) Launch this turn's satellites: resolve `by` (id first, then + * a case-insensitive unique name), require `regionKey` to be a valid key + * owned by `by`, and append a fresh `{ id, by, regionKey, type, launchedTurn, + * launchedDate }` per op. Unknown / ambiguous `by`, an unowned or invalid + * `regionKey`, or an unknown `type` (already filtered at validate time) drops + * the op. Never throws. + */ +function launchSatellites(next: GameState, ops: SpaceOps['launch'], ctx: AbilityCtx): void { + const validKeys = ctx.validRegionKeys; + const taken = new Set((next.satellites ?? []).map((s) => s.id)); + for (const op of ops) { + const by = resolveNationRef(next, op.by); + if (!by) continue; // unresolvable launcher: drop + const key = op.regionKey; + if (validKeys && !validKeys.has(key)) continue; // not a valid region key: drop + const owner = next.nations[by]; + if (!owner || !owner.territory.includes(key)) continue; // by does not own it: drop + next.satellites = (next.satellites ?? []).concat({ + id: freshSatelliteId(taken), + by, + regionKey: key, + type: op.type, + launchedTurn: ctx.actingTurn, + launchedDate: next.date, + }); + taken.add(next.satellites[next.satellites.length - 1].id); + } +} + +/** + * (v0.45.0, #81) The per-turn satellite bonus, applied EVERY committed turn + * (not just to this turn's launches): each active satellite grants its owner + * +`SATELLITE_STABILITY_BONUS` stability (clamped to 0–100), mirrored to every + * region the owner holds (`regionStats`), then the owner's derived `stats` are + * re-derived (the same 0-region / no-entry guards as the economy / migration + * layers — a 0-region nation keeps its directly-mutated value). A nation with + * several satellites stacks the bonus (one `+1` per satellite). A claim + * whose nation no longer exists (removed by a merge / split this turn) is + * skipped. Never throws. + */ +export function applySatelliteBonuses(next: GameState, geoOf?: (key: string) => { area: number; population: number } | undefined): void { + const satellites = next.satellites ?? []; + if (satellites.length === 0) return; + + // nation id -> number of satellites it owns (stacks the bonus). + const counts = new Map(); + for (const s of satellites) { + counts.set(s.by, (counts.get(s.by) ?? 0) + 1); + } + + const applyNation = (n: Nation, count: number): void => { + n.stats.stability = clampStat(n.stats.stability + count * SATELLITE_STABILITY_BONUS); + for (const key of n.territory) { + const rs = next.regionStats[key]; + if (!rs) continue; + rs.stability = clampStat(rs.stability + count * SATELLITE_STABILITY_BONUS); + } + // Re-derive so the effect persists (the main recompute ran earlier this + // turn). A nation with no regionStats entry keeps its directly-mutated value. + if (n.territory.length > 0 && n.territory.some((k) => next.regionStats[k])) { + n.stats = deriveNationStats(n.territory, next.regionStats, geoOf); + } + }; + + for (const [id, count] of counts) { + const n = next.nations[id]; + if (!n) continue; // nation removed this turn: bonus dropped (satellite kept) + applyNation(n, count); + } +} + +/** + * (v0.45.0, #81) Apply the turn's space ops to `next`, mutating in place and + * never throwing. Runs in the `ABILITIES` loop AFTER the territory step and + * the derived-stats recompute, and BEFORE markers / nationOps, so the + * ownership it inspects is final for this turn. + * + * (1) DEORBIT first: remove satellites by id (absent id = no-op). + * (2) LAUNCH: for each launch op, resolve `by`, require `regionKey` to be a + * valid key owned by `by`, and append a fresh satellite stamped with + * `launchedTurn` / `launchedDate`. + * (3) PER-TURN BONUS: every active satellite grants its owner +1 stability + * (clamped, mirrored to its regions, re-derived) — one per satellite. + * + * Sole ownership is never touched; only `satellites` and stability change. + */ +export function applySpaceControl(next: GameState, ops: SpaceOps, ctx: AbilityCtx): void { + deorbitSatellites(next, ops?.deorbit ?? []); + launchSatellites(next, ops?.launch ?? [], ctx); + applySatelliteBonuses(next, ctx.geoOf); +} + +export const space: Ability<'space'> = { + field: 'space', + default: () => ({ launch: [], deorbit: [] }), + validate: (raw) => validateSpaceOps(raw), + apply: (state, data, ctx) => applySpaceControl(state, data, ctx), + schemaLine: () => + ' "space": {"launch":[{"by":"","regionKey":"","type":""}],"deorbit":[""]},', + constraint: () => + '- Space program ("space") is the orbital layer: a "launch" op ({"by":"","regionKey":"","type":""}) puts a satellite over a region the launching nation owns (it must own the region; an unowned or invalid key, or an unresolvable launcher, drops the op), and a "deorbit" op ([""]) removes an active satellite by its stable id (an unknown id is a no-op). Each active satellite grants its owner +1 stability per committed turn, mirrored to its regions and re-derived, and a nation with several satellites stacks the bonus. Launches/deorbits NEVER move borders (sole ownership is untouched). At most 1-2 launches per turn, narrated as scientific/cultural events; deorbits as military/political events. The user message\'s "satellites" field lists the active satellites (id, launcher, region key, type, launched turn/date).', + isMeaningful: (data) => data.launch.length > 0 || data.deorbit.length > 0, + stateFields: ['satellites'], + stub: () => ({ launch: [], deorbit: [] }), + // Space is DURABLE (like sea / wars / industry): the user payload ALWAYS + // carries the current satellites (even when empty) so the model knows what + // is in orbit and can deorbit it. + payload: (ctx) => ({ satellites: ctx.state.satellites ?? [] }), +}; diff --git a/server/abilities/types.ts b/server/abilities/types.ts index e552ce2..127b7c9 100644 --- a/server/abilities/types.ts +++ b/server/abilities/types.ts @@ -36,7 +36,9 @@ export type AbilityField = | 'disasters' | 'migration' | 'sea' - | 'contested'; + | 'contested' + | 'alliances' + | 'space'; /** * Everything a single ability's `apply` may need from the surrounding turn. diff --git a/server/db.test.ts b/server/db.test.ts index 6068f5d..1d378f1 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -42,6 +42,10 @@ function makeState(id: string): GameState { seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; } diff --git a/server/engine.test.ts b/server/engine.test.ts index a51f8aa..d82040d 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -137,6 +137,10 @@ function baseState(): GameState { seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; } @@ -574,7 +578,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -596,7 +600,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }); expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); @@ -617,7 +621,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, // new proposal this turn + nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -636,7 +640,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1093,7 +1097,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1110,7 +1114,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1133,7 +1137,7 @@ describe('applyTurnResult', () => { // is not a real key at all. Both are proposed as a gain by DE. territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); // the valid key was gained (taken from its sole previous owner); the bogus one was ignored @@ -1159,7 +1163,7 @@ describe('applyTurnResult', () => { // released (not in state.unowned), so it does not enter GameState.unowned. territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1177,7 +1181,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { regionChanges: [], relations: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1267,7 +1271,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1382,7 +1386,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => { expect( - isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } }), + isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1689,9 +1693,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => { describe('isMeaningful', () => { it('is true with an event, false when everything is empty', () => { - expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] } })).toBe(false); + expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] } })).toBe(false); }); it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => { @@ -1704,7 +1708,7 @@ describe('isMeaningful', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1725,7 +1729,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); }); @@ -1746,7 +1750,7 @@ describe('isMeaningful', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -1769,7 +1773,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.de.name).toBe('The Reich'); @@ -1802,7 +1806,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.playerNationId).toBe('uk'); // player still leads it @@ -1826,7 +1830,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no nation was renamed @@ -1853,7 +1857,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // neither was renamed (ambiguous) @@ -1879,7 +1883,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }); expect(validated!.takeovers).toEqual([]); const next = applyTurnResult(state, validated!); @@ -1905,7 +1909,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.uk.name).toBe('The Commonwealth'); @@ -1929,7 +1933,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.version).toBe(2); // exactly one bump for the whole turn @@ -1951,7 +1955,7 @@ describe('takeovers (regime change, v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.nations.de.name).toBe('The Reich'); @@ -1985,7 +1989,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, }; const next = applyTurnResult(state, result, undefined, centroidOf); expect(next.markers).toHaveLength(1); @@ -2009,13 +2013,13 @@ describe('markers (point placement, v0.27 #44)', () => { const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; const first = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(first.markers).toHaveLength(1); // a second turn proposes the exact same marker again const second = applyTurnResult(first, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add, remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add, remove: [] }, }, undefined, centroidOf); expect(second.markers).toHaveLength(1); expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy @@ -2042,7 +2046,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [ { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept { type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped @@ -2089,7 +2093,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [ { type: 'city', name: 'NewA', regionKey: 'new-a' }, { type: 'city', name: 'NewB', regionKey: 'new-b' }, @@ -2114,7 +2118,7 @@ describe('markers (point placement, v0.27 #44)', () => { ]; const next = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [idA, 'zzzz-not-there'] }, }, undefined, centroidOf); expect(next.markers).toHaveLength(1); expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op @@ -2130,14 +2134,14 @@ describe('markers (point placement, v0.27 #44)', () => { // 1) uk releases the region → the marker becomes ownerless const released = applyTurnResult(state, { date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], - unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + unowned: ['moselle-rygy'], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(released.markers[0].ownerNationId).toBeNull(); // 2) de re-claims the region → the marker's owner follows to de const reclaimed = applyTurnResult(released, { date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], - unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }, undefined, centroidOf); expect(reclaimed.markers[0].ownerNationId).toBe('de'); }); @@ -2207,7 +2211,7 @@ describe('markers (point placement, v0.27 #44)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, }); const { state } = await runTurn({ llm: fakeLlm([result]), @@ -2286,6 +2290,10 @@ function mkTurnState( seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; } @@ -2311,6 +2319,8 @@ function mkTurnResult(partial: Partial): TurnResult { migration: partial.migration ?? { ops: [] }, sea: partial.sea ?? { control: [] }, contested: partial.contested ?? { open: [], resolve: [] }, + alliances: partial.alliances ?? { form: [], break: [] }, + space: partial.space ?? { launch: [], deorbit: [] }, markers: partial.markers ?? { add: [], remove: [] }, }; } @@ -3036,7 +3046,7 @@ describe('validateTakeovers (v0.32.0 #54)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }); expect(withTakeovers).not.toBeNull(); // the valid one survives, the empty-name one is dropped @@ -3424,7 +3434,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -3445,7 +3455,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }), ).toBe(true); }); @@ -3466,7 +3476,7 @@ describe('isMeaningful — wars (v0.33.0 #56)', () => { hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, - ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, markers: { add: [], remove: [] }, + ideology: { changes: [] }, disasters: { events: [] }, migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, alliances: { form: [], break: [] }, space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }), ).toBe(false); }); diff --git a/server/engine.ts b/server/engine.ts index dc85ff5..85e445b 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -528,6 +528,8 @@ export function buildUserPrompt( Object.assign(payload, ABILITIES[4].payload(upCtx)); // industry → resources, industry Object.assign(payload, ABILITIES[10].payload(upCtx)); // sea → seaZones (always emitted) Object.assign(payload, ABILITIES[11].payload(upCtx)); // contested → contested (only when non-empty) + Object.assign(payload, ABILITIES[12].payload(upCtx)); // alliances → alliances (always emitted) + Object.assign(payload, ABILITIES[13].payload(upCtx)); // space → satellites (always emitted) return JSON.stringify(payload, null, 2); } @@ -670,6 +672,8 @@ export function validateTurnResult( migration: { ops: [] }, sea: { control: [] }, contested: { open: [], resolve: [] }, + alliances: { form: [], break: [] }, + space: { launch: [], deorbit: [] }, markers: { add: [], remove: [] }, }; diff --git a/server/index.ts b/server/index.ts index d3ac632..0bbbe59 100644 --- a/server/index.ts +++ b/server/index.ts @@ -194,6 +194,10 @@ app.post('/api/games', (req: Request, res) => { seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; // v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys // of all its sub-regions, so ownership (and the choropleth) is at region diff --git a/server/map.test.ts b/server/map.test.ts index b250386..3e7a421 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -63,6 +63,10 @@ const state: GameState = { seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; // The RegionMap built from the fixture: the authoritative region key → owner source. diff --git a/server/protocol.test.ts b/server/protocol.test.ts index ce13377..50aca85 100644 --- a/server/protocol.test.ts +++ b/server/protocol.test.ts @@ -52,6 +52,10 @@ function mkState( seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; if (regionStats !== undefined) base.regionStats = regionStats as GameState['regionStats']; else delete (base as { regionStats?: unknown }).regionStats; // simulate a legacy row diff --git a/server/regions.test.ts b/server/regions.test.ts index eb76a62..bee6ead 100644 --- a/server/regions.test.ts +++ b/server/regions.test.ts @@ -122,6 +122,10 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => { seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. expect(allTerritoryKeys(state).size).toBe(58); diff --git a/shared/protocol.ts b/shared/protocol.ts index 86d2189..78bcc31 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -830,6 +830,108 @@ export interface ContestedRegion { parties: string[]; } +// --- Alliances / mutual-defense treaties (v0.45.0, #80) ---------------------- +// +// A durable, symmetric pact between two distinct nations. The LLM proposes +// `form` / `break` ops each turn; the engine applies them directly +// (`applyTurnResult` → the alliances ability) and persists the active pacts in +// `GameState.alliances`. A pair holds at most one pact (re-forming is a +// no-op), and an active war between the pair overrides the `ally` relation — +// the pact still exists but the `war` relation stays in force until the war +// ends. + +/** One active alliance: which two nations are bound, and when the pact was formed. */ +export interface Alliance { + /** Stable id (`al1`, `al2`, …) assigned by the engine on formation. */ + id: string; + /** The two party nation ids (sorted, distinct). */ + parties: string[]; + /** The turn number the pact was formed on. */ + formedTurn: number; + /** The in-game date the pact took effect on (`YYYY-MM-DD`). */ + formedDate: string; +} + +/** A single "form a new alliance between a and b" op. */ +export interface AllianceFormOp { + /** One party (a nation id or name, resolved via `resolveNationRef`). */ + a: string; + /** The other party (a nation id or name, distinct from `a`). */ + b: string; +} + +/** A single "break the alliance between a and b" op. */ +export interface AllianceBreakOp { + /** One party (a nation id or name). */ + a: string; + /** The other party (a nation id or name). */ + b: string; +} + +/** The alliances block of a `TurnResult`. Required; default `{ form: [], break: [] }`. */ +export interface AllianceOps { + /** New pacts to form this turn. */ + form: AllianceFormOp[]; + /** Existing pacts to end this turn. */ + break: AllianceBreakOp[]; +} + +// --- Space program / satellites (v0.45.0, #81) ------------------------------- +// +// Durable per-turn space ops: the LLM launches satellites over regions it +// owns and deorbits active ones by id. Each active satellite grants its owner +// a small deterministic +1 stability per committed turn (mirrored to its +// regions, re-derived), stacked per satellite. Launches/deorbits never move +// borders — sole ownership is untouched. + +/** The kind of satellite a nation can put into orbit. */ +export type SatelliteType = 'recon' | 'comms' | 'navigation' | 'weather'; + +/** All satellite types, in canonical order (the LLM contract + validator). */ +export const SATELLITE_TYPES: SatelliteType[] = ['recon', 'comms', 'navigation', 'weather']; + +/** True when `t` is one of the four known satellite types. */ +export function isSatelliteType(t: unknown): t is SatelliteType { + return typeof t === 'string' && SATELLITE_TYPES.includes(t as SatelliteType); +} + +/** A single "launch a satellite" op: `by` puts a satellite of `type` over `regionKey` (a region it owns). */ +export interface SatelliteLaunchOp { + /** The launching nation (a nation id or name, resolved via `resolveNationRef`). */ + by: string; + /** The region key the satellite orbits over (must be a valid key owned by `by`). */ + regionKey: string; + /** The satellite's type (one of `SATELLITE_TYPES`). */ + type: SatelliteType; +} + +/** A single "deorbit a satellite" op: the satellite's stable id. */ +export type SatelliteDeorbitOp = string; + +/** The space block of a `TurnResult`. Required; default `{ launch: [], deorbit: [] }`. */ +export interface SpaceOps { + /** New satellites to launch this turn. */ + launch: SatelliteLaunchOp[]; + /** Satellite ids to deorbit (remove) this turn. */ + deorbit: SatelliteDeorbitOp[]; +} + +/** One active satellite: who launched it, over which region, of what type, and when. */ +export interface Satellite { + /** Stable id (`s1`, `s2`, …) assigned by the engine on launch. */ + id: string; + /** The launching nation's id (resolved). */ + by: string; + /** The region key the satellite orbits over. */ + regionKey: string; + /** The satellite's type (one of `SATELLITE_TYPES`). */ + type: SatelliteType; + /** The turn number the satellite was launched on. */ + launchedTurn: number; + /** The in-game date the satellite was launched on (`YYYY-MM-DD`). */ + launchedDate: string; +} + /** The exact JSON object the world-simulator LLM must return for a turn. */ export interface TurnResult { date: string; @@ -1052,6 +1154,27 @@ export interface TurnResult { * default `{ open: [], resolve: [] }`. */ contested: ContestedOps; + /** + * (v0.45.0, #80) Alliances / mutual-defense treaties this turn: `form` + * declares a new pact between two nations (a symmetric alliance entry plus + * an `ally` relation, unless they are at war — the pact still forms but the + * `war` relation keeps overriding); `break` ends an existing pact and sets + * the pair back to `neutral` (unless an active war still binds them). A pair + * holds at most one pact (re-forming is a no-op). Required; + * default `{ form: [], break: [] }`. + */ + alliances: AllianceOps; + /** + * (v0.45.0, #81) Space program / satellites this turn: `launch` puts a new + * satellite (one of the four `SatelliteType`s) into orbit over a region the + * acting nation owns, stamped with the turn and date; `deorbit` removes an + * active satellite by its stable id. Every satellite its owner holds grants + * the owner a small deterministic +1 stability per committed turn (mirrored + * to its regions, re-derived), so a nation with several satellites stacks + * the bonus. Launches/deorbits never move borders — sole ownership is + * untouched. Required; default `{ launch: [], deorbit: [] }`. + */ + space: SpaceOps; /** * (v0.27, #44) Map-marker ops this turn. `add` places new named markers * (the engine positions each at its region's centroid + a small diff --git a/shared/scenario.ts b/shared/scenario.ts index 9607537..6aa3f13 100644 --- a/shared/scenario.ts +++ b/shared/scenario.ts @@ -465,6 +465,10 @@ export function worldDraftToGameState( seaZones: [], // (v0.44.0, #66) no contested regions on a fresh state. contested: [], + // (v0.45.0, #80) no alliances on a fresh state. + alliances: [], + // (v0.45.0, #81) no satellites on a fresh state. + satellites: [], }; return state; } diff --git a/shared/types.ts b/shared/types.ts index 660d35d..f3d0990 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -3,7 +3,12 @@ // (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, ContestedRegion } from './protocol'; +import type { + Alliance, + NationOp, + ContestedRegion, + Satellite, +} from './protocol'; /** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */ export interface NationStats { @@ -486,6 +491,29 @@ export interface GameState { * with this defaulted to `[]` (see server/db.ts) — additive, no data wipe. */ contested: ContestedRegion[]; + /** + * (v0.45.0, #80) Active alliances / mutual-defense treaties (see {@link + * Alliance}): one entry per pact — which two nations are bound and when the + * pact was formed. Declared/ended by a turn's `alliances.form` / + * `alliances.break` ops (a `form` op also sets the pair's relation to + * `ally` unless an active war overrides it; a `break` op sets it back to + * `neutral` unless an active war still binds them). A pair holds at most one + * pact (re-forming is a no-op). Always `[]` on a fresh game; rows saved + * before v0.45 load with this defaulted to `[]` (see server/db.ts) — + * additive, no data wipe. + */ + alliances: Alliance[]; + /** + * (v0.45.0, #81) Active satellites (see {@link Satellite}): one entry per + * satellite in orbit — which nation launched it, over which region, of what + * type, and when. Declared/deorbited by a turn's `space.launch` / + * `space.deorbit` ops (a launch requires `by` to own `regionKey`; each + * active satellite grants its owner +1 stability per committed turn, + * mirrored to its regions and re-derived). Always `[]` on a fresh game; + * rows saved before v0.45 load with this defaulted to `[]` (see + * server/db.ts) — additive, no data wipe. + */ + satellites: Satellite[]; } /** -- 2.54.0