diff --git a/DECISIONS.md b/DECISIONS.md index 9ae0196..1716e52 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1271,3 +1271,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The Step 2.5 economy check moved to the sim's tests**: it needs measured numbers from the balance script, and `packages/content` can't depend on `packages/sim`. It now checks what the simulated player actually earned in every seeded run instead of a "lowest-paying contract" assumption. - **Decision 0.5.85 got its own board test** here (it was only covered indirectly by the balance targets in 12.1). + +## Step 12.3 + +- **The end-to-end tests drive the real UI for the steps the plan names** (start, tutorial board, deal, dispatch sheet, logbook, Locations, Pilots) and use the API, with the browser's own session, to fast-forward the clock and read state. CR-011 allows the developer routes for this. +- **They share the server unit tests' database** (`TEST_DATABASE_URL`); each test signs up its own guest, so nothing needs resetting. Don't run them at the same time as the server tests, which truncate it. +- **The test's expected state follows CR-013**: two ships after the deal, not the one the plan's 12.3 text lists. diff --git a/apps/web/e2e/12-3-first-ship.e2e.ts b/apps/web/e2e/12-3-first-ship.e2e.ts new file mode 100644 index 0000000..416626a --- /dev/null +++ b/apps/web/e2e/12-3-first-ship.e2e.ts @@ -0,0 +1,39 @@ +import { content } from "@deadhead/content"; +import { expect, test } from "@playwright/test"; +import { + advanceTo, + dismissLogbook, + dispatchFromEarthBoard, + logbook, + playToDeal, + state, +} from "./support"; + +test.describe("end to end (Phase 12)", () => { + test("12.3: tutorial runs, the deal, then a contract that repays the loan", async ({ page }) => { + const owner = await playToDeal(page); + + // The deal (CR-001, CR-013): Courier crewed by the avatar and Clipper by the veteran, at Earth. + expect(owner.tutorial).toBeNull(); + expect(owner.bays.map((b) => b.locationId)).toEqual(["earth"]); + expect(owner.company.loanBalance).toBe(content.dealLoanAmount); + const avatar = owner.pilots.find((p) => p.isAvatar)!; + const veteran = owner.pilots.find((p) => p.isVeteran)!; + expect(owner.ships.map((s) => [s.modelId, s.locationId, s.pilotId])).toEqual([ + ["courier", "earth", avatar.id], + ["clipper", "earth", veteran.id], + ]); + + // One contract, dispatched in the UI and resolved. + const dispatchId = await dispatchFromEarthBoard(page); + const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!; + await advanceTo(page, flight.arrivalAt); + await page.reload(); + await expect(logbook(page)).toContainText("went to the lender"); + await dismissLogbook(page); + + const after = await state(page); + expect(after.activeDispatches).toEqual([]); + expect(after.company.loanBalance).toBeLessThan(content.dealLoanAmount); + }); +}); diff --git a/apps/web/e2e/support.ts b/apps/web/e2e/support.ts new file mode 100644 index 0000000..b98d10c --- /dev/null +++ b/apps/web/e2e/support.ts @@ -0,0 +1,203 @@ +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"); +} diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 685b03d..bdb36d9 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,21 +1,55 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { defineConfig, devices } from "@playwright/test"; -const PORT = 4173; +const WEB_PORT = 4173; +const API_PORT = 3135; +const WEB = `http://127.0.0.1:${WEB_PORT}`; + +// Local runs read the repo-root .env (TEST_DATABASE_URL); CI sets real variables. +const envFile = fileURLToPath(new URL("../../.env", import.meta.url)); +if (existsSync(envFile)) process.loadEnvFile(envFile); +const databaseUrl = process.env.TEST_DATABASE_URL; /** - * Browser tests against the production build (Phase 11): service worker, - * install and offline behaviour can't be tested in jsdom. Run `pnpm build` - * first; `pnpm e2e` serves dist/ with `vite preview`. + * Browser tests against the production build (Phases 11–12): service worker, + * install, offline, and end-to-end play against a real API server on the test + * database. Run `pnpm build` first. The server clock is shared, and the + * end-to-end tests move it forward, so tests run one at a time. */ export default defineConfig({ testDir: "e2e", testMatch: "**/*.e2e.ts", + workers: 1, fullyParallel: false, retries: process.env.CI ? 1 : 0, - use: { baseURL: `http://127.0.0.1:${PORT}`, ...devices["Desktop Chrome"] }, - webServer: { - command: `pnpm exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, - url: `http://127.0.0.1:${PORT}`, - reuseExistingServer: !process.env.CI, - }, + timeout: 120_000, + use: { baseURL: WEB, ...devices["Desktop Chrome"] }, + webServer: [ + { + // The API on the test database, migrated first; developer routes on (not production). + command: "pnpm --filter @deadhead/server db:migrate && pnpm --filter @deadhead/server start", + cwd: "../..", + url: `http://127.0.0.1:${API_PORT}/api/health`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + NODE_ENV: "test", + PORT: String(API_PORT), + LOG_LEVEL: "warn", + DATABASE_URL: databaseUrl ?? "", + BETTER_AUTH_URL: WEB, + BETTER_AUTH_SECRET: + process.env.BETTER_AUTH_SECRET ?? "e2e-only-secret-not-for-production-use-0000", + WORKERS_ENABLED: "false", + }, + }, + { + // The built app, with /api proxied to the server above (same origin, decision 0.5.20). + command: `pnpm exec vite preview --port ${WEB_PORT} --strictPort --host 127.0.0.1`, + url: WEB, + reuseExistingServer: !process.env.CI, + env: { API_PROXY_TARGET: `http://127.0.0.1:${API_PORT}` }, + }, + ], }); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 0e7e93b..4056c37 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -308,7 +308,7 @@ Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR- - [x] **Step 12.1 — Balance simulation script.** `packages/sim/balance/simulate.ts`: a pure, deterministic simulated player that plays from the start using only sim and content functions the way the server applies them (tutorial runs, deal, boards per period, dispatch, deadhead, rest, resolution, goals, skill points, Market purchases with a hired pilot, upgrades from spare cash), with a trip log for tracing. Reports milestones (deal, first Market purchase, first discovery, loan paid off, four ships), credits/loan/ships/pilot levels every 10 minutes, and breaches of the invariants (soft-lock, idle ship with no work and no way out, dispatch while resting). `pnpm --filter @deadhead/sim balance [seed] [hours]` prints both policies. **Design changes (user-approved):** pay and fuel use economic distance (decision 0.5.84), and boards offer a first contact the fleet can fly (0.5.85). **Tuning:** economic-distance exponent 0.65, Clipper fuel 600 → 450 cr/M km, loan repayment share 0.3 → 0.25. Medians over 7 seeds (Lighter-first): deal 2.9 min, first purchase 66, first discovery 33, loan paid off 188, four ships 170 (Freighter-first 201); no breaches in any run. Test: `test/balance.test.ts` (determinism, each target on the median, four-ship comparison, invariants in every run, veteran contract in the first session, samples reported). - [x] **Step 12.2 — Revisit the tutorial economy test.** The amended Step 2.5 check ("the tutorial share plus starting credits covers fuel for three Courier Earth→Moon runs") no longer assumes the lowest-paying tutorial contract: the simulator records the tutorial share actually earned and the credits at the deal, and `packages/sim/test/balance.test.ts` checks, in every seeded run, four tutorial runs, credits at the deal = starting credits + share earned, and at least three Courier Earth→Moon fuel loads. The placeholder test left `packages/content/test/config.test.ts` (content can't import the sim). Also added `test/first-contact-fit.test.ts` for decision 0.5.85 (Step 12.1): every Earth board over four hours offers a first contact to Mars that the deal's Clipper crew can take, and none once Mars is discovered. -- [ ] Step 12.3 — End-to-end: tutorial to first ship +- [x] **Step 12.3 — End-to-end: tutorial to first ship.** Playwright now also starts the real API server (port 3135) on `TEST_DATABASE_URL`, migrated first, next to the production web build with `/api` proxied to it; tests run one at a time because the developer clock is shared. `e2e/support.ts` drives play: start a company, fly the tutorial board, shake on the deal, dispatch from a board through the sheet, dismiss the logbook, and fast-forward with the developer routes (CR-011). Test (`e2e/12-3-first-ship.e2e.ts`): guest → four tutorial runs in the UI → deal → the state has the Courier (avatar) and Clipper (veteran) at Earth, one Earth bay, the 66,600 cr loan and no tutorial → one contract dispatched in the dispatch sheet and resolved, its logbook entry shows the repayment and the loan went down. - [ ] Step 12.4 — End-to-end: offline progress - [ ] Step 12.5 — End-to-end: discovery - [ ] Step 12.6 — End-to-end: pilot progression