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
|
||||
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.
|
||||
|
||||
@@ -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 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
@@ -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
@@ -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}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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()', () => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) })),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user