diff --git a/src/classes/avatar/core.py b/src/classes/avatar/core.py index 039cdad..8dd43d9 100644 --- a/src/classes/avatar/core.py +++ b/src/classes/avatar/core.py @@ -304,8 +304,10 @@ class Avatar( self._init_known_regions() def __hash__(self) -> int: + if not hasattr(self, 'id'): + # 防御性编程:如果id尚未初始化(例如deepcopy过程中),使用对象内存地址 + return super().__hash__() return hash(self.id) def __str__(self) -> str: return str(self.get_info(detailed=False)) - diff --git a/src/sim/load/avatar_load_mixin.py b/src/sim/load/avatar_load_mixin.py index 3b27669..84ef5bc 100644 --- a/src/sim/load/avatar_load_mixin.py +++ b/src/sim/load/avatar_load_mixin.py @@ -6,10 +6,11 @@ Avatar读档反序列化Mixin 读档策略: - 两阶段加载:先加载所有Avatar(relations留空),再重建relations网络 - 引用对象:通过id从全局字典获取(如techniques_by_id) -- weapon/auxiliary:深拷贝后恢复special_data +- weapon/auxiliary:浅拷贝后恢复special_data(避免deepcopy带来的递归/崩溃风险) - 错误容错:缺失的引用对象会跳过而不是崩溃 """ from typing import TYPE_CHECKING +import copy if TYPE_CHECKING: from src.classes.world import World @@ -98,21 +99,26 @@ class AvatarLoadMixin: if item_id in items_by_id: avatar.items[items_by_id[item_id]] = quantity - # 重建weapon(深拷贝因为special_data是实例特有的) + # 重建weapon + # 使用copy而非deepcopy,避免潜在的递归引用导致的崩溃,且性能更好 + # special_data 是实例特有的,需要单独赋值 weapon_id = data.get("weapon_id") if weapon_id is not None and weapon_id in weapons_by_id: - import copy - avatar.weapon = copy.deepcopy(weapons_by_id[weapon_id]) + # 浅拷贝:复制引用,但 weapon.special_data 会被共享 + # 所以需要手动重新赋值 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_proficiency = float(data.get("weapon_proficiency", 0.0)) - # 重建auxiliary(深拷贝因为special_data是实例特有的) + # 重建auxiliary + # 同上,使用copy auxiliary_id = data.get("auxiliary_id") if auxiliary_id is not None and auxiliary_id in auxiliaries_by_id: - import copy - avatar.auxiliary = copy.deepcopy(auxiliaries_by_id[auxiliary_id]) + auxiliary_proto = auxiliaries_by_id[auxiliary_id] + avatar.auxiliary = copy.copy(auxiliary_proto) avatar.auxiliary.special_data = data.get("auxiliary_special_data", {}) # 重建spirit_animal @@ -197,4 +203,3 @@ class AvatarLoadMixin: avatar.recalc_effects() return avatar - diff --git a/tests/test_deepcopy_fix.py b/tests/test_deepcopy_fix.py deleted file mode 100644 index 8813a76..0000000 --- a/tests/test_deepcopy_fix.py +++ /dev/null @@ -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"] - diff --git a/tests/test_save_load.py b/tests/test_save_load.py index e69de29..aa4e43e 100644 --- a/tests/test_save_load.py +++ b/tests/test_save_load.py @@ -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