THE GHOST: '▶ watch him do it' — every day driver replays as a live demo

The dev harness ships in production now, because it turned out to be the
best teacher in the building. Any ticket whose day has a registered driver
grows a '▶ watch him do it' button: the same deterministic script that
verifies the day in CI performs it live on the real scene, real input,
real judge — then the judge waves it off ('A demonstration. None of it
counts. Now you.'), the save restores from a snapshot, and the day deals
again for the player to play for real.

The hard part was time. A demo must ride the app's own rAF loop to be
watchable — but hidden tabs pause rAF AND throttle setTimeout down to one
fire per MINUTE, which froze the show mid-egg. F(n) now has three gears:
sync (verification), rAF-riding (visible demo), and fast-forward when
document.hidden — no audience, no theatre; he finishes the dish while
you're away. Also fixed a straggler from the async conversion: the
egg-day float loop fired six un-awaited drags concurrently, so nothing
floated, the liar egg passed as safe, and the demo cracked it into the
bowl (5.6/10 with a fart). Awaited, the glass does its work.

Verified live, hidden-tab and visible: demos score 15:9.4 16:8.0 17:10.0
19:9.0 20:10.0 21:9.9 22:9.9, localStorage untouched during playback,
NEXT restores the snapshot and replays the day for real.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 17:27:07 +10:00
parent 56389dabab
commit 322aeec16d
3 changed files with 159 additions and 59 deletions

View File

@ -32,6 +32,45 @@ export function installDevHarness(app: App, game: Game): void {
const run = (frames: number, dt = 1 / 60) => {
for (let i = 0; i < frames; i++) app.step(dt);
};
// DEMO PLAYBACK: when on, `F(n)` does not step at all — it waits for the
// app's own rAF loop to advance n frames, so the same driver that verifies a
// day headless becomes a watchable, real-time performance of it.
let demoPlayback = false;
let tick = 0;
app.onTick(() => tick++);
const F = (n: number, dt = 1 / 60): Promise<void> => {
// No audience, no theatre: hidden tabs throttle both rAF and timers (down
// to 1 fire/min), so a "real-time" demo would hang. Fast-forward instead —
// he finishes the dish while you're away.
if (!demoPlayback || document.hidden) {
run(n, dt);
return Promise.resolve();
}
const until = tick + n;
return new Promise((res) => {
// Ride the app's own rAF loop; if it stalls (tab just hidden, or the
// loop is throttled), step what the wall clock owes — or finish the
// remainder synchronously once the tab reports hidden.
const pump = () => {
if (tick >= until) return res();
if (document.hidden) {
run(until - tick, dt);
return res();
}
const before = tick;
const t0 = performance.now();
window.setTimeout(() => {
if (tick === before) {
const owed = Math.round((performance.now() - t0) / (1000 / 60));
const steps = Math.min(Math.max(1, owed), 90, until - tick);
for (let i = 0; i < steps; i++) app.step(dt);
}
pump();
}, 16);
};
pump();
});
};
const tap = (code: string) => {
window.dispatchEvent(new KeyboardEvent('keydown', { code }));
app.step(1 / 60);
@ -550,9 +589,9 @@ export function installDevHarness(app: App, game: Game): void {
/** Play the whole day-17 fridge order: route in, put the delivery away
* (well or badly), shut the door (3 days), serve, read the scorecard. */
fridgeDay(good = true) {
async fridgeDay(good = true) {
game.setDay(17);
run(5);
await F(5);
const fv = game.fridgeView as unknown as {
items: { it: import('./sim/coldchain').StoredItem; mesh: unknown }[];
storeState: import('./sim/coldchain').ColdStore;
@ -578,9 +617,9 @@ export function installDevHarness(app: App, game: Game): void {
}
}
tap('Enter'); // shut the door — 3 days pass
run(10);
await F(10);
tap('Enter'); // the inspector opens it → serve
run(5);
await F(5);
const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
@ -589,80 +628,82 @@ export function installDevHarness(app: App, game: Game): void {
/** Play the day-20 egg order with REAL input. good=true floats everything
* first and only cracks the safe ones; false cracks blind (the beat). */
eggDay(good = true) {
async eggDay(good = true) {
game.setDay(20);
run(6);
await F(6);
const ev = game.eggBenchView as unknown as { session: import('./sim/eggs').EggSession };
const s = ev.session;
if (!s) return { error: 'not at eggs' };
const GLASS = { x: 2.1, z: -0.4 };
const BOWL = { x: 0, z: 0.55 };
const slot = (i: number) => ({ x: -2.25 + (i % 3) * 0.55, z: -1.2 + Math.floor(i / 3) * 0.55 });
const drag = (from: { x: number; z: number }, to: { x: number; z: number }) => {
point(from.x, 0.4, from.z, false); run(2);
point(from.x, 0.4, from.z, true); run(2);
point((from.x + to.x) / 2, 0.4, (from.z + to.z) / 2, true); run(2);
point(to.x, 0.4, to.z, true); run(2);
point(to.x, 0.4, to.z, false); run(2);
const drag = async (from: { x: number; z: number }, to: { x: number; z: number }) => {
point(from.x, 0.4, from.z, false); await F(2);
point(from.x, 0.4, from.z, true); await F(2);
point((from.x + to.x) / 2, 0.4, (from.z + to.z) / 2, true); await F(2);
point(to.x, 0.4, to.z, true); await F(2);
point(to.x, 0.4, to.z, false); await F(2);
};
const holdCrack = (frames: number) => {
const holdCrack = async (frames: number) => {
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' }));
run(frames);
await F(frames);
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }));
run(3);
await F(3);
};
if (good) {
for (let i = 0; i < 6; i++) drag(slot(i), GLASS); // float the lot
for (let i = 0; i < 6; i++) await drag(slot(i), GLASS); // float the lot
const safe = s.eggs.filter((e) => e.floatWord !== 'FLOATS').map((e) => e.index);
for (const i of safe.slice(0, s.need)) {
drag(slot(i), BOWL);
holdCrack(30); // ~0.55 — the clean window
// separate with patient alternation (~0.9s gaps)
for (let p = 0; p < 6 && s.eggs[i].state === 'cracked' && !s.eggs[i].yolkBroken; p++) {
run(54);
await drag(slot(i), BOWL);
await holdCrack(30); // ~0.55 — the clean window
// separate with patient alternation (~0.9s gaps). Generous budget:
// under live demo timing, frames slip between taps — keep passing
// until the egg is done, not until an arbitrary count runs out.
for (let p = 0; p < 14 && s.eggs[i].state === 'cracked' && !s.eggs[i].yolkBroken; p++) {
await F(54);
tap(p % 2 === 0 ? 'ArrowRight' : 'ArrowLeft');
}
}
} else {
// Blind: crack the first ROTTEN egg straight into the bowl. The beat.
const rotten = s.eggs.find((e) => e.freshness < 0.25) ?? s.eggs[0];
drag(slot(rotten.index), BOWL);
holdCrack(30);
await drag(slot(rotten.index), BOWL);
await holdCrack(30);
tap('KeyD'); // dump the crime
run(4);
await F(4);
}
tap('Enter');
run(6);
await F(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
return { floats: s.eggs.map((e) => e.floatWord), rottenIn: s.rottenIn, dumped: s.bowlsDumped, rows };
},
/** Play the day-22 benedict: toast the bread, then poach the egg for it. */
benedictDay() {
async benedictDay() {
game.setDay(22);
run(6);
await F(6);
harness.toast(10, 6); // golden-ish; pops -> routes to the poach
run(10);
await F(10);
const pv = game.poachView as unknown as { session: import('./sim/poach').PoachSession };
const s = pv.session;
if (!s) return { error: 'not at poach after toast', view: app.currentView === game.poachView ? 'poach' : 'other' };
s.src.target = 7;
let g = 0;
while (s.src.output < 6 && g++ < 1200) run(1);
while (s.src.output < 6 && g++ < 1200) await F(1);
for (let k = 0; k < 3 * 24; k++) {
const a = (k / 24) * Math.PI * 2;
point(Math.cos(a) * 0.7, 0.7, Math.sin(a) * 0.7, true);
run(1);
await F(1);
}
point(1.6, 0.7, 1.6, false);
run(2);
await F(2);
tap('Space');
g = 0;
while ((s.whiteSet < 0.86 || s.yolkSet > 0.35) && s.yolkSet <= 0.3 && g++ < 60 * 40) run(1);
while ((s.whiteSet < 0.86 || s.yolkSet > 0.35) && s.yolkSet <= 0.3 && g++ < 60 * 40) await F(1);
tap('KeyL');
for (let i = 0; i < 60 * 4; i++) run(1);
for (let i = 0; i < 60 * 4; i++) await F(1);
tap('Enter');
run(6);
await F(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const lines = [...document.querySelectorAll('.judge-lines p')].map((e) => e.textContent);
return { rows, lines };
@ -670,43 +711,43 @@ export function installDevHarness(app: App, game: Game): void {
/** Play the day-21 poach with REAL input. good=true: shiver + vortex + drop
* + lift-at-set + drain. false: still water, rolling boil, overstay, no drain. */
poachDay(good = true) {
async poachDay(good = true) {
game.setDay(21);
run(6);
await F(6);
const pv = game.poachView as unknown as { session: import('./sim/poach').PoachSession };
const s = pv.session;
if (!s) return { error: 'not at poach' };
const swirlCircles = (turns: number) => {
const swirlCircles = async (turns: number) => {
// real circular drags over the pot
for (let k = 0; k < turns * 24; k++) {
const a = (k / 24) * Math.PI * 2;
point(Math.cos(a) * 0.7, 0.7, Math.sin(a) * 0.7, true);
run(1);
await F(1);
}
point(1.6, 0.7, 1.6, false);
run(2);
await F(2);
};
if (good) {
s.src.target = 7; // the shiver band
let g = 0;
while (s.src.output < 6 && g++ < 1200) run(1);
swirlCircles(3);
while (s.src.output < 6 && g++ < 1200) await F(1);
await swirlCircles(3);
tap('Space'); // the drop, into the spin
g = 0;
while ((s.whiteSet < 0.86 || s.yolkSet > 0.35) && s.yolkSet <= 0.3 && g++ < 60 * 40) run(1);
while ((s.whiteSet < 0.86 || s.yolkSet > 0.35) && s.yolkSet <= 0.3 && g++ < 60 * 40) await F(1);
tap('KeyL');
for (let i = 0; i < 60 * 4; i++) run(1); // drain
for (let i = 0; i < 60 * 4; i++) await F(1); // drain
} else {
s.src.target = 9; // straight to a rolling boil
let g = 0;
while (s.src.output < 8.5 && g++ < 1200) run(1);
while (s.src.output < 8.5 && g++ < 1200) await F(1);
tap('Space'); // no vortex, into the boil
for (let i = 0; i < 60 * 30; i++) run(1); // overstay — yolk hardens, rags tear
for (let i = 0; i < 60 * 30; i++) await F(1); // overstay — yolk hardens, rags tear
tap('KeyL');
// no drain
}
tap('Enter');
run(6);
await F(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const q = (n: number) => +n.toFixed(3);
return { white: q(s.whiteSet), yolk: q(s.yolkSet), rags: q(s.rags), vortexAtDrop: q(s.vortexAtDrop), rows };
@ -801,7 +842,7 @@ export function installDevHarness(app: App, game: Game): void {
/** Play the whole day-19 rib-eye order: sear on the pan, then rest (or don't)
* and cut across (or along) the grain on the board, serve. */
steakDay(opts: { rest?: boolean; alongGrain?: boolean } = {}) {
async steakDay(opts: { rest?: boolean; alongGrain?: boolean } = {}) {
const { rest = true, alongGrain = false } = opts;
game.setDay(19);
run(6);
@ -1001,26 +1042,26 @@ export function installDevHarness(app: App, game: Game): void {
/** Play the whole day-16 pan order: route in, butter foam food
* flip serve, and read the scorecard. A clean cook should grade well. */
panDay() {
async panDay() {
game.setDay(16);
run(5);
await F(5);
const pv = game.panView as unknown as { session: import('./sim/pan').PanSession; result(): unknown };
const s = pv.session;
if (!s) return { error: 'not at pan' };
// Only run() advances time (the scene's update() steps the pan). Set state
// Only await F() advances time (the scene's update() steps the pan). Set state
// through the sim, but never step it directly here.
setPanKnob(s, 7);
addButter(s);
let guard = 0;
while (butterState(s) !== 'foaming' && guard++ < 60 * 30) run(1); // wait for the foam
while (butterState(s) !== 'foaming' && guard++ < 60 * 30) await F(1); // wait for the foam
addFood(s, 'button_mushroom', 'A');
// Flip a touch before the halfway mark: a gas flame gives the first side a
// flare lead, so a good cook turns it early (induction wouldn't need to).
for (let i = 0; i < 60 * 8; i++) run(1); // sear side one
for (let i = 0; i < 60 * 8; i++) await F(1); // sear side one
flip(s);
for (let i = 0; i < 60 * 9; i++) run(1); // sear side two
for (let i = 0; i < 60 * 9; i++) await F(1); // sear side two
tap('Enter');
run(5);
await F(5);
const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
@ -1154,9 +1195,9 @@ export function installDevHarness(app: App, game: Game): void {
/** Play the whole day-15 grill order: route in, bank a two-zone bed, sear
* each piece then move it to the lee, serve, and read the scorecard. */
grillDay() {
async grillDay() {
game.setDay(15);
run(5);
await F(5);
const g = game.grillView as unknown as { session: import('./sim/charcoal').GrillSession; result(): unknown };
const s = g.session;
if (!s) return { error: 'not at grill', view: 'other' };
@ -1169,10 +1210,10 @@ export function installDevHarness(app: App, game: Game): void {
// pull each piece to the lee the moment it hits a good sear — before its
// fat renders and flares (the whole technique).
for (const f of s.foods) if (f.sear >= 0.56 && f.u < 0.6) f.u = 0.82;
run(30); // half a second — tighter cadence so we catch the window
await F(30); // half a second — tighter cadence so we catch the window
}
tap('Enter');
run(5);
await F(5);
const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade');
@ -1359,5 +1400,20 @@ export function installDevHarness(app: App, game: Game): void {
},
};
// THE DEMONSTRATIONS: day -> a driver that performs that authored day live.
// The game's "watch him do it" button runs these under demo playback.
const demos: Record<number, () => Promise<unknown>> = {
15: () => harness.grillDay(),
16: () => harness.panDay(),
17: () => harness.fridgeDay(true),
19: () => harness.steakDay({ rest: true, alongGrain: false }),
20: () => harness.eggDay(true),
21: () => harness.poachDay(true),
22: () => harness.benedictDay(),
};
(harness as unknown as { demos: typeof demos }).demos = demos;
(harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => {
demoPlayback = on;
};
(window as unknown as { __t: unknown }).__t = harness;
}

View File

@ -87,6 +87,9 @@ export class Game {
private knifeTrip = false;
/** The Daily Deal: everyone on Earth gets the same order today. */
private dailyDate: string | null = null;
/** A demonstration is playing — the judge performs, nothing counts. */
private demoMode = false;
private demoSnapshot: string | null = null;
private rng = new Rng(20260716);
private order!: Order;
/** Sim-time service clock — wall clock lies whenever the tab throttles. */
@ -170,6 +173,15 @@ export class Game {
this.kitchen.flash(cut.knife === 'the_best_thing' ? 'cut with The Best Thing' : 'now toast it');
};
this.judgeView.onNext = () => {
if (this.demoMode) {
// The demonstration is over. Put everything back and deal the day for real.
if (this.demoSnapshot) this.save = JSON.parse(this.demoSnapshot) as Save;
this.demoSnapshot = null;
this.demoMode = false;
demoWriteGuard = false;
this.beginDay();
return;
}
// Commit any fridge consumption atomically with the day advance: a reload
// before this point re-derives the same fetch, so the cap can't be dodged.
if (this.pendingFridge) {
@ -536,6 +548,22 @@ export class Game {
* day counter and streaks are left alone. Deterministic: replaying today
* re-deals today.
*/
/**
* "Watch him do it": replay this authored day as a live demonstration, driven
* by the same harness script that verifies it. The save is snapshotted and
* write-guarded a demo can neither farm best scores nor advance anything.
*/
startDemo(day: number): void {
const t = (window as unknown as { __t?: { demos?: Record<number, () => Promise<unknown>>; setDemoPlayback?: (on: boolean) => void } }).__t;
const demo = t?.demos?.[day];
if (!demo || !t?.setDemoPlayback || this.demoMode) return;
this.demoSnapshot = JSON.stringify(this.save);
this.demoMode = true;
demoWriteGuard = true;
t.setDemoPlayback(true);
void demo().finally(() => t.setDemoPlayback!(false));
}
startDaily(): void {
const date = new Date().toISOString().slice(0, 10);
this.dailyDate = date;
@ -949,6 +977,11 @@ export class Game {
* ride the judge screen via extraLines.
*/
private recordVerdict(v: Verdict): void {
if (this.demoMode) {
this.judgeView.extraLines = ['A demonstration. None of it counts. Now you.'];
this.judgeView.shareText = '';
return;
}
const extras: string[] = [];
// The streak. He notices. He hates that he notices.
const prev = this.save.streak ?? 0;
@ -987,6 +1020,11 @@ export class Game {
this.ticketEl.innerHTML = '';
el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`);
el('div', 'ticket-who', this.ticketEl, o.who);
const demos = (window as unknown as { __t?: { demos?: Record<number, unknown> } }).__t?.demos;
if (!this.demoMode && demos && demos[o.day]) {
const watch = el('button', 'btn ghost watch-btn', this.ticketEl, '▶ watch him do it');
watch.addEventListener('click', () => this.startDemo(o.day));
}
const knownAs = this.save.customers?.[o.who];
if (knownAs && knownAs.visits > 0) {
el('div', 'ticket-hint', this.ticketEl, `visit №${knownAs.visits + 1} — last time: ${knownAs.last}${knownAs.last === 'F' ? '. they came BACK.' : ''}`);
@ -1122,7 +1160,11 @@ function loadSave(): Save {
return { day: 1, maxDay: 1, total: 0, best: {}, streak: 0, customers: {}, daily: {} };
}
/** While a demonstration runs, nothing it does may touch the disk. */
let demoWriteGuard = false;
function writeSave(s: Save): void {
if (demoWriteGuard) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
} catch {

View File

@ -14,8 +14,10 @@ const game = new Game(app);
// works without it — so boot the game now and let physics catch up.
void game.init();
if (import.meta.env.DEV) {
// The main harness ships in production too: it powers the in-game
// demonstrations ("watch him do it"), and console cooks are welcome guests.
void import('./dev').then((m) => m.installDevHarness(app, game));
if (import.meta.env.DEV) {
void import('./dev-juice').then((m) => m.installJuiceHarness(app));
void import('./dev-grate').then((m) => m.installGrateHarness(app));
}