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 { 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( llm_client: &LlmClient, user_prompt: String, debug_label: &str, repair_prompt_builder: F, repair_debug_label: &str, empty_response_message: &str, ) -> Result 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, 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 = 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, 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 = 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, ) -> Result, 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::>(); 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, 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::>(); 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, 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 { 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::(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::>() .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, story_detailed: Vec, generated_scene_entries: Vec, session: &CustomWorldAgentSessionRecord, setting_text: &str, ) -> JsonMap { 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::(); 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 { let mut terms = Vec::new(); let chinese_chars = source .chars() .filter(|character| ('\u{4e00}'..='\u{9fff}').contains(character)) .collect::>(); for size in [2usize, 1usize] { if chinese_chars.len() < size { continue; } for window in chinese_chars.windows(size) { let term = window.iter().collect::(); 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, key: &str) -> Option { 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 { 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, Vec) { 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::>(); (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 { 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 { // 幕背景描述必须来自关键场景生成步骤,不能在草稿合成阶段再用规则句拼接。 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::>(), ); 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::>(), }) } 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 { 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, id_prefix: &str) -> Vec { 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 { 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 { let mut resolved = requested_role_names .iter() .filter_map(|name| resolve_scene_role_ref(name, scene_role_refs)) .collect::>(); 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 { 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) -> Vec { 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 { 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 { value .get(key) .and_then(JsonValue::as_array) .cloned() .unwrap_or_default() } fn names_from_entries(entries: &[JsonValue]) -> Vec { 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 { 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 { json_path_text(value, &[key]) } fn json_path_text(value: &JsonValue, path: &[&str]) -> Option { 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> { 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::>(); 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 { 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, 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, 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::>() }) .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, 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) { 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::>(); 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::>() }) .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, 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, 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, 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::>() .join(";") }) }) .filter(|value| !value.trim().is_empty()) }) .unwrap_or_else(|| { "玩家会以一名已经卷入当前局势的人物进入世界,并被迫尽快确认自己的立场与行动方向。" .to_string() }) } fn read_text_field(object: &JsonMap, keys: &[&str]) -> Option { 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 { let trimmed = text.trim(); if let Some(start) = trimmed.find('{') && let Some(end) = trimmed.rfind('}') && end > start { return serde_json::from_str::(&trimmed[start..=end]); } serde_json::from_str::(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::(&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::::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::(&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>>, response_bodies: Vec, ) -> 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 { buffer .windows(4) .position(|window| window == b"\r\n\r\n") .map(|index| index + 4) } fn read_content_length(headers: &[u8]) -> Option { 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::().ok(); } None }) } }