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:
+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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user