953 lines
44 KiB
JavaScript
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);
|