// 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, and is // cross-checked against the owner's transcription of the physical cards in // assets/info/sw-force-cards.csv. 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, actOppChoice, 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; } const COST_KEYS = ['exileSelf', 'exileFromHand', 'discardFromHand']; const GATE_KEYS = ['requiresForce', 'requiresCapital', 'requiresOppCapital', 'requiresTraitInPlay', 'requiresOtherHero']; const OPPCHOICE_KINDS = ['discardSelf', 'giveForce', 'attackBoost']; // Every op reachable inside an ability (choose options, multi effects, revealTop onMine). function opsIn(ab) { if (!ab) return []; let out = [ab.op]; if (ab.op === 'choose') for (const o of ab.options || []) out = out.concat(opsIn(o)); if (ab.op === 'multi') for (const o of ab.effects || []) out = out.concat(opsIn(o)); if (ab.op === 'revealTop' && ab.onMine) out = out.concat(opsIn(ab.onMine)); return out; } // ── 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`); for (const op of opsIn(ab)) 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`); if (ab.cost) ok(Object.keys(ab.cost).every((k) => COST_KEYS.includes(k)), `${id}: cost keys valid`); ok(Object.keys(ab).filter((k) => k.startsWith('requires')).every((k) => GATE_KEYS.includes(k)), `${id}: gate keys valid`); if (ab.op === 'oppChoice') ok((ab.options || []).every((o) => OPPCHOICE_KINDS.includes(o.kind)), `${id}: oppChoice kinds valid`); }; 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. CSV cross-check (owner's transcription of the physical cards) ──────── console.log('CSV cross-check…'); { const csvText = readFileSync(new URL('../assets/info/sw-force-cards.csv', import.meta.url), 'utf8'); const rows = []; { let row = [], field = '', inQ = false; for (let i = 0; i < csvText.length; i++) { const ch = csvText[i]; if (inQ) { if (ch === '"') { if (csvText[i + 1] === '"') { field += '"'; i++; } else inQ = false; } else field += ch; } else if (ch === '"') inQ = true; else if (ch === ',') { row.push(field); field = ''; } else if (ch === '\n' || ch === '\r') { if (ch === '\r' && csvText[i + 1] === '\n') i++; row.push(field); field = ''; if (row.some((c) => c.trim() !== '')) rows.push(row); row = []; } else field += ch; } row.push(field); if (row.some((c) => c.trim() !== '')) rows.push(row); } const header = rows.shift().map((h) => h.trim()); ok(header[0] === 'Title' && header.length === 14, `CSV header shape (${header.length} cols)`); const norm = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]/g, ''); const ALIASES = { gonzanticruiser: 'gozanticruiser' }; // CSV typo for Gozanti const byNorm = new Map(); for (const def of json.cards) byNorm.set(norm(def.name), def); const factionMap = { neutral: 'neutral', rebelalliance: 'rebel', empire: 'empire' }; const parseReward = (s) => { const t = (s || '').trim(); if (!t) return null; if (/^purchase/i.test(t)) return { freePurchase: true }; if (/^exile/i.test(t)) { const m = t.match(/(\d+)/); return { exile: m ? +m[1] : 1 }; } const out = {}; const res = t.match(/(\d+)\s*Resources?/i); if (res) out.resources = +res[1]; const force = t.match(/(\d+)\s*Force/i); if (force) out.force = +force[1]; return Object.keys(out).length ? out : null; }; const normReward = (r) => { if (!r) return 'null'; const o = {}; for (const k of ['resources', 'force', 'draw', 'exile', 'freePurchase']) if (r[k]) o[k] = r[k]; return JSON.stringify(o); }; const seen = new Set(); for (const r of rows) { let [title, cost, attack, resources, force, type, trait, ability, bounty, bountyReward, count, hp, aligned, hero] = r.map((c) => c.trim()); // Known transcription quirk: the Imperial Carrier row is shifted one cell — // its ability text sits in the Trait column. if (!ability && trait.length > 30) { ability = trait; trait = ''; } const key = ALIASES[norm(title)] || norm(title); const def = byNorm.get(key) || json.cards.find((c) => c.id === key); if (!ok(!!def, `CSV "${title}" exists in swdbg-cards.json`)) continue; seen.add(def.id); const num = (v) => (v === '' ? 0 : +v); ok(def.cost === num(cost), `${def.id}: cost ${def.cost} matches CSV ${cost}`); ok((def.attack || 0) === num(attack), `${def.id}: attack matches CSV`); ok((def.resources || 0) === num(resources), `${def.id}: resources matches CSV`); ok((def.force || 0) === num(force), `${def.id}: force matches CSV`); ok(def.type === (type === 'Capital Ship' ? 'capital' : 'unit'), `${def.id}: type matches CSV`); ok(def.count === num(count), `${def.id}: count ${def.count} matches CSV ${count}`); ok((def.hp ?? null) === (hp === '' ? null : +hp), `${def.id}: hp matches CSV`); ok((def.target ?? null) === (bounty === '' ? null : +bounty), `${def.id}: bounty value matches CSV`); ok(def.faction === factionMap[norm(aligned)], `${def.id}: faction matches CSV`); ok(!!def.unique === (hero.toLowerCase() === 'yes'), `${def.id}: hero flag matches CSV`); ok(normReward(def.reward) === normReward(parseReward(bountyReward)), `${def.id}: bounty reward matches CSV (${bountyReward || 'none'})`); ok((ability === '') === !def.ability, `${def.id}: ability presence matches CSV`); if (def.type === 'unit' && trait !== '' && ability !== trait) { const want = trait.split(',').map((t) => t.trim()).sort().join('|'); const have = (def.traits || []).slice().sort().join('|'); ok(want === have, `${def.id}: traits [${have}] match CSV [${want}]`); } } ok(seen.size === json.cards.length, `every JSON galaxy card appears in the CSV (${seen.size}/${json.cards.length})`); ok(rows.length === json.cards.length, `CSV row count matches card count (${rows.length})`); } // ── 3. 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 'oppChoice': actOppChoice(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 }; } // ── 4. 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)`); } // ── 5. 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)`); } // ── 6. 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); } let giveUid = 900000; function give(st, seat, id) { // Inject a known card into a player's play area for scripted checks. const inst = { uid: ++giveUid, 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; } function plantRow(st, slot, id) { const inst = { uid: ++giveUid, id }; st.galaxy.deck.push(st.galaxy.row[slot]); // keep total card count honest st.galaxy.row[slot] = inst; return inst; } { // 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'); } { // Y-Wing dealDamage: exile the unit, then damage the base OR a capital. const st = freshGame(303); const reb = seatOf(st, 'rebel'); const emp = 1 - reb; st.turnSeat = reb; const cap = give(st, emp, 'gozanticruiser'); // hp 3 const y1 = give(st, reb, 'ywing'); actTurn(st, reb, { type: 'ability', zone: 'play', uid: y1.card.uid }); ok(st.exile.some((c) => c.uid === y1.card.uid), 'Y-Wing exiles itself as the cost'); let d = pendingDecision(st); ok(d.kind === 'target' && d.op === 'dealDamage', 'Y-Wing queues a damage target choice'); ok(d.candidates.some((c) => c.kind === 'oppBase') && d.candidates.some((c) => c.kind === 'capital'), 'both the enemy base and their capital are candidates'); actChooseTarget(st, reb, d.candidates.find((c) => c.kind === 'capital')); ok(cap.damage === 2, 'capital takes 2 damage and survives'); const y2 = give(st, reb, 'ywing'); const dmgBefore = st.players[emp].base.damage; actTurn(st, reb, { type: 'ability', zone: 'play', uid: y2.card.uid }); d = pendingDecision(st); actChooseTarget(st, reb, d.candidates.find((c) => c.kind === 'oppBase')); ok(st.players[emp].base.damage === dmgBefore + 2, 'base takes 2 damage when chosen'); // Base destruction wins at 3 lost bases. st.players[emp].base.damage = baseDef('empire', st.players[emp].base.id).hp - 1; st.players[emp].lostBases = 2; st.players[emp].capitals = []; const trooper = give(st, reb, 'rebeltrooper'); actTurn(st, reb, { type: 'attackBase', uids: [trooper.card.uid] }); ok(st.over && st.winner === reb, '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; const target = plantRow(st, 0, 'landingcraft'); // target 4, reward 4 resources 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 + 4, '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 (Fighter trait) + Rodian row bonus. const st = freshGame(505); const emp = seatOf(st, 'empire'); give(st, emp, 'imperialcarrier'); const tie1 = give(st, emp, 'tiefighter'); // TIE Fighter: 2 base + 1 carrier aura = 3 ok(entryAttack(st, emp, tie1) === 3, `TIE Fighter attack with carrier aura 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'); } { // Admiral Piett: typeBoost gives every friendly capital ship +1 attack. const st = freshGame(506); const emp = seatOf(st, 'empire'); const sd = give(st, emp, 'stardestroyer'); // 4 attack ok(entryAttack(st, emp, sd) === 4, 'Star Destroyer base attack is 4'); give(st, emp, 'admiralpiett'); ok(entryAttack(st, emp, sd) === 5, 'Piett boosts capital ships to 5'); const tie = give(st, emp, 'tiefighter'); ok(entryAttack(st, emp, tie) === 2, 'Piett does not boost units'); } { // Baze Malbus: +1 attack per base you have lost. const st = freshGame(507); const reb = seatOf(st, 'rebel'); const baze = give(st, reb, 'bazemalbus'); ok(entryAttack(st, reb, baze) === 2, 'Baze starts at 2 attack'); st.players[reb].lostBases = 2; ok(entryAttack(st, reb, baze) === 4, 'Baze gains 1 attack per lost base'); } { // TIE Fighter: action ability gated on having a capital ship in play. const st = freshGame(508); const emp = seatOf(st, 'empire'); const tie = give(st, emp, 'tiefighter'); let threw = false; try { actTurn(st, emp, { type: 'ability', zone: 'play', uid: tie.card.uid }); } catch { threw = true; } ok(threw, 'TIE Fighter draw ability unavailable without a capital ship in play'); give(st, emp, 'gozanticruiser'); const handBefore = st.players[emp].hand.length; actTurn(st, emp, { type: 'ability', zone: 'play', uid: tie.card.uid }); ok(st.players[emp].hand.length === handBefore + 1, 'TIE Fighter draws a card once a capital ship is in play'); } { // Z-95 Headhunter: gated on the OPPONENT having a capital ship in play. const st = freshGame(510); const reb = seatOf(st, 'rebel'); const emp = 1 - reb; st.turnSeat = reb; const z = give(st, reb, 'z95headhunter'); give(st, reb, 'moncalamaricruiser'); // own capital must NOT satisfy the gate let threw = false; try { actTurn(st, reb, { type: 'ability', zone: 'play', uid: z.card.uid }); } catch { threw = true; } ok(threw, 'Z-95 unusable while the opponent has no capital ship'); give(st, emp, 'gozanticruiser'); const handBefore = st.players[reb].hand.length; actTurn(st, reb, { type: 'ability', zone: 'play', uid: z.card.uid }); ok(st.players[reb].hand.length === handBefore + 1, 'Z-95 draws once the opponent has a capital ship'); } { // General Veers: needs a Trooper or Vehicle in play. Chewbacca: another Hero unit. const st = freshGame(511); const emp = seatOf(st, 'empire'); st.turnSeat = emp; const veers = give(st, emp, 'generalveers'); // Officer — doesn't satisfy itself let threw = false; try { actTurn(st, emp, { type: 'ability', zone: 'play', uid: veers.card.uid }); } catch { threw = true; } ok(threw, 'Veers unusable without a Trooper or Vehicle in play'); give(st, emp, 'atst'); // Vehicle const handBefore = st.players[emp].hand.length; actTurn(st, emp, { type: 'ability', zone: 'play', uid: veers.card.uid }); ok(st.players[emp].hand.length === handBefore + 1, 'Veers draws with a Vehicle in play'); const st2 = freshGame(512); const reb2 = seatOf(st2, 'rebel'); st2.turnSeat = reb2; const chewie = give(st2, reb2, 'chewbacca'); let threw2 = false; try { actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: chewie.card.uid }); } catch { threw2 = true; } ok(threw2, 'Chewbacca unusable without another Hero unit'); give(st2, reb2, 'jynerso'); const hb2 = st2.players[reb2].hand.length; actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: chewie.card.uid }); ok(st2.players[reb2].hand.length === hb2 + 1, 'Chewbacca draws with another Hero unit in play'); } { // Han Solo: draw 1, or 2 with the Millennium Falcon in play. const st = freshGame(513); const reb = seatOf(st, 'rebel'); st.turnSeat = reb; const han = give(st, reb, 'hansolo'); let hb = st.players[reb].hand.length; actTurn(st, reb, { type: 'ability', zone: 'play', uid: han.card.uid }); ok(st.players[reb].hand.length === hb + 1, 'Han draws 1 without the Falcon'); give(st, reb, 'millenniumfalcon'); const han2 = give(st, reb, 'hansolo'); hb = st.players[reb].hand.length; actTurn(st, reb, { type: 'ability', zone: 'play', uid: han2.card.uid }); ok(st.players[reb].hand.length === hb + 2, 'Han draws 2 with the Falcon in play'); } { // Cassian Andor: onBounty trigger fires only when he joins the attack. const st = freshGame(509); const reb = seatOf(st, 'rebel'); const emp = 1 - reb; st.turnSeat = reb; const cassian = give(st, reb, 'cassianandor'); // 5 attack const target = plantRow(st, 0, 'tiefighter'); // target 1 st.players[emp].hand = [{ uid: 'h1', id: 'stormtrooper' }]; actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [cassian.card.uid] }); const d = pendingDecision(st); ok(d.kind === 'oppDiscard' && d.seat === emp, "Cassian Andor's onBounty queues a forced opponent discard"); actOppDiscard(st, emp, 'h1'); ok(st.players[emp].hand.length === 0, 'the forced discard resolves'); } { // onBounty participation: a bystander bounty hunter does NOT trigger. const st = freshGame(514); const reb = seatOf(st, 'rebel'); st.turnSeat = reb; st.force = 0; give(st, reb, 'bossk'); // onBounty: gain 1 Force — but he stays home const commando = give(st, reb, 'rebelcommando'); // 3 attack const t1 = plantRow(st, 0, 'tiefighter'); actTurn(st, reb, { type: 'attackRow', targetUid: t1.uid, uids: [commando.card.uid] }); ok(st.force === 0, "Bossk doesn't trigger when he didn't join the attack"); const bossk2 = give(st, reb, 'bossk'); const t2 = plantRow(st, 1, 'tiefighter'); actTurn(st, reb, { type: 'attackRow', targetUid: t2.uid, uids: [bossk2.card.uid] }); ok(st.force === 1, 'Bossk gains 1 Force when he defeats the target himself'); } { // Bounty rewards: exile decisions and a free own-faction purchase. const st = freshGame(515); const emp = seatOf(st, 'empire'); st.turnSeat = emp; const snow = plantRow(st, 0, 'snowspeeder'); // rebel, target 2, reward exile 1 const trooper = give(st, emp, 'stormtrooper'); // 2 attack actTurn(st, emp, { type: 'attackRow', targetUid: snow.uid, uids: [trooper.card.uid] }); let d = pendingDecision(st); ok(d.kind === 'target' && d.op === 'exileCards' && d.optional, 'exile bounty reward queues an optional exile'); actChooseTarget(st, emp, null); // decline const st2 = freshGame(516); const reb2 = seatOf(st2, 'rebel'); st2.turnSeat = reb2; const atat = plantRow(st2, 0, 'atat'); // empire, target 6, reward free purchase plantRow(st2, 1, 'xwing'); // guarantee a rebel candidate in the row const c1 = give(st2, reb2, 'chewbacca'); // 5 const c2 = give(st2, reb2, 'ywing'); // 2 const resBefore = st2.players[reb2].resources; actTurn(st2, reb2, { type: 'attackRow', targetUid: atat.uid, uids: [c1.card.uid, c2.card.uid] }); d = pendingDecision(st2); ok(d.kind === 'target' && d.op === 'freePurchase', 'free-purchase bounty reward queues a row pick'); ok(d.candidates.every((c) => cardDef(st2.galaxy.row.find((x) => x.uid === c.uid)).faction === 'rebel'), 'free purchase offers own-faction cards only'); const pick = d.candidates[0]; const pickId = st2.galaxy.row.find((x) => x.uid === pick.uid).id; actChooseTarget(st2, reb2, pick); ok(st2.players[reb2].resources === resBefore, 'free purchase costs nothing'); ok(st2.players[reb2].discard.some((c) => c.id === pickId), 'free purchase lands in the discard'); } { // Duros Spy oppChoice: victim with an empty hand auto-resolves to Force gain. const st = freshGame(607); const reb = seatOf(st, 'rebel'); st.turnSeat = reb; st.force = 0; st.players[1 - reb].hand = []; const spy = give(st, reb, 'durosspy'); actTurn(st, reb, { type: 'ability', zone: 'play', uid: spy.card.uid }); ok(st.force === 1, 'Duros Spy auto-resolves to Force when victim hand is empty'); // B-Wing oppChoice: the victim may concede +2 attack instead of discarding. const st2 = freshGame(608); const reb2 = seatOf(st2, 'rebel'); const emp2 = 1 - reb2; st2.turnSeat = reb2; const bwing = give(st2, reb2, 'bwing'); actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: bwing.card.uid }); const d = pendingDecision(st2); ok(d.kind === 'oppChoice' && d.seat === emp2 && d.options.length === 2, 'B-Wing forces an opponent choice'); const boostIdx = d.options.findIndex((o) => o.kind === 'attackBoost'); actOppChoice(st2, emp2, boostIdx); ok(entryAttack(st2, reb2, bwing) === 7, 'B-Wing gains 2 attack when the opponent concedes (5+2)'); } { // Lando: draw 1 always; opponent discards only while the Force is with you. const st = freshGame(609); // Force starts with the Rebels const reb = seatOf(st, 'rebel'); const emp = 1 - reb; st.turnSeat = reb; const lando = give(st, reb, 'landocalrissian'); const hb = st.players[reb].hand.length; actTurn(st, reb, { type: 'ability', zone: 'play', uid: lando.card.uid }); ok(st.players[reb].hand.length === hb + 1, 'Lando draws 1'); const d = pendingDecision(st); ok(d.kind === 'oppDiscard' && d.seat === emp, 'with the Force, Lando also forces a discard'); actOppDiscard(st, emp, st.players[emp].hand[0].uid); const st2 = freshGame(610); const reb2 = seatOf(st2, 'rebel'); st2.turnSeat = reb2; st2.force = 0; // Force with no one const lando2 = give(st2, reb2, 'landocalrissian'); const hb2 = st2.players[reb2].hand.length; const oppHand = st2.players[1 - reb2].hand.length; actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: lando2.card.uid }); ok(st2.players[reb2].hand.length === hb2 + 1 && st2.players[1 - reb2].hand.length === oppHand, 'without the Force, Lando only draws'); } { // C-ROC Cruiser: discard a hand card to repair 3. const st = freshGame(611); const reb = seatOf(st, 'rebel'); st.turnSeat = reb; const p = st.players[reb]; p.base.damage = 5; const croc = give(st, reb, 'crocruiser'); const costCard = p.hand[0]; const hb = p.hand.length; actTurn(st, reb, { type: 'ability', zone: 'capital', uid: croc.card.uid, costUid: costCard.uid }); ok(p.base.damage === 2, 'C-ROC repairs 3 damage'); ok(p.hand.length === hb - 1 && p.discard.some((c) => c.uid === costCard.uid), 'the cost card is discarded, not exiled'); } { // Twi'lek Smuggler: the next purchase this turn goes on top of the deck. const st = freshGame(612); const reb = seatOf(st, 'rebel'); st.turnSeat = reb; const p = st.players[reb]; p.resources = 20; const twilek = give(st, reb, 'twileksmuggler'); actTurn(st, reb, { type: 'ability', zone: 'play', uid: twilek.card.uid }); ok(p.topdeckNext === true, 'topdeck flag armed'); const buy = legalActions(st, reb).buys.find((b) => b.uid !== 'outerrim'); const buyId = st.galaxy.row.find((c) => c.uid === buy.uid).id; actTurn(st, reb, { type: 'buy', uid: buy.uid }); ok(p.deck[p.deck.length - 1].id === buyId, 'the purchase lands on top of the deck'); ok(p.topdeckNext === false, 'flag consumed by the purchase'); } { // Fang Fighter: purchased to hand; +1 draw while the Force is with you. const st = freshGame(613); // Force pegged Rebel const reb = seatOf(st, 'rebel'); st.turnSeat = reb; const p = st.players[reb]; p.resources = 3; const fang = plantRow(st, 0, 'fangfighter'); const hb = p.hand.length; actTurn(st, reb, { type: 'buy', uid: fang.uid }); ok(p.hand.some((c) => c.uid === fang.uid), 'Fang Fighter joins the hand'); ok(p.hand.length === hb + 2, 'and draws a card with the Force'); ok(!p.discard.some((c) => c.uid === fang.uid), 'it does not touch the discard'); } { // Quarren Mercenary: on purchase, exile up to 1 (2 with the Force). const st = freshGame(614); const reb = seatOf(st, 'rebel'); st.turnSeat = reb; st.players[reb].resources = 4; const q = plantRow(st, 0, 'quarrenmercenary'); actTurn(st, reb, { type: 'buy', uid: q.uid }); // Force is with the Rebels → 2 exiles let d = pendingDecision(st); ok(d.kind === 'target' && d.op === 'exileCards' && d.optional, 'Quarren queues an optional exile'); actChooseTarget(st, reb, d.candidates[0]); d = pendingDecision(st); ok(d && d.kind === 'target' && d.op === 'exileCards', 'a second exile with the Force'); actChooseTarget(st, reb, null); ok(st.players[reb].discard.some((c) => c.uid === q.uid), 'Quarren itself lands in the discard'); } { // Jawa Scavenger: exile it to purchase from the galaxy discard pile. const st = freshGame(615); const reb = seatOf(st, 'rebel'); st.turnSeat = reb; const p = st.players[reb]; p.resources = 5; const wreck = { uid: ++giveUid, id: 'xwing' }; // cost 3, rebel st.galaxy.discard.push(wreck); const jawa = give(st, reb, 'jawascavenger'); actTurn(st, reb, { type: 'ability', zone: 'play', uid: jawa.card.uid }); ok(st.exile.some((c) => c.uid === jawa.card.uid), 'Jawa exiles itself'); const d = pendingDecision(st); ok(d.kind === 'target' && d.op === 'buyGalaxyDiscard', 'purchase-from-discard decision queued'); actChooseTarget(st, reb, d.candidates.find((c) => c.uid === wreck.uid)); ok(p.resources === 2, 'the card cost was paid'); ok(p.discard.some((c) => c.uid === wreck.uid), 'the salvaged card lands in the player discard'); ok(!st.galaxy.discard.some((c) => c.uid === wreck.uid), 'and leaves the galaxy discard'); } { // Grand Moff Tarkin: borrow an Empire row card; it exiles at end of turn. const st = freshGame(616); const emp = seatOf(st, 'empire'); st.turnSeat = emp; const p = st.players[emp]; const loot = plantRow(st, 0, 'tiefighter'); const tarkin = give(st, emp, 'grandmofftarkin'); actTurn(st, emp, { type: 'ability', zone: 'play', uid: tarkin.card.uid }); const d = pendingDecision(st); ok(d.kind === 'target' && d.op === 'takeRowTemp', 'Tarkin queues a row pick'); ok(d.candidates.every((c) => cardDef(st.galaxy.row.find((x) => x.uid === c.uid)).faction === 'empire'), 'only Empire cards offered'); actChooseTarget(st, emp, d.candidates.find((c) => c.uid === loot.uid)); ok(p.hand.some((c) => c.uid === loot.uid), 'the borrowed card is in hand'); ok(p.tempExileUids.includes(loot.uid), 'and flagged for end-of-turn exile'); actTurn(st, emp, { type: 'endTurn' }); ok(st.exile.some((c) => c.uid === loot.uid), 'the borrowed card is exiled at end of turn'); ok(!p.discard.some((c) => c.uid === loot.uid), 'it never reaches the discard'); } { // Jyn Erso: look at the opponent's hand; with the Force, topdeck one card. const st = freshGame(617); // Force with the Rebels const reb = seatOf(st, 'rebel'); const emp = 1 - reb; st.turnSeat = reb; st.players[emp].hand = [{ uid: 'jh1', id: 'stormtrooper' }, { uid: 'jh2', id: 'imperialshuttle' }]; const jyn = give(st, reb, 'jynerso'); actTurn(st, reb, { type: 'ability', zone: 'play', uid: jyn.card.uid }); const evs = takeEvents(st); ok(evs.some((e) => e.type === 'handReveal' && e.bySeat === reb && e.ids.length === 2), 'hand reveal event emitted'); const d = pendingDecision(st); ok(d.kind === 'target' && d.op === 'oppTopdeck' && d.candidates.every((c) => c.id), 'topdeck pick offered with card ids'); actChooseTarget(st, reb, d.candidates.find((c) => c.uid === 'jh1')); const oppDeck = st.players[emp].deck; ok(oppDeck[oppDeck.length - 1].uid === 'jh1', 'chosen card sits on top of their deck'); ok(st.players[emp].hand.length === 1, 'their hand shrank by one'); } { // Moff Jerjerrod: peek the top card; with the Force, swap it into the row. const st = freshGame(618); const emp = seatOf(st, 'empire'); st.turnSeat = emp; st.force = -FORCE_MAX; // Force with the Empire const jerjerrod = give(st, emp, 'moffjerjerrod'); const topBefore = st.galaxy.deck[st.galaxy.deck.length - 1]; actTurn(st, emp, { type: 'ability', zone: 'play', uid: jerjerrod.card.uid }); const d = pendingDecision(st); ok(d.kind === 'target' && d.op === 'lookTopSwap' && d.peekId === topBefore.id, 'peek shows the top card'); const rowRef = d.candidates.find((c) => c.kind === 'row'); ok(!!rowRef, 'row swap offered while the Force is with you'); const outUid = rowRef.uid; actChooseTarget(st, emp, rowRef); ok(st.galaxy.row.some((c) => c.uid === topBefore.uid), 'top card swapped into the row'); ok(st.galaxy.deck[st.galaxy.deck.length - 1].uid === outUid, 'row card went on top of the deck'); const st2 = freshGame(619); const emp2 = seatOf(st2, 'empire'); st2.turnSeat = emp2; // Force with the Rebels → no swap for the Empire const j2 = give(st2, emp2, 'moffjerjerrod'); actTurn(st2, emp2, { type: 'ability', zone: 'play', uid: j2.card.uid }); const d2 = pendingDecision(st2); ok(d2.op === 'lookTopSwap' && d2.candidates.every((c) => c.kind === 'opt'), 'without the Force, look only'); actChooseTarget(st2, emp2, d2.candidates[0]); } { // destroyCapital scopes: Luke needs one in play; Hammerhead also hits the row. const st = freshGame(620); // Force with the Rebels const reb = seatOf(st, 'rebel'); const emp = 1 - reb; st.turnSeat = reb; plantRow(st, 0, 'stardestroyer'); // enemy capital in the row — NOT a Luke target const luke = give(st, reb, 'lukeskywalker'); let threw = false; try { actTurn(st, reb, { type: 'ability', zone: 'play', uid: luke.card.uid }); } catch { threw = true; } ok(threw, 'Luke has no target while the opponent has no capital in play'); give(st, emp, 'gozanticruiser'); actTurn(st, reb, { type: 'ability', zone: 'play', uid: luke.card.uid }); const d = pendingDecision(st); ok(d.op === 'destroyCapital' && d.candidates.every((c) => c.kind === 'capital'), "Luke's scope is opponent play only"); actChooseTarget(st, reb, d.candidates[0]); ok(st.players[emp].capitals.length === 0, 'Luke destroys the capital'); const st2 = freshGame(621); const reb2 = seatOf(st2, 'rebel'); st2.turnSeat = reb2; const rowCap = plantRow(st2, 0, 'stardestroyer'); const hammer = give(st2, reb2, 'hammerheadcorvette'); actTurn(st2, reb2, { type: 'ability', zone: 'capital', uid: hammer.card.uid }); ok(st2.exile.some((c) => c.uid === hammer.card.uid), 'Hammerhead exiles itself'); const d2 = pendingDecision(st2); ok(d2.op === 'destroyCapital' && d2.candidates.some((c) => c.kind === 'rowCapital' && c.uid === rowCap.uid), 'Hammerhead can target an enemy capital in the row'); actChooseTarget(st2, reb2, d2.candidates.find((c) => c.kind === 'rowCapital')); ok(st2.galaxy.discard.some((c) => c.uid === rowCap.uid), 'row capital destroyed to the galaxy discard'); } { // recoverDiscard filters: Falcon (Hero units), AT-AT (Troopers). const st = freshGame(622); const reb = seatOf(st, 'rebel'); st.turnSeat = reb; const p = st.players[reb]; p.discard.push({ uid: 'fd1', id: 'hansolo' }, { uid: 'fd2', id: 'xwing' }, { uid: 'fd3', id: 'moncalamaricruiser' }); const falcon = give(st, reb, 'millenniumfalcon'); actTurn(st, reb, { type: 'ability', zone: 'play', uid: falcon.card.uid }); const d = pendingDecision(st); ok(d.op === 'recoverDiscard' && d.candidates.length === 1 && d.candidates[0].uid === 'fd1', 'Falcon offers only Hero units from the discard'); actChooseTarget(st, reb, d.candidates[0]); ok(p.hand.some((c) => c.uid === 'fd1'), 'Han returns to hand'); const st2 = freshGame(623); const emp2 = seatOf(st2, 'empire'); st2.turnSeat = emp2; const p2 = st2.players[emp2]; p2.discard.push({ uid: 'fd4', id: 'stormtrooper' }, { uid: 'fd5', id: 'tiefighter' }); const atat = give(st2, emp2, 'atat'); actTurn(st2, emp2, { type: 'ability', zone: 'play', uid: atat.card.uid }); const d2 = pendingDecision(st2); ok(d2.op === 'recoverDiscard' && d2.candidates.length === 1 && d2.candidates[0].uid === 'fd4', 'AT-AT offers only Troopers from the discard'); actChooseTarget(st2, emp2, d2.candidates[0]); } { // revealTop variants: Scout Trooper gains Force, TIE Interceptor draws. const st = freshGame(624); const emp = seatOf(st, 'empire'); st.turnSeat = emp; st.force = 0; st.galaxy.deck.push({ uid: ++giveUid, id: 'tiefighter' }); // Empire card on top const scout = give(st, emp, 'scouttrooper'); actTurn(st, emp, { type: 'ability', zone: 'play', uid: scout.card.uid }); ok(st.force === -1, 'Scout Trooper gains 1 Force on an Empire reveal'); st.galaxy.deck.push({ uid: ++giveUid, id: 'tiefighter' }); const inter = give(st, emp, 'tieinterceptor'); const hb = st.players[emp].hand.length; actTurn(st, emp, { type: 'ability', zone: 'play', uid: inter.card.uid }); ok(st.players[emp].hand.length === hb + 1, 'TIE Interceptor draws on an Empire reveal'); st.galaxy.deck.push({ uid: ++giveUid, id: 'xwing' }); // enemy card on top const scout2 = give(st, emp, 'scouttrooper'); actTurn(st, emp, { type: 'ability', zone: 'play', uid: scout2.card.uid }); const d = pendingDecision(st); ok(d.kind === 'target' && d.op === 'revealTopDiscard', 'an enemy reveal offers the discard choice'); actChooseTarget(st, emp, d.candidates.find((c) => c.v === 'discard')); ok(st.galaxy.discard.some((c) => c.id === 'xwing'), 'the enemy card can be discarded'); } { // Lobot: onPlay choose — attack, resources, or Force. const st = freshGame(625); const reb = seatOf(st, 'rebel'); st.turnSeat = reb; const p = st.players[reb]; p.hand.push({ uid: 'lobot1', id: 'lobot' }); actTurn(st, reb, { type: 'play', uid: 'lobot1' }); const d = pendingDecision(st); ok(d.kind === 'chooseOption' && d.options.length === 3, 'Lobot offers three options on play'); const atkIdx = d.options.findIndex((o) => o.op === 'gainAttack'); actChooseOption(st, reb, atkIdx); const lobot = p.inPlay.find((e) => e.card.uid === 'lobot1'); ok(entryAttack(st, reb, lobot) === 2, 'Lobot gains 2 attack this turn (0 printed + 2)'); } { // Force mechanics: pegged bonus resource at start of turn. 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'); } { // 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);