import { content } from "@deadhead/content"; import type { Contract, Pilot, PlayerState, ResolutionSummary } from "@deadhead/schema"; import { act, render, screen, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; import { CatchingUp, pilotLogbook } from "../src/components/PilotLogbook"; import { logbookFrom, pilotExtras } from "./fixtures"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const PERIOD_START = Date.parse("2100-01-01T00:00:00Z"); const FIRST_LOOK = content.config.independentPilots.firstLookRealMinutes * 60_000; const job = (over: Partial = {}): Contract => ({ id: "earth:1:0", originId: "earth", destinationId: "moon", cargoTypeId: "standard", goodId: "consumer-goods", shipper: "Pacific Orbital Logistics", priority: false, deliveryWindowGameHours: null, tier: 0, massTonnes: 6, payout: 1_234, requiredSkill: null, requiredLongHaulLevel: 0, offeredAt: PERIOD_START, expiresAt: PERIOD_START + 600_000, firstContact: false, ...over, }); const pilot = (id: string, name: string, over: Partial = {}): Pilot => ({ id, name, isAvatar: false, isVeteran: false, xp: 0, level: 0, skills: {}, skillPointsAvailable: 0, assignedShipId: `ship-${id}`, status: "idle", locationId: "earth", dutyHours: 0, dutyLimit: 12, resting: false, restEndsAt: null, ...pilotExtras, mode: "independent", ...over, }); function owner(pilots: Pilot[]): PlayerState { const base = samplePlayerState({ tutorial: null }); return { ...base, bays: [{ id: "b", locationId: "earth" }], pilots: [base.pilots[0]!, ...pilots], ships: pilots.map((p) => ({ id: `ship-${p.id}`, modelId: "courier", name: `Ship ${p.id}`, status: "idle", locationId: "earth", dispatchId: null, lastArrivalAt: 0, ...shipExtras("courier"), pilotId: p.id, })), dispatchOffice: { owned: true, spendingFloor: 500 }, }; } describe("first-look chip (Step 21.7, decision 0.5.126)", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.setSystemTime(PERIOD_START + 29_000); useClock.setState({ offsetMs: 0, synced: true, now: PERIOD_START + 29_000 }); }); afterEach(() => vi.useRealTimers()); const show = (state: PlayerState, contracts: Contract[]) => renderApp( fakeApi({ getPlayerState: () => state, getContractBoard: () => ({ contracts, periodEndsAt: PERIOD_START + 600_000 }), }), "/contracts?location=earth", ); it("counts down the player's first look, then disappears", async () => { show(owner([pilot("p1", "Ada")]), [job()]); const card = (await screen.findByText("1,234 cr")).closest("li")!; expect(within(card).getByLabelText("Yours for")).toHaveTextContent("Yours for 02:31"); act(() => { vi.setSystemTime(PERIOD_START + FIRST_LOOK + 1_000); useClock.setState({ now: PERIOD_START + FIRST_LOOK + 1_000 }); }); expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument(); }); it("isn't shown without Independent pilots", async () => { show(owner([pilot("p1", "Ada", { mode: "manual" })]), [job()]); const card = (await screen.findByText("1,234 cr")).closest("li")!; expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument(); }); it("isn't shown on a first contact, which is always the player's", async () => { show(owner([pilot("p1", "Ada")]), [job({ firstContact: true, destinationId: "mars" })]); const card = (await screen.findByText("1,234 cr")).closest("li")!; expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument(); }); }); describe("catching up indicator (Step 21.7)", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it("appears only when a catch-up runs longer than half a second", () => { const { rerender } = render(); act(() => vi.advanceTimersByTime(400)); expect(screen.queryByRole("status")).not.toBeInTheDocument(); act(() => vi.advanceTimersByTime(200)); expect(screen.getByRole("status")).toHaveTextContent("Catching up on your fleet…"); rerender(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); // A quick pass never shows it. rerender(); act(() => vi.advanceTimersByTime(300)); rerender(); act(() => vi.advanceTimersByTime(1_000)); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); }); describe("pilot logbook (Step 21.7)", () => { const summary = (over: Partial): ResolutionSummary => ({ dispatchId: "d", shipId: "ship-p1", pilotId: "p1", originId: "earth", destinationId: "moon", tutorial: false, independent: true, deadhead: false, fuelCost: 100, topUp: null, goodId: "consumer-goods", massTonnes: 6, shipper: "Pacific Orbital Logistics", credits: 600, loanRepayment: 200, pilotPay: 200, pilotXp: 10, discoveredLocationId: null, scriptedId: null, event: null, restEndsAt: null, resolvedAt: PERIOD_START, ...over, }); const ada = pilot("p1", "Ada"); const bo = pilot("p2", "Bo", { locationId: "moon" }); const summaries = [ summary({ dispatchId: "a1", resolvedAt: 1 }), summary({ dispatchId: "a2", deadhead: true, goodId: null, massTonnes: null, shipper: null, credits: 0, loanRepayment: 0, pilotPay: 0, pilotXp: 0, fuelCost: 40, resolvedAt: 2, }), summary({ dispatchId: "b1", pilotId: "p2", shipId: "ship-p2", destinationId: "earth", originId: "moon", credits: 900, pilotPay: 300, loanRepayment: 0, pilotXp: 12, restEndsAt: PERIOD_START + 3_600_000, resolvedAt: 3, }), // The player's own delivery isn't in the pilots' logbook. summary({ dispatchId: "you", pilotId: "avatar", independent: false, resolvedAt: 4 }), ]; // The logbook is built from entries now (CR-025): these stand for the summaries above. const entries = () => logbookFrom({ ...owner([ada, bo]), summaries }).entries; it("lists each pilot's jobs, deadheads, rests and XP, newest first, and where they are now", () => { const book = pilotLogbook(owner([ada, bo]), entries()); expect(book.pilots.map((p) => p.name)).toEqual(["Captain", "Ada", "Bo"]); const [, a, b] = book.pilots; expect(a!.entries.map((e) => e.kind)).toEqual(["flight", "flight"]); expect(a!.entries[1]).toMatchObject({ goodId: "consumer-goods", payout: 1_000, pilotPay: 200 }); expect(a!.xp).toBe(10); expect(a!.now).toBe("At Earth"); expect(b!.entries.map((e) => e.kind)).toEqual(["rest", "flight"]); expect(b!.now).toBe("At Moon"); }); it("totals the entries for the company", () => { const book = pilotLogbook(owner([ada, bo]), entries()); expect(book.totals).toEqual({ jobs: 3, deadheads: 1, payout: 1_000 + 1_200 + 1_000, pilotShare: 200 + 300 + 200, fuelCost: 100 + 40 + 100 + 100, loanRepaid: 200 + 200, net: 1_000 + 1_200 + 1_000 - 700 - 340 - 400, xp: 32, }); }); });