summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-13 00:26:52 -0500
committerChristian Cleberg <[email protected]>2026-07-13 18:46:35 -0500
commitf1a0321e1edb598cd1eb421e4621be769b2d8b75 (patch)
treec5d1307a4db81aa56acbce273cf117a05689e492
parentde2460845efda51820eb3c6343fb158661ae76bb (diff)
downloadmicro-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.
-rw-r--r--1mb/data.js87
-rw-r--r--1mb/index.html227
-rw-r--r--README.md9
-rw-r--r--docs/SIZES.md9
-rwxr-xr-xtools/check-size.sh45
-rwxr-xr-xtools/check.sh13
-rwxr-xr-xtools/pre-commit7
-rwxr-xr-xtools/validate.js104
8 files changed, 439 insertions, 62 deletions
diff --git a/1mb/data.js b/1mb/data.js
index 1a9e32f..2fdae59 100644
--- a/1mb/data.js
+++ b/1mb/data.js
@@ -944,7 +944,7 @@ const ESCAPES6=[[
"############"]]
const BEASTS6=[[10,8],[10,7],[9,7],[10,6]]
const LEVEL_ORDER=[1,2,3,4,5,6]
-const LEVEL_ARCS={1:"foundation",2:"pressure",3:"ward",4:"hunt",5:"hollow",6:"finale",7:"afterglow"}
+const LEVEL_ARCS={1:"foundation",2:"pressure",3:"ward",4:"hunt",5:"hollow",6:"finale",7:"afterglow",9:"proving ground"}
const MODES={
standard:{label:"standard",desc:"full campaign"},
iron:{label:"iron",desc:"early beast"},
@@ -980,7 +980,8 @@ const FLOOR_THEMES={
4:{name:"hunt",tag:"rose alarm",accent:"pink hunt",fog:"thin red haze"},
5:{name:"hollow",tag:"ash hush",accent:"bone gray",fog:"dry blackout"},
6:{name:"finale",tag:"crown weather",accent:"gold ruin",fog:"judging dark"},
- 7:{name:"afterglow",tag:"borrowed dawn",accent:"ash gold",fog:"embered haze"}
+ 7:{name:"afterglow",tag:"borrowed dawn",accent:"ash gold",fog:"embered haze"},
+ 9:{name:"proving ground",tag:"debug",accent:"bare stone",fog:"none"}
}
const ENEMY_TEXT={
beast:["A one-eyed pursuer that reads space better than fear.","It wants a lane, not a duel."],
@@ -1124,10 +1125,11 @@ const LEVELS={
4:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES4,beasts:BEASTS4,dark:"behipcv",tone:"The hunt floor listens for every sound you make."},
5:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES5,beasts:BEASTS5,dark:"behipcv",tone:"The hollow floor strips away whatever was merely enough."},
6:{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."},
-7:{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."}
+7:{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."},
+9:{pits:PITS,idols:IDOLS,cracks:CRACKS,halls:HALLS,escapes:ESCAPES,beasts:BEASTS,dark:"",tone:"A proving ground outside the campaign."}
}
const BASE={
-g:{m:[
+g:[
"############",
"#..........#",
"#..L.....R.#",
@@ -1139,8 +1141,8 @@ g:{m:[
"#..........#",
"#..........#",
"#..........#",
-"############"],ex:{">":"h",L:"b",R:"e"}},
-b:{m:[
+"############"],
+b:[
"############",
"#..........#",
"#...MT.....#",
@@ -1152,8 +1154,8 @@ b:{m:[
"#..........#",
"#..........#",
"#..........#",
-"############"],ex:{">":"h"}},
-v:{m:[
+"############"],
+v:[
"############",
"#..........#",
"#....C.K...#",
@@ -1165,11 +1167,11 @@ v:{m:[
"#..........#",
"#....>.....#",
"#..........#",
-"############"],ex:{">":"e"}},
-d:{m:["############","#..........#","#..........#","#..........#","#....X.....#","#..........#","#..........#","#..........#","#..........#","#..........#","#..........#","############"],ex:{}}
+"############"],
+d:["############","#..........#","#..........#","#..........#","#....X.....#","#..........#","#..........#","#..........#","#..........#","#..........#","#..........#","############"]
}
const BASE2={
-g:{m:[
+g:[
"############",
"#..........#",
"#..L..#..R.#",
@@ -1181,8 +1183,8 @@ g:{m:[
"#..........#",
"#..........#",
"#..........#",
-"############"],ex:{">":"h",L:"b",R:"e"}},
-b:{m:[
+"############"],
+b:[
"############",
"#..........#",
"#...MT.....#",
@@ -1194,8 +1196,8 @@ b:{m:[
"#..........#",
"#..........#",
"#..........#",
-"############"],ex:{">":"h"}},
-v:{m:[
+"############"],
+v:[
"############",
"#..........#",
"#...#C.K...#",
@@ -1207,11 +1209,11 @@ v:{m:[
"#..........#",
"#...>......#",
"#..........#",
-"############"],ex:{">":"e"}},
+"############"],
d:BASE.d
}
const BASE3={
-g:{m:[
+g:[
"############",
"#..........#",
"#..L..S..R.#",
@@ -1223,8 +1225,8 @@ g:{m:[
"#..........#",
"#..........#",
"#..........#",
-"############"],ex:{">":"h",L:"b",R:"e"}},
-b:{m:[
+"############"],
+b:[
"############",
"#..........#",
"#..SMT.....#",
@@ -1236,8 +1238,8 @@ b:{m:[
"#..........#",
"#..........#",
"#..........#",
-"############"],ex:{">":"h"}},
-v:{m:[
+"############"],
+v:[
"############",
"#..........#",
"#...#C.K.S.#",
@@ -1249,7 +1251,46 @@ v:{m:[
"#..........#",
"#...>......#",
"#..........#",
-"############"],ex:{">":"e"}},
+"############"],
d:BASE.d
}
-const BASES={1:BASE,2:BASE2,3:BASE3,4:BASE3,5:BASE3,6:BASE3,7:BASE3}
+const BASES={1:BASE,2:BASE2,3:BASE3,4:BASE3,5:BASE3,6:BASE3,7:BASE3,9:BASE}
+// Procedural room slots: floors 2+ draw each variant pick from the authored
+// arrays above plus PROC_SLOTS generated layouts per room type (escapes only
+// through floor 5 — the finale arenas stay authored, as do BASES rooms).
+// Specs feed genRoom() in the engine: exits lists required exit letters
+// ("any" = random open tile), ring is the pit-room O border, walls and pits
+// are tile budgets for wall clusters and pit veins, traps/items sprinkle
+// chars onto open floor, stamp drops a small pattern whole onto the map.
+const PROC_SLOTS=2
+const PROC_SPECS={
+ pits:{ring:1,pits:9,walls:2,exits:{">":"any"},items:{F:1}},
+ idols:{walls:3,exits:{">":"any"},stamp:["ggg","gIg","ggg"]},
+ cracks:{walls:14,traps:{x:3},exits:{">":"any"}},
+ halls:{walls:5,exits:{a:"any",b:"any",c:"any",d:"any",">":"any"},items:{F:1}},
+ escapes:{walls:5,exits:{">":"any"}}
+}
+// Floor topology: room set + exit wiring, consumed generically by build().
+// base: fixed map from BASES[floor]. arr + v: variant array in LEVELS[floor]
+// and the V slot that picks the variant. An exit destination prefixed "!" is
+// one-way: it seals behind you after one crossing. Room codes beyond one char
+// (h2) are extra instances; they inherit family behavior from the first letter.
+const TOPO={
+ g:{base:"g",ex:{">":"h",L:"b",R:"e"}},
+ b:{base:"b",ex:{">":"h"}},
+ v:{base:"v",ex:{">":"e"}},
+ d:{base:"d",ex:{}},
+ h:{arr:"halls",v:"hall",ex:{a:"p",b:"i",c:"c",d:"d",">":"g"}},
+ p:{arr:"pits",v:"pit",ex:{">":"v"}},
+ i:{arr:"idols",v:"idol",ex:{">":"h"}},
+ c:{arr:"cracks",v:"crack",ex:{">":"v"}},
+ e:{arr:"escapes",v:"escape",ex:{">":"g"}}
+}
+const GRAPH={1:TOPO,2:TOPO,3:TOPO,4:TOPO,5:TOPO,6:TOPO,7:TOPO,
+// floor 9: debug proving ground (T in play) — a second hall instance reached
+// by a one-way drop; exit b in h2 climbs back to the first hall.
+9:{
+ g:{base:"g",ex:{">":"h"}},
+ h:{arr:"halls",v:"hall",ex:{a:"!h2",">":"g"}},
+ h2:{arr:"halls",v:"hall",ex:{b:"h",">":"g"}}
+}}
diff --git a/1mb/index.html b/1mb/index.html
index 32cc57c..4668483 100644
--- a/1mb/index.html
+++ b/1mb/index.html
@@ -131,13 +131,90 @@ function campOffers(next){
return [a,b]
}
+function genRoom(rand,spec){
+ const pick=n=>(rand()*n)|0,lo=spec.ring?2:1,hi=spec.ring?W-3:W-2
+ const bad=[...(spec.keep||[]),...(spec.blocked||[])]
+ const openAt=(g,x,y)=>g[y][x]=="."&&!bad.some(b=>b[0]==x&&b[1]==y)
+ 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}
+ for(let t=0;t<25;t++){
+ const sparse=t==24
+ const g=Array.from({length:H},(_,y)=>Array.from({length:W},(_,x)=>!x||!y||x==W-1||y==H-1?"#":"."))
+ 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"
+ if(!sparse){
+ for(let w=spec.walls||0;w>0;){
+ const hz=pick(2),len=2+pick(3)
+ const x=lo+pick(hi-lo+1-(hz?len:0)),y=lo+pick(hi-lo+1-(hz?0:len))
+ 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]="#"
+ w-=len
+ }
+ let px=lo+pick(hi-lo+1),py=lo+pick(hi-lo+1)
+ for(let p=spec.pits||0;p>0;p--){
+ if(openAt(g,px,py))g[py][px]="O"
+ if(pick(5)){const d=[[1,0],[-1,0],[0,1],[0,-1]][pick(4)]
+ px=Math.min(hi,Math.max(lo,px+d[0]));py=Math.min(hi,Math.max(lo,py+d[1]))
+ }else{px=lo+pick(hi-lo+1);py=lo+pick(hi-lo+1)}
+ }
+ }
+ if(spec.stamp){
+ const sh=spec.stamp.length,sw=spec.stamp[0].length
+ let put=0
+ for(let s=0;s<20&&!put;s++){
+ const sx=lo+pick(hi-lo+2-sw),sy=lo+pick(hi-lo+2-sh)
+ if(bad.some(b=>b[0]>=sx&&b[0]<sx+sw&&b[1]>=sy&&b[1]<sy+sh))continue
+ for(let y=0;y<sh;y++)for(let x=0;x<sw;x++)g[sy+y][sx+x]=spec.stamp[y][x]
+ put=1
+ }
+ if(!put)continue
+ }
+ let full=0
+ 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--){
+ const c=spots(g)
+ if(!c.length){full=1;break}
+ const [x,y]=c[pick(c.length)];g[y][x]=ch
+ }
+ for(const ch in spec.exits){
+ const at=spec.exits[ch]
+ if(at=="any"){
+ const c=spots(g)
+ if(!c.length){full=1;break}
+ const [x,y]=c[pick(c.length)];g[y][x]=ch
+ }else g[at[1]][at[0]]=ch
+ }
+ if(full)continue
+ for(const [x,y] of spec.keep||[])if(g[y][x]=="#"||g[y][x]=="O")g[y][x]="."
+ const solid=(x,y)=>g[y][x]=="#"||g[y][x]=="O"||(spec.blocked||[]).some(b=>b[0]==x&&b[1]==y)
+ let total=0,start=null
+ for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(!solid(x,y)){total++;start=start||[x,y]}
+ const seen=new Set([start.join()]),q=[start]
+ while(q.length){
+ const [x,y]=q.pop()
+ for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
+ const nx=x+dx,ny=y+dy,k=nx+","+ny
+ if(nx<0||ny<0||nx>=W||ny>=H||solid(nx,ny)||seen.has(k))continue
+ seen.add(k);q.push([nx,ny])
+ }
+ }
+ if(seen.size==total)return g.map(row=>row.join(""))
+ }
+}
+function procSpec(kind,level,code){
+ const spec={...PROC_SPECS[kind]},keep=[(P[code]||P[code[0]]).slice()],blocked=[]
+ for(const s of BLOCK_SPOTS[level]||[])if(s[0]==code)blocked.push([s[1],s[2]])
+ for(const T of [RELIC_SPOTS,EFFIGY_SPOTS,BRAZIER_SPOTS,SHRINE_SPOTS,CURSE_SPOTS,SHADE_SPOTS,WATCHER_SPOTS,LEECH_SPOTS])
+ for(const s of T[level]||[])if(s[0]==code)keep.push([s[1],s[2]])
+ if(kind=="escapes")for(const b of LEVELS[level].beasts)keep.push(b.slice())
+ spec.keep=keep;spec.blocked=blocked
+ return spec
+}
+
function build(seed0){
- const L=LEVELS[S&&S.level||1],r=rng(seed0+((S&&S.level||1)-1)*9973)
- const B=BASES[S&&S.level||1]
- 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}
+ const level=S&&S.level||1
+ const L=LEVELS[level],r=rng(seed0+(level-1)*9973)
+ const B=BASES[level]
+ const slots=k=>level<2||(k=="escapes"&&level>=6)?0:PROC_SLOTS
+ 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}
B0=L.beasts[V.beast]
const relicBag=RELIC_IDS.slice().sort(()=>r()-.5)
- const level=S&&S.level||1
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})),
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})),
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){
leeches:pickSpots(r,LEECH_SPOTS[level]||[],Math.max(0,level-2)).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
blocks:pickSpots(r,BLOCK_SPOTS[level]||[],99).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
effigies:pickSpots(r,EFFIGY_SPOTS[level]||[],Math.max(0,level-1)).map(v=>({r:v[0],x:v[1],y:v[2],on:1}))}
- R={
- g:B.g,b:B.b,v:B.v,d:B.d,
- h:{m:L.halls[V.hall],ex:{a:"p",b:"i",c:"c",d:"d",">":"g"}},
- p:{m:L.pits[V.pit],ex:{">":"v"}},
- i:{m:L.idols[V.idol],ex:{">":"h"}},
- c:{m:L.cracks[V.crack],ex:{">":"v"}},
- e:{m:L.escapes[V.escape],ex:{">":"g"}}
+ R={}
+ for(const code in GRAPH[level]){
+ const spec=GRAPH[level][code],arr=L[spec.arr],vi=V[spec.v]
+ const m=spec.base?B[spec.base]:vi<arr.length?arr[vi]:genRoom(r,procSpec(spec.arr,level,code))||arr[vi%arr.length]
+ R[code]={m,ex:{...spec.ex}}
+ }
+ if(R.e&&V.escape>=L.escapes.length){
+ let ok=escapeSolve(level,V.escape,V.beast,level>=3?8:6)
+ for(let i=0;i<6&&!ok;i++){
+ R.e.m=genRoom(r,procSpec("escapes",level,"e"))||L.escapes[V.escape%L.escapes.length]
+ ok=escapeSolve(level,V.escape,V.beast,level>=3?8:6)
+ }
+ if(!ok)R.e.m=L.escapes[V.escape%L.escapes.length]
}
}
@@ -341,8 +424,8 @@ function tile(x,y,r=S.r){
if(overAt(r,x,y,"watchers"))return "q"
if(overAt(r,x,y,"leeches"))return "l"
if(overAt(r,x,y,"curses"))return "z"
- if(r=="b"&&S.flags.mask&&x==4&&y==2)return "."
- if(r=="b"&&S.flags.torch&&x==5&&y==2)return "."
+ if(r[0]=="b"&&S.flags.mask&&x==4&&y==2)return "."
+ if(r[0]=="b"&&S.flags.torch&&x==5&&y==2)return "."
return base(x,y,r)
}
function 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
function 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}
function sign(n){return n<0?-1:n>0?1:0}
function dist(ax,ay,bx,by){return Math.abs(ax-bx)+Math.abs(ay-by)}
-function dark(r=S.r){return LEVELS[S.level].dark.includes(r)}
+function dark(r=S.r){return LEVELS[S.level].dark.includes(r[0])}
function carriedFire(){return S.flags.torch&&!S.flags.mask}
function fire(){return carriedFire()||(S.drop&&S.drop.r=="e"&&S.drop.fuel)}
@@ -378,6 +461,7 @@ function runStyle(){
}
function roomMsg(r=S.r){
+ r=r[0]
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.")
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.")
if(r=="e"){
@@ -496,7 +580,7 @@ function variantSummary(){
}
function escapeSolve(level=S.level,escapeIdx=V.escape,beastIdx=V.beast,torch=6){
- 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()
+ 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()
while(q.length){
const [s,path]=q.shift(),k=[s.x,s.y,s.bx,s.by,s.torch,s.chase,s.ward].join()
if(seen.has(k))continue
@@ -531,18 +615,70 @@ function escapeSolve(level=S.level,escapeIdx=V.escape,beastIdx=V.beast,torch=6){
return ""
}
+function floorSolve(seed0,level=S&&S.level||1){
+ const kS=S,kV=V,kR=R,kO=O,kB=B0
+ S={level,mods:kS&&kS.mods||pickMods(seed0)}
+ build(seed0)
+ const solidAt=(rm,x,y)=>{const t=R[rm].m[y][x];return t=="#"||t=="O"||!!overAt(rm,x,y,"blocks")}
+ 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}
+ const exitTo=(rm,dest)=>{for(const ch in R[rm].ex)if(R[rm].ex[ch].replace("!","")==dest)return find(rm,ch);return null}
+ const cost=(rm,from,...tos)=>{
+ let total=0,at=from
+ for(const to of tos){
+ if(!to)return -1
+ let d=-1
+ const seen=new Set([at.join()]),q=[[at[0],at[1],0]]
+ while(q.length){
+ const [x,y,n]=q.shift()
+ if(x==to[0]&&y==to[1]){d=n;break}
+ for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
+ const nx=x+dx,ny=y+dy,k=nx+","+ny
+ if(nx<0||ny<0||nx>=W||ny>=H||seen.has(k)||solidAt(rm,nx,ny))continue
+ seen.add(k);q.push([nx,ny,n+1])
+ }
+ }
+ if(d<0)return -1
+ total+=d;at=to
+ }
+ return total
+ }
+ const sp=rm=>(P[rm]||P[rm[0]]).slice()
+ const out=(()=>{
+ if(!R.b||!R.h||!R.i||!R.v||!R.e)return ""
+ let total=0
+ for(const c of [
+ cost("g",sp("g"),exitTo("g","b")),
+ cost("b",sp("b"),find("b","T"),exitTo("b","h")),
+ cost("h",sp("h"),exitTo("h","i")),
+ cost("i",sp("i"),find("i","I"),exitTo("i","h"))
+ ]){if(c<0)return "";total+=c}
+ const via=r2=>{
+ const a=cost("h",sp("h"),exitTo("h",r2))
+ if(a<0)return -1
+ const b=cost(r2,sp(r2),exitTo(r2,"v"))
+ return b<0?-1:a+b
+ }
+ const vp=via("p"),vc=via("c")
+ if(vp<0&&vc<0)return ""
+ total+=vp<0?vc:vc<0?vp:Math.min(vp,vc)
+ const vault=cost("v",sp("v"),find("v","C"),find("v","K"),exitTo("v","e"))
+ if(vault<0)return ""
+ total+=vault
+ // floor 1 crosses e with fresh-run fuel plus a brazier refuel: 8 is fair
+ const esc=escapeSolve(level,V.escape,V.beast,level==2?6:8)
+ if(!esc)return ""
+ return "ok:"+(total+esc.length)
+ })()
+ S=kS;V=kV;R=kR;O=kO;B0=kB
+ return out
+}
+
function validateSeeds(n=24){
- const keep=S
let bad=0,msg=[]
- for(const level of [2,3,4,5,6])for(let i=0;i<n;i++){
- const t={level,mods:keep.mods||pickMods(seed+i)}
- S=t;build(seed+i)
- const path=escapeSolve(level,V.escape,V.beast,level>=3?8:6)
- if(!path){bad++;if(msg.length<6)msg.push(`l${level}:${(seed+i).toString(36)} e${V.escape} b${V.beast}`)}
+ for(const level of [1,2,3,4,5,6,7])for(let i=0;i<n;i++){
+ if(!floorSolve(seed+i,level)){bad++;if(msg.length<6)msg.push(`l${level}:${(seed+i).toString(36)}`)}
}
- S=keep
- build(seed)
- return `check ${n} seeds | ${bad?"bad "+bad+" "+msg.join(", "):"all clear"}`
+ return `check ${n} seeds x floors 1-7 | ${bad?"bad "+bad+" "+msg.join(", "):"all clear"}`
}
function dropTorch(){
@@ -582,6 +718,7 @@ function beastState(){
function cover(x,y){
let n=0
+ if(!R.e)return 0
for(const d of [[1,0],[-1,0],[0,1],[0,-1]])if(tile(x+d[0],y+d[1],"e")=="#")n++
return n
}
@@ -619,7 +756,7 @@ function beastTurn(){
function burnTorch(){
if(!S.alive||S.win||!dark())return
if(S.flags.torch){
- let drain=(S.level==2||S.level>=4)&&"ec".includes(S.r)?TORCH_DRAIN_HAZARD:TORCH_DRAIN_BASE
+ let drain=(S.level==2||S.level>=4)&&"ec".includes(S.r[0])?TORCH_DRAIN_HAZARD:TORCH_DRAIN_BASE
if(S.level>=3&&S.flags.ward)drain++
if(hasMod("thin"))drain++
if(S.flags.relic=="wick")drain++
@@ -656,31 +793,32 @@ function shadow(){
return 0
}
-function enter(r,x=P[r][0],y=P[r][1],msg){
+function enter(r,x=(P[r]||P[r[0]])[0],y=(P[r]||P[r[0]])[1],msg){
S.hint=""
S.r=r;S.x=x;S.y=y
- if(r=="e"){S.chase=0;if(S.flags.crown)S.beast={x:B0[0],y:B0[1]}}
- if(r=="p")S.path.pit++
- if(r=="c")S.path.crack++
- if(r=="d")return die("The black arch keeps its promise.")
- 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.")
- if(r=="c"&&S.flags.gold&&!S.flags.mask&&S.flags.relic!="tooth"){
+ const f=r[0]
+ if(f=="e"){S.chase=0;if(S.flags.crown)S.beast={x:B0[0],y:B0[1]}}
+ if(f=="p")S.path.pit++
+ if(f=="c")S.path.crack++
+ if(f=="d")return die("The black arch keeps its promise.")
+ 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.")
+ if(f=="c"&&S.flags.gold&&!S.flags.mask&&S.flags.relic!="tooth"){
if(S.level>=3&&S.flags.ward){loseWard(S.flags.relic=="greave"?4:3);msg="Ward parts the crack; the blessing thins."}
else return die("Weighted by treasure, you wedge fast in the crack.")
}
- if(r=="c"&&S.flags.torch&&!(S.flags.relic=="veil"&&S.flags.mask)){
+ if(f=="c"&&S.flags.torch&&!(S.flags.relic=="veil"&&S.flags.mask)){
S.flags.torch=Math.max(0,S.flags.torch-(S.flags.relic=="greave"?2:S.flags.relic=="wick"?0:S.flags.torch))
S.flags.ash=!S.flags.torch?1:S.flags.ash
msg=S.flags.torch?"Crack strips the torch.":"Crack snuffs the torch."
}
- if(r=="g"&&S.flags.crown&&S.flags.key){
+ if(f=="g"&&S.flags.crown&&S.flags.key){
if(S.level<5)return startCamp(S.level+1)
if(S.level==5)return startFinale()
return win()
}
- if(r=="b"&&!S.flags.torch)showHint("torch")
- if(r=="c")showHint("crack")
- if(r=="e")showHint("beast")
+ if(f=="b"&&!S.flags.torch)showHint("torch")
+ if(f=="c")showHint("crack")
+ if(f=="e")showHint("beast")
setMsg(msg||roomMsg(r))
}
@@ -786,8 +924,9 @@ function step(dx,dy){
if(t=="#")return setMsg(roomMsg())
S.turn++
if(shadow()){setMsg("Darkness twists the room around you.");burnTorch();beastTurn();return}
- const ex=R[S.r].ex[t]
+ let ex=R[S.r].ex[t]
if(ex){
+ if(ex[0]=="!"){delete R[S.r].ex[t];ex=ex.slice(1)}
if(S.r=="e"&&nx==S.beast.x&&ny==S.beast.y)return die("The beast tears you open.")
burnTorch();return enter(ex)
}
@@ -819,6 +958,15 @@ function jumpLevel(level=2){
render()
}
+function testFloor(){
+ startRun(S&&S.mode||"standard",0)
+ S.level=9;S.currentLevel=9
+ build(seed)
+ S.r="g";S.x=P.g[0];S.y=P.g[1]
+ setMsg("Proving ground. Exit a in the hall drops one-way into h2; exit b in h2 climbs back.")
+ render()
+}
+
function key(e){
if(e.key=="m"){muted=!muted;render();return}
if(S.gameState=="win"&&(e.key=="p"||e.key=="7")){startPostgame();render();return}
@@ -835,6 +983,7 @@ function key(e){
if(e.key=="J"){jumpLevel(3);return}
if(e.key=="k"){jumpLevel(4);return}
if(e.key=="K"){jumpLevel(5);return}
+ if(e.key=="T"){testFloor();return}
if(S.gameState=="camp"){
if("1234".includes(e.key))applyCampChoice(e.key)
render()
@@ -915,7 +1064,7 @@ function render(){
remember()
const th=floorTheme()
U.innerHTML=`floor ${S.level} | ${th.name}<small>${buildTitle()} | ${th.tag} | ${th.accent} | ${MODES[S.mode||"standard"].label} | seed ${S.seed.toString(36)}</small>`
- 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`:"")
+ 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`:"")
L.textContent=[S.msg,S.hint,roomMsg()].filter(Boolean).join("\n")
X.onclick=()=>{reset(1);render()}
X.hidden=true
diff --git a/README.md b/README.md
index f2e6ae5..1392bc5 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,15 @@ From the title screen, press a number to start:
Win once, and a hidden seventh floor opens up — for anyone who isn't done yet.
+### Budget & verification
+The 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)).
+
+```sh
+tools/check.sh
+```
+
+To run it automatically before each commit: `ln -s ../../tools/pre-commit .git/hooks/pre-commit`
+
---
## 1KB Version — the hypertext original
diff --git a/docs/SIZES.md b/docs/SIZES.md
new file mode 100644
index 0000000..3413d41
--- /dev/null
+++ b/docs/SIZES.md
@@ -0,0 +1,9 @@
+# 1MB Roguelike — size ledger
+
+Raw bytes of everything in `1mb/`, against the 1,048,576-byte (1 MiB) budget.
+Record a row after each phase of work; `tools/check-size.sh` prints the current numbers.
+
+| Phase | Date | index.html | data.js | Total | % of budget |
+|---|---|---:|---:|---:|---:|
+| Phase 0 — guardrails (no game changes) | 2026-07-12 | 55,225 | 27,015 | 82,240 | 7.8% |
+| Phase 1 — engine enablers (data-driven graph, procgen rooms, full-floor solver) | 2026-07-12 | 61,356 | 28,998 | 90,354 | 8.6% |
diff --git a/tools/check-size.sh b/tools/check-size.sh
new file mode 100755
index 0000000..ed28157
--- /dev/null
+++ b/tools/check-size.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+# Byte-budget gate for the 1MB roguelike.
+# Usage: tools/check-size.sh [dir] (dir defaults to the repo's 1mb/)
+# Prints a per-file ledger, the total, and percent of budget.
+# Exit 1 if the total exceeds 1,048,576 bytes; loud warning above 95%.
+set -eu
+
+dir=${1:-$(cd "$(dirname "$0")/.." && pwd)/1mb}
+budget=1048576
+
+if [ ! -d "$dir" ]; then
+ echo "check-size: no such directory: $dir" >&2
+ exit 2
+fi
+
+total=0
+echo "byte ledger for $dir"
+echo "----------------------------------------"
+# Newline-separated iteration so paths with spaces survive.
+oldifs=$IFS
+IFS='
+'
+for f in $(find "$dir" -type f | sort); do
+ bytes=$(wc -c < "$f")
+ bytes=$((bytes + 0))
+ total=$((total + bytes))
+ printf '%10d %s\n' "$bytes" "${f#"$dir"/}"
+done
+IFS=$oldifs
+
+# Percent to one decimal place using integer math only.
+pct10=$((total * 1000 / budget))
+echo "----------------------------------------"
+printf '%10d total (%d.%d%% of %d)\n' "$total" $((pct10 / 10)) $((pct10 % 10)) "$budget"
+
+if [ "$total" -gt "$budget" ]; then
+ echo "FAIL: over budget by $((total - budget)) bytes" >&2
+ exit 1
+fi
+if [ "$pct10" -ge 950 ]; then
+ echo ""
+ echo "!!! WARNING: past 95% of the 1 MiB budget ($((budget - total)) bytes left) !!!"
+ echo ""
+fi
+echo "OK: $((budget - total)) bytes remaining"
diff --git a/tools/check.sh b/tools/check.sh
new file mode 100755
index 0000000..0fa90f1
--- /dev/null
+++ b/tools/check.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+# Single verification entry point for the 1MB roguelike:
+# size budget gate + headless solver sweep.
+# Usage: tools/check.sh [seedCount] (seedCount defaults to 200)
+set -eu
+
+root=$(cd "$(dirname "$0")/.." && pwd)
+
+"$root/tools/check-size.sh"
+echo ""
+node "$root/tools/validate.js" "${1:-200}"
+echo ""
+echo "check: all gates passed"
diff --git a/tools/pre-commit b/tools/pre-commit
new file mode 100755
index 0000000..1cf06b6
--- /dev/null
+++ b/tools/pre-commit
@@ -0,0 +1,7 @@
+#!/bin/sh
+# Optional git pre-commit hook: run the 1MB budget + solver gate.
+# Install (from the repo root):
+# ln -s ../../tools/pre-commit .git/hooks/pre-commit
+set -eu
+root=$(git rev-parse --show-toplevel)
+exec "$root/tools/check.sh"
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)`)