From 3787350d4a6a2a11bc1f900639587b2e91c9745f Mon Sep 17 00:00:00 2001 From: dejvino Date: Sun, 9 Aug 2026 07:20:28 +0200 Subject: [PATCH] A campaign starts knowing its own ground The map was blank everywhere on a fresh start, including behind your own lines, which read as amnesia rather than insurgency. You are a local with a car: the pocket your side holds is the part of the map you would obviously know, and the war is the part you would not. It also fixes the board. offersAt splits its offers into targets you have visited and targets you have not, and with nothing visited every offer came up "new" - so the known-against-new comparison the whole mission design turns on was not on screen until you had wandered around for a while. The first board of a fresh campaign now reads: a known target at 0.14 parts against new ones at 0.31 and 0.41. Strictly bounded to liberated ground. Contested ground is not yours to know, and none of this survives the line moving - the map still only remembers who held a place when you last stood in it. Co-Authored-By: Claude Opus 5 --- src/main.ts | 7 ++++++- src/sim/intel.ts | 45 ++++++++++++++++++++++++++++++++++++++++++ src/sim/quests.test.ts | 39 +++++++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/main.ts b/src/main.ts index 8925234..286a5c7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ import { areaAt, createAreas, createHeat, effectiveHeat, speedAttention, stepHea import { ROAD_ATTENTION, routeAt, segmentAt } from './sim/roads'; import { baseAt, placeBases } from './sim/bases'; import { buildGraph } from './sim/routing'; -import { createIntel, observe, reveal, survey } from './sim/intel'; +import { createIntel, observe, reveal, revealHomeGround, survey } from './sim/intel'; import { applyMissionImpact, controlAt, @@ -293,6 +293,11 @@ async function boot() { heatProps.finished(segment.id); } } + } else { + // A fresh campaign starts knowing its own ground. You are a local with a + // car, not an amnesiac: the pocket your side holds is the part of the map + // you would obviously know, and the war is the part you would not. + revealHomeGround(intel, heat, model.roads, elapsed, controlOf); } // Chassis transform at the end of the last two fixed steps, for interpolation. diff --git a/src/sim/intel.ts b/src/sim/intel.ts index 640710c..42ddfb5 100644 --- a/src/sim/intel.ts +++ b/src/sim/intel.ts @@ -137,6 +137,51 @@ export function survey( return count; } +/** + * What you already know at the start of a campaign: your own ground. + * + * A fresh map was blank everywhere, including behind your own lines, which made + * the first several sessions read as amnesia rather than as insurgency. You are + * a local with a car — the pocket your own side holds is the part of the map you + * would obviously know, and the war is the part you would not. + * + * It also fixes the board. `offersAt` splits its offers into targets you have + * visited and targets you have not, and with nothing visited every offer came + * up "new" — so the known-against-new comparison the whole mission design turns + * on simply was not on screen until you had wandered for a while. + * + * Strictly bounded to liberated ground. Contested ground is not yours to know. + */ +export function revealHomeGround( + intel: Intel, + heat: HeatState, + roads: RoadNetwork, + now: number, + controlOf: (x: number, z: number) => Control, +): void { + for (let row = 0; row < intel.cellsPerSide; row++) { + for (let col = 0; col < intel.cellsPerSide; col++) { + const x = intel.gridOrigin + (col + 0.5) * CELL_SIZE; + const z = intel.gridOrigin + (row + 0.5) * CELL_SIZE; + if (controlOf(x, z) !== 'liberated') continue; + const index = row * intel.cellsPerSide + col; + intel.explored[index] = 1; + intel.rememberedControl[index] = CONTROLS.indexOf('liberated'); + } + } + + for (const s of roads.segments) { + if (controlOf((s.ax + s.bx) / 2, (s.az + s.bz) / 2) !== 'liberated') continue; + observe(intel, heat, s.id, now); + } + + // Junctions on home ground count as places you have been, which is what makes + // them eligible as the board's "known target". + for (const n of roads.nodes) { + if (controlOf(n.x, n.z) === 'liberated') intel.visitedNodes.add(n.id); + } +} + export interface RouteIntel { /** Worst level remembered anywhere on the route. */ worst: HeatLevel; diff --git a/src/sim/quests.test.ts b/src/sim/quests.test.ts index a0c1277..8c1cc8e 100644 --- a/src/sim/quests.test.ts +++ b/src/sim/quests.test.ts @@ -2,8 +2,9 @@ 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 { createIntel, observe, revealHomeGround, summariseRoute, survey, hasSeen } from './intel'; import { createAreas, createHeat, stepHeat } from './heat'; +import { controlAt, createFront } from './regions'; import { accept, createQuests, @@ -233,3 +234,39 @@ describe('mission lifecycle', () => { expect(state.active!.worked).toBe(0); }); }); + +describe('what a campaign starts out knowing', () => { + const started = () => { + const intel = createIntel(world.roads, world.extent); + const heat = createHeat(world.roads, areas); + const front = createFront(1337, world.extent, world.spawn); + revealHomeGround(intel, heat, world.roads, 0, (x, z) => controlAt(front, x, z)); + return { intel, heat, front }; + }; + + it('knows its own ground and nothing past the line', () => { + const { intel, front } = started(); + let home = 0; + let away = 0; + for (const s of world.roads.segments) { + const liberated = + controlAt(front, (s.ax + s.bx) / 2, (s.az + s.bz) / 2) === 'liberated'; + if (liberated) home += hasSeen(intel, s.id) ? 1 : 0; + else away += hasSeen(intel, s.id) ? 1 : 0; + } + expect(home).toBeGreaterThan(3); + // The war is the part you do not know. Nothing past the line is filled in. + expect(away).toBe(0); + }); + + it('gives the board a known target to weigh a new one against', () => { + // With nothing visited, offersAt has no "visited" pool at all and every + // offer comes up new — so the comparison the mission design turns on was + // not on screen until the player had wandered around for a while. + const { intel, front } = started(); + const offers = offersAt(createQuests(), world.roads, graph, intel, bases[0]!, 0, 99); + expect(offers.some((o) => !o.novel)).toBe(true); + expect(offers.some((o) => o.novel)).toBe(true); + void front; + }); +});