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) against the real server. The page's * request context shares the browser's session cookie, so API calls act as the * player on screen. */ async function api( page: Page, method: "GET" | "POST", path: string, data?: unknown, ): Promise { const response = method === "GET" ? await page.request.get(path) : await page.request.post(path, { data }); expect(response.ok(), `${method} ${path}: ${await response.text()}`).toBe(true); return (await response.json()) as T; } export const state = (page: Page) => api(page, "GET", "/api/state"); export const serverNow = async (page: Page) => (await api<{ realTime: number }>(page, "GET", "/api/time")).realTime; export const board = (page: Page, locationId: string) => api<{ contracts: Contract[] }>(page, "GET", `/api/locations/${locationId}/board`); /** Moves the (shared) server clock forward. */ export async function advance(page: Page, ms: number) { await api(page, "POST", "/api/test/advance-clock", { advanceMs: Math.max(1, Math.ceil(ms)) }); } /** Moves the server 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) => api(page, "POST", "/api/test/add-credits", { amount }); export const dispatchContract = (page: Page, contractId: string, shipId: string, pilotId: string) => api<{ dispatch: Dispatch }>(page, "POST", "/api/dispatches", { kind: "contract", contractId, shipId, pilotId, }); export const acknowledgeAll = async (page: Page) => { const { summaries } = await state(page); if (summaries.length > 0) { await api(page, "POST", "/api/summaries/acknowledge", { 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(); } /** 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 server refuses (not eligible). */ export async function tryDispatch( page: Page, contractId: string, shipId: string, pilotId: string, ): Promise { const response = await page.request.post("/api/dispatches", { data: { kind: "contract", contractId, shipId, pilotId }, }); return response.ok() ? ((await response.json()) as { dispatch: Dispatch }).dispatch : null; } export const buyShip = (page: Page, modelId: string, bayId: string) => api<{ ship: { id: string } }>(page, "POST", "/api/ships", { modelId, bayId }); export const hirePilot = (page: Page, locationId: string) => api<{ pilot: { id: string; name: string } }>(page, "POST", "/api/pilots", { locationId }); export const assignPilot = (page: Page, pilotId: string, shipId: string) => api(page, "POST", `/api/pilots/${pilotId}/assign`, { 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"); }