2902 lines
111 KiB
Rust
2902 lines
111 KiB
Rust
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;
|
||
|
||
#[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,
|
||
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",
|
||
"世界框架阶段没有返回有效内容。",
|
||
)
|
||
.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),
|
||
&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),
|
||
&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),
|
||
&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),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
let playable_detailed = expand_foundation_role_entries(
|
||
llm_client,
|
||
&framework,
|
||
"playable",
|
||
&playable_narrative,
|
||
"dossier",
|
||
(76, 84),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
let story_narrative = expand_foundation_role_entries(
|
||
llm_client,
|
||
&framework,
|
||
"story",
|
||
&story_outlines,
|
||
"narrative",
|
||
(84, 92),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
let story_detailed = expand_foundation_role_entries(
|
||
llm_client,
|
||
&framework,
|
||
"story",
|
||
&story_narrative,
|
||
"dossier",
|
||
(92, 96),
|
||
&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;
|
||
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 2;
|
||
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,
|
||
) -> Result<JsonValue, String>
|
||
where
|
||
F: Fn(&str) -> String,
|
||
{
|
||
let response = llm_client
|
||
.request_text(LlmTextRequest::new(vec![
|
||
LlmMessage::system(FOUNDATION_JSON_ONLY_SYSTEM_PROMPT),
|
||
LlmMessage::user(user_prompt),
|
||
]))
|
||
.await
|
||
.map_err(|error| format!("{debug_label} LLM 请求失败:{error}"))?;
|
||
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)),
|
||
]))
|
||
.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 generate_foundation_role_outline_entries(
|
||
llm_client: &LlmClient,
|
||
framework: &JsonValue,
|
||
role_type: &str,
|
||
total_count: usize,
|
||
progress_range: (u32, u32),
|
||
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(),
|
||
"角色框架名单阶段没有返回有效内容。",
|
||
)
|
||
.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),
|
||
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(),
|
||
"地点框架名单阶段没有返回有效内容。",
|
||
)
|
||
.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),
|
||
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 = 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(),
|
||
"角色档案补全阶段没有返回有效内容。",
|
||
)
|
||
.await?;
|
||
merged_entries.extend(array_field(&raw, role_key(role_type)));
|
||
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 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,
|
||
};
|
||
|
||
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("seedText:custom-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!("新草稿"))
|
||
);
|
||
}
|
||
|
||
#[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("海洋生物学家常出现在与“记录异常海沟的人”相关的场景中,周围保留其身份线索。")
|
||
);
|
||
}
|
||
|
||
#[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":"灯火按被篡改的航线闪烁。"},{"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, |_| {})
|
||
.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");
|
||
|
||
assert!(captured_requests.len() >= 17);
|
||
assert!(request_text.contains("在失真的海图上追查一场被篡改的沉船事故。"));
|
||
assert!(request_text.contains("世界核心骨架"));
|
||
assert!(request_text.contains("attributeSchema"));
|
||
assert!(request_text.contains("可扮演角色框架名单"));
|
||
assert!(request_text.contains("场景角色框架名单"));
|
||
assert!(request_text.contains("场景框架名单"));
|
||
assert!(request_text.contains("第一条场景必须是玩家进入世界时所在的开局场景"));
|
||
assert!(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")
|
||
);
|
||
}
|
||
|
||
#[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",
|
||
"choices": [
|
||
{
|
||
"message": {
|
||
"content": content,
|
||
}
|
||
}
|
||
]
|
||
})
|
||
.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 {
|
||
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(response_bodies);
|
||
for _ in 0..32 {
|
||
let response_body = response_queue.pop_front().unwrap_or_else(|| {
|
||
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_body);
|
||
}
|
||
});
|
||
|
||
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, body: String) {
|
||
let raw_response = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
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
|
||
})
|
||
}
|
||
}
|