Step 27.4: engine: Independent pilots and catch-up
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c60e905e79
commit
c4ebe88369
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue