// verifyDungeonBoss.js — headless checks for the Dungeon Boss engine. // Run from repo root: node tools/verifyDungeonBoss.js // Imports only Data/Logic/AI (never the Phaser scene). import { CLASSES, BOSSES, ROOMS, SPELLS, HEROES, OPS, PASSIVES, HERO_DECK_SIZING, heroSouls, heroWounds, roomDef, spellDef, heroDef, } from '../src/games/dungeonboss/DungeonBossData.js'; import { newGame, makeRng, pendingDecision, takeEvents, publicView, actSetupDiscard, actBuild, actWindow, actReact, actChooseTarget, actDiscard, actRoomDraw, legalBuilds, windowActions, treasureCount, dungeonDamage, baitTargets, isOver, finalRanking, SOULS_TO_WIN, WOUNDS_TO_DIE, MAX_ROOMS, } from '../src/games/dungeonboss/DungeonBossLogic.js'; import { decide } from '../src/games/dungeonboss/DungeonBossAI.js'; 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…'); { ok(Object.keys(BOSSES).length === 8, 'exactly 8 bosses'); const xps = Object.values(BOSSES).map((b) => b.xp); ok(new Set(xps).size === 8, 'boss XP values unique'); for (const [id, b] of Object.entries(BOSSES)) { ok(CLASSES.includes(b.treasure), `boss ${id} treasure class valid`); ok(OPS.includes(b.levelUp.op), `boss ${id} level-up op supported`); ok(!!b.name && !!b.text, `boss ${id} name/text present`); } let roomCards = 0; for (const [id, r] of Object.entries(ROOMS)) { roomCards += r.count; ok(['monster', 'trap'].includes(r.type), `room ${id} type valid`); ok(r.dmg >= 0, `room ${id} damage >= 0`); ok(Object.keys(r.treasure || {}).every((c) => CLASSES.includes(c)), `room ${id} treasure classes valid`); for (const eff of (r.effects || [])) ok(OPS.includes(eff.op), `room ${id} op ${eff.op} supported`); if (r.passive) ok(PASSIVES.includes(r.passive), `room ${id} passive ${r.passive} supported`); if (r.advanced) { ok(Object.keys(r.treasure).length > 0, `advanced room ${id} has a treasure icon (buildable)`); } ok(!!r.name && !!r.text, `room ${id} name/text present`); } ok(roomCards === 75, `room deck is 75 cards (got ${roomCards})`); let spellCards = 0; for (const [id, s] of Object.entries(SPELLS)) { spellCards += s.count; ok(['build', 'adventure', 'both'].includes(s.phase), `spell ${id} phase valid`); ok(OPS.includes(s.op.op), `spell ${id} op supported`); ok(!!s.name && !!s.text, `spell ${id} name/text present`); } ok(spellCards === 31, `spell deck is 31 cards (got ${spellCards})`); const ordinary = Object.values(HEROES).filter((h) => !h.epic); const epics = Object.values(HEROES).filter((h) => h.epic); ok(ordinary.length === 25, `25 ordinary heroes (got ${ordinary.length})`); ok(epics.length === 16, `16 epic heroes (got ${epics.length})`); for (const cls of CLASSES) { const o = ordinary.filter((h) => h.cls === cls); ok(o.length === 6, `6 ordinary ${cls} heroes`); ok(o.some((h) => h.hp <= 4), `${cls} has a killable low-hp hero`); ok(epics.filter((h) => h.cls === cls).length === 4, `4 epic ${cls} heroes`); } ok(Object.values(HEROES).filter((h) => h.fool).length === 1, 'exactly one Fool'); } // ── 2. Setup invariants ────────────────────────────────────────────────────── console.log('Setup invariants…'); { for (const n of [2, 3, 4]) { const st = newGame(n, 12345 + n); ok(st.players.length === n, `${n}p: player count`); for (const p of st.players) { ok(p.hand.rooms.length === 5 && p.hand.spells.length === 2, `${n}p: dealt 5 rooms + 2 spells`); } const sizing = HERO_DECK_SIZING[n]; ok(st.decks.heroes.length === sizing.ordinaryPerClass * 4 + 1, `${n}p: ordinary hero deck sized (${st.decks.heroes.length})`); ok(st.decks.epics.length === sizing.epicPerClass * 4, `${n}p: epic deck sized`); const d = pendingDecision(st); ok(d && d.kind === 'setupDiscard', `${n}p: first decision is setupDiscard`); // Turn order sorted by XP descending. const xps = st.turnOrder.map((s) => BOSSES[st.players[s].boss.id].xp); ok(xps.every((x, i) => i === 0 || xps[i - 1] >= x), `${n}p: turn order by XP desc`); } } // ── AI driver ──────────────────────────────────────────────────────────────── function driveDecision(st, d, skill, rnd, tally) { const view = publicView(st, d.seat); const choice = decide(view, d, skill, rnd); switch (d.kind) { case 'setupDiscard': actSetupDiscard(st, d.seat, choice); break; case 'build': { if (choice) { const legal = legalBuilds(st, d.seat); ok(legal.some((b) => b.roomUid === choice.roomUid && b.slotIdx === choice.slotIdx), 'AI build choice is legal'); } actBuild(st, d.seat, choice); break; } case 'window': { const windowId = d.window; actWindow(st, d.seat, choice); if (tally && choice && !choice.pass) { tally.casts++; if (windowId === 'advRoom') tally.advRoomCasts++; } break; } case 'react': actReact(st, d.seat, choice); break; case 'target': actChooseTarget(st, d.seat, choice); break; case 'discard': actDiscard(st, d.seat, choice); break; case 'roomDraw': actRoomDraw(st, d.seat, choice); break; default: throw new Error(`unknown decision kind ${d.kind}`); } } function playGame(nPlayers, seed, skills, collect = null) { const st = newGame(nPlayers, seed); const rnd = makeRng(seed ^ 0x5eed); const tally = { casts: 0, advRoomCasts: 0 }; let steps = 0; const eventLog = []; while (!isOver(st)) { if (++steps > 20000) throw new Error(`game ${seed} exceeded step guard (round ${st.round})`); const d = pendingDecision(st); if (!d) throw new Error(`game ${seed}: no decision but game not over (phase ${st.phase}, round ${st.round})`); if (!st.players[d.seat].alive) throw new Error(`game ${seed}: decision for dead seat ${d.seat}`); driveDecision(st, d, skills[d.seat] ?? 3, rnd, tally); checkInvariants(st, seed); if (collect !== null) eventLog.push(...takeEvents(st).map((e) => JSON.stringify(e))); else takeEvents(st); } if (collect !== null) collect.push(...eventLog); return { st, tally, steps }; } function checkInvariants(st, seed) { for (const p of st.players) { if (p.dungeon.length > MAX_ROOMS) throw new Error(`game ${seed}: dungeon > ${MAX_ROOMS}`); if (p.alive && p.wounds >= WOUNDS_TO_DIE) throw new Error(`game ${seed}: alive at ${p.wounds} wounds`); if (!p.alive && p.dungeon.length) throw new Error(`game ${seed}: dead seat kept a dungeon`); const soulSum = p.soulCards.reduce((a, h) => a + heroSouls(heroDef(h)), 0); if (soulSum !== p.souls) throw new Error(`game ${seed}: souls ${p.souls} != soul cards ${soulSum}`); } } // ── 3. Determinism ─────────────────────────────────────────────────────────── console.log('Determinism…'); { const logA = []; const logB = []; playGame(4, 777, [3, 3, 3, 3], logA); playGame(4, 777, [3, 3, 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 (320 games)…'); { let totalRounds = 0; let totalCasts = 0; let totalAdvRoomCasts = 0; let eliminations = 0; const winReasons = {}; const winsBySeat = {}; let games = 0; for (let g = 0; g < 320; g++) { const nPlayers = 2 + (g % 3); const skills = Array.from({ length: nPlayers }, (_, i) => 1 + ((g + i) % 5)); const { st, tally } = playGame(nPlayers, 1000 + g * 7, skills); games++; totalRounds += st.round; totalCasts += tally.casts; totalAdvRoomCasts += tally.advRoomCasts; eliminations += st.players.filter((p) => !p.alive).length; const over = st.events; // drained already; use final state ok(st.winner != null, `game ${g}: has a winner`); const w = st.players[st.winner]; const reason = w.souls >= SOULS_TO_WIN ? 'souls' : st.players.filter((p) => p.alive).length === 1 ? 'lastStanding' : 'decksExhausted'; winReasons[reason] = (winReasons[reason] || 0) + 1; winsBySeat[st.winner] = (winsBySeat[st.winner] || 0) + 1; ok(w.alive, `game ${g}: winner is alive`); ok(w.souls >= SOULS_TO_WIN || st.players.filter((p) => p.alive).length === 1 || (st.decks.heroes.length === 0 && st.decks.epics.length === 0), `game ${g}: valid end condition`); // Epic activation implies the ordinary deck is empty. if (st.epicsActive) ok(st.decks.heroes.length === 0, `game ${g}: epics only after ordinary deck empty`); const ranking = finalRanking(st); ok(ranking[0] === st.winner || !w.alive === false, `game ${g}: winner ranks first`); } console.log(` ${games} games · avg rounds ${(totalRounds / games).toFixed(1)}` + ` · spell/ability activations ${totalCasts} (${totalAdvRoomCasts} mid-walk)` + ` · eliminations ${eliminations}` + ` · endings ${JSON.stringify(winReasons)}` + ` · wins by seat ${JSON.stringify(winsBySeat)}`); ok(totalCasts > games, 'AI actually casts spells / activates rooms'); ok(totalAdvRoomCasts > 0, 'AI actually uses the mid-walk advRoom casting window'); ok((winReasons.souls || 0) > games * 0.25, 'a healthy share of mixed-skill games end on 10 souls'); } // Skilled play should mostly end on souls, weak play on elimination. console.log('Skill gradient (60 games @ skill 5)…'); { let souls = 0; for (let g = 0; g < 60; g++) { const nPlayers = 2 + (g % 3); const { st } = playGame(nPlayers, 5000 + g, Array(nPlayers).fill(5)); if (st.players[st.winner].souls >= SOULS_TO_WIN) souls++; } ok(souls >= 40, `skill-5 games end on souls (${souls}/60)`); } // ── 5. Scripted rule spot-checks ───────────────────────────────────────────── console.log('Rule spot-checks…'); // Drive a fresh game up to the first build phase with scripted choices. function driveTo(st, predicate, maxSteps = 5000) { const rnd = makeRng(99); let steps = 0; while (!isOver(st)) { const d = pendingDecision(st); if (!d) throw new Error('stalled'); if (predicate(d, st)) return d; driveDecision(st, d, 3, rnd, null); takeEvents(st); if (++steps > maxSteps) throw new Error('driveTo exceeded step guard'); } return null; } { // Bait: strict max wins the hero, ties stay in town. const st = newGame(2, 4242); driveTo(st, (d) => d.kind === 'build' && !d.setup); const bt = baitTargets(st); for (const hero of st.town) { const def = heroDef(hero); if (def.fool) { const [a, b] = [st.players[0].souls, st.players[1].souls]; ok((a === b) === (bt[hero.uid] == null), 'fool bait: tie stays, otherwise fewest souls'); } else { const counts = st.players.map((p) => treasureCount(st, p.seat, def.cls)); const max = Math.max(...counts); const winners = counts.filter((c) => c === max && c > 0).length; if (winners === 1 && max > 0) ok(counts[bt[hero.uid]] === max, `bait goes to strict max for ${def.cls}`); else ok(bt[hero.uid] == null, 'bait tie (or all zero) stays in town'); } } } { // Advanced rooms only over a shared treasure icon; ordinary anywhere. const st = newGame(2, 555); driveTo(st, (d) => d.kind === 'build' && !d.setup); const seat = pendingDecision(st).seat; const p = st.players[seat]; // Inject a known hand/dungeon to make the check exact. const mk = (id) => ({ uid: 900000 + Math.floor(Math.random() * 100000), id }); p.hand.rooms = [mk('beastmenagerie'), mk('goblinarmory')]; // advanced fighter / ordinary p.dungeon = [ { room: mk('brainsuckerhive'), under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null }, // mage { room: mk('golemfactory'), under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null }, // fighter ]; const legal = legalBuilds(st, seat); const adv = p.hand.rooms[0]; const advSlots = legal.filter((b) => b.roomUid === adv.uid).map((b) => b.slotIdx); ok(!advSlots.includes(0), 'advanced room cannot go over a non-matching room'); ok(advSlots.includes(1), 'advanced room can go over a matching (fighter) room'); ok(!advSlots.includes(2), 'advanced room cannot open a new slot'); const ordSlots = legal.filter((b) => b.roomUid === p.hand.rooms[1].uid).map((b) => b.slotIdx); ok(ordSlots.includes(0) && ordSlots.includes(1) && ordSlots.includes(2), 'ordinary room can build anywhere'); } { // Level-up fires exactly once, on the 5th room. let leveledEvents = 0; const st = newGame(2, 31337); const rnd = makeRng(1); let steps = 0; while (!isOver(st) && steps < 20000) { steps++; const d = pendingDecision(st); if (!d) break; driveDecision(st, d, 5, rnd, null); for (const e of takeEvents(st)) { if (e.type === 'levelUp') { leveledEvents++; ok(st.players[e.seat].dungeon.length === MAX_ROOMS, 'level-up at exactly 5 rooms'); ok(st.players[e.seat].boss.leveledUp, 'level-up flag set'); } } } ok(leveledEvents <= st.players.length, 'level-up at most once per player'); } { // Surviving heroes wound the boss; epic heroes wound twice. const st = newGame(2, 90210); driveTo(st, (d) => d.kind === 'build' && !d.setup); const seat = st.turnOrder[0]; const p = st.players[seat]; p.dungeon = []; // empty dungeon: any hero walks straight through st.town = []; // keep the bait phase from adding extra heroes to the walk const tough = Object.keys(HEROES).find((id) => HEROES[id].epic); p.entrance = [{ uid: 777777, id: tough, hp: HEROES[tough].hp, hpMax: HEROES[tough].hp }]; const before = p.wounds; // Run just the adventure by driving until this hero's walk (queued, or mid- // crawl and paused at a mid-walk advRoom window) finishes — entrance alone // isn't enough any more: it empties the instant the hero starts walking, // but the walk itself can now pause (for an advRoom casting window) partway // through, before the boss-wound step that's actually being asserted here. const rnd = makeRng(5); let steps = 0; const stillWalking = () => p.entrance.some((h) => h.uid === 777777) || (st.adv && st.adv.walking && st.adv.walking.hero.uid === 777777); while (stillWalking() && !isOver(st) && steps < 5000) { steps++; const d = pendingDecision(st); driveDecision(st, d, 1, rnd, null); takeEvents(st); } ok(p.wounds - before === 2 || !p.alive, `epic hero deals 2 wounds (got ${p.wounds - before})`); } console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);