feat(engine): loop idle runner with enabledLoopActionIds
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
createGameState,
|
createGameState,
|
||||||
enqueueAction,
|
enqueueAction,
|
||||||
executeInstant,
|
executeInstant,
|
||||||
|
maybeStartLoopAction,
|
||||||
startAction,
|
startAction,
|
||||||
tickGame,
|
tickGame,
|
||||||
} from '../game';
|
} from '../game';
|
||||||
@@ -402,3 +403,46 @@ describe('executeInstant()', () => {
|
|||||||
expect(() => executeInstant(state, instantContent, 'buy_supply')).toThrow(/Cannot/);
|
expect(() => executeInstant(state, instantContent, 'buy_supply')).toThrow(/Cannot/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loop idle runner', () => {
|
||||||
|
const loopContent = buildContent({
|
||||||
|
resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'rest',
|
||||||
|
name: 'Rest',
|
||||||
|
kind: 'loop',
|
||||||
|
group: { id: 'camp_loop', label: 'Camp' },
|
||||||
|
durationMs: 1000,
|
||||||
|
loopPriority: 0,
|
||||||
|
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults enabledLoopActionIds to empty', () => {
|
||||||
|
const state = createGameState(loopContent);
|
||||||
|
expect(state.enabledLoopActionIds).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts enabled loop action when idle', () => {
|
||||||
|
const state = createGameState(loopContent);
|
||||||
|
state.enabledLoopActionIds = { rest: true };
|
||||||
|
maybeStartLoopAction(state, loopContent);
|
||||||
|
expect(state.activeActionId).toBe('rest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start loop when queue has items', () => {
|
||||||
|
const state = createGameState(loopContent);
|
||||||
|
state.enabledLoopActionIds = { rest: true };
|
||||||
|
state.actionQueue.push('rest');
|
||||||
|
maybeStartLoopAction(state, loopContent);
|
||||||
|
expect(state.activeActionId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start a disabled loop action', () => {
|
||||||
|
const state = createGameState(loopContent);
|
||||||
|
maybeStartLoopAction(state, loopContent);
|
||||||
|
expect(state.activeActionId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ describe('createSave()', () => {
|
|||||||
expect(save.state.currentStoryNodeId).toBe('route_a_beat');
|
expect(save.state.currentStoryNodeId).toBe('route_a_beat');
|
||||||
expect(save.state.seenStoryNodeIds).toHaveLength(2);
|
expect(save.state.seenStoryNodeIds).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('snapshots enabledLoopActionIds in the save payload and isolates from mutation', () => {
|
||||||
|
const state = sampleState();
|
||||||
|
state.enabledLoopActionIds = { rest: true };
|
||||||
|
const save = createSave(state, 1700);
|
||||||
|
expect(save.state.enabledLoopActionIds).toEqual({ rest: true });
|
||||||
|
state.enabledLoopActionIds.rest = false;
|
||||||
|
expect(save.state.enabledLoopActionIds).toEqual({ rest: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('serialize / deserialize round-trip', () => {
|
describe('serialize / deserialize round-trip', () => {
|
||||||
@@ -84,6 +93,27 @@ describe('serialize / deserialize round-trip', () => {
|
|||||||
const restored = fromExportString(toExportString(save));
|
const restored = fromExportString(toExportString(save));
|
||||||
expect(restored).toEqual(save);
|
expect(restored).toEqual(save);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves enabledLoopActionIds through a round-trip', () => {
|
||||||
|
const state = sampleState();
|
||||||
|
state.enabledLoopActionIds = { rest: true, patrol: false };
|
||||||
|
const restored = deserializeSave(serializeSave(createSave(state, 1700)));
|
||||||
|
expect(restored.state.enabledLoopActionIds).toEqual({ rest: true, patrol: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults enabledLoopActionIds to {} when absent from save JSON', () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
savedAt: 1700,
|
||||||
|
state: {
|
||||||
|
resources: { gold: 0 },
|
||||||
|
activeActionId: null,
|
||||||
|
actionElapsedMs: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const restored = deserializeSave(json);
|
||||||
|
expect(restored.state.enabledLoopActionIds).toEqual({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('invalid / tampered saves', () => {
|
describe('invalid / tampered saves', () => {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface GameState {
|
|||||||
currentStoryNodeId: string;
|
currentStoryNodeId: string;
|
||||||
/** Story node ids the player has already seen. */
|
/** Story node ids the player has already seen. */
|
||||||
seenStoryNodeIds: string[];
|
seenStoryNodeIds: string[];
|
||||||
|
/** loop-kind action id -> whether the player has enabled it for idle running. */
|
||||||
|
enabledLoopActionIds: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameState(content: Content): GameState {
|
export function createGameState(content: Content): GameState {
|
||||||
@@ -41,6 +43,7 @@ export function createGameState(content: Content): GameState {
|
|||||||
storyFlags: {},
|
storyFlags: {},
|
||||||
currentStoryNodeId: '',
|
currentStoryNodeId: '',
|
||||||
seenStoryNodeIds: [],
|
seenStoryNodeIds: [],
|
||||||
|
enabledLoopActionIds: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +200,26 @@ export function executeStoryAction(
|
|||||||
applyChoice(state, content, action.storyChoiceId);
|
applyChoice(state, content, action.storyChoiceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the highest-priority enabled, available loop action when the game is idle.
|
||||||
|
* Invoked by the runtime AFTER each live tick — never from `tickGame`, so loop
|
||||||
|
* actions do not run during offline catch-up (which replays `tickGame` directly).
|
||||||
|
*/
|
||||||
|
export function maybeStartLoopAction(state: GameState, content: Content): void {
|
||||||
|
if (state.activeActionId !== null || state.actionQueue.length > 0) return;
|
||||||
|
|
||||||
|
const candidates = content.actions
|
||||||
|
.filter((a) => a.kind === 'loop' && state.enabledLoopActionIds[a.id])
|
||||||
|
.sort((a, b) => (a.loopPriority ?? 0) - (b.loopPriority ?? 0));
|
||||||
|
|
||||||
|
for (const action of candidates) {
|
||||||
|
if (isActionAvailable(state, content, action.id)) {
|
||||||
|
beginAction(state, content, action.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance the active action by `tickMs`. On completion, grants yields and
|
* Advance the active action by `tickMs`. On completion, grants yields and
|
||||||
* advances the queue — actions do not auto-repeat when the queue is empty.
|
* advances the queue — actions do not auto-repeat when the queue is empty.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const gameStateSchema = z.object({
|
|||||||
storyFlags: z.record(z.string(), z.boolean()).default({}),
|
storyFlags: z.record(z.string(), z.boolean()).default({}),
|
||||||
currentStoryNodeId: z.string().default(''),
|
currentStoryNodeId: z.string().default(''),
|
||||||
seenStoryNodeIds: z.array(z.string()).default([]),
|
seenStoryNodeIds: z.array(z.string()).default([]),
|
||||||
|
enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const saveSchema = z.object({
|
export const saveSchema = z.object({
|
||||||
@@ -55,6 +56,7 @@ export function createSave(state: GameState, now: number): SaveData {
|
|||||||
storyFlags: { ...state.storyFlags },
|
storyFlags: { ...state.storyFlags },
|
||||||
currentStoryNodeId: state.currentStoryNodeId,
|
currentStoryNodeId: state.currentStoryNodeId,
|
||||||
seenStoryNodeIds: [...state.seenStoryNodeIds],
|
seenStoryNodeIds: [...state.seenStoryNodeIds],
|
||||||
|
enabledLoopActionIds: { ...state.enabledLoopActionIds },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ describe('loadGame()', () => {
|
|||||||
storyFlags: {},
|
storyFlags: {},
|
||||||
currentStoryNodeId: '',
|
currentStoryNodeId: '',
|
||||||
seenStoryNodeIds: [],
|
seenStoryNodeIds: [],
|
||||||
|
enabledLoopActionIds: {},
|
||||||
},
|
},
|
||||||
1000,
|
1000,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export async function loadGame(
|
|||||||
storyFlags: { ...save.state.storyFlags },
|
storyFlags: { ...save.state.storyFlags },
|
||||||
currentStoryNodeId: save.state.currentStoryNodeId ?? '',
|
currentStoryNodeId: save.state.currentStoryNodeId ?? '',
|
||||||
seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])],
|
seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])],
|
||||||
|
enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) },
|
||||||
};
|
};
|
||||||
savedAt = save.savedAt;
|
savedAt = save.savedAt;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user