// verifySWDBG.js — headless checks for the Star Wars Deckbuilder engine. // Run from repo root: node tools/verifySWDBG.js // Imports only Data/Logic/AI (never the Phaser scene). Card content is read // from data/swdbg-cards.json, the same file the game loads at runtime. import { readFileSync } from 'node:fs'; import { loadCardData, getData, cardDef, baseDef, basesFor, OPS, TIMINGS, CONSTANT_OPS, FACTIONS, } from '../src/games/swdbg/SWDBGData.js'; import { newGame, makeRng, pendingDecision, takeEvents, publicView, legalActions, actTurn, actChooseBase, actChooseTarget, actOppDiscard, actSpyChoice, actChooseOption, entryAttack, forceWith, forcePegged, purchaseCost, isOver, FORCE_MAX, } from '../src/games/swdbg/SWDBGLogic.js'; import { decide } from '../src/games/swdbg/SWDBGAI.js'; const json = JSON.parse(readFileSync(new URL('../data/swdbg-cards.json', import.meta.url), 'utf8')); loadCardData(json); let pass = 0; let fail = 0; function ok(cond, msg) { if (cond) { pass++; return true; } fail++; console.error(` ✗ ${msg}`); return false; } // ── 1. Content sanity ──────────────────────────────────────────────────────── console.log('Content sanity…'); { const counts = { empire: 0, rebel: 0, neutral: 0 }; let total = 0; const checkAbility = (id, ab, allowed = TIMINGS) => { if (!ab) return; ok(allowed.includes(ab.timing || 'action') || ab.timing === undefined, `${id}: ability timing valid`); const ops = ab.op === 'choose' ? ab.options.map((o) => o.op) : [ab.op]; for (const op of ops) ok(OPS.includes(op), `${id}: op ${op} supported`); if (ab.timing === 'constant') ok(CONSTANT_OPS.includes(ab.op), `${id}: constant op is passive`); if (ab.timing === 'action' || !ab.timing) ok(!CONSTANT_OPS.includes(ab.op), `${id}: action op is active`); }; for (const def of json.cards) { total += def.count; counts[def.faction] += def.count; ok(FACTIONS.includes(def.faction) || def.faction === 'neutral', `${def.id}: faction valid`); ok(['unit', 'capital'].includes(def.type), `${def.id}: type valid`); ok(def.cost >= 0 && def.count >= 1, `${def.id}: cost/count sane`); ok(!!def.name && !!def.text, `${def.id}: name/text present`); if (def.type === 'capital') { ok(def.hp > 0, `${def.id}: capital has hp`); ok(def.target == null, `${def.id}: capital ships cannot be bounty targets`); } if (def.faction === 'neutral') { ok(def.target == null && def.reward == null, `${def.id}: neutral has no target/reward`); } else if (def.type === 'unit') { ok(def.target != null && def.target > 0, `${def.id}: faction unit has a target value`); ok(!!def.reward, `${def.id}: faction unit has a reward`); } if (def.unique) ok(def.count === 1, `${def.id}: unique is a 1-of`); checkAbility(def.id, def.ability); } ok(total === 90, `galaxy deck is 90 cards (got ${total})`); ok(counts.empire === 30, `30 Empire cards (got ${counts.empire})`); ok(counts.rebel === 30, `30 Rebel cards (got ${counts.rebel})`); ok(counts.neutral === 30, `30 neutral cards (got ${counts.neutral})`); for (const f of FACTIONS) { const starters = json.starters[f].reduce((a, s) => a + s.count, 0); ok(starters === 10, `${f} starter deck is 10 cards`); for (const s of json.starters[f]) ok(!!cardDef(s.id), `${f} starter ${s.id} defined`); const bases = basesFor(f); ok(bases.length >= 5, `${f} has at least 5 bases`); ok(bases.filter((b) => b.starting).length === 1, `${f} has exactly one starting base`); for (const b of bases) { ok(b.hp > 0 && !!b.name && !!b.text, `${f} base ${b.id} fields`); checkAbility(`base ${b.id}`, b.ability, ['action', 'constant', 'onReveal']); } } ok(json.outerRimPilot.count === 10, 'Outer Rim Pilot stack is 10'); checkAbility('outerrimpilot', json.outerRimPilot.ability); ok(json.meta.basesToWin === 3 && json.meta.handSize === 5 && json.meta.galaxyRowSize === 6, 'meta matches the beginner rules'); } // ── 2. Setup invariants ────────────────────────────────────────────────────── console.log('Setup invariants…'); { for (const faction of FACTIONS) { const st = newGame({ humanFaction: faction, seed: 424242 }); ok(st.players[0].faction === faction, `human plays ${faction}`); ok(st.players[1].faction !== faction, 'AI plays the other side'); ok(st.players.every((p) => p.hand.length === 5), 'both draw 5'); ok(st.players.every((p) => p.deck.length === 5), '5 left in each deck'); ok(st.galaxy.row.length === 6, 'galaxy row is 6'); ok(st.galaxy.deck.length === 84, '84 cards left in the galaxy deck'); ok(st.outerRim.length === 10, '10 Outer Rim Pilots'); ok(st.force === FORCE_MAX, 'Force starts pegged to the Rebel side'); ok(st.players[st.turnSeat].faction === 'empire', 'Empire takes the first turn'); ok(st.players.every((p) => baseDef(p.faction, p.base.id).starting), 'both start on their starting base'); const d = pendingDecision(st); ok(d && d.kind === 'turn' && d.seat === st.turnSeat, 'first decision is the Empire turn'); const rebelSeat = st.players.findIndex((p) => p.faction === 'rebel'); ok(forceWith(st, rebelSeat) && forcePegged(st, rebelSeat), 'the Force is with the Rebels at setup'); ok(!forceWith(st, 1 - rebelSeat), 'and not with the Empire'); } } // ── AI driver ──────────────────────────────────────────────────────────────── function driveDecision(st, d, skill, rnd, tally = null) { const view = publicView(st, d.seat); const choice = decide(view, d, skill, rnd); switch (d.kind) { case 'turn': if (tally) tally[choice.type] = (tally[choice.type] || 0) + 1; actTurn(st, d.seat, choice); break; case 'chooseBase': actChooseBase(st, d.seat, choice); break; case 'target': actChooseTarget(st, d.seat, choice); break; case 'oppDiscard': actOppDiscard(st, d.seat, choice); break; case 'spyChoice': actSpyChoice(st, d.seat, choice); break; case 'chooseOption': actChooseOption(st, d.seat, choice); break; default: throw new Error(`unknown decision kind ${d.kind}`); } } function countAll(st) { // Every card instance must live in exactly one zone. let n = st.galaxy.deck.length + st.galaxy.row.length + st.galaxy.discard.length + st.outerRim.length + st.exile.length; for (const p of st.players) { n += p.deck.length + p.hand.length + p.discard.length + p.inPlay.length + p.capitals.length; } return n; } function checkInvariants(st, seed) { if (st.force < -FORCE_MAX || st.force > FORCE_MAX) throw new Error(`game ${seed}: force off track (${st.force})`); for (const p of st.players) { if (p.resources < 0) throw new Error(`game ${seed}: negative resources`); if (p.base) { const def = baseDef(p.faction, p.base.id); if (p.base.damage >= def.hp) throw new Error(`game ${seed}: destroyed base still in play`); } if (!st.over && p.lostBases >= st.meta.basesToWin) throw new Error(`game ${seed}: lost ${p.lostBases} bases but game continues`); for (const e of p.capitals) { if (e.damage >= cardDef(e.card).hp) throw new Error(`game ${seed}: destroyed capital still in play`); } } } function playGame(seed, skills, collect = null) { const st = newGame({ humanFaction: seed % 2 ? 'rebel' : 'empire', seed }); const rnd = makeRng(seed ^ 0x5eed); const expectTotal = countAll(st); const tally = {}; let steps = 0; while (!isOver(st)) { if (++steps > 40000) throw new Error(`game ${seed} exceeded step guard (turn ${st.turnCount})`); const d = pendingDecision(st); if (!d) throw new Error(`game ${seed}: no decision but game not over (turn ${st.turnCount})`); driveDecision(st, d, skills[d.seat] ?? 3, rnd, tally); checkInvariants(st, seed); if (countAll(st) !== expectTotal) throw new Error(`game ${seed}: card count drifted (${countAll(st)} != ${expectTotal})`); if (collect !== null) collect.push(...takeEvents(st).map((e) => JSON.stringify(e))); else takeEvents(st); } return { st, tally, steps }; } // ── 3. Determinism ─────────────────────────────────────────────────────────── console.log('Determinism…'); { const logA = []; const logB = []; playGame(777, [3, 3], logA); playGame(777, [3, 3], logB); ok(logA.length === logB.length && logA.every((e, i) => e === logB[i]), `same seed reproduces the same event log (${logA.length} events)`); } // ── 4. Self-play soak ──────────────────────────────────────────────────────── console.log('Self-play soak (300 games)…'); { let games = 0; let totalTurns = 0; const tally = {}; const winsByFaction = { empire: 0, rebel: 0 }; let maxTurns = 0; for (let g = 0; g < 300; g++) { const skills = [1 + (g % 5), 1 + ((g + 2) % 5)]; const { st, tally: t } = playGame(1000 + g * 7, skills); games++; totalTurns += st.turnCount; maxTurns = Math.max(maxTurns, st.turnCount); for (const [k, v] of Object.entries(t)) tally[k] = (tally[k] || 0) + v; ok(st.winner != null, `game ${g}: has a winner`); const w = st.players[st.winner]; const l = st.players[1 - st.winner]; ok(l.lostBases >= st.meta.basesToWin, `game ${g}: loser lost ${st.meta.basesToWin}+ bases`); ok(w.lostBases < st.meta.basesToWin, `game ${g}: winner kept a base standing`); winsByFaction[w.faction]++; } console.log(` ${games} games · avg turns ${(totalTurns / games).toFixed(1)} (max ${maxTurns})` + ` · wins ${JSON.stringify(winsByFaction)}` + ` · actions ${JSON.stringify(tally)}`); ok(tally.buy > games * 3, 'AI buys cards'); ok((tally.attackRow || 0) > games * 0.3, 'AI bounty hunts / sabotages'); ok((tally.ability || 0) > games, 'AI uses abilities'); ok(totalTurns / games < 80, 'games end in a reasonable number of turns'); const skew = Math.abs(winsByFaction.empire - winsByFaction.rebel) / games; ok(skew < 0.35, `faction win rates not wildly skewed (skew ${(skew * 100).toFixed(0)}%)`); } // Skill gradient: a 5 should beat a 1 far more often than not. console.log('Skill gradient (80 games, 5 vs 1)…'); { let strongWins = 0; for (let g = 0; g < 80; g++) { const strongSeat = g % 2; const skills = strongSeat === 0 ? [5, 1] : [1, 5]; const { st } = playGame(9000 + g * 13, skills); if (st.winner === strongSeat) strongWins++; } console.log(` skill-5 wins ${strongWins}/80`); ok(strongWins >= 52, `skill 5 dominates skill 1 (${strongWins}/80)`); } // ── 5. Scripted rule spot-checks ───────────────────────────────────────────── console.log('Rule spot-checks…'); function freshGame(seed = 31337) { return newGame({ humanFaction: 'rebel', seed }); } function seatOf(st, faction) { return st.players.findIndex((p) => p.faction === faction); } function give(st, seat, id) { // Inject a known card into a player's play area for scripted checks. const inst = { uid: 900000 + Math.floor(Math.random() * 99999), id }; const entry = { card: inst, tempAttack: 0, used: false, committed: false }; if (cardDef(id).type === 'capital') { entry.damage = 0; st.players[seat].capitals.push(entry); } else st.players[seat].inPlay.push(entry); return entry; } { // Faction purchase restriction: Empire cannot buy Rebel cards, anyone buys neutral. const st = freshGame(101); const emp = seatOf(st, 'empire'); st.turnSeat = emp; st.players[emp].resources = 20; const rebelRow = st.galaxy.row.find((c) => cardDef(c).faction === 'rebel'); if (rebelRow) { let threw = false; try { actTurn(st, emp, { type: 'buy', uid: rebelRow.uid }); } catch { threw = true; } ok(threw, 'Empire cannot purchase a Rebel card'); } const legal = legalActions(st, emp); ok(legal.buys.every((b) => { if (b.uid === 'outerrim') return true; const f = cardDef(st.galaxy.row.find((c) => c.uid === b.uid)).faction; return f === 'empire' || f === 'neutral'; }), 'legal buys are own-faction or neutral only'); ok(legal.buys.some((b) => b.uid === 'outerrim'), 'Outer Rim Pilot is always purchasable'); } { // Capital ships absorb an attack before the base; damage persists. const st = freshGame(202); const emp = seatOf(st, 'empire'); const reb = 1 - emp; st.turnSeat = reb; st.players[reb].resources = 0; const cap = give(st, emp, 'gozanticruiser'); // hp 3 const atk = give(st, reb, 'quarrenmercenary'); // 4 attack const baseBefore = st.players[emp].base.damage; actTurn(st, reb, { type: 'attackBase', uids: [atk.card.uid] }); ok(st.players[emp].capitals.length === 0, 'capital destroyed by the attack'); ok(st.players[emp].discard.some((c) => c.uid === cap.card.uid), 'destroyed capital goes to owner discard'); ok(st.players[emp].base.damage === baseBefore + 1, 'excess damage rolls onto the base'); // A second attacker cannot re-commit the same unit. let threw = false; try { actTurn(st, reb, { type: 'attackBase', uids: [atk.card.uid] }); } catch { threw = true; } ok(threw, 'a committed unit cannot attack twice'); } { // Direct ability damage ignores capital ships; base destruction wins at 3. const st = freshGame(303); const emp = seatOf(st, 'empire'); const reb = 1 - emp; st.turnSeat = emp; give(st, reb, 'moncalamaricruiser'); // big capital that would otherwise absorb const bomber = give(st, emp, 'tiebomber'); st.force = -FORCE_MAX; // Force with the Empire → bomber deals 2 const dmgBefore = st.players[reb].base.damage; actTurn(st, emp, { type: 'ability', zone: 'play', uid: bomber.card.uid }); ok(st.players[reb].base.damage === dmgBefore + 2, 'ability damage hits the base directly (Force-boosted)'); ok(st.players[reb].capitals.length === 1, 'capital untouched by direct damage'); st.players[reb].base.damage = baseDef('rebel', st.players[reb].base.id).hp - 1; st.players[reb].lostBases = 2; const bomber2 = give(st, emp, 'tiebomber'); actTurn(st, emp, { type: 'ability', zone: 'play', uid: bomber2.card.uid }); ok(st.over && st.winner === emp, 'third base destroyed ends the game immediately'); } { // Bounty hunting: target value gate, rewards, and galaxy discard. const st = freshGame(404); const reb = seatOf(st, 'rebel'); st.turnSeat = reb; // Put a known Empire unit in the row. const target = { uid: 888001, id: 'deathtrooper' }; // target 4, reward 2 resources st.galaxy.row[0] = target; const a1 = give(st, reb, 'rebelcommando'); // 3 attack let threw = false; try { actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [a1.card.uid] }); } catch { threw = true; } ok(threw, 'attack below target value is rejected'); const a2 = give(st, reb, 'xwing'); // 3 attack const resBefore = st.players[reb].resources; actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [a1.card.uid, a2.card.uid] }); ok(st.galaxy.discard.some((c) => c.uid === target.uid), 'defeated card goes to the galaxy discard'); ok(st.players[reb].resources === resBefore + 2, 'bounty reward granted'); ok(st.galaxy.row.length === 6, 'row refilled after the bounty'); ok(a1.committed && a2.committed, 'bounty attackers are committed'); } { // Constant boosts: Imperial Carrier aura + TIE swarm + Rodian row bonus. const st = freshGame(505); const emp = seatOf(st, 'empire'); give(st, emp, 'imperialcarrier'); const tie1 = give(st, emp, 'tiefighter'); const tie2 = give(st, emp, 'tiefighter'); // TIE: 1 base + 1 swarm (another Fighter) + 1 carrier aura = 3 ok(entryAttack(st, emp, tie1) === 3, `TIE Fighter attack with swarm+carrier is 3 (got ${entryAttack(st, emp, tie1)})`); const rodian = give(st, emp, 'rodiangunslinger'); ok(entryAttack(st, emp, rodian) === 2, 'Rodian is 2 attack vs bases'); ok(entryAttack(st, emp, rodian, { vsRow: true }) === 4, 'Rodian is 4 attack vs the galaxy row'); } { // Force mechanics: pegged bonus resource, spy choice restrictions. const st = freshGame(606); const emp = seatOf(st, 'empire'); st.force = -FORCE_MAX; st.turnSeat = 1 - emp; // let empire's beginTurn run via endTurn actTurn(st, 1 - emp, { type: 'endTurn' }); ok(st.players[emp].resources >= 1, 'pegged Force grants a start-of-turn resource'); // Duros Spy with victim hand empty auto-resolves to Force gain. const st2 = freshGame(607); const reb2 = seatOf(st2, 'rebel'); st2.turnSeat = reb2; st2.force = 0; st2.players[1 - reb2].hand = []; const spy = give(st2, reb2, 'durosspy'); actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: spy.card.uid }); ok(st2.force === 1, 'Duros Spy auto-resolves to Force when victim hand is empty'); } { // End of turn: units + hand discarded, capitals persist, hand refills to 5. const st = freshGame(707); const emp = seatOf(st, 'empire'); st.turnSeat = emp; const p = st.players[emp]; give(st, emp, 'stardestroyer'); actTurn(st, emp, { type: 'playAll' }); const played = p.inPlay.length; ok(played > 0, 'units in play before end of turn'); actTurn(st, emp, { type: 'endTurn' }); ok(p.inPlay.length === 0, 'units discarded at end of turn'); ok(p.capitals.length === 1, 'capital ship stays in play'); ok(p.hand.length === 5, 'hand refilled to 5'); ok(p.resources === 0, 'unspent resources returned'); ok(st.turnSeat === 1 - emp, 'turn passes'); } console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);