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
This commit is contained in:
2026-10-15 08:24:03 +00:00
parent 44e54470e2
commit a93ece9474
9 changed files with 418 additions and 5 deletions
+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/);
});
});