diff --git a/DECISIONS.md b/DECISIONS.md index 98bed16..ead4cdd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2021,3 +2021,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **A service trip is any decision while the ship has an unstarted, reachable booking and isn't at the HQ yet**: only those pilots decide past the fleet's sleep time (still before the 7-day cap). A pilot waiting at the HQ (for funds) follows the sleep rule, so a long wait doesn't fill the catch-up with idle looks. - **After the work, the pilot goes back to their orders**; hull wear on the next trips can cross the threshold again and book the next repair. + +## Step 27.5 + +- **The Service section lives in the ship's Upgrades sheet**, under the hull: that sheet is the ship's detail view. +- **The tag is a wrapping chip**, not a pill: "Service booked: Cargo Bay Expansion 2 at Ceres Station" doesn't fit a pill on a phone. Cyan when on track, amber for "Repair recommended", waiting for funds or out of reach. +- **The slider saves as it moves** (the engine is local, so each step is cheap) and is disabled while auto-repair is off. +- **An Independent pilot's line puts the service first** (heading in, in the shop, waiting for funds); out of reach, it shows their usual work, and the ship's tag carries the warning. diff --git a/apps/web/src/components/IndependentPilots.tsx b/apps/web/src/components/IndependentPilots.tsx index 763224d..852575c 100644 --- a/apps/web/src/components/IndependentPilots.tsx +++ b/apps/web/src/components/IndependentPilots.tsx @@ -7,6 +7,7 @@ import { formatLocalTime } from "../screens/DispatchSheet"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { Button, Icon, Panel, Pill } from "../ui"; import { goodName } from "./Goods"; +import { serviceStatus } from "./Service"; const inputClass = "rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-sm text-ink-50 focus:border-amber-400 focus:outline-none"; @@ -64,6 +65,10 @@ export function DispatchOfficePanel() { */ export function independentStatus(state: PlayerState, pilot: Pilot): string | null { if (pilot.mode !== "independent") return null; + // Booked service comes first (CR-027 §4): heading in, in the shop, waiting for funds. + const ship = state.ships.find((s) => s.id === pilot.assignedShipId); + const service = ship && ship.booking?.status !== "cant-reach" ? serviceStatus(ship, pilot) : null; + if (service) return service; const flight = state.activeDispatches.find((d) => d.pilotId === pilot.id); if (flight) { const where = locationName(flight.destinationId); diff --git a/apps/web/src/components/Service.tsx b/apps/web/src/components/Service.tsx new file mode 100644 index 0000000..dce2c17 --- /dev/null +++ b/apps/web/src/components/Service.tsx @@ -0,0 +1,313 @@ +import { content } from "@deadhead/content"; +import type { Pilot, PlayerState, Ship } from "@deadhead/schema"; +import { nearestReachableHq, nextUpgradeLevel, repairCost, upgradeCost } from "@deadhead/sim"; +import { useState } from "react"; +import { formatCredits, locationName } from "../format"; +import { shipModel, simShip } from "../game"; +import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; +import { Button } from "../ui"; + +type Booking = NonNullable; + +/** "Repair + Engine Tuning 2": what a booking will do (CR-027). */ +export function serviceItems(booking: Pick) { + const items: string[] = []; + if (booking.repair) items.push("Repair"); + if (booking.upgradeId) { + const name = content.upgrade(booking.upgradeId)?.name ?? booking.upgradeId; + items.push(booking.upgradeLevel !== null ? `${name} ${booking.upgradeLevel}` : name); + } + return items.join(" + "); +} + +/** The Fleet card and dispatch sheet tag (CR-027 §4); null when there's nothing to say. */ +export function serviceTag(ship: Ship): string | null { + if (ship.booking) { + return `Service booked: ${serviceItems(ship.booking)} at ${locationName(ship.booking.hqId)}`; + } + return ship.repairRecommended ? "Repair recommended" : null; +} + +/** + * The pilot's service status line (CR-027 §4): heading in, in the shop, + * waiting for funds, or out of reach; null without a booking. + */ +export function serviceStatus(ship: Ship, pilot: Pilot | undefined): string | null { + const booking = ship.booking; + if (!booking) return null; + const hq = locationName(booking.hqId); + switch (booking.status) { + case "heading": + return `Heading to ${hq} for service`; + case "in-shop": + return pilot?.restingInShopUntil != null || pilot?.resting + ? `In the shop at ${hq} (resting)` + : `In the shop at ${hq}`; + case "waiting-funds": + return `Waiting for funds: ${serviceItems(booking)} at ${hq}`; + case "cant-reach": + return `Can't reach ${hq} for service`; + case "booked": + return `Service booked at ${hq}: starts when the ship is next idle there`; + } +} + +/** A small tag for the Fleet card and the dispatch sheet. */ +export function ServiceTag({ ship }: { ship: Ship }) { + const tag = serviceTag(ship); + if (!tag) return null; + const warn = + !ship.booking || + ship.booking.status === "waiting-funds" || + ship.booking.status === "cant-reach"; + return ( +

+ {tag} +

+ ); +} + +/** Where the ship is, or lands (bookings are made from anywhere). */ +function shipPlace(state: PlayerState, ship: Ship): string | null { + if (ship.locationId) return ship.locationId; + return state.activeDispatches.find((d) => d.id === ship.dispatchId)?.destinationId ?? null; +} + +/** The nearest HQ the ship can reach under the fuel rules, as the engine picks it. */ +export function defaultHq(state: PlayerState, ship: Ship): string | null { + const place = shipPlace(state, ship); + if (!place) return null; + const hqs = [...new Set(state.bays.map((b) => b.locationId))]; + const discovered = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id)); + return nearestReachableHq( + { + ...simShip(ship), + locationId: place, + inFlight: false, + tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice }, + }, + hqs, + discovered, + content, + ); +} + +/** What booking `repair` and `upgradeId` would cost now. */ +function estimate(ship: Ship, repair: boolean, upgradeId: string | null): number { + const model = shipModel(ship.modelId); + const upgrade = upgradeId ? content.upgrade(upgradeId) : undefined; + const level = upgrade ? nextUpgradeLevel(upgrade, model.id, ship.upgrades) : null; + return ( + (repair && ship.hullConditionPct < 100 + ? repairCost(model, ship.hullConditionPct, content.config) + : 0) + (level !== null ? upgradeCost(model, level, content.config) : 0) + ); +} + +/** Choosing the items and the HQ: a new booking or a change (decision 0.5.153). */ +function BookingForm({ ship, onDone }: { ship: Ship; onDone: () => void }) { + const state = useLoadedPlayer(); + const book = useGameAction("bookService"); + const model = shipModel(ship.modelId); + const hqs = [...new Set(state.bays.map((b) => b.locationId))]; + const nearest = defaultHq(state, ship); + const canRepair = ship.hullConditionPct < 100; + const [repair, setRepair] = useState(ship.booking?.repair ?? canRepair); + const [upgradeId, setUpgradeId] = useState(ship.booking?.upgradeId ?? null); + const [hqId, setHqId] = useState(ship.booking?.hqId ?? nearest ?? hqs[0] ?? ""); + const upgrades = content.upgrades.filter( + (u) => nextUpgradeLevel(u, model.id, ship.upgrades) !== null, + ); + const cost = estimate(ship, repair, upgradeId); + const nothing = !(repair && canRepair) && !upgradeId; + + return ( +
+ + + +

+ Estimate: {formatCredits(cost)}, charged when the work starts +

+
+ + +
+ {book.isError && ( +

+ {errorMessage(book.error)} +

+ )} +
+ ); +} + +/** Auto-repair on or off and its threshold, in steps of 5% (decision 0.5.157). */ +function AutoRepair({ ship }: { ship: Ship }) { + const save = useGameAction("setAutoRepair"); + const { minPct, maxPct, stepPct } = content.config.autoRepair; + const { on, thresholdPct } = ship.autoRepair; + return ( +
+ + save.mutate({ shipId: ship.id, on, thresholdPct: Number(e.target.value) })} + className="mt-2 w-full accent-amber-400" + /> +

+ With an Independent pilot, a repair is booked at the nearest HQ when the hull drops below + this. With you flying it, the ship shows “Repair recommended”. +

+ {save.isError && ( +

+ {errorMessage(save.error)} +

+ )} +
+ ); +} + +/** + * The ship's Service section (CR-027 §4): its booking with the estimate and + * status, Book service, Change and Cancel, and auto-repair. + */ +export function ServiceSection({ shipId }: { shipId: string }) { + const state = useLoadedPlayer(); + const cancel = useGameAction("cancelBooking"); + const [editing, setEditing] = useState(false); + const ship = state.ships.find((s) => s.id === shipId)!; + const pilot = state.pilots.find((p) => p.id === ship.pilotId); + const booking = ship.booking; + const started = booking?.status === "in-shop"; + + return ( +
+
+ Service + {booking && ( + {booking.by === "auto" ? "Auto" : "Booked"} + )} +
+ {booking && !editing && ( +
+

+ {serviceItems(booking)} at {locationName(booking.hqId)} ·{" "} + {formatCredits(booking.estimate)} +

+

+ {serviceStatus(ship, pilot)} +

+ {!started && ( +
+ + +
+ )} +
+ )} + {!booking && !editing && ( + <> +

+ Book a repair or an upgrade from anywhere: the work starts when the ship is next idle at + the HQ. +

+ + + )} + {editing && setEditing(false)} />} + {cancel.isError && ( +

+ {errorMessage(cancel.error)} +

+ )} + +
+ ); +} diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index d650fe1..a98a486 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -43,6 +43,7 @@ import { formatTimeOfDay, } from "../clock/timeFormat"; import { usePreferences } from "../state/preferences"; +import { serviceTag } from "../components/Service"; /** Short player-facing reasons a ship+pilot cannot take a contract. */ export const INELIGIBLE_REASONS: Record = { @@ -177,6 +178,8 @@ export interface PairOption { modelSprite: string; pilotId: string | null; pilotName: string | null; + /** "Service booked: Repair at Mars" or "Repair recommended" (CR-027); null otherwise. */ + service: string | null; /** Why this pair cannot take the contract; empty when it can. */ problems: string[]; tripMs: number; @@ -282,6 +285,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) modelSprite: model.sprite, pilotId: pilot?.id ?? null, pilotName: pilot?.name ?? null, + service: serviceTag(ship), problems, tripMs, fuelCost, @@ -429,6 +433,11 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo {option.modelName} · {option.pilotName ?? "no pilot"} + {option.service && ( + + {option.service} + + )} {blocked ? ( diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index dba8c79..8cf02ae 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -13,6 +13,7 @@ import { useClock } from "../clock/clock"; import { Countdown } from "../components/Countdown"; import { formatFu, FuelGauge, refuelForLeg, ShipFuel } from "../components/Fuel"; import { ShipUpgrades } from "./ShipUpgrades"; +import { ServiceTag } from "../components/Service"; import { strandedText } from "./DispatchSheet"; import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format"; import { location, lowFuel, shipModel, tripRealMs, shipStatsOf, simShip } from "../game"; @@ -161,6 +162,7 @@ function ShipRow(props: { )} + {low && (

= { @@ -137,9 +138,11 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: () titleId="upgrades-title" eyebrow={model.name} title={ship.name} - subtitle="Upgrades and hull repairs are done at an HQ. The ship stays in the shop until the work is done." + subtitle="Upgrades and hull repairs are done at an HQ: start them here, or book service from anywhere. The ship stays in the shop until the work is done." > + {/* Booked from anywhere; the work starts at the HQ (CR-027). */} + {state.tutorial === null && }

Upgrades

diff --git a/apps/web/test/service.test.tsx b/apps/web/test/service.test.tsx new file mode 100644 index 0000000..fccad98 --- /dev/null +++ b/apps/web/test/service.test.tsx @@ -0,0 +1,221 @@ +import { content } from "@deadhead/content"; +import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema"; +import { repairCost } from "@deadhead/sim"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { independentStatus } from "../src/components/IndependentPilots"; +import { serviceStatus, serviceTag } from "../src/components/Service"; +import { formatCredits } from "../src/format"; +import { pairOptions } from "../src/screens/DispatchSheet"; +import { pilotExtras } from "./fixtures"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; + +type Booking = NonNullable; + +const ship = (over: Partial = {}): Ship => ({ + id: "s1", + name: "Dented Wren", + modelId: "courier", + status: "idle", + locationId: "moon", + dispatchId: null, + pilotId: "p1", + lastArrivalAt: 0, + ...shipExtras("courier"), + hullConditionPct: 60, + ...over, +}); +const booking = (over: Partial = {}): Booking => ({ + repair: true, + upgradeId: null, + upgradeLevel: null, + hqId: "earth", + by: "player", + estimate: 4_000, + status: "heading", + ...over, +}); +const rosa: Pilot = { + ...samplePlayerState().pilots[0]!, + id: "p1", + name: "Rosa", + isAvatar: false, + assignedShipId: "s1", + locationId: null, + ...pilotExtras, + mode: "independent", +}; +const state = (s: Ship, over: Partial = {}) => + samplePlayerState({ + tutorial: null, + company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 }, + bays: [{ id: "b1", locationId: "earth" }], + ships: [s], + pilots: [samplePlayerState().pilots[0]!, rosa], + ...over, + }); + +async function openService(api: ReturnType) { + renderApp(api, "/fleet"); + const card = (await screen.findByText("Dented Wren")).closest("li")!; + await userEvent.click(within(card).getByRole("button", { name: "Upgrades for Dented Wren" })); + return within(screen.getByRole("dialog", { name: "Dented Wren" })).getByLabelText("Service"); +} + +describe("the Service section (Step 27.5, decision 0.5.153)", () => { + it("books service with the estimate and the nearest reachable HQ by default", async () => { + const api = fakeApi({ + getPlayerState: () => state(ship()), + bookService: () => ({ ship: ship({ booking: booking() }) }), + }); + const section = await openService(api); + await userEvent.click(within(section).getByRole("button", { name: "Book service" })); + const form = within(section).getByRole("group", { name: "Book service" }); + expect(within(form).getByLabelText("HQ")).toHaveValue("earth"); + expect(within(form).getByRole("option", { name: "Earth (nearest)" })).toBeInTheDocument(); + const cost = repairCost(content.shipModel("courier")!, 60, content.config); + expect(within(form).getByLabelText("Estimate")).toHaveTextContent(formatCredits(cost)); + await userEvent.selectOptions(within(form).getByLabelText("Upgrade"), "cargo-bay"); + await userEvent.click(within(form).getByRole("button", { name: "Book service" })); + expect(api.call).toHaveBeenCalledWith("bookService", { + shipId: "s1", + repair: true, + upgradeId: "cargo-bay", + hqId: "earth", + }); + }); + + it("shows a booking's items, estimate and status, and changes or cancels it", async () => { + const booked = ship({ + booking: booking({ upgradeId: "cargo-bay", upgradeLevel: 1, estimate: 12_345 }), + }); + const api = fakeApi({ + getPlayerState: () => state(booked), + cancelBooking: () => ({ ship: ship() }), + bookService: () => ({ ship: booked }), + }); + const section = await openService(api); + expect(section).toHaveTextContent( + `Repair + Cargo Bay Expansion 1 at Earth · ${formatCredits(12_345)}`, + ); + expect(within(section).getByLabelText("Service status")).toHaveTextContent( + "Heading to Earth for service", + ); + await userEvent.click(within(section).getByRole("button", { name: "Cancel booking" })); + expect(api.call).toHaveBeenCalledWith("cancelBooking", { shipId: "s1" }); + + await userEvent.click(within(section).getByRole("button", { name: "Change" })); + const form = within(section).getByRole("group", { name: "Book service" }); + expect(within(form).getByLabelText("Upgrade")).toHaveValue("cargo-bay"); + await userEvent.selectOptions(within(form).getByLabelText("Upgrade"), ""); + await userEvent.click(within(form).getByRole("button", { name: "Save booking" })); + expect(api.call).toHaveBeenCalledWith("bookService", { + shipId: "s1", + repair: true, + upgradeId: null, + hqId: "earth", + }); + }); + + it("can't change work that has started", async () => { + const api = fakeApi({ + getPlayerState: () => state(ship({ booking: booking({ status: "in-shop" }) })), + }); + const section = await openService(api); + expect(within(section).queryByRole("button", { name: "Change" })).not.toBeInTheDocument(); + expect( + within(section).queryByRole("button", { name: "Cancel booking" }), + ).not.toBeInTheDocument(); + }); + + it("saves the auto-repair threshold in steps of 5, and turns it off", async () => { + const api = fakeApi({ + getPlayerState: () => state(ship()), + setAutoRepair: () => ({ ship: ship() }), + }); + const section = await openService(api); + const slider = within(section).getByLabelText("Auto-repair threshold"); + expect(slider).toHaveAttribute("step", "5"); + expect(slider).toHaveAttribute("min", "10"); + expect(slider).toHaveAttribute("max", "90"); + fireEvent.change(slider, { target: { value: "55" } }); + await waitFor(() => + expect(api.call).toHaveBeenCalledWith("setAutoRepair", { + shipId: "s1", + on: true, + thresholdPct: 55, + }), + ); + await userEvent.click(within(section).getByRole("checkbox", { name: /Auto-repair below 40%/ })); + expect(api.call).toHaveBeenCalledWith("setAutoRepair", { + shipId: "s1", + on: false, + thresholdPct: 40, + }); + }); +}); + +describe("service tags and status lines (Step 27.5, CR-027 §4)", () => { + it("tags the Fleet card with the booking, or Repair recommended", async () => { + renderApp( + fakeApi({ + getPlayerState: () => + state( + ship({ + booking: booking({ upgradeId: "engines", upgradeLevel: 2, repair: false }), + }), + ), + }), + "/fleet", + ); + expect(await screen.findByLabelText("Dented Wren service")).toHaveTextContent( + "Service booked: Engine Tuning 2 at Earth", + ); + }); + + it("words each state", () => { + const at = (status: Booking["status"]) => ship({ booking: booking({ status }) }); + expect(serviceStatus(at("heading"), rosa)).toBe("Heading to Earth for service"); + expect(serviceStatus(at("in-shop"), { ...rosa, restingInShopUntil: 1 })).toBe( + "In the shop at Earth (resting)", + ); + expect(serviceStatus(at("in-shop"), rosa)).toBe("In the shop at Earth"); + expect(serviceStatus(at("waiting-funds"), rosa)).toBe("Waiting for funds: Repair at Earth"); + expect(serviceStatus(at("cant-reach"), rosa)).toBe("Can't reach Earth for service"); + expect(serviceStatus(ship(), rosa)).toBeNull(); + expect(serviceTag(ship({ repairRecommended: true }))).toBe("Repair recommended"); + expect(serviceTag(ship())).toBeNull(); + }); + + it("puts the service first in an Independent pilot's status line", () => { + const heading = ship({ booking: booking() }); + expect(independentStatus(state(heading), rosa)).toBe("Heading to Earth for service"); + // Out of reach: the pilot works as usual, and the line says so. + const far = ship({ booking: booking({ status: "cant-reach" }) }); + expect(independentStatus(state(far), { ...rosa, locationId: "moon" })).not.toMatch(/service/); + }); + + it("tags the ship in the dispatch sheet", () => { + const job: Contract = { + id: "c", + originId: "moon", + destinationId: "earth", + cargoTypeId: "standard", + goodId: "consumer-goods", + shipper: "Pacific Orbital Logistics", + priority: false, + deliveryWindowGameHours: null, + tier: 0, + massTonnes: 5, + payout: 1_000, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: 0, + expiresAt: 600_000, + firstContact: false, + }; + const [pair] = pairOptions(state(ship({ booking: booking({ status: "booked" }) })), job, 0); + expect(pair!.service).toBe("Service booked: Repair at Earth"); + }); +}); diff --git a/docs/screenshots/27-5-book-desktop.png b/docs/screenshots/27-5-book-desktop.png new file mode 100644 index 0000000..8d79aa4 Binary files /dev/null and b/docs/screenshots/27-5-book-desktop.png differ diff --git a/docs/screenshots/27-5-book-phone.png b/docs/screenshots/27-5-book-phone.png new file mode 100644 index 0000000..4d57087 Binary files /dev/null and b/docs/screenshots/27-5-book-phone.png differ diff --git a/docs/screenshots/27-5-booked-card-desktop.png b/docs/screenshots/27-5-booked-card-desktop.png new file mode 100644 index 0000000..fc35e58 Binary files /dev/null and b/docs/screenshots/27-5-booked-card-desktop.png differ diff --git a/docs/screenshots/27-5-booked-card-phone.png b/docs/screenshots/27-5-booked-card-phone.png new file mode 100644 index 0000000..59f23d6 Binary files /dev/null and b/docs/screenshots/27-5-booked-card-phone.png differ diff --git a/docs/screenshots/27-5-booking-desktop.png b/docs/screenshots/27-5-booking-desktop.png new file mode 100644 index 0000000..e163dd0 Binary files /dev/null and b/docs/screenshots/27-5-booking-desktop.png differ diff --git a/docs/screenshots/27-5-booking-phone.png b/docs/screenshots/27-5-booking-phone.png new file mode 100644 index 0000000..d532fcc Binary files /dev/null and b/docs/screenshots/27-5-booking-phone.png differ diff --git a/docs/screenshots/27-5-recommended-desktop.png b/docs/screenshots/27-5-recommended-desktop.png new file mode 100644 index 0000000..eb7b558 Binary files /dev/null and b/docs/screenshots/27-5-recommended-desktop.png differ diff --git a/docs/screenshots/27-5-recommended-phone.png b/docs/screenshots/27-5-recommended-phone.png new file mode 100644 index 0000000..5d91692 Binary files /dev/null and b/docs/screenshots/27-5-recommended-phone.png differ diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 4e6aedc..e2aecbf 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -573,6 +573,6 @@ Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR- - [x] **Step 27.2 — Sim: routing and triggers.** `packages/sim/src/service.ts`: `canReachHq` (one leg under the fuel rules: the route, landing, no-strand, a full tank where fuel is sold, else the fuel aboard), `nearestReachableHq`, `bookingAllowsJob`, `autoRepairBooks` (crossing below the threshold, on, Independent crew, no repair already booked) and `repairRecommended` (a Manual crew below the threshold). `chooseAction` takes an optional booking: at the HQ the pilot waits for the work; with the HQ reachable, only jobs ending there, else `deadhead-service` straight there (under the fuel rules and spending floor), else wait; out of reach, the usual choice. Tests (`packages/sim/test/service.test.ts`): reach (Mars yes, Ceres no for a Clipper from Earth); only jobs to the booked HQ; the deadhead there; waiting at the HQ; normal work when it's out of reach; the nearest HQ; the auto-repair trigger for each case, adding a repair to an upgrade booking; Repair recommended. - [x] **Step 27.3 — Engine: booking commands and service start.** `packages/engine/src/game/service.ts`: `bookService` (repair and/or one upgrade level at an HQ, default the nearest reachable; errors `nothing-to-service`, `hull-intact`, `upgrade-maxed`, `unknown-hq`, `no-hq-reachable`, `service-started`), `cancelBooking` (`no-booking`, `service-started`), `setAutoRepair` (10–90% in steps of 5). `tryStartService` runs after every command and catch-up event: a ship idle at its booked HQ and out of the shop starts the work (repair then upgrade, one shop stay), charged then — a player booking whenever credits cover it, an automatic one only above the spending floor — else it waits, logged once. A stay as long as a rest takes the pilot along (CR-023), so a rest due on arrival or already running overlaps it and ends with it. Auto-repair books (or adds a repair to a booking) when damage, wear or the dev tool takes an Independent crew's hull below the threshold. Logbook kind `service` (booked, started, finished, waiting-funds). Ship API: `booking` (items, level, HQ, by, estimate, status `booked`/`heading`/`in-shop`/`waiting-funds`/`cant-reach`), `autoRepair`, `repairRecommended`. Routes `bookService`, `cancelBooking`, `setAutoRepair` (manual decisions). Tests (`packages/engine/test/service.test.ts`): immediate start at the HQ with the charge, the upgrade and the log; start on arrival charged for the landed hull; rest on arrival overlapping, duty reset; a running rest overlapping; waiting for funds then starting; refusals; cancel; the automatic booking, starting or waiting below the floor; Manual crews only recommended; the threshold setting. - [x] **Step 27.4 — Engine: Independent pilots and catch-up.** An Independent pilot's decision passes the ship's unstarted booking to `chooseAction`, so they take only jobs ending at the booked HQ, else deadhead there, and wait there for the work; out of reach, they work as usual. A pilot on a service trip (booked, reachable, not there yet) keeps deciding while the fleet is asleep, up to the 7-day cap; once the work starts they're back under the sleep rule. Tests (`packages/engine/test/service-routing.test.ts`): the job to the HQ over a richer one, with the work on landing; the deadhead; the service trip while asleep, and nothing after; an HQ out of reach; the same save caught up at once or an hour at a time. -- [ ] Step 27.5 — Client: Service section and tags +- [x] **Step 27.5 — Client: Service section and tags.** `apps/web/src/components/Service.tsx`: the Service section in the ship's Upgrades sheet (the booking's items, HQ, estimate and status; Book service with a repair box, an upgrade picker (the next level of each) and an HQ picker defaulting to the nearest reachable (`defaultHq`, the sim's `nearestReachableHq`), with the estimate; Change and Cancel booking until the work starts; the auto-repair box and a 10–90% slider in steps of 5). `serviceTag` on the Fleet card and in the dispatch sheet ("Service booked: Engine Tuning 2 at Earth", "Repair recommended"); `serviceStatus` ("Heading to Earth for service", "In the shop at Earth (resting)", "Waiting for funds: Repair at Earth", "Can't reach Earth for service") leads an Independent pilot's status line. Tests (`apps/web/test/service.test.tsx`): booking with the estimate and the nearest HQ; a booking's items and status, Change and Cancel calling the engine; no change once started; the slider and the box; the Fleet tag; each status wording; the pilot line; the dispatch sheet tag. Screenshots: `docs/screenshots/27-5-*` (Repair recommended, the booking form, a booked ship's card and sheet; phone and desktop). - [ ] Step 27.6 — Client: Logbook and summaries - [ ] Step 27.7 — Balance and regression