summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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)`)