Steering. The falloff with speed was linear, so the car had already lost a third of its lock by 20km/h — exactly the speed you take a right-angle junction at, on a map made of right-angle junctions. It is quadratic now, which leaves low speeds nearly untouched and still calms things down at pace, plus a faster rack and a little more lock. Pinned with a test that drives an actual quarter turn and measures its radius. A first attempt asserted that fast corners take longer, which is simply false: a fast car swings through ninety degrees quicker, just across far more tarmac. Radius is what a corner costs you. Contact. Units were points that ignored everything, so there was nothing to hit. Vehicles now carry kinematic bodies and ramming one is a real collision that damages them and shoves you. People get no collider on purpose — a capsule means snagging on pedestrians or launching them — so you drive through them and they go down, and running over a civilian is counted for later. Territory. Liberated ground covered more than half the map because the player started in the middle of it, so most of the world was safe by geometry. The starting base is now at the friendly edge and the bands are anchored to give roughly 15/25/27/33 across liberated, contested, occupied and frontier. Measured in the running game rather than guessed, since the axis runs diagonally across a square and area is not linear in depth. Also fixes a fragile test that compared reward rates across whichever targets a board happened to offer, and so mostly measured route length rather than the novelty premium it claimed to check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
9.3 KiB
TypeScript
273 lines
9.3 KiB
TypeScript
/**
|
|
* 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<MissionType, MissionShape> = {
|
|
// 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;
|
|
}
|