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/.
38 KiB
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
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
tickGameinto the test (minimal fix for this task)
Replace the advance callback body with:
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
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
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
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:
/**
* ...
* 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:
/**
* ...
* 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
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:
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:
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:
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:
const validActions = [
{
id: 'forage',
name: 'Forage',
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
];
In src/content/definitions.ts:
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
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
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:
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
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):
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:
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
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
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:
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':
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:
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
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:
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:
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:
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:
state: {
resources: { ...state.resources },
activeActionId: state.activeActionId,
actionElapsedMs: state.actionElapsedMs,
actionQueue: [...state.actionQueue],
},
In src/state/persistence.ts loadGame, extend hydration:
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
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
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:
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:
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
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:
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:
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:
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:
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
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:
- `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
pnpm typecheck
pnpm lint
pnpm test:coverage
pnpm build
Expected: all green; src/engine/ coverage ≥ 80%.
- Step 3: Manual playtest checklist
pnpm dev— open app.- Click Gather supplies three times quickly → event log shows Started + 2× Queued.
- Watch resources: costs deduct on start for costed actions; yields apply on completion.
- Reload → queue and resources preserved.
- Resize to mobile width — no regressions (full mobile pass is PR4).
- Step 4: Commit
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
git push -u origin feat/m1-foundations
- Step 2: Create PR
Title: feat(m1): foundations — queue engine, costs, stub content
Body:
## 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?