Compare commits

..

4 Commits

Author SHA1 Message Date
fireshaper 1c115a7465 Step 12.6: end-to-end: pilot progression
CI / check (push) Successful in 7m4s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:37:16 -04:00
fireshaper 5e8b5b45e4 Step 12.5: end-to-end: discovery
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:37:16 -04:00
fireshaper 2dbbe23332 Step 12.4: end-to-end: offline progress
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:37:16 -04:00
fireshaper e3ac95f23f Step 12.3: end-to-end: tutorial to first ship
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:37:15 -04:00
8 changed files with 482 additions and 14 deletions

View File

@ -1271,3 +1271,22 @@ 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. - **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). - **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.
## Step 12.4
- **"Close the page" is a real page close**; the return is a fresh page in the same browser session, as when reopening the installed app.
## Step 12.5
- **The Mars window is computed, not waited for**: the test finds the next date the Clipper can reach Mars and fly on, and fast-forwards to it.
## Step 12.6
- **A Lighter at the Moon, not the Courier**: with fleet-fit boards (CR-006) a Courier never sees a Heavy job it could carry, and a Lighter at the Moon sees one on about one board in eight, so the test waits for one board period at a time.
- **The new pilot is levelled by real deliveries** (the server's own XP), rests included, rather than by editing XP.

View File

@ -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);
});
});

View File

@ -0,0 +1,27 @@
import { expect, test } from "@playwright/test";
import { advanceTo, dispatchFromEarthBoard, logbook, playToDeal, state } from "./support";
test.describe("end to end (Phase 12)", () => {
test("12.4: a delivery that lands while the app is closed shows on return", async ({
page,
context,
}) => {
await playToDeal(page);
const dispatchId = await dispatchFromEarthBoard(page);
const before = await state(page);
const flight = before.activeDispatches.find((d) => d.id === dispatchId)!;
// Close the app, let the server clock pass the arrival, come back.
await page.close();
const later = await context.newPage();
await advanceTo(later, flight.arrivalAt);
await later.goto("/");
const summary = (await state(later)).summaries.find((s) => s.dispatchId === dispatchId)!;
await expect(logbook(later)).toContainText(`Delivered ${summary.massTonnes} t`);
const after = await state(later);
// Credits went up by the delivery's net payout; the ship is at the destination.
expect(after.company.credits).toBe(before.company.credits + summary.credits);
expect(after.ships.find((s) => s.id === flight.shipId)!.locationId).toBe(flight.destinationId);
});
});

View File

@ -0,0 +1,51 @@
import { expect, test } from "@playwright/test";
import {
addCredits,
advanceTo,
board,
dismissLogbook,
logbook,
nextMarsWindow,
playToDeal,
serverNow,
state,
tryDispatch,
} from "./support";
test.describe("end to end (Phase 12)", () => {
test("12.5: a first contact to Mars discovers it, opens its board and lets you lease there", async ({
page,
}) => {
const owner = await playToDeal(page);
const clipper = owner.ships.find((s) => s.modelId === "clipper")!;
const veteran = owner.pilots.find((p) => p.isVeteran)!;
// A date when the Clipper can reach Mars, Long Haul for the trip, and fuel money.
await advanceTo(page, nextMarsWindow(await serverNow(page)));
await page.goto("/pilots");
const raise = page.getByRole("button", { name: `Raise Long Haul for ${veteran.name}` });
while ((await state(page)).pilots.find((p) => p.id === veteran.id)!.skillPointsAvailable > 0) {
await raise.click();
}
await addCredits(page, 100_000);
// The board offers a first contact the Clipper can fly (decision 0.5.85).
const offer = (await board(page, "earth")).contracts.find(
(c) => c.firstContact && c.destinationId === "mars",
);
expect(offer, "a first contact to Mars on the Earth board").toBeDefined();
const flight = await tryDispatch(page, offer!.id, clipper.id, veteran.id);
expect(flight, "the Clipper and veteran can take it").not.toBeNull();
await advanceTo(page, flight!.arrivalAt);
await page.goto("/");
await expect(logbook(page)).toContainText("Discovered Mars!");
await dismissLogbook(page);
// Mars is discovered on the Locations screen and can be leased; its board is open without a bay (CR-002).
await page.goto("/locations");
await expect(page.getByRole("button", { name: "Lease bay at Mars" })).toBeEnabled();
const marsBoard = await page.request.get("/api/locations/mars/board");
expect(marsBoard.ok()).toBe(true);
expect((await state(page)).bays.map((b) => b.locationId)).toEqual(["earth"]);
});
});

View File

@ -0,0 +1,93 @@
import { content } from "@deadhead/content";
import { expect, test } from "@playwright/test";
import {
acknowledgeAll,
addCredits,
advance,
advanceTo,
assignPilot,
board,
buyShip,
flyUntil,
hirePilot,
playToDeal,
state,
} from "./support";
test.describe("end to end (Phase 12)", () => {
test("12.6: a pilot levels up, learns Heavy Cargo and can take a Heavy job; Long Haul lengthens duty", async ({
page,
}) => {
const owner = await playToDeal(page);
const bay = owner.bays[0]!;
await addCredits(page, 100_000);
const { ship } = await buyShip(page, "lighter", bay.id);
const { pilot } = await hirePilot(page, "earth");
await assignPilot(page, pilot.id, ship.id);
// Deliveries until the new pilot reaches level 1 (two skill points).
await flyUntil(
page,
ship.id,
pilot.id,
(s) => s.pilots.find((p) => p.id === pilot.id)!.level >= 1,
);
// Get the Lighter to the Moon, and wait for a Heavy Cargo 1 job it can carry there.
await flyUntil(
page,
ship.id,
pilot.id,
(s) => s.ships.find((x) => x.id === ship.id)!.locationId === "moon",
);
const heavyJob = async () =>
(await board(page, "moon")).contracts.find(
(c) =>
c.requiredSkill?.skillId === "heavy-cargo" &&
c.requiredSkill.level === 1 &&
c.massTonnes <= 80 &&
c.requiredLongHaulLevel === 0,
);
let job = await heavyJob();
for (let k = 0; !job && k < 60; k++) {
await advance(page, content.config.contractBoardPeriodRealMinutes * 60_000);
job = await heavyJob();
}
expect(job, "a Heavy Cargo 1 job at the Moon").toBeDefined();
await acknowledgeAll(page);
const resting = (await state(page)).pilots.find((p) => p.id === pilot.id)!.restEndsAt;
if (resting !== null) await advanceTo(page, resting);
// In the dispatch sheet the Lighter's pair is blocked by the skill...
const pairFor = async () => {
await page.goto("/contracts?location=moon");
const card = page
.getByRole("listitem")
.filter({ hasText: content.good(job!.goodId)!.name })
.filter({ hasText: `${job!.massTonnes} t` });
await card.getByRole("button", { name: "Dispatch" }).first().click();
return page.locator(`input[name="pair"][value="${ship.id}"]`);
};
await expect(await pairFor()).toBeDisabled();
await expect(page.getByText("Needs Heavy Cargo 1 (pilot has 0)")).toBeVisible();
// ...until the pilot spends a point in Heavy Cargo.
await page.goto("/pilots");
await page.getByRole("button", { name: `Raise Heavy Cargo for ${pilot.name}` }).click();
await expect
.poll(
async () =>
(await state(page)).pilots.find((p) => p.id === pilot.id)!.skills["heavy-cargo"],
)
.toBe(1);
await expect(await pairFor()).toBeEnabled();
// CR-004: Long Haul raises the duty limit.
const limitBefore = (await state(page)).pilots.find((p) => p.id === pilot.id)!.dutyLimit;
await page.goto("/pilots");
await page.getByRole("button", { name: `Raise Long Haul for ${pilot.name}` }).click();
await expect
.poll(async () => (await state(page)).pilots.find((p) => p.id === pilot.id)!.dutyLimit)
.toBe(limitBefore + content.config.longHaulDutyBonusGameHours);
});
});

203
apps/web/e2e/support.ts Normal file
View File

@ -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<T>(
page: Page,
method: "GET" | "POST",
path: string,
data?: unknown,
): Promise<T> {
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<PlayerState>(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<Dispatch | null> {
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<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");
}

View File

@ -1,21 +1,55 @@
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { defineConfig, devices } from "@playwright/test"; 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, * Browser tests against the production build (Phases 11–12): service worker,
* install and offline behaviour can't be tested in jsdom. Run `pnpm build` * install, offline, and end-to-end play against a real API server on the test
* first; `pnpm e2e` serves dist/ with `vite preview`. * 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({ export default defineConfig({
testDir: "e2e", testDir: "e2e",
testMatch: "**/*.e2e.ts", testMatch: "**/*.e2e.ts",
workers: 1,
fullyParallel: false, fullyParallel: false,
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 0,
use: { baseURL: `http://127.0.0.1:${PORT}`, ...devices["Desktop Chrome"] }, timeout: 120_000,
webServer: { use: { baseURL: WEB, ...devices["Desktop Chrome"] },
command: `pnpm exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`, webServer: [
url: `http://127.0.0.1:${PORT}`, {
reuseExistingServer: !process.env.CI, // 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}` },
},
],
}); });

View File

@ -308,7 +308,9 @@ 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.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. - [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 - [x] **Step 12.4 — End-to-end: offline progress.** Test (`e2e/12-4-offline-progress.e2e.ts`): dispatch a contract, close the page, move the server clock past the arrival, open a new page: the logbook shows the delivery, credits went up by exactly the delivery's net payout, and the ship is at the destination.
- [ ] Step 12.5 — End-to-end: discovery - [x] **Step 12.5 — End-to-end: discovery.** Test (`e2e/12-5-discovery.e2e.ts`): after the deal, the clock moves to a date when a base Clipper can fly Earth → Mars (computed with the sim, so the test doesn't depend on today's planets); the veteran's points go into Long Haul in the Pilots screen; the Earth board offers a first contact to Mars the Clipper can take (decision 0.5.85); on arrival the logbook says "Discovered Mars!", the Locations screen offers "Lease bay at Mars", and Mars's board loads with no bay there (CR-002).
- [ ] Step 12.6 — End-to-end: pilot progression - [x] **Step 12.6 — End-to-end: pilot progression.** Test (`e2e/12-6-pilot-progression.e2e.ts`): buy a Lighter, hire and assign a new pilot, fly them until level 1; take the Lighter to the Moon and wait for a Heavy Cargo 1 job it can carry; in the dispatch sheet the Lighter's pair is disabled with "Needs Heavy Cargo 1 (pilot has 0)"; after raising Heavy Cargo in the Pilots screen the pair is enabled; raising Long Haul adds the Long Haul duty bonus to the pilot's duty limit (CR-004).
**Phase 12 complete — waiting for review.**