krz/micro-roguelike

A micro roguelike web game in 1kb and 1mb versions.

clone: git clone https://gitbay.org/krz/micro-roguelike.git

main: tools/validate.js · raw

  1#!/usr/bin/env node
  2// Headless solver harness for the 1MB roguelike.
  3// Usage: node tools/validate.js [seedCount] [gameDir]
  4//
  5// Loads the real, unmodified game: evaluates 1mb/data.js and the inline
  6// engine script from 1mb/index.html inside a stubbed browser environment,
  7// then sweeps seedCount seeds x floors 1-7 asserting floorSolve() proves
  8// each full floor completable (gate -> torch -> bow -> vault -> escape
  9// room -> gate) — the same check the in-game validateSeeds() runs.
 10"use strict"
 11const fs = require("fs")
 12const path = require("path")
 13const vm = require("vm")
 14
 15const seedCount = Math.max(1, parseInt(process.argv[2], 10) || 200)
 16const gameDir = process.argv[3] || path.join(__dirname, "..", "1mb")
 17
 18const html = fs.readFileSync(path.join(gameDir, "index.html"), "utf8")
 19const dataSrc = fs.readFileSync(path.join(gameDir, "data.js"), "utf8")
 20
 21// The engine is the inline <script> block that follows the data.js include.
 22const m = html.match(/<script src=data\.js><\/script>\s*<script>([\s\S]*?)<\/script>/)
 23if (!m) {
 24  console.error("validate: could not find the inline engine <script> after the data.js include in index.html")
 25  process.exit(2)
 26}
 27const engineSrc = m[1]
 28
 29// Minimal inert DOM node — enough surface for the engine's load-time render().
 30function el() {
 31  return {
 32    dataset: {},
 33    textContent: "",
 34    innerHTML: "",
 35    className: "",
 36    hidden: false,
 37    appendChild() {},
 38    onclick: null,
 39  }
 40}
 41
 42const sandbox = {
 43  document: {
 44    getElementById: () => el(),
 45    createElement: () => el(),
 46  },
 47  addEventListener() {},
 48  localStorage: {
 49    _mem: Object.create(null),
 50    getItem(k) { return k in this._mem ? this._mem[k] : null },
 51    setItem(k, v) { this._mem[k] = String(v) },
 52    removeItem(k) { delete this._mem[k] },
 53  },
 54  window: { AudioContext: function () { throw new Error("SFX must not run headless") } },
 55  console,
 56}
 57const ctx = vm.createContext(sandbox)
 58
 59try {
 60  vm.runInContext(dataSrc, ctx, { filename: "1mb/data.js" })
 61  vm.runInContext(engineSrc, ctx, { filename: "1mb/index.html#engine" })
 62} catch (e) {
 63  console.error("validate: game code failed to evaluate headless — improve the stubs in this harness, do not touch the game.")
 64  console.error(e && e.stack || e)
 65  process.exit(2)
 66}
 67
 68// Runs in the same context, so it sees the engine's top-level let/const
 69// bindings (S, V) and functions (build, pickMods, escapeSolve) directly.
 70const driver = vm.runInContext(`(function (seeds) {
 71  const fails = []
 72  for (const s0 of seeds) {
 73    for (const level of [1, 2, 3, 4, 5, 6, 7]) {
 74      S = { level, mods: pickMods(s0) }
 75      if (!floorSolve(s0, level)) {
 76        S = { level, mods: pickMods(s0) }
 77        build(s0)
 78        fails.push({ seed: s0, level, escape: V.escape, beast: V.beast, hall: V.hall, pit: V.pit, crack: V.crack, idol: V.idol })
 79      }
 80    }
 81  }
 82  return JSON.stringify(fails)
 83})`, ctx, { filename: "validate-driver" })
 84
 85// Deterministic seed sweep using the game's own rng, same range as rollSeed().
 86const seedsJson = vm.runInContext(
 87  `JSON.stringify((() => { const r = rng(1), out = []; for (let i = 0; i < ${seedCount}; i++) out.push(Math.floor(r() * 1e9)); return out })())`,
 88  ctx, { filename: "validate-seeds" })
 89const seeds = JSON.parse(seedsJson)
 90
 91const t0 = Date.now()
 92const fails = JSON.parse(driver(seeds))
 93const elapsed = ((Date.now() - t0) / 1000).toFixed(1)
 94const checks = seedCount * 7
 95
 96if (fails.length) {
 97  console.error(`validate: ${fails.length}/${checks} floors NOT COMPLETABLE (${seedCount} seeds x floors 1-7, ${elapsed}s)`)
 98  for (const f of fails.slice(0, 20)) {
 99    console.error(`  seed ${f.seed.toString(36)} floor ${f.level}: escape variant ${f.escape}, beast variant ${f.beast} (hall ${f.hall}, pit ${f.pit}, crack ${f.crack}, idol ${f.idol})`)
100  }
101  if (fails.length > 20) console.error(`  ... and ${fails.length - 20} more`)
102  process.exit(1)
103}
104console.log(`validate: OK — ${checks} full floors solvable (${seedCount} seeds x floors 1-7, ${elapsed}s)`)