drive-between-the-lines/src/sim/quests.test.ts
dejvino 9eaa05d7ef Districts that remember you, and roads worth staying on
Area heat. Road heat alone rewarded a strange kind of play: work one part of
town hard but take a different street every time, and nothing ever got hot. A
second, coarser pool now accrues from being inside a district at all — on-road
or off — and bleeds into every road running through it, including ones never
driven. Deliberately capped below the barricade threshold: a hot district gets
roads patrolled, but concrete still takes somebody actually using that road. It
also decays slower than road heat, because one patrol re-secures a road while a
district's reputation lingers.

Surfaces. Off the route the car now makes about 40% of its road top speed, loses
grip and scrubs momentum in a fifth of the distance, so the road hierarchy is
something you feel rather than something the map merely claims. A first tuning
made off-road a 12 km/h crawl, which removed the choice instead of pricing it —
going round a checkpoint cross-country has to stay a legitimate move.

Speed. Heat scales with how fast you were going, not just how far, and stops
rewarding ever-higher speed past a point so the answer is never one exact number
on the clock. With the road classes, that makes the routing decision real: the
trunk fast and noticed, or the lanes slow and ignored.

Save format goes to v2 for the district pool; older saves are refused rather
than half-restored. Also fixes a population test that hardcoded densities since
tuned, and README claims about patrols and AI that Phase 6 made untrue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:06:59 +02:00

236 lines
9.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { generateWorld } from './world';
import { placeBases, baseAt } from './bases';
import { buildGraph, findRoute } from './routing';
import { createIntel, observe, summariseRoute, survey, hasSeen } from './intel';
import { createAreas, createHeat, stepHeat } from './heat';
import {
accept,
createQuests,
MISSION_SHAPE,
MISSION_TYPES,
offersAt,
rewardFor,
stepQuest,
} from './quests';
const world = generateWorld(1337);
const graph = buildGraph(world.roads);
const bases = placeBases(world.roads, world.spawn);
const areas = createAreas(world.roads, world.extent);
describe('bases', () => {
it('puts one where the player starts, so the loop is available at once', () => {
expect(baseAt(bases, world.spawn.x, world.spawn.z)).not.toBeNull();
});
it('spreads the rest out, so no single choke point can strand the player', () => {
const spacing: number[] = [];
for (let i = 0; i < bases.length; i++) {
for (let j = i + 1; j < bases.length; j++) {
spacing.push(Math.hypot(bases[i]!.x - bases[j]!.x, bases[i]!.z - bases[j]!.z));
}
}
expect(Math.min(...spacing)).toBeGreaterThan(100);
});
it('reaches every base by road from every other', () => {
for (const from of bases) {
for (const to of bases) {
if (from === to) continue;
expect(findRoute(graph, from.nodeId, to.nodeId)).not.toBeNull();
}
}
});
});
describe('routing', () => {
it('finds a route whose segments actually chain together', () => {
const route = findRoute(graph, bases[0]!.nodeId, bases[2]!.nodeId)!;
expect(route.nodes[0]).toBe(bases[0]!.nodeId);
expect(route.nodes.at(-1)).toBe(bases[2]!.nodeId);
expect(route.segments).toHaveLength(route.nodes.length - 1);
for (let i = 0; i < route.segments.length; i++) {
const s = world.roads.segments[route.segments[i]!]!;
const pair = [s.a, s.b];
expect(pair).toContain(route.nodes[i]);
expect(pair).toContain(route.nodes[i + 1]);
}
});
it('reports true metres, and no longer than the straight line is short', () => {
const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!;
const straight = Math.hypot(bases[0]!.x - bases[1]!.x, bases[0]!.z - bases[1]!.z);
expect(route.length).toBeGreaterThanOrEqual(straight - 1e-6);
const summed = route.segments.reduce((t, id) => t + world.roads.segments[id]!.length, 0);
expect(route.length).toBeCloseTo(summed, 6);
});
it('takes a detour when the direct road is weighted as expensive', () => {
const direct = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!;
const avoided = new Set(direct.segments);
const detour = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId, (id, length) =>
avoided.has(id) ? length * 100 : length,
)!;
// Loops in the network mean an alternative exists and costs more.
expect(detour.segments).not.toEqual(direct.segments);
expect(detour.length).toBeGreaterThan(direct.length);
});
});
describe('intel', () => {
it('starts blank and only records what was observed', () => {
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads, areas);
expect(hasSeen(intel, 4)).toBe(false);
observe(intel, heat, 4, 10);
expect(hasSeen(intel, 4)).toBe(true);
expect(hasSeen(intel, 5)).toBe(false);
});
it('remembers what a road was, not what it has since become', () => {
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads, areas);
observe(intel, heat, 0, 0);
expect(intel.rememberedLevel[0]).toBe('clear');
for (let m = 0; m < 400; m++) stepHeat(heat, { dt: 1 / 60, segmentId: 0, distance: 1 , areaId: null}, areas);
expect(heat.level[0]).toBe('turret');
// The player was not there to see it escalate.
expect(intel.rememberedLevel[0]).toBe('clear');
});
it('surveys a whole area at once, which is what recon is for', () => {
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads, areas);
const node = world.roads.nodes[24]!;
const mapped = survey(intel, heat, world.roads, node.x, node.z, 5);
expect(mapped).toBeGreaterThan(1);
expect(intel.seenAt.filter((t) => t >= 0)).toHaveLength(mapped);
});
it('summarises a route by its worst road and how much is unscouted', () => {
const intel = createIntel(world.roads, world.extent);
const heat = createHeat(world.roads, areas);
const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!;
expect(summariseRoute(intel, route.segments, 0).unknownCount).toBe(route.segments.length);
for (let m = 0; m < 400; m++) {
stepHeat(heat, { dt: 1 / 60, segmentId: route.segments[0]!, distance: 1 , areaId: null}, areas);
}
for (const id of route.segments) observe(intel, heat, id, 100);
const summary = summariseRoute(intel, route.segments, 400);
expect(summary.unknownCount).toBe(0);
expect(summary.worst).toBe('turret');
expect(summary.stalest).toBeCloseTo(300, 6);
});
});
describe('quest board', () => {
it('always offers something new to go and look at', () => {
const state = createQuests();
const intel = createIntel(world.roads, world.extent);
const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 1);
expect(offers.length).toBeGreaterThan(0);
expect(offers.some((o) => o.novel)).toBe(true);
});
it('offers both a known and an unknown target once somewhere has been visited', () => {
const state = createQuests();
const intel = createIntel(world.roads, world.extent);
intel.visitedNodes.add(world.roads.nodes[10]!.id);
intel.visitedNodes.add(world.roads.nodes[30]!.id);
const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 7);
expect(offers.some((o) => o.novel)).toBe(true);
expect(offers.some((o) => !o.novel)).toBe(true);
});
it('pays more for an unknown target, or nobody would ever take one', () => {
// Compared on the *same* route. An earlier version of this test compared
// rates across whatever targets each board happened to offer, which mostly
// measured route length: the reward has a fixed component, so a short trip
// always looks better per kilometre whether or not it is novel.
const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!;
for (const type of MISSION_TYPES) {
expect(rewardFor(type, route, true)).toBeGreaterThan(rewardFor(type, route, false));
}
});
it('never offers the base you are standing in as a target', () => {
const state = createQuests();
const intel = createIntel(world.roads, world.extent);
for (const base of bases) {
for (const offer of offersAt(state, world.roads, graph, intel, base, 0, 11)) {
expect(offer.targetNode).not.toBe(base.nodeId);
}
}
});
});
describe('mission lifecycle', () => {
const start = () => {
const state = createQuests();
const intel = createIntel(world.roads, world.extent);
const offer = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 5)[0]!;
accept(state, offer, bases[0]!);
return state;
};
it('runs travel → work → return → hand in', () => {
const state = start();
expect(state.active!.stage).toBe('travel');
expect(stepQuest(state, { dt: 1 / 60, distanceToTarget: 400, atBase: null, speed: 20 })).toBeNull();
expect(stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 20 })).toEqual({
kind: 'arrived',
});
// Still rolling: no work gets done.
stepQuest(state, { dt: 1, distanceToTarget: 5, atBase: null, speed: 20 });
expect(state.active!.worked).toBe(0);
let worked = null;
for (let i = 0; i < MISSION_SHAPE[state.active!.type].work * 60 + 2 && !worked; i++) {
worked = stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 });
}
expect(worked).toEqual({ kind: 'worked', type: state.active!.type });
expect(state.active!.stage).toBe('return');
const done = stepQuest(state, {
dt: 1 / 60,
distanceToTarget: 900,
atBase: bases[2]!,
speed: 0,
});
expect(done?.kind).toBe('completed');
expect(state.active).toBeNull();
expect(state.completed).toBe(1);
expect(state.parts).toBeGreaterThan(0);
});
it('accepts a hand-in at any base, so a blocked road home cannot strand you', () => {
const state = start();
stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 });
for (let i = 0; i < MISSION_SHAPE[state.active!.type].work * 60 + 2; i++) {
stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 });
}
const elsewhere = bases.find((b) => b.nodeId !== state.active!.originBase)!;
expect(
stepQuest(state, { dt: 1 / 60, distanceToTarget: 999, atBase: elsewhere, speed: 0 })?.kind,
).toBe('completed');
});
it('abandons half-finished work if you drive away', () => {
const state = start();
stepQuest(state, { dt: 1 / 60, distanceToTarget: 5, atBase: null, speed: 0 });
stepQuest(state, { dt: 1, distanceToTarget: 5, atBase: null, speed: 0 });
expect(state.active!.worked).toBeGreaterThan(0);
stepQuest(state, { dt: 1 / 60, distanceToTarget: 200, atBase: null, speed: 25 });
expect(state.active!.stage).toBe('travel');
expect(state.active!.worked).toBe(0);
});
});