From ef66e1aa7a8f9964ddd7542281cd845ad97f5d28 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 01:45:44 +1000 Subject: [PATCH] THE MOTHER: a sourdough starter that lives in your save and holds a grudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After five days survived, a jar appears on the title shelf. She wants feeding — one click — every new day you reach, and the stamps are maxDay based so replaying old days for the S never starves her. Fed within a day, toaster-days earn +0.2 and the line 'The starter is lively — the crumb held. (She did that, not you.)'. Three days forgotten and the jar sulks, and the crumb knows. Five and she goes still: the button becomes a funeral, and one click begins THE MOTHER (second of her name) — she forgives, mostly. The deaths counter never resets. Station days (grill, pan, cold, steak, eggs, poach) are honestly out of her reach. Also hardened loadSave against non-finite numbers: NaN and Infinity stringify to null, and one bad number was enough to rot every derived stat downstream. Every numeric field now refuses them on the way in. Verified live: jar born fed at maxDay, starved to a funeral, resurrected as second-of-her-name (deaths=1 persisted), lively bonus fired on a bruschetta (9.6 -> 9.8 with the line), sulk line at gap 3, save numbers intact after the full arc. Co-Authored-By: Claude Fable 5 --- src/game/game.ts | 36 +++++++++++++++++++++++++++++++++++- src/style.css | 6 ++++++ src/ui/title.ts | 29 ++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/game/game.ts b/src/game/game.ts index f25beaf..49b0707 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -55,6 +55,10 @@ interface Save { customers?: Record; /** Days S-graded under the Inspector's Shadow — no words, face and sound only. */ shadowed?: Record; + /** THE MOTHER: the sourdough starter. Appears after day 5, wants feeding + * every new day you reach, and remembers exactly how long you forgot her. + * `fed`/`born` are maxDay stamps so replaying old days never starves her. */ + starter?: { born: number; fed: number; deaths: number }; } /** @@ -234,6 +238,11 @@ export class Game { this.kitchen.root.visible = false; this.ticket.hide(); eye.hide(); // he doesn't watch you read the menu + // The jar arrives once the kitchen has survived five days. + if (!this.save.starter && this.save.maxDay >= 5) { + this.save.starter = { born: this.save.maxDay, fed: this.save.maxDay, deaths: 0 }; + writeSave(this.save); + } new Title(this.save, (day, shadow) => { audio.resume(); this.shadowMode = !!shadow; @@ -248,6 +257,11 @@ export class Game { writeSave(this.save); } this.beginDay(); + }, () => { + // Feeding day: the jar mutated in place; make it stick and let her sing. + writeSave(this.save); + audio.resume(); + audio.blip(0.3); }); } @@ -1006,6 +1020,19 @@ export class Game { else if (v.grade === 'F') extras.push('The Critic: I will be gentle in print. This kitchen has suffered enough today.'); else extras.push(`The Critic: the ${v.worst.label.toLowerCase()} betrayed you. It will be the headline.`); } + // THE MOTHER: a lively starter firms the crumb on toaster days. A dead + // one is on the shelf where he can see it, and he can smell a coward. + const st = this.save.starter; + if (st) { + const gap = this.save.maxDay - st.fed; + const toastDay = !this.order.grill && !this.order.pan && !this.order.fridge && !this.order.steak && !this.order.eggs && !this.order.poach; + if (gap >= 5) extras.push('The jar on the shelf has gone still. You know what you did.'); + else if (gap <= 1 && toastDay) { + v.total = Math.min(10, Math.round((v.total + 0.2) * 10) / 10); + v.grade = gradeOf(v.total); + extras.push('The starter is lively — the crumb held. (+0.2. She did that, not you.)'); + } else if (gap >= 3 && toastDay) extras.push('The jar says nothing today. It is sulking, and the crumb knows.'); + } // The streak. He notices. He hates that he notices. const prev = this.save.streak ?? 0; if (v.grade === 'S') { @@ -1178,8 +1205,15 @@ function loadSave(): Save { const raw = localStorage.getItem(SAVE_KEY); if (raw) { const s = JSON.parse(raw) as Partial; + // NaN/Infinity stringify to null and null arithmetic breeds more NaN — + // one bad number would otherwise rot the whole save. Refuse them here. + const num = (x: unknown, d: number): number => (typeof x === 'number' && Number.isFinite(x) ? x : d); if (typeof s.day === 'number' && s.day >= 1) { - return { day: s.day, maxDay: Math.max(s.day, s.maxDay ?? s.day), total: s.total ?? 0, best: s.best ?? {}, fridge: s.fridge, streak: s.streak ?? 0, customers: s.customers ?? {}, daily: s.daily ?? {}, shadowed: s.shadowed ?? {} }; + const day = num(s.day, 1); + const maxDay = Math.max(day, num(s.maxDay, day)); + const st = s.starter; + const starter = st && Number.isFinite(st.born) && Number.isFinite(st.fed) ? { born: Math.min(st.born, maxDay), fed: Math.min(st.fed, maxDay), deaths: num(st.deaths, 0) } : undefined; + return { day, maxDay, total: num(s.total, 0), best: s.best ?? {}, fridge: s.fridge, streak: num(s.streak, 0), customers: s.customers ?? {}, daily: s.daily ?? {}, shadowed: s.shadowed ?? {}, starter }; } } } catch { diff --git a/src/style.css b/src/style.css index 89e3e90..a9d12e3 100644 --- a/src/style.css +++ b/src/style.css @@ -800,3 +800,9 @@ body.shadow .load { /* No keys while you read the menu - the title disposes itself on start. */ body:has(#ui > .title) #touchpad { display: none; } + +.jar-btn.fed { + border-color: #7da05f; + color: #b8d89a; + transition: all 0.2s; +} diff --git a/src/ui/title.ts b/src/ui/title.ts index 5cc8e33..bc49c54 100644 --- a/src/ui/title.ts +++ b/src/ui/title.ts @@ -12,8 +12,9 @@ export class Title { private panel: Panel; constructor( - save: { day: number; maxDay: number; best: Record; shadowed?: Record }, + save: { day: number; maxDay: number; best: Record; shadowed?: Record; starter?: { born: number; fed: number; deaths: number } }, onStart: (day?: number | 'daily', shadow?: boolean) => void, + onFeed?: () => void, ) { this.panel = new Panel('title'); const wrap = el('div', 'title-wrap', this.panel.root); @@ -46,6 +47,32 @@ export class Title { }); } + // THE MOTHER: the sourdough starter. A pet that lives in the save and + // holds a grudge. Feeding is one click; forgetting is a funeral. + if (save.starter) { + const st = save.starter; + const jar = el('button', 'btn ghost jar-btn', txt, ''); + const label = () => { + const gap = save.maxDay - st.fed; + const name = st.deaths > 0 ? `THE MOTHER (${['second', 'third', 'fourth', 'umpteenth'][Math.min(st.deaths - 1, 3)]} of her name)` : 'THE MOTHER'; + if (gap >= 5) return `\u{1FAD9} ${name} has gone still. begin again \u2014 she forgives, mostly`; + if (gap === 0) return `\u{1FAD9} ${name} \u2014 fed. bubbling like gossip`; + if (gap === 1) return `\u{1FAD9} ${name} \u2014 peckish. FEED HER`; + if (gap === 4) return `\u{1FAD9} ${name} \u2014 one day from the end. FEED HER NOW`; + return `\u{1FAD9} ${name} \u2014 hungry (${gap} days). the crumb suffers`; + }; + jar.textContent = label(); + jar.addEventListener('click', () => { + const gap = save.maxDay - st.fed; + if (gap >= 5) { st.deaths++; st.born = save.maxDay; } + st.fed = save.maxDay; + onFeed?.(); + jar.textContent = label(); + jar.classList.add('fed'); + window.setTimeout(() => jar.classList.remove('fed'), 700); + }); + } + // The day ledger: one chip per reached day, best grade underneath. Click to // replay it (progress never regresses — maxDay remembers how far you got). if (save.maxDay > 1) {