@@ -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
|
||||
|
||||
@@ -216,8 +216,7 @@
|
||||
|
||||
### 🏛️ 世界背景系统
|
||||
- ✅ 注入基础世界知识
|
||||
- [ ] 动态世界观生成
|
||||
- [ ] 基于用户输入历史的动态功法、装备、宗门、地图生成
|
||||
- ✅ 用户输入历史,动态生成功法、装备、宗门、区域信息
|
||||
|
||||
### 特殊
|
||||
- ✅ 奇遇
|
||||
|
||||
178
src/classes/history.py
Normal file
178
src/classes/history.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
# 写入文件
|
||||
|
||||
@@ -10,6 +10,7 @@ llm:
|
||||
relation_resolver: "fast"
|
||||
story_teller: "fast"
|
||||
interaction_feedback: "fast"
|
||||
history_influence: "normal"
|
||||
|
||||
paths:
|
||||
templates: static/templates/
|
||||
|
||||
57
static/templates/history_influence.txt
Normal file
57
static/templates/history_influence.txt
Normal 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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
129
tests/test_history.py
Normal 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"
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -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)
|
||||
// 但这次我们直接重构,不再保留大对象,鼓励按需引用
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
开始
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user