add circulation manager
This commit is contained in:
@@ -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
|
||||
|
||||
71
src/classes/circulation.py
Normal file
71
src/classes/circulation.py
Normal file
@@ -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", {})
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
235
tests/test_circulation.py
Normal file
235
tests/test_circulation.py
Normal file
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user