Files
History/server/abilities/contested.test.ts
T
kamilkrzywda 5477336537
CI / build + unit + e2e (pull_request) Successful in 2m10s
feat: #66 contested regions / frontlines — 12th ability (v0.44.0)
A third region state (contested frontline): a region in no nation's territory and not in unowned, opened/resolved by the LLM, tied to an active war. Sole-ownership invariant preserved. Durable GameState.contested; contested-inert stub; map legend stays at 7.
2026-10-16 15:21:45 +00:00

1012 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, Nation>,
regionStats: Record<string, RegionStats>,
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<string, number>();
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'] }] });
});
});