The endless mode uses the whole kitchen; the ledger makes days replayable

1) Station-mix procedural days: past day 19 the kitchen deals from every
station it taught — toast ~34%, then prep cuts (technique onion dice past
21), juice (better reamers + more pips later), the grill (4 pieces past
24), the pan (electric arrives 22, induction 26), the rib eye (random
grain, electric past 23), and the fridge delivery. Each with its own
ticket voice. Deals are seeded BY DAY: a reload re-deals the identical
order (no reroll-scumming) and the harness can reproduce any day.

2) THE LEDGER on the title: one chip per reached day with your best grade
stamped on it (S green through F red), click to replay. save.maxDay
tracks furthest-ever progress so replaying day 5 for the S never locks
day 19 away; old saves migrate (maxDay defaults to day).

Verified live: 26-day sweep deals all 7 stations, deterministic re-deals;
chip click starts day 5; procedural day-26 steak (electric fuel ramp)
played end-to-end: rested, cut across a random grain, 9.1/10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 01:27:25 +10:00
parent 1e5d069c99
commit 64bf04c0b6
4 changed files with 231 additions and 10 deletions

View File

@ -36,6 +36,9 @@ const TEMP_TOGGLE_COST = 8;
interface Save {
day: number;
/** The furthest day ever reached the day-select grid unlocks up to here,
* so replaying an old day for the S never locks the future away. */
maxDay: number;
total: number;
best: Record<string, number>;
/** The cold store, persisted: what you stocked on a fridge day is still there
@ -155,6 +158,7 @@ export class Game {
this.pendingFridge = null;
}
this.save.day++;
this.save.maxDay = Math.max(this.save.maxDay, this.save.day);
writeSave(this.save);
this.beginDay();
};
@ -186,8 +190,14 @@ export class Game {
// gesture the browser wants before any audio is allowed to make a sound.
this.kitchen.root.visible = false;
this.ticket.hide();
new Title(this.save.day, () => {
new Title(this.save, (day) => {
audio.resume();
if (day !== undefined && day !== this.save.day) {
// Replaying an old day: move the cursor there, but maxDay holds the
// real progress so the ledger never shrinks.
this.save.day = day;
writeSave(this.save);
}
this.beginDay();
});
}
@ -433,6 +443,7 @@ export class Game {
/** Test hook: jump straight to a day, through the real order table. */
setDay(day: number): void {
this.save.day = day;
this.save.maxDay = Math.max(this.save.maxDay ?? day, day);
writeSave(this.save);
this.beginDay();
}
@ -482,10 +493,14 @@ export class Game {
beginDay(): void {
const day = this.save.day;
// Procedural days are seeded BY the day, not by a running stream: a reload
// re-deals the identical order (no reroll-scumming), and the harness can
// reproduce any day by number.
const dayRng = new Rng((day * 2654435761) >>> 0);
const o =
day <= DAYS.length
? DAYS[day - 1]
: proceduralOrder(day, () => this.rng.next());
: proceduralOrder(day, () => dayRng.next());
if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`;
this.order = o;
this.orderSeconds = 0;
@ -852,13 +867,13 @@ function loadSave(): Save {
if (raw) {
const s = JSON.parse(raw) as Partial<Save>;
if (typeof s.day === 'number' && s.day >= 1) {
return { day: s.day, total: s.total ?? 0, best: s.best ?? {}, fridge: s.fridge };
return { day: s.day, maxDay: Math.max(s.day, s.maxDay ?? s.day), total: s.total ?? 0, best: s.best ?? {}, fridge: s.fridge };
}
}
} catch {
/* a corrupt save is not worth a crash */
}
return { day: 1, total: 0, best: {} };
return { day: 1, maxDay: 1, total: 0, best: {} };
}
function writeSave(s: Save): void {

View File

@ -440,7 +440,117 @@ export const DAYS: Order[] = [
];
/** Days beyond the script — keep going, with everything cranked. */
/**
* Days beyond the script use the WHOLE kitchen. Toast stays the bread and
* butter (a third of days); the other two thirds rotate through every station
* the campaign taught, each with its own knobs turned by the day number.
* Deterministic per day (the caller seeds by day) a reload re-deals the SAME
* order, so there's no reroll-scumming your way to an easy one.
*/
export function proceduralOrder(day: number, rand: () => number): Order {
const roll = rand();
if (roll < 0.34) return proceduralToast(day, rand);
if (roll < 0.47) return proceduralPrep(day, rand);
if (roll < 0.57) return proceduralJuice(day, rand);
if (roll < 0.69) return proceduralGrill(day, rand);
if (roll < 0.81) return proceduralPan(day, rand);
if (roll < 0.92) return proceduralSteak(day, rand);
return proceduralFridge(day, rand);
}
const pickOf = <T,>(rand: () => number, a: T[]): T => a[Math.floor(rand() * a.length)];
/** The toast fields every Order carries; station days never read most of them. */
function baseOrder(day: number, who: string, text: string): Order {
return {
day,
bread: 'white',
brand: 'WONDERSLICE',
spread: 'butter',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.5,
who,
text,
};
}
function proceduralPrep(day: number, rand: () => number): Order {
const cuttables: IngredientId[] = ['tomato', 'onion', 'cucumber', 'bell_pepper', 'button_mushroom', 'portobello', 'king_oyster'];
const ingredient = pickOf(rand, cuttables);
const kinds = ['slices', 'wedges', 'dice'] as const;
const kind = pickOf(rand, [...kinds]);
// The knife pool loosens with the days — sometimes you get the good one.
const knife: ToolId = rand() < 0.2 ? 'the_best_thing' : 'dinner_knife';
const n = kind === 'dice' ? 3 + Math.floor(rand() * 2) : 4 + Math.floor(rand() * (kind === 'wedges' ? 5 : 3));
// Past day 21, an onion dice is the TECHNIQUE dice — stop at the root.
const root = kind === 'dice' && ingredient === 'onion' && day > 21;
const pattern: CutPattern = kind === 'dice' ? { kind, n, ...(root ? { root: true } : {}) } : { kind, n };
const o = baseOrder(
day,
pickOf(rand, ['a regular', 'the lunch rush', 'Deidre', 'the caterer next door']),
'',
);
o.prep = [{ kind: 'cut', ingredient, pattern, knife }];
o.text = `${prepAsk(o.prep[0]).replace(/^./, (c) => c.toUpperCase())} — even ones, tidy bench. Then ${o.browningName} white, butter.`;
return o;
}
function proceduralJuice(day: number, rand: () => number): Order {
// Better reamers show up as the days go on — and more pips with them.
const juicer: JuicerId = day > 25 && rand() < 0.4 ? 'juicernaut' : day > 21 && rand() < 0.5 ? 'ribbed_deluxe' : 'pyramid_classic';
const seeds = 3 + Math.floor(rand() * 4);
const o = baseOrder(day, pickOf(rand, ['Deidre', 'a thirsty tradie', 'the morning walker']), '');
o.prep = [{ kind: 'juice', ingredient: 'orange', juicer, seeds }];
o.text = `Fresh orange juice with the toast — squeeze it properly, mind the pips. Then ${o.browningName} white, butter.`;
return o;
}
function proceduralGrill(day: number, rand: () => number): Order {
const pool = ['steak', 'snag', 'corn', 'mushroom'];
const count = day > 24 ? 4 : 3;
const foods: string[] = [];
for (let i = 0; i < count; i++) foods.push(pool[Math.floor(rand() * pool.length)]);
const o = baseOrder(day, pickOf(rand, ['the bloke from two doors down', 'half the street', 'the cricket club']), '');
o.grill = foods;
o.text = `Barbie's on — ${count} bits. Bank the coals hot one side, sear 'em, then off to the lee before they flare. A proper char, not a cremation.`;
return o;
}
function proceduralPan(day: number, rand: () => number): Order {
const foods = ['button_mushroom', 'portobello', 'bell_pepper'];
// Electric arrives day 22 (the coil that won't let go); induction day 26.
const fuel = day > 25 && rand() < 0.35 ? 'induction' : day > 21 && rand() < 0.45 ? 'electric' : 'gas';
const food = pickOf(rand, foods);
const o = baseOrder(day, pickOf(rand, ['the chef, on her day off', 'a food blogger', 'Ray']), '');
o.pan = { food, fuel };
const fuelLine = fuel === 'electric' ? 'On the electric — mind the coil, it remembers.' : fuel === 'induction' ? 'On the induction — silent, exact, no excuses.' : 'On the gas.';
o.text = `Pan-sear the ${food.replace(/_/g, ' ')}s. ${fuelLine} Butter in first — wait for the FOAM. Both faces. Burn the butter and I will know.`;
return o;
}
function proceduralSteak(day: number, rand: () => number): Order {
const target = pickOf(rand, ['rare', 'medium', 'medium', 'well'] as const);
const grain = rand() * Math.PI;
const fuel = day > 23 && rand() < 0.4 ? 'electric' : 'gas';
const o = baseOrder(day, pickOf(rand, ['the man at table nine', 'a birthday dinner', 'the butcher, testing you']), '');
o.steak = { fuel, target, grain };
o.text = `Rib eye, ${target}. Sear it, then REST it — do not cut it early, I will see it bleed. And slice ACROSS the grain, clean.`;
return o;
}
function proceduralFridge(day: number, rand: () => number): Order {
const o = baseOrder(day, 'the delivery, at 6am', '');
void rand();
o.fridge = true;
o.text = 'The order came in. Raw meat down low, perishables in the cold at the back, and don\'t cram one shelf. Three days till the inspector looks.';
return o;
}
function proceduralToast(day: number, rand: () => number): Order {
const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];
const spreads: SpreadId[] = ['butter', 'peanut', 'crunchy', 'mitey', 'marmalade'];
const amounts: AmountClass[] = ['thin', 'normal', 'thick'];

View File

@ -595,6 +595,74 @@ canvas#c {
color: #6d6155;
}
/* The day ledger: replay any reached day, best grade stamped on the chip. */
.title-days-lbl {
margin-top: 20px;
font-size: 10px;
letter-spacing: 0.22em;
color: var(--ink-dim);
}
.title-days {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 5px;
max-width: 380px;
max-height: 118px;
overflow-y: auto;
}
.day-chip {
width: 34px;
padding: 4px 0 3px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
background: rgba(243, 233, 220, 0.06);
border: 1px solid rgba(243, 233, 220, 0.14);
border-radius: 6px;
color: var(--ink);
cursor: pointer;
font-family: var(--mono);
}
.day-chip:hover {
background: rgba(232, 165, 58, 0.18);
border-color: rgba(232, 165, 58, 0.5);
}
.day-num {
font-size: 11px;
}
.day-grade {
font-size: 10px;
color: var(--ink-dim);
}
.day-grade.grade-S {
color: #6cbf6c;
font-weight: 700;
}
.day-grade.grade-A {
color: #8fce8f;
}
.day-grade.grade-B {
color: #e8a53a;
}
.day-grade.grade-C {
color: #d2703a;
}
.day-grade.grade-F {
color: #c0392b;
}
.toast-panel .amount {
margin-top: 8px;
font-size: 11px;

View File

@ -1,11 +1,20 @@
import { el, Panel } from './hud';
import { assetUrl } from '../scenes/assets';
import { gradeOf } from '../game/judging';
import { DAYS } from '../game/orders';
/** The front door. One button, one piece of art, one joke. */
/**
* The front door. One button, one piece of art, one joke and now the ledger:
* every day you've reached, replayable, with your best grade stamped on it.
* Chasing the S column is the whole late game.
*/
export class Title {
private panel: Panel;
constructor(day: number, onStart: () => void) {
constructor(
save: { day: number; maxDay: number; best: Record<string, number> },
onStart: (day?: number) => void,
) {
this.panel = new Panel('title');
const wrap = el('div', 'title-wrap', this.panel.root);
const art = el('img', 'title-art', wrap);
@ -14,11 +23,30 @@ export class Title {
const txt = el('div', 'title-txt', wrap);
el('h1', undefined, txt, 'TOASTSIM');
el('p', 'title-sub', txt, 'Bread goes in. You are judged.');
const btn = el('button', 'btn', txt, day > 1 ? `RESUME — DAY ${day}` : 'START DAY 1');
btn.addEventListener('click', () => {
const btn = el('button', 'btn', txt, save.day > 1 ? `RESUME — DAY ${save.day}` : 'START DAY 1');
const go = (day?: number) => {
this.panel.dispose();
onStart();
});
onStart(day);
};
btn.addEventListener('click', () => go());
// The day ledger: one chip per reached day, best grade underneath. Click to
// replay it (progress never regresses — maxDay remembers how far you got).
if (save.maxDay > 1) {
el('div', 'title-days-lbl', txt, `THE LEDGER — ${DAYS.length} on the card, then the kitchen deals`);
const grid = el('div', 'title-days', txt);
for (let d = 1; d <= save.maxDay; d++) {
const chip = el('button', 'day-chip', grid);
el('span', 'day-num', chip, String(d));
const bestScore = save.best[`day${d}`];
const grade = bestScore !== undefined ? gradeOf(bestScore) : null;
const g = el('span', `day-grade${grade ? ` grade-${grade}` : ''}`, chip, grade ?? '·');
void g;
chip.title = bestScore !== undefined ? `day ${d} — best ${bestScore.toFixed(1)}/10` : `day ${d} — not judged yet`;
chip.addEventListener('click', () => go(d));
}
}
const keys = el('div', 'title-keys', txt);
el('div', undefined, keys, 'SPACE — lever down, then pop');
el('div', undefined, keys, 'DRAG — fish the right tool from the drawer');