THE MOTHER: a sourdough starter that lives in your save and holds a grudge

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 01:45:44 +10:00
parent 9eb1c2db3a
commit ef66e1aa7a
3 changed files with 69 additions and 2 deletions

View File

@ -55,6 +55,10 @@ interface Save {
customers?: Record<string, { visits: number; last: Grade; best: number }>;
/** Days S-graded under the Inspector's Shadow — no words, face and sound only. */
shadowed?: Record<string, boolean>;
/** 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<Save>;
// 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 {

View File

@ -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;
}

View File

@ -12,8 +12,9 @@ export class Title {
private panel: Panel;
constructor(
save: { day: number; maxDay: number; best: Record<string, number>; shadowed?: Record<string, boolean> },
save: { day: number; maxDay: number; best: Record<string, number>; shadowed?: Record<string, boolean>; 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) {