No man's land: TurnResult.unowned + release/re-claim in applyTurnResult + distinct grey map rendering (#16 P3)
- shared/protocol.ts: TurnResult.unowned (keys released this turn) + the map presentation contract (regionFillStyle / unownedTooltipLabel, hue-less grey) - shared/types.ts: required GameState.unowned (cumulative mid-game releases; [] at init, distinct from preset-unassigned regions) - server/engine.ts: applyTurnResult applies releases first (sole-owner only, never fabricating no man's land from unassigned keys), then claims against the post-release snapshot (gain on an ownerless key = re-claim); lone release is isMeaningful; prompts + user payload carry the unowned field - server/stub.ts: unchanged behavior (never releases; e2e stays stable) - server/db.ts: legacy rows load with unowned defaulted to [] - server/index.ts: create-game sets []; jump reference table includes released keys so the model can name them for re-claim - WorldMap/App: unowned prop -> visible grey + 'No man's land' tooltip - unit tests: 105 -> 127 (release/re-claim suites, presentation contract, legacy-row normalization)
This commit is contained in:
@@ -23,6 +23,7 @@ function makeState(id: string): GameState {
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +74,17 @@ describe('openDb', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('(v0.22 #16 P3) a legacy row saved before the unowned field existed loads with it defaulted to []', () => {
|
||||
const db = openDb(':memory:');
|
||||
const legacy = makeState('legacy');
|
||||
delete (legacy as { unowned?: string[] }).unowned; // a pre-v0.22 save (field absent)
|
||||
db.save(legacy);
|
||||
const loaded = db.load('legacy');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.unowned).toEqual([]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('delete removes a game by id and reports absence otherwise', () => {
|
||||
const db = openDb(':memory:');
|
||||
db.save(makeState('a'));
|
||||
|
||||
+5
-1
@@ -39,7 +39,11 @@ export function openDb(filePath: string): Db {
|
||||
const row = byId.get(id) as { data: string } | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data) as GameState;
|
||||
const state = JSON.parse(row.data) as GameState;
|
||||
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
|
||||
// with it defaulted: a legacy game has no no-man's-land regions.
|
||||
if (state.unowned === undefined) state.unowned = [];
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
+216
-7
@@ -78,6 +78,7 @@ function baseState(): GameState {
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,8 +93,8 @@ const validResult = JSON.stringify({
|
||||
],
|
||||
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
|
||||
// v0.21 (#16 P2): DE gains the UK's GBR — the key has exactly one previous
|
||||
// owner, so the transfer applies (an unowned key like 'POL' would be ignored
|
||||
// by the new sole-ownership rules in applyTurnResult).
|
||||
// owner, so the transfer applies (since v0.22 #16 P3 a key owned by NOBODY
|
||||
// would be re-claimed instead of ignored; a release lives in "unowned").
|
||||
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
||||
});
|
||||
|
||||
@@ -338,6 +339,22 @@ describe('buildUserPrompt (batched round)', () => {
|
||||
const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> };
|
||||
expect(withoutRef.regions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('(v0.22 #16 P3) emits the no-man\'s-land list when provided (even when empty) and omits it otherwise', () => {
|
||||
// runTurn always passes state.unowned (even []), so the field is present...
|
||||
const withEmpty = JSON.parse(buildUserPrompt(baseState(), [], undefined, undefined, [])) as {
|
||||
unowned?: string[];
|
||||
};
|
||||
expect(withEmpty.unowned).toEqual([]);
|
||||
// ...and a populated list reaches the model verbatim (it knows what it can re-claim)
|
||||
const withKeys = JSON.parse(
|
||||
buildUserPrompt(baseState(), [], undefined, undefined, ['GBR', 'POL']),
|
||||
) as { unowned?: string[] };
|
||||
expect(withKeys.unowned).toEqual(['GBR', 'POL']);
|
||||
// but a caller that does not provide it keeps the legacy payload shape
|
||||
const omitted = JSON.parse(buildUserPrompt(baseState(), [])) as { unowned?: string[] };
|
||||
expect(omitted.unowned).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueRoundItem', () => {
|
||||
@@ -460,6 +477,7 @@ describe('applyTurnResult', () => {
|
||||
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
};
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
@@ -474,6 +492,7 @@ describe('applyTurnResult', () => {
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
@@ -494,6 +513,7 @@ describe('applyTurnResult', () => {
|
||||
// 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz'
|
||||
// is not a real key at all. Both are proposed as a gain by DE.
|
||||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||
unowned: [],
|
||||
};
|
||||
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
||||
@@ -514,13 +534,16 @@ describe('applyTurnResult', () => {
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
// 'wales-bbbb' has a sole previous owner (uk) -> it transfers; 'POL' is
|
||||
// owned by no one -> it is IGNORED (a key may not end a turn unowned).
|
||||
// owned by no one -> a re-claim (v0.22, #16 P3): DE gains it. It was never
|
||||
// released (not in state.unowned), so it does not enter GameState.unowned.
|
||||
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||
unowned: [],
|
||||
};
|
||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||||
expect(next.nations.de.territory).not.toContain('POL');
|
||||
expect(next.nations.de.territory).toContain('POL'); // re-claimed, not ignored
|
||||
expect(next.unowned).toEqual([]); // never released -> not no man's land
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,6 +553,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
unowned: [],
|
||||
};
|
||||
|
||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||
@@ -610,6 +634,178 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
||||
const noEvents = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
};
|
||||
|
||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||
const state = baseState();
|
||||
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
|
||||
expect(next.nations.uk.territory).not.toContain('GBR');
|
||||
expect(next.unowned).toEqual(['GBR']);
|
||||
// nothing else moved
|
||||
expect(next.nations.us.territory).toEqual(['USA']);
|
||||
expect(next.nations.de.territory).toEqual(['DEU']);
|
||||
});
|
||||
|
||||
it('a re-claim (a gain on a currently-unowned key) grants it and removes it from GameState.unowned', () => {
|
||||
const state = baseState();
|
||||
state.unowned = ['POL']; // released in an earlier turn
|
||||
const next = applyTurnResult(state, {
|
||||
...noEvents,
|
||||
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
|
||||
});
|
||||
expect(next.nations.de.territory).toContain('POL');
|
||||
expect(next.unowned).toEqual([]); // it left no man's land
|
||||
});
|
||||
|
||||
it('a release and a claim of the same key in one turn: the claim wins (the key is owned)', () => {
|
||||
const state = baseState();
|
||||
const next = applyTurnResult(state, {
|
||||
...noEvents,
|
||||
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
||||
unowned: ['GBR'],
|
||||
});
|
||||
expect(next.nations.de.territory).toContain('GBR');
|
||||
expect(next.nations.uk.territory).not.toContain('GBR');
|
||||
expect(next.unowned).toEqual([]);
|
||||
});
|
||||
|
||||
it('a double claim on a released key is rejected: the key stays in no man\'s land', () => {
|
||||
const state = baseState();
|
||||
const next = applyTurnResult(state, {
|
||||
...noEvents,
|
||||
territoryChanges: [
|
||||
{ nation: 'de', gain: ['GBR'], lose: [] },
|
||||
{ nation: 'us', gain: ['GBR'], lose: [] },
|
||||
],
|
||||
unowned: ['GBR'], // released first, then both nations lunge for it
|
||||
});
|
||||
expect(next.nations.uk.territory).not.toContain('GBR'); // the release applied
|
||||
expect(next.nations.de.territory).not.toContain('GBR'); // both claims rejected
|
||||
expect(next.nations.us.territory).not.toContain('GBR');
|
||||
expect(next.unowned).toEqual(['GBR']);
|
||||
});
|
||||
|
||||
it('releasing a key no nation owns (a preset-unassigned region) is a no-op — no man\'s land can only arise from a real release', () => {
|
||||
const state = baseState();
|
||||
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
|
||||
expect(next.unowned).toEqual([]);
|
||||
for (const n of Object.values(next.nations)) expect(n.territory).not.toContain('POL');
|
||||
});
|
||||
|
||||
it('releasing a key with two owners (a corrupt state) is a no-op', () => {
|
||||
const state = baseState();
|
||||
state.nations.us.territory = ['USA', 'GBR']; // uk AND us share GBR
|
||||
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
|
||||
expect(next.nations.uk.territory).toContain('GBR');
|
||||
expect(next.nations.us.territory).toContain('GBR');
|
||||
expect(next.unowned).toEqual([]);
|
||||
});
|
||||
|
||||
it('releasing a key already in no man\'s land is a no-op (it stays listed once)', () => {
|
||||
const state = baseState();
|
||||
state.unowned = ['POL'];
|
||||
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
|
||||
expect(next.unowned).toEqual(['POL']);
|
||||
});
|
||||
|
||||
it('with a validKeys set: releasing a hallucinated key is a no-op', () => {
|
||||
const state = baseState();
|
||||
const next = applyTurnResult(state, { ...noEvents, unowned: ['not-a-region-xyz'] }, new Set(['wales-bbbb']));
|
||||
expect(next.unowned).toEqual([]);
|
||||
});
|
||||
|
||||
it('a legacy state without the unowned field accepts a release (the result carries the field)', () => {
|
||||
const state = baseState();
|
||||
delete (state as { unowned?: string[] }).unowned; // simulate a pre-v0.22 row
|
||||
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
|
||||
expect(next.unowned).toEqual(['GBR']);
|
||||
expect(next.nations.uk.territory).not.toContain('GBR');
|
||||
});
|
||||
|
||||
it('a turn with no releases leaves the existing no man\'s land list unchanged', () => {
|
||||
const state = baseState();
|
||||
state.unowned = ['POL'];
|
||||
const next = applyTurnResult(state, {
|
||||
...noEvents,
|
||||
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
|
||||
});
|
||||
expect(next.unowned).toEqual(['POL']);
|
||||
expect(next.nations.de.territory).toContain('GBR');
|
||||
});
|
||||
|
||||
it('validateTurnResult dedupes and filters the unowned list (trimmed non-empty strings only)', () => {
|
||||
const r = validateTurnResult({
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [' a ', 'a', '', 42, null, ' b '],
|
||||
});
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.unowned).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
|
||||
expect(
|
||||
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('runTurn applies a release end-to-end: the model narrates it and the key leaves its owner', async () => {
|
||||
const original = baseState();
|
||||
original.pending = [{ kind: 'action', text: 'Scorch the earth on the retreat' }];
|
||||
const releaseJson = JSON.stringify({
|
||||
date: '1939-09-05',
|
||||
events: [
|
||||
{
|
||||
nation: 'uk',
|
||||
type: 'military',
|
||||
title: 'A retreat through the wastelands',
|
||||
text: 'The abandoned highlands burn and lie empty.',
|
||||
outcome: 'partial',
|
||||
},
|
||||
],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: ['GBR'],
|
||||
});
|
||||
const payloads: string[] = [];
|
||||
const client: LlmClient = {
|
||||
chat: async (messages) => {
|
||||
const user = messages.find((m) => m.role === 'user');
|
||||
if (user) payloads.push(user.content as string);
|
||||
return releaseJson;
|
||||
},
|
||||
listModels: async () => [],
|
||||
};
|
||||
const { state } = await runTurn({ llm: client, preset, state: original, items: original.pending });
|
||||
// the event landed and the release applied
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].title).toBe('A retreat through the wastelands');
|
||||
expect(state.nations.uk.territory).not.toContain('GBR');
|
||||
expect(state.unowned).toEqual(['GBR']);
|
||||
// on the first turn the model was shown the (empty) no-man's-land list...
|
||||
const first = JSON.parse(payloads[0]) as { unowned?: string[] };
|
||||
expect(first.unowned).toEqual([]);
|
||||
// ...and on the next turn it is shown the released key (so it can re-claim it)
|
||||
const next = await runTurn({ llm: client, preset, state, items: [] });
|
||||
const second = JSON.parse(payloads[1]) as { unowned?: string[] };
|
||||
expect(second.unowned).toEqual(['GBR']);
|
||||
// the repeated "release" of an already-unowned key is a no-op
|
||||
expect(next.state.unowned).toEqual(['GBR']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeNeighboursExecutor (v0.21 #16 P2)', () => {
|
||||
const getNeighbours = (keys: string[]) => keys.map((k) => `nb-${k}`);
|
||||
|
||||
@@ -850,12 +1046,25 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
|
||||
expect(withTool).toMatch(/frontier/i);
|
||||
expect(withTool).not.toMatch(/Only change borders when war/i);
|
||||
});
|
||||
|
||||
it('(v0.22 #16 P3) both modes contract the no-man\'s-land field (release + re-claim) and constrain it to owned keys', () => {
|
||||
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||||
// the JSON shape carries the field in both modes
|
||||
expect(p).toMatch(/"unowned": \["<region key>"\]/);
|
||||
// the rule explains release (narrated in events) and re-claim (ordinary gain)
|
||||
expect(p).toMatch(/no man's land/i);
|
||||
expect(p).toMatch(/narrate the cause in "events"/);
|
||||
expect(p).toMatch(/re-claims it with a "gain"/i);
|
||||
// the hard constraint: only currently-owned keys may be released
|
||||
expect(p).toMatch(/only region keys that a nation currently owns/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful', () => {
|
||||
it('is true with an event, false when everything is empty', () => {
|
||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [] })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [] })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [] })).toBe(false);
|
||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [] })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [] })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+99
-26
@@ -142,12 +142,23 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
const respondLine = withNeighboursTool
|
||||
? 'You have a getNeighbours tool: given region keys it returns the region keys that border them. You may call it (a few times) to verify land borders before deciding territory transfers. Once done, respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:'
|
||||
: 'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:';
|
||||
// (v0.22, #16 P3) No man's land: a region can be left unowned after a war or
|
||||
// cataclysm (the model narrates the cause in "events" and lists the key in
|
||||
// "unowned"); unowned regions can later be re-claimed with an ordinary gain.
|
||||
// The model sees the current no-man's-land keys in the user payload's
|
||||
// "unowned" field and in the "regions" reference table.
|
||||
const noMansLandRule =
|
||||
'- A region key has at most one owner at all times — never assign a key to two nations. After a war or cataclysm a region may be left unowned ("no man\'s land"): list its key in "unowned" and narrate the cause in "events"; it stays unowned (grey on the map) until a nation re-claims it with a "gain". The "unowned" field of the user message lists the regions currently in no man\'s land.';
|
||||
const territoryRules = withNeighboursTool
|
||||
? [
|
||||
'- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns. One region key has exactly one owner at all times — never assign a key to two nations or to none.',
|
||||
'- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns.',
|
||||
'- The "frontier" field of the user message lists region keys owned by other nations that border the player\'s territory — the natural targets of a player-led conquest this turn.',
|
||||
noMansLandRule,
|
||||
]
|
||||
: ['- Only change borders when war or a decisive political shift justifies it.'];
|
||||
: [
|
||||
'- Only change borders when war or a decisive political shift justifies it.',
|
||||
noMansLandRule,
|
||||
];
|
||||
return [
|
||||
`WORLD: ${preset.title} — ${preset.startDate}`,
|
||||
preset.worldBackground,
|
||||
@@ -164,12 +175,14 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' ],',
|
||||
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}]',
|
||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
|
||||
' "unowned": ["<region key>"]',
|
||||
'}',
|
||||
'',
|
||||
'HARD CONSTRAINTS',
|
||||
'- Use only nation ids and region keys that exist in the provided state.',
|
||||
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.',
|
||||
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
|
||||
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
|
||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||
'- Keep every stat value between 0 and 100.',
|
||||
@@ -198,6 +211,13 @@ export function buildUserPrompt(
|
||||
* contiguous targets. Omitted entirely when undefined (no adjacency data).
|
||||
*/
|
||||
frontier?: string[],
|
||||
/**
|
||||
* (v0.22, #16 P3) Region keys that are currently "no man's land" (released
|
||||
* mid-game — `state.unowned`). Emitted verbatim (even when empty) so the
|
||||
* model knows which unowned regions exist and can be re-claimed this turn.
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
noMansLand?: string[],
|
||||
): string {
|
||||
const player = state.nations[state.playerNationId];
|
||||
const playerName = player?.name ?? state.playerNationId;
|
||||
@@ -235,6 +255,7 @@ export function buildUserPrompt(
|
||||
};
|
||||
if (regionReference) payload.regions = regionReference;
|
||||
if (frontier !== undefined) payload.frontier = frontier;
|
||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
@@ -309,6 +330,7 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
};
|
||||
|
||||
if (Array.isArray(o.events)) {
|
||||
@@ -384,12 +406,26 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
||||
}
|
||||
}
|
||||
|
||||
// (v0.22, #16 P3) Keys released to "no man's land": deduped, non-empty
|
||||
// strings only (applied later by applyTurnResult against validKeys).
|
||||
if (Array.isArray(o.unowned)) {
|
||||
for (const k of o.unowned) {
|
||||
const s = typeof k === 'string' ? k.trim() : '';
|
||||
if (s && !result.unowned.includes(s)) result.unowned.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A turn is "meaningful" if it produced at least an event or a stat change. */
|
||||
/**
|
||||
* A turn is "meaningful" if it produced at least an event, a stat change, or
|
||||
* a no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||
* when the model under-narrates it, so it must survive the retry/nudge path
|
||||
* instead of being replaced by the generic "unclear" result).
|
||||
*/
|
||||
export function isMeaningful(result: TurnResult): boolean {
|
||||
return result.events.length > 0 || result.stateChanges.length > 0;
|
||||
return result.events.length > 0 || result.stateChanges.length > 0 || result.unowned.length > 0;
|
||||
}
|
||||
|
||||
// --- Applying the delta -------------------------------------------------------
|
||||
@@ -406,7 +442,8 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
||||
|
||||
/**
|
||||
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
||||
* events, apply stat changes (clamped), symmetric relations, and territory moves.
|
||||
* events, apply stat changes (clamped), symmetric relations, and territory
|
||||
* moves (including no-man's-land releases, v0.22 #16 P3).
|
||||
*/
|
||||
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
@@ -431,22 +468,53 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
|
||||
a.relations[r.b] = r.set;
|
||||
b.relations[r.a] = r.set;
|
||||
}
|
||||
// --- Territory (v0.21, #16 P2: a region key has exactly one owner) ---
|
||||
// --- Territory (v0.21, #16 P2: at most one owner per key;
|
||||
// v0.22, #16 P3: a key may also be released to "no man's land") ---
|
||||
//
|
||||
// Snapshot-first: BEFORE any mutation, compute who currently owns each
|
||||
// region key. A key transfers only when (a) exactly one nation claims to
|
||||
// gain it — a double claim (two or more gainers) is rejected so the key
|
||||
// stays where it is, (b) the key currently has exactly one owner — a
|
||||
// pre-existing double ownership (or unownership) is never touched, and
|
||||
// (c) that owner is not the gainer (a self-gain is a no-op). A transfer
|
||||
// removes the key from every nation first (defensive) and then grants it
|
||||
// to the gainer; "lose" entries are pure no-ops by design, because a lone
|
||||
// loss would orphan the key and a valid gain already removes it from the
|
||||
// old owner. Ownership is therefore conserved: a key can never end a turn
|
||||
// unowned or multiply-owned.
|
||||
// Two kinds of change, applied in this order:
|
||||
//
|
||||
// (1) RELEASES (`result.unowned`, v0.22 #16 P3): each key that a nation
|
||||
// CURRENTLY owns (exactly one owner) is removed from that owner, left
|
||||
// ownerless, and added to `GameState.unowned` — the client renders it
|
||||
// as the distinct no-man's-land grey. A key with zero owners (a preset
|
||||
// never assigned it) is never released: doing so would fabricate a
|
||||
// visible no-man's-land out of an ordinary unowned region. A key with
|
||||
// two or more owners (pre-existing corruption) is never touched, and
|
||||
// a key already in `unowned` is a no-op (it is already unowned).
|
||||
//
|
||||
// (2) CLAIMS (`gain` entries), resolved against the ownership AFTER the
|
||||
// releases (snapshot-first):
|
||||
// - a key with exactly one owner that is not the gainer → a TRANSFER:
|
||||
// removed from the old owner, granted to the gainer (v0.21 rules);
|
||||
// - a key with NO owner → a RE-CLAIM: granted to the (sole) gainer and
|
||||
// removed from `GameState.unowned` (it leaves no man's land);
|
||||
// - a double claim (two or more distinct gainers) is rejected — the
|
||||
// key stays as it is; a self-gain is a no-op; a pre-existing double
|
||||
// ownership is never touched.
|
||||
//
|
||||
// Invariants after the turn: no key sits in two or more territories; a
|
||||
// released key sits in none; `GameState.unowned` holds exactly the
|
||||
// zero-owner keys that were released mid-game (not the preset-unassigned
|
||||
// ones). "lose" entries remain deliberate no-ops: releasing is the
|
||||
// `unowned` list's job, and a lone loss without a release would just
|
||||
// orphan a key silently.
|
||||
const valid = (k: string) => !validKeys || validKeys.has(k);
|
||||
const ownerOf = new Map<string, string>(); // key -> sole owner id, or '' (0 or 2+ owners)
|
||||
for (const n of Object.values(state.nations)) {
|
||||
const unowned = new Set(state.unowned ?? []);
|
||||
|
||||
// (1) Releases: establish the new no-man's-land set before any claim.
|
||||
for (const key of new Set(result.unowned)) {
|
||||
if (!valid(key) || unowned.has(key)) continue;
|
||||
const owners = Object.values(next.nations)
|
||||
.filter((n) => n.territory.includes(key))
|
||||
.map((n) => n.id);
|
||||
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
|
||||
unowned.add(key);
|
||||
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
|
||||
}
|
||||
|
||||
// (2) Claims against the post-release ownership snapshot.
|
||||
const ownerOf = new Map<string, string>(); // key -> sole owner id, or '' (2+ owners; 0 = absent)
|
||||
for (const n of Object.values(next.nations)) {
|
||||
for (const key of n.territory) {
|
||||
if (!valid(key)) continue;
|
||||
const cur = ownerOf.get(key);
|
||||
@@ -479,16 +547,18 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
|
||||
for (const [key, claiming] of claimants) {
|
||||
if (claiming.size !== 1) continue; // double claim: the key stays as it was
|
||||
const gainer = [...claiming][0];
|
||||
const owner = ownerOf.get(key);
|
||||
if (owner === undefined || owner === '' || owner === gainer) continue;
|
||||
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
||||
if (owner === '') continue; // pre-existing double ownership: never touched
|
||||
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
|
||||
for (const n of Object.values(next.nations)) {
|
||||
const i = n.territory.indexOf(key);
|
||||
if (i !== -1) n.territory.splice(i, 1);
|
||||
}
|
||||
next.nations[gainer].territory.push(key);
|
||||
unowned.delete(key); // re-claimed: the key leaves no man's land
|
||||
}
|
||||
// "lose" entries are deliberate no-ops (see above): a valid gain already
|
||||
// removes the key from its old owner, and a lone loss would orphan it.
|
||||
|
||||
next.unowned = [...unowned];
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -558,7 +628,9 @@ export async function runTurn(opts: {
|
||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||
const system = buildSystemPrompt(preset, useTools);
|
||||
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
|
||||
const user = buildUserPrompt(state, items, regionReference, frontier);
|
||||
// (v0.22, #16 P3) the model always sees the current no-man's-land keys (empty
|
||||
// list on a fresh game) so it knows which unowned regions can be re-claimed.
|
||||
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? []);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||
|
||||
@@ -601,6 +673,7 @@ export async function runTurn(opts: {
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -124,6 +124,10 @@ app.post('/api/games', (req: Request, res) => {
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1, // the first saved revision; every later mutation bumps it
|
||||
// (v0.22, #16 P3) No man's land can only arise mid-game — a fresh game
|
||||
// starts with nothing released; this list grows as the LLM releases
|
||||
// regions and shrinks as they get re-claimed.
|
||||
unowned: [],
|
||||
};
|
||||
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||
@@ -235,7 +239,12 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
||||
preset,
|
||||
state: game,
|
||||
items: game.pending,
|
||||
regionReference: referenceForKeys(allTerritoryKeys(game)),
|
||||
// (v0.22, #16 P3) the region reference table must also cover released
|
||||
// (no-man's-land) keys — they belong to no nation's territory, but the
|
||||
// model needs their names to re-claim them.
|
||||
regionReference: referenceForKeys(
|
||||
new Set([...allTerritoryKeys(game), ...(game.unowned ?? [])]),
|
||||
),
|
||||
validRegionKeys: validKeys(),
|
||||
// v0.21 (#16 P2): when the region-adjacency manifest loaded, hand the
|
||||
// engine the resolver — the turn then runs with the LLM's getNeighbours
|
||||
|
||||
+69
-1
@@ -1,6 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildRegionMap } from './map';
|
||||
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
||||
import {
|
||||
mapFeatureOwner,
|
||||
nationColor,
|
||||
NO_MANS_LAND_FILL,
|
||||
NO_MANS_LAND_STROKE,
|
||||
OWNED_STROKE,
|
||||
regionFillStyle,
|
||||
regionOwner,
|
||||
UNOWNED_FILL,
|
||||
UNOWNED_STROKE,
|
||||
unownedTooltipLabel,
|
||||
type RegionMap,
|
||||
} from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
// Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed
|
||||
@@ -35,6 +47,7 @@ const state: GameState = {
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
};
|
||||
|
||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||
@@ -49,6 +62,16 @@ describe('buildRegionMap', () => {
|
||||
// a region nobody owns is simply absent
|
||||
expect(m['normandie-dddd']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('(v0.22 #16 P3) a released (no-man\'s-land) key — in GameState.unowned, owned by no nation — is absent: absence IS unowned', () => {
|
||||
const s: GameState = JSON.parse(JSON.stringify(state));
|
||||
// simulate a mid-game release: the key left every territory and entered unowned
|
||||
s.nations.uk.territory = s.nations.uk.territory.filter((k) => k !== 'albania-aaaa');
|
||||
s.unowned = ['albania-aaaa'];
|
||||
const m = buildRegionMap(s);
|
||||
expect(m['albania-aaaa']).toBeUndefined(); // the map cannot tell which kind of unowned
|
||||
expect(s.unowned).toEqual(['albania-aaaa']); // ...the client's GameState.unowned decides that
|
||||
});
|
||||
});
|
||||
|
||||
describe('regionOwner', () => {
|
||||
@@ -143,3 +166,48 @@ describe('nationColor', () => {
|
||||
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
// (v0.22, #16 P3) The no-man's-land presentation contract lives in
|
||||
// shared/protocol (shared/*.test.ts is not in the vitest glob, so these
|
||||
// tests live here): the distinct visible grey, the faint preset-unassigned
|
||||
// look, the per-kind borders, and the tooltip sub-line.
|
||||
describe('no man\'s land presentation contract (v0.22 #16 P3)', () => {
|
||||
it('regionFillStyle: an owned region uses its nation color + the owned border', () => {
|
||||
expect(regionFillStyle(regions['albania-aaaa'], false)).toEqual({
|
||||
fill: nationColor('uk'),
|
||||
stroke: OWNED_STROKE,
|
||||
});
|
||||
expect(regionFillStyle(regions['brandenburg-cccc'], true)).toEqual({
|
||||
fill: nationColor('de'),
|
||||
stroke: OWNED_STROKE,
|
||||
}); // the no-man's-land flag never affects an owned region
|
||||
});
|
||||
|
||||
it('regionFillStyle: an unowned region that IS no man\'s land gets the distinct visible grey', () => {
|
||||
expect(regionFillStyle(undefined, true)).toEqual({
|
||||
fill: NO_MANS_LAND_FILL,
|
||||
stroke: NO_MANS_LAND_STROKE,
|
||||
});
|
||||
});
|
||||
|
||||
it('regionFillStyle: an unowned region that was never released keeps the faint look', () => {
|
||||
expect(regionFillStyle(undefined, false)).toEqual({
|
||||
fill: UNOWNED_FILL,
|
||||
stroke: UNOWNED_STROKE,
|
||||
});
|
||||
});
|
||||
|
||||
it('the no-man\'s-land grey can never collide with a nation color (hue-less vs HSL)', () => {
|
||||
expect(NO_MANS_LAND_FILL).not.toMatch(/^hsl\(/);
|
||||
expect(NO_MANS_LAND_FILL).not.toBe(nationColor('uk'));
|
||||
expect(NO_MANS_LAND_FILL).not.toBe(nationColor('de'));
|
||||
// and it must be visibly different from the faint preset-unassigned fill
|
||||
expect(NO_MANS_LAND_FILL).not.toBe(UNOWNED_FILL);
|
||||
expect(NO_MANS_LAND_STROKE).not.toBe(UNOWNED_STROKE);
|
||||
});
|
||||
|
||||
it('unownedTooltipLabel: the tooltip sub-line names the two kinds of unowned', () => {
|
||||
expect(unownedTooltipLabel(true)).toBe("No man's land");
|
||||
expect(unownedTooltipLabel(false)).toBe('No owner');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
// color (see shared.nationColor). The client merges this onto the sub-national
|
||||
// regions.geo.json (resolving each feature by its own region key) and calls
|
||||
// source.setData(). Format-agnostic: it maps whatever keys sit in territory.
|
||||
//
|
||||
// A key ABSENT from the returned map is unowned (no nation's territory holds
|
||||
// it) — this covers both regions a preset never assigned and regions released
|
||||
// to no man's land mid-game (v0.22, #16 P3). The map deliberately does NOT
|
||||
// distinguish the two: the visible no-man's-land grey vs the faint
|
||||
// preset-unassigned look is decided by the client from `GameState.unowned`
|
||||
// (see shared/protocol.ts's regionFillStyle).
|
||||
import { nationColor, type RegionMap } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
unowned: [],
|
||||
};
|
||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||
expect(allTerritoryKeys(state).size).toBe(58);
|
||||
|
||||
@@ -24,6 +24,13 @@ export interface StubPayload {
|
||||
* reads the frontier from the prompt instead.
|
||||
*/
|
||||
frontier?: string[];
|
||||
/**
|
||||
* (v0.22, #16 P3) Region keys currently in no man's land (`state.unowned`),
|
||||
* emitted by the engine's user prompt. The stub ignores this field — it
|
||||
* never releases a region (see deterministicTurn), so no-man's-land behavior
|
||||
* is covered by unit tests against applyTurnResult, not by the stub.
|
||||
*/
|
||||
unowned?: string[];
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
@@ -45,6 +52,11 @@ function truncate(s: string, n: number): string {
|
||||
* content in one round), so the frontier comes from the prompt payload, not
|
||||
* from a getNeighbours call. With an empty frontier (no controllable border)
|
||||
* no territory change is emitted, keeping every existing assertion stable.
|
||||
*
|
||||
* (v0.22, #16 P3) The stub NEVER releases a region to no man's land (it
|
||||
* always returns `unowned: []`) — e2e assertions rely on its fully-deterministic
|
||||
* one-conquest behavior, so the release/re-claim path is exercised by unit
|
||||
* tests only (see server/engine.test.ts).
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -108,6 +120,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||
territoryChanges,
|
||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user