feat(m1): foundations — queue engine, costs, stub content #13
@@ -8,7 +8,8 @@ Idlegame is split into four layers.
|
|||||||
|
|
||||||
- `tickLoop.ts`: fixed-timestep accumulator. Large elapsed deltas, including
|
- `tickLoop.ts`: fixed-timestep accumulator. Large elapsed deltas, including
|
||||||
offline catch-up, drain through the same tick path as active play.
|
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
|
- `save.ts`: versioned save schema, serialized export/import strings, and
|
||||||
offline elapsed calculation.
|
offline elapsed calculation.
|
||||||
- `num.ts`: branded numeric boundary and human-readable formatting.
|
- `num.ts`: branded numeric boundary and human-readable formatting.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,12 @@ import { buildContent } from '../schema';
|
|||||||
|
|
||||||
const validResources = [{ id: 'gold', name: 'Gold' }];
|
const validResources = [{ id: 'gold', name: 'Gold' }];
|
||||||
const validActions = [
|
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()', () => {
|
describe('buildContent()', () => {
|
||||||
@@ -24,7 +29,7 @@ describe('buildContent()', () => {
|
|||||||
id: 'forage',
|
id: 'forage',
|
||||||
name: 'Forage',
|
name: 'Forage',
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
yields: { resourceId: 'ghost', amount: 1 },
|
yields: [{ resourceId: 'ghost', amount: 1 }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
expect(() => buildContent({ resources: validResources, actions })).toThrow(/unknown resource/i);
|
expect(() => buildContent({ resources: validResources, actions })).toThrow(/unknown resource/i);
|
||||||
@@ -40,8 +45,82 @@ describe('buildContent()', () => {
|
|||||||
|
|
||||||
it('rejects a structurally invalid definition', () => {
|
it('rejects a structurally invalid definition', () => {
|
||||||
const actions = [
|
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();
|
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
@@ -1,17 +1,45 @@
|
|||||||
/**
|
export const resourceDefs = [
|
||||||
* Walking-skeleton content: one resource, one timed action.
|
{ id: 'supplies', name: 'Supplies', startAmount: 10 },
|
||||||
*
|
{ id: 'coin', name: 'Coin', startAmount: 0 },
|
||||||
* 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 actionDefs = [
|
export const actionDefs = [
|
||||||
{
|
{
|
||||||
id: 'forage',
|
id: 'gather_supplies',
|
||||||
name: 'Forage for coin',
|
name: 'Gather supplies',
|
||||||
durationMs: 3000,
|
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
@@ -15,17 +15,28 @@ export const resourceDefSchema = z.object({
|
|||||||
startAmount: z.number().nonnegative().default(0),
|
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({
|
export const actionDefSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
durationMs: z.number().positive(),
|
durationMs: z.number().positive(),
|
||||||
yields: z.object({
|
costs: z.array(resourceAmountSchema).default([]),
|
||||||
resourceId: z.string().min(1),
|
yields: z.array(resourceAmountSchema).min(1),
|
||||||
amount: z.number().positive(),
|
unlock: unlockDefSchema.optional(),
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ResourceDef = z.infer<typeof resourceDefSchema>;
|
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 type ActionDef = z.infer<typeof actionDefSchema>;
|
||||||
|
|
||||||
export interface Content {
|
export interface Content {
|
||||||
@@ -55,10 +66,15 @@ export function buildContent(input: { resources: unknown[]; actions: unknown[] }
|
|||||||
const actionsById = indexById(actions, 'action');
|
const actionsById = indexById(actions, 'action');
|
||||||
|
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
if (!resourcesById[action.yields.resourceId]) {
|
for (const y of action.yields) {
|
||||||
throw new Error(
|
if (!resourcesById[y.resourceId]) {
|
||||||
`Action "${action.id}" yields unknown resource "${action.yields.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}"`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,67 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildContent } from '../../content/schema';
|
import { buildContent } from '../../content/schema';
|
||||||
import { createGameState, startAction, tickGame } from '../game';
|
import {
|
||||||
|
cancelQueuedAction,
|
||||||
|
canUnlockAction,
|
||||||
|
clearQueue,
|
||||||
|
createGameState,
|
||||||
|
enqueueAction,
|
||||||
|
startAction,
|
||||||
|
tickGame,
|
||||||
|
} from '../game';
|
||||||
|
|
||||||
function testContent() {
|
function testContent() {
|
||||||
return buildContent({
|
return buildContent({
|
||||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }],
|
resources: [{ id: 'gold', name: 'Gold', startAmount: 5 }],
|
||||||
actions: [
|
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.resources.gold).toBe(5);
|
||||||
expect(state.activeActionId).toBeNull();
|
expect(state.activeActionId).toBeNull();
|
||||||
expect(state.actionElapsedMs).toBe(0);
|
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()', () => {
|
describe('tickGame()', () => {
|
||||||
it('does nothing when no action is active', () => {
|
it('does nothing when no action is active', () => {
|
||||||
const content = testContent();
|
const content = testContent();
|
||||||
@@ -54,7 +305,7 @@ describe('tickGame()', () => {
|
|||||||
expect(state.actionElapsedMs).toBe(100);
|
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 content = testContent();
|
||||||
const state = createGameState(content);
|
const state = createGameState(content);
|
||||||
startAction(state, content, 'forage');
|
startAction(state, content, 'forage');
|
||||||
@@ -62,15 +313,16 @@ describe('tickGame()', () => {
|
|||||||
tickGame(state, content, 100);
|
tickGame(state, content, 100);
|
||||||
tickGame(state, content, 100); // 300ms -> one completion, +2 gold
|
tickGame(state, content, 100); // 300ms -> one completion, +2 gold
|
||||||
expect(state.resources.gold).toBe(7);
|
expect(state.resources.gold).toBe(7);
|
||||||
|
expect(state.activeActionId).toBeNull();
|
||||||
expect(state.actionElapsedMs).toBe(0);
|
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 content = testContent();
|
||||||
const state = createGameState(content);
|
const state = createGameState(content);
|
||||||
startAction(state, content, 'forage');
|
enqueueAction(state, content, 'forage');
|
||||||
tickGame(state, content, 1000); // 3 completions (900ms) + 100ms remainder
|
tickGame(state, content, 1000); // one completion only unless re-enqueued
|
||||||
expect(state.resources.gold).toBe(11); // 5 + 3*2
|
expect(state.resources.gold).toBe(7); // 5 + 2
|
||||||
expect(state.actionElapsedMs).toBe(100);
|
expect(state.activeActionId).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,12 @@ function testContent() {
|
|||||||
return buildContent({
|
return buildContent({
|
||||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||||
actions: [
|
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;
|
state.resources.gold = 999;
|
||||||
expect(save.state.resources.gold).toBe(12);
|
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', () => {
|
describe('serialize / deserialize round-trip', () => {
|
||||||
@@ -86,7 +100,8 @@ describe('applyOfflineProgress()', () => {
|
|||||||
startAction(state, content, 'forage'); // 3000ms per gold
|
startAction(state, content, 'forage'); // 3000ms per gold
|
||||||
const credited = applyOfflineProgress(state, content, 1000, 1000 + 9000); // 9s
|
const credited = applyOfflineProgress(state, content, 1000, 1000 + 9000); // 9s
|
||||||
expect(credited).toBe(9000);
|
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', () => {
|
it('credits nothing when the clock did not advance', () => {
|
||||||
@@ -103,6 +118,7 @@ describe('applyOfflineProgress()', () => {
|
|||||||
startAction(state, content, 'forage');
|
startAction(state, content, 'forage');
|
||||||
const credited = applyOfflineProgress(state, content, 0, 10_000_000, 6000);
|
const credited = applyOfflineProgress(state, content, 0, 10_000_000, 6000);
|
||||||
expect(credited).toBe(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
@@ -14,6 +14,8 @@ export interface GameState {
|
|||||||
activeActionId: string | null;
|
activeActionId: string | null;
|
||||||
/** Progress of the active action, in milliseconds. */
|
/** Progress of the active action, in milliseconds. */
|
||||||
actionElapsedMs: number;
|
actionElapsedMs: number;
|
||||||
|
/** Action ids waiting to run after the active action finishes. */
|
||||||
|
actionQueue: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameState(content: Content): GameState {
|
export function createGameState(content: Content): GameState {
|
||||||
@@ -21,35 +23,156 @@ export function createGameState(content: Content): GameState {
|
|||||||
for (const resource of content.resources) {
|
for (const resource of content.resources) {
|
||||||
resources[resource.id] = resource.startAmount;
|
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. */
|
function assertKnownAction(content: Content, actionId: string): void {
|
||||||
export function startAction(state: GameState, content: Content, actionId: string): void {
|
|
||||||
if (!content.actionsById[actionId]) {
|
if (!content.actionsById[actionId]) {
|
||||||
throw new Error(`Unknown action "${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.activeActionId = actionId;
|
||||||
state.actionElapsedMs = 0;
|
state.actionElapsedMs = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance the active action by `tickMs`. Each time it reaches its duration it
|
* Start an action immediately when idle, otherwise append it to the queue.
|
||||||
* grants its yield and repeats, carrying the remainder — so one large tick (the
|
* Costs deduct on start (D-0012). Throws when unknown, unaffordable, or locked.
|
||||||
* offline catch-up path) can complete an action many times.
|
|
||||||
*/
|
*/
|
||||||
export function tickGame(state: GameState, content: Content, tickMs: number): void {
|
export function enqueueAction(state: GameState, content: Content, actionId: string): void {
|
||||||
if (!state.activeActionId) {
|
assertKnownAction(content, actionId);
|
||||||
return;
|
if (!isActionAvailable(state, content, actionId)) {
|
||||||
|
throw new Error(`Cannot enqueue action "${actionId}"`);
|
||||||
}
|
}
|
||||||
const action = content.actionsById[state.activeActionId];
|
if (state.activeActionId === null) {
|
||||||
if (!action) {
|
beginAction(state, content, actionId);
|
||||||
return;
|
} 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;
|
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.actionElapsedMs -= action.durationMs;
|
||||||
state.resources[action.yields.resourceId] += action.yields.amount;
|
completeActiveAction(state, content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { TICK_MS } from './tickLoop';
|
|||||||
* into a validated, versioned, compressed string and back. Saves carry a
|
* into a validated, versioned, compressed string and back. Saves carry a
|
||||||
* `version` so future migrations have a hook; for now a version mismatch is
|
* `version` so future migrations have a hook; for now a version mismatch is
|
||||||
* rejected cleanly rather than silently coerced.
|
* 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;
|
export const SAVE_VERSION = 1;
|
||||||
@@ -24,6 +28,7 @@ export const gameStateSchema = z.object({
|
|||||||
resources: z.record(z.string(), z.number()),
|
resources: z.record(z.string(), z.number()),
|
||||||
activeActionId: z.string().nullable(),
|
activeActionId: z.string().nullable(),
|
||||||
actionElapsedMs: z.number().nonnegative(),
|
actionElapsedMs: z.number().nonnegative(),
|
||||||
|
actionQueue: z.array(z.string()).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const saveSchema = z.object({
|
export const saveSchema = z.object({
|
||||||
@@ -43,6 +48,7 @@ export function createSave(state: GameState, now: number): SaveData {
|
|||||||
resources: { ...state.resources },
|
resources: { ...state.resources },
|
||||||
activeActionId: state.activeActionId,
|
activeActionId: state.activeActionId,
|
||||||
actionElapsedMs: state.actionElapsedMs,
|
actionElapsedMs: state.actionElapsedMs,
|
||||||
|
actionQueue: [...state.actionQueue],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
* keeping the sub-tick remainder so results are independent of how the caller
|
* keeping the sub-tick remainder so results are independent of how the caller
|
||||||
* chunks calls (determinism — see the test suite).
|
* chunks calls (determinism — see the test suite).
|
||||||
*
|
*
|
||||||
* Offline catch-up is the *same* code path: a large `now` delta simply owes many
|
* Offline catch-up uses this same path: a large `now` delta owes many ticks.
|
||||||
* ticks. `maxTicks` caps how many run per call so a huge delta can't lock the
|
* `maxTicks` caps work per `advance()` call; the accumulator retains the
|
||||||
* thread; the remainder stays in the accumulator and drains on the next call,
|
* remainder so no ticks are lost across calls.
|
||||||
* so no ticks are ever lost. Clamping the *credited* offline window lives in the
|
*
|
||||||
* save layer (T3.3), not here.
|
* 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;
|
export const TICK_HZ = 10;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildContent } from '../../content/schema';
|
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 { createSave, serializeSave } from '../../engine/save';
|
||||||
import { createMemoryBackend, loadGame, saveGame } from '../persistence';
|
import { createMemoryBackend, loadGame, saveGame } from '../persistence';
|
||||||
|
|
||||||
@@ -8,7 +8,22 @@ function testContent() {
|
|||||||
return buildContent({
|
return buildContent({
|
||||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||||
actions: [
|
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
|
const result = await loadGame(content, backend, 1000 + 9000); // 9s offline
|
||||||
expect(result.offlineMs).toBe(9000);
|
expect(result.offlineMs).toBe(9000);
|
||||||
expect(result.state.resources.gold).toBe(13); // 10 + 9000/3000
|
expect(result.state.resources.gold).toBe(11); // 10 + one completion
|
||||||
expect(result.state.activeActionId).toBe('forage');
|
expect(result.state.activeActionId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to a fresh game on a corrupt save instead of throwing', async () => {
|
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);
|
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 () => {
|
it('drops an active action that no longer exists in content', async () => {
|
||||||
const content = testContent();
|
const content = testContent();
|
||||||
const stale = serializeSave(
|
const stale = serializeSave(
|
||||||
createSave(
|
createSave(
|
||||||
{ resources: { gold: 1 }, activeActionId: 'ghost-action', actionElapsedMs: 0 },
|
{
|
||||||
|
resources: { gold: 1 },
|
||||||
|
activeActionId: 'ghost-action',
|
||||||
|
actionElapsedMs: 0,
|
||||||
|
actionQueue: [],
|
||||||
|
},
|
||||||
1000,
|
1000,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildContent } from '../../content/schema';
|
import { buildContent } from '../../content/schema';
|
||||||
import { createGameState, startAction } from '../../engine/game';
|
import { createGameState, enqueueAction, startAction } from '../../engine/game';
|
||||||
import { formatOfflineDuration, toView } from '../viewModel';
|
import { formatOfflineDuration, toView } from '../viewModel';
|
||||||
|
|
||||||
function testContent() {
|
function testContent() {
|
||||||
return buildContent({
|
return buildContent({
|
||||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
|
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
|
||||||
actions: [
|
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;
|
state.actionElapsedMs = 999;
|
||||||
expect(toView(state, content).actionProgress).toBe(1);
|
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()', () => {
|
describe('formatOfflineDuration()', () => {
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export async function loadGame(
|
|||||||
resources: { ...base.resources, ...save.state.resources },
|
resources: { ...base.resources, ...save.state.resources },
|
||||||
activeActionId,
|
activeActionId,
|
||||||
actionElapsedMs: save.state.actionElapsedMs,
|
actionElapsedMs: save.state.actionElapsedMs,
|
||||||
|
actionQueue: [...(save.state.actionQueue ?? [])],
|
||||||
};
|
};
|
||||||
savedAt = save.savedAt;
|
savedAt = save.savedAt;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+12
-6
@@ -1,5 +1,5 @@
|
|||||||
import { content } from '../content';
|
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 { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
|
||||||
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
||||||
import { useGameStore } from './store';
|
import { useGameStore } from './store';
|
||||||
@@ -58,15 +58,21 @@ class GameRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startAction(actionId: string): void {
|
enqueueAction(actionId: string): void {
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
engineStartAction(state, content, actionId);
|
try {
|
||||||
const action = content.actionsById[actionId];
|
engineEnqueueAction(state, content, actionId);
|
||||||
if (action) {
|
const action = content.actionsById[actionId];
|
||||||
useGameStore.getState().appendLog(`Started: ${action.name}.`);
|
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();
|
this.publish();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export const useGameStore = create<GameStoreState>((set) => ({
|
|||||||
activeActionId: null,
|
activeActionId: null,
|
||||||
actionName: null,
|
actionName: null,
|
||||||
actionProgress: 0,
|
actionProgress: 0,
|
||||||
|
queuedActionIds: [],
|
||||||
|
queuedActionNames: [],
|
||||||
log: [],
|
log: [],
|
||||||
setView: (view) => set(view),
|
setView: (view) => set(view),
|
||||||
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
|
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface GameView {
|
|||||||
actionName: string | null;
|
actionName: string | null;
|
||||||
/** Progress of the active action, clamped to 0..1. */
|
/** Progress of the active action, clamped to 0..1. */
|
||||||
actionProgress: number;
|
actionProgress: number;
|
||||||
|
queuedActionIds: string[];
|
||||||
|
queuedActionNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toView(state: GameState, content: Content): GameView {
|
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 action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined;
|
||||||
const actionProgress = action ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0;
|
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 {
|
return {
|
||||||
resources,
|
resources,
|
||||||
activeActionId: state.activeActionId,
|
activeActionId: state.activeActionId,
|
||||||
actionName: action ? action.name : null,
|
actionName: action ? action.name : null,
|
||||||
actionProgress,
|
actionProgress,
|
||||||
|
queuedActionIds,
|
||||||
|
queuedActionNames,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function ActionPanel() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={action.id}
|
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"
|
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 ? (
|
{isActive ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user