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=[[
944"############"]] 944"############"]]
945const BEASTS6=[[10,8],[10,7],[9,7],[10,6]] 945const BEASTS6=[[10,8],[10,7],[9,7],[10,6]]
946const LEVEL_ORDER=[1,2,3,4,5,6] 946const 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"}
948const MODES={ 948const MODES={
949 standard:{label:"standard",desc:"full campaign"}, 949 standard:{label:"standard",desc:"full campaign"},
950 iron:{label:"iron",desc:"early beast"}, 950 iron:{label:"iron",desc:"early beast"},
@@ -980,7 +980,8 @@ const FLOOR_THEMES={
980 4:{name:"hunt",tag:"rose alarm",accent:"pink hunt",fog:"thin red haze"}, 980 4:{name:"hunt",tag:"rose alarm",accent:"pink hunt",fog:"thin red haze"},
981 5:{name:"hollow",tag:"ash hush",accent:"bone gray",fog:"dry blackout"}, 981 5:{name:"hollow",tag:"ash hush",accent:"bone gray",fog:"dry blackout"},
982 6:{name:"finale",tag:"crown weather",accent:"gold ruin",fog:"judging dark"}, 982 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"}
984} 985}
985const ENEMY_TEXT={ 986const ENEMY_TEXT={
986 beast:["A one-eyed pursuer that reads space better than fear.","It wants a lane, not a duel."], 987 beast:["A one-eyed pursuer that reads space better than fear.","It wants a lane, not a duel."],
@@ -1124,10 +1125,11 @@ const LEVELS={
11244:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES4,beasts:BEASTS4,dark:"behipcv",tone:"The hunt floor listens for every sound you make."}, 11254:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES4,beasts:BEASTS4,dark:"behipcv",tone:"The hunt floor listens for every sound you make."},
11255:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES5,beasts:BEASTS5,dark:"behipcv",tone:"The hollow floor strips away whatever was merely enough."}, 11265:{pits:PITS4,idols:IDOLS4,cracks:CRACKS4,halls:HALLS4,escapes:ESCAPES5,beasts:BEASTS5,dark:"behipcv",tone:"The hollow floor strips away whatever was merely enough."},
11266:{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."}, 11276:{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."}
1128} 1130}
1129const BASE={ 1131const BASE={
1130g:{m:[ 1132g:[
1131"############", 1133"############",
1132"#..........#", 1134"#..........#",
1133"#..L.....R.#", 1135"#..L.....R.#",
@@ -1139,8 +1141,8 @@ g:{m:[
1139"#..........#", 1141"#..........#",
1140"#..........#", 1142"#..........#",
1141"#..........#", 1143"#..........#",
1142"############"],ex:{">":"h",L:"b",R:"e"}}, 1144"############"],
1143b:{m:[ 1145b:[
1144"############", 1146"############",
1145"#..........#", 1147"#..........#",
1146"#...MT.....#", 1148"#...MT.....#",
@@ -1152,8 +1154,8 @@ b:{m:[
1152"#..........#", 1154"#..........#",
1153"#..........#", 1155"#..........#",
1154"#..........#", 1156"#..........#",
1155"############"],ex:{">":"h"}}, 1157"############"],
1156v:{m:[ 1158v:[
1157"############", 1159"############",
1158"#..........#", 1160"#..........#",
1159"#....C.K...#", 1161"#....C.K...#",
@@ -1165,11 +1167,11 @@ v:{m:[
1165"#..........#", 1167"#..........#",
1166"#....>.....#", 1168"#....>.....#",
1167"#..........#", 1169"#..........#",
1168"############"],ex:{">":"e"}}, 1170"############"],
1169d:{m:["############","#..........#","#..........#","#..........#","#....X.....#","#..........#","#..........#","#..........#","#..........#","#..........#","#..........#","############"],ex:{}} 1171d:["############","#..........#","#..........#","#..........#","#....X.....#","#..........#","#..........#","#..........#","#..........#","#..........#","#..........#","############"]
1170} 1172}
1171const BASE2={ 1173const BASE2={
1172g:{m:[ 1174g:[
1173"############", 1175"############",
1174"#..........#", 1176"#..........#",
1175"#..L..#..R.#", 1177"#..L..#..R.#",
@@ -1181,8 +1183,8 @@ g:{m:[
1181"#..........#", 1183"#..........#",
1182"#..........#", 1184"#..........#",
1183"#..........#", 1185"#..........#",
1184"############"],ex:{">":"h",L:"b",R:"e"}}, 1186"############"],
1185b:{m:[ 1187b:[
1186"############", 1188"############",
1187"#..........#", 1189"#..........#",
1188"#...MT.....#", 1190"#...MT.....#",
@@ -1194,8 +1196,8 @@ b:{m:[
1194"#..........#", 1196"#..........#",
1195"#..........#", 1197"#..........#",
1196"#..........#", 1198"#..........#",
1197"############"],ex:{">":"h"}}, 1199"############"],
1198v:{m:[ 1200v:[
1199"############", 1201"############",
1200"#..........#", 1202"#..........#",
1201"#...#C.K...#", 1203"#...#C.K...#",
@@ -1207,11 +1209,11 @@ v:{m:[
1207"#..........#", 1209"#..........#",
1208"#...>......#", 1210"#...>......#",
1209"#..........#", 1211"#..........#",
1210"############"],ex:{">":"e"}}, 1212"############"],
1211d:BASE.d 1213d:BASE.d
1212} 1214}
1213const BASE3={ 1215const BASE3={
1214g:{m:[ 1216g:[
1215"############", 1217"############",
1216"#..........#", 1218"#..........#",
1217"#..L..S..R.#", 1219"#..L..S..R.#",
@@ -1223,8 +1225,8 @@ g:{m:[
1223"#..........#", 1225"#..........#",
1224"#..........#", 1226"#..........#",
1225"#..........#", 1227"#..........#",
1226"############"],ex:{">":"h",L:"b",R:"e"}}, 1228"############"],
1227b:{m:[ 1229b:[
1228"############", 1230"############",
1229"#..........#", 1231"#..........#",
1230"#..SMT.....#", 1232"#..SMT.....#",
@@ -1236,8 +1238,8 @@ b:{m:[
1236"#..........#", 1238"#..........#",
1237"#..........#", 1239"#..........#",
1238"#..........#", 1240"#..........#",
1239"############"],ex:{">":"h"}}, 1241"############"],
1240v:{m:[ 1242v:[
1241"############", 1243"############",
1242"#..........#", 1244"#..........#",
1243"#...#C.K.S.#", 1245"#...#C.K.S.#",
@@ -1249,7 +1251,46 @@ v:{m:[
1249"#..........#", 1251"#..........#",
1250"#...>......#", 1252"#...>......#",
1251"#..........#", 1253"#..........#",
1252"############"],ex:{">":"e"}}, 1254"############"],
1253d:BASE.d 1255d:BASE.d
1254} 1256}
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){
131 return [a,b] 131 return [a,b]
132} 132}
133 133
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
134function build(seed0){ 210function build(seed0){
135 const L=LEVELS[S&&S.level||1],r=rng(seed0+((S&&S.level||1)-1)*9973) 211 const level=S&&S.level||1
136 const B=BASES[S&&S.level||1] 212 const L=LEVELS[level],r=rng(seed0+(level-1)*9973)
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} 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}
138 B0=L.beasts[V.beast] 216 B0=L.beasts[V.beast]
139 const relicBag=RELIC_IDS.slice().sort(()=>r()-.5) 217 const relicBag=RELIC_IDS.slice().sort(()=>r()-.5)
140 const level=S&&S.level||1
141 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})), 218 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})),
142 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})), 219 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})),
143 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})), 220 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){
147 leeches:pickSpots(r,LEECH_SPOTS[level]||[],Math.max(0,level-2)).map(v=>({r:v[0],x:v[1],y:v[2],on:1})), 224 leeches:pickSpots(r,LEECH_SPOTS[level]||[],Math.max(0,level-2)).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
148 blocks:pickSpots(r,BLOCK_SPOTS[level]||[],99).map(v=>({r:v[0],x:v[1],y:v[2],on:1})), 225 blocks:pickSpots(r,BLOCK_SPOTS[level]||[],99).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
149 effigies:pickSpots(r,EFFIGY_SPOTS[level]||[],Math.max(0,level-1)).map(v=>({r:v[0],x:v[1],y:v[2],on:1}))} 226 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={ 227 R={}
151 g:B.g,b:B.b,v:B.v,d:B.d, 228 for(const code in GRAPH[level]){
152 h:{m:L.halls[V.hall],ex:{a:"p",b:"i",c:"c",d:"d",">":"g"}}, 229 const spec=GRAPH[level][code],arr=L[spec.arr],vi=V[spec.v]
153 p:{m:L.pits[V.pit],ex:{">":"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]
154 i:{m:L.idols[V.idol],ex:{">":"h"}}, 231 R[code]={m,ex:{...spec.ex}}
155 c:{m:L.cracks[V.crack],ex:{">":"v"}}, 232 }
156 e:{m:L.escapes[V.escape],ex:{">":"g"}} 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]
157 } 240 }
158} 241}
159 242
@@ -341,8 +424,8 @@ function tile(x,y,r=S.r){
341 if(overAt(r,x,y,"watchers"))return "q" 424 if(overAt(r,x,y,"watchers"))return "q"
342 if(overAt(r,x,y,"leeches"))return "l" 425 if(overAt(r,x,y,"leeches"))return "l"
343 if(overAt(r,x,y,"curses"))return "z" 426 if(overAt(r,x,y,"curses"))return "z"
344 if(r=="b"&&S.flags.mask&&x==4&&y==2)return "." 427 if(r[0]=="b"&&S.flags.mask&&x==4&&y==2)return "."
345 if(r=="b"&&S.flags.torch&&x==5&&y==2)return "." 428 if(r[0]=="b"&&S.flags.torch&&x==5&&y==2)return "."
346 return base(x,y,r) 429 return base(x,y,r)
347} 430}
348function setMsg(msg){S.msg=msg} 431function 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
350function 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} 433function 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}
351function sign(n){return n<0?-1:n>0?1:0} 434function sign(n){return n<0?-1:n>0?1:0}
352function dist(ax,ay,bx,by){return Math.abs(ax-bx)+Math.abs(ay-by)} 435function 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])}
354function carriedFire(){return S.flags.torch&&!S.flags.mask} 437function carriedFire(){return S.flags.torch&&!S.flags.mask}
355function fire(){return carriedFire()||(S.drop&&S.drop.r=="e"&&S.drop.fuel)} 438function fire(){return carriedFire()||(S.drop&&S.drop.r=="e"&&S.drop.fuel)}
356 439
@@ -378,6 +461,7 @@ function runStyle(){
378} 461}
379 462
380function roomMsg(r=S.r){ 463function roomMsg(r=S.r){
464 r=r[0]
381 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.") 465 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.")
382 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.") 466 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.")
383 if(r=="e"){ 467 if(r=="e"){
@@ -496,7 +580,7 @@ function variantSummary(){
496} 580}
497 581
498function escapeSolve(level=S.level,escapeIdx=V.escape,beastIdx=V.beast,torch=6){ 582function 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()
500 while(q.length){ 584 while(q.length){
501 const [s,path]=q.shift(),k=[s.x,s.y,s.bx,s.by,s.torch,s.chase,s.ward].join() 585 const [s,path]=q.shift(),k=[s.x,s.y,s.bx,s.by,s.torch,s.chase,s.ward].join()
502 if(seen.has(k))continue 586 if(seen.has(k))continue
@@ -531,18 +615,70 @@ function escapeSolve(level=S.level,escapeIdx=V.escape,beastIdx=V.beast,torch=6){
531 return "" 615 return ""
532} 616}
533 617
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
534function validateSeeds(n=24){ 676function validateSeeds(n=24){
535 const keep=S
536 let bad=0,msg=[] 677 let bad=0,msg=[]
537 for(const level of [2,3,4,5,6])for(let i=0;i<n;i++){ 678 for(const level of [1,2,3,4,5,6,7])for(let i=0;i<n;i++){
538 const t={level,mods:keep.mods||pickMods(seed+i)} 679 if(!floorSolve(seed+i,level)){bad++;if(msg.length<6)msg.push(`l${level}:${(seed+i).toString(36)}`)}
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}`)}
542 } 680 }
543 S=keep 681 return `check ${n} seeds x floors 1-7 | ${bad?"bad "+bad+" "+msg.join(", "):"all clear"}`
544 build(seed)
545 return `check ${n} seeds | ${bad?"bad "+bad+" "+msg.join(", "):"all clear"}`
546} 682}
547 683
548function dropTorch(){ 684function dropTorch(){
@@ -582,6 +718,7 @@ function beastState(){
582 718
583function cover(x,y){ 719function cover(x,y){
584 let n=0 720 let n=0
721 if(!R.e)return 0
585 for(const d of [[1,0],[-1,0],[0,1],[0,-1]])if(tile(x+d[0],y+d[1],"e")=="#")n++ 722 for(const d of [[1,0],[-1,0],[0,1],[0,-1]])if(tile(x+d[0],y+d[1],"e")=="#")n++
586 return n 723 return n
587} 724}
@@ -619,7 +756,7 @@ function beastTurn(){
619function burnTorch(){ 756function burnTorch(){
620 if(!S.alive||S.win||!dark())return 757 if(!S.alive||S.win||!dark())return
621 if(S.flags.torch){ 758 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
623 if(S.level>=3&&S.flags.ward)drain++ 760 if(S.level>=3&&S.flags.ward)drain++
624 if(hasMod("thin"))drain++ 761 if(hasMod("thin"))drain++
625 if(S.flags.relic=="wick")drain++ 762 if(S.flags.relic=="wick")drain++
@@ -656,31 +793,32 @@ function shadow(){
656 return 0 793 return 0
657} 794}
658 795
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){
660 S.hint="" 797 S.hint=""
661 S.r=r;S.x=x;S.y=y 798 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]}} 799 const f=r[0]
663 if(r=="p")S.path.pit++ 800 if(f=="e"){S.chase=0;if(S.flags.crown)S.beast={x:B0[0],y:B0[1]}}
664 if(r=="c")S.path.crack++ 801 if(f=="p")S.path.pit++
665 if(r=="d")return die("The black arch keeps its promise.") 802 if(f=="c")S.path.crack++
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.") 803 if(f=="d")return die("The black arch keeps its promise.")
667 if(r=="c"&&S.flags.gold&&!S.flags.mask&&S.flags.relic!="tooth"){ 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"){
668 if(S.level>=3&&S.flags.ward){loseWard(S.flags.relic=="greave"?4:3);msg="Ward parts the crack; the blessing thins."} 806 if(S.level>=3&&S.flags.ward){loseWard(S.flags.relic=="greave"?4:3);msg="Ward parts the crack; the blessing thins."}
669 else return die("Weighted by treasure, you wedge fast in the crack.") 807 else return die("Weighted by treasure, you wedge fast in the crack.")
670 } 808 }
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)){
672 S.flags.torch=Math.max(0,S.flags.torch-(S.flags.relic=="greave"?2:S.flags.relic=="wick"?0:S.flags.torch)) 810 S.flags.torch=Math.max(0,S.flags.torch-(S.flags.relic=="greave"?2:S.flags.relic=="wick"?0:S.flags.torch))
673 S.flags.ash=!S.flags.torch?1:S.flags.ash 811 S.flags.ash=!S.flags.torch?1:S.flags.ash
674 msg=S.flags.torch?"Crack strips the torch.":"Crack snuffs the torch." 812 msg=S.flags.torch?"Crack strips the torch.":"Crack snuffs the torch."
675 } 813 }
676 if(r=="g"&&S.flags.crown&&S.flags.key){ 814 if(f=="g"&&S.flags.crown&&S.flags.key){
677 if(S.level<5)return startCamp(S.level+1) 815 if(S.level<5)return startCamp(S.level+1)
678 if(S.level==5)return startFinale() 816 if(S.level==5)return startFinale()
679 return win() 817 return win()
680 } 818 }
681 if(r=="b"&&!S.flags.torch)showHint("torch") 819 if(f=="b"&&!S.flags.torch)showHint("torch")
682 if(r=="c")showHint("crack") 820 if(f=="c")showHint("crack")
683 if(r=="e")showHint("beast") 821 if(f=="e")showHint("beast")
684 setMsg(msg||roomMsg(r)) 822 setMsg(msg||roomMsg(r))
685} 823}
686 824
@@ -786,8 +924,9 @@ function step(dx,dy){
786 if(t=="#")return setMsg(roomMsg()) 924 if(t=="#")return setMsg(roomMsg())
787 S.turn++ 925 S.turn++
788 if(shadow()){setMsg("Darkness twists the room around you.");burnTorch();beastTurn();return} 926 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]
790 if(ex){ 928 if(ex){
929 if(ex[0]=="!"){delete R[S.r].ex[t];ex=ex.slice(1)}
791 if(S.r=="e"&&nx==S.beast.x&&ny==S.beast.y)return die("The beast tears you open.") 930 if(S.r=="e"&&nx==S.beast.x&&ny==S.beast.y)return die("The beast tears you open.")
792 burnTorch();return enter(ex) 931 burnTorch();return enter(ex)
793 } 932 }
@@ -819,6 +958,15 @@ function jumpLevel(level=2){
819 render() 958 render()
820} 959}
821 960
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
822function key(e){ 970function key(e){
823 if(e.key=="m"){muted=!muted;render();return} 971 if(e.key=="m"){muted=!muted;render();return}
824 if(S.gameState=="win"&&(e.key=="p"||e.key=="7")){startPostgame();render();return} 972 if(S.gameState=="win"&&(e.key=="p"||e.key=="7")){startPostgame();render();return}
@@ -835,6 +983,7 @@ function key(e){
835 if(e.key=="J"){jumpLevel(3);return} 983 if(e.key=="J"){jumpLevel(3);return}
836 if(e.key=="k"){jumpLevel(4);return} 984 if(e.key=="k"){jumpLevel(4);return}
837 if(e.key=="K"){jumpLevel(5);return} 985 if(e.key=="K"){jumpLevel(5);return}
986 if(e.key=="T"){testFloor();return}
838 if(S.gameState=="camp"){ 987 if(S.gameState=="camp"){
839 if("1234".includes(e.key))applyCampChoice(e.key) 988 if("1234".includes(e.key))applyCampChoice(e.key)
840 render() 989 render()
@@ -915,7 +1064,7 @@ function render(){
915 remember() 1064 remember()
916 const th=floorTheme() 1065 const th=floorTheme()
917 U.innerHTML=`floor ${S.level} | ${th.name}<small>${buildTitle()} | ${th.tag} | ${th.accent} | ${MODES[S.mode||"standard"].label} | seed ${S.seed.toString(36)}</small>` 1066 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`:"")
919 L.textContent=[S.msg,S.hint,roomMsg()].filter(Boolean).join("\n") 1068 L.textContent=[S.msg,S.hint,roomMsg()].filter(Boolean).join("\n")
920 X.onclick=()=>{reset(1);render()} 1069 X.onclick=()=>{reset(1);render()}
921 X.hidden=true 1070 X.hidden=true
README.md +9
@@ -50,6 +50,15 @@ From the title screen, press a number to start:
50 50
51Win once, and a hidden seventh floor opens up — for anyone who isn't done yet. 51Win once, and a hidden seventh floor opens up — for anyone who isn't done yet.
52 52
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
53--- 62---
54 63
55## 1KB Version — the hypertext original 64## 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)`)