refactor buy and sell

This commit is contained in:
bridge
2026-01-05 23:37:52 +08:00
parent 4bff8e503b
commit 6873746d29
6 changed files with 358 additions and 90 deletions

View File

@@ -25,7 +25,7 @@ from .breakthrough import Breakthrough
from .play import Play
from .hunt import Hunt
from .harvest import Harvest
from .sell import SellItems
from .sell import Sell
from .attack import Attack
from .plunder_mortals import PlunderMortals
from .help_mortals import HelpMortals
@@ -37,7 +37,7 @@ from .switch_weapon import SwitchWeapon
from .assassinate import Assassinate
from .move_to_direction import MoveToDirection
from .cast import Cast
from .buy import BuyItem
from .buy import Buy
# 注册到 ActionRegistry标注是否为实际可执行动作
register_action(actual=False)(Action)
@@ -59,7 +59,7 @@ register_action(actual=True)(Breakthrough)
register_action(actual=True)(Play)
register_action(actual=True)(Hunt)
register_action(actual=True)(Harvest)
register_action(actual=True)(SellItems)
register_action(actual=True)(Sell)
register_action(actual=False)(Attack)
register_action(actual=True)(PlunderMortals)
register_action(actual=True)(HelpMortals)
@@ -71,7 +71,7 @@ register_action(actual=True)(SwitchWeapon)
register_action(actual=True)(Assassinate)
register_action(actual=True)(MoveToDirection)
register_action(actual=True)(Cast)
register_action(actual=True)(BuyItem)
register_action(actual=True)(Buy)
# Talk 已移动到 mutual_action 模块,在那里注册
__all__ = [
@@ -96,7 +96,7 @@ __all__ = [
"Play",
"Hunt",
"Harvest",
"SellItems",
"Sell",
"Attack",
"PlunderMortals",
"HelpMortals",
@@ -108,7 +108,7 @@ __all__ = [
"Assassinate",
"MoveToDirection",
"Cast",
"BuyItem",
"Buy",
# Talk 已移动到 mutual_action 模块
# Occupy 已移动到 mutual_action 模块
]

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import copy
from typing import TYPE_CHECKING, Tuple, Any
from src.classes.action import InstantAction
@@ -7,62 +8,75 @@ from src.classes.event import Event
from src.classes.region import CityRegion
from src.classes.elixir import elixirs_by_name, Elixir
from src.classes.item import items_by_name, Item
from src.classes.weapon import weapons_by_name, Weapon
from src.classes.auxiliary import auxiliaries_by_name, Auxiliary
from src.classes.prices import prices
from src.classes.normalize import normalize_item_name
from src.classes.normalize import normalize_goods_name
if TYPE_CHECKING:
from src.classes.avatar import Avatar
class BuyItem(InstantAction):
class Buy(InstantAction):
"""
在城镇购买物品。
如果是丹药:购买后强制立即服用。
如果是其他物品:购买后放入背包。
如果是装备(兵器/法宝):购买后直接装备(替换原有装备)。
"""
ACTION_NAME = "购买物品"
ACTION_NAME = "购买"
EMOJI = "💸"
DESC = "在城镇购买物品(丹药购买后将立即服用)"
elixir_names_str = ", ".join(elixirs_by_name.keys())
DESC = f"在城镇购买物品/装备(丹药购买后将立即服用)。可选丹药:{elixir_names_str}"
DOABLES_REQUIREMENTS = "在城镇且金钱足够"
PARAMS = {"item_name": "str"}
PARAMS = {"target_name": "str"}
def _resolve_obj(self, item_name: str) -> Tuple[Any, str, str]:
def _resolve_obj(self, target_name: str) -> Tuple[Any, str, str]:
"""
解析物品名称,返回 (对象, 类型, 显示名称)。
类型字符串: "elixir", "item", "unknown"
类型字符串: "elixir", "item", "weapon", "auxiliary", "unknown"
"""
normalized_name = normalize_item_name(item_name)
normalized_name = normalize_goods_name(target_name)
# 1. 尝试作为丹药查找
if normalized_name in elixirs_by_name:
# 这里的 elixirs_by_name 返回的是 list我们取第一个作为购买对象
# TODO: 如果未来有同名不同级的丹药,这里可能需要更精确的逻辑
elixir = elixirs_by_name[normalized_name][0]
return elixir, "elixir", elixir.name
# 2. 尝试作为普通物品查找
# 2. 尝试作为兵器查找
weapon = weapons_by_name.get(normalized_name)
if weapon:
return weapon, "weapon", weapon.name
# 3. 尝试作为辅助装备查找
auxiliary = auxiliaries_by_name.get(normalized_name)
if auxiliary:
return auxiliary, "auxiliary", auxiliary.name
# 4. 尝试作为普通物品查找
item = items_by_name.get(normalized_name)
if item:
return item, "item", item.name
return None, "unknown", normalized_name
def can_start(self, item_name: str | None = None) -> tuple[bool, str]:
def can_start(self, target_name: str | None = None) -> tuple[bool, str]:
region = self.avatar.tile.region
if not isinstance(region, CityRegion):
return False, "仅能在城市区域执行"
if item_name is None:
if target_name is None:
# 用于动作空间检查
# 理论上只要有钱就可以买东西,这里简单判定金钱>0
ok = self.avatar.magic_stone > 0
return (ok, "" if ok else "身无分文")
obj, obj_type, display_name = self._resolve_obj(item_name)
obj, obj_type, display_name = self._resolve_obj(target_name)
if obj_type == "unknown":
return False, f"未知物品: {item_name}"
return False, f"未知物品: {target_name}"
# 检查价格
price = prices.get_buying_price(obj, self.avatar)
@@ -85,8 +99,8 @@ class BuyItem(InstantAction):
return True, ""
def _execute(self, item_name: str) -> None:
obj, obj_type, display_name = self._resolve_obj(item_name)
def _execute(self, target_name: str) -> None:
obj, obj_type, display_name = self._resolve_obj(target_name)
if obj_type == "unknown":
return
@@ -98,11 +112,25 @@ class BuyItem(InstantAction):
self.avatar.consume_elixir(obj)
elif obj_type == "item":
self.avatar.add_item(obj)
elif obj_type == "weapon":
# 购买装备需要深拷贝,因为装备有独立状态
new_weapon = copy.deepcopy(obj)
self.avatar.change_weapon(new_weapon)
elif obj_type == "auxiliary":
# 购买装备需要深拷贝
new_auxiliary = copy.deepcopy(obj)
self.avatar.change_auxiliary(new_auxiliary)
def start(self, item_name: str) -> Event:
obj, obj_type, display_name = self._resolve_obj(item_name)
def start(self, target_name: str) -> Event:
obj, obj_type, display_name = self._resolve_obj(target_name)
action_desc = "购买并服用了" if obj_type == "elixir" else "购买了"
if obj_type == "elixir":
action_desc = "购买并服用了"
elif obj_type in ["weapon", "auxiliary"]:
action_desc = "购买并装备了"
else:
action_desc = "购买了"
price = prices.get_buying_price(obj, self.avatar) if obj else 0
return Event(
@@ -111,6 +139,5 @@ class BuyItem(InstantAction):
related_avatars=[self.avatar.id]
)
async def finish(self, item_name: str) -> list[Event]:
async def finish(self, target_name: str) -> list[Event]:
return []

View File

@@ -1,70 +1,94 @@
from __future__ import annotations
from typing import Tuple, Any
from src.classes.action import InstantAction
from src.classes.event import Event
from src.classes.region import CityRegion
from src.classes.item import items_by_name
from src.classes.normalize import normalize_item_name
from src.classes.normalize import normalize_goods_name
class SellItems(InstantAction):
class Sell(InstantAction):
"""
在城镇出售指定名称的物品,一次性卖出持有的全部数量
收益通过 avatar.sell_item() 结算
在城镇出售指定名称的物品/装备
如果是材料:一次性卖出持有的全部数量
如果是装备:卖出当前装备的(如果是当前装备)。
收益通过 avatar.sell_item() / sell_weapon() / sell_auxiliary() 结算。
"""
ACTION_NAME = "出售物品"
ACTION_NAME = "出售"
EMOJI = "💰"
DESC = "在城镇出售持有的某类物品的全部"
DOABLES_REQUIREMENTS = "在城镇且背包非空"
PARAMS = {"item_name": "str"}
DESC = "在城镇出售持有的某类物品的全部,或当前装备"
DOABLES_REQUIREMENTS = "在城镇且持有可出售物品/装备"
PARAMS = {"target_name": "str"}
def _execute(self, item_name: str) -> None:
def _resolve_obj(self, target_name: str) -> Tuple[Any, str, str]:
"""
解析出售对象
返回: (对象, 类型, 显示名称)
类型: "item", "weapon", "auxiliary", "none"
"""
normalized_name = normalize_goods_name(target_name)
# 1. 检查背包材料
item = items_by_name.get(normalized_name)
if item and self.avatar.get_item_quantity(item) > 0:
return item, "item", item.name
# 2. 检查当前兵器
if self.avatar.weapon and normalize_goods_name(self.avatar.weapon.name) == normalized_name:
return self.avatar.weapon, "weapon", self.avatar.weapon.name
# 3. 检查当前辅助装备
if self.avatar.auxiliary and normalize_goods_name(self.avatar.auxiliary.name) == normalized_name:
return self.avatar.auxiliary, "auxiliary", self.avatar.auxiliary.name
return None, "none", normalized_name
def _execute(self, target_name: str) -> None:
region = self.avatar.tile.region
if not isinstance(region, CityRegion):
return
# 规范化物品名称(去除境界等附加信息)
normalized_name = normalize_item_name(item_name)
obj, obj_type, _ = self._resolve_obj(target_name)
# 找到物品
item = items_by_name.get(normalized_name)
if item is None:
return
if obj_type == "item":
quantity = self.avatar.get_item_quantity(obj)
self.avatar.sell_item(obj, quantity)
elif obj_type == "weapon":
self.avatar.sell_weapon(obj)
self.avatar.change_weapon(None) # 卖出后卸下
elif obj_type == "auxiliary":
self.avatar.sell_auxiliary(obj)
self.avatar.change_auxiliary(None) # 卖出后卸下
# 检查持有数量
quantity = self.avatar.get_item_quantity(item)
if quantity <= 0:
return
# 通过统一接口出售
self.avatar.sell_item(item, quantity)
def can_start(self, item_name: str | None = None) -> tuple[bool, str]:
def can_start(self, target_name: str | None = None) -> tuple[bool, str]:
region = self.avatar.tile.region
if not isinstance(region, CityRegion):
return False, "仅能在城市区域执行"
if item_name is None:
# 用于动作空间:只要背包非空即可
ok = bool(self.avatar.items)
return (ok, "" if ok else "背包为空,无可出售物品")
if target_name is None:
# 用于动作空间:只要有任何可卖东西即可
has_items = bool(self.avatar.items)
has_weapon = self.avatar.weapon is not None
has_auxiliary = self.avatar.auxiliary is not None
ok = has_items or has_weapon or has_auxiliary
return (ok, "" if ok else "背包为空且无装备,无可出售物品")
# 规范化物品名称
normalized_name = normalize_item_name(item_name)
item = items_by_name.get(normalized_name)
if item is None:
return False, f"未知物品: {item_name}"
ok = self.avatar.get_item_quantity(item) > 0
return (ok, "" if ok else "该物品数量为0")
obj, obj_type, _ = self._resolve_obj(target_name)
if obj_type == "none":
return False, f"未持有物品/装备: {target_name}"
return True, ""
def start(self, item_name: str) -> Event:
# 规范化物品名称用于显示(与执行逻辑一致)
normalized_name = normalize_item_name(item_name)
# 尝试获取标准物品名(如果存在)
item = items_by_name.get(normalized_name)
display_name = item.name if item is not None else normalized_name
return Event(self.world.month_stamp, f"{self.avatar.name} 在城镇出售 {display_name}", related_avatars=[self.avatar.id])
def start(self, target_name: str) -> Event:
obj, obj_type, display_name = self._resolve_obj(target_name)
return Event(
self.world.month_stamp,
f"{self.avatar.name} 在城镇出售了 {display_name}",
related_avatars=[self.avatar.id]
)
async def finish(self, item_name: str) -> list[Event]:
async def finish(self, target_name: str) -> list[Event]:
return []

View File

@@ -105,28 +105,30 @@ def normalize_region_name(name: str) -> str:
return s
def normalize_item_name(name: str) -> str:
def normalize_goods_name(name: str) -> str:
"""
规范化品名称:去除境界标识等附加信息
规范化品名称(包括物品、兵器、法宝、丹药)
处理格式
- "青云鹿角 -(练气)" -> "青云鹿角"
- "风速马皮(筑基)" -> "风速马皮"
统一逻辑
1. 移除括号及内容(如境界、类型说明)
2. 移除尾部的 " -" 标记(常见于材料生成名)
3. 移除首尾空格
Args:
name: 原始品名称,可能包含境界等附加信息
name: 原始品名称
Returns:
规范化后的品名称
规范化后的品名称
Examples:
>>> normalize_item_name("青云鹿角 -(练气)")
>>> normalize_goods_name("青云鹿角 -(练气)") # item
'青云鹿角'
>>> normalize_item_name("风速马皮(筑基)")
'风速马皮'
>>> normalize_goods_name("精铁剑(练气)") # weapon
'精铁剑'
>>> normalize_goods_name("聚气丹(练气)") # elixir
'聚气丹'
"""
s = _remove_parentheses(name)
# 额外处理:去除尾部的 " -" 标记
s = s.rstrip(" -").strip()
return s

View File

@@ -1,6 +1,6 @@
import pytest
from unittest.mock import patch, MagicMock
from src.classes.action.buy import BuyItem
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
@@ -76,7 +76,7 @@ def test_buy_item_success(avatar_in_city, mock_objects):
with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \
patch("src.classes.action.buy.items_by_name", items_mock):
action = BuyItem(avatar_in_city, avatar_in_city.world)
action = Buy(avatar_in_city, avatar_in_city.world)
# 1. 检查是否可购买
can_start, reason = action.can_start("铁矿石")
@@ -100,7 +100,7 @@ def test_buy_elixir_success(avatar_in_city, mock_objects):
with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \
patch("src.classes.action.buy.items_by_name", items_mock):
action = BuyItem(avatar_in_city, avatar_in_city.world)
action = Buy(avatar_in_city, avatar_in_city.world)
can_start, reason = action.can_start("聚气丹")
assert can_start is True
@@ -131,7 +131,7 @@ def test_buy_fail_not_in_city(dummy_avatar, mock_objects):
with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \
patch("src.classes.action.buy.items_by_name", items_mock):
action = BuyItem(dummy_avatar, dummy_avatar.world)
action = Buy(dummy_avatar, dummy_avatar.world)
can_start, reason = action.can_start("铁矿石")
assert can_start is False
@@ -146,7 +146,7 @@ def test_buy_fail_no_money(avatar_in_city, mock_objects):
with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \
patch("src.classes.action.buy.items_by_name", items_mock):
action = BuyItem(avatar_in_city, avatar_in_city.world)
action = Buy(avatar_in_city, avatar_in_city.world)
can_start, reason = action.can_start("铁矿石")
assert can_start is False
@@ -159,7 +159,7 @@ def test_buy_fail_unknown_item(avatar_in_city, mock_objects):
with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \
patch("src.classes.action.buy.items_by_name", items_mock):
action = BuyItem(avatar_in_city, avatar_in_city.world)
action = Buy(avatar_in_city, avatar_in_city.world)
can_start, reason = action.can_start("不存在的东西")
assert can_start is False
@@ -179,7 +179,7 @@ def test_buy_elixir_fail_realm_too_low(avatar_in_city, mock_objects):
with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \
patch("src.classes.action.buy.items_by_name", items_mock):
action = BuyItem(avatar_in_city, avatar_in_city.world)
action = Buy(avatar_in_city, avatar_in_city.world)
can_start, reason = action.can_start("筑基丹")
assert can_start is False
@@ -202,7 +202,7 @@ def test_buy_elixir_fail_duplicate_active(avatar_in_city, mock_objects):
with patch("src.classes.action.buy.elixirs_by_name", elixirs_mock), \
patch("src.classes.action.buy.items_by_name", items_mock):
action = BuyItem(avatar_in_city, avatar_in_city.world)
action = Buy(avatar_in_city, avatar_in_city.world)
can_start, reason = action.can_start("聚气丹")
assert can_start is False

215
tests/test_sell_action.py Normal file
View File

@@ -0,0 +1,215 @@
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.weapon import Weapon
from src.classes.auxiliary import Auxiliary
from src.classes.cultivation import Realm
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,
name=name,
desc="测试物品",
realm=realm
)
def create_test_weapon(name, realm, weapon_id=201):
return Weapon(
id=weapon_id,
name=name,
weapon_type=WeaponType.SWORD,
realm=realm,
desc="测试兵器",
effects={},
effect_desc=""
)
def create_test_auxiliary(name, realm, aux_id=301):
return Auxiliary(
id=aux_id,
name=name,
realm=realm,
desc="测试法宝",
effects={},
effect_desc=""
)
@pytest.fixture
def avatar_in_city(dummy_avatar):
"""
修改 dummy_avatar使其位于城市中并给予初始状态
"""
city_region = CityRegion(id=1, name="TestCity", desc="测试城市")
tile = Tile(0, 0, TileType.CITY)
tile.region = city_region
dummy_avatar.tile = tile
dummy_avatar.magic_stone = 0
dummy_avatar.items = {}
dummy_avatar.weapon = None
dummy_avatar.auxiliary = None
return dummy_avatar
@pytest.fixture
def mock_sell_objects():
"""
Mock items_by_name 并提供测试对象
"""
test_item = create_test_item("铁矿石", Realm.Qi_Refinement)
test_weapon = create_test_weapon("青云剑", Realm.Qi_Refinement)
test_auxiliary = create_test_auxiliary("聚灵珠", Realm.Qi_Refinement)
items_mock = {
"铁矿石": test_item
}
return items_mock, test_item, test_weapon, test_auxiliary
def test_sell_item_success(avatar_in_city, mock_sell_objects):
"""测试出售普通物品成功"""
items_mock, test_item, _, _ = mock_sell_objects
# 给角色添加物品
avatar_in_city.add_item(test_item, quantity=5)
with patch("src.classes.action.sell.items_by_name", items_mock):
action = Sell(avatar_in_city, avatar_in_city.world)
# 1. 检查是否可出售
can_start, reason = action.can_start("铁矿石")
assert can_start is True
# 2. 执行出售
# 练气期物品基础价格 10卖出倍率默认为 1.0 -> 单价 10
# 卖出全部 5 个 -> 总价 50
initial_money = avatar_in_city.magic_stone
expected_income = 50
action._execute("铁矿石")
# 3. 验证结果
assert avatar_in_city.magic_stone == initial_money + expected_income
assert avatar_in_city.get_item_quantity(test_item) == 0
def test_sell_weapon_success(avatar_in_city, mock_sell_objects):
"""测试出售当前兵器成功"""
items_mock, _, test_weapon, _ = mock_sell_objects
# 装备兵器
avatar_in_city.weapon = test_weapon
with patch("src.classes.action.sell.items_by_name", items_mock):
action = Sell(avatar_in_city, avatar_in_city.world)
# 1. 检查是否可出售
can_start, reason = action.can_start("青云剑")
assert can_start is True
# 2. 执行出售
# 练气期兵器基础价格 100卖出倍率 1.0 -> 100
# 注意Prices.WEAPON_PRICES[Realm.Qi_Refinement] 实际值需确认,假设是 default 100 或 mock
# 根据 prices.py: WEAPON_PRICES = {Realm.Qi_Refinement: 10...}
# 等等prices.py 里 Qi_Refinement 兵器是 10 吗?
# 让我们 check prices.py 的内容:
# Realm.Qi_Refinement: 10 (ITEM_PRICES)
# Realm.Qi_Refinement: 10 (WEAPON_PRICES)
# Realm.Qi_Refinement: 10 (AUXILIARY_PRICES)
# 看来练气期都是 10。
expected_income = 10
action._execute("青云剑")
# 3. 验证结果
assert avatar_in_city.magic_stone == expected_income
assert avatar_in_city.weapon is None
def test_sell_auxiliary_success(avatar_in_city, mock_sell_objects):
"""测试出售当前法宝成功"""
items_mock, _, _, test_auxiliary = mock_sell_objects
# 装备法宝
avatar_in_city.auxiliary = test_auxiliary
with patch("src.classes.action.sell.items_by_name", items_mock):
action = Sell(avatar_in_city, avatar_in_city.world)
can_start, reason = action.can_start("聚灵珠")
assert can_start is True
# 练气期辅助装备也是 10
expected_income = 10
action._execute("聚灵珠")
assert avatar_in_city.magic_stone == expected_income
assert avatar_in_city.auxiliary is None
def test_sell_fail_not_in_city(dummy_avatar, mock_sell_objects):
"""测试不在城市无法出售"""
items_mock, test_item, _, _ = mock_sell_objects
# 确保不在城市
assert not isinstance(dummy_avatar.tile.region, CityRegion)
dummy_avatar.add_item(test_item, 1)
with patch("src.classes.action.sell.items_by_name", items_mock):
action = Sell(dummy_avatar, dummy_avatar.world)
can_start, reason = action.can_start("铁矿石")
assert can_start is False
assert "仅能在城市" in reason
def test_sell_fail_no_item(avatar_in_city, mock_sell_objects):
"""测试未持有该物品"""
items_mock, _, _, _ = mock_sell_objects
# 背包为空,无装备
with patch("src.classes.action.sell.items_by_name", items_mock):
action = Sell(avatar_in_city, avatar_in_city.world)
can_start, reason = action.can_start("铁矿石")
assert can_start is False
assert "未持有物品/装备" in reason
def test_sell_fail_unknown_name(avatar_in_city, mock_sell_objects):
"""测试未知物品名称"""
items_mock, _, _, _ = mock_sell_objects
with patch("src.classes.action.sell.items_by_name", items_mock):
action = Sell(avatar_in_city, avatar_in_city.world)
can_start, reason = action.can_start("不存在的神器")
assert can_start is False
assert "未持有物品/装备" in reason
def test_sell_priority(avatar_in_city, mock_sell_objects):
"""测试物品优先级:同名时优先卖背包里的材料"""
items_mock, test_item, test_weapon, _ = mock_sell_objects
# 构造一个同名的兵器和材料(虽然逻辑上不太可能,但测试代码健壮性)
# 假设 items_mock 里有一个 "青云剑" 的材料
fake_sword_item = create_test_item("青云剑", Realm.Qi_Refinement)
items_mock["青云剑"] = fake_sword_item
# 角色同时拥有该材料和该兵器
avatar_in_city.add_item(fake_sword_item, 1)
avatar_in_city.weapon = test_weapon # name也是 "青云剑"
with patch("src.classes.action.sell.items_by_name", items_mock):
action = Sell(avatar_in_city, avatar_in_city.world)
# 执行出售
action._execute("青云剑")
# 应该优先卖掉了材料
assert avatar_in_city.get_item_quantity(fake_sword_item) == 0
assert avatar_in_city.weapon is not None # 兵器还在