M5: days + escalation — one new pressure per day

- DAYS table (game/game.ts): 5 days, each introduces exactly one pressure —
  basics / +1 ingredient / wetter (2x tomato) / a real cut (katsu) / patience
  ramp (maxSeconds 45->22, angerRamp 1.8). Game.setDay loads order + ramp.
- samurai.ramp scales every sin (sheathing unaffected) — day 5 the samurai
  runs out of patience faster.
- Exit-bar: __s.playAllDays() — every day beatable by a scripted-honest run
  (all S, standing). Ramp bites: day 1 = 7 crime-cuts to strike, day 5 = 4.
  Verified in-browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 21:12:22 +10:00
parent 58285712ef
commit cdde03bef4
4 changed files with 76 additions and 12 deletions

View File

@ -58,10 +58,18 @@
`reset()` restrings in place — Matter's constraint solve-order makes the `reset()` restrings in place — Matter's constraint solve-order makes the
restrung puppet marginally different but still walks fine (head stays ~400, restrung puppet marginally different but still walks fine (head stays ~400,
floor 640). floor 640).
- **Next: M5** orders/days escalation (`game/orders.ts`-ish) — one new pressure - **M5 ✅** days + escalation. `DAYS` in `game/game.ts` — 5 days, each ONE new
per day. Then **M6** juice (title, audio, string unlockables). pressure: (1) basics, (2) +1 ingredient (4 layers), (3) wetter (2× tomato),
- Run: `npm run dev` (:5173). Harness: `__s.playCleanRun() __s.playDisasterRun() (4) a real cut (katsu), (5) patience ramp (maxSeconds 45→22, angerRamp 1.8).
__s.serve() __s.anger(ev)` + all M1M3 verbs. Headless: `npm run check`. `Game.setDay(n)` loads order + ramp; sins scale by `samurai.ramp` (sheathing
doesn't). Exit-bar: `__s.playAllDays()` — every day beatable by a scripted-
honest run (all S, standing). Ramp bites: day 1 needs 7 crime-cuts to strike,
day 5 only 4. Verified in-browser.
- **Next: M6** juice — title screen (「サンドニエット」brush art + SHUBATTO crawl +
attract ragdoll), Web Audio (shamisen sting / slash whoosh / wet splat), food
faces on pieces, string unlockables (rubber/chain/silk — pre-authorized defer).
- Run: `npm run dev` (:5173). Harness: `__s.playDay(n) __s.playAllDays()
__s.playCleanRun() __s.playDisasterRun()` + all M1M3 verbs. Headless: `npm run check`.

View File

@ -1,6 +1,7 @@
import type { Stage } from './scenes/stage'; import type { Stage } from './scenes/stage';
import { FLOOR_Y } from './scenes/stage'; import { FLOOR_Y } from './scenes/stage';
import { spawnFood } from './sim/food'; import { spawnFood, FOODS } from './sim/food';
import { DAYS } from './game/game';
/** /**
* Dev harness toastsim's single most valuable organ, transplanted. * Dev harness toastsim's single most valuable organ, transplanted.
@ -306,6 +307,32 @@ export function installHarness(stage: Stage): void {
return { ...v, standing: stage.sando.stats().standing, order: stage.session.order.name }; return { ...v, standing: stage.sando.stats().standing, order: stage.session.order.name };
}, },
// ---- M5: days + escalation ---------------------------------------------
/** Play day `n` with a scripted-but-honest run: cut the filling, stack the
* ordered layers, serve in time. Each day must stay beatable (B+). */
playDay(n = 0) {
this.freshRun();
const d = stage.session.setDay(n);
const order = stage.session.order;
const fill = order.layers.find((k) => k !== 'bread' && k in FOODS) ?? 'tomato';
this.chop(fill); // a clean cut feeds THE CUT
this.buildSando(order.layers.map((k) => [k, 0] as [string, number]));
const v = this.serve(true, Math.max(6, order.maxSeconds - 6));
return {
day: n + 1,
name: order.name,
pressure: d.pressure,
grade: v.grade,
total: v.total,
standing: stage.sando.stats().standing,
};
},
/** M5 exit-bar: every day introduces its pressure and is beatable (B+). */
playAllDays() {
return DAYS.map((_, i) => this.playDay(i));
},
/** M4 exit-bar B: anger the samurai to the top → the string cut → ragdoll. */ /** M4 exit-bar B: anger the samurai to the top → the string cut → ragdoll. */
playDisasterRun() { playDisasterRun() {
this.freshRun(); this.freshRun();

View File

@ -2,11 +2,25 @@ import type { Stage } from '../scenes/stage';
import { Samurai } from '../sim/samurai'; import { Samurai } from '../sim/samurai';
import { judge, type Order, type RunResult, type Verdict } from './judging'; import { judge, type Order, type RunResult, type Verdict } from './judging';
/** Day 1: one open tomato sando. Escalation is M5. */ /** A day introduces exactly ONE new pressure (never two) and stays beatable by
export const ORDERS: Order[] = [ * a scripted-but-honest run. angerRamp scales every sin the samurai's
{ name: 'Open Tomato Sando', layers: ['bread', 'tomato', 'bread'], needsCut: true, maxSeconds: 40 }, * patience shortens on the rush-hour day. */
export interface Day {
order: Order;
pressure: string;
angerRamp: number;
}
export const DAYS: Day[] = [
{ order: { name: 'Open Tomato Sando', layers: ['bread', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'the basics', angerRamp: 1 },
{ order: { name: 'Cheese & Tomato', layers: ['bread', 'cheese', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: '+1 ingredient', angerRamp: 1 },
{ order: { name: 'Double Tomato', layers: ['bread', 'tomato', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'wetter — twice the tomato', angerRamp: 1 },
{ order: { name: 'Katsu Sando', layers: ['bread', 'katsu', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'a real cut — the katsu', angerRamp: 1 },
{ order: { name: 'Rush Hour', layers: ['bread', 'tomato', 'bread'], needsCut: true, maxSeconds: 22 }, pressure: 'the patience ramp', angerRamp: 1.8 },
]; ];
export const ORDERS: Order[] = DAYS.map((d) => d.order);
/** /**
* The run controller. Holds the samurai + the current ticket, tallies the floor, * The run controller. Holds the samurai + the current ticket, tallies the floor,
* and turns a finished attempt into a Verdict. Kitchen sins feed the anger * and turns a finished attempt into a Verdict. Kitchen sins feed the anger
@ -14,7 +28,8 @@ export const ORDERS: Order[] = [
*/ */
export class Game { export class Game {
samurai = new Samurai(); samurai = new Samurai();
order: Order = ORDERS[0]; day = 0;
order: Order = DAYS[0].order;
drops = 0; drops = 0;
seconds = 0; // ticket timer (seconds) seconds = 0; // ticket timer (seconds)
@ -22,7 +37,18 @@ export class Game {
this.samurai.onStrike = () => this.stage.stringCut(); this.samurai.onStrike = () => this.stage.stringCut();
} }
reset(order: Order = ORDERS[0]): void { /** Load day `n` (clamped): its order + its samurai patience ramp. */
setDay(n: number): Day {
this.day = Math.max(0, Math.min(DAYS.length - 1, n));
const d = DAYS[this.day];
this.order = d.order;
this.samurai.ramp = d.angerRamp;
this.drops = 0;
this.seconds = 0;
return d;
}
reset(order: Order = this.order): void {
this.samurai.reset(); this.samurai.reset();
this.order = order; this.order = order;
this.drops = 0; this.drops = 0;

View File

@ -24,6 +24,7 @@ export const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 's
export class Samurai { export class Samurai {
anger = 0; // 0..1 anger = 0; // 0..1
ramp = 1; // day patience multiplier — sins hurt more on the rush-hour day
struck = false; struck = false;
/** Fired once when the meter tops out — the stage wires the string cut here. */ /** Fired once when the meter tops out — the stage wires the string cut here. */
onStrike?: () => void; onStrike?: () => void;
@ -32,10 +33,12 @@ export class Samurai {
/** The last event, for barks. */ /** The last event, for barks. */
last: AngerEvent | null = null; last: AngerEvent | null = null;
/** Apply an anger event (optionally overriding the amount, e.g. ramped late). */ /** Apply an anger event (optionally overriding the amount, e.g. ramped late).
* Sins scale by the day ramp; sheathing (a clean serve) does not. */
bump(ev: AngerEvent, amount = ANGER[ev]): void { bump(ev: AngerEvent, amount = ANGER[ev]): void {
this.last = ev; this.last = ev;
this.anger = Math.max(0, Math.min(1, this.anger + amount)); const scaled = amount > 0 ? amount * this.ramp : amount;
this.anger = Math.max(0, Math.min(1, this.anger + scaled));
this.onChange?.(this.anger, this.stage); this.onChange?.(this.anger, this.stage);
if (this.anger >= 1 && !this.struck) this.strike(); if (this.anger >= 1 && !this.struck) this.strike();
} }