Step 14.8: company creation on the device
CI / check (push) Successful in 7m44s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-09 20:25:59 -04:00
parent 4c165b9b16
commit f77f786408
3 changed files with 91 additions and 1 deletions

View File

@ -1393,3 +1393,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Developer tools in test builds**: `vite build --mode e2e` reads `.env.e2e` (`VITE_DEV_TOOLS=true`), which turns on developer commands, persists the developer clock offset in local storage (so a fast-forward survives a reload) and exposes `window.deadhead.call` for browser tests. Deployable builds (`pnpm build`) have none of it.
- **Browser tests are the web build alone**: the API server left the Playwright config; helpers run engine commands through the hook. Two waits were needed that a server never needed: the hook appears only after the page takes the tab lock, and an acknowledgement is written in the page, so a test must not reload before it is saved.
- **"Closed while it landed" (12.4)** is now a new page opening with the developer offset already past the arrival: the engine resolves the trip on load, exactly as for a player who closed the app.
## Step 14.8
- **"Start your company" keeps its command name** (`createGuest`): the start screen, its tests and the route catalog stay unchanged; the command now builds the save on the device instead of an account and a database row.
- **The test pins the whole starting state** with fixed ids and a fixed seed, so any drift from CR-001's start shows up as a diff, not as a vague mismatch.

View File

@ -348,7 +348,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011
- [x] **Step 14.5 — Port the game logic.** `packages/engine/src/game/`: every server endpoint is an engine command with the same request and response schemas (`GameEngine.call(name, input)`: input checked against the route catalog, due arrivals resolved first as the server's `withPlayer` did, the command run on a copy of the save through the single-writer queue, the response checked, and the save written only if something changed, with old boards, accepted contracts and seen trips pruned). Modules mirror the server's: `state` (player state, ships, pilots, goals), `company` (starting save, deal, loan, rename, goals), `boards`, `dispatch`, `fleet` (bays, ships, pilots, skills, upgrades, summaries), `resolve` (arrivals, pruning), `commands` (the table), plus the server's names and messages. Refusals are `CommandError`s with the server's codes; no company yet answers `unauthorized`, as before. Developer commands only with `devTools`; the backup-code endpoints (`getBackupCode`, `createBackupCode`, `restoreCompany`) are not ported (replaced in 14.10). Save additions: `stats` (contract and Priority deliveries, for goals) and each ship's `lastArrivalAt`, so pruning never changes progress; config `boardRetentionPeriods` (24, from the server's cleanup job) and `savedDispatchRetentionRealHours` (168). Tests: 33 server test files ported to `packages/engine/test/ported/` over `test/support.ts` (the server helpers' names and shapes, with `call` routing old endpoint paths to commands and answering with the server's status codes, and `edit`/`readSave`/`addShip`/`addPilot` in place of database writes); rewritten where the thing tested changed (guest sign-up → starting a company, server time → the engine clock, request validation, the board-key constraint → one stored board per key, the cleanup job → pruning on save, the resolver race → resolving a save twice); dropped where it no longer exists (cookies and sessions, other players' bays and summaries, HTTP-only validation, the pg-boss arrival job, Postgres migrations, the health route, the backup code, the database harness). `test/catalog.test.ts` checks that every command has an engine test.
- [x] **Step 14.6 — Resolution loop.** `packages/engine/src/loop.ts`: `ResolutionLoop` runs `GameEngine.resolveDue` once on start (the app has just loaded), on every resume and every 5 seconds (`RESOLUTION_INTERVAL_MS`), never two passes at once, and calls `onChange` when an arrival was resolved or a rest or shop job ended since the last pass. Timer and resume come from an injected `LoopHost` (the browser's `visibilitychange` and Capacitor's `resume` are wired in 14.7). `resolveDue` uses the same idempotent resolution every command runs first, and saves only when something was resolved. Tests (`test/loop.test.ts`): an arrival that passed while the app was closed is resolved exactly once when it opens again on the same storage and shows in the "while you were away" summaries; two passes in a row leave the save byte-for-byte unchanged the second time; the loop ticks on its timer and on resume and reports a rest ending; nothing happens before a company exists.
- [x] **Step 14.7 — Switch the client to the engine.** `src/platform/game.ts` builds the game on the device: IndexedDB save storage (memory where IndexedDB is missing), the `DeviceClock`, the Web Locks `TabLock`, and the `GameEngine` (developer commands in development builds and in `vite build --mode e2e` test builds via `.env.e2e`; the developer clock offset is kept in local storage in those builds so a fast-forward survives a reload). `src/api/engine-client.ts`: the typed `ApiClient` over engine commands, refusals turned into the same `ApiError`s (status and code), so screens and TanStack Query are unchanged. `src/GameRoot.tsx`: takes the tab lock ("Deadhead is open in another tab" with "Use here" otherwise, and when another tab takes over), provides the engine client, runs the resolution loop (browser timer and `visibilitychange`; a change refetches state and boards), and exposes `window.deadhead.call` in developer builds for browser tests. The clock reads the engine clock instead of the server's. The Step 11.2 offline mode is gone (cached last state, offline banner, `needsServer` buttons, the connection store and their tests): the game is always offline-capable; a failed load now says "Could not load the game." Browser tests run against the web build alone: no API server in the Playwright config, helpers call engine commands through the hook and wait for it after each load, and wait for an acknowledgement to be saved before navigating; 12.4 reopens the app with the developer offset already moved past the arrival. CI builds with `build:e2e`. Tests: `test/on-device.test.tsx` (the engine client answers as the server did, refusals included; "open in another tab" until Use here takes over; the game runs at once when no other tab has it); `e2e/14-7-on-device.e2e.ts` (with every request outside the app's own files blocked from the first load: start a company, fly the tutorial, accept the deal, dispatch and land a contract, and nothing was blocked); the Phase 12 end-to-end tests pass on the engine.
- [ ] Step 14.8 — Company creation on the device
- [x] **Step 14.8 — Company creation on the device.** With no save, the app shows the start screen (no company answers `unauthorized`, as before); "Start your company" runs `createGuest`, which builds the CR-001 starting state in a new save with a fresh world seed from the platform's crypto source (`startingSave`, shared with "Start a new company"). Asking again when a company exists returns that company. Tests (`packages/engine/test/start.test.ts`, with fixed ids and seed): from empty storage the state is exactly the CR-001 start (starting credits, no loan, the avatar at Earth beside the loaner, Earth and the Moon discovered, four tutorial runs to go, no ships, bays, flights, summaries or goals, the seed's company name); a new engine on the same storage opens the same company; different seeds give different companies.
- [ ] Step 14.9 — Local notifications
- [ ] Step 14.10 — Settings changes
- [ ] Step 14.11 — Retire the server

View File

@ -0,0 +1,85 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { GameEngine, MemoryBackend, SaveStorage, type RandomSource } from "../src/index";
import { companyName } from "../src/game/names";
import { TEST_NOW } from "./support";
/** Deterministic ids and seeds, so the starting state can be compared exactly. */
function fixedRandom(seed: number): RandomSource {
let n = 0;
return { id: () => `id-${++n}`, seed: () => seed };
}
function engineOn(backend: MemoryBackend, seed = 1234) {
return new GameEngine({
storage: new SaveStorage(backend),
clock: { now: () => TEST_NOW },
random: fixedRandom(seed),
});
}
describe("company creation on the device (Step 14.8)", () => {
it("starts from empty storage with exactly CR-001's starting state", async () => {
const backend = new MemoryBackend();
const engine = engineOn(backend);
await expect(engine.call("getPlayerState", {})).rejects.toMatchObject({ code: "unauthorized" });
const { state } = await engine.call("createGuest", {});
expect(state).toEqual({
playerId: "id-1",
company: {
id: "id-2",
name: companyName(1234),
credits: content.config.startingCredits,
loanBalance: 0,
},
ships: [],
pilots: [
{
id: "id-3",
name: "Captain",
isAvatar: true,
isVeteran: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: null,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: content.config.dutyLimitGameHours,
resting: false,
restEndsAt: null,
},
],
bays: [],
locations: content.locations.map((l) => ({ id: l.id, discovered: l.startsDiscovered })),
tutorial: {
loanerLocationId: "earth",
runInFlight: false,
runsCompleted: 0,
dealAvailable: false,
},
activeDispatches: [],
summaries: [],
goals: [],
});
});
it("opens the same company after a reload", async () => {
const backend = new MemoryBackend();
const created = await engineOn(backend).call("createGuest", {});
// A new engine on the same storage: the app reloaded.
const reopened = engineOn(backend, 9999);
expect(await reopened.call("getPlayerState", {})).toEqual(created.state);
expect(await reopened.call("createGuest", {})).toEqual(created);
});
it("gives each new company its own world seed", async () => {
const a = await engineOn(new MemoryBackend(), 1).call("createGuest", {});
const b = await engineOn(new MemoryBackend(), 2).call("createGuest", {});
expect(a.state.company.name).toBe(companyName(1));
expect(b.state.company.name).toBe(companyName(2));
});
});