refactor: add store mixin into city regions & refactor buying action

This commit is contained in:
bridge committed 2026-01-08 22:16:33 +08:00
1 parent d1d7e7d7bd
commit 9c21259577
13 files changed
+258 -108

No files matched your search

+11 -4
View File
@@ -5,9 +5,8 @@ from typing import TYPE_CHECKING, Tuple, Any
from src.classes.action import InstantAction from src.classes.action import InstantAction
from src.classes.event import Event from src.classes.event import Event
from src.classes.region import CityRegion 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.prices import prices
from src.classes.cultivation import Realm
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.material import Material from src.classes.material import Material
@@ -28,8 +27,7 @@ class Buy(InstantAction):
ACTION_NAME = "购买" ACTION_NAME = "购买"
EMOJI = "💸" EMOJI = "💸"
elixir_names_str = ", ".join([e.name for e in get_elixirs_by_realm(Realm.Qi_Refinement)]) DESC = f"在城镇购买物品/装备/丹药。"
DESC = f"在城镇购买物品/装备(丹药购买后将立即服用)。可选丹药:{elixir_names_str}"
DOABLES_REQUIREMENTS = "在城镇且金钱足够" DOABLES_REQUIREMENTS = "在城镇且金钱足够"
PARAMS = {"target_name": "str"} PARAMS = {"target_name": "str"}
@@ -42,6 +40,15 @@ class Buy(InstantAction):
if not res.is_valid: if not res.is_valid:
return False, f"未知物品: {target_name}" 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 # 核心逻辑委托给 Avatar
return self.avatar.can_buy_item(res.obj) return self.avatar.can_buy_item(res.obj)
+1
View File
@@ -56,6 +56,7 @@ class Auxiliary(Item):
full_desc = f"{full_desc} (已吞噬魂魄:{souls})" full_desc = f"{full_desc} (已吞噬魂魄:{souls})"
return { return {
"id": str(self.id),
"name": self.name, "name": self.name,
"desc": full_desc, "desc": full_desc,
"grade": self.realm.value, "grade": self.realm.value,
-4
View File
@@ -185,10 +185,6 @@ class InventoryMixin:
# 2. 丹药特殊检查 # 2. 丹药特殊检查
if isinstance(obj, Elixir): if isinstance(obj, Elixir):
# 商店业务规则:当前仅开放练气期丹药购买
if obj.realm != Realm.Qi_Refinement:
return False, "当前仅开放练气期丹药购买"
# 境界限制 # 境界限制
if obj.realm > self.cultivation_progress.realm: if obj.realm > self.cultivation_progress.realm:
return False, f"境界不足,无法承受药力 ({obj.realm.value})" return False, f"境界不足,无法承受药力 ({obj.realm.value})"
+1
View File
@@ -63,6 +63,7 @@ class Elixir(Item):
def get_structured_info(self) -> dict: def get_structured_info(self) -> dict:
return { return {
"id": str(self.id),
"name": self.name, "name": self.name,
"desc": self.desc, "desc": self.desc,
"grade": self.realm.value, "grade": self.realm.value,
+33 -2
View File
@@ -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.plant import Plant, plants_by_id
from src.classes.lode import Lode, lodes_by_id from src.classes.lode import Lode, lodes_by_id
from src.classes.sect import sects_by_name from src.classes.sect import sects_by_name
from src.classes.store import StoreMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.classes.avatar import Avatar from src.classes.avatar import Avatar
@@ -212,18 +213,48 @@ class CultivateRegion(Region):
@dataclass(eq=False) @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: def get_region_type(self) -> str:
return "city" return "city"
def _get_desc(self) -> str: def _get_desc(self) -> str:
store_info = self.get_store_info()
if store_info:
return f"({store_info})"
return "" return ""
def __str__(self) -> str: 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: def get_structured_info(self) -> dict:
info = super().get_structured_info() info = super().get_structured_info()
info["type_name"] = "城市区域" 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 return info
+72
View File
@@ -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)
+1
View File
@@ -48,6 +48,7 @@ class Weapon(Item):
def get_structured_info(self) -> dict: def get_structured_info(self) -> dict:
return { return {
"id": str(self.id),
"name": self.name, "name": self.name,
"desc": self.desc, "desc": self.desc,
"grade": self.realm.value, "grade": self.realm.value,
+4
View File
@@ -104,6 +104,10 @@ def _load_and_assign_regions(game_map: Map, region_coords: dict[int, list[tuple[
elif type_tag == "cultivate": elif type_tag == "cultivate":
params["essence_type"] = EssenceType.from_str(get_str(row, "root_type")) params["essence_type"] = EssenceType.from_str(get_str(row, "root_type"))
params["essence_density"] = get_int(row, "root_density") 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": elif type_tag == "sect":
sect_id = get_int(row, "sect_id") sect_id = get_int(row, "sect_id")
params["sect_id"] = sect_id params["sect_id"] = sect_id
+7 -7
View File
@@ -1,7 +1,7 @@
id,name,desc id,name,desc,sell_items
ID必须以3开头,, ID必须以3开头,,,
301,青云城,繁华都市,人烟稠密,商贾云集。此地是交易天材地宝、寻找机缘的重要场所。 301,青云城,繁华都市,人烟稠密,商贾云集。此地是交易天材地宝、寻找机缘的重要场所。,"['练气破境丹', '练气长生丹']"
302,沙月城,沙漠绿洲中的贸易重镇,各路商队在此集结,是修士补给和交流的重要据点。 302,沙月城,沙漠绿洲中的贸易重镇,各路商队在此集结,是修士补给和交流的重要据点。,"['练气燃血丹', '练气回春丹']"
303,翠林城,森林深处的修仙重镇,众多修士在此栖居,是修炼和炼宝的理想之地。 303,翠林城,森林深处的修仙重镇,众多修士在此栖居,是修炼和炼宝的理想之地。,"['练气剑', '练气刀', '练气枪']"
304,沧澜城,坐落于大河入海口的三角洲,百川归海,水运昌隆,是水系修士往来最为频繁的宝地。 304,沧澜城,坐落于大河入海口的三角洲,百川归海,水运昌隆,是水系修士往来最为频繁的宝地。,"['练气棍', '练气扇', '练气鞭']"
305,揽月城,屹立于连绵群山之巅,终年云雾缭绕,灵气纯净,是苦修之士感悟天道的绝佳场所。 305,揽月城,屹立于连绵群山之巅,终年云雾缭绕,灵气纯净,是苦修之士感悟天道的绝佳场所。,"['练气琴', '练气笛', '练气暗器']"
1 id name desc sell_items
2 ID必须以3开头
3 301 青云城 繁华都市,人烟稠密,商贾云集。此地是交易天材地宝、寻找机缘的重要场所。 ['练气破境丹', '练气长生丹']
4 302 沙月城 沙漠绿洲中的贸易重镇,各路商队在此集结,是修士补给和交流的重要据点。 ['练气燃血丹', '练气回春丹']
5 303 翠林城 森林深处的修仙重镇,众多修士在此栖居,是修炼和炼宝的理想之地。 ['练气剑', '练气刀', '练气枪']
6 304 沧澜城 坐落于大河入海口的三角洲,百川归海,水运昌隆,是水系修士往来最为频繁的宝地。 ['练气棍', '练气扇', '练气鞭']
7 305 揽月城 屹立于连绵群山之巅,终年云雾缭绕,灵气纯净,是苦修之士感悟天道的绝佳场所。 ['练气琴', '练气笛', '练气暗器']
+110 -91
View File
@@ -12,25 +12,33 @@ def test_buy_item_success(avatar_in_city, mock_item_data):
materials_mock = mock_item_data["materials"] materials_mock = mock_item_data["materials"]
test_material = mock_item_data["obj_material"] test_material = mock_item_data["obj_material"]
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ # 模拟 CityRegion.is_selling 方法
patch("src.utils.resolution.materials_by_name", materials_mock): # 直接在 avatar_in_city.tile.region 上 mock 或者动态添加属性
# 由于 avatar_in_city 使用了 mock_region,我们需要确保它有 is_selling
action = Buy(avatar_in_city, avatar_in_city.world) # 假设 conftest 中 avatar_in_city.tile.region 是一个 CityRegion 实例或者 Mock
# 1. 检查是否可购买 # 我们这里动态 patch is_selling
can_start, reason = action.can_start("铁矿石") with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True) as mock_is_selling:
assert can_start is True with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
patch("src.utils.resolution.materials_by_name", materials_mock):
# 2. 执行购买
initial_money = avatar_in_city.magic_stone action = Buy(avatar_in_city, avatar_in_city.world)
# 练气期材料基础价格 10,倍率 1.5 -> 15
expected_price = int(10 * 1.5) # 1. 检查是否可购买
can_start, reason = action.can_start("铁矿石")
action._execute("铁矿石") assert can_start is True
mock_is_selling.assert_called_with("铁矿石")
# 3. 验证结果
assert avatar_in_city.magic_stone == initial_money - expected_price # 2. 执行购买
assert avatar_in_city.get_material_quantity(test_material) == 1 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): 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"] materials_mock = mock_item_data["materials"]
test_elixir = mock_item_data["obj_elixir"] test_elixir = mock_item_data["obj_elixir"]
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True):
patch("src.utils.resolution.materials_by_name", materials_mock): 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)
action = Buy(avatar_in_city, avatar_in_city.world)
can_start, reason = action.can_start("聚气丹")
assert can_start is True 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) initial_money = avatar_in_city.magic_stone
expected_price = int(test_elixir.price * 1.5)
# 模拟服用丹药的行为
action._execute("聚气丹") # 模拟服用丹药的行为
action._execute("聚气丹")
assert avatar_in_city.magic_stone == initial_money - expected_price
# 背包里不应该有丹药 assert avatar_in_city.magic_stone == initial_money - expected_price
assert len(avatar_in_city.materials) == 0 # 背包里不应该有丹药
# 已服用列表应该有 assert len(avatar_in_city.materials) == 0
assert len(avatar_in_city.elixirs) == 1 # 已服用列表应该有
assert avatar_in_city.elixirs[0].elixir.name == "聚气丹" 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): 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 # 没钱 avatar_in_city.magic_stone = 0 # 没钱
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True):
patch("src.utils.resolution.materials_by_name", materials_mock): 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("铁矿石") 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_fail_unknown_item(avatar_in_city, mock_item_data): 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 avatar_in_city.cultivation_progress.realm == Realm.Qi_Refinement
assert high_level_elixir.realm == Realm.Foundation_Establishment assert high_level_elixir.realm == Realm.Foundation_Establishment
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True):
patch("src.utils.resolution.materials_by_name", materials_mock): 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("筑基丹") 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_item_data): 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) avatar_in_city.elixirs.append(consumed)
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True):
patch("src.utils.resolution.materials_by_name", materials_mock): 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("聚气丹") 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_weapon_trade_in(avatar_in_city, mock_item_data): 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"] materials_mock = mock_item_data["materials"]
new_weapon = mock_item_data["obj_weapon"] 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 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 expected_money = initial_money - buy_cost + sell_refund
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ with patch.object(avatar_in_city.tile.region, 'is_selling', return_value=True):
patch("src.utils.resolution.materials_by_name", materials_mock): 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)
action = Buy(avatar_in_city, avatar_in_city.world)
# 验证 Event 描述
event = action.start("青云剑") # 验证 Event 描述
assert "青云剑" in event.content event = action.start("青云剑")
assert "铁剑" in event.content assert "青云剑" in event.content
assert "折价售出" in event.content assert "铁剑" in event.content
assert "折价售出" in event.content
# 执行购买
action._execute("青云剑") # 执行购买
action._execute("青云剑")
assert avatar_in_city.weapon.name == "青云剑"
assert avatar_in_city.weapon != old_weapon assert avatar_in_city.weapon.name == "青云剑"
assert avatar_in_city.magic_stone == expected_money assert avatar_in_city.weapon != old_weapon
assert avatar_in_city.magic_stone == expected_money
@@ -107,6 +107,21 @@ function jumpToAvatar(id: string) {
/> />
</div> </div>
</div> </div>
<!-- Store Items -->
<div class="section" v-if="data.store_items?.length">
<div class="section-title">坊市交易</div>
<div class="list">
<EntityRow
v-for="item in data.store_items"
:key="item.id || item.name"
:item="item"
:meta="`${item.price}灵石`"
compact
@click="showDetail(item)"
/>
</div>
</div>
</div> </div>
</template> </template>
@@ -22,6 +22,7 @@ const typeMap: Record<string, string> = {
}; };
const displayType = computed(() => { const displayType = computed(() => {
if (props.item?.type_name) return props.item.type_name; // 优先使用后端传回的中文类型名
if (!props.item?.type) return ''; if (!props.item?.type) return '';
return typeMap[props.item.type] || props.item.type; return typeMap[props.item.type] || props.item.type;
}); });
+2
View File
@@ -24,6 +24,7 @@ export interface EffectEntity extends EntityBase {
grade?: string; grade?: string;
rarity?: string; // e.g., 'SSR', 'R', '上品' rarity?: string; // e.g., 'SSR', 'R', '上品'
type?: string; type?: string;
type_name?: string; // 新增:中文类型名,如"丹药"、"破境"等
color?: string | number[]; // 某些实体自带颜色 color?: string | number[]; // 某些实体自带颜色
drops?: EffectEntity[]; drops?: EffectEntity[];
hq_name?: string; hq_name?: string;
@@ -167,6 +168,7 @@ export interface RegionDetail extends EntityBase {
animals: EffectEntity[]; animals: EffectEntity[];
plants: EffectEntity[]; plants: EffectEntity[];
lodes: EffectEntity[]; lodes: EffectEntity[];
store_items?: (EffectEntity & { price: number })[];
} }
// --- 天地灵机 --- // --- 天地灵机 ---