953784ae93
- W6: warm-tagged pool depth raised to ≥9 (8th-harvest threshold + 1 buffer) so a worst-case all-rosemary playthrough never exhausts. Total per-pool targets: ≥9 warm, ≥3 contemplative, ≥3 heavy, plus the sentinel. - W2: JournalIcon now listens for the 'tlg:toggle-journal' window event so App.tsx can wire a 'j' hotkey without lifting open/close state into the store. Hotkey is gated on the same revealed selector as the icon itself.
1294 lines
61 KiB
Markdown
1294 lines
61 KiB
Markdown
---
|
||
phase: 02
|
||
plan: 03
|
||
type: execute
|
||
wave: 1
|
||
depends_on: [02-01]
|
||
files_modified:
|
||
- src/sim/memory/selector.ts
|
||
- src/sim/memory/selector.test.ts
|
||
- src/sim/memory/pool.ts
|
||
- src/sim/memory/index.ts
|
||
- src/sim/garden/commands.ts
|
||
- src/sim/garden/commands.test.ts
|
||
- src/ui/journal/Journal.tsx
|
||
- src/ui/journal/Journal.test.tsx
|
||
- src/ui/journal/FragmentRevealModal.tsx
|
||
- src/ui/journal/FragmentRevealModal.test.tsx
|
||
- src/ui/journal/journal-icon.tsx
|
||
- src/ui/journal/index.ts
|
||
- src/ui/index.ts
|
||
- src/App.tsx
|
||
- content/seasons/01-soil/fragments.yaml
|
||
- content/seasons/01-soil/fragments/lura-first-letter.md
|
||
- content/seasons/01-soil/fragments/winter-rose-night.md
|
||
- scripts/check-bundle-split.mjs
|
||
- scripts/check-bundle-split.test.mjs
|
||
- package.json
|
||
autonomous: true
|
||
requirements: [GARD-03, GARD-04, MEMR-01, MEMR-02, MEMR-03, MEMR-04, MEMR-05, MEMR-06, PIPE-02, UX-01]
|
||
tags: [vertical-slice, harvest, journal, fragments, content-authoring, lazy-load, mvp]
|
||
|
||
must_haves:
|
||
truths:
|
||
- "Player clicks a ready-stage tile → harvest command enqueues → next sim tick selects exactly one fragment from the gated pool, appends to harvestedFragmentIds, empties the tile (GARD-03, MEMR-01)"
|
||
- "Fragment selector is deterministic (same inputs → same fragment), respects Season + plant-type gating, and never duplicates a fragment within a playthrough until the gated pool is exhausted (MEMR-06)"
|
||
- "When the gated pool is exhausted, selector returns the documented sentinel fragment (e.g., 'season1.soil.gardener-knows-this-one-already') OR repeats the most-recently-harvested fragment (Pitfall 8). Behavior chosen + documented."
|
||
- "Player clicks an immature plant → compost command enqueues → tile empties → an Ink-authored single-line tonal acknowledgement plays (GARD-04, D-07, RESEARCH Open Question 2). Phase 2 ships acknowledgements as a small Ink file under /content/dialogue/season1/compost-acknowledgements.ink — Plan 02-04 owns ink runtime; Plan 02-03 ships the AUTHORED CONTENT and the placeholder text-snippet UX (with TODO comment) so Plan 02-04 can swap to Ink without reworking."
|
||
- "Newly harvested fragments in active play surface in a full-text reveal modal (D-25); dismissing files into the journal under their Season"
|
||
- "Journal icon is invisible until the first harvest, then persistent (D-23). Journal opens on icon click as a full-screen modal (D-24); fragments grouped by Season; text is selectable + copy-pasteable DOM (MEMR-05)"
|
||
- "Season 1 ships ≥10 authored fragments under /content/seasons/01-soil/ — enough to comfortably exceed the 8th-harvest Lura threshold + plant-type unlocks per RESEARCH Pitfall 8 + Assumption A8"
|
||
- "Plant-type unlock thresholds: yarrow unlocks at 3 harvests (rosemary-pool); winter-rose unlocks at 6 harvests (yarrow-pool exhausted or near-exhausted). Specific values are Claude's discretion within reason (D-05); document chosen values in SUMMARY.md"
|
||
- "Compost returns the tile to empty immediately (D-07); no resource refund (D-04 = infinite seeds, no cost-recovery)"
|
||
- "PIPE-02 lazy loader actually loads Season-1 fragments via loadSeasonFragments(1); structural assertion via scripts/check-bundle-split.mjs proves Vite emits a separate Season-1 chunk after `npm run build`"
|
||
- "All authored fragment IDs match the regex /^season1\\.[a-z0-9._-]+$/ (MEMR-03 stable string ID rule)"
|
||
- "Fragment text matches bible voice (CLAUDE.md Tone) — short, specific, intermittent, sometimes funny, sometimes devastating"
|
||
- "npm run ci is green; the new scripts/check-bundle-split.mjs runs as part of `ci` and exits 0"
|
||
artifacts:
|
||
- path: src/sim/memory/selector.ts
|
||
provides: "selectFragment(state, currentSeason, plantTypeId, allFragments) → Fragment | null — pure deterministic selector with gating + no-dup + exhaustion fallback (MEMR-06, RESEARCH Pitfall 8)"
|
||
exports: ["selectFragment", "EXHAUSTION_FALLBACK_ID"]
|
||
- path: src/sim/memory/pool.ts
|
||
provides: "filterPool(allFragments, season, plantTypeId, alreadyHarvestedIds) — pure filter helper"
|
||
exports: ["filterPool"]
|
||
- path: src/sim/garden/commands.ts
|
||
provides: "(extended) harvest(state, tileIdx, currentTick), compost(state, tileIdx, currentTick) — pure commands. simulateOneTick branches on harvest/compost"
|
||
exports: ["plantSeed", "harvest", "compost", "simulateOneTick", "tileGrowthStage"]
|
||
- path: src/ui/journal/Journal.tsx
|
||
provides: "Full-screen modal listing all harvested fragments grouped by Season; selectable DOM text per MEMR-05"
|
||
exports: ["Journal"]
|
||
- path: src/ui/journal/FragmentRevealModal.tsx
|
||
provides: "Active-play reveal modal (D-25) — surfaces just-harvested fragment in full text"
|
||
exports: ["FragmentRevealModal"]
|
||
- path: src/ui/journal/journal-icon.tsx
|
||
provides: "Corner icon button (D-23/D-29). Hidden pre-first-harvest; opens Journal modal on click"
|
||
exports: ["JournalIcon"]
|
||
- path: content/seasons/01-soil/fragments.yaml
|
||
provides: "≥8 short Season-1 fragments authored in voice (the bulk pool that Lura's beats + plant-unlock thresholds draw from)"
|
||
- path: content/seasons/01-soil/fragments/*.md
|
||
provides: "≥2 long-form per-file Season-1 fragments (Markdown + frontmatter); proves the Markdown loader path on Season 1 too"
|
||
- path: scripts/check-bundle-split.mjs
|
||
provides: "PIPE-02 structural verification: after `npm run build`, asserts that dist/assets/ contains a chunk specifically named to include 'season1' or 'fragments' (Vite default chunk-naming based on the dynamic-import path)"
|
||
key_links:
|
||
- from: src/sim/garden/commands.ts
|
||
to: src/sim/memory/selector.ts
|
||
via: "harvest() invokes selectFragment to pick exactly one fragment"
|
||
pattern: "selectFragment"
|
||
- from: src/ui/journal/Journal.tsx
|
||
to: src/store/index.ts
|
||
via: "useAppStore(s => s.harvestedFragmentIds) — DOM render of fragments by Season"
|
||
pattern: "useAppStore"
|
||
- from: src/ui/journal/FragmentRevealModal.tsx
|
||
to: src/store/index.ts
|
||
via: "useAppStore(s => s.fragmentRevealId) — opens when set; clears on dismiss"
|
||
pattern: "fragmentRevealId"
|
||
- from: src/sim/memory/selector.ts
|
||
to: src/content/index.ts
|
||
via: "selector takes the loaded `fragments` array as an argument; pool is INJECTED so selector stays pure (no module-load coupling to Vite glob)"
|
||
pattern: "Fragment\\[\\]"
|
||
- from: package.json scripts.ci
|
||
to: scripts/check-bundle-split.mjs
|
||
via: "ci runs `npm run build` then `node scripts/check-bundle-split.mjs` to assert PIPE-02 chunk split"
|
||
pattern: "check:bundle-split"
|
||
---
|
||
|
||
<note>
|
||
**Wave 1 vertical slice. Depends on Plan 02-01 (foundations).**
|
||
|
||
Runs in parallel with Plan 02-02 (Begin + Plant + Grow). Both depend only on 02-01. The shared surface is `src/sim/garden/types.ts` (locked by Plan 02-02 Task 1) and `src/sim/garden/commands.ts` (Plan 02-02 ships plantSeed; Plan 02-03 ADDS harvest + compost branches via merge). Coordinate the merge moment — both plans edit `simulateOneTick`'s switch.
|
||
|
||
3 tasks. Estimated context cost ~50%.
|
||
</note>
|
||
|
||
<objective>
|
||
Ship the Harvest → Journal → Fragment-reveal vertical slice end-to-end. Player clicks a ready plant → harvest fires → exactly one Season-1 fragment is selected from the authored pool (deterministic, gated, no-dup) → reveal modal pops with the fragment's full text (selectable, copy-pasteable DOM) → dismissing the reveal files the fragment into the Memory Journal under Season 1 → a journal icon (hidden pre-first-harvest) reveals in the corner → clicking opens the Journal modal listing all collected fragments grouped by Season.
|
||
|
||
Also ships compost → tile-empties + tonal acknowledgement, the actual Season-1 authored content (≥10 fragments matching bible voice), the plant-type unlock thresholds (yarrow at 3 harvests, winter-rose at 6 — Claude's discretion within D-05), and the PIPE-02 structural verification script proving Vite emits a separate Season-1 chunk after build.
|
||
|
||
Purpose: Completes the second half of the player's first session (the first half — Begin → Plant → Grow — lands in Plan 02-02). After this plan ships, a player can run the full active-play loop end-to-end on real authored content. Plan 02-04 layers Lura's beats on top; Plan 02-05 layers offline catch-up + the letter on top.
|
||
|
||
Output: Complete sim/memory module (selector + pool), extended sim/garden/commands.ts (harvest + compost branches), DOM-rendered Journal + FragmentRevealModal + journal-icon, ≥10 authored Season-1 fragments under /content/seasons/01-soil/, PIPE-02 structural test script, all green under `npm run ci`.
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/PROJECT.md
|
||
@.planning/ROADMAP.md
|
||
@.planning/STATE.md
|
||
@CLAUDE.md
|
||
@.planning/anti-fomo-doctrine.md
|
||
@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md
|
||
@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md
|
||
@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md
|
||
@.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md
|
||
@.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-SUMMARY.md
|
||
@.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.md
|
||
@content/README.md
|
||
|
||
<interfaces>
|
||
<!-- Types and exports the executor needs from Wave 0 + Plan 02-02. -->
|
||
|
||
From src/sim/garden/index.ts (Plan 02-02):
|
||
```typescript
|
||
export type { Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage } from './types';
|
||
export { GRID_SIZE, GRID_ROWS, GRID_COLS, tileIdx, tileCoords } from './types';
|
||
export { PLANT_TYPES, getPlantType } from './plants';
|
||
export { advanceGrowth, GROWTH_THRESHOLDS } from './growth';
|
||
export { plantSeed, simulateOneTick, tileGrowthStage } from './commands';
|
||
// ^^^^^^^^^ Plan 02-03 EXTENDS commands.ts with harvest + compost; simulateOneTick branches on those kinds.
|
||
```
|
||
|
||
From src/store/index.ts (Plan 02-01) — already exposes:
|
||
```typescript
|
||
fragmentRevealId: string | null;
|
||
setFragmentRevealId(id: string | null);
|
||
harvestedFragmentIds: string[];
|
||
setHarvested(ids: string[]);
|
||
```
|
||
|
||
From src/content/index.ts (Plan 02-02 extension):
|
||
```typescript
|
||
export const fragments: Fragment[]; // eager (legacy)
|
||
export function loadSeasonFragments(seasonId: number): Promise<Fragment[]>; // PIPE-02 lazy
|
||
export const uiStrings: Record<number, UiStrings>;
|
||
export type Fragment = { id: string; season: number; body: string };
|
||
```
|
||
|
||
Fragment ID regex (FragmentSchema): `/^season\d+\.[a-z0-9._-]+$/`. Examples: `season1.soil.first-bloom`, `season1.soil.lura.greeting` (dots and dashes both allowed).
|
||
|
||
Existing src/App.tsx after Plan 02-02 (mount BeginScreen + SeedPicker; this plan adds Journal + FragmentRevealModal + JournalIcon):
|
||
|
||
```typescript
|
||
<div id="app">
|
||
<PhaserGame ref={phaserRef} />
|
||
<BeginScreen />
|
||
<SeedPicker />
|
||
{/* Plan 02-03: <Journal />, <FragmentRevealModal />, <JournalIcon /> */}
|
||
</div>
|
||
```
|
||
|
||
From src/sim/state.ts (Plan 02-01):
|
||
```typescript
|
||
export interface SimState {
|
||
garden: { tiles: unknown[] };
|
||
plants: unknown[];
|
||
harvestedFragmentIds: string[];
|
||
lastTickAt: number;
|
||
unlockedPlantTypes: string[];
|
||
luraBeatProgress: { ... };
|
||
offlineEvents: unknown | null;
|
||
settings: { ...; persistenceToastShown: boolean };
|
||
}
|
||
```
|
||
|
||
Mulberry32 seeded PRNG (RESEARCH line 1013, ~10 LoC pure):
|
||
```typescript
|
||
function mulberry32(a: number): () => number {
|
||
return function() {
|
||
let t = a += 0x6D2B79F5;
|
||
t = Math.imul(t ^ t >>> 15, t | 1);
|
||
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||
}
|
||
}
|
||
```
|
||
</interfaces>
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto">
|
||
<name>Task 1: Author ≥10 Season-1 fragments + sim/memory selector + extend sim/garden/commands with harvest + compost</name>
|
||
<read_first>
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pitfall 8 lines 1102-1108 fragment exhaustion, Pitfall 10 lines 1118-1124 unlock off-by-one, Open Question 1 lines 1225-1229 plant identity)
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group D lines 274-310, Group C lines 226-272 for sim/garden command pattern)
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-03 plant types, D-05 unlocks, D-07 post-harvest beat, D-14 Lura thresholds — gives a sense of how many harvests Phase 2 expects)
|
||
- CLAUDE.md (Tone — bible voice for fragment text)
|
||
- content/README.md (fragment authoring conventions)
|
||
- content/seasons/01-soil/fragments.yaml (Plan 02-02 placeholder — REPLACE with real content)
|
||
- src/sim/garden/commands.ts (Plan 02-02 — extend the simulateOneTick switch)
|
||
- src/sim/garden/commands.test.ts (Plan 02-02 — extend with harvest + compost cases)
|
||
</read_first>
|
||
<files>
|
||
content/seasons/01-soil/fragments.yaml,
|
||
content/seasons/01-soil/fragments/lura-first-letter.md,
|
||
content/seasons/01-soil/fragments/winter-rose-night.md,
|
||
src/sim/memory/selector.ts,
|
||
src/sim/memory/selector.test.ts,
|
||
src/sim/memory/pool.ts,
|
||
src/sim/memory/index.ts,
|
||
src/sim/garden/commands.ts,
|
||
src/sim/garden/commands.test.ts
|
||
</files>
|
||
<action>
|
||
**Step 1 — Author Season-1 fragments.**
|
||
|
||
Replace `content/seasons/01-soil/fragments.yaml` (currently a Plan-02-02 placeholder) with ≥8 short fragments authored in voice. Each fragment:
|
||
- Has stable string ID matching `/^season1\.[a-z0-9._-]+$/`.
|
||
- Belongs to one of the three plant types' tonal registers (warm / contemplative / heavy) via the `tags` field (a Phase-2 extension to FragmentSchema — see Step 2).
|
||
- 2–6 sentences max. Bible voice: warm, specific, intermittent, sometimes funny, sometimes devastating.
|
||
|
||
Author at least 8 fragments in fragments.yaml + 2 long-form Markdown fragments in `content/seasons/01-soil/fragments/*.md`. Total ≥10. The exhaustion fallback fragment (`season1.soil.gardener-knows-this-one-already`) is the 11th and may live in either yaml or md; document its role in a comment.
|
||
|
||
**The fragment file MUST also include a 12th sentinel ID `season1.soil._exhaustion`** as the no-fragment-pool fallback per RESEARCH Pitfall 8.
|
||
|
||
**Step 2 — Extend FragmentSchema with optional `tags` field** for plant-type gating (MEMR-06):
|
||
|
||
Edit `src/content/schemas/fragment.ts`:
|
||
```typescript
|
||
export const FragmentSchema = z.object({
|
||
id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/),
|
||
season: z.number().int().min(0).max(7),
|
||
body: z.string().min(1),
|
||
tags: z.array(z.string().min(1)).optional(), // Phase 2 extension for MEMR-06 gating
|
||
});
|
||
```
|
||
|
||
This is backward-compatible (optional field). Existing tests still pass.
|
||
|
||
**Sample fragments** (executor adapts; all matched to bible voice):
|
||
|
||
```yaml
|
||
# content/seasons/01-soil/fragments.yaml
|
||
fragments:
|
||
# ----- WARM tonal register (rosemary pool) -----
|
||
- id: season1.soil.first-bloom
|
||
season: 1
|
||
tags: [warm]
|
||
body: |
|
||
The first thing that grew was rosemary. The shape of it didn't matter
|
||
so much as the smell — sharp, the kind of green that means the air
|
||
will warm up by afternoon.
|
||
|
||
- id: season1.soil.bread-was-easy
|
||
season: 1
|
||
tags: [warm]
|
||
body: |
|
||
Someone, in the place this came from, was very good at bread. There
|
||
isn't a name attached. There is the shape of an oven door, and a
|
||
towel folded a particular way.
|
||
|
||
- id: season1.soil.the-cat
|
||
season: 1
|
||
tags: [warm]
|
||
body: |
|
||
The cat is missing now too. It used to walk along the wall at dusk.
|
||
It would not come when called. It came anyway, in its own time. Most
|
||
good things were like that.
|
||
|
||
# ----- CONTEMPLATIVE tonal register (yarrow pool) -----
|
||
- id: season1.soil.what-the-wind-was-for
|
||
season: 1
|
||
tags: [contemplative]
|
||
body: |
|
||
The wind used to mean something specific in spring — a person putting
|
||
sheets out to dry, the line across two posts, the way it would crack
|
||
like a small flag. That meaning has gone soft. The wind still blows.
|
||
|
||
- id: season1.soil.the-letter-not-sent
|
||
season: 1
|
||
tags: [contemplative]
|
||
body: |
|
||
There was a letter someone meant to send. The address is gone, the
|
||
ink is gone, the reason is gone. What remains is the silence on the
|
||
other side of it — a room, somewhere, that never received the news.
|
||
|
||
- id: season1.soil.numbers-in-the-margin
|
||
season: 1
|
||
tags: [contemplative]
|
||
body: |
|
||
A book had a number written in the margin: 47. Whose age, whose page,
|
||
whose count of something — gone. The 47 sits very calmly on the
|
||
paper. Numbers are the last to forget. They will outlast all of us.
|
||
|
||
# ----- HEAVY tonal register (winter-rose pool) -----
|
||
- id: season1.soil.the-name-she-used
|
||
season: 1
|
||
tags: [heavy]
|
||
body: |
|
||
She had a name for him that wasn't his name. He had stopped objecting
|
||
to it long before the end. After, the name kept arriving — at the
|
||
door, in the post, in the mouths of people who had heard it once and
|
||
never been corrected. The garden does not say it. The garden only
|
||
grows.
|
||
|
||
- id: season1.soil.what-the-snow-took
|
||
season: 1
|
||
tags: [heavy]
|
||
body: |
|
||
Snow took the orchard one March. The trees were already old. The
|
||
orchard had been someone's grandfather's, then someone's father's,
|
||
then a row of stumps and a few unrooted sticks pretending. Pretending
|
||
is also a kind of remembering, until one day it isn't.
|
||
|
||
# ----- EXHAUSTION FALLBACK (returned when gated pool is empty per Pitfall 8) -----
|
||
- id: season1.soil._exhaustion
|
||
season: 1
|
||
tags: [_meta]
|
||
body: |
|
||
The garden knows this one already. The light comes in the same way it
|
||
came yesterday. There will be a new thing tomorrow. There is also
|
||
this — the steady part, that does not need re-learning.
|
||
```
|
||
|
||
```markdown
|
||
<!-- content/seasons/01-soil/fragments/lura-first-letter.md -->
|
||
---
|
||
id: season1.soil.lura-first-letter
|
||
season: 1
|
||
tags: [warm]
|
||
---
|
||
Lura wrote you a letter once, and never sent it. It was about a recipe — the
|
||
proportions of vinegar to honey, and how long to let the onions sit. Most of
|
||
the letter is the recipe. Two paragraphs at the bottom are about something
|
||
else: a bee in a kitchen window, a song you didn't recognize, the shape your
|
||
hand made on a glass.
|
||
|
||
She left the letter in a drawer, decided it sounded too much. Then there was
|
||
no drawer, and no letter. The recipe is real. You could find it again, if you
|
||
asked.
|
||
```
|
||
|
||
```markdown
|
||
<!-- content/seasons/01-soil/fragments/winter-rose-night.md -->
|
||
---
|
||
id: season1.soil.winter-rose-night
|
||
season: 1
|
||
tags: [heavy]
|
||
---
|
||
Winter-rose blooms at night. This is, technically, slander — the rose blooms
|
||
when it blooms, and the night is when most people are asleep, and so the
|
||
night is when most people fail to see things bloom. But the slander stuck.
|
||
A flower for the people who couldn't sleep.
|
||
|
||
Someone, in this place, used to set a chair by the window in February and
|
||
wait. The wait was the thing. The flower would bloom in its own time. Most
|
||
good things were like that, until they weren't.
|
||
```
|
||
|
||
(W6 fix — bump warm-pool depth so a worst-case all-rosemary playthrough still has fragments left at harvest 8.
|
||
|
||
Total: ≥14 in yaml + ≥2 in md + 1 sentinel = ≥17 fragments. Tags distribute: ≥9 warm, ≥3 contemplative, ≥3 heavy, 1 _meta. The yaml block above shows 3 warm samples; the executor authors ≥6 additional warm-tagged fragments matching the same tonal register before committing. Pool depth must satisfy the worst-case constraint: 8 harvests of rosemary alone must not exhaust the warm pool. The exhaustion sentinel still exists as a defensive fallback (Pitfall 8), but the authored pool should be deep enough that it is never reached during normal Phase-2 play.)
|
||
|
||
**Step 3 — `src/sim/memory/pool.ts`** (PATTERNS Group D filter pattern):
|
||
|
||
```typescript
|
||
import type { Fragment } from '../../content';
|
||
import type { PlantTypeId } from '../garden/types';
|
||
import { PLANT_TYPES } from '../garden/plants';
|
||
|
||
/**
|
||
* Filter the loaded fragments down to the gated, not-yet-harvested pool
|
||
* for a given (season, plantTypeId) at the moment of harvest.
|
||
*
|
||
* Per MEMR-06: respects authored gating (Season + plantType.fragmentTags
|
||
* intersection) and avoids duplicates within a playthrough.
|
||
*
|
||
* Per RESEARCH Pitfall 8: callers MUST handle the case where the returned
|
||
* pool is empty by falling back to the exhaustion sentinel
|
||
* (EXHAUSTION_FALLBACK_ID in selector.ts).
|
||
*
|
||
* Pure. No DOM. No Date.now.
|
||
*/
|
||
export function filterPool(
|
||
allFragments: readonly Fragment[],
|
||
season: number,
|
||
plantTypeId: PlantTypeId,
|
||
alreadyHarvestedIds: readonly string[],
|
||
): Fragment[] {
|
||
const type = PLANT_TYPES[plantTypeId];
|
||
if (!type) return [];
|
||
const tagSet = new Set(type.fragmentTags);
|
||
const harvestedSet = new Set(alreadyHarvestedIds);
|
||
return allFragments.filter((f) => {
|
||
if (f.season !== season) return false;
|
||
if (harvestedSet.has(f.id)) return false;
|
||
// MEMR-06 plant-type gating: fragment must share at least one tag with the plant type's tonal register
|
||
if (!f.tags || !f.tags.some((t) => tagSet.has(t))) return false;
|
||
// Exclude the exhaustion sentinel from the pool — it's reserved for the fallback
|
||
if (f.tags.includes('_meta')) return false;
|
||
return true;
|
||
});
|
||
}
|
||
```
|
||
|
||
**Step 4 — `src/sim/memory/selector.ts`** (RESEARCH Don't Hand-Roll line 1013 + PATTERNS Group D):
|
||
|
||
```typescript
|
||
import type { Fragment } from '../../content';
|
||
import type { PlantTypeId } from '../garden/types';
|
||
import { filterPool } from './pool';
|
||
|
||
/**
|
||
* MEMR-06 deterministic fragment selector.
|
||
*
|
||
* Inputs are pure: (allFragments, currentSeason, plantTypeId, alreadyHarvestedIds, seedHash).
|
||
* Same inputs → same output. No Date.now, no Math.random — the seed is
|
||
* derived from `(harvestedFragmentIds.length, plantedAtTick)` in the
|
||
* caller (sim/garden/commands.ts) so the player's actions advance the
|
||
* stream without leaking wall-clock state into sim modules.
|
||
*
|
||
* Per RESEARCH Pitfall 8 (exhaustion):
|
||
* - If the gated pool is non-empty: return the seeded selection.
|
||
* - If the gated pool is empty: return the EXHAUSTION_FALLBACK_ID sentinel
|
||
* fragment (authored at content/seasons/01-soil/fragments.yaml as
|
||
* `season1.soil._exhaustion`).
|
||
* - If even the sentinel is missing (degenerate test fixture):
|
||
* return null and let the caller treat it as a no-op harvest.
|
||
*/
|
||
export const EXHAUSTION_FALLBACK_ID = 'season1.soil._exhaustion';
|
||
|
||
function mulberry32(a: number): () => number {
|
||
return function() {
|
||
let t = (a += 0x6D2B79F5);
|
||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
export function selectFragment(
|
||
allFragments: readonly Fragment[],
|
||
currentSeason: number,
|
||
plantTypeId: PlantTypeId,
|
||
alreadyHarvestedIds: readonly string[],
|
||
seedHash: number,
|
||
): Fragment | null {
|
||
const pool = filterPool(allFragments, currentSeason, plantTypeId, alreadyHarvestedIds);
|
||
if (pool.length === 0) {
|
||
return allFragments.find((f) => f.id === EXHAUSTION_FALLBACK_ID) ?? null;
|
||
}
|
||
const rng = mulberry32(seedHash);
|
||
const idx = Math.floor(rng() * pool.length);
|
||
return pool[idx] ?? null;
|
||
}
|
||
```
|
||
|
||
**Step 5 — `src/sim/memory/selector.test.ts`** — exhaustive Vitest:
|
||
|
||
- Empty pool + sentinel present → returns sentinel.
|
||
- Empty pool + no sentinel → returns null.
|
||
- Pool with one fragment → always returns that fragment regardless of seed.
|
||
- Pool with three fragments — same `seedHash` returns same fragment; different `seedHash` may return different.
|
||
- Pool gating: `selectFragment([{id, season=1, tags:['warm']}, {id, season=1, tags:['heavy']}], 1, 'rosemary', [], 0)` returns only the warm-tagged one (rosemary tonal register).
|
||
- No-dup: passing a fragment's id in `alreadyHarvestedIds` excludes it from the pool.
|
||
- Season gating: fragment with `season=2` is never selected when `currentSeason=1`.
|
||
- Sentinel exclusion: a fragment tagged `['_meta']` is NEVER returned via the normal-pool branch (only via the exhaustion fallback).
|
||
|
||
**Step 6 — `src/sim/memory/index.ts`:**
|
||
```typescript
|
||
export { selectFragment, EXHAUSTION_FALLBACK_ID } from './selector';
|
||
export { filterPool } from './pool';
|
||
```
|
||
|
||
Also add `export * from './memory'` to `src/sim/index.ts`.
|
||
|
||
**Step 7 — Extend `src/sim/garden/commands.ts`** with `harvest` and `compost`. Add a `MemoryRegistry` injection point so the sim stays decoupled from `import.meta.glob` Vite magic:
|
||
|
||
```typescript
|
||
// add at top of commands.ts
|
||
import { selectFragment, EXHAUSTION_FALLBACK_ID } from '../memory/selector';
|
||
import type { Fragment } from '../../content';
|
||
|
||
/**
|
||
* The fragment pool injected into simulateOneTick. The application
|
||
* layer (Phaser scene) loads fragments via loadSeasonFragments(1) and
|
||
* passes the array in. Sim modules stay decoupled from import.meta.glob.
|
||
*/
|
||
export interface SimContext {
|
||
fragments: readonly Fragment[];
|
||
currentSeason: number;
|
||
}
|
||
|
||
/**
|
||
* harvest(state, tileIdx, currentTick, ctx) → state'
|
||
*
|
||
* Pure. Picks exactly ONE fragment via the deterministic selector,
|
||
* empties the tile, and appends to harvestedFragmentIds. The seed
|
||
* derives from (harvestCount + plantedAtTick) — pure of all wall-clock.
|
||
*
|
||
* Per GARD-03 + MEMR-01 + MEMR-06.
|
||
*
|
||
* Returns the original state unchanged if the tile is empty or not ready.
|
||
*/
|
||
export function harvest(state: SimState, tileIdx: number, currentTick: number, ctx: SimContext): SimState {
|
||
if (tileIdx < 0 || tileIdx >= GRID_SIZE) return state;
|
||
const tiles = state.garden.tiles as Tile[];
|
||
const tile = tiles[tileIdx];
|
||
if (!tile?.plant) return state;
|
||
const type = PLANT_TYPES[tile.plant.plantTypeId];
|
||
if (!type) return state;
|
||
const stage = advanceGrowth(tile.plant, type, currentTick);
|
||
if (stage !== 'ready') return state; // refuse to harvest immature plants
|
||
|
||
const seedHash = state.harvestedFragmentIds.length * 2654435761 + tile.plant.plantedAtTick;
|
||
const fragment = selectFragment(
|
||
ctx.fragments,
|
||
ctx.currentSeason,
|
||
tile.plant.plantTypeId,
|
||
state.harvestedFragmentIds,
|
||
seedHash,
|
||
);
|
||
if (!fragment) return state; // degenerate: no fragment AND no sentinel — refuse to harvest
|
||
|
||
const nextTiles: Tile[] = tiles.map((t, i) => i === tileIdx ? { idx: i, plant: null } : t);
|
||
const harvestedIds = [...state.harvestedFragmentIds, fragment.id];
|
||
|
||
// D-05 plant-type unlock thresholds (Claude's discretion within reason):
|
||
// yarrow unlocks at 3 harvests
|
||
// winter-rose unlocks at 6 harvests
|
||
// Defended in selector.test.ts boundary cases. Document final values in SUMMARY.md.
|
||
const unlockedPlantTypes = computePlantUnlocks(harvestedIds.length);
|
||
|
||
return {
|
||
...state,
|
||
garden: { tiles: nextTiles },
|
||
harvestedFragmentIds: harvestedIds,
|
||
unlockedPlantTypes,
|
||
};
|
||
}
|
||
|
||
const PLANT_UNLOCK_THRESHOLDS: Array<{ count: number; plantTypeId: PlantTypeId }> = [
|
||
{ count: 0, plantTypeId: 'rosemary' }, // available from start
|
||
{ count: 3, plantTypeId: 'yarrow' }, // unlocks at 3rd harvest (Pitfall 10: check AFTER harvest commit)
|
||
{ count: 6, plantTypeId: 'winter-rose' }, // unlocks at 6th harvest
|
||
];
|
||
|
||
function computePlantUnlocks(harvestCount: number): string[] {
|
||
return PLANT_UNLOCK_THRESHOLDS
|
||
.filter((t) => harvestCount >= t.count)
|
||
.map((t) => t.plantTypeId);
|
||
}
|
||
|
||
/**
|
||
* compost(state, tileIdx, currentTick) → state'
|
||
*
|
||
* Pure. Empties the tile regardless of growth stage. No fragment yield.
|
||
* No resource refund (D-04 = infinite seeds).
|
||
*
|
||
* The "tonal beat" (D-07 + GARD-04) is a UI concern — Plan 02-04's Ink
|
||
* runtime renders compost-acknowledgements.ink lines via the dialogue
|
||
* overlay. Phase 2 Plan 02-03 ships the AUTHORED CONTENT; the React
|
||
* surface fires the beat by setting a flag; Plan 02-04 wires the Ink
|
||
* playback (placeholder DOM text in this plan, swap to ink later).
|
||
*/
|
||
export function compost(state: SimState, tileIdx: number, _currentTick: number): SimState {
|
||
if (tileIdx < 0 || tileIdx >= GRID_SIZE) return state;
|
||
const tiles = state.garden.tiles as Tile[];
|
||
const tile = tiles[tileIdx];
|
||
if (!tile?.plant) return state;
|
||
const nextTiles: Tile[] = tiles.map((t, i) => i === tileIdx ? { idx: i, plant: null } : t);
|
||
return { ...state, garden: { tiles: nextTiles } };
|
||
}
|
||
```
|
||
|
||
**Update `simulateOneTick`** to dispatch on `harvest` and `compost`:
|
||
|
||
```typescript
|
||
export function simulateOneTick(
|
||
state: SimState,
|
||
currentTick: number,
|
||
commands: GardenCommand[],
|
||
ctx: SimContext,
|
||
): SimState {
|
||
let next = state;
|
||
for (const cmd of commands) {
|
||
if (cmd.kind === 'plantSeed' && cmd.plantTypeId) {
|
||
next = plantSeed(next, cmd.tileIdx, cmd.plantTypeId as PlantTypeId, currentTick);
|
||
} else if (cmd.kind === 'harvest') {
|
||
next = harvest(next, cmd.tileIdx, currentTick, ctx);
|
||
} else if (cmd.kind === 'compost') {
|
||
next = compost(next, cmd.tileIdx, currentTick);
|
||
}
|
||
}
|
||
return { ...next, lastTickAt: currentTick };
|
||
}
|
||
```
|
||
|
||
**Note:** simulateOneTick now takes a `ctx: SimContext` 4th argument. Update Plan 02-02's Garden scene to pass `{fragments: <loaded>, currentSeason: 1}` — the executor edits `src/game/scenes/Garden.ts` to load fragments and pass through. The Garden scene's `update()` becomes:
|
||
|
||
```typescript
|
||
const result = drainTicks(simStateNow, this.accumulatorMs, (s, _dtMs, _silent) => {
|
||
const next = simulateOneTick(s, this.currentTick + 1, commands, this.simContext);
|
||
this.currentTick++;
|
||
return next;
|
||
});
|
||
```
|
||
|
||
with `this.simContext` initialized in `create()` via `await loadSeasonFragments(1)`. Use `this.events.once('create')` or chain via `.then` since `create()` is sync but we need fragments early — practical approach: call `loadSeasonFragments(1)` in `init()` then `this.simContext = { fragments: [], currentSeason: 1 }` until resolved, then assign. (Or load eagerly via the existing `fragments` export from Plan 01-04 — for Phase 2 this is simpler and Plan 02-04+ can swap to lazy when content grows.)
|
||
|
||
**Simpler approach (executor's preference allowed):** import the eager `fragments` export and filter for `season === 1` in the Garden scene's `create()`:
|
||
```typescript
|
||
import { fragments as allFragments } from '../../content';
|
||
this.simContext = { fragments: allFragments, currentSeason: 1 };
|
||
```
|
||
PIPE-02's lazy split is structurally verified by `scripts/check-bundle-split.mjs` (Task 3 of this plan); the runtime can use the eager pool until Phase 4 grows beyond Season 1. **Document this trade-off in SUMMARY.md.**
|
||
|
||
**Step 8 — Extend `src/sim/garden/commands.test.ts`** with harvest + compost cases:
|
||
|
||
- Harvest a ready plant → returns state with tile cleared and exactly ONE new entry in harvestedFragmentIds.
|
||
- Harvest the same tile after harvesting → returns state unchanged (tile is empty).
|
||
- Harvest an immature plant → returns state unchanged.
|
||
- Harvest with empty fragment context → returns state unchanged (no fragment selected).
|
||
- Determinism: two calls to `harvest` on identical state produce identical results.
|
||
- Plant-type unlocks: plant 3 rosemary, harvest each → after 3rd harvest, `unlockedPlantTypes` includes 'yarrow'.
|
||
- Plant-type unlocks Pitfall 10 (off-by-one): after 2 harvests, `unlockedPlantTypes` does NOT include 'yarrow'; after 3, it does.
|
||
- Compost a sprout → tile clears.
|
||
- Compost an empty tile → state unchanged.
|
||
- Compost does not change harvestedFragmentIds.
|
||
- Compost does not change unlockedPlantTypes (no-fragment path).
|
||
|
||
**Commit:** `feat(02-03): Season-1 fragments + sim/memory selector + harvest/compost commands`. Run `npm run lint && npx vitest run src/sim/ src/content/ && npm run build` before committing (npm run build proves the new fragments parse).
|
||
</action>
|
||
<acceptance_criteria>
|
||
- `grep -c "^ - id: season1\\." content/seasons/01-soil/fragments.yaml` returns ≥14
|
||
- `[ "$(grep -c "tags: \\[warm\\]" content/seasons/01-soil/fragments.yaml)" -ge 9 ]` (W6: warm pool ≥ 8th-harvest depth + 1 buffer)
|
||
- `ls content/seasons/01-soil/fragments/*.md | wc -l` returns ≥2
|
||
- `grep -q "season1.soil._exhaustion" content/seasons/01-soil/fragments.yaml`
|
||
- `grep -q "tags: \\[warm\\]\\|tags: \\[contemplative\\]\\|tags: \\[heavy\\]" content/seasons/01-soil/fragments.yaml` (multiple)
|
||
- `grep -q "tags: z.array(z.string()" src/content/schemas/fragment.ts` (schema extended)
|
||
- `grep -q "EXHAUSTION_FALLBACK_ID = 'season1.soil._exhaustion'" src/sim/memory/selector.ts`
|
||
- `grep -q "function mulberry32" src/sim/memory/selector.ts`
|
||
- `grep -q "export function harvest" src/sim/garden/commands.ts`
|
||
- `grep -q "export function compost" src/sim/garden/commands.ts`
|
||
- `grep -q "PLANT_UNLOCK_THRESHOLDS" src/sim/garden/commands.ts`
|
||
- `grep -L "Date.now" src/sim/memory/selector.ts src/sim/memory/pool.ts` (sim purity)
|
||
- `npx vitest run src/sim/memory/ src/sim/garden/ src/content/` exits 0 with all tests green; harvest/compost coverage ≥6 new cases
|
||
- `npm run build` succeeds — Vite parses all new fragments without schema violation
|
||
- `npm run lint` exits 0
|
||
</acceptance_criteria>
|
||
<verify>
|
||
<automated>npm run lint && npx vitest run src/sim/memory/ src/sim/garden/ src/content/ && npm run build</automated>
|
||
</verify>
|
||
<done>
|
||
≥10 Season-1 fragments authored under /content/seasons/01-soil/ (≥8 yaml + ≥2 md + 1 sentinel). Bible voice maintained. FragmentSchema extended with optional tags. Deterministic selector with gating + no-dup + exhaustion fallback ships under sim/memory/. harvest + compost commands extend sim/garden/commands.ts; simulateOneTick takes a SimContext. Garden scene wired to pass real fragment context. ≥6 new Vitest cases green.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>Task 2: Memory Journal UI (Journal modal + FragmentRevealModal + JournalIcon) + App.tsx wiring + harvest event flow</name>
|
||
<read_first>
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Memory Journal section + Architectural Responsibility Map row "Memory Journal")
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I lines 471-518)
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-23 reveal-after-first-harvest, D-24 full-screen modal, D-25 immediate-reveal-modal)
|
||
- src/store/memory-slice.ts (Plan 02-01 — fragmentRevealId state slot)
|
||
- src/store/garden-slice.ts (Plan 02-01 — enqueueCommand)
|
||
- src/ui/begin/BeginScreen.tsx (Plan 02-02 — pattern for full-screen DOM overlay)
|
||
- src/App.tsx (Plan 02-02 — extend mount list)
|
||
- src/game/event-bus.ts (Plan 02-01 — fragment-revealed event)
|
||
- src/game/scenes/Garden.ts (Plan 02-02 — wire harvest pointerdown + emit fragment-revealed)
|
||
</read_first>
|
||
<files>
|
||
src/ui/journal/Journal.tsx,
|
||
src/ui/journal/Journal.test.tsx,
|
||
src/ui/journal/FragmentRevealModal.tsx,
|
||
src/ui/journal/FragmentRevealModal.test.tsx,
|
||
src/ui/journal/journal-icon.tsx,
|
||
src/ui/journal/index.ts,
|
||
src/ui/index.ts,
|
||
src/App.tsx,
|
||
src/game/scenes/Garden.ts
|
||
</files>
|
||
<action>
|
||
**Step 1 — `src/ui/journal/Journal.tsx`** — full-screen modal (D-24):
|
||
|
||
```typescript
|
||
import { useState } from 'react';
|
||
import { useAppStore } from '../../store';
|
||
import { fragments as allFragments, uiStrings } from '../../content';
|
||
|
||
/**
|
||
* D-24 — full-screen Memory Journal modal. DOM-rendered text per MEMR-05
|
||
* (selectable, copy-pasteable). Fragments grouped by Season; each fragment
|
||
* shown in full body text.
|
||
*
|
||
* Visibility is local state, opened by JournalIcon onClick. Phase 2 has
|
||
* only Season 1 — Phase 4+ Journal will need pagination / collapse.
|
||
*/
|
||
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 the order the player harvested them
|
||
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
|
||
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"
|
||
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 }}>{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>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Step 2 — `src/ui/journal/FragmentRevealModal.tsx`** (D-25):
|
||
|
||
```typescript
|
||
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; dismissing files it into the Journal.
|
||
*
|
||
* Triggered by sim/garden/commands.ts harvest setting fragmentRevealId
|
||
* via the application layer (Garden scene's update loop on fragment-
|
||
* revealed event). Dismiss clears fragmentRevealId.
|
||
*/
|
||
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: if the id doesn't resolve (degenerate), dismiss silently
|
||
setFragmentRevealId(null);
|
||
return null;
|
||
}
|
||
|
||
const onDismiss = () => setFragmentRevealId(null);
|
||
|
||
return (
|
||
<div
|
||
role="dialog"
|
||
aria-label="A new memory"
|
||
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}
|
||
style={{
|
||
marginTop: '2rem', padding: '0.5rem 1.4rem',
|
||
background: 'transparent', color: '#e8e0d0',
|
||
border: '1px solid #e8e0d0', cursor: 'pointer',
|
||
fontFamily: 'serif',
|
||
}}
|
||
>
|
||
Close
|
||
</button>
|
||
</article>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Step 3 — `src/ui/journal/journal-icon.tsx`** (D-23 + D-29):
|
||
|
||
```typescript
|
||
import { useEffect, useState } from 'react';
|
||
import { useAppStore, selectJournalRevealed } from '../../store';
|
||
import { Journal } from './Journal';
|
||
|
||
/**
|
||
* D-23 — journal affordance reveals after first harvest, then is persistent.
|
||
* D-29 — corner icon access pattern.
|
||
*
|
||
* Pre-first-harvest, returns null. Post-first-harvest, renders a small
|
||
* fixed-position icon button that opens the Journal modal.
|
||
*/
|
||
export function JournalIcon(): JSX.Element | null {
|
||
const revealed = useAppStore(selectJournalRevealed);
|
||
const [open, setOpen] = useState(false);
|
||
|
||
// W2 — D-29 'j' hotkey toggles the journal. App.tsx dispatches a window
|
||
// CustomEvent so the JournalIcon owns its open/close state without lifting
|
||
// it into the store. The listener is keyed off the same revealed gate as
|
||
// the icon itself — pre-first-harvest the hotkey is a no-op (matches the
|
||
// anti-FOMO doctrine: nothing exists for the player to "discover" early).
|
||
useEffect(() => {
|
||
if (!revealed) return;
|
||
const onToggle = () => setOpen((o) => !o);
|
||
window.addEventListener('tlg:toggle-journal', onToggle);
|
||
return () => window.removeEventListener('tlg:toggle-journal', onToggle);
|
||
}, [revealed]);
|
||
|
||
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)} />
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
(The `✎` glyph is allowed — it's a typographic affordance, not localized copy. If the user prefers a SVG icon, swap; surfacing in SUMMARY.md.)
|
||
|
||
**Step 4 — `src/ui/journal/Journal.test.tsx`** — Vitest + @testing-library/react:
|
||
|
||
- Initial render with `harvestedFragmentIds: []` shows the empty-state copy from `uiStrings[1].journal.empty_state`.
|
||
- With `harvestedFragmentIds: ['season1.soil.first-bloom']`, the Journal renders the full body of that fragment.
|
||
- The fragment body is inside an element with `userSelect: 'text'` (selectable per MEMR-05) — assert via computed style on a found element.
|
||
- The body text includes the actual sentence "The first thing that grew was rosemary" (selectable text content, not innerHTML — confirms DOM rendering, not canvas).
|
||
- Fragments grouped by Season — `<h2>Season 1</h2>` is rendered.
|
||
- Close button click invokes `onClose` callback once.
|
||
|
||
**Step 5 — `src/ui/journal/FragmentRevealModal.test.tsx`** — Vitest:
|
||
|
||
- With `fragmentRevealId: null`, returns null (not visible).
|
||
- With `fragmentRevealId: 'season1.soil.first-bloom'`, the fragment body renders.
|
||
- Click on the modal background dismisses (sets fragmentRevealId=null in the store).
|
||
- Click on the article body does NOT dismiss (event.stopPropagation works).
|
||
- Click on the inner Close button dismisses.
|
||
|
||
**Step 6 — `src/ui/journal/index.ts`:**
|
||
```typescript
|
||
export { Journal } from './Journal';
|
||
export { FragmentRevealModal } from './FragmentRevealModal';
|
||
export { JournalIcon } from './journal-icon';
|
||
```
|
||
|
||
Update `src/ui/index.ts`:
|
||
```typescript
|
||
export * from './begin';
|
||
export * from './garden';
|
||
export * from './journal';
|
||
```
|
||
|
||
**Step 7 — Update `src/App.tsx`** to mount the new overlays:
|
||
|
||
```typescript
|
||
import { useRef } from 'react';
|
||
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
|
||
import { BeginScreen } from './ui/begin';
|
||
import { SeedPicker } from './ui/garden';
|
||
import { JournalIcon, FragmentRevealModal } from './ui/journal';
|
||
|
||
function App() {
|
||
const phaserRef = useRef<IRefPhaserGame | null>(null);
|
||
return (
|
||
<div id="app">
|
||
<PhaserGame ref={phaserRef} />
|
||
<BeginScreen />
|
||
<SeedPicker />
|
||
<FragmentRevealModal />
|
||
<JournalIcon />
|
||
{/* Plan 02-04: <LuraDialogue /> */}
|
||
{/* Plan 02-05: <Letter />, <Settings />, <PersistenceToast /> */}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|
||
```
|
||
|
||
**Step 8 — Update `src/game/scenes/Garden.ts`** to:
|
||
|
||
(a) Wire pointerdown on a ready-plant tile to enqueue a `harvest` command.
|
||
(b) Detect when a new fragment was harvested in a sim tick (new id appended to `harvestedFragmentIds`) and set `fragmentRevealId` via `simAdapter` (extend simAdapter with `applyHarvestedFragmentsAndReveal` if needed; or do it inline by reading the previous count vs new count).
|
||
|
||
In `Garden.ts`'s `update()` method, after the scheduler call, compare prev vs next `harvestedFragmentIds.length`:
|
||
|
||
```typescript
|
||
const prevCount = appStore.getState().harvestedFragmentIds.length;
|
||
// ... drainTicks ...
|
||
if (result.ticksApplied > 0) {
|
||
// Apply garden + memory state
|
||
simAdapter.applyTilesAndUnlocks(result.state.garden.tiles, result.state.unlockedPlantTypes);
|
||
if (result.state.harvestedFragmentIds.length > prevCount) {
|
||
// A new fragment was harvested in this tick — reveal it (D-25)
|
||
const newId = result.state.harvestedFragmentIds[result.state.harvestedFragmentIds.length - 1];
|
||
simAdapter.applyHarvestedFragments(result.state.harvestedFragmentIds);
|
||
appStore.getState().setFragmentRevealId(newId);
|
||
}
|
||
}
|
||
```
|
||
|
||
In the pointerdown handler:
|
||
|
||
```typescript
|
||
private handleTilePointerDown(idx: number): void {
|
||
const tiles = appStore.getState().tiles as Tile[];
|
||
const tile = tiles[idx];
|
||
if (!tile?.plant) {
|
||
// Empty tile — emit event for the React seed picker.
|
||
const dom = tileCenterToDom(this, idx);
|
||
eventBus.emit('tile-clicked-coords', { tileIdx: idx, screenX: dom.x, screenY: dom.y });
|
||
return;
|
||
}
|
||
// Has plant — check growth stage.
|
||
const stage = tileGrowthStage(tile, this.currentTick);
|
||
if (stage === 'ready') {
|
||
appStore.getState().enqueueCommand({ kind: 'harvest', tileIdx: idx });
|
||
} else {
|
||
// Immature — compost (Plan 02-04 may add a confirmation prompt; Phase 2 ships immediate compost)
|
||
appStore.getState().enqueueCommand({ kind: 'compost', tileIdx: idx });
|
||
}
|
||
}
|
||
```
|
||
|
||
**Note on compost beat:** The tonal acknowledgement (D-07 + GARD-04) should fire after compost. Plan 02-04 wires the Ink playback for the line. Plan 02-03 ships a TODO comment in Garden.ts (or a tiny placeholder DOM toast) so the affordance is visible:
|
||
|
||
```typescript
|
||
// TODO Plan 02-04: replace this placeholder with the Ink-authored compost beat
|
||
// rendered through the dialogue overlay (compost-acknowledgements.ink).
|
||
```
|
||
|
||
Plan 02-04's authored content will land the actual lines.
|
||
|
||
**Commit:** `feat(02-03): journal + reveal modal + harvest pointer wiring`. Run `npm run ci` before committing. Manual smoke test: harvest a ready plant in dev → reveal modal pops → close → journal icon appears in corner → click → modal lists fragment.
|
||
</action>
|
||
<acceptance_criteria>
|
||
- `grep -q "Memory Journal" src/ui/journal/Journal.tsx` (aria-label)
|
||
- `grep -q "userSelect: 'text'" src/ui/journal/Journal.tsx` (MEMR-05 selectable)
|
||
- `grep -q "userSelect: 'text'" src/ui/journal/FragmentRevealModal.tsx`
|
||
- `grep -q "selectJournalRevealed" src/ui/journal/journal-icon.tsx` (D-23 first-harvest reveal gate)
|
||
- `grep -q "<JournalIcon />" src/App.tsx`
|
||
- `grep -q "<FragmentRevealModal />" src/App.tsx`
|
||
- `grep -q "kind: 'harvest'" src/game/scenes/Garden.ts`
|
||
- `grep -q "kind: 'compost'" src/game/scenes/Garden.ts`
|
||
- `grep -q "setFragmentRevealId" src/game/scenes/Garden.ts` (reveal flow wired)
|
||
- `npx vitest run src/ui/journal/` exits 0 with all tests green (≥10 cases across 2 files)
|
||
- `npm run ci` exits 0
|
||
</acceptance_criteria>
|
||
<verify>
|
||
<automated>npm run lint && npx vitest run src/ui/journal/ && npm run ci</automated>
|
||
</verify>
|
||
<done>
|
||
Journal + FragmentRevealModal + JournalIcon land. App.tsx mounts them. Garden.ts wires harvest/compost pointer events + reveal flow. Manual smoke test confirms: harvest ready plant → reveal pops → close → journal icon appears → opens journal modal listing fragment. Selectable text confirmed via Vitest.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>Task 3: PIPE-02 structural verification — scripts/check-bundle-split.mjs and CI integration</name>
|
||
<read_first>
|
||
- .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 8 lines 906-940 PIPE-02 lazy loading, Open Question 4 lines 1240-1244)
|
||
- scripts/validate-assets.mjs (Phase 1 — analog for Node ESM build script)
|
||
- package.json scripts (current `ci` chain)
|
||
- src/content/loader.ts (Plan 02-02 — loadSeasonFragments lazy glob already wired)
|
||
</read_first>
|
||
<files>
|
||
scripts/check-bundle-split.mjs,
|
||
scripts/check-bundle-split.test.mjs,
|
||
package.json
|
||
</files>
|
||
<action>
|
||
**Step 1 — `scripts/check-bundle-split.mjs`** — structural assertion that Vite emits a separate chunk for Season-1 fragments after `npm run build`:
|
||
|
||
```javascript
|
||
#!/usr/bin/env node
|
||
// Phase 2 Plan 02-03 — PIPE-02 structural verification.
|
||
//
|
||
// After `npm run build`, Vite splits each lazy `import.meta.glob` target
|
||
// into its own chunk. Phase 2 has only Season 1; the wiring is structural
|
||
// so Phase 4 (Season 2) inherits without rework.
|
||
//
|
||
// This script asserts that `dist/assets/` contains at least one chunk
|
||
// whose name reflects the lazy-imported Season-1 fragment paths
|
||
// (Vite's default chunk name uses the module path slug; for
|
||
// `/content/seasons/01-soil/fragments.yaml` the chunk name typically
|
||
// includes `fragments` and may include `01-soil`).
|
||
//
|
||
// If the assertion is too tight, the script prints the chunk listing
|
||
// for the dev to inspect and exits non-zero with guidance.
|
||
|
||
import { readdirSync, existsSync, readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
|
||
const distAssets = resolve(process.cwd(), 'dist/assets');
|
||
const distIndex = resolve(process.cwd(), 'dist/index.html');
|
||
|
||
if (!existsSync(distAssets)) {
|
||
console.error('[check-bundle-split] dist/assets/ not found — run `npm run build` first');
|
||
process.exit(2);
|
||
}
|
||
|
||
const files = readdirSync(distAssets);
|
||
|
||
// PIPE-02 looks for at least ONE chunk that references Season-1 fragment paths.
|
||
// Vite hashes filenames; the source path is preserved as a comment in the
|
||
// generated JS, but Vite typically also includes path slugs in chunk names
|
||
// for dynamically-imported resources.
|
||
//
|
||
// We check three places:
|
||
// 1. Any .js file in dist/assets/ whose NAME contains 'fragments' or 'season1' or '01-soil'.
|
||
// 2. Any .js file whose CONTENTS reference '/content/seasons/01-soil/' (raw `?raw` imports
|
||
// may inline the fragment YAML into a chunk).
|
||
// 3. A non-empty fragments.yaml inlined as a string literal in some chunk.
|
||
|
||
const chunkNameMatch = files.some((f) =>
|
||
f.endsWith('.js') && (f.includes('fragments') || f.includes('season1') || f.includes('01-soil'))
|
||
);
|
||
|
||
let chunkContentMatch = false;
|
||
for (const f of files) {
|
||
if (!f.endsWith('.js')) continue;
|
||
const contents = readFileSync(resolve(distAssets, f), 'utf8');
|
||
if (contents.includes('/content/seasons/01-soil/') || contents.includes('season1.soil.first-bloom')) {
|
||
chunkContentMatch = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (chunkNameMatch || chunkContentMatch) {
|
||
console.log('[check-bundle-split] PIPE-02 OK — Season-1 content reachable via build output');
|
||
console.log(` chunkNameMatch=${chunkNameMatch}, chunkContentMatch=${chunkContentMatch}`);
|
||
console.log(` files: ${files.filter((f) => f.endsWith('.js')).join(', ')}`);
|
||
process.exit(0);
|
||
}
|
||
|
||
console.error('[check-bundle-split] FAIL — no chunk references /content/seasons/01-soil/');
|
||
console.error(` dist/assets contained: ${files.join(', ')}`);
|
||
console.error(' Expected: a chunk filename or content containing "fragments" / "season1" / "01-soil"');
|
||
console.error(' See RESEARCH.md Pattern 8 (Per-Season Lazy Loading) for context.');
|
||
process.exit(1);
|
||
```
|
||
|
||
**Step 2 — `scripts/check-bundle-split.test.mjs`** — Vitest unit test that exercises the script in two synthetic-fixture modes:
|
||
|
||
Actually, since this script reads from disk after a real `npm run build`, the most pragmatic test is to:
|
||
- Verify the script exists, has shebang, and is syntactically valid Node ESM.
|
||
- Provide a Vitest test that mocks `dist/assets/` via a temp directory (use `node:fs/promises` and `mkdtemp`) and runs the script's main logic against the mock.
|
||
|
||
For Phase 2 we ship a SIMPLER test: assert the script's existence and that it runs against the real `dist/` (which the CI's `npm run build` step will have populated).
|
||
|
||
```javascript
|
||
// scripts/check-bundle-split.test.mjs — vitest config includes scripts/**/*.test.mjs
|
||
import { describe, it, expect } from 'vitest';
|
||
import { existsSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
|
||
describe('scripts/check-bundle-split.mjs', () => {
|
||
it('exists and is non-empty', () => {
|
||
const path = resolve(process.cwd(), 'scripts/check-bundle-split.mjs');
|
||
expect(existsSync(path)).toBe(true);
|
||
});
|
||
|
||
// The actual structural assertion fires during `npm run ci` after `npm run build`
|
||
// populates dist/. Running it standalone in Vitest would either skip (no dist/)
|
||
// or duplicate the CI assertion. The script is exit-code-asserted via the ci chain.
|
||
it('is syntactically valid Node ESM (parses without error)', async () => {
|
||
// Smoke: importing it should not throw at parse time
|
||
await expect(import(resolve(process.cwd(), 'scripts/check-bundle-split.mjs'))).resolves.toBeTruthy();
|
||
});
|
||
});
|
||
```
|
||
|
||
**Note:** The script has a `process.exit()` at the top level — importing it in Vitest will terminate the test process. To avoid that, wrap the script body in a `runCheck()` function exported via ESM AND only call it when `import.meta.url === \`file://${process.argv[1]}\`` (CLI mode). Refactor the script accordingly:
|
||
|
||
```javascript
|
||
#!/usr/bin/env node
|
||
import { readdirSync, existsSync, readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
|
||
export function runCheck() {
|
||
// ... all the body logic above ...
|
||
// Return { ok: boolean, message: string } instead of calling process.exit
|
||
}
|
||
|
||
// CLI invocation
|
||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||
const result = runCheck();
|
||
console.log(result.message);
|
||
process.exit(result.ok ? 0 : 1);
|
||
}
|
||
```
|
||
|
||
The Vitest test imports `runCheck` and asserts the structure (skipping the actual filesystem check if `dist/` is absent at test time).
|
||
|
||
**Step 3 — Update `package.json`:**
|
||
|
||
Add to scripts:
|
||
```json
|
||
"check:bundle-split": "node scripts/check-bundle-split.mjs",
|
||
"ci": "npm run lint && npm run test && npm run validate:assets && npm run build && npm run check:bundle-split"
|
||
```
|
||
|
||
This places `check:bundle-split` AFTER `build` in the CI chain so dist/ is populated before the assertion runs.
|
||
|
||
**Step 4 — Verify the script works on a fresh build:**
|
||
|
||
Run from repo root:
|
||
```
|
||
rm -rf dist
|
||
npm run build
|
||
node scripts/check-bundle-split.mjs
|
||
```
|
||
Expect exit code 0 with the success message. If it fails, inspect dist/assets/ output and adjust the matching heuristic in `runCheck()`.
|
||
|
||
**Defended option:** If the heuristic is fragile (e.g., Vite renames chunks differently in production builds), document in SUMMARY.md and consider adding `vite.config.ts` `build.rollupOptions.output.manualChunks` to force a `season1` chunk name. Don't auto-add this configuration; surface as Plan 02-05 follow-up.
|
||
|
||
**Commit:** `chore(02-03): scripts/check-bundle-split.mjs (PIPE-02 structural verification)`. Run `npm run ci` before committing.
|
||
</action>
|
||
<acceptance_criteria>
|
||
- `test -f scripts/check-bundle-split.mjs`
|
||
- `grep -q "runCheck" scripts/check-bundle-split.mjs` (refactored to allow Vitest import)
|
||
- `grep -q "check:bundle-split" package.json`
|
||
- `grep -q "npm run check:bundle-split" package.json` (in scripts.ci)
|
||
- Running `node scripts/check-bundle-split.mjs` after `npm run build` exits 0 with success message
|
||
- `npx vitest run scripts/check-bundle-split.test.mjs` exits 0
|
||
- `npm run ci` exits 0 end-to-end
|
||
</acceptance_criteria>
|
||
<verify>
|
||
<automated>npm run lint && npm run build && node scripts/check-bundle-split.mjs && npx vitest run scripts/check-bundle-split.test.mjs && npm run ci</automated>
|
||
</verify>
|
||
<done>
|
||
PIPE-02 structural verification script exists, integrated into CI chain. `npm run ci` exits 0 with the new step in place. If the heuristic needs tuning post-build, surface in SUMMARY.md.
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<threat_model>
|
||
## Trust Boundaries
|
||
|
||
| Boundary | Description |
|
||
|----------|-------------|
|
||
| Authored content boundary | Fragment body strings are repo-controlled (not user-supplied); Zod-validated at module-eval. React renders as text, no dangerouslySetInnerHTML. |
|
||
| Sim ↔ content boundary | sim/memory imports the Fragment[] via injected SimContext; no module-load coupling between sim and Vite's import.meta.glob. |
|
||
| Selector seed boundary | mulberry32 seed derives from sim state (harvestCount + plantedAtTick); no wall-clock leak. |
|
||
|
||
## STRIDE Threat Register
|
||
|
||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||
|-----------|----------|-----------|-------------|-----------------|
|
||
| T-02-03-01 | Tampering | Player edits harvestedFragmentIds via DevTools | accept | Single-player; CRC-32 detects accidental save corruption only (per Phase 1 doctrine). |
|
||
| T-02-03-02 | Tampering | Numeric / non-stable fragment ID injected via authoring | mitigate | FragmentSchema regex `/^season\d+\.[a-z0-9._-]+$/` enforced at module-eval (Phase 1 PIPE-01); `npm run build` fails on schema violation. |
|
||
| T-02-03-03 | Information disclosure | Fragment body XSS via Markdown / YAML | mitigate | gray-matter + yaml parsers handle content; React renders inside `<pre>` with text content (not HTML); `userSelect: 'text'` doesn't change escape semantics. No dangerouslySetInnerHTML in Journal or RevealModal. |
|
||
| T-02-03-04 | Tampering | Selector returns same fragment via seed manipulation | accept | Seed is pure function of sim state; even if a player manipulates state, no-dup logic ensures progression. |
|
||
| T-02-03-05 | Denial-of-service | Massive fragment file slows initial load | mitigate | PIPE-02 lazy split keeps Season-2-7 out of initial bundle. Phase 2 only ships Season 1 (~12 fragments, <10KB). check-bundle-split.mjs verifies the lazy structure. |
|
||
|
||
No `high` severity threats. The selector + content surface is small and well-bounded.
|
||
</threat_model>
|
||
|
||
<verification>
|
||
|
||
After all 3 tasks committed:
|
||
|
||
1. **Linter:** `npm run lint` exits 0.
|
||
2. **Tests:** `npx vitest run` exits 0; new tests: `src/sim/memory/selector.test.ts` (≥8 cases), `src/sim/memory/pool.test.ts` (optional), `src/sim/garden/commands.test.ts` extended with harvest/compost (≥6 new cases), `src/ui/journal/Journal.test.tsx` (≥6 cases), `src/ui/journal/FragmentRevealModal.test.tsx` (≥5 cases), `scripts/check-bundle-split.test.mjs` (≥2 cases). Combined Phase-1+Phase-2 test count ≥150.
|
||
3. **Build:** `npm run build` exits 0; ≥10 fragments in `/content/seasons/01-soil/` parse without schema violation.
|
||
4. **PIPE-02 structural verify:** `node scripts/check-bundle-split.mjs` exits 0 after build.
|
||
5. **Full CI:** `npm run ci` exits 0 (now includes `check:bundle-split` step).
|
||
6. **Manual smoke** (executor performs once): `npm run dev`, plant rosemary on tile 0, wait 2 minutes (or use FakeClock injection from Plan 02-05's URL flag if landed), click ready plant → reveal modal pops with the selected Season-1 fragment → close → journal icon appears in corner → click icon → journal modal shows the fragment. Plant another rosemary, harvest, then a third — confirm `unlockedPlantTypes` now includes 'yarrow' (visible in the seed picker as a new selectable option).
|
||
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
|
||
Plan 02-03 is complete when:
|
||
|
||
- [ ] All 3 tasks committed.
|
||
- [ ] `npm run ci` exits 0 (now with `check:bundle-split` integrated).
|
||
- [ ] Active-play harvest loop works end-to-end: ready plant → click → reveal modal → close → journal icon → journal modal.
|
||
- [ ] ≥10 Season-1 fragments authored under /content/seasons/01-soil/, all matching bible voice + stable string ID rule.
|
||
- [ ] Plant-type unlock thresholds (yarrow at 3 / winter-rose at 6) take effect (Pitfall 10 boundary tested).
|
||
- [ ] Compost works (immature plant → tile clears).
|
||
- [ ] PIPE-02 structurally verified.
|
||
- [ ] MEMR-05 satisfied: Journal text is selectable + copy-pasteable DOM (Vitest covers, manual confirms via browser DevTools).
|
||
- [ ] D-23, D-24, D-25 all visibly satisfied in dev build.
|
||
- [ ] Plan 02-04 (Lura's Ink dialogue) and Plan 02-05 (offline + letter + e2e) can build on this.
|
||
|
||
</success_criteria>
|
||
|
||
<output>
|
||
Create `.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-SUMMARY.md` per template. Document:
|
||
- Plant-type unlock thresholds finalized (yarrow=3, winter-rose=6 — adjust if playtest demands).
|
||
- Total Season-1 fragment count (target ≥10; record actual).
|
||
- Per-tag distribution (warm / contemplative / heavy counts).
|
||
- Whether `scripts/check-bundle-split.mjs` heuristic worked first try or needed tuning.
|
||
- Manual smoke test confirmation.
|
||
- Any compost-acknowledgement Ink content authored ahead of Plan 02-04 (the executor MAY land the .ink file here as a head-start; Plan 02-04 wires the runtime).
|
||
- Garden scene's chosen approach to fragment loading (eager `fragments` filter for Season 1 vs early `loadSeasonFragments(1)` await — both acceptable; document which).
|
||
</output>
|