diff options
| author | Christian Cleberg <[email protected]> | 2026-07-13 00:26:52 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-13 18:46:35 -0500 |
| commit | f1a0321e1edb598cd1eb421e4621be769b2d8b75 (patch) | |
| tree | c5d1307a4db81aa56acbce273cf117a05689e492 /tools/validate.js | |
| parent | de2460845efda51820eb3c6343fb158661ae76bb (diff) | |
| download | micro-roguelike-f1a0321e1edb598cd1eb421e4621be769b2d8b75.tar.gz micro-roguelike-f1a0321e1edb598cd1eb421e4621be769b2d8b75.tar.bz2 micro-roguelike-f1a0321e1edb598cd1eb421e4621be769b2d8b75.zip | |
Phase 0–1: verification guardrails and engine enablers for the 1MB expansion (#2)
* Add byte-budget gate, headless solver harness, and size ledger
Phase 0 guardrails for the 1MB roguelike; no changes under 1mb/.
- tools/check-size.sh: per-file byte ledger for 1mb/, fails past
1 MiB, warns loudly past 95%
- tools/validate.js: evaluates the unmodified game in a stubbed vm
environment and sweeps seeds x floors 2-6 through the real
escapeSolve, failing on any unwinnable escape room
- tools/check.sh: single entry point running both gates
- tools/pre-commit: optional installable hook running check.sh
- README: Budget & verification section; docs/SIZES.md: ledger
seeded at 82,240 B (7.8% of budget)
* Task 1.1: data-driven floor graph
Move room topology out of build() into a GRAPH table in data.js: each
floor declares its room set and exit wiring (TOPO shared by floors 1-7),
and build() consumes it generically. BASES flatten to raw map arrays
since their exit wiring now lives in GRAPH.
Two new graph capabilities, exercised by a hidden floor-9 proving ground
(debug key T): extra room instances beyond the nine fixed codes (h2,
inheriting family behavior from its first letter — spawn, darkness,
room text, path counters) and one-way exits ("!dest" seals after one
crossing). Guard cover()/the debug solve line for floors with no e room.
Behavior-neutral: fixed-seed summary() and variantSummary() identical
across floors 1-7 before/after; save format unchanged (v:1 round-trips).
* Task 1.2: procedural room generator
Add genRoom(rand, spec) to the engine: builds a 12x12 map from a spec
(required exit letters, pit-room O ring, wall-cluster and pit-vein tile
budgets, trap/item sprinkles, a stamp pattern for the idol ring) with
perimeter walls and a flood-fill guarantee that every walkable tile and
exit stays mutually reachable — block-overlay spots count as solid, and
overlay spot coords plus spawns are forced open so hazards never bury a
door. Bounded retries end in a sparse layout, then an authored fallback.
Floors 2+ draw each variant pick from the authored arrays plus
PROC_SLOTS=2 generated slots per room type (PROC_SPECS in data.js);
escapes stay authored from floor 6 up and BASES rooms are never
generated. Procedural escape rooms are gated by escapeSolve at build
time with regeneration on failure. Fully deterministic per seed.
Generator weighs 3,268 bytes. validate.js 500: 2500/2500 solvable.
* Task 1.3: full-floor solver
Add floorSolve(seed, level) to the engine: proves a floor completable
end to end — gate to bones for the torch, hall to idol for the bow,
hall to vault via pit or crack, crown and key pickups, then the escape
room via the existing beast simulation, back to the gate. Room legs are
BFS path costs over the built maps with block overlays solid and pits
never crossed; exits and item tiles are located dynamically from the
GRAPH-built rooms, so future topologies validate without changes.
Floor 1 crosses the escape room at torch 8 (fresh-run fuel plus an
allowed brazier refuel; 6 was falsely failing a third of floor-1 seeds
that play fine).
validateSeeds (V key) and tools/validate.js now sweep floors 1-7 with
full-floor checks: 200 seeds x 7 floors in ~6.5s, 500 in ~15s. The
debug panel gains a "floor : ok:<cost>" line (n/a on graphs without
idol/vault/escape rooms). A scratch copy with the vault crown walled
off fails 250/350 checks with per-floor variant detail.
Diffstat (limited to 'tools/validate.js')
| -rwxr-xr-x | tools/validate.js | 104 |
1 files changed, 104 insertions, 0 deletions
diff --git a/tools/validate.js b/tools/validate.js new file mode 100755 index 0000000..b0f4532 --- /dev/null +++ b/tools/validate.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// Headless solver harness for the 1MB roguelike. +// Usage: node tools/validate.js [seedCount] [gameDir] +// +// Loads the real, unmodified game: evaluates 1mb/data.js and the inline +// engine script from 1mb/index.html inside a stubbed browser environment, +// then sweeps seedCount seeds x floors 1-7 asserting floorSolve() proves +// each full floor completable (gate -> torch -> bow -> vault -> escape +// room -> gate) — the same check the in-game validateSeeds() runs. +"use strict" +const fs = require("fs") +const path = require("path") +const vm = require("vm") + +const seedCount = Math.max(1, parseInt(process.argv[2], 10) || 200) +const gameDir = process.argv[3] || path.join(__dirname, "..", "1mb") + +const html = fs.readFileSync(path.join(gameDir, "index.html"), "utf8") +const dataSrc = fs.readFileSync(path.join(gameDir, "data.js"), "utf8") + +// The engine is the inline <script> block that follows the data.js include. +const m = html.match(/<script src=data\.js><\/script>\s*<script>([\s\S]*?)<\/script>/) +if (!m) { + console.error("validate: could not find the inline engine <script> after the data.js include in index.html") + process.exit(2) +} +const engineSrc = m[1] + +// Minimal inert DOM node — enough surface for the engine's load-time render(). +function el() { + return { + dataset: {}, + textContent: "", + innerHTML: "", + className: "", + hidden: false, + appendChild() {}, + onclick: null, + } +} + +const sandbox = { + document: { + getElementById: () => el(), + createElement: () => el(), + }, + addEventListener() {}, + localStorage: { + _mem: Object.create(null), + getItem(k) { return k in this._mem ? this._mem[k] : null }, + setItem(k, v) { this._mem[k] = String(v) }, + removeItem(k) { delete this._mem[k] }, + }, + window: { AudioContext: function () { throw new Error("SFX must not run headless") } }, + console, +} +const ctx = vm.createContext(sandbox) + +try { + vm.runInContext(dataSrc, ctx, { filename: "1mb/data.js" }) + vm.runInContext(engineSrc, ctx, { filename: "1mb/index.html#engine" }) +} catch (e) { + console.error("validate: game code failed to evaluate headless — improve the stubs in this harness, do not touch the game.") + console.error(e && e.stack || e) + process.exit(2) +} + +// Runs in the same context, so it sees the engine's top-level let/const +// bindings (S, V) and functions (build, pickMods, escapeSolve) directly. +const driver = vm.runInContext(`(function (seeds) { + const fails = [] + for (const s0 of seeds) { + for (const level of [1, 2, 3, 4, 5, 6, 7]) { + S = { level, mods: pickMods(s0) } + if (!floorSolve(s0, level)) { + S = { level, mods: pickMods(s0) } + build(s0) + fails.push({ seed: s0, level, escape: V.escape, beast: V.beast, hall: V.hall, pit: V.pit, crack: V.crack, idol: V.idol }) + } + } + } + return JSON.stringify(fails) +})`, ctx, { filename: "validate-driver" }) + +// Deterministic seed sweep using the game's own rng, same range as rollSeed(). +const seedsJson = vm.runInContext( + `JSON.stringify((() => { const r = rng(1), out = []; for (let i = 0; i < ${seedCount}; i++) out.push(Math.floor(r() * 1e9)); return out })())`, + ctx, { filename: "validate-seeds" }) +const seeds = JSON.parse(seedsJson) + +const t0 = Date.now() +const fails = JSON.parse(driver(seeds)) +const elapsed = ((Date.now() - t0) / 1000).toFixed(1) +const checks = seedCount * 7 + +if (fails.length) { + console.error(`validate: ${fails.length}/${checks} floors NOT COMPLETABLE (${seedCount} seeds x floors 1-7, ${elapsed}s)`) + for (const f of fails.slice(0, 20)) { + 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})`) + } + if (fails.length > 20) console.error(` ... and ${fails.length - 20} more`) + process.exit(1) +} +console.log(`validate: OK — ${checks} full floors solvable (${seedCount} seeds x floors 1-7, ${elapsed}s)`) |
