diff --git a/src/game/game.ts b/src/game/game.ts index c3663d1..32e8533 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -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; + /** 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): 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 { diff --git a/src/game/judging.ts b/src/game/judging.ts index 6e9905a..9eb0980 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -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. diff --git a/src/game/orders.ts b/src/game/orders.ts index a6b3d91..8c1fa9c 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -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; } diff --git a/src/scenes/cure.ts b/src/scenes/cure.ts new file mode 100644 index 0000000..6145904 --- /dev/null +++ b/src/scenes/cure.ts @@ -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) => 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 { + return packResult(this.session!); + } +} diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index 1822a2c..955cc05 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -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 = { 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 = { 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 = { 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 diff --git a/src/sim/cure.ts b/src/sim/cure.ts new file mode 100644 index 0000000..04aca84 --- /dev/null +++ b/src/sim/cure.ts @@ -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'; +} diff --git a/src/style.css b/src/style.css index 14a21d7..a361dcb 100644 --- a/src/style.css +++ b/src/style.css @@ -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; } diff --git a/src/ui/title.ts b/src/ui/title.ts index 4510d71..32c9414 100644 --- a/src/ui/title.ts +++ b/src/ui/title.ts @@ -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; shadowed?: Record; starter?: { born: number; fed: number; deaths: number }; drills?: Record }, + save: { day: number; maxDay: number; best: Record; shadowed?: Record; starter?: { born: number; fed: number; deaths: number }; drills?: Record; 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) {