Files
idlegame/docs/superpowers/plans/2026-06-11-m1-pr1-foundations.md
ginnoir 203478268c
CI / verify (push) Successful in 1m31s
CI / verify (pull_request) Successful in 1m2s
docs(m1): add PR3 shell UI spec and implementation plans
Capture the PR3 UX brainstorm: three-region shell, action columns by kind, story-via-actions, Story tab tree+log, and universal automation with shareable recipes. Includes T3.0/T3.1 implementation plans and M1 vertical slice plan. Also ignore local .worktrees/.
2026-06-11 20:01:30 -05:00

1343 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, 45 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 67 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<string[]> {
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<typeof resourceAmountSchema>;
```
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<typeof unlockDefSchema>;
```
- [ ] **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<string, number>;
activeActionId: string | null;
actionElapsedMs: number;
actionQueue: string[];
}
export function createGameState(content: Content): GameState {
const resources: Record<string, number> = {};
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<string, boolean> = {},
): 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<string, boolean> = {},
): 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?**