add buy action
This commit is contained in:
@@ -37,6 +37,7 @@ from .switch_weapon import SwitchWeapon
|
|||||||
from .assassinate import Assassinate
|
from .assassinate import Assassinate
|
||||||
from .move_to_direction import MoveToDirection
|
from .move_to_direction import MoveToDirection
|
||||||
from .cast import Cast
|
from .cast import Cast
|
||||||
|
from .buy import BuyItem
|
||||||
|
|
||||||
# 注册到 ActionRegistry(标注是否为实际可执行动作)
|
# 注册到 ActionRegistry(标注是否为实际可执行动作)
|
||||||
register_action(actual=False)(Action)
|
register_action(actual=False)(Action)
|
||||||
@@ -70,6 +71,7 @@ register_action(actual=True)(SwitchWeapon)
|
|||||||
register_action(actual=True)(Assassinate)
|
register_action(actual=True)(Assassinate)
|
||||||
register_action(actual=True)(MoveToDirection)
|
register_action(actual=True)(MoveToDirection)
|
||||||
register_action(actual=True)(Cast)
|
register_action(actual=True)(Cast)
|
||||||
|
register_action(actual=True)(BuyItem)
|
||||||
# Talk 已移动到 mutual_action 模块,在那里注册
|
# Talk 已移动到 mutual_action 模块,在那里注册
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -106,6 +108,7 @@ __all__ = [
|
|||||||
"Assassinate",
|
"Assassinate",
|
||||||
"MoveToDirection",
|
"MoveToDirection",
|
||||||
"Cast",
|
"Cast",
|
||||||
|
"BuyItem",
|
||||||
# Talk 已移动到 mutual_action 模块
|
# Talk 已移动到 mutual_action 模块
|
||||||
# Occupy 已移动到 mutual_action 模块
|
# Occupy 已移动到 mutual_action 模块
|
||||||
]
|
]
|
||||||
|
|||||||
116
src/classes/action/buy.py
Normal file
116
src/classes/action/buy.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Tuple, Any
|
||||||
|
|
||||||
|
from src.classes.action import InstantAction
|
||||||
|
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.prices import prices
|
||||||
|
from src.classes.normalize import normalize_item_name
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.classes.avatar import Avatar
|
||||||
|
|
||||||
|
|
||||||
|
class BuyItem(InstantAction):
|
||||||
|
"""
|
||||||
|
在城镇购买物品。
|
||||||
|
|
||||||
|
如果是丹药:购买后强制立即服用。
|
||||||
|
如果是其他物品:购买后放入背包。
|
||||||
|
"""
|
||||||
|
|
||||||
|
ACTION_NAME = "购买物品"
|
||||||
|
EMOJI = "💸"
|
||||||
|
DESC = "在城镇购买物品(丹药购买后将立即服用)"
|
||||||
|
DOABLES_REQUIREMENTS = "在城镇且金钱足够"
|
||||||
|
PARAMS = {"item_name": "str"}
|
||||||
|
|
||||||
|
def _resolve_obj(self, item_name: str) -> Tuple[Any, str, str]:
|
||||||
|
"""
|
||||||
|
解析物品名称,返回 (对象, 类型, 显示名称)。
|
||||||
|
类型字符串: "elixir", "item", "unknown"
|
||||||
|
"""
|
||||||
|
normalized_name = normalize_item_name(item_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. 尝试作为普通物品查找
|
||||||
|
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]:
|
||||||
|
region = self.avatar.tile.region
|
||||||
|
if not isinstance(region, CityRegion):
|
||||||
|
return False, "仅能在城市区域执行"
|
||||||
|
|
||||||
|
if item_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)
|
||||||
|
if obj_type == "unknown":
|
||||||
|
return False, f"未知物品: {item_name}"
|
||||||
|
|
||||||
|
# 检查价格
|
||||||
|
price = prices.get_buying_price(obj, self.avatar)
|
||||||
|
if self.avatar.magic_stone < price:
|
||||||
|
return False, f"灵石不足 (需要 {price})"
|
||||||
|
|
||||||
|
# 丹药特殊限制
|
||||||
|
if obj_type == "elixir":
|
||||||
|
elixir: Elixir = obj
|
||||||
|
|
||||||
|
# 境界限制
|
||||||
|
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, ""
|
||||||
|
|
||||||
|
def _execute(self, item_name: str) -> None:
|
||||||
|
obj, obj_type, display_name = self._resolve_obj(item_name)
|
||||||
|
if obj_type == "unknown":
|
||||||
|
return
|
||||||
|
|
||||||
|
price = prices.get_buying_price(obj, self.avatar)
|
||||||
|
self.avatar.magic_stone -= price
|
||||||
|
|
||||||
|
# 交付
|
||||||
|
if obj_type == "elixir":
|
||||||
|
self.avatar.consume_elixir(obj)
|
||||||
|
elif obj_type == "item":
|
||||||
|
self.avatar.add_item(obj)
|
||||||
|
|
||||||
|
def start(self, item_name: str) -> Event:
|
||||||
|
obj, obj_type, display_name = self._resolve_obj(item_name)
|
||||||
|
|
||||||
|
action_desc = "购买并服用了" if obj_type == "elixir" else "购买了"
|
||||||
|
price = prices.get_buying_price(obj, self.avatar) if obj else 0
|
||||||
|
|
||||||
|
return Event(
|
||||||
|
self.world.month_stamp,
|
||||||
|
f"{self.avatar.name} 在城镇花费 {price} 灵石{action_desc} {display_name}",
|
||||||
|
related_avatars=[self.avatar.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def finish(self, item_name: str) -> list[Event]:
|
||||||
|
return []
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ class Prices:
|
|||||||
from src.classes.item import Item
|
from src.classes.item import Item
|
||||||
from src.classes.weapon import Weapon
|
from src.classes.weapon import Weapon
|
||||||
from src.classes.auxiliary import Auxiliary
|
from src.classes.auxiliary import Auxiliary
|
||||||
|
from src.classes.elixir import Elixir
|
||||||
|
|
||||||
if isinstance(obj, Item):
|
if isinstance(obj, Item):
|
||||||
return self.get_item_price(obj)
|
return self.get_item_price(obj)
|
||||||
@@ -87,6 +88,8 @@ class Prices:
|
|||||||
return self.get_weapon_price(obj)
|
return self.get_weapon_price(obj)
|
||||||
elif isinstance(obj, Auxiliary):
|
elif isinstance(obj, Auxiliary):
|
||||||
return self.get_auxiliary_price(obj)
|
return self.get_auxiliary_price(obj)
|
||||||
|
elif isinstance(obj, Elixir):
|
||||||
|
return obj.price
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def get_buying_price(self, obj: Sellable, buyer: "Avatar" = None) -> int:
|
def get_buying_price(self, obj: Sellable, buyer: "Avatar" = None) -> int:
|
||||||
|
|||||||
211
tests/test_buy_action.py
Normal file
211
tests/test_buy_action.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from src.classes.action.buy import BuyItem
|
||||||
|
from src.classes.region import CityRegion, Region
|
||||||
|
from src.classes.elixir import Elixir, ElixirType, ConsumedElixir
|
||||||
|
from src.classes.item import Item
|
||||||
|
from src.classes.cultivation import Realm
|
||||||
|
from src.classes.tile import Tile, TileType
|
||||||
|
|
||||||
|
# 创建一些测试用的对象
|
||||||
|
def create_test_elixir(name, realm, price=100, elixir_id=1, effects=None):
|
||||||
|
if effects is None:
|
||||||
|
effects = {"max_hp": 10}
|
||||||
|
return Elixir(
|
||||||
|
id=elixir_id,
|
||||||
|
name=name,
|
||||||
|
realm=realm,
|
||||||
|
type=ElixirType.Breakthrough,
|
||||||
|
desc="测试丹药",
|
||||||
|
price=price,
|
||||||
|
effects=effects
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_test_item(name, realm, item_id=101):
|
||||||
|
return Item(
|
||||||
|
id=item_id,
|
||||||
|
name=name,
|
||||||
|
desc="测试物品",
|
||||||
|
realm=realm
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def avatar_in_city(dummy_avatar):
|
||||||
|
"""
|
||||||
|
修改 dummy_avatar,使其位于城市中,并给予初始资金
|
||||||
|
"""
|
||||||
|
# 模拟 Tile 和 Region
|
||||||
|
# Region init: id, name, desc, cors (default=[])
|
||||||
|
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 = 1000 # 初始资金
|
||||||
|
dummy_avatar.cultivation_progress.realm = Realm.Qi_Refinement # 练气期
|
||||||
|
dummy_avatar.elixirs = [] # 清空已服用丹药
|
||||||
|
|
||||||
|
return dummy_avatar
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_objects():
|
||||||
|
"""
|
||||||
|
Mock elixirs_by_name 和 items_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)
|
||||||
|
|
||||||
|
# elixirs_by_name 是 Dict[str, List[Elixir]]
|
||||||
|
elixirs_mock = {
|
||||||
|
"聚气丹": [test_elixir],
|
||||||
|
"筑基丹": [high_level_elixir]
|
||||||
|
}
|
||||||
|
|
||||||
|
# items_by_name 是 Dict[str, Item]
|
||||||
|
items_mock = {
|
||||||
|
"铁矿石": test_item
|
||||||
|
}
|
||||||
|
|
||||||
|
return elixirs_mock, items_mock, test_elixir, high_level_elixir, test_item
|
||||||
|
|
||||||
|
def test_buy_item_success(avatar_in_city, mock_objects):
|
||||||
|
"""测试购买普通物品成功"""
|
||||||
|
elixirs_mock, items_mock, _, _, test_item = 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)
|
||||||
|
|
||||||
|
# 1. 检查是否可购买
|
||||||
|
can_start, reason = action.can_start("铁矿石")
|
||||||
|
assert can_start is True
|
||||||
|
|
||||||
|
# 2. 执行购买
|
||||||
|
initial_money = avatar_in_city.magic_stone
|
||||||
|
# 练气期物品基础价格 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
|
||||||
|
|
||||||
|
def test_buy_elixir_success(avatar_in_city, mock_objects):
|
||||||
|
"""测试购买并服用丹药成功"""
|
||||||
|
elixirs_mock, items_mock, test_elixir, _, _ = 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)
|
||||||
|
|
||||||
|
can_start, reason = action.can_start("聚气丹")
|
||||||
|
assert can_start is True
|
||||||
|
|
||||||
|
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.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
|
||||||
|
|
||||||
|
# 确保不在城市 (dummy_avatar 默认在 (0,0) PLAIN)
|
||||||
|
assert not isinstance(dummy_avatar.tile.region, CityRegion)
|
||||||
|
|
||||||
|
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)
|
||||||
|
can_start, reason = action.can_start("铁矿石")
|
||||||
|
|
||||||
|
assert can_start is False
|
||||||
|
assert "仅能在城市" in reason
|
||||||
|
|
||||||
|
def test_buy_fail_no_money(avatar_in_city, mock_objects):
|
||||||
|
"""测试没钱无法购买"""
|
||||||
|
elixirs_mock, items_mock, _, _, test_item = mock_objects
|
||||||
|
|
||||||
|
avatar_in_city.magic_stone = 0 # 没钱
|
||||||
|
|
||||||
|
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)
|
||||||
|
can_start, reason = action.can_start("铁矿石")
|
||||||
|
|
||||||
|
assert can_start is False
|
||||||
|
assert "灵石不足" in reason
|
||||||
|
|
||||||
|
def test_buy_fail_unknown_item(avatar_in_city, mock_objects):
|
||||||
|
"""测试未知物品"""
|
||||||
|
elixirs_mock, items_mock, _, _, _ = 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)
|
||||||
|
can_start, reason = action.can_start("不存在的东西")
|
||||||
|
|
||||||
|
assert can_start is False
|
||||||
|
assert "未知物品" in reason
|
||||||
|
|
||||||
|
def test_buy_elixir_fail_realm_too_low(avatar_in_city, mock_objects):
|
||||||
|
"""测试境界不足无法购买丹药"""
|
||||||
|
elixirs_mock, items_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.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)
|
||||||
|
can_start, reason = action.can_start("筑基丹")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 先服用一个
|
||||||
|
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.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)
|
||||||
|
can_start, reason = action.can_start("聚气丹")
|
||||||
|
|
||||||
|
assert can_start is False
|
||||||
|
assert "药效尚存" in reason
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user