astara / src /checks.js
system's picture
system HF Staff
d7dab35: Her own drawings for the sword, the fall and the torch
75ec82b verified
Raw History Blame Contribute Delete
188 kB
// 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)]));
}