From c60e905e797a9c922859effe53bc4f111deed367 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 18 Oct 2026 13:09:51 -0400 Subject: [PATCH] Step 27.3: engine: booking commands and service start Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 9 + apps/web/test/fixtures.ts | 7 + memory-bank/architecture.md | 5 + memory-bank/progress.md | 2 +- packages/engine/src/game/catchup.ts | 7 + packages/engine/src/game/commands.ts | 21 ++- packages/engine/src/game/engine.ts | 3 + packages/engine/src/game/events.ts | 5 + packages/engine/src/game/fleet.ts | 8 +- packages/engine/src/game/resolve.ts | 3 + packages/engine/src/game/service.ts | 260 ++++++++++++++++++++++++++ packages/engine/src/game/state.ts | 37 ++++ packages/engine/test/service.test.ts | 262 +++++++++++++++++++++++++++ packages/schema/src/entities.ts | 17 ++ packages/schema/src/errors.ts | 5 + packages/schema/src/logbook.ts | 15 ++ packages/schema/src/requests.ts | 13 ++ packages/schema/src/routes.ts | 27 +++ packages/schema/test/routes.test.ts | 6 + packages/schema/test/schemas.test.ts | 3 + packages/sim/src/away.ts | 9 + 21 files changed, 718 insertions(+), 6 deletions(-) create mode 100644 packages/engine/src/game/service.ts create mode 100644 packages/engine/test/service.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 4b9cf0b..1194d25 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts index c30aa18..622926f 100644 --- a/apps/web/test/fixtures.ts +++ b/apps/web/test/fixtures.ts @@ -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, }; } diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 631af11..ce6f68d 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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`. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 08a6e99..ae38880 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/engine/src/game/catchup.ts b/packages/engine/src/game/catchup.ts index 73bb28e..c8ec4ec 100644 --- a/packages/engine/src/game/catchup.ts +++ b/packages/engine/src/game/catchup.ts @@ -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!; } diff --git a/packages/engine/src/game/commands.ts b/packages/engine/src/game/commands.ts index b14eb8f..3e3dd68 100644 --- a/packages/engine/src/game/commands.ts +++ b/packages/engine/src/game/commands.ts @@ -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; diff --git a/packages/engine/src/game/engine.ts b/packages/engine/src/game/engine.ts index 58c6a63..23a026d 100644 --- a/packages/engine/src/game/engine.ts +++ b/packages/engine/src/game/engine.ts @@ -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 }; }); diff --git a/packages/engine/src/game/events.ts b/packages/engine/src/game/events.ts index 75f873d..4d49c0e 100644 --- a/packages/engine/src/game/events.ts +++ b/packages/engine/src/game/events.ts @@ -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, }); diff --git a/packages/engine/src/game/fleet.ts b/packages/engine/src/game/fleet.ts index aa84d4e..258fd4b 100644 --- a/packages/engine/src/game/fleet.ts +++ b/packages/engine/src/game/fleet.ts @@ -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; diff --git a/packages/engine/src/game/resolve.ts b/packages/engine/src/game/resolve.ts index 609d528..ea1460f 100644 --- a/packages/engine/src/game/resolve.ts +++ b/packages/engine/src/game/resolve.ts @@ -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) { diff --git a/packages/engine/src/game/service.ts b/packages/engine/src/game/service.ts new file mode 100644 index 0000000..6a1aae3 --- /dev/null +++ b/packages/engine/src/game/service.ts @@ -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; + +/** 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 }; +} diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts index 14b1e15..12e89f0 100644 --- a/packages/engine/src/game/state.ts +++ b/packages/engine/src/game/state.ts @@ -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, + }), }; } diff --git a/packages/engine/test/service.test.ts b/packages/engine/test/service.test.ts new file mode 100644 index 0000000..0b0411d --- /dev/null +++ b/packages/engine/test/service.test.ts @@ -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" }); + } + }); +}); diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 773286c..b5f3a93 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -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; diff --git a/packages/schema/src/errors.ts b/packages/schema/src/errors.ts index f0fa896..99a8333 100644 --- a/packages/schema/src/errors.ts +++ b/packages/schema/src/errors.ts @@ -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 = diff --git a/packages/schema/src/logbook.ts b/packages/schema/src/logbook.ts index cc1c60b..e47124f 100644 --- a/packages/schema/src/logbook.ts +++ b/packages/schema/src/logbook.ts @@ -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, diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index 8766b76..3fb2a30 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -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 }); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 39f8181..325bbe8 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -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", diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index 8c053a9..df7d2d4 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -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", diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index f15c750..035e5e9 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -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", diff --git a/packages/sim/src/away.ts b/packages/sim/src/away.ts index 0ad12cc..77e56d1 100644 --- a/packages/sim/src/away.ts +++ b/packages/sim/src/away.ts @@ -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;