diff --git a/OPUS-BUILD-BRIEF.md b/OPUS-BUILD-BRIEF.md index 23c36b0..fa953b1 100644 --- a/OPUS-BUILD-BRIEF.md +++ b/OPUS-BUILD-BRIEF.md @@ -58,10 +58,18 @@ `reset()` restrings in place — Matter's constraint solve-order makes the restrung puppet marginally different but still walks fine (head stays ~400, floor 640). -- **Next: M5** orders/days escalation (`game/orders.ts`-ish) — one new pressure - per day. Then **M6** juice (title, audio, string unlockables). -- Run: `npm run dev` (:5173). Harness: `__s.playCleanRun() __s.playDisasterRun() - __s.serve() __s.anger(ev)` + all M1–M3 verbs. Headless: `npm run check`. +- **M5 ✅** days + escalation. `DAYS` in `game/game.ts` — 5 days, each ONE new + pressure: (1) basics, (2) +1 ingredient (4 layers), (3) wetter (2× tomato), + (4) a real cut (katsu), (5) patience ramp (maxSeconds 45→22, angerRamp 1.8). + `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 M1–M3 verbs. Headless: `npm run check`. diff --git a/src/dev.ts b/src/dev.ts index 7cada82..c22b558 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -1,6 +1,7 @@ import type { Stage } 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. @@ -306,6 +307,32 @@ export function installHarness(stage: Stage): void { 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. */ playDisasterRun() { this.freshRun(); diff --git a/src/game/game.ts b/src/game/game.ts index d0428cc..a938f49 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -2,11 +2,25 @@ import type { Stage } from '../scenes/stage'; import { Samurai } from '../sim/samurai'; import { judge, type Order, type RunResult, type Verdict } from './judging'; -/** Day 1: one open tomato sando. Escalation is M5. */ -export const ORDERS: Order[] = [ - { name: 'Open Tomato Sando', layers: ['bread', 'tomato', 'bread'], needsCut: true, maxSeconds: 40 }, +/** A day introduces exactly ONE new pressure (never two) and stays beatable by + * a scripted-but-honest run. angerRamp scales every sin — the samurai's + * 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, * and turns a finished attempt into a Verdict. Kitchen sins feed the anger @@ -14,7 +28,8 @@ export const ORDERS: Order[] = [ */ export class Game { samurai = new Samurai(); - order: Order = ORDERS[0]; + day = 0; + order: Order = DAYS[0].order; drops = 0; seconds = 0; // ticket timer (seconds) @@ -22,7 +37,18 @@ export class Game { 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.order = order; this.drops = 0; diff --git a/src/sim/samurai.ts b/src/sim/samurai.ts index b2b3fe9..0bcf87e 100644 --- a/src/sim/samurai.ts +++ b/src/sim/samurai.ts @@ -24,6 +24,7 @@ export const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 's export class Samurai { anger = 0; // 0..1 + ramp = 1; // day patience multiplier — sins hurt more on the rush-hour day struck = false; /** Fired once when the meter tops out — the stage wires the string cut here. */ onStrike?: () => void; @@ -32,10 +33,12 @@ export class Samurai { /** The last event, for barks. */ 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 { 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); if (this.anger >= 1 && !this.struck) this.strike(); }