deadhead-space-freight-tycoon/apps/web/e2e/support.ts

230 lines
9.4 KiB
TypeScript

import { content } from "@deadhead/content";
import type { Contract, Dispatch, PlayerState } from "@deadhead/schema";
import { expect, type Page } from "@playwright/test";
/**
* Helpers for end-to-end play (Phase 12, CR-014): the game runs in the page, so
* tests run engine commands through the test build's `window.deadhead` hook
* and fast-forward with the developer clock. No server is involved.
*/
export async function run<T>(page: Page, name: string, input: unknown = {}): Promise<T> {
// After a load the game installs the hook once it holds the tab.
await page.waitForFunction(() => window.deadhead !== undefined);
return (await page.evaluate(([command, data]) => window.deadhead!.call(command as never, data), [
name,
input,
] as const)) as T;
}
export const state = (page: Page) => run<PlayerState>(page, "getPlayerState");
export const serverNow = async (page: Page) =>
(await run<{ realTime: number }>(page, "getServerTime")).realTime;
export const board = (page: Page, locationId: string) =>
run<{ contracts: Contract[] }>(page, "getContractBoard", { locationId });
/** Moves the game clock forward (the developer offset). */
export async function advance(page: Page, ms: number) {
await run(page, "devAdvanceClock", { advanceMs: Math.max(1, Math.ceil(ms)) });
}
/** Moves the game clock to just after `time`, if it isn't there yet. */
export async function advanceTo(page: Page, time: number) {
const now = await serverNow(page);
if (time > now) await advance(page, time - now + 1_000);
}
export const addCredits = (page: Page, amount: number) => run(page, "devAddCredits", { amount });
export const dispatchContract = (page: Page, contractId: string, shipId: string, pilotId: string) =>
run<{ dispatch: Dispatch }>(page, "createDispatch", {
kind: "contract",
contractId,
shipId,
pilotId,
});
export const acknowledgeAll = async (page: Page) => {
const { summaries } = await state(page);
if (summaries.length > 0) {
await run(page, "acknowledgeSummaries", { dispatchIds: summaries.map((s) => s.dispatchId) });
}
};
/** The "While you were away" logbook (found by its heading). */
export const logbook = (page: Page) =>
page.getByRole("dialog").filter({
has: page.getByRole("heading", { name: "While you were away" }),
});
/** Waits for the logbook when the server has unseen summaries, and dismisses it. */
/**
* Closes "While you were away" if coming back opened it (CR-028: after a
* short absence the news shows as toasts instead, and closes the summaries
* itself). Waits until no delivery summary is left.
*/
export async function dismissLogbook(page: Page) {
const sheet = logbook(page);
await expect
.poll(async () => (await sheet.isVisible()) || (await state(page)).summaries.length === 0, {
timeout: 15_000,
})
.toBe(true);
if (!(await sheet.isVisible())) {
// Nothing to close yet: give a summary that is still reading the logbook a moment.
await sheet.waitFor({ state: "visible", timeout: 1_500 }).catch(() => {});
}
if (await sheet.isVisible()) {
await sheet.getByRole("button").last().click();
await expect(sheet).toBeHidden();
}
// The acknowledgement is saved in the page; don't navigate away before it lands.
await expect.poll(async () => (await state(page)).summaries.length).toBe(0);
}
/** The tab hidden (CR-028): the player is away; the game keeps running. */
export async function goAway(page: Page) {
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "hidden" });
document.dispatchEvent(new Event("visibilitychange"));
});
}
/** News, as a toast (the player was here, or briefly away) or in "While you were away". */
export async function expectNews(page: Page, text: RegExp | string) {
await expect(page.getByText(text).first()).toBeVisible();
}
/** A brand-new company: "Start your company" from the start screen. */
export async function startCompany(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Start your company" }).click();
// Every new company opens with the welcome cards (CR-022): skip them.
const welcome = page.getByRole("dialog", { name: "Welcome to Deadhead" });
await expect(welcome).toBeVisible();
await welcome.getByRole("button", { name: "Skip" }).click();
await expect(welcome).toBeHidden();
// Closing records it in the save; wait for that before any reload.
await expect.poll(async () => (await state(page)).welcomeSeen).toBe(true);
await expect(page.getByRole("button", { name: "Fly it" }).first()).toBeVisible();
}
/** Flies the configured tutorial runs through the board UI, fast-forwarding each one. */
export async function flyTutorial(page: Page) {
for (let run = 0; run < content.config.tutorialRunsBeforeDeal; run++) {
await page.goto("/contracts");
await page.getByRole("button", { name: "Fly it" }).first().click();
await expect.poll(async () => (await state(page)).activeDispatches.length).toBe(1);
const [flight] = (await state(page)).activeDispatches;
// Away while it flies (CR-028), back with a reload.
await goAway(page);
await advanceTo(page, flight!.arrivalAt);
await page.reload();
await dismissLogbook(page);
}
}
/** Tutorial runs, then "Shake on it": the player owns the business. */
export async function playToDeal(page: Page) {
await startCompany(page);
await flyTutorial(page);
await page.goto("/");
await dismissLogbook(page);
await page.getByRole("button", { name: "Shake on it" }).click();
await expect(page.getByRole("heading", { level: 1, name: "Bridge" })).toBeVisible();
// Both pilots have skill points at the deal (CR-017); the pop-up says so.
const skills = page.getByRole("dialog", { name: "Your pilots are ready to train" });
await expect(skills.getByLabel("Unspent skill points")).toContainText(/You: \d+ points?/);
await expect(skills.getByLabel("Unspent skill points")).toContainText(/Rosa: \d+ points?/);
await skills.getByRole("button", { name: "Later" }).click();
await expect(skills).toBeHidden();
const owner = await state(page);
// Halvorsen's ships come full, on Manual refueling (CR-018).
for (const ship of owner.ships) {
expect(ship.fuelFu, ship.name).toBeCloseTo(ship.stats.tankCapacityFu);
expect(ship.refuelPolicy, ship.name).toBe("manual");
}
return owner;
}
/** Tries to dispatch; returns the flight, or null if the engine refuses (not eligible). */
export async function tryDispatch(
page: Page,
contractId: string,
shipId: string,
pilotId: string,
): Promise<Dispatch | null> {
try {
return (await dispatchContract(page, contractId, shipId, pilotId)).dispatch;
} catch {
return null;
}
}
export const buyShip = (page: Page, modelId: string, bayId: string) =>
run<{ ship: { id: string } }>(page, "buyShip", { modelId, bayId });
export const hirePilot = (page: Page, locationId: string) =>
run<{ pilot: { id: string; name: string } }>(page, "hirePilot", { locationId });
export const assignPilot = (page: Page, pilotId: string, shipId: string) =>
run(page, "assignPilot", { pilotId, shipId });
const BOARD_PERIOD_MS = content.config.contractBoardPeriodRealMinutes * 60_000;
/**
* Flies a ship and its pilot on whatever they can take from the board where the
* ship is, fast-forwarding each trip and any rest, until `done` says stop.
*/
export async function flyUntil(
page: Page,
shipId: string,
pilotId: string,
done: (s: PlayerState) => boolean,
maxTrips = 40,
) {
for (let trip = 0; trip < maxTrips; trip++) {
const current = await state(page);
if (done(current)) return current;
const pilot = current.pilots.find((p) => p.id === pilotId)!;
if (pilot.restEndsAt !== null) await advanceTo(page, pilot.restEndsAt);
const ship = current.ships.find((s) => s.id === shipId)!;
// Ships are on Manual refueling (CR-018): fill up first, as the player would.
if (ship.fuelFu < ship.stats.tankCapacityFu) {
await run(page, "refuelShip", { shipId }).catch(() => {});
}
let flight: Dispatch | null = null;
for (const contract of (await board(page, ship.locationId!)).contracts) {
flight = await tryDispatch(page, contract.id, shipId, pilotId);
if (flight) break;
}
if (flight) await advanceTo(page, flight.arrivalAt);
else await advance(page, BOARD_PERIOD_MS);
await acknowledgeAll(page);
}
throw new Error(`gave up after ${maxTrips} trips`);
}
/**
* Dispatches one contract from Earth through the board and dispatch sheet,
* trying jobs until one has an eligible ship and pilot. Returns its id.
*/
export async function dispatchFromEarthBoard(page: Page): Promise<string> {
await page.goto("/contracts?location=earth");
const jobs = page.getByRole("button", { name: "Dispatch" });
await expect(jobs.first()).toBeVisible();
for (let i = 0; i < (await jobs.count()); i++) {
await jobs.nth(i).click();
const sheet = page.getByRole("dialog").filter({ hasText: "Confirm" });
const pair = sheet.locator('input[name="pair"]:not([disabled])').first();
if ((await pair.count()) > 0) {
await pair.check({ force: true });
const confirm = sheet.getByRole("button", { name: "Confirm" });
await expect(confirm).toBeEnabled();
await confirm.click();
await expect(sheet).toBeHidden();
const [flight] = (await state(page)).activeDispatches;
return flight!.id;
}
await sheet.getByRole("button", { name: "Cancel" }).click();
}
throw new Error("no job on the Earth board had an eligible ship and pilot");
}