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:
+2
-1
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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';
|
||||
|
||||
Reference in New Issue
Block a user