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:
Zihao Xu
2026-01-07 23:41:25 -08:00
committed by bridge
parent 624f697bee
commit 8631be501b
3 changed files with 141 additions and 4 deletions

View File

@@ -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():

View File

@@ -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)

View File

@@ -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 () => {