THE AIR FRYER: day 23 — air is the ingredient

John's spec from the atlas, honored verbatim: the basket is a small 2D
tray and ARRANGEMENT is the skill. Six wings arrive DUMPED in a pile;
every piece wants a clear gap to breathe (Clark–Evans inverted — the
same spacing statistics that reward an even topping spread here reward
margins). Smothered wings brown slow and keep a PALE face the judge can
see; steam is a LOCAL sin — a corner pile in an empty tray still steams
(the first cut used global fill fraction, which six wings could never
trip; the adversarial pass caught the dead mechanic and the smother
statistic replaced it). The drawer pauses the cook and bleeds the heat —
hovering costs. The SHAKE is the egg-crack grammar: hold F, release; a
sane charge turns the pale faces to the wind, a full-send puts five
wings on his floor and the corner eye goes horrified.

New station, full rails: sim/airfryer.ts (pure, seeded), the top-down
scene with the sliding basket (snap-out on reset — a stale docked tray
was eating the demo's opening drags), judgeAirfryer (THE CRISP golden
band / THE TURN pale faces / THE AIR steam / THE COUNT floor wings),
day 23 on the card with the cursed recipe card, a procedural endless
arm, fan-whir foley that dies when the drawer opens, ghost demo slot 23,
and the wing man's portrait (high-five not coming).

Verified: honest lattice play 9.5 twice back-to-back; the lazy pile
3.6 ('a steamer wearing a fryer badge', raw, never shaken); full-charge
shake loses 5 of 6 with the horrified eye.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 03:17:49 +10:00
parent d0fc805705
commit 29f6b25c5b
11 changed files with 670 additions and 8 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

View File

@ -54,3 +54,5 @@ gen bg_pantry 405 "$BSTYLE, warm pantry shelves with egg cartons, flour sacks
gen bg_coldroom 406 "$BSTYLE, stainless steel commercial kitchen cold room, soft blue-grey light"
echo done
gen hero_fryer 307 "$JSTYLE, air fryer basket full of golden crispy chicken wings with space between them, seen from above, single basket centered"
gen who_the_wing_man 114 "$STYLE, cheerful heavyset man in a sports jersey and backwards cap, licking sauce off one thumb, other hand raised for a high-five that is not coming"

View File

@ -678,6 +678,48 @@ 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-23 wings with REAL input: arrange on a lattice, cook at
* 180, shake at half-time (a sane 0.5 charge), finish at 190, serve. */
async fryerDay() {
game.setDay(23);
await F(6);
const fv = game.airFryerView as unknown as { session: import('./sim/airfryer').FryerSession };
const s = fv.session;
if (!s) return { error: 'not at fryer' };
const W = (x: number, y: number) => ({ wx: (x - 0.5) * 3.2, wz: (y - 0.5) * 3.2 + 0.35 });
const dragTo = async (i: number, x: number, y: number) => {
const from = W(s.pieces[i].x, s.pieces[i].y);
const to = W(x, y);
point(from.wx, 0.52, from.wz, false); await F(2);
point(from.wx, 0.52, from.wz, true); await F(2);
point((from.wx + to.wx) / 2, 0.52, (from.wz + to.wz) / 2, true); await F(2);
point(to.wx, 0.52, to.wz, true); await F(2);
point(to.wx, 0.52, to.wz, false); await F(2);
};
// A 3x2 lattice with margins — every wing gets its air.
const spots: [number, number][] = [[0.18, 0.2], [0.5, 0.2], [0.82, 0.2], [0.18, 0.66], [0.5, 0.66], [0.82, 0.66]];
for (let i = 0; i < Math.min(s.pieces.length, spots.length); i++) await dragTo(i, spots[i][0], spots[i][1]);
s.knob = 6; // 180 on the dial
tap('Space'); // drawer in — cook
const mean = () => s.pieces.filter((p) => !p.lost).reduce((a, p) => a + p.browning, 0) / Math.max(1, s.pieces.filter((p) => !p.lost).length);
let g = 0;
while (mean() < 0.3 && g++ < 60 * 90) await F(1);
tap('Space'); // drawer out — the shake
await F(4);
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyF' }));
await F(33); // ~0.5 charge: firm, nothing jumps
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyF' }));
await F(4);
s.knob = 8; // 190 to golden
tap('Space'); // back in
g = 0;
while (mean() < 0.66 && g++ < 60 * 90) await F(1);
tap('Enter');
await F(6);
const rows = [...document.querySelectorAll('.crit')].map((e) => e.textContent);
return { crisp: mean().toFixed(2), lost: s.lostCount, steamed: s.steamed.toFixed(2), rows };
},
/** Play the day-22 benedict: toast the bread, then poach the egg for it. */
async benedictDay() {
game.setDay(22);
@ -1410,6 +1452,7 @@ export function installDevHarness(app: App, game: Game): void {
20: () => harness.eggDay(true),
21: () => harness.poachDay(true),
22: () => harness.benedictDay(),
23: () => harness.fryerDay(),
};
(harness as unknown as { demos: typeof demos }).demos = demos;
(harness as unknown as { setDemoPlayback: (on: boolean) => void }).setDemoPlayback = (on: boolean) => {

View File

@ -11,6 +11,7 @@ import { FridgeView } from '../scenes/fridge';
import { SteakBoardView } from '../scenes/steakboard';
import { EggBenchView } from '../scenes/eggbench';
import { PoachView } from '../scenes/poach';
import { AirFryerView } from '../scenes/airfryer';
import { serializeStore, restoreStore, fetch as fetchStock, usableQuality, type StoredItemSave } from '../sim/coldchain';
import { AssemblyView } from '../scenes/assembly';
import type { CutPattern } from '../sim/cutting';
@ -19,7 +20,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, type BruschettaResult, type PrepResult, type Verdict } from './judging';
import { judgeBruschetta, judgeGrill, judgePan, judgeFridge, judgeSteak, judgeEggs, judgePoach, judgeBenedict, judgeAirfryer, 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';
@ -80,6 +81,7 @@ export class Game {
private steakBoard: SteakBoardView;
private eggBench: EggBenchView;
private poach: PoachView;
private airFryer: AirFryerView;
private assembly: AssemblyView;
/** What the prep bench reported for the current order, if it ran. */
private prepResult: PrepResult | null = null;
@ -129,6 +131,7 @@ export class Game {
this.steakBoard = new SteakBoardView(app);
this.eggBench = new EggBenchView(app);
this.poach = new PoachView(app);
this.airFryer = new AirFryerView(app);
this.assembly = new AssemblyView(app);
app.scene.add(this.kitchen.root);
app.scene.add(this.judgeView.root);
@ -143,6 +146,7 @@ export class Game {
app.scene.add(this.steakBoard.root);
app.scene.add(this.eggBench.root);
app.scene.add(this.poach.root);
app.scene.add(this.airFryer.root);
app.scene.add(this.assembly.root);
this.judgeView.root.visible = false;
this.drawer.root.visible = false;
@ -156,6 +160,7 @@ export class Game {
this.steakBoard.root.visible = false;
this.eggBench.root.visible = false;
this.poach.root.visible = false;
this.airFryer.root.visible = false;
this.assembly.root.visible = false;
this.ticket = new Panel('ticket');
@ -313,6 +318,9 @@ export class Game {
get eggBenchView(): EggBenchView {
return this.eggBench;
}
get airFryerView(): AirFryerView {
return this.airFryer;
}
get poachView(): PoachView {
return this.poach;
}
@ -485,6 +493,7 @@ export class Game {
this.steakBoard.root.visible = false;
this.eggBench.root.visible = false;
this.poach.root.visible = false;
this.airFryer.root.visible = false;
this.assembly.root.visible = false;
}
@ -630,13 +639,14 @@ export class Game {
if (o.steak) return this.enterSteak();
if (o.eggs) return this.enterEggs();
if (o.poach) return this.enterPoach();
if (o.airfryer) return this.enterAirFryer();
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.fridge ? '🧊' : o.prep ? '🔪' : '🍞';
const emoji = o.steak ? '🥩' : o.grill ? '🔥' : o.pan ? '🍳' : o.benedict ? '🍞🍳' : o.poach ? '🥚' : o.eggs ? '🥚' : o.airfryer ? '🍗' : 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`;
}
@ -718,6 +728,11 @@ export class Game {
this.enterEggs();
return;
}
// A wing day: the basket, the air, the shake, the count.
if (this.order.airfryer) {
this.enterAirFryer();
return;
}
// A poach day: the shiver, the vortex, the drop, the wobble.
if (this.order.poach) {
this.enterPoach();
@ -926,6 +941,34 @@ export class Game {
}
/** The charcoal grill day (M-H6): arrange the coals, cook on the gradient, serve. */
private enterAirFryer(): void {
const f = this.order.airfryer!;
this.airFryer.reset(f.count, f.card, `${f.count} wings — golden, breathing, all present`);
this.hideAllScenes();
this.airFryer.root.visible = true;
this.app.setView(this.airFryer);
this.ticket.show();
this.airFryer.onDone = (result) => {
this.airFryer.root.visible = false;
this.serveAirFryer(result);
};
}
private serveAirFryer(result: ReturnType<AirFryerView['result']>): void {
this.timing = false;
const seconds = this.orderSeconds;
const slice = this.kitchen.currentSlice;
const v = judgeAirfryer(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 enterGrill(): void {
this.grill.reset(this.order.grill!);
this.hideAllScenes();

View File

@ -26,7 +26,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';
group?: 'prep' | 'toast' | 'spread' | 'bread' | 'topping' | 'bench' | 'grill' | 'pan' | 'cold' | 'steak' | 'eggs' | 'poach' | 'fryer';
}
/** What the prep bench hands the judge, when the order had prep on it. */
@ -616,6 +616,65 @@ 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 air fryer — arrangement judged as air. The crisp is the browning band;
// the turn reads the pale shadowed faces a lazy shake never showed the wind.
export function judgeAirfryer(r: import('../sim/airfryer').FryerResult, order: Order, seconds: number): Verdict {
const asked = order.airfryer?.count ?? 6;
const criteria: Criterion[] = [
{
key: 'frycrisp',
group: 'fryer',
label: 'The Crisp',
score: r.crisp < 0.55 ? clamp01(r.crisp / 0.55) : r.crisp <= 0.8 ? 1 : clamp01(1 - (r.crisp - 0.8) / 0.2),
weight: 1.3,
detail: r.crisp < 0.3 ? 'raw in the middle of a MACHINE' : r.crisp < 0.55 ? 'pale — warm is not fried' : r.crisp <= 0.8 ? 'golden, audibly' : 'past golden into regret',
},
{
key: 'fryturn',
group: 'fryer',
label: 'The Turn',
score: clamp01(r.evenness - r.pale * 0.6),
weight: 1.0,
detail: r.pale > 0.4 ? 'pale faces — they were never shaken' : r.pale > 0.18 ? 'one side saw more wind than the other' : 'turned like he taught you',
},
{
key: 'fryair',
group: 'fryer',
label: 'The Air',
score: clamp01(1 - r.steamed / 5),
weight: 1.1,
detail: r.steamed > 2.5 ? 'that is a steamer wearing a fryer badge' : r.steamed > 0.6 ? 'crowded for a while — it shows' : 'every wing breathed',
},
{
key: 'frycount',
group: 'bench',
label: 'The Count',
score: clamp01(r.served / asked) * (r.lost > 0 ? Math.max(0.3, 1 - r.lost * 0.35) : 1),
weight: 0.8,
detail: r.lost > 0 ? `${r.lost} on the floor. His floor.` : r.served < asked ? `${r.served} of ${asked}` : 'all present',
},
{
key: 'time',
label: 'Service',
score: clamp01(1 - (seconds - 90) / 120),
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;
}
const total = (sum / wsum) * 10;
const rankable = criteria.filter((c) => c.key !== 'time');
const sorted = [...rankable].sort((a, b) => a.score - b.score);
return { criteria, total, grade: gradeOf(total), best: sorted[sorted.length - 1], worst: sorted[0], lines: [] };
}
// =====================================================================
// The benedict — two crafts, one plate. The Bread rows read the slice the
// toaster made; The Poach rows read the egg; the cling sogs the toast beneath.

View File

@ -119,6 +119,8 @@ export interface Order {
/** Eggs benedict: toast the bread, then poach the egg and land it ON the
* toast one plate, two crafts, one verdict. */
benedict?: { fuel: string };
/** THE AIR FRYER: arrangement is the skill. The card is the law. */
airfryer?: { count: number; card: string };
}
export const BROWNING_NAMES: [number, string][] = [
@ -490,6 +492,21 @@ export const DAYS: Order[] = [
text: 'The full benedict. Golden toast — THEN the egg: shiver, vortex, drop, lift it trembling, drain it, and land it on the bread. Two crafts, one plate. Ruin either and the other dies with it.',
benedict: { fuel: 'gas' },
},
{
day: 23,
brand: 'WONDERSLICE',
bread: 'white',
spread: 'butter',
amount: 'normal',
browning: 0.55,
browningName: 'golden',
noChar: true,
tool: 'butter_knife',
butterSoftness: 0.5,
who: 'the wing man',
text: 'Six wings in the air fryer. If they touch, they steam — and warm is not fried. Give every wing its air, SHAKE at half-time for the pale faces, and count them out: what jumps the rack is mine to look at.',
airfryer: { count: 6, card: 'THE CARD: 180\u00b0 until they colour \u00b7 drawer OUT, SHAKE \u00b7 190\u00b0 to golden \u00b7 all six present' },
},
];
/** Days beyond the script — keep going, with everything cranked. */
@ -508,12 +525,24 @@ export function proceduralOrder(day: number, rand: () => number): Order {
if (roll < 0.64) return proceduralGrill(day, rand);
if (roll < 0.75) return proceduralPan(day, rand);
if (roll < 0.84) return proceduralSteak(day, rand);
if (roll < 0.90) return proceduralEggs(day, rand);
if (roll < 0.89) return proceduralEggs(day, rand);
if (roll < 0.92) return proceduralAirfryer(day, rand);
if (roll < 0.94) return proceduralPoach(day, rand);
if (roll < 0.97) return proceduralBenedict(day, rand);
return proceduralFridge(day, rand);
}
function proceduralAirfryer(day: number, rand: () => number): Order {
const count = 5 + Math.floor(rand() * 3); // 5..7 — seven is a lesson in restraint
const o = baseOrder(
day,
pickOf(rand, ['the wing man', 'the lunch rush', 'a regular', 'someone new']),
`${count} wings, air-fried. Air between every piece, a proper SHAKE at half-time, nothing on my floor.`,
);
o.airfryer = { count, card: `THE CARD: 180\u00b0 until colour \u00b7 SHAKE \u00b7 190\u00b0 to golden \u00b7 all ${count} present` };
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. */

277
src/scenes/airfryer.ts Normal file
View File

@ -0,0 +1,277 @@
import * as THREE from 'three';
import type { App, View } from '../core/app';
import { audio } from '../core/audio';
import { eye } from '../ui/eye';
import {
type FryerSession,
newFryerSession,
setFryerKnob,
fryStep,
shake,
fryerResult,
fryerWord,
airflowOf,
fillFrac,
knobDegrees,
PIECE_R,
} from '../sim/airfryer';
import { el, Panel } from '../ui/hud';
import { loadBackdrop } from './props';
const TRAY_Y = 0.5;
const TRAY_W = 3.2; // world size of the 1×1 tray space
/**
* THE AIR FRYER the basket seen from above, like the grill, because both
* games are ABOUT the 2D arrangement. DRAG wings to give them air. WHEEL sets
* the knob. SPACE slams the drawer in (cook) or pulls it out (arrange and
* the heat leaves). HOLD F and release to SHAKE: charge it like the egg crack;
* too hard and he watches a wing land on his shoe. ENTER serves.
*/
export class AirFryerView implements View {
readonly root = new THREE.Group();
private session: FryerSession | null = null;
private tray!: THREE.Mesh;
private body!: THREE.Mesh;
private wingMeshes: THREE.Mesh[] = [];
private ray = new THREE.Raycaster();
private hit = new THREE.Vector3();
private trayPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(TRAY_Y + 0.02));
private grabbed = -1;
private wasDown = false;
private shakeCharge = 0;
private charging = false;
private shakeAnim = 0;
private bgTex = loadBackdrop('/assets/img/bg_stove.png');
private prevBg: unknown = null;
private panel: Panel;
private askEl!: HTMLElement;
private cardEl!: HTMLElement;
private stateEl!: HTMLElement;
private wordEl!: HTMLElement;
private hintEl!: HTMLElement;
onDone: ((result: ReturnType<typeof fryerResult>) => void) | null = null;
constructor(private app: App) {
this.buildScenery();
this.panel = new Panel('fryer-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, 'AIR FRY IT');
this.askEl = el('div', 'slicer-ask', card, '');
this.cardEl = el('div', 'fryer-card', 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: 0x3a3a3e, roughness: 0.7, metalness: 0.2 }),
);
bench.position.y = -0.2;
bench.receiveShadow = true;
this.root.add(bench);
// The machine: a chunky black drum the basket slides out of.
this.body = new THREE.Mesh(
new THREE.CylinderGeometry(1.9, 2.1, 1.4, 8),
new THREE.MeshStandardMaterial({ color: 0x161616, roughness: 0.55, metalness: 0.4 }),
);
this.body.position.set(0, 0.5, -2.6);
this.body.castShadow = true;
this.root.add(this.body);
// The basket tray: a dark wire square, seen from above.
this.tray = new THREE.Mesh(
new THREE.BoxGeometry(TRAY_W, 0.06, TRAY_W),
new THREE.MeshStandardMaterial({ color: 0x222226, roughness: 0.5, metalness: 0.6 }),
);
this.tray.position.y = TRAY_Y;
this.tray.receiveShadow = true;
this.root.add(this.tray);
// Wire rack lines.
for (let i = 0; i < 7; i++) {
const bar = new THREE.Mesh(
new THREE.BoxGeometry(TRAY_W - 0.1, 0.02, 0.02),
new THREE.MeshStandardMaterial({ color: 0x3c3c42, roughness: 0.4, metalness: 0.7 }),
);
bar.position.set(0, TRAY_Y + 0.045, -TRAY_W / 2 + 0.25 + (i * (TRAY_W - 0.5)) / 6);
this.root.add(bar);
}
}
reset(count = 6, cardText = '', askText = 'wings — golden, breathing, all present'): void {
this.session = newFryerSession(count);
this.grabbed = -1;
this.shakeCharge = 0;
this.charging = false;
this.tray.position.set(0, TRAY_Y, 0.35); // fresh basket arrives OUT, instantly
for (const m of this.wingMeshes) this.root.remove(m);
this.wingMeshes = [];
for (let i = 0; i < count; i++) {
const wing = new THREE.Mesh(
new THREE.SphereGeometry(PIECE_R * TRAY_W, 12, 9),
new THREE.MeshStandardMaterial({ color: 0xe8cfae, roughness: 0.7 }),
);
wing.scale.set(1.15, 0.6, 0.85);
wing.rotation.y = (i * 1.7) % Math.PI;
wing.castShadow = true;
this.root.add(wing);
this.wingMeshes.push(wing);
}
this.askEl.textContent = askText;
this.cardEl.textContent = cardText;
this.hintEl.textContent = 'DRAG — give them AIR · WHEEL — knob · SPACE — drawer in/out · hold F, release — SHAKE · 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, 4.4, 2.3);
this.app.camera.lookAt(0, TRAY_Y, 0.1);
const s = this.session;
if (!s) return;
const inp = this.app.input;
if (inp.wheel !== 0) setFryerKnob(s, Math.round(s.knob) + (inp.wheel > 0 ? 1 : -1));
// The drawer: out to arrange and shake, in to cook.
if (inp.justPressed('Space')) {
s.drawerOpen = !s.drawerOpen;
audio.clunk(s.drawerOpen);
}
// Drag wings — only with the drawer out. He has seen people reach into
// a running fryer. He has notes about those people.
this.ray.setFromCamera(inp.ndc, this.app.camera);
const on = this.ray.ray.intersectPlane(this.trayPlane, this.hit);
if (s.drawerOpen && inp.down && on) {
const tx = (this.hit.x - this.tray.position.x) / TRAY_W + 0.5;
const ty = (this.hit.z - this.tray.position.z) / TRAY_W + 0.5;
if (!this.wasDown) {
this.grabbed = -1;
let best = 0.09;
for (let i = 0; i < s.pieces.length; i++) {
if (s.pieces[i].lost) continue;
const d = Math.hypot(s.pieces[i].x - tx, s.pieces[i].y - ty);
if (d < best) {
best = d;
this.grabbed = i;
}
}
}
if (this.grabbed >= 0) {
s.pieces[this.grabbed].x = Math.max(PIECE_R, Math.min(1 - PIECE_R, tx));
s.pieces[this.grabbed].y = Math.max(PIECE_R, Math.min(1 - PIECE_R, ty));
}
}
if (!inp.down) this.grabbed = -1;
this.wasDown = inp.down;
// The shake: hold F to wind up, release to let it go.
if (inp.held('KeyF') && s.drawerOpen) {
this.charging = true;
this.shakeCharge = Math.min(1, this.shakeCharge + dt / 1.1);
} else if (this.charging) {
const r = shake(s, this.shakeCharge);
if (r.redistributed) {
audio.clatter(0.3 + this.shakeCharge * 0.5, 0.7);
this.shakeAnim = 1;
if (r.jumped > 0) {
audio.clatter(0.8, 1.6);
eye.mood('horrified', 2.5);
this.wordEl.textContent = `${r.jumped} wing${r.jumped > 1 ? 's' : ''} jumped the rack. He is looking at his shoe.`;
this.wordEl.style.color = '#e2603a';
}
}
this.charging = false;
this.shakeCharge = 0;
}
fryStep(s, dt);
// The fan IS the thermometer: the whir climbs with the chamber and dies
// the moment the drawer comes out.
const heatN = s.heat / 9;
audio.bed('fan', s.drawerOpen ? 0 : 0.02 + heatN * 0.07, 260 + heatN * 260, 0.5, 0.6 + heatN * 0.5);
const b = s.pieces.filter((p) => !p.lost).reduce((a, p) => a + p.browning, 0) / Math.max(1, s.pieces.filter((p) => !p.lost).length);
audio.bed('sizzle', s.drawerOpen ? 0 : Math.min(0.1, b * 0.25) * heatN, 5200, 0.8, 0.9);
this.syncVisuals(dt);
this.updateHud();
if (inp.justPressed('Enter')) {
audio.bed('fan', 0, 300);
audio.bed('sizzle', 0, 5200);
const cb = this.onDone;
this.onDone = null;
cb?.(fryerResult(this.session!));
}
}
private syncVisuals(dt: number): void {
const s = this.session!;
this.shakeAnim = Math.max(0, this.shakeAnim - dt * 3);
const wob = Math.sin(this.shakeAnim * 30) * this.shakeAnim * 0.06;
// Drawer out = tray forward on the bench; in = docked under the drum.
const targetZ = s.drawerOpen ? 0.35 : -1.7;
this.tray.position.z += (targetZ - this.tray.position.z) * (1 - Math.exp(-dt / 0.09));
this.tray.position.x = wob;
for (let i = 0; i < s.pieces.length; i++) {
const p = s.pieces[i];
const m = this.wingMeshes[i];
m.visible = !p.lost;
if (p.lost) continue;
m.position.set(
this.tray.position.x + (p.x - 0.5) * TRAY_W,
TRAY_Y + 0.12,
this.tray.position.z + (p.y - 0.5) * TRAY_W,
);
// Colour: raw cream → golden → carbon; pale face lightens the mix back.
const g = Math.min(1, p.browning);
const mat = m.material as THREE.MeshStandardMaterial;
const golden = new THREE.Color(0xe8cfae).lerp(new THREE.Color(0x7a4416), Math.min(1, g * 1.15));
if (g > 0.82) golden.lerp(new THREE.Color(0x241a12), (g - 0.82) * 4);
golden.lerp(new THREE.Color(0xead9bb), p.pale * 0.55);
mat.color = golden;
// Charging shake: the whole rack trembles with intent.
if (this.charging) m.position.y += Math.sin(s.seconds * 40 + i) * 0.02 * this.shakeCharge;
}
// The charging tray leans — you can SEE the wind-up.
this.tray.rotation.z = this.charging ? Math.sin(s.seconds * 30) * 0.03 * this.shakeCharge : 0;
}
private updateHud(): void {
const s = this.session!;
const live = s.pieces.filter((p) => !p.lost);
const touching = live.filter((_, i) => airflowOf(s, s.pieces.indexOf(live[i])) < 0.35).length;
this.stateEl.textContent = `${knobDegrees(s.knob)}° set · ${live.length} in the basket · ${touching > 0 ? `${touching} smothered` : 'all breathing'} · fill ${(fillFrac(s) * 100).toFixed(0)}% · ${Math.round(s.seconds)}s`;
if (!this.wordEl.style.color || this.wordEl.textContent === '' || !this.wordEl.textContent.includes('shoe')) {
this.wordEl.textContent = fryerWord(s);
this.wordEl.style.color = '';
}
}
result(): ReturnType<typeof fryerResult> {
return fryerResult(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' : null;
const heroKey = order.benedict ? 'benedict' : order.poach ? 'poach' : order.eggs ? 'eggs' : order.steak ? 'steak' : order.grill ? 'grill' : order.pan ? 'pan' : order.airfryer ? 'fryer' : 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.grill
this.orderEl.textContent = order.airfryer
? `Day ${order.day} · ${order.who} asked for the wings`
: order.grill
? `Day ${order.day} · ${order.who} asked for the barbie`
: order.benedict
? `Day ${order.day} · ${order.who} asked for the full benedict`
@ -227,7 +229,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) => 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',
);
const headers: Record<string, string> = {
prep: 'THE PREP',
@ -241,9 +243,10 @@ export class JudgeView implements View {
steak: 'THE STEAK',
eggs: 'THE EGGS',
poach: 'THE POACH',
fryer: 'THE FRYER',
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, 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, spread: 4, bench: 5 };
let lastGroup: string | undefined;
const rank = (g?: string) => (g && g in groupRank ? groupRank[g] : 9);
const ordered = grouped

196
src/sim/airfryer.ts Normal file
View File

@ -0,0 +1,196 @@
import { Rng } from '../core/rng';
/**
* THE AIR FRYER arrangement is the skill (John's spec, honored verbatim).
*
* The basket is a small 2D tray and the whole game is AIR: every piece wants
* a gap around it. ClarkEvans inverted the same spacing statistics that
* reward an even topping spread here reward margins. Pieces touching shadow
* each other and the shadowed faces stay pale; overfill the tray and nothing
* fries, everything steams ("that's not fried, that's WARM"). The drawer
* pauses the cook and bleeds the heat hovering costs, exactly like life.
* The SHAKE is its own mini-skill: too soft does nothing, too hard and the
* wings jump the rack.
*/
export const TRAY = 1; // tray is 1×1 in tray-space
export const PIECE_R = 0.105;
/** A piece with at least this much clear edge-gap gets full airflow. */
const AIR_GAP = 0.055;
/** Mean smother (1 - airflow) past which the wet heat takes over. */
const SMOTHER_AT = 0.25;
/** Browning per second at full heat with full airflow. */
const BROWN_RATE = 0.021;
/** The knob maps 0..9 → 120..210 °C for the ticket's language. */
export const knobDegrees = (k: number): number => 120 + Math.round(k) * 10;
export interface Piece {
x: number;
y: number;
/** Cooked colour, 0 raw .. 1 carbon. ~0.550.8 is golden. */
browning: number;
/** EMA of starved airflow — the pale shadowed face the judge can see. */
pale: number;
/** Shaken clean out of the basket. On the floor. His floor. */
lost: boolean;
}
export interface FryerSession {
pieces: Piece[];
/** Knob target 0..9. */
knob: number;
/** Chamber heat 0..9 — chases the knob, bleeds out the open drawer. */
heat: number;
drawerOpen: boolean;
seconds: number;
/** Accumulated steam-sog from overfilled cooking. */
steamed: number;
/** Shakes that went badly (pieces out). */
lostCount: number;
rng: Rng;
}
export function newFryerSession(count = 6, seed = 20260721): FryerSession {
const rng = new Rng(seed);
const pieces: Piece[] = [];
// They arrive DUMPED — a careless pile in the middle. Arranging is the job.
for (let i = 0; i < count; i++) {
pieces.push({
x: 0.5 + (rng.next() - 0.5) * 0.28,
y: 0.5 + (rng.next() - 0.5) * 0.28,
browning: 0,
pale: 0,
lost: false,
});
}
return { pieces, knob: 0, heat: 0, drawerOpen: true, seconds: 0, steamed: 0, lostCount: 0, rng };
}
export function setFryerKnob(s: FryerSession, k: number): void {
s.knob = Math.max(0, Math.min(9, Math.round(k)));
}
/** Clear gap from this piece's edge to the nearest neighbour edge or wall. */
export function gapOf(s: FryerSession, i: number): number {
const p = s.pieces[i];
if (p.lost) return AIR_GAP;
let gap = Math.min(p.x, p.y, TRAY - p.x, TRAY - p.y) - PIECE_R; // wall margin
for (let j = 0; j < s.pieces.length; j++) {
if (j === i || s.pieces[j].lost) continue;
const q = s.pieces[j];
gap = Math.min(gap, Math.hypot(p.x - q.x, p.y - q.y) - 2 * PIECE_R);
}
return gap;
}
/** 0 = smothered, 1 = breathing. The whole minigame reduced to one number. */
export function airflowOf(s: FryerSession, i: number): number {
const g = gapOf(s, i);
return Math.max(0, Math.min(1, (g + 0.02) / (AIR_GAP + 0.02)));
}
/** Piece-area over tray-area, only what's actually in the basket. */
export function fillFrac(s: FryerSession): number {
const n = s.pieces.filter((p) => !p.lost).length;
return (n * Math.PI * PIECE_R * PIECE_R) / (TRAY * TRAY);
}
export function fryStep(s: FryerSession, dt: number): void {
s.seconds += dt;
// The chamber chases the knob — quick little machine — but an open drawer
// dumps its heat and stops the cook. Hover if you must; it costs.
if (s.drawerOpen) {
s.heat = Math.max(0, s.heat - 1.6 * dt);
return;
}
s.heat += (s.knob - s.heat) * (1 - Math.exp(-dt / 2.5));
const heatN = s.heat / 9;
if (heatN <= 0.02) return;
// Steam is a LOCAL sin: it doesn't matter how empty the tray is if the
// wings are piled in one corner breathing each other's breath.
const live = s.pieces.filter((p) => !p.lost);
const smother = live.length ? live.reduce((a, p) => a + (1 - airflowOf(s, s.pieces.indexOf(p))), 0) / live.length : 0;
const over = Math.max(0, smother - SMOTHER_AT) / (1 - SMOTHER_AT);
const steamFactor = 1 - over * 0.85; // crowded = wet heat, no crisp
if (over > 0) s.steamed += over * dt * heatN * 2;
for (let i = 0; i < s.pieces.length; i++) {
const p = s.pieces[i];
if (p.lost) continue;
const air = airflowOf(s, i);
p.browning += BROWN_RATE * heatN * heatN * (0.3 + 0.7 * air) * steamFactor * dt;
// The starved face: what the airflow never touched stays pale, and only
// a SHAKE (new neighbours, new faces) can pay that debt down.
p.pale += ((1 - air) - p.pale) * (1 - Math.exp(-dt / 6));
}
}
/**
* The shake drawer OUT, both hands, one sharp motion. `strength` 0..1.
* Soft barely moves them; right redistributes; past ~0.75 the wings start
* jumping the rack, and he counts what hits the floor.
*/
export function shake(s: FryerSession, strength: number): { redistributed: boolean; jumped: number } {
if (!s.drawerOpen) return { redistributed: false, jumped: 0 };
const st = Math.max(0, Math.min(1, strength));
if (st < 0.15) return { redistributed: false, jumped: 0 };
let jumped = 0;
for (const p of s.pieces) {
if (p.lost) continue;
if (st > 0.75 && s.rng.next() < (st - 0.75) * 2.2) {
p.lost = true;
s.lostCount++;
jumped++;
continue;
}
// Jitter scaled by strength; clamped to the tray. New faces to the air.
p.x = Math.max(PIECE_R, Math.min(TRAY - PIECE_R, p.x + (s.rng.next() - 0.5) * 0.3 * st));
p.y = Math.max(PIECE_R, Math.min(TRAY - PIECE_R, p.y + (s.rng.next() - 0.5) * 0.3 * st));
p.pale *= 1 - st * 0.8; // the pale face turns to the wind
}
s.seconds += 2;
return { redistributed: true, jumped };
}
export interface FryerResult {
crisp: number;
evenness: number;
pale: number;
steamed: number;
lost: number;
served: number;
seconds: number;
}
export function fryerResult(s: FryerSession): FryerResult {
const live = s.pieces.filter((p) => !p.lost);
const n = Math.max(1, live.length);
const mean = live.reduce((a, p) => a + p.browning, 0) / n;
const sd = Math.sqrt(live.reduce((a, p) => a + (p.browning - mean) ** 2, 0) / n);
const pale = live.reduce((a, p) => a + p.pale, 0) / n;
return {
crisp: mean,
evenness: Math.max(0, 1 - sd * 3.5),
pale,
steamed: s.steamed,
lost: s.lostCount,
served: live.length,
seconds: s.seconds,
};
}
/** The tray, in words. No meters. */
export function fryerWord(s: FryerSession): string {
if (s.drawerOpen) return s.heat > 2 ? 'drawer out — the heat is LEAVING' : 'drawer out. arrange them. air is the ingredient';
const liveW = s.pieces.filter((p) => !p.lost);
const over = liveW.length > 0 && liveW.reduce((a, p) => a + (1 - airflowOf(s, s.pieces.indexOf(p))), 0) / liveW.length > SMOTHER_AT;
const touching = s.pieces.some((p, i) => !p.lost && gapOf(s, i) < 0);
if (over) return 'crowded — that is a steamer now';
if (touching) return 'they are TOUCHING. touching wings steam';
if (s.heat < s.knob - 1.5) return 'coming up to heat…';
const b = s.pieces.filter((p) => !p.lost).reduce((a, p) => a + p.browning, 0) / Math.max(1, s.pieces.filter((p) => !p.lost).length);
if (b < 0.2) return 'the fan is doing its work';
if (b < 0.45) return 'starting to colour';
if (b < 0.55) return 'nearly — hold your nerve';
if (b < 0.8) return 'GOLDEN. this is the window';
return 'past it — get them OUT';
}

View File

@ -836,3 +836,13 @@ body:has(#ui > .title) #touchpad { display: none; }
float: right;
margin: 0 0 6px 10px;
}
.fryer-card {
font: 600 11px ui-monospace, monospace;
color: #8a6a3a;
background: #efe4cf;
border-radius: 3px;
padding: 5px 8px;
margin: 6px 0;
letter-spacing: 0.02em;
}