diff --git a/DECISIONS.md b/DECISIONS.md index 358f74d..7e664d4 100644 --- a/DECISIONS.md +++ b/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. - **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. + +## 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. diff --git a/apps/web/e2e/12-3-first-ship.e2e.ts b/apps/web/e2e/12-3-first-ship.e2e.ts index 416626a..8f89969 100644 --- a/apps/web/e2e/12-3-first-ship.e2e.ts +++ b/apps/web/e2e/12-3-first-ship.e2e.ts @@ -29,7 +29,8 @@ test.describe("end to end (Phase 12)", () => { const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!; await advanceTo(page, flight.arrivalAt); 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); const after = await state(page); diff --git a/apps/web/e2e/12-4-offline-progress.e2e.ts b/apps/web/e2e/12-4-offline-progress.e2e.ts index 935dbac..1c706e3 100644 --- a/apps/web/e2e/12-4-offline-progress.e2e.ts +++ b/apps/web/e2e/12-4-offline-progress.e2e.ts @@ -1,3 +1,4 @@ +import { content } from "@deadhead/content"; import { expect, test } from "@playwright/test"; import { dispatchFromEarthBoard, logbook, playToDeal, serverNow, state } from "./support"; @@ -26,7 +27,8 @@ test.describe("end to end (Phase 12)", () => { await later.goto("/"); 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); // Credits went up by the delivery's net payout, less any Auto top-up on landing // (CR-015); the ship is at the destination. diff --git a/apps/web/e2e/12-5-discovery.e2e.ts b/apps/web/e2e/12-5-discovery.e2e.ts index 97fed53..a6ef43f 100644 --- a/apps/web/e2e/12-5-discovery.e2e.ts +++ b/apps/web/e2e/12-5-discovery.e2e.ts @@ -60,7 +60,7 @@ test.describe("end to end (Phase 12)", () => { expect(flight, "the Clipper and veteran can take it").not.toBeNull(); await advanceTo(page, flight!.arrivalAt); await page.goto("/"); - await expect(logbook(page)).toContainText("Discovered Mars!"); + await expect(logbook(page)).toContainText("opened a new market: Mars"); await dismissLogbook(page); // Mars is discovered on the Locations screen and can be leased; its board is open without a bay (CR-002). diff --git a/apps/web/e2e/25-3-away-fits.e2e.ts b/apps/web/e2e/25-3-away-fits.e2e.ts new file mode 100644 index 0000000..8e641e1 --- /dev/null +++ b/apps/web/e2e/25-3-away-fits.e2e.ts @@ -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(); + }); +}); diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx index 08af687..de08506 100644 --- a/apps/web/src/components/AwaySummary.tsx +++ b/apps/web/src/components/AwaySummary.tsx @@ -1,22 +1,184 @@ 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 { useNavigate } from "react-router"; +import { useApi } from "../api/context"; import { useClock } from "../clock/clock"; -import { formatCredits, locationName, toLocation } from "../format"; -import { formatLocalTime } from "../screens/DispatchSheet"; +import { fill, formatCredits, locationName } from "../format"; import { useGameAction, useLoadedPlayer } from "../state/actions"; import { Button, Icon, Sheet } from "../ui"; 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): 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() { const state = useLoadedPlayer(); const acknowledge = useGameAction("acknowledgeSummaries"); // Hide as soon as the player dismisses; the refetch clears the list for real. const [dismissed, setDismissed] = useState>(new Set()); - const navigate = useNavigate(); const now = useClock((s) => s.now); const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId)); @@ -24,15 +186,10 @@ export function AwaySummary() { const outOfShop = state.ships.filter( (s) => s.shop !== null && s.shop.until <= now && !dismissed.has(`ship:${s.id}`), ); - // Goals waiting to be claimed (Step 10A.17). - const readyGoals = state.goals - .filter((g) => g.status === "ready") - .map((g) => content.onboardingGoal(g.id)) - .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 open = summaries.length > 0 || outOfShop.length > 0; + const refresh = [...summaries.map((s) => s.dispatchId), ...outOfShop.map((s) => s.id)].join(); + const summary = useAwaySummary(state, refresh, open); + if (!open) return null; const dismiss = () => { const ids = summaries.map((s) => s.dispatchId); @@ -41,134 +198,131 @@ export function AwaySummary() { acknowledge.mutate({ dispatchIds: ids, ...(shipIds.length > 0 ? { shipIds } : {}) }); }; - // Independent pilots' own trips (CR-021) are listed by pilot, not one card each. - const logbook = pilotLogbook(state, summaries); - const yours = summaries.filter((s) => !s.independent); + const title = fill(words.title, { duration: summary ? formatAbsence(summary.durationMs) : "…" }); + const highlights = summary?.highlights ?? []; return ( - - - -
    - {yours.map((s) => ( -
  • -
    - - {shipName(s.shipId, s.tutorial)} - - - {locationName(s.originId)} → {locationName(s.destinationId)} - -
    - {!s.deadhead && s.goodId && s.massTonnes !== null && ( -

    - Delivered {s.massTonnes} t {content.good(s.goodId)?.name ?? s.goodId} - {s.shipper && ` for ${s.shipper}`} {toLocation(s.destinationId)} -

    - )} - {s.deadhead ? ( - // An empty flight earns nothing: say what it was and what it cost. -
    - - Deadhead · {formatCredits(s.fuelCost)} fuel used -
    - ) : ( -
    - = 0 ? "text-emerald-300" : "text-rose-300"}> - {s.credits >= 0 ? "+" : ""} - {formatCredits(s.credits)} - - +{s.pilotXp} XP -
    - )} - {s.pilotPay > 0 && ( -
    - {formatCredits(s.pilotPay)} pilot share to{" "} - {state.pilots.find((p) => p.id === s.pilotId)?.name ?? "the pilot"} -
    - )} - {s.topUp && ( -
    - Topped up {formatFu(s.topUp.boughtFu)} at {locationName(s.topUp.locationId)},{" "} - {formatCredits(s.topUp.cost)} -
    - )} - {s.loanRepayment > 0 && ( -
    - {formatCredits(s.loanRepayment)} went to the lender -
    - )} - {s.scriptedId && ( -

    - { - content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId) - ?.flavorMessage - } -

    - )} - {s.discoveredLocationId && ( -
    - - Discovered {locationName(s.discoveredLocationId)}! -
    - )} - {s.restEndsAt !== null && ( -
    - - - {state.pilots.find((p) => p.id === s.pilotId)?.name ?? "The pilot"} is resting - until {formatLocalTime(s.restEndsAt)} - -
    - )} -
  • - ))} - {outOfShop.map((ship) => ( -
  • - - {ship.name} is out of the shop:{" "} - {content.upgrade(ship.shop!.upgradeId)?.name ?? ship.shop!.upgradeId} {ship.shop!.level} -
  • - ))} -
- {readyGoals.length > 0 && ( -
-

- - Goals ready to claim -

-
    - {readyGoals.map((goal) => ( -
  • - {goal.title}{" "} - - +{formatCredits(goal.reward)} - + +
    + + {!summary ? ( +

    Reading the logbook…

    + ) : summary.direct ? ( +
      + {summary.direct.map((entry) => ( +
    • + + {directText(entry as LogEntry, state)}
    • ))}
    - -
    - )} - + ) : ( + + )} + {summary && highlights.length > 0 && ( +
      + {highlights.map((h, i) => ( +
    • + + {highlightText(h, state)} +
    • + ))} + {summary.moreHighlights > 0 && ( +
    • + {fill(words.moreHighlights, { count: summary.moreHighlights })} +
    • + )} +
    + )} + {summary && !summary.direct && } +
+
+ + +
); } + +/** 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 ( +
+ {figures.map(([label, value, tone]) => ( +
+
+ {label} +
+
{value}
+
+ ))} +
+ ); +} + +/** 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 ( + + ); +} diff --git a/apps/web/src/format.ts b/apps/web/src/format.ts index d7027b7..aa9782f 100644 --- a/apps/web/src/format.ts +++ b/apps/web/src/format.ts @@ -4,6 +4,13 @@ export function formatCredits(credits: number): string { 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 { + 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". */ export function formatDuration(ms: number): string { const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); diff --git a/apps/web/test/away-goals.test.tsx b/apps/web/test/away-goals.test.tsx index 6cffe2d..450ac21 100644 --- a/apps/web/test/away-goals.test.tsx +++ b/apps/web/test/away-goals.test.tsx @@ -57,7 +57,7 @@ function returningPlayer(acknowledged: boolean): PlayerState { } 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; const api = fakeApi({ getPlayerState: () => returningPlayer(acknowledged), @@ -68,29 +68,24 @@ describe("something waiting on return", () => { }); renderApp(api, "/fleet"); - const dialog = await screen.findByRole("dialog", { name: "While you were away" }); - expect(dialog).toHaveTextContent("Dusty Mule"); - const ready = within(dialog).getByRole("list", { name: "Ready goals" }); - expect(ready).toHaveTextContent(content.onboardingGoal("first-own-delivery")!.title); - - 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(); + const dialog = await screen.findByRole("dialog", { name: /While you were away/ }); + const title = content.onboardingGoal("first-own-delivery")!.title; + expect(await within(dialog).findByText(`Goal ready to claim: ${title}`)).toBeInTheDocument(); + await userEvent.click(within(dialog).getByRole("button", { name: "Done" })); await waitFor(() => 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 quiet = { ...state, goals: state.goals.map((g) => ({ ...g, status: "incomplete" as const })), }; renderApp(fakeApi({ getPlayerState: () => quiet })); - const dialog = await screen.findByRole("dialog", { name: "While you were away" }); - expect(within(dialog).queryByRole("list", { name: "Ready goals" })).not.toBeInTheDocument(); + const dialog = await screen.findByRole("dialog", { name: /While you were away/ }); + await within(dialog).findByRole("list", { name: "What happened" }); + expect(dialog).not.toHaveTextContent("Goal ready"); }); }); diff --git a/apps/web/test/away-summary.test.tsx b/apps/web/test/away-summary.test.tsx index 1b8bf87..5683272 100644 --- a/apps/web/test/away-summary.test.tsx +++ b/apps/web/test/away-summary.test.tsx @@ -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 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"; -const ship: Ship = { - id: "s1", +const NOW = Date.parse("2100-01-01T12:00:00Z"); +const FROM = NOW - (9 * 60 + 40) * 60_000; + +const ship = (id: string, pilotId: string): Ship => ({ + id, modelId: "courier", - name: "Dusty Mule", + name: id === "s0" ? "Dusty Mule" : `Ship ${id}`, status: "idle", - locationId: "mars", + locationId: "moon", dispatchId: null, lastArrivalAt: 0, ...shipExtras("courier"), - pilotId: "avatar", + pilotId, +}); + +const pilot = (i: number): Pilot => ({ + ...samplePlayerState().pilots[0]!, + id: `p${i}`, + name: i === 0 ? "Rosa" : `Pilot ${i}`, + isAvatar: false, + assignedShipId: `s${i}`, + locationId: "moon", + ...pilotExtras, + mode: "independent", +}); + +/** A summary that keeps the pop-up open (the trigger is unchanged: unseen deliveries). */ +const pending: ResolutionSummary = { + dispatchId: "d0", + shipId: "s0", + pilotId: "p0", + originId: "earth", + destinationId: "moon", + tutorial: false, + independent: true, + deadhead: false, + fuelCost: 100, + topUp: null, + goodId: "consumer-goods", + massTonnes: 6, + shipper: "Pacific Orbital Logistics", + credits: 700, + loanRepayment: 0, + pilotPay: 200, + pilotXp: 10, + discoveredLocationId: null, + scriptedId: null, + restEndsAt: null, + resolvedAt: NOW - 60_000, }; -const summaries: ResolutionSummary[] = [ - { - dispatchId: "d1", - shipId: "s1", - pilotId: "avatar", - originId: "earth", - destinationId: "mars", +let n = 0; +const flight = (pilotId: string, payout: number, over: Partial = {}): LogEntry => + ({ + id: `f${n++}`, + at: FROM + 60_000 + n, + pilotId, + shipId: `s${pilotId.slice(1)}`, + kind: "flight", + dispatchId: `d${n}`, + deadhead: false, tutorial: false, - credits: 51_200, - deadhead: false, - independent: false, - fuelCost: 0, - topUp: null, - goodId: "consumer-goods", - massTonnes: 12, + independent: true, + originId: "earth", + destinationId: "moon", + goodId: "helium-3", + massTonnes: 6, shipper: "Pacific Orbital Logistics", - loanRepayment: 0, + payout, pilotPay: 0, - pilotXp: 32, - discoveredLocationId: "mars", - scriptedId: null, - restEndsAt: null, - resolvedAt: 1, - }, - { - dispatchId: "d2", - shipId: "loaner", - pilotId: "avatar", - originId: "moon", - destinationId: "earth", - tutorial: true, - credits: 240, - deadhead: false, - independent: false, fuelCost: 0, - topUp: null, - goodId: "consumer-goods", - massTonnes: 12, - shipper: "Pacific Orbital Logistics", loanRepayment: 0, - pilotPay: 0, - pilotXp: 10, - discoveredLocationId: null, + xp: 10, scriptedId: null, - restEndsAt: null, - resolvedAt: 2, - }, -]; + ...over, + }) as LogEntry; -describe("while you were away", () => { - it("names what was delivered, how much, for whom and where (CR-007)", async () => { - const delivery: ResolutionSummary = { - ...summaries[0]!, - destinationId: "moon", - goodId: "medical-supplies", - massTonnes: 6, - shipper: "Meridian Health", - discoveredLocationId: null, - }; - renderApp( - fakeApi({ - getPlayerState: () => - samplePlayerState({ tutorial: null, ships: [ship], summaries: [delivery] }), +function render(pilots: number, entries: LogEntry[], over: Partial = {}) { + let seen = false; + const all = Array.from({ length: pilots }, (_, i) => pilot(i)); + const api = fakeApi({ + getPlayerState: () => + samplePlayerState({ + tutorial: null, + pilots: [samplePlayerState().pilots[0]!, ...all], + ships: all.map((p, i) => ship(`s${i}`, p.id)), + summaries: seen ? [] : [pending], }), - ); - 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", - ); + getLogbook: () => ({ + entries, + absence: { from: FROM, to: NOW }, + lastAbsence: null, + problems: [], + ...over, + }), + acknowledgeSummaries: () => { + seen = true; + return { acknowledged: 1 }; + }, + }); + renderApp(api); + return api; +} + +const dialog = () => screen.findByRole("dialog", { name: /While you were away/ }); + +describe("the highlights pop-up (Step 25.3, decision 0.5.140)", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("titles the absence with its length", async () => { + render(1, [flight("p0", 410)]); + const sheet = await dialog(); + await within(sheet).findByRole("list", { name: "What happened" }); + expect(sheet).toHaveAccessibleName("While you were away · 9 h 40 m"); + expect([formatAbsence(12 * 60_000), formatAbsence(50 * 3_600_000)]).toEqual([ + "12 m", + "2 d 2 h", + ]); }); - it("shows a deadhead as an empty flight with its fuel, not as a +0 payout", async () => { - const deadhead: ResolutionSummary = { - ...summaries[0]!, - 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 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("lists each completed delivery, and dismissing acknowledges them and hides the dialog", async () => { - let acknowledged = false; - const api = fakeApi({ - getPlayerState: () => - samplePlayerState({ - tutorial: null, - ships: [ship], - summaries: acknowledged ? [] : summaries, - }), - acknowledgeSummaries: ({ dispatchIds }) => { - acknowledged = true; - return { acknowledged: dispatchIds.length }; + 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" }], }); - renderApp(api); + const sheet = await dialog(); + const totals = await within(sheet).findByLabelText("Company totals"); + const net = entries.reduce( + (sum, e) => sum + (e.kind === "flight" ? e.payout - e.fuelCost : 0), + 0, + ); + expect(totals).toHaveTextContent(`Net earned+${net.toLocaleString("en-US")} cr`); + expect(totals).toHaveTextContent("Deliveries100"); + expect(totals).toHaveTextContent("Discoveries1"); + expect(totals).not.toHaveTextContent("Loan repaid"); - const dialog = await screen.findByRole("dialog", { name: "While you were away" }); - const items = within(dialog).getAllByRole("listitem"); - expect(items).toHaveLength(2); + 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", + ]); - expect(items[0]).toHaveTextContent("Dusty Mule"); - expect(items[0]).toHaveTextContent("Earth → Mars"); - expect(items[0]).toHaveTextContent("+51,200 cr"); - expect(items[0]).toHaveTextContent("+32 XP"); - expect(items[0]).toHaveTextContent("Discovered Mars!"); + 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(); + }); - expect(items[1]).toHaveTextContent("Company loaner"); - expect(items[1]).toHaveTextContent("Moon → Earth"); - expect(items[1]).not.toHaveTextContent("Discovered"); + 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(); + }); - await userEvent.click(within(dialog).getByRole("button", { name: "Got it" })); + it("closes on Done, acknowledging what it showed", async () => { + const api = render(1, [flight("p0", 410)]); + const sheet = await dialog(); + await within(sheet).findByRole("list", { name: "What happened" }); + await userEvent.click(within(sheet).getByRole("button", { name: "Done" })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); await waitFor(() => - expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d1", "d2"] }), + expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: ["d0"] }), ); }); diff --git a/apps/web/test/board-logbook.test.tsx b/apps/web/test/board-logbook.test.tsx index f0206b6..88c1d8a 100644 --- a/apps/web/test/board-logbook.test.tsx +++ b/apps/web/test/board-logbook.test.tsx @@ -4,7 +4,6 @@ import { act, render, screen, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; import { CatchingUp, pilotLogbook } from "../src/components/PilotLogbook"; -import { formatCredits } from "../src/format"; import { pilotExtras } from "./fixtures"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; @@ -222,18 +221,4 @@ describe("pilot logbook (Step 21.7)", () => { }); 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); - }); }); diff --git a/apps/web/test/dashboard.test.tsx b/apps/web/test/dashboard.test.tsx index 46713f8..b8800ac 100644 --- a/apps/web/test/dashboard.test.tsx +++ b/apps/web/test/dashboard.test.tsx @@ -171,8 +171,11 @@ describe("loan on HQ", () => { renderApp( fakeApi({ getPlayerState: () => state, acknowledgeSummaries: () => ({ acknowledged: 1 }) }), ); - const dialog = await screen.findByRole("dialog", { name: "While you were away" }); - expect(dialog).toHaveTextContent("230 cr went to the lender"); + const dialog = await screen.findByRole("dialog", { name: /While you were away/ }); + // 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"), + ); }); }); diff --git a/apps/web/test/deal-modal.test.tsx b/apps/web/test/deal-modal.test.tsx index fbd8244..41ef0cf 100644 --- a/apps/web/test/deal-modal.test.tsx +++ b/apps/web/test/deal-modal.test.tsx @@ -122,7 +122,7 @@ describe("the deal", () => { fakeApi({ getPlayerState: withSummary, getTutorialBoard: () => sampleTutorialBoard() }), "/contracts", ); - await screen.findByRole("dialog", { name: "While you were away" }); + await screen.findByRole("dialog", { name: /While you were away/ }); expect( screen.queryByRole("dialog", { name: "An offer from Halvorsen" }), ).not.toBeInTheDocument(); diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts index a003453..b877565 100644 --- a/apps/web/test/fixtures.ts +++ b/apps/web/test/fixtures.ts @@ -1,5 +1,5 @@ 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"; // Plain data builders shared by component tests and the Playwright tests (no Vitest imports). @@ -100,3 +100,76 @@ export function samplePlayerState(overrides: Partial = {}): PlayerS ...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: [], + }; +} diff --git a/apps/web/test/fleet-sleep.test.tsx b/apps/web/test/fleet-sleep.test.tsx index dd375c5..0de8975 100644 --- a/apps/web/test/fleet-sleep.test.tsx +++ b/apps/web/test/fleet-sleep.test.tsx @@ -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 () => { const api = sleepyApi({ summaries: [summary] }); 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"); expect(banner).toHaveTextContent( /^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 () => { 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(); }); diff --git a/apps/web/test/pilot-share.test.tsx b/apps/web/test/pilot-share.test.tsx index 9453388..f07f426 100644 --- a/apps/web/test/pilot-share.test.tsx +++ b/apps/web/test/pilot-share.test.tsx @@ -2,7 +2,7 @@ import { pilotExtras } from "./fixtures"; import { content } from "@deadhead/content"; import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema"; 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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)}`); }); - 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]) }), "/"); - const dialog = await screen.findByRole("dialog", { name: "While you were away" }); - expect(dialog).toHaveTextContent(`${formatCredits(63)} pilot share to Rosa Kell`); + const dialog = await screen.findByRole("dialog", { name: /While you were away/ }); + // 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)}`)); }); }); diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx index 0114f92..1438473 100644 --- a/apps/web/test/render.tsx +++ b/apps/web/test/render.tsx @@ -6,6 +6,7 @@ import { vi } from "vitest"; import { App } from "../src/App"; import { ApiError, type ApiClient } from "../src/api/client"; import { ApiProvider } from "../src/api/context"; +import { logbookFrom } from "./fixtures"; type Handlers = { [N in RouteName]?: (input: RouteRequest) => RouteResponse | Promise>; @@ -13,13 +14,18 @@ type Handlers = { /** * 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) { const call = vi.fn(async (name: RouteName, input: unknown) => { if (name === "getServerTime" && !handlers.getServerTime) { 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; if (!handler) throw new ApiError(404, "not-found", `No fake for ${name}`); return handler(input); diff --git a/apps/web/test/rest-summary-hq.test.tsx b/apps/web/test/rest-summary-hq.test.tsx index e4ceac2..a19d769 100644 --- a/apps/web/test/rest-summary-hq.test.tsx +++ b/apps/web/test/rest-summary-hq.test.tsx @@ -1,9 +1,8 @@ import { pilotExtras } from "./fixtures"; -import type { Pilot, ResolutionSummary } from "@deadhead/schema"; -import { screen, within } from "@testing-library/react"; +import type { Pilot } from "@deadhead/schema"; +import { screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; -import { formatLocalTime } from "../src/screens/DispatchSheet"; import { fakeApi, renderApp, samplePlayerState } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -28,31 +27,6 @@ const pilot = (id: string, name: string, extra: Partial = {}): Pilot => ( ...extra, }); -const summary = (extra: Partial): 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 = [ pilot("p-rae", "Rae Okafor", { resting: true, restEndsAt: NOW + 40 * 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()); - 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 () => { renderApp( fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null, pilots }) }), diff --git a/apps/web/test/shop-summary-hq.test.tsx b/apps/web/test/shop-summary-hq.test.tsx index 4eb737b..14e1149 100644 --- a/apps/web/test/shop-summary-hq.test.tsx +++ b/apps/web/test/shop-summary-hq.test.tsx @@ -47,11 +47,13 @@ describe("the shop in summaries and on HQ (Step 10I.8)", () => { }), }); renderApp(api, "/"); - const dialog = await screen.findByRole("dialog", { name: "While you were away" }); - expect(dialog).toHaveTextContent("Slow Ox is out of the shop: Engine Tuning 2"); + const dialog = await screen.findByRole("dialog", { name: /While you were away/ }); + await waitFor(() => + expect(dialog).toHaveTextContent("Slow Ox is out of the shop: Engine Tuning level 2"), + ); 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(() => expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", { dispatchIds: [], diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index 3f1ca0d..efc53a5 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -162,7 +162,7 @@ describe("scripted delivery summary", () => { acknowledgeSummaries: () => ({ acknowledged: 1 }), }), ); - const dialog = await screen.findByRole("dialog", { name: "While you were away" }); - expect(dialog).toHaveTextContent(flavor); + const dialog = await screen.findByRole("dialog", { name: /While you were away/ }); + await waitFor(() => expect(dialog).toHaveTextContent(flavor)); }); }); diff --git a/docs/screenshots/25-3-away-desktop.png b/docs/screenshots/25-3-away-desktop.png new file mode 100644 index 0000000..2e1e0f2 Binary files /dev/null and b/docs/screenshots/25-3-away-desktop.png differ diff --git a/docs/screenshots/25-3-away-phone.png b/docs/screenshots/25-3-away-phone.png new file mode 100644 index 0000000..4e103af Binary files /dev/null and b/docs/screenshots/25-3-away-phone.png differ diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 11f061a..85ed98c 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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 | | `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) | +| `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) | | `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 | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 2f44dba..08f31fb 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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.5 — Regression diff --git a/packages/engine/src/game/away.ts b/packages/engine/src/game/away.ts new file mode 100644 index 0000000..dc81239 --- /dev/null +++ b/packages/engine/src/game/away.ts @@ -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; +} diff --git a/packages/engine/src/game/commands.ts b/packages/engine/src/game/commands.ts index eff1c63..de0711e 100644 --- a/packages/engine/src/game/commands.ts +++ b/packages/engine/src/game/commands.ts @@ -1,4 +1,5 @@ import { content } from "@deadhead/content"; +import { awayProblems } from "./away"; import { tutorialBoard, locationBoard } from "./boards"; import { acceptDeal, claimGoal, maxedUpgrades, renameCompany, repayLoan } from "./company"; import { dispatchContract, dispatchReposition, dispatchTutorial } from "./dispatch"; @@ -68,6 +69,13 @@ export function commandTable(): CommandTable { acknowledgeSummaries: ({ save, now }, input) => ({ 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 // after the command; stamping here too makes the returned state awake. backToWork: ({ save, now }) => { diff --git a/packages/engine/test/logbook.test.ts b/packages/engine/test/logbook.test.ts index 033d576..1c9ac02 100644 --- a/packages/engine/test/logbook.test.ts +++ b/packages/engine/test/logbook.test.ts @@ -260,3 +260,65 @@ describe("net earnings (Step 25.2, decision 0.5.142)", () => { 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([]); + }); +}); diff --git a/packages/schema/src/logbook.ts b/packages/schema/src/logbook.ts index 3ddb811..3ec951c 100644 --- a/packages/schema/src/logbook.ts +++ b/packages/schema/src/logbook.ts @@ -1,4 +1,4 @@ -import type { LogbookEntry } from "@deadhead/sim"; +import type { AwayProblem, LogbookEntry } from "@deadhead/sim"; import { z } from "zod"; import { idSchema, timeSchema } from "./entities"; @@ -70,7 +70,29 @@ export const logEntrySchema = z.discriminatedUnion("kind", [ export type LogEntry = z.infer; 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; + // The saved entry is the summary builder's own: a change on either side fails the build. type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; const _sameEntry: Same = true; -void _sameEntry; +const _sameProblem: Same, AwayProblem> = true; +void [_sameEntry, _sameProblem]; diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index ad7be7f..8b5e4b5 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -1,5 +1,6 @@ import type { z } from "zod"; import { playerStateSchema, serverTimeSchema } from "./entities"; +import { logbookResponseSchema } from "./logbook"; import { acknowledgeSummariesRequestSchema, acknowledgeSummariesResponseSchema, @@ -241,6 +242,14 @@ export const routes = { manualDecision: false, }, /** 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: { method: "POST", path: "/api/fleet/wake", diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index 05fc428..d0b574d 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -13,6 +13,7 @@ describe("route catalog", () => { "getPlayerState", "getContractBoard", "getTutorialBoard", + "getLogbook", "createDispatch", "buyShip", "leaseBay", diff --git a/packages/sim/src/away.ts b/packages/sim/src/away.ts index 16149df..e940102 100644 --- a/packages/sim/src/away.ts +++ b/packages/sim/src/away.ts @@ -122,6 +122,7 @@ export const MAX_PILOT_LINES = 5; export const SHORT_ABSENCE_EVENTS = 3; const EVENT_KINDS = new Set(["flight", "discovery", "level-up", "upgrade"]); +const LISTED_DIRECTLY = new Set(["discovery", "level-up", "best-job"]); /** * "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 discoveries = of("discovery"); 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 = { net: flights.reduce((sum, f) => sum + flightNet(f), 0), @@ -158,7 +167,7 @@ export function buildAwaySummary(input: AwayInput): AwaySummary { locationId: d.locationId, pilotId: d.pilotId, })), - ...levelUps.map((l) => ({ + ...topLevels.map((l) => ({ kind: "level-up" as const, pilotId: l.pilotId, level: l.level, @@ -201,17 +210,20 @@ export function buildAwaySummary(input: AwayInput): AwaySummary { const rest = lines.slice(MAX_PILOT_LINES); 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 { durationMs: Math.max(0, input.to - input.from), totals, - highlights: highlights.slice(0, MAX_HIGHLIGHTS), - moreHighlights: Math.max(0, highlights.length - MAX_HIGHLIGHTS), + highlights: shown.slice(0, MAX_HIGHLIGHTS), + moreHighlights: Math.max(0, shown.length - MAX_HIGHLIGHTS), pilots: lines.slice(0, MAX_PILOT_LINES), morePilots: rest.length > 0 ? { count: rest.length, net: rest.reduce((sum, line) => sum + line.net, 0) } : null, idlePilots: input.pilots.length - lines.length, - direct: events.length <= SHORT_ABSENCE_EVENTS ? events : null, + direct, }; } diff --git a/packages/sim/test/away.test.ts b/packages/sim/test/away.test.ts index cc8ae08..e1896e6 100644 --- a/packages/sim/test/away.test.ts +++ b/packages/sim/test/away.test.ts @@ -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.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([ { 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" }), ]; 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", () => { @@ -145,3 +155,18 @@ describe("the away summary builder (Step 25.2, decision 0.5.140)", () => { 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 }, + ]); + }); +});