Merge pull request 'feat(m1): foundations — queue engine, costs, stub content' (#13) from feat/m1-foundations into main
CI / verify (push) Successful in 52s
Deploy Internal Playtest / deploy (push) Successful in 47s

This commit was merged in pull request #13.
This commit is contained in:
2026-06-11 18:28:15 -05:00
19 changed files with 755 additions and 67 deletions
+2 -1
View File
@@ -8,7 +8,8 @@ Idlegame is split into four layers.
- `tickLoop.ts`: fixed-timestep accumulator. Large elapsed deltas, including
offline catch-up, drain through the same tick path as active play.
- `game.ts`: core game state, active action progress, and resource accrual.
- `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).
- `save.ts`: versioned save schema, serialized export/import strings, and
offline elapsed calculation.
- `num.ts`: branded numeric boundary and human-readable formatting.
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { createGameState, enqueueAction, tickGame } from '../../engine/game';
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 state = createGameState(content);
const trade = content.actions.find((a) => a.costs.length > 0);
if (!trade) {
throw new Error('expected at least one costed action');
}
enqueueAction(state, content, trade.id);
tickGame(state, content, trade.durationMs);
expect(state.activeActionId).toBeNull();
});
});
+82 -3
View File
@@ -3,7 +3,12 @@ import { buildContent } from '../schema';
const validResources = [{ id: 'gold', name: 'Gold' }];
const validActions = [
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
{
id: 'forage',
name: 'Forage',
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
];
describe('buildContent()', () => {
@@ -24,7 +29,7 @@ describe('buildContent()', () => {
id: 'forage',
name: 'Forage',
durationMs: 3000,
yields: { resourceId: 'ghost', amount: 1 },
yields: [{ resourceId: 'ghost', amount: 1 }],
},
];
expect(() => buildContent({ resources: validResources, actions })).toThrow(/unknown resource/i);
@@ -40,8 +45,82 @@ describe('buildContent()', () => {
it('rejects a structurally invalid definition', () => {
const actions = [
{ id: 'forage', name: 'Forage', durationMs: -1, yields: { resourceId: 'gold', amount: 1 } },
{
id: 'forage',
name: 'Forage',
durationMs: -1,
yields: [{ resourceId: 'gold', amount: 1 }],
},
];
expect(() => buildContent({ resources: validResources, actions })).toThrow();
});
});
describe('costs and multi-yield', () => {
it('accepts optional costs and multiple yields', () => {
const resources = [
{ id: 'gold', name: 'Gold' },
{ id: 'wood', name: 'Wood' },
];
const actions = [
{
id: 'craft',
name: 'Craft',
durationMs: 1000,
costs: [{ resourceId: 'wood', amount: 2 }],
yields: [
{ resourceId: 'gold', amount: 1 },
{ resourceId: 'wood', amount: 1 },
],
},
];
const content = buildContent({ resources, actions });
expect(content.actionsById.craft.costs).toEqual([{ resourceId: 'wood', amount: 2 }]);
expect(content.actionsById.craft.yields).toHaveLength(2);
});
it('rejects cost referencing unknown resource', () => {
const actions = [
{
id: 'craft',
name: 'Craft',
durationMs: 1000,
costs: [{ resourceId: 'ghost', amount: 1 }],
yields: [{ resourceId: 'gold', amount: 1 }],
},
];
expect(() => buildContent({ resources: validResources, actions })).toThrow(/unknown resource/i);
});
it('defaults costs to empty array', () => {
const content = buildContent({ resources: validResources, actions: validActions });
expect(content.actionsById.forage.costs).toEqual([]);
});
});
describe('unlock conditions', () => {
it('accepts optional minResources and requireStoryFlags', () => {
const actions = [
{
id: 'forage',
name: 'Forage',
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
unlock: {
minResources: { gold: 10 },
requireStoryFlags: ['intro_complete'],
},
},
];
const content = buildContent({ resources: validResources, actions });
expect(content.actionsById.forage.unlock).toEqual({
minResources: { gold: 10 },
requireStoryFlags: ['intro_complete'],
});
});
it('defaults unlock to undefined when omitted', () => {
const content = buildContent({ resources: validResources, actions: validActions });
expect(content.actionsById.forage.unlock).toBeUndefined();
});
});
+39 -11
View File
@@ -1,17 +1,45 @@
/**
* Walking-skeleton content: one resource, one timed action.
*
* M1 expands this into the real resource/action set and the story graph. Kept as
* plain data so it stays diffable and authorable without touching engine code.
*/
export const resourceDefs = [{ id: 'gold', name: 'Gold', startAmount: 0 }];
export const resourceDefs = [
{ id: 'supplies', name: 'Supplies', startAmount: 10 },
{ id: 'coin', name: 'Coin', startAmount: 0 },
];
export const actionDefs = [
{
id: 'forage',
name: 'Forage for coin',
id: 'gather_supplies',
name: 'Gather supplies',
durationMs: 3000,
yields: { resourceId: 'gold', amount: 1 },
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 }],
},
];
+24 -8
View File
@@ -15,17 +15,28 @@ export const resourceDefSchema = z.object({
startAmount: z.number().nonnegative().default(0),
});
export const resourceAmountSchema = z.object({
resourceId: z.string().min(1),
amount: z.number().positive(),
});
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(),
yields: z.object({
resourceId: z.string().min(1),
amount: z.number().positive(),
}),
costs: z.array(resourceAmountSchema).default([]),
yields: z.array(resourceAmountSchema).min(1),
unlock: unlockDefSchema.optional(),
});
export type ResourceDef = z.infer<typeof resourceDefSchema>;
export type ResourceAmount = z.infer<typeof resourceAmountSchema>;
export type UnlockDef = z.infer<typeof unlockDefSchema>;
export type ActionDef = z.infer<typeof actionDefSchema>;
export interface Content {
@@ -55,10 +66,15 @@ export function buildContent(input: { resources: unknown[]; actions: unknown[] }
const actionsById = indexById(actions, 'action');
for (const action of actions) {
if (!resourcesById[action.yields.resourceId]) {
throw new Error(
`Action "${action.id}" yields unknown resource "${action.yields.resourceId}"`,
);
for (const y of action.yields) {
if (!resourcesById[y.resourceId]) {
throw new Error(`Action "${action.id}" yields unknown resource "${y.resourceId}"`);
}
}
for (const c of action.costs) {
if (!resourcesById[c.resourceId]) {
throw new Error(`Action "${action.id}" cost references unknown resource "${c.resourceId}"`);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState, enqueueAction, tickGame } 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(state, content, TICK_MS);
});
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);
});
});
+260 -8
View File
@@ -1,12 +1,67 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState, startAction, tickGame } from '../game';
import {
cancelQueuedAction,
canUnlockAction,
clearQueue,
createGameState,
enqueueAction,
startAction,
tickGame,
} from '../game';
function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }],
actions: [
{ id: 'forage', name: 'Forage', durationMs: 300, yields: { resourceId: 'gold', amount: 2 } },
{
id: 'forage',
name: 'Forage',
durationMs: 300,
yields: [{ resourceId: 'gold', amount: 2 }],
},
],
});
}
function queueContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
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 }] },
],
});
}
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 } },
},
],
});
}
@@ -17,6 +72,7 @@ describe('createGameState()', () => {
expect(state.resources.gold).toBe(5);
expect(state.activeActionId).toBeNull();
expect(state.actionElapsedMs).toBe(0);
expect(state.actionQueue).toEqual([]);
});
});
@@ -37,6 +93,201 @@ describe('startAction()', () => {
});
});
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.actionElapsedMs).toBe(0);
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 an 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.activeActionId).toBe('a');
expect(state.actionQueue).toEqual(['c']);
});
it('throws RangeError when index is out of range', () => {
const content = queueContent();
const state = createGameState(content);
enqueueAction(state, content, 'a');
enqueueAction(state, content, 'b');
expect(() => cancelQueuedAction(state, 5)).toThrow(RangeError);
});
});
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');
enqueueAction(state, content, 'c');
state.actionElapsedMs = 250;
clearQueue(state);
expect(state.activeActionId).toBe('a');
expect(state.actionElapsedMs).toBe(250);
expect(state.actionQueue).toEqual([]);
});
});
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');
});
it('rejects actions until required story flags are set', () => {
const content = buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [
{
id: 'secret',
name: 'Secret',
durationMs: 100,
yields: [{ resourceId: 'gold', amount: 1 }],
unlock: { requireStoryFlags: ['path_scouted'] },
},
],
});
const state = createGameState(content);
expect(() => enqueueAction(state, content, 'secret')).toThrow(/cannot enqueue/i);
expect(canUnlockAction(state, content, 'secret', { path_scouted: true })).toBe(true);
});
});
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('skips queued actions that are unaffordable when their turn arrives', () => {
const content = buildContent({
resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }],
actions: [
{
id: 'cheap',
name: 'Cheap',
durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
{
id: 'dear',
name: 'Dear',
durationMs: 100,
costs: [{ resourceId: 'supplies', amount: 6 }],
yields: [{ resourceId: 'supplies', amount: 1 }],
},
{
id: 'free',
name: 'Free',
durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
],
});
const state = createGameState(content);
state.activeActionId = 'cheap';
state.actionElapsedMs = 99;
state.actionQueue = ['dear', 'free'];
state.resources.supplies = 3;
tickGame(state, content, 1);
expect(state.activeActionId).toBe('free');
expect(state.actionQueue).toEqual([]);
});
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);
});
});
describe('tickGame()', () => {
it('does nothing when no action is active', () => {
const content = testContent();
@@ -54,7 +305,7 @@ describe('tickGame()', () => {
expect(state.actionElapsedMs).toBe(100);
});
it('grants the yield on completion and repeats, carrying the remainder', () => {
it('grants the yield on completion then goes idle', () => {
const content = testContent();
const state = createGameState(content);
startAction(state, content, 'forage');
@@ -62,15 +313,16 @@ describe('tickGame()', () => {
tickGame(state, content, 100);
tickGame(state, content, 100); // 300ms -> one completion, +2 gold
expect(state.resources.gold).toBe(7);
expect(state.activeActionId).toBeNull();
expect(state.actionElapsedMs).toBe(0);
});
it('handles multiple completions within a single large tick (offline catch-up)', () => {
it('handles a single completion within a large tick (offline catch-up)', () => {
const content = testContent();
const state = createGameState(content);
startAction(state, content, 'forage');
tickGame(state, content, 1000); // 3 completions (900ms) + 100ms remainder
expect(state.resources.gold).toBe(11); // 5 + 3*2
expect(state.actionElapsedMs).toBe(100);
enqueueAction(state, content, 'forage');
tickGame(state, content, 1000); // one completion only unless re-enqueued
expect(state.resources.gold).toBe(7); // 5 + 2
expect(state.activeActionId).toBeNull();
});
});
+26
View File
@@ -0,0 +1,26 @@
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);
}
}
});
});
+19 -3
View File
@@ -15,7 +15,12 @@ function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
{
id: 'forage',
name: 'Forage',
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
],
});
}
@@ -44,6 +49,15 @@ describe('createSave()', () => {
state.resources.gold = 999;
expect(save.state.resources.gold).toBe(12);
});
it('snapshots actionQueue in save payload', () => {
const state = sampleState();
state.actionQueue = ['forage', 'forage'];
const save = createSave(state, 1700);
expect(save.state.actionQueue).toEqual(['forage', 'forage']);
state.actionQueue.push('forage');
expect(save.state.actionQueue).toEqual(['forage', 'forage']);
});
});
describe('serialize / deserialize round-trip', () => {
@@ -86,7 +100,8 @@ describe('applyOfflineProgress()', () => {
startAction(state, content, 'forage'); // 3000ms per gold
const credited = applyOfflineProgress(state, content, 1000, 1000 + 9000); // 9s
expect(credited).toBe(9000);
expect(state.resources.gold).toBe(3); // 9000 / 3000
expect(state.resources.gold).toBe(1); // one completion then idle
expect(state.activeActionId).toBeNull();
});
it('credits nothing when the clock did not advance', () => {
@@ -103,6 +118,7 @@ describe('applyOfflineProgress()', () => {
startAction(state, content, 'forage');
const credited = applyOfflineProgress(state, content, 0, 10_000_000, 6000);
expect(credited).toBe(6000);
expect(state.resources.gold).toBe(2); // 6000 / 3000
expect(state.resources.gold).toBe(1); // one completion then idle
expect(state.activeActionId).toBeNull();
});
});
+137 -14
View File
@@ -14,6 +14,8 @@ export interface GameState {
activeActionId: string | null;
/** Progress of the active action, in milliseconds. */
actionElapsedMs: number;
/** Action ids waiting to run after the active action finishes. */
actionQueue: string[];
}
export function createGameState(content: Content): GameState {
@@ -21,35 +23,156 @@ export function createGameState(content: Content): GameState {
for (const resource of content.resources) {
resources[resource.id] = resource.startAmount;
}
return { resources, activeActionId: null, actionElapsedMs: 0 };
return { resources, activeActionId: null, actionElapsedMs: 0, actionQueue: [] };
}
/** Begin running an action, resetting its progress. Throws on an unknown id. */
export function startAction(state: GameState, content: Content, actionId: string): void {
function assertKnownAction(content: Content, actionId: string): void {
if (!content.actionsById[actionId]) {
throw new Error(`Unknown action "${actionId}"`);
}
}
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 (!nextId) {
break;
}
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);
}
/**
* Begin running an action, resetting its progress. Throws on an unknown id.
*
* @deprecated Prefer `enqueueAction` — it starts immediately when idle and queues otherwise.
*/
export function startAction(state: GameState, content: Content, actionId: string): void {
assertKnownAction(content, actionId);
state.activeActionId = actionId;
state.actionElapsedMs = 0;
}
/**
* Advance the active action by `tickMs`. Each time it reaches its duration it
* grants its yield and repeats, carrying the remainder — so one large tick (the
* offline catch-up path) can complete an action many times.
* Start an action immediately when idle, otherwise append it to the queue.
* Costs deduct on start (D-0012). Throws when unknown, unaffordable, or locked.
*/
export function tickGame(state: GameState, content: Content, tickMs: number): void {
if (!state.activeActionId) {
return;
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}"`);
}
const action = content.actionsById[state.activeActionId];
if (!action) {
return;
if (state.activeActionId === null) {
beginAction(state, content, actionId);
} else {
state.actionQueue.push(actionId);
}
}
/** Remove a queued action by index. Throws RangeError when out of range. */
export function cancelQueuedAction(state: GameState, index: number): void {
if (index < 0 || index >= state.actionQueue.length) {
throw new RangeError(`Queue index ${index} is out of range`);
}
state.actionQueue.splice(index, 1);
}
/** Clear all queued actions without stopping the active action. */
export function clearQueue(state: GameState): void {
state.actionQueue.length = 0;
}
/**
* Advance the active action by `tickMs`. On completion, grants yields and
* advances the queue — actions do not auto-repeat when the queue is empty.
*/
export function tickGame(state: GameState, content: Content, tickMs: number): void {
if (!state.activeActionId) return;
state.actionElapsedMs += tickMs;
while (state.actionElapsedMs >= action.durationMs) {
while (state.activeActionId) {
const action = content.actionsById[state.activeActionId];
if (!action) return;
if (state.actionElapsedMs < action.durationMs) return;
state.actionElapsedMs -= action.durationMs;
state.resources[action.yields.resourceId] += action.yields.amount;
completeActiveAction(state, content);
}
}
+6
View File
@@ -13,6 +13,10 @@ import { TICK_MS } from './tickLoop';
* into a validated, versioned, compressed string and back. Saves carry a
* `version` so future migrations have a hook; for now a version mismatch is
* rejected cleanly rather than silently coerced.
*
* 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.
*/
export const SAVE_VERSION = 1;
@@ -24,6 +28,7 @@ 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([]),
});
export const saveSchema = z.object({
@@ -43,6 +48,7 @@ export function createSave(state: GameState, now: number): SaveData {
resources: { ...state.resources },
activeActionId: state.activeActionId,
actionElapsedMs: state.actionElapsedMs,
actionQueue: [...state.actionQueue],
},
};
}
+7 -5
View File
@@ -6,11 +6,13 @@
* keeping the sub-tick remainder so results are independent of how the caller
* chunks calls (determinism — see the test suite).
*
* Offline catch-up is the *same* code path: a large `now` delta simply owes many
* ticks. `maxTicks` caps how many run per call so a huge delta can't lock the
* thread; the remainder stays in the accumulator and drains on the next call,
* so no ticks are ever lost. Clamping the *credited* offline window lives in the
* save layer (T3.3), not here.
* 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.
*/
export const TICK_HZ = 10;
+38 -5
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState, startAction } from '../../engine/game';
import { createGameState, enqueueAction, startAction } from '../../engine/game';
import { createSave, serializeSave } from '../../engine/save';
import { createMemoryBackend, loadGame, saveGame } from '../persistence';
@@ -8,7 +8,22 @@ function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
{
id: 'forage',
name: 'Forage',
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
],
});
}
function queueTestContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [
{ id: 'a', name: 'A', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'b', name: 'B', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
],
});
}
@@ -31,8 +46,8 @@ describe('loadGame()', () => {
const result = await loadGame(content, backend, 1000 + 9000); // 9s offline
expect(result.offlineMs).toBe(9000);
expect(result.state.resources.gold).toBe(13); // 10 + 9000/3000
expect(result.state.activeActionId).toBe('forage');
expect(result.state.resources.gold).toBe(11); // 10 + one completion
expect(result.state.activeActionId).toBeNull();
});
it('falls back to a fresh game on a corrupt save instead of throwing', async () => {
@@ -42,11 +57,29 @@ describe('loadGame()', () => {
expect(result.offlineMs).toBe(0);
});
it('restores actionQueue on load', async () => {
const content = queueTestContent();
const state = createGameState(content);
enqueueAction(state, content, 'a');
enqueueAction(state, content, 'b');
const backend = createMemoryBackend();
await saveGame(state, backend, 1000);
const result = await loadGame(content, backend, 1000);
expect(result.state.activeActionId).toBe('a');
expect(result.state.actionQueue).toEqual(['b']);
});
it('drops an active action that no longer exists in content', async () => {
const content = testContent();
const stale = serializeSave(
createSave(
{ resources: { gold: 1 }, activeActionId: 'ghost-action', actionElapsedMs: 0 },
{
resources: { gold: 1 },
activeActionId: 'ghost-action',
actionElapsedMs: 0,
actionQueue: [],
},
1000,
),
);
+23 -2
View File
@@ -1,13 +1,18 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState, startAction } from '../../engine/game';
import { createGameState, enqueueAction, startAction } from '../../engine/game';
import { formatOfflineDuration, toView } from '../viewModel';
function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
actions: [
{ id: 'forage', name: 'Forage', durationMs: 200, yields: { resourceId: 'gold', amount: 1 } },
{
id: 'forage',
name: 'Forage',
durationMs: 200,
yields: [{ resourceId: 'gold', amount: 1 }],
},
],
});
}
@@ -44,6 +49,22 @@ describe('toView()', () => {
state.actionElapsedMs = 999;
expect(toView(state, content).actionProgress).toBe(1);
});
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']);
});
});
describe('formatOfflineDuration()', () => {
+1
View File
@@ -102,6 +102,7 @@ export async function loadGame(
resources: { ...base.resources, ...save.state.resources },
activeActionId,
actionElapsedMs: save.state.actionElapsedMs,
actionQueue: [...(save.state.actionQueue ?? [])],
};
savedAt = save.savedAt;
} catch {
+12 -6
View File
@@ -1,5 +1,5 @@
import { content } from '../content';
import { startAction as engineStartAction, type GameState, tickGame } from '../engine/game';
import { enqueueAction as engineEnqueueAction, type GameState, tickGame } from '../engine/game';
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
import { useGameStore } from './store';
@@ -58,15 +58,21 @@ class GameRuntime {
}
}
startAction(actionId: string): void {
enqueueAction(actionId: string): void {
const state = this.state;
if (!state) {
return;
}
engineStartAction(state, content, actionId);
const action = content.actionsById[actionId];
if (action) {
useGameStore.getState().appendLog(`Started: ${action.name}.`);
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();
}
+2
View File
@@ -20,6 +20,8 @@ export const useGameStore = create<GameStoreState>((set) => ({
activeActionId: null,
actionName: null,
actionProgress: 0,
queuedActionIds: [],
queuedActionNames: [],
log: [],
setView: (view) => set(view),
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
+6
View File
@@ -18,6 +18,8 @@ export interface GameView {
actionName: string | null;
/** Progress of the active action, clamped to 0..1. */
actionProgress: number;
queuedActionIds: string[];
queuedActionNames: string[];
}
export function toView(state: GameState, content: Content): GameView {
@@ -29,12 +31,16 @@ export function toView(state: GameState, content: Content): GameView {
const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined;
const actionProgress = action ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0;
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,
};
}
+1 -1
View File
@@ -16,7 +16,7 @@ export function ActionPanel() {
<button
type="button"
key={action.id}
onClick={() => gameRuntime.startAction(action.id)}
onClick={() => gameRuntime.enqueueAction(action.id)}
className="relative overflow-hidden rounded-lg border border-slate-700 bg-slate-800/70 px-4 py-3 text-left transition-colors hover:border-amber-500/60 hover:bg-slate-800"
>
{isActive ? (