rename item -> material & refactor buying action

This commit is contained in:
bridge
2026-01-07 22:43:26 +08:00
parent 73c50286b7
commit b2a021bf8a
43 changed files with 795 additions and 580 deletions

View File

@@ -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

View File

@@ -133,7 +133,7 @@
- [ ] 角色间相性
- [ ] 生活技能
- ✅ 铸造
- [ ] 丹药
- ✅ 炼丹
- [ ] 种植
- [ ] 饲养
- [ ] 技能可进化
@@ -270,7 +270,7 @@
## 贡献者
* Aku, 世界观\玩法设计与讨论
* [@xzhseh](https://github.com/xzhseh), 贡献部分前端代码
* [@xzhseh](https://github.com/xzhseh), 贡献代码
## 致谢
- 参考了ai life engine部分ui

View File

@@ -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]
)

View File

@@ -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(

View File

@@ -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]
)]

View File

@@ -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]
)]

View File

@@ -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]
)]

View File

@@ -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炼丹成功

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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 = []

View File

@@ -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

View File

@@ -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,

View File

@@ -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 - 额外移动步数

View File

@@ -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

View File

@@ -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()

View File

@@ -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}

57
src/classes/material.py Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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):

View File

@@ -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}"

View File

@@ -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避免潜在的递归引用导致的崩溃且性能更好

View File

@@ -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", {})

View File

@@ -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,

View File

@@ -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}

View File

@@ -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

View File

@@ -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
1 id name desc stage_id item_ids material_ids
2 该动物对应的物品ID
3 1 灵兔 天性机敏的灵性兔子,毛色雪白,蕴含微弱灵力,性情温和易驯服 1 1
4 2 魔狼 凶猛的魔性狼族,体型巨大,拥有强大的魔力和锋利的爪牙 2 2

View File

@@ -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
1 id name rarity effects desc duration_years
2 1 紫气东来 R {extra_cultivate_exp: 15} 天地灵气充沛,修士修行速度大增,修行欲望提高 5
3 2 金煞之年 R {extra_battle_strength_points: 3} 金煞充盈天地肃杀,修士更大可能嗜血而相互攻伐 5
4 3 木灵盛世 R {extra_harvest_items: 2, extra_hp_recovery_rate: 0.5} {extra_harvest_materials: 2, extra_hp_recovery_rate: 0.5} 木德滋养生机盎然,采集收获倍增且伤势恢复极快,宜四处搜罗天材地宝 5
5 4 水德之纪 R {extra_cultivate_exp: 20, cultivate_duration_reduction: 0.2} 水行流转通达无碍,修炼效率与速度双重提升,正是闭关苦修的大好时机 5
6 5 火劫时代 R {extra_battle_strength_points: 5, extra_max_lifespan: -20} 天火燃烧劫数降临,战力暴涨但寿元流逝,当速战速决以命搏天 5
7 6 土厚之世 R {damage_reduction: 0.15, extra_max_hp: 150} 土德厚重载物无疆,身躯坚韧血气充盈,可无惧强敌正面争锋 5

View File

@@ -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
1 id name desc stage_id item_ids material_ids
2 该矿脉对应的物品ID
3 1 玄铁矿脉 蕴含玄铁的普通矿脉,常见于浅层地表 1 101 101
4 2 赤铜矿脉 深埋地下的赤铜矿脉,周围往往伴生火热之气 2 102 102

View File

@@ -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}
1 id name exclusion_names desc rarity condition effects
6 4 冒险 怠惰;惜命 你总是会冒险,喜欢刺激,总想放手一搏。 N
7 5 随性 理性;极端正义;极端邪恶 你总是会随机应变,性子到哪里了就是哪里,没有一定之规。 N
8 6 贪财 你对灵石和财富有着强烈的渴望。 N {extra_item_sell_price_multiplier: 0.1}
9 7 采集者 喜欢在山林中寻找各种奇花异草和灵药,对植物有着敏锐的直觉和深厚的兴趣。你认为大自然的恩赐需要用心去发现和珍惜。 R {extra_harvest_items: 1} {extra_harvest_materials: 1}
10 8 猎人 享受在野外追踪猎物的刺激感,对各种动物的习性了如指掌,喜欢捕猎野兽。情况允许也会御兽。 R {extra_hunt_items: 1} {extra_hunt_materials: 1}
11 9 沉思 无常 你总是会深思熟虑,思考问题比较有哲理。 N
12 10 惜命 冒险;极端正义;极端邪恶 你总是会珍惜自己的生命,不会轻易冒险。 R {extra_escape_success_rate: 0.15}
13 11 友爱 孤僻;淡漠;好斗;复仇;极端正义;极端邪恶 你重视同伴与和谐,乐于助人,倾向通过协作与沟通化解矛盾。 N
62 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}]
63 61 炼器师 好斗 精通炼器之道,对材料敏锐,擅长铸造法宝。你认为法宝是修行的关键,战斗并非你的专长。 R {extra_cast_success_rate: 0.15}
64 62 情绪化 理性;淡漠 你的情绪波动很大,极易受外界事件影响而改变心情,做事也更随心所欲。 N
65 63 矿工 怠惰 擅长勘探挖掘,对矿脉有着独特的直觉。你认为地下的宝藏才是最实在的财富。 R {extra_mine_items: 1} {extra_mine_materials: 1}

View File

@@ -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
1 id name desc stage_id item_ids material_ids
2 该植物对应的物品ID
3 1 奇草 生长在灵气充沛之地的奇异草药,叶片呈淡蓝色,具有神奇的治愈效果 1 10
4 2 灵木 千年古树吸收天地灵气而成,木质坚硬且蕴含浓郁灵力 2 11

View File

@@ -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}",楼主;掌刑使;影刺;探子
1 id name desc member_act_style alignment weight preferred_weapon effects rank_names
2 宗门名称与描述 宗门成员行事风格 阵营(正/中立/邪) 权重(默认1) 倾向兵器类型 effects(JSON形式,支持宽松格式,见effects.py说明) 自定义职位(掌门;长老;内门;外门)
3 1 明心剑宗 通玄界东方第一宗,以无上剑道称雄于世。云纹禁制为不传心法。【剑道专精】作为剑修,你使用剑类兵器时战力惊人,且在剑道上的感悟速度远超常人。 清明克己,行止如一。重剑与心法并重,讲究明心见性。 1 {extra_battle_strength_points: 3, extra_weapon_proficiency_gain: 0.5}
4 2 百兽宗 以驯养灵兽闻名,豢养各种妖兽灵怪为战力。【御兽大师】你拥有独特的御兽天赋,捕捉妖兽对你来说轻而易举,善于驱使兽群为你而战。 言语直接,重视力量与血性,崇尚狩猎与搏斗。 1 {extra_catch_success_rate: 0.25, extra_hunt_items: 1} {extra_catch_success_rate: 0.25, extra_hunt_materials: 1} 谷主;供奉;驭兽师;扈从
5 3 水镜宗 正道十宗之一,实则严守中立。拥有仙界异宝"彻天水镜"可预知未来。【趋吉避凶】你拥有超乎常人的直觉,视野开阔,且极易在探索中发现奇遇。 处事冷静圆融,喜以柔克刚,擅借力与反制。 中立 1 {extra_observation_radius: 2, extra_fortune_probability: 0.002, extra_refine_success_rate: 0.05} 镜主;掌镜人;传人;侍镜
6 4 冥王宗 行走幽冥之道,术法阴冷狠厉。【通幽】你修行幽冥之法,心志坚定,突破瓶颈时心无杂念,成功率更高。 言辞冷厉少情,敬畏因果而不惧杀伐,偏向效率与结果。 1 {extra_breakthrough_success_rate: 0.1} 殿主;判官;无常;鬼卒
7 5 朱勾宗 邪宗大派。以炼器、机关、暗杀闻名于世,素来阴毒冷僻。【暗杀专家】你精通潜伏与刺杀,对于强敌,避开正面交锋、伺机暗杀往往是你最佳的制胜之道。 直面欲望与代价,不惧黑暗,以攻伐见长。 1 暗器 {extra_assassinate_success_rate: 0.15, extra_battle_strength_points: 1, extra_cast_success_rate: 0.05} 楼主;掌刑使;影刺;探子

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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__ 判断,
# 纯单元测试建议主要覆盖逻辑分支。集成测试覆盖实际类。

View File

@@ -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)

View File

@@ -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

124
tests/test_single_choice.py Normal file
View File

@@ -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()

View File

@@ -154,12 +154,12 @@ async function handleClearObjective() {
/>
</div>
<!-- Items -->
<div class="section" v-if="data.items?.length">
<div class="section-title">物品</div>
<!-- Materials -->
<div class="section" v-if="data.materials?.length">
<div class="section-title">材料</div>
<div class="list-container">
<EntityRow
v-for="item in data.items"
v-for="item in data.materials"
:key="item.name"
:item="item"
:meta="`x${item.count}`"

View File

@@ -30,7 +30,7 @@ export interface EffectEntity extends EntityBase {
hq_desc?: string;
}
export interface Item extends EffectEntity {
export interface Material extends EffectEntity {
count: number;
}
@@ -93,7 +93,7 @@ export interface AvatarDetail extends EntityBase {
spirit_animal?: EffectEntity;
// 列表数据
items: Item[];
materials: Material[];
relations: RelationInfo[];
// 附加信息