428 lines
20 KiB
Markdown
428 lines
20 KiB
Markdown
# 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()`. | content, astronomy-engine |
|
|
| `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` |
|
|
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId` |
|
|
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
|
|
| `Bay` | `id`, `locationId` |
|
|
| `LocationState` | `id`, `discovered` |
|
|
| `Contract` | mirrors sim `Contract` |
|
|
| `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` |
|
|
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits`, `pilotXp`, `discoveredLocationId`, `resolvedAt` |
|
|
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight` |
|
|
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries` |
|
|
|
|
### 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, 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/bays/: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 |
|
|
| `buyShip` | `POST /api/ships` | `modelId` | `{ 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 |
|
|
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
|
|
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes |
|
|
|
|
## 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). 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 | pk `id`, unique `user_id` → `user.id` |
|
|
| `companies` | Name and credits (the row locked for money changes) | unique `player_id` |
|
|
| `ships` | Owned ships: model, name, status, location (null in flight) | — |
|
|
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | 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 bay boards and tutorial boards per period (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`) |
|
|
| `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`) |
|
|
| `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", ["bay", "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"),
|
|
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(),
|
|
});
|
|
|
|
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<Record<string, number>>().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"),
|
|
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(),
|
|
contracts: jsonb("contracts").$type<Contract[]>().notNull(),
|
|
createdAt: time("created_at").notNull(),
|
|
},
|
|
(t) => [
|
|
uniqueIndex("contract_boards_unique_idx").on(t.playerId, t.kind, t.locationId, t.periodIndex),
|
|
],
|
|
);
|
|
|
|
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<DispatchRecord>().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<Resolution>(),
|
|
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] })],
|
|
);
|
|
```
|
|
|
|
```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.<name>, 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.
|
|
|
|
## 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, `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/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + 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 |
|
|
|
|
Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself.
|