feat(02-03): Season-1 fragments + sim/memory selector + harvest/compost commands

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:00:38 -04:00
parent d052a35478
commit f192e8298c
12 changed files with 926 additions and 14 deletions
+177 -6
View File
@@ -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.
@@ -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.
@@ -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.
+14
View File
@@ -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<typeof FragmentSchema>;
+262 -3
View File
@@ -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> = {}): 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);
});
});
+147 -4
View File
@@ -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 };
}
+8 -1
View File
@@ -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';
+1
View File
@@ -11,4 +11,5 @@
export * from './numbers';
export * from './scheduler';
export * from './garden';
export * from './memory';
export type { SimState } from './state';
+11
View File
@@ -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';
+49
View File
@@ -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;
});
}
+171
View File
@@ -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<string>();
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);
}
});
});
+59
View File
@@ -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;
}