deadhead-space-freight-tycoon/apps/web/test/hull-events.test.tsx

263 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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%",
);
});
});