import { content } from "@deadhead/content"; import type { Contract, LogEntry, Pilot, PlayerState, Ship } from "@deadhead/schema"; import { buildAwaySummary, MS_PER_HOUR, repairCost } from "@deadhead/sim"; import { screen, 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"; import { directText, highlightText } from "../src/components/AwaySummary"; import { pilotLogbook } from "../src/components/PilotLogbook"; import { formatCredits } from "../src/format"; import { pairOptions } from "../src/screens/DispatchSheet"; import { pilotExtras } from "./fixtures"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); const GAME_HOUR = MS_PER_HOUR / content.config.timeCompression; const ship = (id: string, name: string, over: Partial = {}): Ship => ({ id, name, modelId: "courier", status: "idle", locationId: "earth", dispatchId: null, pilotId: "avatar", lastArrivalAt: 0, ...shipExtras("courier"), ...over, }); const rosa: Pilot = { ...samplePlayerState().pilots[0]!, id: "p1", name: "Rosa", isAvatar: false, assignedShipId: "s1", ...pilotExtras, }; const state = (ships: Ship[], over: Partial = {}) => samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 }, bays: [{ id: "b1", locationId: "earth" }], ships, pilots: [{ ...samplePlayerState().pilots[0]!, assignedShipId: ships[0]?.id ?? null }, rosa], ...over, }); const event = (over: Partial>): LogEntry => ({ id: "e1", at: NOW - 60_000, pilotId: "p1", shipId: "s1", seen: false, kind: "event", dispatchId: "d1", eventId: "debris-field", optionId: "go-around", good: false, answered: false, delayMs: 3 * GAME_HOUR, hullLoss: 0, payLoss: 0, fee: 0, bonus: 0, priorityLost: 0, xpBonusPct: 0, extraFuelFu: 0, ...over, }); describe("hull condition and event results on screen (Step 26.7, decisions 0.5.146, 0.5.148)", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(NOW); useClock.setState({ offsetMs: 0, synced: true, now: NOW }); }); afterEach(() => vi.useRealTimers()); it("shows a hull bar on Fleet cards, and Needs repair below 25%", async () => { renderApp( fakeApi({ getPlayerState: () => state([ ship("s1", "Dented Wren", { hullConditionPct: 20 }), ship("s2", "Fine Kite", { hullConditionPct: 60, pilotId: null }), ]), }), "/fleet", ); const dented = (await screen.findByText("Dented Wren")).closest("li")!; expect(within(dented).getByRole("progressbar", { name: "Dented Wren hull" })).toHaveAttribute( "aria-valuenow", "20", ); expect(within(dented).getByLabelText("Dented Wren needs repair")).toHaveTextContent( "Needs repair", ); const fine = screen.getByText("Fine Kite").closest("li")!; expect(within(fine).queryByText("Needs repair")).not.toBeInTheDocument(); }); it("offers Repair in the ship's sheet at an HQ, with its cost and time, and calls the engine", async () => { const api = fakeApi({ getPlayerState: () => state([ship("s1", "Dented Wren", { hullConditionPct: 60 })]), repairShip: () => ({ ship: ship("s1", "Dented Wren", { hullConditionPct: 60, repairing: true }), }), }); renderApp(api, "/fleet"); const card = (await screen.findByText("Dented Wren")).closest("li")!; await userEvent.click(within(card).getByRole("button", { name: "Upgrades for Dented Wren" })); const hull = within(screen.getByRole("dialog", { name: "Dented Wren" })).getByLabelText("Hull"); const cost = repairCost(content.shipModel("courier")!, 60, content.config); const button = within(hull).getByRole("button", { name: /^Repair to 100%/ }); expect(button).toHaveTextContent(`Repair to 100% · ${formatCredits(cost)} · 20m 00s`); await userEvent.click(button); expect(api.call).toHaveBeenCalledWith("repairShip", { shipId: "s1" }); }); it("says repairs are done at an HQ away from one, and lists Hull Plating in the shop", async () => { renderApp( fakeApi({ getPlayerState: () => state([ship("s1", "Far Wren", { hullConditionPct: 60, locationId: "moon" })]), }), "/fleet", ); const card = (await screen.findByText("Far Wren")).closest("li")!; await userEvent.click(within(card).getByRole("button", { name: "Upgrades for Far Wren" })); const sheet = screen.getByRole("dialog", { name: "Far Wren" }); expect(within(sheet).getByLabelText("Hull")).toHaveTextContent( "Repairs are done at one of your HQs.", ); const plating = within(sheet).getByRole("listitem", { name: "Hull Plating" }); const rating = content.shipModel("courier")!.hullRating; expect(plating).toHaveTextContent(`Hull rating ${rating} → ${rating + 1}`); }); it("gives the dispatch reason for Dangerous cargo on a damaged hull", () => { const hazmat: Contract = { id: "c", originId: "earth", destinationId: "moon", cargoTypeId: "dangerous", goodId: "helium-3", shipper: "Pacific Orbital Logistics", priority: false, deliveryWindowGameHours: null, tier: 0, massTonnes: 5, payout: 1_000, requiredSkill: null, requiredLongHaulLevel: 0, offeredAt: NOW, expiresAt: NOW + 600_000, firstContact: false, }; const [pair] = pairOptions(state([ship("s1", "Wren", { hullConditionPct: 40 })]), hazmat, NOW); expect(pair!.problems).toContain("Hull too damaged for hazardous cargo: repair at an HQ"); const [whole] = pairOptions(state([ship("s1", "Wren", { hullConditionPct: 50 })]), hazmat, NOW); expect(whole!.problems).not.toContain("Hull too damaged for hazardous cargo: repair at an HQ"); }); it("words event results for the summary and the Logbook, and logs repairs", () => { const s = state([ship("s1", "Wren")]); expect(directText(event({}), s)).toBe( "Debris field ahead — Rosa: Go around (no answer) · +3 h", ); expect( directText(event({ optionId: "push-through", answered: true, delayMs: 0, hullLoss: 14 }), s), ).toBe("Debris field ahead — Rosa: Push through · hull −14%"); expect( directText( event({ eventId: "solar-tailwind", optionId: "ride", good: true, delayMs: -1.5 * GAME_HOUR, }), s, ), ).toBe("Solar tailwind — Rosa: 1.5 h sooner"); const book = pilotLogbook(s, [ event({ eventId: "grateful-client", optionId: "tip", good: true, delayMs: 0, bonus: 800 }), { id: "r1", at: NOW, pilotId: "p1", shipId: "s1", seen: false, kind: "repair", fromPct: 60, cost: 2_000, }, ]); const lines = book.pilots[0]!.entries; expect(lines.map((e) => e.kind)).toEqual(["repair", "event"]); }); it("ranks good outcomes and hull damage just below discoveries and level-ups", () => { const s = state([ship("s1", "Wren")]); const flights = Array.from({ length: 4 }, (_, i): LogEntry => ({ id: `f${i}`, at: NOW - 30_000 + i, pilotId: "p1", shipId: "s1", seen: false, kind: "flight", dispatchId: `d${i}`, deadhead: false, tutorial: false, independent: true, originId: "earth", destinationId: "moon", goodId: "consumer-goods", massTonnes: 5, shipper: "Pacific Orbital Logistics", payout: 1_000, pilotPay: 0, fuelCost: 0, loanRepayment: 0, xp: 5, scriptedId: null, })); const summary = buildAwaySummary({ from: NOW - MS_PER_HOUR, to: NOW, entries: [ ...flights, { id: "g", at: NOW - 50_000, pilotId: null, shipId: null, seen: false, kind: "goal-ready", goalId: "first-own-delivery", }, event({ id: "bad", hullLoss: 14, optionId: "push-through", answered: true, delayMs: 0 }), event({ id: "quiet" }), { id: "l", at: NOW - 40_000, pilotId: "p1", shipId: "s1", seen: false, kind: "level-up", level: 2, skillPoints: 1, }, ], pilots: [{ id: "p1", name: "Rosa", now: "At Moon" }], problems: [], }); // The detour without harm isn't a highlight; the hull hit is, after the level-up. expect(summary.highlights.map((h) => h.kind)).toEqual(["level-up", "event", "goal-ready"]); expect(highlightText(summary.highlights[1]!, s)).toBe( "Debris field ahead — Rosa: Push through · hull −14%", ); }); });