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 & { manualCompletionCounts: Record; 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 & { manualCompletionCounts: Record; 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(); }); });