import { content } from "@deadhead/content"; import type { Contract, Dispatch, PlayerState } from "@deadhead/schema"; import { checkRoute, effectiveStats, 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(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(); 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) { if ( checkRoute(clipper, earth, mars).ok && !wouldStrand(clipper, mars, 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 { 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 { 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"); }