feat(#50): make the four 0-100 stats consequential (v0.30.0) #51
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
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
|
## 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.
|
- **#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`.
|
- **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`.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.29.0",
|
"version": "0.30.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
|
|||||||
+201
-1
@@ -1,6 +1,16 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { GameState, MarkerType, Nation, NationStats, Preset, RegionStats, RoundItem } from '../shared/types';
|
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 type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||||
import { deterministicTurn, makeStubLlmClient } from './stub';
|
import { deterministicTurn, makeStubLlmClient } from './stub';
|
||||||
import {
|
import {
|
||||||
@@ -2470,3 +2480,193 @@ describe('stub — regionChanges (v0.29, #46 PR 2)', () => {
|
|||||||
expect(result).toHaveProperty('markers');
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+111
-2
@@ -19,6 +19,7 @@ import type {
|
|||||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
|
canConquer,
|
||||||
clampStat,
|
clampStat,
|
||||||
deriveNationStats,
|
deriveNationStats,
|
||||||
fnv1a32,
|
fnv1a32,
|
||||||
@@ -201,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>"]}',
|
' "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',
|
'HARD CONSTRAINTS',
|
||||||
'- Use only nation ids and region keys that exist in the provided state.',
|
'- 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.',
|
'- 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.',
|
||||||
@@ -280,6 +286,63 @@ export function buildUserPrompt(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (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> = {
|
const payload: Record<string, unknown> = {
|
||||||
state: {
|
state: {
|
||||||
turn: state.turn,
|
turn: state.turn,
|
||||||
@@ -305,6 +368,8 @@ export function buildUserPrompt(
|
|||||||
};
|
};
|
||||||
if (regionReference) payload.regions = regionReference;
|
if (regionReference) payload.regions = regionReference;
|
||||||
if (frontier !== undefined) payload.frontier = frontier;
|
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 (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||||
if (markers !== undefined) payload.markers = markers;
|
if (markers !== undefined) payload.markers = markers;
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
@@ -695,6 +760,11 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
|||||||
* area×population-weighted median of its regions' stats. Without it the
|
* area×population-weighted median of its regions' stats. Without it the
|
||||||
* recompute degrades to an unweighted median (every region weight 1) — still
|
* recompute degrades to an unweighted median (every region weight 1) — still
|
||||||
* region-consistent, just unweighted. The engine stays pure either way.
|
* 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(
|
export function applyTurnResult(
|
||||||
state: GameState,
|
state: GameState,
|
||||||
@@ -702,6 +772,7 @@ export function applyTurnResult(
|
|||||||
validKeys?: ReadonlySet<string>,
|
validKeys?: ReadonlySet<string>,
|
||||||
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
|
||||||
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
geoOf?: (key: string) => { area: number; population: number } | undefined,
|
||||||
|
regionNameOf?: (key: string) => string | undefined,
|
||||||
): GameState {
|
): GameState {
|
||||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||||
@@ -809,6 +880,34 @@ export function applyTurnResult(
|
|||||||
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
const owner = ownerOf.get(key); // undefined: no owner; '': 2+ owners (corrupt)
|
||||||
if (owner === '') continue; // pre-existing double ownership: never touched
|
if (owner === '') continue; // pre-existing double ownership: never touched
|
||||||
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
|
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)) {
|
for (const n of Object.values(next.nations)) {
|
||||||
const i = n.territory.indexOf(key);
|
const i = n.territory.indexOf(key);
|
||||||
if (i !== -1) n.territory.splice(i, 1);
|
if (i !== -1) n.territory.splice(i, 1);
|
||||||
@@ -1325,8 +1424,15 @@ export async function runTurn(opts: {
|
|||||||
* applyTurnResult's regionStats lookup).
|
* applyTurnResult's regionStats lookup).
|
||||||
*/
|
*/
|
||||||
regionKeyResolver?: (ref: string) => string | null;
|
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 }> {
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||||
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver } = opts;
|
const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf, geoOf, regionKeyResolver, regionNameOf } = opts;
|
||||||
const chatWithTools = llm.chatWithTools;
|
const chatWithTools = llm.chatWithTools;
|
||||||
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
|
||||||
const system = buildSystemPrompt(preset, useTools);
|
const system = buildSystemPrompt(preset, useTools);
|
||||||
@@ -1398,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) -----------------------------------------------------
|
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||||
|
|||||||
@@ -314,6 +314,10 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
|||||||
// pure (validKeys + regionName) and degrades to a key-only match when
|
// pure (validKeys + regionName) and degrades to a key-only match when
|
||||||
// the manifest is absent.
|
// the manifest is absent.
|
||||||
regionKeyResolver: (ref) => resolveRegionKey(ref, validKeys(), (k) => regionName(k)),
|
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);
|
db.save(state);
|
||||||
broadcast(id, 'turn', { state, result });
|
broadcast(id, 'turn', { state, result });
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
canConquer,
|
||||||
|
CONQUEST_MILITARY_MARGIN,
|
||||||
deriveNationStats,
|
deriveNationStats,
|
||||||
migrateRegionStats,
|
migrateRegionStats,
|
||||||
seedRegionStats,
|
seedRegionStats,
|
||||||
@@ -218,3 +220,40 @@ describe('migrateRegionStats', () => {
|
|||||||
expect('uk' in (out.regionStats ?? {})).toBe(false);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -452,6 +452,42 @@ export function nationColor(id: string): string {
|
|||||||
return `hsl(${hue} 58% 46%)`;
|
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 ---
|
// --- 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
|
// An unowned region has two distinct looks, and the difference is NOT in the
|
||||||
|
|||||||
+9
-1
@@ -79,7 +79,15 @@ export const EVENT_TYPES: EventType[] = [
|
|||||||
'cultural',
|
'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) -------------------------------------------------
|
// --- Map markers (v0.27, #44) -------------------------------------------------
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export const OUTCOME_COLORS: Record<EventOutcome, string> = {
|
|||||||
failure: '#ef4444',
|
failure: '#ef4444',
|
||||||
partial: '#f59e0b',
|
partial: '#f59e0b',
|
||||||
neutral: '#94a3b8',
|
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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user