Step 21.7: client: board and logbook
CI / check (push) Has been cancelled
Details
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:
parent
956a621b12
commit
a75a39e01f
|
|
@ -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.
|
||||
- **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".
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { createEngineClient } from "./api/engine-client";
|
||||
import { ApiProvider } from "./api/context";
|
||||
import { CatchingUp } from "./components/PilotLogbook";
|
||||
import { GameContext } from "./platform/context";
|
||||
import { startResolutionLoop, type Game } from "./platform/game";
|
||||
import { PLAYER_STATE_KEY } from "./state/player";
|
||||
|
|
@ -26,6 +27,8 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode }
|
|||
const queryClient = useQueryClient();
|
||||
const api = useMemo(() => createEngineClient(game.engine), [game]);
|
||||
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(() => {
|
||||
game.lock.onLost(() => setStatus("elsewhere"));
|
||||
|
|
@ -40,10 +43,14 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode }
|
|||
|
||||
useEffect(() => {
|
||||
if (status !== "here") return;
|
||||
const loop = startResolutionLoop(game, () => {
|
||||
const loop = startResolutionLoop(
|
||||
game,
|
||||
() => {
|
||||
void queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
|
||||
void queryClient.invalidateQueries({ queryKey: ["board"] });
|
||||
});
|
||||
},
|
||||
setCatchingUp,
|
||||
);
|
||||
if (game.devTools) {
|
||||
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 (
|
||||
<GameContext.Provider value={game}>
|
||||
<ApiProvider api={api}>{children}</ApiProvider>
|
||||
<CatchingUp busy={catchingUp} />
|
||||
</GameContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { formatCredits, locationName, toLocation } from "../format";
|
|||
import { formatLocalTime } from "../screens/DispatchSheet";
|
||||
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
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). */
|
||||
export function AwaySummary() {
|
||||
|
|
@ -39,10 +40,15 @@ export function AwaySummary() {
|
|||
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 (
|
||||
<Sheet titleId="away-title" eyebrow="Logbook" title="While you were away">
|
||||
<PilotLogbook logbook={logbook} />
|
||||
<ul className="mt-4 space-y-3">
|
||||
{summaries.map((s) => (
|
||||
{yours.map((s) => (
|
||||
<li
|
||||
key={s.dispatchId}
|
||||
className="rounded-xl border border-space-600 bg-space-950/50 p-3 text-sm"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
IndexedDbBackend,
|
||||
MemoryBackend,
|
||||
NotificationSync,
|
||||
RESOLUTION_INTERVAL_MS,
|
||||
ResolutionLoop,
|
||||
SaveStorage,
|
||||
TabLock,
|
||||
|
|
@ -88,8 +89,18 @@ export const browserLoopHost: LoopHost = {
|
|||
},
|
||||
};
|
||||
|
||||
export function startResolutionLoop(game: Game, onChange: () => void): ResolutionLoop {
|
||||
const loop = new ResolutionLoop(game.engine, browserLoopHost, onChange);
|
||||
export function startResolutionLoop(
|
||||
game: Game,
|
||||
onChange: () => void,
|
||||
onBusy?: (busy: boolean) => void,
|
||||
): ResolutionLoop {
|
||||
const loop = new ResolutionLoop(
|
||||
game.engine,
|
||||
browserLoopHost,
|
||||
onChange,
|
||||
RESOLUTION_INTERVAL_MS,
|
||||
onBusy,
|
||||
);
|
||||
void loop.start();
|
||||
return loop;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,7 +143,11 @@ function LocationBoard({
|
|||
onSelect?: (contract: Contract) => void;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const state = useLoadedPlayer();
|
||||
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({
|
||||
queryKey: locationBoardKey(locationId),
|
||||
queryFn: () => api.call("getContractBoard", { locationId }),
|
||||
|
|
@ -245,6 +249,20 @@ function LocationBoard({
|
|||
First contact
|
||||
</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>
|
||||
<RouteStrip
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
|
|||
tutorial: false,
|
||||
credits: 538,
|
||||
deadhead: false,
|
||||
independent: false,
|
||||
fuelCost: 0,
|
||||
topUp: null,
|
||||
goodId: "consumer-goods",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const summaries: ResolutionSummary[] = [
|
|||
tutorial: false,
|
||||
credits: 51_200,
|
||||
deadhead: false,
|
||||
independent: false,
|
||||
fuelCost: 0,
|
||||
topUp: null,
|
||||
goodId: "consumer-goods",
|
||||
|
|
@ -48,6 +49,7 @@ const summaries: ResolutionSummary[] = [
|
|||
tutorial: true,
|
||||
credits: 240,
|
||||
deadhead: false,
|
||||
independent: false,
|
||||
fuelCost: 0,
|
||||
topUp: null,
|
||||
goodId: "consumer-goods",
|
||||
|
|
@ -91,6 +93,7 @@ describe("while you were away", () => {
|
|||
dispatchId: "d3",
|
||||
destinationId: "moon",
|
||||
deadhead: true,
|
||||
independent: false,
|
||||
fuelCost: 230,
|
||||
topUp: null,
|
||||
goodId: null,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -152,6 +152,7 @@ describe("loan on HQ", () => {
|
|||
tutorial: false,
|
||||
credits: 538,
|
||||
deadhead: false,
|
||||
independent: false,
|
||||
fuelCost: 0,
|
||||
topUp: null,
|
||||
goodId: "consumer-goods",
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ describe("the deal", () => {
|
|||
tutorial: true,
|
||||
credits: 96,
|
||||
deadhead: false,
|
||||
independent: false,
|
||||
fuelCost: 0,
|
||||
topUp: null,
|
||||
goodId: "consumer-goods",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const summary: ResolutionSummary = {
|
|||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
tutorial: false,
|
||||
independent: false,
|
||||
deadhead: false,
|
||||
fuelCost: 150,
|
||||
topUp: null,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const summary = (extra: Partial<ResolutionSummary>): ResolutionSummary => ({
|
|||
tutorial: false,
|
||||
credits: 500,
|
||||
deadhead: false,
|
||||
independent: false,
|
||||
fuelCost: 0,
|
||||
topUp: null,
|
||||
goodId: "consumer-goods",
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ describe("scripted delivery summary", () => {
|
|||
tutorial: true,
|
||||
credits: 461,
|
||||
deadhead: false,
|
||||
independent: false,
|
||||
fuelCost: 0,
|
||||
topUp: null,
|
||||
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 |
|
|
@ -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.
|
||||
|
||||
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.9 — Plan amendments and regression
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState {
|
|||
originId: d.record.originId,
|
||||
destinationId: d.record.destinationId,
|
||||
tutorial: d.tutorial,
|
||||
independent: d.independent,
|
||||
deadhead: d.record.contract === null,
|
||||
fuelCost: d.record.fuelCost,
|
||||
topUp: d.resolution?.topUp
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export class ResolutionLoop {
|
|||
private readonly host: LoopHost,
|
||||
private readonly onChange: () => void,
|
||||
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`. */
|
||||
|
|
@ -52,6 +54,7 @@ export class ResolutionLoop {
|
|||
/** One pass (ticks never overlap: a tick that is still running is reused). */
|
||||
tick(): Promise<void> {
|
||||
this.#running ??= (async () => {
|
||||
this.onBusy?.(true);
|
||||
try {
|
||||
const result = await this.engine.resolveDue(this.#since);
|
||||
this.#since = result.now;
|
||||
|
|
@ -60,6 +63,7 @@ export class ResolutionLoop {
|
|||
}
|
||||
} finally {
|
||||
this.#running = null;
|
||||
this.onBusy?.(false);
|
||||
}
|
||||
})();
|
||||
return this.#running;
|
||||
|
|
|
|||
|
|
@ -193,6 +193,8 @@ export const resolutionSummarySchema = z.object({
|
|||
originId: idSchema,
|
||||
destinationId: idSchema,
|
||||
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). */
|
||||
deadhead: z.boolean(),
|
||||
/** The fuel used, at the tank's average price (decision 0.5.98). */
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ const summary = {
|
|||
tutorial: true,
|
||||
credits: 240,
|
||||
deadhead: false,
|
||||
independent: false,
|
||||
fuelCost: 0,
|
||||
topUp: null,
|
||||
goodId: "consumer-goods",
|
||||
|
|
|
|||
Loading…
Reference in New Issue