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.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)

View File

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

View File

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

View File

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

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

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:
return {
"id": str(self.id),
"name": self.name,
"desc": self.desc,
"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":
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

View File

@@ -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,揽月城,屹立于连绵群山之巅,终年云雾缭绕,灵气纯净,是苦修之士感悟天道的绝佳场所。,"['练气琴', '练气笛', '练气暗器']"
1 id name desc sell_items
2 ID必须以3开头
3 301 青云城 繁华都市,人烟稠密,商贾云集。此地是交易天材地宝、寻找机缘的重要场所。 ['练气破境丹', '练气长生丹']
4 302 沙月城 沙漠绿洲中的贸易重镇,各路商队在此集结,是修士补给和交流的重要据点。 ['练气燃血丹', '练气回春丹']
5 303 翠林城 森林深处的修仙重镇,众多修士在此栖居,是修炼和炼宝的理想之地。 ['练气剑', '练气刀', '练气枪']
6 304 沧澜城 坐落于大河入海口的三角洲,百川归海,水运昌隆,是水系修士往来最为频繁的宝地。 ['练气棍', '练气扇', '练气鞭']
7 305 揽月城 屹立于连绵群山之巅,终年云雾缭绕,灵气纯净,是苦修之士感悟天道的绝佳场所。 ['练气琴', '练气笛', '练气暗器']

View File

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

View File

@@ -107,6 +107,21 @@ function jumpToAvatar(id: string) {
/>
</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>
</template>

View File

@@ -22,6 +22,7 @@ const typeMap: Record<string, string> = {
};
const displayType = computed(() => {
if (props.item?.type_name) return props.item.type_name; // 优先使用后端传回的中文类型名
if (!props.item?.type) return '';
return typeMap[props.item.type] || props.item.type;
});

View File

@@ -24,6 +24,7 @@ export interface EffectEntity extends EntityBase {
grade?: string;
rarity?: string; // e.g., 'SSR', 'R', '上品'
type?: string;
type_name?: string; // 新增:中文类型名,如"丹药"、"破境"等
color?: string | number[]; // 某些实体自带颜色
drops?: EffectEntity[];
hq_name?: string;
@@ -167,6 +168,7 @@ export interface RegionDetail extends EntityBase {
animals: EffectEntity[];
plants: EffectEntity[];
lodes: EffectEntity[];
store_items?: (EffectEntity & { price: number })[];
}
// --- 天地灵机 ---