diff --git a/DECISIONS.md b/DECISIONS.md index ead4cdd..669bb6f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2028,3 +2028,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **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. + +## Step 27.6 + +- **Only automatic bookings are logged as "booked"**: the player's own bookings are their decisions, already on screen. Start, finish and waiting for funds are logged for both. +- **"Service finished" sits beside the repair and upgrade lines** it covers, so the costs stay where they were (the repair line with the repair's cost, the start line with the whole charge). +- **The problems are found from the state** like the others (decision 0.5.140): a booking waiting for funds, or an unstarted one whose HQ is out of reach, wherever the ship is. diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx index 644c819..a409428 100644 --- a/apps/web/src/components/AwaySummary.tsx +++ b/apps/web/src/components/AwaySummary.tsx @@ -17,6 +17,7 @@ import { useGameAction, useLoadedPlayer } from "../state/actions"; import { Button, Icon, Sheet } from "../ui"; import { eventResultText } from "./EventResults"; import { SleepBanner } from "./FleetSleep"; +import { serviceItems } from "./Service"; const words = content.awayWording; @@ -62,10 +63,27 @@ function namer(state: PlayerState) { }; } -function problemText(problem: AwayProblem, name: ReturnType): string { +function problemText( + problem: AwayProblem, + name: ReturnType, + state: PlayerState, +): string { if (problem.kind === "below-floor") { return fill(words.highlights.belowFloor, { floor: formatCredits(problem.floor) }); } + if (problem.kind === "service-waiting-funds" || problem.kind === "service-cant-reach") { + const booking = state.ships.find((s) => s.id === problem.shipId)?.booking; + return fill( + problem.kind === "service-waiting-funds" + ? words.highlights.serviceWaitingFunds + : words.highlights.serviceCantReach, + { + ship: name.ship(problem.shipId), + items: booking ? serviceItems(booking) : "Service", + place: locationName(problem.hqId), + }, + ); + } const template = { "no-jobs": words.highlights.noJobs, "low-fuel": words.highlights.lowFuel, @@ -106,7 +124,7 @@ export function highlightText(highlight: AwayHighlight, state: PlayerState): str pilot: name.pilot(highlight.pilotId), }); case "problem": - return problemText(highlight.problem, name); + return problemText(highlight.problem, name, state); case "event": return eventResultText(highlight.entry, state); } diff --git a/apps/web/src/components/PilotLogbook.tsx b/apps/web/src/components/PilotLogbook.tsx index d551143..790328a 100644 --- a/apps/web/src/components/PilotLogbook.tsx +++ b/apps/web/src/components/PilotLogbook.tsx @@ -9,6 +9,7 @@ import { Button, Icon } from "../ui"; import { pilotNow } from "./AwaySummary"; import { eventResultText } from "./EventResults"; import { formatSleepTime } from "./FleetSleep"; +import { serviceItems } from "./Service"; /** One pilot's part of the logbook (decision 0.5.141), or the company's own entries. */ export interface PilotLog { @@ -171,6 +172,22 @@ function EntryLine({ entry, state }: { entry: LogEntry; state: PlayerState }) { {formatCredits(entry.cost)} ); + case "service": { + const items = serviceItems({ ...entry, upgradeLevel: null }); + const where = locationName(entry.hqId); + const ship = shipName(state, entry.shipId); + return ( + + {entry.phase === "booked" + ? `${ship}: ${items} booked automatically at ${where}` + : entry.phase === "started" + ? `${ship}: service started at ${where} (${items}), ${formatCredits(entry.cost ?? 0)}` + : entry.phase === "finished" + ? `${ship}: service finished at ${where} (${items})` + : `${ship}: waiting for funds for ${items} at ${where}`} + + ); + } case "goal-ready": return ( diff --git a/apps/web/test/service.test.tsx b/apps/web/test/service.test.tsx index fccad98..f7b2607 100644 --- a/apps/web/test/service.test.tsx +++ b/apps/web/test/service.test.tsx @@ -1,10 +1,13 @@ import { content } from "@deadhead/content"; -import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema"; +import type { Contract, LogEntry, Pilot, PlayerState, Ship } from "@deadhead/schema"; import { repairCost } from "@deadhead/sim"; -import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { buildAwaySummary } from "@deadhead/sim"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; +import { highlightText } from "../src/components/AwaySummary"; import { independentStatus } from "../src/components/IndependentPilots"; +import { PilotLogbook, pilotLogbook } from "../src/components/PilotLogbook"; import { serviceStatus, serviceTag } from "../src/components/Service"; import { formatCredits } from "../src/format"; import { pairOptions } from "../src/screens/DispatchSheet"; @@ -219,3 +222,64 @@ describe("service tags and status lines (Step 27.5, CR-027 §4)", () => { expect(pair!.service).toBe("Service booked: Repair at Earth"); }); }); + +describe("service in the Logbook and the away summary (Step 27.6)", () => { + const entry = ( + phase: "booked" | "started" | "finished" | "waiting-funds", + over: Partial> = {}, + ): LogEntry => ({ + id: phase, + at: 1_000, + pilotId: "p1", + shipId: "s1", + kind: "service", + phase, + hqId: "earth", + repair: true, + upgradeId: null, + by: "auto", + cost: null, + ...over, + }); + + it("lists automatic bookings, service started and finished, and waiting for funds", () => { + const s = state(ship()); + const book = pilotLogbook(s, [ + entry("booked"), + entry("waiting-funds"), + entry("started", { at: 2_000, cost: 3_200 }), + entry("finished", { at: 3_000, upgradeId: "engines" }), + ]); + render(); + expect( + screen.getByText("Dented Wren: Repair booked automatically at Earth"), + ).toBeInTheDocument(); + expect( + screen.getByText("Dented Wren: waiting for funds for Repair at Earth"), + ).toBeInTheDocument(); + expect( + screen.getByText(`Dented Wren: service started at Earth (Repair), ${formatCredits(3_200)}`), + ).toBeInTheDocument(); + expect( + screen.getByText("Dented Wren: service finished at Earth (Repair + Engine Tuning)"), + ).toBeInTheDocument(); + }); + + it("highlights service waiting for funds or out of reach", () => { + const s = state(ship({ booking: booking({ status: "waiting-funds" }) })); + const summary = buildAwaySummary({ + from: 0, + to: 1_000, + entries: [], + pilots: [{ id: "p1", name: "Rosa", now: "At Moon" }], + problems: [ + { kind: "service-waiting-funds", shipId: "s1", hqId: "earth" }, + { kind: "service-cant-reach", shipId: "s1", hqId: "mars" }, + ], + }); + expect(summary.highlights.map((h) => highlightText(h, s))).toEqual([ + "Dented Wren is waiting for funds: Repair at Earth", + "Dented Wren can't reach Mars for its booked service", + ]); + }); +}); diff --git a/docs/screenshots/27-6-logbook-desktop.png b/docs/screenshots/27-6-logbook-desktop.png new file mode 100644 index 0000000..f343cbe Binary files /dev/null and b/docs/screenshots/27-6-logbook-desktop.png differ diff --git a/docs/screenshots/27-6-logbook-phone.png b/docs/screenshots/27-6-logbook-phone.png new file mode 100644 index 0000000..56909b7 Binary files /dev/null and b/docs/screenshots/27-6-logbook-phone.png differ diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 20be815..0e24b48 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -164,3 +164,5 @@ Balance simulator events (CR-026 Step 26.9): `simulate` options `events` ("off" `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`. Catch-up and service (CR-027 Step 27.4): `decide` passes the ship's unstarted booking (`{ hqId }`) to `chooseAction`; `nextDecision` lets a pilot on a service trip (`onServiceTrip`: booked, reachable, not there yet) decide up to the 7-day cap even while the fleet is asleep. + +Away problems (CR-027 Step 27.6): `awayProblems` adds `service-waiting-funds` and `service-cant-reach` (ship and HQ) for unstarted bookings; the client words them from `awayWording.highlights.serviceWaitingFunds` / `serviceCantReach`. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e2aecbf..4adc6b3 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -574,5 +574,5 @@ Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR- - [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. - [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 +- [x] **Step 27.6 — Client: Logbook and summaries.** Logbook lines for `service` entries: "Iron Mule: Repair booked automatically at Earth", "service started at Earth (Repair + Engine Tuning), 3,600 cr", "service finished at Earth (…)", "waiting for funds for Repair at Earth". New away problems from the state (`awayProblems`, schema and sim `AwayProblem`): `service-waiting-funds` and `service-cant-reach`, with wording in `awayWording.highlights` ("Iron Mule is waiting for funds: Repair at Earth", "Iron Mule can't reach Jupiter Cloud Platform for its booked service"). Tests: the engine reports each problem (`packages/engine/test/service.test.ts`); the four logbook lines and both highlights render (`apps/web/test/service.test.tsx`). Screenshots: `docs/screenshots/27-6-logbook-*`. - [ ] Step 27.7 — Balance and regression diff --git a/packages/content/src/data/onboarding.ts b/packages/content/src/data/onboarding.ts index 59fe71a..4e93bf5 100644 --- a/packages/content/src/data/onboarding.ts +++ b/packages/content/src/data/onboarding.ts @@ -49,6 +49,8 @@ export const awayWording: AwayWording = { lowFuel: "{ship} is too low on fuel to fly from {place}", emergencyFuel: "{ship} is stuck at {place}: emergency fuel is available", belowFloor: "Credits are below your pilots' spending floor ({floor})", + serviceWaitingFunds: "{ship} is waiting for funds: {items} at {place}", + serviceCantReach: "{ship} can't reach {place} for its booked service", }, moreHighlights: "+{count} more in the Logbook", pilotLine: "{pilot} · {jobs} · {credits} · {now}", diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index e768fb1..ae33ada 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -644,6 +644,8 @@ export const awayWordingSchema = z.object({ lowFuel: line, emergencyFuel: line, belowFloor: line, + serviceWaitingFunds: line, + serviceCantReach: line, }), moreHighlights: line, pilotLine: line, diff --git a/packages/engine/src/game/away.ts b/packages/engine/src/game/away.ts index dc81239..cfdde88 100644 --- a/packages/engine/src/game/away.ts +++ b/packages/engine/src/game/away.ts @@ -13,12 +13,14 @@ import { } from "@deadhead/sim"; import { acceptedContractIds } from "./boards"; import { discoveredIds, shipState } from "./state"; +import { canReachBooking } from "./service"; /** * What needs the player now (decision 0.5.140's last highlight), from the * state: idle crewed ships that can't fly anywhere on the fuel aboard (or need * emergency fuel), Manual ships whose board this period has no job they could - * take, and credits below the pilots' spending floor. + * take, credits below the pilots' spending floor, and booked service waiting + * for funds or out of reach (CR-027). */ export function awayProblems(save: SaveDocument, now: RealTime): AwayProblem[] { if (save.player.tutorialActive) return []; @@ -26,6 +28,16 @@ export function awayProblems(save: SaveDocument, now: RealTime): AwayProblem[] { const accepted = acceptedContractIds(save); const period = boardPeriodIndex(now, content.config); const problems: AwayProblem[] = []; + // Booked service stuck (CR-027 §4): waiting for funds, or the HQ out of reach. + for (const ship of save.ships) { + const booking = ship.booking; + if (!booking || booking.startedAt !== null) continue; + if (booking.waitingSince !== null) { + problems.push({ kind: "service-waiting-funds", shipId: ship.id, hqId: booking.hqId }); + } else if (!canReachBooking(save, ship)) { + problems.push({ kind: "service-cant-reach", shipId: ship.id, hqId: booking.hqId }); + } + } for (const ship of save.ships) { const pilot = save.pilots.find((p) => p.assignedShipId === ship.id); if (ship.status !== "idle" || !ship.locationId || !pilot || isInShop(ship, now)) continue; diff --git a/packages/engine/test/service.test.ts b/packages/engine/test/service.test.ts index 0b0411d..9e8d64e 100644 --- a/packages/engine/test/service.test.ts +++ b/packages/engine/test/service.test.ts @@ -260,3 +260,36 @@ describe("auto-repair (Step 27.3, decision 0.5.157)", () => { } }); }); + +describe("service problems for the away summary (Step 27.6)", () => { + it("reports a booking waiting for funds, and one whose HQ is out of reach", async () => { + const { test, owner } = await dented(60, 0); + await test.app.call("bookService", { shipId: owner.shipId, repair: true, upgradeId: null }); + let { problems } = await test.app.call("getLogbook", {}); + expect(problems).toContainEqual({ + kind: "service-waiting-funds", + shipId: owner.shipId, + hqId: "earth", + }); + + await test.app.call("cancelBooking", { shipId: owner.shipId }); + await edit(test.app, (save) => { + // An HQ at Jupiter, which a Courier at Earth can't reach in one leg. + save.bays.push({ id: "jb", locationId: "jupiter-cloud-platform", createdAt: 0 }); + save.discoveredLocations.push({ locationId: "jupiter-cloud-platform", discoveredAt: 0 }); + }); + await test.app.call("bookService", { + shipId: owner.shipId, + repair: true, + upgradeId: null, + hqId: "jupiter-cloud-platform", + }); + ({ problems } = await test.app.call("getLogbook", {})); + expect(problems).toContainEqual({ + kind: "service-cant-reach", + shipId: owner.shipId, + hqId: "jupiter-cloud-platform", + }); + expect(problems.some((p) => p.kind === "service-waiting-funds")).toBe(false); + }); +}); diff --git a/packages/schema/src/logbook.ts b/packages/schema/src/logbook.ts index e47124f..4e2f011 100644 --- a/packages/schema/src/logbook.ts +++ b/packages/schema/src/logbook.ts @@ -117,6 +117,8 @@ export const awayProblemSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("low-fuel"), shipId: idSchema, locationId: idSchema }), z.object({ kind: z.literal("emergency-fuel"), shipId: idSchema, locationId: idSchema }), z.object({ kind: z.literal("below-floor"), credits, floor: credits }), + z.object({ kind: z.literal("service-waiting-funds"), shipId: idSchema, hqId: idSchema }), + z.object({ kind: z.literal("service-cant-reach"), shipId: idSchema, hqId: idSchema }), ]); const windowSchema = z.object({ from: timeSchema, to: timeSchema }); diff --git a/packages/sim/src/away.ts b/packages/sim/src/away.ts index 77e56d1..d42916d 100644 --- a/packages/sim/src/away.ts +++ b/packages/sim/src/away.ts @@ -86,7 +86,10 @@ export type AwayProblem = | { kind: "no-jobs"; shipId: string; locationId: string } | { kind: "low-fuel"; shipId: string; locationId: string } | { kind: "emergency-fuel"; shipId: string; locationId: string } - | { kind: "below-floor"; credits: number; floor: number }; + | { kind: "below-floor"; credits: number; floor: number } + /** Booked service that can't start for want of credits, or whose HQ is out of reach (CR-027). */ + | { kind: "service-waiting-funds"; shipId: string; hqId: string } + | { kind: "service-cant-reach"; shipId: string; hqId: string }; /** The highlights, in priority order (decision 0.5.140). */ export type AwayHighlight =