diff --git a/DECISIONS.md b/DECISIONS.md index 9b3c685..4a8972b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -779,3 +779,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Validation never looks at duty hours** (decision 0.5.44), so a pilot over the limit who is not resting can always be dispatched. Duty only matters when a trip resolves. - **Resolution owns the duty outcome:** it adds the trip's game hours (none for tutorial runs), and at or over the limit returns duty 0 and rest-ends-at = arrival + rest. The resolution time does not matter, so offline resolution matches live. - **Interim wiring:** until the pilots table has the columns (10D.4–10D.5), the server passes `restEndsAt: null` and duty 0, and the client passes `restEndsAt: null`, so behaviour is unchanged. + +## Step 10D.4 + +- **`restEndsAt` in the API is null unless the pilot is resting now.** The database keeps the last value (which goes stale harmlessly), but clients only see a time when it matters, next to `resting: true`. +- **`dutyLimit` is sent with each pilot** so the client does not recompute Long Haul bonuses for display (it still uses the sim for predictions). +- **`pilot-resting` needed no new server code:** the API's error codes include every sim validation reason, and 10D.3 added it there. +- **Pilot responses depend on the time** (resting or not), so `toApiPilot`, `loadPlayerState` and the pilot actions now take `now` from `withPlayer`. +- **Duty hours are a double** (game hours with fractions, as trip times are). Summaries read `restEndsAt` from the stored resolution, so older resolutions without it show null. diff --git a/apps/server/drizzle/0004_cr004_pilot_duty.sql b/apps/server/drizzle/0004_cr004_pilot_duty.sql new file mode 100644 index 0000000..c5ad651 --- /dev/null +++ b/apps/server/drizzle/0004_cr004_pilot_duty.sql @@ -0,0 +1,2 @@ +ALTER TABLE "pilots" ADD COLUMN "duty_hours" double precision DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "pilots" ADD COLUMN "rest_ends_at" bigint; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0004_snapshot.json b/apps/server/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..89e33d3 --- /dev/null +++ b/apps/server/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1332 @@ +{ + "id": "e8a22e4a-d8ea-4ccf-a64b-5e4fd9fdea92", + "prevId": "85b1ffb1-0667-4aee-89aa-5d9daf642cbb", + "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.ships": { + "name": "ships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "activity_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ships_player_idx": { + "name": "ships_player_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ships_player_id_players_id_fk": { + "name": "ships_player_id_players_id_fk", + "tableFrom": "ships", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.activity_status": { + "name": "activity_status", + "schema": "public", + "values": [ + "idle", + "in_flight" + ] + }, + "public.board_kind": { + "name": "board_kind", + "schema": "public", + "values": [ + "location", + "tutorial" + ] + }, + "public.dispatch_status": { + "name": "dispatch_status", + "schema": "public", + "values": [ + "in_flight", + "resolved" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 1648858..a2e5847 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1791053200611, "tag": "0003_cr002_location_boards", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1791078648879, + "tag": "0004_cr004_pilot_duty", + "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 130d71f..a95ca31 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -2,6 +2,7 @@ import type { Contract, DispatchRecord, Resolution } from "@deadhead/sim"; import { bigint, boolean, + doublePrecision, index, integer, jsonb, @@ -88,6 +89,10 @@ export const pilots = pgTable( /** Only unassigned pilots store a location (decision 0.5.27). */ locationId: text("location_id"), status: activityStatus("status").notNull().default("idle"), + /** Game hours flown since the last rest (decision 0.5.42). */ + dutyHours: doublePrecision("duty_hours").notNull().default(0), + /** Resting until this time; null (or past) when not resting (decision 0.5.45). */ + restEndsAt: time("rest_ends_at"), createdAt: time("created_at").notNull(), }, (t) => [index("pilots_player_idx").on(t.playerId)], diff --git a/apps/server/src/game/mappers.ts b/apps/server/src/game/mappers.ts index a99eb38..93caf87 100644 --- a/apps/server/src/game/mappers.ts +++ b/apps/server/src/game/mappers.ts @@ -1,6 +1,12 @@ import { content } from "@deadhead/content"; import type { Dispatch, Pilot } from "@deadhead/schema"; -import { pilotLevel, skillPointsAvailable } from "@deadhead/sim"; +import { + dutyLimit, + isResting, + pilotLevel, + skillPointsAvailable, + type RealTime, +} from "@deadhead/sim"; import type { dispatches, pilots } from "../db/schema"; /** Database status values use `in_flight`; the API uses `in-flight`. */ @@ -25,7 +31,9 @@ export function toApiDispatch(row: typeof dispatches.$inferSelect): Dispatch { export function toApiPilot( row: typeof pilots.$inferSelect, assignedShipLocationId: string | null, + now: RealTime, ): Pilot { + const resting = isResting(row.restEndsAt, now); const level = pilotLevel(row.xp, content.config); return { id: row.id, @@ -43,5 +51,9 @@ export function toApiPilot( : row.assignedShipId ? assignedShipLocationId : row.locationId, + dutyHours: row.dutyHours, + dutyLimit: dutyLimit(row.skills, content.config), + resting, + restEndsAt: resting ? row.restEndsAt : null, }; } diff --git a/apps/server/src/game/pilots.ts b/apps/server/src/game/pilots.ts index 09f4e08..e2194f7 100644 --- a/apps/server/src/game/pilots.ts +++ b/apps/server/src/game/pilots.ts @@ -65,7 +65,7 @@ export async function hirePilot( createdAt: now, }) .returning(); - return toApiPilot(pilot!, null); + return toApiPilot(pilot!, null, now); } /** @@ -77,6 +77,7 @@ export async function assignPilot( playerId: string, pilotId: string, shipId: string, + now: RealTime, ): Promise { const pilot = await requireOwnPilot(tx, playerId, pilotId); const ship = await requireOwnShip(tx, playerId, shipId); @@ -111,7 +112,7 @@ export async function assignPilot( .set({ assignedShipId: shipId, locationId: null }) .where(eq(pilots.id, pilotId)) .returning(); - return toApiPilot(updated!, ship.locationId); + return toApiPilot(updated!, ship.locationId, now); } /** Unassigns a pilot from its ship; only allowed where the player has a bay (decision 0.5.2). */ @@ -119,6 +120,7 @@ export async function unassignPilot( tx: Queryable, playerId: string, pilotId: string, + now: RealTime, ): Promise { const pilot = await requireOwnPilot(tx, playerId, pilotId); if (pilot.assignedShipId === null) { @@ -137,7 +139,7 @@ export async function unassignPilot( .set({ assignedShipId: null, locationId: ship.locationId }) .where(eq(pilots.id, pilotId)) .returning(); - return toApiPilot(updated!, null); + return toApiPilot(updated!, null, now); } /** Spends one available skill point on a skill below its max level (Step 6.12). */ @@ -146,6 +148,7 @@ export async function spendSkillPoint( playerId: string, pilotId: string, skillId: string, + now: RealTime, ): Promise { const pilot = await requireOwnPilot(tx, playerId, pilotId); const skill = content.skill(skillId); @@ -171,5 +174,5 @@ export async function spendSkillPoint( const [ship] = updated!.assignedShipId ? await tx.select().from(ships).where(eq(ships.id, updated!.assignedShipId)) : []; - return toApiPilot(updated!, ship?.locationId ?? null); + return toApiPilot(updated!, ship?.locationId ?? null, now); } diff --git a/apps/server/src/game/player-state.ts b/apps/server/src/game/player-state.ts index 2ae2307..7440060 100644 --- a/apps/server/src/game/player-state.ts +++ b/apps/server/src/game/player-state.ts @@ -1,5 +1,6 @@ import { content } from "@deadhead/content"; import type { PlayerState } from "@deadhead/schema"; +import type { RealTime } from "@deadhead/sim"; import { and, eq, isNull, max } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { @@ -16,7 +17,11 @@ import { goalStates } from "./goals"; import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers"; /** Reads everything the client needs about one player. */ -export async function loadPlayerState(db: Queryable, playerId: string): Promise { +export async function loadPlayerState( + db: Queryable, + playerId: string, + now: RealTime, +): Promise { const [player] = await db.select().from(players).where(eq(players.id, playerId)); const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId)); if (!player || !company) throw new Error(`Player ${playerId} not found`); @@ -74,7 +79,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise< lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt, })), pilots: pilotRows.map((p) => - toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null), + toApiPilot( + p, + p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null, + now, + ), ), bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })), locations: content.locations.map((l) => ({ id: l.id, discovered: discoveredIds.has(l.id) })), @@ -103,6 +112,7 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise< pilotXp: d.resolution?.pilotXp ?? 0, discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null, scriptedId: d.record.contract?.scriptedId ?? null, + restEndsAt: d.resolution?.pilotRestEndsAt ?? null, resolvedAt: d.resolvedAt ?? d.arrivalAt, })), goals: await goalStates(db, playerId), diff --git a/apps/server/src/routes/fleet.ts b/apps/server/src/routes/fleet.ts index 38da9f8..c36bb4c 100644 --- a/apps/server/src/routes/fleet.ts +++ b/apps/server/src/routes/fleet.ts @@ -27,20 +27,20 @@ export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void ); registerRoute(app, routes.assignPilot, ctx.options, async (input, { playerId }) => - withPlayer(ctx, playerId, async (tx) => ({ - pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId), + withPlayer(ctx, playerId, async (tx, now) => ({ + pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId, now), })), ); registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) => - withPlayer(ctx, playerId, async (tx) => ({ - pilot: await unassignPilot(tx, playerId, input.pilotId), + withPlayer(ctx, playerId, async (tx, now) => ({ + pilot: await unassignPilot(tx, playerId, input.pilotId, now), })), ); registerRoute(app, routes.spendSkillPoint, ctx.options, async (input, { playerId }) => - withPlayer(ctx, playerId, async (tx) => ({ - pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId), + withPlayer(ctx, playerId, async (tx, now) => ({ + pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId, now), })), ); } diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts index 3e69843..b00ff84 100644 --- a/apps/server/src/routes/player.ts +++ b/apps/server/src/routes/player.ts @@ -39,12 +39,12 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi }); // Pass Better Auth's session cookie through to the client. reply.header("set-cookie", signIn.headers.getSetCookie()); - return { playerId, state: await loadPlayerState(db, playerId) }; + return { playerId, state: await loadPlayerState(db, playerId, clock()) }; }); // Lazy resolution on read (Step 6.1): apply every arrival before returning state. registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) => - withPlayer(ctx, playerId, (tx) => loadPlayerState(tx, playerId)), + withPlayer(ctx, playerId, (tx, now) => loadPlayerState(tx, playerId, now)), ); registerRoute(app, routes.acknowledgeSummaries, options, async (input, { playerId }) => @@ -56,7 +56,7 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi registerRoute(app, routes.acceptDeal, options, async (_input, { playerId }) => withPlayer(ctx, playerId, async (tx, now) => { await acceptDeal(tx, playerId, now); - return loadPlayerState(tx, playerId); + return loadPlayerState(tx, playerId, now); }), ); diff --git a/apps/web/test/api-client.test.ts b/apps/web/test/api-client.test.ts index 2ad4898..1d787c8 100644 --- a/apps/web/test/api-client.test.ts +++ b/apps/web/test/api-client.test.ts @@ -38,6 +38,10 @@ describe("typed API client", () => { assignedShipId: "s1", status: "idle", locationId: "earth", + dutyHours: 0, + dutyLimit: 12, + resting: false, + restEndsAt: null, }; const fetch = mockFetch(200, { pilot }); const api = createApiClient({ fetch }); diff --git a/apps/web/test/away-goals.test.tsx b/apps/web/test/away-goals.test.tsx index 6afd7b9..580af6e 100644 --- a/apps/web/test/away-goals.test.tsx +++ b/apps/web/test/away-goals.test.tsx @@ -36,6 +36,7 @@ function returningPlayer(acknowledged: boolean): PlayerState { pilotXp: 10, discoveredLocationId: null, scriptedId: null, + restEndsAt: null, resolvedAt: 1, }, ], diff --git a/apps/web/test/away-summary.test.tsx b/apps/web/test/away-summary.test.tsx index 0bbc557..cbc28fd 100644 --- a/apps/web/test/away-summary.test.tsx +++ b/apps/web/test/away-summary.test.tsx @@ -28,6 +28,7 @@ const summaries: ResolutionSummary[] = [ pilotXp: 32, discoveredLocationId: "mars", scriptedId: null, + restEndsAt: null, resolvedAt: 1, }, { @@ -42,6 +43,7 @@ const summaries: ResolutionSummary[] = [ pilotXp: 10, discoveredLocationId: null, scriptedId: null, + restEndsAt: null, resolvedAt: 2, }, ]; diff --git a/apps/web/test/deal-modal.test.tsx b/apps/web/test/deal-modal.test.tsx index bff8e3b..ee5fd28 100644 --- a/apps/web/test/deal-modal.test.tsx +++ b/apps/web/test/deal-modal.test.tsx @@ -86,6 +86,7 @@ describe("the deal", () => { pilotXp: 10, discoveredLocationId: null, scriptedId: null, + restEndsAt: null, resolvedAt: 1, }, ], diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index df47989..d2c3e65 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -41,6 +41,10 @@ const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pil assignedShipId: shipId, status: "idle", locationId: "earth", + dutyHours: 0, + dutyLimit: 12, + resting: false, + restEndsAt: null, }); const marsJob: Contract = { diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/hq.test.tsx index 3cc45f5..18c97b7 100644 --- a/apps/web/test/hq.test.tsx +++ b/apps/web/test/hq.test.tsx @@ -150,6 +150,7 @@ describe("loan on HQ", () => { pilotXp: 10, discoveredLocationId: null, scriptedId: null, + restEndsAt: null, resolvedAt: 1, }, ], diff --git a/apps/web/test/pilots.test.tsx b/apps/web/test/pilots.test.tsx index 13820ed..acf5169 100644 --- a/apps/web/test/pilots.test.tsx +++ b/apps/web/test/pilots.test.tsx @@ -29,6 +29,10 @@ const rae: Pilot = { assignedShipId: null, status: "idle", locationId: "moon", + dutyHours: 0, + dutyLimit: 12, + resting: false, + restEndsAt: null, }; function pilotsState(overrides: Partial = {}): PlayerState { diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx index 9928d2b..22500de 100644 --- a/apps/web/test/render.tsx +++ b/apps/web/test/render.tsx @@ -48,6 +48,10 @@ export function samplePlayerState(overrides: Partial = {}): PlayerS assignedShipId: null, status: "idle", locationId: "earth", + dutyHours: 0, + dutyLimit: 12, + resting: false, + restEndsAt: null, }, ], bays: [], diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index 246f28d..b1325f3 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -153,6 +153,7 @@ describe("scripted delivery summary", () => { pilotXp: 10, discoveredLocationId: null, scriptedId: "unmarked-crates", + restEndsAt: null, resolvedAt: NOW, }, ], diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 5d21c52..b7d2b30 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -26,12 +26,12 @@ 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) | -| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) | +| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight), `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting; CR-004) | | `Bay` | `id`, `locationId` | | `LocationState` | `id`, `discovered` | | `Contract` | mirrors sim `Contract` (optional `scriptedId` on a scripted tutorial contract) | | `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` | -| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `resolvedAt` | +| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits` (net), `loanRepayment`, `pilotXp`, `discoveredLocationId`, `scriptedId`, `restEndsAt` (set when the trip ended in mandatory rest), `resolvedAt` | | `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight`, `runsCompleted`, `dealAvailable` | | `GoalState` | `id`, `status` (`incomplete` / `ready` / `claimed`) | | `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries`, `goals` (empty during the tutorial) | @@ -57,6 +57,7 @@ The `routes` catalog is the single source for both server and client. Each entry | `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes | | `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes | | `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 | | `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes | @@ -72,7 +73,7 @@ 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) | — | -| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status | 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) | 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`) | @@ -176,6 +177,10 @@ export const pilots = pgTable( /** Only unassigned pilots store a location (decision 0.5.27). */ locationId: text("location_id"), status: activityStatus("status").notNull().default("idle"), + /** Game hours flown since the last rest (decision 0.5.42). */ + dutyHours: doublePrecision("duty_hours").notNull().default(0), + /** Resting until this time; null (or past) when not resting (decision 0.5.45). */ + restEndsAt: time("rest_ends_at"), createdAt: time("created_at").notNull(), }, (t) => [index("pilots_player_idx").on(t.playerId)], diff --git a/memory-bank/progress.md b/memory-bank/progress.md index aa9247c..32513d0 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -167,7 +167,7 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec - [x] **Step 10D.1 — Content config.** GameConfig gains `dutyLimitGameHours` (12), `longHaulDutyBonusGameHours` (2) and `restGameHours` (60 = one 60-minute board period at 60× compression). Tests: rest in real time ≥ board period; duty numbers positive. - [x] **Step 10D.2 — Sim: duty and rest functions.** `packages/sim/src/duty.ts`: `dutyLimit(skills)`, `isResting(restEndsAt, now)`, `tripGameHours(departure, arrival)`, `dutyAfterTrip(duty, hours, tutorial)`, `needsRest(duty, limit)`, `restEndsAt(start)`. Test: `duty.test.ts` (including 2,000 random rest starts all waking in a later board period). - [x] **Step 10D.3 — Sim: validation and resolution.** `PilotState.restEndsAt`; `validateDispatch` and `validateReposition` fail with `pilot-resting` before the other pilot checks. `ResolveInput` takes `pilotDutyHours` and `pilotSkills`; `Resolution` returns `pilotDutyHours` and `pilotRestEndsAt` (rest from the arrival time). Server and client pass interim values (no rest, zero duty) until the columns exist (10D.4–10D.5). Test: `duty-dispatch.test.ts`. -- [ ] Step 10D.4 — Schema and database +- [x] **Step 10D.4 — Schema and database.** Migration `0004_cr004_pilot_duty`: `pilots.duty_hours` (double, default 0) and `pilots.rest_ends_at` (nullable). API `Pilot` gains `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting); `ResolutionSummary` gains `restEndsAt` (mandatory rest). Route `restPilot` (`POST /api/pilots/:pilotId/rest`). `pilot-resting` is an API code via the sim's failure list. `toApiPilot` and `loadPlayerState` take `now`. Dev database migrated. - [ ] Step 10D.5 — Server: apply duty and rest - [ ] Step 10D.6 — Client: pilot duty on the Pilots screen - [ ] Step 10D.7 — Client: duty in the dispatch flow diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 70c9e88..95ef86d 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -58,6 +58,12 @@ export const pilotSchema = z.object({ status: z.enum(["idle", "in-flight"]), /** Where the pilot is (their ship's location if assigned); null while in flight. */ locationId: idSchema.nullable(), + /** Game hours flown since the last rest, and the limit before mandatory rest (CR-004). */ + dutyHours: z.number().nonnegative(), + dutyLimit: z.number().positive(), + /** Resting pilots cannot fly until `restEndsAt` (null when not resting). */ + resting: z.boolean(), + restEndsAt: timeSchema.nullable(), }); export type Pilot = z.infer; @@ -129,6 +135,8 @@ export const resolutionSummarySchema = z.object({ discoveredLocationId: idSchema.nullable(), /** The scripted tutorial contract delivered, if any (for its flavor message). */ scriptedId: idSchema.nullable(), + /** Set when the trip ended in mandatory rest: the pilot rests until then (CR-004). */ + restEndsAt: timeSchema.nullable(), resolvedAt: timeSchema, }); export type ResolutionSummary = z.infer; diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index fa9883c..4da710d 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -56,6 +56,8 @@ export const bayResponseSchema = z.object({ bay: baySchema }); export const hirePilotRequestSchema = z.object({ locationId: idSchema }); export const assignPilotRequestSchema = z.object({ pilotId: idSchema, shipId: idSchema }); export const unassignPilotRequestSchema = z.object({ pilotId: idSchema }); +/** CR-004: rest an idle pilot now, at any duty level. */ +export const restPilotRequestSchema = z.object({ pilotId: idSchema }); export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema }); export const pilotResponseSchema = z.object({ pilot: pilotSchema }); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 2a62499..e438b79 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -20,6 +20,7 @@ import { repayLoanRequestSchema, shipResponseSchema, spendSkillPointRequestSchema, + restPilotRequestSchema, unassignPilotRequestSchema, } from "./requests"; @@ -114,6 +115,13 @@ export const routes = { response: pilotResponseSchema, auth: true, }, + restPilot: { + method: "POST", + path: "/api/pilots/:pilotId/rest", + request: restPilotRequestSchema, + response: pilotResponseSchema, + auth: true, + }, spendSkillPoint: { method: "POST", path: "/api/pilots/:pilotId/skills", diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index dfb35a0..eb07276 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -19,6 +19,7 @@ describe("route catalog", () => { "hirePilot", "assignPilot", "unassignPilot", + "restPilot", "spendSkillPoint", "acknowledgeSummaries", "acceptDeal", diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index 3ef1475..ba8f725 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -33,6 +33,7 @@ import { locationStateSchema, pilotResponseSchema, pilotSchema, + restPilotRequestSchema, playerStateSchema, resolutionSummarySchema, serverTimeSchema, @@ -67,6 +68,10 @@ const pilot = { assignedShipId: "s1", status: "idle", locationId: "earth", + dutyHours: 7.5, + dutyLimit: 14, + resting: false, + restEndsAt: null, }; const bay = { id: "b1", locationId: "earth" }; const location = { id: "mars", discovered: false }; @@ -113,6 +118,7 @@ const summary = { pilotXp: 10, discoveredLocationId: null, scriptedId: "unmarked-crates", + restEndsAt: null, resolvedAt: 3_900_000, }; const tutorial = { @@ -144,11 +150,25 @@ const cases: [string, z.ZodType, unknown, unknown][] = [ ["company", companySchema, company, { ...company, credits: 1.5 }], ["ship", shipSchema, ship, { ...ship, status: "lost" }], ["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }], + [ + "resting pilot", + pilotSchema, + { ...pilot, dutyHours: 0, resting: true, restEndsAt: 4_000_000 }, + { ...pilot, dutyHours: -1 }, + ], + ["pilot duty limit", pilotSchema, { ...pilot, dutyLimit: 12 }, { ...pilot, dutyLimit: 0 }], + ["rest pilot request", restPilotRequestSchema, { pilotId: "p1" }, { pilotId: "" }], ["bay", baySchema, bay, { id: "b1" }], ["location", locationStateSchema, location, { id: "mars", discovered: "no" }], ["contract", contractSchema, contract, { ...contract, tier: -1 }], ["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }], ["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }], + [ + "summary with rest started", + resolutionSummarySchema, + { ...summary, restEndsAt: 7_500_000 }, + { ...summary, restEndsAt: -1 }, + ], ["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }], ["goal state", goalStateSchema, goal, { ...goal, status: "done" }], ["repay loan request", repayLoanRequestSchema, { amount: 500 }, { amount: 0 }],