<!doctype html><meta charset="utf-8"><title>NOMAD mobs</title>
<meta http-equiv="cache-control" content="no-store">
<meta property="og:type" content="website">
<meta property="og:title" content="NOMAD — The Mountain">
<meta property="og:description" content="Room and creature art from NOMAD, a MUD that runs on Nostr.">
<meta property="og:image" content="/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<style>
 body{margin:0;background:#16120c;color:#ede3cc;font:13px ui-monospace,Menlo,monospace}
 #bar{position:sticky;top:0;z-index:5;background:#1e1912;border-bottom:1px solid #3a3020;padding:10px 14px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
 select,button{background:#241e15;color:#ede3cc;border:1px solid #3a3020;padding:5px 9px;font:inherit;cursor:pointer}
 button:hover,select:hover{border-color:#d8a94e;color:#d8a94e}
 button.on{border-color:#d8a94e;color:#d8a94e;background:#2e2517}
 /* THE HOUR IS THE CONTROL YOU TOUCH MOST, so it is not behind a menu:
    twelve values, all of them visible, one click each. */
 .seg{display:flex;gap:0;border:1px solid #3a3020}
 .seg button{border:0;border-right:1px solid #3a3020;padding:5px 8px;font-size:11px}
 .seg button:last-child{border-right:0}
 .seg button.on{background:#3a2f1c;color:#f0d089}
 kbd{background:#241e15;border:1px solid #3a3020;border-radius:3px;padding:0 4px;font:inherit;font-size:10px;color:#9a8b66}
 #grid{display:flex;flex-wrap:wrap;gap:6px;padding:18px;align-items:flex-end}
 .cell{border:1px solid #2c2418;background:#100d09;padding:8px;display:flex;flex-direction:column;align-items:center;gap:6px;min-width:150px}
 .stage{height:300px;display:flex;align-items:center;justify-content:center}
 .mob{background-repeat:no-repeat;background-position-y:center;image-rendering:pixelated;
      filter:drop-shadow(0 3px 6px rgba(0,0,0,.75))}
 .n{color:#9a8b66;font-size:11px;text-align:center;line-height:1.5}
 .n b{color:#ede3cc;font-weight:400}
 .no{color:#8a5a4a}
 /* ---- the scene's own stylesheet, lifted ---- */
 #stage #scene {
    display: block;
    position: fixed;
    inset: 0;
    z-index: 0;
    background-color: transparent;
    background-size: cover;
    background-position: center 55%;
    background-repeat: no-repeat;
    transition: filter .8s ease;
  }
 #stage #sky {
    display: block;
    position: fixed;
    inset: 0;
    
    z-index: 0;
    background-color: var(--bg);
    background-size: cover;
    background-position: center 55%;
    background-repeat: no-repeat;
  }
 #stage #scene.t-eclipse { filter: brightness(.70) saturate(.55) contrast(1.06); }
 #stage #scene.t-moon { filter: brightness(1.10) saturate(.92); }
 #stage #scene.t-night { filter: brightness(.42) saturate(.85); }
 #stage #scene.t-after-rain { filter: brightness(.94) saturate(.80) contrast(.96); }
 #stage #scene.t-fog { filter: brightness(.92) saturate(.45) contrast(.86); }
 #stage #scene.t-rain { filter: brightness(.74) saturate(.80) contrast(1.04); }
 #stage #scene.t-snow { filter: brightness(1.04) saturate(.55); }
 #stage #scene.t-blood::after {
    background: #d67c6c;
    mix-blend-mode: multiply;
    -webkit-mask-image: var(--sceneimg); mask-image: var(--sceneimg);
    -webkit-mask-size: cover; mask-size: cover;
    -webkit-mask-position: center 55%; mask-position: center 55%;
    -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  }
 #stage #scene::after {
    content: "";
    position: absolute;
    inset: 0;
    background: var(--scrim, transparent);
    transition: background .8s ease;
    pointer-events: none;
  }
 #stage #mobs {
    display: flex;
    position: fixed;
    left: 0; right: 0;
    
    top: 55%;
    transform: translateY(-50%);
    z-index: 0;
    align-items: center;
    justify-content: center;
    gap: 3vw;
    pointer-events: none;
    padding: 0 4vw;
  }
 #stage #mobs.t-dawn img, #stage #mobs.t-dawn .mob { filter: brightness(.44) saturate(.80) sepia(.16) hue-rotate(-6deg) drop-shadow(0 3px 6px rgba(0,0,0,.75)); }
 #stage #mobs.t-dusk img, #stage #mobs.t-dusk .mob { filter: brightness(.42) saturate(.95) sepia(.30) hue-rotate(-16deg) drop-shadow(0 3px 6px rgba(0,0,0,.75)); }
 #stage #mobs.t-night img, #stage #mobs.t-night .mob { filter: brightness(.26) saturate(.70) drop-shadow(0 3px 6px rgba(0,0,0,.75)); }
 #stage #mobs.t-night-torch img, #stage #mobs.t-night-torch .mob { filter: brightness(.80) saturate(1.04) sepia(.22) hue-rotate(-14deg) drop-shadow(0 3px 6px rgba(0,0,0,.8)); }
 #stage #mobs.t-eclipse img, #stage #mobs.t-eclipse .mob { filter: brightness(.48) saturate(.45) contrast(1.06) drop-shadow(0 3px 6px rgba(0,0,0,.75)); }
 #stage #mobs.t-moon img, #stage #mobs.t-moon .mob { filter: brightness(.52) saturate(.80) drop-shadow(0 3px 6px rgba(0,0,0,.75)); }
 #stage #mobs.t-blood img, #stage #mobs.t-blood .mob { filter: brightness(.28) saturate(.85) sepia(.30) hue-rotate(-24deg) drop-shadow(0 3px 6px rgba(0,0,0,.75)); }
 #stage #mobs.t-after-rain img, #stage #mobs.t-after-rain .mob { filter: brightness(.94) saturate(.82) contrast(.97) drop-shadow(0 3px 6px rgba(0,0,0,.75)); }
 #stage #mobs.t-fog img, #stage #mobs.t-fog .mob { filter: brightness(.78) saturate(.35) contrast(.80) drop-shadow(0 3px 6px rgba(0,0,0,.75)); }
 #stage #mobs.t-rain img, #stage #mobs.t-rain .mob { filter: brightness(.58) saturate(.70) contrast(1.04) drop-shadow(0 3px 6px rgba(0,0,0,.75)); }
 #stage #mobs.t-snow img, #stage #mobs.t-snow .mob { filter: brightness(.82) saturate(.45) contrast(.92) drop-shadow(0 3px 6px rgba(0,0,0,.75)); }
 #mobs img, #mobs .mob {
    display: block;
    width: auto;
    image-rendering: pixelated;
    filter: drop-shadow(0 3px 6px rgba(0,0,0,.75));
    
    min-width: 0;
    object-fit: contain;
  }
 #mobs .mob {
    background-repeat: no-repeat;
    background-position-y: center;
    
    flex: 0 0 auto;
  }
 #scene.sky-day { --scrim: transparent; }
 #scene.sky-dawn { --scrim: linear-gradient(rgba(84,104,148,.20), rgba(198,168,118,.10)); }
 #scene.sky-dusk { --scrim: linear-gradient(rgba(58,38,28,.20), rgba(188,108,48,.14)); }
 #scene.sky-night { --scrim: linear-gradient(rgba(8,10,26,.46), rgba(8,10,26,.38)); }
 #scene.sky-moon { --scrim: linear-gradient(rgba(20,30,60,.32), rgba(30,44,80,.24)); }
 #scene.sky-blood { --scrim: linear-gradient(rgba(70,14,14,.42), rgba(46,10,10,.34)); }
 #scene.sky-eclipse { --scrim: linear-gradient(rgba(18,20,34,.44), rgba(30,26,30,.36)); }
 #scene.sky-fog { --scrim: linear-gradient(rgba(150,150,145,.30), rgba(118,120,118,.24)); }
 #scene.sky-rain { --scrim: linear-gradient(rgba(30,40,50,.28), rgba(24,32,44,.22)); }
 #scene.sky-snow { --scrim: linear-gradient(rgba(202,212,226,.22), rgba(168,184,204,.16)); }
 #scene.sky-in { --scrim: radial-gradient(ellipse at 50% 62%, rgba(60,40,18,.10) 0%, rgba(10,8,6,.30) 55%, rgba(6,5,4,.52) 100%); }
 /* ---- end lifted; the four rules below are the only edits: the game's
    layers are fixed to the window and here they line a panel ---- */
 /* FULL WIDTH, like the game — the game's window IS the whole browser, so the
    stage is too. Constraining this to the plate's own ratio shrank it to a
    centred box, which was worse than the crop it was fixing; the crop is
    answered by the "whole plate" button and nowhere else.
    AND IT ENDS WHERE THE WINDOW ENDS. The bar is sticky, so any part of the
    stage that does not fit on screen goes UNDER it the moment you scroll to
    look at it. So the bar and the stage share one viewport-tall box and the
    stage takes whatever the bar leaves — nothing to scroll, nothing covered,
    whatever the bar wraps to. */
 #top{height:100dvh;display:flex;flex-direction:column}
 #bar{flex:0 0 auto}
 /* THE READOUT LIVES INSIDE THE VIEWPORT BOX. It sat after </div>, outside #top,
    which is 100dvh — so it began exactly at the bottom edge of the screen and
    had to be scrolled to. That is fallout from the fix that stopped the bar
    covering the picture: #top became a full-height column and the shelf, already
    outside it, was pushed off the bottom. It reports which plate and which sky
    are up, which day the pool is on, both tints, and why nothing is standing
    here — none of which is any use one scroll below the thing it describes. */
 #shelf{flex:0 0 auto;border-bottom:0;border-top:1px solid #2c2418}
 #stage{position:relative;flex:1 1 auto;min-height:0;
        overflow:hidden;background:#0b0906;border-bottom:1px solid #3a3020}
 /* ...and "whole plate" gives up the crop entirely and letterboxes it, for
    looking at the art rather than at the room. */
 #stage.whole #scene,#stage.whole #sky{background-size:contain!important;background-position:center!important}
 #stage #scene,#stage #sky{position:absolute}
 #stage #mobs{position:absolute}
 #stage.bare::before{content:"no plate for this ground — the world paints bare here too";
   position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#6b5c40}
 #shelf{padding:8px 14px;color:#9a8b66;font-size:11px;border-bottom:1px solid #2c2418;
        display:flex;gap:14px;flex-wrap:wrap;align-items:baseline}
 #shelf b{color:#d8a94e;font-weight:400}
 #shelf .miss{color:#8a5a4a}
 .t-dawn .mob{filter:brightness(.44) saturate(.80) sepia(.16) hue-rotate(-6deg) drop-shadow(0 3px 6px rgba(0,0,0,.75))}
 .t-dusk .mob{filter:brightness(.42) saturate(.95) sepia(.30) hue-rotate(-16deg) drop-shadow(0 3px 6px rgba(0,0,0,.75))}
 .t-night .mob{filter:brightness(.26) saturate(.70) drop-shadow(0 3px 6px rgba(0,0,0,.75))}
 .t-night-torch .mob{filter:brightness(.80) saturate(1.04) sepia(.22) hue-rotate(-14deg) drop-shadow(0 3px 6px rgba(0,0,0,.8))}
 .t-eclipse .mob{filter:brightness(.48) saturate(.45) contrast(1.06) drop-shadow(0 3px 6px rgba(0,0,0,.75))}
 .t-moon .mob{filter:brightness(.52) saturate(.80) drop-shadow(0 3px 6px rgba(0,0,0,.75))}
 .t-blood .mob{filter:brightness(.28) saturate(.85) sepia(.30) hue-rotate(-24deg) drop-shadow(0 3px 6px rgba(0,0,0,.75))}
 .t-after-rain .mob{filter:brightness(.94) saturate(.82) contrast(.97) drop-shadow(0 3px 6px rgba(0,0,0,.75))}
 .t-fog .mob{filter:brightness(.78) saturate(.35) contrast(.80) drop-shadow(0 3px 6px rgba(0,0,0,.75))}
 .t-rain .mob{filter:brightness(.58) saturate(.70) contrast(1.04) drop-shadow(0 3px 6px rgba(0,0,0,.75))}
 .t-snow .mob{filter:brightness(.82) saturate(.45) contrast(.92) drop-shadow(0 3px 6px rgba(0,0,0,.75))}
</style>
<div id="top">
<div id="bar">
 <span style="color:#d8a94e">NOMAD</span>
 <label>ground <select id="gnd"></select></label>
 <span id="hours" class="seg"></span><select id="hour" hidden></select>
 <button id="torch">torch</button>
 <button id="roll">next sky</button>
 <label>sky <select id="pick"></select></label>
 <button id="fit">whole plate</button>
 <label>standing <select id="who"></select></label>
 <label>scale <select id="sc"></select></label>
 <button id="fire">move</button>
 <button id="atk">attack</button>
 <button id="hit">take a hit</button>
 <button id="die">die</button>
 <button id="slp" data-st="rest">sleep</button>
 <button data-st="hunt">hunting you</button>
 <button data-st="flee">fleeing</button>
 <button data-st="hurt">wounded</button>
 <button data-st="reel">reeling</button>
 <span class="n">build 17:29:06 · ART_V 21 · 26 animated · 26 strike · 24 die</span>
</div>
<div id="stage"><div id="sky"></div><div id="scene"></div><div id="mobs"></div></div>
<div id="shelf"></div>
</div>
<div id="grid"></div>
<script>
var MOB_SPRITE={"the-herd":22,"the-milker":13,"a-fold-dog":9,"cave-lion":19,"red-hind":18,"bone-breaker":14,"brooding-vulture":14,"hill-wolf":13,"feral-goat":13,"carrion-vulture":13,"hill-eagle":11,"lynx":10,"eagle-owl":9,"scarp-raven":7,"mountain-hare":6,"snow-fox":6,"hill-fox":6,"glutton":6,"wildcat":5,"mountain-chough":5,"ptarmigan":4,"gill-adder":4,"ermine":3,"stone-adder":3,"eyrie-holder":22,"the-drake":44}, MOB_ANIM={"a-fold-dog":{"n":6,"aspect":1.46,"f":{"idle":0,"rest":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"bone-breaker":{"n":8,"aspect":1.132,"f":{"idle":0,"up":1,"glide":2,"down":3,"landing":4,"feed":5,"attack":6,"death":7}},"brooding-vulture":{"n":8,"aspect":1.086,"f":{"idle":0,"rest":1,"recover":2,"attack":3,"death":4,"feed":5,"move-a":6,"move-b":7}},"carrion-vulture":{"n":8,"aspect":1.115,"f":{"idle":0,"up":1,"glide":2,"down":3,"landing":4,"attack":5,"feed":6,"death":7}},"cave-lion":{"n":6,"aspect":1.696,"f":{"idle":0,"rest":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"eagle-owl":{"n":8,"aspect":1.149,"f":{"idle":0,"up":1,"glide":2,"down":3,"landing":4,"attack":5,"rest":6,"death":7}},"ermine":{"n":6,"aspect":1.307,"f":{"idle":0,"inspect-upright":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"eyrie-holder":{"n":8,"aspect":1.149,"f":{"idle":0,"up":1,"glide":2,"down":3,"landing":4,"attack":5,"feed":6,"death":7}},"feral-goat":{"n":6,"aspect":1.195,"f":{"idle":0,"graze":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"gill-adder":{"n":6,"aspect":0.938,"f":{"idle":0,"alert":1,"move-a":2,"move-b":3,"attack":4,"recover":5}},"glutton":{"n":6,"aspect":1.735,"f":{"idle":0,"feed":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"hill-eagle":{"n":8,"aspect":1.118,"f":{"idle":0,"up":1,"glide":2,"down":3,"landing":4,"attack":5,"rest":6,"death":7}},"hill-fox":{"n":6,"aspect":1.752,"f":{"idle":0,"rest":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"hill-wolf":{"n":6,"aspect":1.452,"f":{"idle":0,"rest":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"lynx":{"n":6,"aspect":1.324,"f":{"idle":0,"rest":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"mountain-chough":{"n":8,"aspect":1.069,"f":{"idle":0,"up":1,"glide":2,"down":3,"landing":4,"rest":5,"attack":6,"death":7}},"mountain-hare":{"n":6,"aspect":1.207,"f":{"idle":0,"rest":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"ptarmigan":{"n":8,"aspect":1.099,"f":{"idle":0,"up":1,"glide":2,"down":3,"landing":4,"rest":5,"attack":6,"death":7}},"red-hind":{"n":6,"aspect":1.108,"f":{"idle":0,"graze":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"scarp-raven":{"n":8,"aspect":1.102,"f":{"idle":0,"up":1,"glide":2,"down":3,"landing":4,"attack":5,"rest":6,"death":7}},"snow-fox":{"n":6,"aspect":1.556,"f":{"idle":0,"rest":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"stone-adder":{"n":6,"aspect":1.199,"f":{"idle":0,"watch":1,"hold-warm-ground":2,"attack":3,"recover":4,"bask":5}},"the-drake":{"n":14,"aspect":1.483,"f":{"idle":0,"alert":1,"bite":2,"sweep":3,"inhale":4,"breath":5,"takeoff":6,"up":7,"glide":8,"down":9,"dive":10,"landing":11,"hit":12,"death":13}},"the-herd":{"n":6,"aspect":0.991,"f":{"idle":0,"keep-the-line":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"the-milker":{"n":6,"aspect":1.167,"f":{"idle":0,"work-pull":1,"move-a":2,"move-b":3,"attack":4,"death":5}},"wildcat":{"n":6,"aspect":1.447,"f":{"idle":0,"rest":1,"move-a":2,"move-b":3,"attack":4,"death":5}}}, ART_V="21";
// THE ANSWER, WORKED OUT ABOVE, for exactly the grounds this build offers. null
// means the world could not be read and the page will not pretend to know.
var BARREN={"gatehouse":"you are behind the door — nothing is in the world with you","the-summit-gate":"nothing can reach this room","the-last-shelter":"a sanctuary — nothing that walks can follow you in","the-rib-cage":"a sanctuary — nothing that walks can follow you in","the-kept-room":"nothing can reach this room","the-relay-house":"nothing idles in a doorway","the-shieling":"nothing idles in a doorway","the-stell":"nothing idles in a doorway","the-slabs":"nothing idles in a doorway","the-shelter-crag":"nothing idles in a doorway"};
// What this build OFFERS. null means everything, which is the working preview.
var ONLY_GATE=["the-shieling","the-stell","the-slabs","the-shelter-crag"];
var DROP_HOUR=["fog","rain","snow"];
/* ---- the scene, lifted verbatim from public.ts ---- */
var ROOM_PLATE = {
  // The top of the mountain, and the two rooms of the approach to it. All three
  // were being answered by the ground rules and answered wrongly — the Summit
  // was painted as a snowfield by a room whose own text says there is no snow
  // in it, and the other two matched "vent" on their warm air and were handed a
  // bare scree slope.
  "the-summit":       "day night night-torch fog rain snow",
  "the-summit-gate":  "day night night-torch fog rain snow",
  // TWO CONDITIONS, AND NO DAY AT ALL (rome, 2026-09-09). It was three, and the
  // third was a lie: this is a hole under a fallen block with one slot in it,
  // and there is no hour at which the inside of it is a daylit room. The slot
  // gets bright, the room does not. So the interior is always the dark plate and
  // what changes with the hour is the SKY BEHIND THE SLOT — at noon that is the
  // day sky in a dark hole, which is what standing in there actually looks like.
  //
  // The other three conditions stay unshot for the older reason: under a roof
  // they would be three photographs spent on the light in one slot. See
  // SHELTERED and WEATHER_FROM_INSIDE.
  "the-last-shelter": "night night-torch",
  // THE LION'S GROUND. Three rooms of the bone fan, shot as open hillside and
  // cut on the skyline like every other outdoor plate.
  "the-dry-bones":    "day night night-torch fog rain snow",
  "the-rib-cage":     "day night night-torch fog rain snow",
  "the-ochre-shelf":  "day night night-torch fog rain snow",
  // THE RIDDLE DOOR'S ROOM. Six conditions like any hillside, but the only plate
  // in the game with NO SKY IN IT: you are at the foot of seven hundred feet of
  // wall that leans over you, so the rock runs off the top of the frame and the
  // shared sky is drawn behind an opaque picture and never seen. That costs
  // nothing and is the honest composition — the room says there is no way up.
  "the-back-wall":    "day night night-torch fog rain snow",
  // AND WHAT IS BEHIND IT. One plate, and one is the whole set: the room reports
  // sky "in", SKY_BASE maps that to the day slot, and "in" is not in TORCH_HOURS
  // so no flame ever swaps it. It is also in DARK_ROOMS, which means nobody has
  // ever seen this room without a light in their hand — so the single plate is
  // painted already lit by the torch you must be carrying to be standing in it.
  "the-kept-room":    "day",
  // TWO ROOMS THAT BORROW. Same conditions as the plate they point at — see
  // PLATE_OF below for why they are not simply copies of the files.
  "the-bone-ground":  "day night night-torch fog rain snow",
  "the-oxide-flat":   "day night night-torch fog rain snow",
};
var MOB_LINE_DEFAULT = 55;
var SHELTERED = { "the-last-shelter": 1 };
var SKY_BASE = {
  day: "day", "in": "day", dawn: "night", dusk: "night",
  // The ground it borrows is the DAY ground, not the rain ground: the rain
  // plates have rain falling in them, and it has stopped.
  "after-rain": "day",
  // TOTALITY IS A DARK SCENE, not a daylit one under a strange sky. It sits
  // at midday, so the day ground was the obvious answer and the obvious
  // answer was wrong: the light goes out, and bright noon stone under a
  // blacked sun is the one mismatch nobody can look past.
  eclipse: "night",
  night: "night", moon: "night", blood: "night",
  fog: "fog", rain: "rain", snow: "snow",
};
var WEATHER_FROM_INSIDE = { rain: 1, fog: 1, snow: 1, "after-rain": 1 };
var TORCH_HOURS = { night: 1, moon: 1, blood: 1, eclipse: 1, dusk: 1, dawn: 1 };
var PLATE_OF = {
  "the-bone-ground": "the-dry-bones",
  "the-oxide-flat":  "the-ochre-shelf",
};
var KEYED = { day: 1, night: 1, "night-torch": 1 };
var SKY_PAINTED = {
  day: 1, night: 1, dawn: 1, dusk: 1, moon: 1, blood: 1, eclipse: 1,
  // THE HOUR AFTER THE RAIN. The world already knew about it — the rain's
  // aftermath is a real phase, the ground is churned to mud and the prose says
  // so — and the picture went straight back to a blue midday. This is the one
  // sky that exists for a phase rather than an hour.
  "after-rain": 1,
  // fog, rain and snow need none: those scenes carry their own sky, because
  // weather you can see the far hills through is weather in the SCENE.
};
var NO_GROUND_TINT = { dawn: 1, dusk: 1, blood: 1 };
var MOB_LINE = { "corrie-rim": 72, "corrie-floor": 72, "the-back-wall": 62, "the-kept-room": 65,
                 boulder: 64, glass: 70, snow: 64, "the-rib-cage": 66,
                 // THE ALDER IS A BANK WITH WATER BEHIND IT (rome, 2026-09-09:
                 // the animals sit too high in it). Its near ground starts around
                 // 70% and the mire lies behind that, so at the default 55 only
                 // the biggest thing on the plate reached peat: a creature is
                 // CENTRED on this line, not stood on it, so half its height is
                 // the whole argument. A hill fox is 23vh and put its feet at
                 // 67%, a gill adder is 19vh and put them at 64% — both of them
                 // standing on open water, which the small ones always give away
                 // first. At 62 the adder lands at 72% and the goat at 78%, and
                 // everything on this ground is on the bank.
                 alder: 62,
                 // THE GULLY IS A FLOOR WITH WALLS OVER IT (rome, 2026-09-10).
                 // Its near floor starts around 67% and the walls stand behind
                 // it, so at the default 55 the smallest thing on the plate put
                 // its feet at 64% — above the floor's near edge, on wall. Four
                 // points down and the adder lands at 68%, the fox at 71% and
                 // the wolf at 76%, all three on gravel, and nothing has sunk
                 // behind the prose. 62 was tried and is too far: it puts the
                 // adder under the text, which is the one thing the centred line
                 // exists to avoid.
                 gully: 59,
                 // THE BECK IS A POOL WITH A BANK IN FRONT OF IT. Its gravel
                 // starts around 67%, so at 55 the wolf reached it and the adder
                 // stood in the water. Five down and all three are on gravel.
                 beck: 60,
                 // AND THE CRAG IS A WALL WITH A LEDGE AT THE FOOT OF IT — the
                 // one ground in the game whose subject is the thing you CANNOT
                 // stand on. Its floor is the bone-strewn shelf at about 88% and
                 // everything above that is vertical rock, so at 55 all three
                 // were pinned to the face like flies. 72 is the documented cap
                 // on this table (above it the biggest sprite's feet leave the
                 // picture) and it is what this plate needs: the wolf lands on
                 // the shelf, the two small ones just above it.
                 //
                 // IT IS AT THE CAP, WHICH IS THE THING TO KNOW. If this still
                 // reads wrong the answer is not a bigger number, it is a plate
                 // whose shelf sits higher in frame — there is no line left to
                 // give.
                 crag: 72 };
var GATE_PLATE = {
  "the-relay-house":  "day night night-torch fog rain snow",
  "the-shieling":     "day night night-torch fog rain snow",
  "the-stell":        "day night night-torch fog rain snow",
  "the-slabs":        "day night night-torch fog rain snow",
  "the-shelter-crag": "day night night-torch fog rain snow",
  // "gate", "sally-port", "weeper-arch",
  // "the-ferry-house", "the-crossing-house",
  // "the-first-milestone", "the-relay-house",
  // "the-gate-arch", "the-timber-stack", "the-withy-hut",
  // "the-shieling", "the-stell", "the-shelter-crag", "the-slabs",
};
var BANDS_WITH_PLATES = { mountain: 1 };
var TERRAIN_PLATE = {
  scree: ["scree"], gully: ["gully"], snow: ["snow"], cairn: ["cairn"],
  alder: ["alder"], "corrie-rim": ["corrie-rim"], "corrie-floor": ["corrie-floor"],
  gatehouse: ["gatehouse"],
  // THIS TABLE IS WHAT DECIDES A TERRAIN EXISTS AT ALL, and four grounds were
  // painted, cut, installed and declared in TERRAIN_SCENES without ever being
  // named here — so every boulder field, tilted slab, glazed rock and stone pen
  // in the world went on falling through TERRAIN_NEAR onto scree and cairn. All
  // that art was sitting on disk unreachable. Add a ground to BOTH tables or it
  // may as well not have been drawn.
  boulder: ["boulder"], slab: ["slab"], glass: ["glass"], fold: ["fold"],
  // THE ORDINARY HILL (rome, 2026-09-10). Not a ground anything matches — it is
  // what you get when nothing does, and it is deliberately nothing
  // in particular: a broad open shoulder of frost-shattered rock and wind-burnt
  // turf, no path, no cairn, no built thing. Thirty-nine mountain rooms whose
  // names and descriptions never say what they are standing on have been wearing
  // the scree plate, which is a specific ground and was answering a question
  // they never asked.
  //
  // IT GETS NO TERRAIN_RULES ENTRY, and that is the point of it. Give a
  // catch-all a regex and it starts taking rooms off the named grounds, which is
  // the opposite of what a default is for. It is reached only through
  // BAND_FALLBACK below, which fires when a room has no terrain at all.
  mountainside: ["mountainside"],
  // THE LAST THREE GROUNDS ON THE HILL (rome, 2026-09-10). Every one of them was
  // a real terrain the rules already named and the art never answered, so each
  // has been wearing a stand-in out of TERRAIN_NEAR below: 52 rooms of face and
  // buttress drawn as a drain, 18 rooms of running water drawn as the same
  // drain, and the whole geothermal ground — the one genuinely strange terrain
  // the mountain has — drawn as a scree slope.
  crag: ["crag"], beck: ["beck"], vent: ["vent"],
};
var TERRAIN_NEAR = {
  boulder: "scree",
  // Warm flags, glazed rock and steaming ground are all BARE STONE, and the
  // cairn plate is a grassy shoulder — it was the worst possible stand-in for
  // the hottest, barest ground on the hill. Scree at least agrees about what
  // the ground is made of.
  slab: "scree", glass: "scree", fold: "cairn",
};
var BAND_FALLBACK = { mountain: "mountainside" };
var TERRAIN_SCENES = {
  // Eleven mountain grounds, 256 rooms between them — and since 2026-09-08 all
  // eleven carry a torch-lit night. The gully was the last one waiting; there is
  // no ground left on the mountain that goes dark under a flame.
  snow:  "day night night-torch fog rain snow",
  cairn: "day night night-torch fog rain snow",
  gully: "day night night-torch fog rain snow",
  scree: "day night night-torch fog rain snow",
  boulder: "day night night-torch fog rain snow",
  slab:  "day night night-torch fog rain snow",
  glass: "day night night-torch fog rain snow",
  fold:  "day night night-torch fog rain snow",
  alder: "day night night-torch fog rain snow",
  "corrie-rim":   "day night night-torch fog rain snow",
  "corrie-floor": "day night night-torch fog rain snow",
  mountainside:   "day night night-torch fog rain snow",
  crag:           "day night night-torch fog rain snow",
  beck:           "day night night-torch fog rain snow",
  vent:           "day night night-torch fog rain snow",
};
var ART_V = "21";
var SKY_KNOWN = { day:1, dawn:1, dusk:1, night:1, moon:1, blood:1, eclipse:1, fog:1, rain:1, snow:1, "in":1, "after-rain":1 };
var SKY_POOL = {
  day:   ["day", "day/x", "day/y", "day/xy"],
  night: ["night", "night/x", "night/y", "night/xy"],
  // THE DUSK SKY WAS REPLACED, NOT ADDED TO (rome, 2026-09-08). The one it
  // replaced ran 24 31 40 49 86 from the top of the frame down; this one runs
  // 21 26 28 28 25 — flat, and half as bright overall. That is a departure from
  // the recipe's rule about the lower edge (thickening pale air toward a horizon
  // below the frame, never a flat wall) and it was judged better on the picture
  // rather than on the rule, which is the right way round.
  //
  // Its flatness has one real consequence: it is the only sky in the set whose
  // /y and /xy turns cost NOTHING, because there is no gradient to put upside
  // down. Dawn still has one, so dawn/y and dawn/xy still invert it.
  //
  // AND THE TWO NO LONGER BORROW EACH OTHER (rome, 2026-09-08). They did, on the
  // argument that a sky with no sun in it asserts no side of the day — which is
  // still true, and stopped being the point once the new dusk landed. Dawn
  // measures 85 and dusk 25, so half of dawn's pool would have been a sky less
  // than a third as bright as the other half: not variety, a flicker between two
  // different times of day. Each hour keeps its own picture now, turned four
  // ways.
  dawn:  ["dawn", "dawn/x", "dawn/y", "dawn/xy"],
  dusk:  ["dusk", "dusk/x", "dusk/y", "dusk/xy"],
  // One file, four arrangements. It never borrows and is never borrowed — an
  // aftermath sky over a midday is the one swap that would still be a lie.
  "after-rain": ["after-rain", "after-rain/x", "after-rain/y", "after-rain/xy"],
  // THE THREE CALENDAR SKIES GET THE MIRROR AND NOTHING ELSE (rome, 2026-09-09).
  // They were left out of turning entirely, and only because nobody had asked.
  //
  // MIRRORING IS WORTH MORE HERE THAN ANYWHERE ELSE, because unlike the other
  // five these three have a SUBJECT — a full moon, a blood moon, a corona — and
  // /x carries it right across the sky. Every other pool's turns only move cloud
  // about; this one moves the thing you are looking at, and does it without
  // touching the colour, the light or the claim the sky makes about the world.
  //
  // AND /y AND /xy ARE OUT FOR THE SAME REASON, not for the usual one. The
  // standing objection to a vertical turn is that it inverts the light: measured
  // top-to-bottom, moon runs 47->95 and eclipse 30->116, so both would hang a
  // pale zenith over a dark horizon and read as a lid. True, and the smaller
  // problem. The bigger one is that flipping drops the SUBJECT to the bottom of
  // the frame, where the first ridgeline eats it — a moon half-buried in a hill,
  // and on the eclipse the sunset band laid across the top of the sky with the
  // corona down in the rocks. Blood is flat enough (8->24) that the light
  // argument does not touch it, and it is still out, because its moon goes down
  // there with the others.
  moon:    ["moon", "moon/x"],
  blood:   ["blood", "blood/x"],
  eclipse: ["eclipse", "eclipse/x"],
};
var SKY_TURN = { x: "scaleX(-1)", y: "scaleY(-1)", xy: "scale(-1, -1)" };
function skyPick(hour) {
  var pool = SKY_POOL[hour];
  var e = (pool && pool.length) ? pool[mix32(plateHash(hour + ":" + skyRoll)) % pool.length] : hour;
  var cut = e.indexOf("/");
  return cut < 0 ? { file: e, turn: "" }
                 : { file: e.slice(0, cut), turn: SKY_TURN[e.slice(cut + 1)] || "" };
}
function mix32(h) {
  h ^= h >>> 16; h = Math.imul(h, 2246822507);
  h ^= h >>> 13; h = Math.imul(h, 3266489909);
  return (h ^ (h >>> 16)) >>> 0;
}
function plateHash(str) {
  var h = 2166136261;
  for (var i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
  return h >>> 0;
}
var sceneEl=document.getElementById("scene"), skyEl=document.getElementById("sky"),
    mobsEl=document.getElementById("mobs"), viewMode="image",
    lastBand="", lastSky="", lastTerrain="", lastRoomKey="", lastPlace="", lastTorch=false, skyRoll=0,
    scenePainted="", sceneSeq=0;
function paintScene(band, sky, terrain, roomKey, torch, roll, place) {
  if (roomKey) lastRoomKey = roomKey;
  // Cleared as well as set: walking out of a singular room and into ordinary
  // ground must stop naming the room, or the summit follows you down the hill.
  if (place !== undefined) lastPlace = place || "";
  if (band) lastBand = band;
  if (sky) lastSky = sky;
  // A bare truth test would not do: the whole point is that going dark is a
  // change too, and a falsy-but-present 0 has to be able to put the light out.
  if (torch !== undefined && torch !== null) lastTorch = !!torch;
  if (roll !== undefined && roll !== null) skyRoll = roll;
  if (terrain !== undefined && terrain !== null) lastTerrain = terrain;
  if (!sceneEl || viewMode !== "image") return;
  // Terrain first, band second: the room's own ground beats its region's.
  // ROOM ART ONLY. The threshold's paintings were never room art: they are oil,
  // painterly, and each one is a specific somewhere — so a beam walk over open
  // water was being handed a dusk village in a different MEDIUM entirely. A
  // region with no plates of its own paints bare ground and waits its turn.
  // A PLATE BELONGS TO THE COUNTRY IT WAS PAINTED FOR. The terrain rules were
  // written against the mountain and then let loose on all 1,186 rooms, so a
  // briar court inside the fortress ring matched a rock word and was handed the
  // mountain's drain. Scree, gully, snow, cairn, corrie: every one of those is
  // a MOUNTAIN picture, and nowhere else may borrow them however well the words
  // happen to line up. Other bands paint bare ground until they own plates.
  // The gatehouse belongs to no band — it is the same small warm room at every
  // door — so it paints wherever it is found rather than waiting for the
  // country outside to have pictures of its own.
  // A gate is asked for by name before anything else, and if it has no plate
  // yet the terrain it is standing on answers instead — hence the fallthrough
  // rather than an early return.
  // A SINGULAR ROOM IS ASKED FOR FIRST, before the door it might be and before
  // the ground it stands on — it is the most specific thing anything can know
  // about where you are. It falls through to both when it has no plate yet.
  var place = (lastPlace && ROOM_PLATE[lastPlace] !== undefined) ? lastPlace : "";
  var gate = lastTerrain.slice(0, 5) === "gate:" ? lastTerrain.slice(5) : "";
  // "lit" below is whether the TORCH PLATE WAS ACTUALLY USED, not the same
  // question as whether a torch is burning: a ground with no torch plate cut
  // yet is standing in the plain dark however bright your hand is. The
  // creatures read this rather than lastTorch, so nothing ever blazes on a
  // hillside the picture left unlit.
  var scene = "", sky = "", tint = "", lit = false, turn = "", line = MOB_LINE_DEFAULT;
  // WHAT HOUR THE CREATURES ARE STANDING IN, which is the sky outside everywhere
  // except under a roof, where it is whatever the plate was lit for.
  var mobHour = lastSky;
  if (place) {
    // Identical to the gate branch below, and deliberately so: a plate is a
    // plate, and the only thing that differs is which table named it.
    var phave = " " + ROOM_PLATE[place] + " ";
    var shut = !!SHELTERED[place];
    var pbase = SKY_BASE[lastSky] || "day";
    // What the slot shows, which is only ever different from the hour under a
    // roof in bad weather — there is no rain sky in the game, and a dark room
    // under a bright noon sky would be the wrong half of the picture.
    var slot = lastSky;
    if (shut && WEATHER_FROM_INSIDE[lastSky]) { pbase = "night"; slot = "after-rain"; }
    // AND WHEN THE PLATE HAS NOT GOT THAT CONDITION, THE FIRST ONE IT LISTS.
    // This was hardcoded to "day" on the assumption every plate owns one, which
    // held until the Last Shelter gave its up: a room with no day plate resolved
    // day -> day and asked for a file that is not there, which is a 404 and an
    // empty frame at the one hour it is most likely to be walked into. Falling
    // back to the plate's own first condition cannot miss, because a plate is
    // only ever listed once its files exist. Every other plate in the game leads
    // with "day", so this changes nothing anywhere else.
    if (phave.indexOf(" " + pbase + " ") < 0) pbase = ROOM_PLATE[place].split(" ")[0];
    // A TORCH SHOWS WHEREVER THE ROOM IS DARK, which under a roof is not the same
    // question as which hour it is. TORCH_HOURS answers it outdoors — rain and
    // snow are daylit there, whole photographs with their own light in them. But
    // a sheltered room in rain has just resolved to its NIGHT plate, and a room
    // dark enough to be drawn at night is a room a flame belongs in. Outdoors
    // this clause changes nothing: every hour whose ground is the night plate is
    // already in TORCH_HOURS.
    var darkEnough = TORCH_HOURS[lastSky] || pbase === "night";
    var pwant = (lastTorch && darkEnough && phave.indexOf(" night-torch ") >= 0) ? "night-torch" : pbase;
    // The picture, which may belong to another room (PLATE_OF).
    var stem = PLATE_OF[place] || place;
    scene = "/room-bg/" + stem + "-" + pwant + ".webp";
    if (KEYED[pwant]) {
      var pp = skyPick(SKY_PAINTED[slot] ? slot : pbase);
      sky = "/sky/" + pp.file + ".webp"; turn = pp.turn;
    }
    lit = pwant === "night-torch";
    tint = (lit || shut || NO_GROUND_TINT[lastSky] || pbase === lastSky) ? "" : lastSky;
    if (shut) mobHour = lit ? "" : pbase;   // a roof is over them too
    line = MOB_LINE[stem] || MOB_LINE_DEFAULT;   // the standing line belongs to the PICTURE
  }
  if (!scene && gate && GATE_PLATE[gate] !== undefined) {
    // GROUND WEATHER IS A DIFFERENT PHOTOGRAPH. Night is not the day gone dim,
    // fog is not a grey wash, rain wets the stone and snow lies on it — none of
    // those is anything a sky behind a dry daylit scene can do. Five scenes.
    var have = " " + GATE_PLATE[gate] + " ";
    // The scene this sky wants, or the day scene if it has not been shot yet —
    // so a gate can land with one photograph and gain the other four later
    // without anything breaking in between.
    var base = SKY_BASE[lastSky] || "day";
    if (have.indexOf(" " + base + " ") < 0) base = "day";
    // AND A FLAME OF YOUR OWN IS A SIXTH. Only over the night ground, because
    // that is the only condition it was shot under — and the check is against
    // this door's own list, so one gate can have the torch plate and its
    // neighbour go without.
    var want = (lastTorch && TORCH_HOURS[lastSky] && have.indexOf(" night-torch ") >= 0) ? "night-torch" : base;
    scene = "/room-bg/gate-" + gate + "-" + want + ".webp";
    // AND A KEYED SCENE MUST ALWAYS GET A SKY. This said so and then did not do
    // it: it asked for the sky of the CONDITION, so a gate with no rain scene
    // fell back to its day ground — which is cut, and transparent where the sky
    // was — and then drew nothing behind it, because no rain sky exists. A hole
    // in the world, every time it rained anywhere a scene was missing. The sky
    // to draw is the condition's when there is one, and otherwise the sky that
    // belongs to the GROUND actually being used.
    // THE SKY IS THE BASE'S, NEVER THE TORCH VARIANT'S. There is no
    // night-torch.webp in the sky folder and there never will be: a torch is on
    // the ground and the sky over it is the ordinary night.
    if (KEYED[want]) {
      var gp = skyPick(SKY_PAINTED[lastSky] ? lastSky : base);
      sky = "/sky/" + gp.file + ".webp"; turn = gp.turn;
    }
    // BORROWED GROUND NEEDS BRINGING INTO LINE. The tint used to switch off the
    // moment a real sky was drawn, on the reasoning that the picture already WAS
    // the weather — true when the scene was painted for this hour, and false
    // whenever it was borrowed. Day stone under a dusk sky is still lit for
    // noon, and the eye catches that before it catches anything else.
    lit = want === "night-torch";
    line = MOB_LINE["gate-" + gate] || MOB_LINE_DEFAULT;
    // A TORCH PLATE TAKES NO HOUR CORRECTION, EVER. The correction exists to
    // relight a ground that was shot under the wrong sky — and a torch plate's
    // light does not come from the sky. It comes from the flame, and it is the
    // same flame at every hour it is carried, so there is nothing for the hour
    // to put right. Dusk is the case that makes it obvious: t-dusk was written
    // to take a NOON ground down to evening, and laid over an already-dark
    // picture it dims the one thing the picture is of.
    tint = (lit || NO_GROUND_TINT[lastSky] || base === lastSky) ? "" : lastSky;
  }
  if (!scene) {
    var kind = lastTerrain === "gatehouse" ? "gatehouse"
      : gate ? ""
      : BANDS_WITH_PLATES[lastBand]
        ? (TERRAIN_PLATE[lastTerrain] ? lastTerrain
           : (TERRAIN_NEAR[lastTerrain] || BAND_FALLBACK[lastBand] || ""))
        : "";
    if (!kind && gate && BANDS_WITH_PLATES[lastBand]) kind = BAND_FALLBACK[lastBand] || "";
    var list = kind ? TERRAIN_PLATE[kind] : null;
    var terr = (list && list.length) ? list[plateHash(lastRoomKey || kind) % list.length] : "";
    if (terr && TERRAIN_SCENES[terr] !== undefined) {
      // Painted for layering: same law as a gate, and it falls through to the
      // shared code below rather than repeating it.
      var thave = " " + TERRAIN_SCENES[terr] + " ";
      var tbase = SKY_BASE[lastSky] || "day";
      if (thave.indexOf(" " + tbase + " ") < 0) tbase = "day";
      var twant = (lastTorch && TORCH_HOURS[lastSky] && thave.indexOf(" night-torch ") >= 0) ? "night-torch" : tbase;
      scene = "/room-bg/" + terr + "-" + twant + ".webp";
      if (KEYED[twant]) {
        var tp = skyPick(SKY_PAINTED[lastSky] ? lastSky : tbase);
        sky = "/sky/" + tp.file + ".webp"; turn = tp.turn;
      }
      lit = twant === "night-torch";
      line = MOB_LINE[terr] || MOB_LINE_DEFAULT;
      tint = (lit || NO_GROUND_TINT[lastSky] || tbase === lastSky) ? "" : lastSky;
    } else {
      // The old single-layer plates: sky baked in, wash on top, unchanged.
      // It still claims the sequence and records what it left on screen, so the
      // held-frame swap below cannot be fooled by a plate this branch painted:
      // without these two lines, walking gatehouse -> hillside and back would
      // find scenePainted still naming the hillside and put it up unloaded.
      sceneSeq++;
      scenePainted = terr ? "/room-bg/" + terr + ".webp" : "";
      if (skyEl) { skyEl.style.backgroundImage = ""; skyEl.style.transform = ""; }
      sceneEl.style.backgroundImage = terr ? "url(/room-bg/" + terr + ".webp?v=" + ART_V + ")" : "";
      sceneEl.style.backgroundSize = "cover";
      // NO WEATHER INDOORS. The gatehouse is one baked plate lit by its own
      // fire, and washing it with the hour put rain on a room with a roof and
      // dusk on a room with no window. Whatever is happening outside stops at
      // the door (rome, 2026-09-08).
      sceneEl.className = (kind === "gatehouse") ? "" : (SKY_KNOWN[lastSky] ? "sky-" + lastSky : "");
      if (mobsEl) { mobsEl.className = ""; mobsEl.style.top = MOB_LINE_DEFAULT + "%"; }
      sceneEl.style.backgroundPosition = "center 55%";
      return;
    }
  }
  // THE SKY MUST NOT ARRIVE BEFORE THE GROUND (rome, 2026-09-06). Both layers
  // are assigned in the same breath, so this is not an ordering mistake — it is
  // the CACHE. There are seven sky files for the entire world, so after the
  // first minute of play every one of them is local and paints in the same
  // frame it is asked for. There are eighty scenes averaging 2.7MB, one per
  // ground per condition, so nearly every room you walk into is a fresh
  // download. Set both at once and the sky lands instantly and the ground lands
  // when it lands, and in between the player is looking at an empty sky over
  // nothing, which reads as the world failing to load rather than as loading.
  //
  // So the swap WAITS for the ground. The room you are leaving stays on screen,
  // whole, until the next one can be shown whole — a held frame reads as a
  // pause, a skeleton reads as a fault, and the pause is shorter than it looks
  // because the picture is decoded before it goes up rather than during. A
  // sequence number makes walking fast safe: a slow plate that arrives after
  // you have already left cannot paint over the room you are now in.
  var url = "url(" + scene + "?v=" + ART_V + ")";
  var mine = ++sceneSeq;
  var put = function () {
    if (mine !== sceneSeq) return;         // a newer room got here first
    sceneEl.style.backgroundImage = url;
    sceneEl.style.backgroundSize = "cover";
    // The mask on the blood tint needs the same picture; a custom property is
    // the only way to hand a stylesheet a URL that is decided at runtime.
    sceneEl.style.setProperty("--sceneimg", url);
    if (skyEl) {
      skyEl.style.backgroundImage = sky ? "url(" + sky + "?v=" + ART_V + ")" : "";
      skyEl.style.backgroundPosition = "center 55%";
      // Set every time, cleared when there is no turn: a transform left behind
      // from the last room would mirror a sky that was never asked to be.
      skyEl.style.transform = turn;
    }
    // No overlay on a layered room — the sky is real. The only thing that
    // changes is how the ground is lit, and that is a filter that respects the cut.
    sceneEl.className = tint ? "t-" + tint : "";
    // WHAT IS STANDING THERE IS STANDING IN THE SAME LIGHT — but it does not
    // take the same class, and sharing one was the bug. The scene's tint is a
    // CORRECTION: it is only set when the ground is borrowed, because a plate
    // photographed for this hour needs nothing done to it. A sprite is never in
    // that position. There is exactly one picture of each creature, lit for day,
    // and it is borrowed EVERY hour that is not day — so on the night plate,
    // where the scene rightly asked for no correction, an owl stood in full
    // daylight on a moonless glacier.
    //
    // So the creatures read the HOUR, not the scene's correction for it. Day
    // and "in" have no rule and pass through untouched, which is what they want.
    // AND WHAT IS STANDING IN IT IS IN THE TORCHLIGHT TOO. This is the one
    // place the creatures do not simply read the hour: the hour is still night,
    // but the light on the thing three yards in front of you is coming from
    // your hand, and t-night at brightness .26 would leave a wolf as a silhouette
    // on ground the plate has lit to orange. It follows the PLATE, so an unpainted
    // ground keeps its dark and nothing is lit by a torch the picture cannot see.
    if (mobsEl) {
      mobsEl.className = lit ? "t-night-torch" : mobHour ? "t-" + mobHour : "";
      // ...and standing where this plate's ground actually is. Set every time,
      // never only when it differs: a line left over from the room behind you
      // would put the next room's animals wherever the last one's stood.
      mobsEl.style.top = line + "%";
    }
    sceneEl.style.backgroundPosition = "center 55%";
    scenePainted = scene;
  };
  // Already up: this is a light change on the same ground (the hour turning, a
  // sky the scene is borrowed under). Nothing to fetch, so do not hold a frame.
  if (scene === scenePainted) { put(); return; }
  var pre = new Image();
  // A plate that 404s or a connection that dies must not freeze the last room
  // on screen forever — put it up regardless and let the layer be empty. Same
  // handler both ways on purpose: the failure case and the success case want
  // exactly the same thing to happen next.
  pre.onload = put;
  pre.onerror = put;
  pre.src = scene + "?v=" + ART_V;
  // A picture already in the browser's cache is complete the moment its src is
  // set and may never fire a load event, which would hold the previous room up
  // for good. Walking back the way you came is the common case, so this is the
  // common path, not the edge.
  if (pre.complete) put();
}
/* ---- end lifted ---- */
var SPRITE=MOB_SPRITE, ANIM=MOB_ANIM;   // the page's own shorthand
var MAN_VH = 42;
// 0.85 -> 0.65 -> 0.45 (rome, 2026-09-09: twice, the small ones are too small). P is the amount
// of squeeze and it works from the anchor outwards, so lowering it moves the two
// ends of the roster and leaves the middle where it is. Across the two steps an
// adder went 7.7 -> 17.1vh and a ptarmigan 9.9 -> 19.5, while a hill-wolf shifted
// 26.9 -> 33.1 and a red hind barely moved at all. A standing man is the anchor
// and does not move by construction.
//
// IT ALSO PULLS THE DRAKE IN, 75.7 -> 57.4vh, which is the other half of what was
// asked for.
//
// HOW MUCH FURTHER THIS CAN GO. 0.35 would put an adder at 20.9vh, and that is
// where the curve starts costing something real: a hare would be 26.7 against a
// wolf at 34.9 and a hind at 39.2, and the ladder between a small animal, a
// middling one and a big one stops being readable. 0.45 still has that ladder. Worth knowing that it is the SAME number doing both: the curve has
// one dial and you cannot raise the small end without lowering the big one.
// Everything here is further from life-size than it was, and deliberately so —
// see the note above. A stoat you cannot see is not more realistic, it is absent.
var MOB_P = 0.45;
var MOB_K = 42 / Math.pow(22, MOB_P);
function mobVh(id) { return MOB_K * Math.pow(MOB_SPRITE[id], MOB_P); }

/* ---- lifted verbatim from public.ts ---- */
var CALM_POSES = ["rest","bask","graze","feed","listen","watch","hold-ground",
  "stand-ground","hold-warm-ground","keep-the-line","alert","inspect-upright",
  // THE VARIANTS' OWN POSES, which were drawn and shipped and never named here
  // (2026-09-09). Every rare form on the hill is six frames — idle, its own
  // pose, two of gait, a blow and a death — and the second slot is the one this
  // list reads. The common forms all have theirs in it: a fold dog rests, an
  // ermine goes up on its hind legs, a herdsman keeps the line, the butter wife
  // listens. The variants were drawn with something better in that same slot and
  // nobody added the new words, so the pose existed in the strip and the driver
  // could not ask for it. Three of them are the same kind of thing their base
  // form does, so they belong here and nowhere else:
  "work-pull",      // the milker, pulling at the pail. She is rooted and this is
                    // her ordinary work, not an event
  "twisting-leap",  // the dancer. A stoat dancing IS a stoat at rest — it is
                    // what the animal is named for and when it does it
  "call-uphill"];   // the last dog, still calling for people who are not coming
var GAIT_HZ = 5;            // gait poses alternate this fast...
var GAIT_HZ_SLOW = 2;       // ...except the old glutton, which lumbers
var WINGBEAT_HZ = 4;        // and wings beat this fast...
var WINGBEAT_HZ_FAST = 8;   // ...except the ptarmigan, which whirrs
var SWAY = 0.11;            // body sway while travelling, as a fraction of width
var HOP = 0.16;             // how far a hare or the dancer leaves the ground, of its height
var BOB = 0.024;            // and how much everything else nods as it walks
var LIFT = 0.9;             // a flying bird rises about its own height
var AIR_SHRINK = 0.25;      // and shrinks, because it is further away
var BREATH = 0.003;         // the resting scale pulse: three parts in a thousand
var TRAVEL_MS = 2600;       // how long a creature walks once it starts
var ROOTED = { "the-milker": 1, "the-butter-wife": 1 };   // they work in place

var IDLE_MS = 1400;      // how long one idle frame is held...
var IDLE_JITTER = 1100;  // ...plus this much, so two snakes never breathe in step
var ACT_MS = 190;        // and how fast the thing it does actually happens
var ACT_ODDS = 0.06;     // per idle beat: roughly once every half-minute
var mobsEl = document.getElementById("mobs");
var lastMobs = "";
var ATTACK_S = 1.7, HIT_S = 0.9, DEATH_S = 1.1, FEED_S = 2.4;
var ROUND_S = 4;          // COMBAT_ROUND_MS, and the beat everything here answers to
var STAGGER_S = 0.75;     // how far apart blows in the same round are spread
// WHAT A SLEEPING ONE LOOKS LIKE, best pose first. A wolf curls up, an adder
// keeps its warm stone; anything with none of these just stands and breathes
// slower, which is still the difference between a thing lying up and a thing
// watching you.
var SLEEP_POSES = ["rest", "bask", "hold-warm-ground", "hold-ground", "feed"];
var STRIKE_POSES = ["attack", "bite", "sweep", "breath"];
var HIT_POSES = ["hit"];
// How it looks at you when it has decided something about you.
// AND ONE OF THE VARIANTS' POSES IS NOT AN IDLE CUT. The one who stayed was
// drawn walking at you, and a man closing the distance is a claim about you,
// not something a creature does to fill a breath. So it goes here rather than
// in CALM_POSES with the other three: it is what he does once he has decided
// something, which is this list's whole subject. He has no other pose in here,
// so before now his every hunt, fight and windup was drawn on the bare idle.
var WATCH_POSES = ["alert", "watch", "alert-alarm", "listen", "stand-ground",
                   "hold-ground", "inspect-upright", "advance", "recover", "idle"];
// A body stays where it fell for a beat before the room repaints without it.
var mobHold = 0, mobPending = null, mobPendingRest = null, mobPendingDead = null;
function poseAt(a, now) {
  var f = a.spec.f, t = a.t, x = 0, air = 0, s = 1, name = "idle";
  if (a.phase === "death") {
    // It drops, and then it is a thing on the ground. The only motion is the
    // settle in the first quarter second; after that nothing about it moves.
    name = "death";
    s *= 1 + (1 - Math.min(t / 0.25, 1)) * 0.05;
  } else if (a.phase === "attack") {
    if (t < 0) {
      name = a.watch;                                   // its turn has not come yet
    } else {
      var blow = a.blow || a.strike;
      var u = Math.min(t / ATTACK_S, 1);
      // A BREATH IS NOT A BITE AND MUST NOT BE TIMED LIKE ONE. The windup is the
      // whole of what makes it frightening — the chest drawing up and swelling
      // before anything comes out — and the drakes were drawn a frame for
      // exactly that. So the gather runs twice as long and is spent on "inhale"
      // rather than on the watch pose, the release runs longer than a lunge, and
      // the body swells into it and empties instead of driving forward. Nothing
      // else in the game owns either frame, so nothing else takes this path.
      if (blow === "breath" && f.inhale !== undefined) {
        if (u < 0.38) {                                 // draws it in, and grows
          name = "inhale";
          x = -Math.sin(u / 0.38 * Math.PI / 2) * SWAY * 0.18;
          s *= 1 + (u / 0.38) * 0.045;
        } else if (u < 0.74) {                          // and lets it go
          name = "breath";
          x = Math.sin((u - 0.38) / 0.36 * Math.PI) * SWAY * 0.55;
          s *= 1.045 - ((u - 0.38) / 0.36) * 0.06;
        } else {                                        // and is empty after it
          name = a.watch;
          x = -(1 - (u - 0.74) / 0.26) * SWAY * 0.14;
          s *= 0.985 + ((u - 0.74) / 0.26) * 0.015;
        }
      } else if (u < 0.20) {                            // gathers, and draws back
        name = a.watch;
        x = -Math.sin(u / 0.20 * Math.PI / 2) * SWAY * 0.30;
      } else if (u < 0.58) {                            // and goes
        name = blow;
        x = Math.sin((u - 0.20) / 0.38 * Math.PI) * SWAY * 1.0;
      } else {                                          // and comes off it
        // THROUGH THE FRAME DRAWN FOR COMING OFF IT, where one exists
        // (2026-09-09). Four creatures carry a "recover" and it is the only
        // pose in the set whose name says which instant it is for: the adder
        // gathering itself back into the coil after the strike. The brooding
        // vulture already showed it, because with no alert and no watch drawn
        // it wins WATCH_POSES by default — and that accident was the only
        // reason the frame was ever on screen. The three adders each have an
        // alert or a watch that beats it in that list, so all three struck and
        // then snapped straight back to a head-up pose they were not drawn
        // returning through. The recovery is a real third of the swing here;
        // it is worth the frame it was given.
        name = (f.recover !== undefined) ? "recover" : a.watch;
        x = -(1 - (u - 0.58) / 0.42) * SWAY * 0.22;
      }
    }
  } else if (a.phase === "hit") {
    name = a.recoil;
    // driven back hard, then a shudder on the way out of it
    var hu = Math.min(t / HIT_S, 1);
    x = -0.085 * Math.exp(-t * 6.5) + Math.sin(t * 22) * 0.03 * Math.exp(-t * 4);
    a.rot = -0.055 * Math.exp(-t * 7) + 0.022 * Math.sin(t * 13) * Math.exp(-t * 3.5);
    s *= 1 - 0.05 * Math.exp(-t * 9);      // it gives, and comes back up
  } else if (a.phase === "feed") {
    // Head down at the body, with the small working shift of something pulling
    // at meat rather than standing over it.
    name = "feed";
    x = Math.sin(t * 3.4) * SWAY * 0.10;
  } else if (a.state === "hunt") {
    // IT HAS YOU. It does not wander, it does not cut to its calm pose - it
    // holds the alert it was drawn with and closes, slowly, on a breath that is
    // too long to be comfortable. The nearest thing this driver has to menace.
    name = a.watch;
    x = Math.sin(t * 0.55) * SWAY * 0.42;
    s *= 1 + Math.sin(t * 0.8) * BREATH * 2.2;
  } else if (a.state === "fight") {
    // Busy with somebody else: the same tension, quicker, and not aimed at you.
    name = a.watch;
    x = Math.sin(t * 1.9) * SWAY * 0.22;
  } else if (a.state === "flee") {
    // Going, and going away: the gait at speed, shrinking as it leaves.
    // AND ONE OF THEM LEAVES WITH SOMETHING IN ITS MOUTH (2026-09-09). The
    // raiding fox was drawn going off with what it came for, which is the whole
    // of the difference between it and the hill fox it is a variant of — and no
    // state the wire can report ever reached the frame. It holds for the first
    // stride only and then the gait takes over, because a held picture stops
    // reading as flight about half a second in. Nothing else owns the frame, so
    // nothing else takes this path.
    name = (f["snatch-escape"] !== undefined && t < 0.55) ? "snatch-escape"
         : f["move-a"] !== undefined ? (Math.floor(t * GAIT_HZ * 1.6) % 2 ? "move-a" : "move-b")
         : f.up !== undefined ? (Math.floor(t * WINGBEAT_HZ_FAST) % 2 ? "up" : "down") : "idle";
    x = -SWAY * Math.min(1, t / 1.2) * 1.5;
    if (f.up !== undefined) air = LIFT * Math.min(1, t / 1.2);
    s *= 1 - 0.08 * Math.min(1, t / 1.5);
  } else if (a.state === "reel") {
    // Rung, and not over it: the hit shudder, held rather than decaying out.
    name = a.recoil;
    x = Math.sin(t * 9) * 0.02;
    a.rot = 0.02 * Math.sin(t * 5);
  } else if (a.state === "hurt") {
    // Still up, but it has been opened. Low, slow, and it sags.
    name = a.calm && Math.floor(t / 4) % 3 === 0 ? a.calm : "idle";
    air = -0.012;
    s *= 1 + Math.sin(t * 1.1) * BREATH * 2.6;
  } else if (a.state === "eyeing" || a.state === "watch") {
    // Head up, fixed on something: the floor gear it means to take, or the room
    // the noise came from. It has stopped doing anything else.
    name = a.watch;
    s *= 1 + Math.sin(t * 1.6) * BREATH;
  } else if (a.asleep) {
    // Lying up. It does not travel, it does not cut to its calm pose on a
    // clock - it holds the one pose and breathes deeper and slower than a
    // standing creature does.
    name = a.sleep;
    s *= 1 + Math.sin(t * 0.9) * BREATH * 1.8;
  } else if (a.phase === "travel") {
    if (f["move-a"] !== undefined) {
      var hz = a.id === "the-old-glutton" ? GAIT_HZ_SLOW : GAIT_HZ;
      name = Math.floor(t * hz) % 2 ? "move-a" : "move-b";
      if (!ROOTED[a.id]) {
        x = Math.sin(t * 1.7) * SWAY;
        if (a.id.indexOf("hare") >= 0 || a.id === "the-dancer") air = Math.abs(Math.sin(t * 5 * Math.PI)) * HOP;
        else if (a.id === "a-fold-dog") { x *= 1.8; s *= 1 + 0.05 * Math.cos(t * 1.7); }
        else air = Math.abs(Math.sin(t * 5 * Math.PI)) * BOB;
      }
    } else if (f.up !== undefined) {
      var wh = a.id === "ptarmigan" ? WINGBEAT_HZ_FAST : WINGBEAT_HZ;
      var uf = Math.min(t / (TRAVEL_MS / 1000), 1);
      var beat = Math.floor(t * wh) % 2 ? "up" : "down";
      if (f.glide === undefined || f.landing === undefined) {
        name = beat; air = LIFT + Math.sin(t * 3) * 0.04;      // no arc drawn: just fly
      } else if (uf < 0.32) {
        // LEAVING THE GROUND IS ITS OWN MOVEMENT for anything drawn doing it:
        // the crouch and the shove, before the wings have air to bite on. Only
        // the drakes have the frame; every bird climbs out on the beat as before.
        name = (f.takeoff !== undefined && uf < 0.13) ? "takeoff" : beat;
        air = LIFT * (uf / 0.32);
      } else if (uf < (f.dive !== undefined ? 0.60 : 0.72)) {
        name = "glide"; air = LIFT; x = Math.sin(t * 1.2) * SWAY * 0.6;
      } else if (f.dive !== undefined && uf < 0.84) {
        // AND IT DOES NOT DRIFT DOWN. A thing that hunts from the air comes off
        // the glide in a stoop — wings back, head down, and fast — and puts them
        // out to land only at the end of it. Two frames the drakes carried and
        // never used, and between them they turn a flat circuit into a flight.
        name = "dive";
        air = LIFT * (1 - (uf - 0.60) / 0.24 * 0.72);
        x = Math.sin(t * 1.2) * SWAY * 0.22;
        s *= 1 - (uf - 0.60) / 0.24 * 0.05;
      } else {
        var d0 = f.dive !== undefined ? 0.84 : 0.72;
        name = "landing";
        air = LIFT * (1 - (uf - d0) / (1 - d0)) * (f.dive !== undefined ? 0.28 : 1);
      }
    } else {
      // No gait and no wings. The rooted ones - the brooding vulture on its nest,
      // an adder holding its warm stone - travel by doing the thing they were
      // drawn doing instead of going anywhere, one pose per second.
      name = a.spec.acts[Math.floor(t) % a.spec.acts.length];
    }
  } else {
    name = a.calm && Math.floor(t / 3) % 2 ? a.calm : "idle";
    s *= 1 + Math.sin(t * 2) * BREATH;
  }
  if (a.phase !== "hit") a.rot = 0;
  if (f[name] === undefined) name = "idle";
  s *= 1 - AIR_SHRINK * Math.min(1, air / LIFT);
  return { k: f[name], x: x, air: air, s: s };
}
function mobBeat(swung, struck, died, fed) {
  for (var i = 0; i < anims.length; i++) {
    var a = anims[i];
    // Dying outranks everything: a thing that took the last blow is not also
    // recoiling from it. Only a creature with the pose drawn goes down on
    // screen - the rest simply stop being there, the way they always have.
    if (died && died.indexOf(a.id) >= 0 && a.spec.f.death !== undefined && a.phase !== "death") {
      a.phase = "death"; a.t = 0; a.asleep = false;
      mobHold = Date.now() + DEATH_S * 1000;
    } else if (a.phase === "death") continue;
    else if (swung && swung.indexOf(a.id) >= 0 && a.strike) {
      // NOT ALL AT ONCE. The wire reports a whole round in one message, so
      // without this every creature in a dogpile swings on the same frame.
      a.phase = "attack"; a.t = -Math.random() * STAGGER_S;
      // AND NOT ALWAYS THE SAME BLOW. Rolled per swing rather than held once, so
      // a long fight with a drake is bite, sweep and breath in no order you can
      // count on. Everything else has a list one long and gets the frame it
      // always got.
      a.blow = (a.strikes && a.strikes.length > 1)
        ? a.strikes[Math.floor(Math.random() * a.strikes.length)]
        : a.strike;
    }
    else if (struck && struck.indexOf(a.id) >= 0) { a.phase = "hit"; a.t = 0; }
    else if (fed && fed.indexOf(a.id) >= 0 && a.spec.f.feed !== undefined) { a.phase = "feed"; a.t = 0; }
  }
}
function stepAnims() {
  var now = Date.now();
  for (var i = 0; i < anims.length; i++) {
    var a = anims[i];
    a.t += 0.06;
    // A dead one has no next phase. It holds until the room repaints without it.
    if (a.phase === "death") { /* it stays down */ }
    else if (a.phase === "travel") { if (a.t > TRAVEL_MS / 1000 || a.asleep) { a.phase = "idle"; a.t = 0; } }
    else if (a.phase === "attack") { if (a.t > ATTACK_S) { a.phase = "idle"; a.t = 0; } }
    else if (a.phase === "hit") { if (a.t > HIT_S) { a.phase = "idle"; a.t = 0; } }
    else if (a.phase === "feed") { if (a.t > FEED_S) { a.phase = "idle"; a.t = 0; } }
    // IT ONLY WANDERS WHEN IT HAS NOTHING ELSE ON. Everything above is the world
    // telling the picture what is happening; the stroll is what is left when
    // nothing is. Its cadence comes from the animal - a wolf paces, a vulture
    // sits - so a room is not a metronome with four hands.
    else if (!a.state && now > a.next) { a.phase = "travel"; a.t = 0; a.next = now + a.rate + Math.random() * a.rate * 1.6; }
    var p = poseAt(a, now);
    a.el.style.backgroundPositionX = (p.k * 100 / (a.spec.n - 1)) + "%";
    a.el.style.transform = "translate(" + (p.x * 100).toFixed(1) + "%,"
      + (-(p.air + (a.lift || 0)) * 100).toFixed(1) + "%)"
      + " rotate(" + ((a.rot || 0) * 57.3).toFixed(2) + "deg) scale(" + p.s.toFixed(3) + ")";
  }
  if (mobHold && now >= mobHold) releaseMobs();
}
function applyState(doing) {
  for (var i = 0; i < anims.length; i++) {
    // BY SLOT, NOT BY NAME. Keyed by creature id this read one state for every
    // sprite of a kind, so one wolf waking woke the whole heap.
    var a = anims[i], st = (doing && doing[a.slot]) || "";
    if (a.phase === "death") continue;
    if (st === a.state) continue;
    a.state = st;
    a.asleep = st === "rest";
    a.t = 0;
    // a creature that has just noticed you does not finish its stroll first
    if (a.phase === "travel" && st) { a.phase = "idle"; }
  }
}
/* ---- end lifted ---- */

// the preview has no room to repaint, so the body simply stays down
function releaseMobs(){ mobHold = 0; }

var anims=[], hour=document.getElementById("hour"), sc=document.getElementById("sc"), grid=document.getElementById("grid");
var gnd=document.getElementById("gnd"), who=document.getElementById("who"),
    torchBtn=document.getElementById("torch"), rollBtn=document.getElementById("roll"),
    pick=document.getElementById("pick"), fitBtn=null, whole=false,
    stage=document.getElementById("stage"),
    shelf=document.getElementById("shelf"), torch=false, stageAnim=null;
// THE HOURS ARE THE SKIES THE CLIENT KNOWS, read off its own table rather than
// typed here — the list was hand-written before and was two short, missing the
// hour after the rain entirely for as long as that sky has existed.
var HOURS=Object.keys(SKY_KNOWN).filter(function(h){ return !DROP_HOUR||DROP_HOUR.indexOf(h)<0; });
HOURS.forEach(function(h){
  var o=document.createElement("option");o.textContent=h;o.selected=(h==="dusk");hour.appendChild(o);});
// AND THE SAME LIST AGAIN AS BUTTONS. The select is kept and hidden rather than
// removed, because a dozen places read hour.value and every one of them goes on
// working; this is a second face on the same control, not a replacement for it.
var hourBar=document.getElementById("hours");
HOURS.forEach(function(h){
  var b=document.createElement("button"); b.textContent=h; b.dataset.h=h;
  b.onclick=function(){ hour.value=h; repaint(); };
  hourBar.appendChild(b);});
function markHour(){
  var kids=hourBar.children;
  for(var i=0;i<kids.length;i++) kids[i].className=(kids[i].dataset.h===hour.value)?"on":"";
}
// AND THE GROUNDS ARE THE ONES WITH PLATES, likewise read off the tables: a
// ground that gets painted tomorrow appears here the day it is declared.
Object.keys(TERRAIN_PLATE).sort().forEach(function(t){
  var o=document.createElement("option");o.value=t;o.textContent=t;o.selected=(t==="scree");gnd.appendChild(o);});
Object.keys(GATE_PLATE).sort().forEach(function(g){
  if(ONLY_GATE&&ONLY_GATE.indexOf(g)<0) return;
  var o=document.createElement("option");o.value="gate:"+g;o.textContent="gate · "+g;gnd.appendChild(o);});
// THE THIRD TABLE. Rooms that are one of one are picked the same way as the
// other two — this list was built from terrains and gates alone, so a room plate
// could be installed and declared and still be unreachable here.
Object.keys(ROOM_PLATE).sort().forEach(function(r){
  var o=document.createElement("option");o.value="room:"+r;o.textContent="room · "+r;gnd.appendChild(o);});
Object.keys(MOB_ANIM).sort().forEach(function(id){
  var o=document.createElement("option");o.textContent=id;o.selected=(id==="hill-wolf");who.appendChild(o);});
[0.6,0.8,1,1.4].forEach(function(v){var o=document.createElement("option");o.textContent=v;o.selected=(v==1);sc.appendChild(o);});

function build(){
  grid.innerHTML=""; anims=[];
  var scale=parseFloat(sc.value);
  Object.keys(SPRITE).sort().forEach(function(id){
    var a=ANIM[id], cell=document.createElement("div"); cell.className="cell";
    var stage=document.createElement("div"); stage.className="stage";
    var el=document.createElement("div"); el.className="mob";
    el.style.height=(mobVh(id)*scale*3)+"px";
    if(a){
      el.style.width=(mobVh(id)*scale*3*a.aspect)+"px"; el.style.flex="0 0 auto";
      el.style.backgroundImage="url(mob/"+id+".webp?v="+ART_V+")";
      el.style.backgroundSize=(a.n*100)+"% 100%"; el.style.backgroundPositionX="0%";
      // the client's own choices, made the same way
      var calm=""; for(var q=0;q<CALM_POSES.length;q++) if(a.f[CALM_POSES[q]]!==undefined&&!calm) calm=CALM_POSES[q];
      var acts=[]; for(var w in a.f) if(w!=="idle") acts.push(w); a.acts=acts.length?acts:["idle"];
      var sleep="idle"; for(var z=0;z<SLEEP_POSES.length;z++) if(a.f[SLEEP_POSES[z]]!==undefined){sleep=SLEEP_POSES[z];break;}
      var watch="idle"; for(var z9=0;z9<WATCH_POSES.length;z9++) if(a.f[WATCH_POSES[z9]]!==undefined){watch=WATCH_POSES[z9];break;}
      var rate=a.f["move-a"]!==undefined?7000:a.f.up!==undefined?11000:20000;
      // EVERY blow, the way the client collects them: the drakes have three and
      // a preview that kept only the first would show two frames it cannot draw.
      var strikes=[]; for(var y=0;y<STRIKE_POSES.length;y++) if(a.f[STRIKE_POSES[y]]!==undefined) strikes.push(STRIKE_POSES[y]);
      var strike=strikes[0]||"";
      var recoil="idle"; for(var v=0;v<HIT_POSES.length;v++) if(a.f[HIT_POSES[v]]!==undefined){recoil=HIT_POSES[v];break;}
      anims.push({el:el,spec:a,id:id,phase:"idle",t:0,state:"",calm:calm,sleep:sleep,
                  strike:strike,strikes:strikes,blow:strike,recoil:recoil,watch:watch,rate:rate,
                  slot:anims.length, lift:Math.max(0,(mobVh(id)-MAN_VH)/2)/mobVh(id),
                  next:Date.now()+2000+Math.random()*9000});
    } else {
      el.style.aspectRatio="1"; el.style.backgroundImage="url(mob/"+id+".webp?v="+ART_V+")";
      el.style.backgroundSize="contain"; el.style.backgroundPositionX="center";
    }
    stage.appendChild(el); cell.appendChild(stage);
    var n=document.createElement("div"); n.className="n";
    var flags=[]; if(a){ if(!STRIKE_POSES.some(function(k){return a.f[k]!==undefined})) flags.push("<span class=no>no attack frame</span>");
                         if(a.f.death===undefined)  flags.push("<span class=no>no death frame</span>"); }
    n.innerHTML="<b>"+id+"</b><br>"+mobVh(id).toFixed(0)+"vh"+(a?" · "+a.n+" frames":" · still")
      +(a?"<br>"+Object.keys(a.f).join(" "):"")+(flags.length?"<br>"+flags.join("<br>"):"");
    cell.appendChild(n); grid.appendChild(cell);
  });
}
// THE STAGE. One creature, sized the way the game sizes it — mobVh is a
// fraction of the WINDOW height there and of the panel height here, which is
// the same number scaled by how much of the window the panel takes.
var STAGE_VH = 62;
// WHY NOTHING IS STANDING HERE, or "" if something can. One lookup for all three
// kinds of ground, and the answer was computed from the world rather than typed.
function barren(){
  if(!BARREN) return "";
  var v=gnd.value;
  if(v==="gatehouse") return BARREN.gatehouse||"";
  var c=v.indexOf(":");
  return (c<0?"":BARREN[v.slice(c+1)])||"";
}
function dress(){
  var id=who.value, a=MOB_ANIM[id];
  mobsEl.innerHTML=""; 
  if(stageAnim){var k=anims.indexOf(stageAnim);if(k>=0)anims.splice(k,1);stageAnim=null;}
  if(!a||barren()!=="") return;
  var el=document.createElement("div"); el.className="mob";
  var h=mobVh(id)*STAGE_VH/100;
  el.style.height=h+"vh"; el.style.width=(h*a.aspect)+"vh"; el.style.flex="0 0 auto";
  el.style.backgroundImage="url(mob/"+id+".webp?v="+ART_V+")";
  el.style.backgroundSize=(a.n*100)+"% 100%"; el.style.backgroundPositionX="0%";
  el.style.backgroundRepeat="no-repeat"; el.style.backgroundPositionY="center";
  mobsEl.appendChild(el);
  // Built exactly the way the grid builds one, so the action buttons reach it.
  var calm=""; for(var q=0;q<CALM_POSES.length;q++) if(a.f[CALM_POSES[q]]!==undefined&&!calm) calm=CALM_POSES[q];
  var acts=[]; for(var w in a.f) if(w!=="idle") acts.push(w); a.acts=acts.length?acts:["idle"];
  var sleep="idle"; for(var z=0;z<SLEEP_POSES.length;z++) if(a.f[SLEEP_POSES[z]]!==undefined){sleep=SLEEP_POSES[z];break;}
  var watch="idle"; for(var z9=0;z9<WATCH_POSES.length;z9++) if(a.f[WATCH_POSES[z9]]!==undefined){watch=WATCH_POSES[z9];break;}
  var strikes=[]; for(var y=0;y<STRIKE_POSES.length;y++) if(a.f[STRIKE_POSES[y]]!==undefined) strikes.push(STRIKE_POSES[y]);
  var strike=strikes[0]||"";
  var recoil="idle"; for(var v=0;v<HIT_POSES.length;v++) if(a.f[HIT_POSES[v]]!==undefined){recoil=HIT_POSES[v];break;}
  stageAnim={el:el,spec:a,id:id,phase:"idle",t:0,state:"",calm:calm,sleep:sleep,
             strike:strike,strikes:strikes,blow:strike,
             recoil:recoil,watch:watch,rate:a.f["move-a"]!==undefined?7000:a.f.up!==undefined?11000:20000,
             slot:anims.length, lift:Math.max(0,(mobVh(id)-MAN_VH)/2)/mobVh(id),
             next:Date.now()+2000+Math.random()*9000};
  anims.push(stageAnim);
}
// WHAT THE CLIENT DECIDED, read back off the elements rather than recomputed —
// a shelf that worked out the answer a second time could agree with itself and
// still be wrong about the page.
function shelfLine(){
  // Browsers serialise this as url("...") WITH the quotes, so they have to come
  // off or the shelf prints a stray " in front of every path.
  var url=function(v){ v=v||""; var a=v.indexOf("(")+1, b=v.indexOf("?v=");
    if(a<=0) return "—";
    return v.slice(a, b>0?b:v.length-1).replace(/^["']|["']$/g,""); };
  var sc=url(sceneEl.style.backgroundImage), sk=url(skyEl.style.backgroundImage);
  var missing=(sc!=="—"&&torch&&SKY_BASE[hour.value]==="night"&&sc.indexOf("night-torch")<0);
  stage.className = (sc==="—" ? "bare " : "") + (whole ? "whole" : "");
  var pool=SKY_POOL[hour.value], many=!!(pool&&pool.length>1);
  // THE POOL, NAMED AND PICKABLE. Stepping is how the game does it — once a
  // cycle, in order — and it is the wrong tool for judging one sky against
  // another, which is a thing you do by flipping back and forth. So the pool
  // lists itself and any entry can be pinned. It is the SAME index the day
  // count would land on, so nothing here can show a sky the world could not.
  if(pick.dataset.hour!==hour.value){
    pick.dataset.hour=hour.value; pick.replaceChildren();
    (pool||[hour.value]).forEach(function(e,i){
      var o=document.createElement("option"); o.value=i; o.textContent=e; pick.appendChild(o);});
  }
  // The day no longer INDEXES the pool, it is hashed into it, so the current
  // entry has to be asked for the same way the client asks.
  pick.value=String(pool&&pool.length?mix32(plateHash(hour.value+":"+skyRoll))%pool.length:0);
  pick.disabled=!many;
  rollBtn.disabled=!many;
  rollBtn.textContent=many?"next sky · "+(mix32(plateHash(hour.value+":"+skyRoll))%pool.length+1)+"/"+pool.length:"next sky";
  // The turn is invisible in a URL and it is half of what a pool entry says.
  var t=skyEl.style.transform;
  var turn=t.indexOf("-1, -1")>0?" upside down":t?" mirrored":"";
  rollBtn.title=many?"walks the world-day forward to the next day this hour shows a different sky":"this hour owns one sky";
  shelf.innerHTML="ground <b>"+sc+"</b>  sky <b>"+sk+turn+"</b>"
    +"  day <b>"+skyRoll+"</b>"+(pool&&pool.length>1?" of "+pool.length+" skies":" · one sky")
    +"  scene tint <b>"+(sceneEl.className||"none")+"</b>  creature tint <b>"+(mobsEl.className||"none")+"</b>"
    +(missing?"  <span class=miss>no torch plate for this ground yet</span>":"")
    +(barren()?"  <span class=miss>"+barren()+"</span>":"");
  markHour();                        // the strip is a view of hour.value, however it changed
  grid.className=mobsEl.className;   // the roster wears whatever the stage's animals wear
}
function repaint(){
  var v=gnd.value, room=v.slice(0,5)==="room:"?v.slice(5):"";
  // Walking ONTO a sanctuary must clear the animal and walking off must bring it
  // back, so this is re-asked on every paint rather than only when you pick one.
  if((barren()!=="")!==(mobsEl.children.length===0)) dress();
  // A room plate is passed as the place and the terrain is left as it was, which
  // is exactly how the wire carries it: place BESIDE terrain, never instead.
  paintScene("mountain", hour.value, room?"scree":v, v, torch?1:0, undefined, room);
  shelfLine();
}
// STEPPING THE WORLD-DAY. In the game this comes off the clock and moves once
// a cycle; here it is a button, because an hour whose pool has two skies in it
// is the one thing you cannot see by waiting.
//
// IT STEPS TO THE NEXT SKY, NOT THE NEXT DAY, and the difference is the whole
// reason it was worth touching. The day is HASHED into the pool rather than
// indexing it, so consecutive days land on the same entry about a quarter of the
// time (measured across 20,000 days: 24.6-25.4% for all five pools, which is
// exactly chance for a pool of four, and is correct — a sky genuinely does
// repeat two days running out on the hill). But a button that does nothing on
// one press in four does not read as the world being honest, it reads as a
// broken button, and runs of three and four identical days exist in the first
// thirty for every hour.
//
// So it walks forward to the next day that shows a DIFFERENT sky, which keeps
// the one property that matters and the picker below already has: every day it
// lands on is a real world-day, so it can never show a sky the game could not.
// The shelf still prints the day count, so a jump of three is visible as one.
rollBtn.onclick=function(){
  var pool=SKY_POOL[hour.value];
  if(!pool||pool.length<2){ skyRoll++; repaint(); return; }
  var now=mix32(plateHash(hour.value+":"+skyRoll))%pool.length;
  for(var d=skyRoll+1;d<skyRoll+9999;d++)
    if(mix32(plateHash(hour.value+":"+d))%pool.length!==now){ skyRoll=d; break; }
  repaint();
};
// PINNING MEANS FINDING A DAY THAT SHOWS IT. The picker used to set the day
// count to the entry's index, which worked only while the day indexed the pool
// directly. It is hashed now, so this walks forward until it finds a real day
// the world would show the chosen sky on — which keeps the property the picker
// had from the start: it can never show you a sky the game could not.
pick.onchange=function(){
  var want=+pick.value, pool=SKY_POOL[hour.value];
  if(!pool||!pool.length) return;
  for(var d=0;d<9999;d++) if(mix32(plateHash(hour.value+":"+d))%pool.length===want){ skyRoll=d; break; }
  repaint();
};
fitBtn=document.getElementById("fit");
fitBtn.onclick=function(){ whole=!whole; fitBtn.className=whole?"on":""; shelfLine(); };
gnd.onchange=repaint; hour.onchange=repaint;
// NOTHING HERE SHOULD NEED A MENU AND A CLICK. Stepping ground and hour from the
// keyboard is the difference between comparing two plates and giving up on it:
// the whole value of this page is flicking back and forth, and a dropdown puts
// three actions in front of every comparison.
function step(sel,d){
  var n=sel.options.length; if(!n) return;
  sel.selectedIndex=(sel.selectedIndex+d+n)%n; repaint();
}
document.addEventListener("keydown",function(e){
  // Never steal a key from something being typed into, and never from a
  // shortcut the browser owns.
  if(e.metaKey||e.ctrlKey||e.altKey) return;
  var t=e.target, tag=t&&t.tagName;
  if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT"||(t&&t.isContentEditable)) return;
  var k=e.key;
  if(k==="ArrowRight"){ step(gnd,1); }
  else if(k==="ArrowLeft"){ step(gnd,-1); }
  else if(k==="ArrowDown"){ step(hour,1); }
  else if(k==="ArrowUp"){ step(hour,-1); }
  else if(k==="t"||k==="T"){ torchBtn.onclick(); }
  else if(k==="n"||k==="N"||k===" "){ if(!rollBtn.disabled) rollBtn.onclick(); }
  else if(k==="f"||k==="F"){ fitBtn.onclick(); }
  else return;
  e.preventDefault();
});
who.onchange=function(){ dress(); repaint(); };   // a gate answers differently for a boss
torchBtn.onclick=function(){ torch=!torch; torchBtn.className=torch?"on":""; repaint(); };

var ids=function(){return anims.map(function(a){return a.id})};
document.getElementById("fire").onclick=function(){anims.forEach(function(a){if(a.phase!=="death"){a.phase="travel";a.t=0;}})};
document.getElementById("atk").onclick=function(){mobBeat(ids(),null,null)};
document.getElementById("hit").onclick=function(){mobBeat(null,ids(),null)};
document.getElementById("die").onclick=function(){mobBeat(null,null,ids())};
// BY SLOT. The client indexes states by a sprite's position in the room list,
// not by what kind of creature it is — three wolves in a den are three slots.
function mkState(v){var o=[];anims.forEach(function(a){o[a.slot]=v});return o}
var state="";
function setState(v){
  state = state===v ? "" : v;
  document.querySelectorAll("[data-st]").forEach(function(b){b.className=b.dataset.st===state?"on":""});
  anims.forEach(function(a){if(a.phase==="death"){a.phase="idle";a.t=0;}});
  mobHold=0; applyState(state?mkState(state):{});
}
document.querySelectorAll("[data-st]").forEach(function(b){
  b.onclick=function(){ setState(b.dataset.st) };
});
// THE SHELF WAITS FOR THE PICTURE. repaint() called shelfLine() straight after
// paintScene, and paintScene does not paint straight away: it holds the previous
// room up behind a preloader and swaps the background on the image's load event.
// So on any plate that was not already cached the shelf read the element BEFORE
// it was set and reported "ground —  sky —" over a room that was plainly on the
// screen — and never corrected itself, because nothing called it again. A warm
// cache hid it completely (the preloader completes synchronously then), which is
// why it survived: it only ever lied on the first look at a plate.
//
// Rather than guess a delay, watch what actually happened. The shelf's whole
// design is to read back off the elements instead of recomputing the answer, so
// this is the same idea one step further: when what the elements say changes,
// say it again.
var shelfWas = "";
setInterval(function(){
  var now = (sceneEl.style.backgroundImage||"") + "|" + (skyEl.style.backgroundImage||"")
          + "|" + (sceneEl.className||"") + "|" + (mobsEl.className||"");
  if (now !== shelfWas) { shelfWas = now; shelfLine(); }
}, 120);
// THE SHELF WAITS FOR THE PICTURE. repaint() called shelfLine() straight after
// paintScene, and paintScene does not paint straight away: it holds the previous
// room up behind a preloader and swaps the background on the image's load event.
// So on any plate not already cached the shelf read the element BEFORE it was
// set, reported "ground —  sky —" over a room plainly on the screen, and never
// corrected itself because nothing called it again. A warm cache hid it
// completely — the preloader completes synchronously then — which is why it
// survived: it only ever lied on the first look at a plate.
//
// Rather than guess a delay, watch what actually happened. The shelf's whole
// design is to read back off the elements instead of recomputing the answer;
// this is that idea one step further — when what the elements say changes, say
// it again.
var shelfWas = "";
setInterval(function(){
  var now = (sceneEl.style.backgroundImage||"") + "|" + (skyEl.style.backgroundImage||"")
          + "|" + (sceneEl.className||"") + "|" + (mobsEl.className||"") + "|" + gnd.value;
  if (now !== shelfWas) { shelfWas = now; shelfLine(); }
}, 120);
setInterval(stepAnims,60);
// THE SCALE REBUILDS THE ROSTER, AND THE ROSTER OWNS THE ANIMATION LIST.
// build() starts with anims=[], which throws away the stage animal's entry while
// leaving the animal itself on screen — so after touching the scale the creature
// looked perfectly normal and every action button silently did nothing to it,
// because ids(), mkState() and setState() all walk anims. It has to be dressed
// again, in that order, or the stage is a picture rather than an animal.
sc.onchange=function(){ build(); dress(); };
build();
dress();
repaint();
</script>
