From f192e8298cbf14c2b66a5bc3198e08c4e3f3ebe3 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 9 May 2026 10:00:38 -0400 Subject: [PATCH] feat(02-03): Season-1 fragments + sim/memory selector + harvest/compost commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 of Plan 02-03: ship Season-1 authored content + the deterministic fragment selector + extend sim/garden/commands.ts with harvest + compost. Content (≥17 Season-1 fragments under /content/seasons/01-soil/): - 14 in fragments.yaml (9 warm / 3 contemplative / 2 heavy + 1 _meta sentinel) - 2 long-form Markdown fragments (lura-first-letter.md, winter-rose-night.md) - Pool depth (W6): warm pool ≥9 satisfies the worst-case all-rosemary playthrough at the 8th-harvest Lura threshold (CONTEXT D-14) - All ids match /^season1\.[a-z0-9._-]+$/ (FragmentSchema regex; CLAUDE.md stable-string-ID rule); bible voice maintained throughout FragmentSchema extension (back-compat — tags is optional): - Optional `tags: z.array(z.string()).optional()` for tonal-register gating - Reserved tag `_meta` excludes the exhaustion sentinel from the normal pool src/sim/memory/ (new module): - pool.ts — filterPool() pure helper (Season + tonal-register + no-dup gates) - selector.ts — selectFragment() deterministic + mulberry32 PRNG + EXHAUSTION_FALLBACK_ID for Pitfall 8 fallback - selector.test.ts — 16 tests covering gating / no-dup / determinism / sentinel-fallback / sentinel-exclusion-from-normal-pool - index.ts — barrel; src/sim/index.ts re-exports src/sim/garden/commands.ts (extended): - harvest() pure command — empties tile, appends one fragment id, re-computes unlockedPlantTypes (Pitfall 10: thresholds checked AFTER the harvest commit). Refuses immature plants and OOR indices. - compost() pure command — empties tile regardless of stage; no fragment yield (D-07); no resource refund (D-04 = infinite seeds). - SimContext interface — application-layer-injected (fragments, currentSeason) - simulateOneTick() takes optional ctx (default empty pool); harvest/compost branches added to the kind switch. - BLOCKER 3 invariant preserved — sim writes tickCount, never lastTickAt. Plant-type unlock thresholds (CONTEXT D-05, plan author's discretion): - rosemary @ count 0 (start) - yarrow @ count 3 (third harvest) - winter-rose @ count 6 (sixth harvest) commands.test.ts: +18 new cases (harvest / compost / Pitfall 10 boundary on yarrow + winter-rose / sentinel fallback / immutability). 65/65 tests green across src/sim/memory + src/sim/garden + src/content; lint exits 0; build green (Vite parses all 17 fragments without schema violation). Co-Authored-By: Claude Opus 4.7 (1M context) --- content/seasons/01-soil/fragments.yaml | 183 +++++++++++- .../01-soil/fragments/lura-first-letter.md | 14 + .../01-soil/fragments/winter-rose-night.md | 13 + src/content/schemas/fragment.ts | 14 + src/sim/garden/commands.test.ts | 265 +++++++++++++++++- src/sim/garden/commands.ts | 151 +++++++++- src/sim/garden/index.ts | 9 +- src/sim/index.ts | 1 + src/sim/memory/index.ts | 11 + src/sim/memory/pool.ts | 49 ++++ src/sim/memory/selector.test.ts | 171 +++++++++++ src/sim/memory/selector.ts | 59 ++++ 12 files changed, 926 insertions(+), 14 deletions(-) create mode 100644 content/seasons/01-soil/fragments/lura-first-letter.md create mode 100644 content/seasons/01-soil/fragments/winter-rose-night.md create mode 100644 src/sim/memory/index.ts create mode 100644 src/sim/memory/pool.ts create mode 100644 src/sim/memory/selector.test.ts create mode 100644 src/sim/memory/selector.ts diff --git a/content/seasons/01-soil/fragments.yaml b/content/seasons/01-soil/fragments.yaml index a6ec368..89bf35c 100644 --- a/content/seasons/01-soil/fragments.yaml +++ b/content/seasons/01-soil/fragments.yaml @@ -1,10 +1,181 @@ # /content/seasons/01-soil/fragments.yaml # -# Phase 2 placeholder. Plan 02-03 replaces with the authored Season-1 -# fragments (≥10 in voice, MEMR-* coverage). The single placeholder -# fragment here keeps the eager fragment loader green during Plan 02-02 -# (Plan 02-03 expands the file). +# Phase 2 Plan 02-03 — Season 1 ("Soil") authored fragment pool. +# +# Bible voice (CLAUDE.md "Tone"): warm, specific, intermittent, sometimes +# funny, sometimes devastating. Lura is the warmth anchor (Plan 02-04); +# Phase 2 Wave 1 ships the gardener-keeper voice — the contrast, not a +# co-griever. +# +# Tag tonal registers (Plan 02-03 extension to FragmentSchema): +# warm — light, mundane, sometimes funny (rosemary pool) +# contemplative — quiet weight, the shape of an absence (yarrow pool) +# heavy — clear-eyed grief; never melodrama (winter-rose pool) +# _meta — selector-only sentinel; the gated pool excludes this tag +# +# Pool depth (Plan W6 fix): a worst-case all-rosemary playthrough must not +# exhaust the warm pool before the 8th harvest (Lura's farewell threshold, +# CONTEXT D-14). The warm pool below ships ≥9 entries for the 1-buffer +# safety margin. The exhaustion sentinel `season1.soil._exhaustion` is a +# defensive fallback (RESEARCH Pitfall 8); under normal Phase-2 play it is +# unreachable. +# +# IDs match /^season1\.[a-z0-9._-]+$/ (FragmentSchema regex; CLAUDE.md +# stable-string-ID rule). IDs are forever — once shipped, only the body +# may change, never the id. + fragments: - - id: season1.soil.placeholder + # ----- WARM tonal register (rosemary pool) ----- + - id: season1.soil.first-bloom season: 1 - body: "(placeholder — Plan 02-03 ships authored fragments)" + 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. + + - id: season1.soil.kettle-on-the-hob + season: 1 + tags: [warm] + body: | + A kettle, a little dented on one side, lived on a stove that no + longer exists. It whistled flat — half a step under the note it was + meant to make. Nobody ever fixed it. Nobody ever needed to. + + - id: season1.soil.the-wrong-song + season: 1 + tags: [warm] + body: | + Someone in the kitchen used to sing a song with the words mostly + wrong. They would commit to the wrong words anyway, full voice. It + was funnier each time. The garden has the rhythm but not the words. + + - id: season1.soil.the-jam-summer + season: 1 + tags: [warm] + body: | + There was a summer where someone made too much jam. Apricot, mostly. + The cupboards filled. People came over and were given jam. Strangers + were given jam. It became a small embarrassment, and then a joke, + and then a kindness people remembered for a long time after. + + - id: season1.soil.boots-by-the-door + season: 1 + tags: [warm] + body: | + Two pairs of boots used to sit by a door. One pair larger, one pair + smaller. They were left muddy more often than not. Whoever it was + that minded the mud, in the end, did not really mind it. + + - id: season1.soil.the-good-spoon + season: 1 + tags: [warm] + body: | + Every kitchen has a good spoon. The one you reach for without + thinking. This one was wooden, with a small burn mark on the handle + from a moment of inattention years ago. It outlasted the inattentive + person. Some objects are like that. + + - id: season1.soil.the-laughing-fit + season: 1 + tags: [warm] + body: | + A laughing fit at a funeral. The kind that makes things worse and + better at once. It started over something nobody could later + identify. They were all forgiven. Mostly by themselves, after a + decent interval. + + # ----- 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. + + - id: season1.soil.the-clock-that-stopped + season: 1 + tags: [contemplative] + body: | + A clock on a mantel stopped at 4:18. Nobody wound it again. It was + not a meaningful hour. It was the hour the hand happened to be on + when nobody was looking. Now it is the only hour, forever, in that + one small place. + + # ----- 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. + + - id: season1.soil.the-quiet-after + season: 1 + tags: [heavy] + body: | + There is a quiet that comes after, that is not the same as the quiet + that came before. The room is the same. The light is the same. The + quiet is differently shaped — slightly larger than the room, somehow. + Nobody needs to explain this to anyone who has felt it. + + # ----- EXHAUSTION FALLBACK (RESEARCH Pitfall 8) ----- + # Returned ONLY when the gated pool is empty. The pool excludes anything + # tagged `_meta`; selector.ts looks this id up explicitly via + # EXHAUSTION_FALLBACK_ID. In normal Phase-2 play this is unreachable + # (the warm pool is sized to outlast the 8th-harvest Lura threshold), + # but the sentinel is the documented "behavior chosen" for the + # gated-pool-exhaustion case and is committed to the corpus so the + # selector has something to return rather than null. + - 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. diff --git a/content/seasons/01-soil/fragments/lura-first-letter.md b/content/seasons/01-soil/fragments/lura-first-letter.md new file mode 100644 index 0000000..87f69ad --- /dev/null +++ b/content/seasons/01-soil/fragments/lura-first-letter.md @@ -0,0 +1,14 @@ +--- +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. diff --git a/content/seasons/01-soil/fragments/winter-rose-night.md b/content/seasons/01-soil/fragments/winter-rose-night.md new file mode 100644 index 0000000..25e4020 --- /dev/null +++ b/content/seasons/01-soil/fragments/winter-rose-night.md @@ -0,0 +1,13 @@ +--- +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. diff --git a/src/content/schemas/fragment.ts b/src/content/schemas/fragment.ts index cf3752b..35e50c4 100644 --- a/src/content/schemas/fragment.ts +++ b/src/content/schemas/fragment.ts @@ -15,6 +15,20 @@ 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), + /** + * Phase 2 Plan 02-03 extension — optional tonal-register tags. + * + * Used by sim/memory/pool.ts to gate fragments per plant type + * (MEMR-06): a plant's `fragmentTags` array intersects this array. + * The tag `_meta` reserves the fragment for the exhaustion sentinel + * fallback (RESEARCH Pitfall 8); see sim/memory/selector.ts. + * + * Optional + back-compatible — Phase-1 fragments without `tags` still + * parse and load. Phase-2 authored fragments under + * /content/seasons/01-soil/ ship `tags` for the rosemary/yarrow/winter- + * rose tonal registers (warm / contemplative / heavy). + */ + tags: z.array(z.string().min(1)).optional(), }); export type Fragment = z.infer; diff --git a/src/sim/garden/commands.test.ts b/src/sim/garden/commands.test.ts index 9e1ce95..7ed7258 100644 --- a/src/sim/garden/commands.test.ts +++ b/src/sim/garden/commands.test.ts @@ -1,7 +1,35 @@ import { describe, it, expect } from 'vitest'; import type { SimState } from '../state'; -import { plantSeed, simulateOneTick, tileGrowthStage } from './commands'; +import type { Fragment } from '../../content'; +import { + plantSeed, + harvest, + compost, + simulateOneTick, + tileGrowthStage, + type SimContext, +} from './commands'; import { emptyTiles, type Tile } from './types'; +import { PLANT_TYPES } from './plants'; + +// Tiny Fragment[] fixture for harvest tests. A deeper warm pool ensures +// determinism tests + plant-type unlock thresholds (3rd / 6th harvest) +// have enough material to drive harvests through. +const fixtureFragments: Fragment[] = [ + { id: 'season1.soil.f-warm-1', season: 1, body: 'warm-1', tags: ['warm'] }, + { id: 'season1.soil.f-warm-2', season: 1, body: 'warm-2', tags: ['warm'] }, + { id: 'season1.soil.f-warm-3', season: 1, body: 'warm-3', tags: ['warm'] }, + { id: 'season1.soil.f-warm-4', season: 1, body: 'warm-4', tags: ['warm'] }, + { id: 'season1.soil.f-warm-5', season: 1, body: 'warm-5', tags: ['warm'] }, + { id: 'season1.soil.f-warm-6', season: 1, body: 'warm-6', tags: ['warm'] }, + { id: 'season1.soil.f-warm-7', season: 1, body: 'warm-7', tags: ['warm'] }, + { id: 'season1.soil.f-warm-8', season: 1, body: 'warm-8', tags: ['warm'] }, + { id: 'season1.soil.f-contemplative-1', season: 1, body: 'contemplative-1', tags: ['contemplative'] }, + { id: 'season1.soil.f-heavy-1', season: 1, body: 'heavy-1', tags: ['heavy'] }, + { id: 'season1.soil._exhaustion', season: 1, body: 'sentinel', tags: ['_meta'] }, +]; +const fixtureCtx: SimContext = { fragments: fixtureFragments, currentSeason: 1 }; +const emptyCtx: SimContext = { fragments: [], currentSeason: 1 }; function freshSimState(overrides: Partial = {}): SimState { return { @@ -98,15 +126,17 @@ describe('simulateOneTick (BLOCKER 3 — writes tickCount, NEVER lastTickAt)', ( expect(next.tickCount).toBe(1); }); - it('ignores harvest/compost commands at this stage (Plan 02-03 wires them)', () => { + it('routes harvest/compost commands through the new branches; tick still ticks', () => { + // Plan 02-03 wires harvest + compost. With empty tiles, both are no-ops + // (return state reference unchanged) — but the tick counter still advances. const state = freshSimState({ tickCount: 0 }); const next = simulateOneTick(state, 1, [ { kind: 'harvest', tileIdx: 0 }, { kind: 'compost', tileIdx: 1 }, ]); - // No-op for now — but the tick still ticks. expect(next.tickCount).toBe(1); expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull(); + expect((next.garden.tiles as Tile[])[1]?.plant).toBeNull(); }); it('applies multiple commands in order in a single tick', () => { @@ -134,3 +164,232 @@ describe('tileGrowthStage', () => { expect(tileGrowthStage(tile, 600)).toBe('ready'); }); }); + +describe('harvest (GARD-03 / MEMR-01 / MEMR-06 / Pitfall 10)', () => { + // Helper: place a single ready rosemary on tile `idx`. Rosemary's + // durationTicks is 600; planting at tick 0 means it is 'ready' at tick 600. + function withReadyRosemary(idx = 0): SimState { + return freshSimState({ + garden: { + tiles: emptyTiles().map((t, i) => + i === idx + ? { idx: i, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } + : t, + ), + }, + }); + } + + it('clears the tile and appends exactly ONE id to harvestedFragmentIds on a ready plant', () => { + const state = withReadyRosemary(0); + const next = harvest(state, 0, PLANT_TYPES.rosemary.durationTicks, fixtureCtx); + expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull(); + expect(next.harvestedFragmentIds.length).toBe(state.harvestedFragmentIds.length + 1); + expect(next.harvestedFragmentIds[next.harvestedFragmentIds.length - 1]).toMatch( + /^season1\.soil\./, + ); + }); + + it('returns the SAME state reference when harvesting an immature plant', () => { + const state = withReadyRosemary(0); + // Tick 100 — sprout still + const next = harvest(state, 0, 100, fixtureCtx); + expect(next).toBe(state); + }); + + it('returns the SAME state reference when harvesting an empty tile', () => { + const state = freshSimState(); + const next = harvest(state, 0, 100, fixtureCtx); + expect(next).toBe(state); + }); + + it('returns the SAME state reference on out-of-range tileIdx', () => { + const state = withReadyRosemary(0); + expect(harvest(state, -1, 600, fixtureCtx)).toBe(state); + expect(harvest(state, 16, 600, fixtureCtx)).toBe(state); + }); + + it('returns the SAME state reference when ctx is empty AND no sentinel resolves (degenerate)', () => { + const state = withReadyRosemary(0); + const next = harvest(state, 0, 600, emptyCtx); + expect(next).toBe(state); + }); + + it('is deterministic — two calls on identical state produce identical results', () => { + const state = withReadyRosemary(0); + const a = harvest(state, 0, 600, fixtureCtx); + const b = harvest(state, 0, 600, fixtureCtx); + expect(a.harvestedFragmentIds).toEqual(b.harvestedFragmentIds); + }); + + it('does NOT modify the source tiles array (immutability)', () => { + const state = withReadyRosemary(0); + harvest(state, 0, 600, fixtureCtx); + expect((state.garden.tiles as Tile[])[0]?.plant).not.toBeNull(); + expect((state.garden.tiles as Tile[])[0]?.plant?.plantTypeId).toBe('rosemary'); + }); + + it('Pitfall 10 — plant-type unlocks update AFTER the harvest commit (3rd harvest unlocks yarrow)', () => { + // Hand-roll a state with exactly 2 prior harvests and a ready rosemary. + const state = freshSimState({ + harvestedFragmentIds: ['season1.soil.dummy-1', 'season1.soil.dummy-2'], + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t, + ), + }, + }); + expect(state.unlockedPlantTypes).not.toContain('yarrow'); + const next = harvest(state, 0, 600, fixtureCtx); + expect(next.harvestedFragmentIds.length).toBe(3); + expect(next.unlockedPlantTypes).toContain('yarrow'); + expect(next.unlockedPlantTypes).not.toContain('winter-rose'); + }); + + it('Pitfall 10 — yarrow stays locked after 2 harvests', () => { + const state = freshSimState({ + harvestedFragmentIds: ['season1.soil.dummy-1'], + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t, + ), + }, + }); + const next = harvest(state, 0, 600, fixtureCtx); + expect(next.harvestedFragmentIds.length).toBe(2); + expect(next.unlockedPlantTypes).not.toContain('yarrow'); + }); + + it('Pitfall 10 — winter-rose unlocks at 6 harvests', () => { + const state = freshSimState({ + harvestedFragmentIds: [ + 'season1.soil.d-1', + 'season1.soil.d-2', + 'season1.soil.d-3', + 'season1.soil.d-4', + 'season1.soil.d-5', + ], + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t, + ), + }, + }); + const next = harvest(state, 0, 600, fixtureCtx); + expect(next.harvestedFragmentIds.length).toBe(6); + expect(next.unlockedPlantTypes).toContain('winter-rose'); + }); + + it('falls back to the exhaustion sentinel when the gated pool is empty (Pitfall 8)', () => { + // Pre-harvest every warm fragment so the rosemary pool is empty. + const warmIds = fixtureFragments + .filter((f) => f.tags?.includes('warm')) + .map((f) => f.id); + const state = freshSimState({ + harvestedFragmentIds: warmIds, + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t, + ), + }, + }); + const next = harvest(state, 0, 600, fixtureCtx); + expect(next.harvestedFragmentIds[next.harvestedFragmentIds.length - 1]).toBe( + 'season1.soil._exhaustion', + ); + }); +}); + +describe('compost (GARD-04 / D-07 / no-resource-refund)', () => { + it('clears the tile of an immature plant', () => { + const state = freshSimState({ + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t, + ), + }, + }); + const next = compost(state, 0, 100); + expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull(); + }); + + it('returns the SAME state reference on an empty tile', () => { + const state = freshSimState(); + const next = compost(state, 0, 100); + expect(next).toBe(state); + }); + + it('does NOT modify harvestedFragmentIds (D-07 no-yield)', () => { + const state = freshSimState({ + harvestedFragmentIds: ['season1.soil.dummy-1'], + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t, + ), + }, + }); + const next = compost(state, 0, 100); + expect(next.harvestedFragmentIds).toEqual(state.harvestedFragmentIds); + }); + + it('does NOT modify unlockedPlantTypes (D-04 no resource-recovery)', () => { + const state = freshSimState({ + unlockedPlantTypes: ['rosemary'], + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t, + ), + }, + }); + const next = compost(state, 0, 100); + expect(next.unlockedPlantTypes).toEqual(['rosemary']); + }); + + it('returns the SAME state reference on out-of-range tileIdx', () => { + const state = freshSimState(); + expect(compost(state, -1, 100)).toBe(state); + expect(compost(state, 16, 100)).toBe(state); + }); +}); + +describe('simulateOneTick — harvest + compost integration (BLOCKER 3 carry-through)', () => { + it('routes harvest commands through SimContext and produces a fragment', () => { + const state = freshSimState({ + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t, + ), + }, + }); + const next = simulateOneTick(state, 600, [{ kind: 'harvest', tileIdx: 0 }], fixtureCtx); + expect(next.harvestedFragmentIds.length).toBe(1); + expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull(); + expect(next.tickCount).toBe(1); + }); + + it('still does NOT modify lastTickAt when harvesting (BLOCKER 3)', () => { + const state = freshSimState({ + lastTickAt: 99999, + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t, + ), + }, + }); + const next = simulateOneTick(state, 600, [{ kind: 'harvest', tileIdx: 0 }], fixtureCtx); + expect(next.lastTickAt).toBe(99999); + }); + + it('routes compost commands through the new branch and ticks', () => { + const state = freshSimState({ + garden: { + tiles: emptyTiles().map((t, i) => + i === 0 ? { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } } : t, + ), + }, + }); + const next = simulateOneTick(state, 100, [{ kind: 'compost', tileIdx: 0 }]); + expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull(); + expect(next.tickCount).toBe(1); + }); +}); diff --git a/src/sim/garden/commands.ts b/src/sim/garden/commands.ts index 3519b24..43814ca 100644 --- a/src/sim/garden/commands.ts +++ b/src/sim/garden/commands.ts @@ -1,9 +1,11 @@ import type { SimState } from '../state'; import type { GardenCommand } from '../../store/garden-slice'; +import type { Fragment } from '../../content'; import { PLANT_TYPES } from './plants'; import type { GrowthStage, PlantInstance, PlantTypeId, Tile } from './types'; import { GRID_SIZE } from './types'; import { advanceGrowth } from './growth'; +import { selectFragment } from '../memory/selector'; /** * Pure command applications. Each returns a NEW SimState — no mutation. @@ -14,9 +16,51 @@ import { advanceGrowth } from './growth'; * sim → store type-only imports are permitted because they leave no * runtime coupling. The runtime store is never loaded by the sim. * - * Phase 2 wires plantSeed here; harvest + compost ship in Plan 02-03. + * Plan 02-02 shipped plantSeed + simulateOneTick. Plan 02-03 extends with + * harvest + compost branches and the SimContext injection point that + * carries the loaded Fragment[] corpus + currentSeason. The sim stays + * decoupled from Vite's import.meta.glob — the application layer + * (Garden scene) loads the corpus and passes it through. */ +/** + * Plant-type unlock thresholds (CONTEXT D-05 + RESEARCH Pitfall 10). + * + * rosemary — available from start (count 0) + * yarrow — unlocks at the 3rd harvest + * winter-rose — unlocks at the 6th harvest + * + * Per Pitfall 10: thresholds are checked AFTER the harvest is committed + * to harvestedFragmentIds, in the same simulate-step. This guarantees + * the off-by-one boundary (2 harvests = locked, 3 = unlocked) holds. + * + * Final values selected within the plan author's discretion (D-05). Pinned + * by commands.test.ts boundary tests. + */ +const PLANT_UNLOCK_THRESHOLDS: ReadonlyArray<{ count: number; plantTypeId: PlantTypeId }> = + Object.freeze([ + { count: 0, plantTypeId: 'rosemary' }, + { count: 3, plantTypeId: 'yarrow' }, + { count: 6, plantTypeId: 'winter-rose' }, + ]); + +function computePlantUnlocks(harvestCount: number): string[] { + return PLANT_UNLOCK_THRESHOLDS.filter((t) => harvestCount >= t.count).map( + (t) => t.plantTypeId, + ); +} + +/** + * SimContext — application-layer-injected pool of Fragments + current + * Season. The Garden scene reads `fragments` (eager export from + * src/content) at create() time and passes the snapshot through every + * simulateOneTick call. Sim modules NEVER import import.meta.glob. + */ +export interface SimContext { + fragments: readonly Fragment[]; + currentSeason: number; +} + export function plantSeed( state: SimState, tileIdx: number, @@ -46,6 +90,99 @@ export function plantSeed( return { ...state, garden: { tiles: nextTiles } }; } +/** + * harvest(state, tileIdx, currentTick, ctx) → state' + * + * Pure. Picks exactly ONE fragment via the deterministic selector, + * empties the tile, appends to harvestedFragmentIds, and re-computes + * unlockedPlantTypes (Pitfall 10: AFTER the commit). + * + * No-op (returns the original state reference) when: + * - tileIdx is out of range + * - tile is empty + * - plant is not yet at the 'ready' growth stage + * - selector returns null (degenerate: no fragment AND no sentinel) + * + * Seed derivation: `(harvestedFragmentIds.length, plant.plantedAtTick)`. + * Both are sim-internal counters; no Date.now leaks (BLOCKER 3 / D-33). + * + * Per GARD-03 + MEMR-01 + MEMR-06. + */ +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 + + // Knuth's multiplicative hash on a 32-bit integer; spreads adjacent + // (harvestCount, plantedAtTick) pairs across the seed space so the + // mulberry32 PRNG produces visibly-different results from each + // harvest. Bitwise OR with 0 forces 32-bit integer truncation. + const seedHash = + (state.harvestedFragmentIds.length * 2654435761 + tile.plant.plantedAtTick) | 0; + 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]; + // Pitfall 10: check thresholds AFTER the harvest commit. + const unlockedPlantTypes = computePlantUnlocks(harvestedIds.length); + + return { + ...state, + garden: { tiles: nextTiles }, + harvestedFragmentIds: harvestedIds, + unlockedPlantTypes, + }; +} + +/** + * compost(state, tileIdx, currentTick) → state' + * + * Pure. Empties the tile regardless of growth stage. No fragment yield + * (D-07). No resource refund (D-04 = infinite seeds). + * + * The tonal acknowledgement beat (D-07 + GARD-04) is a UI concern — + * Plan 02-04's Ink runtime renders compost-acknowledgements.ink lines + * via the dialogue overlay. Plan 02-03 ships the AUTHORED CONTENT under + * /content/dialogue/season1/ so Plan 02-04 can swap to the runtime + * without re-authoring; the React surface fires a placeholder beat for + * now (see src/game/scenes/Garden.ts handleTilePointerDown). + * + * Returns the original state reference on no-op (empty tile, OOR idx). + */ +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 } }; +} + /** * Pure single-tick simulation. Drains pending commands, advances all plants. * Per CORE-02 — fixed-timestep, deterministic from inputs. @@ -54,13 +191,16 @@ export function plantSeed( * STRY-10), NEVER lastTickAt. lastTickAt is wall-clock ms owned by the * application layer's saveSync (src/PhaserGame.tsx). * - * Phase 2 Plan 02-02 implements plantSeed only; harvest + compost arrive - * in Plan 02-03 (extended via the kind switch below). + * Plan 02-03 adds the SimContext 4th argument so harvest() can call + * selectFragment with the application-layer-injected fragment corpus. + * Plan 02-02 callers that pass only 3 args still compile (ctx defaults to + * an empty pool); compost + plantSeed don't read ctx at all. */ export function simulateOneTick( state: SimState, currentTick: number, commands: GardenCommand[], + ctx: SimContext = { fragments: [], currentSeason: 1 }, ): SimState { let next = state; // Drain commands FIRST so state effects of new commands participate in @@ -68,8 +208,11 @@ export function simulateOneTick( 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); } - // Plan 02-03 will add 'harvest' and 'compost' branches here. } return { ...next, tickCount: next.tickCount + 1 }; } diff --git a/src/sim/garden/index.ts b/src/sim/garden/index.ts index c6975b4..e895265 100644 --- a/src/sim/garden/index.ts +++ b/src/sim/garden/index.ts @@ -6,4 +6,11 @@ export type { Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage } from '. export { GRID_ROWS, GRID_COLS, GRID_SIZE, tileIdx, tileCoords, emptyTiles } from './types'; export { PLANT_TYPES, getPlantType } from './plants'; export { advanceGrowth, GROWTH_THRESHOLDS } from './growth'; -export { plantSeed, simulateOneTick, tileGrowthStage } from './commands'; +export { + plantSeed, + harvest, + compost, + simulateOneTick, + tileGrowthStage, +} from './commands'; +export type { SimContext } from './commands'; diff --git a/src/sim/index.ts b/src/sim/index.ts index f2c227e..2fd6b11 100644 --- a/src/sim/index.ts +++ b/src/sim/index.ts @@ -11,4 +11,5 @@ export * from './numbers'; export * from './scheduler'; export * from './garden'; +export * from './memory'; export type { SimState } from './state'; diff --git a/src/sim/memory/index.ts b/src/sim/memory/index.ts new file mode 100644 index 0000000..2b30a3c --- /dev/null +++ b/src/sim/memory/index.ts @@ -0,0 +1,11 @@ +/** + * Public barrel for src/sim/memory/. App code imports from here, never + * from the individual module files. + * + * Per CORE-10, this module is sim — pure (no DOM, no Date.now, no + * import.meta.glob). The Fragment[] corpus is INJECTED by the application + * layer (Garden scene's update loop), keeping sim/memory decoupled from + * Vite-magic content loading. + */ +export { selectFragment, EXHAUSTION_FALLBACK_ID } from './selector'; +export { filterPool } from './pool'; diff --git a/src/sim/memory/pool.ts b/src/sim/memory/pool.ts new file mode 100644 index 0000000..11ea9d7 --- /dev/null +++ b/src/sim/memory/pool.ts @@ -0,0 +1,49 @@ +import type { Fragment } from '../../content'; +import type { PlantTypeId } from '../garden/types'; +import { PLANT_TYPES } from '../garden/plants'; + +/** + * sim/memory/pool — pure filter helper. + * + * Per MEMR-06: filter the loaded fragment corpus down to the gated, + * not-yet-harvested pool for a given (season, plantTypeId) at the moment + * of harvest. The pool obeys three constraints: + * + * 1. Season gate — fragment.season must match currentSeason. + * 2. Plant-type tonal register — fragment.tags must intersect the + * plant type's fragmentTags array (warm / contemplative / heavy). + * Fragments without tags are excluded — Phase-2 authored fragments + * ship tags; legacy / placeholder content does not have tonal + * register and so cannot be selected by this gating path. + * 3. No-dup — fragment.id must not appear in alreadyHarvestedIds. + * + * 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 (CLAUDE.md sim-purity rule + ESLint Block 3). + */ +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. Fragments without tags fall + // out (legacy / placeholder content has no tonal register). + if (!f.tags || f.tags.length === 0) return false; + if (!f.tags.some((t) => tagSet.has(t))) return false; + // Reserved sentinel fragments are excluded from the normal pool — + // selector.ts pulls them via EXHAUSTION_FALLBACK_ID lookup only. + if (f.tags.includes('_meta')) return false; + return true; + }); +} diff --git a/src/sim/memory/selector.test.ts b/src/sim/memory/selector.test.ts new file mode 100644 index 0000000..2fa24fb --- /dev/null +++ b/src/sim/memory/selector.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import type { Fragment } from '../../content'; +import { selectFragment, EXHAUSTION_FALLBACK_ID } from './selector'; +import { filterPool } from './pool'; + +/** + * Deterministic-selector + gated-pool tests for sim/memory. + * + * Pins MEMR-06 (deterministic, gated, no-dup) and RESEARCH Pitfall 8 + * (exhaustion fallback). + */ + +const sentinel: Fragment = { + id: EXHAUSTION_FALLBACK_ID, + season: 1, + body: '(sentinel)', + tags: ['_meta'], +}; + +const warmA: Fragment = { + id: 'season1.soil.warm-a', + season: 1, + body: 'warm-a', + tags: ['warm'], +}; +const warmB: Fragment = { + id: 'season1.soil.warm-b', + season: 1, + body: 'warm-b', + tags: ['warm'], +}; +const warmC: Fragment = { + id: 'season1.soil.warm-c', + season: 1, + body: 'warm-c', + tags: ['warm'], +}; +const heavy: Fragment = { + id: 'season1.soil.heavy-a', + season: 1, + body: 'heavy-a', + tags: ['heavy'], +}; +const contemplative: Fragment = { + id: 'season1.soil.contemplative-a', + season: 1, + body: 'contemplative-a', + tags: ['contemplative'], +}; +const futureSeasonWarm: Fragment = { + id: 'season2.future.warm-a', + season: 2, + body: 'warm-but-future', + tags: ['warm'], +}; + +describe('filterPool (MEMR-06 gating)', () => { + it('returns only fragments matching the plant-type tonal register (warm → rosemary)', () => { + const pool = filterPool([warmA, heavy, contemplative], 1, 'rosemary', []); + expect(pool.map((f) => f.id)).toEqual([warmA.id]); + }); + + it('returns only fragments matching the plant-type tonal register (contemplative → yarrow)', () => { + const pool = filterPool([warmA, heavy, contemplative], 1, 'yarrow', []); + expect(pool.map((f) => f.id)).toEqual([contemplative.id]); + }); + + it('returns only fragments matching the plant-type tonal register (heavy → winter-rose)', () => { + const pool = filterPool([warmA, heavy, contemplative], 1, 'winter-rose', []); + expect(pool.map((f) => f.id)).toEqual([heavy.id]); + }); + + it('excludes fragments already in alreadyHarvestedIds (no-dup)', () => { + const pool = filterPool([warmA, warmB, warmC], 1, 'rosemary', [warmB.id]); + expect(pool.map((f) => f.id).sort()).toEqual([warmA.id, warmC.id].sort()); + }); + + it('excludes fragments from a different Season', () => { + const pool = filterPool([warmA, futureSeasonWarm], 1, 'rosemary', []); + expect(pool.map((f) => f.id)).toEqual([warmA.id]); + }); + + it('excludes the _meta-tagged sentinel from the normal pool', () => { + const pool = filterPool([warmA, sentinel], 1, 'rosemary', []); + expect(pool.map((f) => f.id)).toEqual([warmA.id]); + }); + + it('excludes fragments without a tags array (no tonal register)', () => { + const tagless: Fragment = { + id: 'season1.soil.tagless', + season: 1, + body: 'no tags', + }; + const pool = filterPool([warmA, tagless], 1, 'rosemary', []); + expect(pool.map((f) => f.id)).toEqual([warmA.id]); + }); +}); + +describe('selectFragment (deterministic, gated, no-dup, exhaustion)', () => { + it('returns the sentinel when the gated pool is empty AND the sentinel exists', () => { + // Pool empty because alreadyHarvestedIds covers everything warm. + const fragment = selectFragment( + [warmA, sentinel], + 1, + 'rosemary', + [warmA.id], + 0, + ); + expect(fragment?.id).toBe(EXHAUSTION_FALLBACK_ID); + }); + + it('returns null when the gated pool is empty AND the sentinel is missing', () => { + const fragment = selectFragment([warmA], 1, 'rosemary', [warmA.id], 0); + expect(fragment).toBeNull(); + }); + + it('returns the only available fragment regardless of seedHash when pool size is 1', () => { + expect(selectFragment([warmA, sentinel], 1, 'rosemary', [], 0)?.id).toBe(warmA.id); + expect(selectFragment([warmA, sentinel], 1, 'rosemary', [], 1)?.id).toBe(warmA.id); + expect(selectFragment([warmA, sentinel], 1, 'rosemary', [], 999_999)?.id).toBe(warmA.id); + }); + + it('is deterministic — same inputs ALWAYS yield the same fragment', () => { + const corpus = [warmA, warmB, warmC, sentinel]; + const a = selectFragment(corpus, 1, 'rosemary', [], 12345); + const b = selectFragment(corpus, 1, 'rosemary', [], 12345); + expect(a?.id).toBe(b?.id); + }); + + it('different seedHash values can yield different fragments (PRNG actually varies)', () => { + const corpus = [warmA, warmB, warmC, sentinel]; + const seen = new Set(); + for (let s = 0; s < 50; s++) { + const f = selectFragment(corpus, 1, 'rosemary', [], s); + if (f) seen.add(f.id); + } + expect(seen.size).toBeGreaterThanOrEqual(2); + }); + + it('respects plant-type gating — heavy fragment is never returned for a rosemary harvest', () => { + const corpus = [warmA, heavy, contemplative, sentinel]; + for (let s = 0; s < 50; s++) { + const f = selectFragment(corpus, 1, 'rosemary', [], s); + expect(f?.tags).toContain('warm'); + } + }); + + it('respects season gating — Season-2 fragment is never returned for a Season-1 harvest', () => { + const corpus = [warmA, futureSeasonWarm, sentinel]; + for (let s = 0; s < 50; s++) { + const f = selectFragment(corpus, 1, 'rosemary', [], s); + expect(f?.season).toBe(1); + } + }); + + it('respects no-dup — passing a fragment id in alreadyHarvestedIds excludes it from selection', () => { + const corpus = [warmA, warmB, sentinel]; + for (let s = 0; s < 50; s++) { + const f = selectFragment(corpus, 1, 'rosemary', [warmA.id], s); + expect(f?.id).toBe(warmB.id); + } + }); + + it('never returns the sentinel via the normal-pool path (exhaustion-only)', () => { + const corpus = [warmA, warmB, sentinel]; + for (let s = 0; s < 50; s++) { + const f = selectFragment(corpus, 1, 'rosemary', [], s); + expect(f?.id).not.toBe(EXHAUSTION_FALLBACK_ID); + } + }); +}); diff --git a/src/sim/memory/selector.ts b/src/sim/memory/selector.ts new file mode 100644 index 0000000..c37403a --- /dev/null +++ b/src/sim/memory/selector.ts @@ -0,0 +1,59 @@ +import type { Fragment } from '../../content'; +import type { PlantTypeId } from '../garden/types'; +import { filterPool } from './pool'; + +/** + * MEMR-06 deterministic fragment selector. + * + * Pure inputs: (allFragments, currentSeason, plantTypeId, + * alreadyHarvestedIds, seedHash) → Fragment | null. Same inputs ALWAYS + * yield the same fragment — pinned by selector.test.ts. + * + * The seed is derived in the caller (sim/garden/commands.ts harvest() + * step) from `(state.harvestedFragmentIds.length, plant.plantedAtTick)`. + * Both are sim-internal counters; no Date.now leaks into the seed. + * + * Per RESEARCH Pitfall 8 (gated-pool 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 as `season1.soil._exhaustion` in + * /content/seasons/01-soil/fragments.yaml). + * - If even the sentinel is missing (degenerate test fixture): + * return null and let the caller treat it as a no-op harvest. + * + * Plan 02-03 ships ≥9 warm-tag fragments so a worst-case all-rosemary + * playthrough does NOT exhaust the pool before Lura's 8th-harvest + * farewell threshold (CONTEXT D-14). The sentinel is a defensive + * fallback, not an expected normal-play path. + */ +export const EXHAUSTION_FALLBACK_ID = 'season1.soil._exhaustion'; + +/** + * mulberry32 — small seeded PRNG (RESEARCH "Don't Hand-Roll" line 1013; + * pure, ~10 LoC). Returns a function that yields uniformly-distributed + * floats in [0, 1) on each call. Deterministic from the seed. + */ +function mulberry32(a: number): () => number { + return function (): number { + 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; +}