From 5b5cd79cb53c627eb0ffd15544977fccecdf8fe6 Mon Sep 17 00:00:00 2001 From: bridge Date: Sat, 3 Jan 2026 22:15:25 +0800 Subject: [PATCH] add circulation manager --- src/classes/avatar/inventory_mixin.py | 6 + src/classes/circulation.py | 71 ++++++++ src/classes/world.py | 3 + src/sim/load/load_game.py | 4 + src/sim/save/save_game.py | 2 + tests/test_circulation.py | 235 ++++++++++++++++++++++++++ 6 files changed, 321 insertions(+) create mode 100644 src/classes/circulation.py create mode 100644 tests/test_circulation.py diff --git a/src/classes/avatar/inventory_mixin.py b/src/classes/avatar/inventory_mixin.py index aad373a..4944f4f 100644 --- a/src/classes/avatar/inventory_mixin.py +++ b/src/classes/avatar/inventory_mixin.py @@ -136,6 +136,9 @@ class InventoryMixin: """ from src.classes.prices import prices + # 记录流转 + self.world.circulation.add_weapon(weapon) + total = int(prices.get_weapon_price(weapon) * self._get_sell_multiplier()) self.magic_stone = self.magic_stone + total return total @@ -147,6 +150,9 @@ class InventoryMixin: """ from src.classes.prices import prices + # 记录流转 + self.world.circulation.add_auxiliary(auxiliary) + total = int(prices.get_auxiliary_price(auxiliary) * self._get_sell_multiplier()) self.magic_stone = self.magic_stone + total return total diff --git a/src/classes/circulation.py b/src/classes/circulation.py new file mode 100644 index 0000000..42fdd2a --- /dev/null +++ b/src/classes/circulation.py @@ -0,0 +1,71 @@ +from __future__ import annotations +from typing import Dict, List, TYPE_CHECKING +import copy + +if TYPE_CHECKING: + from src.classes.weapon import Weapon + from src.classes.auxiliary import Auxiliary + + +class CirculationManager: + """ + 出世物品流通管理器 + 记录所有从角色身上流出的贵重物品(出售、死亡掉落且未被夺取等) + 用于后续拍卖会等玩法的物品池 + """ + + def __init__(self): + # 存储被卖出的法宝 + self.sold_weapons: List[Weapon] = [] + # 存储被卖出的宝物 + self.sold_auxiliaries: List[Auxiliary] = [] + + def add_weapon(self, weapon: "Weapon") -> None: + """记录一件流出的兵器""" + if weapon is None: + return + # 使用深拷贝存储,防止外部修改影响记录 + # 注意:这里假设 weapon 对象是可以被 copy 的 + self.sold_weapons.append(copy.deepcopy(weapon)) + + def add_auxiliary(self, auxiliary: "Auxiliary") -> None: + """记录一件流出的辅助装备""" + if auxiliary is None: + return + self.sold_auxiliaries.append(copy.deepcopy(auxiliary)) + + def to_save_dict(self) -> dict: + """序列化为字典以便存档""" + return { + "weapons": [self._item_to_dict(w) for w in self.sold_weapons], + "auxiliaries": [self._item_to_dict(a) for a in self.sold_auxiliaries] + } + + def load_from_dict(self, data: dict) -> None: + """从字典恢复数据""" + from src.classes.weapon import weapons_by_id + from src.classes.auxiliary import auxiliaries_by_id + + self.sold_weapons = [] + for w_data in data.get("weapons", []): + w_id = w_data.get("id") + if w_id in weapons_by_id: + weapon = copy.copy(weapons_by_id[w_id]) + weapon.special_data = w_data.get("special_data", {}) + self.sold_weapons.append(weapon) + + self.sold_auxiliaries = [] + for a_data in data.get("auxiliaries", []): + a_id = a_data.get("id") + if a_id in auxiliaries_by_id: + auxiliary = copy.copy(auxiliaries_by_id[a_id]) + auxiliary.special_data = a_data.get("special_data", {}) + self.sold_auxiliaries.append(auxiliary) + + def _item_to_dict(self, item) -> dict: + """将物品对象转换为简略的存储格式""" + return { + "id": item.id, + "special_data": getattr(item, "special_data", {}) + } + diff --git a/src/classes/world.py b/src/classes/world.py index 371f16d..16551f0 100644 --- a/src/classes/world.py +++ b/src/classes/world.py @@ -5,6 +5,7 @@ from src.classes.map import Map from src.classes.calendar import Year, Month, MonthStamp from src.classes.avatar_manager import AvatarManager from src.classes.event_manager import EventManager +from src.classes.circulation import CirculationManager if TYPE_CHECKING: from src.classes.avatar import Avatar @@ -22,6 +23,8 @@ class World(): current_phenomenon: Optional["CelestialPhenomenon"] = None # 天地灵机开始年份(用于计算持续时间) phenomenon_start_year: int = 0 + # 出世物品流通管理器 + circulation: CirculationManager = field(default_factory=CirculationManager) def get_info(self, detailed: bool = False, avatar: Optional["Avatar"] = None) -> dict: """ diff --git a/src/sim/load/load_game.py b/src/sim/load/load_game.py index eb134fd..85fe621 100644 --- a/src/sim/load/load_game.py +++ b/src/sim/load/load_game.py @@ -94,6 +94,10 @@ def load_game(save_path: Optional[Path] = None) -> Tuple["World", "Simulator", L if phenomenon_id is not None and phenomenon_id in celestial_phenomena_by_id: world.current_phenomenon = celestial_phenomena_by_id[phenomenon_id] world.phenomenon_start_year = world_data.get("phenomenon_start_year", 0) + + # 恢复出世物品流转 + circulation_data = world_data.get("circulation", {}) + world.circulation.load_from_dict(circulation_data) # 获取本局启用的宗门 existed_sect_ids = world_data.get("existed_sect_ids", []) diff --git a/src/sim/save/save_game.py b/src/sim/save/save_game.py index b6ec530..048e6fe 100644 --- a/src/sim/save/save_game.py +++ b/src/sim/save/save_game.py @@ -95,6 +95,8 @@ def save_game( "current_phenomenon_id": world.current_phenomenon.id if world.current_phenomenon else None, "phenomenon_start_year": world.phenomenon_start_year if hasattr(world, 'phenomenon_start_year') else 0, "cultivate_regions_hosts": cultivate_regions_hosts, + # 出世物品流转 + "circulation": world.circulation.to_save_dict(), } # 保存所有Avatar(第一阶段:不含relations) diff --git a/tests/test_circulation.py b/tests/test_circulation.py new file mode 100644 index 0000000..59740f3 --- /dev/null +++ b/tests/test_circulation.py @@ -0,0 +1,235 @@ +import pytest +import copy +from unittest.mock import MagicMock, patch +from src.classes.world import World +from src.classes.circulation import CirculationManager +from src.classes.weapon import Weapon, WeaponType +from src.classes.auxiliary import Auxiliary +from src.classes.cultivation import Realm +from src.classes.avatar import Avatar, Gender +from src.classes.age import Age +from src.classes.calendar import Month, Year, create_month_stamp +from src.utils.id_generator import get_avatar_id +from src.sim.save.save_game import save_game +from src.sim.load.load_game import load_game +from src.sim.simulator import Simulator +from src.classes.map import Map +from src.classes.tile import TileType + +# --- Helper Objects --- + +def create_mock_weapon(w_id=1, name="MockSword"): + w = MagicMock(spec=Weapon) + w.id = w_id + w.name = name + w.realm = Realm.Qi_Refinement + w.special_data = {"test_val": 123} + # Mock to_save_dict behavior manually or rely on CirculationManager using id/special_data + return w + +def create_mock_auxiliary(a_id=1, name="MockRing"): + a = MagicMock(spec=Auxiliary) + a.id = a_id + a.name = name + a.realm = Realm.Qi_Refinement + a.special_data = {"souls": 5} + return a + +def create_test_map(): + m = Map(width=10, height=10) + for x in range(10): + for y in range(10): + m.create_tile(x, y, TileType.PLAIN) + return m + +@pytest.fixture +def temp_save_dir(tmp_path): + d = tmp_path / "saves" + d.mkdir() + return d + +@pytest.fixture +def empty_world(): + game_map = create_test_map() + return World(map=game_map, month_stamp=create_month_stamp(Year(1), Month.JANUARY)) + +# --- Tests --- + +def test_circulation_manager_basic(): + """Test basic adding of items to CirculationManager""" + cm = CirculationManager() + + # Test adding Weapon + w = create_mock_weapon(1, "Sword") + # CirculationManager uses deepcopy, so we need to ensure the mock supports it or use real objects if possible. + # MagicMock is hard to deepcopy properly in some contexts, let's use a simple object structure or patch copy.deepcopy + # But for robustness, let's try to make a real-ish object or a class that looks like Weapon + + # Let's define a simple dummy class for testing to avoid importing all Weapon dependencies + class DummyItem: + def __init__(self, id, name, special_data=None): + self.id = id + self.name = name + self.special_data = special_data or {} + + w1 = DummyItem(1, "Sword", {"kills": 10}) + cm.add_weapon(w1) + + assert len(cm.sold_weapons) == 1 + assert cm.sold_weapons[0].name == "Sword" + # Ensure it's a copy + assert cm.sold_weapons[0] is not w1 + assert cm.sold_weapons[0].special_data["kills"] == 10 + + # Test adding Auxiliary + a1 = DummyItem(2, "Ring", {"mana": 50}) + cm.add_auxiliary(a1) + + assert len(cm.sold_auxiliaries) == 1 + assert cm.sold_auxiliaries[0].name == "Ring" + +def test_circulation_serialization(): + """Test to_save_dict and load_from_dict""" + cm = CirculationManager() + + # Prepare data using real-ish mocks that can be looked up by ID + # We need to patch weapons_by_id and auxiliaries_by_id during load + + class DummyItem: + def __init__(self, id, name): + self.id = id + self.name = name + self.special_data = {} + + w1 = DummyItem(101, "RareSword") + w1.special_data = {"stat": 1} + + a1 = DummyItem(202, "RareRing") + a1.special_data = {"stat": 2} + + cm.add_weapon(w1) + cm.add_auxiliary(a1) + + saved_data = cm.to_save_dict() + + # Verify saved structure + assert "weapons" in saved_data + assert "auxiliaries" in saved_data + assert len(saved_data["weapons"]) == 1 + assert saved_data["weapons"][0]["id"] == 101 + assert saved_data["weapons"][0]["special_data"] == {"stat": 1} + + # Test Loading + new_cm = CirculationManager() + + # We need to mock the global dictionaries used in load_from_dict + mock_weapons_db = {101: DummyItem(101, "RareSword_Proto")} # Proto doesn't have special_data usually + mock_aux_db = {202: DummyItem(202, "RareRing_Proto")} + + with patch("src.classes.weapon.weapons_by_id", mock_weapons_db), \ + patch("src.classes.auxiliary.auxiliaries_by_id", mock_aux_db): + + new_cm.load_from_dict(saved_data) + + assert len(new_cm.sold_weapons) == 1 + assert new_cm.sold_weapons[0].id == 101 + assert new_cm.sold_weapons[0].name == "RareSword_Proto" # Should come from prototype + assert new_cm.sold_weapons[0].special_data == {"stat": 1} # Should be restored from save + + assert len(new_cm.sold_auxiliaries) == 1 + assert new_cm.sold_auxiliaries[0].id == 202 + +def test_avatar_sell_integration(empty_world): + """Test that selling an item via Avatar correctly adds it to World.circulation""" + + # Setup Avatar + avatar = Avatar( + world=empty_world, + name="Seller", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(1), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE + ) + empty_world.avatar_manager.avatars[avatar.id] = avatar + + # Setup Prices mock to avoid complex price calculation dependencies + with patch("src.classes.prices.prices") as mock_prices: + mock_prices.get_weapon_price.return_value = 100 + mock_prices.get_auxiliary_price.return_value = 200 + + # 1. Test Sell Weapon + # Create a dummy weapon that acts like the real one + weapon = MagicMock(spec=Weapon) + weapon.id = 999 + weapon.name = "TestBlade" + weapon.realm = Realm.Qi_Refinement + + # The mixin usually requires self.items to have the item for sell_item, + # but sell_weapon/sell_auxiliary are for equipped items or passed items. + # Looking at inventory_mixin.py: sell_weapon(self, weapon) just calculates price and adds stones. + # It calls _get_sell_multiplier() + + # Ensure avatar has magic stones initialized + avatar.magic_stone = 0 + + # Action + avatar.sell_weapon(weapon) + + # Verify + assert avatar.magic_stone == 100 + assert len(empty_world.circulation.sold_weapons) == 1 + # Since we use MagicMock, deepcopy might be weird, but let's check basic attr + assert empty_world.circulation.sold_weapons[0].id == 999 + + # 2. Test Sell Auxiliary + aux = MagicMock(spec=Auxiliary) + aux.id = 888 + aux.name = "TestAmulet" + + # Action + avatar.sell_auxiliary(aux) + + # Verify + assert avatar.magic_stone == 300 # 100 + 200 + assert len(empty_world.circulation.sold_auxiliaries) == 1 + assert empty_world.circulation.sold_auxiliaries[0].id == 888 + +def test_save_load_circulation(temp_save_dir, empty_world): + """Test full save/load cycle with circulation data""" + + # 1. Populate circulation + class SimpleItem: + def __init__(self, id, name): + self.id = id + self.name = name + self.special_data = {} + self.realm = Realm.Qi_Refinement # needed if deepcopy looks at it or for other checks + + w1 = SimpleItem(10, "LostSword") + w1.special_data = {"kills": 99} + empty_world.circulation.add_weapon(w1) + + # 2. Save + sim = Simulator(empty_world) + save_path = temp_save_dir / "circulation_test.json" + + save_game(empty_world, sim, [], save_path) + + # 3. Load + # We need to mock the DBs to recognize ID 10 + mock_weapons_db = {10: SimpleItem(10, "LostSword_Proto")} + + with patch("src.run.load_map.load_cultivation_world_map", return_value=create_test_map()), \ + patch("src.classes.weapon.weapons_by_id", mock_weapons_db), \ + patch("src.classes.auxiliary.auxiliaries_by_id", {}): + + loaded_world, _, _ = load_game(save_path) + + # 4. Verify + assert len(loaded_world.circulation.sold_weapons) == 1 + loaded_w = loaded_world.circulation.sold_weapons[0] + assert loaded_w.id == 10 + assert loaded_w.name == "LostSword_Proto" # Should be restored from proto name + assert loaded_w.special_data == {"kills": 99} # Should have restored data +