deadhead-space-freight-tycoon/memory-bank/architecture.md

16 KiB

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.

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] })],
);
// 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.