CI / build + unit + e2e (pull_request) Successful in 2m10s
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.
1012 lines
39 KiB
TypeScript
1012 lines
39 KiB
TypeScript
// 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'] }] });
|
||
});
|
||
});
|