add pytest
This commit is contained in:
@@ -304,8 +304,10 @@ class Avatar(
|
|||||||
self._init_known_regions()
|
self._init_known_regions()
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
|
if not hasattr(self, 'id'):
|
||||||
|
# 防御性编程:如果id尚未初始化(例如deepcopy过程中),使用对象内存地址
|
||||||
|
return super().__hash__()
|
||||||
return hash(self.id)
|
return hash(self.id)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return str(self.get_info(detailed=False))
|
return str(self.get_info(detailed=False))
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ Avatar读档反序列化Mixin
|
|||||||
读档策略:
|
读档策略:
|
||||||
- 两阶段加载:先加载所有Avatar(relations留空),再重建relations网络
|
- 两阶段加载:先加载所有Avatar(relations留空),再重建relations网络
|
||||||
- 引用对象:通过id从全局字典获取(如techniques_by_id)
|
- 引用对象:通过id从全局字典获取(如techniques_by_id)
|
||||||
- weapon/auxiliary:深拷贝后恢复special_data
|
- weapon/auxiliary:浅拷贝后恢复special_data(避免deepcopy带来的递归/崩溃风险)
|
||||||
- 错误容错:缺失的引用对象会跳过而不是崩溃
|
- 错误容错:缺失的引用对象会跳过而不是崩溃
|
||||||
"""
|
"""
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
import copy
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.classes.world import World
|
from src.classes.world import World
|
||||||
@@ -98,21 +99,26 @@ class AvatarLoadMixin:
|
|||||||
if item_id in items_by_id:
|
if item_id in items_by_id:
|
||||||
avatar.items[items_by_id[item_id]] = quantity
|
avatar.items[items_by_id[item_id]] = quantity
|
||||||
|
|
||||||
# 重建weapon(深拷贝因为special_data是实例特有的)
|
# 重建weapon
|
||||||
|
# 使用copy而非deepcopy,避免潜在的递归引用导致的崩溃,且性能更好
|
||||||
|
# special_data 是实例特有的,需要单独赋值
|
||||||
weapon_id = data.get("weapon_id")
|
weapon_id = data.get("weapon_id")
|
||||||
if weapon_id is not None and weapon_id in weapons_by_id:
|
if weapon_id is not None and weapon_id in weapons_by_id:
|
||||||
import copy
|
# 浅拷贝:复制引用,但 weapon.special_data 会被共享
|
||||||
avatar.weapon = copy.deepcopy(weapons_by_id[weapon_id])
|
# 所以需要手动重新赋值 special_data
|
||||||
|
weapon_proto = weapons_by_id[weapon_id]
|
||||||
|
avatar.weapon = copy.copy(weapon_proto)
|
||||||
avatar.weapon.special_data = data.get("weapon_special_data", {})
|
avatar.weapon.special_data = data.get("weapon_special_data", {})
|
||||||
|
|
||||||
# 恢复兵器熟练度
|
# 恢复兵器熟练度
|
||||||
avatar.weapon_proficiency = float(data.get("weapon_proficiency", 0.0))
|
avatar.weapon_proficiency = float(data.get("weapon_proficiency", 0.0))
|
||||||
|
|
||||||
# 重建auxiliary(深拷贝因为special_data是实例特有的)
|
# 重建auxiliary
|
||||||
|
# 同上,使用copy
|
||||||
auxiliary_id = data.get("auxiliary_id")
|
auxiliary_id = data.get("auxiliary_id")
|
||||||
if auxiliary_id is not None and auxiliary_id in auxiliaries_by_id:
|
if auxiliary_id is not None and auxiliary_id in auxiliaries_by_id:
|
||||||
import copy
|
auxiliary_proto = auxiliaries_by_id[auxiliary_id]
|
||||||
avatar.auxiliary = copy.deepcopy(auxiliaries_by_id[auxiliary_id])
|
avatar.auxiliary = copy.copy(auxiliary_proto)
|
||||||
avatar.auxiliary.special_data = data.get("auxiliary_special_data", {})
|
avatar.auxiliary.special_data = data.get("auxiliary_special_data", {})
|
||||||
|
|
||||||
# 重建spirit_animal
|
# 重建spirit_animal
|
||||||
@@ -197,4 +203,3 @@ class AvatarLoadMixin:
|
|||||||
avatar.recalc_effects()
|
avatar.recalc_effects()
|
||||||
|
|
||||||
return avatar
|
return avatar
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
|
|
||||||
import pytest
|
|
||||||
import copy
|
|
||||||
from src.classes.weapon import Weapon, WeaponType
|
|
||||||
from src.classes.equipment_grade import EquipmentGrade
|
|
||||||
from src.classes.sect import Sect, SectHeadQuarter
|
|
||||||
from src.classes.alignment import Alignment
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def test_weapon_deepcopy_does_not_copy_sect():
|
|
||||||
# 1. 创建模拟的 Sect
|
|
||||||
hq = SectHeadQuarter("HQ", "Desc", Path("img.png"))
|
|
||||||
sect = Sect(
|
|
||||||
id=1, name="TestSect", desc="Desc", member_act_style="Style",
|
|
||||||
alignment=Alignment.Righteous, headquarter=hq, technique_names=[]
|
|
||||||
)
|
|
||||||
|
|
||||||
# 向 Sect 中添加一些可能导致问题的成员(虽然这里只是简单测试引用)
|
|
||||||
# 在真实场景中,Sect.members 可能包含复杂的 Avatar 对象
|
|
||||||
sect.members["dummy"] = "DummyAvatar"
|
|
||||||
|
|
||||||
# 2. 创建 Weapon 并关联 Sect
|
|
||||||
weapon = Weapon(
|
|
||||||
id=101, name="TestWeapon", weapon_type=WeaponType.SWORD,
|
|
||||||
grade=EquipmentGrade.COMMON, sect_id=1, desc="Desc", sect=sect
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. 深拷贝 Weapon
|
|
||||||
weapon_copy = copy.deepcopy(weapon)
|
|
||||||
|
|
||||||
# 4. 验证 Weapon 被复制了
|
|
||||||
assert weapon_copy is not weapon
|
|
||||||
assert weapon_copy.id == weapon.id
|
|
||||||
|
|
||||||
# 5. 关键验证:Sect 应该是同一个对象(浅拷贝)
|
|
||||||
assert weapon_copy.sect is sect
|
|
||||||
assert weapon_copy.sect is weapon.sect
|
|
||||||
|
|
||||||
# 验证 Sect 的成员没有被复制
|
|
||||||
assert weapon_copy.sect.members is sect.members
|
|
||||||
|
|
||||||
def test_weapon_special_data_is_copied():
|
|
||||||
# 验证 special_data 是否被正确深拷贝
|
|
||||||
weapon = Weapon(
|
|
||||||
id=101, name="TestWeapon", weapon_type=WeaponType.SWORD,
|
|
||||||
grade=EquipmentGrade.COMMON, sect_id=None, desc="Desc"
|
|
||||||
)
|
|
||||||
weapon.special_data = {"souls": 10, "nested": {"a": 1}}
|
|
||||||
|
|
||||||
weapon_copy = copy.deepcopy(weapon)
|
|
||||||
|
|
||||||
assert weapon_copy.special_data == weapon.special_data
|
|
||||||
assert weapon_copy.special_data is not weapon.special_data
|
|
||||||
assert weapon_copy.special_data["nested"] is not weapon.special_data["nested"]
|
|
||||||
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from src.classes.world import World
|
||||||
|
from src.classes.map import Map
|
||||||
|
from src.classes.tile import TileType
|
||||||
|
from src.classes.calendar import Month, Year, create_month_stamp
|
||||||
|
from src.classes.avatar import Avatar, Gender
|
||||||
|
from src.classes.age import Age
|
||||||
|
from src.classes.cultivation import Realm
|
||||||
|
from src.sim.simulator import Simulator
|
||||||
|
from src.sim.save.save_game import save_game
|
||||||
|
from src.sim.load.load_game import load_game
|
||||||
|
from src.utils.id_generator import get_avatar_id
|
||||||
|
from src.utils.config import CONFIG
|
||||||
|
|
||||||
|
# Helper to create a simple map
|
||||||
|
def create_simple_map():
|
||||||
|
m = Map(width=5, height=5) # Slightly larger to be safe
|
||||||
|
for x in range(5):
|
||||||
|
for y in range(5):
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_save_load_cycle(temp_save_dir):
|
||||||
|
"""
|
||||||
|
Test the full save and load cycle with a real World and Simulator instance,
|
||||||
|
but without running the LLM or stepping the simulation.
|
||||||
|
"""
|
||||||
|
# 1. Setup World
|
||||||
|
# Create a deterministic map for testing
|
||||||
|
game_map = create_simple_map()
|
||||||
|
|
||||||
|
# Set a specific time
|
||||||
|
start_year = Year(100)
|
||||||
|
start_month = Month.JANUARY
|
||||||
|
month_stamp = create_month_stamp(start_year, start_month)
|
||||||
|
|
||||||
|
world = World(map=game_map, month_stamp=month_stamp)
|
||||||
|
|
||||||
|
# 2. Add an Avatar
|
||||||
|
avatar_id = get_avatar_id()
|
||||||
|
avatar_name = "TestUser_SaveLoad"
|
||||||
|
avatar = Avatar(
|
||||||
|
world=world,
|
||||||
|
name=avatar_name,
|
||||||
|
id=avatar_id,
|
||||||
|
birth_month_stamp=create_month_stamp(Year(80), Month.JANUARY),
|
||||||
|
age=Age(20, Realm.Qi_Refinement),
|
||||||
|
gender=Gender.MALE
|
||||||
|
)
|
||||||
|
# Set some specific attributes to verify persistence
|
||||||
|
# Note: hp.max is recalculated from realm and effects on load, so setting it manually
|
||||||
|
# without a supporting effect will result in it being reset.
|
||||||
|
# We test hp.cur persistence instead (as long as it's <= max).
|
||||||
|
# Default max for Qi_Refinement is 100.
|
||||||
|
avatar.hp.cur = 80
|
||||||
|
|
||||||
|
# Add to world
|
||||||
|
world.avatar_manager.avatars[avatar.id] = avatar
|
||||||
|
|
||||||
|
# 3. Setup Simulator
|
||||||
|
sim = Simulator(world)
|
||||||
|
# Modify a config value on the instance to check if it persists
|
||||||
|
test_birth_rate = 0.99
|
||||||
|
sim.birth_rate = test_birth_rate
|
||||||
|
|
||||||
|
# 4. Prepare Existed Sects (Empty for this basic test)
|
||||||
|
existed_sects = []
|
||||||
|
|
||||||
|
# 5. Save Game
|
||||||
|
save_filename = "test_save_cycle.json"
|
||||||
|
save_path = temp_save_dir / save_filename
|
||||||
|
|
||||||
|
success, saved_name = save_game(world, sim, existed_sects, save_path)
|
||||||
|
|
||||||
|
assert success, "Save operation failed"
|
||||||
|
assert save_path.exists(), "Save file was not created"
|
||||||
|
|
||||||
|
# 6. Load Game
|
||||||
|
# We need to patch 'load_cultivation_world_map' because load_game calls it.
|
||||||
|
# We want it to return our simple map (or a new equivalent one) instead of loading the real huge map.
|
||||||
|
# Note: load_game imports it inside the function, so we patch where it is imported FROM if it was global,
|
||||||
|
# but since it's inside, we rely on sys.modules or patch the target module path.
|
||||||
|
# The import in load_game.py is: from src.run.load_map import load_cultivation_world_map
|
||||||
|
|
||||||
|
with patch('src.run.load_map.load_cultivation_world_map', return_value=create_simple_map()):
|
||||||
|
# We also need to be careful about 'sects_by_id' if we had sects, but we don't.
|
||||||
|
loaded_world, loaded_sim, loaded_sects = load_game(save_path)
|
||||||
|
|
||||||
|
# 7. Verification
|
||||||
|
|
||||||
|
# Verify World Metadata
|
||||||
|
assert loaded_world.month_stamp == world.month_stamp
|
||||||
|
assert loaded_world.month_stamp.get_year() == 100
|
||||||
|
|
||||||
|
# Verify Avatar
|
||||||
|
assert len(loaded_world.avatar_manager.avatars) == 1
|
||||||
|
assert avatar_id in loaded_world.avatar_manager.avatars
|
||||||
|
|
||||||
|
loaded_avatar = loaded_world.avatar_manager.avatars[avatar_id]
|
||||||
|
assert loaded_avatar.name == avatar_name
|
||||||
|
assert loaded_avatar.age.age == 20
|
||||||
|
assert loaded_avatar.cultivation_progress.realm == Realm.Qi_Refinement
|
||||||
|
assert loaded_avatar.gender == Gender.MALE
|
||||||
|
# hp.max is reset to 100 based on Realm.Qi_Refinement
|
||||||
|
assert loaded_avatar.hp.max == 100
|
||||||
|
assert loaded_avatar.hp.cur == 80
|
||||||
|
|
||||||
|
# Verify Simulator
|
||||||
|
assert loaded_sim.birth_rate == test_birth_rate
|
||||||
|
|
||||||
|
# Verify World/Simulator linkage
|
||||||
|
assert loaded_sim.world == loaded_world
|
||||||
|
assert loaded_avatar.world == loaded_world
|
||||||
|
|
||||||
|
def test_save_load_with_relations(temp_save_dir):
|
||||||
|
"""
|
||||||
|
Test saving and loading avatars with relationships.
|
||||||
|
"""
|
||||||
|
game_map = create_simple_map()
|
||||||
|
world = World(map=game_map, month_stamp=create_month_stamp(Year(1), Month.JANUARY))
|
||||||
|
|
||||||
|
# Create two avatars
|
||||||
|
av1 = Avatar(world, "Av1", get_avatar_id(), create_month_stamp(Year(1), Month.JANUARY), Age(20, Realm.Qi_Refinement), Gender.MALE)
|
||||||
|
av2 = Avatar(world, "Av2", get_avatar_id(), create_month_stamp(Year(1), Month.JANUARY), Age(20, Realm.Qi_Refinement), Gender.FEMALE)
|
||||||
|
|
||||||
|
world.avatar_manager.avatars[av1.id] = av1
|
||||||
|
world.avatar_manager.avatars[av2.id] = av2
|
||||||
|
|
||||||
|
# Add relationship
|
||||||
|
from src.classes.relation import Relation
|
||||||
|
|
||||||
|
# Manually adding relation for test (usually done via helper methods)
|
||||||
|
# relation value is integer
|
||||||
|
av1.relations[av2] = Relation.FRIEND
|
||||||
|
|
||||||
|
sim = Simulator(world)
|
||||||
|
|
||||||
|
save_path = temp_save_dir / "test_relation.json"
|
||||||
|
save_game(world, sim, [], save_path)
|
||||||
|
|
||||||
|
with patch('src.run.load_map.load_cultivation_world_map', return_value=create_simple_map()):
|
||||||
|
l_world, _, _ = load_game(save_path)
|
||||||
|
|
||||||
|
l_av1 = l_world.avatar_manager.avatars[av1.id]
|
||||||
|
l_av2 = l_world.avatar_manager.avatars[av2.id]
|
||||||
|
|
||||||
|
assert l_av2 in l_av1.relations
|
||||||
|
assert l_av1.relations[l_av2] == Relation.FRIEND
|
||||||
|
|||||||
Reference in New Issue
Block a user