From 8631be501b0044996d7b7290aec5dd7d27acfea7 Mon Sep 17 00:00:00 2001 From: Zihao Xu Date: Wed, 7 Jan 2026 23:41:25 -0800 Subject: [PATCH] fix: prevent game from auto-starting to avoid stale initialization events Problem: When loading a save (e.g., from year 106), events from year 100 would appear. This happened because the game auto-started on server startup and client connection, generating initialization events before the user could load a save. Solution: 1. Backend: Keep game paused on startup even if LLM check passes 2. Backend: Remove auto-resume on first WebSocket connection 3. Frontend: Start with game paused (isManualPaused = true) Now the user must explicitly click 'resume' to start a new game, or load a save first. This prevents the race condition where game_loop generates events with stale world state. --- src/server/main.py | 10 ++- tests/test_save_load.py | 132 ++++++++++++++++++++++++++++++++++++++++ web/src/App.vue | 3 +- 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/src/server/main.py b/src/server/main.py index 85a19cc..00c60ea 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -126,9 +126,10 @@ class ConnectionManager: await websocket.accept() self.active_connections.append(websocket) - # 当第一个客户端连接时,自动恢复游戏 + # 不再自动恢复游戏,让用户明确选择"新游戏"或"加载存档"。 + # 这样可以避免在用户加载存档前就生成初始化事件。 if len(self.active_connections) == 1: - self._set_pause_state(False, "检测到客户端连接,自动恢复游戏运行。") + print("[Auto-Control] 检测到客户端连接,游戏保持暂停状态,等待用户操作。") def disconnect(self, websocket: WebSocket): if websocket in self.active_connections: @@ -367,7 +368,10 @@ def init_game(): print("LLM 连通性检测通过 ✓") game_instance["llm_check_failed"] = False game_instance["llm_error_message"] = "" - game_instance["is_paused"] = False + # 即使 LLM 检测通过,也保持暂停状态。 + # 等待用户选择"新游戏"或"加载存档"后再开始运行。 + # 这样可以避免在用户加载存档前就生成初始化事件(如长期目标)。 + game_instance["is_paused"] = True # ===== LLM 检测结束 ===== async def game_loop(): diff --git a/tests/test_save_load.py b/tests/test_save_load.py index 197a14b..d47e55f 100644 --- a/tests/test_save_load.py +++ b/tests/test_save_load.py @@ -705,3 +705,135 @@ class TestDatabaseSwitchingOnLoad: assert major_events[0].content == "Birth event" loaded_world.event_manager.close() + + +# API Load Game Tests - Race Condition Prevention +# ============================================================================= + +class TestApiLoadGamePausesGame: + """ + Tests for the race condition fix in api_load_game. + + The bug: When loading a save, the game_loop could still be running with the + old world, generating events with stale timestamps (e.g., 100年1月 events + appearing after loading a 106年 save). + + The fix: Pause the game before loading and keep it paused after. + """ + + def test_load_game_pauses_game_instance(self, temp_save_dir): + """Test that api_load_game sets is_paused to True.""" + from fastapi.testclient import TestClient + from src.server import main + + # Create a simple save file. + game_map = create_test_map() + month_stamp = create_month_stamp(Year(200), Month.JUNE) + world = World(map=game_map, month_stamp=month_stamp) + + avatar = Avatar( + world=world, + name="TestAvatar", + id=get_avatar_id(), + birth_month_stamp=create_month_stamp(Year(180), Month.JANUARY), + age=Age(20, Realm.Qi_Refinement), + gender=Gender.MALE, + ) + world.avatar_manager.avatars[avatar.id] = avatar + + sim = Simulator(world) + save_path = temp_save_dir / "test_pause.json" + save_game(world, sim, [], save_path) + + # Setup: game is running (not paused). + original_state = main.game_instance.copy() + main.game_instance["is_paused"] = False + main.game_instance["world"] = World( + map=create_test_map(), + month_stamp=create_month_stamp(Year(100), Month.JANUARY), + ) + main.game_instance["sim"] = MagicMock() + + # Mock CONFIG.paths.saves to point to our temp dir. + with patch.object(CONFIG.paths, "saves", temp_save_dir): + with patch('src.run.load_map.load_cultivation_world_map', return_value=create_test_map()): + client = TestClient(main.app) + response = client.post( + "/api/game/load", + json={"filename": "test_pause.json"} + ) + + assert response.status_code == 200 + + # Key assertion: game should be paused after load. + assert main.game_instance["is_paused"] is True + + # Verify the world was actually replaced. + assert main.game_instance["world"].month_stamp.get_year() == 200 + + # Cleanup. + main.game_instance.update(original_state) + + def test_load_game_prevents_stale_events(self, temp_save_dir): + """ + Test that pausing during load prevents events with old timestamps. + + This simulates the race condition scenario: + 1. Old world is at 100年1月 + 2. User loads a save from 200年6月 + 3. Without the fix, game_loop might generate 100年1月 events + 4. With the fix, game is paused so no stale events are generated + """ + from src.server import main + + # Create a save at 200年. + game_map = create_test_map() + save_world = World( + map=game_map, + month_stamp=create_month_stamp(Year(200), Month.JUNE), + ) + sim = Simulator(save_world) + save_path = temp_save_dir / "test_stale.json" + save_game(save_world, sim, [], save_path) + + # Setup: "old" world at 100年. + old_world = World( + map=create_test_map(), + month_stamp=create_month_stamp(Year(100), Month.JANUARY), + ) + + original_state = main.game_instance.copy() + main.game_instance["world"] = old_world + main.game_instance["sim"] = Simulator(old_world) + main.game_instance["is_paused"] = False + + # Simulate what happens during load. + # Before the fix, is_paused would remain False during load_game(). + # After the fix, is_paused is set to True before load_game(). + + # Verify initial state. + assert main.game_instance["is_paused"] is False + assert main.game_instance["world"].month_stamp.get_year() == 100 + + # Perform load (this should pause the game). + with patch.object(CONFIG.paths, "saves", temp_save_dir): + with patch('src.run.load_map.load_cultivation_world_map', return_value=create_test_map()): + from fastapi.testclient import TestClient + client = TestClient(main.app) + response = client.post( + "/api/game/load", + json={"filename": "test_stale.json"} + ) + + assert response.status_code == 200 + + # After load: game is paused, world is updated. + assert main.game_instance["is_paused"] is True + assert main.game_instance["world"].month_stamp.get_year() == 200 + + # The key point: because is_paused is True, game_loop will skip + # sim.step(), so no events with stale timestamps (100年) will be + # generated. The frontend will receive a clean state at 200年. + + # Cleanup. + main.game_instance.update(original_state) diff --git a/web/src/App.vue b/web/src/App.vue index f3ecd8e..fe104f0 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -18,7 +18,8 @@ const uiStore = useUiStore() const socketStore = useSocketStore() const showMenu = ref(false) -const isManualPaused = ref(false) +// 启动时默认暂停,让用户选择"新游戏"或"加载存档"后再继续。 +const isManualPaused = ref(true) const menuDefaultTab = ref<'save' | 'load' | 'create' | 'delete' | 'llm'>('load') onMounted(async () => {