From c4ebe88369d72ad97eb7541272d017c7d1f7922f Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 18 Oct 2026 13:14:02 -0400 Subject: [PATCH] Step 27.4: engine: Independent pilots and catch-up Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 + memory-bank/architecture.md | 2 + memory-bank/progress.md | 2 +- packages/engine/src/game/catchup.ts | 35 +++- packages/engine/test/service-routing.test.ts | 162 +++++++++++++++++++ 5 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 packages/engine/test/service-routing.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 1194d25..98bed16 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2016,3 +2016,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **A stay shorter than a rest doesn't take the pilot along** (CR-023's rule): a short repair runs beside the pilot's rest, which ends on its own, so the pilot is still free at the later end. - **An automatic booking with no reachable HQ goes to the first HQ** and shows as "can't reach"; the pilot works as usual (decision 0.5.155). - **Booking again replaces an unstarted booking**: that is the "Change" in the Service section. + +## Step 27.4 + +- **A service trip is any decision while the ship has an unstarted, reachable booking and isn't at the HQ yet**: only those pilots decide past the fleet's sleep time (still before the 7-day cap). A pilot waiting at the HQ (for funds) follows the sleep rule, so a long wait doesn't fill the catch-up with idle looks. +- **After the work, the pilot goes back to their orders**; hull wear on the next trips can cross the threshold again and book the next repair. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index ce6f68d..20be815 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -162,3 +162,5 @@ Balance simulator events (CR-026 Step 26.9): `simulate` options `events` ("off" `packages/engine/src/game/catchup.ts`: `catchUp(save, until, { decideUntil, maxEvents, viewedContractId })` replays time as an event queue. It takes the next in-flight arrival or the next Independent pilot's `nextDecisionAt`, in time order: arrivals first at the same moment, then pilots by id. Each arrival resolves at its own time. A decision point checks rest, the shop and the first-look window (each pushes `nextDecisionAt` later), then asks the sim's `chooseAction` about the pilot's board. It acts through the normal `refuelShip`, `dispatchContract` and `dispatchReposition`, with ids made from the pilot and the moment (`ind-…`), so the same time always gives the same save. Waiting schedules the next board period's first-look end and counts empty periods away from home. A trip's landing schedules the next decision a turnaround after the arrival, or after the rest it triggers. Decisions stop `maxCatchUpRealDays` after the save's `lastTimeSeen` (`decideUntil`) and at the fleet's sleep time (`sleepsAt` in `state.ts`: `company.lastManualDecisionAt` plus `sleepAfterHours`, CR-024), and later ones resume from `until`, so the next manual decision wakes pilots from then. Arrivals and shop ends always resolve. A null `lastManualDecisionAt` (a save just migrated) is set to `until` first. Every command runs `catchUp` to its `now` before it acts. `GameEngine.resolveDue(since, { chunkEvents, onProgress })` runs the loop's pass in chunks (500 events, one save each), with `onProgress(0…1)` in between. `GameEngine.setViewedContract(id)` tells pilots which contract the player has open. Dispatches an Independent pilot chose are flagged `independent`, and no notification is scheduled for them. `packages/engine/src/game/service.ts` (CR-027): ship `booking` and `autoRepair`. `bookService`, `cancelBooking` and `setAutoRepair` are the commands; `startDueServices` runs after every command and every catch-up event (start, trip events, shop ends, arrivals, decisions) and starts each booked ship idle at its HQ (`tryStartService`: the charge, the upgrade level, the shop fields, `shopPilotId` when the stay covers a rest, or `waitingSince` when it can't pay); `finishShop` calls `finishService` to clear it; `autoRepairAfterDamage` runs after event damage, landing wear and the dev tool. The ship API adds `booking` (with an estimate and a status), `autoRepair` and `repairRecommended`. Logbook kind `service`. + +Catch-up and service (CR-027 Step 27.4): `decide` passes the ship's unstarted booking (`{ hqId }`) to `chooseAction`; `nextDecision` lets a pilot on a service trip (`onServiceTrip`: booked, reachable, not there yet) decide up to the 7-day cap even while the fleet is asleep. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index ae38880..4e6aedc 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -572,7 +572,7 @@ Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR- - [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). - [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. - [x] **Step 27.3 — Engine: booking commands and service start.** `packages/engine/src/game/service.ts`: `bookService` (repair and/or one upgrade level at an HQ, default the nearest reachable; errors `nothing-to-service`, `hull-intact`, `upgrade-maxed`, `unknown-hq`, `no-hq-reachable`, `service-started`), `cancelBooking` (`no-booking`, `service-started`), `setAutoRepair` (10–90% in steps of 5). `tryStartService` runs after every command and catch-up event: a ship idle at its booked HQ and out of the shop starts the work (repair then upgrade, one shop stay), charged then — a player booking whenever credits cover it, an automatic one only above the spending floor — else it waits, logged once. A stay as long as a rest takes the pilot along (CR-023), so a rest due on arrival or already running overlaps it and ends with it. Auto-repair books (or adds a repair to a booking) when damage, wear or the dev tool takes an Independent crew's hull below the threshold. Logbook kind `service` (booked, started, finished, waiting-funds). Ship API: `booking` (items, level, HQ, by, estimate, status `booked`/`heading`/`in-shop`/`waiting-funds`/`cant-reach`), `autoRepair`, `repairRecommended`. Routes `bookService`, `cancelBooking`, `setAutoRepair` (manual decisions). Tests (`packages/engine/test/service.test.ts`): immediate start at the HQ with the charge, the upgrade and the log; start on arrival charged for the landed hull; rest on arrival overlapping, duty reset; a running rest overlapping; waiting for funds then starting; refusals; cancel; the automatic booking, starting or waiting below the floor; Manual crews only recommended; the threshold setting. -- [ ] Step 27.4 — Engine: Independent pilots and catch-up +- [x] **Step 27.4 — Engine: Independent pilots and catch-up.** An Independent pilot's decision passes the ship's unstarted booking to `chooseAction`, so they take only jobs ending at the booked HQ, else deadhead there, and wait there for the work; out of reach, they work as usual. A pilot on a service trip (booked, reachable, not there yet) keeps deciding while the fleet is asleep, up to the 7-day cap; once the work starts they're back under the sleep rule. Tests (`packages/engine/test/service-routing.test.ts`): the job to the HQ over a richer one, with the work on landing; the deadhead; the service trip while asleep, and nothing after; an HQ out of reach; the same save caught up at once or an hour at a time. - [ ] Step 27.5 — Client: Service section and tags - [ ] Step 27.6 — Client: Logbook and summaries - [ ] Step 27.7 — Balance and regression diff --git a/packages/engine/src/game/catchup.ts b/packages/engine/src/game/catchup.ts index c8ec4ec..9b2dcfc 100644 --- a/packages/engine/src/game/catchup.ts +++ b/packages/engine/src/game/catchup.ts @@ -18,7 +18,7 @@ import { logReadyGoals } from "./logbook"; import { scheduleDecision } from "./independent"; import { resolveArrivals } from "./resolve"; import { discoveredIds, pilotState, shipState, sleepsAt } from "./state"; -import { startDueServices } from "./service"; +import { canReachBooking, startDueServices } from "./service"; type SavedPilot = SaveDocument["pilots"][number]; @@ -67,12 +67,32 @@ export interface CatchUpResult { reached: RealTime; } -/** The earliest decision due by `limit`, ties to the lower pilot id. */ -function nextDecision(save: SaveDocument, limit: RealTime): SavedPilot | null { +/** + * Whether a pilot is on their way to booked service (decision 0.5.154): an + * unstarted booking at an HQ the ship can reach, and not there yet. + */ +function onServiceTrip(save: SaveDocument, pilot: SavedPilot): boolean { + const ship = save.ships.find((s) => s.id === pilot.assignedShipId); + const booking = ship?.booking; + if (!ship || !booking || booking.startedAt !== null) return false; + return ship.locationId !== booking.hqId && canReachBooking(save, ship); +} + +/** + * The earliest decision due by `limit`, ties to the lower pilot id. A pilot + * heading for booked service decides up to `serviceLimit` instead: service + * trips go on while the fleet is asleep (CR-027 §2). + */ +function nextDecision( + save: SaveDocument, + limit: RealTime, + serviceLimit: RealTime, +): SavedPilot | null { let best: SavedPilot | null = null; for (const pilot of save.pilots) { const t = pilot.nextDecisionAt; - if (t === null || t > limit || pilot.mode !== "independent") continue; + if (t === null || pilot.mode !== "independent") continue; + if (t > limit && (t > serviceLimit || !onServiceTrip(save, pilot))) continue; if (!best || t < best.nextDecisionAt! || (t === best.nextDecisionAt && pilot.id < best.id)) { best = pilot; } @@ -139,6 +159,8 @@ function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: stri discoveredIds: discoveredIds(save), emptyPeriods: pilot.emptyPeriods, viewedContractId: viewed, + // Booked service steers the choice until the work starts (decision 0.5.154). + booking: ship.booking && ship.booking.startedAt === null ? { hqId: ship.booking.hqId } : null, }, content, ); @@ -199,6 +221,7 @@ export function catchUp( const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY; // Pilots decide before the 7-day cap and before the fleet goes quiet (decision 0.5.137). const decideLimit = Math.min(until, options.decideUntil, sleepsAt(save, until) - 1); + const serviceLimit = Math.min(until, options.decideUntil); let resolved = 0; let decisions = 0; let reached = Number.NEGATIVE_INFINITY; @@ -209,7 +232,7 @@ export function catchUp( const at = eventDueAt(due)!; const shopFirst = nextShopEnd(save, until); const arrivalFirst = nextArrival(save, until); - const decisionFirst = nextDecision(save, decideLimit)?.nextDecisionAt ?? null; + const decisionFirst = nextDecision(save, decideLimit, serviceLimit)?.nextDecisionAt ?? null; const earliest = Math.min( shopFirst?.inShopUntil ?? Infinity, arrivalFirst ?? Infinity, @@ -225,7 +248,7 @@ export function catchUp( const shop = nextShopEnd(save, until); const shopAt = shop?.inShopUntil ?? null; const arrival = nextArrival(save, until); - const pilot = nextDecision(save, decideLimit); + const pilot = nextDecision(save, decideLimit, serviceLimit); const decisionAt = pilot?.nextDecisionAt ?? null; if ( shopAt !== null && diff --git a/packages/engine/test/service-routing.test.ts b/packages/engine/test/service-routing.test.ts new file mode 100644 index 0000000..747edf0 --- /dev/null +++ b/packages/engine/test/service-routing.test.ts @@ -0,0 +1,162 @@ +import { content } from "@deadhead/content"; +import type { SaveDocument } from "@deadhead/schema"; +import { MS_PER_MINUTE } from "@deadhead/sim"; +import { describe, expect, it, vi } from "vitest"; +import { + addPilot, + addShip, + edit, + newTestApp, + putBoard, + readSave, + setCredits, + signUpOwner, + testContract, + TEST_NOW, + type TestApp, +} from "./support"; + +// Long catch-ups: slow under coverage on CI. +vi.setConfig({ testTimeout: 60_000 }); + +const HOUR = 60 * MS_PER_MINUTE; +const FIRST_LOOK = content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE; + +/** An owner (HQ at Earth) with the Dispatch Office and Independent pilot "pa" crewing a dented Courier at `at`. */ +async function bookedPilot(at = "moon") { + const test = newTestApp(); + test.clock.now = TEST_NOW + FIRST_LOOK; + await signUpOwner(test.app); + await setCredits(test.app, content.config.independentPilots.dispatchOfficePrice + 200_000); + await test.app.call("buyDispatchOffice", {}); + await addShip(test.app, { + id: "ship-pa", + modelId: "courier", + name: "Courier pa", + locationId: at, + hullConditionPct: 60, + }); + await addPilot(test.app, { id: "pa", name: "Pilot pa", assignedShipId: "ship-pa" }); + await test.app.call("setPilotMode", { pilotId: "pa", mode: "independent" }); + return test; +} + +const tripsOf = (save: SaveDocument) => save.dispatches.filter((d) => d.pilotId === "pa"); +const shipPa = (save: SaveDocument) => save.ships.find((s) => s.id === "ship-pa")!; +const serviceLog = (save: SaveDocument) => + save.logbook.flatMap((e) => (e.kind === "service" && e.shipId === "ship-pa" ? [e.phase] : [])); + +/** A second engine holding a copy of `test`'s save, at the same time. */ +async function copyOf(test: TestApp): Promise { + const { code } = await test.app.call("exportSave", {}); + const copy = newTestApp(); + copy.clock.now = test.clock.now; + await copy.app.call("importSave", { code }); + return copy; +} + +describe("Independent pilots and booked service (Step 27.4, decision 0.5.154)", () => { + it("takes the job that ends at the booked HQ, and the work starts on landing", async () => { + const test = await bookedPilot(); + const home = testContract({ id: "moon:home", originId: "moon", destinationId: "earth" }); + const rich = testContract({ + id: "moon:rich", + originId: "moon", + destinationId: "mars", + payout: 20_000, + }); + await putBoard(test.app, [rich, home], test.clock.now, "moon"); + await test.app.call("bookService", { shipId: "ship-pa", repair: true, upgradeId: null }); + expect( + (await test.app.call("getPlayerState", {})).ships.find((s) => s.id === "ship-pa")!.booking, + ).toMatchObject({ status: "heading", hqId: "earth" }); + + test.clock.now += 2 * HOUR; + await test.app.call("getPlayerState", {}); + const save = await readSave(test.app); + expect(tripsOf(save)[0]!.record.contract?.id).toBe("moon:home"); + // Back to work afterward: wear and auto-repair may start the next round. + expect(serviceLog(save).slice(0, 2)).toEqual(["started", "finished"]); + expect(save.logbook.find((e) => e.kind === "repair" && e.shipId === "ship-pa")).toBeTruthy(); + }); + + it("deadheads to the HQ when no job goes there", async () => { + const test = await bookedPilot(); + const away = testContract({ id: "moon:away", originId: "moon", destinationId: "mars" }); + await putBoard(test.app, [away], test.clock.now, "moon"); + await test.app.call("bookService", { shipId: "ship-pa", repair: true, upgradeId: null }); + test.clock.now += 2 * HOUR; + await test.app.call("getPlayerState", {}); + const first = tripsOf(await readSave(test.app))[0]!; + expect(first.record.contract).toBeNull(); + expect(first.record.destinationId).toBe("earth"); + }); + + it("still flies to booked service while the fleet is asleep, then waits", async () => { + const test = await bookedPilot(); + await putBoard(test.app, [], test.clock.now, "moon"); + await edit(test.app, (save) => { + // An automatic booking (not a manual decision), and the fleet has gone quiet. + shipPa(save).booking = { + repair: true, + upgradeId: null, + hqId: "earth", + by: "auto", + createdAt: test.clock.now, + startedAt: null, + waitingSince: null, + }; + save.company.lastManualDecisionAt = test.clock.now - 25 * HOUR; + }); + expect((await test.app.call("getPlayerState", {})).fleetSleep.asleep).toBe(true); + test.clock.now += 12 * HOUR; + await test.app.call("getPlayerState", {}); + const save = await readSave(test.app); + expect(tripsOf(save).map((d) => d.record.destinationId)).toEqual(["earth"]); + expect(serviceLog(save)).toEqual(["started", "finished"]); + expect(shipPa(save).locationId).toBe("earth"); + }); + + it("works as usual when the booked HQ is out of reach", async () => { + const test = await bookedPilot("earth"); + // An HQ at Jupiter: a Courier can't get there from Earth in one leg. + await edit(test.app, (save) => { + save.bays.push({ + id: "jupiter-bay", + locationId: "jupiter-cloud-platform", + createdAt: TEST_NOW, + }); + save.discoveredLocations.push({ + locationId: "jupiter-cloud-platform", + discoveredAt: TEST_NOW, + }); + }); + const moon = testContract({ id: "earth:moon" }); + await putBoard(test.app, [moon], test.clock.now, "earth"); + const { ship } = await test.app.call("bookService", { + shipId: "ship-pa", + repair: true, + upgradeId: null, + hqId: "jupiter-cloud-platform", + }); + expect(ship.booking!.status).toBe("cant-reach"); + test.clock.now += HOUR; + await test.app.call("getPlayerState", {}); + expect(tripsOf(await readSave(test.app))[0]!.record.contract?.id).toBe("earth:moon"); + }); + + it("gives the same save whether the service trip is caught up at once or an hour at a time", async () => { + const once = await bookedPilot(); + await once.app.call("bookService", { shipId: "ship-pa", repair: true, upgradeId: null }); + const pieces = await copyOf(once); + once.clock.now += 8 * HOUR; + await once.app.call("getPlayerState", {}); + for (let hour = 0; hour < 8; hour++) { + pieces.clock.now += HOUR; + await pieces.app.call("getPlayerState", {}); + } + const a = await readSave(once.app); + expect(serviceLog(a).slice(0, 2)).toEqual(["started", "finished"]); + expect({ ...(await readSave(pieces.app)), lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 }); + }); +});