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>
166 lines
6.2 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|