feat(state): wire enqueueAction through runtime and view model

This commit is contained in:
ginnoir
2026-06-11 18:21:12 -05:00
parent 7625e081fd
commit fce561b8e5
5 changed files with 38 additions and 8 deletions
+17 -1
View File
@@ -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 { formatOfflineDuration, toView } from '../viewModel'; import { formatOfflineDuration, toView } from '../viewModel';
function testContent() { function testContent() {
@@ -44,6 +44,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()', () => {
+12 -6
View File
@@ -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();
} }
+2
View File
@@ -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) })),
+6
View File
@@ -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,
}; };
} }
+1 -1
View File
@@ -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 ? (