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.
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user