139 lines
6.3 KiB
JavaScript
139 lines
6.3 KiB
JavaScript
// 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');
|