Commit 65f351a15a

65f351a15ad455ba51de8b8ac067cf851d4fc4f7

parent: 0f0b9e58d0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-27 23:41 UTC

Add a full-run playthrough simulator

tools/playthrough.js plays standard runs through the real engine:
floors 1-5 with each camp choice, the finale, and floor 7. Each floor
is split into goals (torch, bow, crown and key into the den, leave),
each a best-first search over real game states preferring the most
torch, with Pareto pruning on torch, ward, dread and chase, keeping
the best end state per flag combination and backtracking on failure.
Only the last goal may enter the den; wait and drop torch are tried
only there. Every win is replayed from a fresh run through key() and
must end in a win on floor 7.

20 seeds with THIN_FUEL_BONUS=10: 17 won. 8q10qn (floor 2), copsc9
(floor 3) and 7j21g7 (floor 5), all thin flame, exhausted the search
budget.

tools/engine.js holds the headless loader, shared with validate.js.

Layout: unified · split

README.org +10 −1
@@ -44,7 +44,16 @@ docs/SIZES.txt.
44tools/check.sh 44tools/check.sh
45#+end_src 45#+end_src
46 46
47run it before each commit: 47tools/playthrough.js plays full runs (floors 1-7, camps, finale)
48through the real engine and replays each win through the key handler.
49it takes 1-3 minutes per seed, so it is not part of check.sh:
50
51#+begin_src sh
52node tools/playthrough.js 20
53node tools/playthrough.js --seeds 8q10qn
54#+end_src
55
56run check.sh before each commit:
48 57
49#+begin_src sh 58#+begin_src sh
50ln -s ../../tools/pre-commit .git/hooks/pre-commit 59ln -s ../../tools/pre-commit .git/hooks/pre-commit
tools/engine.js added +67
@@ -0,0 +1,67 @@
1"use strict"
2// Loads the real, unmodified 1MB game into a stubbed browser environment:
3// sprites.js, data.js, then the inline engine script from index.html.
4// Returns the vm context; engine globals (S, R, O, step, build, ...) live in it.
5const fs = require("fs")
6const path = require("path")
7const vm = require("vm")
8
9function load(gameDir) {
10 const html = fs.readFileSync(path.join(gameDir, "index.html"), "utf8")
11 const dataSrc = fs.readFileSync(path.join(gameDir, "data.js"), "utf8")
12 const spriteSrc = fs.readFileSync(path.join(gameDir, "sprites.js"), "utf8")
13
14 // The engine is the inline <script> block that follows the data.js include.
15 const m = html.match(/<script src=data\.js><\/script>\s*<script>([\s\S]*?)<\/script>/)
16 if (!m) {
17 console.error("engine: could not find the inline engine <script> after the data.js include in index.html")
18 process.exit(2)
19 }
20 const engineSrc = m[1]
21
22 // Minimal inert DOM node — enough surface for the engine's load-time render().
23 function el() {
24 return {
25 dataset: {},
26 textContent: "",
27 innerHTML: "",
28 className: "",
29 hidden: false,
30 appendChild() {},
31 onclick: null,
32 classList: { toggle() {} },
33 insertAdjacentHTML() {},
34 insertAdjacentText() {},
35 }
36 }
37
38 const sandbox = {
39 document: {
40 getElementById: () => el(),
41 createElement: () => el(),
42 },
43 addEventListener() {},
44 localStorage: {
45 _mem: Object.create(null),
46 getItem(k) { return k in this._mem ? this._mem[k] : null },
47 setItem(k, v) { this._mem[k] = String(v) },
48 removeItem(k) { delete this._mem[k] },
49 },
50 window: { AudioContext: function () { throw new Error("SFX must not run headless") } },
51 console,
52 }
53 const ctx = vm.createContext(sandbox)
54
55 try {
56 vm.runInContext(spriteSrc, ctx, { filename: "1mb/sprites.js" })
57 vm.runInContext(dataSrc, ctx, { filename: "1mb/data.js" })
58 vm.runInContext(engineSrc, ctx, { filename: "1mb/index.html#engine" })
59 } catch (e) {
60 console.error("engine: game code failed to evaluate headless — improve the stubs in this harness, do not touch the game.")
61 console.error(e && e.stack || e)
62 process.exit(2)
63 }
64 return ctx
65}
66
67module.exports = { load }
tools/playthrough.js added +167
@@ -0,0 +1,167 @@
1#!/usr/bin/env node
2// Headless playthrough for the 1MB roguelike.
3// Usage: node tools/playthrough.js [seedCount] [mode] [gameDir]
4// node tools/playthrough.js --seeds <base36,...> [mode] [gameDir]
5//
6// Plays full runs through the real engine: floors 1-5 with a camp choice
7// between each, the finale (floor 6), then the postgame floor 7. Each floor
8// is split into goals (torch, bow, crown + key into the beast den, leave)
9// and each goal is a best-first search over real game states (most torch
10// first), keeping the best end state per flag combination and backtracking
11// when a later goal fails. Every winning
12// input sequence is then replayed from a fresh run through key(), the real
13// input handler, and must end in a win on floor 7.
14"use strict"
15const path = require("path")
16const vm = require("vm")
17
18const only = process.argv[2] == "--seeds" ? process.argv.splice(2, 2)[1].split(",").map(v => parseInt(v, 36)) : null
19const seedCount = only ? only.length : Math.max(1, parseInt(process.argv[2], 10) || 50)
20const mode = process.argv[3] || "standard"
21const gameDir = process.argv[4] || path.join(__dirname, "..", "1mb")
22const ctx = require("./engine").load(gameDir)
23
24vm.runInContext(`
25const BOT_ACTS={U:()=>step(0,-1),D:()=>step(0,1),L:()=>step(-1,0),R:()=>step(1,0),".":()=>wait(),t:()=>dropTorch()}
26const BOT_KEYS={U:"ArrowUp",D:"ArrowDown",L:"ArrowLeft",R:"ArrowRight",".":".",t:"t"}
27const LEG_LIMIT=2000000
28let botBudget=0,botDeep=[0,0]
29const GOAL_NAMES=["torch","bow","crown and key into the den","leave"]
30function botBits(){let b="";for(const k in O)for(const o of O[k])b+=o.on?1:0;return b}
31// Position, flags, overlays, and the turn parity and beast only where they act; torch, ward, dread and chase are compared by dominance.
32function botKey(){const f=S.flags,d=S.drop;return [S.r,S.x,S.y,dark()&&!f.torch?S.turn&3:"",S.gameState,S.level,!!f.torch,f.ash,f.idol,f.gold,f.crown,f.key,f.mask,f.relic,f.relicPlus,S.r=="e"?S.beast.x+","+S.beast.y:"",d?[d.r,d.x,d.y,d.fuel].join():"",botBits()].join("|")}
33function botRes(){return [S.flags.torch,S.flags.ward,-S.dread,-S.chase]}
34// Records the state; false when a recorded state with the same key is at least as good in every resource.
35function botNew(seen){
36 const k=botKey(),v=botRes(),list=seen.get(k)
37 if(!list){seen.set(k,[v]);return true}
38 if(list.some(o=>o.every((x,i)=>x>=v[i])))return false
39 list.push(v);return true
40}
41function botSnap(){return JSON.stringify(S)+"\\u0000"+botBits()}
42function botRestore(p,world){
43 R=world.R;O=world.O;V=world.V
44 const i=p.lastIndexOf("\\u0000"),b=p.slice(i+1)
45 S=JSON.parse(p.slice(0,i))
46 let j=0;for(const k in O)for(const o of O[k])o.on=b[j++]=="1"
47}
48function botFull(){return JSON.stringify({S,R,O,V})}
49function botLoad(f){const x=JSON.parse(f);S=x.S;R=x.R;O=x.O;V=x.V}
50// Distinct end states by route-relevant flags, keeping the most torch for each.
51function botSig(){const f=S.flags;return [S.gameState,S.level,S.r,f.gold,f.mask,f.ward>0,f.relic].join()}
52function botLeg(goal,last){
53 if(goal())return [[botFull(),""]]
54 const world={R,O,V},start=botSnap(),seen=new Map(),buckets=[],ends=new Map()
55 const push=(p,path)=>{const t=Math.min(S.flags.torch,63);(buckets[t]=buckets[t]||{h:0,a:[]}).a.push([p,path])}
56 botNew(seen);push(start,"")
57 let n=0
58 for(;;){
59 let b=buckets.length-1;while(b>=0&&!(buckets[b]&&buckets[b].h<buckets[b].a.length))b--
60 if(b<0||n>=LEG_LIMIT)break
61 const bk=buckets[b],[p,path]=bk.a[bk.h];bk.a[bk.h++]=null
62 for(const a in BOT_ACTS){
63 botRestore(p,world)
64 if((a=="."||a=="t")&&S.r!="e")continue
65 BOT_ACTS[a]();n++
66 if(!S.alive||S.gameState=="dead")continue
67 if(!botNew(seen))continue
68 // Only the last goal (the escape) may wander the beast den.
69 const hit=goal()
70 if(!last&&!hit&&S.r=="e")continue
71 if(hit){
72 const g=botSig(),e=ends.get(g)
73 if(!e||S.flags.torch>e[2])ends.set(g,[botFull(),path+a,S.flags.torch])
74 continue
75 }
76 push(botSnap(),path+a)
77 }
78 }
79 botBudget-=n
80 botRestore(start,world)
81 return [...ends.values()].sort((a,b)=>b[2]-a[2])
82}
83function botGoals(L){
84 const done=()=>S.gameState!="play"||S.level!=L
85 return L==6?[done]:[()=>S.flags.torch>0,()=>S.flags.idol,()=>S.flags.crown&&S.flags.key&&(S.r=="e"||!R.e),done]
86}
87function botSolve(goals,i,cont){
88 if(botBudget<0)return null
89 if(i==goals.length)return cont()
90 const at=[S.level,goals.length==1?3:i];if(at[0]>botDeep[0]||at[0]==botDeep[0]&&at[1]>botDeep[1])botDeep=at
91 for(const [f,path] of botLeg(goals[i],i==goals.length-1)){
92 botLoad(f)
93 const rest=botSolve(goals,i+1,cont)
94 if(rest!=null)return path+rest
95 }
96 return null
97}
98function botRun(){
99 const L=S.level
100 return botSolve(botGoals(L),0,()=>{
101 if(S.gameState=="win"){
102 if(S.level>=7)return ""
103 startPostgame()
104 const r=botRun()
105 return r==null?null:"|p"+r
106 }
107 if(S.gameState=="camp"){
108 const f=botFull()
109 for(const c of "1423"){
110 botLoad(f);applyCampChoice(c)
111 const r=botRun()
112 if(r!=null)return "|"+c+r
113 }
114 return null
115 }
116 if(S.level==6&&S.gameState=="play")return botRun()
117 return null
118 })
119}
120function botPlay(s,mode,budget){
121 seed=s;startRun(mode,0);botBudget=budget;botDeep=[0,0]
122 const moves=botRun()
123 return {moves,left:botBudget,stuck:"floor "+botDeep[0]+" "+GOAL_NAMES[botDeep[1]]}
124}
125function botReplay(s,mode,moves){
126 muted=1
127 seed=s;startRun(mode,0)
128 for(let i=0;i<moves.length;i++){
129 const c=moves[i]
130 if(c=="|"){key({key:moves[++i]});continue}
131 key({key:BOT_KEYS[c]})
132 }
133 return {state:S.gameState,level:S.level,turns:moves.replace(/\\|./g,"").length}
134}
135`, ctx, { filename: "playthrough-bot" })
136
137const seeds = only || JSON.parse(vm.runInContext(
138 `JSON.stringify((() => { const r = rng(1), out = []; for (let i = 0; i < ${seedCount}; i++) out.push(Math.floor(r() * 1e9)); return out })())`,
139 ctx, { filename: "playthrough-seeds" }))
140
141const t0 = Date.now()
142const fails = []
143let totalMoves = 0
144for (const s of seeds) {
145 const res = JSON.parse(vm.runInContext(`JSON.stringify(botPlay(${s}, ${JSON.stringify(mode)}, 30000000))`, ctx))
146 if (res.moves == null) {
147 fails.push({ seed: s, why: `${res.left < 0 ? "search budget exhausted" : "no winning line found"}, deepest: ${res.stuck}` })
148 console.log(` seed ${s.toString(36)}: NOT WON, ${fails[fails.length - 1].why}`)
149 continue
150 }
151 const rep = JSON.parse(vm.runInContext(`JSON.stringify(botReplay(${s}, ${JSON.stringify(mode)}, ${JSON.stringify(res.moves)}))`, ctx))
152 if (rep.state != "win" || rep.level != 7) {
153 fails.push({ seed: s, why: `replay ended ${rep.state} on floor ${rep.level}` })
154 console.log(` seed ${s.toString(36)}: NOT WON, ${fails[fails.length - 1].why}`)
155 continue
156 }
157 totalMoves += rep.turns
158 console.log(` seed ${s.toString(36)}: won in ${rep.turns} moves`)
159}
160const elapsed = ((Date.now() - t0) / 1000).toFixed(1)
161const won = seeds.length - fails.length
162if (fails.length) {
163 console.error(`playthrough: ${fails.length}/${seeds.length} ${mode} runs NOT WON (${elapsed}s)`)
164 for (const f of fails.slice(0, 20)) console.error(` seed ${f.seed.toString(36)}: ${f.why}`)
165 process.exit(1)
166}
167console.log(`playthrough: OK — ${won}/${seeds.length} ${mode} runs won floors 1-7 and replayed through key() (avg ${Math.round(totalMoves / won)} moves, ${elapsed}s)`)
tools/validate.js +1 −55
@@ -8,67 +8,13 @@
8// each full floor completable (gate -> torch -> bow -> vault -> escape 8// each full floor completable (gate -> torch -> bow -> vault -> escape
9// room -> gate) — the same check the in-game validateSeeds() runs. 9// room -> gate) — the same check the in-game validateSeeds() runs.
10"use strict" 10"use strict"
11const fs = require("fs")
12const path = require("path") 11const path = require("path")
13const vm = require("vm") 12const vm = require("vm")
14 13
15const seedCount = Math.max(1, parseInt(process.argv[2], 10) || 200) 14const seedCount = Math.max(1, parseInt(process.argv[2], 10) || 200)
16const gameDir = process.argv[3] || path.join(__dirname, "..", "1mb") 15const gameDir = process.argv[3] || path.join(__dirname, "..", "1mb")
17 16
18const html = fs.readFileSync(path.join(gameDir, "index.html"), "utf8") 17const ctx = require("./engine").load(gameDir)
19const dataSrc = fs.readFileSync(path.join(gameDir, "data.js"), "utf8")
20const spriteSrc = fs.readFileSync(path.join(gameDir, "sprites.js"), "utf8")
21
22// The engine is the inline <script> block that follows the data.js include.
23const m = html.match(/<script src=data\.js><\/script>\s*<script>([\s\S]*?)<\/script>/)
24if (!m) {
25 console.error("validate: could not find the inline engine <script> after the data.js include in index.html")
26 process.exit(2)
27}
28const engineSrc = m[1]
29
30// Minimal inert DOM node — enough surface for the engine's load-time render().
31function el() {
32 return {
33 dataset: {},
34 textContent: "",
35 innerHTML: "",
36 className: "",
37 hidden: false,
38 appendChild() {},
39 onclick: null,
40 classList: { toggle() {} },
41 insertAdjacentHTML() {},
42 insertAdjacentText() {},
43 }
44}
45
46const sandbox = {
47 document: {
48 getElementById: () => el(),
49 createElement: () => el(),
50 },
51 addEventListener() {},
52 localStorage: {
53 _mem: Object.create(null),
54 getItem(k) { return k in this._mem ? this._mem[k] : null },
55 setItem(k, v) { this._mem[k] = String(v) },
56 removeItem(k) { delete this._mem[k] },
57 },
58 window: { AudioContext: function () { throw new Error("SFX must not run headless") } },
59 console,
60}
61const ctx = vm.createContext(sandbox)
62
63try {
64 vm.runInContext(spriteSrc, ctx, { filename: "1mb/sprites.js" })
65 vm.runInContext(dataSrc, ctx, { filename: "1mb/data.js" })
66 vm.runInContext(engineSrc, ctx, { filename: "1mb/index.html#engine" })
67} catch (e) {
68 console.error("validate: game code failed to evaluate headless — improve the stubs in this harness, do not touch the game.")
69 console.error(e && e.stack || e)
70 process.exit(2)
71}
72 18
73// Runs in the same context, so it sees the engine's top-level let/const 19// Runs in the same context, so it sees the engine's top-level let/const
74// bindings (S, V) and functions (build, pickMods, escapeSolve) directly. 20// bindings (S, V) and functions (build, pickMods, escapeSolve) directly.