Step 27.2: sim: routing and triggers
CI / check (push) Successful in 8m37s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-18 12:59:05 -04:00
parent bd2a8de100
commit 58ce4f09e6
6 changed files with 333 additions and 1 deletions

View File

@ -2000,3 +2000,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The booking lives on the ship** (one per ship, as the CR has one booking per ship), with its timings: `startedAt` once the work is in the shop (the booking clears at the shop end), `waitingSince` while it waits for funds at the HQ.
- **One upgrade level per booking** (`upgradeId`: the next level of that upgrade when the work starts), as the CR allows one level per visit.
## Step 27.2
- **"Reachable" is one leg**, with a full tank where the ship stands if fuel is sold there (the CR's "even with refueling"), else the fuel aboard, and the no-strand rule. Multi-leg routes aren't in the game (plan 0.3).
- **The auto-repair trigger fires on crossing the threshold** (from at or above it to below it), not on every hit while below: a player who cancels an automatic booking isn't re-booked by the next scratch.
- **Repair recommended is a state**, not an event: a Manual crew's hull below the threshold with auto-repair on and no repair booked.
- **The pilot tests book at Mars**: a Clipper can't reach Ceres from Earth in one leg, which the reach test checks.

View File

@ -570,7 +570,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C
Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR-025 … Phase 25"). New decisions recorded as plan §0.5.153–0.5.159.
- [x] **Step 27.1 — Content and save.** Config `autoRepair` (default 40%, 10–90% in steps of 5; the default must be a step). Save version 12: each ship has `booking` (repair, upgrade, HQ, by player or auto, created, started, waiting for funds since; null when none) and `autoRepair` (on, threshold); new ships start on at 40% with no booking; the 11 → 12 migration does the same for existing ships. Tests: `packages/content/test/config.test.ts`; `packages/schema/test/save.test.ts` (11 → 12); `packages/engine/test/trip-events.test.ts` (a bought ship).
- [ ] Step 27.2 — Sim: routing and triggers
- [x] **Step 27.2 — Sim: routing and triggers.** `packages/sim/src/service.ts`: `canReachHq` (one leg under the fuel rules: the route, landing, no-strand, a full tank where fuel is sold, else the fuel aboard), `nearestReachableHq`, `bookingAllowsJob`, `autoRepairBooks` (crossing below the threshold, on, Independent crew, no repair already booked) and `repairRecommended` (a Manual crew below the threshold). `chooseAction` takes an optional booking: at the HQ the pilot waits for the work; with the HQ reachable, only jobs ending there, else `deadhead-service` straight there (under the fuel rules and spending floor), else wait; out of reach, the usual choice. Tests (`packages/sim/test/service.test.ts`): reach (Mars yes, Ceres no for a Clipper from Earth); only jobs to the booked HQ; the deadhead there; waiting at the HQ; normal work when it's out of reach; the nearest HQ; the auto-repair trigger for each case, adding a repair to an upgrade booking; Repair recommended.
- [ ] Step 27.3 — Engine: booking commands and service start
- [ ] Step 27.4 — Engine: Independent pilots and catch-up
- [ ] Step 27.5 — Client: Service section and tags

View File

@ -14,6 +14,7 @@ import {
} from "./dispatch";
import { fuelPriceAt, refuel, type Tank } from "./fuel";
import { pilotShare } from "./pay";
import { bookingAllowsJob, canReachHq, type ServiceTarget } from "./service";
/** What the player tells an Independent pilot (decision 0.5.124). */
export interface PilotOrders {
@ -55,6 +56,8 @@ export interface IndependentInput {
emptyPeriods: number;
/** The contract the player has open, which pilots leave alone (decision 0.5.126). */
viewedContractId?: string | null;
/** Service booked at an HQ (decision 0.5.154): the pilot works their way there. */
booking?: ServiceTarget | null;
}
/** Fuel to buy before a leg: what it burns beyond what's aboard, rounded up to 0.001 fu. */
@ -118,6 +121,8 @@ export function independentOptions(input: IndependentInput, content: SimContent)
const options: JobOption[] = [];
for (const contract of input.board) {
if (contract.firstContact || contract.id === input.viewedContractId) continue;
// Heading for service: only jobs that end at the booked HQ (decision 0.5.154).
if (input.booking && !bookingAllowsJob(contract, input.booking)) continue;
if (orders.destinationIds && !orders.destinationIds.includes(contract.destinationId)) continue;
if (!orders.cargoClassIds.includes(contract.cargoTypeId)) continue;
if (contract.priority && !orders.takePriority) continue;
@ -165,10 +170,52 @@ export function compareOptions(a: JobOption, b: JobOption): number {
return a.contract.id < b.contract.id ? -1 : a.contract.id > b.contract.id ? 1 : 0;
}
/**
* With service booked at a reachable HQ (decision 0.5.154): the best job that
* ends there, else a deadhead straight there if the fuel rules allow, else wait.
*/
function serviceAction(input: IndependentInput, hqId: string, content: SimContent): PilotAction {
const [best] = independentOptions(input, content).sort(compareOptions);
if (best) return { kind: "take", contractId: best.contract.id, buyFu: best.buyFu };
const deadhead = deadheadTo(input, hqId, content);
return deadhead ? { kind: "deadhead-service", ...deadhead } : { kind: "wait" };
}
/** A deadhead to `destinationId` under the fuel rules, with the fuel to buy first; null if it can't go. */
function deadheadTo(
input: IndependentInput,
destinationId: string,
content: SimContent,
): { destinationId: string; buyFu: number } | null {
const { ship } = input;
const model = shipStats(ship, content);
const burnFu = tripFuel(
model,
findLocation(ship.locationId, content),
findLocation(destinationId, content),
content.config,
);
const fuel = legRefuel(input, burnFu, content);
if (!fuel) return null;
const check = validateReposition(
{
ship: { ...ship, tank: fuel.tank },
pilot: input.pilot,
destinationId,
discoveredIds: input.discoveredIds,
now: input.now,
},
content,
);
return check.ok ? { destinationId, buyFu: fuel.buyFu } : null;
}
/** What an Independent pilot does when they can act (CR-021 §2). */
export type PilotAction =
| { kind: "take"; contractId: string; buyFu: number }
| { kind: "deadhead-home"; destinationId: string; buyFu: number }
/** Straight to the booked HQ for service (decision 0.5.154). */
| { kind: "deadhead-service"; destinationId: string; buyFu: number }
| { kind: "wait" };
/**
@ -177,6 +224,16 @@ export type PilotAction =
* deadhead home if the ship can make it under the fuel rules; else wait.
*/
export function chooseAction(input: IndependentInput, content: SimContent): PilotAction {
const booking = input.booking ?? null;
if (booking) {
// At the HQ, the engine starts the work; the pilot stays put.
if (input.ship.locationId === booking.hqId) return { kind: "wait" };
// Out of reach, the booking waits and the pilot works as usual (decision 0.5.154).
if (canReachHq(input.ship, booking.hqId, input.discoveredIds, content)) {
return serviceAction(input, booking.hqId, content);
}
return chooseAction({ ...input, booking: null }, content);
}
const [best] = independentOptions(input, content).sort(compareOptions);
if (best) return { kind: "take", contractId: best.contract.id, buyFu: best.buyFu };

View File

@ -176,3 +176,11 @@ export {
repairCost,
repairShopMs,
} from "./hull";
export {
autoRepairBooks,
bookingAllowsJob,
canReachHq,
nearestReachableHq,
repairRecommended,
} from "./service";
export type { AutoRepair, ServiceTarget } from "./service";

View File

@ -0,0 +1,93 @@
import type { SimContent } from "./board";
import type { Contract } from "./contract";
import { findLocation, shipStats, tripFuel, wouldStrand, type ShipState } from "./dispatch";
import { routeDistance } from "./distance";
import { checkRoute } from "./eligibility";
import type { Tank } from "./fuel";
/** A booking as routing sees it (decision 0.5.153): where the service is. */
export interface ServiceTarget {
hqId: string;
}
/**
* Whether a ship can get from where it is to an HQ in one leg under the fuel
* rules (decision 0.5.154): the route, landing and the no-strand rule, with a
* full tank where its location sells fuel, else the fuel aboard. Already
* there counts as reachable.
*/
export function canReachHq(
ship: ShipState & { tank: Tank },
hqId: string,
discoveredIds: ReadonlySet<string>,
content: SimContent,
): boolean {
if (ship.locationId === hqId) return true;
if (!discoveredIds.has(hqId)) return false;
const model = shipStats(ship, content);
const origin = findLocation(ship.locationId, content);
const destination = findLocation(hqId, content);
if (!checkRoute(model, origin, destination, content.config).ok) return false;
const available = origin.sellsFuel ? model.tankCapacityFu : ship.tank.fuelFu;
const burn = tripFuel(model, origin, destination, content.config);
if (burn > available) return false;
return !wouldStrand(model, destination, available - burn, discoveredIds, content);
}
/** The nearest HQ the ship can reach (decision 0.5.153); null when none can be reached. */
export function nearestReachableHq(
ship: ShipState & { tank: Tank },
hqIds: readonly string[],
discoveredIds: ReadonlySet<string>,
content: SimContent,
): string | null {
const reachable = hqIds
.filter((id) => canReachHq(ship, id, discoveredIds, content))
.map((id) => ({
id,
distance: routeDistance(findLocation(ship.locationId, content), findLocation(id, content)),
}))
.sort((a, b) => a.distance - b.distance || (a.id < b.id ? -1 : 1));
return reachable[0]?.id ?? null;
}
/** With a booking, a pilot takes only jobs ending at the booked HQ (decision 0.5.154). */
export function bookingAllowsJob(contract: Contract, booking: ServiceTarget | null): boolean {
return booking === null || contract.destinationId === booking.hqId;
}
/** A ship's auto-repair setting (decision 0.5.157). */
export interface AutoRepair {
on: boolean;
thresholdPct: number;
}
/**
* Whether a hull change books a repair (decision 0.5.157): only when it
* crosses below the threshold, with auto-repair on, an Independent crew, and
* no repair already booked. An existing booking gets the repair added.
*/
export function autoRepairBooks(input: {
hullBefore: number;
hullAfter: number;
autoRepair: AutoRepair;
pilotMode: "manual" | "independent" | null;
booking: { repair: boolean } | null;
}): boolean {
const { hullBefore, hullAfter, autoRepair, pilotMode, booking } = input;
if (!autoRepair.on || pilotMode !== "independent" || booking?.repair) return false;
return hullBefore >= autoRepair.thresholdPct && hullAfter < autoRepair.thresholdPct;
}
/** "Repair recommended" (decision 0.5.157): a Manual crew's hull below the auto-repair threshold. */
export function repairRecommended(input: {
hullPct: number;
autoRepair: AutoRepair;
pilotMode: "manual" | "independent" | null;
booking: { repair: boolean } | null;
}): boolean {
const { hullPct, autoRepair, pilotMode, booking } = input;
return (
autoRepair.on && pilotMode === "manual" && hullPct < autoRepair.thresholdPct && !booking?.repair
);
}

View File

@ -0,0 +1,167 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
autoRepairBooks,
boardPeriodMs,
canReachHq,
chooseAction,
defaultOrders,
fuelPriceAt,
independentOptions,
MS_PER_MINUTE,
nearestReachableHq,
repairRecommended,
type Contract,
type IndependentInput,
} from "../src/index";
const { config } = content;
const clipper = content.shipModel("clipper")!;
const PERIOD = boardPeriodMs(config);
const PERIOD_START = 1000 * PERIOD;
const AFTER_LOOK =
PERIOD_START + (config.independentPilots.firstLookRealMinutes + 1) * MS_PER_MINUTE;
const EARTH_PRICE = fuelPriceAt(content.location("earth")!, config);
const DISCOVERED = new Set(["earth", "moon", "mars", "ceres-station", "jupiter-cloud-platform"]);
const job = (id: string, destinationId: string): Contract => ({
id,
originId: "earth",
destinationId,
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 30_000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: PERIOD_START,
expiresAt: PERIOD_START + PERIOD,
firstContact: false,
});
function input(board: Contract[], over: Partial<IndependentInput> = {}): IndependentInput {
return {
ship: {
id: "s",
modelId: "clipper",
locationId: "earth",
inFlight: false,
tank: { fuelFu: clipper.tankCapacityFu, avgPrice: EARTH_PRICE },
},
pilot: {
id: "p",
isAvatar: false,
assignedShipId: "s",
inFlight: false,
skills: { "long-haul": 3 },
restEndsAt: null,
},
orders: defaultOrders("earth", content),
board,
now: AFTER_LOOK,
credits: 1_000_000,
spendingFloor: config.independentPilots.defaultSpendingFloor,
discoveredIds: DISCOVERED,
emptyPeriods: 0,
...over,
};
}
// Mars is one leg from Earth for a Clipper (CR-015); Ceres isn't.
const mars = { hqId: "mars" };
describe("service routing (Step 27.2, decisions 0.5.153, 0.5.154)", () => {
it("can reach Mars but not Ceres from Earth in a Clipper", () => {
expect(canReachHq(input([]).ship, "mars", DISCOVERED, content)).toBe(true);
expect(canReachHq(input([]).ship, "ceres-station", DISCOVERED, content)).toBe(false);
});
it("with a booking at Mars, considers only jobs that end at Mars", () => {
const board = [job("a", "moon"), job("b", "ceres-station"), job("c", "mars")];
const options = independentOptions(input(board, { booking: mars }), content);
expect(options.map((o) => o.contract.destinationId)).toEqual(["mars"]);
expect(chooseAction(input(board, { booking: mars }), content)).toMatchObject({
kind: "take",
contractId: "c",
});
});
it("deadheads straight to Mars when no job goes there", () => {
const board = [job("a", "moon")];
expect(chooseAction(input(board, { booking: mars }), content)).toMatchObject({
kind: "deadhead-service",
destinationId: "mars",
});
});
it("waits at the booked HQ for the work to start", () => {
const at = input([job("a", "moon")], { booking: mars });
const there = { ...at, ship: { ...at.ship, locationId: "mars" } };
expect(chooseAction(there, content)).toEqual({ kind: "wait" });
});
it("works as usual when the booked HQ is out of reach", () => {
// A Courier can't reach Jupiter from Earth.
const courier = content.shipModel("courier")!;
const base = input([job("a", "moon")], { booking: { hqId: "jupiter-cloud-platform" } });
const small = {
...base,
ship: {
...base.ship,
modelId: "courier",
tank: { fuelFu: courier.tankCapacityFu, avgPrice: 5 },
},
};
expect(canReachHq(small.ship, "jupiter-cloud-platform", DISCOVERED, content)).toBe(false);
expect(chooseAction(small, content)).toMatchObject({ kind: "take", contractId: "a" });
});
it("finds the nearest reachable HQ", () => {
const ship = input([]).ship;
expect(nearestReachableHq(ship, ["mars", "moon"], DISCOVERED, content)).toBe("moon");
expect(nearestReachableHq(ship, ["earth", "moon"], DISCOVERED, content)).toBe("earth");
const courier = { ...ship, modelId: "courier", tank: { fuelFu: 40, avgPrice: 5 } };
expect(nearestReachableHq(courier, ["jupiter-cloud-platform"], DISCOVERED, content)).toBeNull();
});
});
describe("auto-repair (Step 27.2, decision 0.5.157)", () => {
const on = { on: true, thresholdPct: 40 };
const drop = { hullBefore: 45, hullAfter: 35, autoRepair: on, booking: null };
it("books a repair when an Independent crew's hull drops below the threshold", () => {
expect(autoRepairBooks({ ...drop, pilotMode: "independent" })).toBe(true);
});
it("doesn't with a Manual crew, with auto-repair off, or without crossing the threshold", () => {
expect(autoRepairBooks({ ...drop, pilotMode: "manual" })).toBe(false);
expect(
autoRepairBooks({ ...drop, pilotMode: "independent", autoRepair: { ...on, on: false } }),
).toBe(false);
expect(autoRepairBooks({ ...drop, pilotMode: "independent", hullBefore: 38 })).toBe(false);
expect(autoRepairBooks({ ...drop, pilotMode: "independent", hullAfter: 41 })).toBe(false);
});
it("adds the repair to an upgrade booking, but not a second repair", () => {
expect(autoRepairBooks({ ...drop, pilotMode: "independent", booking: { repair: false } })).toBe(
true,
);
expect(autoRepairBooks({ ...drop, pilotMode: "independent", booking: { repair: true } })).toBe(
false,
);
});
it("recommends a repair for a Manual crew below the threshold", () => {
const base = { hullPct: 35, autoRepair: on, booking: null };
expect(repairRecommended({ ...base, pilotMode: "manual" })).toBe(true);
expect(repairRecommended({ ...base, pilotMode: "independent" })).toBe(false);
expect(repairRecommended({ ...base, pilotMode: "manual", hullPct: 50 })).toBe(false);
expect(repairRecommended({ ...base, pilotMode: "manual", booking: { repair: true } })).toBe(
false,
);
});
});