fertig-classic-games/tools/verifySWDBG.js

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