THE SPICE RACK: day 27 — six axes, no subtract, and a chili that lies

P7, and the irreversibility doctrine generalised. The pot proved it on
one axis; flavour space has six — salt, heat, acid, sweet, bitter,
aroma — and every ticket is a REGION you are trying to land in. Pouring
is the only verb that moves the point. Nothing moves it back.

THE CHILI IS THE STATION. Its heat does not arrive when you add it; it
BLOOMS eight seconds later. So TASTE — the one honest instrument on the
bench — physically cannot see the heat you just put in. It tells you the
truth about a pot that is about to stop being true. The readout is a
MEMORY, not a meter: it prints how many seconds ago the spoon said it,
and the state line will admit 'something in there is still coming up' —
but the pips do not move, because a tongue is not a sensor. The second
pinch is the one that ruins the dish and the game never says don't.

DILUTE is the only eraser and a deliberately bad one: it drags every
axis toward nothing, including the four you had right, and leaves you a
big pot of not very much.

Two fixes that mattered more than the feature:

1. Tasting fast-forwarded the bloom. taste() added 3s to the flavour
   clock ON TOP of the 3-second spoon animation the clock already ran
   through — so a cook could taste twice to skip the lag, defusing the
   only trap the station has. Removed.

2. THE STATION CARD HAS BEEN INVISIBLE. `.slicer-card` sits at (26,26);
   the order ticket sits at (22,20) and is 272 wide and 365 tall. The
   card was painted completely underneath it — elementFromPoint at the
   card's centre returned the ticket's own button. That is EVERY station
   in the game: the pan's butter word, the poach's shiver, the fryer's
   oil, the steamer's greens — the entire no-meters interface, hidden
   behind the ask. Moved to the opposite corner: what they want on the
   left, what the pan is telling you on the right.

Flavour is also not scored as a mean, because nobody eats an average —
they taste the loudest thing in the bowl. The card is pulled hard toward
its worst row, so five perfect axes cannot hide one that is screaming.

Verified: honest curry 10.0; the chili trap 5.4 (spoon read 0.00 heat,
second pinch bloomed to 0.73, 'that is not warm, that is ANGRY') — 8.1
before the worst-axis weighting, which was far too kind; the panicker
who over-salts and thins twice 5.2, 'a big pot of not very much'.
Steamer, pasta, chips and air fryer all unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 19:51:43 +10:00
parent 9ec99add02
commit 0e092e2551
8 changed files with 791 additions and 11 deletions

View File

@ -678,6 +678,49 @@ export function installDevHarness(app: App, game: Game): void {
return { floats: s.eggs.map((e) => e.floatWord), rottenIn: s.rottenIn, dumped: s.bowlsDumped, rows };
},
/** Play the day-27 curry with REAL input: pour each spice into its band,
* taste, WAIT for the chili to bloom, taste again, serve. */
async spiceDay() {
game.setDay(27);
await F(6);
const sv = game.spiceView as unknown as { session: import('./sim/flavor').FlavorSession };
const s = sv.session;
if (!s) return { error: 'not at spice bench' };
const SP = (await import('./sim/flavor')).SPICES;
const jarX = (i: number) => (i - (SP.length - 1) / 2) * 0.62;
/** Carry jar `i` over the pot and hold it there until `stop()` says so. */
const pourUntil = async (i: number, stop: () => boolean, cap = 60 * 20) => {
point(jarX(i), 0.85, -1.55, false); await F(2);
point(jarX(i), 0.85, -1.55, true); await F(2);
point(0, 0.85, 0, true); // over the pot — pouring starts
let g = 0;
while (!stop() && g++ < cap) await F(1);
point(0, 0.85, 0, false); // lift away
await F(3);
};
// Salt, lemon, cumin straight into the middle of their bands.
await pourUntil(0, () => s.vec.salt >= 0.43);
await pourUntil(3, () => s.vec.acid >= 0.31);
await pourUntil(5, () => s.vec.aroma >= 0.41);
// The chili: pour a measured amount and then STOP. What is in the pot
// reads as no heat at all for eight seconds — the trap is believing it.
await pourUntil(1, () => {
const pend = s.pending.reduce((a, p) => a + (p.axis === 'heat' ? p.amount : 0), 0);
return s.vec.heat + pend >= 0.36;
});
tap('KeyT'); // taste — honest about a pot that is still changing
await F(200);
// Wait for the bloom before deciding anything else. This is the lesson.
let g = 0;
while (s.pending.length && g++ < 60 * 20) await F(1);
tap('KeyT'); // taste again, now that it has stopped moving
await F(200);
tap('Enter');
await F(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
return { vec: Object.fromEntries(Object.entries(s.vec).map(([k, v]) => [k, (v as number).toFixed(2)])), tastes: s.tastes, volume: s.volume, rows };
},
/** Play the day-26 greens with REAL input: steam in the band, lid ON to
* cook, lift it to LOOK near the end, serve tender and still green. */
async steamDay() {
@ -1547,6 +1590,7 @@ export function installDevHarness(app: App, game: Game): void {
24: () => harness.chipDay(),
25: () => harness.potDay(),
26: () => harness.steamDay(),
27: () => harness.spiceDay(),
};
(harness as unknown as { demos: typeof demos }).demos = demos;
(harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => {

View File

@ -15,6 +15,7 @@ import { AirFryerView } from '../scenes/airfryer';
import { FryerPotView } from '../scenes/fryerpot';
import { PotView } from '../scenes/pot';
import { SteamerView } from '../scenes/steamer';
import { SpiceView } from '../scenes/spice';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
@ -23,7 +24,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, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, judgeFryerPot, judgePot, judgeSteamer, judgeSpice, 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';
@ -88,6 +89,7 @@ export class Game {
private fryerPot: FryerPotView;
private pot: PotView;
private steamer: SteamerView;
private spice: SpiceView;
private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
@ -141,6 +143,7 @@ export class Game {
this.fryerPot = new FryerPotView(app);
this.pot = new PotView(app);
this.steamer = new SteamerView(app);
this.spice = new SpiceView(app);
this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
@ -159,6 +162,7 @@ export class Game {
app.scene.add(this.fryerPot.root);
app.scene.add(this.pot.root);
app.scene.add(this.steamer.root);
app.scene.add(this.spice.root);
app.scene.add(this.assembly.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
@ -176,6 +180,7 @@ export class Game {
this.fryerPot.root.visible = false;
this.pot.root.visible = false;
this.steamer.root.visible = false;
this.spice.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
@ -333,6 +338,9 @@ export class Game {
get eggBenchView(): EggBenchView {
return this.eggBench;
}
get spiceView(): SpiceView {
return this.spice;
}
get steamerView(): SteamerView {
return this.steamer;
}
@ -521,6 +529,7 @@ export class Game {
this.fryerPot.root.visible = false;
this.pot.root.visible = false;
this.steamer.root.visible = false;
this.spice.root.visible = false;
this.assembly.root.visible = false;
}
@ -670,13 +679,14 @@ export class Game {
if (o.fryerpot) return this.enterFryerPot();
if (o.pot) return this.enterPot();
if (o.steamer) return this.enterSteamer();
if (o.spice) return this.enterSpice();
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.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.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`;
}
@ -778,6 +788,11 @@ export class Game {
this.enterSteamer();
return;
}
// The spice rack: six axes, no subtract, and a chili that lies.
if (this.order.spice) {
this.enterSpice();
return;
}
// A poach day: the shiver, the vortex, the drop, the wobble.
if (this.order.poach) {
this.enterPoach();
@ -986,6 +1001,34 @@ export class Game {
}
/** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */
private enterSpice(): void {
const sp = this.order.spice!;
this.spice.reset(sp.target, 'season it \u2014 warm, bright, and properly salted', sp.base);
this.hideAllScenes();
this.spice.root.visible = true;
this.app.setView(this.spice);
this.ticket.show();
this.spice.onDone = (result) => {
this.spice.root.visible = false;
this.serveSpice(result);
};
}
private serveSpice(result: ReturnType<SpiceView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeSpice(result, this.order, seconds);
this.recordVerdict(v);
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();
}
private enterSteamer(): void {
this.steamer.reset('greens \u2014 tender, and still GREEN');
this.hideAllScenes();

View File

@ -16,6 +16,7 @@ import type { EggResult } from '../sim/eggs';
import type { PoachResult } from '../sim/poach';
import { type PotResult, saltScore, SALT_LO, SALT_RUIN } from '../sim/pot';
import type { SteamerResult } from '../sim/steamer';
import { type FlavorResult, AXES, axisScore, missWord, inBandWord } from '../sim/flavor';
export interface Criterion {
key: string;
@ -28,7 +29,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';
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer' | 'pot' | 'steam' | 'spice';
}
/** What the prep bench hands the judge, when the order had prep on it. */
@ -618,6 +619,63 @@ 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 spice rack — a row per axis the ticket actually asked about, because
// "it needs something" is not a note, and he has never once given one.
export function judgeSpice(r: FlavorResult, order: Order, seconds: number): Verdict {
const criteria: Criterion[] = [];
for (const a of AXES) {
const band = r.target[a];
if (!band) continue; // an axis the dish never cared about earns no row
const sc = axisScore(r.vec[a], band);
criteria.push({
key: `flavor_${a}`,
group: 'spice',
label: a[0].toUpperCase() + a.slice(1),
score: clamp01(sc),
weight: a === 'heat' ? 1.3 : 1.0, // the trap axis carries the most
detail: sc >= 0.999 ? inBandWord(a) : missWord(a, r.vec[a], band),
});
}
criteria.push({
key: 'flavorbody',
group: 'bench',
label: 'The Body',
score: clamp01(1 - (r.volume - 1) * 1.5),
weight: 0.9,
detail: r.dilutions === 0
? 'full bodied — you never had to thin it'
: r.dilutions === 1
? 'thinned once. more of it, and less of everything in it'
: `thinned ${r.dilutions}×. this is a big pot of not very much`,
});
criteria.push({
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 95) / 130),
weight: 0.3,
detail: `${Math.round(seconds)}s${r.tastes ? `, tasted ${r.tastes}\u00d7` : ', never tasted it'}`,
});
let sum = 0;
let wsum = 0;
for (const c of criteria) {
sum += c.score * c.weight;
wsum += c.weight;
}
const rankable = criteria.filter((c) => c.key !== 'time');
const sorted = [...rankable].sort((a, b) => a.score - b.score);
// Flavour is NOT a mean. Nobody eats an average — they taste the loudest
// thing in the bowl, and one axis screaming ruins a dish that is perfect
// on the other five. So the card is pulled hard toward its worst row. A
// dish that is right everywhere still scores a clean ten; a dish that is
// right everywhere except ANGRY does not get to hide behind the rest.
const mean = sum / wsum;
const total = (mean * 0.55 + sorted[0].score * 0.45) * 10;
void order;
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// The steamer — the only card where COLOUR is the headline row. Grey broccoli
// is a visible confession and he will not be talked out of it.

View File

@ -128,6 +128,8 @@ export interface Order {
pot?: { dish: string };
/** THE STEAMER: the lid is opaque. Cooking blind is the game. */
steamer?: { veg: string };
/** THE SPICE RACK: six axes, no subtract, and a chili that lies. */
spice?: { dish: string; target: import('../sim/flavor').Target; base?: Partial<Record<import('../sim/flavor').Axis, number>> };
}
export const BROWNING_NAMES: [number, string][] = [
@ -559,6 +561,25 @@ export const DAYS: Order[] = [
text: 'Steamed greens. STEAMED \u2014 not boiled, I can tell the difference by looking at them. Lid on or it will never cook, lid off if you want to see what you are doing, and you cannot have both. Tender, and still green.',
steamer: { veg: 'broccoli' },
},
{
day: 27,
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: 'Season the curry. Warm \u2014 WARM, not angry, and remember the chili takes its time to show you what it is. Bright with lemon. Properly salted. It should smell like someone cooked it. Taste it before you serve it, and taste it AGAIN before you add more.',
spice: {
dish: 'curry',
target: { salt: [0.34, 0.52], heat: [0.28, 0.46], acid: [0.22, 0.40], aroma: [0.30, 0.52] },
base: { aroma: 0.06, bitter: 0.04 },
},
},
];
/** Days beyond the script — keep going, with everything cranked. */
@ -581,7 +602,8 @@ export function proceduralOrder(day: number, rand: () => number): Order {
if (roll < 0.915) return proceduralAirfryer(day, rand);
if (roll < 0.925) return proceduralFryerPot(day, rand);
if (roll < 0.935) return proceduralPot(day, rand);
if (roll < 0.95) return proceduralSteamer(day, rand);
if (roll < 0.945) return proceduralSteamer(day, rand);
if (roll < 0.96) return proceduralSpice(day, rand);
if (roll < 0.945) return proceduralPoach(day, rand);
if (roll < 0.97) return proceduralBenedict(day, rand);
return proceduralFridge(day, rand);
@ -639,6 +661,7 @@ export function stationOf(o: Order): string | null {
if (o.fryerpot) return 'fryerpot';
if (o.pot) return 'pot';
if (o.steamer) return 'steamer';
if (o.spice) return 'spice';
return null;
}
@ -655,6 +678,26 @@ function proceduralSteamer(day: number, rand: () => number): Order {
return o;
}
function proceduralSpice(day: number, rand: () => number): Order {
const o = baseOrder(
day,
pickOf(rand, ['the chef, on her day off', 'the man at table nine', 'a regular', 'someone new']),
'Season it. Balanced, and nothing shouting over the top of anything else.',
);
// The bands drift day to day so the shelf never becomes muscle memory.
const lo = (c: number) => Math.round((c + (rand() - 0.5) * 0.1) * 100) / 100;
const salt = lo(0.4);
const heat = lo(0.36);
const acid = lo(0.3);
const aroma = lo(0.4);
o.spice = {
dish: 'curry',
target: { salt: [salt - 0.09, salt + 0.09], heat: [heat - 0.09, heat + 0.09], acid: [acid - 0.09, acid + 0.09], aroma: [aroma - 0.11, aroma + 0.11] },
base: { aroma: 0.06, bitter: 0.04 },
};
return o;
}
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. */

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' : 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' : 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.steamer
this.orderEl.textContent = order.spice
? `Day ${order.day} · ${order.who} asked for it SEASONED`
: order.steamer
? `Day ${order.day} · ${order.who} asked for the greens`
: order.pot
? `Day ${order.day} · ${order.who} asked for pasta`
@ -235,7 +237,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) => 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',
);
const headers: Record<string, string> = {
prep: 'THE PREP',
@ -252,9 +254,10 @@ export class JudgeView implements View {
fryer: 'THE FRYER',
pot: 'THE POT',
steam: 'THE STEAMER',
spice: 'THE SEASONING',
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, 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, spread: 4, bench: 5 };
let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped

323
src/scenes/spice.ts Normal file
View File

@ -0,0 +1,323 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { eye } from '../ui/eye';
import {
type FlavorSession,
type Axis,
newFlavorSession,
pour,
dilute,
taste,
flavorStep,
flavorResult,
potWord,
SPICES,
AXES,
} from '../sim/flavor';
import { el, Panel } from '../ui/hud';
import { loadBackdrop } from './props';
import { loadProp } from './assets';
const POT_Y = 0.55;
const RACK_Z = -1.55;
/**
* THE SPICE BENCH six jars, one pot, and no way back.
*
* DRAG a jar over the pot and HOLD it there: it pours for as long as you keep
* it there and stops when you take it away, and nothing you do afterwards
* removes what went in. T tastes (three seconds of your service, and the
* readout it gives you is a MEMORY it ages on the card and it cannot see
* the chili that has not bloomed yet). D thins the pot, which is the only
* eraser and a bad one. ENTER serves.
*/
export class SpiceView implements View {
readonly root = new THREE.Group();
private session: FlavorSession | null = null;
private potMix!: THREE.Mesh;
private jars: THREE.Group[] = [];
private jarHome: THREE.Vector3[] = [];
private spoon!: THREE.Mesh;
private grabbed = -1;
private steam: THREE.Mesh[] = [];
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(POT_Y + 0.3));
private bgTex = loadBackdrop('/assets/img/bg_pantry.png');
private prevBg: unknown = null;
private panel: Panel;
private askEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private readoutEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof flavorResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('spice-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, 'SEASON IT');
this.askEl = el('div', 'slicer-ask', card, '');
this.readoutEl = el('div', 'taste-readout', 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: 0x4a3a2c, roughness: 0.8 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
const pot = new THREE.Mesh(
new THREE.CylinderGeometry(1.15, 1.08, 0.9, 32, 1, true),
new THREE.MeshStandardMaterial({ color: 0x9298a0, roughness: 0.3, metalness: 0.8, side: THREE.DoubleSide }),
);
pot.position.y = POT_Y;
this.root.add(pot);
void loadProp('/assets/models/stock_pot.glb', 2.7)
.then((prop) => {
prop.position.set(0, 0.06, 0);
pot.visible = false;
this.root.add(prop);
})
.catch(() => undefined);
// The dish itself. Its colour carries the vector — heat reddens it,
// aroma darkens it, dilution washes it out.
this.potMix = new THREE.Mesh(
new THREE.CylinderGeometry(0.6, 0.6, 0.07, 32),
new THREE.MeshStandardMaterial({ color: 0xc98a42, roughness: 0.55 }),
);
this.potMix.position.y = POT_Y + 0.04;
this.root.add(this.potMix);
for (let i = 0; i < 7; i++) {
const w = new THREE.Mesh(
new THREE.SphereGeometry(0.1, 8, 6),
new THREE.MeshBasicMaterial({ color: 0xe8e0d4, transparent: true, opacity: 0.12 }),
);
w.position.set((Math.random() - 0.5) * 1.1, POT_Y + 0.5 + Math.random() * 0.7, (Math.random() - 0.5) * 1.1);
this.steam.push(w);
this.root.add(w);
}
// The rack: one jar per spice, each its own colour so the hand learns
// the shelf rather than reading labels every time.
const jarColours = [0xf2efe6, 0xc2352a, 0x4a4038, 0xe8d64a, 0xf6f2e2, 0xa8702c];
for (let i = 0; i < SPICES.length; i++) {
const g = new THREE.Group();
const glass = new THREE.Mesh(
new THREE.CylinderGeometry(0.19, 0.19, 0.42, 14),
new THREE.MeshStandardMaterial({ color: 0xd8d4c8, roughness: 0.25, transparent: true, opacity: 0.55 }),
);
const fill = new THREE.Mesh(
new THREE.CylinderGeometry(0.155, 0.155, 0.28, 14),
new THREE.MeshStandardMaterial({ color: jarColours[i], roughness: 0.9 }),
);
fill.position.y = -0.05;
const cap = new THREE.Mesh(
new THREE.CylinderGeometry(0.2, 0.2, 0.07, 14),
new THREE.MeshStandardMaterial({ color: 0x2c2a26, roughness: 0.6 }),
);
cap.position.y = 0.24;
g.add(glass, fill, cap);
const x = (i - (SPICES.length - 1) / 2) * 0.62;
g.position.set(x, 0.24, RACK_Z);
this.jarHome.push(g.position.clone());
this.jars.push(g);
this.root.add(g);
}
// The taste spoon — only visible while you are using it.
this.spoon = new THREE.Mesh(
new THREE.SphereGeometry(0.17, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2.2),
new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.35, metalness: 0.75, side: THREE.DoubleSide }),
);
this.spoon.rotation.x = Math.PI;
this.spoon.position.set(1.5, POT_Y + 0.5, 0.8);
this.spoon.visible = false;
this.root.add(this.spoon);
}
reset(target: import('../sim/flavor').Target, askText: string, base?: Partial<Record<Axis, number>>): void {
this.session = newFlavorSession(target, base);
this.grabbed = -1;
for (let i = 0; i < this.jars.length; i++) this.jars[i].position.copy(this.jarHome[i]);
this.askEl.textContent = askText;
this.readoutEl.innerHTML = '';
this.hintEl.textContent = 'DRAG a jar over the pot and HOLD to pour · T — taste (3s, and it ages) · D — thin it · ENTER serves';
}
enter(): void {
this.prevBg = this.app.scene.background;
this.app.scene.background = this.bgTex;
this.panel.show();
}
exit(): void {
this.app.scene.background = this.prevBg as never;
this.panel.hide();
}
update(dt: number): void {
this.app.camera.position.set(0, 3.4, 3.5);
this.app.camera.lookAt(0, POT_Y + 0.1, -0.3);
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);
// Pick a jar up, carry it, and pour by HOLDING it over the pot.
if (inp.down && on) {
if (this.grabbed < 0) {
let best = 0.5;
for (let i = 0; i < this.jars.length; i++) {
const d = Math.hypot(this.jarHome[i].x - this.hit.x, this.jarHome[i].z - this.hit.z);
if (d < best) { best = d; this.grabbed = i; }
}
}
if (this.grabbed >= 0) {
const j = this.jars[this.grabbed];
j.position.set(this.hit.x, POT_Y + 0.55, this.hit.z);
const overPot = Math.hypot(this.hit.x, this.hit.z) < 0.95;
j.rotation.z = overPot ? -1.15 : 0; // tipped: it is POURING
if (overPot) {
const sp = SPICES[this.grabbed];
pour(s, sp, dt);
s.pouring = sp.label;
audio.bed('pour', 0.028, 5200, 0.9, 1.1);
} else {
s.pouring = null;
audio.bed('pour', 0, 5200);
}
}
} else {
if (this.grabbed >= 0) {
this.jars[this.grabbed].position.copy(this.jarHome[this.grabbed]);
this.jars[this.grabbed].rotation.z = 0;
}
this.grabbed = -1;
s.pouring = null;
audio.bed('pour', 0, 5200);
}
// THE SPOON. Three seconds, and what it tells you is true only of the
// pot as it is now — not the pot that is coming.
if (s.tasting > 0) {
s.tasting = Math.min(1, s.tasting + dt / 3);
this.spoon.visible = true;
if (s.tasting >= 1) {
taste(s);
s.tasting = 0;
this.renderReadout();
audio.clatter(0.25, 1.8);
}
} else {
this.spoon.visible = false;
if (inp.justPressed('KeyT')) s.tasting = 0.001;
}
if (inp.justPressed('KeyD')) {
dilute(s);
audio.clatter(0.4, 0.7);
eye.mood('disappointed', 2); // he watched you try to undo it
this.renderReadout();
}
flavorStep(s, dt);
this.syncVisuals(dt);
this.updateHud();
if (inp.justPressed('Enter')) {
audio.bed('pour', 0, 5200);
const cb = this.onDone;
this.onDone = null;
cb?.(flavorResult(s));
}
}
/** The readout is a snapshot with a timestamp, not a live meter. */
private renderReadout(): void {
const s = this.session!;
this.readoutEl.innerHTML = '';
if (!s.lastTaste) return;
(this.readoutEl as HTMLElement).dataset.at = String(Math.round(s.seconds));
for (const a of AXES) {
const band = s.target[a];
if (!band) continue; // the ticket does not care about this axis
const row = el('div', 'taste-row', this.readoutEl);
el('span', 'taste-axis', row, a);
const bar = el('div', 'taste-bar', row);
const zone = el('div', 'taste-band', bar);
(zone as HTMLElement).style.left = `${Math.min(100, band[0] * 100)}%`;
(zone as HTMLElement).style.width = `${Math.min(100 - band[0] * 100, (band[1] - band[0]) * 100)}%`;
const pip = el('div', 'taste-pip', bar);
// The wobble: the spoon is a tongue, not an instrument.
const wob = (s.rng.next() - 0.5) * 0.03;
(pip as HTMLElement).style.left = `${Math.max(0, Math.min(99, (s.lastTaste[a] / s.volume + wob) * 100))}%`;
}
}
private syncVisuals(dt: number): void {
const s = this.session!;
const v = s.vec;
// The pot's colour is an honest read of the BLOOMED pot — which is
// exactly as much as the spoon knows, and no more.
const base = new THREE.Color(0xc98a42);
base.lerp(new THREE.Color(0xb02a18), Math.min(1, v.heat * 0.9));
base.lerp(new THREE.Color(0x6a4a22), Math.min(1, v.aroma * 0.5));
base.lerp(new THREE.Color(0xd8c8a8), Math.min(0.7, (s.volume - 1) * 0.8));
(this.potMix.material as THREE.MeshStandardMaterial).color = base;
this.potMix.position.y = POT_Y + 0.04 + Math.sin(s.seconds * 3) * 0.006;
this.potMix.scale.setScalar(1 + (s.volume - 1) * 0.06);
if (s.tasting > 0) {
this.spoon.position.set(
1.5 - s.tasting * 1.4,
POT_Y + 0.5 + Math.sin(s.tasting * Math.PI) * 0.35,
0.8 - s.tasting * 0.5,
);
}
for (const w of this.steam) {
w.position.y += 0.22 * dt;
if (w.position.y > POT_Y + 1.4) {
w.position.y = POT_Y + 0.45;
w.position.x = (Math.random() - 0.5) * 1.1;
w.position.z = (Math.random() - 0.5) * 1.1;
}
}
}
private updateHud(): void {
const s = this.session!;
const age = s.lastTaste ? Math.round(s.seconds - Number((this.readoutEl as HTMLElement).dataset.at ?? s.seconds)) : 0;
this.stateEl.textContent = s.lastTaste
? `the spoon said this ${age}s ago${s.dilutions ? ` · thinned ${s.dilutions}×` : ''} · ${Math.round(s.seconds)}s`
: `you have not tasted it${s.dilutions ? ` · thinned ${s.dilutions}×` : ''} · ${Math.round(s.seconds)}s`;
this.wordEl.textContent = potWord(s);
}
result(): ReturnType<typeof flavorResult> {
return flavorResult(this.session!);
}
}

217
src/sim/flavor.ts Normal file
View File

@ -0,0 +1,217 @@
import { Rng } from '../core/rng';
/**
* THE SPICE RACK six axes, no subtract button, and one spice that lies.
*
* Every dish is a point in flavour space and every ticket is a REGION you
* are trying to land in. Adding is the only verb: `pour` moves the point,
* and nothing moves it back. DILUTE is the nearest thing to an eraser and
* it is a bad one it drags every axis toward nothing at once, including
* the ones you had right, and leaves you with more of a weaker dish.
*
* And then there is the chili. Chili's heat does not arrive when you add
* it; it BLOOMS, seconds later. So TASTE the only honest instrument on
* the bench cannot see the heat you just put in. It reports the truth
* about a pot that is about to stop being true. The second pinch is the
* one that ruins the dish, and the game never tells you not to take it.
*/
export type Axis = 'salt' | 'heat' | 'acid' | 'sweet' | 'bitter' | 'aroma';
export const AXES: Axis[] = ['salt', 'heat', 'acid', 'sweet', 'bitter', 'aroma'];
export interface Spice {
id: string;
label: string;
/** How one second of pouring moves each axis. */
vector: Partial<Record<Axis, number>>;
/** Seconds before this spice's contribution actually shows up in the pot.
* Only chili has one, and it is the whole trap. */
lag?: number;
}
export const SPICES: Spice[] = [
{ id: 'salt', label: 'SALT', vector: { salt: 0.42 } },
{ id: 'chili', label: 'CHILI', vector: { heat: 0.5 }, lag: 8 },
{ id: 'pepper', label: 'PEPPER', vector: { heat: 0.16, aroma: 0.14 } },
{ id: 'lemon', label: 'LEMON', vector: { acid: 0.44, aroma: 0.1 } },
{ id: 'sugar', label: 'SUGAR', vector: { sweet: 0.4 } },
{ id: 'cumin', label: 'CUMIN', vector: { aroma: 0.38, bitter: 0.12 } },
];
/** A band per axis. Absent axis = the ticket does not care about it. */
export type Target = Partial<Record<Axis, [number, number]>>;
interface Pending {
axis: Axis;
amount: number;
/** Session seconds at which it lands. */
at: number;
}
export interface FlavorSession {
/** What is ACTUALLY in the pot, bloomed and true. */
vec: Record<Axis, number>;
/** Added but not yet arrived. The taste spoon cannot see these. */
pending: Pending[];
target: Target;
/** 1 = the pot as the recipe intends it. Dilution only ever raises it. */
volume: number;
dilutions: number;
/** Held-pour bookkeeping so the HUD can name what is being poured. */
pouring: string | null;
/** Taste in progress 0..1, and the last honest readout it produced. */
tasting: number;
lastTaste: Record<Axis, number> | null;
tastes: number;
seconds: number;
rng: Rng;
}
const zero = (): Record<Axis, number> => ({ salt: 0, heat: 0, acid: 0, sweet: 0, bitter: 0, aroma: 0 });
export function newFlavorSession(target: Target, base?: Partial<Record<Axis, number>>, seed = 20260725): FlavorSession {
const vec = zero();
if (base) for (const a of AXES) if (base[a] !== undefined) vec[a] = base[a]!;
return {
vec, pending: [], target, volume: 1, dilutions: 0,
pouring: null, tasting: 0, lastTaste: null, tastes: 0, seconds: 0, rng: new Rng(seed),
};
}
/**
* Pour `spice` for `dt` seconds. Lagged spices go to `pending` and land
* later which is exactly why the pot can be right and doomed at once.
*/
export function pour(s: FlavorSession, spice: Spice, dt: number): void {
for (const a of AXES) {
const v = spice.vector[a];
if (!v) continue;
const amount = v * dt;
if (spice.lag) s.pending.push({ axis: a, amount, at: s.seconds + spice.lag });
else s.vec[a] += amount;
}
}
/**
* The only eraser, and a blunt one: everything moves toward nothing, the
* axes you had right included, and you are left with MORE of a weaker dish.
*/
export function dilute(s: FlavorSession): void {
for (const a of AXES) s.vec[a] *= 0.72;
for (const p of s.pending) p.amount *= 0.72;
s.volume += 0.28;
s.dilutions++;
s.seconds += 2;
}
export function flavorStep(s: FlavorSession, dt: number): void {
s.seconds += dt;
// The bloom: what you added seconds ago arrives now, whether you are
// ready for it or not.
if (s.pending.length) {
const still: Pending[] = [];
for (const p of s.pending) {
if (s.seconds >= p.at) s.vec[p.axis] += p.amount;
else still.push(p);
}
s.pending = still;
}
}
/** What the spoon honestly reports: the pot as it is RIGHT NOW. */
export function taste(s: FlavorSession): Record<Axis, number> {
const out = zero();
for (const a of AXES) out[a] = s.vec[a];
s.lastTaste = out;
s.tastes++;
// NO clock bump here. The spoon's three seconds are real seconds — the
// scene animates them and flavorStep advances the clock through them like
// any others. Adding three MORE would let a cook taste twice to fast
// forward the chili's bloom, which is the one thing this station is about.
return out;
}
/** True once every lagged addition has landed — the pot has stopped moving. */
export function settled(s: FlavorSession): boolean {
return s.pending.length === 0;
}
/** 1 inside the band, sliding off outside it. Volume thins everything. */
export function axisScore(value: number, band: [number, number]): number {
const [lo, hi] = band;
if (value < lo) return Math.max(0, 1 - (lo - value) / (lo * 0.9 + 0.12));
if (value > hi) return Math.max(0, 1 - (value - hi) / (hi * 0.55 + 0.1));
return 1;
}
/** The effective pot: dilution spreads the same flavour through more soup. */
export function effective(s: FlavorSession): Record<Axis, number> {
const out = zero();
for (const a of AXES) out[a] = s.vec[a] / s.volume;
return out;
}
export interface FlavorResult {
vec: Record<Axis, number>;
target: Target;
volume: number;
dilutions: number;
tastes: number;
pendingLeft: number;
seconds: number;
}
export function flavorResult(s: FlavorSession): FlavorResult {
// Anything still pending lands the moment it reaches the table — you do
// not get to serve a dish before its own heat arrives.
const v = effective(s);
for (const p of s.pending) v[p.axis] += p.amount / s.volume;
return {
vec: v,
target: s.target,
volume: s.volume,
dilutions: s.dilutions,
tastes: s.tastes,
pendingLeft: s.pending.length,
seconds: s.seconds,
};
}
/** How an axis is missing, in his words. Never a number. */
export function missWord(axis: Axis, value: number, band: [number, number]): string {
const under = value < band[0];
switch (axis) {
case 'salt':
return under ? 'flat. it wants salt and you know it' : 'salty. I can taste nothing else';
case 'heat':
return under ? 'no warmth at all in it' : "that is not warm, that is ANGRY";
case 'acid':
return under ? "the acid's hiding, love" : 'sour. it grabs at the back of the throat';
case 'sweet':
return under ? 'it wants a little sweetness to sit on' : 'sweet. this is pudding now';
case 'bitter':
return under ? 'could stand a touch of bitterness' : 'bitter — something scorched or overground';
case 'aroma':
return under ? 'it smells of nothing. spices are not decoration' : 'perfumed. I am wearing it now';
}
}
export function inBandWord(axis: Axis): string {
switch (axis) {
case 'salt': return 'seasoned exactly right';
case 'heat': return 'warm, and it stays warm — no ambush';
case 'acid': return 'bright. it lifts';
case 'sweet': return 'the sweetness sits underneath where it belongs';
case 'bitter': return 'just enough bitterness to give it a back';
case 'aroma': return 'it smells like someone cooked it on purpose';
}
}
/** The pot, out loud, for the state line — vague on purpose. */
export function potWord(s: FlavorSession): string {
if (s.pouring) return `pouring ${s.pouring}`;
if (s.tasting > 0) return 'tasting…';
if (s.pending.length) return 'something in there is still coming up';
if (s.dilutions > 0) return `thinned ${s.dilutions}× — there is more of it now, and less of everything`;
return 'simmering, waiting to be seasoned';
}

View File

@ -677,12 +677,17 @@ canvas#c {
opacity: 0.55;
}
/* ---- the slicing bench ---- */
/* ---- the station card ----
TOP RIGHT, not top left. The order ticket lives at (22,26) and is 272 wide,
so a card at (26,26) sat completely underneath it every station's word
readout, which is the entire no-meters interface, was painted behind the
ticket and never seen. Opposite corner: the ask on one side, what the pan
is telling you on the other. */
.slicer-card {
position: absolute;
top: 26px;
left: 26px;
width: 220px;
right: 26px;
width: 240px;
padding: 16px 18px;
background: rgba(12, 9, 8, 0.78);
border: 1px solid rgba(243, 233, 220, 0.14);
@ -846,3 +851,47 @@ body:has(#ui > .title) #touchpad { display: none; }
margin: 6px 0;
letter-spacing: 0.02em;
}
/* The taste readout: a snapshot the spoon gave you, ageing on the card. */
.taste-readout:empty { display: none; }
.taste-readout {
margin: 6px 0 4px;
padding: 6px 8px;
background: rgba(30, 24, 18, 0.35);
border-radius: 4px;
}
.taste-row {
display: flex;
align-items: center;
gap: 8px;
margin: 3px 0;
}
.taste-axis {
width: 46px;
font: 600 9px ui-monospace, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.8;
}
.taste-bar {
position: relative;
flex: 1;
height: 9px;
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
overflow: hidden;
}
.taste-band {
position: absolute;
top: 0;
height: 100%;
background: rgba(140, 190, 120, 0.5);
}
.taste-pip {
position: absolute;
top: -2px;
width: 3px;
height: 13px;
background: #f0e2c0;
border-radius: 1px;
}