1
This commit is contained in:
389
server-rs/crates/api-server/src/prompt/big_fish.rs
Normal file
389
server-rs/crates/api-server/src/prompt/big_fish.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
use module_big_fish::BigFishAnchorPack;
|
||||
use serde_json::Value as JsonValue;
|
||||
use spacetime_client::{
|
||||
BigFishAgentMessageRecord, BigFishAnchorPackRecord, BigFishGameDraftRecord,
|
||||
BigFishLevelBlueprintRecord, BigFishSessionRecord,
|
||||
};
|
||||
|
||||
use crate::creation_agent_anchor_templates::{
|
||||
get_creation_agent_anchor_template, render_anchor_question_block,
|
||||
};
|
||||
use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||
|
||||
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
|
||||
|
||||
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。
|
||||
|
||||
你必须同时输出:
|
||||
1. 一段直接发给用户的中文回复 replyText
|
||||
2. 当前进度 progressPercent
|
||||
3. 下一轮完整可用的 nextAnchorPack
|
||||
|
||||
硬约束:
|
||||
1. 只能输出 JSON,不能输出代码块或解释
|
||||
2. nextAnchorPack 必须是完整对象,不能只输出 patch
|
||||
3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词
|
||||
4. replyText 一次最多推进一个最关键问题
|
||||
5. 必须对齐 RPG 共创的体验:先理解玩家幻想,再收束成能进入运行时的可玩效果
|
||||
6. progressPercent 范围只能是 0 到 100
|
||||
7. status 只能使用 missing / inferred / confirmed / locked
|
||||
"#;
|
||||
|
||||
const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
|
||||
{
|
||||
"replyText": "",
|
||||
"progressPercent": 0,
|
||||
"nextAnchorPack": {
|
||||
"gameplayPromise": {
|
||||
"key": "gameplayPromise",
|
||||
"label": "玩法承诺",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"ecologyVisualTheme": {
|
||||
"key": "ecologyVisualTheme",
|
||||
"label": "生态视觉主题",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"growthLadder": {
|
||||
"key": "growthLadder",
|
||||
"label": "成长阶梯",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
},
|
||||
"riskTempo": {
|
||||
"key": "riskTempo",
|
||||
"label": "风险节奏",
|
||||
"value": "",
|
||||
"status": "missing"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// 大鱼吃小鱼草稿生成对话提示词脚本。
|
||||
///
|
||||
/// 这里单独承载 Agent 共创阶段的 system prompt 与 user prompt 组装,
|
||||
/// 避免聊天契约、草稿编译路由和结果页资产生成混在同一个业务文件里。
|
||||
pub(crate) fn build_big_fish_agent_prompt(
|
||||
session: &BigFishSessionRecord,
|
||||
quick_fill_requested: bool,
|
||||
) -> String {
|
||||
let anchor_question_block = get_creation_agent_anchor_template("big_fish")
|
||||
.map(render_anchor_question_block)
|
||||
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
|
||||
let quick_fill_rules = if quick_fill_requested {
|
||||
format!(
|
||||
"\n\n{}",
|
||||
render_quick_fill_extra_rules(
|
||||
"当前玩法方向里的成长、生态、风险节奏等缺失关键词",
|
||||
"不要要求用户再提供等级、鱼群、场景或节奏信息",
|
||||
"输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项",
|
||||
"生成结果页",
|
||||
)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
|
||||
anchor_question_block = anchor_question_block,
|
||||
quick_fill_rules = quick_fill_rules,
|
||||
turn = session.current_turn.saturating_add(1),
|
||||
progress = session.progress_percent,
|
||||
quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" },
|
||||
anchor_pack = serialize_record_anchor_pack(&session.anchor_pack),
|
||||
chat_history =
|
||||
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
contract = BIG_FISH_AGENT_OUTPUT_CONTRACT,
|
||||
)
|
||||
}
|
||||
|
||||
/// 大鱼吃小鱼主图生成提示词脚本。
|
||||
pub(crate) fn build_big_fish_level_main_image_prompt(
|
||||
_draft: &BigFishGameDraftRecord,
|
||||
level: &BigFishLevelBlueprintRecord,
|
||||
) -> String {
|
||||
vec![
|
||||
"生成角色形象图片。".to_string(),
|
||||
format!(
|
||||
"等级:Lv.{},名称:{},幻想描述:{}。",
|
||||
level.level, level.name, level.one_line_fantasy
|
||||
),
|
||||
format!("文字描述:{}。", level.text_description),
|
||||
format!("轮廓方向:{}。", level.silhouette_direction),
|
||||
format!("形象描述:{}。", level.visual_description),
|
||||
format!("主图提示词:{}。", level.visual_prompt_seed),
|
||||
"等级对形象的影响规则:等级越高越霸气、有气场、看起来强大、画面细节丰富,等级级别越低越弱小、普通。最低等级为1级,最高等级可能是6-12级".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
|
||||
"背景要求:透明背景 PNG 风格,不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框;不要出现多只主体。".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// 大鱼吃小鱼动作关键帧生成提示词脚本。
|
||||
pub(crate) fn build_big_fish_level_motion_prompt(
|
||||
draft: &BigFishGameDraftRecord,
|
||||
level: &BigFishLevelBlueprintRecord,
|
||||
motion_key: &str,
|
||||
) -> String {
|
||||
let motion_text = match motion_key {
|
||||
"move_swim" => format!(
|
||||
"{} 向右游动的关键帧预览,身体与尾鳍有清晰推进姿态,带轻微水流拖尾。",
|
||||
level.move_motion_description
|
||||
),
|
||||
_ => format!(
|
||||
"{} 待机漂浮的关键帧预览,身体轻微摆动,姿态稳定,适合作为 idle 状态。",
|
||||
level.idle_motion_description
|
||||
),
|
||||
};
|
||||
vec![
|
||||
format!(
|
||||
"为竖屏移动游戏《{}》生成一张等级生物动作关键帧静态预览图。",
|
||||
draft.title
|
||||
),
|
||||
format!("生态主题:{}。", draft.ecology_theme),
|
||||
format!(
|
||||
"等级:Lv.{},名称:{},幻想描述:{}。",
|
||||
level.level, level.name, level.one_line_fantasy
|
||||
),
|
||||
format!("文字描述:{}。", level.text_description),
|
||||
format!("动作提示词种子:{}。", level.motion_prompt_seed),
|
||||
format!("动作要求:{motion_text}"),
|
||||
"画面要求:按 RPG 角色动画资产口径生成,单体鱼形生物完整入镜,轮廓清晰,动作方向明确,2D 高完成度游戏插画,适合作为 Big Fish 动作槽位的静态 keyframe。".to_string(),
|
||||
"背景要求:透明背景 PNG 风格,不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框;不要生成序列帧拼图,不要出现多只主体。".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// 大鱼吃小鱼场地背景生成提示词脚本。
|
||||
pub(crate) fn build_big_fish_stage_background_prompt(draft: &BigFishGameDraftRecord) -> String {
|
||||
let background = &draft.background;
|
||||
vec![
|
||||
"生成一张 9:16 的游戏场景背景图。".to_string(),
|
||||
format!("生态主题:{}。", draft.ecology_theme),
|
||||
format!("背景主题:{}。色彩氛围:{}。", background.theme, background.color_mood),
|
||||
format!("前景提示:{}。", background.foreground_hints),
|
||||
format!("中景构图:{}。", background.midground_composition),
|
||||
format!("背景纵深:{}。", background.background_depth),
|
||||
format!("安全操作区:{}。", background.safe_play_area_hint),
|
||||
format!("出生边缘:{}。", background.spawn_edge_hint),
|
||||
format!("背景提示词种子:{}。", background.background_prompt_seed),
|
||||
"画面要求:竖屏9:16,大场地,全屏运行态背景,中央 80% 保持开阔清爽,边缘只保留少量出生区环境提示。".to_string(),
|
||||
"元素要求:画面中不出现任何形象主体、密集装饰、UI、文字、logo、水印、对话框或边框;不要把中央操作区画得过暗或过复杂。".to_string(),
|
||||
]
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// 大鱼吃小鱼图片生成默认反向提示词脚本。
|
||||
pub(crate) const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,复杂背景";
|
||||
|
||||
/// 大鱼吃小鱼透明主体类图片生成默认反向提示词脚本。
|
||||
pub(crate) const BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,场景背景,水草背景,气泡背景,多只主体,阴影地面";
|
||||
|
||||
fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec<JsonValue> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
serde_json::json!({
|
||||
"role": message.role,
|
||||
"kind": message.kind,
|
||||
"content": message.text,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn serialize_record_anchor_pack(anchor_pack: &BigFishAnchorPackRecord) -> String {
|
||||
serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack))
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn map_big_fish_record_anchor_pack(record: &BigFishAnchorPackRecord) -> BigFishAnchorPack {
|
||||
BigFishAnchorPack {
|
||||
gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise),
|
||||
ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme),
|
||||
growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder),
|
||||
risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_big_fish_record_anchor_item(
|
||||
record: &spacetime_client::BigFishAnchorItemRecord,
|
||||
) -> module_big_fish::BigFishAnchorItem {
|
||||
module_big_fish::BigFishAnchorItem {
|
||||
key: record.key.clone(),
|
||||
label: record.label.clone(),
|
||||
value: record.value.clone(),
|
||||
status: match record.status.as_str() {
|
||||
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
|
||||
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
|
||||
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
|
||||
_ => module_big_fish::BigFishAnchorStatus::Missing,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BIG_FISH_DEFAULT_NEGATIVE_PROMPT, BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT,
|
||||
build_big_fish_agent_prompt, build_big_fish_level_main_image_prompt,
|
||||
build_big_fish_level_motion_prompt, build_big_fish_stage_background_prompt,
|
||||
};
|
||||
|
||||
fn anchor_item(
|
||||
key: &str,
|
||||
label: &str,
|
||||
value: &str,
|
||||
status: &str,
|
||||
) -> spacetime_client::BigFishAnchorItemRecord {
|
||||
spacetime_client::BigFishAnchorItemRecord {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
value: value.to_string(),
|
||||
status: status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_session_record() -> spacetime_client::BigFishSessionRecord {
|
||||
spacetime_client::BigFishSessionRecord {
|
||||
session_id: "big-fish-session-test".to_string(),
|
||||
current_turn: 2,
|
||||
progress_percent: 60,
|
||||
stage: "collecting_anchors".to_string(),
|
||||
anchor_pack: spacetime_client::BigFishAnchorPackRecord {
|
||||
gameplay_promise: anchor_item(
|
||||
"gameplayPromise",
|
||||
"玩法承诺",
|
||||
"微光小鱼逆袭深海巨兽",
|
||||
"confirmed",
|
||||
),
|
||||
ecology_visual_theme: anchor_item(
|
||||
"ecologyVisualTheme",
|
||||
"生态视觉主题",
|
||||
"幽蓝珊瑚海沟",
|
||||
"confirmed",
|
||||
),
|
||||
growth_ladder: anchor_item("growthLadder", "成长阶梯", "", "missing"),
|
||||
risk_tempo: anchor_item("riskTempo", "风险节奏", "", "missing"),
|
||||
},
|
||||
draft: None,
|
||||
asset_slots: Vec::new(),
|
||||
asset_coverage: spacetime_client::BigFishAssetCoverageRecord {
|
||||
level_main_image_ready_count: 0,
|
||||
level_motion_ready_count: 0,
|
||||
background_ready: false,
|
||||
required_level_count: 8,
|
||||
publish_ready: false,
|
||||
blockers: Vec::new(),
|
||||
},
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
publish_ready: false,
|
||||
updated_at: "2026-04-24T10:00:00.000Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_draft() -> spacetime_client::BigFishGameDraftRecord {
|
||||
spacetime_client::BigFishGameDraftRecord {
|
||||
title: "深海逆袭".to_string(),
|
||||
subtitle: "从微光幼体吞噬到深渊王座".to_string(),
|
||||
core_fun: "吞噬成长与风险闪避".to_string(),
|
||||
ecology_theme: "幽蓝海沟珊瑚裂谷".to_string(),
|
||||
levels: vec![spacetime_client::BigFishLevelBlueprintRecord {
|
||||
level: 3,
|
||||
name: "裂潮猎游者".to_string(),
|
||||
one_line_fantasy: "在电光海沟中疾行收割的中阶猎鱼".to_string(),
|
||||
text_description: "裂潮猎游者是中阶进化体,已经具备更清晰的猎食轮廓和压迫感。"
|
||||
.to_string(),
|
||||
silhouette_direction: "长尾前探、背鳍后掠".to_string(),
|
||||
size_ratio: 1.8,
|
||||
visual_description: "深海霓虹风格的中阶猎鱼,长尾锐利,骨质鳍刃明显,轮廓成熟。"
|
||||
.to_string(),
|
||||
visual_prompt_seed: "深海霓虹、锐利长尾、骨质鳍刃".to_string(),
|
||||
idle_motion_description:
|
||||
"待机时身体轻微悬停,尾鳍保持低频摆动,像是在观察猎物距离。".to_string(),
|
||||
move_motion_description: "移动时长尾快速摆动,身体前探,形成明显突进巡游姿态。"
|
||||
.to_string(),
|
||||
motion_prompt_seed: "突进摆尾、鳍面拉伸、水流拖尾".to_string(),
|
||||
merge_source_level: Some(2),
|
||||
prey_window: vec![1, 2],
|
||||
threat_window: vec![4, 5],
|
||||
is_final_level: false,
|
||||
}],
|
||||
background: spacetime_client::BigFishBackgroundBlueprintRecord {
|
||||
theme: "裂谷荧光水域".to_string(),
|
||||
color_mood: "蓝绿冷光、边缘紫雾".to_string(),
|
||||
foreground_hints: "边角保留细碎水母草和岩脊".to_string(),
|
||||
midground_composition: "中央留大面积清爽水道".to_string(),
|
||||
background_depth: "远处海沟层叠透视".to_string(),
|
||||
safe_play_area_hint: "中央 80% 为操作安全区".to_string(),
|
||||
spawn_edge_hint: "左右边缘保留出生点环境提示".to_string(),
|
||||
background_prompt_seed: "荧光裂谷、冷色纵深、轻体积光".to_string(),
|
||||
},
|
||||
runtime_params: spacetime_client::BigFishRuntimeParamsRecord {
|
||||
level_count: 8,
|
||||
merge_count_per_upgrade: 3,
|
||||
spawn_target_count: 12,
|
||||
leader_move_speed: 240.0,
|
||||
follower_catch_up_speed: 280.0,
|
||||
offscreen_cull_seconds: 4.5,
|
||||
prey_spawn_delta_levels: vec![0, 1],
|
||||
threat_spawn_delta_levels: vec![1, 2],
|
||||
win_level: 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_fill_prompt_forbids_follow_up_questions() {
|
||||
let prompt = build_big_fish_agent_prompt(&empty_session_record(), true);
|
||||
|
||||
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
|
||||
assert!(prompt.contains("不要再继续提问"));
|
||||
assert!(prompt.contains("progressPercent 直接输出为 100"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_main_image_prompt_keeps_core_constraints() {
|
||||
let draft = sample_draft();
|
||||
let prompt = build_big_fish_level_main_image_prompt(&draft, &draft.levels[0]);
|
||||
|
||||
assert!(prompt.contains("裂潮猎游者"));
|
||||
assert!(prompt.contains("形象描述"));
|
||||
assert!(prompt.contains("透明背景 PNG 风格"));
|
||||
assert!(prompt.contains("主图提示词"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_motion_prompt_varies_with_motion_key() {
|
||||
let draft = sample_draft();
|
||||
let move_prompt = build_big_fish_level_motion_prompt(&draft, &draft.levels[0], "move_swim");
|
||||
let idle_prompt =
|
||||
build_big_fish_level_motion_prompt(&draft, &draft.levels[0], "idle_float");
|
||||
|
||||
assert!(move_prompt.contains("向右游动的关键帧预览"));
|
||||
assert!(idle_prompt.contains("待机漂浮的关键帧预览"));
|
||||
assert!(move_prompt.contains("透明背景 PNG 风格"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_background_prompt_keeps_runtime_field_constraints() {
|
||||
let draft = sample_draft();
|
||||
let prompt = build_big_fish_stage_background_prompt(&draft);
|
||||
|
||||
assert!(prompt.contains("生成一张 9:16 的游戏场景背景图"));
|
||||
assert!(prompt.contains("中央 80% 保持开阔清爽"));
|
||||
assert!(prompt.contains("背景提示词种子"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_prompts_keep_text_and_background_blockers() {
|
||||
assert!(BIG_FISH_DEFAULT_NEGATIVE_PROMPT.contains("文字"));
|
||||
assert!(BIG_FISH_DEFAULT_NEGATIVE_PROMPT.contains("复杂背景"));
|
||||
assert!(BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.contains("场景背景"));
|
||||
assert!(BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.contains("多只主体"));
|
||||
}
|
||||
}
|
||||
@@ -199,7 +199,7 @@ fn build_video_action_prompt(
|
||||
) -> String {
|
||||
[
|
||||
format!("生成有创意细节饱满的角色动作视频,动作英文名是 {}。", action_id),
|
||||
"角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,禁止退化成完全 90 度纯右视图。".to_string(),
|
||||
"角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,禁止退化成完全90度纯右视图。".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景等场景内容。".to_string(),
|
||||
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
|
||||
if use_chroma_key {
|
||||
|
||||
@@ -47,7 +47,7 @@ fn resolve_original_role_archetype(source: &str) -> &'static str {
|
||||
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
|
||||
fn build_master_prompt(character_brief: &str) -> String {
|
||||
[
|
||||
"单人,2D像素角色形象,头身比必须控制在 1.5 到 2 头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
|
||||
"单人,2D像素角色形象,头身比必须控制在1.5头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
|
||||
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
|
||||
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
pub(crate) mod agent_chat;
|
||||
pub(crate) mod big_fish;
|
||||
pub(crate) mod character_animation;
|
||||
pub(crate) mod character_visual;
|
||||
pub(crate) mod foundation_draft;
|
||||
pub(crate) mod puzzle_image;
|
||||
pub(crate) mod runtime_chat;
|
||||
pub(crate) mod rpg;
|
||||
pub(crate) mod scene_background;
|
||||
|
||||
pub(crate) use rpg::agent_chat;
|
||||
pub(crate) use rpg::foundation_draft;
|
||||
pub(crate) use rpg::role_asset_studio;
|
||||
pub(crate) use rpg::runtime_chat;
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> 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(),
|
||||
"- 每个属性维度definition都要像RPG游戏属性名,同时能服务战斗、社交、探索三种场景,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(),
|
||||
4
server-rs/crates/api-server/src/prompt/rpg/mod.rs
Normal file
4
server-rs/crates/api-server/src/prompt/rpg/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod agent_chat;
|
||||
pub(crate) mod foundation_draft;
|
||||
pub(crate) mod role_asset_studio;
|
||||
pub(crate) mod runtime_chat;
|
||||
348
server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs
Normal file
348
server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::Value;
|
||||
use shared_contracts::assets::{
|
||||
CharacterAssetRolePromptInput, CharacterRoleAssetWorkflowPayload,
|
||||
CharacterRolePromptBundlePayload, CharacterWorkflowCachePayload,
|
||||
};
|
||||
|
||||
const CORE_ANIMATION_KEYS: [&str; 4] = ["run", "attack", "idle", "die"];
|
||||
|
||||
/// 角色资产工坊默认 prompt 与缓存合并的后端主源。
|
||||
///
|
||||
/// 前端只保留输入框中的用户草稿;默认值挑选、旧 prompt 过滤、逐动作缓存继承都在这里统一执行。
|
||||
pub(crate) fn build_role_asset_workflow(
|
||||
role: CharacterAssetRolePromptInput,
|
||||
cache: Option<&CharacterWorkflowCachePayload>,
|
||||
) -> CharacterRoleAssetWorkflowPayload {
|
||||
let default_prompt_bundle = build_default_role_prompt_bundle(&role);
|
||||
let visual_prompt_text =
|
||||
resolve_visual_prompt_text(&role, cache, &default_prompt_bundle.visual_prompt_text);
|
||||
let animation_prompt_text_by_key =
|
||||
resolve_animation_prompt_text_by_key(&role, cache, &default_prompt_bundle);
|
||||
let animation_prompt_text = animation_prompt_text_by_key
|
||||
.get("idle")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| default_prompt_bundle.animation_prompt_text.clone());
|
||||
|
||||
CharacterRoleAssetWorkflowPayload {
|
||||
role: role.clone(),
|
||||
default_prompt_bundle,
|
||||
visual_prompt_text,
|
||||
animation_prompt_text,
|
||||
animation_prompt_text_by_key,
|
||||
visual_drafts: cache
|
||||
.map(|cache| cache.visual_drafts.clone())
|
||||
.unwrap_or_default(),
|
||||
selected_visual_draft_id: cache
|
||||
.map(|cache| cache.selected_visual_draft_id.clone())
|
||||
.unwrap_or_default(),
|
||||
selected_animation: cache
|
||||
.map(|cache| cache.selected_animation.clone())
|
||||
.filter(|value| CORE_ANIMATION_KEYS.contains(&value.as_str()))
|
||||
.unwrap_or_else(|| "run".to_string()),
|
||||
image_src: cache
|
||||
.and_then(|cache| cache.image_src.clone())
|
||||
.or_else(|| trim_optional_text(role.image_src.as_deref())),
|
||||
generated_visual_asset_id: cache
|
||||
.and_then(|cache| cache.generated_visual_asset_id.clone())
|
||||
.or_else(|| trim_optional_text(role.generated_visual_asset_id.as_deref())),
|
||||
generated_animation_set_id: cache
|
||||
.and_then(|cache| cache.generated_animation_set_id.clone())
|
||||
.or_else(|| trim_optional_text(role.generated_animation_set_id.as_deref())),
|
||||
animation_map: cache
|
||||
.and_then(|cache| cache.animation_map.clone())
|
||||
.or_else(|| role.animation_map.clone())
|
||||
.filter(Value::is_object),
|
||||
updated_at: cache.and_then(|cache| cache.updated_at.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_default_role_prompt_bundle(
|
||||
role: &CharacterAssetRolePromptInput,
|
||||
) -> CharacterRolePromptBundlePayload {
|
||||
CharacterRolePromptBundlePayload {
|
||||
visual_prompt_text: pick_first_description(
|
||||
[
|
||||
role.visual_description.as_deref(),
|
||||
role.description.as_deref(),
|
||||
],
|
||||
220,
|
||||
),
|
||||
animation_prompt_text: pick_first_description(
|
||||
[
|
||||
role.action_description.as_deref(),
|
||||
role.combat_style.as_deref(),
|
||||
],
|
||||
180,
|
||||
),
|
||||
scene_prompt_text: pick_first_description(
|
||||
[
|
||||
role.scene_visual_description.as_deref(),
|
||||
role.backstory.as_deref(),
|
||||
],
|
||||
220,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_animation_prompt_text_by_key(
|
||||
prompt_text_by_key: BTreeMap<String, String>,
|
||||
) -> BTreeMap<String, String> {
|
||||
prompt_text_by_key
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let key = trim_optional_text(Some(key.as_str()))?;
|
||||
let value = clamp_seed_text(value.as_str(), 280);
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((key, value))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_visual_prompt_text(
|
||||
role: &CharacterAssetRolePromptInput,
|
||||
cache: Option<&CharacterWorkflowCachePayload>,
|
||||
fallback_text: &str,
|
||||
) -> String {
|
||||
if trim_optional_text(role.visual_description.as_deref()).is_none() {
|
||||
if let Some(cached_text) = cache
|
||||
.map(|cache| cache.visual_prompt_text.as_str())
|
||||
.and_then(|value| trim_optional_text(Some(value)))
|
||||
.filter(|value| !is_legacy_generated_visual_description(value))
|
||||
{
|
||||
return cached_text;
|
||||
}
|
||||
}
|
||||
|
||||
fallback_text.to_string()
|
||||
}
|
||||
|
||||
fn resolve_animation_prompt_text_by_key(
|
||||
role: &CharacterAssetRolePromptInput,
|
||||
cache: Option<&CharacterWorkflowCachePayload>,
|
||||
default_prompt_bundle: &CharacterRolePromptBundlePayload,
|
||||
) -> BTreeMap<String, String> {
|
||||
let fallback_text = default_prompt_bundle.animation_prompt_text.as_str();
|
||||
let prefer_fresh_role_text = trim_optional_text(role.action_description.as_deref()).is_some();
|
||||
let cached_by_key = cache
|
||||
.map(|cache| &cache.animation_prompt_text_by_key)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let legacy_text = cache
|
||||
.map(|cache| cache.animation_prompt_text.as_str())
|
||||
.and_then(|value| trim_optional_text(Some(value)))
|
||||
.filter(|value| !is_legacy_generated_action_description(value));
|
||||
|
||||
CORE_ANIMATION_KEYS
|
||||
.iter()
|
||||
.map(|animation| {
|
||||
let cached_text = cached_by_key
|
||||
.get(*animation)
|
||||
.and_then(|value| trim_optional_text(Some(value.as_str())))
|
||||
.filter(|value| !is_legacy_generated_action_description(value));
|
||||
let prompt_text = if prefer_fresh_role_text {
|
||||
fallback_text.to_string()
|
||||
} else {
|
||||
cached_text
|
||||
.or_else(|| legacy_text.clone())
|
||||
.unwrap_or_else(|| fallback_text.to_string())
|
||||
};
|
||||
|
||||
((*animation).to_string(), prompt_text)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pick_first_description<const N: usize>(values: [Option<&str>; N], max_length: usize) -> String {
|
||||
values
|
||||
.into_iter()
|
||||
.filter_map(|value| value.map(|value| clamp_seed_text(value, max_length)))
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn trim_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn clamp_seed_text(value: &str, max_length: usize) -> String {
|
||||
trim_optional_text(Some(value))
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(max_length)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_legacy_generated_visual_description(value: &str) -> bool {
|
||||
let normalized = value.trim();
|
||||
!normalized.is_empty()
|
||||
&& [
|
||||
"2D 横版 RPG",
|
||||
"纯绿色绿幕",
|
||||
"2 到 2.5 头身",
|
||||
"深色粗轮廓",
|
||||
"身体整体朝右",
|
||||
"脚底完整可见",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
}
|
||||
|
||||
fn is_legacy_generated_action_description(value: &str) -> bool {
|
||||
let normalized = value.trim();
|
||||
!normalized.is_empty()
|
||||
&& [
|
||||
"动作气质参考:",
|
||||
"发力起手明确",
|
||||
"收招利落",
|
||||
"动作表现偏向",
|
||||
"起手克制",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn role_input() -> CharacterAssetRolePromptInput {
|
||||
CharacterAssetRolePromptInput {
|
||||
id: "hero".to_string(),
|
||||
name: "沈砺".to_string(),
|
||||
title: "灰炬向导".to_string(),
|
||||
role: "边路同行者".to_string(),
|
||||
visual_description: Some("灰黑短斗篷压着风痕。".to_string()),
|
||||
action_description: Some("起手先观察风向,再用短弓牵制。".to_string()),
|
||||
scene_visual_description: Some("边路哨点铺着潮湿石板。".to_string()),
|
||||
description: Some("熟悉裂潮边路的向导。".to_string()),
|
||||
backstory: Some("他把旧案痕迹留在边路。".to_string()),
|
||||
personality: None,
|
||||
motivation: None,
|
||||
combat_style: Some("短弓牵制后贴近补刀。".to_string()),
|
||||
tags: Vec::new(),
|
||||
image_src: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_prompt_bundle_keeps_existing_mapping_rules() {
|
||||
let bundle = build_default_role_prompt_bundle(&role_input());
|
||||
|
||||
assert_eq!(bundle.visual_prompt_text, "灰黑短斗篷压着风痕。");
|
||||
assert_eq!(
|
||||
bundle.animation_prompt_text,
|
||||
"起手先观察风向,再用短弓牵制。"
|
||||
);
|
||||
assert_eq!(bundle.scene_prompt_text, "边路哨点铺着潮湿石板。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_prefers_fresh_role_prompt_over_cache() {
|
||||
let cache = CharacterWorkflowCachePayload {
|
||||
character_id: "hero".to_string(),
|
||||
cache_scope_id: None,
|
||||
visual_prompt_text: "缓存视觉".to_string(),
|
||||
animation_prompt_text: "缓存动作".to_string(),
|
||||
animation_prompt_text_by_key: BTreeMap::from([(
|
||||
"run".to_string(),
|
||||
"缓存奔跑".to_string(),
|
||||
)]),
|
||||
visual_drafts: Vec::new(),
|
||||
selected_visual_draft_id: String::new(),
|
||||
selected_animation: "idle".to_string(),
|
||||
image_src: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
let workflow = build_role_asset_workflow(role_input(), Some(&cache));
|
||||
|
||||
assert_eq!(workflow.visual_prompt_text, "灰黑短斗篷压着风痕。");
|
||||
assert_eq!(
|
||||
workflow.animation_prompt_text_by_key["run"],
|
||||
"起手先观察风向,再用短弓牵制。"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_uses_non_legacy_cache_when_role_has_no_fresh_text() {
|
||||
let mut role = role_input();
|
||||
role.visual_description = None;
|
||||
role.action_description = None;
|
||||
let cache = CharacterWorkflowCachePayload {
|
||||
character_id: "hero".to_string(),
|
||||
cache_scope_id: None,
|
||||
visual_prompt_text: "缓存视觉".to_string(),
|
||||
animation_prompt_text: "缓存旧动作".to_string(),
|
||||
animation_prompt_text_by_key: BTreeMap::from([(
|
||||
"attack".to_string(),
|
||||
"缓存攻击动作".to_string(),
|
||||
)]),
|
||||
visual_drafts: Vec::new(),
|
||||
selected_visual_draft_id: String::new(),
|
||||
selected_animation: "attack".to_string(),
|
||||
image_src: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
let workflow = build_role_asset_workflow(role, Some(&cache));
|
||||
|
||||
assert_eq!(workflow.visual_prompt_text, "缓存视觉");
|
||||
assert_eq!(
|
||||
workflow.animation_prompt_text_by_key["attack"],
|
||||
"缓存攻击动作"
|
||||
);
|
||||
assert_eq!(workflow.animation_prompt_text_by_key["run"], "缓存旧动作");
|
||||
assert_eq!(workflow.selected_animation, "attack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_filters_legacy_cache_prompts() {
|
||||
let mut role = role_input();
|
||||
role.visual_description = None;
|
||||
role.action_description = None;
|
||||
let cache = CharacterWorkflowCachePayload {
|
||||
character_id: "hero".to_string(),
|
||||
cache_scope_id: None,
|
||||
visual_prompt_text: "2D 横版 RPG,纯绿色绿幕。".to_string(),
|
||||
animation_prompt_text: "动作气质参考:发力起手明确。".to_string(),
|
||||
animation_prompt_text_by_key: BTreeMap::from([(
|
||||
"run".to_string(),
|
||||
"收招利落,动作表现偏向快速。".to_string(),
|
||||
)]),
|
||||
visual_drafts: Vec::new(),
|
||||
selected_visual_draft_id: String::new(),
|
||||
selected_animation: "unknown".to_string(),
|
||||
image_src: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
let workflow = build_role_asset_workflow(role, Some(&cache));
|
||||
|
||||
assert_eq!(workflow.visual_prompt_text, "熟悉裂潮边路的向导。");
|
||||
assert_eq!(
|
||||
workflow.animation_prompt_text_by_key["run"],
|
||||
"短弓牵制后贴近补刀。"
|
||||
);
|
||||
assert_eq!(workflow.selected_animation, "run");
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,48 @@ pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str {
|
||||
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。"
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CharacterChatPromptParams<'a> {
|
||||
pub world_type: &'a str,
|
||||
pub player_character: &'a Value,
|
||||
pub target_character: &'a Value,
|
||||
pub story_history: &'a [Value],
|
||||
pub context: &'a Value,
|
||||
pub conversation_history: &'a [Value],
|
||||
pub conversation_summary: &'a str,
|
||||
pub previous_summary: &'a str,
|
||||
pub player_message: &'a str,
|
||||
pub target_status: &'a Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct NpcRecruitDialoguePromptParams<'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 invitation_text: &'a str,
|
||||
pub recruit_summary: &'a str,
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_reply_system_prompt() -> &'static str {
|
||||
"你是像素动作 RPG 中正在与玩家私下交谈的同行角色。只输出这名角色此刻会说的话,只允许中文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_suggestions_system_prompt() -> &'static str {
|
||||
"你要为玩家生成 3 条下一句可直接发送的中文回复建议。只输出 3 行纯文本,不要序号、引号、Markdown 或解释。三条建议要分别偏关心、追问、轻松拉近关系。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_summary_system_prompt() -> &'static str {
|
||||
"你要把玩家与该角色的聊天沉淀成一段后续剧情可用的关系摘要。只输出一段中文摘要,不要标题、Markdown、JSON 或解释。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_npc_recruit_dialogue_system_prompt() -> &'static str {
|
||||
"你是角色扮演 RPG 的招募剧情对话编剧。只输出纯中文对话正文,不要输出解释、代码、Markdown、JSON 或额外说明。最后一行必须由对方明确答应加入队伍。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_npc_dialogue_user_prompt(
|
||||
npc_name: &str,
|
||||
params: RuntimeNpcDialoguePromptParams<'_>,
|
||||
@@ -88,6 +130,76 @@ pub(crate) fn build_runtime_npc_dialogue_user_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_reply_user_prompt(
|
||||
params: CharacterChatPromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"worldType": params.world_type,
|
||||
"playerCharacter": params.player_character,
|
||||
"targetCharacter": params.target_character,
|
||||
"storyHistory": params.story_history,
|
||||
"context": params.context,
|
||||
"conversationHistory": params.conversation_history,
|
||||
"conversationSummary": params.conversation_summary,
|
||||
"playerMessage": params.player_message,
|
||||
"targetStatus": params.target_status,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_suggestions_user_prompt(
|
||||
params: CharacterChatPromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"worldType": params.world_type,
|
||||
"playerCharacter": params.player_character,
|
||||
"targetCharacter": params.target_character,
|
||||
"storyHistory": params.story_history,
|
||||
"context": params.context,
|
||||
"conversationHistory": params.conversation_history,
|
||||
"conversationSummary": params.conversation_summary,
|
||||
"targetStatus": params.target_status,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_summary_user_prompt(
|
||||
params: CharacterChatPromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"worldType": params.world_type,
|
||||
"playerCharacter": params.player_character,
|
||||
"targetCharacter": params.target_character,
|
||||
"storyHistory": params.story_history,
|
||||
"context": params.context,
|
||||
"conversationHistory": params.conversation_history,
|
||||
"previousSummary": params.previous_summary,
|
||||
"targetStatus": params.target_status,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_npc_recruit_dialogue_user_prompt(
|
||||
npc_name: &str,
|
||||
params: NpcRecruitDialoguePromptParams<'_>,
|
||||
) -> String {
|
||||
let state_prompt = json!({
|
||||
"worldType": params.world_type,
|
||||
"character": params.character,
|
||||
"encounter": params.encounter,
|
||||
"monsters": params.monsters,
|
||||
"history": params.history,
|
||||
"context": params.context,
|
||||
"invitationText": params.invitation_text,
|
||||
"recruitSummary": params.recruit_summary,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
format!(
|
||||
"请基于以下运行时状态,把“邀请 {npc_name} 入队”这件事写成 4 到 6 行可直接展示的中文对话。最后一行必须由 {npc_name} 明确答应加入。\n{state_prompt}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_reasoned_story_system_prompt() -> &'static str {
|
||||
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态,不要发明额外奖励。"
|
||||
}
|
||||
@@ -414,6 +526,116 @@ pub(crate) fn build_deterministic_npc_reply(
|
||||
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_reply_fallback(
|
||||
target_character: &Value,
|
||||
player_message: &str,
|
||||
conversation_summary: &str,
|
||||
) -> String {
|
||||
let target_name =
|
||||
read_name_field(target_character, "name").unwrap_or_else(|| "对方".to_string());
|
||||
let focus = if player_message.trim().is_empty() {
|
||||
"我听见你刚才的话了。".to_string()
|
||||
} else if player_message.trim().ends_with('。') {
|
||||
player_message.trim().to_string()
|
||||
} else {
|
||||
format!("{}。", player_message.trim())
|
||||
};
|
||||
|
||||
if conversation_summary.trim().is_empty() {
|
||||
format!("{focus}我会认真回答你。既然你愿意直接来问,我们就把这件事说清楚。")
|
||||
} else {
|
||||
format!("{focus}{target_name}显然记得你们之前谈过的事,所以这次回答也比先前更直接。")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_suggestions_fallback(target_character: &Value) -> String {
|
||||
let target_name = read_name_field(target_character, "name").unwrap_or_else(|| "你".to_string());
|
||||
[
|
||||
"我想先听你把真正担心的事说出来。".to_string(),
|
||||
format!("{target_name},这件事你还瞒了我什么?"),
|
||||
"先别谈别的,我想多了解你一点。".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_summary_fallback(
|
||||
target_character: &Value,
|
||||
conversation_history: &[Value],
|
||||
previous_summary: &str,
|
||||
) -> String {
|
||||
let target_name =
|
||||
read_name_field(target_character, "name").unwrap_or_else(|| "这名角色".to_string());
|
||||
let latest_turns = conversation_history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|item| {
|
||||
let record = as_record(item)?;
|
||||
let speaker =
|
||||
read_string(record.get("speaker")).unwrap_or_else(|| "character".to_string());
|
||||
let text = read_string(record.get("text"))?;
|
||||
Some(format!(
|
||||
"{}:{}",
|
||||
if speaker == "player" {
|
||||
"玩家"
|
||||
} else {
|
||||
target_name.as_str()
|
||||
},
|
||||
text
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let current = if latest_turns.is_empty() {
|
||||
format!("{target_name}愿意继续私下交谈,对玩家的态度正在慢慢松动。")
|
||||
} else {
|
||||
format!("{target_name}在私下交谈中比先前更愿意回应。最近交流:{latest_turns}")
|
||||
};
|
||||
|
||||
if previous_summary.trim().is_empty() {
|
||||
current
|
||||
} else {
|
||||
format!("{} {}", previous_summary.trim(), current)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_npc_chat_dialogue_fallback(encounter: &Value, topic: &str) -> String {
|
||||
let npc_name = read_name_field(encounter, "npcName")
|
||||
.or_else(|| read_name_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
[
|
||||
format!(
|
||||
"你:{}。我想先听听你的看法。",
|
||||
if topic.trim().is_empty() {
|
||||
"这件事我还没看透"
|
||||
} else {
|
||||
topic.trim()
|
||||
}
|
||||
),
|
||||
format!("{npc_name}:你问得并不随意,看来是真想弄清这里的底细。"),
|
||||
"你:前面的局势我还没看透。你若知道什么,就别只说一半。".to_string(),
|
||||
format!("{npc_name}:我能告诉你的,是这里近来一直不太平。接下来多留神些。"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_npc_recruit_dialogue_fallback(encounter: &Value) -> String {
|
||||
let npc_name = read_name_field(encounter, "npcName")
|
||||
.or_else(|| read_name_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
[
|
||||
"你:这不是客套。我是真心希望你能加入队伍,和我一起走下去。".to_string(),
|
||||
format!("{npc_name}:你这番话够坦诚,我听得出你不是随口一提。"),
|
||||
"你:前路不会轻松,但我还是希望你能与我并肩同行。".to_string(),
|
||||
format!("{npc_name}:好,我答应你。从现在起,我便与你结伴同行。"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_deterministic_chat_suggestions(
|
||||
npc_name: &str,
|
||||
player_message: &str,
|
||||
@@ -794,6 +1016,15 @@ fn read_string_field(value: &Value, field: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_name_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_string(value: Option<&Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
Reference in New Issue
Block a user