Merge branch 'codex/backend-rewrite-spacetimedb' of http://82.157.175.59:3000/GenarrativeAI/Genarrative into codex/backend-rewrite-spacetimedb

# Conflicts:
#	docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md   resolved by origin/codex/backend-rewrite-spacetimedb(远端) version
This commit is contained in:
2026-04-25 17:06:57 +08:00
393 changed files with 2902 additions and 91859 deletions

View File

@@ -90,6 +90,7 @@ use crate::{
runtime_browse_history::{
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
},
runtime_chat::stream_runtime_npc_chat_turn,
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{get_profile_dashboard, get_profile_play_stats, get_profile_wallet_ledger},
runtime_save::{
@@ -238,6 +239,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/chat/npc/turn/stream",
post(stream_runtime_npc_chat_turn).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/logout",
post(logout)

View File

@@ -63,6 +63,9 @@ use crate::{
generate_custom_world_foundation_draft,
},
http_error::AppError,
prompt::scene_background::{
SceneActBackgroundPromptParams, build_scene_act_background_image_prompt,
},
request_context::RequestContext,
state::AppState,
};
@@ -1598,7 +1601,7 @@ async fn generate_draft_foundation_act_backgrounds(
act_ref.scene_id.as_str(),
act_ref.scene_name.as_str(),
act_ref.scene_description.as_str(),
act_ref.prompt.as_str(),
act_ref.scene_image_prompt.as_str(),
)
.await
};
@@ -1772,9 +1775,13 @@ struct SceneActGenerationRef {
scene_name: String,
scene_description: String,
prompt: String,
scene_image_prompt: String,
}
fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
let world_name =
json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string());
let world_tone = json_text_from_value(draft_profile, "tone").unwrap_or_default();
let scene_context_by_id = collect_scene_context_by_id(draft_profile);
draft_profile
.get("sceneChapterBlueprints")
@@ -1800,9 +1807,10 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
description: json_text_from_value(chapter, "description")
.or_else(|| json_text_from_value(chapter, "summary"))
.unwrap_or_default(),
danger_level: json_text_from_value(chapter, "dangerLevel").unwrap_or_default(),
});
let scene_contexts = scene_context_by_id.clone();
let world_name = world_name.clone();
let world_tone = world_tone.clone();
chapter
.get("acts")
.and_then(Value::as_array)
@@ -1838,8 +1846,31 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
id: act_scene_id.clone(),
name: scene_name,
description: chapter_scene_context.description.clone(),
danger_level: chapter_scene_context.danger_level.clone(),
});
let title = json_text_from_value(act, "title")
.unwrap_or_else(|| format!("{}", act_index + 1));
let summary = json_text_from_value(act, "summary").unwrap_or_default();
let act_goal = json_text_from_value(act, "actGoal").unwrap_or_default();
let transition_hook =
json_text_from_value(act, "transitionHook").unwrap_or_default();
let primary_role_name = json_first_text_from_value(
act,
&["primaryRoleName", "primaryRole", "mainRoleName"],
)
.unwrap_or_default();
let scene_image_prompt =
build_scene_act_background_image_prompt(SceneActBackgroundPromptParams {
world_name: world_name.as_str(),
world_tone: world_tone.as_str(),
scene_name: scene_context.name.as_str(),
title: title.as_str(),
summary: summary.as_str(),
act_goal: act_goal.as_str(),
transition_hook: transition_hook.as_str(),
primary_role_name: primary_role_name.as_str(),
support_role_names: collect_scene_act_support_role_names(act),
prompt_text: prompt.as_str(),
});
SceneActGenerationRef {
chapter_index,
@@ -1847,19 +1878,18 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
scene_id: act_scene_id,
scene_name: scene_context.name,
scene_description: scene_context.description,
prompt: prompt.clone(),
prompt,
scene_image_prompt,
}
})
})
.collect()
}
#[derive(Clone, Debug)]
struct SceneImageContext {
id: String,
name: String,
description: String,
danger_level: String,
}
fn collect_scene_context_by_id(draft_profile: &Value) -> BTreeMap<String, SceneImageContext> {
@@ -1895,10 +1925,26 @@ fn scene_context_from_object(
description: read_string_field(object, "description")
.or_else(|| read_string_field(object, "visualDescription"))
.unwrap_or_default(),
danger_level: read_string_field(object, "dangerLevel").unwrap_or_default(),
})
}
fn collect_scene_act_support_role_names(act: &Value) -> Vec<String> {
// 兼容旧 Node 自动资产链路可能写入的 supportRoleNames也兼容单字段字符串避免迁移后丢上下文。
let mut names = act
.get("supportRoleNames")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
names.extend(json_text_from_value(act, "supportRoleName"));
names.extend(json_text_from_value(act, "supportRoles"));
names
}
fn validate_scene_act_background_prompts(act_refs: &[SceneActGenerationRef]) -> Result<(), String> {
if let Some(act_ref) = act_refs.iter().find(|act_ref| act_ref.prompt.is_empty()) {
return Err(format!(
@@ -2664,8 +2710,7 @@ mod tests {
{
"id": "scene-office",
"name": "旧港办公室",
"description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。",
"dangerLevel": "low"
"description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。"
}
],
"sceneChapterBlueprints": [

View File

@@ -205,7 +205,7 @@ fn build_custom_world_agent_landmark_expansion_prompt(params: ExpansionPromptPar
params.prompt_seed
}
),
"返回 JSON 数组。每个对象字段只允许包含name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。".to_string(),
"返回 JSON 数组。每个对象字段只允许包含name, purpose, mood, secret, summary, threadIds, characterIds。".to_string(),
"threadIds / characterIds 必须优先引用现有对象 id。".to_string(),
]
.join("\n")
@@ -341,7 +341,6 @@ fn normalize_generated_landmark_profile_fields(object: &mut JsonMap<String, Json
}
insert_text_if_missing(object, "description", description_parts.join(" ").as_str());
insert_text_if_missing(object, "dangerLevel", "");
object
.entry("connections".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
@@ -612,7 +611,6 @@ mod tests {
"purpose": "玩家第一次追查沉钟旧案的入口。",
"mood": "潮湿、压抑、灯火忽明忽暗。",
"secret": "码头木桩下藏着改写航道的符牌。",
"dangerLevel": "",
"characterIds": ["character-witness"]
})],
draft_profile

View File

@@ -34,6 +34,10 @@ use crate::{
build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt,
},
http_error::AppError,
prompt::scene_background::{
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark,
SceneImagePromptParams, SceneImagePromptProfile, build_custom_world_scene_image_prompt,
},
request_context::RequestContext,
state::AppState,
};
@@ -172,8 +176,6 @@ struct SceneImageLandmarkInput {
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
danger_level: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
@@ -340,7 +342,6 @@ struct OptimizedCoverUpload {
bytes: Vec<u8>,
}
const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框人物近景特写多人合照模糊低清晰度畸形建筑现代车辆监控摄像头";
const COVER_OUTPUT_WIDTH: u32 = 1600;
const COVER_OUTPUT_HEIGHT: u32 = 900;
const COVER_UPLOAD_MAX_BYTES: usize = 10 * 1024 * 1024;
@@ -575,7 +576,6 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
id: Some(scene_id.to_string()),
name: Some(scene_name.to_string()),
description: Some(scene_description.to_string()),
danger_level: None,
}),
};
let normalized = normalize_scene_image_request(payload)?;
@@ -1147,7 +1147,6 @@ fn build_landmark_fallback(world_name: &str) -> Value {
"name": "新场景",
"description": format!("围绕《{world_name}》当前主线冲突扩展出的关键场景。"),
"visualDescription": "低照度、层次复杂、带有明显环境叙事痕迹。",
"dangerLevel": "medium",
"sceneNpcIds": [],
"connections": [],
"narrativeResidues": [],
@@ -1180,14 +1179,24 @@ fn normalize_scene_image_request(
}
let prompt = trim_to_option(payload.prompt.as_deref()).unwrap_or_else(|| {
build_custom_world_scene_image_prompt(
&profile,
&landmark,
payload.user_prompt.as_deref().unwrap_or_default(),
reference_image_src.is_some(),
landmark_name.as_deref(),
world_name.as_str(),
)
build_custom_world_scene_image_prompt(SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: profile.name.as_deref().unwrap_or_default(),
subtitle: profile.subtitle.as_deref().unwrap_or_default(),
tone: profile.tone.as_deref().unwrap_or_default(),
player_goal: profile.player_goal.as_deref().unwrap_or_default(),
summary: profile.summary.as_deref().unwrap_or_default(),
setting_text: profile.setting_text.as_deref().unwrap_or_default(),
},
landmark: SceneImagePromptLandmark {
name: landmark.name.as_deref().unwrap_or_default(),
description: landmark.description.as_deref().unwrap_or_default(),
},
user_prompt: payload.user_prompt.as_deref().unwrap_or_default(),
has_reference_image: reference_image_src.is_some(),
fallback_landmark_name: landmark_name.as_deref(),
fallback_world_name: world_name.as_str(),
})
});
if prompt.is_empty() {
return Err(
@@ -1212,117 +1221,6 @@ fn normalize_scene_image_request(
})
}
fn build_custom_world_scene_image_prompt(
profile: &SceneImageProfileInput,
landmark: &SceneImageLandmarkInput,
user_prompt: &str,
has_reference_image: bool,
fallback_landmark_name: Option<&str>,
fallback_world_name: &str,
) -> String {
let world_name = clamp_scene_image_text(
trim_to_option(profile.name.as_deref())
.unwrap_or_else(|| fallback_world_name.to_string())
.as_str(),
18,
);
let world_subtitle = clamp_scene_image_text(
trim_to_option(profile.subtitle.as_deref())
.unwrap_or_default()
.as_str(),
18,
);
let world_tone = clamp_scene_image_text(
trim_to_option(profile.tone.as_deref())
.unwrap_or_default()
.as_str(),
48,
);
let world_goal = clamp_scene_image_text(
trim_to_option(profile.player_goal.as_deref())
.unwrap_or_default()
.as_str(),
48,
);
let world_summary = clamp_scene_image_text(
trim_to_option(profile.summary.as_deref())
.unwrap_or_default()
.as_str(),
72,
);
let world_setting = clamp_scene_image_text(
trim_to_option(profile.setting_text.as_deref())
.unwrap_or_default()
.as_str(),
72,
);
let landmark_name = clamp_scene_image_text(
trim_to_option(landmark.name.as_deref())
.or_else(|| fallback_landmark_name.map(ToOwned::to_owned))
.unwrap_or_else(|| "未命名场景".to_string())
.as_str(),
18,
);
let landmark_description = clamp_scene_image_text(
trim_to_option(landmark.description.as_deref())
.unwrap_or_default()
.as_str(),
96,
);
let requested_visual = clamp_scene_image_text(user_prompt, 120);
let danger_mood = describe_danger_level(
trim_to_option(landmark.danger_level.as_deref())
.unwrap_or_default()
.as_str(),
);
vec![
"为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。".to_string(),
"画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。".to_string(),
"下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。".to_string(),
"下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。".to_string(),
"下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。".to_string(),
if has_reference_image {
"已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。".to_string()
} else {
String::new()
},
format!(
"世界:{}{}",
if world_name.is_empty() {
"未命名世界"
} else {
world_name.as_str()
},
if world_subtitle.is_empty() {
String::new()
} else {
format!("{world_subtitle}")
}
),
conditional_prompt_line("玩家设定", world_setting.as_str()),
conditional_prompt_line("世界概述", world_summary.as_str()),
conditional_prompt_line("整体基调", world_tone.as_str()),
conditional_prompt_line("玩家目标关联", world_goal.as_str()),
format!(
"场景名称:{}",
if landmark_name.is_empty() {
"未命名场景"
} else {
landmark_name.as_str()
}
),
conditional_prompt_line("场景描述", landmark_description.as_str()),
conditional_prompt_line("本次想要生成的画面内容", requested_visual.as_str()),
format!("{danger_mood}"),
"不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。".to_string(),
]
.into_iter()
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("")
}
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
// Stage 2 的真实图片生成统一走 DashScope这里先把配置缺失拦在业务入口前。
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
@@ -2362,10 +2260,6 @@ fn mime_to_extension(mime_type: &str) -> &str {
}
}
fn clamp_scene_image_text(value: &str, max_length: usize) -> String {
clamp_text(value, max_length, true)
}
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
if value.is_empty() {
String::new()
@@ -2374,17 +2268,6 @@ fn conditional_prompt_line(prefix: &str, value: &str) -> String {
}
}
fn describe_danger_level(danger_level: &str) -> String {
match danger_level.trim().to_ascii_lowercase().as_str() {
"low" | "" => "气氛相对平静,但暗藏细节张力".to_string(),
"medium" | "" => "带有明确的探索压力与潜在威胁".to_string(),
"high" | "" => "危险感强烈,空间中有明显压迫感".to_string(),
"extreme" | "极高" => "极端危险,环境本身就像会吞没闯入者".to_string(),
_ if !danger_level.trim().is_empty() => format!("危险氛围:{}", danger_level.trim()),
_ => "危险气质保持克制但不可忽视".to_string(),
}
}
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
@@ -2627,7 +2510,6 @@ mod tests {
id: Some("reef_temple".to_string()),
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
danger_level: None,
}),
};
@@ -2665,7 +2547,6 @@ mod tests {
id: Some("reef_temple".to_string()),
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
danger_level: Some("high".to_string()),
};
let manual_prompt = build_custom_world_scene_image_prompt(
&profile_input,

View File

@@ -1,365 +1,6 @@
use crate::character_animation_assets::find_motion_template;
use shared_contracts::assets::CharacterAnimationStrategy;
/// 自定义世界角色主图提示词脚本。
pub(crate) fn build_character_visual_prompt(
prompt_text: &str,
character_brief_text: Option<&str>,
) -> String {
let character_brief = [character_brief_text.unwrap_or_default(), prompt_text]
.into_iter()
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n");
build_master_prompt(character_brief.as_str())
}
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
fn build_master_prompt(character_brief: &str) -> String {
[
"单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
"请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。".to_string(),
"主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。".to_string(),
"视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。".to_string(),
character_brief.trim().to_string(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n")
}
/// 自定义世界角色主图负面提示词脚本。
pub(crate) fn build_character_visual_negative_prompt() -> String {
[
"正面视角",
"左朝向",
"完全 90 度纯右视图",
"镜头透视",
"半身像",
"脚被裁切",
"头顶被裁切",
"多角色",
"复杂背景",
"建筑场景",
"漂浮物",
"烟雾环境",
"武器消失",
"武器换手",
"额外手臂",
"额外腿",
"服装变化",
"脸部变化",
"模糊",
"运动模糊",
"文字",
"水印",
"UI 元素",
"软萌 Q版大头贴",
"儿童绘本风",
"厚涂插画感",
"低对比柔边",
]
.join("")
}
pub(crate) fn build_character_animation_prompt(
strategy: &CharacterAnimationStrategy,
prompt_text: &str,
character_brief_text: Option<&str>,
action_template_id: Option<&str>,
animation: &str,
frame_count: u32,
fps: u32,
duration_seconds: u32,
loop_: bool,
use_chroma_key: bool,
) -> String {
match strategy {
CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt(
animation,
prompt_text,
character_brief_text,
action_template_id,
loop_,
use_chroma_key,
),
CharacterAnimationStrategy::ImageSequence => {
build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key)
}
CharacterAnimationStrategy::MotionTransfer
| CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt(
animation,
prompt_text,
character_brief_text,
action_template_id,
loop_,
use_chroma_key,
fps,
duration_seconds,
),
}
}
fn build_image_sequence_prompt(
animation: &str,
prompt_text: &str,
frame_count: u32,
use_chroma_key: bool,
) -> String {
[
format!(
"同一角色连续 {} 帧动作序列,动作主题是 {}",
frame_count, animation
),
"固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(),
"帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(),
if use_chroma_key {
"纯绿色背景,无地面装饰,方便后期抠像。".to_string()
} else {
"背景尽量纯净,避免复杂场景。".to_string()
},
prompt_text.trim().to_string(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn build_npc_animation_prompt(
animation: &str,
prompt_text: &str,
character_brief_text: Option<&str>,
action_template_id: Option<&str>,
loop_: bool,
use_chroma_key: bool,
fps: u32,
duration_seconds: u32,
) -> String {
let character_brief = build_compact_animation_character_brief(character_brief_text);
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
let loop_rule = if loop_ {
"这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。"
.to_string()
} else if animation == "die" {
"这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。"
.to_string()
} else {
"这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string()
};
if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) {
return [
format!(
"单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。",
template.animation
),
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
} else {
"背景简洁纯净,无复杂场景。".to_string()
},
if character_brief.is_empty() {
String::new()
} else {
format!("角色设定:{}", character_brief)
},
format!("动作补充:{}", template.prompt_suffix),
if action_detail_text.is_empty() {
String::new()
} else {
format!("动作细节:{}", action_detail_text)
},
format!("目标帧率 {} fps时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)),
loop_rule,
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
}
[
format!("单人 NPC 全身动作视频,动作主题是 {}", animation),
"角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(),
"动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(),
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
} else {
"背景简洁纯净,无复杂场景。".to_string()
},
if character_brief.is_empty() {
String::new()
} else {
format!("角色设定:{}", character_brief)
},
if action_detail_text.is_empty() {
String::new()
} else {
action_detail_text
},
format!(
"目标帧率 {} fps时长约 {} 秒。",
fps.clamp(1, 60),
duration_seconds.clamp(1, 8)
),
loop_rule,
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn build_ark_character_animation_prompt(
animation: &str,
prompt_text: &str,
character_brief_text: Option<&str>,
action_template_id: Option<&str>,
loop_: bool,
use_chroma_key: bool,
) -> String {
let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_");
let normalized_animation_name = if normalized_animation_name.is_empty() {
"idle".to_string()
} else {
normalized_animation_name
};
let character_brief = build_compact_animation_character_brief(character_brief_text);
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
if let Some(template) = action_template_id.and_then(find_motion_template) {
return build_video_action_prompt(
template.id,
template.prompt_suffix,
action_detail_text.as_str(),
Some(character_brief.as_str()),
use_chroma_key,
);
}
build_video_action_prompt(
normalized_animation_name.as_str(),
if loop_ {
"循环动作必须自然闭环,不要静止开场。"
} else {
"中段完成完整动作变化,收束干净。"
},
action_detail_text.as_str(),
Some(character_brief.as_str()),
use_chroma_key,
)
}
/// 角色动作视频统一提示词骨架,按每个动作模板与补充描述生成。
fn build_video_action_prompt(
action_id: &str,
action_sequence: &str,
action_detail_text: &str,
character_brief_text: Option<&str>,
use_chroma_key: bool,
) -> String {
[
format!("单人全身角色动作视频,动作英文名是 {}", action_id),
"角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰不要退化成完全 90 度纯右视图。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string()
} else {
"背景简洁纯净,无其他人物和复杂场景元素,方便后期抽帧。".to_string()
},
format!(
"动作补充细节:{}",
if action_detail_text.trim().is_empty() {
"保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。"
} else {
action_detail_text.trim()
}
),
character_brief_text
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("角色设定:{}", value))
.unwrap_or_default(),
"目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。".to_string(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn build_fallback_moderation_safe_animation_prompt(
animation: &str,
loop_: bool,
use_chroma_key: bool,
) -> String {
[
format!("单人全身角色动作视频,动作主题是 {}", animation),
"角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(),
if loop_ {
"循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string()
} else {
"非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string()
},
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素。".to_string()
} else {
"背景简洁纯净。".to_string()
},
]
.join(" ")
}
fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String {
value
.replace(char::is_whitespace, " ")
.replace("血浆", "")
.replace("喷血", "")
.replace("鲜血", "")
.replace("断肢", "")
.replace("斩首", "")
.replace("裸体", "")
.replace("裸露", "")
.replace("色情", "")
.replace("性交", "")
.replace("死亡", "倒地结束")
.replace("死去", "倒地结束")
.replace("击杀", "倒地结束")
.replace("受击", "失衡")
.replace("受伤", "失衡")
.replace("砍杀", "挥击")
.replace("斩击", "挥击")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(max_length)
.collect::<String>()
.trim()
.to_string()
}
fn build_compact_animation_character_brief(value: Option<&str>) -> String {
let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160);
if normalized.is_empty() {
return String::new();
}
normalized
.split(['/', '|', '\n', '', ',', '。', '', ';'])
.map(str::trim)
.filter(|item| !item.is_empty())
.take(4)
.collect::<Vec<_>>()
.join("")
}
pub(crate) use crate::prompt::character_animation::{
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
};
pub(crate) use crate::prompt::character_visual::{
build_character_visual_negative_prompt, build_character_visual_prompt,
};

View File

@@ -1,4 +1,14 @@
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
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_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;
@@ -158,7 +168,6 @@ const FOUNDATION_DRAFT_LANDMARK_COUNT: usize = 2;
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE: usize = 2;
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 2;
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE: usize = 2;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES: [i64; 4] = [15, 30, 60, 90];
async fn request_foundation_json_stage<F>(
llm_client: &LlmClient,
@@ -604,360 +613,6 @@ fn build_eight_anchor_foundation_text(anchor_content: &JsonValue) -> String {
sections.join("\n")
}
fn build_custom_world_framework_prompt(setting_text: &str) -> String {
[
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物和地图细节。".to_string(),
"玩家设定:".to_string(),
setting_text.trim().to_string(),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"name\": \"世界名称\",".to_string(),
" \"subtitle\": \"世界副标题\",".to_string(),
" \"summary\": \"世界概述\",".to_string(),
" \"tone\": \"世界基调\",".to_string(),
" \"playerGoal\": \"玩家核心目标\",".to_string(),
" \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(),
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
" \"camp\": {".to_string(),
" \"name\": \"开局归处名称\",".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(),
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
" }".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
"- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].join("\n")
}
fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> String {
[
"下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。",
"请只输出修复后的 JSON 对象。",
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。",
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"camp 必须是对象且包含name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。",
"原始文本:",
response_text.trim(),
].join("\n")
}
fn build_custom_world_role_outline_batch_prompt(
framework: &JsonValue,
role_type: &str,
batch_count: usize,
forbidden_names: &[String],
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
[
format!("请根据下面的世界核心信息,生成一批{label}框架名单。"),
"后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if forbidden_names.is_empty() { "".to_string() } else { format!("这些名字已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
format!(" \"{key}\": ["),
" {".to_string(),
" \"name\": \"角色名称\",".to_string(),
" \"title\": \"称号\",".to_string(),
" \"role\": \"身份\",".to_string(),
" \"description\": \"极简定位描述\",".to_string(),
" \"visualDescription\": \"默认角色形象描述\",".to_string(),
" \"actionDescription\": \"默认角色动作描述\",".to_string(),
" \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(),
" \"initialAffinity\": 18,".to_string(),
" \"relationshipHooks\": [\"一个关系切入口\"],".to_string(),
" \"tags\": [\"标签1\", \"标签2\"]".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
format!("- 必须生成恰好 {batch_count}{label}"),
"- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(),
"- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(),
"- 只保留name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(),
"- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(),
"- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(),
"- relationshipHooks 最多 1 条tags 保持 1 到 2 个。".to_string(),
"- description 控制在 8 到 18 个汉字内title 和 role 也尽量短。".to_string(),
"- initialAffinity 必须是 -40 到 90 的整数。".to_string(),
if role_type == "playable" { "- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。".to_string() } else { "- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。".to_string() },
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_role_outline_batch_json_repair_prompt(
response_text: &str,
role_type: &str,
expected_count: usize,
forbidden_names: &[String],
) -> String {
let key = role_key(role_type);
[
format!("下面这段文本本应是自定义世界{}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("必须保留恰好 {expected_count} 个角色对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"如果缺少字段字符串补空字符串relationshipHooks 和 tags 补空数组initialAffinity 补默认整数。".to_string(),
"不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_landmark_seed_batch_prompt(
framework: &JsonValue,
batch_count: usize,
forbidden_names: &[String],
) -> String {
[
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述和逐幕背景描述。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"landmarks\": [".to_string(),
" {".to_string(),
" \"name\": \"场景名称\",".to_string(),
" \"description\": \"场景极简描述\",".to_string(),
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(),
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。".to_string(),
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(),
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景玩家会在……”这类标题、摘要、规则句拼接格式必须像可直接交给生图模型的自然画面描述。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- description 控制在 12 到 24 个汉字内。".to_string(),
"- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_landmark_seed_batch_json_repair_prompt(
response_text: &str,
expected_count: usize,
forbidden_names: &[String],
) -> String {
[
"下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"请只输出修复后的 JSON 对象。".to_string(),
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("必须保留恰好 {expected_count} 个地点对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个地点只包含name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。".to_string(),
"如果缺少字段字符串补空字符串actBackgroundPromptTexts 和 actEventDescriptions 补空数组dangerLevel 补 medium。".to_string(),
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_landmark_network_batch_prompt(
framework: &JsonValue,
story_npcs: &[JsonValue],
landmark_batch: &[JsonValue],
) -> String {
[
"请补全下面这一批关键场景的探索网络信息。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 10),
"可用场景角色名单:".to_string(),
names_from_entries(story_npcs).join(""),
"本批场景:".to_string(),
compact_json_text(&JsonValue::Array(landmark_batch.to_vec())),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"landmarks\": [".to_string(),
" {".to_string(),
" \"name\": \"场景名称\",".to_string(),
" \"description\": \"场景描述\",".to_string(),
" \"dangerLevel\": \"low|medium|high|extreme\",".to_string(),
" \"sceneNpcNames\": [\"会在这里出现的角色名\"],".to_string(),
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 必须只补全本批场景name 必须与本批场景完全一致,不得增删改名。".to_string(),
"- sceneNpcNames 只能引用上方可用场景角色名单中的名字,每个地点 1 到 3 个。".to_string(),
"- connectedLandmarkNames 优先引用已知关键场景名称,每个地点 1 到 3 个。".to_string(),
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_landmark_network_batch_json_repair_prompt(
response_text: &str,
expected_names: &[String],
) -> String {
[
"下面这段文本本应是自定义世界关键场景探索网络补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"请只输出修复后的 JSON 对象。".to_string(),
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("这个数组里只能保留这些地点名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个地点都必须包含name、description、dangerLevel、sceneNpcNames、connectedLandmarkNames、entryHook。".to_string(),
"如果缺少字段字符串补空字符串数组补空数组dangerLevel 补 medium。".to_string(),
"不要新增名单外的地点。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n")
}
fn build_custom_world_role_batch_prompt(
framework: &JsonValue,
role_type: &str,
role_batch: &[JsonValue],
stage: &str,
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
let stage_label = if stage == "narrative" {
"叙事档案"
} else {
"养成档案"
};
let required_fields = if stage == "narrative" {
"name、backstory、personality、motivation、combatStyle"
} else {
"name、backstoryReveal、skills、initialItems"
};
let template_extra = if stage == "narrative" {
[
" \"backstory\": \"公开背景\",",
" \"personality\": \"性格关键词\",",
" \"motivation\": \"当前动机\",",
" \"combatStyle\": \"行动或战斗风格\"",
]
.join("\n")
} else {
[
" \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },",
" \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],",
" \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]",
].join("\n")
};
[
format!("请为下面这一批{label}补全{stage_label}"),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 10),
"本批角色:".to_string(),
build_role_outline_prompt_text(role_batch, framework, role_type),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
format!(" \"{key}\": ["),
" {".to_string(),
" \"name\": \"角色名称\",".to_string(),
template_extra,
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 必须只补全本批角色name 必须与本批角色完全一致,不得增删改名。".to_string(),
format!("- 每个角色必须包含:{required_fields}"),
if stage == "narrative" { "- backstory 控制在 32 到 80 个汉字内personality、motivation、combatStyle 都要短而具体。".to_string() } else { format!("- backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 {}", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("")) },
if stage == "narrative" { "- 不要输出 backstoryReveal、skills、initialItems。".to_string() } else { "- skills 默认 3 个initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。".to_string() },
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
fn build_custom_world_role_batch_json_repair_prompt(
response_text: &str,
role_type: &str,
stage: &str,
expected_names: &[String],
) -> String {
let key = role_key(role_type);
if stage == "narrative" {
return [
format!("下面这段文本本应是自定义世界{}叙事档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("这个数组里只能保留这些角色名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个角色都必须包含name、backstory、personality、motivation、combatStyle。".to_string(),
"如果缺少字段:字符串补空字符串。".to_string(),
"不要输出 backstoryReveal、skills、initialItems也不要新增名单外的角色。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n");
}
[
format!("下面这段文本本应是自定义世界{}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("这个数组里只能保留这些角色名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个角色都必须包含name、backstoryReveal、skills、initialItems。".to_string(),
format!("backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 {}", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("")),
"skills 默认补成 3 个对象,每个对象包含 name、summary、styleinitialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。".to_string(),
"不要输出 backstory、personality、motivation、combatStyle、landmarks也不要新增名单外的角色。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n")
}
#[cfg(test)]
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
let anchor_content = to_pretty_json(&session.anchor_content);
@@ -1071,14 +726,17 @@ fn build_foundation_draft_profile_from_framework(
)])
}),
);
let camp = framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" }));
let camp = framework.get("camp").cloned().unwrap_or_else(
|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }),
);
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);
let scene_chapter_blueprints =
build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks);
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
object.insert(
@@ -1127,8 +785,8 @@ fn build_scene_chapter_blueprint_from_scene(
) -> 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 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));
@@ -1201,7 +859,9 @@ fn build_scene_act_blueprint_from_landmark(
fn build_default_scene_task_description(scene_name: &str, scene_summary: &str) -> String {
if scene_summary.trim().is_empty() {
return format!("首次进入{scene_name}时,确认当前场景的核心异常、关键角色与下一步行动方向。");
return format!(
"首次进入{scene_name}时,确认当前场景的核心异常、关键角色与下一步行动方向。"
);
}
format!("首次进入{scene_name}时,围绕{scene_summary}确认核心异常、关键角色与下一步行动方向。")
}
@@ -1269,7 +929,10 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
object.insert("coreConflicts".to_string(), JsonValue::Array(Vec::new()));
}
if !object.get("camp").is_some_and(JsonValue::is_object) {
object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" }));
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
@@ -1350,131 +1013,6 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
}
}
fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String {
let landmark_text = array_field(framework, "landmarks")
.into_iter()
.take(max_landmarks)
.map(|landmark| {
format!(
"{}{}{}",
json_text(&landmark, "name").unwrap_or_default(),
json_text(&landmark, "dangerLevel").unwrap_or_default(),
json_text(&landmark, "description").unwrap_or_default()
)
})
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("");
[
format!("世界:{}", json_text(framework, "name").unwrap_or_default()),
format!(
"副标题:{}",
json_text(framework, "subtitle").unwrap_or_default()
),
format!(
"世界概述:{}",
json_text(framework, "summary").unwrap_or_default()
),
format!(
"世界基调:{}",
json_text(framework, "tone").unwrap_or_default()
),
format!(
"玩家核心目标:{}",
json_text(framework, "playerGoal").unwrap_or_default()
),
json_string_array(framework, "majorFactions")
.map(|items| format!("主要势力:{}", items.join("")))
.unwrap_or_default(),
json_string_array(framework, "coreConflicts")
.map(|items| format!("核心冲突:{}", items.join("")))
.unwrap_or_default(),
format!(
"开局归处:{}{}",
json_path_text(framework, &["camp", "name"]).unwrap_or_default(),
json_path_text(framework, &["camp", "description"]).unwrap_or_default()
),
if landmark_text.is_empty() {
String::new()
} else {
format!("关键场景:{landmark_text}")
},
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn build_role_outline_prompt_text(
role_batch: &[JsonValue],
framework: &JsonValue,
role_type: &str,
) -> String {
role_batch
.iter()
.map(|role| {
let appearance_text = if role_type == "story" {
landmark_names_for_role(
framework,
json_text(role, "name").unwrap_or_default().as_str(),
)
.join("")
} else {
String::new()
};
[
format!(
"- {} / {}",
json_text(role, "name").unwrap_or_default(),
json_text(role, "title").unwrap_or_default()
),
format!("身份:{}", json_text(role, "role").unwrap_or_default()),
format!(
"框架描述:{}",
json_text(role, "description").unwrap_or_default()
),
format!(
"预设好感:{}",
role.get("initialAffinity")
.and_then(JsonValue::as_i64)
.unwrap_or(0)
),
json_string_array(role, "relationshipHooks")
.map(|items| format!("关系切入口:{}", items.join("")))
.unwrap_or_default(),
json_string_array(role, "tags")
.map(|items| format!("标签:{}", items.join("")))
.unwrap_or_default(),
if appearance_text.is_empty() {
String::new()
} else {
format!("出现场景:{appearance_text}")
},
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
})
.collect::<Vec<_>>()
.join("\n")
}
fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec<String> {
array_field(framework, "landmarks")
.into_iter()
.filter_map(|landmark| {
let names = json_string_array(&landmark, "sceneNpcNames").unwrap_or_default();
if names.iter().any(|name| name == role_name) {
json_text(&landmark, "name")
} else {
None
}
})
.collect()
}
fn role_key(role_type: &str) -> &'static str {
if role_type == "playable" {
"playableNpcs"
@@ -1679,8 +1217,9 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("第一幕");
object.insert("title".to_string(), JsonValue::String(title.to_string()));
.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)
@@ -1695,7 +1234,7 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_default_scene_task_description(title, summary.as_str()));
.unwrap_or_else(|| build_default_scene_task_description(title.as_str(), summary.as_str()));
object.insert(
"sceneTaskDescription".to_string(),
JsonValue::String(scene_task_description),
@@ -1794,12 +1333,18 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
.unwrap_or_else(|| {
build_default_act_event_description(summary.as_str(), opposite_npc_id.as_str(), index)
});
object.insert("encounterNpcIds".to_string(), JsonValue::Array(encounter_npc_ids));
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(
"oppositeNpcId".to_string(),
JsonValue::String(opposite_npc_id),
);
object.insert(
"eventDescription".to_string(),
JsonValue::String(event_description),
@@ -1979,11 +1524,17 @@ mod tests {
let landmarks = vec![json!({
"name": "雾港码头",
"description": "旧船骨露出黑潮。",
"sceneTaskDescription": "首次进入雾港码头时,查明黑潮船骨与灯童丁目击证词的关联。",
"actBackgroundPromptTexts": [
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。",
"封锁绳与巡海灯横切码头,中景堆满浸水货箱,远景黑潮拍打沉船残骸。",
"退潮后的泥滩露出父亲留下的海图匣,雾中灯火错位闪烁,岸边留出对峙站位。"
],
"actEventDescriptions": [
"灯童丁在潮湿栈桥上拦住玩家,交出与旧船骨有关的第一句证词。",
"灯童丁发现巡海灯突然转向,逼玩家判断封锁线真正保护的目标。",
"灯童丁指认海图匣位置,玩家必须在退潮前确认父亲留下的暗号。"
],
"sceneNpcNames": ["灯童丁"]
})];
@@ -2000,6 +1551,20 @@ mod tests {
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。"
))
);
assert_eq!(
blueprints[0].get("sceneTaskDescription"),
Some(&json!(
"首次进入雾港码头时,查明黑潮船骨与灯童丁目击证词的关联。"
))
);
assert_eq!(acts[0].get("oppositeNpcId"), Some(&json!("灯童丁")));
assert_eq!(acts[0].get("primaryNpcId"), Some(&json!("灯童丁")));
assert_eq!(
acts[0].get("eventDescription"),
Some(&json!(
"灯童丁在潮湿栈桥上拦住玩家,交出与旧船骨有关的第一句证词。"
))
);
assert!(
!acts[0]
.get("backgroundPromptText")
@@ -2014,8 +1579,7 @@ mod tests {
let mut framework = json!({
"camp": {
"name": "萧家祖宅",
"description": "玩家开局并成长的家族祖宅。",
"dangerLevel": "low"
"description": "玩家开局并成长的家族祖宅。"
}
});
normalize_framework_shape(&mut framework, "废柴少年的逆袭传奇");
@@ -2038,10 +1602,22 @@ mod tests {
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_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_eq!(blueprints.len(), 2);
}
@@ -2056,6 +1632,11 @@ mod tests {
);
assert_eq!(act.get("backgroundPromptText"), Some(&json!("")));
assert!(
act.get("eventDescription")
.and_then(JsonValue::as_str)
.is_some_and(|value| value.contains("玩家进入雾港码头"))
);
}
#[test]
@@ -2127,7 +1708,7 @@ mod tests {
request_capture.clone(),
vec![
llm_response(
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。","dangerLevel":"low"}}"#,
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
),
llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
@@ -2145,10 +1726,10 @@ mod tests {
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","dangerLevel":"medium"},{"name":"沉船湾","description":"退潮后露出旧船骨","dangerLevel":"high"}]}"#,
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火"},{"name":"沉船湾","description":"退潮后露出旧船骨"}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","dangerLevel":"medium","sceneNpcNames":["灯童丁","档吏庚"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","dangerLevel":"high","sceneNpcNames":["船魂戊","潮医乙"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","sceneNpcNames":["灯童丁","档吏庚"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","sceneNpcNames":["船魂戊","潮医乙"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
),
llm_response(
r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#,

View File

@@ -36,12 +36,15 @@ mod logout_all;
mod password_entry;
mod password_management;
mod phone_auth;
mod prompt;
mod puzzle;
mod puzzle_agent_turn;
mod refresh_session;
mod request_context;
mod response_headers;
mod runtime_browse_history;
mod runtime_chat;
mod runtime_chat_prompt;
mod runtime_inventory;
mod runtime_profile;
mod runtime_save;

View File

@@ -0,0 +1,297 @@
use crate::character_animation_assets::find_motion_template;
use shared_contracts::assets::CharacterAnimationStrategy;
pub(crate) fn build_character_animation_prompt(
strategy: &CharacterAnimationStrategy,
prompt_text: &str,
character_brief_text: Option<&str>,
action_template_id: Option<&str>,
animation: &str,
frame_count: u32,
fps: u32,
duration_seconds: u32,
loop_: bool,
use_chroma_key: bool,
) -> String {
match strategy {
CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt(
animation,
prompt_text,
character_brief_text,
action_template_id,
loop_,
use_chroma_key,
),
CharacterAnimationStrategy::ImageSequence => {
build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key)
}
CharacterAnimationStrategy::MotionTransfer
| CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt(
animation,
prompt_text,
character_brief_text,
action_template_id,
loop_,
use_chroma_key,
fps,
duration_seconds,
),
}
}
fn build_image_sequence_prompt(
animation: &str,
prompt_text: &str,
frame_count: u32,
use_chroma_key: bool,
) -> String {
[
format!(
"同一角色连续 {} 帧动作序列,动作主题是 {}",
frame_count, animation
),
"固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(),
"帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(),
if use_chroma_key {
"纯绿色背景,无地面装饰,方便后期抠像。".to_string()
} else {
"背景尽量纯净,避免复杂场景。".to_string()
},
prompt_text.trim().to_string(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn build_npc_animation_prompt(
animation: &str,
prompt_text: &str,
character_brief_text: Option<&str>,
action_template_id: Option<&str>,
loop_: bool,
use_chroma_key: bool,
fps: u32,
duration_seconds: u32,
) -> String {
let character_brief = build_compact_animation_character_brief(character_brief_text);
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
let loop_rule = if loop_ {
"这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。"
.to_string()
} else if animation == "die" {
"这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。"
.to_string()
} else {
"这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string()
};
if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) {
return [
format!(
"单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。",
template.animation
),
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
} else {
"背景简洁纯净,无复杂场景。".to_string()
},
if character_brief.is_empty() {
String::new()
} else {
format!("角色设定:{}", character_brief)
},
format!("动作补充:{}", template.prompt_suffix),
if action_detail_text.is_empty() {
String::new()
} else {
format!("动作细节:{}", action_detail_text)
},
format!("目标帧率 {} fps时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)),
loop_rule,
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
}
[
format!("单人 NPC 全身动作视频,动作主题是 {}", animation),
"角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(),
"动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(),
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
} else {
"背景简洁纯净,无复杂场景。".to_string()
},
if character_brief.is_empty() {
String::new()
} else {
format!("角色设定:{}", character_brief)
},
if action_detail_text.is_empty() {
String::new()
} else {
action_detail_text
},
format!(
"目标帧率 {} fps时长约 {} 秒。",
fps.clamp(1, 60),
duration_seconds.clamp(1, 8)
),
loop_rule,
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn build_ark_character_animation_prompt(
animation: &str,
prompt_text: &str,
character_brief_text: Option<&str>,
action_template_id: Option<&str>,
loop_: bool,
use_chroma_key: bool,
) -> String {
let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_");
let normalized_animation_name = if normalized_animation_name.is_empty() {
"idle".to_string()
} else {
normalized_animation_name
};
let character_brief = build_compact_animation_character_brief(character_brief_text);
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
if let Some(template) = action_template_id.and_then(find_motion_template) {
return build_video_action_prompt(
template.id,
template.prompt_suffix,
action_detail_text.as_str(),
Some(character_brief.as_str()),
use_chroma_key,
);
}
build_video_action_prompt(
normalized_animation_name.as_str(),
if loop_ {
"循环动作必须自然闭环,不要静止开场。"
} else {
"中段完成完整动作变化,收束干净。"
},
action_detail_text.as_str(),
Some(character_brief.as_str()),
use_chroma_key,
)
}
/// 角色动作视频统一提示词骨架,按每个动作模板与补充描述生成。
fn build_video_action_prompt(
action_id: &str,
action_sequence: &str,
action_detail_text: &str,
character_brief_text: Option<&str>,
use_chroma_key: bool,
) -> String {
[
format!("单人全身角色动作视频,动作英文名是 {}", action_id),
"角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰不要退化成完全 90 度纯右视图。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string()
} else {
"背景简洁纯净,无其他人物和复杂场景元素,方便后期抽帧。".to_string()
},
format!(
"动作补充细节:{}",
if action_detail_text.trim().is_empty() {
"保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。"
} else {
action_detail_text.trim()
}
),
character_brief_text
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("角色设定:{}", value))
.unwrap_or_default(),
"目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。".to_string(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn build_fallback_moderation_safe_animation_prompt(
animation: &str,
loop_: bool,
use_chroma_key: bool,
) -> String {
[
format!("单人全身角色动作视频,动作主题是 {}", animation),
"角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(),
if loop_ {
"循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string()
} else {
"非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string()
},
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素。".to_string()
} else {
"背景简洁纯净。".to_string()
},
]
.join(" ")
}
fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String {
value
.replace(char::is_whitespace, " ")
.replace("血浆", "")
.replace("喷血", "")
.replace("鲜血", "")
.replace("断肢", "")
.replace("斩首", "")
.replace("裸体", "")
.replace("裸露", "")
.replace("色情", "")
.replace("性交", "")
.replace("死亡", "倒地结束")
.replace("死去", "倒地结束")
.replace("击杀", "倒地结束")
.replace("受击", "失衡")
.replace("受伤", "失衡")
.replace("砍杀", "挥击")
.replace("斩击", "挥击")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(max_length)
.collect::<String>()
.trim()
.to_string()
}
fn build_compact_animation_character_brief(value: Option<&str>) -> String {
let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160);
if normalized.is_empty() {
return String::new();
}
normalized
.split(['/', '|', '\n', '', ',', '。', '', ';'])
.map(str::trim)
.filter(|item| !item.is_empty())
.take(4)
.collect::<Vec<_>>()
.join("")
}

View File

@@ -0,0 +1,67 @@
/// 自定义世界角色主图提示词脚本。
pub(crate) fn build_character_visual_prompt(
prompt_text: &str,
character_brief_text: Option<&str>,
) -> String {
let character_brief = [character_brief_text.unwrap_or_default(), prompt_text]
.into_iter()
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n");
build_master_prompt(character_brief.as_str())
}
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
fn build_master_prompt(character_brief: &str) -> String {
[
"单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
"请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。".to_string(),
"主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。".to_string(),
"视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。".to_string(),
character_brief.trim().to_string(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n")
}
/// 自定义世界角色主图负面提示词脚本。
pub(crate) fn build_character_visual_negative_prompt() -> String {
[
"正面视角",
"左朝向",
"完全 90 度纯右视图",
"镜头透视",
"半身像",
"脚被裁切",
"头顶被裁切",
"多角色",
"复杂背景",
"建筑场景",
"漂浮物",
"烟雾环境",
"武器消失",
"武器换手",
"额外手臂",
"额外腿",
"服装变化",
"脸部变化",
"模糊",
"运动模糊",
"文字",
"水印",
"UI 元素",
"软萌 Q版大头贴",
"儿童绘本风",
"厚涂插画感",
"低对比柔边",
]
.join("")
}

View File

@@ -0,0 +1,534 @@
use serde_json::Value as JsonValue;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES: [i64; 4] = [15, 30, 60, 90];
pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String {
[
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物和地图细节。".to_string(),
"玩家设定:".to_string(),
setting_text.trim().to_string(),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"name\": \"世界名称\",".to_string(),
" \"subtitle\": \"世界副标题\",".to_string(),
" \"summary\": \"世界概述\",".to_string(),
" \"tone\": \"世界基调\",".to_string(),
" \"playerGoal\": \"玩家核心目标\",".to_string(),
" \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(),
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
" \"camp\": {".to_string(),
" \"name\": \"开局归处名称\",".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(),
" }".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
"- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].join("\n")
}
pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> String {
[
"下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。",
"请只输出修复后的 JSON 对象。",
"顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。",
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"camp 必须是对象且包含name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。",
"原始文本:",
response_text.trim(),
].join("\n")
}
pub(crate) fn build_custom_world_role_outline_batch_prompt(
framework: &JsonValue,
role_type: &str,
batch_count: usize,
forbidden_names: &[String],
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
[
format!("请根据下面的世界核心信息,生成一批{label}框架名单。"),
"后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if forbidden_names.is_empty() { "".to_string() } else { format!("这些名字已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
format!(" \"{key}\": ["),
" {".to_string(),
" \"name\": \"角色名称\",".to_string(),
" \"title\": \"称号\",".to_string(),
" \"role\": \"身份\",".to_string(),
" \"description\": \"极简定位描述\",".to_string(),
" \"visualDescription\": \"默认角色形象描述\",".to_string(),
" \"actionDescription\": \"默认角色动作描述\",".to_string(),
" \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(),
" \"initialAffinity\": 18,".to_string(),
" \"relationshipHooks\": [\"一个关系切入口\"],".to_string(),
" \"tags\": [\"标签1\", \"标签2\"]".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
format!("- 必须生成恰好 {batch_count}{label}"),
"- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(),
"- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(),
"- 只保留name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(),
"- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(),
"- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(),
"- relationshipHooks 最多 1 条tags 保持 1 到 2 个。".to_string(),
"- description 控制在 8 到 18 个汉字内title 和 role 也尽量短。".to_string(),
"- initialAffinity 必须是 -40 到 90 的整数。".to_string(),
if role_type == "playable" { "- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。".to_string() } else { "- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。".to_string() },
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_role_outline_batch_json_repair_prompt(
response_text: &str,
role_type: &str,
expected_count: usize,
forbidden_names: &[String],
) -> String {
let key = role_key(role_type);
[
format!("下面这段文本本应是自定义世界{}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("必须保留恰好 {expected_count} 个角色对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"如果缺少字段字符串补空字符串relationshipHooks 和 tags 补空数组initialAffinity 补默认整数。".to_string(),
"不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
framework: &JsonValue,
batch_count: usize,
forbidden_names: &[String],
) -> String {
[
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述和逐幕背景描述。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
" \"landmarks\": [".to_string(),
" {".to_string(),
" \"name\": \"场景名称\",".to_string(),
" \"description\": \"场景极简描述\",".to_string(),
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(),
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(),
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(),
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景玩家会在……”这类标题、摘要、规则句拼接格式必须像可直接交给生图模型的自然画面描述。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- description 控制在 12 到 24 个汉字内。".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_seed_batch_json_repair_prompt(
response_text: &str,
expected_count: usize,
forbidden_names: &[String],
) -> String {
[
"下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
"请只输出修复后的 JSON 对象。".to_string(),
"顶层必须只包含一个 landmarks 数组。".to_string(),
format!("必须保留恰好 {expected_count} 个地点对象。"),
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}", forbidden_names.join("")) },
"每个地点只包含name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。".to_string(),
"如果缺少字段字符串补空字符串actBackgroundPromptTexts 和 actEventDescriptions 补空数组。".to_string(),
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
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(),
"- 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,
role_batch: &[JsonValue],
stage: &str,
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
let stage_label = if stage == "narrative" {
"叙事档案"
} else {
"养成档案"
};
let required_fields = if stage == "narrative" {
"name、backstory、personality、motivation、combatStyle"
} else {
"name、backstoryReveal、skills、initialItems"
};
let template_extra = if stage == "narrative" {
[
" \"backstory\": \"公开背景\",",
" \"personality\": \"性格关键词\",",
" \"motivation\": \"当前动机\",",
" \"combatStyle\": \"行动或战斗风格\"",
]
.join("\n")
} else {
[
" \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },",
" \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],",
" \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]",
].join("\n")
};
[
format!("请为下面这一批{label}补全{stage_label}"),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 10),
"本批角色:".to_string(),
build_role_outline_prompt_text(role_batch, framework, role_type),
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".to_string(),
format!(" \"{key}\": ["),
" {".to_string(),
" \"name\": \"角色名称\",".to_string(),
template_extra,
" }".to_string(),
" ]".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 必须只补全本批角色name 必须与本批角色完全一致,不得增删改名。".to_string(),
format!("- 每个角色必须包含:{required_fields}"),
if stage == "narrative" { "- backstory 控制在 32 到 80 个汉字内personality、motivation、combatStyle 都要短而具体。".to_string() } else { format!("- backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 {}", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("")) },
if stage == "narrative" { "- 不要输出 backstoryReveal、skills、initialItems。".to_string() } else { "- skills 默认 3 个initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。".to_string() },
"- 所有生成文本都必须使用中文。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_role_batch_json_repair_prompt(
response_text: &str,
role_type: &str,
stage: &str,
expected_names: &[String],
) -> String {
let key = role_key(role_type);
if stage == "narrative" {
return [
format!("下面这段文本本应是自定义世界{}叙事档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("这个数组里只能保留这些角色名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个角色都必须包含name、backstory、personality、motivation、combatStyle。".to_string(),
"如果缺少字段:字符串补空字符串。".to_string(),
"不要输出 backstoryReveal、skills、initialItems也不要新增名单外的角色。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n");
}
[
format!("下面这段文本本应是自定义世界{}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
"请只输出修复后的 JSON 对象。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
format!("这个数组里只能保留这些角色名:{}", expected_names.join("")),
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
"每个角色都必须包含name、backstoryReveal、skills、initialItems。".to_string(),
format!("backstoryReveal 必须包含 publicSummary 和 4 个 chapterschapters.affinityRequired 固定为 {}", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("")),
"skills 默认补成 3 个对象,每个对象包含 name、summary、styleinitialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。".to_string(),
"不要输出 backstory、personality、motivation、combatStyle、landmarks也不要新增名单外的角色。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].join("\n")
}
fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String {
let landmark_text = array_field(framework, "landmarks")
.into_iter()
.take(max_landmarks)
.map(|landmark| {
format!(
"{}{}",
json_text(&landmark, "name").unwrap_or_default(),
json_text(&landmark, "description").unwrap_or_default()
)
})
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("");
[
format!("世界:{}", json_text(framework, "name").unwrap_or_default()),
format!(
"副标题:{}",
json_text(framework, "subtitle").unwrap_or_default()
),
format!(
"世界概述:{}",
json_text(framework, "summary").unwrap_or_default()
),
format!(
"世界基调:{}",
json_text(framework, "tone").unwrap_or_default()
),
format!(
"玩家核心目标:{}",
json_text(framework, "playerGoal").unwrap_or_default()
),
json_string_array(framework, "majorFactions")
.map(|items| format!("主要势力:{}", items.join("")))
.unwrap_or_default(),
json_string_array(framework, "coreConflicts")
.map(|items| format!("核心冲突:{}", items.join("")))
.unwrap_or_default(),
format!(
"开局归处:{}{}",
json_path_text(framework, &["camp", "name"]).unwrap_or_default(),
json_path_text(framework, &["camp", "description"]).unwrap_or_default()
),
if landmark_text.is_empty() {
String::new()
} else {
format!("关键场景:{landmark_text}")
},
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn build_role_outline_prompt_text(
role_batch: &[JsonValue],
framework: &JsonValue,
role_type: &str,
) -> String {
role_batch
.iter()
.map(|role| {
let appearance_text = if role_type == "story" {
landmark_names_for_role(
framework,
json_text(role, "name").unwrap_or_default().as_str(),
)
.join("")
} else {
String::new()
};
[
format!(
"- {} / {}",
json_text(role, "name").unwrap_or_default(),
json_text(role, "title").unwrap_or_default()
),
format!("身份:{}", json_text(role, "role").unwrap_or_default()),
format!(
"框架描述:{}",
json_text(role, "description").unwrap_or_default()
),
format!(
"预设好感:{}",
role.get("initialAffinity")
.and_then(JsonValue::as_i64)
.unwrap_or(0)
),
json_string_array(role, "relationshipHooks")
.map(|items| format!("关系切入口:{}", items.join("")))
.unwrap_or_default(),
json_string_array(role, "tags")
.map(|items| format!("标签:{}", items.join("")))
.unwrap_or_default(),
if appearance_text.is_empty() {
String::new()
} else {
format!("出现场景:{appearance_text}")
},
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
})
.collect::<Vec<_>>()
.join("\n")
}
fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec<String> {
array_field(framework, "landmarks")
.into_iter()
.filter_map(|landmark| {
let names = json_string_array(&landmark, "sceneNpcNames").unwrap_or_default();
if names.iter().any(|name| name == role_name) {
json_text(&landmark, "name")
} else {
None
}
})
.collect()
}
fn role_key(role_type: &str) -> &'static str {
if role_type == "playable" {
"playableNpcs"
} else {
"storyNpcs"
}
}
fn array_field(value: &JsonValue, key: &str) -> Vec<JsonValue> {
value
.get(key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default()
}
fn names_from_entries(entries: &[JsonValue]) -> Vec<String> {
entries
.iter()
.filter_map(|entry| json_text(entry, "name"))
.filter(|value| !value.is_empty())
.collect()
}
fn json_text(value: &JsonValue, key: &str) -> Option<String> {
json_path_text(value, &[key])
}
fn json_path_text(value: &JsonValue, path: &[&str]) -> Option<String> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn json_string_array(value: &JsonValue, key: &str) -> Option<Vec<String>> {
let items = value
.get(key)?
.as_array()?
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|entry| !entry.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if items.is_empty() { None } else { Some(items) }
}
fn compact_json_text(value: &JsonValue) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
}

View File

@@ -0,0 +1,4 @@
pub(crate) mod character_animation;
pub(crate) mod character_visual;
pub(crate) mod foundation_draft;
pub(crate) mod scene_background;

View File

@@ -0,0 +1,166 @@
#[derive(Clone, Debug, Default)]
pub(crate) struct SceneImagePromptProfile<'a> {
pub name: &'a str,
pub subtitle: &'a str,
pub tone: &'a str,
pub player_goal: &'a str,
pub summary: &'a str,
pub setting_text: &'a str,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct SceneImagePromptLandmark<'a> {
pub name: &'a str,
pub description: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct SceneImagePromptParams<'a> {
pub profile: SceneImagePromptProfile<'a>,
pub landmark: SceneImagePromptLandmark<'a>,
pub user_prompt: &'a str,
pub has_reference_image: bool,
pub fallback_landmark_name: Option<&'a str>,
pub fallback_world_name: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct SceneActBackgroundPromptParams<'a> {
pub world_name: &'a str,
pub world_tone: &'a str,
pub scene_name: &'a str,
pub title: &'a str,
pub summary: &'a str,
pub act_goal: &'a str,
pub transition_hook: &'a str,
pub primary_role_name: &'a str,
pub support_role_names: Vec<String>,
pub prompt_text: &'a str,
}
pub(crate) const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框人物近景特写多人合照模糊低清晰度畸形建筑现代车辆监控摄像头";
pub(crate) fn build_custom_world_scene_image_prompt(params: SceneImagePromptParams<'_>) -> String {
let world_name = clamp_scene_image_text(
if params.profile.name.trim().is_empty() {
params.fallback_world_name
} else {
params.profile.name
},
18,
);
let world_subtitle = clamp_scene_image_text(params.profile.subtitle, 18);
let world_tone = clamp_scene_image_text(params.profile.tone, 48);
let world_goal = clamp_scene_image_text(params.profile.player_goal, 48);
let world_summary = clamp_scene_image_text(params.profile.summary, 72);
let world_setting = clamp_scene_image_text(params.profile.setting_text, 72);
let landmark_name = clamp_scene_image_text(
if params.landmark.name.trim().is_empty() {
params.fallback_landmark_name.unwrap_or("未命名场景")
} else {
params.landmark.name
},
18,
);
let landmark_description = clamp_scene_image_text(params.landmark.description, 96);
let requested_visual = clamp_scene_image_text(params.user_prompt, 120);
vec![
"为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。".to_string(),
"画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。".to_string(),
"下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。".to_string(),
"下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。".to_string(),
"下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。".to_string(),
if params.has_reference_image {
"已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。".to_string()
} else {
String::new()
},
format!(
"世界:{}{}",
if world_name.is_empty() {
"未命名世界"
} else {
world_name.as_str()
},
if world_subtitle.is_empty() {
String::new()
} else {
format!("{world_subtitle}")
}
),
conditional_prompt_line("玩家设定", world_setting.as_str()),
conditional_prompt_line("世界概述", world_summary.as_str()),
conditional_prompt_line("整体基调", world_tone.as_str()),
conditional_prompt_line("玩家目标关联", world_goal.as_str()),
format!(
"场景名称:{}",
if landmark_name.is_empty() {
"未命名场景"
} else {
landmark_name.as_str()
}
),
conditional_prompt_line("场景描述", landmark_description.as_str()),
conditional_prompt_line("本次想要生成的画面内容", requested_visual.as_str()),
"不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。".to_string(),
]
.into_iter()
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("")
}
pub(crate) fn build_scene_act_background_image_prompt(
params: SceneActBackgroundPromptParams<'_>,
) -> String {
// 幕背景图不是普通地点图,必须把世界、幕目标、过渡钩子和角色关系一起写入图像提示词,
// 同时明确禁止角色立绘和 UI 元素进入背景资产。
[
format!("这是世界《{}》中的场景幕背景图。", params.world_name),
format!("场景:{}", params.scene_name),
format!("幕标题:{}", params.title),
format!("幕摘要:{}", params.summary),
format!("幕目标:{}", params.act_goal),
format!("过渡钩子:{}", params.transition_hook),
format!(
"主角色:{}",
if params.primary_role_name.trim().is_empty() {
"待补主角色"
} else {
params.primary_role_name.trim()
}
),
if params.support_role_names.is_empty() {
String::new()
} else {
format!("辅助角色:{}", params.support_role_names.join(""))
},
format!("世界气质:{}", params.world_tone),
format!("背景描述:{}", params.prompt_text),
"要求:只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字。".to_string(),
]
.into_iter()
.filter(|line| !line.trim().is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn clamp_scene_image_text(value: &str, max_length: usize) -> String {
value
.trim()
.replace(char::is_whitespace, " ")
.chars()
.take(max_length)
.collect::<String>()
.trim()
.to_string()
}
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
if value.is_empty() {
String::new()
} else {
format!("{prefix}{value}")
}
}

View File

@@ -0,0 +1,419 @@
use axum::{
Json,
extract::{Extension, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use platform_llm::{LlmMessage, LlmTextRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
http_error::AppError,
request_context::RequestContext,
runtime_chat_prompt::{
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
NpcChatTurnPromptInput, build_npc_chat_turn_reply_prompt,
build_npc_chat_turn_suggestion_prompt,
},
state::AppState,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NpcChatTurnRequest {
#[serde(default)]
world_type: String,
#[serde(default)]
character: Option<Value>,
#[serde(default)]
player: Option<Value>,
encounter: Value,
#[serde(default)]
monsters: Vec<Value>,
#[serde(default)]
history: Vec<Value>,
#[serde(default)]
context: Value,
#[serde(default)]
conversation_history: Vec<Value>,
#[serde(default)]
dialogue: Vec<Value>,
#[serde(default)]
combat_context: Option<Value>,
player_message: String,
#[serde(default)]
npc_state: Value,
#[serde(default)]
npc_initiates_conversation: bool,
#[serde(default)]
chat_directive: Option<Value>,
}
pub async fn stream_runtime_npc_chat_turn(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<NpcChatTurnRequest>,
) -> Result<Response, Response> {
let npc_name = read_string_field(&payload.encounter, "npcName")
.or_else(|| read_string_field(&payload.encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
let player_message = payload.player_message.trim();
if player_message.is_empty() {
return Err(runtime_chat_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-chat",
"message": "playerMessage 不能为空",
})),
));
}
let llm_result =
generate_llm_npc_chat_turn(&state, &request_context, &payload, &npc_name).await;
let (mut body, npc_reply, suggestions) = match llm_result {
Some(result) => result,
None => {
let npc_reply = build_deterministic_npc_reply(
npc_name.as_str(),
player_message,
payload.npc_initiates_conversation,
);
let suggestions = if should_force_chat_exit(payload.chat_directive.as_ref()) {
Vec::new()
} else {
build_deterministic_chat_suggestions(npc_name.as_str(), player_message)
};
let mut body = String::new();
append_sse_event(
&request_context,
&mut body,
"reply_delta",
&json!({ "text": npc_reply }),
)?;
(body, npc_reply, suggestions)
}
};
let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0);
let affinity_delta =
compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count);
let complete_payload = json!({
"npcReply": npc_reply,
"affinityDelta": affinity_delta,
"affinityText": describe_affinity_shift(affinity_delta),
"suggestions": suggestions,
"pendingQuestOffer": null,
"chatDirective": build_completion_directive(payload.chat_directive.as_ref()),
});
append_sse_event(&request_context, &mut body, "complete", &complete_payload)?;
body.push_str("data: [DONE]\n\n");
Ok(build_event_stream_response(body))
}
async fn generate_llm_npc_chat_turn(
state: &AppState,
request_context: &RequestContext,
payload: &NpcChatTurnRequest,
npc_name: &str,
) -> Option<(String, String, Vec<String>)> {
let llm_client = state.llm_client()?;
let character = payload
.character
.as_ref()
.or(payload.player.as_ref())
.unwrap_or(&Value::Null);
let prompt_input = NpcChatTurnPromptInput {
world_type: payload.world_type.as_str(),
character,
encounter: &payload.encounter,
monsters: &payload.monsters,
history: &payload.history,
context: &payload.context,
conversation_history: &payload.conversation_history,
dialogue: &payload.dialogue,
combat_context: payload.combat_context.as_ref(),
player_message: payload.player_message.as_str(),
npc_state: &payload.npc_state,
npc_initiates_conversation: payload.npc_initiates_conversation,
chat_directive: payload.chat_directive.as_ref(),
};
let mut body = String::new();
let reply_prompt = build_npc_chat_turn_reply_prompt(&prompt_input);
let mut reply_request = LlmTextRequest::new(vec![
LlmMessage::system(NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT),
LlmMessage::user(reply_prompt),
]);
reply_request.max_tokens = Some(700);
reply_request.enable_web_search = state.config.rpg_llm_web_search_enabled;
let reply_response = llm_client
.stream_text(reply_request, |delta| {
let _ = append_sse_event(
request_context,
&mut body,
"reply_delta",
&json!({ "text": delta.accumulated_text }),
);
})
.await
.ok()?;
let npc_reply = normalize_required_text(reply_response.content.as_str()).unwrap_or_else(|| {
build_deterministic_npc_reply(
npc_name,
payload.player_message.as_str(),
payload.npc_initiates_conversation,
)
});
if should_force_chat_exit(payload.chat_directive.as_ref()) {
return Some((body, npc_reply, Vec::new()));
}
let suggestion_prompt =
build_npc_chat_turn_suggestion_prompt(&prompt_input, npc_reply.as_str());
let mut suggestion_request = LlmTextRequest::new(vec![
LlmMessage::system(NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT),
LlmMessage::user(suggestion_prompt),
]);
suggestion_request.max_tokens = Some(200);
suggestion_request.enable_web_search = state.config.rpg_llm_web_search_enabled;
let suggestions = llm_client
.request_text(suggestion_request)
.await
.ok()
.map(|response| parse_line_list_content(response.content.as_str(), 3))
.filter(|items| items.len() == 3)
.unwrap_or_else(|| build_fallback_npc_chat_suggestions(payload.player_message.as_str()));
Some((body, npc_reply, suggestions))
}
fn build_deterministic_npc_reply(
npc_name: &str,
player_message: &str,
npc_initiates_conversation: bool,
) -> String {
// Rust API 尚未迁入旧 Node 的完整 LLM NPC 聊天编排前,先由后端提供稳定兜底,保证相遇与选项聊天链路不断。
if npc_initiates_conversation {
return format!("{npc_name}看向你,先开口说道:“你来了。先别急着走,我正有话想和你说。”");
}
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
}
fn build_deterministic_chat_suggestions(npc_name: &str, player_message: &str) -> Vec<String> {
// 建议只承载玩家可点选的行动意图,不在 UI 里额外塞说明文案。
vec![
format!("继续询问{npc_name}的近况"),
"追问这里发生了什么".to_string(),
if player_message.contains('帮') || player_message.contains('忙') {
"请对方说清需要什么帮助".to_string()
} else {
"换个轻松的话题".to_string()
},
]
}
fn build_fallback_npc_chat_suggestions(player_message: &str) -> Vec<String> {
let topic = player_message.trim().chars().take(8).collect::<String>();
let topic = if topic.is_empty() {
"刚才那句".to_string()
} else {
topic
};
vec![
"你刚才那句是什么意思".to_string(),
format!("这事和{topic}有关吗"),
"你愿意再说清楚点吗".to_string(),
]
}
fn build_completion_directive(chat_directive: Option<&Value>) -> Value {
let Some(directive) = chat_directive else {
return Value::Null;
};
let closing_mode = read_string_field(directive, "closingMode")
.filter(|value| value == "foreshadow_close")
.unwrap_or_else(|| "free".to_string());
let force_exit = closing_mode == "foreshadow_close"
|| directive
.get("forceExitAfterTurn")
.and_then(Value::as_bool)
.unwrap_or(false);
json!({
"turnLimit": directive.get("turnLimit").cloned().unwrap_or(Value::Null),
"remainingTurns": directive.get("remainingTurns").cloned().unwrap_or(Value::Null),
"forceExit": force_exit,
"closingMode": closing_mode,
})
}
fn read_string_field(value: &Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
fn read_number_field(value: &Value, field: &str) -> Option<f64> {
value
.get(field)
.and_then(Value::as_f64)
.filter(|number| number.is_finite())
}
fn should_force_chat_exit(chat_directive: Option<&Value>) -> bool {
let Some(directive) = chat_directive else {
return false;
};
read_string_field(directive, "closingMode").as_deref() == Some("foreshadow_close")
|| directive
.get("forceExitAfterTurn")
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn normalize_required_text(value: &str) -> Option<String> {
let normalized = value.trim();
if normalized.is_empty() {
return None;
}
Some(normalized.to_string())
}
fn parse_line_list_content(text: &str, max_items: usize) -> Vec<String> {
text.replace('\r', "")
.lines()
.map(|line| trim_line_list_marker(line.trim()).trim().to_string())
.filter(|line| !line.is_empty())
.take(max_items)
.collect()
}
fn trim_line_list_marker(line: &str) -> &str {
line.trim_start_matches(|character: char| {
character == '-'
|| character == '*'
|| character.is_ascii_digit()
|| character == '.'
|| character == ')'
|| character.is_whitespace()
})
}
fn count_keyword_matches(text: &str, keywords: &[&str]) -> i32 {
keywords
.iter()
.filter(|keyword| text.contains(**keyword))
.count() as i32
}
fn clamp_affinity_delta(value: i32) -> i32 {
value.clamp(-3, 3)
}
fn compute_npc_chat_affinity_delta(
player_message: &str,
npc_reply: &str,
chatted_count: f64,
) -> i32 {
let positive_keywords = [
"谢谢", "辛苦", "抱歉", "理解", "相信", "放心", "一起", "帮你", "在意", "关心",
];
let negative_keywords = [
"闭嘴",
"",
"少废话",
"威胁",
"",
"不信",
"别装",
"快说",
"审问",
"怀疑",
];
let warm_reply_keywords = ["可以", "愿意", "放心", "谢谢", "明白", ""];
let cold_reply_keywords = ["没必要", "不想", "别问", "与你无关", "算了", "住口"];
let positive_score = count_keyword_matches(player_message.trim(), &positive_keywords)
+ count_keyword_matches(npc_reply.trim(), &warm_reply_keywords);
let negative_score = count_keyword_matches(player_message.trim(), &negative_keywords)
+ count_keyword_matches(npc_reply.trim(), &cold_reply_keywords);
if positive_score == 0 && negative_score == 0 {
return if chatted_count == 0.0 { 1 } else { 0 };
}
if positive_score > negative_score {
let base_delta = positive_score - negative_score + if chatted_count <= 1.0 { 1 } else { 0 };
return clamp_affinity_delta(base_delta);
}
if negative_score > positive_score {
return clamp_affinity_delta(positive_score - negative_score);
}
0
}
fn describe_affinity_shift(affinity_delta: i32) -> &'static str {
if affinity_delta >= 8 {
return "态度明显软化了下来。";
}
if affinity_delta >= 5 {
return "态度比刚才亲近了一些。";
}
if affinity_delta > 0 {
return "对话气氛稍微松动了一点。";
}
if affinity_delta < 0 {
return "这轮对话让气氛变得更紧了一些。";
}
"这轮对话暂时没有带来明显关系变化。"
}
fn append_sse_event(
request_context: &RequestContext,
body: &mut String,
event: &str,
payload: &Value,
) -> Result<(), Response> {
let payload_text = serde_json::to_string(payload).map_err(|error| {
runtime_chat_error_response(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "runtime-chat",
"message": format!("SSE payload 序列化失败:{error}"),
})),
)
})?;
body.push_str("event: ");
body.push_str(event);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload_text);
body.push_str("\n\n");
Ok(())
}
fn build_event_stream_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
],
body,
)
.into_response()
}
fn runtime_chat_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}

View File

@@ -0,0 +1,549 @@
use serde_json::Value;
pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#;
pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
只输出纯文本,共 3 行,每行 1 条。
不要加编号、项目符号、Markdown、JSON 或额外说明。
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。"#;
#[derive(Debug)]
pub(crate) struct NpcChatTurnPromptInput<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub encounter: &'a Value,
pub monsters: &'a [Value],
pub history: &'a [Value],
pub context: &'a Value,
pub conversation_history: &'a [Value],
pub dialogue: &'a [Value],
pub combat_context: Option<&'a Value>,
pub player_message: &'a str,
pub npc_state: &'a Value,
pub npc_initiates_conversation: bool,
pub chat_directive: Option<&'a Value>,
}
pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);
let context = as_record(payload.context);
let npc_state = as_record(payload.npc_state);
let chat_directive = payload.chat_directive.and_then(as_record);
let conversation_history = if !payload.conversation_history.is_empty() {
payload.conversation_history
} else {
payload.dialogue
};
let opening_camp_background =
context.and_then(|record| read_string(record.get("openingCampBackground")));
let opening_camp_dialogue =
context.and_then(|record| read_string(record.get("openingCampDialogue")));
let allowed_topics = context
.and_then(|record| record.get("encounterAllowedTopics"))
.map(read_string_array)
.unwrap_or_default();
let blocked_topics = context
.and_then(|record| record.get("encounterBlockedTopics"))
.map(read_string_array)
.unwrap_or_default();
let is_first_meaningful_contact = context
.and_then(|record| read_bool(record.get("isFirstMeaningfulContact")))
.unwrap_or(false);
let affinity = npc_state
.and_then(|record| read_number(record.get("affinity")))
.unwrap_or(0.0);
let chatted_count = npc_state
.and_then(|record| read_number(record.get("chattedCount")))
.unwrap_or(0.0);
let limit_reason = chat_directive.and_then(|record| read_string(record.get("limitReason")));
let turn_limit = chat_directive
.and_then(|record| read_number(record.get("turnLimit")))
.unwrap_or(0.0)
.max(0.0);
let remaining_turns = chat_directive
.and_then(|record| read_number(record.get("remainingTurns")))
.unwrap_or(0.0)
.max(0.0);
let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode")));
let is_limited_negative_affinity_chat =
limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0;
let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close")
|| chat_directive
.and_then(|record| read_bool(record.get("forceExitAfterTurn")))
.unwrap_or(false);
let has_npc_reply_in_history = conversation_history.iter().any(|item| {
as_record(item)
.and_then(|turn| read_string(turn.get("speaker")))
.is_some_and(|speaker| speaker == "npc")
});
let is_first_npc_spoken_turn =
is_first_meaningful_contact && !has_npc_reply_in_history && chatted_count <= 0.0;
let first_contact_relation_stance = describe_first_contact_relation_stance(
context.and_then(|record| record.get("firstContactRelationStance")),
);
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
[
Some(build_npc_dialogue_prompt_base(payload)),
Some(describe_npc_conversation_history(
conversation_history,
encounter.npc_name.as_str(),
)),
combat_context_block,
opening_camp_background.map(|text| format!("营地开场背景:{text}")),
opening_camp_dialogue.map(|text| format!("刚刚发生的第一段对话:{text}")),
Some(format!("当前关系值:{}", format_prompt_number(affinity))),
Some(format!("已聊天轮次:{}", format_prompt_number(chatted_count))),
if is_first_npc_spoken_turn {
Some(format!(
"当前接触阶段:第一次真正接触({first_contact_relation_stance})。这是这次聊天里 {} 第一次真正对玩家开口。",
encounter.npc_name
))
} else {
None
},
if is_first_npc_spoken_turn {
Some("第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。".to_string())
} else {
None
},
if is_first_npc_spoken_turn {
Some("不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。".to_string())
} else {
None
},
if payload.npc_initiates_conversation {
Some(format!(
"当前要求:这是 {} 主动开口的第一句,不要假装玩家已经先说过话。",
encounter.npc_name
))
} else {
None
},
if allowed_topics.is_empty() {
None
} else {
Some(format!("当前更适合先谈:{}", allowed_topics.join("")))
},
if blocked_topics.is_empty() {
None
} else {
Some(format!("当前避免直接说破:{}", blocked_topics.join("")))
},
if is_limited_negative_affinity_chat {
Some(format!(
"当前相遇属于负好感主角色有限聊天,本次总上限 {} 轮。",
format_prompt_number(turn_limit)
))
} else {
None
},
if is_limited_negative_affinity_chat {
Some(format!(
"在你回复完这一轮之后,还剩 {} 轮可以继续聊。",
format_prompt_number(remaining_turns)
))
} else {
None
},
if is_limited_negative_affinity_chat && !is_foreshadow_close_turn {
Some("语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。".to_string())
} else {
None
},
if payload.npc_initiates_conversation {
Some("玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。".to_string())
} else {
Some(format!("玩家刚刚说:{}", payload.player_message.trim()))
},
if payload.npc_initiates_conversation {
Some(format!(
"现在请只写 {} 主动开口时会说的话。",
encounter.npc_name
))
} else {
Some(format!(
"现在请只写 {} 这一轮会回复玩家的话。",
encounter.npc_name
))
},
]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
pub(crate) fn build_npc_chat_turn_suggestion_prompt(
payload: &NpcChatTurnPromptInput<'_>,
npc_reply: &str,
) -> String {
let encounter = describe_encounter(payload.encounter);
let conversation_history = if !payload.conversation_history.is_empty() {
payload.conversation_history
} else {
payload.dialogue
};
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
[
Some(build_npc_dialogue_prompt_base(payload)),
Some(describe_npc_conversation_history(
conversation_history,
encounter.npc_name.as_str(),
)),
combat_context_block,
Some(format!("玩家刚刚说:{}", payload.player_message)),
Some(format!("NPC 刚刚回复:{npc_reply}")),
Some("请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。".to_string()),
Some("每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议。".to_string()),
Some("每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。".to_string()),
]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);
[
format!("世界:{}", describe_world(payload.world_type)),
describe_scene_context(payload.context),
describe_character("玩家 / ", payload.character),
encounter.block,
describe_monsters(payload.monsters),
describe_story_history(payload.history),
]
.into_iter()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
struct EncounterDescription {
npc_name: String,
block: String,
}
fn describe_encounter(encounter: &Value) -> EncounterDescription {
let record = as_record(encounter);
let npc_name = record
.and_then(|item| read_string(item.get("npcName")))
.unwrap_or_else(|| "眼前角色".to_string());
let context_text = record
.and_then(|item| read_string(item.get("context")))
.or_else(|| record.and_then(|item| read_string(item.get("npcDescription"))))
.unwrap_or_else(|| "你们正在当前遭遇里继续对话。".to_string());
EncounterDescription {
npc_name: npc_name.clone(),
block: format!("当前对象:{npc_name}\n对象背景:{context_text}"),
}
}
fn describe_first_contact_relation_stance(value: Option<&Value>) -> String {
match value.and_then(|item| item.as_str()).map(str::trim) {
Some("guarded") => "戒备试探".to_string(),
Some("neutral") => "正常交流但仍不熟".to_string(),
Some("cooperative") => "已有善意,先确认合作节奏".to_string(),
Some("bonded") => "明显信任,但仍是第一次正式对上人".to_string(),
_ => "第一次真正接触".to_string(),
}
}
fn describe_world(world_type: &str) -> String {
match world_type {
"WUXIA" => "边城模板".to_string(),
"XIANXIA" => "灵潮模板".to_string(),
"CUSTOM" => "自定义世界".to_string(),
value if !value.trim().is_empty() => value.to_string(),
_ => "未知世界".to_string(),
}
}
fn describe_stats(label: &str, record: Option<&serde_json::Map<String, Value>>) -> String {
let hp = record
.and_then(|item| read_number(item.get("hp")))
.unwrap_or(0.0);
let max_hp = record
.and_then(|item| read_number(item.get("maxHp")))
.unwrap_or(hp)
.max(1.0);
let mana = record
.and_then(|item| read_number(item.get("mana")))
.unwrap_or(0.0);
let max_mana = record
.and_then(|item| read_number(item.get("maxMana")))
.unwrap_or(mana)
.max(1.0);
format!(
"{label}生命 {}/{},灵力 {}/{}",
format_prompt_number(hp),
format_prompt_number(max_hp),
format_prompt_number(mana),
format_prompt_number(max_mana)
)
}
fn describe_character(label: &str, value: &Value) -> String {
let record = as_record(value);
let name = record
.and_then(|item| read_string(item.get("name")))
.unwrap_or_else(|| "未知角色".to_string());
let title = record
.and_then(|item| read_string(item.get("title")))
.unwrap_or_else(|| "未知称号".to_string());
let description = record
.and_then(|item| read_string(item.get("description")))
.unwrap_or_else(|| "暂无额外描述".to_string());
let personality = record
.and_then(|item| read_string(item.get("personality")))
.unwrap_or_else(|| "性格信息未显式提供".to_string());
[
format!("{label}姓名:{name}"),
format!("{label}称号:{title}"),
format!("{label}描述:{description}"),
format!("{label}性格:{personality}"),
]
.join("\n")
}
fn describe_story_history(history: &[Value]) -> String {
if history.is_empty() {
return "近期剧情:暂无。".to_string();
}
let lines = history
.iter()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text"))))
.collect::<Vec<_>>();
if lines.is_empty() {
"近期剧情:暂无。".to_string()
} else {
let mut result = vec!["近期剧情:".to_string()];
result.extend(lines.into_iter().map(|line| format!("- {line}")));
result.join("\n")
}
}
fn describe_npc_conversation_history(history: &[Value], npc_name: &str) -> String {
if history.is_empty() {
return "当前聊天记录:暂无。".to_string();
}
let lines = history
.iter()
.rev()
.take(10)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| {
let record = as_record(item)?;
let speaker = read_string(record.get("speaker"));
let speaker_name = read_string(record.get("speakerName"));
let text = read_string(record.get("text"))?;
match speaker.as_deref() {
Some("player") => Some(format!("- 玩家:{text}")),
Some("npc") => Some(format!(
"- {}{text}",
speaker_name.unwrap_or_else(|| npc_name.to_string())
)),
Some("system") => Some(format!("- 系统提示:{text}")),
_ => Some(format!(
"- {}{text}",
speaker_name.unwrap_or_else(|| "同伴".to_string())
)),
}
})
.collect::<Vec<_>>();
if lines.is_empty() {
"当前聊天记录:暂无。".to_string()
} else {
let mut result = vec!["当前聊天记录:".to_string()];
result.extend(lines);
result.join("\n")
}
}
fn describe_npc_combat_context(combat_context: &Value) -> Option<String> {
let record = as_record(combat_context)?;
let summary = read_string(record.get("summary"));
let battle_outcome = read_string(record.get("battleOutcome"));
let log_lines = record
.get("logLines")
.map(read_string_array)
.unwrap_or_default()
.into_iter()
.take(6)
.collect::<Vec<_>>();
if summary.is_none() && log_lines.is_empty() {
return None;
}
let outcome_text = match battle_outcome.as_deref() {
Some("spar_complete") => Some("切磋刚刚结束。".to_string()),
Some("victory") => Some("战斗刚刚分出胜负。".to_string()),
_ => None,
};
let mut lines = vec!["刚刚结束的交锋:".to_string()];
if let Some(text) = outcome_text {
lines.push(text);
}
if let Some(text) = summary {
lines.push(format!("- 结果摘要:{text}"));
}
if !log_lines.is_empty() {
lines.push("- 战斗日志:".to_string());
lines.extend(log_lines.into_iter().map(|line| format!(" - {line}")));
}
Some(lines.join("\n"))
}
fn describe_scene_context(context: &Value) -> String {
let record = as_record(context);
let scene_name = record
.and_then(|item| read_string(item.get("sceneName")))
.unwrap_or_else(|| "当前区域".to_string());
let scene_description = record
.and_then(|item| read_string(item.get("sceneDescription")))
.unwrap_or_else(|| "周围气氛仍未完全安定。".to_string());
let in_battle = if record
.and_then(|item| read_bool(item.get("inBattle")))
.unwrap_or(false)
{
"战斗中"
} else {
"非战斗"
};
let custom_world_profile = record
.and_then(|item| item.get("customWorldProfile"))
.and_then(as_record);
let custom_world_name = custom_world_profile.and_then(|item| read_string(item.get("name")));
let custom_world_summary =
custom_world_profile.and_then(|item| read_string(item.get("summary")));
[
Some(format!(
"世界补充:{}",
custom_world_name.unwrap_or_else(|| "".to_string())
)),
custom_world_summary.map(|text| format!("世界摘要:{text}")),
Some(format!("场景:{scene_name}")),
Some(format!("场景描述:{scene_description}")),
Some(format!("当前状态:{in_battle}")),
Some(describe_stats("玩家", record)),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n")
}
fn describe_monsters(monsters: &[Value]) -> String {
if monsters.is_empty() {
return "当前敌对目标:无。".to_string();
}
let lines = monsters
.iter()
.take(4)
.filter_map(|item| {
let record = as_record(item)?;
let name = read_string(record.get("name"))
.or_else(|| read_string(record.get("npcName")))
.or_else(|| read_string(record.get("id")))?;
let hp = read_number(record.get("hp")).unwrap_or(0.0);
let max_hp = read_number(record.get("maxHp")).unwrap_or(hp).max(1.0);
Some(format!(
"- {name}(生命 {}/{})",
format_prompt_number(hp),
format_prompt_number(max_hp)
))
})
.collect::<Vec<_>>();
if lines.is_empty() {
"当前敌对目标:无。".to_string()
} else {
let mut result = vec!["当前敌对目标:".to_string()];
result.extend(lines);
result.join("\n")
}
}
fn read_string(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
fn read_number(value: Option<&Value>) -> Option<f64> {
value
.and_then(Value::as_f64)
.filter(|number| number.is_finite())
}
fn read_bool(value: Option<&Value>) -> Option<bool> {
value.and_then(Value::as_bool)
}
fn read_string_array(value: &Value) -> Vec<String> {
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| read_string(Some(item)))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn as_record(value: &Value) -> Option<&serde_json::Map<String, Value>> {
value.as_object()
}
fn format_prompt_number(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
value.to_string()
}
}