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/);
|
||||
});
|
||||
});
|
||||
|
||||
+111
-2
@@ -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) -----------------------------------------------------
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user