This commit is contained in:
2026-04-26 16:50:53 +08:00
parent ea33413187
commit 705a2d3dd8
30 changed files with 1537 additions and 570 deletions

View File

@@ -1,11 +1,8 @@
use crate::prompt::foundation_draft::{
build_custom_world_framework_json_repair_prompt, build_custom_world_framework_prompt,
build_custom_world_landmark_network_batch_json_repair_prompt,
build_custom_world_landmark_network_batch_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_asset_fields_repair_prompt,
build_custom_world_role_outline_batch_json_repair_prompt,
build_custom_world_role_outline_batch_prompt,
};
@@ -78,22 +75,11 @@ pub async fn generate_custom_world_foundation_draft(
.await?;
framework["storyNpcs"] = JsonValue::Array(story_outlines.clone());
let landmark_seeds = generate_foundation_landmark_seed_entries(
let landmarks = 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),
(44, 66),
&mut on_progress,
)
.await?;
@@ -169,6 +155,12 @@ const FOUNDATION_DRAFT_LANDMARK_COUNT: usize = 2;
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE: usize = 2;
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 2;
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE: usize = 2;
const WORLD_ATTRIBUTE_SLOT_IDS: [&str; 6] =
["axis_a", "axis_b", "axis_c", "axis_d", "axis_e", "axis_f"];
const BANNED_ATTRIBUTE_NAMES: [&str; 13] = [
"生命", "法力", "护甲", "攻击", "防御", "力量", "敏捷", "智力", "精神", "战士", "法师", "刺客",
"魔道",
];
async fn request_foundation_json_stage<F>(
llm_client: &LlmClient,
@@ -279,8 +271,7 @@ async fn generate_foundation_role_outline_entries(
.into_iter()
.take(batch_count)
.collect();
let repaired_entries =
ensure_role_outline_asset_fields(llm_client, role_type, raw_entries).await?;
let repaired_entries = ensure_role_outline_asset_fields(role_type, raw_entries)?;
merged_entries.extend(repaired_entries);
}
let merged_entries: Vec<JsonValue> = merged_entries.into_iter().take(total_count).collect();
@@ -357,45 +348,75 @@ async fn generate_foundation_landmark_seed_entries(
Ok(merged_entries)
}
async fn ensure_role_outline_asset_fields(
llm_client: &LlmClient,
fn ensure_role_outline_asset_fields(
role_type: &str,
entries: Vec<JsonValue>,
) -> Result<Vec<JsonValue>, String> {
let missing_report = role_asset_field_missing_report(&entries);
if missing_report.is_empty() {
return Ok(entries);
}
let key = role_key(role_type);
// 中文注释:角色默认资产字段必须随角色 outline 同一次模型调用产出;模型漏字段时只做本地兜底,不再额外发起修复模型调用。
let expected_names = names_from_entries(&entries);
let repaired = request_foundation_json_stage(
llm_client,
build_custom_world_role_outline_asset_fields_repair_prompt(
role_type,
&entries,
missing_report.as_str(),
),
format!("agent-foundation-{role_type}-outline-asset-fields-repair").as_str(),
|response_text| {
build_custom_world_role_outline_batch_json_repair_prompt(
response_text,
role_type,
entries.len(),
&[],
)
},
format!("agent-foundation-{role_type}-outline-asset-fields-json-repair").as_str(),
"角色形象设定文本修复阶段没有返回有效内容。",
)
.await?;
let repaired_entries = array_field(&repaired, key)
let repaired_entries = entries
.into_iter()
.take(entries.len())
.map(|entry| fill_missing_role_outline_asset_fields(entry, role_type))
.collect::<Vec<_>>();
let merged_entries = merge_entries_by_name(&entries, &repaired_entries);
validate_role_outline_asset_fields(&merged_entries, &expected_names)?;
Ok(merged_entries)
validate_role_outline_asset_fields(&repaired_entries, &expected_names)?;
Ok(repaired_entries)
}
fn fill_missing_role_outline_asset_fields(mut entry: JsonValue, role_type: &str) -> JsonValue {
if !entry.is_object() {
entry = json!({});
}
let name = json_text(&entry, "name").unwrap_or_else(|| "未命名角色".to_string());
let title = json_text(&entry, "title").unwrap_or_default();
let role = json_text(&entry, "role").unwrap_or_else(|| {
if role_type == "playable" {
"可扮演角色".to_string()
} else {
"场景角色".to_string()
}
});
let description = json_text(&entry, "description").unwrap_or_else(|| role.clone());
let tags = json_string_array(&entry, "tags").unwrap_or_default();
let tag_text = tags.first().cloned().unwrap_or_else(|| role.clone());
let Some(object) = entry.as_object_mut() else {
return entry;
};
insert_text_if_missing(
object,
"visualDescription",
format!("{name}身带{tag_text}气质,服装和轮廓呼应“{description}”,有清晰识别点。").as_str(),
);
insert_text_if_missing(
object,
"actionDescription",
format!("{name}{role}身份行动,围绕“{description}”做出稳定而可识别的动作。").as_str(),
);
insert_text_if_missing(
object,
"sceneVisualDescription",
format!("{name}常出现在与“{description}”相关的场景中,周围保留其身份线索。").as_str(),
);
if !object
.get("title")
.and_then(JsonValue::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
object.insert("title".to_string(), JsonValue::String(title));
}
entry
}
fn insert_text_if_missing(object: &mut JsonMap<String, JsonValue>, key: &str, fallback: &str) {
if object
.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return;
}
object.insert(key.to_string(), JsonValue::String(fallback.to_string()));
}
fn validate_role_outline_asset_fields(
@@ -414,7 +435,7 @@ fn validate_role_outline_asset_fields(
.any(|entry| json_text(entry, "name").as_deref() == Some(expected_name.as_str()))
{
return Err(format!(
"角色形象设定文本修复后缺少原角色「{expected_name}」。请重新生成底稿。"
"角色形象设定文本补齐后缺少原角色「{expected_name}」。请重新生成底稿。"
));
}
}
@@ -440,69 +461,6 @@ fn role_asset_field_missing_report(entries: &[JsonValue]) -> String {
missing_items.join("")
}
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,
@@ -827,6 +785,14 @@ fn build_foundation_draft_profile_from_framework(
)])
}),
);
object.insert(
"attributeSchema".to_string(),
normalize_world_attribute_schema(
framework.get("attributeSchema"),
&framework,
setting_text,
),
);
let camp = framework.get("camp").cloned().unwrap_or_else(
|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }),
);
@@ -847,6 +813,288 @@ fn build_foundation_draft_profile_from_framework(
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,
"definition": json_map_text(raw_slot, "definition")
.or_else(|| json_map_text(&fallback_slot, "definition"))
.unwrap_or_else(|| "这个维度用于描述角色在当前世界中的关键表现。".to_string()),
"positiveSignals": json_map_string_array(raw_slot, "positiveSignals")
.or_else(|| json_map_string_array(&fallback_slot, "positiveSignals"))
.unwrap_or_else(|| vec!["稳定".to_string(), "主动".to_string()]),
"negativeSignals": json_map_string_array(raw_slot, "negativeSignals")
.or_else(|| json_map_string_array(&fallback_slot, "negativeSignals"))
.unwrap_or_else(|| vec!["失衡".to_string(), "被动".to_string()]),
"combatUseText": json_map_text(raw_slot, "combatUseText")
.or_else(|| json_map_text(&fallback_slot, "combatUseText"))
.unwrap_or_else(|| "影响战斗中的推进、承压与应对。".to_string()),
"socialUseText": json_map_text(raw_slot, "socialUseText")
.or_else(|| json_map_text(&fallback_slot, "socialUseText"))
.unwrap_or_else(|| "影响对话中的判断、牵引与立场。".to_string()),
"explorationUseText": json_map_text(raw_slot, "explorationUseText")
.or_else(|| json_map_text(&fallback_slot, "explorationUseText"))
.unwrap_or_else(|| "影响探索中的观察、穿行与续航。".to_string()),
}));
}
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),
"schemaName": json_map_text(schema, "schemaName")
.filter(|value| !is_invalid_attribute_schema_name(value))
.unwrap_or_else(|| build_attribute_schema_name(framework, setting_text)),
"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,
"schemaName": build_attribute_schema_name(framework, setting_text),
"generatedFrom": {
"worldType": "CUSTOM",
"worldName": world_name,
"settingSummary": summary,
"tone": tone,
"conflictCore": conflict_core,
},
"slots": [
build_attribute_slot("axis_a", format!("{prefix}"), format!("承受{prefix}压、正面冲击与长期消耗的底子。"), ["承压", "稳阵"], ["虚浮", "易散"], "顶住正面压力并守住行动空间。", "在强压场面里保持可信和稳固。", "穿过危险环境时维持身体与装备状态。"),
build_attribute_slot("axis_b", format!("{prefix_alt}"), 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,
definition: impl Into<String>,
positive_signals: [&str; 2],
negative_signals: [&str; 2],
combat_use_text: &str,
social_use_text: &str,
exploration_use_text: &str,
) -> JsonValue {
json!({
"slotId": slot_id,
"name": name,
"definition": definition.into(),
"positiveSignals": positive_signals,
"negativeSignals": negative_signals,
"combatUseText": combat_use_text,
"socialUseText": social_use_text,
"explorationUseText": exploration_use_text,
})
}
fn framework_world_name(framework: &JsonValue, setting_text: &str) -> String {
json_text(framework, "name").unwrap_or_else(|| {
let fallback = setting_text
.chars()
.filter(|character| !character.is_whitespace())
.take(8)
.collect::<String>();
if fallback.trim().is_empty() {
"自定义世界".to_string()
} else {
fallback
}
})
}
fn build_attribute_schema_id(framework: &JsonValue, setting_text: &str) -> String {
format!(
"schema:rpg-agent:{}:v1",
stable_ascii_slug(framework_world_name(framework, setting_text).as_str())
)
}
fn build_attribute_schema_name(framework: &JsonValue, setting_text: &str) -> String {
let source = [
framework_world_name(framework, setting_text),
json_text(framework, "summary").unwrap_or_default(),
json_text(framework, "tone").unwrap_or_default(),
]
.join("");
let terms = collect_attribute_theme_terms(source.as_str());
format!(
"{}六维",
terms.first().cloned().unwrap_or_else(|| "叙境".to_string())
)
}
fn collect_attribute_theme_terms(source: &str) -> Vec<String> {
let mut terms = Vec::new();
let chinese_chars = source
.chars()
.filter(|character| ('\u{4e00}'..='\u{9fff}').contains(character))
.collect::<Vec<_>>();
for size in [2usize, 1usize] {
if chinese_chars.len() < size {
continue;
}
for window in chinese_chars.windows(size) {
let term = window.iter().collect::<String>();
if term.chars().count() > 2
|| BANNED_ATTRIBUTE_NAMES
.iter()
.any(|banned| term.contains(banned))
{
continue;
}
if !terms.contains(&term) {
terms.push(term);
}
if terms.len() >= 3 {
return terms;
}
}
}
terms
}
fn is_invalid_attribute_name(name: &str, seen_names: &[String]) -> bool {
let trimmed = name.trim();
trimmed.is_empty()
|| trimmed.chars().count() > 4
|| seen_names.iter().any(|seen| seen == trimmed)
|| BANNED_ATTRIBUTE_NAMES
.iter()
.any(|banned| trimmed.contains(banned))
}
fn is_invalid_attribute_schema_name(name: &str) -> bool {
BANNED_ATTRIBUTE_NAMES
.iter()
.any(|banned| name.trim().contains(banned))
}
fn json_map_text(map: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
map.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn json_map_string_array(map: &JsonMap<String, JsonValue>, key: &str) -> Option<Vec<String>> {
let items = map
.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 first_json_string(value: &JsonValue, key: &str) -> Option<String> {
value
.get(key)
.and_then(JsonValue::as_array)
.and_then(|items| items.first())
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
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 build_scene_chapter_blueprints_from_camp_and_landmarks(
camp: &JsonValue,
landmarks: &[JsonValue],
@@ -893,7 +1141,9 @@ fn build_scene_chapter_blueprint_from_scene(
.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 scene_npc_names = json_string_array(scene, "sceneNpcNames").unwrap_or_default();
let act_npc_names = json_string_array(scene, "actNPCNames")
.or_else(|| json_string_array(scene, "sceneNpcNames"))
.unwrap_or_default();
json!({
"id": scene_id.clone(),
@@ -908,7 +1158,7 @@ fn build_scene_chapter_blueprint_from_scene(
&summary,
&act_prompts,
&act_events,
&scene_npc_names,
&act_npc_names,
act_index,
))
.collect::<Vec<_>>(),
@@ -920,7 +1170,7 @@ fn build_scene_act_blueprint_from_landmark(
scene_summary: &str,
act_prompts: &[String],
act_events: &[String],
scene_npc_names: &[String],
act_npc_names: &[String],
act_index: usize,
) -> JsonValue {
let act_title = if act_index == 0 {
@@ -934,7 +1184,11 @@ fn build_scene_act_blueprint_from_landmark(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let opposite_npc_id = scene_npc_names.first().cloned().unwrap_or_default();
let opposite_npc_id = act_npc_names
.get(act_index)
.or_else(|| act_npc_names.first())
.cloned()
.unwrap_or_default();
let event_description = act_events
.get(act_index)
.map(String::as_str)
@@ -958,13 +1212,29 @@ fn build_scene_act_blueprint_from_landmark(
"title": act_title,
"summary": scene_summary,
"backgroundPromptText": background_prompt,
"encounterNpcIds": scene_npc_names,
"encounterNpcIds": build_act_encounter_npc_ids(act_npc_names, opposite_npc_id.as_str()),
"primaryNpcId": opposite_npc_id,
"oppositeNpcId": opposite_npc_id,
"eventDescription": event_description,
})
}
fn build_act_encounter_npc_ids(act_npc_names: &[String], primary_npc_id: &str) -> Vec<String> {
let mut names = Vec::with_capacity(act_npc_names.len().max(1));
let primary = primary_npc_id.trim();
if !primary.is_empty() {
names.push(primary.to_string());
}
for name in act_npc_names {
let normalized = name.trim();
if normalized.is_empty() || names.iter().any(|item| item == normalized) {
continue;
}
names.push(normalized.to_string());
}
names
}
fn build_default_scene_task_description(scene_name: &str, scene_summary: &str) -> String {
if scene_summary.trim().is_empty() {
return format!(
@@ -1071,6 +1341,13 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
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(),
@@ -1744,7 +2021,7 @@ mod tests {
"灯童丁发现巡海灯突然转向,逼玩家判断封锁线真正保护的目标。",
"灯童丁指认海图匣位置,玩家必须在退潮前确认父亲留下的暗号。"
],
"sceneNpcNames": ["灯童丁"]
"actNPCNames": ["灯童丁", "档吏庚", "灯童丁"]
})];
let blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks);
@@ -1768,6 +2045,8 @@ mod tests {
);
assert_eq!(acts[0].get("oppositeNpcId"), Some(&json!("灯童丁")));
assert_eq!(acts[0].get("primaryNpcId"), Some(&json!("灯童丁")));
assert_eq!(acts[1].get("oppositeNpcId"), Some(&json!("档吏庚")));
assert_eq!(acts[1].get("primaryNpcId"), Some(&json!("档吏庚")));
assert_eq!(
acts[0].get("eventDescription"),
Some(&json!(
@@ -1994,15 +2273,8 @@ mod tests {
}
#[tokio::test]
async fn role_outline_missing_asset_fields_are_repaired_before_details() {
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server(
request_capture.clone(),
vec![llm_response(
r#"{"storyNpcs":[{"name":"海洋生物学家","title":"深海观察员","role":"调查者","description":"记录异常海沟的人","visualDescription":"防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。","actionDescription":"蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。","sceneVisualDescription":"她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。","initialAffinity":18,"relationshipHooks":["深海样本"],"tags":["科学家"]}]}"#,
)],
);
let llm_client = build_test_llm_client(server_url);
async fn role_outline_missing_asset_fields_are_filled_locally_before_details() {
let request_capture = Arc::new(Mutex::new(Vec::<String>::new()));
let entries = vec![json!({
"name": "海洋生物学家",
"title": "深海观察员",
@@ -2013,37 +2285,34 @@ mod tests {
"tags": ["科学家"]
})];
let repaired = ensure_role_outline_asset_fields(&llm_client, "story", entries)
.await
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();
let request_text = captured_requests.join("\n---request---\n");
assert_eq!(captured_requests.len(), 1);
assert!(request_text.contains("角色「海洋生物学家」缺少 visualDescription"));
assert_eq!(captured_requests.len(), 0);
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("visualDescription"))
.and_then(JsonValue::as_str),
Some("防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱")
Some("海洋生物学家身带科学家气质,服装和轮廓呼应“记录异常海沟的人”,有清晰识别点")
);
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("actionDescription"))
.and_then(JsonValue::as_str),
Some("蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤")
Some("海洋生物学家以调查者身份行动,围绕“记录异常海沟的人”做出稳定而可识别的动作")
);
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("sceneVisualDescription"))
.and_then(JsonValue::as_str),
Some("她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器")
Some("海洋生物学家常出现在与“记录异常海沟的人”相关的场景中,周围保留其身份线索")
);
}
@@ -2054,7 +2323,7 @@ mod tests {
request_capture.clone(),
vec![
llm_response(
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"schemaName":"雾港六维","slots":[{"slotId":"axis_a","name":"灯骨","definition":"承受封航压力与潮湿险境的底子。","positiveSignals":["承压"],"negativeSignals":["虚浮"],"combatUseText":"顶住正面压迫。","socialUseText":"在质问中稳住姿态。","explorationUseText":"穿过潮湿险境。"},{"slotId":"axis_b","name":"潮步","definition":"顺潮换位与穿行的能力。","positiveSignals":["轻快"],"negativeSignals":["迟滞"],"combatUseText":"切线换位。","socialUseText":"顺势调整说法。","explorationUseText":"穿越雾港通路。"},{"slotId":"axis_c","name":"灯识","definition":"辨认灯号和旧档错页的能力。","positiveSignals":["辨伪"],"negativeSignals":["误读"],"combatUseText":"看破破绽。","socialUseText":"听出遮掩。","explorationUseText":"辨认旧档线索。"},{"slotId":"axis_d","name":"雾魄","definition":"在海雾和旧案压力中推进的胆气。","positiveSignals":["果断"],"negativeSignals":["退缩"],"combatUseText":"压上突破口。","socialUseText":"在对峙中定调。","explorationUseText":"敢进陌生雾区。"},{"slotId":"axis_e","name":"旧约","definition":"维系旧友、信物与地方关系的能力。","positiveSignals":["守诺"],"negativeSignals":["疏离"],"combatUseText":"借同伴协同。","socialUseText":"建立信任交换。","explorationUseText":"从人情旧物找线索。"},{"slotId":"axis_f","name":"回澜","definition":"长线消耗中回稳节奏的能力。","positiveSignals":["回稳"],"negativeSignals":["紊乱"],"combatUseText":"久战不乱。","socialUseText":"不被情绪带偏。","explorationUseText":"远行中保有余力。"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
),
llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
@@ -2072,10 +2341,7 @@ mod tests {
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火"},{"name":"沉船湾","description":"退潮后露出旧船骨"}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","sceneNpcNames":["灯童丁","档吏庚"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","sceneNpcNames":["船魂戊","潮医乙"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
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":"借灯火与海图周旋"}]}"#,
@@ -2105,13 +2371,17 @@ mod tests {
.clone();
let request_text = captured_requests.join("\n---request---\n");
assert!(captured_requests.len() >= 18);
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("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"));
@@ -2134,6 +2404,24 @@ mod tests {
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!(
draft_profile
.get("worldHook")
@@ -2165,6 +2453,41 @@ mod tests {
.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.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("档吏庚")
);
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("灯童丁")
);
}
fn llm_response(content: &str) -> String {

View File

@@ -20,6 +20,17 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
" \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(),
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
" \"attributeSchema\": {".to_string(),
" \"schemaName\": \"本世界六维名称\",".to_string(),
" \"slots\": [".to_string(),
" { \"slotId\": \"axis_a\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_b\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_c\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_d\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_e\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_f\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" }".to_string(),
" ]".to_string(),
" },".to_string(),
" \"camp\": {".to_string(),
" \"name\": \"开局归处名称\",".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
@@ -31,7 +42,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"".to_string(),
"要求:".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(),
"- 这一步只输出顶层 10 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
@@ -40,6 +51,9 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应第 1/2/3 幕背景图画面内容描述;每条必须基于同序号 actEventDescriptions 和相关角色写出画面主体、站位空间、冲突痕迹与氛围,能直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- attributeSchema 必须是本世界专属的角色六维属性体系slots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
"- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(),
"- 每个属性维度都要同时能服务战斗、社交、探索三种场景definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题。".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(),
@@ -50,9 +64,10 @@ pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &st
[
"下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。",
"请只输出修复后的 JSON 对象。",
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。",
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。",
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"attributeSchema 必须是对象,且包含 schemaName 与 slotsslots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f。",
"camp 必须是对象且包含name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。",
"原始文本:",
response_text.trim(),
@@ -135,47 +150,19 @@ pub(crate) fn build_custom_world_role_outline_batch_json_repair_prompt(
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_role_outline_asset_fields_repair_prompt(
role_type: &str,
role_entries: &[JsonValue],
missing_report: &str,
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
[
format!("下面这批{label}框架名单已经能解析为 JSON但有角色缺少资产默认描述字段。"),
"请只输出修复后的单个 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
"必须保留原有角色数量、顺序和 name不得新增、删除或改名。".to_string(),
"每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"visualDescription 必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内,不能复制 description。".to_string(),
"actionDescription 必须体现该角色默认动作节奏、武器或行动方式,控制在 18 到 48 个汉字内。".to_string(),
"sceneVisualDescription 必须描述该角色常出现或关联的场景画面,控制在 24 到 60 个汉字内。".to_string(),
"缺失报告:".to_string(),
missing_report.trim().to_string(),
"原始角色 JSON".to_string(),
compact_json_text(&JsonValue::Array(role_entries.to_vec())),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
framework: &JsonValue,
batch_count: usize,
forbidden_names: &[String],
) -> String {
let story_npc_names = names_from_entries(&array_field(framework, "storyNpcs"));
[
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述逐幕背景描述。".to_string(),
"这一步必须一次性生成场景骨架、地点默认生图描述逐幕背景描述、幕 NPC 分配和相连场景信息".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("")) },
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
@@ -188,6 +175,9 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(),
" \"actNPCNames\": [\"第一幕主场景角色名\", \"第二幕主场景角色名\", \"第三幕主场景角色名\"],".to_string(),
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
@@ -196,9 +186,14 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(),
"- 每个地点只保留name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook".to_string(),
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actNPCNames 只能引用上方可用场景角色名单中的名字,表示第 1/2/3 幕各自的主场景角色;如果名单为空,输出空数组。".to_string(),
"- 可用场景角色名单非空时actNPCNames 必须恰好 3 个;可以重复使用同一角色,但每一项都必须服务对应幕事件。".to_string(),
"- actNPCNames[n] 会成为第 n+1 幕对面主角色;三幕事件和幕背景必须围绕对应角色的行动、阻碍、试探或求助展开。".to_string(),
"- connectedLandmarkNames 优先引用本批或已知关键场景名称,每个地点 1 到 3 个;只有 1 个地点时可以输出空数组。".to_string(),
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须基于同序号 actEventDescriptions、当前地点和可出场角色直接写出画面主体、站位空间、冲突痕迹与氛围控制在 40 到 90 个汉字内。".to_string(),
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景玩家会在……”这类标题、摘要、规则句拼接格式必须像可直接交给生图模型的自然画面描述。".to_string(),
@@ -219,71 +214,14 @@ pub(crate) fn build_custom_world_landmark_seed_batch_json_repair_prompt(
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("必须保留恰好 {expected_count} 个地点对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个地点只包含name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(),
"如果缺少字段字符串补空字符串actBackgroundPromptTextsactEventDescriptions 补空数组。".to_string(),
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
"每个地点只包含name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook".to_string(),
"如果缺少字段字符串补空字符串actBackgroundPromptTextsactEventDescriptions、actNPCNames 和 connectedLandmarkNames 补空数组。".to_string(),
"不要输出 items 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) 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(),
" \"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(),
"- sceneNpcNames 的第一位会成为每幕对面主角色;三幕事件和幕背景必须围绕这个角色的行动、阻碍、试探或求助展开。".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")
}
pub(crate) 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、sceneNpcNames、connectedLandmarkNames、entryHook。".to_string(),
"如果缺少字段:字符串补空字符串,数组补空数组。".to_string(),
"不要新增名单外的地点。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n")
}
pub(crate) fn build_custom_world_role_batch_prompt(
framework: &JsonValue,
role_type: &str,
@@ -499,7 +437,9 @@ 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();
let names = json_string_array(&landmark, "actNPCNames")
.or_else(|| json_string_array(&landmark, "sceneNpcNames"))
.unwrap_or_default();
if names.iter().any(|name| name == role_name) {
json_text(&landmark, "name")
} else {
@@ -560,7 +500,3 @@ fn json_string_array(value: &JsonValue, key: &str) -> Option<Vec<String>> {
.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())
}