Step 27.3: engine: booking commands and service start
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:09:51 -04:00
parent 58ce4f09e6
commit c60e905e79
21 changed files with 718 additions and 6 deletions

View File

@ -2007,3 +2007,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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.
## Step 27.3
- **The charge is taken when the work starts**, not when booked, at the hull the ship has then (after landing wear), so the estimate shown while booked can move.
- **Service checks run after every command and catch-up event**, so the work starts at the exact arrival, and a waiting booking starts on the first check once the credits cover it (a delivery, a sale, the dev tool).
- **"Waiting for funds" is logged once** per wait, not on every check.
- **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.

View File

@ -22,6 +22,9 @@ export function shipExtras(
| "refuelPolicy"
| "hullConditionPct"
| "repairing"
| "booking"
| "autoRepair"
| "repairRecommended"
> {
const model = content.shipModel(modelId);
if (!model) throw new Error(`Unknown ship model ${modelId}`);
@ -45,6 +48,10 @@ export function shipExtras(
// A whole hull, not in for repair (CR-026).
hullConditionPct: 100,
repairing: false,
// No service booked; auto-repair at the default threshold (CR-027).
booking: null,
autoRepair: { on: true, thresholdPct: 40 },
repairRecommended: false,
};
}

View File

@ -68,6 +68,9 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
| `getLogbook` | `GET /api/logbook` | — | `{ entries, absence: { from, to }, lastAbsence, problems }` (CR-025: the last 7 days, the absence since the summary was last closed, the one before, and what needs the player now) | yes |
| `answerRadioCall` | `POST /api/dispatches/:dispatchId/radio` | `dispatchId`, `optionId` | `{ radioCalls }` (still waiting) | yes; a manual decision; `radio-call-not-found` before it fires, `radio-call-closed` after its window or once applied, `radio-option-unavailable` (CR-026) |
| `repairShip` | `POST /api/ships/:shipId/repair` | `shipId` | `{ ship }` (in the shop) | yes; a manual decision; at an HQ, below 100% (`hull-intact`), charged now (CR-026) |
| `bookService` | `POST /api/ships/:shipId/service` | `shipId`, `repair`, `upgradeId` (or null), `hqId` (optional: the nearest reachable HQ) | `{ ship }` (with its `booking`) | yes; a manual decision; replaces an unstarted booking; starts at once if idle at the HQ; `nothing-to-service`, `hull-intact`, `upgrade-maxed`, `unknown-hq`, `no-hq-reachable`, `service-started` (CR-027) |
| `cancelBooking` | `POST /api/ships/:shipId/service/cancel` | `shipId` | `{ ship }` | yes; a manual decision; `no-booking`, `service-started` (CR-027) |
| `setAutoRepair` | `POST /api/ships/:shipId/auto-repair` | `shipId`, `on`, `thresholdPct` (10–90, steps of 5) | `{ ship }` | yes; a manual decision; `validation-error` off the steps (CR-027) |
| `backToWork` | `POST /api/fleet/wake` | — | `PlayerState` (awake) | yes; a manual decision that does nothing else: wakes the fleet (CR-024) |
| `exportSave` | engine only (CR-014) | — | `{ code }`: `DEADHEAD1.` + base64url(gzip(save JSON)) | yes |
| `importSave` | engine only (CR-014) | `code` (whitespace ignored) | `{ playerId, state }`; `invalid-save` if it isn't a good save | no |
@ -157,3 +160,5 @@ Balance simulator events (CR-026 Step 26.9): `simulate` options `events` ("off"
`packages/engine/src/game/events.ts` (CR-026): `rollForDispatch` rolls a contract's or deadhead's event at dispatch and keeps the pacing counters (ship `quietTrips`, company `tripsSinceDeal` and `firstEventDone`); `applyTripEvent` applies an option (new arrival on the dispatch and its record, hull hit, extra fuel, an `event` logbook entry); catch-up settles due events (`nextEventDue`: an automatic event at its fire time, an unanswered radio call at its window end, before anything else at that moment); `radioCalls` lists the waiting calls in `PlayerState.radioCalls`; `answerRadioCall` and `repairShip` are the commands; `finishRepair` ends a repair at its shop end (hull 100%, a `repair` entry). Resolution passes the applied event to the sim, so pay and XP change.
`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`.

View File

@ -571,7 +571,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.
- [ ] Step 27.3 — Engine: booking commands and service start
- [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
- [ ] Step 27.5 — Client: Service section and tags
- [ ] Step 27.6 — Client: Logbook and summaries

View File

@ -18,6 +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";
type SavedPilot = SaveDocument["pilots"][number];
@ -194,6 +195,7 @@ export function catchUp(
// Goals already ready before this catch-up count from when the save was last seen, so
// the time is the same however the catch-up is split (decision 0.5.141).
logReadyGoals(save, save.lastTimeSeen);
startDueServices(save, save.lastTimeSeen);
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);
@ -215,6 +217,7 @@ export function catchUp(
);
if (at <= earliest) {
settleDueEvent(save, due);
startDueServices(save, at);
reached = at;
continue;
}
@ -231,6 +234,7 @@ export function catchUp(
) {
finishShop(save, shop!);
logReadyGoals(save, shopAt);
startDueServices(save, shopAt);
reached = shopAt;
continue;
}
@ -250,6 +254,8 @@ export function catchUp(
);
resolved += resolveArrivals(save, arrival);
logReadyGoals(save, arrival);
// A ship landing at its booked HQ starts its service, beside any rest (decision 0.5.159).
startDueServices(save, arrival);
for (const d of landed) {
const flyer = save.pilots.find((p) => p.id === d.pilotId);
if (flyer?.mode === "independent") {
@ -259,6 +265,7 @@ export function catchUp(
reached = arrival;
} else {
decide(save, pilot!, decisionAt!, options.viewedContractId ?? null);
startDueServices(save, decisionAt!);
decisions++;
reached = decisionAt!;
}

View File

@ -30,6 +30,7 @@ import {
toApiShip,
} from "./state";
import { buyDispatchOffice, setPilotMode, setPilotOrders, setSpendingFloor } from "./independent";
import { autoRepairAfterDamage, bookService, cancelBooking, setAutoRepair } from "./service";
/** Every command that works on an existing company's save (the server's endpoints). */
export function commandTable(): CommandTable {
@ -77,6 +78,19 @@ export function commandTable(): CommandTable {
acknowledgeSummaries: ({ save, now }, input) => ({
acknowledged: acknowledge(save, input, now),
}),
// Service bookings and auto-repair (decisions 0.5.153, 0.5.157).
bookService: ({ save, now }, input) => {
bookService(save, input, now);
return { ship: toApiShip(save, requireShip(save, input.shipId), now) };
},
cancelBooking: ({ save, now }, input) => {
cancelBooking(save, input);
return { ship: toApiShip(save, requireShip(save, input.shipId), now) };
},
setAutoRepair: ({ save, now }, input) => {
setAutoRepair(save, input);
return { ship: toApiShip(save, requireShip(save, input.shipId), now) };
},
// Radio calls and repairs (decisions 0.5.144, 0.5.148).
answerRadioCall: ({ save, now }, input) => ({
radioCalls: answerRadioCall(save, input, now),
@ -152,10 +166,13 @@ export function commandTable(): CommandTable {
// A smaller tank holds no more than it can (CR-015).
ship.fuelFu = Math.min(ship.fuelFu, content.shipModel(ship.modelId)!.tankCapacityFu);
break;
case "damage-hull":
// To try repairs and the "Needs repair" warning (CR-026).
case "damage-hull": {
// To try repairs and the "Needs repair" warning (CR-026), and auto-repair (CR-027).
const before = ship.hullConditionPct;
ship.hullConditionPct = 20;
autoRepairAfterDamage(save, ship, before, now);
break;
}
case "finish-shop":
// Done now: the "out of the shop" note still shows, as for a finished job.
if (ship.inShopUntil !== null && ship.inShopUntil > now) ship.inShopUntil = now;

View File

@ -19,6 +19,7 @@ import { catchUp, decideUntil } from "./catchup";
import { logReadyGoals } from "./logbook";
import { pruneSave } from "./resolve";
import { playerState } from "./state";
import { startDueServices } from "./service";
/** The commands the engine runs: the whole route catalog (CR-014). */
export type CommandName = RouteName;
@ -260,6 +261,8 @@ export class GameEngine {
if (routes[name].manualDecision) save.company.lastManualDecisionAt = now;
// A purchase or delivery may have made a goal ready (decision 0.5.141).
logReadyGoals(save, now);
// A booking made, credits come in, a ship landed: service that can start does (CR-027).
startDueServices(save, now);
pruneSave(save, now);
return { save, result, unchanged: JSON.stringify(save) === before };
});

View File

@ -16,6 +16,7 @@ import {
import { fail } from "./errors";
import { logEntry, shipPilotId } from "./logbook";
import { hasBayAt, requireShip, spend } from "./state";
import { autoRepairAfterDamage } from "./service";
type SavedDispatch = SaveDocument["dispatches"][number];
type SavedShip = SaveDocument["ships"][number];
@ -88,11 +89,13 @@ export function applyTripEvent(
dispatch.arrivalAt = applied.arrivalAt;
dispatch.record = { ...dispatch.record, arrivalAt: applied.arrivalAt };
if (ship) {
const before = ship.hullConditionPct;
ship.hullConditionPct = hullAfterDamage(
ship.hullConditionPct,
applied.hullLoss,
content.config,
);
autoRepairAfterDamage(save, ship, before, at);
if (applied.extraFuelFu > 0) {
const burned = Math.min(applied.extraFuelFu, ship.fuelFu);
ship.fuelFu -= burned;
@ -235,6 +238,8 @@ export function repairShip(save: SaveDocument, input: { shipId: string }, now: R
Object.assign(ship, {
inShopUntil: until,
shopRepair: true,
shopUpgradeId: null,
shopLevel: null,
shopOpen: true,
shopPilotId: pilot && coversRest ? pilot.id : null,
});

View File

@ -31,6 +31,7 @@ import {
toApiPilot,
toApiShip,
} from "./state";
import { finishService } from "./service";
/** Leases a bay at a discovered location for its one-time price (Step 6.6). */
export function leaseBay(
@ -187,9 +188,9 @@ export function unassignPilot(save: SaveDocument, pilotId: string, now: RealTime
*/
export function finishShop(save: SaveDocument, ship: SavedShip): void {
const at = ship.inShopUntil;
if (ship.shopOpen && at !== null && ship.shopRepair) {
finishRepair(save, ship, at);
} else if (ship.shopOpen && at !== null && ship.shopUpgradeId && ship.shopLevel !== null) {
if (ship.shopOpen && at !== null && ship.shopRepair) finishRepair(save, ship, at);
// A booked service can repair and upgrade in one stay (CR-027).
if (ship.shopOpen && at !== null && ship.shopUpgradeId && ship.shopLevel !== null) {
logEntry(save, `${ship.id}:upgrade:${at}`, {
at,
pilotId: shipPilotId(save, ship.id),
@ -199,6 +200,7 @@ export function finishShop(save: SaveDocument, ship: SavedShip): void {
level: ship.shopLevel,
});
}
if (ship.shopOpen && at !== null) finishService(save, ship, at);
ship.shopOpen = false;
const pilot = save.pilots.find((p) => p.id === ship.shopPilotId);
ship.shopPilotId = null;

View File

@ -14,6 +14,7 @@ import {
} from "@deadhead/sim";
import { logEntry, pruneLogbook } from "./logbook";
import { discoveredIds } from "./state";
import { autoRepairAfterDamage } from "./service";
/**
* Resolves every in-flight dispatch that has arrived by `now`, in arrival
@ -89,6 +90,7 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
dispatch.record.arrivalAt,
content.config,
);
const hullBefore = ship.hullConditionPct;
ship.hullConditionPct = hullAfterDamage(
ship.hullConditionPct,
hullWear(hours, content.config),
@ -96,6 +98,7 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
);
ship.status = "idle";
ship.locationId = r.shipLocationId;
autoRepairAfterDamage(save, ship, hullBefore, now);
ship.lastArrivalAt = Math.max(ship.lastArrivalAt, dispatch.arrivalAt);
}
if (dispatch.tutorial) {

View File

@ -0,0 +1,260 @@
import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import {
autoRepairBooks,
canReachHq,
isInShop,
nearestReachableHq,
nextUpgradeLevel,
repairCost,
repairShopMs,
restEndsAt,
upgradeCost,
upgradeShopMs,
type RealTime,
} from "@deadhead/sim";
import { fail } from "./errors";
import { logEntry, shipPilotId } from "./logbook";
import { discoveredIds, hasBayAt, requireShip, requireTutorialOver, shipState } from "./state";
type SavedShip = SaveDocument["ships"][number];
type Booking = NonNullable<SavedShip["booking"]>;
/** The HQs: locations with one of the player's bays. */
function hqIds(save: SaveDocument): string[] {
return [...new Set(save.bays.map((b) => b.locationId))];
}
/** Where a ship is, or will be when its flight lands. */
export function shipPlace(save: SaveDocument, ship: SavedShip): string | null {
if (ship.locationId) return ship.locationId;
const flight = save.dispatches.find((d) => d.shipId === ship.id && d.status === "in-flight");
return flight?.record.destinationId ?? null;
}
/** The ship as the sim's reach rules see it, at `place`. */
function reachShip(ship: SavedShip, place: string) {
return {
...shipState(ship),
locationId: place,
inFlight: false,
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
};
}
/** Whether the ship can get to its booked HQ from where it is (or lands). */
export function canReachBooking(save: SaveDocument, ship: SavedShip): boolean {
const place = shipPlace(save, ship);
if (!ship.booking || !place) return false;
return canReachHq(reachShip(ship, place), ship.booking.hqId, discoveredIds(save), content);
}
/** The nearest HQ a ship can reach from where it is (decision 0.5.153), or null. */
export function nearestHq(save: SaveDocument, ship: SavedShip): string | null {
const place = shipPlace(save, ship);
if (!place) return null;
return nearestReachableHq(reachShip(ship, place), hqIds(save), discoveredIds(save), content);
}
/** The work a booking would do now, and what it costs and takes (decision 0.5.153). */
export function serviceWork(ship: SavedShip, booking: Booking) {
const model = content.shipModel(ship.modelId)!;
const repair = booking.repair && ship.hullConditionPct < 100;
const upgrade = booking.upgradeId ? content.upgrade(booking.upgradeId) : undefined;
const level = upgrade ? nextUpgradeLevel(upgrade, model.id, ship.upgrades) : null;
const cost =
(repair ? repairCost(model, ship.hullConditionPct, content.config) : 0) +
(level !== null ? upgradeCost(model, level, content.config) : 0);
const ms =
(repair ? repairShopMs(ship.hullConditionPct, content.config) : 0) +
(level !== null ? upgradeShopMs(level, content.config) : 0);
return { repair, upgradeId: level !== null ? booking.upgradeId : null, level, cost, ms };
}
function logService(
save: SaveDocument,
ship: SavedShip,
booking: Booking,
phase: "booked" | "started" | "finished" | "waiting-funds",
at: RealTime,
cost: number | null = null,
): void {
logEntry(save, `${ship.id}:service-${phase}:${at}`, {
at,
pilotId: shipPilotId(save, ship.id),
shipId: ship.id,
kind: "service",
phase,
hqId: booking.hqId,
repair: booking.repair,
upgradeId: booking.upgradeId,
by: booking.by,
cost,
});
}
/**
* Starts a ship's booked service if it can (decisions 0.5.153, 0.5.156,
* 0.5.159): idle at the HQ, out of the shop, and paid for (a player's booking
* whenever the credits cover it, an automatic one only above the spending
* floor). Repair first, then the upgrade, in one shop stay; a stay long enough
* to count as rest overlaps the pilot's rest (decision 0.5.135). A ship that
* can't pay waits, logged once.
*/
export function tryStartService(save: SaveDocument, ship: SavedShip, now: RealTime): void {
const booking = ship.booking;
if (!booking || booking.startedAt !== null) return;
if (ship.status !== "idle" || ship.locationId !== booking.hqId || isInShop(ship, now)) return;
if (!hasBayAt(save, booking.hqId)) return;
const work = serviceWork(ship, booking);
if (!work.repair && work.upgradeId === null) {
// Nothing left to do (repaired or maxed since): the booking clears.
ship.booking = null;
return;
}
const { credits, npcSpendingFloor } = save.company;
const affordable =
booking.by === "player" ? credits >= work.cost : credits - work.cost >= npcSpendingFloor;
if (!affordable) {
if (booking.waitingSince === null) {
booking.waitingSince = now;
logService(save, ship, booking, "waiting-funds", now);
}
return;
}
save.company.credits -= work.cost;
if (work.upgradeId !== null && work.level !== null) {
ship.upgrades = { ...ship.upgrades, [work.upgradeId]: work.level };
}
const until = now + work.ms;
const pilot = save.pilots.find((p) => p.assignedShipId === ship.id);
const coversRest = work.ms >= restEndsAt(now, content.config) - now;
Object.assign(ship, {
inShopUntil: until,
shopOpen: true,
shopRepair: work.repair,
shopUpgradeId: work.upgradeId,
shopLevel: work.level,
shopPilotId: pilot && coversRest ? pilot.id : null,
});
booking.startedAt = now;
booking.waitingSince = null;
logService(save, ship, booking, "started", now, work.cost);
}
/** Every ship's booked service that can start now does (run after each event and command). */
export function startDueServices(save: SaveDocument, now: RealTime): void {
for (const ship of save.ships) tryStartService(save, ship, now);
}
/** A started service's shop time ends: the booking clears (decision 0.5.153). */
export function finishService(save: SaveDocument, ship: SavedShip, at: RealTime): void {
const booking = ship.booking;
if (!booking || booking.startedAt === null) return;
logService(save, ship, booking, "finished", at);
ship.booking = null;
}
/**
* Auto-repair (decision 0.5.157): a hull that crosses below the threshold with
* an Independent crew gets a repair booked, added to the ship's booking or at
* the nearest reachable HQ (or the nearest HQ at all, flagged out of reach).
*/
export function autoRepairAfterDamage(
save: SaveDocument,
ship: SavedShip,
hullBefore: number,
at: RealTime,
): void {
const pilot = save.pilots.find((p) => p.assignedShipId === ship.id);
const books = autoRepairBooks({
hullBefore,
hullAfter: ship.hullConditionPct,
autoRepair: ship.autoRepair,
pilotMode: pilot?.mode ?? null,
booking: ship.booking,
});
if (!books) return;
if (ship.booking && ship.booking.startedAt === null) {
ship.booking.repair = true;
} else if (!ship.booking) {
const hqId = nearestHq(save, ship) ?? hqIds(save)[0];
if (!hqId) return;
ship.booking = {
repair: true,
upgradeId: null,
hqId,
by: "auto",
createdAt: at,
startedAt: null,
waitingSince: null,
};
} else {
return;
}
logService(save, ship, ship.booking, "booked", at);
}
/** "Book service" (decision 0.5.153): from anywhere, a repair and/or one upgrade level at an HQ. */
export function bookService(
save: SaveDocument,
input: { shipId: string; repair: boolean; upgradeId: string | null; hqId?: string | null },
now: RealTime,
): void {
requireTutorialOver(save, "booking service");
const ship = requireShip(save, input.shipId);
if (ship.booking?.startedAt != null) {
throw fail("service-started", "The work has started: it can't be changed now.");
}
if (!input.repair && !input.upgradeId) {
throw fail("nothing-to-service", "Choose a repair, an upgrade, or both.");
}
if (input.repair && ship.hullConditionPct >= 100) {
throw fail("hull-intact", "The hull is already at 100%.");
}
if (input.upgradeId) {
const upgrade = content.upgrade(input.upgradeId);
if (!upgrade) throw fail("not-found", `No upgrade "${input.upgradeId}".`);
if (nextUpgradeLevel(upgrade, ship.modelId, ship.upgrades) === null) {
throw fail("upgrade-maxed", "That upgrade is already at its top level.");
}
}
const hqId = input.hqId ?? nearestHq(save, ship);
if (!hqId) throw fail("no-hq-reachable", "None of your HQs can be reached from here.");
if (!hasBayAt(save, hqId)) throw fail("unknown-hq", "Service is done at one of your HQs.");
ship.booking = {
repair: input.repair,
upgradeId: input.upgradeId,
hqId,
by: "player",
createdAt: now,
startedAt: null,
waitingSince: null,
};
// Already idle at the HQ: the work starts now.
tryStartService(save, ship, now);
}
/** Cancels a booking before the work starts (decision 0.5.153), releasing a waiting ship. */
export function cancelBooking(save: SaveDocument, input: { shipId: string }): void {
const ship = requireShip(save, input.shipId);
if (!ship.booking) throw fail("no-booking", "That ship has no service booked.");
if (ship.booking.startedAt !== null) {
throw fail("service-started", "The work has started: it can't be canceled now.");
}
ship.booking = null;
}
/** Auto-repair on or off and its threshold, one of the offered steps (decision 0.5.157). */
export function setAutoRepair(
save: SaveDocument,
input: { shipId: string; on: boolean; thresholdPct: number },
): void {
const ship = requireShip(save, input.shipId);
const { minPct, maxPct, stepPct } = content.config.autoRepair;
const t = input.thresholdPct;
if (t < minPct || t > maxPct || (t - minPct) % stepPct !== 0) {
throw fail("validation-error", `Choose ${minPct}% to ${maxPct}% in steps of ${stepPct}.`);
}
ship.autoRepair = { on: input.on, thresholdPct: t };
}

View File

@ -16,6 +16,7 @@ import {
isInShop,
isResting,
MS_PER_HOUR,
repairRecommended,
pilotLevel,
skillPointsAvailable,
type GoalProgress,
@ -25,6 +26,7 @@ import {
} from "@deadhead/sim";
import { fail } from "./errors";
import { nextRadioCallAt, radioCalls } from "./events";
import { canReachBooking, serviceWork } from "./service";
type SavedShip = SaveDocument["ships"][number];
type SavedPilot = SaveDocument["pilots"][number];
@ -157,6 +159,33 @@ export function toApiPilot(save: SaveDocument, pilot: SavedPilot, now: RealTime)
};
}
/** A ship's booking for the client (CR-027): what, where, the estimate and its status. */
function bookingView(save: SaveDocument, ship: SavedShip): Ship["booking"] {
const booking = ship.booking;
if (!booking) return null;
const work = serviceWork(ship, booking);
const pilot = save.pilots.find((p) => p.assignedShipId === ship.id);
const status =
booking.startedAt !== null
? "in-shop"
: booking.waitingSince !== null
? "waiting-funds"
: !canReachBooking(save, ship)
? "cant-reach"
: pilot?.mode === "independent"
? "heading"
: "booked";
return {
repair: booking.repair,
upgradeId: booking.upgradeId,
upgradeLevel: work.level,
hqId: booking.hqId,
by: booking.by,
estimate: work.cost,
status,
};
}
/** A ship with its upgrades and effective stats (CR-009). */
export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): Ship {
const model = content.shipModel(ship.modelId);
@ -191,6 +220,14 @@ export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): S
refuelPolicy: ship.refuelPolicy,
hullConditionPct: ship.hullConditionPct,
repairing: ship.shopRepair && isInShop(ship, now),
booking: bookingView(save, ship),
autoRepair: ship.autoRepair,
repairRecommended: repairRecommended({
hullPct: ship.hullConditionPct,
autoRepair: ship.autoRepair,
pilotMode: save.pilots.find((p) => p.assignedShipId === ship.id)?.mode ?? null,
booking: ship.booking,
}),
};
}

View File

@ -0,0 +1,262 @@
import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import { repairCost, repairShopMs, restEndsAt, upgradeCost } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { edit, insertDispatch, newTestApp, readSave, setCredits, signUpOwner } from "./support";
const MINUTE = 60_000;
const courier = content.shipModel("courier")!;
const { config } = content;
const shipOf = (save: SaveDocument, id: string) => save.ships.find((s) => s.id === id)!;
const avatarOf = (save: SaveDocument) => save.pilots.find((p) => p.isAvatar)!;
/** An owner with a dented Courier at Earth (their HQ) and `credits`. */
async function dented(hull: number, credits = 100_000) {
const test = newTestApp({ devTools: true });
const owner = await signUpOwner(test.app);
await setCredits(test.app, credits);
await edit(test.app, (save) => {
shipOf(save, owner.shipId).hullConditionPct = hull;
});
return { test, owner };
}
describe("booking service (Step 27.3, decision 0.5.153)", () => {
it("starts at once when the ship is idle at the HQ: charged, in the shop, logged", async () => {
const { test, owner } = await dented(60);
const cost = repairCost(courier, 60, config) + upgradeCost(courier, 1, config);
const now = test.clock.now;
const { ship } = await test.app.call("bookService", {
shipId: owner.shipId,
repair: true,
upgradeId: "cargo-bay",
});
expect(ship.booking).toMatchObject({ status: "in-shop", hqId: "earth", by: "player" });
// Repair then the upgrade, in one stay.
expect(ship.inShopUntil).toBe(now + repairShopMs(60, config) + 10 * MINUTE);
const save = await readSave(test.app);
expect(save.company.credits).toBe(100_000 - cost);
expect(shipOf(save, owner.shipId).upgrades["cargo-bay"]).toBe(1);
expect(save.logbook.find((e) => e.kind === "service")).toMatchObject({
phase: "started",
cost,
});
test.clock.now = ship.inShopUntil!;
const state = await test.app.call("getPlayerState", {});
expect(state.ships.find((s) => s.id === owner.shipId)).toMatchObject({
hullConditionPct: 100,
booking: null,
});
const kinds = (await readSave(test.app)).logbook.map((e) =>
e.kind === "service" ? `service-${e.phase}` : e.kind,
);
expect(kinds).toEqual(expect.arrayContaining(["repair", "upgrade", "service-finished"]));
});
it("starts on arrival at the HQ, charged for the hull it lands with", async () => {
const { test, owner } = await dented(60);
const avatar = avatarOf(await readSave(test.app)).id;
await edit(test.app, (save) => {
shipOf(save, owner.shipId).locationId = "moon";
});
const record = await insertDispatch(test.app, {
shipId: owner.shipId,
pilotId: avatar,
departAt: test.clock.now,
contract: null,
destinationId: "earth",
});
const { ship } = await test.app.call("bookService", {
shipId: owner.shipId,
repair: true,
upgradeId: null,
});
expect(ship.booking).toMatchObject({ status: "booked", hqId: "earth" });
expect(ship.inShopUntil).toBeNull();
test.clock.now = record.arrivalAt + MINUTE;
await test.app.call("getPlayerState", {});
const save = await readSave(test.app);
const landed = save.logbook.find((e) => e.kind === "service" && e.phase === "started")!;
const hull = shipOf(save, owner.shipId).hullConditionPct;
expect(landed.at).toBe(record.arrivalAt);
expect(save.company.credits).toBe(100_000 - repairCost(courier, hull, config));
expect(shipOf(save, owner.shipId).inShopUntil).toBe(
record.arrivalAt + repairShopMs(hull, config),
);
});
it("runs a rest due on arrival alongside the service: free at the later end, duty reset", async () => {
const { test, owner } = await dented(60);
const avatar = avatarOf(await readSave(test.app)).id;
await edit(test.app, (save) => {
shipOf(save, owner.shipId).locationId = "moon";
// Near the duty limit: the trip home ends in mandatory rest.
save.pilots.find((p) => p.id === avatar)!.dutyHours = config.dutyLimitGameHours - 0.5;
});
const record = await insertDispatch(test.app, {
shipId: owner.shipId,
pilotId: avatar,
departAt: test.clock.now,
contract: null,
destinationId: "earth",
});
await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null });
test.clock.now = record.arrivalAt + MINUTE;
await test.app.call("getPlayerState", {});
let save = await readSave(test.app);
const ship = shipOf(save, owner.shipId);
expect(avatarOf(save).restEndsAt).toBe(restEndsAt(record.arrivalAt, config));
// The repair (20 min) outlasts the rest (10 min): the pilot stays with the ship.
expect(ship.shopPilotId).toBe(avatar);
test.clock.now = ship.inShopUntil! + MINUTE;
await test.app.call("getPlayerState", {});
save = await readSave(test.app);
expect(avatarOf(save).dutyHours).toBe(0);
expect(avatarOf(save).restEndsAt!).toBeLessThanOrEqual(ship.inShopUntil!);
});
it("overlaps a rest already running when the work starts", async () => {
const { test, owner } = await dented(60);
const now = test.clock.now;
const avatar = avatarOf(await readSave(test.app)).id;
await edit(test.app, (save) => {
Object.assign(
save.pilots.find((p) => p.id === avatar)!,
{
dutyHours: 0,
restEndsAt: now + 5 * MINUTE,
},
);
});
const { ship } = await test.app.call("bookService", {
shipId: owner.shipId,
repair: true,
upgradeId: null,
});
expect(ship.booking!.status).toBe("in-shop");
expect(shipOf(await readSave(test.app), owner.shipId).shopPilotId).toBe(avatar);
test.clock.now = ship.inShopUntil!;
await test.app.call("getPlayerState", {});
// Free when the later (the shop) ends: not 5 minutes of rest plus 20 of shop.
expect(avatarOf(await readSave(test.app)).restEndsAt!).toBeLessThanOrEqual(ship.inShopUntil!);
});
it("waits for funds on a player booking, then starts when the credits cover it", async () => {
const cost = repairCost(courier, 60, config);
const { test, owner } = await dented(60, cost - 1);
const { ship } = await test.app.call("bookService", {
shipId: owner.shipId,
repair: true,
upgradeId: null,
});
expect(ship.booking).toMatchObject({ status: "waiting-funds", estimate: cost });
expect(ship.inShopUntil).toBeNull();
// Logged once, however often it's checked.
await test.app.call("getPlayerState", {});
let save = await readSave(test.app);
expect(save.logbook.filter((e) => e.kind === "service")).toHaveLength(1);
await setCredits(test.app, cost);
await test.app.call("getPlayerState", {});
save = await readSave(test.app);
expect(save.company.credits).toBe(0);
expect(shipOf(save, owner.shipId).booking!.startedAt).toBe(test.clock.now);
});
it("refuses what can't be booked", async () => {
const { test, owner } = await dented(100);
const book = (input: { repair: boolean; upgradeId: string | null; hqId?: string }) =>
test.app.call("bookService", { shipId: owner.shipId, ...input });
await expect(book({ repair: false, upgradeId: null })).rejects.toMatchObject({
code: "nothing-to-service",
});
await expect(book({ repair: true, upgradeId: null })).rejects.toMatchObject({
code: "hull-intact",
});
await expect(
book({ repair: false, upgradeId: "cargo-bay", hqId: "mars" }),
).rejects.toMatchObject({ code: "unknown-hq" });
});
});
describe("canceling a booking (Step 27.3, decision 0.5.153)", () => {
it("releases a waiting booking, and can't cancel work that has started", async () => {
const { test, owner } = await dented(60, 0);
await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null });
const { ship } = await test.app.call("cancelBooking", { shipId: owner.shipId });
expect(ship.booking).toBeNull();
await expect(test.app.call("cancelBooking", { shipId: owner.shipId })).rejects.toMatchObject({
code: "no-booking",
});
await setCredits(test.app, 100_000);
await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null });
await expect(test.app.call("cancelBooking", { shipId: owner.shipId })).rejects.toMatchObject({
code: "service-started",
});
});
});
describe("auto-repair (Step 27.3, decision 0.5.157)", () => {
async function independent(credits: number) {
const { test, owner } = await dented(45, credits);
await edit(test.app, (save) => {
// Independent, but with no decision scheduled: the ship stays put.
Object.assign(avatarOf(save), { mode: "independent", nextDecisionAt: null });
});
return { test, owner };
}
it("books a repair for an Independent crew when the hull drops below the threshold", async () => {
const { test, owner } = await independent(1_000_000);
await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" });
const save = await readSave(test.app);
// Idle at the HQ already: the automatic repair starts.
expect(shipOf(save, owner.shipId).booking).toMatchObject({ by: "auto", hqId: "earth" });
expect(
save.logbook.filter((e) => e.kind === "service").map((e) => e.kind === "service" && e.phase),
).toEqual(["booked", "started"]);
});
it("waits while an automatic repair would take credits below the spending floor", async () => {
const floor = config.independentPilots.defaultSpendingFloor;
const { test, owner } = await independent(floor);
await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" });
const state = await test.app.call("getPlayerState", {});
expect(state.ships.find((s) => s.id === owner.shipId)!.booking).toMatchObject({
by: "auto",
status: "waiting-funds",
});
await setCredits(test.app, floor + repairCost(courier, 20, config));
await test.app.call("getPlayerState", {});
expect((await readSave(test.app)).company.credits).toBe(floor);
});
it("only recommends a repair for a Manual crew, and honors the setting", async () => {
const { test, owner } = await dented(45);
await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" });
let ship = (await test.app.call("getPlayerState", {})).ships.find(
(s) => s.id === owner.shipId,
)!;
expect(ship).toMatchObject({ booking: null, repairRecommended: true });
({ ship } = await test.app.call("setAutoRepair", {
shipId: owner.shipId,
on: false,
thresholdPct: 55,
}));
expect(ship).toMatchObject({
autoRepair: { on: false, thresholdPct: 55 },
repairRecommended: false,
});
for (const thresholdPct of [42, 5, 95]) {
await expect(
test.app.call("setAutoRepair", { shipId: owner.shipId, on: true, thresholdPct }),
).rejects.toMatchObject({ code: "validation-error" });
}
});
});

View File

@ -76,6 +76,23 @@ export const shipSchema = z.object({
hullConditionPct: z.number().min(0).max(100),
/** In the shop for a hull repair rather than an upgrade. */
repairing: z.boolean(),
/** Service booked at an HQ (CR-027), with what it'll cost and where it stands; null when none. */
booking: z
.object({
repair: z.boolean(),
upgradeId: idSchema.nullable(),
/** The level the upgrade will reach; null without one. */
upgradeLevel: z.number().int().positive().nullable(),
hqId: idSchema,
by: z.enum(["player", "auto"]),
/** What the work would cost now. */
estimate: z.number().int().nonnegative(),
status: z.enum(["booked", "heading", "in-shop", "waiting-funds", "cant-reach"]),
})
.nullable(),
autoRepair: z.object({ on: z.boolean(), thresholdPct: z.number() }),
/** A Manual crew's hull below the auto-repair threshold (decision 0.5.157). */
repairRecommended: z.boolean(),
});
export type Ship = z.infer<typeof shipSchema>;

View File

@ -48,6 +48,11 @@ export const SERVER_ERROR_CODES = [
"radio-call-closed",
"radio-option-unavailable",
"hull-intact",
"service-started",
"nothing-to-service",
"no-hq-reachable",
"unknown-hq",
"no-booking",
] as const;
export type ErrorCode =

View File

@ -85,6 +85,21 @@ export const logEntrySchema = z.discriminatedUnion("kind", [
xpBonusPct: z.number().nonnegative(),
extraFuelFu: z.number().nonnegative(),
}),
/**
* Service at an HQ (CR-027): booked automatically, started (with its cost),
* finished, or waiting for funds.
*/
z.object({
...base,
kind: z.literal("service"),
phase: z.enum(["booked", "started", "finished", "waiting-funds"]),
hqId: idSchema,
repair: z.boolean(),
upgradeId: idSchema.nullable(),
by: z.enum(["player", "auto"]),
/** What the work cost (started); null otherwise. */
cost: credits.nonnegative().nullable(),
}),
/** A hull repaired to 100% at an HQ (decision 0.5.148). */
z.object({
...base,

View File

@ -87,6 +87,19 @@ export const pilotOrdersRequestSchema = z.object({ pilotId: idSchema, orders: pi
/** Answer a radio call on a trip (decision 0.5.144). */
export const answerRadioCallRequestSchema = z.object({ dispatchId: idSchema, optionId: idSchema });
export const radioCallsResponseSchema = z.object({ radioCalls: z.array(radioCallSchema) });
/** Book (or change) service for a ship (decision 0.5.153); no HQ means the nearest reachable. */
export const bookServiceRequestSchema = z.object({
shipId: idSchema,
repair: z.boolean(),
upgradeId: idSchema.nullable(),
hqId: idSchema.nullable().optional(),
});
/** Auto-repair on or off and its threshold (decision 0.5.157). */
export const autoRepairRequestSchema = z.object({
shipId: idSchema,
on: z.boolean(),
thresholdPct: z.number(),
});
/** Repair a ship's hull at an HQ (decision 0.5.148). */
export const repairShipRequestSchema = z.object({ shipId: idSchema });

View File

@ -4,6 +4,8 @@ import { logbookResponseSchema } from "./logbook";
import {
acknowledgeSummariesRequestSchema,
answerRadioCallRequestSchema,
autoRepairRequestSchema,
bookServiceRequestSchema,
radioCallsResponseSchema,
repairShipRequestSchema,
acknowledgeSummariesResponseSchema,
@ -262,6 +264,31 @@ export const routes = {
auth: true,
manualDecision: true,
},
bookService: {
method: "POST",
path: "/api/ships/:shipId/service",
request: bookServiceRequestSchema,
response: shipResponseSchema,
auth: true,
// Booking, changing and canceling are the player's decisions (decision 0.5.153).
manualDecision: true,
},
cancelBooking: {
method: "POST",
path: "/api/ships/:shipId/service/cancel",
request: repairShipRequestSchema,
response: shipResponseSchema,
auth: true,
manualDecision: true,
},
setAutoRepair: {
method: "POST",
path: "/api/ships/:shipId/auto-repair",
request: autoRepairRequestSchema,
response: shipResponseSchema,
auth: true,
manualDecision: true,
},
getLogbook: {
method: "GET",
path: "/api/logbook",

View File

@ -16,6 +16,9 @@ describe("route catalog", () => {
"getLogbook",
"answerRadioCall",
"repairShip",
"bookService",
"cancelBooking",
"setAutoRepair",
"createDispatch",
"buyShip",
"leaseBay",
@ -113,6 +116,9 @@ describe("manual decisions in the catalog (Step 24.2, decision 0.5.136)", () =>
"refuelShip",
"renameCompany",
"repairShip",
"bookService",
"cancelBooking",
"setAutoRepair",
"repayLoan",
"restPilot",
"setPilotMode",

View File

@ -74,6 +74,9 @@ const ship = {
refuelPolicy: "auto",
hullConditionPct: 100,
repairing: false,
booking: null,
autoRepair: { on: true, thresholdPct: 40 },
repairRecommended: false,
};
const pilot = {
id: "p1",

View File

@ -60,6 +60,15 @@ export type LogbookEntry = EntryBase &
extraFuelFu: number;
}
| { kind: "repair"; fromPct: number; cost: number }
| {
kind: "service";
phase: "booked" | "started" | "finished" | "waiting-funds";
hqId: string;
repair: boolean;
upgradeId: string | null;
by: "player" | "auto";
cost: number | null;
}
);
type Flight = Extract<LogbookEntry, { kind: "flight" }>;