fertig-classic-games/tools/verifySWDBG.js

953 lines
44 KiB
JavaScript

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