feat(#46 PR 2): per-region stat lever (regionChanges) + region-stat hover
CI / build + unit + e2e (pull_request) Successful in 2m31s
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:
+360
-10
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+169
-14
@@ -12,6 +12,7 @@ import type {
|
||||
NationStats,
|
||||
Preset,
|
||||
RelationStatus,
|
||||
RegionStats,
|
||||
RoundItem,
|
||||
StatKey,
|
||||
} from '../shared/types';
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
type MarkerInfo,
|
||||
type MarkerSpec,
|
||||
type NationOp,
|
||||
type RegionStatChange,
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
} from '../shared/protocol';
|
||||
@@ -187,6 +189,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' {"nation":"<nationId>","type":"<military|diplomatic|economic|political|social|disaster|scientific|cultural>","title":"short headline","text":"1-3 sentences of narrative","outcome":"<success|failure|partial|neutral>"}',
|
||||
' ],',
|
||||
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||||
' "regionChanges": [{"region":"<region key or name>","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>"]}],',
|
||||
' "unowned": ["<region key>"],',
|
||||
@@ -210,6 +213,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
'- 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.',
|
||||
'- Per-region stat changes ("regionChanges") target ONE region\'s stats directly, by its region key (preferred) or name, and make that region differ from the rest of its nation (a garrisoned border province, a bombed-out city, an industrial heartland). A nation-wide "stateChanges" entry applies to ALL of a nation\'s regions; a "regionChanges" entry overrides it for that one region. The user message\'s "playerRegionStats" lists the player\'s own regions\' current stats; use region keys from the "regions" table.',
|
||||
...territoryRules,
|
||||
'- If a field is not relevant this turn, set it to an empty array.',
|
||||
].join('\n');
|
||||
@@ -263,6 +267,19 @@ export function buildUserPrompt(
|
||||
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
||||
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
||||
|
||||
// (v0.29, #46 PR 2) The player's own regions' current per-region stats, so the
|
||||
// model can reason about — and target via `regionChanges` — the player's
|
||||
// individual regions. Bounded to the player's territory (not the whole
|
||||
// world's 1,800 regions) to keep the prompt small. Always emitted: {} when
|
||||
// the player has no region with a stats entry yet.
|
||||
const playerRegionStats: Record<string, RegionStats> = {};
|
||||
if (state.regionStats) {
|
||||
for (const key of player?.territory ?? []) {
|
||||
const rs = state.regionStats[key];
|
||||
if (rs) playerRegionStats[key] = rs;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
state: {
|
||||
turn: state.turn,
|
||||
@@ -284,6 +301,7 @@ export function buildUserPrompt(
|
||||
playerActions,
|
||||
pending: items,
|
||||
roundInstruction,
|
||||
playerRegionStats,
|
||||
};
|
||||
if (regionReference) payload.regions = regionReference;
|
||||
if (frontier !== undefined) payload.frontier = frontier;
|
||||
@@ -328,8 +346,13 @@ function extractBalancedObject(text: string): string | null {
|
||||
/**
|
||||
* Parse raw LLM output into a validated TurnResult, or null if unusable. Strips
|
||||
* code fences, extracts the first balanced object, JSON.parse, then validates.
|
||||
* (v0.29, #46 PR 2) The optional `regionKeyResolver` is threaded to
|
||||
* `validateTurnResult` to resolve `regionChanges` refs to region keys.
|
||||
*/
|
||||
export function parseTurnResult(raw: string): TurnResult | null {
|
||||
export function parseTurnResult(
|
||||
raw: string,
|
||||
regionKeyResolver?: (ref: string) => string | null,
|
||||
): TurnResult | null {
|
||||
const cleaned = stripFence(raw);
|
||||
const jsonText = extractBalancedObject(cleaned);
|
||||
if (!jsonText) return null;
|
||||
@@ -339,7 +362,7 @@ export function parseTurnResult(raw: string): TurnResult | null {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return validateTurnResult(obj);
|
||||
return validateTurnResult(obj, regionKeyResolver);
|
||||
}
|
||||
|
||||
function str(s: unknown, max = 0): string | null {
|
||||
@@ -400,14 +423,23 @@ export function validateNationOps(raw: unknown): NationOp[] {
|
||||
* Validate and normalize an arbitrary parsed object into a safe TurnResult.
|
||||
* Ignores unknown/invalid entries; clamps numeric stats to 0-100. Returns null
|
||||
* only when the input is not even a plain object.
|
||||
*
|
||||
* (v0.29, #46 PR 2) The optional `regionKeyResolver` resolves each
|
||||
* `regionChanges` ref (key or name) to a concrete region key, dropping
|
||||
* unknown/ambiguous refs; without it the refs are kept as-is (shape-only), so
|
||||
* callers that build a TurnResult by hand (tests) need not supply it.
|
||||
*/
|
||||
export function validateTurnResult(input: unknown): TurnResult | null {
|
||||
export function validateTurnResult(
|
||||
input: unknown,
|
||||
regionKeyResolver?: (ref: string) => string | null,
|
||||
): TurnResult | null {
|
||||
if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
|
||||
const o = input as Record<string, unknown>;
|
||||
const result: TurnResult = {
|
||||
date: typeof o.date === 'string' ? o.date : '',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
@@ -507,6 +539,12 @@ export function validateTurnResult(input: unknown): TurnResult | null {
|
||||
// moves (a new marker's owner is the region's post-turn owner).
|
||||
result.markers = validateMarkerOps(o.markers);
|
||||
|
||||
// (v0.29, #46 PR 2) Per-region stat changes: each targets a single region's
|
||||
// stats (by key or name). Resolved to a key here when a resolver is
|
||||
// supplied; applied by applyTurnResult after the nation-wide stateChanges
|
||||
// and before the derived-stat recompute.
|
||||
result.regionChanges = validateRegionChanges(o.regionChanges, regionKeyResolver);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -547,6 +585,61 @@ export function validateMarkerOps(raw: unknown): { add: MarkerSpec[]; remove: st
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.29, #46 PR 2) Validate the raw `regionChanges` array of a turn into a
|
||||
* safe {@link RegionStatChange} list — the fine-grained, per-region stat lever
|
||||
* that complements the nation-wide `stateChanges`. Defensive like the rest of
|
||||
* the parser — never throws. Each entry needs a non-empty `region` (the
|
||||
* region's stable key, preferred, or its human-readable name — capped at 120
|
||||
* chars), a known stat, a finite numeric `value`, and an op of `set` or `add`.
|
||||
*
|
||||
* When `regionKeyResolver` is provided, each `region` ref is resolved to a
|
||||
* concrete region key (key first, then a unique case-insensitive name match —
|
||||
* see {@link resolveRegionKey}) and the resolved key is stored; a ref that
|
||||
* resolves to `null` (unknown or ambiguous) is dropped, exactly like an
|
||||
* unresolvable nation ref. When the resolver is absent (pure unit tests), the
|
||||
* ref is kept as-is (shape-only validation) — a name that is not a key simply
|
||||
* no-ops later in `applyTurnResult` (its `regionStats` lookup misses). `set`
|
||||
* values are clamped to 0–100 here; `add` deltas are clamped against the
|
||||
* current value at apply time (they may legitimately exceed 100 in the raw
|
||||
* input, since they are added to an existing 0–100 value).
|
||||
*/
|
||||
export function validateRegionChanges(
|
||||
raw: unknown,
|
||||
regionKeyResolver?: (ref: string) => string | null,
|
||||
): RegionStatChange[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: RegionStatChange[] = [];
|
||||
for (const e of raw) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const c = e as Record<string, unknown>;
|
||||
const region = str(c.region, 120);
|
||||
const stat = str(c.stat);
|
||||
if (!region || !stat || !VALID_STATS.has(stat)) continue;
|
||||
if (typeof c.value !== 'number' || !Number.isFinite(c.value)) continue;
|
||||
if (c.op !== 'set' && c.op !== 'add') continue;
|
||||
// Resolve the ref to a key when a resolver is available; drop it when the
|
||||
// resolver says the ref is unknown or ambiguous (never throw). Without a
|
||||
// resolver the ref is kept as-is (a name that is not a key no-ops later in
|
||||
// applyTurnResult, whose regionStats lookup simply misses it).
|
||||
let finalRegion: string;
|
||||
if (regionKeyResolver) {
|
||||
const resolved = regionKeyResolver(region);
|
||||
if (resolved === null) continue; // unknown or ambiguous → drop
|
||||
finalRegion = resolved;
|
||||
} else {
|
||||
finalRegion = region;
|
||||
}
|
||||
out.push({
|
||||
region: finalRegion,
|
||||
stat: stat as StatKey,
|
||||
op: c.op,
|
||||
value: c.op === 'set' ? clampStat(c.value) : c.value,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
||||
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||
@@ -559,6 +652,8 @@ export function isMeaningful(result: TurnResult): boolean {
|
||||
return (
|
||||
result.events.length > 0 ||
|
||||
result.stateChanges.length > 0 ||
|
||||
// (v0.29, #46 PR 2) a per-region stat change is a real world change.
|
||||
result.regionChanges.length > 0 ||
|
||||
result.unowned.length > 0 ||
|
||||
result.nationOps.length > 0 ||
|
||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||
@@ -761,6 +856,18 @@ export function applyTurnResult(
|
||||
}
|
||||
n.stats[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(n.stats[c.stat] + c.value);
|
||||
}
|
||||
// (v0.29, #46 PR 2) Fine-grained, per-region stat changes: each targets ONE
|
||||
// region's own stats entry (by its resolved region key). Applied AFTER the
|
||||
// nation-wide stateChanges (so a region-level change overrides the
|
||||
// nation-level one for that single region) and BEFORE the weighted-median
|
||||
// recompute below, which propagates every region's final value up into its
|
||||
// owning nation's derived `stats`. A region with no stats entry (unowned /
|
||||
// no man's land) is a no-op — there is nothing to change.
|
||||
for (const c of result.regionChanges) {
|
||||
const rs = next.regionStats[c.region];
|
||||
if (!rs) continue; // no entry (unowned) → no-op
|
||||
rs[c.stat] = c.op === 'set' ? clampStat(c.value) : clampStat(rs[c.stat] + c.value);
|
||||
}
|
||||
for (const n of Object.values(next.nations)) {
|
||||
if (n.territory.length === 0) continue; // 0-region guard: no regions to aggregate
|
||||
if (!n.territory.some((k) => next.regionStats[k])) continue; // no entry: keep n.stats
|
||||
@@ -908,6 +1015,37 @@ export function resolveNationRef(state: GameState, ref: string): string | null {
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.29, #46 PR 2) Resolve a **region** reference (a `regionChanges` entry's
|
||||
* `region` — the LLM may write either the region's stable **key** or its
|
||||
* human-readable **name**) to a concrete region key: an exact key match wins,
|
||||
* then a unique case-insensitive name match (via `nameOf`). Returns `null`
|
||||
* when the reference is empty, unknown, or ambiguous (two or more regions
|
||||
* share that name) — every caller treats `null` as "drop this change" and
|
||||
* nothing here ever throws. Mirrors {@link resolveNationRef}. `validKeys` is
|
||||
* the set of all region keys and `nameOf` maps a key to its human name (both
|
||||
* supplied by server/regions.ts).
|
||||
*/
|
||||
export function resolveRegionKey(
|
||||
ref: string,
|
||||
validKeys: ReadonlySet<string>,
|
||||
nameOf: (key: string) => string | undefined,
|
||||
): string | null {
|
||||
const r = (ref ?? '').trim();
|
||||
if (!r) return null;
|
||||
if (validKeys.has(r)) return r; // exact key first
|
||||
const lower = r.toLowerCase();
|
||||
let match: string | null = null;
|
||||
for (const k of validKeys) {
|
||||
const name = nameOf(k);
|
||||
if (name && name.trim().toLowerCase() === lower) {
|
||||
if (match !== null) return null; // ambiguous: two or more regions share the name
|
||||
match = k;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh, unused nation id for a newly created nation (split
|
||||
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
|
||||
@@ -1177,8 +1315,18 @@ export async function runTurn(opts: {
|
||||
* still region-consistent, just unweighted.
|
||||
*/
|
||||
geoOf?: (key: string) => { area: number; population: number } | undefined;
|
||||
/**
|
||||
* (v0.29, #46 PR 2) Region ref resolver: resolves a `regionChanges` entry's
|
||||
* `region` (a region key, preferred, or a human-readable name) to a concrete
|
||||
* region key, or `null` when the ref is unknown or ambiguous (two or more
|
||||
* regions share the name) — the engine drops such entries. Present, the
|
||||
* model may name a region by its name and it is resolved before apply;
|
||||
* absent, refs are kept as-is (a name that is not a key simply no-ops in
|
||||
* applyTurnResult's regionStats lookup).
|
||||
*/
|
||||
regionKeyResolver?: (ref: string) => string | null;
|
||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf } = opts;
|
||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver } = opts;
|
||||
const chatWithTools = llm.chatWithTools;
|
||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||
const system = buildSystemPrompt(preset, useTools);
|
||||
@@ -1216,18 +1364,24 @@ export async function runTurn(opts: {
|
||||
return llm.chat(history as ChatMessage[], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS });
|
||||
};
|
||||
|
||||
let result = parseTurnResult(await call([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
]));
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
result = parseTurnResult(await call([
|
||||
let result = parseTurnResult(
|
||||
await call([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
]));
|
||||
]),
|
||||
regionKeyResolver,
|
||||
);
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
result = parseTurnResult(
|
||||
await call([
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'user', content: nudge },
|
||||
]),
|
||||
regionKeyResolver,
|
||||
);
|
||||
}
|
||||
|
||||
if (result === null || !isMeaningful(result)) {
|
||||
@@ -1235,6 +1389,7 @@ export async function runTurn(opts: {
|
||||
date: addDays(state.date, 2),
|
||||
events: [genericUnclearEvent(state)],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
|
||||
+9
-2
@@ -20,9 +20,9 @@ import type {
|
||||
import type { GameState } from '../shared/types';
|
||||
import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
|
||||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
|
||||
import { buildRegionMap } from './map';
|
||||
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
|
||||
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validKeys } from './regions';
|
||||
import { createPresetStore } from './presets';
|
||||
import { openDb, type Db } from './db';
|
||||
|
||||
@@ -307,6 +307,13 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
||||
// owned region by area×population (without it the aggregate degrades to
|
||||
// an unweighted median — still region-consistent, just unweighted).
|
||||
geoOf: hasGeo() ? (key) => geoOf(key) : undefined,
|
||||
// (v0.29, #46 PR 2) region ref resolver: lets the model name a
|
||||
// regionChanges target by its human-readable name (resolved
|
||||
// case-insensitively against the region-key manifest) or its key;
|
||||
// unknown / ambiguous refs are dropped. Always on — the resolver is
|
||||
// pure (validKeys + regionName) and degrades to a key-only match when
|
||||
// the manifest is absent.
|
||||
regionKeyResolver: (ref) => resolveRegionKey(ref, validKeys(), (k) => regionName(k)),
|
||||
});
|
||||
db.save(state);
|
||||
broadcast(id, 'turn', { state, result });
|
||||
|
||||
@@ -72,6 +72,11 @@ function truncate(s: string, n: number): string {
|
||||
* (v0.27, #44) The stub likewise NEVER places or removes a map marker (it
|
||||
* always returns `markers: { add: [], remove: [] }`) — the marker path is
|
||||
* covered by unit tests only, keeping the e2e assertions untouched.
|
||||
*
|
||||
* (v0.29, #46 PR 2) The stub likewise NEVER changes per-region stats (it
|
||||
* always returns `regionChanges: []`) — the per-region lever is covered by
|
||||
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
|
||||
* untouched.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -133,6 +138,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
date: addDays(state?.date ?? '1939-09-01', 2),
|
||||
events,
|
||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||
regionChanges: [], // the stub never changes per-region stats (see doc comment)
|
||||
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