From b3d4975111fce240c6e7de735403524b2373889a Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 14:49:05 -0400 Subject: [PATCH] Step 10B.3: key contract boards by location Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 + .../drizzle/0003_cr002_location_boards.sql | 5 + apps/server/drizzle/meta/0003_snapshot.json | 1319 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/src/db/schema.ts | 2 +- apps/server/src/game/boards.ts | 6 +- apps/server/src/game/dispatch.ts | 11 +- apps/server/test/board-keys.test.ts | 30 + apps/server/test/game.ts | 14 +- memory-bank/architecture.md | 4 +- memory-bank/progress.md | 2 +- 11 files changed, 1391 insertions(+), 14 deletions(-) create mode 100644 apps/server/drizzle/0003_cr002_location_boards.sql create mode 100644 apps/server/drizzle/meta/0003_snapshot.json create mode 100644 apps/server/test/board-keys.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index ac09c81..0cb065b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -627,3 +627,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Deadhead checks, in order:** ship idle, pilot idle, pilot assigned to the ship, destination not the current location (`already-there`), destination discovered (`location-undiscovered`), landing, range, fuel credits. Bays play no part. - **`no-bay-at-destination` is gone** from sim reasons and API error codes; `location-undiscovered` (already a server code) is now also a sim reason. - **The server change landed in this step** (it must pass `discoveredIds` to compile), so the Step 6.11 "no bay at destination" test became "undiscovered destination" here; Step 10B.5 adds the stranded-ship scenario. + +## Step 10B.3 + +- **The board key was already per location:** (player, kind, location, period, tutorial run) since CR-001. This step renames the `board_kind` value `bay` to `location`, so the stored data says what it is. +- **The migration relabels existing `bay` rows as `location`** in one `UPDATE` while the column is temporarily text. That keeps the user's development database usable without a reset; it is a one-line relabel, not a data migration of board contents. diff --git a/apps/server/drizzle/0003_cr002_location_boards.sql b/apps/server/drizzle/0003_cr002_location_boards.sql new file mode 100644 index 0000000..2f46d32 --- /dev/null +++ b/apps/server/drizzle/0003_cr002_location_boards.sql @@ -0,0 +1,5 @@ +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"; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0003_snapshot.json b/apps/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..20a121d --- /dev/null +++ b/apps/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1319 @@ +{ + "id": "85b1ffb1-0667-4aee-89aa-5d9daf642cbb", + "prevId": "ee1be5c3-4dc4-417d-9b1a-dac302bb4a2c", + "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": [ + "location", + "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 0a595a8..1648858 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1791039630016, "tag": "0002_cr001_faster_start", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1791053200611, + "tag": "0003_cr002_location_boards", + "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 a362106..130d71f 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -18,7 +18,7 @@ const time = (name: string) => bigint(name, { mode: "number" }); export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]); export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]); -export const boardKind = pgEnum("board_kind", ["bay", "tutorial"]); +export const boardKind = pgEnum("board_kind", ["location", "tutorial"]); export const players = pgTable("players", { id: text("id").primaryKey(), diff --git a/apps/server/src/game/boards.ts b/apps/server/src/game/boards.ts index 0598db1..06302e1 100644 --- a/apps/server/src/game/boards.ts +++ b/apps/server/src/game/boards.ts @@ -33,10 +33,10 @@ export async function getOrCreateBoard( tx: Queryable, key: { playerId: string; - kind: "bay" | "tutorial"; + kind: "location" | "tutorial"; locationId: string; periodIndex: number; - /** Tutorial boards: runs completed (CR-001). Bay boards: 0. */ + /** Tutorial boards: runs completed (CR-001). Location boards: 0. */ tutorialRun?: number; }, generate: () => Contract[] | Promise, @@ -140,7 +140,7 @@ export async function bayBoard( const periodIndex = boardPeriodIndex(now, content.config); const contracts = await getOrCreateBoard( tx, - { playerId, kind: "bay", locationId, periodIndex }, + { playerId, kind: "location", locationId, periodIndex }, async () => { const discovered = await tx .select({ locationId: discoveredLocations.locationId }) diff --git a/apps/server/src/game/dispatch.ts b/apps/server/src/game/dispatch.ts index 546cbdf..8b61866 100644 --- a/apps/server/src/game/dispatch.ts +++ b/apps/server/src/game/dispatch.ts @@ -39,7 +39,7 @@ export const LOANER_SHIP_ID = "loaner"; export async function findBoardContract( tx: Queryable, playerId: string, - kind: "bay" | "tutorial", + kind: "location" | "tutorial", contractId: string, ): Promise<{ contract: Contract; periodIndex: number } | undefined> { const [board] = await tx @@ -60,7 +60,7 @@ export async function findBoardContract( export async function claimableContract( tx: Queryable, playerId: string, - kind: "bay" | "tutorial", + kind: "location" | "tutorial", contractId: string, ): Promise<{ contract: Contract; periodIndex: number }> { const found = await findBoardContract(tx, playerId, kind, contractId); @@ -212,7 +212,12 @@ export async function dispatchContract( input: { contractId: string; shipId: string; pilotId: string }, now: RealTime, ): Promise { - const { contract, periodIndex } = await claimableContract(tx, playerId, "bay", input.contractId); + 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); diff --git a/apps/server/test/board-keys.test.ts b/apps/server/test/board-keys.test.ts new file mode 100644 index 0000000..e27cb9f --- /dev/null +++ b/apps/server/test/board-keys.test.ts @@ -0,0 +1,30 @@ +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(); + }); +}); diff --git a/apps/server/test/game.ts b/apps/server/test/game.ts index 3527871..da63e57 100644 --- a/apps/server/test/game.ts +++ b/apps/server/test/game.ts @@ -186,13 +186,19 @@ export async function insertDispatch( return record; } -/** Stores a hand-made Earth bay board for the current period. */ -export async function putBoard(db: Db, playerId: string, contracts: Contract[], now = TEST_NOW) { +/** 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: "bay", - locationId: "earth", + kind: "location", + locationId, periodIndex: boardPeriodIndex(now, content.config), contracts, createdAt: now, diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 33383f9..2367c35 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -75,7 +75,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri | `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 (tutorial boards also per run) (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) | +| `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (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`) | @@ -106,7 +106,7 @@ const time = (name: string) => bigint(name, { mode: "number" }); export const activityStatus = pgEnum("activity_status", ["idle", "in_flight"]); export const dispatchStatus = pgEnum("dispatch_status", ["in_flight", "resolved"]); -export const boardKind = pgEnum("board_kind", ["bay", "tutorial"]); +export const boardKind = pgEnum("board_kind", ["location", "tutorial"]); export const players = pgTable("players", { id: text("id").primaryKey(), diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 8dafb01..e34eeb4 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -136,7 +136,7 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record - [x] **Step 10B.1 — Sim: boards at any location.** `BoardInput.location` (was `origin`) is the board's location; no bay notion in sim board generation. Moon board test added. - [x] **Step 10B.2 — Sim: deadhead validation.** `validateReposition` takes `discoveredIds` (not bays): any origin, destination discovered (`location-undiscovered`) and not the current location, range and landing. `REPOSITION_FAILURES` swaps `no-bay-at-destination` for `location-undiscovered`. Server passes the player's discovered locations. -- [ ] Step 10B.3 — Database: boards keyed by location +- [x] **Step 10B.3 — Database: boards keyed by location.** Migration `0003_cr002_location_boards`: `board_kind` is now `location` / `tutorial` (old `bay` rows relabelled); unique key (player, kind, location, period, tutorial run) means one board per player, location and period. Test: two locations coexist, a duplicate is rejected. - [ ] Step 10B.4 — Server: board endpoint for any discovered location - [ ] Step 10B.5 — Server: no more stranded ships - [ ] Step 10B.6 — Server: ship delivery to a bay