Compare commits

...
Author SHA1 Message Date
kamilkrzywda a93ece9474 feat(#50): make the four 0-100 stats consequential
CI / build + unit + e2e (pull_request) Successful in 2m8s
- Add a hard military conquest gate (canConquer, CONQUEST_MILITARY_MARGIN=15)
  enforced in applyTurnResult: a contested invasion is repelled when the
  attacker's military is more than 15 below the defender's; re-claims of
  unowned regions are never gated. Emits a new 'repelled' event.
- Document the soft levers in the system prompt: stability<30 + gathering
  marker -> non-military takeover; economy growth/decay drives other stats.
- Emit bounded frontierMilitary (per-neighbour balance) and takeoverRisks
  hints in the user payload (only when present).
- repelled outcome color (#9f1239); full unit test coverage; stub untouched.
- Bump to v0.30.0.

Fixes #50
2026-10-15 08:24:03 +00:00
kamilkrzywda 44e54470e2 Merge pull request 'v0.29.0: Per-region stat lever (regionChanges) + region-stat hover' (#49) from feat/46-region-stats into master
CI / build + unit + e2e (push) Successful in 2m5s
2026-10-15 03:10:07 +02:00
kamilkrzywda cdabf16052 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)
2026-10-15 01:06:53 +00:00
12 changed files with 1041 additions and 35 deletions
+30
View File
@@ -2,6 +2,36 @@
All notable changes to **History** are listed here, newest first.
## v0.30.0 — 2026-10-15 08:22
- **#50: make the four 0–100 stats consequential — a hard military conquest gate, soft stability takeovers, and economy growth/decay.** The four stats now actually drive outcomes instead of being decorative. **Military is a hard gate:** a contested invasion (a region's sole owner changing hands) is repelled whenever the attacker's military is **more than `CONQUEST_MILITARY_MARGIN = 15`** below the defender's — equal or within 15 still wins. The engine enforces this in `applyTurnResult` and emits a new `repelled` event when it fires; **re-claims of unowned (no man's land) regions are not gated** (there is no defender). **Stability is a soft lever:** the system prompt documents that a nation whose stability is below 30 *and* that hosts a `gathering` marker can suffer a **non-military takeover** (coup / revolution / faction seizure) — narrated by the LLM with a political/social success event, a gathering marker, and a downward stability/economy shock. **Economy is a soft lever:** a strong (rising) economy trends the other stats up (growth) and a collapsing economy drags them down (decay), reflected via `stateChanges` / `regionChanges`. A **frontier military-balance hint** and a **takeover-risks** hint are emitted in the user payload (each only when present) so the model — and the player, via the same data — sees the levers before acting. The stub stays 100% deterministic and untouched; e2e is unaffected (the UK/wwii-1939 e2e has an empty frontier, so the gate is vacuous there).
- **Protocol (`shared/protocol.ts`).** New `CONQUEST_MILITARY_MARGIN = 15` and a pure `canConquer(attackerMil, defenderMil, margin = CONQUEST_MILITARY_MARGIN)` — `!(attackerMil < defenderMil - margin)` (proceeds unless the attacker is strictly more than the margin below the defender).
- **Types (`shared/types.ts`).** `EventOutcome` gains `'repelled'` (engine-only; the LLM never produces it).
- **Colors (`src/lib/colors.ts`).** `OUTCOME_COLORS.repelled = '#9f1239'` — a muted rose-800, distinct from `failure` (`#ef4444`) and `neutral` (`#94a3b8`).
- **Engine (`server/engine.ts`).**
- `applyTurnResult` gains an optional `regionNameOf` (5th optional param, after `geoOf`) and the **military hard gate** inside the snapshot-first claims loop: for each contested key (a gainer's `gain` whose current sole owner is a *different* nation), it compares the gainer's start-of-turn military to the owner's via `canConquer`; on a fail it keeps the key with the owner, leaves both nations' military untouched, and pushes one `repelled` military event (`"<Attacker>'s invasion of <region name> was repelled by <Defender>."`, using the region's geo display name when known). Re-claims (`owner === undefined`) bypass the gate.
- `buildSystemPrompt` gains a **"HOW THE FOUR 0–100 STATS DRIVE OUTCOMES"** section: the enforced military gate (incl. the no-man's-land carve-out), the soft stability takeover, and the economy growth/decay lever — present in both tool and plain modes.
- `buildUserPrompt` computes and emits two new, bounded payload fields (each only when non-empty, the rest of the payload untouched): `frontierMilitary` (one line per frontier neighbour: `[Your military: X] vs [Name: Y] — a direct invasion could succeed | would be repelled`, decided by `canConquer`) and `takeoverRisks` (one line per nation with stability < 30 *and* a `gathering` marker: `Name (stability N, a gathering is in progress)`).
- `runTurn` threads `regionNameOf` through to `applyTurnResult`.
- **Server route (`server/index.ts`).** The `/jump` route wires `regionNameOf: (k) => regionName(k)` so repelled invasions name the region properly.
- **Stub (`server/stub.ts`).** Unchanged (100%) — it never releases, never proposes a nation op, and its one-conquest behavior is what e2e relies on; the new gate / takeover / economy paths are covered by unit tests only.
- **Unit tests.** `server/protocol.test.ts`: `canConquer` edges (equal, within margin, over margin, gap == margin, gap == margin + 1, margin = 0). `server/engine.test.ts`: the hard gate (proceed within margin / proceed when stronger / **repel when over margin** with a correct `repelled` event + region display name + attacker untouched / **re-claim ungated** / boundary at exactly the margin and one past it); the system prompt documents all three mechanics in both modes; the user prompt emits `frontierMilitary` (could-succeed vs repelled per neighbour) and `takeoverRisks` (and omits both fields when no frontier / no markers).
## v0.29.0 — 2026-10-15 01:05
- **#46 (PR 2 of 2): per-region stat lever — the LLM can now target individual regions' stats, and the map hover shows them.** The LLM gains a fine-grained `TurnResult.regionChanges` field (alongside the existing nation-wide `stateChanges`): each entry targets **one** region's own stats by its region key (preferred) or case-insensitive name. A `set` replaces the value; an `add` increments it (both clamped 0–100 at apply time). Region-level changes are applied **after** nation-wide `stateChanges` (so they override the nation-level value for that one region) and **before** the weighted-median recompute, which propagates every region's final value into its owning nation's derived `stats`. A region with no stats entry (unowned / no man's land) is a no-op. The map hover tooltip now shows each region's own four stats (military / economy / stability / population) alongside the region name and owner chip; the `NationPanel` keeps showing the nation-level derived aggregate. The stub stays fully deterministic (`regionChanges: []`) — e2e assertions are untouched.
- **Protocol (`shared/protocol.ts`).** New `RegionStatChange = { region: string; stat: StatKey; op: 'set'|'add'; value: number }`. `TurnResult.regionChanges: RegionStatChange[]` is **required** (default `[]`); sits right after `stateChanges`.
- **Engine (`server/engine.ts`).**
- `resolveRegionKey(ref, validKeys, nameOf)` — pure resolver: exact key first; then a case-insensitive unique-name match; ambiguous / unknown / empty → `null`.
- `validateRegionChanges(raw, regionKeyResolver?)` — validates and resolves each entry; drops unknown / ambiguous refs (never throws); clamps `set` values at validation; `add` values are clamped at apply time.
- `parseTurnResult` / `validateTurnResult` / `runTurn` all accept an **optional** `regionKeyResolver` (2nd arg / opts) threaded to `validateRegionChanges`; the generic "unclear" fallback carries `regionChanges: []`.
- `applyTurnResult` applies `regionChanges` after `stateChanges` and before the weighted-median recompute (the load-bearing order is: events → relations → territory → stateChanges → **regionChanges** → recompute → markers → nationOps).
- `isMeaningful` — a turn with only `regionChanges` is meaningful (survives the retry/nudge path).
- `buildSystemPrompt` — documents the `regionChanges` JSON field and a constraint bullet (target one region, use keys from the "regions" table, "playerRegionStats" lists the player's current per-region stats).
- `buildUserPrompt` — computes and emits `playerRegionStats` (the player's owned regions' current stats; bounded to the player's territory to keep the prompt small; `{}` when none).
- **Server route (`server/index.ts`).** The `/jump` route wires the `regionKeyResolver` (always on; degrades to key-only matching when the adjacency manifest is absent).
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `regionChanges: []` — the per-region lever is covered by unit tests only, keeping the e2e assertions untouched.
- **Client (`src/components/WorldMap.tsx`).** The hover tooltip gains a `regionStats` prop and shows the region's own four stats (`Mil / Eco / Stab / Pop`); a region with no entry shows a `–` placeholder. The name and owner chip are kept; marker rendering and the click→dossier path are unchanged. `src/App.tsx` passes `game.regionStats ?? {}`.
- **Unit tests (`server/engine.test.ts`).** New suites: `resolveRegionKey` (key match, case-insensitive name match, unknown / ambiguous → null, key-over-name priority); `validateRegionChanges` (key / name resolution, unknown / invalid stat / non-numeric / invalid op dropped, `set` clamped, `add` not clamped at validation, non-array → [], no-resolver path); `applyTurnResult` with `regionChanges` (set changes only that region, add clamps 0/100, unowned no-op, applied after stateChanges, non-mutation); prompt contract (system prompt documents the field + constraint, user prompt emits bounded `playerRegionStats`); `isMeaningful` with `regionChanges`; stub returns `regionChanges: []`.
## v0.28.0 — 2026-10-15 00:12
- **#46 (PR 1 of 2): per-region stats — a nation's four 0–100 stats become a *derived* aggregate.** Instead of storing `Nation.stats` as four independent per-nation numbers, they are now **derived** as the **area×population-weighted median** of the stats of the regions the nation owns. Each sub-region carries its own `RegionStats` in a new `GameState.regionStats: Record<regionKey, RegionStats>` table; a nation's displayed stats are the weighted median over its owned regions. This is the data foundation for the per-region LLM levers that land in **PR 2** — in this PR the LLM still only emits nation-level `stateChanges`, which are re-interpreted as applying to *all* of a nation's owned regions. A new `public/region-geo.json` manifest (area km² + population per region key) supplies the weighting. **Not deployed in this PR** — the production site updates when PR 2 ships (the change is additive and backward-compatible, so deferring the deploy is safe).
- **Data build (`scripts/`, turf-`.mjs` only; never imported by `.ts`).**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.28.0",
"version": "0.30.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+561 -11
View File
@@ -1,8 +1,18 @@
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 {
CONQUEST_MILITARY_MARGIN,
makeEvent,
markerId,
MAX_MARKERS,
seedRegionStats,
type MarkerInfo,
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 +30,11 @@ import {
rejectNationOp,
removeRoundItem,
resolveNationRef,
resolveRegionKey,
runTurn,
validateMarkerOps,
validateNationOps,
validateRegionChanges,
validateTurnResult,
} from './engine';
@@ -548,6 +560,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 +581,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -586,6 +600,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1042,6 +1057,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 +1074,7 @@ describe('applyTurnResult', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1078,6 +1095,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 +1120,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 +1142,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 +1231,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1329,7 +1350,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 +1657,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 +1668,7 @@ describe('isMeaningful', () => {
date: 'x',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1661,6 +1683,7 @@ describe('isMeaningful', () => {
date: 'x',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1688,6 +1711,7 @@ describe('markers (point placement, v0.27 #44)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1715,13 +1739,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 +1763,7 @@ describe('markers (point placement, v0.27 #44)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1780,6 +1805,7 @@ describe('markers (point placement, v0.27 #44)', () => {
date: '1939-09-03',
events: [],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1808,7 +1834,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 +1850,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 +2001,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 +2147,526 @@ 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');
});
});
describe('applyTurnResult — military hard gate (v0.30 #50)', () => {
it('an invasion whose attacker is MORE than the margin below the defender is REPELLED (key stays, a repelled event is emitted)', () => {
// uk owns 'a' (military 80). de (military 50) tries to gain 'a'.
// gap = 80 - 50 = 30 > 15 → repelled.
const state = mkTurnState(
{
uk: mkNation('uk', NS(80, 70, 60, 50), ['a']),
de: mkNation('de', NS(50, 50, 50, 50), ['b']),
},
{ a: RS(80, 70, 60, 50), b: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(
state,
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
undefined,
undefined,
undefined,
(k) => (k === 'a' ? 'Region A' : undefined),
);
// the key stays with uk; de gains nothing
expect(next.nations.uk.territory).toEqual(['a']);
expect(next.nations.de.territory).toEqual(['b']);
// exactly one repelled event, concerning the attacker (de), military type
expect(next.events).toHaveLength(1);
expect(next.events[0]).toMatchObject({
nation: 'de',
type: 'military',
outcome: 'repelled',
title: 'Invasion repelled',
});
// the text names the attacker, the defender, and the region's display name
expect(next.events[0].text).toBe("DE's invasion of Region A was repelled by UK.");
// the attacker's military is NOT weakened by the failed attack
expect(next.nations.de.stats.military).toBe(50);
});
it('an invasion within the margin (gap <= 15) PROCEEDS (no repelled event)', () => {
// uk owns 'a' (military 80). de (military 66) tries to gain 'a'. gap = 14.
const state = mkTurnState(
{
uk: mkNation('uk', NS(80, 70, 60, 50), ['a']),
de: mkNation('de', NS(66, 50, 50, 50), ['b']),
},
{ a: RS(80, 70, 60, 50), b: RS(66, 50, 50, 50) },
);
const next = applyTurnResult(
state,
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
);
expect(next.nations.de.territory).toContain('a');
expect(next.nations.uk.territory).not.toContain('a');
expect(next.events).toHaveLength(0);
});
it('a STRONGER attacker (negative gap) proceeds', () => {
// uk owns 'a' (military 50). de (military 80) gains 'a'.
const state = mkTurnState(
{
uk: mkNation('uk', NS(50, 70, 60, 50), ['a']),
de: mkNation('de', NS(80, 50, 50, 50), ['b']),
},
{ a: RS(50, 70, 60, 50), b: RS(80, 50, 50, 50) },
);
const next = applyTurnResult(
state,
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
);
expect(next.nations.de.territory).toContain('a');
expect(next.events).toHaveLength(0);
});
it('a re-claim of an UNOWNED key is NOT gated (it always proceeds)', () => {
// 'a' is in no man's land. de (military 10) re-claims it even though uk
// (military 80) is far stronger — there is no defender, so the gate is bypassed.
const state = mkTurnState(
{
uk: mkNation('uk', NS(80, 70, 60, 50), ['b']),
de: mkNation('de', NS(10, 50, 50, 50), ['c']),
},
{ b: RS(80, 70, 60, 50), c: RS(10, 50, 50, 50) },
['a'],
);
const next = applyTurnResult(
state,
mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }),
);
expect(next.nations.de.territory).toContain('a');
expect(next.unowned).toEqual([]); // 'a' leaves no man's land
expect(next.events).toHaveLength(0);
});
it('boundary: gap exactly CONQUEST_MILITARY_MARGIN proceeds; one more point is repelled', () => {
expect(CONQUEST_MILITARY_MARGIN).toBe(15);
// uk 65 vs de 50 → gap 15 (== margin) → proceeds
{
const state = mkTurnState(
{ uk: mkNation('uk', NS(65, 70, 60, 50), ['a']), de: mkNation('de', NS(50, 50, 50, 50), ['b']) },
{ a: RS(65, 70, 60, 50), b: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }));
expect(next.nations.de.territory).toContain('a');
expect(next.events).toHaveLength(0);
}
// uk 66 vs de 50 → gap 16 (margin + 1) → repelled
{
const state = mkTurnState(
{ uk: mkNation('uk', NS(66, 70, 60, 50), ['a']), de: mkNation('de', NS(50, 50, 50, 50), ['b']) },
{ a: RS(66, 70, 60, 50), b: RS(50, 50, 50, 50) },
);
const next = applyTurnResult(state, mkTurnResult({ territoryChanges: [{ nation: 'de', gain: ['a'], lose: [] }] }));
expect(next.nations.uk.territory).toContain('a');
expect(next.events.some((e) => e.outcome === 'repelled')).toBe(true);
}
});
});
describe('buildSystemPrompt — stats mechanics (v0.30 #50)', () => {
it('both tool and plain modes document the hard gate, the stability takeover, and economy growth/decay', () => {
for (const useTools of [true, false]) {
const prompt = buildSystemPrompt(preset, useTools);
// section header + the military hard gate
expect(prompt).toMatch(/HOW THE FOUR 0-100 STATS DRIVE OUTCOMES/);
expect(prompt).toMatch(/MILITARY/);
expect(prompt).toMatch(/the engine ENFORCES/);
expect(prompt).toMatch(/MORE than 15 below the defender is REPELLED/);
expect(prompt).toMatch(/no man's land/);
// the soft stability takeover
expect(prompt).toMatch(/STABILITY/);
expect(prompt).toMatch(/stability is below 30/);
expect(prompt).toMatch(/gathering/);
expect(prompt).toMatch(/NON-military takeover/i);
// the economy growth / decay lever
expect(prompt).toMatch(/ECONOMY/);
expect(prompt).toMatch(/trend UP/);
expect(prompt).toMatch(/DECAY/);
}
});
});
describe('buildUserPrompt — frontier balance + takeover risks (v0.30 #50)', () => {
const pending: RoundItem[] = [{ kind: 'action', text: 'x' }];
it('emits a military-balance hint per frontier neighbour (repelled vs could succeed)', () => {
// player uk (military 50) borders de (60, within 15 → could succeed) and
// us (90, 40 above → repelled).
const state = mkTurnState(
{
uk: mkNation('uk', NS(50, 70, 60, 50), ['a']),
de: mkNation('de', NS(60, 70, 60, 50), ['b']),
us: mkNation('us', NS(90, 70, 60, 50), ['c']),
},
{ a: RS(50, 70, 60, 50), b: RS(60, 70, 60, 50), c: RS(90, 70, 60, 50) },
);
const prompt = buildUserPrompt(state, pending, undefined, ['b', 'c']);
expect(prompt).toMatch(/\[Your military: 50\] vs \[DE: 60\] — a direct invasion could succeed/);
expect(prompt).toMatch(/\[Your military: 50\] vs \[US: 90\] — a direct invasion would be repelled/);
});
it('does NOT emit the frontier military balance when no frontier is supplied', () => {
const state = mkTurnState({ uk: mkNation('uk', NS(50, 70, 60, 50), ['a']) }, { a: RS(50, 70, 60, 50) });
const prompt = buildUserPrompt(state, pending);
expect(prompt).not.toMatch(/frontierMilitary/);
});
it('emits takeover risks for low-stability nations that host a gathering (and not for others)', () => {
// de has stability 20 + a gathering marker → at risk. uk has stability 50 → not.
const markers: MarkerInfo[] = [
{ id: 'g1', type: 'gathering', name: 'Revolt', regionKey: 'b', owner: 'de' },
{ id: 'g2', type: 'gathering', name: 'Rally', regionKey: 'a', owner: 'uk' },
];
const state = mkTurnState(
{
uk: mkNation('uk', NS(50, 70, 50, 50), ['a']),
de: mkNation('de', NS(50, 70, 20, 50), ['b']),
},
{ a: RS(50, 70, 50, 50), b: RS(50, 70, 20, 50) },
);
const prompt = buildUserPrompt(state, pending, undefined, undefined, undefined, markers);
expect(prompt).toMatch(/takeoverRisks/);
expect(prompt).toMatch(/DE \(stability 20, a gathering is in progress\)/);
expect(prompt).not.toMatch(/UK \(stability/);
});
it('does NOT emit takeover risks when no markers exist', () => {
const state = mkTurnState({ uk: mkNation('uk', NS(50, 70, 20, 50), ['a']) }, { a: RS(50, 70, 20, 50) });
const prompt = buildUserPrompt(state, pending);
expect(prompt).not.toMatch(/takeoverRisks/);
});
});
+279 -15
View File
@@ -12,12 +12,14 @@ import type {
NationStats,
Preset,
RelationStatus,
RegionStats,
RoundItem,
StatKey,
} from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
import {
addDays,
canConquer,
clampStat,
deriveNationStats,
fnv1a32,
@@ -30,6 +32,7 @@ import {
type MarkerInfo,
type MarkerSpec,
type NationOp,
type RegionStatChange,
type TurnEventSpec,
type TurnResult,
} from '../shared/protocol';
@@ -187,6 +190,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>"],',
@@ -198,6 +202,11 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
'HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)',
'- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender\'s military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man\'s land) region is never blocked this way.',
'- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.',
'- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation\'s other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.',
'',
'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 and unowned.',
@@ -210,6 +219,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 +273,76 @@ 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;
}
}
// (v0.30, #50) A per-neighbor military-balance hint for the player's frontier,
// so the model — and, via the same data, the player — sees the hard gate
// before it fires: for each frontier neighbor, compare the player's derived
// military to the neighbor's and state whether a direct invasion would be
// repelled (via canConquer). Computed here (pure) from `state` + `frontier`;
// emitted only when a non-empty frontier is present, and the rest of the
// payload is left untouched.
let frontierMilitary: string[] | undefined;
if (frontier !== undefined && frontier.length > 0) {
const playerMil = player?.stats.military ?? 0;
const ownerByKey = new Map<string, string>(); // region key -> owning nation id
for (const n of Object.values(state.nations)) {
for (const k of n.territory) ownerByKey.set(k, n.id);
}
const byNeighbor = new Map<string, number>(); // neighbor id -> its military (deduped)
for (const k of frontier) {
const ownerId = ownerByKey.get(k);
if (!ownerId || ownerId === state.playerNationId) continue; // unowned / self: skip
const owner = state.nations[ownerId];
if (!owner) continue;
if (!byNeighbor.has(ownerId)) byNeighbor.set(ownerId, owner.stats.military);
}
if (byNeighbor.size > 0) {
frontierMilitary = [...byNeighbor.entries()].map(([ownerId, mil]) => {
const o = state.nations[ownerId];
const wins = canConquer(playerMil, mil);
return `[Your military: ${playerMil}] vs [${o?.name ?? ownerId}: ${mil}] — ${
wins ? 'a direct invasion could succeed' : 'a direct invasion would be repelled'
}`;
});
}
}
// (v0.30, #50) Bounded soft-lever hint: nations whose stability is below 30
// AND that currently have a "gathering" marker on one of their regions are
// the ones ripe for a NON-military takeover (a coup / revolution / faction
// seizure) — the model can act on them this turn. One line per matching
// nation, emitted only when there is at least one; the rest of the payload
// is left untouched. A soft nudge only — there is no hard gate behind it.
let takeoverRisks: string[] | undefined;
if (markers !== undefined && markers.length > 0) {
const risks = new Set<string>();
for (const m of markers) {
if (m.type !== 'gathering') continue;
const ownerId = m.owner;
if (!ownerId) continue;
const n = state.nations[ownerId];
if (n && n.stats.stability < 30) risks.add(n.id);
}
if (risks.size > 0) {
takeoverRisks = [...risks].map((id) => {
const n = state.nations[id];
return `${n?.name ?? id} (stability ${n?.stats.stability}, a gathering is in progress)`;
});
}
}
const payload: Record<string, unknown> = {
state: {
turn: state.turn,
@@ -284,9 +364,12 @@ export function buildUserPrompt(
playerActions,
pending: items,
roundInstruction,
playerRegionStats,
};
if (regionReference) payload.regions = regionReference;
if (frontier !== undefined) payload.frontier = frontier;
if (frontierMilitary !== undefined) payload.frontierMilitary = frontierMilitary;
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
if (noMansLand !== undefined) payload.unowned = noMansLand;
if (markers !== undefined) payload.markers = markers;
return JSON.stringify(payload, null, 2);
@@ -328,8 +411,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 +427,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 +488,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 +604,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 +650,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 +717,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.
@@ -600,6 +760,11 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
* area×population-weighted median of its regions' stats. Without it the
* recompute degrades to an unweighted median (every region weight 1) — still
* region-consistent, just unweighted. The engine stays pure either way.
*
* `regionNameOf` (v0.30, #50) resolves a region key to its human display name
* (from the geo `properties.name`, via server/regions.ts) for the text of the
* engine-emitted "invasion repelled" event; without it the event falls back to
* the raw key. Pure either way.
*/
export function applyTurnResult(
state: GameState,
@@ -607,6 +772,7 @@ export function applyTurnResult(
validKeys?: ReadonlySet<string>,
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
regionNameOf?: (key: string) => string | undefined,
): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
@@ -714,6 +880,34 @@ export function applyTurnResult(
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
// (v0.30, #50) MILITARY HARD GATE: a CONTESTED conquest (the key has a
// different sole owner) only proceeds when the attacker's derived military
// is not MORE than CONQUEST_MILITARY_MARGIN below the defender's. A
// re-claim of an unowned key (owner === undefined) is never gated. On
// repel, the key stays with its owner, a "repelled" event is emitted (the
// engine's own narrative — the model never sees it as an input), and the
// gainer is untouched; the engine never throws.
if (
owner !== undefined &&
!canConquer(next.nations[gainer].stats.military, next.nations[owner].stats.military)
) {
next.events.push(
makeEvent(
{
nation: gainer,
type: 'military',
title: 'Invasion repelled',
text: `${next.nations[gainer].name}'s invasion of ${
regionNameOf?.(key) ?? key
} was repelled by ${next.nations[owner].name}.`,
outcome: 'repelled',
},
actingTurn,
next.date,
),
);
continue;
}
for (const n of Object.values(next.nations)) {
const i = n.territory.indexOf(key);
if (i !== -1) n.territory.splice(i, 1);
@@ -761,6 +955,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 +1114,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 +1414,25 @@ 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;
/**
* (v0.30, #50) Region-key -> human display name resolver (the geo
* `properties.name`, from server/regions.ts). Present, the engine's
* "invasion repelled" event names the region by its display name; absent,
* it falls back to the raw region key. Pure either way.
*/
regionNameOf?: (key: string) => string | undefined;
}): 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, regionNameOf } = opts;
const chatWithTools = llm.chatWithTools;
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
const system = buildSystemPrompt(preset, useTools);
@@ -1216,18 +1470,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 +1495,7 @@ export async function runTurn(opts: {
date: addDays(state.date, 2),
events: [genericUnclearEvent(state)],
stateChanges: [],
regionChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
@@ -1243,7 +1504,10 @@ export async function runTurn(opts: {
};
}
return { state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf), result };
return {
state: applyTurnResult(state, result, validRegionKeys, centroidOf, geoOf, regionNameOf),
result,
};
}
// --- Advisor (brainstorm) -----------------------------------------------------
+13 -2
View File
@@ -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,17 @@ 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)),
// (v0.30, #50) region display name for the engine's "invasion repelled"
// event (geo properties.name, else the raw key). Always on — the
// resolver is pure and degrades to the key when the manifest is absent.
regionNameOf: (k) => regionName(k),
});
db.save(state);
broadcast(id, 'turn', { state, result });
+39
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
canConquer,
CONQUEST_MILITARY_MARGIN,
deriveNationStats,
migrateRegionStats,
seedRegionStats,
@@ -218,3 +220,40 @@ describe('migrateRegionStats', () => {
expect('uk' in (out.regionStats ?? {})).toBe(false);
});
});
// --- canConquer (v0.30, #50: the military hard gate) -------------------------
describe('canConquer', () => {
it('the default margin constant is 15', () => {
expect(CONQUEST_MILITARY_MARGIN).toBe(15);
});
it('an attacker within the margin of the defender proceeds (gap == margin)', () => {
// attacker 10, defender 25 → gap of 15 == margin → win (not more than 15 below)
expect(canConquer(10, 25)).toBe(true);
});
it('an attacker more than the margin below the defender is repelled (gap == margin+1)', () => {
// attacker 9, defender 25 → gap of 16 > margin → repel
expect(canConquer(9, 25)).toBe(false);
});
it('an equal attacker proceeds', () => {
expect(canConquer(50, 50)).toBe(true);
});
it('an attacker within the margin (gap < margin) proceeds', () => {
expect(canConquer(30, 40)).toBe(true); // gap 10
expect(canConquer(14, 25)).toBe(true); // gap 11
});
it('an attacker ahead of the defender always proceeds', () => {
expect(canConquer(90, 10)).toBe(true);
});
it('a margin of 0 means only an equal-or-ahead attacker proceeds', () => {
expect(canConquer(50, 50, 0)).toBe(true); // equal
expect(canConquer(51, 50, 0)).toBe(true); // ahead
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel
});
});
+6
View File
@@ -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)
+64
View File
@@ -20,6 +20,23 @@ export type StateChange =
| { nation: string; stat: StatKey; op: 'set'; value: number }
| { nation: string; stat: StatKey; op: 'add'; value: number };
/**
* (v0.29, #46 PR 2) A numeric change to one of **one region's** 0–100 stats —
* the fine-grained, per-region lever that complements the nation-wide
* {@link StateChange}. `region` names the target by its **region key**
* (preferred) or its human-readable **name** (the engine resolves it
* case-insensitively, mirroring `resolveNationRef`); after validation it holds
* the resolved **region key**. `op` is `set` (replace the value) or `add`
* (delta, clamped to 0–100 by the engine).
*/
export type RegionStatChange = {
/** The target region's key (preferred) or name (resolved by the engine). */
region: string;
stat: StatKey;
op: 'set' | 'add';
value: number;
};
/** Set the bilateral relation between nations `a` and `b` to `set` (symmetric). */
export interface RelationChange {
a: string;
@@ -217,6 +234,17 @@ export interface TurnResult {
date: string;
events: TurnEventSpec[];
stateChanges: StateChange[];
/**
* (v0.29, #46 PR 2) Fine-grained, **per-region** stat changes: each targets a
* single region's 0–100 stats (by region key or name) and complements the
* nation-wide `stateChanges` (which apply to *all* of a nation's regions).
* The engine resolves each `region` to a key (key first, then a
* case-insensitive unique name match) and applies it to
* `GameState.regionStats` only for regions that have an entry (owned); an
* unowned / no-man's-land region is a no-op. Empty on turns where the model
* changes no individual region. Required (default `[]`).
*/
regionChanges: RegionStatChange[];
relations: RelationChange[];
territoryChanges: TerritoryChange[];
/**
@@ -424,6 +452,42 @@ export function nationColor(id: string): string {
return `hsl(${hue} 58% 46%)`;
}
// --- Combat: the military hard gate (v0.30, #50) ------------------------------
//
// The one rule the engine enforces on the player's behalf instead of trusting
// the LLM: a contested conquest only succeeds when the attacker is not MORE
// than `CONQUEST_MILITARY_MARGIN` below the defender. The LLM still proposes
// the transfer; the engine decides whether it holds (see `applyTurnResult`).
// Re-claims of unowned / no-man's-land regions are never gated.
/**
* (v0.30, #50) The margin of military superiority an attacker must NOT be more
* than below the defender for a contested conquest to succeed. A gap of exactly
* this many points is still enough to win; a gap one larger is repelled.
*/
export const CONQUEST_MILITARY_MARGIN = 15;
/**
* (v0.30, #50) Hard military gate for a **contested** conquest (a region
* currently owned by a different nation). Returns `true` — the invasion
* proceeds — unless the attacker's military is MORE than `margin` below the
* defender's: equal, or within `margin` (default {@link CONQUEST_MILITARY_MARGIN}),
* is enough to win, while a gap larger than `margin` is repelled.
*
* The comparison is intentionally one-sided (it only protects the *defender*
* from an overwhelming odds gap, not the attacker from a small disadvantage):
* `attacker < defender - margin` → repel; everything else (equal, within the
* margin, or ahead) → proceed. Pure + shared so the engine, the tests, and the
* prompt's frontier-balance hint all agree on the outcome.
*/
export function canConquer(
attackerMil: number,
defenderMil: number,
margin: number = CONQUEST_MILITARY_MARGIN,
): boolean {
return !(attackerMil < defenderMil - margin);
}
// --- No man's land (v0.22, #16 P3) — the map presentation contract ---
//
// An unowned region has two distinct looks, and the difference is NOT in the
+9 -1
View File
@@ -79,7 +79,15 @@ export const EVENT_TYPES: EventType[] = [
'cultural',
];
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
/**
* The outcome of a narrative event. `success` / `failure` / `partial` /
* `neutral` are the four the LLM is asked to emit. `repelled` (v0.30, #50) is
* reserved for the engine: it is emitted automatically when the military hard
* gate rejects a contested conquest (an invasion whose attacker was more than
* `CONQUEST_MILITARY_MARGIN` below the defender) — the LLM never produces it,
* and the client renders it in a distinct muted-red failure tone.
*/
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral' | 'repelled';
// --- Map markers (v0.27, #44) -------------------------------------------------
//
+1
View File
@@ -707,6 +707,7 @@ function GameScreen({
presetId={game.presetId}
unowned={game.unowned ?? []}
markers={game.markers ?? []}
regionStats={game.regionStats ?? {}}
onFocusNation={focusNation}
/>
+35 -5
View File
@@ -17,7 +17,7 @@ import {
type RegionMap,
type RegionOwner,
} from '@shared/protocol';
import { MARKER_TYPES, type MapMarker, type Nation } from '@shared/types';
import { MARKER_TYPES, type MapMarker, type Nation, type RegionStats } from '@shared/types';
import { cn } from '@/lib/utils';
import { idToKey } from '@/lib/regions';
@@ -410,11 +410,29 @@ function hoverHtml(
region?: RegionOwner,
nation?: Nation,
noMansLand = false,
regionKey?: string,
regionStats?: Record<string, RegionStats>,
): string {
if (!region || !nation) {
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
}
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
// (v0.29, #46 PR 2) The region's OWN per-region stats (not the nation's
// derived aggregate), looked up by region key. When the region has no stats
// entry (defensive — an owned region should always have one) every stat
// shows a placeholder dash. The dossier (NationPanel) still shows the
// nation's derived stats, so this tooltip is the one place a single region's
// numbers are visible on the map.
const rs = regionKey ? regionStats?.[regionKey] : undefined;
const row = (label: string, v?: number): string =>
rs == null
? `<div class="hm-stat"><span class="hm-stat-k">${label}</span><span class="hm-stat-v">–</span></div>`
: statRow(label, v);
const stats = [
row('Mil', rs?.military),
row('Eco', rs?.economy),
row('Stab', rs?.stability),
row('Pop', rs?.population),
].join('');
return `<div class="hm-popup">
<div class="hm-popup-title">${esc(subRegionName || region.owner)}</div>
<div class="hm-popup-owner"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
@@ -453,6 +471,14 @@ interface Props {
* a top-most symbol layer (per-type glyph + name, per-type color).
*/
markers?: MapMarker[];
/**
* (v0.29, #46 PR 2) The game's per-region stats (`GameState.regionStats`,
* keyed by region key). Used only by the hover tooltip to show a single
* region's own four 0–100 stats (a region with no entry shows placeholders).
* The dossier (NationPanel) is unaffected — it keeps the nation's derived
* stats.
*/
regionStats?: Record<string, RegionStats>;
}
/**
@@ -509,6 +535,7 @@ export default function WorldMap({
unowned,
onFocusNation,
markers,
regionStats,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
@@ -524,6 +551,8 @@ export default function WorldMap({
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
const onFocusNationRef = useRef(onFocusNation);
const markersRef = useRef<MapMarker[]>(markers ?? []);
// (v0.29, #46 PR 2) latest per-region stats, for the hover tooltip.
const regionStatsRef = useRef<Record<string, RegionStats>>(regionStats ?? {});
regionsRef.current = regions;
nationsRef.current = nations;
playerRef.current = playerNationId;
@@ -531,6 +560,7 @@ export default function WorldMap({
unownedRef.current = new Set(unowned ?? []);
onFocusNationRef.current = onFocusNation;
markersRef.current = markers ?? [];
regionStatsRef.current = regionStats ?? {};
// Diffing state for the transient territory-change flash (owned by this component
// so a resync with identical data never re-flashes).
@@ -689,11 +719,11 @@ export default function WorldMap({
// (v0.22, #16 P3) whether this region is no man's land (released
// mid-game), so the ownerless tooltip can say so.
const pKey = f.properties?.regionKey;
const noMansLand =
!region && typeof pKey === 'string' && pKey !== '' && unownedRef.current.has(pKey);
const keyStr = typeof pKey === 'string' && pKey !== '' ? pKey : undefined;
const noMansLand = !region && keyStr !== undefined && unownedRef.current.has(keyStr);
hoverPopup
.setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand))
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand, keyStr, regionStatsRef.current))
.addTo(map);
map.getCanvas().style.cursor = 'pointer';
};
+3
View File
@@ -26,6 +26,9 @@ export const OUTCOME_COLORS: Record<EventOutcome, string> = {
failure: '#ef4444',
partial: '#f59e0b',
neutral: '#94a3b8',
// (v0.30, #50) engine-emitted "invasion repelled" — a muted, darker red so it
// reads as a defensive failure distinct from a bright outright `failure`.
repelled: '#9f1239',
};
/**