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(page: Page, name: string, input: unknown = {}): Promise { // 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(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(); // 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; 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: "Dashboard" })).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 { 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 { 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"); }