From 9eae5ae64d5e4d4a9e635579537af870377499f2 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 21:40:14 -0400 Subject: [PATCH] Step 10J.4: the veteran pilot comes with the deal Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 + .../drizzle/0007_cr010_veteran_pilot.sql | 1 + apps/server/drizzle/meta/0007_snapshot.json | 1410 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/src/db/schema.ts | 2 + apps/server/src/game/deal.ts | 16 + apps/server/src/game/goals.ts | 17 +- apps/server/src/game/mappers.ts | 1 + apps/server/test/veteran.test.ts | 147 ++ apps/web/test/api-client.test.ts | 1 + apps/web/test/attention.test.tsx | 1 + apps/web/test/board-refresh.test.tsx | 1 + apps/web/test/dispatch-duty.test.tsx | 1 + apps/web/test/dispatch-priority.test.tsx | 1 + apps/web/test/dispatch-sheet.test.tsx | 1 + apps/web/test/effective-stats-ui.test.tsx | 1 + apps/web/test/fleet.test.tsx | 1 + apps/web/test/map-interactions.test.tsx | 1 + apps/web/test/pilots-duty.test.tsx | 1 + apps/web/test/pilots.test.tsx | 1 + apps/web/test/render.tsx | 1 + apps/web/test/rest-summary-hq.test.tsx | 1 + memory-bank/architecture.md | 4 +- memory-bank/progress.md | 2 +- packages/schema/src/entities.ts | 2 + packages/schema/test/schemas.test.ts | 1 + 26 files changed, 1624 insertions(+), 5 deletions(-) create mode 100644 apps/server/drizzle/0007_cr010_veteran_pilot.sql create mode 100644 apps/server/drizzle/meta/0007_snapshot.json create mode 100644 apps/server/test/veteran.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index ec4c45f..0ca6137 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1012,3 +1012,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **New goal id `veteran-contract`** (was `first-hire`), with a new condition kind `veteran-contracts`. A new id keeps any old "first hire" claim from counting as this different goal; `pilots-hired` stays available as a condition kind but no goal uses it now. - **The title is built from content** (`Fly a contract with ${veteranPilot.name}`), so renaming the veteran renames the goal. - **Counting is the server's job**: the sim only compares `veteranContracts` to the goal; which deliveries count (contracts, not deadheads or tutorial runs, flown by the veteran) is decided where they are stored (10J.4). + +## Step 10J.4 + +- **The veteran is a pilots row with `is_veteran`**, otherwise identical to a hire (pay, duty, rest, assignment rules). The flag only identifies them for goal 4 and the bio line. +- **Level 2 is stored as XP**: the veteran starts with exactly level 2's threshold (250 XP), so levels and skill points come from the normal rules; one point is recorded as spent on Heavy Cargo 1. +- **Created inside `acceptDeal`**, after the deal check, in the player-locked transaction: a refused or repeated accept creates nothing (tested). diff --git a/apps/server/drizzle/0007_cr010_veteran_pilot.sql b/apps/server/drizzle/0007_cr010_veteran_pilot.sql new file mode 100644 index 0000000..cad993e --- /dev/null +++ b/apps/server/drizzle/0007_cr010_veteran_pilot.sql @@ -0,0 +1 @@ +ALTER TABLE "pilots" ADD COLUMN "is_veteran" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0007_snapshot.json b/apps/server/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..00e121e --- /dev/null +++ b/apps/server/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1410 @@ +{ + "id": "4d1e7e7c-f079-46db-8c00-bfe58ba49f73", + "prevId": "21666449-7a7a-42de-8e7c-bb0deec8658e", + "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 + }, + "is_veteran": { + "name": "is_veteran", + "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 3e9417b..0c382ac 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1791155978006, "tag": "0006_cr009_ship_upgrades", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1791164145228, + "tag": "0007_cr010_veteran_pilot", + "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 ed00d21..8178713 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -100,6 +100,8 @@ export const pilots = pgTable( .references(() => players.id, { onDelete: "cascade" }), name: text("name").notNull(), isAvatar: boolean("is_avatar").notNull().default(false), + /** The veteran who came with the deal (decision 0.5.70); otherwise a normal hired pilot. */ + isVeteran: boolean("is_veteran").notNull().default(false), xp: integer("xp").notNull().default(0), skills: jsonb("skills").$type>().notNull().default({}), skillPointsSpent: integer("skill_points_spent").notNull().default(0), diff --git a/apps/server/src/game/deal.ts b/apps/server/src/game/deal.ts index c5c4d34..af2500a 100644 --- a/apps/server/src/game/deal.ts +++ b/apps/server/src/game/deal.ts @@ -71,6 +71,22 @@ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime) .update(pilots) .set({ assignedShipId: shipId, locationId: null, status: "idle" }) .where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true))); + // Halvorsen's veteran comes with the business (decision 0.5.70): at Earth, + // unassigned, at their level with their skills bought and the rest unspent. + const veteran = content.veteranPilot; + const xpForLevel = + veteran.level === 0 ? 0 : content.config.pilotLevelXpThresholds[veteran.level - 1]!; + await tx.insert(pilots).values({ + id: randomUUID(), + playerId, + name: veteran.name, + isVeteran: true, + xp: xpForLevel, + skills: veteran.skills, + skillPointsSpent: Object.values(veteran.skills).reduce((a, b) => a + b, 0), + locationId: DEAL_BAY_LOCATION_ID, + createdAt: now, + }); await tx .update(companies) .set({ loanBalance: content.config.dealLoanAmount }) diff --git a/apps/server/src/game/goals.ts b/apps/server/src/game/goals.ts index e9a76b8..4df286d 100644 --- a/apps/server/src/game/goals.ts +++ b/apps/server/src/game/goals.ts @@ -32,6 +32,20 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise 'contract' <> 'null'::jsonb`, ), ); + // Contracts (not deadheads or tutorial runs) the veteran has delivered (CR-010). + const [veteranContracts] = await tx + .select({ n: count() }) + .from(dispatches) + .innerJoin(pilots, eq(pilots.id, dispatches.pilotId)) + .where( + and( + eq(dispatches.playerId, playerId), + eq(dispatches.status, "resolved"), + eq(dispatches.tutorial, false), + eq(pilots.isVeteran, true), + sql`${dispatches.record} -> 'contract' <> 'null'::jsonb`, + ), + ); const [spent] = await tx .select({ n: sum(pilots.skillPointsSpent) }) .from(pilots) @@ -55,8 +69,7 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise + errorResponseSchema.parse(response.json()).error.code; +const veteranOf = (state: PlayerState) => state.pilots.find((p) => p.isVeteran)!; +const avatarOf = (state: PlayerState) => state.pilots.find((p) => p.isAvatar)!; +const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z"); + +/** A guest who flies the tutorial runs and accepts the deal. */ +async function dealt(): Promise<{ guest: Guest; state: PlayerState }> { + const guest = await signUp(testApp().app); + for (let run = 0; run < content.config.tutorialRunsBeforeDeal; run++) { + await flyTutorialRun(testApp(), guest); + } + const response = await call(testApp().app, guest, "POST", "/api/deal/accept", {}); + expect(response.statusCode).toBe(200); + return { guest, state: playerStateSchema.parse(response.json()) }; +} + +describe("the veteran comes with the deal (Step 10J.4)", () => { + it("adds the veteran at Earth, unassigned, level 2 with Heavy Cargo 1", async () => { + const { state } = await dealt(); + expect(state.pilots).toHaveLength(2); + const courier = state.ships[0]!; + expect(avatarOf(state)).toMatchObject({ assignedShipId: courier.id, locationId: "earth" }); + const spentPoints = 1; + expect(veteranOf(state)).toMatchObject({ + name: content.veteranPilot.name, + isAvatar: false, + isVeteran: true, + level: 2, + skills: { "heavy-cargo": 1 }, + skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints, + assignedShipId: null, + locationId: "earth", + status: "idle", + }); + }); + + it("creates no veteran on a refused or repeated accept", async () => { + const guest = await signUp(testApp().app); + const early = await call(testApp().app, guest, "POST", "/api/deal/accept", {}); + expect(errorCode(early)).toBe("deal-not-available"); + expect((await getState(testApp().app, guest)).pilots).toHaveLength(1); + + const { guest: owner } = await dealt(); + const again = await call(testApp().app, owner, "POST", "/api/deal/accept", {}); + expect(again.statusCode).toBe(409); + const pilots = (await getState(testApp().app, owner)).pilots; + expect(pilots.filter((p) => p.isVeteran)).toHaveLength(1); + }); + + it("swaps in the veteran while the avatar rests, and pays the veteran a profit share", async () => { + const { app, clock } = testApp(); + const { guest, state } = await dealt(); + const courier = state.ships[0]!; + const veteran = veteranOf(state); + await setCredits(database().db, guest.playerId, 10_000); + + expect( + (await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/rest`, {})).statusCode, + ).toBe(200); + expect( + (await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {})).statusCode, + ).toBe(200); + expect( + (await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id })) + .statusCode, + ).toBe(200); + + const job = testContract({ payout: 2000, expiresAt: FAR_FUTURE }); + await putBoard(database().db, guest.playerId, [job], clock.now); + const response = await call(app, guest, "POST", "/api/dispatches", { + kind: "contract", + contractId: job.id, + shipId: courier.id, + pilotId: veteran.id, + }); + expect(response.statusCode).toBe(200); + const { dispatch } = dispatchResponseSchema.parse(response.json()); + + clock.now = dispatch.arrivalAt; + const after = await getState(app, guest); + const summary = after.summaries.find((s) => s.dispatchId === dispatch.id)!; + const pay = pilotShare(job.payout, dispatch.fuelCost, content.config); + expect(pay).toBeGreaterThan(0); + expect(summary.credits + summary.loanRepayment).toBe(job.payout - pay); + expect(after.goals.find((g) => g.id === "veteran-contract")?.status).toBe("ready"); + }); + + it("counts only contracts the veteran flew toward goal 4", async () => { + const { app, clock } = testApp(); + const { guest, state } = await dealt(); + const courier = state.ships[0]!; + const veteran = veteranOf(state); + const goal = async () => + (await getState(app, guest)).goals.find((g) => g.id === "veteran-contract")?.status; + + // The avatar's delivery doesn't count. + const byAvatar = await insertDispatch(database().db, { + playerId: guest.playerId, + shipId: courier.id, + pilotId: guest.avatarId, + departAt: clock.now, + contract: testContract({ expiresAt: FAR_FUTURE }), + }); + clock.now = byAvatar.arrivalAt; + expect(await goal()).toBe("incomplete"); + + // A deadhead by the veteran doesn't count either. + await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`, {}); + await call(app, guest, "POST", `/api/pilots/${veteran.id}/assign`, { shipId: courier.id }); + const deadhead = await insertDispatch(database().db, { + playerId: guest.playerId, + shipId: courier.id, + pilotId: veteran.id, + departAt: clock.now, + contract: null, + destinationId: "earth", + }); + clock.now = deadhead.arrivalAt; + expect(await goal()).toBe("incomplete"); + }); +}); diff --git a/apps/web/test/api-client.test.ts b/apps/web/test/api-client.test.ts index 1d787c8..4ba51c8 100644 --- a/apps/web/test/api-client.test.ts +++ b/apps/web/test/api-client.test.ts @@ -31,6 +31,7 @@ describe("typed API client", () => { id: "p1", name: "Rae Okafor", isAvatar: false, + isVeteran: false, xp: 0, level: 0, skills: {}, diff --git a/apps/web/test/attention.test.tsx b/apps/web/test/attention.test.tsx index 1cdb78b..b38259f 100644 --- a/apps/web/test/attention.test.tsx +++ b/apps/web/test/attention.test.tsx @@ -43,6 +43,7 @@ describe("navigation attention badges (Step 10E.3)", () => { ...avatar, id: "p2", isAvatar: false, + isVeteran: false, assignedShipId: "s2", resting: true, restEndsAt: 1, diff --git a/apps/web/test/board-refresh.test.tsx b/apps/web/test/board-refresh.test.tsx index ae543df..c0dbf8a 100644 --- a/apps/web/test/board-refresh.test.tsx +++ b/apps/web/test/board-refresh.test.tsx @@ -28,6 +28,7 @@ const rae: Pilot = { id: "p1", name: "Rae Okafor", isAvatar: false, + isVeteran: false, xp: 0, level: 0, skills: {}, diff --git a/apps/web/test/dispatch-duty.test.tsx b/apps/web/test/dispatch-duty.test.tsx index 0b79918..8d0b72e 100644 --- a/apps/web/test/dispatch-duty.test.tsx +++ b/apps/web/test/dispatch-duty.test.tsx @@ -26,6 +26,7 @@ const pilot = (id: string, name: string, shipId: string, extra: Partial): id, name, isAvatar: false, + isVeteran: false, xp: 0, level: 0, skills: {}, diff --git a/apps/web/test/dispatch-priority.test.tsx b/apps/web/test/dispatch-priority.test.tsx index 7eabf1e..82c646a 100644 --- a/apps/web/test/dispatch-priority.test.tsx +++ b/apps/web/test/dispatch-priority.test.tsx @@ -28,6 +28,7 @@ const pilot = (id: string, name: string, shipId: string): Pilot => ({ id, name, isAvatar: false, + isVeteran: false, xp: 0, level: 0, skills: {}, diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index dc5f650..36e927f 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -36,6 +36,7 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil id, name, isAvatar, + isVeteran: false, xp: 0, level: 0, skills: {}, diff --git a/apps/web/test/effective-stats-ui.test.tsx b/apps/web/test/effective-stats-ui.test.tsx index bb8af53..b39b46b 100644 --- a/apps/web/test/effective-stats-ui.test.tsx +++ b/apps/web/test/effective-stats-ui.test.tsx @@ -26,6 +26,7 @@ const pilot: Pilot = { id: "p1", name: "Rae Okafor", isAvatar: false, + isVeteran: false, xp: 0, level: 0, skills: {}, diff --git a/apps/web/test/fleet.test.tsx b/apps/web/test/fleet.test.tsx index dc0e08b..6136470 100644 --- a/apps/web/test/fleet.test.tsx +++ b/apps/web/test/fleet.test.tsx @@ -68,6 +68,7 @@ function fleetState(overrides: Partial = {}): PlayerState { id: "p2", name: "Rae Okafor", isAvatar: false, + isVeteran: false, assignedShipId: "s-fly", status: "in-flight", locationId: null, diff --git a/apps/web/test/map-interactions.test.tsx b/apps/web/test/map-interactions.test.tsx index 95fe914..09fe440 100644 --- a/apps/web/test/map-interactions.test.tsx +++ b/apps/web/test/map-interactions.test.tsx @@ -53,6 +53,7 @@ function mapState(): PlayerState { id: "p-rae", name: "Rae Okafor", isAvatar: false, + isVeteran: false, assignedShipId: "s1", status: "in-flight", locationId: null, diff --git a/apps/web/test/pilots-duty.test.tsx b/apps/web/test/pilots-duty.test.tsx index 53138e4..10557c7 100644 --- a/apps/web/test/pilots-duty.test.tsx +++ b/apps/web/test/pilots-duty.test.tsx @@ -11,6 +11,7 @@ const pilot = (overrides: Partial): Pilot => ({ id: "p", name: "Pilot", isAvatar: false, + isVeteran: false, xp: 0, level: 0, skills: {}, diff --git a/apps/web/test/pilots.test.tsx b/apps/web/test/pilots.test.tsx index 89776f0..e602b9a 100644 --- a/apps/web/test/pilots.test.tsx +++ b/apps/web/test/pilots.test.tsx @@ -23,6 +23,7 @@ const rae: Pilot = { id: "p-rae", name: "Rae Okafor", isAvatar: false, + isVeteran: false, xp: 260, level: 2, skills: { "heavy-cargo": 1 }, diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx index 15635ec..02793b1 100644 --- a/apps/web/test/render.tsx +++ b/apps/web/test/render.tsx @@ -68,6 +68,7 @@ export function samplePlayerState(overrides: Partial = {}): PlayerS id: "avatar", name: "Captain", isAvatar: true, + isVeteran: false, xp: 0, level: 0, skills: {}, diff --git a/apps/web/test/rest-summary-hq.test.tsx b/apps/web/test/rest-summary-hq.test.tsx index e51e3bf..a6173f3 100644 --- a/apps/web/test/rest-summary-hq.test.tsx +++ b/apps/web/test/rest-summary-hq.test.tsx @@ -11,6 +11,7 @@ const pilot = (id: string, name: string, extra: Partial = {}): Pilot => ( id, name, isAvatar: false, + isVeteran: false, xp: 0, level: 0, skills: {}, diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 498a77b..00d7df1 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -26,7 +26,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), `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) | +| `Pilot` | `id`, `name`, `isAvatar`, `isVeteran` (the deal's veteran, CR-010), `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`: 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) | @@ -75,7 +75,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri | `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), 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) | +| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004), `is_veteran` (CR-010) | unique `assigned_ship_id` (one pilot per ship) | | `bays` | Leased bays | unique (`player_id`, `location_id`) | | `discovered_locations` | Discovered location ids | pk (`player_id`, `location_id`) | | `contract_boards` | Stored location boards (one per discovered location, CR-002) and tutorial boards per period (tutorial boards also per run); `kind` is `location` or `tutorial` (jsonb contracts) | unique (`player_id`, `kind`, `location_id`, `period_index`, `tutorial_run`) | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index d5acce0..389c666 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -243,7 +243,7 @@ Spec: `memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md` (receiv - [x] **Step 10J.1 — Content: config and veteran.** Config `pilotProfitShare` 0.25 (a fraction 0–1); the flat `pilotPayPerDelivery` is removed in 10J.2 when the sim switches to the share. `veteranPilotSchema` and `content.veteranPilot`: Rosa Kell, "Halvorsen's pilot for twelve years.", level 2, Heavy Cargo 1. Test: `content/test/veteran.test.ts`. - [x] **Step 10J.2 — Sim: profit-share pay.** `sim/src/pay.ts` `pilotShare(payout, fuel)` = round(max(0, payout − fuel) × `pilotProfitShare`). Resolution charges it for hired pilots on contract deliveries (avatar, tutorial runs, deadheads: 0), then takes the loan repayment from what remains; `Resolution.pilotPay` records it. The flat `pilotPayPerDelivery` is gone (config, schema, client). The dispatch sheet's net profit uses the share. Test: `profit-share.test.ts` (400/150 → 63; fuel ≥ payout → 0; exemptions; repayment after pay; credits + pay + repayment = payout over 500 random cases); Step 3.17 tests updated. - [x] **Step 10J.3 — Sim: goal change.** Goal 4 is now `veteran-contract`, "Fly a contract with Rosa Kell" (title from `content.veteranPilot.name`), condition `veteran-contracts` ≥ 1; `GoalProgress.veteranContracts`. The server reports 0 until the veteran is stored (10J.4). Tests: `goals.test.ts` (avatar deliveries alone don't complete it; one veteran contract does) and the goal-id updates in server and web goal tests. -- [ ] Step 10J.4 — Server: veteran on the deal +- [x] **Step 10J.4 — Server: veteran on the deal.** Migration `0007_cr010_veteran_pilot`: `pilots.is_veteran`. `acceptDeal` inserts the veteran in the same transaction: content name, XP at level 2's threshold, Heavy Cargo 1 bought (1 point spent, 3 unspent), at Earth, unassigned. API `Pilot.isVeteran`. Goal progress counts resolved contract deliveries (not deadheads or tutorial runs) by the veteran. Test: `veteran.test.ts` (state after the deal; no veteran on a refused or repeated accept; avatar rests, is swapped out, the veteran flies at once and is paid a profit share, goal 4 ready; avatar deliveries and a veteran deadhead don't count). - [ ] Step 10J.5 — Client: deal modal and pilot card - [ ] Step 10J.6 — Client: pilot share in dispatch and summaries - [ ] Step 10J.7 — Plan amendments and regression diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 72e29af..d27fecc 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -72,6 +72,8 @@ export const pilotSchema = z.object({ id: idSchema, name: z.string().min(1), isAvatar: z.boolean(), + /** Halvorsen's veteran, who came with the deal (CR-010); paid like any hired pilot. */ + isVeteran: z.boolean(), xp: z.number().int().nonnegative(), level: z.number().int().nonnegative(), /** Skill levels by skill id; a missing skill is level 0. */ diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index bef6bc1..6ebdf27 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -72,6 +72,7 @@ const pilot = { id: "p1", name: "Avery Kade", isAvatar: true, + isVeteran: false, xp: 120, level: 1, skills: { "long-haul": 1 },