184 lines
7.4 KiB
TypeScript
184 lines
7.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. */
|
|
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();
|
|
// 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();
|
|
return state(page);
|
|
}
|
|
|
|
/** 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");
|
|
}
|