// 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);