Commit f1a0321e1e

f1a0321e1edb598cd1eb421e4621be769b2d8b75

parent: de2460845e

Verified · cmc

cmc <hello@cleberg.net> · 2026-07-13 05:26 UTC

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.

Layout: unified · split

1mb/data.js +64 −23
@@ -944,7 +944,7 @@ const ESCAPES6=[[
944944"############"]]
945945const BEASTS6=[[10,8],[10,7],[9,7],[10,6]]
946946const LEVEL_ORDER=[1,2,3,4,5,6]
947const LEVEL_ARCS={1:"foundation",2:"pressure",3:"ward",4:"hunt",5:"hollow",6:"finale",7:"afterglow"}
947const LEVEL_ARCS={1:"foundation",2:"pressure",3:"ward",4:"hunt",5:"hollow",6:"finale",7:"afterglow",9:"proving ground"}
948948const MODES={
949949 standard:{label:"standard",desc:"full campaign"},
950950 iron:{label:"iron",desc:"early beast"},
@@ -980,7 +980,8 @@ const FLOOR_THEMES={
980980 4:{name:"hunt",tag:"rose alarm",accent:"pink hunt",fog:"thin red haze"},
981981 5:{name:"hollow",tag:"ash hush",accent:"bone gray",fog:"dry blackout"},
982982 6:{name:"finale",tag:"crown weather",accent:"gold ruin",fog:"judging dark"},
983 7:{name:"afterglow",tag:"borrowed dawn",accent:"ash gold",fog:"embered haze"}
983 7:{name:"afterglow",tag:"borrowed dawn",accent:"ash gold",fog:"embered haze"},
984 9:{name:"proving ground",tag:"debug",accent:"bare stone",fog:"none"}
984985}
985986const ENEMY_TEXT={
986987 beast:["A one-eyed pursuer that reads space better than fear.","It wants a lane, not a duel."],
@@ -1124,10 +1125,11 @@ const LEVELS={
112411254:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES4,beasts:BEASTS4,dark:"behipcv",tone:"The hunt floor listens for every sound you make."},
112511265:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES5,beasts:BEASTS5,dark:"behipcv",tone:"The hollow floor strips away whatever was merely enough."},
112611276:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES6,beasts:BEASTS6,dark:"behipcv",tone:"The final gate wakes. Everything left in you will be tested."},
11277:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES6,beasts:BEASTS6,dark:"behipcv",tone:"You already won. The ruin didn't agree to let you leave."}
11287:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES6,beasts:BEASTS6,dark:"behipcv",tone:"You already won. The ruin didn't agree to let you leave."},
11299:{pits:PITS,idols:IDOLS,cracks:CRACKS,halls:HALLS,escapes:ESCAPES,beasts:BEASTS,dark:"",tone:"A proving ground outside the campaign."}
11281130}
11291131const BASE={
1130g:{m:[
1132g:[
11311133"############",
11321134"#..........#",
11331135"#..L.....R.#",
@@ -1139,8 +1141,8 @@ g:{m:[
11391141"#..........#",
11401142"#..........#",
11411143"#..........#",
1142"############"],ex:{">":"h",L:"b",R:"e"}},
1143b:{m:[
1144"############"],
1145b:[
11441146"############",
11451147"#..........#",
11461148"#...MT.....#",
@@ -1152,8 +1154,8 @@ b:{m:[
11521154"#..........#",
11531155"#..........#",
11541156"#..........#",
1155"############"],ex:{">":"h"}},
1156v:{m:[
1157"############"],
1158v:[
11571159"############",
11581160"#..........#",
11591161"#....C.K...#",
@@ -1165,11 +1167,11 @@ v:{m:[
11651167"#..........#",
11661168"#....>.....#",
11671169"#..........#",
1168"############"],ex:{">":"e"}},
1169d:{m:["############","#..........#","#..........#","#..........#","#....X.....#","#..........#","#..........#","#..........#","#..........#","#..........#","#..........#","############"],ex:{}}
1170"############"],
1171d:["############","#..........#","#..........#","#..........#","#....X.....#","#..........#","#..........#","#..........#","#..........#","#..........#","#..........#","############"]
11701172}
11711173const BASE2={
1172g:{m:[
1174g:[
11731175"############",
11741176"#..........#",
11751177"#..L..#..R.#",
@@ -1181,8 +1183,8 @@ g:{m:[
11811183"#..........#",
11821184"#..........#",
11831185"#..........#",
1184"############"],ex:{">":"h",L:"b",R:"e"}},
1185b:{m:[
1186"############"],
1187b:[
11861188"############",
11871189"#..........#",
11881190"#...MT.....#",
@@ -1194,8 +1196,8 @@ b:{m:[
11941196"#..........#",
11951197"#..........#",
11961198"#..........#",
1197"############"],ex:{">":"h"}},
1198v:{m:[
1199"############"],
1200v:[
11991201"############",
12001202"#..........#",
12011203"#...#C.K...#",
@@ -1207,11 +1209,11 @@ v:{m:[
12071209"#..........#",
12081210"#...>......#",
12091211"#..........#",
1210"############"],ex:{">":"e"}},
1212"############"],
12111213d:BASE.d
12121214}
12131215const BASE3={
1214g:{m:[
1216g:[
12151217"############",
12161218"#..........#",
12171219"#..L..S..R.#",
@@ -1223,8 +1225,8 @@ g:{m:[
12231225"#..........#",
12241226"#..........#",
12251227"#..........#",
1226"############"],ex:{">":"h",L:"b",R:"e"}},
1227b:{m:[
1228"############"],
1229b:[
12281230"############",
12291231"#..........#",
12301232"#..SMT.....#",
@@ -1236,8 +1238,8 @@ b:{m:[
12361238"#..........#",
12371239"#..........#",
12381240"#..........#",
1239"############"],ex:{">":"h"}},
1240v:{m:[
1241"############"],
1242v:[
12411243"############",
12421244"#..........#",
12431245"#...#C.K.S.#",
@@ -1249,7 +1251,46 @@ v:{m:[
12491251"#..........#",
12501252"#...>......#",
12511253"#..........#",
1252"############"],ex:{">":"e"}},
1254"############"],
12531255d:BASE.d
12541256}
1255const BASES={1:BASE,2:BASE2,3:BASE3,4:BASE3,5:BASE3,6:BASE3,7:BASE3}
1257const BASES={1:BASE,2:BASE2,3:BASE3,4:BASE3,5:BASE3,6:BASE3,7:BASE3,9:BASE}
1258// Procedural room slots: floors 2+ draw each variant pick from the authored
1259// arrays above plus PROC_SLOTS generated layouts per room type (escapes only
1260// through floor 5 — the finale arenas stay authored, as do BASES rooms).
1261// Specs feed genRoom() in the engine: exits lists required exit letters
1262// ("any" = random open tile), ring is the pit-room O border, walls and pits
1263// are tile budgets for wall clusters and pit veins, traps/items sprinkle
1264// chars onto open floor, stamp drops a small pattern whole onto the map.
1265const PROC_SLOTS=2
1266const PROC_SPECS={
1267 pits:{ring:1,pits:9,walls:2,exits:{">":"any"},items:{F:1}},
1268 idols:{walls:3,exits:{">":"any"},stamp:["ggg","gIg","ggg"]},
1269 cracks:{walls:14,traps:{x:3},exits:{">":"any"}},
1270 halls:{walls:5,exits:{a:"any",b:"any",c:"any",d:"any",">":"any"},items:{F:1}},
1271 escapes:{walls:5,exits:{">":"any"}}
1272}
1273// Floor topology: room set + exit wiring, consumed generically by build().
1274// base: fixed map from BASES[floor]. arr + v: variant array in LEVELS[floor]
1275// and the V slot that picks the variant. An exit destination prefixed "!" is
1276// one-way: it seals behind you after one crossing. Room codes beyond one char
1277// (h2) are extra instances; they inherit family behavior from the first letter.
1278const TOPO={
1279 g:{base:"g",ex:{">":"h",L:"b",R:"e"}},
1280 b:{base:"b",ex:{">":"h"}},
1281 v:{base:"v",ex:{">":"e"}},
1282 d:{base:"d",ex:{}},
1283 h:{arr:"halls",v:"hall",ex:{a:"p",b:"i",c:"c",d:"d",">":"g"}},
1284 p:{arr:"pits",v:"pit",ex:{">":"v"}},
1285 i:{arr:"idols",v:"idol",ex:{">":"h"}},
1286 c:{arr:"cracks",v:"crack",ex:{">":"v"}},
1287 e:{arr:"escapes",v:"escape",ex:{">":"g"}}
1288}
1289const GRAPH={1:TOPO,2:TOPO,3:TOPO,4:TOPO,5:TOPO,6:TOPO,7:TOPO,
1290// floor 9: debug proving ground (T in play) — a second hall instance reached
1291// by a one-way drop; exit b in h2 climbs back to the first hall.
12929:{
1293 g:{base:"g",ex:{">":"h"}},
1294 h:{arr:"halls",v:"hall",ex:{a:"!h2",">":"g"}},
1295 h2:{arr:"halls",v:"hall",ex:{b:"h",">":"g"}}
1296}}
1mb/index.html +188 −39
@@ -131,13 +131,90 @@ function campOffers(next){
131131 return [a,b]
132132}
133133
134function genRoom(rand,spec){
135 const pick=n=>(rand()*n)|0,lo=spec.ring?2:1,hi=spec.ring?W-3:W-2
136 const bad=[...(spec.keep||[]),...(spec.blocked||[])]
137 const openAt=(g,x,y)=>g[y][x]=="."&&!bad.some(b=>b[0]==x&&b[1]==y)
138 const spots=g=>{const c=[];for(let y=lo;y<=hi;y++)for(let x=lo;x<=hi;x++)if(openAt(g,x,y))c.push([x,y]);return c}
139 for(let t=0;t<25;t++){
140 const sparse=t==24
141 const g=Array.from({length:H},(_,y)=>Array.from({length:W},(_,x)=>!x||!y||x==W-1||y==H-1?"#":"."))
142 if(spec.ring)for(let i=1;i<W-1;i++)g[1][i]=g[H-2][i]=g[i][1]=g[i][W-2]="O"
143 if(!sparse){
144 for(let w=spec.walls||0;w>0;){
145 const hz=pick(2),len=2+pick(3)
146 const x=lo+pick(hi-lo+1-(hz?len:0)),y=lo+pick(hi-lo+1-(hz?0:len))
147 for(let i=0;i<len;i++)if(openAt(g,hz?x+i:x,hz?y:y+i))g[hz?y:y+i][hz?x+i:x]="#"
148 w-=len
149 }
150 let px=lo+pick(hi-lo+1),py=lo+pick(hi-lo+1)
151 for(let p=spec.pits||0;p>0;p--){
152 if(openAt(g,px,py))g[py][px]="O"
153 if(pick(5)){const d=[[1,0],[-1,0],[0,1],[0,-1]][pick(4)]
154 px=Math.min(hi,Math.max(lo,px+d[0]));py=Math.min(hi,Math.max(lo,py+d[1]))
155 }else{px=lo+pick(hi-lo+1);py=lo+pick(hi-lo+1)}
156 }
157 }
158 if(spec.stamp){
159 const sh=spec.stamp.length,sw=spec.stamp[0].length
160 let put=0
161 for(let s=0;s<20&&!put;s++){
162 const sx=lo+pick(hi-lo+2-sw),sy=lo+pick(hi-lo+2-sh)
163 if(bad.some(b=>b[0]>=sx&&b[0]<sx+sw&&b[1]>=sy&&b[1]<sy+sh))continue
164 for(let y=0;y<sh;y++)for(let x=0;x<sw;x++)g[sy+y][sx+x]=spec.stamp[y][x]
165 put=1
166 }
167 if(!put)continue
168 }
169 let full=0
170 for(const kind of [sparse?0:spec.traps,spec.items])if(kind)for(const ch in kind)for(let n=kind[ch];n>0;n--){
171 const c=spots(g)
172 if(!c.length){full=1;break}
173 const [x,y]=c[pick(c.length)];g[y][x]=ch
174 }
175 for(const ch in spec.exits){
176 const at=spec.exits[ch]
177 if(at=="any"){
178 const c=spots(g)
179 if(!c.length){full=1;break}
180 const [x,y]=c[pick(c.length)];g[y][x]=ch
181 }else g[at[1]][at[0]]=ch
182 }
183 if(full)continue
184 for(const [x,y] of spec.keep||[])if(g[y][x]=="#"||g[y][x]=="O")g[y][x]="."
185 const solid=(x,y)=>g[y][x]=="#"||g[y][x]=="O"||(spec.blocked||[]).some(b=>b[0]==x&&b[1]==y)
186 let total=0,start=null
187 for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(!solid(x,y)){total++;start=start||[x,y]}
188 const seen=new Set([start.join()]),q=[start]
189 while(q.length){
190 const [x,y]=q.pop()
191 for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
192 const nx=x+dx,ny=y+dy,k=nx+","+ny
193 if(nx<0||ny<0||nx>=W||ny>=H||solid(nx,ny)||seen.has(k))continue
194 seen.add(k);q.push([nx,ny])
195 }
196 }
197 if(seen.size==total)return g.map(row=>row.join(""))
198 }
199}
200function procSpec(kind,level,code){
201 const spec={...PROC_SPECS[kind]},keep=[(P[code]||P[code[0]]).slice()],blocked=[]
202 for(const s of BLOCK_SPOTS[level]||[])if(s[0]==code)blocked.push([s[1],s[2]])
203 for(const T of [RELIC_SPOTS,EFFIGY_SPOTS,BRAZIER_SPOTS,SHRINE_SPOTS,CURSE_SPOTS,SHADE_SPOTS,WATCHER_SPOTS,LEECH_SPOTS])
204 for(const s of T[level]||[])if(s[0]==code)keep.push([s[1],s[2]])
205 if(kind=="escapes")for(const b of LEVELS[level].beasts)keep.push(b.slice())
206 spec.keep=keep;spec.blocked=blocked
207 return spec
208}
209
134210function build(seed0){
135 const L=LEVELS[S&&S.level||1],r=rng(seed0+((S&&S.level||1)-1)*9973)
136 const B=BASES[S&&S.level||1]
137 V={hall:(r()*L.halls.length)|0,pit:(r()*L.pits.length)|0,idol:(r()*L.idols.length)|0,crack:(r()*L.cracks.length)|0,escape:(r()*L.escapes.length)|0,beast:(r()*L.beasts.length)|0}
211 const level=S&&S.level||1
212 const L=LEVELS[level],r=rng(seed0+(level-1)*9973)
213 const B=BASES[level]
214 const slots=k=>level<2||(k=="escapes"&&level>=6)?0:PROC_SLOTS
215 V={hall:(r()*(L.halls.length+slots("halls")))|0,pit:(r()*(L.pits.length+slots("pits")))|0,idol:(r()*(L.idols.length+slots("idols")))|0,crack:(r()*(L.cracks.length+slots("cracks")))|0,escape:(r()*(L.escapes.length+slots("escapes")))|0,beast:(r()*L.beasts.length)|0}
138216 B0=L.beasts[V.beast]
139217 const relicBag=RELIC_IDS.slice().sort(()=>r()-.5)
140 const level=S&&S.level||1
141218 O={relics:pickSpots(r,RELIC_SPOTS[level]||[],(level>=5?RELIC_SLOTS_HIGH:level>=3?RELIC_SLOTS_MID:RELIC_SLOTS_LOW)+(S&&S.mods&&S.mods.includes("rich")?RELIC_SLOTS_RICH_BONUS:0)).map((v,i)=>({r:v[0],x:v[1],y:v[2],id:relicBag[i%relicBag.length],on:1})),
142219 braziers:pickSpots(r,BRAZIER_SPOTS[level]||[],level>=5?3:level>=3?2:1).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
143220 shrines:pickSpots(r,SHRINE_SPOTS[level]||[],level>=4?3:level>=3?2:1).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
@@ -147,13 +224,19 @@ function build(seed0){
147224 leeches:pickSpots(r,LEECH_SPOTS[level]||[],Math.max(0,level-2)).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
148225 blocks:pickSpots(r,BLOCK_SPOTS[level]||[],99).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
149226 effigies:pickSpots(r,EFFIGY_SPOTS[level]||[],Math.max(0,level-1)).map(v=>({r:v[0],x:v[1],y:v[2],on:1}))}
150 R={
151 g:B.g,b:B.b,v:B.v,d:B.d,
152 h:{m:L.halls[V.hall],ex:{a:"p",b:"i",c:"c",d:"d",">":"g"}},
153 p:{m:L.pits[V.pit],ex:{">":"v"}},
154 i:{m:L.idols[V.idol],ex:{">":"h"}},
155 c:{m:L.cracks[V.crack],ex:{">":"v"}},
156 e:{m:L.escapes[V.escape],ex:{">":"g"}}
227 R={}
228 for(const code in GRAPH[level]){
229 const spec=GRAPH[level][code],arr=L[spec.arr],vi=V[spec.v]
230 const m=spec.base?B[spec.base]:vi<arr.length?arr[vi]:genRoom(r,procSpec(spec.arr,level,code))||arr[vi%arr.length]
231 R[code]={m,ex:{...spec.ex}}
232 }
233 if(R.e&&V.escape>=L.escapes.length){
234 let ok=escapeSolve(level,V.escape,V.beast,level>=3?8:6)
235 for(let i=0;i<6&&!ok;i++){
236 R.e.m=genRoom(r,procSpec("escapes",level,"e"))||L.escapes[V.escape%L.escapes.length]
237 ok=escapeSolve(level,V.escape,V.beast,level>=3?8:6)
238 }
239 if(!ok)R.e.m=L.escapes[V.escape%L.escapes.length]
157240 }
158241}
159242
@@ -341,8 +424,8 @@ function tile(x,y,r=S.r){
341424 if(overAt(r,x,y,"watchers"))return "q"
342425 if(overAt(r,x,y,"leeches"))return "l"
343426 if(overAt(r,x,y,"curses"))return "z"
344 if(r=="b"&&S.flags.mask&&x==4&&y==2)return "."
345 if(r=="b"&&S.flags.torch&&x==5&&y==2)return "."
427 if(r[0]=="b"&&S.flags.mask&&x==4&&y==2)return "."
428 if(r[0]=="b"&&S.flags.torch&&x==5&&y==2)return "."
346429 return base(x,y,r)
347430}
348431function setMsg(msg){S.msg=msg}
@@ -350,7 +433,7 @@ function die(msg,cause=msg){S.alive=0;S.gameState="dead";S.deathCause=cause;S.en
350433function win(){S.win=1;S.gameState="win";S.endTitle="dawn";S.endLead="You escape alive.";S.fx="win";setMsg("Dawn spills through the gate. You escape alive.");S.end=summary();clearSave();hasSave=!1}
351434function sign(n){return n<0?-1:n>0?1:0}
352435function dist(ax,ay,bx,by){return Math.abs(ax-bx)+Math.abs(ay-by)}
353function dark(r=S.r){return LEVELS[S.level].dark.includes(r)}
436function dark(r=S.r){return LEVELS[S.level].dark.includes(r[0])}
354437function carriedFire(){return S.flags.torch&&!S.flags.mask}
355438function fire(){return carriedFire()||(S.drop&&S.drop.r=="e"&&S.drop.fuel)}
356439
@@ -378,6 +461,7 @@ function runStyle(){
378461}
379462
380463function roomMsg(r=S.r){
464 r=r[0]
381465 if(r=="g")return S.flags.crown&&S.flags.key?(S.level==1?"The gate shudders instead of opening. Something deeper calls you on.":S.level<5?"The gate yields, but the campaign is not done with you yet.":S.level==5?"The gate opens onto the final road.": "The last gate waits. Only what survives the shrines goes home."):(S.level==1?"The broken gate yawns behind you. Bones left, beast right, ruin below.":S.level==2?"The gate is still shut. The deeper ruin waits below.":S.level==3?"The gate is quiet, but the shrines are not.":S.level==4?"The hunt floor waits below the gate, listening.":S.level==5?"The hollow floor waits below the gate, hungry for what remains.":"The final gate is quiet. Shrines and hunger ring it like judges.")
382466 if(r=="b")return S.flags.torch?(S.level==1?"The bone room is dim now, only soot and old footprints.":S.level==2?"The bones are freshened by newer ash. Even the dead have been disturbed.":"The bones are carved with ward-scratches. Someone learned too late."):(S.level==1?"A torch and a funeral mask wait among the bones.":S.level==2?"A torch and a funeral mask wait among the bones, as if placed for your return.":"A torch, a mask, and an old shrine wait among the bones.")
383467 if(r=="e"){
@@ -496,7 +580,7 @@ function variantSummary(){
496580}
497581
498582function escapeSolve(level=S.level,escapeIdx=V.escape,beastIdx=V.beast,torch=6){
499 const map=LEVELS[level].escapes[escapeIdx],start=P.e,beast=LEVELS[level].beasts[beastIdx],q=[[{x:start[0],y:start[1],bx:beast[0],by:beast[1],torch,chase:0,ward:level==3?8:0},""]],seen=new Set()
583 const map=LEVELS[level].escapes[escapeIdx]||R.e.m,start=P.e,beast=LEVELS[level].beasts[beastIdx],q=[[{x:start[0],y:start[1],bx:beast[0],by:beast[1],torch,chase:0,ward:level==3?8:0},""]],seen=new Set()
500584 while(q.length){
501585 const [s,path]=q.shift(),k=[s.x,s.y,s.bx,s.by,s.torch,s.chase,s.ward].join()
502586 if(seen.has(k))continue
@@ -531,18 +615,70 @@ function escapeSolve(level=S.level,escapeIdx=V.escape,beastIdx=V.beast,torch=6){
531615 return ""
532616}
533617
618function floorSolve(seed0,level=S&&S.level||1){
619 const kS=S,kV=V,kR=R,kO=O,kB=B0
620 S={level,mods:kS&&kS.mods||pickMods(seed0)}
621 build(seed0)
622 const solidAt=(rm,x,y)=>{const t=R[rm].m[y][x];return t=="#"||t=="O"||!!overAt(rm,x,y,"blocks")}
623 const find=(rm,ch)=>{for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(R[rm].m[y][x]==ch)return [x,y];return null}
624 const exitTo=(rm,dest)=>{for(const ch in R[rm].ex)if(R[rm].ex[ch].replace("!","")==dest)return find(rm,ch);return null}
625 const cost=(rm,from,...tos)=>{
626 let total=0,at=from
627 for(const to of tos){
628 if(!to)return -1
629 let d=-1
630 const seen=new Set([at.join()]),q=[[at[0],at[1],0]]
631 while(q.length){
632 const [x,y,n]=q.shift()
633 if(x==to[0]&&y==to[1]){d=n;break}
634 for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
635 const nx=x+dx,ny=y+dy,k=nx+","+ny
636 if(nx<0||ny<0||nx>=W||ny>=H||seen.has(k)||solidAt(rm,nx,ny))continue
637 seen.add(k);q.push([nx,ny,n+1])
638 }
639 }
640 if(d<0)return -1
641 total+=d;at=to
642 }
643 return total
644 }
645 const sp=rm=>(P[rm]||P[rm[0]]).slice()
646 const out=(()=>{
647 if(!R.b||!R.h||!R.i||!R.v||!R.e)return ""
648 let total=0
649 for(const c of [
650 cost("g",sp("g"),exitTo("g","b")),
651 cost("b",sp("b"),find("b","T"),exitTo("b","h")),
652 cost("h",sp("h"),exitTo("h","i")),
653 cost("i",sp("i"),find("i","I"),exitTo("i","h"))
654 ]){if(c<0)return "";total+=c}
655 const via=r2=>{
656 const a=cost("h",sp("h"),exitTo("h",r2))
657 if(a<0)return -1
658 const b=cost(r2,sp(r2),exitTo(r2,"v"))
659 return b<0?-1:a+b
660 }
661 const vp=via("p"),vc=via("c")
662 if(vp<0&&vc<0)return ""
663 total+=vp<0?vc:vc<0?vp:Math.min(vp,vc)
664 const vault=cost("v",sp("v"),find("v","C"),find("v","K"),exitTo("v","e"))
665 if(vault<0)return ""
666 total+=vault
667 // floor 1 crosses e with fresh-run fuel plus a brazier refuel: 8 is fair
668 const esc=escapeSolve(level,V.escape,V.beast,level==2?6:8)
669 if(!esc)return ""
670 return "ok:"+(total+esc.length)
671 })()
672 S=kS;V=kV;R=kR;O=kO;B0=kB
673 return out
674}
675
534676function validateSeeds(n=24){
535 const keep=S
536677 let bad=0,msg=[]
537 for(const level of [2,3,4,5,6])for(let i=0;i<n;i++){
538 const t={level,mods:keep.mods||pickMods(seed+i)}
539 S=t;build(seed+i)
540 const path=escapeSolve(level,V.escape,V.beast,level>=3?8:6)
541 if(!path){bad++;if(msg.length<6)msg.push(`l${level}:${(seed+i).toString(36)} e${V.escape} b${V.beast}`)}
678 for(const level of [1,2,3,4,5,6,7])for(let i=0;i<n;i++){
679 if(!floorSolve(seed+i,level)){bad++;if(msg.length<6)msg.push(`l${level}:${(seed+i).toString(36)}`)}
542680 }
543 S=keep
544 build(seed)
545 return `check ${n} seeds | ${bad?"bad "+bad+" "+msg.join(", "):"all clear"}`
681 return `check ${n} seeds x floors 1-7 | ${bad?"bad "+bad+" "+msg.join(", "):"all clear"}`
546682}
547683
548684function dropTorch(){
@@ -582,6 +718,7 @@ function beastState(){
582718
583719function cover(x,y){
584720 let n=0
721 if(!R.e)return 0
585722 for(const d of [[1,0],[-1,0],[0,1],[0,-1]])if(tile(x+d[0],y+d[1],"e")=="#")n++
586723 return n
587724}
@@ -619,7 +756,7 @@ function beastTurn(){
619756function burnTorch(){
620757 if(!S.alive||S.win||!dark())return
621758 if(S.flags.torch){
622 let drain=(S.level==2||S.level>=4)&&"ec".includes(S.r)?TORCH_DRAIN_HAZARD:TORCH_DRAIN_BASE
759 let drain=(S.level==2||S.level>=4)&&"ec".includes(S.r[0])?TORCH_DRAIN_HAZARD:TORCH_DRAIN_BASE
623760 if(S.level>=3&&S.flags.ward)drain++
624761 if(hasMod("thin"))drain++
625762 if(S.flags.relic=="wick")drain++
@@ -656,31 +793,32 @@ function shadow(){
656793 return 0
657794}
658795
659function enter(r,x=P[r][0],y=P[r][1],msg){
796function enter(r,x=(P[r]||P[r[0]])[0],y=(P[r]||P[r[0]])[1],msg){
660797 S.hint=""
661798 S.r=r;S.x=x;S.y=y
662 if(r=="e"){S.chase=0;if(S.flags.crown)S.beast={x:B0[0],y:B0[1]}}
663 if(r=="p")S.path.pit++
664 if(r=="c")S.path.crack++
665 if(r=="d")return die("The black arch keeps its promise.")
666 if(r=="e"&&dist(S.x,S.y,S.beast.x,S.beast.y)<=1)return die("The beast is on you before you can breathe.")
667 if(r=="c"&&S.flags.gold&&!S.flags.mask&&S.flags.relic!="tooth"){
799 const f=r[0]
800 if(f=="e"){S.chase=0;if(S.flags.crown)S.beast={x:B0[0],y:B0[1]}}
801 if(f=="p")S.path.pit++
802 if(f=="c")S.path.crack++
803 if(f=="d")return die("The black arch keeps its promise.")
804 if(f=="e"&&dist(S.x,S.y,S.beast.x,S.beast.y)<=1)return die("The beast is on you before you can breathe.")
805 if(f=="c"&&S.flags.gold&&!S.flags.mask&&S.flags.relic!="tooth"){
668806 if(S.level>=3&&S.flags.ward){loseWard(S.flags.relic=="greave"?4:3);msg="Ward parts the crack; the blessing thins."}
669807 else return die("Weighted by treasure, you wedge fast in the crack.")
670808 }
671 if(r=="c"&&S.flags.torch&&!(S.flags.relic=="veil"&&S.flags.mask)){
809 if(f=="c"&&S.flags.torch&&!(S.flags.relic=="veil"&&S.flags.mask)){
672810 S.flags.torch=Math.max(0,S.flags.torch-(S.flags.relic=="greave"?2:S.flags.relic=="wick"?0:S.flags.torch))
673811 S.flags.ash=!S.flags.torch?1:S.flags.ash
674812 msg=S.flags.torch?"Crack strips the torch.":"Crack snuffs the torch."
675813 }
676 if(r=="g"&&S.flags.crown&&S.flags.key){
814 if(f=="g"&&S.flags.crown&&S.flags.key){
677815 if(S.level<5)return startCamp(S.level+1)
678816 if(S.level==5)return startFinale()
679817 return win()
680818 }
681 if(r=="b"&&!S.flags.torch)showHint("torch")
682 if(r=="c")showHint("crack")
683 if(r=="e")showHint("beast")
819 if(f=="b"&&!S.flags.torch)showHint("torch")
820 if(f=="c")showHint("crack")
821 if(f=="e")showHint("beast")
684822 setMsg(msg||roomMsg(r))
685823}
686824
@@ -786,8 +924,9 @@ function step(dx,dy){
786924 if(t=="#")return setMsg(roomMsg())
787925 S.turn++
788926 if(shadow()){setMsg("Darkness twists the room around you.");burnTorch();beastTurn();return}
789 const ex=R[S.r].ex[t]
927 let ex=R[S.r].ex[t]
790928 if(ex){
929 if(ex[0]=="!"){delete R[S.r].ex[t];ex=ex.slice(1)}
791930 if(S.r=="e"&&nx==S.beast.x&&ny==S.beast.y)return die("The beast tears you open.")
792931 burnTorch();return enter(ex)
793932 }
@@ -819,6 +958,15 @@ function jumpLevel(level=2){
819958 render()
820959}
821960
961function testFloor(){
962 startRun(S&&S.mode||"standard",0)
963 S.level=9;S.currentLevel=9
964 build(seed)
965 S.r="g";S.x=P.g[0];S.y=P.g[1]
966 setMsg("Proving ground. Exit a in the hall drops one-way into h2; exit b in h2 climbs back.")
967 render()
968}
969
822970function key(e){
823971 if(e.key=="m"){muted=!muted;render();return}
824972 if(S.gameState=="win"&&(e.key=="p"||e.key=="7")){startPostgame();render();return}
@@ -835,6 +983,7 @@ function key(e){
835983 if(e.key=="J"){jumpLevel(3);return}
836984 if(e.key=="k"){jumpLevel(4);return}
837985 if(e.key=="K"){jumpLevel(5);return}
986 if(e.key=="T"){testFloor();return}
838987 if(S.gameState=="camp"){
839988 if("1234".includes(e.key))applyCampChoice(e.key)
840989 render()
@@ -915,7 +1064,7 @@ function render(){
9151064 remember()
9161065 const th=floorTheme()
9171066 U.innerHTML=`floor ${S.level} | ${th.name}<small>${buildTitle()} | ${th.tag} | ${th.accent} | ${MODES[S.mode||"standard"].label} | seed ${S.seed.toString(36)}</small>`
918 I.textContent=sidePanel()+(DBG.show?`\n\ndebug\n${variantSummary()}\nsolve : ${escapeSolve(S.level,V.escape,V.beast,S.level>=3?8:6)?"ok":"fail"}${DBG.out?`\ncheck : ${DBG.out}`:""}\nkeys : v V R j J k K`:"")
1067 I.textContent=sidePanel()+(DBG.show?`\n\ndebug\n${variantSummary()}\nsolve : ${R.e?escapeSolve(S.level,V.escape,V.beast,S.level>=3?8:6)?"ok":"fail":"n/a"}\nfloor : ${R.i&&R.v&&R.e?floorSolve(seed,S.level)||"fail":"n/a"}${DBG.out?`\ncheck : ${DBG.out}`:""}\nkeys : v V R j J k K T`:"")
9191068 L.textContent=[S.msg,S.hint,roomMsg()].filter(Boolean).join("\n")
9201069 X.onclick=()=>{reset(1);render()}
9211070 X.hidden=true
README.md +9
@@ -50,6 +50,15 @@ From the title screen, press a number to start:
5050
5151Win once, and a hidden seventh floor opens up — for anyone who isn't done yet.
5252
53### Budget & verification
54The whole game is the two raw files in `1mb/` — no build step, no minification, ever — and together they must stay under 1,048,576 bytes (1 MiB). `tools/check.sh` is the gate: it prints the byte ledger and proves every escape room stays winnable across hundreds of seeds (ledger history lives in [docs/SIZES.md](docs/SIZES.md)).
55
56```sh
57tools/check.sh
58```
59
60To run it automatically before each commit: `ln -s ../../tools/pre-commit .git/hooks/pre-commit`
61
5362---
5463
5564## 1KB Version — the hypertext original
docs/SIZES.md added +9
@@ -0,0 +1,9 @@
1# 1MB Roguelike — size ledger
2
3Raw bytes of everything in `1mb/`, against the 1,048,576-byte (1 MiB) budget.
4Record a row after each phase of work; `tools/check-size.sh` prints the current numbers.
5
6| Phase | Date | index.html | data.js | Total | % of budget |
7|---|---|---:|---:|---:|---:|
8| Phase 0 — guardrails (no game changes) | 2026-07-12 | 55,225 | 27,015 | 82,240 | 7.8% |
9| Phase 1 — engine enablers (data-driven graph, procgen rooms, full-floor solver) | 2026-07-12 | 61,356 | 28,998 | 90,354 | 8.6% |
tools/check-size.sh added +45
@@ -0,0 +1,45 @@
1#!/bin/sh
2# Byte-budget gate for the 1MB roguelike.
3# Usage: tools/check-size.sh [dir] (dir defaults to the repo's 1mb/)
4# Prints a per-file ledger, the total, and percent of budget.
5# Exit 1 if the total exceeds 1,048,576 bytes; loud warning above 95%.
6set -eu
7
8dir=${1:-$(cd "$(dirname "$0")/.." && pwd)/1mb}
9budget=1048576
10
11if [ ! -d "$dir" ]; then
12 echo "check-size: no such directory: $dir" >&2
13 exit 2
14fi
15
16total=0
17echo "byte ledger for $dir"
18echo "----------------------------------------"
19# Newline-separated iteration so paths with spaces survive.
20oldifs=$IFS
21IFS='
22'
23for f in $(find "$dir" -type f | sort); do
24 bytes=$(wc -c < "$f")
25 bytes=$((bytes + 0))
26 total=$((total + bytes))
27 printf '%10d %s\n' "$bytes" "${f#"$dir"/}"
28done
29IFS=$oldifs
30
31# Percent to one decimal place using integer math only.
32pct10=$((total * 1000 / budget))
33echo "----------------------------------------"
34printf '%10d total (%d.%d%% of %d)\n' "$total" $((pct10 / 10)) $((pct10 % 10)) "$budget"
35
36if [ "$total" -gt "$budget" ]; then
37 echo "FAIL: over budget by $((total - budget)) bytes" >&2
38 exit 1
39fi
40if [ "$pct10" -ge 950 ]; then
41 echo ""
42 echo "!!! WARNING: past 95% of the 1 MiB budget ($((budget - total)) bytes left) !!!"
43 echo ""
44fi
45echo "OK: $((budget - total)) bytes remaining"
tools/check.sh added +13
@@ -0,0 +1,13 @@
1#!/bin/sh
2# Single verification entry point for the 1MB roguelike:
3# size budget gate + headless solver sweep.
4# Usage: tools/check.sh [seedCount] (seedCount defaults to 200)
5set -eu
6
7root=$(cd "$(dirname "$0")/.." && pwd)
8
9"$root/tools/check-size.sh"
10echo ""
11node "$root/tools/validate.js" "${1:-200}"
12echo ""
13echo "check: all gates passed"
tools/pre-commit added +7
@@ -0,0 +1,7 @@
1#!/bin/sh
2# Optional git pre-commit hook: run the 1MB budget + solver gate.
3# Install (from the repo root):
4# ln -s ../../tools/pre-commit .git/hooks/pre-commit
5set -eu
6root=$(git rev-parse --show-toplevel)
7exec "$root/tools/check.sh"
tools/validate.js added +104
@@ -0,0 +1,104 @@
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)`)