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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
/** In the shop for a hull repair rather than an upgrade. */
|
||||
repairing: z.boolean(),
|
||||
/** Service booked at an HQ (CR-027), with what it'll cost and where it stands; null when none. */
|
||||
booking: z
|
||||
.object({
|
||||
repair: z.boolean(),
|
||||
upgradeId: idSchema.nullable(),
|
||||
/** The level the upgrade will reach; null without one. */
|
||||
upgradeLevel: z.number().int().positive().nullable(),
|
||||
hqId: idSchema,
|
||||
by: z.enum(["player", "auto"]),
|
||||
/** What the work would cost now. */
|
||||
estimate: z.number().int().nonnegative(),
|
||||
status: z.enum(["booked", "heading", "in-shop", "waiting-funds", "cant-reach"]),
|
||||
})
|
||||
.nullable(),
|
||||
autoRepair: z.object({ on: z.boolean(), thresholdPct: z.number() }),
|
||||
/** A Manual crew's hull below the auto-repair threshold (decision 0.5.157). */
|
||||
repairRecommended: z.boolean(),
|
||||
});
|
||||
export type Ship = z.infer<typeof shipSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,15 @@ export type LogbookEntry = EntryBase &
|
|||
extraFuelFu: number;
|
||||
}
|
||||
| { kind: "repair"; fromPct: number; cost: number }
|
||||
| {
|
||||
kind: "service";
|
||||
phase: "booked" | "started" | "finished" | "waiting-funds";
|
||||
hqId: string;
|
||||
repair: boolean;
|
||||
upgradeId: string | null;
|
||||
by: "player" | "auto";
|
||||
cost: number | null;
|
||||
}
|
||||
);
|
||||
|
||||
type Flight = Extract<LogbookEntry, { kind: "flight" }>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue