From e82a11b98839529e3b355eb48b8d7bebd1fb9bef Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 9 May 2026 09:32:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(02-02):=20sim/garden=20=E2=80=94=20types,?= =?UTF-8?q?=20plants=20table,=20growth=20state=20machine,=20plantSeed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/ --- src/sim/garden/commands.test.ts | 136 ++++++++++++++++++++++++++++++++ src/sim/garden/commands.ts | 86 ++++++++++++++++++++ src/sim/garden/growth.test.ts | 67 ++++++++++++++++ src/sim/garden/growth.ts | 28 +++++++ src/sim/garden/index.ts | 9 +++ src/sim/garden/plants.ts | 46 +++++++++++ src/sim/garden/types.ts | 73 +++++++++++++++++ src/sim/index.ts | 1 + 8 files changed, 446 insertions(+) create mode 100644 src/sim/garden/commands.test.ts create mode 100644 src/sim/garden/commands.ts create mode 100644 src/sim/garden/growth.test.ts create mode 100644 src/sim/garden/growth.ts create mode 100644 src/sim/garden/index.ts create mode 100644 src/sim/garden/plants.ts create mode 100644 src/sim/garden/types.ts diff --git a/src/sim/garden/commands.test.ts b/src/sim/garden/commands.test.ts new file mode 100644 index 0000000..9e1ce95 --- /dev/null +++ b/src/sim/garden/commands.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/sim/garden/commands.ts b/src/sim/garden/commands.ts new file mode 100644 index 0000000..3519b24 --- /dev/null +++ b/src/sim/garden/commands.ts @@ -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); +} diff --git a/src/sim/garden/growth.test.ts b/src/sim/garden/growth.test.ts new file mode 100644 index 0000000..fadd562 --- /dev/null +++ b/src/sim/garden/growth.test.ts @@ -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); + }); +}); diff --git a/src/sim/garden/growth.ts b/src/sim/garden/growth.ts new file mode 100644 index 0000000..873080a --- /dev/null +++ b/src/sim/garden/growth.ts @@ -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'; +} diff --git a/src/sim/garden/index.ts b/src/sim/garden/index.ts new file mode 100644 index 0000000..c6975b4 --- /dev/null +++ b/src/sim/garden/index.ts @@ -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'; diff --git a/src/sim/garden/plants.ts b/src/sim/garden/plants.ts new file mode 100644 index 0000000..8c8a8fa --- /dev/null +++ b/src/sim/garden/plants.ts @@ -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 2–5min 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> = 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; +} diff --git a/src/sim/garden/types.ts b/src/sim/garden/types.ts new file mode 100644 index 0000000..17ed82e --- /dev/null +++ b/src/sim/garden/types.ts @@ -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; +} diff --git a/src/sim/index.ts b/src/sim/index.ts index 505bee5..f2c227e 100644 --- a/src/sim/index.ts +++ b/src/sim/index.ts @@ -10,4 +10,5 @@ export * from './numbers'; export * from './scheduler'; +export * from './garden'; export type { SimState } from './state';