Files
Genarrative/server-rs/crates/api-server/src/custom_world_foundation_draft.rs
2026-04-24 17:59:48 +08:00

1997 lines
84 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use 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 landmark_seeds = generate_foundation_landmark_seed_entries(
llm_client,
&framework,
FOUNDATION_DRAFT_LANDMARK_COUNT,
(44, 56),
&mut on_progress,
)
.await?;
framework["landmarks"] = JsonValue::Array(landmark_seeds.clone());
let landmarks = expand_foundation_landmark_network_entries(
llm_client,
&framework,
&story_outlines,
&landmark_seeds,
(56, 66),
&mut on_progress,
)
.await?;
framework["landmarks"] = JsonValue::Array(landmarks.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,
landmarks,
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 CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES: [i64; 4] = [15, 30, 60, 90];
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);
merged_entries.extend(array_field(&raw, key).into_iter().take(batch_count));
}
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);
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),
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,
)
},
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)
}
async fn expand_foundation_landmark_network_entries(
llm_client: &LlmClient,
framework: &JsonValue,
story_npcs: &[JsonValue],
base_entries: &[JsonValue],
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_LANDMARK_BATCH_SIZE)
.collect();
let mut processed_count = 0usize;
for (batch_index, batch) in batches.iter().enumerate() {
emit_foundation_draft_progress(
on_progress,
"建立场景连接",
format!(
"正在补全场景连接第 {} / {} 批,当前已完成 {}/{}",
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_landmark_network_batch_prompt(framework, story_npcs, batch),
format!(
"agent-foundation-landmark-network-batch-{}",
batch_index + 1
)
.as_str(),
|response_text| {
build_custom_world_landmark_network_batch_json_repair_prompt(
response_text,
&names_from_entries(batch),
)
},
format!(
"agent-foundation-landmark-network-batch-{}-json-repair",
batch_index + 1
)
.as_str(),
"地点网络补全阶段没有返回有效内容。",
)
.await?;
merged_entries.extend(array_field(&raw, "landmarks"));
processed_count = processed_count
.saturating_add(batch.len())
.min(base_entries.len());
}
emit_foundation_draft_progress(
on_progress,
"建立场景连接",
"关键场景的角色分布与路径连接已经整理完成。",
progress_range.1,
);
Ok(merge_entries_by_name(base_entries, &merged_entries))
}
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 in [
"worldPromise",
"playerEntryPoint",
"coreLoop",
"mainConflict",
"keyCharacters",
"keyPlaces",
"toneAndStyle",
"firstScene",
] {
if let Some(value) = anchor_content.get(key)
&& !value.is_null()
{
sections.push(format!("{key}{}", compact_json_text(value)));
}
}
sections.join("\n")
}
fn build_custom_world_framework_prompt(setting_text: &str) -> String {
[
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物和地图细节。".to_string(),
"玩家设定:".to_string(),
setting_text.trim().to_string(),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"name\": \"世界名称\",".to_string(),
" \"subtitle\": \"世界副标题\",".to_string(),
" \"summary\": \"世界概述\",".to_string(),
" \"tone\": \"世界基调\",".to_string(),
" \"playerGoal\": \"玩家核心目标\",".to_string(),
" \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(),
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
" \"camp\": {".to_string(),
" \"name\": \"开局归处名称\",".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
" }".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
"- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].join("\n")
}
fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> String {
[
"下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。",
"请只输出修复后的 JSON 对象。",
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。",
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"camp 必须是对象且包含name、description、dangerLevel。",
"原始文本:",
response_text.trim(),
].join("\n")
}
fn build_custom_world_role_outline_batch_prompt(
framework: &JsonValue,
role_type: &str,
batch_count: usize,
forbidden_names: &[String],
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
[
format!("请根据下面的世界核心信息,生成一批{label}框架名单。"),
"后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if forbidden_names.is_empty() { "".to_string() } else { format!("这些名字已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
format!(" \"{key}\": ["),
" {".to_string(),
" \"name\": \"角色名称\",".to_string(),
" \"title\": \"称号\",".to_string(),
" \"role\": \"身份\",".to_string(),
" \"description\": \"极简定位描述\",".to_string(),
" \"visualDescription\": \"默认角色形象描述\",".to_string(),
" \"actionDescription\": \"默认角色动作描述\",".to_string(),
" \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(),
" \"initialAffinity\": 18,".to_string(),
" \"relationshipHooks\": [\"一个关系切入口\"],".to_string(),
" \"tags\": [\"标签1\", \"标签2\"]".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
format!("- 必须生成恰好 {batch_count}{label}"),
"- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(),
"- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(),
"- 只保留name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(),
"- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(),
"- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(),
"- relationshipHooks 最多 1 条tags 保持 1 到 2 个。".to_string(),
"- description 控制在 8 到 18 个汉字内title 和 role 也尽量短。".to_string(),
"- initialAffinity 必须是 -40 到 90 的整数。".to_string(),
if role_type == "playable" { "- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。".to_string() } else { "- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。".to_string() },
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_role_outline_batch_json_repair_prompt(
response_text: &str,
role_type: &str,
expected_count: usize,
forbidden_names: &[String],
) -> String {
let key = role_key(role_type);
[
format!("下面这段文本本应是自定义世界{}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("必须保留恰好 {expected_count} 个角色对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"如果缺少字段字符串补空字符串relationshipHooks 和 tags 补空数组initialAffinity 补默认整数。".to_string(),
"不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_landmark_seed_batch_prompt(
framework: &JsonValue,
batch_count: usize,
forbidden_names: &[String],
) -> String {
[
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架与默认生图描述。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"landmarks\": [".to_string(),
" {".to_string(),
" \"name\": \"场景名称\",".to_string(),
" \"description\": \"场景极简描述\",".to_string(),
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留name、description、visualDescription、dangerLevel。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- description 控制在 12 到 24 个汉字内。".to_string(),
"- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_landmark_seed_batch_json_repair_prompt(
response_text: &str,
expected_count: usize,
forbidden_names: &[String],
) -> String {
[
"下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"请只输出修复后的 JSON 对象。".to_string(),
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("必须保留恰好 {expected_count} 个地点对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个地点只包含name、description、visualDescription、dangerLevel。".to_string(),
"如果缺少字段字符串补空字符串dangerLevel 补 medium。".to_string(),
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_landmark_network_batch_prompt(
framework: &JsonValue,
story_npcs: &[JsonValue],
landmark_batch: &[JsonValue],
) -> String {
[
"请补全下面这一批关键场景的探索网络信息。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 10),
"可用场景角色名单:".to_string(),
names_from_entries(story_npcs).join(""),
"本批场景:".to_string(),
compact_json_text(&JsonValue::Array(landmark_batch.to_vec())),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"landmarks\": [".to_string(),
" {".to_string(),
" \"name\": \"场景名称\",".to_string(),
" \"description\": \"场景描述\",".to_string(),
" \"dangerLevel\": \"low|medium|high|extreme\",".to_string(),
" \"sceneNpcNames\": [\"会在这里出现的角色名\"],".to_string(),
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 必须只补全本批场景name 必须与本批场景完全一致,不得增删改名。".to_string(),
"- sceneNpcNames 只能引用上方可用场景角色名单中的名字,每个地点 1 到 3 个。".to_string(),
"- connectedLandmarkNames 优先引用已知关键场景名称,每个地点 1 到 3 个。".to_string(),
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_landmark_network_batch_json_repair_prompt(
response_text: &str,
expected_names: &[String],
) -> String {
[
"下面这段文本本应是自定义世界关键场景探索网络补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"请只输出修复后的 JSON 对象。".to_string(),
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("这个数组里只能保留这些地点名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个地点都必须包含name、description、dangerLevel、sceneNpcNames、connectedLandmarkNames、entryHook。".to_string(),
"如果缺少字段字符串补空字符串数组补空数组dangerLevel 补 medium。".to_string(),
"不要新增名单外的地点。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n")
}
fn build_custom_world_role_batch_prompt(
framework: &JsonValue,
role_type: &str,
role_batch: &[JsonValue],
stage: &str,
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
let stage_label = if stage == "narrative" {
"叙事档案"
} else {
"养成档案"
};
let required_fields = if stage == "narrative" {
"name、backstory、personality、motivation、combatStyle"
} else {
"name、backstoryReveal、skills、initialItems"
};
let template_extra = if stage == "narrative" {
[
" \"backstory\": \"公开背景\",",
" \"personality\": \"性格关键词\",",
" \"motivation\": \"当前动机\",",
" \"combatStyle\": \"行动或战斗风格\"",
]
.join("\n")
} else {
[
" \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },",
" \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],",
" \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]",
].join("\n")
};
[
format!("请为下面这一批{label}补全{stage_label}"),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 10),
"本批角色:".to_string(),
build_role_outline_prompt_text(role_batch, framework, role_type),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
format!(" \"{key}\": ["),
" {".to_string(),
" \"name\": \"角色名称\",".to_string(),
template_extra,
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 必须只补全本批角色name 必须与本批角色完全一致,不得增删改名。".to_string(),
format!("- 每个角色必须包含:{required_fields}"),
if stage == "narrative" { "- backstory 控制在 32 到 80 个汉字内personality、motivation、combatStyle 都要短而具体。".to_string() } else { format!("- backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 {}", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("")) },
if stage == "narrative" { "- 不要输出 backstoryReveal、skills、initialItems。".to_string() } else { "- skills 默认 3 个initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。".to_string() },
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_role_batch_json_repair_prompt(
response_text: &str,
role_type: &str,
stage: &str,
expected_names: &[String],
) -> String {
let key = role_key(role_type);
if stage == "narrative" {
return [
format!("下面这段文本本应是自定义世界{}叙事档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("这个数组里只能保留这些角色名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个角色都必须包含name、backstory、personality、motivation、combatStyle。".to_string(),
"如果缺少字段:字符串补空字符串。".to_string(),
"不要输出 backstoryReveal、skills、initialItems也不要新增名单外的角色。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n");
}
[
format!("下面这段文本本应是自定义世界{}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("这个数组里只能保留这些角色名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个角色都必须包含name、backstoryReveal、skills、initialItems。".to_string(),
format!("backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 {}", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("")),
"skills 默认补成 3 个对象,每个对象包含 name、summary、styleinitialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。".to_string(),
"不要输出 backstory、personality、motivation、combatStyle、landmarks也不要新增名单外的角色。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n")
}
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>,
landmarks: 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("camp".to_string(), framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" })));
object.insert(
"playableNpcs".to_string(),
JsonValue::Array(playable_detailed),
);
object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed));
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
normalize_foundation_draft_profile(JsonValue::Object(object), session)
}
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()));
}
if !object.get("camp").is_some_and(JsonValue::is_object) {
object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" }));
}
}
fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String {
let landmark_text = array_field(framework, "landmarks")
.into_iter()
.take(max_landmarks)
.map(|landmark| {
format!(
"{}{}{}",
json_text(&landmark, "name").unwrap_or_default(),
json_text(&landmark, "dangerLevel").unwrap_or_default(),
json_text(&landmark, "description").unwrap_or_default()
)
})
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("");
[
format!("世界:{}", json_text(framework, "name").unwrap_or_default()),
format!(
"副标题:{}",
json_text(framework, "subtitle").unwrap_or_default()
),
format!(
"世界概述:{}",
json_text(framework, "summary").unwrap_or_default()
),
format!(
"世界基调:{}",
json_text(framework, "tone").unwrap_or_default()
),
format!(
"玩家核心目标:{}",
json_text(framework, "playerGoal").unwrap_or_default()
),
json_string_array(framework, "majorFactions")
.map(|items| format!("主要势力:{}", items.join("")))
.unwrap_or_default(),
json_string_array(framework, "coreConflicts")
.map(|items| format!("核心冲突:{}", items.join("")))
.unwrap_or_default(),
format!(
"开局归处:{}{}",
json_path_text(framework, &["camp", "name"]).unwrap_or_default(),
json_path_text(framework, &["camp", "description"]).unwrap_or_default()
),
if landmark_text.is_empty() {
String::new()
} else {
format!("关键场景:{landmark_text}")
},
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn build_role_outline_prompt_text(
role_batch: &[JsonValue],
framework: &JsonValue,
role_type: &str,
) -> String {
role_batch
.iter()
.map(|role| {
let appearance_text = if role_type == "story" {
landmark_names_for_role(
framework,
json_text(role, "name").unwrap_or_default().as_str(),
)
.join("")
} else {
String::new()
};
[
format!(
"- {} / {}",
json_text(role, "name").unwrap_or_default(),
json_text(role, "title").unwrap_or_default()
),
format!("身份:{}", json_text(role, "role").unwrap_or_default()),
format!(
"框架描述:{}",
json_text(role, "description").unwrap_or_default()
),
format!(
"预设好感:{}",
role.get("initialAffinity")
.and_then(JsonValue::as_i64)
.unwrap_or(0)
),
json_string_array(role, "relationshipHooks")
.map(|items| format!("关系切入口:{}", items.join("")))
.unwrap_or_default(),
json_string_array(role, "tags")
.map(|items| format!("标签:{}", items.join("")))
.unwrap_or_default(),
if appearance_text.is_empty() {
String::new()
} else {
format!("出现场景:{appearance_text}")
},
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
})
.collect::<Vec<_>>()
.join("\n")
}
fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec<String> {
array_field(framework, "landmarks")
.into_iter()
.filter_map(|landmark| {
let names = json_string_array(&landmark, "sceneNpcNames").unwrap_or_default();
if names.iter().any(|name| name == role_name) {
json_text(&landmark, "name")
} else {
None
}
})
.collect()
}
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())
.unwrap_or("第一幕");
object.insert("title".to_string(), JsonValue::String(title.to_string()));
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 fallback_act = build_fallback_scene_act_with_index(index);
let fallback_prompt = fallback_act
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.unwrap_or("当前幕场景背景,突出可探索空间、站位地面和局势氛围。")
.to_string();
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 background_prompt = object
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{title}{summary}{fallback_prompt}"));
object.insert(
"backgroundPromptText".to_string(),
JsonValue::String(background_prompt),
);
JsonValue::Object(object)
}
fn build_fallback_scene_chapter_blueprint() -> JsonValue {
json!({
"id": "chapter-act-1",
"title": "第一幕",
"summary": "第一幕用于让玩家进入当前世界的主线矛盾,并看见最初的风险与方向。",
"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 {
json!({
"id": format!("scene-act-{}", index + 1),
"title": if index == 0 { "开场场景幕".to_string() } else { format!("{}", index + 1) },
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"backgroundPromptText": "第一幕场景背景,突出玩家初入现场时的空间轮廓、可站立地面、远近景层次和第一波威胁氛围。",
})
}
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_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_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_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)
}
fn to_pretty_json(value: &JsonValue) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
}
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 foundation_prompt_uses_real_seed_text() {
let session = build_test_session();
let prompt = build_foundation_draft_user_prompt(&session);
assert!(prompt.contains("seedText海雾会吞掉记错航线的人。"));
assert!(!prompt.contains("seedTextcustom-world-agent-session-1"));
}
#[test]
fn 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 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":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。","dangerLevel":"low"}}"#,
),
llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"船魂戊","title":"沉船残魂","role":"异类","description":"困在潮声里","initialAffinity":-20,"relationshipHooks":["沉船真相"],"tags":["异类"]},{"name":"巡海己","title":"巡海队长","role":"追捕者","description":"封锁海岸线","initialAffinity":-15,"relationshipHooks":["封锁令"],"tags":["巡海"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","dangerLevel":"medium"},{"name":"沉船湾","description":"退潮后露出旧船骨","dangerLevel":"high"}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","dangerLevel":"medium","sceneNpcNames":["灯童丁","档吏庚"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","dangerLevel":"high","sceneNpcNames":["船魂戊","潮医乙"],"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() >= 18);
assert!(request_text.contains("在失真的海图上追查一场被篡改的沉船事故。"));
assert!(request_text.contains("世界核心骨架"));
assert!(request_text.contains("可扮演角色框架名单"));
assert!(request_text.contains("场景角色框架名单"));
assert!(request_text.contains("关键场景框架名单"));
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("name"), Some(&json!("雾港归航")));
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("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.map(|entries| !entries.is_empty()),
Some(true)
);
}
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
})
}
}