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

201 lines
7.9 KiB
TypeScript

import { content } from "@deadhead/content";
import type { Contract, Dispatch, PlayerState } from "@deadhead/schema";
import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/sim";
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. */
export async function dismissLogbook(page: Page) {
if ((await state(page)).summaries.length === 0) return;
const sheet = logbook(page);
await expect(sheet).toBeVisible();
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);
}
/** 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();
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;
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: "HQ" })).toBeVisible();
return state(page);
}
/**
* The next real time (from `from`, stepping a game day at a time) at which a
* base Clipper can fly Earth → Mars and fly on from there (CR-012), so a Mars
* test doesn't depend on today's planet positions.
*/
export function nextMarsWindow(from: number): number {
const clipper = effectiveStats(content.shipModel("clipper")!, {}, content);
const [earth, mars] = [content.location("earth")!, content.location("mars")!];
const gameDay = (24 * 3_600_000) / content.config.timeCompression;
for (let t = from; t < from + 1_500 * gameDay; t += gameDay) {
const g = realToGame(t, content.config);
if (
checkRoute(clipper, earth, mars, g).ok &&
!wouldStrand(clipper, earth, mars, 30, g, new Set(["earth", "moon", "mars"]), content)
) {
// Well inside the window: a few game days in.
return t + 3 * gameDay;
}
}
throw new Error("no Mars window found");
}
/** 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)!;
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");
}