diff --git a/DECISIONS.md b/DECISIONS.md index 74852e1..41326eb 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -905,3 +905,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## Step 10G.5 - **Tutorial goods prefer one whose range holds the tier mass** instead of clamping the tier to a random good, so the CR-001 light/medium/full masses (5, 12, 20 t) and the tutorial's timing tests stay exact. Earth→Moon: Mail or Consumer Goods at 5 t, Packaged Food or Consumer Goods at 12 and 20 t; Moon→Earth: Souvenirs or Optical Glass at 5 t, Optical Glass at 12 and 20 t. + +## Step 10G.6 + +- **A backfill migration instead of a database reset.** The CR allows resetting, but contracts are JSONB, so a custom SQL migration can give old rows the new fields and keep saves. It uses a `pg_temp` helper function (drizzle runs all migrations in one session) and touches only contracts without a `goodId`, so rerunning is a no-op. +- **Summaries carry `goodId`, `massTonnes` and `shipper`** for Step 10G.10's "Delivered 6 t Medical Supplies for Meridian Health to the Moon". diff --git a/apps/server/drizzle/0005_cr007_goods.sql b/apps/server/drizzle/0005_cr007_goods.sql new file mode 100644 index 0000000..3ffed7b --- /dev/null +++ b/apps/server/drizzle/0005_cr007_goods.sql @@ -0,0 +1,36 @@ +-- CR-007 (Step 10G.6): contracts carry a good, a shipper and Priority fields. +-- Contracts live in JSONB (stored boards and dispatch snapshots), so no columns +-- change. Backfill contracts stored before the change so existing saves keep +-- working: a default good of the contract's class, the origin's first shipper, +-- and no Priority. Rows that already have a good are left alone, so running +-- this again changes nothing. +CREATE FUNCTION pg_temp.cr007_backfill(c jsonb) RETURNS jsonb AS $$ + SELECT CASE WHEN c ? 'goodId' THEN c ELSE c || jsonb_build_object( + 'goodId', CASE c->>'cargoTypeId' + WHEN 'heavy' THEN 'machine-parts' + WHEN 'high-value' THEN 'electronics' + WHEN 'dangerous' THEN 'industrial-chemicals' + ELSE 'consumer-goods' END, + 'shipper', CASE c->>'originId' + WHEN 'moon' THEN 'Shackleton Mining Co.' + WHEN 'mars' THEN 'Jezero Agricultural Co-op' + WHEN 'ceres-station' THEN 'Belt Ore Consolidated' + WHEN 'jupiter-cloud-platform' THEN 'Galilean Fuel Combine' + ELSE 'Pacific Orbital Logistics' END, + 'priority', false, + 'deliveryWindowGameHours', null + ) END +$$ LANGUAGE sql IMMUTABLE; +--> statement-breakpoint +UPDATE "contract_boards" +SET "contracts" = ( + SELECT COALESCE(jsonb_agg(pg_temp.cr007_backfill(t.c) ORDER BY t.ord), '[]'::jsonb) + FROM jsonb_array_elements("contracts") WITH ORDINALITY AS t(c, ord) +) +WHERE EXISTS ( + SELECT 1 FROM jsonb_array_elements("contracts") AS e(c) WHERE NOT (e.c ? 'goodId') +); +--> statement-breakpoint +UPDATE "dispatches" +SET "record" = jsonb_set("record", '{contract}', pg_temp.cr007_backfill("record"->'contract')) +WHERE jsonb_typeof("record"->'contract') = 'object' AND NOT ("record"->'contract' ? 'goodId'); diff --git a/apps/server/drizzle/meta/0005_snapshot.json b/apps/server/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..eff356e --- /dev/null +++ b/apps/server/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1332 @@ +{ + "id": "412112de-94d1-4792-9691-27058a4e089b", + "prevId": "e8a22e4a-d8ea-4ccf-a64b-5e4fd9fdea92", + "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", + "columnsFrom": [ + "player_id" + ], + "tableTo": "players", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "accepted_contracts_dispatch_id_dispatches_id_fk": { + "name": "accepted_contracts_dispatch_id_dispatches_id_fk", + "tableFrom": "accepted_contracts", + "columnsFrom": [ + "dispatch_id" + ], + "tableTo": "dispatches", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "bays_player_id_players_id_fk": { + "name": "bays_player_id_players_id_fk", + "tableFrom": "bays", + "columnsFrom": [ + "player_id" + ], + "tableTo": "players", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "player_id" + ], + "tableTo": "players", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "player_id" + ], + "tableTo": "players", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_player_id_unique": { + "name": "companies_player_id_unique", + "columns": [ + "player_id" + ], + "nullsNotDistinct": false + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "contract_boards_player_id_players_id_fk": { + "name": "contract_boards_player_id_players_id_fk", + "tableFrom": "contract_boards", + "columnsFrom": [ + "player_id" + ], + "tableTo": "players", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "player_id" + ], + "tableTo": "players", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "dispatches_player_id_players_id_fk": { + "name": "dispatches_player_id_players_id_fk", + "tableFrom": "dispatches", + "columnsFrom": [ + "player_id" + ], + "tableTo": "players", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "dispatches_ship_id_ships_id_fk": { + "name": "dispatches_ship_id_ships_id_fk", + "tableFrom": "dispatches", + "columnsFrom": [ + "ship_id" + ], + "tableTo": "ships", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "dispatches_pilot_id_pilots_id_fk": { + "name": "dispatches_pilot_id_pilots_id_fk", + "tableFrom": "dispatches", + "columnsFrom": [ + "pilot_id" + ], + "tableTo": "pilots", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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'" + }, + "duty_hours": { + "name": "duty_hours", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rest_ends_at": { + "name": "rest_ends_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "pilots_player_id_players_id_fk": { + "name": "pilots_player_id_players_id_fk", + "tableFrom": "pilots", + "columnsFrom": [ + "player_id" + ], + "tableTo": "players", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "pilots_assigned_ship_id_ships_id_fk": { + "name": "pilots_assigned_ship_id_ships_id_fk", + "tableFrom": "pilots", + "columnsFrom": [ + "assigned_ship_id" + ], + "tableTo": "ships", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pilots_assigned_ship_id_unique": { + "name": "pilots_assigned_ship_id_unique", + "columns": [ + "assigned_ship_id" + ], + "nullsNotDistinct": false + } + }, + "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", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "players_user_id_unique": { + "name": "players_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "ships_player_id_players_id_fk": { + "name": "ships_player_id_players_id_fk", + "tableFrom": "ships", + "columnsFrom": [ + "player_id" + ], + "tableTo": "players", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "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", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "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": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_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 a2e5847..6f13b3e 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1791078648879, "tag": "0004_cr004_pilot_duty", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1791148094765, + "tag": "0005_cr007_goods", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/game/player-state.ts b/apps/server/src/game/player-state.ts index 9f7fd5a..b767f1f 100644 --- a/apps/server/src/game/player-state.ts +++ b/apps/server/src/game/player-state.ts @@ -109,6 +109,9 @@ export async function loadPlayerState( tutorial: d.tutorial, deadhead: d.record.contract === null, fuelCost: d.record.fuelCost, + goodId: d.record.contract?.goodId ?? null, + massTonnes: d.record.contract?.massTonnes ?? null, + shipper: d.record.contract?.shipper ?? null, credits: d.resolution?.credits ?? 0, loanRepayment: d.resolution?.loanRepayment ?? 0, pilotXp: d.resolution?.pilotXp ?? 0, diff --git a/apps/server/test/goods-backfill.test.ts b/apps/server/test/goods-backfill.test.ts new file mode 100644 index 0000000..7f2949c --- /dev/null +++ b/apps/server/test/goods-backfill.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { contractBoardResponseSchema } from "@deadhead/schema"; +import { eq } from "drizzle-orm"; +import pg from "pg"; +import { describe, expect, it } from "vitest"; +import { contractBoards, dispatches } from "../src/db/schema"; +import { useTestDatabase } from "./db"; +import { + call, + getState, + insertDispatch, + putBoard, + signUpOwner, + testContract, + useTestApp, +} from "./game"; +import { testConfig } from "./helpers"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +const migration = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "../drizzle/0005_cr007_goods.sql"), + "utf8", +); + +/** Runs the CR-007 backfill in one session (it uses a pg_temp helper). */ +async function backfill() { + const client = new pg.Client({ connectionString: testConfig().databaseUrl }); + await client.connect(); + try { + for (const statement of migration.split("--> statement-breakpoint")) { + await client.query(statement); + } + } finally { + await client.end(); + } +} + +/** A contract as stored before CR-007: no good, shipper or Priority fields. */ +function legacy(overrides: Parameters[0] = {}) { + const { goodId, shipper, priority, deliveryWindowGameHours, ...old } = testContract(overrides); + void [goodId, shipper, priority, deliveryWindowGameHours]; + return old as ReturnType; +} + +describe("CR-007 backfill migration", () => { + it("gives stored boards and in-flight contracts a good, shipper and no Priority, once", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app, database().db); + const db = database().db; + await putBoard( + db, + owner.playerId, + [ + legacy({ id: "earth:1:0", cargoTypeId: "standard" }), + legacy({ id: "earth:1:1", cargoTypeId: "high-value", originId: "moon" }), + ], + clock.now, + ); + const record = await insertDispatch(db, { + playerId: owner.playerId, + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: clock.now, + contract: legacy({ cargoTypeId: "dangerous" }), + }); + + await backfill(); + const [board] = await db.select().from(contractBoards); + expect(board!.contracts.map((c) => [c.goodId, c.shipper, c.priority])).toEqual([ + ["consumer-goods", "Pacific Orbital Logistics", false], + ["electronics", "Shackleton Mining Co.", false], + ]); + const [row] = await db.select().from(dispatches).where(eq(dispatches.id, record.id)); + expect(row!.record.contract).toMatchObject({ + goodId: "industrial-chemicals", + priority: false, + deliveryWindowGameHours: null, + }); + + // A second run changes nothing. + await backfill(); + const [again] = await db.select().from(contractBoards); + expect(again!.contracts).toEqual(board!.contracts); + + // The API accepts the backfilled rows. + const state = await getState(app, owner); + expect(state.activeDispatches[0]!.contract!.goodId).toBe("industrial-chemicals"); + const response = await call(app, owner, "GET", "/api/locations/earth/board"); + expect(response.statusCode).toBe(200); + contractBoardResponseSchema.parse(response.json()); + }); +}); diff --git a/apps/web/test/away-goals.test.tsx b/apps/web/test/away-goals.test.tsx index 36debc5..48fe46a 100644 --- a/apps/web/test/away-goals.test.tsx +++ b/apps/web/test/away-goals.test.tsx @@ -34,6 +34,9 @@ function returningPlayer(acknowledged: boolean): PlayerState { credits: 538, deadhead: false, fuelCost: 0, + goodId: "consumer-goods", + massTonnes: 12, + shipper: "Pacific Orbital Logistics", loanRepayment: 230, pilotXp: 10, discoveredLocationId: null, diff --git a/apps/web/test/away-summary.test.tsx b/apps/web/test/away-summary.test.tsx index fc026bc..6d24361 100644 --- a/apps/web/test/away-summary.test.tsx +++ b/apps/web/test/away-summary.test.tsx @@ -26,6 +26,9 @@ const summaries: ResolutionSummary[] = [ credits: 51_200, deadhead: false, fuelCost: 0, + goodId: "consumer-goods", + massTonnes: 12, + shipper: "Pacific Orbital Logistics", loanRepayment: 0, pilotXp: 32, discoveredLocationId: "mars", @@ -43,6 +46,9 @@ const summaries: ResolutionSummary[] = [ credits: 240, deadhead: false, fuelCost: 0, + goodId: "consumer-goods", + massTonnes: 12, + shipper: "Pacific Orbital Logistics", loanRepayment: 0, pilotXp: 10, discoveredLocationId: null, @@ -60,6 +66,9 @@ describe("while you were away", () => { destinationId: "moon", deadhead: true, fuelCost: 230, + goodId: "consumer-goods", + massTonnes: 12, + shipper: "Pacific Orbital Logistics", credits: 0, pilotXp: 0, discoveredLocationId: null, diff --git a/apps/web/test/deal-modal.test.tsx b/apps/web/test/deal-modal.test.tsx index 50b190f..1eb7a56 100644 --- a/apps/web/test/deal-modal.test.tsx +++ b/apps/web/test/deal-modal.test.tsx @@ -84,6 +84,9 @@ describe("the deal", () => { credits: 96, deadhead: false, fuelCost: 0, + goodId: "consumer-goods", + massTonnes: 12, + shipper: "Pacific Orbital Logistics", loanRepayment: 0, pilotXp: 10, discoveredLocationId: null, diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/hq.test.tsx index 69ca5aa..bb73191 100644 --- a/apps/web/test/hq.test.tsx +++ b/apps/web/test/hq.test.tsx @@ -148,6 +148,9 @@ describe("loan on HQ", () => { credits: 538, deadhead: false, fuelCost: 0, + goodId: "consumer-goods", + massTonnes: 12, + shipper: "Pacific Orbital Logistics", loanRepayment: 230, pilotXp: 10, discoveredLocationId: null, diff --git a/apps/web/test/rest-summary-hq.test.tsx b/apps/web/test/rest-summary-hq.test.tsx index 648a53d..e51e3bf 100644 --- a/apps/web/test/rest-summary-hq.test.tsx +++ b/apps/web/test/rest-summary-hq.test.tsx @@ -35,6 +35,9 @@ const summary = (extra: Partial): ResolutionSummary => ({ credits: 500, deadhead: false, fuelCost: 0, + goodId: "consumer-goods", + massTonnes: 12, + shipper: "Pacific Orbital Logistics", loanRepayment: 0, pilotXp: 10, discoveredLocationId: null, diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index a4ae0a7..6a78ca8 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -151,6 +151,9 @@ describe("scripted delivery summary", () => { credits: 461, deadhead: false, fuelCost: 0, + goodId: "consumer-goods", + massTonnes: 12, + shipper: "Pacific Orbital Logistics", loanRepayment: 0, pilotXp: 10, discoveredLocationId: null, diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index b7d2b30..8577bff 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -29,16 +29,16 @@ Living reference for how the code is organised. Update it after any database sch | `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) | | `Bay` | `id`, `locationId` | | `LocationState` | `id`, `discovered` | -| `Contract` | mirrors sim `Contract` (optional `scriptedId` on a scripted tutorial contract) | +| `Contract` | mirrors sim `Contract`: route, `cargoTypeId` (class), `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (Priority only, else null), `tier`, `massTonnes` (to 0.1 t under 10 t), `payout`, requirements, `offeredAt`/`expiresAt`, `firstContact`, optional `scriptedId` on a scripted tutorial contract (CR-007) | | `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` | -| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` | +| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `deadhead`, `fuelCost`, `goodId` / `massTonnes` / `shipper` (null for a deadhead, CR-007), `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `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 -Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged, plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...). +Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged (CR-007 adds `too-slow`: the ship can't make a Priority contract's delivery window), plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...). ## API routes (`packages/schema/src/routes.ts`) @@ -66,7 +66,7 @@ The `routes` catalog is the single source for both server and client. Each entry ## Database (`apps/server/src/db`) -Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/drizzle/`, generated with `pnpm --filter @deadhead/server db:generate` and applied with `db:migrate` (uses `DATABASE_URL`; rerunning is a no-op). Every game table hangs off `players.id`; times are bigint Unix ms. +Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/drizzle/`, generated with `pnpm --filter @deadhead/server db:generate` and applied with `db:migrate` (uses `DATABASE_URL`; rerunning is a no-op). Contracts are JSONB snapshots (`contract_boards.contracts`, `dispatches.record`); `0005_cr007_goods` backfills pre-CR-007 contracts with a default good of their class, the origin's first shipper and no Priority. Every game table hangs off `players.id`; times are bigint Unix ms. | Table | Purpose | Key constraints | |---|---|---| diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 1d244d7..4d42f20 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -207,3 +207,4 @@ Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 / - [x] **Step 10G.3 — Sim: goods, Priority and shippers in board generation.** Class selection is unchanged (by `cargoClassMix`). Then `chooseGood` (band wins): a random good of the class on the route whose mass range overlaps the slot's band (the lightest band has no floor) with mass in both and within a fit ship's capacity; else the largest-range good as a bulk lot from the band. Masses under 10 t to 0.1 t, else whole tonnes. `rollPriority`: eligible goods become Priority with `priorityChance`; window = `priorityWindow` (reference model's trip time at offer × factor); a fit job is Priority only if its own ship makes the window. Shipper: random from the origin's list. Measured over 1,000 Earth boards (all discovered, seed 12345): Standard 76.04 % (baseline 76.79), High-Value 23.96 % (23.21); Priority on 13.6 % of eligible contracts. Bulk lots make Packaged Food and Electronics the most common goods in the big bands. Tests: `board-goods.test.ts` (determinism with and without a fleet; goods made at the origin and wanted at the destination except when no Standard good goes that way; mass rules; class mix ±3 points; Priority 10–20 % of eligible, only eligible goods, window = reference trip; shippers from the origin's list; `chooseGood` overlap, bulk and capacity cases). `board.test.ts` / `board-fleet.test.ts`: the lightest band's floor is 0 for goods. - [x] **Step 10G.4 — Sim: Priority validation.** `too-slow` joins `DISPATCH_FAILURES` after the route checks: a Priority contract fails when `contractTripGameHours` (cruise + overhead + handling, distance at departure; exported) exceeds `deliveryWindowGameHours`. Server message and the dispatch sheet's short reason added (the API error code follows from the sim's list). `priorityWindow` exported. Tests: `priority.test.ts` (Priority mail Earth→Moon with the Freighter reference: Courier and Freighter pass, Bulk Hauler fails `too-slow`; the trip is measured like the window; a normal contract never fails as too slow); `board-fleet.test.ts` now also requires fit Priority jobs to be within the crew's reach. - [x] **Step 10G.5 — Sim: tutorial goods.** Each tutorial slot carries a Standard good made at the loaner's location and wanted at the other end, chosen (seeded from location, run and period) among goods whose range holds the CR-001 tier mass, else clamped to a good's range; a random shipper from the origin; never Priority. On both routes some good holds 5, 12 and 20 t, so tutorial masses are unchanged; payouts now include the good's pay multiplier (e.g. Mail ×1.2). "Unmarked crates" is unchanged. Test: `tutorial.test.ts` (goods per direction over 6 runs × 20 periods, never Priority, shippers, masses 5/12/20; the existing scripted-run test still passes). +- [x] **Step 10G.6 — Schema and database.** API `Contract` (since 10G.2) carries `goodId`, `shipper`, `priority`, `deliveryWindowGameHours`; `ResolutionSummary` gains `goodId`, `massTonnes`, `shipper` (null for a deadhead); `too-slow` is an API error code via the sim's list. Contracts are JSONB, so no columns change: migration `0005_cr007_goods` (custom SQL) backfills stored boards and dispatch snapshots without a good (default good of the class, origin's first shipper, no Priority) and skips rows that have one. Dev and screenshot databases migrated (all 48 stored dev contracts now carry a good). `architecture.md` updated. Tests: `goods-backfill.test.ts` (legacy board and in-flight contract are filled, a second run changes nothing, the API then serves them), `migrations.test.ts` unchanged, schema cases for Priority windows, empty goods and summary goods. diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 08a6c1d..1e16f57 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -137,6 +137,10 @@ export const resolutionSummarySchema = z.object({ deadhead: z.boolean(), /** Fuel paid at departure. */ fuelCost: credits.nonnegative(), + /** What was delivered and for whom (CR-007); null for a deadhead. */ + goodId: idSchema.nullable(), + massTonnes: z.number().positive().nullable(), + shipper: z.string().min(1).nullable(), /** Credits added after pilot pay and loan repayment. */ credits, /** Credits the lender took from this payout (decision 0.5.32). */ diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index 052ee61..36b1f3c 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -120,6 +120,9 @@ const summary = { credits: 240, deadhead: false, fuelCost: 0, + goodId: "consumer-goods", + massTonnes: 12, + shipper: "Pacific Orbital Logistics", loanRepayment: 0, pilotXp: 10, discoveredLocationId: null, @@ -167,6 +170,19 @@ const cases: [string, z.ZodType, unknown, unknown][] = [ ["bay", baySchema, bay, { id: "b1" }], ["location", locationStateSchema, location, { id: "mars", discovered: "no" }], ["contract", contractSchema, contract, { ...contract, tier: -1 }], + [ + "priority contract (CR-007)", + contractSchema, + { ...contract, priority: true, deliveryWindowGameHours: 4.5 }, + { ...contract, priority: true, deliveryWindowGameHours: -1 }, + ], + ["contract without a good", contractSchema, contract, { ...contract, goodId: "" }], + [ + "delivery summary goods (CR-007)", + resolutionSummarySchema, + { ...summary, goodId: null, massTonnes: null, shipper: null }, + { ...summary, massTonnes: 0 }, + ], ["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }], ["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }], [