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>
236 lines
9.3 KiB
TypeScript
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);
|
|
});
|
|
});
|