// verifySpireClimb.js — headless engine verification for Spire Climb. // Runs map generation, full auto-played combats for both classes, reward/shop // rolls, and a handful of invariant checks. Pure-logic (no Phaser/browser). // // node verifySpireClimb.js import { newRun, generateMap, availableNodes, enterNode, encounterForNode, startCombat, autoPlayTurn, isCombatOver, settleCombat, makeRng, rollRewards, generateShop, rollCardChoices, rollRelic, addCardToDeck, upgradeCardInDeck, addRelic, resolvedCard, playableHand, nodeById, } from './public/src/games/spireclimb/SpireClimbLogic.js'; import { CLASSES, CARDS, ENEMIES, ENCOUNTERS, ACT } from './public/src/games/spireclimb/SpireClimbData.js'; let pass = 0, fail = 0; const fails = []; function ok(cond, msg) { if (cond) pass++; else { fail++; fails.push(msg); } } // ── 1. content sanity ── for (const [cid, c] of Object.entries(CARDS)) { ok(typeof c.name === 'string' && c.name.length > 0, `card ${cid} has name`); ok(c.type && ['attack', 'skill', 'power', 'status', 'curse'].includes(c.type), `card ${cid} valid type`); if (c.upgrade) { const inst = { uid: 1, id: cid, upgraded: true }; const up = resolvedCard(inst); ok(up.name.endsWith('+'), `card ${cid} upgrade name suffix`); } } for (const [eid, e] of Object.entries(ENEMIES)) { ok(Array.isArray(e.moves) && e.moves.length > 0, `enemy ${eid} has moves`); ok(e.hp[0] <= e.hp[1], `enemy ${eid} hp range ordered`); ok(Number.isInteger(e.placeholderFrame), `enemy ${eid} has placeholderFrame`); } for (const tier of ['normal', 'elite', 'boss']) { ok(ENCOUNTERS[tier].length > 0, `encounter table ${tier} non-empty`); for (const grp of ENCOUNTERS[tier]) for (const id of grp) ok(!!ENEMIES[id], `encounter enemy ${id} exists`); } // ── 2. map generation ── for (let s = 0; s < 50; s++) { const rng = makeRng(s + 1); const map = generateMap(rng); ok(map.grid.length === ACT.rows, `map seed ${s} rows`); ok(map.grid[map.rows - 1][0].type === 'boss', `map seed ${s} boss at top`); ok(map.grid[0].every((n) => n.type === 'combat'), `map seed ${s} row0 combat`); // connectivity: every non-top node has an edge; every non-row0 node has a parent const hasParent = new Set(); for (let r = 0; r < map.rows - 1; r++) for (const n of map.grid[r]) { ok(n.edges.length > 0, `map seed ${s} node ${n.id} has out-edge`); n.edges.forEach((id) => hasParent.add(id)); } for (let r = 1; r < map.rows; r++) for (const n of map.grid[r]) ok(hasParent.has(n.id), `map seed ${s} node ${n.id} reachable`); // a full path exists from row0 to boss let frontier = map.grid[0].map((n) => n.id); for (let r = 0; r < map.rows - 1; r++) { const next = new Set(); for (const id of frontier) nodeById({ map }, id).edges.forEach((e) => next.add(e)); frontier = [...next]; } ok(frontier.includes('n' + (map.rows - 1) + '_0'), `map seed ${s} boss reachable from start`); } // ── 3. full auto-played combats, both classes, many seeds ── function runCombat(className, enemyIds, seed) { const run = newRun(className, seed); const combat = startCombat(run, enemyIds, seed * 7 + 3); let guard = 200; while (!isCombatOver(combat) && guard-- > 0) autoPlayTurn(combat); return { run, combat, looped: guard <= 0 }; } let playerWins = 0, total = 0, neverLooped = true; for (const className of ['warrior', 'rogue']) { for (const tier of ['normal', 'elite', 'boss']) { for (const grp of ENCOUNTERS[tier]) { for (let s = 0; s < 12; s++) { const { combat, looped } = runCombat(className, grp, s + 1); if (looped) neverLooped = false; const res = isCombatOver(combat); ok(res === 'won' || res === 'lost', `${className} vs ${grp.join('+')} seed ${s} resolved (${res})`); // hp invariants ok(combat.player.hp <= combat.player.maxHp, `${className} hp <= max (${grp.join('+')})`); combat.enemies.forEach((e) => ok(e.hp <= e.maxHp, `enemy hp<=max ${e.name}`)); if (res === 'won') { combat.enemies.forEach((e) => ok(!e.alive, `won => all dead ${e.name}`)); playerWins++; } total++; } } } } ok(neverLooped, 'no combat hit the turn-guard (possible infinite loop)'); ok(playerWins > 0, 'player wins at least some auto-played combats'); // ── 4. rewards / shop / deck ops ── { const run = newRun('warrior', 123); const rng = makeRng(99); const node = { type: 'elite' }; const reward = rollRewards(run, node, rng); ok(reward.gold > 0, 'reward has gold'); ok(reward.cards.length === ACT.cardRewardChoices, 'reward card choices count'); ok(reward.cards.every((c) => CARDS[c.id]), 'reward cards valid'); ok(reward.relic && true, 'elite reward has relic'); const before = run.deck.length; addCardToDeck(run, reward.cards[0].id); ok(run.deck.length === before + 1, 'addCardToDeck grows deck'); const upTarget = run.deck.find((c) => CARDS[c.id].upgrade && !c.upgraded); if (upTarget) { upgradeCardInDeck(run, upTarget.uid); ok(upTarget.upgraded, 'upgradeCardInDeck flips flag'); } const shop = generateShop(run, rng); ok(shop.cards.length > 0, 'shop has cards'); ok(shop.cards.every((c) => c.price > 0), 'shop card prices positive'); } // ── 5. determinism: same seed => same map & same combat trace ── { const a = generateMap(makeRng(7)); const b = generateMap(makeRng(7)); ok(JSON.stringify(a) === JSON.stringify(b), 'map generation deterministic for fixed seed'); const r1 = runCombat('rogue', ['jawworm'], 5); const r2 = runCombat('rogue', ['jawworm'], 5); ok(r1.combat.player.hp === r2.combat.player.hp && isCombatOver(r1.combat) === isCombatOver(r2.combat), 'combat deterministic for fixed seed'); } // ── 6. starter decks well-formed ── for (const cls of Object.values(CLASSES)) { ok(cls.startingDeck.every((id) => CARDS[id]), `${cls.id} starter deck valid card ids`); ok(cls.startingDeck.length >= 10, `${cls.id} starter deck size`); } // ── report ── console.log(`\nSpire Climb verification`); console.log(` combats auto-played: ${total} (player win rate ${(100 * playerWins / total).toFixed(0)}%)`); console.log(` checks: ${pass} passed, ${fail} failed`); if (fail) { console.log('\nFAILURES:'); fails.slice(0, 30).forEach((m) => console.log(' ✗ ' + m)); process.exit(1); } console.log(' ✓ all checks passed');