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/.
1343 lines
38 KiB
Markdown
1343 lines
38 KiB
Markdown
# 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<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?**
|