From aea768bebf8553f2b237e4a3a8f8cfa7a08cf006 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 11:04:57 -0400 Subject: [PATCH] Step 10A.6: add loan, tutorial run and goal schema and routes Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 9 + .../drizzle/0002_cr001_faster_start.sql | 14 + apps/server/drizzle/meta/0002_snapshot.json | 1319 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/src/db/schema.ts | 29 +- apps/server/src/game/deal.ts | 19 + apps/server/src/game/player-state.ts | 17 +- apps/server/src/http/errors.ts | 1 + apps/server/test/end-tutorial.test.ts | 2 +- apps/server/test/guest.test.ts | 2 +- apps/server/test/migrations.test.ts | 1 + apps/server/test/tutorial-dispatch.test.ts | 4 +- apps/web/test/away-summary.test.tsx | 4 + apps/web/test/dispatch-sheet.test.tsx | 2 +- apps/web/test/hq.test.tsx | 4 +- apps/web/test/layout.test.tsx | 3 +- apps/web/test/locations.test.tsx | 2 +- apps/web/test/market.test.tsx | 11 +- apps/web/test/pilots.test.tsx | 3 +- apps/web/test/render.tsx | 10 +- apps/web/test/tutorial-board.test.tsx | 9 +- memory-bank/architecture.md | 50 +- memory-bank/progress.md | 2 +- packages/schema/src/entities.ts | 19 + packages/schema/src/errors.ts | 5 + packages/schema/src/requests.ts | 6 + packages/schema/src/routes.ts | 24 + packages/schema/test/routes.test.ts | 3 + packages/schema/test/schemas.test.ts | 26 +- 29 files changed, 1578 insertions(+), 29 deletions(-) create mode 100644 apps/server/drizzle/0002_cr001_faster_start.sql create mode 100644 apps/server/drizzle/meta/0002_snapshot.json create mode 100644 apps/server/src/game/deal.ts diff --git a/DECISIONS.md b/DECISIONS.md index 007897c..76131ee 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -516,3 +516,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Goals are evaluated against a small `GoalProgress` summary** (own-ship deliveries, skill points spent, ships owned, pilots hired, extra locations discovered, deal accepted, loan balance) rather than full player state. The server builds it from the database, and the evaluator stays independent of storage. - **"Pay off your loan" requires the deal to have been accepted**, so a player who never had a loan does not complete it by default. - **The evaluator takes the goal list as an argument** (not part of `SimContent`), since only goal code needs it. + +## Step 10A.6 + +- **The repayment is stored in each dispatch's `resolution` JSON** (`loanRepayment`), next to the other applied effects, rather than as a separate column. +- **`purchasable` lives only in content.** Ship models are not stored in the database; the Market lists `content.purchasableShipModels`, and the server refuses non-purchasable models (Step 10A.1). +- **Tutorial boards are keyed by run** (`contract_boards.tutorial_run`, 0 for bay boards) as well as period and location (see CR-001 notes). +- **New state fields:** `company.loanBalance`; `tutorial.runsCompleted` and `tutorial.dealAvailable` (computed by `isDealAvailable`: tutorial on, deal not yet taken, runs ≥ `tutorialRunsBeforeDeal`, loaner at Earth, no run in flight); `goals` as `{ id, status }`, empty until Step 10A.10 fills it; summaries gain `loanRepayment` and `scriptedId`. +- **New routes:** `POST /api/deal/accept` returns the whole new `PlayerState`, since the deal changes almost everything; `POST /api/loan/repay` and `POST /api/goals/:goalId/claim` return `{ company }`. +- **Error codes:** `tutorial-active` (purchases during the tutorial), `deal-not-available`, `unknown-goal` (404), `goal-not-ready`, `goal-already-claimed`. diff --git a/apps/server/drizzle/0002_cr001_faster_start.sql b/apps/server/drizzle/0002_cr001_faster_start.sql new file mode 100644 index 0000000..3475d9a --- /dev/null +++ b/apps/server/drizzle/0002_cr001_faster_start.sql @@ -0,0 +1,14 @@ +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"); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0002_snapshot.json b/apps/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..1c3d858 --- /dev/null +++ b/apps/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1319 @@ +{ + "id": "ee1be5c3-4dc4-417d-9b1a-dac302bb4a2c", + "prevId": "b5c9da12-6f56-470b-8eef-0ef12cd9d2c2", + "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.claimed_goals": { + "name": "claimed_goals", + "schema": "", + "columns": { + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "claimed_goals_player_id_players_id_fk": { + "name": "claimed_goals_player_id_players_id_fk", + "tableFrom": "claimed_goals", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "claimed_goals_player_id_goal_id_pk": { + "name": "claimed_goals_player_id_goal_id_pk", + "columns": [ + "player_id", + "goal_id" + ] + } + }, + "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 + }, + "loan_balance": { + "name": "loan_balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "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 + }, + "tutorial_run": { + "name": "tutorial_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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" + }, + { + "expression": "tutorial_run", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "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 + }, + "tutorial_runs_completed": { + "name": "tutorial_runs_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deal_accepted": { + "name": "deal_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "players_user_id_user_id_fk": { + "name": "players_user_id_user_id_fk", + "tableFrom": "players", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "players_user_id_unique": { + "name": "players_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "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 + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index a77866a..0a595a8 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1790991751364, "tag": "0001_auth", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1791039630016, + "tag": "0002_cr001_faster_start", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 257a0f1..a362106 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -32,6 +32,10 @@ export const players = pgTable("players", { 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(), }); @@ -44,6 +48,8 @@ export const companies = pgTable("companies", { 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( @@ -127,11 +133,19 @@ export const contractBoards = pgTable( kind: boardKind("kind").notNull(), locationId: text("location_id").notNull(), periodIndex: integer("period_index").notNull(), + /** Tutorial boards only: runs completed when generated (0 for bay boards). */ + tutorialRun: integer("tutorial_run").notNull().default(0), contracts: jsonb("contracts").$type().notNull(), createdAt: time("created_at").notNull(), }, (t) => [ - uniqueIndex("contract_boards_unique_idx").on(t.playerId, t.kind, t.locationId, t.periodIndex), + uniqueIndex("contract_boards_unique_idx").on( + t.playerId, + t.kind, + t.locationId, + t.periodIndex, + t.tutorialRun, + ), ], ); @@ -178,3 +192,16 @@ export const acceptedContracts = pgTable( }, (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] })], +); diff --git a/apps/server/src/game/deal.ts b/apps/server/src/game/deal.ts new file mode 100644 index 0000000..9a69a28 --- /dev/null +++ b/apps/server/src/game/deal.ts @@ -0,0 +1,19 @@ +import { content } from "@deadhead/content"; +import type { players } from "../db/schema"; + +/** + * 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 + ); +} diff --git a/apps/server/src/game/player-state.ts b/apps/server/src/game/player-state.ts index 2978760..6e38890 100644 --- a/apps/server/src/game/player-state.ts +++ b/apps/server/src/game/player-state.ts @@ -11,6 +11,7 @@ import { players, ships, } from "../db/schema"; +import { isDealAvailable } from "./deal"; import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers"; /** Reads everything the client needs about one player. */ @@ -49,7 +50,12 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise< ); return { - company: { id: company.id, name: company.name, credits: company.credits }, + company: { + id: company.id, + name: company.name, + credits: company.credits, + loanBalance: company.loanBalance, + }, ships: shipRows.map((s) => ({ id: s.id, modelId: s.modelId, @@ -69,6 +75,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise< ? { 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), @@ -80,9 +91,13 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise< destinationId: d.record.destinationId, tutorial: d.tutorial, credits: d.resolution?.credits ?? 0, + loanRepayment: d.resolution?.loanRepayment ?? 0, pilotXp: d.resolution?.pilotXp ?? 0, discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null, + scriptedId: d.record.contract?.scriptedId ?? null, resolvedAt: d.resolvedAt ?? d.arrivalAt, })), + // Goal statuses arrive with Step 10A.10. + goals: [], }; } diff --git a/apps/server/src/http/errors.ts b/apps/server/src/http/errors.ts index e3e5224..cbeaa08 100644 --- a/apps/server/src/http/errors.ts +++ b/apps/server/src/http/errors.ts @@ -16,6 +16,7 @@ const NOT_FOUND_CODES: ReadonlySet = new Set([ "not-found", "unknown-contract", "unknown-skill", + "unknown-goal", ]); /** A game-rule failure: 404 for unknown things, 409 for everything else. */ diff --git a/apps/server/test/end-tutorial.test.ts b/apps/server/test/end-tutorial.test.ts index 1d0b841..8163130 100644 --- a/apps/server/test/end-tutorial.test.ts +++ b/apps/server/test/end-tutorial.test.ts @@ -71,7 +71,7 @@ describe("end of tutorial", () => { ); await buyShipAndEarthBay(guest); - expect((await getState(app, guest)).tutorial).toEqual({ + expect((await getState(app, guest)).tutorial).toMatchObject({ loanerLocationId: "earth", runInFlight: true, }); diff --git a/apps/server/test/guest.test.ts b/apps/server/test/guest.test.ts index 651dcb3..cba2877 100644 --- a/apps/server/test/guest.test.ts +++ b/apps/server/test/guest.test.ts @@ -32,7 +32,7 @@ describe("guest accounts", () => { const body = createGuestResponseSchema.parse(response.json()); expect(body.state.company.credits).toBe(content.config.startingCredits); - expect(body.state.tutorial).toEqual({ loanerLocationId: "earth", runInFlight: false }); + expect(body.state.tutorial).toMatchObject({ loanerLocationId: "earth", runInFlight: false }); }); it("returns the new player's state for that session", async () => { diff --git a/apps/server/test/migrations.test.ts b/apps/server/test/migrations.test.ts index bc882b4..7ead101 100644 --- a/apps/server/test/migrations.test.ts +++ b/apps/server/test/migrations.test.ts @@ -35,6 +35,7 @@ describe("database migrations", () => { "accepted_contracts", "account", "bays", + "claimed_goals", "companies", "contract_boards", "discovered_locations", diff --git a/apps/server/test/tutorial-dispatch.test.ts b/apps/server/test/tutorial-dispatch.test.ts index 9ee182c..3cf118f 100644 --- a/apps/server/test/tutorial-dispatch.test.ts +++ b/apps/server/test/tutorial-dispatch.test.ts @@ -54,7 +54,7 @@ describe("tutorial dispatch", () => { const state = await getState(app, guest); expect(state.company.credits).toBe(content.config.startingCredits); - expect(state.tutorial).toEqual({ loanerLocationId: "earth", runInFlight: true }); + expect(state.tutorial).toMatchObject({ loanerLocationId: "earth", runInFlight: true }); expect(state.pilots[0]).toMatchObject({ status: "in-flight", locationId: null }); expect(state.activeDispatches.map((d) => d.id)).toEqual([dispatch.id]); expect((await tutorialBoard(guest)).map((c) => c.id)).not.toContain(contract!.id); @@ -84,7 +84,7 @@ describe("tutorial dispatch", () => { expect(state.company.credits).toBe(content.config.startingCredits + contract!.payout); expect(state.pilots[0]!.xp).toBeGreaterThan(0); expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon" }); - expect(state.tutorial).toEqual({ loanerLocationId: "moon", runInFlight: false }); + expect(state.tutorial).toMatchObject({ loanerLocationId: "moon", runInFlight: false }); const next = await tutorialBoard(guest); expect(next.length).toBeGreaterThan(0); diff --git a/apps/web/test/away-summary.test.tsx b/apps/web/test/away-summary.test.tsx index 588aecd..b3773a6 100644 --- a/apps/web/test/away-summary.test.tsx +++ b/apps/web/test/away-summary.test.tsx @@ -23,8 +23,10 @@ const summaries: ResolutionSummary[] = [ destinationId: "mars", tutorial: false, credits: 51_200, + loanRepayment: 0, pilotXp: 32, discoveredLocationId: "mars", + scriptedId: null, resolvedAt: 1, }, { @@ -35,8 +37,10 @@ const summaries: ResolutionSummary[] = [ destinationId: "earth", tutorial: true, credits: 240, + loanRepayment: 0, pilotXp: 10, discoveredLocationId: null, + scriptedId: null, resolvedAt: 2, }, ]; diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index dbe4f90..4aa8ed8 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -59,7 +59,7 @@ const marsJob: Contract = { const state = samplePlayerState({ tutorial: null, - company: { id: "c", name: "D", credits: 1_000_000 }, + company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 }, bays: [{ id: "b1", locationId: "earth" }], ships: [ ship("s-courier", "Dusty Mule", "courier", "avatar"), diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/hq.test.tsx index c3fd907..f2f28de 100644 --- a/apps/web/test/hq.test.tsx +++ b/apps/web/test/hq.test.tsx @@ -67,7 +67,7 @@ describe("HQ screen", () => { it("shows tutorial goal progress as a percentage of a ship plus an Earth bay", async () => { const goal = tutorialGoalCost(); const state = samplePlayerState({ - company: { id: "c", name: "D", credits: Math.round(goal * 0.4) }, + company: { id: "c", name: "D", credits: Math.round(goal * 0.4), loanBalance: 0 }, }); renderApp(fakeApi({ getPlayerState: () => state })); @@ -84,7 +84,7 @@ describe("HQ screen", () => { it("caps tutorial progress at 100%", async () => { const state = samplePlayerState({ - company: { id: "c", name: "D", credits: tutorialGoalCost() * 3 }, + company: { id: "c", name: "D", credits: tutorialGoalCost() * 3, loanBalance: 0 }, }); renderApp(fakeApi({ getPlayerState: () => state })); expect(await screen.findByText("100%")).toBeInTheDocument(); diff --git a/apps/web/test/layout.test.tsx b/apps/web/test/layout.test.tsx index a98a0c1..4eda009 100644 --- a/apps/web/test/layout.test.tsx +++ b/apps/web/test/layout.test.tsx @@ -6,7 +6,8 @@ import { fakeApi, renderApp, samplePlayerState } from "./render"; const signedIn = () => fakeApi({ - getPlayerState: () => samplePlayerState({ company: { id: "c", name: "D", credits: 1234 } }), + getPlayerState: () => + samplePlayerState({ company: { id: "c", name: "D", credits: 1234, loanBalance: 0 } }), }); describe("app layout", () => { diff --git a/apps/web/test/locations.test.tsx b/apps/web/test/locations.test.tsx index 52f38cc..3ade5f3 100644 --- a/apps/web/test/locations.test.tsx +++ b/apps/web/test/locations.test.tsx @@ -31,7 +31,7 @@ describe("locations screen", () => { const api = fakeApi({ getPlayerState: () => samplePlayerState({ - company: { id: "c", name: "D", credits: 100_000 }, + company: { id: "c", name: "D", credits: 100_000, loanBalance: 0 }, bays: [{ id: "b1", locationId: "earth" }], }), }); diff --git a/apps/web/test/market.test.tsx b/apps/web/test/market.test.tsx index ee803fb..a6cbc26 100644 --- a/apps/web/test/market.test.tsx +++ b/apps/web/test/market.test.tsx @@ -26,7 +26,9 @@ describe("market screen", () => { renderApp( fakeApi({ getPlayerState: () => - samplePlayerState({ company: { id: "c", name: "D", credits: courierPrice } }), + samplePlayerState({ + company: { id: "c", name: "D", credits: courierPrice, loanBalance: 0 }, + }), }), "/market", ); @@ -51,7 +53,12 @@ describe("market screen", () => { samplePlayerState({ tutorial: null, ships: owned, - company: { id: "c", name: "D", credits: owned.length ? 1 : freighterPrice }, + company: { + id: "c", + name: "D", + credits: owned.length ? 1 : freighterPrice, + loanBalance: 0, + }, }), buyShip: () => { owned = [bought]; diff --git a/apps/web/test/pilots.test.tsx b/apps/web/test/pilots.test.tsx index 5d2c9a1..d2af1e1 100644 --- a/apps/web/test/pilots.test.tsx +++ b/apps/web/test/pilots.test.tsx @@ -97,7 +97,8 @@ describe("pilots screen", () => { const cost = content.config.pilotHireCost; renderApp( fakeApi({ - getPlayerState: () => pilotsState({ company: { id: "c", name: "D", credits: cost - 1 } }), + getPlayerState: () => + pilotsState({ company: { id: "c", name: "D", credits: cost - 1, loanBalance: 0 } }), }), "/pilots", ); diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx index d11f772..3692c49 100644 --- a/apps/web/test/render.tsx +++ b/apps/web/test/render.tsx @@ -34,7 +34,7 @@ export const unauthorized = () => { /** A brand-new tutorial player, as guest sign-up returns it. */ export function samplePlayerState(overrides: Partial = {}): PlayerState { return { - company: { id: "co1", name: "Deadhead Freight", credits: 0 }, + company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 }, ships: [], pilots: [ { @@ -58,9 +58,15 @@ export function samplePlayerState(overrides: Partial = {}): PlayerS { id: "ceres-station", discovered: false }, { id: "jupiter-cloud-platform", discovered: false }, ], - tutorial: { loanerLocationId: "earth", runInFlight: false }, + tutorial: { + loanerLocationId: "earth", + runInFlight: false, + runsCompleted: 0, + dealAvailable: false, + }, activeDispatches: [], summaries: [], + goals: [], ...overrides, }; } diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index 64718f7..f4108b6 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -86,7 +86,14 @@ describe("tutorial contract board", () => { it("disables Fly it while a tutorial run is in flight", async () => { const api = fakeApi({ getPlayerState: () => - samplePlayerState({ tutorial: { loanerLocationId: "earth", runInFlight: true } }), + samplePlayerState({ + tutorial: { + loanerLocationId: "earth", + runInFlight: true, + runsCompleted: 0, + dealAvailable: false, + }, + }), getTutorialBoard: () => board, }); renderApp(api, "/contracts"); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 19107a3..33383f9 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -24,16 +24,17 @@ Living reference for how the code is organised. Update it after any database sch | Shape | Fields | |---|---| | `ServerTime` | `realTime`, `gameTime` | -| `Company` | `id`, `name`, `credits` | +| `Company` | `id`, `name`, `credits`, `loanBalance` | | `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId` | | `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) | | `Bay` | `id`, `locationId` | | `LocationState` | `id`, `discovered` | -| `Contract` | mirrors sim `Contract` | +| `Contract` | mirrors sim `Contract` (optional `scriptedId` on a scripted tutorial contract) | | `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` | -| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits`, `pilotXp`, `discoveredLocationId`, `resolvedAt` | -| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight` | -| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries` | +| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `resolvedAt` | +| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` | +| `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) | +| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) | ### Errors @@ -58,6 +59,9 @@ The `routes` catalog is the single source for both server and client. Each entry | `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes | | `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes | | `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes | +| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes | +| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes | +| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes | ## Database (`apps/server/src/db`) @@ -65,15 +69,16 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri | Table | Purpose | Key constraints | |---|---|---| -| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location | pk `id`, unique `user_id` → `user.id` | -| `companies` | Name and credits (the row locked for money changes) | unique `player_id` | +| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` | +| `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` | | `ships` | Owned ships: model, name, status, location (null in flight) | — | | `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | unique `assigned_ship_id` (one pilot per ship) | | `bays` | Leased bays | unique (`player_id`, `location_id`) | | `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) | -| `contract_boards` | Stored bay boards and tutorial boards per period (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`) | +| `contract_boards` | Stored bay boards and tutorial boards per period (tutorial boards also per run) (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) | | `dispatches` | Full sim record (jsonb, with contract snapshot), status, arrival, resolution (jsonb), resolved-at, acknowledged-at | index (`status`, `arrival_at`), (`player_id`, `status`) | | `accepted_contracts` | Contracts taken from a board, linked to their dispatch | pk (`player_id`, `contract_id`) | +| `claimed_goals` | Onboarding goals whose reward was claimed | pk (`player_id`, `goal_id`) | | `user`, `session`, `account`, `verification` | Better Auth tables (`src/db/auth-schema.ts`, generated by its CLI; `user.is_anonymous` from the anonymous plugin). Timestamps here are Postgres `timestamp`, Better Auth's convention. | — | ### Drizzle schema @@ -115,6 +120,10 @@ export const players = pgTable("players", { 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(), }); @@ -127,6 +136,8 @@ export const companies = pgTable("companies", { 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( @@ -210,11 +221,19 @@ export const contractBoards = pgTable( kind: boardKind("kind").notNull(), locationId: text("location_id").notNull(), periodIndex: integer("period_index").notNull(), + /** Tutorial boards only: runs completed when generated (0 for bay boards). */ + tutorialRun: integer("tutorial_run").notNull().default(0), contracts: jsonb("contracts").$type().notNull(), createdAt: time("created_at").notNull(), }, (t) => [ - uniqueIndex("contract_boards_unique_idx").on(t.playerId, t.kind, t.locationId, t.periodIndex), + uniqueIndex("contract_boards_unique_idx").on( + t.playerId, + t.kind, + t.locationId, + t.periodIndex, + t.tutorialRun, + ), ], ); @@ -261,6 +280,19 @@ export const acceptedContracts = pgTable( }, (t) => [primaryKey({ columns: [t.playerId, t.contractId] })], ); + +/** Onboarding goals whose reward has been claimed (decision 0.5.34). */ +export const claimedGoals = pgTable( + "claimed_goals", + { + playerId: text("player_id") + .notNull() + .references(() => players.id, { onDelete: "cascade" }), + goalId: text("goal_id").notNull(), + claimedAt: time("claimed_at").notNull(), + }, + (t) => [primaryKey({ columns: [t.playerId, t.goalId] })], +); ``` ```ts diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 8eb0720..bdfba16 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -111,7 +111,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a - [x] **Step 10A.3 — Sim: tutorial board v2.** `generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex })`: light/medium/full Standard contracts at the tutorial share; a scripted contract for the upcoming run replaces the medium slot (`scriptedId` on the contract, at its pay multiplier). Loaner is `config.tutorialLoanerShipModelId`. Step 3.10 tests replaced. Server passes `runsCompleted: 0` until Step 10A.7. - [x] **Step 10A.4 — Sim: loan repayment.** `splitLoanRepayment(credits, balance, share)` (`src/loan.ts`); `resolveDispatch` takes `loanBalance` and returns `loanRepayment` with net `credits`. Server passes a zero balance until Step 10A.9. - [x] **Step 10A.5 — Sim: onboarding goal evaluation.** `completedGoals(goals, progress)` / `isGoalConditionMet` (`src/goals.ts`) over a `GoalProgress` summary; tests check each goal just short of and at its condition. Sim line coverage still 100%. -- [ ] Step 10A.6 — Schema and database +- [x] **Step 10A.6 — Schema and database.** Migration `0002_cr001_faster_start`: `companies.loan_balance`, `players.tutorial_runs_completed` / `deal_accepted`, `contract_boards.tutorial_run` (in the unique key), table `claimed_goals`; repayment stored in each dispatch's resolution JSON. API: `company.loanBalance`, `tutorial.runsCompleted` / `dealAvailable`, `goals`, summary `loanRepayment` / `scriptedId`; routes `acceptDeal`, `repayLoan`, `claimGoal`; error codes `tutorial-active`, `deal-not-available`, `unknown-goal`, `goal-not-ready`, `goal-already-claimed`. `architecture.md` updated. - [ ] Step 10A.7 — Server: tutorial flow v2 - [ ] Step 10A.8 — Server: accept the deal - [ ] Step 10A.9 — Server: loan repayment diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 64ed565..120c524 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -22,6 +22,8 @@ export const companySchema = z.object({ name: z.string().min(1), /** May go negative only through pilot pay on a low payout (see DECISIONS Step 3.17). */ credits, + /** Owed to the lender from the deal (decision 0.5.32); 0 when paid off or never taken. */ + loanBalance: credits.nonnegative(), }); export type Company = z.infer; @@ -113,10 +115,15 @@ export const resolutionSummarySchema = z.object({ originId: idSchema, destinationId: idSchema, tutorial: z.boolean(), + /** Credits added after pilot pay and loan repayment. */ credits, + /** Credits the lender took from this payout (decision 0.5.32). */ + loanRepayment: credits.nonnegative(), pilotXp: z.number().int().nonnegative(), /** Set when the delivery discovered its destination. */ discoveredLocationId: idSchema.nullable(), + /** The scripted tutorial contract delivered, if any (for its flavor message). */ + scriptedId: idSchema.nullable(), resolvedAt: timeSchema, }); export type ResolutionSummary = z.infer; @@ -125,9 +132,19 @@ export type ResolutionSummary = z.infer; export const tutorialStateSchema = z.object({ loanerLocationId: z.enum(["earth", "moon"]), runInFlight: z.boolean(), + runsCompleted: z.number().int().nonnegative(), + /** The one-time deal can be accepted now (decision 0.5.30). */ + dealAvailable: z.boolean(), }); export type TutorialState = z.infer; +/** An onboarding goal's progress (decision 0.5.34). Static details come from content. */ +export const goalStateSchema = z.object({ + id: idSchema, + status: z.enum(["incomplete", "ready", "claimed"]), +}); +export type GoalState = z.infer; + export const playerStateSchema = z.object({ company: companySchema, ships: z.array(shipSchema), @@ -139,5 +156,7 @@ export const playerStateSchema = z.object({ activeDispatches: z.array(dispatchSchema), /** Unacknowledged "while you were away" entries. */ summaries: z.array(resolutionSummarySchema), + /** Onboarding goals in content order; empty during the tutorial. */ + goals: z.array(goalStateSchema), }); export type PlayerState = z.infer; diff --git a/packages/schema/src/errors.ts b/packages/schema/src/errors.ts index 6897e60..bcdd97a 100644 --- a/packages/schema/src/errors.ts +++ b/packages/schema/src/errors.ts @@ -28,6 +28,11 @@ export const SERVER_ERROR_CODES = [ "skill-maxed", "unknown-skill", "avatar-in-tutorial", + "tutorial-active", + "deal-not-available", + "unknown-goal", + "goal-not-ready", + "goal-already-claimed", ] as const; export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number]; diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index e816fdc..5bbf029 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { baySchema, + companySchema, contractSchema, dispatchSchema, idSchema, @@ -63,3 +64,8 @@ export const acknowledgeSummariesRequestSchema = z.object({ export const acknowledgeSummariesResponseSchema = z.object({ acknowledged: z.number().int().nonnegative(), }); + +/** Pay the loan down by up to `amount` credits (decision 0.5.32). */ +export const repayLoanRequestSchema = z.object({ amount: z.number().int().positive() }); +export const claimGoalRequestSchema = z.object({ goalId: idSchema }); +export const companyResponseSchema = z.object({ company: companySchema }); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index cc14371..42522e6 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -6,6 +6,8 @@ import { assignPilotRequestSchema, bayResponseSchema, buyShipRequestSchema, + claimGoalRequestSchema, + companyResponseSchema, contractBoardRequestSchema, contractBoardResponseSchema, createDispatchRequestSchema, @@ -15,6 +17,7 @@ import { hirePilotRequestSchema, leaseBayRequestSchema, pilotResponseSchema, + repayLoanRequestSchema, shipResponseSchema, spendSkillPointRequestSchema, unassignPilotRequestSchema, @@ -125,6 +128,27 @@ export const routes = { response: acknowledgeSummariesResponseSchema, auth: true, }, + acceptDeal: { + method: "POST", + path: "/api/deal/accept", + request: emptyRequestSchema, + response: playerStateSchema, + auth: true, + }, + repayLoan: { + method: "POST", + path: "/api/loan/repay", + request: repayLoanRequestSchema, + response: companyResponseSchema, + auth: true, + }, + claimGoal: { + method: "POST", + path: "/api/goals/:goalId/claim", + request: claimGoalRequestSchema, + response: companyResponseSchema, + auth: true, + }, } as const satisfies Record; export type Routes = typeof routes; diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index 95735f8..dfb35a0 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -21,6 +21,9 @@ describe("route catalog", () => { "unassignPilot", "spendSkillPoint", "acknowledgeSummaries", + "acceptDeal", + "repayLoan", + "claimGoal", ].sort(), ); }); diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index cfba4f9..7a044ce 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -11,6 +11,10 @@ import { acknowledgeSummariesResponseSchema, assignPilotRequestSchema, baySchema, + claimGoalRequestSchema, + companyResponseSchema, + goalStateSchema, + repayLoanRequestSchema, bayResponseSchema, buyShipRequestSchema, companySchema, @@ -41,7 +45,7 @@ import { type Dispatch, } from "../src/index"; -const company = { id: "co1", name: "Deadhead Freight", credits: 5000 }; +const company = { id: "co1", name: "Deadhead Freight", credits: 5000, loanBalance: 3500 }; const ship = { id: "s1", modelId: "courier", @@ -104,11 +108,19 @@ const summary = { destinationId: "earth", tutorial: true, credits: 240, + loanRepayment: 0, pilotXp: 10, discoveredLocationId: null, + scriptedId: "unmarked-crates", resolvedAt: 3_900_000, }; -const tutorial = { loanerLocationId: "earth", runInFlight: false }; +const tutorial = { + loanerLocationId: "earth", + runInFlight: false, + runsCompleted: 2, + dealAvailable: false, +}; +const goal = { id: "first-hire", status: "ready" }; const playerState = { company, ships: [ship], @@ -118,6 +130,7 @@ const playerState = { tutorial, activeDispatches: [dispatch], summaries: [summary], + goals: [goal], }; const cases: [string, z.ZodType, unknown, unknown][] = [ @@ -136,6 +149,15 @@ const cases: [string, z.ZodType, unknown, unknown][] = [ ["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }], ["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }], ["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }], + ["goal state", goalStateSchema, goal, { ...goal, status: "done" }], + ["repay loan request", repayLoanRequestSchema, { amount: 500 }, { amount: 0 }], + ["claim goal request", claimGoalRequestSchema, { goalId: "first-hire" }, {}], + [ + "company response", + companyResponseSchema, + { company }, + { company: { ...company, loanBalance: -1 } }, + ], ["player state", playerStateSchema, playerState, { ...playerState, ships: null }], [ "error",