From b2a021bf8acd74d68e3c22e776ec84161a5af9a5 Mon Sep 17 00:00:00 2001 From: bridge Date: Wed, 7 Jan 2026 22:43:26 +0800 Subject: [PATCH] rename item -> material & refactor buying action --- EN_README.md | 4 +- README.md | 4 +- src/classes/action/buy.py | 78 +++----- src/classes/action/cast.py | 21 +- src/classes/action/harvest.py | 12 +- src/classes/action/hunt.py | 12 +- src/classes/action/mine.py | 10 +- src/classes/action/refine.py | 33 ++-- src/classes/action/sell.py | 22 +-- src/classes/animal.py | 26 +-- src/classes/avatar/core.py | 4 +- src/classes/avatar/info_presenter.py | 20 +- src/classes/avatar/inventory_mixin.py | 180 +++++++++++++++--- src/classes/effect/__init__.py | 5 +- src/classes/effect/consts.py | 25 ++- src/classes/effect/desc.py | 7 +- src/classes/item.py | 56 ------ src/classes/lode.py | 29 ++- src/classes/material.py | 57 ++++++ src/classes/plant.py | 26 +-- src/classes/prices.py | 22 +-- src/classes/single_choice.py | 74 ++++--- src/sim/load/avatar_load_mixin.py | 16 +- src/sim/load/load_game.py | 9 +- src/sim/save/avatar_save_mixin.py | 14 +- src/utils/gather.py | 14 +- src/utils/resolution.py | 12 +- static/game_configs/animal.csv | 2 +- static/game_configs/celestial_phenomenon.csv | 2 +- static/game_configs/lode.csv | 2 +- .../game_configs/{item.csv => material.csv} | 0 static/game_configs/persona.csv | 6 +- static/game_configs/plant.csv | 2 +- static/game_configs/sect.csv | 2 +- tests/test_buy_action.py | 142 ++++++++------ tests/test_circulation.py | 2 +- tests/test_gather.py | 56 +++--- tests/test_normalize_resolution.py | 16 +- tests/test_prices.py | 74 +++---- tests/test_sell_action.py | 141 ++++---------- tests/test_single_choice.py | 124 ++++++++++++ .../game/panels/info/AvatarDetail.vue | 8 +- web/src/types/core.ts | 4 +- 43 files changed, 795 insertions(+), 580 deletions(-) delete mode 100644 src/classes/item.py create mode 100644 src/classes/material.py rename static/game_configs/{item.csv => material.csv} (100%) create mode 100644 tests/test_single_choice.py diff --git a/EN_README.md b/EN_README.md index 292ec41..d87882a 100644 --- a/EN_README.md +++ b/EN_README.md @@ -133,7 +133,7 @@ You can also join the QQ group for discussion: 1071821688. Verification answer i - [ ] Character compatibility - [ ] Life Skills - ✅ Forging - - [ ] Alchemy + - ✅ Refine - [ ] Planting - [ ] Taming - [ ] Evolving skills @@ -271,7 +271,7 @@ You can also join the QQ group for discussion: 1071821688. Verification answer i ## Contributors - Aku, for world design & discussion -- [@xzhseh](https://github.com/xzhseh), contributed part of the frontend code +- [@xzhseh](https://github.com/xzhseh), contributed code ## Acknowledgments - Referenced some UI elements from ailifeengine \ No newline at end of file diff --git a/README.md b/README.md index c618e6e..4f4e1b3 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ - [ ] 角色间相性 - [ ] 生活技能 - ✅ 铸造 - - [ ] 丹药 + - ✅ 炼丹 - [ ] 种植 - [ ] 饲养 - [ ] 技能可进化 @@ -270,7 +270,7 @@ ## 贡献者 * Aku, 世界观\玩法设计与讨论 -* [@xzhseh](https://github.com/xzhseh), 贡献部分前端代码 +* [@xzhseh](https://github.com/xzhseh), 贡献代码 ## 致谢 - 参考了ai life engine部分ui \ No newline at end of file diff --git a/src/classes/action/buy.py b/src/classes/action/buy.py index 3ebf002..c00b8a7 100644 --- a/src/classes/action/buy.py +++ b/src/classes/action/buy.py @@ -1,6 +1,5 @@ from __future__ import annotations -import copy from typing import TYPE_CHECKING, Tuple, Any from src.classes.action import InstantAction @@ -11,7 +10,7 @@ from src.classes.prices import prices from src.classes.cultivation import Realm from src.classes.weapon import Weapon from src.classes.auxiliary import Auxiliary -from src.classes.item import Item +from src.classes.material import Material from src.utils.resolution import resolve_query if TYPE_CHECKING: @@ -24,7 +23,7 @@ class Buy(InstantAction): 如果是丹药:购买后强制立即服用。 如果是其他物品:购买后放入背包。 - 如果是装备(兵器/法宝):购买后直接装备(替换原有装备)。 + 如果是装备(兵器/法宝):购买后直接装备(替换原有装备,旧装备折价售出)。 """ ACTION_NAME = "购买" @@ -39,77 +38,46 @@ class Buy(InstantAction): if not isinstance(region, CityRegion): return False, "仅能在城市区域执行" - res = resolve_query(target_name, expected_types=[Elixir, Weapon, Auxiliary, Item]) + res = resolve_query(target_name, expected_types=[Elixir, Weapon, Auxiliary, Material]) if not res.is_valid: return False, f"未知物品: {target_name}" - obj = res.obj - - # 检查价格 - price = prices.get_buying_price(obj, self.avatar) - if self.avatar.magic_stone < price: - return False, f"灵石不足 (需要 {price})" - - # 丹药特殊限制 - if isinstance(obj, Elixir): - elixir: Elixir = obj - - # 必须是练气期丹药 - if elixir.realm != Realm.Qi_Refinement: - return False, "当前仅开放练气期丹药购买" - - # 境界限制 - if elixir.realm > self.avatar.cultivation_progress.realm: - return False, f"境界不足,无法承受药力 ({elixir.realm.value})" - - # 耐药性/生效中检查 - for consumed in self.avatar.elixirs: - if consumed.elixir.id == elixir.id: - if not consumed.is_completely_expired(int(self.world.month_stamp)): - return False, "药效尚存,无法重复服用" - - return True, "" + # 核心逻辑委托给 Avatar + return self.avatar.can_buy_item(res.obj) def _execute(self, target_name: str) -> None: - res = resolve_query(target_name, expected_types=[Elixir, Weapon, Auxiliary, Item]) + res = resolve_query(target_name, expected_types=[Elixir, Weapon, Auxiliary, Material]) if not res.is_valid: return - obj = res.obj - price = prices.get_buying_price(obj, self.avatar) - self.avatar.magic_stone -= price - - # 交付 - if isinstance(obj, Elixir): - self.avatar.consume_elixir(obj) - elif isinstance(obj, Item): - self.avatar.add_item(obj) - elif isinstance(obj, Weapon): - # 购买装备需要深拷贝,因为装备有独立状态 - new_weapon = copy.deepcopy(obj) - self.avatar.change_weapon(new_weapon) - elif isinstance(obj, Auxiliary): - # 购买装备需要深拷贝 - new_auxiliary = copy.deepcopy(obj) - self.avatar.change_auxiliary(new_auxiliary) + # 真正执行购买 (含扣款、服用/装备/卖旧) + self.avatar.buy_item(res.obj) def start(self, target_name: str) -> Event: - res = resolve_query(target_name, expected_types=[Elixir, Weapon, Auxiliary, Item]) + res = resolve_query(target_name, expected_types=[Elixir, Weapon, Auxiliary, Material]) obj = res.obj display_name = res.name + # 预先获取一些信息用于生成文本 (不修改状态) + price = prices.get_buying_price(obj, self.avatar) + + # 构造描述 + action_desc = "购买了" + suffix = "" + if isinstance(obj, Elixir): action_desc = "购买并服用了" elif isinstance(obj, (Weapon, Auxiliary)): action_desc = "购买并装备了" - else: - action_desc = "购买了" - - price = prices.get_buying_price(obj, self.avatar) if obj else 0 - + # 预测是否会有卖旧行为,生成对应描述 + if isinstance(obj, Weapon) and self.avatar.weapon: + suffix = f" (并将原有的{self.avatar.weapon.name}折价售出)" + elif isinstance(obj, Auxiliary) and self.avatar.auxiliary: + suffix = f" (并将原有的{self.avatar.auxiliary.name}折价售出)" + return Event( self.world.month_stamp, - f"{self.avatar.name} 在城镇花费 {price} 灵石{action_desc} {display_name}", + f"{self.avatar.name} 在城镇花费 {price} 灵石{action_desc} {display_name}{suffix}", related_avatars=[self.avatar.id] ) diff --git a/src/classes/action/cast.py b/src/classes/action/cast.py index 1183e4e..b33356b 100644 --- a/src/classes/action/cast.py +++ b/src/classes/action/cast.py @@ -6,8 +6,7 @@ from typing import Optional, TYPE_CHECKING, List from src.classes.action import TimedAction from src.classes.cultivation import Realm from src.classes.event import Event -from src.classes.item import Item -from src.classes.lode import ORE_ITEM_IDS +from src.classes.material import Material from src.classes.weapon import get_random_weapon_by_realm from src.classes.auxiliary import get_random_auxiliary_by_realm from src.classes.single_choice import handle_item_exchange @@ -49,12 +48,10 @@ class Cast(TimedAction): def _count_materials(self, realm: Realm) -> int: """ 统计符合条件的材料数量。 - 注意:统计所有 Item 类的直接实例,不限于矿石。 """ count = 0 - for item, qty in self.avatar.items.items(): - # 只要是 Item 实例且境界符合即可 - if type(item).__name__ == "Item" and item.realm == realm: + for material, qty in self.avatar.materials.items(): + if material.realm == realm: count += qty return count @@ -85,19 +82,19 @@ class Cast(TimedAction): # 扣除材料逻辑 to_deduct = cost - items_to_modify = [] + materials_to_modify = [] # 再次遍历寻找材料进行扣除 - for item, qty in self.avatar.items.items(): + for material, qty in self.avatar.materials.items(): if to_deduct <= 0: break - if type(item).__name__ == "Item" and item.realm == self.target_realm: + if material.realm == self.target_realm: take = min(qty, to_deduct) - items_to_modify.append((item, take)) + materials_to_modify.append((material, take)) to_deduct -= take - for item, take in items_to_modify: - self.avatar.remove_item(item, take) + for material, take in materials_to_modify: + self.avatar.remove_material(material, take) realm_val = self.target_realm.value if self.target_realm else target_realm return Event( diff --git a/src/classes/action/harvest.py b/src/classes/action/harvest.py index 3c6f38f..9b7af45 100644 --- a/src/classes/action/harvest.py +++ b/src/classes/action/harvest.py @@ -8,7 +8,7 @@ from src.utils.gather import execute_gather, check_can_start_gather class Harvest(TimedAction): """ 采集动作,在有植物的区域进行采集,持续6个月 - 可以获得植物对应的物品 + 可以获得植物对应的材料 """ ACTION_NAME = "采集" @@ -21,15 +21,15 @@ class Harvest(TimedAction): def __init__(self, avatar, world): super().__init__(avatar, world) - self.gained_items: dict[str, int] = {} + self.gained_materials: dict[str, int] = {} def _execute(self) -> None: """ 执行采集动作 """ - gained = execute_gather(self.avatar, "plants", "extra_harvest_items") + gained = execute_gather(self.avatar, "plants", "extra_harvest_materials") for name, count in gained.items(): - self.gained_items[name] = self.gained_items.get(name, 0) + count + self.gained_materials[name] = self.gained_materials.get(name, 0) + count def can_start(self) -> tuple[bool, str]: return check_can_start_gather(self.avatar, "plants", "植物") @@ -41,9 +41,9 @@ class Harvest(TimedAction): async def finish(self) -> list[Event]: # 必定有产出 - items_desc = "、".join([f"{k}x{v}" for k, v in self.gained_items.items()]) + materials_desc = "、".join([f"{k}x{v}" for k, v in self.gained_materials.items()]) return [Event( self.world.month_stamp, - f"{self.avatar.name} 结束了采集,获得了:{items_desc}", + f"{self.avatar.name} 结束了采集,获得了:{materials_desc}", related_avatars=[self.avatar.id] )] diff --git a/src/classes/action/hunt.py b/src/classes/action/hunt.py index bba9434..da27f9a 100644 --- a/src/classes/action/hunt.py +++ b/src/classes/action/hunt.py @@ -8,7 +8,7 @@ from src.utils.gather import execute_gather, check_can_start_gather class Hunt(TimedAction): """ 狩猎动作,在有动物的区域进行狩猎,持续6个月 - 可以获得动物对应的物品 + 可以获得动物对应的材料 """ ACTION_NAME = "狩猎" @@ -21,15 +21,15 @@ class Hunt(TimedAction): def __init__(self, avatar, world): super().__init__(avatar, world) - self.gained_items: dict[str, int] = {} + self.gained_materials: dict[str, int] = {} def _execute(self) -> None: """ 执行狩猎动作 """ - gained = execute_gather(self.avatar, "animals", "extra_hunt_items") + gained = execute_gather(self.avatar, "animals", "extra_hunt_materials") for name, count in gained.items(): - self.gained_items[name] = self.gained_items.get(name, 0) + count + self.gained_materials[name] = self.gained_materials.get(name, 0) + count def can_start(self) -> tuple[bool, str]: return check_can_start_gather(self.avatar, "animals", "动物") @@ -41,9 +41,9 @@ class Hunt(TimedAction): async def finish(self) -> list[Event]: # 必定有产出 - items_desc = "、".join([f"{k}x{v}" for k, v in self.gained_items.items()]) + materials_desc = "、".join([f"{k}x{v}" for k, v in self.gained_materials.items()]) return [Event( self.world.month_stamp, - f"{self.avatar.name} 结束了狩猎,获得了:{items_desc}", + f"{self.avatar.name} 结束了狩猎,获得了:{materials_desc}", related_avatars=[self.avatar.id] )] diff --git a/src/classes/action/mine.py b/src/classes/action/mine.py index 2026440..94c72fa 100644 --- a/src/classes/action/mine.py +++ b/src/classes/action/mine.py @@ -21,15 +21,15 @@ class Mine(TimedAction): def __init__(self, avatar, world): super().__init__(avatar, world) - self.gained_items: dict[str, int] = {} + self.gained_materials: dict[str, int] = {} def _execute(self) -> None: """ 执行挖矿动作 """ - gained = execute_gather(self.avatar, "lodes", "extra_mine_items") + gained = execute_gather(self.avatar, "lodes", "extra_mine_materials") for name, count in gained.items(): - self.gained_items[name] = self.gained_items.get(name, 0) + count + self.gained_materials[name] = self.gained_materials.get(name, 0) + count def can_start(self) -> tuple[bool, str]: return check_can_start_gather(self.avatar, "lodes", "矿脉") @@ -40,9 +40,9 @@ class Mine(TimedAction): # TimedAction 已统一 step 逻辑 async def finish(self) -> list[Event]: - items_desc = "、".join([f"{k}x{v}" for k, v in self.gained_items.items()]) + materials_desc = "、".join([f"{k}x{v}" for k, v in self.gained_materials.items()]) return [Event( self.world.month_stamp, - f"{self.avatar.name} 结束了挖矿,获得了:{items_desc}", + f"{self.avatar.name} 结束了挖矿,获得了:{materials_desc}", related_avatars=[self.avatar.id] )] diff --git a/src/classes/action/refine.py b/src/classes/action/refine.py index 1c9fb8b..920ab4f 100644 --- a/src/classes/action/refine.py +++ b/src/classes/action/refine.py @@ -46,12 +46,11 @@ class Refine(TimedAction): def _count_materials(self, realm: Realm) -> int: """ 统计符合条件的材料数量。 - 注意:统计所有 Item 类的直接实例,不限于矿石。 + 注意:统计所有材料,不限于矿石。 """ count = 0 - for item, qty in self.avatar.items.items(): - # 只要是 Item 实例且境界符合即可 - if type(item).__name__ == "Item" and item.realm == realm: + for material, qty in self.avatar.materials.items(): + if material.realm == realm: count += qty return count @@ -62,7 +61,7 @@ class Refine(TimedAction): res = resolve_query(target_realm, expected_types=[Realm]) if not res.is_valid: return False, f"无效的境界: {target_realm}" - + realm = res.obj cost = self._get_cost() @@ -82,19 +81,19 @@ class Refine(TimedAction): # 扣除材料逻辑 to_deduct = cost - items_to_modify = [] + materials_to_modify = [] # 再次遍历寻找材料进行扣除 - for item, qty in self.avatar.items.items(): + for material, qty in self.avatar.materials.items(): if to_deduct <= 0: break - if type(item).__name__ == "Item" and item.realm == self.target_realm: + if material.realm == self.target_realm: take = min(qty, to_deduct) - items_to_modify.append((item, take)) + materials_to_modify.append((material, take)) to_deduct -= take - for item, take in items_to_modify: - self.avatar.remove_item(item, take) + for material, take in materials_to_modify: + self.avatar.remove_material(material, take) realm_val = self.target_realm.value if self.target_realm else target_realm return Event( @@ -133,18 +132,8 @@ class Refine(TimedAction): # 3. 成功:生成物品 new_item = get_random_elixir_by_realm(self.target_realm) - if new_item is None: - # 理论上不应该发生,除非该境界没有配置丹药 - fail_event = Event( - self.world.month_stamp, - f"{self.avatar.name} 炼制成功,但似乎没有产生任何已知的丹药。", - related_avatars=[self.avatar.id], - is_major=False - ) - events.append(fail_event) - return events - # 4. 决策:保留还是卖出 + # 4. 决策:保留(服用)还是卖出 base_desc = f"炼丹成功!获得了{self.target_realm.value}丹药『{new_item.name}』。" # 事件1:炼丹成功 diff --git a/src/classes/action/sell.py b/src/classes/action/sell.py index 095f158..55a8063 100644 --- a/src/classes/action/sell.py +++ b/src/classes/action/sell.py @@ -7,7 +7,7 @@ from src.classes.event import Event from src.classes.region import CityRegion from src.classes.normalize import normalize_goods_name from src.utils.resolution import resolve_query -from src.classes.item import Item +from src.classes.material import Material from src.classes.weapon import Weapon from src.classes.auxiliary import Auxiliary @@ -17,7 +17,7 @@ class Sell(InstantAction): 在城镇出售指定名称的物品/装备。 如果是材料:一次性卖出持有的全部数量。 如果是装备:卖出当前装备的(如果是当前装备)。 - 收益通过 avatar.sell_item() / sell_weapon() / sell_auxiliary() 结算。 + 收益通过 avatar.sell_material() / sell_weapon() / sell_auxiliary() 结算。 """ ACTION_NAME = "出售" @@ -32,19 +32,19 @@ class Sell(InstantAction): return False, "仅能在城市区域执行" # 使用通用解析逻辑获取物品原型和类型 - res = resolve_query(target_name, expected_types=[Item, Weapon, Auxiliary]) + res = resolve_query(target_name, expected_types=[Material, Weapon, Auxiliary]) if not res.is_valid: return False, f"未持有物品/装备: {target_name}" obj = res.obj normalized_name = normalize_goods_name(target_name) - # 1. 如果是物品,检查背包 - if isinstance(obj, Item): - if self.avatar.get_item_quantity(obj) > 0: + # 1. 如果是材料,检查背包 + if isinstance(obj, Material): + if self.avatar.get_material_quantity(obj) > 0: pass # 检查通过 else: - return False, f"未持有物品: {target_name}" + return False, f"未持有材料: {target_name}" # 2. 如果是兵器,检查当前装备 elif isinstance(obj, Weapon): @@ -70,16 +70,16 @@ class Sell(InstantAction): if not isinstance(region, CityRegion): return - res = resolve_query(target_name, expected_types=[Item, Weapon, Auxiliary]) + res = resolve_query(target_name, expected_types=[Material, Weapon, Auxiliary]) if not res.is_valid: return obj = res.obj normalized_name = normalize_goods_name(target_name) - if isinstance(obj, Item): - quantity = self.avatar.get_item_quantity(obj) - self.avatar.sell_item(obj, quantity) + if isinstance(obj, Material): + quantity = self.avatar.get_material_quantity(obj) + self.avatar.sell_material(obj, quantity) elif isinstance(obj, Weapon): # 需要再确认一次是否是当前装备 if self.avatar.weapon and normalize_goods_name(self.avatar.weapon.name) == normalized_name: diff --git a/src/classes/animal.py b/src/classes/animal.py index ab8d79c..00198a0 100644 --- a/src/classes/animal.py +++ b/src/classes/animal.py @@ -3,7 +3,7 @@ from typing import Optional from src.utils.df import game_configs, get_str, get_int, get_list_int from src.utils.config import CONFIG -from src.classes.item import Item, items_by_id +from src.classes.material import Material, materials_by_id from src.classes.cultivation import Realm @dataclass @@ -15,16 +15,16 @@ class Animal: name: str desc: str realm: Realm - item_ids: list[int] = field(default_factory=list) # 该动物对应的物品IDs + material_ids: list[int] = field(default_factory=list) # 该动物对应的物品IDs # 这些字段将在__post_init__中设置 - items: list[Item] = field(init=False, default_factory=list) # 该动物对应的物品实例 + materials: list[Material] = field(init=False, default_factory=list) # 该动物对应的物品实例 def __post_init__(self): """初始化物品实例""" - for item_id in self.item_ids: - if item_id in items_by_id: - self.items.append(items_by_id[item_id]) + for material_id in self.material_ids: + if material_id in materials_by_id: + self.materials.append(materials_by_id[material_id]) def __hash__(self) -> int: return hash(self.id) @@ -38,20 +38,20 @@ class Animal: """ info_parts = [f"【{self.name}】({self.realm.value})", self.desc] - if self.items: - item_names = [item.name for item in self.items] - info_parts.append(f"可获得材料:{', '.join(item_names)}") + if self.materials: + material_names = [material.name for material in self.materials] + info_parts.append(f"可获得材料:{', '.join(material_names)}") return " - ".join(info_parts) def get_structured_info(self) -> dict: - items_info = [item.get_structured_info() for item in self.items] + materials_info = [material.get_structured_info() for material in self.materials] return { "id": str(self.id), "name": self.name, "desc": self.desc, "grade": self.realm.value, - "drops": items_info, + "drops": materials_info, "type": "animal" } @@ -62,14 +62,14 @@ def _load_animals() -> tuple[dict[int, Animal], dict[str, Animal]]: animal_df = game_configs["animal"] for row in animal_df: - item_ids_list = get_list_int(row, "item_ids") + material_ids_list = get_list_int(row, "material_ids") animal = Animal( id=get_int(row, "id"), name=get_str(row, "name"), desc=get_str(row, "desc"), realm=Realm.from_id(get_int(row, "stage_id")), - item_ids=item_ids_list + material_ids=material_ids_list ) animals_by_id[animal.id] = animal animals_by_name[animal.name] = animal diff --git a/src/classes/avatar/core.py b/src/classes/avatar/core.py index faa194c..7397e88 100644 --- a/src/classes/avatar/core.py +++ b/src/classes/avatar/core.py @@ -26,7 +26,7 @@ from src.classes.event import Event from src.classes.action_runtime import ActionPlan, ActionInstance from src.classes.alignment import Alignment from src.classes.persona import Persona, get_random_compatible_personas -from src.classes.item import Item +from src.classes.material import Material from src.classes.weapon import Weapon from src.classes.auxiliary import Auxiliary from src.classes.magic_stone import MagicStone @@ -96,7 +96,7 @@ class Avatar( short_term_objective: str = "" long_term_objective: Optional[LongTermObjective] = None magic_stone: MagicStone = field(default_factory=lambda: MagicStone(0)) - items: dict[Item, int] = field(default_factory=dict) + materials: dict[Material, int] = field(default_factory=dict) hp: HP = field(default_factory=lambda: HP(0, 0)) relations: dict["Avatar", Relation] = field(default_factory=dict) alignment: Alignment | None = None diff --git a/src/classes/avatar/info_presenter.py b/src/classes/avatar/info_presenter.py index dd802dc..e4bc021 100644 --- a/src/classes/avatar/info_presenter.py +++ b/src/classes/avatar/info_presenter.py @@ -50,7 +50,7 @@ def get_avatar_info(avatar: "Avatar", detailed: bool = False) -> dict: technique_info = avatar.technique.get_detailed_info() if avatar.technique is not None else "无" cultivation_info = avatar.cultivation_progress.get_detailed_info() personas_info = ", ".join([p.get_detailed_info() for p in avatar.personas]) if avatar.personas else "无" - items_info = ",".join([f"{item.get_detailed_info()}x{quantity}" for item, quantity in avatar.items.items()]) if avatar.items else "无" + materials_info = ",".join([f"{mat.get_detailed_info()}x{quantity}" for mat, quantity in avatar.materials.items()]) if avatar.materials else "无" appearance_info = avatar.appearance.get_detailed_info(avatar.gender) spirit_animal_info = avatar.spirit_animal.get_info() if avatar.spirit_animal is not None else "无" else: @@ -63,7 +63,7 @@ def get_avatar_info(avatar: "Avatar", detailed: bool = False) -> dict: technique_info = avatar.technique.get_info() if avatar.technique is not None else "无" cultivation_info = avatar.cultivation_progress.get_info() personas_info = ", ".join([p.get_detailed_info() for p in avatar.personas]) if avatar.personas else "无" - items_info = ",".join([f"{item.get_info()}x{quantity}" for item, quantity in avatar.items.items()]) if avatar.items else "无" + materials_info = ",".join([f"{mat.get_info()}x{quantity}" for mat, quantity in avatar.materials.items()]) if avatar.materials else "无" appearance_info = avatar.appearance.get_info() spirit_animal_info = avatar.spirit_animal.get_info() if avatar.spirit_animal is not None else "无" @@ -81,7 +81,7 @@ def get_avatar_info(avatar: "Avatar", detailed: bool = False) -> dict: "功法": technique_info, "境界": cultivation_info, "特质": personas_info, - "物品": items_info, + "材料": materials_info, "外貌": appearance_info, "兵器": weapon_info, "辅助装备": auxiliary_info, @@ -185,13 +185,13 @@ def get_avatar_structured_info(avatar: "Avatar") -> dict: else: info["auxiliary"] = None - # 5. 物品 (Items) - items_list = [] - for item, count in avatar.items.items(): - i_info = item.get_structured_info() - i_info["count"] = count - items_list.append(i_info) - info["items"] = items_list + # 5. 材料 (Materials) + materials_list = [] + for material, count in avatar.materials.items(): + m_info = material.get_structured_info() + m_info["count"] = count + materials_list.append(m_info) + info["materials"] = materials_list # 6. 关系 (Relations) relations_list = [] diff --git a/src/classes/avatar/inventory_mixin.py b/src/classes/avatar/inventory_mixin.py index 3ed284c..87c47f6 100644 --- a/src/classes/avatar/inventory_mixin.py +++ b/src/classes/avatar/inventory_mixin.py @@ -3,73 +3,74 @@ Avatar 物品与装备管理 Mixin """ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Any, Union if TYPE_CHECKING: from src.classes.avatar.core import Avatar - from src.classes.item import Item + from src.classes.material import Material from src.classes.weapon import Weapon from src.classes.auxiliary import Auxiliary + from src.classes.elixir import Elixir class InventoryMixin: """物品与装备管理相关方法""" - def add_item(self: "Avatar", item: "Item", quantity: int = 1) -> None: + def add_material(self: "Avatar", material: "Material", quantity: int = 1) -> None: """ 添加物品到背包 Args: - item: 要添加的物品 + material: 要添加的物品 quantity: 添加数量,默认为1 """ if quantity <= 0: return - if item in self.items: - self.items[item] += quantity + if material in self.materials: + self.materials[material] += quantity else: - self.items[item] = quantity + self.materials[material] = quantity - def remove_item(self: "Avatar", item: "Item", quantity: int = 1) -> bool: + def remove_material(self: "Avatar", material: "Material", quantity: int = 1) -> bool: """ - 从背包移除物品 + 从背包移除材料 Args: - item: 要移除的物品 + material: 要移除的材料 quantity: 移除数量,默认为1 Returns: - bool: 是否成功移除(如果物品不足则返回False) + bool: 是否成功移除(如果材料不足则返回False) """ if quantity <= 0: return True - if item not in self.items: + if material not in self.materials: return False - if self.items[item] < quantity: + if self.materials[material] < quantity: return False - self.items[item] -= quantity + self.materials[material] -= quantity # 如果数量为0,从字典中移除该物品 - if self.items[item] == 0: - del self.items[item] + if self.materials[material] == 0: + del self.materials[material] return True - def get_item_quantity(self: "Avatar", item: "Item") -> int: + def get_material_quantity(self: "Avatar", material: "Material") -> int: """ - 获取指定物品的数量 + 获取指定材料的数量 Args: - item: 要查询的物品 + material: 要查询的材料 Returns: - int: 物品数量,如果没有该物品则返回0 + int: 材料数量,如果没有该材料则返回0 """ - return self.items.get(item, 0) + return self.materials.get(material, 0) def change_weapon(self: "Avatar", new_weapon: "Weapon") -> None: """ @@ -106,20 +107,20 @@ class InventoryMixin: # ==================== 出售接口 ==================== - def sell_item(self: "Avatar", item: "Item", quantity: int = 1) -> int: + def sell_material(self: "Avatar", material: "Material", quantity: int = 1) -> int: """ 出售材料物品,返回获得的灵石数量。 应用 extra_item_sell_price_multiplier 效果。 """ from src.classes.prices import prices - if quantity <= 0 or self.get_item_quantity(item) < quantity: + if quantity <= 0 or self.get_material_quantity(material) < quantity: return 0 - self.remove_item(item, quantity) + self.remove_material(material, quantity) # 使用统一的卖出价格接口(包含所有加成逻辑) - unit_price = prices.get_selling_price(item, self) + unit_price = prices.get_selling_price(material, self) total = unit_price * quantity self.magic_stone = self.magic_stone + total @@ -155,3 +156,132 @@ class InventoryMixin: self.magic_stone = self.magic_stone + total return total + def sell_elixir(self: "Avatar", elixir: "Elixir") -> int: + """ + 出售丹药,返回获得的灵石数量。 + """ + from src.classes.prices import prices + + # 使用统一的卖出价格接口 + total = prices.get_selling_price(elixir, self) + self.magic_stone = self.magic_stone + total + return total + + # ==================== 购买接口 ==================== + + def can_buy_item(self: "Avatar", obj: Any) -> tuple[bool, str]: + """ + 检查是否可以购买指定物品。 + 涵盖价格检查、境界限制、耐药性等。 + """ + from src.classes.elixir import Elixir + from src.classes.prices import prices + from src.classes.cultivation import Realm + + # 1. 检查价格 + price = prices.get_buying_price(obj, self) + if self.magic_stone < price: + return False, f"灵石不足 (需要 {price})" + + # 2. 丹药特殊检查 + if isinstance(obj, Elixir): + # 商店业务规则:当前仅开放练气期丹药购买 + if obj.realm != Realm.Qi_Refinement: + return False, "当前仅开放练气期丹药购买" + + # 境界限制 + if obj.realm > self.cultivation_progress.realm: + return False, f"境界不足,无法承受药力 ({obj.realm.value})" + + # 耐药性/生效中检查 + for consumed in self.elixirs: + if consumed.elixir.id == obj.id: + if not consumed.is_completely_expired(int(self.world.month_stamp)): + return False, "药效尚存,无法重复服用" + + return True, "" + + def buy_item(self: "Avatar", obj: Any) -> dict: + """ + 执行购买逻辑。 + 包括扣款、获得物品(服用/入包/装备)、以旧换新。 + 返回交易报告 dict。 + """ + import copy + from src.classes.elixir import Elixir + from src.classes.weapon import Weapon + from src.classes.auxiliary import Auxiliary + from src.classes.material import Material + from src.classes.prices import prices + + report = { + "success": True, + "cost": 0, + "action_type": "store", # store, consume, equip + "sold_item_name": None, + "sold_item_refund": 0 + } + + # 1. 扣款 + price = prices.get_buying_price(obj, self) + self.magic_stone -= price + report["cost"] = price + + # 2. 交付 + if isinstance(obj, Elixir): + # 购买即服用 + self.consume_elixir(obj) + report["action_type"] = "consume" + + elif isinstance(obj, Material): + # 放入背包 + self.add_material(obj) + report["action_type"] = "store" + + elif isinstance(obj, (Weapon, Auxiliary)): + # 装备需要深拷贝 + new_equip = copy.deepcopy(obj) + + # 尝试卖出旧装备并换上新装备 + sold_name, refund = self._equip_and_trade_in(new_equip) + + report["action_type"] = "equip" + if sold_name: + report["sold_item_name"] = sold_name + report["sold_item_refund"] = refund + + return report + + def _equip_and_trade_in(self: "Avatar", new_equip: Union["Weapon", "Auxiliary"]) -> tuple[str | None, int]: + """ + 内部方法:装备新物品,并尝试卖出旧物品(如果有)。 + 返回: (旧物品名称, 卖出金额) + """ + from src.classes.weapon import Weapon + from src.classes.auxiliary import Auxiliary + + sold_name = None + refund = 0 + + if isinstance(new_equip, Weapon): + # 检查是否有旧兵器 + if self.weapon: + sold_name = self.weapon.name + # sell_weapon 会把旧兵器加到 circulation 并加钱给 avatar + # 注意:sell_weapon 不会 clear self.weapon,也不会 deepcopy(因为是直接把引用给 circulation) + # 这是正确的,旧对象给系统,新对象上身 + refund = self.sell_weapon(self.weapon) + + # 换上新兵器 (覆盖 self.weapon) + self.change_weapon(new_equip) + + elif isinstance(new_equip, Auxiliary): + # 检查是否有旧法宝 + if self.auxiliary: + sold_name = self.auxiliary.name + refund = self.sell_auxiliary(self.auxiliary) + + # 换上新法宝 + self.change_auxiliary(new_equip) + + return sold_name, refund diff --git a/src/classes/effect/__init__.py b/src/classes/effect/__init__.py index 2a58bc0..5d9dca9 100644 --- a/src/classes/effect/__init__.py +++ b/src/classes/effect/__init__.py @@ -7,8 +7,9 @@ from .consts import ( CULTIVATE_DURATION_REDUCTION, EXTRA_BREAKTHROUGH_SUCCESS_RATE, EXTRA_DUAL_CULTIVATION_EXP, - EXTRA_HARVEST_ITEMS, - EXTRA_HUNT_ITEMS, + EXTRA_HARVEST_MATERIALS, + EXTRA_HUNT_MATERIALS, + EXTRA_MINE_MATERIALS, EXTRA_MOVE_STEP, EXTRA_CATCH_SUCCESS_RATE, EXTRA_ESCAPE_SUCCESS_RATE, diff --git a/src/classes/effect/consts.py b/src/classes/effect/consts.py index ee2e1f7..bcb5cfe 100644 --- a/src/classes/effect/consts.py +++ b/src/classes/effect/consts.py @@ -105,30 +105,38 @@ EXTRA_DUAL_CULTIVATION_EXP = "extra_dual_cultivation_exp" """ # --- 采集相关 --- -EXTRA_HARVEST_ITEMS = "extra_harvest_items" +EXTRA_HARVEST_MATERIALS = "extra_harvest_materials" """ -额外采集物品数量 +额外采集材料数量 类型: int 结算: src/classes/action/harvest.py -说明: 采集植物时额外获得的物品数量。 +说明: 采集植物时额外获得的材料数量。 数值参考: - 微量: 1 - 中量: 2 - 大量: 3 """ -EXTRA_HUNT_ITEMS = "extra_hunt_items" +EXTRA_HUNT_MATERIALS = "extra_hunt_materials" """ -额外狩猎物品数量 +额外狩猎材料数量 类型: int 结算: src/classes/action/hunt.py -说明: 狩猎动物时额外获得的物品数量。 +说明: 狩猎动物时额外获得的材料数量。 数值参考: - 微量: 1 - 中量: 2 - 大量: 3 """ +EXTRA_MINE_MATERIALS = "extra_mine_materials" +""" +额外挖矿材料数量 +类型: int +结算: src/classes/action/mine.py +说明: 挖矿时额外获得的材料数量。 +""" + # --- 移动相关 --- EXTRA_MOVE_STEP = "extra_move_step" """ @@ -421,8 +429,9 @@ ALL_EFFECTS = [ "extra_dual_cultivation_exp", # int - 额外双修经验 # 采集相关 - "extra_harvest_items", # int - 额外采集物品数量 - "extra_hunt_items", # int - 额外狩猎物品数量 + "extra_harvest_materials", # int - 额外采集材料数量 + "extra_hunt_materials", # int - 额外狩猎材料数量 + "extra_mine_materials", # int - 额外挖矿材料数量 # 移动相关 "extra_move_step", # int - 额外移动步数 diff --git a/src/classes/effect/desc.py b/src/classes/effect/desc.py index 26b1e42..c9ba44e 100644 --- a/src/classes/effect/desc.py +++ b/src/classes/effect/desc.py @@ -10,8 +10,9 @@ EFFECT_DESC_MAP = { "extra_breakthrough_success_rate": "突破成功率", "extra_fortune_probability": "奇遇概率", "extra_misfortune_probability": "霉运概率", - "extra_harvest_items": "采集获取物品", - "extra_hunt_items": "狩猎获取物品", + "extra_harvest_materials": "采集获取材料", + "extra_hunt_materials": "狩猎获取材料", + "extra_mine_materials": "挖矿获取材料", "extra_item_sell_price_multiplier": "物品出售价格", "shop_buy_price_reduction": "购买折扣", "extra_weapon_upgrade_chance": "兵器升级概率", @@ -163,7 +164,7 @@ def format_effects_to_text(effects: dict[str, Any] | list[dict[str, Any]]) -> st desc_list = [] for k, v in effects.items(): - if k == "when": + if k in ["when", "duration_month"]: continue # 跳过 eval 表达式或者无法解析的 key,或者直接显示 key diff --git a/src/classes/item.py b/src/classes/item.py deleted file mode 100644 index 4188a0c..0000000 --- a/src/classes/item.py +++ /dev/null @@ -1,56 +0,0 @@ -from dataclasses import dataclass - -from src.utils.df import game_configs, get_str, get_int -from src.classes.cultivation import Realm - -@dataclass -class Item: - """ - 物品 - """ - id: int - name: str - desc: str - realm: Realm - - def __hash__(self) -> int: - return hash(self.id) - - def __str__(self) -> str: - return self.name - - def get_info(self) -> str: - return f"{self.name} -({self.realm.value})" - - def get_detailed_info(self) -> str: - return f"{self.name} - {self.desc}({self.realm.value})" - - def get_structured_info(self) -> dict: - return { - "id": str(self.id), - "name": self.name, - "desc": self.desc, - "grade": self.realm.value, - "effect_desc": "" # 物品暂时没有效果字段 - } - -def _load_items() -> tuple[dict[int, Item], dict[str, Item]]: - """从配表加载item数据""" - items_by_id: dict[int, Item] = {} - items_by_name: dict[str, Item] = {} - - item_df = game_configs["item"] - for row in item_df: - item = Item( - id=get_int(row, "id"), - name=get_str(row, "name"), - desc=get_str(row, "desc"), - realm=Realm.from_id(get_int(row, "stage_id")) - ) - items_by_id[item.id] = item - items_by_name[item.name] = item - - return items_by_id, items_by_name - -# 从配表加载item数据 -items_by_id, items_by_name = _load_items() diff --git a/src/classes/lode.py b/src/classes/lode.py index 431f7f7..c07807b 100644 --- a/src/classes/lode.py +++ b/src/classes/lode.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from typing import Optional from src.utils.df import game_configs, get_str, get_int, get_list_int -from src.classes.item import Item, items_by_id +from src.classes.material import Material, materials_by_id from src.classes.cultivation import Realm @dataclass @@ -14,16 +14,16 @@ class Lode: name: str desc: str realm: Realm - item_ids: list[int] = field(default_factory=list) # 该矿脉对应的物品IDs + material_ids: list[int] = field(default_factory=list) # 该矿脉对应的物品IDs # 这些字段将在__post_init__中设置 - items: list[Item] = field(init=False, default_factory=list) # 该矿脉对应的物品实例 + materials: list[Material] = field(init=False, default_factory=list) # 该矿脉对应的物品实例 def __post_init__(self): """初始化物品实例""" - for item_id in self.item_ids: - if item_id in items_by_id: - self.items.append(items_by_id[item_id]) + for material_id in self.material_ids: + if material_id in materials_by_id: + self.materials.append(materials_by_id[material_id]) def __hash__(self) -> int: return hash(self.id) @@ -37,20 +37,20 @@ class Lode: """ info_parts = [f"【{self.name}】({self.realm.value})", self.desc] - if self.items: - item_names = [item.name for item in self.items] - info_parts.append(f"可获得矿石:{', '.join(item_names)}") + if self.materials: + material_names = [material.name for material in self.materials] + info_parts.append(f"可获得矿石:{', '.join(material_names)}") return " - ".join(info_parts) def get_structured_info(self) -> dict: - items_info = [item.get_structured_info() for item in self.items] + materials_info = [material.get_structured_info() for material in self.materials] return { "id": str(self.id), "name": self.name, "desc": self.desc, "grade": self.realm.value, - "drops": items_info, + "drops": materials_info, "type": "lode" } @@ -65,14 +65,14 @@ def _load_lodes() -> tuple[dict[int, Lode], dict[str, Lode]]: lode_df = game_configs["lode"] for row in lode_df: - item_ids_list = get_list_int(row, "item_ids") + material_ids_list = get_list_int(row, "material_ids") lode = Lode( id=get_int(row, "id"), name=get_str(row, "name"), desc=get_str(row, "desc"), realm=Realm.from_id(get_int(row, "stage_id")), - item_ids=item_ids_list + material_ids=material_ids_list ) lodes_by_id[lode.id] = lode lodes_by_name[lode.name] = lode @@ -83,5 +83,4 @@ def _load_lodes() -> tuple[dict[int, Lode], dict[str, Lode]]: lodes_by_id, lodes_by_name = _load_lodes() # 导出所有属于矿石的物品ID,供铸造逻辑判断 -ORE_ITEM_IDS = {item_id for lode in lodes_by_id.values() for item_id in lode.item_ids} - +ORE_MATERIAL_IDS = {material_id for lode in lodes_by_id.values() for material_id in lode.material_ids} diff --git a/src/classes/material.py b/src/classes/material.py new file mode 100644 index 0000000..0f0c2a1 --- /dev/null +++ b/src/classes/material.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass + +from src.utils.df import game_configs, get_str, get_int +from src.classes.cultivation import Realm + +@dataclass +class Material: + """ + 材料 + """ + id: int + name: str + desc: str + realm: Realm + + def __hash__(self) -> int: + return hash(self.id) + + def __str__(self) -> str: + return self.name + + def get_info(self) -> str: + return f"{self.name} -({self.realm.value})" + + def get_detailed_info(self) -> str: + return f"{self.name} - {self.desc}({self.realm.value})" + + def get_structured_info(self) -> dict: + return { + "id": str(self.id), + "name": self.name, + "desc": self.desc, + "grade": self.realm.value, + "effect_desc": "" # 材料暂时没有效果字段 + } + +def _load_materials() -> tuple[dict[int, Material], dict[str, Material]]: + """从配表加载 material 数据""" + materials_by_id: dict[int, Material] = {} + materials_by_name: dict[str, Material] = {} + + if "material" in game_configs: + material_df = game_configs["material"] + for row in material_df: + material = Material( + id=get_int(row, "id"), + name=get_str(row, "name"), + desc=get_str(row, "desc"), + realm=Realm.from_id(get_int(row, "stage_id")) + ) + materials_by_id[material.id] = material + materials_by_name[material.name] = material + + return materials_by_id, materials_by_name + +# 从配表加载 material 数据 +materials_by_id, materials_by_name = _load_materials() diff --git a/src/classes/plant.py b/src/classes/plant.py index a58d710..6b5a85f 100644 --- a/src/classes/plant.py +++ b/src/classes/plant.py @@ -3,7 +3,7 @@ from typing import Optional from src.utils.df import game_configs, get_str, get_int, get_list_int from src.utils.config import CONFIG -from src.classes.item import Item, items_by_id +from src.classes.material import Material, materials_by_id from src.classes.cultivation import Realm @dataclass @@ -15,16 +15,16 @@ class Plant: name: str desc: str realm: Realm - item_ids: list[int] = field(default_factory=list) # 该植物对应的物品IDs + material_ids: list[int] = field(default_factory=list) # 该植物对应的物品IDs # 这些字段将在__post_init__中设置 - items: list[Item] = field(init=False, default_factory=list) # 该植物对应的物品实例 + materials: list[Material] = field(init=False, default_factory=list) # 该植物对应的物品实例 def __post_init__(self): """初始化物品实例""" - for item_id in self.item_ids: - if item_id in items_by_id: - self.items.append(items_by_id[item_id]) + for material_id in self.material_ids: + if material_id in materials_by_id: + self.materials.append(materials_by_id[material_id]) def __hash__(self) -> int: return hash(self.id) @@ -38,20 +38,20 @@ class Plant: """ info_parts = [f"【{self.name}】({self.realm.value})", self.desc] - if self.items: - item_names = [item.name for item in self.items] - info_parts.append(f"可获得材料:{', '.join(item_names)}") + if self.materials: + material_names = [material.name for material in self.materials] + info_parts.append(f"可获得材料:{', '.join(material_names)}") return " - ".join(info_parts) def get_structured_info(self) -> dict: - items_info = [item.get_structured_info() for item in self.items] + materials_info = [material.get_structured_info() for material in self.materials] return { "id": str(self.id), "name": self.name, "desc": self.desc, "grade": self.realm.value, - "drops": items_info, + "drops": materials_info, "type": "plant" } @@ -62,14 +62,14 @@ def _load_plants() -> tuple[dict[int, Plant], dict[str, Plant]]: plant_df = game_configs["plant"] for row in plant_df: - item_ids_list = get_list_int(row, "item_ids") + material_ids_list = get_list_int(row, "material_ids") plant = Plant( id=get_int(row, "id"), name=get_str(row, "name"), desc=get_str(row, "desc"), realm=Realm.from_id(get_int(row, "stage_id")), - item_ids=item_ids_list + material_ids=material_ids_list ) plants_by_id[plant.id] = plant plants_by_name[plant.name] = plant diff --git a/src/classes/prices.py b/src/classes/prices.py index 79e2a17..2f55f67 100644 --- a/src/classes/prices.py +++ b/src/classes/prices.py @@ -6,7 +6,7 @@ 价格只和对应的 realm 绑定,全局统一。 价格设计参考(以练气期年收入约 20-30 灵石为基准): -- 材料(Item): 采集物等消耗品 +- 材料(Material): 采集物等消耗品 - 兵器(Weapon): 稀有装备,价值较高 - 辅助装备(Auxiliary): 法宝等,价值次于兵器 """ @@ -17,13 +17,13 @@ from typing import Union, TYPE_CHECKING from src.classes.cultivation import Realm if TYPE_CHECKING: - from src.classes.item import Item + from src.classes.material import Material from src.classes.weapon import Weapon from src.classes.auxiliary import Auxiliary from src.classes.avatar import Avatar # 类型别名 -Sellable = Union["Item", "Weapon", "Auxiliary"] +Sellable = Union["Material", "Weapon", "Auxiliary"] class Prices: @@ -36,11 +36,11 @@ class Prices: GLOBAL_BUY_MULTIPLIER = 1.5 # 材料价格表(采集物等) - ITEM_PRICES = { + MATERIAL_PRICES = { Realm.Qi_Refinement: 10, Realm.Foundation_Establishment: 30, - Realm.Core_Formation: 60, - Realm.Nascent_Soul: 100, + Realm.Core_Formation: 50, + Realm.Nascent_Soul: 70, } # 兵器价格表(稀有,价值高) @@ -59,9 +59,9 @@ class Prices: Realm.Nascent_Soul: 1600, } - def get_item_price(self, item: "Item") -> int: + def get_material_price(self, material: "Material") -> int: """获取材料基础价格""" - return self.ITEM_PRICES.get(item.realm, 10) + return self.MATERIAL_PRICES.get(material.realm, 10) def get_weapon_price(self, weapon: "Weapon") -> int: """获取兵器基础价格""" @@ -77,13 +77,13 @@ class Prices: 根据对象类型自动分发到对应的价格查询方法。 注意:这是物品的【基准价值】,通常等于玩家【卖出给系统】的基础价格。 """ - from src.classes.item import Item + from src.classes.material import Material from src.classes.weapon import Weapon from src.classes.auxiliary import Auxiliary from src.classes.elixir import Elixir - if isinstance(obj, Item): - return self.get_item_price(obj) + if isinstance(obj, Material): + return self.get_material_price(obj) elif isinstance(obj, Weapon): return self.get_weapon_price(obj) elif isinstance(obj, Auxiliary): diff --git a/src/classes/single_choice.py b/src/classes/single_choice.py index b8ceef1..621a079 100644 --- a/src/classes/single_choice.py +++ b/src/classes/single_choice.py @@ -76,22 +76,49 @@ def _get_item_ops(avatar: "Avatar", item_type: str) -> dict: return { "label": "兵器", "get_current": lambda: avatar.weapon, - "equip": avatar.change_weapon, - "sell": avatar.sell_weapon + "use_func": avatar.change_weapon, + "sell_func": avatar.sell_weapon, + "verbs": { + "action": "装备", + "done": "换上了", + "replace": "替换" + } } elif item_type == "auxiliary": return { "label": "辅助装备", "get_current": lambda: avatar.auxiliary, - "equip": avatar.change_auxiliary, - "sell": avatar.sell_auxiliary + "use_func": avatar.change_auxiliary, + "sell_func": avatar.sell_auxiliary, + "verbs": { + "action": "装备", + "done": "换上了", + "replace": "替换" + } } elif item_type == "technique": return { "label": "功法", "get_current": lambda: avatar.technique, - "equip": lambda x: setattr(avatar, 'technique', x), - "sell": None # 功法通常不能卖 + "use_func": lambda x: setattr(avatar, 'technique', x), + "sell_func": None, # 功法通常不能卖 + "verbs": { + "action": "修炼", + "done": "改修了", + "replace": "替换" + } + } + elif item_type == "elixir": + return { + "label": "丹药", + "get_current": lambda: None, # 丹药没有“当前装备”的概念,都是新获得的 + "use_func": avatar.consume_elixir, + "sell_func": avatar.sell_elixir, + "verbs": { + "action": "服用", + "done": "服用了", + "replace": "替换" # 丹药其实没有 replace,但为了模板通用可以给个默认值 + } } else: raise ValueError(f"Unsupported item type: {item_type}") @@ -100,7 +127,7 @@ def _get_item_ops(avatar: "Avatar", item_type: str) -> dict: async def handle_item_exchange( avatar: "Avatar", new_item: Any, - item_type: str, # "weapon", "auxiliary", "technique" + item_type: str, # "weapon", "auxiliary", "technique", "elixir" context_intro: str, can_sell_new: bool = False ) -> Tuple[bool, str]: @@ -119,6 +146,7 @@ async def handle_item_exchange( """ ops = _get_item_ops(avatar, item_type) label = ops["label"] + verbs = ops["verbs"] current_item = ops["get_current"]() new_name = new_item.name @@ -126,8 +154,8 @@ async def handle_item_exchange( # 1. 自动装备:当前无装备且不强制考虑卖新 if current_item is None and not can_sell_new: - ops["equip"](new_item) - return True, f"{avatar.name} 获得了{new_grade}{label}『{new_name}』并装备。" + ops["use_func"](new_item) + return True, f"{avatar.name} 获得了{new_grade}{label}『{new_name}』并{verbs['action']}。" # 2. 需要决策:准备描述 old_name = current_item.name if current_item else "" @@ -137,19 +165,19 @@ async def handle_item_exchange( if current_item: old_info = current_item.get_info(detailed=True) swap_desc = f"现有{label}:{old_info}\n{swap_desc}" - if ops["sell"]: - swap_desc += f"\n(选择替换将卖出旧{label})" + if ops["sell_func"]: + swap_desc += f"\n(选择{verbs['replace']}将卖出旧{label})" # 3. 构建选项 - # Option A: 装备新物品 - opt_a_text = f"装备新{label}『{new_name}』" - if current_item and ops["sell"]: + # Option A: 装备/服用新物品 + opt_a_text = f"{verbs['action']}新{label}『{new_name}』" + if current_item and ops["sell_func"]: opt_a_text += f",卖掉旧{label}『{old_name}』" elif current_item: - opt_a_text += f",替换旧{label}『{old_name}』" + opt_a_text += f",{verbs['replace']}旧{label}『{old_name}』" # Option B: 拒绝新物品 - if can_sell_new and ops["sell"]: + if can_sell_new and ops["sell_func"]: opt_b_text = f"卖掉新{label}『{new_name}』换取灵石,保留现状" else: opt_b_text = f"放弃『{new_name}』" @@ -167,15 +195,15 @@ async def handle_item_exchange( # 4. 执行决策 if choice == "A": # 卖旧(如果有且能卖) - if current_item and ops["sell"]: - ops["sell"](current_item) - # 装新 - ops["equip"](new_item) - return True, f"{avatar.name} 换上了{new_grade}{label}『{new_name}』。" + if current_item and ops["sell_func"]: + ops["sell_func"](current_item) + # 装新/服用 + ops["use_func"](new_item) + return True, f"{avatar.name} {verbs['done']}{new_grade}{label}『{new_name}』。" else: # 卖新(如果被要求且能卖) - if can_sell_new and ops["sell"]: - sold_price = ops["sell"](new_item) + if can_sell_new and ops["sell_func"]: + sold_price = ops["sell_func"](new_item) return False, f"{avatar.name} 卖掉了新获得的{new_name},获利 {sold_price} 灵石。" else: return False, f"{avatar.name} 放弃了{new_name}。" diff --git a/src/sim/load/avatar_load_mixin.py b/src/sim/load/avatar_load_mixin.py index 0d9ce13..2077fb5 100644 --- a/src/sim/load/avatar_load_mixin.py +++ b/src/sim/load/avatar_load_mixin.py @@ -41,7 +41,7 @@ class AvatarLoadMixin: from src.classes.age import Age from src.classes.hp import HP from src.classes.technique import techniques_by_id - from src.classes.item import items_by_id + from src.classes.material import materials_by_id from src.classes.weapon import weapons_by_id from src.classes.auxiliary import auxiliaries_by_id from src.classes.sect import sects_by_id @@ -92,13 +92,13 @@ class AvatarLoadMixin: # 设置物品与资源 avatar.magic_stone = MagicStone(data.get("magic_stone", 0)) - # 重建items - items_dict = data.get("items", {}) - avatar.items = {} - for item_id_str, quantity in items_dict.items(): - item_id = int(item_id_str) - if item_id in items_by_id: - avatar.items[items_by_id[item_id]] = quantity + # 重建materials (兼容旧档 items) + materials_dict = data.get("materials") or data.get("items") or {} + avatar.materials = {} + for mat_id_str, quantity in materials_dict.items(): + mat_id = int(mat_id_str) + if mat_id in materials_by_id: + avatar.materials[materials_by_id[mat_id]] = quantity # 重建weapon # 使用copy而非deepcopy,避免潜在的递归引用导致的崩溃,且性能更好 diff --git a/src/sim/load/load_game.py b/src/sim/load/load_game.py index c3f3a85..07e1ea6 100644 --- a/src/sim/load/load_game.py +++ b/src/sim/load/load_game.py @@ -8,7 +8,7 @@ 加载流程(两阶段): 1. 第一阶段:加载所有Avatar对象(relations留空) - 通过AvatarLoadMixin.from_save_dict反序列化 - - 配表对象(Technique, Item等)通过id从全局字典获取 + - 配表对象(Technique, Material等)通过id从全局字典获取 2. 第二阶段:重建Avatar之间的relations网络 - 必须在所有Avatar加载完成后才能建立引用关系 @@ -123,12 +123,7 @@ def load_game(save_path: Optional[Path] = None) -> Tuple["World", "Simulator", L avatar.relations[other_avatar] = relation # 将所有avatar添加到world - # 根据生死状态分流 - for avatar in all_avatars.values(): - if avatar.is_dead: - world.avatar_manager.dead_avatars[avatar.id] = avatar - else: - world.avatar_manager.avatars[avatar.id] = avatar + world.avatar_manager.avatars = all_avatars # 恢复洞府主人关系 cultivate_regions_hosts = world_data.get("cultivate_regions_hosts", {}) diff --git a/src/sim/save/avatar_save_mixin.py b/src/sim/save/avatar_save_mixin.py index 508e1b3..99864ce 100644 --- a/src/sim/save/avatar_save_mixin.py +++ b/src/sim/save/avatar_save_mixin.py @@ -4,9 +4,9 @@ Avatar存档序列化Mixin 将Avatar的序列化逻辑从avatar.py分离出来,保持核心类的清晰性。 存档策略: -- 引用对象(Technique, Item等):保存id,加载时从全局字典获取 +- 引用对象(Technique, Material等):保存id,加载时从全局字典获取 - relations:转换为dict[str, str](avatar_id -> relation_value) -- items:转换为dict[int, int](item_id -> quantity) +- materials:转换为dict[int, int](material_id -> quantity) - current_action:保存动作类名和参数 - weapon/auxiliary:需要深拷贝(因为special_data是实例特有的) """ @@ -30,10 +30,10 @@ class AvatarSaveMixin: for other, relation in self.relations.items() } - # 序列化items: dict[Item, int] -> dict[int, int] - items_dict = { - item.id: quantity - for item, quantity in self.items.items() + # 序列化materials: dict[Material, int] -> dict[int, int] + materials_dict = { + material.id: quantity + for material, quantity in self.materials.items() } # 序列化current_action @@ -75,7 +75,7 @@ class AvatarSaveMixin: # 物品与资源 "magic_stone": self.magic_stone.value, - "items": items_dict, + "materials": materials_dict, "weapon_id": self.weapon.id if self.weapon else None, "weapon_special_data": self.weapon.special_data if self.weapon else {}, "weapon_proficiency": self.weapon_proficiency, diff --git a/src/utils/gather.py b/src/utils/gather.py index 2feaf95..6bc0c13 100644 --- a/src/utils/gather.py +++ b/src/utils/gather.py @@ -37,7 +37,7 @@ def execute_gather( ) -> dict[str, int]: """ 执行采集逻辑。 - 返回: {item_name: count} + 返回: {material_name: count} """ from src.classes.region import NormalRegion region = avatar.tile.region @@ -61,16 +61,16 @@ def execute_gather( target = random.choice(available) # 2. 随机选择产出物 - if not hasattr(target, "items") or not target.items: + if not hasattr(target, "materials") or not target.materials: return {} - item = random.choice(target.items) + material = random.choice(target.materials) base_quantity = 1 - extra_items = int(avatar.effects.get(extra_effect_key, 0) or 0) - total_quantity = base_quantity + extra_items + extra_materials = int(avatar.effects.get(extra_effect_key, 0) or 0) + total_quantity = base_quantity + extra_materials - avatar.add_item(item, total_quantity) + avatar.add_material(material, total_quantity) - return {item.name: total_quantity} + return {material.name: total_quantity} diff --git a/src/utils/resolution.py b/src/utils/resolution.py index 1efad79..b53a5f9 100644 --- a/src/utils/resolution.py +++ b/src/utils/resolution.py @@ -6,7 +6,7 @@ from src.classes.normalize import normalize_goods_name, normalize_name, normaliz from src.classes.elixir import elixirs_by_name, Elixir from src.classes.weapon import weapons_by_name, Weapon from src.classes.auxiliary import auxiliaries_by_name, Auxiliary -from src.classes.item import items_by_name, Item +from src.classes.material import materials_by_name, Material from src.classes.cultivation import Realm @dataclass @@ -36,7 +36,7 @@ def resolve_query( query: 待解析的对象(字符串名 或 直接的对象实例) world: 世界对象上下文(用于查找 Avatar, Region) expected_types: 期望的类型列表。如果提供,将优先或仅尝试这些类型。 - 支持的类型: Item, Weapon, Elixir, Auxiliary, Region, Avatar, Realm 等类对象 + 支持的类型: Material, Weapon, Elixir, Auxiliary, Region, Avatar, Realm 等类对象 必须直接传入类对象,不再支持字符串名。 """ if query is None: @@ -73,7 +73,7 @@ def resolve_query( t_name = t.__name__ - if t_name in ["Item", "Weapon", "Elixir", "Auxiliary"]: + if t_name in ["Material", "Weapon", "Elixir", "Auxiliary"]: if "goods" not in checks: checks.append("goods") elif t_name == "Region" or t_name == "CityRegion" or t_name == "SectRegion" or t_name == "CultivateRegion": if "region" not in checks: checks.append("region") @@ -124,9 +124,9 @@ def _resolve_goods(name: str) -> Any | None: if norm in auxiliaries_by_name: return auxiliaries_by_name[norm] - # 4. 普通物品 - if norm in items_by_name: - return items_by_name[norm] + # 4. 材料 + if norm in materials_by_name: + return materials_by_name[norm] return None diff --git a/static/game_configs/animal.csv b/static/game_configs/animal.csv index ede1284..8068791 100644 --- a/static/game_configs/animal.csv +++ b/static/game_configs/animal.csv @@ -1,4 +1,4 @@ -id,name,desc,stage_id,item_ids +id,name,desc,stage_id,material_ids ,,,"该动物对应的物品ID" 1,灵兔,天性机敏的灵性兔子,毛色雪白,蕴含微弱灵力,性情温和易驯服,1,1 2,魔狼,凶猛的魔性狼族,体型巨大,拥有强大的魔力和锋利的爪牙,2,2 diff --git a/static/game_configs/celestial_phenomenon.csv b/static/game_configs/celestial_phenomenon.csv index ce47fcf..4df7201 100644 --- a/static/game_configs/celestial_phenomenon.csv +++ b/static/game_configs/celestial_phenomenon.csv @@ -1,7 +1,7 @@ id,name,rarity,effects,desc,duration_years 1,紫气东来,R,{extra_cultivate_exp: 15},天地灵气充沛,修士修行速度大增,修行欲望提高,5 2,金煞之年,R,"{extra_battle_strength_points: 3}",金煞充盈天地肃杀,修士更大可能嗜血而相互攻伐,5 -3,木灵盛世,R,"{extra_harvest_items: 2, extra_hp_recovery_rate: 0.5}",木德滋养生机盎然,采集收获倍增且伤势恢复极快,宜四处搜罗天材地宝,5 +3,木灵盛世,R,"{extra_harvest_materials: 2, extra_hp_recovery_rate: 0.5}",木德滋养生机盎然,采集收获倍增且伤势恢复极快,宜四处搜罗天材地宝,5 4,水德之纪,R,"{extra_cultivate_exp: 20, cultivate_duration_reduction: 0.2}",水行流转通达无碍,修炼效率与速度双重提升,正是闭关苦修的大好时机,5 5,火劫时代,R,"{extra_battle_strength_points: 5, extra_max_lifespan: -20}",天火燃烧劫数降临,战力暴涨但寿元流逝,当速战速决以命搏天,5 6,土厚之世,R,"{damage_reduction: 0.15, extra_max_hp: 150}",土德厚重载物无疆,身躯坚韧血气充盈,可无惧强敌正面争锋,5 diff --git a/static/game_configs/lode.csv b/static/game_configs/lode.csv index a5862ea..95acd08 100644 --- a/static/game_configs/lode.csv +++ b/static/game_configs/lode.csv @@ -1,4 +1,4 @@ -id,name,desc,stage_id,item_ids +id,name,desc,stage_id,material_ids ,,,"该矿脉对应的物品ID" 1,玄铁矿脉,蕴含玄铁的普通矿脉,常见于浅层地表,1,101 2,赤铜矿脉,深埋地下的赤铜矿脉,周围往往伴生火热之气,2,102 diff --git a/static/game_configs/item.csv b/static/game_configs/material.csv similarity index 100% rename from static/game_configs/item.csv rename to static/game_configs/material.csv diff --git a/static/game_configs/persona.csv b/static/game_configs/persona.csv index ea7d71c..4b7f005 100644 --- a/static/game_configs/persona.csv +++ b/static/game_configs/persona.csv @@ -6,8 +6,8 @@ id,name,exclusion_names,desc,rarity,condition,effects 4,冒险,怠惰;惜命,你总是会冒险,喜欢刺激,总想放手一搏。,N, 5,随性,理性;极端正义;极端邪恶,你总是会随机应变,性子到哪里了就是哪里,没有一定之规。,N, 6,贪财,,你对灵石和财富有着强烈的渴望。,N,,{extra_item_sell_price_multiplier: 0.1} -7,采集者,,喜欢在山林中寻找各种奇花异草和灵药,对植物有着敏锐的直觉和深厚的兴趣。你认为大自然的恩赐需要用心去发现和珍惜。,R,,{extra_harvest_items: 1} -8,猎人,,享受在野外追踪猎物的刺激感,对各种动物的习性了如指掌,喜欢捕猎野兽。情况允许也会御兽。,R,,{extra_hunt_items: 1} +7,采集者,,喜欢在山林中寻找各种奇花异草和灵药,对植物有着敏锐的直觉和深厚的兴趣。你认为大自然的恩赐需要用心去发现和珍惜。,R,,{extra_harvest_materials: 1} +8,猎人,,享受在野外追踪猎物的刺激感,对各种动物的习性了如指掌,喜欢捕猎野兽。情况允许也会御兽。,R,,{extra_hunt_materials: 1} 9,沉思,无常,你总是会深思熟虑,思考问题比较有哲理。,N, 10,惜命,冒险;极端正义;极端邪恶,你总是会珍惜自己的生命,不会轻易冒险。,R,,{extra_escape_success_rate: 0.15} 11,友爱,孤僻;淡漠;好斗;复仇;极端正义;极端邪恶,你重视同伴与和谐,乐于助人,倾向通过协作与沟通化解矛盾。,N, @@ -62,4 +62,4 @@ id,name,exclusion_names,desc,rarity,condition,effects 60,大器晚成,,早年修行多舛,霉运连连;但若能坚持至金丹元婴,便可否极泰来,气运亨通。,SR,,"[{when: 'avatar.cultivation_progress.realm.value in [""练气"", ""筑基""]', extra_misfortune_probability: 0.005}, {when: 'avatar.cultivation_progress.realm.value in [""金丹"", ""元婴""]', extra_fortune_probability: 0.01}]" 61,炼器师,好斗,精通炼器之道,对材料敏锐,擅长铸造法宝。你认为法宝是修行的关键,战斗并非你的专长。,R,,{extra_cast_success_rate: 0.15} 62,情绪化,理性;淡漠,你的情绪波动很大,极易受外界事件影响而改变心情,做事也更随心所欲。,N,, -63,矿工,怠惰,擅长勘探挖掘,对矿脉有着独特的直觉。你认为地下的宝藏才是最实在的财富。,R,,{extra_mine_items: 1} +63,矿工,怠惰,擅长勘探挖掘,对矿脉有着独特的直觉。你认为地下的宝藏才是最实在的财富。,R,,{extra_mine_materials: 1} diff --git a/static/game_configs/plant.csv b/static/game_configs/plant.csv index c45b32f..9e6305b 100644 --- a/static/game_configs/plant.csv +++ b/static/game_configs/plant.csv @@ -1,4 +1,4 @@ -id,name,desc,stage_id,item_ids +id,name,desc,stage_id,material_ids ,,,"该植物对应的物品ID" 1,奇草,生长在灵气充沛之地的奇异草药,叶片呈淡蓝色,具有神奇的治愈效果,1,10 2,灵木,千年古树吸收天地灵气而成,木质坚硬且蕴含浓郁灵力,2,11 diff --git a/static/game_configs/sect.csv b/static/game_configs/sect.csv index ee3deac..620304c 100644 --- a/static/game_configs/sect.csv +++ b/static/game_configs/sect.csv @@ -1,7 +1,7 @@ id,name,desc,member_act_style,alignment,weight,preferred_weapon,effects,rank_names ,,宗门名称与描述,宗门成员行事风格,阵营(正/中立/邪),权重(默认1),倾向兵器类型,"effects(JSON形式,支持宽松格式,见effects.py说明)",自定义职位(掌门;长老;内门;外门) 1,明心剑宗,"通玄界东方第一宗,以无上剑道称雄于世。云纹禁制为不传心法。【剑道专精】作为剑修,你使用剑类兵器时战力惊人,且在剑道上的感悟速度远超常人。",清明克己,行止如一。重剑与心法并重,讲究明心见性。,正,1,剑,"{extra_battle_strength_points: 3, extra_weapon_proficiency_gain: 0.5}", -2,百兽宗,"以驯养灵兽闻名,豢养各种妖兽灵怪为战力。【御兽大师】你拥有独特的御兽天赋,捕捉妖兽对你来说轻而易举,善于驱使兽群为你而战。",言语直接,重视力量与血性,崇尚狩猎与搏斗。,邪,1,鞭,"{extra_catch_success_rate: 0.25, extra_hunt_items: 1}",谷主;供奉;驭兽师;扈从 +2,百兽宗,"以驯养灵兽闻名,豢养各种妖兽灵怪为战力。【御兽大师】你拥有独特的御兽天赋,捕捉妖兽对你来说轻而易举,善于驱使兽群为你而战。",言语直接,重视力量与血性,崇尚狩猎与搏斗。,邪,1,鞭,"{extra_catch_success_rate: 0.25, extra_hunt_materials: 1}",谷主;供奉;驭兽师;扈从 3,水镜宗,"正道十宗之一,实则严守中立。拥有仙界异宝""彻天水镜""可预知未来。【趋吉避凶】你拥有超乎常人的直觉,视野开阔,且极易在探索中发现奇遇。",处事冷静圆融,喜以柔克刚,擅借力与反制。,中立,1,扇,"{extra_observation_radius: 2, extra_fortune_probability: 0.002, extra_refine_success_rate: 0.05}",镜主;掌镜人;传人;侍镜 4,冥王宗,"行走幽冥之道,术法阴冷狠厉。【通幽】你修行幽冥之法,心志坚定,突破瓶颈时心无杂念,成功率更高。",言辞冷厉少情,敬畏因果而不惧杀伐,偏向效率与结果。,邪,1,扇,"{extra_breakthrough_success_rate: 0.1}",殿主;判官;无常;鬼卒 5,朱勾宗,"邪宗大派。以炼器、机关、暗杀闻名于世,素来阴毒冷僻。【暗杀专家】你精通潜伏与刺杀,对于强敌,避开正面交锋、伺机暗杀往往是你最佳的制胜之道。",直面欲望与代价,不惧黑暗,以攻伐见长。,邪,1,暗器,"{extra_assassinate_success_rate: 0.15, extra_battle_strength_points: 1, extra_cast_success_rate: 0.05}",楼主;掌刑使;影刺;探子 diff --git a/tests/test_buy_action.py b/tests/test_buy_action.py index 3de500c..3a9941f 100644 --- a/tests/test_buy_action.py +++ b/tests/test_buy_action.py @@ -3,7 +3,9 @@ from unittest.mock import patch, MagicMock from src.classes.action.buy import Buy from src.classes.region import CityRegion, Region from src.classes.elixir import Elixir, ElixirType, ConsumedElixir -from src.classes.item import Item +from src.classes.material import Material +from src.classes.weapon import Weapon +from src.classes.weapon_type import WeaponType from src.classes.cultivation import Realm from src.classes.tile import Tile, TileType @@ -21,9 +23,9 @@ def create_test_elixir(name, realm, price=100, elixir_id=1, effects=None): effects=effects ) -def create_test_item(name, realm, item_id=101): - return Item( - id=item_id, +def create_test_material(name, realm, material_id=101): + return Material( + id=material_id, name=name, desc="测试物品", realm=realm @@ -50,11 +52,11 @@ def avatar_in_city(dummy_avatar): @pytest.fixture def mock_objects(): """ - Mock elixirs_by_name 和 items_by_name + Mock elixirs_by_name 和 materials_by_name """ test_elixir = create_test_elixir("聚气丹", Realm.Qi_Refinement, price=100) high_level_elixir = create_test_elixir("筑基丹", Realm.Foundation_Establishment, price=1000, elixir_id=2) - test_item = create_test_item("铁矿石", Realm.Qi_Refinement) + test_material = create_test_material("铁矿石", Realm.Qi_Refinement) # elixirs_by_name 是 Dict[str, List[Elixir]] elixirs_mock = { @@ -62,19 +64,19 @@ def mock_objects(): "筑基丹": [high_level_elixir] } - # items_by_name 是 Dict[str, Item] - items_mock = { - "铁矿石": test_item + # materials_by_name 是 Dict[str, Material] + materials_mock = { + "铁矿石": test_material } - return elixirs_mock, items_mock, test_elixir, high_level_elixir, test_item + return elixirs_mock, materials_mock, test_elixir, high_level_elixir, test_material def test_buy_item_success(avatar_in_city, mock_objects): - """测试购买普通物品成功""" - elixirs_mock, items_mock, _, _, test_item = mock_objects + """测试购买普通材料成功""" + elixirs_mock, materials_mock, _, _, test_material = mock_objects with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ - patch("src.utils.resolution.items_by_name", items_mock): + patch("src.utils.resolution.materials_by_name", materials_mock): action = Buy(avatar_in_city, avatar_in_city.world) @@ -84,21 +86,21 @@ def test_buy_item_success(avatar_in_city, mock_objects): # 2. 执行购买 initial_money = avatar_in_city.magic_stone - # 练气期物品基础价格 10,倍率 1.5 -> 15 + # 练气期材料基础价格 10,倍率 1.5 -> 15 expected_price = int(10 * 1.5) action._execute("铁矿石") # 3. 验证结果 assert avatar_in_city.magic_stone == initial_money - expected_price - assert avatar_in_city.get_item_quantity(test_item) == 1 + assert avatar_in_city.get_material_quantity(test_material) == 1 def test_buy_elixir_success(avatar_in_city, mock_objects): """测试购买并服用丹药成功""" - elixirs_mock, items_mock, test_elixir, _, _ = mock_objects + elixirs_mock, materials_mock, test_elixir, _, _ = mock_objects with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ - patch("src.utils.resolution.items_by_name", items_mock): + patch("src.utils.resolution.materials_by_name", materials_mock): action = Buy(avatar_in_city, avatar_in_city.world) @@ -108,28 +110,26 @@ def test_buy_elixir_success(avatar_in_city, mock_objects): initial_money = avatar_in_city.magic_stone expected_price = int(test_elixir.price * 1.5) - # 模拟服用丹药的行为(因为 consume_elixir 是 Avatar 的方法,我们可以信赖它, - # 但为了单元测试的隔离性,或者确认它被调用了,可以验证副作用) - # 这里直接验证副作用:elixirs 列表增加 + # 模拟服用丹药的行为 action._execute("聚气丹") assert avatar_in_city.magic_stone == initial_money - expected_price # 背包里不应该有丹药 - assert len(avatar_in_city.items) == 0 + assert len(avatar_in_city.materials) == 0 # 已服用列表应该有 assert len(avatar_in_city.elixirs) == 1 assert avatar_in_city.elixirs[0].elixir.name == "聚气丹" def test_buy_fail_not_in_city(dummy_avatar, mock_objects): """测试不在城市无法购买""" - elixirs_mock, items_mock, _, _, _ = mock_objects + elixirs_mock, materials_mock, _, _, _ = mock_objects # 确保不在城市 (dummy_avatar 默认在 (0,0) PLAIN) assert not isinstance(dummy_avatar.tile.region, CityRegion) with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ - patch("src.utils.resolution.items_by_name", items_mock): + patch("src.utils.resolution.materials_by_name", materials_mock): action = Buy(dummy_avatar, dummy_avatar.world) can_start, reason = action.can_start("铁矿石") @@ -139,12 +139,12 @@ def test_buy_fail_not_in_city(dummy_avatar, mock_objects): def test_buy_fail_no_money(avatar_in_city, mock_objects): """测试没钱无法购买""" - elixirs_mock, items_mock, _, _, test_item = mock_objects + elixirs_mock, materials_mock, _, _, test_material = mock_objects avatar_in_city.magic_stone = 0 # 没钱 with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ - patch("src.utils.resolution.items_by_name", items_mock): + patch("src.utils.resolution.materials_by_name", materials_mock): action = Buy(avatar_in_city, avatar_in_city.world) can_start, reason = action.can_start("铁矿石") @@ -154,10 +154,10 @@ def test_buy_fail_no_money(avatar_in_city, mock_objects): def test_buy_fail_unknown_item(avatar_in_city, mock_objects): """测试未知物品""" - elixirs_mock, items_mock, _, _, _ = mock_objects + elixirs_mock, materials_mock, _, _, _ = mock_objects with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ - patch("src.utils.resolution.items_by_name", items_mock): + patch("src.utils.resolution.materials_by_name", materials_mock): action = Buy(avatar_in_city, avatar_in_city.world) can_start, reason = action.can_start("不存在的东西") @@ -165,43 +165,38 @@ def test_buy_fail_unknown_item(avatar_in_city, mock_objects): assert can_start is False assert "未知物品" in reason - def test_buy_elixir_fail_high_level_restricted(avatar_in_city, mock_objects): - """测试购买高阶丹药被限制""" - elixirs_mock, items_mock, _, high_level_elixir, _ = mock_objects +def test_buy_elixir_fail_high_level_restricted(avatar_in_city, mock_objects): + """测试购买高阶丹药被限制""" + elixirs_mock, materials_mock, _, high_level_elixir, _ = mock_objects + + # 给予足够金钱,避免因为钱不够而先报错 + avatar_in_city.magic_stone = 10000 + + # 角色是练气期,尝试买筑基期丹药 + assert avatar_in_city.cultivation_progress.realm == Realm.Qi_Refinement + assert high_level_elixir.realm == Realm.Foundation_Establishment + + with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ + patch("src.utils.resolution.materials_by_name", materials_mock): - # 给予足够金钱,避免因为钱不够而先报错 - avatar_in_city.magic_stone = 10000 + action = Buy(avatar_in_city, avatar_in_city.world) + can_start, reason = action.can_start("筑基丹") - # 角色是练气期,尝试买筑基期丹药 - assert avatar_in_city.cultivation_progress.realm == Realm.Qi_Refinement - assert high_level_elixir.realm == Realm.Foundation_Establishment - - with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ - patch("src.utils.resolution.items_by_name", items_mock): - - action = Buy(avatar_in_city, avatar_in_city.world) - can_start, reason = action.can_start("筑基丹") - - assert can_start is False - # 当前版本限制仅开放练气期丹药 - assert "当前仅开放练气期丹药购买" in reason + assert can_start is False + # 当前版本限制仅开放练气期丹药 + assert "当前仅开放练气期丹药购买" in reason def test_buy_elixir_fail_duplicate_active(avatar_in_city, mock_objects): """测试药效尚存无法重复购买""" - elixirs_mock, items_mock, test_elixir, _, _ = mock_objects + elixirs_mock, materials_mock, test_elixir, _, _ = mock_objects # 先服用一个 consumed = ConsumedElixir(test_elixir, int(avatar_in_city.world.month_stamp)) - # 假设它是持久效果或未过期 - # ConsumedElixir 计算过期时间依赖 effects,我们在 create_test_elixir 里如果不给 duration_month,默认是 inf 或者是 0 (Action里的逻辑是看 is_completely_expired) - # 这里的 mock elixir 默认 effects 是 {"max_hp": 10},没有 duration_month,所以是永久效果? - # 查阅 ConsumedElixir._get_max_duration: 如果没有 duration_month, return inf (永久)。 - # 所以这应该是永久生效的。 avatar_in_city.elixirs.append(consumed) with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ - patch("src.utils.resolution.items_by_name", items_mock): + patch("src.utils.resolution.materials_by_name", materials_mock): action = Buy(avatar_in_city, avatar_in_city.world) can_start, reason = action.can_start("聚气丹") @@ -209,4 +204,45 @@ def test_buy_elixir_fail_duplicate_active(avatar_in_city, mock_objects): assert can_start is False assert "药效尚存" in reason - +def test_buy_weapon_trade_in(avatar_in_city, mock_objects): + """测试购买新武器时自动卖出旧武器""" + elixirs_mock, materials_mock, _, _, _ = mock_objects + + # 构造旧武器和新武器 + old_weapon = Weapon(id=201, name="铁剑", weapon_type=WeaponType.SWORD, realm=Realm.Qi_Refinement, desc="...", effects={'atk': 1}) + new_weapon = Weapon(id=202, name="青云剑", weapon_type=WeaponType.SWORD, realm=Realm.Qi_Refinement, desc="...", effects={'atk': 10}) + + # 装备旧武器 + avatar_in_city.change_weapon(old_weapon) + assert avatar_in_city.weapon == old_weapon + + materials_mock["青云剑"] = new_weapon + + initial_money = avatar_in_city.magic_stone + + # 价格计算 + # 练气期 Weapon Base Price = 10 + # 买入: 10 * 1.5 = 15 + buy_cost = 15 + # 卖出: 10 * 1.0 = 10 + sell_refund = 10 + + expected_money = initial_money - buy_cost + sell_refund + + with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ + patch("src.utils.resolution.materials_by_name", materials_mock): + + action = Buy(avatar_in_city, avatar_in_city.world) + + # 验证 Event 描述 + event = action.start("青云剑") + assert "青云剑" in event.content + assert "铁剑" in event.content + assert "折价售出" in event.content + + # 执行购买 + action._execute("青云剑") + + assert avatar_in_city.weapon.name == "青云剑" + assert avatar_in_city.weapon != old_weapon # 应该是新对象 + assert avatar_in_city.magic_stone == expected_money diff --git a/tests/test_circulation.py b/tests/test_circulation.py index 1298924..f876e61 100644 --- a/tests/test_circulation.py +++ b/tests/test_circulation.py @@ -179,7 +179,7 @@ def test_avatar_sell_integration(empty_world): weapon.name = "TestBlade" weapon.realm = Realm.Qi_Refinement - # The mixin usually requires self.items to have the item for sell_item, + # The mixin usually requires self.materials to have the material for sell_material, # but sell_weapon/sell_auxiliary are for equipped items or passed items. # Looking at inventory_mixin.py: sell_weapon(self, weapon) just calculates price and adds stones. # It calls _get_sell_multiplier() diff --git a/tests/test_gather.py b/tests/test_gather.py index 46cd6d6..1edf744 100644 --- a/tests/test_gather.py +++ b/tests/test_gather.py @@ -17,18 +17,18 @@ def mock_region(dummy_avatar): return real_region @pytest.fixture -def mock_resource_item(): - item = MagicMock() - item.name = "TestItem" - item.realm = Realm.Qi_Refinement - return item +def mock_resource_material(): + material = MagicMock() + material.name = "TestMaterial" + material.realm = Realm.Qi_Refinement + return material @pytest.fixture -def mock_resource(mock_resource_item): +def mock_resource(mock_resource_material): """创建一个通用的资源对象 (Lode/Animal/Plant)""" res = MagicMock() res.realm = Realm.Qi_Refinement - res.items = [mock_resource_item] + res.materials = [mock_resource_material] return res def test_check_can_start_gather_success(dummy_avatar, mock_region, mock_resource): @@ -66,22 +66,22 @@ def test_check_can_start_gather_realm_too_low(dummy_avatar, mock_region, mock_re assert can is False assert "当前区域的矿脉境界过高" in msg -def test_execute_gather_success(dummy_avatar, mock_region, mock_resource, mock_resource_item): +def test_execute_gather_success(dummy_avatar, mock_region, mock_resource, mock_resource_material): """测试执行采集逻辑成功""" mock_region.lodes = [mock_resource] - # 模拟 add_item - dummy_avatar.add_item = MagicMock() + # 模拟 add_material + dummy_avatar.add_material = MagicMock() - result = execute_gather(dummy_avatar, "lodes", "extra_mine_items") + result = execute_gather(dummy_avatar, "lodes", "extra_mine_materials") - assert "TestItem" in result - assert result["TestItem"] >= 1 - dummy_avatar.add_item.assert_called_once() + assert "TestMaterial" in result + assert result["TestMaterial"] >= 1 + dummy_avatar.add_material.assert_called_once() - # 验证获得的物品是正确的 - args, _ = dummy_avatar.add_item.call_args - assert args[0] == mock_resource_item + # 验证获得的材料是正确的 + args, _ = dummy_avatar.add_material.call_args + assert args[0] == mock_resource_material assert args[1] >= 1 def test_execute_gather_with_extra_effect(dummy_avatar, mock_region, mock_resource): @@ -90,28 +90,28 @@ def test_execute_gather_with_extra_effect(dummy_avatar, mock_region, mock_resour # effects 是只读属性,它通过合并各个组件的 effects 来计算。 # 为了测试,我们 Mock 掉 effects 属性。 - with patch.object(type(dummy_avatar), 'effects', new_callable=lambda: {"extra_mine_items": 2}): - dummy_avatar.add_item = MagicMock() + with patch.object(type(dummy_avatar), 'effects', new_callable=lambda: {"extra_mine_materials": 2}): + dummy_avatar.add_material = MagicMock() - result = execute_gather(dummy_avatar, "lodes", "extra_mine_items") + result = execute_gather(dummy_avatar, "lodes", "extra_mine_materials") # 基础1 + 加成2 = 3 - assert result["TestItem"] == 3 + assert result["TestMaterial"] == 3 def test_execute_gather_random_selection(dummy_avatar, mock_region): """测试从多个资源中随机选择""" res1 = MagicMock() res1.realm = Realm.Qi_Refinement - res1.items = [MagicMock(name="Item1")] - res1.items[0].name = "Item1" + res1.materials = [MagicMock(name="Material1")] + res1.materials[0].name = "Material1" res2 = MagicMock() res2.realm = Realm.Qi_Refinement - res2.items = [MagicMock(name="Item2")] - res2.items[0].name = "Item2" + res2.materials = [MagicMock(name="Material2")] + res2.materials[0].name = "Material2" mock_region.lodes = [res1, res2] - dummy_avatar.add_item = MagicMock() + dummy_avatar.add_material = MagicMock() - execute_gather(dummy_avatar, "lodes", "extra_mine_items") - dummy_avatar.add_item.assert_called_once() + execute_gather(dummy_avatar, "lodes", "extra_mine_materials") + dummy_avatar.add_material.assert_called_once() diff --git a/tests/test_normalize_resolution.py b/tests/test_normalize_resolution.py index 97e3eb4..38540c1 100644 --- a/tests/test_normalize_resolution.py +++ b/tests/test_normalize_resolution.py @@ -10,7 +10,7 @@ from src.utils.resolution import ( resolve_query, ResolutionResult ) -from src.classes.item import Item +from src.classes.material import Material from src.classes.cultivation import Realm # ==================== Normalize Tests ==================== @@ -75,14 +75,14 @@ def test_resolve_query_empty(): def test_resolve_query_direct_object(): """测试直接传递对象""" # 1. 匹配类型 - item = Item(id=999, name="测试物品", desc="测试描述", realm=Realm.Qi_Refinement) - res = resolve_query(item, expected_types=[Item]) + material = Material(id=999, name="测试材料", desc="测试描述", realm=Realm.Qi_Refinement) + res = resolve_query(material, expected_types=[Material]) assert res.is_valid - assert res.obj is item - assert res.resolved_type == Item + assert res.obj is material + assert res.resolved_type == Material # 2. 不匹配类型但作为对象传入 - res = resolve_query(item, expected_types=[Realm]) + res = resolve_query(material, expected_types=[Realm]) assert not res.is_valid def test_resolve_query_realm(): @@ -101,7 +101,7 @@ def test_resolve_query_realm(): def test_resolve_query_unsupported_type(): """测试不支持的类型输入""" - res = resolve_query(123, expected_types=[Item]) + res = resolve_query(123, expected_types=[Material]) assert not res.is_valid assert "非字符串" in res.error_msg @@ -121,7 +121,7 @@ def test_resolve_region_mock(mock_world): # 或者我们只测试逻辑分支 pass -# 由于 resolution.py 内部强依赖了实际的类 (Item, Region 等), +# 由于 resolution.py 内部强依赖了实际的类 (Material, Region 等), # 且使用了 isinstance(t, type) 和 t.__name__ 判断, # 纯单元测试建议主要覆盖逻辑分支。集成测试覆盖实际类。 diff --git a/tests/test_prices.py b/tests/test_prices.py index 78f6157..d9c1127 100644 --- a/tests/test_prices.py +++ b/tests/test_prices.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch from src.classes.prices import prices, Prices from src.classes.cultivation import Realm -from src.classes.item import items_by_id +from src.classes.material import materials_by_id from src.classes.weapon import weapons_by_id, Weapon, get_random_weapon_by_realm from src.classes.auxiliary import auxiliaries_by_id, Auxiliary, get_random_auxiliary_by_realm @@ -12,11 +12,11 @@ from src.classes.auxiliary import auxiliaries_by_id, Auxiliary, get_random_auxil class TestPrices: """价格系统测试""" - def test_item_prices_by_realm(self): + def test_material_prices_by_realm(self): """测试材料价格按境界递增""" - assert prices.ITEM_PRICES[Realm.Qi_Refinement] < prices.ITEM_PRICES[Realm.Foundation_Establishment] - assert prices.ITEM_PRICES[Realm.Foundation_Establishment] < prices.ITEM_PRICES[Realm.Core_Formation] - assert prices.ITEM_PRICES[Realm.Core_Formation] < prices.ITEM_PRICES[Realm.Nascent_Soul] + assert prices.MATERIAL_PRICES[Realm.Qi_Refinement] < prices.MATERIAL_PRICES[Realm.Foundation_Establishment] + assert prices.MATERIAL_PRICES[Realm.Foundation_Establishment] < prices.MATERIAL_PRICES[Realm.Core_Formation] + assert prices.MATERIAL_PRICES[Realm.Core_Formation] < prices.MATERIAL_PRICES[Realm.Nascent_Soul] def test_weapon_prices_by_realm(self): """测试兵器价格按境界递增""" @@ -30,16 +30,16 @@ class TestPrices: assert prices.AUXILIARY_PRICES[Realm.Foundation_Establishment] < prices.AUXILIARY_PRICES[Realm.Core_Formation] assert prices.AUXILIARY_PRICES[Realm.Core_Formation] < prices.AUXILIARY_PRICES[Realm.Nascent_Soul] - def test_get_price_for_item(self): - """测试 get_price 对 Item 类型的分发""" - if not items_by_id: + def test_get_price_for_material(self): + """测试 get_price 对 Material 类型的分发""" + if not materials_by_id: pytest.skip("No items available in config") - item = next(iter(items_by_id.values())) + item = next(iter(materials_by_id.values())) price = prices.get_price(item) - expected = prices.get_item_price(item) + expected = prices.get_material_price(item) assert price == expected - assert price == prices.ITEM_PRICES[item.realm] + assert price == prices.MATERIAL_PRICES[item.realm] def test_get_price_for_weapon(self): """测试 get_price 对 Weapon 类型的分发""" @@ -63,54 +63,54 @@ class TestPrices: assert price == expected assert price == prices.AUXILIARY_PRICES[aux.realm] - def test_weapon_more_expensive_than_item(self): + def test_weapon_more_expensive_than_material(self): """测试同境界下兵器比材料贵""" for realm in Realm: - if realm in prices.ITEM_PRICES and realm in prices.WEAPON_PRICES: - assert prices.WEAPON_PRICES[realm] >= prices.ITEM_PRICES[realm] + if realm in prices.MATERIAL_PRICES and realm in prices.WEAPON_PRICES: + assert prices.WEAPON_PRICES[realm] >= prices.MATERIAL_PRICES[realm] class TestAvatarSell: """Avatar 出售接口测试""" - def test_sell_item_basic(self, dummy_avatar): + def test_sell_material_basic(self, dummy_avatar): """测试基础材料出售""" - if not items_by_id: + if not materials_by_id: pytest.skip("No items available in config") - item = next(iter(items_by_id.values())) - dummy_avatar.items = {} # 清空背包 + item = next(iter(materials_by_id.values())) + dummy_avatar.materials = {} # 清空背包 dummy_avatar.magic_stone.value = 0 # 添加物品 - dummy_avatar.add_item(item, 5) - assert dummy_avatar.get_item_quantity(item) == 5 + dummy_avatar.add_material(item, 5) + assert dummy_avatar.get_material_quantity(item) == 5 # 出售3个 - gained = dummy_avatar.sell_item(item, 3) + gained = dummy_avatar.sell_material(item, 3) - expected_price = prices.get_item_price(item) * 3 + expected_price = prices.get_material_price(item) * 3 assert gained == expected_price assert dummy_avatar.magic_stone.value == expected_price - assert dummy_avatar.get_item_quantity(item) == 2 + assert dummy_avatar.get_material_quantity(item) == 2 - def test_sell_item_insufficient(self, dummy_avatar): + def test_sell_material_insufficient(self, dummy_avatar): """测试出售物品数量不足""" - if not items_by_id: + if not materials_by_id: pytest.skip("No items available in config") - item = next(iter(items_by_id.values())) - dummy_avatar.items = {} + item = next(iter(materials_by_id.values())) + dummy_avatar.materials = {} dummy_avatar.magic_stone.value = 100 - dummy_avatar.add_item(item, 2) + dummy_avatar.add_material(item, 2) # 尝试出售5个(只有2个) - gained = dummy_avatar.sell_item(item, 5) + gained = dummy_avatar.sell_material(item, 5) assert gained == 0 assert dummy_avatar.magic_stone.value == 100 # 没有变化 - assert dummy_avatar.get_item_quantity(item) == 2 # 物品未减少 + assert dummy_avatar.get_material_quantity(item) == 2 # 物品未减少 def test_sell_weapon(self, dummy_avatar): """测试出售兵器""" @@ -142,15 +142,15 @@ class TestAvatarSell: def test_sell_with_price_multiplier(self, dummy_avatar): """测试出售价格倍率效果""" - if not items_by_id: + if not materials_by_id: pytest.skip("No items available in config") - item = next(iter(items_by_id.values())) - dummy_avatar.items = {} + item = next(iter(materials_by_id.values())) + dummy_avatar.materials = {} dummy_avatar.magic_stone.value = 0 - dummy_avatar.add_item(item, 1) + dummy_avatar.add_material(item, 1) - base_price = prices.get_item_price(item) + base_price = prices.get_material_price(item) # 模拟 20% 加成 (0.2) # 这里的 effects 是 property,需要用 PropertyMock @@ -158,7 +158,7 @@ class TestAvatarSell: # 注意:由于 Avatar 分布在多个 mixin 中,patch 的位置取决于 effects 定义的位置 # effects 定义在 EffectsMixin 中,但混入后是在 Avatar 类上 # 如果 patch 比较麻烦,我们可以利用 Prices.get_selling_price 的逻辑 - # 这里我们其实也可以直接 patch get_selling_price 来验证 sell_item 是否使用了它 + # 这里我们其实也可以直接 patch get_selling_price 来验证 sell_material 是否使用了它 # 但为了验证集成逻辑,我们尝试 patch effects pass @@ -169,7 +169,7 @@ class TestAvatarSell: expected_total = int(base_price * 1.2) with patch("src.classes.prices.prices.get_selling_price", return_value=expected_total) as mock_get_price: - gained = dummy_avatar.sell_item(item, 1) + gained = dummy_avatar.sell_material(item, 1) # 验证调用参数 mock_get_price.assert_called_with(item, dummy_avatar) diff --git a/tests/test_sell_action.py b/tests/test_sell_action.py index 9f4b30f..f1a6570 100644 --- a/tests/test_sell_action.py +++ b/tests/test_sell_action.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import patch, MagicMock from src.classes.action.sell import Sell from src.classes.region import CityRegion -from src.classes.item import Item +from src.classes.material import Material from src.classes.weapon import Weapon from src.classes.auxiliary import Auxiliary from src.classes.cultivation import Realm @@ -10,11 +10,11 @@ from src.classes.tile import Tile, TileType from src.classes.weapon_type import WeaponType # 创建测试用的对象 helper -def create_test_item(name, realm, item_id=101): - return Item( - id=item_id, +def create_test_material(name, realm, material_id=101): + return Material( + id=material_id, name=name, - desc="测试物品", + desc="测试材料", realm=realm ) @@ -50,7 +50,7 @@ def avatar_in_city(dummy_avatar): dummy_avatar.tile = tile dummy_avatar.magic_stone = 0 - dummy_avatar.items = {} + dummy_avatar.materials = {} dummy_avatar.weapon = None dummy_avatar.auxiliary = None @@ -59,26 +59,26 @@ def avatar_in_city(dummy_avatar): @pytest.fixture def mock_sell_objects(): """ - Mock items_by_name/weapons/auxiliaries 并提供测试对象 + Mock materials_by_name/weapons/auxiliaries 并提供测试对象 """ - test_item = create_test_item("铁矿石", Realm.Qi_Refinement) + test_material = create_test_material("铁矿石", Realm.Qi_Refinement) test_weapon = create_test_weapon("青云剑", Realm.Qi_Refinement) test_auxiliary = create_test_auxiliary("聚灵珠", Realm.Qi_Refinement) - items_mock = {"铁矿石": test_item} + materials_mock = {"铁矿石": test_material} weapons_mock = {"青云剑": test_weapon} auxiliaries_mock = {"聚灵珠": test_auxiliary} - return items_mock, weapons_mock, auxiliaries_mock, test_item, test_weapon, test_auxiliary + return materials_mock, weapons_mock, auxiliaries_mock, test_material, test_weapon, test_auxiliary -def test_sell_item_success(avatar_in_city, mock_sell_objects): - """测试出售普通物品成功""" - items_mock, weapons_mock, auxiliaries_mock, test_item, _, _ = mock_sell_objects +def test_sell_material_success(avatar_in_city, mock_sell_objects): + """测试出售普通材料成功""" + materials_mock, weapons_mock, auxiliaries_mock, test_material, _, _ = mock_sell_objects - # 给角色添加物品 - avatar_in_city.add_item(test_item, quantity=5) + # 给角色添加材料 + avatar_in_city.add_material(test_material, quantity=5) - with patch("src.utils.resolution.items_by_name", items_mock), \ + with patch("src.utils.resolution.materials_by_name", materials_mock), \ patch("src.utils.resolution.weapons_by_name", weapons_mock), \ patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock): @@ -89,7 +89,7 @@ def test_sell_item_success(avatar_in_city, mock_sell_objects): assert can_start is True # 2. 执行出售 - # 练气期物品基础价格 10,卖出倍率默认为 1.0 -> 单价 10 + # 练气期材料基础价格 10,卖出倍率默认为 1.0 -> 单价 10 # 卖出全部 5 个 -> 总价 50 initial_money = avatar_in_city.magic_stone expected_income = 50 @@ -98,16 +98,16 @@ def test_sell_item_success(avatar_in_city, mock_sell_objects): # 3. 验证结果 assert avatar_in_city.magic_stone == initial_money + expected_income - assert avatar_in_city.get_item_quantity(test_item) == 0 + assert avatar_in_city.get_material_quantity(test_material) == 0 def test_sell_weapon_success(avatar_in_city, mock_sell_objects): """测试出售当前兵器成功""" - items_mock, weapons_mock, auxiliaries_mock, _, test_weapon, _ = mock_sell_objects + materials_mock, weapons_mock, auxiliaries_mock, _, test_weapon, _ = mock_sell_objects # 装备兵器 avatar_in_city.weapon = test_weapon - with patch("src.utils.resolution.items_by_name", items_mock), \ + with patch("src.utils.resolution.materials_by_name", materials_mock), \ patch("src.utils.resolution.weapons_by_name", weapons_mock), \ patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock): @@ -134,12 +134,12 @@ def test_sell_weapon_success(avatar_in_city, mock_sell_objects): def test_sell_auxiliary_success(avatar_in_city, mock_sell_objects): """测试出售当前法宝成功""" - items_mock, weapons_mock, auxiliaries_mock, _, _, test_auxiliary = mock_sell_objects + materials_mock, weapons_mock, auxiliaries_mock, _, _, test_auxiliary = mock_sell_objects # 装备法宝 avatar_in_city.auxiliary = test_auxiliary - with patch("src.utils.resolution.items_by_name", items_mock), \ + with patch("src.utils.resolution.materials_by_name", materials_mock), \ patch("src.utils.resolution.weapons_by_name", weapons_mock), \ patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock): @@ -157,13 +157,13 @@ def test_sell_auxiliary_success(avatar_in_city, mock_sell_objects): def test_sell_fail_not_in_city(dummy_avatar, mock_sell_objects): """测试不在城市无法出售""" - items_mock, weapons_mock, auxiliaries_mock, test_item, _, _ = mock_sell_objects + materials_mock, weapons_mock, auxiliaries_mock, test_material, _, _ = mock_sell_objects # 确保不在城市 assert not isinstance(dummy_avatar.tile.region, CityRegion) - dummy_avatar.add_item(test_item, 1) + dummy_avatar.add_material(test_material, 1) - with patch("src.utils.resolution.items_by_name", items_mock), \ + with patch("src.utils.resolution.materials_by_name", materials_mock), \ patch("src.utils.resolution.weapons_by_name", weapons_mock), \ patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock): @@ -174,12 +174,12 @@ def test_sell_fail_not_in_city(dummy_avatar, mock_sell_objects): assert "仅能在城市" in reason def test_sell_fail_no_item(avatar_in_city, mock_sell_objects): - """测试未持有该物品""" - items_mock, weapons_mock, auxiliaries_mock, _, _, _ = mock_sell_objects + """测试未持有该材料""" + materials_mock, weapons_mock, auxiliaries_mock, _, _, _ = mock_sell_objects # 背包为空,无装备 - with patch("src.utils.resolution.items_by_name", items_mock), \ + with patch("src.utils.resolution.materials_by_name", materials_mock), \ patch("src.utils.resolution.weapons_by_name", weapons_mock), \ patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock): @@ -187,13 +187,13 @@ def test_sell_fail_no_item(avatar_in_city, mock_sell_objects): can_start, reason = action.can_start("铁矿石") assert can_start is False - assert "未持有物品" in reason + assert "未持有材料" in reason def test_sell_fail_unknown_name(avatar_in_city, mock_sell_objects): """测试未知物品名称""" - items_mock, weapons_mock, auxiliaries_mock, _, _, _ = mock_sell_objects + materials_mock, weapons_mock, auxiliaries_mock, _, _, _ = mock_sell_objects - with patch("src.utils.resolution.items_by_name", items_mock), \ + with patch("src.utils.resolution.materials_by_name", materials_mock), \ patch("src.utils.resolution.weapons_by_name", weapons_mock), \ patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock): @@ -204,18 +204,18 @@ def test_sell_fail_unknown_name(avatar_in_city, mock_sell_objects): assert "未持有物品/装备" in reason def test_sell_priority(avatar_in_city, mock_sell_objects): - """测试物品优先级:同名时优先卖背包里的材料""" - items_mock, weapons_mock, auxiliaries_mock, test_item, test_weapon, _ = mock_sell_objects + """测试优先级:同名时优先卖身上装备(根据 resolution 优先级)""" + materials_mock, weapons_mock, auxiliaries_mock, test_material, test_weapon, _ = mock_sell_objects # 构造一个同名的兵器和材料 - fake_sword_item = create_test_item("青云剑", Realm.Qi_Refinement) - items_mock["青云剑"] = fake_sword_item + fake_sword_material = create_test_material("青云剑", Realm.Qi_Refinement) + materials_mock["青云剑"] = fake_sword_material # 角色同时拥有该材料和该兵器 - avatar_in_city.add_item(fake_sword_item, 1) + avatar_in_city.add_material(fake_sword_material, 1) avatar_in_city.weapon = test_weapon # name也是 "青云剑" - with patch("src.utils.resolution.items_by_name", items_mock), \ + with patch("src.utils.resolution.materials_by_name", materials_mock), \ patch("src.utils.resolution.weapons_by_name", weapons_mock), \ patch("src.utils.resolution.auxiliaries_by_name", auxiliaries_mock): @@ -224,69 +224,6 @@ def test_sell_priority(avatar_in_city, mock_sell_objects): # 执行出售 action._execute("青云剑") - # 应该优先卖掉了材料 - # 注意:在新的 resolution 逻辑中,resolve_goods_by_name 的查找顺序是: - # 1. Elixir - # 2. Weapon - # 3. Auxiliary - # 4. Item - # 所以如果在 mock 中都有 "青云剑",它会先被解析为 Weapon 类型。 - # - # 然后 Sell._resolve_obj (现在内联在方法里) 逻辑: - # obj, obj_type, _ = resolve_goods_by_name(target_name) - # - # 如果解析出来是 Weapon: - # Sell logic: if obj_type == "weapon": check if self.avatar.weapon == normalized_name - # - # 所以如果名字相同,且 resolve 优先判定为 Weapon,那么代码会认为你想卖 Weapon。 - # 之前的逻辑: - # 1. 检查背包材料 -> 有就卖 - # 2. 检查兵器 - # - # 新的逻辑: - # 1. resolve_goods_by_name -> 返回类型 - # 2. 根据类型检查 - # - # 由于 resolution 中 Weapon 优先于 Item,所以 "青云剑" 会被解析为 Weapon。 - # 于是 Sell 动作会尝试卖身上的兵器。 - # 如果此时也正好装备了青云剑,就会卖掉兵器。 - # - # 这意味着:新逻辑改变了优先级! - # 之前是优先卖背包里的 Item(即使有同名的 Weapon 定义)。 - # 现在是看 resolution 认为它是什么。 - # - # 如果我想保留"优先卖背包"的逻辑,我需要在 Sell 里特殊处理吗? - # 或者接受这个变更。 - # - # 假设"青云剑"既是 Weapon 又是 Item。 - # resolve_goods_by_name 会返回 Weapon。 - # Sell 拿到 Weapon 类型,检查 self.avatar.weapon。 - # -> 卖掉兵器。 - # - # 如果我想测试"优先卖背包",这在当前新逻辑下可能不再成立,除非 Item 的查找优先级高于 Weapon。 - # 但通常 Item 优先级最低。 - # - # 考虑到“青云剑”作为材料这种名字冲突本身就很罕见。 - # 我将修改测试预期:现在应该优先卖掉兵器(或者说,被识别为兵器)。 - - # 但是,如果我没有装备青云剑呢? - # resolve 还是返回 Weapon。 - # Sell 检查 weapon -> 没装备 -> 报错 "未持有装备"。 - # 而背包里其实有 "青云剑" (Item)。 - # 这就是一个潜在的 Bug/Feature change。 - # - # 如果用户输入 "青云剑",系统认为这是个 Weapon。用户没装备,系统提示"你没装备这个"。 - # 用户困惑:"但我背包里有一堆青云剑材料啊!" - # - # 为了解决这个问题,resolve_goods_by_name 可能需要更智能,或者 Sell 需要尝试多种可能。 - # 但目前的 resolve 是确定的。 - # - # 也许我应该让 Item 的优先级高于 Weapon? - # 不,通常名字是唯一的。 - # - # 让我们先按新逻辑修正测试预期。 - # 如果 resolve 返回 Weapon,且角色装备了,就会卖掉装备。 - # 所以这里断言:兵器没了,材料还在。 - + # 断言:兵器没了,材料还在。 assert avatar_in_city.weapon is None - assert avatar_in_city.get_item_quantity(fake_sword_item) == 1 + assert avatar_in_city.get_material_quantity(fake_sword_material) == 1 diff --git a/tests/test_single_choice.py b/tests/test_single_choice.py new file mode 100644 index 0000000..8e8b739 --- /dev/null +++ b/tests/test_single_choice.py @@ -0,0 +1,124 @@ +import pytest +from unittest.mock import AsyncMock, Mock, patch +from src.classes.single_choice import handle_item_exchange + +# Mocks for types +class MockAvatar: + def __init__(self): + self.name = "TestAvatar" + self.weapon = None + self.auxiliary = None + self.technique = None + self.world = Mock() + self.world.static_info = {} + self.change_weapon = Mock() + self.sell_weapon = Mock(return_value=100) + self.consume_elixir = Mock() + self.sell_elixir = Mock(return_value=50) + + def get_info(self, detailed=False): + return {"name": self.name} + +class MockItem: + def __init__(self, name, item_type="weapon"): + self.name = name + self.item_type = item_type + # Weapon/Auxiliary/Elixir usually have realm or grade + self.realm = Mock() + self.realm.value = "TestRealm" + + def get_info(self, detailed=False): + return f"Info({self.name})" + +@pytest.mark.asyncio +async def test_weapon_auto_equip_no_sell_new(): + """测试:自动装备兵器(无旧兵器,不可卖新)""" + avatar = MockAvatar() + new_weapon = MockItem("NewSword", "weapon") + + # 模拟无旧兵器 + avatar.weapon = None + + swapped, msg = await handle_item_exchange( + avatar, new_weapon, "weapon", "Context", can_sell_new=False + ) + + assert swapped is True + assert "获得了TestRealm兵器『NewSword』并装备" in msg + avatar.change_weapon.assert_called_once_with(new_weapon) + +@pytest.mark.asyncio +async def test_weapon_swap_choice_A(): + """测试:替换兵器,选择 A(装备新,卖旧)""" + avatar = MockAvatar() + old_weapon = MockItem("OldSword", "weapon") + new_weapon = MockItem("NewSword", "weapon") + avatar.weapon = old_weapon + + # Mock decision to return 'A' + with patch("src.classes.single_choice.make_decision", new_callable=AsyncMock) as mock_decision: + mock_decision.return_value = "A" + + swapped, msg = await handle_item_exchange( + avatar, new_weapon, "weapon", "Context", can_sell_new=True + ) + + # 验证文案包含动词 + # call_args[0][1] is context string, check options description + call_args = mock_decision.call_args + options = call_args[0][2] # options list + opt_a_desc = options[0]["desc"] + + # 验证选项文案使用了 "装备" 和 "卖掉" + assert "装备新兵器『NewSword』" in opt_a_desc + assert "卖掉旧兵器『OldSword』" in opt_a_desc + + assert swapped is True + assert "换上了TestRealm兵器『NewSword』" in msg + avatar.sell_weapon.assert_called_once_with(old_weapon) + avatar.change_weapon.assert_called_once_with(new_weapon) + +@pytest.mark.asyncio +async def test_elixir_consume_choice_A(): + """测试:获得丹药,选择 A(服用)""" + avatar = MockAvatar() + new_elixir = MockItem("PowerPill", "elixir") + + # Mock decision to return 'A' + with patch("src.classes.single_choice.make_decision", new_callable=AsyncMock) as mock_decision: + mock_decision.return_value = "A" + + swapped, msg = await handle_item_exchange( + avatar, new_elixir, "elixir", "Context", can_sell_new=True + ) + + # 验证选项文案 + options = mock_decision.call_args[0][2] + opt_a_desc = options[0]["desc"] + # 验证使用了 "服用" + assert "服用新丹药『PowerPill』" in opt_a_desc + + assert swapped is True + # 验证结果使用了 "服用了" + assert "服用了TestRealm丹药『PowerPill』" in msg + avatar.consume_elixir.assert_called_once_with(new_elixir) + +@pytest.mark.asyncio +async def test_elixir_sell_choice_B(): + """测试:获得丹药,选择 B(卖出)""" + avatar = MockAvatar() + new_elixir = MockItem("PowerPill", "elixir") + + # Mock decision to return 'B' + with patch("src.classes.single_choice.make_decision", new_callable=AsyncMock) as mock_decision: + mock_decision.return_value = "B" + + swapped, msg = await handle_item_exchange( + avatar, new_elixir, "elixir", "Context", can_sell_new=True + ) + + assert swapped is False + assert "卖掉了新获得的PowerPill" in msg + avatar.sell_elixir.assert_called_once_with(new_elixir) + avatar.consume_elixir.assert_not_called() + diff --git a/web/src/components/game/panels/info/AvatarDetail.vue b/web/src/components/game/panels/info/AvatarDetail.vue index 0a00a0d..ca523b8 100644 --- a/web/src/components/game/panels/info/AvatarDetail.vue +++ b/web/src/components/game/panels/info/AvatarDetail.vue @@ -154,12 +154,12 @@ async function handleClearObjective() { /> - -
-
物品
+ +
+
材料