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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user