add buy action

This commit is contained in:
bridge
2026-01-05 23:16:58 +08:00
parent 8d7e11b021
commit 4bff8e503b
4 changed files with 333 additions and 0 deletions

View File

@@ -37,6 +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
# 注册到 ActionRegistry标注是否为实际可执行动作
register_action(actual=False)(Action)
@@ -70,6 +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)
# Talk 已移动到 mutual_action 模块,在那里注册
__all__ = [
@@ -106,6 +108,7 @@ __all__ = [
"Assassinate",
"MoveToDirection",
"Cast",
"BuyItem",
# Talk 已移动到 mutual_action 模块
# Occupy 已移动到 mutual_action 模块
]

116
src/classes/action/buy.py Normal file
View 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 []

View File

@@ -80,6 +80,7 @@ class Prices:
from src.classes.item import Item
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)
@@ -87,6 +88,8 @@ class Prices:
return self.get_weapon_price(obj)
elif isinstance(obj, Auxiliary):
return self.get_auxiliary_price(obj)
elif isinstance(obj, Elixir):
return obj.price
return 0
def get_buying_price(self, obj: Sellable, buyer: "Avatar" = None) -> int:

211
tests/test_buy_action.py Normal file
View 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