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

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

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)

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,

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})"

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,

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
src/classes/store.py Normal file
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)

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,

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

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 揽月城 屹立于连绵群山之巅,终年云雾缭绕,灵气纯净,是苦修之士感悟天道的绝佳场所。 ['练气琴', '练气笛', '练气暗器']

View File

@@ -12,6 +12,13 @@ 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"]
# 模拟 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), \ with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
patch("src.utils.resolution.materials_by_name", materials_mock): patch("src.utils.resolution.materials_by_name", materials_mock):
@@ -20,6 +27,7 @@ def test_buy_item_success(avatar_in_city, mock_item_data):
# 1. 检查是否可购买 # 1. 检查是否可购买
can_start, reason = action.can_start("铁矿石") can_start, reason = action.can_start("铁矿石")
assert can_start is True assert can_start is True
mock_is_selling.assert_called_with("铁矿石")
# 2. 执行购买 # 2. 执行购买
initial_money = avatar_in_city.magic_stone initial_money = avatar_in_city.magic_stone
@@ -38,6 +46,7 @@ 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.object(avatar_in_city.tile.region, 'is_selling', return_value=True):
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
patch("src.utils.resolution.materials_by_name", materials_mock): patch("src.utils.resolution.materials_by_name", materials_mock):
@@ -59,6 +68,22 @@ def test_buy_elixir_success(avatar_in_city, mock_item_data):
assert len(avatar_in_city.elixirs) == 1 assert len(avatar_in_city.elixirs) == 1
assert avatar_in_city.elixirs[0].elixir.name == "聚气丹" 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):
"""测试不在城市无法购买""" """测试不在城市无法购买"""
elixirs_mock = mock_item_data["elixirs"] elixirs_mock = mock_item_data["elixirs"]
@@ -83,6 +108,7 @@ 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.object(avatar_in_city.tile.region, 'is_selling', return_value=True):
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
patch("src.utils.resolution.materials_by_name", materials_mock): patch("src.utils.resolution.materials_by_name", materials_mock):
@@ -119,6 +145,7 @@ 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.object(avatar_in_city.tile.region, 'is_selling', return_value=True):
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
patch("src.utils.resolution.materials_by_name", materials_mock): patch("src.utils.resolution.materials_by_name", materials_mock):
@@ -126,7 +153,8 @@ def test_buy_elixir_fail_high_level_restricted(avatar_in_city, mock_item_data):
can_start, reason = action.can_start("筑基丹") can_start, reason = action.can_start("筑基丹")
assert can_start is False assert can_start is False
assert "当前仅开放练气期丹药购买" in reason # 错误信息变了,现在是通用的境界限制
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,6 +167,7 @@ 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.object(avatar_in_city.tile.region, 'is_selling', return_value=True):
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
patch("src.utils.resolution.materials_by_name", materials_mock): patch("src.utils.resolution.materials_by_name", materials_mock):
@@ -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,6 +208,7 @@ 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.object(avatar_in_city.tile.region, 'is_selling', return_value=True):
with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \ with patch("src.utils.resolution.elixirs_by_name", elixirs_mock), \
patch("src.utils.resolution.materials_by_name", materials_mock): patch("src.utils.resolution.materials_by_name", materials_mock):

View File

@@ -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>

View File

@@ -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;
}); });

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 })[];
} }
// --- 天地灵机 --- // --- 天地灵机 ---