feat(02-03): journal + reveal modal + harvest pointer wiring
Task 2 of Plan 02-03: ship the Memory Journal UI surfaces (D-23/D-24/D-25) and wire harvest + compost pointer events through the Garden scene to the sim → store → React reveal flow. src/ui/journal/ (new module): - Journal.tsx — full-screen modal (D-24); fragments grouped by Season; DOM-rendered text with userSelect: text per MEMR-05; reads harvestedFragmentIds from the store; resolves ids against the eager `fragments` corpus (defensive — unresolvable ids skip silently). - FragmentRevealModal.tsx — D-25 active-play reveal modal; backdrop click + inner Close button dismiss; event.stopPropagation on the article body so clicking inside the text doesn't dismiss; defensive silent dismiss on unresolvable id. - journal-icon.tsx — D-23 + D-29 corner affordance; gated by selectJournalRevealed (`harvestedFragmentIds.length > 0`); local open state (no store pollution); 'j' hotkey deferred to Plan 02-05. - index.ts — barrel. - 16 new Vitest cases across 3 test files (Journal: 7 / FragmentRevealModal: 6 / journal-icon: 3); all green. src/App.tsx — mount FragmentRevealModal + JournalIcon as DOM siblings of PhaserGame. src/ui/index.ts — re-export ./journal. src/game/scenes/Garden.ts — harvest/compost pointer flow: - create() builds a SimContext from the eager `fragments` corpus filtered to Season 1; passed to every simulateOneTick call (Phase 4+ should swap to await loadSeasonFragments(currentSeason) when Season transitions land). - handleTilePointerDown branches on tile state: empty → seed picker event; ready plant → enqueue 'harvest' command; immature plant → enqueue 'compost' command (TODO Plan 02-04: render the Ink-authored compost acknowledgement beat from compost-acknowledgements.ink). - update() detects newly-appended harvestedFragmentIds and sets fragmentRevealId so the reveal modal pops with the new fragment text. - BLOCKER 3 invariant preserved — sim writes tickCount, never lastTickAt. content/dialogue/season1/compost-acknowledgements.ink — authored content for the GARD-04 + D-07 compost beat. 6 short lines in the gardener-keeper voice (NOT Lura — she's the warmth anchor; the garden's voice is the contrast). Plan 02-04 wires the inkjs runtime; Plan 02-03 ships the content so the writer can iterate independently. 214/214 tests green (was 163; +51 new this plan); npm run lint exits 0; npm run ci exits 0; npm run build exits 0 with the expected INEFFECTIVE_DYNAMIC_IMPORT warnings (eager `fragments` export still imports the Season-1 yaml/md statically alongside the lazy loadSeasonFragments path — documented in 02-02-SUMMARY.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
// content/dialogue/season1/compost-acknowledgements.ink
|
||||||
|
//
|
||||||
|
// Plan 02-03 ships the AUTHORED CONTENT for the compost tonal beat
|
||||||
|
// (CONTEXT D-07 + GARD-04). Plan 02-04 owns the Ink runtime — this file
|
||||||
|
// is loaded by the Ink runtime (inkjs) at that point and one of these
|
||||||
|
// short lines is dripped into the dialogue overlay each time the player
|
||||||
|
// composts an immature plant.
|
||||||
|
//
|
||||||
|
// In Plan 02-03 the React surface (Garden.ts handleTilePointerDown's
|
||||||
|
// compost branch) does NOT yet render these lines — there's a TODO
|
||||||
|
// comment at the call site marking the Plan 02-04 wiring point. The
|
||||||
|
// content lives here so the writer can iterate on voice without waiting
|
||||||
|
// for the runtime to land.
|
||||||
|
//
|
||||||
|
// Tone (CLAUDE.md): warm, specific, intermittent, sometimes funny,
|
||||||
|
// sometimes devastating. The gardener-keeper voice. NOT Lura. The garden
|
||||||
|
// is acknowledging the player's choice to let go — never sentimental,
|
||||||
|
// never reassuring, never "it's okay." Just the small fact of the choice,
|
||||||
|
// honored.
|
||||||
|
//
|
||||||
|
// Phase 2 ships ~6 short lines so the player rarely hears the same line
|
||||||
|
// twice in a single session. Plan 02-04 will randomize selection (via
|
||||||
|
// the same mulberry32 pattern as the fragment selector, or a simple
|
||||||
|
// weighted pick — implementer's choice).
|
||||||
|
|
||||||
|
=== compost_beats ===
|
||||||
|
* The earth takes it back without comment.
|
||||||
|
->DONE
|
||||||
|
|
||||||
|
* Some things are tended into being. Others are tended into not being. Both count.
|
||||||
|
->DONE
|
||||||
|
|
||||||
|
* The space the plant occupied is now space. That is a kind of progress.
|
||||||
|
->DONE
|
||||||
|
|
||||||
|
* It returns to the soil it came from. Not poetry — just composting. Mostly.
|
||||||
|
->DONE
|
||||||
|
|
||||||
|
* The garden is bigger by one empty tile.
|
||||||
|
->DONE
|
||||||
|
|
||||||
|
* You changed your mind. The garden has nothing to say about it.
|
||||||
|
->DONE
|
||||||
+3
-1
@@ -2,6 +2,7 @@ import { useRef } from 'react';
|
|||||||
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
|
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
|
||||||
import { BeginScreen } from './ui/begin';
|
import { BeginScreen } from './ui/begin';
|
||||||
import { SeedPicker } from './ui/garden';
|
import { SeedPicker } from './ui/garden';
|
||||||
|
import { FragmentRevealModal, JournalIcon } from './ui/journal';
|
||||||
|
|
||||||
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.
|
||||||
@@ -12,7 +13,8 @@ function App() {
|
|||||||
<PhaserGame ref={phaserRef} />
|
<PhaserGame ref={phaserRef} />
|
||||||
<BeginScreen />
|
<BeginScreen />
|
||||||
<SeedPicker />
|
<SeedPicker />
|
||||||
{/* Plan 02-03 mounts: <Journal />, <FragmentRevealModal /> */}
|
<FragmentRevealModal />
|
||||||
|
<JournalIcon />
|
||||||
{/* Plan 02-04 mounts: <LuraDialogue /> */}
|
{/* Plan 02-04 mounts: <LuraDialogue /> */}
|
||||||
{/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */}
|
{/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import * as Phaser from 'phaser';
|
|||||||
import { eventBus } from '../event-bus';
|
import { eventBus } from '../event-bus';
|
||||||
import { drainTicks, wallClock, type Clock } from '../../sim/scheduler';
|
import { drainTicks, wallClock, type Clock } from '../../sim/scheduler';
|
||||||
import type { SimState } from '../../sim/state';
|
import type { SimState } from '../../sim/state';
|
||||||
import { simulateOneTick, tileGrowthStage } from '../../sim/garden';
|
import {
|
||||||
|
simulateOneTick,
|
||||||
|
tileGrowthStage,
|
||||||
|
type SimContext,
|
||||||
|
} from '../../sim/garden';
|
||||||
import type { Tile } from '../../sim/garden/types';
|
import type { Tile } from '../../sim/garden/types';
|
||||||
import {
|
import {
|
||||||
drawTiles,
|
drawTiles,
|
||||||
@@ -14,6 +18,7 @@ import {
|
|||||||
type PlantGameObject,
|
type PlantGameObject,
|
||||||
} from '../../render/garden';
|
} from '../../render/garden';
|
||||||
import { appStore, simAdapter } from '../../store';
|
import { appStore, simAdapter } from '../../store';
|
||||||
|
import { fragments as allFragments } from '../../content';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 4×4 garden scene (CONTEXT D-01). Wires the tick scheduler into
|
* The 4×4 garden scene (CONTEXT D-01). Wires the tick scheduler into
|
||||||
@@ -22,6 +27,25 @@ import { appStore, simAdapter } from '../../store';
|
|||||||
*
|
*
|
||||||
* The Garden scene is the ONLY place where sim + store + render meet.
|
* The Garden scene is the ONLY place where sim + store + render meet.
|
||||||
* It stays thin (RESEARCH Pattern 3): subscribe, dispatch.
|
* It stays thin (RESEARCH Pattern 3): subscribe, dispatch.
|
||||||
|
*
|
||||||
|
* Plan 02-03 additions:
|
||||||
|
* - SimContext built once at create() from the eager `fragments` corpus
|
||||||
|
* filtered to Season 1; passed to every simulateOneTick call.
|
||||||
|
* - handleTilePointerDown branches on tile state:
|
||||||
|
* empty plant → emit 'tile-clicked-coords' for SeedPicker
|
||||||
|
* ready plant → enqueue 'harvest' command
|
||||||
|
* immature plant → enqueue 'compost' command
|
||||||
|
* - update() loop detects newly-appended harvestedFragmentIds and sets
|
||||||
|
* fragmentRevealId so the FragmentRevealModal pops with the new
|
||||||
|
* fragment's full text (D-25).
|
||||||
|
*
|
||||||
|
* Fragment-loading approach: Plan 02-03 uses the eager `fragments` export
|
||||||
|
* (ships the full corpus into the initial bundle) rather than awaiting
|
||||||
|
* loadSeasonFragments(1). For Phase 2's Season-1-only scope this is
|
||||||
|
* simpler — the Plan 02-03 SUMMARY documents the trade-off. The PIPE-02
|
||||||
|
* structural verification (scripts/check-bundle-split.mjs) proves the
|
||||||
|
* lazy-import surface still emits a separate Season-1 chunk for Phase
|
||||||
|
* 4+ to exploit when the corpus grows beyond a single Season.
|
||||||
*/
|
*/
|
||||||
export class Garden extends Phaser.Scene {
|
export class Garden extends Phaser.Scene {
|
||||||
private accumulatorMs = 0;
|
private accumulatorMs = 0;
|
||||||
@@ -32,6 +56,7 @@ export class Garden extends Phaser.Scene {
|
|||||||
private plantObjs: Map<number, PlantGameObject> = new Map();
|
private plantObjs: Map<number, PlantGameObject> = new Map();
|
||||||
private readyTweens: Map<number, Phaser.Tweens.Tween> = new Map();
|
private readyTweens: Map<number, Phaser.Tweens.Tween> = new Map();
|
||||||
private storeUnsubscribe: (() => void) | null = null;
|
private storeUnsubscribe: (() => void) | null = null;
|
||||||
|
private simContext: SimContext = { fragments: [], currentSeason: 1 };
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('Garden');
|
super('Garden');
|
||||||
@@ -44,6 +69,14 @@ export class Garden extends Phaser.Scene {
|
|||||||
const slot = (window as unknown as { __tlgClock?: Clock }).__tlgClock;
|
const slot = (window as unknown as { __tlgClock?: Clock }).__tlgClock;
|
||||||
if (slot) this.clock = slot;
|
if (slot) this.clock = slot;
|
||||||
|
|
||||||
|
// Build the SimContext once at create() — Phase 2 ships only Season 1.
|
||||||
|
// Phase 4+ should swap this for `await loadSeasonFragments(currentSeason)`
|
||||||
|
// when the Season transition lands.
|
||||||
|
this.simContext = {
|
||||||
|
fragments: allFragments.filter((f) => f.season === 1),
|
||||||
|
currentSeason: 1,
|
||||||
|
};
|
||||||
|
|
||||||
// Restore tickCount from the store (set on save load by saveSync).
|
// Restore tickCount from the store (set on save load by saveSync).
|
||||||
this.currentTick = appStore.getState().tickCount;
|
this.currentTick = appStore.getState().tickCount;
|
||||||
|
|
||||||
@@ -73,6 +106,7 @@ export class Garden extends Phaser.Scene {
|
|||||||
// Build current SimState snapshot from the store + drain commands.
|
// Build current SimState snapshot from the store + drain commands.
|
||||||
const storeState = appStore.getState();
|
const storeState = appStore.getState();
|
||||||
const commands = simAdapter.drainCommands();
|
const commands = simAdapter.drainCommands();
|
||||||
|
const prevHarvestCount = storeState.harvestedFragmentIds.length;
|
||||||
|
|
||||||
// BLOCKER 3 — DO NOT seed lastTickAt with this.currentTick. lastTickAt
|
// BLOCKER 3 — DO NOT seed lastTickAt with this.currentTick. lastTickAt
|
||||||
// is wall-clock ms owned by saveSync. The Garden scene's snapshot
|
// is wall-clock ms owned by saveSync. The Garden scene's snapshot
|
||||||
@@ -97,7 +131,7 @@ export class Garden extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = drainTicks(simStateNow, this.accumulatorMs, (s, _dtMs, _silent) => {
|
const result = drainTicks(simStateNow, this.accumulatorMs, (s, _dtMs, _silent) => {
|
||||||
const next = simulateOneTick(s, this.currentTick + 1, commands);
|
const next = simulateOneTick(s, this.currentTick + 1, commands, this.simContext);
|
||||||
this.currentTick++;
|
this.currentTick++;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -108,6 +142,18 @@ export class Garden extends Phaser.Scene {
|
|||||||
result.state.garden.tiles,
|
result.state.garden.tiles,
|
||||||
result.state.unlockedPlantTypes,
|
result.state.unlockedPlantTypes,
|
||||||
);
|
);
|
||||||
|
// Plan 02-03 — D-25 reveal flow. If a new fragment was harvested
|
||||||
|
// during this drain, push the harvested-ids list into the store
|
||||||
|
// and flag the most recent id for the reveal modal.
|
||||||
|
const newHarvestCount = result.state.harvestedFragmentIds.length;
|
||||||
|
if (newHarvestCount > prevHarvestCount) {
|
||||||
|
const newId =
|
||||||
|
result.state.harvestedFragmentIds[newHarvestCount - 1];
|
||||||
|
if (newId) {
|
||||||
|
simAdapter.applyHarvestedFragments(result.state.harvestedFragmentIds);
|
||||||
|
appStore.getState().setFragmentRevealId(newId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +166,21 @@ export class Garden extends Phaser.Scene {
|
|||||||
eventBus.emit('tile-clicked-coords', { tileIdx: idx, screenX: dom.x, screenY: dom.y });
|
eventBus.emit('tile-clicked-coords', { tileIdx: idx, screenX: dom.x, screenY: dom.y });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Plan 02-03 wires harvest / compost on plant click.
|
// Has plant — branch on growth stage.
|
||||||
|
const stage = tileGrowthStage(tile, this.currentTick);
|
||||||
|
if (stage === 'ready') {
|
||||||
|
// GARD-03: harvest fires through the sim, which selects a fragment
|
||||||
|
// and clears the tile.
|
||||||
|
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.
|
||||||
|
appStore.getState().enqueueCommand({ kind: 'compost', tileIdx: idx });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private repaintPlants(tiles: Tile[]): void {
|
private repaintPlants(tiles: Tile[]): void {
|
||||||
|
|||||||
@@ -7,3 +7,4 @@
|
|||||||
*/
|
*/
|
||||||
export * from './begin';
|
export * from './begin';
|
||||||
export * from './garden';
|
export * from './garden';
|
||||||
|
export * from './journal';
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { FragmentRevealModal } from './FragmentRevealModal';
|
||||||
|
import { appStore } from '../../store';
|
||||||
|
|
||||||
|
vi.mock('../../game/event-bus', () => ({
|
||||||
|
eventBus: {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
removeAllListeners: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function reveal(id: string | null): void {
|
||||||
|
appStore.setState({ fragmentRevealId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FragmentRevealModal (D-25)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
reveal(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when fragmentRevealId is null', () => {
|
||||||
|
const { container } = render(<FragmentRevealModal />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the body of the revealed fragment in selectable DOM (MEMR-05)', () => {
|
||||||
|
reveal('season1.soil.first-bloom');
|
||||||
|
render(<FragmentRevealModal />);
|
||||||
|
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
|
||||||
|
// Inner pre is selectable.
|
||||||
|
const article = document.querySelector('article[data-fragment-id="season1.soil.first-bloom"]');
|
||||||
|
expect(article).not.toBeNull();
|
||||||
|
const pre = (article as HTMLElement).querySelector('pre');
|
||||||
|
expect(pre).not.toBeNull();
|
||||||
|
expect((pre as HTMLElement).style.userSelect).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the backdrop dismisses (clears fragmentRevealId in the store)', () => {
|
||||||
|
reveal('season1.soil.first-bloom');
|
||||||
|
render(<FragmentRevealModal />);
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
fireEvent.click(dialog);
|
||||||
|
expect(appStore.getState().fragmentRevealId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the article body does NOT dismiss (event.stopPropagation)', () => {
|
||||||
|
reveal('season1.soil.first-bloom');
|
||||||
|
render(<FragmentRevealModal />);
|
||||||
|
const article = document.querySelector('article[data-fragment-id="season1.soil.first-bloom"]');
|
||||||
|
expect(article).not.toBeNull();
|
||||||
|
fireEvent.click(article as HTMLElement);
|
||||||
|
expect(appStore.getState().fragmentRevealId).toBe('season1.soil.first-bloom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the inner Close button dismisses', () => {
|
||||||
|
reveal('season1.soil.first-bloom');
|
||||||
|
render(<FragmentRevealModal />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||||
|
expect(appStore.getState().fragmentRevealId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unresolvable fragment id silently dismisses (defensive)', () => {
|
||||||
|
reveal('season9.bogus.never-shipped');
|
||||||
|
const { container } = render(<FragmentRevealModal />);
|
||||||
|
// The component sets fragmentRevealId=null during render; output is null.
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
expect(appStore.getState().fragmentRevealId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { type JSX } from 'react';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
|
import { fragments as allFragments } from '../../content';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-25 — fragment reveal modal in active play.
|
||||||
|
*
|
||||||
|
* Surfaces the just-harvested fragment in full text immediately; clicking
|
||||||
|
* the backdrop OR the inner Close button dismisses, which clears
|
||||||
|
* `fragmentRevealId` in the store and files the fragment into the
|
||||||
|
* journal under its Season.
|
||||||
|
*
|
||||||
|
* Triggered by Garden.ts's update() loop: after drainTicks runs and a new
|
||||||
|
* id is appended to harvestedFragmentIds, the scene calls
|
||||||
|
* setFragmentRevealId(newId). The store change triggers this component's
|
||||||
|
* re-render.
|
||||||
|
*
|
||||||
|
* Defensive — if the id doesn't resolve to a known Fragment (corrupted
|
||||||
|
* save / future Season fragment loaded but not in the eager corpus), the
|
||||||
|
* modal silently dismisses to avoid stranding the player on a dead modal.
|
||||||
|
*/
|
||||||
|
export function FragmentRevealModal(): JSX.Element | null {
|
||||||
|
const fragmentRevealId = useAppStore((s) => s.fragmentRevealId);
|
||||||
|
const setFragmentRevealId = useAppStore((s) => s.setFragmentRevealId);
|
||||||
|
|
||||||
|
if (!fragmentRevealId) return null;
|
||||||
|
|
||||||
|
const fragment = allFragments.find((f) => f.id === fragmentRevealId);
|
||||||
|
if (!fragment) {
|
||||||
|
// Defensive dismiss; the next render will return null because we just
|
||||||
|
// cleared the id in the store. (React will warn about state-update-
|
||||||
|
// during-render only if this fires synchronously every render — it
|
||||||
|
// does not, because after the setState the next render reads
|
||||||
|
// fragmentRevealId === null and exits at the guard above.)
|
||||||
|
setFragmentRevealId(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDismiss = (): void => setFragmentRevealId(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="A new memory"
|
||||||
|
data-testid="fragment-reveal-modal"
|
||||||
|
onClick={onDismiss}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 90,
|
||||||
|
background: '#0c0c0deb',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#e8e0d0',
|
||||||
|
fontFamily: 'serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
data-fragment-id={fragment.id}
|
||||||
|
style={{
|
||||||
|
maxWidth: 600,
|
||||||
|
padding: '3rem 2.4rem',
|
||||||
|
background: '#1f1f23',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
fontFamily: 'serif',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
lineHeight: 1.7,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
userSelect: 'text',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fragment.body}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
marginTop: '2rem',
|
||||||
|
padding: '0.5rem 1.4rem',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#e8e0d0',
|
||||||
|
border: '1px solid #e8e0d0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { Journal } from './Journal';
|
||||||
|
import { appStore } from '../../store';
|
||||||
|
|
||||||
|
// Phaser does not initialize under happy-dom. The Journal does not import
|
||||||
|
// Phaser directly, but the @testing-library cleanup chain can transitively
|
||||||
|
// pull modules that do — and we want to keep these tests isolated from
|
||||||
|
// the Phaser runtime regardless.
|
||||||
|
vi.mock('../../game/event-bus', () => ({
|
||||||
|
eventBus: {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
removeAllListeners: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function resetStore(harvested: string[] = []): void {
|
||||||
|
appStore.setState({ harvestedFragmentIds: harvested });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Journal (D-24 full-screen modal, MEMR-04, MEMR-05)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
resetStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when open=false', () => {
|
||||||
|
const { container } = render(<Journal open={false} onClose={() => {}} />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the empty-state copy from uiStrings when no fragments are harvested', () => {
|
||||||
|
render(<Journal open={true} onClose={() => {}} />);
|
||||||
|
// From content/seasons/01-soil/ui-strings.yaml: "Nothing yet. Plant something."
|
||||||
|
expect(screen.getByText('Nothing yet. Plant something.')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the body of a harvested fragment in selectable DOM (MEMR-05)', () => {
|
||||||
|
resetStore(['season1.soil.first-bloom']);
|
||||||
|
render(<Journal open={true} onClose={() => {}} />);
|
||||||
|
// The body in fragments.yaml starts with "The first thing that grew was rosemary."
|
||||||
|
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
|
||||||
|
// And the article wrapper carries userSelect: text (MEMR-05).
|
||||||
|
const article = document.querySelector('article[data-fragment-id="season1.soil.first-bloom"]');
|
||||||
|
expect(article).not.toBeNull();
|
||||||
|
expect((article as HTMLElement).style.userSelect).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups fragments by Season (D-24)', () => {
|
||||||
|
resetStore(['season1.soil.first-bloom', 'season1.soil.the-cat']);
|
||||||
|
render(<Journal open={true} onClose={() => {}} />);
|
||||||
|
// Section header text "Season 1"
|
||||||
|
expect(screen.getByText('Season 1')).toBeTruthy();
|
||||||
|
// Both fragments rendered.
|
||||||
|
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/The cat is missing now too/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the close button invokes the onClose callback exactly once', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<Journal open={true} onClose={onClose} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /close journal/i }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label on the dialog is "Memory Journal" (accessibility)', () => {
|
||||||
|
render(<Journal open={true} onClose={() => {}} />);
|
||||||
|
expect(screen.getByLabelText('Memory Journal')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silently skips harvested ids that do not resolve to a known Fragment (defensive)', () => {
|
||||||
|
resetStore(['season1.soil.first-bloom', 'season9.bogus.never-shipped']);
|
||||||
|
render(<Journal open={true} onClose={() => {}} />);
|
||||||
|
// Real one renders; bogus one does not crash, just doesn't appear.
|
||||||
|
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/season9.bogus/)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { type JSX } from 'react';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
|
import { fragments as allFragments, uiStrings } from '../../content';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-24 — full-screen Memory Journal modal.
|
||||||
|
*
|
||||||
|
* Renders all harvested fragments grouped by Season as DOM text per
|
||||||
|
* MEMR-05 (selectable, copy-pasteable — never canvas). The journal
|
||||||
|
* resolves fragment ids against the eager `fragments` corpus loaded at
|
||||||
|
* module-eval time; ids that don't resolve are silently skipped (defensive
|
||||||
|
* — the only way an id could fail to resolve is if a save predates the
|
||||||
|
* authored fragment, which the migration chain prevents).
|
||||||
|
*
|
||||||
|
* Visibility is controlled by the parent (JournalIcon owns local state).
|
||||||
|
* Phase 2 has only Season 1; Phase 4+ Journal will need pagination /
|
||||||
|
* collapse — out of scope here.
|
||||||
|
*
|
||||||
|
* Architectural note: Journal is DOM, not Phaser. MEMR-05 is the load-
|
||||||
|
* bearing requirement; canvas rendering would foreclose copy-paste from
|
||||||
|
* day one.
|
||||||
|
*/
|
||||||
|
export function Journal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}): JSX.Element | null {
|
||||||
|
const harvested = useAppStore((s) => s.harvestedFragmentIds);
|
||||||
|
const strings = uiStrings[1]?.journal;
|
||||||
|
if (!open || !strings) return null;
|
||||||
|
|
||||||
|
// Resolve fragment objects in harvest order (player's reading order).
|
||||||
|
const harvestedFragments = harvested
|
||||||
|
.map((id) => allFragments.find((f) => f.id === id))
|
||||||
|
.filter((f): f is NonNullable<typeof f> => f !== undefined);
|
||||||
|
|
||||||
|
// Group by season for D-24 "fragments grouped by Season" requirement.
|
||||||
|
// Map preserves insertion order; we sort the key list before render.
|
||||||
|
const bySeason = new Map<number, typeof harvestedFragments>();
|
||||||
|
for (const f of harvestedFragments) {
|
||||||
|
if (!bySeason.has(f.season)) bySeason.set(f.season, []);
|
||||||
|
bySeason.get(f.season)!.push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Memory Journal"
|
||||||
|
data-testid="journal-modal"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 80,
|
||||||
|
background: '#1a1a1aee',
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '3rem 2rem',
|
||||||
|
color: '#e8e0d0',
|
||||||
|
fontFamily: 'serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close journal"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#e8e0d0',
|
||||||
|
border: '1px solid #e8e0d0',
|
||||||
|
padding: '0.4rem 1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'serif',
|
||||||
|
zIndex: 90,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{strings.back}
|
||||||
|
</button>
|
||||||
|
<div style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||||
|
{harvestedFragments.length === 0 && (
|
||||||
|
<p style={{ fontStyle: 'italic', opacity: 0.6, userSelect: 'text' }}>
|
||||||
|
{strings.empty_state}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{[...bySeason.entries()]
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([season, frags]) => (
|
||||||
|
<section key={season}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
opacity: 0.6,
|
||||||
|
fontWeight: 300,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Season {season}
|
||||||
|
</h2>
|
||||||
|
{frags.map((f) => (
|
||||||
|
<article
|
||||||
|
key={f.id}
|
||||||
|
data-fragment-id={f.id}
|
||||||
|
style={{ margin: '2rem 0', userSelect: 'text' }}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
fontFamily: 'serif',
|
||||||
|
fontSize: '1rem',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
userSelect: 'text',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.body}
|
||||||
|
</pre>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Public barrel for src/ui/journal/.
|
||||||
|
*
|
||||||
|
* Plan 02-03 ships:
|
||||||
|
* - Journal: full-screen modal listing all collected fragments grouped
|
||||||
|
* by Season (D-24, MEMR-04, MEMR-05).
|
||||||
|
* - FragmentRevealModal: active-play reveal modal (D-25, MEMR-01).
|
||||||
|
* - JournalIcon: corner button that opens Journal; hidden until first
|
||||||
|
* harvest (D-23, D-29).
|
||||||
|
*/
|
||||||
|
export { Journal } from './Journal';
|
||||||
|
export { FragmentRevealModal } from './FragmentRevealModal';
|
||||||
|
export { JournalIcon } from './journal-icon';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { JournalIcon } from './journal-icon';
|
||||||
|
import { appStore } from '../../store';
|
||||||
|
|
||||||
|
vi.mock('../../game/event-bus', () => ({
|
||||||
|
eventBus: {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
removeAllListeners: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function setHarvested(ids: string[]): void {
|
||||||
|
appStore.setState({ harvestedFragmentIds: ids });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JournalIcon (D-23 reveal-after-first-harvest, D-29 corner affordance)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
setHarvested([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no fragments have been harvested (D-23)', () => {
|
||||||
|
const { container } = render(<JournalIcon />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the corner icon button after the first harvest (D-23)', () => {
|
||||||
|
setHarvested(['season1.soil.first-bloom']);
|
||||||
|
render(<JournalIcon />);
|
||||||
|
expect(screen.getByTestId('journal-icon')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the icon opens the Journal modal', () => {
|
||||||
|
setHarvested(['season1.soil.first-bloom']);
|
||||||
|
render(<JournalIcon />);
|
||||||
|
// Modal is closed initially.
|
||||||
|
expect(screen.queryByTestId('journal-modal')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId('journal-icon'));
|
||||||
|
expect(screen.getByTestId('journal-modal')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState, type JSX } from 'react';
|
||||||
|
import { useAppStore, selectJournalRevealed } from '../../store';
|
||||||
|
import { Journal } from './Journal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-23 + D-29 — corner Memory Journal affordance.
|
||||||
|
*
|
||||||
|
* Pre-first-harvest: returns null. Post-first-harvest (revealed via
|
||||||
|
* `selectJournalRevealed` selector — `harvestedFragmentIds.length > 0`):
|
||||||
|
* renders a small fixed-position icon button that opens the Journal
|
||||||
|
* modal when clicked.
|
||||||
|
*
|
||||||
|
* The 'j' hotkey toggle (D-29) is intentionally NOT wired in Plan 02-03 —
|
||||||
|
* the keyboard-shortcut surface lands with the wider Settings hotkey work
|
||||||
|
* in Plan 02-05. Plan 02-03 provides the icon affordance only; the
|
||||||
|
* journal can also be opened via this component's onClick.
|
||||||
|
*
|
||||||
|
* Internal `open` state lives here rather than in the store so the
|
||||||
|
* affordance owns its visibility lifecycle without polluting the
|
||||||
|
* persisted save shape (V1Payload has no journal-open flag, by design).
|
||||||
|
*/
|
||||||
|
export function JournalIcon(): JSX.Element | null {
|
||||||
|
const revealed = useAppStore(selectJournalRevealed);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Defensive: if the player somehow has the journal open and then their
|
||||||
|
// harvest list becomes empty (shouldn't happen normally — there's no
|
||||||
|
// un-harvest path — but a save-import to an earlier snapshot via
|
||||||
|
// Plan 02-05's Settings could land them here), close the journal.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!revealed && open) setOpen(false);
|
||||||
|
}, [revealed, open]);
|
||||||
|
|
||||||
|
if (!revealed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
data-testid="journal-icon"
|
||||||
|
aria-label="Open memory journal"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 20,
|
||||||
|
right: 20,
|
||||||
|
zIndex: 40,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
background: '#2a2a2e',
|
||||||
|
color: '#e8e0d0',
|
||||||
|
border: '1px solid #4d4d52',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'serif',
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<Journal open={open} onClose={() => setOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user