diff --git a/DECISIONS.md b/DECISIONS.md index 76bbd56..4b9cf0b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 09c86f4..08a6e99 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/sim/src/independent.ts b/packages/sim/src/independent.ts index 6313f99..d8ed510 100644 --- a/packages/sim/src/independent.ts +++ b/packages/sim/src/independent.ts @@ -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 }; diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 3147c2c..125512f 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -176,3 +176,11 @@ export { repairCost, repairShopMs, } from "./hull"; +export { + autoRepairBooks, + bookingAllowsJob, + canReachHq, + nearestReachableHq, + repairRecommended, +} from "./service"; +export type { AutoRepair, ServiceTarget } from "./service"; diff --git a/packages/sim/src/service.ts b/packages/sim/src/service.ts new file mode 100644 index 0000000..3eb4259 --- /dev/null +++ b/packages/sim/src/service.ts @@ -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, + 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, + 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 + ); +} diff --git a/packages/sim/test/service.test.ts b/packages/sim/test/service.test.ts new file mode 100644 index 0000000..329b7d9 --- /dev/null +++ b/packages/sim/test/service.test.ts @@ -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 { + 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, + ); + }); +});