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 { FragmentRevealModal, JournalIcon } from './ui/journal';
|
||||||
import { LuraDialogue } from './ui/dialogue';
|
import { LuraDialogue } from './ui/dialogue';
|
||||||
import { Letter } from './ui/letter';
|
import { Letter } from './ui/letter';
|
||||||
import { Settings, PersistenceToast } from './ui/settings';
|
import { Settings, PersistenceToast, CompostToast } from './ui/settings';
|
||||||
import { useAppStore } from './store';
|
import { useAppStore } from './store';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -59,6 +59,7 @@ function App() {
|
|||||||
<Letter />
|
<Letter />
|
||||||
<Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
<PersistenceToast show={showPersistenceToast} />
|
<PersistenceToast show={showPersistenceToast} />
|
||||||
|
<CompostToast />
|
||||||
<button
|
<button
|
||||||
data-testid="settings-icon"
|
data-testid="settings-icon"
|
||||||
aria-label="Open settings"
|
aria-label="Open settings"
|
||||||
|
|||||||
@@ -234,12 +234,13 @@ export class Garden extends Phaser.Scene {
|
|||||||
appStore.getState().enqueueCommand({ kind: 'harvest', tileIdx: idx });
|
appStore.getState().enqueueCommand({ kind: 'harvest', tileIdx: idx });
|
||||||
} else {
|
} else {
|
||||||
// GARD-04 + D-07: compost an immature plant (sprout / mature).
|
// GARD-04 + D-07: compost an immature plant (sprout / mature).
|
||||||
// TODO Plan 02-04: replace with the Ink-authored compost beat
|
// Plan 02-05 — bump the compost-beat tick so CompostToast fires
|
||||||
// rendered through the dialogue overlay (compost-acknowledgements.ink).
|
// a transient one-line acknowledgement from uiStrings.post_harvest_beat.
|
||||||
// Plan 02-03 ships the authored content under
|
// The Ink-authored richer voice in compost-acknowledgements.ink
|
||||||
// /content/dialogue/season1/ so Plan 02-04 can wire the runtime
|
// remains compiled + runtime-loadable (loadInkStory + InkRenderer)
|
||||||
// without re-authoring.
|
// for Phase 4+ to swap in if richer branching is needed.
|
||||||
appStore.getState().enqueueCommand({ kind: 'compost', tileIdx: idx });
|
appStore.getState().enqueueCommand({ kind: 'compost', tileIdx: idx });
|
||||||
|
appStore.getState().bumpCompostBeat();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,23 @@ export interface SessionSlice {
|
|||||||
letterOverlayOpen: boolean;
|
letterOverlayOpen: boolean;
|
||||||
/** OfflineEventBlock; populated by the boot path's silent catchup loop. */
|
/** OfflineEventBlock; populated by the boot path's silent catchup loop. */
|
||||||
pendingLetterEventBlock: unknown | null;
|
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;
|
dismissBeginGate: () => void;
|
||||||
setPersistenceToastShown: (v: boolean) => void;
|
setPersistenceToastShown: (v: boolean) => void;
|
||||||
setShowPersistenceToast: (v: boolean) => void;
|
setShowPersistenceToast: (v: boolean) => void;
|
||||||
openLetter: (block: unknown) => void;
|
openLetter: (block: unknown) => void;
|
||||||
dismissLetter: () => void;
|
dismissLetter: () => void;
|
||||||
|
bumpCompostBeat: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice> = (set) => ({
|
export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice> = (set) => ({
|
||||||
@@ -32,9 +44,11 @@ export const createSessionSlice: StateCreator<SessionSlice, [], [], SessionSlice
|
|||||||
showPersistenceToast: false,
|
showPersistenceToast: false,
|
||||||
letterOverlayOpen: false,
|
letterOverlayOpen: false,
|
||||||
pendingLetterEventBlock: null,
|
pendingLetterEventBlock: null,
|
||||||
|
compostBeatTick: 0,
|
||||||
dismissBeginGate: () => set({ beginGateDismissed: true }),
|
dismissBeginGate: () => set({ beginGateDismissed: true }),
|
||||||
setPersistenceToastShown: (v) => set({ persistenceToastShown: v }),
|
setPersistenceToastShown: (v) => set({ persistenceToastShown: v }),
|
||||||
setShowPersistenceToast: (v) => set({ showPersistenceToast: v }),
|
setShowPersistenceToast: (v) => set({ showPersistenceToast: v }),
|
||||||
openLetter: (block) => set({ letterOverlayOpen: true, pendingLetterEventBlock: block }),
|
openLetter: (block) => set({ letterOverlayOpen: true, pendingLetterEventBlock: block }),
|
||||||
dismissLetter: () => set({ letterOverlayOpen: false, pendingLetterEventBlock: null }),
|
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:
|
* Plan 02-05 ships:
|
||||||
* - Settings: D-28 save-management modal (Export / Import / Restore)
|
* - Settings: D-28 save-management modal (Export / Import / Restore)
|
||||||
* - PersistenceToast: D-30 one-time soft toast in voice
|
* - 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 { Settings } from './Settings';
|
||||||
export { PersistenceToast } from './persistence-toast';
|
export { PersistenceToast } from './persistence-toast';
|
||||||
|
export { CompostToast } from './compost-toast';
|
||||||
|
|||||||
Reference in New Issue
Block a user