From 979a1ad1a84c2d457bc05828164f6c84ee51ab03 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Mon, 5 Oct 2026 11:43:38 -0400 Subject: [PATCH] Step 10M.2: goals for a Market purchase and a Priority delivery Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 + .../drizzle/0009_cr013_ship_from_deal.sql | 5 + apps/server/drizzle/meta/0009_snapshot.json | 1494 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/src/db/schema.ts | 2 + apps/server/src/game/deal.ts | 1 + apps/server/src/game/goals.ts | 16 +- apps/server/test/db.ts | 2 + apps/server/test/goals.test.ts | 36 +- apps/server/test/veteran.test.ts | 46 +- apps/web/test/goals.test.tsx | 6 +- memory-bank/architecture.md | 2 +- memory-bank/progress.md | 2 +- packages/content/src/data/onboarding.ts | 15 +- packages/content/src/schemas.ts | 6 +- packages/content/test/onboarding.test.ts | 4 +- packages/sim/src/goals.ts | 12 +- packages/sim/test/goals.test.ts | 36 +- 18 files changed, 1612 insertions(+), 87 deletions(-) create mode 100644 apps/server/drizzle/0009_cr013_ship_from_deal.sql create mode 100644 apps/server/drizzle/meta/0009_snapshot.json diff --git a/DECISIONS.md b/DECISIONS.md index 9dee2d5..b452fbc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1210,3 +1210,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The deal's contents are config** (`deal.avatarShipModelId`, `veteranShipModelId`, `bayLocationId`) so the loan formula and the server's deal both read the same ids; the stored `dealLoanAmount` number is gone. - **The loan is a pure function of content** (`dealLoanAmount`), computed once into the bundle. Tests call it with altered prices to show it follows them. - **The loan grows from 5,000 to 66,600 credits.** At the 30 % repayment share, paying it off takes far more deliveries than before; the "under 4 hours of active play" target is for the balance script (Step 12.1) to check. + +## Step 10M.2 + +- **Goal ids changed with their meaning**: `second-ship` → `market-ship`, `veteran-contract` → `priority-contract`. A claim recorded under an old id no longer matches any goal; the CR allows resetting databases, and a stale claim only hides nothing. +- **"Bought from the Market" is a stored flag** (`ships.from_deal`) rather than "ships owned minus two", so it stays right if the deal's contents change. The migration marks each existing player's first ship as the deal's (it always is: the loaner isn't a row). +- **The old `veteran-contracts` condition is removed**, not kept unused. `ships-owned` and `pilots-hired` stay as general-purpose conditions. +- **A Priority delivery counts by the contract snapshot** (`record.contract.priority`), for any pilot, excluding tutorial runs (which are never Priority anyway). diff --git a/apps/server/drizzle/0009_cr013_ship_from_deal.sql b/apps/server/drizzle/0009_cr013_ship_from_deal.sql new file mode 100644 index 0000000..62f30a5 --- /dev/null +++ b/apps/server/drizzle/0009_cr013_ship_from_deal.sql @@ -0,0 +1,5 @@ +ALTER TABLE "ships" ADD COLUMN "from_deal" boolean DEFAULT false NOT NULL;--> statement-breakpoint +-- Backfill: each player's first ship came with the deal (CR-001), not from the Market. +UPDATE "ships" SET "from_deal" = true WHERE "id" IN ( + SELECT DISTINCT ON ("player_id") "id" FROM "ships" ORDER BY "player_id", "created_at", "id" +); diff --git a/apps/server/drizzle/meta/0009_snapshot.json b/apps/server/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..6448ed6 --- /dev/null +++ b/apps/server/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1494 @@ +{ + "id": "8344e66f-27e5-466e-aad0-4878dd4fb197", + "prevId": "9c84497a-e6c5-4313-a339-71cc45364eb4", + "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.player_logins": { + "name": "player_logins", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "player_logins_user_id_user_id_fk": { + "name": "player_logins_user_id_user_id_fk", + "tableFrom": "player_logins", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "player_logins_player_id_players_id_fk": { + "name": "player_logins_player_id_players_id_fk", + "tableFrom": "player_logins", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "backup_code_hash": { + "name": "backup_code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backup_code_created_at": { + "name": "backup_code_created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "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" + ] + }, + "players_backup_code_hash_unique": { + "name": "players_backup_code_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "backup_code_hash" + ] + } + }, + "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 + }, + "from_deal": { + "name": "from_deal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 ee3d04b..1e770c7 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1791171500580, "tag": "0008_cr011_backup_codes", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1791214535055, + "tag": "0009_cr013_ship_from_deal", + "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 9bade69..f6c59d8 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -76,6 +76,8 @@ export const ships = pgTable( */ shopUpgradeId: text("shop_upgrade_id"), shopLevel: integer("shop_level"), + /** Handed over in the deal rather than bought from the Market (CR-013 goal 3). */ + fromDeal: boolean("from_deal").notNull().default(false), createdAt: time("created_at").notNull(), }, (t) => [index("ships_player_idx").on(t.playerId)], diff --git a/apps/server/src/game/deal.ts b/apps/server/src/game/deal.ts index 6558163..2a5b8c4 100644 --- a/apps/server/src/game/deal.ts +++ b/apps/server/src/game/deal.ts @@ -59,6 +59,7 @@ export async function acceptDeal(tx: Queryable, playerId: string, now: RealTime) modelId: DEAL_SHIP_MODEL_ID, name: shipName(player.worldSeed, owned!.n), locationId: DEAL_BAY_LOCATION_ID, + fromDeal: true, createdAt: now, }); await tx.insert(bays).values({ diff --git a/apps/server/src/game/goals.ts b/apps/server/src/game/goals.ts index 4df286d..6801022 100644 --- a/apps/server/src/game/goals.ts +++ b/apps/server/src/game/goals.ts @@ -32,18 +32,16 @@ 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 + // Priority contracts delivered, by any pilot (CR-013). + const [priorityDeliveries] = 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`, + sql`(${dispatches.record} -> 'contract' ->> 'priority') = 'true'`, ), ); const [spent] = await tx @@ -51,6 +49,11 @@ export async function goalProgress(tx: Queryable, playerId: string): Promise { expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id)); // Paid-off loan: the test owner has no balance. expect(goals.find((g) => g.id === "loan-paid-off")?.status).toBe("ready"); - expect(goals.find((g) => g.id === "veteran-contract")?.status).toBe("incomplete"); + expect(goals.find((g) => g.id === "priority-contract")?.status).toBe("incomplete"); }); it("becomes ready when its condition is met: a delivery with the player's own ship", async () => { @@ -65,6 +66,37 @@ describe("onboarding goals", () => { expect(await status(owner, "first-own-delivery")).toBe("ready"); }); + it("counts a ship bought from the Market, not the deal's (CR-013)", async () => { + const owner = await dealOwner(); + expect(await status(owner, "market-ship")).toBe("incomplete"); + await setCredits(database().db, owner.playerId, content.shipModel("lighter")!.price); + const response = await call(testApp().app, owner, "POST", "/api/ships", { + modelId: "lighter", + bayId: owner.earthBayId, + }); + expect(response.statusCode).toBe(200); + expect(await status(owner, "market-ship")).toBe("ready"); + }); + + it("counts a Priority delivery, not a Standard one (CR-013)", async () => { + const { clock } = testApp(); + const owner = await dealOwner(); + const fly = async (priority: boolean) => { + const record = await insertDispatch(database().db, { + playerId: owner.playerId, + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: clock.now, + contract: testContract({ priority, deliveryWindowGameHours: priority ? 1000 : null }), + }); + clock.now = record.arrivalAt; + }; + await fly(false); + expect(await status(owner, "priority-contract")).toBe("incomplete"); + await fly(true); + expect(await status(owner, "priority-contract")).toBe("ready"); + }); + it("counts skill points spent", async () => { const owner = await dealOwner(); await database() @@ -93,7 +125,7 @@ describe("onboarding goals", () => { it("refuses an incomplete goal and an unknown one", async () => { const owner = await dealOwner(); await setCredits(database().db, owner.playerId, 100); - const response = await claim(owner, "veteran-contract"); + const response = await claim(owner, "priority-contract"); expect(response.statusCode).toBe(409); expect(errorCode(response)).toBe("goal-not-ready"); expect((await claim(owner, "win-the-game")).statusCode).toBe(404); diff --git a/apps/server/test/veteran.test.ts b/apps/server/test/veteran.test.ts index 949414e..475d611 100644 --- a/apps/server/test/veteran.test.ts +++ b/apps/server/test/veteran.test.ts @@ -8,16 +8,7 @@ import { import { pilotShare } from "@deadhead/sim"; import { describe, expect, it } from "vitest"; import { setCredits, useTestDatabase } from "./db"; -import { - call, - getState, - insertDispatch, - putBoard, - signUp, - testContract, - useTestApp, - type Guest, -} from "./game"; +import { call, getState, putBoard, signUp, testContract, useTestApp, type Guest } from "./game"; import { flyTutorialRun } from "./tutorial-flow"; const database = useTestDatabase(); @@ -108,40 +99,5 @@ describe("the veteran comes with the deal (Step 10J.4)", () => { 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/goals.test.tsx b/apps/web/test/goals.test.tsx index c82e916..3a091b3 100644 --- a/apps/web/test/goals.test.tsx +++ b/apps/web/test/goals.test.tsx @@ -31,7 +31,7 @@ describe("goals on HQ", () => { expect(items[0]).toHaveTextContent( `+${content.onboardingGoal("first-own-delivery")!.reward.toLocaleString("en-US")} cr`, ); - expect(items[2]).toHaveTextContent(title("second-ship")); + expect(items[2]).toHaveTextContent(title("market-ship")); }); it("shows Claim only on ready goals", async () => { @@ -56,11 +56,11 @@ describe("goals on HQ", () => { }); renderApp(api); const card = await screen.findByRole("region", { name: "Goals" }); - expect(card).not.toHaveTextContent(title("veteran-contract")); + expect(card).not.toHaveTextContent(title("priority-contract")); await userEvent.click(within(card).getByRole("button", { name: "Claim" })); expect(api.call).toHaveBeenCalledWith("claimGoal", { goalId: "first-own-delivery" }); - await waitFor(() => expect(card).toHaveTextContent(title("veteran-contract"))); + await waitFor(() => expect(card).toHaveTextContent(title("priority-contract"))); expect(card).not.toHaveTextContent(title("first-own-delivery")); }); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 624dd2c..2980a66 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -80,7 +80,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), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009) | — | +| `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), `from_deal` (handed over in the deal, not bought; migration `0009_cr013_ship_from_deal` backfills each player's first ship; CR-013) | — | | `player_logins` | Extra sign-ins per player: a guest user created by restoring with a backup code (CR-011) | PK `user_id` | | `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), `is_veteran` (CR-010) | unique `assigned_ship_id` (one pilot per ship) | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 9efa806..f4b6e93 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -288,7 +288,7 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR-010 … Phase 10J"). New decisions recorded as plan §0.5.82–0.5.83; §0.5.70 amended. - [x] **Step 10M.1 — Content: veteran skill and loan.** The veteran starts with **Expensive Cargo 1**. Config `dealLoanAmount` (5,000) is replaced by `deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }`, and the loan is computed by `dealLoanAmount({ config, shipModels, locations })` (`packages/content/src/deal.ts`), exposed as `content.dealLoanAmount`: 4,000 + 61,600 + 1,000 = **66,600 cr**. All users (server deal, loan card, deal modal, tests) read `content.dealLoanAmount`. Tests: config (deal ids; loan = Courier + Clipper + Earth lease; it moves by exactly the change when either price or the lease changes), veteran skills (content and server). -- [ ] Step 10M.2 — Sim: goal changes +- [x] **Step 10M.2 — Sim: goal changes.** Goal 3 is now `market-ship` "Buy a ship from the Market" (condition `market-ships-bought` 1) and goal 4 `priority-contract` "Complete a Priority contract" (`priority-deliveries` 1, any pilot); the `veteran-contracts` condition is gone. `GoalProgress` gains `marketShipsBought` and `priorityDeliveries` (drops `veteranContracts`). Server: `ships.from_deal` (migration 0009, backfilling each player's first ship), set on the deal's ship; goal progress counts non-deal ships and resolved non-tutorial dispatches whose contract is Priority. Tests: sim (the deal's two ships don't complete goal 3, a bought Lighter does; Standard deliveries don't complete goal 4, a Priority one does), server goals (buying a Lighter; a Standard then a Priority delivery), web goal list ids. - [ ] Step 10M.3 — Server: deal creates the Clipper - [ ] Step 10M.4 — Client: deal modal - [ ] Step 10M.5 — Plan amendments and regression diff --git a/packages/content/src/data/onboarding.ts b/packages/content/src/data/onboarding.ts index 9d4ce0e..ded03bb 100644 --- a/packages/content/src/data/onboarding.ts +++ b/packages/content/src/data/onboarding.ts @@ -40,16 +40,17 @@ export const onboardingGoals: OnboardingGoal[] = [ reward: 300, }, { - id: "second-ship", - title: "Buy a second ship", - condition: { kind: "ships-owned", count: 2 }, + // Was "Buy a second ship"; the deal now brings two (CR-013). + id: "market-ship", + title: "Buy a ship from the Market", + condition: { kind: "market-ships-bought", count: 1 }, reward: 1000, }, { - // Replaces "Hire your first pilot" (CR-010): the veteran comes with the deal. - id: "veteran-contract", - title: `Fly a contract with ${veteranPilot.name}`, - condition: { kind: "veteran-contracts", count: 1 }, + // Was "Fly a contract with" the veteran (CR-010, replaced by CR-013). + id: "priority-contract", + title: "Complete a Priority contract", + condition: { kind: "priority-deliveries", count: 1 }, reward: 1000, }, { diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index aa14a38..0336ddc 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -322,9 +322,11 @@ export const goalConditionSchema = z.discriminatedUnion("kind", [ /** Skill points spent across all pilots. */ z.object({ kind: z.literal("skill-points-spent"), count: positiveInt }), z.object({ kind: z.literal("ships-owned"), count: positiveInt }), + /** Ships bought from the Market; the deal's ships don't count (CR-013). */ + z.object({ kind: z.literal("market-ships-bought"), count: positiveInt }), z.object({ kind: z.literal("pilots-hired"), count: positiveInt }), - /** Contracts (not deadheads) delivered by the veteran pilot (CR-010). */ - z.object({ kind: z.literal("veteran-contracts"), count: positiveInt }), + /** Priority contracts delivered, by any pilot (CR-013). */ + z.object({ kind: z.literal("priority-deliveries"), count: positiveInt }), /** Locations discovered beyond the ones that start discovered. */ z.object({ kind: z.literal("locations-discovered"), count: positiveInt }), /** The deal's loan is fully repaid. */ diff --git a/packages/content/test/onboarding.test.ts b/packages/content/test/onboarding.test.ts index ff64de6..83094ff 100644 --- a/packages/content/test/onboarding.test.ts +++ b/packages/content/test/onboarding.test.ts @@ -53,8 +53,8 @@ describe("onboarding goals", () => { expect(onboardingGoals.map((g) => g.condition.kind)).toEqual([ "own-ship-deliveries", "skill-points-spent", - "ships-owned", - "veteran-contracts", + "market-ships-bought", + "priority-deliveries", "locations-discovered", "loan-paid-off", ]); diff --git a/packages/sim/src/goals.ts b/packages/sim/src/goals.ts index 9ac7669..8a48ecb 100644 --- a/packages/sim/src/goals.ts +++ b/packages/sim/src/goals.ts @@ -7,10 +7,12 @@ export interface GoalProgress { /** Skill points spent across all pilots. */ skillPointsSpent: number; shipsOwned: number; + /** Ships bought from the Market, not received in the deal (CR-013). */ + marketShipsBought: number; /** Pilots hired (the avatar excluded). */ pilotsHired: number; - /** Resolved contract deliveries (not deadheads) flown by the veteran (CR-010). */ - veteranContracts: number; + /** Resolved Priority contract deliveries, by any pilot (CR-013). */ + priorityDeliveries: number; /** Locations discovered beyond the ones that start discovered. */ locationsDiscovered: number; dealAccepted: boolean; @@ -27,8 +29,10 @@ export function isGoalConditionMet(condition: GoalCondition, progress: GoalProgr return progress.shipsOwned >= condition.count; case "pilots-hired": return progress.pilotsHired >= condition.count; - case "veteran-contracts": - return progress.veteranContracts >= condition.count; + case "market-ships-bought": + return progress.marketShipsBought >= condition.count; + case "priority-deliveries": + return progress.priorityDeliveries >= condition.count; case "locations-discovered": return progress.locationsDiscovered >= condition.count; case "loan-paid-off": diff --git a/packages/sim/test/goals.test.ts b/packages/sim/test/goals.test.ts index a26aadf..25e703b 100644 --- a/packages/sim/test/goals.test.ts +++ b/packages/sim/test/goals.test.ts @@ -4,13 +4,14 @@ import { completedGoals, type GoalProgress } from "../src/index"; const goals = content.onboardingGoals; -/** Just after the deal: a ship, an Earth bay and the full loan. */ +/** Just after the deal: the Courier and the Clipper, an Earth bay and the full loan (CR-013). */ const fresh: GoalProgress = { ownShipDeliveries: 0, skillPointsSpent: 0, - shipsOwned: 1, - pilotsHired: 0, - veteranContracts: 0, + shipsOwned: 2, + marketShipsBought: 0, + pilotsHired: 1, + priorityDeliveries: 0, locationsDiscovered: 0, dealAccepted: true, loanBalance: content.dealLoanAmount, @@ -23,8 +24,8 @@ const isComplete = (id: string, progress: GoalProgress) => const cases: [string, Partial, Partial][] = [ ["first-own-delivery", { ownShipDeliveries: 0 }, { ownShipDeliveries: 1 }], ["first-skill-point", { skillPointsSpent: 0 }, { skillPointsSpent: 1 }], - ["second-ship", { shipsOwned: 1 }, { shipsOwned: 2 }], - ["veteran-contract", { veteranContracts: 0 }, { veteranContracts: 1 }], + ["market-ship", { marketShipsBought: 0 }, { marketShipsBought: 1, shipsOwned: 3 }], + ["priority-contract", { priorityDeliveries: 0 }, { priorityDeliveries: 1 }], ["first-discovery", { locationsDiscovered: 0 }, { locationsDiscovered: 1 }], ["loan-paid-off", { loanBalance: 1 }, { loanBalance: 0 }], ]; @@ -42,13 +43,20 @@ describe("onboarding goal evaluation", () => { }, ); - it("needs a contract flown by the veteran, not just the avatar's deliveries (CR-010)", () => { + it("doesn't count the deal's two ships as a Market purchase; a bought Lighter does (CR-013)", () => { + expect(isComplete("market-ship", fresh)).toBe(false); + expect(goals.find((g) => g.id === "market-ship")?.title).toBe("Buy a ship from the Market"); + // After buying a Lighter: three ships, one of them from the Market. + expect(isComplete("market-ship", { ...fresh, shipsOwned: 3, marketShipsBought: 1 })).toBe(true); + }); + + it("needs a Priority delivery, not just any deliveries (CR-013)", () => { + expect(isComplete("priority-contract", { ...fresh, ownShipDeliveries: 12 })).toBe(false); expect( - isComplete("veteran-contract", { ...fresh, ownShipDeliveries: 12, pilotsHired: 1 }), - ).toBe(false); - expect(isComplete("veteran-contract", { ...fresh, veteranContracts: 1 })).toBe(true); - expect(goals.find((g) => g.id === "veteran-contract")?.title).toBe( - `Fly a contract with ${content.veteranPilot.name}`, + isComplete("priority-contract", { ...fresh, ownShipDeliveries: 1, priorityDeliveries: 1 }), + ).toBe(true); + expect(goals.find((g) => g.id === "priority-contract")?.title).toBe( + "Complete a Priority contract", ); }); @@ -59,8 +67,8 @@ describe("onboarding goal evaluation", () => { }); it("returns completed goals in content order, identically for identical inputs", () => { - const progress: GoalProgress = { ...fresh, veteranContracts: 3, ownShipDeliveries: 5 }; - expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "veteran-contract"]); + const progress: GoalProgress = { ...fresh, priorityDeliveries: 3, ownShipDeliveries: 5 }; + expect(completedGoals(goals, progress)).toEqual(["first-own-delivery", "priority-contract"]); expect(completedGoals(goals, progress)).toEqual(completedGoals(goals, { ...progress })); }); });