Files
Genarrative/server-rs/crates/api-server/src/custom_world_foundation_draft.rs
2026-05-03 00:17:50 +08:00

3403 lines
138 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::prompt::foundation_draft::{
build_custom_world_framework_json_repair_prompt, build_custom_world_framework_prompt,
build_custom_world_landmark_seed_batch_json_repair_prompt,
build_custom_world_landmark_seed_batch_prompt,
build_custom_world_role_batch_json_repair_prompt, build_custom_world_role_batch_prompt,
build_custom_world_role_outline_batch_json_repair_prompt,
build_custom_world_role_outline_batch_prompt,
};
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map as JsonMap, Value as JsonValue, json};
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
use spacetime_client::CustomWorldAgentSessionRecord;
use tracing::warn;
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldFoundationDraftResult {
pub draft_profile_json: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DraftFoundationPayloadError {
SerializePayload(String),
InvalidPayloadShape,
InvalidGeneratedDraft(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldFoundationDraftProgress {
pub phase_label: String,
pub phase_detail: String,
pub progress: u32,
}
pub async fn generate_custom_world_foundation_draft(
llm_client: &LlmClient,
session: &CustomWorldAgentSessionRecord,
enable_web_search: bool,
mut on_progress: impl FnMut(CustomWorldFoundationDraftProgress) + Send,
) -> Result<CustomWorldFoundationDraftResult, String> {
let setting_text = build_foundation_generation_seed_text(session);
emit_foundation_draft_progress(
&mut on_progress,
"整理世界骨架",
"正在根据百梦主锚点生成第一版世界框架。",
12,
);
let mut framework = request_foundation_json_stage(
llm_client,
build_custom_world_framework_prompt(setting_text.as_str()),
"agent-foundation-framework",
|response_text| build_custom_world_framework_json_repair_prompt(response_text),
"agent-foundation-framework-json-repair",
"世界框架阶段没有返回有效内容。",
enable_web_search,
)
.await?;
normalize_framework_shape(&mut framework, setting_text.as_str());
let playable_outlines = generate_foundation_role_outline_entries(
llm_client,
&framework,
"playable",
FOUNDATION_DRAFT_PLAYABLE_COUNT,
(16, 30),
enable_web_search,
&mut on_progress,
)
.await?;
framework["playableNpcs"] = JsonValue::Array(playable_outlines.clone());
let story_outlines = generate_foundation_role_outline_entries(
llm_client,
&framework,
"story",
FOUNDATION_DRAFT_STORY_COUNT,
(30, 44),
enable_web_search,
&mut on_progress,
)
.await?;
framework["storyNpcs"] = JsonValue::Array(story_outlines.clone());
let generated_scene_entries = generate_foundation_landmark_seed_entries(
llm_client,
&framework,
FOUNDATION_DRAFT_LANDMARK_COUNT,
(44, 66),
enable_web_search,
&mut on_progress,
)
.await?;
framework["landmarks"] = JsonValue::Array(generated_scene_entries.clone());
let playable_narrative = expand_foundation_role_entries(
llm_client,
&framework,
"playable",
&playable_outlines,
"narrative",
(66, 76),
enable_web_search,
&mut on_progress,
)
.await?;
let playable_detailed = expand_foundation_role_entries(
llm_client,
&framework,
"playable",
&playable_narrative,
"dossier",
(76, 84),
enable_web_search,
&mut on_progress,
)
.await?;
let story_narrative = expand_foundation_role_entries(
llm_client,
&framework,
"story",
&story_outlines,
"narrative",
(84, 92),
enable_web_search,
&mut on_progress,
)
.await?;
let story_detailed = expand_foundation_role_entries(
llm_client,
&framework,
"story",
&story_narrative,
"dossier",
(92, 96),
enable_web_search,
&mut on_progress,
)
.await?;
emit_foundation_draft_progress(
&mut on_progress,
"编译世界底稿",
"正在把分批生成结果直接整理成第一版 foundation draft并同步兼容结果快照。",
97,
);
let draft_profile = build_foundation_draft_profile_from_framework(
framework,
playable_detailed,
story_detailed,
generated_scene_entries,
session,
setting_text.as_str(),
);
let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile))
.map_err(|error| format!("foundation draft JSON 序列化失败:{error}"))?;
Ok(CustomWorldFoundationDraftResult { draft_profile_json })
}
const FOUNDATION_JSON_ONLY_SYSTEM_PROMPT: &str = "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。";
const FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT: &str = "你是 JSON 修复器。\n你会收到一段本应为单个 JSON 对象的文本。\n你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。\n不要输出 Markdown、代码块、解释、注释或额外文字。";
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;
// 中文注释:单个场景已经包含三幕事件、三幕背景图 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"];
const BANNED_ATTRIBUTE_NAMES: [&str; 13] = [
"生命", "法力", "护甲", "攻击", "防御", "力量", "敏捷", "智力", "精神", "战士", "法师", "刺客",
"魔道",
];
async fn request_foundation_json_stage<F>(
llm_client: &LlmClient,
user_prompt: String,
debug_label: &str,
repair_prompt_builder: F,
repair_debug_label: &str,
empty_response_message: &str,
enable_web_search: bool,
) -> Result<JsonValue, String>
where
F: Fn(&str) -> String,
{
let response = request_foundation_text_with_optional_search_fallback(
llm_client,
FOUNDATION_JSON_ONLY_SYSTEM_PROMPT,
user_prompt.as_str(),
debug_label,
enable_web_search,
)
.await?;
let text = response.content.trim();
if text.is_empty() {
return Err(empty_response_message.to_string());
}
match parse_json_response_text(text) {
Ok(value) => Ok(value),
Err(_) => {
let repaired = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT),
LlmMessage::user(repair_prompt_builder(text)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api(),
)
.await
.map_err(|error| format!("{repair_debug_label} LLM 请求失败:{error}"))?;
parse_json_response_text(repaired.content.as_str())
.map_err(|error| format!("{repair_debug_label} JSON 解析失败:{error}"))
}
}
}
async fn request_foundation_text_with_optional_search_fallback(
llm_client: &LlmClient,
system_prompt: &str,
user_prompt: &str,
debug_label: &str,
enable_web_search: bool,
) -> Result<platform_llm::LlmTextResponse, String> {
match request_foundation_text(llm_client, system_prompt, user_prompt, enable_web_search).await {
Ok(response) => Ok(response),
Err(error) if enable_web_search && should_retry_foundation_without_web_search(&error) => {
warn!(
error = %error,
debug_label,
"foundation draft 联网搜索增强不可用或超时,自动降级为无联网搜索重试"
);
request_foundation_text(llm_client, system_prompt, user_prompt, false)
.await
.map_err(|retry_error| format!("{debug_label} LLM 请求失败:{retry_error}"))
}
Err(error) => Err(format!("{debug_label} LLM 请求失败:{error}")),
}
}
async fn request_foundation_text(
llm_client: &LlmClient,
system_prompt: &str,
user_prompt: &str,
enable_web_search: bool,
) -> Result<platform_llm::LlmTextResponse, platform_llm::LlmError> {
llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(enable_web_search),
)
.await
}
fn should_retry_foundation_without_web_search(error: &platform_llm::LlmError) -> bool {
match error {
platform_llm::LlmError::Timeout { .. } | platform_llm::LlmError::Connectivity { .. } => {
true
}
platform_llm::LlmError::Upstream { message, .. } => {
message.contains("ToolNotOpen")
|| message.contains("has not activated web search")
|| message.contains("未开通")
}
_ => false,
}
}
async fn generate_foundation_role_outline_entries(
llm_client: &LlmClient,
framework: &JsonValue,
role_type: &str,
total_count: usize,
progress_range: (u32, u32),
enable_web_search: bool,
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new();
let planned_batch_count = total_count
.div_ceil(FOUNDATION_ROLE_OUTLINE_BATCH_SIZE)
.max(1);
for batch_index in 0..planned_batch_count {
if merged_entries.len() >= total_count {
break;
}
let batch_count =
(total_count - merged_entries.len()).min(FOUNDATION_ROLE_OUTLINE_BATCH_SIZE);
let forbidden_names = names_from_entries(&merged_entries);
let role_label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
emit_foundation_draft_progress(
on_progress,
format!("生成{role_label}").as_str(),
format!(
"正在生成{role_label}{} / {} 批,当前已完成 {}/{}",
batch_index + 1,
planned_batch_count,
merged_entries.len(),
total_count,
)
.as_str(),
to_batch_progress(progress_range, merged_entries.len(), total_count),
);
let raw = request_foundation_json_stage(
llm_client,
build_custom_world_role_outline_batch_prompt(
framework,
role_type,
batch_count,
&forbidden_names,
),
format!(
"agent-foundation-{role_type}-outline-batch-{}",
batch_index + 1
)
.as_str(),
|response_text| {
build_custom_world_role_outline_batch_json_repair_prompt(
response_text,
role_type,
batch_count,
&forbidden_names,
)
},
format!(
"agent-foundation-{role_type}-outline-batch-{}-json-repair",
batch_index + 1
)
.as_str(),
"角色框架名单阶段没有返回有效内容。",
enable_web_search,
)
.await?;
let key = role_key(role_type);
let raw_entries = array_field(&raw, key)
.into_iter()
.take(batch_count)
.collect();
let repaired_entries = ensure_role_outline_asset_fields(role_type, raw_entries)?;
merged_entries.extend(repaired_entries);
}
let merged_entries: Vec<JsonValue> = merged_entries.into_iter().take(total_count).collect();
let role_label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
emit_foundation_draft_progress(
on_progress,
format!("生成{role_label}").as_str(),
format!("{role_label}已经整理完成,共 {} 个。", merged_entries.len()).as_str(),
progress_range.1,
);
Ok(merged_entries)
}
async fn generate_foundation_landmark_seed_entries(
llm_client: &LlmClient,
framework: &JsonValue,
total_count: usize,
progress_range: (u32, u32),
enable_web_search: bool,
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new();
let planned_batch_count = total_count.div_ceil(FOUNDATION_LANDMARK_BATCH_SIZE).max(1);
for batch_index in 0..planned_batch_count {
if merged_entries.len() >= total_count {
break;
}
let batch_count = (total_count - merged_entries.len()).min(FOUNDATION_LANDMARK_BATCH_SIZE);
let forbidden_names = names_from_entries(&merged_entries);
let is_opening_batch = batch_index == 0 && merged_entries.is_empty();
emit_foundation_draft_progress(
on_progress,
"生成关键场景",
format!(
"正在生成关键场景第 {} / {} 批,当前已完成 {}/{}",
batch_index + 1,
planned_batch_count,
merged_entries.len(),
total_count,
)
.as_str(),
to_batch_progress(progress_range, merged_entries.len(), total_count),
);
let raw = request_foundation_json_stage(
llm_client,
build_custom_world_landmark_seed_batch_prompt(
framework,
batch_count,
&forbidden_names,
is_opening_batch,
),
format!("agent-foundation-landmark-seed-batch-{}", batch_index + 1).as_str(),
|response_text| {
build_custom_world_landmark_seed_batch_json_repair_prompt(
response_text,
batch_count,
&forbidden_names,
is_opening_batch,
)
},
format!(
"agent-foundation-landmark-seed-batch-{}-json-repair",
batch_index + 1
)
.as_str(),
"地点框架名单阶段没有返回有效内容。",
enable_web_search,
)
.await?;
merged_entries.extend(array_field(&raw, "landmarks").into_iter().take(batch_count));
}
let merged_entries: Vec<JsonValue> = merged_entries.into_iter().take(total_count).collect();
emit_foundation_draft_progress(
on_progress,
"生成关键场景",
format!("关键场景骨架已整理完成,共 {} 个。", merged_entries.len()).as_str(),
progress_range.1,
);
Ok(merged_entries)
}
fn ensure_role_outline_asset_fields(
role_type: &str,
entries: Vec<JsonValue>,
) -> Result<Vec<JsonValue>, String> {
// 中文注释:角色默认资产字段必须随角色 outline 同一次模型调用产出;模型漏字段时只做本地兜底,不再额外发起修复模型调用。
let expected_names = names_from_entries(&entries);
let repaired_entries = entries
.into_iter()
.map(|entry| fill_missing_role_outline_asset_fields(entry, role_type))
.collect::<Vec<_>>();
validate_role_outline_asset_fields(&repaired_entries, &expected_names)?;
Ok(repaired_entries)
}
fn fill_missing_role_outline_asset_fields(mut entry: JsonValue, role_type: &str) -> JsonValue {
if !entry.is_object() {
entry = json!({});
}
let name = json_text(&entry, "name").unwrap_or_else(|| "未命名角色".to_string());
let title = json_text(&entry, "title").unwrap_or_default();
let role = json_text(&entry, "role").unwrap_or_else(|| {
if role_type == "playable" {
"可扮演角色".to_string()
} else {
"场景角色".to_string()
}
});
let description = json_text(&entry, "description").unwrap_or_else(|| role.clone());
let tags = json_string_array(&entry, "tags").unwrap_or_default();
let tag_text = tags.first().cloned().unwrap_or_else(|| role.clone());
let Some(object) = entry.as_object_mut() else {
return entry;
};
insert_text_if_missing(
object,
"visualDescription",
format!("{name}身带{tag_text}气质,服装和轮廓呼应“{description}”,有清晰识别点。").as_str(),
);
insert_text_if_missing(
object,
"actionDescription",
format!("{name}{role}身份行动,围绕“{description}”做出稳定而可识别的动作。").as_str(),
);
insert_text_if_missing(
object,
"sceneVisualDescription",
format!("{name}常出现在与“{description}”相关的场景中,周围保留其身份线索。").as_str(),
);
if !object
.get("title")
.and_then(JsonValue::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
object.insert("title".to_string(), JsonValue::String(title));
}
entry
}
fn insert_text_if_missing(object: &mut JsonMap<String, JsonValue>, key: &str, fallback: &str) {
if object
.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return;
}
object.insert(key.to_string(), JsonValue::String(fallback.to_string()));
}
fn validate_role_outline_asset_fields(
entries: &[JsonValue],
expected_names: &[String],
) -> Result<(), String> {
let missing_report = role_asset_field_missing_report(entries);
if !missing_report.is_empty() {
return Err(format!(
"角色形象设定文本生成不完整:{missing_report}。请重新生成底稿。"
));
}
for expected_name in expected_names {
if !entries
.iter()
.any(|entry| json_text(entry, "name").as_deref() == Some(expected_name.as_str()))
{
return Err(format!(
"角色形象设定文本补齐后缺少原角色「{expected_name}」。请重新生成底稿。"
));
}
}
Ok(())
}
fn role_asset_field_missing_report(entries: &[JsonValue]) -> String {
let mut missing_items = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let name = json_text(entry, "name").unwrap_or_else(|| format!("角色{}", index + 1));
let missing_fields = [
"visualDescription",
"actionDescription",
"sceneVisualDescription",
]
.into_iter()
.filter(|field| json_text(entry, field).is_none())
.collect::<Vec<_>>();
if !missing_fields.is_empty() {
missing_items.push(format!("角色「{name}」缺少 {}", missing_fields.join("/")));
}
}
missing_items.join("")
}
async fn expand_foundation_role_entries(
llm_client: &LlmClient,
framework: &JsonValue,
role_type: &str,
base_entries: &[JsonValue],
stage: &str,
progress_range: (u32, u32),
enable_web_search: bool,
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new();
let batches: Vec<&[JsonValue]> = base_entries
.chunks(FOUNDATION_ROLE_DETAIL_BATCH_SIZE)
.collect();
let mut processed_count = 0usize;
for (batch_index, batch) in batches.iter().enumerate() {
let expected_names = names_from_entries(batch);
let role_label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
let stage_label = if stage == "narrative" {
"叙事基础"
} else {
"档案细节"
};
emit_foundation_draft_progress(
on_progress,
format!("补全{role_label}{stage_label}").as_str(),
format!(
"正在补全{role_label}{stage_label}{} / {} 批,当前已完成 {}/{}",
batch_index + 1,
batches.len(),
processed_count,
base_entries.len(),
)
.as_str(),
to_batch_progress(progress_range, processed_count, base_entries.len()),
);
let raw_result = request_foundation_json_stage(
llm_client,
build_custom_world_role_batch_prompt(framework, role_type, batch, stage),
format!(
"agent-foundation-{role_type}-{stage}-batch-{}",
batch_index + 1
)
.as_str(),
|response_text| {
build_custom_world_role_batch_json_repair_prompt(
response_text,
role_type,
stage,
&expected_names,
)
},
format!(
"agent-foundation-{role_type}-{stage}-batch-{}-json-repair",
batch_index + 1
)
.as_str(),
"角色档案补全阶段没有返回有效内容。",
enable_web_search,
)
.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());
}
let role_label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
let stage_label = if stage == "narrative" {
"叙事基础"
} else {
"档案细节"
};
emit_foundation_draft_progress(
on_progress,
format!("补全{role_label}{stage_label}").as_str(),
format!("{role_label}{stage_label}已经整理完成。").as_str(),
progress_range.1,
);
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,
phase_detail: &str,
progress: u32,
) {
on_progress(CustomWorldFoundationDraftProgress {
phase_label: phase_label.to_string(),
phase_detail: phase_detail.to_string(),
progress: progress.min(100),
});
}
fn to_batch_progress(progress_range: (u32, u32), completed: usize, total: usize) -> u32 {
if total == 0 {
return progress_range.1;
}
let start = progress_range.0 as f64;
let end = progress_range.1 as f64;
let ratio = (completed as f64 / total as f64).clamp(0.0, 1.0);
(start + (end - start) * ratio).round().clamp(0.0, 100.0) as u32
}
// foundation draft 已经由 api-server 真实生成,落库前只负责把它注入现有 action payload。
pub fn build_draft_foundation_action_payload_json(
payload: &ExecuteCustomWorldAgentActionRequest,
draft_profile_json: &str,
) -> Result<String, DraftFoundationPayloadError> {
let mut payload_value = serde_json::to_value(payload).map_err(|error| {
DraftFoundationPayloadError::SerializePayload(format!(
"action payload JSON 序列化失败:{error}"
))
})?;
let payload_object = payload_value
.as_object_mut()
.ok_or(DraftFoundationPayloadError::InvalidPayloadShape)?;
let draft_profile_value =
serde_json::from_str::<JsonValue>(draft_profile_json).map_err(|error| {
DraftFoundationPayloadError::InvalidGeneratedDraft(format!(
"foundation draft JSON 非法:{error}"
))
})?;
if !draft_profile_value.is_object() {
return Err(DraftFoundationPayloadError::InvalidGeneratedDraft(
"foundation draft JSON 必须是 object".to_string(),
));
}
payload_object.insert("draftProfile".to_string(), draft_profile_value);
serde_json::to_string(&payload_value).map_err(|error| {
DraftFoundationPayloadError::SerializePayload(format!(
"action payload JSON 序列化失败:{error}"
))
})
}
fn build_foundation_generation_seed_text(session: &CustomWorldAgentSessionRecord) -> String {
let anchor_text = build_eight_anchor_foundation_text(&session.anchor_content);
if !anchor_text.trim().is_empty() {
return anchor_text;
}
if let Some(summary) = session
.anchor_pack
.get("creatorIntentSummary")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return summary.to_string();
}
let sections = [
json_path_text(&session.creator_intent, &["worldHook"])
.map(|value| format!("世界核心:{value}")),
json_path_text(&session.creator_intent, &["playerPremise"])
.map(|value| format!("玩家身份:{value}")),
json_path_text(&session.creator_intent, &["openingSituation"])
.map(|value| format!("开局处境:{value}")),
json_string_array(&session.creator_intent, "coreConflicts")
.map(|items| format!("核心冲突:{}", items.join(""))),
json_string_array(&session.creator_intent, "iconicElements")
.map(|items| format!("标志元素:{}", items.join(""))),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n");
if sections.trim().is_empty() {
session.seed_text.trim().to_string()
} else {
sections
}
}
fn build_eight_anchor_foundation_text(anchor_content: &JsonValue) -> String {
let mut sections = Vec::new();
for (key, label) in [
("worldPromise", "世界承诺"),
("playerFantasy", "玩家幻想"),
("themeBoundary", "主题边界"),
("playerEntryPoint", "玩家切入口"),
("coreConflict", "核心冲突"),
("keyRelationships", "关键关系"),
("hiddenLines", "暗线与揭示节奏"),
("iconicElements", "标志元素与硬规则"),
] {
if let Some(value) = anchor_content.get(key)
&& has_meaningful_anchor_value(value)
{
// foundation draft 必须直接吃 Agent session 当前八锚点,避免旧字段名把 8 个锚点压缩成残缺 seed。
sections.push(format!("{label}{}", compact_json_text(value)));
}
}
sections.join("\n")
}
fn has_meaningful_anchor_value(value: &JsonValue) -> bool {
match value {
JsonValue::Null => false,
JsonValue::Bool(_) | JsonValue::Number(_) => true,
JsonValue::String(text) => !text.trim().is_empty(),
JsonValue::Array(items) => items.iter().any(has_meaningful_anchor_value),
JsonValue::Object(object) => object.values().any(has_meaningful_anchor_value),
}
}
#[cfg(test)]
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
let anchor_content = to_pretty_json(&session.anchor_content);
let creator_intent = to_pretty_json(&session.creator_intent);
let anchor_pack = to_pretty_json(&session.anchor_pack);
let current_draft = if is_non_null_json(&session.draft_profile) {
to_pretty_json(&session.draft_profile)
} else {
"{}".to_string()
};
let quality_findings = to_pretty_json(&JsonValue::Array(session.quality_findings.clone()));
[
format!("seedText{}", session.seed_text.trim()),
format!("当前 stage{}", session.stage.trim()),
format!("当前 progressPercent{}", session.progress_percent),
format!(
"当前最后一条 assistant 回复:{}",
session.last_assistant_reply.clone().unwrap_or_default()
),
format!("当前 anchorContent\n{anchor_content}"),
format!("当前 creatorIntent\n{creator_intent}"),
format!("当前 anchorPack\n{anchor_pack}"),
format!("当前已有 draftProfile\n{current_draft}"),
format!("当前 qualityFindings\n{quality_findings}"),
"请直接返回第一版 foundation draft JSON。".to_string(),
"约束:".to_string(),
"1. worldHook 必须是一句可以直接用于发布门禁校验的世界钩子。".to_string(),
"2. playerPremise 必须明确玩家身份与切入前提。".to_string(),
"3. coreConflicts 必须至少 1 条。".to_string(),
"4. chapters 或 sceneChapterBlueprints 必须体现主线第一幕。".to_string(),
"5. sceneChapterBlueprints[0].acts 至少 1 条。".to_string(),
"6. sceneChapterBlueprints[*].acts[*].backgroundPromptText 必须逐幕生成,作为每一幕生成背景图时默认填入的场景画面描述,不要只生成一个全局场景背景提示词。".to_string(),
"7. summary 要像结果页摘要,不要只是原始 seed 重复。".to_string(),
]
.join("\n\n")
}
fn build_foundation_draft_profile_from_framework(
framework: JsonValue,
playable_detailed: Vec<JsonValue>,
story_detailed: Vec<JsonValue>,
generated_scene_entries: Vec<JsonValue>,
session: &CustomWorldAgentSessionRecord,
setting_text: &str,
) -> JsonMap<String, JsonValue> {
let mut object = JsonMap::new();
object.insert(
"name".to_string(),
JsonValue::String(
json_text(&framework, "name")
.unwrap_or_else(|| derive_world_name(&JsonMap::new(), session)),
),
);
object.insert(
"subtitle".to_string(),
JsonValue::String(
json_text(&framework, "subtitle").unwrap_or_else(|| "世界底稿已生成".to_string()),
),
);
object.insert(
"summary".to_string(),
JsonValue::String(
json_text(&framework, "summary").unwrap_or_else(|| setting_text.to_string()),
),
);
object.insert(
"tone".to_string(),
JsonValue::String(json_text(&framework, "tone").unwrap_or_default()),
);
object.insert(
"playerGoal".to_string(),
JsonValue::String(json_text(&framework, "playerGoal").unwrap_or_default()),
);
object.insert(
"worldHook".to_string(),
JsonValue::String(
json_text(&framework, "summary")
.unwrap_or_else(|| derive_world_hook(&JsonMap::new(), session)),
),
);
object.insert(
"playerPremise".to_string(),
JsonValue::String(
json_text(&framework, "playerGoal")
.unwrap_or_else(|| derive_player_premise(&JsonMap::new(), session)),
),
);
object.insert(
"settingText".to_string(),
JsonValue::String(setting_text.to_string()),
);
object.insert(
"templateWorldType".to_string(),
JsonValue::String(
json_text(&framework, "templateWorldType").unwrap_or_else(|| "WUXIA".to_string()),
),
);
object.insert(
"majorFactions".to_string(),
framework
.get("majorFactions")
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
);
object.insert(
"coreConflicts".to_string(),
framework.get("coreConflicts").cloned().unwrap_or_else(|| {
JsonValue::Array(vec![JsonValue::String(
"核心冲突仍需继续深化,但已经具备第一版主线推进方向。".to_string(),
)])
}),
);
object.insert(
"attributeSchema".to_string(),
normalize_world_attribute_schema(
framework.get("attributeSchema"),
&framework,
setting_text,
),
);
let fallback_camp = framework.get("camp").cloned().unwrap_or_else(
|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }),
);
let playable_detailed = assign_role_ids(playable_detailed, "playable-npc");
let story_detailed = assign_role_ids(story_detailed, "story-npc");
let scene_role_refs = collect_scene_role_refs(&story_detailed);
let (camp, landmarks) =
split_generated_scenes_into_camp_and_landmarks(fallback_camp, generated_scene_entries);
object.insert("camp".to_string(), camp.clone());
object.insert(
"playableNpcs".to_string(),
JsonValue::Array(playable_detailed),
);
object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed));
let scene_chapter_blueprints =
build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks, &scene_role_refs);
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(scene_chapter_blueprints),
);
normalize_foundation_draft_profile(JsonValue::Object(object), session)
}
fn normalize_world_attribute_schema(
raw_schema: Option<&JsonValue>,
framework: &JsonValue,
setting_text: &str,
) -> JsonValue {
let fallback = build_fallback_world_attribute_schema(framework, setting_text);
let Some(schema) = raw_schema.and_then(JsonValue::as_object) else {
return fallback;
};
let raw_slots = schema
.get("slots")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
if raw_slots.len() != WORLD_ATTRIBUTE_SLOT_IDS.len() {
return fallback;
}
let fallback_slots = fallback
.get("slots")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
let mut seen_names = Vec::with_capacity(WORLD_ATTRIBUTE_SLOT_IDS.len());
let mut normalized_slots = Vec::with_capacity(WORLD_ATTRIBUTE_SLOT_IDS.len());
for (index, slot_id) in WORLD_ATTRIBUTE_SLOT_IDS.iter().enumerate() {
let Some(raw_slot) = raw_slots.get(index).and_then(JsonValue::as_object) else {
return fallback;
};
let fallback_slot = fallback_slots
.get(index)
.and_then(JsonValue::as_object)
.cloned()
.unwrap_or_default();
let name = json_map_text(raw_slot, "name").unwrap_or_else(|| {
json_map_text(&fallback_slot, "name").unwrap_or_else(|| format!("叙轴{}", index + 1))
});
if is_invalid_attribute_name(name.as_str(), &seen_names) {
return fallback;
}
seen_names.push(name.clone());
normalized_slots.push(json!({
"slotId": slot_id,
"name": name,
}));
}
json!({
"id": json_map_text(schema, "id")
.unwrap_or_else(|| build_attribute_schema_id(framework, setting_text)),
"worldId": json_map_text(schema, "worldId")
.unwrap_or_else(|| format!("custom:{}", framework_world_name(framework, setting_text))),
"schemaVersion": schema
.get("schemaVersion")
.and_then(JsonValue::as_i64)
.filter(|value| *value > 0)
.unwrap_or(1),
"generatedFrom": {
"worldType": "CUSTOM",
"worldName": framework_world_name(framework, setting_text),
"settingSummary": json_text(framework, "summary").unwrap_or_else(|| setting_text.to_string()),
"tone": json_text(framework, "tone").unwrap_or_default(),
"conflictCore": first_json_string(framework, "coreConflicts")
.or_else(|| json_text(framework, "playerGoal"))
.unwrap_or_else(|| setting_text.to_string()),
},
"slots": normalized_slots,
})
}
fn build_fallback_world_attribute_schema(framework: &JsonValue, setting_text: &str) -> JsonValue {
let world_name = framework_world_name(framework, setting_text);
let summary = json_text(framework, "summary").unwrap_or_else(|| setting_text.to_string());
let tone = json_text(framework, "tone").unwrap_or_default();
let player_goal = json_text(framework, "playerGoal").unwrap_or_else(|| summary.clone());
let conflict_core =
first_json_string(framework, "coreConflicts").unwrap_or_else(|| player_goal.clone());
let theme_seed = [
world_name.as_str(),
summary.as_str(),
tone.as_str(),
conflict_core.as_str(),
]
.join("");
let theme_terms = collect_attribute_theme_terms(theme_seed.as_str());
let prefix = theme_terms
.first()
.cloned()
.unwrap_or_else(|| "".to_string());
let prefix_alt = theme_terms
.get(1)
.cloned()
.unwrap_or_else(|| "".to_string());
json!({
"id": build_attribute_schema_id(framework, setting_text),
"worldId": format!("custom:{world_name}"),
"schemaVersion": 1,
"generatedFrom": {
"worldType": "CUSTOM",
"worldName": world_name,
"settingSummary": summary,
"tone": tone,
"conflictCore": conflict_core,
},
"slots": [
build_attribute_slot("axis_a", format!("{prefix}")),
build_attribute_slot("axis_b", format!("{prefix_alt}")),
build_attribute_slot("axis_c", format!("{prefix}")),
build_attribute_slot("axis_d", format!("{prefix_alt}")),
build_attribute_slot("axis_e", format!("{prefix}")),
build_attribute_slot("axis_f", format!("{prefix_alt}")),
],
})
}
fn build_attribute_slot(slot_id: &str, name: String) -> JsonValue {
json!({
"slotId": slot_id,
"name": name,
})
}
fn framework_world_name(framework: &JsonValue, setting_text: &str) -> String {
json_text(framework, "name").unwrap_or_else(|| {
let fallback = setting_text
.chars()
.filter(|character| !character.is_whitespace())
.take(8)
.collect::<String>();
if fallback.trim().is_empty() {
"自定义世界".to_string()
} else {
fallback
}
})
}
fn build_attribute_schema_id(framework: &JsonValue, setting_text: &str) -> String {
format!(
"schema:rpg-agent:{}:v1",
stable_ascii_slug(framework_world_name(framework, setting_text).as_str())
)
}
fn collect_attribute_theme_terms(source: &str) -> Vec<String> {
let mut terms = Vec::new();
let chinese_chars = source
.chars()
.filter(|character| ('\u{4e00}'..='\u{9fff}').contains(character))
.collect::<Vec<_>>();
for size in [2usize, 1usize] {
if chinese_chars.len() < size {
continue;
}
for window in chinese_chars.windows(size) {
let term = window.iter().collect::<String>();
if term.chars().count() > 2
|| BANNED_ATTRIBUTE_NAMES
.iter()
.any(|banned| term.contains(banned))
{
continue;
}
if !terms.contains(&term) {
terms.push(term);
}
if terms.len() >= 3 {
return terms;
}
}
}
terms
}
fn is_invalid_attribute_name(name: &str, seen_names: &[String]) -> bool {
let trimmed = name.trim();
trimmed.is_empty()
|| trimmed.chars().count() > 4
|| seen_names.iter().any(|seen| seen == trimmed)
|| BANNED_ATTRIBUTE_NAMES
.iter()
.any(|banned| trimmed.contains(banned))
}
fn json_map_text(map: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
map.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn first_json_string(value: &JsonValue, key: &str) -> Option<String> {
value
.get(key)
.and_then(JsonValue::as_array)
.and_then(|items| items.first())
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub(crate) fn stable_ascii_slug(value: &str) -> String {
let mut hash = 0u32;
for character in value.chars() {
hash = hash.wrapping_mul(31).wrapping_add(character as u32);
}
format!("{hash:08x}")
}
fn split_generated_scenes_into_camp_and_landmarks(
fallback_camp: JsonValue,
generated_scene_entries: Vec<JsonValue>,
) -> (JsonValue, Vec<JsonValue>) {
let mut entries = generated_scene_entries.into_iter();
let opening_scene = entries.next().unwrap_or(fallback_camp);
let camp = normalize_generated_opening_scene(opening_scene);
let landmarks = entries.collect::<Vec<_>>();
(camp, landmarks)
}
fn normalize_generated_opening_scene(scene: JsonValue) -> JsonValue {
let mut object = scene.as_object().cloned().unwrap_or_default();
let name = object
.get("name")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("开局归处")
.to_string();
let description = object
.get("description")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("玩家进入世界后的第一处落脚点。")
.to_string();
object.insert("id".to_string(), JsonValue::String("camp-1".to_string()));
object.insert("kind".to_string(), JsonValue::String("camp".to_string()));
object.insert("name".to_string(), JsonValue::String(name));
object.insert("description".to_string(), JsonValue::String(description));
JsonValue::Object(object)
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct SceneRoleRef {
id: String,
name: String,
}
fn build_scene_chapter_blueprints_from_camp_and_landmarks(
camp: &JsonValue,
landmarks: &[JsonValue],
scene_role_refs: &[SceneRoleRef],
) -> Vec<JsonValue> {
let mut blueprints = Vec::with_capacity(landmarks.len() + 1);
blueprints.push(build_scene_chapter_blueprint_from_scene(
camp,
0,
"camp",
"开局归处",
scene_role_refs,
));
blueprints.extend(build_scene_chapter_blueprints_from_landmarks(
landmarks,
scene_role_refs,
));
blueprints
}
fn build_scene_chapter_blueprints_from_landmarks(
landmarks: &[JsonValue],
scene_role_refs: &[SceneRoleRef],
) -> Vec<JsonValue> {
// 幕背景描述必须来自关键场景生成步骤,不能在草稿合成阶段再用规则句拼接。
landmarks
.iter()
.enumerate()
.map(|(chapter_index, landmark)| {
build_scene_chapter_blueprint_from_scene(
landmark,
chapter_index,
"saved-landmark",
"关键场景",
scene_role_refs,
)
})
.collect()
}
fn build_scene_chapter_blueprint_from_scene(
scene: &JsonValue,
chapter_index: usize,
id_prefix: &str,
fallback_name_prefix: &str,
scene_role_refs: &[SceneRoleRef],
) -> JsonValue {
let scene_name = json_text(scene, "name")
.unwrap_or_else(|| format!("{}{}", fallback_name_prefix, chapter_index + 1));
let scene_id =
json_text(scene, "id").unwrap_or_else(|| format!("{}-{}", id_prefix, chapter_index + 1));
let summary = json_text(scene, "description").unwrap_or_default();
let scene_task_description = json_text(scene, "sceneTaskDescription")
.unwrap_or_else(|| build_default_scene_task_description(&scene_name, &summary));
let act_prompts = json_string_array(scene, "actBackgroundPromptTexts").unwrap_or_default();
let act_events = json_string_array(scene, "actEventDescriptions").unwrap_or_default();
let act_npc_names = json_string_array(scene, "actNPCNames")
.or_else(|| json_string_array(scene, "sceneNpcNames"))
.unwrap_or_default();
let resolved_act_roles = resolve_scene_act_roles(&act_npc_names, scene_role_refs);
let scene_npc_ids = dedupe_text_values(
&resolved_act_roles
.iter()
.map(|role| role.id.clone())
.collect::<Vec<_>>(),
);
json!({
"id": scene_id.clone(),
"sceneId": scene_id.clone(),
"title": scene_name,
"summary": summary,
"sceneTaskDescription": scene_task_description,
"linkedLandmarkIds": [scene_id.clone()],
"sceneNpcIds": scene_npc_ids,
"acts": (0..3)
.map(|act_index| build_scene_act_blueprint_from_landmark(
&scene_id,
&summary,
&act_prompts,
&act_events,
&resolved_act_roles,
act_index,
))
.collect::<Vec<_>>(),
})
}
fn build_scene_act_blueprint_from_landmark(
scene_id: &str,
scene_summary: &str,
act_prompts: &[String],
act_events: &[String],
act_roles: &[SceneRoleRef],
act_index: usize,
) -> JsonValue {
let act_title = if act_index == 0 {
"第1幕".to_string()
} else {
format!("{}", act_index + 1)
};
let prompt = act_prompts
.get(act_index)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let opposite_role = act_roles
.get(act_index)
.or_else(|| act_roles.first())
.cloned();
let opposite_npc_id = opposite_role
.as_ref()
.map(|role| role.id.clone())
.unwrap_or_default();
let opposite_role_name = opposite_role
.as_ref()
.map(|role| role.name.clone())
.unwrap_or_default();
let event_description = act_events
.get(act_index)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
build_default_act_event_description(
scene_summary,
opposite_role_name.as_str(),
act_index,
)
});
let background_prompt = prompt.unwrap_or_else(|| {
build_default_act_background_prompt(
scene_summary,
opposite_role_name.as_str(),
event_description.as_str(),
act_index,
)
});
json!({
"id": format!("{}-act-{}", scene_id, act_index + 1),
"sceneId": scene_id,
"title": act_title,
"summary": scene_summary,
"backgroundPromptText": background_prompt,
"encounterNpcIds": build_act_encounter_npc_ids(act_roles, opposite_npc_id.as_str()),
"primaryNpcId": opposite_npc_id,
"oppositeNpcId": opposite_npc_id,
"primaryRoleName": opposite_role_name,
"oppositeRoleName": opposite_role_name,
"eventDescription": event_description,
})
}
fn build_act_encounter_npc_ids(act_roles: &[SceneRoleRef], primary_npc_id: &str) -> Vec<String> {
let mut names = Vec::with_capacity(act_roles.len().max(1));
let primary = primary_npc_id.trim();
if !primary.is_empty() {
names.push(primary.to_string());
}
for role in act_roles {
let normalized = role.id.trim();
if normalized.is_empty() || names.iter().any(|item| item == normalized) {
continue;
}
names.push(normalized.to_string());
}
names
}
fn assign_role_ids(entries: Vec<JsonValue>, id_prefix: &str) -> Vec<JsonValue> {
entries
.into_iter()
.enumerate()
.map(|(index, entry)| assign_role_id(entry, id_prefix, index))
.collect()
}
fn assign_role_id(mut entry: JsonValue, id_prefix: &str, index: usize) -> JsonValue {
let name = json_text(&entry, "name").unwrap_or_else(|| format!("角色{}", index + 1));
let fallback_id = format!("{}-{}", id_prefix, stable_ascii_slug(name.as_str()));
let Some(object) = entry.as_object_mut() else {
return json!({
"id": fallback_id,
"name": name,
});
};
if object
.get("id")
.and_then(JsonValue::as_str)
.map(str::trim)
.is_none_or(str::is_empty)
{
object.insert("id".to_string(), JsonValue::String(fallback_id));
}
entry
}
fn collect_scene_role_refs(entries: &[JsonValue]) -> Vec<SceneRoleRef> {
entries
.iter()
.filter_map(|entry| {
let name = json_text(entry, "name")?;
let id = json_text(entry, "id")?;
Some(SceneRoleRef { id, name })
})
.collect()
}
fn resolve_scene_act_roles(
requested_role_names: &[String],
scene_role_refs: &[SceneRoleRef],
) -> Vec<SceneRoleRef> {
let mut resolved = requested_role_names
.iter()
.filter_map(|name| resolve_scene_role_ref(name, scene_role_refs))
.collect::<Vec<_>>();
if resolved.is_empty() {
resolved.extend(scene_role_refs.iter().take(3).cloned());
}
dedupe_scene_role_refs(resolved)
}
fn resolve_scene_role_ref(
name_or_id: &str,
scene_role_refs: &[SceneRoleRef],
) -> Option<SceneRoleRef> {
let normalized = name_or_id.trim();
if normalized.is_empty() {
return None;
}
scene_role_refs
.iter()
.find(|role| role.name == normalized || role.id == normalized)
.cloned()
}
fn dedupe_scene_role_refs(entries: Vec<SceneRoleRef>) -> Vec<SceneRoleRef> {
let mut seen = Vec::new();
let mut result = Vec::new();
for entry in entries {
if entry.id.trim().is_empty() || seen.iter().any(|id| id == &entry.id) {
continue;
}
seen.push(entry.id.clone());
result.push(entry);
}
result
}
fn dedupe_text_values(values: &[String]) -> Vec<String> {
let mut result = Vec::new();
for value in values {
let normalized = value.trim();
if normalized.is_empty() || result.iter().any(|item| item == normalized) {
continue;
}
result.push(normalized.to_string());
}
result
}
fn build_default_scene_task_description(scene_name: &str, scene_summary: &str) -> String {
if scene_summary.trim().is_empty() {
return format!(
"首次进入{scene_name}时,确认当前场景的核心异常、关键角色与下一步行动方向。"
);
}
format!("首次进入{scene_name}时,围绕{scene_summary}确认核心异常、关键角色与下一步行动方向。")
}
fn build_default_act_event_description(
scene_summary: &str,
opposite_npc_id: &str,
act_index: usize,
) -> String {
let role_text = if opposite_npc_id.trim().is_empty() {
"当前场景关键角色"
} else {
opposite_npc_id.trim()
};
let scene_text = if scene_summary.trim().is_empty() {
"场景内的主线压力"
} else {
scene_summary.trim()
};
match act_index {
0 => format!(
"第1幕中{}先露出与{}有关的异常线索,玩家必须确认局势入口。",
role_text, scene_text
),
1 => format!(
"第2幕中{}的立场或阻碍让{}升级,玩家必须在压力下作出判断。",
role_text, scene_text
),
_ => format!(
"第3幕中{}{}推向高潮,玩家必须面对关键抉择或直接后果。",
role_text, scene_text
),
}
}
fn build_default_act_background_prompt(
scene_summary: &str,
opposite_npc_id: &str,
event_description: &str,
act_index: usize,
) -> String {
let role_text = if opposite_npc_id.trim().is_empty() {
"当前场景关键角色"
} else {
opposite_npc_id.trim()
};
let scene_text = if scene_summary.trim().is_empty() {
"场景内的主线压力"
} else {
scene_summary.trim()
};
let phase_text = match act_index {
0 => "铺垫阶段",
1 => "冲突升级阶段",
_ => "高潮阶段",
};
// 中文注释:幕背景默认值直接吃同幕事件和角色,避免前端再拼规则说明句。
format!(
"{scene_text}{phase_text}画面,{role_text}与玩家隔着可站立空间形成对峙,环境里保留“{event_description}”的冲突痕迹与清晰氛围。"
)
}
fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
if !framework.is_object() {
*framework = json!({});
}
let object = framework
.as_object_mut()
.expect("framework object should exist");
for key in [
"name",
"subtitle",
"summary",
"tone",
"playerGoal",
"templateWorldType",
] {
let value = object
.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_default()
.to_string();
if value.is_empty() {
object.insert(
key.to_string(),
JsonValue::String(if key == "summary" {
setting_text.to_string()
} else {
String::new()
}),
);
}
}
if !object.get("majorFactions").is_some_and(JsonValue::is_array) {
object.insert("majorFactions".to_string(), JsonValue::Array(Vec::new()));
}
if !object.get("coreConflicts").is_some_and(JsonValue::is_array) {
object.insert("coreConflicts".to_string(), JsonValue::Array(Vec::new()));
}
let framework_snapshot = JsonValue::Object(object.clone());
let attribute_schema = normalize_world_attribute_schema(
framework_snapshot.get("attributeSchema"),
&framework_snapshot,
setting_text,
);
object.insert("attributeSchema".to_string(), attribute_schema);
if !object.get("camp").is_some_and(JsonValue::is_object) {
object.insert(
"camp".to_string(),
json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }),
);
}
if let Some(camp) = object.get_mut("camp").and_then(JsonValue::as_object_mut) {
let camp_name = camp
.get("name")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("开局归处")
.to_string();
let camp_description = camp
.get("description")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("玩家进入世界后的第一处落脚点。")
.to_string();
camp.insert("name".to_string(), JsonValue::String(camp_name.clone()));
camp.insert(
"description".to_string(),
JsonValue::String(camp_description.clone()),
);
// 中文注释framework 只保留开局归处占位;完整开局场景任务与三幕内容统一交给场景批生成阶段。
for generated_scene_key in [
"sceneTaskDescription",
"actBackgroundPromptTexts",
"actEventDescriptions",
"actNPCNames",
"sceneNpcNames",
] {
camp.remove(generated_scene_key);
}
}
}
fn role_key(role_type: &str) -> &'static str {
if role_type == "playable" {
"playableNpcs"
} else {
"storyNpcs"
}
}
fn array_field(value: &JsonValue, key: &str) -> Vec<JsonValue> {
value
.get(key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default()
}
fn names_from_entries(entries: &[JsonValue]) -> Vec<String> {
entries
.iter()
.filter_map(|entry| json_text(entry, "name"))
.filter(|value| !value.is_empty())
.collect()
}
fn merge_entries_by_name(
base_entries: &[JsonValue],
patch_entries: &[JsonValue],
) -> Vec<JsonValue> {
base_entries
.iter()
.map(|base| {
let Some(name) = json_text(base, "name") else {
return base.clone();
};
let patch = patch_entries
.iter()
.find(|entry| json_text(entry, "name").as_deref() == Some(name.as_str()));
merge_json_objects(base, patch.unwrap_or(base))
})
.collect()
}
fn merge_json_objects(base: &JsonValue, patch: &JsonValue) -> JsonValue {
let mut object = base.as_object().cloned().unwrap_or_default();
if let Some(patch_object) = patch.as_object() {
for (key, value) in patch_object {
if !value.is_null() {
object.insert(key.clone(), value.clone());
}
}
}
JsonValue::Object(object)
}
fn json_text(value: &JsonValue, key: &str) -> Option<String> {
json_path_text(value, &[key])
}
fn json_path_text(value: &JsonValue, path: &[&str]) -> Option<String> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn json_string_array(value: &JsonValue, key: &str) -> Option<Vec<String>> {
let items = value
.get(key)?
.as_array()?
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|entry| !entry.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if items.is_empty() { None } else { Some(items) }
}
fn compact_json_text(value: &JsonValue) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
}
fn normalize_foundation_draft_profile(
value: JsonValue,
session: &CustomWorldAgentSessionRecord,
) -> JsonMap<String, JsonValue> {
let mut object = value.as_object().cloned().unwrap_or_default();
let fallback_title = derive_world_name(&object, session);
let fallback_world_hook = derive_world_hook(&object, session);
let fallback_player_premise = derive_player_premise(&object, session);
ensure_text_field(&mut object, "name", fallback_title.as_str());
ensure_text_field(&mut object, "subtitle", "世界底稿已生成");
ensure_text_field(
&mut object,
"summary",
"第一版世界底稿已经整理完成,可继续精修关键角色、地点和主线第一幕。",
);
ensure_text_field(&mut object, "worldHook", fallback_world_hook.as_str());
ensure_text_field(
&mut object,
"playerPremise",
fallback_player_premise.as_str(),
);
ensure_text_array_field(
&mut object,
"coreConflicts",
vec!["核心冲突仍需继续深化,但已经具备第一版主线推进方向。"],
);
ensure_object_array_field(&mut object, "playableNpcs");
ensure_object_array_field(&mut object, "storyNpcs");
ensure_object_array_field(&mut object, "landmarks");
ensure_object_array_field(&mut object, "chapters");
ensure_scene_chapter_blueprints(&mut object);
object
}
fn ensure_text_field(object: &mut JsonMap<String, JsonValue>, key: &str, fallback: &str) {
let current = object
.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
object.insert(
key.to_string(),
JsonValue::String(current.unwrap_or_else(|| fallback.to_string())),
);
}
fn ensure_text_array_field(
object: &mut JsonMap<String, JsonValue>,
key: &str,
fallback_items: Vec<&str>,
) {
let current_items = object
.get(key)
.and_then(JsonValue::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|value| !value.is_empty())
.map(|value| JsonValue::String(value.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if current_items.is_empty() {
object.insert(
key.to_string(),
JsonValue::Array(
fallback_items
.into_iter()
.map(|value| JsonValue::String(value.to_string()))
.collect(),
),
);
} else {
object.insert(key.to_string(), JsonValue::Array(current_items));
}
}
fn ensure_object_array_field(object: &mut JsonMap<String, JsonValue>, key: &str) {
let current = object
.get(key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
object.insert(key.to_string(), JsonValue::Array(current));
}
fn ensure_scene_chapter_blueprints(object: &mut JsonMap<String, JsonValue>) {
let blueprints = object
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
if blueprints.is_empty() {
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(vec![build_fallback_scene_chapter_blueprint()]),
);
return;
}
let normalized = blueprints
.into_iter()
.map(|chapter| normalize_scene_chapter_blueprint(chapter))
.collect::<Vec<_>>();
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(normalized),
);
}
fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
let mut object = chapter.as_object().cloned().unwrap_or_default();
let title = object
.get("title")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| "第一幕".to_string());
object.insert("title".to_string(), JsonValue::String(title.clone()));
let summary = object
.get("summary")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| "第一幕用于让玩家进入当前世界的主线矛盾。".to_string());
object.insert("summary".to_string(), JsonValue::String(summary.clone()));
let scene_task_description = object
.get("sceneTaskDescription")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_default_scene_task_description(title.as_str(), summary.as_str()));
object.insert(
"sceneTaskDescription".to_string(),
JsonValue::String(scene_task_description),
);
let acts = object
.get("acts")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
if acts.is_empty() {
object.insert(
"acts".to_string(),
JsonValue::Array(vec![build_fallback_scene_act()]),
);
} else {
object.insert(
"acts".to_string(),
JsonValue::Array(
acts.into_iter()
.enumerate()
.map(|(index, act)| normalize_scene_act_blueprint(act, index))
.collect(),
),
);
}
JsonValue::Object(object)
}
fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
let mut object = act.as_object().cloned().unwrap_or_default();
let title = object
.get("title")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{}", index + 1));
let summary = object
.get("summary")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| "当前幕推动场景内的主线压力。".to_string());
object.insert("title".to_string(), JsonValue::String(title.clone()));
object.insert("summary".to_string(), JsonValue::String(summary.clone()));
let raw_background_prompt = object
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let encounter_npc_ids = object
.get("encounterNpcIds")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|value| !value.is_empty())
.map(|value| JsonValue::String(value.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let opposite_npc_id = object
.get("oppositeNpcId")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
object
.get("primaryNpcId")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
})
.map(ToOwned::to_owned)
.or_else(|| {
encounter_npc_ids
.first()
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
})
.unwrap_or_default();
let event_description = object
.get("eventDescription")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
build_default_act_event_description(summary.as_str(), opposite_npc_id.as_str(), index)
});
let background_prompt = raw_background_prompt.unwrap_or_else(|| {
build_default_act_background_prompt(
summary.as_str(),
opposite_npc_id.as_str(),
event_description.as_str(),
index,
)
});
object.insert(
"backgroundPromptText".to_string(),
JsonValue::String(background_prompt),
);
object.insert(
"encounterNpcIds".to_string(),
JsonValue::Array(encounter_npc_ids),
);
object.insert(
"primaryNpcId".to_string(),
JsonValue::String(opposite_npc_id.clone()),
);
object.insert(
"oppositeNpcId".to_string(),
JsonValue::String(opposite_npc_id),
);
object.insert(
"eventDescription".to_string(),
JsonValue::String(event_description),
);
JsonValue::Object(object)
}
fn build_fallback_scene_chapter_blueprint() -> JsonValue {
json!({
"id": "chapter-act-1",
"title": "第一幕",
"summary": "第一幕用于让玩家进入当前世界的主线矛盾,并看见最初的风险与方向。",
"sceneTaskDescription": "首次进入当前场景时,确认主线矛盾、关键角色与下一步追查方向。",
"acts": [build_fallback_scene_act()],
})
}
fn build_fallback_scene_act() -> JsonValue {
build_fallback_scene_act_with_index(0)
}
fn build_fallback_scene_act_with_index(index: usize) -> JsonValue {
let event_description = build_default_act_event_description(
"玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"",
index,
);
json!({
"id": format!("scene-act-{}", index + 1),
"title": if index == 0 { "开场场景幕".to_string() } else { format!("{}", index + 1) },
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"backgroundPromptText": build_default_act_background_prompt(
"玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"",
event_description.as_str(),
index,
),
"encounterNpcIds": [],
"primaryNpcId": "",
"oppositeNpcId": "",
"eventDescription": event_description,
})
}
fn derive_world_name(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["name", "title"])
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
})
.unwrap_or_else(|| "未命名世界草稿".to_string())
}
fn derive_world_hook(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["worldHook"])
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
})
.unwrap_or_else(|| {
"这个世界正被一条持续扩大的主线危机推向失衡,而玩家会被卷入其中心。".to_string()
})
}
fn derive_player_premise(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["playerPremise"])
.or_else(|| {
session
.anchor_content
.get("playerEntryPoint")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
session
.anchor_content
.get("playerEntryPoint")
.and_then(JsonValue::as_object)
.map(|entry| {
let identity = entry
.get("openingIdentity")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let problem = entry
.get("openingProblem")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let motivation = entry
.get("entryMotivation")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
[identity, problem, motivation]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("")
})
})
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| {
"玩家会以一名已经卷入当前局势的人物进入世界,并被迫尽快确认自己的立场与行动方向。"
.to_string()
})
}
fn read_text_field(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
for key in keys {
let mut current = JsonValue::Object(object.clone());
let mut found = true;
for segment in key.split('.') {
if let Some(next) = current.get(segment) {
current = next.clone();
} else {
found = false;
break;
}
}
if found
&& let Some(value) = current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(value.to_string());
}
}
None
}
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
#[cfg(test)]
fn to_pretty_json(value: &JsonValue) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
}
#[cfg(test)]
fn is_non_null_json(value: &JsonValue) -> bool {
!matches!(value, JsonValue::Null)
}
#[cfg(test)]
mod tests {
use std::{
collections::VecDeque,
io::{Read, Write},
net::TcpListener,
sync::{Arc, Mutex},
thread,
time::{Duration as StdDuration, SystemTime, UNIX_EPOCH},
};
use platform_llm::{DEFAULT_REQUEST_TIMEOUT_MS, LlmConfig, LlmProvider};
use super::*;
#[test]
fn role_asset_field_missing_report_lists_visual_text_fields() {
let entries = vec![json!({
"name": "海洋生物学家",
"title": "深海观察员",
"role": "调查者",
"description": "记录异常海沟的人"
})];
let report = role_asset_field_missing_report(&entries);
assert!(report.contains("海洋生物学家"));
assert!(report.contains("visualDescription"));
assert!(report.contains("actionDescription"));
assert!(report.contains("sceneVisualDescription"));
}
#[test]
fn scene_chapter_blueprints_use_landmark_act_background_prompts() {
let landmarks = vec![json!({
"name": "雾港码头",
"description": "旧船骨露出黑潮。",
"sceneTaskDescription": "首次进入雾港码头时,查明黑潮船骨与灯童丁目击证词的关联。",
"actBackgroundPromptTexts": [
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。",
"封锁绳与巡海灯横切码头,中景堆满浸水货箱,远景黑潮拍打沉船残骸。",
"退潮后的泥滩露出父亲留下的海图匣,雾中灯火错位闪烁,岸边留出对峙站位。"
],
"actEventDescriptions": [
"灯童丁在潮湿栈桥上拦住玩家,交出与旧船骨有关的第一句证词。",
"灯童丁发现巡海灯突然转向,逼玩家判断封锁线真正保护的目标。",
"灯童丁指认海图匣位置,玩家必须在退潮前确认父亲留下的暗号。"
],
"actNPCNames": ["灯童丁", "档吏庚", "灯童丁"]
})];
let scene_role_refs = vec![
SceneRoleRef {
id: "story-npc-lamp-child".to_string(),
name: "灯童丁".to_string(),
},
SceneRoleRef {
id: "story-npc-archive-clerk".to_string(),
name: "档吏庚".to_string(),
},
];
let blueprints =
build_scene_chapter_blueprints_from_landmarks(&landmarks, &scene_role_refs);
let acts = blueprints[0]
.get("acts")
.and_then(JsonValue::as_array)
.expect("acts should exist");
assert_eq!(acts.len(), 3);
assert_eq!(
acts[0].get("backgroundPromptText"),
Some(&json!(
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。"
))
);
assert_eq!(
blueprints[0].get("sceneTaskDescription"),
Some(&json!(
"首次进入雾港码头时,查明黑潮船骨与灯童丁目击证词的关联。"
))
);
assert_eq!(
acts[0].get("oppositeNpcId"),
Some(&json!("story-npc-lamp-child"))
);
assert_eq!(
acts[0].get("primaryNpcId"),
Some(&json!("story-npc-lamp-child"))
);
assert_eq!(acts[0].get("primaryRoleName"), Some(&json!("灯童丁")));
assert_eq!(
acts[1].get("oppositeNpcId"),
Some(&json!("story-npc-archive-clerk"))
);
assert_eq!(
acts[1].get("primaryNpcId"),
Some(&json!("story-npc-archive-clerk"))
);
assert_eq!(
acts[0].get("eventDescription"),
Some(&json!(
"灯童丁在潮湿栈桥上拦住玩家,交出与旧船骨有关的第一句证词。"
))
);
assert!(
!acts[0]
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.contains("第1幕背景")
);
}
#[test]
fn scene_chapter_blueprints_include_opening_camp_acts() {
let mut framework = json!({
"camp": {
"name": "萧家祖宅",
"description": "玩家开局并成长的家族祖宅。"
}
});
normalize_framework_shape(&mut framework, "废柴少年的逆袭传奇");
let camp = framework
.get("camp")
.expect("camp should exist after normalize");
let landmarks = vec![json!({
"id": "landmark-duel-ground",
"name": "萧家斗技场",
"description": "萧家子弟修炼斗技、比试的场所。",
"actBackgroundPromptTexts": ["斗技台晨雾未散,石阶旁少年们列队观望。", "木桩与兵器架围出训练区,族徽旗帜在风里猎猎。", "暮色压下斗技场,中央擂台留出一对一交锋空间。"]
})];
let scene_role_refs = vec![SceneRoleRef {
id: "story-npc-mentor".to_string(),
name: "药师长老".to_string(),
}];
let blueprints = build_scene_chapter_blueprints_from_camp_and_landmarks(
camp,
&landmarks,
&scene_role_refs,
);
let opening_chapter = &blueprints[0];
let opening_acts = opening_chapter
.get("acts")
.and_then(JsonValue::as_array)
.expect("opening camp acts should exist");
assert_eq!(opening_chapter.get("sceneId"), Some(&json!("camp-1")));
assert_eq!(opening_acts.len(), 3);
assert!(opening_acts.iter().all(|act| {
act.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.is_some_and(|value| !value.trim().is_empty())
}));
assert!(
opening_chapter
.get("sceneTaskDescription")
.and_then(JsonValue::as_str)
.is_some_and(|value| !value.trim().is_empty())
);
assert!(opening_acts.iter().all(|act| {
act.get("eventDescription")
.and_then(JsonValue::as_str)
.is_some_and(|value| !value.trim().is_empty())
}));
assert!(opening_acts.iter().all(|act| {
act.get("primaryNpcId")
.and_then(JsonValue::as_str)
.is_some_and(|value| value == "story-npc-mentor")
}));
assert!(opening_acts.iter().all(|act| {
act.get("encounterNpcIds")
.and_then(JsonValue::as_array)
.and_then(|items| items.first())
.and_then(JsonValue::as_str)
.is_some_and(|value| value == "story-npc-mentor")
}));
assert_eq!(blueprints.len(), 2);
}
#[test]
fn normalize_scene_act_fills_missing_background_prompt_from_event() {
let act = normalize_scene_act_blueprint(
json!({
"title": "第1幕",
"summary": "玩家进入雾港码头。"
}),
0,
);
assert!(
act.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.is_some_and(|value| {
value.contains("铺垫阶段")
&& value.contains("玩家进入雾港码头")
&& value.contains("冲突痕迹")
})
);
assert!(
act.get("eventDescription")
.and_then(JsonValue::as_str)
.is_some_and(|value| value.contains("玩家进入雾港码头"))
);
}
#[test]
fn foundation_prompt_uses_real_seed_text() {
let session = build_test_session();
let prompt = build_foundation_draft_user_prompt(&session);
assert!(prompt.contains("seedText海雾会吞掉记错航线的人。"));
assert!(!prompt.contains("seedTextcustom-world-agent-session-1"));
}
#[test]
fn foundation_seed_text_keeps_current_eight_anchor_content() {
let mut session = build_test_session();
session.anchor_content = json!({
"worldPromise": {
"hook": "海雾会吞掉记错航线的人。",
"differentiator": "每张航线图都会主动撒谎。",
"desiredExperience": "调查、压迫、反转"
},
"playerFantasy": {
"playerRole": "返乡守灯人",
"corePursuit": "找回父亲沉船真相",
"fearOfLoss": "最后一盏灯也被议会熄灭"
},
"themeBoundary": {
"toneKeywords": ["海雾悬疑", "群岛旧案"],
"aestheticDirectives": ["湿冷灯塔", "错位航线"],
"forbiddenDirectives": ["现代都市校园"]
},
"playerEntryPoint": {
"openingIdentity": "被停职返乡的守灯人",
"openingProblem": "灯塔记录被人改写",
"entryMotivation": "查清父亲沉船真相"
},
"coreConflict": {
"surfaceConflicts": ["群岛议会封锁旧档案"],
"hiddenCrisis": "沉船事故其实是一次祭灯仪式失败",
"firstTouchedConflict": "玩家回到旧灯塔时发现灯火按假航线闪烁"
},
"keyRelationships": [
{
"pairs": "玩家 / 灯童丁",
"relationshipType": "证人与守护者",
"secretOrCost": "灯童丁说出真相会失去家族庇护"
}
],
"hiddenLines": {
"hiddenTruths": ["父亲没有死在事故当晚"],
"misdirectionHints": ["议会伪造的潮汐记录"],
"revealPacing": "先露出旧档错页,再揭开祭灯失败"
},
"iconicElements": {
"iconicMotifs": ["错位灯火", "会变字的海图"],
"institutionsOrArtifacts": ["灯塔署", "群岛议会"],
"hardRules": ["海雾中读错灯语会失去一段记忆"]
}
});
session.anchor_pack = json!({
"creatorIntentSummary": "不应该回退到这个五段摘要。"
});
let seed_text = build_foundation_generation_seed_text(&session);
for label in [
"世界承诺",
"玩家幻想",
"主题边界",
"玩家切入口",
"核心冲突",
"关键关系",
"暗线与揭示节奏",
"标志元素与硬规则",
] {
assert!(
seed_text.contains(label),
"seed text should include {label}"
);
}
assert!(seed_text.contains("返乡守灯人"));
assert!(seed_text.contains("父亲没有死在事故当晚"));
assert!(!seed_text.contains("不应该回退到这个五段摘要"));
assert!(!seed_text.contains("coreLoop"));
assert!(!seed_text.contains("mainConflict"));
}
#[test]
fn build_draft_foundation_action_payload_json_injects_generated_profile() {
let payload = ExecuteCustomWorldAgentActionRequest {
action: "draft_foundation".to_string(),
profile_id: Some("profile-1".to_string()),
draft_profile: Some(json!({ "name": "旧草稿" })),
legacy_result_profile: None,
setting_text: Some("旧设定".to_string()),
card_id: None,
sections: None,
profile: None,
count: None,
role_type: None,
prompt_text: Some("补充提示".to_string()),
anchor_card_ids: Some(vec!["card-1".to_string()]),
role_ids: None,
role_id: None,
portrait_path: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
scene_ids: None,
scene_id: None,
scene_kind: None,
image_src: None,
generated_scene_asset_id: None,
generated_scene_prompt: None,
generated_scene_model: None,
checkpoint_id: None,
};
let payload_json = build_draft_foundation_action_payload_json(
&payload,
r#"{"name":"新草稿","worldHook":"失灯海域会吞掉所有记错航线的人。"}"#,
)
.expect("payload json should build");
let payload_value =
serde_json::from_str::<JsonValue>(&payload_json).expect("payload json should parse");
assert_eq!(
payload_value.get("action"),
Some(&json!("draft_foundation"))
);
assert_eq!(payload_value.get("profileId"), Some(&json!("profile-1")));
assert_eq!(
payload_value
.get("draftProfile")
.and_then(|value| value.get("name")),
Some(&json!("新草稿"))
);
}
#[test]
fn foundation_search_fallback_handles_tool_unavailable_and_timeout() {
let tool_error = platform_llm::LlmError::Upstream {
status_code: 404,
message: "Your account has not activated web search. code=ToolNotOpen".to_string(),
};
let timeout_error = platform_llm::LlmError::Timeout { attempts: 2 };
assert!(should_retry_foundation_without_web_search(&tool_error));
assert!(should_retry_foundation_without_web_search(&timeout_error));
assert!(!should_retry_foundation_without_web_search(
&platform_llm::LlmError::EmptyResponse
));
}
#[tokio::test]
async fn foundation_json_stage_retries_without_web_search_when_tool_unavailable() {
let log_dir = std::env::temp_dir().join(format!(
"api-server-foundation-raw-log-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos()
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server_with_statuses(
request_capture.clone(),
vec![
MockHttpResponse {
status_code: 404,
body: r#"{"error":{"code":"ToolNotOpen","message":"Your account has not activated web search."}}"#.to_string(),
},
MockHttpResponse {
status_code: 200,
body: llm_response(r#"{"name":"无搜索底稿"}"#),
},
],
);
let llm_client = build_test_llm_client(server_url);
let parsed = request_foundation_json_stage(
&llm_client,
"请生成 JSON".to_string(),
"agent-foundation-test",
|_| "修复 JSON".to_string(),
"agent-foundation-test-json-repair",
"空响应",
true,
)
.await
.expect("web search fallback should succeed");
assert_eq!(parsed.get("name"), Some(&json!("无搜索底稿")));
let requests = request_capture
.lock()
.expect("request capture should lock")
.clone();
assert_eq!(requests.len(), 2);
assert!(requests[0].contains("\"tools\""));
assert!(requests[0].contains("\"web_search\""));
assert!(!requests[1].contains("\"tools\""));
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
if log_dir.exists() {
std::fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed");
}
}
#[tokio::test]
async fn role_outline_missing_asset_fields_are_filled_locally_before_details() {
let request_capture = Arc::new(Mutex::new(Vec::<String>::new()));
let entries = vec![json!({
"name": "海洋生物学家",
"title": "深海观察员",
"role": "调查者",
"description": "记录异常海沟的人",
"initialAffinity": 18,
"relationshipHooks": ["深海样本"],
"tags": ["科学家"]
})];
let repaired = ensure_role_outline_asset_fields("story", entries)
.expect("missing asset fields should be repaired");
let captured_requests = request_capture
.lock()
.expect("request capture should lock")
.clone();
assert_eq!(captured_requests.len(), 0);
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("visualDescription"))
.and_then(JsonValue::as_str),
Some("海洋生物学家身带科学家气质,服装和轮廓呼应“记录异常海沟的人”,有清晰识别点。")
);
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("actionDescription"))
.and_then(JsonValue::as_str),
Some("海洋生物学家以调查者身份行动,围绕“记录异常海沟的人”做出稳定而可识别的动作。")
);
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("sceneVisualDescription"))
.and_then(JsonValue::as_str),
Some("海洋生物学家常出现在与“记录异常海沟的人”相关的场景中,周围保留其身份线索。")
);
}
#[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()));
let server_url = spawn_mock_server(
request_capture.clone(),
vec![
llm_response(
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"slots":[{"name":"灯骨"},{"name":"潮步"},{"name":"灯识"},{"name":"雾魄"},{"name":"旧约"},{"name":"回澜"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
),
llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
),
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":["证人"]}]}"#,
),
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":["学徒"]}]}"#,
),
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":["巡海"]}]}"#,
),
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":["引路"]}]}"#,
),
llm_response(
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":"借灯火与海图周旋"}]}"#,
),
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":["线索"]}]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索"}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"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":"rare","description":"可调动巡海队。","tags":["权力"]}]}]}"#,
),
],
);
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("draft generation should succeed");
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
.expect("draft profile should parse");
let captured_requests = request_capture
.lock()
.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() >= 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_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"));
assert!(request_text.contains("actNPCNames"));
assert!(!request_text.contains("\"sceneNpcNames\""));
assert!(request_text.contains("connectedLandmarkNames"));
assert!(!request_text.contains("探索网络信息"));
assert!(request_text.contains("叙事档案"));
assert!(request_text.contains("养成档案"));
assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1"));
assert_eq!(
draft_profile
.get("playableNpcs")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("visualDescription"))
.and_then(JsonValue::as_str),
Some("灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。")
);
assert_eq!(
draft_profile
.get("storyNpcs")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("visualDescription"))
.and_then(JsonValue::as_str),
Some("深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。")
);
assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航")));
assert_eq!(
draft_profile
.get("attributeSchema")
.and_then(|schema| schema.get("slots"))
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(6)
);
assert_eq!(
draft_profile
.get("attributeSchema")
.and_then(|schema| schema.get("slots"))
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("name"))
.and_then(JsonValue::as_str),
Some("灯骨")
);
assert_eq!(
draft_profile
.get("attributeSchema")
.and_then(|schema| schema.get("slots"))
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(JsonValue::as_object)
.map(|entry| entry.contains_key("definition")),
Some(false)
);
assert!(
draft_profile
.get("worldHook")
.and_then(JsonValue::as_str)
.is_some()
);
assert!(
draft_profile
.get("playerPremise")
.and_then(JsonValue::as_str)
.is_some()
);
assert_eq!(
draft_profile
.get("camp")
.and_then(|entry| entry.get("name"))
.and_then(JsonValue::as_str),
Some("旧灯塔")
);
assert_eq!(
draft_profile
.get("camp")
.and_then(|entry| entry.get("id"))
.and_then(JsonValue::as_str),
Some("camp-1")
);
assert_eq!(
draft_profile
.get("camp")
.and_then(|entry| entry.get("sceneTaskDescription"))
.and_then(JsonValue::as_str),
Some("首次进入旧灯塔时,追查被篡改的灯火航线记录。")
);
assert_eq!(
draft_profile
.get("landmarks")
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(1)
);
assert_eq!(
draft_profile
.get("landmarks")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("name"))
.and_then(JsonValue::as_str),
Some("沉船湾")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("sceneId"))
.and_then(JsonValue::as_str),
Some("camp-1")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.map(Vec::len),
Some(3)
);
assert_eq!(
draft_profile
.get("landmarks")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("actNPCNames"))
.and_then(JsonValue::as_array)
.and_then(|items| items.first())
.and_then(JsonValue::as_str),
Some("船魂戊")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.get(1))
.and_then(|act| act.get("primaryNpcId"))
.and_then(JsonValue::as_str),
Some("story-npc-0192680e")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.first())
.and_then(|act| act.get("primaryNpcId"))
.and_then(JsonValue::as_str),
Some("story-npc-01b5406b")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.first())
.and_then(|act| act.get("encounterNpcIds"))
.and_then(JsonValue::as_array)
.and_then(|items| items.first())
.and_then(JsonValue::as_str),
Some("story-npc-01b5406b")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.first())
.and_then(|act| act.get("primaryRoleName"))
.and_then(JsonValue::as_str),
Some("灯童丁")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.get(1))
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.first())
.and_then(|act| act.get("primaryNpcId"))
.and_then(JsonValue::as_str),
Some("story-npc-01fc0701")
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.get(1))
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.and_then(|acts| acts.get(1))
.and_then(|act| act.get("primaryNpcId"))
.and_then(JsonValue::as_str),
Some("story-npc-01acae6c")
);
}
#[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!({
"name": "世界骨架占位归处",
"description": "只来自 framework 的轻量占位。"
});
let generated_scenes = vec![
json!({
"name": "旧灯塔",
"description": "雾中仍亮着错位灯火",
"sceneTaskDescription": "首次进入旧灯塔时,追查被篡改的灯火航线记录。",
"actBackgroundPromptTexts": ["", "", ""],
"actEventDescriptions": ["", "", ""],
}),
json!({
"name": "沉船湾",
"description": "退潮后露出旧船骨"
}),
];
let (camp, landmarks) =
split_generated_scenes_into_camp_and_landmarks(fallback_camp, generated_scenes);
assert_eq!(camp.get("id"), Some(&json!("camp-1")));
assert_eq!(camp.get("kind"), Some(&json!("camp")));
assert_eq!(camp.get("name"), Some(&json!("旧灯塔")));
assert_eq!(
camp.get("sceneTaskDescription"),
Some(&json!("首次进入旧灯塔时,追查被篡改的灯火航线记录。"))
);
assert_eq!(landmarks.len(), 1);
assert_eq!(landmarks[0].get("name"), Some(&json!("沉船湾")));
}
fn llm_response(content: &str) -> String {
json!({
"id": "resp_01",
"model": CREATION_TEMPLATE_LLM_MODEL,
"output_text": content,
"status": "completed"
})
.to_string()
}
fn build_test_session() -> CustomWorldAgentSessionRecord {
CustomWorldAgentSessionRecord {
session_id: "custom-world-agent-session-1".to_string(),
seed_text: "海雾会吞掉记错航线的人。".to_string(),
current_turn: 2,
anchor_content: json!({
"worldPromise": {
"hook": "在失真的海图上追查一场被篡改的沉船事故。"
},
"playerEntryPoint": {
"openingIdentity": "被停职返乡的守灯人",
"openingProblem": "灯塔记录被人改写",
"entryMotivation": "查清父亲沉船真相"
}
}),
progress_percent: 100,
last_assistant_reply: Some("世界锚点已经基本齐全,可以整理第一版底稿。".to_string()),
stage: "foundation_review".to_string(),
focus_card_id: None,
creator_intent: json!({
"theme": "悬疑航海",
"playerPremise": "玩家是返乡调查旧案的守灯人。"
}),
creator_intent_readiness: json!({
"isReady": true
}),
anchor_pack: json!({
"coreConflict": "群岛议会正在掩盖沉船真相。"
}),
lock_state: json!({}),
draft_profile: JsonValue::Null,
messages: Vec::new(),
draft_cards: Vec::new(),
pending_clarifications: Vec::new(),
suggested_actions: Vec::new(),
recommended_replies: Vec::new(),
quality_findings: Vec::new(),
asset_coverage: json!({}),
checkpoints: Vec::new(),
supported_actions: Vec::new(),
publish_gate: None,
result_preview: None,
updated_at: "2026-04-23T00:00:00Z".to_string(),
}
}
fn build_test_llm_client(base_url: String) -> LlmClient {
let config = LlmConfig::new(
LlmProvider::Ark,
base_url,
"test-key".to_string(),
"test-model".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
0,
1,
)
.expect("llm config should build");
LlmClient::new(config).expect("llm client should build")
}
fn spawn_mock_server(
request_capture: Arc<Mutex<Vec<String>>>,
response_bodies: Vec<String>,
) -> String {
spawn_mock_server_with_statuses(
request_capture,
response_bodies
.into_iter()
.map(|body| MockHttpResponse {
status_code: 200,
body,
})
.collect(),
)
}
struct MockHttpResponse {
status_code: u16,
body: String,
}
fn spawn_mock_server_with_statuses(
request_capture: Arc<Mutex<Vec<String>>>,
responses: Vec<MockHttpResponse>,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener
.local_addr()
.expect("listener should expose address");
thread::spawn(move || {
let mut response_queue = VecDeque::from(responses);
for _ in 0..32 {
let response = response_queue.pop_front().unwrap_or_else(|| {
MockHttpResponse {
status_code: 200,
body: llm_response(r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索","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":"rare","description":"可调动巡海队。","tags":["权力"]}]}]}"#),
}
});
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
request_capture
.lock()
.expect("request capture should lock")
.push(request_text);
write_response(&mut stream, response);
}
});
format!("http://{address}")
}
fn read_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(StdDuration::from_secs(1)))
.expect("read timeout should be configured");
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut expected_total = None;
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(bytes_read) => {
buffer.extend_from_slice(&chunk[..bytes_read]);
if expected_total.is_none()
&& let Some(header_end) = find_header_end(&buffer)
{
let content_length =
read_content_length(&buffer[..header_end]).unwrap_or(0);
expected_total = Some(header_end + content_length);
}
if let Some(total_bytes) = expected_total
&& buffer.len() >= total_bytes
{
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
String::from_utf8(buffer).expect("request should be utf-8")
}
fn write_response(stream: &mut std::net::TcpStream, response: MockHttpResponse) {
let status_text = if response.status_code == 200 {
"OK"
} else {
"ERROR"
};
let raw_response = format!(
"HTTP/1.1 {} {}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.status_code,
status_text,
response.body.len(),
response.body
);
stream
.write_all(raw_response.as_bytes())
.expect("mock response should be written");
stream.flush().expect("mock response should flush");
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
}
fn read_content_length(headers: &[u8]) -> Option<usize> {
let text = String::from_utf8_lossy(headers);
text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
return value.trim().parse::<usize>().ok();
}
None
})
}
}