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