Step 14.11: retire the server
CI / check (push) Successful in 6m6s Details

Removes apps/server (Fastify, Drizzle, pg-boss, Better Auth), the
development Postgres, the backup code routes, the web HTTP client and
the CI database service. Deployment is static hosting with Caddy. A
Playwright test checks that the production build loads only its own
files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-09 23:50:05 -04:00
parent 30aa4cd5bd
commit 0125af2086
148 changed files with 161 additions and 23666 deletions

View File

@ -1,16 +0,0 @@
# Copy to .env and adjust. Values below match docker-compose.yml.
# Postgres: development and test databases (same server, see docker-compose.yml)
DATABASE_URL=postgres://deadhead:deadhead@localhost:5432/deadhead
TEST_DATABASE_URL=postgres://deadhead:deadhead@localhost:5432/deadhead_test
# Auth (Better Auth). Secret must be at least 32 random characters;
# generate one with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-me-to-a-long-random-string
BETTER_AUTH_URL=http://localhost:3000
# HTTP port the API server listens on
PORT=3000
# Run background job workers (arrival resolution, cleanup) in the API process
WORKERS_ENABLED=true

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

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
node_modules/ node_modules/
dist/ dist/
dist-*/
coverage/ coverage/
*.tsbuildinfo *.tsbuildinfo
.env .env

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

@ -1416,3 +1416,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"Share as file"** uses the Web Share API with a file where the device supports it (phones, including the Capacitor web view), otherwise a download. No Capacitor share plugin. - **"Share as file"** uses the Web Share API with a file where the device supports it (phones, including the Capacitor web view), otherwise a download. No Capacitor share plugin.
- **Only save version 1 exists**, so the "older save migrates" test injects a migration through the engine's `migrate` option; `applyMigrations`'s stepping is tested in the schema package. - **Only save version 1 exists**, so the "older save migrates" test injects a migration through the engine's `migrate` option; `applyMigrations`'s stepping is tested in the schema package.
- **Debug info reads storage through a small `GameContext`** that `GameRoot` provides; outside it (component tests) storage shows as "unknown". - **Debug info reads storage through a small `GameContext`** that `GameRoot` provides; outside it (component tests) storage shows as "unknown".
## Step 14.11
- **The route catalog stays as the command list**, with `method` and `path` kept: about 30 ported test files route by them, and renaming every command reference wouldn't change behavior. `auth` now reads as "needs a company".
- **The production-build check runs a real production build** (`dist-production`, built by `build:e2e` alongside the test build) rather than the test build with requests blocked: the test build has developer code the production one doesn't, so only the real build shows what players get. It records every request instead of blocking, so a stray request fails the test instead of being quietly aborted.
- **Deploying is static hosting with the same Caddy setup**, minus the API and backups: the domain, HTTPS and caching rules from Phase 13 still hold. Player saves live on devices; Export save is the backup.
- **The web Dockerfile was missing the engine package's manifest** since 14.7 (`pnpm install --filter @deadhead/web...` couldn't link it), found while rewriting `deploy/`; fixed here.

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", "./src/db/auth-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,57 +0,0 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"is_anonymous" boolean DEFAULT false,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "user_id" text NOT NULL;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint
ALTER TABLE "players" ADD CONSTRAINT "players_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "players" ADD CONSTRAINT "players_user_id_unique" UNIQUE("user_id");

View File

@ -1,14 +0,0 @@
CREATE TABLE "claimed_goals" (
"player_id" text NOT NULL,
"goal_id" text NOT NULL,
"claimed_at" bigint NOT NULL,
CONSTRAINT "claimed_goals_player_id_goal_id_pk" PRIMARY KEY("player_id","goal_id")
);
--> statement-breakpoint
DROP INDEX "contract_boards_unique_idx";--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN "loan_balance" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "contract_boards" ADD COLUMN "tutorial_run" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "tutorial_runs_completed" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "deal_accepted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "claimed_goals" ADD CONSTRAINT "claimed_goals_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 "contract_boards_unique_idx" ON "contract_boards" USING btree ("player_id","kind","location_id","period_index","tutorial_run");

View File

@ -1,5 +0,0 @@
ALTER TABLE "contract_boards" ALTER COLUMN "kind" SET DATA TYPE text;--> statement-breakpoint
UPDATE "contract_boards" SET "kind" = 'location' WHERE "kind" = 'bay';--> statement-breakpoint
DROP TYPE "public"."board_kind";--> statement-breakpoint
CREATE TYPE "public"."board_kind" AS ENUM('location', 'tutorial');--> statement-breakpoint
ALTER TABLE "contract_boards" ALTER COLUMN "kind" SET DATA TYPE "public"."board_kind" USING "kind"::"public"."board_kind";

View File

@ -1,2 +0,0 @@
ALTER TABLE "pilots" ADD COLUMN "duty_hours" double precision DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "pilots" ADD COLUMN "rest_ends_at" bigint;

View File

@ -1,36 +0,0 @@
-- CR-007 (Step 10G.6): contracts carry a good, a shipper and Priority fields.
-- Contracts live in JSONB (stored boards and dispatch snapshots), so no columns
-- change. Backfill contracts stored before the change so existing saves keep
-- working: a default good of the contract's class, the origin's first shipper,
-- and no Priority. Rows that already have a good are left alone, so running
-- this again changes nothing.
CREATE FUNCTION pg_temp.cr007_backfill(c jsonb) RETURNS jsonb AS $$
SELECT CASE WHEN c ? 'goodId' THEN c ELSE c || jsonb_build_object(
'goodId', CASE c->>'cargoTypeId'
WHEN 'heavy' THEN 'machine-parts'
WHEN 'high-value' THEN 'electronics'
WHEN 'dangerous' THEN 'industrial-chemicals'
ELSE 'consumer-goods' END,
'shipper', CASE c->>'originId'
WHEN 'moon' THEN 'Shackleton Mining Co.'
WHEN 'mars' THEN 'Jezero Agricultural Co-op'
WHEN 'ceres-station' THEN 'Belt Ore Consolidated'
WHEN 'jupiter-cloud-platform' THEN 'Galilean Fuel Combine'
ELSE 'Pacific Orbital Logistics' END,
'priority', false,
'deliveryWindowGameHours', null
) END
$$ LANGUAGE sql IMMUTABLE;
--> statement-breakpoint
UPDATE "contract_boards"
SET "contracts" = (
SELECT COALESCE(jsonb_agg(pg_temp.cr007_backfill(t.c) ORDER BY t.ord), '[]'::jsonb)
FROM jsonb_array_elements("contracts") WITH ORDINALITY AS t(c, ord)
)
WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements("contracts") AS e(c) WHERE NOT (e.c ? 'goodId')
);
--> statement-breakpoint
UPDATE "dispatches"
SET "record" = jsonb_set("record", '{contract}', pg_temp.cr007_backfill("record"->'contract'))
WHERE jsonb_typeof("record"->'contract') = 'object' AND NOT ("record"->'contract' ? 'goodId');

View File

@ -1,11 +0,0 @@
CREATE TABLE "ship_upgrades" (
"ship_id" text NOT NULL,
"upgrade_id" text NOT NULL,
"level" integer NOT NULL,
CONSTRAINT "ship_upgrades_ship_id_upgrade_id_pk" PRIMARY KEY("ship_id","upgrade_id")
);
--> statement-breakpoint
ALTER TABLE "ships" ADD COLUMN "in_shop_until" bigint;--> statement-breakpoint
ALTER TABLE "ships" ADD COLUMN "shop_upgrade_id" text;--> statement-breakpoint
ALTER TABLE "ships" ADD COLUMN "shop_level" integer;--> statement-breakpoint
ALTER TABLE "ship_upgrades" ADD CONSTRAINT "ship_upgrades_ship_id_ships_id_fk" FOREIGN KEY ("ship_id") REFERENCES "public"."ships"("id") ON DELETE cascade ON UPDATE no action;

View File

@ -1 +0,0 @@
ALTER TABLE "pilots" ADD COLUMN "is_veteran" boolean DEFAULT false NOT NULL;

View File

@ -1,11 +0,0 @@
CREATE TABLE "player_logins" (
"user_id" text PRIMARY KEY NOT NULL,
"player_id" text NOT NULL,
"created_at" bigint NOT NULL
);
--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "backup_code_hash" text;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "backup_code_created_at" bigint;--> statement-breakpoint
ALTER TABLE "player_logins" ADD CONSTRAINT "player_logins_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "player_logins" ADD CONSTRAINT "player_logins_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "players" ADD CONSTRAINT "players_backup_code_hash_unique" UNIQUE("backup_code_hash");

View File

@ -1,5 +0,0 @@
ALTER TABLE "ships" ADD COLUMN "from_deal" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- Backfill: each player's first ship came with the deal (CR-001), not from the Market.
UPDATE "ships" SET "from_deal" = true WHERE "id" IN (
SELECT DISTINCT ON ("player_id") "id" FROM "ships" ORDER BY "player_id", "created_at", "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": {}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,76 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1790991444530,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1790991751364,
"tag": "0001_auth",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1791039630016,
"tag": "0002_cr001_faster_start",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1791053200611,
"tag": "0003_cr002_location_boards",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1791078648879,
"tag": "0004_cr004_pilot_duty",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1791148094765,
"tag": "0005_cr007_goods",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1791155978006,
"tag": "0006_cr009_ship_upgrades",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1791164145228,
"tag": "0007_cr010_veteran_pilot",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1791171500580,
"tag": "0008_cr011_backup_codes",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1791214535055,
"tag": "0009_cr013_ship_from_deal",
"breakpoints": true
}
]
}

View File

@ -1,31 +0,0 @@
{
"name": "@deadhead/server",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"dev": "tsx watch src/main.ts",
"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:*",
"better-auth": "^1.7.7",
"drizzle-orm": "^0.45.3",
"fastify": "^5.12.5",
"pg": "^8.23.1",
"pg-boss": "^12.36.0",
"tsx": "^4.23.15",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/pg": "^8.23.1",
"drizzle-kit": "^0.31.11"
}
}

View File

@ -1,75 +0,0 @@
import Fastify, { type FastifyInstance } from "fastify";
import { createAuth } from "./auth";
import { type Clock, createOffsetClock, systemClock } from "./clock";
import type { ServerConfig } from "./config";
import { createDatabase, type Database } from "./db/client";
import { registerErrorHandler } from "./http/errors";
import { requirePlayerId } from "./http/session";
import { appJobs } from "./jobs/definitions";
import { createPgBossQueue, type JobQueue } from "./jobs/queue";
import { registerBoardRoutes } from "./routes/boards";
import type { AppContext } from "./routes/context";
import { registerDispatchRoutes } from "./routes/dispatches";
import { registerFleetRoutes } from "./routes/fleet";
import { registerPlayerRoutes } from "./routes/player";
import { registerDevRoutes } from "./routes/dev";
import { registerTestClockRoute } from "./routes/test-clock";
export interface AppDeps {
clock?: Clock;
/** An existing database (tests). Otherwise the app opens one and closes it on shutdown. */
database?: Database;
/** Background job queue. Defaults to pg-boss on the app's database. */
jobs?: JobQueue;
}
/** Builds the Fastify app without listening, so tests can start it on any port. */
export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInstance {
const database = deps.database ?? createDatabase(config.databaseUrl);
const { db } = database;
const auth = createAuth(db, { secret: config.authSecret, baseUrl: config.authUrl });
const clock = createOffsetClock(deps.clock ?? systemClock);
const jobs =
deps.jobs ??
createPgBossQueue(config.databaseUrl, {
workers: config.workersEnabled,
jobs: appJobs({ db, clock: clock.now }),
});
const ctx: AppContext = {
db,
auth,
clock: clock.now,
// pg-boss timers run on the real clock, not the (possibly offset) game clock.
toBaseTime: clock.toBase,
jobs,
options: {
requirePlayer: (request) => requirePlayerId(request, auth, db),
validateResponses: config.env !== "production",
},
};
const app = Fastify({
// Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise.
logger: { level: config.logLevel },
});
// The queue starts with the app and stops before the database closes.
app.addHook("onReady", () => jobs.start());
app.addHook("onClose", async () => {
await jobs.stop();
if (!deps.database) await database.close();
});
registerErrorHandler(app);
// Under /api so it is reachable through the same-domain proxy (decision 0.5.20).
app.get("/api/health", async () => ({ status: "ok" }));
registerPlayerRoutes(app, ctx);
registerBoardRoutes(app, ctx);
registerDispatchRoutes(app, ctx);
registerFleetRoutes(app, ctx);
if (config.env !== "production") {
registerTestClockRoute(app, clock);
registerDevRoutes(app, ctx, clock);
}
return app;
}

View File

@ -1,23 +0,0 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { anonymous } from "better-auth/plugins/anonymous";
import type { Db } from "./db/client";
import * as authSchema from "./db/auth-schema";
export interface AuthSettings {
secret: string;
baseUrl: string;
}
/** Better Auth with guest (anonymous) sign-in, storing users and sessions in Postgres. */
export function createAuth(db: Db, settings: AuthSettings) {
return betterAuth({
database: drizzleAdapter(db, { provider: "pg", schema: authSchema }),
secret: settings.secret,
baseURL: settings.baseUrl,
basePath: "/api/auth",
plugins: [anonymous()],
});
}
export type Auth = ReturnType<typeof createAuth>;

View File

@ -1,30 +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();
/**
* A clock that runs ahead of (or behind) its base clock by an adjustable offset.
* The test-only clock-offset endpoint moves it; production never does (Step 7.2).
*/
export interface OffsetClock {
now: Clock;
offsetMs(): number;
setOffset(ms: number): void;
/** Converts a time on this clock to the base clock, e.g. for pg-boss timers. */
toBase(time: RealTime): RealTime;
}
export function createOffsetClock(base: Clock): OffsetClock {
let offset = 0;
return {
now: () => base() + offset,
offsetMs: () => offset,
setOffset: (ms) => {
offset = ms;
},
toBase: (time) => time - offset,
};
}

View File

@ -1,45 +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(),
/** Run background job workers in this process (Step 7.1). */
WORKERS_ENABLED: z.enum(["true", "false"]).default("true"),
});
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;
workersEnabled: boolean;
}
/** 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,
workersEnabled: e.WORKERS_ENABLED === "true",
};
}

View File

@ -1,96 +0,0 @@
// 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],
}),
}));

View File

@ -1,23 +0,0 @@
import type { PgDatabase } from "drizzle-orm/pg-core";
import { drizzle, type NodePgDatabase, type NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
import pg from "pg";
import * as authSchema from "./auth-schema";
import * as gameSchema from "./schema";
const schema = { ...gameSchema, ...authSchema };
type Schema = typeof schema;
export type Db = NodePgDatabase<Schema>;
/** The database or an open transaction: anything that can run queries. */
export type Queryable = PgDatabase<NodePgQueryResultHKT, 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,254 +0,0 @@
import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim";
import {
bigint,
boolean,
doublePrecision,
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(),
/** SHA-256 of the player's backup code (decision 0.5.72); null until one is made. */
backupCodeHash: text("backup_code_hash").unique(),
backupCodeCreatedAt: time("backup_code_created_at"),
});
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"),
/** In the upgrade shop until this time (decision 0.5.66); null or past: not. */
inShopUntil: time("in_shop_until"),
/**
* The latest shop job (upgrade and level), kept after it finishes until the
* player has seen it ("out of the shop"); null when none or acknowledged.
*/
shopUpgradeId: text("shop_upgrade_id"),
shopLevel: integer("shop_level"),
/** Handed over in the deal rather than bought from the Market (CR-013 goal 3). */
fromDeal: boolean("from_deal").notNull().default(false),
createdAt: time("created_at").notNull(),
},
(t) => [index("ships_player_idx").on(t.playerId)],
);
/** Installed upgrade levels per ship (CR-009). A missing row is level 0. */
export const shipUpgrades = pgTable(
"ship_upgrades",
{
shipId: text("ship_id")
.notNull()
.references(() => ships.id, { onDelete: "cascade" }),
upgradeId: text("upgrade_id").notNull(),
level: integer("level").notNull(),
},
(t) => [primaryKey({ columns: [t.shipId, t.upgradeId] })],
);
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),
/** The veteran who came with the deal (decision 0.5.70); otherwise a normal hired pilot. */
isVeteran: boolean("is_veteran").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"),
/** 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<Contract[]>().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<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] })],
);
/** 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] })],
);
/**
* Extra sign-ins for a player (decisions 0.5.72, 0.5.73): each device that
* restores with a backup code gets its own guest user, linked here.
*/
export const playerLogins = pgTable("player_logins", {
userId: text("user_id")
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
createdAt: time("created_at").notNull(),
});

View File

@ -1,96 +0,0 @@
import { createHash, randomInt } from "node:crypto";
import type { RealTime } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { players } from "../db/schema";
/**
* Backup codes (decision 0.5.72): 20 characters from 31 that can't be mistaken
* for each other (no 0/O, 1/I/L), shown as four groups of five: about 99 bits.
* Only a SHA-256 of the normalized code is stored; the code itself is random
* enough that a slow hash would add nothing.
*/
export const BACKUP_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
const GROUPS = 4;
const GROUP_LENGTH = 5;
export function generateBackupCode(): string {
const groups = Array.from({ length: GROUPS }, () =>
Array.from(
{ length: GROUP_LENGTH },
() => BACKUP_ALPHABET[randomInt(BACKUP_ALPHABET.length)],
).join(""),
);
return groups.join("-");
}
/** The code without spaces or dashes, in capitals: how players type it doesn't matter. */
export function normalizeBackupCode(code: string): string {
return code.toUpperCase().replace(/[\s-]/g, "");
}
export function hashBackupCode(code: string): string {
return createHash("sha256").update(normalizeBackupCode(code)).digest("hex");
}
/** Makes a new code for the player, replacing any old one; returns it once. */
export async function createBackupCode(
tx: Queryable,
playerId: string,
now: RealTime,
): Promise<{ code: string; createdAt: RealTime }> {
const code = generateBackupCode();
await tx
.update(players)
.set({ backupCodeHash: hashBackupCode(code), backupCodeCreatedAt: now })
.where(eq(players.id, playerId));
return { code, createdAt: now };
}
export async function backupCodeStatus(tx: Queryable, playerId: string) {
const [row] = await tx
.select({ hash: players.backupCodeHash, createdAt: players.backupCodeCreatedAt })
.from(players)
.where(eq(players.id, playerId));
return { hasCode: Boolean(row?.hash), createdAt: row?.hash ? (row.createdAt ?? null) : null };
}
/** The player a backup code belongs to, or null. */
export async function playerForBackupCode(tx: Queryable, code: string): Promise<string | null> {
if (normalizeBackupCode(code).length === 0) return null;
const [row] = await tx
.select({ id: players.id })
.from(players)
.where(eq(players.backupCodeHash, hashBackupCode(code)));
return row?.id ?? null;
}
/**
* Failed restore attempts per key (an IP, a device), in memory: at most
* `limit` failures per `windowMs` for any key (decision 0.5.72).
*/
export class RestoreLimiter {
private readonly failures = new Map<string, { count: number; resetAt: RealTime }>();
constructor(
readonly limit = 5,
readonly windowMs = 15 * 60_000,
) {}
/** Whether any of the keys has used up its attempts. */
blocked(keys: readonly string[], now: RealTime): boolean {
return keys.some((key) => {
const entry = this.failures.get(key);
return entry !== undefined && entry.resetAt > now && entry.count >= this.limit;
});
}
fail(keys: readonly string[], now: RealTime): void {
for (const key of keys) {
const entry = this.failures.get(key);
if (!entry || entry.resetAt <= now)
this.failures.set(key, { count: 1, resetAt: now + this.windowMs });
else entry.count++;
}
}
}

View File

@ -1,198 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import {
boardPeriodIndex,
boardPeriodMs,
generateContractBoard,
generateTutorialBoard,
type Contract,
type FleetMember,
type RealTime,
} from "@deadhead/sim";
import { and, asc, eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
acceptedContracts,
contractBoards,
discoveredLocations,
pilots,
players,
ships,
} from "../db/schema";
import { gameError } from "../http/errors";
import { loadUpgradeLevels } from "./upgrades";
export interface BoardView {
contracts: Contract[];
periodEndsAt: RealTime;
}
/**
* Returns the stored board for (player, kind, location, period), generating and
* storing it on the first request (decisions 0.5.5 and 0.5.21). Concurrent
* first requests store one board: the insert ignores conflicts and re-reads.
*/
export async function getOrCreateBoard(
tx: Queryable,
key: {
playerId: string;
kind: "location" | "tutorial";
locationId: string;
periodIndex: number;
/** Tutorial boards: runs completed (CR-001). Location boards: 0. */
tutorialRun?: number;
},
generate: () => Contract[] | Promise<Contract[]>,
now: RealTime,
): Promise<Contract[]> {
const where = and(
eq(contractBoards.playerId, key.playerId),
eq(contractBoards.kind, key.kind),
eq(contractBoards.locationId, key.locationId),
eq(contractBoards.periodIndex, key.periodIndex),
eq(contractBoards.tutorialRun, key.tutorialRun ?? 0),
);
const [existing] = await tx.select().from(contractBoards).where(where);
if (existing) return existing.contracts;
await tx
.insert(contractBoards)
.values({
id: randomUUID(),
...key,
tutorialRun: key.tutorialRun ?? 0,
contracts: await generate(),
createdAt: now,
})
.onConflictDoNothing();
const [stored] = await tx.select().from(contractBoards).where(where);
return stored!.contracts;
}
export async function acceptedContractIds(tx: Queryable, playerId: string): Promise<Set<string>> {
const rows = await tx
.select({ contractId: acceptedContracts.contractId })
.from(acceptedContracts)
.where(eq(acceptedContracts.playerId, playerId));
return new Set(rows.map((r) => r.contractId));
}
/** Board contracts minus the ones this player already accepted. */
export async function boardView(
tx: Queryable,
playerId: string,
contracts: Contract[],
periodIndex: number,
): Promise<BoardView> {
const accepted = await acceptedContractIds(tx, playerId);
return {
contracts: contracts.filter((c) => !accepted.has(c.id)),
periodEndsAt: (periodIndex + 1) * boardPeriodMs(content.config),
};
}
/** The tutorial board for the current run and period at the loaner's location (Steps 6.3, 10A.7). */
export async function tutorialBoard(
tx: Queryable,
playerId: string,
now: RealTime,
): Promise<BoardView> {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player?.tutorialActive || !player.loanerLocationId) {
throw gameError("not-in-tutorial", "The tutorial is over.");
}
const loanerLocationId = player.loanerLocationId as "earth" | "moon";
const periodIndex = boardPeriodIndex(now, content.config);
const runsCompleted = player.tutorialRunsCompleted;
// One board per run (and per period), so every run gets three fresh contracts.
const contracts = await getOrCreateBoard(
tx,
{
playerId,
kind: "tutorial",
locationId: loanerLocationId,
periodIndex,
tutorialRun: runsCompleted,
},
() => generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex }, content),
now,
);
return boardView(tx, playerId, contracts, periodIndex);
}
/**
* The contract board (freight market) of any discovered location for the
* current period (Step 6.9, CR-002): no bay needed. Generated on the first
* request in the period from the locations discovered and the fleet owned at
* that moment (CR-006), then stored, so later changes in the same period do
* not change it.
*/
export async function locationBoard(
tx: Queryable,
playerId: string,
locationId: string,
now: RealTime,
): Promise<BoardView> {
const origin = content.location(locationId);
if (!origin) throw gameError("not-found", `No location "${locationId}".`);
const [known] = await tx
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(
and(
eq(discoveredLocations.playerId, playerId),
eq(discoveredLocations.locationId, locationId),
),
);
if (!known) {
throw gameError("location-undiscovered", `Deliver to ${origin.name} to open its market.`);
}
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
const periodIndex = boardPeriodIndex(now, content.config);
const contracts = await getOrCreateBoard(
tx,
{ playerId, kind: "location", locationId, periodIndex },
async () => {
const discovered = await tx
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
return generateContractBoard(
{
location: origin,
discoveredIds: new Set(discovered.map((d) => d.locationId)),
periodIndex,
worldSeed: player!.worldSeed,
fleet: await playerFleet(tx, playerId),
},
content,
);
},
now,
);
return boardView(tx, playerId, contracts, periodIndex);
}
/**
* The player's ships with their pilots' skills (none without a pilot), in a
* stable order so the same fleet always gives the same board (decision 0.5.52).
*/
export async function playerFleet(tx: Queryable, playerId: string): Promise<FleetMember[]> {
const rows = await tx
.select({ id: ships.id, modelId: ships.modelId, skills: pilots.skills })
.from(ships)
.leftJoin(pilots, eq(pilots.assignedShipId, ships.id))
.where(eq(ships.playerId, playerId))
.orderBy(asc(ships.createdAt), asc(ships.id));
// Upgrades count: an expanded cargo bay gets bigger fit jobs (CR-009).
const upgrades = await loadUpgradeLevels(
tx,
rows.map((r) => r.id),
);
return rows.map((r) => ({
modelId: r.modelId,
skills: r.skills ?? {},
upgrades: upgrades.get(r.id) ?? {},
}));
}

View File

@ -1,24 +0,0 @@
import type { Company } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { companies } from "../db/schema";
/** Renames the player's company (decision 0.5.75); the name arrives validated and trimmed. */
export async function renameCompany(
tx: Queryable,
playerId: string,
name: string,
): Promise<Company> {
const [updated] = await tx
.update(companies)
.set({ name })
.where(eq(companies.playerId, playerId))
.returning();
if (!updated) throw new Error(`Player ${playerId} has no company`);
return {
id: updated.id,
name: updated.name,
credits: updated.credits,
loanBalance: updated.loanBalance,
};
}

View File

@ -1,107 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { RealTime } from "@deadhead/sim";
import { and, count, eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { bays, companies, dispatches, pilots, players, ships } from "../db/schema";
import { gameError } from "../http/errors";
import { shipName } from "./names";
/** What the deal hands over (decisions 0.5.30, 0.5.82), from content. */
const { avatarShipModelId, veteranShipModelId, bayLocationId } = content.config.deal;
export const DEAL_SHIP_MODEL_ID = avatarShipModelId;
export const DEAL_BAY_LOCATION_ID = bayLocationId;
/**
* The deal can be accepted now (decision 0.5.30): tutorial on, enough runs
* completed, the loaner back at Earth, and no tutorial run in flight.
*/
export function isDealAvailable(
player: typeof players.$inferSelect,
tutorialRunInFlight: boolean,
): boolean {
return (
player.tutorialActive &&
!player.dealAccepted &&
player.tutorialRunsCompleted >= content.config.tutorialRunsBeforeDeal &&
player.loanerLocationId === "earth" &&
!tutorialRunInFlight
);
}
/**
* Accepts the deal (Steps 10A.8, 10M.3): the loaner goes back to the company;
* the player gets the old Courier at Earth crewed by the avatar, the Clipper
* crewed by the veteran, the Earth bay, and the loan; the tutorial ends. Run inside the player lock, so two simultaneous
* accepts produce one deal.
*/
export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime): Promise<void> {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player) throw new Error(`Player ${playerId} not found`);
const [running] = await tx
.select({ id: dispatches.id })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.tutorial, true),
eq(dispatches.status, "in_flight"),
),
);
if (!isDealAvailable(player, running !== undefined)) {
throw gameError("deal-not-available", "There is no deal on the table right now.");
}
const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId));
const shipId = randomUUID();
const clipperId = randomUUID();
await tx.insert(ships).values(
[
{ id: shipId, modelId: avatarShipModelId },
{ id: clipperId, modelId: veteranShipModelId },
].map((ship, i) => ({
...ship,
playerId,
name: shipName(player.worldSeed, owned!.n + i),
locationId: bayLocationId,
fromDeal: true,
createdAt: now,
})),
);
await tx.insert(bays).values({
id: randomUUID(),
playerId,
locationId: DEAL_BAY_LOCATION_ID,
createdAt: now,
});
await tx
.update(pilots)
.set({ assignedShipId: shipId, locationId: null, status: "idle" })
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
// Halvorsen's veteran comes with the business (decisions 0.5.70, 0.5.82),
// crewing his Clipper, at their level with their skills bought and the rest unspent.
const veteran = content.veteranPilot;
const xpForLevel =
veteran.level === 0 ? 0 : content.config.pilotLevelXpThresholds[veteran.level - 1]!;
await tx.insert(pilots).values({
id: randomUUID(),
playerId,
name: veteran.name,
isVeteran: true,
xp: xpForLevel,
skills: veteran.skills,
skillPointsSpent: Object.values(veteran.skills).reduce((a, b) => a + b, 0),
assignedShipId: clipperId,
// Assigned pilots are where their ship is (as for the avatar).
locationId: null,
createdAt: now,
});
await tx
.update(companies)
.set({ loanBalance: content.dealLoanAmount })
.where(eq(companies.playerId, playerId));
await tx
.update(players)
.set({ tutorialActive: false, loanerLocationId: null, dealAccepted: true })
.where(eq(players.id, playerId));
}

View File

@ -1,307 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { Dispatch } from "@deadhead/schema";
import {
createDispatchRecord,
createRepositionRecord,
loanerModel,
validateDispatch,
validateReposition,
type Contract,
type DispatchRecord,
type PilotState,
type RealTime,
type ShipState,
type UpgradeLevels,
} from "@deadhead/sim";
import { and, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
acceptedContracts,
companies,
contractBoards,
discoveredLocations,
dispatches,
pilots,
players,
ships,
} from "../db/schema";
import { gameError } from "../http/errors";
import { acceptedContractIds } from "./boards";
import { requireCompany, requirePlayer } from "./fleet";
import { toApiDispatch } from "./mappers";
import { VALIDATION_MESSAGES } from "./messages";
import { requireOwnPilot, requireOwnShip } from "./pilots";
import { shipUpgradeLevels } from "./upgrades";
/** The id the tutorial loaner uses in dispatch records (it is not a ship row). */
export const LOANER_SHIP_ID = "loaner";
/** Finds a contract on any of the player's stored boards of a kind. */
export async function findBoardContract(
tx: Queryable,
playerId: string,
kind: "location" | "tutorial",
contractId: string,
): Promise<{ contract: Contract; periodIndex: number } | undefined> {
const [board] = await tx
.select()
.from(contractBoards)
.where(
and(
eq(contractBoards.playerId, playerId),
eq(contractBoards.kind, kind),
sql`${contractBoards.contracts} @> ${JSON.stringify([{ id: contractId }])}::jsonb`,
),
);
const contract = board?.contracts.find((c) => c.id === contractId);
return board && contract ? { contract, periodIndex: board.periodIndex } : undefined;
}
/** Loads a board contract the player may still accept, or throws. */
export async function claimableContract(
tx: Queryable,
playerId: string,
kind: "location" | "tutorial",
contractId: string,
): Promise<{ contract: Contract; periodIndex: number }> {
const found = await findBoardContract(tx, playerId, kind, contractId);
if (!found) throw gameError("unknown-contract", "No such contract on your boards.");
if ((await acceptedContractIds(tx, playerId)).has(contractId)) {
throw gameError("contract-already-accepted", "That contract has already been taken.");
}
return found;
}
export function pilotState(row: typeof pilots.$inferSelect): PilotState {
return {
id: row.id,
isAvatar: row.isAvatar,
assignedShipId: row.assignedShipId,
inFlight: row.status === "in_flight",
skills: row.skills,
restEndsAt: row.restEndsAt,
};
}
/** Stores a dispatch, marks its contract accepted, and charges fuel. */
export async function storeDispatch(
tx: Queryable,
args: {
playerId: string;
record: DispatchRecord;
/** Null for the tutorial loaner. */
shipId: string | null;
/** The board period the contract came from; null for a reposition. */
periodIndex: number | null;
now: RealTime;
},
): Promise<Dispatch> {
const { playerId, record, shipId, now } = args;
const [row] = await tx
.insert(dispatches)
.values({
id: record.id,
playerId,
shipId,
pilotId: record.pilotId,
tutorial: record.tutorial,
record,
arrivalAt: record.arrivalAt,
})
.returning();
if (record.contract && args.periodIndex !== null) {
await tx.insert(acceptedContracts).values({
playerId,
contractId: record.contract.id,
periodIndex: args.periodIndex,
dispatchId: record.id,
acceptedAt: now,
});
}
if (record.fuelCost > 0) {
await tx
.update(companies)
.set({ credits: sql`${companies.credits} - ${record.fuelCost}` })
.where(eq(companies.playerId, playerId));
}
return toApiDispatch(row!);
}
/**
* The avatar flies the company loaner on a tutorial contract (Step 6.4). The
* company pays fuel, and only one tutorial run may be in flight.
*/
export async function dispatchTutorial(
tx: Queryable,
playerId: string,
contractId: string,
now: RealTime,
): Promise<Dispatch> {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player?.tutorialActive || !player.loanerLocationId) {
throw gameError("not-in-tutorial", "The tutorial is over.");
}
const [running] = await tx
.select({ id: dispatches.id })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.tutorial, true),
eq(dispatches.status, "in_flight"),
),
);
if (running) throw gameError("tutorial-run-in-flight", "Your tutorial run is still flying.");
const { contract, periodIndex } = await claimableContract(tx, playerId, "tutorial", contractId);
const [avatar] = await tx
.select()
.from(pilots)
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
if (!avatar || !company) throw new Error(`Player ${playerId} is missing its avatar or company`);
const loaner: ShipState = {
id: LOANER_SHIP_ID,
modelId: loanerModel(content).id,
locationId: player.loanerLocationId,
inFlight: false,
};
const pilot = pilotState(avatar);
const check = validateDispatch(
{ ship: loaner, pilot, contract, tutorial: true, credits: company.credits, now },
content,
);
if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createDispatchRecord(
{
id: randomUUID(),
ship: loaner,
pilot,
contract,
tutorial: true,
now,
worldSeed: player.worldSeed,
},
content,
);
const dispatch = await storeDispatch(tx, { playerId, record, shipId: null, periodIndex, now });
await tx
.update(pilots)
.set({ status: "in_flight", locationId: null })
.where(eq(pilots.id, avatar.id));
return dispatch;
}
export function shipState(row: typeof ships.$inferSelect, upgrades: UpgradeLevels = {}): ShipState {
return {
id: row.id,
modelId: row.modelId,
// An in-flight ship has no location; validation reports it as not idle first.
locationId: row.locationId ?? "",
inFlight: row.status === "in_flight",
// Effective stats and the shop block (CR-009).
upgrades,
inShopUntil: row.inShopUntil,
};
}
/**
* Dispatches an owned ship and its pilot on a location-board contract, from
* wherever the ship is (Steps 6.10, 10B.5; no bay involved):
* sim validation, fuel charged, contract accepted, ship and pilot in flight.
*/
export async function dispatchContract(
tx: Queryable,
playerId: string,
input: { contractId: string; shipId: string; pilotId: string },
now: RealTime,
): Promise<Dispatch> {
const { contract, periodIndex } = await claimableContract(
tx,
playerId,
"location",
input.contractId,
);
const ship = await requireOwnShip(tx, playerId, input.shipId);
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId);
const discovered = await tx
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
const proposal = {
ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)),
pilot: pilotState(pilot),
contract,
// The no-strand rule counts only discovered locations (decision 0.5.79).
discoveredIds: new Set(discovered.map((d) => d.locationId)),
tutorial: false,
credits: company.credits,
now,
};
const check = validateDispatch(proposal, content);
if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createDispatchRecord(
{ id: randomUUID(), ...proposal, worldSeed: player.worldSeed },
content,
);
const dispatch = await storeDispatch(tx, { playerId, record, shipId: ship.id, periodIndex, now });
await setInFlight(tx, ship.id, pilot.id);
return dispatch;
}
export async function setInFlight(tx: Queryable, shipId: string, pilotId: string) {
await tx.update(ships).set({ status: "in_flight", locationId: null }).where(eq(ships.id, shipId));
await tx
.update(pilots)
.set({ status: "in_flight", locationId: null })
.where(eq(pilots.id, pilotId));
}
/** Deadhead: flies a ship and its pilot empty to another discovered location, paying fuel only (Steps 6.11, 10B.2). */
export async function dispatchReposition(
tx: Queryable,
playerId: string,
input: { shipId: string; pilotId: string; destinationId: string },
now: RealTime,
): Promise<Dispatch> {
const ship = await requireOwnShip(tx, playerId, input.shipId);
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId);
const discovered = await tx
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
const proposal = {
ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)),
pilot: pilotState(pilot),
destinationId: input.destinationId,
discoveredIds: new Set(discovered.map((d) => d.locationId)),
credits: company.credits,
now,
};
const check = validateReposition(proposal, content);
if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createRepositionRecord(
{ id: randomUUID(), ...proposal, worldSeed: player.worldSeed },
content,
);
const dispatch = await storeDispatch(tx, {
playerId,
record,
shipId: ship.id,
periodIndex: null,
now,
});
await setInFlight(tx, ship.id, pilot.id);
return dispatch;
}

View File

@ -1,124 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { Bay, Ship } from "@deadhead/schema";
import type { RealTime } from "@deadhead/sim";
import { and, count, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { bays, companies, discoveredLocations, players, ships } from "../db/schema";
import { gameError } from "../http/errors";
import { toApiShip } from "./mappers";
import { shipName } from "./names";
export async function requireCompany(tx: Queryable, playerId: string) {
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
if (!company) throw new Error(`Player ${playerId} has no company`);
return company;
}
export async function requirePlayer(tx: Queryable, playerId: string) {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player) throw new Error(`Player ${playerId} not found`);
return player;
}
/** Purchases are blocked while the tutorial runs (decision 0.5.16, CR-001). */
export async function requireTutorialOver(tx: Queryable, playerId: string, what: string) {
const player = await requirePlayer(tx, playerId);
if (player.tutorialActive) {
throw gameError("tutorial-active", `Finish the company runs before ${what}.`);
}
}
/** Deducts credits, or throws `insufficient-funds` without changing anything. */
export async function spend(tx: Queryable, playerId: string, amount: number, what: string) {
const company = await requireCompany(tx, playerId);
if (company.credits < amount) {
throw gameError("insufficient-funds", `Not enough credits for ${what} (${amount} needed).`);
}
await tx
.update(companies)
.set({ credits: sql`${companies.credits} - ${amount}` })
.where(eq(companies.playerId, playerId));
}
/** Leases a bay at a discovered location for its one-time price (Step 6.6). */
export async function leaseBay(
tx: Queryable,
playerId: string,
locationId: string,
now: RealTime,
): Promise<Bay> {
await requireTutorialOver(tx, playerId, "leasing a bay");
const location = content.location(locationId);
if (!location) throw gameError("not-found", `No location "${locationId}".`);
const [discovered] = await tx
.select()
.from(discoveredLocations)
.where(
and(
eq(discoveredLocations.playerId, playerId),
eq(discoveredLocations.locationId, locationId),
),
);
if (!discovered) {
throw gameError(
"location-undiscovered",
`Deliver to ${location.name} before leasing a bay there.`,
);
}
const [existing] = await tx
.select()
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId)));
if (existing)
throw gameError("bay-already-leased", `You already have a bay at ${location.name}.`);
await spend(tx, playerId, location.bayLeasePrice, `a bay at ${location.name}`);
const [bay] = await tx
.insert(bays)
.values({ id: randomUUID(), playerId, locationId, createdAt: now })
.returning();
return { id: bay!.id, locationId: bay!.locationId };
}
/**
* Buys a ship model, delivered idle and uncrewed to one of the player's bays
* (Steps 6.5, 10B.6; decision 0.5.35).
*/
export async function buyShip(
tx: Queryable,
playerId: string,
input: { modelId: string; bayId: string },
now: RealTime,
): Promise<Ship> {
const { modelId } = input;
await requireTutorialOver(tx, playerId, "buying a ship");
const model = content.shipModel(modelId);
if (!model?.purchasable) throw gameError("not-found", `No ship model "${modelId}" for sale.`);
const [bay] = await tx
.select()
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.id, input.bayId)));
if (!bay) throw gameError("not-found", "You have no such bay.");
await spend(tx, playerId, model.price, `a ${model.name}`);
const player = await requirePlayer(tx, playerId);
const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId));
const [ship] = await tx
.insert(ships)
.values({
id: randomUUID(),
playerId,
modelId,
name: shipName(player.worldSeed, owned!.n),
locationId: bay.locationId,
createdAt: now,
})
.returning();
return toApiShip(
ship!,
{ dispatchId: null, pilotId: null, lastArrivalAt: ship!.createdAt, upgrades: {} },
now,
);
}

View File

@ -1,131 +0,0 @@
import { content } from "@deadhead/content";
import type { Company, GoalState } from "@deadhead/schema";
import { completedGoals, type GoalProgress, type RealTime } from "@deadhead/sim";
import { and, count, eq, notInArray, sql, sum } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
claimedGoals,
companies,
discoveredLocations,
dispatches,
pilots,
ships,
} from "../db/schema";
import { gameError } from "../http/errors";
import { requireCompany, requirePlayer } from "./fleet";
const START_LOCATION_IDS = content.locations.filter((l) => l.startsDiscovered).map((l) => l.id);
/** The facts goals are checked against, read from the database (Step 10A.10). */
export async function goalProgress(tx: Queryable, playerId: string): Promise<GoalProgress> {
const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId);
const [deliveries] = await tx
.select({ n: count() })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.status, "resolved"),
eq(dispatches.tutorial, false),
// Repositions store a null contract.
sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`,
),
);
// Priority contracts delivered, by any pilot (CR-013).
const [priorityDeliveries] = await tx
.select({ n: count() })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.status, "resolved"),
eq(dispatches.tutorial, false),
sql`(${dispatches.record} -> 'contract' ->> 'priority') = 'true'`,
),
);
const [spent] = await tx
.select({ n: sum(pilots.skillPointsSpent) })
.from(pilots)
.where(eq(pilots.playerId, playerId));
const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId));
// The deal's ships don't count as Market purchases (CR-013).
const [bought] = await tx
.select({ n: count() })
.from(ships)
.where(and(eq(ships.playerId, playerId), eq(ships.fromDeal, false)));
const [hired] = await tx
.select({ n: count() })
.from(pilots)
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, false)));
const [discovered] = await tx
.select({ n: count() })
.from(discoveredLocations)
.where(
and(
eq(discoveredLocations.playerId, playerId),
notInArray(discoveredLocations.locationId, START_LOCATION_IDS),
),
);
return {
ownShipDeliveries: deliveries!.n,
skillPointsSpent: Number(spent?.n ?? 0),
shipsOwned: owned!.n,
marketShipsBought: bought!.n,
pilotsHired: hired!.n,
priorityDeliveries: priorityDeliveries!.n,
locationsDiscovered: discovered!.n,
dealAccepted: player.dealAccepted,
loanBalance: company.loanBalance,
};
}
async function claimedIds(tx: Queryable, playerId: string): Promise<Set<string>> {
const rows = await tx
.select({ goalId: claimedGoals.goalId })
.from(claimedGoals)
.where(eq(claimedGoals.playerId, playerId));
return new Set(rows.map((r) => r.goalId));
}
/** Every goal's status in content order; none while the tutorial runs (decision 0.5.34). */
export async function goalStates(tx: Queryable, playerId: string): Promise<GoalState[]> {
const player = await requirePlayer(tx, playerId);
if (player.tutorialActive) return [];
const complete = new Set(
completedGoals(content.onboardingGoals, await goalProgress(tx, playerId)),
);
const claimed = await claimedIds(tx, playerId);
return content.onboardingGoals.map((goal) => ({
id: goal.id,
status: claimed.has(goal.id) ? "claimed" : complete.has(goal.id) ? "ready" : "incomplete",
}));
}
/** Claims a completed goal's reward, once. Run inside the player lock. */
export async function claimGoal(
tx: Queryable,
playerId: string,
goalId: string,
now: RealTime,
): Promise<Company> {
const goal = content.onboardingGoal(goalId);
if (!goal) throw gameError("unknown-goal", `No goal "${goalId}".`);
const status = (await goalStates(tx, playerId)).find((g) => g.id === goalId)?.status;
if (status === "claimed")
throw gameError("goal-already-claimed", "You already claimed that reward.");
if (status !== "ready") throw gameError("goal-not-ready", `Not done yet: ${goal.title}.`);
await tx.insert(claimedGoals).values({ playerId, goalId, claimedAt: now });
const [company] = await tx
.update(companies)
.set({ credits: sql`${companies.credits} + ${goal.reward}` })
.where(eq(companies.playerId, playerId))
.returning();
return {
id: company!.id,
name: company!.name,
credits: company!.credits,
loanBalance: company!.loanBalance,
};
}

View File

@ -1,33 +0,0 @@
import type { Company } from "@deadhead/schema";
import { eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { companies } from "../db/schema";
import { gameError } from "../http/errors";
import { requireCompany } from "./fleet";
/**
* Pays the loan down by up to `amount` credits (decision 0.5.32): only the
* balance is taken, and paying more than the player has fails with no change.
* Run inside the player lock.
*/
export async function repayLoan(tx: Queryable, playerId: string, amount: number): Promise<Company> {
const company = await requireCompany(tx, playerId);
const payment = Math.min(amount, company.loanBalance);
if (payment > company.credits) {
throw gameError("insufficient-funds", `You only have ${company.credits} credits.`);
}
const [updated] = await tx
.update(companies)
.set({
credits: sql`${companies.credits} - ${payment}`,
loanBalance: sql`${companies.loanBalance} - ${payment}`,
})
.where(eq(companies.playerId, playerId))
.returning();
return {
id: updated!.id,
name: updated!.name,
credits: updated!.credits,
loanBalance: updated!.loanBalance,
};
}

View File

@ -1,22 +0,0 @@
import { eq } from "drizzle-orm";
import type { Db, Queryable } from "../db/client";
import { companies } from "../db/schema";
/**
* Runs `fn` in a transaction holding a row lock on the player's company, so
* changes to one player's money and fleet never interleave (Step 6.14).
*/
export function withPlayerLock<T>(
db: Db,
playerId: string,
fn: (tx: Queryable) => Promise<T>,
): Promise<T> {
return db.transaction(async (tx) => {
await tx
.select({ id: companies.id })
.from(companies)
.where(eq(companies.playerId, playerId))
.for("update");
return fn(tx);
});
}

View File

@ -1,101 +0,0 @@
import { content } from "@deadhead/content";
import type { Dispatch, Pilot, Ship } from "@deadhead/schema";
import {
dutyLimit,
effectiveStats,
isInShop,
isResting,
pilotLevel,
skillPointsAvailable,
type RealTime,
} from "@deadhead/sim";
import type { dispatches, pilots, ships } from "../db/schema";
/** Database status values use `in_flight`; the API uses `in-flight`. */
export function toApiStatus<S extends "idle" | "in_flight" | "resolved">(
status: S,
): S extends "in_flight" ? "in-flight" : S {
return (status === "in_flight" ? "in-flight" : status) as S extends "in_flight" ? "in-flight" : S;
}
export function toApiDispatch(row: typeof dispatches.$inferSelect): Dispatch {
return {
...row.record,
status: toApiStatus(row.status),
resolvedAt: row.resolvedAt,
};
}
/**
* API view of a pilot. An assigned pilot is wherever its ship is
* (`assignedShipLocationId`); an unassigned pilot stores its own location.
*/
export function toApiPilot(
row: typeof pilots.$inferSelect,
assignedShipLocationId: string | null,
now: RealTime,
): Pilot {
const resting = isResting(row.restEndsAt, now);
const level = pilotLevel(row.xp, content.config);
return {
id: row.id,
name: row.name,
isAvatar: row.isAvatar,
isVeteran: row.isVeteran,
xp: row.xp,
level,
skills: row.skills,
skillPointsAvailable: skillPointsAvailable(level, row.skillPointsSpent, content.config),
assignedShipId: row.assignedShipId,
status: toApiStatus(row.status),
locationId:
row.status === "in_flight"
? null
: row.assignedShipId
? assignedShipLocationId
: row.locationId,
dutyHours: row.dutyHours,
dutyLimit: dutyLimit(row.skills, content.config),
resting,
restEndsAt: resting ? row.restEndsAt : null,
};
}
/** A ship row as the API shows it, with its upgrades and effective stats (CR-009). */
export function toApiShip(
row: typeof ships.$inferSelect,
extra: {
dispatchId: string | null;
pilotId: string | null;
lastArrivalAt: number;
upgrades: Record<string, number>;
},
now: RealTime,
): Ship {
const model = content.shipModel(row.modelId);
if (!model) throw new Error(`Ship ${row.id} has unknown model ${row.modelId}`);
const stats = effectiveStats(model, extra.upgrades, content);
return {
id: row.id,
modelId: row.modelId,
name: row.name,
status: toApiStatus(row.status),
locationId: row.status === "in_flight" ? null : row.locationId,
dispatchId: extra.dispatchId,
pilotId: extra.pilotId,
lastArrivalAt: extra.lastArrivalAt,
upgrades: extra.upgrades,
stats: {
cruiseSpeedKmPerGameHour: stats.cruiseSpeedKmPerGameHour,
cargoCapacityTonnes: stats.cargoCapacityTonnes,
fuelRangeMillionKm: stats.fuelRangeMillionKm,
overheadGameHours: stats.overheadGameHours,
handlingGameHoursPerTonne: stats.handlingGameHoursPerTonne,
},
inShopUntil: isInShop(row, now) ? row.inShopUntil : null,
shop:
row.shopUpgradeId && row.shopLevel !== null && row.inShopUntil !== null
? { upgradeId: row.shopUpgradeId, level: row.shopLevel, until: row.inShopUntil }
: null,
};
}

View File

@ -1,34 +0,0 @@
import type { DispatchFailure, RepositionFailure, UpgradeFailure } from "@deadhead/sim";
/** Player-facing text for each sim validation reason. */
export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, string> = {
"ship-not-idle": "That ship is already flying.",
"ship-not-at-origin": "That ship is not at the contract's pickup location.",
"ship-in-shop": "That ship is in the upgrade shop until its work is done.",
"pilot-resting": "That pilot is resting and cannot fly until their rest ends.",
"pilot-not-idle": "That pilot is already flying.",
"pilot-not-assigned": "That pilot is not assigned to that ship.",
"over-capacity": "The cargo is too heavy for that ship.",
"cannot-land": "That ship cannot land at the destination.",
"out-of-range": "The destination is out of that ship's range right now.",
"would-strand":
"That ship couldn't fly anywhere else from the destination, so it would be stranded there.",
"too-slow": "That ship can't make this Priority delivery's window.",
"cargo-skill-too-low": "The pilot's cargo skill is too low for this contract.",
"long-haul-too-low": "The pilot's Long Haul skill is too low for this distance.",
"contract-expired": "This contract has expired.",
"insufficient-credits": "Not enough credits for fuel.",
"already-there": "The ship is already there.",
"location-undiscovered": "You can only fly empty to places you have discovered.",
};
/** Player-facing text for each upgrade refusal (CR-009). */
export const UPGRADE_MESSAGES: Record<UpgradeFailure, string> = {
"tutorial-active": "Finish the company runs before upgrading ships.",
"not-upgradable": "The company loaner can't be upgraded.",
"ship-not-idle": "That ship is flying; upgrade it once it lands at an HQ.",
"ship-in-shop": "That ship is already in the shop.",
"not-at-hq": "Upgrades are installed at an HQ; bring the ship to one of your bays.",
"upgrade-maxed": "That upgrade is already at its highest level.",
"insufficient-funds": "Not enough credits for that upgrade.",
};

View File

@ -1,117 +0,0 @@
import { createRng, deriveSeed, type Seed } from "@deadhead/sim";
const SHIP_ADJECTIVES = [
"Dusty",
"Rusty",
"Lucky",
"Stubborn",
"Quiet",
"Restless",
"Patient",
"Wandering",
"Crooked",
"Faithful",
"Midnight",
"Copper",
"Iron",
"Silver",
"Old",
"Brave",
];
const SHIP_NOUNS = [
"Mule",
"Ox",
"Heron",
"Comet",
"Lantern",
"Drifter",
"Kettle",
"Anchor",
"Sparrow",
"Badger",
"Tortoise",
"Pilgrim",
"Barge",
"Nomad",
"Hauler",
"Wren",
];
const FIRST_NAMES = [
"Rae",
"Jonah",
"Mira",
"Tobias",
"Ines",
"Kofi",
"Lena",
"Arjun",
"Sasha",
"Dmitri",
"Yuki",
"Nadia",
"Emeka",
"Freya",
"Mateo",
"Priya",
];
const LAST_NAMES = [
"Okafor",
"Lindqvist",
"Moreau",
"Tanaka",
"Reyes",
"Novak",
"Haddad",
"Kowalski",
"Mbeki",
"Castillo",
"Sorensen",
"Iyer",
"Brennan",
"Vasquez",
"Kim",
"Abara",
];
/** The nth ship name in a world (decision 0.5.14): stable for a given seed and n. */
export function shipName(worldSeed: Seed, n: number): string {
const rng = createRng(deriveSeed(worldSeed, `ship-name:${n}`));
return `${rng.pick(SHIP_ADJECTIVES)} ${rng.pick(SHIP_NOUNS)}`;
}
/** The nth hired pilot's name in a world: stable for a given seed and n. */
export function pilotName(worldSeed: Seed, n: number): string {
const rng = createRng(deriveSeed(worldSeed, `pilot-name:${n}`));
return `${rng.pick(FIRST_NAMES)} ${rng.pick(LAST_NAMES)}`;
}
const COMPANY_FIRST = [
"Halcyon",
"Meridian",
"Lodestar",
"Northstar",
"Bluewater",
"Redline",
"Ironclad",
"Kestrel",
"Copperhead",
"Longhaul",
"Brightwater",
"Stonebridge",
];
const COMPANY_LAST = [
"Freight",
"Haulage",
"Cargo",
"Logistics",
"Shipping",
"Transit",
"Lines",
"Carriers",
];
/** A company's default name, stable for its world seed (decision 0.5.75). */
export function companyName(worldSeed: Seed): string {
const rng = createRng(deriveSeed(worldSeed, "company-name"));
return `${rng.pick(COMPANY_FIRST)} ${rng.pick(COMPANY_LAST)}`;
}

View File

@ -1,59 +0,0 @@
import { randomInt, randomUUID } from "node:crypto";
import type { RealTime } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
acceptedContracts,
bays,
claimedGoals,
companies,
contractBoards,
discoveredLocations,
dispatches,
pilots,
players,
ships,
} from "../db/schema";
import { requirePlayer } from "./fleet";
import { createStartingState } from "./new-player";
/**
* "Start a new company" (decision 0.5.74), inside the caller's player-locked
* transaction: deletes everything the company owns, then builds the starting
* state exactly as guest creation does, with a new world seed. The player
* (and so their sign-ins and backup code) stays.
*/
export async function newCompany(tx: Queryable, playerId: string, now: RealTime): Promise<void> {
const player = await requirePlayer(tx, playerId);
// Children before parents; ship upgrades go with their ships.
await tx.delete(claimedGoals).where(eq(claimedGoals.playerId, playerId));
await tx.delete(acceptedContracts).where(eq(acceptedContracts.playerId, playerId));
await tx.delete(dispatches).where(eq(dispatches.playerId, playerId));
await tx.delete(contractBoards).where(eq(contractBoards.playerId, playerId));
await tx.delete(discoveredLocations).where(eq(discoveredLocations.playerId, playerId));
await tx.delete(bays).where(eq(bays.playerId, playerId));
await tx.delete(pilots).where(eq(pilots.playerId, playerId));
await tx.delete(ships).where(eq(ships.playerId, playerId));
await tx.delete(companies).where(eq(companies.playerId, playerId));
// A 32-bit seed (decision 0.5.10), never the old one.
let worldSeed = randomInt(0, 2 ** 32);
while (worldSeed === player.worldSeed) worldSeed = randomInt(0, 2 ** 32);
await tx
.update(players)
.set({
worldSeed,
tutorialActive: true,
loanerLocationId: "earth",
tutorialRunsCompleted: 0,
dealAccepted: false,
})
.where(eq(players.id, playerId));
await createStartingState(tx, {
playerId,
companyId: randomUUID(),
avatarId: randomUUID(),
worldSeed,
now,
});
}

View File

@ -1,76 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { RealTime, Seed } from "@deadhead/sim";
import type { Db, Queryable } from "../db/client";
import { companies, discoveredLocations, pilots, players } from "../db/schema";
import { companyName } from "./names";
export const AVATAR_NAME = "Captain";
export interface NewPlayer {
playerId: string;
companyId: string;
avatarId: string;
}
/**
* Creates a fresh player in tutorial mode: starting credits, every location
* that starts discovered, the avatar (level 0, unassigned), and the loaner at
* Earth. The avatar's position comes from the loaner while the tutorial runs.
*/
export async function createPlayer(
db: Db,
args: { userId: string; worldSeed: Seed; now: RealTime; playerId?: string },
): Promise<NewPlayer> {
const { worldSeed, now } = args;
const playerId = args.playerId ?? randomUUID();
const companyId = randomUUID();
const avatarId = randomUUID();
await db.transaction(async (tx) => {
await tx.insert(players).values({
id: playerId,
userId: args.userId,
worldSeed,
tutorialActive: true,
loanerLocationId: "earth",
createdAt: now,
});
await createStartingState(tx, { playerId, companyId, avatarId, worldSeed, now });
});
return { playerId, companyId, avatarId };
}
/**
* A player's starting company (CR-001 tutorial state): starting credits, every
* location that starts discovered, and the avatar at Earth beside the loaner.
* Shared by guest creation and "Start a new company" (decision 0.5.74).
*/
export async function createStartingState(
tx: Queryable,
ids: { playerId: string; companyId: string; avatarId: string; worldSeed: Seed; now: RealTime },
): Promise<void> {
const { playerId, companyId, avatarId, worldSeed, now } = ids;
await tx.insert(companies).values({
id: companyId,
playerId,
name: companyName(worldSeed),
credits: content.config.startingCredits,
});
await tx
.insert(discoveredLocations)
.values(
content.locations
.filter((l) => l.startsDiscovered)
.map((l) => ({ playerId, locationId: l.id, discoveredAt: now })),
);
await tx.insert(pilots).values({
id: avatarId,
playerId,
name: AVATAR_NAME,
isAvatar: true,
locationId: "earth",
createdAt: now,
});
}

View File

@ -1,217 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { Pilot } from "@deadhead/schema";
import {
isResting,
pilotLevel,
restEndsAt,
skillPointsAvailable,
type RealTime,
} from "@deadhead/sim";
import { and, count, eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { bays, pilots, ships } from "../db/schema";
import { gameError } from "../http/errors";
import { requirePlayer, requireTutorialOver, spend } from "./fleet";
import { toApiPilot } from "./mappers";
import { VALIDATION_MESSAGES } from "./messages";
import { pilotName } from "./names";
export async function hasBayAt(
tx: Queryable,
playerId: string,
locationId: string,
): Promise<boolean> {
const [bay] = await tx
.select({ id: bays.id })
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId)));
return bay !== undefined;
}
export async function requireOwnPilot(tx: Queryable, playerId: string, pilotId: string) {
const [pilot] = await tx
.select()
.from(pilots)
.where(and(eq(pilots.id, pilotId), eq(pilots.playerId, playerId)));
if (!pilot) throw gameError("not-found", "No such pilot.");
return pilot;
}
export async function requireOwnShip(tx: Queryable, playerId: string, shipId: string) {
const [ship] = await tx
.select()
.from(ships)
.where(and(eq(ships.id, shipId), eq(ships.playerId, playerId)));
if (!ship) throw gameError("not-found", "No such ship.");
return ship;
}
/** Hires a level-0 pilot at one of the player's bays (Step 6.8, decision 0.5.22). */
export async function hirePilot(
tx: Queryable,
playerId: string,
locationId: string,
now: RealTime,
): Promise<Pilot> {
await requireTutorialOver(tx, playerId, "hiring pilots");
if (!(await hasBayAt(tx, playerId, locationId))) {
throw gameError("no-bay-at-location", "Pilots can only be hired at one of your bays.");
}
await spend(tx, playerId, content.config.pilotHireCost, "a new pilot");
const player = await requirePlayer(tx, playerId);
const [hired] = await tx
.select({ n: count() })
.from(pilots)
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, false)));
const [pilot] = await tx
.insert(pilots)
.values({
id: randomUUID(),
playerId,
name: pilotName(player.worldSeed, hired!.n),
locationId,
createdAt: now,
})
.returning();
return toApiPilot(pilot!, null, now);
}
/**
* Assigns an idle, unassigned pilot to an idle, uncrewed ship at the same
* location. The avatar is locked to the loaner during the tutorial (decision 0.5.23).
*/
export async function assignPilot(
tx: Queryable,
playerId: string,
pilotId: string,
shipId: string,
now: RealTime,
): Promise<Pilot> {
const pilot = await requireOwnPilot(tx, playerId, pilotId);
const ship = await requireOwnShip(tx, playerId, shipId);
const player = await requirePlayer(tx, playerId);
if (pilot.isAvatar && player.tutorialActive) {
throw gameError("avatar-in-tutorial", "You fly the company loaner until the tutorial ends.");
}
if (pilot.status === "in_flight") {
throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
}
if (pilot.assignedShipId !== null) {
throw gameError(
"pilot-already-assigned",
"That pilot already has a ship. Unassign them first.",
);
}
if (ship.status === "in_flight") {
throw gameError("ship-not-idle", VALIDATION_MESSAGES["ship-not-idle"]);
}
if (ship.locationId !== pilot.locationId) {
throw gameError("ship-not-idle-here", "The pilot and the ship must be at the same location.");
}
const [crew] = await tx
.select({ id: pilots.id })
.from(pilots)
.where(eq(pilots.assignedShipId, shipId));
if (crew) throw gameError("ship-crewed", "That ship already has a pilot.");
const [updated] = await tx
.update(pilots)
.set({ assignedShipId: shipId, locationId: null })
.where(eq(pilots.id, pilotId))
.returning();
return toApiPilot(updated!, ship.locationId, now);
}
/** Unassigns a pilot from its ship; only allowed where the player has a bay (decision 0.5.2). */
export async function unassignPilot(
tx: Queryable,
playerId: string,
pilotId: string,
now: RealTime,
): Promise<Pilot> {
const pilot = await requireOwnPilot(tx, playerId, pilotId);
if (pilot.assignedShipId === null) {
throw gameError("pilot-not-assigned-to-ship", "That pilot has no ship.");
}
if (pilot.status === "in_flight") {
throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
}
const ship = await requireOwnShip(tx, playerId, pilot.assignedShipId);
if (ship.locationId === null || !(await hasBayAt(tx, playerId, ship.locationId))) {
throw gameError("no-bay-at-location", "Pilots can only leave their ship at one of your bays.");
}
const [updated] = await tx
.update(pilots)
.set({ assignedShipId: null, locationId: ship.locationId })
.where(eq(pilots.id, pilotId))
.returning();
return toApiPilot(updated!, null, now);
}
/**
* Rests an idle pilot now, at any duty level (CR-004): duty resets and they
* cannot fly until the rest ends. No bay is needed; crew swaps stay allowed.
*/
export async function restPilot(
tx: Queryable,
playerId: string,
pilotId: string,
now: RealTime,
): Promise<Pilot> {
await requireTutorialOver(tx, playerId, "resting a pilot");
const pilot = await requireOwnPilot(tx, playerId, pilotId);
if (pilot.status === "in_flight") {
throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
}
if (isResting(pilot.restEndsAt, now)) {
throw gameError("pilot-resting", "That pilot is already resting.");
}
const [updated] = await tx
.update(pilots)
.set({ dutyHours: 0, restEndsAt: restEndsAt(now, content.config) })
.where(eq(pilots.id, pilotId))
.returning();
const ship = updated!.assignedShipId
? await requireOwnShip(tx, playerId, updated!.assignedShipId)
: null;
return toApiPilot(updated!, ship?.locationId ?? null, now);
}
/** Spends one available skill point on a skill below its max level (Step 6.12). */
export async function spendSkillPoint(
tx: Queryable,
playerId: string,
pilotId: string,
skillId: string,
now: RealTime,
): Promise<Pilot> {
const pilot = await requireOwnPilot(tx, playerId, pilotId);
const skill = content.skill(skillId);
if (!skill) throw gameError("unknown-skill", `No skill "${skillId}".`);
const level = pilotLevel(pilot.xp, content.config);
if (skillPointsAvailable(level, pilot.skillPointsSpent, content.config) <= 0) {
throw gameError("no-skill-points", "That pilot has no skill points to spend.");
}
const current = pilot.skills[skillId] ?? 0;
if (current >= skill.maxLevel) {
throw gameError("skill-maxed", `${skill.name} is already at its maximum level.`);
}
const [updated] = await tx
.update(pilots)
.set({
skills: { ...pilot.skills, [skillId]: current + 1 },
skillPointsSpent: pilot.skillPointsSpent + 1,
})
.where(eq(pilots.id, pilotId))
.returning();
const [ship] = updated!.assignedShipId
? await tx.select().from(ships).where(eq(ships.id, updated!.assignedShipId))
: [];
return toApiPilot(updated!, ship?.locationId ?? null, now);
}

View File

@ -1,134 +0,0 @@
import { content } from "@deadhead/content";
import type { PlayerState } from "@deadhead/schema";
import type { RealTime } from "@deadhead/sim";
import { and, eq, isNull, max } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
bays,
companies,
discoveredLocations,
dispatches,
pilots,
players,
ships,
} from "../db/schema";
import { isDealAvailable } from "./deal";
import { goalStates } from "./goals";
import { toApiDispatch, toApiPilot, toApiShip } from "./mappers";
import { loadUpgradeLevels } from "./upgrades";
/** Reads everything the client needs about one player. */
export async function loadPlayerState(
db: Queryable,
playerId: string,
now: RealTime,
): Promise<PlayerState> {
const [player] = await db.select().from(players).where(eq(players.id, playerId));
const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId));
if (!player || !company) throw new Error(`Player ${playerId} not found`);
const shipRows = await db.select().from(ships).where(eq(ships.playerId, playerId));
const pilotRows = await db.select().from(pilots).where(eq(pilots.playerId, playerId));
const bayRows = await db.select().from(bays).where(eq(bays.playerId, playerId));
const discovered = await db
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
const active = await db
.select()
.from(dispatches)
.where(and(eq(dispatches.playerId, playerId), eq(dispatches.status, "in_flight")));
const arrivals = await db
.select({ shipId: dispatches.shipId, at: max(dispatches.arrivalAt) })
.from(dispatches)
.where(and(eq(dispatches.playerId, playerId), eq(dispatches.status, "resolved")))
.groupBy(dispatches.shipId);
const unacknowledged = await db
.select()
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.status, "resolved"),
isNull(dispatches.acknowledgedAt),
),
);
const discoveredIds = new Set(discovered.map((d) => d.locationId));
const shipById = new Map(shipRows.map((s) => [s.id, s]));
const activeByShip = new Map(active.filter((d) => d.shipId).map((d) => [d.shipId!, d.id]));
const upgradesByShip = await loadUpgradeLevels(
db,
shipRows.map((s) => s.id),
);
const lastArrivalByShip = new Map(arrivals.map((a) => [a.shipId, a.at]));
const pilotByShip = new Map(
pilotRows.filter((p) => p.assignedShipId).map((p) => [p.assignedShipId!, p.id]),
);
return {
playerId,
company: {
id: company.id,
name: company.name,
credits: company.credits,
loanBalance: company.loanBalance,
},
ships: shipRows.map((s) =>
toApiShip(
s,
{
dispatchId: activeByShip.get(s.id) ?? null,
pilotId: pilotByShip.get(s.id) ?? null,
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
upgrades: upgradesByShip.get(s.id) ?? {},
},
now,
),
),
pilots: pilotRows.map((p) =>
toApiPilot(
p,
p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null,
now,
),
),
bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })),
locations: content.locations.map((l) => ({ id: l.id, discovered: discoveredIds.has(l.id) })),
tutorial:
player.tutorialActive && player.loanerLocationId
? {
loanerLocationId: player.loanerLocationId as "earth" | "moon",
runInFlight: active.some((d) => d.tutorial),
runsCompleted: player.tutorialRunsCompleted,
dealAvailable: isDealAvailable(
player,
active.some((d) => d.tutorial),
),
}
: null,
activeDispatches: active.map(toApiDispatch),
summaries: unacknowledged.map((d) => ({
dispatchId: d.id,
shipId: d.record.shipId,
pilotId: d.pilotId,
originId: d.record.originId,
destinationId: d.record.destinationId,
tutorial: d.tutorial,
deadhead: d.record.contract === null,
fuelCost: d.record.fuelCost,
goodId: d.record.contract?.goodId ?? null,
massTonnes: d.record.contract?.massTonnes ?? null,
shipper: d.record.contract?.shipper ?? null,
credits: d.resolution?.credits ?? 0,
loanRepayment: d.resolution?.loanRepayment ?? 0,
pilotPay: d.resolution?.pilotPay ?? 0,
pilotXp: d.resolution?.pilotXp ?? 0,
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
scriptedId: d.record.contract?.scriptedId ?? null,
restEndsAt: d.resolution?.pilotRestEndsAt ?? null,
resolvedAt: d.resolvedAt ?? d.arrivalAt,
})),
goals: await goalStates(db, playerId),
};
}

View File

@ -1,116 +0,0 @@
import { content } from "@deadhead/content";
import { resolveDispatch, type RealTime } from "@deadhead/sim";
import { and, asc, eq, lte, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { companies, discoveredLocations, dispatches, pilots, players, ships } from "../db/schema";
/**
* Resolves every in-flight dispatch of a player that has arrived by `now`, in
* arrival order, applying credits (net of loan repayment), the repayment, XP,
* ship/pilot location and discovery. Run
* it inside `withPlayerLock`. Each dispatch moves from in_flight to resolved
* exactly once: the status update is conditional, and effects are applied only
* when it changed a row. Returns how many dispatches were resolved.
*/
export async function resolveArrivedDispatches(
tx: Queryable,
playerId: string,
now: RealTime,
): Promise<number> {
const due = await tx
.select()
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.status, "in_flight"),
lte(dispatches.arrivalAt, now),
),
)
.orderBy(asc(dispatches.arrivalAt));
if (due.length === 0) return 0;
const discovered = await tx
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
const discoveredIds = new Set(discovered.map((d) => d.locationId));
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
// Carried forward so each arrival repays against the balance left by the previous one.
let loanBalance = company?.loanBalance ?? 0;
let resolvedCount = 0;
for (const dispatch of due) {
const [pilot] = await tx.select().from(pilots).where(eq(pilots.id, dispatch.pilotId));
if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`);
const result = resolveDispatch(
{
record: dispatch.record,
pilotIsAvatar: pilot.isAvatar,
discoveredIds,
loanBalance,
pilotDutyHours: pilot.dutyHours,
pilotSkills: pilot.skills,
now,
},
content,
);
if (result.status !== "resolved") continue;
const { resolution } = result;
const claimed = await tx
.update(dispatches)
.set({ status: "resolved", resolution, resolvedAt: now })
.where(and(eq(dispatches.id, dispatch.id), eq(dispatches.status, "in_flight")))
.returning({ id: dispatches.id });
if (claimed.length === 0) continue;
resolvedCount++;
loanBalance -= resolution.loanRepayment;
await tx
.update(companies)
.set({
credits: sql`${companies.credits} + ${resolution.credits}`,
loanBalance: sql`${companies.loanBalance} - ${resolution.loanRepayment}`,
})
.where(eq(companies.playerId, playerId));
await tx
.update(pilots)
.set({
xp: sql`${pilots.xp} + ${resolution.pilotXp}`,
status: "idle",
// Duty and mandatory rest from the arrival time (decisions 0.5.42–0.5.45).
dutyHours: resolution.pilotDutyHours,
...(resolution.pilotRestEndsAt !== null ? { restEndsAt: resolution.pilotRestEndsAt } : {}),
// Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar.
...(pilot.assignedShipId === null ? { locationId: resolution.shipLocationId } : {}),
})
.where(eq(pilots.id, pilot.id));
if (dispatch.shipId) {
await tx
.update(ships)
.set({ status: "idle", locationId: resolution.shipLocationId })
.where(eq(ships.id, dispatch.shipId));
}
if (dispatch.tutorial) {
await tx
.update(players)
.set({
loanerLocationId: resolution.shipLocationId,
tutorialRunsCompleted: sql`${players.tutorialRunsCompleted} + 1`,
})
.where(eq(players.id, playerId));
}
if (resolution.discoversDestination) {
await tx
.insert(discoveredLocations)
.values({ playerId, locationId: dispatch.record.destinationId, discoveredAt: now })
.onConflictDoNothing();
discoveredIds.add(dispatch.record.destinationId);
}
}
return resolvedCount;
}

View File

@ -1,56 +0,0 @@
import type { RealTime } from "@deadhead/sim";
import { and, eq, inArray, isNotNull, isNull, lte } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { dispatches, ships } from "../db/schema";
/**
* Marks "while you were away" summaries as seen (Step 6.13). Only this
* player's resolved, unacknowledged dispatches are touched; other ids are
* ignored. Returns how many were acknowledged.
*/
export async function acknowledgeSummaries(
tx: Queryable,
playerId: string,
dispatchIds: string[],
now: RealTime,
): Promise<number> {
const updated = await tx
.update(dispatches)
.set({ acknowledgedAt: now })
.where(
and(
eq(dispatches.playerId, playerId),
inArray(dispatches.id, dispatchIds),
eq(dispatches.status, "resolved"),
isNull(dispatches.acknowledgedAt),
),
)
.returning({ id: dispatches.id });
return updated.length;
}
/**
* Clears finished upgrades the player has now seen ("out of the shop", CR-009).
* Ships still in the shop keep their job.
*/
export async function acknowledgeFinishedUpgrades(
tx: Queryable,
playerId: string,
shipIds: string[],
now: RealTime,
): Promise<number> {
if (shipIds.length === 0) return 0;
const cleared = await tx
.update(ships)
.set({ shopUpgradeId: null, shopLevel: null })
.where(
and(
eq(ships.playerId, playerId),
inArray(ships.id, shipIds),
isNotNull(ships.shopUpgradeId),
lte(ships.inShopUntil, now),
),
)
.returning({ id: ships.id });
return cleared.length;
}

View File

@ -1,101 +0,0 @@
import { content } from "@deadhead/content";
import type { Ship } from "@deadhead/schema";
import { validateUpgrade, type RealTime, type UpgradeLevels } from "@deadhead/sim";
import { and, eq, inArray, max, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { companies, dispatches, pilots, players, shipUpgrades, ships } from "../db/schema";
import { gameError } from "../http/errors";
import { shipState } from "./dispatch";
import { toApiShip } from "./mappers";
import { UPGRADE_MESSAGES } from "./messages";
import { hasBayAt, requireOwnShip } from "./pilots";
/** Installed upgrade levels for each of the given ships (CR-009). */
export async function loadUpgradeLevels(
tx: Queryable,
shipIds: string[],
): Promise<Map<string, Record<string, number>>> {
const levels = new Map<string, Record<string, number>>();
if (shipIds.length === 0) return levels;
const rows = await tx.select().from(shipUpgrades).where(inArray(shipUpgrades.shipId, shipIds));
for (const row of rows) {
const ship = levels.get(row.shipId) ?? {};
ship[row.upgradeId] = row.level;
levels.set(row.shipId, ship);
}
return levels;
}
/** One ship's installed upgrade levels. */
export async function shipUpgradeLevels(tx: Queryable, shipId: string): Promise<UpgradeLevels> {
return (await loadUpgradeLevels(tx, [shipId])).get(shipId) ?? {};
}
/**
* Starts the next level of an upgrade on a ship (CR-009, decisions 0.5.64–0.5.66),
* inside the caller's player-locked transaction: validate, charge, raise the
* level now, and keep the ship in the shop until the work is done.
*/
export async function startUpgrade(
tx: Queryable,
playerId: string,
input: { shipId: string; upgradeId: string },
now: RealTime,
): Promise<Ship> {
const upgrade = content.upgrade(input.upgradeId);
if (!upgrade) throw gameError("not-found", `No upgrade "${input.upgradeId}".`);
const row = await requireOwnShip(tx, playerId, input.shipId);
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
if (!player || !company) throw new Error(`Player ${playerId} not found`);
const levels = await shipUpgradeLevels(tx, row.id);
const check = validateUpgrade(
{
ship: shipState(row, levels),
upgrade,
atHq: row.locationId !== null && (await hasBayAt(tx, playerId, row.locationId)),
tutorialActive: player.tutorialActive,
credits: company.credits,
now,
},
content,
);
if (!check.ok) throw gameError(check.reason, UPGRADE_MESSAGES[check.reason]);
await tx
.update(companies)
.set({ credits: sql`${companies.credits} - ${check.cost}` })
.where(eq(companies.playerId, playerId));
await tx
.insert(shipUpgrades)
.values({ shipId: row.id, upgradeId: upgrade.id, level: check.level })
.onConflictDoUpdate({
target: [shipUpgrades.shipId, shipUpgrades.upgradeId],
set: { level: check.level },
});
const [updated] = await tx
.update(ships)
.set({ inShopUntil: check.shopUntil, shopUpgradeId: upgrade.id, shopLevel: check.level })
.where(eq(ships.id, row.id))
.returning();
const [pilot] = await tx
.select({ id: pilots.id })
.from(pilots)
.where(eq(pilots.assignedShipId, row.id));
const [arrival] = await tx
.select({ at: max(dispatches.arrivalAt) })
.from(dispatches)
.where(and(eq(dispatches.shipId, row.id), eq(dispatches.status, "resolved")));
return toApiShip(
updated!,
{
dispatchId: null,
pilotId: pilot?.id ?? null,
lastArrivalAt: arrival?.at ?? row.createdAt,
upgrades: { ...levels, [upgrade.id]: check.level },
},
now,
);
}

View File

@ -1,48 +0,0 @@
import type { ErrorCode, ErrorResponse } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
/** An expected failure, sent as `{ error: { code, message } }` with its HTTP status. */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: ErrorCode,
message: string,
) {
super(message);
}
}
const NOT_FOUND_CODES: ReadonlySet<ErrorCode> = new Set([
"not-found",
"unknown-contract",
"unknown-skill",
"unknown-goal",
]);
/** A game-rule failure: 404 for unknown things, 409 for everything else. */
export function gameError(code: ErrorCode, message: string): ApiError {
return new ApiError(NOT_FOUND_CODES.has(code) ? 404 : 409, code, message);
}
export function errorBody(code: ErrorCode, message: string): ErrorResponse {
return { error: { code, message } };
}
/** Turns thrown `ApiError`s into the documented error format; anything else is a 500. */
export function registerErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error, request, reply) => {
if (error instanceof ApiError) {
return reply.status(error.status).send(errorBody(error.code, error.message));
}
// Fastify's own 4xx errors, e.g. a body that is not valid JSON.
const status = (error as { statusCode?: number }).statusCode;
if (status !== undefined && status >= 400 && status < 500) {
return reply.status(400).send(errorBody("validation-error", (error as Error).message));
}
request.log.error(error);
return reply.status(500).send(errorBody("internal-error", "Something went wrong."));
});
app.setNotFoundHandler((_request, reply) =>
reply.status(404).send(errorBody("not-found", "No such endpoint.")),
);
}

View File

@ -1,72 +0,0 @@
import type { RouteDef } from "@deadhead/schema";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import type { z } from "zod";
import { ApiError } from "./errors";
export interface RouteContext<Auth extends boolean> {
/** The signed-in player; only present on routes that require a session. */
playerId: Auth extends true ? string : null;
request: FastifyRequest;
reply: FastifyReply;
}
export interface RouteOptions {
/** Resolves the session to a player id, or throws `401 unauthorized`. */
requirePlayer: (request: FastifyRequest) => Promise<string>;
/** Check responses against their schema (everywhere except production). */
validateResponses: boolean;
}
function describeIssues(error: z.ZodError): string {
return error.issues
.map((issue) => (issue.path.length ? `${issue.path.join(".")}: ` : "") + issue.message)
.join("; ");
}
/**
* Registers one catalog route. The request (path parameters, query and body
* merged) is validated before anything else, then the session is checked if the
* route needs one, and the response is checked against its schema.
*/
export function registerRoute<R extends RouteDef>(
app: FastifyInstance,
route: R,
options: RouteOptions,
handler: (
input: z.output<R["request"]>,
context: RouteContext<R["auth"]>,
) => Promise<z.input<R["response"]>>,
): void {
app.route({
method: route.method,
url: route.path,
handler: async (request, reply) => {
const raw = {
...(request.query as object),
...(typeof request.body === "object" && request.body !== null ? request.body : {}),
...(request.params as object),
};
const parsed = route.request.safeParse(raw);
if (!parsed.success) {
throw new ApiError(400, "validation-error", describeIssues(parsed.error));
}
const playerId = route.auth ? await options.requirePlayer(request) : null;
const result = await handler(parsed.data as z.output<R["request"]>, {
playerId: playerId as RouteContext<R["auth"]>["playerId"],
request,
reply,
});
if (options.validateResponses) {
const checked = route.response.safeParse(result);
if (!checked.success) {
throw new Error(
`Response for ${route.method} ${route.path} violates its schema: ${describeIssues(checked.error)}`,
);
}
}
return result;
},
});
}

View File

@ -1,30 +0,0 @@
import { eq } from "drizzle-orm";
import { fromNodeHeaders } from "better-auth/node";
import type { FastifyRequest } from "fastify";
import type { Auth } from "../auth";
import type { Db } from "../db/client";
import { playerLogins, players } from "../db/schema";
import { ApiError } from "./errors";
/** The signed-in player's id, or a 401 `unauthorized` error. */
export async function requirePlayerId(
request: FastifyRequest,
auth: Auth,
db: Db,
): Promise<string> {
const session = await auth.api.getSession({ headers: fromNodeHeaders(request.headers) });
if (!session) throw new ApiError(401, "unauthorized", "Sign in to continue.");
const [player] = await db
.select({ id: players.id })
.from(players)
.where(eq(players.userId, session.user.id));
if (player) return player.id;
// A device signed in with a backup code (decision 0.5.72).
const [login] = await db
.select({ playerId: playerLogins.playerId })
.from(playerLogins)
.where(eq(playerLogins.userId, session.user.id));
if (!login) throw new ApiError(401, "unauthorized", "No player for this account.");
return login.playerId;
}

View File

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

View File

@ -1,34 +0,0 @@
import { content } from "@deadhead/content";
import { boardPeriodIndex, type RealTime } from "@deadhead/sim";
import { lt } from "drizzle-orm";
import type { Db } from "../db/client";
import { acceptedContracts, contractBoards } from "../db/schema";
/**
* How many past board periods to keep. Older boards are never shown, and their
* contracts are expired; dispatches keep their own contract snapshot.
*/
export const BOARD_RETENTION_PERIODS = 24;
/**
* Deletes stored contract boards and accepted-contract records from periods
* older than the retention window (Step 7.3). Returns how many rows went.
*/
export async function cleanupExpiredContracts(
db: Db,
now: RealTime,
retentionPeriods = BOARD_RETENTION_PERIODS,
): Promise<{ boards: number; acceptedContracts: number }> {
const cutoff = boardPeriodIndex(now, content.config) - retentionPeriods;
return db.transaction(async (tx) => {
const boards = await tx
.delete(contractBoards)
.where(lt(contractBoards.periodIndex, cutoff))
.returning({ id: contractBoards.id });
const accepted = await tx
.delete(acceptedContracts)
.where(lt(acceptedContracts.periodIndex, cutoff))
.returning({ id: acceptedContracts.contractId });
return { boards: boards.length, acceptedContracts: accepted.length };
});
}

View File

@ -1,42 +0,0 @@
import type { Clock } from "../clock";
import type { Db } from "../db/client";
import { withPlayerLock } from "../game/lock";
import { resolveArrivedDispatches } from "../game/resolve";
import { cleanupExpiredContracts } from "./cleanup";
import type { JobDefinition } from "./queue";
export const RESOLVE_ARRIVALS_JOB = "resolve-arrivals";
export const CLEANUP_CONTRACTS_JOB = "cleanup-expired-contracts";
export interface JobContext {
db: Db;
clock: Clock;
}
/**
* Resolves a player's arrived dispatches, exactly like lazy resolution on read
* (Step 7.2). Scheduled for each dispatch's arrival; does nothing if a request
* already resolved it.
*/
export async function resolveArrivalsJob(ctx: JobContext, data: Record<string, unknown>) {
const playerId = data.playerId;
if (typeof playerId !== "string") throw new Error("resolve-arrivals job needs a playerId");
await withPlayerLock(ctx.db, playerId, (tx) =>
resolveArrivedDispatches(tx, playerId, ctx.clock()),
);
}
/** Every background job the app runs. */
export function appJobs(ctx: JobContext): JobDefinition[] {
return [
{ name: RESOLVE_ARRIVALS_JOB, handler: (data) => resolveArrivalsJob(ctx, data) },
{
name: CLEANUP_CONTRACTS_JOB,
// Hourly, on the hour (UTC).
cron: "0 * * * *",
handler: async () => {
await cleanupExpiredContracts(ctx.db, ctx.clock());
},
},
];
}

View File

@ -1,72 +0,0 @@
import { PgBoss } from "pg-boss";
export type JobHandler = (data: Record<string, unknown>) => Promise<void>;
export interface JobDefinition {
name: string;
handler: JobHandler;
/** Cron expression for a recurring job (UTC). */
cron?: string;
}
/** Background work. The real one is pg-boss; tests can pass a recording fake. */
export interface JobQueue {
start(): Promise<void>;
stop(): Promise<void>;
/** Enqueues a job, optionally not before `startAfter` (Unix ms). */
send(name: string, data: Record<string, unknown>, startAfter?: number): Promise<string | null>;
}
/**
* pg-boss in the same Postgres database (its own `pgboss` schema). Queues are
* always created so the app can enqueue; workers and cron schedules run only
* when `workers` is true (Step 7.1).
*/
export function createPgBossQueue(
databaseUrl: string,
options: { workers: boolean; jobs: JobDefinition[]; onError?: (error: Error) => void },
): JobQueue & { boss: PgBoss } {
const boss = new PgBoss({ connectionString: databaseUrl, schedule: options.workers });
if (options.onError) boss.on("error", options.onError);
return {
boss,
async start() {
await boss.start();
for (const job of options.jobs) {
await boss.createQueue(job.name);
if (!options.workers) continue;
await boss.work<Record<string, unknown>>(job.name, async (batch) => {
for (const item of batch) await job.handler(item.data);
});
if (job.cron) await boss.schedule(job.name, job.cron);
}
},
async stop() {
await boss.stop({ graceful: true });
},
send(name, data, startAfter) {
return boss.send(
name,
data,
startAfter === undefined ? {} : { startAfter: new Date(startAfter) },
);
},
};
}
/** A queue that only records what was sent; for tests that call handlers directly. */
export function createRecordingQueue(): JobQueue & {
sent: { name: string; data: Record<string, unknown>; startAfter?: number }[];
} {
const sent: { name: string; data: Record<string, unknown>; startAfter?: number }[] = [];
return {
sent,
async start() {},
async stop() {},
async send(name, data, startAfter) {
sent.push({ name, data, ...(startAfter === undefined ? {} : { startAfter }) });
return `recorded-${sent.length}`;
},
};
}

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,17 +0,0 @@
import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
import { locationBoard, tutorialBoard } from "../game/boards";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/** Contract boards. */
export function registerBoardRoutes(app: FastifyInstance, ctx: AppContext): void {
registerRoute(app, routes.getTutorialBoard, ctx.options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, (tx, now) => tutorialBoard(tx, playerId, now)),
);
registerRoute(app, routes.getContractBoard, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, (tx, now) => locationBoard(tx, playerId, input.locationId, now)),
);
}

View File

@ -1,16 +0,0 @@
import type { Auth } from "../auth";
import type { Clock } from "../clock";
import type { Db } from "../db/client";
import type { RouteOptions } from "../http/route";
import type { JobQueue } from "../jobs/queue";
/** What every route module needs. */
export interface AppContext {
db: Db;
auth: Auth;
clock: Clock;
/** Converts an app-clock time to the real clock pg-boss uses (they differ only in tests). */
toBaseTime: (time: number) => number;
jobs: JobQueue;
options: RouteOptions;
}

View File

@ -1,127 +0,0 @@
import { content, upgradeMaxLevel } from "@deadhead/content";
import { routes } from "@deadhead/schema";
import { and, eq, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import type { OffsetClock } from "../clock";
import { companies, discoveredLocations, pilots, ships, shipUpgrades } from "../db/schema";
import { gameError } from "../http/errors";
import { registerRoute } from "../http/route";
import { requireOwnPilot, requireOwnShip } from "../game/pilots";
import { loadPlayerState } from "../game/player-state";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/**
* Developer tools (CR-011): fast-forward the clock, add credits, and adjust
* pilots, ships and discoveries for testing. Only registered outside
* production, like the clock-offset route (decision 0.5.18).
*/
export function registerDevRoutes(app: FastifyInstance, ctx: AppContext, clock: OffsetClock): void {
registerRoute(app, routes.devAdvanceClock, ctx.options, async (input) => {
clock.setOffset(clock.offsetMs() + input.advanceMs);
return { offsetMs: clock.offsetMs(), realTime: clock.now() };
});
registerRoute(app, routes.devAddCredits, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => {
const [company] = await tx
.update(companies)
.set({ credits: sql`${companies.credits} + ${input.amount}` })
.where(eq(companies.playerId, playerId))
.returning();
return {
company: {
id: company!.id,
name: company!.name,
credits: company!.credits,
loanBalance: company!.loanBalance,
},
};
}),
);
// Level up: XP to the next level's threshold (skill points follow from XP).
// Clear rest: duty back to zero and any rest ended now.
registerRoute(app, routes.devPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
if (input.action === "level-up") {
const thresholds = content.config.pilotLevelXpThresholds;
const next = thresholds.find((xp) => xp > pilot.xp);
if (next !== undefined) {
await tx.update(pilots).set({ xp: next }).where(eq(pilots.id, pilot.id));
}
} else {
await tx
.update(pilots)
.set({ dutyHours: 0, restEndsAt: null })
.where(eq(pilots.id, pilot.id));
}
return loadPlayerState(tx, playerId, now);
}),
);
registerRoute(app, routes.devShip, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
const ship = await requireOwnShip(tx, playerId, input.shipId);
switch (input.action) {
case "max-upgrades": {
// Every upgrade at its cap for this model, installed at once and free.
const rows = content.upgrades
.map((u) => ({
shipId: ship.id,
upgradeId: u.id,
level: upgradeMaxLevel(u, ship.modelId),
}))
.filter((r) => r.level > 0);
await tx.delete(shipUpgrades).where(eq(shipUpgrades.shipId, ship.id));
if (rows.length > 0) await tx.insert(shipUpgrades).values(rows);
break;
}
case "reset-upgrades":
await tx.delete(shipUpgrades).where(eq(shipUpgrades.shipId, ship.id));
break;
case "finish-shop":
// Done now: the "out of the shop" note still shows, as for a finished job.
if (ship.inShopUntil !== null && ship.inShopUntil > now) {
await tx.update(ships).set({ inShopUntil: now }).where(eq(ships.id, ship.id));
}
break;
case "move": {
if (ship.status !== "idle") {
throw gameError("ship-not-idle", "Only an idle ship can be moved.");
}
const [known] = await tx
.select()
.from(discoveredLocations)
.where(
and(
eq(discoveredLocations.playerId, playerId),
eq(discoveredLocations.locationId, input.locationId),
),
);
if (!known) {
throw gameError(
"location-undiscovered",
"Ships can only be moved to discovered places.",
);
}
await tx.update(ships).set({ locationId: input.locationId }).where(eq(ships.id, ship.id));
break;
}
}
return loadPlayerState(tx, playerId, now);
}),
);
// Every location discovered, for testing range and reach.
registerRoute(app, routes.devDiscoverAll, ctx.options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
await tx
.insert(discoveredLocations)
.values(content.locations.map((l) => ({ playerId, locationId: l.id, discoveredAt: now })))
.onConflictDoNothing();
return loadPlayerState(tx, playerId, now);
}),
);
}

View File

@ -1,36 +0,0 @@
import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
import { dispatchContract, dispatchReposition, dispatchTutorial } from "../game/dispatch";
import { registerRoute } from "../http/route";
import { RESOLVE_ARRIVALS_JOB } from "../jobs/definitions";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/** Contract, tutorial and reposition dispatches. */
export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): void {
registerRoute(app, routes.createDispatch, ctx.options, async (input, { playerId, request }) => {
const result = await withPlayer(ctx, playerId, async (tx, now) => {
switch (input.kind) {
case "contract":
return { dispatch: await dispatchContract(tx, playerId, input, now) };
case "tutorial":
return { dispatch: await dispatchTutorial(tx, playerId, input.contractId, now) };
case "reposition":
return { dispatch: await dispatchReposition(tx, playerId, input, now) };
}
});
// Resolve at arrival even if the player never comes back (Step 7.2). Sent after
// the dispatch commits; if sending fails, lazy resolution on read still covers it.
try {
await ctx.jobs.send(
RESOLVE_ARRIVALS_JOB,
{ playerId, dispatchId: result.dispatch.id },
ctx.toBaseTime(result.dispatch.arrivalAt),
);
} catch (error) {
request.log.error({ err: error }, "Could not schedule the arrival job");
}
return result;
});
}

View File

@ -1,59 +0,0 @@
import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
import { buyShip, leaseBay } from "../game/fleet";
import { assignPilot, hirePilot, restPilot, spendSkillPoint, unassignPilot } from "../game/pilots";
import { startUpgrade } from "../game/upgrades";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/** Ships, bays and pilots. */
export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void {
registerRoute(app, routes.buyShip, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
ship: await buyShip(tx, playerId, input, now),
})),
);
registerRoute(app, routes.leaseBay, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
bay: await leaseBay(tx, playerId, input.locationId, now),
})),
);
registerRoute(app, routes.hirePilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await hirePilot(tx, playerId, input.locationId, now),
})),
);
registerRoute(app, routes.assignPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId, now),
})),
);
registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await unassignPilot(tx, playerId, input.pilotId, now),
})),
);
registerRoute(app, routes.startUpgrade, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
ship: await startUpgrade(tx, playerId, input, now),
})),
);
registerRoute(app, routes.restPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await restPilot(tx, playerId, input.pilotId, now),
})),
);
registerRoute(app, routes.spendSkillPoint, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId, now),
})),
);
}

View File

@ -1,142 +0,0 @@
import { randomInt } from "node:crypto";
import { content } from "@deadhead/content";
import { routes } from "@deadhead/schema";
import { realToGame } from "@deadhead/sim";
import { fromNodeHeaders } from "better-auth/node";
import type { FastifyInstance } from "fastify";
import { acceptDeal } from "../game/deal";
import { claimGoal } from "../game/goals";
import {
backupCodeStatus,
createBackupCode,
playerForBackupCode,
RestoreLimiter,
} from "../game/backup";
import { renameCompany } from "../game/company";
import { repayLoan } from "../game/loan";
import { newCompany } from "../game/new-company";
import { createPlayer } from "../game/new-player";
import { loadPlayerState } from "../game/player-state";
import { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries";
import { playerLogins } from "../db/schema";
import { ApiError } from "../http/errors";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/** Server time, guest sign-up and player state. */
export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): void {
const { db, auth, clock, options } = ctx;
registerRoute(app, routes.getServerTime, options, async () => {
const realTime = clock();
return { realTime, gameTime: realToGame(realTime, content.config) };
});
registerRoute(app, routes.createGuest, options, async (_input, { request, reply }) => {
const signIn = await auth.api.signInAnonymous({
headers: fromNodeHeaders(request.headers),
returnHeaders: true,
});
if (!signIn.response) throw new ApiError(500, "internal-error", "Could not create a guest.");
const { playerId } = await createPlayer(db, {
userId: signIn.response.user.id,
// A 32-bit unsigned seed (decision 0.5.10).
worldSeed: randomInt(0, 2 ** 32),
now: clock(),
});
// Pass Better Auth's session cookie through to the client.
reply.header("set-cookie", signIn.headers.getSetCookie());
return { playerId, state: await loadPlayerState(db, playerId, clock()) };
});
// Lazy resolution on read (Step 6.1): apply every arrival before returning state.
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, (tx, now) => loadPlayerState(tx, playerId, now)),
);
registerRoute(app, routes.acknowledgeSummaries, options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
acknowledged:
(await acknowledgeSummaries(tx, playerId, input.dispatchIds, now)) +
(await acknowledgeFinishedUpgrades(tx, playerId, input.shipIds ?? [], now)),
})),
);
registerRoute(app, routes.acceptDeal, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
await acceptDeal(tx, playerId, now);
return loadPlayerState(tx, playerId, now);
}),
);
registerRoute(app, routes.getBackupCode, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, (tx) => backupCodeStatus(tx, playerId)),
);
registerRoute(app, routes.createBackupCode, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, (tx, now) => createBackupCode(tx, playerId, now)),
);
// Restoring signs this device in as the code's player (decisions 0.5.72, 0.5.73).
const limiter = new RestoreLimiter();
registerRoute(app, routes.restoreCompany, options, async (input, { request, reply }) => {
const now = clock();
const device = request.headers["x-device-id"];
const keys = [`ip:${request.ip}`, ...(typeof device === "string" ? [`device:${device}`] : [])];
if (limiter.blocked(keys, now)) {
throw new ApiError(
429,
"rate-limited",
"Too many attempts. Wait a few minutes and try again.",
);
}
const playerId = await playerForBackupCode(db, input.code);
if (!playerId) {
limiter.fail(keys, now);
throw new ApiError(
409,
"restore-failed",
"That code didn't open a company. Check it and try again.",
);
}
// A fresh guest sign-in for this device (any old session is left behind),
// linked to the code's player.
const headers = fromNodeHeaders(request.headers);
headers.delete("cookie");
const signIn = await auth.api.signInAnonymous({ headers, returnHeaders: true });
if (!signIn.response)
throw new ApiError(500, "internal-error", "Could not sign this device in.");
await db
.insert(playerLogins)
.values({ userId: signIn.response.user.id, playerId, createdAt: now });
reply.header("set-cookie", signIn.headers.getSetCookie());
return { playerId, state: await loadPlayerState(db, playerId, now) };
});
registerRoute(app, routes.newCompany, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
await newCompany(tx, playerId, now);
return loadPlayerState(tx, playerId, now);
}),
);
registerRoute(app, routes.renameCompany, options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({
company: await renameCompany(tx, playerId, input.name),
})),
);
registerRoute(app, routes.repayLoan, options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({
company: await repayLoan(tx, playerId, input.amount),
})),
);
registerRoute(app, routes.claimGoal, options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
company: await claimGoal(tx, playerId, input.goalId, now),
})),
);
}

View File

@ -1,20 +0,0 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { OffsetClock } from "../clock";
import { ApiError } from "../http/errors";
const bodySchema = z.object({ offsetMs: z.number().int() }).strict();
/**
* Test-only: shifts the server clock so E2E tests can fast-forward to arrivals
* (decision 0.5.18). Never registered in production; not part of the catalog.
*/
export function registerTestClockRoute(app: FastifyInstance, clock: OffsetClock): void {
app.post("/api/test/clock-offset", async (request) => {
const parsed = bodySchema.safeParse(request.body ?? {});
if (!parsed.success)
throw new ApiError(400, "validation-error", "Expected { offsetMs: integer }.");
clock.setOffset(parsed.data.offsetMs);
return { offsetMs: clock.offsetMs(), realTime: clock.now() };
});
}

View File

@ -1,20 +0,0 @@
import type { Queryable } from "../db/client";
import { withPlayerLock } from "../game/lock";
import { resolveArrivedDispatches } from "../game/resolve";
import type { AppContext } from "./context";
/**
* Runs a game action for a player: takes the player lock, applies any arrivals
* that are due (so the action sees current state), then runs `fn`.
*/
export function withPlayer<T>(
ctx: AppContext,
playerId: string,
fn: (tx: Queryable, now: number) => Promise<T>,
): Promise<T> {
const now = ctx.clock();
return withPlayerLock(ctx.db, playerId, async (tx) => {
await resolveArrivedDispatches(tx, playerId, now);
return fn(tx, now);
});
}

View File

@ -1,120 +0,0 @@
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { companies, dispatches, ships } from "../src/db/schema";
import { RESOLVE_ARRIVALS_JOB, resolveArrivalsJob } from "../src/jobs/definitions";
import { createRecordingQueue } from "../src/jobs/queue";
import { makeOwner, setCredits, useTestDatabase } from "./db";
import { call, getState, putBoard, signUp, testContract, useTestApp } from "./game";
import { testConfig } from "./helpers";
const database = useTestDatabase();
const testApp = useTestApp(database);
/** An owner who has just dispatched a 2,000-credit Earth→Moon contract. */
async function dispatched() {
const { app } = testApp();
const { db } = database();
const guest = await signUp(app);
const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 10_000);
const contract = testContract();
await putBoard(db, guest.playerId, [contract]);
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "contract",
contractId: contract.id,
shipId,
pilotId: guest.avatarId,
});
const dispatch = response.json().dispatch as { id: string; arrivalAt: number; fuelCost: number };
return { guest, shipId, dispatch };
}
const credits = async (playerId: string) =>
(await database().db.select().from(companies).where(eq(companies.playerId, playerId)))[0]!
.credits;
describe("arrival resolution job", () => {
it("is scheduled for the dispatch's arrival time", async () => {
const { jobs } = testApp();
const { guest, dispatch } = await dispatched();
expect(jobs.sent).toEqual([
{
name: RESOLVE_ARRIVALS_JOB,
data: { playerId: guest.playerId, dispatchId: dispatch.id },
startAfter: dispatch.arrivalAt,
},
]);
});
it("resolves the dispatch at arrival without any player state fetch", async () => {
const { clock, jobs } = testApp();
const { db } = database();
const { guest, shipId, dispatch } = await dispatched();
const jobCtx = { db, clock: clock.read };
// Too early: nothing happens.
clock.now = dispatch.arrivalAt - 1;
await resolveArrivalsJob(jobCtx, jobs.sent[0]!.data);
expect((await db.select().from(dispatches))[0]!.status).toBe("in_flight");
clock.now = dispatch.arrivalAt;
await resolveArrivalsJob(jobCtx, jobs.sent[0]!.data);
const [row] = await db.select().from(dispatches).where(eq(dispatches.id, dispatch.id));
expect(row!.status).toBe("resolved");
expect(await credits(guest.playerId)).toBe(10_000 - dispatch.fuelCost + 2000);
const [ship] = await db.select().from(ships).where(eq(ships.id, shipId));
expect(ship).toMatchObject({ status: "idle", locationId: "moon" });
});
it("does nothing if lazy resolution already happened", async () => {
const { app, clock, jobs } = testApp();
const { db } = database();
const { guest, dispatch } = await dispatched();
clock.now = dispatch.arrivalAt + 1000;
const state = await getState(app, guest);
await resolveArrivalsJob({ db, clock: clock.read }, jobs.sent[0]!.data);
expect(await credits(guest.playerId)).toBe(state.company.credits);
expect((await getState(app, guest)).summaries).toHaveLength(1);
});
});
describe("test-only clock offset endpoint", () => {
it("shifts the server clock outside production", async () => {
const { app, clock } = testApp();
const response = await app.inject({
method: "POST",
url: "/api/test/clock-offset",
payload: { offsetMs: 3_600_000 },
});
expect(response.statusCode).toBe(200);
const time = (await app.inject({ method: "GET", url: "/api/time" })).json();
expect(time.realTime).toBe(clock.now + 3_600_000);
});
it("schedules arrival jobs on the real clock while an offset is set", async () => {
const { app, jobs } = testApp();
await app.inject({
method: "POST",
url: "/api/test/clock-offset",
payload: { offsetMs: 60_000 },
});
const { dispatch } = await dispatched();
expect(jobs.sent[0]!.startAfter).toBe(dispatch.arrivalAt - 60_000);
});
it("does not exist in production", async () => {
const app = buildApp(testConfig({ NODE_ENV: "production" }), {
database: database(),
jobs: createRecordingQueue(),
});
const response = await app.inject({
method: "POST",
url: "/api/test/clock-offset",
payload: { offsetMs: 1 },
});
expect(response.statusCode).toBe(404);
await app.close();
});
});

View File

@ -1,106 +0,0 @@
import {
backupCodeResponseSchema,
createGuestResponseSchema,
errorResponseSchema,
} from "@deadhead/schema";
import { sql } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { BACKUP_ALPHABET, normalizeBackupCode } from "../src/game/backup";
import { setCredits, useTestDatabase } from "./db";
import { call, cookieFrom, getState, signUp, signUpOwner, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const makeCode = async (guest: Guest) =>
backupCodeResponseSchema.parse(
(await call(testApp().app, guest, "POST", "/api/backup-code", {})).json(),
).code;
/** Restores on a new device (no cookie); `device` sets the device-id header. */
const restore = (code: string, device = "device-a", ip = "10.0.0.1") =>
testApp().app.inject({
method: "POST",
url: "/api/restore",
headers: { "x-device-id": device },
remoteAddress: ip,
payload: { code },
});
describe("backup codes (Step 10K.8)", () => {
it("makes a 20-character code in four groups, without look-alike characters", async () => {
const guest = await signUp(testApp().app);
const code = await makeCode(guest);
expect(code).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}-[A-Z2-9]{5}-[A-Z2-9]{5}$/);
for (const ch of normalizeBackupCode(code)) expect(BACKUP_ALPHABET).toContain(ch);
expect(code).not.toMatch(/[01OIL]/);
expect((await call(testApp().app, guest, "GET", "/api/backup-code")).json()).toMatchObject({
hasCode: true,
});
});
it("restores the same player on a second device, and both see the same state", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 4321);
const code = await makeCode(owner);
const response = await restore(code.toLowerCase().replaceAll("-", " "));
expect(response.statusCode).toBe(200);
const body = createGuestResponseSchema.parse(response.json());
expect(body.playerId).toBe(owner.playerId);
const second: Guest = { ...owner, cookie: cookieFrom(response.headers["set-cookie"]) };
expect(second.cookie).not.toBe(owner.cookie);
await call(app, second, "POST", "/api/company/name", { name: "Second Device Co" });
expect((await getState(app, owner)).company).toMatchObject({
name: "Second Device Co",
credits: 4321,
});
});
it("stops the old code working when a new one is made", async () => {
const guest = await signUp(testApp().app);
const first = await makeCode(guest);
const second = await makeCode(guest);
expect(second).not.toBe(first);
expect((await restore(first)).statusCode).toBe(409);
expect((await restore(second)).statusCode).toBe(200);
});
it("fails a wrong code with a generic error, and limits attempts", async () => {
const wrong = await restore("ABCDE-FGHJK-MNPQR-STUVW", "device-b", "10.0.0.2");
expect(wrong.statusCode).toBe(409);
expect(errorResponseSchema.parse(wrong.json()).error.code).toBe("restore-failed");
for (let i = 0; i < 4; i++) await restore("NOPE", "device-b", "10.0.0.2");
const limited = await restore("NOPE", "device-b", "10.0.0.2");
expect(limited.statusCode).toBe(429);
expect(errorResponseSchema.parse(limited.json()).error.code).toBe("rate-limited");
// The same device from another network is still limited.
expect((await restore("NOPE", "device-b", "10.0.0.3")).statusCode).toBe(429);
});
it("never stores the plain code", async () => {
const guest = await signUp(testApp().app);
const code = await makeCode(guest);
const plain = normalizeBackupCode(code);
const rows = await database().db.execute(
sql`select row_to_json(p)::text as row from players p`,
);
for (const { row } of rows.rows as { row: string }[]) {
expect(row).not.toContain(code);
expect(row).not.toContain(plain);
}
});
it("still restores the player after they start a new company", async () => {
const owner = await signUpOwner(testApp().app, database().db);
const code = await makeCode(owner);
await call(testApp().app, owner, "POST", "/api/company/new", {});
const response = await restore(code, "device-c", "10.0.0.4");
expect(createGuestResponseSchema.parse(response.json())).toMatchObject({
playerId: owner.playerId,
state: { tutorial: { runsCompleted: 0 } },
});
});
});

View File

@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { useTestDatabase } from "./db";
import { putBoard, signUp, testContract, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("contract boards keyed by location (CR-002)", () => {
it("lets one player have boards at two locations in the same period", async () => {
const { db } = database();
const guest = await signUp(testApp().app);
await putBoard(db, guest.playerId, [testContract()], undefined, "earth");
await putBoard(
db,
guest.playerId,
[testContract({ originId: "moon", destinationId: "earth" })],
undefined,
"moon",
);
});
it("rejects a second board for the same player, location and period", async () => {
const { db } = database();
const guest = await signUp(testApp().app);
await putBoard(db, guest.playerId, [testContract()], undefined, "moon");
await expect(
putBoard(db, guest.playerId, [testContract()], undefined, "moon"),
).rejects.toThrow();
});
});

View File

@ -1,158 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { bays } from "../src/db/schema";
import { shipName } from "../src/game/names";
import { setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, getState, signUp, signUpOwner, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
describe("buy a ship", () => {
it("deducts the price and adds an idle, uncrewed ship at the Earth bay", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const price = content.shipModel("courier")!.price;
await setCredits(database().db, owner.playerId, price + 500);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "courier",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(200);
const { ship } = shipResponseSchema.parse(response.json());
expect(ship).toMatchObject({
modelId: "courier",
status: "idle",
locationId: "earth",
pilotId: null,
dispatchId: null,
});
expect(ship.name.length).toBeGreaterThan(0);
const state = await getState(app, owner);
expect(state.company.credits).toBe(500);
expect(state.ships.map((s) => s.id)).toEqual([owner.shipId, ship.id]);
});
it("returns insufficient-funds and changes nothing without enough credits", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const price = content.shipModel("freighter")!.price;
await setCredits(database().db, owner.playerId, price - 1);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "freighter",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("insufficient-funds");
const state = await getState(app, owner);
expect(state.company.credits).toBe(price - 1);
expect(state.ships).toHaveLength(1);
});
it("returns tutorial-active during the tutorial and changes nothing", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 1_000_000);
const response = await call(app, guest, "POST", "/api/ships", {
modelId: "courier",
bayId: "any",
});
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("tutorial-active");
const state = await getState(app, guest);
expect(state.company.credits).toBe(1_000_000);
expect(state.ships).toHaveLength(0);
});
it("refuses to sell the company loaner", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1_000_000);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "company-courier",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(404);
});
it("rejects an unknown model", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "x-wing",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(404);
expect(errorCode(response)).toBe("not-found");
});
});
describe("ship delivery to a bay (CR-002)", () => {
it("delivers a Freighter bought for the Moon bay to the Moon", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const moonBayId = randomUUID();
await database()
.db.insert(bays)
.values({ id: moonBayId, playerId: owner.playerId, locationId: "moon", createdAt: TEST_NOW });
await setCredits(database().db, owner.playerId, content.shipModel("freighter")!.price);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "freighter",
bayId: moonBayId,
});
expect(response.statusCode).toBe(200);
expect(shipResponseSchema.parse(response.json()).ship).toMatchObject({
locationId: "moon",
status: "idle",
pilotId: null,
});
});
it("rejects a purchase with no bay id as a validation error", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1_000_000);
const response = await call(app, owner, "POST", "/api/ships", { modelId: "courier" });
expect(response.statusCode).toBe(400);
expect(errorCode(response)).toBe("validation-error");
expect((await getState(app, owner)).company.credits).toBe(1_000_000);
});
it("refuses another player's bay and changes nothing", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const other = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1_000_000);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "courier",
bayId: other.earthBayId,
});
expect(response.statusCode).toBe(404);
expect(errorCode(response)).toBe("not-found");
const state = await getState(app, owner);
expect(state.company.credits).toBe(1_000_000);
expect(state.ships).toHaveLength(1);
expect((await getState(app, other)).ships).toHaveLength(1);
});
});
describe("ship names", () => {
it("are stable for a world seed and differ between ships", () => {
expect(shipName(42, 0)).toBe(shipName(42, 0));
const names = new Set(Array.from({ length: 10 }, (_, i) => shipName(42, i)));
expect(names.size).toBeGreaterThan(1);
});
});

View File

@ -1,80 +0,0 @@
import { content } from "@deadhead/content";
import { boardPeriodIndex, boardPeriodMs } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { acceptedContracts, contractBoards } from "../src/db/schema";
import { BOARD_RETENTION_PERIODS, cleanupExpiredContracts } from "../src/jobs/cleanup";
import { appJobs, CLEANUP_CONTRACTS_JOB } from "../src/jobs/definitions";
import { makeOwner, TEST_NOW, useTestDatabase } from "./db";
import { insertDispatch, putBoard, signUp, testContract, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("expired contract cleanup", () => {
it("deletes old boards and accepted contracts and keeps current ones", async () => {
const { db } = database();
const guest = await signUp(testApp().app);
const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
const period = boardPeriodMs(content.config);
const current = boardPeriodIndex(TEST_NOW, content.config);
const old = current - BOARD_RETENTION_PERIODS - 1;
// A board from long ago and one from the current period.
await putBoard(db, guest.playerId, [testContract()], TEST_NOW - (current - old) * period);
await putBoard(db, guest.playerId, [testContract()], TEST_NOW);
// An accepted contract from each period, both pointing at real dispatches.
const record = await insertDispatch(db, {
playerId: guest.playerId,
shipId,
pilotId: guest.avatarId,
departAt: TEST_NOW,
});
await db.insert(acceptedContracts).values([
{
playerId: guest.playerId,
contractId: "old",
periodIndex: old,
dispatchId: record.id,
acceptedAt: TEST_NOW,
},
{
playerId: guest.playerId,
contractId: "new",
periodIndex: current,
dispatchId: record.id,
acceptedAt: TEST_NOW,
},
]);
expect(await cleanupExpiredContracts(db, TEST_NOW)).toEqual({
boards: 1,
acceptedContracts: 1,
});
expect((await db.select().from(contractBoards)).map((b) => b.periodIndex)).toEqual([current]);
expect((await db.select().from(acceptedContracts)).map((a) => a.contractId)).toEqual(["new"]);
});
it("keeps boards inside the retention window", async () => {
const { db } = database();
const guest = await signUp(testApp().app);
const period = boardPeriodMs(content.config);
await putBoard(
db,
guest.playerId,
[testContract()],
TEST_NOW - BOARD_RETENTION_PERIODS * period,
);
expect(await cleanupExpiredContracts(db, TEST_NOW)).toEqual({
boards: 0,
acceptedContracts: 0,
});
});
it("runs as an hourly job", () => {
const job = appJobs({ db: database().db, clock: () => TEST_NOW }).find(
(j) => j.name === CLEANUP_CONTRACTS_JOB,
);
expect(job?.cron).toBe("0 * * * *");
});
});

View File

@ -1,46 +0,0 @@
import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { companyName } from "../src/game/names";
import { useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const rename = async (name: string) => {
const guest = await signUp(testApp().app);
const before = (await getState(testApp().app, guest)).company.name;
const response = await call(testApp().app, guest, "POST", "/api/company/name", { name });
return { guest, before, response };
};
describe("company name (Step 10K.5)", () => {
it("starts with a name made from the world seed", async () => {
const guest = await signUp(testApp().app);
const state = await getState(testApp().app, guest);
expect(state.company.name.length).toBeGreaterThanOrEqual(3);
expect(companyName(42)).toBe(companyName(42));
const names = new Set(Array.from({ length: 30 }, (_, i) => companyName(i)));
expect(names.size).toBeGreaterThan(1);
});
it("saves a valid name, trimmed", async () => {
const { guest, response } = await rename(" Rosa & Sons Haulage! ");
expect(response.statusCode).toBe(200);
expect(companyResponseSchema.parse(response.json()).company.name).toBe("Rosa & Sons Haulage!");
expect((await getState(testApp().app, guest)).company.name).toBe("Rosa & Sons Haulage!");
});
it.each([
["too short", "Ab"],
["too long", "A".repeat(33)],
["blank after trimming", " "],
["disallowed characters", "Freight <script>"],
["emoji", "Space 🚀 Co"],
])("rejects a name that is %s, changing nothing", async (_why, name) => {
const { guest, before, response } = await rename(name);
expect(response.statusCode).toBe(400);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("validation-error");
expect((await getState(testApp().app, guest)).company.name).toBe(before);
});
});

View File

@ -1,78 +0,0 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { makeOwner, setCredits, useTestDatabase } from "./db";
import { call, getState, putBoard, signUp, signUpOwner, testContract, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("concurrency and money safety", () => {
it("lets only one of two simultaneous purchases through when only one is affordable", async () => {
const { app } = testApp();
const guest = await signUpOwner(app, database().db);
const price = content.shipModel("courier")!.price;
await setCredits(database().db, guest.playerId, price + price / 2);
const responses = await Promise.all([
call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, guest);
// The owner already had one ship.
expect(state.ships).toHaveLength(2);
expect(state.company.credits).toBe(price / 2);
});
it("never goes negative under many simultaneous purchases", async () => {
const { app } = testApp();
const guest = await signUpOwner(app, database().db);
const price = content.shipModel("courier")!.price;
await setCredits(database().db, guest.playerId, price * 3);
const responses = await Promise.all(
Array.from({ length: 8 }, () =>
call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
),
);
expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3);
const state = await getState(app, guest);
expect(state.ships).toHaveLength(1 + 3);
expect(state.company.credits).toBe(0);
});
it("accepts a contract only once when dispatched twice at the same moment", async () => {
const { app } = testApp();
const { db } = database();
const guest = await signUp(app);
const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 10_000);
const contract = testContract();
await putBoard(db, guest.playerId, [contract]);
const body = { kind: "contract", contractId: contract.id, shipId, pilotId: guest.avatarId };
const responses = await Promise.all([
call(app, guest, "POST", "/api/dispatches", body),
call(app, guest, "POST", "/api/dispatches", body),
]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, guest);
expect(state.activeDispatches).toHaveLength(1);
expect(state.company.credits).toBe(10_000 - state.activeDispatches[0]!.fuelCost);
});
it("leases one bay when the same lease is sent twice at once", async () => {
const { app } = testApp();
const guest = await signUpOwner(app, database().db);
await setCredits(database().db, guest.playerId, 100_000);
const responses = await Promise.all([
call(app, guest, "POST", "/api/bays", { locationId: "moon" }),
call(app, guest, "POST", "/api/bays", { locationId: "moon" }),
]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, guest);
expect(state.bays.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]);
expect(state.company.credits).toBe(100_000 - content.location("moon")!.bayLeasePrice);
});
});

View File

@ -1,69 +0,0 @@
import { randomUUID } from "node:crypto";
import { errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it, vi } from "vitest";
import { dispatches, pilots, ships } from "../src/db/schema";
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, putBoard, signUp, testContract, useTestApp } from "./game";
// Every base-game ship is atmospheric (decision 0.5.1), so `cannot-land` needs
// a test-only vacuum ship in the content the server sees.
vi.mock("@deadhead/content", async (importOriginal) => {
const original = await importOriginal<typeof import("@deadhead/content")>();
const courier = original.content.shipModel("courier")!;
const shipModels = [
...original.content.shipModels,
{ ...courier, id: "test-vacuum", landingClass: "vacuum" as const },
];
return {
...original,
content: {
...original.content,
shipModels,
shipModel: (id: string) => shipModels.find((s) => s.id === id),
},
};
});
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("contract dispatch landing check", () => {
it("rejects cannot-land and changes nothing", async () => {
const { app } = testApp();
const { db } = database();
const guest = await signUp(app);
await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 10_000);
// A vacuum-only ship at the Moon, crewed by a pilot, offered a Moon→Earth job.
const shipId = randomUUID();
await db.insert(ships).values({
id: shipId,
playerId: guest.playerId,
modelId: "test-vacuum",
name: "Moon Crawler",
locationId: "moon",
createdAt: TEST_NOW,
});
const pilotId = randomUUID();
await db.insert(pilots).values({
id: pilotId,
playerId: guest.playerId,
name: "Vac Pilot",
assignedShipId: shipId,
createdAt: TEST_NOW,
});
const contract = testContract({ originId: "moon", destinationId: "earth" });
await putBoard(db, guest.playerId, [contract]);
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "contract",
contractId: contract.id,
shipId,
pilotId,
});
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("cannot-land");
expect(await db.select().from(dispatches)).toEqual([]);
});
});

View File

@ -1,249 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import {
contractBoardResponseSchema,
dispatchResponseSchema,
errorResponseSchema,
} from "@deadhead/schema";
import { boardPeriodMs, contractTripGameHours } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { acceptedContracts, companies, dispatches, pilots, ships } from "../src/db/schema";
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, getState, putBoard, signUp, testContract, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
interface Owner {
guest: Guest;
shipId: string;
}
/** An owner with a crewed Courier at Earth, an Earth bay and 10,000 credits. */
async function owner(): Promise<Owner> {
const guest = await signUp(testApp().app);
const { shipId } = await makeOwner(database().db, guest.playerId, guest.avatarId);
await setCredits(database().db, guest.playerId, 10_000);
return { guest, shipId };
}
function dispatch(
o: Owner,
contractId: string,
overrides: { shipId?: string; pilotId?: string } = {},
) {
return call(testApp().app, o.guest, "POST", "/api/dispatches", {
kind: "contract",
contractId,
shipId: overrides.shipId ?? o.shipId,
pilotId: overrides.pilotId ?? o.guest.avatarId,
});
}
/** Asserts the request failed with `code` and changed nothing. */
async function expectRejected(
o: Owner,
response: Awaited<ReturnType<typeof dispatch>>,
code: string,
) {
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe(code);
const { db } = database();
expect(
await db.select().from(dispatches).where(eq(dispatches.playerId, o.guest.playerId)),
).toEqual([]);
expect(await db.select().from(acceptedContracts)).toEqual([]);
const [company] = await db
.select()
.from(companies)
.where(eq(companies.playerId, o.guest.playerId));
expect(company!.credits).toBe(10_000);
}
describe("contract dispatch", () => {
it("dispatches, charges fuel, stores the record and removes the contract from the board", async () => {
const { app, clock } = testApp();
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
const response = await dispatch(o, contract.id);
expect(response.statusCode).toBe(200);
const record = dispatchResponseSchema.parse(response.json()).dispatch;
expect(record).toMatchObject({
status: "in-flight",
shipId: o.shipId,
pilotId: o.guest.avatarId,
departureAt: clock.now,
expectedPayout: contract.payout,
tutorial: false,
});
expect(record.fuelCost).toBeGreaterThan(0);
const state = await getState(app, o.guest);
expect(state.company.credits).toBe(10_000 - record.fuelCost);
expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id });
expect(state.pilots[0]!.status).toBe("in-flight");
const board = contractBoardResponseSchema.parse(
(await call(app, o.guest, "GET", "/api/locations/earth/board")).json(),
);
expect(board.contracts).toEqual([]);
});
it("rejects ship-not-idle", async () => {
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
await database().db.update(ships).set({ status: "in_flight" }).where(eq(ships.id, o.shipId));
await expectRejected(o, await dispatch(o, contract.id), "ship-not-idle");
});
it("rejects ship-not-at-origin", async () => {
const o = await owner();
const contract = testContract({ originId: "moon", destinationId: "earth" });
await putBoard(database().db, o.guest.playerId, [contract]);
await expectRejected(o, await dispatch(o, contract.id), "ship-not-at-origin");
});
it("rejects pilot-not-idle", async () => {
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
await database()
.db.update(pilots)
.set({ status: "in_flight" })
.where(eq(pilots.id, o.guest.avatarId));
await expectRejected(o, await dispatch(o, contract.id), "pilot-not-idle");
});
it("rejects pilot-not-assigned", async () => {
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
const otherPilotId = randomUUID();
await database().db.insert(pilots).values({
id: otherPilotId,
playerId: o.guest.playerId,
name: "Spare Pilot",
locationId: "earth",
createdAt: TEST_NOW,
});
await expectRejected(
o,
await dispatch(o, contract.id, { pilotId: otherPilotId }),
"pilot-not-assigned",
);
});
it("rejects over-capacity", async () => {
const o = await owner();
const contract = testContract({ massTonnes: 50 });
await putBoard(database().db, o.guest.playerId, [contract]);
await expectRejected(o, await dispatch(o, contract.id), "over-capacity");
});
it("rejects out-of-range", async () => {
const o = await owner();
const contract = testContract({ destinationId: "mars" });
await putBoard(database().db, o.guest.playerId, [contract]);
await expectRejected(o, await dispatch(o, contract.id), "out-of-range");
});
it("rejects cargo-skill-too-low", async () => {
const o = await owner();
const contract = testContract({
cargoTypeId: "heavy",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 2,
requiredSkill: { skillId: "heavy-cargo", level: 2 },
});
await putBoard(database().db, o.guest.playerId, [contract]);
await expectRejected(o, await dispatch(o, contract.id), "cargo-skill-too-low");
});
it("rejects long-haul-too-low", async () => {
const o = await owner();
const contract = testContract({ requiredLongHaulLevel: 1 });
await putBoard(database().db, o.guest.playerId, [contract]);
await expectRejected(o, await dispatch(o, contract.id), "long-haul-too-low");
});
it("rejects contract-expired once the board's period has rolled over", async () => {
const { clock } = testApp();
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
// The contract expires at the end of its board period (TEST_NOW is a period start).
expect(contract.expiresAt).toBe(TEST_NOW + boardPeriodMs(content.config));
clock.now = contract.expiresAt;
await expectRejected(o, await dispatch(o, contract.id), "contract-expired");
});
it("rejects insufficient-credits", async () => {
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
await setCredits(database().db, o.guest.playerId, 0);
const response = await dispatch(o, contract.id);
expect(errorCode(response)).toBe("insufficient-credits");
expect(await database().db.select().from(dispatches)).toEqual([]);
});
it("rejects an unknown contract and a contract taken twice", async () => {
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
expect((await dispatch(o, "earth:0:99")).statusCode).toBe(404);
expect((await dispatch(o, contract.id)).statusCode).toBe(200);
expect(errorCode(await dispatch(o, contract.id))).toBe("contract-already-accepted");
});
});
describe("Priority contracts (CR-007)", () => {
const courier = content.shipModel("courier")!;
/** A Priority Earth→Moon mail job whose window is the Courier's trip plus `slackHours`. */
async function priorityJob(o: Owner, id: string, slackHours: number) {
const { clock } = testApp();
const base = testContract({ id, goodId: "mail", massTonnes: 4, priority: true });
const trip = contractTripGameHours(courier, base, clock.now, content);
const contract = { ...base, deliveryWindowGameHours: trip + slackHours };
await putBoard(database().db, o.guest.playerId, [contract], clock.now);
return contract;
}
it("refuses a ship too slow for the window with too-slow and changes nothing", async () => {
const o = await owner();
const job = await priorityJob(o, "earth:1:0", -0.25);
await expectRejected(o, await dispatch(o, job.id), "too-slow");
});
it("dispatches a ship fast enough for the window", async () => {
const o = await owner();
const job = await priorityJob(o, "earth:1:0", 0.25);
const response = await dispatch(o, job.id);
expect(response.statusCode).toBe(200);
const { dispatch: stored } = dispatchResponseSchema.parse(response.json());
expect(stored.contract).toMatchObject({ priority: true, goodId: "mail" });
});
it("serves generated boards whose contracts all name a known good and a shipper", async () => {
const o = await owner();
const response = await call(testApp().app, o.guest, "GET", "/api/locations/earth/board");
const { contracts } = contractBoardResponseSchema.parse(response.json());
expect(contracts.length).toBeGreaterThan(0);
for (const c of contracts) {
const good = content.good(c.goodId);
expect(good?.name).toBeTruthy();
expect(good!.cargoClassId).toBe(c.cargoTypeId);
expect(content.location("earth")!.shippers).toContain(c.shipper);
}
});
});

View File

@ -1,121 +0,0 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, beforeEach } from "vitest";
import { createDatabase, type Database, type Db } from "../src/db/client";
import { runMigrations } from "../src/db/migrate";
import { user } from "../src/db/auth-schema";
import { bays, companies, pilots, players, ships } from "../src/db/schema";
import { createPlayer, type NewPlayer } from "../src/game/new-player";
import { testConfig } from "./helpers";
export const TEST_NOW = Date.parse("2100-01-01T00:00:00Z");
/**
* Connects a test file to the test database: migrates once, empties every
* table before each test, and closes the pool at the end. Use `database()`
* inside tests.
*/
export function useTestDatabase(): () => Database {
let database: Database | undefined;
const url = testConfig().databaseUrl;
beforeAll(async () => {
await runMigrations(url);
database = createDatabase(url);
});
beforeEach(async () => {
await resetDatabase(database!.db);
});
afterAll(async () => {
await database?.close();
});
return () => {
if (!database) throw new Error("useTestDatabase: database not ready (use it inside tests)");
return database;
};
}
/** Empties every table in the public schema. */
export async function resetDatabase(db: Db): Promise<void> {
const result = await db.execute<{ tablename: string }>(
sql`select tablename from pg_tables where schemaname = 'public'`,
);
const names = result.rows.map((r) => `"${r.tablename}"`);
if (names.length > 0) await db.execute(sql.raw(`truncate ${names.join(", ")} cascade`));
}
export type SeedState = "tutorial" | "owner";
export interface SeededPlayer extends NewPlayer {
/** The Courier owned in the "owner" state. */
shipId?: string;
}
/**
* Creates a player in a named state:
* - `tutorial`: brand new player (what guest sign-up creates).
* - `owner`: tutorial over, owns one Courier at Earth crewed by the avatar, has an Earth bay.
*/
export async function seedPlayer(
db: Db,
state: SeedState,
options: { worldSeed?: number; credits?: number } = {},
): Promise<SeededPlayer> {
const userId = randomUUID();
await db.insert(user).values({
id: userId,
name: "Guest",
email: `${userId}@guest.test`,
isAnonymous: true,
});
const player = await createPlayer(db, {
userId,
worldSeed: options.worldSeed ?? 12345,
now: TEST_NOW,
});
if (options.credits !== undefined) await setCredits(db, player.playerId, options.credits);
if (state === "tutorial") return player;
return { ...player, ...(await makeOwner(db, player.playerId, player.avatarId)) };
}
export async function setCredits(db: Db, playerId: string, credits: number): Promise<void> {
await db.update(companies).set({ credits }).where(eq(companies.playerId, playerId));
}
/**
* Turns a tutorial player into an owner: tutorial over, one Courier at Earth
* crewed by the avatar, an Earth bay. Returns the ship and bay ids.
*/
export async function makeOwner(
db: Db,
playerId: string,
avatarId: string,
): Promise<{ shipId: string; earthBayId: string }> {
const shipId = randomUUID();
const earthBayId = randomUUID();
await db.transaction(async (tx) => {
await tx
.update(players)
.set({ tutorialActive: false, loanerLocationId: null })
.where(eq(players.id, playerId));
await tx.insert(ships).values({
id: shipId,
playerId,
modelId: "courier",
name: "Test Courier",
locationId: "earth",
// Stands in for the deal's Courier (not a Market purchase, CR-013).
fromDeal: true,
createdAt: TEST_NOW,
});
await tx
.update(pilots)
.set({ assignedShipId: shipId, locationId: null })
.where(eq(pilots.id, avatarId));
await tx
.insert(bays)
.values({ id: earthBayId, playerId, locationId: "earth", createdAt: TEST_NOW });
});
return { shipId, earthBayId };
}

View File

@ -1,120 +0,0 @@
import { content } from "@deadhead/content";
import { errorResponseSchema, playerStateSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { bays, companies, pilots, players, ships } from "../src/db/schema";
import { useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
/** Fast-forwards a new guest to "four runs done, loaner back at Earth". */
async function readyForDeal(guest: Guest) {
await database()
.db.update(players)
.set({
tutorialRunsCompleted: content.config.tutorialRunsBeforeDeal,
loanerLocationId: "earth",
})
.where(eq(players.id, guest.playerId));
}
const accept = (guest: Guest) => call(testApp().app, guest, "POST", "/api/deal/accept");
/** Everything the deal touches, read straight from the database. */
async function snapshot(guest: Guest) {
const { db } = database();
return {
player: (await db.select().from(players).where(eq(players.id, guest.playerId)))[0],
company: (await db.select().from(companies).where(eq(companies.playerId, guest.playerId)))[0],
ships: await db.select().from(ships).where(eq(ships.playerId, guest.playerId)),
bays: await db.select().from(bays).where(eq(bays.playerId, guest.playerId)),
pilots: await db.select().from(pilots).where(eq(pilots.playerId, guest.playerId)),
};
}
describe("accepting the deal", () => {
it("hands over a crewed Courier and Clipper at Earth, an Earth bay and the loan, and ends the tutorial", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
expect((await getState(testApp().app, guest)).tutorial?.dealAvailable).toBe(true);
const response = await accept(guest);
expect(response.statusCode).toBe(200);
const state = playerStateSchema.parse(response.json());
expect(state.tutorial).toBeNull();
// CR-013: the Courier for the avatar and the Clipper for the veteran.
expect(state.ships).toHaveLength(2);
const [ship, clipper] = state.ships;
expect(ship).toMatchObject({
modelId: "courier",
status: "idle",
locationId: "earth",
pilotId: guest.avatarId,
});
const veteran = state.pilots.find((p) => p.isVeteran)!;
expect(clipper).toMatchObject({
modelId: "clipper",
status: "idle",
locationId: "earth",
pilotId: veteran.id,
});
expect(veteran).toMatchObject({ assignedShipId: clipper!.id, locationId: "earth" });
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
expect(state.pilots[0]).toMatchObject({
isAvatar: true,
assignedShipId: ship!.id,
locationId: "earth",
});
expect(state.company.loanBalance).toBe(content.dealLoanAmount);
const after = await snapshot(guest);
expect(after.player).toMatchObject({
tutorialActive: false,
dealAccepted: true,
loanerLocationId: null,
});
});
it("fails with no changes when the deal is not available", async () => {
const guest = await signUp(testApp().app);
const before = await snapshot(guest);
const response = await accept(guest);
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("deal-not-available");
expect(await snapshot(guest)).toEqual(before);
});
it("fails while the loaner is at the Moon", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
await database()
.db.update(players)
.set({ loanerLocationId: "moon" })
.where(eq(players.id, guest.playerId));
expect((await accept(guest)).statusCode).toBe(409);
});
it("fails the second time", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
expect((await accept(guest)).statusCode).toBe(200);
const before = await snapshot(guest);
const again = await accept(guest);
expect(errorResponseSchema.parse(again.json()).error.code).toBe("deal-not-available");
expect(await snapshot(guest)).toEqual(before);
});
it("produces exactly one deal from two simultaneous accepts", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
const responses = await Promise.all([accept(guest), accept(guest)]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const after = await snapshot(guest);
expect(after.ships).toHaveLength(2);
expect(after.bays).toHaveLength(1);
expect(after.company!.loanBalance).toBe(content.dealLoanAmount);
});
});

View File

@ -1,156 +0,0 @@
import { content, upgradeMaxLevel } from "@deadhead/content";
import { companyResponseSchema, errorResponseSchema, playerStateSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import { useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
signUp,
signUpOwner,
testContract,
useTestApp,
} from "./game";
import { pilots, ships } from "../src/db/schema";
import { testConfig } from "./helpers";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("developer tools (Step 10K.11)", () => {
it("advances the server clock", async () => {
const guest = await signUp(testApp().app);
const before = (await call(testApp().app, guest, "GET", "/api/time")).json().realTime as number;
const response = await call(testApp().app, guest, "POST", "/api/test/advance-clock", {
advanceMs: 600_000,
});
expect(response.statusCode).toBe(200);
const after = (await call(testApp().app, guest, "GET", "/api/time")).json().realTime as number;
expect(after - before).toBeGreaterThanOrEqual(600_000);
});
it("adds credits", async () => {
const guest = await signUp(testApp().app);
const start = (await getState(testApp().app, guest)).company.credits;
const response = await call(testApp().app, guest, "POST", "/api/test/add-credits", {
amount: 10_000,
});
expect(companyResponseSchema.parse(response.json()).company.credits).toBe(start + 10_000);
});
it("lands every flight when advanced to the last arrival", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const flight = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
expect((await getState(app, owner)).activeDispatches).toHaveLength(1);
await call(app, owner, "POST", "/api/test/advance-clock", {
advanceMs: flight.arrivalAt - clock.now + 1000,
});
expect((await getState(app, owner)).activeDispatches).toHaveLength(0);
});
it("does not exist in production", async () => {
const app = buildApp(testConfig({ NODE_ENV: "production" }), {
database: database(),
jobs: createRecordingQueue(),
});
for (const [url, payload] of [
["/api/test/clock-offset", { offsetMs: 1 }],
["/api/test/advance-clock", { advanceMs: 1000 }],
["/api/test/add-credits", { amount: 10_000 }],
["/api/test/pilot", { pilotId: "p", action: "level-up" }],
["/api/test/ship", { shipId: "s", action: "max-upgrades" }],
["/api/test/discover-all", {}],
] as const) {
const response = await app.inject({ method: "POST", url, payload });
expect(response.statusCode, url).toBe(404);
}
await app.close();
});
it("levels a pilot up to the next level's XP, and clears duty and rest", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await database()
.db.update(pilots)
.set({ xp: 40, dutyHours: 9, restEndsAt: clock.now + 3_600_000 })
.where(eq(pilots.id, owner.avatarId));
const pilotAfter = async (action: string) => {
const response = await call(app, owner, "POST", "/api/test/pilot", {
pilotId: owner.avatarId,
action,
});
expect(response.statusCode).toBe(200);
return playerStateSchema.parse(response.json()).pilots.find((p) => p.id === owner.avatarId)!;
};
const levelled = await pilotAfter("level-up");
expect(levelled.xp).toBe(content.config.pilotLevelXpThresholds[0]);
expect(levelled.level).toBe(1);
expect(levelled.skillPointsAvailable).toBe(content.config.skillPointsPerLevel);
expect((await pilotAfter("level-up")).level).toBe(2);
const rested = await pilotAfter("clear-rest");
expect(rested).toMatchObject({ dutyHours: 0, resting: false, restEndsAt: null });
});
it("maxes and resets a ship's upgrades, and finishes shop work", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const ship = async (input: Record<string, unknown>) => {
const response = await call(app, owner, "POST", "/api/test/ship", {
shipId: owner.shipId,
...input,
});
expect(response.statusCode).toBe(200);
return playerStateSchema.parse(response.json()).ships.find((s) => s.id === owner.shipId)!;
};
const maxed = await ship({ action: "max-upgrades" });
for (const upgrade of content.upgrades) {
expect(maxed.upgrades[upgrade.id], upgrade.id).toBe(upgradeMaxLevel(upgrade, "courier"));
}
expect(maxed.stats.fuelRangeMillionKm).toBeGreaterThan(
content.shipModel("courier")!.fuelRangeMillionKm,
);
expect((await ship({ action: "reset-upgrades" })).upgrades).toEqual({});
await database()
.db.update(ships)
.set({ inShopUntil: clock.now + 600_000, shopUpgradeId: "engines", shopLevel: 1 })
.where(eq(ships.id, owner.shipId));
expect((await ship({ action: "finish-shop" })).inShopUntil).toBeNull();
});
it("moves an idle ship to a discovered place only", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const move = (locationId: string) =>
call(app, owner, "POST", "/api/test/ship", {
shipId: owner.shipId,
action: "move",
locationId,
});
const moved = await move("moon");
expect(moved.statusCode).toBe(200);
expect(playerStateSchema.parse(moved.json()).ships[0]!.locationId).toBe("moon");
const refused = await move("mars");
expect(refused.statusCode).toBe(409);
expect(errorResponseSchema.parse(refused.json()).error.code).toBe("location-undiscovered");
});
it("discovers every location", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const response = await call(app, owner, "POST", "/api/test/discover-all", {});
const { locations } = playerStateSchema.parse(response.json());
expect(locations.every((l) => l.discovered)).toBe(true);
expect(locations).toHaveLength(content.locations.length);
});
});

View File

@ -1,158 +0,0 @@
import { content } from "@deadhead/content";
import {
dispatchResponseSchema,
errorResponseSchema,
pilotResponseSchema,
type PlayerState,
} from "@deadhead/schema";
import { restEndsAt } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
putBoard,
signUp,
signUpOwner,
testContract,
useTestApp,
type Guest,
} from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
const avatar = (state: PlayerState) => state.pilots.find((p) => p.isAvatar)!;
const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z");
/** Flies the owner's Courier Earth↔Moon until a trip ends in mandatory rest. */
async function chainUntilRest(owner: Guest & { shipId: string }) {
const { clock } = testApp();
let at = "earth";
for (let trip = 0; trip < 20; trip++) {
const to = at === "earth" ? "moon" : "earth";
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ originId: at, destinationId: to, expiresAt: FAR_FUTURE }),
});
clock.now = record.arrivalAt;
const state = await getState(testApp().app, owner);
at = to;
if (avatar(state).resting) return { state, record, at };
expect(avatar(state).dutyHours).toBeGreaterThan(0);
}
throw new Error("never needed rest");
}
describe("pilot duty and rest on the server (Step 10D.5)", () => {
it("rests a pilot after chained runs, refuses them until rest ends, then dispatches", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 50_000);
const { state, record, at } = await chainUntilRest(owner);
const rested = avatar(state);
expect(rested).toMatchObject({ dutyHours: 0, resting: true });
// Rest starts at the arrival time (decision 0.5.45).
expect(rested.restEndsAt).toBe(restEndsAt(record.arrivalAt, content.config));
expect(state.summaries.at(-1)!.restEndsAt).toBe(rested.restEndsAt);
const job = testContract({
originId: at,
destinationId: at === "earth" ? "moon" : "earth",
expiresAt: FAR_FUTURE,
});
await putBoard(database().db, owner.playerId, [job], clock.now, at);
const dispatch = () =>
call(app, owner, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: owner.shipId,
pilotId: owner.avatarId,
});
const refused = await dispatch();
expect(refused.statusCode).toBe(409);
expect(errorCode(refused)).toBe("pilot-resting");
clock.now = rested.restEndsAt!;
const accepted = await dispatch();
expect(accepted.statusCode).toBe(200);
dispatchResponseSchema.parse(accepted.json());
});
it("rests an idle pilot by hand, and refuses resting one who is flying or already resting", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const response = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {});
expect(response.statusCode).toBe(200);
expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({
dutyHours: 0,
resting: true,
restEndsAt: restEndsAt(clock.now, content.config),
});
const again = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {});
expect(again.statusCode).toBe(409);
expect(errorCode(again)).toBe("pilot-resting");
const other = await signUpOwner(app, database().db);
await insertDispatch(database().db, {
playerId: other.playerId,
shipId: other.shipId,
pilotId: other.avatarId,
departAt: clock.now,
});
const flying = await call(app, other, "POST", `/api/pilots/${other.avatarId}/rest`, {});
expect(flying.statusCode).toBe(409);
expect(errorCode(flying)).toBe("pilot-not-idle");
});
it("lets a resting pilot leave the ship at a bay and a fresh pilot fly it at once", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 50_000);
await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {});
const unassign = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/unassign`, {});
expect(unassign.statusCode).toBe(200);
const hired = pilotResponseSchema.parse(
(await call(app, owner, "POST", "/api/pilots", { locationId: "earth" })).json(),
).pilot;
const assign = await call(app, owner, "POST", `/api/pilots/${hired.id}/assign`, {
shipId: owner.shipId,
});
expect(assign.statusCode).toBe(200);
const job = testContract({ expiresAt: FAR_FUTURE });
await putBoard(database().db, owner.playerId, [job], clock.now);
const response = await call(app, owner, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: owner.shipId,
pilotId: hired.id,
});
expect(response.statusCode).toBe(200);
});
it("does not rest the avatar during the tutorial", async () => {
const guest = await signUp(testApp().app);
const response = await call(
testApp().app,
guest,
"POST",
`/api/pilots/${guest.avatarId}/rest`,
{},
);
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("tutorial-active");
});
});

View File

@ -1,222 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema";
import {
boardPeriodIndex,
boardPeriodMs,
createDispatchRecord,
createRepositionRecord,
type Contract,
type DispatchRecord,
} from "@deadhead/sim";
import { eq } from "drizzle-orm";
import { afterEach, beforeEach } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import type { Database, Db } from "../src/db/client";
import { contractBoards, dispatches, pilots, players, ships } from "../src/db/schema";
import { makeOwner, TEST_NOW } from "./db";
import { testConfig } from "./helpers";
/** A clock tests can move. Starts at `TEST_NOW`. */
export class TestClock {
now = TEST_NOW;
read = () => this.now;
advance(ms: number) {
this.now += ms;
}
}
export interface TestApp {
app: ReturnType<typeof buildApp>;
clock: TestClock;
/** Jobs the app scheduled; tests run their handlers directly. */
jobs: ReturnType<typeof createRecordingQueue>;
}
/** A fresh app per test on the shared test database, with a controllable clock. */
export function useTestApp(database: () => Database): () => TestApp {
let current: TestApp | undefined;
beforeEach(() => {
const clock = new TestClock();
const jobs = createRecordingQueue();
current = {
app: buildApp(testConfig(), { database: database(), clock: clock.read, jobs }),
clock,
jobs,
};
});
afterEach(() => current?.app.close());
return () => current!;
}
export function cookieFrom(setCookie: string | string[] | undefined): string {
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
return values.map((c) => c.split(";")[0]).join("; ");
}
export interface Guest {
cookie: string;
playerId: string;
avatarId: string;
}
/** Signs up a guest through the API (a tutorial player) and returns its session. */
export async function signUp(app: TestApp["app"]): Promise<Guest> {
const response = await app.inject({ method: "POST", url: "/api/guest" });
const body = createGuestResponseSchema.parse(response.json());
return {
cookie: cookieFrom(response.headers["set-cookie"]),
playerId: body.playerId,
avatarId: body.state.pilots[0]!.id,
};
}
/** Calls an endpoint as a guest. */
export async function call(
app: TestApp["app"],
guest: Guest,
method: "GET" | "POST",
url: string,
payload?: object,
) {
return app.inject({
method,
url,
headers: { cookie: guest.cookie },
...(payload ? { payload } : {}),
});
}
export async function getState(app: TestApp["app"], guest: Guest): Promise<PlayerState> {
const response = await call(app, guest, "GET", "/api/state");
if (response.statusCode !== 200) throw new Error(`state failed: ${response.body}`);
return playerStateSchema.parse(response.json());
}
export function testContract(overrides: Partial<Contract> = {}): Contract {
return {
id: `test:${randomUUID()}`,
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: TEST_NOW,
// Contracts expire at the end of their board period (TEST_NOW is a period start).
expiresAt: TEST_NOW + boardPeriodMs(content.config),
firstContact: false,
...overrides,
};
}
/**
* Puts an in-flight dispatch straight into the database, as if it had been
* dispatched at `departAt`: the ship and pilot are marked in flight.
*/
export async function insertDispatch(
db: Db,
args: {
playerId: string;
shipId: string;
pilotId: string;
departAt: number;
contract?: Contract | null;
destinationId?: string;
},
): Promise<DispatchRecord> {
const [player] = await db.select().from(players).where(eq(players.id, args.playerId));
const [ship] = await db.select().from(ships).where(eq(ships.id, args.shipId));
const [pilot] = await db.select().from(pilots).where(eq(pilots.id, args.pilotId));
const shipState = {
id: ship!.id,
modelId: ship!.modelId,
locationId: ship!.locationId!,
inFlight: false,
};
const pilotState = {
id: pilot!.id,
isAvatar: pilot!.isAvatar,
assignedShipId: pilot!.assignedShipId,
inFlight: false,
skills: pilot!.skills,
restEndsAt: null,
};
const id = randomUUID();
const contract = args.contract === undefined ? testContract() : args.contract;
const record = contract
? createDispatchRecord(
{
id,
ship: shipState,
pilot: pilotState,
contract,
tutorial: false,
now: args.departAt,
worldSeed: player!.worldSeed,
},
content,
)
: createRepositionRecord(
{
id,
ship: shipState,
pilot: pilotState,
destinationId: args.destinationId!,
now: args.departAt,
worldSeed: player!.worldSeed,
},
content,
);
await db.insert(dispatches).values({
id,
playerId: args.playerId,
shipId: ship!.id,
pilotId: pilot!.id,
tutorial: false,
record,
arrivalAt: record.arrivalAt,
});
await db
.update(ships)
.set({ status: "in_flight", locationId: null })
.where(eq(ships.id, ship!.id));
await db.update(pilots).set({ status: "in_flight" }).where(eq(pilots.id, pilot!.id));
return record;
}
/** Stores a hand-made location board (Earth by default) for the period containing `now`. */
export async function putBoard(
db: Db,
playerId: string,
contracts: Contract[],
now = TEST_NOW,
locationId = "earth",
) {
await db.insert(contractBoards).values({
id: randomUUID(),
playerId,
kind: "location",
locationId,
periodIndex: boardPeriodIndex(now, content.config),
contracts,
createdAt: now,
});
}
/** Signs up a guest and turns them into an owner: tutorial over, a Courier at Earth crewed by the avatar, an Earth bay. */
export async function signUpOwner(
app: TestApp["app"],
db: Db,
): Promise<Guest & { shipId: string; earthBayId: string }> {
const guest = await signUp(app);
return { ...guest, ...(await makeOwner(db, guest.playerId, guest.avatarId)) };
}

View File

@ -1,139 +0,0 @@
import { content } from "@deadhead/content";
import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { pilots, players } from "../src/db/schema";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
signUp,
signUpOwner,
testContract,
useTestApp,
type Guest,
} from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const claim = (guest: Guest, goalId: string) =>
call(testApp().app, guest, "POST", `/api/goals/${goalId}/claim`);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
/** An owner who has accepted the deal (so "pay off your loan" can apply). */
async function dealOwner() {
const owner = await signUpOwner(testApp().app, database().db);
await database()
.db.update(players)
.set({ dealAccepted: true })
.where(eq(players.id, owner.playerId));
return owner;
}
const status = async (guest: Guest, goalId: string) =>
(await getState(testApp().app, guest)).goals.find((g) => g.id === goalId)?.status;
describe("onboarding goals", () => {
it("are absent from state during the tutorial", async () => {
const guest = await signUp(testApp().app);
expect((await getState(testApp().app, guest)).goals).toEqual([]);
});
it("are listed in content order once the tutorial is over", async () => {
const owner = await dealOwner();
const goals = (await getState(testApp().app, owner)).goals;
expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id));
// Paid-off loan: the test owner has no balance.
expect(goals.find((g) => g.id === "loan-paid-off")?.status).toBe("ready");
expect(goals.find((g) => g.id === "priority-contract")?.status).toBe("incomplete");
});
it("becomes ready when its condition is met: a delivery with the player's own ship", async () => {
const { clock } = testApp();
const owner = await dealOwner();
expect(await status(owner, "first-own-delivery")).toBe("incomplete");
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
});
clock.now = record.arrivalAt;
expect(await status(owner, "first-own-delivery")).toBe("ready");
});
it("counts a ship bought from the Market, not the deal's (CR-013)", async () => {
const owner = await dealOwner();
expect(await status(owner, "market-ship")).toBe("incomplete");
await setCredits(database().db, owner.playerId, content.shipModel("lighter")!.price);
const response = await call(testApp().app, owner, "POST", "/api/ships", {
modelId: "lighter",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(200);
expect(await status(owner, "market-ship")).toBe("ready");
});
it("counts a Priority delivery, not a Standard one (CR-013)", async () => {
const { clock } = testApp();
const owner = await dealOwner();
const fly = async (priority: boolean) => {
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ priority, deliveryWindowGameHours: priority ? 1000 : null }),
});
clock.now = record.arrivalAt;
};
await fly(false);
expect(await status(owner, "priority-contract")).toBe("incomplete");
await fly(true);
expect(await status(owner, "priority-contract")).toBe("ready");
});
it("counts skill points spent", async () => {
const owner = await dealOwner();
await database()
.db.update(pilots)
.set({ skillPointsSpent: 1 })
.where(eq(pilots.id, owner.avatarId));
expect(await status(owner, "first-skill-point")).toBe("ready");
});
it("claiming a completed goal adds its reward once", async () => {
const owner = await dealOwner();
await setCredits(database().db, owner.playerId, 100);
const reward = content.onboardingGoal("loan-paid-off")!.reward;
const response = await claim(owner, "loan-paid-off");
expect(response.statusCode).toBe(200);
expect(companyResponseSchema.parse(response.json()).company.credits).toBe(100 + reward);
expect(await status(owner, "loan-paid-off")).toBe("claimed");
const again = await claim(owner, "loan-paid-off");
expect(again.statusCode).toBe(409);
expect(errorCode(again)).toBe("goal-already-claimed");
expect((await getState(testApp().app, owner)).company.credits).toBe(100 + reward);
});
it("refuses an incomplete goal and an unknown one", async () => {
const owner = await dealOwner();
await setCredits(database().db, owner.playerId, 100);
const response = await claim(owner, "priority-contract");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("goal-not-ready");
expect((await claim(owner, "win-the-game")).statusCode).toBe(404);
expect((await getState(testApp().app, owner)).company.credits).toBe(100);
});
it("refuses claims during the tutorial", async () => {
const guest = await signUp(testApp().app);
expect(errorCode(await claim(guest, "loan-paid-off"))).toBe("goal-not-ready");
});
});

View File

@ -1,96 +0,0 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { contractBoardResponseSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import pg from "pg";
import { describe, expect, it } from "vitest";
import { contractBoards, dispatches } from "../src/db/schema";
import { useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
putBoard,
signUpOwner,
testContract,
useTestApp,
} from "./game";
import { testConfig } from "./helpers";
const database = useTestDatabase();
const testApp = useTestApp(database);
const migration = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "../drizzle/0005_cr007_goods.sql"),
"utf8",
);
/** Runs the CR-007 backfill in one session (it uses a pg_temp helper). */
async function backfill() {
const client = new pg.Client({ connectionString: testConfig().databaseUrl });
await client.connect();
try {
for (const statement of migration.split("--> statement-breakpoint")) {
await client.query(statement);
}
} finally {
await client.end();
}
}
/** A contract as stored before CR-007: no good, shipper or Priority fields. */
function legacy(overrides: Parameters<typeof testContract>[0] = {}) {
const { goodId, shipper, priority, deliveryWindowGameHours, ...old } = testContract(overrides);
void [goodId, shipper, priority, deliveryWindowGameHours];
return old as ReturnType<typeof testContract>;
}
describe("CR-007 backfill migration", () => {
it("gives stored boards and in-flight contracts a good, shipper and no Priority, once", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const db = database().db;
await putBoard(
db,
owner.playerId,
[
legacy({ id: "earth:1:0", cargoTypeId: "standard" }),
legacy({ id: "earth:1:1", cargoTypeId: "high-value", originId: "moon" }),
],
clock.now,
);
const record = await insertDispatch(db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: legacy({ cargoTypeId: "dangerous" }),
});
await backfill();
const [board] = await db.select().from(contractBoards);
expect(board!.contracts.map((c) => [c.goodId, c.shipper, c.priority])).toEqual([
["consumer-goods", "Pacific Orbital Logistics", false],
["electronics", "Shackleton Mining Co.", false],
]);
const [row] = await db.select().from(dispatches).where(eq(dispatches.id, record.id));
expect(row!.record.contract).toMatchObject({
goodId: "industrial-chemicals",
priority: false,
deliveryWindowGameHours: null,
});
// A second run changes nothing.
await backfill();
const [again] = await db.select().from(contractBoards);
expect(again!.contracts).toEqual(board!.contracts);
// The API accepts the backfilled rows.
const state = await getState(app, owner);
expect(state.activeDispatches[0]!.contract!.goodId).toBe("industrial-chemicals");
const response = await call(app, owner, "GET", "/api/locations/earth/board");
expect(response.statusCode).toBe(200);
contractBoardResponseSchema.parse(response.json());
});
});

View File

@ -1,89 +0,0 @@
import { content } from "@deadhead/content";
import {
createGuestResponseSchema,
errorResponseSchema,
playerStateSchema,
} from "@deadhead/schema";
import { beforeEach, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import { useTestDatabase } from "./db";
import { testConfig } from "./helpers";
const database = useTestDatabase();
/** Turns Set-Cookie headers into a Cookie request header. */
function cookieFrom(setCookie: string | string[] | undefined): string {
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
return values.map((c) => c.split(";")[0]).join("; ");
}
describe("guest accounts", () => {
let app: ReturnType<typeof buildApp>;
beforeEach(() => {
app = buildApp(testConfig(), { database: database(), jobs: createRecordingQueue() });
return () => app.close();
});
it("creates a guest with a session cookie and a new tutorial player", async () => {
const response = await app.inject({ method: "POST", url: "/api/guest" });
expect(response.statusCode).toBe(200);
expect(cookieFrom(response.headers["set-cookie"])).toMatch(/session_token=/);
const body = createGuestResponseSchema.parse(response.json());
expect(body.state.company.credits).toBe(content.config.startingCredits);
expect(body.state.tutorial).toMatchObject({ loanerLocationId: "earth", runInFlight: false });
});
it("returns the new player's state for that session", async () => {
const guest = await app.inject({ method: "POST", url: "/api/guest" });
const cookie = cookieFrom(guest.headers["set-cookie"]);
const response = await app.inject({ method: "GET", url: "/api/state", headers: { cookie } });
expect(response.statusCode).toBe(200);
const state = playerStateSchema.parse(response.json());
expect(state.company.credits).toBe(content.config.startingCredits);
expect(
state.locations
.filter((l) => l.discovered)
.map((l) => l.id)
.sort(),
).toEqual(["earth", "moon"]);
expect(state.pilots).toHaveLength(1);
expect(state.pilots[0]).toMatchObject({
isAvatar: true,
level: 0,
xp: 0,
locationId: "earth",
status: "idle",
});
expect(state.ships).toHaveLength(0);
expect(state.tutorial).not.toBeNull();
});
it("gives each guest their own player", async () => {
const a = createGuestResponseSchema.parse(
(await app.inject({ method: "POST", url: "/api/guest" })).json(),
);
const b = createGuestResponseSchema.parse(
(await app.inject({ method: "POST", url: "/api/guest" })).json(),
);
expect(a.playerId).not.toBe(b.playerId);
});
it("rejects a state request with no session", async () => {
const response = await app.inject({ method: "GET", url: "/api/state" });
expect(response.statusCode).toBe(401);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("unauthorized");
});
it("rejects a state request with a forged session cookie", async () => {
const response = await app.inject({
method: "GET",
url: "/api/state",
headers: { cookie: "better-auth.session_token=forged.value" },
});
expect(response.statusCode).toBe(401);
});
});

View File

@ -1,60 +0,0 @@
import { content } from "@deadhead/content";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { bays, companies, discoveredLocations, pilots, players, ships } from "../src/db/schema";
import { seedPlayer, useTestDatabase } from "./db";
const database = useTestDatabase();
describe("test database harness", () => {
it("isolates tests: first test sees only its own player", async () => {
const { db } = database();
await seedPlayer(db, "tutorial");
expect(await db.select().from(players)).toHaveLength(1);
});
it("isolates tests: second test also sees only its own player", async () => {
const { db } = database();
await seedPlayer(db, "tutorial");
expect(await db.select().from(players)).toHaveLength(1);
});
it("seeds a new tutorial player", async () => {
const { db } = database();
const { playerId, avatarId } = await seedPlayer(db, "tutorial");
const [player] = await db.select().from(players).where(eq(players.id, playerId));
const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId));
const discovered = await db
.select()
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
const [avatar] = await db.select().from(pilots).where(eq(pilots.id, avatarId));
expect(player).toMatchObject({ tutorialActive: true, loanerLocationId: "earth" });
expect(company!.credits).toBe(content.config.startingCredits);
expect(discovered.map((d) => d.locationId).sort()).toEqual(["earth", "moon"]);
expect(avatar).toMatchObject({ isAvatar: true, xp: 0, assignedShipId: null });
expect(await db.select().from(ships)).toHaveLength(0);
});
it("seeds an owner with one crewed Courier and an Earth bay", async () => {
const { db } = database();
const { playerId, avatarId, shipId } = await seedPlayer(db, "owner", { credits: 777 });
const [player] = await db.select().from(players).where(eq(players.id, playerId));
const [avatar] = await db.select().from(pilots).where(eq(pilots.id, avatarId));
expect(player!.tutorialActive).toBe(false);
expect(await db.select().from(ships)).toEqual([
expect.objectContaining({
id: shipId,
modelId: "courier",
locationId: "earth",
status: "idle",
}),
]);
expect(avatar!.assignedShipId).toBe(shipId);
expect((await db.select().from(bays)).map((b) => b.locationId)).toEqual(["earth"]);
const [company] = await db.select().from(companies);
expect(company!.credits).toBe(777);
});
});

View File

@ -1,38 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import { loadConfig } from "../src/config";
import { testConfig } from "./helpers";
describe("server skeleton", () => {
const app = buildApp(testConfig(), { jobs: createRecordingQueue() });
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,18 +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",
WORKERS_ENABLED: "false",
...overrides,
});
}

View File

@ -1,53 +0,0 @@
import { describe, expect, it } from "vitest";
import { createPgBossQueue } from "../src/jobs/queue";
import { testConfig } from "./helpers";
/** Polls `check` until it returns true or the timeout passes. */
async function waitFor(check: () => Promise<boolean> | boolean, timeoutMs = 20_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("Timed out waiting");
}
describe("pg-boss job queue", () => {
it("runs an enqueued no-op job to completion", async () => {
const handled: unknown[] = [];
const queue = createPgBossQueue(testConfig().databaseUrl, {
workers: true,
jobs: [{ name: "test-noop", handler: async (data) => void handled.push(data) }],
});
await queue.start();
try {
const id = await queue.send("test-noop", { hello: "world" });
expect(id).not.toBeNull();
await waitFor(() => handled.length > 0);
expect(handled).toEqual([{ hello: "world" }]);
await waitFor(
async () => (await queue.boss.getJobById("test-noop", id!))?.state === "completed",
);
} finally {
await queue.stop();
}
}, 30_000);
it("only enqueues when workers are disabled", async () => {
const handled: unknown[] = [];
const queue = createPgBossQueue(testConfig().databaseUrl, {
workers: false,
jobs: [{ name: "test-idle", handler: async (data) => void handled.push(data) }],
});
await queue.start();
try {
const id = await queue.send("test-idle", {});
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(handled).toEqual([]);
expect((await queue.boss.getJobById("test-idle", id!))?.state).toBe("created");
} finally {
await queue.stop();
}
}, 30_000);
});

View File

@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { TEST_NOW, useTestDatabase } from "./db";
import { getState, insertDispatch, signUpOwner, testContract, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("ship lastArrivalAt (CR-002)", () => {
it("is the delivery time before a flight and the latest arrival after flights", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(TEST_NOW);
const out = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
clock.now = out.arrivalAt;
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(out.arrivalAt);
const back = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ originId: "moon", destinationId: "earth" }),
});
clock.now = back.arrivalAt + 1000;
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(back.arrivalAt);
});
});

View File

@ -1,74 +0,0 @@
import { content } from "@deadhead/content";
import { bayResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { setCredits, useTestDatabase } from "./db";
import { call, getState, signUp, signUpOwner, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
describe("lease a bay", () => {
it("leases a bay at the Moon and deducts the price", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const price = content.location("moon")!.bayLeasePrice;
await setCredits(database().db, owner.playerId, price + 100);
const response = await call(app, owner, "POST", "/api/bays", { locationId: "moon" });
expect(response.statusCode).toBe(200);
const { bay } = bayResponseSchema.parse(response.json());
expect(bay.locationId).toBe("moon");
const state = await getState(app, owner);
expect(state.company.credits).toBe(100);
expect(state.bays.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]);
});
it("refuses an undiscovered location", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1_000_000);
const response = await call(app, owner, "POST", "/api/bays", { locationId: "mars" });
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("location-undiscovered");
expect((await getState(app, owner)).company.credits).toBe(1_000_000);
});
it("refuses a second bay at the same location", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1_000_000);
const response = await call(app, owner, "POST", "/api/bays", { locationId: "earth" });
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("bay-already-leased");
const state = await getState(app, owner);
expect(state.bays).toHaveLength(1);
expect(state.company.credits).toBe(1_000_000);
});
it("refuses when the player cannot afford it, and unknown locations", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 0);
expect(errorCode(await call(app, owner, "POST", "/api/bays", { locationId: "moon" }))).toBe(
"insufficient-funds",
);
const unknown = await call(app, owner, "POST", "/api/bays", { locationId: "pluto" });
expect(unknown.statusCode).toBe(404);
});
it("returns tutorial-active during the tutorial and changes nothing", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 1_000_000);
const response = await call(app, guest, "POST", "/api/bays", { locationId: "earth" });
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("tutorial-active");
const state = await getState(app, guest);
expect(state.bays).toEqual([]);
expect(state.company.credits).toBe(1_000_000);
});
});

View File

@ -1,80 +0,0 @@
import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { checkRoute, effectiveStats, gameToReal, realToGame, wouldStrand } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { discoveredLocations, pilots, ships } from "../src/db/schema";
import { setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, getState, signUpOwner, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("Lighter and Clipper (Step 10L.6)", () => {
for (const modelId of ["lighter", "clipper"]) {
it(`buys a ${modelId} at the chosen HQ`, async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const price = content.shipModel(modelId)!.price;
await setCredits(database().db, owner.playerId, price);
const response = await call(app, owner, "POST", "/api/ships", {
modelId,
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(200);
const { ship } = shipResponseSchema.parse(response.json());
expect(ship).toMatchObject({ modelId, status: "idle", locationId: "earth" });
expect((await getState(app, owner)).company.credits).toBe(0);
});
}
it("refuses a deadhead that would strand the ship, changing nothing", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const db = database().db;
await setCredits(db, owner.playerId, 100_000);
// A Clipper at the Moon, 1,420 game days in: Mars is in range from the Moon
// (198 × 1.3), but from Mars (198 × 1.15) nothing is when it gets there.
const departure = realToGame(TEST_NOW, content.config) + 1420 * 24 * 3_600_000;
clock.now = gameToReal(departure, content.config);
await db
.update(ships)
.set({ modelId: "clipper", locationId: "moon" })
.where(eq(ships.id, owner.shipId));
await db.update(pilots).set({ locationId: "moon" }).where(eq(pilots.id, owner.avatarId));
await db
.insert(discoveredLocations)
.values({ playerId: owner.playerId, locationId: "mars", discoveredAt: TEST_NOW });
// The scenario as the sim sees it.
const clipper = effectiveStats(content.shipModel("clipper")!, {}, content);
const [moon, mars] = [content.location("moon")!, content.location("mars")!];
expect(checkRoute(clipper, moon, mars, realToGame(clock.now, content.config)).ok).toBe(true);
expect(
wouldStrand(
clipper,
moon,
mars,
0,
realToGame(clock.now, content.config),
undefined,
content,
),
).toBe(true);
const before = await getState(app, owner);
const response = await call(app, owner, "POST", "/api/dispatches", {
kind: "reposition",
shipId: owner.shipId,
pilotId: owner.avatarId,
destinationId: "mars",
});
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("would-strand");
const after = await getState(app, owner);
expect(after.company.credits).toBe(before.company.credits);
expect(after.ships).toEqual(before.ships);
expect(after.activeDispatches).toEqual([]);
});
});

View File

@ -1,132 +0,0 @@
import { content } from "@deadhead/content";
import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { companies } from "../src/db/schema";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
signUpOwner,
testContract,
useTestApp,
type Guest,
} from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
async function setLoan(guest: Guest, loanBalance: number) {
await database()
.db.update(companies)
.set({ loanBalance })
.where(eq(companies.playerId, guest.playerId));
}
const share = content.config.loanRepaymentShare;
describe("loan repayment on delivery", () => {
it("adds the net to credits, lowers the balance by the repayment, and records it", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1000);
await setLoan(owner, 5000);
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ payout: 2000 }),
});
clock.now = record.arrivalAt;
const state = await getState(app, owner);
// The avatar flew it, so there is no pilot pay.
const repayment = Math.floor(2000 * share);
expect(state.company).toMatchObject({
credits: 1000 + 2000 - repayment,
loanBalance: 5000 - repayment,
});
expect(state.summaries[0]).toMatchObject({
deadhead: false,
credits: 2000 - repayment,
loanRepayment: repayment,
});
});
it("repays only what is owed when the balance is small", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 0);
await setLoan(owner, 50);
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ payout: 2000 }),
});
clock.now = record.arrivalAt;
const state = await getState(app, owner);
expect(state.company).toMatchObject({ credits: 1950, loanBalance: 0 });
});
it("puts the full amount into credits when there is no balance", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 0);
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ payout: 2000 }),
});
clock.now = record.arrivalAt;
const state = await getState(app, owner);
expect(state.company).toMatchObject({ credits: 2000, loanBalance: 0 });
expect(state.summaries[0]!.loanRepayment).toBe(0);
});
});
describe("paying down the loan", () => {
const repay = (guest: Guest, amount: number) =>
call(testApp().app, guest, "POST", "/api/loan/repay", { amount });
it("moves the amount from credits to the loan", async () => {
const owner = await signUpOwner(testApp().app, database().db);
await setCredits(database().db, owner.playerId, 3000);
await setLoan(owner, 5000);
const response = await repay(owner, 1200);
expect(response.statusCode).toBe(200);
expect(companyResponseSchema.parse(response.json()).company).toMatchObject({
credits: 1800,
loanBalance: 3800,
});
});
it("takes only the balance when asked to repay more", async () => {
const owner = await signUpOwner(testApp().app, database().db);
await setCredits(database().db, owner.playerId, 3000);
await setLoan(owner, 400);
const { company } = companyResponseSchema.parse((await repay(owner, 2500)).json());
expect(company).toMatchObject({ credits: 2600, loanBalance: 0 });
});
it("fails with no changes when repaying more than the player has", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 300);
await setLoan(owner, 5000);
const response = await repay(owner, 1000);
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("insufficient-funds");
expect((await getState(app, owner)).company).toMatchObject({ credits: 300, loanBalance: 5000 });
});
it("rejects a non-positive amount", async () => {
const owner = await signUpOwner(testApp().app, database().db);
expect((await repay(owner, 0)).statusCode).toBe(400);
});
});

View File

@ -1,108 +0,0 @@
import { content } from "@deadhead/content";
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { boardPeriodMs, validateDispatch } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { discoveredLocations } from "../src/db/schema";
import { TEST_NOW, useTestDatabase } from "./db";
import { call, insertDispatch, signUpOwner, testContract, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
async function board(guest: Guest, locationId: string) {
const response = await call(testApp().app, guest, "GET", `/api/locations/${locationId}/board`);
expect(response.statusCode).toBe(200);
return contractBoardResponseSchema.parse(response.json());
}
describe("location contract boards", () => {
it("returns the Moon's board for a player with no Moon bay", async () => {
const owner = await signUpOwner(testApp().app, database().db);
const { contracts } = await board(owner, "moon");
expect(contracts).toHaveLength(content.config.contractBoardSize);
for (const c of contracts) expect(c.originId).toBe("moon");
});
it("returns the Earth board of a player with an Earth bay too", async () => {
const owner = await signUpOwner(testApp().app, database().db);
const { contracts, periodEndsAt } = await board(owner, "earth");
expect(contracts).toHaveLength(content.config.contractBoardSize);
expect(periodEndsAt).toBeGreaterThan(testApp().clock.now);
});
it("returns the same stored board within a period, even after a discovery", async () => {
const { clock } = testApp();
const owner = await signUpOwner(testApp().app, database().db);
const first = await board(owner, "moon");
await database()
.db.insert(discoveredLocations)
.values({ playerId: owner.playerId, locationId: "mars", discoveredAt: TEST_NOW });
clock.advance(boardPeriodMs(content.config) / 2 - 1);
expect((await board(owner, "moon")).contracts).toEqual(first.contracts);
});
it("returns a new board after the period rolls over", async () => {
const { clock } = testApp();
const owner = await signUpOwner(testApp().app, database().db);
const first = await board(owner, "earth");
clock.now = first.periodEndsAt;
const next = await board(owner, "earth");
expect(next.periodEndsAt).toBe(first.periodEndsAt + boardPeriodMs(content.config));
expect(next.contracts.map((c) => c.id)).not.toEqual(first.contracts.map((c) => c.id));
});
it("refuses Mars before it is discovered, and opens it after a delivery discovers it", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const before = await call(app, owner, "GET", "/api/locations/mars/board");
expect(before.statusCode).toBe(409);
expect(errorResponseSchema.parse(before.json()).error.code).toBe("location-undiscovered");
// A delivery to Mars (placed directly; the Courier's range is not the point here).
const record = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ destinationId: "mars" }),
});
clock.now = record.arrivalAt;
const { contracts } = await board(owner, "mars");
for (const c of contracts) expect(c.originId).toBe("mars");
});
it("returns not-found for an unknown location", async () => {
const owner = await signUpOwner(testApp().app, database().db);
const response = await call(testApp().app, owner, "GET", "/api/locations/pluto/board");
expect(response.statusCode).toBe(404);
});
it("fills a lone Courier's Earth board with jobs it can fly (CR-006)", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const { contracts } = await board(owner, "earth");
const flyable = contracts.filter(
(contract) =>
validateDispatch(
{
ship: { id: owner.shipId, modelId: "courier", locationId: "earth", inFlight: false },
pilot: {
id: owner.avatarId,
isAvatar: true,
assignedShipId: owner.shipId,
inFlight: false,
skills: {},
restEndsAt: null,
},
contract,
tutorial: false,
credits: 1_000_000,
now: clock.now,
},
content,
).ok,
);
expect(contracts).toHaveLength(content.config.contractBoardSize);
expect(flyable.length).toBeGreaterThanOrEqual(5);
});
});

View File

@ -1,59 +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",
"account",
"bays",
"claimed_goals",
"companies",
"contract_boards",
"discovered_locations",
"dispatches",
"pilots",
"player_logins",
"players",
"session",
"ship_upgrades",
"ships",
"user",
"verification",
]);
});
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,77 +0,0 @@
import { content } from "@deadhead/content";
import { playerStateSchema, type PlayerState } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { players } from "../src/db/schema";
import { resolveArrivalsJob } from "../src/jobs/definitions";
import { setCredits, useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
signUp,
signUpOwner,
testContract,
useTestApp,
} from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
/** The parts of a state that must match a brand-new guest's (ids differ). */
const shape = (s: PlayerState) => ({
credits: s.company.credits,
loanBalance: s.company.loanBalance,
ships: s.ships.length,
bays: s.bays.length,
pilots: s.pilots.map((p) => ({
isAvatar: p.isAvatar,
locationId: p.locationId,
level: p.level,
assignedShipId: p.assignedShipId,
})),
discovered: s.locations.filter((l) => l.discovered).map((l) => l.id),
tutorial: s.tutorial,
activeDispatches: s.activeDispatches.length,
summaries: s.summaries.length,
goals: s.goals,
});
describe("start a new company (Step 10K.6)", () => {
it("turns a mid-game company back into a fresh guest start, with a new world seed", async () => {
const { app, clock } = testApp();
const { db } = database();
const owner = await signUpOwner(app, db);
await setCredits(db, owner.playerId, 50_000);
await call(app, owner, "POST", "/api/bays", { locationId: "moon" });
await call(app, owner, "POST", `/api/ships/${owner.shipId}/upgrades`, { upgradeId: "engines" });
const flight = await insertDispatch(db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
const [before] = await db.select().from(players).where(eq(players.id, owner.playerId));
const response = await call(app, owner, "POST", "/api/company/new", {});
expect(response.statusCode).toBe(200);
const fresh = playerStateSchema.parse(response.json());
const guest = await signUp(app);
expect(shape(fresh)).toEqual(shape(await getState(app, guest)));
expect(fresh.tutorial).toMatchObject({ loanerLocationId: "earth", runsCompleted: 0 });
expect(fresh.company.credits).toBe(content.config.startingCredits);
expect(shape(fresh).discovered.sort()).toEqual(["earth", "moon"]);
const [after] = await db.select().from(players).where(eq(players.id, owner.playerId));
expect(after!.worldSeed).not.toBe(before!.worldSeed);
expect(after!.id).toBe(before!.id);
// The old flight's arrival job finds nothing and changes nothing.
clock.now = flight.arrivalAt + 1000;
await expect(
resolveArrivalsJob({ db, clock: clock.read }, { playerId: owner.playerId }),
).resolves.toBeUndefined();
expect(shape(await getState(app, owner))).toEqual(shape(fresh));
});
});

Some files were not shown because too many files have changed in this diff Show More