diff --git a/EN_README.md b/EN_README.md index 81ed5f2..b272dbc 100644 --- a/EN_README.md +++ b/EN_README.md @@ -106,6 +106,7 @@ You can also join the QQ group for discussion: 1071821688. Verification answer i - ✅ Flexible custom LLM interface - ✅ Support macOS - [ ] Support multi-language localization +- [ ] Game settings panel at startup ### 🗺️ World System - ✅ Basic tile mechanics @@ -204,8 +205,7 @@ You can also join the QQ group for discussion: 1071821688. Verification answer i ### 🏛️ World Lore - ✅ Inject basic world knowledge -- [ ] Dynamic worldview generation -- [ ] Dynamic generation of techniques, equipment, sects, and maps based on user input history +- ✅ User input history, dynamic generation of techniques, equipment, sects, and region info ### Specials - ✅ Fortuitous encounters @@ -221,7 +221,6 @@ You can also join the QQ group for discussion: 1071821688. Verification answer i - [ ] World Secrets & World Laws (Flexible customization) - [ ] Gu Refining - [ ] World-ending Crisis -- [ ] Become a Legend of Later Ages ### 🔭 Long-term - [ ] Novelization/imagery/video for history and events diff --git a/README.md b/README.md index b4fc162..d9b9a95 100644 --- a/README.md +++ b/README.md @@ -216,8 +216,7 @@ ### 🏛️ 世界背景系统 - ✅ 注入基础世界知识 -- [ ] 动态世界观生成 -- [ ] 基于用户输入历史的动态功法、装备、宗门、地图生成 +- ✅ 用户输入历史,动态生成功法、装备、宗门、区域信息 ### 特殊 - ✅ 奇遇 diff --git a/src/classes/history.py b/src/classes/history.py new file mode 100644 index 0000000..e4c969f --- /dev/null +++ b/src/classes/history.py @@ -0,0 +1,178 @@ +import asyncio +import json +from pathlib import Path +from typing import Dict, Any, Optional, TYPE_CHECKING +import logging + +from src.classes.item_registry import ItemRegistry +from src.classes.technique import techniques_by_id, techniques_by_name +from src.classes.weapon import weapons_by_name +from src.utils.llm.client import call_llm_with_task_name +from src.run.log import get_logger + +if TYPE_CHECKING: + from src.classes.world import World + +class HistoryManager: + """ + 历史管理器 + 在游戏开局时,根据历史文本一次性修改世界中的对象数据。 + """ + def __init__(self, world: "World"): + self.world = world + # 配置目录路径 + self.config_dir = Path("static/game_configs") + self.logger = get_logger().logger + + async def apply_history_influence(self, history_text: str): + """ + 核心方法:读取 CSV -> LLM 分析 -> 更新内存对象 + """ + # 1. 准备 Prompt 参数:直接读取 CSV 原始内容 + infos = { + "world_info": str(self.world.static_info) if self.world else "", + "history_str": history_text, + "city_regions": self._read_csv("city_region.csv"), + "normal_regions": self._read_csv("normal_region.csv"), + "cultivate_regions": self._read_csv("cultivate_region.csv"), + "sect_regions": self._read_csv("sect_region.csv"), + "techniques": self._read_csv("technique.csv"), + "weapons": self._read_csv("weapon.csv"), + "auxiliarys": self._read_csv("auxiliary.csv"), + } + + # 2. 调用 LLM + self.logger.info("[History] 正在根据历史推演世界变化...") + try: + result = await call_llm_with_task_name( + task_name="history_influence", + template_path="static/templates/history_influence.txt", + infos=infos, + max_retries=3 # 增加重试次数,确保 JSON 格式正确 + ) + except Exception as e: + self.logger.error(f"[History] LLM 调用或解析失败: {e}") + return + + # 3. 应用变更到内存对象 + if result: + self._apply_changes(result) + else: + self.logger.info("[History] LLM 返回为空,未进行任何修改") + + def _read_csv(self, filename: str) -> str: + """读取 CSV 文件原始内容""" + file_path = self.config_dir / filename + if not file_path.exists(): + self.logger.warning(f"[History] Warning: 配置文件不存在 {file_path}") + return "" + try: + return file_path.read_text(encoding='utf-8') + except Exception as e: + self.logger.error(f"[History] 读取文件 {filename} 失败: {e}") + return "" + + def _apply_changes(self, result: Dict[str, Any]): + """分发并应用变更""" + + # 3.1 区域变更 + self._update_regions(result.get("city_regions_change", {})) + self._update_regions(result.get("normal_regions_change", {})) + self._update_regions(result.get("cultivate_regions_change", {})) + self._update_regions(result.get("sect_regions_change", {})) + + # 3.2 功法变更 + self._update_techniques(result.get("techniques_change", {})) + + # 3.3 装备变更 + self._update_items(result.get("weapons_change", {}), weapons_by_name) + self._update_items(result.get("auxiliarys_change", {}), None) # 辅助装备可能没有全局 name 索引 + + def _update_regions(self, changes: Dict[str, Any]): + """更新区域 (Map.regions)""" + if not changes: return + + count = 0 + for rid_str, data in changes.items(): + try: + rid = int(rid_str) + # 从 World.Map 获取区域 + if self.world and self.world.map: + region = self.world.map.regions.get(rid) + if region: + self._update_obj_attrs(region, data) + self.logger.info(f"[History] 区域变更 - ID: {rid}, Name: {region.name}, Desc: {region.desc}") + count += 1 + except Exception as e: + self.logger.error(f"[History] 区域更新失败 - ID: {rid_str}, Error: {e}") + continue + if count > 0: + self.logger.info(f"[History] 更新了 {count} 个区域") + + def _update_techniques(self, changes: Dict[str, Any]): + """更新功法 (techniques_by_id)""" + if not changes: return + + count = 0 + for tid_str, data in changes.items(): + try: + tid = int(tid_str) + tech = techniques_by_id.get(tid) + if tech: + old_name = tech.name + self._update_obj_attrs(tech, data) + + # 同步 techniques_by_name 索引 + if tech.name != old_name: + if old_name in techniques_by_name: + del techniques_by_name[old_name] + techniques_by_name[tech.name] = tech + + self.logger.info(f"[History] 功法变更 - ID: {tid}, Name: {tech.name}, Desc: {tech.desc}") + count += 1 + except Exception as e: + self.logger.error(f"[History] 功法更新失败 - ID: {tid_str}, Error: {e}") + continue + if count > 0: + self.logger.info(f"[History] 更新了 {count} 本功法") + + def _update_items(self, changes: Dict[str, Any], by_name_index: Optional[Dict[str, Any]]): + """更新物品 (ItemRegistry)""" + if not changes: return + + count = 0 + for iid_str, data in changes.items(): + try: + iid = int(iid_str) + item = ItemRegistry.get(iid) + if item: + old_name = item.name + self._update_obj_attrs(item, data) + + # 同步可选的 name 索引 (如 weapons_by_name) + if by_name_index is not None and item.name != old_name: + if old_name in by_name_index: + del by_name_index[old_name] + by_name_index[item.name] = item + + self.logger.info(f"[History] 装备变更 - ID: {iid}, Name: {item.name}, Desc: {item.desc}") + count += 1 + except Exception as e: + self.logger.error(f"[History] 装备更新失败 - ID: {iid_str}, Error: {e}") + continue + if count > 0: + self.logger.info(f"[History] 更新了 {count} 件装备") + + def _update_obj_attrs(self, obj: Any, data: Dict[str, Any]): + """通用属性更新 helper""" + if "name" in data and data["name"]: + obj.name = str(data["name"]) + if "desc" in data and data["desc"]: + obj.desc = str(data["desc"]) + +if __name__ == "__main__": + # 模拟运行 + history_str = "上古时期..." + # 注意:这里直接运行可能会报错,因为需要 World 对象 + # 这里只是为了保留文件结构的完整性 + pass diff --git a/src/server/main.py b/src/server/main.py index ab1de65..8059f7b 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -17,6 +17,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) from src.sim.simulator import Simulator from src.classes.world import World +from src.classes.history import HistoryManager from src.classes.calendar import Month, Year, create_month_stamp from src.run.load_map import load_cultivation_world_map from src.sim.new_avatar import make_avatars as _new_make_random, create_avatar_from_request @@ -286,10 +287,11 @@ def check_llm_connectivity() -> tuple[bool, str]: INIT_PHASE_NAMES = { 0: "scanning_assets", 1: "loading_map", - 2: "initializing_sects", - 3: "generating_avatars", - 4: "checking_llm", - 5: "generating_initial_events", + 2: "processing_history", + 3: "initializing_sects", + 4: "generating_avatars", + 5: "checking_llm", + 6: "generating_initial_events", } def update_init_progress(phase: int, phase_name: str = ""): @@ -297,8 +299,8 @@ 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, "") # 最后一阶段到 100% - progress_map = {0: 0, 1: 10, 2: 20, 3: 30, 4: 40, 5: 50} - game_instance["init_progress"] = progress_map.get(phase, phase * 17) + progress_map = {0: 0, 1: 10, 2: 25, 3: 40, 4: 55, 5: 70, 6: 85} + game_instance["init_progress"] = progress_map.get(phase, phase * 14) print(f"[Init] Phase {phase}: {game_instance['init_phase_name']} ({game_instance['init_progress']}%)") async def init_game_async(): @@ -337,8 +339,20 @@ async def init_game_async(): ) sim = Simulator(world) - # 阶段 2: 宗门初始化 - update_init_progress(2, "initializing_sects") + # 阶段 2: 历史背景影响 (如果配置了历史) + update_init_progress(2, "processing_history") + world_history = getattr(CONFIG.game, "world_history", "") + if world_history and world_history.strip(): + print(f"正在根据历史背景重塑世界: {world_history[:50]}...") + try: + history_mgr = HistoryManager(world) + await history_mgr.apply_history_influence(world_history) + print("历史背景应用完成 ✓") + except Exception as e: + print(f"[警告] 历史背景应用失败: {e}") + + # 阶段 3: 宗门初始化 + update_init_progress(3, "initializing_sects") all_sects = list(sects_by_id.values()) needed_sects = int(getattr(CONFIG.game, "sect_num", 0) or 0) existed_sects = [] @@ -347,8 +361,8 @@ async def init_game_async(): random.shuffle(pool) existed_sects = pool[:needed_sects] - # 阶段 3: 角色生成 - update_init_progress(3, "generating_avatars") + # 阶段 4: 角色生成 + update_init_progress(4, "generating_avatars") protagonist_mode = getattr(CONFIG.avatar, "protagonist", "none") target_total_count = int(getattr(CONFIG.game, "init_npc_num", 12)) final_avatars = {} @@ -385,8 +399,8 @@ async def init_game_async(): game_instance["world"] = world game_instance["sim"] = sim - # 阶段 4: LLM 连通性检测 - update_init_progress(4, "checking_llm") + # 阶段 5: LLM 连通性检测 + update_init_progress(5, "checking_llm") print("正在检测 LLM 连通性...") # 使用线程池执行,避免阻塞事件循环,让 /api/init-status 可以响应 success, error_msg = await asyncio.to_thread(check_llm_connectivity) @@ -400,8 +414,8 @@ async def init_game_async(): game_instance["llm_check_failed"] = False game_instance["llm_error_message"] = "" - # 阶段 5: 生成初始事件(第一次 sim.step) - update_init_progress(5, "generating_initial_events") + # 阶段 6: 生成初始事件(第一次 sim.step) + update_init_progress(6, "generating_initial_events") print("正在生成初始事件...") # 取消暂停,执行第一步来生成初始事件 @@ -906,6 +920,7 @@ class GameStartRequest(BaseModel): sect_num: int protagonist: str npc_awakening_rate_per_month: float + world_history: Optional[str] = None @app.get("/api/config/current") def get_current_config(): @@ -914,7 +929,8 @@ def get_current_config(): "game": { "init_npc_num": getattr(CONFIG.game, "init_npc_num", 12), "sect_num": getattr(CONFIG.game, "sect_num", 3), - "npc_awakening_rate_per_month": getattr(CONFIG.game, "npc_awakening_rate_per_month", 0.01) + "npc_awakening_rate_per_month": getattr(CONFIG.game, "npc_awakening_rate_per_month", 0.01), + "world_history": getattr(CONFIG.game, "world_history", "") }, "avatar": { "protagonist": getattr(CONFIG.avatar, "protagonist", "none") @@ -956,6 +972,7 @@ async def start_game(req: GameStartRequest): conf.game.init_npc_num = req.init_npc_num conf.game.sect_num = req.sect_num conf.game.npc_awakening_rate_per_month = req.npc_awakening_rate_per_month + conf.game.world_history = req.world_history or "" conf.avatar.protagonist = req.protagonist # 写入文件 diff --git a/static/config.yml b/static/config.yml index b9abb60..826623d 100644 --- a/static/config.yml +++ b/static/config.yml @@ -10,6 +10,7 @@ llm: relation_resolver: "fast" story_teller: "fast" interaction_feedback: "fast" + history_influence: "normal" paths: templates: static/templates/ diff --git a/static/templates/history_influence.txt b/static/templates/history_influence.txt new file mode 100644 index 0000000..49c9382 --- /dev/null +++ b/static/templates/history_influence.txt @@ -0,0 +1,57 @@ +你是一个仙侠世界的创作者,我会给你一个原始的世界背景,以及一段历史。 +你需要基于世界背景,根据这段历史,修改这个世界中存在的部分功法、兵器、辅助装备、区域信息。 + +世界背景: +{world_info} + +历史文本: +{history_str} + +城市区域信息: +{city_regions} + +普通区域信息: +{normal_regions} + +修炼区域信息: +{cultivate_regions} + +宗门区域信息: +{sect_regions} + +功法信息: +{techniques} + +兵器信息: +{weapons} + +辅助装备信息: +{auxiliarys} + +基于以上信息,分析,并返回修改意见。 + +返回JSON格式: +{{ + "thinking": "分析应该有怎么样的修改", + "city_regions_change": + {{ + "id": {{ //原来的id + "name": str // 新的名字 + "desc": desc // 新的desc + }} + }}, + "normal_regions_change": {{}} // dict, 结构同上 + "cultivate_regions_change": {{}} // dict, 结构同上 + "sect_regions_change": {{}} // dict, 结构同上 + "techniques_change": {{}} // dict, 结构同上 + "weapons_change": {{}} // dict, 结构同上 + "auxiliarys_change": {{}} // dict, 结构同上 +}} + +要求: +1. thinking是你的思考过程,要详细分析 +2. 要参考history的内容进行修改,言之有理,比如。 +3. history的文本内容多,就多修改点,少的话就少修改点 +4. 某项没有修改的话,就返回空字典{{}} +5. 要修改的项,只返回name和desc,不返回别的key + diff --git a/tests/conftest.py b/tests/conftest.py index f602b7f..e0cd25e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,18 +73,21 @@ def mock_llm_managers(): with patch("src.sim.simulator.llm_ai") as mock_ai, \ patch("src.sim.simulator.process_avatar_long_term_objective", new_callable=AsyncMock) as mock_lto, \ patch("src.classes.nickname.process_avatar_nickname", new_callable=AsyncMock) as mock_nick, \ - patch("src.classes.relation_resolver.RelationResolver.run_batch", new_callable=AsyncMock) as mock_rr: + patch("src.classes.relation_resolver.RelationResolver.run_batch", new_callable=AsyncMock) as mock_rr, \ + patch("src.classes.history.HistoryManager.apply_history_influence", new_callable=AsyncMock) as mock_hist: mock_ai.decide = AsyncMock(return_value={}) mock_lto.return_value = None mock_nick.return_value = None mock_rr.return_value = [] + mock_hist.return_value = None yield { "ai": mock_ai, "lto": mock_lto, "nick": mock_nick, - "rr": mock_rr + "rr": mock_rr, + "hist": mock_hist } # --- Shared Helpers for Item Creation --- diff --git a/tests/test_action_gift.py b/tests/test_action_gift.py index 841b8a4..b593a4b 100644 --- a/tests/test_action_gift.py +++ b/tests/test_action_gift.py @@ -31,7 +31,7 @@ def target_avatar(base_world): def gift_action(dummy_avatar, base_world): """初始化 Gift 动作""" # 模拟 _call_llm_feedback,避免 step 中调用 asyncio.get_running_loop() - with patch.object(Gift, '_call_llm_feedback') as mock_llm: + with patch.object(Gift, '_call_llm_feedback', new_callable=MagicMock) as mock_llm: # 返回一个 mock task,确保 task.done() 初始为 False, # 但在这里我们主要是为了让 step 不报错 mock_llm.return_value = {} diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..5c833fe --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,129 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from src.classes.history import HistoryManager +from src.classes.region import CityRegion, NormalRegion, CultivateRegion +from src.classes.technique import Technique, TechniqueAttribute, TechniqueGrade +from src.classes.weapon import Weapon, WeaponType +from src.classes.auxiliary import Auxiliary +from src.classes.cultivation import Realm +from src.classes.item_registry import ItemRegistry + +# 假设这些全局字典在模块层级 +from src.classes import technique as technique_module +from src.classes import weapon as weapon_module +# auxiliary 模块没有导出全局字典,所以这里不需要特别处理它的全局字典,只需要处理 ItemRegistry + +@pytest.mark.asyncio +async def test_history_influence(base_world): + # --- Setup Test Data --- + + # 1. Regions + city_region = CityRegion(id=1, name="OldCity", desc="Old Desc") + normal_region = NormalRegion(id=2, name="OldWild", desc="Old Wild Desc") + cult_region = CultivateRegion(id=3, name="OldCave", desc="Old Cave Desc") + + base_world.map.regions = { + 1: city_region, + 2: normal_region, + 3: cult_region + } + + # 2. Techniques + tech = Technique( + id=101, + name="OldTech", + attribute=TechniqueAttribute.GOLD, + grade=TechniqueGrade.LOWER, + desc="Old Tech Desc", + weight=1.0, + condition="" + ) + + # 3. Weapons & Auxiliaries + weapon = Weapon( + id=201, + name="OldSword", + weapon_type=WeaponType.SWORD, + realm=Realm.Qi_Refinement, + desc="Old Sword Desc" + ) + aux = Auxiliary( + id=301, + name="OldOrb", + realm=Realm.Qi_Refinement, + desc="Old Orb Desc" + ) + + # --- Patch Global Registries --- + # 使用 patch.dict 来隔离对全局字典的修改 + with patch.dict(technique_module.techniques_by_id, {101: tech}, clear=True), \ + patch.dict(technique_module.techniques_by_name, {"OldTech": tech}, clear=True), \ + patch.dict(weapon_module.weapons_by_name, {"OldSword": weapon}, clear=True), \ + patch.object(ItemRegistry, "_items_by_id", {201: weapon, 301: aux}): # ItemRegistry 是类属性 + + # --- Prepare LLM Mock Response --- + mock_response = { + "city_regions_change": { + "1": {"name": "NewCity", "desc": "New Desc"} + }, + "normal_regions_change": { + "2": {"name": "NewWild", "desc": "New Wild Desc"} + }, + "cultivate_regions_change": { + "3": {"name": "NewCave", "desc": "New Cave Desc"} + }, + "techniques_change": { + "101": {"name": "NewTech", "desc": "New Tech Desc"} + }, + "weapons_change": { + "201": {"name": "NewSword", "desc": "New Sword Desc"} + }, + "auxiliarys_change": { + "301": {"name": "NewOrb", "desc": "New Orb Desc"} + } + } + + # --- Instantiate Manager & Mock Internal Methods --- + manager = HistoryManager(base_world) + + # Mock _read_csv to return dummy string + manager._read_csv = MagicMock(return_value="dummy,csv,content") + + # Mock call_llm_with_task_name + with patch("src.classes.history.call_llm_with_task_name", new_callable=AsyncMock) as mock_llm: + mock_llm.return_value = mock_response + + # --- Execute --- + await manager.apply_history_influence("Some history text") + + # --- Assertions --- + + # 1. LLM Called + mock_llm.assert_called_once() + + # 2. Regions Updated + assert city_region.name == "NewCity" + assert city_region.desc == "New Desc" + assert normal_region.name == "NewWild" + assert normal_region.desc == "New Wild Desc" + assert cult_region.name == "NewCave" + assert cult_region.desc == "New Cave Desc" + + # 3. Technique Updated & Index Synced + assert tech.name == "NewTech" + assert tech.desc == "New Tech Desc" + assert "NewTech" in technique_module.techniques_by_name + assert "OldTech" not in technique_module.techniques_by_name + assert technique_module.techniques_by_name["NewTech"] == tech + + # 4. Weapon Updated & Index Synced + assert weapon.name == "NewSword" + assert weapon.desc == "New Sword Desc" + assert "NewSword" in weapon_module.weapons_by_name + assert "OldSword" not in weapon_module.weapons_by_name + assert weapon_module.weapons_by_name["NewSword"] == weapon + + # 5. Auxiliary Updated + assert aux.name == "NewOrb" + assert aux.desc == "New Orb Desc" + diff --git a/tests/test_init_status_api.py b/tests/test_init_status_api.py index 44433aa..8089bfd 100644 --- a/tests/test_init_status_api.py +++ b/tests/test_init_status_api.py @@ -67,7 +67,7 @@ class TestInitStatusEndpoint: 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"] = 3 game_instance["init_phase_name"] = "initializing_sects" game_instance["init_progress"] = 33 game_instance["init_start_time"] = time.time() - 5 # 5 seconds ago @@ -77,7 +77,7 @@ class TestInitStatusEndpoint: data = response.json() assert data["status"] == "in_progress" - assert data["phase"] == 2 + assert data["phase"] == 3 assert data["phase_name"] == "initializing_sects" assert data["progress"] == 33 assert data["elapsed_seconds"] >= 5 @@ -85,7 +85,7 @@ class TestInitStatusEndpoint: 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"] = 6 game_instance["init_phase_name"] = "generating_initial_events" game_instance["init_progress"] = 100 @@ -127,29 +127,30 @@ class TestUpdateInitProgress: def test_update_progress_with_phase_name(self, reset_game_instance): """Test updating progress with explicit phase name.""" - update_init_progress(2, "initializing_sects") + update_init_progress(3, "initializing_sects") - assert game_instance["init_phase"] == 2 + assert game_instance["init_phase"] == 3 assert game_instance["init_phase_name"] == "initializing_sects" - assert game_instance["init_progress"] == 20 + assert game_instance["init_progress"] == 40 def test_update_progress_without_phase_name(self, reset_game_instance): """Test updating progress uses default phase name from mapping.""" - update_init_progress(3) + update_init_progress(4) - assert game_instance["init_phase"] == 3 + assert game_instance["init_phase"] == 4 assert game_instance["init_phase_name"] == "generating_avatars" - assert game_instance["init_progress"] == 30 + assert game_instance["init_progress"] == 55 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", + 2: "processing_history", + 3: "initializing_sects", + 4: "generating_avatars", + 5: "checking_llm", + 6: "generating_initial_events", } assert INIT_PHASE_NAMES == expected_phases @@ -165,7 +166,8 @@ class TestNewGameEndpoint: "init_npc_num": 10, "sect_num": 2, "protagonist": "none", - "npc_awakening_rate_per_month": 0.01 + "npc_awakening_rate_per_month": 0.01, + "world_history": "Some history" } response = client.post("/api/game/start", json=payload) @@ -221,7 +223,7 @@ class TestReinitEndpoint: game_instance["sim"] = MagicMock() game_instance["init_status"] = "error" game_instance["init_error"] = "Some error" - game_instance["init_phase"] = 3 + game_instance["init_phase"] = 4 game_instance["init_progress"] = 50 with patch.object(main, 'init_game_async', new_callable=AsyncMock): @@ -262,7 +264,7 @@ class TestMapAndStateAPIDuringInit: game_instance["world"] = mock_world game_instance["init_status"] = "in_progress" - game_instance["init_phase"] = 4 + game_instance["init_phase"] = 5 game_instance["init_phase_name"] = "checking_llm" # The /api/map endpoint should work. @@ -280,7 +282,7 @@ class TestMapAndStateAPIDuringInit: game_instance["world"] = mock_world game_instance["init_status"] = "in_progress" - game_instance["init_phase"] = 5 + game_instance["init_phase"] = 6 game_instance["init_phase_name"] = "generating_initial_events" response = client.get("/api/state") @@ -291,7 +293,7 @@ class TestInitGameAsync: """Tests for the async initialization flow.""" @pytest.mark.asyncio - async def test_init_sets_status_to_in_progress(self, reset_game_instance): + async def test_init_sets_status_to_in_progress(self, reset_game_instance, mock_llm_managers): """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, \ @@ -317,7 +319,7 @@ class TestInitGameAsync: await task # Let it complete. @pytest.mark.asyncio - async def test_init_error_sets_error_status(self, reset_game_instance): + async def test_init_error_sets_error_status(self, reset_game_instance, mock_llm_managers): """Test initialization error sets status to error.""" with patch.object(main, 'scan_avatar_assets', side_effect=Exception("Test error")): await main.init_game_async() @@ -326,7 +328,7 @@ class TestInitGameAsync: assert "Test error" in game_instance["init_error"] @pytest.mark.asyncio - async def test_init_completes_with_ready_status(self, reset_game_instance): + async def test_init_completes_with_ready_status(self, reset_game_instance, mock_llm_managers): """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, \ @@ -355,7 +357,7 @@ class TestInitGameAsync: assert game_instance["init_progress"] == 100 @pytest.mark.asyncio - async def test_init_records_llm_failure(self, reset_game_instance): + async def test_init_records_llm_failure(self, reset_game_instance, mock_llm_managers): """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, \ @@ -387,7 +389,47 @@ class TestInitGameAsync: assert game_instance["llm_error_message"] == "API key invalid" @pytest.mark.asyncio - async def test_init_pauses_after_initial_events(self, reset_game_instance): + async def test_init_calls_history_manager(self, reset_game_instance, mock_llm_managers): + """Test initialization calls HistoryManager when history is present.""" + 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, \ + patch('src.server.main.HistoryManager') as mock_history_class: + + mock_config.game.sect_num = 0 + mock_config.game.init_npc_num = 0 + mock_config.avatar.protagonist = "none" + mock_config.game.world_history = "Ancient times..." + + mock_map = MagicMock() + mock_load_map.return_value = mock_map + mock_world = MagicMock() + mock_world.avatar_manager.avatars = {} + mock_world_class.create_with_db.return_value = mock_world + mock_sim = MagicMock() + mock_sim.step = AsyncMock() + mock_sim_class.return_value = mock_sim + + # Use the mock from fixture if available, but here we patch HistoryManager class specifically + # to verify constructor call. + mock_history_mgr = MagicMock() + # We want to verify that apply_history_influence is called. + # Even if mock_llm_managers mocks the underlying method on the real class, + # here we mock the whole class, so we get a fresh mock instance. + mock_history_mgr.apply_history_influence = AsyncMock() + mock_history_class.return_value = mock_history_mgr + + await main.init_game_async() + + mock_history_class.assert_called_once_with(mock_world) + mock_history_mgr.apply_history_influence.assert_called_once_with("Ancient times...") + + @pytest.mark.asyncio + async def test_init_pauses_after_initial_events(self, reset_game_instance, mock_llm_managers): """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, \ diff --git a/web/src/api/index.ts b/web/src/api/index.ts index a03f31c..2412c20 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -5,5 +5,7 @@ export { systemApi } from './modules/system'; export { llmApi } from './modules/llm'; export { eventApi } from './modules/event'; +export type { InitStatusDTO } from '../types/api'; + // 保持向后兼容的聚合对象 (Optional, for transition) // 但这次我们直接重构,不再保留大对象,鼓励按需引用 diff --git a/web/src/components/LoadingOverlay.vue b/web/src/components/LoadingOverlay.vue index d88ed60..3d1b380 100644 --- a/web/src/components/LoadingOverlay.vue +++ b/web/src/components/LoadingOverlay.vue @@ -10,6 +10,7 @@ const props = defineProps<{ const phaseTexts: Record = { 'scanning_assets': '扫描天地资源', 'loading_map': '构建洪荒山川', + 'processing_history': '推演天道历史', 'initializing_sects': '宗门入世', 'generating_avatars': '众修士降临', 'checking_llm': '连通天道意志', @@ -54,7 +55,7 @@ const tips = [ '丹药有生效的时间限制', '由于大模型需要思考,游戏启动可能耗时较久', '模拟世界对大模型token消耗较大,请注意', - '设定开局历史,世界也会随之而改变', + '开局时设定历史,整个修仙世界也会随之而改变', ] const currentTip = ref(tips[Math.floor(Math.random() * tips.length)]) @@ -125,8 +126,8 @@ function startTimers() { // 伪进度逻辑 if (props.status?.status === 'in_progress' && displayProgress.value < 99) { const currentPhase = props.status?.phase ?? 0 - // 后端定义的进度节点: {0: 0, 1: 17, 2: 33, 3: 50, 4: 67, 5: 83} - const progressMap: Record = { 0: 0, 1: 17, 2: 33, 3: 50, 4: 67, 5: 83 } + // 后端定义的进度节点: {0: 0, 1: 10, 2: 25, 3: 40, 4: 55, 5: 70, 6: 85} + const progressMap: Record = { 0: 0, 1: 10, 2: 25, 3: 40, 4: 55, 5: 70, 6: 85 } const nextPhaseStart = progressMap[currentPhase + 1] ?? 100 // 每1秒增加 1% @@ -134,8 +135,8 @@ function startTimers() { // 如果还没达到下一阶段的起点前 1%,就继续自增 if (displayProgress.value < nextPhaseStart - 1) { displayProgress.value++ - } else if (currentPhase === 5 && displayProgress.value < 99) { - // 最后一个阶段(5阶段)允许一直增加到 99% + } else if (currentPhase === 6 && displayProgress.value < 99) { + // 最后一个阶段(6阶段)允许一直增加到 99% displayProgress.value++ } } diff --git a/web/src/components/game/panels/system/GameStartPanel.vue b/web/src/components/game/panels/system/GameStartPanel.vue index e5db3e8..b3bcf1c 100644 --- a/web/src/components/game/panels/system/GameStartPanel.vue +++ b/web/src/components/game/panels/system/GameStartPanel.vue @@ -1,6 +1,6 @@