Step 27.6: client: Logbook and summaries
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
ab7ab88f9d
commit
b354e93acf
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -644,6 +644,8 @@ export const awayWordingSchema = z.object({
|
|||
lowFuel: line,
|
||||
emergencyFuel: line,
|
||||
belowFloor: line,
|
||||
serviceWaitingFunds: line,
|
||||
serviceCantReach: line,
|
||||
}),
|
||||
moreHighlights: line,
|
||||
pilotLine: line,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in New Issue