Step 25.3: client: highlights pop-up
CI / check (push) Successful in 7m44s
Details
CI / check (push) Successful in 7m44s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c7044ad2d1
commit
32b34a5d6c
11
DECISIONS.md
11
DECISIONS.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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}>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
<SleepBanner />
|
<SleepBanner />
|
||||||
<PilotLogbook logbook={logbook} />
|
{!summary ? (
|
||||||
<ul className="mt-4 space-y-3">
|
<p className="text-ink-400">Reading the logbook…</p>
|
||||||
{yours.map((s) => (
|
) : summary.direct ? (
|
||||||
<li
|
<ul aria-label="What happened" className="space-y-1.5">
|
||||||
key={s.dispatchId}
|
{summary.direct.map((entry) => (
|
||||||
className="rounded-xl border border-space-600 bg-space-950/50 p-3 text-sm"
|
<li key={entry.id} className="flex items-start gap-2 text-ink-100">
|
||||||
>
|
<Icon name="send" className="mt-0.5 size-4 shrink-0 text-cyan-300" />
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
{directText(entry as LogEntry, state)}
|
||||||
<span className="font-display font-bold tracking-wide">
|
</li>
|
||||||
{shipName(s.shipId, s.tutorial)}
|
))}
|
||||||
</span>
|
</ul>
|
||||||
<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">
|
<Totals summary={summary} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
{summary && highlights.length > 0 && (
|
||||||
|
<ul aria-label="Highlights" className="space-y-1.5">
|
||||||
|
{highlights.map((h, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-amber-100">
|
||||||
|
<Icon
|
||||||
|
name={h.kind === "problem" ? "alert" : "star"}
|
||||||
|
className="mt-0.5 size-4 shrink-0 text-amber-300"
|
||||||
|
/>
|
||||||
|
{highlightText(h, state)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{outOfShop.map((ship) => (
|
{summary.moreHighlights > 0 && (
|
||||||
<li
|
<li className="pl-6 text-ink-400">
|
||||||
key={`shop:${ship.id}`}
|
{fill(words.moreHighlights, { count: summary.moreHighlights })}
|
||||||
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>
|
</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>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
{summary && !summary.direct && <PilotLines summary={summary} />}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="success"
|
variant="secondary"
|
||||||
size="sm"
|
className="flex-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dismiss();
|
dismiss();
|
||||||
void navigate({ pathname: "/", hash: "#goals" });
|
navigate("/logbook?range=absence");
|
||||||
}}
|
}}
|
||||||
className="mt-3"
|
|
||||||
>
|
>
|
||||||
Claim on the Bridge
|
{words.viewLogbook}
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1" onClick={dismiss}>
|
||||||
|
{words.done}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<Button block onClick={dismiss} className="mt-4">
|
|
||||||
Got it
|
|
||||||
</Button>
|
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 summaries: ResolutionSummary[] = [
|
const pilot = (i: number): Pilot => ({
|
||||||
{
|
...samplePlayerState().pilots[0]!,
|
||||||
dispatchId: "d1",
|
id: `p${i}`,
|
||||||
shipId: "s1",
|
name: i === 0 ? "Rosa" : `Pilot ${i}`,
|
||||||
pilotId: "avatar",
|
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",
|
originId: "earth",
|
||||||
destinationId: "mars",
|
destinationId: "moon",
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
credits: 51_200,
|
independent: true,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
independent: false,
|
fuelCost: 100,
|
||||||
fuelCost: 0,
|
|
||||||
topUp: null,
|
topUp: null,
|
||||||
goodId: "consumer-goods",
|
goodId: "consumer-goods",
|
||||||
massTonnes: 12,
|
massTonnes: 6,
|
||||||
shipper: "Pacific Orbital Logistics",
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
credits: 700,
|
||||||
loanRepayment: 0,
|
loanRepayment: 0,
|
||||||
pilotPay: 0,
|
pilotPay: 200,
|
||||||
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,
|
|
||||||
topUp: null,
|
|
||||||
goodId: "consumer-goods",
|
|
||||||
massTonnes: 12,
|
|
||||||
shipper: "Pacific Orbital Logistics",
|
|
||||||
loanRepayment: 0,
|
|
||||||
pilotPay: 0,
|
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
discoveredLocationId: null,
|
discoveredLocationId: null,
|
||||||
scriptedId: null,
|
scriptedId: null,
|
||||||
restEndsAt: null,
|
restEndsAt: null,
|
||||||
resolvedAt: 2,
|
resolvedAt: NOW - 60_000,
|
||||||
},
|
};
|
||||||
];
|
|
||||||
|
|
||||||
describe("while you were away", () => {
|
let n = 0;
|
||||||
it("names what was delivered, how much, for whom and where (CR-007)", async () => {
|
const flight = (pilotId: string, payout: number, over: Partial<LogEntry> = {}): LogEntry =>
|
||||||
const delivery: ResolutionSummary = {
|
({
|
||||||
...summaries[0]!,
|
id: `f${n++}`,
|
||||||
|
at: FROM + 60_000 + n,
|
||||||
|
pilotId,
|
||||||
|
shipId: `s${pilotId.slice(1)}`,
|
||||||
|
kind: "flight",
|
||||||
|
dispatchId: `d${n}`,
|
||||||
|
deadhead: false,
|
||||||
|
tutorial: false,
|
||||||
|
independent: true,
|
||||||
|
originId: "earth",
|
||||||
destinationId: "moon",
|
destinationId: "moon",
|
||||||
goodId: "medical-supplies",
|
goodId: "helium-3",
|
||||||
massTonnes: 6,
|
massTonnes: 6,
|
||||||
shipper: "Meridian Health",
|
shipper: "Pacific Orbital Logistics",
|
||||||
discoveredLocationId: null,
|
payout,
|
||||||
};
|
pilotPay: 0,
|
||||||
renderApp(
|
fuelCost: 0,
|
||||||
fakeApi({
|
loanRepayment: 0,
|
||||||
getPlayerState: () =>
|
xp: 10,
|
||||||
samplePlayerState({ tutorial: null, ships: [ship], summaries: [delivery] }),
|
scriptedId: null,
|
||||||
}),
|
...over,
|
||||||
);
|
}) as LogEntry;
|
||||||
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
|
|
||||||
expect(within(dialog).getAllByRole("listitem")[0]).toHaveTextContent(
|
|
||||||
"Delivered 6 t Medical Supplies for Meridian Health to the Moon",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a deadhead as an empty flight with its fuel, not as a +0 payout", async () => {
|
function render(pilots: number, entries: LogEntry[], over: Partial<LogbookResponse> = {}) {
|
||||||
const deadhead: ResolutionSummary = {
|
let seen = false;
|
||||||
...summaries[0]!,
|
const all = Array.from({ length: pilots }, (_, i) => pilot(i));
|
||||||
dispatchId: "d3",
|
|
||||||
destinationId: "moon",
|
|
||||||
deadhead: true,
|
|
||||||
independent: false,
|
|
||||||
fuelCost: 230,
|
|
||||||
topUp: null,
|
|
||||||
goodId: null,
|
|
||||||
massTonnes: null,
|
|
||||||
shipper: null,
|
|
||||||
credits: 0,
|
|
||||||
pilotXp: 0,
|
|
||||||
discoveredLocationId: null,
|
|
||||||
};
|
|
||||||
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 () => {
|
|
||||||
let acknowledged = false;
|
|
||||||
const api = fakeApi({
|
const api = fakeApi({
|
||||||
getPlayerState: () =>
|
getPlayerState: () =>
|
||||||
samplePlayerState({
|
samplePlayerState({
|
||||||
tutorial: null,
|
tutorial: null,
|
||||||
ships: [ship],
|
pilots: [samplePlayerState().pilots[0]!, ...all],
|
||||||
summaries: acknowledged ? [] : summaries,
|
ships: all.map((p, i) => ship(`s${i}`, p.id)),
|
||||||
|
summaries: seen ? [] : [pending],
|
||||||
}),
|
}),
|
||||||
acknowledgeSummaries: ({ dispatchIds }) => {
|
getLogbook: () => ({
|
||||||
acknowledged = true;
|
entries,
|
||||||
return { acknowledged: dispatchIds.length };
|
absence: { from: FROM, to: NOW },
|
||||||
|
lastAbsence: null,
|
||||||
|
problems: [],
|
||||||
|
...over,
|
||||||
|
}),
|
||||||
|
acknowledgeSummaries: () => {
|
||||||
|
seen = true;
|
||||||
|
return { acknowledged: 1 };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
renderApp(api);
|
renderApp(api);
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
|
const dialog = () => screen.findByRole("dialog", { name: /While you were away/ });
|
||||||
const items = within(dialog).getAllByRole("listitem");
|
|
||||||
expect(items).toHaveLength(2);
|
|
||||||
|
|
||||||
expect(items[0]).toHaveTextContent("Dusty Mule");
|
describe("the highlights pop-up (Step 25.3, decision 0.5.140)", () => {
|
||||||
expect(items[0]).toHaveTextContent("Earth → Mars");
|
beforeEach(() => {
|
||||||
expect(items[0]).toHaveTextContent("+51,200 cr");
|
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||||
expect(items[0]).toHaveTextContent("+32 XP");
|
vi.setSystemTime(NOW);
|
||||||
expect(items[0]).toHaveTextContent("Discovered Mars!");
|
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
expect(items[1]).toHaveTextContent("Company loaner");
|
it("titles the absence with its length", async () => {
|
||||||
expect(items[1]).toHaveTextContent("Moon → Earth");
|
render(1, [flight("p0", 410)]);
|
||||||
expect(items[1]).not.toHaveTextContent("Discovered");
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
await userEvent.click(within(dialog).getByRole("button", { name: "Got it" }));
|
it("lists a short absence's events directly: no totals or pilot lines", async () => {
|
||||||
|
render(2, [
|
||||||
|
flight("p0", 410, { goodId: "consumer-goods" }),
|
||||||
|
flight("p1", 0, { deadhead: true, goodId: null, fuelCost: 230 }),
|
||||||
|
]);
|
||||||
|
const list = await within(await dialog()).findByRole("list", { name: "What happened" });
|
||||||
|
expect(
|
||||||
|
within(list)
|
||||||
|
.getAllByRole("listitem")
|
||||||
|
.map((li) => li.textContent),
|
||||||
|
).toEqual([
|
||||||
|
"Dusty Mule delivered Consumer Goods to Moon, +410 cr",
|
||||||
|
"Ship s1 flew empty to Moon, 230 cr fuel",
|
||||||
|
]);
|
||||||
|
expect(screen.queryByLabelText("Company totals")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("list", { name: "Pilots" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with 50 active pilots shows totals, 3 highlights, 5 pilot lines and the rest summed", async () => {
|
||||||
|
const entries = Array.from({ length: 50 }, (_, i) => [
|
||||||
|
flight(`p${i}`, (i + 1) * 100, { fuelCost: 10 }),
|
||||||
|
flight(`p${i}`, (i + 1) * 100, { fuelCost: 10 }),
|
||||||
|
]).flat();
|
||||||
|
entries.push(
|
||||||
|
{
|
||||||
|
id: "x1",
|
||||||
|
at: FROM + 5,
|
||||||
|
pilotId: "p3",
|
||||||
|
shipId: "s3",
|
||||||
|
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" }],
|
||||||
|
});
|
||||||
|
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 highlights = within(sheet).getByRole("list", { name: "Highlights" });
|
||||||
|
expect(
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lines = within(sheet).getByRole("list", { name: "Pilots" });
|
||||||
|
const items = within(lines).getAllByRole("listitem");
|
||||||
|
// 5 pilots, the other 45, and the Captain, who flew nothing.
|
||||||
|
expect(items).toHaveLength(7);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds +N more lines only when needed, and sums idle pilots", async () => {
|
||||||
|
render(4, [flight("p0", 100), flight("p0", 100), flight("p1", 50), flight("p1", 50)]);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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"] }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 }) }),
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ describe("route catalog", () => {
|
||||||
"getPlayerState",
|
"getPlayerState",
|
||||||
"getContractBoard",
|
"getContractBoard",
|
||||||
"getTutorialBoard",
|
"getTutorialBoard",
|
||||||
|
"getLogbook",
|
||||||
"createDispatch",
|
"createDispatch",
|
||||||
"buyShip",
|
"buyShip",
|
||||||
"leaseBay",
|
"leaseBay",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue