import { describe, expect, it } from 'vitest'; import { generateWorld } from './world'; import { applyWear, freshCondition } from './car'; import { createHeat, stepHeat } from './heat'; import { createIntel, observe, reveal } from './intel'; import { applyMissionImpact, controlAt, createFront, stepFront } from './regions'; import { createQuests } from './quests'; import { apply, isLoadable, serialise, SAVE_VERSION, type Snapshot } from './save'; const world = generateWorld(1337); /** A campaign with some history behind it, so a round trip has work to do. */ function played(): Snapshot { const heat = createHeat(world.roads); const intel = createIntel(world.roads, world.extent); const front = createFront(1337, world.extent, world.spawn); const quests = createQuests(); let condition = freshCondition(); for (let m = 0; m < 400; m++) stepHeat(heat, { dt: 1 / 60, segmentId: 2, distance: 1 }); for (let i = 0; i < 200; i++) { condition = applyWear(condition, { dt: 1 / 60, distance: 3, throttle: 1, impactForce: 3e4 }); } observe(intel, heat, 2, 12); reveal(intel, world.spawn.x, world.spawn.z, 90, (x, z) => controlAt(front, x, z)); intel.visitedNodes.add(9); for (let i = 0; i < 60 * 45; i++) stepFront(front, 1 / 60); applyMissionImpact(front, 18); quests.completed = 3; quests.parts = 0.42; quests.nextId = 7; return { seed: 1337, elapsed: 412.5, condition, car: { position: [12, 1.1, -30], rotation: [0, 0.3, 0, 0.95], linvel: [4, 0, 1], angvel: [0, 0.2, 0] }, heat, intel, front, quests, }; } /** A pristine campaign for the same world, to restore into. */ function blank(): Snapshot { const front = createFront(1337, world.extent, world.spawn); return { seed: 1337, elapsed: 0, condition: freshCondition(), car: { position: [0, 0, 0], rotation: [0, 0, 0, 1], linvel: [0, 0, 0], angvel: [0, 0, 0] }, heat: createHeat(world.roads), intel: createIntel(world.roads, world.extent), front, quests: createQuests(), }; } describe('save round trip', () => { it('survives JSON, which is what actually goes to storage', () => { const source = played(); const restored = blank(); const data = JSON.parse(JSON.stringify(serialise(source))); expect(isLoadable(data, 1337, world.roads.segments.length, restored.intel.explored.length)).toBe( true, ); apply(data, restored); expect(restored.elapsed).toBe(source.elapsed); expect(restored.condition).toEqual(source.condition); expect(restored.car).toEqual(source.car); expect(restored.heat.value).toEqual(source.heat.value); expect(restored.heat.level).toEqual(source.heat.level); expect(restored.quests.completed).toBe(3); expect(restored.quests.parts).toBeCloseTo(0.42, 9); }); it('brings back the ceiling, not just the current condition', () => { const source = played(); const restored = blank(); // Otherwise a reload would quietly undo every permanent bit of damage, // which is the one thing "decline, not reset" cannot survive. expect(source.condition.ceiling.chassis).toBeLessThan(1); apply(JSON.parse(JSON.stringify(serialise(source))), restored); expect(restored.condition.ceiling).toEqual(source.condition.ceiling); }); it('restores what the player knew, including the parts that are wrong', () => { const source = played(); const restored = blank(); apply(JSON.parse(JSON.stringify(serialise(source))), restored); expect(restored.intel.seenAt).toEqual(source.intel.seenAt); expect(restored.intel.rememberedLevel).toEqual(source.intel.rememberedLevel); expect([...restored.intel.visitedNodes]).toEqual([...source.intel.visitedNodes]); expect(restored.intel.explored).toEqual(source.intel.explored); expect(restored.intel.rememberedControl).toEqual(source.intel.rememberedControl); expect(restored.intel.explored).toBeInstanceOf(Uint8Array); }); it('puts the front back where the war had got to', () => { const source = played(); const restored = blank(); expect(controlAt(restored.front, 100, 100)).toBeDefined(); apply(JSON.parse(JSON.stringify(serialise(source))), restored); stepFront(restored.front, 0); stepFront(source.front, 0); expect(restored.front.boundaries).toEqual(source.front.boundaries); }); it('keeps an in-flight mission, so a save mid-run is not a lost run', () => { const source = played(); source.quests.active = { id: 4, type: 'delivery', targetNode: 11, novel: true, route: { nodes: [1, 2], segments: [0], length: 120 }, intel: { worst: 'patrol', unknownCount: 1, stalest: 30 }, reward: 0.2, impact: 12, originBase: 1, stage: 'return', worked: 3, }; const restored = blank(); apply(JSON.parse(JSON.stringify(serialise(source))), restored); expect(restored.quests.active?.stage).toBe('return'); expect(restored.quests.active?.targetNode).toBe(11); }); }); describe('save validation', () => { const segments = world.roads.segments.length; const cells = createIntel(world.roads, world.extent).explored.length; const good = () => JSON.parse(JSON.stringify(serialise(played()))); it('accepts a save for this world', () => { expect(isLoadable(good(), 1337, segments, cells)).toBe(true); }); it('rejects a save from a different world', () => { // Seeds define the map; loading one campaign's roads into another's world // would put checkpoints and memories on roads that do not exist. expect(isLoadable(good(), 999, segments, cells)).toBe(false); }); it('rejects a save from an older format', () => { expect(isLoadable({ ...good(), version: SAVE_VERSION - 1 }, 1337, segments, cells)).toBe(false); }); it('rejects a save whose arrays no longer match the generator', () => { // Changing world generation invalidates old saves. Refusing is correct: // a half-restored campaign is worse than a fresh one. expect(isLoadable(good(), 1337, segments + 1, cells)).toBe(false); expect(isLoadable(good(), 1337, segments, cells + 1)).toBe(false); }); it('rejects junk without throwing', () => { for (const junk of [null, undefined, 42, 'save', {}, { version: SAVE_VERSION }]) { expect(isLoadable(junk, 1337, segments, cells)).toBe(false); } }); });