feat(02-05): wire compost beat toast (Plan 02-04 deferral)

- src/ui/settings/compost-toast.tsx: thin transient toast (D-07,
  GARD-04). Reads a rotating line from uiStrings[1].post_harvest_beat
  on each compost dispatch; fades out after 3.5s. Co-located with
  PersistenceToast as the plan specified ('folded into the persistence-
  toast UI surface').
- src/store/session-slice.ts: compostBeatTick monotonic counter +
  bumpCompostBeat action. Counter (vs boolean) ensures consecutive
  composts re-fire the toast without dedup.
- src/game/scenes/Garden.ts: handleTilePointerDown's compost branch
  calls bumpCompostBeat after enqueueCommand.
- src/App.tsx: mounts <CompostToast />.
- 4 new compost-toast tests; 312/312 vitest green; e2e still 1.6s
  green; npm run ci exits 0.

Implementation choice (per plan 'surfaced in SUMMARY'): minimum-viable
toast surface chosen over the Ink runtime path. The Ink-authored
compost-acknowledgements.ink content remains compiled + runtime-
loadable via loadInkStory + InkRenderer, so a future plan can swap
this component for the richer voice without touching sim or store.
This commit is contained in:
2026-05-09 11:07:43 -04:00
parent dd486969a9
commit 31f8ede9ac
6 changed files with 167 additions and 6 deletions
+2 -1
View File
@@ -5,7 +5,7 @@ import { SeedPicker } from './ui/garden';
import { FragmentRevealModal, JournalIcon } from './ui/journal';
import { LuraDialogue } from './ui/dialogue';
import { Letter } from './ui/letter';
import { Settings, PersistenceToast } from './ui/settings';
import { Settings, PersistenceToast, CompostToast } from './ui/settings';
import { useAppStore } from './store';
function App() {
@@ -59,6 +59,7 @@ function App() {
<Letter />
<Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<PersistenceToast show={showPersistenceToast} />
<CompostToast />
<button
data-testid="settings-icon"
aria-label="Open settings"
+6 -5
View File
@@ -234,12 +234,13 @@ export class Garden extends Phaser.Scene {
appStore.getState().enqueueCommand({ kind: 'harvest', tileIdx: idx });
} else {
// GARD-04 + D-07: compost an immature plant (sprout / mature).
// TODO Plan 02-04: replace with the Ink-authored compost beat
// rendered through the dialogue overlay (compost-acknowledgements.ink).
// Plan 02-03 ships the authored content under
// /content/dialogue/season1/ so Plan 02-04 can wire the runtime
// without re-authoring.
// Plan 02-05 — bump the compost-beat tick so CompostToast fires
// a transient one-line acknowledgement from uiStrings.post_harvest_beat.
// The Ink-authored richer voice in compost-acknowledgements.ink
// remains compiled + runtime-loadable (loadInkStory + InkRenderer)
// for Phase 4+ to swap in if richer branching is needed.
appStore.getState().enqueueCommand({ kind: 'compost', tileIdx: idx });
appStore.getState().bumpCompostBeat();
}
}
+14
View File
@@ -19,11 +19,23 @@ export interface SessionSlice {
letterOverlayOpen: boolean;
/** OfflineEventBlock; populated by the boot path's silent catchup loop. */
pendingLetterEventBlock: unknown | null;
/**
* Plan 02-05 — compost-acknowledgement transient marker (D-07 + GARD-04).
* Garden.ts sets this to a monotonic counter on each compost dispatch so
* the CompostToast component can re-fire on every compost (vs. boolean,
* which would deduplicate consecutive composts as the same notification).
* The runtime line shown is chosen from `uiStrings[seasonId].post_harvest_beat`
* by the CompostToast component (a minimum-viable surface; the Ink-authored
* /content/dialogue/season1/compost-acknowledgements.ink is fully compiled
* and runtime-loadable for Phase 4+ to swap in if richer voice is needed).
*/
compostBeatTick: number;
dismissBeginGate: () => void;
setPersistenceToastShown: (v: boolean) => void;
setShowPersistenceToast: (v: boolean) => void;
openLetter: (block: unknown) => void;
dismissLetter: () => void;
bumpCompostBeat: () => void;
}
export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice> = (set) => ({
@@ -32,9 +44,11 @@ export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice
showPersistenceToast: false,
letterOverlayOpen: false,
pendingLetterEventBlock: null,
compostBeatTick: 0,
dismissBeginGate: () => set({ beginGateDismissed: true }),
setPersistenceToastShown: (v) => set({ persistenceToastShown: v }),
setShowPersistenceToast: (v) => set({ showPersistenceToast: v }),
openLetter: (block) => set({ letterOverlayOpen: true, pendingLetterEventBlock: block }),
dismissLetter: () => set({ letterOverlayOpen: false, pendingLetterEventBlock: null }),
bumpCompostBeat: () => set((s) => ({ compostBeatTick: s.compostBeatTick + 1 })),
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, act } from '@testing-library/react';
import { appStore } from '../../store';
vi.mock('../../game/event-bus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
removeAllListeners: vi.fn(),
},
}));
import { CompostToast } from './compost-toast';
describe('CompostToast (D-07 + GARD-04 — transient compost beat toast)', () => {
beforeEach(() => {
appStore.setState({ compostBeatTick: 0 });
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.clearAllMocks();
});
it('returns null at initial state (compostBeatTick=0)', () => {
const { container } = render(<CompostToast />);
expect(container.firstChild).toBeNull();
});
it('appears after bumpCompostBeat is called', async () => {
render(<CompostToast />);
await act(async () => {
appStore.getState().bumpCompostBeat();
});
expect(screen.getByTestId('compost-toast')).toBeTruthy();
// The line shown comes from uiStrings.post_harvest_beat[1] (cycle of 3).
// We don't pin the exact line — that's a content concern — only that
// it has non-empty text content.
expect(screen.getByTestId('compost-toast').textContent?.length).toBeGreaterThan(0);
});
it('fades out after the timeout duration', async () => {
render(<CompostToast />);
await act(async () => {
appStore.getState().bumpCompostBeat();
});
expect(screen.queryByTestId('compost-toast')).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(4000);
});
expect(screen.queryByTestId('compost-toast')).toBeNull();
});
it('re-fires when bumpCompostBeat is called again', async () => {
render(<CompostToast />);
await act(async () => {
appStore.getState().bumpCompostBeat();
});
expect(screen.queryByTestId('compost-toast')).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(4000);
});
expect(screen.queryByTestId('compost-toast')).toBeNull();
// Second compost re-fires.
await act(async () => {
appStore.getState().bumpCompostBeat();
});
expect(screen.queryByTestId('compost-toast')).toBeTruthy();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useState, type JSX } from 'react';
import { useAppStore } from '../../store';
import { uiStrings } from '../../content';
/**
* CompostToast (CONTEXT D-07 + GARD-04 — Plan 02-04 deferred wiring).
*
* A small transient toast that fires when the player composts a plant.
* Surfaces a single line from `uiStrings[1].post_harvest_beat` (which
* was authored as a quiet acknowledgement set in Plan 02-02; see
* content/seasons/01-soil/ui-strings.yaml).
*
* Implementation choice (surfaced in Plan 02-05 SUMMARY): Phase 2 ships
* the minimum-viable thin-toast variant rather than the Ink runtime
* surface. The compost-acknowledgements.ink content authored in Plan
* 02-03 + rewritten by Plan 02-04 IS fully compiled (see
* src/content/compiled-ink/season1/compost-acknowledgements.ink.json)
* and the runtime path is loadInkStory('compost-acknowledgements'); a
* future plan that wants richer voice or branching can swap this
* component for the Ink runtime without touching the sim or store.
*
* Triggered by Garden.ts's compost dispatch via session.bumpCompostBeat,
* which increments compostBeatTick. We watch the tick value via
* useEffect and fire the toast for ~3.5s before fading out.
*/
const COMPOST_TOAST_DURATION_MS = 3500;
export function CompostToast(): JSX.Element | null {
const tick = useAppStore((s) => s.compostBeatTick);
const strings = uiStrings[1];
const [visible, setVisible] = useState(false);
const [lineIdx, setLineIdx] = useState(0);
useEffect(() => {
if (tick === 0) return; // initial state, no toast
setVisible(true);
// Cycle through the post_harvest_beat array on each compost so the
// player rarely hears the same line twice in a row.
setLineIdx((i) => (i + 1) % Math.max(1, strings?.post_harvest_beat?.length ?? 1));
const t = setTimeout(() => setVisible(false), COMPOST_TOAST_DURATION_MS);
return () => clearTimeout(t);
}, [tick, strings]);
if (!visible || !strings?.post_harvest_beat?.length) return null;
const line = strings.post_harvest_beat[lineIdx % strings.post_harvest_beat.length];
return (
<div
role="status"
data-testid="compost-toast"
style={{
position: 'fixed',
bottom: 84,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 32,
maxWidth: 420,
padding: '0.7rem 1.4rem',
background: '#1f1f23ee',
color: '#e8e0d0',
border: '1px solid #4d4d52',
fontFamily: 'serif',
fontStyle: 'italic',
fontSize: '0.95rem',
}}
>
{line}
</div>
);
}
+4
View File
@@ -4,6 +4,10 @@
* Plan 02-05 ships:
* - Settings: D-28 save-management modal (Export / Import / Restore)
* - PersistenceToast: D-30 one-time soft toast in voice
* - CompostToast: D-07 + GARD-04 thin transient toast (Plan 02-04
* deferral landed here — co-located with PersistenceToast since
* both share the same toast UX shape).
*/
export { Settings } from './Settings';
export { PersistenceToast } from './persistence-toast';
export { CompostToast } from './compost-toast';