// Unit tests for the CONTESTED (contested regions / frontlines) ability // (v0.44.0, #66) — the modular, DURABLE LLM-ability that introduces a THIRD // region state, besides "owned by one nation" and "no man's land": a war // frontline that is in **no** nation's `territory` and not in `unowned`. // `open` moves a contestable region (a sole owner at war with a party, or a // no-man's-land region whose parties are at war with each other) out of // ownership into `GameState.contested`; `resolve` ends a frontline, releasing // the region to no man's land unless a party's concurrent claim re-owns it. // The layer never places a key in two territories (sole ownership is // sacrosanct), never touches borders directly, and is deterministic. // 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 opens or resolves a contested region, // so the apply path is exercised here, not by the e2e suite. import { describe, expect, it } from 'vitest'; import { CONTESTED_FILL, CONTESTED_STROKE, applyContested, contested, contestedTooltipLabel, validateContestedOps, } from './contested'; import type { AbilityCtx, UserPromptCtx } from './types'; import type { ContestedRegion, ContestedOps, } from '../../shared/protocol'; import type { GameState, Nation, NationStats, RegionStats, War, } from '../../shared/types'; // --------------------------------------------------------------------------- // Fixture helpers // --------------------------------------------------------------------------- /** Build a NationStats from the four canonical indices (military, economy, stability, population). */ function NS(military: number, economy: number, stability: number, population: number): NationStats { return { military, economy, stability, population }; } /** Build a RegionStats from the four canonical indices. */ function RS(military: number, economy: number, stability: number, population: number): RegionStats { return { military, economy, stability, population }; } /** A minimal nation: the given id/name/stats/region-key territory. */ function mkNation( id: string, name: string, stats: NationStats, territory: string[], ): Nation { return { id, name, type: 'major', stats, territory, relations: {} }; } /** A minimal War entity (active by default). */ function mkWar(id: string, sides: string[], status: War['status'] = 'active'): War { return { id, sides, declaredTurn: 1, status }; } /** * A minimal GameState carrying the given nations (keyed by id) and per-region * stats, plus optional wars / unowned / contested fixtures. Every other * required field is at its fresh-game baseline. */ function mkState( nations: Record, regionStats: Record, opts: { wars?: War[]; unowned?: string[]; contested?: ContestedRegion[]; } = {}, ): 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: opts.unowned ?? [], pendingNationOps: [], markers: [], regionStats, wars: opts.wars ?? [], trades: [], embargoes: [], industry: [], seaZones: [], contested: opts.contested ?? [], }; } /** The minimal AbilityCtx the contested apply path needs (it reads only `next`). */ function ctx(state: GameState): AbilityCtx { return { previous: state, actingTurn: state.turn }; } /** * The sacrosanct invariants, asserted after every op: no region key is in more * than one nation's territory; a contested key is in NO territory and NOT in * `unowned`; an unowned key is in no territory and not contested. */ function assertSoleOwnership(state: GameState): void { const owners = new Map(); for (const n of Object.values(state.nations)) { for (const k of n.territory) owners.set(k, (owners.get(k) ?? 0) + 1); } for (const [k, c] of owners) expect(c, `key ${k} has ${c} owners`).toBe(1); const contestedKeys = new Set(state.contested.map((c) => c.regionKey)); for (const k of contestedKeys) { expect(owners.get(k) ?? 0, `contested key ${k} must be in no territory`).toBe(0); expect(state.unowned, `contested key ${k} must not be no man's land`).not.toContain(k); } for (const k of state.unowned) { expect(owners.get(k) ?? 0, `unowned key ${k} must be in no territory`).toBe(0); expect(contestedKeys.has(k), `unowned key ${k} must not be contested`).toBe(false); } } // --------------------------------------------------------------------------- // Map-fill constants / tooltip label (re-exported from the shared protocol) // --------------------------------------------------------------------------- describe('CONTESTED_FILL / CONTESTED_STROKE / contestedTooltipLabel', () => { it('exposes the distinct frontline fill + stroke', () => { expect(CONTESTED_FILL).toBe('#f97316'); expect(CONTESTED_STROKE).toBe('rgba(255,237,213,0.55)'); }); it('contestedTooltipLabel joins the party names with " vs " ("Contested: A vs B")', () => { expect(contestedTooltipLabel(['Alpha', 'Bravo'])).toBe('Contested: Alpha vs Bravo'); expect(contestedTooltipLabel(['Alpha'])).toBe('Contested: Alpha'); }); it('a missing party name degrades to a dash, never crashes', () => { expect(contestedTooltipLabel(['', 'Bravo'])).toBe('Contested: — vs Bravo'); expect(contestedTooltipLabel([])).toBe('Contested: '); }); }); // --------------------------------------------------------------------------- // validateContestedOps // --------------------------------------------------------------------------- describe('validateContestedOps', () => { it('accepts the { open: [...], resolve: [...] } object shape', () => { const open = { regionKey: 'x-r1', parties: ['a1', 'b2'] }; expect(validateContestedOps({ open: [open], resolve: ['x-r2'] })).toEqual({ open: [open], resolve: ['x-r2'], }); }); it('a missing sub-key yields an empty list for it', () => { expect(validateContestedOps({})).toEqual({ open: [], resolve: [] }); expect(validateContestedOps({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] })).toEqual({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [], }); }); it('drops an open op with a missing / empty / non-string regionKey', () => { expect(validateContestedOps({ open: [{ parties: ['a1', 'b2'] }] })).toEqual({ open: [], resolve: [], }); expect(validateContestedOps({ open: [{ regionKey: ' ', parties: ['a1', 'b2'] }] })).toEqual({ open: [], resolve: [], }); expect( validateContestedOps({ open: [{ regionKey: 42, parties: ['a1', 'b2'] }] }), ).toEqual({ open: [], resolve: [] }); }); it('drops an open op whose parties is not an array', () => { expect( validateContestedOps({ open: [{ regionKey: 'x-r1', parties: 'a1' }] }), ).toEqual({ open: [], resolve: [] }); expect( validateContestedOps({ open: [{ regionKey: 'x-r1' }] }), ).toEqual({ open: [], resolve: [] }); }); it('drops an open op with fewer than two party refs (including non-strings filtered out)', () => { expect(validateContestedOps({ open: [{ regionKey: 'x-r1', parties: [] }] })).toEqual({ open: [], resolve: [], }); expect( validateContestedOps({ open: [{ regionKey: 'x-r1', parties: ['a1'] }] }), ).toEqual({ open: [], resolve: [] }); // a non-string party is filtered out, leaving one → dropped expect( validateContestedOps({ open: [{ regionKey: 'x-r1', parties: ['a1', 42] }] }), ).toEqual({ open: [], resolve: [] }); }); it('keeps an open op with two or more party refs (refs stay raw — resolved at apply time)', () => { expect( validateContestedOps({ open: [{ regionKey: 'x-r1', parties: ['Alpha', 'Bravo', 'c3'] }] }), ).toEqual({ open: [{ regionKey: 'x-r1', parties: ['Alpha', 'Bravo', 'c3'] }], resolve: [] }); }); it('drops a non-object open entry', () => { expect( validateContestedOps({ open: [42, 'nope', null, { regionKey: 'x-r1', parties: ['a1', 'b2'] }] }), ).toEqual({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }); }); it('keeps non-empty resolve keys and drops empty / non-string entries', () => { expect( validateContestedOps({ resolve: ['x-r1', '', null, 42, 'x-r2'] }), ).toEqual({ open: [], resolve: ['x-r1', 'x-r2'] }); }); it('caps regionKey at 120 chars and party refs at 200 chars', () => { const out = validateContestedOps({ open: [{ regionKey: 'k'.repeat(300), parties: ['p'.repeat(300), 'b2'] }], }); expect(out.open).toHaveLength(1); expect(out.open[0].regionKey).toHaveLength(120); expect(out.open[0].parties[0]).toHaveLength(200); const out2 = validateContestedOps({ resolve: ['k'.repeat(300)] }); expect(out2.resolve[0]).toHaveLength(120); }); it('a missing / malformed top-level value yields { open: [], resolve: [] }', () => { expect(validateContestedOps(undefined)).toEqual({ open: [], resolve: [] }); expect(validateContestedOps(null)).toEqual({ open: [], resolve: [] }); expect(validateContestedOps('nope')).toEqual({ open: [], resolve: [] }); // a bare array is NOT accepted (unlike some sibling abilities) expect(validateContestedOps([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }])).toEqual({ open: [], resolve: [], }); expect(validateContestedOps({ open: 'nope', resolve: 'nope' })).toEqual({ open: [], resolve: [], }); }); }); // --------------------------------------------------------------------------- // applyContested — OPEN // --------------------------------------------------------------------------- describe('applyContested — open', () => { it('a sole owner at war with a party: the key leaves the owner, becomes contested (owner may be a party)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); // a1 (the owner) and b2 are both parties; the owner is at war with b2 → open applyContested(state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, ctx(state)); expect(state.nations.a1.territory).toEqual([]); expect(state.unowned).toEqual([]); // NOT released to no man's land expect(state.regionStats['x-r1']).toBeUndefined(); // the per-region stats leave expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); assertSoleOwnership(state); }); it('a party at war with the owner (the owner is not itself a party): open', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), c3: mkNation('c3', 'Charlie', NS(50, 50, 50, 50), ['c-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50), 'c-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); // owner a1 is at war with party b2 (c3 is a bystander party) → open applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['b2', 'c3'] }], resolve: [] }, ctx(state), ); expect(state.nations.a1.territory).toEqual([]); expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['b2', 'c3'] }]); assertSoleOwnership(state); }); it('a sole owner not at war with any party: dropped (no contest, territory intact)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), c3: mkNation('c3', 'Charlie', NS(50, 50, 50, 50), ['c-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50), 'c-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['b2', 'c3'])] }, ); // the parties (b2, c3) are at war with EACH OTHER, but the owner (a1) is at // war with neither — the NML branch does not apply to an owned region applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['b2', 'c3'] }], resolve: [] }, ctx(state), ); expect(state.contested).toEqual([]); expect(state.nations.a1.territory).toEqual(['x-r1']); expect(state.regionStats['x-r1']).toEqual(RS(10, 10, 10, 10)); assertSoleOwnership(state); }); it('a no-man’s-land region (in `unowned`) whose two parties are at war: open (key leaves `unowned`)', () => { 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) }, { wars: [mkWar('w1', ['a1', 'b2'])], unowned: ['x-r2'] }, ); applyContested( state, { open: [{ regionKey: 'x-r2', parties: ['a1', 'b2'] }], resolve: [] }, ctx(state), ); expect(state.unowned).toEqual([]); expect(state.contested).toEqual([{ regionKey: 'x-r2', parties: ['a1', 'b2'] }]); assertSoleOwnership(state); }); it('a no-man’s-land region whose parties are NOT at war with each other: dropped', () => { 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) }, { unowned: ['x-r2'] }, // no wars at all ); applyContested( state, { open: [{ regionKey: 'x-r2', parties: ['a1', 'b2'] }], resolve: [] }, ctx(state), ); expect(state.contested).toEqual([]); expect(state.unowned).toEqual(['x-r2']); // untouched assertSoleOwnership(state); }); it('a key with zero owners that is NOT in `unowned` (never assigned): dropped', () => { 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) }, { wars: [mkWar('w1', ['a1', 'b2'])], unowned: [] }, ); applyContested( state, { open: [{ regionKey: 'x-r2', parties: ['a1', 'b2'] }], resolve: [] }, ctx(state), ); expect(state.contested).toEqual([]); assertSoleOwnership(state); }); it('a key with two or more owners (corrupt) is never contestable: dropped', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['x-r1']), // corrupt double-ownership }, { 'x-r1': RS(10, 10, 10, 10) }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, ctx(state), ); expect(state.contested).toEqual([]); // the (corrupt) ownership is left as-is — the layer never "fixes" it expect(state.nations.a1.territory).toEqual(['x-r1']); expect(state.nations.b2.territory).toEqual(['x-r1']); }); it('an ended (armistice) war does not make the parties "at war": dropped', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2'], 'armistice')] }, ); applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, ctx(state), ); expect(state.contested).toEqual([]); expect(state.nations.a1.territory).toEqual(['x-r1']); assertSoleOwnership(state); }); it('fewer than two DISTINCT resolved parties (two refs to one nation): dropped', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); // 'Alpha' resolves to a1 — the same nation as 'a1' → only one distinct party applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'Alpha'] }], resolve: [] }, ctx(state), ); expect(state.contested).toEqual([]); expect(state.nations.a1.territory).toEqual(['x-r1']); assertSoleOwnership(state); }); it('an unresolvable party ref is dropped; if fewer than two parties remain, the op is dropped', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'ghost'] }], resolve: [] }, ctx(state), ); expect(state.contested).toEqual([]); expect(state.nations.a1.territory).toEqual(['x-r1']); assertSoleOwnership(state); }); it('an ambiguous party name (two nations share it) is dropped like an unknown ref', () => { const state = mkState( { a1: mkNation('a1', 'Twin', NS(50, 50, 50, 50), ['x-r1']), a2: mkNation('a2', 'Twin', NS(50, 50, 50, 50), ['a-r2']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'a-r2': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['Twin', 'Bravo'] }], resolve: [] }, ctx(state), ); // 'Twin' is ambiguous → dropped → only one party remains → dropped expect(state.contested).toEqual([]); expect(state.nations.a1.territory).toEqual(['x-r1']); assertSoleOwnership(state); }); it('resolves party refs by unique name (not just id) — the stored parties are the resolved ids', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['Alpha', 'Bravo'] }], resolve: [] }, ctx(state), ); expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); assertSoleOwnership(state); }); it('resolves the region by unique name when a key manifest is loaded (validRegionKeys + regionNameOf)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); const c: AbilityCtx = { previous: state, actingTurn: state.turn, validRegionKeys: new Set(['x-r1', 'b-r1']), regionNameOf: (k) => (k === 'x-r1' ? 'The Great Plain' : k === 'b-r1' ? 'B Plain' : undefined), }; applyContested( state, { open: [{ regionKey: 'the great plain', parties: ['a1', 'b2'] }], resolve: [] }, c, ); expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); assertSoleOwnership(state); }); it('a region key absent from the loaded manifest is dropped', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); const c: AbilityCtx = { previous: state, actingTurn: state.turn, validRegionKeys: new Set(['x-r1', 'b-r1']), regionNameOf: (k) => (k === 'x-r1' ? 'The Great Plain' : k === 'b-r1' ? 'B Plain' : undefined), }; applyContested( state, { open: [{ regionKey: 'nope-r9', parties: ['a1', 'b2'] }], resolve: [] }, c, ); expect(state.contested).toEqual([]); expect(state.nations.a1.territory).toEqual(['x-r1']); assertSoleOwnership(state); }); it('an open re-derives the stripped owner’s stats from its remaining regions', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1', 'a-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'a-r1': RS(30, 40, 50, 60), 'b-r1': RS(50, 50, 50, 50), }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, ctx(state), ); // a1 keeps only a-r1 → its derived stats become that region's (the // unweighted median of a single value is the value itself) expect(state.nations.a1.territory).toEqual(['a-r1']); expect(state.nations.a1.stats).toEqual(NS(30, 40, 50, 60)); expect(state.regionStats['x-r1']).toBeUndefined(); assertSoleOwnership(state); }); it('an owner left with zero regions keeps its directly-mutated stats (0-region guard)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(77, 78, 79, 80), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, ctx(state), ); expect(state.nations.a1.territory).toEqual([]); expect(state.nations.a1.stats).toEqual(NS(77, 78, 79, 80)); // unchanged assertSoleOwnership(state); }); it('two open ops on the same key in one turn: only the first takes effect (the key is ownerless, not NML, afterwards)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), c3: mkNation('c3', 'Charlie', NS(50, 50, 50, 50), ['c-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'b-r1': RS(50, 50, 50, 50), 'c-r1': RS(50, 50, 50, 50) }, { wars: [mkWar('w1', ['a1', 'b2']), mkWar('w2', ['a1', 'c3'])] }, ); applyContested( state, { open: [ { regionKey: 'x-r1', parties: ['a1', 'b2'] }, { regionKey: 'x-r1', parties: ['a1', 'c3'] }, ], resolve: [], }, ctx(state), ); // the first op strips x-r1 from a1; the second then sees 0 owners and no // NML membership → dropped. The key is contested exactly once (first op). expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); expect(state.unowned).toEqual([]); assertSoleOwnership(state); }); it('opens two distinct keys in one turn (both added, in op order)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1', 'x-r2']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'x-r2': RS(20, 20, 20, 20), 'b-r1': RS(50, 50, 50, 50), }, { wars: [mkWar('w1', ['a1', 'b2'])] }, ); applyContested( state, { open: [ { regionKey: 'x-r1', parties: ['a1', 'b2'] }, { regionKey: 'x-r2', parties: ['a1', 'b2'] }, ], resolve: [], }, ctx(state), ); expect(state.nations.a1.territory).toEqual([]); expect(state.contested).toEqual([ { regionKey: 'x-r1', parties: ['a1', 'b2'] }, { regionKey: 'x-r2', parties: ['a1', 'b2'] }, ]); assertSoleOwnership(state); }); }); // --------------------------------------------------------------------------- // applyContested — resolve // --------------------------------------------------------------------------- describe('applyContested — resolve', () => { it('releases a still-contested (ownerless) key to no man’s land: out of `contested`, into `unowned`, stats deleted', () => { 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), // defensive: a stray entry on the ownerless contested key 'x-r1': RS(10, 10, 10, 10), }, { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, ); applyContested( state, { open: [], resolve: ['x-r1'] }, ctx(state), ); expect(state.contested).toEqual([]); expect(state.unowned).toEqual(['x-r1']); expect(state.regionStats['x-r1']).toBeUndefined(); assertSoleOwnership(state); }); it('a resolving party’s concurrent winning claim: the key stays owned, keeps its stats, and is NOT re-added to `contested`', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']), b2: mkNation('b2', 'Bravo', NS(60, 55, 50, 45), ['b-r1', 'x-r1']), // re-owned this turn }, { 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(60, 55, 50, 45), // the claim re-initialised the (previously deleted) entry 'x-r1': RS(10, 20, 30, 40), }, { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, ); applyContested(state, { open: [], resolve: ['x-r1'] }, ctx(state)); expect(state.contested).toEqual([]); expect(state.nations.b2.territory).toEqual(['b-r1', 'x-r1']); // still owned expect(state.unowned).toEqual([]); // NOT released expect(state.regionStats['x-r1']).toEqual(RS(10, 20, 30, 40)); // kept assertSoleOwnership(state); }); it('a resolving party’s claim that left no regionStats entry re-inits it from the owner’s stats', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']), b2: mkNation('b2', 'Bravo', NS(60, 55, 50, 45), ['b-r1', 'x-r1']), }, { 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(60, 55, 50, 45) }, // no 'x-r1' entry { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, ); applyContested(state, { open: [], resolve: ['x-r1'] }, ctx(state)); expect(state.contested).toEqual([]); expect(state.nations.b2.territory).toEqual(['b-r1', 'x-r1']); expect(state.regionStats['x-r1']).toEqual(RS(60, 55, 50, 45)); // re-init = copy of b2.stats assertSoleOwnership(state); }); it('a resolved key owned by a NON-party (defensive) is released to no man’s land', () => { 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']), c3: mkNation('c3', 'Charlie', NS(50, 50, 50, 50), ['c-r1', 'x-r1']), }, { 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50), 'c-r1': RS(50, 50, 50, 50), 'x-r1': RS(10, 10, 10, 10), }, { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, ); // c3 (not a party) somehow owns the contested key → release applyContested(state, { open: [], resolve: ['x-r1'] }, ctx(state)); expect(state.contested).toEqual([]); expect(state.nations.c3.territory).toEqual(['c-r1']); expect(state.unowned).toEqual(['x-r1']); expect(state.regionStats['x-r1']).toBeUndefined(); assertSoleOwnership(state); }); it('resolving one of several contested keys leaves the others untouched', () => { 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), 'x-r1': RS(10, 10, 10, 10), 'x-r2': RS(20, 20, 20, 20), }, { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }, { regionKey: 'x-r2', parties: ['a1', 'b2'] }] }, ); applyContested(state, { open: [], resolve: ['x-r1'] }, ctx(state)); expect(state.contested).toEqual([{ regionKey: 'x-r2', parties: ['a1', 'b2'] }]); expect(state.unowned).toEqual(['x-r1']); expect(state.regionStats['x-r2']).toEqual(RS(20, 20, 20, 20)); // untouched assertSoleOwnership(state); }); it('a resolve of a key that is not currently contested is a no-op', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['x-r2']), }, { 'x-r1': RS(10, 10, 10, 10), 'x-r2': RS(20, 20, 20, 20) }, { contested: [{ regionKey: 'x-r3', parties: ['a1', 'b2'] }] }, ); applyContested(state, { open: [], resolve: ['x-r1'] }, ctx(state)); // x-r1 is not contested → untouched; the x-r3 frontline persists expect(state.contested).toEqual([{ regionKey: 'x-r3', parties: ['a1', 'b2'] }]); expect(state.nations.a1.territory).toEqual(['x-r1']); expect(state.regionStats['x-r1']).toEqual(RS(10, 10, 10, 10)); expect(state.unowned).toEqual([]); assertSoleOwnership(state); }); it('an empty resolve list keeps every existing contested entry', () => { 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) }, { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, ); applyContested(state, { open: [], resolve: [] }, ctx(state)); expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); expect(state.unowned).toEqual([]); assertSoleOwnership(state); }); }); // --------------------------------------------------------------------------- // applyContested — ordering & interaction (resolve runs before open) // --------------------------------------------------------------------------- describe('applyContested — ordering & interaction', () => { it('a re-open (open op on a key that already has a contested entry) replaces the entry — unique by region key', () => { const state = mkState( { // a1 re-owns x-r1 this turn (simulating a claim that ran before the // abilities loop), so the key is contestable again a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1', 'x-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), c3: mkNation('c3', 'Charlie', NS(50, 50, 50, 50), ['c-r1']), }, { 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50), 'c-r1': RS(50, 50, 50, 50), 'x-r1': RS(10, 10, 10, 10), }, { wars: [mkWar('w1', ['a1', 'b2']), mkWar('w2', ['a1', 'c3'])], contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], }, ); // a1 is at war with c3 → the new op's war-linkage holds and the open // replaces the kept entry for the same key applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'c3'] }], resolve: [] }, ctx(state), ); expect(state.nations.a1.territory).toEqual(['a-r1']); expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'c3'] }]); assertSoleOwnership(state); }); it('resolve-then-open on the same key: the release to no man’s land makes a same-turn open from NML succeed', () => { 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) }, { wars: [mkWar('w1', ['a1', 'b2'])], contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], }, ); // resolve releases x-r1 to NML first; the open then finds it ownerless + // in unowned with warring parties → it re-contests from no man's land applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: ['x-r1'] }, ctx(state), ); expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); expect(state.unowned).toEqual([]); assertSoleOwnership(state); }); it('a still-contested key (ownerless, NOT in unowned) cannot be re-opened directly', () => { 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) }, { wars: [mkWar('w1', ['a1', 'b2'])], contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], }, ); // x-r1 is ownerless and not in unowned → neither open branch applies applyContested( state, { open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] }, ctx(state), ); // the existing entry persists unchanged (a no-op open keeps it) expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); expect(state.unowned).toEqual([]); assertSoleOwnership(state); }); it('apply() via the Ability: an open and a resolve in one call; bad refs never throw', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['x-r1', 'a-r1']), b2: mkNation('b2', 'Bravo', NS(50, 50, 50, 50), ['b-r1']), }, { 'x-r1': RS(10, 10, 10, 10), 'a-r1': RS(50, 50, 50, 50), 'b-r1': RS(50, 50, 50, 50), 'x-r2': RS(20, 20, 20, 20), }, { wars: [mkWar('w1', ['a1', 'b2'])], contested: [{ regionKey: 'x-r2', parties: ['a1', 'b2'] }], }, ); const ops: ContestedOps = { open: [ { regionKey: 'x-r1', parties: ['a1', 'b2'] }, // an unresolvable second party → only one distinct party → dropped { regionKey: 'a-r1', parties: ['a1', 'ghost'] }, ], resolve: ['x-r2'], }; contested.apply(state, ops, ctx(state)); // x-r1 opened (a1 keeps a-r1); x-r2 released to no man's land; the // dropped op left a-r1 exactly where it was expect(state.nations.a1.territory).toEqual(['a-r1']); expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); expect(state.unowned).toEqual(['x-r2']); expect(state.regionStats['x-r1']).toBeUndefined(); expect(state.regionStats['x-r2']).toBeUndefined(); assertSoleOwnership(state); }); it('apply() with the default (empty) ops is a no-op on an existing contested list', () => { 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) }, { contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] }, ); contested.apply(state, contested.default(), ctx(state)); expect(state.contested).toEqual([{ regionKey: 'x-r1', parties: ['a1', 'b2'] }]); expect(state.unowned).toEqual([]); assertSoleOwnership(state); }); }); // --------------------------------------------------------------------------- // The Ability object contract // --------------------------------------------------------------------------- describe('contested ability', () => { it('owns the "contested" field; default() is a fresh empty { open: [], resolve: [] }', () => { expect(contested.field).toBe('contested'); expect(contested.default()).toEqual({ open: [], resolve: [] }); expect(contested.default()).not.toBe(contested.default()); }); it('default() is not meaningful; a non-empty open OR resolve is', () => { expect(contested.isMeaningful(contested.default())).toBe(false); expect(contested.isMeaningful({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: [] })).toBe(true); expect(contested.isMeaningful({ open: [], resolve: ['x-r1'] })).toBe(true); }); it('validate() wraps the validator into the ContestedOps shape', () => { expect( contested.validate({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: ['x-r2'] }), ).toEqual({ open: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }], resolve: ['x-r2'] }); expect(contested.validate(undefined)).toEqual({ open: [], resolve: [] }); }); it('the stub value equals default() (the stub never opens or resolves a frontline)', () => { expect(contested.stub()).toEqual({ open: [], resolve: [] }); }); it('stateFields is ["contested"] (the durable GameState field)', () => { expect(contested.stateFields).toEqual(['contested']); }); it('schemaLine() / constraint() document the field for the system prompt', () => { expect(contested.schemaLine()).toContain('"contested"'); expect(contested.schemaLine()).toContain('"regionKey"'); expect(contested.constraint()).toContain('frontlines'); expect(contested.constraint()).toContain('no man'); }); it('payload() contributes nothing when the contested list is empty / undefined (a fresh world)', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, { 'a-r1': RS(50, 50, 50, 50) }, ); expect(contested.payload({ state } as UserPromptCtx)).toEqual({}); expect(contested.payload({ state, contested: [] } as UserPromptCtx)).toEqual({}); }); it('payload() lists the current frontlines with party NAMES (ids resolved to names)', () => { 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) }, ); expect( contested.payload( { state, contested: [{ regionKey: 'x-r1', parties: ['a1', 'b2'] }] } as UserPromptCtx, ), ).toEqual({ contested: [{ regionKey: 'x-r1', parties: ['Alpha', 'Bravo'] }] }); }); it('payload() degrades a missing party id (a removed nation) to the raw id, never crashes', () => { const state = mkState( { a1: mkNation('a1', 'Alpha', NS(50, 50, 50, 50), ['a-r1']) }, { 'a-r1': RS(50, 50, 50, 50) }, ); expect( contested.payload( { state, contested: [{ regionKey: 'x-r1', parties: ['ghost', 'a1'] }] } as UserPromptCtx, ), ).toEqual({ contested: [{ regionKey: 'x-r1', parties: ['ghost', 'Alpha'] }] }); }); });