fertig-classic-games/tools/verifyRisk.js

214 lines
9.7 KiB
JavaScript

// Headless verification for Risk.
// node server/scripts/verifyRisk.js [--games=N]
// Exits non-zero on any failure.
//
// 1. Fixture tests: map integrity (adjacency symmetry, continents, deck),
// card-set values/validity, combat-loss bounds, reinforcement math.
// 2. Self-play: full all-AI games asserting invariants every turn (42 owned
// territories, ≥1 army each, valid phases) and that the match terminates
// with a single winner, over many seeded games.
import {
TERRITORIES, NUM_TERRITORIES, ADJ, CONTINENTS, CONTINENT_TERRITORIES,
makeDeck, setValue, isValidSet, CARD_INFANTRY, CARD_CAVALRY, CARD_ARTILLERY,
CARD_WILD,
} from '../src/games/risk/RiskData.js';
import {
createInitialState, reinforcementCount, resolveAttack, advanceArmies,
placeArmies, tradeCards, endAttack, fortify, endTurn, isGameOver,
territoriesOf, countTerritories, legalAttacks, canAttack,
} from '../src/games/risk/RiskLogic.js';
import {
chooseTrade, planReinforcements, chooseAttack, chooseAdvance, chooseFortify,
} from '../src/games/risk/RiskAI.js';
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { console.log(` ok ${name}`); return; }
failures++;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
const arg = process.argv.find((a) => a.startsWith('--games='));
const GAMES = arg ? Math.max(1, parseInt(arg.split('=')[1], 10) || 0) : 200;
// ── 1. Map integrity ────────────────────────────────────────────────────────────
console.log('Map integrity:');
check('42 territories', NUM_TERRITORIES === 42, `got ${NUM_TERRITORIES}`);
{
// adjacency symmetric, no self-loops, valid ids
let sym = true, selfLoop = false, bad = false;
for (let t = 0; t < NUM_TERRITORIES; t++) {
for (const nb of ADJ[t]) {
if (nb === t) selfLoop = true;
if (nb < 0 || nb >= NUM_TERRITORIES) bad = true;
if (!ADJ[nb].includes(t)) sym = false;
}
}
check('adjacency symmetric', sym);
check('no self-loops', !selfLoop);
check('adjacency ids valid', !bad);
// graph connected (you can reach every territory)
const seen = new Set([0]); const stack = [0];
while (stack.length) { for (const nb of ADJ[stack.pop()]) if (!seen.has(nb)) { seen.add(nb); stack.push(nb); } }
check('map fully connected', seen.size === NUM_TERRITORIES, `reached ${seen.size}`);
}
{
// each territory in exactly one continent; continents partition the map
const counts = new Array(NUM_TERRITORIES).fill(0);
for (const c of CONTINENTS) for (const t of CONTINENT_TERRITORIES[c.id]) counts[t]++;
check('continents partition map', counts.every((n) => n === 1));
const total = CONTINENT_TERRITORIES.reduce((a, ids) => a + ids.length, 0);
check('continent territory total = 42', total === 42, `got ${total}`);
}
// ── 2. Cards ────────────────────────────────────────────────────────────────────
console.log('Cards:');
{
const deck = makeDeck();
check('deck has 44 cards', deck.length === 44, `got ${deck.length}`);
check('deck has 2 wilds', deck.filter((c) => c.type === CARD_WILD).length === 2);
const T = (type) => ({ id: Math.random(), territory: null, type });
check('three different is a set', isValidSet([T(CARD_INFANTRY), T(CARD_CAVALRY), T(CARD_ARTILLERY)]));
check('three same is a set', isValidSet([T(CARD_CAVALRY), T(CARD_CAVALRY), T(CARD_CAVALRY)]));
check('wild completes a set', isValidSet([T(CARD_INFANTRY), T(CARD_INFANTRY), T(CARD_WILD)]));
check('two same + one diff is NOT a set', !isValidSet([T(CARD_INFANTRY), T(CARD_INFANTRY), T(CARD_CAVALRY)]));
check('set values escalate 4,6,8,10,12,15', [0, 1, 2, 3, 4, 5].map(setValue).join(',') === '4,6,8,10,12,15');
check('set value 6th=20, 7th=25', setValue(6) === 20 && setValue(7) === 25, `${setValue(6)},${setValue(7)}`);
}
// ── 3. Combat loss bounds ────────────────────────────────────────────────────────
console.log('Combat:');
{
// From a controlled 2-player state, run many single exchanges and check that
// per-exchange losses never exceed 2 and a conquest flips ownership.
let badLoss = false, conquestSeen = false, ownershipOk = true;
let s = createInitialState({ playerCount: 2, seed: 123456 });
// Force a known battle: seat 0 owns territory 0 with a big stack attacking nb.
const from = 0, to = ADJ[0][0];
s = { ...s, owner: s.owner.slice(), armies: s.armies.slice() };
s.owner[from] = 0; s.owner[to] = 1; s.armies[from] = 20; s.armies[to] = 5;
s.current = 0; s.phase = 'attack'; s.pendingConquest = null;
for (let i = 0; i < 200 && s.owner[to] === 1; i++) {
const beforeA = s.armies[from], beforeD = s.armies[to];
s = resolveAttack(s, from, to);
const lost = (beforeA - s.armies[from]) + (beforeD - Math.max(0, s.armies[to]));
if (lost > 2) badLoss = true;
if (s.pendingConquest) { conquestSeen = true; s = advanceArmies(s, s.pendingConquest.maxMove); }
}
check('per-exchange losses ≤ 2', !badLoss);
check('conquest occurs & flips ownership', conquestSeen && s.owner[to] === 0);
if (s.owner[to] === 0 && s.armies[to] < 1) ownershipOk = false;
check('captured territory keeps ≥1 army', ownershipOk);
}
// ── 4. Reinforcement math ────────────────────────────────────────────────────────
console.log('Reinforcements:');
{
let s = createInitialState({ playerCount: 3, seed: 99 });
// Give seat 0 all of Australia (continent 5, +2) plus enough territories.
s = { ...s, owner: s.owner.slice() };
for (let t = 0; t < NUM_TERRITORIES; t++) s.owner[t] = 1; // everything to seat 1
const aus = CONTINENT_TERRITORIES[5];
for (const t of aus) s.owner[t] = 0; // seat 0 owns Australia (4)
const base = Math.max(3, Math.floor(4 / 3)); // 3
check('floor(terr/3) min 3 + continent bonus', reinforcementCount(s, 0) === base + 2,
`got ${reinforcementCount(s, 0)}`);
}
// ── 5. Self-play ─────────────────────────────────────────────────────────────────
console.log(`Self-play (${GAMES} games):`);
const TURN_CAP = 3000;
let wins = {}, draws = 0, exceptions = 0, invariantFails = 0, longest = 0, totalTurns = 0;
function checkInvariants(s) {
let owned = 0, minArmy = Infinity;
for (let t = 0; t < NUM_TERRITORIES; t++) {
const o = s.owner[t];
if (o < 0 || o >= s.playerCount) { invariantFails++; return; }
if (!s.players[o].alive) { invariantFails++; return; }
owned++;
if (s.armies[t] < minArmy) minArmy = s.armies[t];
}
if (owned !== NUM_TERRITORIES) invariantFails++;
if (minArmy < 1) invariantFails++;
}
function playOneTurn(s) {
const seat = s.current;
const skill = s.players[seat].skill;
// reinforce: trade (forced or worthwhile), then place all armies
let g = 0;
while (s.phase === 'reinforce' && g++ < 12) {
const set = chooseTrade(s, seat, skill);
if (!set) break;
s = tradeCards(s, set);
}
for (const step of planReinforcements(s, seat, skill)) {
if (s.phase !== 'reinforce') break;
s = placeArmies(s, step.terr, step.n);
}
g = 0;
while (s.phase === 'reinforce' && g++ < 200) { // safety: dump any remainder
const mine = territoriesOf(s, seat);
s = placeArmies(s, mine[0], s.reinforcements);
}
// attack
g = 0;
while (s.phase === 'attack' && g++ < 2000) {
const atk = chooseAttack(s, seat, skill);
if (!atk) { s = endAttack(s); break; }
s = resolveAttack(s, atk.from, atk.to, atk.numDice);
if (s.pendingConquest) s = advanceArmies(s, chooseAdvance(s, seat, skill));
if (isGameOver(s)) return s;
}
if (isGameOver(s)) return s;
// fortify (and end turn)
if (s.phase === 'fortify') {
const f = chooseFortify(s, seat, skill);
s = f ? fortify(s, f.from, f.to, f.n) : endTurn(s);
}
return s;
}
for (let game = 0; game < GAMES; game++) {
const pc = 2 + (game % 5); // cycle 2..6 players
const skills = {};
for (let i = 0; i < pc; i++) skills[i] = 2 + ((game + i) % 4); // skills 2..5
try {
let s = createInitialState({ playerCount: pc, skills, seed: (game * 2654435761) | 0 });
let turns = 0;
while (!isGameOver(s) && turns < TURN_CAP) {
s = playOneTurn(s);
checkInvariants(s);
turns++;
}
totalTurns += turns;
longest = Math.max(longest, turns);
if (isGameOver(s) && s.winner != null) wins[s.winner] = (wins[s.winner] ?? 0) + 1;
else draws++; // hit the turn cap → unresolved
} catch (e) {
exceptions++;
if (exceptions <= 3) console.error(' exception:', e?.stack ?? e);
}
}
console.log(` games: ${GAMES}`);
console.log(` resolved: ${GAMES - draws - exceptions}`);
console.log(` unresolved: ${draws} (hit ${TURN_CAP}-turn cap)`);
console.log(` exceptions: ${exceptions}`);
console.log(` invariantFail:${invariantFails}`);
console.log(` avg turns: ${(totalTurns / Math.max(1, GAMES)).toFixed(1)}, longest ${longest}`);
console.log(` wins by seat: ${JSON.stringify(wins)}`);
check('no exceptions', exceptions === 0);
check('no invariant violations', invariantFails === 0);
check('most games resolve to a winner', (GAMES - draws - exceptions) >= Math.ceil(GAMES * 0.9),
`${GAMES - draws - exceptions}/${GAMES}`);
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.');
process.exit(failures ? 1 : 0);