diff options
| -rw-r--r-- | 1mb/data.js | 87 | ||||
| -rw-r--r-- | 1mb/index.html | 227 | ||||
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | docs/SIZES.md | 9 | ||||
| -rwxr-xr-x | tools/check-size.sh | 45 | ||||
| -rwxr-xr-x | tools/check.sh | 13 | ||||
| -rwxr-xr-x | tools/pre-commit | 7 | ||||
| -rwxr-xr-x | tools/validate.js | 104 |
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 @@ -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)`) |
