feat(engine): costs on start, unlock checks, queue completion advancement
This commit is contained in:
@@ -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, tickGame } from '../game';
|
import { createGameState, enqueueAction, tickGame } from '../game';
|
||||||
import { advance, createTickLoop, TICK_MS } from '../tickLoop';
|
import { advance, createTickLoop, TICK_MS } from '../tickLoop';
|
||||||
|
|
||||||
function testContent() {
|
function testContent() {
|
||||||
@@ -20,7 +20,7 @@ function testContent() {
|
|||||||
function simulate(elapsedMs: number, chunkMs: number) {
|
function simulate(elapsedMs: number, chunkMs: number) {
|
||||||
const content = testContent();
|
const content = testContent();
|
||||||
const state = createGameState(content);
|
const state = createGameState(content);
|
||||||
startAction(state, content, 'forage');
|
enqueueAction(state, content, 'forage');
|
||||||
|
|
||||||
const loop = createTickLoop({ tickMs: TICK_MS, startNow: 0 });
|
const loop = createTickLoop({ tickMs: TICK_MS, startNow: 0 });
|
||||||
let now = 0;
|
let now = 0;
|
||||||
|
|||||||
@@ -29,6 +29,37 @@ function queueContent() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('createGameState()', () => {
|
describe('createGameState()', () => {
|
||||||
it('seeds resource amounts from their start amounts with no active action', () => {
|
it('seeds resource amounts from their start amounts with no active action', () => {
|
||||||
const state = createGameState(testContent());
|
const state = createGameState(testContent());
|
||||||
@@ -119,6 +150,85 @@ describe('clearQueue()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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();
|
||||||
@@ -136,7 +246,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');
|
||||||
@@ -144,15 +254,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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,7 +86,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 +104,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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+107
-24
@@ -26,33 +26,121 @@ export function createGameState(content: Content): GameState {
|
|||||||
return { resources, activeActionId: null, actionElapsedMs: 0, actionQueue: [] };
|
return { resources, activeActionId: null, actionElapsedMs: 0, actionQueue: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (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.
|
* Begin running an action, resetting its progress. Throws on an unknown id.
|
||||||
*
|
*
|
||||||
* @deprecated Prefer `enqueueAction` — it starts immediately when idle and queues otherwise.
|
* @deprecated Prefer `enqueueAction` — it starts immediately when idle and queues otherwise.
|
||||||
*/
|
*/
|
||||||
export function startAction(state: GameState, content: Content, actionId: string): void {
|
export function startAction(state: GameState, content: Content, actionId: string): void {
|
||||||
if (!content.actionsById[actionId]) {
|
assertKnownAction(content, actionId);
|
||||||
throw new Error(`Unknown action "${actionId}"`);
|
|
||||||
}
|
|
||||||
state.activeActionId = actionId;
|
state.activeActionId = actionId;
|
||||||
state.actionElapsedMs = 0;
|
state.actionElapsedMs = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start an action immediately when idle, otherwise append it to the queue.
|
* Start an action immediately when idle, otherwise append it to the queue.
|
||||||
* Throws on an unknown id. Cost/unlock checks are not applied yet.
|
* Costs deduct on start (D-0012). Throws when unknown, unaffordable, or locked.
|
||||||
*/
|
*/
|
||||||
export function enqueueAction(state: GameState, content: Content, actionId: string): void {
|
export function enqueueAction(state: GameState, content: Content, actionId: string): void {
|
||||||
if (!content.actionsById[actionId]) {
|
assertKnownAction(content, actionId);
|
||||||
throw new Error(`Unknown action "${actionId}"`);
|
if (!isActionAvailable(state, content, actionId)) {
|
||||||
|
throw new Error(`Cannot enqueue action "${actionId}"`);
|
||||||
}
|
}
|
||||||
if (state.activeActionId === null) {
|
if (state.activeActionId === null) {
|
||||||
state.activeActionId = actionId;
|
beginAction(state, content, actionId);
|
||||||
state.actionElapsedMs = 0;
|
} else {
|
||||||
return;
|
state.actionQueue.push(actionId);
|
||||||
}
|
}
|
||||||
state.actionQueue.push(actionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a queued action by index. Throws RangeError when out of range. */
|
/** Remove a queued action by index. Throws RangeError when out of range. */
|
||||||
@@ -69,24 +157,19 @@ export function clearQueue(state: GameState): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance the active action by `tickMs`. Each time it reaches its duration it
|
* Advance the active action by `tickMs`. On completion, grants yields and
|
||||||
* grants its yield and repeats, carrying the remainder — so one large tick (the
|
* advances the queue — actions do not auto-repeat when the queue is empty.
|
||||||
* offline catch-up path) can complete an action many times.
|
|
||||||
*/
|
*/
|
||||||
export function tickGame(state: GameState, content: Content, tickMs: number): void {
|
export function tickGame(state: GameState, content: Content, tickMs: number): void {
|
||||||
if (!state.activeActionId) {
|
if (!state.activeActionId) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const action = content.actionsById[state.activeActionId];
|
|
||||||
if (!action) {
|
|
||||||
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;
|
||||||
for (const y of action.yields) {
|
completeActiveAction(state, content);
|
||||||
state.resources[y.resourceId] += y.amount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user