init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,433 @@
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::custom_world_agent_turn::{
EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState,
PromptUserInputSignal,
};
use module_custom_world::empty_agent_anchor_content_json;
use serde_json::Value as JsonValue;
pub(crate) const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
1. 当前完整设定结构
2. 用户聊天记录
然后输出:
1. 一版新的完整设定结构
2. 当前 progress 百分比
3. 一段直接回复用户的话
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
你的输出会直接覆盖上一版设定结构。
你不是在做局部 patch。
你不是在做解释报告。
你不是在给开发者写分析。
你是在同时完成:
1. 世界设定更新
2. 当前推进程度判断
3. 对用户的共创回复"#;
pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
1. 必须输出完整的设定结构,而不是只输出变化部分。
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
5. progressPercent 最低为 0不允许为负数。
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
11. 你输出的 JSON 必须可以被直接解析。
12. 输出字段顺序必须固定为replyText、progressPercent、nextAnchorContent。"#;
pub(crate) fn quick_fill_extra_rules() -> String {
render_quick_fill_extra_rules(
"当前 RPG 世界方向里的剩余设定",
"不要要求用户再提供世界观、角色、冲突或禁忌信息",
"直接输出一版尽量完整的设定结构",
"进入“生成游戏设定草稿”",
)
}
pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
你必须综合以下信息判断:
1. 当前轮次 currentTurn
2. 当前完成度 progressPercent
3. 用户是否要求自动补全 quickFillRequested
4. 当前完整设定结构
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
你需要输出 4 个字段:
1. userInputSignal只能是 rich / normal / sparse / correction / delegate
2. driftRisk只能是 low / medium / high
3. conversationMode只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
4. judgementSummary1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
请按下面的语义判断。
一、userInputSignal 定义
1. rich
- 用户这一轮给了多条可直接落地的有效信息
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
- 正式生成时应优先高密度吸收,不要只更新一个点
2. normal
- 用户在顺着当前方向做正常补充
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
- 正式生成时应稳定推进并自然接住用户内容
3. sparse
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
4. correction
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
- correction 的优先级高于 rich 和 normal
5. delegate
- 用户把部分决定权交给系统
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
- delegate 关注的是授权关系,不只是信息多寡
二、driftRisk 定义
1. low
- 当前轮输入与已有方向基本一致
- 没有明显改口或冲突
2. medium
- 当前轮带来一定方向变化或扩张
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
3. high
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
- 这时最重要的是防止旧方向重新回流到正式生成结果里
三、conversationMode 选择原则
1. bootstrap
- 适用于前期、信息少、核心方向未稳定
- replyText 更适合低压力确认和单点启发
2. expand
- 适用于方向已成形,正在顺着现有路线继续补充
- replyText 更适合总结已接住的内容并往前推一步
3. compress
- 适用于中后段,已有骨架,需要开始收束
- replyText 更适合聚焦最关键缺口,而不是继续开支线
4. repair_direction
- 适用于用户正在纠偏
- replyText 更适合先承认修正,再沿修正后的方向继续推进
5. force_complete
- 适用于用户明确要求自动补全
- replyText 不再提问,而应给出完成感和下一步引导
6. closing
- 适用于接近完成但并非强制一键补全
- replyText 更像确认与收束,而不是前期式探索
四、优先级规则
1. 如果 quickFillRequested 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 repair_direction
3. 如果用户核心意图是授权系统替他补完userInputSignal 优先判为 delegate
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
五、关于 replyText 风格的专门判断要求
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
4. 如果用户输入已经足够 rich就不要再机械提问优先吸收和推进
5. 如果用户在 correction 或 delegate 状态下replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
六、关于 replyText 用语的硬约束
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
七、关于 judgementSummary 的写法
1. 必须简洁,不要写成长篇分析
2. 必须直接服务于下一轮正式生成
3. 最好同时包含两层信息:
- 为什么这么判断
- 正式生成时最该优先做什么,或最该避免什么
八、硬性约束
1. 只能输出 JSON不能输出解释、代码块或额外说明
2. 不能发明上下文里不存在的设定事实
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
5. judgementSummary 必须是中文
6. 输出值必须严格落在给定枚举中"#;
pub(crate) const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"userInputSignal": "normal",
"driftRisk": "low",
"conversationMode": "expand",
"judgementSummary": ""
}"#;
pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": "",
"playerFantasy": "",
"themeBoundary": "",
"playerEntryPoint": "",
"coreConflict": "",
"keyRelationships": "",
"hiddenLines": "",
"iconicElements": ""
}
}
nextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null不允许输出对象或数组。
请把每个锚点写成一段凝练中文:
- worldPromise 关注世界钩子、差异点、玩家体验。
- playerFantasy 关注玩家身份、核心追求、失去风险。
- themeBoundary 关注主题气质、美术方向、禁用方向。
- playerEntryPoint 关注开局身份、开局问题、行动动机。
- coreConflict 关注表层冲突、隐藏危机、首次触发点。
- keyRelationships 关注关键人物关系、关系类型、代价或秘密。
- hiddenLines 关注隐藏真相、误导线索、揭示节奏。
- iconicElements 关注标志意象、组织/物件、硬规则。
"#;
pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
format!(
"上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}",
dynamic_state.user_input_signal.as_str(),
dynamic_state.drift_risk.as_str(),
dynamic_state.conversation_mode.as_str(),
dynamic_state.judgement_summary
)
}
pub(crate) fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String {
format!(
"当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}",
serde_json::to_string_pretty(anchor_content)
.unwrap_or_else(|_| empty_agent_anchor_content_json())
)
}
pub(crate) fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
format!(
"以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}",
serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string())
)
}
pub(crate) fn parse_user_input_signal(value: Option<&JsonValue>) -> Option<PromptUserInputSignal> {
match value.and_then(JsonValue::as_str)? {
"rich" => Some(PromptUserInputSignal::Rich),
"normal" => Some(PromptUserInputSignal::Normal),
"sparse" => Some(PromptUserInputSignal::Sparse),
"correction" => Some(PromptUserInputSignal::Correction),
"delegate" => Some(PromptUserInputSignal::Delegate),
_ => None,
}
}
pub(crate) fn parse_drift_risk(value: Option<&JsonValue>) -> Option<PromptDriftRisk> {
match value.and_then(JsonValue::as_str)? {
"low" => Some(PromptDriftRisk::Low),
"medium" => Some(PromptDriftRisk::Medium),
"high" => Some(PromptDriftRisk::High),
_ => None,
}
}
pub(crate) fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversationMode> {
match value.and_then(JsonValue::as_str)? {
"bootstrap" => Some(PromptConversationMode::Bootstrap),
"expand" => Some(PromptConversationMode::Expand),
"compress" => Some(PromptConversationMode::Compress),
"repair_direction" => Some(PromptConversationMode::RepairDirection),
"force_complete" => Some(PromptConversationMode::ForceComplete),
"closing" => Some(PromptConversationMode::Closing),
_ => None,
}
}
pub(crate) fn mode_rules(mode: PromptConversationMode) -> &'static str {
match mode {
PromptConversationMode::Bootstrap => {
r#"当前模式bootstrap
目标:
1. 先把世界的基本方向抓住
2. 不要一次塞太多新设定
3. 回复要降低用户开口压力
本轮行为要求:
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
2. 如果用户信息很少,不要强行把整套结构一次补满
3. replyText 要像共创搭档,而不是像审问
4. 默认只推进一个最关键的问题方向
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
7. 不要把问题问得像表单采集,不要一口气追问多个维度
用户体验要求:
1. 让用户觉得“现在很容易继续往下说”
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
3. replyText 最好短、稳、可接话
4. 如果用户信息很少,也不要显得冷淡或机械"#
}
PromptConversationMode::Expand => {
r#"当前模式expand
目标:
1. 在保持现有方向的前提下,把设定结构逐步补全
2. 尽量让一轮输入覆盖多个关键维度
本轮行为要求:
1. 继续保留上一版里仍成立的设定
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
3. replyText 要明确体现“你已经理解了哪些内容”
4. 不要突然大幅改写已经成形的世界
5. 如果用户这一轮给了多条有效信息replyText 应先把这些信息自然串起来,再决定下一步
6. 可以适度替用户整理,但不要把回复写成总结报告
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
用户体验要求:
1. 让用户感到“我刚说的内容都被接住了”
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
3. 不要无视用户刚提供的高价值细节
4. 不要让用户觉得系统在自顾自重写世界"#
}
PromptConversationMode::Compress => {
r#"当前模式compress
目标:
1. 开始收束当前设定
2. 减少无效发散
3. 让 progress 更接近可进入下一阶段
本轮行为要求:
1. 新的设定结构优先保留稳定内容,不要无端重写
2. 对用户本轮输入做高密度吸收
3. replyText 要更聚焦,不要绕圈
4. 默认只推进当前最影响 completion 的一步
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
7. 如果已有信息足够replyText 可以更像“确认并收束”,少一点继续发散式追问
用户体验要求:
1. 让用户感觉世界正在变得更稳,而不是越来越散
2. 让推进感更明确,但不要显得催促
3. 回复语气应更笃定一些,减少反复横跳
4. 不要把用户刚补进来的细节又冲淡掉"#
}
PromptConversationMode::RepairDirection => {
r#"当前模式repair_direction
目标:
1. 处理用户对既有设定的修正
2. 避免世界方向飘散或自相矛盾
本轮行为要求:
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
2. 对已经不再成立的旧设定,不要机械保留
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
5. 先处理“改掉什么”,再决定“往哪里继续推”
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
7. 如果修正幅度很大replyText 可以帮助用户确认新方向已经接管当前语境
用户体验要求:
1. 让用户感到“我刚刚的纠偏真的生效了”
2. 不要和用户辩论旧方案为什么也行
3. 不要表现出对修正的不情愿
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
}
PromptConversationMode::ForceComplete => {
r#"当前模式force_complete
目标:
1. 基于当前方向直接补齐剩余设定
2. 生成一版尽量完整、可进入下一阶段的设定结构
3. 结束当前收集阶段
本轮行为要求:
1. 尽量保留已经形成的世界方向
2. 对明显缺失的关键维度进行合理补全
3. 不要继续拉长聊天,不要再追问用户
4. progressPercent 直接输出为 100
5. replyText 要自然引导用户点击“生成游戏设定草稿”
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
用户体验要求:
1. 让用户感到“系统已经帮我把能补的补好了”
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
3. 回复要有完成感,但不要太官话
4. 清楚告诉用户下一步可以做什么"#
}
PromptConversationMode::Closing => {
r#"当前模式closing
目标:
1. 尽量形成一版可用的设定底子
2. 不再继续发散新世界观
本轮行为要求:
1. 优先收束,而不是扩写
2. 不要大改已经成形的核心设定
3. progressPercent 接近完成时replyText 要更像确认与推进
4. 如果用户没有大改方向,尽量让下一版内容更稳定
5. 可以轻微补足缺口,但不要再大开新支线
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
用户体验要求:
1. 让用户感觉作品已经快成了,而不是还在无穷试探
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
3. 保持留白感,不要把所有东西都一次说死
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
}
}
}
pub(crate) fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
match signal {
PromptUserInputSignal::Rich => {
r#"本轮用户输入信息密度高。
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
}
PromptUserInputSignal::Normal => {
r#"本轮用户输入为正常补充。
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
}
PromptUserInputSignal::Sparse => {
r#"本轮用户输入较少或较虚。
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
replyText 要让用户容易继续往下说。"#
}
PromptUserInputSignal::Correction => {
r#"本轮用户在修正或推翻旧设定。
请优先吸收修正,不要机械复读旧版本。
新的完整设定结构必须以修正后的方向为准。"#
}
PromptUserInputSignal::Delegate => {
r#"本轮用户把部分决定权交给你。
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
}
}
}

View File

@@ -0,0 +1,294 @@
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(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景等场景内容。".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,96 @@
/// 自定义世界角色主图提示词脚本。
pub(crate) fn build_character_visual_prompt(prompt_text: &str) -> String {
build_master_prompt(prompt_text.trim())
}
/// 角色主图被供应商内容审核拦截时使用的安全兜底提示词。
///
/// 这里刻意不继续携带角色姓名、作品名和长设定文本,避免把可疑专名原样送回上游导致连续失败。
pub(crate) fn build_fallback_moderation_safe_character_visual_prompt(prompt_text: &str) -> String {
let archetype = resolve_original_role_archetype(prompt_text);
build_master_prompt(
[
format!("角色定位:{}", archetype),
"原创奇幻冒险角色,成年类人骨架,站姿稳定,表情中性,服装为无品牌旅行装、轻甲或职业装备的原创组合。".to_string(),
"不参考任何现有动漫、游戏、影视、小说角色,不使用可识别 IP 元素、商标、队徽、作品名、角色名或知名角色标志性发型服装。".to_string(),
"所有图案、配色、武器、饰品都采用原创通用设计,只保留横版像素动作角色所需的清晰轮廓和可读职业特征。".to_string(),
]
.join("\n")
.as_str(),
)
}
fn resolve_original_role_archetype(source: &str) -> &'static str {
if source.contains("法师") || source.contains("魔法") || source.contains("术士") {
return "原创法术职业冒险者";
}
if source.contains("骑士") || source.contains("守卫") || source.contains("圣骑") {
return "原创重装守护者";
}
if source.contains("") || source.contains("猎人") || source.contains("游侠") {
return "原创远程游侠";
}
if source.contains("刺客") || source.contains("盗贼") || source.contains("潜行") {
return "原创敏捷潜行者";
}
if source.contains("") || source.contains("战士") || source.contains("武士") {
return "原创近战剑士";
}
if source.contains("祭司") || source.contains("牧师") || source.contains("治疗") {
return "原创支援祭司";
}
"原创冒险者"
}
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
fn build_master_prompt(character_brief: &str) -> String {
[
"单人2D像素角色形象头身比必须控制在 1.5 到 2 头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".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 元素",
"厚涂插画感",
"低对比柔边",
]
.join("")
}

View File

@@ -0,0 +1,502 @@
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(),
" \"attributeSchema\": {".to_string(),
" \"schemaName\": \"本世界六维名称\",".to_string(),
" \"slots\": [".to_string(),
" { \"slotId\": \"axis_a\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_b\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_c\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_d\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_e\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
" { \"slotId\": \"axis_f\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" }".to_string(),
" ]".to_string(),
" },".to_string(),
" \"camp\": {".to_string(),
" \"name\": \"开局归处名称\",".to_string(),
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
" \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(),
" \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(),
" }".to_string(),
"}".to_string(),
"".to_string(),
"要求:".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
"- 这一步只输出顶层 10 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应第 1/2/3 幕背景图画面内容描述;每条必须基于同序号 actEventDescriptions 和相关角色写出画面主体、站位空间、冲突痕迹与氛围,能直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
"- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。".to_string(),
"- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。".to_string(),
"- attributeSchema 必须是本世界专属的角色六维属性体系slots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
"- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(),
"- 每个属性维度都要同时能服务战斗、社交、探索三种场景definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题。".to_string(),
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
"- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。".to_string(),
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
].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、attributeSchema、camp。",
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
"majorFactions 与 coreConflicts 必须是字符串数组。",
"attributeSchema 必须是对象,且包含 schemaName 与 slotsslots 必须恰好 6 个slotId 固定为 axis_a 到 axis_f。",
"camp 必须是对象且包含name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。",
"原始文本:",
response_text.trim(),
].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 {
let story_npc_names = names_from_entries(&array_field(framework, "storyNpcs"));
[
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
"这一步必须一次性生成场景骨架、地点默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string(),
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
"世界核心信息:".to_string(),
build_framework_summary_text(framework, 0),
if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("")) },
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("")) },
"".to_string(),
"输出 JSON 模板:".to_string(),
"{".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(),
" \"actNPCNames\": [\"第一幕主场景角色名\", \"第二幕主场景角色名\", \"第三幕主场景角色名\"],".to_string(),
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".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、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actNPCNames 只能引用上方可用场景角色名单中的名字,表示第 1/2/3 幕各自的主场景角色;如果名单为空,输出空数组。".to_string(),
"- 可用场景角色名单非空时actNPCNames 必须恰好 3 个;可以重复使用同一角色,但每一项都必须服务对应幕事件。".to_string(),
"- actNPCNames[n] 会成为第 n+1 幕对面主角色;三幕事件和幕背景必须围绕对应角色的行动、阻碍、试探或求助展开。".to_string(),
"- connectedLandmarkNames 优先引用本批或已知关键场景名称,每个地点 1 到 3 个;只有 1 个地点时可以输出空数组。".to_string(),
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须基于同序号 actEventDescriptions、当前地点和可出场角色直接写出画面主体、站位空间、冲突痕迹与氛围控制在 40 到 90 个汉字内。".to_string(),
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景玩家会在……”这类标题、摘要、规则句拼接格式必须像可直接交给生图模型的自然画面描述。".to_string(),
"- 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、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
"如果缺少字段字符串补空字符串actBackgroundPromptTexts、actEventDescriptions、actNPCNames 和 connectedLandmarkNames 补空数组。".to_string(),
"不要输出 items 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_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, "actNPCNames")
.or_else(|| 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) }
}

View File

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

View File

@@ -0,0 +1,114 @@
use serde_json::{Value, json};
#[derive(Clone, Debug)]
pub(crate) struct RuntimeStoryTextPromptParams<'a> {
pub world_type: &'a str,
pub character: Value,
pub monsters: Value,
pub history: Value,
pub choice: Value,
pub context: Value,
pub available_options: Value,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeNpcDialoguePromptParams<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub encounter: &'a Value,
pub monsters: Vec<Value>,
pub history: Vec<Value>,
pub context: Value,
pub topic: &'a str,
pub result_summary: &'a str,
pub requested_option: Value,
pub available_options: Vec<Value>,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeReasonedStoryPromptParams<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub monsters: Vec<Value>,
pub history: Vec<Value>,
pub context: Value,
pub choice: &'a str,
pub result_summary: &'a str,
pub requested_option: Value,
pub available_options: Vec<Value>,
}
pub(crate) fn runtime_story_director_system_prompt(initial: bool) -> &'static str {
if initial {
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。"
} else {
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。"
}
}
pub(crate) fn build_runtime_story_director_user_prompt(
params: RuntimeStoryTextPromptParams<'_>,
) -> String {
json!({
"worldType": params.world_type,
"character": params.character,
"monsters": params.monsters,
"history": params.history,
"choice": params.choice,
"context": params.context,
"availableOptions": params.available_options,
})
.to_string()
}
pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str {
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。"
}
pub(crate) fn build_runtime_npc_dialogue_user_prompt(
npc_name: &str,
params: RuntimeNpcDialoguePromptParams<'_>,
) -> String {
let state_prompt = json!({
"worldType": params.world_type,
"character": params.character,
"encounter": params.encounter,
"monsters": params.monsters,
"history": params.history,
"context": params.context,
"topic": params.topic,
"resultSummary": params.result_summary,
"requestedOption": params.requested_option,
"availableOptions": params.available_options,
})
.to_string();
format!(
"请基于以下运行时状态,把玩家这一轮选择改写成 2 到 5 行可直接展示的 NPC 对话。可以使用“你:”和“{npc_name}:”格式,必须保留既有结算含义。\n{state_prompt}"
)
}
pub(crate) fn runtime_reasoned_story_system_prompt() -> &'static str {
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态不要发明额外奖励。"
}
pub(crate) fn build_runtime_reasoned_story_user_prompt(
params: RuntimeReasonedStoryPromptParams<'_>,
) -> String {
let state_prompt = json!({
"worldType": params.world_type,
"character": params.character,
"monsters": params.monsters,
"history": params.history,
"context": params.context,
"choice": params.choice,
"resultSummary": params.result_summary,
"requestedOption": params.requested_option,
"availableOptions": params.available_options,
})
.to_string();
format!(
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{state_prompt}"
)
}

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}")
}
}