239 lines
8.6 KiB
TypeScript
239 lines
8.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { buildContent } from '../../content/schema';
|
|
import { createGameState, startAction } from '../game';
|
|
import {
|
|
applyOfflineProgress,
|
|
createSave,
|
|
deserializeSave,
|
|
fromExportString,
|
|
SAVE_VERSION,
|
|
serializeSave,
|
|
toExportString,
|
|
} from '../save';
|
|
|
|
function testContent() {
|
|
return buildContent({
|
|
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
|
actions: [
|
|
{
|
|
id: 'forage',
|
|
name: 'Forage',
|
|
group: { id: 'test', label: 'Test' },
|
|
durationMs: 3000,
|
|
yields: [{ resourceId: 'gold', amount: 1 }],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
function sampleState() {
|
|
const content = testContent();
|
|
const state = createGameState(content);
|
|
state.resources.gold = 12;
|
|
startAction(state, content, 'forage');
|
|
state.actionElapsedMs = 500;
|
|
return state;
|
|
}
|
|
|
|
describe('createSave()', () => {
|
|
it('stamps the current version and timestamp around a snapshot of state', () => {
|
|
const save = createSave(sampleState(), 1700);
|
|
expect(save.version).toBe(SAVE_VERSION);
|
|
expect(save.savedAt).toBe(1700);
|
|
expect(save.state.resources.gold).toBe(12);
|
|
expect(save.state.activeActionId).toBe('forage');
|
|
});
|
|
|
|
it('snapshots state so later mutation does not affect the save', () => {
|
|
const state = sampleState();
|
|
const save = createSave(state, 1700);
|
|
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']);
|
|
});
|
|
|
|
it('snapshots story fields in the save payload', () => {
|
|
const state = sampleState();
|
|
state.storyFlags = { route_a: true };
|
|
state.currentStoryNodeId = 'route_a_beat';
|
|
state.seenStoryNodeIds = ['boot_intro', 'route_a_beat'];
|
|
const save = createSave(state, 1700);
|
|
expect(save.state.storyFlags).toEqual({ route_a: true });
|
|
expect(save.state.currentStoryNodeId).toBe('route_a_beat');
|
|
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 });
|
|
});
|
|
|
|
it('snapshots manualCompletionCounts and automationQueue in the save payload', () => {
|
|
const state = sampleState() as ReturnType<typeof sampleState> & {
|
|
manualCompletionCounts: Record<string, number>;
|
|
automationQueue: string[];
|
|
};
|
|
state.manualCompletionCounts = { forage: 2 };
|
|
state.automationQueue = ['forage'];
|
|
const save = createSave(state, 1700);
|
|
expect(save.state.manualCompletionCounts).toEqual({ forage: 2 });
|
|
expect(save.state.automationQueue).toEqual(['forage']);
|
|
state.manualCompletionCounts.forage = 3;
|
|
state.automationQueue.push('forage');
|
|
expect(save.state.manualCompletionCounts).toEqual({ forage: 2 });
|
|
expect(save.state.automationQueue).toEqual(['forage']);
|
|
});
|
|
});
|
|
|
|
describe('serialize / deserialize round-trip', () => {
|
|
it('survives JSON serialization unchanged', () => {
|
|
const save = createSave(sampleState(), 1700);
|
|
const restored = deserializeSave(serializeSave(save));
|
|
expect(restored).toEqual(save);
|
|
});
|
|
|
|
it('survives the compressed export string unchanged', () => {
|
|
const save = createSave(sampleState(), 1700);
|
|
const restored = fromExportString(toExportString(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('preserves manualCompletionCounts and automationQueue through a round-trip', () => {
|
|
const state = sampleState() as ReturnType<typeof sampleState> & {
|
|
manualCompletionCounts: Record<string, number>;
|
|
automationQueue: string[];
|
|
};
|
|
state.manualCompletionCounts = { forage: 4 };
|
|
state.automationQueue = ['forage'];
|
|
const restored = deserializeSave(serializeSave(createSave(state, 1700)));
|
|
expect(restored.state.manualCompletionCounts).toEqual({ forage: 4 });
|
|
expect(restored.state.automationQueue).toEqual(['forage']);
|
|
});
|
|
|
|
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({});
|
|
});
|
|
|
|
it('defaults manualCompletionCounts and automationQueue 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.manualCompletionCounts).toEqual({});
|
|
expect(restored.state.automationQueue).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('invalid / tampered saves', () => {
|
|
it('rejects a non-JSON export string cleanly', () => {
|
|
expect(() => fromExportString('@@@not-a-valid-payload@@@')).toThrow();
|
|
});
|
|
|
|
it('rejects JSON that does not match the save schema', () => {
|
|
expect(() => deserializeSave('{"version":1,"savedAt":1,"state":{}}')).toThrow();
|
|
});
|
|
|
|
it('rejects an unsupported save version', () => {
|
|
const future = JSON.stringify({
|
|
version: 999,
|
|
savedAt: 1,
|
|
state: { resources: {}, activeActionId: null, actionElapsedMs: 0 },
|
|
});
|
|
expect(() => deserializeSave(future)).toThrow(/version/i);
|
|
});
|
|
});
|
|
|
|
describe('applyOfflineProgress() — loop invariant', () => {
|
|
it('does NOT start a loop action during offline catch-up even when it is enabled', () => {
|
|
// Hardest invariant: maybeStartLoopAction is called by the runtime AFTER each live
|
|
// tick, never from tickGame itself. Offline catch-up replays tickGame directly, so
|
|
// loop actions must never start (and therefore never yield) during catch-up.
|
|
const content = buildContent({
|
|
resources: [{ id: 'wood', name: 'Wood', startAmount: 0 }],
|
|
actions: [
|
|
{
|
|
id: 'chop',
|
|
name: 'Chop Wood',
|
|
kind: 'loop',
|
|
group: { id: 'test', label: 'Test' },
|
|
durationMs: 1000,
|
|
yields: [{ resourceId: 'wood', amount: 1 }],
|
|
},
|
|
],
|
|
});
|
|
const state = createGameState(content);
|
|
// Enable the loop — player has toggled it on — but do NOT make it active.
|
|
state.enabledLoopActionIds.chop = true;
|
|
// Simulate coming back online after 10 seconds (10 full loop durations).
|
|
applyOfflineProgress(state, content, 0, 10_000);
|
|
// The loop must NOT have started or yielded during offline catch-up.
|
|
expect(state.activeActionId).toBeNull();
|
|
expect(state.resources.wood).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('applyOfflineProgress()', () => {
|
|
it('credits whole ticks of elapsed time to the active action', () => {
|
|
const content = testContent();
|
|
const state = createGameState(content);
|
|
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(1); // one completion then idle
|
|
expect(state.activeActionId).toBeNull();
|
|
});
|
|
|
|
it('credits nothing when the clock did not advance', () => {
|
|
const content = testContent();
|
|
const state = createGameState(content);
|
|
startAction(state, content, 'forage');
|
|
expect(applyOfflineProgress(state, content, 5000, 4000)).toBe(0);
|
|
expect(state.resources.gold).toBe(0);
|
|
});
|
|
|
|
it('clamps credited time to the offline cap', () => {
|
|
const content = testContent();
|
|
const state = createGameState(content);
|
|
startAction(state, content, 'forage');
|
|
const credited = applyOfflineProgress(state, content, 0, 10_000_000, 6000);
|
|
expect(credited).toBe(6000);
|
|
expect(state.resources.gold).toBe(1); // one completion then idle
|
|
expect(state.activeActionId).toBeNull();
|
|
});
|
|
});
|