# Architecture Living reference for how the code is organised. Update it after any database schema or API change (implementation plan rule 0.1.9). Decisions and their reasons live in `DECISIONS.md`. ## Packages | Package | Role | Depends on | |---|---|---| | `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod | | `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. | content, astronomy-engine | | `packages/engine` | The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. `DeviceClock`: `max(device time + dev offset, last time seen)`, never backward. | content, schema, sim | | `packages/schema` | Zod schemas for every API request, response and error. Shared by server and client. | sim (error codes), zod | | `apps/server` | Fastify API, Postgres via Drizzle, pg-boss workers. The authority on outcomes. | content, sim, schema | | `apps/web` | React client. Predicts with sim, never decides outcomes. | content, sim, schema | ## Conventions - **Times** are Unix milliseconds (numbers). A *real* time is a wall-clock instant; a *game* time is the instant of the in-game date (`realToGame` / `gameToReal` in sim). - **Ids** are non-empty strings. Content ids are kebab-case (`earth`, `bulk-hauler`). - **Credits** are whole numbers. - **Static vs. player data:** the API never sends static data. The client imports `@deadhead/content` and joins by id. ## API data model (`packages/schema`) | Shape | Fields | |---|---| | `ServerTime` | `realTime`, `gameTime` | | `Company` | `id`, `name`, `credits`, `loanBalance` | | `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location), `upgrades` (`{ upgradeId: level }`), `stats` (effective: speed, capacity, range, overhead, handling per tonne), `inShopUntil` (null unless in the shop now), `shop` (`{ upgradeId, level, until }` of the latest job until acknowledged; CR-009) | | `Pilot` | `id`, `name`, `isAvatar`, `isVeteran` (the deal's veteran, CR-010), `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) | | `Bay` | `id`, `locationId` | | `LocationState` | `id`, `discovered` | | `Contract` | mirrors sim `Contract`: route, `cargoTypeId` (class), `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (Priority only, else null), `tier`, `massTonnes` (to 0.1 t under 10 t), `payout`, requirements, `offeredAt`/`expiresAt`, `firstContact`, optional `scriptedId` on a scripted tutorial contract (CR-007) | | `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` | | `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `deadhead`, `fuelCost`, `goodId` / `massTonnes` / `shipper` (null for a deadhead, CR-007), `credits` (net), `loanRepayment`, `pilotPay` (hired pilot's profit share, CR-010), `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` | | `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` | | `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) | | `PlayerState` | `playerId` (CR-011), `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) | ### Errors Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged (CR-007 adds `too-slow`: the ship can't make a Priority contract's delivery window; CR-012 adds `would-strand`: from the destination, at arrival plus `noStrandMarginGameHours`, the ship couldn't reach any other discovered location), plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...). ## API routes (`packages/schema/src/routes.ts`) The `routes` catalog is the single source for both server and client. Each entry has `method`, `path`, `request` (path parameters merged with the body), `response` (2xx body) and `auth`. Errors always use the error body above. | Name | Method & path | Request | Response | Auth | |---|---|---|---|---| | `getServerTime` | `GET /api/time` | — | `ServerTime` | no | | `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` + session cookie | no | | `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes | | `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes | | `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes | | `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes | | `startUpgrade` | `POST /api/ships/:shipId/upgrades` | `shipId`, `upgradeId` (next level; ship idle at an HQ) | `{ ship }` | yes | | `buyShip` | `POST /api/ships` | `modelId`, `bayId` (one of the player's bays; the ship is delivered there) | `{ ship }` | yes | | `leaseBay` | `POST /api/bays` | `locationId` | `{ bay }` | yes | | `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes | | `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes | | `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes | | `restPilot` | `POST /api/pilots/:pilotId/rest` | `pilotId` (idle, not resting) | `{ pilot }` | yes | | `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes | | `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes | | `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes | | `getBackupCode` | `GET /api/backup-code` | — | `{ hasCode, createdAt }` | yes | | `createBackupCode` | `POST /api/backup-code` | — | `{ code, createdAt }` (shown once; replaces any old code) | yes | | `restoreCompany` | `POST /api/restore` | `code` (case, spaces and dashes ignored); header `x-device-id` for rate limiting | `{ playerId, state }` + session cookie | no | | `devAdvanceClock` | `POST /api/test/advance-clock` | `advanceMs` | `{ offsetMs, realTime }` | yes; non-production only | | `devAddCredits` | `POST /api/test/add-credits` | `amount` | `{ company }` | yes; non-production only | | `devPilot` | `POST /api/test/pilot` | `pilotId`, `action`: `level-up` (XP to the next level) / `clear-rest` (duty 0, rest ended) | `PlayerState` | yes; non-production only | | `devShip` | `POST /api/test/ship` | `shipId`, `action`: `max-upgrades` / `reset-upgrades` / `finish-shop` / `move` (+ `locationId`, idle ship, discovered place) | `PlayerState` | yes; non-production only | | `devDiscoverAll` | `POST /api/test/discover-all` | — | `PlayerState` | yes; non-production only | | `newCompany` | `POST /api/company/new` | — | `PlayerState` (the fresh start) | yes | | `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes | | `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes | | `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes | ## Save document (`packages/schema/src/save.ts`, CR-014) One document per company on the device, validated on every load and save: `saveVersion` (currently 1), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map and shop fields), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the database tables below without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing. Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned. Resolution loop (`packages/engine/src/loop.ts`): on load, on resume and every 5 s, `resolveDue` applies due arrivals (saving only if any) and reports rests and shop jobs that ended, so the app refreshes; timer and resume events come from a `LoopHost`. Storage (`packages/engine/src/storage.ts`): `SaveStorage` keeps the save in slot `main` and the previous three in `backup-1..3` (rotated on every save), falling back to the newest valid backup if the main save is unreadable. Backends store text per slot, all-or-nothing: `MemoryBackend` (tests), `IndexedDbBackend` (web, database `deadhead`, store `saves`), `FilesystemBackend` (native, `deadhead/.json` in the Capacitor data directory, temp file then rename). ## Database (`apps/server/src/db`) Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/drizzle/`, generated with `pnpm --filter @deadhead/server db:generate` and applied with `db:migrate` (uses `DATABASE_URL`; rerunning is a no-op). Contracts are JSONB snapshots (`contract_boards.contracts`, `dispatches.record`); `0005_cr007_goods` backfills pre-CR-007 contracts with a default good of their class, the origin's first shipper and no Priority. Every game table hangs off `players.id`; times are bigint Unix ms. | Table | Purpose | Key constraints | |---|---|---| | `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` | | `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` | | `ships` | Owned ships: model, name, status, location (null in flight), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009), `from_deal` (handed over in the deal, not bought; migration `0009_cr013_ship_from_deal` backfills each player's first ship; CR-013) | — | | `player_logins` | Extra sign-ins per player: a guest user created by restoring with a backup code (CR-011) | PK `user_id` | | `ship_upgrades` | Installed upgrade level per ship and upgrade (CR-009) | primary key (ship, upgrade) | | `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004), `is_veteran` (CR-010) | unique `assigned_ship_id` (one pilot per ship) | | `bays` | Leased bays | unique (`player_id`, `location_id`) | | `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) | | `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) | | `dispatches` | Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at | index (`status`, `arrival_at`), (`player_id`, `status`) | | `accepted_contracts` | Contracts taken from a board, linked to their dispatch | pk (`player_id`, `contract_id`) | | `claimed_goals` | Onboarding goals whose reward was claimed | pk (`player_id`, `goal_id`) | | `user`, `session`, `account`, `verification` | Better Auth tables (`src/db/auth-schema.ts`, generated by its CLI; `user.is_anonymous` from the anonymous plugin). Timestamps here are Postgres `timestamp`, Better Auth's convention. | — | ### Drizzle schema Copied from `apps/server/src/db/schema.ts` (game tables) and `apps/server/src/db/auth-schema.ts` (Better Auth); update both together. ```ts import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim"; import { bigint, boolean, index, integer, jsonb, pgEnum, pgTable, primaryKey, text, uniqueIndex, } from "drizzle-orm/pg-core"; import { user } from "./auth-schema"; // All times are Unix milliseconds (bigint), matching sim and the API. const time = (name: string) => bigint(name, { mode: "number" }); export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]); export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]); export const boardKind = pgEnum("board_kind", ["location", "tutorial"]); export const players = pgTable("players", { id: text("id").primaryKey(), /** The Better Auth user (a guest account in the base game). */ userId: text("user_id") .notNull() .unique() .references(() => user.id, { onDelete: "cascade" }), /** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */ worldSeed: bigint("world_seed", { mode: "number" }).notNull(), tutorialActive: boolean("tutorial_active").notNull().default(true), /** Where the tutorial loaner is (decision 0.5.26); null once the tutorial ends. */ loanerLocationId: text("loaner_location_id"), /** Tutorial runs resolved so far (CR-001). */ tutorialRunsCompleted: integer("tutorial_runs_completed").notNull().default(0), /** The one-time deal has been accepted (decision 0.5.30). */ dealAccepted: boolean("deal_accepted").notNull().default(false), createdAt: time("created_at").notNull(), }); export const companies = pgTable("companies", { id: text("id").primaryKey(), playerId: text("player_id") .notNull() .unique() .references(() => players.id, { onDelete: "cascade" }), name: text("name").notNull(), /** Row-locked by every endpoint that changes money or fleet state (Step 6.14). */ credits: bigint("credits", { mode: "number" }).notNull(), /** Owed to the lender (decision 0.5.32). */ loanBalance: bigint("loan_balance", { mode: "number" }).notNull().default(0), }); export const ships = pgTable( "ships", { id: text("id").primaryKey(), playerId: text("player_id") .notNull() .references(() => players.id, { onDelete: "cascade" }), modelId: text("model_id").notNull(), name: text("name").notNull(), status: activityStatus("status").notNull().default("idle"), /** Null while in flight. */ locationId: text("location_id"), createdAt: time("created_at").notNull(), }, (t) => [index("ships_player_idx").on(t.playerId)], ); export const pilots = pgTable( "pilots", { id: text("id").primaryKey(), playerId: text("player_id") .notNull() .references(() => players.id, { onDelete: "cascade" }), name: text("name").notNull(), isAvatar: boolean("is_avatar").notNull().default(false), xp: integer("xp").notNull().default(0), skills: jsonb("skills").$type>().notNull().default({}), skillPointsSpent: integer("skill_points_spent").notNull().default(0), /** One pilot per ship (decision 0.5.2). */ assignedShipId: text("assigned_ship_id") .unique() .references(() => ships.id, { onDelete: "set null" }), /** Only unassigned pilots store a location (decision 0.5.27). */ locationId: text("location_id"), status: activityStatus("status").notNull().default("idle"), /** Game hours flown since the last rest (decision 0.5.42). */ dutyHours: doublePrecision("duty_hours").notNull().default(0), /** Resting until this time; null (or past) when not resting (decision 0.5.45). */ restEndsAt: time("rest_ends_at"), createdAt: time("created_at").notNull(), }, (t) => [index("pilots_player_idx").on(t.playerId)], ); export const bays = pgTable( "bays", { id: text("id").primaryKey(), playerId: text("player_id") .notNull() .references(() => players.id, { onDelete: "cascade" }), locationId: text("location_id").notNull(), createdAt: time("created_at").notNull(), }, (t) => [uniqueIndex("bays_player_location_idx").on(t.playerId, t.locationId)], ); export const discoveredLocations = pgTable( "discovered_locations", { playerId: text("player_id") .notNull() .references(() => players.id, { onDelete: "cascade" }), locationId: text("location_id").notNull(), discoveredAt: time("discovered_at").notNull(), }, (t) => [primaryKey({ columns: [t.playerId, t.locationId] })], ); /** * One stored board per bay per period, and one tutorial board per period per * loaner location (decisions 0.5.5 and 0.5.21). `locationId` is the bay's or * the loaner's location. */ export const contractBoards = pgTable( "contract_boards", { id: text("id").primaryKey(), playerId: text("player_id") .notNull() .references(() => players.id, { onDelete: "cascade" }), kind: boardKind("kind").notNull(), locationId: text("location_id").notNull(), periodIndex: integer("period_index").notNull(), /** Tutorial boards only: runs completed when generated (0 for bay boards). */ tutorialRun: integer("tutorial_run").notNull().default(0), contracts: jsonb("contracts").$type().notNull(), createdAt: time("created_at").notNull(), }, (t) => [ uniqueIndex("contract_boards_unique_idx").on( t.playerId, t.kind, t.locationId, t.periodIndex, t.tutorialRun, ), ], ); export const dispatches = pgTable( "dispatches", { id: text("id").primaryKey(), playerId: text("player_id") .notNull() .references(() => players.id, { onDelete: "cascade" }), /** Null for a tutorial run (the loaner is not a ship row). */ shipId: text("ship_id").references(() => ships.id, { onDelete: "set null" }), pilotId: text("pilot_id") .notNull() .references(() => pilots.id, { onDelete: "cascade" }), tutorial: boolean("tutorial").notNull(), /** The full sim record, including a snapshot of the contract. */ record: jsonb("record").$type().notNull(), arrivalAt: time("arrival_at").notNull(), status: dispatchStatus("status").notNull().default("in_flight"), /** Applied effects, kept for the "while you were away" summary. */ resolution: jsonb("resolution").$type(), resolvedAt: time("resolved_at"), acknowledgedAt: time("acknowledged_at"), }, (t) => [ index("dispatches_due_idx").on(t.status, t.arrivalAt), index("dispatches_player_status_idx").on(t.playerId, t.status), ], ); export const acceptedContracts = pgTable( "accepted_contracts", { playerId: text("player_id") .notNull() .references(() => players.id, { onDelete: "cascade" }), contractId: text("contract_id").notNull(), periodIndex: integer("period_index").notNull(), dispatchId: text("dispatch_id") .notNull() .references(() => dispatches.id, { onDelete: "cascade" }), acceptedAt: time("accepted_at").notNull(), }, (t) => [primaryKey({ columns: [t.playerId, t.contractId] })], ); /** Onboarding goals whose reward has been claimed (decision 0.5.34). */ export const claimedGoals = pgTable( "claimed_goals", { playerId: text("player_id") .notNull() .references(() => players.id, { onDelete: "cascade" }), goalId: text("goal_id").notNull(), claimedAt: time("claimed_at").notNull(), }, (t) => [primaryKey({ columns: [t.playerId, t.goalId] })], ); ``` ```ts // Better Auth tables, generated by `@better-auth/cli generate` (anonymous plugin). // Regenerate rather than hand-edit when Better Auth or its plugins change. import { relations } from "drizzle-orm"; import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; export const user = pgTable("user", { id: text("id").primaryKey(), name: text("name").notNull(), email: text("email").notNull().unique(), emailVerified: boolean("email_verified").default(false).notNull(), image: text("image"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), isAnonymous: boolean("is_anonymous").default(false), }); export const session = pgTable( "session", { id: text("id").primaryKey(), expiresAt: timestamp("expires_at").notNull(), token: text("token").notNull().unique(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), ipAddress: text("ip_address"), userAgent: text("user_agent"), userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), }, (table) => [index("session_userId_idx").on(table.userId)], ); export const account = pgTable( "account", { id: text("id").primaryKey(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), accessToken: text("access_token"), refreshToken: text("refresh_token"), idToken: text("id_token"), accessTokenExpiresAt: timestamp("access_token_expires_at"), refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), scope: text("scope"), password: text("password"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, (table) => [index("account_userId_idx").on(table.userId)], ); export const verification = pgTable( "verification", { id: text("id").primaryKey(), identifier: text("identifier").notNull(), value: text("value").notNull(), expiresAt: timestamp("expires_at").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, (table) => [index("verification_identifier_idx").on(table.identifier)], ); export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), })); export const sessionRelations = relations(session, ({ one }) => ({ user: one(user, { fields: [session.userId], references: [user.id], }), })); export const accountRelations = relations(account, ({ one }) => ({ user: one(user, { fields: [account.userId], references: [user.id], }), })); ``` ## Auth and sessions Better Auth (`apps/server/src/auth.ts`) with the anonymous plugin, mounted only through its server API (no `/api/auth/*` HTTP handler yet). `POST /api/guest` calls `signInAnonymous`, forwards its session cookie, and creates the player (`createPlayer`) linked to the new user. Authenticated routes call `requirePlayerId`, which resolves the cookie to a session, then to `players.user_id`; failures are `401 unauthorized`. ## Request pipeline (`apps/server/src/http`) Every catalog endpoint is registered with `registerRoute(app, routes., options, handler)`: 1. Merge path parameters, query and body, and validate them against the route's `request` schema → `400 validation-error` on failure (also for malformed JSON). 2. If `route.auth`, resolve the session to a player id → `401 unauthorized` on failure. 3. Run the handler with the typed input and `{ playerId, request, reply }`. 4. Outside production, validate the result against the route's `response` schema; a mismatch is logged and returned as `500 internal-error`. Handlers throw `ApiError(status, code, message)` for expected failures. Unknown paths return `404 not-found`. ## Game logic (`apps/server/src/game`) | Module | Responsibility | |---|---| | `lock.ts` | `withPlayerLock`: transaction + `FOR UPDATE` on the player's company row | | `resolve.ts` | `resolveArrivedDispatches`: exactly-once resolution of due dispatches (lazy on every request; Phase 7 job) | | `tutorial.ts` | `maybeEndTutorial` (decision 0.5.4) | | `boards.ts` | Stored tutorial and bay boards per period; accepted contracts hidden | | `dispatch.ts` | Tutorial, contract and reposition dispatch; `storeDispatch` (record, accepted contract, fuel) | | `fleet.ts` | Buy ship, lease bay, `spend` credits | | `pilots.ts` | Hire, assign, unassign, spend skill point | | `summaries.ts` | Acknowledge "while you were away" entries | | `player-state.ts`, `mappers.ts` | Database rows to API shapes | | `names.ts` | Seeded ship and pilot names | Routes (`apps/server/src/routes`) wrap every authenticated action in `withPlayer`: lock, resolve due arrivals, run the action, then `maybeEndTutorial`. Game-rule failures are `409` with their error code; unknown things are `404`. ## Background jobs (`apps/server/src/jobs`) pg-boss (`queue.ts`) in the app's Postgres database (schema `pgboss`), behind a `JobQueue` interface; tests use a recording fake and call handlers directly. Queues are always created; workers and cron schedules run only when `WORKERS_ENABLED=true`. | Job | Trigger | Does | |---|---|---| | `resolve-arrivals` | Sent after each dispatch commits, `startAfter` = arrival (on the real clock) | `resolveArrivedDispatches` under the player lock; no-op if a request already resolved it | | `cleanup-expired-contracts` | Cron `0 * * * *` | Deletes boards and accepted contracts older than 24 periods | The app clock is `base clock + offset`; the offset is set only by the test-only `POST /api/test/clock-offset`, which is not registered in production. ## Deployment (`deploy/`) | File | Role | |---|---| | `server.Dockerfile` | API image: production dependencies via `pnpm deploy`, TypeScript run with tsx, migrations then listen; non-root; health check on `/api/health` | | `compose.yml` | Production stack (project `deadhead`): Postgres 18 (named volume, not published), the server, `web` (Caddy on `HTTP_PORT`/`HTTPS_PORT`, certificate volumes) and `backup` (`BACKUP_DIR` mounted at `/backups`) | | `web.Dockerfile`, `Caddyfile` | Caddy image with the built app: `/api/*` to `server:3000`, client routes fall back to `index.html`, hashed assets cached for a year, shell and service worker never; HTTPS for `SITE_ADDRESS` | | `backup/` | `schedule.sh` (daily at `BACKUP_HOUR`), `backup.sh` (`pg_dump` to `BACKUP_DIR`, retention `BACKUP_KEEP_DAYS`), `restore.sh` (a dump into a database, default the newest into `deadhead_restore`); run by the `backup` service (Postgres image) | | `README.md` | Home-server setup: DNS, port forwarding, `.env`, start/update, LAN-only mode, backups and restores | | `.env.example` | `PUBLIC_URL` (also Better Auth's URL), `SITE_ADDRESS`, host ports, Postgres credentials, `BETTER_AUTH_SECRET`, log level; copy to `deploy/.env` | | `check.sh` | Builds and starts the stack under a throwaway project, checks it, removes it | ## Client (`apps/web`) Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In development, Vite proxies `/api` to the server at `127.0.0.1:3000` (same origin, decision 0.5.20). | Module | Role | |---|---| | `src/api/client.ts` | `createApiClient().call(route, input)`: typed by the route catalog; validates responses; throws `ApiError` (server code) or `ApiValidationError` | | `src/api/context.tsx` | `ApiProvider` / `useApi` (tests inject a fake) | | `src/clock/clock.ts` | Zustand `useClock`: server offset (last one kept in `deadhead:clock-offset` for offline starts), `serverNow()`, 1 s ticker | | `src/state/player.ts` | `usePlayerState` (TanStack Query, key `player-state`): `null` means no session; refetch on focus and on arrival | | `src/App.tsx` | Clock sync; loading / start screen / routed layout | | `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar | | `src/routes.ts` | Screen list shared by router and navigation | | `src/map/art/manifest.json` | Map art manifest (CR-003): asset id → `{ kind, file }` in `public/assets/map/` (never edited); `null` file = missing, placeholder used | | `scripts/map-art.mjs` | Generates `public/assets/map-gen/` (`@1x.png`, `@2x.png`, `bg.webp`) from the manifest with sharp; output committed | | `src/map/style.ts` | Map presentation config (CR-003): sprite sizes, zoom scale limits, child-body offsets, badges, tap size, parallax, colors; validated by `mapStyleSchema` | | `src/map/art/load.ts`, `art/visuals.ts` | Load the generated art as one PixiJS bundle (2x at DPR ≥ 2); texture-or-placeholder per scene item (decision 0.5.41) | | `src/map/layout.ts` | Pure screen layout: `backgroundRect`, `spriteScale`, `childOffset`, `layoutScene` (centres, sizes, tap targets; used by renderer and hit test), `decorate` (label and badge placement) | | `src/map/overlays.ts` | Per-location state overlays (lock, desaturate/dim, HQ marker, idle badge), applied by the renderer | | `src/map/ticker.ts` | `runWhileVisible`: pause the map's ticker while the page is hidden; stop it on unmount | | `src/map/renderer.ts` | `MapRenderer`: all PixiJS drawing for the map, layer by layer; `MapCanvas` owns the app, input and loading state | | `src/state/connection.ts` | Offline (Step 11.2): Zustand `useConnection` (`online`, `lastUpdate`), browser online/offline events, and the last player state cache (`deadhead:last-state`, schema-checked on load) | | `vite.config.ts` (PWA) | `vite-plugin-pwa` (Step 11.1): manifest, generateSW precache of the shell, woff2 fonts, `map-gen` art and icons (`public/icons`, from `scripts/icons.mjs`); `/api` never cached | | `e2e/*.e2e.ts` | Playwright tests against `vite preview` of the build (install, offline); the API is faked with route interception | | `src/state/preferences.ts` | Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera; local storage, every access wrapped | | `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen | | `src/components/*` | `AwaySummary` dialog, `Countdown` | | `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) | | `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) | | `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting | Since CR-014 the client runs the game itself: `src/platform/game.ts` (storage, device clock, tab lock, engine), `src/api/engine-client.ts` (the `ApiClient` over engine commands), `src/GameRoot.tsx` (tab lock screen, resolution loop, the `window.deadhead` test hook in developer builds). The sections on HTTP, sessions and offline mode below are historical until the server is removed (14.11). Session: Better Auth's cookie (`credentials: "include"`). On the device the client keeps only caches and preferences: the last player state and clock offset (offline start), device preferences, the device id and seen board ids. Offline (Step 11.2): a request that gets no reply marks the app offline and `getPlayerState` falls back to the cached state; the banner reads "Offline — showing last update from HH:MM". Buttons with `needsServer` (every server action and the sheets that lead to one) are disabled while offline; the away summary and the deal wait until back online. The browser's `online` event refetches.