feat(#46 PR 2): per-region stat lever (regionChanges) + region-stat hover
CI / build + unit + e2e (pull_request) Successful in 2m31s

v0.29.0 — the LLM can now target individual regions' stats via
TurnResult.regionChanges (set/add per stat, clamped 0-100), applied
after nation-wide stateChanges and before the weighted-median
recompute. The map hover tooltip shows each region's own four stats.
The stub stays deterministic (regionChanges: []).

Fixes #46 (PR 2 of 2)
This commit is contained in:
2026-10-15 01:06:53 +00:00
parent 22805a3be6
commit cdabf16052
9 changed files with 625 additions and 32 deletions
+360 -10
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { GameState, MarkerType, Nation, NationStats, Preset, RegionStats, RoundItem } from '../shared/types';
import { makeEvent, markerId, MAX_MARKERS, seedRegionStats, type MarkerSpec, type NationOp, type TurnResult } from '../shared/protocol';
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
import { makeStubLlmClient } from './stub';
import { deterministicTurn, makeStubLlmClient } from './stub';
import {
applyNationOp,
applyTurnResult,
@@ -20,9 +20,11 @@ import {
rejectNationOp,
removeRoundItem,
resolveNationRef,
resolveRegionKey,
runTurn,
validateMarkerOps,
validateNationOps,
validateRegionChanges,
validateTurnResult,
} from './engine';
@@ -548,6 +550,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
date: '1939-09-03',
events: [{ nation: 'de', type: 'political', title: 'Reunification', text: 'The nation is renamed.', outcome: 'success' }],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -568,6 +571,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -586,6 +590,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1042,6 +1047,7 @@ describe('applyTurnResult', () => {
date: '1939-09-03',
events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'success' }],
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1058,6 +1064,7 @@ describe('applyTurnResult', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1078,6 +1085,7 @@ describe('applyTurnResult', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
// '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.
@@ -1102,6 +1110,7 @@ describe('applyTurnResult', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
// 'wales-bbbb' has a sole previous owner (uk) -> it transfers; 'POL' is
// owned by no one -> a re-claim (v0.22, #16 P3): DE gains it. It was never
@@ -1123,6 +1132,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
unowned: [],
nationOps: [], markers: { add: [], remove: [] },
@@ -1211,6 +1221,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1329,7 +1340,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], markers: { add: [], remove: [] } }),
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], markers: { add: [], remove: [] } }),
).toBe(true);
});
@@ -1636,9 +1647,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
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: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(false);
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(false);
});
it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => {
@@ -1647,6 +1658,7 @@ describe('isMeaningful', () => {
date: 'x',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1661,6 +1673,7 @@ describe('isMeaningful', () => {
date: 'x',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1688,6 +1701,7 @@ describe('markers (point placement, v0.27 #44)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1715,13 +1729,13 @@ describe('markers (point placement, v0.27 #44)', () => {
state.nations.uk.territory = ['moselle-rygy'];
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
const first = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [],
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], markers: { add, remove: [] },
}, undefined, centroidOf);
expect(first.markers).toHaveLength(1);
// a second turn proposes the exact same marker again
const second = applyTurnResult(first, {
date: '1939-09-05', events: [], stateChanges: [], relations: [], territoryChanges: [],
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], markers: { add, remove: [] },
}, undefined, centroidOf);
expect(second.markers).toHaveLength(1);
@@ -1739,6 +1753,7 @@ describe('markers (point placement, v0.27 #44)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1780,6 +1795,7 @@ describe('markers (point placement, v0.27 #44)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1808,7 +1824,7 @@ describe('markers (point placement, v0.27 #44)', () => {
{ id: idB, type: 'port', name: 'Dunkirk', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 },
];
const next = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [],
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: [], nationOps: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] },
}, undefined, centroidOf);
expect(next.markers).toHaveLength(1);
@@ -1824,13 +1840,13 @@ describe('markers (point placement, v0.27 #44)', () => {
];
// 1) uk releases the region → the marker becomes ownerless
const released = applyTurnResult(state, {
date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [],
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
unowned: ['moselle-rygy'], nationOps: [], markers: { add: [], remove: [] },
}, undefined, centroidOf);
expect(released.markers[0].ownerNationId).toBeNull();
// 2) de re-claims the region → the marker's owner follows to de
const reclaimed = applyTurnResult(released, {
date: '1939-09-05', events: [], stateChanges: [], relations: [],
date: '1939-09-05', events: [], stateChanges: [], regionChanges: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
unowned: [], nationOps: [], markers: { add: [], remove: [] },
}, undefined, centroidOf);
@@ -1975,6 +1991,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
date: '1939-09-03',
events: partial.events ?? [],
stateChanges: partial.stateChanges ?? [],
regionChanges: partial.regionChanges ?? [],
relations: partial.relations ?? [],
territoryChanges: partial.territoryChanges ?? [],
unowned: partial.unowned ?? [],
@@ -2120,3 +2137,336 @@ describe('applyTurnResult — per-region stats (v0.28, #46)', () => {
expect(JSON.stringify(state.regionStats)).toBe(beforeRegions);
});
});
// --- (v0.29, #46 PR 2) Per-region stat lever + region-stat hover ---
describe('resolveRegionKey (v0.29, #46 PR 2)', () => {
const keys = new Set(['aabb-1111', 'bccc-2222', 'cddd-3333']);
const nameOf = (k: string) =>
k === 'aabb-1111' ? 'Alpha' : k === 'bccc-2222' ? 'Beta' : k === 'cddd-3333' ? 'Gamma' : undefined;
it('an exact key match returns the key', () => {
expect(resolveRegionKey('aabb-1111', keys, nameOf)).toBe('aabb-1111');
});
it('a case-insensitive name match returns the key', () => {
expect(resolveRegionKey('alpha', keys, nameOf)).toBe('aabb-1111');
expect(resolveRegionKey('ALPHA', keys, nameOf)).toBe('aabb-1111');
expect(resolveRegionKey(' gamma ', keys, nameOf)).toBe('cddd-3333');
});
it('an unknown ref (not a key, no name match) returns null', () => {
expect(resolveRegionKey('not-a-key', keys, nameOf)).toBeNull();
});
it('an ambiguous name (two regions share it) returns null', () => {
const nameOf2 = (k: string) =>
k === 'aabb-1111' || k === 'bccc-2222' ? 'Twin' : undefined;
expect(resolveRegionKey('twin', keys, nameOf2)).toBeNull();
});
it('empty or whitespace ref returns null', () => {
expect(resolveRegionKey('', keys, nameOf)).toBeNull();
expect(resolveRegionKey(' ', keys, nameOf)).toBeNull();
});
it('a key that matches both a key and a different region\'s name prefers the key', () => {
// 'aabb-1111' is a valid key; nameOf maps 'bccc-2222' to 'Aabb-1111' (case-insensitive match)
const nameOf2 = (k: string) => (k === 'bccc-2222' ? 'Aabb-1111' : undefined);
expect(resolveRegionKey('aabb-1111', keys, nameOf2)).toBe('aabb-1111'); // key wins
});
});
describe('validateRegionChanges (v0.29, #46 PR 2)', () => {
const keys = new Set(['aabb-1111', 'bccc-2222']);
const nameOf = (k: string) =>
k === 'aabb-1111' ? 'Alpha' : k === 'bccc-2222' ? 'Beta' : undefined;
const resolver = (ref: string) => resolveRegionKey(ref, keys, nameOf);
it('a valid key ref is resolved and passed through', () => {
const out = validateRegionChanges(
[{ region: 'aabb-1111', stat: 'military', op: 'set', value: 50 }],
resolver,
);
expect(out).toEqual([{ region: 'aabb-1111', stat: 'military', op: 'set', value: 50 }]);
});
it('a case-insensitive name ref is resolved to the key', () => {
const out = validateRegionChanges(
[{ region: 'alpha', stat: 'economy', op: 'set', value: 30 }],
resolver,
);
expect(out).toEqual([{ region: 'aabb-1111', stat: 'economy', op: 'set', value: 30 }]);
});
it('an unknown ref (resolver returns null) is dropped', () => {
const out = validateRegionChanges(
[{ region: 'no-such-region', stat: 'military', op: 'set', value: 50 }],
resolver,
);
expect(out).toEqual([]);
});
it('an invalid stat is dropped', () => {
const out = validateRegionChanges(
[{ region: 'aabb-1111', stat: 'bogus', op: 'set', value: 50 }],
resolver,
);
expect(out).toEqual([]);
});
it('a non-numeric value is dropped', () => {
const out = validateRegionChanges(
[{ region: 'aabb-1111', stat: 'military', op: 'set', value: 'fifty' }],
resolver,
);
expect(out).toEqual([]);
});
it('a NaN value is dropped', () => {
const out = validateRegionChanges(
[{ region: 'aabb-1111', stat: 'military', op: 'set', value: NaN }],
resolver,
);
expect(out).toEqual([]);
});
it('an invalid op is dropped', () => {
const out = validateRegionChanges(
[{ region: 'aabb-1111', stat: 'military', op: 'multiply', value: 2 }],
resolver,
);
expect(out).toEqual([]);
});
it('a set value is clamped to 0-100 at validation', () => {
const out = validateRegionChanges(
[
{ region: 'aabb-1111', stat: 'military', op: 'set', value: 150 },
{ region: 'aabb-1111', stat: 'economy', op: 'set', value: -10 },
],
resolver,
);
expect(out).toEqual([
{ region: 'aabb-1111', stat: 'military', op: 'set', value: 100 },
{ region: 'aabb-1111', stat: 'economy', op: 'set', value: 0 },
]);
});
it('an add value is NOT clamped at validation (it is clamped at apply time)', () => {
const out = validateRegionChanges(
[{ region: 'aabb-1111', stat: 'military', op: 'add', value: 999 }],
resolver,
);
expect(out).toEqual([{ region: 'aabb-1111', stat: 'military', op: 'add', value: 999 }]);
});
it('non-array input returns []', () => {
expect(validateRegionChanges(null)).toEqual([]);
expect(validateRegionChanges('hello')).toEqual([]);
expect(validateRegionChanges(undefined)).toEqual([]);
});
it('non-object entries are skipped', () => {
const out = validateRegionChanges(
[null, 42, 'x', { region: 'aabb-1111', stat: 'military', op: 'set', value: 50 }],
resolver,
);
expect(out).toEqual([{ region: 'aabb-1111', stat: 'military', op: 'set', value: 50 }]);
});
it('without a resolver, the raw ref is kept (apply will no-op on a miss)', () => {
const out = validateRegionChanges(
[{ region: 'some-name', stat: 'military', op: 'set', value: 50 }],
);
expect(out).toEqual([{ region: 'some-name', stat: 'military', op: 'set', value: 50 }]);
});
});
describe('applyTurnResult — regionChanges (v0.29, #46 PR 2)', () => {
it('a set changes only that region\'s stats; the derived aggregate shifts', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']) },
{ a: RS(50, 50, 50, 50), b: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(
state,
mkTurnResult({ regionChanges: [{ region: 'a', stat: 'military', op: 'set', value: 80 }] }),
);
// region a got the set; region b is untouched
expect(next.regionStats.a).toEqual(RS(80, 50, 50, 50));
expect(next.regionStats.b).toEqual(RS(50, 50, 50, 50));
// derived stats are the uniform median of a and b → military median(80,50)=50
expect(next.nations.uk.stats.military).toBe(50);
});
it('an add clamps to 0 and 100 at apply time', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(90, 50, 50, 50), ['a']) },
{ a: RS(90, 50, 50, 50) },
);
const next = applyTurnResult(
state,
mkTurnResult({
regionChanges: [
{ region: 'a', stat: 'military', op: 'add', value: 999 }, // 90+999 → clamps to 100
{ region: 'a', stat: 'economy', op: 'add', value: -999 }, // 50-999 → clamps to 0
],
}),
);
expect(next.regionStats.a.military).toBe(100);
expect(next.regionStats.a.economy).toBe(0);
});
it('a region with no stats entry (unowned / no man\'s land) is a no-op', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a']) },
{ a: RS(50, 50, 50, 50) },
['ghost'], // ghost is unowned, has no entry
);
const next = applyTurnResult(
state,
mkTurnResult({ regionChanges: [{ region: 'ghost', stat: 'military', op: 'set', value: 99 }] }),
);
// ghost has no entry → no-op; a is unchanged; ghost does NOT gain an entry
expect(next.regionStats.a).toEqual(RS(50, 50, 50, 50));
expect(next.regionStats).not.toHaveProperty('ghost');
});
it('regionChanges are applied AFTER stateChanges (region-level overrides nation-level)', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a']) },
{ a: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(
state,
mkTurnResult({
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 70 }],
regionChanges: [{ region: 'a', stat: 'military', op: 'set', value: 90 }],
}),
);
// stateChanges set all of uk's regions to 70; then regionChanges overwrote a to 90
expect(next.regionStats.a.military).toBe(90);
});
it('a nation-level add and a region-level set compose correctly', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']) },
{ a: RS(50, 50, 50, 50), b: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(
state,
mkTurnResult({
stateChanges: [{ nation: 'uk', stat: 'economy', op: 'add', value: 10 }],
regionChanges: [{ region: 'a', stat: 'economy', op: 'set', value: 99 }],
}),
);
// stateChanges: both regions +10 → a=60, b=60
// regionChanges: a set to 99 → a=99, b=60
expect(next.regionStats.a.economy).toBe(99);
expect(next.regionStats.b.economy).toBe(60);
});
it('the original state is never mutated', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a']) },
{ a: RS(50, 50, 50, 50) },
);
const before = JSON.stringify(state);
applyTurnResult(
state,
mkTurnResult({ regionChanges: [{ region: 'a', stat: 'military', op: 'set', value: 99 }] }),
);
expect(JSON.stringify(state)).toBe(before);
});
});
describe('prompt contract — regionChanges (v0.29, #46 PR 2)', () => {
it('the system prompt documents the regionChanges JSON field', () => {
const p = buildSystemPrompt(preset);
expect(p).toMatch(/"regionChanges": \[\{"region":"<region key or name>"/);
});
it('the system prompt has the per-region stat change constraint bullet', () => {
const p = buildSystemPrompt(preset);
expect(p).toMatch(/Per-region stat changes \("regionChanges"\) target ONE region/);
expect(p).toMatch(/"playerRegionStats"/);
});
it('the user prompt emits playerRegionStats bounded to the player territory', () => {
const state = mkTurnState(
{
uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']),
de: mkNation('de', NS(90, 90, 90, 90), ['c']),
},
{ a: RS(50, 50, 50, 50), b: RS(60, 60, 60, 60), c: RS(90, 90, 90, 90) },
);
const p = buildUserPrompt(state, []);
const payload = JSON.parse(p);
// only the player's regions are in playerRegionStats
expect(payload.playerRegionStats).toEqual({
a: RS(50, 50, 50, 50),
b: RS(60, 60, 60, 60),
});
// de's region c is NOT in playerRegionStats
expect(payload.playerRegionStats).not.toHaveProperty('c');
});
it('playerRegionStats is {} when the player has no regions with entries', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(50, 50, 50, 50), []) },
{},
);
const p = buildUserPrompt(state, []);
const payload = JSON.parse(p);
expect(payload.playerRegionStats).toEqual({});
});
it('playerRegionStats omits a player region that has no stats entry', () => {
const state = mkTurnState(
{ uk: mkNation('uk', NS(50, 50, 50, 50), ['a', 'b']) },
{ a: RS(50, 50, 50, 50) }, // b has no entry
);
const p = buildUserPrompt(state, []);
const payload = JSON.parse(p);
expect(payload.playerRegionStats).toEqual({ a: RS(50, 50, 50, 50) });
expect(payload.playerRegionStats).not.toHaveProperty('b');
});
});
describe('isMeaningful — regionChanges (v0.29, #46 PR 2)', () => {
it('a turn with only regionChanges is meaningful', () => {
const result = mkTurnResult({
regionChanges: [{ region: 'a', stat: 'military', op: 'set', value: 90 }],
});
expect(isMeaningful(result)).toBe(true);
});
});
describe('stub — regionChanges (v0.29, #46 PR 2)', () => {
it('the deterministic stub returns regionChanges: []', () => {
const payload = {
state: {
turn: 1,
date: '1939-09-01',
playerNationId: 'uk',
nations: {
uk: { stats: { military: 50, economy: 50, stability: 50, population: 50 }, territory: ['a'] },
de: { stats: { military: 60, economy: 60, stability: 60, population: 60 }, territory: ['b'] },
},
},
pending: [{ kind: 'action' as const, text: 'test' }],
};
const result = deterministicTurn(payload);
expect(result.regionChanges).toEqual([]);
// all other required fields are present
expect(result).toHaveProperty('date');
expect(result).toHaveProperty('events');
expect(result).toHaveProperty('stateChanges');
expect(result).toHaveProperty('relations');
expect(result).toHaveProperty('territoryChanges');
expect(result).toHaveProperty('unowned');
expect(result).toHaveProperty('nationOps');
expect(result).toHaveProperty('markers');
});
});