Merge pull request #21 from AI-Cultivation/feat/history

Feat/history
This commit is contained in:
4thfever
2026-01-12 00:40:56 +08:00
committed by GitHub
14 changed files with 497 additions and 53 deletions

View File

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

View File

@@ -216,8 +216,7 @@
### 🏛️ 世界背景系统
- ✅ 注入基础世界知识
- [ ] 动态世界观生成
- [ ] 基于用户输入历史的动态功法、装备、宗门、地图生成
- ✅ 用户输入历史,动态生成功法、装备、宗门、区域信息
### 特殊
- ✅ 奇遇

178
src/classes/history.py Normal file
View File

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

View File

@@ -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
# 写入文件

View File

@@ -10,6 +10,7 @@ llm:
relation_resolver: "fast"
story_teller: "fast"
interaction_feedback: "fast"
history_influence: "normal"
paths:
templates: static/templates/

View File

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

View File

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

View File

@@ -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 = {}

129
tests/test_history.py Normal file
View File

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

View File

@@ -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, \

View File

@@ -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)
// 但这次我们直接重构,不再保留大对象,鼓励按需引用

View File

@@ -10,6 +10,7 @@ const props = defineProps<{
const phaseTexts: Record<string, string | string[]> = {
'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<number, number> = { 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<number, number> = { 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++
}
}

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NForm, NFormItem, NInputNumber, NSelect, NButton, useMessage } from 'naive-ui'
import { NForm, NFormItem, NInputNumber, NSelect, NButton, NInput, useMessage } from 'naive-ui'
import { systemApi } from '../../../../api'
const props = defineProps<{
@@ -14,7 +14,8 @@ const config = ref({
init_npc_num: 12,
sect_num: 3,
protagonist: 'none',
npc_awakening_rate_per_month: 0.01
npc_awakening_rate_per_month: 0.01,
world_history: ''
})
const loading = ref(false)
@@ -34,7 +35,8 @@ async function fetchConfig() {
init_npc_num: res.game.init_npc_num,
sect_num: res.game.sect_num,
protagonist: res.avatar.protagonist,
npc_awakening_rate_per_month: res.game.npc_awakening_rate_per_month
npc_awakening_rate_per_month: res.game.npc_awakening_rate_per_month,
world_history: res.game.world_history || ''
}
} catch (e) {
message.error('加载配置失败')
@@ -110,6 +112,18 @@ onMounted(() => {
/>
</n-form-item>
<n-form-item label="世界历史背景" path="world_history">
<n-input
v-model:value="config.world_history"
type="textarea"
placeholder="请输入修仙界历史背景(可选)。"
:autosize="{ minRows: 3, maxRows: 6 }"
/>
</n-form-item>
<div class="tip-text" style="margin-top: -12px;">
可以包括上古中古近古注意启用此功能会调用LLM初始化时间会显著增加
</div>
<div class="actions" v-if="!readonly">
<n-button type="primary" size="large" @click="startGame" :loading="loading">
开始

View File

@@ -129,6 +129,7 @@ export interface GameStartConfigDTO {
sect_num: number;
protagonist: string;
npc_awakening_rate_per_month: number;
world_history?: string;
}
export interface CurrentConfigDTO {
@@ -136,6 +137,7 @@ export interface CurrentConfigDTO {
init_npc_num: number;
sect_num: number;
npc_awakening_rate_per_month: number;
world_history?: string;
};
avatar: {
protagonist: string;