This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -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),

View File

@@ -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"));

View File

@@ -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());

View File

@@ -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: "大鱼吃小鱼聊天生成失败,请稍后重试。",

View File

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

View File

@@ -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);
}
}

View File

@@ -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());

View File

@@ -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,
&current_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: "这一轮设定生成失败,请稍后重试。",

View File

@@ -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>,

View File

@@ -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;

View File

@@ -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;

View 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("文字水印"));
}
}

View File

@@ -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 必须为 trueterminationReason 必须为 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()
}
}

View File

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

View File

@@ -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: "拼图聊天生成失败,请稍后重试。",

View File

@@ -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;

View File

@@ -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 必须为 trueterminationReason 必须为 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()
}
}

View File

@@ -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 {

View File

@@ -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());

View File

@@ -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;

View File

@@ -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,
&current_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,
&current_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("雨夜猫咪神庙"));

View File

@@ -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>,

View File

@@ -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)]

View File

@@ -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)

View File

@@ -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]

View File

@@ -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(),