/** * Missions and target selection. Pure — no engine imports. * * Phase 2's job is to turn heat into a decision at the point of choice: a known * target whose route you can assess against a new one you cannot. The board * therefore always offers both, and pays more for the unknown — otherwise the * known option is strictly better and the choice is not a choice. */ import { makeRng, randRange, type Rng } from '../core/rng'; import type { Base } from './bases'; import { summariseRoute, type Intel, type RouteIntel } from './intel'; import { findRoute, travelTime, type Graph, type Route } from './routing'; import type { RoadNetwork } from './roads'; /** * The four jobs the insurgency actually needs doing. * * They are meant to differ in *pacing and risk*, not just in the noun on the * board — otherwise "four mission types" is one mission type with four labels. * The shape of each is set by MISSION_SHAPE below. */ export type MissionType = 'supply' | 'retrieval' | 'intel' | 'recon'; export interface MissionShape { label: string; /** Seconds stopped at the target. */ work: number; /** Words for what you are doing while parked there. */ working: string; /** True if you are carrying something home that can be lost. */ cargoHome: boolean; /** Multiplier on parts paid. */ reward: number; /** Multiplier on how far the job shifts the front. */ impact: number; } export const MISSION_SHAPE: Record = { // Quick drop, paid on the way back. The baseline. supply: { label: 'Supply run', work: 3, working: 'unloading', cargoHome: false, reward: 1, impact: 1.2 }, // The dangerous one: the risk is all on the return leg, with the goods aboard // and a road you have already made hot behind you. retrieval: { label: 'Retrieval', work: 5, working: 'loading', cargoHome: true, reward: 1.35, impact: 1.1, }, // A long stationary wait in hostile ground. Nothing to carry, but the sitting // still is the risk, and it is the one job that pays in front-line movement. intel: { label: 'Intel drop', work: 9, working: 'transmitting', cargoHome: false, reward: 1.1, impact: 1.5, }, // Cheap and quick on site; the payoff is the map, not the money. recon: { label: 'Survey', work: 2, working: 'surveying', cargoHome: false, reward: 1.15, impact: 0.8 }, }; export const MISSION_TYPES = Object.keys(MISSION_SHAPE) as MissionType[]; export interface Offer { id: number; type: MissionType; /** Node the player has to reach. */ targetNode: number; /** True when the player has never been to this target. */ novel: boolean; route: Route; intel: RouteIntel; /** Parts awarded on hand-in. */ reward: number; /** * How far this job pushes the front line, in metres. The brief asks for * impact to be weighted per mission rather than every job counting the same, * so a long haul into unknown country moves the war more than a local errand. */ impact: number; } export interface ActiveQuest extends Offer { originBase: number; stage: 'travel' | 'working' | 'return'; /** Seconds of work completed at the target. */ worked: number; /** Condition of the goods being carried home, 1..0. Retrieval jobs only. */ cargo: number; } export interface QuestState { active: ActiveQuest | null; completed: number; /** Unspent repair parts. */ parts: number; nextId: number; } export const createQuests = (): QuestState => ({ active: null, completed: 0, parts: 0, nextId: 1, }); /** How close counts as "there", for both targets and bases. */ export const ARRIVAL_RADIUS = 18; /** * Share of the cargo's condition lost per newton-ish of impact on the way home. * Retrieval pays more, but only if what you are carrying survives the trip. */ const CARGO_FRAGILITY = 1.4e-6; /** * Novel targets pay this much more. This multiplier *is* the phase's tuning * dial: if the board's "new target" option is never taken, it is too low; if * known targets are never taken, too high. */ const NOVELTY_BONUS = 1.8; const PARTS_PER_KM = 0.1; /** Exported so the novelty premium can be tested on like for like. */ export function rewardFor(type: MissionType, route: Route, novel: boolean): number { const base = 0.08 + (route.length / 1000) * PARTS_PER_KM; return base * (novel ? NOVELTY_BONUS : 1) * MISSION_SHAPE[type].reward; } /** Metres of front-line movement a job is worth. */ const IMPACT_BASE = 5; const IMPACT_PER_KM = 11; function impactFor(type: MissionType, route: Route, novel: boolean): number { const base = IMPACT_BASE + (route.length / 1000) * IMPACT_PER_KM; return base * (novel ? 1.4 : 1) * MISSION_SHAPE[type].impact; } /** * Builds the board for a base: always at least one previously visited target * and one the player has never been to, so the comparison the phase is testing * is on screen every time. */ export function offersAt( state: QuestState, roads: RoadNetwork, graph: Graph, intel: Intel, base: Base, now: number, seed: number, ): Offer[] { const rng: Rng = makeRng(seed); const candidates = roads.nodes.filter((n) => n.id !== base.nodeId); const visited = candidates.filter((n) => intel.visitedNodes.has(n.id)); const fresh = candidates.filter((n) => !intel.visitedNodes.has(n.id)); const pick = (pool: typeof candidates): number | null => pool.length === 0 ? null : pool[Math.floor(rng() * pool.length)]!.id; const chosen: Array<{ node: number; novel: boolean }> = []; const known = pick(visited); if (known !== null) chosen.push({ node: known, novel: false }); for (const _ of [0, 1]) { const novel = pick(fresh.filter((n) => !chosen.some((c) => c.node === n.id))); if (novel !== null) chosen.push({ node: novel, novel: true }); } // Early on there is nothing visited yet; top up so the board is never thin. while (chosen.length < 3) { const any = pick(candidates.filter((n) => !chosen.some((c) => c.node === n.id))); if (any === null) break; chosen.push({ node: any, novel: !intel.visitedNodes.has(any) }); } const offers: Offer[] = []; for (const { node, novel } of chosen) { // Routed by time, not distance, so the board's estimate matches the way a // driver would actually go: trunk where there is one, lanes where there is not. const route = findRoute(graph, base.nodeId, node, travelTime(roads)); if (!route) continue; const type = MISSION_TYPES[Math.floor(rng() * MISSION_TYPES.length)]!; offers.push({ id: state.nextId++, type, targetNode: node, novel, route, intel: summariseRoute(intel, route.segments, now), reward: rewardFor(type, route, novel) * randRange(rng, 0.9, 1.1), impact: impactFor(type, route, novel), }); } return offers; } export function accept(state: QuestState, offer: Offer, base: Base): void { state.active = { ...offer, originBase: base.nodeId, stage: 'travel', worked: 0, cargo: 1 }; } export interface QuestStep { dt: number; /** Distance from the car to the active target, metres. */ distanceToTarget: number; /** Base the car is currently sitting at, or null. */ atBase: Base | null; /** Metres per second, so "stopped" can be required for work. */ speed: number; /** Impact force this step, which damages anything being carried. */ impactForce?: number; } export type QuestEvent = | { kind: 'arrived' } | { kind: 'worked'; type: MissionType } | { kind: 'cargoDamaged'; cargo: number } | { kind: 'completed'; parts: number; impact: number; cargo: number }; /** Advances the active mission. Returns whatever just happened, if anything. */ export function stepQuest(state: QuestState, step: QuestStep): QuestEvent | null { const quest = state.active; if (!quest) return null; const stopped = step.speed < 2.5; if (quest.stage === 'travel') { if (step.distanceToTarget < ARRIVAL_RADIUS) { quest.stage = 'working'; return { kind: 'arrived' }; } return null; } if (quest.stage === 'working') { // Wandering off resets the job — you have to actually stop and do it. if (step.distanceToTarget > ARRIVAL_RADIUS * 1.5) { quest.stage = 'travel'; quest.worked = 0; return null; } if (!stopped) return null; quest.worked += step.dt; if (quest.worked >= MISSION_SHAPE[quest.type].work) { quest.stage = 'return'; return { kind: 'worked', type: quest.type }; } return null; } // On the way home. Anything being carried is now at risk — which is what // makes retrieval a different job rather than a supply run in reverse. let damaged = false; if (MISSION_SHAPE[quest.type].cargoHome && step.impactForce) { const before = quest.cargo; quest.cargo = Math.max(0, quest.cargo - step.impactForce * CARGO_FRAGILITY); damaged = quest.cargo < before - 1e-6; } // Hand in at *any* base, not the one that issued it. The brief is explicit // that a blocked road home must never strand the player. if (step.atBase && stopped) { // Damaged goods are worth less. The job still counts; it just paid badly. const paid = quest.reward * (MISSION_SHAPE[quest.type].cargoHome ? quest.cargo : 1); state.active = null; state.completed++; state.parts += paid; return { kind: 'completed', parts: paid, impact: quest.impact * quest.cargo, cargo: quest.cargo }; } return damaged ? { kind: 'cargoDamaged', cargo: quest.cargo } : null; }