340 lines
15 KiB
JavaScript
340 lines
15 KiB
JavaScript
// 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);
|