diff --git a/src/classes/action/buy.py b/src/classes/action/buy.py index c00b8a7..69f9319 100644 --- a/src/classes/action/buy.py +++ b/src/classes/action/buy.py @@ -5,9 +5,8 @@ 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 Elixir, get_elixirs_by_realm +from src.classes.elixir import Elixir 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.material import Material @@ -28,8 +27,7 @@ class Buy(InstantAction): ACTION_NAME = "购买" EMOJI = "💸" - elixir_names_str = ", ".join([e.name for e in get_elixirs_by_realm(Realm.Qi_Refinement)]) - DESC = f"在城镇购买物品/装备(丹药购买后将立即服用)。可选丹药:{elixir_names_str}" + DESC = f"在城镇购买物品/装备/丹药。" DOABLES_REQUIREMENTS = "在城镇且金钱足够" PARAMS = {"target_name": "str"} @@ -42,6 +40,15 @@ class Buy(InstantAction): if not res.is_valid: return False, f"未知物品: {target_name}" + # 检查商店是否售卖 + # 必须是 StoreMixin (CityRegion 混入了 StoreMixin) + if hasattr(region, "is_selling"): + if not region.is_selling(res.obj.name): + return False, f"{region.name} 不出售 {res.obj.name}" + else: + # 如果不是商店区域(虽然前面已经检查了 CityRegion,但为了安全) + return False, "该区域没有商店" + # 核心逻辑委托给 Avatar return self.avatar.can_buy_item(res.obj) diff --git a/src/classes/auxiliary.py b/src/classes/auxiliary.py index 513d481..534df54 100644 --- a/src/classes/auxiliary.py +++ b/src/classes/auxiliary.py @@ -56,6 +56,7 @@ class Auxiliary(Item): full_desc = f"{full_desc} (已吞噬魂魄:{souls})" return { + "id": str(self.id), "name": self.name, "desc": full_desc, "grade": self.realm.value, diff --git a/src/classes/avatar/inventory_mixin.py b/src/classes/avatar/inventory_mixin.py index 1613f78..3e37797 100644 --- a/src/classes/avatar/inventory_mixin.py +++ b/src/classes/avatar/inventory_mixin.py @@ -185,10 +185,6 @@ class InventoryMixin: # 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})" diff --git a/src/classes/elixir.py b/src/classes/elixir.py index 1470c1b..4c9ad28 100644 --- a/src/classes/elixir.py +++ b/src/classes/elixir.py @@ -63,6 +63,7 @@ class Elixir(Item): def get_structured_info(self) -> dict: return { + "id": str(self.id), "name": self.name, "desc": self.desc, "grade": self.realm.value, diff --git a/src/classes/region.py b/src/classes/region.py index ec56287..0c857c3 100644 --- a/src/classes/region.py +++ b/src/classes/region.py @@ -11,6 +11,7 @@ from src.classes.animal import Animal, animals_by_id from src.classes.plant import Plant, plants_by_id from src.classes.lode import Lode, lodes_by_id from src.classes.sect import sects_by_name +from src.classes.store import StoreMixin if TYPE_CHECKING: from src.classes.avatar import Avatar @@ -212,18 +213,48 @@ class CultivateRegion(Region): @dataclass(eq=False) -class CityRegion(Region): +class CityRegion(Region, StoreMixin): """城市区域""" + sell_items: str = field(default="[]") + + def __post_init__(self): + super().__post_init__() + try: + import ast + items_list = ast.literal_eval(self.sell_items) + if isinstance(items_list, list): + self.init_store(items_list) + else: + self.init_store([]) + except Exception: + self.init_store([]) + def get_region_type(self) -> str: return "city" def _get_desc(self) -> str: + store_info = self.get_store_info() + if store_info: + return f"({store_info})" return "" def __str__(self) -> str: - return f"城市区域:{self.name} - {self.desc}" + store_info = self.get_store_info() + desc_part = f" | {store_info}" if store_info else "" + return f"城市区域:{self.name} - {self.desc}{desc_part}" def get_structured_info(self) -> dict: info = super().get_structured_info() info["type_name"] = "城市区域" + + store_items_info = [] + if hasattr(self, 'store_items'): + from src.classes.prices import prices + for item in self.store_items: + item_info = item.get_structured_info() + # Inject price + item_info["price"] = prices.get_buying_price(item, None) + store_items_info.append(item_info) + + info["store_items"] = store_items_info return info diff --git a/src/classes/store.py b/src/classes/store.py new file mode 100644 index 0000000..be64d84 --- /dev/null +++ b/src/classes/store.py @@ -0,0 +1,72 @@ +from collections import defaultdict +from typing import Any, List + +from src.utils.resolution import resolve_query +from src.classes.elixir import Elixir +from src.classes.weapon import Weapon +from src.classes.auxiliary import Auxiliary +from src.classes.prices import prices + +class StoreMixin: + """ + 商店功能混入类 + 赋予区域售卖物品的能力 + """ + + def init_store(self, item_names: list[str]): + """ + 初始化商店物品 + :param item_names: 物品名称列表 + """ + self.store_items = [] + if not item_names: + return + + for name in item_names: + # 期望类型:丹药、武器、辅助 + res = resolve_query(name, expected_types=[Elixir, Weapon, Auxiliary]) + if res.is_valid and res.obj: + self.store_items.append(res.obj) + + def get_store_info(self) -> str: + """ + 获取商店信息描述 + 例如:交易:练气剑、练气刀(100灵石);练气破境丹(50灵石) + """ + # 如果没有初始化或者没有物品 + if not hasattr(self, 'store_items') or not self.store_items: + return "" + + # 按价格分组 + items_by_price = defaultdict(list) + for item in self.store_items: + # 获取该物品的标准购买价格(作为标价,买家为 None) + price = prices.get_buying_price(item, None) + items_by_price[price].append(item.name) + + if not items_by_price: + return "" + + # 格式化输出 + parts = [] + # 按价格从低到高排序 + for price in sorted(items_by_price.keys()): + names = items_by_price[price] + # 去重并保持顺序 (Python 3.7+ dict key insertion order) + unique_names = list(dict.fromkeys(names)) + names_str = "、".join(unique_names) + parts.append(f"{names_str}({price}灵石)") + + return "交易:" + ";".join(parts) + + def is_selling(self, item_name: str) -> bool: + """ + 检查商店是否出售该物品 + """ + if not hasattr(self, 'store_items'): + return False + + # 简单的名字匹配 (Assuming item.name is what we look for) + # 如果需要更严格的匹配(如 normalized name),需要在这里处理, + # 但通常 resolve_query 解析出的 obj.name 是标准名。 + return any(item.name == item_name for item in self.store_items) diff --git a/src/classes/weapon.py b/src/classes/weapon.py index 06bb75a..eaa65a1 100644 --- a/src/classes/weapon.py +++ b/src/classes/weapon.py @@ -48,6 +48,7 @@ class Weapon(Item): def get_structured_info(self) -> dict: return { + "id": str(self.id), "name": self.name, "desc": self.desc, "grade": self.realm.value, diff --git a/src/run/load_map.py b/src/run/load_map.py index b93acf7..2022621 100644 --- a/src/run/load_map.py +++ b/src/run/load_map.py @@ -104,6 +104,10 @@ def _load_and_assign_regions(game_map: Map, region_coords: dict[int, list[tuple[ elif type_tag == "cultivate": params["essence_type"] = EssenceType.from_str(get_str(row, "root_type")) params["essence_density"] = get_int(row, "root_density") + elif type_tag == "city": + sell_items_str = get_str(row, "sell_items") + if sell_items_str: + params["sell_items"] = sell_items_str elif type_tag == "sect": sect_id = get_int(row, "sect_id") params["sect_id"] = sect_id diff --git a/static/game_configs/city_region.csv b/static/game_configs/city_region.csv index 6d77fba..303923a 100644 --- a/static/game_configs/city_region.csv +++ b/static/game_configs/city_region.csv @@ -1,7 +1,7 @@ -id,name,desc -ID必须以3开头,, -301,青云城,繁华都市,人烟稠密,商贾云集。此地是交易天材地宝、寻找机缘的重要场所。 -302,沙月城,沙漠绿洲中的贸易重镇,各路商队在此集结,是修士补给和交流的重要据点。 -303,翠林城,森林深处的修仙重镇,众多修士在此栖居,是修炼和炼宝的理想之地。 -304,沧澜城,坐落于大河入海口的三角洲,百川归海,水运昌隆,是水系修士往来最为频繁的宝地。 -305,揽月城,屹立于连绵群山之巅,终年云雾缭绕,灵气纯净,是苦修之士感悟天道的绝佳场所。 +id,name,desc,sell_items +ID必须以3开头,,, +301,青云城,繁华都市,人烟稠密,商贾云集。此地是交易天材地宝、寻找机缘的重要场所。,"['练气破境丹', '练气长生丹']" +302,沙月城,沙漠绿洲中的贸易重镇,各路商队在此集结,是修士补给和交流的重要据点。,"['练气燃血丹', '练气回春丹']" +303,翠林城,森林深处的修仙重镇,众多修士在此栖居,是修炼和炼宝的理想之地。,"['练气剑', '练气刀', '练气枪']" +304,沧澜城,坐落于大河入海口的三角洲,百川归海,水运昌隆,是水系修士往来最为频繁的宝地。,"['练气棍', '练气扇', '练气鞭']" +305,揽月城,屹立于连绵群山之巅,终年云雾缭绕,灵气纯净,是苦修之士感悟天道的绝佳场所。,"['练气琴', '练气笛', '练气暗器']" diff --git a/tests/test_buy_action.py b/tests/test_buy_action.py index e5560b5..d7e2180 100644 --- a/tests/test_buy_action.py +++ b/tests/test_buy_action.py @@ -12,25 +12,33 @@ def test_buy_item_success(avatar_in_city, mock_item_data): materials_mock = mock_item_data["materials"] test_material = mock_item_data["obj_material"] - 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) - - # 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_material_quantity(test_material) == 1 + # 模拟 CityRegion.is_selling 方法 + # 直接在 avatar_in_city.tile.region 上 mock 或者动态添加属性 + # 由于 avatar_in_city 使用了 mock_region,我们需要确保它有 is_selling + # 假设 conftest 中 avatar_in_city.tile.region 是一个 CityRegion 实例或者 Mock + + # 我们这里动态 patch is_selling + with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True) as mock_is_selling: + 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) + + # 1. 检查是否可购买 + can_start, reason = action.can_start("铁矿石") + assert can_start is True + mock_is_selling.assert_called_with("铁矿石") + + # 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_material_quantity(test_material) == 1 def test_buy_elixir_success(avatar_in_city, mock_item_data): """测试购买并服用丹药成功""" @@ -38,26 +46,43 @@ def test_buy_elixir_success(avatar_in_city, mock_item_data): materials_mock = mock_item_data["materials"] test_elixir = mock_item_data["obj_elixir"] - 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) - - 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) - - # 模拟服用丹药的行为 - action._execute("聚气丹") - - assert avatar_in_city.magic_stone == initial_money - expected_price - # 背包里不应该有丹药 - assert len(avatar_in_city.materials) == 0 - # 已服用列表应该有 - assert len(avatar_in_city.elixirs) == 1 - assert avatar_in_city.elixirs[0].elixir.name == "聚气丹" + with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True): + 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) + + 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) + + # 模拟服用丹药的行为 + action._execute("聚气丹") + + assert avatar_in_city.magic_stone == initial_money - expected_price + # 背包里不应该有丹药 + 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_item_not_sold(avatar_in_city, mock_item_data): + """测试商品不在商店售卖列表中""" + elixirs_mock = mock_item_data["elixirs"] + materials_mock = mock_item_data["materials"] + + # Mock is_selling 返回 False + with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=False): + 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) + can_start, reason = action.can_start("铁矿石") + + assert can_start is False + assert "不出售" in reason def test_buy_fail_not_in_city(dummy_avatar, mock_item_data): """测试不在城市无法购买""" @@ -83,14 +108,15 @@ def test_buy_fail_no_money(avatar_in_city, mock_item_data): avatar_in_city.magic_stone = 0 # 没钱 - 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) - can_start, reason = action.can_start("铁矿石") - - assert can_start is False - assert "灵石不足" in reason + with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True): + 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) + can_start, reason = action.can_start("铁矿石") + + assert can_start is False + assert "灵石不足" in reason def test_buy_fail_unknown_item(avatar_in_city, mock_item_data): """测试未知物品""" @@ -119,14 +145,16 @@ def test_buy_elixir_fail_high_level_restricted(avatar_in_city, mock_item_data): 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): - - action = Buy(avatar_in_city, avatar_in_city.world) - can_start, reason = action.can_start("筑基丹") - - assert can_start is False - assert "当前仅开放练气期丹药购买" in reason + with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True): + 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) + 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_item_data): """测试药效尚存无法重复购买""" @@ -139,14 +167,15 @@ def test_buy_elixir_fail_duplicate_active(avatar_in_city, mock_item_data): avatar_in_city.elixirs.append(consumed) - 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) - can_start, reason = action.can_start("聚气丹") - - assert can_start is False - assert "药效尚存" in reason + with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True): + 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) + can_start, reason = action.can_start("聚气丹") + + assert can_start is False + assert "药效尚存" in reason def test_buy_weapon_trade_in(avatar_in_city, mock_item_data): """测试购买新武器时自动卖出旧武器""" @@ -158,17 +187,6 @@ def test_buy_weapon_trade_in(avatar_in_city, mock_item_data): materials_mock = mock_item_data["materials"] new_weapon = mock_item_data["obj_weapon"] - # 手动添加武器到 materials_mock (Buy logic looks up weapons in materials too? Or just assumes unique names?) - # Buy code checks `get_item_by_name` which checks all dicts. - # In test_buy_action we only mocked elixirs and materials. - # Let's ensure '青云剑' is findable. Ideally it should be in weapons_by_name but maybe Buy logic is flexible? - # Original test put it in materials_mock["青云剑"] = new_weapon. Let's follow that pattern for now or better: mock weapons too. - - # Wait, original test: materials_mock["青云剑"] = new_weapon - # But `src.utils.resolution.get_item_by_name` checks materials, weapons, auxiliaries. - # Let's do it properly by mocking weapons_by_name as well if possible, or just stick to materials for simplicity if Buy allows. - # Buy uses `get_item_by_name`. - materials_mock["青云剑"] = new_weapon # 构造旧武器 @@ -190,20 +208,21 @@ def test_buy_weapon_trade_in(avatar_in_city, mock_item_data): 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 + with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True): + with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ + patch("src.utils.resolution.materials_by_name", materials_mock): + + action = Buy(avatar_in_city, avatar_in_city.world) + + # 验证 Event 描述 + event = action.start("青云剑") + assert "青云剑" in event.content + assert "铁剑" in event.content + assert "折价售出" in event.content + + # 执行购买 + action._execute("青云剑") + + assert avatar_in_city.weapon.name == "青云剑" + assert avatar_in_city.weapon != old_weapon + assert avatar_in_city.magic_stone == expected_money diff --git a/web/src/components/game/panels/info/RegionDetail.vue b/web/src/components/game/panels/info/RegionDetail.vue index ab26481..8ef1c80 100644 --- a/web/src/components/game/panels/info/RegionDetail.vue +++ b/web/src/components/game/panels/info/RegionDetail.vue @@ -107,6 +107,21 @@ function jumpToAvatar(id: string) { /> + + +