diff --git a/DECISIONS.md b/DECISIONS.md index 486b24c..9e46a69 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -950,3 +950,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Success returns everything the server writes**: the new level, its cost, and in-shop-until (now + that level's shop time). - **`ship-in-shop` also blocks deadheads**, since a ship in the shop isn't going anywhere. It sits right after `ship-not-idle` in both validators. - **Costs are rounded to whole credits** like fuel; for current prices all three levels are exact. + +## Step 10I.4 + +- **Ships remember their latest shop job** (`shop_upgrade_id`, `shop_level` with `in_shop_until`) until the player acknowledges it, so the "out of the shop" line (Step 10I.8) knows what finished while they were away without a job table. The API shows it as `shop`, and `inShopUntil` only while the ship is actually in the shop. +- **Acknowledging a finished upgrade reuses `acknowledgeSummaries`** with optional `shipIds`, since both are cleared from the same "While you were away" modal. The request needs at least one id overall (so `dispatchIds` may be empty). +- **Effective stats are sent per ship** (`stats`), computed by the server with the sim, so every screen can show them without recomputing; the client still uses the sim for predictions. +- **Upgrade codes come from the sim's `UPGRADE_FAILURES`**, joining the dispatch and deadhead reasons in the API's error list; the new ones are `not-at-hq`, `ship-in-shop`, `upgrade-maxed` and `not-upgradable`. diff --git a/apps/server/drizzle/0006_cr009_ship_upgrades.sql b/apps/server/drizzle/0006_cr009_ship_upgrades.sql new file mode 100644 index 0000000..064593e --- /dev/null +++ b/apps/server/drizzle/0006_cr009_ship_upgrades.sql @@ -0,0 +1,11 @@ +CREATE TABLE "ship_upgrades" ( + "ship_id" text NOT NULL, + "upgrade_id" text NOT NULL, + "level" integer NOT NULL, + CONSTRAINT "ship_upgrades_ship_id_upgrade_id_pk" PRIMARY KEY("ship_id","upgrade_id") +); +--> statement-breakpoint +ALTER TABLE "ships" ADD COLUMN "in_shop_until" bigint;--> statement-breakpoint +ALTER TABLE "ships" ADD COLUMN "shop_upgrade_id" text;--> statement-breakpoint +ALTER TABLE "ships" ADD COLUMN "shop_level" integer;--> statement-breakpoint +ALTER TABLE "ship_upgrades" ADD CONSTRAINT "ship_upgrades_ship_id_ships_id_fk" FOREIGN KEY ("ship_id") REFERENCES "public"."ships"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0006_snapshot.json b/apps/server/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..05daa8a --- /dev/null +++ b/apps/server/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1403 @@ +{ + "id": "21666449-7a7a-42de-8e7c-bb0deec8658e", + "prevId": "412112de-94d1-4792-9691-27058a4e089b", + "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'" + }, + "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, + "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.ship_upgrades": { + "name": "ship_upgrades", + "schema": "", + "columns": { + "ship_id": { + "name": "ship_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upgrade_id": { + "name": "upgrade_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ship_upgrades_ship_id_ships_id_fk": { + "name": "ship_upgrades_ship_id_ships_id_fk", + "tableFrom": "ship_upgrades", + "tableTo": "ships", + "columnsFrom": [ + "ship_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ship_upgrades_ship_id_upgrade_id_pk": { + "name": "ship_upgrades_ship_id_upgrade_id_pk", + "columns": [ + "ship_id", + "upgrade_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ships": { + "name": "ships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "activity_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_shop_until": { + "name": "in_shop_until", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "shop_upgrade_id": { + "name": "shop_upgrade_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shop_level": { + "name": "shop_level", + "type": "integer", + "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 6f13b3e..3e9417b 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1791148094765, "tag": "0005_cr007_goods", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1791155978006, + "tag": "0006_cr009_ship_upgrades", + "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 a95ca31..ed00d21 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -65,11 +65,32 @@ export const ships = pgTable( status: activityStatus("status").notNull().default("idle"), /** Null while in flight. */ locationId: text("location_id"), + /** In the upgrade shop until this time (decision 0.5.66); null or past: not. */ + inShopUntil: time("in_shop_until"), + /** + * The latest shop job (upgrade and level), kept after it finishes until the + * player has seen it ("out of the shop"); null when none or acknowledged. + */ + shopUpgradeId: text("shop_upgrade_id"), + shopLevel: integer("shop_level"), createdAt: time("created_at").notNull(), }, (t) => [index("ships_player_idx").on(t.playerId)], ); +/** Installed upgrade levels per ship (CR-009). A missing row is level 0. */ +export const shipUpgrades = pgTable( + "ship_upgrades", + { + shipId: text("ship_id") + .notNull() + .references(() => ships.id, { onDelete: "cascade" }), + upgradeId: text("upgrade_id").notNull(), + level: integer("level").notNull(), + }, + (t) => [primaryKey({ columns: [t.shipId, t.upgradeId] })], +); + export const pilots = pgTable( "pilots", { diff --git a/apps/server/src/game/fleet.ts b/apps/server/src/game/fleet.ts index 420eeb4..764972a 100644 --- a/apps/server/src/game/fleet.ts +++ b/apps/server/src/game/fleet.ts @@ -6,6 +6,7 @@ import { and, count, eq, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { bays, companies, discoveredLocations, players, ships } from "../db/schema"; import { gameError } from "../http/errors"; +import { toApiShip } from "./mappers"; import { shipName } from "./names"; export async function requireCompany(tx: Queryable, playerId: string) { @@ -115,14 +116,9 @@ export async function buyShip( createdAt: now, }) .returning(); - return { - id: ship!.id, - modelId: ship!.modelId, - name: ship!.name, - status: "idle", - locationId: ship!.locationId, - dispatchId: null, - pilotId: null, - lastArrivalAt: ship!.createdAt, - }; + return toApiShip( + ship!, + { dispatchId: null, pilotId: null, lastArrivalAt: ship!.createdAt, upgrades: {} }, + now, + ); } diff --git a/apps/server/src/game/mappers.ts b/apps/server/src/game/mappers.ts index 93caf87..c25dd50 100644 --- a/apps/server/src/game/mappers.ts +++ b/apps/server/src/game/mappers.ts @@ -1,13 +1,15 @@ import { content } from "@deadhead/content"; -import type { Dispatch, Pilot } from "@deadhead/schema"; +import type { Dispatch, Pilot, Ship } from "@deadhead/schema"; import { dutyLimit, + effectiveStats, + isInShop, isResting, pilotLevel, skillPointsAvailable, type RealTime, } from "@deadhead/sim"; -import type { dispatches, pilots } from "../db/schema"; +import type { dispatches, pilots, ships } from "../db/schema"; /** Database status values use `in_flight`; the API uses `in-flight`. */ export function toApiStatus( @@ -57,3 +59,42 @@ export function toApiPilot( restEndsAt: resting ? row.restEndsAt : null, }; } + +/** A ship row as the API shows it, with its upgrades and effective stats (CR-009). */ +export function toApiShip( + row: typeof ships.$inferSelect, + extra: { + dispatchId: string | null; + pilotId: string | null; + lastArrivalAt: number; + upgrades: Record; + }, + now: RealTime, +): Ship { + const model = content.shipModel(row.modelId); + if (!model) throw new Error(`Ship ${row.id} has unknown model ${row.modelId}`); + const stats = effectiveStats(model, extra.upgrades, content); + return { + id: row.id, + modelId: row.modelId, + name: row.name, + status: toApiStatus(row.status), + locationId: row.status === "in_flight" ? null : row.locationId, + dispatchId: extra.dispatchId, + pilotId: extra.pilotId, + lastArrivalAt: extra.lastArrivalAt, + upgrades: extra.upgrades, + stats: { + cruiseSpeedKmPerGameHour: stats.cruiseSpeedKmPerGameHour, + cargoCapacityTonnes: stats.cargoCapacityTonnes, + fuelRangeMillionKm: stats.fuelRangeMillionKm, + overheadGameHours: stats.overheadGameHours, + handlingGameHoursPerTonne: stats.handlingGameHoursPerTonne, + }, + inShopUntil: isInShop(row, now) ? row.inShopUntil : null, + shop: + row.shopUpgradeId && row.shopLevel !== null && row.inShopUntil !== null + ? { upgradeId: row.shopUpgradeId, level: row.shopLevel, until: row.inShopUntil } + : null, + }; +} diff --git a/apps/server/src/game/player-state.ts b/apps/server/src/game/player-state.ts index b767f1f..4e4a82d 100644 --- a/apps/server/src/game/player-state.ts +++ b/apps/server/src/game/player-state.ts @@ -14,7 +14,8 @@ import { } from "../db/schema"; import { isDealAvailable } from "./deal"; import { goalStates } from "./goals"; -import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers"; +import { toApiDispatch, toApiPilot, toApiShip } from "./mappers"; +import { loadUpgradeLevels } from "./upgrades"; /** Reads everything the client needs about one player. */ export async function loadPlayerState( @@ -56,6 +57,10 @@ export async function loadPlayerState( const discoveredIds = new Set(discovered.map((d) => d.locationId)); const shipById = new Map(shipRows.map((s) => [s.id, s])); const activeByShip = new Map(active.filter((d) => d.shipId).map((d) => [d.shipId!, d.id])); + const upgradesByShip = await loadUpgradeLevels( + db, + shipRows.map((s) => s.id), + ); const lastArrivalByShip = new Map(arrivals.map((a) => [a.shipId, a.at])); const pilotByShip = new Map( pilotRows.filter((p) => p.assignedShipId).map((p) => [p.assignedShipId!, p.id]), @@ -68,16 +73,18 @@ export async function loadPlayerState( credits: company.credits, loanBalance: company.loanBalance, }, - ships: shipRows.map((s) => ({ - id: s.id, - modelId: s.modelId, - name: s.name, - status: toApiStatus(s.status), - locationId: s.status === "in_flight" ? null : s.locationId, - dispatchId: activeByShip.get(s.id) ?? null, - pilotId: pilotByShip.get(s.id) ?? null, - lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt, - })), + ships: shipRows.map((s) => + toApiShip( + s, + { + dispatchId: activeByShip.get(s.id) ?? null, + pilotId: pilotByShip.get(s.id) ?? null, + lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt, + upgrades: upgradesByShip.get(s.id) ?? {}, + }, + now, + ), + ), pilots: pilotRows.map((p) => toApiPilot( p, diff --git a/apps/server/src/game/upgrades.ts b/apps/server/src/game/upgrades.ts new file mode 100644 index 0000000..9cc8e3a --- /dev/null +++ b/apps/server/src/game/upgrades.ts @@ -0,0 +1,25 @@ +import type { UpgradeLevels } from "@deadhead/sim"; +import { inArray } from "drizzle-orm"; +import type { Queryable } from "../db/client"; +import { shipUpgrades } from "../db/schema"; + +/** Installed upgrade levels for each of the given ships (CR-009). */ +export async function loadUpgradeLevels( + tx: Queryable, + shipIds: string[], +): Promise>> { + const levels = new Map>(); + if (shipIds.length === 0) return levels; + const rows = await tx.select().from(shipUpgrades).where(inArray(shipUpgrades.shipId, shipIds)); + for (const row of rows) { + const ship = levels.get(row.shipId) ?? {}; + ship[row.upgradeId] = row.level; + levels.set(row.shipId, ship); + } + return levels; +} + +/** One ship's installed upgrade levels. */ +export async function shipUpgradeLevels(tx: Queryable, shipId: string): Promise { + return (await loadUpgradeLevels(tx, [shipId])).get(shipId) ?? {}; +} diff --git a/apps/server/test/migrations.test.ts b/apps/server/test/migrations.test.ts index 7ead101..c12ffea 100644 --- a/apps/server/test/migrations.test.ts +++ b/apps/server/test/migrations.test.ts @@ -43,6 +43,7 @@ describe("database migrations", () => { "pilots", "players", "session", + "ship_upgrades", "ships", "user", "verification", diff --git a/apps/web/test/attention.test.tsx b/apps/web/test/attention.test.tsx index d8d935e..1cdb78b 100644 --- a/apps/web/test/attention.test.tsx +++ b/apps/web/test/attention.test.tsx @@ -2,7 +2,7 @@ import type { PlayerState, Ship } from "@deadhead/schema"; import { screen, within } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { tabAttention } from "../src/layout/attention"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const ship = (overrides: Partial): Ship => ({ id: "s", @@ -12,6 +12,7 @@ const ship = (overrides: Partial): Ship => ({ locationId: "earth", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: null, ...overrides, }); diff --git a/apps/web/test/away-goals.test.tsx b/apps/web/test/away-goals.test.tsx index 48fe46a..3b5419a 100644 --- a/apps/web/test/away-goals.test.tsx +++ b/apps/web/test/away-goals.test.tsx @@ -3,7 +3,7 @@ import type { PlayerState } from "@deadhead/schema"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; function returningPlayer(acknowledged: boolean): PlayerState { return samplePlayerState({ @@ -18,6 +18,7 @@ function returningPlayer(acknowledged: boolean): PlayerState { locationId: "moon", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: "avatar", }, ], diff --git a/apps/web/test/away-summary.test.tsx b/apps/web/test/away-summary.test.tsx index 6fd7881..bf59090 100644 --- a/apps/web/test/away-summary.test.tsx +++ b/apps/web/test/away-summary.test.tsx @@ -2,7 +2,7 @@ import type { ResolutionSummary, Ship } from "@deadhead/schema"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const ship: Ship = { id: "s1", @@ -12,6 +12,7 @@ const ship: Ship = { locationId: "mars", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: "avatar", }; diff --git a/apps/web/test/board-refresh.test.tsx b/apps/web/test/board-refresh.test.tsx index 4007759..ae543df 100644 --- a/apps/web/test/board-refresh.test.tsx +++ b/apps/web/test/board-refresh.test.tsx @@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; import { formatClock } from "../src/format"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const period = boardPeriodMs(content.config); // A quarter of the way into a board period (derived from config, whatever its length). @@ -21,6 +21,7 @@ const courier: Ship = { locationId: "earth", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: "p1", }; const rae: Pilot = { diff --git a/apps/web/test/contract-card.test.tsx b/apps/web/test/contract-card.test.tsx index 72985a7..ac1b5be 100644 --- a/apps/web/test/contract-card.test.tsx +++ b/apps/web/test/contract-card.test.tsx @@ -3,7 +3,7 @@ import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { realTimeOf } from "../src/components/Goods"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.now(); @@ -36,6 +36,7 @@ const ship = (modelId: string): Ship => ({ dispatchId: null, pilotId: null, lastArrivalAt: 0, + ...shipExtras(modelId), }); function render(contracts: Contract[], ships: Ship[]) { diff --git a/apps/web/test/deal-modal.test.tsx b/apps/web/test/deal-modal.test.tsx index 1eb7a56..9e115e3 100644 --- a/apps/web/test/deal-modal.test.tsx +++ b/apps/web/test/deal-modal.test.tsx @@ -2,7 +2,7 @@ import { content } from "@deadhead/content"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; -import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render"; +import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, shipExtras } from "./render"; const offered = () => samplePlayerState({ @@ -33,6 +33,7 @@ const afterDeal = () => locationId: "earth", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: "avatar", }, ], diff --git a/apps/web/test/dispatch-duty.test.tsx b/apps/web/test/dispatch-duty.test.tsx index 95af04d..0b79918 100644 --- a/apps/web/test/dispatch-duty.test.tsx +++ b/apps/web/test/dispatch-duty.test.tsx @@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; import { formatGameHours } from "../src/format"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -18,6 +18,7 @@ const ship = (id: string, name: string, pilotId: string): Ship => ({ locationId: "earth", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId, }); diff --git a/apps/web/test/dispatch-priority.test.tsx b/apps/web/test/dispatch-priority.test.tsx index 668cb0e..7eabf1e 100644 --- a/apps/web/test/dispatch-priority.test.tsx +++ b/apps/web/test/dispatch-priority.test.tsx @@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; import { formatWindow } from "../src/components/Goods"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); const earth = content.location("earth")!; @@ -20,6 +20,7 @@ const ship = (id: string, name: string, modelId: string, pilotId: string): Ship locationId: "earth", dispatchId: null, lastArrivalAt: 0, + ...shipExtras(modelId), pilotId, }); diff --git a/apps/web/test/dispatch-problems.test.tsx b/apps/web/test/dispatch-problems.test.tsx index 2bff797..64c3bdc 100644 --- a/apps/web/test/dispatch-problems.test.tsx +++ b/apps/web/test/dispatch-problems.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; import { tutorialHint } from "../src/screens/HQ"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -47,6 +47,7 @@ function afterDeal(): PlayerState { locationId: "earth", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: "avatar", }, ], diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index 6f644b6..2c8449e 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -15,7 +15,7 @@ import { useClock } from "../src/clock/clock"; import { formatGameTime } from "../src/clock/GameClock"; import { formatCredits, formatDuration } from "../src/format"; import { formatLocalTime } from "../src/screens/DispatchSheet"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -27,6 +27,7 @@ const ship = (id: string, name: string, modelId: string, pilotId: string | null) locationId: "earth", dispatchId: null, lastArrivalAt: 0, + ...shipExtras(modelId), pilotId, }); diff --git a/apps/web/test/fleet.test.tsx b/apps/web/test/fleet.test.tsx index 6242ada..dc0e08b 100644 --- a/apps/web/test/fleet.test.tsx +++ b/apps/web/test/fleet.test.tsx @@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; import { formatCredits, formatDuration } from "../src/format"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -36,6 +36,7 @@ const ship = (overrides: Partial): Ship => ({ locationId: "earth", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: null, ...overrides, }); diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/hq.test.tsx index bb73191..64d490e 100644 --- a/apps/web/test/hq.test.tsx +++ b/apps/web/test/hq.test.tsx @@ -4,7 +4,7 @@ import { act, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -16,6 +16,7 @@ const ship = (id: string, status: Ship["status"]): Ship => ({ locationId: status === "idle" ? "earth" : null, dispatchId: status === "idle" ? null : `d-${id}`, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: null, }); diff --git a/apps/web/test/location-boards.test.tsx b/apps/web/test/location-boards.test.tsx index 884cdd6..5aab2a6 100644 --- a/apps/web/test/location-boards.test.tsx +++ b/apps/web/test/location-boards.test.tsx @@ -3,7 +3,7 @@ import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -57,6 +57,7 @@ const ship = (id: string, overrides: Partial): Ship => ({ dispatchId: null, pilotId: null, lastArrivalAt: NOW - 60_000, + ...shipExtras("courier"), ...overrides, }); diff --git a/apps/web/test/map-art-loading.test.ts b/apps/web/test/map-art-loading.test.ts index 4d56dcd..c309d9c 100644 --- a/apps/web/test/map-art-loading.test.ts +++ b/apps/web/test/map-art-loading.test.ts @@ -4,7 +4,7 @@ import { loadArt, resetArtRegistration, type AssetLoader } from "../src/map/art/ import { artManifest } from "../src/map/art/manifest"; import { sceneVisuals } from "../src/map/art/visuals"; import { buildScene } from "../src/map/scene"; -import { samplePlayerState } from "./render"; +import { samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -35,6 +35,7 @@ const flying = (id: string, modelId: string, dispatchId: string): Ship => ({ dispatchId, pilotId: null, lastArrivalAt: 0, + ...shipExtras(modelId), }); /** Ships of every model in flight, plus the tutorial loaner. */ diff --git a/apps/web/test/map-interactions.test.tsx b/apps/web/test/map-interactions.test.tsx index b9131b9..95fe914 100644 --- a/apps/web/test/map-interactions.test.tsx +++ b/apps/web/test/map-interactions.test.tsx @@ -6,7 +6,7 @@ import { useClock } from "../src/clock/clock"; import { DEFAULT_CAMERA, toScreen } from "../src/map/camera"; import { hitTest } from "../src/map/hitTest"; import { buildScene } from "../src/map/scene"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -42,6 +42,7 @@ function mapState(): PlayerState { locationId: null, dispatchId: "d1", lastArrivalAt: 0, + ...shipExtras("freighter"), pilotId: "p-rae", }, ], diff --git a/apps/web/test/map-overlays.test.ts b/apps/web/test/map-overlays.test.ts index 145c693..70bf55f 100644 --- a/apps/web/test/map-overlays.test.ts +++ b/apps/web/test/map-overlays.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { locationOverlays } from "../src/map/overlays"; import { buildScene } from "../src/map/scene"; import { mapStyle } from "../src/map/style"; -import { samplePlayerState } from "./render"; +import { samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); const idleAtMoon = (id: string): Ship => ({ @@ -15,6 +15,7 @@ const idleAtMoon = (id: string): Ship => ({ dispatchId: null, pilotId: null, lastArrivalAt: 0, + ...shipExtras("courier"), }); /** After the deal: an Earth bay, two idle ships at the Moon, Mars and beyond undiscovered. */ diff --git a/apps/web/test/map-scene.test.ts b/apps/web/test/map-scene.test.ts index fe66b87..ec1513e 100644 --- a/apps/web/test/map-scene.test.ts +++ b/apps/web/test/map-scene.test.ts @@ -2,7 +2,7 @@ import type { Dispatch } from "@deadhead/schema"; import { describe, expect, it } from "vitest"; import { DEFAULT_CAMERA, toScreen, zoomAt } from "../src/map/camera"; import { buildScene, flightMarker } from "../src/map/scene"; -import { samplePlayerState } from "./render"; +import { samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -64,6 +64,7 @@ describe("ship markers", () => { locationId: null, dispatchId: "d1", lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: "p1", }, { @@ -74,6 +75,7 @@ describe("ship markers", () => { locationId: "moon", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: null, }, { @@ -84,6 +86,7 @@ describe("ship markers", () => { locationId: "moon", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: null, }, ], diff --git a/apps/web/test/market.test.tsx b/apps/web/test/market.test.tsx index 7836da1..34d69c5 100644 --- a/apps/web/test/market.test.tsx +++ b/apps/web/test/market.test.tsx @@ -3,7 +3,7 @@ import type { Ship } from "@deadhead/schema"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const courierPrice = content.shipModel("courier")!.price; const freighterPrice = content.shipModel("freighter")!.price; @@ -47,6 +47,7 @@ describe("market screen", () => { locationId: "earth", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("freighter"), pilotId: null, }; let owned: Ship[] = []; @@ -102,6 +103,7 @@ describe("market screen", () => { dispatchId: null, pilotId: null, lastArrivalAt: 0, + ...shipExtras("courier"), }, }), }); diff --git a/apps/web/test/pilots.test.tsx b/apps/web/test/pilots.test.tsx index acf5169..89776f0 100644 --- a/apps/web/test/pilots.test.tsx +++ b/apps/web/test/pilots.test.tsx @@ -4,7 +4,7 @@ import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { levelProgress } from "../src/screens/Pilots"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const ship = (overrides: Partial): Ship => ({ id: "s", @@ -14,6 +14,7 @@ const ship = (overrides: Partial): Ship => ({ locationId: "moon", dispatchId: null, lastArrivalAt: 0, + ...shipExtras("courier"), pilotId: null, ...overrides, }); diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx index c7f9095..15635ec 100644 --- a/apps/web/test/render.tsx +++ b/apps/web/test/render.tsx @@ -1,4 +1,6 @@ -import type { PlayerState, RouteName, RouteRequest, RouteResponse } from "@deadhead/schema"; +import { content } from "@deadhead/content"; +import type { PlayerState, RouteName, RouteRequest, RouteResponse, Ship } from "@deadhead/schema"; +import { effectiveStats } from "@deadhead/sim"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render } from "@testing-library/react"; import { MemoryRouter } from "react-router"; @@ -32,6 +34,31 @@ export const unauthorized = () => { }; /** A brand-new tutorial player, as guest sign-up returns it. */ +/** + * The CR-009 fields of a ship with no upgrades and not in the shop: its model's + * stats as effective stats. Spread into Ship fixtures, then override as needed. + */ +export function shipExtras( + modelId: string, + upgrades: Record = {}, +): Pick { + const model = content.shipModel(modelId); + if (!model) throw new Error(`Unknown ship model ${modelId}`); + const s = effectiveStats(model, upgrades, content); + return { + upgrades, + stats: { + cruiseSpeedKmPerGameHour: s.cruiseSpeedKmPerGameHour, + cargoCapacityTonnes: s.cargoCapacityTonnes, + fuelRangeMillionKm: s.fuelRangeMillionKm, + overheadGameHours: s.overheadGameHours, + handlingGameHoursPerTonne: s.handlingGameHoursPerTonne, + }, + inShopUntil: null, + shop: null, + }; +} + export function samplePlayerState(overrides: Partial = {}): PlayerState { return { company: { id: "co1", name: "Deadhead Freight", credits: 0, loanBalance: 0 }, diff --git a/apps/web/test/weight-warning.test.tsx b/apps/web/test/weight-warning.test.tsx index 3b22310..a669538 100644 --- a/apps/web/test/weight-warning.test.tsx +++ b/apps/web/test/weight-warning.test.tsx @@ -2,7 +2,7 @@ import type { Contract, Ship } from "@deadhead/schema"; import { screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { carryLimit } from "../src/screens/LocationBoards"; -import { fakeApi, renderApp, samplePlayerState } from "./render"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.now(); @@ -34,6 +34,7 @@ const ship = (id: string, overrides: Partial): Ship => ({ dispatchId: null, pilotId: null, lastArrivalAt: 0, + ...shipExtras("courier"), ...overrides, }); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 8577bff..498a77b 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -25,7 +25,7 @@ Living reference for how the code is organised. Update it after any database sch |---|---| | `ServerTime` | `realTime`, `gameTime` | | `Company` | `id`, `name`, `credits`, `loanBalance` | -| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location) | +| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location), `upgrades` (`{ upgradeId: level }`), `stats` (effective: speed, capacity, range, overhead, handling per tonne), `inShopUntil` (null unless in the shop now), `shop` (`{ upgradeId, level, until }` of the latest job until acknowledged; CR-009) | | `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` | @@ -52,6 +52,7 @@ The `routes` catalog is the single source for both server and client. Each entry | `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes | | `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes | | `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes | +| `startUpgrade` | `POST /api/ships/:shipId/upgrades` | `shipId`, `upgradeId` (next level; ship idle at an HQ) | `{ ship }` | yes | | `buyShip` | `POST /api/ships` | `modelId`, `bayId` (one of the player's bays; the ship is delivered there) | `{ ship }` | yes | | `leaseBay` | `POST /api/bays` | `locationId` | `{ bay }` | yes | | `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes | @@ -59,7 +60,7 @@ The `routes` catalog is the single source for both server and client. Each entry | `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes | | `restPilot` | `POST /api/pilots/:pilotId/rest` | `pilotId` (idle, not resting) | `{ pilot }` | yes | | `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes | -| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes | +| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ 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 | @@ -72,7 +73,8 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri |---|---|---| | `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) | — | +| `ships` | Owned ships: model, name, status, location (null in flight), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009) | — | +| `ship_upgrades` | Installed upgrade level per ship and upgrade (CR-009) | primary key (ship, upgrade) | | `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004) | 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`) | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e606da1..776588a 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -229,7 +229,7 @@ Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006 - [x] **Step 10I.1 — Content: upgrades.** `upgradeSchema` (id, name, `stat` from `SHIP_STATS`, `percentPerLevel`, `maxLevel`, optional `modelOverrides`) and `data/upgrades.ts`: engines +10 %, cargo-bay +15 %, fuel-tanks +20 % (Courier +600 %, Freighter +15 %), landing-thrusters −15 %, handling-gear −15 %, all 3 levels; `upgradePercentPerLevel` / `upgradeMaxLevel`; `content.upgrades` / `content.upgrade(id)`. Config `upgradeLevels`: 10/20/35 % of price, 10/20/30 real minutes. Tests: `content/test/upgrades.test.ts` (validity, stats exist, decision 0.5.67), `sim/test/courier-mars.test.ts` (decision 0.5.68 over monthly samples for two game years). - [x] **Step 10I.2 — Sim: effective stats.** `sim/src/upgrades.ts`: `effectiveStats(model, levels, content)` → `EffectiveStats` (the model with upgraded stats plus its own `handlingGameHoursPerTonne`). `ShipState.upgrades` (optional) and `shipStats(ship, content)` feed every check in `validateDispatch`, `validateReposition` and dispatch records (capacity, range, travel time, Priority window). `travelTimeGameHours` uses the ship's handling time when given. Board fit uses each fleet member's effective capacity (`FleetMember.upgrades`). `SimContent` gains `upgrades`. Test: `effective-stats.test.ts`; all earlier sim tests unchanged. - [x] **Step 10I.3 — Sim: upgrade validation and cost.** `upgradeCost(model, level)` (whole credits, % of price), `upgradeShopMs(level)`, `nextUpgradeLevel`, `isInShop(ship, now)` and `validateUpgrade` with `UPGRADE_FAILURES` in check order: `tutorial-active`, `not-upgradable`, `ship-not-idle`, `ship-in-shop`, `not-at-hq`, `upgrade-maxed`, `insufficient-funds`; success returns the level, cost and in-shop-until. `ShipState.inShopUntil`; dispatch and deadhead validation fail `ship-in-shop` right after `ship-not-idle`. Test: `upgrade-validation.test.ts`. -- [ ] Step 10I.4 — Schema and database +- [x] **Step 10I.4 — Schema and database.** Migration `0006_cr009_ship_upgrades`: `ship_upgrades` (ship, upgrade, level; PK ship+upgrade), and on `ships` `in_shop_until`, `shop_upgrade_id`, `shop_level`. API `Ship` gains `upgrades`, `stats` (effective), `inShopUntil`, `shop`; one server mapper `toApiShip` (player state and purchases). Route `startUpgrade` (`POST /api/ships/:shipId/upgrades`). Upgrade failure reasons join the API error codes. `acknowledgeSummaries` takes optional `shipIds`. Web test helper `shipExtras(modelId)`. Dev database migrated. - [ ] Step 10I.5 — Server: start upgrade - [ ] Step 10I.6 — Client: upgrade shop - [ ] Step 10I.7 — Client: effective stats everywhere diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 1e16f57..72e29af 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -27,6 +27,16 @@ export const companySchema = z.object({ }); export type Company = z.infer; +/** A ship's stats after upgrades (CR-009). */ +export const effectiveStatsSchema = z.object({ + cruiseSpeedKmPerGameHour: z.number().positive(), + cargoCapacityTonnes: z.number().positive(), + fuelRangeMillionKm: z.number().positive(), + overheadGameHours: z.number().nonnegative(), + handlingGameHoursPerTonne: z.number().nonnegative(), +}); +export type EffectiveShipStats = z.infer; + export const shipSchema = z.object({ id: idSchema, modelId: idSchema, @@ -42,6 +52,19 @@ export const shipSchema = z.object({ * delivery if it has not flown. Picks the default Contracts location (CR-002). */ lastArrivalAt: timeSchema, + /** Installed upgrade levels by upgrade id; a missing upgrade is level 0 (CR-009). */ + upgrades: z.record(z.string(), z.number().int().positive()), + /** The model's stats after upgrades (decision 0.5.63). */ + stats: effectiveStatsSchema, + /** In the upgrade shop until then; null when not in the shop now. */ + inShopUntil: timeSchema.nullable(), + /** + * The latest shop job, until the player acknowledges it: in progress while + * `until` is in the future, finished ("out of the shop") once it has passed. + */ + shop: z + .object({ upgradeId: idSchema, level: z.number().int().positive(), until: timeSchema }) + .nullable(), }); export type Ship = z.infer; diff --git a/packages/schema/src/errors.ts b/packages/schema/src/errors.ts index bcdd97a..977cba4 100644 --- a/packages/schema/src/errors.ts +++ b/packages/schema/src/errors.ts @@ -1,8 +1,10 @@ import { DISPATCH_FAILURES, REPOSITION_FAILURES, + UPGRADE_FAILURES, type DispatchFailure, type RepositionFailure, + type UpgradeFailure, } from "@deadhead/sim"; import { z } from "zod"; @@ -35,11 +37,17 @@ export const SERVER_ERROR_CODES = [ "goal-already-claimed", ] as const; -export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number]; +export type ErrorCode = + DispatchFailure | RepositionFailure | UpgradeFailure | (typeof SERVER_ERROR_CODES)[number]; /** Every machine-readable error code. Sim validation reasons are used unchanged. */ export const ERROR_CODES = [ - ...new Set([...DISPATCH_FAILURES, ...REPOSITION_FAILURES, ...SERVER_ERROR_CODES]), + ...new Set([ + ...DISPATCH_FAILURES, + ...REPOSITION_FAILURES, + ...UPGRADE_FAILURES, + ...SERVER_ERROR_CODES, + ]), ] as [ErrorCode, ...ErrorCode[]]; export const errorCodeSchema = z.enum(ERROR_CODES); diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index 4da710d..449b045 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -49,6 +49,9 @@ export const dispatchResponseSchema = z.object({ dispatch: dispatchSchema }); export const buyShipRequestSchema = z.object({ modelId: idSchema, bayId: idSchema }); export const shipResponseSchema = z.object({ ship: shipSchema }); +/** CR-009: start the next level of an upgrade on a ship at an HQ. */ +export const startUpgradeRequestSchema = z.object({ shipId: idSchema, upgradeId: idSchema }); + export const leaseBayRequestSchema = z.object({ locationId: idSchema }); export const bayResponseSchema = z.object({ bay: baySchema }); @@ -61,9 +64,15 @@ export const restPilotRequestSchema = z.object({ pilotId: idSchema }); export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema }); export const pilotResponseSchema = z.object({ pilot: pilotSchema }); -export const acknowledgeSummariesRequestSchema = z.object({ - dispatchIds: z.array(idSchema).min(1), -}); +export const acknowledgeSummariesRequestSchema = z + .object({ + dispatchIds: z.array(idSchema), + /** Ships whose finished upgrade the player has now seen (CR-009). */ + shipIds: z.array(idSchema).optional(), + }) + .refine((r) => r.dispatchIds.length + (r.shipIds?.length ?? 0) > 0, { + message: "Acknowledge at least one summary or ship", + }); export const acknowledgeSummariesResponseSchema = z.object({ acknowledged: z.number().int().nonnegative(), }); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index e438b79..9936259 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -21,6 +21,7 @@ import { shipResponseSchema, spendSkillPointRequestSchema, restPilotRequestSchema, + startUpgradeRequestSchema, unassignPilotRequestSchema, } from "./requests"; @@ -87,6 +88,13 @@ export const routes = { response: shipResponseSchema, auth: true, }, + startUpgrade: { + method: "POST", + path: "/api/ships/:shipId/upgrades", + request: startUpgradeRequestSchema, + response: shipResponseSchema, + auth: true, + }, leaseBay: { method: "POST", path: "/api/bays", diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index eb07276..a45f335 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -20,6 +20,7 @@ describe("route catalog", () => { "assignPilot", "unassignPilot", "restPilot", + "startUpgrade", "spendSkillPoint", "acknowledgeSummaries", "acceptDeal", diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index 36b1f3c..bef6bc1 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -9,6 +9,7 @@ import type { z } from "zod"; import { acknowledgeSummariesRequestSchema, acknowledgeSummariesResponseSchema, + startUpgradeRequestSchema, assignPilotRequestSchema, baySchema, claimGoalRequestSchema, @@ -56,6 +57,16 @@ const ship = { dispatchId: null, pilotId: "p1", lastArrivalAt: 4_102_444_800_000, + upgrades: { "cargo-bay": 1 }, + stats: { + cruiseSpeedKmPerGameHour: 3_000_000, + cargoCapacityTonnes: 23, + fuelRangeMillionKm: 5, + overheadGameHours: 2, + handlingGameHoursPerTonne: 0.02, + }, + inShopUntil: null, + shop: { upgradeId: "cargo-bay", level: 1, until: 4_102_444_000_000 }, }; const pilot = { id: "p1", @@ -158,6 +169,24 @@ const cases: [string, z.ZodType, unknown, unknown][] = [ ], ["company", companySchema, company, { ...company, credits: 1.5 }], ["ship", shipSchema, ship, { ...ship, status: "lost" }], + [ + "ship in the shop", + shipSchema, + { ...ship, inShopUntil: 4_102_445_000_000 }, + { ...ship, upgrades: { engines: 0 } }, + ], + [ + "ship stats", + shipSchema, + { ...ship, shop: null }, + { ...ship, stats: { ...ship.stats, cargoCapacityTonnes: 0 } }, + ], + [ + "start upgrade request", + startUpgradeRequestSchema, + { shipId: "s1", upgradeId: "engines" }, + { shipId: "s1" }, + ], ["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }], [ "resting pilot", @@ -259,6 +288,12 @@ const cases: [string, z.ZodType, unknown, unknown][] = [ { dispatchIds: ["d0"] }, { dispatchIds: [] }, ], + [ + "acknowledge a finished upgrade", + acknowledgeSummariesRequestSchema, + { dispatchIds: [], shipIds: ["s1"] }, + { dispatchIds: [], shipIds: [] }, + ], ["acknowledge summaries response", acknowledgeSummariesResponseSchema, { acknowledged: 1 }, {}], ];