Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c56024bc | ||
|
|
3c187c6901 |
@@ -2,6 +2,19 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.32.0 — 2026-10-15 10:56
|
||||
- **#54: non-military takeovers become a visible regime change — a `Takeover` in the turn renames the nation.** The v0.30.0 stats work made stability a *soft* lever: the system prompt told the model that a low-stability nation (`stability < 30`) hosting a `gathering` marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with an event and a stat shock — but the model had no actual *field* to express the regime change itself, so the nation's name never changed and the takeover was invisible in the world. This version closes that gap with a new `TurnResult.takeovers` field: `Takeover = { nationRef, newRegimeName }`. Each entry is an **automatic, in-world** regime change applied directly by `applyTurnResult` (unlike `nationOps`, which are player-confirmed proposals) — the target nation's display `name` becomes `newRegimeName`, while its stable `id`, territory, relations (keyed by id), stats, and `playerNationId` are all preserved, so a player whose nation is overthrown keeps leading it under the new regime.
|
||||
- **Protocol (`shared/protocol.ts`).** New `Takeover` interface (`nationRef` + `newRegimeName`, both strings). `TurnResult.takeovers: Takeover[]` is **required** (default `[]`), placed between `nationOps` and `markers`.
|
||||
- **Engine (`server/engine.ts`).**
|
||||
- `validateTakeovers(raw)` — new exported, defensive validator (same style as `validateNationOps`, never throws): non-array → `[]`; each entry must be an object with a non-empty (whitespace-trimmed) `nationRef` **and** a non-empty `newRegimeName`, each capped at 200 chars; anything else is dropped. Resolution of `nationRef` (id first, then a case-insensitive unique name) happens at apply time via the existing `resolveNationRef` — an unknown or ambiguous ref is a no-op at apply, never a throw.
|
||||
- `validateTurnResult` populates `takeovers` from the validated array (and the generic "unclear" fallback in `runTurn` carries `takeovers: []`).
|
||||
- `applyTurnResult` applies takeovers **after** the per-region stats recompute and **before** the markers block (the rename is order-independent of territory/stats; marker owners are nation **ids**, not names, so the rename never disturbs the owner recompute).
|
||||
- `isMeaningful` — a turn that only performs a takeover is meaningful (it survives the retry/nudge path).
|
||||
- `buildSystemPrompt` — the JSON shape gains `"takeovers": [{…}]` and a new HARD CONSTRAINTS bullet documents the field: **automatic, NOT player-confirmed**; fire it when a low-stability nation hosting a `gathering` marker is overthrown (the v0.30 stability/gathering mechanic now has a concrete consequence), narrate it with a political/social success event plus a stability/economy shock, and use `nationOps` rename only for the player-confirmed kind of name change.
|
||||
- **Stub (`server/stub.ts`).** `deterministicTurn` returns `takeovers: []` (the stub never performs a takeover) — its one-conquest behavior and every e2e assertion are untouched; the regime-rename path is covered by unit tests only.
|
||||
- **Client.** No change — a takeover is just a nation `name` change, which the existing dossier / event feed / map rendering already show (the nation keeps its stable color, since that is keyed by id).
|
||||
- **Unit tests (`server/engine.test.ts`).** 15 new tests: the apply suite (a takeover renames the target with id/territory/relations/stats preserved; a takeover of the player's own nation preserves `playerNationId`; unknown ref → no-op; ambiguous ref → no-op; empty/whitespace `newRegimeName` dropped at validation; multiple takeovers in one turn all apply; a takeover turn bumps the version exactly once; case-insensitive name resolution); `isMeaningful` with takeovers; the system-prompt contract (both modes document the field + the automatic/not-player-confirmed rule + the stability/gathering tie-in + the distinction from `nationOps` rename); `validateTakeovers` (valid entries trimmed/capped, invalid dropped, non-array → `[]`, `validateTurnResult` carry-through + default); the stub returns `takeovers: []`. All existing `TurnResult` literals in the suite gained the new required field. Totals: **322 unit tests (14 files)**; typecheck, lint, and build green.
|
||||
|
||||
## v0.31.0 — 2026-10-15 10:22
|
||||
- **#52: dynamic scenario generation — the "Create scenario" path.** An additive second way to start a game alongside the 5 static presets: the user picks one of **3 light era templates** (`ww2` 1939, `present` 2026, `near-future` 2035) and writes a free-text **description** (plus an editable start year), and the LLM **generates** a draft world + an advisor **checkpoint list** (step 1). The player reviews the checkpoints (approve/remove) and picks a nation to lead, and a second LLM call **finalizes** the world — constrained to the *approved* checkpoints — into a saved, playable `GameState` (step 2): a normal game at `#/game/<id>` that plays with the existing turn engine, map, and panels untouched. The 5 static presets and the turn stub (`server/stub.ts`) are 100% unchanged; a new deterministic **scenario stub** (a distinct transport discriminating the two calls by marker) makes the whole path reproducible under `LLM_STUB=1`.
|
||||
- **Era templates (`presets/templates/`).** Three small JSON seeds + an `index.json` manifest — each `{ id, name, year, eraDescription, suggestedPowers: [{ name, region (ISO_A3), note }], tensions: [] }` (e.g. near-future suggests a North American Union/USA, a Chinese Federation/CHN, a European Confederation/FRA, a Russian Union/RUS, with tensions like "autonomous weapons and the new arms race"). `server/templates.ts` is a tiny store mirroring `createPresetStore` (manifest + per-file load; a missing/broken file degrades to absent).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.31.0",
|
||||
"version": "0.32.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
|
||||
+305
-20
@@ -35,6 +35,7 @@ import {
|
||||
validateMarkerOps,
|
||||
validateNationOps,
|
||||
validateRegionChanges,
|
||||
validateTakeovers,
|
||||
validateTurnResult,
|
||||
} from './engine';
|
||||
|
||||
@@ -542,7 +543,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withOps).not.toBeNull();
|
||||
expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]);
|
||||
@@ -564,7 +565,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.nations.de.name).toBe('DE'); // still the old name
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
@@ -585,7 +586,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], markers: { add: [], remove: [] }, // new proposal this turn
|
||||
nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], takeovers: [], markers: { add: [], remove: [] }, // new proposal this turn
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([
|
||||
{ kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived
|
||||
@@ -604,7 +605,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]);
|
||||
});
|
||||
@@ -1061,7 +1062,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
@@ -1078,7 +1079,7 @@ describe('applyTurnResult', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
@@ -1101,7 +1102,7 @@ describe('applyTurnResult', () => {
|
||||
// is not a real key at all. Both are proposed as a gain by DE.
|
||||
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||
// the valid key was gained (taken from its sole previous owner); the bogus one was ignored
|
||||
@@ -1127,7 +1128,7 @@ describe('applyTurnResult', () => {
|
||||
// released (not in state.unowned), so it does not enter GameState.unowned.
|
||||
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
@@ -1145,7 +1146,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
|
||||
@@ -1235,7 +1236,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [], markers: { add: [], remove: [] },
|
||||
nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
|
||||
@@ -1350,7 +1351,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: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], markers: { add: [], remove: [] } }),
|
||||
isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], takeovers: [], markers: { add: [], remove: [] } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1657,9 +1658,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: [], 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);
|
||||
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] } })).toBe(true);
|
||||
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], 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)', () => {
|
||||
@@ -1672,7 +1673,7 @@ describe('isMeaningful', () => {
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] },
|
||||
nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], takeovers: [], markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -1688,10 +1689,202 @@ describe('isMeaningful', () => {
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('(v0.32.0 #54) a turn that only performs a takeover is meaningful (it survives the retry/nudge path)', () => {
|
||||
expect(
|
||||
isMeaningful({
|
||||
date: 'x',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
markers: { add: [], remove: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('takeovers (regime change, v0.32.0 #54)', () => {
|
||||
it('a takeover renames the target nation; id, territory, relations, and stats are preserved', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.de.name).toBe('The Reich');
|
||||
// id is preserved
|
||||
expect(next.nations.de.id).toBe('de');
|
||||
// territory is preserved
|
||||
expect(next.nations.de.territory).toEqual(['DEU']);
|
||||
// relations are preserved (keyed by id, not name)
|
||||
expect(next.nations.de.relations).toEqual({});
|
||||
// stats are preserved
|
||||
expect(next.nations.de.stats.military).toBe(95);
|
||||
// other nations are untouched
|
||||
expect(next.nations.uk.name).toBe('UK');
|
||||
expect(next.nations.us.name).toBe('US');
|
||||
});
|
||||
|
||||
it('a takeover of the player\'s own nation preserves playerNationId (player keeps leading it)', () => {
|
||||
const state = baseState(); // playerNationId is 'uk'
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'uk', newRegimeName: 'The Commonwealth' }],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.playerNationId).toBe('uk'); // player still leads it
|
||||
expect(next.nations.uk.name).toBe('The Commonwealth');
|
||||
expect(next.nations.uk.id).toBe('uk');
|
||||
});
|
||||
|
||||
it('an unknown nationRef → no-op (nation unchanged, no throw)', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'nonexistent', newRegimeName: 'New Name' }],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
// no nation was renamed
|
||||
expect(next.nations.uk.name).toBe('UK');
|
||||
expect(next.nations.us.name).toBe('US');
|
||||
expect(next.nations.de.name).toBe('DE');
|
||||
});
|
||||
|
||||
it('an ambiguous nationRef (two nations share the name) → no-op', () => {
|
||||
const state = baseState();
|
||||
// make two nations share the same name
|
||||
state.nations.us.name = 'UK'; // now both 'uk' and 'us' are named 'UK'
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'UK', newRegimeName: 'The Empire' }],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
// neither was renamed (ambiguous)
|
||||
expect(next.nations.uk.name).toBe('UK');
|
||||
expect(next.nations.us.name).toBe('UK');
|
||||
});
|
||||
|
||||
it('an empty/whitespace newRegimeName → no-op', () => {
|
||||
const state = baseState();
|
||||
// validateTakeovers drops entries with empty newRegimeName, so the validated
|
||||
// result has no takeovers → applyTurnResult is a no-op.
|
||||
const validated = validateTurnResult({
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: ' ' }],
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(validated!.takeovers).toEqual([]);
|
||||
const next = applyTurnResult(state, validated!);
|
||||
expect(next.nations.de.name).toBe('DE');
|
||||
});
|
||||
|
||||
it('multiple takeovers in one turn all apply', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [
|
||||
{ nationRef: 'uk', newRegimeName: 'The Commonwealth' },
|
||||
{ nationRef: 'de', newRegimeName: 'The Reich' },
|
||||
],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.uk.name).toBe('The Commonwealth');
|
||||
expect(next.nations.de.name).toBe('The Reich');
|
||||
expect(next.nations.us.name).toBe('US'); // untouched
|
||||
});
|
||||
|
||||
it('a takeover turn bumps the version once (same as any other turn)', () => {
|
||||
const state = baseState(); // version 1
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.version).toBe(2); // exactly one bump for the whole turn
|
||||
});
|
||||
|
||||
it('a takeover via case-insensitive name resolution works', () => {
|
||||
const state = baseState();
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'De', newRegimeName: 'The Reich' }], // 'De' → resolves to 'de' by name
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.nations.de.name).toBe('The Reich');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markers (point placement, v0.27 #44)', () => {
|
||||
@@ -1716,6 +1909,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] },
|
||||
};
|
||||
const next = applyTurnResult(state, result, undefined, centroidOf);
|
||||
@@ -1740,13 +1934,13 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
const add = [mk('city', 'Strasbourg', 'moselle-rygy')];
|
||||
const first = applyTurnResult(state, {
|
||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||
unowned: [], nationOps: [], markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], 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: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||
unowned: [], nationOps: [], markers: { add, remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], markers: { add, remove: [] },
|
||||
}, undefined, centroidOf);
|
||||
expect(second.markers).toHaveLength(1);
|
||||
expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy
|
||||
@@ -1768,6 +1962,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept
|
||||
@@ -1810,6 +2005,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: {
|
||||
add: [
|
||||
{ type: 'city', name: 'NewA', regionKey: 'new-a' },
|
||||
@@ -1835,7 +2031,7 @@ describe('markers (point placement, v0.27 #44)', () => {
|
||||
];
|
||||
const next = applyTurnResult(state, {
|
||||
date: '1939-09-03', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||
unowned: [], nationOps: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||
unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] },
|
||||
}, undefined, centroidOf);
|
||||
expect(next.markers).toHaveLength(1);
|
||||
expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op
|
||||
@@ -1851,14 +2047,14 @@ 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: [], regionChanges: [], relations: [], territoryChanges: [],
|
||||
unowned: ['moselle-rygy'], nationOps: [], markers: { add: [], remove: [] },
|
||||
unowned: ['moselle-rygy'], nationOps: [], takeovers: [], 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: [], regionChanges: [], relations: [],
|
||||
territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }],
|
||||
unowned: [], nationOps: [], markers: { add: [], remove: [] },
|
||||
unowned: [], nationOps: [], takeovers: [], markers: { add: [], remove: [] },
|
||||
}, undefined, centroidOf);
|
||||
expect(reclaimed.markers[0].ownerNationId).toBe('de');
|
||||
});
|
||||
@@ -2006,6 +2202,7 @@ function mkTurnResult(partial: Partial<TurnResult>): TurnResult {
|
||||
territoryChanges: partial.territoryChanges ?? [],
|
||||
unowned: partial.unowned ?? [],
|
||||
nationOps: partial.nationOps ?? [],
|
||||
takeovers: partial.takeovers ?? [],
|
||||
markers: partial.markers ?? { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
@@ -2670,3 +2867,91 @@ describe('buildUserPrompt — frontier balance + takeover risks (v0.30 #50)', ()
|
||||
expect(prompt).not.toMatch(/takeoverRisks/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt — takeovers contract (v0.32.0 #54)', () => {
|
||||
it('both modes document the takeovers field and the automatic (NOT player-confirmed) rule', () => {
|
||||
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
|
||||
// the JSON shape carries the field in both modes
|
||||
expect(p).toMatch(
|
||||
/"takeovers": \[\{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"\}\]/,
|
||||
);
|
||||
// the rule: a takeover is an automatic in-world regime change, not a proposal
|
||||
expect(p).toMatch(/Takeover \(automatic, NOT player-confirmed\)/);
|
||||
// and it is tied to the stability + gathering mechanic
|
||||
expect(p).toMatch(/low-stability nation/);
|
||||
expect(p).toMatch(/gathering/);
|
||||
// and it is distinguished from the player-confirmed nationOps rename
|
||||
expect(p).toMatch(/use "nationOps" rename only for a diplomatic\/political name change/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTakeovers (v0.32.0 #54)', () => {
|
||||
it('parses valid entries (trimmed, capped) and drops invalid ones', () => {
|
||||
const takeovers = validateTakeovers([
|
||||
{ nationRef: ' de ', newRegimeName: 'The Reich' },
|
||||
{ nationRef: 'uk', newRegimeName: ' ' }, // empty name -> dropped
|
||||
{ nationRef: '', newRegimeName: 'X' }, // empty ref -> dropped
|
||||
{ newRegimeName: 'no ref' }, // missing nationRef -> dropped
|
||||
{ nationRef: 'us' }, // missing newRegimeName -> dropped
|
||||
'not-an-object',
|
||||
null,
|
||||
42,
|
||||
]);
|
||||
expect(takeovers).toEqual([{ nationRef: 'de', newRegimeName: 'The Reich' }]);
|
||||
});
|
||||
|
||||
it('caps both fields at 200 characters', () => {
|
||||
const long = 'x'.repeat(250);
|
||||
const takeovers = validateTakeovers([{ nationRef: long, newRegimeName: long }]);
|
||||
expect(takeovers).toEqual([{ nationRef: 'x'.repeat(200), newRegimeName: 'x'.repeat(200) }]);
|
||||
});
|
||||
|
||||
it('returns [] for non-array input and is never thrown by', () => {
|
||||
expect(validateTakeovers(undefined)).toEqual([]);
|
||||
expect(validateTakeovers('nope')).toEqual([]);
|
||||
expect(validateTakeovers({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('validateTurnResult carries validated takeovers through (and defaults to [] when absent)', () => {
|
||||
const withTakeovers = validateTurnResult({
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
regionChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [{ nationRef: 'de', newRegimeName: 'The Reich' }, { nationRef: 'bad', newRegimeName: '' }],
|
||||
markers: { add: [], remove: [] },
|
||||
});
|
||||
expect(withTakeovers).not.toBeNull();
|
||||
// the valid one survives, the empty-name one is dropped
|
||||
expect(withTakeovers!.takeovers).toEqual([{ nationRef: 'de', newRegimeName: 'The Reich' }]);
|
||||
|
||||
const without = validateTurnResult({ date: '1939-09-03', events: [], stateChanges: [] });
|
||||
expect(without).not.toBeNull();
|
||||
expect(without!.takeovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stub — takeovers (v0.32.0 #54)', () => {
|
||||
it('the deterministic stub returns takeovers: [] (it never performs a takeover)', () => {
|
||||
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.takeovers).toEqual([]);
|
||||
expect(result).toHaveProperty('takeovers');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type MarkerSpec,
|
||||
type NationOp,
|
||||
type RegionStatChange,
|
||||
type Takeover,
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
} from '../shared/protocol';
|
||||
@@ -199,6 +200,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}]}',
|
||||
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||||
' ],',
|
||||
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
|
||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||
'}',
|
||||
'',
|
||||
@@ -216,6 +218,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
'- Rename: add {"kind":"rename","nationRef":"<nation id or name>","to":"<new name>"} for a revolution, regime change or reunification that changes a nation\'s name.',
|
||||
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
||||
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
||||
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.',
|
||||
'- 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.',
|
||||
@@ -509,6 +512,7 @@ export function validateTurnResult(
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
@@ -599,6 +603,10 @@ export function validateTurnResult(
|
||||
// list for the player to confirm/reject.
|
||||
result.nationOps = validateNationOps(o.nationOps);
|
||||
|
||||
// (v0.32.0, #54) Non-military regime changes (takeovers) — applied directly
|
||||
// by applyTurnResult (automatic, not player-confirmed).
|
||||
result.takeovers = validateTakeovers(o.takeovers);
|
||||
|
||||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||
// removals by stable id. Applied by applyTurnResult after the territory
|
||||
// moves (a new marker's owner is the region's post-turn owner).
|
||||
@@ -705,6 +713,29 @@ export function validateRegionChanges(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.32.0, #54) Validate the raw `takeovers` array of a turn into a safe
|
||||
* {@link Takeover} list — the non-military regime-change lever (a coup or
|
||||
* revolution that renames a nation to its new regime name). Defensive like
|
||||
* the rest of the parser — never throws. Each entry needs a non-empty
|
||||
* `nationRef` (the target nation's id or name — capped at 200 chars) and a
|
||||
* non-empty `newRegimeName` (capped at 200 chars). Resolution of `nationRef`
|
||||
* to a concrete nation id happens at apply time (`resolveNationRef`), not
|
||||
* here. Entries with either field missing or whitespace-only are dropped.
|
||||
*/
|
||||
export function validateTakeovers(raw: unknown): Takeover[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: Takeover[] = [];
|
||||
for (const e of raw) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const o = e as Record<string, unknown>;
|
||||
const nationRef = str(o.nationRef, 200);
|
||||
const newRegimeName = str(o.newRegimeName, 200);
|
||||
if (nationRef && newRegimeName) out.push({ nationRef, newRegimeName });
|
||||
}
|
||||
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
|
||||
@@ -721,6 +752,8 @@ export function isMeaningful(result: TurnResult): boolean {
|
||||
result.regionChanges.length > 0 ||
|
||||
result.unowned.length > 0 ||
|
||||
result.nationOps.length > 0 ||
|
||||
// (v0.32.0, #54) a takeover is a real world change (regime rename).
|
||||
result.takeovers.length > 0 ||
|
||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||
result.markers.add.length > 0 ||
|
||||
result.markers.remove.length > 0
|
||||
@@ -973,6 +1006,30 @@ export function applyTurnResult(
|
||||
n.stats = deriveNationStats(n.territory, next.regionStats, geoOf);
|
||||
}
|
||||
|
||||
// --- Takeovers (v0.32.0, #54): non-military regime changes (coups /
|
||||
// revolutions) that rename a nation to its new regime. Applied AFTER the
|
||||
// per-region recompute (the rename doesn't depend on territory or stats,
|
||||
// so it is order-independent with respect to those steps) and BEFORE the
|
||||
// markers block (a marker's owner is a nation id, not a name, so the
|
||||
// rename does not affect the owner recompute).
|
||||
//
|
||||
// For each takeover:
|
||||
// 1. Resolve the target nation via `resolveNationRef` (id first, then
|
||||
// case-insensitive unique name). Skip (no-op) if `null` — an
|
||||
// unknown or ambiguous ref is silently dropped, never thrown.
|
||||
// 2. Rename the resolved nation: set `n.name = newRegimeName`. Only
|
||||
// the display name changes — the stable id, territory, relations
|
||||
// (keyed by id), stats, and `playerNationId` (which references the
|
||||
// id, not the name) are all preserved. The player keeps leading an
|
||||
// overthrown nation under its new regime.
|
||||
for (const t of result.takeovers) {
|
||||
const id = resolveNationRef(next, t.nationRef);
|
||||
if (id === null) continue; // unknown / ambiguous ref: no-op
|
||||
const n = next.nations[id];
|
||||
if (!n) continue; // defensive: id resolved but nation missing (corrupt state)
|
||||
n.name = t.newRegimeName;
|
||||
}
|
||||
|
||||
// --- Map markers (v0.27, #44) ---
|
||||
//
|
||||
// Markers are anchored to a region key and are processed against the FINAL
|
||||
@@ -1500,6 +1557,7 @@ export async function runTurn(opts: {
|
||||
territoryChanges: [],
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ function truncate(s: string, n: number): string {
|
||||
* always returns `regionChanges: []`) — the per-region lever is covered by
|
||||
* unit tests only (see server/engine.test.ts), keeping the e2e assertions
|
||||
* untouched.
|
||||
*
|
||||
* (v0.32.0, #54) The stub likewise NEVER performs a non-military takeover
|
||||
* (it always returns `takeovers: []`) — the regime-rename path is covered by
|
||||
* unit tests only, keeping the e2e assertions untouched.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -143,6 +147,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
territoryChanges,
|
||||
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||
takeovers: [], // the stub never performs a takeover (see doc comment)
|
||||
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +68,25 @@ export interface TerritoryChange {
|
||||
* engine's `resolveNationRef`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* (v0.32.0, #54) A **non-military regime change** — a coup or revolution that
|
||||
* renames a nation to the name of the new regime. Unlike a {@link RenameOp}
|
||||
* (a player-confirmed diplomatic/political name change), a takeover is
|
||||
* **automatic and in-world**: `applyTurnResult` applies it directly (no
|
||||
* pending queue, no player confirm). Only `Nation.name` changes — the stable
|
||||
* `id`, territory, relations (keyed by id), stats, and `playerNationId`
|
||||
* (which references the id) all stay intact, so the player keeps leading the
|
||||
* nation under its new regime. `nationRef` is resolved exactly like the
|
||||
* `nationRef` of a NationOp (id first, then a case-insensitive unique name
|
||||
* match — see `resolveNationRef`).
|
||||
*/
|
||||
export interface Takeover {
|
||||
/** The target nation's id or current name (resolved by `resolveNationRef`). */
|
||||
nationRef: string;
|
||||
/** The name of the new regime to set as the nation's display name. */
|
||||
newRegimeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A **rename** proposal: change a nation's human-readable `name`. Only the
|
||||
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
||||
@@ -267,6 +286,17 @@ export interface TurnResult {
|
||||
* survivor. Empty on turns where the model proposes none.
|
||||
*/
|
||||
nationOps: NationOp[];
|
||||
/**
|
||||
* (v0.32.0, #54) Non-military regime changes (coups / revolutions) that take
|
||||
* effect **this turn** — each renames the target nation to `newRegimeName`.
|
||||
* Unlike `nationOps` (player-confirmed proposals), a takeover is **automatic
|
||||
* and in-world**: it is applied directly by `applyTurnResult` (like territory
|
||||
* / state changes), not queued to `pendingNationOps`. Only the nation's
|
||||
* `name` changes; the id, territory, relations, stats, and `playerNationId`
|
||||
* are all preserved. Empty on turns where no takeover occurs. Required
|
||||
* (default `[]`).
|
||||
*/
|
||||
takeovers: Takeover[];
|
||||
/**
|
||||
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||
* (the engine positions each at its region's centroid + a small
|
||||
|
||||
Reference in New Issue
Block a user