263 lines
8.9 KiB
TypeScript
263 lines
8.9 KiB
TypeScript
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> = {}): 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<PlayerState> = {}) =>
|
||
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<Extract<LogEntry, { kind: "event" }>>): 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%",
|
||
);
|
||
});
|
||
});
|