feat(02-02): sim/garden — types, plants table, growth state machine, plantSeed

- src/sim/garden/types.ts: Tile/PlantInstance/PlantType/PlantTypeId/GrowthStage interfaces; tileIdx(row,col) + tileCoords(idx) + emptyTiles() helpers (RESEARCH Pitfall 2 canonical row*4+col encoding)
- src/sim/garden/plants.ts: 3 Season-1 plants per D-03 (rosemary 600t / yarrow 900t / winter-rose 1500t — D-08/D-09 2–5min band) with placeholder tints (D-26)
- src/sim/garden/growth.ts: advanceGrowth() pure function — Sprout (0%) → Mature (33%) → Ready (100%); Math.max clamp on negative deltas defends Pitfall 1 system-clock rewind
- src/sim/garden/commands.ts: plantSeed (D-05 unlock-gate + occupied silent no-op + immutability) + simulateOneTick (BLOCKER 3 — writes tickCount, NEVER lastTickAt) + tileGrowthStage helper
- src/sim/garden/index.ts: barrel
- src/sim/index.ts: re-export garden barrel
- 25 new tests (11 growth + 14 commands) — all green; lint clean; build green
- ESLint sim-purity rule from Plan 02-01 confirms zero Date.now/setInterval call sites under src/sim/garden/
This commit is contained in:
2026-05-09 09:32:59 -04:00
parent 38535bac73
commit e82a11b988
8 changed files with 446 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import type { SimState } from '../state';
import { plantSeed, simulateOneTick, tileGrowthStage } from './commands';
import { emptyTiles, type Tile } from './types';
function freshSimState(overrides: Partial<SimState> = {}): SimState {
return {
garden: { tiles: emptyTiles() },
plants: [],
harvestedFragmentIds: [],
lastTickAt: 0,
tickCount: 0,
unlockedPlantTypes: ['rosemary'],
luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null },
offlineEvents: null,
settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: false },
...overrides,
};
}
describe('plantSeed (D-05 unlock gate, immutability, occupied-tile no-op)', () => {
it('plants on an empty tile and produces a new state (immutability)', () => {
const state = freshSimState();
const next = plantSeed(state, 0, 'rosemary', 100);
const nextTile = (next.garden.tiles as Tile[])[0];
expect(nextTile?.plant).toEqual({ plantTypeId: 'rosemary', plantedAtTick: 100 });
// Original state unchanged.
expect((state.garden.tiles as Tile[])[0]?.plant).toBeNull();
});
it('returns the SAME state reference when planting a locked plant type (D-05 silent no-op)', () => {
// unlockedPlantTypes = ['rosemary']; yarrow is locked at game start.
const state = freshSimState();
const next = plantSeed(state, 0, 'yarrow', 100);
expect(next).toBe(state);
});
it('returns the SAME state reference when the tile is occupied (silent no-op)', () => {
const state = freshSimState();
const after = plantSeed(state, 0, 'rosemary', 100);
const second = plantSeed(after, 0, 'rosemary', 200);
expect(second).toBe(after);
});
it('throws on out-of-range tileIdx (>= GRID_SIZE)', () => {
const state = freshSimState();
expect(() => plantSeed(state, 16, 'rosemary', 100)).toThrow(/Bad tile index/);
});
it('throws on negative tileIdx', () => {
const state = freshSimState();
expect(() => plantSeed(state, -1, 'rosemary', 100)).toThrow(/Bad tile index/);
});
it('does not modify other tiles', () => {
const state = freshSimState();
const next = plantSeed(state, 5, 'rosemary', 100);
const tiles = next.garden.tiles as Tile[];
for (let i = 0; i < 16; i++) {
if (i === 5) {
expect(tiles[i]?.plant?.plantTypeId).toBe('rosemary');
} else {
expect(tiles[i]?.plant).toBeNull();
}
}
});
});
describe('simulateOneTick (BLOCKER 3 — writes tickCount, NEVER lastTickAt)', () => {
it('increments tickCount by 1 even when no commands arrive', () => {
const state = freshSimState({ tickCount: 5 });
const next = simulateOneTick(state, 6, []);
expect(next.tickCount).toBe(6);
});
it('does NOT modify lastTickAt (BLOCKER 3 — saveSync owns that field)', () => {
const state = freshSimState({ lastTickAt: 1234, tickCount: 0 });
const next = simulateOneTick(state, 1, []);
expect(next.lastTickAt).toBe(1234);
});
it('applies a plantSeed command and increments tickCount', () => {
const state = freshSimState({ tickCount: 0 });
const next = simulateOneTick(state, 1, [
{ kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary' },
]);
expect((next.garden.tiles as Tile[])[0]?.plant?.plantTypeId).toBe('rosemary');
expect((next.garden.tiles as Tile[])[0]?.plant?.plantedAtTick).toBe(1);
expect(next.tickCount).toBe(1);
});
it('skips plantSeed commands without plantTypeId', () => {
const state = freshSimState({ tickCount: 0 });
const next = simulateOneTick(state, 1, [
{ kind: 'plantSeed', tileIdx: 0 },
]);
expect((next.garden.tiles as Tile[])[0]?.plant).toBeNull();
expect(next.tickCount).toBe(1);
});
it('ignores harvest/compost commands at this stage (Plan 02-03 wires them)', () => {
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();
});
it('applies multiple commands in order in a single tick', () => {
const state = freshSimState({ tickCount: 0 });
const next = simulateOneTick(state, 1, [
{ kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary' },
{ kind: 'plantSeed', tileIdx: 1, plantTypeId: 'rosemary' },
]);
expect((next.garden.tiles as Tile[])[0]?.plant?.plantTypeId).toBe('rosemary');
expect((next.garden.tiles as Tile[])[1]?.plant?.plantTypeId).toBe('rosemary');
expect(next.tickCount).toBe(1);
});
});
describe('tileGrowthStage', () => {
it('returns null for an empty tile', () => {
const tile: Tile = { idx: 0, plant: null };
expect(tileGrowthStage(tile, 100)).toBeNull();
});
it('returns the correct stage for a planted tile', () => {
const tile: Tile = { idx: 0, plant: { plantTypeId: 'rosemary', plantedAtTick: 0 } };
expect(tileGrowthStage(tile, 0)).toBe('sprout');
expect(tileGrowthStage(tile, 250)).toBe('mature');
expect(tileGrowthStage(tile, 600)).toBe('ready');
});
});
+86
View File
@@ -0,0 +1,86 @@
import type { SimState } from '../state';
import type { GardenCommand } from '../../store/garden-slice';
import { PLANT_TYPES } from './plants';
import type { GrowthStage, PlantInstance, PlantTypeId, Tile } from './types';
import { GRID_SIZE } from './types';
import { advanceGrowth } from './growth';
/**
* Pure command applications. Each returns a NEW SimState — no mutation.
* Time is INJECTED via currentTick. Per CORE-02 + sim-purity ESLint rule.
*
* Note on the type-only `GardenCommand` import: this is `import type`, so
* it is erased at compile time. CORE-10 forbids sim → render/ui imports;
* 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.
*/
export function plantSeed(
state: SimState,
tileIdx: number,
plantTypeId: PlantTypeId,
currentTick: number,
): SimState {
if (tileIdx < 0 || tileIdx >= GRID_SIZE) {
throw new Error(`Bad tile index: ${tileIdx}`);
}
const tiles = state.garden.tiles as Tile[];
const target = tiles[tileIdx];
if (target?.plant !== null && target?.plant !== undefined) {
// Tile occupied — silent no-op. Player tap on an occupied tile is a
// render-tier path (harvest/compost in Plan 02-03); the sim refuses
// to re-plant.
return state;
}
// Plant type must be unlocked (D-05 fragment-count thresholds; defaults
// to ['rosemary'] at game start via PhaserGame.tsx bootstrap).
if (!state.unlockedPlantTypes.includes(plantTypeId)) {
return state;
}
const plant: PlantInstance = { plantTypeId, plantedAtTick: currentTick };
const nextTiles: Tile[] = tiles.map((t, i) =>
i === tileIdx ? { idx: i, plant } : t,
);
return { ...state, garden: { tiles: nextTiles } };
}
/**
* Pure single-tick simulation. Drains pending commands, advances all plants.
* Per CORE-02 — fixed-timestep, deterministic from inputs.
*
* BLOCKER 3 invariant: the sim writes tickCount (sim-internal counter for
* 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).
*/
export function simulateOneTick(
state: SimState,
currentTick: number,
commands: GardenCommand[],
): SimState {
let next = state;
// Drain commands FIRST so state effects of new commands participate in
// this tick.
for (const cmd of commands) {
if (cmd.kind === 'plantSeed' && cmd.plantTypeId) {
next = plantSeed(next, cmd.tileIdx, cmd.plantTypeId as PlantTypeId, currentTick);
}
// Plan 02-03 will add 'harvest' and 'compost' branches here.
}
return { ...next, tickCount: next.tickCount + 1 };
}
/**
* Helper for renderers (read-only): given a Tile, what stage is its plant in?
* Pure; called from src/render/garden/plant-renderer.ts via injected currentTick.
*/
export function tileGrowthStage(tile: Tile, currentTick: number): GrowthStage | null {
if (!tile.plant) return null;
const type = PLANT_TYPES[tile.plant.plantTypeId];
if (!type) return null;
return advanceGrowth(tile.plant, type, currentTick);
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { advanceGrowth, GROWTH_THRESHOLDS } from './growth';
import { PLANT_TYPES } from './plants';
import type { PlantInstance } from './types';
const rosemary = PLANT_TYPES.rosemary;
const yarrow = PLANT_TYPES.yarrow;
const winterRose = PLANT_TYPES['winter-rose'];
function plant(plantedAtTick: number, plantTypeId: PlantInstance['plantTypeId'] = 'rosemary'): PlantInstance {
return { plantedAtTick, plantTypeId };
}
describe('advanceGrowth (D-08, D-09; pure function of currentTick + duration)', () => {
it('returns sprout at tick=plantedAtTick', () => {
expect(advanceGrowth(plant(0), rosemary, 0)).toBe('sprout');
});
it('returns sprout just below the 33% mature threshold', () => {
// 600 * 0.33 = 198. Tick 197 is below the threshold.
expect(advanceGrowth(plant(0), rosemary, 197)).toBe('sprout');
});
it('returns mature at the 33% threshold (≥, not >)', () => {
expect(advanceGrowth(plant(0), rosemary, 198)).toBe('mature');
});
it('returns mature just below the ready threshold', () => {
expect(advanceGrowth(plant(0), rosemary, 599)).toBe('mature');
});
it('returns ready at the duration boundary (100%)', () => {
expect(advanceGrowth(plant(0), rosemary, 600)).toBe('ready');
});
it('returns sprout when just planted (currentTick === plantedAtTick != 0)', () => {
expect(advanceGrowth(plant(100), rosemary, 100)).toBe('sprout');
});
it('clamps negative deltas to sprout (Pitfall 1 — system-clock rewind defense)', () => {
expect(advanceGrowth(plant(100), rosemary, 50)).toBe('sprout');
});
it('overgrowth stays at ready (no overflow stage)', () => {
expect(advanceGrowth(plant(0), rosemary, 100000)).toBe('ready');
});
it('respects per-plant duration — yarrow at 900 ticks is ready', () => {
// Yarrow 33% threshold = 297; 900 = ready.
expect(advanceGrowth(plant(0), yarrow, 296)).toBe('sprout');
expect(advanceGrowth(plant(0), yarrow, 297)).toBe('mature');
expect(advanceGrowth(plant(0), yarrow, 899)).toBe('mature');
expect(advanceGrowth(plant(0), yarrow, 900)).toBe('ready');
});
it('respects per-plant duration — winter-rose at 1500 ticks is ready', () => {
// 1500 * 0.33 = 495.
expect(advanceGrowth(plant(0), winterRose, 494)).toBe('sprout');
expect(advanceGrowth(plant(0), winterRose, 495)).toBe('mature');
expect(advanceGrowth(plant(0), winterRose, 1499)).toBe('mature');
expect(advanceGrowth(plant(0), winterRose, 1500)).toBe('ready');
});
it('GROWTH_THRESHOLDS is frozen (no accidental mutation)', () => {
expect(Object.isFrozen(GROWTH_THRESHOLDS)).toBe(true);
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { PlantInstance, PlantType, GrowthStage } from './types';
/**
* Sprout (0%) → Mature (33%) → Ready (≥100%). Per CONTEXT D-08/D-09.
*
* Pure function of (plantedAtTick, currentTick, durationTicks). Sim safety:
* no Date.now(), no DOM. The tick scheduler injects currentTick.
*
* Negative deltas (currentTick < plantedAtTick) are clamped to 0 so a
* just-planted plant always reports `'sprout'` even if a future caller
* passes an out-of-order tick (defends Pitfall 1 — system-clock rewinds).
*/
export const GROWTH_THRESHOLDS = Object.freeze({
matureFraction: 0.33,
readyFraction: 1.0,
});
export function advanceGrowth(
plant: PlantInstance,
plantType: PlantType,
currentTick: number,
): GrowthStage {
const ticksSincePlant = Math.max(0, currentTick - plant.plantedAtTick);
const progress = ticksSincePlant / plantType.durationTicks;
if (progress >= GROWTH_THRESHOLDS.readyFraction) return 'ready';
if (progress >= GROWTH_THRESHOLDS.matureFraction) return 'mature';
return 'sprout';
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Public barrel for src/sim/garden/. App code imports from here, never
* from the individual module files.
*/
export type { Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage } from './types';
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';
+46
View File
@@ -0,0 +1,46 @@
import type { PlantType, PlantTypeId } from './types';
/**
* Three Season-1 plants with tonal identity per the bible's
* "real species, slightly wrong" rule (CLAUDE.md "Tone").
*
* Names are placeholder pending writer review; player-visible display
* names actually come from /content/seasons/01-soil/ui-strings.yaml.
* Tonal register: rosemary (warm) / yarrow (contemplative) / winter-rose (heavy).
*
* Per D-08/D-09: durations vary within a 25min active-play band.
* rosemary → 600 ticks ≈ 2 min (the warm short one)
* yarrow → 900 ticks ≈ 3 min (medium contemplative)
* winter-rose → 1500 ticks ≈ 5 min (the heavy slow one)
*
* Tints are placeholders — Phase 3 swaps watercolor textures over these.
*/
export const PLANT_TYPES: Readonly<Record<PlantTypeId, PlantType>> = Object.freeze({
rosemary: {
id: 'rosemary',
fallbackName: 'Rosemary',
durationTicks: 600,
tints: { sprout: 0x8aa17a, mature: 0x5d7651, ready: 0xb6c7a8 },
fragmentTags: ['warm'],
},
yarrow: {
id: 'yarrow',
fallbackName: 'Yarrow',
durationTicks: 900,
tints: { sprout: 0xc8b89a, mature: 0xa39777, ready: 0xe8d8b6 },
fragmentTags: ['contemplative'],
},
'winter-rose': {
id: 'winter-rose',
fallbackName: 'Winter-rose',
durationTicks: 1500,
tints: { sprout: 0xa9a3b1, mature: 0x7d758a, ready: 0xc7bdd3 },
fragmentTags: ['heavy'],
},
});
export function getPlantType(id: PlantTypeId): PlantType {
const type = PLANT_TYPES[id];
if (!type) throw new Error(`Unknown plant type: ${id}`);
return type;
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Garden state shapes (CONTEXT D-01: 4×4 fixed grid; D-26: primitive shapes).
* Pure data; sim mutates these via pure-function commands. Per CORE-10
* firewall, this module is sim — no DOM, no React, no Phaser, no Date.now.
*
* Tile coordinate convention (RESEARCH Pitfall 2): canonical encoding
* tileIdx = row * GRID_COLS + col
* Always use the helpers; never inline the arithmetic.
*/
export const GRID_ROWS = 4;
export const GRID_COLS = 4;
export const GRID_SIZE = GRID_ROWS * GRID_COLS; // 16
export type GrowthStage = 'sprout' | 'mature' | 'ready';
export type PlantTypeId = 'rosemary' | 'yarrow' | 'winter-rose'; // 3 Season-1 plants per D-03
export interface PlantInstance {
plantTypeId: PlantTypeId;
/** Tick number, NOT wall time — per CORE-02 / BLOCKER 3. */
plantedAtTick: number;
}
export interface Tile {
/** 0..15 inclusive. */
idx: number;
/** null = empty. */
plant: PlantInstance | null;
}
export interface PlantType {
id: PlantTypeId;
/**
* Display name (player-visible). The runtime source is
* /content/seasons/01-soil/ui-strings.yaml; this string here is a
* fallback for build-only test fixtures and should never appear in
* production UI (the SeedPicker reads from uiStrings).
*/
fallbackName: string;
/** Growth duration in ticks (TICK_MS=200; 1500 ticks = 5 min). Per D-08/D-09. */
durationTicks: number;
/** Phaser tint hex per growth stage (D-26). */
tints: { sprout: number; mature: number; ready: number };
/** Fragment pool subset filter for MEMR-06 (Plan 02-03 wires this). */
fragmentTags: readonly string[];
}
export function tileIdx(row: number, col: number): number {
if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) {
throw new Error(`Tile out of range: row=${row} col=${col}`);
}
return row * GRID_COLS + col;
}
export function tileCoords(idx: number): { row: number; col: number } {
if (idx < 0 || idx >= GRID_SIZE) {
throw new Error(`Tile index out of range: ${idx}`);
}
return { row: Math.floor(idx / GRID_COLS), col: idx % GRID_COLS };
}
/**
* Build a fresh empty 16-tile grid. Pure helper; used by the initial sim
* state hydration path and by tests.
*/
export function emptyTiles(): Tile[] {
const out: Tile[] = [];
for (let i = 0; i < GRID_SIZE; i++) {
out.push({ idx: i, plant: null });
}
return out;
}
+1
View File
@@ -10,4 +10,5 @@
export * from './numbers';
export * from './scheduler';
export * from './garden';
export type { SimState } from './state';