feat(02-02): begin screen + seed picker + ui-strings + lazy content split
- content/seasons/01-soil/ui-strings.yaml: player-visible Phase-2 copy externalized per CLAUDE.md (Begin / seed picker / post-harvest beat / journal / settings / plant display names); voice reviewed against bible + anti-fomo-doctrine.md
- content/seasons/01-soil/fragments.yaml: placeholder Season-1 fragment file (Plan 02-03 expands to ≥10 authored)
- content/seasons/00-demo/: deleted (Phase-1 demo replaced)
- src/content/schemas/ui-strings.ts: UiStringsSchema (Zod) — validates structure of every season's ui-strings.yaml at load time
- src/content/schemas/index.ts + src/content/index.ts: re-export UiStringsSchema/UiStrings
- src/content/loader.ts: eager `uiStrings` glob + PIPE-02 lazy `loadSeasonFragments(seasonId)` (Plan 02-03+ exploit)
- src/ui/begin/use-audio-bootstrap.ts: bootstrapAudioContext() lazy-creates + resumes (RESEARCH Pattern 9; Pitfall 5 mitigation — context construction inside the gesture for iOS Safari) + installFirstInteractionGestureHandler() one-shot for D-22 returning players + __resetAudioBootstrapForTest()
- src/ui/begin/BeginScreen.tsx: D-21 typographic Begin screen — title + subtitle + CTA from uiStrings[1].begin; onClick calls bootstrapAudioContext synchronously inside the click event then dismisses the session gate (D-22)
- src/ui/begin/BeginScreen.test.tsx: 4 tests — render / D-22 skip / click bootstraps + dismisses / subtitle string
- src/ui/garden/SeedPicker.tsx: D-02 inline DOM popover; subscribes to 'tile-clicked-coords'; renders one button per unlocked plant type from uiStrings[1].plants; click enqueues plantSeed command via store.enqueueCommand
- src/ui/garden/SeedPicker.test.tsx: 6 tests — initial-null / coords-positioned / unlocked-only / enqueue / dismiss / multi-plant; mocks game/event-bus to avoid Phaser canvas init under happy-dom (deviation Rule 3)
- src/ui/{begin,garden,index}.ts: barrels
- src/App.tsx: mount BeginScreen + SeedPicker as overlay siblings to PhaserGame
- src/PhaserGame.tsx: bootstrap unlockedPlantTypes=['rosemary'] for first-run; install gesture handler + scene-ready listener
- npm run ci exits 0; 163/163 tests pass (10 new this commit + 25 from Task 1 + 128 baseline)
This commit is contained in:
@@ -1,13 +0,0 @@
|
|||||||
# /content/seasons/00-demo/fragments.yaml
|
|
||||||
#
|
|
||||||
# Phase 1 demo fragment — proves the loader round-trips end-to-end.
|
|
||||||
# Removed in Phase 2 when real Season 1 content lands under /content/seasons/01-soil/.
|
|
||||||
#
|
|
||||||
# Fragment ID convention is `season<N>.<id>` per CLAUDE.md "Code Style"
|
|
||||||
# and content/README.md. Never numeric. Renames forbidden once shipped.
|
|
||||||
fragments:
|
|
||||||
- id: season0.demo.first-light
|
|
||||||
season: 0
|
|
||||||
body: |
|
|
||||||
The garden remembers the first time it was tended,
|
|
||||||
though it cannot say in whose voice.
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# /content/seasons/01-soil/fragments.yaml
|
||||||
|
#
|
||||||
|
# Phase 2 placeholder. Plan 02-03 replaces with the authored Season-1
|
||||||
|
# fragments (≥10 in voice, MEMR-* coverage). The single placeholder
|
||||||
|
# fragment here keeps the eager fragment loader green during Plan 02-02
|
||||||
|
# (Plan 02-03 expands the file).
|
||||||
|
fragments:
|
||||||
|
- id: season1.soil.placeholder
|
||||||
|
season: 1
|
||||||
|
body: "(placeholder — Plan 02-03 ships authored fragments)"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Player-visible Phase 2 UI copy. Externalized per CLAUDE.md
|
||||||
|
# Code Style ("anything player-facing... should match the bible's voice")
|
||||||
|
# and reviewed against anti-fomo-doctrine.md (no FOMO, no nag, no streaks).
|
||||||
|
#
|
||||||
|
# Tone: warm, specific, intermittent, sometimes funny, sometimes devastating.
|
||||||
|
# Lura's warmth is the contrast (Plan 02-04); Phase 2 Wave 1 ships only the
|
||||||
|
# outermost shell — Begin screen, the seed picker chrome, and the post-harvest
|
||||||
|
# beat that Plan 02-03 will surface.
|
||||||
|
season: 1
|
||||||
|
|
||||||
|
begin:
|
||||||
|
title: "The Last Garden"
|
||||||
|
subtitle: "tend"
|
||||||
|
cta: "Begin"
|
||||||
|
|
||||||
|
seed_picker:
|
||||||
|
title: "Sow"
|
||||||
|
cancel: "Not yet"
|
||||||
|
|
||||||
|
# Three short beats, surfaced one at a time after a harvest (Plan 02-03).
|
||||||
|
# Authored to be quiet — the player is meant to almost miss them.
|
||||||
|
post_harvest_beat:
|
||||||
|
- "The earth remembers."
|
||||||
|
- "Something stayed."
|
||||||
|
- "It rests where it grew."
|
||||||
|
|
||||||
|
journal:
|
||||||
|
empty_state: "Nothing yet. Plant something."
|
||||||
|
back: "Close"
|
||||||
|
|
||||||
|
settings:
|
||||||
|
title: "Settings"
|
||||||
|
export: "Save to a copy"
|
||||||
|
import: "Restore from a copy"
|
||||||
|
restore_snapshot: "Earlier garden"
|
||||||
|
persistence_denied_toast: "The garden may forget, if your browser asks it to."
|
||||||
|
|
||||||
|
# Plant display names — sourced here so the writer can adjust without
|
||||||
|
# touching src/sim/garden/plants.ts (which carries fallbackName for tests).
|
||||||
|
plants:
|
||||||
|
rosemary: "Rosemary"
|
||||||
|
yarrow: "Yarrow"
|
||||||
|
winter-rose: "Winter-rose"
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
|
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
|
||||||
|
import { BeginScreen } from './ui/begin';
|
||||||
|
import { SeedPicker } from './ui/garden';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// PhaserGame ref — Phase 2+ will use this to access the active scene from React.
|
// PhaserGame ref — Phase 2+ will use this to access the active scene from React.
|
||||||
@@ -8,6 +10,11 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<PhaserGame ref={phaserRef} />
|
<PhaserGame ref={phaserRef} />
|
||||||
|
<BeginScreen />
|
||||||
|
<SeedPicker />
|
||||||
|
{/* Plan 02-03 mounts: <Journal />, <FragmentRevealModal /> */}
|
||||||
|
{/* Plan 02-04 mounts: <LuraDialogue /> */}
|
||||||
|
{/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-5
@@ -1,6 +1,9 @@
|
|||||||
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react';
|
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react';
|
||||||
import StartGame from './game/main.ts';
|
import StartGame from './game/main.ts';
|
||||||
import type * as Phaser from 'phaser';
|
import type * as Phaser from 'phaser';
|
||||||
|
import { eventBus } from './game/event-bus';
|
||||||
|
import { appStore } from './store';
|
||||||
|
import { installFirstInteractionGestureHandler } from './ui/begin';
|
||||||
|
|
||||||
export interface IRefPhaserGame {
|
export interface IRefPhaserGame {
|
||||||
game: Phaser.Game | null;
|
game: Phaser.Game | null;
|
||||||
@@ -11,11 +14,20 @@ interface IProps {
|
|||||||
currentActiveScene?: (sceneInstance: Phaser.Scene) => void;
|
currentActiveScene?: (sceneInstance: Phaser.Scene) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(function PhaserGame(_props, ref) {
|
export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(function PhaserGame(props, ref) {
|
||||||
const game = useRef<Phaser.Game | null>(null);
|
const game = useRef<Phaser.Game | null>(null);
|
||||||
|
const sceneRef = useRef<Phaser.Scene | null>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (game.current === null) {
|
if (game.current === null) {
|
||||||
|
// Bootstrap initial state. Plan 02-05 will replace this with the
|
||||||
|
// real save-load path; for now, first-run players get rosemary
|
||||||
|
// unlocked (D-05 — the warm starter plant).
|
||||||
|
const initial = appStore.getState();
|
||||||
|
if (initial.unlockedPlantTypes.length === 0) {
|
||||||
|
appStore.setState({ unlockedPlantTypes: ['rosemary'] });
|
||||||
|
}
|
||||||
|
|
||||||
game.current = StartGame('game-container');
|
game.current = StartGame('game-container');
|
||||||
|
|
||||||
if (typeof ref === 'function') {
|
if (typeof ref === 'function') {
|
||||||
@@ -34,13 +46,26 @@ export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(function PhaserGame
|
|||||||
}, [ref]);
|
}, [ref]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Phase 2+: subscribe to scene-ready events here and surface the active scene
|
const onSceneReady = (scene: Phaser.Scene): void => {
|
||||||
// through `currentActiveScene` so React can talk to Phaser.
|
sceneRef.current = scene;
|
||||||
}, []);
|
props.currentActiveScene?.(scene);
|
||||||
|
};
|
||||||
|
eventBus.on('scene-ready', onSceneReady);
|
||||||
|
|
||||||
|
// Install the first-interaction gesture handler unconditionally —
|
||||||
|
// it is a one-shot that bootstraps audio on the first click /
|
||||||
|
// touch / keypress whether the Begin screen handled it or not (D-22
|
||||||
|
// fallback for returning players who skip the Begin gate).
|
||||||
|
installFirstInteractionGestureHandler();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventBus.off('scene-ready', onSceneReady);
|
||||||
|
};
|
||||||
|
}, [props]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
game: game.current,
|
game: game.current,
|
||||||
scene: null,
|
scene: sceneRef.current,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return <div id="game-container" />;
|
return <div id="game-container" />;
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
export { fragments, loadFragmentsFromGlob } from './loader.ts';
|
export {
|
||||||
|
fragments,
|
||||||
|
loadFragmentsFromGlob,
|
||||||
|
loadSeasonFragments,
|
||||||
|
uiStrings,
|
||||||
|
} from './loader.ts';
|
||||||
export {
|
export {
|
||||||
FragmentSchema,
|
FragmentSchema,
|
||||||
SeasonContentSchema,
|
SeasonContentSchema,
|
||||||
|
UiStringsSchema,
|
||||||
type Fragment,
|
type Fragment,
|
||||||
type SeasonContent,
|
type SeasonContent,
|
||||||
|
type UiStrings,
|
||||||
} from './schemas/index.ts';
|
} from './schemas/index.ts';
|
||||||
|
|||||||
+95
-7
@@ -1,6 +1,12 @@
|
|||||||
import grayMatter from 'gray-matter';
|
import grayMatter from 'gray-matter';
|
||||||
import { parse as parseYAML } from 'yaml';
|
import { parse as parseYAML } from 'yaml';
|
||||||
import { SeasonContentSchema, FragmentSchema, type Fragment } from './schemas/index.ts';
|
import {
|
||||||
|
SeasonContentSchema,
|
||||||
|
FragmentSchema,
|
||||||
|
UiStringsSchema,
|
||||||
|
type Fragment,
|
||||||
|
type UiStrings,
|
||||||
|
} from './schemas/index.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite-native content pipeline (PIPE-01). The glob patterns MUST be
|
* Vite-native content pipeline (PIPE-01). The glob patterns MUST be
|
||||||
@@ -12,9 +18,9 @@ import { SeasonContentSchema, FragmentSchema, type Fragment } from './schemas/in
|
|||||||
* through Vite into the build process — `npm run build` exits non-zero,
|
* through Vite into the build process — `npm run build` exits non-zero,
|
||||||
* which is the PIPE-01 contract.
|
* which is the PIPE-01 contract.
|
||||||
*
|
*
|
||||||
* Phase 1 ships one demo fragment under /content/seasons/00-demo/fragments.yaml;
|
* Phase 2 (Plan 02-02) replaces the 00-demo placeholder with /content/
|
||||||
* Phase 2 fills /content/seasons/01-soil/ and may also begin authoring
|
* seasons/01-soil/. Plan 02-03 will populate the real Season-1 fragment
|
||||||
* one-per-file Markdown fragments under /content/seasons/<slug>/fragments/*.md.
|
* pool (currently a single placeholder fragment).
|
||||||
*/
|
*/
|
||||||
const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', {
|
const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', {
|
||||||
eager: true,
|
eager: true,
|
||||||
@@ -52,12 +58,94 @@ function loadMdFragments(): Fragment[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All fragments discovered at build time. Phase 1 ships one demo fragment
|
* All fragments discovered at build time. Phase 2 (Plan 02-02) ships a
|
||||||
* under /content/seasons/00-demo/fragments.yaml; Phase 2 fills
|
* single Season-1 placeholder; Plan 02-03 expands to ≥10 authored
|
||||||
* /content/seasons/01-soil/.
|
* fragments.
|
||||||
*/
|
*/
|
||||||
export const fragments: Fragment[] = [...loadYamlFragments(), ...loadMdFragments()];
|
export const fragments: Fragment[] = [...loadYamlFragments(), ...loadMdFragments()];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// PIPE-02 — per-Season lazy fragment chunks (Plan 02-02 wires the
|
||||||
|
// surface; Plan 02-03 + 02-04 + Phase-4+ exploit it as Seasons grow).
|
||||||
|
//
|
||||||
|
// The eager `fragments` export above stays for now; Plan 02-03 may
|
||||||
|
// switch the consuming code to `await loadSeasonFragments(seasonId)`
|
||||||
|
// once the Phase-2 fragment count makes eager loading expensive.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
const lazyYamlFragments = import.meta.glob('/content/seasons/*/fragments.yaml', {
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
const lazyMdFragments = import.meta.glob('/content/seasons/*/fragments/*.md', {
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return n.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSeasonFragments(seasonId: number): Promise<Fragment[]> {
|
||||||
|
const yamlMatch = Object.entries(lazyYamlFragments).filter(([p]) =>
|
||||||
|
p.includes(`/${pad2(seasonId)}-`),
|
||||||
|
);
|
||||||
|
const mdMatch = Object.entries(lazyMdFragments).filter(([p]) =>
|
||||||
|
p.includes(`/${pad2(seasonId)}-`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const yamlOut: Fragment[] = [];
|
||||||
|
for (const [path, loader] of yamlMatch) {
|
||||||
|
const raw = (await loader()) as string;
|
||||||
|
const parsed = SeasonContentSchema.safeParse(parseYAML(raw));
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
|
||||||
|
}
|
||||||
|
yamlOut.push(...parsed.data.fragments);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdOut: Fragment[] = [];
|
||||||
|
for (const [path, loader] of mdMatch) {
|
||||||
|
const raw = (await loader()) as string;
|
||||||
|
const { data, content } = grayMatter(raw);
|
||||||
|
const merged = { ...data, body: content.trim() };
|
||||||
|
const parsed = FragmentSchema.safeParse(merged);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
|
||||||
|
}
|
||||||
|
mdOut.push(parsed.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...yamlOut, ...mdOut];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// UI strings — loaded eagerly so first paint can reference any string
|
||||||
|
// without await. Per CLAUDE.md externalized-strings rule.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
const uiStringFiles = import.meta.glob('/content/seasons/*/ui-strings.yaml', {
|
||||||
|
eager: true,
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
}) as Record<string, string>;
|
||||||
|
|
||||||
|
function loadUiStrings(): Record<number, UiStrings> {
|
||||||
|
const result: Record<number, UiStrings> = {};
|
||||||
|
for (const [path, raw] of Object.entries(uiStringFiles)) {
|
||||||
|
const data = parseYAML(raw);
|
||||||
|
const parsed = UiStringsSchema.safeParse(data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
|
||||||
|
}
|
||||||
|
result[parsed.data.season] = parsed.data;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uiStrings: Record<number, UiStrings> = loadUiStrings();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test-only helper that lets loader.test.ts validate mocked SeasonContent
|
* Test-only helper that lets loader.test.ts validate mocked SeasonContent
|
||||||
* shapes against the schema without touching the filesystem. PIPE-01 is
|
* shapes against the schema without touching the filesystem. PIPE-01 is
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { FragmentSchema, type Fragment } from './fragment.ts';
|
export { FragmentSchema, type Fragment } from './fragment.ts';
|
||||||
export { SeasonContentSchema, type SeasonContent } from './season.ts';
|
export { SeasonContentSchema, type SeasonContent } from './season.ts';
|
||||||
|
export { UiStringsSchema, type UiStrings } from './ui-strings.ts';
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player-visible UI strings, externalized per CLAUDE.md "Code Style":
|
||||||
|
* "Player-visible strings are externalized in /content/, never hardcoded."
|
||||||
|
*
|
||||||
|
* One file per season under /content/seasons/<slug>/ui-strings.yaml. The
|
||||||
|
* loader (src/content/loader.ts) keys them by `season` so the runtime can
|
||||||
|
* resolve `uiStrings[1].begin.title` etc.
|
||||||
|
*/
|
||||||
|
export const UiStringsSchema = z.object({
|
||||||
|
season: z.number().int().min(0).max(7),
|
||||||
|
begin: z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
subtitle: z.string().min(1),
|
||||||
|
cta: z.string().min(1),
|
||||||
|
}),
|
||||||
|
seed_picker: z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
cancel: z.string().min(1),
|
||||||
|
}),
|
||||||
|
post_harvest_beat: z.array(z.string().min(1)).min(1),
|
||||||
|
journal: z.object({
|
||||||
|
empty_state: z.string().min(1),
|
||||||
|
back: z.string().min(1),
|
||||||
|
}),
|
||||||
|
settings: z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
export: z.string().min(1),
|
||||||
|
import: z.string().min(1),
|
||||||
|
restore_snapshot: z.string().min(1),
|
||||||
|
persistence_denied_toast: z.string().min(1),
|
||||||
|
}),
|
||||||
|
plants: z.record(z.string(), z.string().min(1)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UiStrings = z.infer<typeof UiStringsSchema>;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { BeginScreen } from './BeginScreen';
|
||||||
|
import { appStore } from '../../store';
|
||||||
|
import { __resetAudioBootstrapForTest } from './use-audio-bootstrap';
|
||||||
|
|
||||||
|
// Mock the audio bootstrap module so happy-dom (which lacks AudioContext)
|
||||||
|
// doesn't blow up; also gives us a spy to assert the click invokes it.
|
||||||
|
vi.mock('./use-audio-bootstrap', async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import('./use-audio-bootstrap')>('./use-audio-bootstrap');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
bootstrapAudioContext: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { bootstrapAudioContext } from './use-audio-bootstrap';
|
||||||
|
|
||||||
|
describe('BeginScreen (AEST-07, D-21, D-22)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
appStore.setState({ beginGateDismissed: false });
|
||||||
|
__resetAudioBootstrapForTest();
|
||||||
|
vi.mocked(bootstrapAudioContext).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the title and Begin CTA when not dismissed', () => {
|
||||||
|
render(<BeginScreen />);
|
||||||
|
expect(screen.getByText('The Last Garden')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: 'Begin' })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when beginGateDismissed=true (D-22 returning-player skip)', () => {
|
||||||
|
appStore.setState({ beginGateDismissed: true });
|
||||||
|
const { container } = render(<BeginScreen />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses the gate and triggers audio bootstrap on click', () => {
|
||||||
|
render(<BeginScreen />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Begin' }));
|
||||||
|
expect(bootstrapAudioContext).toHaveBeenCalledTimes(1);
|
||||||
|
expect(appStore.getState().beginGateDismissed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subtitle is the externalized "tend" string (CLAUDE.md tone)', () => {
|
||||||
|
render(<BeginScreen />);
|
||||||
|
expect(screen.getByText('tend')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { type JSX } from 'react';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
|
import { uiStrings } from '../../content';
|
||||||
|
import { bootstrapAudioContext } from './use-audio-bootstrap';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-21 + AEST-07: tasteful typographic Begin screen. Phase 3 swaps in
|
||||||
|
* the painted gesture-gate without changing this file's behavior.
|
||||||
|
*
|
||||||
|
* D-22: shown on first run only — gated by session.beginGateDismissed.
|
||||||
|
* Once dismissed, the gate stays down for the session; persistence
|
||||||
|
* across sessions is owned by the save lifecycle (Plan 02-05).
|
||||||
|
*
|
||||||
|
* CLAUDE.md banner concern #7 (Web Audio user-gesture): the click
|
||||||
|
* handler calls `bootstrapAudioContext()` SYNCHRONOUSLY (not inside a
|
||||||
|
* useEffect), so iOS Safari sees the AudioContext construction inside
|
||||||
|
* the gesture (Pitfall 5).
|
||||||
|
*/
|
||||||
|
export function BeginScreen(): JSX.Element | null {
|
||||||
|
const dismissed = useAppStore((s) => s.beginGateDismissed);
|
||||||
|
const dismissBeginGate = useAppStore((s) => s.dismissBeginGate);
|
||||||
|
|
||||||
|
if (dismissed) return null;
|
||||||
|
|
||||||
|
const strings = uiStrings[1]?.begin;
|
||||||
|
if (!strings) return null;
|
||||||
|
|
||||||
|
const onBegin = (): void => {
|
||||||
|
void bootstrapAudioContext(); // synchronous-inside-click; do NOT move into useEffect (Pitfall 5)
|
||||||
|
dismissBeginGate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
background: '#1a1a1a',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#e8e0d0',
|
||||||
|
fontFamily: 'serif',
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={strings.title}
|
||||||
|
>
|
||||||
|
<h1 style={{ fontSize: '3rem', margin: 0, fontWeight: 300 }}>{strings.title}</h1>
|
||||||
|
<p style={{ marginTop: '1rem', opacity: 0.7, letterSpacing: '0.2em' }}>{strings.subtitle}</p>
|
||||||
|
<button
|
||||||
|
onClick={onBegin}
|
||||||
|
style={{
|
||||||
|
marginTop: '4rem',
|
||||||
|
padding: '0.6rem 2.4rem',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#e8e0d0',
|
||||||
|
border: '1px solid #e8e0d0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{strings.cta}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { BeginScreen } from './BeginScreen';
|
||||||
|
export {
|
||||||
|
bootstrapAudioContext,
|
||||||
|
installFirstInteractionGestureHandler,
|
||||||
|
} from './use-audio-bootstrap';
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Audio bootstrap (AEST-07 + RESEARCH Pattern 9 + CLAUDE.md banner concern #7).
|
||||||
|
*
|
||||||
|
* Web Audio cannot start until the user has interacted with the page; iOS
|
||||||
|
* Safari further requires the AudioContext to be CONSTRUCTED inside the
|
||||||
|
* gesture, not merely resumed (Pitfall 5). This module is the single
|
||||||
|
* owner of that contract.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - BeginScreen onClick → call `bootstrapAudioContext()` synchronously
|
||||||
|
* inside the click handler (NOT inside a useEffect).
|
||||||
|
* - Returning players (D-22) skip the Begin screen → PhaserGame.tsx
|
||||||
|
* installs `installFirstInteractionGestureHandler()` so the next
|
||||||
|
* click / touch / keypress bootstraps audio.
|
||||||
|
*
|
||||||
|
* This module is in src/ui/ — it is allowed to touch the DOM and
|
||||||
|
* AudioContext freely (the CORE-10 firewall only forbids src/sim/ from
|
||||||
|
* importing src/ui/, not the other way).
|
||||||
|
*/
|
||||||
|
|
||||||
|
let _ctx: AudioContext | null = null;
|
||||||
|
let _resumed = false;
|
||||||
|
|
||||||
|
export async function bootstrapAudioContext(): Promise<AudioContext | null> {
|
||||||
|
if (_resumed && _ctx) return _ctx;
|
||||||
|
if (!_ctx) {
|
||||||
|
try {
|
||||||
|
const Ctor =
|
||||||
|
typeof AudioContext !== 'undefined'
|
||||||
|
? AudioContext
|
||||||
|
: (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!Ctor) return null;
|
||||||
|
_ctx = new Ctor();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _ctx.resume();
|
||||||
|
_resumed = true;
|
||||||
|
return _ctx;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For returning players (D-22): no Begin screen, but the next click,
|
||||||
|
* touch, or keypress must bootstrap audio. One-shot — removes itself
|
||||||
|
* from all three event types after the first fire.
|
||||||
|
*/
|
||||||
|
export function installFirstInteractionGestureHandler(): void {
|
||||||
|
const handler = (): void => {
|
||||||
|
void bootstrapAudioContext();
|
||||||
|
document.removeEventListener('click', handler);
|
||||||
|
document.removeEventListener('touchstart', handler);
|
||||||
|
document.removeEventListener('keydown', handler);
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handler);
|
||||||
|
document.addEventListener('touchstart', handler);
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: reset the module-level cache between tests.
|
||||||
|
* NOT part of the public surface; internal to the test harness.
|
||||||
|
*/
|
||||||
|
export function __resetAudioBootstrapForTest(): void {
|
||||||
|
_ctx = null;
|
||||||
|
_resumed = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';
|
||||||
|
|
||||||
|
// Phaser does not initialize under happy-dom (canvas.getContext('2d')
|
||||||
|
// returns null, which trips its checkInverseAlpha boot probe). The
|
||||||
|
// SeedPicker only needs an event-emitter surface — swap in a lightweight
|
||||||
|
// EventTarget-like shim instead of pulling Phaser into the test runtime.
|
||||||
|
vi.mock('../../game/event-bus', () => {
|
||||||
|
type Listener = (...args: unknown[]) => void;
|
||||||
|
const listeners = new Map<string, Set<Listener>>();
|
||||||
|
return {
|
||||||
|
eventBus: {
|
||||||
|
on(event: string, fn: Listener): void {
|
||||||
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||||
|
listeners.get(event)!.add(fn);
|
||||||
|
},
|
||||||
|
off(event: string, fn: Listener): void {
|
||||||
|
listeners.get(event)?.delete(fn);
|
||||||
|
},
|
||||||
|
emit(event: string, ...args: unknown[]): void {
|
||||||
|
listeners.get(event)?.forEach((fn) => fn(...args));
|
||||||
|
},
|
||||||
|
removeAllListeners(): void {
|
||||||
|
listeners.clear();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { SeedPicker } from './SeedPicker';
|
||||||
|
import { eventBus } from '../../game/event-bus';
|
||||||
|
import { appStore } from '../../store';
|
||||||
|
|
||||||
|
function resetStore(unlocked: string[] = ['rosemary']): void {
|
||||||
|
appStore.setState({
|
||||||
|
tiles: new Array(16).fill(null),
|
||||||
|
unlockedPlantTypes: unlocked,
|
||||||
|
pendingCommands: [],
|
||||||
|
tickCount: 0,
|
||||||
|
lastTickAt: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SeedPicker (D-02 inline DOM popover)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
resetStore();
|
||||||
|
eventBus.removeAllListeners();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
eventBus.removeAllListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on initial render (not visible until tile-clicked-coords fires)', () => {
|
||||||
|
const { container } = render(<SeedPicker />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appears positioned at the emitted screen coords when tile-clicked-coords fires', () => {
|
||||||
|
render(<SeedPicker />);
|
||||||
|
act(() => {
|
||||||
|
eventBus.emit('tile-clicked-coords', { tileIdx: 5, screenX: 200, screenY: 300 });
|
||||||
|
});
|
||||||
|
const popover = screen.getByTestId('seed-picker');
|
||||||
|
expect(popover).toBeTruthy();
|
||||||
|
// left = x - 80, top = y - 120
|
||||||
|
expect(popover.style.left).toBe('120px');
|
||||||
|
expect(popover.style.top).toBe('180px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders one button per unlocked plant type', () => {
|
||||||
|
resetStore(['rosemary']);
|
||||||
|
render(<SeedPicker />);
|
||||||
|
act(() => {
|
||||||
|
eventBus.emit('tile-clicked-coords', { tileIdx: 0, screenX: 0, screenY: 0 });
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('button', { name: 'Rosemary' })).toBeTruthy();
|
||||||
|
// Yarrow is locked — no button.
|
||||||
|
expect(screen.queryByRole('button', { name: 'Yarrow' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a plant button enqueues a plantSeed command into the store', () => {
|
||||||
|
resetStore(['rosemary']);
|
||||||
|
render(<SeedPicker />);
|
||||||
|
act(() => {
|
||||||
|
eventBus.emit('tile-clicked-coords', { tileIdx: 7, screenX: 0, screenY: 0 });
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Rosemary' }));
|
||||||
|
const cmds = appStore.getState().pendingCommands;
|
||||||
|
expect(cmds).toEqual([
|
||||||
|
{ kind: 'plantSeed', tileIdx: 7, plantTypeId: 'rosemary' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses (returns null) after a plant is selected', () => {
|
||||||
|
resetStore(['rosemary']);
|
||||||
|
const { container } = render(<SeedPicker />);
|
||||||
|
act(() => {
|
||||||
|
eventBus.emit('tile-clicked-coords', { tileIdx: 0, screenX: 0, screenY: 0 });
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Rosemary' }));
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows multiple plants when unlocked', () => {
|
||||||
|
resetStore(['rosemary', 'yarrow', 'winter-rose']);
|
||||||
|
render(<SeedPicker />);
|
||||||
|
act(() => {
|
||||||
|
eventBus.emit('tile-clicked-coords', { tileIdx: 0, screenX: 0, screenY: 0 });
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('button', { name: 'Rosemary' })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: 'Yarrow' })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: 'Winter-rose' })).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState, type JSX } from 'react';
|
||||||
|
import { eventBus } from '../../game/event-bus';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
|
import { uiStrings } from '../../content';
|
||||||
|
import { PLANT_TYPES } from '../../sim/garden';
|
||||||
|
import type { PlantTypeId } from '../../sim/garden/types';
|
||||||
|
|
||||||
|
interface PickerState {
|
||||||
|
visible: boolean;
|
||||||
|
tileIdx: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TileClickPayload {
|
||||||
|
tileIdx: number;
|
||||||
|
screenX: number;
|
||||||
|
screenY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-02 — inline DOM popover positioned over the Phaser canvas.
|
||||||
|
*
|
||||||
|
* Listens for 'tile-clicked-coords' from the Garden scene; mounts itself
|
||||||
|
* absolutely-positioned at those screen coords. Click outside dismisses.
|
||||||
|
*
|
||||||
|
* Architectural note: the popover is a DOM overlay rather than a Phaser
|
||||||
|
* UI scene because (a) it needs HTML form-element accessibility (button
|
||||||
|
* focus / tab order / screen-reader semantics) and (b) RESEARCH Pattern
|
||||||
|
* 4 — the inline-DOM-popover-over-canvas pattern — keeps the SeedPicker
|
||||||
|
* decoupled from the watercolor render pipeline that lands in Phase 3.
|
||||||
|
*/
|
||||||
|
export function SeedPicker(): JSX.Element | null {
|
||||||
|
const [picker, setPicker] = useState<PickerState>({
|
||||||
|
visible: false,
|
||||||
|
tileIdx: -1,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const unlocked = useAppStore((s) => s.unlockedPlantTypes);
|
||||||
|
const enqueueCommand = useAppStore((s) => s.enqueueCommand);
|
||||||
|
const strings = uiStrings[1]?.seed_picker;
|
||||||
|
const plantStrings = uiStrings[1]?.plants ?? {};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCoords = (payload: TileClickPayload): void => {
|
||||||
|
setPicker({
|
||||||
|
visible: true,
|
||||||
|
tileIdx: payload.tileIdx,
|
||||||
|
x: payload.screenX,
|
||||||
|
y: payload.screenY,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
eventBus.on('tile-clicked-coords', onCoords);
|
||||||
|
return () => {
|
||||||
|
eventBus.off('tile-clicked-coords', onCoords);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Click-outside dismiss. Defer one tick so the click that opened the
|
||||||
|
// picker isn't itself interpreted as the "outside" click.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!picker.visible) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
const onClick = (): void => setPicker((p) => ({ ...p, visible: false }));
|
||||||
|
document.addEventListener('click', onClick, { once: true });
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [picker.visible]);
|
||||||
|
|
||||||
|
if (!picker.visible || !strings) return null;
|
||||||
|
|
||||||
|
const onSelect = (plantTypeId: PlantTypeId): void => {
|
||||||
|
enqueueCommand({ kind: 'plantSeed', tileIdx: picker.tileIdx, plantTypeId });
|
||||||
|
setPicker((p) => ({ ...p, visible: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Translate screen coords → picker top-left (centered above tile).
|
||||||
|
const left = picker.x - 80;
|
||||||
|
const top = picker.y - 120;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="seed-picker"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
zIndex: 50,
|
||||||
|
background: '#2a2a2e',
|
||||||
|
color: '#e8e0d0',
|
||||||
|
padding: '0.6rem 0.8rem',
|
||||||
|
borderRadius: 4,
|
||||||
|
boxShadow: '0 6px 18px rgba(0,0,0,0.4)',
|
||||||
|
fontFamily: 'serif',
|
||||||
|
minWidth: 160,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '0.85rem', marginBottom: '0.5rem', opacity: 0.7 }}>
|
||||||
|
{strings.title}
|
||||||
|
</div>
|
||||||
|
{unlocked.length === 0 && (
|
||||||
|
<div style={{ fontSize: '0.85rem', fontStyle: 'italic', opacity: 0.6 }}>—</div>
|
||||||
|
)}
|
||||||
|
{unlocked.map((id) => {
|
||||||
|
const type = PLANT_TYPES[id as PlantTypeId];
|
||||||
|
if (!type) return null;
|
||||||
|
const display = plantStrings[id] ?? type.fallbackName;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => onSelect(id as PlantTypeId)}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '0.4rem 0.6rem',
|
||||||
|
margin: '0.1rem 0',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#e8e0d0',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SeedPicker } from './SeedPicker';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Top-level barrel for src/ui/. App code imports from here.
|
||||||
|
*
|
||||||
|
* Per CORE-10: src/sim/** must NOT import from this module. The sim
|
||||||
|
* stays UI-agnostic; only src/App.tsx + src/PhaserGame.tsx consume the
|
||||||
|
* UI layer.
|
||||||
|
*/
|
||||||
|
export * from './begin';
|
||||||
|
export * from './garden';
|
||||||
Reference in New Issue
Block a user