1
This commit is contained in:
@@ -68,7 +68,7 @@ use crate::{
|
||||
proxy_generated_animations, proxy_generated_big_fish_assets,
|
||||
proxy_generated_character_drafts, proxy_generated_characters,
|
||||
proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes,
|
||||
proxy_generated_qwen_sprites,
|
||||
proxy_generated_puzzle_assets, proxy_generated_qwen_sprites,
|
||||
},
|
||||
llm::proxy_llm_chat_completions,
|
||||
login_options::auth_login_options,
|
||||
@@ -188,6 +188,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/generated-big-fish-assets/{*path}",
|
||||
get(proxy_generated_big_fish_assets),
|
||||
)
|
||||
.route(
|
||||
"/generated-puzzle-assets/{*path}",
|
||||
get(proxy_generated_puzzle_assets),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-scenes/{*path}",
|
||||
get(proxy_generated_custom_world_scenes),
|
||||
|
||||
@@ -27,6 +27,13 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
|
||||
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = [
|
||||
"character_visual",
|
||||
"scene_image",
|
||||
"puzzle_cover_image",
|
||||
];
|
||||
|
||||
pub async fn create_direct_upload_ticket(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -118,11 +125,11 @@ pub async fn get_asset_history(
|
||||
Query(query): Query<AssetHistoryQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let asset_kind = query.kind.trim().to_string();
|
||||
if asset_kind != "character_visual" && asset_kind != "scene_image" {
|
||||
if !is_supported_asset_history_kind(asset_kind.as_str()) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"field": "kind",
|
||||
"message": "历史素材类型只支持 character_visual 或 scene_image",
|
||||
"message": supported_asset_history_kind_message(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -288,6 +295,17 @@ fn format_asset_owner_label(owner_user_id: Option<&str>) -> String {
|
||||
format!("账号 {owner_user_id}")
|
||||
}
|
||||
|
||||
fn is_supported_asset_history_kind(asset_kind: &str) -> bool {
|
||||
SUPPORTED_ASSET_HISTORY_KINDS.contains(&asset_kind)
|
||||
}
|
||||
|
||||
fn supported_asset_history_kind_message() -> String {
|
||||
format!(
|
||||
"历史素材类型只支持 {}",
|
||||
SUPPORTED_ASSET_HISTORY_KINDS.join("、")
|
||||
)
|
||||
}
|
||||
|
||||
async fn build_confirm_asset_object_upsert_input(
|
||||
oss_client: &platform_oss::OssClient,
|
||||
payload: ConfirmAssetObjectRequest,
|
||||
@@ -457,6 +475,22 @@ mod tests {
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
|
||||
#[test]
|
||||
fn asset_history_kind_support_includes_puzzle_cover_image() {
|
||||
assert!(super::is_supported_asset_history_kind("character_visual"));
|
||||
assert!(super::is_supported_asset_history_kind("scene_image"));
|
||||
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
|
||||
assert!(!super::is_supported_asset_history_kind("puzzle_preview_image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_history_kind_message_lists_all_supported_kinds() {
|
||||
assert_eq!(
|
||||
super::supported_asset_history_kind_message(),
|
||||
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -238,6 +238,7 @@ pub async fn submit_big_fish_message(
|
||||
llm_client: state.llm_client(),
|
||||
session: &submitted_session,
|
||||
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
},
|
||||
move |text| {
|
||||
draft_sink.persist_visible_text_async(text);
|
||||
@@ -350,6 +351,7 @@ pub async fn stream_big_fish_message(
|
||||
llm_client: state.llm_client(),
|
||||
session: &submitted_session,
|
||||
quick_fill_requested,
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
},
|
||||
move |text| {
|
||||
let _ = reply_tx.send(text.to_string());
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) struct BigFishAgentTurnRequest<'a> {
|
||||
pub llm_client: Option<&'a LlmClient>,
|
||||
pub session: &'a BigFishSessionRecord,
|
||||
pub quick_fill_requested: bool,
|
||||
pub enable_web_search: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -122,6 +123,7 @@ where
|
||||
request.llm_client,
|
||||
format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
|
||||
"请按约定输出这一轮的 JSON。",
|
||||
request.enable_web_search,
|
||||
CreationAgentLlmTurnErrorMessages {
|
||||
model_unavailable: "当前模型不可用,请稍后重试。",
|
||||
generation_failed: "大鱼吃小鱼聊天生成失败,请稍后重试。",
|
||||
|
||||
@@ -81,6 +81,7 @@ pub struct AppConfig {
|
||||
pub llm_max_retries: u32,
|
||||
pub llm_retry_backoff_ms: u64,
|
||||
pub rpg_llm_web_search_enabled: bool,
|
||||
pub creation_agent_llm_web_search_enabled: bool,
|
||||
pub dashscope_base_url: String,
|
||||
pub dashscope_api_key: Option<String>,
|
||||
pub dashscope_scene_image_model: String,
|
||||
@@ -170,6 +171,7 @@ impl Default for AppConfig {
|
||||
llm_max_retries: DEFAULT_MAX_RETRIES,
|
||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||
rpg_llm_web_search_enabled: true,
|
||||
creation_agent_llm_web_search_enabled: true,
|
||||
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
|
||||
dashscope_api_key: None,
|
||||
dashscope_scene_image_model: "wan2.2-t2i-flash".to_string(),
|
||||
@@ -475,6 +477,13 @@ impl AppConfig {
|
||||
config.rpg_llm_web_search_enabled = rpg_llm_web_search_enabled;
|
||||
}
|
||||
|
||||
if let Some(creation_agent_llm_web_search_enabled) = read_first_bool_env(&[
|
||||
"GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED",
|
||||
"CREATION_AGENT_LLM_WEB_SEARCH_ENABLED",
|
||||
]) {
|
||||
config.creation_agent_llm_web_search_enabled = creation_agent_llm_web_search_enabled;
|
||||
}
|
||||
|
||||
if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) {
|
||||
config.dashscope_base_url = dashscope_base_url;
|
||||
}
|
||||
@@ -843,4 +852,24 @@ mod tests {
|
||||
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_creation_agent_llm_web_search_switch() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock should not poison");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
|
||||
std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "false");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert!(!config.creation_agent_llm_web_search_enabled);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ pub(crate) async fn stream_creation_agent_json_turn<F, E>(
|
||||
llm_client: Option<&LlmClient>,
|
||||
system_prompt: String,
|
||||
user_prompt: impl Into<String>,
|
||||
enable_web_search: bool,
|
||||
messages: CreationAgentLlmTurnErrorMessages<'_>,
|
||||
mut on_reply_update: F,
|
||||
build_error: impl Fn(String) -> E,
|
||||
@@ -33,10 +34,7 @@ where
|
||||
let mut latest_reply_text = String::new();
|
||||
let response = llm_client
|
||||
.stream_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt.into()),
|
||||
]),
|
||||
build_creation_agent_llm_request(system_prompt, user_prompt.into(), enable_web_search),
|
||||
|delta: &LlmStreamDelta| {
|
||||
if let Some(reply_progress) =
|
||||
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
|
||||
@@ -61,6 +59,19 @@ where
|
||||
Ok(CreationAgentJsonTurnOutput { parsed })
|
||||
}
|
||||
|
||||
fn build_creation_agent_llm_request(
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
enable_web_search: bool,
|
||||
) -> LlmTextRequest {
|
||||
// 创作 Agent 是否联网由 api-server 配置集中传入,避免各玩法各自散落默认值。
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_web_search(enable_web_search)
|
||||
}
|
||||
|
||||
pub(crate) async fn request_creation_agent_json_turn<E>(
|
||||
llm_client: &LlmClient,
|
||||
system_prompt: String,
|
||||
@@ -149,7 +160,10 @@ fn read_reply_text(parsed: &JsonValue) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{extract_reply_text_from_partial_json, parse_json_response_text};
|
||||
use super::{
|
||||
build_creation_agent_llm_request, extract_reply_text_from_partial_json,
|
||||
parse_json_response_text,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn extracts_reply_text_from_partial_json_with_chinese_text() {
|
||||
@@ -167,4 +181,13 @@ mod tests {
|
||||
|
||||
assert_eq!(parsed["replyText"].as_str(), Some("好"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_stream_request_with_web_search_when_enabled() {
|
||||
let request =
|
||||
build_creation_agent_llm_request("系统提示".to_string(), "用户提示".to_string(), true);
|
||||
|
||||
assert!(request.enable_web_search);
|
||||
assert_eq!(request.messages.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,6 +759,7 @@ pub async fn submit_custom_world_agent_message(
|
||||
session: &session,
|
||||
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
||||
focus_card_id: payload.focus_card_id.clone(),
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
},
|
||||
move |text| {
|
||||
draft_sink.persist_visible_text_async(text);
|
||||
@@ -910,6 +911,7 @@ pub async fn stream_custom_world_agent_message(
|
||||
session: &session,
|
||||
quick_fill_requested,
|
||||
focus_card_id,
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
},
|
||||
move |text| {
|
||||
let _ = reply_tx.send(text.to_string());
|
||||
|
||||
@@ -28,6 +28,7 @@ pub(crate) struct CustomWorldAgentTurnRequest<'a> {
|
||||
pub session: &'a CustomWorldAgentSessionRecord,
|
||||
pub quick_fill_requested: bool,
|
||||
pub focus_card_id: Option<String>,
|
||||
pub enable_web_search: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -214,6 +215,7 @@ where
|
||||
request.session.progress_percent,
|
||||
request.quick_fill_requested,
|
||||
¤t_anchor_content,
|
||||
request.enable_web_search,
|
||||
on_reply_update,
|
||||
)
|
||||
.await?;
|
||||
@@ -476,6 +478,7 @@ async fn stream_single_turn<F>(
|
||||
progress_percent: u32,
|
||||
quick_fill_requested: bool,
|
||||
current_anchor_content: &EightAnchorContent,
|
||||
enable_web_search: bool,
|
||||
on_reply_update: F,
|
||||
) -> Result<SingleTurnModelOutput, CustomWorldTurnError>
|
||||
where
|
||||
@@ -505,6 +508,7 @@ where
|
||||
Some(llm_client),
|
||||
prompt,
|
||||
"请按约定输出这一轮的 JSON。",
|
||||
enable_web_search,
|
||||
CreationAgentLlmTurnErrorMessages {
|
||||
model_unavailable: "当前模型不可用,请稍后重试。",
|
||||
generation_failed: "这一轮设定生成失败,请稍后重试。",
|
||||
|
||||
@@ -39,6 +39,13 @@ pub async fn proxy_generated_big_fish_assets(
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::BigFishAssets, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_puzzle_assets(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::PuzzleAssets, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_custom_world_scenes(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
|
||||
@@ -46,7 +46,6 @@ 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;
|
||||
|
||||
@@ -2,5 +2,6 @@ pub(crate) mod agent_chat;
|
||||
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 scene_background;
|
||||
|
||||
44
server-rs/crates/api-server/src/prompt/puzzle_image.rs
Normal file
44
server-rs/crates/api-server/src/prompt/puzzle_image.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
/// 拼图图片生成的默认反向提示词。
|
||||
///
|
||||
/// 这里单独收口拼图图片提示词,避免图片生成链路、候选图持久化和 DashScope 请求编排
|
||||
/// 混在同一个脚本里,后续调画风或资产约束时只需要改这一处。
|
||||
pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
|
||||
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
|
||||
|
||||
/// 根据拼图关卡名和创作者输入构造最终发给图片模型的提示词。
|
||||
pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"请生成一张适合正方形拼图关卡的高清插画。",
|
||||
"关卡名:{level_name}。",
|
||||
"画面主体:{prompt}。",
|
||||
"画面要求:1:1 正方形画布,适配 3x3 或 4x4 拼图切块,",
|
||||
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
|
||||
"避免文字、水印、边框和 UI 元素。"
|
||||
),
|
||||
level_name = level_name,
|
||||
prompt = prompt,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() {
|
||||
let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索");
|
||||
|
||||
assert!(prompt.contains("雨夜神庙"));
|
||||
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
|
||||
assert!(prompt.contains("正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_negative_prompt_blocks_text_and_low_quality_assets() {
|
||||
assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("低清晰度"));
|
||||
assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("文字水印"));
|
||||
}
|
||||
}
|
||||
@@ -112,3 +112,726 @@ pub(crate) fn build_runtime_reasoned_story_user_prompt(
|
||||
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{state_prompt}"
|
||||
)
|
||||
}
|
||||
|
||||
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#"你要为 RPG NPC 聊天生成下一步候选,并判断敌对聊天是否已经收束。
|
||||
只输出 JSON,不要输出 Markdown 或解释。
|
||||
JSON 结构:
|
||||
{"shouldEndChat":false,"terminationReason":null,"suggestions":["温和共情台词","冷静追问台词","施压质疑台词"],"functionSuggestions":[{"functionId":"...","actionText":"玩家动作文本"}]}
|
||||
- suggestions 是玩家下一轮可直接说出口的中文短句,每条 20 字以内;三条必须按顺序导向不同氛围和好感结果。
|
||||
- suggestions 第 1 条温和共情,通常让气氛缓和、好感上升;第 2 条冷静追问或试探,通常保持中性但推进情报;第 3 条施压、质疑或立场冲突,通常让气氛变紧、好感下降或付出代价。
|
||||
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
|
||||
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId,不写规则说明。
|
||||
- 非敌对聊天 shouldEndChat 必须为 false。
|
||||
- 敌对聊天可以随时 shouldEndChat=true,且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
|
||||
|
||||
#[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_hostile_model_chat = chat_directive
|
||||
.and_then(|record| read_string(record.get("terminationMode")))
|
||||
.as_deref()
|
||||
== Some("hostile_model")
|
||||
|| chat_directive
|
||||
.and_then(|record| read_bool(record.get("isHostileChat")))
|
||||
.unwrap_or(false);
|
||||
let is_player_exit_turn = chat_directive
|
||||
.and_then(|record| read_string(record.get("terminationReason")))
|
||||
.as_deref()
|
||||
== Some("player_exit");
|
||||
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_hostile_model_chat {
|
||||
Some("当前是敌对或负好感聊天。对方不受固定回合限制,但随时可能不耐烦、结束谈话并把局势推向战斗或驱逐。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_hostile_model_chat {
|
||||
Some("敌对角色更偏好短促、戒备、带威胁的回应;如果玩家逼问、挑衅、退场或话题触到底线,回复应自然收束到对峙前一刻。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_player_exit_turn {
|
||||
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
|
||||
} 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);
|
||||
let chat_directive = payload.chat_directive.and_then(as_record);
|
||||
let is_hostile_model_chat = chat_directive
|
||||
.and_then(|record| read_string(record.get("terminationMode")))
|
||||
.as_deref()
|
||||
== Some("hostile_model")
|
||||
|| chat_directive
|
||||
.and_then(|record| read_bool(record.get("isHostileChat")))
|
||||
.unwrap_or(false);
|
||||
let is_player_exit_turn = chat_directive
|
||||
.and_then(|record| read_string(record.get("terminationReason")))
|
||||
.as_deref()
|
||||
== Some("player_exit");
|
||||
let function_options_block = chat_directive
|
||||
.and_then(|record| record.get("functionOptions"))
|
||||
.map(describe_function_options)
|
||||
.filter(|text| !text.trim().is_empty());
|
||||
|
||||
[
|
||||
Some(build_npc_dialogue_prompt_base(payload)),
|
||||
Some(describe_npc_conversation_history(
|
||||
conversation_history,
|
||||
encounter.npc_name.as_str(),
|
||||
)),
|
||||
combat_context_block,
|
||||
function_options_block,
|
||||
if payload.npc_initiates_conversation {
|
||||
Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string())
|
||||
} else {
|
||||
Some(format!("玩家刚刚说:{}", payload.player_message))
|
||||
},
|
||||
Some(format!("NPC 刚刚回复:{npc_reply}")),
|
||||
if is_hostile_model_chat {
|
||||
Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙。".to_string())
|
||||
} else {
|
||||
Some("这是非敌对聊天,shouldEndChat 必须为 false。".to_string())
|
||||
},
|
||||
if is_player_exit_turn {
|
||||
Some("玩家已经选择结束聊天,shouldEndChat 必须为 true,terminationReason 必须为 player_exit。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Some("suggestions 必须按顺序生成三种明显不同的玩家台词:温和共情、冷静追问或试探、施压质疑;不要给出同一种态度的近义句。".to_string()),
|
||||
Some("functionSuggestions 从 functionOptions 中挑可触发动作并改写 actionText。".to_string()),
|
||||
Some("只输出 JSON:{\"shouldEndChat\":false,\"terminationReason\":null,\"suggestions\":[\"...\"],\"functionSuggestions\":[{\"functionId\":\"...\",\"actionText\":\"...\"}]}".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_deterministic_npc_reply(
|
||||
npc_name: &str,
|
||||
player_message: &str,
|
||||
npc_initiates_conversation: bool,
|
||||
) -> String {
|
||||
// LLM 不可用时仍由后端返回稳定中文对白,保证相遇和点击聊天链路不断。
|
||||
if npc_initiates_conversation {
|
||||
return format!("{npc_name}看向你,先开口说道:“你来了。先别急着走,我正有话想和你说。”");
|
||||
}
|
||||
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
|
||||
}
|
||||
|
||||
pub(crate) 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()
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) 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(),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn build_fallback_function_suggestions(chat_directive: Option<&Value>) -> Vec<Value> {
|
||||
read_function_options(chat_directive)
|
||||
.into_iter()
|
||||
.filter(|option| {
|
||||
read_string_field(option, "functionId")
|
||||
.as_deref()
|
||||
.is_some_and(|function_id| function_id != "npc_chat")
|
||||
})
|
||||
.take(2)
|
||||
.filter_map(|option| {
|
||||
let function_id = read_string_field(option, "functionId")?;
|
||||
let action_text = read_string_field(option, "actionText")?;
|
||||
Some(json!({
|
||||
"functionId": function_id,
|
||||
"actionText": action_text,
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn describe_function_options(value: &Value) -> String {
|
||||
let lines = value
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.take(8)
|
||||
.filter_map(|item| {
|
||||
let record = as_record(item)?;
|
||||
let function_id = read_string(record.get("functionId"))?;
|
||||
let action_text = read_string(record.get("actionText"))?;
|
||||
let detail_text = read_string(record.get("detailText"));
|
||||
let action = read_string(record.get("action"));
|
||||
Some(format!(
|
||||
"- functionId: {function_id}; actionText: {action_text}; action: {}; detail: {}",
|
||||
action.unwrap_or_else(|| "unknown".to_string()),
|
||||
detail_text.unwrap_or_else(|| "无".to_string()),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut result = vec!["当前聊天中可改写为动作候选的 functionOptions:".to_string()];
|
||||
result.extend(lines);
|
||||
result.join("\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_function_options(chat_directive: Option<&Value>) -> Vec<&Value> {
|
||||
chat_directive
|
||||
.and_then(|directive| directive.get("functionOptions"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
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_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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,16 @@ use axum::{
|
||||
sse::{Event, Sse},
|
||||
},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use module_puzzle::PuzzleGeneratedImageCandidate;
|
||||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::{
|
||||
puzzle_agent::{
|
||||
@@ -64,6 +68,7 @@ use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||
puzzle_agent_turn::{
|
||||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
run_puzzle_agent_turn,
|
||||
@@ -78,8 +83,6 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
|
||||
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
|
||||
const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
|
||||
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
||||
const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
|
||||
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
|
||||
|
||||
pub async fn create_puzzle_agent_session(
|
||||
State(state): State<AppState>,
|
||||
@@ -216,6 +219,7 @@ pub async fn submit_puzzle_agent_message(
|
||||
llm_client: state.llm_client(),
|
||||
session: &submitted_session,
|
||||
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
},
|
||||
|_| {},
|
||||
)
|
||||
@@ -320,6 +324,7 @@ pub async fn stream_puzzle_agent_message(
|
||||
llm_client: state.llm_client(),
|
||||
session: &session,
|
||||
quick_fill_requested,
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
},
|
||||
move |text| {
|
||||
let _ = reply_tx.send(text.to_string());
|
||||
@@ -447,7 +452,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
(
|
||||
"compile_puzzle_draft",
|
||||
"完整拼图草稿",
|
||||
"已编译草稿、生成候选图并应用正式图片。",
|
||||
"已编译草稿、生成拼图图片并应用为正式图。",
|
||||
session,
|
||||
)
|
||||
}
|
||||
@@ -468,7 +473,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| draft.summary.clone());
|
||||
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_count = 1;
|
||||
let candidate_start_index = draft.candidates.len();
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
&state,
|
||||
@@ -476,6 +482,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
&session.session_id,
|
||||
&draft.level_name,
|
||||
&prompt,
|
||||
payload.reference_image_src.as_deref(),
|
||||
candidate_count,
|
||||
candidate_start_index,
|
||||
)
|
||||
@@ -521,8 +528,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
};
|
||||
(
|
||||
"generate_puzzle_images",
|
||||
"候选图生成",
|
||||
"已生成 2 张候选拼图图像。",
|
||||
"拼图图片生成",
|
||||
"已生成并替换当前拼图图片。",
|
||||
session,
|
||||
)
|
||||
}
|
||||
@@ -1296,6 +1303,7 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
||||
previous_level_tags: run.previous_level_tags,
|
||||
current_level: run.current_level.map(map_puzzle_runtime_level_response),
|
||||
recommended_next_profile_id: run.recommended_next_profile_id,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1381,6 +1389,10 @@ fn map_puzzle_runtime_level_response(
|
||||
cover_image_src: level.cover_image_src,
|
||||
board: map_puzzle_board_response(level.board),
|
||||
status: level.status,
|
||||
started_at_ms: 0,
|
||||
cleared_at_ms: None,
|
||||
elapsed_ms: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1476,7 +1488,8 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
&compiled_session.session_id,
|
||||
&draft.level_name,
|
||||
&draft.summary,
|
||||
2,
|
||||
None,
|
||||
1,
|
||||
draft.candidates.len(),
|
||||
)
|
||||
.await
|
||||
@@ -1619,23 +1632,53 @@ async fn generate_puzzle_image_candidates(
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
reference_image_src: Option<&str>,
|
||||
candidate_count: u32,
|
||||
candidate_start_index: usize,
|
||||
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
|
||||
let count = candidate_count.clamp(1, 2);
|
||||
let count = candidate_count.clamp(1, 1);
|
||||
let settings =
|
||||
require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?;
|
||||
let http_client = build_puzzle_dashscope_http_client(&settings)
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
let generated = create_puzzle_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_puzzle_image_prompt(level_name, prompt).as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
"1024*1024",
|
||||
count,
|
||||
)
|
||||
.await
|
||||
let actual_prompt = build_puzzle_image_prompt(level_name, prompt);
|
||||
let reference_image = match reference_image_src
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(source) => Some(
|
||||
resolve_puzzle_reference_image_as_data_url(state, &http_client, source)
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。
|
||||
let generated = match reference_image.as_deref() {
|
||||
Some(reference_image) => {
|
||||
create_puzzle_image_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
"1024*1024",
|
||||
count,
|
||||
reference_image,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
create_puzzle_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
"1024*1024",
|
||||
count,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
let mut items = Vec::with_capacity(generated.images.len());
|
||||
|
||||
@@ -1661,9 +1704,10 @@ async fn generate_puzzle_image_candidates(
|
||||
image_src: asset.image_src,
|
||||
asset_id: asset.asset_id,
|
||||
prompt: prompt.to_string(),
|
||||
actual_prompt: Some(prompt.to_string()),
|
||||
actual_prompt: Some(actual_prompt.clone()),
|
||||
source_type: "generated".to_string(),
|
||||
selected: candidate_start_index == 0 && index == 0,
|
||||
// 单图生成结果总是直接成为当前正式图。
|
||||
selected: index == 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1740,7 +1784,8 @@ async fn build_local_next_puzzle_run(
|
||||
&session.session_id,
|
||||
&draft.level_name,
|
||||
&draft.summary,
|
||||
2,
|
||||
None,
|
||||
1,
|
||||
draft.candidates.len(),
|
||||
)
|
||||
.await
|
||||
@@ -1946,6 +1991,7 @@ fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
|
||||
struct PuzzleDashScopeSettings {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
reference_image_model: String,
|
||||
request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
@@ -1960,6 +2006,11 @@ struct PuzzleDownloadedImage {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
struct ParsedPuzzleImageDataUrl {
|
||||
mime_type: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
struct GeneratedPuzzleAssetResponse {
|
||||
image_src: String,
|
||||
asset_id: String,
|
||||
@@ -1994,6 +2045,7 @@ fn require_puzzle_dashscope_settings(
|
||||
Ok(PuzzleDashScopeSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
reference_image_model: state.config.dashscope_reference_image_model.clone(),
|
||||
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
@@ -2036,7 +2088,7 @@ async fn create_puzzle_text_to_image_generation(
|
||||
candidate_count: u32,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let mut parameters = Map::from_iter([
|
||||
("n".to_string(), json!(candidate_count.clamp(1, 2))),
|
||||
("n".to_string(), json!(candidate_count.clamp(1, 1))),
|
||||
("size".to_string(), Value::String(size.to_string())),
|
||||
("prompt_extend".to_string(), Value::Bool(true)),
|
||||
("watermark".to_string(), Value::Bool(false)),
|
||||
@@ -2127,7 +2179,7 @@ async fn create_puzzle_text_to_image_generation(
|
||||
let mut images = Vec::with_capacity(image_urls.len());
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 2) as usize)
|
||||
.take(candidate_count.clamp(1, 1) as usize)
|
||||
{
|
||||
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
|
||||
}
|
||||
@@ -2150,6 +2202,270 @@ async fn create_puzzle_text_to_image_generation(
|
||||
)
|
||||
}
|
||||
|
||||
async fn resolve_puzzle_reference_image_as_data_url(
|
||||
state: &AppState,
|
||||
http_client: &reqwest::Client,
|
||||
source: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let trimmed = source.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图不能为空。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||||
return Ok(format!(
|
||||
"data:{};base64,{}",
|
||||
parsed.mime_type,
|
||||
BASE64_STANDARD.encode(parsed.bytes)
|
||||
));
|
||||
}
|
||||
|
||||
if !trimmed.starts_with('/') {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let object_key = trimmed.trim_start_matches('/');
|
||||
if LegacyAssetPrefix::from_object_key(object_key).is_none() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图当前只支持 /generated-* 旧路径。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: object_key.to_string(),
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
let response = http_client
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("读取拼图参考图失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("读取拼图参考图内容失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取参考图失败,状态码:{status}"),
|
||||
"objectKey": object_key,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if body.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": "读取参考图失败:对象内容为空",
|
||||
"objectKey": object_key,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"data:{};base64,{}",
|
||||
content_type,
|
||||
BASE64_STANDARD.encode(body)
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_puzzle_image_to_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &PuzzleDashScopeSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_image: &str,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let mut content = vec![json!({ "image": reference_image })];
|
||||
content.push(json!({ "text": prompt }));
|
||||
|
||||
let response = http_client
|
||||
.post(format!(
|
||||
"{}/services/aigc/multimodal-generation/generation",
|
||||
settings.base_url
|
||||
))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.json(&json!({
|
||||
"model": settings.reference_image_model.as_str(),
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": content,
|
||||
}
|
||||
],
|
||||
},
|
||||
"parameters": {
|
||||
"n": candidate_count.clamp(1, 1),
|
||||
"size": size,
|
||||
"negative_prompt": negative_prompt,
|
||||
"prompt_extend": true,
|
||||
"watermark": false,
|
||||
},
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("创建拼图参考图生成任务失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("读取拼图参考图生成响应失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_puzzle_dashscope_upstream_error(
|
||||
response_text.as_str(),
|
||||
"创建拼图参考图生成任务失败",
|
||||
));
|
||||
}
|
||||
let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图参考图生成响应失败")?;
|
||||
let image_urls = extract_puzzle_image_urls(&payload);
|
||||
if image_urls.is_empty() {
|
||||
let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "拼图参考图生成未返回 task_id 或图片地址",
|
||||
}))
|
||||
})?;
|
||||
return wait_puzzle_generated_images(
|
||||
http_client,
|
||||
settings,
|
||||
task_id.as_str(),
|
||||
candidate_count,
|
||||
"拼图参考图生成任务失败",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize);
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 1) as usize)
|
||||
{
|
||||
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
|
||||
}
|
||||
Ok(PuzzleGeneratedImages {
|
||||
task_id: format!("puzzle-ref-{}", current_utc_micros()),
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_puzzle_generated_images(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &PuzzleDashScopeSettings,
|
||||
task_id: &str,
|
||||
candidate_count: u32,
|
||||
failure_message: &str,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
|
||||
|
||||
while Instant::now() < deadline {
|
||||
let poll_response = http_client
|
||||
.get(format!("{}/tasks/{}", settings.base_url, task_id))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}"))
|
||||
})?;
|
||||
let poll_status = poll_response.status();
|
||||
let poll_text = poll_response.text().await.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}"))
|
||||
})?;
|
||||
if !poll_status.is_success() {
|
||||
return Err(map_puzzle_dashscope_upstream_error(
|
||||
poll_text.as_str(),
|
||||
"查询拼图图片生成任务失败",
|
||||
));
|
||||
}
|
||||
|
||||
let poll_payload =
|
||||
parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?;
|
||||
let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if task_status == "SUCCEEDED" {
|
||||
let image_urls = extract_puzzle_image_urls(&poll_payload);
|
||||
if image_urls.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "拼图图片生成成功但未返回图片地址",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mut images = Vec::with_capacity(image_urls.len());
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 1) as usize)
|
||||
{
|
||||
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
|
||||
}
|
||||
return Ok(PuzzleGeneratedImages {
|
||||
task_id: task_id.to_string(),
|
||||
images,
|
||||
});
|
||||
}
|
||||
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
|
||||
return Err(map_puzzle_dashscope_upstream_error(
|
||||
poll_text.as_str(),
|
||||
failure_message,
|
||||
));
|
||||
}
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "拼图图片生成超时或未返回图片地址",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn download_puzzle_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
@@ -2278,12 +2594,6 @@ async fn persist_puzzle_generated_asset(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
|
||||
format!(
|
||||
"生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_puzzle_asset_metadata(
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
@@ -2307,6 +2617,46 @@ fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result<V
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_puzzle_image_data_url(value: &str) -> Option<ParsedPuzzleImageDataUrl> {
|
||||
let body = value.strip_prefix("data:")?;
|
||||
let (mime_type, data) = body.split_once(";base64,")?;
|
||||
if !mime_type.starts_with("image/") {
|
||||
return None;
|
||||
}
|
||||
let bytes = decode_puzzle_base64(data)?;
|
||||
Some(ParsedPuzzleImageDataUrl {
|
||||
mime_type: mime_type.to_string(),
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
|
||||
let cleaned = value.trim().replace(char::is_whitespace, "");
|
||||
let mut output = Vec::with_capacity(cleaned.len() * 3 / 4);
|
||||
let mut buffer = 0u32;
|
||||
let mut bits = 0u8;
|
||||
|
||||
for byte in cleaned.bytes() {
|
||||
let value = match byte {
|
||||
b'A'..=b'Z' => byte - b'A',
|
||||
b'a'..=b'z' => byte - b'a' + 26,
|
||||
b'0'..=b'9' => byte - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
b'=' => break,
|
||||
_ => return None,
|
||||
} as u32;
|
||||
buffer = (buffer << 6) | value;
|
||||
bits += 6;
|
||||
while bits >= 8 {
|
||||
bits -= 8;
|
||||
output.push(((buffer >> bits) & 0xFF) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
Some(output)
|
||||
}
|
||||
|
||||
fn extract_puzzle_task_id(payload: &Value) -> Option<String> {
|
||||
find_first_puzzle_string_by_key(payload, "task_id")
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) struct PuzzleAgentTurnRequest<'a> {
|
||||
pub llm_client: Option<&'a LlmClient>,
|
||||
pub session: &'a PuzzleAgentSessionRecord,
|
||||
pub quick_fill_requested: bool,
|
||||
pub enable_web_search: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -128,6 +129,7 @@ where
|
||||
request.llm_client,
|
||||
format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
|
||||
"请按约定输出这一轮的 JSON。",
|
||||
request.enable_web_search,
|
||||
CreationAgentLlmTurnErrorMessages {
|
||||
model_unavailable: "当前模型不可用,请稍后重试。",
|
||||
generation_failed: "拼图聊天生成失败,请稍后重试。",
|
||||
|
||||
@@ -14,12 +14,14 @@ use std::convert::Infallible;
|
||||
|
||||
use crate::{
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
runtime_chat_prompt::{
|
||||
prompt::runtime_chat::{
|
||||
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
|
||||
NpcChatTurnPromptInput, build_npc_chat_turn_reply_prompt,
|
||||
NpcChatTurnPromptInput, build_deterministic_chat_suggestions,
|
||||
build_deterministic_npc_reply, build_fallback_function_suggestions,
|
||||
build_fallback_npc_chat_suggestions, build_npc_chat_turn_reply_prompt,
|
||||
build_npc_chat_turn_suggestion_prompt,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -256,66 +258,6 @@ where
|
||||
Some((npc_reply, suggestions, function_suggestions, force_exit))
|
||||
}
|
||||
|
||||
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_fallback_function_suggestions(chat_directive: Option<&Value>) -> Vec<Value> {
|
||||
read_function_options(chat_directive)
|
||||
.into_iter()
|
||||
.filter(|option| {
|
||||
read_string_field(option, "functionId")
|
||||
.as_deref()
|
||||
.is_some_and(|function_id| function_id != "npc_chat")
|
||||
})
|
||||
.take(2)
|
||||
.filter_map(|option| {
|
||||
let function_id = read_string_field(option, "functionId")?;
|
||||
let action_text = read_string_field(option, "actionText")?;
|
||||
Some(json!({
|
||||
"functionId": function_id,
|
||||
"actionText": action_text,
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_completion_directive(chat_directive: Option<&Value>, force_exit: bool) -> Value {
|
||||
let Some(directive) = chat_directive else {
|
||||
return Value::Null;
|
||||
|
||||
@@ -1,644 +0,0 @@
|
||||
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#"你要为 RPG NPC 聊天生成下一步候选,并判断敌对聊天是否已经收束。
|
||||
只输出 JSON,不要输出 Markdown 或解释。
|
||||
JSON 结构:
|
||||
{"shouldEndChat":false,"terminationReason":null,"suggestions":["温和共情台词","冷静追问台词","施压质疑台词"],"functionSuggestions":[{"functionId":"...","actionText":"玩家动作文本"}]}
|
||||
- suggestions 是玩家下一轮可直接说出口的中文短句,每条 20 字以内;三条必须按顺序导向不同氛围和好感结果。
|
||||
- suggestions 第 1 条温和共情,通常让气氛缓和、好感上升;第 2 条冷静追问或试探,通常保持中性但推进情报;第 3 条施压、质疑或立场冲突,通常让气氛变紧、好感下降或付出代价。
|
||||
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
|
||||
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId,不写规则说明。
|
||||
- 非敌对聊天 shouldEndChat 必须为 false。
|
||||
- 敌对聊天可以随时 shouldEndChat=true,且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
|
||||
|
||||
#[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_hostile_model_chat = chat_directive
|
||||
.and_then(|record| read_string(record.get("terminationMode")))
|
||||
.as_deref()
|
||||
== Some("hostile_model")
|
||||
|| chat_directive
|
||||
.and_then(|record| read_bool(record.get("isHostileChat")))
|
||||
.unwrap_or(false);
|
||||
let is_player_exit_turn = chat_directive
|
||||
.and_then(|record| read_string(record.get("terminationReason")))
|
||||
.as_deref()
|
||||
== Some("player_exit");
|
||||
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_hostile_model_chat {
|
||||
Some("当前是敌对或负好感聊天。对方不受固定回合限制,但随时可能不耐烦、结束谈话并把局势推向战斗或驱逐。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_hostile_model_chat {
|
||||
Some("敌对角色更偏好短促、戒备、带威胁的回应;如果玩家逼问、挑衅、退场或话题触到底线,回复应自然收束到对峙前一刻。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_player_exit_turn {
|
||||
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
|
||||
} 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);
|
||||
let chat_directive = payload.chat_directive.and_then(as_record);
|
||||
let is_hostile_model_chat = chat_directive
|
||||
.and_then(|record| read_string(record.get("terminationMode")))
|
||||
.as_deref()
|
||||
== Some("hostile_model")
|
||||
|| chat_directive
|
||||
.and_then(|record| read_bool(record.get("isHostileChat")))
|
||||
.unwrap_or(false);
|
||||
let is_player_exit_turn = chat_directive
|
||||
.and_then(|record| read_string(record.get("terminationReason")))
|
||||
.as_deref()
|
||||
== Some("player_exit");
|
||||
let function_options_block = chat_directive
|
||||
.and_then(|record| record.get("functionOptions"))
|
||||
.map(describe_function_options)
|
||||
.filter(|text| !text.trim().is_empty());
|
||||
|
||||
[
|
||||
Some(build_npc_dialogue_prompt_base(payload)),
|
||||
Some(describe_npc_conversation_history(
|
||||
conversation_history,
|
||||
encounter.npc_name.as_str(),
|
||||
)),
|
||||
combat_context_block,
|
||||
function_options_block,
|
||||
if payload.npc_initiates_conversation {
|
||||
Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string())
|
||||
} else {
|
||||
Some(format!("玩家刚刚说:{}", payload.player_message))
|
||||
},
|
||||
Some(format!("NPC 刚刚回复:{npc_reply}")),
|
||||
if is_hostile_model_chat {
|
||||
Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙。".to_string())
|
||||
} else {
|
||||
Some("这是非敌对聊天,shouldEndChat 必须为 false。".to_string())
|
||||
},
|
||||
if is_player_exit_turn {
|
||||
Some("玩家已经选择结束聊天,shouldEndChat 必须为 true,terminationReason 必须为 player_exit。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Some("suggestions 必须按顺序生成三种明显不同的玩家台词:温和共情、冷静追问或试探、施压质疑;不要给出同一种态度的近义句。".to_string()),
|
||||
Some("functionSuggestions 从 functionOptions 中挑可触发动作并改写 actionText。".to_string()),
|
||||
Some("只输出 JSON:{\"shouldEndChat\":false,\"terminationReason\":null,\"suggestions\":[\"...\"],\"functionSuggestions\":[{\"functionId\":\"...\",\"actionText\":\"...\"}]}".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
fn describe_function_options(value: &Value) -> String {
|
||||
let lines = value
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.take(8)
|
||||
.filter_map(|item| {
|
||||
let record = as_record(item)?;
|
||||
let function_id = read_string(record.get("functionId"))?;
|
||||
let action_text = read_string(record.get("actionText"))?;
|
||||
let detail_text = read_string(record.get("detailText"));
|
||||
let action = read_string(record.get("action"));
|
||||
Some(format!(
|
||||
"- functionId: {function_id}; actionText: {action_text}; action: {}; detail: {}",
|
||||
action.unwrap_or_else(|| "unknown".to_string()),
|
||||
detail_text.unwrap_or_else(|| "无".to_string()),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut result = vec!["当前聊天中可改写为动作候选的 functionOptions:".to_string()];
|
||||
result.extend(lines);
|
||||
result.join("\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()
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
@@ -70,8 +71,8 @@ pub async fn put_runtime_snapshot(
|
||||
let updated_at_micros = offset_datetime_to_unix_micros(now);
|
||||
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
|
||||
|
||||
let record = state
|
||||
.put_runtime_snapshot_record(
|
||||
let record = if is_non_persistent_runtime_snapshot(&payload.game_state) {
|
||||
build_transient_runtime_snapshot_record(
|
||||
user_id,
|
||||
saved_at_micros,
|
||||
payload.bottom_tab,
|
||||
@@ -79,10 +80,21 @@ pub async fn put_runtime_snapshot(
|
||||
payload.current_story,
|
||||
updated_at_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||
})?;
|
||||
} else {
|
||||
state
|
||||
.put_runtime_snapshot_record(
|
||||
user_id,
|
||||
saved_at_micros,
|
||||
payload.bottom_tab,
|
||||
payload.game_state,
|
||||
payload.current_story,
|
||||
updated_at_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||
})?
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -184,6 +196,52 @@ fn build_saved_game_snapshot_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_transient_runtime_snapshot_record(
|
||||
user_id: String,
|
||||
saved_at_micros: i64,
|
||||
bottom_tab: String,
|
||||
game_state: Value,
|
||||
current_story: Option<Value>,
|
||||
updated_at_micros: i64,
|
||||
) -> module_runtime::RuntimeSnapshotRecord {
|
||||
// 中文注释:预览/测试入口可得到本次响应,但不能覆盖用户正式当前快照。
|
||||
module_runtime::RuntimeSnapshotRecord {
|
||||
user_id,
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
saved_at: format_utc_micros(saved_at_micros),
|
||||
saved_at_micros,
|
||||
bottom_tab,
|
||||
game_state_json: game_state.to_string(),
|
||||
current_story_json: current_story.as_ref().map(Value::to_string),
|
||||
game_state,
|
||||
current_story,
|
||||
created_at_micros: updated_at_micros,
|
||||
updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
|
||||
let Some(game_state) = game_state.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if game_state
|
||||
.get("runtimePersistenceDisabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
game_state
|
||||
.get("runtimeMode")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim),
|
||||
Some("preview") | Some("test")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_profile_save_archive_summary_response(
|
||||
record: &module_runtime::RuntimeProfileSaveArchiveRecord,
|
||||
) -> ProfileSaveArchiveSummaryResponse {
|
||||
|
||||
@@ -8,7 +8,7 @@ use module_npc::{
|
||||
NpcRelationStance, build_initial_stance_profile as build_module_npc_initial_stance_profile,
|
||||
build_relation_state as build_module_npc_relation_state,
|
||||
};
|
||||
use module_runtime::RuntimeSnapshotRecord;
|
||||
use module_runtime::{RuntimeSnapshotRecord, SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||
use module_runtime_story_compat::{
|
||||
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
|
||||
PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution,
|
||||
@@ -376,15 +376,28 @@ async fn persist_runtime_story_snapshot(
|
||||
)
|
||||
})?
|
||||
.unwrap_or(now);
|
||||
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
|
||||
let updated_at_micros = offset_datetime_to_unix_micros(now);
|
||||
|
||||
if is_non_persistent_runtime_story_snapshot(&snapshot) {
|
||||
return Ok(build_transient_runtime_snapshot_record(
|
||||
user_id,
|
||||
saved_at_micros,
|
||||
snapshot.bottom_tab,
|
||||
snapshot.game_state,
|
||||
snapshot.current_story,
|
||||
updated_at_micros,
|
||||
));
|
||||
}
|
||||
|
||||
state
|
||||
.put_runtime_snapshot_record(
|
||||
user_id,
|
||||
offset_datetime_to_unix_micros(saved_at),
|
||||
saved_at_micros,
|
||||
snapshot.bottom_tab,
|
||||
snapshot.game_state,
|
||||
snapshot.current_story,
|
||||
offset_datetime_to_unix_micros(now),
|
||||
updated_at_micros,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
@@ -392,6 +405,52 @@ async fn persist_runtime_story_snapshot(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_transient_runtime_snapshot_record(
|
||||
user_id: String,
|
||||
saved_at_micros: i64,
|
||||
bottom_tab: String,
|
||||
game_state: Value,
|
||||
current_story: Option<Value>,
|
||||
updated_at_micros: i64,
|
||||
) -> RuntimeSnapshotRecord {
|
||||
// 中文注释:预览/测试只需要本次响应里的 hydrated snapshot,不能写入正式存档表。
|
||||
RuntimeSnapshotRecord {
|
||||
user_id,
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
saved_at: format_utc_micros(saved_at_micros),
|
||||
saved_at_micros,
|
||||
bottom_tab,
|
||||
game_state_json: game_state.to_string(),
|
||||
current_story_json: current_story.as_ref().map(Value::to_string),
|
||||
game_state,
|
||||
current_story,
|
||||
created_at_micros: updated_at_micros,
|
||||
updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_non_persistent_runtime_story_snapshot(snapshot: &RuntimeStorySnapshotPayload) -> bool {
|
||||
let Some(game_state) = snapshot.game_state.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if game_state
|
||||
.get("runtimePersistenceDisabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
game_state
|
||||
.get("runtimeMode")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim),
|
||||
Some("preview") | Some("test")
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> {
|
||||
if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() {
|
||||
return Err("snapshot.bottomTab 不能为空".to_string());
|
||||
|
||||
@@ -191,6 +191,133 @@ async fn runtime_story_routes_resolve_through_rust_route_boundary() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_preview_snapshot_returns_transient_response_without_overwriting_save() {
|
||||
let state = seed_authenticated_state().await;
|
||||
let token = issue_access_token(&state);
|
||||
let app = build_router(state);
|
||||
|
||||
let formal_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/runtime/save/snapshot")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"bottomTab": "adventure",
|
||||
"gameState": {
|
||||
"runtimeSessionId": "runtime-main",
|
||||
"runtimeActionVersion": 1,
|
||||
"worldType": "WUXIA",
|
||||
"playerCharacter": { "id": "hero" },
|
||||
"currentScene": "Story",
|
||||
"runtimeStats": { "playTimeMs": 0 },
|
||||
"storyHistory": []
|
||||
},
|
||||
"currentStory": {
|
||||
"text": "正式存档里的故事。",
|
||||
"options": []
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(formal_response.status(), StatusCode::OK);
|
||||
|
||||
let preview_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/story/actions/resolve")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": "runtime-main",
|
||||
"clientVersion": 3,
|
||||
"action": {
|
||||
"type": "story_choice",
|
||||
"functionId": "idle_rest_focus"
|
||||
},
|
||||
"snapshot": {
|
||||
"bottomTab": "adventure",
|
||||
"gameState": {
|
||||
"runtimeSessionId": "runtime-main",
|
||||
"runtimeActionVersion": 3,
|
||||
"runtimeMode": "preview",
|
||||
"runtimePersistenceDisabled": true,
|
||||
"playerHp": 10,
|
||||
"playerMaxHp": 30,
|
||||
"playerMana": 2,
|
||||
"playerMaxMana": 12,
|
||||
"storyHistory": []
|
||||
},
|
||||
"currentStory": {
|
||||
"text": "幕预览里的临时故事。",
|
||||
"options": []
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(preview_response.status(), StatusCode::OK);
|
||||
let preview_payload: Value = serde_json::from_slice(
|
||||
&preview_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes(),
|
||||
)
|
||||
.expect("response should be json");
|
||||
assert_eq!(
|
||||
preview_payload["data"]["snapshot"]["gameState"]["runtimeMode"],
|
||||
json!("preview")
|
||||
);
|
||||
|
||||
let saved_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/runtime/save/snapshot")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-response-envelope", "v1")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(saved_response.status(), StatusCode::OK);
|
||||
let saved_payload: Value = serde_json::from_slice(
|
||||
&saved_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes(),
|
||||
)
|
||||
.expect("response should be json");
|
||||
|
||||
assert_eq!(
|
||||
saved_payload["data"]["currentStory"]["text"],
|
||||
json!("正式存档里的故事。")
|
||||
);
|
||||
assert!(saved_payload["data"]["gameState"]["runtimeMode"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_action_resolve_rejects_client_version_conflict() {
|
||||
let state = seed_authenticated_state().await;
|
||||
|
||||
@@ -15,6 +15,7 @@ pub const PUZZLE_PROFILE_ID_PREFIX: &str = "puzzle-profile-";
|
||||
pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-";
|
||||
pub const PUZZLE_MIN_TAG_COUNT: usize = 3;
|
||||
pub const PUZZLE_MAX_TAG_COUNT: usize = 6;
|
||||
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -680,7 +681,7 @@ pub fn build_generated_candidates(
|
||||
) -> Result<Vec<PuzzleGeneratedImageCandidate>, PuzzleFieldError> {
|
||||
let session_id =
|
||||
normalize_required_string(session_id).ok_or(PuzzleFieldError::MissingSessionId)?;
|
||||
let count = candidate_count.max(1).min(2);
|
||||
let count = candidate_count.max(1).min(1);
|
||||
let prompt = normalize_required_string(prompt_text.unwrap_or(&draft.summary))
|
||||
.unwrap_or_else(|| draft.summary.clone());
|
||||
|
||||
@@ -690,7 +691,7 @@ pub fn build_generated_candidates(
|
||||
let candidate_id = format!("{session_id}-candidate-{}", index + 1);
|
||||
PuzzleGeneratedImageCandidate {
|
||||
candidate_id: candidate_id.clone(),
|
||||
// 拼图候选图的正式持久化由 api-server 上传 OSS;这里仅保留 reducer
|
||||
// 拼图图片的正式持久化由 api-server 上传 OSS;这里仅保留 reducer
|
||||
// 单测/保底路径构造,前缀必须与 OSS 兼容路由一致,不能再指向 public 目录。
|
||||
image_src: format!(
|
||||
"/generated-puzzle-assets/{session_id}/{candidate_seed}/cover.svg"
|
||||
@@ -884,37 +885,18 @@ pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 {
|
||||
}
|
||||
|
||||
pub fn build_initial_board(grid_size: u32) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
|
||||
build_initial_board_with_seed(grid_size, 0)
|
||||
}
|
||||
|
||||
pub fn build_initial_board_with_seed(
|
||||
grid_size: u32,
|
||||
shuffle_seed: u64,
|
||||
) -> Result<PuzzleBoardSnapshot, PuzzleFieldError> {
|
||||
if !matches!(grid_size, 3 | 4) {
|
||||
return Err(PuzzleFieldError::InvalidGridSize);
|
||||
}
|
||||
|
||||
let total = grid_size * grid_size;
|
||||
let mut positions = (0..total)
|
||||
.map(|index| PuzzleCellPosition {
|
||||
row: index / grid_size,
|
||||
col: index % grid_size,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if total > 1 {
|
||||
positions.rotate_left(1);
|
||||
}
|
||||
|
||||
let pieces = (0..total)
|
||||
.map(|index| {
|
||||
let correct_row = index / grid_size;
|
||||
let correct_col = index % grid_size;
|
||||
let current = &positions[index as usize];
|
||||
PuzzlePieceState {
|
||||
piece_id: format!("piece-{index}"),
|
||||
correct_row,
|
||||
correct_col,
|
||||
current_row: current.row,
|
||||
current_col: current.col,
|
||||
merged_group_id: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let pieces = build_initial_pieces_without_correct_neighbors(grid_size, shuffle_seed);
|
||||
|
||||
Ok(rebuild_board_snapshot(grid_size, pieces, None))
|
||||
}
|
||||
@@ -925,7 +907,23 @@ pub fn start_run(
|
||||
cleared_level_count: u32,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
|
||||
let board = build_initial_board(grid_size)?;
|
||||
let shuffle_seed = puzzle_shuffle_seed(
|
||||
&run_id,
|
||||
&entry_profile.profile_id,
|
||||
cleared_level_count + 1,
|
||||
grid_size,
|
||||
);
|
||||
start_run_with_shuffle_seed(run_id, entry_profile, cleared_level_count, shuffle_seed)
|
||||
}
|
||||
|
||||
pub fn start_run_with_shuffle_seed(
|
||||
run_id: String,
|
||||
entry_profile: &PuzzleWorkProfile,
|
||||
cleared_level_count: u32,
|
||||
shuffle_seed: u64,
|
||||
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
|
||||
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
|
||||
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
|
||||
Ok(PuzzleRunSnapshot {
|
||||
run_id: run_id.clone(),
|
||||
entry_profile_id: entry_profile.profile_id.clone(),
|
||||
@@ -989,7 +987,23 @@ pub fn swap_pieces(
|
||||
pieces[second_index].current_row = first_row;
|
||||
pieces[second_index].current_col = first_col;
|
||||
|
||||
let next_board = rebuild_board_snapshot(current_level.grid_size, pieces, None);
|
||||
let affected_cells = [
|
||||
PuzzleCellPosition {
|
||||
row: first_row,
|
||||
col: first_col,
|
||||
},
|
||||
PuzzleCellPosition {
|
||||
row: second_row,
|
||||
col: second_col,
|
||||
},
|
||||
];
|
||||
let next_board = rebuild_board_snapshot_for_affected_cells(
|
||||
current_level.grid_size,
|
||||
¤t_level.board,
|
||||
pieces,
|
||||
affected_cells,
|
||||
None,
|
||||
);
|
||||
Ok(with_next_board(run, next_board))
|
||||
}
|
||||
|
||||
@@ -1019,13 +1033,91 @@ pub fn drag_piece_or_group(
|
||||
.ok_or(PuzzleFieldError::MissingPieceId)?;
|
||||
let source_group_id = pieces[piece_index].merged_group_id.clone();
|
||||
|
||||
match source_group_id {
|
||||
let operation_cells = match source_group_id {
|
||||
Some(group_id) => drag_group(&mut pieces, &group_id, target_row, target_col, grid_size)?,
|
||||
None => drag_single_piece(&mut pieces, piece_index, target_row, target_col)?,
|
||||
};
|
||||
|
||||
let next_board = rebuild_board_snapshot_for_affected_cells(
|
||||
grid_size,
|
||||
¤t_level.board,
|
||||
pieces,
|
||||
operation_cells,
|
||||
None,
|
||||
);
|
||||
Ok(with_next_board(run, next_board))
|
||||
}
|
||||
|
||||
pub fn rebuild_board_snapshot_for_affected_cells(
|
||||
grid_size: u32,
|
||||
previous_board: &PuzzleBoardSnapshot,
|
||||
pieces: Vec<PuzzlePieceState>,
|
||||
affected_cells: impl IntoIterator<Item = PuzzleCellPosition>,
|
||||
selected_piece_id: Option<String>,
|
||||
) -> PuzzleBoardSnapshot {
|
||||
let affected_scope = expand_affected_cells(grid_size, affected_cells);
|
||||
if affected_scope.is_empty() || previous_board.merged_groups.is_empty() {
|
||||
return rebuild_board_snapshot(grid_size, pieces, selected_piece_id);
|
||||
}
|
||||
|
||||
let next_board = rebuild_board_snapshot(grid_size, pieces, None);
|
||||
Ok(with_next_board(run, next_board))
|
||||
let mut recalculated_piece_ids = pieces
|
||||
.iter()
|
||||
.filter(|piece| affected_scope.contains(&(piece.current_row, piece.current_col)))
|
||||
.map(|piece| piece.piece_id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let previous_piece_by_id = previous_board
|
||||
.pieces
|
||||
.iter()
|
||||
.map(|piece| (piece.piece_id.clone(), piece))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
for piece_id in recalculated_piece_ids.clone() {
|
||||
if let Some(previous_piece) = previous_piece_by_id.get(&piece_id)
|
||||
&& let Some(group_id) = previous_piece.merged_group_id.as_deref()
|
||||
{
|
||||
add_previous_group_piece_ids(previous_board, group_id, &mut recalculated_piece_ids);
|
||||
}
|
||||
}
|
||||
|
||||
let mut preserved_groups = Vec::new();
|
||||
for group in &previous_board.merged_groups {
|
||||
if group
|
||||
.piece_ids
|
||||
.iter()
|
||||
.any(|piece_id| recalculated_piece_ids.contains(piece_id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let occupied_cells = group
|
||||
.piece_ids
|
||||
.iter()
|
||||
.filter_map(|piece_id| {
|
||||
pieces
|
||||
.iter()
|
||||
.find(|piece| piece.piece_id == *piece_id)
|
||||
.map(|piece| PuzzleCellPosition {
|
||||
row: piece.current_row,
|
||||
col: piece.current_col,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if occupied_cells.len() == group.piece_ids.len() {
|
||||
preserved_groups.push(PuzzleMergedGroupState {
|
||||
group_id: group.group_id.clone(),
|
||||
piece_ids: group.piece_ids.clone(),
|
||||
occupied_cells,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let recalculated_pieces = pieces
|
||||
.iter()
|
||||
.filter(|piece| recalculated_piece_ids.contains(&piece.piece_id))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut next_groups = preserved_groups;
|
||||
next_groups.extend(resolve_merged_groups(&recalculated_pieces));
|
||||
rebuild_board_snapshot_with_groups(grid_size, pieces, next_groups, selected_piece_id)
|
||||
}
|
||||
|
||||
pub fn advance_next_level(
|
||||
@@ -1042,7 +1134,13 @@ pub fn advance_next_level(
|
||||
|
||||
let next_cleared_count = run.cleared_level_count;
|
||||
let next_grid_size = resolve_puzzle_grid_size(next_cleared_count);
|
||||
let next_board = build_initial_board(next_grid_size)?;
|
||||
let shuffle_seed = puzzle_shuffle_seed(
|
||||
&run.run_id,
|
||||
&next_profile.profile_id,
|
||||
run.current_level_index + 1,
|
||||
next_grid_size,
|
||||
);
|
||||
let next_board = build_initial_board_with_seed(next_grid_size, shuffle_seed)?;
|
||||
let mut played_profile_ids = run.played_profile_ids.clone();
|
||||
played_profile_ids.push(next_profile.profile_id.clone());
|
||||
|
||||
@@ -1258,12 +1356,146 @@ fn split_phrase_list(value: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn puzzle_shuffle_seed(run_id: &str, profile_id: &str, level_index: u32, grid_size: u32) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
|
||||
for byte in run_id
|
||||
.bytes()
|
||||
.chain(profile_id.bytes())
|
||||
.chain(level_index.to_le_bytes())
|
||||
.chain(grid_size.to_le_bytes())
|
||||
{
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn shuffle_positions(positions: &mut [PuzzleCellPosition], seed: u64) {
|
||||
if positions.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = seed ^ ((positions.len() as u64) << 32) ^ 0x9e37_79b9_7f4a_7c15;
|
||||
for index in (1..positions.len()).rev() {
|
||||
state = state
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
let swap_index = (state % ((index + 1) as u64)) as usize;
|
||||
positions.swap(index, swap_index);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_initial_pieces_without_correct_neighbors(
|
||||
grid_size: u32,
|
||||
shuffle_seed: u64,
|
||||
) -> Vec<PuzzlePieceState> {
|
||||
let total = grid_size * grid_size;
|
||||
let base_positions = build_correct_positions(grid_size);
|
||||
for attempt in 0..PUZZLE_INITIAL_SHUFFLE_ATTEMPTS {
|
||||
let mut positions = base_positions.clone();
|
||||
shuffle_positions(
|
||||
&mut positions,
|
||||
shuffle_seed.wrapping_add(attempt.wrapping_mul(0x9e37_79b9_7f4a_7c15)),
|
||||
);
|
||||
ensure_board_is_not_solved(&mut positions, grid_size);
|
||||
let pieces = build_pieces_from_positions(grid_size, &positions);
|
||||
if !has_any_correct_neighbor_pair(&pieces) {
|
||||
return pieces;
|
||||
}
|
||||
}
|
||||
|
||||
// 反序布局等价于把完整棋盘旋转 180 度;任意原图相邻块在当前棋盘中的方向都会反向,
|
||||
// 因此可作为“开局没有正确相邻块”的确定性兜底。
|
||||
let fallback_pieces =
|
||||
build_pieces_from_positions(grid_size, &build_reverse_positions(total, grid_size));
|
||||
debug_assert!(!has_any_correct_neighbor_pair(&fallback_pieces));
|
||||
fallback_pieces
|
||||
}
|
||||
|
||||
fn build_correct_positions(grid_size: u32) -> Vec<PuzzleCellPosition> {
|
||||
let total = grid_size * grid_size;
|
||||
(0..total)
|
||||
.map(|index| PuzzleCellPosition {
|
||||
row: index / grid_size,
|
||||
col: index % grid_size,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_reverse_positions(total: u32, grid_size: u32) -> Vec<PuzzleCellPosition> {
|
||||
(0..total)
|
||||
.rev()
|
||||
.map(|index| PuzzleCellPosition {
|
||||
row: index / grid_size,
|
||||
col: index % grid_size,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_pieces_from_positions(
|
||||
grid_size: u32,
|
||||
positions: &[PuzzleCellPosition],
|
||||
) -> Vec<PuzzlePieceState> {
|
||||
positions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, current)| {
|
||||
let index = index as u32;
|
||||
PuzzlePieceState {
|
||||
piece_id: format!("piece-{index}"),
|
||||
correct_row: index / grid_size,
|
||||
correct_col: index % grid_size,
|
||||
current_row: current.row,
|
||||
current_col: current.col,
|
||||
merged_group_id: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ensure_board_is_not_solved(positions: &mut [PuzzleCellPosition], grid_size: u32) {
|
||||
if positions.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_solved = positions.iter().enumerate().all(|(index, position)| {
|
||||
position.row == index as u32 / grid_size && position.col == index as u32 % grid_size
|
||||
});
|
||||
if is_solved {
|
||||
positions.rotate_left(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn has_any_correct_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
|
||||
let pieces_by_cell = pieces
|
||||
.iter()
|
||||
.map(|piece| ((piece.current_row, piece.current_col), piece))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
pieces.iter().any(|piece| {
|
||||
neighbor_cells(piece.current_row, piece.current_col)
|
||||
.into_iter()
|
||||
.filter_map(|cell| pieces_by_cell.get(&cell))
|
||||
.any(|neighbor| are_correct_neighbors(piece, neighbor))
|
||||
})
|
||||
}
|
||||
|
||||
fn rebuild_board_snapshot(
|
||||
grid_size: u32,
|
||||
mut pieces: Vec<PuzzlePieceState>,
|
||||
pieces: Vec<PuzzlePieceState>,
|
||||
selected_piece_id: Option<String>,
|
||||
) -> PuzzleBoardSnapshot {
|
||||
let merged_groups = resolve_merged_groups(&pieces);
|
||||
rebuild_board_snapshot_with_groups(grid_size, pieces, merged_groups, selected_piece_id)
|
||||
}
|
||||
|
||||
fn rebuild_board_snapshot_with_groups(
|
||||
grid_size: u32,
|
||||
mut pieces: Vec<PuzzlePieceState>,
|
||||
merged_groups: Vec<PuzzleMergedGroupState>,
|
||||
selected_piece_id: Option<String>,
|
||||
) -> PuzzleBoardSnapshot {
|
||||
let merged_groups = normalize_group_ids(merged_groups);
|
||||
let group_by_piece = merged_groups
|
||||
.iter()
|
||||
.flat_map(|group| {
|
||||
@@ -1279,9 +1511,13 @@ fn rebuild_board_snapshot(
|
||||
piece.merged_group_id = group_by_piece.get(&piece.piece_id).cloned();
|
||||
}
|
||||
|
||||
let all_tiles_resolved = pieces.iter().all(|piece| {
|
||||
let all_pieces_in_correct_cells = pieces.iter().all(|piece| {
|
||||
piece.correct_row == piece.current_row && piece.correct_col == piece.current_col
|
||||
});
|
||||
let all_pieces_merged_into_one_group = merged_groups
|
||||
.iter()
|
||||
.any(|group| group.piece_ids.len() == pieces.len() && pieces.len() > 1);
|
||||
let all_tiles_resolved = all_pieces_in_correct_cells || all_pieces_merged_into_one_group;
|
||||
|
||||
PuzzleBoardSnapshot {
|
||||
rows: grid_size,
|
||||
@@ -1293,6 +1529,50 @@ fn rebuild_board_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_group_ids(groups: Vec<PuzzleMergedGroupState>) -> Vec<PuzzleMergedGroupState> {
|
||||
groups
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, group)| PuzzleMergedGroupState {
|
||||
group_id: format!("group-{}", index + 1),
|
||||
..group
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn expand_affected_cells(
|
||||
grid_size: u32,
|
||||
cells: impl IntoIterator<Item = PuzzleCellPosition>,
|
||||
) -> BTreeSet<(u32, u32)> {
|
||||
let mut scope = BTreeSet::new();
|
||||
for cell in cells {
|
||||
if cell.row >= grid_size || cell.col >= grid_size {
|
||||
continue;
|
||||
}
|
||||
scope.insert((cell.row, cell.col));
|
||||
for (row, col) in neighbor_cells(cell.row, cell.col) {
|
||||
if row < grid_size && col < grid_size {
|
||||
scope.insert((row, col));
|
||||
}
|
||||
}
|
||||
}
|
||||
scope
|
||||
}
|
||||
|
||||
fn add_previous_group_piece_ids(
|
||||
previous_board: &PuzzleBoardSnapshot,
|
||||
group_id: &str,
|
||||
piece_ids: &mut BTreeSet<String>,
|
||||
) {
|
||||
if let Some(group) = previous_board
|
||||
.merged_groups
|
||||
.iter()
|
||||
.find(|group| group.group_id == group_id)
|
||||
{
|
||||
piece_ids.extend(group.piece_ids.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_merged_groups(pieces: &[PuzzlePieceState]) -> Vec<PuzzleMergedGroupState> {
|
||||
let pieces_by_cell = pieces
|
||||
.iter()
|
||||
@@ -1385,17 +1665,32 @@ fn drag_single_piece(
|
||||
piece_index: usize,
|
||||
target_row: u32,
|
||||
target_col: u32,
|
||||
) -> Result<(), PuzzleFieldError> {
|
||||
) -> Result<Vec<PuzzleCellPosition>, PuzzleFieldError> {
|
||||
let target_index = pieces
|
||||
.iter()
|
||||
.position(|piece| piece.current_row == target_row && piece.current_col == target_col)
|
||||
.ok_or(PuzzleFieldError::InvalidTargetCell)?;
|
||||
|
||||
let mut affected_cells = vec![
|
||||
PuzzleCellPosition {
|
||||
row: pieces[piece_index].current_row,
|
||||
col: pieces[piece_index].current_col,
|
||||
},
|
||||
PuzzleCellPosition {
|
||||
row: target_row,
|
||||
col: target_col,
|
||||
},
|
||||
];
|
||||
|
||||
if let Some(target_group_id) = pieces[target_index].merged_group_id.clone() {
|
||||
for piece in pieces
|
||||
.iter_mut()
|
||||
.filter(|piece| piece.merged_group_id.as_deref() == Some(target_group_id.as_str()))
|
||||
{
|
||||
affected_cells.push(PuzzleCellPosition {
|
||||
row: piece.current_row,
|
||||
col: piece.current_col,
|
||||
});
|
||||
piece.merged_group_id = None;
|
||||
}
|
||||
}
|
||||
@@ -1410,7 +1705,7 @@ fn drag_single_piece(
|
||||
pieces[target_index].current_row = source_row;
|
||||
pieces[target_index].current_col = source_col;
|
||||
}
|
||||
Ok(())
|
||||
Ok(affected_cells)
|
||||
}
|
||||
|
||||
fn drag_group(
|
||||
@@ -1419,7 +1714,7 @@ fn drag_group(
|
||||
target_row: u32,
|
||||
target_col: u32,
|
||||
grid_size: u32,
|
||||
) -> Result<(), PuzzleFieldError> {
|
||||
) -> Result<Vec<PuzzleCellPosition>, PuzzleFieldError> {
|
||||
let group_indices = pieces
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -1456,8 +1751,19 @@ fn drag_group(
|
||||
.iter()
|
||||
.map(|index| (pieces[*index].current_row, pieces[*index].current_col))
|
||||
.collect::<Vec<_>>();
|
||||
let mut affected_cells = source_positions
|
||||
.iter()
|
||||
.map(|(row, col)| PuzzleCellPosition {
|
||||
row: *row,
|
||||
col: *col,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (index, next_row, next_col) in &target_positions {
|
||||
affected_cells.push(PuzzleCellPosition {
|
||||
row: *next_row,
|
||||
col: *next_col,
|
||||
});
|
||||
if let Some(target_piece_index) = pieces.iter().position(|piece| {
|
||||
piece.current_row == *next_row
|
||||
&& piece.current_col == *next_col
|
||||
@@ -1473,6 +1779,14 @@ fn drag_group(
|
||||
.copied()
|
||||
.ok_or(PuzzleFieldError::InvalidOperation)?;
|
||||
pieces[target_piece_index].merged_group_id = None;
|
||||
affected_cells.push(PuzzleCellPosition {
|
||||
row: pieces[target_piece_index].current_row,
|
||||
col: pieces[target_piece_index].current_col,
|
||||
});
|
||||
affected_cells.push(PuzzleCellPosition {
|
||||
row: fallback.0,
|
||||
col: fallback.1,
|
||||
});
|
||||
pieces[target_piece_index].current_row = fallback.0;
|
||||
pieces[target_piece_index].current_col = fallback.1;
|
||||
}
|
||||
@@ -1480,7 +1794,7 @@ fn drag_group(
|
||||
pieces[*index].current_col = *next_col;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(affected_cells)
|
||||
}
|
||||
|
||||
fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) -> PuzzleRunSnapshot {
|
||||
@@ -1553,13 +1867,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_candidates_use_oss_compatible_prefix() {
|
||||
fn generated_candidate_uses_oss_compatible_prefix_and_single_image() {
|
||||
let anchor_pack = infer_anchor_pack("雨夜猫咪", Some("雨夜猫咪"));
|
||||
let draft = compile_result_draft(&anchor_pack, &[]);
|
||||
let candidates = build_generated_candidates("session-1", None, &draft, 2, 1_000)
|
||||
.expect("candidates should build");
|
||||
|
||||
assert_eq!(candidates.len(), 2);
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert!(
|
||||
candidates[0]
|
||||
.image_src
|
||||
@@ -1611,6 +1925,281 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_board_shuffle_changes_by_run_id() {
|
||||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
let first = start_run("run-random-a".to_string(), &profile, 0).expect("first run");
|
||||
let second = start_run("run-random-b".to_string(), &profile, 0).expect("second run");
|
||||
let first_positions = first
|
||||
.current_level
|
||||
.expect("first level")
|
||||
.board
|
||||
.pieces
|
||||
.into_iter()
|
||||
.map(|piece| (piece.current_row, piece.current_col))
|
||||
.collect::<Vec<_>>();
|
||||
let second_positions = second
|
||||
.current_level
|
||||
.expect("second level")
|
||||
.board
|
||||
.pieces
|
||||
.into_iter()
|
||||
.map(|piece| (piece.current_row, piece.current_col))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_ne!(first_positions, second_positions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_board_has_no_correct_neighbor_pairs() {
|
||||
for grid_size in [3, 4] {
|
||||
for shuffle_seed in 0..128 {
|
||||
let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board");
|
||||
|
||||
assert!(board.merged_groups.is_empty());
|
||||
assert!(
|
||||
!has_any_correct_neighbor_pair(&board.pieces),
|
||||
"grid_size={grid_size}, shuffle_seed={shuffle_seed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correct_neighbors_auto_merge_after_swap() {
|
||||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
let mut run =
|
||||
start_run_with_shuffle_seed("run-merge".to_string(), &profile, 0, 7).expect("run");
|
||||
let current_level = run.current_level.as_mut().expect("level");
|
||||
current_level.board = rebuild_board_snapshot(
|
||||
3,
|
||||
vec![
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-0".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 0,
|
||||
current_row: 1,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-1".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 1,
|
||||
current_row: 0,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-2".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 2,
|
||||
current_row: 2,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-3".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 0,
|
||||
current_row: 0,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-4".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 1,
|
||||
current_row: 1,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-5".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 2,
|
||||
current_row: 2,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-6".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 0,
|
||||
current_row: 0,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-7".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 1,
|
||||
current_row: 1,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-8".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 2,
|
||||
current_row: 2,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
],
|
||||
None,
|
||||
);
|
||||
|
||||
let swapped = swap_pieces(&run, "piece-0", "piece-6").expect("swap");
|
||||
let board = &swapped.current_level.as_ref().expect("level").board;
|
||||
let group = board
|
||||
.merged_groups
|
||||
.iter()
|
||||
.find(|group| {
|
||||
group.piece_ids.contains(&"piece-0".to_string())
|
||||
&& group.piece_ids.contains(&"piece-1".to_string())
|
||||
})
|
||||
.expect("piece-0 and piece-1 should merge");
|
||||
|
||||
assert_eq!(group.piece_ids.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_piece_dragging_into_group_splits_target_group() {
|
||||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
let mut run =
|
||||
start_run_with_shuffle_seed("run-split".to_string(), &profile, 0, 9).expect("run");
|
||||
let current_level = run.current_level.as_mut().expect("level");
|
||||
current_level.board = rebuild_board_snapshot(
|
||||
3,
|
||||
vec![
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-0".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 0,
|
||||
current_row: 0,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-1".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 1,
|
||||
current_row: 0,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-2".to_string(),
|
||||
correct_row: 0,
|
||||
correct_col: 2,
|
||||
current_row: 2,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-3".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 0,
|
||||
current_row: 1,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-4".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 1,
|
||||
current_row: 1,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-5".to_string(),
|
||||
correct_row: 1,
|
||||
correct_col: 2,
|
||||
current_row: 1,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-6".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 0,
|
||||
current_row: 2,
|
||||
current_col: 0,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-7".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 1,
|
||||
current_row: 2,
|
||||
current_col: 1,
|
||||
merged_group_id: None,
|
||||
},
|
||||
PuzzlePieceState {
|
||||
piece_id: "piece-8".to_string(),
|
||||
correct_row: 2,
|
||||
correct_col: 2,
|
||||
current_row: 0,
|
||||
current_col: 2,
|
||||
merged_group_id: None,
|
||||
},
|
||||
],
|
||||
None,
|
||||
);
|
||||
|
||||
let dragged = drag_piece_or_group(&run, "piece-8", 0, 1).expect("drag");
|
||||
let board = &dragged.current_level.as_ref().expect("level").board;
|
||||
|
||||
assert_eq!(
|
||||
board
|
||||
.pieces
|
||||
.iter()
|
||||
.find(|piece| piece.piece_id == "piece-8")
|
||||
.map(|piece| (piece.current_row, piece.current_col)),
|
||||
Some((0, 1))
|
||||
);
|
||||
assert!(
|
||||
board
|
||||
.merged_groups
|
||||
.iter()
|
||||
.all(|group| !(group.piece_ids.contains(&"piece-0".to_string())
|
||||
&& group.piece_ids.contains(&"piece-1".to_string())))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_full_board_group_marks_level_cleared() {
|
||||
let pieces = (0..9)
|
||||
.map(|index| PuzzlePieceState {
|
||||
piece_id: format!("piece-{index}"),
|
||||
correct_row: index / 3,
|
||||
correct_col: index % 3,
|
||||
current_row: index / 3,
|
||||
current_col: (index + 1) % 3,
|
||||
merged_group_id: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let board = rebuild_board_snapshot_with_groups(
|
||||
3,
|
||||
pieces,
|
||||
vec![PuzzleMergedGroupState {
|
||||
group_id: "group-full".to_string(),
|
||||
piece_ids: (0..9).map(|index| format!("piece-{index}")).collect(),
|
||||
occupied_cells: (0..9)
|
||||
.map(|index| PuzzleCellPosition {
|
||||
row: index / 3,
|
||||
col: (index + 1) % 3,
|
||||
})
|
||||
.collect(),
|
||||
}],
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(board.all_tiles_resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_publish_overrides_updates_draft_truth() {
|
||||
let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙"));
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct ExecutePuzzleAgentActionRequest {
|
||||
#[serde(default)]
|
||||
pub prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub candidate_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub candidate_id: Option<String>,
|
||||
|
||||
@@ -56,6 +56,16 @@ pub struct PuzzleMergedGroupStateResponse {
|
||||
pub occupied_cells: Vec<PuzzleCellPositionResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PuzzleLeaderboardEntryResponse {
|
||||
pub rank: u32,
|
||||
pub nickname: String,
|
||||
pub elapsed_ms: u64,
|
||||
#[serde(default)]
|
||||
pub is_current_player: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PuzzleBoardSnapshotResponse {
|
||||
@@ -82,6 +92,14 @@ pub struct PuzzleRuntimeLevelSnapshotResponse {
|
||||
pub cover_image_src: Option<String>,
|
||||
pub board: PuzzleBoardSnapshotResponse,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub started_at_ms: u64,
|
||||
#[serde(default)]
|
||||
pub cleared_at_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub elapsed_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -98,6 +116,8 @@ pub struct PuzzleRunSnapshotResponse {
|
||||
pub current_level: Option<PuzzleRuntimeLevelSnapshotResponse>,
|
||||
#[serde(default)]
|
||||
pub recommended_next_profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::*;
|
||||
const ASSET_HISTORY_MAX_LIMIT: usize = 120;
|
||||
const ASSET_HISTORY_CHARACTER_VISUAL_KIND: &str = "character_visual";
|
||||
const ASSET_HISTORY_SCENE_IMAGE_KIND: &str = "scene_image";
|
||||
const ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND: &str = "puzzle_cover_image";
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = asset_object,
|
||||
@@ -199,8 +200,11 @@ fn list_asset_history(
|
||||
let asset_kind = input.asset_kind.trim();
|
||||
if asset_kind != ASSET_HISTORY_CHARACTER_VISUAL_KIND
|
||||
&& asset_kind != ASSET_HISTORY_SCENE_IMAGE_KIND
|
||||
&& asset_kind != ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND
|
||||
{
|
||||
return Err("历史素材类型只支持 character_visual 或 scene_image".to_string());
|
||||
return Err(
|
||||
"历史素材类型只支持 character_visual、scene_image 或 puzzle_cover_image".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let limit = usize::try_from(input.limit)
|
||||
|
||||
@@ -685,7 +685,7 @@ fn save_puzzle_generated_images_tx(
|
||||
if candidates.is_empty() {
|
||||
return Err("拼图候选图不能为空".to_string());
|
||||
}
|
||||
append_generated_candidates(&mut draft, candidates);
|
||||
replace_generated_candidate(&mut draft, candidates);
|
||||
draft.generation_status = "ready".to_string();
|
||||
if let Some(selected) = draft
|
||||
.candidates
|
||||
@@ -724,7 +724,7 @@ fn save_puzzle_generated_images_tx(
|
||||
stage: next_stage,
|
||||
anchor_pack_json: row.anchor_pack_json.clone(),
|
||||
draft_json: Some(serialize_json(&draft)),
|
||||
last_assistant_reply: Some("候选图已经生成,请选择正式拼图图片。".to_string()),
|
||||
last_assistant_reply: Some("拼图图片已经生成,并已替换当前正式图。".to_string()),
|
||||
published_profile_id: row.published_profile_id.clone(),
|
||||
created_at: row.created_at,
|
||||
updated_at: saved_at,
|
||||
@@ -1510,21 +1510,19 @@ fn increment_puzzle_profile_play_count(
|
||||
);
|
||||
}
|
||||
|
||||
fn append_generated_candidates(
|
||||
fn replace_generated_candidate(
|
||||
draft: &mut PuzzleResultDraft,
|
||||
candidates: Vec<PuzzleGeneratedImageCandidate>,
|
||||
) {
|
||||
let has_selected_candidate = draft.candidates.iter().any(|entry| entry.selected);
|
||||
// 再次生成图片是扩充候选池,不覆盖创作者已经看到或已经选择的候选图。
|
||||
// 若已有正式选择,新追加候选图保持未选中,避免同一草稿出现多个 selected。
|
||||
draft
|
||||
.candidates
|
||||
.extend(candidates.into_iter().map(|mut candidate| {
|
||||
if has_selected_candidate {
|
||||
candidate.selected = false;
|
||||
}
|
||||
// 结果页生图采用单图替换:每次只保留最新图片,并立即作为正式图。
|
||||
draft.candidates = candidates
|
||||
.into_iter()
|
||||
.take(1)
|
||||
.map(|mut candidate| {
|
||||
candidate.selected = true;
|
||||
candidate
|
||||
}));
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn list_published_puzzle_profiles(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
|
||||
@@ -1634,7 +1632,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_generated_images_are_appended_without_clearing_existing_candidates() {
|
||||
fn puzzle_generated_images_replace_existing_candidate() {
|
||||
let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪"));
|
||||
let mut draft = compile_result_draft(&anchor_pack, &[]);
|
||||
draft.candidates = vec![PuzzleGeneratedImageCandidate {
|
||||
@@ -1647,7 +1645,7 @@ mod tests {
|
||||
selected: true,
|
||||
}];
|
||||
|
||||
append_generated_candidates(
|
||||
replace_generated_candidate(
|
||||
&mut draft,
|
||||
vec![PuzzleGeneratedImageCandidate {
|
||||
candidate_id: "session-1-candidate-2".to_string(),
|
||||
@@ -1660,11 +1658,9 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(draft.candidates.len(), 2);
|
||||
assert_eq!(draft.candidates[0].candidate_id, "session-1-candidate-1");
|
||||
assert_eq!(draft.candidates.len(), 1);
|
||||
assert_eq!(draft.candidates[0].candidate_id, "session-1-candidate-2");
|
||||
assert!(draft.candidates[0].selected);
|
||||
assert_eq!(draft.candidates[1].candidate_id, "session-1-candidate-2");
|
||||
assert!(!draft.candidates[1].selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -434,6 +434,10 @@ pub(crate) fn sync_profile_projections_from_snapshot(
|
||||
let game_state_object = game_state.as_object();
|
||||
let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros);
|
||||
|
||||
if is_non_persistent_runtime_snapshot(&game_state) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sync_profile_dashboard_from_snapshot(ctx, snapshot, game_state_object, saved_at);
|
||||
sync_profile_save_archive_from_snapshot(ctx, snapshot, &game_state, saved_at)?;
|
||||
|
||||
@@ -740,6 +744,10 @@ fn resolve_profile_save_archive_meta(
|
||||
game_state: &JsonValue,
|
||||
current_story_json: Option<&str>,
|
||||
) -> Option<ProfileSaveArchiveMeta> {
|
||||
if is_non_persistent_runtime_snapshot(game_state) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let game_state_object = game_state.as_object();
|
||||
let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?;
|
||||
let story_engine_memory = game_state_object
|
||||
@@ -813,6 +821,25 @@ fn resolve_profile_save_archive_meta(
|
||||
})
|
||||
}
|
||||
|
||||
fn is_non_persistent_runtime_snapshot(game_state: &JsonValue) -> bool {
|
||||
let Some(game_state) = game_state.as_object() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if game_state
|
||||
.get("runtimePersistenceDisabled")
|
||||
.and_then(JsonValue::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
read_string_from_json(game_state.get("runtimeMode")).as_deref(),
|
||||
Some("preview") | Some("test")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_builtin_world_title(world_type: &str) -> String {
|
||||
match world_type {
|
||||
"WUXIA" => "武侠世界".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user