198 lines
7.7 KiB
TypeScript
198 lines
7.7 KiB
TypeScript
import { content } from "@deadhead/content";
|
||
import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema";
|
||
import { emergencyFuel, fuelPriceAt, tripFuel } from "@deadhead/sim";
|
||
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";
|
||
import { formatCredits } from "../src/format";
|
||
import { simShip } from "../src/game";
|
||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||
|
||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||
const [earth, moon] = [content.location("earth")!, content.location("moon")!];
|
||
const courier = content.shipModel("courier")!;
|
||
const earthPrice = fuelPriceAt(earth, content.config);
|
||
const legFu = tripFuel(courier, earth, moon, content.config);
|
||
|
||
const ship = (fuelFu: number, refuelPolicy: Ship["refuelPolicy"] = "auto"): Ship => ({
|
||
id: "s1",
|
||
name: "Dusty Mule",
|
||
modelId: "courier",
|
||
status: "idle",
|
||
locationId: "earth",
|
||
dispatchId: null,
|
||
lastArrivalAt: 0,
|
||
...shipExtras("courier"),
|
||
fuelFu,
|
||
fuelAvgPrice: 15,
|
||
refuelPolicy,
|
||
pilotId: "avatar",
|
||
});
|
||
const avatar: Pilot = {
|
||
id: "avatar",
|
||
name: "Captain",
|
||
isAvatar: true,
|
||
isVeteran: false,
|
||
xp: 0,
|
||
level: 0,
|
||
skills: {},
|
||
skillPointsAvailable: 0,
|
||
assignedShipId: "s1",
|
||
status: "idle",
|
||
locationId: "earth",
|
||
dutyHours: 0,
|
||
dutyLimit: 12,
|
||
resting: false,
|
||
restEndsAt: null,
|
||
};
|
||
const moonJob: Contract = {
|
||
id: "c-moon",
|
||
originId: "earth",
|
||
destinationId: "moon",
|
||
cargoTypeId: "standard",
|
||
goodId: "consumer-goods",
|
||
shipper: "Pacific Orbital Logistics",
|
||
priority: false,
|
||
deliveryWindowGameHours: null,
|
||
tier: 0,
|
||
massTonnes: 5,
|
||
payout: 900,
|
||
requiredSkill: null,
|
||
requiredLongHaulLevel: 0,
|
||
offeredAt: NOW,
|
||
expiresAt: NOW + 3_600_000,
|
||
firstContact: false,
|
||
};
|
||
|
||
/** A one-ship company with `fuelFu` on board and `credits`, whose refuels really fill the tank. */
|
||
function company(fuelFu: number, credits = 10_000) {
|
||
let state: PlayerState = samplePlayerState({
|
||
tutorial: null,
|
||
company: { id: "c", name: "D", credits, loanBalance: 0 },
|
||
bays: [{ id: "b1", locationId: "earth" }],
|
||
ships: [ship(fuelFu)],
|
||
pilots: [avatar],
|
||
});
|
||
const refuelShip = vi.fn((input: { shipId: string; amountFu?: number }) => {
|
||
const s = state.ships[0]!;
|
||
const bought = input.amountFu ?? s.stats.tankCapacityFu - s.fuelFu;
|
||
const refuelled = { ...s, fuelFu: s.fuelFu + bought };
|
||
state = {
|
||
...state,
|
||
company: {
|
||
...state.company,
|
||
credits: state.company.credits - Math.round(bought * earthPrice),
|
||
},
|
||
ships: [refuelled],
|
||
};
|
||
return { ship: refuelled };
|
||
});
|
||
const api = fakeApi({
|
||
getPlayerState: () => state,
|
||
getContractBoard: () => ({ contracts: [moonJob], periodEndsAt: NOW + 3_600_000 }),
|
||
refuelShip,
|
||
});
|
||
return { api, refuelShip, state: () => state };
|
||
}
|
||
|
||
describe("fuel in the client (Step 15.7)", () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||
vi.setSystemTime(NOW);
|
||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||
});
|
||
afterEach(() => vi.useRealTimers());
|
||
|
||
it("shows each ship's fuel gauge and refuel policy on the Fleet screen", async () => {
|
||
renderApp(company(30).api, "/fleet");
|
||
const gauge = await screen.findByRole("progressbar", { name: "Dusty Mule fuel" });
|
||
// The gauge runs from empty to the tank's capacity, in fu.
|
||
expect(gauge).toHaveAttribute("aria-valuenow", "30");
|
||
expect(gauge).toHaveAttribute("aria-valuemax", String(courier.tankCapacityFu));
|
||
expect(screen.getByLabelText("Dusty Mule fuel level")).toHaveTextContent("30 fu · 75%");
|
||
expect(screen.getByText("Auto top-up")).toBeInTheDocument();
|
||
});
|
||
|
||
it('offers "Refuel here first" for a pair short of fuel, fills the tank and enables it', async () => {
|
||
const { api, refuelShip } = company(2);
|
||
renderApp(api, "/contracts");
|
||
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
|
||
const dialog = screen.getByRole("dialog");
|
||
const pair = within(dialog).getByText("Dusty Mule").closest("li")!;
|
||
expect(within(pair).getByRole("radio")).toBeDisabled();
|
||
expect(pair).toHaveTextContent("Not enough fuel");
|
||
expect(within(pair).getByLabelText("Fuel")).toHaveTextContent(
|
||
`Fuel needed ${Math.round(legFu)} fu · on board 2 fu`,
|
||
);
|
||
|
||
await userEvent.click(within(pair).getByRole("button", { name: "Refuel here first" }));
|
||
// A full tank: the credits cover it.
|
||
expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1" });
|
||
// The sheet stays open and the pair is now flyable and picked.
|
||
await waitFor(() => expect(within(dialog).getByRole("radio")).toBeEnabled());
|
||
expect(within(dialog).getByRole("radio")).toBeChecked();
|
||
expect(screen.getByRole("dialog")).toBe(dialog);
|
||
});
|
||
|
||
it("buys just the leg's fuel when the credits can't fill the tank", async () => {
|
||
const { api, refuelShip } = company(2, Math.round(10 * earthPrice));
|
||
renderApp(api, "/contracts");
|
||
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
|
||
const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!;
|
||
await userEvent.click(within(pair).getByRole("button", { name: "Refuel here first" }));
|
||
expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1", amountFu: legFu - 2 });
|
||
});
|
||
|
||
it("prices the fuel used at the tank's average price, as the sim does", async () => {
|
||
renderApp(company(40).api, "/contracts");
|
||
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
|
||
const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!;
|
||
expect(pair).toHaveTextContent(
|
||
`Fuel used (at your average price)${formatCredits(Math.round(legFu * 15))}`,
|
||
);
|
||
});
|
||
|
||
it("highlights the cheapest fuel on the Locations screen", async () => {
|
||
const all = samplePlayerState({
|
||
tutorial: null,
|
||
locations: content.locations.map((l) => ({ id: l.id, discovered: true })),
|
||
});
|
||
renderApp(fakeApi({ getPlayerState: () => all }), "/locations");
|
||
const jupiter = await screen.findByLabelText("Fuel at Jupiter Cloud Platform");
|
||
// Base price 5 cr/fu (CR-018): Jupiter × 0.6, Earth × 1.6.
|
||
expect(jupiter).toHaveTextContent("3 cr/fu · cheapest");
|
||
expect(screen.getByLabelText("Fuel at Earth")).toHaveTextContent("8 cr/fu");
|
||
expect(screen.getByLabelText("Fuel at Earth")).not.toHaveTextContent("cheapest");
|
||
});
|
||
|
||
it("shows emergency fuel only when the sim offers it", async () => {
|
||
// Stuck at the Moon with 1 fu and no credits: the only way out is Earth.
|
||
const stuck = samplePlayerState({
|
||
tutorial: null,
|
||
company: { id: "c", name: "D", credits: 0, loanBalance: 0 },
|
||
ships: [{ ...ship(1), locationId: "moon" }],
|
||
pilots: [avatar],
|
||
});
|
||
const offer = emergencyFuel(
|
||
{
|
||
ship: simShip(stuck.ships[0]!),
|
||
credits: 0,
|
||
discoveredIds: new Set(["earth", "moon"]),
|
||
},
|
||
content,
|
||
)!;
|
||
const { unmount } = renderApp(fakeApi({ getPlayerState: () => stuck }), "/fleet");
|
||
await userEvent.click(await screen.findByRole("button", { name: "Fuel for Dusty Mule" }));
|
||
expect(
|
||
screen.getByRole("button", { name: /Emergency fuel \(added to loan\)/ }),
|
||
).toHaveTextContent(formatCredits(offer.cost));
|
||
unmount();
|
||
|
||
const fine = samplePlayerState({ tutorial: null, ships: [ship(40)], pilots: [avatar] });
|
||
renderApp(fakeApi({ getPlayerState: () => fine }), "/fleet");
|
||
await userEvent.click(await screen.findByRole("button", { name: "Fuel for Dusty Mule" }));
|
||
expect(screen.queryByRole("button", { name: /Emergency fuel/ })).toBeNull();
|
||
});
|
||
});
|