Step 27.6: client: Logbook and summaries
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-18 13:39:17 -04:00
parent ab7ab88f9d
commit b354e93acf
14 changed files with 168 additions and 7 deletions

View File

@ -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.

View File

@ -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<typeof namer>): string {
function problemText(
problem: AwayProblem,
name: ReturnType<typeof namer>,
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);
}

View File

@ -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)}
</span>
);
case "service": {
const items = serviceItems({ ...entry, upgradeLevel: null });
const where = locationName(entry.hqId);
const ship = shipName(state, entry.shipId);
return (
<span className={entry.phase === "waiting-funds" ? "text-amber-200" : "text-cyan-100"}>
{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}`}
</span>
);
}
case "goal-ready":
return (
<span className="text-amber-200">

View File

@ -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<Extract<LogEntry, { kind: "service" }>> = {},
): 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(<PilotLogbook logbook={book} state={s} />);
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",
]);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

View File

@ -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`.

View File

@ -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

View File

@ -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}",

View File

@ -644,6 +644,8 @@ export const awayWordingSchema = z.object({
lowFuel: line,
emergencyFuel: line,
belowFloor: line,
serviceWaitingFunds: line,
serviceCantReach: line,
}),
moreHighlights: line,
pilotLine: line,

View File

@ -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;

View File

@ -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);
});
});

View File

@ -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 });

View File

@ -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 =