diff --git a/CHANGELOG.md b/CHANGELOG.md index 63bf2ef..a3b4c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ 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 (`"'s invasion of was repelled by ."`, 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`. diff --git a/package.json b/package.json index b3d1ef0..e3445fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.29.0", + "version": "0.30.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/engine.test.ts b/server/engine.test.ts index b78a42c..ddeb5ff 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -1,6 +1,16 @@ 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 { deterministicTurn, makeStubLlmClient } from './stub'; import { @@ -2470,3 +2480,193 @@ describe('stub — regionChanges (v0.29, #46 PR 2)', () => { 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/); + }); +}); diff --git a/server/engine.ts b/server/engine.ts index f7c3238..011815b 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -19,6 +19,7 @@ import type { import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types'; import { addDays, + canConquer, clampStat, deriveNationStats, fnv1a32, @@ -201,6 +202,11 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s ' "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]}', '}', '', + '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 " ()". 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(); // 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(); // 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(); + 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 = { state: { turn: state.turn, @@ -305,6 +368,8 @@ export function buildUserPrompt( }; 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); @@ -695,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, @@ -702,6 +772,7 @@ export function applyTurnResult( validKeys?: ReadonlySet, 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 @@ -809,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); @@ -1325,8 +1424,15 @@ export async function runTurn(opts: { * 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, regionKeyResolver } = 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); @@ -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) ----------------------------------------------------- diff --git a/server/index.ts b/server/index.ts index 16add1e..bda31c9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -314,6 +314,10 @@ app.post('/api/games/:id/jump', async (req, res) => { // 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 }); diff --git a/server/protocol.test.ts b/server/protocol.test.ts index 3a4de86..a29dde7 100644 --- a/server/protocol.test.ts +++ b/server/protocol.test.ts @@ -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 + }); +}); diff --git a/shared/protocol.ts b/shared/protocol.ts index ed085e3..996ef49 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -452,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 diff --git a/shared/types.ts b/shared/types.ts index 1c35b7c..17628c0 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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) ------------------------------------------------- // diff --git a/src/lib/colors.ts b/src/lib/colors.ts index f7c23db..f5e1fbc 100644 --- a/src/lib/colors.ts +++ b/src/lib/colors.ts @@ -26,6 +26,9 @@ export const OUTCOME_COLORS: Record = { 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', }; /**