Merge branch 'worktree-agent-afbcb63060ae43337'
This commit is contained in:
commit
d962b77e66
81
src/dev.ts
81
src/dev.ts
@ -212,10 +212,12 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
/**
|
||||
* One cut at the prep bench: aim at `pos` (-0.5..0.5 along the axis),
|
||||
* bite, saw to completion. `press` seconds of leaning without sawing
|
||||
* first, to invite the slip.
|
||||
* first, to invite the slip. `stopAt` (0..1) is the onion stop-line: release
|
||||
* the blade once saw progress reaches it, committing a cut that stops short
|
||||
* of the root instead of sawing through.
|
||||
*/
|
||||
chop(opts: { pos?: number; dy?: number; wobble?: number; press?: number } = {}) {
|
||||
const { pos = 0, dy = 0.045, wobble = 0, press = 0 } = opts;
|
||||
chop(opts: { pos?: number; dy?: number; wobble?: number; press?: number; stopAt?: number } = {}) {
|
||||
const { pos = 0, dy = 0.045, wobble = 0, press = 0, stopAt } = opts;
|
||||
const pv = game.prepView as unknown as {
|
||||
session: { phase: string; aimPos: number; progress: number; slips: number; cuts: number[] };
|
||||
};
|
||||
@ -245,10 +247,12 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
point(X() + (f % 2 ? wobble : -wobble), y, Z, true);
|
||||
run(1);
|
||||
f++;
|
||||
// Stop-line: let the blade up short of the root — the release commits it.
|
||||
if (stopAt !== undefined && s.phase === 'saw' && s.progress >= stopAt) break;
|
||||
}
|
||||
app.input.down = false;
|
||||
run(2);
|
||||
return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f };
|
||||
run(3); // let the release-commit (liftKnife) land before the next chop
|
||||
return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f, progress: +s.progress.toFixed(3) };
|
||||
},
|
||||
|
||||
/**
|
||||
@ -278,6 +282,28 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
return harness.prepStats();
|
||||
},
|
||||
|
||||
/**
|
||||
* The M14 onion dice with the stop-line. The first pass (root-ward cuts) is
|
||||
* released short at `stopAt` (the sim commits on release); the sim rotates
|
||||
* the board and the second pass criss-crosses straight through. Pass
|
||||
* `through: true` to saw the root pass all the way — the "went through the
|
||||
* stem, served confetti" failure. Reads live dicePhase, so it stays in step
|
||||
* with the sim's own rotation.
|
||||
*/
|
||||
diceOnion(opts: { n?: number; stopAt?: number; dy?: number; wobble?: number; through?: boolean } = {}) {
|
||||
// stopAt 0.8 lands the committed depth ~0.85 (a frame of overshoot) — square
|
||||
// in the "stopped short" band and level with the scene's stop-line marker.
|
||||
const { n = 4, stopAt = 0.8, dy = 0.05, wobble = 0, through = false } = opts;
|
||||
const pv = game.prepView as unknown as { session: { dicePhase: number } };
|
||||
const total = 2 * (n - 1);
|
||||
for (let i = 0; i < total; i++) {
|
||||
const pos = (i % (n - 1) + 1) / n - 0.5;
|
||||
if (pv.session.dicePhase === 0 && !through) harness.chop({ pos, dy, wobble, stopAt });
|
||||
else harness.chop({ pos, dy, wobble });
|
||||
}
|
||||
return harness.prepStats();
|
||||
},
|
||||
|
||||
/** Hand the prepped board off to the kitchen (the ENTER the scene waits on). */
|
||||
handoff() {
|
||||
tap('Enter');
|
||||
@ -307,6 +333,51 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
return { ...r, bench: game.prepView.mess.stats(), word: game.prepView.mess.benchWord() };
|
||||
},
|
||||
|
||||
/**
|
||||
* Drive the REAL routed juice station (game.juiceView, the one a day-12
|
||||
* order sends you to — not the __tj sandbox): seat the half, then press-and-
|
||||
* twist for `revolutions` turns at `press` downforce. Follow with `t.handoff()`
|
||||
* (ENTER) to serve the glass and drop back into the toast flow.
|
||||
*/
|
||||
juice(revolutions = 3, press = 0.7, dir: 1 | -1 = 1) {
|
||||
const view = game.juiceView as unknown as { placeHalf(): void; autoPress: boolean; press: number };
|
||||
run(3); // let the juice view take the camera before we project points
|
||||
view.placeHalf();
|
||||
run(1);
|
||||
view.autoPress = false;
|
||||
view.press = press;
|
||||
const CONE_TOP = 0.92;
|
||||
const R = 0.3;
|
||||
const steps = Math.max(1, Math.round(revolutions * 48));
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = dir * (i / 48) * Math.PI * 2;
|
||||
point(Math.cos(a) * R, CONE_TOP, Math.sin(a) * R, true);
|
||||
run(1);
|
||||
}
|
||||
app.input.down = false;
|
||||
run(1);
|
||||
return harness.juiceStats();
|
||||
},
|
||||
|
||||
juiceStats() {
|
||||
const view = game.juiceView;
|
||||
const r = view.result();
|
||||
const round = (n: number) => +n.toFixed(3);
|
||||
return {
|
||||
yieldOfOrange: round(r.yieldOfOrange),
|
||||
yieldOfReachable: round(r.yieldOfReachable),
|
||||
theoretical: round(r.theoretical),
|
||||
extracted: round(r.extracted),
|
||||
pips: r.pips,
|
||||
seedsCaught: r.seedsCaught,
|
||||
bitterness: round(r.bitterness),
|
||||
pulp: round(r.pulp),
|
||||
bench: view.mess.stats(),
|
||||
benchWord: view.mess.benchWord(),
|
||||
juicer: r.juicerName,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Cut a slice off the loaf. `dy` is world-units per frame of saw motion
|
||||
* (0.05 ≈ deliberate, 0.4 ≈ slamming); `wobble` is sideways drift per frame.
|
||||
|
||||
@ -3,8 +3,10 @@ import { Rng } from '../core/rng';
|
||||
import { KitchenView } from '../scenes/kitchen';
|
||||
import { SlicerView } from '../scenes/slicer';
|
||||
import { PrepView } from '../scenes/prep';
|
||||
import { JuiceView } from '../scenes/juice';
|
||||
import type { CutPattern } from '../sim/cutting';
|
||||
import type { IngredientId } from '../sim/ingredients';
|
||||
import { juiceCriteria, type JuiceResult } from '../sim/juicing';
|
||||
import type { PrepResult } from './judging';
|
||||
import { JudgeView } from '../scenes/judge';
|
||||
import { DrawerView } from '../scenes/drawer';
|
||||
@ -35,8 +37,11 @@ export class Game {
|
||||
private drawer: DrawerView;
|
||||
private slicer: SlicerView;
|
||||
private prep: PrepView;
|
||||
private juice: JuiceView;
|
||||
/** What the prep bench reported for the current order, if it ran. */
|
||||
private prepResult: PrepResult | null = null;
|
||||
/** What the juice station reported, if the order sent you there. */
|
||||
private juiceResult: JuiceResult | null = null;
|
||||
/** Remaining prep steps for the current order, drained as each hands off. */
|
||||
private prepQueue: PrepStep[] = [];
|
||||
/** The pre-toast drawer trip on loaf days: you're after a bread knife. */
|
||||
@ -57,15 +62,18 @@ export class Game {
|
||||
this.drawer = new DrawerView(app, 7);
|
||||
this.slicer = new SlicerView(app);
|
||||
this.prep = new PrepView(app);
|
||||
this.juice = new JuiceView(app);
|
||||
app.scene.add(this.kitchen.root);
|
||||
app.scene.add(this.judgeView.root);
|
||||
app.scene.add(this.drawer.root);
|
||||
app.scene.add(this.slicer.root);
|
||||
app.scene.add(this.prep.root);
|
||||
app.scene.add(this.juice.root);
|
||||
this.judgeView.root.visible = false;
|
||||
this.drawer.root.visible = false;
|
||||
this.slicer.root.visible = false;
|
||||
this.prep.root.visible = false;
|
||||
this.juice.root.visible = false;
|
||||
|
||||
this.ticket = new Panel('ticket');
|
||||
this.ticketEl = el('div', 'ticket-body', this.ticket.root);
|
||||
@ -135,6 +143,9 @@ export class Game {
|
||||
get prepView(): PrepView {
|
||||
return this.prep;
|
||||
}
|
||||
get juiceView(): JuiceView {
|
||||
return this.juice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the player to the prep bench in isolation. The harness drives this
|
||||
@ -166,6 +177,7 @@ export class Game {
|
||||
this.kitchen.root.visible = false;
|
||||
this.drawer.root.visible = false;
|
||||
this.slicer.root.visible = false;
|
||||
this.juice.root.visible = false;
|
||||
this.prep.root.visible = true;
|
||||
this.app.setView(this.prep);
|
||||
this.ticket.show();
|
||||
@ -175,10 +187,37 @@ export class Game {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The juice station's door, same shape as enterPrep: reset the reamer for one
|
||||
* orange, take the screen, and route wherever `onComplete` says once ENTER
|
||||
* serves the glass. The bench it leaves behind folds into the same prepResult
|
||||
* path a cut order uses, so The Bench is one row however you dirtied it.
|
||||
*/
|
||||
private enterJuice(
|
||||
step: Extract<PrepStep, { kind: 'juice' }>,
|
||||
onComplete: (result: JuiceResult) => void,
|
||||
): void {
|
||||
const halve = step.halve ?? { offset: 0, wedge: 0 };
|
||||
this.juice.reset(step.juicer, halve, prepAsk(step), step.seeds);
|
||||
this.kitchen.root.visible = false;
|
||||
this.drawer.root.visible = false;
|
||||
this.slicer.root.visible = false;
|
||||
this.prep.root.visible = false;
|
||||
this.juice.root.visible = true;
|
||||
this.app.setView(this.juice);
|
||||
this.ticket.show();
|
||||
this.juice.onDone = (result) => {
|
||||
this.juice.root.visible = false;
|
||||
onComplete(result);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the current order's prep queue one bench trip at a time, folding each
|
||||
* result into `prepResult`, then fall through to the toaster. Orders ship with
|
||||
* a single step today; the merge keeps a two-step order honest anyway.
|
||||
* result into `prepResult`, then fall through to the toaster. A step is a cut
|
||||
* (the bench) or a juice (the reamer); both leave a bench, and the juice also
|
||||
* leaves a scored glass. Orders ship with a single step today; the merge keeps
|
||||
* a two-step order honest anyway.
|
||||
*/
|
||||
private runNextPrep(): void {
|
||||
const step = this.prepQueue.shift();
|
||||
@ -186,6 +225,16 @@ export class Game {
|
||||
this.enterToastFlow();
|
||||
return;
|
||||
}
|
||||
if (step.kind === 'juice') {
|
||||
this.enterJuice(step, (result) => {
|
||||
this.juiceResult = result;
|
||||
// The glass is scored on its own criteria; its spray joins the bench
|
||||
// through the same prepResult path a cut order uses.
|
||||
this.prepResult = mergePrep(this.prepResult, { bench: result.bench });
|
||||
this.runNextPrep();
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => {
|
||||
this.prepResult = mergePrep(this.prepResult, result);
|
||||
this.runNextPrep();
|
||||
@ -252,6 +301,7 @@ export class Game {
|
||||
this.order = o;
|
||||
this.orderSeconds = 0;
|
||||
this.prepResult = null;
|
||||
this.juiceResult = null;
|
||||
this.prepQueue = o.prep ? [...o.prep] : [];
|
||||
this.timing = true;
|
||||
|
||||
@ -291,6 +341,7 @@ export class Game {
|
||||
this.drawer.root.visible = false;
|
||||
this.slicer.root.visible = false;
|
||||
this.prep.root.visible = false;
|
||||
this.juice.root.visible = false;
|
||||
this.app.setView(this.kitchen);
|
||||
this.kitchen.root.visible = true;
|
||||
this.ticket.show();
|
||||
@ -326,7 +377,10 @@ export class Game {
|
||||
this.timing = false;
|
||||
const seconds = this.orderSeconds;
|
||||
const slice = this.kitchen.currentSlice;
|
||||
const v = judge(slice, this.order, this.kitchen.toolId, seconds, this.prepResult ?? undefined);
|
||||
// The juicer scored its own glass (The Juice + Pips); fold those rows in so
|
||||
// one verdict covers the toast and everything the order asked beside it.
|
||||
const extra = this.juiceResult ? juiceCriteria(this.juiceResult) : [];
|
||||
const v = judge(slice, this.order, this.kitchen.toolId, seconds, this.prepResult ?? undefined, extra);
|
||||
this.save.total += v.total;
|
||||
const key = `day${this.order.day}`;
|
||||
this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total);
|
||||
@ -356,10 +410,16 @@ export class Game {
|
||||
function mergePrep(a: PrepResult | null, b: PrepResult): PrepResult {
|
||||
if (!a) return b;
|
||||
return {
|
||||
cv: Math.max(a.cv ?? 0, b.cv ?? 0),
|
||||
// A step with no cut of its own (the juicer's bench-only fold) mustn't wipe
|
||||
// out the cut a previous step already recorded — only take defined values.
|
||||
cv: b.cv !== undefined ? Math.max(a.cv ?? 0, b.cv) : a.cv,
|
||||
pieces: (a.pieces ?? 0) + (b.pieces ?? 0),
|
||||
patternName: b.patternName ?? a.patternName,
|
||||
slips: (a.slips ?? 0) + (b.slips ?? 0),
|
||||
// Onion technique carries through untouched — only one step is ever the dice.
|
||||
technique: b.technique ?? a.technique,
|
||||
sting: b.sting ?? a.sting,
|
||||
stem: b.stem ?? a.stem,
|
||||
bench:
|
||||
a.bench && b.bench
|
||||
? {
|
||||
|
||||
@ -25,6 +25,13 @@ export interface PrepResult {
|
||||
slips?: number;
|
||||
/** From sim/mess Mess.stats() at serve time. */
|
||||
bench?: { mass: number; spreadFrac: number; solids: number };
|
||||
// --- M14 onion, present only on the technique-test dice --------------------
|
||||
/** Eye sting endured, 0..1 (sim/cutting). Scored when `technique` is set. */
|
||||
sting?: number;
|
||||
/** True on the root-stop dice: turns on The Dice + The Eyes rows. */
|
||||
technique?: boolean;
|
||||
/** Stem discipline — how the root-ward cuts stopped short (sim/cutting). */
|
||||
stem?: { through: number; stopMean: number; score: number };
|
||||
}
|
||||
|
||||
export type Grade = 'S' | 'A' | 'B' | 'C' | 'F';
|
||||
@ -54,6 +61,9 @@ export function judge(
|
||||
usedTool: ToolId,
|
||||
seconds: number,
|
||||
prep?: PrepResult,
|
||||
/** Criteria a station scored for itself (the juicer's The Juice + Pips) —
|
||||
* folded straight into the card so the toast and the prep are one verdict. */
|
||||
extra: Criterion[] = [],
|
||||
): Verdict {
|
||||
const b = slice.browning.stats(slice.mask);
|
||||
const s = slice.spread.stats(slice.mask);
|
||||
@ -73,7 +83,10 @@ export function judge(
|
||||
const covered = spreadStdevOverCovered(slice, COVER_FLOOR);
|
||||
|
||||
const criteria: Criterion[] = [
|
||||
...extra,
|
||||
...(prep?.cv !== undefined ? [cutEvennessCriterion(prep.cv, prep.pieces ?? 0, prep.patternName ?? 'cut')] : []),
|
||||
...(prep?.technique && prep.stem ? [stemCriterion(prep.stem)] : []),
|
||||
...(prep?.technique && prep.sting !== undefined ? [stingCriterion(prep.sting)] : []),
|
||||
...(prep?.bench ? [benchCriterion(prep.bench)] : []),
|
||||
{
|
||||
key: 'browning',
|
||||
@ -318,6 +331,33 @@ export function cutEvennessCriterion(cv: number, pieces: number, patternName: st
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stem discipline — the onion dice's technique row. Stopping every root-ward
|
||||
* cut short of the root is the whole craft: the root holds the layers together
|
||||
* for the criss-cross, and cutting through it turns a dice into confetti. This
|
||||
* is scored SEPARATELY from The Pieces (which reads the criss-cross spacing) so
|
||||
* a player can be told exactly which half they got wrong.
|
||||
*/
|
||||
export function stemCriterion(stem: { through: number; stopMean: number; score: number }): Criterion {
|
||||
const detail =
|
||||
stem.through > 0
|
||||
? `${stem.through} cut through the root — it scattered`
|
||||
: `stopped short at ${Math.round(stem.stopMean * 100)}% — the root held`;
|
||||
return { key: 'dice', group: 'prep', label: 'The Dice', score: stem.score, weight: 1.3, detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* The eyes. A cut onion off-gasses; the longer it's open and the more it was
|
||||
* crushed rather than sliced, the more it stings. Low sting means you were
|
||||
* sharp and quick — which is the point. Weighted lightly (0.7): it colours the
|
||||
* verdict, it doesn't decide it.
|
||||
*/
|
||||
export function stingCriterion(sting: number): Criterion {
|
||||
const score = clamp01(1 - sting / 0.85);
|
||||
const word = sting < 0.25 ? 'clear-eyed' : sting < 0.55 ? 'watering' : sting < 0.8 ? 'streaming' : 'blind';
|
||||
return { key: 'sting', group: 'prep', label: 'The Eyes', score, weight: 0.7, detail: `${word} — sting ${sting.toFixed(2)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of the board at serve time. Weighted mildly (0.9): mess is a
|
||||
* running gag before it's a crime, but it IS on the card.
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Criterion, Grade, Verdict } from './judging';
|
||||
import type { Order } from './orders';
|
||||
import { SPREADS } from '../sim/spreads';
|
||||
import { JUICE_LINES, juicerRecognitionLine } from '../sim/juicing';
|
||||
|
||||
/**
|
||||
* The judge. Deadpan, specific, never cruel about anything except MITEY, about
|
||||
@ -187,8 +188,39 @@ const BANK: Bank = {
|
||||
'Somebody wiped as they went. Somebody was raised right.',
|
||||
],
|
||||
},
|
||||
dice: {
|
||||
bad: [
|
||||
'You went through the stem. The onion became confetti. You served confetti.',
|
||||
'The root was the one thing holding it together. You cut the root.',
|
||||
'It fell apart into slivers. A dice has corners. This has regrets.',
|
||||
'You did not stop. The onion did — all over my board.',
|
||||
'That is not diced. That is shredded, by someone in a hurry.',
|
||||
],
|
||||
good: [
|
||||
'Square. Actually square. I am keeping one.',
|
||||
'You stopped at the root every time. That is the whole skill, and you have it.',
|
||||
'Even little cubes, and the root held. Textbook.',
|
||||
],
|
||||
},
|
||||
sting: {
|
||||
bad: [
|
||||
'You were crying before the second cut. It shows in the pieces.',
|
||||
'Blunt and slow. The onion made you pay in tears.',
|
||||
'You crushed it more than you cut it. I could smell it from the pass.',
|
||||
'Take your time and the onion takes your eyes. You took your time.',
|
||||
],
|
||||
good: [
|
||||
'Sharp, quick, dry-eyed. That is how you beat an onion.',
|
||||
'Not a tear. You were faster than it was.',
|
||||
'Clean and fast. The onion never got a word in.',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// The juicer's two rows (The Juice + Pips) ship their lines with the sim, so the
|
||||
// station owns its whole voice. Merge them in rather than duplicate them here.
|
||||
Object.assign(BANK, JUICE_LINES);
|
||||
|
||||
/** MITEY gets its own vocabulary. */
|
||||
const MITEY_CRIMES = [
|
||||
'This is not a scrape of MITEY. This is a hate crime.',
|
||||
@ -237,6 +269,16 @@ export function verdictLines(v: Verdict, order: Order, tool: string, rand: () =>
|
||||
const out: string[] = [];
|
||||
out.push(pick(GRADE_OPENERS[v.grade]));
|
||||
|
||||
// The Juicernaut announces itself before the verdict proper — if the order
|
||||
// was juiced on one, and only then.
|
||||
for (const step of order.prep ?? []) {
|
||||
if (step.kind === 'juice') {
|
||||
const rec = juicerRecognitionLine(step.juicer);
|
||||
if (rec) out.push(rec);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const mitey = order.spread === 'mitey';
|
||||
const amountBad = (v.criteria.find((c) => c.key === 'amount')?.score ?? 1) < 0.4;
|
||||
if (mitey && amountBad) {
|
||||
|
||||
@ -4,24 +4,40 @@ import type { ToolId } from '../sim/cutlery';
|
||||
import { CUT_BANDS, type CutClass } from '../sim/slicing';
|
||||
import type { CutPattern } from '../sim/cutting';
|
||||
import type { IngredientId } from '../sim/ingredients';
|
||||
import type { JuicerId } from '../sim/juicing';
|
||||
|
||||
/**
|
||||
* A stop at the prep bench before (or instead of) the toaster. M11 ships the
|
||||
* 'cut' kind; juicing, zesting, grating and roasting arrive with their
|
||||
* stations (M12+) and extend this union.
|
||||
* A stop at the prep bench before the toaster. M11 shipped the 'cut' kind;
|
||||
* M12 adds 'juice' (the ribbed reamer). Zesting, grating and roasting arrive
|
||||
* with their stations and extend this union.
|
||||
*/
|
||||
export interface PrepStep {
|
||||
kind: 'cut';
|
||||
ingredient: IngredientId;
|
||||
pattern: CutPattern;
|
||||
/** The knife the bench hands you for this step. Prep knives don't come from
|
||||
* the drawer (that trip is for the spread tool); the order supplies them. */
|
||||
knife?: ToolId;
|
||||
}
|
||||
export type PrepStep =
|
||||
| {
|
||||
kind: 'cut';
|
||||
ingredient: IngredientId;
|
||||
pattern: CutPattern;
|
||||
/** The knife the bench hands you. Prep knives don't come from the drawer
|
||||
* (that trip is for the spread tool); the order supplies them. */
|
||||
knife?: ToolId;
|
||||
}
|
||||
| {
|
||||
kind: 'juice';
|
||||
ingredient: IngredientId;
|
||||
/** Which reamer sits on the bench for this order (progression, like bread
|
||||
* brands). Day 12 hands you the Pyramid Classic — no seed moat. */
|
||||
juicer: JuicerId;
|
||||
/** The cut the halving left, capping the reachable juice. Absent = a clean
|
||||
* cook's halve (dead-centre), so the whole test is the twist itself. */
|
||||
halve?: { offset: number; wedge: number };
|
||||
/** Seeds in this orange (fixed so the pip count is a skill test, not a
|
||||
* dice roll). Defaults to the orange's own count via the station. */
|
||||
seeds?: number;
|
||||
};
|
||||
|
||||
/** The ticket's short line for a prep step: "dice the onion", "6 wedges of tomato". */
|
||||
/** The ticket's short line for a prep step: "dice the onion", "juice the orange". */
|
||||
export function prepAsk(step: PrepStep): string {
|
||||
const name = step.ingredient.replace(/_/g, ' ');
|
||||
if (step.kind === 'juice') return `juice the ${name}`;
|
||||
const p = step.pattern;
|
||||
switch (p.kind) {
|
||||
case 'halve':
|
||||
@ -249,6 +265,44 @@ export const DAYS: Order[] = [
|
||||
// it skates and hoses the board; saw and it gives clean radial cuts.
|
||||
prep: [{ kind: 'cut', ingredient: 'tomato', pattern: { kind: 'wedges', n: 6 }, knife: 'dinner_knife' }],
|
||||
},
|
||||
{
|
||||
day: 12,
|
||||
brand: 'WONDERSLICE',
|
||||
bread: 'white',
|
||||
spread: 'butter',
|
||||
amount: 'normal',
|
||||
browning: 0.5,
|
||||
browningName: 'golden',
|
||||
noChar: true,
|
||||
tool: 'butter_knife',
|
||||
butterSoftness: 0.55,
|
||||
who: 'Deidre',
|
||||
text: 'Fresh orange juice with the toast, love — squeeze it properly, mind the pips. Then golden white, butter, normal.',
|
||||
// The juice detour, first outing. The Pyramid Classic has no seed moat, so
|
||||
// every pip it frees goes to the glass — the twist is the whole test. Five
|
||||
// seeds fixed so Pips is a skill row, not a lottery. The halve is left clean
|
||||
// (dead-centre) — the halve→juice chain is M12's own later order.
|
||||
prep: [{ kind: 'juice', ingredient: 'orange', juicer: 'pyramid_classic', seeds: 5 }],
|
||||
},
|
||||
{
|
||||
day: 13,
|
||||
brand: 'WONDERSLICE',
|
||||
bread: 'white',
|
||||
spread: 'butter',
|
||||
amount: 'normal',
|
||||
browning: 0.5,
|
||||
browningName: 'golden',
|
||||
noChar: true,
|
||||
tool: 'butter_knife',
|
||||
butterSoftness: 0.5,
|
||||
who: 'the inspector himself',
|
||||
text: 'Dice this onion PROPERLY — stop the cut short of the root or it falls to confetti. Sharp and quick, mind your eyes. Then golden white, butter.',
|
||||
// The technique test. root: true arms the stop-line — the first pass of cuts
|
||||
// must be RELEASED short of the root; saw through and the layers scatter and
|
||||
// the Dice row collapses. The Best Thing is the sharpest edge, so squash (and
|
||||
// therefore sting) stays low and the whole story is restraint at the root.
|
||||
prep: [{ kind: 'cut', ingredient: 'onion', pattern: { kind: 'dice', n: 4, root: true }, knife: 'the_best_thing' }],
|
||||
},
|
||||
];
|
||||
|
||||
/** Days beyond the script — keep going, with everything cranked. */
|
||||
|
||||
@ -12,6 +12,8 @@ import {
|
||||
pieceCV,
|
||||
pieceVolumes,
|
||||
plannedCuts,
|
||||
stemDiscipline,
|
||||
stingStep,
|
||||
type CutPattern,
|
||||
type CutSession,
|
||||
} from '../sim/cutting';
|
||||
@ -45,6 +47,10 @@ export class PrepView implements View {
|
||||
private pieces: THREE.Group | null = null;
|
||||
private seams: THREE.Mesh[] = [];
|
||||
private knifeMesh!: THREE.Group;
|
||||
/** The onion's stop-line: a bright band the blade must not saw past. */
|
||||
private stopLine: THREE.Mesh | null = null;
|
||||
/** Full-screen sting overlay — the eyes welling up, cheap DOM. */
|
||||
private stingOverlay: HTMLDivElement;
|
||||
private rng = new Rng(20260717);
|
||||
|
||||
private messTexData: Uint8Array;
|
||||
@ -101,6 +107,14 @@ export class PrepView implements View {
|
||||
this.panel = new Panel('prep-panel');
|
||||
this.buildUi();
|
||||
this.panel.hide();
|
||||
|
||||
// The sting overlay lives over everything, ignores the mouse, and does
|
||||
// nothing until an onion is open. backdrop-filter blur is the "screen blurs"
|
||||
// and the radial vignette is the eyes welling — both scale with sting.
|
||||
this.stingOverlay = document.createElement('div');
|
||||
this.stingOverlay.style.cssText =
|
||||
'position:fixed;inset:0;pointer-events:none;z-index:40;opacity:0;transition:opacity 0.25s ease';
|
||||
document.body.appendChild(this.stingOverlay);
|
||||
}
|
||||
|
||||
private buildUi(): void {
|
||||
@ -212,6 +226,19 @@ export class PrepView implements View {
|
||||
if (this.knifeMesh) this.root.remove(this.knifeMesh);
|
||||
this.knifeMesh = makeCutleryMesh(TOOLS[knife]);
|
||||
this.root.add(this.knifeMesh);
|
||||
if (this.stopLine) {
|
||||
this.root.remove(this.stopLine);
|
||||
this.stopLine = null;
|
||||
}
|
||||
// The onion dice arms the stop-line: a bright band the blade must not pass.
|
||||
if (pattern.kind === 'dice' && pattern.root) {
|
||||
this.stopLine = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(this.ing.size * 1.04, 0.006, this.ing.size * 1.04),
|
||||
new THREE.MeshBasicMaterial({ color: 0x3ad17a, transparent: true, opacity: 0.55 }),
|
||||
);
|
||||
this.stopLine.visible = false;
|
||||
this.root.add(this.stopLine);
|
||||
}
|
||||
this.mess.reset();
|
||||
this.askEl.textContent = askText;
|
||||
this.doneTimer = 0;
|
||||
@ -225,6 +252,10 @@ export class PrepView implements View {
|
||||
}
|
||||
exit(): void {
|
||||
this.panel.hide();
|
||||
// Leaving the bench: the eyes clear the moment the onion is off the board.
|
||||
this.stingOverlay.style.opacity = '0';
|
||||
this.stingOverlay.style.backdropFilter = 'none';
|
||||
(this.stingOverlay.style as unknown as { webkitBackdropFilter: string }).webkitBackdropFilter = 'none';
|
||||
}
|
||||
|
||||
/** Board-local UV under the pointer, if the ray hits the board. */
|
||||
@ -269,7 +300,10 @@ export class PrepView implements View {
|
||||
}
|
||||
this.applyFrame(f, dy);
|
||||
} else if (!inp.down) {
|
||||
liftKnife(s);
|
||||
// Release. On the onion's root pass this COMMITS a stop-short cut, so
|
||||
// apply the frame it returns (seam, clunk) exactly like a through-cut.
|
||||
const f = liftKnife(s);
|
||||
if (f.cutDone) this.applyFrame(f, 0);
|
||||
}
|
||||
} else if (inp.down) {
|
||||
// Off the ingredient with the button down: the cloth.
|
||||
@ -295,22 +329,45 @@ export class PrepView implements View {
|
||||
}
|
||||
}
|
||||
|
||||
// The eyes keep stinging whether you're cutting or the onion's just sitting
|
||||
// there open while you dither — that's the whole point of the clock.
|
||||
stingStep(s, dt);
|
||||
|
||||
this.prev.set(this.planePt.x, this.planePt.y);
|
||||
this.hasPrev = !!hit;
|
||||
this.positionKnife(s);
|
||||
this.updateStopLine(s);
|
||||
this.syncMess();
|
||||
this.updateHud(s);
|
||||
}
|
||||
|
||||
/** Park the stop-line at the depth the blade must not saw past, on pass one. */
|
||||
private updateStopLine(s: CutSession): void {
|
||||
if (!this.stopLine) return;
|
||||
const show = s.dicePhase === 0 && s.phase !== 'done';
|
||||
this.stopLine.visible = show;
|
||||
if (show && this.ingMesh) {
|
||||
const topY = this.ingMesh.position.y + this.ing.size * 0.55;
|
||||
// The knife tip at progress p sits at topY + 0.22 − p·size·0.9 (positionKnife);
|
||||
// the line marks p ≈ 0.82, the middle of the "stopped short" band.
|
||||
this.stopLine.position.set(0, topY + 0.22 - 0.82 * this.ing.size * 0.9, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** What the judge reads. Snapshot at any time; final after onDone. */
|
||||
result(): PrepResult {
|
||||
const s = this.session!;
|
||||
const technique = s.pattern.kind === 'dice' && s.pattern.root === true;
|
||||
return {
|
||||
cv: pieceCV(s),
|
||||
pieces: pieceVolumes(s).length,
|
||||
patternName: s.pattern.kind === 'halve' ? 'halves' : s.pattern.kind,
|
||||
slips: s.slips,
|
||||
bench: this.mess.stats(),
|
||||
// Onions carry their sting whatever the pattern; only the technique dice
|
||||
// turns it (and the stop-line) into scored rows.
|
||||
...(this.ing.sting ? { sting: s.sting } : {}),
|
||||
...(technique ? { technique: true, stem: stemDiscipline(s) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -434,15 +491,50 @@ export class PrepView implements View {
|
||||
this.cutsEl.textContent = `cut ${Math.min(done + 1, total)} of ${total}${s.phase === 'aim' ? ' (aiming)' : ''}`;
|
||||
}
|
||||
this.progFill.style.width = `${Math.round(s.progress * 100)}%`;
|
||||
this.progFill.style.background = '#e8a53a';
|
||||
// On the root pass the bar turns red past the stop-line — a live warning
|
||||
// that another stroke puts the blade through the root.
|
||||
const rootPass = s.pattern.kind === 'dice' && s.pattern.root && s.dicePhase === 0;
|
||||
this.progFill.style.background = rootPass && s.progress > 0.9 ? '#e2603a' : '#e8a53a';
|
||||
|
||||
const bench = this.mess.benchWord();
|
||||
const wobble =
|
||||
s.phase === 'saw' && s.wedge > 0.12 ? (s.wedge > 0.3 ? ' · that cut is wandering' : ' · drifting…') : '';
|
||||
this.benchEl.textContent = `bench: ${bench}${wobble}${s.slips ? ` · slips: ${s.slips}` : ''}`;
|
||||
this.benchEl.style.color = bench === 'clean' ? '' : bench === 'smeared' ? '#e8a53a' : '#e2603a';
|
||||
const eyes = this.ing.sting ? ` · ${stingWord(s.sting)}` : '';
|
||||
this.benchEl.textContent = `bench: ${bench}${wobble}${s.slips ? ` · slips: ${s.slips}` : ''}${eyes}`;
|
||||
this.benchEl.style.color = bench === 'clean' && s.sting < 0.4 ? '' : bench === 'smeared' || s.sting < 0.75 ? '#e8a53a' : '#e2603a';
|
||||
|
||||
if (rootPass && s.phase !== 'done') {
|
||||
this.hintEl.textContent =
|
||||
'saw toward the root and STOP at the green line — release to commit · through the root and it falls to confetti';
|
||||
}
|
||||
|
||||
this.syncSting(s);
|
||||
}
|
||||
|
||||
/** The eyes welling up — vignette + blur that grow with sting, cheap DOM. */
|
||||
private syncSting(s: CutSession): void {
|
||||
if (!this.ing.sting || s.sting <= 0.01) {
|
||||
this.stingOverlay.style.opacity = '0';
|
||||
this.stingOverlay.style.backdropFilter = 'none';
|
||||
(this.stingOverlay.style as unknown as { webkitBackdropFilter: string }).webkitBackdropFilter = 'none';
|
||||
return;
|
||||
}
|
||||
const st = s.sting;
|
||||
const vig = Math.min(0.6, st * 0.62);
|
||||
const blur = (st * 5).toFixed(1);
|
||||
this.stingOverlay.style.opacity = '1';
|
||||
this.stingOverlay.style.background = `radial-gradient(ellipse at 50% 46%, transparent ${Math.round(48 - st * 22)}%, rgba(150,172,120,${vig.toFixed(2)}) 100%)`;
|
||||
this.stingOverlay.style.backdropFilter = `blur(${blur}px)`;
|
||||
(this.stingOverlay.style as unknown as { webkitBackdropFilter: string }).webkitBackdropFilter = `blur(${blur}px)`;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.panel.dispose();
|
||||
this.stingOverlay.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** The eyes readout, in the judge's own thresholds. */
|
||||
function stingWord(sting: number): string {
|
||||
return sting < 0.25 ? 'eyes fine' : sting < 0.55 ? 'eyes watering' : sting < 0.8 ? 'eyes streaming' : 'can barely see';
|
||||
}
|
||||
|
||||
@ -22,7 +22,15 @@ export type CutPattern =
|
||||
| { kind: 'halve' }
|
||||
| { kind: 'slices'; n: number }
|
||||
| { kind: 'wedges'; n: number }
|
||||
| { kind: 'dice'; n: number };
|
||||
/**
|
||||
* dice — two rotated passes of parallel cuts. `root: true` turns on the M14
|
||||
* onion stop-line: the first pass (the horizontal, root-ward cuts) must STOP
|
||||
* short of the root end, committed by releasing the blade mid-saw. Saw all the
|
||||
* way through and the root lets go — the layers fall apart into confetti.
|
||||
* Plain dice (day 10) leaves `root` unset and every cut runs straight through,
|
||||
* exactly as M11 shipped it.
|
||||
*/
|
||||
| { kind: 'dice'; n: number; root?: boolean };
|
||||
|
||||
export interface CutFrame {
|
||||
/** Skated this frame: the aim jumped to `aimPos`, juice sprayed. */
|
||||
@ -65,6 +73,17 @@ export interface CutSession {
|
||||
aggression: number;
|
||||
wedgeWorst: number;
|
||||
squashWorst: number;
|
||||
// --- M14 onion stop-line + sting -------------------------------------------
|
||||
/** Saw depth (0..1 of a full cut) at which each root-pass cut was committed.
|
||||
* A stopped cut records where you let the blade up; a through-cut records 1. */
|
||||
stopDepths: number[];
|
||||
/** Root-pass cuts that sawed all the way through the root — each one is a
|
||||
* handful of confetti. */
|
||||
rootThrough: number;
|
||||
/** 0..1 eye sting, risen while the cut onion sat exposed. */
|
||||
sting: number;
|
||||
/** Seconds the onion has been open — the readout and the tuning both use it. */
|
||||
stingTime: number;
|
||||
}
|
||||
|
||||
const STROKE_GAIN = 0.11;
|
||||
@ -74,6 +93,19 @@ const SQUASH_GAIN = 0.55;
|
||||
/** Seconds of leaning on slippery skin before it skates, at slippery = 1
|
||||
* with a knife that has no saw stats worth the name. */
|
||||
const SLIP_PATIENCE = 0.35;
|
||||
/** Below this saw depth a release is a fumble, not a committed stop-short cut. */
|
||||
const STOP_MIN = 0.15;
|
||||
/** Sting per (second × open cut-surface × ing.sting × aggression factor). Tuned
|
||||
* so a sharp, quick clean dice finishes clear-eyed (~0.15) and a slow crushing
|
||||
* one — or one left sitting — floods the eyes (→ 1). See the commit. */
|
||||
const STING_RATE = 0.02;
|
||||
|
||||
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
|
||||
/** The onion's root-ward first pass: where the stop-line lives. */
|
||||
function isRootPass(s: CutSession): boolean {
|
||||
return s.pattern.kind === 'dice' && s.pattern.root === true && s.dicePhase === 0;
|
||||
}
|
||||
|
||||
export function plannedCuts(pattern: CutPattern): number {
|
||||
switch (pattern.kind) {
|
||||
@ -111,6 +143,10 @@ export function newCutSession(ing: Ingredient, knife: ToolId, pattern: CutPatter
|
||||
aggression: 0,
|
||||
wedgeWorst: 0,
|
||||
squashWorst: 0,
|
||||
stopDepths: [],
|
||||
rootThrough: 0,
|
||||
sting: 0,
|
||||
stingTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -133,9 +169,104 @@ export function beginBite(s: CutSession): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Mouse released mid-cut: progress keeps, the blade comes off the skin. */
|
||||
export function liftKnife(s: CutSession): void {
|
||||
if (s.phase === 'bite') s.phase = 'aim';
|
||||
/**
|
||||
* Mouse released. On an ordinary cut this just lifts the blade off the skin
|
||||
* (progress keeps, you can come back). On the onion's root-ward pass it is the
|
||||
* stop-line commit: letting the blade UP past the first bite is how you end a
|
||||
* cut deliberately short of the root. That is the whole feel — restraint, one
|
||||
* cut at a time, not a menu choice. Returns the frame so the scene draws the
|
||||
* seam and hears the clunk exactly as a through-cut would.
|
||||
*/
|
||||
export function liftKnife(s: CutSession): CutFrame {
|
||||
const out: CutFrame = { slipped: false, juice: 0, cutDone: false, seedsSpilled: 0, sessionDone: false };
|
||||
if (s.phase === 'bite') {
|
||||
s.phase = 'aim';
|
||||
return out;
|
||||
}
|
||||
if (s.phase === 'saw' && isRootPass(s) && s.progress >= STOP_MIN) {
|
||||
finishCut(s, out, false);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cut has landed — record it, bank the wobble/squash it earned, spill any
|
||||
* seeds, and advance the pattern (rotate the board on the dice's first pass,
|
||||
* finish on the last). `through` is true when the blade sawed clean through
|
||||
* (progress hit 1); false when it was stopped short at the root.
|
||||
*/
|
||||
function finishCut(s: CutSession, out: CutFrame, through: boolean): void {
|
||||
out.cutDone = true;
|
||||
if (isRootPass(s)) {
|
||||
s.stopDepths.push(Math.min(1, s.progress));
|
||||
if (through) s.rootThrough++;
|
||||
}
|
||||
s.cuts.push(s.aimPos);
|
||||
s.wedgeWorst = Math.max(s.wedgeWorst, s.wedge);
|
||||
s.squashWorst = Math.max(s.squashWorst, s.squash);
|
||||
out.seedsSpilled = seedsFor(s);
|
||||
s.progress = 0;
|
||||
s.wedge = 0;
|
||||
s.squash = 0;
|
||||
s.biteTravel = 0;
|
||||
s.pressTime = 0;
|
||||
if (s.pattern.kind === 'dice' && s.dicePhase === 0 && s.cuts.length >= plannedCuts(s.pattern) / 2) {
|
||||
// First pass done: bank it, rotate the board, go again.
|
||||
s.diceCutsA = s.cuts;
|
||||
s.cuts = [];
|
||||
s.dicePhase = 1;
|
||||
s.phase = 'aim';
|
||||
} else if (totalCutsDone(s) >= plannedCuts(s.pattern)) {
|
||||
s.phase = 'done';
|
||||
out.sessionDone = true;
|
||||
} else {
|
||||
s.phase = 'aim';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The eye clock. While a cut onion sits open — being cut, or just sitting on
|
||||
* the board after — sting rises with the open cut-surface (how many cuts you've
|
||||
* made) and how much you CRUSHED rather than sliced (blunt knives and squashy
|
||||
* strokes aerosolise it — literally tear gas). Sharp, quick and few cuts stings
|
||||
* slow; dawdling floods you. Pure and time-stepped, so the harness can read it.
|
||||
*/
|
||||
export function stingStep(s: CutSession, dt: number): void {
|
||||
if (!s.ing.sting || dt <= 0) return;
|
||||
const exposed = s.cuts.length + s.diceCutsA.length;
|
||||
const cutting = s.phase === 'saw' || s.phase === 'bite';
|
||||
if (exposed <= 0 && !cutting) return;
|
||||
const surface = Math.max(1, exposed);
|
||||
const aggro = 1 + Math.min(2, s.aggression * 4);
|
||||
s.stingTime += dt;
|
||||
s.sting = clamp01(s.sting + dt * STING_RATE * s.ing.sting * surface * aggro);
|
||||
}
|
||||
|
||||
/** Onion stem discipline: how well the root-ward cuts stopped short of the root. */
|
||||
export interface StemState {
|
||||
/** Cuts that sawed clean through the root (each = confetti). */
|
||||
through: number;
|
||||
/** Mean committed depth of the root-ward cuts, 0..1 (ideal ~0.75–0.9). */
|
||||
stopMean: number;
|
||||
/** 0..1 — 1 is every cut stopped clean at the line, 0 is the onion in pieces. */
|
||||
score: number;
|
||||
}
|
||||
|
||||
export function stemDiscipline(s: CutSession): StemState {
|
||||
const depths = s.stopDepths;
|
||||
const through = s.rootThrough;
|
||||
if (depths.length === 0) {
|
||||
return { through, stopMean: 1, score: through > 0 ? 0.1 : 1 };
|
||||
}
|
||||
let penalty = 0;
|
||||
for (const d of depths) {
|
||||
if (d >= 0.97) penalty += 1; // through the root → the layers scatter
|
||||
else if (d > 0.9) penalty += (d - 0.9) * 3; // grazed the root — risky
|
||||
else if (d < 0.55) penalty += (0.55 - d) * 1.4; // undercut → chunks still joined
|
||||
}
|
||||
penalty /= depths.length;
|
||||
const stopMean = depths.reduce((a, d) => a + d, 0) / depths.length;
|
||||
return { through, stopMean, score: clamp01(1 - penalty) };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -204,28 +335,9 @@ export function cutFrame(s: CutSession, dz: number, dx: number, dt: number, rand
|
||||
}
|
||||
|
||||
if (s.progress >= 1) {
|
||||
out.cutDone = true;
|
||||
s.cuts.push(s.aimPos);
|
||||
s.wedgeWorst = Math.max(s.wedgeWorst, s.wedge);
|
||||
s.squashWorst = Math.max(s.squashWorst, s.squash);
|
||||
out.seedsSpilled = seedsFor(s);
|
||||
s.progress = 0;
|
||||
s.wedge = 0;
|
||||
s.squash = 0;
|
||||
s.biteTravel = 0;
|
||||
s.pressTime = 0;
|
||||
if (s.pattern.kind === 'dice' && s.dicePhase === 0 && s.cuts.length >= plannedCuts(s.pattern) / 2) {
|
||||
// First pass done: bank it, rotate the board, go again.
|
||||
s.diceCutsA = s.cuts;
|
||||
s.cuts = [];
|
||||
s.dicePhase = 1;
|
||||
s.phase = 'aim';
|
||||
} else if (totalCutsDone(s) >= plannedCuts(s.pattern)) {
|
||||
s.phase = 'done';
|
||||
out.sessionDone = true;
|
||||
} else {
|
||||
s.phase = 'aim';
|
||||
}
|
||||
// Sawed clean through. On the onion's root pass that's exactly the sin —
|
||||
// you were meant to stop short. finishCut records it as a through-cut.
|
||||
finishCut(s, out, true);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user