Compare commits

..

No commits in common. "5754b7ca25504dd8ee45d2197debaf14538c3cb6" and "439c35ae3515124bf35a4d25602c956fa96eb32f" have entirely different histories.

25 changed files with 11 additions and 3117 deletions

View File

@ -7,21 +7,6 @@ on:
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
# Test database for server integration tests.
postgres:
image: postgres:18
env:
POSTGRES_USER: deadhead
POSTGRES_PASSWORD: deadhead
POSTGRES_DB: deadhead_test
options: >-
--health-cmd "pg_isready -U deadhead -d deadhead_test"
--health-interval 2s
--health-timeout 5s
--health-retries 30
env:
TEST_DATABASE_URL: postgres://deadhead:deadhead@postgres:5432/deadhead_test
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6

View File

@ -1,4 +1,3 @@
pnpm-lock.yaml pnpm-lock.yaml
memory-bank/ memory-bank/
.pnpm-store/ .pnpm-store/
apps/server/drizzle/

View File

@ -200,28 +200,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Each route has an `auth` flag.** Only `getServerTime` and `createGuest` work without a session. - **Each route has an `auth` flag.** Only `getServerTime` and `createGuest` work without a session.
- **REST-style paths:** collections are created with `POST` (`/api/ships`, `/api/bays`, `/api/pilots`, `/api/dispatches`), and pilot actions are sub-resources (`/api/pilots/:pilotId/assign`). - **REST-style paths:** collections are created with `POST` (`/api/ships`, `/api/bays`, `/api/pilots`, `/api/dispatches`), and pilot actions are sub-resources (`/api/pilots/:pilotId/assign`).
- **The catalog is an `as const` object** that satisfies `Record<string, RouteDef>`, so the server and client get exact request and response types per route name (`RouteRequest<"buyShip">`). - **The catalog is an `as const` object** that satisfies `Record<string, RouteDef>`, so the server and client get exact request and response types per route name (`RouteRequest<"buyShip">`).
## Step 5.1
- **The server runs with `tsx`** (`pnpm --filter @deadhead/server dev` / `start`). Workspace packages ship TypeScript source with extensionless imports, which Node's built-in type stripping cannot resolve. There is no build step yet.
- **Config** comes from env vars validated with Zod in `loadConfig`, which reports every problem at once. `NODE_ENV`, `PORT` and `LOG_LEVEL` have defaults (development, 3000, info). The entry point loads the repo-root `.env` with `process.loadEnvFile` when the file exists; deployments set real env vars.
- **The health endpoint is `GET /api/health`**, under `/api` so it is reachable through the same-domain proxy (decision 0.5.20). It is infrastructure, so it is not in the route catalog.
- **`buildApp(config)` does not listen,** so tests start it on port 0. Logs are pino JSON; tests use `LOG_LEVEL=silent`.
## Step 5.2
- **The injectable clock arrives now, not in Step 7.2.** `buildApp(config, { clock })` takes a `Clock` (`() => RealTime`) and defaults to `Date.now`. Every endpoint will read "now" through it (decision 0.5.18). Step 7.2 still adds the test-only clock-offset endpoint.
- **Game time uses the default `content.config`**, via sim `realToGame`.
## Step 5.3
- **Every game table references `players.id`.** Players and companies are one-to-one; `companies` holds credits and is the row that money-changing endpoints lock (Step 6.14).
- **Times are `bigint` Unix ms** (Drizzle `mode: "number"`), like sim and the API. World seeds are `bigint` because unsigned 32-bit values overflow `integer`.
- **Status enums use `in_flight`** in the database, as the plan words it; the API uses `in-flight`, so the server maps between them.
- **Dispatches store the full sim `DispatchRecord` as JSONB**, plus the columns queries need (`status`, `arrival_at`, `resolved_at`, `acknowledged_at`, `tutorial`, `ship_id`, `pilot_id`). The applied `Resolution` is kept as JSONB for the summary. A tutorial dispatch has `ship_id` null, because the loaner is not a ship row.
- **Pilot skills are a JSONB `{ skillId: level }` map**, plus a `skill_points_spent` counter. Level is derived from XP, not stored.
- **Contract boards store their contracts as JSONB**, keyed by player, kind (`bay` / `tutorial`), location (the bay or the loaner's location) and period.
- **Auth tables are not here yet;** Better Auth's tables and the link from players to users arrive in Step 5.5.
- **Migrations are generated SQL** (`drizzle-kit generate`) committed in `apps/server/drizzle/`, which Prettier ignores. They are applied with the drizzle-orm migrator (`db:migrate`, or `runMigrations(url)` in tests).
- **Server test files run one at a time** (`apps/server/vitest.config.ts`) because integration tests share one test database.
- **CI runs a `postgres:18` service container** and points `TEST_DATABASE_URL` at it; locally the default is the compose database `deadhead_test`.

View File

@ -1,8 +0,0 @@
import { defineConfig } from "drizzle-kit";
// `pnpm --filter @deadhead/server db:generate` writes SQL migrations to ./drizzle.
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./drizzle",
});

View File

@ -1,109 +0,0 @@
CREATE TYPE "public"."activity_status" AS ENUM('idle', 'in_flight');--> statement-breakpoint
CREATE TYPE "public"."board_kind" AS ENUM('bay', 'tutorial');--> statement-breakpoint
CREATE TYPE "public"."dispatch_status" AS ENUM('in_flight', 'resolved');--> statement-breakpoint
CREATE TABLE "accepted_contracts" (
"player_id" text NOT NULL,
"contract_id" text NOT NULL,
"period_index" integer NOT NULL,
"dispatch_id" text NOT NULL,
"accepted_at" bigint NOT NULL,
CONSTRAINT "accepted_contracts_player_id_contract_id_pk" PRIMARY KEY("player_id","contract_id")
);
--> statement-breakpoint
CREATE TABLE "bays" (
"id" text PRIMARY KEY NOT NULL,
"player_id" text NOT NULL,
"location_id" text NOT NULL,
"created_at" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "companies" (
"id" text PRIMARY KEY NOT NULL,
"player_id" text NOT NULL,
"name" text NOT NULL,
"credits" bigint NOT NULL,
CONSTRAINT "companies_player_id_unique" UNIQUE("player_id")
);
--> statement-breakpoint
CREATE TABLE "contract_boards" (
"id" text PRIMARY KEY NOT NULL,
"player_id" text NOT NULL,
"kind" "board_kind" NOT NULL,
"location_id" text NOT NULL,
"period_index" integer NOT NULL,
"contracts" jsonb NOT NULL,
"created_at" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "discovered_locations" (
"player_id" text NOT NULL,
"location_id" text NOT NULL,
"discovered_at" bigint NOT NULL,
CONSTRAINT "discovered_locations_player_id_location_id_pk" PRIMARY KEY("player_id","location_id")
);
--> statement-breakpoint
CREATE TABLE "dispatches" (
"id" text PRIMARY KEY NOT NULL,
"player_id" text NOT NULL,
"ship_id" text,
"pilot_id" text NOT NULL,
"tutorial" boolean NOT NULL,
"record" jsonb NOT NULL,
"arrival_at" bigint NOT NULL,
"status" "dispatch_status" DEFAULT 'in_flight' NOT NULL,
"resolution" jsonb,
"resolved_at" bigint,
"acknowledged_at" bigint
);
--> statement-breakpoint
CREATE TABLE "pilots" (
"id" text PRIMARY KEY NOT NULL,
"player_id" text NOT NULL,
"name" text NOT NULL,
"is_avatar" boolean DEFAULT false NOT NULL,
"xp" integer DEFAULT 0 NOT NULL,
"skills" jsonb DEFAULT '{}'::jsonb NOT NULL,
"skill_points_spent" integer DEFAULT 0 NOT NULL,
"assigned_ship_id" text,
"location_id" text,
"status" "activity_status" DEFAULT 'idle' NOT NULL,
"created_at" bigint NOT NULL,
CONSTRAINT "pilots_assigned_ship_id_unique" UNIQUE("assigned_ship_id")
);
--> statement-breakpoint
CREATE TABLE "players" (
"id" text PRIMARY KEY NOT NULL,
"world_seed" bigint NOT NULL,
"tutorial_active" boolean DEFAULT true NOT NULL,
"loaner_location_id" text,
"created_at" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ships" (
"id" text PRIMARY KEY NOT NULL,
"player_id" text NOT NULL,
"model_id" text NOT NULL,
"name" text NOT NULL,
"status" "activity_status" DEFAULT 'idle' NOT NULL,
"location_id" text,
"created_at" bigint NOT NULL
);
--> statement-breakpoint
ALTER TABLE "accepted_contracts" ADD CONSTRAINT "accepted_contracts_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "accepted_contracts" ADD CONSTRAINT "accepted_contracts_dispatch_id_dispatches_id_fk" FOREIGN KEY ("dispatch_id") REFERENCES "public"."dispatches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "bays" ADD CONSTRAINT "bays_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "companies" ADD CONSTRAINT "companies_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contract_boards" ADD CONSTRAINT "contract_boards_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "discovered_locations" ADD CONSTRAINT "discovered_locations_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dispatches" ADD CONSTRAINT "dispatches_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dispatches" ADD CONSTRAINT "dispatches_ship_id_ships_id_fk" FOREIGN KEY ("ship_id") REFERENCES "public"."ships"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dispatches" ADD CONSTRAINT "dispatches_pilot_id_pilots_id_fk" FOREIGN KEY ("pilot_id") REFERENCES "public"."pilots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pilots" ADD CONSTRAINT "pilots_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pilots" ADD CONSTRAINT "pilots_assigned_ship_id_ships_id_fk" FOREIGN KEY ("assigned_ship_id") REFERENCES "public"."ships"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ships" ADD CONSTRAINT "ships_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "bays_player_location_idx" ON "bays" USING btree ("player_id","location_id");--> statement-breakpoint
CREATE UNIQUE INDEX "contract_boards_unique_idx" ON "contract_boards" USING btree ("player_id","kind","location_id","period_index");--> statement-breakpoint
CREATE INDEX "dispatches_due_idx" ON "dispatches" USING btree ("status","arrival_at");--> statement-breakpoint
CREATE INDEX "dispatches_player_status_idx" ON "dispatches" USING btree ("player_id","status");--> statement-breakpoint
CREATE INDEX "pilots_player_idx" ON "pilots" USING btree ("player_id");--> statement-breakpoint
CREATE INDEX "ships_player_idx" ON "ships" USING btree ("player_id");

View File

@ -1,842 +0,0 @@
{
"id": "1390d91d-5047-43d3-984f-06f82cb52ab5",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accepted_contracts": {
"name": "accepted_contracts",
"schema": "",
"columns": {
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"contract_id": {
"name": "contract_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"period_index": {
"name": "period_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"dispatch_id": {
"name": "dispatch_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"accepted_at": {
"name": "accepted_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"accepted_contracts_player_id_players_id_fk": {
"name": "accepted_contracts_player_id_players_id_fk",
"tableFrom": "accepted_contracts",
"tableTo": "players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"accepted_contracts_dispatch_id_dispatches_id_fk": {
"name": "accepted_contracts_dispatch_id_dispatches_id_fk",
"tableFrom": "accepted_contracts",
"tableTo": "dispatches",
"columnsFrom": [
"dispatch_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"accepted_contracts_player_id_contract_id_pk": {
"name": "accepted_contracts_player_id_contract_id_pk",
"columns": [
"player_id",
"contract_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.bays": {
"name": "bays",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location_id": {
"name": "location_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"bays_player_location_idx": {
"name": "bays_player_location_idx",
"columns": [
{
"expression": "player_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "location_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"bays_player_id_players_id_fk": {
"name": "bays_player_id_players_id_fk",
"tableFrom": "bays",
"tableTo": "players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.companies": {
"name": "companies",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credits": {
"name": "credits",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"companies_player_id_players_id_fk": {
"name": "companies_player_id_players_id_fk",
"tableFrom": "companies",
"tableTo": "players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"companies_player_id_unique": {
"name": "companies_player_id_unique",
"nullsNotDistinct": false,
"columns": [
"player_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.contract_boards": {
"name": "contract_boards",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "board_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"location_id": {
"name": "location_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"period_index": {
"name": "period_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"contracts": {
"name": "contracts",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"contract_boards_unique_idx": {
"name": "contract_boards_unique_idx",
"columns": [
{
"expression": "player_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "kind",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "location_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "period_index",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"contract_boards_player_id_players_id_fk": {
"name": "contract_boards_player_id_players_id_fk",
"tableFrom": "contract_boards",
"tableTo": "players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.discovered_locations": {
"name": "discovered_locations",
"schema": "",
"columns": {
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location_id": {
"name": "location_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"discovered_at": {
"name": "discovered_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"discovered_locations_player_id_players_id_fk": {
"name": "discovered_locations_player_id_players_id_fk",
"tableFrom": "discovered_locations",
"tableTo": "players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"discovered_locations_player_id_location_id_pk": {
"name": "discovered_locations_player_id_location_id_pk",
"columns": [
"player_id",
"location_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.dispatches": {
"name": "dispatches",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ship_id": {
"name": "ship_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pilot_id": {
"name": "pilot_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"tutorial": {
"name": "tutorial",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"record": {
"name": "record",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"arrival_at": {
"name": "arrival_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "dispatch_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'in_flight'"
},
"resolution": {
"name": "resolution",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"resolved_at": {
"name": "resolved_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"acknowledged_at": {
"name": "acknowledged_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"dispatches_due_idx": {
"name": "dispatches_due_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "arrival_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"dispatches_player_status_idx": {
"name": "dispatches_player_status_idx",
"columns": [
{
"expression": "player_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"dispatches_player_id_players_id_fk": {
"name": "dispatches_player_id_players_id_fk",
"tableFrom": "dispatches",
"tableTo": "players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"dispatches_ship_id_ships_id_fk": {
"name": "dispatches_ship_id_ships_id_fk",
"tableFrom": "dispatches",
"tableTo": "ships",
"columnsFrom": [
"ship_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"dispatches_pilot_id_pilots_id_fk": {
"name": "dispatches_pilot_id_pilots_id_fk",
"tableFrom": "dispatches",
"tableTo": "pilots",
"columnsFrom": [
"pilot_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pilots": {
"name": "pilots",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_avatar": {
"name": "is_avatar",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"xp": {
"name": "xp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"skills": {
"name": "skills",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"skill_points_spent": {
"name": "skill_points_spent",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"assigned_ship_id": {
"name": "assigned_ship_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"location_id": {
"name": "location_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "activity_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'idle'"
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"pilots_player_idx": {
"name": "pilots_player_idx",
"columns": [
{
"expression": "player_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"pilots_player_id_players_id_fk": {
"name": "pilots_player_id_players_id_fk",
"tableFrom": "pilots",
"tableTo": "players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"pilots_assigned_ship_id_ships_id_fk": {
"name": "pilots_assigned_ship_id_ships_id_fk",
"tableFrom": "pilots",
"tableTo": "ships",
"columnsFrom": [
"assigned_ship_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pilots_assigned_ship_id_unique": {
"name": "pilots_assigned_ship_id_unique",
"nullsNotDistinct": false,
"columns": [
"assigned_ship_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.players": {
"name": "players",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"world_seed": {
"name": "world_seed",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"tutorial_active": {
"name": "tutorial_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"loaner_location_id": {
"name": "loaner_location_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ships": {
"name": "ships",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "activity_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'idle'"
},
"location_id": {
"name": "location_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ships_player_idx": {
"name": "ships_player_idx",
"columns": [
{
"expression": "player_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ships_player_id_players_id_fk": {
"name": "ships_player_id_players_id_fk",
"tableFrom": "ships",
"tableTo": "players",
"columnsFrom": [
"player_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.activity_status": {
"name": "activity_status",
"schema": "public",
"values": [
"idle",
"in_flight"
]
},
"public.board_kind": {
"name": "board_kind",
"schema": "public",
"values": [
"bay",
"tutorial"
]
},
"public.dispatch_status": {
"name": "dispatch_status",
"schema": "public",
"values": [
"in_flight",
"resolved"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1790991444530,
"tag": "0000_init",
"breakpoints": true
}
]
}

View File

@ -6,24 +6,6 @@
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
"scripts": { "scripts": {
"dev": "tsx watch src/main.ts", "typecheck": "tsc --noEmit"
"start": "tsx src/main.ts",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate-cli.ts"
},
"dependencies": {
"@deadhead/content": "workspace:*",
"@deadhead/schema": "workspace:*",
"@deadhead/sim": "workspace:*",
"drizzle-orm": "^0.45.3",
"fastify": "^5.12.5",
"pg": "^8.23.1",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/pg": "^8.23.1",
"drizzle-kit": "^0.31.11",
"tsx": "^4.23.15"
} }
} }

View File

@ -1,29 +0,0 @@
import { content } from "@deadhead/content";
import { routes, type ServerTime } from "@deadhead/schema";
import { realToGame } from "@deadhead/sim";
import Fastify, { type FastifyInstance } from "fastify";
import { type Clock, systemClock } from "./clock";
import type { ServerConfig } from "./config";
export interface AppDeps {
clock?: Clock;
}
/** Builds the Fastify app without listening, so tests can start it on any port. */
export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInstance {
const clock = deps.clock ?? systemClock;
const app = Fastify({
// Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise.
logger: { level: config.logLevel },
});
// Under /api so it is reachable through the same-domain proxy (decision 0.5.20).
app.get("/api/health", async () => ({ status: "ok" }));
app.get(routes.getServerTime.path, async (): Promise<ServerTime> => {
const realTime = clock();
return { realTime, gameTime: realToGame(realTime, content.config) };
});
return app;
}

View File

@ -1,6 +0,0 @@
import type { RealTime } from "@deadhead/sim";
/** The server's only source of "now" (decision 0.5.18), so tests can control time. */
export type Clock = () => RealTime;
export const systemClock: Clock = () => Date.now();

View File

@ -1,41 +0,0 @@
import { z } from "zod";
/** Server settings from environment variables (see `.env.example`). */
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(0).max(65535).default(3000),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
DATABASE_URL: z.url(),
TEST_DATABASE_URL: z.url().optional(),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.url(),
});
export interface ServerConfig {
env: "development" | "test" | "production";
port: number;
logLevel: z.infer<typeof envSchema>["LOG_LEVEL"];
databaseUrl: string;
testDatabaseUrl: string | undefined;
authSecret: string;
authUrl: string;
}
/** Validates the environment, throwing one error that lists every problem. */
export function loadConfig(env: Record<string, string | undefined>): ServerConfig {
const result = envSchema.safeParse(env);
if (!result.success) {
const problems = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
throw new Error(`Invalid server environment:\n${problems.join("\n")}`);
}
const e = result.data;
return {
env: e.NODE_ENV,
port: e.PORT,
logLevel: e.LOG_LEVEL,
databaseUrl: e.DATABASE_URL,
testDatabaseUrl: e.TEST_DATABASE_URL,
authSecret: e.BETTER_AUTH_SECRET,
authUrl: e.BETTER_AUTH_URL,
};
}

View File

@ -1,16 +0,0 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import pg from "pg";
import * as schema from "./schema";
export type Db = NodePgDatabase<typeof schema>;
export interface Database {
db: Db;
pool: pg.Pool;
close(): Promise<void>;
}
export function createDatabase(url: string): Database {
const pool = new pg.Pool({ connectionString: url });
return { db: drizzle(pool, { schema }), pool, close: () => pool.end() };
}

View File

@ -1,15 +0,0 @@
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { runMigrations } from "./migrate";
// `pnpm --filter @deadhead/server db:migrate` migrates DATABASE_URL.
const envFile = fileURLToPath(new URL("../../../../.env", import.meta.url));
if (existsSync(envFile)) process.loadEnvFile(envFile);
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL is not set");
process.exit(1);
}
await runMigrations(url);
console.log("Migrations applied");

View File

@ -1,15 +0,0 @@
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { createDatabase } from "./client";
export const MIGRATIONS_FOLDER = fileURLToPath(new URL("../../drizzle", import.meta.url));
/** Applies pending migrations. Already-applied ones are skipped, so it is safe to rerun. */
export async function runMigrations(url: string): Promise<void> {
const database = createDatabase(url);
try {
await migrate(database.db, { migrationsFolder: MIGRATIONS_FOLDER });
} finally {
await database.close();
}
}

View File

@ -1,174 +0,0 @@
import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
import {
bigint,
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
primaryKey,
text,
uniqueIndex,
} from "drizzle-orm/pg-core";
// 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(),
/** 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] })],
);

View File

@ -1,3 +1 @@
export { buildApp, type AppDeps } from "./app"; export {};
export { systemClock, type Clock } from "./clock";
export { loadConfig, type ServerConfig } from "./config";

View File

@ -1,18 +0,0 @@
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { buildApp } from "./app";
import { loadConfig } from "./config";
// Local development reads the repo-root .env; deployments set real env vars.
const envFile = fileURLToPath(new URL("../../../.env", import.meta.url));
if (existsSync(envFile)) process.loadEnvFile(envFile);
const config = loadConfig(process.env);
const app = buildApp(config);
try {
await app.listen({ host: "0.0.0.0", port: config.port });
} catch (error) {
app.log.error(error);
process.exit(1);
}

View File

@ -1,37 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { loadConfig } from "../src/config";
import { testConfig } from "./helpers";
describe("server skeleton", () => {
const app = buildApp(testConfig());
let baseUrl = "";
beforeAll(async () => {
baseUrl = await app.listen({ host: "127.0.0.1", port: 0 });
});
afterAll(() => app.close());
it("answers the health endpoint over HTTP", async () => {
const response = await fetch(`${baseUrl}/api/health`);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ status: "ok" });
});
});
describe("config loading", () => {
it("rejects a missing database URL and a short auth secret, listing both", () => {
expect(() =>
loadConfig({ BETTER_AUTH_SECRET: "short", BETTER_AUTH_URL: "http://localhost:3000" }),
).toThrow(/DATABASE_URL[\s\S]*BETTER_AUTH_SECRET|BETTER_AUTH_SECRET[\s\S]*DATABASE_URL/);
});
it("applies defaults", () => {
const config = loadConfig({
DATABASE_URL: "postgres://u:p@localhost:5432/db",
BETTER_AUTH_SECRET: "x".repeat(32),
BETTER_AUTH_URL: "http://localhost:3000",
});
expect(config).toMatchObject({ env: "development", port: 3000, logLevel: "info" });
});
});

View File

@ -1,17 +0,0 @@
import { loadConfig, type ServerConfig } from "../src/config";
/** Config for tests: silent logs, an ephemeral port, and the test database. */
export function testConfig(overrides: Record<string, string> = {}): ServerConfig {
const testDatabaseUrl =
process.env.TEST_DATABASE_URL ?? "postgres://deadhead:deadhead@localhost:5432/deadhead_test";
return loadConfig({
NODE_ENV: "test",
PORT: "0",
LOG_LEVEL: "silent",
DATABASE_URL: testDatabaseUrl,
TEST_DATABASE_URL: testDatabaseUrl,
BETTER_AUTH_SECRET: "test-secret-that-is-at-least-32-characters-long",
BETTER_AUTH_URL: "http://localhost:3000",
...overrides,
});
}

View File

@ -1,52 +0,0 @@
import pg from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runMigrations } from "../src/db/migrate";
import { testConfig } from "./helpers";
const url = testConfig().databaseUrl;
const client = new pg.Client({ connectionString: url });
const tables = async () =>
(
await client.query<{ table_name: string }>(
"select table_name from information_schema.tables where table_schema = 'public' order by table_name",
)
).rows.map((r) => r.table_name);
const appliedMigrations = async () =>
Number(
(await client.query<{ n: string }>("select count(*) as n from drizzle.__drizzle_migrations"))
.rows[0]!.n,
);
describe("database migrations", () => {
beforeAll(async () => {
await client.connect();
// Start from an empty database.
await client.query("drop schema if exists drizzle cascade");
await client.query("drop schema public cascade");
await client.query("create schema public");
});
afterAll(() => client.end());
it("creates every table on an empty database", async () => {
await runMigrations(url);
expect(await tables()).toEqual([
"accepted_contracts",
"bays",
"companies",
"contract_boards",
"discovered_locations",
"dispatches",
"pilots",
"players",
"ships",
]);
});
it("is a no-op when run a second time", async () => {
const before = { tables: await tables(), migrations: await appliedMigrations() };
await runMigrations(url);
expect({ tables: await tables(), migrations: await appliedMigrations() }).toEqual(before);
});
});

View File

@ -1,27 +0,0 @@
import { content } from "@deadhead/content";
import { serverTimeSchema } from "@deadhead/schema";
import { realToGame } from "@deadhead/sim";
import { afterAll, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { testConfig } from "./helpers";
describe("server time endpoint", () => {
const app = buildApp(testConfig());
afterAll(() => app.close());
it("returns the current real time and the matching game time", async () => {
const response = await app.inject({ method: "GET", url: "/api/time" });
expect(response.statusCode).toBe(200);
const body = serverTimeSchema.parse(response.json());
expect(Math.abs(body.realTime - Date.now())).toBeLessThan(2000);
expect(body.gameTime).toBe(realToGame(body.realTime, content.config));
});
it("reads time from the injected clock", async () => {
const fixed = Date.parse("2100-06-01T12:00:00Z");
const fixedApp = buildApp(testConfig(), { clock: () => fixed });
const body = (await fixedApp.inject({ method: "GET", url: "/api/time" })).json();
expect(body).toEqual({ realTime: fixed, gameTime: realToGame(fixed, content.config) });
await fixedApp.close();
});
});

View File

@ -1,8 +0,0 @@
import { defineProject } from "vitest/config";
// Integration tests share one test database, so test files run one at a time.
export default defineProject({
test: {
fileParallelism: false,
},
});

View File

@ -58,200 +58,3 @@ The `routes` catalog is the single source for both server and client. Each entry
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes | | `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes | | `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | 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` | World seed, tutorial flag, loaner location | pk `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`) |
### Drizzle schema
Copied from `apps/server/src/db/schema.ts`; 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";
// 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(),
/** 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] })],
);
```

View File

@ -42,12 +42,3 @@
- [x] **Step 4.1 — API data shapes.** `packages/schema/src`: entity schemas (`entities.ts`), error format and codes (`errors.ts`), request/response bodies (`requests.ts`). Error codes include every sim validation reason; sim now exports `DISPATCH_FAILURES` / `REPOSITION_FAILURES`. `architecture.md` started with the API data model. - [x] **Step 4.1 — API data shapes.** `packages/schema/src`: entity schemas (`entities.ts`), error format and codes (`errors.ts`), request/response bodies (`requests.ts`). Error codes include every sim validation reason; sim now exports `DISPATCH_FAILURES` / `REPOSITION_FAILURES`. `architecture.md` started with the API data model.
- [x] **Step 4.2 — API route catalog.** `packages/schema/src/routes.ts`: 13 typed endpoints under `/api` with request/response schemas and an `auth` flag, plus `RouteRequest` / `RouteResponse` helper types. Route table added to `architecture.md`. - [x] **Step 4.2 — API route catalog.** `packages/schema/src/routes.ts`: 13 typed endpoints under `/api` with request/response schemas and an `auth` flag, plus `RouteRequest` / `RouteResponse` helper types. Route table added to `architecture.md`.
## Phase 5 — Server Foundation
- [x] **Step 5.1 — Fastify skeleton.** `apps/server`: Zod-validated env config (`src/config.ts`), `buildApp` with pino JSON logging and `GET /api/health` (`src/app.ts`), entry point `src/main.ts` (loads repo-root `.env` if present). `pnpm --filter @deadhead/server dev|start` run it with tsx. Integration test starts the app on an ephemeral port and calls health over HTTP.
- [x] **Step 5.2 — Server time endpoint.** `GET /api/time` returns `{ realTime, gameTime }` from an injectable `Clock` (`src/clock.ts`, passed via `buildApp(config, { clock })`).
- [x] **Step 5.3 — Database schema and migrations.** Drizzle schema (`apps/server/src/db/schema.ts`, copied into `architecture.md`), first migration `drizzle/0000_init.sql`, `db:generate` / `db:migrate` scripts. Migration test runs against an emptied test database twice. CI now has a Postgres 18 service; server tests run one file at a time.
- [ ] Step 5.4 — Test database harness
- [ ] Step 5.5 — Guest accounts
- [ ] Step 5.6 — Request validation from the route catalog

File diff suppressed because it is too large Load Diff