feat(#50): make the four 0-100 stats consequential
CI / build + unit + e2e (pull_request) Successful in 2m8s
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:
+201
-1
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user