Files
idlegame/src/engine/__tests__/save.test.ts
T

137 lines
4.5 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);
});
});
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);
});
});
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()', () => {
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();
});
});