From 9485b62cfdb3b5a2dd14c0c352e86443db98c069 Mon Sep 17 00:00:00 2001 From: Zihao Xu Date: Thu, 8 Jan 2026 00:57:21 -0800 Subject: [PATCH] feat: add loading screen with progress tracking - Add async initialization with 6 phases: scanning_assets, loading_map, initializing_sects, generating_avatars, checking_llm, generating_initial_events - Add /api/init-status endpoint for frontend polling - Add /api/control/reinit endpoint for error recovery - Add LoadingOverlay.vue component with: - Progress ring with gradient - Phase text in xianxia style (rotating messages for LLM phase) - Tips that rotate every 5 seconds - Time-based background transparency (fades to 80% over 20s) - Backdrop blur effect - Error state with retry button - Preload map and avatars during LLM initialization for smoother UX - Add comprehensive tests for init status API --- src/server/main.py | 279 ++++++++++- tests/test_init_status_api.py | 405 ++++++++++++++++ web/src/App.vue | 118 ++++- web/src/api/game.ts | 25 + web/src/components/LoadingOverlay.vue | 441 ++++++++++++++++++ .../game/panels/system/SaveLoadPanel.vue | 9 +- web/src/stores/world.ts | 64 ++- 7 files changed, 1305 insertions(+), 36 deletions(-) create mode 100644 tests/test_init_status_api.py create mode 100644 web/src/components/LoadingOverlay.vue diff --git a/src/server/main.py b/src/server/main.py index 00c60ea..f170d11 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -3,6 +3,7 @@ import os import asyncio import webbrowser import subprocess +import time from contextlib import asynccontextmanager from typing import List, Optional from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query @@ -43,7 +44,14 @@ from src.utils.llm.config import LLMConfig, LLMMode game_instance = { "world": None, "sim": None, - "is_paused": True # 默认启动为暂停状态,等待前端连接唤醒 + "is_paused": True, # 默认启动为暂停状态,等待前端连接唤醒 + # 初始化状态字段 + "init_status": "idle", # idle | pending | in_progress | ready | error + "init_phase": 0, # 当前阶段 (0-5) + "init_phase_name": "", # 当前阶段名称 + "init_progress": 0, # 总体进度 (0-100) + "init_error": None, # 错误信息 + "init_start_time": None, # 初始化开始时间戳 } # Cache for avatar IDs @@ -273,12 +281,138 @@ def check_llm_connectivity() -> tuple[bool, str]: except Exception as e: return False, f"连通性检测异常:{str(e)}" -def init_game(): - """初始化游戏世界,逻辑复用自 src/run/run.py""" +# 初始化阶段名称映射(用于前端显示) +INIT_PHASE_NAMES = { + 0: "scanning_assets", + 1: "loading_map", + 2: "initializing_sects", + 3: "generating_avatars", + 4: "checking_llm", + 5: "generating_initial_events", +} + +def update_init_progress(phase: int, phase_name: str = ""): + """更新初始化进度。""" + game_instance["init_phase"] = phase + game_instance["init_phase_name"] = phase_name or INIT_PHASE_NAMES.get(phase, "") + # 每阶段占约 16.7%(共 6 阶段),最后一阶段到 100% + progress_map = {0: 0, 1: 17, 2: 33, 3: 50, 4: 67, 5: 83} + game_instance["init_progress"] = progress_map.get(phase, phase * 17) + print(f"[Init] Phase {phase}: {game_instance['init_phase_name']} ({game_instance['init_progress']}%)") + +async def init_game_async(): + """异步初始化游戏世界,带进度更新。""" + game_instance["init_status"] = "in_progress" + game_instance["init_start_time"] = time.time() + game_instance["init_error"] = None + + try: + # 阶段 0: 资源扫描 + update_init_progress(0, "scanning_assets") + await asyncio.to_thread(scan_avatar_assets) + + # 阶段 1: 地图加载 + update_init_progress(1, "loading_map") + game_map = await asyncio.to_thread(load_cultivation_world_map) + world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY)) + sim = Simulator(world) + + # 阶段 2: 宗门初始化 + update_init_progress(2, "initializing_sects") + all_sects = list(sects_by_id.values()) + needed_sects = int(getattr(CONFIG.game, "sect_num", 0) or 0) + existed_sects = [] + if needed_sects > 0 and all_sects: + pool = list(all_sects) + random.shuffle(pool) + existed_sects = pool[:needed_sects] + + # 阶段 3: 角色生成 + update_init_progress(3, "generating_avatars") + protagonist_mode = getattr(CONFIG.avatar, "protagonist", "none") + target_total_count = int(getattr(CONFIG.game, "init_npc_num", 12)) + final_avatars = {} + + spawned_protagonists_count = 0 + if protagonist_mode in ["all", "random"]: + prob = 1.0 if protagonist_mode == "all" else 0.05 + def _spawn_protagonists_sync(): + return prot_utils.spawn_protagonists(world, world.month_stamp, probability=prob) + prot_avatars = await asyncio.to_thread(_spawn_protagonists_sync) + final_avatars.update(prot_avatars) + spawned_protagonists_count = len(prot_avatars) + print(f"生成了 {spawned_protagonists_count} 位主角 (Mode: {protagonist_mode})") + + remaining_count = 0 + if protagonist_mode == "all": + remaining_count = 0 + else: + remaining_count = max(0, target_total_count - spawned_protagonists_count) + + if remaining_count > 0: + def _make_random_sync(): + return _new_make_random( + world, + count=remaining_count, + current_month_stamp=world.month_stamp, + existed_sects=existed_sects + ) + random_avatars = await asyncio.to_thread(_make_random_sync) + final_avatars.update(random_avatars) + print(f"生成了 {len(random_avatars)} 位随机路人") + + world.avatar_manager.avatars.update(final_avatars) + game_instance["world"] = world + game_instance["sim"] = sim + + # 阶段 4: LLM 连通性检测 + update_init_progress(4, "checking_llm") + print("正在检测 LLM 连通性...") + # 使用线程池执行,避免阻塞事件循环,让 /api/init-status 可以响应 + success, error_msg = await asyncio.to_thread(check_llm_connectivity) + + if not success: + print(f"[警告] LLM 连通性检测失败: {error_msg}") + game_instance["llm_check_failed"] = True + game_instance["llm_error_message"] = error_msg + else: + print("LLM 连通性检测通过 ✓") + game_instance["llm_check_failed"] = False + game_instance["llm_error_message"] = "" + + # 阶段 5: 生成初始事件(第一次 sim.step) + update_init_progress(5, "generating_initial_events") + print("正在生成初始事件...") + + # 取消暂停,执行第一步来生成初始事件 + game_instance["is_paused"] = False + try: + await sim.step() + print("初始事件生成完成 ✓") + except Exception as e: + print(f"[警告] 初始事件生成失败: {e}") + finally: + # 执行完后重新暂停,等待前端准备好 + game_instance["is_paused"] = True + + # 完成 + game_instance["init_status"] = "ready" + game_instance["init_progress"] = 100 + print("游戏世界初始化完成!") + + except Exception as e: + import traceback + traceback.print_exc() + game_instance["init_status"] = "error" + game_instance["init_error"] = str(e) + print(f"[Error] 初始化失败: {e}") + + + """初始化游戏世界,逻辑复用自 src/run/run.py (同步版本,保留用于向后兼容)""" from datetime import datetime from src.sim.load_game import get_events_db_path - print("正在初始化游戏世界...") + scan_avatar_assets() game_map = load_cultivation_world_map() # 生成时间戳命名的存档路径 @@ -373,18 +507,37 @@ def init_game(): # 这样可以避免在用户加载存档前就生成初始化事件(如长期目标)。 game_instance["is_paused"] = True # ===== LLM 检测结束 ===== + + # 更新初始化状态为就绪 + game_instance["init_status"] = "ready" + game_instance["init_progress"] = 100 async def game_loop(): - """后台自动运行游戏循环""" - print("后台游戏循环已启动...") + """后台自动运行游戏循环。""" + print("后台游戏循环已启动,等待初始化完成...") + + # 等待初始化完成 + while game_instance.get("init_status") not in ("ready", "error"): + await asyncio.sleep(0.5) + + if game_instance.get("init_status") == "error": + print("[game_loop] 初始化失败,游戏循环退出。") + return + + print("[game_loop] 初始化完成,开始游戏循环。") + while True: # 控制游戏速度,例如每秒 1 次更新 - await asyncio.sleep(1.0) + await asyncio.sleep(1.0) try: # 检查暂停状态 if game_instance.get("is_paused", False): continue + + # 再次检查初始化状态(可能被重新初始化) + if game_instance.get("init_status") != "ready": + continue sim = game_instance.get("sim") world = game_instance.get("world") @@ -466,10 +619,12 @@ async def game_loop(): @asynccontextmanager async def lifespan(app: FastAPI): - # 启动时初始化 - scan_avatar_assets() - init_game() - # 启动后台任务 + # 启动时自动开始异步初始化游戏 + # 前端会轮询 /api/init-status 来显示加载进度 + print("服务器启动,开始异步初始化游戏...") + asyncio.create_task(init_game_async()) + + # 启动后台游戏循环(会自动等待初始化完成) asyncio.create_task(game_loop()) npm_process = None @@ -805,6 +960,73 @@ def resume_game(): game_instance["is_paused"] = False return {"status": "ok", "message": "Game resumed"} + +# --- 初始化状态 API --- + +@app.get("/api/init-status") +def get_init_status(): + """获取初始化状态。""" + status = game_instance.get("init_status", "idle") + start_time = game_instance.get("init_start_time") + elapsed = time.time() - start_time if start_time else 0 + + return { + "status": status, + "phase": game_instance.get("init_phase", 0), + "phase_name": game_instance.get("init_phase_name", ""), + "progress": game_instance.get("init_progress", 0), + "elapsed_seconds": round(elapsed, 1), + "error": game_instance.get("init_error"), + # 额外信息:LLM 状态 + "llm_check_failed": game_instance.get("llm_check_failed", False), + "llm_error_message": game_instance.get("llm_error_message", ""), + } + + +@app.post("/api/game/new") +async def start_new_game(): + """开始新游戏(异步初始化)。""" + current_status = game_instance.get("init_status", "idle") + + # 如果已经在初始化中,返回错误 + if current_status == "in_progress": + raise HTTPException(status_code=400, detail="Game is already initializing") + + # 如果已经初始化完成,需要重置 + if current_status == "ready": + # 清理旧的游戏状态 + game_instance["world"] = None + game_instance["sim"] = None + + # 重置初始化状态 + game_instance["init_status"] = "pending" + game_instance["init_phase"] = 0 + game_instance["init_progress"] = 0 + game_instance["init_error"] = None + + # 启动异步初始化任务 + asyncio.create_task(init_game_async()) + + return {"status": "ok", "message": "New game initialization started"} + + +@app.post("/api/control/reinit") +async def reinit_game(): + """重新初始化游戏(用于错误恢复)。""" + # 清理旧的游戏状态 + game_instance["world"] = None + game_instance["sim"] = None + game_instance["init_status"] = "pending" + game_instance["init_phase"] = 0 + game_instance["init_progress"] = 0 + game_instance["init_error"] = None + + # 启动异步初始化任务 + asyncio.create_task(init_game_async()) + + return {"status": "ok", "message": "Reinitialization started"} + + @app.get("/api/detail") def get_detail_info( target_type: str = Query(alias="type"), @@ -1298,8 +1520,8 @@ def api_save_game(req: SaveGameRequest): raise HTTPException(status_code=500, detail="Save failed") @app.post("/api/game/load") -def api_load_game(req: LoadGameRequest): - """加载游戏""" +async def api_load_game(req: LoadGameRequest): + """加载游戏(异步,支持进度更新)。""" # 安全检查:只允许加载 saves 目录下的文件 if ".." in req.filename or "/" in req.filename or "\\" in req.filename: raise HTTPException(status_code=400, detail="Invalid filename") @@ -1311,8 +1533,22 @@ def api_load_game(req: LoadGameRequest): if not target_path.exists(): raise HTTPException(status_code=404, detail="File not found") + # 设置加载状态 + game_instance["init_status"] = "in_progress" + game_instance["init_start_time"] = time.time() + game_instance["init_error"] = None + game_instance["init_phase"] = 0 + game_instance["init_phase_name"] = "loading_save" + game_instance["init_progress"] = 10 + # 暂停游戏,防止 game_loop 在加载过程中使用旧 world 生成事件。 game_instance["is_paused"] = True + await asyncio.sleep(0) # 让出控制权 + + # 更新进度 + game_instance["init_progress"] = 30 + game_instance["init_phase_name"] = "parsing_data" + await asyncio.sleep(0) # 关闭旧 World 的 EventManager,释放 SQLite 连接。 old_world = game_instance.get("world") @@ -1321,6 +1557,11 @@ def api_load_game(req: LoadGameRequest): # 加载 new_world, new_sim, new_sects = load_game(target_path) + + # 更新进度 + game_instance["init_progress"] = 70 + game_instance["init_phase_name"] = "restoring_state" + await asyncio.sleep(0) # 确保挂载 existed_sects 以便下次保存 new_world.existed_sects = new_sects @@ -1330,6 +1571,16 @@ def api_load_game(req: LoadGameRequest): game_instance["sim"] = new_sim game_instance["current_save_path"] = target_path + # 更新进度 + game_instance["init_progress"] = 90 + game_instance["init_phase_name"] = "finalizing" + await asyncio.sleep(0) + + # 加载完成 + game_instance["init_status"] = "ready" + game_instance["init_progress"] = 100 + game_instance["init_phase_name"] = "complete" + # 加载完成后保持暂停状态,让用户决定何时恢复。 # 这也给前端时间来刷新状态。 @@ -1337,6 +1588,8 @@ def api_load_game(req: LoadGameRequest): except Exception as e: import traceback traceback.print_exc() + game_instance["init_status"] = "error" + game_instance["init_error"] = str(e) raise HTTPException(status_code=500, detail=f"Load failed: {str(e)}") # --- 静态文件挂载 (必须放在最后) --- diff --git a/tests/test_init_status_api.py b/tests/test_init_status_api.py new file mode 100644 index 0000000..20becc4 --- /dev/null +++ b/tests/test_init_status_api.py @@ -0,0 +1,405 @@ +""" +Tests for the initialization status API endpoints. + +These tests verify the loading screen backend functionality: +- /api/init-status endpoint +- /api/game/new endpoint +- /api/control/reinit endpoint +- Initialization phases and progress tracking +""" + +import pytest +import asyncio +import time +from unittest.mock import patch, MagicMock, AsyncMock +from fastapi.testclient import TestClient + +from src.server import main +from src.server.main import app, game_instance, update_init_progress, INIT_PHASE_NAMES + + +@pytest.fixture +def client(): + """Create a test client for the FastAPI app.""" + return TestClient(app) + + +@pytest.fixture +def reset_game_instance(): + """Reset game_instance to initial state before each test.""" + original_state = dict(game_instance) + game_instance.clear() + game_instance.update({ + "world": None, + "sim": None, + "is_paused": True, + "init_status": "idle", + "init_phase": 0, + "init_phase_name": "", + "init_progress": 0, + "init_start_time": None, + "init_error": None, + "llm_check_failed": False, + "llm_error_message": "", + }) + yield + game_instance.clear() + game_instance.update(original_state) + + +class TestInitStatusEndpoint: + """Tests for /api/init-status endpoint.""" + + def test_init_status_idle(self, client, reset_game_instance): + """Test init-status returns idle state correctly.""" + response = client.get("/api/init-status") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "idle" + assert data["phase"] == 0 + assert data["phase_name"] == "" + assert data["progress"] == 0 + assert data["error"] is None + assert data["llm_check_failed"] is False + assert data["llm_error_message"] == "" + + def test_init_status_in_progress(self, client, reset_game_instance): + """Test init-status during initialization.""" + game_instance["init_status"] = "in_progress" + game_instance["init_phase"] = 2 + game_instance["init_phase_name"] = "initializing_sects" + game_instance["init_progress"] = 33 + game_instance["init_start_time"] = time.time() - 5 # 5 seconds ago + + response = client.get("/api/init-status") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "in_progress" + assert data["phase"] == 2 + assert data["phase_name"] == "initializing_sects" + assert data["progress"] == 33 + assert data["elapsed_seconds"] >= 5 + + def test_init_status_ready(self, client, reset_game_instance): + """Test init-status when initialization is complete.""" + game_instance["init_status"] = "ready" + game_instance["init_phase"] = 5 + game_instance["init_phase_name"] = "generating_initial_events" + game_instance["init_progress"] = 100 + + response = client.get("/api/init-status") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "ready" + assert data["progress"] == 100 + + def test_init_status_error(self, client, reset_game_instance): + """Test init-status when initialization failed.""" + game_instance["init_status"] = "error" + game_instance["init_error"] = "LLM connection failed" + + response = client.get("/api/init-status") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "error" + assert data["error"] == "LLM connection failed" + + def test_init_status_llm_check_failed(self, client, reset_game_instance): + """Test init-status includes LLM check status.""" + game_instance["init_status"] = "ready" + game_instance["llm_check_failed"] = True + game_instance["llm_error_message"] = "API key invalid" + + response = client.get("/api/init-status") + assert response.status_code == 200 + + data = response.json() + assert data["llm_check_failed"] is True + assert data["llm_error_message"] == "API key invalid" + + +class TestUpdateInitProgress: + """Tests for update_init_progress function.""" + + def test_update_progress_with_phase_name(self, reset_game_instance): + """Test updating progress with explicit phase name.""" + update_init_progress(2, "initializing_sects") + + assert game_instance["init_phase"] == 2 + assert game_instance["init_phase_name"] == "initializing_sects" + assert game_instance["init_progress"] == 33 + + def test_update_progress_without_phase_name(self, reset_game_instance): + """Test updating progress uses default phase name from mapping.""" + update_init_progress(3) + + assert game_instance["init_phase"] == 3 + assert game_instance["init_phase_name"] == "generating_avatars" + assert game_instance["init_progress"] == 50 + + def test_all_phase_names_mapped(self): + """Test all phases have corresponding names.""" + expected_phases = { + 0: "scanning_assets", + 1: "loading_map", + 2: "initializing_sects", + 3: "generating_avatars", + 4: "checking_llm", + 5: "generating_initial_events", + } + assert INIT_PHASE_NAMES == expected_phases + + def test_progress_percentages(self, reset_game_instance): + """Test progress percentages for each phase.""" + expected_progress = {0: 0, 1: 17, 2: 33, 3: 50, 4: 67, 5: 83} + + for phase, expected in expected_progress.items(): + update_init_progress(phase) + assert game_instance["init_progress"] == expected, f"Phase {phase} should have progress {expected}" + + +class TestNewGameEndpoint: + """Tests for /api/game/new endpoint.""" + + def test_new_game_starts_initialization(self, client, reset_game_instance): + """Test /api/game/new starts initialization process.""" + with patch.object(main, 'init_game_async', new_callable=AsyncMock) as mock_init: + response = client.post("/api/game/new") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert "started" in data["message"].lower() + assert game_instance["init_status"] == "pending" + + def test_new_game_rejects_when_in_progress(self, client, reset_game_instance): + """Test /api/game/new rejects request when already initializing.""" + game_instance["init_status"] = "in_progress" + + response = client.post("/api/game/new") + + assert response.status_code == 400 + assert "already initializing" in response.json()["detail"].lower() + + def test_new_game_clears_existing_state(self, client, reset_game_instance): + """Test /api/game/new clears existing game state when ready.""" + mock_world = MagicMock() + mock_sim = MagicMock() + game_instance["world"] = mock_world + game_instance["sim"] = mock_sim + game_instance["init_status"] = "ready" + + with patch.object(main, 'init_game_async', new_callable=AsyncMock): + response = client.post("/api/game/new") + + assert response.status_code == 200 + assert game_instance["world"] is None + assert game_instance["sim"] is None + + +class TestReinitEndpoint: + """Tests for /api/control/reinit endpoint.""" + + def test_reinit_clears_state(self, client, reset_game_instance): + """Test /api/control/reinit clears all game state.""" + game_instance["world"] = MagicMock() + game_instance["sim"] = MagicMock() + game_instance["init_status"] = "error" + game_instance["init_error"] = "Some error" + game_instance["init_phase"] = 3 + game_instance["init_progress"] = 50 + + with patch.object(main, 'init_game_async', new_callable=AsyncMock): + response = client.post("/api/control/reinit") + + assert response.status_code == 200 + assert game_instance["world"] is None + assert game_instance["sim"] is None + assert game_instance["init_status"] == "pending" + assert game_instance["init_phase"] == 0 + assert game_instance["init_progress"] == 0 + assert game_instance["init_error"] is None + + def test_reinit_starts_new_initialization(self, client, reset_game_instance): + """Test /api/control/reinit starts new initialization task.""" + with patch.object(main, 'init_game_async', new_callable=AsyncMock) as mock_init: + response = client.post("/api/control/reinit") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert "reinitialization" in data["message"].lower() + + +class TestMapAndStateAPIDuringInit: + """Tests to verify /api/map and /api/state availability during initialization phases.""" + + def test_map_available_during_checking_llm(self, client, reset_game_instance): + """Test /api/map is available when world exists (checking_llm phase).""" + # Simulate world being created but LLM check in progress. + mock_world = MagicMock() + mock_map = MagicMock() + mock_map.width = 100 + mock_map.height = 100 + mock_map.tiles = {} + mock_map.regions = {} + mock_world.map = mock_map + + game_instance["world"] = mock_world + game_instance["init_status"] = "in_progress" + game_instance["init_phase"] = 4 + game_instance["init_phase_name"] = "checking_llm" + + # The /api/map endpoint should work. + response = client.get("/api/map") + # It may return data or empty, but should not error with 503. + assert response.status_code == 200 + + def test_state_available_during_generating_events(self, client, reset_game_instance): + """Test /api/state is available during generating_initial_events phase.""" + mock_world = MagicMock() + mock_world.month_stamp.get_year.return_value = 100 + mock_world.month_stamp.get_month.return_value = MagicMock(value=1) + mock_world.avatar_manager.avatars = {} + mock_world.event_manager = None + + game_instance["world"] = mock_world + game_instance["init_status"] = "in_progress" + game_instance["init_phase"] = 5 + game_instance["init_phase_name"] = "generating_initial_events" + + response = client.get("/api/state") + assert response.status_code == 200 + + +class TestInitGameAsync: + """Tests for the async initialization flow.""" + + @pytest.mark.asyncio + async def test_init_sets_status_to_in_progress(self, reset_game_instance): + """Test initialization sets status to in_progress immediately.""" + with patch.object(main, 'scan_avatar_assets'), \ + patch.object(main, 'load_cultivation_world_map') as mock_load_map, \ + patch.object(main, 'check_llm_connectivity', return_value=(True, "")), \ + patch('src.server.main.World') as mock_world_class, \ + patch('src.server.main.Simulator') as mock_sim_class: + + mock_map = MagicMock() + mock_load_map.return_value = mock_map + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_world_class.return_value = mock_world + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + mock_sim_class.return_value = mock_sim + + # Start init but check status immediately. + task = asyncio.create_task(main.init_game_async()) + await asyncio.sleep(0.01) # Let it start. + + assert game_instance["init_status"] in ["in_progress", "ready"] + + await task # Let it complete. + + @pytest.mark.asyncio + async def test_init_error_sets_error_status(self, reset_game_instance): + """Test initialization error sets status to error.""" + with patch.object(main, 'scan_avatar_assets', side_effect=Exception("Test error")): + await main.init_game_async() + + assert game_instance["init_status"] == "error" + assert "Test error" in game_instance["init_error"] + + @pytest.mark.asyncio + async def test_init_completes_with_ready_status(self, reset_game_instance): + """Test successful initialization sets status to ready.""" + with patch.object(main, 'scan_avatar_assets'), \ + patch.object(main, 'load_cultivation_world_map') as mock_load_map, \ + patch.object(main, 'check_llm_connectivity', return_value=(True, "")), \ + patch('src.server.main.World') as mock_world_class, \ + patch('src.server.main.Simulator') as mock_sim_class, \ + patch('src.server.main.sects_by_id', {}), \ + patch('src.server.main.CONFIG') as mock_config: + + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 0 + mock_config.avatar.protagonist = "none" + + mock_map = MagicMock() + mock_load_map.return_value = mock_map + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_world_class.return_value = mock_world + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + mock_sim_class.return_value = mock_sim + + await main.init_game_async() + + assert game_instance["init_status"] == "ready" + assert game_instance["init_progress"] == 100 + + @pytest.mark.asyncio + async def test_init_records_llm_failure(self, reset_game_instance): + """Test LLM check failure is recorded but doesn't stop initialization.""" + with patch.object(main, 'scan_avatar_assets'), \ + patch.object(main, 'load_cultivation_world_map') as mock_load_map, \ + patch.object(main, 'check_llm_connectivity', return_value=(False, "API key invalid")), \ + patch('src.server.main.World') as mock_world_class, \ + patch('src.server.main.Simulator') as mock_sim_class, \ + patch('src.server.main.sects_by_id', {}), \ + patch('src.server.main.CONFIG') as mock_config: + + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 0 + mock_config.avatar.protagonist = "none" + + mock_map = MagicMock() + mock_load_map.return_value = mock_map + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_world_class.return_value = mock_world + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + mock_sim_class.return_value = mock_sim + + await main.init_game_async() + + # Should still complete successfully. + assert game_instance["init_status"] == "ready" + # But LLM failure should be recorded. + assert game_instance["llm_check_failed"] is True + assert game_instance["llm_error_message"] == "API key invalid" + + @pytest.mark.asyncio + async def test_init_pauses_after_initial_events(self, reset_game_instance): + """Test game is paused after generating initial events.""" + with patch.object(main, 'scan_avatar_assets'), \ + patch.object(main, 'load_cultivation_world_map') as mock_load_map, \ + patch.object(main, 'check_llm_connectivity', return_value=(True, "")), \ + patch('src.server.main.World') as mock_world_class, \ + patch('src.server.main.Simulator') as mock_sim_class, \ + patch('src.server.main.sects_by_id', {}), \ + patch('src.server.main.CONFIG') as mock_config: + + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 0 + mock_config.avatar.protagonist = "none" + + mock_map = MagicMock() + mock_load_map.return_value = mock_map + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_world_class.return_value = mock_world + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + mock_sim_class.return_value = mock_sim + + await main.init_game_async() + + # Game should be paused after initialization. + assert game_instance["is_paused"] is True diff --git a/web/src/App.vue b/web/src/App.vue index fe104f0..9575046 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,33 +1,120 @@ + + + + diff --git a/web/src/components/game/panels/system/SaveLoadPanel.vue b/web/src/components/game/panels/system/SaveLoadPanel.vue index 9fa94da..d8445ea 100644 --- a/web/src/components/game/panels/system/SaveLoadPanel.vue +++ b/web/src/components/game/panels/system/SaveLoadPanel.vue @@ -49,17 +49,16 @@ async function handleLoad(filename: string) { loading.value = true try { + // 调用后端加载存档,后端会设置 init_status = "in_progress" + // App.vue 的轮询会检测到状态变化,显示加载界面,并在 ready 后重新初始化前端 await gameApi.loadGame(filename) - worldStore.reset() - uiStore.clearSelection() - await worldStore.initialize() - message.success('读档成功') + // 关闭菜单,让加载界面显示出来 emit('close') } catch (e) { message.error('读档失败') - } finally { loading.value = false } + // 注意:不在这里设置 loading.value = false,因为菜单会关闭 } watch(() => props.mode, () => { diff --git a/web/src/stores/world.ts b/web/src/stores/world.ts index f86f850..092f037 100644 --- a/web/src/stores/world.ts +++ b/web/src/stores/world.ts @@ -128,13 +128,10 @@ export const useWorldStore = defineStore('world', () => { isLoaded.value = true; } - async function initialize() { + // 提前加载地图数据(在 LLM 初始化期间可用)。 + async function preloadMap() { try { - const [stateRes, mapRes] = await Promise.all([ - gameApi.fetchInitialState(), - gameApi.fetchMap() - ]); - + const mapRes = await gameApi.fetchMap(); mapData.value = mapRes.data; if (mapRes.config) { frontendConfig.value = mapRes.config; @@ -142,11 +139,60 @@ export const useWorldStore = defineStore('world', () => { const regionMap = new Map(); mapRes.regions.forEach(r => regionMap.set(r.id, r)); regions.value = regionMap; + // 标记地图已加载,让 MapLayer 可以渲染。 + isLoaded.value = true; + console.log('[WorldStore] Map preloaded'); + } catch (e) { + console.warn('[WorldStore] Failed to preload map, will retry on initialize', e); + } + } - applyStateSnapshot(stateRes); + // 提前加载角色数据(在 checking_llm 阶段 world 已创建)。 + async function preloadAvatars() { + try { + const stateRes = await gameApi.fetchInitialState(); + // 只更新角色,不标记完全初始化。 + const avatarMap = new Map(); + if (stateRes.avatars) { + stateRes.avatars.forEach(av => avatarMap.set(av.id, av)); + } + avatars.value = avatarMap; + setTime(stateRes.year, stateRes.month); + console.log('[WorldStore] Avatars preloaded:', avatarMap.size); + } catch (e) { + console.warn('[WorldStore] Failed to preload avatars, will retry on initialize', e); + } + } + + async function initialize() { + try { + // 如果地图还没加载,一起加载。 + const needMapLoad = mapData.value.length === 0; + + if (needMapLoad) { + const [stateRes, mapRes] = await Promise.all([ + gameApi.fetchInitialState(), + gameApi.fetchMap() + ]); + + mapData.value = mapRes.data; + if (mapRes.config) { + frontendConfig.value = mapRes.config; + } + const regionMap = new Map(); + mapRes.regions.forEach(r => regionMap.set(r.id, r)); + regions.value = regionMap; + + applyStateSnapshot(stateRes); + } else { + // 地图已预加载,只需获取状态。 + const stateRes = await gameApi.fetchInitialState(); + applyStateSnapshot(stateRes); + } // 从分页 API 加载事件。 await resetEvents({}); + } catch (e) { console.error('Failed to initialize world', e); } @@ -274,7 +320,9 @@ export const useWorldStore = defineStore('world', () => { frontendConfig, currentPhenomenon, phenomenaList, - // Functions. + + preloadMap, + preloadAvatars, initialize, fetchState, handleTick,