From 322aeec16de10d0c1695e068c5a0b497154c526d Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 17:27:07 +1000 Subject: [PATCH] =?UTF-8?q?THE=20GHOST:=20'=E2=96=B6=20watch=20him=20do=20?= =?UTF-8?q?it'=20=E2=80=94=20every=20day=20driver=20replays=20as=20a=20liv?= =?UTF-8?q?e=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/dev.ts | 172 +++++++++++++++++++++++++++++++---------------- src/game/game.ts | 42 ++++++++++++ src/main.ts | 4 +- 3 files changed, 159 insertions(+), 59 deletions(-) diff --git a/src/dev.ts b/src/dev.ts index f016c57..f313513 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -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 => { + // 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 Promise> = { + 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; } diff --git a/src/game/game.ts b/src/game/game.ts index 18043e8..3ecb7ea 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -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 Promise>; 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 } }).__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 { diff --git a/src/main.ts b/src/main.ts index 9e0be38..cf49970 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(); +// 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').then((m) => m.installDevHarness(app, game)); void import('./dev-juice').then((m) => m.installJuiceHarness(app)); void import('./dev-grate').then((m) => m.installGrateHarness(app)); }