Step 21.7: client: board and logbook
CI / check (push) Has been cancelled Details

A 'Yours for' chip in the first-look window, a 'Catching up on your
fleet' indicator for slow catch-ups, and a per-pilot logbook with company
totals on the away sheet (CR-021).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-11 10:45:32 -04:00
parent 956a621b12
commit a75a39e01f
22 changed files with 527 additions and 8 deletions

View File

@ -1722,3 +1722,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"Anywhere you know" is `destinationIds: null`**, so places discovered later are included. Unticking it starts from every place known now. - **"Anywhere you know" is `destinationIds: null`**, so places discovered later are included. Unticking it starts from every place known now.
- **The Fleet badge sits on the pilot line**: beside the status pill on a phone it truncated the ship's name. - **The Fleet badge sits on the pilot line**: beside the status pill on a phone it truncated the ship's name.
- **The spending floor's button is "Save floor"**, distinct from the company name's "Save". - **The spending floor's button is "Save floor"**, distinct from the company name's "Save".
## Step 21.7
- **The "Yours for" chip appears only when a pilot is Independent**: without one, nobody competes for the job and the chip would be noise. First contacts never get it; they're always the player's.
- **The logbook replaces per-trip cards for pilots' own trips**: an hour away is a dozen trips per pilot. Each pilot is a collapsible section, open when there's only one.
- **A logbook job's payout is credits + pilot share + loan repayment**, rebuilt from the summary: what the contract paid before deductions.
- **The indicator follows the loop's own pass**, via `onBusy`, not a separate catch-up call, so it covers the first pass on opening and each resume.

View File

@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { createEngineClient } from "./api/engine-client"; import { createEngineClient } from "./api/engine-client";
import { ApiProvider } from "./api/context"; import { ApiProvider } from "./api/context";
import { CatchingUp } from "./components/PilotLogbook";
import { GameContext } from "./platform/context"; import { GameContext } from "./platform/context";
import { startResolutionLoop, type Game } from "./platform/game"; import { startResolutionLoop, type Game } from "./platform/game";
import { PLAYER_STATE_KEY } from "./state/player"; import { PLAYER_STATE_KEY } from "./state/player";
@ -26,6 +27,8 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode }
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const api = useMemo(() => createEngineClient(game.engine), [game]); const api = useMemo(() => createEngineClient(game.engine), [game]);
const [status, setStatus] = useState<Status>("starting"); const [status, setStatus] = useState<Status>("starting");
// A slow catch-up of Independent pilots shows "Catching up on your fleet…" (CR-021).
const [catchingUp, setCatchingUp] = useState(false);
useEffect(() => { useEffect(() => {
game.lock.onLost(() => setStatus("elsewhere")); game.lock.onLost(() => setStatus("elsewhere"));
@ -40,10 +43,14 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode }
useEffect(() => { useEffect(() => {
if (status !== "here") return; if (status !== "here") return;
const loop = startResolutionLoop(game, () => { const loop = startResolutionLoop(
game,
() => {
void queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY }); void queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
void queryClient.invalidateQueries({ queryKey: ["board"] }); void queryClient.invalidateQueries({ queryKey: ["board"] });
}); },
setCatchingUp,
);
if (game.devTools) { if (game.devTools) {
window.deadhead = { call: (name, input) => game.engine.call(name, input as never) }; window.deadhead = { call: (name, input) => game.engine.call(name, input as never) };
} }
@ -77,6 +84,7 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode }
return ( return (
<GameContext.Provider value={game}> <GameContext.Provider value={game}>
<ApiProvider api={api}>{children}</ApiProvider> <ApiProvider api={api}>{children}</ApiProvider>
<CatchingUp busy={catchingUp} />
</GameContext.Provider> </GameContext.Provider>
); );
} }

View File

@ -7,6 +7,7 @@ import { formatCredits, locationName, toLocation } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet"; import { formatLocalTime } from "../screens/DispatchSheet";
import { useGameAction, useLoadedPlayer } from "../state/actions"; import { useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Sheet } from "../ui"; import { Button, Icon, Sheet } from "../ui";
import { PilotLogbook, pilotLogbook } from "./PilotLogbook";
/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */ /** "While you were away": deliveries resolved since the player last looked (Step 9.2). */
export function AwaySummary() { export function AwaySummary() {
@ -39,10 +40,15 @@ export function AwaySummary() {
acknowledge.mutate({ dispatchIds: ids, ...(shipIds.length > 0 ? { shipIds } : {}) }); acknowledge.mutate({ dispatchIds: ids, ...(shipIds.length > 0 ? { shipIds } : {}) });
}; };
// Independent pilots' own trips (CR-021) are listed by pilot, not one card each.
const logbook = pilotLogbook(state, summaries);
const yours = summaries.filter((s) => !s.independent);
return ( return (
<Sheet titleId="away-title" eyebrow="Logbook" title="While you were away"> <Sheet titleId="away-title" eyebrow="Logbook" title="While you were away">
<PilotLogbook logbook={logbook} />
<ul className="mt-4 space-y-3"> <ul className="mt-4 space-y-3">
{summaries.map((s) => ( {yours.map((s) => (
<li <li
key={s.dispatchId} key={s.dispatchId}
className="rounded-xl border border-space-600 bg-space-950/50 p-3 text-sm" className="rounded-xl border border-space-600 bg-space-950/50 p-3 text-sm"

View File

@ -0,0 +1,208 @@
import { content } from "@deadhead/content";
import type { PlayerState, ResolutionSummary } from "@deadhead/schema";
import { useEffect, useState } from "react";
import { formatCredits, locationName } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet";
import { Icon } from "../ui";
import { independentStatus } from "./IndependentPilots";
/** One line of a pilot's logbook (CR-021 §2). */
export type LogEntry =
| {
kind: "job";
dispatchId: string;
good: string;
originId: string;
destinationId: string;
/** The contract's payout: what the company kept, the pilot's share and the lender's cut. */
payout: number;
pilotShare: number;
fuelCost: number;
xp: number;
}
| {
kind: "deadhead";
dispatchId: string;
originId: string;
destinationId: string;
fuelCost: number;
}
| { kind: "rest"; dispatchId: string; until: number };
export interface PilotLog {
pilotId: string;
name: string;
entries: LogEntry[];
xp: number;
/** Where they are or what they're doing now. */
now: string;
}
export interface Logbook {
totals: {
jobs: number;
deadheads: number;
payout: number;
pilotShare: number;
fuelCost: number;
xp: number;
};
pilots: PilotLog[];
}
/**
* The pilot logbook (CR-021 §2): Independent pilots' own trips since the
* player last looked, by pilot in roster order, oldest first, with company
* totals that are the sums of the entries.
*/
export function pilotLogbook(state: PlayerState, summaries: readonly ResolutionSummary[]): Logbook {
const own = summaries.filter((s) => s.independent).sort((a, b) => a.resolvedAt - b.resolvedAt);
const pilots: PilotLog[] = [];
for (const pilot of state.pilots) {
const trips = own.filter((s) => s.pilotId === pilot.id);
if (trips.length === 0) continue;
const entries: LogEntry[] = [];
for (const s of trips) {
if (s.deadhead) {
entries.push({
kind: "deadhead",
dispatchId: s.dispatchId,
originId: s.originId,
destinationId: s.destinationId,
fuelCost: s.fuelCost,
});
} else {
entries.push({
kind: "job",
dispatchId: s.dispatchId,
good: (s.goodId && content.good(s.goodId)?.name) || "Cargo",
originId: s.originId,
destinationId: s.destinationId,
payout: s.credits + s.pilotPay + s.loanRepayment,
pilotShare: s.pilotPay,
fuelCost: s.fuelCost,
xp: s.pilotXp,
});
}
if (s.restEndsAt !== null) {
entries.push({ kind: "rest", dispatchId: s.dispatchId, until: s.restEndsAt });
}
}
pilots.push({
pilotId: pilot.id,
name: pilot.name,
entries,
xp: trips.reduce((sum, s) => sum + s.pilotXp, 0),
now:
independentStatus(state, pilot) ??
(pilot.locationId ? `At ${locationName(pilot.locationId)}` : "In flight"),
});
}
const all = pilots.flatMap((p) => p.entries);
const jobs = all.filter((e) => e.kind === "job");
const deadheads = all.filter((e) => e.kind === "deadhead");
return {
totals: {
jobs: jobs.length,
deadheads: deadheads.length,
payout: jobs.reduce((sum, e) => sum + e.payout, 0),
pilotShare: jobs.reduce((sum, e) => sum + e.pilotShare, 0),
fuelCost: [...jobs, ...deadheads].reduce((sum, e) => sum + e.fuelCost, 0),
xp: pilots.reduce((sum, p) => sum + p.xp, 0),
},
pilots,
};
}
/** The pilot logbook on the "while you were away" sheet. */
export function PilotLogbook({ logbook }: { logbook: Logbook }) {
if (logbook.pilots.length === 0) return null;
const { totals } = logbook;
return (
<section aria-label="Pilot logbook" className="mt-4 space-y-3 text-sm">
<div
aria-label="Company totals"
className="grid grid-cols-2 gap-x-3 gap-y-1 rounded-xl border border-cyan-400/40 bg-cyan-950/30 p-3 sm:grid-cols-3"
>
<span>{totals.jobs} jobs</span>
<span>{totals.deadheads} deadheads</span>
<span className="text-emerald-300">{formatCredits(totals.payout)} paid</span>
<span className="text-ink-300">{formatCredits(totals.pilotShare)} pilot shares</span>
<span className="text-amber-200">{formatCredits(totals.fuelCost)} fuel</span>
<span className="text-violet-300">+{totals.xp} XP</span>
</div>
{logbook.pilots.map((pilot) => (
<details
key={pilot.pilotId}
open={logbook.pilots.length === 1}
className="rounded-xl border border-space-600 bg-space-950/50 p-3"
>
<summary className="flex cursor-pointer items-baseline justify-between gap-2">
<span className="font-display font-bold tracking-wide">{pilot.name}</span>
<span className="text-xs text-violet-300">+{pilot.xp} XP</span>
</summary>
<p aria-label={`${pilot.name} now`} className="mt-1 text-cyan-100">
{pilot.now}
</p>
<ul aria-label={`${pilot.name} log`} className="mt-2 space-y-1 text-ink-200">
{pilot.entries.map((e) => (
<li key={`${e.kind}:${e.dispatchId}`} className="flex flex-wrap gap-x-2">
{e.kind === "job" && (
<>
<span className="font-semibold">{e.good}</span>
<span className="text-ink-400">
{locationName(e.originId)} → {locationName(e.destinationId)}
</span>
<span className="text-emerald-300">{formatCredits(e.payout)}</span>
<span className="text-ink-400">share {formatCredits(e.pilotShare)}</span>
<span className="text-amber-200/90">fuel {formatCredits(e.fuelCost)}</span>
</>
)}
{e.kind === "deadhead" && (
<>
<Icon name="send" className="size-4 text-cyan-300" />
<span>
Deadhead {locationName(e.originId)} → {locationName(e.destinationId)}
</span>
<span className="text-amber-200/90">fuel {formatCredits(e.fuelCost)}</span>
</>
)}
{e.kind === "rest" && (
<span className="flex items-center gap-1 text-sky-300">
<Icon name="rest" className="size-4" />
Rested until {formatLocalTime(e.until)}
</span>
)}
</li>
))}
</ul>
</details>
))}
</section>
);
}
/**
* "Catching up on your fleet…" (CR-021): shown only when a catch-up has been
* running for more than `delayMs`, so quick passes never flash it.
*/
export function CatchingUp({ busy, delayMs = 500 }: { busy: boolean; delayMs?: number }) {
const [shown, setShown] = useState(false);
useEffect(() => {
if (!busy) {
setShown(false);
return;
}
const timer = setTimeout(() => setShown(true), delayMs);
return () => clearTimeout(timer);
}, [busy, delayMs]);
if (!shown) return null;
return (
<p
role="status"
className="fixed top-3 left-1/2 z-40 -translate-x-1/2 rounded-full border border-cyan-400/50 bg-space-900/95 px-4 py-1.5 font-display text-sm text-cyan-100 shadow-panel"
>
Catching up on your fleet…
</p>
);
}

View File

@ -4,6 +4,7 @@ import {
IndexedDbBackend, IndexedDbBackend,
MemoryBackend, MemoryBackend,
NotificationSync, NotificationSync,
RESOLUTION_INTERVAL_MS,
ResolutionLoop, ResolutionLoop,
SaveStorage, SaveStorage,
TabLock, TabLock,
@ -88,8 +89,18 @@ export const browserLoopHost: LoopHost = {
}, },
}; };
export function startResolutionLoop(game: Game, onChange: () => void): ResolutionLoop { export function startResolutionLoop(
const loop = new ResolutionLoop(game.engine, browserLoopHost, onChange); game: Game,
onChange: () => void,
onBusy?: (busy: boolean) => void,
): ResolutionLoop {
const loop = new ResolutionLoop(
game.engine,
browserLoopHost,
onChange,
RESOLUTION_INTERVAL_MS,
onBusy,
);
void loop.start(); void loop.start();
return loop; return loop;
} }

View File

@ -143,7 +143,11 @@ function LocationBoard({
onSelect?: (contract: Contract) => void; onSelect?: (contract: Contract) => void;
}) { }) {
const api = useApi(); const api = useApi();
const state = useLoadedPlayer();
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
// Jobs are the player's alone for the first minutes of a board (decision 0.5.126).
const pilotsOnTheirOwn = state.pilots.some((p) => p.mode === "independent");
const firstLookMs = content.config.independentPilots.firstLookRealMinutes * 60_000;
const board = useQuery({ const board = useQuery({
queryKey: locationBoardKey(locationId), queryKey: locationBoardKey(locationId),
queryFn: () => api.call("getContractBoard", { locationId }), queryFn: () => api.call("getContractBoard", { locationId }),
@ -245,6 +249,20 @@ function LocationBoard({
First contact First contact
</Pill> </Pill>
)} )}
{pilotsOnTheirOwn &&
!contract.firstContact &&
now < contract.offeredAt + firstLookMs && (
<Pill tone="amber" icon="clock">
<span aria-label="Yours for">
Yours for{" "}
<Countdown
to={contract.offeredAt + firstLookMs}
format={formatClock}
done="now"
/>
</span>
</Pill>
)}
</div> </div>
</div> </div>
<RouteStrip <RouteStrip

View File

@ -34,6 +34,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
tutorial: false, tutorial: false,
credits: 538, credits: 538,
deadhead: false, deadhead: false,
independent: false,
fuelCost: 0, fuelCost: 0,
topUp: null, topUp: null,
goodId: "consumer-goods", goodId: "consumer-goods",

View File

@ -26,6 +26,7 @@ const summaries: ResolutionSummary[] = [
tutorial: false, tutorial: false,
credits: 51_200, credits: 51_200,
deadhead: false, deadhead: false,
independent: false,
fuelCost: 0, fuelCost: 0,
topUp: null, topUp: null,
goodId: "consumer-goods", goodId: "consumer-goods",
@ -48,6 +49,7 @@ const summaries: ResolutionSummary[] = [
tutorial: true, tutorial: true,
credits: 240, credits: 240,
deadhead: false, deadhead: false,
independent: false,
fuelCost: 0, fuelCost: 0,
topUp: null, topUp: null,
goodId: "consumer-goods", goodId: "consumer-goods",
@ -91,6 +93,7 @@ describe("while you were away", () => {
dispatchId: "d3", dispatchId: "d3",
destinationId: "moon", destinationId: "moon",
deadhead: true, deadhead: true,
independent: false,
fuelCost: 230, fuelCost: 230,
topUp: null, topUp: null,
goodId: null, goodId: null,

View File

@ -0,0 +1,239 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, PlayerState, ResolutionSummary } from "@deadhead/schema";
import { act, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { CatchingUp, pilotLogbook } from "../src/components/PilotLogbook";
import { formatCredits } from "../src/format";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const PERIOD_START = Date.parse("2100-01-01T00:00:00Z");
const FIRST_LOOK = content.config.independentPilots.firstLookRealMinutes * 60_000;
const job = (over: Partial<Contract> = {}): Contract => ({
id: "earth:1:0",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 6,
payout: 1_234,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: PERIOD_START,
expiresAt: PERIOD_START + 600_000,
firstContact: false,
...over,
});
const pilot = (id: string, name: string, over: Partial<Pilot> = {}): Pilot => ({
id,
name,
isAvatar: false,
isVeteran: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: `ship-${id}`,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
...pilotExtras,
mode: "independent",
...over,
});
function owner(pilots: Pilot[]): PlayerState {
const base = samplePlayerState({ tutorial: null });
return {
...base,
bays: [{ id: "b", locationId: "earth" }],
pilots: [base.pilots[0]!, ...pilots],
ships: pilots.map((p) => ({
id: `ship-${p.id}`,
modelId: "courier",
name: `Ship ${p.id}`,
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: p.id,
})),
dispatchOffice: { owned: true, spendingFloor: 500 },
};
}
describe("first-look chip (Step 21.7, decision 0.5.126)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(PERIOD_START + 29_000);
useClock.setState({ offsetMs: 0, synced: true, now: PERIOD_START + 29_000 });
});
afterEach(() => vi.useRealTimers());
const show = (state: PlayerState, contracts: Contract[]) =>
renderApp(
fakeApi({
getPlayerState: () => state,
getContractBoard: () => ({ contracts, periodEndsAt: PERIOD_START + 600_000 }),
}),
"/contracts?location=earth",
);
it("counts down the player's first look, then disappears", async () => {
show(owner([pilot("p1", "Ada")]), [job()]);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
expect(within(card).getByLabelText("Yours for")).toHaveTextContent("Yours for 02:31");
act(() => {
vi.setSystemTime(PERIOD_START + FIRST_LOOK + 1_000);
useClock.setState({ now: PERIOD_START + FIRST_LOOK + 1_000 });
});
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
});
it("isn't shown without Independent pilots", async () => {
show(owner([pilot("p1", "Ada", { mode: "manual" })]), [job()]);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
});
it("isn't shown on a first contact, which is always the player's", async () => {
show(owner([pilot("p1", "Ada")]), [job({ firstContact: true, destinationId: "mars" })]);
const card = (await screen.findByText("1,234 cr")).closest("li")!;
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
});
});
describe("catching up indicator (Step 21.7)", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("appears only when a catch-up runs longer than half a second", () => {
const { rerender } = render(<CatchingUp busy />);
act(() => vi.advanceTimersByTime(400));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
act(() => vi.advanceTimersByTime(200));
expect(screen.getByRole("status")).toHaveTextContent("Catching up on your fleet…");
rerender(<CatchingUp busy={false} />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
// A quick pass never shows it.
rerender(<CatchingUp busy />);
act(() => vi.advanceTimersByTime(300));
rerender(<CatchingUp busy={false} />);
act(() => vi.advanceTimersByTime(1_000));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});
describe("pilot logbook (Step 21.7)", () => {
const summary = (over: Partial<ResolutionSummary>): ResolutionSummary => ({
dispatchId: "d",
shipId: "ship-p1",
pilotId: "p1",
originId: "earth",
destinationId: "moon",
tutorial: false,
independent: true,
deadhead: false,
fuelCost: 100,
topUp: null,
goodId: "consumer-goods",
massTonnes: 6,
shipper: "Pacific Orbital Logistics",
credits: 600,
loanRepayment: 200,
pilotPay: 200,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
restEndsAt: null,
resolvedAt: PERIOD_START,
...over,
});
const ada = pilot("p1", "Ada");
const bo = pilot("p2", "Bo", { locationId: "moon" });
const summaries = [
summary({ dispatchId: "a1", resolvedAt: 1 }),
summary({
dispatchId: "a2",
deadhead: true,
goodId: null,
massTonnes: null,
shipper: null,
credits: 0,
loanRepayment: 0,
pilotPay: 0,
pilotXp: 0,
fuelCost: 40,
resolvedAt: 2,
}),
summary({
dispatchId: "b1",
pilotId: "p2",
shipId: "ship-p2",
destinationId: "earth",
originId: "moon",
credits: 900,
pilotPay: 300,
loanRepayment: 0,
pilotXp: 12,
restEndsAt: PERIOD_START + 3_600_000,
resolvedAt: 3,
}),
// The player's own delivery isn't in the pilots' logbook.
summary({ dispatchId: "you", pilotId: "avatar", independent: false, resolvedAt: 4 }),
];
it("lists each pilot's jobs, deadheads, rests and XP, and where they are now", () => {
const book = pilotLogbook(owner([ada, bo]), summaries);
expect(book.pilots.map((p) => p.name)).toEqual(["Ada", "Bo"]);
const [a, b] = book.pilots;
expect(a!.entries.map((e) => e.kind)).toEqual(["job", "deadhead"]);
expect(a!.entries[0]).toMatchObject({ good: "Consumer Goods", payout: 1_000, pilotShare: 200 });
expect(a!.xp).toBe(10);
expect(a!.now).toBe("Waiting for new contracts at Earth");
expect(b!.entries.map((e) => e.kind)).toEqual(["job", "rest"]);
expect(b!.now).toBe("Waiting for new contracts at Moon");
});
it("totals the entries for the company", () => {
const book = pilotLogbook(owner([ada, bo]), summaries);
const entries = book.pilots.flatMap((p) => p.entries);
const jobs = entries.filter((e) => e.kind === "job");
expect(book.totals).toEqual({
jobs: 2,
deadheads: 1,
payout: jobs.reduce((s, e) => s + (e.kind === "job" ? e.payout : 0), 0),
pilotShare: 500,
fuelCost: 100 + 40 + 100,
xp: 22,
});
expect(book.totals.payout).toBe(1_000 + 1_200);
});
it("shows on the while-you-were-away sheet, with the player's own trip as a card", async () => {
const state = { ...owner([ada, bo]), summaries };
renderApp(fakeApi({ getPlayerState: () => state }), "/");
const book = await screen.findByLabelText("Pilot logbook");
expect(within(book).getByLabelText("Company totals")).toHaveTextContent(
`2 jobs1 deadheads${formatCredits(2_200)} paid`,
);
expect(within(book).getByLabelText("Ada log")).toHaveTextContent("Deadhead Earth → Moon");
expect(within(book).getByLabelText("Bo log")).toHaveTextContent("Rested until");
// One card for the player's own delivery, outside the logbook.
const sheet = screen.getByRole("dialog", { name: "While you were away" });
expect(within(sheet).getAllByText("Earth → Moon").length).toBeGreaterThan(0);
});
});

View File

@ -152,6 +152,7 @@ describe("loan on HQ", () => {
tutorial: false, tutorial: false,
credits: 538, credits: 538,
deadhead: false, deadhead: false,
independent: false,
fuelCost: 0, fuelCost: 0,
topUp: null, topUp: null,
goodId: "consumer-goods", goodId: "consumer-goods",

View File

@ -102,6 +102,7 @@ describe("the deal", () => {
tutorial: true, tutorial: true,
credits: 96, credits: 96,
deadhead: false, deadhead: false,
independent: false,
fuelCost: 0, fuelCost: 0,
topUp: null, topUp: null,
goodId: "consumer-goods", goodId: "consumer-goods",

View File

@ -65,6 +65,7 @@ const summary: ResolutionSummary = {
originId: "earth", originId: "earth",
destinationId: "moon", destinationId: "moon",
tutorial: false, tutorial: false,
independent: false,
deadhead: false, deadhead: false,
fuelCost: 150, fuelCost: 150,
topUp: null, topUp: null,

View File

@ -37,6 +37,7 @@ const summary = (extra: Partial<ResolutionSummary>): ResolutionSummary => ({
tutorial: false, tutorial: false,
credits: 500, credits: 500,
deadhead: false, deadhead: false,
independent: false,
fuelCost: 0, fuelCost: 0,
topUp: null, topUp: null,
goodId: "consumer-goods", goodId: "consumer-goods",

View File

@ -143,6 +143,7 @@ describe("scripted delivery summary", () => {
tutorial: true, tutorial: true,
credits: 461, credits: 461,
deadhead: false, deadhead: false,
independent: false,
fuelCost: 0, fuelCost: 0,
topUp: null, topUp: null,
goodId: "consumer-goods", goodId: "consumer-goods",

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@ -474,6 +474,11 @@ Spec: `memory-bank/change-requests/CR-021-independent-pilots.md` (received as "C
- **`IndependentBadge`** on Fleet cards, beside the pilot's name. - **`IndependentBadge`** on Fleet cards, beside the pilot's name.
Screenshots: `docs/screenshots/21-6-dispatch-office-desktop.png`, `21-6-dispatch-office-owned-phone.png`, `21-6-pilot-orders-phone.png`, `21-6-fleet-badge-phone.png`. Tests (`apps/web/test/independent-controls.test.tsx`): the toggle disabled with its reason before the office and absent for the avatar; Independent sent to the engine once bought; edited orders saved; each status line; the badge only on an Independent crew; buying the office on the Bridge and saving the spending floor. Screenshots: `docs/screenshots/21-6-dispatch-office-desktop.png`, `21-6-dispatch-office-owned-phone.png`, `21-6-pilot-orders-phone.png`, `21-6-fleet-badge-phone.png`. Tests (`apps/web/test/independent-controls.test.tsx`): the toggle disabled with its reason before the office and absent for the avatar; Independent sent to the engine once bought; edited orders saved; each status line; the badge only on an Independent crew; buying the office on the Bridge and saving the spending floor.
- [ ] Step 21.7 — Client: board and logbook - [x] **Step 21.7 — Client: board and logbook.**
- **First-look chip.** Contract cards show "Yours for mm:ss" until the first-look window ends. It appears only when some pilot is Independent, and never on first contacts.
- **Catch-up indicator.** "Catching up on your fleet…" (`CatchingUp` in `apps/web/src/components/PilotLogbook.tsx`) appears when the loop's pass runs longer than 0.5 s. `ResolutionLoop` takes an `onBusy` callback.
- **Pilot logbook.** The API summary carries `independent`. On the "While you were away" sheet, `pilotLogbook` groups Independent trips by pilot: jobs (good, route, payout, share, fuel), deadheads, rests, XP, and what the pilot is doing now. Company totals sit at the top. The player's own trips stay as cards.
Screenshots: `docs/screenshots/21-7-logbook-phone.png`, `21-7-logbook-desktop.png` (Rosa, an hour away: 11–12 jobs, two rests), `21-7-first-look-desktop.png`. Tests (`apps/web/test/board-logbook.test.tsx`): the chip counts down from 02:31 and disappears after the window; no chip without Independent pilots or on a first contact; the indicator only after 0.5 s and never for a quick pass; the logbook's entries, XP and current status per pilot; totals that are the sums of the entries; the sheet shows the logbook and the player's own trip as a card. Summary fixtures gained `independent`.
- [ ] Step 21.8 — Balance - [ ] Step 21.8 — Balance
- [ ] Step 21.9 — Plan amendments and regression - [ ] Step 21.9 — Plan amendments and regression

View File

@ -258,6 +258,7 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState {
originId: d.record.originId, originId: d.record.originId,
destinationId: d.record.destinationId, destinationId: d.record.destinationId,
tutorial: d.tutorial, tutorial: d.tutorial,
independent: d.independent,
deadhead: d.record.contract === null, deadhead: d.record.contract === null,
fuelCost: d.record.fuelCost, fuelCost: d.record.fuelCost,
topUp: d.resolution?.topUp topUp: d.resolution?.topUp

View File

@ -32,6 +32,8 @@ export class ResolutionLoop {
private readonly host: LoopHost, private readonly host: LoopHost,
private readonly onChange: () => void, private readonly onChange: () => void,
private readonly intervalMs = RESOLUTION_INTERVAL_MS, private readonly intervalMs = RESOLUTION_INTERVAL_MS,
/** True while a pass runs, false when it ends (CR-021: "Catching up on your fleet…"). */
private readonly onBusy?: (busy: boolean) => void,
) {} ) {}
/** Resolves once now, then keeps going until `stop`. */ /** Resolves once now, then keeps going until `stop`. */
@ -52,6 +54,7 @@ export class ResolutionLoop {
/** One pass (ticks never overlap: a tick that is still running is reused). */ /** One pass (ticks never overlap: a tick that is still running is reused). */
tick(): Promise<void> { tick(): Promise<void> {
this.#running ??= (async () => { this.#running ??= (async () => {
this.onBusy?.(true);
try { try {
const result = await this.engine.resolveDue(this.#since); const result = await this.engine.resolveDue(this.#since);
this.#since = result.now; this.#since = result.now;
@ -60,6 +63,7 @@ export class ResolutionLoop {
} }
} finally { } finally {
this.#running = null; this.#running = null;
this.onBusy?.(false);
} }
})(); })();
return this.#running; return this.#running;

View File

@ -193,6 +193,8 @@ export const resolutionSummarySchema = z.object({
originId: idSchema, originId: idSchema,
destinationId: idSchema, destinationId: idSchema,
tutorial: z.boolean(), tutorial: z.boolean(),
/** Flown by an Independent pilot's own choice (CR-021): it goes in the pilot logbook. */
independent: z.boolean(),
/** An empty reposition flight: no cargo, no pay (CR-002). */ /** An empty reposition flight: no cargo, no pay (CR-002). */
deadhead: z.boolean(), deadhead: z.boolean(),
/** The fuel used, at the tank's average price (decision 0.5.98). */ /** The fuel used, at the tank's average price (decision 0.5.98). */

View File

@ -145,6 +145,7 @@ const summary = {
tutorial: true, tutorial: true,
credits: 240, credits: 240,
deadhead: false, deadhead: false,
independent: false,
fuelCost: 0, fuelCost: 0,
topUp: null, topUp: null,
goodId: "consumer-goods", goodId: "consumer-goods",