Step 25.3: client: highlights pop-up
CI / check (push) Successful in 7m44s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-16 22:53:22 -04:00
parent c7044ad2d1
commit 32b34a5d6c
31 changed files with 907 additions and 368 deletions

View File

@ -1873,3 +1873,14 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"Events" for a short absence** are flights (deliveries and deadheads), discoveries, level-ups and finished upgrades. The pop-up also opens for a deadhead or a finished upgrade alone, and it should then list it. Refuels, rests and goals aren't events. - **"Events" for a short absence** are flights (deliveries and deadheads), discoveries, level-ups and finished upgrades. The pop-up also opens for a deadhead or a finished upgrade alone, and it should then list it. Refuels, rests and goals aren't events.
- **Each discovery, level-up, goal and scripted delivery is its own highlight**, in time order within its rank; the biggest job is one highlight (the highest payout, the earliest on a tie); each problem is one. - **Each discovery, level-up, goal and scripted delivery is its own highlight**, in time order within its rank; the biggest job is one highlight (the highest payout, the earliest on a tie); each problem is one.
- **Pilot lines count flights**: a pilot who only deadheaded is active (0 jobs, a negative net). Ties in net go by name. - **Pilot lines count flights**: a pilot who only deadheaded is active (0 jobs, a negative net). Ties in net go by name.
## Step 25.3
- **The client builds the summary** from `getLogbook` with the sim's pure builder: wording stays in content and the client, and "where a pilot is now" comes from the player state the client already has. The engine finds the problems, which need its boards and fuel rules.
- **"Fits one screen" is checked in a browser**, not jsdom, which has no layout: the e2e test opens the pop-up at 375×667 with 8 active pilots (more than the 5 lines) and checks the sheet doesn't scroll. With at most 3 highlights and 5 lines, more pilots don't make it longer. The component test checks the caps with 50 pilots.
- **Ready goals are a highlight now**, without the old "Claim on the Bridge" button: the pop-up has two buttons (View logbook, Done), and the Bridge's goals card has its badge.
- **"No reachable jobs" uses the board stored for this period**: if the player hasn't opened that board, there's nothing to judge, and no problem is shown. Independent pilots' ships are left out: they wait or head home on their own.
- **A short absence doesn't repeat its events as highlights.** Discoveries, level-ups and the biggest job are already in its list, so the builder drops them before taking 3, and "+N more" counts only what isn't shown.
- **One level-up highlight per pilot**, at the highest level reached: a pilot who went from 1 to 3 reads "reached level 3".
- **View logbook also closes the pop-up**, so the absence it showed becomes `lastAbsence`, which is what the Logbook's "This absence" shows (Step 25.4).
- **Seen while building it:** pilot names are drawn without checking the roster, so two hired pilots can share a name (two "Dmitri Brennan" in the screenshot run). That's older than this CR and isn't changed here.

View File

@ -29,7 +29,8 @@ test.describe("end to end (Phase 12)", () => {
const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!; const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!;
await advanceTo(page, flight.arrivalAt); await advanceTo(page, flight.arrivalAt);
await page.reload(); await page.reload();
await expect(logbook(page)).toContainText("went to the lender"); // The net, after the lender's cut (CR-025); the cut itself is in the Logbook.
await expect(logbook(page)).toContainText(/delivered .+ to Moon, \+[\d,]+ cr/);
await dismissLogbook(page); await dismissLogbook(page);
const after = await state(page); const after = await state(page);

View File

@ -1,3 +1,4 @@
import { content } from "@deadhead/content";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { dispatchFromEarthBoard, logbook, playToDeal, serverNow, state } from "./support"; import { dispatchFromEarthBoard, logbook, playToDeal, serverNow, state } from "./support";
@ -26,7 +27,8 @@ test.describe("end to end (Phase 12)", () => {
await later.goto("/"); await later.goto("/");
const summary = (await state(later)).summaries.find((s) => s.dispatchId === dispatchId)!; const summary = (await state(later)).summaries.find((s) => s.dispatchId === dispatchId)!;
await expect(logbook(later)).toContainText(`Delivered ${summary.massTonnes} t`); const good = content.good(summary.goodId!)!.name;
await expect(logbook(later)).toContainText(`delivered ${good} to`);
const after = await state(later); const after = await state(later);
// Credits went up by the delivery's net payout, less any Auto top-up on landing // Credits went up by the delivery's net payout, less any Auto top-up on landing
// (CR-015); the ship is at the destination. // (CR-015); the ship is at the destination.

View File

@ -60,7 +60,7 @@ test.describe("end to end (Phase 12)", () => {
expect(flight, "the Clipper and veteran can take it").not.toBeNull(); expect(flight, "the Clipper and veteran can take it").not.toBeNull();
await advanceTo(page, flight!.arrivalAt); await advanceTo(page, flight!.arrivalAt);
await page.goto("/"); await page.goto("/");
await expect(logbook(page)).toContainText("Discovered Mars!"); await expect(logbook(page)).toContainText("opened a new market: Mars");
await dismissLogbook(page); await dismissLogbook(page);
// Mars is discovered on the Locations screen and can be leased; its board is open without a bay (CR-002). // Mars is discovered on the Locations screen and can be leased; its board is open without a bay (CR-002).

View File

@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test";
import type { PlayerState } from "@deadhead/schema";
import { addCredits, advance, logbook, playToDeal, run, state } from "./support";
const HOUR = 3_600_000;
const PILOTS = 7;
test.describe("the highlights pop-up (CR-025 Step 25.3)", () => {
test("fits one phone screen with more pilots than it lists", async ({ page }) => {
test.setTimeout(180_000);
await page.setViewportSize({ width: 375, height: 667 });
const owner = await playToDeal(page);
await addCredits(page, 2_000_000);
await run(page, "buyDispatchOffice");
const bayId = owner.bays[0]!.id;
for (let i = 0; i < PILOTS; i++) {
const { ship } = await run<{ ship: { id: string } }>(page, "buyShip", {
modelId: "courier",
bayId,
});
const { pilot } = await run<{ pilot: { id: string } }>(page, "hirePilot", {
locationId: "earth",
});
await run(page, "assignPilot", { pilotId: pilot.id, shipId: ship.id });
await run(page, "setPilotMode", { pilotId: pilot.id, mode: "independent" });
}
const rosa = (await state(page)).pilots.find(
(p: PlayerState["pilots"][number]) => p.isVeteran,
)!;
await run(page, "setPilotMode", { pilotId: rosa.id, mode: "independent" });
for (let h = 0; h < 4; h++) await advance(page, HOUR);
await page.goto("/");
const sheet = logbook(page);
const pilots = sheet.getByRole("list", { name: "Pilots" });
await expect(pilots).toBeVisible();
await expect(pilots.getByText(/more pilots:/)).toBeVisible();
// Everything fits: the sheet doesn't scroll, and the buttons are on screen.
const fits = await sheet.evaluate((el) => el.scrollHeight <= el.clientHeight);
expect(fits).toBe(true);
await expect(sheet.getByRole("button", { name: "Done" })).toBeInViewport();
});
});

View File

@ -1,22 +1,184 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { formatFu } from "./Fuel"; import type { LogEntry, Pilot, PlayerState } from "@deadhead/schema";
import {
buildAwaySummary,
flightNet,
type AwayHighlight,
type AwayProblem,
type AwaySummary as Summary,
} from "@deadhead/sim";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { formatCredits, locationName, toLocation } from "../format"; import { fill, formatCredits, locationName } from "../format";
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 { SleepBanner } from "./FleetSleep"; import { SleepBanner } from "./FleetSleep";
import { PilotLogbook, pilotLogbook } from "./PilotLogbook";
/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */ const words = content.awayWording;
export const logbookKey = ["logbook"] as const;
/** An absence's length: "2 d 3 h", "9 h 40 m", "12 m". */
export function formatAbsence(ms: number): string {
const minutes = Math.max(1, Math.round(ms / 60_000));
const days = Math.floor(minutes / 1440);
const hours = Math.floor((minutes % 1440) / 60);
const mins = minutes % 60;
if (days > 0) return `${days} d ${hours} h`;
if (hours > 0) return `${hours} h ${mins} m`;
return `${mins} m`;
}
/** Credits with their sign: "+12,300 cr", "−410 cr". */
export function signedCredits(credits: number): string {
return credits < 0 ? `−${formatCredits(-credits)}` : `+${formatCredits(credits)}`;
}
const plural = (count: number, one: string, many = `${one}s`) =>
`${count} ${count === 1 ? one : many}`;
/** Where a pilot is now, in a few words (a pilot line's last part). */
export function pilotNow(state: PlayerState, pilot: Pilot): string {
const flight = state.activeDispatches.find((d) => d.pilotId === pilot.id);
if (flight) return `Flying to ${locationName(flight.destinationId)}`;
const place = locationName(pilot.locationId ?? "");
if (pilot.restingInShopUntil !== null) return `In the shop at ${place}`;
if (pilot.resting) return `Resting at ${place}`;
if (pilot.mode === "independent" && state.fleetSleep.asleep) return "Waiting for orders";
return `At ${place}`;
}
/** Names for ids, from the state and content. */
function namer(state: PlayerState) {
return {
pilot: (id: string | null) => state.pilots.find((p) => p.id === id)?.name ?? "A pilot",
ship: (id: string | null, tutorial = false) =>
tutorial ? "Company loaner" : (state.ships.find((s) => s.id === id)?.name ?? "A ship"),
good: (id: string | null) => (id ? (content.good(id)?.name ?? id) : "cargo"),
};
}
function problemText(problem: AwayProblem, name: ReturnType<typeof namer>): string {
if (problem.kind === "below-floor") {
return fill(words.highlights.belowFloor, { floor: formatCredits(problem.floor) });
}
const template = {
"no-jobs": words.highlights.noJobs,
"low-fuel": words.highlights.lowFuel,
"emergency-fuel": words.highlights.emergencyFuel,
}[problem.kind];
return fill(template, {
ship: name.ship(problem.shipId),
place: locationName(problem.locationId),
});
}
/** One highlight in words (decision 0.5.140). */
export function highlightText(highlight: AwayHighlight, state: PlayerState): string {
const name = namer(state);
switch (highlight.kind) {
case "discovery":
return fill(words.highlights.discovery, { place: locationName(highlight.locationId) });
case "level-up":
return fill(words.highlights.levelUp, {
pilot: name.pilot(highlight.pilotId),
level: highlight.level,
points: plural(highlight.skillPoints, "skill point"),
});
case "goal-ready":
return fill(words.highlights.goalReady, {
goal: content.onboardingGoal(highlight.goalId)?.title ?? highlight.goalId,
});
case "scripted":
return (
content.scriptedTutorialContracts.find((c) => c.id === highlight.scriptedId)
?.flavorMessage ?? ""
);
case "best-job":
return fill(words.highlights.bestJob, {
good: name.good(highlight.goodId),
place: locationName(highlight.destinationId),
credits: formatCredits(highlight.payout),
pilot: name.pilot(highlight.pilotId),
});
case "problem":
return problemText(highlight.problem, name);
}
}
/** A short absence's event in words ("Courier delivered Mail to the Moon, +410 cr"). */
export function directText(entry: LogEntry, state: PlayerState): string {
const name = namer(state);
switch (entry.kind) {
case "flight":
return entry.deadhead
? fill(words.direct.deadhead, {
ship: name.ship(entry.shipId, entry.tutorial),
place: locationName(entry.destinationId),
fuel: formatCredits(entry.fuelCost),
})
: fill(words.direct.delivery, {
ship: name.ship(entry.shipId, entry.tutorial),
good: name.good(entry.goodId),
place: locationName(entry.destinationId),
credits: signedCredits(flightNet(entry)),
});
case "upgrade":
return fill(words.direct.upgrade, {
ship: name.ship(entry.shipId),
upgrade: content.upgrade(entry.upgradeId)?.name ?? entry.upgradeId,
level: entry.level,
});
case "discovery":
return fill(words.direct.discovery, {
pilot: name.pilot(entry.pilotId),
place: locationName(entry.locationId),
});
case "level-up":
return fill(words.direct.levelUp, {
pilot: name.pilot(entry.pilotId),
level: entry.level,
points: plural(entry.skillPoints, "skill point"),
});
default:
return "";
}
}
/** The summary for the absence now (decision 0.5.140). */
export function useAwaySummary(
state: PlayerState,
refresh: string,
enabled = true,
): Summary | null {
const api = useApi();
const book = useQuery({
queryKey: [...logbookKey, refresh],
queryFn: () => api.call("getLogbook", {}),
enabled,
});
if (!book.data) return null;
return buildAwaySummary({
...book.data.absence,
entries: book.data.entries,
pilots: state.pilots.map((p) => ({ id: p.id, name: p.name, now: pilotNow(state, p) })),
problems: book.data.problems,
});
}
/**
* "While you were away" (Step 9.2, decision 0.5.140): highlights on one phone
* screen, the detail in the Logbook. Opens on unseen deliveries and finished
* upgrades, as before.
*/
export function AwaySummary() { export function AwaySummary() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
const acknowledge = useGameAction("acknowledgeSummaries"); const acknowledge = useGameAction("acknowledgeSummaries");
// Hide as soon as the player dismisses; the refetch clears the list for real. // Hide as soon as the player dismisses; the refetch clears the list for real.
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set()); const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());
const navigate = useNavigate(); const navigate = useNavigate();
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId)); const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId));
@ -24,15 +186,10 @@ export function AwaySummary() {
const outOfShop = state.ships.filter( const outOfShop = state.ships.filter(
(s) => s.shop !== null && s.shop.until <= now && !dismissed.has(`ship:${s.id}`), (s) => s.shop !== null && s.shop.until <= now && !dismissed.has(`ship:${s.id}`),
); );
// Goals waiting to be claimed (Step 10A.17). const open = summaries.length > 0 || outOfShop.length > 0;
const readyGoals = state.goals const refresh = [...summaries.map((s) => s.dispatchId), ...outOfShop.map((s) => s.id)].join();
.filter((g) => g.status === "ready") const summary = useAwaySummary(state, refresh, open);
.map((g) => content.onboardingGoal(g.id)) if (!open) return null;
.filter((g) => g !== undefined);
if (summaries.length === 0 && outOfShop.length === 0) return null;
const shipName = (shipId: string, tutorial: boolean) =>
tutorial ? "Company loaner" : (state.ships.find((s) => s.id === shipId)?.name ?? "A ship");
const dismiss = () => { const dismiss = () => {
const ids = summaries.map((s) => s.dispatchId); const ids = summaries.map((s) => s.dispatchId);
@ -41,134 +198,131 @@ 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 title = fill(words.title, { duration: summary ? formatAbsence(summary.durationMs) : "…" });
const logbook = pilotLogbook(state, summaries); const highlights = summary?.highlights ?? [];
const yours = summaries.filter((s) => !s.independent);
return ( return (
<Sheet titleId="away-title" eyebrow="Logbook" title="While you were away"> <Sheet titleId="away-title" title={title}>
<SleepBanner /> <div className="space-y-3 text-sm">
<PilotLogbook logbook={logbook} /> <SleepBanner />
<ul className="mt-4 space-y-3"> {!summary ? (
{yours.map((s) => ( <p className="text-ink-400">Reading the logbook…</p>
<li ) : summary.direct ? (
key={s.dispatchId} <ul aria-label="What happened" className="space-y-1.5">
className="rounded-xl border border-space-600 bg-space-950/50 p-3 text-sm" {summary.direct.map((entry) => (
> <li key={entry.id} className="flex items-start gap-2 text-ink-100">
<div className="flex items-baseline justify-between gap-2"> <Icon name="send" className="mt-0.5 size-4 shrink-0 text-cyan-300" />
<span className="font-display font-bold tracking-wide"> {directText(entry as LogEntry, state)}
{shipName(s.shipId, s.tutorial)}
</span>
<span className="text-ink-400">
{locationName(s.originId)} → {locationName(s.destinationId)}
</span>
</div>
{!s.deadhead && s.goodId && s.massTonnes !== null && (
<p className="mt-1 text-ink-200">
Delivered {s.massTonnes} t {content.good(s.goodId)?.name ?? s.goodId}
{s.shipper && ` for ${s.shipper}`} {toLocation(s.destinationId)}
</p>
)}
{s.deadhead ? (
// An empty flight earns nothing: say what it was and what it cost.
<div className="mt-2 flex items-center gap-1.5 font-display text-ink-300">
<Icon name="send" className="size-4 shrink-0 text-cyan-300" />
Deadhead · {formatCredits(s.fuelCost)} fuel used
</div>
) : (
<div className="mt-2 flex gap-4 font-display text-lg font-bold tabular-nums">
<span className={s.credits >= 0 ? "text-emerald-300" : "text-rose-300"}>
{s.credits >= 0 ? "+" : ""}
{formatCredits(s.credits)}
</span>
<span className="text-violet-300">+{s.pilotXp} XP</span>
</div>
)}
{s.pilotPay > 0 && (
<div className="mt-1 text-ink-400">
{formatCredits(s.pilotPay)} pilot share to{" "}
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "the pilot"}
</div>
)}
{s.topUp && (
<div className="mt-1 text-amber-200/90">
Topped up {formatFu(s.topUp.boughtFu)} at {locationName(s.topUp.locationId)},{" "}
{formatCredits(s.topUp.cost)}
</div>
)}
{s.loanRepayment > 0 && (
<div className="mt-1 text-ink-400">
{formatCredits(s.loanRepayment)} went to the lender
</div>
)}
{s.scriptedId && (
<p className="mt-1 text-fuchsia-200 italic">
{
content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId)
?.flavorMessage
}
</p>
)}
{s.discoveredLocationId && (
<div className="mt-2 flex items-center gap-1.5 font-display font-semibold text-amber-300">
<Icon name="star" className="size-4" />
Discovered {locationName(s.discoveredLocationId)}!
</div>
)}
{s.restEndsAt !== null && (
<div className="mt-1 flex items-center gap-1.5 text-sky-300">
<Icon name="rest" className="size-4 shrink-0" />
<span>
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "The pilot"} is resting
until {formatLocalTime(s.restEndsAt)}
</span>
</div>
)}
</li>
))}
{outOfShop.map((ship) => (
<li
key={`shop:${ship.id}`}
className="flex items-center gap-1.5 rounded-xl border border-amber-400/40 bg-amber-950/30 p-3 text-sm text-amber-200"
>
<Icon name="check" className="size-4 shrink-0" />
{ship.name} is out of the shop:{" "}
{content.upgrade(ship.shop!.upgradeId)?.name ?? ship.shop!.upgradeId} {ship.shop!.level}
</li>
))}
</ul>
{readyGoals.length > 0 && (
<div className="mt-3 rounded-xl border border-emerald-400/40 bg-emerald-950/40 p-3 text-sm shadow-glow-emerald">
<h3 className="flex items-center gap-1.5 font-display font-bold text-emerald-300">
<Icon name="target" className="size-4" />
Goals ready to claim
</h3>
<ul aria-label="Ready goals" className="mt-1 space-y-0.5">
{readyGoals.map((goal) => (
<li key={goal.id}>
{goal.title}{" "}
<span className="font-display text-amber-300/90">
+{formatCredits(goal.reward)}
</span>
</li> </li>
))} ))}
</ul> </ul>
<Button ) : (
variant="success" <Totals summary={summary} />
size="sm" )}
onClick={() => { {summary && highlights.length > 0 && (
dismiss(); <ul aria-label="Highlights" className="space-y-1.5">
void navigate({ pathname: "/", hash: "#goals" }); {highlights.map((h, i) => (
}} <li key={i} className="flex items-start gap-2 text-amber-100">
className="mt-3" <Icon
> name={h.kind === "problem" ? "alert" : "star"}
Claim on the Bridge className="mt-0.5 size-4 shrink-0 text-amber-300"
</Button> />
</div> {highlightText(h, state)}
)} </li>
<Button block onClick={dismiss} className="mt-4"> ))}
Got it {summary.moreHighlights > 0 && (
</Button> <li className="pl-6 text-ink-400">
{fill(words.moreHighlights, { count: summary.moreHighlights })}
</li>
)}
</ul>
)}
{summary && !summary.direct && <PilotLines summary={summary} />}
</div>
<div className="mt-4 flex gap-2">
<Button
variant="secondary"
className="flex-1"
onClick={() => {
dismiss();
navigate("/logbook?range=absence");
}}
>
{words.viewLogbook}
</Button>
<Button className="flex-1" onClick={dismiss}>
{words.done}
</Button>
</div>
</Sheet> </Sheet>
); );
} }
/** One row of company figures: net, deliveries, discoveries and loan repaid (if any). */
function Totals({ summary }: { summary: Summary }) {
const { net, deliveries, discoveries, loanRepaid } = summary.totals;
const figures: [string, string, string][] = [
[words.totals.net, signedCredits(net), net < 0 ? "text-rose-300" : "text-emerald-300"],
[words.totals.deliveries, String(deliveries), "text-ink-50"],
];
if (discoveries > 0)
figures.push([words.totals.discoveries, String(discoveries), "text-cyan-200"]);
if (loanRepaid > 0)
figures.push([words.totals.loanRepaid, formatCredits(loanRepaid), "text-ink-50"]);
return (
<dl
aria-label="Company totals"
className="grid grid-flow-col gap-2 rounded-xl border border-cyan-400/30 bg-cyan-950/30 p-3"
>
{figures.map(([label, value, tone]) => (
<div key={label}>
<dt className="font-display text-[10px] tracking-wider text-ink-400 uppercase">
{label}
</dt>
<dd className={`font-display text-base font-bold tabular-nums ${tone}`}>{value}</dd>
</div>
))}
</dl>
);
}
/** One line per active pilot, at most 5, then the rest and the idle ones summed. */
function PilotLines({ summary }: { summary: Summary }) {
if (summary.pilots.length === 0 && summary.idlePilots === 0) return null;
return (
<ul aria-label="Pilots" className="space-y-1 text-ink-200">
{summary.pilots.map((line) => (
<li key={line.pilotId} className="flex items-baseline gap-2 truncate">
<span className="truncate">
{fill(words.pilotLine, {
pilot: line.name,
jobs: plural(line.deliveries, "job"),
credits: signedCredits(line.net),
now: line.now,
})}
</span>
{line.leveledUp && (
<span className="shrink-0 rounded-full bg-amber-500/20 px-1.5 text-[10px] font-semibold text-amber-200 uppercase">
{words.leveledUp}
</span>
)}
</li>
))}
{summary.morePilots && (
<li className="text-ink-400">
{fill(words.morePilots, {
count: summary.morePilots.count,
credits: signedCredits(summary.morePilots.net),
})}
</li>
)}
{summary.idlePilots > 0 && (
<li className="text-ink-400">
{summary.idlePilots === 1
? words.idlePilot
: fill(words.idlePilots, { count: summary.idlePilots })}
</li>
)}
</ul>
);
}

View File

@ -4,6 +4,13 @@ export function formatCredits(credits: number): string {
return `${Math.round(credits).toLocaleString("en-US")} cr`; return `${Math.round(credits).toLocaleString("en-US")} cr`;
} }
/** Fills `{name}` placeholders in content wording (CR-025); unknown names stay as they are. */
export function fill(template: string, values: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (all, key: string) =>
key in values ? String(values[key]) : all,
);
}
/** A duration as the two largest units: "2h 05m", "4m 09s", "45s". */ /** A duration as the two largest units: "2h 05m", "4m 09s", "45s". */
export function formatDuration(ms: number): string { export function formatDuration(ms: number): string {
const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); const totalSeconds = Math.max(0, Math.ceil(ms / 1000));

View File

@ -57,7 +57,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
} }
describe("something waiting on return", () => { describe("something waiting on return", () => {
it("lists ready goals alongside the delivery, and the link opens HQ's goals", async () => { it("highlights a goal ready to claim (claimed on the Bridge's goals card, CR-025)", async () => {
let acknowledged = false; let acknowledged = false;
const api = fakeApi({ const api = fakeApi({
getPlayerState: () => returningPlayer(acknowledged), getPlayerState: () => returningPlayer(acknowledged),
@ -68,29 +68,24 @@ describe("something waiting on return", () => {
}); });
renderApp(api, "/fleet"); renderApp(api, "/fleet");
const dialog = await screen.findByRole("dialog", { name: "While you were away" }); const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
expect(dialog).toHaveTextContent("Dusty Mule"); const title = content.onboardingGoal("first-own-delivery")!.title;
const ready = within(dialog).getByRole("list", { name: "Ready goals" }); expect(await within(dialog).findByText(`Goal ready to claim: ${title}`)).toBeInTheDocument();
expect(ready).toHaveTextContent(content.onboardingGoal("first-own-delivery")!.title); await userEvent.click(within(dialog).getByRole("button", { name: "Done" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Claim on the Bridge" }));
expect(screen.queryByRole("dialog", { name: "While you were away" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { level: 1, name: "Dashboard" })).toBeInTheDocument();
const goals = screen.getByRole("region", { name: "Goals" });
expect(within(goals).getByRole("button", { name: "Claim" })).toBeInTheDocument();
await waitFor(() => await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d1"] }), expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d1"] }),
); );
}); });
it("does not list goals when none are ready", async () => { it("does not mention goals when none are ready", async () => {
const state = returningPlayer(false); const state = returningPlayer(false);
const quiet = { const quiet = {
...state, ...state,
goals: state.goals.map((g) => ({ ...g, status: "incomplete" as const })), goals: state.goals.map((g) => ({ ...g, status: "incomplete" as const })),
}; };
renderApp(fakeApi({ getPlayerState: () => quiet })); renderApp(fakeApi({ getPlayerState: () => quiet }));
const dialog = await screen.findByRole("dialog", { name: "While you were away" }); const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
expect(within(dialog).queryByRole("list", { name: "Ready goals" })).not.toBeInTheDocument(); await within(dialog).findByRole("list", { name: "What happened" });
expect(dialog).not.toHaveTextContent("Goal ready");
}); });
}); });

View File

@ -1,158 +1,244 @@
import type { ResolutionSummary, Ship } from "@deadhead/schema"; import type { LogbookResponse, LogEntry, Pilot, ResolutionSummary, Ship } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatAbsence } from "../src/components/AwaySummary";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const ship: Ship = { const NOW = Date.parse("2100-01-01T12:00:00Z");
id: "s1", const FROM = NOW - (9 * 60 + 40) * 60_000;
const ship = (id: string, pilotId: string): Ship => ({
id,
modelId: "courier", modelId: "courier",
name: "Dusty Mule", name: id === "s0" ? "Dusty Mule" : `Ship ${id}`,
status: "idle", status: "idle",
locationId: "mars", locationId: "moon",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0, lastArrivalAt: 0,
...shipExtras("courier"), ...shipExtras("courier"),
pilotId: "avatar", pilotId,
});
const pilot = (i: number): Pilot => ({
...samplePlayerState().pilots[0]!,
id: `p${i}`,
name: i === 0 ? "Rosa" : `Pilot ${i}`,
isAvatar: false,
assignedShipId: `s${i}`,
locationId: "moon",
...pilotExtras,
mode: "independent",
});
/** A summary that keeps the pop-up open (the trigger is unchanged: unseen deliveries). */
const pending: ResolutionSummary = {
dispatchId: "d0",
shipId: "s0",
pilotId: "p0",
originId: "earth",
destinationId: "moon",
tutorial: false,
independent: true,
deadhead: false,
fuelCost: 100,
topUp: null,
goodId: "consumer-goods",
massTonnes: 6,
shipper: "Pacific Orbital Logistics",
credits: 700,
loanRepayment: 0,
pilotPay: 200,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
restEndsAt: null,
resolvedAt: NOW - 60_000,
}; };
const summaries: ResolutionSummary[] = [ let n = 0;
{ const flight = (pilotId: string, payout: number, over: Partial<LogEntry> = {}): LogEntry =>
dispatchId: "d1", ({
shipId: "s1", id: `f${n++}`,
pilotId: "avatar", at: FROM + 60_000 + n,
originId: "earth", pilotId,
destinationId: "mars", shipId: `s${pilotId.slice(1)}`,
kind: "flight",
dispatchId: `d${n}`,
deadhead: false,
tutorial: false, tutorial: false,
credits: 51_200, independent: true,
deadhead: false, originId: "earth",
independent: false, destinationId: "moon",
fuelCost: 0, goodId: "helium-3",
topUp: null, massTonnes: 6,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics", shipper: "Pacific Orbital Logistics",
loanRepayment: 0, payout,
pilotPay: 0, pilotPay: 0,
pilotXp: 32,
discoveredLocationId: "mars",
scriptedId: null,
restEndsAt: null,
resolvedAt: 1,
},
{
dispatchId: "d2",
shipId: "loaner",
pilotId: "avatar",
originId: "moon",
destinationId: "earth",
tutorial: true,
credits: 240,
deadhead: false,
independent: false,
fuelCost: 0, fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0, loanRepayment: 0,
pilotPay: 0, xp: 10,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null, scriptedId: null,
restEndsAt: null, ...over,
resolvedAt: 2, }) as LogEntry;
},
];
describe("while you were away", () => { function render(pilots: number, entries: LogEntry[], over: Partial<LogbookResponse> = {}) {
it("names what was delivered, how much, for whom and where (CR-007)", async () => { let seen = false;
const delivery: ResolutionSummary = { const all = Array.from({ length: pilots }, (_, i) => pilot(i));
...summaries[0]!, const api = fakeApi({
destinationId: "moon", getPlayerState: () =>
goodId: "medical-supplies", samplePlayerState({
massTonnes: 6, tutorial: null,
shipper: "Meridian Health", pilots: [samplePlayerState().pilots[0]!, ...all],
discoveredLocationId: null, ships: all.map((p, i) => ship(`s${i}`, p.id)),
}; summaries: seen ? [] : [pending],
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({ tutorial: null, ships: [ship], summaries: [delivery] }),
}), }),
); getLogbook: () => ({
const dialog = await screen.findByRole("dialog", { name: "While you were away" }); entries,
expect(within(dialog).getAllByRole("listitem")[0]).toHaveTextContent( absence: { from: FROM, to: NOW },
"Delivered 6 t Medical Supplies for Meridian Health to the Moon", lastAbsence: null,
); problems: [],
...over,
}),
acknowledgeSummaries: () => {
seen = true;
return { acknowledged: 1 };
},
});
renderApp(api);
return api;
}
const dialog = () => screen.findByRole("dialog", { name: /While you were away/ });
describe("the highlights pop-up (Step 25.3, decision 0.5.140)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("titles the absence with its length", async () => {
render(1, [flight("p0", 410)]);
const sheet = await dialog();
await within(sheet).findByRole("list", { name: "What happened" });
expect(sheet).toHaveAccessibleName("While you were away · 9 h 40 m");
expect([formatAbsence(12 * 60_000), formatAbsence(50 * 3_600_000)]).toEqual([
"12 m",
"2 d 2 h",
]);
}); });
it("shows a deadhead as an empty flight with its fuel, not as a +0 payout", async () => { it("lists a short absence's events directly: no totals or pilot lines", async () => {
const deadhead: ResolutionSummary = { render(2, [
...summaries[0]!, flight("p0", 410, { goodId: "consumer-goods" }),
dispatchId: "d3", flight("p1", 0, { deadhead: true, goodId: null, fuelCost: 230 }),
destinationId: "moon", ]);
deadhead: true, const list = await within(await dialog()).findByRole("list", { name: "What happened" });
independent: false, expect(
fuelCost: 230, within(list)
topUp: null, .getAllByRole("listitem")
goodId: null, .map((li) => li.textContent),
massTonnes: null, ).toEqual([
shipper: null, "Dusty Mule delivered Consumer Goods to Moon, +410 cr",
credits: 0, "Ship s1 flew empty to Moon, 230 cr fuel",
pilotXp: 0, ]);
discoveredLocationId: null, expect(screen.queryByLabelText("Company totals")).not.toBeInTheDocument();
}; expect(screen.queryByRole("list", { name: "Pilots" })).not.toBeInTheDocument();
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({ tutorial: null, ships: [ship], summaries: [deadhead] }),
}),
);
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
const entry = within(dialog).getAllByRole("listitem")[0]!;
expect(entry).toHaveTextContent("Dusty Mule");
expect(entry).toHaveTextContent("Earth → Moon");
expect(entry).toHaveTextContent("Deadhead · 230 cr fuel");
expect(entry).not.toHaveTextContent("+0 cr");
expect(entry).not.toHaveTextContent("XP");
expect(entry).not.toHaveTextContent("Delivered");
}); });
it("lists each completed delivery, and dismissing acknowledges them and hides the dialog", async () => { it("with 50 active pilots shows totals, 3 highlights, 5 pilot lines and the rest summed", async () => {
let acknowledged = false; const entries = Array.from({ length: 50 }, (_, i) => [
const api = fakeApi({ flight(`p${i}`, (i + 1) * 100, { fuelCost: 10 }),
getPlayerState: () => flight(`p${i}`, (i + 1) * 100, { fuelCost: 10 }),
samplePlayerState({ ]).flat();
tutorial: null, entries.push(
ships: [ship], {
summaries: acknowledged ? [] : summaries, id: "x1",
}), at: FROM + 5,
acknowledgeSummaries: ({ dispatchIds }) => { pilotId: "p3",
acknowledged = true; shipId: "s3",
return { acknowledged: dispatchIds.length }; kind: "discovery",
locationId: "mars",
}, },
{
id: "x2",
at: FROM + 6,
pilotId: "p0",
shipId: "s0",
kind: "level-up",
level: 4,
skillPoints: 1,
},
);
render(50, entries, {
problems: [{ kind: "low-fuel", shipId: "s2", locationId: "moon" }],
}); });
renderApp(api); const sheet = await dialog();
const totals = await within(sheet).findByLabelText("Company totals");
const net = entries.reduce(
(sum, e) => sum + (e.kind === "flight" ? e.payout - e.fuelCost : 0),
0,
);
expect(totals).toHaveTextContent(`Net earned+${net.toLocaleString("en-US")} cr`);
expect(totals).toHaveTextContent("Deliveries100");
expect(totals).toHaveTextContent("Discoveries1");
expect(totals).not.toHaveTextContent("Loan repaid");
const dialog = await screen.findByRole("dialog", { name: "While you were away" }); const highlights = within(sheet).getByRole("list", { name: "Highlights" });
const items = within(dialog).getAllByRole("listitem"); expect(
expect(items).toHaveLength(2); within(highlights)
.getAllByRole("listitem")
.map((li) => li.textContent),
).toEqual([
"New market: Mars",
"Rosa reached level 4 · 1 skill point",
"Biggest job: Helium-3 Canisters to Moon, 5,000 cr, Pilot 49",
"+1 more in the Logbook",
]);
expect(items[0]).toHaveTextContent("Dusty Mule"); const lines = within(sheet).getByRole("list", { name: "Pilots" });
expect(items[0]).toHaveTextContent("Earth → Mars"); const items = within(lines).getAllByRole("listitem");
expect(items[0]).toHaveTextContent("+51,200 cr"); // 5 pilots, the other 45, and the Captain, who flew nothing.
expect(items[0]).toHaveTextContent("+32 XP"); expect(items).toHaveLength(7);
expect(items[0]).toHaveTextContent("Discovered Mars!"); expect(items[6]).toHaveTextContent("1 pilot had nothing to do");
expect(items[0]).toHaveTextContent("Pilot 49 · 2 jobs · +9,980 cr · At Moon");
const rest = Array.from({ length: 45 }, (_, i) => 2 * ((i + 1) * 100 - 10)).reduce(
(a, b) => a + b,
);
expect(items[5]).toHaveTextContent(`+45 more pilots: +${rest.toLocaleString("en-US")} cr`);
// Rosa leveled up but is 50th by net: her badge is in the collapsed line, not shown.
expect(within(lines).queryByText("Level up")).not.toBeInTheDocument();
});
expect(items[1]).toHaveTextContent("Company loaner"); it("adds +N more lines only when needed, and sums idle pilots", async () => {
expect(items[1]).toHaveTextContent("Moon → Earth"); render(4, [flight("p0", 100), flight("p0", 100), flight("p1", 50), flight("p1", 50)]);
expect(items[1]).not.toHaveTextContent("Discovered"); const sheet = await dialog();
const lines = await within(sheet).findByRole("list", { name: "Pilots" });
expect(
within(lines)
.getAllByRole("listitem")
.map((li) => li.textContent),
).toEqual([
"Rosa · 2 jobs · +200 cr · At Moon",
"Pilot 1 · 2 jobs · +100 cr · At Moon",
// The two without flights, and the Captain.
"3 pilots had nothing to do",
]);
expect(within(sheet).queryByText(/more in the Logbook/)).not.toBeInTheDocument();
});
await userEvent.click(within(dialog).getByRole("button", { name: "Got it" })); it("closes on Done, acknowledging what it showed", async () => {
const api = render(1, [flight("p0", 410)]);
const sheet = await dialog();
await within(sheet).findByRole("list", { name: "What happened" });
await userEvent.click(within(sheet).getByRole("button", { name: "Done" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await waitFor(() => await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d1", "d2"] }), expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d0"] }),
); );
}); });

View File

@ -4,7 +4,6 @@ import { act, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { CatchingUp, pilotLogbook } from "../src/components/PilotLogbook"; import { CatchingUp, pilotLogbook } from "../src/components/PilotLogbook";
import { formatCredits } from "../src/format";
import { pilotExtras } from "./fixtures"; import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
@ -222,18 +221,4 @@ describe("pilot logbook (Step 21.7)", () => {
}); });
expect(book.totals.payout).toBe(1_000 + 1_200); 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

@ -171,8 +171,11 @@ describe("loan on HQ", () => {
renderApp( renderApp(
fakeApi({ getPlayerState: () => state, acknowledgeSummaries: () => ({ acknowledged: 1 }) }), fakeApi({ getPlayerState: () => state, acknowledgeSummaries: () => ({ acknowledged: 1 }) }),
); );
const dialog = await screen.findByRole("dialog", { name: "While you were away" }); const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
expect(dialog).toHaveTextContent("230 cr went to the lender"); // The pop-up shows the net, after the lender's cut; the cut itself is in the Logbook (CR-025).
await waitFor(() =>
expect(dialog).toHaveTextContent("delivered Consumer Goods to Moon, +538 cr"),
);
}); });
}); });

View File

@ -122,7 +122,7 @@ describe("the deal", () => {
fakeApi({ getPlayerState: withSummary, getTutorialBoard: () => sampleTutorialBoard() }), fakeApi({ getPlayerState: withSummary, getTutorialBoard: () => sampleTutorialBoard() }),
"/contracts", "/contracts",
); );
await screen.findByRole("dialog", { name: "While you were away" }); await screen.findByRole("dialog", { name: /While you were away/ });
expect( expect(
screen.queryByRole("dialog", { name: "An offer from Halvorsen" }), screen.queryByRole("dialog", { name: "An offer from Halvorsen" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();

View File

@ -1,5 +1,5 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Pilot, PlayerState, Ship } from "@deadhead/schema"; import type { LogbookResponse, LogEntry, Pilot, PlayerState, Ship } from "@deadhead/schema";
import { effectiveStats } from "@deadhead/sim"; import { effectiveStats } from "@deadhead/sim";
// Plain data builders shared by component tests and the Playwright tests (no Vitest imports). // Plain data builders shared by component tests and the Playwright tests (no Vitest imports).
@ -100,3 +100,76 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
...overrides, ...overrides,
}; };
} }
/**
* The logbook a state's unseen summaries and finished upgrades stand for
* (CR-025): what the engine's `getLogbook` would return. `fakeApi` answers
* `getLogbook` with it unless a test gives its own.
*/
export function logbookFrom(state: PlayerState): LogbookResponse {
const entries: LogEntry[] = [];
for (const s of state.summaries) {
const who = { at: s.resolvedAt, pilotId: s.pilotId, shipId: s.shipId };
entries.push({
id: `${s.dispatchId}:flight`,
...who,
kind: "flight",
dispatchId: s.dispatchId,
deadhead: s.deadhead,
tutorial: s.tutorial,
independent: s.independent,
originId: s.originId,
destinationId: s.destinationId,
goodId: s.goodId,
massTonnes: s.massTonnes,
shipper: s.shipper,
payout: Math.max(0, s.credits + s.pilotPay + s.loanRepayment),
pilotPay: s.pilotPay,
fuelCost: s.fuelCost,
loanRepayment: s.loanRepayment,
xp: s.pilotXp,
scriptedId: s.scriptedId,
});
if (s.restEndsAt !== null) {
entries.push({ id: `${s.dispatchId}:rest`, ...who, kind: "rest", until: s.restEndsAt });
}
if (s.discoveredLocationId) {
entries.push({
id: `${s.dispatchId}:discovery`,
...who,
kind: "discovery",
locationId: s.discoveredLocationId,
});
}
}
for (const ship of state.ships) {
if (!ship.shop || ship.shop.until > Date.now()) continue;
entries.push({
id: `${ship.id}:upgrade`,
at: ship.shop.until,
pilotId: ship.pilotId,
shipId: ship.id,
kind: "upgrade",
upgradeId: ship.shop.upgradeId,
level: ship.shop.level,
});
}
for (const goal of state.goals) {
if (goal.status !== "ready") continue;
entries.push({
id: `goal:${goal.id}`,
at: Date.now(),
pilotId: null,
shipId: null,
kind: "goal-ready",
goalId: goal.id,
});
}
const from = Math.min(Date.now(), ...entries.map((e) => e.at)) - 60_000;
return {
entries: entries.sort((a, b) => a.at - b.at),
absence: { from, to: Math.max(Date.now(), ...entries.map((e) => e.at)) },
lastAbsence: null,
problems: [],
};
}

View File

@ -121,7 +121,7 @@ describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () =>
it("puts the sleep banner on the while-you-were-away screen, until Back to work", async () => { it("puts the sleep banner on the while-you-were-away screen, until Back to work", async () => {
const api = sleepyApi({ summaries: [summary] }); const api = sleepyApi({ summaries: [summary] });
renderApp(api, "/"); renderApp(api, "/");
const sheet = await screen.findByRole("dialog", { name: "While you were away" }); const sheet = await screen.findByRole("dialog", { name: /While you were away/ });
const banner = within(sheet).getByLabelText("Fleet asleep"); const banner = within(sheet).getByLabelText("Fleet asleep");
expect(banner).toHaveTextContent( expect(banner).toHaveTextContent(
/^Your fleet went quiet on .+, .+ after 24 h without orders\./, /^Your fleet went quiet on .+, .+ after 24 h without orders\./,
@ -134,7 +134,7 @@ describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () =>
it("leaves the banner off the away screen when the fleet didn't go quiet", async () => { it("leaves the banner off the away screen when the fleet didn't go quiet", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(false, { summaries: [summary] }) }), "/"); renderApp(fakeApi({ getPlayerState: () => owner(false, { summaries: [summary] }) }), "/");
const sheet = await screen.findByRole("dialog", { name: "While you were away" }); const sheet = await screen.findByRole("dialog", { name: /While you were away/ });
expect(within(sheet).queryByLabelText("Fleet asleep")).not.toBeInTheDocument(); expect(within(sheet).queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
}); });

View File

@ -2,7 +2,7 @@ import { pilotExtras } from "./fixtures";
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema"; import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema";
import { pilotShare, tripFuel } from "@deadhead/sim"; import { pilotShare, tripFuel } from "@deadhead/sim";
import { screen, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
@ -130,9 +130,10 @@ describe("pilot share in the client (Step 10J.6)", () => {
expect(mine).toHaveTextContent(`Net profit${formatCredits(job.payout - fuel)}`); expect(mine).toHaveTextContent(`Net profit${formatCredits(job.payout - fuel)}`);
}); });
it("lists the pilot's share in a delivery summary", async () => { it("takes the pilot's share out of a delivery's net in the summary", async () => {
renderApp(fakeApi({ getPlayerState: () => state([summary]) }), "/"); renderApp(fakeApi({ getPlayerState: () => state([summary]) }), "/");
const dialog = await screen.findByRole("dialog", { name: "While you were away" }); const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
expect(dialog).toHaveTextContent(`${formatCredits(63)} pilot share to Rosa Kell`); // 600 cr after the share, less 150 cr of fuel (CR-025); the share itself is in the Logbook.
await waitFor(() => expect(dialog).toHaveTextContent(`+${formatCredits(450)}`));
}); });
}); });

View File

@ -6,6 +6,7 @@ import { vi } from "vitest";
import { App } from "../src/App"; import { App } from "../src/App";
import { ApiError, type ApiClient } from "../src/api/client"; import { ApiError, type ApiClient } from "../src/api/client";
import { ApiProvider } from "../src/api/context"; import { ApiProvider } from "../src/api/context";
import { logbookFrom } from "./fixtures";
type Handlers = { type Handlers = {
[N in RouteName]?: (input: RouteRequest<N>) => RouteResponse<N> | Promise<RouteResponse<N>>; [N in RouteName]?: (input: RouteRequest<N>) => RouteResponse<N> | Promise<RouteResponse<N>>;
@ -13,13 +14,18 @@ type Handlers = {
/** /**
* A fake API: each route answers from its handler. Unhandled routes fail with * A fake API: each route answers from its handler. Unhandled routes fail with
* `not-found`; `getServerTime` defaults to the device clock. * `not-found`; `getServerTime` defaults to the device clock, and `getLogbook`
* to the logbook the player state stands for.
*/ */
export function fakeApi(handlers: Handlers) { export function fakeApi(handlers: Handlers) {
const call = vi.fn(async (name: RouteName, input: unknown) => { const call = vi.fn(async (name: RouteName, input: unknown) => {
if (name === "getServerTime" && !handlers.getServerTime) { if (name === "getServerTime" && !handlers.getServerTime) {
return { realTime: Date.now(), gameTime: 0 }; return { realTime: Date.now(), gameTime: 0 };
} }
// The logbook the state stands for (CR-025), unless the test gives one.
if (name === "getLogbook" && !handlers.getLogbook && handlers.getPlayerState) {
return logbookFrom(await handlers.getPlayerState({} as never));
}
const handler = handlers[name] as ((input: unknown) => unknown) | undefined; const handler = handlers[name] as ((input: unknown) => unknown) | undefined;
if (!handler) throw new ApiError(404, "not-found", `No fake for ${name}`); if (!handler) throw new ApiError(404, "not-found", `No fake for ${name}`);
return handler(input); return handler(input);

View File

@ -1,9 +1,8 @@
import { pilotExtras } from "./fixtures"; import { pilotExtras } from "./fixtures";
import type { Pilot, ResolutionSummary } from "@deadhead/schema"; import type { Pilot } from "@deadhead/schema";
import { screen, within } from "@testing-library/react"; import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; import { useClock } from "../src/clock/clock";
import { formatLocalTime } from "../src/screens/DispatchSheet";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -28,31 +27,6 @@ const pilot = (id: string, name: string, extra: Partial<Pilot> = {}): Pilot => (
...extra, ...extra,
}); });
const summary = (extra: Partial<ResolutionSummary>): ResolutionSummary => ({
dispatchId: "d1",
shipId: "s1",
pilotId: "p-rae",
originId: "earth",
destinationId: "moon",
tutorial: false,
credits: 500,
deadhead: false,
independent: false,
fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 0,
pilotPay: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
restEndsAt: null,
resolvedAt: NOW,
...extra,
});
const pilots = [ const pilots = [
pilot("p-rae", "Rae Okafor", { resting: true, restEndsAt: NOW + 40 * 60_000 }), pilot("p-rae", "Rae Okafor", { resting: true, restEndsAt: NOW + 40 * 60_000 }),
pilot("p-jun", "Jun Sato", { resting: true, restEndsAt: NOW + 9 * 60_000 }), pilot("p-jun", "Jun Sato", { resting: true, restEndsAt: NOW + 9 * 60_000 }),
@ -67,28 +41,6 @@ describe("rest in summaries and on HQ (Step 10D.9)", () => {
}); });
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
it("says in the away summary that a pilot is resting, only for trips that ended in rest", async () => {
const restEndsAt = NOW + 40 * 60_000;
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: null,
pilots,
summaries: [
summary({ dispatchId: "d1", restEndsAt }),
summary({ dispatchId: "d2", pilotId: "p-cy" }),
],
}),
}),
"/",
);
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
const [rested, plain] = within(dialog).getAllByRole("listitem");
expect(rested).toHaveTextContent(`Rae Okafor is resting until ${formatLocalTime(restEndsAt)}`);
expect(plain).not.toHaveTextContent("resting");
});
it("shows how many pilots are resting and who wakes first", async () => { it("shows how many pilots are resting and who wakes first", async () => {
renderApp( renderApp(
fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null, pilots }) }), fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null, pilots }) }),

View File

@ -47,11 +47,13 @@ describe("the shop in summaries and on HQ (Step 10I.8)", () => {
}), }),
}); });
renderApp(api, "/"); renderApp(api, "/");
const dialog = await screen.findByRole("dialog", { name: "While you were away" }); const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
expect(dialog).toHaveTextContent("Slow Ox is out of the shop: Engine Tuning 2"); await waitFor(() =>
expect(dialog).toHaveTextContent("Slow Ox is out of the shop: Engine Tuning level 2"),
);
expect(dialog).not.toHaveTextContent("Busy Ox"); expect(dialog).not.toHaveTextContent("Busy Ox");
await userEvent.click(within(dialog).getByRole("button", { name: "Got it" })); await userEvent.click(within(dialog).getByRole("button", { name: "Done" }));
await waitFor(() => await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", {
dispatchIds: [], dispatchIds: [],

View File

@ -162,7 +162,7 @@ describe("scripted delivery summary", () => {
acknowledgeSummaries: () => ({ acknowledged: 1 }), acknowledgeSummaries: () => ({ acknowledged: 1 }),
}), }),
); );
const dialog = await screen.findByRole("dialog", { name: "While you were away" }); const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
expect(dialog).toHaveTextContent(flavor); await waitFor(() => expect(dialog).toHaveTextContent(flavor));
}); });
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -65,6 +65,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes | | `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes |
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes | | `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
| `setSleepThreshold` | `POST /api/fleet/sleep-after` | `hours` (one of `fleetSleep.optionsHours`, else `validation-error`) | `PlayerState` | yes; a manual decision (CR-024) | | `setSleepThreshold` | `POST /api/fleet/sleep-after` | `hours` (one of `fleetSleep.optionsHours`, else `validation-error`) | `PlayerState` | yes; a manual decision (CR-024) |
| `getLogbook` | `GET /api/logbook` | — | `{ entries, absence: { from, to }, lastAbsence, problems }` (CR-025: the last 7 days, the absence since the summary was last closed, the one before, and what needs the player now) | yes |
| `backToWork` | `POST /api/fleet/wake` | — | `PlayerState` (awake) | yes; a manual decision that does nothing else: wakes the fleet (CR-024) | | `backToWork` | `POST /api/fleet/wake` | — | `PlayerState` (awake) | yes; a manual decision that does nothing else: wakes the fleet (CR-024) |
| `exportSave` | engine only (CR-014) | — | `{ code }`: `DEADHEAD1.` + base64url(gzip(save JSON)) | yes | | `exportSave` | engine only (CR-014) | — | `{ code }`: `DEADHEAD1.` + base64url(gzip(save JSON)) | yes |
| `importSave` | engine only (CR-014) | `code` (whitespace ignored) | `{ playerId, state }`; `invalid-save` if it isn't a good save | no | | `importSave` | engine only (CR-014) | `code` (whitespace ignored) | `{ playerId, state }`; `invalid-save` if it isn't a good save | no |

View File

@ -537,6 +537,6 @@ Spec: `memory-bank/change-requests/CR-025-short-away-summary.md` (received as "C
- [x] **Step 25.1 — Engine: logbook entries and retention.** Schema `logEntrySchema` (`packages/schema/src/logbook.ts`) and save version 10: `logbook`, player `awaySince` (when the summary was last closed), `lastAbsence` (the absence it covered) and `readyGoalsLogged`, ship `shopOpen`. The engine logs each arrival (the flight, its Auto top-up, a forced rest, a discovery, a level-up with skill points), fuel the player, an Independent pilot or emergency fuel buys, a rest the player orders, every upgrade at its shop end (now a catch-up event for any open shop), and each goal the first time it's ready. `pruneSave` drops entries over 7 days old (`logbookRetentionRealDays`). Closing the summary sets `lastAbsence` and moves `awaySince` to now. The 9 → 10 migration turns resolved dispatches into flight, top-up, rest and discovery entries (level-ups can't be rebuilt), leaves an upgrade still in the shop open for catch-up, and starts the absence at the last dismissed summary. Tests: `packages/engine/test/logbook.test.ts` (each kind's fields; pruning at 7 days; catch-up in one go equals 2-minute steps over 6 hours with Independent pilots, pilot fuel and an upgrade; closing the summary); `packages/schema/test/save.test.ts` (9 → 10). - [x] **Step 25.1 — Engine: logbook entries and retention.** Schema `logEntrySchema` (`packages/schema/src/logbook.ts`) and save version 10: `logbook`, player `awaySince` (when the summary was last closed), `lastAbsence` (the absence it covered) and `readyGoalsLogged`, ship `shopOpen`. The engine logs each arrival (the flight, its Auto top-up, a forced rest, a discovery, a level-up with skill points), fuel the player, an Independent pilot or emergency fuel buys, a rest the player orders, every upgrade at its shop end (now a catch-up event for any open shop), and each goal the first time it's ready. `pruneSave` drops entries over 7 days old (`logbookRetentionRealDays`). Closing the summary sets `lastAbsence` and moves `awaySince` to now. The 9 → 10 migration turns resolved dispatches into flight, top-up, rest and discovery entries (level-ups can't be rebuilt), leaves an upgrade still in the shop open for catch-up, and starts the absence at the last dismissed summary. Tests: `packages/engine/test/logbook.test.ts` (each kind's fields; pruning at 7 days; catch-up in one go equals 2-minute steps over 6 hours with Independent pilots, pilot fuel and an upgrade; closing the summary); `packages/schema/test/save.test.ts` (9 → 10).
- [x] **Step 25.2 — Sim: summary builder.** `buildAwaySummary` (`packages/sim/src/away.ts`, pure) takes the absence (`from`, `to`), the logbook entries, every pilot with where they are now, and the problems found from the state, and returns the duration, totals (net, deliveries, discoveries, loan repaid), at most 3 highlights in priority order (discovery, level-up, goal ready, scripted flavor, biggest job, problem) with the overflow count, at most 5 pilot lines by net with the rest summed and the idle count, and the direct list for 3 events or fewer. `flightNet` is payout − pilot share − fuel used − loan repayment. The sim's `LogbookEntry` and the save's `LogEntry` are checked to be the same type. Wording in content (`content.awayWording`, `{name}` placeholders). Tests: `packages/sim/test/away.test.ts` (50 pilots: 5 lines and an overflow line equal to the other 45; priority order and the cap with the right overflow; totals, with fuel bought not counted; the direct list for 2 events and not for 4; only the absence); `packages/engine/test/logbook.test.ts` (an entry's net equals the delivery summary's credits less its fuel); `packages/content/test/onboarding.test.ts` (the wording). - [x] **Step 25.2 — Sim: summary builder.** `buildAwaySummary` (`packages/sim/src/away.ts`, pure) takes the absence (`from`, `to`), the logbook entries, every pilot with where they are now, and the problems found from the state, and returns the duration, totals (net, deliveries, discoveries, loan repaid), at most 3 highlights in priority order (discovery, level-up, goal ready, scripted flavor, biggest job, problem) with the overflow count, at most 5 pilot lines by net with the rest summed and the idle count, and the direct list for 3 events or fewer. `flightNet` is payout − pilot share − fuel used − loan repayment. The sim's `LogbookEntry` and the save's `LogEntry` are checked to be the same type. Wording in content (`content.awayWording`, `{name}` placeholders). Tests: `packages/sim/test/away.test.ts` (50 pilots: 5 lines and an overflow line equal to the other 45; priority order and the cap with the right overflow; totals, with fuel bought not counted; the direct list for 2 events and not for 4; only the absence); `packages/engine/test/logbook.test.ts` (an entry's net equals the delivery summary's credits less its fuel); `packages/content/test/onboarding.test.ts` (the wording).
- [ ] Step 25.3 — Client: highlights pop-up - [x] **Step 25.3 — Client: highlights pop-up.** New `getLogbook` command: entries, the absence (`awaySince` to now), the last closed one, and problems from the state (`awayProblems` in `packages/engine/src/game/away.ts`: a ship that can't reach anywhere on the fuel aboard, one that needs emergency fuel, a Manual ship whose stored board has no job it could take, credits below the floor with Independent pilots). The pop-up (`apps/web/src/components/AwaySummary.tsx`) still opens on unseen deliveries and finished upgrades, and builds its contents with `buildAwaySummary`: "While you were away · 9 h 40 m", the sleep banner, a totals row, up to 3 highlights and "+N more in the Logbook", up to 5 pilot lines ("Rosa · 6 jobs · +12,300 cr · Resting at Ceres", a Level up badge), "+N more pilots" and the idle line; a short absence lists its events instead ("Dusty Mule delivered Consumer Goods to Moon, +410 cr"). View logbook and Done both close it; View logbook goes to `/logbook?range=absence` (Step 25.4). The builder now drops what a short absence lists (discoveries, level-ups, the biggest job) before capping, and shows each pilot's highest level-up only. Tests: `apps/web/test/away-summary.test.tsx` (the title; the direct list; 50 pilots: totals, 3 highlights and the overflow, 5 lines plus the rest and the idle line; "+N more" only when needed; Done); `packages/engine/test/logbook.test.ts` (`getLogbook`'s windows; each problem); new e2e `apps/web/e2e/25-3-away-fits.e2e.ts` (8 Independent pilots at 375×667: the sheet doesn't scroll and Done is on screen). Tests about per-delivery detail now check the net in the pop-up (pilot share, the lender's cut) or wait for the Logbook screen (rest, the pilot logbook). Screenshots: `docs/screenshots/25-3-away-{phone,desktop}.png`.
- [ ] Step 25.4 — Client: Logbook screen - [ ] Step 25.4 — Client: Logbook screen
- [ ] Step 25.5 — Regression - [ ] Step 25.5 — Regression

View File

@ -0,0 +1,78 @@
import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import {
boardPeriodIndex,
canLandAt,
emergencyFuel,
isInShop,
permanentFailure,
shipStats,
tripFuel,
type AwayProblem,
type RealTime,
} from "@deadhead/sim";
import { acceptedContractIds } from "./boards";
import { discoveredIds, shipState } from "./state";
/**
* 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.
*/
export function awayProblems(save: SaveDocument, now: RealTime): AwayProblem[] {
if (save.player.tutorialActive) return [];
const discovered = discoveredIds(save);
const accepted = acceptedContractIds(save);
const period = boardPeriodIndex(now, content.config);
const problems: AwayProblem[] = [];
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;
const at = { shipId: ship.id, locationId: ship.locationId };
const state = shipState(ship);
if (
emergencyFuel(
{ ship: state, credits: save.company.credits, discoveredIds: discovered },
content,
)
) {
problems.push({ kind: "emergency-fuel", ...at });
continue;
}
const model = shipStats(state, content);
const here = content.location(ship.locationId)!;
const legs = content.locations
.filter((next) => next.id !== here.id && discovered.has(next.id) && canLandAt(model, next))
.map((next) => tripFuel(model, here, next, content.config));
if (legs.length > 0 && Math.min(...legs) > ship.fuelFu) {
problems.push({ kind: "low-fuel", ...at });
continue;
}
// Only the player sends a Manual pilot; an Independent one waits or heads home.
if (pilot.mode !== "manual") continue;
const board = save.contractBoards.find(
(b) => b.kind === "location" && b.locationId === ship.locationId && b.periodIndex === period,
);
if (!board) continue;
const member = {
modelId: ship.modelId,
skills: pilot.skills,
upgrades: ship.upgrades,
fuelFu: ship.fuelFu,
};
const open = board.contracts.filter((c) => !accepted.has(c.id) && c.expiresAt > now);
if (!open.some((c) => permanentFailure(c, member, discovered, content) === null)) {
problems.push({ kind: "no-jobs", ...at });
}
}
const { credits, npcSpendingFloor, dispatchOffice } = save.company;
if (
dispatchOffice &&
credits < npcSpendingFloor &&
save.pilots.some((p) => p.mode === "independent")
) {
problems.push({ kind: "below-floor", credits, floor: npcSpendingFloor });
}
return problems;
}

View File

@ -1,4 +1,5 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { awayProblems } from "./away";
import { tutorialBoard, locationBoard } from "./boards"; import { tutorialBoard, locationBoard } from "./boards";
import { acceptDeal, claimGoal, maxedUpgrades, renameCompany, repayLoan } from "./company"; import { acceptDeal, claimGoal, maxedUpgrades, renameCompany, repayLoan } from "./company";
import { dispatchContract, dispatchReposition, dispatchTutorial } from "./dispatch"; import { dispatchContract, dispatchReposition, dispatchTutorial } from "./dispatch";
@ -68,6 +69,13 @@ export function commandTable(): CommandTable {
acknowledgeSummaries: ({ save, now }, input) => ({ acknowledgeSummaries: ({ save, now }, input) => ({
acknowledged: acknowledge(save, input, now), acknowledged: acknowledge(save, input, now),
}), }),
// The logbook and the absence (decisions 0.5.140, 0.5.141).
getLogbook: ({ save, now }) => ({
entries: save.logbook,
absence: { from: save.player.awaySince, to: now },
lastAbsence: save.player.lastAbsence,
problems: awayProblems(save, now),
}),
// Wakes the fleet now (decision 0.5.138). The engine stamps every manual decision // Wakes the fleet now (decision 0.5.138). The engine stamps every manual decision
// after the command; stamping here too makes the returned state awake. // after the command; stamping here too makes the returned state awake.
backToWork: ({ save, now }) => { backToWork: ({ save, now }) => {

View File

@ -260,3 +260,65 @@ describe("net earnings (Step 25.2, decision 0.5.142)", () => {
expect(flightNet(entry!)).toBe(summary.credits - summary.fuelCost); expect(flightNet(entry!)).toBe(summary.credits - summary.fuelCost);
}); });
}); });
describe("getLogbook and what needs the player (Step 25.3, decision 0.5.140)", () => {
it("returns the entries, this absence and the last one closed", async () => {
const test = newTestApp();
const o = await owner(test);
const start = (await readSave(test.app)).player.awaySince;
const { dispatch } = await deliver(test, o.avatarId, o.shipId);
let book = await test.app.call("getLogbook", {});
expect(book.entries.map((e) => e.id)).toContain(`${dispatch.id}:flight`);
expect(book.absence).toEqual({ from: start, to: test.clock.now });
expect(book.lastAbsence).toBeNull();
const closed = test.clock.now;
await test.app.call("acknowledgeSummaries", { dispatchIds: [dispatch.id] });
test.clock.now += HOUR;
book = await test.app.call("getLogbook", {});
expect(book.lastAbsence).toEqual({ from: start, to: closed });
expect(book.absence).toEqual({ from: closed, to: test.clock.now });
});
it("finds a ship too low on fuel, one needing emergency fuel, and credits below the floor", async () => {
const test = newTestApp();
const o = await owner(test);
await edit(test.app, (s) => {
s.ships.find((x) => x.id === o.shipId)!.fuelFu = 0;
});
let { problems } = await test.app.call("getLogbook", {});
// Earth sells fuel and the company can pay: a refuel away.
expect(problems).toEqual([{ kind: "low-fuel", shipId: o.shipId, locationId: "earth" }]);
await setCredits(test.app, content.config.independentPilots.dispatchOfficePrice);
await test.app.call("buyDispatchOffice", {});
await addShip(test.app, { id: "s2", modelId: "courier", name: "Wren" });
await addPilot(test.app, { id: "p2", name: "Ada", assignedShipId: "s2" });
await test.app.call("setPilotMode", { pilotId: "p2", mode: "independent" });
await setCredits(test.app, 0);
({ problems } = await test.app.call("getLogbook", {}));
expect(problems).toEqual([
{ kind: "emergency-fuel", shipId: o.shipId, locationId: "earth" },
{
kind: "below-floor",
credits: 0,
floor: content.config.independentPilots.defaultSpendingFloor,
},
]);
});
it("finds a Manual ship whose board has no job it can take", async () => {
const test = newTestApp();
const o = await owner(test);
const heavy = testContract({ massTonnes: 10_000, expiresAt: test.clock.now + HOUR });
await putBoard(test.app, [heavy], test.clock.now);
expect((await test.app.call("getLogbook", {})).problems).toEqual([
{ kind: "no-jobs", shipId: o.shipId, locationId: "earth" },
]);
await putBoard(test.app, [testContract({ expiresAt: test.clock.now + HOUR })], test.clock.now);
await edit(test.app, (s) => {
s.contractBoards = s.contractBoards.slice(-1);
});
expect((await test.app.call("getLogbook", {})).problems).toEqual([]);
});
});

View File

@ -1,4 +1,4 @@
import type { LogbookEntry } from "@deadhead/sim"; import type { AwayProblem, LogbookEntry } from "@deadhead/sim";
import { z } from "zod"; import { z } from "zod";
import { idSchema, timeSchema } from "./entities"; import { idSchema, timeSchema } from "./entities";
@ -70,7 +70,29 @@ export const logEntrySchema = z.discriminatedUnion("kind", [
export type LogEntry = z.infer<typeof logEntrySchema>; export type LogEntry = z.infer<typeof logEntrySchema>;
export type LogEntryKind = LogEntry["kind"]; export type LogEntryKind = LogEntry["kind"];
/** Something on the player's desk now (decision 0.5.140): a highlight from the state. */
export const awayProblemSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("no-jobs"), shipId: idSchema, locationId: idSchema }),
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 }),
]);
const windowSchema = z.object({ from: timeSchema, to: timeSchema });
/** `getLogbook`: the last 7 days, the absence now and the last one closed, and what needs the player. */
export const logbookResponseSchema = z.object({
entries: z.array(logEntrySchema),
/** Since "While you were away" was last closed, up to now. */
absence: windowSchema,
/** The absence the last closed summary covered (the Logbook's "This absence"). */
lastAbsence: windowSchema.nullable(),
problems: z.array(awayProblemSchema),
});
export type LogbookResponse = z.infer<typeof logbookResponseSchema>;
// The saved entry is the summary builder's own: a change on either side fails the build. // The saved entry is the summary builder's own: a change on either side fails the build.
type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false; type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
const _sameEntry: Same<LogEntry, LogbookEntry> = true; const _sameEntry: Same<LogEntry, LogbookEntry> = true;
void _sameEntry; const _sameProblem: Same<z.infer<typeof awayProblemSchema>, AwayProblem> = true;
void [_sameEntry, _sameProblem];

View File

@ -1,5 +1,6 @@
import type { z } from "zod"; import type { z } from "zod";
import { playerStateSchema, serverTimeSchema } from "./entities"; import { playerStateSchema, serverTimeSchema } from "./entities";
import { logbookResponseSchema } from "./logbook";
import { import {
acknowledgeSummariesRequestSchema, acknowledgeSummariesRequestSchema,
acknowledgeSummariesResponseSchema, acknowledgeSummariesResponseSchema,
@ -241,6 +242,14 @@ export const routes = {
manualDecision: false, manualDecision: false,
}, },
/** CR-022: the welcome cards are closed for this company (decision 0.5.131). */ /** CR-022: the welcome cards are closed for this company (decision 0.5.131). */
getLogbook: {
method: "GET",
path: "/api/logbook",
request: emptyRequestSchema,
response: logbookResponseSchema,
auth: true,
manualDecision: false,
},
backToWork: { backToWork: {
method: "POST", method: "POST",
path: "/api/fleet/wake", path: "/api/fleet/wake",

View File

@ -13,6 +13,7 @@ describe("route catalog", () => {
"getPlayerState", "getPlayerState",
"getContractBoard", "getContractBoard",
"getTutorialBoard", "getTutorialBoard",
"getLogbook",
"createDispatch", "createDispatch",
"buyShip", "buyShip",
"leaseBay", "leaseBay",

View File

@ -122,6 +122,7 @@ export const MAX_PILOT_LINES = 5;
export const SHORT_ABSENCE_EVENTS = 3; export const SHORT_ABSENCE_EVENTS = 3;
const EVENT_KINDS = new Set<LogbookEntry["kind"]>(["flight", "discovery", "level-up", "upgrade"]); const EVENT_KINDS = new Set<LogbookEntry["kind"]>(["flight", "discovery", "level-up", "upgrade"]);
const LISTED_DIRECTLY = new Set<AwayHighlight["kind"]>(["discovery", "level-up", "best-job"]);
/** /**
* "While you were away" from the logbook (decision 0.5.140): totals, at most 3 * "While you were away" from the logbook (decision 0.5.140): totals, at most 3
@ -139,6 +140,14 @@ export function buildAwaySummary(input: AwayInput): AwaySummary {
const deliveries = flights.filter((f) => !f.deadhead); const deliveries = flights.filter((f) => !f.deadhead);
const discoveries = of("discovery"); const discoveries = of("discovery");
const levelUps = of("level-up"); const levelUps = of("level-up");
// One level-up highlight per pilot: the highest level reached.
const topLevels = levelUps.filter(
(l) =>
!levelUps.some(
(m) =>
m.pilotId === l.pilotId && (m.level > l.level || (m.level === l.level && m.at > l.at)),
),
);
const totals = { const totals = {
net: flights.reduce((sum, f) => sum + flightNet(f), 0), net: flights.reduce((sum, f) => sum + flightNet(f), 0),
@ -158,7 +167,7 @@ export function buildAwaySummary(input: AwayInput): AwaySummary {
locationId: d.locationId, locationId: d.locationId,
pilotId: d.pilotId, pilotId: d.pilotId,
})), })),
...levelUps.map((l) => ({ ...topLevels.map((l) => ({
kind: "level-up" as const, kind: "level-up" as const,
pilotId: l.pilotId, pilotId: l.pilotId,
level: l.level, level: l.level,
@ -201,17 +210,20 @@ export function buildAwaySummary(input: AwayInput): AwaySummary {
const rest = lines.slice(MAX_PILOT_LINES); const rest = lines.slice(MAX_PILOT_LINES);
const events = entries.filter((e) => EVENT_KINDS.has(e.kind)); const events = entries.filter((e) => EVENT_KINDS.has(e.kind));
const direct = events.length <= SHORT_ABSENCE_EVENTS ? events : null;
// A short absence lists discoveries, level-ups and jobs itself: not again as highlights.
const shown = direct ? highlights.filter((h) => !LISTED_DIRECTLY.has(h.kind)) : highlights;
return { return {
durationMs: Math.max(0, input.to - input.from), durationMs: Math.max(0, input.to - input.from),
totals, totals,
highlights: highlights.slice(0, MAX_HIGHLIGHTS), highlights: shown.slice(0, MAX_HIGHLIGHTS),
moreHighlights: Math.max(0, highlights.length - MAX_HIGHLIGHTS), moreHighlights: Math.max(0, shown.length - MAX_HIGHLIGHTS),
pilots: lines.slice(0, MAX_PILOT_LINES), pilots: lines.slice(0, MAX_PILOT_LINES),
morePilots: morePilots:
rest.length > 0 rest.length > 0
? { count: rest.length, net: rest.reduce((sum, line) => sum + line.net, 0) } ? { count: rest.length, net: rest.reduce((sum, line) => sum + line.net, 0) }
: null, : null,
idlePilots: input.pilots.length - lines.length, idlePilots: input.pilots.length - lines.length,
direct: events.length <= SHORT_ABSENCE_EVENTS ? events : null, direct,
}; };
} }

View File

@ -82,7 +82,10 @@ describe("the away summary builder (Step 25.2, decision 0.5.140)", () => {
expect(summary.highlights.map((h) => h.kind)).toEqual(["discovery", "level-up", "goal-ready"]); expect(summary.highlights.map((h) => h.kind)).toEqual(["discovery", "level-up", "goal-ready"]);
expect(summary.moreHighlights).toBe(3); expect(summary.moreHighlights).toBe(3);
const fewer = summarise(all.slice(0, 2), { problems: [problem] }); // Four flights: not a short absence, so every highlight counts.
const fewer = summarise([...all.slice(0, 2), flight("p1", 10), flight("p1", 20)], {
problems: [problem],
});
expect(fewer.highlights).toEqual([ expect(fewer.highlights).toEqual([
{ kind: "scripted", scriptedId: "unmarked-crates" }, { kind: "scripted", scriptedId: "unmarked-crates" },
{ {
@ -135,6 +138,13 @@ describe("the away summary builder (Step 25.2, decision 0.5.140)", () => {
other({ kind: "refuel", locationId: "earth", boughtFu: 1, cost: 5, source: "bought" }), other({ kind: "refuel", locationId: "earth", boughtFu: 1, cost: 5, source: "bought" }),
]; ];
expect(summarise(quiet).direct!.map((e) => e.kind)).toEqual(["flight"]); expect(summarise(quiet).direct!.map((e) => e.kind)).toEqual(["flight"]);
// What the list shows isn't a highlight too, and isn't counted as "more".
const listed = summarise(
[flight("p0", 500), other({ kind: "discovery", locationId: "mars" })],
{ problems: [{ kind: "below-floor", credits: 10, floor: 500 }] },
);
expect(listed.highlights.map((h) => h.kind)).toEqual(["problem"]);
expect(listed.moreHighlights).toBe(0);
}); });
it("covers only the absence, and says how long it was", () => { it("covers only the absence, and says how long it was", () => {
@ -145,3 +155,18 @@ describe("the away summary builder (Step 25.2, decision 0.5.140)", () => {
expect(summary.durationMs).toBe(10 * HOUR); expect(summary.durationMs).toBe(10 * HOUR);
}); });
}); });
describe("level-ups in the highlights", () => {
it("shows one per pilot, at the highest level reached", () => {
const summary = summarise([
other({ kind: "level-up", pilotId: "p0", level: 2, skillPoints: 2 }),
other({ kind: "level-up", pilotId: "p0", level: 3, skillPoints: 3 }),
other({ kind: "level-up", pilotId: "p1", level: 1, skillPoints: 1 }),
flight("p2", 0, { goodId: null, deadhead: true }),
]);
expect(summary.highlights).toEqual([
{ kind: "level-up", pilotId: "p0", level: 3, skillPoints: 3 },
{ kind: "level-up", pilotId: "p1", level: 1, skillPoints: 1 },
]);
});
});