Files
TheLastGarden/content
josh 26eb77a216 feat(02-05): sim/offline + auto-harvest + letter Ink + letter-renderer
- src/sim/offline/: OfflineEventBlockSchema (Zod) + EMPTY_OFFLINE_EVENTS
  + aggregateOfflineEvent pure aggregator (D-19); 14 tests green
- src/sim/garden/auto-harvest.ts: autoHarvestReadyPlants silent-mode
  branch (D-10); reuses harvest() pipeline so selector + Pitfall 10
  unlocks + STRY-10 Lura gate all run identically; BLOCKER 3 invariant
  preserved (no lastTickAt writes); 7 tests green
- simulateOneTick: ctx.silent triggers auto-harvest sweep before tick
  increment; active-play path unchanged (silent defaults false)
- content/dialogue/season1/letter-from-the-garden.ink: authored skeleton
  with VAR plants_bloomed / fragment_titles / lura_was_here per D-17/D-18;
  bible voice, anti-FOMO compliant, 24h cap silent in voice (D-11)
- ink-loader: loadInkStory union extended with letter-from-the-garden;
  separate letterStoryGlob for lazy code-split chunk; INK_VARIABLE_MAP
  gains plants_bloomed / fragment_titles / lura_was_here slots reading
  from session.pendingLetterEventBlock
- src/ui/letter/letter-renderer.ts: pure buildLetterSlots helper —
  prefers fragment first-sentence body for tonal weight, slugified-id
  fallback; 10 tests green
- npm run compile:ink emits 5 .ink.json files (was 4); Vite emits the
  letter as a separate lazy chunk (letter-from-the-garden.ink-*.js)
- 295/295 tests green (was 264; +31 new); npm run ci exits 0
2026-05-09 10:49:59 -04:00
..

/content/ — authored content tree

All player-visible strings, memory fragments, and dialogue live here, never in src/. The build pipeline (src/content/loader.ts) reads this tree at build time, validates against Zod schemas, and emits typed values into the runtime bundle.

This is the contract. Phase 2's writer can author against it without reading any TypeScript.

Directory shape

/content/
├── seasons/
│   ├── 00-demo/                          # Phase 1 only; removed in Phase 2
│   │   └── fragments.yaml
│   ├── 01-soil/                          # Phase 2 fills this
│   │   ├── fragments.yaml                # bulk-authored fragments
│   │   └── fragments/                    # one-per-file long-form fragments (.md with frontmatter)
│   │       └── lura-first-letter.md
│   ├── 02-roots/                         # Phase 4
│   └── ...                               # Seasons 37 added in Phase 5+
├── dialogue/                             # Phase 2+ Ink (.ink) files
│   └── (empty in Phase 1)
└── README.md (this file)

Fragment ID convention (locked — see CLAUDE.md)

Fragment IDs are stable strings of the shape:

season<N>.<id>

where <N> is 0..7 and <id> matches [a-z0-9._-]+. Examples:

  • season1.soil.first-bloom
  • season3.canopy.lura_07.vignette

Never use numeric IDs. Renames are forbidden once a fragment ships; re-authoring an existing fragment changes its body, never its ID.

The exact regex enforced by src/content/schemas/fragment.ts is:

^season\d+\.[a-z0-9._-]+$

Adding fragments

Option A — bulk YAML (preferred for short fragments)

Add an entry to /content/seasons/<slug>/fragments.yaml:

fragments:
  - id: season1.soil.first-bloom
    season: 1
    body: |
      Multi-line text here.

Option B — one-per-file Markdown with frontmatter (for longer pieces)

Create /content/seasons/<slug>/fragments/<slug>.md:

---
id: season1.soil.lura-first-letter
season: 1
---

The body of the fragment goes here as Markdown. Frontmatter holds the
structured fields; the body is everything after the closing `---`.

The loader (src/content/loader.ts) merges frontmatter + body into the same Fragment shape as the YAML form.

Validation (PIPE-01)

Every fragment is validated by the Zod schema in src/content/schemas/fragment.ts. A schema violation throws at module-eval time, which fails npm run build.

Test coverage in src/content/loader.test.ts proves the schema rejects:

  • numeric IDs (violates the stable-string rule)
  • season values outside [0, 7]
  • Markdown frontmatter missing required fields

If your edit causes the build or tests to fail with a [content] schema violation error, the message includes the offending file path.

Ink dialogue

Phase 1 installs inkjs + inklecate and ships a no-op npm run compile:ink script. Phase 2 begins authoring .ink files under /content/dialogue/ and replaces the no-op with inklecate -o src/content/compiled-ink/ content/dialogue/*.ink.

Deferred (Phase 2+)

  • Per-Season lazy loading: Phase 2 switches to { eager: false } for Seasons 27 so the initial bundle contains only Season 1 (PIPE-02).
  • Tag/keyword indices: Phase 5+ may add fragment tagging if the Memory Storm UI needs filtered queries.
  • Season-range narrowing: Phase 2 narrows the season field to [1, 7] when the demo fragment is removed.