# M1 PR1 — Foundations Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship PR1 (`feat/m1-foundations`): hardened tick/offline determinism, expanded content schemas (costs, unlocks, multi-yield), action queue engine, stub M1 content pack, and save persistence for the queue — closing Gitea #3 and #4. **Architecture:** Extend the pure `src/engine/game.ts` state machine with `actionQueue` and completion-driven advancement (actions no longer auto-repeat; queue drains on completion). Content validation stays in `src/content/schema.ts` via Zod; costs deduct on **start** per D-0012. Save v1 schema grows to include `actionQueue` (formal v1→v2 migration waits for PR4). Runtime exposes `enqueueAction` for manual playtest; full queue UI is PR2. **Tech Stack:** TypeScript strict, Vitest, Zod 4, Biome, pnpm. No React in engine. **Parent spec:** `docs/plans/2026-06-11-m1-vertical-slice.md` (PR1 section). **Branch:** `feat/m1-foundations` off `main`. --- ## File map | File | Responsibility | |---|---| | `src/engine/game.ts` | `GameState` + queue, affordability, unlock checks, `enqueueAction`, completion handler | | `src/engine/__tests__/game.test.ts` | Queue, costs, unlocks, completion advancement | | `src/engine/__tests__/determinism.test.ts` | **Create** — tick loop + game integration determinism | | `src/engine/__tests__/purity.test.ts` | **Create** — engine must not import React/DOM | | `src/engine/save.ts` | Extend `gameStateSchema` with `actionQueue` | | `src/engine/__tests__/save.test.ts` | Round-trip queue in saves | | `src/content/schema.ts` | Costs, unlocks, multi-yield yields | | `src/content/__tests__/schema.test.ts` | Schema validation cases | | `src/content/definitions.ts` | Stub M1 content (2 resources, 4–5 actions) | | `src/state/persistence.ts` | Hydrate `actionQueue` on load | | `src/state/__tests__/persistence.test.ts` | Queue survives save/load | | `src/state/runtime.ts` | `enqueueAction` command (replaces direct `startAction` use) | | `src/state/viewModel.ts` | Expose `queuedActionIds` for minimal visibility | | `src/state/__tests__/viewModel.test.ts` | View model queue field | | `docs/architecture.md` | Note queue + completion semantics | --- ### Task 1: Determinism integration test **Files:** - Create: `src/engine/__tests__/determinism.test.ts` - [ ] **Step 1: Write the failing test** ```typescript import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; import { createGameState, enqueueAction } from '../game'; import { advance, createTickLoop, TICK_MS } from '../tickLoop'; function testContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ { id: 'forage', name: 'Forage', durationMs: 300, yields: [{ resourceId: 'gold', amount: 1 }], }, ], }); } function simulate(elapsedMs: number, chunkMs: number) { const content = testContent(); const state = createGameState(content); enqueueAction(state, content, 'forage'); const loop = createTickLoop({ tickMs: TICK_MS, startNow: 0 }); let now = 0; while (now < elapsedMs) { const next = Math.min(now + chunkMs, elapsedMs); advance(loop, next, () => { // tickGame imported once Task 6 lands; for now this file won't compile — that's the red step. }); now = next; } return { tickCount: loop.tickCount, gold: state.resources.gold }; } describe('determinism integration', () => { it('produces identical tick counts and game state regardless of advance chunking', () => { const whole = simulate(5000, 5000); const chunked = simulate(5000, 37); expect(chunked.tickCount).toBe(whole.tickCount); expect(chunked.gold).toBe(whole.gold); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm test src/engine/__tests__/determinism.test.ts` Expected: FAIL — `enqueueAction` not exported / `tickGame` not wired. - [ ] **Step 3: Wire `tickGame` into the test (minimal fix for this task)** Replace the `advance` callback body with: ```typescript import { tickGame } from '../game'; // inside advance callback: tickGame(state, content, TICK_MS); ``` Re-run after Task 6 exports `enqueueAction`. **This task completes when the determinism test passes after Task 6–7 land.** If implementing in order, leave this test file created but skipped (`it.skip`) until Task 7, then unskip. - [ ] **Step 4: Run test to verify it passes** Run: `pnpm test src/engine/__tests__/determinism.test.ts` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/engine/__tests__/determinism.test.ts git commit -m "test(engine): add tick loop + game determinism integration test" ``` --- ### Task 2: Engine purity guard **Files:** - Create: `src/engine/__tests__/purity.test.ts` - [ ] **Step 1: Write the failing test** ```typescript import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; const ENGINE_DIR = join(import.meta.dirname, '..'); const FORBIDDEN = [/from\s+['"]react/, /from\s+['"]react-dom/, /from\s+['"]zustand/]; async function engineSourceFiles(): Promise { const entries = await readdir(ENGINE_DIR, { withFileTypes: true }); return entries .filter((e) => e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.test.ts')) .map((e) => join(ENGINE_DIR, e.name)); } describe('engine purity', () => { it('does not import React, react-dom, or Zustand', async () => { const files = await engineSourceFiles(); expect(files.length).toBeGreaterThan(0); for (const file of files) { const source = await readFile(file, 'utf8'); for (const pattern of FORBIDDEN) { expect(source, `${file} must stay free of ${pattern}`).not.toMatch(pattern); } } }); }); ``` - [ ] **Step 2: Run test to verify it passes immediately** Run: `pnpm test src/engine/__tests__/purity.test.ts` Expected: PASS (M0 engine is already pure) - [ ] **Step 3: Commit** ```bash git add src/engine/__tests__/purity.test.ts git commit -m "test(engine): guard against React/DOM imports in engine" ``` --- ### Task 3: Document offline behavior **Files:** - Modify: `src/engine/tickLoop.ts` (module doc comment) - Modify: `src/engine/save.ts` (module doc comment) - [ ] **Step 1: Extend tickLoop module doc** At the top of `src/engine/tickLoop.ts`, ensure the module comment includes: ```typescript /** * ... * Offline catch-up uses this same path: a large `now` delta owes many ticks. * `maxTicks` caps work per `advance()` call; the accumulator retains the * remainder so no ticks are lost across calls. * * The save layer (`save.ts`) separately clamps how much *wall-clock* elapsed * time is credited on load via `DEFAULT_MAX_OFFLINE_MS`. Tick loop batching * and offline credit clamping are independent concerns. */ ``` - [ ] **Step 2: Extend save module doc** At the top of `src/engine/save.ts`, add after the existing paragraph: ```typescript /** * ... * Offline credit: `applyOfflineProgress` floors elapsed ms to whole ticks * (same `TICK_MS` as the live loop) and runs `tickGame` that many times. * Elapsed beyond `DEFAULT_MAX_OFFLINE_MS` is not credited. */ ``` - [ ] **Step 3: Commit** ```bash git add src/engine/tickLoop.ts src/engine/save.ts git commit -m "docs(engine): clarify offline tick batching vs save-layer clamp" ``` --- ### Task 4: Content schema — multi-yield and costs **Files:** - Modify: `src/content/schema.ts` - Modify: `src/content/__tests__/schema.test.ts` - [ ] **Step 1: Write the failing tests** Add to `src/content/__tests__/schema.test.ts`: ```typescript describe('costs and multi-yield', () => { it('accepts optional costs and multiple yields', () => { const content = buildContent({ resources: [ { id: 'supplies', name: 'Supplies', startAmount: 10 }, { id: 'coin', name: 'Coin' }, ], actions: [ { id: 'trade', name: 'Trade', durationMs: 4000, costs: [{ resourceId: 'supplies', amount: 3 }], yields: [ { resourceId: 'coin', amount: 2 }, { resourceId: 'supplies', amount: 1 }, ], }, ], }); expect(content.actionsById.trade.costs).toHaveLength(1); expect(content.actionsById.trade.yields).toHaveLength(2); }); it('rejects a cost referencing an unknown resource', () => { expect(() => buildContent({ resources: [{ id: 'gold', name: 'Gold' }], actions: [ { id: 'buy', name: 'Buy', durationMs: 1000, costs: [{ resourceId: 'ghost', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }], }, ], }), ).toThrow(/unknown resource/i); }); it('defaults costs to an empty array and yields to at least one entry', () => { const content = buildContent({ resources: [{ id: 'gold', name: 'Gold' }], actions: [{ id: 'forage', name: 'Forage', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }], }); expect(content.actionsById.forage.costs).toEqual([]); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `pnpm test src/content/__tests__/schema.test.ts` Expected: FAIL — `costs` / array `yields` not in schema. - [ ] **Step 3: Implement schema changes** Replace `actionDefSchema` and `buildContent` validation in `src/content/schema.ts`: ```typescript export const resourceAmountSchema = z.object({ resourceId: z.string().min(1), amount: z.number().positive(), }); export const actionDefSchema = z.object({ id: z.string().min(1), name: z.string().min(1), durationMs: z.number().positive(), costs: z.array(resourceAmountSchema).default([]), yields: z.array(resourceAmountSchema).min(1), }); export type ResourceAmount = z.infer; ``` In `buildContent`, after the yields resource check, add costs check: ```typescript for (const action of actions) { for (const cost of action.costs) { if (!resourcesById[cost.resourceId]) { throw new Error( `Action "${action.id}" costs unknown resource "${cost.resourceId}"`, ); } } for (const y of action.yields) { if (!resourcesById[y.resourceId]) { throw new Error( `Action "${action.id}" yields unknown resource "${y.resourceId}"`, ); } } } ``` Remove the old single-object `yields` schema. - [ ] **Step 4: Update existing tests and definitions to array yields** In `src/content/__tests__/schema.test.ts`, change `validActions` yields to array form: ```typescript const validActions = [ { id: 'forage', name: 'Forage', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], }, ]; ``` In `src/content/definitions.ts`: ```typescript export const actionDefs = [ { id: 'forage', name: 'Forage for coin', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }], }, ]; ``` Update every `buildContent` call in engine tests similarly (grep `yields:`). - [ ] **Step 5: Run tests to verify they pass** Run: `pnpm test src/content/__tests__/schema.test.ts` Expected: PASS - [ ] **Step 6: Commit** ```bash git add src/content/schema.ts src/content/__tests__/schema.test.ts src/content/definitions.ts git commit -m "feat(content): add action costs and multi-yield schema" ``` --- ### Task 5: Content schema — unlock conditions **Files:** - Modify: `src/content/schema.ts` - Modify: `src/content/__tests__/schema.test.ts` - [ ] **Step 1: Write the failing tests** ```typescript describe('unlock conditions', () => { it('accepts optional minResources and requireStoryFlags', () => { const content = buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], actions: [ { id: 'scout', name: 'Scout', durationMs: 5000, yields: [{ resourceId: 'gold', amount: 1 }], unlock: { minResources: { gold: 5 }, requireStoryFlags: ['path_scouted'], }, }, ], }); expect(content.actionsById.scout.unlock?.minResources?.gold).toBe(5); expect(content.actionsById.scout.unlock?.requireStoryFlags).toEqual(['path_scouted']); }); it('defaults unlock to undefined when omitted', () => { const content = buildContent({ resources: [{ id: 'gold', name: 'Gold' }], actions: [ { id: 'forage', name: 'Forage', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); expect(content.actionsById.forage.unlock).toBeUndefined(); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `pnpm test src/content/__tests__/schema.test.ts -t "unlock"` Expected: FAIL - [ ] **Step 3: Implement unlock schema** Add to `src/content/schema.ts`: ```typescript export const unlockDefSchema = z.object({ minResources: z.record(z.string(), z.number().nonnegative()).optional(), requireStoryFlags: z.array(z.string().min(1)).optional(), }); export const actionDefSchema = z.object({ id: z.string().min(1), name: z.string().min(1), durationMs: z.number().positive(), costs: z.array(resourceAmountSchema).default([]), yields: z.array(resourceAmountSchema).min(1), unlock: unlockDefSchema.optional(), }); export type UnlockDef = z.infer; ``` - [ ] **Step 4: Run tests to verify they pass** Run: `pnpm test src/content/__tests__/schema.test.ts` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/content/schema.ts src/content/__tests__/schema.test.ts git commit -m "feat(content): add action unlock condition schema" ``` --- ### Task 6: GameState queue field and enqueue API **Files:** - Modify: `src/engine/game.ts` - Modify: `src/engine/__tests__/game.test.ts` - [ ] **Step 1: Write the failing tests** Add to `src/engine/__tests__/game.test.ts` (update `testContent` yields to array form first): ```typescript import { cancelQueuedAction, clearQueue, enqueueAction } from '../game'; function queueContent() { return buildContent({ resources: [{ id: 'gold', name: 'Gold', startAmount: 10 }], actions: [ { id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, { id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, { id: 'c', name: 'C', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); } describe('enqueueAction()', () => { it('starts immediately when idle', () => { const content = queueContent(); const state = createGameState(content); enqueueAction(state, content, 'a'); expect(state.activeActionId).toBe('a'); expect(state.actionQueue).toEqual([]); }); it('queues when another action is active', () => { const content = queueContent(); const state = createGameState(content); enqueueAction(state, content, 'a'); enqueueAction(state, content, 'b'); enqueueAction(state, content, 'c'); expect(state.activeActionId).toBe('a'); expect(state.actionQueue).toEqual(['b', 'c']); }); it('throws on unknown action id', () => { const content = queueContent(); const state = createGameState(content); expect(() => enqueueAction(state, content, 'nope')).toThrow(/unknown action/i); }); }); describe('cancelQueuedAction()', () => { it('removes a queued action by index', () => { const content = queueContent(); const state = createGameState(content); enqueueAction(state, content, 'a'); enqueueAction(state, content, 'b'); enqueueAction(state, content, 'c'); cancelQueuedAction(state, 0); expect(state.actionQueue).toEqual(['c']); }); }); describe('clearQueue()', () => { it('empties the queue without stopping the active action', () => { const content = queueContent(); const state = createGameState(content); enqueueAction(state, content, 'a'); enqueueAction(state, content, 'b'); clearQueue(state); expect(state.activeActionId).toBe('a'); expect(state.actionQueue).toEqual([]); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `pnpm test src/engine/__tests__/game.test.ts -t "enqueueAction|cancelQueuedAction|clearQueue"` Expected: FAIL — exports missing. - [ ] **Step 3: Implement queue fields and APIs** In `src/engine/game.ts`: ```typescript export interface GameState { resources: Record; activeActionId: string | null; actionElapsedMs: number; actionQueue: string[]; } export function createGameState(content: Content): GameState { const resources: Record = {}; for (const resource of content.resources) { resources[resource.id] = resource.startAmount; } return { resources, activeActionId: null, actionElapsedMs: 0, actionQueue: [] }; } function assertKnownAction(content: Content, actionId: string) { if (!content.actionsById[actionId]) { throw new Error(`Unknown action "${actionId}"`); } } /** @deprecated Prefer enqueueAction — kept for tests migrating incrementally */ export function startAction(state: GameState, content: Content, actionId: string): void { assertKnownAction(content, actionId); state.activeActionId = actionId; state.actionElapsedMs = 0; } export function enqueueAction(state: GameState, content: Content, actionId: string): void { assertKnownAction(content, actionId); if (!state.activeActionId) { state.activeActionId = actionId; state.actionElapsedMs = 0; } else { state.actionQueue.push(actionId); } } export function cancelQueuedAction(state: GameState, index: number): void { if (index < 0 || index >= state.actionQueue.length) { throw new RangeError(`Queue index out of range: ${index}`); } state.actionQueue.splice(index, 1); } export function clearQueue(state: GameState): void { state.actionQueue = []; } ``` Update `createGameState` tests to expect `actionQueue: []`. - [ ] **Step 4: Run tests to verify they pass** Run: `pnpm test src/engine/__tests__/game.test.ts -t "enqueueAction|cancelQueuedAction|clearQueue"` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/engine/game.ts src/engine/__tests__/game.test.ts git commit -m "feat(engine): add action queue state and enqueue APIs" ``` --- ### Task 7: Costs, unlocks, and completion-driven queue advancement **Files:** - Modify: `src/engine/game.ts` - Modify: `src/engine/__tests__/game.test.ts` - Modify: `src/engine/__tests__/determinism.test.ts` (unskip if skipped) - [ ] **Step 1: Write the failing tests** ```typescript import { canAffordAction, canUnlockAction, enqueueAction, isActionAvailable, tickGame, } from '../game'; function costContent() { return buildContent({ resources: [ { id: 'supplies', name: 'Supplies', startAmount: 10 }, { id: 'coin', name: 'Coin', startAmount: 0 }, ], actions: [ { id: 'gather', name: 'Gather', durationMs: 300, yields: [{ resourceId: 'supplies', amount: 2 }], }, { id: 'trade', name: 'Trade', durationMs: 300, costs: [{ resourceId: 'supplies', amount: 5 }], yields: [{ resourceId: 'coin', amount: 3 }], }, { id: 'scout', name: 'Scout', durationMs: 300, yields: [{ resourceId: 'coin', amount: 1 }], unlock: { minResources: { coin: 1 } }, }, ], }); } describe('costs on start (D-0012)', () => { it('deducts costs when an action becomes active', () => { const content = costContent(); const state = createGameState(content); enqueueAction(state, content, 'trade'); expect(state.resources.supplies).toBe(5); }); it('rejects enqueue when unaffordable', () => { const content = costContent(); const state = createGameState(content); state.resources.supplies = 2; expect(() => enqueueAction(state, content, 'trade')).toThrow(/cannot enqueue/i); }); }); describe('unlock conditions', () => { it('rejects locked actions', () => { const content = costContent(); const state = createGameState(content); expect(() => enqueueAction(state, content, 'scout')).toThrow(/cannot enqueue/i); }); it('allows actions once unlock thresholds are met', () => { const content = costContent(); const state = createGameState(content); state.resources.coin = 1; enqueueAction(state, content, 'scout'); expect(state.activeActionId).toBe('scout'); }); }); describe('completion advances queue', () => { it('starts the next queued action after the active one completes', () => { const content = costContent(); const state = createGameState(content); enqueueAction(state, content, 'gather'); enqueueAction(state, content, 'gather'); tickGame(state, content, 300); expect(state.activeActionId).toBe('gather'); expect(state.actionQueue).toEqual([]); expect(state.resources.supplies).toBe(12); }); it('goes idle when the queue is empty after completion', () => { const content = costContent(); const state = createGameState(content); enqueueAction(state, content, 'gather'); tickGame(state, content, 300); expect(state.activeActionId).toBeNull(); expect(state.actionElapsedMs).toBe(0); }); it('grants all yields on completion', () => { const content = buildContent({ resources: [ { id: 'a', name: 'A', startAmount: 0 }, { id: 'b', name: 'B', startAmount: 0 }, ], actions: [ { id: 'combo', name: 'Combo', durationMs: 100, yields: [ { resourceId: 'a', amount: 2 }, { resourceId: 'b', amount: 3 }, ], }, ], }); const state = createGameState(content); enqueueAction(state, content, 'combo'); tickGame(state, content, 100); expect(state.resources.a).toBe(2); expect(state.resources.b).toBe(3); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `pnpm test src/engine/__tests__/game.test.ts -t "costs on start|unlock|completion advances"` Expected: FAIL - [ ] **Step 3: Implement affordability, unlock, and new tickGame completion semantics** Replace `tickGame` and add helpers in `src/engine/game.ts`: ```typescript export function canAffordAction(state: GameState, content: Content, actionId: string): boolean { const action = content.actionsById[actionId]; if (!action) return false; return action.costs.every((cost) => (state.resources[cost.resourceId] ?? 0) >= cost.amount); } /** Story flags land in PR2; placeholder field keeps unlock schema honest. */ export function canUnlockAction( state: GameState, content: Content, actionId: string, storyFlags: Record = {}, ): boolean { const action = content.actionsById[actionId]; if (!action) return false; const unlock = action.unlock; if (!unlock) return true; if (unlock.minResources) { for (const [resourceId, min] of Object.entries(unlock.minResources)) { if ((state.resources[resourceId] ?? 0) < min) return false; } } if (unlock.requireStoryFlags) { for (const flag of unlock.requireStoryFlags) { if (!storyFlags[flag]) return false; } } return true; } export function isActionAvailable( state: GameState, content: Content, actionId: string, storyFlags: Record = {}, ): boolean { return canAffordAction(state, content, actionId) && canUnlockAction(state, content, actionId, storyFlags); } function deductCosts(state: GameState, content: Content, actionId: string): void { const action = content.actionsById[actionId]; if (!action) return; for (const cost of action.costs) { state.resources[cost.resourceId] -= cost.amount; } } function grantYields(state: GameState, content: Content, actionId: string): void { const action = content.actionsById[actionId]; if (!action) return; for (const y of action.yields) { state.resources[y.resourceId] = (state.resources[y.resourceId] ?? 0) + y.amount; } } function beginAction(state: GameState, content: Content, actionId: string): void { assertKnownAction(content, actionId); deductCosts(state, content, actionId); state.activeActionId = actionId; state.actionElapsedMs = 0; } function startNextFromQueue(state: GameState, content: Content): void { while (state.actionQueue.length > 0) { const nextId = state.actionQueue.shift()!; if (isActionAvailable(state, content, nextId)) { beginAction(state, content, nextId); return; } } state.activeActionId = null; state.actionElapsedMs = 0; } function completeActiveAction(state: GameState, content: Content): void { const actionId = state.activeActionId; if (!actionId) return; grantYields(state, content, actionId); startNextFromQueue(state, content); } export function enqueueAction(state: GameState, content: Content, actionId: string): void { assertKnownAction(content, actionId); if (!isActionAvailable(state, content, actionId)) { throw new Error(`Cannot enqueue action "${actionId}"`); } if (!state.activeActionId) { beginAction(state, content, actionId); } else { state.actionQueue.push(actionId); } } export function tickGame(state: GameState, content: Content, tickMs: number): void { if (!state.activeActionId) return; const action = content.actionsById[state.activeActionId]; if (!action) return; state.actionElapsedMs += tickMs; while (state.actionElapsedMs >= action.durationMs) { state.actionElapsedMs -= action.durationMs; completeActiveAction(state, content); if (!state.activeActionId) return; const next = content.actionsById[state.activeActionId]; if (!next) return; if (state.actionElapsedMs < next.durationMs) return; } } ``` Update existing `tickGame` tests: they used auto-repeat — change expectations so one completion goes idle unless re-enqueued. Example fix for `'grants the yield on completion and repeats'` test — rename to `'grants the yield on completion then goes idle'`: ```typescript tickGame(state, content, 300); expect(state.resources.gold).toBe(7); expect(state.activeActionId).toBeNull(); ``` Remove or rewrite the multi-completion-in-one-tick test to enqueue once then tick large: ```typescript enqueueAction(state, content, 'forage'); tickGame(state, content, 1000); expect(state.resources.gold).toBe(7); // one completion only expect(state.activeActionId).toBeNull(); ``` - [ ] **Step 4: Run all engine tests** Run: `pnpm test src/engine/__tests__/game.test.ts src/engine/__tests__/determinism.test.ts` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/engine/game.ts src/engine/__tests__/game.test.ts src/engine/__tests__/determinism.test.ts git commit -m "feat(engine): costs on start, unlock checks, queue completion advancement" ``` --- ### Task 8: Save schema — persist actionQueue **Files:** - Modify: `src/engine/save.ts` - Modify: `src/engine/__tests__/save.test.ts` - Modify: `src/state/persistence.ts` - Modify: `src/state/__tests__/persistence.test.ts` - [ ] **Step 1: Write the failing tests** In `src/engine/__tests__/save.test.ts`, update `sampleState` and add: ```typescript it('snapshots actionQueue in the save payload', () => { const state = sampleState(); state.actionQueue = ['b', 'c']; const save = createSave(state, 1700); expect(save.state.actionQueue).toEqual(['b', 'c']); }); ``` In `src/state/__tests__/persistence.test.ts`, add: ```typescript it('restores actionQueue on load', async () => { const content = buildContent({ resources: [{ id: 'gold', name: 'Gold' }], actions: [ { id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, { id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); const backend = createMemoryBackend(); const state = createGameState(content); enqueueAction(state, content, 'a'); enqueueAction(state, content, 'b'); await saveGame(state, backend, 1000); const loaded = await loadGame(content, backend, 1000); expect(loaded.state.actionQueue).toEqual(['b']); expect(loaded.state.activeActionId).toBe('a'); }); ``` Import `enqueueAction` from `../engine/game` in persistence test. - [ ] **Step 2: Run tests to verify they fail** Run: `pnpm test src/engine/__tests__/save.test.ts src/state/__tests__/persistence.test.ts -t "actionQueue"` Expected: FAIL - [ ] **Step 3: Extend save schema and persistence hydrate** In `src/engine/save.ts`: ```typescript export const gameStateSchema = z.object({ resources: z.record(z.string(), z.number()), activeActionId: z.string().nullable(), actionElapsedMs: z.number().nonnegative(), actionQueue: z.array(z.string()).default([]), }); ``` In `createSave`: ```typescript state: { resources: { ...state.resources }, activeActionId: state.activeActionId, actionElapsedMs: state.actionElapsedMs, actionQueue: [...state.actionQueue], }, ``` In `src/state/persistence.ts` `loadGame`, extend hydration: ```typescript state = { resources: { ...base.resources, ...save.state.resources }, activeActionId, actionElapsedMs: save.state.actionElapsedMs, actionQueue: [...(save.state.actionQueue ?? [])], }; ``` - [ ] **Step 4: Run tests to verify they pass** Run: `pnpm test src/engine/__tests__/save.test.ts src/state/__tests__/persistence.test.ts` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/engine/save.ts src/engine/__tests__/save.test.ts src/state/persistence.ts src/state/__tests__/persistence.test.ts git commit -m "feat(save): persist action queue in v1 save payload" ``` --- ### Task 9: Stub M1 content pack **Files:** - Modify: `src/content/definitions.ts` - Create: `src/content/__tests__/definitions.test.ts` - [ ] **Step 1: Write the failing test** ```typescript import { describe, expect, it } from 'vitest'; import { content } from '../index'; describe('M1 stub content pack', () => { it('defines two resources and four to five actions with costs and unlocks', () => { expect(content.resources).toHaveLength(2); expect(content.actions.length).toBeGreaterThanOrEqual(4); expect(content.actions.length).toBeLessThanOrEqual(5); const withCosts = content.actions.filter((a) => a.costs.length > 0); const withUnlocks = content.actions.filter((a) => a.unlock !== undefined); expect(withCosts.length).toBeGreaterThanOrEqual(2); expect(withUnlocks.length).toBeGreaterThanOrEqual(1); }); it('can simulate a costed action without throwing', () => { const { createGameState, enqueueAction, tickGame } = await import('../../engine/game'); const state = createGameState(content); const trade = content.actions.find((a) => a.costs.length > 0); expect(trade).toBeDefined(); enqueueAction(state, content, trade!.id); tickGame(state, content, trade!.durationMs); expect(state.activeActionId).toBeNull(); }); }); ``` Fix: use static import, not dynamic: ```typescript import { createGameState, enqueueAction, tickGame } from '../../engine/game'; ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm test src/content/__tests__/definitions.test.ts` Expected: FAIL — only one resource/action today. - [ ] **Step 3: Replace definitions with stub pack** In `src/content/definitions.ts`: ```typescript export const resourceDefs = [ { id: 'supplies', name: 'Supplies', startAmount: 10 }, { id: 'coin', name: 'Coin', startAmount: 0 }, ]; export const actionDefs = [ { id: 'gather_supplies', name: 'Gather supplies', durationMs: 3000, yields: [{ resourceId: 'supplies', amount: 2 }], }, { id: 'scout_path', name: 'Scout the path', durationMs: 5000, costs: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'coin', amount: 1 }], }, { id: 'trade_supplies', name: 'Trade at camp', durationMs: 4000, costs: [{ resourceId: 'supplies', amount: 3 }], yields: [{ resourceId: 'coin', amount: 2 }], unlock: { minResources: { coin: 1 } }, }, { id: 'fortify_camp', name: 'Fortify camp', durationMs: 8000, costs: [ { resourceId: 'supplies', amount: 5 }, { resourceId: 'coin', amount: 2 }, ], yields: [{ resourceId: 'supplies', amount: 4 }], unlock: { minResources: { supplies: 8 } }, }, { id: 'rest', name: 'Rest briefly', durationMs: 2000, yields: [{ resourceId: 'supplies', amount: 1 }], }, ]; ``` - [ ] **Step 4: Run tests to verify they pass** Run: `pnpm test src/content/__tests__/definitions.test.ts` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/content/definitions.ts src/content/__tests__/definitions.test.ts git commit -m "feat(content): add M1 stub resource and action pack" ``` --- ### Task 10: Runtime enqueue bridge and view model **Files:** - Modify: `src/state/runtime.ts` - Modify: `src/state/viewModel.ts` - Modify: `src/state/__tests__/viewModel.test.ts` - Modify: `src/ui/ActionPanel.tsx` (minimal — call enqueue instead of start) - [ ] **Step 1: Write the failing view model test** In `src/state/__tests__/viewModel.test.ts`: ```typescript it('includes queued action ids in order', () => { const content = buildContent({ resources: [{ id: 'gold', name: 'Gold' }], actions: [ { id: 'a', name: 'Alpha', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, { id: 'b', name: 'Bravo', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, ], }); const state = createGameState(content); enqueueAction(state, content, 'a'); enqueueAction(state, content, 'b'); const view = toView(state, content); expect(view.queuedActionIds).toEqual(['b']); expect(view.queuedActionNames).toEqual(['Bravo']); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `pnpm test src/state/__tests__/viewModel.test.ts -t "queued"` Expected: FAIL - [ ] **Step 3: Implement view model + runtime + minimal UI** In `src/state/viewModel.ts`: ```typescript export interface GameView { resources: ResourceView[]; activeActionId: string | null; actionName: string | null; actionProgress: number; queuedActionIds: string[]; queuedActionNames: string[]; } export function toView(state: GameState, content: Content): GameView { // ...existing fields... const queuedActionIds = [...state.actionQueue]; const queuedActionNames = queuedActionIds.map( (id) => content.actionsById[id]?.name ?? id, ); return { resources, activeActionId: state.activeActionId, actionName: action ? action.name : null, actionProgress, queuedActionIds, queuedActionNames, }; } ``` In `src/state/runtime.ts`, replace `startAction` with: ```typescript import { enqueueAction as engineEnqueueAction } from '../engine/game'; enqueueAction(actionId: string): void { const state = this.state; if (!state) return; try { engineEnqueueAction(state, content, actionId); const action = content.actionsById[actionId]; if (action) { const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started'; useGameStore.getState().appendLog(`${verb}: ${action.name}.`); } } catch (err) { const msg = err instanceof Error ? err.message : 'Cannot start action'; useGameStore.getState().appendLog(msg); } this.publish(); } ``` Rename public method from `startAction` to `enqueueAction` and update `ActionPanel.tsx`: ```typescript onClick={() => gameRuntime.enqueueAction(action.id)} ``` - [ ] **Step 4: Run tests and typecheck** Run: `pnpm test src/state/__tests__/viewModel.test.ts && pnpm typecheck` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/state/runtime.ts src/state/viewModel.ts src/state/__tests__/viewModel.test.ts src/ui/ActionPanel.tsx git commit -m "feat(state): wire enqueueAction through runtime and view model" ``` --- ### Task 11: Architecture note and coverage gate **Files:** - Modify: `docs/architecture.md` - [ ] **Step 1: Update architecture doc** Under `game.ts` bullet in `docs/architecture.md`: ```markdown - `game.ts`: core game state, action queue, costs/unlocks on start, completion-driven queue advancement (actions do not auto-repeat when the queue is empty). ``` - [ ] **Step 2: Run full pre-PR verification chain** ```powershell pnpm typecheck pnpm lint pnpm test:coverage pnpm build ``` Expected: all green; `src/engine/` coverage ≥ 80%. - [ ] **Step 3: Manual playtest checklist** 1. `pnpm dev` — open app. 2. Click **Gather supplies** three times quickly → event log shows Started + 2× Queued. 3. Watch resources: costs deduct on start for costed actions; yields apply on completion. 4. Reload → queue and resources preserved. 5. Resize to mobile width — no regressions (full mobile pass is PR4). - [ ] **Step 4: Commit** ```bash git add docs/architecture.md git commit -m "docs: document action queue completion semantics" ``` --- ### Task 12: Open PR **Files:** none (git + Gitea) - [ ] **Step 1: Push branch** ```bash git push -u origin feat/m1-foundations ``` - [ ] **Step 2: Create PR** Title: `feat(m1): foundations — queue engine, costs, stub content` Body: ```markdown ## Summary - Hardens engine determinism and purity guards (#3) - Expands content schemas with costs, unlocks, multi-yield (#4) - Adds action queue with completion-driven advancement and save persistence - Ships stub M1 content pack (2 resources, 5 actions) ## Test plan - [x] `pnpm typecheck && pnpm lint && pnpm test:coverage && pnpm build` - [x] Enqueue 3 actions sequentially in dev UI - [x] Reload preserves queue + resources - [x] Engine coverage ≥ 80% Closes #3 Closes #4 ``` - [ ] **Step 3: Verify CI green on PR branch** Expected: Gitea Actions `CI / verify` passes. --- ## Self-review **Spec coverage (PR1 from parent plan):** | Requirement | Task | |---|---| | T1.1 determinism regression tests | Task 1 | | T1.1 offline behavior documented | Task 3 | | T1.1 engine purity guard | Task 2 | | T1.2 costs on start (D-0012) | Tasks 4, 7 | | T1.2 unlock conditions + story flag placeholder | Task 5, 7 | | T1.2 multi-yield | Task 4 | | T1.3 action queue engine | Tasks 6, 7 | | T1.4 stub content pack | Task 9 | | PR1 integration verify | Task 11 | | Closes #3, #4 | Task 12 | **Placeholder scan:** none — all steps include concrete code and commands. **Type consistency:** `GameState.actionQueue`, `GameView.queuedActionIds`, `save.state.actionQueue`, and `enqueueAction` signatures align across tasks. **Out of scope (deferred to later PRs):** story flags on `GameState` (PR2), queue UI polish (PR2 #5), save v2 migration (PR4 #9), automation (PR3 #7). --- ## Execution handoff Plan complete and saved to `docs/superpowers/plans/2026-06-11-m1-pr1-foundations.md`. Two execution options: **1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration **2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints **Which approach, senpai?**