Step 27.4: engine: Independent pilots and catch-up
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-18 13:14:02 -04:00
parent c60e905e79
commit c4ebe88369
5 changed files with 199 additions and 7 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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 &&

View File

@ -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<TestApp> {
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 });
});
});