diff --git a/drizzle/0005_short_guardsmen.sql b/drizzle/0005_short_guardsmen.sql new file mode 100644 index 00000000..c3564ebf --- /dev/null +++ b/drizzle/0005_short_guardsmen.sql @@ -0,0 +1 @@ +ALTER TABLE "wanjiedaoyou_cultivator_state_versions" ADD COLUMN "loadout_version" bigint DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000..ebbe7734 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,4701 @@ +{ + "id": "a4d2992b-ca85-470a-8b0a-b198e792aaae", + "prevId": "062e9e60-4bc2-42d6-93ca-75702388e1a0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.wanjiedaoyou_admin_message_templates": { + "name": "wanjiedaoyou_admin_message_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel": { + "name": "channel", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "subject_template": { + "name": "subject_template", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false + }, + "content_template": { + "name": "content_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_payload": { + "name": "default_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "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": { + "admin_templates_channel_status_created_idx": { + "name": "admin_templates_channel_status_created_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_alchemy_formulas": { + "name": "wanjiedaoyou_alchemy_formulas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "family": { + "name": "family", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "blueprint": { + "name": "blueprint", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "mastery": { + "name": "mastery", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"level\":0,\"exp\":0}'::jsonb" + }, + "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": { + "alchemy_formulas_cultivator_updated_idx": { + "name": "alchemy_formulas_cultivator_updated_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alchemy_formulas_cultivator_family_idx": { + "name": "alchemy_formulas_cultivator_family_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "family", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_alchemy_formulas_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_alchemy_formulas_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_alchemy_formulas", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_app_settings": { + "name": "wanjiedaoyou_app_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_auction_listings": { + "name": "wanjiedaoyou_auction_listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "seller_id": { + "name": "seller_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seller_name": { + "name": "seller_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_name": { + "name": "item_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_quality": { + "name": "item_quality", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_category": { + "name": "item_category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "item_snapshot": { + "name": "item_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "target_cultivator_id": { + "name": "target_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_cultivator_name": { + "name": "target_cultivator_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sold_at": { + "name": "sold_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auction_status_expires_created_idx": { + "name": "auction_status_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_seller_status_idx": { + "name": "auction_seller_status_idx", + "columns": [ + { + "expression": "seller_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_expires_price_idx": { + "name": "auction_status_expires_price_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "price", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_type_expires_created_idx": { + "name": "auction_status_type_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_expires_item_type_idx": { + "name": "auction_status_expires_item_type_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_type_category_expires_created_idx": { + "name": "auction_status_type_category_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_type_quality_expires_created_idx": { + "name": "auction_status_type_quality_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_quality", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_item_name_expires_created_idx": { + "name": "auction_status_item_name_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_status_seller_name_expires_created_idx": { + "name": "auction_status_seller_name_expires_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seller_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auction_visibility_target_status_idx": { + "name": "auction_visibility_target_status_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_auction_listings_seller_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_auction_listings_seller_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_auction_listings", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "seller_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_auction_listings_target_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_auction_listings_target_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_auction_listings", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "target_cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_battle_records": { + "name": "wanjiedaoyou_battle_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "challenge_type": { + "name": "challenge_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "opponent_cultivator_id": { + "name": "opponent_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "battle_result": { + "name": "battle_result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "battle_report": { + "name": "battle_report", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "battle_records_cultivator_created_idx": { + "name": "battle_records_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "battle_records_opponent_created_idx": { + "name": "battle_records_opponent_created_idx", + "columns": [ + { + "expression": "opponent_cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_battle_records_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_battle_records_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_battle_records", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_battle_records_opponent_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_battle_records_opponent_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_battle_records", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "opponent_cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_battle_records_v2": { + "name": "wanjiedaoyou_battle_records_v2", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "battle_type": { + "name": "battle_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "opponent_cultivator_id": { + "name": "opponent_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "engine_version": { + "name": "engine_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "default": "'battle-v5'" + }, + "result_version": { + "name": "result_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "battle_result": { + "name": "battle_result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "battle_report": { + "name": "battle_report", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "battle_records_v2_cultivator_created_idx": { + "name": "battle_records_v2_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "battle_records_v2_opponent_created_idx": { + "name": "battle_records_v2_opponent_created_idx", + "columns": [ + { + "expression": "opponent_cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "battle_records_v2_user_created_idx": { + "name": "battle_records_v2_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_battle_records_v2_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_battle_records_v2_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_battle_records_v2", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_battle_records_v2_opponent_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_battle_records_v2_opponent_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_battle_records_v2", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "opponent_cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_bet_battles": { + "name": "wanjiedaoyou_bet_battles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "creator_name": { + "name": "creator_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "min_realm": { + "name": "min_realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "max_realm": { + "name": "max_realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "taunt": { + "name": "taunt", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "creator_stake_snapshot": { + "name": "creator_stake_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "challenger_stake_snapshot": { + "name": "challenger_stake_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "challenger_id": { + "name": "challenger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "challenger_name": { + "name": "challenger_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "winner_cultivator_id": { + "name": "winner_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "battle_record_id": { + "name": "battle_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "battle_record_v2_id": { + "name": "battle_record_v2_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "matched_at": { + "name": "matched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bet_battles_status_expires_idx": { + "name": "bet_battles_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bet_battles_creator_status_idx": { + "name": "bet_battles_creator_status_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bet_battles_status_created_idx": { + "name": "bet_battles_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_bet_battles_creator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_bet_battles_creator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_bet_battles", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_bet_battles_challenger_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_bet_battles_challenger_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_bet_battles", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "challenger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "wanjiedaoyou_bet_battles_winner_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_bet_battles_winner_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_bet_battles", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "winner_cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "wanjiedaoyou_bet_battles_battle_record_id_wanjiedaoyou_battle_records_id_fk": { + "name": "wanjiedaoyou_bet_battles_battle_record_id_wanjiedaoyou_battle_records_id_fk", + "tableFrom": "wanjiedaoyou_bet_battles", + "tableTo": "wanjiedaoyou_battle_records", + "columnsFrom": [ + "battle_record_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "wanjiedaoyou_bet_battles_battle_record_v2_id_wanjiedaoyou_battle_records_v2_id_fk": { + "name": "wanjiedaoyou_bet_battles_battle_record_v2_id_wanjiedaoyou_battle_records_v2_id_fk", + "tableFrom": "wanjiedaoyou_bet_battles", + "tableTo": "wanjiedaoyou_battle_records_v2", + "columnsFrom": [ + "battle_record_v2_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_breakthrough_history": { + "name": "wanjiedaoyou_breakthrough_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_realm": { + "name": "from_realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "from_stage": { + "name": "from_stage", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "to_realm": { + "name": "to_realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "to_stage": { + "name": "to_stage", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "years_spent": { + "name": "years_spent", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "story": { + "name": "story", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "breakthrough_history_cultivator_created_idx": { + "name": "breakthrough_history_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_breakthrough_history_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_breakthrough_history_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_breakthrough_history", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_consumables": { + "name": "wanjiedaoyou_consumables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "quality": { + "name": "quality", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'凡品'" + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "consumables_cultivator_idx": { + "name": "consumables_cultivator_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consumables_cultivator_name_quality_idx": { + "name": "consumables_cultivator_name_quality_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quality", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consumables_score_idx": { + "name": "consumables_score_idx", + "columns": [ + { + "expression": "score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_consumables_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_consumables_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_consumables", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_creation_products": { + "name": "wanjiedaoyou_creation_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "element": { + "name": "element", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "slot": { + "name": "slot", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_equipped": { + "name": "is_equipped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "product_model": { + "name": "product_model", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "creation_products_cultivator_type_idx": { + "name": "creation_products_cultivator_type_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "creation_products_type_score_idx": { + "name": "creation_products_type_score_idx", + "columns": [ + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "creation_products_equipped_idx": { + "name": "creation_products_equipped_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_equipped", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_creation_products_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_creation_products_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_creation_products", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_friends": { + "name": "wanjiedaoyou_cultivator_friends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "friend_cultivator_id": { + "name": "friend_cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cultivator_friends_pair_uidx": { + "name": "cultivator_friends_pair_uidx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "friend_cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cultivator_friends_friend_idx": { + "name": "cultivator_friends_friend_idx", + "columns": [ + { + "expression": "friend_cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_cultivator_friends_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_friends_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_friends", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_cultivator_friends_friend_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_friends_friend_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_friends", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "friend_cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_state_versions": { + "name": "wanjiedaoyou_cultivator_state_versions", + "schema": "", + "columns": { + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "global_version": { + "name": "global_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "profile_version": { + "name": "profile_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "condition_version": { + "name": "condition_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "progress_version": { + "name": "progress_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency_version": { + "name": "currency_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inventory_version": { + "name": "inventory_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "products_version": { + "name": "products_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "loadout_version": { + "name": "loadout_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "mail_version": { + "name": "mail_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tasks_version": { + "name": "tasks_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wanjiedaoyou_cultivator_state_versions_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_state_versions_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_state_versions", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivator_tasks": { + "name": "wanjiedaoyou_cultivator_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "current_stage": { + "name": "current_stage", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "objectives": { + "name": "objectives", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "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": false, + "default": "now()" + } + }, + "indexes": { + "cultivator_tasks_cultivator_status_updated_idx": { + "name": "cultivator_tasks_cultivator_status_updated_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cultivator_tasks_cultivator_definition_unique": { + "name": "cultivator_tasks_cultivator_definition_unique", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_cultivator_tasks_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_cultivator_tasks_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_cultivator_tasks", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_cultivators": { + "name": "wanjiedaoyou_cultivators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "personality": { + "name": "personality", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background": { + "name": "background", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "realm_stage": { + "name": "realm_stage", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 18 + }, + "lifespan": { + "name": "lifespan", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "closed_door_years_total": { + "name": "closed_door_years_total", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "died_at": { + "name": "died_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "vitality": { + "name": "vitality", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "spirit": { + "name": "spirit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wisdom": { + "name": "wisdom", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "speed": { + "name": "speed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "willpower": { + "name": "willpower", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unallocated_attribute_points": { + "name": "unallocated_attribute_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spirit_stones": { + "name": "spirit_stones", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reputation": { + "name": "reputation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "qi": { + "name": "qi", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 200 + }, + "qi_last_refreshed_at": { + "name": "qi_last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_yield_at": { + "name": "last_yield_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "max_skills": { + "name": "max_skills", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "balance_notes": { + "name": "balance_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "condition": { + "name": "condition", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "game_settings": { + "name": "game_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cultivation_progress": { + "name": "cultivation_progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "cultivators_user_status_updated_idx": { + "name": "cultivators_user_status_updated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cultivators_status_created_idx": { + "name": "cultivators_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_dungeon_histories": { + "name": "wanjiedaoyou_dungeon_histories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "log": { + "name": "log", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "real_gains": { + "name": "real_gains", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dungeon_histories_cultivator_created_idx": { + "name": "dungeon_histories_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_dungeon_histories_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_dungeon_histories_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_dungeon_histories", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_dungeon_runs": { + "name": "wanjiedaoyou_dungeon_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "map_node_id": { + "name": "map_node_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "default": "'EXPLORING'" + }, + "current_round": { + "name": "current_round", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "max_rounds": { + "name": "max_rounds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "danger_score": { + "name": "danger_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "run_state": { + "name": "run_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "cost_ledger": { + "name": "cost_ledger", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "gain_ledger": { + "name": "gain_ledger", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pending_action": { + "name": "pending_action", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "active_battle_id": { + "name": "active_battle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "battle_payload": { + "name": "battle_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dungeon_runs_cultivator_status_updated_idx": { + "name": "dungeon_runs_cultivator_status_updated_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dungeon_runs_status_updated_idx": { + "name": "dungeon_runs_status_updated_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_dungeon_runs_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_dungeon_runs_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_dungeon_runs", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_feedbacks": { + "name": "wanjiedaoyou_feedbacks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "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": { + "feedback_user_created_at_idx": { + "name": "feedback_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_status_type_created_at_idx": { + "name": "feedback_status_type_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_feedbacks_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_feedbacks_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_feedbacks", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_item_library": { + "name": "wanjiedaoyou_item_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_id": { + "name": "item_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'published'" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quality": { + "name": "quality", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "element": { + "name": "element", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "sample_key": { + "name": "sample_key", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "editor_config": { + "name": "editor_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "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": { + "item_library_item_id_unique": { + "name": "item_library_item_id_unique", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "item_library_status_type_idx": { + "name": "item_library_status_type_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "item_library_material_sample_idx": { + "name": "item_library_material_sample_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quality", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sample_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "item_library_name_idx": { + "name": "item_library_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_mails": { + "name": "wanjiedaoyou_mails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mails_cultivator_created_idx": { + "name": "mails_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mails_cultivator_is_read_created_idx": { + "name": "mails_cultivator_is_read_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_mails_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_mails_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_mails", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_materials": { + "name": "wanjiedaoyou_materials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "rank": { + "name": "rank", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "element": { + "name": "element", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "materials_cultivator_idx": { + "name": "materials_cultivator_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "materials_cultivator_name_idx": { + "name": "materials_cultivator_name_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "materials_cultivator_name_rank_idx": { + "name": "materials_cultivator_name_rank_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rank", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_materials_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_materials_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_materials", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_player_state_events": { + "name": "wanjiedaoyou_player_state_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "wanjiedaoyou_player_state_events_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "global_version": { + "name": "global_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "domain_version": { + "name": "domain_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "patch": { + "name": "patch", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "invalidates": { + "name": "invalidates", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "source": { + "name": "source", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "player_state_events_cultivator_version_idx": { + "name": "player_state_events_cultivator_version_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "global_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "player_state_events_user_idx": { + "name": "player_state_events_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "player_state_events_created_idx": { + "name": "player_state_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_player_state_events_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_player_state_events_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_player_state_events", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_pre_heaven_fates": { + "name": "wanjiedaoyou_pre_heaven_fates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quality": { + "name": "quality", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "effects": { + "name": "effects", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "registry_key": { + "name": "registry_key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "pre_heaven_fates_cultivator_idx": { + "name": "pre_heaven_fates_cultivator_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_pre_heaven_fates_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_pre_heaven_fates_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_pre_heaven_fates", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_qi_logs": { + "name": "wanjiedaoyou_qi_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "action_instance_id": { + "name": "action_instance_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "qi_cost": { + "name": "qi_cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "qi_gain": { + "name": "qi_gain", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "qi_before": { + "name": "qi_before", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "qi_after": { + "name": "qi_after", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "qi_logs_action_instance_uidx": { + "name": "qi_logs_action_instance_uidx", + "columns": [ + { + "expression": "action_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "qi_logs_cultivator_created_idx": { + "name": "qi_logs_cultivator_created_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "qi_logs_status_created_idx": { + "name": "qi_logs_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_qi_logs_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_qi_logs_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_qi_logs", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_redeem_code_claims": { + "name": "wanjiedaoyou_redeem_code_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "redeem_code_id": { + "name": "redeem_code_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mail_id": { + "name": "mail_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "redeem_code_claims_code_user_unique": { + "name": "redeem_code_claims_code_user_unique", + "columns": [ + { + "expression": "redeem_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "redeem_code_claims_user_idx": { + "name": "redeem_code_claims_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "redeem_code_claims_code_idx": { + "name": "redeem_code_claims_code_idx", + "columns": [ + { + "expression": "redeem_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_redeem_code_claims_redeem_code_id_wanjiedaoyou_redeem_codes_id_fk": { + "name": "wanjiedaoyou_redeem_code_claims_redeem_code_id_wanjiedaoyou_redeem_codes_id_fk", + "tableFrom": "wanjiedaoyou_redeem_code_claims", + "tableTo": "wanjiedaoyou_redeem_codes", + "columnsFrom": [ + "redeem_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_redeem_code_claims_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_redeem_code_claims_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_redeem_code_claims", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_redeem_code_claims_mail_id_wanjiedaoyou_mails_id_fk": { + "name": "wanjiedaoyou_redeem_code_claims_mail_id_wanjiedaoyou_mails_id_fk", + "tableFrom": "wanjiedaoyou_redeem_code_claims", + "tableTo": "wanjiedaoyou_mails", + "columnsFrom": [ + "mail_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_redeem_codes": { + "name": "wanjiedaoyou_redeem_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "reward_preset_id": { + "name": "reward_preset_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "reward_attachments": { + "name": "reward_attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mail_title": { + "name": "mail_title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "mail_content": { + "name": "mail_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "total_limit": { + "name": "total_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_count": { + "name": "claimed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "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": { + "redeem_codes_code_unique": { + "name": "redeem_codes_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "redeem_codes_status_created_idx": { + "name": "redeem_codes_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "redeem_codes_created_idx": { + "name": "redeem_codes_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_reputation_shop_items": { + "name": "wanjiedaoyou_reputation_shop_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_library_item_id": { + "name": "item_library_item_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "per_user_limit": { + "name": "per_user_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "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": { + "reputation_shop_item_library_item_uidx": { + "name": "reputation_shop_item_library_item_uidx", + "columns": [ + { + "expression": "item_library_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reputation_shop_status_sort_idx": { + "name": "reputation_shop_status_sort_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_reputation_shop_items_item_library_item_id_wanjiedaoyou_item_library_item_id_fk": { + "name": "wanjiedaoyou_reputation_shop_items_item_library_item_id_wanjiedaoyou_item_library_item_id_fk", + "tableFrom": "wanjiedaoyou_reputation_shop_items", + "tableTo": "wanjiedaoyou_item_library", + "columnsFrom": [ + "item_library_item_id" + ], + "columnsTo": [ + "item_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_reputation_shop_purchases": { + "name": "wanjiedaoyou_reputation_shop_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shop_item_id": { + "name": "shop_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_library_item_id": { + "name": "item_library_item_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reputation_cost": { + "name": "reputation_cost", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchase_week": { + "name": "purchase_week", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reputation_shop_purchases_cultivator_item_idx": { + "name": "reputation_shop_purchases_cultivator_item_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shop_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reputation_shop_purchases_week_idx": { + "name": "reputation_shop_purchases_week_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shop_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchase_week", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reputation_shop_purchases_created_idx": { + "name": "reputation_shop_purchases_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_reputation_shop_purchases_shop_item_id_wanjiedaoyou_reputation_shop_items_id_fk": { + "name": "wanjiedaoyou_reputation_shop_purchases_shop_item_id_wanjiedaoyou_reputation_shop_items_id_fk", + "tableFrom": "wanjiedaoyou_reputation_shop_purchases", + "tableTo": "wanjiedaoyou_reputation_shop_items", + "columnsFrom": [ + "shop_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wanjiedaoyou_reputation_shop_purchases_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_reputation_shop_purchases_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_reputation_shop_purchases", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_retreat_records": { + "name": "wanjiedaoyou_retreat_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "realm_stage": { + "name": "realm_stage", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "years": { + "name": "years", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "chance": { + "name": "chance", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "roll": { + "name": "roll", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "modifiers": { + "name": "modifiers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "retreat_records_cultivator_timestamp_idx": { + "name": "retreat_records_cultivator_timestamp_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_retreat_records_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_retreat_records_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_retreat_records", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_spiritual_roots": { + "name": "wanjiedaoyou_spiritual_roots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cultivator_id": { + "name": "cultivator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "element": { + "name": "element", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "strength": { + "name": "strength", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grade": { + "name": "grade", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "spiritual_roots_cultivator_idx": { + "name": "spiritual_roots_cultivator_idx", + "columns": [ + { + "expression": "cultivator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wanjiedaoyou_spiritual_roots_cultivator_id_wanjiedaoyou_cultivators_id_fk": { + "name": "wanjiedaoyou_spiritual_roots_cultivator_id_wanjiedaoyou_cultivators_id_fk", + "tableFrom": "wanjiedaoyou_spiritual_roots", + "tableTo": "wanjiedaoyou_cultivators", + "columnsFrom": [ + "cultivator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wanjiedaoyou_tower_enemy_floors": { + "name": "wanjiedaoyou_tower_enemy_floors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_key": { + "name": "season_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "enemy": { + "name": "enemy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tower_enemy_floors_season_realm_floor_uidx": { + "name": "tower_enemy_floors_season_realm_floor_uidx", + "columns": [ + { + "expression": "season_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "floor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tower_enemy_floors_realm_floor_generated_idx": { + "name": "tower_enemy_floors_realm_floor_generated_idx", + "columns": [ + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "floor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tower_enemy_floors_season_realm_status_idx": { + "name": "tower_enemy_floors_season_realm_status_idx", + "columns": [ + { + "expression": "season_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 51a00fa3..1c0708a9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1782790790603, "tag": "0004_adorable_sasquatch", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1782879152610, + "tag": "0005_short_guardsmen", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/react-app/components/feature/cultivator/BodyCultivationPanels.tsx b/src/react-app/components/feature/cultivator/BodyCultivationPanels.tsx index a21fab1f..4a87b5fe 100644 --- a/src/react-app/components/feature/cultivator/BodyCultivationPanels.tsx +++ b/src/react-app/components/feature/cultivator/BodyCultivationPanels.tsx @@ -1,18 +1,8 @@ import { GameSceneSection, } from '@app/components/game-shell/GameSceneSection'; -import { useInkUI } from '@app/components/providers/InkUIProvider'; import { InkBadge, InkButton, InkNotice } from '@app/components/ui'; -import { - usePlayerStateDomainVersion, - usePlayerStateView, -} from '@app/lib/player-state/selectors'; -import { consumePlayerStateMutation } from '@app/lib/player-state/store'; -import type { ApiFailure } from '@shared/contracts/http'; -import type { - BodyCultivationBreakthroughReadinessData, - BodyCultivationBreakthroughReadinessResponse, -} from '@shared/contracts/bodyCultivation'; +import { usePlayerStateView } from '@app/lib/player-state/selectors'; import { cn } from '@shared/lib/cn'; import { getBodyCultivationSummary, @@ -20,13 +10,7 @@ import { type BodyCultivationTrackSummary, } from '@shared/lib/bodyCultivation/summary'; import type { Cultivator } from '@shared/types/cultivator'; -import { useEffect, useState, type ReactNode } from 'react'; - -type BreakthroughReadinessState = { - readiness: BodyCultivationBreakthroughReadinessData | null; - error: { realmKey: string; message: string } | null; - pending: boolean; -}; +import { type ReactNode } from 'react'; function BodyMetric({ label, @@ -70,20 +54,6 @@ function RequirementLine({ ); } -function formatRequirementCost( - readiness: BodyCultivationBreakthroughReadinessData | null, -): string | null { - const requirements = readiness?.inventoryRequirements ?? []; - if (requirements.length === 0) return null; - - return requirements - .map( - (requirement) => - `${requirement.met ? '✓' : '·'} ${requirement.label ?? requirement.name} ${requirement.ownedQuantity}/${requirement.quantity}`, - ) - .join(' / '); -} - function getTrackProgressPercent(track: BodyCultivationTrackSummary): number { return Math.round( Math.max(0, Math.min(track.progress / track.threshold, 1)) * 100, @@ -230,68 +200,6 @@ function BodyCultivationTrackGrid({ ); } -function useBodyBreakthroughReadiness( - nextRealmKey: string | null, -): BreakthroughReadinessState { - const conditionVersion = usePlayerStateDomainVersion('condition'); - const inventoryVersion = usePlayerStateDomainVersion('inventory'); - const [readiness, setReadiness] = - useState(null); - const [error, setError] = useState<{ - realmKey: string; - message: string; - } | null>(null); - - useEffect(() => { - if (!nextRealmKey) { - return; - } - - const abortController = new AbortController(); - - void fetch('/api/cultivator/body-cultivation/breakthrough', { - signal: abortController.signal, - }) - .then(async (response) => { - const payload = (await response.json()) as - | BodyCultivationBreakthroughReadinessResponse - | ApiFailure; - if (!response.ok || !payload.success) { - throw new Error( - 'error' in payload ? payload.error : '进阶材料读取失败', - ); - } - setReadiness(payload.data); - setError(null); - }) - .catch((caught) => { - if (abortController.signal.aborted) return; - setReadiness(null); - setError({ - realmKey: nextRealmKey, - message: - caught instanceof Error ? caught.message : '进阶材料读取失败', - }); - }); - - return () => abortController.abort(); - }, [conditionVersion, inventoryVersion, nextRealmKey]); - - if (!nextRealmKey) { - return { - readiness: null, - error: null, - pending: false, - }; - } - - return { - readiness, - error, - pending: Boolean(nextRealmKey) && !readiness && !error, - }; -} - export function BodyCultivationSummaryContent({ summary, dense = false, @@ -362,86 +270,19 @@ export function BodyCultivationEntrySection() { export function BodyCultivationDetailPanel() { const { cultivator } = usePlayerStateView(); - const { pushToast } = useInkUI(); - const [breakthroughPending, setBreakthroughPending] = useState(false); const summary = cultivator ? getBodyCultivationSummary(cultivator.condition, { cultivatorRealm: cultivator.realm, }) : null; const nextRealm = summary?.nextRealm ?? null; - const readinessState = useBodyBreakthroughReadiness(nextRealm?.key ?? null); if (!cultivator || !summary) return 尚无角色资料。; - const matchingReadiness = - readinessState.readiness && - readinessState.readiness.nextRealm === nextRealm?.key - ? readinessState.readiness - : null; - const matchingError = - readinessState.error && readinessState.error.realmKey === nextRealm?.key - ? readinessState.error.message - : null; - const inventoryReady = - (matchingReadiness?.inventoryRequirements ?? []).length > 0 && - (matchingReadiness?.inventoryRequirements ?? []).every( - (requirement) => requirement.met, - ); - const canAttemptBreakthrough = - Boolean(nextRealm?.canAttempt) && Boolean(matchingReadiness?.canAttempt); - const breakthroughStatus = readinessState.pending - ? '读取中' - : canAttemptBreakthrough - ? '可进阶' - : matchingError - ? '读取失败' - : nextRealm?.canAttempt - ? inventoryReady - ? '材料已齐' - : '材料不足' - : '条件未齐'; - const costText = matchingError - ? matchingError - : readinessState.pending - ? '正在读取所需材料和丹药' - : formatRequirementCost(matchingReadiness); - - const handleBodyBreakthrough = async () => { - if (!canAttemptBreakthrough || breakthroughPending) return; - const targetRealmLabel = nextRealm?.label ?? '下一阶'; - - setBreakthroughPending(true); - try { - const response = await fetch('/api/cultivator/body-cultivation/breakthrough', { - method: 'POST', - }); - const payload = await response.json(); - - if (!response.ok || !payload.success) { - throw new Error(payload.error || '肉身进阶失败'); - } - - await consumePlayerStateMutation(payload); - const result = payload.data as { - success?: boolean; - guaranteeProgress?: number; - }; - pushToast({ - message: - result.success === false - ? `肉身进阶失败,保底进度 ${Math.floor(result.guaranteeProgress ?? 0)}%` - : `肉身已提升到${targetRealmLabel}`, - tone: result.success === false ? 'warning' : 'success', - }); - } catch (caught) { - pushToast({ - message: caught instanceof Error ? caught.message : '肉身进阶失败', - tone: 'danger', - }); - } finally { - setBreakthroughPending(false); - } - }; + const breakthroughStatus = nextRealm + ? nextRealm.canAttempt + ? '可破限' + : '条件未齐' + : '已圆满'; return (
@@ -450,21 +291,15 @@ export function BodyCultivationDetailPanel() { summary={summary} nextRealm={nextRealm} status={breakthroughStatus} - statusTone={canAttemptBreakthrough ? 'success' : 'default'} + statusTone={nextRealm?.canAttempt ? 'success' : 'default'} action={ nextRealm?.canAttempt ? ( - {breakthroughPending - ? '进阶中' - : readinessState.pending - ? '读取中' - : `提升到${nextRealm.label}`} + 突破 ) : null } @@ -474,13 +309,8 @@ export function BodyCultivationDetailPanel() {
- {Math.round((matchingReadiness?.successChance ?? 0) * 100)} - % · 保底进度 {matchingReadiness?.guaranteeProgress ?? 0}% - - } + label="破限入口" + value={nextRealm.canAttempt ? '可进入准备' : '继续炼体'} />
@@ -490,11 +320,6 @@ export function BodyCultivationDetailPanel() { ))}
- {costText ? ( -

- 所需材料和丹药:{costText} -

- ) : null}
) : (

diff --git a/src/react-app/components/feature/cultivator/PersistentStatusesCard.tsx b/src/react-app/components/feature/cultivator/PersistentStatusesCard.tsx index a6706dc3..939379a7 100644 --- a/src/react-app/components/feature/cultivator/PersistentStatusesCard.tsx +++ b/src/react-app/components/feature/cultivator/PersistentStatusesCard.tsx @@ -1,11 +1,6 @@ import { GameSceneSection } from '@app/components/game-shell/GameSceneSection'; -import { useInkUI } from '@app/components/providers/InkUIProvider'; import { InkButton, InkDialog, type InkDialogState } from '@app/components/ui'; -import { consumePlayerStateMutation } from '@app/lib/player-state/store'; -import { - usePlayerStateDomainVersion, - usePlayerStateView, -} from '@app/lib/player-state/selectors'; +import { usePlayerStateView } from '@app/lib/player-state/selectors'; import { cn } from '@shared/lib/cn'; import { getBreakthroughPenaltyPercent, @@ -20,16 +15,11 @@ import { getGameConceptInfo } from '@shared/lib/gameConceptDisplay'; import { getResourceLabel, getResourceText } from '@shared/lib/gameConceptDisplay'; import { getBodyCultivationSummary } from '@shared/lib/bodyCultivation/summary'; import { getAllTrackConfigs } from '@shared/lib/trackConfigRegistry'; -import type { ApiFailure } from '@shared/contracts/http'; -import type { - BodyCultivationBreakthroughReadinessData, - BodyCultivationBreakthroughReadinessResponse, -} from '@shared/contracts/bodyCultivation'; import type { ConditionStatusInstance, ConditionTrackPath, } from '@shared/types/condition'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { getPillToxicityEffectDetails, getStatusEffectDetails, @@ -438,136 +428,13 @@ export function CultivatorCurrentStatusSection() { export function CultivatorTrackSection() { const state = usePersistentStatusState(); - const { pushToast } = useInkUI(); - const conditionVersion = usePlayerStateDomainVersion('condition'); - const inventoryVersion = usePlayerStateDomainVersion('inventory'); - const [breakthroughPending, setBreakthroughPending] = useState(false); - const [readinessError, setReadinessError] = useState<{ - realmKey: string; - message: string; - } | null>(null); - const [breakthroughReadiness, setBreakthroughReadiness] = - useState(null); - const nextRealm = state?.bodySummary.nextRealm ?? null; - const nextRealmKey = nextRealm?.key ?? null; - - useEffect(() => { - if (!nextRealmKey) { - return; - } - - const abortController = new AbortController(); - - void fetch('/api/cultivator/body-cultivation/breakthrough', { - signal: abortController.signal, - }) - .then(async (response) => { - const payload = (await response.json()) as - | BodyCultivationBreakthroughReadinessResponse - | ApiFailure; - if (!response.ok || !payload.success) { - throw new Error( - 'error' in payload ? payload.error : '肉身进阶条件读取失败', - ); - } - setBreakthroughReadiness(payload.data); - setReadinessError(null); - }) - .catch((error) => { - if (abortController.signal.aborted) return; - setBreakthroughReadiness(null); - setReadinessError({ - realmKey: nextRealmKey, - message: - error instanceof Error ? error.message : '肉身进阶条件读取失败', - }); - }); - - return () => abortController.abort(); - }, [conditionVersion, inventoryVersion, nextRealmKey]); if (!state || state.trackEntries.length === 0) { return null; } - const matchingReadiness = - breakthroughReadiness && breakthroughReadiness.nextRealm === nextRealm?.key - ? breakthroughReadiness - : null; - const matchingReadinessError = - readinessError && readinessError.realmKey === nextRealm?.key - ? readinessError.message - : null; - const inventoryRequirements = matchingReadiness?.inventoryRequirements ?? []; - const readinessPending = - Boolean(nextRealm) && !matchingReadiness && !matchingReadinessError; - const inventoryReady = - inventoryRequirements.length > 0 && - inventoryRequirements.every((requirement) => requirement.met); - const canAttemptBreakthrough = - Boolean(nextRealm?.canAttempt) && - Boolean(matchingReadiness?.canAttempt); - const breakthroughStatus = readinessPending - ? '读取中' - : canAttemptBreakthrough - ? '可进阶' - : matchingReadinessError - ? '读取失败' - : nextRealm?.canAttempt - ? inventoryReady - ? '待确认' - : '缺材料' - : '未满足'; - const costText = - inventoryRequirements.length > 0 - ? inventoryRequirements - .map( - (requirement) => - `${requirement.met ? '✓' : '·'} ${requirement.label ?? requirement.name} ${requirement.ownedQuantity}/${requirement.quantity}`, - ) - .join(' / ') - : readinessPending - ? '正在读取所需材料和丹药' - : matchingReadinessError - ? matchingReadinessError - : undefined; - const handleBodyBreakthrough = async () => { - if (!canAttemptBreakthrough || breakthroughPending) return; - const targetRealmLabel = nextRealm?.label ?? '下一阶'; - - setBreakthroughPending(true); - try { - const response = await fetch('/api/cultivator/body-cultivation/breakthrough', { - method: 'POST', - }); - const payload = await response.json(); - - if (!response.ok || !payload.success) { - throw new Error(payload.error || '肉身进阶失败'); - } - - await consumePlayerStateMutation(payload); - const result = payload.data as { - success?: boolean; - guaranteeProgress?: number; - }; - pushToast({ - message: - result.success === false - ? `肉身进阶失败,保底进度 ${Math.floor(result.guaranteeProgress ?? 0)}%` - : `肉身已提升到${targetRealmLabel}`, - tone: result.success === false ? 'warning' : 'success', - }); - } catch (error) { - pushToast({ - message: error instanceof Error ? error.message : '肉身进阶失败', - tone: 'danger', - }); - } finally { - setBreakthroughPending(false); - } - }; + const breakthroughStatus = nextRealm?.canAttempt ? '可破限' : '未满足'; return ( @@ -592,9 +459,7 @@ export function CultivatorTrackSection() { 下阶·{nextRealm.label}

- {nextRealm.unlockText} · 成功率{' '} - {Math.round((matchingReadiness?.successChance ?? 0) * 100)} - % · 保底进度 {matchingReadiness?.guaranteeProgress ?? 0}% + {nextRealm.unlockText}
@@ -613,9 +478,6 @@ export function CultivatorTrackSection() { ))} - {costText ? ( -
材料:{costText}
- ) : null} ) : null} @@ -623,17 +485,11 @@ export function CultivatorTrackSection() { {nextRealm?.canAttempt ? (
- {breakthroughPending - ? '进阶中' - : readinessPending - ? '读取中' - : `提升到${nextRealm.label}`} + 前往破限
) : null} diff --git a/src/react-app/components/feature/products/abilityDisplay.test.ts b/src/react-app/components/feature/products/abilityDisplay.test.ts index 3f86e4af..ae3d143b 100644 --- a/src/react-app/components/feature/products/abilityDisplay.test.ts +++ b/src/react-app/components/feature/products/abilityDisplay.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { composeProductFromAffixIds } from '@shared/engine/creation-v2/composeProductFromAffixIds'; import { serializeProductModel } from '@shared/engine/creation-v2/persistence/ProductPersistenceMapper'; +import { getArtifactRealmGrowthFactor } from '@shared/engine/shared/artifactRealmScaling'; import { toProductDisplayModel } from './abilityDisplay'; describe('abilityDisplay', () => { @@ -168,10 +169,9 @@ describe('abilityDisplay', () => { }); expect(displayModel.modifiers).toHaveLength(2); - expect(displayModel.modifiers[0].raw.value).toBeCloseTo( - 219.359465258856, - 6, - ); - expect(displayModel.affixes[0].effectText).toContain('+219'); + const expectedValue = + (6 + 2.5 * 6) * getArtifactRealmGrowthFactor('化神', '中期'); + expect(displayModel.modifiers[0].raw.value).toBeCloseTo(expectedValue, 6); + expect(displayModel.affixes[0].effectText).toContain('+101'); }); }); diff --git a/src/react-app/components/feature/tasks/TutorialTaskCard.tsx b/src/react-app/components/feature/tasks/TutorialTaskCard.tsx index d099eadd..f4c0d9a6 100644 --- a/src/react-app/components/feature/tasks/TutorialTaskCard.tsx +++ b/src/react-app/components/feature/tasks/TutorialTaskCard.tsx @@ -1,9 +1,7 @@ import { useInkUI } from '@app/components/providers/InkUIProvider'; import { InkButton } from '@app/components/ui/InkButton'; -import { usePlayerStateView } from '@app/lib/player-state/selectors'; import { claimTaskReward } from '@app/lib/tasks/taskClient'; import { cn } from '@shared/lib/cn'; -import { getNoviceEquipmentState } from '@shared/lib/noviceGuidance'; import type { TaskInstance } from '@shared/types/task'; import { useState } from 'react'; @@ -38,7 +36,6 @@ export function TutorialTaskCard({ className?: string; }) { const { pushToast } = useInkUI(); - const { cultivator } = usePlayerStateView(); const [claiming, setClaiming] = useState(false); const currentStage = task.snapshot.stages.find((stage) => stage.current) ?? @@ -47,13 +44,7 @@ export function TutorialTaskCard({ const rewardSummary = task.snapshot.rewardSummary ?? task.metadata.rewardSummary ?? []; const rewardClaimedAt = task.snapshot.rewardClaimedAt ?? task.metadata.rewardClaimedAt; - const needsStarterRewardRepair = - task.definitionId === 'tutorial_starter_supply' && - Boolean(rewardClaimedAt) && - !task.metadata.rewardGrantedKey && - Boolean(cultivator && !getNoviceEquipmentState(cultivator).hasFullSet); - const canClaim = - task.status === 'completed' && (!rewardClaimedAt || needsStarterRewardRepair); + const canClaim = task.status === 'completed' && !rewardClaimedAt; const handleClaim = async () => { if (!canClaim || claiming) return; @@ -137,11 +128,7 @@ export function TutorialTaskCard({
{canClaim ? ( - {claiming - ? '领取中...' - : needsStarterRewardRepair - ? '补发奖励' - : '领取奖励'} + {claiming ? '领取中...' : '领取奖励'} ) : null} {task.status !== 'completed' && currentStage diff --git a/src/react-app/lib/player-state/selectors.test.ts b/src/react-app/lib/player-state/selectors.test.ts index a823e3be..de9b5fd5 100644 --- a/src/react-app/lib/player-state/selectors.test.ts +++ b/src/react-app/lib/player-state/selectors.test.ts @@ -120,7 +120,7 @@ function createStoreState( progress: 0, currency: 0, inventory: 0, - products: 0, + loadout: 0, mail: 0, tasks: 0, }, @@ -155,7 +155,7 @@ describe('player state selectors', () => { progress: 0, currency: 0, inventory: 0, - products: 0, + loadout: 0, mail: 0, tasks: 0, }, @@ -184,7 +184,7 @@ describe('player state selectors', () => { progress: 2, currency: 0, inventory: 0, - products: 0, + loadout: 0, mail: 0, tasks: 0, }, diff --git a/src/react-app/lib/player-state/selectors.ts b/src/react-app/lib/player-state/selectors.ts index 5e671a23..23e72906 100644 --- a/src/react-app/lib/player-state/selectors.ts +++ b/src/react-app/lib/player-state/selectors.ts @@ -48,14 +48,14 @@ export function selectActiveCultivatorProfile( return null; } - const products = storeState.snapshot.products; - const inventory: Inventory = storeState.snapshot.inventory ?? { - artifacts: products?.artifacts ?? cultivator.inventory?.artifacts ?? [], - consumables: cultivator.inventory?.consumables ?? [], - materials: cultivator.inventory?.materials ?? [], + const loadout = storeState.snapshot.loadout; + const inventory: Inventory = { + artifacts: loadout?.artifacts ?? [], + consumables: [], + materials: [], }; - const equipped = products?.equipped ?? cultivator.equipped ?? defaultEquipped; - const skills = products?.skills ?? cultivator.skills ?? []; + const equipped = loadout?.equipped ?? defaultEquipped; + const skills = loadout?.skills ?? []; const patchedProgress = 'cultivation_exp' in (storeState.snapshot.progress ?? {}) ? (storeState.snapshot.progress as CultivationProgress) @@ -79,7 +79,7 @@ export function selectActiveCultivatorProfile( reputation: storeState.snapshot.currency?.reputation ?? cultivator.reputation ?? 0, inventory, skills, - cultivations: products?.cultivations ?? cultivator.cultivations ?? [], + cultivations: loadout?.cultivations ?? [], equipped, }; } @@ -101,10 +101,10 @@ export function selectInventorySnapshot( export function selectProductsSnapshot( storeState: PlayerStateStoreData, -): PlayerStateSnapshot['products'] { +): PlayerStateSnapshot['loadout'] { const cultivator = selectActiveCultivatorProfile(storeState); - return { + return storeState.snapshot.loadout ?? { skills: cultivator?.skills ?? [], cultivations: cultivator?.cultivations ?? [], artifacts: cultivator?.inventory.artifacts ?? [], diff --git a/src/react-app/lib/player-state/store.test.ts b/src/react-app/lib/player-state/store.test.ts index a0262f3f..b93434ec 100644 --- a/src/react-app/lib/player-state/store.test.ts +++ b/src/react-app/lib/player-state/store.test.ts @@ -39,7 +39,7 @@ function createSnapshotResponse( progress: 0, currency: 0, inventory: 2, - products: 0, + loadout: 0, mail: 1, tasks: 0, }, @@ -203,10 +203,11 @@ describe('PlayerStateStore', () => { .mockResolvedValueOnce( jsonResponse( createSnapshotResponse({ - inventory: { - artifacts: [], - consumables: [], - materials: [{ id: 'material-1', name: '灵草', quantity: 3 }], + loadout: { + artifacts: [{ id: 'artifact-1', name: '青锋' }], + skills: [], + cultivations: [], + equipped: { weapon: 'artifact-1', armor: null, accessory: null }, }, }), ), @@ -216,20 +217,20 @@ describe('PlayerStateStore', () => { await store.refresh(undefined, 'user-1'); store.applyEvents([ createEvent({ - domain: 'inventory', + domain: 'loadout', domainVersion: 3, - eventType: 'inventory.changed', + eventType: 'loadout.changed', patch: {}, - invalidates: ['inventory'], + invalidates: ['loadout'], }), ]); await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledWith('/api/player/state?domains=inventory'); + expect(fetchMock).toHaveBeenCalledWith('/api/player/state?domains=loadout'); expect(store.getSnapshot().staleDomains.size).toBe(0); - expect(store.getSnapshot().snapshot.inventory?.materials).toEqual([ - { id: 'material-1', name: '灵草', quantity: 3 }, + expect(store.getSnapshot().snapshot.loadout?.artifacts).toEqual([ + { id: 'artifact-1', name: '青锋' }, ]); }); diff --git a/src/react-app/lib/router/gameShellRegistry.test.ts b/src/react-app/lib/router/gameShellRegistry.test.ts index 92e886bb..17cb3645 100644 --- a/src/react-app/lib/router/gameShellRegistry.test.ts +++ b/src/react-app/lib/router/gameShellRegistry.test.ts @@ -32,6 +32,9 @@ describe('game shell registry', () => { 'viewport', ); expect(resolveGameShellKind('/game/body-cultivation')).toBe('viewport'); + expect(resolveGameShellKind('/game/body-cultivation/breakthrough')).toBe( + 'viewport', + ); expect(resolveGameShellKind('/game/bet-battle')).toBe('viewport'); expect(resolveGameShellKind('/game/settings')).toBe('viewport'); expect(resolveGameShellKind('/game/battle')).toBe('combat'); diff --git a/src/react-app/lib/router/routeTitle.test.ts b/src/react-app/lib/router/routeTitle.test.ts index 6901fecd..b4778dc3 100644 --- a/src/react-app/lib/router/routeTitle.test.ts +++ b/src/react-app/lib/router/routeTitle.test.ts @@ -164,6 +164,38 @@ describe('route title helpers', () => { }); }); + it('uses body cultivation scene metadata for the breakthrough route', () => { + const matches = [ + { + params: {}, + handle: { + title: '肉身破限', + gameScene: { + id: 'body-cultivation', + label: '肉身炼体', + group: 'cultivation', + chrome: 'standard', + dock: 'core', + presentation: 'workflow', + }, + }, + }, + ] as never; + + expect( + resolveRouteTitle(matches, { + pathname: '/game/body-cultivation/breakthrough', + search: '', + }), + ).toBe('肉身破限'); + expect(resolveGameScene(matches)).toMatchObject({ + id: 'body-cultivation', + group: 'cultivation', + chrome: 'standard', + dock: 'core', + }); + }); + it('preserves immersive scene metadata when a special route opts out of the standard shell', () => { const scene = resolveGameScene([ { diff --git a/src/react-app/router.tsx b/src/react-app/router.tsx index 46581039..91aabef3 100644 --- a/src/react-app/router.tsx +++ b/src/react-app/router.tsx @@ -181,6 +181,20 @@ export const router = createBrowserRouter( '肉身炼体', )} /> + + import('@app/routes/game/body-cultivation/breakthrough/route'), + )} + handle={scene( + { + id: 'body-cultivation', + summary: '五轨炼体等级、当前收益与进阶准备归于此处。', + }, + '肉身破限', + )} + /> import('@app/routes/game/inventory/route'))} diff --git a/src/react-app/routes/game/body-cultivation/breakthrough/route.tsx b/src/react-app/routes/game/body-cultivation/breakthrough/route.tsx new file mode 100644 index 00000000..accf9aaf --- /dev/null +++ b/src/react-app/routes/game/body-cultivation/breakthrough/route.tsx @@ -0,0 +1,694 @@ +import { InkModal } from '@app/components/layout'; +import { useInkUI } from '@app/components/providers/InkUIProvider'; +import { + GameSceneFrame, + GameSceneSection, +} from '@app/components/game-shell'; +import { + InkActionGroup, + InkBadge, + InkButton, + InkIdentifyCelebration, + InkNotice, +} from '@app/components/ui'; +import { usePlayerStateView } from '@app/lib/player-state/selectors'; +import { consumePlayerStateMutation } from '@app/lib/player-state/store'; +import type { + BodyCultivationBreakthroughCostRequirement, + BodyCultivationBreakthroughEligibleData, + BodyCultivationBreakthroughEligibleResponse, + BodyCultivationBreakthroughRequest, +} from '@shared/contracts/bodyCultivation'; +import type { ApiFailure } from '@shared/contracts/http'; +import { previewBodyCultivationRealmBreakthrough } from '@shared/lib/bodyCultivation/breakthrough'; +import { BODY_REALM_LABELS } from '@shared/lib/bodyCultivation/config'; +import { cn } from '@shared/lib/cn'; +import { getMaterialTypeInfo } from '@shared/lib/gameConceptDisplay'; +import type { Quality } from '@shared/types/constants'; +import { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router'; + +type SelectionMap = Record; + +type EligibleMaterial = BodyCultivationBreakthroughEligibleData['materials'][number]; +type EligibleConsumable = + BodyCultivationBreakthroughEligibleData['consumables'][number]; + +type BreakthroughResult = { + success?: boolean; + toRealm?: string; + guaranteeProgress?: number; +}; + +function getRequirementLabel( + requirement: Pick, +): string { + return requirement.label ?? requirement.name; +} + +function formatChance(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function getSelectedTotalByRequirement( + eligible: BodyCultivationBreakthroughEligibleData | null, + selections: { + materials: SelectionMap; + consumables: SelectionMap; + }, + requirementLabel: string, +): number { + const materialTotal = (eligible?.materials ?? []).reduce( + (sum, material) => + material.requirementLabel === requirementLabel + ? sum + (selections.materials[material.id] ?? 0) + : sum, + 0, + ); + const consumableTotal = (eligible?.consumables ?? []).reduce( + (sum, consumable) => + consumable.requirementLabel === requirementLabel + ? sum + (selections.consumables[consumable.id] ?? 0) + : sum, + 0, + ); + return materialTotal + consumableTotal; +} + +function buildRequestBody(selections: { + materials: SelectionMap; + consumables: SelectionMap; +}): BodyCultivationBreakthroughRequest { + return { + materialSelections: Object.entries(selections.materials) + .filter(([, quantity]) => quantity > 0) + .map(([id, quantity]) => ({ id, quantity })), + consumableSelections: Object.entries(selections.consumables) + .filter(([, quantity]) => quantity > 0) + .map(([id, quantity]) => ({ id, quantity })), + }; +} + +function CostRequirementList({ + requirements, + eligible, + selections, +}: { + requirements: BodyCultivationBreakthroughCostRequirement[]; + eligible: BodyCultivationBreakthroughEligibleData | null; + selections: { + materials: SelectionMap; + consumables: SelectionMap; + }; +}) { + return ( +
+ {requirements.map((requirement) => { + const label = getRequirementLabel(requirement); + const selectedQuantity = getSelectedTotalByRequirement( + eligible, + selections, + label, + ); + const met = selectedQuantity >= requirement.quantity; + return ( +
+
+ {label} + + {selectedQuantity}/{requirement.quantity} + +
+
+ ); + })} +
+ ); +} + +function SelectionStepper({ + selected, + disabled, + onAdd, + onRemove, +}: { + selected: number; + disabled: boolean; + onAdd: () => void; + onRemove: () => void; +}) { + return ( +
+ + - + + + {selected} + + + + + +
+ ); +} + +function MaterialRow({ + item, + selected, + requirement, + requirementSelected, + onChange, +}: { + item: EligibleMaterial; + selected: number; + requirement: BodyCultivationBreakthroughCostRequirement; + requirementSelected: number; + onChange: (quantity: number) => void; +}) { + const typeInfo = getMaterialTypeInfo(item.type); + const canAdd = + item.quantity > selected && + requirementSelected < requirement.quantity; + + return ( +
+
+
+ + {typeInfo.icon} {item.name} + + + {typeInfo.label} + + x{item.quantity} +
+

+ {item.requirementLabel} +

+
+ onChange(selected + 1)} + onRemove={() => onChange(Math.max(0, selected - 1))} + /> +
+ ); +} + +function ConsumableRow({ + item, + selected, + requirement, + requirementSelected, + onChange, +}: { + item: EligibleConsumable; + selected: number; + requirement: BodyCultivationBreakthroughCostRequirement; + requirementSelected: number; + onChange: (quantity: number) => void; +}) { + const canAdd = + item.quantity > selected && + requirementSelected < requirement.quantity; + + return ( +
+
+
+ {item.name} + + {item.type} + + x{item.quantity} +
+

+ {item.requirementLabel} +

+
+ onChange(selected + 1)} + onRemove={() => onChange(Math.max(0, selected - 1))} + /> +
+ ); +} + +function SelectionModal({ + isOpen, + eligible, + isLoading, + error, + requirements, + selections, + onClose, + onRetry, + onSelectionChange, +}: { + isOpen: boolean; + eligible: BodyCultivationBreakthroughEligibleData | null; + isLoading: boolean; + error: string | null; + requirements: BodyCultivationBreakthroughCostRequirement[]; + selections: { + materials: SelectionMap; + consumables: SelectionMap; + }; + onClose: () => void; + onRetry: () => void; + onSelectionChange: ( + type: 'materials' | 'consumables', + id: string, + quantity: number, + ) => void; +}) { + const requirementByLabel = useMemo( + () => + new Map( + requirements.map((requirement) => [ + getRequirementLabel(requirement), + requirement, + ]), + ), + [requirements], + ); + + return ( + + + 已选 {buildRequestBody(selections).materialSelections.length} 类材料, + {buildRequestBody(selections).consumableSelections.length} 类丹药 + + + 完成选取 + + + } + > +
+ {isLoading ? 正在检索可用资材…… : null} + {error ? ( + + {error} + + 重试 + + + ) : null} + {!isLoading && !error && eligible ? ( + <> +
+

材料

+ {eligible.materials.length === 0 ? ( + 暂无符合本次破限要求的材料。 + ) : ( +
+ {eligible.materials.map((item) => { + const requirement = requirementByLabel.get( + item.requirementLabel, + ); + if (!requirement) return null; + const requirementSelected = getSelectedTotalByRequirement( + eligible, + selections, + item.requirementLabel, + ); + return ( + + onSelectionChange('materials', item.id, quantity) + } + /> + ); + })} +
+ )} +
+ +
+

丹药

+ {eligible.consumables.length === 0 ? ( + 暂无符合本次破限要求的丹药。 + ) : ( +
+ {eligible.consumables.map((item) => { + const requirement = requirementByLabel.get( + item.requirementLabel, + ); + if (!requirement) return null; + const requirementSelected = getSelectedTotalByRequirement( + eligible, + selections, + item.requirementLabel, + ); + return ( + + onSelectionChange('consumables', item.id, quantity) + } + /> + ); + })} +
+ )} +
+ + ) : null} +
+
+ ); +} + +export default function BodyCultivationBreakthroughPage() { + const { cultivator, isLoading } = usePlayerStateView(); + const { pushToast } = useInkUI(); + const navigate = useNavigate(); + const [isSelectionOpen, setIsSelectionOpen] = useState(false); + const [eligible, setEligible] = + useState(null); + const [eligibleError, setEligibleError] = useState(null); + const [eligibleLoading, setEligibleLoading] = useState(false); + const [selections, setSelections] = useState<{ + materials: SelectionMap; + consumables: SelectionMap; + }>({ materials: {}, consumables: {} }); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + const [celebrationTick, setCelebrationTick] = useState(0); + + const preview = cultivator + ? previewBodyCultivationRealmBreakthrough(cultivator.condition, { + cultivatorRealm: cultivator.realm, + }) + : null; + const nextRealmLabel = preview?.nextRealm + ? BODY_REALM_LABELS[preview.nextRealm] + : null; + const requirements = useMemo( + () => + (preview?.costs ?? []).map((cost) => ({ + type: cost.type, + name: cost.name, + label: cost.label, + quantity: cost.quantity, + ...(cost.type === 'material' + ? { + materialType: cost.materialType, + minQuality: cost.minQuality, + } + : { + family: cost.family, + property: cost.property, + minQuality: cost.minQuality, + }), + })), + [preview], + ); + const allCostsMet = + Boolean(eligible) && + requirements.length > 0 && + requirements.every((requirement) => { + const label = getRequirementLabel(requirement); + return ( + getSelectedTotalByRequirement(eligible, selections, label) === + requirement.quantity + ); + }); + + const loadEligible = useCallback(async () => { + setEligibleLoading(true); + setEligibleError(null); + try { + const response = await fetch( + '/api/cultivator/body-cultivation/breakthrough/eligible', + ); + const payload = (await response.json()) as + | BodyCultivationBreakthroughEligibleResponse + | ApiFailure; + if (!response.ok || !payload.success) { + throw new Error('error' in payload ? payload.error : '资材读取失败'); + } + setEligible(payload.data); + } catch (caught) { + setEligibleError(caught instanceof Error ? caught.message : '资材读取失败'); + } finally { + setEligibleLoading(false); + } + }, []); + + const openSelection = () => { + setIsSelectionOpen(true); + if (!eligible && !eligibleLoading) { + void loadEligible(); + } + }; + + const handleSelectionChange = ( + type: 'materials' | 'consumables', + id: string, + quantity: number, + ) => { + setSelections((prev) => ({ + ...prev, + [type]: { + ...prev[type], + [id]: quantity, + }, + })); + }; + + const submitBreakthrough = async () => { + if (!allCostsMet || submitting) return; + + setSubmitting(true); + try { + const response = await fetch('/api/cultivator/body-cultivation/breakthrough', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildRequestBody(selections)), + }); + const payload = await response.json(); + if (!response.ok || !payload.success) { + throw new Error(payload.error || '肉身破限失败'); + } + + await consumePlayerStateMutation(payload); + const nextResult = payload.data as BreakthroughResult; + setResult(nextResult); + setSelections({ materials: {}, consumables: {} }); + setEligible(null); + if (nextResult.success === false) { + pushToast({ + message: `肉身破限失败,保底进度 ${Math.floor(nextResult.guaranteeProgress ?? 0)}%`, + tone: 'warning', + }); + return; + } + + setCelebrationTick((tick) => tick + 1); + } catch (caught) { + pushToast({ + message: caught instanceof Error ? caught.message : '肉身破限失败', + tone: 'danger', + }); + } finally { + setSubmitting(false); + } + }; + + if (isLoading && !cultivator) { + return ( +
+

正在读取肉身状态……

+
+ ); + } + + if (!cultivator || !preview) { + return ( +
+ + 尚无角色资料,先创建角色后再准备肉身破限。 + + 觉醒灵根 + + +
+ ); + } + + if (!preview.nextRealm || !preview.canAttempt) { + return ( + + +
+ + {!preview.nextRealm + ? '肉身已至当前最高阶位。' + : '当前肉身根基尚未满足破限条件。'} + + {preview.requirements.length > 0 ? ( +
+ {preview.requirements.map((requirement) => ( + + {requirement.met ? '✓' : '·'} {requirement.label} + + ))} +
+ ) : null} + + 返回炼体详情 + +
+
+
+ ); + } + + return ( + +
+ {result?.success ? ( + + 恭喜,肉身已提升至 + {result.toRealm + ? BODY_REALM_LABELS[result.toRealm as keyof typeof BODY_REALM_LABELS] + : nextRealmLabel} + 。 + + ) : result?.success === false ? ( + + 本次破限未成,保底进度已推进至 + {Math.floor(result.guaranteeProgress ?? 0)}%。 + + ) : null} + + +
+
+
+

当前肉身

+

+ {BODY_REALM_LABELS[preview.currentRealm]} +

+
+
+

目标阶位

+

{nextRealmLabel}

+
+
+

成功率

+

+ {formatChance(preview.successChance)} +

+
+
+

保底进度

+

+ {preview.guaranteeProgress}% +

+
+
+
+
+ + +
+ + + + 选取材料 + + + {submitting ? '破限中' : '确认突破'} + + + 返回详情 + + +
+
+
+ + setIsSelectionOpen(false)} + onRetry={() => void loadEligible()} + onSelectionChange={handleSelectionChange} + /> + + {celebrationTick > 0 && result?.success !== false ? ( + navigate('/game/body-cultivation')} + /> + ) : null} +
+ ); +} diff --git a/src/react-app/routes/game/craft/alchemy/route.tsx b/src/react-app/routes/game/craft/alchemy/route.tsx index 2cd8dbd6..a6975c2b 100644 --- a/src/react-app/routes/game/craft/alchemy/route.tsx +++ b/src/react-app/routes/game/craft/alchemy/route.tsx @@ -34,7 +34,6 @@ import { } from '@app/components/ui'; import { STARTER_ALCHEMY_PROMPT, - selectRecommendedStarterAlchemyMaterials, } from '@app/lib/alchemy/starterAlchemy'; import { usePlayerStateView } from '@app/lib/player-state/selectors'; import { useTaskList } from '@app/lib/hooks/useTaskList'; @@ -920,14 +919,6 @@ export default function AlchemyPage() { const nextTutorialTask = useMemo(() => findNextTutorialTask(tasks), [tasks]); const isStarterAlchemyTask = nextTutorialTask?.definitionId === 'tutorial_first_alchemy'; - const starterAlchemySelection = useMemo( - () => - selectRecommendedStarterAlchemyMaterials( - cultivator?.inventory.materials ?? [], - MIN_DOSE, - ), - [cultivator?.inventory.materials], - ); const formulaJudgmentMap = useMemo( () => Object.fromEntries( @@ -1264,32 +1255,6 @@ export default function AlchemyPage() { resetWorkbench(); }; - const applyStarterAlchemyRecommendation = () => { - const selection = starterAlchemySelection; - if (selection.missingNames.length > 0) { - pushToast({ - message: `缺少${selection.missingNames.join('、')},先领取入门供给或检查储物袋。`, - tone: 'warning', - }); - return; - } - - setActiveMode('improvised'); - setStatus(''); - setCreatedConsumable(null); - setFormulaDiscovery(null); - setFormulaProgress(null); - setIsResultModalOpen(false); - setIsDiscoveryModalOpen(false); - setSelectedMaterialIds(selection.selectedIds); - setSelectedMaterialMap(selection.selectedMap); - setDoseMap(selection.doseMap); - setUserPrompt(STARTER_ALCHEMY_PROMPT); - setPreviewState(DEFAULT_PREVIEW_STATE); - clearFormulaAnalysis(); - pushToast({ message: '已按入门方意备好第一炉。', tone: 'success' }); - }; - const submitPayload = useMemo( () => ({ materialIds: selectedMaterialIds, @@ -1755,18 +1720,18 @@ export default function AlchemyPage() { {isStarterAlchemyTask ? ( { + setActiveMode('improvised'); + setUserPrompt(STARTER_ALCHEMY_PROMPT); + setPreviewState(DEFAULT_PREVIEW_STATE); + clearFormulaAnalysis(); + setIsMaterialModalOpen(true); + }} disabled={isSubmitting} > - 使用推荐首炉 + 选择首炉灵材 ) : null} - {isStarterAlchemyTask && - starterAlchemySelection.missingNames.length > 0 ? ( -

- 缺少{starterAlchemySelection.missingNames.join('、')},先回入门卷宗领奖或查看储物袋。 -

- ) : null}
diff --git a/src/react-app/routes/game/dungeon/components/DungeonViewRenderer.tsx b/src/react-app/routes/game/dungeon/components/DungeonViewRenderer.tsx index b42e4381..886246da 100644 --- a/src/react-app/routes/game/dungeon/components/DungeonViewRenderer.tsx +++ b/src/react-app/routes/game/dungeon/components/DungeonViewRenderer.tsx @@ -396,6 +396,7 @@ export function DungeonViewRenderer({ isFirstDungeonTutorialActive: Boolean( firstDungeonTask && !firstDungeonTask.snapshot.isCompleted, ), + hasRecoveryPill: null, }) : null; diff --git a/src/react-app/routes/game/enlightenment/replace/route.tsx b/src/react-app/routes/game/enlightenment/replace/route.tsx index 8a1c8aea..3f8b5518 100644 --- a/src/react-app/routes/game/enlightenment/replace/route.tsx +++ b/src/react-app/routes/game/enlightenment/replace/route.tsx @@ -70,7 +70,7 @@ function ReplaceContent() { try { const [pendingRes, existingRes] = await Promise.all([ fetch(`/api/craft/pending?type=${craftType}`), - fetch(`/api/v2/products?type=${productType}`), + fetch(`/api/v2/products?type=${productType}&page=1&pageSize=100`), ]); const [pendingData, existingData] = await Promise.all([ pendingRes.json(), @@ -86,7 +86,7 @@ function ReplaceContent() { } if (existingData.success) { - const items: V2Product[] = (existingData.data ?? []).map( + const items: V2Product[] = (existingData.data?.items ?? []).map( (item: Record) => ({ id: item.id as string, ...toProductDisplayModel(item), diff --git a/src/react-app/routes/game/inventory/hooks/useInventoryViewModel.tsx b/src/react-app/routes/game/inventory/hooks/useInventoryViewModel.tsx index 3d9d1420..1d745a14 100644 --- a/src/react-app/routes/game/inventory/hooks/useInventoryViewModel.tsx +++ b/src/react-app/routes/game/inventory/hooks/useInventoryViewModel.tsx @@ -182,8 +182,7 @@ export interface UseInventoryViewModelReturn { */ export function useInventoryViewModel(): UseInventoryViewModelReturn { const { cultivator, equipped, isLoading, note } = usePlayerStateView(); - const inventoryVersion = usePlayerStateDomainVersion('inventory'); - const productsVersion = usePlayerStateDomainVersion('products'); + const loadoutVersion = usePlayerStateDomainVersion('loadout'); const { pushToast } = useInkUI(); const { mutate } = usePlayerStateActions(); @@ -402,9 +401,8 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn { }, [ activeTab, cultivator?.id, - inventoryVersion, + loadoutVersion, materialFilters, - productsVersion, pushToast, ]); @@ -447,6 +445,13 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn { ); pushToast({ message: '物品已丢弃', tone: 'success' }); + const tab: InventoryTab = + type === 'artifact' + ? 'artifacts' + : type === 'material' + ? 'materials' + : 'consumables'; + void fetchTabPage(tab, paginationByTabRef.current[tab].page || 1); } catch (error) { pushToast({ message: @@ -460,7 +465,7 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn { })); } }, - [cultivator, mutate, pushToast], + [cultivator, fetchTabPage, mutate, pushToast], ); // 打开丢弃确认 @@ -508,6 +513,7 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn { ); pushToast({ message: '法宝灵性已调顺。', tone: 'success' }); + void fetchTabPage('artifacts', paginationByTabRef.current.artifacts.page || 1); } catch (error) { pushToast({ message: @@ -520,7 +526,7 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn { setPendingId(null); } }, - [cultivator, mutate, pushToast], + [cultivator, fetchTabPage, mutate, pushToast], ); // 服用丹药 @@ -571,6 +577,10 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn { message: result.message || `${item.name}已使用。`, tone: 'success', }); + void fetchTabPage( + 'consumables', + paginationByTabRef.current.consumables.page || 1, + ); } catch (error) { pushToast({ message: @@ -581,7 +591,7 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn { setPendingId(null); } }, - [cultivator, mutate, pushToast], + [cultivator, fetchTabPage, mutate, pushToast], ); // 鉴定神秘材料 @@ -632,6 +642,10 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn { rank: revealed.rank, }); } + void fetchTabPage( + 'materials', + paginationByTabRef.current.materials.page || 1, + ); } catch (error) { pushToast({ message: @@ -663,7 +677,7 @@ export function useInventoryViewModel(): UseInventoryViewModelReturn { onConfirm: executeIdentify, }); }, - [cultivator, mutate, pushToast], + [cultivator, fetchTabPage, mutate, pushToast], ); const pagination = paginationByTab[activeTab]; diff --git a/src/react-app/routes/game/skills/hooks/useSkillsViewModel.tsx b/src/react-app/routes/game/skills/hooks/useSkillsViewModel.tsx index 0edd9eea..cfbcaf50 100644 --- a/src/react-app/routes/game/skills/hooks/useSkillsViewModel.tsx +++ b/src/react-app/routes/game/skills/hooks/useSkillsViewModel.tsx @@ -37,7 +37,7 @@ export interface UseSkillsViewModelReturn { export function useSkillsViewModel(): UseSkillsViewModelReturn { const { cultivator, isLoading, note } = usePlayerStateView(); - const productsVersion = usePlayerStateDomainVersion('products'); + const loadoutVersion = usePlayerStateDomainVersion('loadout'); const { mutate } = usePlayerStateActions(); const { pushToast, openDialog } = useInkUI(); @@ -62,12 +62,12 @@ export function useSkillsViewModel(): UseSkillsViewModelReturn { const loadSkills = async () => { setSkillsLoading(true); try { - const res = await fetch('/api/v2/products?type=skill'); + const res = await fetch('/api/v2/products?type=skill&page=1&pageSize=100'); const data = await res.json(); if (cancelled) return; if (data.success) { - const parsed: V2Skill[] = (data.data ?? []).map( + const parsed: V2Skill[] = (data.data?.items ?? []).map( (r: Record) => ({ id: r.id as string, ...toProductDisplayModel(r), @@ -91,7 +91,7 @@ export function useSkillsViewModel(): UseSkillsViewModelReturn { return () => { cancelled = true; }; - }, [cultivator?.id, productsVersion]); + }, [cultivator?.id, loadoutVersion]); const closeDialog = useCallback(() => setDialog(null), []); diff --git a/src/react-app/routes/game/techniques/hooks/useTechniquesViewModel.tsx b/src/react-app/routes/game/techniques/hooks/useTechniquesViewModel.tsx index b9f8d748..9c290b49 100644 --- a/src/react-app/routes/game/techniques/hooks/useTechniquesViewModel.tsx +++ b/src/react-app/routes/game/techniques/hooks/useTechniquesViewModel.tsx @@ -18,7 +18,7 @@ export type V2Technique = ProductDisplayModel & { id: string }; export function useTechniquesViewModel() { const { cultivator, isLoading, note } = usePlayerStateView(); - const productsVersion = usePlayerStateDomainVersion('products'); + const loadoutVersion = usePlayerStateDomainVersion('loadout'); const { mutate } = usePlayerStateActions(); const { pushToast, openDialog } = useInkUI(); @@ -43,12 +43,12 @@ export function useTechniquesViewModel() { const loadTechniques = async () => { setTechniquesLoading(true); try { - const res = await fetch('/api/v2/products?type=gongfa'); + const res = await fetch('/api/v2/products?type=gongfa&page=1&pageSize=100'); const data = await res.json(); if (cancelled) return; if (data.success) { - const parsed: V2Technique[] = (data.data ?? []).map( + const parsed: V2Technique[] = (data.data?.items ?? []).map( (r: Record) => ({ id: r.id as string, ...toProductDisplayModel(r), @@ -72,7 +72,7 @@ export function useTechniquesViewModel() { return () => { cancelled = true; }; - }, [cultivator?.id, productsVersion]); + }, [cultivator?.id, loadoutVersion]); const openTechniqueDetail = useCallback((technique: V2Technique) => { setSelectedTechnique(technique); diff --git a/src/server/lib/drizzle/schema.ts b/src/server/lib/drizzle/schema.ts index 31726fd7..230d254b 100644 --- a/src/server/lib/drizzle/schema.ts +++ b/src/server/lib/drizzle/schema.ts @@ -146,12 +146,17 @@ export const cultivatorStateVersions = pgTable( currencyVersion: bigint('currency_version', { mode: 'number' }) .notNull() .default(0), + /** @deprecated Retained for rolling deploy compatibility. Use loadoutVersion. */ inventoryVersion: bigint('inventory_version', { mode: 'number' }) .notNull() .default(0), + /** @deprecated Retained for rolling deploy compatibility. Use loadoutVersion. */ productsVersion: bigint('products_version', { mode: 'number' }) .notNull() .default(0), + loadoutVersion: bigint('loadout_version', { mode: 'number' }) + .notNull() + .default(0), mailVersion: bigint('mail_version', { mode: 'number' }) .notNull() .default(0), diff --git a/src/server/lib/dungeon/service_v2.test.ts b/src/server/lib/dungeon/service_v2.test.ts index 5c1cecca..7c692eba 100644 --- a/src/server/lib/dungeon/service_v2.test.ts +++ b/src/server/lib/dungeon/service_v2.test.ts @@ -77,12 +77,17 @@ vi.mock('../drizzle/db', () => ({ getExecutor: vi.fn(), })); -vi.mock('../services/cultivatorService', () => ({ - getCultivatorByIdUnsafe: vi.fn(), - getCultivatorOwnerId: vi.fn(), - getPaginatedInventoryByType: vi.fn(), - updateCultivator: vi.fn(), -})); +vi.mock('../services/cultivatorService', () => { + const getCultivatorByIdUnsafe = vi.fn(); + + return { + getCultivatorByIdUnsafe, + getPlayerRuntimeCultivatorByIdUnsafe: getCultivatorByIdUnsafe, + getCultivatorOwnerId: vi.fn(), + getPaginatedInventoryByType: vi.fn(), + updateCultivator: vi.fn(), + }; +}); vi.mock('../services/ConditionService', () => ({ ConditionService: { diff --git a/src/server/lib/dungeon/service_v2.ts b/src/server/lib/dungeon/service_v2.ts index d6632e89..dc9173fd 100644 --- a/src/server/lib/dungeon/service_v2.ts +++ b/src/server/lib/dungeon/service_v2.ts @@ -31,9 +31,9 @@ import { redis } from '../redis'; import { parseRedisJson } from '../redis/json'; import { stableCompactStringify } from '@server/utils/llmPayload'; import { - getCultivatorByIdUnsafe, getCultivatorOwnerId, getPaginatedInventoryByType, + getPlayerRuntimeCultivatorByIdUnsafe, updateCultivator, } from '../services/cultivatorService'; import { QiService } from '../services/QiService'; @@ -420,7 +420,7 @@ export class DungeonService { return; } - const bundle = await getCultivatorByIdUnsafe(cultivatorId, tx); + const bundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId, tx); if (!bundle?.cultivator) { throw new Error('未找到修真者数据'); } @@ -546,7 +546,7 @@ export class DungeonService { return roundData; } - const bundle = await getCultivatorByIdUnsafe(cultivatorId); + const bundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId); const cultivator = bundle?.cultivator; if (!cultivator) { return roundData; @@ -1456,7 +1456,7 @@ export class DungeonService { battleId, ); - const cultivatorBundle = await getCultivatorByIdUnsafe(cultivatorId); + const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId); if (!cultivatorBundle?.cultivator) { throw new Error('未找到修真者数据'); } @@ -2247,7 +2247,7 @@ export class DungeonService { } async getPlayer(cultivatorId: string) { - const cultivatorBundle = await getCultivatorByIdUnsafe(cultivatorId); + const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId); if (!cultivatorBundle || !cultivatorBundle.cultivator) throw new Error('未找到名为该道友的记录'); const cultivator = cultivatorBundle.cultivator; diff --git a/src/server/lib/repositories/creationProductRepository.ts b/src/server/lib/repositories/creationProductRepository.ts index 0a8d8a13..6bd75661 100644 --- a/src/server/lib/repositories/creationProductRepository.ts +++ b/src/server/lib/repositories/creationProductRepository.ts @@ -58,6 +58,28 @@ export async function findByTypeAndCultivator( ); } +export async function findByTypeAndCultivatorPage( + cultivatorId: string, + productType: CreationProductType, + options: { page: number; pageSize: number }, + q: DbExecutor = getExecutor(), +): Promise { + const page = Math.max(1, options.page); + const pageSize = Math.max(1, options.pageSize); + return q + .select() + .from(schema.creationProducts) + .where( + and( + eq(schema.creationProducts.cultivatorId, cultivatorId), + eq(schema.creationProducts.productType, productType), + ), + ) + .orderBy(desc(schema.creationProducts.createdAt), desc(schema.creationProducts.id)) + .limit(pageSize) + .offset((page - 1) * pageSize); +} + export async function findArtifactsByIdsAndCultivator( cultivatorId: string, artifactIds: string[], diff --git a/src/server/lib/repositories/cultivatorRepository.ts b/src/server/lib/repositories/cultivatorRepository.ts index 0927347b..7aedd343 100644 --- a/src/server/lib/repositories/cultivatorRepository.ts +++ b/src/server/lib/repositories/cultivatorRepository.ts @@ -80,6 +80,26 @@ export async function listCultivatorBreakthroughPills( ); } +export async function hasCultivatorRecoveryPill( + cultivatorId: string, + q: DbExecutor = getExecutor(), +): Promise { + const [result] = await q + .select({ count: sql`count(*)::int` }) + .from(schema.consumables) + .where( + and( + eq(schema.consumables.cultivatorId, cultivatorId), + eq(schema.consumables.type, '丹药'), + sql`${schema.consumables.quantity} > 0`, + sql`${schema.consumables.spec} ->> 'kind' = 'pill'`, + sql`jsonb_path_exists(${schema.consumables.spec}, '$.operations[*] ? (@.type == "restore_resource" && (@.resource == "hp" || @.resource == "mp"))')`, + ), + ); + + return Number(result?.count ?? 0) > 0; +} + export async function listCultivatorTechniqueQualities( cultivatorId: string, q: DbExecutor = getExecutor(), diff --git a/src/server/lib/repositories/playerStateRepository.test.ts b/src/server/lib/repositories/playerStateRepository.test.ts index 14d28fcc..afb8f2c5 100644 --- a/src/server/lib/repositories/playerStateRepository.test.ts +++ b/src/server/lib/repositories/playerStateRepository.test.ts @@ -34,10 +34,10 @@ describe('playerStateRepository', () => { userId: 'user-1', globalVersion: 8, domainVersion: 5, - domain: 'inventory', - eventType: 'inventory.changed', + domain: 'loadout', + eventType: 'loadout.changed', patch: {}, - invalidates: ['inventory'], + invalidates: ['loadout'], source: 'test', requestId: null, createdAt: new Date('2026-06-10T00:00:01.000Z'), @@ -54,7 +54,7 @@ describe('playerStateRepository', () => { globalVersion: 8, domainVersions: { currency: 3, - inventory: 5, + loadout: 5, }, events: [ { @@ -64,9 +64,9 @@ describe('playerStateRepository', () => { source: 'test', }, { - domain: 'inventory', - eventType: 'inventory.changed', - invalidates: ['inventory'], + domain: 'loadout', + eventType: 'loadout.changed', + invalidates: ['loadout'], source: 'test', }, ], @@ -80,7 +80,7 @@ describe('playerStateRepository', () => { }), expect.objectContaining({ globalVersion: 8, - domain: 'inventory', + domain: 'loadout', domainVersion: 5, }), ]); diff --git a/src/server/lib/repositories/playerStateRepository.ts b/src/server/lib/repositories/playerStateRepository.ts index 2d03c7db..c7438f20 100644 --- a/src/server/lib/repositories/playerStateRepository.ts +++ b/src/server/lib/repositories/playerStateRepository.ts @@ -20,8 +20,7 @@ const VERSION_COLUMNS = { condition: 'conditionVersion', progress: 'progressVersion', currency: 'currencyVersion', - inventory: 'inventoryVersion', - products: 'productsVersion', + loadout: 'loadoutVersion', mail: 'mailVersion', tasks: 'tasksVersion', } satisfies Record; @@ -34,8 +33,7 @@ function domainVersionsFromRow( condition: row.conditionVersion, progress: row.progressVersion, currency: row.currencyVersion, - inventory: row.inventoryVersion, - products: row.productsVersion, + loadout: row.loadoutVersion, mail: row.mailVersion, tasks: row.tasksVersion, }; diff --git a/src/server/lib/services/AlchemyFormulaService.ts b/src/server/lib/services/AlchemyFormulaService.ts index 9b886f04..2160dcc2 100644 --- a/src/server/lib/services/AlchemyFormulaService.ts +++ b/src/server/lib/services/AlchemyFormulaService.ts @@ -93,7 +93,7 @@ import { } from './consumablePersistence'; import { addConsumableToInventory, - getCultivatorByIdUnsafe, + getPlayerRuntimeCultivatorByIdUnsafe, } from './cultivatorService'; import { getMysteryMaterialBlockingReason } from './materialMysteryGuard'; @@ -1363,7 +1363,7 @@ export async function craftFromFormula( .where(eq(cultivators.id, cultivatorId)) .limit(1) .then((rows) => rows[0]), - getCultivatorByIdUnsafe(cultivatorId, options.tx), + getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId, options.tx), analysisId ? redis.get(getFormulaAnalysisKey(cultivatorId, analysisId)) : Promise.resolve(null), diff --git a/src/server/lib/services/BetBattleService.ts b/src/server/lib/services/BetBattleService.ts index 22a06b09..1ea4c24f 100644 --- a/src/server/lib/services/BetBattleService.ts +++ b/src/server/lib/services/BetBattleService.ts @@ -17,7 +17,7 @@ import { MailAttachment, MailService } from './MailService'; import { mapConsumableRow } from './consumablePersistence'; import { simulateBattleV5 } from './simulateBattleV5'; import { toArtifactFromProduct } from './creationProductArtifactSupport'; -import { getCultivatorByIdUnsafe } from './cultivatorService'; +import { getPlayerRuntimeCultivatorByIdUnsafe } from './cultivatorService'; import { withPlayerAbilityStrategySettings } from '@shared/lib/battle/abilityStrategyInit'; const CREATE_LOCK_PREFIX = 'bet_battle:create:lock:'; @@ -633,7 +633,7 @@ export async function challengeBetBattle( ); } - const challengerBundle = await getCultivatorByIdUnsafe(input.challengerId); + const challengerBundle = await getPlayerRuntimeCultivatorByIdUnsafe(input.challengerId); if (!challengerBundle?.cultivator) { throw new BetBattleServiceError( BetBattleError.BATTLE_NOT_FOUND, @@ -659,7 +659,7 @@ export async function challengeBetBattle( ); } - const creatorBundle = await getCultivatorByIdUnsafe(betBattle.creatorId); + const creatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(betBattle.creatorId); if (!creatorBundle?.cultivator) { throw new BetBattleServiceError( BetBattleError.BATTLE_NOT_FOUND, diff --git a/src/server/lib/services/BodyCultivationBreakthroughService.ts b/src/server/lib/services/BodyCultivationBreakthroughService.ts index d5d4a297..f93c4b9a 100644 --- a/src/server/lib/services/BodyCultivationBreakthroughService.ts +++ b/src/server/lib/services/BodyCultivationBreakthroughService.ts @@ -1,15 +1,17 @@ -import type { DbTransaction } from '@server/lib/drizzle/db'; +import { getExecutor, type DbExecutor, type DbTransaction } from '@server/lib/drizzle/db'; +import * as schema from '@server/lib/drizzle/schema'; import { consumeMaterialById, consumeConsumableById, } from '@server/lib/services/cultivatorService'; import { - getBodyCultivationRealmBreakthroughCosts, previewBodyCultivationRealmBreakthrough, type BodyCultivationRealmBreakthroughCost, } from '@shared/lib/bodyCultivation/breakthrough'; import type { + BodyCultivationBreakthroughCostRequirement, BodyCultivationBreakthroughInventoryRequirement, + BodyCultivationBreakthroughRequest, BodyCultivationBreakthroughReadinessData, } from '@shared/contracts/bodyCultivation'; import { canonicalizeAlchemyPropertyKey } from '@shared/lib/alchemyProperties'; @@ -18,6 +20,8 @@ import { QUALITY_ORDER } from '@shared/types/constants'; import type { Quality } from '@shared/types/constants'; import type { CompatibleAlchemyPropertyKey } from '@shared/types/consumable'; import type { Consumable, Cultivator, Material } from '@shared/types/cultivator'; +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { mapConsumableRow } from './consumablePersistence'; export interface BodyCultivationBreakthroughCostPlan { requirements: BodyCultivationBreakthroughInventoryRequirement[]; @@ -25,15 +29,63 @@ export interface BodyCultivationBreakthroughCostPlan { materials: Array<{ id: string; quantity: number }>; } -export interface BodyCultivationBreakthroughReadiness - extends BodyCultivationBreakthroughReadinessData { - costPlan: BodyCultivationBreakthroughCostPlan; -} - function getQualityOrder(quality: Quality | undefined): number { return quality ? (QUALITY_ORDER[quality] ?? -1) : -1; } +function getEligibleQualities(minQuality: Quality): Quality[] { + const minOrder = getQualityOrder(minQuality); + return (Object.keys(QUALITY_ORDER) as Quality[]).filter( + (quality) => getQualityOrder(quality) >= minOrder, + ); +} + +function toCostRequirement( + cost: BodyCultivationRealmBreakthroughCost, +): BodyCultivationBreakthroughCostRequirement { + if (cost.type === 'material') { + return { + type: cost.type, + name: cost.name, + label: cost.label, + quantity: cost.quantity, + materialType: cost.materialType, + minQuality: cost.minQuality, + }; + } + + return { + type: cost.type, + name: cost.name, + label: cost.label, + quantity: cost.quantity, + family: cost.family, + property: cost.property, + minQuality: cost.minQuality, + }; +} + +function getCostKey(cost: BodyCultivationRealmBreakthroughCost): string { + if (cost.type === 'material') { + return `${cost.type}:${cost.materialType}:${cost.minQuality}`; + } + + return `${cost.type}:${cost.family}:${cost.property}:${cost.minQuality}`; +} + +function toMaterial(row: typeof schema.materials.$inferSelect): Material { + return { + id: row.id, + name: row.name, + type: row.type as Material['type'], + rank: row.rank as Quality, + quantity: row.quantity, + description: row.description ?? '', + element: row.element as Material['element'], + details: row.details as Material['details'], + }; +} + function isMatchingBodyBreakthroughMaterial( material: Material, cost: Extract, @@ -76,125 +128,339 @@ function isMatchingBodyBreakthroughPill( }); } -function planConsumableCost( - consumables: Consumable[], - cost: Extract, -): { - ownedQuantity: number; - plan: Array<{ id: string; quantity: number }>; -} { - const matches = consumables.filter((consumable) => - isMatchingBodyBreakthroughPill(consumable, cost), - ); - const ownedQuantity = matches.reduce( - (sum, consumable) => sum + Math.max(0, consumable.quantity), - 0, - ); - let remaining = cost.quantity; - const plan: Array<{ id: string; quantity: number }> = []; - - for (const consumable of matches) { - if (remaining <= 0) break; - if (!consumable.id) continue; - const quantity = Math.min(consumable.quantity, remaining); - if (quantity <= 0) continue; - plan.push({ id: consumable.id, quantity }); - remaining -= quantity; - } - - return { ownedQuantity, plan }; -} - -function planMaterialCost( - materials: Material[], - cost: Extract, -): { - ownedQuantity: number; - plan: Array<{ id: string; quantity: number }>; -} { - const matches = materials.filter((material) => - isMatchingBodyBreakthroughMaterial(material, cost), - ); - const ownedQuantity = matches.reduce( - (sum, material) => sum + Math.max(0, material.quantity), - 0, - ); - let remaining = cost.quantity; - const plan: Array<{ id: string; quantity: number }> = []; - - for (const material of matches) { - if (remaining <= 0) break; - if (!material.id) continue; - const quantity = Math.min(material.quantity, remaining); - if (quantity <= 0) continue; - plan.push({ id: material.id, quantity }); - remaining -= quantity; - } - - return { ownedQuantity, plan }; -} - -export function getBodyCultivationBreakthroughReadiness( +export function getBodyCultivationBreakthroughPreviewData( cultivator: Cultivator, -): BodyCultivationBreakthroughReadiness { +): BodyCultivationBreakthroughReadinessData { const preview = previewBodyCultivationRealmBreakthrough(cultivator.condition, { cultivatorRealm: cultivator.realm, }); - const costs = getBodyCultivationRealmBreakthroughCosts(preview.nextRealm); - const requirements: BodyCultivationBreakthroughInventoryRequirement[] = []; - const consumables: BodyCultivationBreakthroughCostPlan['consumables'] = []; - const materials: BodyCultivationBreakthroughCostPlan['materials'] = []; - - for (const cost of costs) { - if (cost.type === 'material') { - const planned = planMaterialCost( - cultivator.inventory.materials ?? [], - cost, - ); - requirements.push({ - type: cost.type, - name: cost.name, - label: cost.label, - quantity: cost.quantity, - ownedQuantity: planned.ownedQuantity, - met: planned.ownedQuantity >= cost.quantity, - materialType: cost.materialType, - minQuality: cost.minQuality, - }); - materials.push(...planned.plan); - continue; - } - - const planned = planConsumableCost(cultivator.inventory.consumables ?? [], cost); - requirements.push({ - type: cost.type, - name: cost.name, - label: cost.label, - quantity: cost.quantity, - ownedQuantity: planned.ownedQuantity, - met: planned.ownedQuantity >= cost.quantity, - family: cost.family, - property: cost.property, - minQuality: cost.minQuality, - }); - consumables.push(...planned.plan); - } - - const inventoryReady = requirements.every((requirement) => requirement.met); return { nextRealm: preview.nextRealm, - canAttempt: preview.canAttempt && inventoryReady, + canAttempt: preview.canAttempt, successChance: preview.successChance, guaranteeProgress: preview.guaranteeProgress, failedAttempts: preview.failedAttempts, ruleRequirements: preview.requirements, - inventoryRequirements: requirements, - costPlan: { - requirements, - consumables, - materials, - }, + costRequirements: preview.costs.map(toCostRequirement), + }; +} + +async function getCandidateMaterialsByIds( + cultivator: Cultivator, + ids: string[], + q: DbExecutor | DbTransaction, +): Promise { + const inventoryMatches = cultivator.inventory.materials?.filter( + (material) => material.id && ids.includes(material.id), + ); + if (inventoryMatches?.length) { + return inventoryMatches; + } + if (ids.length === 0) return []; + + const rows = await q + .select() + .from(schema.materials) + .where( + and( + eq(schema.materials.cultivatorId, cultivator.id!), + inArray(schema.materials.id, ids), + ), + ); + + return rows.map(toMaterial); +} + +async function getCandidateConsumablesByIds( + cultivator: Cultivator, + ids: string[], + q: DbExecutor | DbTransaction, +): Promise { + const inventoryMatches = cultivator.inventory.consumables?.filter( + (consumable) => consumable.id && ids.includes(consumable.id), + ); + if (inventoryMatches?.length) { + return inventoryMatches; + } + if (ids.length === 0) return []; + + const rows = await q + .select() + .from(schema.consumables) + .where( + and( + eq(schema.consumables.cultivatorId, cultivator.id!), + inArray(schema.consumables.id, ids), + ), + ); + + return rows.map(mapConsumableRow); +} + +function normalizeSelections( + selections: BodyCultivationBreakthroughRequest['materialSelections'], +): Array<{ id: string; quantity: number }> { + const selectionMap = new Map(); + + for (const selection of selections) { + const id = selection.id.trim(); + const quantity = Math.floor(selection.quantity); + if (!id || quantity <= 0) { + throw new Error('突破材料选择不完整。'); + } + selectionMap.set(id, (selectionMap.get(id) ?? 0) + quantity); + } + + return [...selectionMap.entries()].map(([id, quantity]) => ({ + id, + quantity, + })); +} + +function assignSelectionToCost(args: { + itemName: string; + quantity: number; + matchingCosts: BodyCultivationRealmBreakthroughCost[]; + totalsByCostKey: Map; +}): void { + const targetCost = args.matchingCosts.find((cost) => { + const current = args.totalsByCostKey.get(getCostKey(cost)) ?? 0; + return current + args.quantity <= cost.quantity; + }); + + if (!targetCost) { + throw new Error( + args.matchingCosts.length > 0 + ? `${args.itemName} 的选择数量超过突破所需。` + : `${args.itemName} 不符合本次肉身破限要求。`, + ); + } + + const key = getCostKey(targetCost); + args.totalsByCostKey.set( + key, + (args.totalsByCostKey.get(key) ?? 0) + args.quantity, + ); +} + +export async function listEligibleBodyCultivationBreakthroughItems( + cultivator: Cultivator, + q: DbExecutor | DbTransaction = getExecutor(), +): Promise<{ + requirements: BodyCultivationBreakthroughCostRequirement[]; + materials: Array; + consumables: Array; +}> { + const preview = previewBodyCultivationRealmBreakthrough(cultivator.condition, { + cultivatorRealm: cultivator.realm, + }); + const materialCosts = preview.costs.filter( + ( + cost, + ): cost is Extract< + BodyCultivationRealmBreakthroughCost, + { type: 'material' } + > => cost.type === 'material', + ); + const consumableCosts = preview.costs.filter( + ( + cost, + ): cost is Extract< + BodyCultivationRealmBreakthroughCost, + { type: 'consumable' } + > => cost.type === 'consumable', + ); + + let materialCandidates = cultivator.inventory.materials ?? []; + if (materialCandidates.length === 0 && materialCosts.length > 0) { + const materialTypes = [ + ...new Set(materialCosts.map((cost) => cost.materialType)), + ]; + const qualities = [ + ...new Set( + materialCosts.flatMap((cost) => getEligibleQualities(cost.minQuality)), + ), + ]; + const rows = await q + .select() + .from(schema.materials) + .where( + and( + eq(schema.materials.cultivatorId, cultivator.id!), + inArray(schema.materials.type, materialTypes), + inArray(schema.materials.rank, qualities), + sql`${schema.materials.quantity} > 0`, + ), + ); + materialCandidates = rows.map(toMaterial); + } + + let consumableCandidates = cultivator.inventory.consumables ?? []; + if (consumableCandidates.length === 0 && consumableCosts.length > 0) { + const qualities = [ + ...new Set( + consumableCosts.flatMap((cost) => getEligibleQualities(cost.minQuality)), + ), + ]; + const rows = await q + .select() + .from(schema.consumables) + .where( + and( + eq(schema.consumables.cultivatorId, cultivator.id!), + eq(schema.consumables.type, '丹药'), + sql`${schema.consumables.quantity} > 0`, + sql`${schema.consumables.spec} ->> 'kind' = 'pill'`, + sql`${schema.consumables.spec} ->> 'family' = 'tempering'`, + inArray(schema.consumables.quality, qualities), + ), + ); + consumableCandidates = rows.map(mapConsumableRow); + } + + return { + requirements: preview.costs.map(toCostRequirement), + materials: materialCandidates.flatMap((material) => { + const matchedCost = materialCosts.find((cost) => + isMatchingBodyBreakthroughMaterial(material, cost), + ); + return matchedCost && material.id + ? [{ ...material, requirementLabel: matchedCost.label }] + : []; + }), + consumables: consumableCandidates.flatMap((consumable) => { + const matchedCost = consumableCosts.find((cost) => + isMatchingBodyBreakthroughPill(consumable, cost), + ); + return matchedCost && consumable.id + ? [{ ...consumable, requirementLabel: matchedCost.label }] + : []; + }), + }; +} + +export async function planBodyCultivationBreakthroughSelections( + cultivator: Cultivator, + request: BodyCultivationBreakthroughRequest, + q: DbExecutor | DbTransaction = getExecutor(), +): Promise { + const preview = previewBodyCultivationRealmBreakthrough(cultivator.condition, { + cultivatorRealm: cultivator.realm, + }); + if (!preview.nextRealm) { + throw new Error('肉身已达最高阶位。'); + } + if (!preview.canAttempt) { + const missing = preview.requirements + .filter((requirement) => !requirement.met) + .map((requirement) => requirement.label) + .join('、'); + throw new Error(`肉身进阶条件不足:${missing || '基础条件不足'}`); + } + + const materialCosts = preview.costs.filter( + ( + cost, + ): cost is Extract< + BodyCultivationRealmBreakthroughCost, + { type: 'material' } + > => cost.type === 'material', + ); + const consumableCosts = preview.costs.filter( + ( + cost, + ): cost is Extract< + BodyCultivationRealmBreakthroughCost, + { type: 'consumable' } + > => cost.type === 'consumable', + ); + const materialSelections = normalizeSelections(request.materialSelections); + const consumableSelections = normalizeSelections(request.consumableSelections); + const selectedMaterials = await getCandidateMaterialsByIds( + cultivator, + materialSelections.map((selection) => selection.id), + q, + ); + const selectedConsumables = await getCandidateConsumablesByIds( + cultivator, + consumableSelections.map((selection) => selection.id), + q, + ); + const materialsById = new Map( + selectedMaterials.flatMap((material) => + material.id ? [[material.id, material] as const] : [], + ), + ); + const consumablesById = new Map( + selectedConsumables.flatMap((consumable) => + consumable.id ? [[consumable.id, consumable] as const] : [], + ), + ); + const totalsByCostKey = new Map(); + const materialPlan: BodyCultivationBreakthroughCostPlan['materials'] = []; + const consumablePlan: BodyCultivationBreakthroughCostPlan['consumables'] = []; + + for (const selection of materialSelections) { + const material = materialsById.get(selection.id); + if (!material) { + throw new Error('所选材料不存在或不属于当前角色。'); + } + if (selection.quantity > material.quantity) { + throw new Error(`${material.name} 数量不足。`); + } + assignSelectionToCost({ + itemName: material.name, + quantity: selection.quantity, + matchingCosts: materialCosts.filter((cost) => + isMatchingBodyBreakthroughMaterial(material, cost), + ), + totalsByCostKey, + }); + materialPlan.push({ id: selection.id, quantity: selection.quantity }); + } + + for (const selection of consumableSelections) { + const consumable = consumablesById.get(selection.id); + if (!consumable) { + throw new Error('所选丹药不存在或不属于当前角色。'); + } + if (selection.quantity > consumable.quantity) { + throw new Error(`${consumable.name} 数量不足。`); + } + assignSelectionToCost({ + itemName: consumable.name, + quantity: selection.quantity, + matchingCosts: consumableCosts.filter((cost) => + isMatchingBodyBreakthroughPill(consumable, cost), + ), + totalsByCostKey, + }); + consumablePlan.push({ id: selection.id, quantity: selection.quantity }); + } + + const requirements = preview.costs.map((cost) => { + const selectedQuantity = totalsByCostKey.get(getCostKey(cost)) ?? 0; + return { + ...toCostRequirement(cost), + ownedQuantity: selectedQuantity, + met: selectedQuantity === cost.quantity, + }; + }); + + for (const cost of preview.costs) { + const selectedQuantity = totalsByCostKey.get(getCostKey(cost)) ?? 0; + if (selectedQuantity !== cost.quantity) { + throw new Error( + `${cost.label} ${selectedQuantity}/${cost.quantity},请重新选择。`, + ); + } + } + + return { + requirements, + materials: materialPlan, + consumables: consumablePlan, }; } diff --git a/src/server/lib/services/ConsumableUseEngine.ts b/src/server/lib/services/ConsumableUseEngine.ts index bf409ac3..de77151d 100644 --- a/src/server/lib/services/ConsumableUseEngine.ts +++ b/src/server/lib/services/ConsumableUseEngine.ts @@ -21,7 +21,7 @@ import type { Consumable } from '@shared/types/cultivator'; import { and, eq } from 'drizzle-orm'; import { consumeConsumableById, - getCultivatorById, + getPlayerRuntimeCultivatorById, replaceSpiritualRoots, } from './cultivatorService'; import { PillOperationExecutor } from './PillOperationExecutor'; @@ -81,7 +81,7 @@ export const ConsumableUseEngine = { message: string; consumable: Consumable; }> { - const cultivator = await getCultivatorById(userId, cultivatorId); + const cultivator = await getPlayerRuntimeCultivatorById(userId, cultivatorId); if (!cultivator) { throw new Error('角色不存在或无权限操作。'); } diff --git a/src/server/lib/services/FateReshapeService.ts b/src/server/lib/services/FateReshapeService.ts index 83632b21..4dd80ce5 100644 --- a/src/server/lib/services/FateReshapeService.ts +++ b/src/server/lib/services/FateReshapeService.ts @@ -13,8 +13,8 @@ import { FATE_RESHAPE_CANDIDATE_COUNT } from './FateConfig'; import { FateEngine } from './FateEngine'; import { consumeConsumableById, - getCultivatorById, - getCultivatorByIdUnsafe, + getPlayerRuntimeCultivatorById, + getPlayerRuntimeCultivatorByIdUnsafe, replacePreHeavenFates, } from './cultivatorService'; import { mapConsumableRow, type ConsumableRow } from './consumablePersistence'; @@ -189,7 +189,10 @@ export const FateReshapeService = { return toSessionDto(existing); } - const cultivator = await getCultivatorById(userId, cultivatorId); + const cultivator = await getPlayerRuntimeCultivatorById( + userId, + cultivatorId, + ); if (!cultivator) { throw new FateReshapeServiceError(404, '当前没有可重塑命格的角色'); } @@ -253,7 +256,7 @@ export const FateReshapeService = { throw new FateReshapeServiceError(400, '本次命格重塑已无法再重抽'); } - const bundle = await getCultivatorByIdUnsafe(cultivatorId); + const bundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId); if (!bundle?.cultivator) { throw new FateReshapeServiceError(404, '当前没有可重塑命格的角色'); } diff --git a/src/server/lib/services/PlayerStateSnapshotService.ts b/src/server/lib/services/PlayerStateSnapshotService.ts index c3fd514d..ff87da97 100644 --- a/src/server/lib/services/PlayerStateSnapshotService.ts +++ b/src/server/lib/services/PlayerStateSnapshotService.ts @@ -1,7 +1,11 @@ import { getExecutor, type DbExecutor } from '@server/lib/drizzle/db'; import { cultivators, cultivatorTasks, mails } from '@server/lib/drizzle/schema'; import { getOrCreateStateVersion } from '@server/lib/repositories/playerStateRepository'; -import { getCultivatorById } from '@server/lib/services/cultivatorService'; +import { + buildCultivatorRuntime, + getPlayerLoadoutByCultivatorId, + getPlayerProfileCultivatorById, +} from '@server/lib/services/cultivatorService'; import { QiService } from '@server/lib/services/QiService'; import { PLAYER_STATE_DOMAINS, @@ -39,14 +43,17 @@ export async function buildPlayerStateSnapshot(args: { const domainSet = new Set(domains); const version = await getOrCreateStateVersion(args.cultivatorId, q); const needsFullCultivator = domains.some((domain) => - ['profile', 'condition', 'progress', 'inventory', 'products'].includes(domain), + ['profile', 'condition', 'progress', 'loadout'].includes(domain), ); const needsCultivatorRow = needsFullCultivator || domainSet.has('currency'); - const cultivator = needsFullCultivator - ? await getCultivatorById(args.userId, args.cultivatorId) + const profile = needsFullCultivator + ? await getPlayerProfileCultivatorById(args.userId, args.cultivatorId, q) + : null; + const loadout = domainSet.has('profile') || domainSet.has('loadout') + ? await getPlayerLoadoutByCultivatorId(args.cultivatorId, q) : null; - if (needsFullCultivator && !cultivator) { + if (needsFullCultivator && !profile) { throw new Error('角色不存在'); } @@ -70,18 +77,19 @@ export async function buildPlayerStateSnapshot(args: { const snapshot: Partial = {}; if (domainSet.has('profile')) { + const runtimeCultivator = buildCultivatorRuntime(profile!, loadout!); snapshot.profile = { - cultivator: cultivator!, - display: getCultivatorDisplaySnapshot(cultivator!), + cultivator: profile!, + display: getCultivatorDisplaySnapshot(runtimeCultivator), }; } if (domainSet.has('condition')) { - snapshot.condition = cultivator!.condition; + snapshot.condition = profile!.condition; } if (domainSet.has('progress')) { - snapshot.progress = cultivator!.cultivation_progress ?? {}; + snapshot.progress = profile!.cultivation_progress ?? {}; } if (domainSet.has('currency')) { @@ -90,25 +98,16 @@ export async function buildPlayerStateSnapshot(args: { qiLastRefreshedAt: rawCultivator?.qiLastRefreshedAt ?? null, }); snapshot.currency = { - spiritStones: rawCultivator?.spirit_stones ?? cultivator?.spirit_stones ?? 0, - reputation: rawCultivator?.reputation ?? cultivator?.reputation ?? 0, + spiritStones: rawCultivator?.spirit_stones ?? profile?.spirit_stones ?? 0, + reputation: rawCultivator?.reputation ?? profile?.reputation ?? 0, qi: qiState.qi, qiLastRefreshedAt: qiState.qiLastRefreshedAt?.toISOString() ?? null, }; } - if (domainSet.has('inventory')) { - snapshot.inventory = cultivator!.inventory; - } - - if (domainSet.has('products')) { - snapshot.products = { - skills: cultivator!.skills, - cultivations: cultivator!.cultivations, - artifacts: cultivator!.inventory.artifacts, - equipped: cultivator!.equipped, - }; + if (domainSet.has('loadout')) { + snapshot.loadout = loadout!; } if (domainSet.has('mail')) { diff --git a/src/server/lib/services/TaskService.ts b/src/server/lib/services/TaskService.ts index 03ac4869..ea3ae83a 100644 --- a/src/server/lib/services/TaskService.ts +++ b/src/server/lib/services/TaskService.ts @@ -38,7 +38,10 @@ import { import { getNextStage } from '@server/utils/breakthroughCalculator'; import { getOrInitCultivationProgress } from '@server/utils/cultivationUtils'; import { simulateBattleV5 } from './simulateBattleV5'; -import { getCultivatorByIdUnsafe, getInventory } from './cultivatorService'; +import { + getInventory, + getPlayerRuntimeCultivatorByIdUnsafe, +} from './cultivatorService'; import { getExecutor, type DbTransaction } from '@server/lib/drizzle/db'; import { calculateSceneCultivationExp } from '@shared/engine/cultivation/ExpBudgetCalculator'; import { @@ -1286,7 +1289,7 @@ async function syncTaskRecord( } async function loadBundleOrThrow(cultivatorId: string): Promise { - const bundle = await getCultivatorByIdUnsafe(cultivatorId); + const bundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId); if (!bundle) { throw new Error('角色不存在'); } diff --git a/src/server/lib/services/alchemyServiceV2.ts b/src/server/lib/services/alchemyServiceV2.ts index a909d756..6efa8c0b 100644 --- a/src/server/lib/services/alchemyServiceV2.ts +++ b/src/server/lib/services/alchemyServiceV2.ts @@ -58,7 +58,7 @@ import { } from './consumablePersistence'; import { addConsumableToInventory, - getCultivatorByIdUnsafe, + getPlayerRuntimeCultivatorByIdUnsafe, } from './cultivatorService'; import { getMysteryMaterialBlockingReason } from './materialMysteryGuard'; @@ -393,7 +393,7 @@ export function createAlchemyService( .where(eq(cultivators.id, cultivatorId)) .limit(1) .then((rows) => rows[0]), - getCultivatorByIdUnsafe(cultivatorId, options.tx), + getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId, options.tx), ]); if (!cultivator) { diff --git a/src/server/lib/services/creationServiceV2.ts b/src/server/lib/services/creationServiceV2.ts index 1081b747..16b1bb43 100644 --- a/src/server/lib/services/creationServiceV2.ts +++ b/src/server/lib/services/creationServiceV2.ts @@ -54,7 +54,7 @@ import type { } from '@shared/types/constants'; import type { Material, PreHeavenFate } from '@shared/types/cultivator'; import { eq, inArray, sql } from 'drizzle-orm'; -import { getCultivatorByIdUnsafe } from './cultivatorService'; +import { getPlayerRuntimeCultivatorByIdUnsafe } from './cultivatorService'; import { getMysteryMaterialBlockingReason } from './materialMysteryGuard'; /** @@ -495,7 +495,10 @@ export async function processCreation( throw new CreationServiceError('道友查无此人', 404); } - const fullCultivator = await getCultivatorByIdUnsafe(cultivatorId, q); + const fullCultivator = await getPlayerRuntimeCultivatorByIdUnsafe( + cultivatorId, + q, + ); const fateContext = evaluateFateContext( fullCultivator?.cultivator.pre_heaven_fates ?? [], ); diff --git a/src/server/lib/services/cultivatorService.ts b/src/server/lib/services/cultivatorService.ts index 7f88020e..abff2170 100644 --- a/src/server/lib/services/cultivatorService.ts +++ b/src/server/lib/services/cultivatorService.ts @@ -28,6 +28,10 @@ import { ensureStarterTechnique, } from '@shared/engine/cultivator/creation/starterProducts'; import type { CultivatorCondition } from '@shared/types/condition'; +import type { + PlayerLoadout, + PlayerProfileCultivator, +} from '@shared/contracts/player'; import { ElementType, EquipmentSlot, @@ -72,6 +76,245 @@ import { FateEngine } from './FateEngine'; import { addMaterialStackToInventory } from './materialInventory'; import { sanitizeMaterialDetails } from './materialDetailsPrivacy'; +const emptyEquipped: EquippedItems = { + weapon: null, + armor: null, + accessory: null, +}; + +function mapSpiritualRoots( + roots: CultivatorRelations['spiritualRoots'], +): Cultivator['spiritual_roots'] { + const spiritualRootCount = roots.length; + return roots.map((r) => { + const element = r.element as ElementType; + return { + element, + strength: r.strength, + grade: + (r.grade as SpiritualRootGrade) ?? + resolveSpiritualRootGrade(spiritualRootCount, element), + }; + }); +} + +function mapPreHeavenFates( + fates: CultivatorRelations['preHeavenFates'], +): Cultivator['pre_heaven_fates'] { + return FateEngine.normalizeFates( + fates.map( + (f): PreHeavenFate => ({ + name: f.name, + quality: f.quality as Quality, + description: f.description || undefined, + effects: + ((f.details as Record | null)?.effects as + | PreHeavenFate['effects'] + | undefined) || undefined, + generationModel: + ((f.details as Record | null)?.generationModel as + | PreHeavenFate['generationModel'] + | undefined) || undefined, + namingMetadata: + ((f.details as Record | null)?.namingMetadata as + | PreHeavenFate['namingMetadata'] + | undefined) || undefined, + }), + ), + ); +} + +function productModelToAbilityConfig( + productModel: Record | null | undefined, + element: string | null | undefined, + id: string, +): AbilityConfig { + const rehydrated = rehydrateStoredProductModel( + productModel as Record, + (element as ElementType) || undefined, + ); + if (!rehydrated) { + return { slug: id } as AbilityConfig; + } + const config = projectAbilityConfig(rehydrated); + return { ...config, slug: id }; +} + +function productModelToRuntimeModel( + productModel: Record | null | undefined, + element: string | null | undefined, +) { + return rehydrateStoredProductModel( + productModel as Record, + (element as ElementType) || undefined, + ); +} + +function mapLoadoutFromProducts( + products: Awaited>[], +): PlayerLoadout { + const flatProducts = products.flat(); + const skillProducts = flatProducts.filter( + (product) => product.productType === 'skill' && product.isEquipped, + ); + const gongfaProducts = flatProducts.filter( + (product) => product.productType === 'gongfa' && product.isEquipped, + ); + const artifactProducts = flatProducts.filter( + (product) => product.productType === 'artifact' && product.isEquipped, + ); + + const cultivations: Cultivator['cultivations'] = gongfaProducts.map((product) => { + const rehydratedModel = productModelToRuntimeModel( + product.productModel as Record, + product.element, + ); + const abilityConfig = productModelToAbilityConfig( + product.productModel as Record, + product.element, + product.id, + ); + + return { + id: product.id, + name: product.name, + element: (product.element as ElementType) || undefined, + quality: product.quality as Quality | undefined, + score: product.score || 0, + description: product.description || undefined, + attributeModifiers: abilityConfig.modifiers ?? [], + abilityConfig, + productModel: rehydratedModel ?? product.productModel ?? undefined, + }; + }); + + const skills: Cultivator['skills'] = skillProducts.map((product) => { + const rehydratedModel = productModelToRuntimeModel( + product.productModel as Record, + product.element, + ); + const abilityConfig = productModelToAbilityConfig( + product.productModel as Record, + product.element, + product.id, + ); + + return { + id: product.id, + name: product.name, + element: (product.element as ElementType) || '金', + quality: product.quality as Quality | undefined, + cost: abilityConfig.mpCost || undefined, + cooldown: abilityConfig.cooldown ?? 0, + target_self: + abilityConfig.targetPolicy?.team === 'self' ? true : undefined, + description: product.description || undefined, + abilityConfig, + productModel: rehydratedModel ?? product.productModel ?? undefined, + }; + }); + + const artifacts = artifactProducts.map((artifact) => + mapArtifactRow(toArtifactFromProduct(artifact)), + ); + + const equipped: EquippedItems = { + weapon: + artifactProducts.find((product) => product.slot === 'weapon')?.id ?? null, + armor: + artifactProducts.find((product) => product.slot === 'armor')?.id ?? null, + accessory: + artifactProducts.find((product) => product.slot === 'accessory')?.id ?? null, + }; + + return { + skills, + cultivations, + artifacts, + equipped, + }; +} + +function buildProfileCultivator( + cultivatorRecord: CultivatorRecord, + relations: Pick, +): PlayerProfileCultivator { + const spiritual_roots = mapSpiritualRoots(relations.spiritualRoots); + const pre_heaven_fates = mapPreHeavenFates(relations.preHeavenFates); + const profileWithoutCondition: PlayerProfileCultivator = { + id: cultivatorRecord.id, + createdAt: cultivatorRecord.createdAt?.toISOString(), + name: cultivatorRecord.name, + title: cultivatorRecord.title || undefined, + gender: (cultivatorRecord.gender as GenderType) || undefined, + origin: cultivatorRecord.origin || undefined, + personality: cultivatorRecord.personality || undefined, + background: cultivatorRecord.background || undefined, + prompt: cultivatorRecord.prompt, + realm: cultivatorRecord.realm as RealmType, + realm_stage: cultivatorRecord.realm_stage as RealmStage, + age: cultivatorRecord.age, + lifespan: cultivatorRecord.lifespan, + status: (cultivatorRecord.status as Cultivator['status']) ?? 'active', + closed_door_years_total: cultivatorRecord.closedDoorYearsTotal ?? undefined, + retreat_records: [], + breakthrough_history: [], + attributes: { + vitality: cultivatorRecord.vitality, + spirit: cultivatorRecord.spirit, + wisdom: cultivatorRecord.wisdom, + speed: cultivatorRecord.speed, + willpower: cultivatorRecord.willpower, + }, + unallocated_attribute_points: + cultivatorRecord.unallocatedAttributePoints ?? 0, + spiritual_roots, + pre_heaven_fates, + spirit_stones: cultivatorRecord.spirit_stones, + reputation: cultivatorRecord.reputation, + last_yield_at: cultivatorRecord.last_yield_at || new Date(), + balance_notes: cultivatorRecord.balance_notes || undefined, + gameSettings: normalizeCultivatorGameSettings( + cultivatorRecord.gameSettings, + ), + cultivation_progress: getOrInitCultivationProgress( + cultivatorRecord.cultivation_progress as CultivationProgress, + cultivatorRecord.realm as Cultivator['realm'], + cultivatorRecord.realm_stage as Cultivator['realm_stage'], + ), + }; + + return { + ...profileWithoutCondition, + condition: ConditionService.tickNaturalRecovery( + buildCultivatorRuntime(profileWithoutCondition, { + skills: [], + cultivations: [], + artifacts: [], + equipped: emptyEquipped, + }), + (cultivatorRecord.condition as CultivatorCondition | null) ?? undefined, + ), + }; +} + +export function buildCultivatorRuntime( + profile: PlayerProfileCultivator, + loadout: PlayerLoadout, +): Cultivator { + return { + ...profile, + skills: loadout.skills, + cultivations: loadout.cultivations, + inventory: { + artifacts: loadout.artifacts, + consumables: [], + materials: [], + }, + equipped: loadout.equipped, + }; +} + async function assembleCultivatorFromRelations( cultivatorRecord: CultivatorRecord, relations: CultivatorRelations, @@ -688,6 +931,80 @@ export async function getCultivatorById( return assembleCultivator(cultivatorRecord, userId, q); } +export async function getPlayerRuntimeCultivatorById( + userId: string, + cultivatorId: string, + executor?: DbExecutor | DbTransaction, +): Promise { + const q = executor ?? getExecutor(); + const profile = await getPlayerProfileCultivatorById(userId, cultivatorId, q); + if (!profile) { + return null; + } + + const loadout = await getPlayerLoadoutByCultivatorId(cultivatorId, q); + return buildCultivatorRuntime(profile, loadout); +} + +export async function getPlayerProfileCultivatorById( + userId: string, + cultivatorId: string, + executor?: DbExecutor | DbTransaction, +): Promise { + const q = executor ?? getExecutor(); + const cultivatorRecord = await findActiveCultivatorRecordByIdAndUser( + userId, + cultivatorId, + q, + ); + if (!cultivatorRecord) { + return null; + } + + const [spiritualRoots, preHeavenFates] = await Promise.all([ + q + .select() + .from(schema.spiritualRoots) + .where(eq(schema.spiritualRoots.cultivatorId, cultivatorId)), + q + .select() + .from(schema.preHeavenFates) + .where(eq(schema.preHeavenFates.cultivatorId, cultivatorId)), + ]); + + return buildProfileCultivator(cultivatorRecord, { + spiritualRoots, + preHeavenFates, + }); +} + +export async function getPlayerLoadoutByCultivatorId( + cultivatorId: string, + executor?: DbExecutor | DbTransaction, +): Promise { + const q = executor ?? getExecutor(); + const [skills, cultivations, artifacts] = await Promise.all([ + creationProductRepository.findEquippedByType(cultivatorId, 'skill', q), + creationProductRepository.findEquippedByType(cultivatorId, 'gongfa', q), + creationProductRepository.findEquippedByType(cultivatorId, 'artifact', q), + ]); + + return mapLoadoutFromProducts([skills, cultivations, artifacts]); +} + +export async function getPlayerProfileCultivatorsByUserId( + userId: string, +): Promise { + const q = getExecutor(); + const record = await findActiveCultivatorRecordByUserId(userId, q); + if (!record) { + return []; + } + + const profile = await getPlayerProfileCultivatorById(userId, record.id, q); + return profile ? [profile] : []; +} + /** * 获取用户的所有角色 */ @@ -773,6 +1090,31 @@ export async function getCultivatorByIdUnsafe( }; } +export async function getPlayerRuntimeCultivatorByIdUnsafe( + cultivatorId: string, + executor?: DbExecutor | DbTransaction, +): Promise { + const q = executor ?? getExecutor(); + const owner = await findCultivatorOwnerStatusById(cultivatorId, q); + if (!owner || owner.status !== 'active') { + return null; + } + + const [profile, loadout] = await Promise.all([ + getPlayerProfileCultivatorById(owner.userId, cultivatorId, q), + getPlayerLoadoutByCultivatorId(cultivatorId, q), + ]); + + if (!profile) { + return null; + } + + return { + cultivator: buildCultivatorRuntime(profile, loadout), + userId: owner.userId, + }; +} + export async function getCultivatorBasicsByIdUnsafe( cultivatorId: string, executor?: DbExecutor | DbTransaction, @@ -973,7 +1315,7 @@ export async function updateCultivator( .update(schema.cultivators) .set(updateData) .where(eq(schema.cultivators.id, cultivatorId)); - const res = await getCultivatorByIdUnsafe(cultivatorId, q); + const res = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId, q); return res?.cultivator || null; } diff --git a/src/server/lib/tower/service.test.ts b/src/server/lib/tower/service.test.ts index 31d53678..5cfc6202 100644 --- a/src/server/lib/tower/service.test.ts +++ b/src/server/lib/tower/service.test.ts @@ -30,6 +30,7 @@ vi.mock('@server/lib/redis', () => ({ vi.mock('@server/lib/services/cultivatorService', () => ({ getCultivatorByIdUnsafe: getCultivatorByIdUnsafeMock, + getPlayerRuntimeCultivatorByIdUnsafe: getCultivatorByIdUnsafeMock, })); vi.mock('@server/lib/services/ConditionService', () => ({ diff --git a/src/server/lib/tower/service.ts b/src/server/lib/tower/service.ts index 0d8eb0f3..b260c4c1 100644 --- a/src/server/lib/tower/service.ts +++ b/src/server/lib/tower/service.ts @@ -23,7 +23,7 @@ import { RewardFactory } from '@server/lib/dungeon/reward'; import { redis } from '@server/lib/redis'; import { parseRedisJson } from '@server/lib/redis/json'; import { - getCultivatorByIdUnsafe, + getPlayerRuntimeCultivatorByIdUnsafe, } from '@server/lib/services/cultivatorService'; import { ConditionService } from '@server/lib/services/ConditionService'; import { MailService, type MailAttachment } from '@server/lib/services/MailService'; @@ -389,7 +389,7 @@ export class TowerService { throw new Error('当前已有尚未结束的幻境进度'); } - const cultivatorBundle = await getCultivatorByIdUnsafe(cultivatorId); + const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId); if (!cultivatorBundle?.cultivator) { throw new Error('未找到修真者数据'); } @@ -491,7 +491,7 @@ export class TowerService { state.status = 'READY'; } - const cultivatorBundle = await getCultivatorByIdUnsafe(cultivatorId); + const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId); if (!cultivatorBundle?.cultivator) { throw new Error('未找到修真者数据'); } @@ -591,7 +591,7 @@ export class TowerService { throw new Error('幻境战局数据不存在或已失效'); } - const cultivatorBundle = await getCultivatorByIdUnsafe(cultivatorId); + const cultivatorBundle = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId); if (!cultivatorBundle?.cultivator) { throw new Error('未找到修真者数据'); } diff --git a/src/server/routes/api/auction.router.ts b/src/server/routes/api/auction.router.ts index 8f578ceb..1a467797 100644 --- a/src/server/routes/api/auction.router.ts +++ b/src/server/routes/api/auction.router.ts @@ -199,6 +199,7 @@ router.post('/list', requireActiveCultivator(), async (c) => { userId: user.id, cultivatorId: cultivator.id, source: 'auction_list', + allowEmpty: true, run: async (tx) => { const result = await listItem( { @@ -215,18 +216,12 @@ router.post('/list', requireActiveCultivator(), async (c) => { { tx, deferCacheClear: true }, ); - const changes: StateChangeDescriptor[] = [ - { - domain: 'inventory', - eventType: 'inventory.auction.listed', - invalidates: ['inventory'], - }, - ]; + const changes: StateChangeDescriptor[] = []; if (itemType === 'artifact') { changes.push({ - domain: 'products', - eventType: 'products.auction.listed', - invalidates: ['products'], + domain: 'loadout', + eventType: 'loadout.auction.listed', + invalidates: ['loadout'], }); } diff --git a/src/server/routes/api/bet-battles.router.ts b/src/server/routes/api/bet-battles.router.ts index 422a2ebb..905dc759 100644 --- a/src/server/routes/api/bet-battles.router.ts +++ b/src/server/routes/api/bet-battles.router.ts @@ -92,18 +92,12 @@ function buildBetStakeChanges( ]; } - const changes: StateChangeDescriptor[] = [ - { - domain: 'inventory', - eventType: 'inventory.bet_battle.staked', - invalidates: ['inventory'], - }, - ]; + const changes: StateChangeDescriptor[] = []; if (stakeItem?.itemType === 'artifact') { changes.push({ - domain: 'products', - eventType: 'products.bet_battle.staked', - invalidates: ['products'], + domain: 'loadout', + eventType: 'loadout.bet_battle.staked', + invalidates: ['loadout'], }); } return changes; @@ -195,6 +189,7 @@ router.post('/create', requireActiveCultivator(), async (c) => { userId: user.id, cultivatorId: cultivator.id, source: 'bet_battle_create', + allowEmpty: true, run: async (tx) => { const result = await createBetBattle( { diff --git a/src/server/routes/api/craft.router.test.ts b/src/server/routes/api/craft.router.test.ts index 9dda6293..98623c26 100644 --- a/src/server/routes/api/craft.router.test.ts +++ b/src/server/routes/api/craft.router.test.ts @@ -110,9 +110,14 @@ vi.mock('@server/lib/services/PlayerStateMutationService', () => ({ })), })); -vi.mock('@server/lib/services/cultivatorService', () => ({ - getCultivatorById: vi.fn(), -})); +vi.mock('@server/lib/services/cultivatorService', () => { + const getCultivatorById = vi.fn(); + + return { + getCultivatorById, + getPlayerProfileCultivatorById: getCultivatorById, + }; +}); import { craftFromFormula, @@ -379,11 +384,6 @@ describe('craft router alchemy routes', () => { eventType: 'currency.changed', invalidates: ['currency'], }), - expect.objectContaining({ - domain: 'inventory', - eventType: 'inventory.changed', - invalidates: ['inventory'], - }), expect.objectContaining({ domain: 'tasks', eventType: 'tasks.changed', @@ -597,10 +597,6 @@ describe('craft router alchemy routes', () => { domain: 'currency', eventType: 'currency.changed', }), - expect.objectContaining({ - domain: 'inventory', - eventType: 'inventory.changed', - }), expect.objectContaining({ domain: 'tasks', eventType: 'tasks.changed', diff --git a/src/server/routes/api/craft.router.ts b/src/server/routes/api/craft.router.ts index 55f4da20..b893ba0b 100644 --- a/src/server/routes/api/craft.router.ts +++ b/src/server/routes/api/craft.router.ts @@ -22,7 +22,7 @@ import { QiServiceError, } from '@server/lib/services/QiService'; import { TaskService } from '@server/lib/services/TaskService'; -import { getCultivatorById } from '@server/lib/services/cultivatorService'; +import { getPlayerProfileCultivatorById } from '@server/lib/services/cultivatorService'; import { commitPlayerStateMutation, toPlayerStateMutationResponse, @@ -163,11 +163,6 @@ function buildAlchemyStateChanges(args: { }, invalidates: ['currency'], }, - { - domain: 'inventory', - eventType: 'inventory.changed', - invalidates: ['inventory'], - }, ]; if (args.taskSynced) { @@ -197,11 +192,6 @@ function buildCreationStateChanges(args: { }, invalidates: ['currency'], }, - { - domain: 'inventory', - eventType: 'inventory.changed', - invalidates: ['inventory'], - }, ]; if (args.craftType === 'create_skill' || args.craftType === 'create_gongfa') { @@ -212,11 +202,14 @@ function buildCreationStateChanges(args: { }); } - if (!args.needsReplace) { + if ( + !args.needsReplace && + (args.craftType === 'create_skill' || args.craftType === 'create_gongfa') + ) { changes.push({ - domain: 'products', - eventType: 'products.changed', - invalidates: ['products'], + domain: 'loadout', + eventType: 'loadout.changed', + invalidates: ['loadout'], }); } @@ -367,8 +360,11 @@ router.get('/', requireActiveCultivator(), async (c) => { } try { - const fullCultivator = await getCultivatorById(user.id, cultivator.id); - const fateList = fullCultivator?.pre_heaven_fates ?? []; + const profileCultivator = await getPlayerProfileCultivatorById( + user.id, + cultivator.id, + ); + const fateList = profileCultivator?.pre_heaven_fates ?? []; const materialIdsParam = c.req.query('materialIds'); const materialQuantitiesParam = c.req.query('materialQuantities'); const craftType = c.req.query('craftType'); @@ -748,9 +744,9 @@ confirmRouter.post('/', requireActiveCultivator(), async (c) => { }, changes: [ { - domain: 'products', - eventType: 'products.changed', - invalidates: ['products'], + domain: 'loadout', + eventType: 'loadout.changed', + invalidates: ['loadout'], }, ], }; diff --git a/src/server/routes/api/cultivator.router.test.ts b/src/server/routes/api/cultivator.router.test.ts index 6fb5f2b5..06f5f998 100644 --- a/src/server/routes/api/cultivator.router.test.ts +++ b/src/server/routes/api/cultivator.router.test.ts @@ -184,23 +184,28 @@ vi.mock('@server/lib/services/InnRecoveryService', () => ({ }, })); -vi.mock('@server/lib/services/cultivatorService', () => ({ - addBreakthroughHistoryEntry: vi.fn(), - addRetreatRecord: vi.fn(), - consumeConsumableById: vi.fn(), - consumeMaterialById: vi.fn(), - equipEquipment: vi.fn(), - getCultivatorArtifacts: vi.fn(), - getCultivatorById: vi.fn(), - getCultivatorConsumables: vi.fn(), - getCultivatorMaterials: vi.fn(), - getLastDeadCultivatorSummary: vi.fn(), - getPaginatedInventoryByType: vi.fn(), - updateCultivationExp: vi.fn(), - updateCultivator: vi.fn(), - updateLastYieldAt: vi.fn(), - updateSpiritStones: vi.fn(), -})); +vi.mock('@server/lib/services/cultivatorService', () => { + const getCultivatorById = vi.fn(); + + return { + addBreakthroughHistoryEntry: vi.fn(), + addRetreatRecord: vi.fn(), + consumeConsumableById: vi.fn(), + consumeMaterialById: vi.fn(), + equipEquipment: vi.fn(), + getCultivatorArtifacts: vi.fn(), + getCultivatorById, + getPlayerRuntimeCultivatorById: getCultivatorById, + getCultivatorConsumables: vi.fn(), + getCultivatorMaterials: vi.fn(), + getLastDeadCultivatorSummary: vi.fn(), + getPaginatedInventoryByType: vi.fn(), + updateCultivationExp: vi.fn(), + updateCultivator: vi.fn(), + updateLastYieldAt: vi.fn(), + updateSpiritStones: vi.fn(), + }; +}); vi.mock('@server/utils/aiClient', () => ({ stream_text: vi.fn(), @@ -402,8 +407,7 @@ function createStateVersionRow(overrides: Record = {}) { conditionVersion: 0, progressVersion: 0, currencyVersion: 0, - inventoryVersion: 0, - productsVersion: 0, + loadoutVersion: 0, mailVersion: 0, tasksVersion: 0, updatedAt: new Date('2026-06-10T00:00:00.000Z'), @@ -535,8 +539,7 @@ function mockTransactionReturning(rows: unknown[]) { conditionVersion: 1, progressVersion: 1, currencyVersion: 1, - inventoryVersion: 0, - productsVersion: 0, + loadoutVersion: 0, mailVersion: 0, tasksVersion: 0, updatedAt: new Date('2026-06-10T00:00:00.000Z'), @@ -595,8 +598,7 @@ function mockMailReadTransaction(unreadCount: number) { conditionVersion: 0, progressVersion: 0, currencyVersion: 0, - inventoryVersion: 0, - productsVersion: 0, + loadoutVersion: 0, mailVersion: 1, tasksVersion: 0, updatedAt: new Date('2026-06-10T00:00:00.000Z'), @@ -666,8 +668,7 @@ function mockConsumeTransaction( conditionVersion: 0, progressVersion: 0, currencyVersion: options.attributeReset ? 0 : 1, - inventoryVersion: 1, - productsVersion: 0, + loadoutVersion: 0, mailVersion: 0, tasksVersion: 1, updatedAt: new Date('2026-06-10T00:00:00.000Z'), @@ -676,13 +677,6 @@ function mockConsumeTransaction( ? createStateEventRows([ { id: 21, - domain: 'inventory', - eventType: 'inventory.consumable.used', - source: 'consumable_use', - invalidates: ['inventory'], - }, - { - id: 22, domain: 'profile', eventType: 'profile.attributes.reset', source: 'consumable_use', @@ -698,6 +692,13 @@ function mockConsumeTransaction( unallocated_attribute_points: 18, }, }, + { + id: 22, + domain: 'tasks', + eventType: 'tasks.maybe_changed', + source: 'consumable_use', + invalidates: ['tasks'], + }, ]) : [ { @@ -705,10 +706,23 @@ function mockConsumeTransaction( cultivatorId: 'cultivator-1', userId: 'user-1', globalVersion: 1, - domain: 'inventory', - eventType: 'inventory.consumable.used', + domain: 'currency', + eventType: 'currency.qi.changed', + patch: { currency: { qi: 150 } }, + invalidates: ['currency'], + source: 'consumable_use', + requestId: null, + createdAt: new Date('2026-06-10T00:00:00.000Z'), + }, + { + id: 22, + cultivatorId: 'cultivator-1', + userId: 'user-1', + globalVersion: 1, + domain: 'tasks', + eventType: 'tasks.maybe_changed', patch: {}, - invalidates: ['inventory'], + invalidates: ['tasks'], source: 'consumable_use', requestId: null, createdAt: new Date('2026-06-10T00:00:00.000Z'), @@ -746,10 +760,10 @@ function mockBodyCultivationBreakthroughTransaction() { patch: {}, }, { - domain: 'inventory', + domain: 'loadout', eventType: 'inventory.body_cultivation.breakthrough_consumed', source: 'body_cultivation_breakthrough', - invalidates: ['inventory'], + invalidates: ['loadout'], }, ]); const insertReturning = vi @@ -966,15 +980,8 @@ describe('cultivator attribute reset route', () => { const txMock = mockStateOnlyTransaction({ versionRow: createStateVersionRow({ profileVersion: 1, - inventoryVersion: 1, }), eventRows: createStateEventRows([ - { - domain: 'inventory', - eventType: 'inventory.attribute_reset_talisman.used', - source: 'profile_attribute_reset', - invalidates: ['inventory'], - }, { domain: 'profile', eventType: 'profile.attributes.reset', @@ -1010,7 +1017,6 @@ describe('cultivator attribute reset route', () => { data: resetResult, state: expect.objectContaining({ domainVersions: { - inventory: 1, profile: 1, }, }), @@ -1346,7 +1352,6 @@ describe('cultivator qi routes', () => { globalVersion: 1, domainVersions: { currency: 1, - inventory: 1, tasks: 1, }, events: [ @@ -1354,10 +1359,21 @@ describe('cultivator qi routes', () => { id: 21, cultivatorId: 'cultivator-1', globalVersion: 1, - domain: 'inventory', - eventType: 'inventory.consumable.used', + domain: 'currency', + eventType: 'currency.qi.changed', + patch: { currency: { qi: 150 } }, + invalidates: ['currency'], + source: 'consumable_use', + createdAt: '2026-06-10T00:00:00.000Z', + }, + { + id: 22, + cultivatorId: 'cultivator-1', + globalVersion: 1, + domain: 'tasks', + eventType: 'tasks.maybe_changed', patch: {}, - invalidates: ['inventory'], + invalidates: ['tasks'], source: 'consumable_use', createdAt: '2026-06-10T00:00:00.000Z', }, @@ -1405,7 +1421,6 @@ describe('cultivator qi routes', () => { }), state: expect.objectContaining({ domainVersions: { - inventory: 1, profile: 1, tasks: 1, }, @@ -1629,7 +1644,7 @@ describe('cultivator body cultivation routes', () => { vi.clearAllMocks(); }); - it('returns body cultivation breakthrough readiness with inventory ownership', async () => { + it('returns body cultivation breakthrough readiness without inventory ownership', async () => { const cultivator = createCultivator(); cultivator.realm = '炼气'; cultivator.condition = { @@ -1702,14 +1717,12 @@ describe('cultivator body cultivation routes', () => { successChance: 0.82, guaranteeProgress: 0, failedAttempts: 0, - inventoryRequirements: [ + costRequirements: [ { type: 'material', name: '进阶材料(特殊辅料,玄品以上)', label: '进阶材料(特殊辅料,玄品以上)', quantity: 1, - ownedQuantity: 1, - met: true, materialType: 'aux', minQuality: '玄品', }, @@ -1718,8 +1731,6 @@ describe('cultivator body cultivation routes', () => { name: '皮肤方向炼体丹(玄品以上)', label: '皮肤方向炼体丹(玄品以上)', quantity: 1, - ownedQuantity: 1, - met: true, family: 'tempering', property: 'body_skin', minQuality: '玄品', @@ -1803,6 +1814,11 @@ describe('cultivator body cultivation routes', () => { '/api/cultivator/body-cultivation/breakthrough', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + materialSelections: [{ id: 'material-1', quantity: 1 }], + consumableSelections: [{ id: 'pill-1', quantity: 1 }], + }), }, ); } finally { @@ -1951,6 +1967,11 @@ describe('cultivator body cultivation routes', () => { '/api/cultivator/body-cultivation/breakthrough', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + materialSelections: [{ id: 'material-1', quantity: 1 }], + consumableSelections: [{ id: 'pill-1', quantity: 1 }], + }), }, ); } finally { @@ -2053,6 +2074,11 @@ describe('cultivator body cultivation routes', () => { '/api/cultivator/body-cultivation/breakthrough', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + materialSelections: [{ id: 'material-1', quantity: 1 }], + consumableSelections: [], + }), }, ); diff --git a/src/server/routes/api/cultivator.router.ts b/src/server/routes/api/cultivator.router.ts index 871f14d4..c597df65 100644 --- a/src/server/routes/api/cultivator.router.ts +++ b/src/server/routes/api/cultivator.router.ts @@ -39,7 +39,9 @@ import { createMessage } from '@server/lib/repositories/worldChatRepository'; import { removeFromAllRankingRealmsExcept } from '@server/lib/redis/rankings'; import { consumeBodyCultivationBreakthroughCosts, - getBodyCultivationBreakthroughReadiness, + getBodyCultivationBreakthroughPreviewData, + listEligibleBodyCultivationBreakthroughItems, + planBodyCultivationBreakthroughSelections, } from '@server/lib/services/BodyCultivationBreakthroughService'; import { AttributeResetService, @@ -76,12 +78,9 @@ import { TaskService } from '@server/lib/services/TaskService'; import { addBreakthroughHistoryEntry, addRetreatRecord, - getCultivatorArtifacts, - getCultivatorById, - getCultivatorConsumables, - getCultivatorMaterials, getLastDeadCultivatorSummary, getPaginatedInventoryByType, + getPlayerRuntimeCultivatorById, updateCultivationExp, updateCultivator, updateSpiritStones, @@ -111,7 +110,10 @@ import type { RetreatResultData, RetreatStreamEvent, } from '@shared/contracts/retreat'; -import type { BodyCultivationBreakthroughReadinessResponse } from '@shared/contracts/bodyCultivation'; +import type { + BodyCultivationBreakthroughEligibleResponse, + BodyCultivationBreakthroughReadinessResponse, +} from '@shared/contracts/bodyCultivation'; import type { PlayerStateDomain, PlayerStateEvent, @@ -202,6 +204,20 @@ const ReadMailSchema = z.object({ mailId: z.string(), }); +const BodyCultivationBreakthroughSelectionSchema = z.object({ + id: z.string().min(1), + quantity: z.number().int().min(1), +}); + +const BodyCultivationBreakthroughSchema = z.object({ + materialSelections: z + .array(BodyCultivationBreakthroughSelectionSchema) + .default([]), + consumableSelections: z + .array(BodyCultivationBreakthroughSelectionSchema) + .default([]), +}); + class RedeemClaimError extends Error { status: number; @@ -261,10 +277,7 @@ function getRewardAffectedDomains( gain.type === 'consumable' || gain.type === 'artifact' ) { - domains.add('inventory'); - } - if (gain.type === 'artifact') { - domains.add('products'); + continue; } if (gain.type === 'spirit_stones' || gain.type === 'reputation') { domains.add('currency'); @@ -305,19 +318,11 @@ function buildMailStateChanges(args: { }, ]; - if (affectedDomains.has('inventory')) { + if (affectedDomains.has('loadout')) { changes.push({ - domain: 'inventory', - eventType: 'inventory.changed', - invalidates: ['inventory'], - }); - } - - if (affectedDomains.has('products')) { - changes.push({ - domain: 'products', - eventType: 'products.changed', - invalidates: ['products'], + domain: 'loadout', + eventType: 'loadout.changed', + invalidates: ['loadout'], }); } @@ -364,18 +369,13 @@ function buildMailStateChanges(args: { function buildArtifactEquipStateChanges(eventType: string): StateChangeDescriptor[] { return [ { - domain: 'products', + domain: 'loadout', eventType, - invalidates: ['products'], - }, - { - domain: 'inventory', - eventType: 'inventory.artifacts.changed', - invalidates: ['inventory'], + invalidates: ['loadout'], }, { domain: 'profile', - eventType: 'profile.products.changed', + eventType: 'profile.loadout.changed', invalidates: ['profile'], }, ]; @@ -657,11 +657,6 @@ router.post('/consume', requireActiveCultivator(), async (c) => { .limit(1); const changes: StateChangeDescriptor[] = [ - { - domain: 'inventory', - eventType: 'inventory.consumable.used', - invalidates: ['inventory'], - }, { domain: 'tasks', eventType: 'tasks.maybe_changed', @@ -761,7 +756,10 @@ router.post('/inn-recovery', requireActiveCultivator(), async (c) => { return c.json({ success: false, error: '未授权访问' }, 401); } - const cultivator = await getCultivatorById(user.id, activeCultivator.id); + const cultivator = await getPlayerRuntimeCultivatorById( + user.id, + activeCultivator.id, + ); if (!cultivator) { return c.json({ success: false, error: '角色不存在' }, 404); } @@ -862,22 +860,61 @@ router.get('/body-cultivation/breakthrough', requireActiveCultivator(), async (c return c.json({ success: false, error: '未授权访问' }, 401); } - const cultivator = await getCultivatorById(user.id, activeCultivator.id); + const cultivator = await getPlayerRuntimeCultivatorById( + user.id, + activeCultivator.id, + ); if (!cultivator) { return c.json({ success: false, error: '角色不存在' }, 404); } - const readiness = getBodyCultivationBreakthroughReadiness(cultivator); + const readiness = getBodyCultivationBreakthroughPreviewData(cultivator); const response: BodyCultivationBreakthroughReadinessResponse = { + success: true, + data: readiness, + }; + + return c.json(response); +}); + +router.get('/body-cultivation/breakthrough/eligible', requireActiveCultivator(), async (c) => { + const user = c.get('user'); + const activeCultivator = c.get('cultivator'); + if (!user || !activeCultivator) { + return c.json({ success: false, error: '未授权访问' }, 401); + } + + const cultivator = await getPlayerRuntimeCultivatorById( + user.id, + activeCultivator.id, + ); + if (!cultivator) { + return c.json({ success: false, error: '角色不存在' }, 404); + } + + const eligible = await listEligibleBodyCultivationBreakthroughItems(cultivator); + const response: BodyCultivationBreakthroughEligibleResponse = { success: true, data: { - nextRealm: readiness.nextRealm, - canAttempt: readiness.canAttempt, - successChance: readiness.successChance, - guaranteeProgress: readiness.guaranteeProgress, - failedAttempts: readiness.failedAttempts, - ruleRequirements: readiness.ruleRequirements, - inventoryRequirements: readiness.inventoryRequirements, + requirements: eligible.requirements, + materials: eligible.materials.map((material) => ({ + id: material.id!, + name: material.name, + type: material.type, + rank: material.rank, + quantity: material.quantity, + element: material.element, + description: material.description, + requirementLabel: material.requirementLabel, + })), + consumables: eligible.consumables.map((consumable) => ({ + id: consumable.id!, + name: consumable.name, + type: consumable.type, + quality: consumable.quality ?? '凡品', + quantity: consumable.quantity, + requirementLabel: consumable.requirementLabel, + })), }, }; @@ -891,39 +928,22 @@ router.post('/body-cultivation/breakthrough', requireActiveCultivator(), async ( return c.json({ success: false, error: '未授权访问' }, 401); } - const cultivator = await getCultivatorById(user.id, activeCultivator.id); + const cultivator = await getPlayerRuntimeCultivatorById( + user.id, + activeCultivator.id, + ); if (!cultivator) { return c.json({ success: false, error: '角色不存在' }, 404); } try { - const readiness = getBodyCultivationBreakthroughReadiness(cultivator); - const missingRuleRequirements = readiness.ruleRequirements.filter( - (requirement) => !requirement.met, + const requestBody = BodyCultivationBreakthroughSchema.parse( + await c.req.json().catch(() => ({})), ); - const missingInventoryRequirements = readiness.inventoryRequirements.filter( - (requirement) => !requirement.met, + const costPlan = await planBodyCultivationBreakthroughSelections( + cultivator, + requestBody, ); - if (!readiness.canAttempt) { - return c.json( - { - success: false, - error: [ - ...missingRuleRequirements.map((requirement) => requirement.label), - ...missingInventoryRequirements.map( - (requirement) => - `${requirement.label ?? requirement.name} ${requirement.ownedQuantity}/${requirement.quantity}`, - ), - ].join('、') || '肉身进阶条件不足', - data: { - nextRealm: readiness.nextRealm, - ruleRequirements: readiness.ruleRequirements, - inventoryRequirements: readiness.inventoryRequirements, - }, - }, - 400, - ); - } const result = ConditionService.breakthroughBodyCultivationRealm( cultivator, @@ -937,7 +957,7 @@ router.post('/body-cultivation/breakthrough', requireActiveCultivator(), async ( await consumeBodyCultivationBreakthroughCosts( user.id, activeCultivator.id, - readiness.costPlan, + costPlan, tx, ); const saved = await updateCultivator( @@ -972,9 +992,9 @@ router.post('/body-cultivation/breakthrough', requireActiveCultivator(), async ( patch: { condition: result.condition }, }, { - domain: 'inventory' as const, + domain: 'loadout' as const, eventType: 'inventory.body_cultivation.breakthrough_consumed', - invalidates: ['inventory'], + invalidates: ['loadout'], }, ], }; @@ -1060,7 +1080,7 @@ router.post('/equip', requireActiveCultivator(), async (c) => { return { result: equippedItems, - changes: buildArtifactEquipStateChanges('products.artifact.equipped'), + changes: buildArtifactEquipStateChanges('loadout.artifact.equipped'), }; }, }); @@ -1314,11 +1334,6 @@ router.post('/attributes/reset', requireActiveCultivator(), async (c) => { return { result, changes: [ - { - domain: 'inventory', - eventType: 'inventory.attribute_reset_talisman.used', - invalidates: ['inventory'], - }, { domain: 'profile', eventType: 'profile.attributes.reset', @@ -1500,7 +1515,10 @@ router.post('/retreat', requireActiveCultivator(), async (c) => { return c.json({ error: '角色正在闭关中,请稍后再试' }, 409); } - const cultivator = await getCultivatorById(user.id, cultivatorId); + const cultivator = await getPlayerRuntimeCultivatorById( + user.id, + cultivatorId, + ); if (!cultivator) { return c.json({ error: '角色不存在' }, 404); } @@ -1892,7 +1910,10 @@ router.post('/yield', requireActiveCultivator(), async (c) => { } try { - const fullCultivator = await getCultivatorById(userId, cultivatorId); + const fullCultivator = await getPlayerRuntimeCultivatorById( + userId, + cultivatorId, + ); if (!fullCultivator) { await redis.del(lockKey); return c.json({ success: false, error: '未找到角色信息' }, 404); @@ -2187,8 +2208,17 @@ inventoryRouter.get('/', requireActiveCultivatorRef(), async (c) => { } const type = c.req.query('type'); - if (type) { - if (!['artifacts', 'materials', 'consumables'].includes(type)) { + if (!type) { + return c.json( + { + success: false, + error: '必须指定背包类型和分页参数', + }, + 400, + ); + } + + if (!['artifacts', 'materials', 'consumables'].includes(type)) { return c.json( { success: false, @@ -2291,22 +2321,6 @@ inventoryRouter.get('/', requireActiveCultivatorRef(), async (c) => { success: true, data: result, }); - } - - const [consumableItems, materialItems, artifactItems] = await Promise.all([ - getCultivatorConsumables(user.id, ref.cultivatorId), - getCultivatorMaterials(user.id, ref.cultivatorId), - getCultivatorArtifacts(user.id, ref.cultivatorId), - ]); - - return c.json({ - success: true, - data: { - consumables: consumableItems, - materials: materialItems, - artifacts: artifactItems, - }, - }); }); inventoryRouter.post('/discard', requireActiveCultivator(), async (c) => { @@ -2322,6 +2336,7 @@ inventoryRouter.post('/discard', requireActiveCultivator(), async (c) => { userId: user.id, cultivatorId: cultivator.id, source: 'inventory_discard', + allowEmpty: true, run: async (tx) => { let deleted = false; @@ -2363,18 +2378,12 @@ inventoryRouter.post('/discard', requireActiveCultivator(), async (c) => { throw new MarketServiceError(404, '物品未找到或无法删除'); } - const changes: StateChangeDescriptor[] = [ - { - domain: 'inventory', - eventType: 'inventory.discarded', - invalidates: ['inventory'], - }, - ]; + const changes: StateChangeDescriptor[] = []; if (itemType === 'artifact') { changes.push({ - domain: 'products', - eventType: 'products.artifact.discarded', - invalidates: ['products'], + domain: 'loadout', + eventType: 'loadout.artifact.discarded', + invalidates: ['loadout'], }); } @@ -2414,11 +2423,6 @@ inventoryRouter.post('/identify', requireActiveCultivator(), async (c) => { return { result, changes: [ - { - domain: 'inventory', - eventType: 'inventory.material.identified', - invalidates: ['inventory'], - }, { domain: 'currency', eventType: 'currency.changed', @@ -2506,6 +2510,7 @@ mailRouter.post('/send', requireActiveCultivator(), async (c) => { userId: user.id, cultivatorId: cultivator.id, source: 'player_mail_send', + allowEmpty: true, run: async (tx) => { const result = await sendPlayerMail({ senderCultivatorId: cultivator.id, @@ -2516,18 +2521,12 @@ mailRouter.post('/send', requireActiveCultivator(), async (c) => { tx, }); - const changes: StateChangeDescriptor[] = [ - { - domain: 'inventory', - eventType: 'inventory.mail.sent', - invalidates: ['inventory'], - }, - ]; + const changes: StateChangeDescriptor[] = []; if (parsed.attachment?.itemType === 'artifact') { changes.push({ - domain: 'products', - eventType: 'products.mail.sent', - invalidates: ['products'], + domain: 'loadout', + eventType: 'loadout.mail.sent', + invalidates: ['loadout'], }); } diff --git a/src/server/routes/api/cultivators.router.ts b/src/server/routes/api/cultivators.router.ts index 4b098984..502e047e 100644 --- a/src/server/routes/api/cultivators.router.ts +++ b/src/server/routes/api/cultivators.router.ts @@ -2,10 +2,12 @@ import { requireUser } from '@server/lib/hono/middleware'; import type { AppEnv } from '@server/lib/hono/types'; import { deleteCultivator, - getCultivatorById, - getCultivatorsByUserId, getLastDeadCultivatorSummary, + getPlayerLoadoutByCultivatorId, + getPlayerProfileCultivatorsByUserId, + getPlayerRuntimeCultivatorById, hasDeadCultivator, + buildCultivatorRuntime, } from '@server/lib/services/cultivatorService'; import { Hono } from 'hono'; @@ -19,7 +21,10 @@ router.get('/', requireUser(), async (c) => { const cultivatorId = c.req.query('id'); if (cultivatorId) { - const cultivator = await getCultivatorById(user.id, cultivatorId); + const cultivator = await getPlayerRuntimeCultivatorById( + user.id, + cultivatorId, + ); if (!cultivator) { return c.json({ error: '角色不存在' }, 404); } @@ -30,7 +35,15 @@ router.get('/', requireUser(), async (c) => { }); } - const cultivators = await getCultivatorsByUserId(user.id); + const profiles = await getPlayerProfileCultivatorsByUserId(user.id); + const cultivators = await Promise.all( + profiles.map(async (profile) => + buildCultivatorRuntime( + profile, + await getPlayerLoadoutByCultivatorId(profile.id!), + ), + ), + ); const deadExists = await hasDeadCultivator(user.id); return c.json({ success: true, diff --git a/src/server/routes/api/dungeon.router.test.ts b/src/server/routes/api/dungeon.router.test.ts index 3f408772..f32d5457 100644 --- a/src/server/routes/api/dungeon.router.test.ts +++ b/src/server/routes/api/dungeon.router.test.ts @@ -12,6 +12,7 @@ const { recoverDungeonMock, listCultivatorTasksMock, getCultivatorByIdUnsafeMock, + hasCultivatorRecoveryPillMock, getCultivatorDisplaySnapshotMock, getMapNodeMock, isSatelliteNodeMock, @@ -32,6 +33,7 @@ const { recoverDungeonMock: vi.fn(), listCultivatorTasksMock: vi.fn(), getCultivatorByIdUnsafeMock: vi.fn(), + hasCultivatorRecoveryPillMock: vi.fn(), getCultivatorDisplaySnapshotMock: vi.fn(), getMapNodeMock: vi.fn(), isSatelliteNodeMock: vi.fn(), @@ -162,7 +164,39 @@ vi.mock('@server/lib/services/QiService', () => ({ })); vi.mock('@server/lib/services/cultivatorService', () => ({ + buildCultivatorRuntime: vi.fn((profile, loadout) => ({ + ...profile, + skills: loadout.skills, + cultivations: loadout.cultivations, + inventory: { + artifacts: loadout.artifacts, + materials: [], + consumables: [], + }, + equipped: loadout.equipped, + })), getCultivatorByIdUnsafe: getCultivatorByIdUnsafeMock, + getPlayerLoadoutByCultivatorId: vi.fn(async () => ({ + skills: [], + cultivations: [], + artifacts: [], + equipped: { + weapon: null, + armor: null, + accessory: null, + }, + })), + getPlayerProfileCultivatorById: vi.fn(async () => { + const bundle = await getCultivatorByIdUnsafeMock(); + return { + id: 'cultivator-1', + realm: bundle?.cultivator?.realm ?? '炼气', + }; + }), +})); + +vi.mock('@server/lib/repositories/cultivatorRepository', () => ({ + hasCultivatorRecoveryPill: hasCultivatorRecoveryPillMock, })); vi.mock('@shared/engine/battle-v5/adapters/CultivatorDisplayAdapter', () => ({ @@ -198,6 +232,7 @@ describe('dungeon battle router', () => { beforeEach(() => { vi.clearAllMocks(); listCultivatorTasksMock.mockResolvedValue([]); + hasCultivatorRecoveryPillMock.mockResolvedValue(true); getCultivatorByIdUnsafeMock.mockResolvedValue({ cultivator: { id: 'cultivator-1', diff --git a/src/server/routes/api/dungeon.router.ts b/src/server/routes/api/dungeon.router.ts index 38657df9..8595c7f1 100644 --- a/src/server/routes/api/dungeon.router.ts +++ b/src/server/routes/api/dungeon.router.ts @@ -24,10 +24,14 @@ import { toPlayerStateMutationResponse, type StateChangeDescriptor, } from '@server/lib/services/PlayerStateMutationService'; -import { getCultivatorByIdUnsafe } from '@server/lib/services/cultivatorService'; +import { hasCultivatorRecoveryPill } from '@server/lib/repositories/cultivatorRepository'; +import { + buildCultivatorRuntime, + getPlayerLoadoutByCultivatorId, + getPlayerProfileCultivatorById, +} from '@server/lib/services/cultivatorService'; import { getOrInitCultivationProgress } from '@server/utils/cultivationUtils'; import { getCultivatorDisplaySnapshot } from '@shared/engine/battle-v5/adapters/CultivatorDisplayAdapter'; -import type { ResourceOperation } from '@shared/engine/resource/types'; import { canChallengeDungeonRealm, getMapNode, @@ -127,23 +131,6 @@ function stableJson(value: unknown): string { return JSON.stringify(value ?? null); } -function getDungeonResultGains(result: unknown): ResourceOperation[] { - if (!result || typeof result !== 'object') return []; - const record = result as Record; - if (Array.isArray(record.realGains)) { - return record.realGains as ResourceOperation[]; - } - const callbackData = record.callbackData; - if ( - callbackData && - typeof callbackData === 'object' && - Array.isArray((callbackData as Record).realGains) - ) { - return (callbackData as Record).realGains as ResourceOperation[]; - } - return []; -} - function buildDungeonStateChanges(args: { before: DungeonPlayerStateRow | null; after: DungeonPlayerStateRow | null; @@ -199,24 +186,6 @@ function buildDungeonStateChanges(args: { }); } - const gains = getDungeonResultGains(args.result); - if ( - gains.some((gain) => gain.type === 'material' || gain.type === 'consumable') - ) { - changes.push({ - domain: 'inventory', - eventType: 'inventory.changed', - invalidates: ['inventory'], - }); - } - if (gains.some((gain) => gain.type === 'artifact')) { - changes.push({ - domain: 'products', - eventType: 'products.changed', - invalidates: ['products'], - }); - } - if (args.includeTasks) { changes.push({ domain: 'tasks', @@ -296,13 +265,16 @@ router.post('/start', requireActiveCultivator(), async (c) => { return c.json({ error: '只有秘境节点可以进行副本挑战' }, 400); } - const [tasks, bundle] = await Promise.all([ + const [tasks, profile, loadout, hasRecoveryPill] = await Promise.all([ TaskService.listCultivatorTasks(cultivator.id), - getCultivatorByIdUnsafe(cultivator.id), + getPlayerProfileCultivatorById(user.id, cultivator.id), + getPlayerLoadoutByCultivatorId(cultivator.id), + hasCultivatorRecoveryPill(cultivator.id), ]); - if (!bundle) { + if (!profile) { return c.json({ error: '当前没有活跃角色' }, 404); } + const runtimeCultivator = buildCultivatorRuntime(profile, loadout); const firstDungeonTask = tasks.find( (task) => task.definitionId === 'tutorial_first_dungeon', @@ -314,25 +286,26 @@ router.post('/start', requireActiveCultivator(), async (c) => { : null; if ( selectedNodeRealm && - !canChallengeDungeonRealm(bundle.cultivator.realm, selectedNodeRealm) + !canChallengeDungeonRealm(runtimeCultivator.realm, selectedNodeRealm) ) { return c.json( { - error: `当前境界${bundle.cultivator.realm}不可挑战${selectedNodeRealm}副本,请先提升大境界`, + error: `当前境界${runtimeCultivator.realm}不可挑战${selectedNodeRealm}副本,请先提升大境界`, }, 409, ); } - const display = getCultivatorDisplaySnapshot(bundle.cultivator); + const display = getCultivatorDisplaySnapshot(runtimeCultivator); const readiness = evaluateNoviceReadiness({ - cultivator: bundle.cultivator, + cultivator: runtimeCultivator, selectedNodeRealm, hp: display.resources.hp, mp: display.resources.mp, isFirstDungeonTutorialActive: Boolean( firstDungeonTask && !firstDungeonTask.snapshot.isCompleted, ), + hasRecoveryPill, }); if (readiness.shouldBlock) { diff --git a/src/server/routes/api/enemies.router.ts b/src/server/routes/api/enemies.router.ts index 8c1d3b9f..208b6c09 100644 --- a/src/server/routes/api/enemies.router.ts +++ b/src/server/routes/api/enemies.router.ts @@ -1,6 +1,6 @@ import { requireUser } from '@server/lib/hono/middleware'; import type { AppEnv } from '@server/lib/hono/types'; -import { getCultivatorById } from '@server/lib/services/cultivatorService'; +import { getPlayerProfileCultivatorById } from '@server/lib/services/cultivatorService'; import { Hono } from 'hono'; const router = new Hono(); @@ -17,7 +17,7 @@ router.get('/:id', requireUser(), async (c) => { } try { - const enemy = await getCultivatorById(user.id, id); + const enemy = await getPlayerProfileCultivatorById(user.id, id); if (!enemy) { return c.json({ error: '敌人角色不存在' }, 404); diff --git a/src/server/routes/api/fate-reshape.router.ts b/src/server/routes/api/fate-reshape.router.ts index 28f7f67d..eb22af31 100644 --- a/src/server/routes/api/fate-reshape.router.ts +++ b/src/server/routes/api/fate-reshape.router.ts @@ -62,6 +62,7 @@ router.post('/session', requireActiveCultivator(), async (c) => { userId: user.id, cultivatorId: cultivator.id, source: 'fate_reshape_start', + allowEmpty: true, run: async (tx) => { const session = await FateReshapeService.startSession( user.id, @@ -77,13 +78,7 @@ router.post('/session', requireActiveCultivator(), async (c) => { session, talismanCount, }, - changes: [ - { - domain: 'inventory', - eventType: 'inventory.changed', - invalidates: ['inventory'], - }, - ], + changes: [], }; }, }); diff --git a/src/server/routes/api/manual-draw.router.ts b/src/server/routes/api/manual-draw.router.ts index 842fba89..80819fc9 100644 --- a/src/server/routes/api/manual-draw.router.ts +++ b/src/server/routes/api/manual-draw.router.ts @@ -39,6 +39,7 @@ router.post('/', requireActiveCultivator(), async (c) => { userId: user.id, cultivatorId: cultivator.id, source: 'manual_draw', + allowEmpty: true, run: async (tx) => { const result = await ManualDrawService.draw( user.id, @@ -49,13 +50,7 @@ router.post('/', requireActiveCultivator(), async (c) => { ); return { result, - changes: [ - { - domain: 'inventory', - eventType: 'inventory.changed', - invalidates: ['inventory'], - }, - ], + changes: [], }; }, }); diff --git a/src/server/routes/api/market.router.test.ts b/src/server/routes/api/market.router.test.ts index e6b8440a..046aebb5 100644 --- a/src/server/routes/api/market.router.test.ts +++ b/src/server/routes/api/market.router.test.ts @@ -92,6 +92,7 @@ vi.mock('@server/lib/services/MarketService', () => ({ vi.mock('@server/lib/services/cultivatorService', () => ({ getCultivatorById: getCultivatorByIdMock, + getPlayerProfileCultivatorById: getCultivatorByIdMock, })); vi.mock('@server/lib/services/MarketRecycleService', () => ({ @@ -243,11 +244,6 @@ describe('market router', () => { eventType: 'currency.changed', patch: { currency: { spiritStones: 1688 } }, }), - expect.objectContaining({ - domain: 'products', - eventType: 'products.changed', - invalidates: ['products'], - }), ]), }), }), @@ -281,11 +277,6 @@ describe('market router', () => { eventType: 'currency.changed', invalidates: ['currency'], }), - expect.objectContaining({ - domain: 'inventory', - eventType: 'inventory.changed', - invalidates: ['inventory'], - }), ]), }), }), diff --git a/src/server/routes/api/market.router.ts b/src/server/routes/api/market.router.ts index 8afc9298..951f1f8d 100644 --- a/src/server/routes/api/market.router.ts +++ b/src/server/routes/api/market.router.ts @@ -21,7 +21,7 @@ import { toPlayerStateMutationResponse, type StateChangeDescriptor, } from '@server/lib/services/PlayerStateMutationService'; -import { getCultivatorById } from '@server/lib/services/cultivatorService'; +import { getPlayerProfileCultivatorById } from '@server/lib/services/cultivatorService'; import type { PreHeavenFate } from '@shared/types/cultivator'; import { RealmType } from '@shared/types/constants'; import type { SellConfirmResponse } from '@shared/types/market'; @@ -85,19 +85,13 @@ function marketBuyChanges(): StateChangeDescriptor[] { eventType: 'currency.changed', invalidates: ['currency'], }, - { - domain: 'inventory', - eventType: 'inventory.changed', - invalidates: ['inventory'], - }, ]; } function marketSellChanges( result: SellConfirmResponse, ): StateChangeDescriptor[] { - const inventoryDomain = result.itemType === 'artifact' ? 'products' : 'inventory'; - return [ + const changes: StateChangeDescriptor[] = [ { domain: 'currency', eventType: 'currency.changed', @@ -108,25 +102,27 @@ function marketSellChanges( }, invalidates: ['currency'], }, - { - domain: inventoryDomain, - eventType: - result.itemType === 'artifact' - ? 'products.changed' - : 'inventory.changed', - invalidates: [inventoryDomain], - }, ]; + + if (result.itemType === 'artifact') { + changes.push({ + domain: 'loadout', + eventType: 'loadout.changed', + invalidates: ['loadout'], + }); + } + + return changes; } async function loadMarketFates( cultivator: { id: string; userId: string }, ): Promise { - const fullCultivator = await getCultivatorById( + const profileCultivator = await getPlayerProfileCultivatorById( cultivator.userId, cultivator.id, ); - return fullCultivator?.pre_heaven_fates ?? []; + return profileCultivator?.pre_heaven_fates ?? []; } router.post('/sell', requireActiveCultivator(), async (c) => { diff --git a/src/server/routes/api/products.router.test.ts b/src/server/routes/api/products.router.test.ts index 4dafec62..3cb31308 100644 --- a/src/server/routes/api/products.router.test.ts +++ b/src/server/routes/api/products.router.test.ts @@ -27,9 +27,10 @@ vi.mock('@server/lib/hono/middleware', () => ({ vi.mock('@server/lib/repositories/creationProductRepository', () => ({ countEquippedByType: vi.fn(), + countByType: vi.fn(), equipArtifact: vi.fn(), findById: vi.fn(), - findByTypeAndCultivator: vi.fn(), + findByTypeAndCultivatorPage: vi.fn(), findEquippedArtifacts: vi.fn(), setProductEquipped: vi.fn(), unequipArtifact: vi.fn(), @@ -42,6 +43,9 @@ import productsRouter from './products.router'; const dbMock = db as unknown as Mock; const findByIdMock = creationProductRepository.findById as unknown as Mock; +const countByTypeMock = creationProductRepository.countByType as unknown as Mock; +const findByTypeAndCultivatorPageMock = + creationProductRepository.findByTypeAndCultivatorPage as unknown as Mock; const countEquippedByTypeMock = creationProductRepository.countEquippedByType as unknown as Mock; const setProductEquippedMock = @@ -55,7 +59,7 @@ function createApp() { return new Hono().route('/api/v2/products', productsRouter); } -function mockProductTransaction(eventType = 'products.equipped') { +function mockProductTransaction(eventType = 'loadout.equipped') { const versionRow = { cultivatorId: 'cultivator-1', globalVersion: 1, @@ -63,8 +67,7 @@ function mockProductTransaction(eventType = 'products.equipped') { conditionVersion: 0, progressVersion: 0, currencyVersion: 0, - inventoryVersion: 1, - productsVersion: 1, + loadoutVersion: 1, mailVersion: 0, tasksVersion: 0, updatedAt: new Date('2026-06-10T00:00:00.000Z'), @@ -75,11 +78,11 @@ function mockProductTransaction(eventType = 'products.equipped') { cultivatorId: 'cultivator-1', userId: 'user-1', globalVersion: 1, - domain: 'products', + domain: 'loadout', eventType, patch: {}, - invalidates: ['products'], - source: eventType === 'products.deleted' ? 'product_delete' : 'product_equip', + invalidates: ['loadout'], + source: eventType === 'loadout.deleted' ? 'product_delete' : 'product_equip', requestId: null, createdAt: new Date('2026-06-10T00:00:00.000Z'), }, @@ -109,6 +112,49 @@ describe('products router equip toggle', () => { vi.clearAllMocks(); }); + it('returns products as a paginated list', async () => { + countByTypeMock.mockResolvedValueOnce(1); + findByTypeAndCultivatorPageMock.mockResolvedValueOnce([ + { + id: '44444444-4444-4444-8444-444444444444', + cultivatorId: 'cultivator-1', + productType: 'artifact', + name: '青锋', + element: '金', + productModel: {}, + }, + ]); + + const response = await createApp().request( + '/api/v2/products?type=artifact&page=2&pageSize=10', + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + success: true, + data: { + items: [ + { + id: '44444444-4444-4444-8444-444444444444', + name: '青锋', + }, + ], + pagination: { + page: 2, + pageSize: 10, + total: 1, + totalPages: 1, + hasMore: false, + }, + }, + }); + expect(findByTypeAndCultivatorPageMock).toHaveBeenCalledWith( + 'cultivator-1', + 'artifact', + { page: 2, pageSize: 10 }, + ); + }); + it('rejects enabling a skill when the effective skill limit is full', async () => { findByIdMock.mockResolvedValueOnce({ id: '11111111-1111-4111-8111-111111111111', @@ -162,7 +208,7 @@ describe('products router equip toggle', () => { cultivatorId: 'cultivator-1', globalVersion: 1, domainVersions: { - products: 1, + loadout: 1, profile: 1, }, events: [ @@ -170,10 +216,10 @@ describe('products router equip toggle', () => { id: 31, cultivatorId: 'cultivator-1', globalVersion: 1, - domain: 'products', - eventType: 'products.equipped', + domain: 'loadout', + eventType: 'loadout.equipped', patch: {}, - invalidates: ['products'], + invalidates: ['loadout'], source: 'product_equip', createdAt: '2026-06-10T00:00:00.000Z', }, @@ -218,19 +264,18 @@ describe('products router equip toggle', () => { cultivatorId: 'cultivator-1', globalVersion: 1, domainVersions: { - products: 1, + loadout: 1, profile: 1, - inventory: 1, }, events: [ { id: 31, cultivatorId: 'cultivator-1', globalVersion: 1, - domain: 'products', - eventType: 'products.equipped', + domain: 'loadout', + eventType: 'loadout.equipped', patch: {}, - invalidates: ['products'], + invalidates: ['loadout'], source: 'product_equip', createdAt: '2026-06-10T00:00:00.000Z', }, diff --git a/src/server/routes/api/products.router.ts b/src/server/routes/api/products.router.ts index b4b341bb..336c6824 100644 --- a/src/server/routes/api/products.router.ts +++ b/src/server/routes/api/products.router.ts @@ -56,14 +56,36 @@ router.get('/', requireActiveCultivatorRef(), async (c) => { return c.json({ error: '请指定有效的产物类型 (skill|gongfa|artifact)' }, 400); } - const products = await creationProductRepository.findByTypeAndCultivator( - ref.cultivatorId, - type as CreationProductType, + const page = Math.max(1, parseInt(c.req.query('page') || '1', 10)); + const pageSize = Math.min( + 100, + Math.max(1, parseInt(c.req.query('pageSize') || '20', 10)), ); + const [total, products] = await Promise.all([ + creationProductRepository.countByType( + ref.cultivatorId, + type as CreationProductType, + ), + creationProductRepository.findByTypeAndCultivatorPage( + ref.cultivatorId, + type as CreationProductType, + { page, pageSize }, + ), + ]); + const totalPages = Math.ceil(total / pageSize); return c.json({ success: true, - data: products.map(withRehydratedProductModel), + data: { + items: products.map(withRehydratedProductModel), + pagination: { + page, + pageSize, + total, + totalPages, + hasMore: page < totalPages, + }, + }, }); }); @@ -129,7 +151,7 @@ router.post('/equip', requireActiveCultivator(), async (c) => { productType, equipped, }, - changes: buildProductStateChanges(productType, 'products.equipped'), + changes: buildProductStateChanges(productType, 'loadout.equipped'), }; }, }); @@ -151,7 +173,7 @@ router.post('/equip', requireActiveCultivator(), async (c) => { productType, equipped: false, }, - changes: buildProductStateChanges(productType, 'products.equipped'), + changes: buildProductStateChanges(productType, 'loadout.equipped'), }; }, }); @@ -187,7 +209,7 @@ router.post('/equip', requireActiveCultivator(), async (c) => { productType, equipped: true, }, - changes: buildProductStateChanges(productType, 'products.equipped'), + changes: buildProductStateChanges(productType, 'loadout.equipped'), }; }, }); @@ -235,7 +257,7 @@ router.delete('/:id', requireActiveCultivator(), async (c) => { productId: id, productType, }, - changes: buildProductStateChanges(productType, 'products.deleted'), + changes: buildProductStateChanges(productType, 'loadout.deleted'), }; }, }); @@ -249,25 +271,17 @@ function buildProductStateChanges( ): StateChangeDescriptor[] { const changes: StateChangeDescriptor[] = [ { - domain: 'products', + domain: 'loadout', eventType, - invalidates: ['products'], + invalidates: ['loadout'], }, { domain: 'profile', - eventType: 'profile.products.changed', + eventType: 'profile.loadout.changed', invalidates: ['profile'], }, ]; - if (productType === 'artifact') { - changes.push({ - domain: 'inventory', - eventType: 'inventory.artifacts.changed', - invalidates: ['inventory'], - }); - } - return changes; } diff --git a/src/server/routes/api/rankings.router.test.ts b/src/server/routes/api/rankings.router.test.ts index 049ee2bb..7c17a31b 100644 --- a/src/server/routes/api/rankings.router.test.ts +++ b/src/server/routes/api/rankings.router.test.ts @@ -122,6 +122,7 @@ vi.mock('@server/lib/repositories/worldChatRepository', () => ({ vi.mock('@server/lib/services/cultivatorService', () => ({ getCultivatorByIdUnsafe: getCultivatorByIdUnsafeMock, + getPlayerRuntimeCultivatorByIdUnsafe: getCultivatorByIdUnsafeMock, })); vi.mock('@server/lib/services/simulateBattleV5', () => ({ diff --git a/src/server/routes/api/rankings.router.ts b/src/server/routes/api/rankings.router.ts index 71807670..92438e90 100644 --- a/src/server/routes/api/rankings.router.ts +++ b/src/server/routes/api/rankings.router.ts @@ -32,7 +32,7 @@ import { toPlayerStateMutationResponse, } from '@server/lib/services/PlayerStateMutationService'; import { - getCultivatorByIdUnsafe, + getPlayerRuntimeCultivatorByIdUnsafe, } from '@server/lib/services/cultivatorService'; import { simulateBattleV5 } from '@server/lib/services/simulateBattleV5'; import { TaskService } from '@server/lib/services/TaskService'; @@ -376,7 +376,7 @@ challengeRouter.post('/probe', requireActiveCultivator(), async (c) => { return c.json({ error: '请提供有效的目标角色ID' }, 400); } - const targetRecord = await getCultivatorByIdUnsafe(targetId); + const targetRecord = await getPlayerRuntimeCultivatorByIdUnsafe(targetId); if (!targetRecord) { return c.json({ error: '目标角色不存在或不可查探' }, 404); } @@ -574,8 +574,8 @@ challengeRouter.post('/challenge-battle/v5', requireActiveCultivator(), async (c } lockAcquired = true; - const challengerRecord = await getCultivatorByIdUnsafe(cultivatorId); - const targetRecord = await getCultivatorByIdUnsafe(targetId); + const challengerRecord = await getPlayerRuntimeCultivatorByIdUnsafe(cultivatorId); + const targetRecord = await getPlayerRuntimeCultivatorByIdUnsafe(targetId); if (!challengerRecord || !targetRecord) { return c.json({ error: '角色不存在' }, 404); } diff --git a/src/server/routes/api/reputation-shop.router.test.ts b/src/server/routes/api/reputation-shop.router.test.ts index 01d0e611..33ca1f2f 100644 --- a/src/server/routes/api/reputation-shop.router.test.ts +++ b/src/server/routes/api/reputation-shop.router.test.ts @@ -184,10 +184,6 @@ describe('reputation shop router', () => { eventType: 'currency.reputation.spent', patch: { currency: { reputation: 2200 } }, }), - expect.objectContaining({ - domain: 'inventory', - eventType: 'inventory.reputation_shop.gained', - }), ]), }), }), diff --git a/src/server/routes/api/reputation-shop.router.ts b/src/server/routes/api/reputation-shop.router.ts index a23afdef..87dc5a39 100644 --- a/src/server/routes/api/reputation-shop.router.ts +++ b/src/server/routes/api/reputation-shop.router.ts @@ -38,26 +38,6 @@ function buildBuyChanges(args: { }, ]; - const hasInventoryGain = args.gains.some((gain) => - ['material', 'consumable'].includes(gain.type), - ); - const hasProductGain = args.gains.some((gain) => gain.type === 'artifact'); - - if (hasInventoryGain) { - changes.push({ - domain: 'inventory', - eventType: 'inventory.reputation_shop.gained', - invalidates: ['inventory'], - }); - } - if (hasProductGain) { - changes.push({ - domain: 'products', - eventType: 'products.reputation_shop.gained', - invalidates: ['products'], - }); - } - return changes; } diff --git a/src/server/routes/api/tasks.router.test.ts b/src/server/routes/api/tasks.router.test.ts index f9f02631..e34b5ce5 100644 --- a/src/server/routes/api/tasks.router.test.ts +++ b/src/server/routes/api/tasks.router.test.ts @@ -70,8 +70,7 @@ function mockTaskClaimTransaction(unreadMailCount: number) { conditionVersion: 0, progressVersion: 0, currencyVersion: 0, - inventoryVersion: 0, - productsVersion: 0, + loadoutVersion: 0, mailVersion: 1, tasksVersion: 1, updatedAt: new Date('2026-06-10T00:00:00.000Z'), diff --git a/src/server/routes/player.router.ts b/src/server/routes/player.router.ts index 45590044..86f3fd72 100644 --- a/src/server/routes/player.router.ts +++ b/src/server/routes/player.router.ts @@ -19,7 +19,9 @@ import { import { subscribePlayerStateEvents } from '@server/lib/services/playerStateBroadcaster'; import type { AppEnv } from '@server/lib/hono/types'; import { - getCultivatorsByUserId, + buildCultivatorRuntime, + getPlayerLoadoutByCultivatorId, + getPlayerProfileCultivatorsByUserId, hasDeadCultivator, updateCultivatorGameSettings, } from '@server/lib/services/cultivatorService'; @@ -56,12 +58,20 @@ router.get('/active', requireUser(), async (c) => { return c.json({ success: false, error: '未授权访问' }, 401); } - const cultivators = await getCultivatorsByUserId(user.id); + const cultivators = await getPlayerProfileCultivatorsByUserId(user.id); const hasDead = await hasDeadCultivator(user.id); - const cultivatorViews = cultivators.map((cultivator) => ({ - cultivator, - display: getCultivatorDisplaySnapshot(cultivator), - })); + const cultivatorViews = await Promise.all( + cultivators.map(async (cultivator) => { + const loadout = await getPlayerLoadoutByCultivatorId(cultivator.id!); + return { + cultivator, + display: getCultivatorDisplaySnapshot( + buildCultivatorRuntime(cultivator, loadout), + ), + loadout, + }; + }), + ); const activeCultivator = cultivatorViews[0] ?? null; const activeCultivatorId = activeCultivator?.cultivator.id; diff --git a/src/shared/contracts/bodyCultivation.ts b/src/shared/contracts/bodyCultivation.ts index d4a256a3..820d3d3e 100644 --- a/src/shared/contracts/bodyCultivation.ts +++ b/src/shared/contracts/bodyCultivation.ts @@ -5,19 +5,23 @@ import type { AlchemyPropertyKey, PillFamily } from '@shared/types/consumable'; export type BodyCultivationBreakthroughCostType = 'material' | 'consumable'; -export interface BodyCultivationBreakthroughInventoryRequirement { +export interface BodyCultivationBreakthroughCostRequirement { type: BodyCultivationBreakthroughCostType; name: string; label?: string; quantity: number; - ownedQuantity: number; - met: boolean; materialType?: MaterialType; family?: PillFamily; property?: AlchemyPropertyKey; minQuality?: Quality; } +export interface BodyCultivationBreakthroughInventoryRequirement + extends BodyCultivationBreakthroughCostRequirement { + ownedQuantity: number; + met: boolean; +} + export interface BodyCultivationBreakthroughReadinessData { nextRealm: BodyCultivationRealm | null; canAttempt: boolean; @@ -28,8 +32,44 @@ export interface BodyCultivationBreakthroughReadinessData { label: string; met: boolean; }[]; - inventoryRequirements: BodyCultivationBreakthroughInventoryRequirement[]; + costRequirements: BodyCultivationBreakthroughCostRequirement[]; + inventoryRequirements?: BodyCultivationBreakthroughInventoryRequirement[]; } export type BodyCultivationBreakthroughReadinessResponse = ApiSuccess; + +export interface BodyCultivationBreakthroughSelection { + id: string; + quantity: number; +} + +export interface BodyCultivationBreakthroughRequest { + materialSelections: BodyCultivationBreakthroughSelection[]; + consumableSelections: BodyCultivationBreakthroughSelection[]; +} + +export interface BodyCultivationBreakthroughEligibleData { + requirements: BodyCultivationBreakthroughCostRequirement[]; + materials: Array<{ + id: string; + name: string; + type: MaterialType; + rank: Quality; + quantity: number; + element?: string; + description?: string; + requirementLabel: string; + }>; + consumables: Array<{ + id: string; + name: string; + type: string; + quality: Quality; + quantity: number; + requirementLabel: string; + }>; +} + +export type BodyCultivationBreakthroughEligibleResponse = + ApiSuccess; diff --git a/src/shared/contracts/player.ts b/src/shared/contracts/player.ts index 978921e3..0a025ff4 100644 --- a/src/shared/contracts/player.ts +++ b/src/shared/contracts/player.ts @@ -3,12 +3,24 @@ import type { CultivatorDisplaySnapshot } from '@shared/engine/battle-v5/adapter import type { Cultivator, CultivationProgress, - Inventory, } from '@shared/types/cultivator'; +export type PlayerLoadout = { + skills: Cultivator['skills']; + cultivations: Cultivator['cultivations']; + artifacts: Cultivator['inventory']['artifacts']; + equipped: Cultivator['equipped']; +}; + +export type PlayerProfileCultivator = Omit< + Cultivator, + 'inventory' | 'skills' | 'cultivations' | 'equipped' +>; + export type PlayerCultivatorView = { - cultivator: Cultivator; + cultivator: PlayerProfileCultivator; display: CultivatorDisplaySnapshot; + loadout: PlayerLoadout; }; export type PlayerActiveData = { @@ -29,8 +41,7 @@ export const PLAYER_STATE_DOMAINS = [ 'condition', 'progress', 'currency', - 'inventory', - 'products', + 'loadout', 'mail', 'tasks', ] as const; @@ -72,7 +83,7 @@ export type PlayerStateMutationResponse = { export type PlayerStateSnapshot = { profile: { - cultivator: Cultivator; + cultivator: PlayerProfileCultivator; display: CultivatorDisplaySnapshot; }; condition: Cultivator['condition']; @@ -83,13 +94,7 @@ export type PlayerStateSnapshot = { qi: number; qiLastRefreshedAt: string | null; }; - inventory: Inventory; - products: { - skills: Cultivator['skills']; - cultivations: Cultivator['cultivations']; - artifacts: Cultivator['inventory']['artifacts']; - equipped: Cultivator['equipped']; - }; + loadout: PlayerLoadout; mail: { unreadCount: number; }; diff --git a/src/shared/engine/battle-v5/BattleEngineV5.ts b/src/shared/engine/battle-v5/BattleEngineV5.ts index 03e8367c..c54dd699 100644 --- a/src/shared/engine/battle-v5/BattleEngineV5.ts +++ b/src/shared/engine/battle-v5/BattleEngineV5.ts @@ -293,6 +293,9 @@ export class BattleEngineV5 { .sort((a, b) => { const speedA = a.attributes.getValue(AttributeType.SPEED); const speedB = b.attributes.getValue(AttributeType.SPEED); + if (speedA === speedB) { + return Math.random() - 0.5; + } return speedB - speedA; }); } diff --git a/src/shared/engine/battle-v5/adapters/CultivatorCombatAdapter.test.ts b/src/shared/engine/battle-v5/adapters/CultivatorCombatAdapter.test.ts index 9c34f393..30089ca7 100644 --- a/src/shared/engine/battle-v5/adapters/CultivatorCombatAdapter.test.ts +++ b/src/shared/engine/battle-v5/adapters/CultivatorCombatAdapter.test.ts @@ -1,4 +1,5 @@ import { GameplayTags } from '@shared/engine/shared/tag-domain'; +import { getArtifactWearerRealmFactor } from '@shared/engine/shared/artifactRealmScaling'; import type { Cultivator } from '@shared/types/cultivator'; import { AbilityType, AttributeType, ModifierType } from '../core/types'; import { createCombatUnitFromCultivator } from './CultivatorCombatAdapter'; @@ -73,10 +74,16 @@ function createCultivatorFixture(): Cultivator { describe('CultivatorCombatAdapter', () => { it('applies cross-realm decay only to main panel fixed modifiers', () => { const unit = createCombatUnitFromCultivator(createCultivatorFixture()); + const factor = getArtifactWearerRealmFactor( + '金丹', + '圆满', + '炼气', + '初期', + ); // 金丹圆满->炼气初期 uses inverse anchor/wearer factor. expect(unit.attributes.getValue(AttributeType.ATK)).toBeCloseTo( - 127.84722, + 70 + 100 * factor, 6, ); // CRIT_RATE is functional attribute and should not be decayed diff --git a/src/shared/engine/battle-v5/adapters/CultivatorDisplayAdapter.test.ts b/src/shared/engine/battle-v5/adapters/CultivatorDisplayAdapter.test.ts index 1af5689f..5a3e9cdb 100644 --- a/src/shared/engine/battle-v5/adapters/CultivatorDisplayAdapter.test.ts +++ b/src/shared/engine/battle-v5/adapters/CultivatorDisplayAdapter.test.ts @@ -1,4 +1,5 @@ import { AttributeType, ModifierType } from '../core/types'; +import { getArtifactWearerRealmFactor } from '@shared/engine/shared/artifactRealmScaling'; import { createDisplayUnitFromCultivator, getCultivatorDisplayAttributes, @@ -219,9 +220,17 @@ describe('CultivatorDisplayAdapter', () => { }; const unit = createDisplayUnitFromCultivator(cultivator); + const factor = getArtifactWearerRealmFactor( + '金丹', + '圆满', + '炼气', + '初期', + ); // 金丹圆满->炼气初期 uses inverse anchor/wearer factor. - expect(unit.attributes.getValue(AttributeType.SPIRIT)).toBe(67); + expect(unit.attributes.getValue(AttributeType.SPIRIT)).toBe( + Math.floor(10 + 100 * factor), + ); // 功能属性不衰减 expect(unit.attributes.getValue(AttributeType.CRIT_RATE)).toBeCloseTo( 0.144884, diff --git a/src/shared/engine/battle-v5/tests/integration/ActionExecutionSystemIntegration.test.ts b/src/shared/engine/battle-v5/tests/integration/ActionExecutionSystemIntegration.test.ts index 9ac795a0..222be3c0 100644 --- a/src/shared/engine/battle-v5/tests/integration/ActionExecutionSystemIntegration.test.ts +++ b/src/shared/engine/battle-v5/tests/integration/ActionExecutionSystemIntegration.test.ts @@ -22,6 +22,7 @@ import { import { DamageEffect } from '../../effects/DamageEffect'; import { ReflectEffect } from '../../effects/ReflectEffect'; import { AbilityFactory } from '../../factories/AbilityFactory'; +import { BattleEngineV5 } from '../../BattleEngineV5'; import { ActionExecutionSystem } from '../../systems/ActionExecutionSystem'; import { DamageSystem } from '../../systems/DamageSystem'; import { LogAggregator } from '../../systems/log/LogAggregator'; @@ -99,6 +100,47 @@ describe('ActionExecutionSystem integration', () => { }); }); +describe('BattleEngineV5 turn order', () => { + beforeEach(() => { + EventBus.instance.reset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + EventBus.instance.reset(); + }); + + it('randomizes turn order when units have equal speed', () => { + const randomSpy = vi.spyOn(Math, 'random'); + randomSpy + .mockReturnValueOnce(0) + .mockReturnValueOnce(0.75) + .mockReturnValue(0.5); + + const player = new Unit('player', '玩家', { + [AttributeType.VITALITY]: 1, + [AttributeType.SPEED]: 10, + }); + const opponent = new Unit('opponent', '对手', { + [AttributeType.VITALITY]: 1, + [AttributeType.SPEED]: 10, + }); + player.setHp(1); + opponent.setHp(1); + + const engine = new BattleEngineV5(player, opponent); + + try { + const result = engine.execute(); + + expect(result.winner).toBe('opponent'); + expect(result.turns).toBe(1); + } finally { + engine.destroy(); + } + }); +}); + describe('DamageSystem hit check', () => { beforeEach(() => { EventBus.instance.reset(); diff --git a/src/shared/engine/creation-v2/affixes/definitions/artifactAffixes.ts b/src/shared/engine/creation-v2/affixes/definitions/artifactAffixes.ts index 6112f8de..ebfad6c8 100644 --- a/src/shared/engine/creation-v2/affixes/definitions/artifactAffixes.ts +++ b/src/shared/engine/creation-v2/affixes/definitions/artifactAffixes.ts @@ -48,12 +48,12 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ { attrType: AttributeType.ATK, modType: ModifierType.FIXED, - value: { base: 40, scale: 'quality', coefficient: 12 }, + value: { base: 6, scale: 'quality', coefficient: 2.5 }, }, { attrType: AttributeType.MAGIC_ATK, modType: ModifierType.FIXED, - value: { base: 40, scale: 'quality', coefficient: 12 }, + value: { base: 6, scale: 'quality', coefficient: 2.5 }, }, ], }, @@ -78,12 +78,12 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ { attrType: AttributeType.DEF, modType: ModifierType.FIXED, - value: { base: 32, scale: 'quality', coefficient: 10 }, + value: { base: 7, scale: 'quality', coefficient: 3 }, }, { attrType: AttributeType.MAGIC_DEF, modType: ModifierType.FIXED, - value: { base: 32, scale: 'quality', coefficient: 10 }, + value: { base: 7, scale: 'quality', coefficient: 3 }, }, ], }, @@ -189,7 +189,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ params: { attrType: AttributeType.ATK, modType: ModifierType.FIXED, - value: { base: 18, scale: 'quality', coefficient: 8 }, + value: { base: 3, scale: 'quality', coefficient: 1.2 }, }, }, }, @@ -215,7 +215,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ params: { attrType: AttributeType.MAGIC_ATK, modType: ModifierType.FIXED, - value: { base: 18, scale: 'quality', coefficient: 8 }, + value: { base: 3, scale: 'quality', coefficient: 1.2 }, }, }, }, @@ -241,7 +241,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ params: { attrType: AttributeType.DEF, modType: ModifierType.FIXED, - value: { base: 12, scale: 'quality', coefficient: 6 }, + value: { base: 3, scale: 'quality', coefficient: 1.5 }, }, }, }, @@ -267,7 +267,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ params: { attrType: AttributeType.MAGIC_DEF, modType: ModifierType.FIXED, - value: { base: 12, scale: 'quality', coefficient: 6 }, + value: { base: 3, scale: 'quality', coefficient: 1.5 }, }, }, }, @@ -450,7 +450,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ params: { attrType: AttributeType.SPIRIT, modType: ModifierType.FIXED, - value: { base: 7, scale: 'quality', coefficient: 3.5 }, + value: { base: 2, scale: 'quality', coefficient: 0.8 }, }, }, }, @@ -476,7 +476,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ params: { attrType: AttributeType.VITALITY, modType: ModifierType.FIXED, - value: { base: 7, scale: 'quality', coefficient: 3.5 }, + value: { base: 2, scale: 'quality', coefficient: 0.8 }, }, }, }, @@ -502,7 +502,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ params: { attrType: AttributeType.WISDOM, modType: ModifierType.FIXED, - value: { base: 7, scale: 'quality', coefficient: 3.5 }, + value: { base: 2, scale: 'quality', coefficient: 0.8 }, }, }, }, @@ -528,7 +528,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ params: { attrType: AttributeType.WILLPOWER, modType: ModifierType.FIXED, - value: { base: 7, scale: 'quality', coefficient: 3.5 }, + value: { base: 2, scale: 'quality', coefficient: 0.8 }, }, }, }, @@ -554,7 +554,7 @@ export const ARTIFACT_AFFIXES: AffixDefinition[] = [ params: { attrType: AttributeType.SPEED, modType: ModifierType.FIXED, - value: { base: 7, scale: 'quality', coefficient: 3.5 }, + value: { base: 2, scale: 'quality', coefficient: 0.8 }, }, }, }, diff --git a/src/shared/engine/creation-v2/affixes/definitions/skillAffixes.ts b/src/shared/engine/creation-v2/affixes/definitions/skillAffixes.ts index 39b374a0..76f35133 100644 --- a/src/shared/engine/creation-v2/affixes/definitions/skillAffixes.ts +++ b/src/shared/engine/creation-v2/affixes/definitions/skillAffixes.ts @@ -21,7 +21,10 @@ import { import { DamageType } from '@shared/engine/battle-v5/core/types'; import { EXCLUSIVE_GROUP } from '../exclusiveGroups'; import type { AffixDefinition } from '../types'; -import { qualityScaledCoefficient } from './utils'; +import { + qualityScaledCoefficient, + qualityScaledDamageCoefficient, +} from './utils'; const DOT_TICK_LISTENER = { eventType: GameplayTags.EVENT.ACTION_PRE, @@ -61,9 +64,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [ type: 'damage', params: { value: { - base: { base: 80, scale: 'quality', coefficient: 14 }, + base: { base: 35, scale: 'quality', coefficient: 6 }, attribute: AttributeType.MAGIC_ATK, - coefficient: qualityScaledCoefficient(1.0), + coefficient: qualityScaledDamageCoefficient(1.0), }, }, }, @@ -97,9 +100,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [ type: 'damage', params: { value: { - base: { base: 86, scale: 'quality', coefficient: 15 }, + base: { base: 38, scale: 'quality', coefficient: 7 }, attribute: AttributeType.MAGIC_ATK, - coefficient: qualityScaledCoefficient(1.2), + coefficient: qualityScaledDamageCoefficient(1.08), }, }, }, @@ -131,9 +134,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [ type: 'damage', params: { value: { - base: { base: 78, scale: 'quality', coefficient: 14 }, + base: { base: 35, scale: 'quality', coefficient: 6 }, attribute: AttributeType.MAGIC_ATK, - coefficient: qualityScaledCoefficient(1.15), + coefficient: qualityScaledDamageCoefficient(1.02), }, }, }, @@ -165,9 +168,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [ type: 'damage', params: { value: { - base: { base: 92, scale: 'quality', coefficient: 15 }, + base: { base: 40, scale: 'quality', coefficient: 7 }, attribute: AttributeType.MAGIC_ATK, - coefficient: qualityScaledCoefficient(0.94), + coefficient: qualityScaledDamageCoefficient(1.04), }, }, }, @@ -199,9 +202,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [ type: 'damage', params: { value: { - base: { base: 60, scale: 'quality', coefficient: 12 }, + base: { base: 30, scale: 'quality', coefficient: 5 }, attribute: AttributeType.ATK, - coefficient: qualityScaledCoefficient(1.12), + coefficient: qualityScaledDamageCoefficient(1.0), }, }, }, @@ -233,9 +236,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [ type: 'damage', params: { value: { - base: { base: 65, scale: 'quality', coefficient: 13 }, + base: { base: 32, scale: 'quality', coefficient: 6 }, attribute: AttributeType.ATK, - coefficient: qualityScaledCoefficient(1.18), + coefficient: qualityScaledDamageCoefficient(1.06), }, }, }, @@ -267,9 +270,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [ type: 'damage', params: { value: { - base: { base: 74, scale: 'quality', coefficient: 13 }, + base: { base: 34, scale: 'quality', coefficient: 6 }, attribute: AttributeType.MAGIC_ATK, - coefficient: qualityScaledCoefficient(0.95), + coefficient: qualityScaledDamageCoefficient(0.98), }, }, }, @@ -301,9 +304,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [ type: 'damage', params: { value: { - base: { base: 70, scale: 'quality', coefficient: 12 }, + base: { base: 32, scale: 'quality', coefficient: 5 }, attribute: AttributeType.MAGIC_ATK, - coefficient: qualityScaledCoefficient(1.0), + coefficient: qualityScaledDamageCoefficient(0.96), }, }, }, @@ -335,9 +338,9 @@ export const SKILL_AFFIXES: AffixDefinition[] = [ type: 'damage', params: { value: { - base: { base: 82, scale: 'quality', coefficient: 14 }, + base: { base: 36, scale: 'quality', coefficient: 6 }, attribute: AttributeType.ATK, - coefficient: qualityScaledCoefficient(1.0), + coefficient: qualityScaledDamageCoefficient(0.98), }, }, }, diff --git a/src/shared/engine/creation-v2/affixes/definitions/utils.ts b/src/shared/engine/creation-v2/affixes/definitions/utils.ts index a3f4df97..16b19688 100644 --- a/src/shared/engine/creation-v2/affixes/definitions/utils.ts +++ b/src/shared/engine/creation-v2/affixes/definitions/utils.ts @@ -1,6 +1,7 @@ import { ScalableParam } from "../types"; const QUALITY_COEFFICIENT_STEP = 0.125; +const DAMAGE_QUALITY_COEFFICIENT_STEP = 0.07; export function qualityScaledCoefficient(base: number): ScalableParam { return { @@ -8,4 +9,17 @@ export function qualityScaledCoefficient(base: number): ScalableParam { scale: 'quality', coefficient: base * QUALITY_COEFFICIENT_STEP, }; -} \ No newline at end of file +} + +/** + * 主动伤害技能专用的品质系数曲线。 + * 伤害词条仍随品质成长,但高品质不再沿用通用 12.5%/阶的斜率, + * 避免新版线性属性池下单段技能过早压到半血以上。 + */ +export function qualityScaledDamageCoefficient(base: number): ScalableParam { + return { + base, + scale: 'quality', + coefficient: base * DAMAGE_QUALITY_COEFFICIENT_STEP, + }; +} diff --git a/src/shared/engine/creation-v2/tests/balancing/CombatNumberBalance.test.ts b/src/shared/engine/creation-v2/tests/balancing/CombatNumberBalance.test.ts new file mode 100644 index 00000000..be87bfe8 --- /dev/null +++ b/src/shared/engine/creation-v2/tests/balancing/CombatNumberBalance.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { AttributeType } from '@shared/engine/battle-v5/core/types'; +import { AttributeSet } from '@shared/engine/battle-v5/units/AttributeSet'; +import { composeProductFromAffixIds } from '@shared/engine/creation-v2/composeProductFromAffixIds'; +import { projectAbilityConfig } from '@shared/engine/creation-v2/models/AbilityProjection'; +import { + getRealmStageNaturalAttributeValue, + getRealmStageUnallocatedAttributeBudget, +} from '@shared/config/realmProgression'; +import type { DamageParams } from '@shared/engine/battle-v5/core/configs'; +import type { Quality, RealmStage, RealmType } from '@shared/types/constants'; + +const QUALITY_CASES = [ + { quality: '凡品', minSkillHpRatio: 0.14, maxSkillHpRatio: 0.2, minWeaponGain: 0.1, maxWeaponGain: 0.22 }, + { quality: '真品', minSkillHpRatio: 0.2, maxSkillHpRatio: 0.28, minWeaponGain: 0.25, maxWeaponGain: 0.45 }, + { quality: '神品', minSkillHpRatio: 0.28, maxSkillHpRatio: 0.4, minWeaponGain: 0.45, maxWeaponGain: 0.72 }, +] as const satisfies ReadonlyArray<{ + quality: Quality; + minSkillHpRatio: number; + maxSkillHpRatio: number; + minWeaponGain: number; + maxWeaponGain: number; +}>; + +const REALM_CASES = [ + { realm: '炼气', stage: '初期' }, + { realm: '金丹', stage: '初期' }, + { realm: '渡劫', stage: '圆满' }, +] as const satisfies ReadonlyArray<{ realm: RealmType; stage: RealmStage }>; + +function createAverageAttributeSet(realm: RealmType, stage: RealmStage): AttributeSet { + const natural = getRealmStageNaturalAttributeValue(realm, stage); + const freePerAttribute = + getRealmStageUnallocatedAttributeBudget(realm, stage) / 5; + const value = natural + freePerAttribute; + + return new AttributeSet({ + [AttributeType.VITALITY]: value, + [AttributeType.SPIRIT]: value, + [AttributeType.WISDOM]: value, + [AttributeType.SPEED]: value, + [AttributeType.WILLPOWER]: value, + }); +} + +function expectedDirectDamage(args: { + preMitigationDamage: number; + defender: AttributeSet; + attacker: AttributeSet; + defenseType: AttributeType.DEF | AttributeType.MAGIC_DEF; +}): number { + const { preMitigationDamage, defender, attacker, defenseType } = args; + const afterDefense = Math.max( + preMitigationDamage * 0.1, + preMitigationDamage - defender.getValue(defenseType), + ); + const critRate = attacker.getValue(AttributeType.CRIT_RATE); + const critDamage = attacker.getValue(AttributeType.CRIT_DAMAGE_MULT); + const critExpectation = 1 + critRate * (critDamage - 1); + + // 同构筑命中期望通常落入 97% 保底命中;这里按系统 dodge 下限建模。 + return afterDefense * critExpectation * 0.97; +} + +function calculateSkillDamageHpRatio( + realm: RealmType, + stage: RealmStage, + quality: Quality, +): number { + const attrs = createAverageAttributeSet(realm, stage); + const skill = composeProductFromAffixIds({ + productType: 'skill', + element: '火', + name: `测试火伤-${quality}`, + affixIds: ['skill-core-damage-fire'], + requestedQuality: quality, + }); + const ability = projectAbilityConfig(skill); + const damageEffect = ability.effects?.find((effect) => effect.type === 'damage'); + expect(damageEffect).toBeDefined(); + + const value = (damageEffect!.params as DamageParams).value; + const preMitigationDamage = + (value.base ?? 0) + + attrs.getValue(value.attribute ?? AttributeType.MAGIC_ATK) * + (value.coefficient ?? 1) + + attrs.getMaxHp() * (value.targetMaxHpRatio ?? 0); + + return expectedDirectDamage({ + preMitigationDamage: Math.round(preMitigationDamage), + defender: attrs, + attacker: attrs, + defenseType: AttributeType.MAGIC_DEF, + }) / attrs.getMaxHp(); +} + +function calculateWeaponCoreBasicAttackGain( + realm: RealmType, + stage: RealmStage, + quality: Quality, +): number { + const attrs = createAverageAttributeSet(realm, stage); + const artifact = composeProductFromAffixIds({ + productType: 'artifact', + element: '金', + name: `测试武器-${quality}`, + affixIds: ['artifact-panel-weapon-dual-atk'], + requestedSlot: 'weapon', + requestedQuality: quality, + realm, + realmStage: stage, + }); + const atkModifier = + artifact.battleProjection.modifiers?.find( + (modifier) => modifier.attrType === AttributeType.ATK, + )?.value ?? 0; + const baseDamage = expectedDirectDamage({ + preMitigationDamage: attrs.getValue(AttributeType.ATK) * 0.8, + defender: attrs, + attacker: attrs, + defenseType: AttributeType.DEF, + }); + const withWeaponDamage = expectedDirectDamage({ + preMitigationDamage: (attrs.getValue(AttributeType.ATK) + atkModifier) * 0.8, + defender: attrs, + attacker: attrs, + defenseType: AttributeType.DEF, + }); + + return withWeaponDamage / baseDamage - 1; +} + +describe('combat number balance', () => { + it('keeps core skill single-hit damage inside same-realm HP targets', () => { + for (const realmCase of REALM_CASES) { + for (const qualityCase of QUALITY_CASES) { + const hpRatio = calculateSkillDamageHpRatio( + realmCase.realm, + realmCase.stage, + qualityCase.quality, + ); + + expect(hpRatio).toBeGreaterThanOrEqual(qualityCase.minSkillHpRatio); + expect(hpRatio).toBeLessThanOrEqual(qualityCase.maxSkillHpRatio); + } + } + }); + + it('keeps same-realm weapon core fixed attack gains inside target bands', () => { + for (const realmCase of REALM_CASES) { + for (const qualityCase of QUALITY_CASES) { + const gain = calculateWeaponCoreBasicAttackGain( + realmCase.realm, + realmCase.stage, + qualityCase.quality, + ); + + expect(gain).toBeGreaterThanOrEqual(qualityCase.minWeaponGain); + expect(gain).toBeLessThanOrEqual(qualityCase.maxWeaponGain); + } + } + }); +}); diff --git a/src/shared/engine/creation-v2/tests/persistence/ProductRehydrator.test.ts b/src/shared/engine/creation-v2/tests/persistence/ProductRehydrator.test.ts index 92ba0579..1944f327 100644 --- a/src/shared/engine/creation-v2/tests/persistence/ProductRehydrator.test.ts +++ b/src/shared/engine/creation-v2/tests/persistence/ProductRehydrator.test.ts @@ -170,7 +170,7 @@ describe('ProductRehydrator', () => { if (rehydrated.productType !== 'artifact') return; expect(rehydrated.metadata).toBeUndefined(); expect(rehydrated.battleProjection.modifiers?.[0]?.value).toBeCloseTo( - 112, + 21, 6, ); }); diff --git a/src/shared/engine/shared/artifactRealmScaling.ts b/src/shared/engine/shared/artifactRealmScaling.ts index 9591ebdf..6f10e84d 100644 --- a/src/shared/engine/shared/artifactRealmScaling.ts +++ b/src/shared/engine/shared/artifactRealmScaling.ts @@ -5,7 +5,7 @@ import { import type { RealmStage, RealmType } from '@shared/types/constants'; import { AttributeType, ModifierType } from '@shared/engine/battle-v5/core/types'; -export const ARTIFACT_REALM_SCALING_EXPONENT = 0.3; +export const ARTIFACT_REALM_SCALING_EXPONENT = 0.7; export const DEFAULT_ARTIFACT_REALM_SCALING_STAGE: RealmStage = '圆满'; const ARTIFACT_MAIN_PANEL_ATTRS = new Set([ diff --git a/src/shared/lib/noviceGuidance.ts b/src/shared/lib/noviceGuidance.ts index 0d5a2c2d..d52e6e0c 100644 --- a/src/shared/lib/noviceGuidance.ts +++ b/src/shared/lib/noviceGuidance.ts @@ -26,6 +26,7 @@ export interface NoviceDungeonReadinessInput { hp: NoviceReadinessResource; mp: NoviceReadinessResource; isFirstDungeonTutorialActive: boolean; + hasRecoveryPill?: boolean | null; } export interface NoviceDungeonReadiness { @@ -40,7 +41,7 @@ export interface NoviceDungeonReadiness { unequippedNoviceEquipmentNames: string[]; hasFullNoviceEquipment: boolean; hasEquippedFullNoviceEquipment: boolean; - hasRecoveryPill: boolean; + hasRecoveryPill: boolean | null; } function toPercent(resource: NoviceReadinessResource): number { @@ -115,7 +116,10 @@ export function evaluateNoviceReadiness( cultivator.equipped.accessory === noviceGuardArtifact.id, ); const noviceEquipmentState = getNoviceEquipmentState(cultivator); - const hasRecoveryPill = cultivator.inventory.consumables.some(isRecoveryPill); + const hasRecoveryPill = + input.hasRecoveryPill !== undefined + ? input.hasRecoveryPill + : cultivator.inventory.consumables.some(isRecoveryPill); const reasons: string[] = []; const hints: string[] = []; @@ -166,7 +170,7 @@ export function evaluateNoviceReadiness( ); } - if (!hasRecoveryPill) { + if (hasRecoveryPill === false) { hints.push('若还没有恢复丹,可先在炼丹房完成第一炉疗伤丹。'); }