drive-between-the-lines/src/sim/save.test.ts
dejvino 90dc18fd17 Braking, stutter, a real road hierarchy, denser towns, and autosave
Braking. Rapier brake impulses were far too small next to a 1100kg chassis, so
the car would not come to a standstill. Raised, plus a coast brake so lifting
off actually slows you and a hold below walking pace so "stop the car" for a
mission is not a fight. First attempt overshot to 1.9g, which reads as hitting a
wall; tuned to about 0.8g, ~25m from 70km/h, and pinned with tests that assert
both an upper and a lower bound on stopping distance.

Stutter. The render drew the raw latest physics transform, so frames that
consumed two steps or none landed as visible jitter at speed. Now interpolated
between the last two steps by the leftover accumulator. Buildings moved into one
instanced draw call and the minimap dropped to 10Hz, both of which were wasted
work in a frame budget that also has to fit a shadow pass.

Roads. Three classes: single-track lanes, two-lane roads, four-lane trunks that
cross the whole map and are never thinned away. Class drives width, the route
corridor, barricade span, map line weight, surface colour, and two things that
matter more: routing is now by travel time so journeys prefer trunks, and
attention scales with class, so a farm track is slow and unwatched while a trunk
is fast and the first place anyone looks.

Towns. Buildings were small and sparse enough to drive around freely. They are
now laid out on a jittered lattice — rejection sampling cannot pack boxes and
saturated at a third of the target — which takes bypass lanes around a barricade
from 0% obstructed to about two thirds. World scaled to 400m half-extent, 81
junctions, 107 roads.

Autosave. Every 20 seconds, plus an immediate checkpoint on taking and
completing a job. Saves carry the subsystem ceilings, what the player knew
including the parts that are now wrong, and where the war had got to. A save is
validated against seed, format version and array lengths before anything is
applied: half-restoring a campaign is worse than starting a fresh one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:28:15 +02:00

166 lines
6.2 KiB
TypeScript

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);
}
});
});