update history pytest

This commit is contained in:
bridge
2026-01-12 00:20:15 +08:00
parent 57cf5ca51a
commit 287f9d2ae4
9 changed files with 131 additions and 50 deletions

View File

@@ -7,7 +7,7 @@ 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_template
from src.utils.llm.client import call_llm_with_task_name
from src.run.log import get_logger
if TYPE_CHECKING:
@@ -43,7 +43,8 @@ class HistoryManager:
# 2. 调用 LLM
self.logger.info("[History] 正在根据历史推演世界变化...")
try:
result = await call_llm_with_template(
result = await call_llm_with_task_name(
task_name="history_influence",
template_path="static/templates/history_influence.txt",
infos=infos,
max_retries=3 # 增加重试次数,确保 JSON 格式正确

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

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

@@ -89,8 +89,8 @@ async def test_history_influence(base_world):
# Mock _read_csv to return dummy string
manager._read_csv = MagicMock(return_value="dummy,csv,content")
# Mock call_llm_with_template
with patch("src.classes.history.call_llm_with_template", new_callable=AsyncMock) as mock_llm:
# 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 ---

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

@@ -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': '连通天道意志',
@@ -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;