1mb/index.html

1142 lines · 64262 bytes

   1<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>1MB Roguelike v1</title>
   2<style>
   3:root{color-scheme:dark}
   4body{margin:0;min-width:100vw;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#020403;color:#dbe8d7;font:14px/1.3 monospace}
   5#s{--bg0:#07100b;--bg1:#010201;--panel:#07110cdd;--panelEdge:#29432d;--gridEdge:#213d28;--accent:#99d9a4;--accent2:#f0c86a;--fog:#d8f1db;--mist:#7fb48622;--glow:#9be8a055;--gridGlow:#9be8a022;--text:#deeddc;--muted:#8aa38f;--floor:#112015;--memory:#5f7b66;--hidden:#081009;--wall:#647569;--pit:#a4473e;--exit:#f7d37f;--torch:#ffbd63;--shrine:#9df3e8;--relic:#ffe27b;--hazard:#ff8777;--shade:#9bc1ff;--watcher:#ffd294;--leech:#8ef3da;--beast:#ff77d2;--player:#f8fff6;--shadow:rgba(0,0,0,.8);--wardGlow:#88fff330;width:100%;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:18px 14px 24px;overflow-x:hidden;box-sizing:border-box;background:
   6radial-gradient(circle at 50% 16%,var(--mist),transparent 30%),
   7radial-gradient(circle at 50% 45%,var(--gridGlow),transparent 46%),
   8linear-gradient(180deg,var(--bg0),var(--bg1) 72%);color:var(--text);transition:background .2s,padding .15s,color .15s}
   9#u,#i,#l,#g,#r{box-sizing:border-box}
  10#u{min-height:42px;width:fit-content;max-width:min(92vw,72ch);padding:9px 14px;border:1px solid color-mix(in srgb,var(--panelEdge) 72%,#5b6a60);background:linear-gradient(180deg,#0b1510d8,#050805f0);box-shadow:0 0 0 1px #000 inset,0 0 18px #0006;text-align:center;white-space:pre-line;color:var(--text);letter-spacing:.04em}
  11#u small{display:block;color:var(--muted);margin-top:3px;font-size:12px;letter-spacing:.14em;text-transform:uppercase}
  12#p{display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:center;gap:16px;width:min(100%,860px);min-height:214px;max-width:100%;overflow:visible}
  13#g{position:relative;display:grid;grid-template-columns:repeat(12,16px);gap:1px;padding:12px;border:1px solid var(--gridEdge);background:
  14radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--bg0) 72%,#0d1510) 0,var(--shadow) 58%,#000 88%),
  15linear-gradient(180deg,#060806,#010101);box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 10px var(--gridGlow);transition:transform .12s,box-shadow .12s,opacity .12s,filter .15s;flex:0 0 auto;max-width:100%}
  16#g:before{content:"";position:absolute;inset:0;pointer-events:none;background:
  17radial-gradient(circle at 50% 50%,transparent 38%,var(--shadow) 76%),
  18linear-gradient(180deg,transparent,rgba(0,0,0,.12));mix-blend-mode:screen;opacity:.72}
  19#g[data-intro]:after{content:attr(data-intro);position:absolute;left:10px;right:10px;top:10px;padding:10px 12px;border:1px solid color-mix(in srgb,var(--accent) 58%,#5b6a60);background:linear-gradient(180deg,#0b130edc,#050805f3);box-shadow:inset 0 0 0 1px #000,0 0 18px #0007;color:var(--fog);white-space:pre-line;line-height:1.35;letter-spacing:.04em;text-align:left;pointer-events:none;z-index:3}
  20#g.splash{display:block;flex:0 1 auto;width:min(calc(35ch + 36px),100%);min-width:calc(35ch + 36px);max-width:100%;padding:16px 18px;white-space:pre;line-height:1.28;text-align:left;color:var(--text);overflow:hidden}
  21#g.splash:before{opacity:.45}
  22#i{flex:0 1 auto;width:min(calc(35ch + 26px),100%);min-width:calc(35ch + 26px);max-width:100%;min-height:230px;padding:11px 13px;border:1px solid color-mix(in srgb,var(--panelEdge) 74%,#5b6a60);background:linear-gradient(180deg,var(--panel),#050805f2);color:var(--text);line-height:1.42;white-space:pre-line;text-align:left;box-shadow:inset 0 0 0 1px #000,0 0 18px #0006;overflow-wrap:break-word}
  23#l{min-height:38px;width:min(100%,860px);padding:9px 16px;border:1px solid color-mix(in srgb,var(--panelEdge) 72%,#5b6a60);background:linear-gradient(180deg,#0a110dcd,#050805ec);color:var(--muted);white-space:pre-line;text-align:center;box-shadow:inset 0 0 0 1px #000}
  24#r{margin-top:8px;padding:8px 14px;border:1px solid color-mix(in srgb,var(--accent) 70%,#6e7c73);background:linear-gradient(180deg,#09110b,#030503);color:var(--accent);font:inherit;letter-spacing:.08em;text-transform:uppercase;cursor:pointer;box-shadow:0 0 14px #0007}
  25#d{display:none;margin-top:8px;grid-template-columns:repeat(3,56px);grid-template-rows:repeat(3,56px);gap:6px}
  26#d button{font:16px/1 inherit;border:1px solid color-mix(in srgb,var(--accent) 70%,#6e7c73);background:linear-gradient(180deg,#09110b,#030503);color:var(--accent);cursor:pointer;box-shadow:0 0 14px #0007;display:flex;align-items:center;justify-content:center;touch-action:manipulation}
  27#d .pu{grid-column:2;grid-row:1}#d .pl{grid-column:1;grid-row:2}#d .pw{grid-column:2;grid-row:2}#d .pr{grid-column:3;grid-row:2}#d .pd{grid-column:2;grid-row:3}
  28.t{position:relative;width:16px;height:16px;display:flex;align-items:center;justify-content:center;transition:transform .08s,color .12s,opacity .12s,filter .12s,text-shadow .12s,background .12s}
  29.v{opacity:1;filter:saturate(1)}.m{opacity:.44;filter:saturate(.42) brightness(.78)}.u{opacity:0}
  30.far{opacity:.58}.mid{opacity:.8}
  31.p{color:var(--player);text-shadow:0 0 9px color-mix(in srgb,var(--player) 30%,var(--glow));font-weight:bold;filter:drop-shadow(0 0 4px #fff3)}.pm{animation:step .12s}.bm{animation:step .12s reverse}
  32.w{color:var(--wall)}.o{color:var(--pit);text-shadow:0 0 6px #4d100c}.f{color:var(--floor)}
  33.i,.C,.K{color:var(--exit);text-shadow:0 0 6px color-mix(in srgb,var(--exit) 50%,#000)}.g{color:var(--accent2);text-shadow:0 0 6px color-mix(in srgb,var(--accent2) 45%,#000)}.I{color:#ffe8a7;text-shadow:0 0 6px #8f7440}.M{color:#d6ddd6}.T,.F{color:var(--torch);text-shadow:0 0 8px color-mix(in srgb,var(--torch) 60%,#000);animation:flicker .9s infinite}.drop{color:#ffd79d}
  34.B{color:var(--beast);text-shadow:0 0 8px color-mix(in srgb,var(--beast) 70%,#000);font-weight:bold}.B.alert{color:#ffa8e4}.B.enraged{color:#ff6767;text-shadow:0 0 8px #ff4d4d}
  35.S{color:var(--shrine);text-shadow:0 0 8px color-mix(in srgb,var(--shrine) 65%,#000);animation:pulse 1.5s infinite;font-weight:bold}.S.stress{color:#c5fbff;animation:pulse .6s infinite}
  36.r{color:var(--relic);text-shadow:0 0 7px color-mix(in srgb,var(--relic) 55%,#000);font-weight:bold}
  37.x,.z{color:var(--hazard);text-shadow:0 0 7px color-mix(in srgb,var(--hazard) 40%,#000);font-weight:bold}.n{color:var(--shade);text-shadow:0 0 6px #22395f}.q{color:var(--watcher);text-shadow:0 0 6px #5a3d12;font-weight:bold}.l{color:var(--leech);text-shadow:0 0 6px #10453b}
  38.n.alert,.q.alert,.l.alert{animation:pulse .65s infinite}
  39.warded{box-shadow:inset 0 0 0 1px var(--wardGlow);background:radial-gradient(circle at 50% 50%,#ffffff08,transparent 72%)}
  40.haz{filter:brightness(1.14)}
  41.fog1{opacity:.9}.fog2{opacity:.72}.fog3{opacity:.55}
  42#g.hit{transform:translateX(2px);box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 14px color-mix(in srgb,var(--hazard) 70%,#000)}#g.win{box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 14px var(--glow)}#g.shift{transform:scale(1.02)}#g.ward{animation:wardbreak .2s ease-out}#g.relic{animation:relicpulse .24s ease-out}#g.dark{animation:darkstep .3s ease-out}
  43#u.dead,#u.win,#u.camp{letter-spacing:.12em;text-transform:uppercase}
  44#s[data-floor="1"]{--bg0:#0a130d;--bg1:#020302;--panel:#08100bdd;--panelEdge:#29442e;--gridEdge:#213929;--accent:#9fe2ab;--accent2:#f0cc76;--fog:#dbf5df;--mist:#88c6911c;--glow:#9be8a055;--gridGlow:#9be8a020;--floor:#132217;--memory:#667f6a;--wall:#6c786f;--pit:#ad584a;--exit:#f3d487;--torch:#ffbf67;--shrine:#9df1df;--relic:#ffe28f;--hazard:#ff8b77;--shade:#a7c8ff;--watcher:#ffd298;--leech:#8ef0db;--beast:#ff81d9;--player:#fbfff8}
  45#s[data-floor="2"]{--bg0:#170d08;--bg1:#040202;--panel:#130b08e1;--panelEdge:#5c3520;--gridEdge:#4d2d1e;--accent:#ffb07a;--accent2:#ffd18f;--fog:#ffd9c0;--mist:#ff985415;--glow:#ff9d6260;--gridGlow:#ff9d6224;--floor:#24130d;--memory:#8a665b;--wall:#8f6d63;--pit:#dd6a55;--exit:#ffd48a;--torch:#ffaf59;--shrine:#ffd0a6;--relic:#ffe08e;--hazard:#ff7766;--shade:#d3c0ff;--watcher:#ffd0ad;--leech:#f1b68f;--beast:#ff7eb4;--player:#fff7f1}
  46#s[data-floor="3"]{--bg0:#081416;--bg1:#020404;--panel:#071113e1;--panelEdge:#1c5459;--gridEdge:#1a474d;--accent:#8ce4eb;--accent2:#d8f09f;--fog:#d5fbff;--mist:#74f2ff18;--glow:#78ebf560;--gridGlow:#78ebf524;--floor:#0f2123;--memory:#628489;--wall:#6f8386;--pit:#b15a65;--exit:#dff2a3;--torch:#ffcf76;--shrine:#8ff8f1;--relic:#fff09c;--hazard:#ff8e86;--shade:#9fc8ff;--watcher:#f8eeae;--leech:#85f0dc;--beast:#ff9be4;--player:#f6ffff}
  47#s[data-floor="4"]{--bg0:#170910;--bg1:#040203;--panel:#120810e1;--panelEdge:#5f1d39;--gridEdge:#4c1b31;--accent:#ff86b5;--accent2:#ffd39a;--fog:#ffd8e7;--mist:#ff6ca41a;--glow:#ff75b460;--gridGlow:#ff75b426;--floor:#22101a;--memory:#876071;--wall:#8c6a7a;--pit:#d75b64;--exit:#ffd392;--torch:#ffb262;--shrine:#f1a8ff;--relic:#ffe28b;--hazard:#ff7b7b;--shade:#b7b7ff;--watcher:#ffd2a6;--leech:#95f1d7;--beast:#ff5da8;--player:#fff7fb}
  48#s[data-floor="5"]{--bg0:#0d0d10;--bg1:#010101;--panel:#0a0b0de1;--panelEdge:#3f4247;--gridEdge:#34373d;--accent:#c8d0da;--accent2:#f1d8a2;--fog:#e6ebf0;--mist:#ffffff10;--glow:#cbd6e245;--gridGlow:#cbd6e216;--floor:#17191d;--memory:#70767d;--wall:#838991;--pit:#c2645e;--exit:#f2ddb0;--torch:#e8b36f;--shrine:#c0ebff;--relic:#f5e2a7;--hazard:#ff9387;--shade:#aab7d9;--watcher:#ebd3a1;--leech:#a2dccf;--beast:#ff8ab4;--player:#fcfdff}
  49#s[data-floor="6"]{--bg0:#140f08;--bg1:#030201;--panel:#110d08e2;--panelEdge:#6b5824;--gridEdge:#53461f;--accent:#f0d06d;--accent2:#fff0b3;--fog:#fff0c4;--mist:#f0c24a18;--glow:#f1cd6760;--gridGlow:#f1cd6726;--floor:#221d10;--memory:#8e7d52;--wall:#93886b;--pit:#d06d56;--exit:#fff0a6;--torch:#ffc768;--shrine:#d0f5c2;--relic:#fff1a1;--hazard:#ff8b67;--shade:#c7d6ff;--watcher:#ffe0a0;--leech:#beefc7;--beast:#ff7d9b;--player:#fffdf3}
  50#s[data-floor="7"]{--bg0:#170c08;--bg1:#040201;--panel:#130a07e2;--panelEdge:#7a3f22;--gridEdge:#5e321c;--accent:#ff9a5c;--accent2:#ffd9a0;--fog:#ffe0b8;--mist:#ff8a4a1c;--glow:#ff9a5c60;--gridGlow:#ff9a5c26;--floor:#24140d;--memory:#93705a;--wall:#8f6f5c;--pit:#c9503f;--exit:#ffcf8f;--torch:#ffab5e;--shrine:#f2cfae;--relic:#ffe3a1;--hazard:#ff7a5c;--shade:#c9b6ff;--watcher:#ffcf8a;--leech:#c9e0a8;--beast:#ff6a7d;--player:#fff6ef}
  51#s[data-state="title"]{--accent:#d9e6d6;--panelEdge:#445348;--gridEdge:#445348;--mist:#7fb48612}
  52#s[data-state="dead"]{--accent:#ff8b79;--panelEdge:#6b2f27;--gridEdge:#612b24;--mist:#ff715812}
  53#s[data-state="win"]{--accent:#f3dd8f;--panelEdge:#7a6930;--gridEdge:#6e5f2c;--mist:#f3dd8f18}
  54#s[data-state="camp"]{--accent:#c8f2dd;--panelEdge:#376050;--gridEdge:#2f5648;--mist:#8ff0cc18}
  55@media (max-width:780px){#s{justify-content:flex-start;padding:12px 10px 20px}#p{flex-direction:column;align-items:center;width:100%}#g,#i,#g.splash,#l,#u{width:min(92vw,420px);max-width:100%}#i,#g.splash{min-width:0}#g.splash{overflow:auto}#s[data-state="play"] #d{display:grid}}
  56#s.gfx{--cs:min(40px,calc((92vw - 26px)/12))}#s.gfx #g:not(.splash){grid-template-columns:repeat(12,var(--cs));gap:0}#s.gfx .t{width:var(--cs);height:var(--cs);font-size:0;text-shadow:none}.t>svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.t>b{position:relative;font-size:calc(var(--cs)*.6)}.t>b.dl{font-size:calc(var(--cs)*.34);color:var(--exit);top:10%}.lg{white-space:normal;margin-bottom:10px;color:var(--muted)}.lr{display:flex;gap:6px;align-items:flex-start;color:var(--text);margin:1px 0}.lr i{flex:0 0 1.5em;height:1.5em;font-style:normal;text-align:center;color:var(--accent)}.lr i svg{width:100%;height:100%}.B.alert{--eye:#ffa8e4}.B.enraged{--eye:#ff4040}
  57@keyframes flicker{0%,100%{opacity:1;transform:translateY(0)}25%{opacity:.75;transform:translateY(-.5px)}50%{opacity:.94}75%{opacity:.68;transform:translateY(.5px)}}
  58@keyframes pulse{0%,100%{opacity:.84;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}}
  59@keyframes step{0%{transform:scale(.82)}100%{transform:scale(1)}}
  60@keyframes wardbreak{0%{filter:brightness(1.35)}100%{filter:brightness(1)}}
  61@keyframes darkstep{0%{filter:brightness(.3);transform:translateX(-3px)}35%{transform:translateX(3px)}70%{transform:translateX(-1px)}100%{filter:brightness(1);transform:none}}
  62@keyframes relicpulse{0%{transform:scale(1)}50%{transform:scale(1.035)}100%{transform:scale(1)}}
  63</style>
  64<div id=s><div id=u></div><div id=p><div id=g></div><div id=i></div></div><div id=l></div><div id=d><button data-k=ArrowUp class=pu>^</button><button data-k=ArrowLeft class=pl>&lt;</button><button data-k=. class=pw>*</button><button data-k=ArrowRight class=pr>&gt;</button><button data-k=ArrowDown class=pd>v</button></div><button id=r hidden>restart</button></div>
  65<script src=sprites.js></script>
  66<script src=data.js></script>
  67<script>
  68const SEl=document.getElementById("s"),U=document.getElementById("u"),G=document.getElementById("g"),I=document.getElementById("i"),L=document.getElementById("l"),X=document.getElementById("r"),D=document.getElementById("d")
  69let muted=0
  70const SFX={
  71  ctx:null,
  72  beep(freq,dur,type){
  73    if(muted||!freq)return
  74    if(!this.ctx)this.ctx=new(window.AudioContext||window.webkitAudioContext)()
  75    if(this.ctx.state=="suspended")this.ctx.resume()
  76    const osc=this.ctx.createOscillator(),gain=this.ctx.createGain()
  77    osc.type=type;osc.frequency.value=freq
  78    gain.gain.setValueAtTime(.15,this.ctx.currentTime)
  79    gain.gain.exponentialRampToValueAtTime(.0001,this.ctx.currentTime+dur)
  80    osc.connect(gain);gain.connect(this.ctx.destination)
  81    osc.start();osc.stop(this.ctx.currentTime+dur)
  82  },
  83  play(fx){
  84    const s=SFX_MAP[fx]
  85    if(s)this.beep(s.freq,s.dur,s.type)
  86  }
  87}
  88let seed=0,B0=[10,8],R,S,V={hall:0,pit:0,idol:0,crack:0,escape:0,beast:0},DBG={show:0,out:""},O={relics:[],curses:[],shades:[],blocks:[],braziers:[],shrines:[],watchers:[],leeches:[],effigies:[]},hasSave=0
  89
  90function rng(n){
  91  return function(){
  92    n|=0;n=n+0x6D2B79F5|0
  93    let t=Math.imul(n^n>>>15,1|n)
  94    t=t+Math.imul(t^t>>>7,61|t)^t
  95    return ((t^t>>>14)>>>0)/4294967296
  96  }
  97}
  98function rollSeed(){return Math.floor(Math.random()*1e9)}
  99function pickSpots(r,spots,n){
 100  const bag=spots.slice(),out=[]
 101  for(let i=0;i<n&&bag.length;i++)out.push(...bag.splice((r()*bag.length)|0,1))
 102  return out
 103}
 104function pickMods(seed0){
 105  const r=rng(seed0+404),bag=MOD_IDS.slice(),out=[]
 106  while(out.length<2&&bag.length)out.push(bag.splice((r()*bag.length)|0,1)[0])
 107  return out
 108}
 109function hash(s){let n=0;for(const ch of s)n=(n*31+ch.charCodeAt(0))|0;return Math.abs(n)}
 110function modeMods(mode){
 111  if(mode=="iron")return ["wake"]
 112  if(mode=="greed")return ["rich"]
 113  if(mode=="dark")return ["thin","gloom"]
 114  if(mode=="warded")return ["grace"]
 115  return []
 116}
 117function hasMod(id){return S.mods&&S.mods.includes(id)}
 118function modSummary(){return S.mods.map(id=>MODS[id].label).join(", ")}
 119function showHint(id,text=HINT_TEXT[id]){
 120  if(!S.hints[id]){
 121    S.hints[id]=1
 122    S.hint=`hint   ${text}`
 123  }
 124}
 125function boolMark(v){return v?"✓":"-"}
 126function statRow(k,v){return `${k.padEnd(7)} ${v}`}
 127function loseWard(n){
 128  const was=S.flags.ward||0
 129  S.flags.ward=Math.max(0,was-n)
 130  if(was&&!S.flags.ward)S.fx="ward"
 131}
 132function campOffers(next){
 133  const r=rng(seed+next*313),pool=RELIC_IDS.filter(id=>id!=S.flags.relic),a=pool.splice((r()*pool.length)|0,1)[0],b=pool.splice((r()*pool.length)|0,1)[0]
 134  return [a,b]
 135}
 136
 137function genRoom(rand,spec){
 138  const pick=n=>(rand()*n)|0,lo=spec.ring?2:1,hi=spec.ring?W-3:W-2
 139  const bad=[...(spec.keep||[]),...(spec.blocked||[])]
 140  const openAt=(g,x,y)=>g[y][x]=="."&&!bad.some(b=>b[0]==x&&b[1]==y)
 141  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}
 142  for(let t=0;t<25;t++){
 143    const sparse=t==24
 144    const g=Array.from({length:H},(_,y)=>Array.from({length:W},(_,x)=>!x||!y||x==W-1||y==H-1?"#":"."))
 145    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"
 146    if(!sparse){
 147      for(let w=spec.walls||0;w>0;){
 148        const hz=pick(2),len=2+pick(3)
 149        const x=lo+pick(hi-lo+1-(hz?len:0)),y=lo+pick(hi-lo+1-(hz?0:len))
 150        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]="#"
 151        w-=len
 152      }
 153      let px=lo+pick(hi-lo+1),py=lo+pick(hi-lo+1)
 154      for(let p=spec.pits||0;p>0;p--){
 155        if(openAt(g,px,py))g[py][px]="O"
 156        if(pick(5)){const d=[[1,0],[-1,0],[0,1],[0,-1]][pick(4)]
 157          px=Math.min(hi,Math.max(lo,px+d[0]));py=Math.min(hi,Math.max(lo,py+d[1]))
 158        }else{px=lo+pick(hi-lo+1);py=lo+pick(hi-lo+1)}
 159      }
 160    }
 161    if(spec.stamp){
 162      const sh=spec.stamp.length,sw=spec.stamp[0].length
 163      let put=0
 164      for(let s=0;s<20&&!put;s++){
 165        const sx=lo+pick(hi-lo+2-sw),sy=lo+pick(hi-lo+2-sh)
 166        if(bad.some(b=>b[0]>=sx&&b[0]<sx+sw&&b[1]>=sy&&b[1]<sy+sh))continue
 167        for(let y=0;y<sh;y++)for(let x=0;x<sw;x++)g[sy+y][sx+x]=spec.stamp[y][x]
 168        put=1
 169      }
 170      if(!put)continue
 171    }
 172    let full=0
 173    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--){
 174      const c=spots(g)
 175      if(!c.length){full=1;break}
 176      const [x,y]=c[pick(c.length)];g[y][x]=ch
 177    }
 178    for(const ch in spec.exits){
 179      const at=spec.exits[ch]
 180      if(at=="any"){
 181        const c=spots(g)
 182        if(!c.length){full=1;break}
 183        const [x,y]=c[pick(c.length)];g[y][x]=ch
 184      }else g[at[1]][at[0]]=ch
 185    }
 186    if(full)continue
 187    for(const [x,y] of spec.keep||[])if(g[y][x]=="#"||g[y][x]=="O")g[y][x]="."
 188    const solid=(x,y)=>g[y][x]=="#"||g[y][x]=="O"||(spec.blocked||[]).some(b=>b[0]==x&&b[1]==y)
 189    let total=0,start=null
 190    for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(!solid(x,y)){total++;start=start||[x,y]}
 191    const seen=new Set([start.join()]),q=[start]
 192    while(q.length){
 193      const [x,y]=q.pop()
 194      for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
 195        const nx=x+dx,ny=y+dy,k=nx+","+ny
 196        if(nx<0||ny<0||nx>=W||ny>=H||solid(nx,ny)||seen.has(k))continue
 197        seen.add(k);q.push([nx,ny])
 198      }
 199    }
 200    if(seen.size==total)return g.map(row=>row.join(""))
 201  }
 202}
 203function procSpec(kind,level,code){
 204  const spec={...PROC_SPECS[kind]},keep=[(P[code]||P[code[0]]).slice()],blocked=[]
 205  for(const s of BLOCK_SPOTS[level]||[])if(s[0]==code)blocked.push([s[1],s[2]])
 206  for(const T of [RELIC_SPOTS,EFFIGY_SPOTS,BRAZIER_SPOTS,SHRINE_SPOTS,CURSE_SPOTS,SHADE_SPOTS,WATCHER_SPOTS,LEECH_SPOTS])
 207    for(const s of T[level]||[])if(s[0]==code)keep.push([s[1],s[2]])
 208  if(kind=="escapes")for(const b of LEVELS[level].beasts)keep.push(b.slice())
 209  spec.keep=keep;spec.blocked=blocked
 210  return spec
 211}
 212
 213function build(seed0){
 214  const level=S&&S.level||1
 215  const L=LEVELS[level],r=rng(seed0+(level-1)*9973)
 216  const B=BASES[level]
 217  const slots=k=>level<2||(k=="escapes"&&level>=6)?0:PROC_SLOTS
 218  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}
 219  B0=L.beasts[V.beast]
 220  const relicBag=RELIC_IDS.slice().sort(()=>r()-.5)
 221  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})),
 222    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})),
 223    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})),
 224    curses:pickSpots(r,CURSE_SPOTS[level]||[],level==1?1:level>=5?3:2).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
 225    shades:pickSpots(r,SHADE_SPOTS[level]||[],Math.min(level+1,5)).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
 226    watchers:pickSpots(r,WATCHER_SPOTS[level]||[],Math.max(0,level-1)).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
 227    leeches:pickSpots(r,LEECH_SPOTS[level]||[],Math.max(0,level-2)).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
 228    blocks:pickSpots(r,BLOCK_SPOTS[level]||[],99).map(v=>({r:v[0],x:v[1],y:v[2],on:1})),
 229    effigies:pickSpots(r,EFFIGY_SPOTS[level]||[],Math.max(0,level-1)).map(v=>({r:v[0],x:v[1],y:v[2],on:1}))}
 230  R={}
 231  for(const code in GRAPH[level]){
 232    const spec=GRAPH[level][code],arr=L[spec.arr],vi=V[spec.v]
 233    const m=spec.base?B[spec.base]:vi<arr.length?arr[vi]:genRoom(r,procSpec(spec.arr,level,code))||arr[vi%arr.length]
 234    R[code]={m,ex:{...spec.ex}}
 235  }
 236  if(R.e&&V.escape>=L.escapes.length){
 237    let ok=escapeSolve(level,V.escape,V.beast,level>=3?8:6)
 238    for(let i=0;i<6&&!ok;i++){
 239      R.e.m=genRoom(r,procSpec("escapes",level,"e"))||L.escapes[V.escape%L.escapes.length]
 240      ok=escapeSolve(level,V.escape,V.beast,level>=3?8:6)
 241    }
 242    if(!ok)R.e.m=L.escapes[V.escape%L.escapes.length]
 243  }
 244}
 245
 246function buildTitle(){
 247  const z=runStyle()
 248  if(S.flags.relic=="chain"||S.path.shrines>2)return "Warded Pilgrim"
 249  if(S.flags.gold&&z.route=="crack")return "Gold-Bound Runner"
 250  if(S.flags.relic=="lure"||S.path.drops>1)return "Beast Angler"
 251  if(S.flags.mask&&S.flags.relic=="veil")return "Veiled Diver"
 252  if(S.flags.relic=="greave"||z.route=="pit")return "Pit Warden"
 253  if(S.flags.relic=="hush"||S.mode=="dark")return "Hush Walker"
 254  return runStyle().style.replace(/\b\w/g,m=>m.toUpperCase())
 255}
 256function enemyNote(kind){
 257  const arr=ENEMY_TEXT[kind]
 258  return arr[(seed+S.level+kind.length)%arr.length]
 259}
 260function flavor(kind){
 261  const pool=kind=="shrine"?SHRINE_TEXT:kind=="brazier"?BRAZIER_TEXT:CURSE_TEXT
 262  return pool[(seed+S.turn+S.level)%pool.length]
 263}
 264function floorLine(level=S.level){
 265  const arr=FLOOR_TEXT[level]
 266  return arr[(seed+level)%arr.length]
 267}
 268function finaleName(){
 269  const idx=(hash((S.flags.relic||"plain")+runStyle().route+(S.flags.gold?"g":"n")+(S.flags.ward?"w":"d")))%FINALE_NAMES.length
 270  return FINALE_NAMES[idx]
 271}
 272function openTitle(){
 273  seed=0
 274  S={gameState:"title",mode:"standard",currentMode:"standard",alive:1,win:0,level:1,currentLevel:1,msg:"",endLead:"",deathCause:"",mods:[],flags:{},path:{},seen:{},turn:0}
 275  hasSave=!!loadSave()
 276}
 277function startRun(mode="standard",fresh=1){
 278  const dailySeed=hash(new Date().toISOString().slice(0,10))
 279  seed=mode=="daily"?dailySeed:(fresh||!seed?rollSeed():seed)
 280  const picked=pickMods(seed),forced=modeMods(mode),mods=[...new Set([...picked,...forced])].slice(0,3)
 281  S={level:1,currentLevel:1,mode,currentMode:mode}
 282  S.mods=mods
 283  build(seed)
 284  S={seed,mode,currentMode:mode,mods:S.mods,level:1,currentLevel:1,gameState:"play",deathCause:"",endTitle:"",endLead:"",fx:"shift",r:"g",x:P.g[0],y:P.g[1],alive:1,win:0,turn:0,crownTurn:-1,chase:0,dread:0,msg:`Begin floor 1. ${floorLine(1)}`,hint:"",intro:1,hints:{torch:0,ward:0,crack:0,beast:0},flags:{idol:0,gold:mode=="greed",torch:mode=="heavy"?FUEL+8:0,ash:0,crown:0,key:0,mask:0,ward:mode=="warded"?4:0,relic:"",relicPlus:0},beast:{x:B0[0],y:B0[1]},drop:null,path:{pit:0,crack:0,drops:0,shrines:0,relics:0,shades:0,watchers:0,leeches:0,camps:0,levels:1,finale:0},camp:null,seen:{},end:"",finaleName:""}
 285}
 286function reset(fresh){
 287  if(fresh)openTitle()
 288  else startRun(S&&S.mode||"standard",0)
 289}
 290openTitle()
 291
 292function enterLevel(next){
 293  const keep={torch:S.flags.torch,ash:S.flags.ash,mask:S.flags.mask,gold:S.flags.gold,ward:S.flags.ward,relic:S.flags.relic,relicPlus:S.flags.relicPlus}
 294  const intro=S.intro,hints=S.hints
 295  S.level=next
 296  S.currentLevel=next
 297  build(seed)
 298  S.r="g";S.x=P.g[0];S.y=P.g[1];S.turn=0;S.crownTurn=-1;S.chase=0;S.dread=0
 299  S.flags={idol:0,gold:keep.gold,torch:keep.torch,ash:keep.ash,crown:0,key:0,mask:keep.mask,ward:next>=3?keep.ward:0,relic:keep.relic,relicPlus:keep.relicPlus}
 300  S.beast={x:B0[0],y:B0[1]}
 301  S.drop=null
 302  S.path={pit:0,crack:0,drops:S.path.drops,shrines:S.path.shrines,relics:S.path.relics,shades:S.path.shades,watchers:S.path.watchers,leeches:S.path.leeches,camps:S.path.camps,levels:next,finale:S.path.finale}
 303  S.intro=intro
 304  S.hints=hints
 305  S.hint=""
 306  S.fx="shift"
 307  setMsg(`Enter floor ${next}. Title: ${buildTitle()}. ${floorLine(next)}`)
 308}
 309
 310function startCamp(next){
 311  S.gameState="camp"
 312  S.path.camps++
 313  S.camp={next,offer:campOffers(next)}
 314  S.endTitle=`camp | floor ${S.level} cleared`
 315  S.endLead=`Between floors, you can temper or trade your relic before Level ${next} (${LEVEL_ARCS[next]}).`
 316  S.fx="shift"
 317  saveGame()
 318}
 319
 320function saveGame(){
 321  if(S.gameState!="camp")return
 322  try{localStorage.setItem("mrl_save",JSON.stringify({v:1,S}))}catch(e){}
 323}
 324function loadSave(){
 325  try{const raw=localStorage.getItem("mrl_save");return raw?JSON.parse(raw):null}catch(e){return null}
 326}
 327function clearSave(){
 328  try{localStorage.removeItem("mrl_save")}catch(e){}
 329}
 330function continueSave(){
 331  const save=loadSave()
 332  if(!save)return
 333  S=save.S
 334  seed=S.seed
 335  build(seed)
 336  S.gameState="camp"
 337  hasSave=!!loadSave()
 338  render()
 339}
 340
 341function startFinale(){
 342  const keep={torch:S.flags.torch,ash:S.flags.ash,mask:S.flags.mask,gold:S.flags.gold,ward:S.flags.ward,relic:S.flags.relic,relicPlus:S.flags.relicPlus}
 343  S.level=6
 344  S.currentLevel=6
 345  S.path.finale=1
 346  const fName=finaleName(),fOffset=FINALE_NAMES.indexOf(fName)
 347  build(seed+909+fOffset*131)
 348  S.gameState="play"
 349  S.r="e";S.x=P.e[0];S.y=P.e[1];S.turn=0;S.crownTurn=0;S.chase=2;S.dread=0
 350  S.flags={idol:1,gold:keep.gold,torch:Math.max(keep.torch,4),ash:keep.ash,crown:1,key:1,mask:keep.mask,ward:Math.max(keep.ward,hasMod("grace")?10:8),relic:keep.relic,relicPlus:keep.relicPlus}
 351  S.beast={x:B0[0],y:B0[1]}
 352  S.drop=null
 353  S.finaleName=fName
 354  S.hint=""
 355  S.fx="shift"
 356  setMsg(`${fName}. Title: ${buildTitle()}. Carry the crown through the last warded gate.`)
 357}
 358
 359function startPostgame(){
 360  const keep={torch:S.flags.torch,ash:S.flags.ash,mask:S.flags.mask,gold:S.flags.gold,ward:S.flags.ward,relic:S.flags.relic,relicPlus:S.flags.relicPlus}
 361  S.level=7
 362  S.currentLevel=7
 363  build(seed)
 364  S.gameState="play"
 365  S.r="g";S.x=P.g[0];S.y=P.g[1];S.turn=0;S.crownTurn=-1;S.chase=0;S.dread=0
 366  S.flags={idol:0,gold:keep.gold,torch:Math.max(keep.torch,4),ash:keep.ash,crown:0,key:0,mask:keep.mask,ward:keep.ward,relic:keep.relic,relicPlus:keep.relicPlus}
 367  S.beast={x:B0[0],y:B0[1]}
 368  S.drop=null
 369  S.path={pit:0,crack:0,drops:S.path.drops,shrines:S.path.shrines,relics:S.path.relics,shades:S.path.shades,watchers:S.path.watchers,leeches:S.path.leeches,camps:S.path.camps,levels:7,finale:S.path.finale}
 370  S.hint=""
 371  S.fx="shift"
 372  setMsg(`Enter floor 7. Title: ${buildTitle()}. ${floorLine(7)}`)
 373}
 374
 375function applyCampChoice(choice){
 376  if(!S.camp)return
 377  if(choice=="1"){
 378    if(S.flags.relic)S.flags.relicPlus=Math.min(2,S.flags.relicPlus+1)
 379    else S.flags.relic=S.camp.offer[0]
 380  }
 381  if(choice=="2")S.flags.relic=S.camp.offer[0],S.flags.relicPlus=0
 382  if(choice=="3")S.flags.relic=S.camp.offer[1],S.flags.relicPlus=0
 383  if(choice=="4"){
 384    S.flags.relic=""
 385    S.flags.relicPlus=0
 386    S.flags.torch=Math.max(S.flags.torch,6)
 387    S.flags.ward=Math.max(S.flags.ward,6)
 388  }
 389  S.gameState="play"
 390  enterLevel(S.camp.next)
 391  S.camp=null
 392}
 393
 394function base(x,y,r=S.r){return R[r].m[y][x]}
 395function overAt(r,x,y,t){return O[t].find(o=>o.on&&o.r==r&&o.x==x&&o.y==y)}
 396function roomKey(r=S.r){return `${S.level}:${r}`}
 397function sight(){
 398  if(!dark())return 99
 399  let v=S.flags.torch?5:S.flags.ward?4:S.flags.mask?3:2
 400  if(S.flags.relic=="ember")v++
 401  if(S.flags.relic=="wick")v++
 402  if(S.r=="e")v++
 403  return v
 404}
 405function visible(x,y,r=S.r){
 406  if(r!=S.r)return 0
 407  if(!dark(r))return 1
 408  return Math.abs(x-S.x)+Math.abs(y-S.y)<=sight()
 409}
 410function remember(){
 411  const k=roomKey()
 412  S.seen[k]||(S.seen[k]={})
 413  for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(visible(x,y))S.seen[k][`${x},${y}`]=1
 414}
 415function seen(x,y,r=S.r){
 416  const k=`${S.level}:${r}`
 417  return !!(S.seen[k]&&S.seen[k][`${x},${y}`])
 418}
 419function tile(x,y,r=S.r){
 420  if(overAt(r,x,y,"blocks"))return "#"
 421  if(S.drop&&S.drop.r==r&&S.drop.x==x&&S.drop.y==y&&S.drop.fuel)return "T"
 422  if(overAt(r,x,y,"braziers"))return "F"
 423  if(overAt(r,x,y,"shrines"))return "S"
 424  if(overAt(r,x,y,"relics"))return "r"
 425  if(overAt(r,x,y,"effigies"))return "r"
 426  if(overAt(r,x,y,"shades"))return "n"
 427  if(overAt(r,x,y,"watchers"))return "q"
 428  if(overAt(r,x,y,"leeches"))return "l"
 429  if(overAt(r,x,y,"curses"))return "z"
 430  if(r[0]=="b"&&S.flags.mask&&x==4&&y==2)return "."
 431  if(r[0]=="b"&&S.flags.torch&&x==5&&y==2)return "."
 432  return base(x,y,r)
 433}
 434function setMsg(msg){S.msg=msg}
 435function die(msg,cause=msg){S.alive=0;S.gameState="dead";S.deathCause=cause;S.endTitle="dead";S.endLead=msg;S.fx="hit";setMsg(msg);S.end=summary();clearSave();hasSave=!1}
 436function 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}
 437function sign(n){return n<0?-1:n>0?1:0}
 438function dist(ax,ay,bx,by){return Math.abs(ax-bx)+Math.abs(ay-by)}
 439function dark(r=S.r){return LEVELS[S.level].dark.includes(r[0])}
 440function carriedFire(){return S.flags.torch&&!S.flags.mask}
 441function fire(){return carriedFire()||(S.drop&&S.drop.r=="e"&&S.drop.fuel)}
 442
 443function runStyle(){
 444  const route=S.path.pit>S.path.crack?"pit":S.path.crack>S.path.pit?"crack":"mixed"
 445  let style="pilgrim"
 446  if(S.flags.relic=="ember")style="ember runner"
 447  else if(S.flags.relic=="tooth")style="greed runner"
 448  else if(S.flags.relic=="hush")style="hush walker"
 449  else if(S.flags.relic=="eye")style="idol sworn"
 450  else if(S.flags.relic=="brand")style="shade breaker"
 451  else if(S.flags.relic=="wick")style="longflame scout"
 452  else if(S.flags.relic=="chain")style="ward binder"
 453  else if(S.flags.relic=="veil")style="veiled diver"
 454  else if(S.flags.relic=="greave")style="pit runner"
 455  else if(S.flags.relic=="lure")style="beast angler"
 456  else if(S.flags.relic=="salt")style="salt walker"
 457  else if(S.flags.relic=="ash")style="brazier feeder"
 458  else if(S.path.shrines)style="ward bearer"
 459  else if(S.flags.mask&&route=="crack")style="mask runner"
 460  else if(S.path.drops)style="torch tamer"
 461  else if(S.flags.gold)style="gold bearer"
 462  else if(route=="pit")style="warded crawl"
 463  return {route,style}
 464}
 465
 466function roomMsg(r=S.r){
 467  r=r[0]
 468  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.")
 469  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.")
 470  if(r=="e"){
 471    const b=beastState()
 472    if(b=="calm")return carriedFire()?`Your torch keeps the beast at bay for now. ${enemyNote("beast")}`:`A one-eyed beast prowls here. Every step draws it closer. ${enemyNote("beast")}`
 473    if(b=="alert")return S.flags.gold?`The beast is quickened by gold, but fire still buys you time. ${enemyNote("beast")}`:`The beast has your scent now. Fire buys you time, not safety. ${enemyNote("beast")}`
 474    return "The beast is enraged. There is an opening, but no mercy."
 475  }
 476  if(r=="h")return S.level==1?"A long hall divides the ruin: pit left supports control; crack right rewards speed.":S.level==2?"The hall is tighter now. Every branch feels one step closer to the beast.":S.level==3?"The hall is strung with ward-shrines. Blessing and danger live in the same stone.":S.level==4?"The hall is full of watchers. Noise becomes pursuit here.":"The hall is bare and hollow. Every route feels like the wrong one."
 477  if(r=="p")return carriedFire()?(S.level==1?"Torchlight steadies the pit's edge.":S.level==2?"Torchlight finds only slivers of safe stone over the pit.":S.level==3?"Wardlight softens the pit, but it drinks your flame.":S.level==4?"Torchlight catches every false edge in the hunt pit.":"The pit remembers every step. Safe footing is never free."):S.level==1?"The pit is blind and wide. Stay to the wall.":S.level==2?"The pit feels hungrier now. The dark is close on every side.":S.level==3?"The pit answers shrines, not fear. Empty hands are still not enough.":S.level==4?"The pit keeps no safe secrets once the flame is gone.":"The pit is the hollow floor's oldest lie."
 478  if(r=="i")return S.flags.mask?(S.level==1?"The idol does not care for your mask, only your bow.":S.level==2?"The idol has learned your face beneath the mask. Bow anyway.":S.level==3?"The idol and the shrine answer each other. Even masked, you must bow.":"The idol sees through every build you've made. Bow anyway."):(S.level==1?"The idol waits in gold. Bow before you claim what it guards.":S.level==2?"The idol waits in harsher silence. Bow before you claim what it guards.":S.level==3?"The idol can steady a ward, but the crown will try to break it.":"The idol is colder now. Every blessing here feels conditional.")
 479  if(r=="c")return S.flags.mask?(S.level==1?"The mask lets you slip the crack with more than bone and breath.":S.level==2?"The mask lets you slip the crack, but the stone still wants your flame.":S.level==3?"A live ward can force the crack open, but it will cost you.":"The crack listens for weakness. A mask only fools part of it."):(S.level==1?"The crack is narrow and mean. Quick to cross, but it steals flame.":S.level==2?"The crack runs longer now: quick, cruel, and eager to waste your time.":S.level==3?"The crack can be bought with ward or paid for with your body.":"The crack is now a wager: speed against whatever fire you still have.")
 480  if(r=="v")return S.flags.crown?(S.level==1?"The vault is emptying into chaos. Take the key and flee.":S.level==2?"The vault is tighter, meaner, and already waking the beast.":S.level==3?"The vault hums against your ward. Take the key and run before the blessing breaks.":S.level==4?"The vault is alive with watchers. The theft is the easy part.":"The final vault sheds every comfort. Take the key and survive the reckoning."):(S.level==1?"In the vault, the crown gleams beside a small iron key.":S.level==2?"The same crown waits again, but the ruin holds it with a tighter grip.":S.level==3?"The crown waits beside old shrine-scratches. Someone once tried to survive this place.":S.level==4?"The crown waits in a vault built to wake pursuit.":"The crown waits at the bottom of the hollow run, brighter than mercy.")
 481  return "You are dead. Press r to begin again."
 482}
 483
 484function inv(){
 485  const f=S.flags
 486  return [
 487    "inventory",
 488    statRow("torch",f.torch?f.torch:f.ash?"burnt":"-"),
 489    statRow("ward",f.ward||"-"),
 490    statRow("relic",f.relic?RELICS[f.relic].label+(f.relicPlus?` +${f.relicPlus}`:""):"-"),
 491    statRow("mask",boolMark(f.mask)),
 492    statRow("idol",boolMark(f.idol)),
 493    statRow("crown",boolMark(f.crown)),
 494    statRow("key",boolMark(f.key)),
 495    statRow("gold",boolMark(f.gold))
 496  ].join("\n")
 497}
 498
 499function summary(){
 500  const z=runStyle()
 501  const build=S.flags.relic?S.flags.relic:S.flags.mask?"mask":S.path.shrines?"ward":S.flags.gold?"gold":"plain"
 502  return `seed ${S.seed.toString(36)} | level ${S.level} | arc ${LEVEL_ARCS[S.level]} | turns ${S.turn} | route ${z.route} | style ${z.style} | build ${build} | mods ${modSummary()} | torch ${S.flags.torch||0} | ward ${S.flags.ward||0} | gold ${S.flags.gold?"yes":"no"} | bowed ${S.flags.idol?"yes":"no"} | mask ${S.flags.mask?"yes":"no"} | relic ${S.flags.relic||"none"}${S.flags.relicPlus?`+${S.flags.relicPlus}`:""} | camps ${S.path.camps} | drops ${S.path.drops} | shrines ${S.path.shrines} | shades ${S.path.shades} | watchers ${S.path.watchers} | leeches ${S.path.leeches} | finale ${S.path.finale?"yes":"no"} | beast ${beastState()}`
 503}
 504
 505function endIdentity(){
 506  const z=runStyle()
 507  return `${z.style} | ${z.route} route`
 508}
 509
 510function endStats(){
 511  const z=runStyle()
 512  const build=S.flags.relic?`${RELICS[S.flags.relic].name}${S.flags.relicPlus?` +${S.flags.relicPlus}`:""}`:S.flags.mask?"funeral mask":S.path.shrines?"warded":"plain"
 513  const encounters=[
 514    S.path.shades?`shades   ${S.path.shades}`:"",
 515    S.path.watchers?`watchers ${S.path.watchers}`:"",
 516    S.path.leeches?`leeches  ${S.path.leeches}`:"",
 517    S.path.drops?`drops    ${S.path.drops}`:"",
 518    S.path.shrines?`shrines  ${S.path.shrines}`:"",
 519    S.path.camps?`camps    ${S.path.camps}`:"",
 520    S.path.finale?`finale   yes`:""
 521  ].filter(Boolean)
 522  const lines=[
 523    "run",
 524    "------",
 525    `seed    ${S.seed.toString(36)}`,
 526    `level   ${S.level} (${LEVEL_ARCS[S.level]})`,
 527    `mode    ${MODES[S.mode||"standard"].label}`,
 528    `turns   ${S.turn}`,
 529    `style   ${endIdentity()}`,
 530    "",
 531    "build",
 532    "------",
 533    `relic   ${build}`,
 534    `mods    ${modSummary()}`,
 535    `route   ${z.route}`,
 536    "",
 537    "state",
 538    "------",
 539    `torch   ${S.flags.torch||0}${S.flags.ash&&!S.flags.torch?" (burnt)":""}`,
 540    `ward    ${S.flags.ward||0}`,
 541    `gold    ${S.flags.gold?"yes":"no"}`,
 542    `bowed   ${S.flags.idol?"yes":"no"}`,
 543    `mask    ${S.flags.mask?"yes":"no"}`,
 544    `beast   ${beastState()}`
 545  ]
 546  if(encounters.length)lines.push("","encounters","----------",...encounters)
 547  return lines.join("\n")
 548}
 549
 550function sidePanel(){
 551  const z=runStyle()
 552  const th=FLOOR_THEMES[S.level]
 553  return `${inv()}\n\nrun\n${statRow("title",buildTitle())}\n${statRow("route",z.route)}\n${statRow("style",z.style)}\n${statRow("floor",`${S.level} ${th.name}`)}\n${statRow("mood",th.tag)}\n${statRow("mode",MODES[S.mode||"standard"].label)}\n${statRow("mods",modSummary())}\n${statRow("turns",S.turn)}\n${statRow("beast",beastState())}`
 554}
 555
 556function exitNote(dest){
 557  if(dest[0]=="d")return " (death)"
 558  if(dest[0]=="c"&&S.flags.gold&&!S.flags.mask&&S.flags.relic!="tooth")return S.level>=3&&S.flags.ward?" (gold: costs ward)":" (gold: death)"
 559  if(dest[0]=="g"&&S.flags.crown&&S.flags.key)return " (crown and key: go on)"
 560  return ""
 561}
 562function legendRows(){
 563  const here={},rows=[]
 564  for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(visible(x,y)||seen(x,y))here[tile(x,y)]=1
 565  if(S.r[0]=="e"&&visible(S.beast.x,S.beast.y))here.B=1
 566  if(here.r&&O.effigies.some(o=>o.on&&o.r==S.r))here.effigy=1
 567  if(dark()&&!S.flags.torch)here.dark=1
 568  for(const k in LEGEND)if(here[k])rows.push([k,LEGEND[k]])
 569  return rows
 570}
 571function exitRows(){
 572  const ex=R[S.r].ex
 573  return Object.keys(ex).map(ch=>{const dest=ex[ch].replace("!","");return [ch,`${ROOM_NAMES[dest[0]]}${ex[ch][0]=="!"?" (one way)":""}${exitNote(dest)}`,dest]})
 574}
 575function legendHTML(){
 576  const esc=v=>v.replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"})[c])
 577  const row=(icon,text)=>`<div class=lr><i>${icon}</i><span>${esc(text)}</span></div>`
 578  const glyph=k=>k=="effigy"?"*":k=="dark"?"":k=="B"?"B":tileGlyph(k,-1,-1,1)
 579  const here=legendRows().map(([k,t])=>row(GFX?gfxIcon(k):esc(glyph(k)),t))
 580  const exits=exitRows().map(([ch,t,dest])=>row(GFX?gfxIcon(ch==">"?"i":dest[0]=="d"?"Dd":"D"):esc(ch),GFX&&ch!=">"?`${ch} ${t}`:t))
 581  return `<div class=lg>${here.length?`here${here.join("")}`:""}exits${exits.join("")}<div class=lr><i></i><span>keys: t drop torch, . wait</span></div></div>`
 582}
 583
 584function frame(lines,w=31){
 585  const body=lines.map(line=>{
 586    const s=(line||"").slice(0,w)
 587    return `| ${s}${" ".repeat(w-s.length)} |`
 588  })
 589  return [`+${"-".repeat(w+2)}+`,...body,`+${"-".repeat(w+2)}+`].join("\n")
 590}
 591function floorTheme(level=S.level){return FLOOR_THEMES[level]||FLOOR_THEMES[1]}
 592function tileGlyph(t,x,y,vis){
 593  if(vis&&x==S.x&&y==S.y)return "@"
 594  if(vis&&S.r=="e"&&x==S.beast.x&&y==S.beast.y)return "B"
 595  if(!vis&&t=="r")return "*"
 596  if(!vis&&".#".includes(t))return t=="#"?"#":"."
 597  return t=="."?".":t=="#"?"":t=="O"?"o":t=="r"?"*":t=="z"?"^":t=="x"?"!":t=="n"?"~":t=="q"?"Q":t=="l"?"j":t=="S"?"Y":t=="F"?"&":t=="T"?"t":t=="g"?"$":t=="M"?"m":t
 598}
 599function fogClass(x,y,vis,mem){
 600  if(!vis&&!mem)return "u"
 601  if(!vis)return "m fog3"
 602  const d=Math.abs(x-S.x)+Math.abs(y-S.y),s=sight()
 603  return d<=Math.max(1,s-2)?"v fog1":d<=Math.max(2,s-1)?"v mid fog2":"v far fog3"
 604}
 605function splash(title,sub,lines,footer){
 606  return frame([title,"",sub,...lines,"",footer].filter(v=>v!==undefined),31)
 607}
 608
 609function variantSummary(){
 610  return `hall ${V.hall} | pit ${V.pit} | idol ${V.idol} | crack ${V.crack} | beast ${V.beast} | escape ${V.escape} | mods ${S.mods.join("/")}`
 611}
 612
 613function escapeSolve(level=S.level,escapeIdx=V.escape,beastIdx=V.beast,torch=6){
 614  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()
 615  while(q.length){
 616    const [s,path]=q.shift(),k=[s.x,s.y,s.bx,s.by,s.torch,s.chase,s.ward].join()
 617    if(seen.has(k))continue
 618    seen.add(k)
 619    for(const [dx,dy,ch] of [[1,0,"R"],[-1,0,"L"],[0,1,"D"],[0,-1,"U"]]){
 620      const nx=s.x+dx,ny=s.y+dy,t=map[ny]&&map[ny][nx]
 621      if(!t||t=="#")continue
 622      if(t==">")return path+ch
 623      let x=nx,y=ny,bx=s.bx,by=s.by,chase=s.chase,lit=s.torch>0,ward=s.ward
 624      let left=Math.max(0,s.torch-((level==2||level==4||level==5)?2:1))
 625      if(level>=3&&ward)left=Math.max(0,left-1)
 626      let state=level>=6&&((!lit&&!ward)||chase>4)?"enraged":level>=6?"alert":level==5&&(!lit||chase>3)?"enraged":level==5?"alert":level==4&&(!lit||chase>4)?"enraged":level==4?"alert":level==3&&ward?"alert":level==2&&(!lit||chase>5)?"enraged":level==2?"alert":left?"alert":"enraged"
 627      if(state=="alert"&&left&&!(chase++%2)){q.push([{x,y,bx,by,torch:left,chase,ward},path+ch]);continue}
 628      for(let i=0;i<(state=="enraged"?2:1);i++){
 629        if(Math.abs(x-bx)+Math.abs(y-by)<=1){bx=-1;break}
 630        const ddx=x-bx,ddy=y-by,ax=Math.abs(ddx),ay=Math.abs(ddy)
 631        let tries=ax>=ay?[[sign(ddx),0],[0,sign(ddy)]]:[[0,sign(ddy)],[sign(ddx),0]]
 632        if(tries[0][0]||tries[0][1]){
 633          const a=tries[0],b=tries[1]
 634          const ca=cover(bx+a[0],by+a[1]),cb=cover(bx+b[0],by+b[1])
 635          if(cb>ca)tries=[b,a]
 636        }
 637        for(const [mx,my] of tries){
 638          const tx=bx+mx,ty=by+my
 639          if((mx||my)&&map[ty]&&map[ty][tx]!="#"){bx=tx;by=ty;break}
 640        }
 641        if(Math.abs(x-bx)+Math.abs(y-by)<=1){bx=-1;break}
 642      }
 643      if(bx>=0)q.push([{x,y,bx,by,torch:left,chase,ward},path+ch])
 644    }
 645  }
 646  return ""
 647}
 648
 649function floorSolve(seed0,level=S&&S.level||1){
 650  const kS=S,kV=V,kR=R,kO=O,kB=B0
 651  S={level,mods:kS&&kS.mods||pickMods(seed0)}
 652  build(seed0)
 653  const solidAt=(rm,x,y)=>{const t=R[rm].m[y][x];return t=="#"||t=="O"||!!overAt(rm,x,y,"blocks")}
 654  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}
 655  const exitTo=(rm,dest)=>{for(const ch in R[rm].ex)if(R[rm].ex[ch].replace("!","")==dest)return find(rm,ch);return null}
 656  const cost=(rm,from,...tos)=>{
 657    let total=0,at=from
 658    for(const to of tos){
 659      if(!to)return -1
 660      let d=-1
 661      const seen=new Set([at.join()]),q=[[at[0],at[1],0]]
 662      while(q.length){
 663        const [x,y,n]=q.shift()
 664        if(x==to[0]&&y==to[1]){d=n;break}
 665        for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){
 666          const nx=x+dx,ny=y+dy,k=nx+","+ny
 667          if(nx<0||ny<0||nx>=W||ny>=H||seen.has(k)||solidAt(rm,nx,ny))continue
 668          seen.add(k);q.push([nx,ny,n+1])
 669        }
 670      }
 671      if(d<0)return -1
 672      total+=d;at=to
 673    }
 674    return total
 675  }
 676  const sp=rm=>(P[rm]||P[rm[0]]).slice()
 677  const out=(()=>{
 678    if(!R.b||!R.h||!R.i||!R.v||!R.e)return ""
 679    let total=0
 680    for(const c of [
 681      cost("g",sp("g"),exitTo("g","b")),
 682      cost("b",sp("b"),find("b","T"),exitTo("b","h")),
 683      cost("h",sp("h"),exitTo("h","i")),
 684      cost("i",sp("i"),find("i","I"),exitTo("i","h"))
 685    ]){if(c<0)return "";total+=c}
 686    const via=r2=>{
 687      const a=cost("h",sp("h"),exitTo("h",r2))
 688      if(a<0)return -1
 689      const b=cost(r2,sp(r2),exitTo(r2,"v"))
 690      return b<0?-1:a+b
 691    }
 692    const vp=via("p"),vc=via("c")
 693    if(vp<0&&vc<0)return ""
 694    total+=vp<0?vc:vc<0?vp:Math.min(vp,vc)
 695    const vault=cost("v",sp("v"),find("v","C"),find("v","K"),exitTo("v","e"))
 696    if(vault<0)return ""
 697    total+=vault
 698    // floor 1 crosses e with fresh-run fuel plus a brazier refuel: 8 is fair
 699    const esc=escapeSolve(level,V.escape,V.beast,level==2?6:8)
 700    if(!esc)return ""
 701    return "ok:"+(total+esc.length)
 702  })()
 703  S=kS;V=kV;R=kR;O=kO;B0=kB
 704  return out
 705}
 706
 707function validateSeeds(n=24){
 708  let bad=0,msg=[]
 709  for(const level of [1,2,3,4,5,6,7])for(let i=0;i<n;i++){
 710    if(!floorSolve(seed+i,level)){bad++;if(msg.length<6)msg.push(`l${level}:${(seed+i).toString(36)}`)}
 711  }
 712  return `check ${n} seeds x floors 1-7 | ${bad?"bad "+bad+" "+msg.join(", "):"all clear"}`
 713}
 714
 715function dropTorch(){
 716  S.hint=""
 717  if(S.flags.torch){
 718    S.drop={r:S.r,x:S.x,y:S.y,fuel:S.flags.torch}
 719    S.flags.torch=0
 720    S.flags.ash=0
 721    S.path.drops++
 722    return setMsg("Set down the torch.")
 723  }
 724  if(S.drop&&S.drop.r==S.r&&S.drop.x==S.x&&S.drop.y==S.y&&S.drop.fuel){
 725    S.flags.torch=S.drop.fuel
 726    S.drop=null
 727    return setMsg("Recover the torch.")
 728  }
 729}
 730
 731function beastState(){
 732  if(hasMod("wake")&&S.r=="e"&&S.level<4)return "alert"
 733  if(S.level==6&&S.r=="e"&&((!S.flags.ward&&!fire())||S.chase>4))return "enraged"
 734  if(S.level==6&&S.r=="e")return "alert"
 735  if(S.level==5&&S.r=="e"&&(!fire()||S.chase>3||S.path.shades>2))return "enraged"
 736  if(S.level==5&&S.r=="e")return "alert"
 737  if(S.level==4&&S.r=="e"&&(!fire()||S.chase>4||S.path.shades>1))return "enraged"
 738  if(S.level==4&&S.r=="e")return "alert"
 739  if(S.level==3&&S.flags.ward&&S.r=="e")return "alert"
 740  if(S.level==3&&S.flags.ward&&S.flags.crown)return "alert"
 741  if(S.flags.relic=="tooth"&&S.r=="e"&&S.chase>3)return "enraged"
 742  if(S.level==2&&S.r=="e"&&(!fire()||S.chase>5))return "enraged"
 743  if(S.level==2&&(S.r=="e"||S.flags.gold||S.flags.crown))return "alert"
 744  if(S.flags.crown&&!fire())return "enraged"
 745  if(S.flags.crown&&S.r=="e"&&S.chase>8)return "enraged"
 746  if(S.flags.crown||S.flags.gold)return "alert"
 747  return "calm"
 748}
 749
 750function cover(x,y){
 751  let n=0
 752  if(!R.e)return 0
 753  for(const d of [[1,0],[-1,0],[0,1],[0,-1]])if(tile(x+d[0],y+d[1],"e")=="#")n++
 754  return n
 755}
 756
 757function beastTurn(){
 758  if(S.r!="e"||!S.alive||S.win)return
 759  const s=beastState()
 760  if(s=="calm"&&fire())return
 761  if(s=="alert"&&carriedFire()&&S.flags.relic!="hush"&&S.flags.relic!="lure"&&!(S.chase++%2))return
 762  const lure=S.drop&&S.drop.r=="e"&&S.drop.fuel&&S.flags.relic!="ember"||S.flags.relic=="lure"&&S.drop&&S.drop.r=="e"&&S.drop.fuel
 763  const tx=lure?S.drop.x:S.x,ty=lure?S.drop.y:S.y
 764  const radius=s=="calm"?BEAST_CALM_RADIUS:99
 765  const speed=s=="enraged"?BEAST_ENRAGED_SPEED:1
 766  if(dist(S.x,S.y,S.beast.x,S.beast.y)<=1)return die("The beast is on you before you can breathe.")
 767  if(dist(tx,ty,S.beast.x,S.beast.y)>radius)return
 768  const bx=S.beast.x,by=S.beast.y
 769  for(let i=0;i<speed;i++){
 770    const dx=tx-S.beast.x,dy=ty-S.beast.y,ax=Math.abs(dx),ay=Math.abs(dy)
 771    let tries=ax>=ay?[[sign(dx),0],[0,sign(dy)]]:[[0,sign(dy)],[sign(dx),0]]
 772    if(tries[0][0]||tries[0][1]){
 773      const a=tries[0],b=tries[1]
 774      const ca=cover(S.beast.x+a[0],S.beast.y+a[1]),cb=cover(S.beast.x+b[0],S.beast.y+b[1])
 775      if((s=="alert"||s=="enraged")&&cb>ca)tries=[b,a]
 776    }
 777    for(const [mx,my] of tries){
 778      const nx=S.beast.x+mx,ny=S.beast.y+my
 779      if((mx||my)&&tile(nx,ny,"e")!="#"){S.beast.x=nx;S.beast.y=ny;break}
 780    }
 781    if(lure&&S.beast.x==S.drop.x&&S.beast.y==S.drop.y){S.drop.fuel=0;setMsg("Beast stamps out the torch.");break}
 782    if(dist(S.x,S.y,S.beast.x,S.beast.y)<=1)return die("The beast tears you open.")
 783  }
 784  if(bx!=S.beast.x||by!=S.beast.y)S.fx="beast"
 785}
 786
 787function burnTorch(){
 788  if(!S.alive||S.win||!dark())return
 789  if(S.flags.torch){
 790    let drain=(S.level==2||S.level>=4)&&"ec".includes(S.r[0])?TORCH_DRAIN_HAZARD:TORCH_DRAIN_BASE
 791    if(S.level>=3&&S.flags.ward)drain++
 792    if(hasMod("thin"))drain++
 793    if(S.flags.relic=="wick")drain++
 794    if(S.flags.relic=="ember")drain=Math.max(1,drain-1)
 795    if(S.flags.relic=="eye"&&S.flags.crown)drain++
 796    if(S.flags.relicPlus&&S.flags.relic=="ember")drain=Math.max(1,drain-S.flags.relicPlus)
 797    if(!S.flags.gold||S.flags.relic=="chain"||S.turn%2){
 798      S.flags.torch=Math.max(0,S.flags.torch-drain)
 799      if(!S.flags.torch){S.flags.ash=1;if(!S.msg)setMsg("Torch gutters out.")}
 800    }
 801  }
 802  if(S.drop&&S.drop.r==S.r&&S.drop.fuel){
 803    let dropDrain=1
 804    if(S.flags.relic=="wick")dropDrain=0
 805    S.drop.fuel=Math.max(0,S.drop.fuel-dropDrain)
 806    if(!S.drop.fuel&&!S.msg)setMsg("Dropped torch gutters out.")
 807  }
 808}
 809
 810function shadow(){
 811  if(S.r=="e")return 0
 812  if(S.level>=3&&S.flags.ward){
 813    const wardLoss=hasMod("grace")||S.flags.relic=="chain"?0:1
 814    if(wardLoss)loseWard(wardLoss)
 815    if(S.dread)S.dread--
 816    return 0
 817  }
 818  if(S.flags.relic=="hush"){if(S.dread)S.dread--;return 0}
 819  if(S.flags.mask){if(S.dread)S.dread--;return 0}
 820  if(dark()&&!S.flags.torch){
 821    if(S.dread<(S.level==2?8:6))S.dread++
 822    if(S.dread>(hasMod("gloom")?1:S.level==2?2:3)&&((S.turn+seed)&(hasMod("gloom")?1:S.level==2?2:3))==0)return 1
 823  }else if(S.dread)S.dread--
 824  return 0
 825}
 826
 827function enter(r,x=(P[r]||P[r[0]])[0],y=(P[r]||P[r[0]])[1],msg){
 828  S.hint=""
 829  S.r=r;S.x=x;S.y=y
 830  const f=r[0]
 831  if(f=="e"){S.chase=0;if(S.flags.crown)S.beast={x:B0[0],y:B0[1]}}
 832  if(f=="p")S.path.pit++
 833  if(f=="c")S.path.crack++
 834  if(f=="d")return die("The black arch keeps its promise.")
 835  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.")
 836  if(f=="c"&&S.flags.gold&&!S.flags.mask&&S.flags.relic!="tooth"){
 837    if(S.level>=3&&S.flags.ward){loseWard(S.flags.relic=="greave"?4:3);msg="Ward parts the crack; the blessing thins."}
 838    else return die("Weighted by treasure, you wedge fast in the crack.")
 839  }
 840  if(f=="c"&&S.flags.torch&&!(S.flags.relic=="veil"&&S.flags.mask)){
 841    S.flags.torch=Math.max(0,S.flags.torch-(S.flags.relic=="greave"?2:S.flags.relic=="wick"?0:S.flags.torch))
 842    S.flags.ash=!S.flags.torch?1:S.flags.ash
 843    msg=S.flags.torch?"Crack strips the torch.":"Crack snuffs the torch."
 844  }
 845  if(f=="g"&&S.flags.crown&&S.flags.key){
 846    if(S.level<5)return startCamp(S.level+1)
 847    if(S.level==5)return startFinale()
 848    return win()
 849  }
 850  if(f=="b"&&!S.flags.torch)showHint("torch")
 851  if(f=="c")showHint("crack")
 852  if(f=="e")showHint("beast")
 853  setMsg(msg||roomMsg(r))
 854}
 855
 856function pitSafe(x,y){
 857  if(S.flags.relic=="greave")return 1
 858  if(S.mode=="swift")return 1
 859  if(S.flags.torch)return 1
 860  for(const d of [[1,0],[-1,0],[0,1],[0,-1]])if(tile(x+d[0],y+d[1])=="#")return 1
 861  return 0
 862}
 863
 864function touch(t,x,y){
 865  if(t=="O"&&!pitSafe(x,y))return die("You step into the dark and do not find the edge.")
 866  if(S.r=="e"&&x==S.beast.x&&y==S.beast.y)return die("The beast tears you open.")
 867  if(S.drop&&S.drop.r==S.r&&S.drop.x==x&&S.drop.y==y&&S.drop.fuel){S.flags.torch=S.drop.fuel;S.drop=null;S.flags.ash=0;return setMsg("Recover the torch.")}
 868  if(t=="T"&&!S.flags.torch){S.flags.torch=FUEL+(hasMod("thin")?THIN_FUEL_BONUS:0);S.flags.ash=0;showHint("torch");setMsg("Take the torch.")}
 869  if(t=="M"&&!S.flags.mask){S.flags.mask=1;setMsg("Take the funeral mask. Dark eases, but flame stops slowing the beast.")} 
 870  if(t=="S"){
 871    const wardBase=S.level>=3?8:4
 872    const wardGain=(hasMod("grace")?2:0)+(S.flags.relic=="eye"?1:0)+(S.flags.relic=="chain"?2+S.flags.relicPlus:0)-(S.flags.relic=="veil")
 873    S.flags.ward=Math.max(S.flags.ward,wardBase+wardGain)
 874    if(S.flags.relic=="ash"&&S.flags.torch){S.flags.torch=Math.max(0,S.flags.torch-1);if(!S.flags.torch)S.flags.ash=1}
 875    S.path.shrines++
 876    showHint("ward")
 877    setMsg(S.level>=3?"Raise a ward. Flame will feed it.":"Raise a ward. It will hold for a while.")
 878  }
 879  if(t=="r"){
 880    const eff=overAt(S.r,x,y,"effigies")
 881    if(eff){eff.on=0;return die("The second relic was never real. Poison closes your throat.")}
 882    const o=overAt(S.r,x,y,"relics")
 883    if(o){
 884      S.flags.relic=o.id;S.flags.relicPlus=0;S.path.relics++;o.on=0;S.fx="relic"
 885      setMsg(`Claim ${RELICS[o.id].name}. Gain ${RELICS[o.id].up}; lose ${RELICS[o.id].down}.${S.path.relics==1?` Title: ${buildTitle()}.`:""}`)
 886    }
 887  }
 888  if(t=="F"){
 889    const cap=FUEL+(S.flags.relic=="wick"?4:0)+(hasMod("thin")?THIN_FUEL_BONUS:0)
 890    const fill=S.flags.relic=="ash"?10:S.flags.relic=="wick"?8:6
 891    const gain=S.flags.relic=="ash"?6:4
 892    if(S.flags.ash||!S.flags.torch){S.flags.torch=fill;S.flags.ash=0;setMsg("Light the torch from the brazier.")}
 893    else if(S.flags.torch<cap+(S.flags.relic=="ash"?2:0)){S.flags.torch=Math.min(cap+(S.flags.relic=="ash"?2:0),S.flags.torch+gain);setMsg("Feed the torch at the brazier.")}
 894    else setMsg("Leave the brazier. The torch is full.")
 895  }
 896  if(t=="x"){S.fx="hit";setMsg("Trigger the crack underfoot.");burnTorch();beastTurn();if(!S.alive)return}
 897  if(t=="z"){
 898    S.dread=Math.min(8,S.dread+(S.flags.relic=="brand"?3:S.flags.relic=="salt"?1:2))
 899    if(S.flags.ward)loseWard(1)
 900    if(S.flags.torch&&S.flags.relic=="brand"){S.flags.torch=Math.max(0,S.flags.torch-1);if(!S.flags.torch)S.flags.ash=1}
 901    S.fx="hit"
 902    setMsg("Wake the curse. Dread rises.")
 903  }
 904  if(t=="n"){
 905    const o=overAt(S.r,x,y,"shades")
 906    if(o)o.on=0
 907    S.path.shades++
 908    if(S.flags.relic=="brand"){S.flags.ward=Math.min(9,S.flags.ward+2);setMsg("Break the shade and steal its spite.");return}
 909    S.dread=Math.min(8,S.dread+2)
 910    if(S.flags.torch){S.flags.torch=Math.max(0,S.flags.torch-(S.flags.relic=="salt"?3:2));if(!S.flags.torch)S.flags.ash=1}
 911    if(S.r=="e")S.chase+=2
 912    S.fx="hit"
 913    setMsg("Take the shade's chill. Beast pressure rises.")
 914  }
 915  if(t=="q"){
 916    const o=overAt(S.r,x,y,"watchers")
 917    if(o)o.on=0
 918    S.path.watchers++
 919    S.dread=Math.min(8,S.dread+(S.flags.relic=="salt"?0:1))
 920    S.chase+=S.level>=4?3:2
 921    if(S.flags.torch&&S.flags.relic!="salt"){S.flags.torch=Math.max(0,S.flags.torch-1);if(!S.flags.torch)S.flags.ash=1}
 922    S.fx="hit"
 923    setMsg("Wake a watcher. Beast pressure rises.")
 924  }
 925  if(t=="l"){
 926    const o=overAt(S.r,x,y,"leeches")
 927    if(o)o.on=0
 928    S.path.leeches++
 929    if(S.flags.ward)loseWard(S.flags.relic=="chain"?1:2)
 930    if(S.flags.torch){S.flags.torch=Math.max(0,S.flags.torch-1);if(!S.flags.torch)S.flags.ash=1}
 931    if(S.flags.gold)S.dread=Math.min(8,S.dread+1)
 932    S.fx="hit"
 933    setMsg("Feed the leech ward and flame.")
 934  }
 935  if((S.r=="i"&&!S.flags.idol&&(t=="I"||t=="g"||t=="S"))||(S.flags.relic=="eye"&&S.r=="i"&&!S.flags.idol)){S.flags.idol=1;if(S.level==3&&S.flags.ward)S.flags.ward=Math.min(9,S.flags.ward+2);setMsg("Bow to the idol.")}
 936  if(t=="g"){S.flags.gold=1;if(!S.msg)setMsg("Take the gold. Beast pressure rises.")} 
 937  if(t=="C"){
 938    if(!S.flags.idol)return die("You reach for the crown unbowed. The idol judges you.")
 939    S.flags.crown=1
 940    S.flags.gold=1
 941    if(S.level==3&&S.flags.ward)loseWard(2)
 942    if(S.flags.relic=="eye"){S.flags.torch=Math.max(0,S.flags.torch-3);if(!S.flags.torch)S.flags.ash=1}
 943    S.crownTurn=S.turn
 944    S.fx="hit"
 945    setMsg("Lift the crown. The ruin wakes hungry.")
 946  }
 947  if(t=="K"&&!S.flags.key){S.flags.key=1;setMsg(S.flags.crown?"Take the key. Run for the gate.":"Take the key. The crown still waits.")}
 948}
 949
 950function step(dx,dy){
 951  if(S.gameState!="play")return
 952  S.msg="";S.hint=""
 953  const px=S.x,py=S.y
 954  const nx=S.x+dx,ny=S.y+dy,t=tile(nx,ny)
 955  if(t=="#")return setMsg(roomMsg())
 956  S.turn++
 957  if(shadow()){setMsg("Darkness twists the room around you.");S.fx="dark";burnTorch();beastTurn();return}
 958  let ex=R[S.r].ex[t]
 959  if(ex){
 960    if(ex[0]=="!"){delete R[S.r].ex[t];ex=ex.slice(1)}
 961    if(S.r=="e"&&nx==S.beast.x&&ny==S.beast.y)return die("The beast tears you open.")
 962    burnTorch();return enter(ex)
 963  }
 964  S.x=nx;S.y=ny
 965  if(px!=S.x||py!=S.y){S.fx="move";S.intro=0}
 966  touch(t,nx,ny)
 967  if(!S.alive||S.win)return
 968  burnTorch()
 969  beastTurn()
 970  if(!S.alive||S.win)return
 971  if(!S.msg)setMsg(roomMsg())
 972}
 973
 974function wait(){
 975  if(S.gameState!="play")return
 976  S.hint=""
 977  S.turn++
 978  S.fx=""
 979  if(shadow())S.msg="Freeze as dark closes in."
 980  else S.msg="Wait one breath."
 981  burnTorch()
 982  beastTurn()
 983  if(!S.alive||S.win)return
 984}
 985
 986function jumpLevel(level=2){
 987  startRun(S&&S.mode||"standard",0)
 988  while(S.level<level)enterLevel(S.level+1)
 989  render()
 990}
 991
 992function testFloor(){
 993  startRun(S&&S.mode||"standard",0)
 994  S.level=9;S.currentLevel=9
 995  build(seed)
 996  S.r="g";S.x=P.g[0];S.y=P.g[1]
 997  setMsg("Proving ground. Exit a in the hall drops one-way into h2; exit b in h2 climbs back.")
 998  render()
 999}
1000
1001function key(e){
1002  if(e.key=="m"){muted=!muted;render();return}
1003  if(e.key=="g"){gfxToggle();render();return}
1004  if(S.gameState=="win"&&(e.key=="p"||e.key=="7")){startPostgame();render();return}
1005  if(S.gameState=="title"){
1006    if("12345678".includes(e.key)){startRun(MODE_IDS[+e.key-1],1);render();return}
1007    if(e.key=="r"){startRun("standard",1);render();return}
1008    if(e.key=="c"&&hasSave){continueSave();return}
1009  }
1010  if(e.key=="r"){reset(1);render();return}
1011  if(e.key=="R"){startRun(S&&S.mode||"standard",0);render();return}
1012  if(e.key=="v"){DBG.show=!DBG.show;render();return}
1013  if(e.key=="V"){DBG.out=validateSeeds();render();return}
1014  if(e.key=="j"){jumpLevel(2);return}
1015  if(e.key=="J"){jumpLevel(3);return}
1016  if(e.key=="k"){jumpLevel(4);return}
1017  if(e.key=="K"){jumpLevel(5);return}
1018  if(e.key=="T"){testFloor();return}
1019  if(S.gameState=="camp"){
1020    if("1234".includes(e.key))applyCampChoice(e.key)
1021    render()
1022    return
1023  }
1024  if(S.gameState!="play"){render();return}
1025  if(e.key=="n"){reset(1);render();return}
1026  if(e.key=="t"){dropTorch();render();return}
1027  if(e.key=="ArrowUp"||e.key=="w")step(0,-1)
1028  if(e.key=="ArrowDown"||e.key=="s")step(0,1)
1029  if(e.key=="ArrowLeft"||e.key=="a")step(-1,0)
1030  if(e.key=="ArrowRight"||e.key=="d")step(1,0)
1031  if(e.key==" "||e.key==".")wait()
1032  render()
1033}
1034addEventListener("keydown",key)
1035D.onclick=e=>{const k=e.target.dataset.k;if(k)key({key:k})}
1036X.onclick=()=>{reset(1);render()}
1037
1038function draw(x,y){
1039  const vis=visible(x,y),mem=seen(x,y)
1040  if(!vis&&!mem)return ""
1041  const t=tile(x,y)
1042  return tileGlyph(t,x,y,vis)
1043}
1044
1045function cls(x,y){
1046  const vis=visible(x,y),mem=seen(x,y)
1047  if(!vis&&!mem)return "u"
1048  if(vis&&x==S.x&&y==S.y)return `p ${fogClass(x,y,vis,mem)} ${S.fx=="move"?"pm":""} ${S.flags.ward&&S.flags.ward<4?"warded":""}`.trim()
1049  if(vis&&S.r=="e"&&x==S.beast.x&&y==S.beast.y)return `B ${fogClass(x,y,vis,mem)} ${S.fx=="beast"?"bm":""} ${beastState()}`.trim()
1050  const t=tile(x,y),state=fogClass(x,y,vis,mem),near=S.flags.ward&&vis&&Math.abs(x-S.x)+Math.abs(y-S.y)<=1,kind=t=="#"?"w":t=="O"?"o":t=="."?"f":t==">"?"i":t
1051  return `${kind} ${state} ${"Ozxnql".includes(t)?"haz":""} ${t=="T"?"drop":""} ${t=="S"||near?"warded":""} ${t=="S"&&S.flags.ward&&S.flags.ward<4?"stress":""}`.trim()
1052}
1053
1054function render(){
1055  SEl.classList.toggle("gfx",!!GFX)
1056  U.className=S.gameState
1057  SEl.dataset.state=S.gameState
1058  SEl.dataset.floor=S.level||1
1059  SEl.dataset.mode=S.mode||"standard"
1060  if(S.gameState=="title"){
1061    delete G.dataset.intro
1062    U.innerHTML=`1MB ROGUELIKE<small>single-file campaign | minimalist descent</small>`
1063    I.textContent=`modes\n1  standard  ${MODES.standard.desc}\n2  iron      ${MODES.iron.desc}\n3  greed     ${MODES.greed.desc}\n4  dark      ${MODES.dark.desc}\n5  daily     ${MODES.daily.desc}\n6  heavy     ${MODES.heavy.desc}\n7  swift     ${MODES.swift.desc}\n8  warded    ${MODES.warded.desc}\n\nkeys\n1-8 begin\nr   quick run${hasSave?"\nc   continue saved run":""}\nm   mute sound\ng   graphics / ascii\n\nglyphs\n@ you      B beast    * relic\nY shrine   & brazier  t torch\n! crack    ^ curse    Q watcher\nj leech    $ gold     > gate\n\nin play, the side panel explains\nevery item and exit in the room`
1064    L.textContent=`Pick a mode, then descend. Flame buys time; wards buy mistakes.${hasSave?" A saved run is waiting — press c to continue.":""}`
1065    X.textContent="start standard"
1066    X.onclick=()=>{startRun("standard",1);render()}
1067    X.hidden=false
1068    G.className="splash shift"
1069    G.textContent=splash("1MB ROGUELIKE","single-file campaign",[
1070      "descend through six authored floors",
1071      "carry light, ward, and stolen momentum",
1072      "every room is text, timing, and pressure"
1073    ],"press 1-5 or use the button below")
1074    return
1075  }
1076  if(S.gameState=="camp"){
1077    delete G.dataset.intro
1078    const next=floorTheme(S.camp.next)
1079    U.innerHTML=`CAMP<small>${buildTitle()} | floor ${S.level} to ${S.camp.next} | ${next.tag}</small>`
1080    X.textContent="restart"
1081    X.onclick=()=>{reset(1);render()}
1082    I.textContent=`camp\n${statRow("title",buildTitle())}\n${statRow("next",`floor ${S.camp.next} ${next.name}`)}\n${statRow("accent",next.accent)}\n${statRow("fog",next.fog)}\n${statRow("mods",modSummary())}\n${statRow("relic",S.flags.relic?RELICS[S.flags.relic].name+(S.flags.relicPlus?` +${S.flags.relicPlus}`:""):"none")}\n\n1 temper current\n2 take ${RELICS[S.camp.offer[0]].label}\n3 take ${RELICS[S.camp.offer[1]].label}\n4 sacrifice`
1083    L.textContent=`${S.endLead}\nTitle: ${buildTitle()}. Temper for consistency, trade for a pivot, or sacrifice for supplies.`
1084    X.hidden=false
1085    G.className="splash shift"
1086    G.textContent=splash("CAMP","the ruin loosens its grip for one room",[
1087      `next floor: ${S.camp.next} ${next.name}`,
1088      `title     : ${buildTitle()}`,
1089      `accent    : ${next.accent}`,
1090      `fog       : ${next.fog}`,
1091      `offer     : ${RELICS[S.camp.offer[0]].name}`,
1092      `offer     : ${RELICS[S.camp.offer[1]].name}`
1093    ],"press 1-4 to choose")
1094    return
1095  }
1096  if(S.gameState=="play"){
1097    remember()
1098    const th=floorTheme()
1099    U.innerHTML=`floor ${S.level} | ${th.name}<small>${buildTitle()} | ${th.tag} | ${th.accent} | ${MODES[S.mode||"standard"].label} | seed ${S.seed.toString(36)}</small>`
1100    I.innerHTML=legendHTML()
1101    I.insertAdjacentText("beforeend",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`:""))
1102    L.textContent=[...new Set([S.msg,S.hint,roomMsg()])].filter(Boolean).join("\n")
1103    X.onclick=()=>{reset(1);render()}
1104    X.hidden=true
1105    if(S.intro)G.dataset.intro=INTRO_TEXT
1106    else delete G.dataset.intro
1107    G.innerHTML=""
1108    G.className=S.fx=="hit"?"hit":S.fx=="win"?"win":S.fx=="shift"?"shift":""
1109    if(S.fx=="ward")G.className="ward"
1110    if(S.fx=="relic")G.className="relic"
1111    if(S.fx=="dark"){G.className="";void G.offsetWidth;G.className="dark"}
1112    for(let y=0;y<H;y++)for(let x=0;x<W;x++){
1113      const d=document.createElement("div")
1114      d.className="t "+cls(x,y)
1115      if(GFX)d.innerHTML=gfxCell(d.className,x,y,draw(x,y))
1116      else d.textContent=draw(x,y)
1117      G.appendChild(d)
1118    }
1119    SFX.play(S.fx)
1120    S.fx=""
1121    return
1122  }
1123  const done=S.gameState=="dead",th=floorTheme()
1124  delete G.dataset.intro
1125  U.innerHTML=`${done?"DEAD":"ESCAPED"}<small>${done?S.deathCause:buildTitle()} | floor ${S.level} ${th.name} | seed ${S.seed.toString(36)}</small>`
1126  X.textContent="restart"
1127  X.onclick=()=>{reset(1);render()}
1128  G.className=`splash ${S.gameState=="win"?"win":"hit"}`
1129  SFX.play(done?"dead":"win")
1130  G.textContent=splash(done?"RUN ENDED":"DAWN","the descent leaves a shape behind",[
1131    `title : ${buildTitle()}`,
1132    `route : ${runStyle().route}`,
1133    `state : ${done?"broken":"survived"}`,
1134    `floor : ${S.level} ${th.name}`,
1135    `mods  : ${modSummary()}`
1136  ],done?"press restart to descend again":"the gate opens and the run resolves")
1137  I.textContent=`summary\n${endStats()}${DBG.show?`\n\ndebug\n${variantSummary()}${DBG.out?`\ncheck: ${DBG.out}`:""}`:""}`
1138  L.textContent=`${S.endLead}\n${done?"The ruin keeps the rest.":"The run closes cleanly at the gate."}${!done?"\npress p to descend further":""}`
1139  X.hidden=false
1140}
1141render()
1142</script>