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) => { const run = (frames: number, dt = 1 / 60) => {
for (let i = 0; i < frames; i++) app.step(dt); 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) => { const tap = (code: string) => {
window.dispatchEvent(new KeyboardEvent('keydown', { code })); window.dispatchEvent(new KeyboardEvent('keydown', { code }));
app.step(1 / 60); 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 /** 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. */ * (well or badly), shut the door (3 days), serve, read the scorecard. */
fridgeDay(good = true) { async fridgeDay(good = true) {
game.setDay(17); game.setDay(17);
run(5); await F(5);
const fv = game.fridgeView as unknown as { const fv = game.fridgeView as unknown as {
items: { it: import('./sim/coldchain').StoredItem; mesh: unknown }[]; items: { it: import('./sim/coldchain').StoredItem; mesh: unknown }[];
storeState: import('./sim/coldchain').ColdStore; 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 tap('Enter'); // shut the door — 3 days pass
run(10); await F(10);
tap('Enter'); // the inspector opens it → serve tap('Enter'); // the inspector opens it → serve
run(5); await F(5);
const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent); const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade'); 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 /** 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). */ * first and only cracks the safe ones; false cracks blind (the beat). */
eggDay(good = true) { async eggDay(good = true) {
game.setDay(20); game.setDay(20);
run(6); await F(6);
const ev = game.eggBenchView as unknown as { session: import('./sim/eggs').EggSession }; const ev = game.eggBenchView as unknown as { session: import('./sim/eggs').EggSession };
const s = ev.session; const s = ev.session;
if (!s) return { error: 'not at eggs' }; if (!s) return { error: 'not at eggs' };
const GLASS = { x: 2.1, z: -0.4 }; const GLASS = { x: 2.1, z: -0.4 };
const BOWL = { x: 0, z: 0.55 }; 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 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 }) => { const drag = async (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, false); await F(2);
point(from.x, 0.4, from.z, true); run(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); run(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); run(2); point(to.x, 0.4, to.z, true); await F(2);
point(to.x, 0.4, to.z, false); run(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' })); window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' }));
run(frames); await F(frames);
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' })); window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }));
run(3); await F(3);
}; };
if (good) { 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); const safe = s.eggs.filter((e) => e.floatWord !== 'FLOATS').map((e) => e.index);
for (const i of safe.slice(0, s.need)) { for (const i of safe.slice(0, s.need)) {
drag(slot(i), BOWL); await drag(slot(i), BOWL);
holdCrack(30); // ~0.55 — the clean window await holdCrack(30); // ~0.55 — the clean window
// separate with patient alternation (~0.9s gaps) // separate with patient alternation (~0.9s gaps). Generous budget:
for (let p = 0; p < 6 && s.eggs[i].state === 'cracked' && !s.eggs[i].yolkBroken; p++) { // under live demo timing, frames slip between taps — keep passing
run(54); // 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'); tap(p % 2 === 0 ? 'ArrowRight' : 'ArrowLeft');
} }
} }
} else { } else {
// Blind: crack the first ROTTEN egg straight into the bowl. The beat. // 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]; const rotten = s.eggs.find((e) => e.freshness < 0.25) ?? s.eggs[0];
drag(slot(rotten.index), BOWL); await drag(slot(rotten.index), BOWL);
holdCrack(30); await holdCrack(30);
tap('KeyD'); // dump the crime tap('KeyD'); // dump the crime
run(4); await F(4);
} }
tap('Enter'); tap('Enter');
run(6); await F(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
return { floats: s.eggs.map((e) => e.floatWord), rottenIn: s.rottenIn, dumped: s.bowlsDumped, rows }; 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. */ /** Play the day-22 benedict: toast the bread, then poach the egg for it. */
benedictDay() { async benedictDay() {
game.setDay(22); game.setDay(22);
run(6); await F(6);
harness.toast(10, 6); // golden-ish; pops -> routes to the poach 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 pv = game.poachView as unknown as { session: import('./sim/poach').PoachSession };
const s = pv.session; const s = pv.session;
if (!s) return { error: 'not at poach after toast', view: app.currentView === game.poachView ? 'poach' : 'other' }; if (!s) return { error: 'not at poach after toast', view: app.currentView === game.poachView ? 'poach' : 'other' };
s.src.target = 7; s.src.target = 7;
let g = 0; 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++) { for (let k = 0; k < 3 * 24; k++) {
const a = (k / 24) * Math.PI * 2; const a = (k / 24) * Math.PI * 2;
point(Math.cos(a) * 0.7, 0.7, Math.sin(a) * 0.7, true); 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); point(1.6, 0.7, 1.6, false);
run(2); await F(2);
tap('Space'); tap('Space');
g = 0; 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'); 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'); tap('Enter');
run(6); await F(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const lines = [...document.querySelectorAll('.judge-lines p')].map((e) => e.textContent); const lines = [...document.querySelectorAll('.judge-lines p')].map((e) => e.textContent);
return { rows, lines }; 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 /** 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. */ * + lift-at-set + drain. false: still water, rolling boil, overstay, no drain. */
poachDay(good = true) { async poachDay(good = true) {
game.setDay(21); game.setDay(21);
run(6); await F(6);
const pv = game.poachView as unknown as { session: import('./sim/poach').PoachSession }; const pv = game.poachView as unknown as { session: import('./sim/poach').PoachSession };
const s = pv.session; const s = pv.session;
if (!s) return { error: 'not at poach' }; if (!s) return { error: 'not at poach' };
const swirlCircles = (turns: number) => { const swirlCircles = async (turns: number) => {
// real circular drags over the pot // real circular drags over the pot
for (let k = 0; k < turns * 24; k++) { for (let k = 0; k < turns * 24; k++) {
const a = (k / 24) * Math.PI * 2; const a = (k / 24) * Math.PI * 2;
point(Math.cos(a) * 0.7, 0.7, Math.sin(a) * 0.7, true); 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); point(1.6, 0.7, 1.6, false);
run(2); await F(2);
}; };
if (good) { if (good) {
s.src.target = 7; // the shiver band s.src.target = 7; // the shiver band
let g = 0; let g = 0;
while (s.src.output < 6 && g++ < 1200) run(1); while (s.src.output < 6 && g++ < 1200) await F(1);
swirlCircles(3); await swirlCircles(3);
tap('Space'); // the drop, into the spin tap('Space'); // the drop, into the spin
g = 0; 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'); 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 { } else {
s.src.target = 9; // straight to a rolling boil s.src.target = 9; // straight to a rolling boil
let g = 0; 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 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'); tap('KeyL');
// no drain // no drain
} }
tap('Enter'); tap('Enter');
run(6); await F(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const q = (n: number) => +n.toFixed(3); 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 }; 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) /** 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. */ * 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; const { rest = true, alongGrain = false } = opts;
game.setDay(19); game.setDay(19);
run(6); 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 /** Play the whole day-16 pan order: route in, butter foam food
* flip serve, and read the scorecard. A clean cook should grade well. */ * flip serve, and read the scorecard. A clean cook should grade well. */
panDay() { async panDay() {
game.setDay(16); game.setDay(16);
run(5); await F(5);
const pv = game.panView as unknown as { session: import('./sim/pan').PanSession; result(): unknown }; const pv = game.panView as unknown as { session: import('./sim/pan').PanSession; result(): unknown };
const s = pv.session; const s = pv.session;
if (!s) return { error: 'not at pan' }; 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. // through the sim, but never step it directly here.
setPanKnob(s, 7); setPanKnob(s, 7);
addButter(s); addButter(s);
let guard = 0; 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'); addFood(s, 'button_mushroom', 'A');
// Flip a touch before the halfway mark: a gas flame gives the first side 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). // 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); 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'); tap('Enter');
run(5); await F(5);
const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent); const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade'); 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 /** 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. */ * each piece then move it to the lee, serve, and read the scorecard. */
grillDay() { async grillDay() {
game.setDay(15); game.setDay(15);
run(5); await F(5);
const g = game.grillView as unknown as { session: import('./sim/charcoal').GrillSession; result(): unknown }; const g = game.grillView as unknown as { session: import('./sim/charcoal').GrillSession; result(): unknown };
const s = g.session; const s = g.session;
if (!s) return { error: 'not at grill', view: 'other' }; 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 // pull each piece to the lee the moment it hits a good sear — before its
// fat renders and flares (the whole technique). // 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; 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'); tap('Enter');
run(5); await F(5);
const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent); const groups = [...document.querySelectorAll('.crit-group')].map((e) => e.textContent);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent); const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
const gradeEl = document.querySelector('.judge-grade, .grade'); 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; (window as unknown as { __t: unknown }).__t = harness;
} }

View File

@ -87,6 +87,9 @@ export class Game {
private knifeTrip = false; private knifeTrip = false;
/** The Daily Deal: everyone on Earth gets the same order today. */ /** The Daily Deal: everyone on Earth gets the same order today. */
private dailyDate: string | null = null; 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 rng = new Rng(20260716);
private order!: Order; private order!: Order;
/** Sim-time service clock — wall clock lies whenever the tab throttles. */ /** 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.kitchen.flash(cut.knife === 'the_best_thing' ? 'cut with The Best Thing' : 'now toast it');
}; };
this.judgeView.onNext = () => { 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 // 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. // before this point re-derives the same fetch, so the cap can't be dodged.
if (this.pendingFridge) { if (this.pendingFridge) {
@ -536,6 +548,22 @@ export class Game {
* day counter and streaks are left alone. Deterministic: replaying today * day counter and streaks are left alone. Deterministic: replaying today
* re-deals 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 { startDaily(): void {
const date = new Date().toISOString().slice(0, 10); const date = new Date().toISOString().slice(0, 10);
this.dailyDate = date; this.dailyDate = date;
@ -949,6 +977,11 @@ export class Game {
* ride the judge screen via extraLines. * ride the judge screen via extraLines.
*/ */
private recordVerdict(v: Verdict): void { 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[] = []; const extras: string[] = [];
// The streak. He notices. He hates that he notices. // The streak. He notices. He hates that he notices.
const prev = this.save.streak ?? 0; const prev = this.save.streak ?? 0;
@ -987,6 +1020,11 @@ export class Game {
this.ticketEl.innerHTML = ''; this.ticketEl.innerHTML = '';
el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`); el('div', 'ticket-day', this.ticketEl, `DAY ${o.day}`);
el('div', 'ticket-who', this.ticketEl, o.who); 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]; const knownAs = this.save.customers?.[o.who];
if (knownAs && knownAs.visits > 0) { 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.' : ''}`); 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: {} }; 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 { function writeSave(s: Save): void {
if (demoWriteGuard) return;
try { try {
localStorage.setItem(SAVE_KEY, JSON.stringify(s)); localStorage.setItem(SAVE_KEY, JSON.stringify(s));
} catch { } 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. // works without it — so boot the game now and let physics catch up.
void game.init(); 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) { 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-juice').then((m) => m.installJuiceHarness(app));
void import('./dev-grate').then((m) => m.installGrateHarness(app)); void import('./dev-grate').then((m) => m.installGrateHarness(app));
} }