Step 27.3: engine: booking commands and service start
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
58ce4f09e6
commit
c60e905e79
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ export function shipExtras(
|
||||||
| "refuelPolicy"
|
| "refuelPolicy"
|
||||||
| "hullConditionPct"
|
| "hullConditionPct"
|
||||||
| "repairing"
|
| "repairing"
|
||||||
|
| "booking"
|
||||||
|
| "autoRepair"
|
||||||
|
| "repairRecommended"
|
||||||
> {
|
> {
|
||||||
const model = content.shipModel(modelId);
|
const model = content.shipModel(modelId);
|
||||||
if (!model) throw new Error(`Unknown ship model ${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).
|
// A whole hull, not in for repair (CR-026).
|
||||||
hullConditionPct: 100,
|
hullConditionPct: 100,
|
||||||
repairing: false,
|
repairing: false,
|
||||||
|
// No service booked; auto-repair at the default threshold (CR-027).
|
||||||
|
booking: null,
|
||||||
|
autoRepair: { on: true, thresholdPct: 40 },
|
||||||
|
repairRecommended: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `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/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/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`.
|
||||||
|
|
|
||||||
|
|
@ -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.1 — Content and save.** Config `autoRepair` (default 40%, 10–90% in steps of 5; the default must be a step). Save version 12: each ship has `booking` (repair, upgrade, HQ, by player or auto, created, started, waiting for funds since; null when none) and `autoRepair` (on, threshold); new ships start on at 40% with no booking; the 11 → 12 migration does the same for existing ships. Tests: `packages/content/test/config.test.ts`; `packages/schema/test/save.test.ts` (11 → 12); `packages/engine/test/trip-events.test.ts` (a bought ship).
|
||||||
- [x] **Step 27.2 — Sim: routing and triggers.** `packages/sim/src/service.ts`: `canReachHq` (one leg under the fuel rules: the route, landing, no-strand, a full tank where fuel is sold, else the fuel aboard), `nearestReachableHq`, `bookingAllowsJob`, `autoRepairBooks` (crossing below the threshold, on, Independent crew, no repair already booked) and `repairRecommended` (a Manual crew below the threshold). `chooseAction` takes an optional booking: at the HQ the pilot waits for the work; with the HQ reachable, only jobs ending there, else `deadhead-service` straight there (under the fuel rules and spending floor), else wait; out of reach, the usual choice. Tests (`packages/sim/test/service.test.ts`): reach (Mars yes, Ceres no for a Clipper from Earth); only jobs to the booked HQ; the deadhead there; waiting at the HQ; normal work when it's out of reach; the nearest HQ; the auto-repair trigger for each case, adding a repair to an upgrade booking; Repair recommended.
|
- [x] **Step 27.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.4 — Engine: Independent pilots and catch-up
|
||||||
- [ ] Step 27.5 — Client: Service section and tags
|
- [ ] Step 27.5 — Client: Service section and tags
|
||||||
- [ ] Step 27.6 — Client: Logbook and summaries
|
- [ ] Step 27.6 — Client: Logbook and summaries
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { logReadyGoals } from "./logbook";
|
||||||
import { scheduleDecision } from "./independent";
|
import { scheduleDecision } from "./independent";
|
||||||
import { resolveArrivals } from "./resolve";
|
import { resolveArrivals } from "./resolve";
|
||||||
import { discoveredIds, pilotState, shipState, sleepsAt } from "./state";
|
import { discoveredIds, pilotState, shipState, sleepsAt } from "./state";
|
||||||
|
import { startDueServices } from "./service";
|
||||||
|
|
||||||
type SavedPilot = SaveDocument["pilots"][number];
|
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
|
// 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).
|
// the time is the same however the catch-up is split (decision 0.5.141).
|
||||||
logReadyGoals(save, save.lastTimeSeen);
|
logReadyGoals(save, save.lastTimeSeen);
|
||||||
|
startDueServices(save, save.lastTimeSeen);
|
||||||
const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
|
const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
|
||||||
// Pilots decide before the 7-day cap and before the fleet goes quiet (decision 0.5.137).
|
// Pilots decide before the 7-day cap and before the fleet goes quiet (decision 0.5.137).
|
||||||
const decideLimit = Math.min(until, options.decideUntil, sleepsAt(save, until) - 1);
|
const decideLimit = Math.min(until, options.decideUntil, sleepsAt(save, until) - 1);
|
||||||
|
|
@ -215,6 +217,7 @@ export function catchUp(
|
||||||
);
|
);
|
||||||
if (at <= earliest) {
|
if (at <= earliest) {
|
||||||
settleDueEvent(save, due);
|
settleDueEvent(save, due);
|
||||||
|
startDueServices(save, at);
|
||||||
reached = at;
|
reached = at;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -231,6 +234,7 @@ export function catchUp(
|
||||||
) {
|
) {
|
||||||
finishShop(save, shop!);
|
finishShop(save, shop!);
|
||||||
logReadyGoals(save, shopAt);
|
logReadyGoals(save, shopAt);
|
||||||
|
startDueServices(save, shopAt);
|
||||||
reached = shopAt;
|
reached = shopAt;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -250,6 +254,8 @@ export function catchUp(
|
||||||
);
|
);
|
||||||
resolved += resolveArrivals(save, arrival);
|
resolved += resolveArrivals(save, arrival);
|
||||||
logReadyGoals(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) {
|
for (const d of landed) {
|
||||||
const flyer = save.pilots.find((p) => p.id === d.pilotId);
|
const flyer = save.pilots.find((p) => p.id === d.pilotId);
|
||||||
if (flyer?.mode === "independent") {
|
if (flyer?.mode === "independent") {
|
||||||
|
|
@ -259,6 +265,7 @@ export function catchUp(
|
||||||
reached = arrival;
|
reached = arrival;
|
||||||
} else {
|
} else {
|
||||||
decide(save, pilot!, decisionAt!, options.viewedContractId ?? null);
|
decide(save, pilot!, decisionAt!, options.viewedContractId ?? null);
|
||||||
|
startDueServices(save, decisionAt!);
|
||||||
decisions++;
|
decisions++;
|
||||||
reached = decisionAt!;
|
reached = decisionAt!;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
toApiShip,
|
toApiShip,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
import { buyDispatchOffice, setPilotMode, setPilotOrders, setSpendingFloor } from "./independent";
|
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). */
|
/** Every command that works on an existing company's save (the server's endpoints). */
|
||||||
export function commandTable(): CommandTable {
|
export function commandTable(): CommandTable {
|
||||||
|
|
@ -77,6 +78,19 @@ export function commandTable(): CommandTable {
|
||||||
acknowledgeSummaries: ({ save, now }, input) => ({
|
acknowledgeSummaries: ({ save, now }, input) => ({
|
||||||
acknowledged: acknowledge(save, input, now),
|
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).
|
// Radio calls and repairs (decisions 0.5.144, 0.5.148).
|
||||||
answerRadioCall: ({ save, now }, input) => ({
|
answerRadioCall: ({ save, now }, input) => ({
|
||||||
radioCalls: answerRadioCall(save, input, now),
|
radioCalls: answerRadioCall(save, input, now),
|
||||||
|
|
@ -152,10 +166,13 @@ export function commandTable(): CommandTable {
|
||||||
// A smaller tank holds no more than it can (CR-015).
|
// A smaller tank holds no more than it can (CR-015).
|
||||||
ship.fuelFu = Math.min(ship.fuelFu, content.shipModel(ship.modelId)!.tankCapacityFu);
|
ship.fuelFu = Math.min(ship.fuelFu, content.shipModel(ship.modelId)!.tankCapacityFu);
|
||||||
break;
|
break;
|
||||||
case "damage-hull":
|
case "damage-hull": {
|
||||||
// To try repairs and the "Needs repair" warning (CR-026).
|
// To try repairs and the "Needs repair" warning (CR-026), and auto-repair (CR-027).
|
||||||
|
const before = ship.hullConditionPct;
|
||||||
ship.hullConditionPct = 20;
|
ship.hullConditionPct = 20;
|
||||||
|
autoRepairAfterDamage(save, ship, before, now);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "finish-shop":
|
case "finish-shop":
|
||||||
// Done now: the "out of the shop" note still shows, as for a finished job.
|
// 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;
|
if (ship.inShopUntil !== null && ship.inShopUntil > now) ship.inShopUntil = now;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { catchUp, decideUntil } from "./catchup";
|
||||||
import { logReadyGoals } from "./logbook";
|
import { logReadyGoals } from "./logbook";
|
||||||
import { pruneSave } from "./resolve";
|
import { pruneSave } from "./resolve";
|
||||||
import { playerState } from "./state";
|
import { playerState } from "./state";
|
||||||
|
import { startDueServices } from "./service";
|
||||||
|
|
||||||
/** The commands the engine runs: the whole route catalog (CR-014). */
|
/** The commands the engine runs: the whole route catalog (CR-014). */
|
||||||
export type CommandName = RouteName;
|
export type CommandName = RouteName;
|
||||||
|
|
@ -260,6 +261,8 @@ export class GameEngine {
|
||||||
if (routes[name].manualDecision) save.company.lastManualDecisionAt = now;
|
if (routes[name].manualDecision) save.company.lastManualDecisionAt = now;
|
||||||
// A purchase or delivery may have made a goal ready (decision 0.5.141).
|
// A purchase or delivery may have made a goal ready (decision 0.5.141).
|
||||||
logReadyGoals(save, now);
|
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);
|
pruneSave(save, now);
|
||||||
return { save, result, unchanged: JSON.stringify(save) === before };
|
return { save, result, unchanged: JSON.stringify(save) === before };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import { fail } from "./errors";
|
import { fail } from "./errors";
|
||||||
import { logEntry, shipPilotId } from "./logbook";
|
import { logEntry, shipPilotId } from "./logbook";
|
||||||
import { hasBayAt, requireShip, spend } from "./state";
|
import { hasBayAt, requireShip, spend } from "./state";
|
||||||
|
import { autoRepairAfterDamage } from "./service";
|
||||||
|
|
||||||
type SavedDispatch = SaveDocument["dispatches"][number];
|
type SavedDispatch = SaveDocument["dispatches"][number];
|
||||||
type SavedShip = SaveDocument["ships"][number];
|
type SavedShip = SaveDocument["ships"][number];
|
||||||
|
|
@ -88,11 +89,13 @@ export function applyTripEvent(
|
||||||
dispatch.arrivalAt = applied.arrivalAt;
|
dispatch.arrivalAt = applied.arrivalAt;
|
||||||
dispatch.record = { ...dispatch.record, arrivalAt: applied.arrivalAt };
|
dispatch.record = { ...dispatch.record, arrivalAt: applied.arrivalAt };
|
||||||
if (ship) {
|
if (ship) {
|
||||||
|
const before = ship.hullConditionPct;
|
||||||
ship.hullConditionPct = hullAfterDamage(
|
ship.hullConditionPct = hullAfterDamage(
|
||||||
ship.hullConditionPct,
|
ship.hullConditionPct,
|
||||||
applied.hullLoss,
|
applied.hullLoss,
|
||||||
content.config,
|
content.config,
|
||||||
);
|
);
|
||||||
|
autoRepairAfterDamage(save, ship, before, at);
|
||||||
if (applied.extraFuelFu > 0) {
|
if (applied.extraFuelFu > 0) {
|
||||||
const burned = Math.min(applied.extraFuelFu, ship.fuelFu);
|
const burned = Math.min(applied.extraFuelFu, ship.fuelFu);
|
||||||
ship.fuelFu -= burned;
|
ship.fuelFu -= burned;
|
||||||
|
|
@ -235,6 +238,8 @@ export function repairShip(save: SaveDocument, input: { shipId: string }, now: R
|
||||||
Object.assign(ship, {
|
Object.assign(ship, {
|
||||||
inShopUntil: until,
|
inShopUntil: until,
|
||||||
shopRepair: true,
|
shopRepair: true,
|
||||||
|
shopUpgradeId: null,
|
||||||
|
shopLevel: null,
|
||||||
shopOpen: true,
|
shopOpen: true,
|
||||||
shopPilotId: pilot && coversRest ? pilot.id : null,
|
shopPilotId: pilot && coversRest ? pilot.id : null,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
toApiPilot,
|
toApiPilot,
|
||||||
toApiShip,
|
toApiShip,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
|
import { finishService } from "./service";
|
||||||
|
|
||||||
/** Leases a bay at a discovered location for its one-time price (Step 6.6). */
|
/** Leases a bay at a discovered location for its one-time price (Step 6.6). */
|
||||||
export function leaseBay(
|
export function leaseBay(
|
||||||
|
|
@ -187,9 +188,9 @@ export function unassignPilot(save: SaveDocument, pilotId: string, now: RealTime
|
||||||
*/
|
*/
|
||||||
export function finishShop(save: SaveDocument, ship: SavedShip): void {
|
export function finishShop(save: SaveDocument, ship: SavedShip): void {
|
||||||
const at = ship.inShopUntil;
|
const at = ship.inShopUntil;
|
||||||
if (ship.shopOpen && at !== null && ship.shopRepair) {
|
if (ship.shopOpen && at !== null && ship.shopRepair) finishRepair(save, ship, at);
|
||||||
finishRepair(save, ship, at);
|
// A booked service can repair and upgrade in one stay (CR-027).
|
||||||
} else if (ship.shopOpen && at !== null && ship.shopUpgradeId && ship.shopLevel !== null) {
|
if (ship.shopOpen && at !== null && ship.shopUpgradeId && ship.shopLevel !== null) {
|
||||||
logEntry(save, `${ship.id}:upgrade:${at}`, {
|
logEntry(save, `${ship.id}:upgrade:${at}`, {
|
||||||
at,
|
at,
|
||||||
pilotId: shipPilotId(save, ship.id),
|
pilotId: shipPilotId(save, ship.id),
|
||||||
|
|
@ -199,6 +200,7 @@ export function finishShop(save: SaveDocument, ship: SavedShip): void {
|
||||||
level: ship.shopLevel,
|
level: ship.shopLevel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (ship.shopOpen && at !== null) finishService(save, ship, at);
|
||||||
ship.shopOpen = false;
|
ship.shopOpen = false;
|
||||||
const pilot = save.pilots.find((p) => p.id === ship.shopPilotId);
|
const pilot = save.pilots.find((p) => p.id === ship.shopPilotId);
|
||||||
ship.shopPilotId = null;
|
ship.shopPilotId = null;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
import { logEntry, pruneLogbook } from "./logbook";
|
import { logEntry, pruneLogbook } from "./logbook";
|
||||||
import { discoveredIds } from "./state";
|
import { discoveredIds } from "./state";
|
||||||
|
import { autoRepairAfterDamage } from "./service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves every in-flight dispatch that has arrived by `now`, in arrival
|
* 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,
|
dispatch.record.arrivalAt,
|
||||||
content.config,
|
content.config,
|
||||||
);
|
);
|
||||||
|
const hullBefore = ship.hullConditionPct;
|
||||||
ship.hullConditionPct = hullAfterDamage(
|
ship.hullConditionPct = hullAfterDamage(
|
||||||
ship.hullConditionPct,
|
ship.hullConditionPct,
|
||||||
hullWear(hours, content.config),
|
hullWear(hours, content.config),
|
||||||
|
|
@ -96,6 +98,7 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
|
||||||
);
|
);
|
||||||
ship.status = "idle";
|
ship.status = "idle";
|
||||||
ship.locationId = r.shipLocationId;
|
ship.locationId = r.shipLocationId;
|
||||||
|
autoRepairAfterDamage(save, ship, hullBefore, now);
|
||||||
ship.lastArrivalAt = Math.max(ship.lastArrivalAt, dispatch.arrivalAt);
|
ship.lastArrivalAt = Math.max(ship.lastArrivalAt, dispatch.arrivalAt);
|
||||||
}
|
}
|
||||||
if (dispatch.tutorial) {
|
if (dispatch.tutorial) {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
isInShop,
|
isInShop,
|
||||||
isResting,
|
isResting,
|
||||||
MS_PER_HOUR,
|
MS_PER_HOUR,
|
||||||
|
repairRecommended,
|
||||||
pilotLevel,
|
pilotLevel,
|
||||||
skillPointsAvailable,
|
skillPointsAvailable,
|
||||||
type GoalProgress,
|
type GoalProgress,
|
||||||
|
|
@ -25,6 +26,7 @@ import {
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
import { fail } from "./errors";
|
import { fail } from "./errors";
|
||||||
import { nextRadioCallAt, radioCalls } from "./events";
|
import { nextRadioCallAt, radioCalls } from "./events";
|
||||||
|
import { canReachBooking, serviceWork } from "./service";
|
||||||
|
|
||||||
type SavedShip = SaveDocument["ships"][number];
|
type SavedShip = SaveDocument["ships"][number];
|
||||||
type SavedPilot = SaveDocument["pilots"][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). */
|
/** A ship with its upgrades and effective stats (CR-009). */
|
||||||
export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): Ship {
|
export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): Ship {
|
||||||
const model = content.shipModel(ship.modelId);
|
const model = content.shipModel(ship.modelId);
|
||||||
|
|
@ -191,6 +220,14 @@ export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): S
|
||||||
refuelPolicy: ship.refuelPolicy,
|
refuelPolicy: ship.refuelPolicy,
|
||||||
hullConditionPct: ship.hullConditionPct,
|
hullConditionPct: ship.hullConditionPct,
|
||||||
repairing: ship.shopRepair && isInShop(ship, now),
|
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,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -76,6 +76,23 @@ export const shipSchema = z.object({
|
||||||
hullConditionPct: z.number().min(0).max(100),
|
hullConditionPct: z.number().min(0).max(100),
|
||||||
/** In the shop for a hull repair rather than an upgrade. */
|
/** In the shop for a hull repair rather than an upgrade. */
|
||||||
repairing: z.boolean(),
|
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>;
|
export type Ship = z.infer<typeof shipSchema>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ export const SERVER_ERROR_CODES = [
|
||||||
"radio-call-closed",
|
"radio-call-closed",
|
||||||
"radio-option-unavailable",
|
"radio-option-unavailable",
|
||||||
"hull-intact",
|
"hull-intact",
|
||||||
|
"service-started",
|
||||||
|
"nothing-to-service",
|
||||||
|
"no-hq-reachable",
|
||||||
|
"unknown-hq",
|
||||||
|
"no-booking",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ErrorCode =
|
export type ErrorCode =
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,21 @@ export const logEntrySchema = z.discriminatedUnion("kind", [
|
||||||
xpBonusPct: z.number().nonnegative(),
|
xpBonusPct: z.number().nonnegative(),
|
||||||
extraFuelFu: 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). */
|
/** A hull repaired to 100% at an HQ (decision 0.5.148). */
|
||||||
z.object({
|
z.object({
|
||||||
...base,
|
...base,
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,19 @@ export const pilotOrdersRequestSchema = z.object({ pilotId: idSchema, orders: pi
|
||||||
/** Answer a radio call on a trip (decision 0.5.144). */
|
/** Answer a radio call on a trip (decision 0.5.144). */
|
||||||
export const answerRadioCallRequestSchema = z.object({ dispatchId: idSchema, optionId: idSchema });
|
export const answerRadioCallRequestSchema = z.object({ dispatchId: idSchema, optionId: idSchema });
|
||||||
export const radioCallsResponseSchema = z.object({ radioCalls: z.array(radioCallSchema) });
|
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). */
|
/** Repair a ship's hull at an HQ (decision 0.5.148). */
|
||||||
export const repairShipRequestSchema = z.object({ shipId: idSchema });
|
export const repairShipRequestSchema = z.object({ shipId: idSchema });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { logbookResponseSchema } from "./logbook";
|
||||||
import {
|
import {
|
||||||
acknowledgeSummariesRequestSchema,
|
acknowledgeSummariesRequestSchema,
|
||||||
answerRadioCallRequestSchema,
|
answerRadioCallRequestSchema,
|
||||||
|
autoRepairRequestSchema,
|
||||||
|
bookServiceRequestSchema,
|
||||||
radioCallsResponseSchema,
|
radioCallsResponseSchema,
|
||||||
repairShipRequestSchema,
|
repairShipRequestSchema,
|
||||||
acknowledgeSummariesResponseSchema,
|
acknowledgeSummariesResponseSchema,
|
||||||
|
|
@ -262,6 +264,31 @@ export const routes = {
|
||||||
auth: true,
|
auth: true,
|
||||||
manualDecision: 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: {
|
getLogbook: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/api/logbook",
|
path: "/api/logbook",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ describe("route catalog", () => {
|
||||||
"getLogbook",
|
"getLogbook",
|
||||||
"answerRadioCall",
|
"answerRadioCall",
|
||||||
"repairShip",
|
"repairShip",
|
||||||
|
"bookService",
|
||||||
|
"cancelBooking",
|
||||||
|
"setAutoRepair",
|
||||||
"createDispatch",
|
"createDispatch",
|
||||||
"buyShip",
|
"buyShip",
|
||||||
"leaseBay",
|
"leaseBay",
|
||||||
|
|
@ -113,6 +116,9 @@ describe("manual decisions in the catalog (Step 24.2, decision 0.5.136)", () =>
|
||||||
"refuelShip",
|
"refuelShip",
|
||||||
"renameCompany",
|
"renameCompany",
|
||||||
"repairShip",
|
"repairShip",
|
||||||
|
"bookService",
|
||||||
|
"cancelBooking",
|
||||||
|
"setAutoRepair",
|
||||||
"repayLoan",
|
"repayLoan",
|
||||||
"restPilot",
|
"restPilot",
|
||||||
"setPilotMode",
|
"setPilotMode",
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,9 @@ const ship = {
|
||||||
refuelPolicy: "auto",
|
refuelPolicy: "auto",
|
||||||
hullConditionPct: 100,
|
hullConditionPct: 100,
|
||||||
repairing: false,
|
repairing: false,
|
||||||
|
booking: null,
|
||||||
|
autoRepair: { on: true, thresholdPct: 40 },
|
||||||
|
repairRecommended: false,
|
||||||
};
|
};
|
||||||
const pilot = {
|
const pilot = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,15 @@ export type LogbookEntry = EntryBase &
|
||||||
extraFuelFu: number;
|
extraFuelFu: number;
|
||||||
}
|
}
|
||||||
| { kind: "repair"; fromPct: number; cost: 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" }>;
|
type Flight = Extract<LogbookEntry, { kind: "flight" }>;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue