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:
@@ -1,10 +1,181 @@
|
|||||||
# /content/seasons/01-soil/fragments.yaml
|
# /content/seasons/01-soil/fragments.yaml
|
||||||
#
|
#
|
||||||
# Phase 2 placeholder. Plan 02-03 replaces with the authored Season-1
|
# Phase 2 Plan 02-03 — Season 1 ("Soil") authored fragment pool.
|
||||||
# fragments (≥10 in voice, MEMR-* coverage). The single placeholder
|
#
|
||||||
# fragment here keeps the eager fragment loader green during Plan 02-02
|
# Bible voice (CLAUDE.md "Tone"): warm, specific, intermittent, sometimes
|
||||||
# (Plan 02-03 expands the file).
|
# 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:
|
fragments:
|
||||||
- id: season1.soil.placeholder
|
# ----- WARM tonal register (rosemary pool) -----
|
||||||
|
- id: season1.soil.first-bloom
|
||||||
season: 1
|
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.
|
||||||
@@ -15,6 +15,20 @@ export const FragmentSchema = z.object({
|
|||||||
id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/),
|
id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/),
|
||||||
season: z.number().int().min(0).max(7),
|
season: z.number().int().min(0).max(7),
|
||||||
body: z.string().min(1),
|
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>;
|
export type Fragment = z.infer<typeof FragmentSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,35 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import type { SimState } from '../state';
|
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 { 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 {
|
function freshSimState(overrides: Partial<SimState> = {}): SimState {
|
||||||
return {
|
return {
|
||||||
@@ -98,15 +126,17 @@ describe('simulateOneTick (BLOCKER 3 — writes tickCount, NEVER lastTickAt)', (
|
|||||||
expect(next.tickCount).toBe(1);
|
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 state = freshSimState({ tickCount: 0 });
|
||||||
const next = simulateOneTick(state, 1, [
|
const next = simulateOneTick(state, 1, [
|
||||||
{ kind: 'harvest', tileIdx: 0 },
|
{ kind: 'harvest', tileIdx: 0 },
|
||||||
{ kind: 'compost', tileIdx: 1 },
|
{ kind: 'compost', tileIdx: 1 },
|
||||||
]);
|
]);
|
||||||
// No-op for now — but the tick still ticks.
|
|
||||||
expect(next.tickCount).toBe(1);
|
expect(next.tickCount).toBe(1);
|
||||||
expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull();
|
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', () => {
|
it('applies multiple commands in order in a single tick', () => {
|
||||||
@@ -134,3 +164,232 @@ describe('tileGrowthStage', () => {
|
|||||||
expect(tileGrowthStage(tile, 600)).toBe('ready');
|
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
@@ -1,9 +1,11 @@
|
|||||||
import type { SimState } from '../state';
|
import type { SimState } from '../state';
|
||||||
import type { GardenCommand } from '../../store/garden-slice';
|
import type { GardenCommand } from '../../store/garden-slice';
|
||||||
|
import type { Fragment } from '../../content';
|
||||||
import { PLANT_TYPES } from './plants';
|
import { PLANT_TYPES } from './plants';
|
||||||
import type { GrowthStage, PlantInstance, PlantTypeId, Tile } from './types';
|
import type { GrowthStage, PlantInstance, PlantTypeId, Tile } from './types';
|
||||||
import { GRID_SIZE } from './types';
|
import { GRID_SIZE } from './types';
|
||||||
import { advanceGrowth } from './growth';
|
import { advanceGrowth } from './growth';
|
||||||
|
import { selectFragment } from '../memory/selector';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure command applications. Each returns a NEW SimState — no mutation.
|
* 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
|
* sim → store type-only imports are permitted because they leave no
|
||||||
* runtime coupling. The runtime store is never loaded by the sim.
|
* 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(
|
export function plantSeed(
|
||||||
state: SimState,
|
state: SimState,
|
||||||
tileIdx: number,
|
tileIdx: number,
|
||||||
@@ -46,6 +90,99 @@ export function plantSeed(
|
|||||||
return { ...state, garden: { tiles: nextTiles } };
|
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.
|
* Pure single-tick simulation. Drains pending commands, advances all plants.
|
||||||
* Per CORE-02 — fixed-timestep, deterministic from inputs.
|
* 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
|
* STRY-10), NEVER lastTickAt. lastTickAt is wall-clock ms owned by the
|
||||||
* application layer's saveSync (src/PhaserGame.tsx).
|
* application layer's saveSync (src/PhaserGame.tsx).
|
||||||
*
|
*
|
||||||
* Phase 2 Plan 02-02 implements plantSeed only; harvest + compost arrive
|
* Plan 02-03 adds the SimContext 4th argument so harvest() can call
|
||||||
* in Plan 02-03 (extended via the kind switch below).
|
* 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(
|
export function simulateOneTick(
|
||||||
state: SimState,
|
state: SimState,
|
||||||
currentTick: number,
|
currentTick: number,
|
||||||
commands: GardenCommand[],
|
commands: GardenCommand[],
|
||||||
|
ctx: SimContext = { fragments: [], currentSeason: 1 },
|
||||||
): SimState {
|
): SimState {
|
||||||
let next = state;
|
let next = state;
|
||||||
// Drain commands FIRST so state effects of new commands participate in
|
// Drain commands FIRST so state effects of new commands participate in
|
||||||
@@ -68,8 +208,11 @@ export function simulateOneTick(
|
|||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
if (cmd.kind === 'plantSeed' && cmd.plantTypeId) {
|
if (cmd.kind === 'plantSeed' && cmd.plantTypeId) {
|
||||||
next = plantSeed(next, cmd.tileIdx, cmd.plantTypeId as PlantTypeId, currentTick);
|
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 };
|
return { ...next, tickCount: next.tickCount + 1 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { GRID_ROWS, GRID_COLS, GRID_SIZE, tileIdx, tileCoords, emptyTiles } from './types';
|
||||||
export { PLANT_TYPES, getPlantType } from './plants';
|
export { PLANT_TYPES, getPlantType } from './plants';
|
||||||
export { advanceGrowth, GROWTH_THRESHOLDS } from './growth';
|
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';
|
||||||
|
|||||||
@@ -11,4 +11,5 @@
|
|||||||
export * from './numbers';
|
export * from './numbers';
|
||||||
export * from './scheduler';
|
export * from './scheduler';
|
||||||
export * from './garden';
|
export * from './garden';
|
||||||
|
export * from './memory';
|
||||||
export type { SimState } from './state';
|
export type { SimState } from './state';
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user