Compare commits

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

Fixes #50
2026-10-15 08:24:03 +00:00
9 changed files with 418 additions and 5 deletions
+14
View File
@@ -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 (`"<Attacker>'s invasion of <region name> was repelled by <Defender>."`, using the region's geo display name when known). Re-claims (`owner === undefined`) bypass the gate.
- `buildSystemPrompt` gains a **"HOW THE FOUR 0–100 STATS DRIVE OUTCOMES"** section: the enforced military gate (incl. the no-man's-land carve-out), the soft stability takeover, and the economy growth/decay lever — present in both tool and plain modes.
- `buildUserPrompt` computes and emits two new, bounded payload fields (each only when non-empty, the rest of the payload untouched): `frontierMilitary` (one line per frontier neighbour: `[Your military: X] vs [Name: Y] — a direct invasion could succeed | would be repelled`, decided by `canConquer`) and `takeoverRisks` (one line per nation with stability < 30 *and* a `gathering` marker: `Name (stability N, a gathering is in progress)`).
- `runTurn` threads `regionNameOf` through to `applyTurnResult`.
- **Server route (`server/index.ts`).** The `/jump` route wires `regionNameOf: (k) => regionName(k)` so repelled invasions name the region properly.
- **Stub (`server/stub.ts`).** Unchanged (100%) — it never releases, never proposes a nation op, and its one-conquest behavior is what e2e relies on; the new gate / takeover / economy paths are covered by unit tests only.
- **Unit tests.** `server/protocol.test.ts`: `canConquer` edges (equal, within margin, over margin, gap == margin, gap == margin + 1, margin = 0). `server/engine.test.ts`: the hard gate (proceed within margin / proceed when stronger / **repel when over margin** with a correct `repelled` event + region display name + attacker untouched / **re-claim ungated** / boundary at exactly the margin and one past it); the system prompt documents all three mechanics in both modes; the user prompt emits `frontierMilitary` (could-succeed vs repelled per neighbour) and `takeoverRisks` (and omits both fields when no frontier / no markers).
## v0.29.0 — 2026-10-15 01:05
- **#46 (PR 2 of 2): per-region stat lever — the LLM can now target individual regions' stats, and the map hover shows them.** The LLM gains a fine-grained `TurnResult.regionChanges` field (alongside the existing nation-wide `stateChanges`): each entry targets **one** region's own stats by its region key (preferred) or case-insensitive name. A `set` replaces the value; an `add` increments it (both clamped 0–100 at apply time). Region-level changes are applied **after** nation-wide `stateChanges` (so they override the nation-level value for that one region) and **before** the weighted-median recompute, which propagates every region's final value into its owning nation's derived `stats`. A region with no stats entry (unowned / no man's land) is a no-op. The map hover tooltip now shows each region's own four stats (military / economy / stability / population) alongside the region name and owner chip; the `NationPanel` keeps showing the nation-level derived aggregate. The stub stays fully deterministic (`regionChanges: []`) — e2e assertions are untouched.
- **Protocol (`shared/protocol.ts`).** New `RegionStatChange = { region: string; stat: StatKey; op: 'set'|'add'; value: number }`. `TurnResult.regionChanges: RegionStatChange[]` is **required** (default `[]`); sits right after `stateChanges`.
+1 -1
View File
@@ -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).",
+201 -1
View File
@@ -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/);
});
});
+111 -2
View File
@@ -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":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
'}',
'',
'HOW THE FOUR 0-100 STATS DRIVE OUTCOMES (military / economy / stability / population)',
'- MILITARY (the engine ENFORCES this rule, so weigh it before proposing a conquest): a nation can take a region by force only when its military is at least the defender\'s military minus 15 — equal, or within 15, is enough to win, but an attacker MORE than 15 below the defender is REPELLED and the region stays with its owner (a "repelled" event is recorded automatically; you cannot capture such a region that turn). Re-claiming an unowned (no man\'s land) region is never blocked this way.',
'- STABILITY (you drive this, no hard gate): a nation whose stability is below 30 AND that has a "gathering" marker on one of its regions can suffer a NON-military takeover — a coup, revolution or faction seizure of power. Narrate it with a political or social event (outcome "success"), a "gathering" marker on the region, and stateChanges that shock its stability and economy downward.',
'- ECONOMY (you drive this, no hard gate): economy drives long-term development — a strong economy lets a nation\'s other stats trend UP over time (small per-turn gains via stateChanges / regionChanges), while a weak economy lets them DECAY. Reflect that trend in the stat changes you make and narrate it.',
'',
'HARD CONSTRAINTS',
'- Use only nation ids and region keys that exist in the provided state.',
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges and unowned.',
@@ -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> = {
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<string>,
centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined,
geoOf?: (key: string) => { area: number; population: number } | undefined,
regionNameOf?: (key: string) => string | undefined,
): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
@@ -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) -----------------------------------------------------
+4
View File
@@ -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 });
+39
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
canConquer,
CONQUEST_MILITARY_MARGIN,
deriveNationStats,
migrateRegionStats,
seedRegionStats,
@@ -218,3 +220,40 @@ describe('migrateRegionStats', () => {
expect('uk' in (out.regionStats ?? {})).toBe(false);
});
});
// --- canConquer (v0.30, #50: the military hard gate) -------------------------
describe('canConquer', () => {
it('the default margin constant is 15', () => {
expect(CONQUEST_MILITARY_MARGIN).toBe(15);
});
it('an attacker within the margin of the defender proceeds (gap == margin)', () => {
// attacker 10, defender 25 → gap of 15 == margin → win (not more than 15 below)
expect(canConquer(10, 25)).toBe(true);
});
it('an attacker more than the margin below the defender is repelled (gap == margin+1)', () => {
// attacker 9, defender 25 → gap of 16 > margin → repel
expect(canConquer(9, 25)).toBe(false);
});
it('an equal attacker proceeds', () => {
expect(canConquer(50, 50)).toBe(true);
});
it('an attacker within the margin (gap < margin) proceeds', () => {
expect(canConquer(30, 40)).toBe(true); // gap 10
expect(canConquer(14, 25)).toBe(true); // gap 11
});
it('an attacker ahead of the defender always proceeds', () => {
expect(canConquer(90, 10)).toBe(true);
});
it('a margin of 0 means only an equal-or-ahead attacker proceeds', () => {
expect(canConquer(50, 50, 0)).toBe(true); // equal
expect(canConquer(51, 50, 0)).toBe(true); // ahead
expect(canConquer(49, 50, 0)).toBe(false); // one below → repel
});
});
+36
View File
@@ -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
+9 -1
View File
@@ -79,7 +79,15 @@ export const EVENT_TYPES: EventType[] = [
'cultural',
];
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
/**
* The outcome of a narrative event. `success` / `failure` / `partial` /
* `neutral` are the four the LLM is asked to emit. `repelled` (v0.30, #50) is
* reserved for the engine: it is emitted automatically when the military hard
* gate rejects a contested conquest (an invasion whose attacker was more than
* `CONQUEST_MILITARY_MARGIN` below the defender) — the LLM never produces it,
* and the client renders it in a distinct muted-red failure tone.
*/
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral' | 'repelled';
// --- Map markers (v0.27, #44) -------------------------------------------------
//
+3
View File
@@ -26,6 +26,9 @@ export const OUTCOME_COLORS: Record<EventOutcome, string> = {
failure: '#ef4444',
partial: '#f59e0b',
neutral: '#94a3b8',
// (v0.30, #50) engine-emitted "invasion repelled" — a muted, darker red so it
// reads as a defensive failure distinct from a bright outright `failure`.
repelled: '#9f1239',
};
/**