Files
TheLastGarden/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md
T
josh a641056364 fix(02): plan revision iter 3 — BLOCKER 3 cross-plan regression + W1/W2
BLOCKER 3 — cross-plan regression: Plans 02-03 and 02-05 BOTH re-author
src/sim/garden/commands.ts but had reverted simulateOneTick to the old
defective return shape (`return { ...next, lastTickAt: currentTick };`).
Wave 1's execution of 02-03 would overwrite 02-02's correct version,
breaking the invariant for the entire phase.

  - 02-03: simulateOneTick return now matches 02-02 line 457 exactly:
    `return { ...next, tickCount: next.tickCount + 1 };`
  - 02-05: same fix for the silent-mode update (Step 6).
  - 02-03 acceptance_criteria: add negative grep
    (`! grep -E "lastTickAt:\s*(this|currentTick)" src/sim/garden/commands.ts`)
    and positive grep (`grep -q "tickCount: next.tickCount" ...`).
  - 02-05 acceptance_criteria: add the same two greps for commands.ts so
    02-05's silent-mode edits cannot silently re-introduce the regression.

W1 — App.tsx import: 02-05 Step 11 used `useEffect` without importing it.
Combined `import { useState }` and `import { useRef }` into a single
`import { useState, useEffect, useRef } from 'react';` line.

W2 — helper arity divergence: Settings.tsx (one-arg, Date.now() inline)
and PhaserGame.tsx (two-arg, clock.now() injected) had two parallel
definitions of buildPayloadFromStore / hydrateStoreFromPayload. Fix:

  - New Step 3.5 introduces `src/save/payload.ts` with the unified
    two-arg signature: `buildPayloadFromStore(state, nowMs)` and
    `hydrateStoreFromPayload(state, payload)`.
  - `src/save/index.ts` re-exports both.
  - Settings.tsx imports from save barrel; passes Date.now() at the
    call site (no clock injection on hand).
  - PhaserGame.tsx imports from save barrel; passes clock.now() (the
    injected wallClock or FakeClock).
  - Inline duplicate definitions in both files removed; replaced with
    a comment pointing to the shared module.
  - files_modified updated to include src/save/payload.ts.
  - acceptance_criteria asserts: shared file exists, both helpers
    exported, both consumers import from save barrel, no inline
    duplicate definitions remain.

VALIDATION.md not updated — no `<automated>` verify command changed;
the new greps live inside `<acceptance_criteria>` (executor-checked
per task), and VALIDATION.md is not present in the phase dir.

All iteration-1 + iteration-2 fixes preserved; no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:15:39 -04:00

61 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
02 03 execute 1
02-01
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
true
GARD-03
GARD-04
MEMR-01
MEMR-02
MEMR-03
MEMR-04
MEMR-05
MEMR-06
PIPE-02
UX-01
vertical-slice
harvest
journal
fragments
content-authoring
lazy-load
mvp
truths artifacts key_links
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
path provides exports
src/sim/memory/selector.ts selectFragment(state, currentSeason, plantTypeId, allFragments) → Fragment | null — pure deterministic selector with gating + no-dup + exhaustion fallback (MEMR-06, RESEARCH Pitfall 8)
selectFragment
EXHAUSTION_FALLBACK_ID
path provides exports
src/sim/memory/pool.ts filterPool(allFragments, season, plantTypeId, alreadyHarvestedIds) — pure filter helper
filterPool
path provides exports
src/sim/garden/commands.ts (extended) harvest(state, tileIdx, currentTick), compost(state, tileIdx, currentTick) — pure commands. simulateOneTick branches on harvest/compost
plantSeed
harvest
compost
simulateOneTick
tileGrowthStage
path provides exports
src/ui/journal/Journal.tsx Full-screen modal listing all harvested fragments grouped by Season; selectable DOM text per MEMR-05
Journal
path provides exports
src/ui/journal/FragmentRevealModal.tsx Active-play reveal modal (D-25) — surfaces just-harvested fragment in full text
FragmentRevealModal
path provides exports
src/ui/journal/journal-icon.tsx Corner icon button (D-23/D-29). Hidden pre-first-harvest; opens Journal modal on click
JournalIcon
path provides
content/seasons/01-soil/fragments.yaml ≥8 short Season-1 fragments authored in voice (the bulk pool that Lura's beats + plant-unlock thresholds draw from)
path provides
content/seasons/01-soil/fragments/*.md ≥2 long-form per-file Season-1 fragments (Markdown + frontmatter); proves the Markdown loader path on Season 1 too
path provides
scripts/check-bundle-split.mjs 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)
from to via pattern
src/sim/garden/commands.ts src/sim/memory/selector.ts harvest() invokes selectFragment to pick exactly one fragment selectFragment
from to via pattern
src/ui/journal/Journal.tsx src/store/index.ts useAppStore(s => s.harvestedFragmentIds) — DOM render of fragments by Season useAppStore
from to via pattern
src/ui/journal/FragmentRevealModal.tsx src/store/index.ts useAppStore(s => s.fragmentRevealId) — opens when set; clears on dismiss fragmentRevealId
from to via pattern
src/sim/memory/selector.ts src/content/index.ts selector takes the loaded `fragments` array as an argument; pool is INJECTED so selector stays pure (no module-load coupling to Vite glob) Fragment[]
from to via pattern
package.json scripts.ci scripts/check-bundle-split.mjs ci runs `npm run build` then `node scripts/check-bundle-split.mjs` to assert PIPE-02 chunk split check:bundle-split
**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%.

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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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

From src/sim/garden/index.ts (Plan 02-02):

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:

fragmentRevealId: string | null;
setFragmentRevealId(id: string | null);
harvestedFragmentIds: string[];
setHarvested(ids: string[]);

From src/content/index.ts (Plan 02-02 extension):

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):

<div id="app">
  <PhaserGame ref={phaserRef} />
  <BeginScreen />
  <SeedPicker />
  {/* Plan 02-03: <Journal />, <FragmentRevealModal />, <JournalIcon /> */}
</div>

From src/sim/state.ts (Plan 02-01):

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):

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;
  }
}
Task 1: Author ≥10 Season-1 fragments + sim/memory selector + extend sim/garden/commands with harvest + compost - .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) 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 **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).
  • 26 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:

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):

# 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.
<!-- 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.
<!-- 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):

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):

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:

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:

// 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:

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);
    }
  }
  // BLOCKER 3 invariant (matches Plan 02-02 line 457): the sim writes
  // tickCount (sim-internal counter for STRY-10), NEVER lastTickAt.
  // lastTickAt is wall-clock ms owned by saveSync. Re-authoring this file
  // in 02-03 must preserve the increment that 02-02 established.
  return { ...next, tickCount: next.tickCount + 1 };
}

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:

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():

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). <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> npm run lint && npx vitest run src/sim/memory/ src/sim/garden/ src/content/ && npm run build ≥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.

Task 2: Memory Journal UI (Journal modal + FragmentRevealModal + JournalIcon) + App.tsx wiring + harvest event flow - .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) 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 **Step 1 — `src/ui/journal/Journal.tsx`** — full-screen modal (D-24):
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):

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):

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:

export { Journal } from './Journal';
export { FragmentRevealModal } from './FragmentRevealModal';
export { JournalIcon } from './journal-icon';

Update src/ui/index.ts:

export * from './begin';
export * from './garden';
export * from './journal';

Step 7 — Update src/App.tsx to mount the new overlays:

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:

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:

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:

// 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. <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) - ! grep -E "lastTickAt:\\s*(this|currentTick)" src/sim/garden/commands.ts (BLOCKER 3 — 02-03's edits to commands.ts must not re-introduce sim-side lastTickAt writes) - grep -q "tickCount: next.tickCount" src/sim/garden/commands.ts (BLOCKER 3 — 02-02's tickCount increment must survive 02-03's harvest/compost edits) - npx vitest run src/ui/journal/ exits 0 with all tests green (≥10 cases across 2 files) - npm run ci exits 0 </acceptance_criteria> npm run lint && npx vitest run src/ui/journal/ && npm run ci 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.

Task 3: PIPE-02 structural verification — scripts/check-bundle-split.mjs and CI integration - .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) scripts/check-bundle-split.mjs, scripts/check-bundle-split.test.mjs, package.json **Step 1 — `scripts/check-bundle-split.mjs`** — structural assertion that Vite emits a separate chunk for Season-1 fragments after `npm run build`:
#!/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).

// 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:

#!/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:

"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. <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> npm run lint && npm run build && node scripts/check-bundle-split.mjs && npx vitest run scripts/check-bundle-split.test.mjs && npm run ci 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.

<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>

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).

<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>

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).