THE CURE: day 30 — the first thing you cannot finish today

P10's headline, and the only mechanic in this kitchen that reaches across
days. You pack a slab of salmon in salt and sugar — coverage and evenness,
with no way to take salt back off, the same dose grammar as everything
else — and then you put it on a shelf and LEAVE it. The scorecard judges
the pack and says 'On the shelf, then. 3 days. I will ask.'

Days later the title carries THE CURING SHELF: a jar per cure, counting
up, going green when it is ready. Open it and the verdict is mostly about
the waiting — 'You packed this 3 services ago and left it alone. It
shows. Good.' Open it the same day and it is a salty raw fish. And a bare
patch plus three days on a shelf is not an under-cure, it is a spoil,
which is why coverage is the heaviest row on the pack card.

Two real problems the verification turned up:

1. THE CLOCK WAS WRONG. I first hung cures off maxDay, copying THE
   MOTHER — but maxDay is a PENALTY clock, deliberately frozen when you
   replay old days so the starter cannot starve for it. A cure needs a
   PROGRESS clock, and on maxDay a cook sitting at day 45 replaying day
   30 could never age a jar at all. Added save.daysServed: monotonic,
   incremented by any completed service. Time passes by cooking, which
   is also just true.
2. The pack rate was measured, not guessed — twice. At 2.6 two thorough
   rasters covered the slab perfectly EVENLY at a mean of 0.33, which is
   uniformly below the covered threshold: a flawless pack reading as
   100% bare. 7.0 makes one pass not enough and two right.

Verified end to end on a clean save: pack 8.0 (covered corner to corner),
three real services elapse, shelf shows 'day 3 of 3 · OPEN IT' in green,
opening gives the good note and consumes the jar. Brûlée 7.5, mandoline
9.6, curry 10.0, chips 10.0, eggs 10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 22:18:56 +10:00
parent 2e4af7a7eb
commit 26a49d26fb
8 changed files with 546 additions and 9 deletions

View File

@ -18,6 +18,7 @@ import { SteamerView } from '../scenes/steamer';
import { SpiceView } from '../scenes/spice';
import { MandolineView } from '../scenes/mandoline';
import { BruleeView } from '../scenes/brulee';
import { CureView } from '../scenes/cure';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
@ -26,7 +27,7 @@ import { juiceCriteria, type JuiceResult } from '../sim/juicing';
import { TempClock } from '../sim/tempclock';
import type { RoastResult } from '../sim/roasting';
import type { AssemblyResult } from '../sim/assembly';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, judgeSpice, judgeMandoline, judgeBrulee, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, judgeSpice, judgeMandoline, judgeBrulee, judgeCurePack, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { JudgeView } from '../scenes/judge';
import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery';
@ -38,6 +39,7 @@ import { DAYS, proceduralOrder, nameBrowning, prepAsk, isToastDay, type Order, t
import { el, Panel } from '../ui/hud';
import { Title } from '../ui/title';
import { DRILLS, scoreDrill, medalRank, medalWord, type Drill, type Medal } from './drills';
import { openCure, CURE_DAYS, type CureRecord } from '../sim/cure';
import { eye } from '../ui/eye';
import { assetUrl } from '../scenes/assets';
@ -53,6 +55,13 @@ interface Save {
maxDay: number;
total: number;
best: Record<string, number>;
/** THE CURING SHELF: jars started on one day and opened on another. */
cures?: CureRecord[];
/** Days SERVED, ever a monotonic clock that only goes up. maxDay is the
* wrong clock for a cure: it only advances when you reach a NEW day, so a
* cook at day 45 replaying day 30 could never age a jar. Any service is a
* day passing, which is also just true. */
daysServed?: number;
/** The cold store, persisted: what you stocked on a fridge day is still there
* (and older) when a later cook reaches in for it. */
fridge?: StoredItemSave[];
@ -97,6 +106,7 @@ export class Game {
private spice: SpiceView;
private mandoline: MandolineView;
private brulee: BruleeView;
private cure: CureView;
private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
@ -156,6 +166,7 @@ export class Game {
this.spice = new SpiceView(app);
this.mandoline = new MandolineView(app);
this.brulee = new BruleeView(app);
this.cure = new CureView(app);
this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
@ -177,6 +188,7 @@ export class Game {
app.scene.add(this.spice.root);
app.scene.add(this.mandoline.root);
app.scene.add(this.brulee.root);
app.scene.add(this.cure.root);
app.scene.add(this.assembly.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
@ -197,6 +209,7 @@ export class Game {
this.spice.root.visible = false;
this.mandoline.root.visible = false;
this.brulee.root.visible = false;
this.cure.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
@ -263,6 +276,7 @@ export class Game {
}
this.save.day++;
this.save.maxDay = Math.max(this.save.maxDay, this.save.day);
this.save.daysServed = (this.save.daysServed ?? 0) + 1; // a day is a day
writeSave(this.save);
this.beginDay();
};
@ -330,6 +344,9 @@ export class Game {
writeSave(this.save);
audio.resume();
audio.blip(0.3);
}, (id) => {
audio.resume();
return this.openCureJar(id);
});
}
@ -372,6 +389,9 @@ export class Game {
get eggBenchView(): EggBenchView {
return this.eggBench;
}
get cureView(): CureView {
return this.cure;
}
get bruleeView(): BruleeView {
return this.brulee;
}
@ -572,6 +592,7 @@ export class Game {
this.spice.root.visible = false;
this.mandoline.root.visible = false;
this.brulee.root.visible = false;
this.cure.root.visible = false;
this.assembly.root.visible = false;
}
@ -799,13 +820,14 @@ export class Game {
if (o.spice) return this.enterSpice();
if (o.mandoline) return this.enterMandoline();
if (o.brulee) return this.enterBrulee();
if (o.cure) return this.enterCure();
this.enterToastFlow();
}
/** The one-line shareable result for the current order's verdict. */
private shareLine(v: Verdict): string {
const o = this.order;
const emoji = o.steak ? '🥩' : o.grill ? '🔥' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? '🥚' : o.eggs ? '🥚' : o.airfryer ? '🍗' : o.fryerpot ? '🍟' : o.pot ? '🍝' : o.steamer ? '🥦' : o.spice ? '🌶️' : o.mandoline ? '🔪' : o.brulee ? '🍮' : o.fridge ? '🧊' : o.prep ? '🔪' : '🍞';
const emoji = o.steak ? '🥩' : o.grill ? '🔥' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? '🥚' : o.eggs ? '🥚' : o.airfryer ? '🍗' : o.fryerpot ? '🍟' : o.pot ? '🍝' : o.steamer ? '🥦' : o.spice ? '🌶️' : o.mandoline ? '🔪' : o.brulee ? '🍮' : o.cure ? '🧂' : o.fridge ? '🧊' : o.prep ? '🔪' : '🍞';
const tag = this.dailyDate ? `daily ${this.dailyDate}` : `day ${o.day}`;
return `TOASTSIM ${tag}${emoji} ${v.grade} ${v.total.toFixed(1)}/10 · partly.party/toastsim`;
}
@ -922,6 +944,11 @@ export class Game {
this.enterBrulee();
return;
}
// The cure: pack it now, and be told about it on some later day.
if (this.order.cure) {
this.enterCure();
return;
}
// A poach day: the shiver, the vortex, the drop, the wobble.
if (this.order.poach) {
this.enterPoach();
@ -1130,6 +1157,61 @@ export class Game {
}
/** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */
private enterCure(): void {
this.cure.reset();
this.hideAllScenes();
this.cure.root.visible = true;
this.app.setView(this.cure);
this.ticket.show();
this.cure.onDone = (result) => {
this.cure.root.visible = false;
// On the shelf it goes, stamped with today. Nothing else happens now —
// that is the entire point of the station.
(this.save.cures ??= []).push({
id: `cure_${this.save.daysServed ?? 0}_${(this.save.cures?.length ?? 0)}`,
kind: this.order.cure?.kind ?? 'gravlax',
startedDay: this.save.daysServed ?? 0,
pack: result,
});
writeSave(this.save);
this.serveCure(result);
};
}
private serveCure(result: ReturnType<CureView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeCurePack(result, this.order, seconds);
this.recordVerdict(v);
this.judgeView.extraLines = [
...this.judgeView.extraLines,
`On the shelf, then. ${CURE_DAYS} days. I will ask.`,
];
this.hideAllScenes();
this.judgeView.root.visible = true;
this.judgeView.show(slice, v, this.order, this.kitchen.toolId, () => this.rng.next());
audio.stamp();
this.app.setView(this.judgeView);
this.ticket.hide();
}
/** Open a jar off the shelf. The verdict is mostly about the WAITING. */
openCureJar(id: string): { note: string; score: number } | null {
const list = this.save.cures ?? [];
const i = list.findIndex((c) => c.id === id);
if (i < 0) return null;
const rec = list[i];
const v = openCure(rec, this.save.daysServed ?? 0);
list.splice(i, 1); // a jar is opened once
const score = v.spoiled ? 0 : Math.round(v.packScore * v.timeScore * 100) / 100;
this.save.total += score * 4; // a good cure is worth a decent service
writeSave(this.save);
audio.clatter(0.5, 0.9);
return { note: v.note, score };
}
private enterBrulee(): void {
this.brulee.reset();
this.hideAllScenes();
@ -1629,7 +1711,7 @@ function loadSave(): Save {
const maxDay = Math.max(day, num(s.maxDay, day));
const st = s.starter;
const starter = st && Number.isFinite(st.born) && Number.isFinite(st.fed) ? { born: Math.min(st.born, maxDay), fed: Math.min(st.fed, maxDay), deaths: num(st.deaths, 0) } : undefined;
return { day, maxDay, total: num(s.total, 0), best: s.best ?? {}, fridge: s.fridge, streak: num(s.streak, 0), customers: s.customers ?? {}, daily: s.daily ?? {}, shadowed: s.shadowed ?? {}, starter, drills: s.drills ?? {} };
return { day, maxDay, total: num(s.total, 0), best: s.best ?? {}, fridge: s.fridge, streak: num(s.streak, 0), customers: s.customers ?? {}, daily: s.daily ?? {}, shadowed: s.shadowed ?? {}, starter, drills: s.drills ?? {}, cures: s.cures ?? [], daysServed: num(s.daysServed, 0) };
}
}
} catch {

View File

@ -19,6 +19,7 @@ import type { SteamerResult } from '../sim/steamer';
import { type FlavorResult, AXES, axisScore, missWord, inBandWord } from '../sim/flavor';
import type { MandolineResult } from '../sim/mandoline';
import { type BruleeResult, crackWord } from '../sim/brulee';
import { type PackResult, packScore } from '../sim/cure';
export interface Criterion {
key: string;
@ -31,7 +32,7 @@ export interface Criterion {
/** Which station earned it the scorecard groups by this on prep orders.
* M15 adds the bruschetta trio: bread (cut+toast+rub), topping (roast,
* distribution, balance, sog), bench (temperature + mess). */
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot' | 'steam' | 'spice' | 'blade' | 'torch';
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot' | 'steam' | 'spice' | 'blade' | 'torch' | 'cure';
}
/** What the prep bench hands the judge, when the order had prep on it. */
@ -621,6 +622,62 @@ export function judgePoach(r: PoachResult, order: Order, seconds: number): Verdi
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// The cure, part one. Today he can only judge the PACK — the verdict on the
// cure itself is days away, and he is quite clear that he has not forgotten.
export function judgeCurePack(r: PackResult, order: Order, seconds: number): Verdict {
const criteria: Criterion[] = [
{
key: 'curecover',
group: 'cure',
label: 'The Cover',
score: clamp01(1 - r.bare / 0.25),
weight: 1.4,
detail: r.bare > 0.3
? 'bare patches. salt cures, air spoils, and you have left it air'
: r.bare > 0.1
? 'a few thin spots that will not cure'
: 'covered corner to corner',
},
{
key: 'cureeven',
group: 'cure',
label: 'The Blanket',
score: clamp01(1 - r.stdev / 0.55),
weight: 1.0,
detail: r.stdev > 0.5 ? 'heaped in places, bare in others' : r.stdev > 0.28 ? 'a little uneven' : 'an even blanket',
},
{
key: 'curerestraint',
group: 'cure',
label: 'The Restraint',
score: clamp01(1 - r.over / 0.3),
weight: 0.8,
detail: r.over > 0.25 ? 'buried. that is more salt than fish' : r.over > 0.08 ? 'heavy in the middle' : 'enough, and no more',
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 70) / 110),
weight: 0.3,
detail: `${Math.round(seconds)}s`,
},
];
let sum = 0;
let wsum = 0;
for (const c of criteria) {
sum += c.score * c.weight;
wsum += c.weight;
}
void packScore;
const total = (sum / wsum) * 10;
const rankable = criteria.filter((c) => c.key !== 'time');
const sorted = [...rankable].sort((a, b) => a.score - b.score);
void order;
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// The brûlée — the only card in the game whose headline row is a SOUND. He
// taps it with the back of a spoon and writes down what he heard.

View File

@ -134,6 +134,8 @@ export interface Order {
mandoline?: { slices: number };
/** THE BLOWTORCH: paint the sugar to glass. The verdict is a SOUND. */
brulee?: true;
/** THE CURE: pack it today, open it days from now. */
cure?: { kind: string };
}
export const BROWNING_NAMES: [number, string][] = [
@ -614,6 +616,21 @@ export const DAYS: Order[] = [
text: 'Cr\u00e8me br\u00fbl\u00e9e. Torch the sugar to a single sheet of GLASS \u2014 hold it close and you will burn a hole, hold it back and sweep and it goes even. And when I tap it with my spoon, I want to hear ONE crack. Not a tap. Not crumbs. A crack.',
brulee: true,
},
{
day: 30,
brand: 'WONDERSLICE',
bread: 'white',
spread: 'butter',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.5,
who: 'the chef, on her day off',
text: 'Gravlax. Pack the whole slab in salt and sugar \u2014 an EVEN blanket, and every bare patch is somewhere it will spoil instead of cure. Then it goes on the shelf and you leave it alone for three days. I will ask you for it. I will remember what day it was.',
cure: { kind: 'gravlax' },
},
];
/** Days beyond the script — keep going, with everything cranked. */
@ -700,6 +717,7 @@ export function stationOf(o: Order): string | null {
if (o.spice) return 'spice';
if (o.mandoline) return 'mandoline';
if (o.brulee) return 'brulee';
if (o.cure) return 'cure';
return null;
}

201
src/scenes/cure.ts Normal file
View File

@ -0,0 +1,201 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { eye } from '../ui/eye';
import {
type CureSession,
newCureSession,
pack,
cureStep,
packResult,
packWord,
COVERED_AT,
OVER_AT,
} from '../sim/cure';
import { el, Panel } from '../ui/hud';
import { loadBackdrop } from './props';
const SLAB_Y = 0.6;
const SLAB_W = 3.4;
/**
* THE CURE BENCH pack it, and then leave it alone for three days.
*
* DRAG the salt-and-sugar mix across the slab. It only ever goes ON: there is
* no unpacking, so the whole skill is laying an even blanket without burying
* it. ENTER puts it on the shelf, and that is the last you see of it today.
*/
export class CureView implements View {
readonly root = new THREE.Group();
private session: CureSession | null = null;
private slab!: THREE.Mesh;
private slabTex!: THREE.DataTexture;
private slabData!: Uint8Array;
private hand!: THREE.Mesh;
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -SLAB_Y);
private bgTex = loadBackdrop('/assets/img/bg_coldroom.png');
private prevBg: unknown = null;
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof packResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('cure-panel');
this.buildUi();
this.panel.hide();
}
private buildUi(): void {
const p = this.panel.root;
const card = el('div', 'slicer-card', p);
el('div', 'drawer-lbl', card, 'PACK IT');
this.askEl = el('div', 'slicer-ask', card, '');
this.stateEl = el('div', 'slicer-thick', card, '');
this.wordEl = el('div', 'slicer-wobble', card, '');
this.hintEl = el('div', 'drawer-hint', p, '');
}
private buildScenery(): void {
const bench = new THREE.Mesh(
new THREE.BoxGeometry(9, 0.4, 6),
new THREE.MeshStandardMaterial({ color: 0x44484c, roughness: 0.6, metalness: 0.3 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
const tray = new THREE.Mesh(
new THREE.BoxGeometry(SLAB_W + 0.5, 0.16, SLAB_W * 0.62),
new THREE.MeshStandardMaterial({ color: 0xb8bec4, roughness: 0.3, metalness: 0.7 }),
);
tray.position.y = SLAB_Y - 0.12;
tray.receiveShadow = true;
this.root.add(tray);
// The slab. Its texture is the cover Field: pink flesh going white.
const n = 24;
this.slabData = new Uint8Array(n * n * 4);
this.slabTex = new THREE.DataTexture(this.slabData, n, n, THREE.RGBAFormat);
this.slabTex.minFilter = THREE.LinearFilter;
this.slabTex.magFilter = THREE.LinearFilter;
this.slabTex.colorSpace = THREE.SRGBColorSpace;
this.slab = new THREE.Mesh(
new THREE.PlaneGeometry(SLAB_W, SLAB_W),
new THREE.MeshStandardMaterial({ map: this.slabTex, roughness: 0.6, transparent: true }),
);
this.slab.rotation.x = -Math.PI / 2;
this.slab.position.y = SLAB_Y;
this.root.add(this.slab);
// The hand: a pale disc showing where the mix is going.
this.hand = new THREE.Mesh(
new THREE.CircleGeometry(0.22, 20),
new THREE.MeshBasicMaterial({ color: 0xf4f0e2, transparent: true, opacity: 0 }),
);
this.hand.rotation.x = -Math.PI / 2;
this.root.add(this.hand);
}
reset(askText = 'pack it — an even blanket, no bare patches'): void {
this.session = newCureSession();
this.wordEl.textContent = '';
this.wordEl.style.color = '';
this.askEl.textContent = askText;
this.hintEl.textContent = 'DRAG the mix across the slab — it only goes ON · ENTER shelves it, and you wait DAYS';
}
enter(): void {
this.prevBg = this.app.scene.background;
this.app.scene.background = this.bgTex;
this.panel.show();
}
exit(): void {
this.panel.hide();
this.app.scene.background = this.prevBg as never;
}
update(dt: number): void {
this.app.camera.position.set(0, 3.6, 2.6);
this.app.camera.lookAt(0, SLAB_Y, 0);
const s = this.session;
if (!s) return;
const inp = this.app.input;
this.ray.setFromCamera(inp.ndc, this.app.camera);
const on = this.ray.ray.intersectPlane(this.plane, this.hit);
const hm = this.hand.material as THREE.MeshBasicMaterial;
if (on) {
this.hand.position.set(this.hit.x, SLAB_Y + 0.02, this.hit.z);
hm.opacity += ((inp.down ? 0.5 : 0.2) - hm.opacity) * (1 - Math.exp(-dt / 0.08));
if (inp.down) {
pack(s, this.hit.x / SLAB_W + 0.5, this.hit.z / SLAB_W + 0.5, dt);
audio.bed('pack', 0.02, 4200, 0.8, 0.7);
} else {
audio.bed('pack', 0, 4200);
}
} else {
hm.opacity += (0 - hm.opacity) * (1 - Math.exp(-dt / 0.1));
audio.bed('pack', 0, 4200);
}
cureStep(s, dt);
this.syncVisuals();
this.updateHud();
if (inp.justPressed('Enter')) {
audio.bed('pack', 0, 4200);
audio.clatter(0.4, 0.8);
eye.mood('intrigued', 2);
const cb = this.onDone;
this.onDone = null;
cb?.(packResult(s));
}
}
private syncVisuals(): void {
const s = this.session!;
const n = 24;
for (let i = 0; i < n * n; i++) {
const inSlab = s.mask.data[i] > 0;
this.slabData[i * 4 + 3] = inSlab ? 255 : 0;
if (!inSlab) continue;
const c = s.cover.data[i];
// Raw flesh is deep pink; the cure blanket whitens it, and a burial
// goes flat chalk so "too much" is as visible as "not enough".
const k = Math.min(1, c / COVERED_AT);
let r = 214 + k * 26;
let g = 108 + k * 118;
let b = 96 + k * 116;
if (c > OVER_AT) {
const o = Math.min(1, (c - OVER_AT) / 0.45);
r = 240 - o * 12; g = 226 + o * 14; b = 212 + o * 26;
}
this.slabData[i * 4] = r;
this.slabData[i * 4 + 1] = g;
this.slabData[i * 4 + 2] = b;
}
this.slabTex.needsUpdate = true;
}
private updateHud(): void {
const s = this.session!;
const p = packResult(s);
this.stateEl.textContent = `${Math.round((1 - p.bare) * 100)}% covered${p.over > 0.02 ? ` · ${Math.round(p.over * 100)}% buried` : ''} · ${Math.round(s.seconds)}s`;
this.wordEl.textContent = packWord(s);
}
result(): ReturnType<typeof packResult> {
return packResult(this.session!);
}
}

View File

@ -195,7 +195,7 @@ export class JudgeView implements View {
// Grill and bruschetta days don't have a browning/spread to name — show who
// asked and what for, not the unread toast defaults.
// The dish itself, in miniature, beside the ask.
const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : order.fryerpot ? 'chips' : order.pot ? 'pasta' : order.steamer ? 'greens' : order.spice ? 'curry' : order.mandoline ? 'blade' : order.brulee ? 'brulee' : null;
const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : order.fryerpot ? 'chips' : order.pot ? 'pasta' : order.steamer ? 'greens' : order.spice ? 'curry' : order.mandoline ? 'blade' : order.brulee ? 'brulee' : order.cure ? 'cure' : null;
this.orderEl.parentElement?.querySelector('.judge-hero')?.remove();
if (heroKey) {
const hero = el('img', 'judge-hero', this.orderEl.parentElement ?? this.orderEl) as HTMLImageElement;
@ -203,7 +203,9 @@ export class JudgeView implements View {
hero.src = assetUrl(`/assets/img/hero_${heroKey}.png`);
this.orderEl.parentElement?.insertBefore(hero, this.orderEl);
}
this.orderEl.textContent = order.brulee
this.orderEl.textContent = order.cure
? `Day ${order.day} · ${order.who} started the cure`
: order.brulee
? `Day ${order.day} · ${order.who} asked for the br\u00fbl\u00e9e`
: order.mandoline
? `Day ${order.day} · ${order.who} asked for the mandoline`
@ -241,7 +243,7 @@ export class JudgeView implements View {
// Station orders (prep, and M15's bruschetta trio) get the grouped card;
// a plain toast order keeps the flat card it always had.
const grouped = verdict.criteria.some(
(c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak' || c.group === 'eggs' || c.group === 'poach' || c.group === 'fryer' || c.group === 'pot' || c.group === 'steam' || c.group === 'spice' || c.group === 'blade' || c.group === 'torch',
(c) => c.group === 'prep' || c.group === 'bread' || c.group === 'topping' || c.group === 'bench' || c.group === 'grill' || c.group === 'pan' || c.group === 'cold' || c.group === 'steak' || c.group === 'eggs' || c.group === 'poach' || c.group === 'fryer' || c.group === 'pot' || c.group === 'steam' || c.group === 'spice' || c.group === 'blade' || c.group === 'torch' || c.group === 'cure',
);
const headers: Record<string, string> = {
prep: 'THE PREP',
@ -261,9 +263,10 @@ export class JudgeView implements View {
spice: 'THE SEASONING',
blade: 'THE BLADE',
torch: 'THE TORCH',
cure: 'THE PACK',
bench: 'THE BENCH',
};
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, poach: 3, fryer: 3, pot: 3, steam: 3, spice: 3, blade: 3, torch: 3, spread: 4, bench: 5 };
const groupRank: Record<string, number> = { prep: 0, bread: 1, toast: 2, topping: 3, grill: 3, pan: 3, cold: 3, steak: 3, eggs: 3, poach: 3, fryer: 3, pot: 3, steam: 3, spice: 3, blade: 3, torch: 3, cure: 3, spread: 4, bench: 5 };
let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped

146
src/sim/cure.ts Normal file
View File

@ -0,0 +1,146 @@
import { Field } from '../core/field';
/**
* THE CURE time as an ingredient, and the first thing in this kitchen you
* cannot finish today.
*
* You pack a slab in salt and sugar and then you put it on a shelf and LEAVE
* it. The pack is judged the way every dose in this game is judged coverage
* and evenness, with no way to take salt back off but the verdict does not
* arrive until days later, when you open the jar and find out whether you
* were patient or greedy.
*
* A bare patch does not cure; it spoils. Too long and the whole slab goes
* from silk to jerky. The clock runs on the save's day counter, so the only
* way to pass time is to cook other people's breakfast.
*/
const N = 24;
/** Days of shelf time this cure wants. */
export const CURE_DAYS = 3;
/** Cover per second of packing under the hand. */
// Measured, not guessed: two thorough rasters of the whole slab is about 3.5
// seconds of hand, and two rasters is the rhythm this wants. At 2.6 that came
// to a mean of 0.33 — every texel evenly, uniformly UNDER the covered
// threshold, so a perfect pack read as 100% bare. At 7.0 one pass is not
// enough and two is right.
const PACK_RATE = 7.0;
/** Enough salt on a texel to actually cure it. */
export const COVERED_AT = 0.45;
/** Past this the flesh has given up more water than it should. */
export const OVER_AT = 1.55;
export interface CureSession {
/** Salt+sugar mass per texel. Only ever goes up — there is no unpacking. */
cover: Field;
mask: Field;
seconds: number;
}
export function newCureSession(): CureSession {
const mask = new Field(N);
const cover = new Field(N);
// The slab: a rounded rectangle of fish, not the whole tray.
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const u = (x + 0.5) / N - 0.5;
const v = (y + 0.5) / N - 0.5;
mask.data[y * N + x] = Math.abs(u) < 0.42 && Math.abs(v) < 0.26 ? 1 : 0;
}
}
return { cover, mask, seconds: 0 };
}
/** Rub the mix on. `dt` is the dose; holding still stacks it in one place. */
export function pack(s: CureSession, u: number, v: number, dt: number, radius = 0.13): void {
s.cover.brush(u, v, radius, (i, w) => {
if (s.mask.data[i] <= 0) return;
s.cover.data[i] = Math.min(2, s.cover.data[i] + PACK_RATE * w * dt);
});
}
export function cureStep(s: CureSession, dt: number): void {
s.seconds += dt;
}
export interface PackResult {
mean: number;
stdev: number;
/** Fraction of the slab left effectively bare — this is where it spoils. */
bare: number;
/** Fraction buried past what the flesh can take. */
over: number;
seconds: number;
}
export function packResult(s: CureSession): PackResult {
const st = s.cover.stats(s.mask);
return {
mean: st.mean,
stdev: st.stdev,
bare: s.cover.fraction(s.mask, (v) => v < COVERED_AT),
over: s.cover.fraction(s.mask, (v) => v > OVER_AT),
seconds: s.seconds,
};
}
/** What a jar on the shelf remembers about the day it was packed. */
export interface CureRecord {
id: string;
kind: string;
/** The save's SERVED-day count when it went on the shelf. */
startedDay: number;
pack: PackResult;
}
export interface CureVerdict {
/** 0..1 — how the pack itself was. */
packScore: number;
/** 0..1 — how the waiting went. */
timeScore: number;
daysWaited: number;
spoiled: boolean;
note: string;
}
/** How well the salt went on. Bare patches are the sin; unevenness is a flaw. */
export function packScore(p: PackResult): number {
const even = Math.max(0, 1 - p.stdev / 0.55);
const enough = Math.max(0, 1 - p.bare / 0.25);
const restraint = Math.max(0, 1 - p.over / 0.3);
return Math.max(0, enough * (0.5 + 0.3 * even + 0.2 * restraint));
}
/**
* Open it. `daysWaited` is the honest count off the save, so the only way to
* get this right is to have actually gone away and cooked something else.
*/
export function openCure(rec: CureRecord, currentDay: number): CureVerdict {
const daysWaited = Math.max(0, currentDay - rec.startedDay);
const ps = packScore(rec.pack);
// A bare patch plus days on a shelf is not an under-cure, it is a spoil.
const spoiled = rec.pack.bare > 0.3 && daysWaited >= 2;
const timeScore = daysWaited < CURE_DAYS
? Math.max(0, daysWaited / CURE_DAYS)
: Math.max(0, 1 - (daysWaited - CURE_DAYS) / 5);
let note: string;
if (spoiled) note = 'There were bare patches and you left it three days. That is not cured, that is spoiled.';
else if (daysWaited === 0) note = 'You packed it and opened it the same day. It is a salty raw fish.';
else if (daysWaited < CURE_DAYS) note = `${daysWaited} day${daysWaited > 1 ? 's' : ''}. It wanted ${CURE_DAYS}. The middle is still raw.`;
else if (daysWaited <= CURE_DAYS + 1) note = `You packed this ${daysWaited} services ago and left it alone. It shows. Good.`;
else if (daysWaited <= CURE_DAYS + 4) note = 'A little long on the shelf — firm, but I have had worse.';
else note = `${daysWaited} days. This is jerky with ambitions.`;
return { packScore: ps, timeScore, daysWaited, spoiled, note };
}
/** The slab, out loud, while you are packing it. */
export function packWord(s: CureSession): string {
const p = packResult(s);
if (p.mean < 0.05) return 'a bare slab and a bowl of salt';
if (p.bare > 0.45) return 'most of it is still uncovered';
if (p.bare > 0.12) return 'bare patches left — they will not cure, they will spoil';
if (p.over > 0.25) return 'buried. that is more salt than flesh';
if (p.stdev > 0.45) return 'covered, but heaped in places and thin in others';
return 'an even blanket, corner to corner. shelf it';
}

View File

@ -985,3 +985,8 @@ body:has(#ui > .title) #touchpad { display: none; }
.drill-note { font-size: 12px; font-style: italic; color: var(--gold); margin: 10px 0 4px; }
.drill-beat { font: 600 10px ui-monospace, monospace; color: #8fce8f; margin-top: 4px; }
.drill-card .judge-btns { justify-content: center; margin-top: 14px; }
/* The curing shelf: a jar that is ready says so. */
.jar-row.ready .drill-best { color: #8fce8f; }
.jar-row.opened { opacity: 0.75; font-style: italic; }
.jar-row.opened .drill-name { white-space: normal; text-align: left; }

View File

@ -3,6 +3,7 @@ import { assetUrl } from '../scenes/assets';
import { gradeOf } from '../game/judging';
import { DAYS } from '../game/orders';
import { DRILLS, medalWord, type Medal } from '../game/drills';
import { CURE_DAYS, type CureRecord } from '../sim/cure';
/**
* The front door. One button, one piece of art, one joke and now the ledger:
@ -13,9 +14,10 @@ export class Title {
private panel: Panel;
constructor(
save: { day: number; maxDay: number; best: Record<string, number>; shadowed?: Record<string, boolean>; starter?: { born: number; fed: number; deaths: number }; drills?: Record<string, { medal: Medal; cv: number; seconds: number }> },
save: { day: number; maxDay: number; best: Record<string, number>; shadowed?: Record<string, boolean>; starter?: { born: number; fed: number; deaths: number }; drills?: Record<string, { medal: Medal; cv: number; seconds: number }>; cures?: CureRecord[]; daysServed?: number },
onStart: (day?: number | 'daily' | string, shadow?: boolean) => void,
onFeed?: () => void,
onOpenCure?: (id: string) => { note: string; score: number } | null,
) {
this.panel = new Panel('title');
const wrap = el('div', 'title-wrap', this.panel.root);
@ -61,6 +63,29 @@ export class Title {
b.addEventListener('click', () => go(`drill:${d.id}`));
}
// THE CURING SHELF. The only thing in this kitchen you cannot finish
// today: jars stamped with the day they were packed, counting up.
if (save.cures && save.cures.length) {
el('div', 'title-days-lbl', txt, 'THE CURING SHELF \u2014 time is the ingredient');
const shelf = el('div', 'title-drills', txt);
for (const c of save.cures.slice()) {
const waited = Math.max(0, (save.daysServed ?? 0) - c.startedDay);
const ready = waited >= CURE_DAYS;
const b = el('button', `btn ghost drill-btn jar-row${ready ? ' ready' : ''}`, shelf);
el('span', 'drill-name', b, `${c.kind.toUpperCase()} \u2014 packed ${waited === 0 ? 'today' : `${waited} service${waited > 1 ? 's' : ''} ago`}`);
el('span', 'drill-best', b, ready ? `day ${waited} of ${CURE_DAYS} \u00b7 OPEN IT` : `day ${waited} of ${CURE_DAYS}`);
b.addEventListener('click', () => {
const r = onOpenCure?.(c.id);
if (!r) return;
// Opening is irreversible, so the answer replaces the row in place.
b.textContent = '';
el('span', 'drill-name', b, r.note);
b.classList.add('opened');
b.disabled = true;
});
}
}
// THE MOTHER: the sourdough starter. A pet that lives in the save and
// holds a grudge. Feeding is one click; forgetting is a funeral.
if (save.starter) {