This commit is contained in:
2026-05-03 00:17:50 +08:00
parent 5831703156
commit 801d1d534a
16 changed files with 1337 additions and 449 deletions

View File

@@ -165,7 +165,8 @@ const FOUNDATION_DRAFT_PLAYABLE_COUNT: usize = 1;
const FOUNDATION_DRAFT_STORY_COUNT: usize = 8;
const FOUNDATION_DRAFT_LANDMARK_COUNT: usize = 2;
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE: usize = 2;
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 2;
// 中文注释:单个场景已经包含三幕事件、三幕背景图 prompt 和 NPC 分配;按 1 个场景拆批,避免 landmark seed 大 JSON 在 Responses 请求中超时。
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 1;
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE: usize = 2;
const WORLD_ATTRIBUTE_SLOT_IDS: [&str; 6] =
["axis_a", "axis_b", "axis_c", "axis_d", "axis_e", "axis_f"];
@@ -586,7 +587,7 @@ async fn expand_foundation_role_entries(
.as_str(),
to_batch_progress(progress_range, processed_count, base_entries.len()),
);
let raw = request_foundation_json_stage(
let raw_result = request_foundation_json_stage(
llm_client,
build_custom_world_role_batch_prompt(framework, role_type, batch, stage),
format!(
@@ -610,8 +611,20 @@ async fn expand_foundation_role_entries(
"角色档案补全阶段没有返回有效内容。",
enable_web_search,
)
.await?;
merged_entries.extend(array_field(&raw, role_key(role_type)));
.await;
match raw_result {
Ok(raw) => merged_entries.extend(array_field(&raw, role_key(role_type))),
Err(error) if stage == "dossier" => {
warn!(
error = %error,
role_type,
batch_index = batch_index + 1,
"foundation draft 角色养成档案 LLM 补全失败,使用本地结构化兜底"
);
merged_entries.extend(build_fallback_role_dossier_entries(batch));
}
Err(error) => return Err(error),
}
processed_count = processed_count
.saturating_add(batch.len())
.min(base_entries.len());
@@ -635,6 +648,103 @@ async fn expand_foundation_role_entries(
Ok(merge_entries_by_name(base_entries, &merged_entries))
}
fn build_fallback_role_dossier_entries(entries: &[JsonValue]) -> Vec<JsonValue> {
entries
.iter()
.enumerate()
.map(|(index, entry)| build_fallback_role_dossier_entry(entry, index))
.collect()
}
fn build_fallback_role_dossier_entry(entry: &JsonValue, index: usize) -> JsonValue {
let name = json_text(entry, "name").unwrap_or_else(|| format!("角色{}", index + 1));
let title = json_text(entry, "title").unwrap_or_default();
let role = json_text(entry, "role").unwrap_or_else(|| "关键角色".to_string());
let description = json_text(entry, "description").unwrap_or_else(|| role.clone());
let backstory = json_text(entry, "backstory").unwrap_or_else(|| description.clone());
let motivation = json_text(entry, "motivation").unwrap_or_else(|| description.clone());
let tag = json_string_array(entry, "tags")
.and_then(|items| items.first().cloned())
.unwrap_or_else(|| role.clone());
let item_prefix = if title.trim().is_empty() {
name.clone()
} else {
title.clone()
};
json!({
"name": name.clone(),
"backstoryReveal": {
"publicSummary": format!("{name}的公开档案围绕“{description}”展开。"),
"chapters": [
{
"affinityRequired": 15,
"title": "初识",
"summary": format!("{name}以{role}身份进入玩家视野,留下与“{tag}”有关的第一条线索。"),
},
{
"affinityRequired": 30,
"title": "试探",
"summary": format!("{name}开始透露“{backstory}”背后的压力,但仍保留关键隐情。"),
},
{
"affinityRequired": 60,
"title": "共同行动",
"summary": format!("{name}围绕“{motivation}”与玩家形成更明确的合作或冲突。"),
},
{
"affinityRequired": 90,
"title": "真相",
"summary": format!("{name}交出与“{description}”相关的核心选择,关系走向定型。"),
},
],
},
"skills": [
{
"name": format!("{tag}洞察"),
"summary": format!("围绕“{description}”判断局势与隐藏线索。"),
"style": "侦查",
},
{
"name": format!("{item_prefix}协助"),
"summary": format!("以{role}身份为玩家提供行动支援。"),
"style": "支援",
},
{
"name": "临场应变",
"summary": format!("在压力升级时根据“{motivation}”调整行动。"),
"style": "应变",
},
],
"initialItems": [
{
"name": format!("{item_prefix}记录"),
"category": "道具",
"quantity": 1,
"rarity": "common",
"description": format!("记录{name}与“{description}”相关的线索。"),
"tags": [tag.clone()],
},
{
"name": format!("{tag}信物"),
"category": "道具",
"quantity": 1,
"rarity": "common",
"description": format!("能证明{name}身份和立场的随身物。"),
"tags": [role.clone()],
},
{
"name": "备用补给",
"category": "消耗品",
"quantity": 1,
"rarity": "common",
"description": format!("{name}在关键行动前准备的基础补给。"),
"tags": ["补给"],
},
],
})
}
fn emit_foundation_draft_progress(
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
phase_label: &str,
@@ -2570,6 +2680,60 @@ mod tests {
);
}
#[test]
fn role_dossier_fallback_keeps_names_and_required_fields() {
let entries = vec![json!({
"name": "埃琳娜·沃克",
"title": "深渊学者",
"role": "深海科研联盟成员",
"description": "执着研究深海生物发光现象的年轻科学家",
"backstory": "她长期追踪发光生物与古代遗迹之间的联系。",
"motivation": "用氧气补给换取玩家的目击信息",
"tags": ["科研人员", "偏执学者"]
})];
let fallback = build_fallback_role_dossier_entries(&entries);
let first = fallback.first().expect("fallback entry should exist");
assert_eq!(first.get("name"), Some(&json!("埃琳娜·沃克")));
assert_eq!(
first
.get("backstoryReveal")
.and_then(|value| value.get("chapters"))
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(4)
);
assert_eq!(
first
.get("backstoryReveal")
.and_then(|value| value.get("chapters"))
.and_then(JsonValue::as_array)
.map(|chapters| {
chapters
.iter()
.filter_map(|chapter| chapter.get("affinityRequired"))
.cloned()
.collect::<Vec<_>>()
}),
Some(vec![json!(15), json!(30), json!(60), json!(90)])
);
assert_eq!(
first
.get("skills")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
assert_eq!(
first
.get("initialItems")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
}
#[tokio::test]
async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() {
let request_capture = Arc::new(Mutex::new(Vec::new()));
@@ -2595,7 +2759,10 @@ mod tests {
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
),
llm_response(
r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#,
@@ -2624,15 +2791,24 @@ mod tests {
.expect("request capture should lock")
.clone();
let request_text = captured_requests.join("\n---request---\n");
let landmark_seed_requests = captured_requests
.iter()
.filter(|request| request.contains("场景框架名单"))
.collect::<Vec<_>>();
assert!(captured_requests.len() >= 17);
assert!(captured_requests.len() >= 18);
assert!(request_text.contains("在失真的海图上追查一场被篡改的沉船事故。"));
assert!(request_text.contains("世界核心骨架"));
assert!(request_text.contains("attributeSchema"));
assert!(request_text.contains("可扮演角色框架名单"));
assert!(request_text.contains("场景角色框架名单"));
assert!(request_text.contains("场景框架名单"));
assert!(request_text.contains("第一条场景必须是玩家进入世界时所在的开局场景"));
assert_eq!(landmark_seed_requests.len(), 2);
assert!(landmark_seed_requests[0].contains("本批场景必须是玩家进入世界时所在的开局场景"));
assert!(landmark_seed_requests[0].contains("必须生成恰好 1 个场景"));
assert!(landmark_seed_requests[1].contains("本批只生成普通关键场景"));
assert!(landmark_seed_requests[1].contains("这些场景已经生成,禁止重复:旧灯塔"));
assert!(!landmark_seed_requests[0].contains("一次性生成开局场景和普通关键场景"));
assert!(request_text.contains("camp 只表示玩家开局时的落脚处占位"));
assert!(!request_text.contains("camp.sceneTaskDescription"));
assert!(!request_text.contains("camp.actBackgroundPromptTexts"));
@@ -2845,6 +3021,150 @@ mod tests {
);
}
#[tokio::test]
async fn role_dossier_timeout_uses_local_fallback_and_keeps_generation_alive() {
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server_with_statuses(
request_capture.clone(),
vec![
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"slots":[{"name":"灯骨"},{"name":"潮步"},{"name":"灯识"},{"name":"雾魄"},{"name":"旧约"},{"name":"回澜"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","visualDescription":"浅灰防潮医袍挽到肘部,药箱铜扣发暗,袖口沾着海盐。","actionDescription":"俯身检查伤痕并快速记录潮汐症状,动作谨慎而利落。","sceneVisualDescription":"他常在沉船湾临时诊台前,背后是湿木棚和摇晃药灯。","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","visualDescription":"暗绿长外套挂满防水口袋,帽檐压低,腰间藏着卷曲海图。","actionDescription":"摊开假海图低声议价,手指总按着袖中短刃。","sceneVisualDescription":"他常站在雾港货棚阴影里,周围堆着封蜡货箱和潮湿灯牌。","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","visualDescription":"瘦小学徒披着过大的灯塔制服,怀里抱黄铜小灯和旧钥匙。","actionDescription":"抱灯快步穿过回廊,听见夜钟时会突然停住回头。","sceneVisualDescription":"他常出现在灯塔螺旋楼梯间,雾光从窄窗切进灰墙。","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"船魂戊","title":"沉船残魂","role":"异类","description":"困在潮声里","visualDescription":"半透明水渍轮廓披着破碎船员衣,胸口嵌着发暗船钉。","actionDescription":"随潮声漂移抬手指路,情绪激烈时水雾会拉长身影。","sceneVisualDescription":"它常浮在沉船湾退潮泥滩上,身后旧船骨像黑色肋骨。","initialAffinity":-20,"relationshipHooks":["沉船真相"],"tags":["异类"]},{"name":"巡海己","title":"巡海队长","role":"追捕者","description":"封锁海岸线","visualDescription":"深蓝巡海甲衣覆着雨水,肩章锋利,手握带灯长枪。","actionDescription":"举枪封路并用灯束扫过海岸,步伐整齐带压迫感。","sceneVisualDescription":"他常立在封锁栈桥尽头,巡海灯和铁链把退路切断。","initialAffinity":-15,"relationshipHooks":["封锁令"],"tags":["巡海"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"landmarks":[{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"playableNpcs":[{"name":"岑灯","backstoryReveal":{"publicSummary":"返乡守灯人的旧案羁绊。","chapters":[{"affinityRequired":15,"title":"返乡","summary":"回到旧灯塔。"},{"affinityRequired":30,"title":"旧档","summary":"发现档案错页。"},{"affinityRequired":60,"title":"沉船","summary":"接近沉船湾。"},{"affinityRequired":90,"title":"真相","summary":"直面议会遮掩。"}]},"skills":[{"name":"读灯","summary":"辨认灯火暗号","style":"侦查"}],"initialItems":[{"name":"旧海图","category":"道具","quantity":1,"rarity":"common","description":"父亲留下的海图。","tags":["线索"]}]}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"潮医乙","backstory":"他保存着沉船伤痕和潮汐症状的旧记录。","personality":"谨慎利落","motivation":"保住证据","combatStyle":"以医疗知识支援判断"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","backstory":"他长期倒卖雾港航线和假海图。","personality":"圆滑警惕","motivation":"从旧案里脱身","combatStyle":"以情报和交易周旋"}]}"#,
),
},
MockHttpResponse {
status_code: 200,
body: llm_response(
r#"{"storyNpcs":[{"name":"船魂戊","backstory":"它被沉船旧案困在潮声和船骨之间。","personality":"激烈执拗","motivation":"让真相重新浮上海面","combatStyle":"借潮声与残影指路"}]}"#,
),
},
MockHttpResponse {
status_code: 504,
body: r#"{"error":{"message":"story dossier timeout"}}"#.to_string(),
},
MockHttpResponse {
status_code: 504,
body: r#"{"error":{"message":"story dossier timeout"}}"#.to_string(),
},
],
);
let llm_client = build_test_llm_client(server_url);
let session = build_test_session();
let result = generate_custom_world_foundation_draft(&llm_client, &session, false, |_| {})
.await
.expect("dossier fallback should keep draft generation alive");
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
.expect("draft profile should parse");
let first_story = draft_profile
.get("storyNpcs")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.expect("first story role should exist");
assert_eq!(first_story.get("name"), Some(&json!("议长甲")));
assert_eq!(
first_story
.get("backstoryReveal")
.and_then(|value| value.get("chapters"))
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(4)
);
assert_eq!(
first_story
.get("skills")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
assert_eq!(
first_story
.get("initialItems")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
let request_text = request_capture
.lock()
.expect("request capture should lock")
.join("\n---request---\n");
assert!(request_text.contains("请为下面这一批场景角色补全养成档案"));
}
#[test]
fn generated_scene_batch_first_entry_becomes_opening_camp() {
let fallback_camp = json!({