// The standing tests, as code rather than as scripts pasted into a console. // // They were ad hoc for six gates and that is how the worst measurement bug in // this project survived: the debug readout — antialiased black text on a bright // sky — was being counted as scene. It sits in the forbidden zone by // construction, it covers enough of the frame to swamp what it measures, and it // gets LONGER when a cue is switched on because it prints that cue's name. So // every cue was charged for its own label, and a baseline of 0.78% was really // 0.199%. Every one of these turns the overlay off and puts it back. // // WHY THE OLD GLOBAL TEST IS GONE. It histogrammed the whole frame and asserted // under 2% between luminance 30 and 90. That was always a PROXY for the real // question — does a black figure have something clearly lighter behind it? With // continuous tone allowed in the background the proxy stops working, so the two // tests below ask the real question directly, in the two places it matters. import { TUNING, BOWL, TORCH, REVEAL_T } from './tuning.js'; import { HOUSE_OF, laneLook, figureMidY } from './drained.js'; import { BAND, linesNeeded, bandBox } from './band.js'; import { loudness } from './loudness.js'; import { GROUND_Y, W, H } from './scene.js'; /** The band a standing figure occupies. */ const FIGURE_BAND = 380; /** * Horizontally, only where a figure can actually BE. * * The camera has a dead zone across the middle third, so the prince is never * outside it — see camera.js. Measuring the full 1920 was harmless while the * background was evenly lit, and became wrong the moment the vignette went in: * it charged the frame for darkening corners the player's character can never * stand in. Taken from the camera's own constant so the two cannot drift. */ const deadZone = () => { const half = (W * TUNING.CAMERA_DEAD_ZONE) / 2; return { x0: Math.round(W / 2 - half), w: Math.round(half * 2) }; }; /** Below this is the black mass — actors, trunks, ground — not background. */ const BLACK = 12; // Floors measured off the build that passed L2R through L6, not picked. // Figure band p10 reads 97 all along the walk; watcher-to-hills is 71 levels. const FLOOR_FIGURE = 90; const FLOOR_WATCHER_GAP = 55; const FLOOR_BRIGHT_START = 25; /** * Where the frame gets sampled, named rather than numbered. * * The last three are inside the village, which is past the end of the road — * and they are the point of this table existing. depthAt returns 1 out there, * so without the clearing the bowl is a fully closed canopy and a figure in it * has nothing behind it to be a silhouette against. The whole matching skill * the village is built to test dies quietly if these three fail. */ const ROAD_AND_BOWL = [ { at: 'road 5%', x: TUNING.LEVEL_LENGTH_PX * 0.05 }, { at: 'road 20%', x: TUNING.LEVEL_LENGTH_PX * 0.20 }, { at: 'road 35%', x: TUNING.LEVEL_LENGTH_PX * 0.35 }, { at: 'road 62%', x: TUNING.LEVEL_LENGTH_PX * 0.62 }, { at: 'road 90%', x: TUNING.LEVEL_LENGTH_PX * 0.90 }, { at: 'bowl mouth', x: BOWL.X0 + TUNING.BOWL_W * 0.10 }, { at: 'bowl mid', x: BOWL.X0 + TUNING.BOWL_W * 0.50 }, { at: 'bowl gate', x: BOWL.X0 + TUNING.BOWL_W * 0.88 }, ]; export function createChecks({ renderer, scene, canvas, clips = {}, audio = null }) { const s = scene.state; const ctx = canvas.getContext('2d', { willReadFrequently: true }); const none = { torch: false, attack: false, pickup: false, sheaf: false, left: false, right: false }; /** Put the world at one spot, settled and deterministic, and draw it once. */ function frameAt(frac, torch = false) { return frameAtX(TUNING.LEVEL_LENGTH_PX * frac, torch); } /** * The same, by world x rather than by fraction of the road. * * The village lives PAST the end of the road, so every fraction inside it is * greater than one and reads as nonsense in a results table. Sampling it is * not optional: depthAt saturates at 1 out there and the clearing is the only * reason a silhouette has anything behind it. */ function frameAtX(worldX, torch = false) { scene.reset(); s.prince.x = s.prince.prevX = worldX; // reset() ALREADY subtracts half the viewport — see camera.js. Passing // `prince.x - W/2` parks him on the dead-zone edge at 1280 instead of the // centre at 960, and camera.follow then tugs him back just enough that the // framing looks plausible. Every staged frame this session was off-centre // by 320px because of it. s.camera.reset(s.prince.x); for (let i = 0; i < (torch ? 90 : 10); i++) scene.update(1 / 60, { ...none, torch }); s.clock = 3.0; // fixed, so the dust and the pulse cannot drift renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); } function withOverlayOff(fn) { const was = renderer.debugShown; renderer.setDebug(false); try { return fn(); } finally { renderer.setDebug(was); } } const lum = (d, i) => 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]; /** * TEST A — can the prince be seen? * * Of the pixels in the band a figure occupies, ignore the black mass and ask * how light the rest is. The 10th percentile, not the mean: the question is * whether the DIMMEST part of the backdrop still separates from a black * silhouette, and a mean is carried by the bright sky above. * * Measured over the walkable level only. The ending is a wall of people on * purpose, and a figure among them is not meant to read against a backdrop. */ function figureContrast(points = ROAD_AND_BOWL) { return withOverlayOff(() => { const rows = points.map((pt) => { frameAtX(pt.x); // Measure the BACKDROP, not the finished frame. // // A near trunk crossing the middle third is in FRONT of the prince, and // a blurred one spreads soft dark over him. Measuring the composited // frame counted that foreground fog as "what is behind him" and reported // a contrast failure at exactly the spots where the occlusion cue — the // one that passed its gate — was working. Same mistake the watcher test // made by sampling sideways into the next watcher. // // So: sky, the far layers, and the vignette. Nothing in front. const camX = s.camera.x; ctx.clearRect(0, 0, W, H); s.background.drawSky(ctx, camX, W); s.background.drawFar(ctx, camX, W, H, 'dark', s.prince.x); renderer.drawVignette(ctx); const z = deadZone(); const d = ctx.getImageData(z.x0, GROUND_Y - FIGURE_BAND, z.w, FIGURE_BAND).data; const vals = []; for (let i = 0; i < d.length; i += 4) { const v = lum(d, i); if (v >= BLACK) vals.push(v); } vals.sort((a, b) => a - b); return { at: pt.at, p10: Math.round(vals[Math.floor(vals.length * 0.1)]), median: Math.round(vals[Math.floor(vals.length * 0.5)]), }; }); const worst = Math.min(...rows.map((r) => r.p10)); return { name: 'figure contrast', pass: worst >= FLOOR_FIGURE, worst, floor: FLOOR_FIGURE, rows }; }); } /** * TEST B — can a WATCHER be seen? * * Test A protects the boy and abandons the 159 figures standing in the trees. * They sit at luminance 26 precisely because two human silhouettes at the same * value merge into one mass, and making them legible at all cost an entire * investigation — the 58px arm gap, measured on the silhouette after 42px * produced a gap of zero. * * Haze them toward the sky and haze the hills too and that separation closes * quietly. So: sample each watcher's torso and the background just beside it * at the same height, and assert the difference survives. */ function watcherContrast(points = ROAD_AND_BOWL.slice(3)) { return withOverlayOff(() => { const layer = s.background.layers.find((l) => l.key === 'watchers'); const rows = []; /** * Screen columns a village building covers. * * A HOUSE AND A WATCHER ARE THE SAME VALUE, deliberately — both are built * or standing things set back off the road, and both sit at luminance 26 * so the pure-black prince reads against them. Which means this test's * scan-up cannot tell them apart: it walks while the value stays within * 8, so it climbs straight through a wall, and a probe that lands on a * house reports a watcher at 26 against a background of 26. * * That is not a contrast failure. It is occlusion, exactly like a watcher * standing behind a near trunk, and it is excluded for the same reason — * except that trunks are caught by their blackness and a house cannot be, * so its footprint has to be handed over explicitly. */ const houseCols = () => { const out = []; for (const h of (s.houses ?? [])) { const sx = h.x - s.camera.x; out.push([sx - h.w / 2 - 20, sx + h.w / 2 + 20]); // +20 for the gable } if (s.gateX != null) { const gx = s.gateX - s.camera.x; out.push([gx - 540, gx + 540]); // leaf, posts, lintel } return out; }; for (const pt of points) { frameAtX(pt.x); const blocked = houseCols(); const d = ctx.getImageData(0, 0, W, H).data; const at = (px, py) => lum(d, (py * W + px) * 4); const shift = s.camera.x * layer.rate; // Only the ones actually drawn. A watcher faded out of the back lane's // way is not on screen, and probing its torso reads whoever is in // front of it. const spots = s.background.sample('watchers', shift - 100, shift + W + 100, true) .map((wx) => Math.round(wx - shift)) .filter((px) => px > 120 && px < W - 120) .filter((px) => !blocked.some(([a, b]) => px >= a && px <= b)); for (const px of spots) { // Torso height for a watcher standing at baseY. const py = layer.baseY - 90; const fig = at(px, py); // The background it is seen against, found by walking UP the same // column until the value changes. Sampling sideways looked obvious // and was wrong: at full density they stand about 100px apart, so a // probe beside a watcher lands on the NEXT watcher and every gap // comes back 0. Straight up clears the head into whatever is behind. let ty = py; while (ty > 40 && Math.abs(at(px, ty) - fig) <= 8) ty--; // The BEST contrast available above the head, not the first pixel // that differs. The first differing pixel is the figure's own // antialiased edge — it reads 37 where the hills are 97 — and a // single sample also lands inside a canopy tooth whenever one happens // to hang directly overhead. Neither is a contrast failure. let bg = 0; for (let k = 4; k < 84 && ty - k > 0; k++) bg = Math.max(bg, at(px, ty - k)); rows.push({ at: pt.at, x: px, watcher: Math.round(fig), behind: Math.round(bg), gap: Math.round(bg - fig), headTop: ty, }); } } // Two kinds of sample are not contrast failures and must not count: // a watcher standing in FRONT of a near trunk has black behind it, and a // watcher standing BEHIND one is not visible at all so the probe reads the // trunk. Both are occlusion, which is the near layer doing its job. const real = rows.filter((r) => r.behind >= BLACK && r.watcher >= BLACK); const worst = real.length ? Math.min(...real.map((r) => r.gap)) : 0; return { name: 'watcher contrast', pass: real.length > 0 && worst >= FLOOR_WATCHER_GAP, worst, floor: FLOOR_WATCHER_GAP, sampled: rows.length, againstBackground: real.length, rows, }; }); } /** * The other half of the two-sided rule: something substantial has to live at * the top, and it has to run out as the canopy closes. That curve IS the * difficulty curve — if brightness does not fall, the level has no shape. */ /** * ROAD ONLY, on purpose. The curve this asserts IS the difficulty curve — the * light runs out as he goes deeper — and the village deliberately breaks it * by being a clearing. Sampling the bowl here would fail a check for doing * the thing it was designed to do. The bowl is covered by figure contrast, * which asks the question that actually matters out there. */ function brightMass(points = [0.05, 0.35, 0.62, 0.9, 0.98]) { return withOverlayOff(() => { const rows = points.map((f) => { frameAt(f); // Same reason as the figure band: the vignette darkens the edges on // purpose, so counting them answers a question nobody asked. const z = deadZone(); const d = ctx.getImageData(z.x0, 0, z.w, H).data; let b = 0, n = 0; for (let i = 0; i < d.length; i += 4) { n++; if (lum(d, i) > 160) b++; } return { at: `${Math.round(f * 100)}%`, bright: +(100 * b / n).toFixed(1) }; }); // A TREND, not a step-by-step guarantee. // // Strict monotonicity worked while this measured the whole frame, because // averaging 1920 pixels wide smoothed everything out. Narrowed to the // camera's dead zone it bounces with whatever happens to be in the middle // third — a trunk, a hill peak — and a real curve gets failed by local // noise. What the check is actually for is "the light runs out as he goes // deeper", which is a trend, so compare the ends. const head = (rows[0].bright + rows[1].bright) / 2; const tail = (rows[rows.length - 1].bright + rows[rows.length - 2].bright) / 2; const falls = tail < head - 4; return { name: 'bright mass', pass: rows[0].bright >= FLOOR_BRIGHT_START && falls, atStart: rows[0].bright, floor: FLOOR_BRIGHT_START, fallsOverTheWalk: falls, startAvg: +head.toFixed(1), endAvg: +tail.toFixed(1), rows, }; }); } /** * THE QUILT. The sky was quantised to three colours through an ordered 4x4 * Bayer matrix at 3px cells, which is a checkerboard with a 6px period and a * matrix that repeats every 12px. Looking at it is not proof: it is invisible * at some brightnesses and obvious at others, so the test is periodicity. * * Autocorrelation of a mean-removed row. A woven pattern spikes at its period. */ function skyWeave(rowY = 300) { return withOverlayOff(() => { frameAt(0.35); const d = ctx.getImageData(0, rowY, W, 40).data; const corr = {}; for (let lag = 2; lag <= 26; lag++) { let sum = 0, n = 0; for (let y = 0; y < 40; y++) { const row = []; for (let px = 0; px < W; px++) row.push(lum(d, ((y * W) + px) * 4)); const mean = row.reduce((p, q) => p + q, 0) / row.length; let num = 0, den = 0; for (let px = 0; px + lag < row.length; px++) { num += (row[px] - mean) * (row[px + lag] - mean); den += (row[px] - mean) ** 2; } if (den > 0) { sum += num / den; n++; } } corr[lag] = sum / Math.max(1, n); } // PROMINENCE, not the raw correlation — and getting this wrong the first // time nearly produced a confidently wrong finding. // // A smooth gradient correlates with itself strongly at every small lag and // decays monotonically: 0.92, 0.89, 0.83, 0.76. The raw value at lag 6 is // therefore HIGHER on a clean sky than on a woven one, and a threshold on // it reports the fix as a regression. // // A weave is a local BUMP: the dithered sky read 0.71 at lag 4, 0.77 at // lag 6, 0.61 at lag 8 — up, then down. So the test asks whether lag 6 or // 12 stands above its own neighbours. const prominence = (lag) => +(corr[lag] - (corr[lag - 2] + corr[lag + 2]) / 2).toFixed(3); const p6 = prominence(6); const p12 = prominence(12); const worst = Math.max(p6, p12); return { name: 'sky weave', pass: worst < 0.03, worstAt6or12: worst, floor: 0.03, prominenceAt6: p6, prominenceAt12: p12, peaks: Object.entries(corr).map(([lag, c]) => ({ lag: +lag, corr: +c.toFixed(3) })), }; }); } /** No input, in the shape scene.update expects. */ const NONE = { torch: false, attack: false, pickup: false, sheaf: false, left: false, right: false }; const drive = (secs, input = {}) => { const n = Math.round(secs * 60); for (let i = 0; i < n; i++) scene.update(1 / 60, { ...NONE, ...input }); }; /** * THE SAME VILLAGE EVERY TIME. * * Everything out there is placed by hashing a world position, so nothing is * random — but a salt collision would quietly put two shapes in one spot, or * a house on top of a shape, and looking at it would not catch that. So: * fingerprint the layout and compare it against what the build shipped with. */ /** * CAN HE LOOK AT A DOOR WITHOUT STARTING A FIGHT? * * The check this project most needed and did not have. Holding the torch * freezes him and takes his sword away, and it reveals out to RADIUS plus * LIGHT_AHEAD. So a Drained standing inside that distance of a door means * lighting the door wakes it while he cannot move or swing, with nowhere to * back off to. The first village had one 59px from the forge, and all five * doors were unlookable — found by playing it, which is the expensive way. * * MEASURED OFF THE LIT TORCH, not computed from RADIUS + LIGHT_AHEAD. * * That sum was right while the pool hung off a fixed offset from him. The * light comes out of the flame now, and the flame is 317px up in the air, so * the pool's footprint on the ground is nothing like its radius: the same * 340px circle that reached 461 ahead of him reaches 328. A derived constant * that derives the wrong thing is worse than a written-down one, because it * looks like it is keeping up. * * So: raise the torch and ask it. Scan forward for the furthest point it can * still reveal a standing figure. Measured once and kept, because it cannot * change inside a run. * * AT THE BACK LANE'S HEIGHT. Every shape a door could wake is standing * beside the road until it is lit, smaller and 56px up, and that puts its * middle nearer the flame: it is lit from 339px where one on the road is lit * from 328. Measuring at the road's height understated it by 11px. */ let doorClear = null; function revealReach() { if (doorClear !== null) return doorClear; const keepX = s.prince.x; const keepFace = s.prince.facing; scene.reset(); s.prince.facing = 1; s.camera.reset(s.prince.x); for (let i = 0; i < 180; i++) scene.update(1 / 60, { ...none, torch: true }); let far = 0; for (let dx = 0; dx <= 1200; dx++) { if (s.torch.covers(s.prince.x + dx, figureMidY(GROUND_Y, 1))) far = dx; } s.prince.x = s.prince.prevX = keepX; s.prince.facing = keepFace; doorClear = far; return far; } function bowlLayout() { // SAME SEED, SAME VILLAGE. The fingerprint used to assert one fixed // arrangement; with a run seed it asserts that a seed reproduces its own. // The spacing floor and the door clearances below are seed-independent — // the placement maths never sees the run seed, which is the whole reason // shuffling roles costs none of V1's guarantees. scene.reset(20250920); // EVERY Drained, road ones included. There is no one-way mouth any more, so // the road is part of the same walkable space and the same rule applies to // it — two encounters landed 667px apart the moment this stopped being two // separate places. const shapes = s.drained.map((d) => d.spawnX); const houses = s.houses.map((h) => `${h.kind}@${h.x}`); const parts = [...shapes, ...houses, `gate@${s.gateX}`, `stop@${s.gateStopX}`]; // A plain string hash. It only has to change when the layout changes. let h = 2166136261 >>> 0; for (const ch of parts.join('|')) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0; } // Nothing may land closer than a body's width, or two shapes read as one. const sorted = [...shapes].sort((a, b) => a - b); let tightest = Infinity; for (let i = 1; i < sorted.length; i++) tightest = Math.min(tightest, sorted[i] - sorted[i - 1]); // Two shapes closer than the torch is WIDE means one raise wakes both. const TORCH_WIDE = TORCH.RADIUS * 2; // The gatekeeper is exempt. He stands at the gatehouse door on purpose — // being blocked by him and swinging is the tutorial, and he is the only // fight in the village the player is meant to walk into rather than choose. const inTheVillage = s.drained.filter((d) => d !== s.village[s.village.length - 1]) .map((d) => d.spawnX); const doors = s.houses.map((hs) => ({ house: hs.kind, nearest: Math.round(Math.min(...inTheVillage.map((x) => Math.abs(x - hs.x)))), })); const worstDoor = Math.min(...doors.map((dd) => dd.nearest)); return { name: 'bowl layout', pass: tightest >= TORCH_WIDE && worstDoor >= revealReach(), fingerprint: h.toString(16), atSeed: 20250920, shapes: shapes.length, inTheVillage: s.village.length, tightestGap: tightest, shapeFloor: TORCH_WIDE, closestToADoor: worstDoor, doorFloor: revealReach(), doors, spacing: Math.round((Math.max(...shapes) - Math.min(...shapes)) / (shapes.length - 1)), houses, }; } /** * BODIES STAY DOWN, AND NOTHING CLEANS THEM UP. * * On the road a downed Drained gets up after DOWN_MS. In the village it never * does, for a reason worth protecting: a body is the only record the player * has of which shapes they already checked, and a dozen near-identical * outlines with no memory of what you tried is the exact tedium this level * exists to measure. * * It is also the best image the level produces for free — brute-force the * village and you walk out through everyone you knocked over. A draw cull or * a despawn added a year from now would delete that silently, so it is a test * rather than a comment. */ function bodiesPersist() { scene.reset(); s.prince.x = s.prince.prevX = BOWL.X0; for (const d of s.village) { d.hits = 99; d.takeHit(d.x - 1); } // scene_level only updates what is within W * 1.4 of him, so he has to walk // the bowl for every one of them to get out of STAGGER and go down. Doing // this on the spot downed the three nearest and silently passed the rest. // // Derived from the width, not written down. A hard-coded 26 seconds was // right for a 9600 village and silently downed only ten of twelve once it // grew — a test that quietly stops covering what it claims to. const crossing = TUNING.BOWL_W / TUNING.WALK_SPEED; drive(crossing + 6, { right: true }); const downed = s.village.filter((d) => d.state === 'DOWNED').length; // Long enough that DOWN_MS would have stood every one of them back up. const waited = Math.max((TUNING.DOWN_MS / 1000) + 5, crossing * 2); drive(waited / 2, { left: true }); drive(waited / 2, { right: true }); const still = s.village.filter((d) => d.state === 'DOWNED').length; return { name: 'bodies persist', pass: downed === s.village.length && still === downed, downed, stillDown: still, of: s.village.length, waitedSeconds: Math.round(waited), }; } /** * NOTHING IS EVER PUT OUT OF REACH. * * The village used to have a one-way mouth: cross it and the camera stopped * following and you could not walk back out. The spec called that "the whole * transition", and it was written when the road was twelve screens of empty * approach nobody would want to re-walk. * * The road is four screens now and has Drained on it carrying what they kept. * Locking the threshold deleted those encounters the moment he stepped past a * house — found by playing it. So the whole level is walkable in both * directions, and this says so in a way a future "focus the level" change * cannot quietly undo. */ function wholeLevelWalkable() { scene.reset(); drive((s.gateStopX / TUNING.WALK_SPEED) + 8, { right: true }); const atGate = Math.round(s.prince.x) === s.gateStopX; drive((s.gateStopX / TUNING.WALK_SPEED) + 8, { left: true }); const back = Math.round(s.prince.x); // Every road encounter has to be reachable again from where he ends up. const road = s.drained.filter((d) => !s.village.includes(d)); const reachable = road.every((d) => d.x >= 0 && d.x <= s.gateStopX); return { name: 'whole level walkable', pass: atGate && back === 0 && reachable && road.length > 0, reachedGate: atGate, walkedBackTo: back, roadEncounters: road.length, allReachable: reachable, }; } /** * NOTHING THAT IS STANDING MOVES UNTIL IT IS LOOKED AT. * * Narrowed from "nothing moves", and the narrowing is a design decision * rather than a concession. Two shapes in the village wander on purpose: they * are the negative control for the loop skill, and their motion has to be * legibly not a trade — standing still would read as the gatekeeper or as a * watcher. * * What survives, and what matters, is that **a wanderer cannot pace into the * light of a door.** Lighting a door would then wake it, and the one thing * the whole placement was built to buy is gone. * * Measured against the torch's own measured reach, and not against * BOWL_CLEAR. 800 is the placement budget a slot is given; 500 is the rule, * derived from RADIUS plus LIGHT_AHEAD. A wanderer sitting at exactly 800 * from a door is legal and 300px outside the light. * * Asserted four ways, because the version before this passed the first two — * it held its position perfectly while playing a walk cycle at quarter speed, * which reads as a man walking on the spot. */ function stillUntilLit() { const reach = revealReach(); scene.reset(); const target = s.drained[0]; const other = s.drained[1]; // Far enough that the torch cannot reach either of them. s.prince.x = s.prince.prevX = target.x - reach - 400; const pose = { clip: target.clip.name, frame: target.frame }; drive(20, { torch: true }); const standing = s.drained.filter((dd) => dd.standing); const drift = Math.max(...standing.map((dd) => Math.abs(dd.x - dd.spawnX))); const posed = target.clip.name === pose.clip && target.frame === pose.frame; // AND THE WANDERERS STAYED ON THEIR OWN GROUND — which means walking past // them, because a Drained is only updated within W * 1.4 of him. Asserting // this from across the village measured nothing at all and passed. const roamers = s.drained.filter((dd) => !dd.standing); let strayed = 0; let penned = roamers.length > 0; s.prince.x = s.prince.prevX = BOWL.X0; for (let pass = 0; pass < 2; pass++) { const n = Math.round(((TUNING.BOWL_W / TUNING.WALK_SPEED) + 4) * 60); for (let i = 0; i < n; i++) { scene.update(1 / 60, { ...NONE, [pass === 0 ? 'right' : 'left']: true }); for (const dd of roamers) { strayed = Math.max(strayed, Math.abs(dd.x - dd.spawnX)); if (s.houses.some((h) => Math.abs(dd.x - h.x) <= reach)) penned = false; } } } // Now stand inside the light's reach and it has to wake and come. INSIDE // IT, measured -- this was a written-down 380, which was comfortably within // the 461 the torch used to reach and 52px outside the 328 it reaches now. // The check failed and the game was fine. s.prince.x = s.prince.prevX = target.x - Math.round(reach * 0.85); s.prince.facing = 1; const from = target.x; let ticks = 0; while (!target.revealed && ticks < 60 * 8) { scene.update(1 / 60, { ...NONE, torch: true }); ticks++; } drive(3); return { name: 'still until lit', pass: drift === 0 && posed && target.revealed && target.x < from && penned, driftWhileUnlit: drift, wanderersStayedOutOfEveryDoorsLight: penned, doorLightReaches: reach, furthestAWandererGot: Math.round(strayed), wanderers: roamers.length, heldItsPose: posed, wokeAfterSeconds: +(ticks / 60).toFixed(2), cameCloserBy: Math.round(from - target.x), }; } /** * CAN HE READ EVERY DOOR, AND DOES IT COST HIM A FIGHT? * * The V2 gate in code. Three things at once, because they are one question: * * 1. Standing where a player would stand, the torch reaches the door and it * latches. A door that cannot be lit says nothing, and the door is the * only thing that says who lived here. * 2. Nothing wakes up while he does it. This is what V1's clear pockets * bought, and it is the thing that will quietly rot first — move a house * or add a shape and it goes without a sound. * 3. The palette rule survives the one bright thing in the level. The wall * has to stay under 30 and the lit doorway over 160, with nothing living * in between, or the forbidden zone has been reopened by a doorway. */ function doorsRead() { const rows = withOverlayOff(() => scene.state.houses.map((h) => { scene.reset(); // Where a player stands to look at a door: beside it, facing in. s.prince.x = s.prince.prevX = h.x - 300; s.prince.facing = 1; drive(2, { torch: true }); s.camera.reset(s.prince.x); s.clock = 3.0; renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); const [dx, dy] = s.doorPoint(h); const px = Math.round(dx - s.camera.x); const py = Math.round(dy); const d = ctx.getImageData(px - 200, py - 240, 400, 300).data; let wall = 255, door = 0; for (let i = 0; i < d.length; i += 4) { const v = lum(d, i); // The wall is the darkest thing that is not the black trade object; // the doorway is the brightest thing in the same patch. if (v > BLACK && v < wall) wall = v; if (v > door) door = v; } return { house: h.kind, lit: h.seen, wall: Math.round(wall), doorway: Math.round(door), woke: s.drained.filter((dd) => dd.revealed).length, }; })); const allLit = rows.every((r) => r.lit); const noneWoke = rows.every((r) => r.woke === 0); const palette = rows.every((r) => r.wall < 30 && r.doorway > 160); return { name: 'doors read', pass: allLit && noneWoke && palette, everyDoorLatched: allLit, nothingWoke: noneWoke, paletteHolds: palette, rows, }; } /** * THE MAP RECORDS, AND READING IT IS LOOKING. * * The V3 gate, and the half that matters is the softlock. The spec sends a * player who took four objects blind back "to light four things on the * ground" — except he took them, so there is nothing on the ground and the * gate could never open. Reading the map is already standing in his own * torchlight, so a `?` resolves into what it is. * * Also asserted: it costs what looking costs. Raising the map raises the * torch, so he cannot move and cannot swing while he does it. */ function pagesCost() { scene.reset(); // Well clear of a door, so nothing files itself by accident. s.prince.x = s.prince.prevX = s.houses[1].x - 1500; for (let k = 0; k < 4; k++) { s.prince.inventory.push({ kept: k, answers: null, seen: false }); } const blind = s.prince.inventory.filter((it) => !it.seen).length; // Try to walk and swing with it up; neither may do anything. const from = s.prince.x; drive(1, { sheaf: true, right: true, attack: true }); const frozen = Math.abs(s.prince.x - from) < 0.001 && !s.prince.attacking; // THE POSE THE SCENE READS IN, whichever drawing that is. It was her // standing drawing, and since she drew the torch held up it is that: // holding the pages holds the torch up. What this ever tested is that he // is STOPPED and holding something, not which drawing it is. const posed = !!s.prince.clip && s.prince.clip === s.mapClip; // And it must NOT resolve anything with the sheaf down. What resolves a // blind take moved onto the page at L3 — `sheafPages` owns that claim now, // because it is a claim about paging. What is left here is the COST of // holding the thing up, which has not changed since it was only a map. scene.reset(); s.prince.x = s.prince.prevX = s.houses[1].x - 1500; s.prince.inventory.push({ kept: 0, answers: null, seen: false }); drive(4); const stillBlind = !s.prince.inventory[0].seen; return { name: 'the pages cost what looking costs', pass: blind === 4 && frozen && posed && stillBlind, tookBlind: blind, cannotMoveOrSwing: frozen, usesTheSheetPose: posed, resolvesNothingWithItDown: stillBlind, }; } /** * THE SHEAF. A pile of loose paper, and every claim here is about paper. * * His orders are page one. Everything after them is in the order he picked * it up — no grouping, no sorting, no index and no tabs, because all of those * are the game telling him what he has, and what he has is the thing he is * meant to work out. * * AND NOTHING IN IT CAME OFF A WALL. Letters stay on their doors and the * note stays on its tree: he does not pocket a stranger's mail. So the paper * in it is his orders and the fragments he took off bodies, and in village * one that is five pages at the most. Keys go in too, and come out again at * the gate; they are carried, and they are not paper. */ function sheafPages() { scene.reset(); const sheaf = s.sheaf; const isPaper = (p) => p.kind === 'letter' || p.kind === 'fragment'; // 1. IT STARTS WITH HIS ORDERS AND NOTHING ELSE. // // This asserted that it started EMPTY, above a comment explaining that // at the start he is carrying nothing. That was true of a pile of what // he had FOUND. The order is the one page he did not find — it is what // he was handed at the palace and what sent him down this road — and it // is page one from before the first frame and permanently. const order = sheaf.pages[0]; const startsWithTheOrder = sheaf.pages.length === 1 && !!order && order.kind === 'letter' && !s.houses[order.house] && s.textFor(order) === s.letters.byHouse[order.house].letter; // 2. LETTERS STAY WHERE THEY WERE LEFT. Light every door and the tree, // stand at each and press E: the band reads it, and the pile does not // grow by a single page. With his orders on top, E at a bare slot does // nothing at all. const stops = s.houses.map((h) => ({ x: h.x, text: s.letters.byHouse[h.index].letter })); if (s.tree) stops.push({ x: s.tree.x, text: s.letters.byHouse[s.tree.index].letter }); let readEveryOne = true; for (const st of stops) { s.prince.x = s.prince.prevX = st.x - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(1.4, { torch: true }); readEveryOne = readEveryOne && s.band.text === st.text; tap(); } const nothingCameOffAWall = sheaf.pages.length === 1 && sheaf.pages[0] === order; // 3. And running off the front of a one-page pile stays on that page. drive(0.5, { sheaf: true, left: true }); const holdsAtTheFront = sheaf.index === 0 && !!s.band.text; // 4. HIS ORDERS AND FOUR FRAGMENTS, AND THAT IS ALL THE PAPER THERE IS. // armed() downs every carrier in the village and takes what each one // was carrying, in the order they stand — so this is the fullest the // pile's paper can ever be, in the order he found it. const carriers = armed(); const paper = sheaf.pages.filter(isPaper); const fullPile = paper.length === 5 && paper.length === carriers.length + 1 && paper[0].kind === 'letter' && !s.houses[paper[0].house] && paper.slice(1).every((p) => p.kind === 'fragment'); const neverAWallsLetter = sheaf.pages.every((p) => p.kind !== 'letter' || (!s.houses[p.house] && p.house !== s.tree?.index)); const foundOrder = paper.slice(1).map((p) => p.kept).join(','); const takenOrder = carriers.map((c) => c.kept).join(','); // 5. A FLIP WITH A BEAT. Holding an arrow walks the pile at reading speed, // not at frame rate. Hold right for a second from the front and count // the turns: at 60fps a free browse would be sixty. sheaf.index = 0; const before = sheaf.turns; drive(1, { sheaf: true, right: true }); const turnsPerSecond = sheaf.turns - before; // 6. IT DOES NOT WRAP. Running off the end of a pile of paper does nothing. drive(4, { sheaf: true, right: true }); const stopsAtTheEnd = sheaf.index === sheaf.pages.length - 1; drive(6, { sheaf: true, left: true }); const stopsAtTheStart = sheaf.index === 0; // 7. THE INDEX SURVIVES RELEASE. He reopens where he left off. drive(1.2, { sheaf: true, right: true }); const wasOn = sheaf.index; drive(1); // sheaf down, walking about drive(0.6, { sheaf: true }); const reopensWhereHeLeftIt = sheaf.index === wasOn && wasOn > 0; // 8. A `?` RESOLVES THE MOMENT HE PAGES TO IT, and only the one he turned // to. Two blind takes, open the second: it fills, the first stays blank. // The pages BEHIND the one in his hand are what the unknown state is for. scene.reset(); s.prince.x = s.prince.prevX = s.houses[1].x - 1500; s.prince.inventory.push({ kept: 0, answers: null, seen: false }); s.prince.inventory.push({ kept: 2, answers: null, seen: false }); sheaf.pages.push({ kind: 'fragment', kept: 0 }, { kind: 'fragment', kept: 2 }); sheaf.index = 2; // the SECOND one he took, past the order drive(0.6, { sheaf: true }); const resolvedOnPage = s.prince.inventory[1].seen && s.band.text === s.letters.byHouse[2].fragment; const resolvedTheOneHeIsOn = !s.prince.inventory[0].seen; // 9. And the one still behind him reads as blank paper — no words in the // band for a page he has not turned to. sheaf.index = 1; // the FIRST fragment, past the order s.band.text = null; const blankWhileUnknown = !s.band.text; drive(0.3, { sheaf: true }); const opensWhenHeTurnsToIt = s.prince.inventory[0].seen && s.band.text === s.letters.byHouse[0].fragment; return { name: 'the sheaf pages', pass: startsWithTheOrder && readEveryOne && nothingCameOffAWall && holdsAtTheFront && fullPile && neverAWallsLetter && foundOrder === takenOrder && turnsPerSecond >= 3 && turnsPerSecond <= 5 && stopsAtTheEnd && stopsAtTheStart && reopensWhereHeLeftIt && blankWhileUnknown && resolvedOnPage && resolvedTheOneHeIsOn && opensWhenHeTurnsToIt, startsWithTheOrder, readsEveryDoorAndTheTree: readEveryOne, andTakesNothingOffAWall: nothingCameOffAWall, holdsAtTheFrontOfThePile: holdsAtTheFront, paperAtItsFullest: paper.length, ordersAndFourFragments: fullPile, neverAHouseLetterOrTheNote: neverAWallsLetter, foundOrder, takenOrder, turnsPerSecondHeld: turnsPerSecond, stopsAtTheEnd, stopsAtTheStart, reopensWhereHeLeftIt, blankUntilHeTurnsToIt: blankWhileUnknown, resolvesOnPage: resolvedOnPage, leavesThePagesBehindHimBlank: resolvedTheOneHeIsOn, opensWhenHeTurnsToIt, }; } /** * THE DEAL OBEYS ITS RULES, FOR ANY RUN SEED. * * Replaces the old `populationReads`, which asserted "three to a motion, one * carrying" — true of the build where the loop meant nothing and false by * design now. Swinging carries zero and bearing carries two, on purpose. * * The rules are the authored part of the level; which specific body satisfies * one is not. So this asserts the rules across a hundred seeds rather than * fingerprinting one arrangement. */ function dealObeysRules(seeds = 100) { const bad = []; let sample = null; for (let n = 0; n < seeds; n++) { scene.reset(n * 7919 + 13); const v = s.village; const keeper = v[v.length - 1]; const carriers = s.drained.filter((d) => d.kept != null); const of = (k) => v.find((d) => d.kept === k); const row = { seed: n, fourCarriers: carriers.length === 4, keeperHasGatehouse: keeper.kept === HOUSE_OF.indexOf('the gatehouse'), forgeOnAHauler: of(HOUSE_OF.indexOf('the forge'))?.loop === 'haul', pairOnBearers: [HOUSE_OF.indexOf('the bakehouse'), HOUSE_OF.indexOf('the well house')] .every((k) => of(k)?.loop === 'bear'), noSwingerCarries: !v.some((d) => d.loop === 'swing' && d.kept != null), nothingForHome: !s.drained.some((d) => d.kept === HOUSE_OF.indexOf('home')), kindsUnique: new Set(carriers.map((d) => d.kept)).size === carriers.length, // No two NEIGHBOURS share a motion. Measured in the order he walks // past them, which is the only order a player experiences — the array // is in construction order and says nothing about the street. noAdjacentMotion: (() => { const line = v.filter((d) => !d.post).sort((a, b) => a.x - b.x); return !line.some((d, i) => i > 0 && d.loop && d.loop === line[i - 1].loop); })(), }; if (!sample) sample = row; if (!Object.values(row).every((x) => x === true || typeof x === 'number')) bad.push(row); } scene.reset(); return { name: 'deal obeys rules', pass: bad.length === 0, seedsTried: seeds, broke: bad.length, firstFailure: bad[0] ?? null, sample, }; } /** * THE SWINGER IS BAIT, AND IT HAS TO BE PROVABLY TRUE. * * The forge letter says "I could hear you at the anvil half the night" and * points at a motion. Walk to the swinging shape because of it, knock it * down, and nothing falls out — then read the fragment on the hauler and it * says "could not work today, my brother's fever is worse". The letter * changed the route twice. * * If one swinger in a hundred seeds drops something, the forge letter is a * lie rather than a subversion. So this walks to every one of them. */ function swingerIsBait(seeds = 25) { const rows = []; for (let n = 0; n < seeds; n++) { scene.reset(n * 104729 + 7); const swingers = s.village.filter((d) => d.loop === 'swing'); let dropped = 0; for (const d of swingers) { // Stand next to it rather than walking the whole village: a Drained is // only updated within W * 1.4 of him, and forty crossings to prove the // same thing is forty crossings. s.prince.x = s.prince.prevX = d.x - 200; s.camera.reset(s.prince.x); d.hits = 99; d.takeHit(s.prince.x); drive(1.5); if (d.keptDropped) dropped++; } rows.push({ seed: n, swingers: swingers.length, downed: swingers.filter((d) => d.state === 'DOWNED').length, dropped }); } scene.reset(); const worst = Math.max(...rows.map((r) => r.dropped)); const allDown = rows.every((r) => r.downed === r.swingers); return { name: 'swinger is bait', pass: worst === 0 && allDown && rows.every((r) => r.swingers === 3), seedsTried: seeds, mostThatEverDropped: worst, swingersPerRun: rows[0].swingers, everyOneActuallyWentDown: allDown, }; } /** * THE RUN CAN ALWAYS BE FINISHED. * * The worst bug this level can have, and it shipped: the gatekeeper's spear * fell on the side the prince "walks off towards", which at a shut gate is * BEHIND him. It landed 375px past the stop line against a 120px pickup * reach, so you could put down all twelve, collect three objects, and have * nothing left to do. * * Checking the coordinates would not have caught it — the drop was where the * code meant it to be. So this walks to every one of them and takes it, which * is the only version of the question that means anything. */ function nothingOutOfReach() { scene.reset(); const carriers = s.drained.filter((d) => d.kept != null); // DOWN THEM ONE AT A TIME, STOOD NEXT TO EACH. // // This used to hit all four and then walk right for a minute, which worked // only because a run starts on the road and walking right goes past every // one of them. At `?at=gate` he starts 1600px from the leaf, reaches it in // three seconds and stands there — and a Drained is only updated within // W * 1.4 of him, so two carriers six thousand px west stayed in STAGGER // for the whole drive, never reached DOWNED, and never dropped anything. // The check then walked to a fragment that was still on a body. // // Standing next to each one is the idiom swingerIsBait already uses, it is // start-position independent, and it is fifty seconds faster. for (const d of carriers) { s.prince.x = s.prince.prevX = d.x - 200; s.camera.reset(s.prince.x); d.hits = 99; d.takeHit(d.x - 1); drive(1.0); // STAGGER is 500ms; the drop lands on DOWNED } const rows = []; for (const d of carriers) { // Walk to it and pick it up, the way a player would. const before = s.prince.inventory.length; let ticks = 0; while (Math.abs(s.prince.x - d.keptX) > TUNING.PICKUP_REACH * 0.5 && ticks < 60 * 90) { scene.update(1 / 60, { ...NONE, [s.prince.x < d.keptX ? 'right' : 'left']: true }); ticks++; } // TAKEN BLIND, which is allowed and is what a player does half the time. // // The key half of this check needs all four FILED, and a fragment he has // not looked at cannot be filed — but the reading happens at a door, // where atDoor() pages to each one. Not here. Holding the torch over a // body in the open street is the one thing in this game that starts a // fight, and a reach check has no business starting one. scene.update(1 / 60, { ...NONE, pickup: true }); drive(0.2); rows.push({ kind: d.answers, at: Math.round(d.keptX), walkedTo: Math.round(s.prince.x), taken: s.prince.inventory.length > before, }); } const allTaken = rows.every((r) => r.taken); // AND THE FOUR KEYS, which is the half of the run that did not exist // before. A key is behind a door, so getting to one means filing that // house's fragment correctly and waiting for its owner to walk home — and // the walk is the thing most likely to strand one somewhere unreachable. for (const k of [0, 1, 2, 4]) { atDoor(k, k); tap({ sheaf: true }); } let t = 0; while (s.drained.some((d) => d.homing) && t < 60 * 250) { scene.update(1 / 60, NONE); t++; } const keyRows = []; for (const k of [0, 1, 2, 4]) { const before = s.prince.keys; const [kx] = s.keyPoint(s.houses[k]); let ticks = 0; while (Math.abs(s.prince.x - kx) > TUNING.DOOR_REACH * 0.5 && ticks < 60 * 120) { scene.update(1 / 60, { ...NONE, [s.prince.x < kx ? 'right' : 'left']: true }); ticks++; } drive(1.4, { torch: true }); // HIS ORDERS ON TOP, so the press can only be for the key. It used to go // to the letter first; letters stay on the door now, and a fragment on // top at a door that is not its own would pin there and wake somebody. s.sheaf.index = 0; tap(); keyRows.push({ house: s.houses[k].kind, at: Math.round(kx), open: s.houses[k].open, taken: s.prince.keys > before }); } const allKeys = keyRows.every((r) => r.taken); // AND EXACTLY ONE THING IT MUST FAIL TO REACH. The loom house key is // visible from the first time that door is lit and there is no way to get // it, for the whole level. Asserted, because an unreachable object is // normally the worst bug this level can have, and this one is the design. const loom = s.houses[HOUSE_OF.indexOf('home')]; s.prince.x = s.prince.prevX = loom.x - 300; s.camera.reset(s.prince.x); drive(1.6, { torch: true }); const heldBefore = s.prince.keys; s.sheaf.index = 0; // his orders on top: nothing to pin here tap(); tap(); tap(); const residue = { lit: loom.seen, open: loom.open, taken: loom.keyTaken, gotNothing: s.prince.keys === heldBefore }; return { name: 'nothing out of reach', pass: allTaken && rows.length === TUNING.GATE_WANTS && allKeys && keyRows.length === TUNING.GATE_WANTS && residue.lit && !residue.open && !residue.taken && residue.gotNothing, objects: rows.length, allReachable: allTaken, keys: keyRows.length, everyKeyReachable: allKeys, theOneThingHeCannotReach: residue, walkableTo: s.gateStopX, rows, keyRows, }; } /** * A FIGHT NOW COSTS SOMETHING. * * Village one played like a beat 'em up because beating up every Drained cost * nothing: three hits, no health, no death. The optimal play was to hit * everything and the game agreed. At zero risk there is no search at all, * just a sweep — and a sweep cannot be tedious, because it is not looking for * anything. * * What this asserts is the shape of the cost, not that it is fun: * * - A man standing in the dark doing nothing is still harmless. The level's * whole argument is that nothing is hostile until it is looked at, and * health must not quietly take that away. * - Six catches, not five and not seven, and the sixth restarts the LEVEL. * - The restart takes the findings with it. A checkpoint that keeps the map * is too forgiving to change how anybody plays. * - Grace: one contact costs one hit, not one per frame of touching. * - It shows on his body — the swing slows and the stance changes — and * nowhere else. There is no bar. */ function healthCosts() { scene.reset(); const v = s.drained[0]; // 1. unlit, and standing right on top of it s.prince.x = s.prince.prevX = v.x - 40; s.camera.reset(s.prince.x); drive(2); const harmless = s.prince.hits === 0 && v.state === 'WANDER'; // 2. GRACE: one contact is one hit, not one per frame of touching. // // `v.takeHit` hurts the DRAINED — it is what wakes this one up. What hurts // the prince is being in contact with it once it is rushing. v.takeHit(s.prince.x); let n = 0; while (s.prince.hits === 0 && n < 60 * 8) { scene.update(1 / 60, { ...NONE }); n++; } const first = s.prince.hits; // Then pinned against it, well inside the grace window. const held = Math.round((TUNING.HURT_GRACE_MS / 1000) * 60 * 0.6); for (let i = 0; i < held; i++) { s.prince.x = s.prince.prevX = v.x - 30; scene.update(1 / 60, { ...NONE }); } const oneContact = first === 1 && s.prince.hits === 1; // 3. it shows on him const fresh = TUNING.SWING_SLOW[0]; const worn = TUNING.SWING_SLOW[TUNING.SWING_SLOW.length - 1]; s.prince.hits = TUNING.LIMP_FROM; s.prince.x = s.prince.prevX = BOWL.X0 + 200; drive(0.5); // IT SHOWS ON HIM, and this used to mean a "holding his side" pose. That // pose is gone — the eye dimming carries the readout, and a placeholder // body next to her standing drawing was worse than no pose change at all. // What has to stay true is that being hurt is VISIBLE without a bar. const dimmed = s.prince.eyeDim > 0; // 4. six, and the level starts again with the findings gone scene.reset(); // RUN THE CHECK TWICE AND IT USED TO LIE. `scene.reset()` restarts the // LEVEL; `run.deaths` is a tally for the RUN and deliberately survives it, // so the second call started with deaths already at 1, the loop below // never ran, and the result reported a prince who never took a hit as a // health failure. Count the deaths this call causes, not the run's total. const deaths0 = s.run.deaths; // WHERE A RESET PUTS HIM, asked rather than assumed. This was `x < 1000`, // which is only true when the run started at the road — so the check failed // on `?at=bowl` and blamed health for it. A check that cries wolf on a debug // flag gets ignored on the day it is right. const spawnX = s.prince.x; s.houses[0].seen = true; s.prince.inventory.push({ kept: 0, answers: 'the forge', seen: true }); s.prince.x = s.prince.prevX = v.x - 30; s.camera.reset(s.prince.x); // SAMPLED AS IT HAPPENS, not once per approach. Reading `hits` after a // 2.2s drive misses the sixth entirely when the death lands inside that // drive — the restart zeroes it before anyone looks. It showed up the day // the reveal dwell changed and the timing shifted by a few hundred ms. let caught = 0; const step = (secs, input) => { const n = Math.round(secs / 0.1); for (let i = 0; i < n && s.run.deaths === deaths0; i++) { drive(0.1, input); caught = Math.max(caught, s.prince.hits); } }; for (let k = 0; k < 14 && s.run.deaths === deaths0; k++) { s.prince.x = s.prince.prevX = v.x - 30; s.camera.reset(s.prince.x); step(1.5, { torch: true }); step(2.2, {}); } const died = { tookSix: caught === TUNING.PRINCE_HITS, deaths: s.run.deaths - deaths0, backToTheStart: Math.abs(s.prince.x - spawnX) < 200, lostTheMap: !s.houses[0].seen && s.prince.inventory.length === 0, hitsReset: s.prince.hits === 0, }; return { name: 'health costs', pass: harmless && oneContact && dimmed && worn > fresh * 1.5 && died.tookSix && died.deaths === 1 && died.backToTheStart && died.lostTheMap && died.hitsReset, unlitIsStillHarmless: harmless, graceHoldsForOneHit: oneContact, theLightIsDimmerOnHim: dimmed, swingSlowsBy: +(worn / fresh).toFixed(2), died, }; } // --- walking home ------------------------------------------------------- // // Put a fragment in the right frame and the person it belonged to gets up, // walks to their own door and opens it. That is the first confirmation this // level has ever given, and it is given by the world rather than by a lock: // nothing is scored, nothing is ticked, and the wrong answer is not refused. /** Down every carrier and take its fragment, so there is something to file. */ function armed(seed = 7) { scene.reset(seed); const carriers = s.drained.filter((d) => d.kept != null); for (const c of carriers) { s.prince.x = s.prince.prevX = c.x - 200; s.camera.reset(s.prince.x); c.hits = 99; c.takeHit(c.x - 1); drive(1.0); // STAGGER is 500ms; it drops on DOWNED s.prince.x = s.prince.prevX = c.keptX; s.camera.reset(s.prince.x); drive(1.4, { torch: true }); // look at it, so the page is not blank drive(0.2, { pickup: true }); drive(0.15); } return carriers; } /** * Stand at house i with the sheaf up, on the page for fragment k. * * THE DOOR IS LIT FIRST, the one time it needs to be: the slot takes a pin * from the first light, and the letter stays where it is. */ function atDoor(i, k = null) { s.prince.x = s.prince.prevX = s.houses[i].x - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); if (!s.houses[i].seen) drive(1.4, { torch: true }); if (k !== null) { s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === k); } drive(1.2, { sheaf: true }); } /** One press of take, on the edge. Holding it would toggle. */ function tap(extra = {}) { drive(0.2, { ...extra, pickup: true }); drive(0.15, extra); } /** * THE TOP PAGE IS WHAT IS IN HIS HAND, and it stays there when the pile * goes away. * * The sheaf is for CHOOSING. Letting go of Q is putting the rest of the * papers back in your coat, not putting down the one you picked. */ function topPageIsTheHand() { armed(); const held = () => s.handPage; // Page to a fragment and let go. s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'fragment'); drive(0.6, { sheaf: true }); const upIsFragment = held()?.kind === 'fragment'; drive(0.6); // Q released const stillFragment = held()?.kind === 'fragment' && !s.sheaf.up; // A letter is a different shape in the hand, and it can be on top too. // The only one he carries is his orders — the rest stay on their doors. const li = s.sheaf.pages.findIndex((p) => p.kind === 'letter'); s.sheaf.index = li; drive(0.6); const letterOnTop = held()?.kind === 'letter'; // AND TAKING SOMETHING PUTS IT ON TOP, which is the whole tutorial: knock // one down, take the scrap, walk to the door, press E. No Q anywhere. const c = s.drained.find((d) => d.kept != null && !d.keptTaken) ?? s.drained.find((d) => d.kept != null); const before = s.sheaf.pages.length; s.prince.x = s.prince.prevX = s.houses[0].x - 900; s.camera.reset(s.prince.x); s.sheaf.pages.push({ kind: 'fragment', kept: 3 }); s.sheaf.index = s.sheaf.pages.length - 1; drive(0.3); const takingPutsItOnTop = s.sheaf.pages.length === before + 1 && held()?.kept === 3; // An empty pile is an empty hand. s.sheaf.pages.length = 0; s.sheaf.index = 0; drive(0.3); const emptyIsEmpty = held() == null; return { name: 'the top page is the hand', pass: upIsFragment && stillFragment && letterOnTop && takingPutsItOnTop && emptyIsEmpty && c != null, aFragmentSurvivesTheReleaseOfQ: stillFragment, aLetterCanBeOnTopToo: letterOnTop, takingPutsItOnTop, andNothingHeldWithAnEmptyPile: emptyIsEmpty, }; } /** * WHAT `E` DOES AT A DOOR, every row of it, and Q makes no difference to any * of them. * * One button, no modes. It is take-or-leave-the-thing-in-front-of-you, which * is what E has meant everywhere else in this game since the first object on * the ground. */ function eAtTheDoor() { const rows = []; const put = (k) => { s.sheaf.index = k === null ? s.sheaf.pages.findIndex((p) => p.kind === 'letter') : s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === k); }; const stand = (i, withQ) => { s.prince.x = s.prince.prevX = s.houses[i].x - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(0.6, withQ ? { sheaf: true } : {}); }; for (const withQ of [false, true]) { armed(); const extra = withQ ? { sheaf: true } : {}; const press = () => { drive(0.2, { ...extra, pickup: true }); drive(0.15, extra); }; // THE BAKEHOUSE, AND THE TWO FRAGMENTS ARE THE FORGE'S AND THE WELL // HOUSE'S. Both are wrong here, on purpose. // // The first pass at this used fragment 1 as "a different fragment" at // house 1 — which is the bakehouse's OWN fragment, so it was correct, // its owner walked home, the patch settled, and every row after it // measured the lock refusing rather than the thing it meant to measure. // The check was wrong and the game was right. const H = 1; const A = 0, B = 2; const h = s.houses[H]; // The door has to have been looked at once for anything to be offered, // which is the latch, not a mode. THERE IS NO LETTER ROW ANY MORE: it // took the letter, and letters stay on the door. stand(H, withQ); drive(1.4, { torch: true }); // 2. A bare slot, a fragment on top -> pins it. put(A); stand(H, withQ); press(); const pinned = h.filed === A; // 3. A fragment in the patch, a DIFFERENT fragment on top -> swap. put(B); stand(H, withQ); press(); const swapped = h.filed === B; // 4. A fragment in the patch, the SAME one on top -> takes it back. put(B); stand(H, withQ); press(); const tookBackSame = h.filed == null; // 5. A fragment in the patch, NOT a fragment on top -> takes it back. put(A); stand(H, withQ); press(); // pin one again put(null); stand(H, withQ); press(); // a letter on top const tookBackWithLetter = h.filed == null; // 6. A bare patch, not a fragment on top -> nothing. put(null); stand(H, withQ); const offeredWithLetter = s.canMark; press(); const bareAndLetterDoesNothing = h.filed == null && !offeredWithLetter; // 7. A settled patch -> nothing. The lock stands. const F = 0; const forge = s.houses[F]; put(0); stand(F, withQ); drive(1.4, { torch: true }); put(0); stand(F, withQ); press(); // the forge's own, so it settles const settled = forge.settled && forge.filed === 0; put(B); stand(F, withQ); press(); // try to swap it const lockHeld = forge.filed === 0 && settled; rows.push({ withQ, pinned, swapped, tookBackSame, tookBackWithLetter, bareAndLetterDoesNothing, lockHeld }); } scene.reset(); const ok = rows.every((r) => Object.entries(r) .every(([k, v]) => k === 'withQ' || v === true)); // AND Q CHANGES NOTHING. Every row, both ways, identical. const sameEitherWay = JSON.stringify({ ...rows[0], withQ: 0 }) === JSON.stringify({ ...rows[1], withQ: 0 }); return { name: 'E at the door', pass: ok && sameEitherWay, everyRowHolds: ok, andTheSheafMakesNoDifference: sameEitherWay, rows, }; } /** * THE WALL SAYS WHERE IT GOES, AND THE SKY SAYS IT LANDED. * * A ten-year-old played the level, picked the scraps up, probably read some * of them, and never worked out that a scrap goes on a wall. She was right to * miss it. The patch was made deliberately quiet — "a value, not an outline, * it must not read as UI" — and it succeeded so completely that it asked * nothing at all. The only thing that had ever said otherwise was the debug * readout printing "[E] PIN IT HERE", and that was on the whole time anybody * tested it. * * Two things, and one without the other still fails: * * THE WALL SHOWS WHAT WOULD GO THERE. Stand at a door holding a scrap and * a ghost of that exact page appears in the patch. It is not a new idea — * the sheaf sheet has always drawn this ghost — it was just drawn on the * paper in his hand instead of on the wall he was looking at. * * THE SKY GOES OFF WHEN IT LANDS. A correct pin was silent, and its payoff * happens somewhere else up to a minute later, so the one right act in the * level looked exactly like nothing. * * NEITHER IS A VERDICT. The ghost appears for a wrong scrap just as readily, * because it previews an act rather than judging one. */ function theWallAsksAndTheSkyAnswers() { return withOverlayOff(() => { const clip = s.houseClip; const patch = (h) => { const [nx, ny, nw, nh] = clip.slotAt[h.variant]; return [Math.round(h.x - s.camera.x - clip.pivotX + nx), Math.round(GROUND_Y - clip.pivotY + ny), nw, nh]; }; // RENDER WITHOUT DRIVING. The first pass at this called a helper that // walked him to the door and held the torch for 1.4s before every single // render, so the two frames being compared were a second and a half of // world apart: the grain floor came out at 5270 against a signal of 7791 // and the check was measuring dust. Three ticks is what `canPin` needs to // notice the page changed, and nothing else moves in three ticks. // AND WITH THE TORCH HELD. Everything in a doorway — the room, the nail, // the patch, the key — is drawn in the LIT pass and the lit pass only // runs while the torch is up. Ticking three frames with no input let it // fall, so the first version of this measured a wall nothing was drawn // on: every comparison came back 0 except the one that accidentally // caught the lightning. // NOT CALLED `TORCH`. It was, and it shadowed the TORCH this file // imports from tuning.js — so `TORCH.KEPT_PULSE_HZ` a few lines down was // undefined, the clock went NaN, and the renderer died trying to build a // gradient stop out of it. The same shadowing that once made the whole // module refuse to load, in a new place. const HOLDING = { ...NONE, torch: true }; const shot = (h) => { for (let i = 0; i < 3; i++) scene.update(1 / 60, HOLDING); s.clock = 3.0; renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); return ctx.getImageData(...patch(h)).data; }; // BRIGHTNESS, NOT A PIXEL DIFF. // // Counting changed subpixels is what every other pixel check here does, // and it cannot work with the torch held: the flame flickers, so two // renders of an unchanged wall differed in 12,668 of 17,024 subpixels — // a noise floor above every signal. A mean is almost blind to the // flicker and is the honest measure anyway, because what a page on a // wall does is put ink on it. const brightness = (dd) => { let sum = 0; for (let k = 0; k < dd.length; k += 4) sum += lum(dd, k); return Math.round(sum / (dd.length / 4)); }; // THE TORCH NEVER GOES DOWN IN HERE. It takes 250ms to rise, so a drive // without it drops the light and the next three ticks are measuring a // half-lit wall getting brighter — which came out as the patch gaining 89 // luminance when a page was put on it, and a "flicker floor" of 32. const stand = (h) => { s.prince.x = s.prince.prevX = h.x - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(1.4, { torch: true }); drive(0.5, { torch: true }); // settled, and staying up }; const put = (k) => { s.sheaf.index = k === null ? s.sheaf.pages.findIndex((p) => p.kind === 'letter') : s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === k); }; armed(); const forge = s.houses[0]; stand(forge); // 1. HIS ORDERS ON TOP IS A BARE SLOT, and two renders of it back to back // are the grain floor everything else is measured against. put(null); const bare = brightness(shot(forge)); const noise = Math.abs(brightness(shot(forge)) - bare); // 1b. AND AN EMPTY SLOT DOES NOT GLOW. THIS CLAIM IS INVERTED. // // It used to breathe, with the same pulse a dropped scrap has: the // patch only appeared once the letter came off, and nothing walked a // player over to find it. The slot is under the letter from the first // light now, and a bare nail under a letter asks its own question. A // pulse on a wall reads as UI — and the loom house's empty slot has to // read as a letter nobody answered, all level, not as a button. // // Measured over one full cycle of KEPT_PULSE_HZ, on a box wider than // the slot so any halo around it would be counted too. const wide = (h) => { const [nx, ny, nw, nh] = clip.slotAt[h.variant]; return [Math.round(h.x - s.camera.x - clip.pivotX + nx) - 26, Math.round(GROUND_Y - clip.pivotY + ny) - 26, nw + 52, nh + 52]; }; const breath = (h) => { const seen = []; for (let i = 0; i < 12; i++) { s.clock = 3.0 + (i / 12) / TORCH.KEPT_PULSE_HZ; renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); seen.push(brightness(ctx.getImageData(...wide(h)).data)); } s.clock = 3.0; return Math.round(Math.max(...seen) - Math.min(...seen)); }; const emptyIsStill = breath(forge); // 2. A SCRAP ON TOP AND THE WALL SHOWS IT. put(0); const ghosted = brightness(shot(forge)); const ghostShows = bare - ghosted; // ink, so it gets darker // 3. AND IT IS A GHOST, NOT THE THING. Pinning for real has to change the // same pixels again, or the preview is a lie. drive(0.2, { torch: true, pickup: true }); // THE SKY, READ NOW. It lasts 560ms; the first version of this read it // after a 1.4s render helper and got zero, which is a check measuring its // own staging rather than the game. const struck = s.flash > 0; drive(0.15, { torch: true }); const pinned = brightness(shot(forge)); const ghostIsNotThePin = ghosted - pinned; // solid, so darker again // AND A FULL ONE IS STILL TOO. Nothing on this wall pulses, ever. const filledIsStill = breath(forge); // 4. AND IT IS OVER. Lightning, not a filter. drive(1.2, { torch: true }); const gone = !(s.flash > 0) && (s.flashMs ?? -1) < 0; // 5. A WRONG SCRAP PREVIEWS JUST THE SAME — it is an act, not an opinion // about one — AND THE SKY STAYS SHUT. armed(); const bake = s.houses[1]; stand(bake); put(null); const bareB = brightness(shot(bake)); put(0); // the forge's, at the bakehouse const wrongGhost = bareB - brightness(shot(bake)); drive(0.2, { torch: true, pickup: true }); const quiet = !(s.flash > 0) && bake.filed === 0; return { name: 'the wall asks and the sky answers', // The flicker moves the mean by a point or two; ink moves it by tens. pass: ghostShows > Math.max(8, noise * 3) && ghostIsNotThePin > Math.max(8, noise * 3) && wrongGhost > Math.max(8, noise * 3) && emptyIsStill <= 2 && filledIsStill <= 2 && struck && quiet && gone, flickerMovesTheMeanBy: noise, anEmptySlotMovesBy: emptyIsStill, andAFilledOneBy: filledIsStill, theWallPreviewsTheScrap: ghostShows, andItPreviewsAWrongOneToo: wrongGhost, theGhostIsNotThePin: ghostIsNotThePin, theSkyGoesOffForARightOne: struck, andStaysShutForAWrongOne: quiet, andTheStrikeEnds: gone, }; }); } function walksHome() { const carriers = armed(); const owner = carriers.find((d) => d.kept === 0); // the forge's // 1. THE WRONG FRAME WAKES THEM. This was "the wrong frame does nothing", // and that is exactly what made the walk home an answer key: pin, see // who walks, try the next door, never read a word. Now a stranger's // house wakes the owner and it comes for him. The page stays on the // wall, and nothing opens or settles. atDoor(1, 0); tap({ sheaf: true }); const seen = [owner.state]; for (let i = 0; i < 60 * 3; i++) { scene.update(1 / 60, NONE); if (seen[seen.length - 1] !== owner.state) seen.push(owner.state); } const wrong = { onTheWall: s.houses[1].filed === 0, rose: seen.includes('RISING'), cameForHim: owner.state === 'RUSH' && !owner.homing, states: seen.join(' > '), nothingOpened: s.houses.every((h) => !h.open), nothingSettled: s.houses.every((h) => !h.settled), }; // Put it back down before it reaches him. What follows is about the walk // home, not a fight; wrongDoorWakes owns the fight. owner.hits = 99; owner.takeHit(owner.x - 1); drive(1.0); // 2. AND IT COMES BACK OUT. Brute force is allowed: four fragments, five // frames, and nothing stops a player working through them. atDoor(1, 0); tap({ sheaf: true }); const pulledBack = { frameEmpty: s.houses[1].filed == null, stillInTheSheaf: s.sheaf.pages.some((p) => p.kind === 'fragment' && p.kept === 0), }; // 3. THE RIGHT FRAME AND HE GETS UP. Nothing is said, here or anywhere. // From wherever it went down last — which, after step 1, is not where // it spawned. atDoor(0, 0); const from = owner.x; const pinnedAt = s.clock; tap({ sheaf: true }); const filed = { onTheWall: s.houses[0].filed === 0, settled: s.houses[0].settled, gotUp: owner.state === 'RISING' || owner.state === 'HOMING', walkingTo: owner.homeX, noVerdictField: !('correct' in s.houses[0]) && !('wrong' in s.houses[0]), }; // 4. AND THE FRAME IS FINISHED WITH. Not a cost — it stops one thing, and // that is taking a name back off a wall after its owner has set off. atDoor(0, 0); const markOffered = s.canMark; tap({ sheaf: true }); const settled = { stillFiled: s.houses[0].filed === 0, markOffered }; // 5. IT ARRIVES, AND ONLY ITS OWN DOOR OPENS. let ticks = 0; while (owner.state !== 'HOME' && ticks < 60 * 200) { scene.update(1 / 60, NONE); ticks++; } const walked = Math.abs(s.houses[0].x - from); const arrived = { home: owner.state === 'HOME', atTheDoor: Math.round(owner.x) === s.houses[0].x, doorsOpen: s.houses.filter((h) => h.open).length, // From the pin, not from here: steps 4's second and a half is part of // the walk, and counting from after it hid behind the old error in // where the walk started. seconds: +(s.clock - pinnedAt).toFixed(1), px: Math.round(walked), // Derived from HOME_SPEED rather than written down, plus the rise. expected: +(walked / TUNING.HOME_SPEED + TUNING.RISE_MS / 1000).toFixed(1), }; // 6. AND NOW IT IS FURNITURE. Hitting it does nothing, like a downed one. const where = owner.x; const hit = owner.takeHit(owner.x - 1); drive(3); const frozen = { ignoredTheSword: hit === false, didNotMove: owner.x === where, stillHome: owner.state === 'HOME' }; return { name: 'walks home', pass: wrong.onTheWall && wrong.rose && wrong.cameForHim && wrong.nothingOpened && wrong.nothingSettled && pulledBack.frameEmpty && pulledBack.stillInTheSheaf && filed.onTheWall && filed.settled && filed.gotUp && filed.walkingTo === s.houses[0].x && filed.noVerdictField && settled.stillFiled && !settled.markOffered && arrived.home && arrived.atTheDoor && arrived.doorsOpen === 1 && Math.abs(arrived.seconds - arrived.expected) < 1.5 && frozen.ignoredTheSword && frozen.didNotMove && frozen.stillHome, theWrongFrameWakesThem: wrong, andItComesBackOut: pulledBack, theRightFrameSendsThemHome: filed, andThenTheFrameIsFinishedWith: settled, arrived, frozen, }; } /** * THE KEY IS BEHIND THE DOOR, and the two door states are the whole point. * * LIT is what the torch does: you can see in, and the key is hanging there. * OPEN is what the person does: you can reach in. You can see it the whole * time and not have it, and nothing anywhere says so — the warm halo is * already this game's one word for "you can pick this up", and it simply is * not on yet. */ function keyBehindTheDoor() { return withOverlayOff(() => { const carriers = armed(); // 1. VISIBLE THE MOMENT A DOOR IS LIT, and measured rather than assumed: // the key's own rectangle has to carry ink against the lit doorway, // and it has to sit inside the light from where he stands to read it. const clip = s.houseClip; const rows = s.houses.map((h) => { s.prince.x = s.prince.prevX = h.x - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(1.6, { torch: true }); s.clock = 3.0; renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); const [kx, ky, kw, kh] = clip.keyAt[h.variant]; const x = Math.round(h.x - s.camera.x - clip.pivotX + kx); const y = Math.round(GROUND_Y - clip.pivotY + ky); const px = ctx.getImageData(x, y, kw, kh).data; let ink = 0, light = 0; for (let i = 0; i < px.length; i += 4) { const L = lum(px, i); if (L < 60) ink++; else if (L > 140) light++; } const p = s.keyPoint(h); return { house: h.kind, ink, light, litByTheTorch: s.torch.covers(p[0], p[1]), offered: s.canTakeKey }; }); const visible = rows.every((r) => r.ink > 120 && r.light > 200 && r.litByTheTorch); const noneOfferedWhileShut = rows.every((r) => !r.offered); // THE DOOR CARRIES FOUR THINGS NOW — letter, slot, key and room — and // whether they all fit is its own check. See theDoorCarriesFour. // 2. NOT HIS WHILE THE DOOR IS SHUT. // HIS ORDERS ON TOP. The top page after armed() is a fragment, and E at // a door that is not its own pins it there — and wakes its owner. s.prince.x = s.prince.prevX = s.houses[0].x - 300; drive(1.6, { torch: true }); s.sheaf.index = 0; tap(); tap(); const shut = { keys: s.prince.keys, taken: s.houses[0].keyTaken }; // 3. HIS ONCE SOMEBODY OPENS IT. for (const k of [0, 1, 2, 4]) { atDoor(k, k); tap({ sheaf: true }); } let ticks = 0; while (s.drained.some((d) => d.homing) && ticks < 60 * 250) { scene.update(1 / 60, NONE); ticks++; } for (const k of [0, 1, 2, 4]) { s.prince.x = s.prince.prevX = s.houses[k].x - 300; s.camera.reset(s.prince.x); drive(1.6, { torch: true }); s.sheaf.index = 0; // his orders on top: the press is for the key tap(); } const open = { keys: s.prince.keys, taken: s.houses.map((h) => h.keyTaken) }; // 4. AND THE LOOM HOUSE KEY HANGS THERE FOR EVER. She took her own when // she went to the palace and nobody is coming back to open it from // inside. It needs no code: nothing carries that fragment, so nothing // ever walks to that door. s.prince.x = s.prince.prevX = s.houses[3].x - 300; s.camera.reset(s.prince.x); drive(1.6, { torch: true }); const offeredAtTheLoom = s.canTakeKey; s.sheaf.index = 0; tap(); tap(); tap(); const residue = { open: s.houses[3].open, taken: s.houses[3].keyTaken, offered: offeredAtTheLoom, keys: s.prince.keys }; return { name: 'the key is behind the door', pass: visible && noneOfferedWhileShut && shut.keys === 0 && !shut.taken && open.keys === 4 && open.taken.filter(Boolean).length === 4 && !residue.open && !residue.taken && !residue.offered && residue.keys === 4 && carriers.length === 4, visibleInEveryLitDoorway: visible, andNotOfferedWhileShut: noneOfferedWhileShut, shut, open, theLoomHouseNeverOpens: residue, rows, }; }); } /** * THE RESIDUE STAYS LOCKED, across a hundred seeds. * * The loom house has a letter and nothing in the forest answers it. Four * doors open with people standing in them and one shut with a key hanging * inside it, which is a more legible thing than an absence — and the game * never says a word about it. */ function residueStaysLocked(seeds = 100) { const LOOM = HOUSE_OF.indexOf('home'); const bad = []; let sample = null; for (let n = 0; n < seeds; n++) { scene.reset(n * 15485863 + 11); const row = { seed: n, nobodyCarriesIt: !s.drained.some((d) => d.kept === LOOM), // So nothing can ever be sent there, whatever the player files. noBodyCanEverHomeToIt: !s.drained.some((d) => d.kept === LOOM), itsDoorIsShut: !s.houses[LOOM].open, itsKeyIsStillThere: !s.houses[LOOM].keyTaken, andTheOtherFourAreCarried: [0, 1, 2, 4].every((k) => s.drained.some((d) => d.kept === k)), }; if (!sample) sample = row; if (!Object.values(row).every((x) => x === true || typeof x === 'number')) bad.push(row); } scene.reset(); return { name: 'the residue stays locked', pass: bad.length === 0, seedsTried: seeds, broke: bad.length, firstFailure: bad[0] ?? null, sample, }; } /** * THE GATE, AND IT INVERTS FOR THE SECOND TIME. * * It once wanted nothing. Then it wanted four fragments he had LOOKED at, * and that is now deleted rather than switched off — looking at four pieces * of paper is not the same act as understanding one, which is exactly how * the level came to be beaten without a word being read. * * IT WANTS FOUR KEYS. A key comes off a hook behind a door that somebody * opened from the inside, and the only thing that makes them open it is * being given their own name back. The gate still never reads the map, still * never grades an answer, and a completely wrong sheaf still walks through. * * 1. Torch down, he reaches the gate, fights nothing, and it stays shut. * 2. Four fragments taken and read leave it shut. That is the old * condition, asserted as a NEGATIVE so it cannot quietly come back. * 3. Three keys leave it shut. Four open it. * 4. Through it ends the playthrough. */ function theGate() { scene.reset(); drive((s.gateStopX / TUNING.WALK_SPEED) + 8, { right: true }); const walked = { reached: Math.round(s.prince.x) === s.gateStopX, fights: s.drained.filter((d) => d.revealed).length, shut: s.gate.open < 1, marks: s.gate.filled, }; // THE OLD CONDITION, AND IT MUST NOT WORK. Four fragments, taken, read, // and even filed under houses — the gate does not look at any of it. for (const k of [0, 1, 2, 4]) { s.prince.inventory.push({ kept: k, answers: null, seen: true }); s.sheaf.pages.push({ kind: 'fragment', kept: k }); s.houses[k].filed = k; } drive(2); const paperAlone = { carrying: s.prince.inventory.length, filed: s.houses.filter((h) => h.filed != null).length, marks: s.gate.filled, shut: s.gate.open < 1, }; // AND NOW KEYS. Handed over rather than earned — walking four bodies home // is walksHome()'s job, and this one is only about the leaf. s.prince.keys = 3; s.prince.x = s.prince.prevX = s.gateStopX; s.camera.reset(s.prince.x); drive(0.3); for (let i = 0; i < 3; i++) { drive(0.2, { pickup: true }); drive(0.15); } drive(2); const three = { placed: s.gate.placed, marks: s.gate.filled, shut: s.gate.open < 1 }; s.prince.keys = 1; drive(0.2, { pickup: true }); drive(0.15); drive(2 + TUNING.GATE_OPEN_MS / 1000); const four = { placed: s.gate.placed, marks: s.gate.filled, open: s.gate.open >= 1, keysLeft: s.prince.keys }; drive(6, { right: true }); const done = { through: s.gate.through, fade: s.fade > 0.9 }; // And the gatekeeper himself: braced, shoving rather than killing, and // never once off the post he has held for sixty years. // // HIS CYCLE IS RESET WITH HIM NOW. It used to survive, so this same fight // measured 1.3s and shoved nobody or 1.9s and shoved twice depending on // what the previous check left him mid-swing at. scene.reset(); const gk = s.village[s.village.length - 1]; const home = gk.x; s.prince.x = s.prince.prevX = s.gateStopX; s.prince.facing = 1; s.camera.reset(s.prince.x); let ticks = 0; let shoves = 0; let last = s.prince.x; while (gk.state !== 'DOWNED' && ticks < 60 * 45) { scene.update(1 / 60, { ...NONE, attack: true }); ticks++; drive(0.42); ticks += 25; if (s.prince.x < last - 60) shoves++; last = s.prince.x; if (s.prince.x < s.gateStopX - 10) { const w = (s.gateStopX - s.prince.x) / TUNING.WALK_SPEED + 0.1; drive(w, { right: true }); ticks += Math.round(w * 60); last = s.prince.x; } } const keeper = { down: gk.state === 'DOWNED', hits: gk.hits, movedFromHisPost: Math.round(gk.x - home), shovedRatherThanKilled: shoves > 0 && !s.loss.active, seconds: +(ticks / 60).toFixed(1), }; return { name: 'the gate', pass: walked.reached && walked.fights === 0 && walked.shut && walked.marks === 0 && paperAlone.carrying === 4 && paperAlone.filed === 4 && paperAlone.marks === 0 && paperAlone.shut && three.placed === 3 && three.marks === 3 && three.shut && four.placed === 4 && four.marks === 4 && four.open && four.keysLeft === 0 && done.through && done.fade && keeper.down && keeper.movedFromHisPost === 0 && keeper.shovedRatherThanKilled, walked, readingAloneNeverOpensIt: paperAlone, shutOnThree: three, opensOnFour: four, done, keeper, }; } /** * MARKING IS A CLAIM, NOT A RECEIPT. * * The map used to file an object under its own house the moment he looked at * it, which meant the document could never be wrong — and a document that * cannot be wrong says nothing when it is right. Everything asserted here is * about that difference: he writes it, he can change it, nothing tells him * whether he is correct, and the empty plot is never flagged. */ function markingIsAClaim() { scene.reset(); // Stand at house i's door with the sheaf up, the door lit. const at = (i) => atDoor(i); const mark = () => drive(0.3, { sheaf: true, pickup: true }); const filed = () => s.houses.map((h) => h.filed); // Light two doors so there is somewhere to file, and take two fragments. at(0); at(1); s.prince.x = s.prince.prevX = s.houses[0].x - 1500; for (const k of [2, 3]) { s.prince.inventory.push({ kept: k, answers: null, seen: true }); s.sheaf.pages.push({ kind: 'fragment', kept: k }); } const fragA = s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === 2); const fragB = s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === 3); // 1. NOTHING FILES ITSELF. He is carrying two he has looked at and every // nail is still bare. const nothingAutoFiles = filed().every((f) => f == null); // 1b. AND A DOOR TAKES A PIN THE FIRST TIME IT IS LIT. THIS CLAIM IS // INVERTED. // // It used to assert that there was nowhere to file until the letter // came off the nail: one spot, the letter on it, and the patch it left // was the slot. Letters stay on the door now and the slot is under the // letter from the first light, so a door he has only just lit takes a // pin like any other. It is the MOVE in claim 2 that takes it off again. const unread = s.houses[2]; const neverLit = !unread.seen; s.prince.x = s.prince.prevX = unread.x - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); s.sheaf.index = fragA; drive(1.2, { sheaf: true }); const offeredOnFirstLight = unread.seen && s.canMark && s.canPin; mark(); const slotFromTheFirstLight = neverLit && offeredOnFirstLight && unread.filed === 2; // 2. He files one at a lit door. s.sheaf.index = fragA; at(0); mark(); const afterFirst = filed(); // 3. REPLACE. A different page at the same door pushes the first out — // two fragments can never sit under one house. s.sheaf.index = fragB; at(0); mark(); const afterReplace = filed(); // 4. MOVE. The same page at another door takes it off the first — one // fragment can never sit under two houses. at(1); mark(); const afterMove = filed(); const onlyOnce = afterMove.filter((f) => f === 3).length === 1; // 5. AND IT CANNOT BE DONE FROM NOWHERE. Away from any door, marking does // nothing at all: the door is the evidence he is comparing against. s.prince.x = s.prince.prevX = s.houses[0].x - 1500; const before = filed().join(','); s.sheaf.index = fragA; mark(); const needsADoor = filed().join(',') === before; // 6. AND IT DOES NOT NEED THE SHEAF UP. THIS CLAIM IS INVERTED. // // It used to assert the opposite — that pinning with the sheaf down did // nothing — and that rule was played and it did not survive contact. // Three conditions and a mode: hold Q, page to the fragment, press E at // a door. Nobody does that with paper. // // The top page of the pile is what he is holding, and E acts on what he // is holding. So the same press with the sheaf DOWN has to do the same // thing the press with it up did, and this asserts exactly that. s.prince.x = s.prince.prevX = s.houses[0].x - 1500; s.sheaf.index = fragA; drive(0.4); // sheaf down, walking about const onTopWithItDown = s.page()?.kept === 2 && !s.sheaf.up; s.prince.x = s.prince.prevX = s.houses[0].x - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(0.5); // NO sheaf, NO torch const offeredWithItDown = s.canPin; tap(); // plain E const pinnedWithItDown = s.houses[0].filed === 2; // And taking it back down is the same press again. tap(); const takenBackWithItDown = s.houses[0].filed == null; const noSheafNeeded = onTopWithItDown && offeredWithItDown && pinnedWithItDown && takenBackWithItDown; // Put the world back where claim 7 expects it. const before2 = filed().join(','); // A LETTER IS NOT A CLAIM. Only a torn page off a body can be filed — the // letter is what the house already says about itself. s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'letter'); at(0); mark(); const mapCannotBeFiled = s.sheaf.index < 0 || filed().join(',') === before2; // 7. NO VERDICT, EVER. The world answers every filing now — a person // walking home, or a person coming for you — and that is all it does. // Nothing in the state says right or wrong: no tick, no score, no // `correct` or `wrong` field anywhere. An empty plot is not flagged. // Four filed and one empty is the finding, and it has to look exactly // like four filed and one not done yet. const noVerdict = !('correct' in s.houses[0]) && !('wrong' in s.houses[0]) && s.houses.every((h) => h.filed == null || typeof h.filed === 'number'); // 8. THROUGH THE GATE, FROZEN. He walks out with a document he made and no // way to check it — and no way to tidy it either. s.sheaf.index = fragA; s.gate.through = true; const atTheGate = filed().join(','); at(0); mark(); const frozenAtTheGate = filed().join(',') === atTheGate; return { name: 'marking is a claim', pass: nothingAutoFiles && slotFromTheFirstLight && noSheafNeeded && afterFirst[0] === 2 && afterReplace[0] === 3 && afterMove[0] == null && afterMove[1] === 3 && onlyOnce && needsADoor && mapCannotBeFiled && noVerdict && frozenAtTheGate, nothingFilesItself: nothingAutoFiles, aDoorTakesAPinTheFirstTimeItIsLit: slotFromTheFirstLight, afterFirst, afterReplace, afterMove, oneFragmentOneHouse: onlyOnce, needsALitDoor: needsADoor, andDoesNotNeedTheSheafUp: noSheafNeeded, withTheSheafDown: { onTop: onTopWithItDown, offered: offeredWithItDown, pinned: pinnedWithItDown, takenBack: takenBackWithItDown }, aLetterCannotBeFiled: mapCannotBeFiled, noRightOrWrongAnywhere: noVerdict, frozenAtTheGate, }; } /** * THE LIGHT GOES OUT OF HIM, AND IT IS THE HEALTH BAR. * * There is no bar and there never was one. He limps, he holds his side, the * swing slows — and now the one thing always visible on a black silhouette * dims as he is hurt. * * Which makes three promises, and all three are easy to break by accident: * every pose must HAVE an eye, they must all be the same SIZE, and the dimmed * eye must never land in the 30-to-90 band this build refuses to put anything * in. */ function theLightGoesOut() { const princeClips = Object.keys(clips).filter((k) => k.startsWith('prince')); // 1. EVERY POSE, EVERY FRAME. A pose without one would read as a man whose // light had already gone out — and `guard` is the pose he takes the // moment something wakes, which is exactly when you look at him. // // EXCEPT THE FALL, which is where it goes out. Her drawing turns his // face away as he goes over, and the eye goes with it -- the light going // out of him is the death. So that one clip may lose it, but only once: // an eye from the first frame, and once it is gone it never comes back. const missing = []; for (const k of princeClips) { const frames = clips[k].frames; const goesOut = k === 'prince_down' && frames[0]?.eyes && frames.findIndex((f) => !f.eyes) > 0 && frames.slice(frames.findIndex((f) => !f.eyes)).every((f) => !f.eyes); if (goesOut) continue; frames.forEach((f, i) => { if (!f.eyes) missing.push(`${k}[${i}]`); }); } // 2. THE SAME SIZE IN ALL OF THEM. Her standing eye is 1.7% of the figure's // height and her running one 2.4%; the placeholder poses were drawn at // 4.4% first, so changing pose doubled it. const widths = {}; for (const k of princeClips) { const c = clips[k]; const off = new OffscreenCanvas(c.width, c.height); const octx2 = off.getContext('2d', { willReadFrequently: true }); octx2.drawImage(c.frames[0].eyes, 0, 0); const g = octx2.getImageData(0, 0, c.width, c.height).data; let lo = 1e9, hi = -1; for (let y = 0; y < c.height; y++) { for (let x = 0; x < c.width; x++) { if (g[(y * c.width + x) * 4 + 3] > 200) { if (x < lo) lo = x; if (x > hi) hi = x; } } } widths[k] = +((hi - lo + 1) / c.height * 100).toFixed(1); } const ws = Object.values(widths); const sameSize = Math.max(...ws) - Math.min(...ws) <= 1.2; // 3. IT DIMS, STEP BY STEP, AND NEVER INTO THE MUD. White over black lands // at 255 x (1 - dim), so the dimmest living state has to clear 90. scene.reset(); const steps = []; for (let h = 0; h < TUNING.PRINCE_HITS; h++) { s.prince.hits = h; drive(0.2); steps.push({ hits: h, dim: +s.prince.eyeDim.toFixed(3), lum: Math.round(255 * (1 - s.prince.eyeDim)) }); } let falls = true; for (let i = 1; i < steps.length; i++) if (steps[i].lum >= steps[i - 1].lum) falls = false; const muddy = steps.filter((r) => r.lum > 30 && r.lum < 90); scene.reset(); return { name: 'the light goes out of him', pass: missing.length === 0 && sameSize && falls && muddy.length === 0, posesWithoutAnEye: missing, eyeWidthPctPerPose: widths, sameSizeInEveryPose: sameSize, dimsOnEveryHit: falls, stepsInTheForbiddenBand: muddy, steps, }; } /** * A GLANCE IS FREE AND A STARE IS NOT. * * The whole search rests on this. The loops narrow twelve shapes to a group; * what narrows a group to a person is seeing the page a carrier is holding, * and that only shows inside the torch. So there has to be a moment where the * light is bright enough to read them and they have not come for you yet. * * THERE WAS NO SUCH MOMENT. The shortest hold that showed a shape and the * shortest hold that woke it were the same 0.25s, because `covers()` starts * the wake clock while the circle is still growing. Which made hitting all * twelve the only strategy, on a health budget that fits five fights. */ function aGlanceIsFree() { const hold = (secs) => { scene.reset(); const d = s.village.find((v) => v.loop === 'haul'); s.prince.x = s.prince.prevX = d.x - 260; s.prince.facing = 1; s.camera.reset(s.prince.x); let readable = false; const n = Math.round(secs * 60); for (let i = 0; i < n; i++) { scene.update(1 / 60, { ...NONE, torch: true }); // Bright enough that the page on a carrier is drawn in the lit pass. if (s.torch.raise > 0.9 && s.torch.covers(d.x, figureMidY(GROUND_Y, d.lane))) readable = true; } drive(2); return { secs, readable, woke: d.revealed }; }; const rows = [0.2, 0.3, 0.45, 0.6, 0.9].map(hold); const glances = rows.filter((r) => r.readable && !r.woke); const stares = rows.filter((r) => r.secs >= 0.9); return { name: 'a glance is free', pass: glances.length >= 2 && stares.every((r) => r.woke), // Holds that show you the shape and leave it asleep. readableWithoutWaking: glances.map((r) => r.secs), wakesAt: rows.find((r) => r.woke)?.secs ?? null, andAStareStillWakesThem: stares.every((r) => r.woke), rows, }; } /** * THE KEYS ARE ALWAYS THERE. * * The controls used to be the last line of the debug readout, which put the * one thing a new player needs behind the same toggle as the frame timings — * and hid it outright under `?debug=0`. Now it is its own line at the foot of * the frame, and it is only allowed to leave for one reason: a page is open * and he is reading it. */ function theKeysAreShown() { // The strip below the ground line, where the line sits and nothing else in // the world is drawn. const foot = () => { const d = ctx.getImageData(0, H - 52, Math.round(W * 0.85), 48).data; let ink = 0; for (let i = 0; i < d.length; i += 4) if (d[i] > 60 && d[i] > d[i + 2]) ink++; return ink; }; const frame = () => { s.camera.reset(s.prince.x); s.clock = 3.0; renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); return foot(); }; scene.reset(); s.prince.x = s.prince.prevX = s.houses[1].x - 1400; s.prince.facing = 1; // 1. WITH THE READOUT OFF. This is the case that was broken: `?debug=0` is // the sane way to play and it took the controls with it. const withoutTheReadout = withOverlayOff(() => { drive(0.4); return frame(); }); // 2. And with it on, unchanged — it is not part of it any more. const withTheReadout = frame(); // 3. It steps aside for a page. Reading a letter is the one thing that // wants the bottom of the frame more than the keys do. const [dx] = s.doorPoint(s.houses[1]); s.prince.x = s.prince.prevX = dx - 300; const whileReading = withOverlayOff(() => { drive(1.5, { torch: true }); return frame(); }); // 4. AND ON TOUCH THERE IS ONE CONTEXTUAL BUTTON, NOT TWO. // // MARK lived on the right wheel because pinning a page needed PAGES // held and TAKE pressed and one thumb cannot do both. The top page is // now what he is holding and TAKE acts on it, so the second button is // deleted. Asserted rather than trusted, because a stray button in the // markup is invisible on a desktop and is the whole control scheme on // a phone. const pad = document.getElementById('pad'); const acts = pad ? [...pad.querySelectorAll('button[data-act]')].map((b) => b.dataset.act) : []; const touch = { noMarkButton: !document.getElementById('markbtn'), // Exactly one thing on the pad fires the contextual verb. onePickupButton: acts.filter((a) => a === 'pickup').length === 1, // And nothing is labelled with the verb that no longer exists. nothingSaysMark: !pad || !/\bMARK\b/.test(pad.textContent), acts, }; return { name: 'the keys are shown', pass: withoutTheReadout > 1500 && withTheReadout > 1500 && whileReading < withoutTheReadout * 0.25 && touch.noMarkButton && touch.onePickupButton && touch.nothingSaysMark, inkWithTheReadoutOff: withoutTheReadout, inkWithItOn: withTheReadout, inkWhileReadingALetter: whileReading, survivesDebugBeingOff: withoutTheReadout > 1500, getsOutOfTheWayOfAPage: whileReading < withoutTheReadout * 0.25, andOneContextualButtonOnTouch: touch, }; } /** * THE DOOR ASKS THE QUESTION, and it asks it with a mark rather than a box. * * Three conditions have to be true at once for a fragment to go anywhere and * nothing said so. The first fix put cues on the sheet, which have the same * flaw every sheet cue has: they only appear once he is already holding it up * at a door, which is the thing the cue was meant to teach. So the question * is asked in the world. * * IT WAS A FRAME AND IT IS NOW TWO NAILS. A bright square box bolted to every * house read as a noticeboard, and no village in any century had one. A * letter nailed up is how you reached somebody who was not home, anywhere, * ever — and under it hangs a second nail and the lighter patch of wall where * their own reply hung. That patch is the slot. The frame was drawn; the * patch is a VALUE, and it is there from the first light, beside a letter * that never comes down. * * THE GATE HAS ALWAYS WORKED THIS WAY. Four empty sockets on the leaf, and * nobody has ever needed telling what they are for. * * Measured in pixels, because it is entirely a claim about what is on screen. */ function theRoomAsks() { const onScreen = (house, rect) => { const clip = s.houseClip; const [fx, fy, fw, fh] = rect; return [Math.round(house.x - s.camera.x - clip.pivotX + fx), Math.round(GROUND_Y - clip.pivotY + fy), fw, fh]; }; const nailRect = (house) => onScreen(house, s.houseClip.slotAt[house.variant]); const letterRect = (house) => onScreen(house, s.houseClip.letterAt[house.variant]); const look = (house) => { const [dx] = s.doorPoint(house); s.prince.x = s.prince.prevX = dx - 300; s.prince.facing = 1; drive(1.4, { torch: true }); s.camera.reset(s.prince.x); s.clock = 3.0; // AT THE TORCH'S OWN SIZE, NOT WHEREVER ITS FLICKER LEFT IT. Two looks // are 1.4s apart, so they caught the pool at two different sizes, and // every subpixel the flicker moved counted as the patch changing. It // passed by 4619 to 4252 with the flame at 317px; her torch holds it at // 333, the slot sits further out in the falloff, and the flicker alone // came to more than a pinned page. The grain floor is grain again. s.torch.radius = s.torch.prevRadius = TORCH.RADIUS; renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); const [x, y, w, h] = nailRect(house); return ctx.getImageData(x, y, w, h).data; }; const differs = (a, b) => { let n = 0; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) n++; return n; }; const brightness = (d) => { let sum = 0; for (let i = 0; i < d.length; i += 4) sum += lum(d, i); return Math.round(sum / (d.length / 4)); }; return withOverlayOff(() => { scene.reset(); const forge = s.houses[0]; const home = s.houses[3]; // 1. THE FIRST LIGHT, AND BOTH ARE THERE. Nothing has been read, taken or // pinned: the letter hangs on its nail and the slot is under it. const patch = look(forge); const patchLum = brightness(patch); const noise = differs(look(forge), patch); const letterLum = (() => { const [x, y, w, h] = letterRect(forge); return brightness(ctx.getImageData(x, y, w, h).data); })(); // 2. AND THE LETTER IS PAPER, NOT PATCH. The two share a column and a // shape, so the thing that tells them apart is the value: the letter // is a lit page with writing on it, the slot is a paler mark on wood. const letterIsPaper = letterLum > patchLum + 20; // THE WALL BELOW IT, which is what the patch has to beat. Finding // somewhere that is actually wall took three goes as the spot moved: // beside it is the lit doorway, above it is roof, and left of it is // where he stands. const wall = (() => { const [x, y, w, h] = nailRect(forge); return brightness(ctx.getImageData(x, y + h + 8, w, h).data); })(); // 3. AND IT IS A MARK, NOT A SOCKET. // // MEASURED AGAINST THE DOORWAY, not against the letter. The letter is // paper on its own lit backing and is bright by construction; what "must // not read as UI" means is that the patch must not look like the socket // it replaced, and the socket was doorway-coloured. So: clear of the // wall, clear of the forbidden band, and nowhere near the lit doorway. // // The BRIGHT part of it, not the average. A sample straight across from // the nail lands half on lit doorway and half on the anvil standing in // it, which averaged to 110 and made the doorway look dimmer than the // patch. What the patch must not be mistaken for is the light, so the // light is what it is measured against. const doorway = (() => { const [x, y, w, h] = nailRect(forge); const d = ctx.getImageData(x + w + 70, y - 100, w, h).data; const all = []; for (let i = 0; i < d.length; i += 4) all.push(lum(d, i)); all.sort((a, b) => a - b); return Math.round(all[Math.floor(all.length * 0.9)]); })(); const readsAsWall = patchLum > 90 && patchLum < doorway - 50; // 4. PIN A PAGE INTO IT AND IT IS THERE. forge.filed = 2; const filledDiff = differs(look(forge), patch); // 5. AND THE HOUSE HE NEVER FILES KEEPS ASKING. Same nail, same patch, // nothing in it, and nothing anywhere says it is empty on purpose. const [hx] = s.doorPoint(home); s.prince.x = s.prince.prevX = hx - 300; drive(1.4, { torch: true }); // Its own grain floor, measured here rather than borrowed from the // forge: two looks at this door with nothing changed between them. A // floor taken under a different stretch of world is not a floor. const homeBefore = look(home); const homeNoise = differs(look(home), homeBefore); const homeSettled = look(home); s.houses[1].filed = 1; s.houses[2].filed = 0; s.houses[4].filed = 4; const homeDiff = differs(look(home), homeSettled); return { name: 'the room asks', pass: letterIsPaper && readsAsWall && patchLum > wall + 40 && filledDiff > Math.max(300, noise * 4) && homeDiff <= Math.max(homeNoise * 2, 200), letterOnTheNailLum: letterLum, patchLum, wallLum: wall, doorwayLum: doorway, grainNoiseFloor: noise, atTheFifthNail: homeNoise, letterAndSlotBothThereFromTheFirstLight: letterIsPaper && readsAsWall, andTheSlotIsAMarkNotASocket: readsAsWall, itReadsAgainstTheWall: patchLum > wall + 40, pinningPutsItOnTheWall: filledDiff > Math.max(300, noise * 4), andTheFifthNailKeepsAsking: homeDiff <= Math.max(homeNoise * 2, 200), subpixelsChangedByPinning: filledDiff, subpixelsChangedAtTheHouseHeNeverFiled: homeDiff, }; }); } /** * THE WRITING FITS, AND THIS TESTS THE WRITING RATHER THAN THE CODE. * * The band is sized so three lines fit with no scrolling. A fragment that * wraps to four is a failure of the writing, not of the layout, and the test * should say so before anybody plays it — which is the right kind of check * for a game whose evidence is text. * * Measured at the AUTHORING size. The band grows on a small display, where * more lines is the correct answer; what is being asserted here is that a * letter is short enough to be read at a glance. */ function bandFits() { const rows = []; withOverlayOff(() => { for (const h of (scene.state.letters?.byHouse ?? [])) { for (const kind of ['letter', 'fragment']) { const text = h[kind]; if (!text) continue; // TWO MEASUREMENTS, BECAUSE THE BAND IS TWO DIFFERENT SHAPES. // // At the authoring size, three lines is the writing rule and a fourth // is a writing failure. On a handset the type grows, the rule cannot // hold, and what matters instead is how far up the picture the band // reaches — so that case is judged on its top edge, not its lines. const small = bandBox(ctx, text, BAND.MIN_CLIENT_W); rows.push({ house: h.house, kind, lines: linesNeeded(ctx, text), chars: text.length, phoneLines: small.lines.length, phoneTop: Math.round(small.top), }); } } }); const worst = rows.length ? Math.max(...rows.map((r) => r.lines)) : 99; const highest = rows.length ? Math.min(...rows.map((r) => r.phoneTop)) : 0; return { name: 'band fits', // ELEVEN, EXACTLY. Nine village strings, his orders, and the note on the // tree. An exact equality rather than a floor, so adding a string and not // looking at it fails here rather than on somebody's phone. pass: rows.length === 11 && worst <= BAND.MAX_LINES && highest >= BAND.TOP_FLOOR, strings: rows.length, longest: worst, allowed: BAND.MAX_LINES, // How far up the scene the tallest band climbs on the narrowest display, // and how far up it is allowed to. Bigger number is lower on screen. highestTopOnPhone: highest, floor: BAND.TOP_FLOOR, overLength: rows.filter((r) => r.lines > BAND.MAX_LINES), tooTall: rows.filter((r) => r.phoneTop < BAND.TOP_FLOOR), rows, }; } // --- the order ---------------------------------------------------------- /** The palace letter's index in byHouse. Found, not counted to. */ const orderHouse = () => s.letters.byHouse.findIndex((h) => h.origin === 'palace'); /** * PAGE ONE, AND HE CANNOT PUT IT DOWN. * * It is the only page in the game he did not find, and the only one that * cannot leave the pile. Nothing in src/ removes a page except the key * splice, which searches for a key and stops at the first one -- so this * falls out of the rules rather than being defended, which is exactly why it * is worth asserting instead of trusting. */ function orderIsPageOne() { const oh = orderHouse(); scene.reset(); const isFirst = (why) => { const p = s.sheaf.pages[0]; return p && p.kind === 'letter' && p.house === oh ? true : why; }; const atInit = isFirst(false) === true; // It survives four fragments going on top of it. armed(); const afterTaking = isFirst(false) === true; // It survives pinning and retrieving at a slot. Not the forge's own // fragment, or it settles and the retrieve is refused. const armedPages = s.sheaf.pages.length; const h = s.houses[0]; s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept !== h.index); s.prince.x = s.prince.prevX = h.x - 300; s.camera.reset(s.prince.x); if (!h.seen) drive(1.4, { torch: true }); drive(0.3, { pickup: true }); drive(0.3); drive(0.3, { pickup: true }); const afterPinning = isFirst(false) === true && s.sheaf.pages.length === armedPages; // And it survives a restart. scene.reset(); const afterReset = isFirst(false) === true && s.sheaf.pages.length === 1; // IT IS NOT A FRAGMENT, so it cannot be claimed. `holding` collapses every // kind but fragment to null, which is what makes E at a bare patch a no-op // with the order in hand -- but the order is a LETTER now, and letters have // always been no-ops there, so this is guarding a rule it shares. const p0 = s.sheaf.pages[0]; const neverPinned = s.houses.every((hh) => hh.filed == null) && !s.filedUnder(p0); const readsAsPalacePaper = s.textFor(p0) === s.letters.byHouse[oh].letter && !s.houses[oh]; return { name: 'the order is page one', pass: atInit && afterTaking && afterPinning && afterReset && neverPinned && readsAsPalacePaper && oh >= 0, palaceIsByHouse: oh, atInit, afterTaking, afterPinning, afterReset, neverPinned, readsAsPalacePaper, pagesAfterEverything: armedPages, }; } /** * HE IS READING IT WHEN THE GAME STARTS, and he holds it until you press * something. * * The posture the player will use for the whole game, shown before they know * there is a button for it -- and it is the one time he does not have to HOLD * the button to keep the page up. */ function openingHolds() { const oh = orderHouse(); scene.reset(); scene.openAgain(); // FRAME ONE, ALREADY UP. Not ramping: the raise is set rather than driven, // because RAISE_MS is 250 and the band needs 0.9, so coming up through the // input path would spend a quarter of a second showing an unreadable page. // // A BOUND TAKES BOTH OF THE NEXT TWO CLAUSES WITH IT. ?order_hold_ms= is // an upper limit on the wait and exists so a developer can get the opening // out of the way; at 1ms it is over before the first frame finishes, so // neither "up on frame one" nor "it waits" can be true and neither is a // fault. They are skipped and the bound is reported instead. Everything // below them still runs. const bounded = TUNING.ORDER_HOLD_MS > 0; const from = s.prince.x; drive(1 / 60); const upOnFrameOne = bounded || (s.sheaf.up && s.torch.raise > 0.9 && s.band.text === s.letters.byHouse[oh].letter); // IT WAITS. Four seconds of nothing -- comfortably past the hold the spec // originally asked for -- and it is still up and he has not moved. // drive(4); const waits = bounded || (!s.opening.done && s.sheaf.up && s.band.text === s.letters.byHouse[oh].letter && Math.abs(s.prince.x - from) < 0.001); if (bounded) { scene.reset(); scene.openAgain(); } // ONE FRAME OF EVERYTHING AT ONCE ends it, and that frame costs nothing. // // This first asserted he could not move "while the page is up with input // held", which is not a state that exists: any input ends the beat, so the // frame after the first one he is already free and of course he walks. The // property that is actually worth having is that the frame which ENDS it // is spent ending it -- he does not also step, and he does not also swing. scene.update(1 / 60, { ...NONE, right: true, attack: true, pickup: true }); const anyInputEndsIt = s.opening.done; const frozen = Math.abs(s.prince.x - from) < 0.001 && !s.prince.attacking; // The page goes down on the existing ramp and he can walk. // // THE BAND STOPS READING HIS ORDERS, which is not the same as closing. At // ?at=gate he starts 320px from the gatehouse, and reading his orders by // torchlight lights its letter too — so with the orders down and him stood // still, the band reads that door. That is the rule for a lit door, not // the order hanging on. drive(0.8); const lowered = !s.sheaf.up && s.torch.raise < 0.01 && (s.band.alpha < 0.01 || s.band.text !== s.letters.byHouse[oh].letter); drive(0.5, { right: true }); const controlsLive = s.prince.x > from + 100; // AND THE PRESS THAT SKIPPED DOES NOT ALSO SWING. The real input object is // not the one the beat ran on, so its one-shots were never cleared -- this // is the single thing here a human playtest would not reliably catch. scene.reset(); scene.openAgain(); const live = { ...NONE, attack: true }; scene.update(1 / 60, live); // ends it, and must eat the press const swungOnTheSkip = s.prince.attacking; scene.update(1 / 60, { ...NONE }); // R DOES NOT REPLAY IT. Dying and restarting must not cost a second read. scene.reset(); drive(1 / 60); const resetDoesNotReplay = s.opening.done && !s.sheaf.up; return { name: 'the opening holds', pass: upOnFrameOne && waits && frozen && anyInputEndsIt && lowered && controlsLive && !swungOnTheSkip && resetDoesNotReplay, upOnFrameOne, waitsForHim: waits, nothingMovesHim: frozen, // 0 is the shipping value: no bound, it waits for him. upperBoundMs: TUNING.ORDER_HOLD_MS, anyInputEndsIt, loweredAfter: lowered, controlsLive, theSkipDoesNotSwing: !swungOnTheSkip, resetDoesNotReplay, }; } // --- wrong doors ---------------------------------------------------------------- // // Played through, the level was finished without reading a word: pin a page, // see who walks, try the next door. The walk home was an answer key. So a // stranger's house now wakes the owner, and it comes for him — guessing is // still allowed, and it costs blood. See docs/specs/astara-wrong-door-addendum.md. /** States a body passes through over the next few seconds, in order. */ const statesOver = (d, secs) => { const seen = [d.state]; for (let i = 0; i < Math.round(secs * 60); i++) { scene.update(1 / 60, NONE); if (seen[seen.length - 1] !== d.state) seen.push(d.state); } return seen; }; /** Put a body down on the spot. The fixture armed() already uses. */ const putDown = (d) => { d.hits = 99; d.takeHit(d.x - 1); drive(1.0); }; /** * A WRONG DOOR WAKES THEM. * * Every carrier, the gatekeeper included: pinned on a door that is not * theirs, the owner rises and comes — RISING, then RUSH, never homing — and * the page stays on the wrong wall. Knocked down, it can be woken again by * the next wrong pin, as often as he makes one. Pinned right, it goes home. * * AND THE BEAT THE RULES MAKE WITHOUT ANYONE DESIGNING IT. `sendHome` from * RUSH goes straight to HOMING, so the way to stop someone coming for you is * to put their words where they belong. A woken body chasing him from beyond * its own door: he pins it right, and it walks up to its door, stops short of * him, turns its back and never touches him. Asserted, because it falls out * of two methods sitting side by side and nothing else protects it. */ function wrongDoorWakes() { const rows = []; for (const c of armed()) { const k = c.kept; // A door that is not theirs AND not finished with. A door filed right is // locked, and the rows before this one have filed theirs right. const w = s.houses.map((_, i) => (k + 1 + i) % s.houses.length) .find((i) => i !== k && !s.houses[i].settled); atDoor(w, k); tap({ sheaf: true }); const first = statesOver(c, 2.5); const woke = first.includes('RISING') && c.state === 'RUSH' && !c.homing && c.revealed; const leftUp = s.houses[w].filed === k; putDown(c); const downed = c.state === 'DOWNED'; const stillUp = s.houses[w].filed === k; atDoor(w, k); tap({ sheaf: true }); // take it back down... const retrieved = s.houses[w].filed == null; atDoor(w, k); tap({ sheaf: true }); // ...and pin it wrong again const again = statesOver(c, 2.5); const wokeAgain = again.includes('RISING') && c.state === 'RUSH' && !c.homing; putDown(c); atDoor(w, k); tap({ sheaf: true }); // off the wrong wall atDoor(k, k); tap({ sheaf: true }); // onto its own const home = c.homing && (c.state === 'RISING' || c.state === 'HOMING') && c.homeX === s.houses[k].x; rows.push({ kept: k, wrongDoor: w, woke, pageStaysUp: leftUp && stillUp, downed, retrieved, wokeAgain, goesHomeWhenRight: home, states: first.join(' > ') }); } // The beat. A carrier that fell beyond its own door, from where he pins, // and a wrong door on his side of it — so it chases him past nothing, and // its own door is between them when he puts the page up. const beat = (() => { for (const seed of [7, 11, 23, 41, 97, 131]) { for (const c of armed(seed).filter((d) => !d.post)) { const k = c.kept; const door = s.houses[k].x; if (!(c.x > door + 300)) continue; const w = s.houses.findIndex((h, i) => i !== k && h.x < door - 300); if (w < 0) continue; atDoor(w, k); tap({ sheaf: true }); // wrong: it wakes atDoor(w, k); tap({ sheaf: true }); // straight back down // He goes to its door and waits; it comes for him, still beyond it. s.prince.x = s.prince.prevX = door - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); let t = 0; while (!(c.state === 'RUSH' && c.x - s.prince.x < W / 2) && t < 60 * 60) { scene.update(1 / 60, NONE); t++; } const closing = c.state === 'RUSH' && c.x - s.prince.x < W / 2 && c.x > door; const cameAtHim = c.facing === -1; const hits0 = s.prince.hits; atDoor(k, k); tap({ sheaf: true }); // right: it stops coming const stopped = c.state === 'HOMING' && c.homing && c.homeX === door; let u = 0, hurt = false, nearest = Infinity; while (c.state !== 'HOME' && u < 60 * 60) { scene.update(1 / 60, NONE); u++; if (s.prince.hits > hits0) hurt = true; nearest = Math.min(nearest, Math.abs(c.x - s.prince.x)); } return { seed, kept: k, secondsToClose: +(t / 60).toFixed(1), closing, cameAtHim, stopped, atItsDoor: c.state === 'HOME' && Math.round(c.x) === door, turnedItsBack: c.facing === Math.sign(c.x - s.prince.x), neverTouchedHim: !hurt, closestItCame: Math.round(nearest), }; } } return null; })(); scene.reset(); return { name: 'a wrong door wakes them', pass: rows.length === 4 && rows.every((r) => r.woke && r.pageStaysUp && r.downed && r.retrieved && r.wokeAgain && r.goesHomeWhenRight) && !!beat && beat.closing && beat.cameAtHim && beat.stopped && beat.atItsDoor && beat.turnedItsBack && beat.neverTouchedHim, rows, theBeat: beat, }; } /** * THE GATEKEEPER WAKES AT HIS POST. * * He is the first page most players pin, and pinned wrong he comes for them * with the spear — the hardest fight in the level, and the first time a * player learns that where things go matters. But he does not leave the * gate: pinned from the far end of the village he wakes and waits there, * and at the gate he braces, thrusts and shoves, exactly as before. */ function gatekeeperWakesAtHisPost() { armed(); const gk = gatekeeper(); const post = gk.x; atDoor(0, gk.kept); // the forge: the far end tap({ sheaf: true }); const seen = statesOver(gk, 3); const woke = seen.includes('RISING') && gk.state === 'RUSH' && gk.revealed && !gk.homing; const stayedWhileHeWasAway = gk.x === post; s.prince.x = s.prince.prevX = s.gateStopX; s.prince.facing = 1; s.camera.reset(s.prince.x); const hits0 = s.prince.hits; const phases = new Set(); let shoved = false, moved = false; for (let i = 0; i < 60 * 4; i++) { const before = s.prince.x; scene.update(1 / 60, NONE); phases.add(gk.phase); if (s.prince.x < before - 60) shoved = true; if (gk.x !== post) moved = true; } const hitsTaken = s.prince.hits - hits0; scene.reset(); return { name: 'the gatekeeper wakes at his post', pass: woke && stayedWhileHeWasAway && !moved && phases.size >= 3 && shoved, woke, states: seen.join(' > '), stayedWhileHeWasAway, movedAtTheGate: moved, phasesSeen: [...phases].sort(), shovedHim: shoved, hitsItCostHim: hitsTaken, }; } /** * THE TREE TEACHES THE RULE, AND NOTHING ELSE. * * A note nailed to a tree on the road: they cannot speak, what they carried * is all that is left of them, take it home. It stands past the first * drained, so "they" has somebody to mean, and before the village, so it is * read before the first fight — and far enough from every drained that * lighting it wakes nobody. * * It reads exactly the way a door does. Light it and the band reads it; * after that, standing by it reads it again with the torch down; walking * past, it does not. It STAYS ON THE TREE, like every letter stays on its * door, so it is never a page. And it is not a door: holding a fragment * there, nothing is offered, and E does nothing. */ function theTreeTeaches() { scene.reset(); const t = s.tree; if (!t) return { name: 'the tree teaches', pass: false, why: 'there is no tree' }; const note = s.letters.byHouse[t.index]?.letter ?? null; const road = s.drained.filter((d) => !s.village.includes(d)); const first = Math.min(...road.map((d) => d.x)); const clearOfAll = Math.min(...road.map((d) => Math.abs(d.x - t.x))); const placed = t.x > first + TUNING.TREE_CLEAR && t.x < BOWL.X0 && clearOfAll >= TUNING.TREE_CLEAR; const settle = TUNING.READ_STILL_MS / 1000 + 0.4; // Up to it in the dark, and stood there: nothing read. The first read // needs light. s.prince.x = s.prince.prevX = t.x - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(settle); const unreadInTheDark = !t.seen && s.band?.text !== note; // Lit: the band reads it, and nobody wakes. drive(1.4, { torch: true }); const reads = t.seen && s.band?.text === note; const nobodyWoke = s.drained.every((d) => !d.revealed); // Torch down and still stood by it: it reads again, for nothing. drive(settle); const readsAgain = s.torch.raise < 0.01 && s.band?.text === note; // Walking past it, it does not. Every frame of a walk from one side of // its reach to the other. let openWhileWalking = 0; let frames = 0; while (s.prince.x < t.x + TUNING.DOOR_REACH + 100 && frames < 60 * 20) { scene.update(1 / 60, { ...NONE, right: true }); if (s.band?.text) openWhileWalking++; frames++; } const closedWhileWalking = openWhileWalking === 0 && frames > 0; // It stays on the tree: E there does nothing, holding a fragment or not, // and the pile is what it was. s.prince.x = s.prince.prevX = t.x - 300; s.camera.reset(s.prince.x); s.prince.inventory.push({ kept: 0, answers: null, seen: true }); s.sheaf.pages.push({ kind: 'fragment', kept: 0 }); s.sheaf.index = s.sheaf.pages.length - 1; const pile = s.sheaf.pages.length; drive(0.5, { torch: true }); drive(0.3); const nothingGoesBack = !s.canPin && !s.canMark; tap(); s.sheaf.index = 0; tap(); const staysOnTheTree = s.sheaf.pages.length === pile && s.sheaf.pages.every((p) => p.kind !== 'letter' || p.house !== t.index); const stillNobodyWoke = s.drained.every((d) => !d.revealed); scene.reset(); const unlitAfterARestart = !t.seen; return { name: 'the tree teaches', pass: !!note && placed && unreadInTheDark && reads && nobodyWoke && readsAgain && closedWhileWalking && nothingGoesBack && staysOnTheTree && stillNobodyWoke && unlitAfterARestart, at: t.x, pastTheFirstDrained: t.x - first, clearOfEveryDrained: clearOfAll, clearFloor: TUNING.TREE_CLEAR, beforeTheVillage: t.x < BOWL.X0, unreadInTheDark, readsUnderTheTorch: reads, nobodyWoke, readsAgainWithTheTorchDown: readsAgain, framesOpenWhileWalkingPast: openWhileWalking, walkedFrames: frames, nothingGoesBack, staysOnTheTree, stillNobodyWoke, unlitAfterARestart, }; } // --- letters stay on the door ------------------------------------------------ // // Letters stay where they were left; fragments come off bodies. He does not // pocket a stranger's mail, and the door becomes where he judges: the // question nailed in front of him, the answer in his hand. See // docs/specs/astara-letters-stay-addendum.md. /** * THE LETTERS STAY, on every door and on the tree, whatever he does. * * There is no state for it any more — nothing can take one down — so this * looks at the wall. At the start of a run, after every input there is at * every door, and at the end of a whole run with the gate open: the letter's * rect is paper. And not once in all of that does a letter from a wall turn * up in the sheaf. * * PAPER IS COUNTED, NOT AVERAGED. The share of the rect brighter than 140, * which nothing on a wall at night comes near except a lit page — and two * renders of the same frame are the grain floor it has to clear. */ function lettersStay() { return withOverlayOff(() => { const orderAt = orderHouse(); let wallLettersHeld = 0; const note = () => { wallLettersHeld += s.sheaf.pages .filter((p) => p.kind === 'letter' && p.house !== orderAt).length; }; const targets = [...s.houses, ...(s.tree ? [s.tree] : [])]; const clipOf = (t) => (t === s.tree ? s.treeClip : s.houseClip); const rectOf = (t) => clipOf(t).letterAt[t.variant]; // WHERE HE STANDS: 130px short of the letter, which is h.x - 300 at a // house — where every check and every player reads a door from — and // the same distance from the note on the tree. const standX = (t) => { const r = rectOf(t); return t.x - clipOf(t).pivotX + r[0] + r[2] / 2 - 130; }; const paper = (t) => { const clip = clipOf(t); const [rx, ry, rw, rh] = rectOf(t); const d = ctx.getImageData(Math.round(t.x - s.camera.x - clip.pivotX + rx), Math.round(GROUND_Y - clip.pivotY + ry), rw, rh).data; let n = 0; for (let i = 0; i < d.length; i += 4) if (lum(d, i) > 140) n++; return n / (d.length / 4); }; const shot = () => { s.clock = 3.0; renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); }; const look = (t) => { s.prince.x = s.prince.prevX = standX(t); s.prince.facing = 1; s.camera.reset(s.prince.x); drive(1.4, { torch: true }); shot(); const a = paper(t); shot(); const floor = Math.abs(paper(t) - a); note(); return { at: t.kind, paper: +a.toFixed(3), floor: +floor.toFixed(3), present: a > Math.max(0.35, floor * 4) }; }; // 1. FROM THE FIRST FRAME. Every carrier down and every fragment taken, // and every letter is still where it was left. const carriers = armed(); const atStart = targets.map(look); // 2. AND NOTHING HE CAN DO AT A DOOR TAKES ONE DOWN. E with his orders // on top, Q, SPACE, F — and E with the house's own fragment, which // pins it and sends its owner home. const afterInputs = targets.map((t) => { look(t); s.sheaf.index = 0; tap(); drive(0.6, { sheaf: true }); drive(0.3); drive(0.6, { torch: true }); drive(0.3); drive(0.1, { attack: true }); drive(0.6); const own = t === s.tree ? -1 : s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === t.index); if (own >= 0) { s.sheaf.index = own; tap(); } note(); return look(t); }); // 3. A WHOLE RUN. Everybody who can go home does, four keys come off // four hooks, and the gate opens on them. let ticks = 0; while (s.drained.some((d) => d.homing) && ticks < 60 * 250) { scene.update(1 / 60, NONE); ticks++; } for (const h of s.houses) { if (!h.open) continue; s.prince.x = s.prince.prevX = h.x - 300; s.camera.reset(s.prince.x); drive(1.4, { torch: true }); s.sheaf.index = 0; tap(); } note(); s.prince.x = s.prince.prevX = s.gateStopX; s.camera.reset(s.prince.x); drive(0.3); for (let i = 0; i < TUNING.GATE_WANTS; i++) tap(); drive(2 + TUNING.GATE_OPEN_MS / 1000); const gateOpen = s.gate.open >= 1; note(); const atTheEnd = targets.map(look); const all = (rows) => rows.every((r) => r.present); return { name: 'letters stay', pass: carriers.length === TUNING.GATE_WANTS && targets.length === 6 && all(atStart) && all(afterInputs) && gateOpen && all(atTheEnd) && wallLettersHeld === 0, onEveryWallAtTheStart: all(atStart), afterEveryInputAtEveryDoor: all(afterInputs), andAtTheEndOfARunWithTheGateOpen: gateOpen && all(atTheEnd), wallLettersEverInTheSheaf: wallLettersHeld, atStart, afterInputs, atTheEnd, }; }); } /** * THE SLOT IS ALWAYS THERE, from the first light, at every door. * * It is the affordance, and it used to be earned: no slot until the letter * came off. Now a bare nail and a lighter patch sit under the letter from the * first moment the torch finds the wall — before anything is pinned, and * before anything is read. Rendered with the door's latch wiped, to prove * the wall draws it off nothing but the light. * * AND IT IS STILL. A value, not a glow: over a whole pulse cycle the patch * and the wall round it do not move. The tree has no slot at all. */ function theSlotIsAlwaysThere() { return withOverlayOff(() => { const clip = s.houseClip; const box = (h, pad = 0) => { const [nx, ny, nw, nh] = clip.slotAt[h.variant]; return [Math.round(h.x - s.camera.x - clip.pivotX + nx) - pad, Math.round(GROUND_Y - clip.pivotY + ny) - pad, nw + pad * 2, nh + pad * 2]; }; const brightness = (d) => { let sum = 0; for (let i = 0; i < d.length; i += 4) sum += lum(d, i); return Math.round(sum / (d.length / 4)); }; const render = (clock = 3.0) => { s.clock = clock; renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); }; const rows = s.houses.map((h) => { scene.reset(); s.prince.x = s.prince.prevX = h.x - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(1.4, { torch: true }); // FORGET HE EVER LOOKED. The render reads state only, so with the // latch wiped nothing in the scene says this door was lit or read. h.seen = false; render(); const slot = brightness(ctx.getImageData(...box(h)).data); const [bx, by, bw, bh] = box(h); const wall = brightness(ctx.getImageData(bx, by + bh + 8, bw, bh).data); render(); const floor = Math.abs(brightness(ctx.getImageData(...box(h)).data) - slot); const seenPulse = []; for (let i = 0; i < 12; i++) { render(3.0 + (i / 12) / TORCH.KEPT_PULSE_HZ); seenPulse.push(brightness(ctx.getImageData(...box(h, 26)).data)); } render(); return { house: h.kind, slot, wall, floor, movesOverAPulse: Math.max(...seenPulse) - Math.min(...seenPulse), nothingPinned: h.filed == null }; }); const everyDoor = rows.every((r) => r.nothingPinned && r.slot > r.wall + 40 && r.slot - r.wall > r.floor * 3); const still = rows.every((r) => r.movesOverAPulse <= 2); const treeHasNone = !!s.treeClip && !s.treeClip.slotAt; scene.reset(); return { name: 'the slot is always there', pass: everyDoor && still && treeHasNone, onEveryDoorFromTheFirstLight: everyDoor, andItDoesNotGlow: still, theTreeHasNone: treeHasNone, rows, }; }); } /** * THE BAND READS THE DOOR, and it is one button to compare. * * Q held the page in his hand. Wins * the torch on a letter, in reach that letter. The first read * standing still, in reach of a lit door that door's letter * otherwise closed * * THE FIRST READ NEEDS LIGHT; EVERY ONE AFTER IS FREE. On a phone TORCH and * PAGES share a thumb, so comparing a door with the page in his hand cannot * need both: hold PAGES for the paper, let go for the door. * * And the band never reads one door while E would act on another. Sampled * on every frame this check runs. */ function bandReadsTheDoor() { const wait = TUNING.READ_STILL_MS; const letterOf = (h) => s.letters.byHouse[h.index].letter; let frames = 0; let notTheDoorEActsOn = 0; const step = (input = {}) => { scene.update(1 / 60, { ...NONE, ...input }); frames++; if (s.band.text) { const h = s.houses.find((hh) => letterOf(hh) === s.band.text); if (h && h !== s.nearDoor) notTheDoorEActsOn++; } }; const run = (secs, input = {}, each = null) => { const n = Math.round(secs * 60); for (let i = 0; i < n; i++) { step(input); if (each) each(i); } }; const standAt = (x) => { s.prince.x = s.prince.prevX = x; s.prince.facing = 1; s.camera.reset(x); }; scene.reset(); const forge = s.houses[0]; const L = letterOf(forge); // 1. A DOOR HE HAS NEVER LIT: closed, however long he stands at it. standAt(forge.x - 300); run(wait / 1000 + 0.5); const neverLitIsClosed = !forge.seen && s.band.text == null; // 2. THE FIRST READ IS THE TORCH'S. Walk in, stop, raise it: it reads // before standing still could have opened it. standAt(forge.x - 400); run(0.2, { right: true }); let firstRead = -1; run(0.5, { torch: true }, (i) => { if (firstRead < 0 && s.band.text === L) firstRead = i + 1; }); const firstReadMs = Math.round(firstRead * 1000 / 60); const theTorchReadsIt = firstRead > 0 && firstReadMs < wait && forge.seen; // 3. TORCH DOWN, STILL STOOD THERE: the same letter again, for nothing. run(0.3); run(wait / 1000 + 0.2); const readsAgainInTheDark = s.torch.raise < 0.01 && s.band.text === L; // 4. COMPARING IS ONE BUTTON. A fragment in his hand; hold Q and the band // reads it, let go and it reads the door. Twice, and the torch is never // pressed. It blanks at every switch: two pieces of paper, not a label. s.prince.inventory.push({ kept: 2, answers: null, seen: true }); s.sheaf.pages.push({ kind: 'fragment', kept: 2 }); s.sheaf.index = s.sheaf.pages.length - 1; const F = s.letters.byHouse[2].fragment; const switches = []; let last = s.band.text; const watch = () => { if (s.band.text && s.band.text !== last) { switches.push({ to: s.band.text === F ? 'page' : s.band.text === L ? 'door' : 'other', alpha: +s.band.alpha.toFixed(3) }); } if (s.band.text) last = s.band.text; }; for (let k = 0; k < 2; k++) { run(0.8, { sheaf: true }, watch); run(0.6, {}, watch); } const order = switches.map((w) => w.to).join(','); const oneButton = order === 'page,door,page,door'; const blanksEachTime = switches.length > 0 && switches.every((w) => w.alpha < 0.1); // 5. EVERY DOOR LIT, THEN THE WHOLE STREET WALKED WITH THE TORCH DOWN: // closed on every frame he is walking. for (const h of s.houses) { standAt(h.x - 300); run(1.4, { torch: true }); } const allLit = s.houses.every((h) => h.seen); standAt(s.houses[0].x - 700); const end = s.houses[s.houses.length - 1].x + 600; let walked = 0; let openWhileWalking = 0; while (s.prince.x < end && walked < 60 * 60) { step({ right: true }); walked++; if (s.band.text) openWhileWalking++; } const closedWhileWalking = allLit && walked > 60 && openWhileWalking === 0; // 6. STOPPING AT A LIT DOOR OPENS IT AFTER READ_STILL_MS, AND NOT BEFORE. standAt(forge.x - 700); // Bounded: a check must never hang the runner. If he cannot walk, the // claim below fails and says so. for (let n = 0; s.prince.x < forge.x - 300 && n < 60 * 5; n++) step({ right: true }); let opened = -1; for (let i = 0; i < 60; i++) { step(); if (opened < 0 && s.band.text === L) opened = i + 1; } const openedMs = Math.round(opened * 1000 / 60); const waitsThenOpens = opened > 0 && openedMs >= wait - 1 && openedMs <= wait + 50; // 7. A SWING IS NOT STANDING STILL. Nothing flashes up mid-fight. let swingFrames = 0; let openWhileSwinging = 0; step({ attack: true }); while (s.prince.attacking && swingFrames < 120) { swingFrames++; if (s.band.text) openWhileSwinging++; step(); } const closedWhileSwinging = swingFrames > 0 && openWhileSwinging === 0; // 8. OUT OF REACH: closed. standAt(forge.x - 900); run(wait / 1000 + 0.5); const closedOutOfReach = s.band.text == null; scene.reset(); return { name: 'the band reads the door', pass: neverLitIsClosed && theTorchReadsIt && readsAgainInTheDark && oneButton && blanksEachTime && closedWhileWalking && waitsThenOpens && closedWhileSwinging && closedOutOfReach && notTheDoorEActsOn === 0, neverLitIsClosed, theTorchReadsItFirst: theTorchReadsIt, firstReadAfterMs: firstReadMs, readsAgainWithTheTorchDown: readsAgainInTheDark, comparingIsOneButton: oneButton, switches: order, andItBlanksAtEachSwitch: blanksEachTime, closedWhileWalkingPastLitDoors: closedWhileWalking, framesWalked: walked, opensAfterStandingStillMs: openedMs, readStillMs: wait, waitsThenOpens, closedWhileSwinging, closedOutOfReach, framesReadingADoorEWouldNotActOn: notTheDoorEActsOn, framesSampled: frames, }; } /** * THE DOOR CARRIES FOUR THINGS: the letter, the slot, the key and the room. * * None may cover another — except the key, which has always hung INSIDE the * room by the jamb, because that is where a key to your own front door goes. * The room is measured off the art rather than written down: the lit * doorway's own pixels, and 16px round them for the things that poke out of * it, the forge's anvil horn furthest. * * AND BOTH PAPERS ARE LIT FROM WHERE HE STANDS, on every frame of three * seconds of flicker rather than on one: a check that passes or fails on * which frame it sampled is not a check. Within DOOR_REACH too, which is * where E and the band both measure from. */ function theDoorCarriesFour() { const clip = s.houseClip; const clear = (a, b) => a[0] + a[2] <= b[0] || b[0] + b[2] <= a[0] || a[1] + a[3] <= b[1] || b[1] + b[3] <= a[1]; const roomBox = (v) => { const off = new OffscreenCanvas(clip.width, clip.height); const o = off.getContext('2d', { willReadFrequently: true }); o.drawImage(clip.frames[v].lit, 0, 0); const d = o.getImageData(0, 0, clip.width, clip.height).data; let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; for (let y = 0; y < clip.height; y++) { for (let x = 0; x < clip.width; x++) { const i = (y * clip.width + x) * 4; if (d[i + 3] < 250 || Math.abs(d[i] - 0xd8) > 6 || Math.abs(d[i + 1] - 0xc3) > 6 || Math.abs(d[i + 2] - 0xa0) > 6) continue; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; } } return [x0 - 16, y0 - 16, x1 - x0 + 33, y1 - y0 + 33]; }; const rows = s.houses.map((h) => { const L = clip.letterAt[h.variant]; const S = clip.slotAt[h.variant]; const K = clip.keyAt[h.variant]; const R = roomBox(h.variant); scene.reset(); s.prince.x = s.prince.prevX = h.x - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(0.6, { torch: true }); // The margin by which each paper is inside the reveal circle, at the // worst frame. Positive is lit. let letterMargin = Infinity; let slotMargin = Infinity; const inside = (p) => REVEAL_T * s.torch.radius - Math.hypot(p[0] - s.torch.cx, p[1] - s.torch.cy); for (let i = 0; i < 180; i++) { scene.update(1 / 60, { ...NONE, torch: true }); letterMargin = Math.min(letterMargin, inside(s.letterPoint(h))); slotMargin = Math.min(slotMargin, inside(s.slotPoint(h))); } const lp = s.letterPoint(h); const sp = s.slotPoint(h); return { house: h.kind, room: R, letterClearOfSlot: clear(L, S), letterClearOfKey: clear(L, K), slotClearOfKey: clear(S, K), letterClearOfRoom: clear(L, R), slotClearOfRoom: clear(S, R), keyInsideTheRoom: !clear(K, R), letterLitEveryFrame: letterMargin > 0, slotLitEveryFrame: slotMargin > 0, worstLetterMargin: Math.round(letterMargin), worstSlotMargin: Math.round(slotMargin), inReach: Math.abs(s.prince.x - lp[0]) <= TUNING.DOOR_REACH && Math.abs(s.prince.x - sp[0]) <= TUNING.DOOR_REACH, }; }); scene.reset(); const ok = rows.every((r) => r.letterClearOfSlot && r.letterClearOfKey && r.slotClearOfKey && r.letterClearOfRoom && r.slotClearOfRoom && r.keyInsideTheRoom && r.letterLitEveryFrame && r.slotLitEveryFrame && r.inReach); return { name: 'the door carries four', pass: ok && rows.length === 5, everyDoor: ok, rows, }; } /** * THE GATEWAY IS HIS ONCE ITS KEEPER HAS GONE HOME. * * While the gatekeeper holds his post, the shut leaf stops the prince just * inside the left post. Once the keeper has walked home, he can walk the * whole width of the leaf, under all four sockets, to just inside the right * post. Found in play: stood at the left edge, a player could not get close * enough to count what the gate wanted. The view follows him in far enough to * frame the whole gateway. * * AND THE LEAF STILL HAS TO LIFT, AND HE STILL HAS TO WALK. Standing under * it when it opens does not end the level; a step through it does. */ function theGateRoomOpens() { // The posts' outer edges, as render.js draws the gate: a 420px half-leaf // and a 100px post each side. const EDGE = 520; const toGate = (secs = 6) => { s.prince.x = s.prince.prevX = s.gateX - 900; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(secs, { right: true }); }; const keeper = gatekeeper(); // 1. Down, but at his post: the old stop. A fight does not move him. const carriers = armed(); toGate(); const heldShut = Math.round(s.prince.x) === s.gateStopX; // 2. His scrap on his own door, and he gets up and walks home. atDoor(4, 4); tap({ sheaf: true }); drive(3); const goneHome = keeper.state === 'HOMING' || keeper.state === 'HOME'; // 3. The whole gateway, and no further — with all of it in the frame. toGate(); const walkedIn = Math.round(s.prince.x) === s.gateRoomX; const cam = s.camera.x; const framed = s.gateX - EDGE - cam >= 0 && s.gateX + EDGE - cam <= W && s.prince.x - cam <= W - 200; // 4. Walking into the far post with the leaf shut ends nothing. drive(1, { right: true }); const shutEndsNothing = !s.gate.through && s.fade === 0; // 5. The leaf lifts over him while he stands under it, and he is still // there. One step takes him through. s.prince.x = s.prince.prevX = s.gateX + 100; s.prince.keys = TUNING.GATE_WANTS; s.sheaf.index = 0; for (let i = 0; i < TUNING.GATE_WANTS; i++) tap(); drive(1 + TUNING.GATE_OPEN_MS / 1000); const openOverHim = s.gate.open >= 1 && !s.gate.through; drive(0.3, { right: true }); const aStepTakesHimThrough = s.gate.through; // 6. A restart puts the keeper back on his post, and the gateway shuts. scene.reset(); toGate(); const shutAgain = Math.round(s.prince.x) === s.gateStopX; return { name: 'the gate room opens', pass: carriers.length === TUNING.GATE_WANTS && heldShut && goneHome && walkedIn && framed && shutEndsNothing && openOverHim && aStepTakesHimThrough && shutAgain, whileHeHoldsThePost: heldShut, keeperWalkedHome: goneHome, walksTheWholeGateway: walkedIn, stopsAt: Math.round(s.gateRoomX - s.gateX), theWholeGatewayIsInFrame: framed, shutEndsNothing, openOverHimAndStillThere: openOverHim, aStepTakesHimThrough, shutAgainAfterARestart: shutAgain, }; } // --- her poses --------------------------------------------------------------- /** * HER POSES FIT THE GAME. The swing, the fall and the torch held up are her * drawings now, cut by tools/import_poses.py, and each was placed against the * standing drawing on purpose. This is what has to stay true if one of them * is ever imported again without the others. * * The torch pose is her standing body with an arm up: raising the torch * must change the arm and nothing else, or he jumps every time he looks. * The arm comes up over four frames, her own arm swung, and the fire sits * on the torch in every one of them -- her flame gone from under it -- and * the light comes out of that fire. * The thrust is on screen when the hit can land, not a frame after. * The fall ends with him on the ground. */ function herPosesFit() { const idle = clips.prince_idle; const torch = clips.prince_torch; const swing = clips.prince_attack; const fall = clips.prince_down; if (!idle || !torch || !swing || !fall) { return { name: 'her poses fit', pass: false, why: 'a pose is missing from the manifest' }; } // Alpha of one frame, on a canvas the size of the clip. const alphaOf = (c, i = 0) => { const off = new OffscreenCanvas(c.width, c.height); const o = off.getContext('2d', { willReadFrequently: true }); o.drawImage(c.frames[i].lit, 0, 0); return o.getImageData(0, 0, c.width, c.height).data; }; const inkAt = (c, a, x, y) => x >= 0 && y >= 0 && x < c.width && y < c.height && a[(y * c.width + x) * 4 + 3] > 60; // 1. THE SAME BODY, IN EVERY FRAME OF THE RAISE. Below the shoulders -- // legs and coat -- each frame, placed by its own pivot, lies on the // standing drawing. const ia = alphaOf(idle); const bodies = []; for (let i = 0; i < torch.count; i++) { const ta = alphaOf(torch, i); let both = 0, either = 0; for (let dy = -Math.round(idle.pivotY * 0.55); dy <= 0; dy++) { for (let dx = -idle.pivotX; dx < idle.width - idle.pivotX; dx++) { const a = inkAt(idle, ia, idle.pivotX + dx, idle.pivotY + dy); const b = inkAt(torch, ta, torch.pivotX + dx, torch.pivotY + dy); if (a && b) both++; if (a || b) either++; } } bodies.push(either > 0 ? +(both / either).toFixed(3) : 0); } const sameBody = Math.min(...bodies); // 2. THE FIRE ON HER TORCH, IN EVERY FRAME, AS HE IS SEEN: in the black // shape drawn on screen, something under where the fire stands and // nothing of her flame above it. And it climbs, frame by frame. const hands = torch.handPivots ?? [torch.handPivot]; const darkOf = (i) => { const off = new OffscreenCanvas(torch.width, torch.height); const o = off.getContext('2d', { willReadFrequently: true }); o.drawImage(torch.frames[i].dark, 0, 0); return o.getImageData(0, 0, torch.width, torch.height).data; }; const fires = hands.map(([hx, hy], i) => { const da = darkOf(i); let under = 0, above = 0; for (let x = hx - 8; x <= hx + 8; x++) { for (let y = hy + 1; y <= hy + 10; y++) if (inkAt(torch, da, x, y)) under++; for (let y = hy - 30; y <= hy - 6; y++) if (inkAt(torch, da, x, y)) above++; } return { under, above }; }); const climbs = hands.every((h, i) => i === 0 || h[1] < hands[i - 1][1]); const fireOnTheTorch = hands.length === torch.count && fires.every((f) => f.under > 20 && f.above === 0) && climbs && !torch.torchArm; // 3. A RAISE SHOWS EVERY FRAME IN ORDER, AND LOWERING PLAYS THEM BACK -- // and on every one of those frames the fire stands where that frame's // torch is, and the light comes out of the fire. scene.reset(); const shown = [], off = []; const sample = (input) => { scene.update(1 / 60, { ...NONE, ...input }); if (s.prince.clip !== torch) return; if (shown[shown.length - 1] !== s.prince.frame) shown.push(s.prince.frame); const [hx, hy] = hands[s.prince.frame]; const ex = s.prince.x + (hx - torch.pivotX) * s.prince.facing; const ey = GROUND_Y + (hy - torch.pivotY); if (Math.abs(s.torch.handX - ex) > 0.5 || Math.abs(s.torch.handY - ey) > 0.5 || Math.abs(s.torch.cx - s.torch.handX) > 0.5 || Math.abs(s.torch.cy - (s.torch.handY - torch.fireCentre)) > 0.5) off.push(s.prince.frame); }; for (let i = 0; i < 60; i++) sample({ torch: true }); const up = shown.join(','); shown.length = 0; for (let i = 0; i < 60; i++) sample({}); const down = shown.join(','); scene.reset(); // Lowering starts FROM the last frame, which the raise ended on, so what it // shows after that is every frame below it, top down. const order = [...Array(torch.count).keys()]; const playsInOrder = up === order.join(',') && down === order.slice(0, -1).reverse().join(','); const lightOutOfTheFire = off.length === 0; // 4. THE THRUST WHEN THE HIT CAN LAND. The active window opens at the end of // the windup; the frame on screen then has to be the one she drew the // blade out straight in. const at = (ms) => Math.min(Math.floor((ms / 1000) * swing.fps), swing.count - 1); const thrustOnTheHit = at(TUNING.ATTACK_WINDUP_MS) === swing.hitFrame && at(TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS - 1) === swing.hitFrame && at(TUNING.ATTACK_WINDUP_MS - 1) < swing.hitFrame; // 5. THE FALL ENDS ON THE GROUND. Measured by where the body is -- the // mean height of its ink above the ground -- not by its top: lying on // his back she has him raise an arm, and that one hand is 46% of his // standing height on its own. const meanHeight = (c, i) => { const a = alphaOf(c, i); let sum = 0, n = 0; for (let y = 0; y < c.height; y++) { for (let x = 0; x < c.width; x++) { if (a[(y * c.width + x) * 4 + 3] > 60) { sum += c.pivotY - y; n++; } } } return n ? Math.round(sum / n) : 0; }; const standing = meanHeight(fall, 0), lying = meanHeight(fall, fall.count - 1); const endsOnTheGround = lying < standing * 0.35; return { name: 'her poses fit', pass: sameBody > 0.9 && fireOnTheTorch && playsInOrder && lightOutOfTheFire && thrustOnTheHit && endsOnTheGround, torchBodyLiesOnTheStandingBody: bodies, fireOnTheTorch, fires, fireClimbs: climbs, raiseShowsFrames: up, lowerShowsFrames: down, playsInOrder, lightOutOfTheFire, framesWithTheFireOffItsTorch: off.length, thrustOnTheHit, fallStandsAt: standing, fallLiesAt: lying, endsOnTheGround, }; } // --- the voices --------------------------------------------------------------- // // Every paper is read aloud by whoever wrote it, while the band shows it. // Sound is muted while checks run, so these watch what the scene ASKS the // audio for -- the three calls a voice goes through -- and leave the rest // of the real, silent object alone. /** * Stand in for the voices. `unlocked: false` is a browser before its first * press: every request is refused until unlock(). */ function fakeVoices({ unlocked = true } = {}) { const saved = { voice: audio.voice, stopVoice: audio.stopVoice, canSpeak: audio.canSpeak }; const log = []; let playing = null; let overlaps = 0; let open = unlocked; audio.voice = (name) => { if (!open) return false; if (playing) overlaps++; playing = name; log.push({ voice: name, at: s.clock }); return true; }; audio.stopVoice = () => { if (playing) log.push({ stop: playing, at: s.clock }); playing = null; }; audio.canSpeak = () => true; return { log, get playing() { return playing; }, get overlaps() { return overlaps; }, unlock() { open = true; }, restore() { Object.assign(audio, saved); }, }; } /** * EVERY PAPER HAS ITS VOICE: each letter and each fragment names a recording, * the file is there, and no two papers share one. And the meter that levels * them is right -- a 1 kHz tone at -20 dBFS is -23 LUFS by BS.1770's own * definition, so that is what it has to read. */ function everyPaperHasAVoice() { const found = (file) => { if (!file) return null; const x = new XMLHttpRequest(); x.open('HEAD', file, false); try { x.send(); } catch { return 0; } return x.status; }; const rows = s.letters.byHouse.map((h) => ({ paper: h.house, letter: found(h.letterVoice), fragment: h.fragment ? found(h.fragmentVoice) : 'none written', })); const papers = s.letters.byHouse.reduce((n, h) => n + (h.letter ? 1 : 0) + (h.fragment ? 1 : 0), 0); const files = s.letters.byHouse.flatMap((h) => [h.letterVoice, h.fragmentVoice]).filter(Boolean); const everyOneThere = rows.every((r) => r.letter === 200 && (r.fragment === 200 || r.fragment === 'none written')); const noneShared = new Set(files).size === files.length; const sr = 48000; const tone = new Float32Array(sr * 3); for (let i = 0; i < tone.length; i++) tone[i] = 0.1 * Math.sin(2 * Math.PI * 1000 * i / sr); const meter = loudness(tone, sr); return { name: 'every paper has a voice', pass: everyOneThere && noneShared && files.length === papers && Math.abs(meter + 23.01) < 0.1, papers, voices: files.length, everyOneThere, noneShared, meterReadsTheReferenceTone: +meter.toFixed(2), rows, }; } /** * THE VOICE READS WHAT THE BAND SHOWS, and nothing else. * * A paper is read once it has been up VOICE_DELAY_MS, from the top; a new * paper stops the old voice first, so two never speak at once; paging past * with an arrow held reads nothing; looking back reads it again; walking * away stops it; and dying stops it. */ function theVoiceReadsWhatTheBandShows() { if (!audio) return { name: 'the voice reads what the band shows', pass: false, why: 'no audio' }; const fake = fakeVoices(); const delay = TUNING.VOICE_DELAY_MS; const frameMs = 1000 / 60; const step = (input = {}) => scene.update(1 / 60, { ...NONE, ...input }); const run = (secs, input = {}) => { for (let i = 0; i < Math.round(secs * 60); i++) step(input); }; const standAt = (x) => { s.prince.x = s.prince.prevX = x; s.prince.facing = 1; s.camera.reset(x); }; const voiced = (from) => fake.log.slice(from).filter((e) => e.voice).map((e) => e.voice); try { scene.reset(); const forge = s.houses[0]; const L = s.letters.byHouse[0].letter; // 1. The torch on the forge's letter: the band opens, and VOICE_DELAY_MS // later Mira reads it. Not a frame before. standAt(forge.x - 300); let opened = -1, spoke = -1; for (let i = 0; i < 90; i++) { step({ torch: true }); if (opened < 0 && s.band.text === L) opened = i; if (spoke < 0 && fake.log.some((e) => e.voice === 'letter 0')) spoke = i; } const waitedMs = Math.round((spoke - opened) * frameMs); const readAfterTheDelay = opened >= 0 && spoke > opened && waitedMs >= delay - frameMs && waitedMs <= delay + 2 * frameMs; // 2. Torch down, stood still: the same paper, so the same reading goes on. let mark = fake.log.length; run(1.2); const notRestartedByTheLight = s.band.text === L && voiced(mark).length === 0 && fake.playing === 'letter 0'; // 3. Hold Q on the well house keeper's scrap: Mira stops and the keeper is // read. Let go: the door is read again, from the top. s.prince.inventory.push({ kept: 2, answers: null, seen: true }); s.sheaf.pages.push({ kind: 'fragment', kept: 2 }); s.sheaf.index = s.sheaf.pages.length - 1; mark = fake.log.length; run(1.2, { sheaf: true }); run(1.2); const loop = fake.log.slice(mark).map((e) => (e.voice ? `read ${e.voice}` : `stop ${e.stop}`)).join(', '); const comparing = loop === 'stop letter 0, read fragment 2, stop fragment 2, read letter 0'; // 4. Paging with an arrow held flips past every page in silence, and // only the one he stops on is read. for (const k of [0, 1, 4]) { s.prince.inventory.push({ kept: k, answers: null, seen: true }); s.sheaf.pages.push({ kind: 'fragment', kept: k }); } s.sheaf.index = 0; run(0.6, { sheaf: true }); mark = fake.log.length; run(1.0, { sheaf: true, right: true }); const flippedPast = voiced(mark); run(1.0, { sheaf: true }); const last = s.page(); const pagedInSilence = flippedPast.length === 0 && voiced(mark).length === 1 && voiced(mark)[0] === `fragment ${last.kept}`; run(0.6); // 5. Walking away closes the band and stops the voice. mark = fake.log.length; run(1.5, { left: true }); const walkingStops = fake.playing === null && fake.log.slice(mark).some((e) => e.stop); // 6. The note on the tree has its own voice. standAt(s.tree.x - 127); mark = fake.log.length; run(1.4, { torch: true }); const treeRead = voiced(mark).includes(`letter ${s.tree.index}`); // 7. Dying stops it. His papers up beside a drained on his last hit: the // light wakes it, it comes, and the voice ends with him. scene.reset(); const v = s.drained[0]; s.prince.inventory.push({ kept: 1, answers: null, seen: true }); s.sheaf.pages.push({ kind: 'fragment', kept: 1 }); s.sheaf.index = s.sheaf.pages.length - 1; s.prince.hits = TUNING.PRINCE_HITS - 1; standAt(v.x - 40); let readWhenHeDied = null; for (let i = 0; i < 60 * 12 && !s.loss.active; i++) { step({ sheaf: true }); if (s.loss.active) readWhenHeDied = fake.playing; } const deathStops = s.loss.active && readWhenHeDied === null && fake.log.some((e) => e.voice === 'fragment 1'); return { name: 'the voice reads what the band shows', pass: readAfterTheDelay && notRestartedByTheLight && comparing && pagedInSilence && walkingStops && treeRead && deathStops && fake.overlaps === 0, readAfterMs: waitedMs, delayMs: delay, readAfterTheDelay, notRestartedByTheLight, comparingDoorAndPaper: loop, pagedInSilence, walkingStops, treeRead, deathStops, twoAtOnce: fake.overlaps, }; } finally { fake.restore(); scene.reset(); } } /** * HIS ORDERS ARE READ TO HIM, and the first press is what lets them be. * * A browser makes no sound before a press, so that press asks for the * reading and the page stays up; the next one puts it away. A key still held * from the first does not count. If the voice never comes, a press after * VOICE_WAIT_MS ends it as it always did -- and with sound already on he * hears it at once, and with sound off nothing about the opening changed. */ function theOrdersAreRead() { if (!audio) return { name: 'the orders are read', pass: false, why: 'no audio' }; const ORDERS = `letter ${orderHouse()}`; const step = (input = {}) => scene.update(1 / 60, { ...NONE, ...input }); const run = (secs, input = {}) => { for (let i = 0; i < Math.round(secs * 60); i++) step(input); }; const out = {}; // 1-4. A browser that has heard nothing yet. let fake = fakeVoices({ unlocked: false }); try { scene.reset(); scene.openAgain(); const from = s.prince.x; run(1); out.silentBeforeAPress = fake.log.length === 0 && !s.opening.done; step({ right: true }); fake.unlock(); // the press is what lets a browser make a sound run(0.3, { right: true }); out.firstPressReads = !s.opening.done && fake.log.some((e) => e.voice === ORDERS) && Math.abs(s.prince.x - from) < 0.001; run(1.5, { right: true }); out.aHeldKeyDoesNotEndIt = !s.opening.done; run(0.3); step({ right: true }); out.theNextPressEndsIt = s.opening.done; run(0.2); out.andTheReadingStops = fake.playing === null && fake.log.some((e) => e.stop === ORDERS); } finally { fake.restore(); } // 5. A voice that never comes. fake = fakeVoices({ unlocked: false }); try { scene.reset(); scene.openAgain(); step({ right: true }); run(0.5); out.itWaitsForTheVoice = !s.opening.done; run(TUNING.VOICE_WAIT_MS / 1000); step({ right: true }); out.butNotForEver = s.opening.done; } finally { fake.restore(); } // 6. Sound already on -- he clicked the game before pressing anything. fake = fakeVoices({ unlocked: true }); try { scene.reset(); scene.openAgain(); run(TUNING.VOICE_DELAY_MS / 1000 + 0.3); out.readAtOnceWithSoundOn = fake.log.some((e) => e.voice === ORDERS) && !s.opening.done; step({ right: true }); out.thenTheFirstPressEndsIt = s.opening.done; } finally { fake.restore(); } // 7. Sound off -- the real audio, muted while the checks run. scene.reset(); scene.openAgain(); run(0.5); step({ right: true }); out.soundOffIsUnchanged = s.opening.done; scene.reset(); return { name: 'the orders are read', pass: Object.values(out).every(Boolean), ...out }; } // --- the two lanes ------------------------------------------------------------ // // Every player who saw a Drained on his road fought it: no side scroller lets // you walk past an enemy on your own line. So an unlit one stands beside the // road instead, and these are the rules that make that true rather than a // picture of it. See docs/specs/astara-two-lanes-spec.md. const gatekeeper = () => s.village[s.village.length - 1]; /** Where a figure's feet are drawn, off the one function the renderer uses. */ const feetY = (d) => GROUND_Y - laneLook(d.lane).rise; /** Draw the level once, with the clock pinned, and keep the pixels. */ const grabFrame = () => { s.clock = 3.0; renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); return ctx.getImageData(0, 0, W, H).data.slice(); }; /** * NOBODY BLOCKS THE ROAD. * * Before anything is lit, every Drained but the gatekeeper stands off the * road's ground line. Then the whole walk, from the first step to the shut * gate with the torch down: nothing on his path is on his plane, nothing * wakes, nothing hurts him. * * AND THE SWORD PASSES THEM. He swings at every one of them as he reaches it. * A blade that reached the back lane would start the exact fight the lane * exists to stop, and nothing on screen would say why it had. * * ONCE EACH. Swinging for as long as one was in reach rooted him there for * good — the blade passes, he is still in reach, he swings again — and the * walk never reached the gate. That was the check, not the game. */ function nobodyBlocksTheRoad() { scene.reset(); const gk = gatekeeper(); const others = s.drained.filter((d) => d !== gk); const beside = others.filter((d) => feetY(d) < GROUND_Y - 1).length; s.prince.x = s.prince.prevX = 0; s.prince.facing = 1; s.camera.reset(0); const hits0 = s.prince.hits; const reach = attackReach(); const swungAt = new Set(); let onHisLine = 0, swings = 0, ticks = 0; const limit = Math.round((s.gateStopX / TUNING.WALK_SPEED + 40) * 60); while (s.prince.x < s.gateStopX - 1 && ticks < limit) { // Swing at each one once, the first moment it is inside the blade's reach. const target = s.prince.attacking ? null : others.find((d) => { const gap = d.x - s.prince.x; return !swungAt.has(d) && gap > -40 && gap <= reach; }); if (target) { swungAt.add(target); swings++; } scene.update(1 / 60, { ...NONE, right: true, attack: !!target }); ticks++; for (const d of others) { if (!d.revealed && d.lane < 1 && Math.abs(d.x - s.prince.x) < 110) onHisLine++; } } const reached = s.prince.x >= s.gateStopX - 1; const struck = others.filter((d) => d.hits > 0).length; const woke = s.drained.filter((d) => d.revealed).length; return { name: 'nobody blocks the road', pass: beside === others.length && reached && onHisLine === 0 && swings >= others.length && struck === 0 && woke === 0 && s.prince.hits === hits0, besideTheRoad: beside, of: others.length, walkedToTheGate: reached, seconds: +(ticks / 60).toFixed(1), ticksAnyoneWasOnHisLine: onHisLine, swungAtThem: swings, struck, woke, hurt: s.prince.hits - hits0, }; } /** How far the sword reaches ahead of him, from the clip the scene swings with. */ const attackReach = () => (clips.prince_attack?.reach ?? 175); /** * THE GATEKEEPER IS ON THE ROAD, AND ONLY HIM. * * The gate is on the road, so its keeper stands on it: the one Drained that * is actually in the way, and so the tutorial for the lane rule as well as * for everything else. Every seed, because the deal moves every other body * from slot to slot and nothing about that may put one on his line. */ function gatekeeperIsOnTheRoad(seeds = 25) { const seed0 = s.seed; const bad = []; for (let n = 0; n < seeds; n++) { scene.reset(n * 104723 + 11); const gk = gatekeeper(); const onRoad = s.drained.filter((d) => feetY(d) >= GROUND_Y - 1 && laneLook(d.lane).scale > 0.999); if (onRoad.length !== 1 || onRoad[0] !== gk || !gk.post) { bad.push({ seed: n, onTheRoad: onRoad.length, gatekeeperAmongThem: onRoad.includes(gk) }); } } // Put the run's own deal back. The other seed-sweeping checks leave the // last one dealt, which is harmless only until something reads it. scene.reset(seed0); return { name: 'the gatekeeper is on the road', pass: bad.length === 0, seedsTried: seeds, broke: bad.length, firstFailure: bad[0] ?? null, }; } /** * THE STEP DOWN IS THE TELEGRAPH. * * A stare wakes one into STEP_DOWN, not straight into RUSH. For STEP_MS it * cannot be hit — refused by its own rule, and passed by the sword, which is * out and mid-swing into it — while its scale, height and value ease from * the back lane's to the road's, and it does not move along the road. Then * it rushes. The stare is 800ms and this is 400, so it is 1.2s from "I am * looking hard" to "it is on the road coming at me". */ function stepDownTelegraphs() { scene.reset(); const d = s.drained[0]; s.prince.x = s.prince.prevX = d.x - 120; s.prince.facing = 1; s.camera.reset(s.prince.x); let n = 0; while (!d.revealed && n < 60 * 4) { scene.update(1 / 60, { ...NONE, torch: true }); n++; } const entered = d.state; // The torch DROPPED rather than lowered, so the sword is out for the whole // step and the swing's live window lands inside it. s.torch.raise = s.torch.prevRaise = 0; const x0 = d.x; const looks = [laneLook(d.lane)]; let refused = 0, tries = 0, t = 0, moved = false, bladeLive = false; while (d.state === 'STEP_DOWN' && t < 120) { tries++; if (!d.takeHit(s.prince.x)) refused++; scene.update(1 / 60, { ...NONE, attack: t === 0 }); const ms = s.prince.attackTime * 1000; if (s.prince.attacking && ms >= TUNING.ATTACK_WINDUP_MS && ms < TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS && d.state === 'STEP_DOWN') bladeLive = true; if (d.x !== x0) moved = true; looks.push(laneLook(d.lane)); t++; } const stepMs = Math.round((t / 60) * 1000); const first = looks[0], last = looks[looks.length - 1]; let monotonic = true; for (let i = 1; i < looks.length; i++) { if (looks[i].scale < looks[i - 1].scale - 1e-9 || looks[i].rise > looks[i - 1].rise + 1e-9 || looks[i].value > looks[i - 1].value + 1e-9) monotonic = false; } // Eased, not linear: less of the way covered in the first quarter than in // the second. const q = Math.max(1, Math.floor(looks.length / 4)); const eased = (looks[0].rise - looks[q].rise) < (looks[q].rise - looks[2 * q].rise); const startsBeside = Math.abs(first.scale - TUNING.BACK_LANE_SCALE) < 0.01 && Math.abs(first.rise - TUNING.BACK_LANE_RISE) < 1 && Math.abs(first.value - TUNING.BACK_LANE_VALUE) < 1; const endsOnRoad = last.scale === 1 && last.rise === 0 && last.value === 0; return { name: 'the step down telegraphs', pass: entered === 'STEP_DOWN' && d.state === 'RUSH' && Math.abs(stepMs - TUNING.STEP_MS) <= 34 && tries > 0 && refused === tries && bladeLive && d.hits === 0 && startsBeside && endsOnRoad && monotonic && eased && !moved, wokeInto: entered, thenInto: d.state, stepMs, stepFloor: TUNING.STEP_MS, hitsRefused: `${refused} of ${tries}`, swordWasLiveDuringIt: bladeLive, hitsLanded: d.hits, startsBeside, endsOnRoad, monotonic, eased, movedAlongTheRoad: moved, }; } /** * THEY WATCH YOU PASS. * * Walking through one used to produce no reaction at all, so a player who * tried it got no evidence it was safe. Inside WATCH_NEAR it turns its whole * body to face him, it follows him as he goes by, and past WATCH_FAR it goes * back to its own facing. In between it holds — asserted twice more, * standing still in the band from each side, because that is where a * facing would flicker if anything were going to. */ function watchesYouPass() { const near = TUNING.WATCH_NEAR, far = TUNING.WATCH_FAR; scene.reset(); const d = s.drained[0]; s.prince.x = s.prince.prevX = d.x - far - 300; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(0.1); const wrong = []; let flips = 0, lastFlip = -1e9, quickFlip = false, prev = d.facing, t = 0, cameNear = false; while (s.prince.x < d.x + far + 300 && t < 60 * 12) { scene.update(1 / 60, { ...NONE, right: true }); t++; if (d.facing !== prev) { flips++; if (t - lastFlip < 15) quickFlip = true; lastFlip = t; prev = d.facing; } const gap = s.prince.x - d.x; const dist = Math.abs(gap); if (dist <= near) cameNear = true; let want; if (!cameNear && dist > near) want = d.dir; // not yet: its own facing else if (dist < far && dist > 8) want = Math.sign(gap); // watching him, either side else if (dist > far) want = d.dir; // he is gone if (want !== undefined && d.facing !== want && wrong.length < 5) { wrong.push({ gap: Math.round(gap), facing: d.facing, want }); } } // The band, from outside: walked in to near + 60 and stood there. scene.reset(); s.prince.x = s.prince.prevX = d.x - near - 60; s.camera.reset(s.prince.x); drive(2); const holdsFromOutside = d.facing === d.dir && !d.watching; // And from inside: came to near - 40, then backed out to near + 60. s.prince.x = s.prince.prevX = d.x - near + 40; drive(0.3); s.prince.x = s.prince.prevX = d.x - near - 60; drive(2); const holdsFromInside = d.facing === -1 && d.watching; scene.reset(); return { name: 'they watch you pass', pass: wrong.length === 0 && flips <= 3 && !quickFlip && holdsFromOutside && holdsFromInside, turns: flips, anyTwoWithinQuarterSecond: quickFlip, wrongFacings: wrong, bandHoldsFromOutside: holdsFromOutside, bandHoldsFromInside: holdsFromInside, near, far, }; } /** * THE LANE READS: the right size, the right height, the right value, and * standing clear of both of its neighbours. * * Measured off the frame, not off the numbers. The figure is drawn where it * stands, then taken away, and the difference between the two frames is the * figure. Its fill is read off the upper half, clear of its edge and above * the mist. * * THE VALUE IS SET FROM HERE, which is what the spec asked for. The prince * measures 2 and a watcher, hazed, measures 41 — not the 26 the palette * names. The value furthest from both is their midpoint, so the rule is that * the lane sits in the middle third between them, and under the no-mud band. * 14 was the spec's starting guess and sat too close to the prince. */ function laneReads() { return withOverlayOff(() => { scene.reset(); const d = s.drained[1]; s.prince.x = s.prince.prevX = d.x - 600; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(0.1); s.camera.x = s.camera.prevX = d.x - W / 2; // it stands at dead centre const withIt = grabFrame(); const x0 = d.x, px0 = d.prevX; d.x = d.prevX = -1e6; const without = grabFrame(); d.x = x0; d.prevX = px0; d.lane = 0; const onRoad = grabFrame(); d.lane = 1; const differs = (img, i) => Math.abs(lum(img, i) - lum(without, i)) > 6; const box = (img) => { let t = H, b = -1, l = W, r = -1; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (!differs(img, (y * W + x) * 4)) continue; if (y < t) t = y; if (y > b) b = y; if (x < l) l = x; if (x > r) r = x; } } return { t, b, l, r, h: b - t + 1 }; }; const lane = box(withIt), road = box(onRoad); const fill = []; for (let y = lane.t + 6; y < lane.t + lane.h * 0.45; y++) { for (let x = lane.l; x <= lane.r; x++) { let clear = true; for (const [dx, dy] of [[-3, 0], [3, 0], [0, -3], [0, 3]]) { if (!differs(withIt, ((y + dy) * W + x + dx) * 4)) { clear = false; break; } } if (clear) fill.push(lum(withIt, (y * W + x) * 4)); } } fill.sort((a, b) => a - b); const value = fill.length ? fill[Math.floor(fill.length / 2)] : NaN; const midX = Math.round((lane.l + lane.r) / 2); let behind = 0; for (let y = lane.t - 4; y > Math.max(0, lane.t - 84); y--) behind = Math.max(behind, lum(withIt, (y * W + midX) * 4)); const patch = (x, y) => { const v = []; for (let j = -2; j <= 2; j++) for (let i = -2; i <= 2; i++) v.push(lum(withIt, ((y + j) * W + x + i) * 4)); v.sort((a, b) => a - b); return v[12]; }; const prince = patch(Math.round(s.prince.x - s.camera.x), GROUND_Y - 200); const layer = s.background.layers.find((l) => l.key === 'watchers'); const shift = s.camera.x * layer.rate; const watcherVals = s.background.sample('watchers', shift - 100, shift + W + 100, true) .map((wx) => Math.round(wx - shift)) .filter((x) => x > 150 && x < W - 150 && Math.abs(x - midX) > 250) .map((x) => patch(x, layer.baseY - 60)) .filter((v) => v > BLACK && v < 90) .sort((a, b) => a - b); const watcher = watcherVals.length ? watcherVals[Math.floor(watcherVals.length / 2)] : NaN; scene.reset(); const scale = lane.h / road.h; const rise = road.b - lane.b; const third = (watcher - prince) / 3; const distinct = value - prince >= third && watcher - value >= third; return { name: 'the lane reads', pass: Math.abs(scale - TUNING.BACK_LANE_SCALE) <= 0.02 && Math.abs(rise - TUNING.BACK_LANE_RISE) <= 2 && Math.abs(value - TUNING.BACK_LANE_VALUE) <= 3 && value < 30 && behind - value >= FLOOR_WATCHER_GAP && distinct, scale: +scale.toFixed(3), scaleSet: TUNING.BACK_LANE_SCALE, rise, riseSet: TUNING.BACK_LANE_RISE, value: Math.round(value), valueSet: TUNING.BACK_LANE_VALUE, prince: Math.round(prince), watcher: Math.round(watcher), watchersMeasured: watcherVals.length, middleThird: [Math.round(prince + third), Math.round(watcher - third)], clearOfBoth: distinct, againstTheSky: Math.round(behind - value), floor: FLOOR_WATCHER_GAP, }; }); } /** * THE GLANCE STILL WORKS AT 0.82. * * The same carrier drawn with its page and without, in the dark and under a * glance, in the back lane and — for comparison — forced to the road's size. * Every pixel that differs between the two is what the page is worth to the * eye. * * The spec feared the smaller figure would take the page with it. At 0.82 a * figure has 67% of the area, so the page alone would keep 67% of what it * showed on the road. Measured over ten deals it keeps 84 to 101%: the lane * is 56px up, the flame is high, and a page nearer the flame is better lit. * The lift pays back most of what the size costs. * * THE FLOOR IS FROM THE DARK. In the dark the page merges into the body and * a carrier cannot be told from a decoy — about 800 pixels, 0.7% of the * figure, which is the number that says unreadable. A glance has to show at * least four times that. Over those ten deals it shows 8.5 to 9.4. * * On the bowl layout's fixed deal, so the number does not wander with the * run seed; the run's own deal is put back after. */ function theGlanceStillWorks() { const seed0 = s.seed; const measure = (torchSecs, lane) => { scene.reset(20250920); const c = s.village.find((v) => v.kept === HOUSE_OF.indexOf('the forge')); s.prince.x = s.prince.prevX = c.x - 280; s.prince.facing = 1; s.camera.reset(s.prince.x); drive(0.1); drive(torchSecs, { torch: torchSecs > 0 }); const woke = c.revealed; const keepLane = c.lane; c.lane = lane; const k = c.kept; const a = grabFrame(); c.setKept(null); const b = grabFrame(); c.setKept(k); c.lane = keepLane; let n = 0; for (let i = 0; i < a.length; i += 4) if (Math.abs(lum(a, i) - lum(b, i)) > 8) n++; const sc = laneLook(lane).scale; return { pixels: n, ofFigure: +(100 * n / (380 * 430 * sc * sc)).toFixed(2), woke }; }; const rows = withOverlayOff(() => ({ laneDark: measure(0, 1), laneGlance: measure(0.45, 1), roadDark: measure(0, 0), roadGlance: measure(0.45, 0), })); scene.reset(seed0); return { name: 'the glance still works', pass: !rows.laneGlance.woke && rows.laneGlance.pixels >= 4 * rows.laneDark.pixels, glanceOverDark: +(rows.laneGlance.pixels / Math.max(1, rows.laneDark.pixels)).toFixed(1), floor: 4, keptOfTheRoadsReading: +(rows.laneGlance.pixels / Math.max(1, rows.roadGlance.pixels)).toFixed(2), ...rows, }; } function all() { const out = [figureContrast(), watcherContrast(), brightMass(), skyWeave(), bowlLayout(), bodiesPersist(), wholeLevelWalkable(), stillUntilLit(), doorsRead(), pagesCost(), sheafPages(), markingIsAClaim(), theRoomAsks(), aGlanceIsFree(), theLightGoesOut(), theKeysAreShown(), bandFits(), dealObeysRules(), swingerIsBait(), topPageIsTheHand(), eAtTheDoor(), theWallAsksAndTheSkyAnswers(), walksHome(), keyBehindTheDoor(), residueStaysLocked(), nothingOutOfReach(), healthCosts(), theGate(), orderIsPageOne(), openingHolds(), nobodyBlocksTheRoad(), gatekeeperIsOnTheRoad(), stepDownTelegraphs(), watchesYouPass(), laneReads(), theGlanceStillWorks(), wrongDoorWakes(), gatekeeperWakesAtHisPost(), theTreeTeaches(), lettersStay(), theSlotIsAlwaysThere(), bandReadsTheDoor(), theDoorCarriesFour(), theGateRoomOpens(), everyPaperHasAVoice(), theVoiceReadsWhatTheBandShows(), theOrdersAreRead(), herPosesFit()]; return { pass: out.every((r) => r.pass), results: out }; } /** * EVERY CHECK RUNS SILENT. * * The suite drives something like half an hour of game into a few hundred * milliseconds. The scene does not know that: it fires a gasp for every * Drained that wakes, a drag for every footfall, a clap for every filing, * and with no real time between them they all land at once and sum into one * blast. Running the checks on a page reload — which is most of what * developing this game consists of — meant that blast, every time. * * Nested calls are safe: `all()` mutes, the checks inside it see audio * already off, take `was` as false and leave it alone, and only the outer * call turns it back on. And it restores what it found rather than turning * sound ON, so running a check while muted does not unmute the game. */ const silent = (fn) => function (...a) { const was = audio ? audio.enabled : false; if (was) audio.setEnabled(false); try { return fn.apply(this, a); } finally { if (was) audio.setEnabled(true); } }; const api = { figureContrast, watcherContrast, brightMass, skyWeave, bowlLayout, bodiesPersist, wholeLevelWalkable, stillUntilLit, doorsRead, pagesCost, sheafPages, markingIsAClaim, theRoomAsks, aGlanceIsFree, theLightGoesOut, theKeysAreShown, bandFits, dealObeysRules, swingerIsBait, topPageIsTheHand, eAtTheDoor, theWallAsksAndTheSkyAnswers, walksHome, keyBehindTheDoor, residueStaysLocked, nothingOutOfReach, healthCosts, theGate, orderIsPageOne, openingHolds, nobodyBlocksTheRoad, gatekeeperIsOnTheRoad, stepDownTelegraphs, watchesYouPass, laneReads, theGlanceStillWorks, wrongDoorWakes, gatekeeperWakesAtHisPost, theTreeTeaches, lettersStay, theSlotIsAlwaysThere, bandReadsTheDoor, theDoorCarriesFour, theGateRoomOpens, everyPaperHasAVoice, theVoiceReadsWhatTheBandShows, theOrdersAreRead, herPosesFit, all, frameAt, frameAtX }; return Object.fromEntries(Object.entries(api).map(([k, fn]) => [k, silent(fn)])); }