This commit is contained in:
2026-05-01 00:33:39 +08:00
parent 61969c5116
commit fe02603ba1
68 changed files with 4586 additions and 748 deletions

View File

@@ -23,8 +23,8 @@ use shared_contracts::assets::{
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
@@ -119,6 +119,7 @@ pub async fn get_asset_read_url(
pub async fn get_asset_history(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Query(query): Query<AssetHistoryQuery>,
) -> Result<Json<Value>, AppError> {
let asset_kind = query.kind.trim().to_string();
@@ -133,18 +134,23 @@ pub async fn get_asset_history(
let entries = state
.spacetime_client()
.list_asset_history(module_assets::AssetHistoryListInput {
asset_kind,
limit: query.limit.unwrap_or(120).clamp(1, 120),
})
.list_asset_history(build_asset_history_list_input(asset_kind, query.limit))
.await
.map_err(map_confirm_asset_object_error)?;
let owner_user_id = authenticated.claims().user_id().to_string();
Ok(json_success_body(
Some(&request_context),
AssetHistoryListResponse {
assets: entries
.into_iter()
// 中文注释Maincloud 旧 wasm 的历史素材 procedure 仍按类型返回HTTP 门面必须兜底做账号隔离。
.filter(|entry| {
is_asset_history_owned_by(
entry.owner_user_id.as_deref(),
owner_user_id.as_str(),
)
})
.map(|entry| AssetHistoryEntryPayload {
owner_label: format_asset_owner_label(entry.owner_user_id.as_deref()),
asset_object_id: entry.asset_object_id,
@@ -296,6 +302,25 @@ fn is_supported_asset_history_kind(asset_kind: &str) -> bool {
SUPPORTED_ASSET_HISTORY_KINDS.contains(&asset_kind)
}
fn is_asset_history_owned_by(entry_owner_user_id: Option<&str>, owner_user_id: &str) -> bool {
let owner_user_id = owner_user_id.trim();
!owner_user_id.is_empty()
&& entry_owner_user_id
.map(str::trim)
.filter(|value| !value.is_empty())
== Some(owner_user_id)
}
fn build_asset_history_list_input(
asset_kind: String,
limit: Option<u32>,
) -> module_assets::AssetHistoryListInput {
module_assets::AssetHistoryListInput {
asset_kind,
limit: limit.unwrap_or(120).clamp(1, 120),
}
}
fn supported_asset_history_kind_message() -> String {
format!(
"历史素材类型只支持 {}",
@@ -490,6 +515,29 @@ mod tests {
);
}
#[test]
fn asset_history_owner_filter_keeps_only_authenticated_owner_assets() {
assert!(super::is_asset_history_owned_by(
Some("user-current"),
"user-current"
));
assert!(!super::is_asset_history_owned_by(
Some("user-other"),
"user-current"
));
assert!(!super::is_asset_history_owned_by(None, "user-current"));
assert!(!super::is_asset_history_owned_by(Some("user-current"), ""));
}
#[test]
fn asset_history_input_clamps_limit_for_spacetime_query() {
let input =
super::build_asset_history_list_input("puzzle_cover_image".to_string(), Some(240));
assert_eq!(input.asset_kind, "puzzle_cover_image");
assert_eq!(input.limit, 120);
}
#[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

@@ -0,0 +1,86 @@
/// 拼图作品草稿生成动作的提示词主源。
///
/// 拼图结果页草稿本体仍由 SpacetimeDB reducer 按表单/锚点确定性编译;
/// 这里收口 api-server 在生成草稿前后需要写入 reducer 的表单 seed 文本,
/// 以及草稿首图生成时的 prompt 来源选择,避免业务路由直接拼提示词文本。
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct PuzzleFormSeedPromptParts<'a> {
pub(crate) title: Option<&'a str>,
pub(crate) work_description: Option<&'a str>,
pub(crate) picture_description: Option<&'a str>,
}
/// 将填表式拼图输入编译成 SpacetimeDB 可恢复的表单 seed prompt。
pub(crate) fn build_puzzle_form_seed_prompt(parts: PuzzleFormSeedPromptParts<'_>) -> String {
[
("作品名称", normalize_prompt_part(parts.title)),
("作品描述", normalize_prompt_part(parts.work_description)),
("画面描述", normalize_prompt_part(parts.picture_description)),
]
.into_iter()
.filter_map(|(label, value)| value.map(|value| format!("{label}{value}")))
.collect::<Vec<_>>()
.join("\n")
}
/// 生成作品草稿时,首图 prompt 优先使用玩家当前表单里的画面描述。
pub(crate) fn resolve_puzzle_draft_cover_prompt(
explicit_prompt: Option<&str>,
level_picture_description: &str,
draft_summary: &str,
) -> String {
normalize_prompt_part(explicit_prompt)
.or_else(|| normalize_prompt_part(Some(level_picture_description)))
.or_else(|| normalize_prompt_part(Some(draft_summary)))
.unwrap_or_default()
.to_string()
}
/// 结果页单关重新生成时,优先使用面板当前编辑态 prompt再回退关卡画面描述。
pub(crate) fn resolve_puzzle_level_image_prompt(
explicit_prompt: Option<&str>,
level_picture_description: &str,
) -> String {
normalize_prompt_part(explicit_prompt)
.or_else(|| normalize_prompt_part(Some(level_picture_description)))
.unwrap_or_default()
.to_string()
}
fn normalize_prompt_part(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn form_seed_prompt_keeps_only_user_visible_fields() {
let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
title: Some(" 暖灯猫街 "),
work_description: Some("雨夜礼物拼图"),
picture_description: Some("猫咪在灯牌下回头"),
});
assert_eq!(
prompt,
"作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头"
);
}
#[test]
fn draft_cover_prompt_prefers_current_picture_description() {
let prompt =
resolve_puzzle_draft_cover_prompt(Some(" 当前表单画面 "), "旧关卡画面", "作品简介");
assert_eq!(prompt, "当前表单画面");
}
#[test]
fn level_image_prompt_falls_back_to_level_description() {
let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述");
assert_eq!(prompt, "关卡画面描述");
}
}

View File

@@ -38,17 +38,15 @@ pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> Strin
image_prompt
}
fn build_puzzle_image_prompt_text(level_name: &str, prompt: &str) -> String {
fn build_puzzle_image_prompt_text(_level_name: &str, prompt: &str) -> String {
format!(
concat!(
"请生成一张高清插画。",
"关卡名:{level_name}。",
"画面主体:{prompt}。",
"画面要求1:1 正方形拼图关卡,适配 3x3 或 4x4 拼图切块,",
"画面要求1:1",
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
"避免文字、水印、边框和 UI 元素。"
),
level_name = level_name,
prompt = prompt,
)
}
@@ -78,10 +76,9 @@ mod tests {
fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() {
let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索");
assert!(prompt.contains("雨夜神庙"));
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
assert!(prompt.contains("1:1 正方形拼图关卡"));
assert!(prompt.contains("3x3 或 4x4"));
assert!(prompt.contains("1:1"));
assert!(prompt.contains("主体要清晰集中"));
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
}
@@ -93,8 +90,8 @@ mod tests {
let prompt = build_puzzle_image_prompt(long_level_name.as_str(), long_description.as_str());
assert!(prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS);
assert!(prompt.contains("1:1 正方形拼图关卡"));
assert!(prompt.contains("3x3 或 4x4"));
assert!(prompt.contains("1:1"));
assert!(prompt.contains("主体要清晰集中"));
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
}

View File

@@ -1,2 +1,3 @@
pub(crate) mod agent_chat;
pub(crate) mod draft;
pub(crate) mod image;

View File

@@ -240,7 +240,10 @@ JSON 结构:
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId不写规则说明。
- 非敌对聊天 shouldEndChat 必须为 false。
- 敌对聊天可以随时 shouldEndChat=true,且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
- 敌对聊天可以随时 shouldEndChat=true
- 敌对 NPC 感知到玩家负面发言时,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,应倾向立即 shouldEndChat=true。
- 敌对 NPC 已聊天轮次达到 4 轮或以上时,本轮结束后会超过 4 轮,应倾向立即 shouldEndChat=true。
- shouldEndChat=true 时 terminationReason 使用 hostile_breakoffsuggestions 与 functionSuggestions 可以为空。"#;
#[derive(Debug)]
pub(crate) struct NpcChatTurnPromptInput<'a> {
@@ -394,6 +397,19 @@ pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<
} else {
None
},
if is_hostile_model_chat {
Some("如果玩家刚才的话被 NPC 感知为负面发言,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,本轮回复应倾向写成最后通牒、驱逐前警告或战斗前狠话。".to_string())
} else {
None
},
if is_hostile_model_chat && chatted_count >= 4.0 {
Some(format!(
"敌对聊天已持续 {} 轮,本轮结束后会超过 4 轮;回复应明显倾向立即收束,像开战前最后一句狠话,而不是继续闲聊。",
format_prompt_number(chatted_count)
))
} else {
None
},
if is_player_exit_turn {
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
} else {
@@ -474,6 +490,9 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let chatted_count = as_record(payload.npc_state)
.and_then(|record| read_number(record.get("chattedCount")))
.unwrap_or(0.0);
let function_options_block = chat_directive
.and_then(|record| record.get("functionOptions"))
.map(describe_function_options)
@@ -498,6 +517,14 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
} else {
Some("这是非敌对聊天shouldEndChat 必须为 false。".to_string())
},
if is_hostile_model_chat {
Some(format!(
"敌对聊天判定:已聊天轮次为 {}。若玩家刚才的话可被 NPC 感知为负面发言,或已聊天轮次达到 4 轮及以上,本轮应倾向 shouldEndChat=true并使用 terminationReason=hostile_breakoff。",
format_prompt_number(chatted_count)
))
} else {
None
},
if is_player_exit_turn {
Some("玩家已经选择结束聊天shouldEndChat 必须为 trueterminationReason 必须为 player_exit。".to_string())
} else {
@@ -526,6 +553,20 @@ pub(crate) fn build_deterministic_npc_reply(
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
}
pub(crate) fn build_deterministic_hostile_breakoff_reply(
npc_name: &str,
player_message: &str,
) -> String {
// 中文注释:当模型不可用而敌对聊天必须中止时,兜底文案也保持“战斗前狠话”的语气。
let player_signal = player_message.trim();
if player_signal.is_empty() {
return format!("{npc_name}冷声说道:“话已经够多了。再往前一步,就别指望还能全身而退。”");
}
format!(
"{npc_name}冷声说道:“{player_signal}?话已经够多了。再往前一步,就别指望还能全身而退。”"
)
}
pub(crate) fn build_character_chat_reply_fallback(
target_character: &Value,
player_message: &str,
@@ -1066,3 +1107,55 @@ fn format_prompt_number(value: f64) -> String {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hostile_prompt_input(npc_state: Value) -> NpcChatTurnPromptInput<'static> {
NpcChatTurnPromptInput {
world_type: "CUSTOM",
character: Box::leak(Box::new(Value::Null)),
encounter: Box::leak(Box::new(Value::Null)),
monsters: &[],
history: &[],
context: Box::leak(Box::new(Value::Null)),
conversation_history: &[],
dialogue: &[],
combat_context: None,
player_message: "少废话,让开。",
npc_state: Box::leak(Box::new(npc_state)),
npc_initiates_conversation: false,
chat_directive: Some(Box::leak(Box::new(json!({
"terminationMode": "hostile_model",
"isHostileChat": true,
})))),
}
}
#[test]
fn hostile_reply_prompt_mentions_final_threat_after_four_turns() {
let input = hostile_prompt_input(json!({
"affinity": -12,
"chattedCount": 4,
}));
let prompt = build_npc_chat_turn_reply_prompt(&input);
assert!(prompt.contains("已聊天轮次4"));
assert!(prompt.contains("战斗前狠话"));
assert!(prompt.contains("本轮结束后会超过 4 轮"));
}
#[test]
fn hostile_suggestion_prompt_mentions_should_end_chat_signals() {
let input = hostile_prompt_input(json!({
"affinity": -12,
"chattedCount": 4,
}));
let prompt = build_npc_chat_turn_suggestion_prompt(&input, "再往前一步,就别想回头。");
assert!(prompt.contains("shouldEndChat=true"));
assert!(prompt.contains("terminationReason=hostile_breakoff"));
assert!(prompt.contains("已聊天轮次为 4"));
}
}

View File

@@ -38,9 +38,10 @@ use shared_contracts::{
puzzle_runtime::{
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse,
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse,
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest,
UsePuzzleRuntimePropRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
@@ -56,12 +57,12 @@ use spacetime_client::{
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkProfileRecord,
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
@@ -72,7 +73,13 @@ use crate::{
asset_billing::execute_billable_asset_operation,
auth::AuthenticatedAccessToken,
http_error::AppError,
prompt::puzzle::image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
prompt::puzzle::{
draft::{
PuzzleFormSeedPromptParts, build_puzzle_form_seed_prompt,
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
},
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,
@@ -472,7 +479,7 @@ pub async fn execute_puzzle_agent_action(
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| payload.prompt_text.as_deref());
if let Err(response) = save_puzzle_form_payload_before_compile(
let compile_session_id = match save_puzzle_form_payload_before_compile(
&state,
&request_context,
&session_id,
@@ -482,8 +489,9 @@ pub async fn execute_puzzle_agent_action(
)
.await
{
return Err(response);
}
Ok(next_session_id) => next_session_id,
Err(response) => return Err(response),
};
let session = execute_billable_asset_operation(
&state,
&owner_user_id,
@@ -492,7 +500,7 @@ pub async fn execute_puzzle_agent_action(
async {
compile_puzzle_draft_with_initial_cover(
&state,
session_id.clone(),
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
payload.reference_image_src.as_deref(),
@@ -522,7 +530,7 @@ pub async fn execute_puzzle_agent_action(
.as_deref()
.or(payload.prompt_text.as_deref()),
);
let session = state
let save_result = state
.spacetime_client()
.save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput {
session_id: session_id.clone(),
@@ -530,14 +538,36 @@ pub async fn execute_puzzle_agent_action(
seed_text,
saved_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
});
.await;
let session = match save_result {
Ok(session) => Ok(session),
Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => {
// 中文注释Maincloud 旧 wasm 缺少该自动保存 procedure 时,返回当前 session避免填表页被非关键错误打断。
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session_id,
owner_user_id = %owner_user_id,
error = %error,
"拼图表单自动保存 procedure 缺失,降级返回当前会话"
);
state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|fallback_error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(fallback_error),
)
})
}
Err(error) => Err(puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)),
};
(
"save_puzzle_form_draft",
"表单草稿保存",
@@ -547,30 +577,42 @@ pub async fn execute_puzzle_agent_action(
}
"generate_puzzle_images" => {
let target_level_id = payload.level_id.clone();
let levels_json = normalize_puzzle_levels_json_for_module(
payload.levels_json.as_deref(),
)
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
});
let session = execute_billable_asset_operation(
&state,
&owner_user_id,
"puzzle_generated_image",
&billing_asset_id,
async {
let levels_json = levels_json?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(map_puzzle_client_error)?;
let draft = session.draft.clone().ok_or_else(|| {
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let target_level =
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let prompt = payload
.prompt_text
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| target_level.picture_description.clone());
let prompt = resolve_puzzle_level_image_prompt(
payload.prompt_text.as_deref(),
&target_level.picture_description,
);
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_count = 1;
let candidate_start_index = target_level.candidates.len();
@@ -609,6 +651,7 @@ pub async fn execute_puzzle_agent_action(
session_id: session.session_id,
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id),
levels_json,
candidates_json,
saved_at_micros: now,
})
@@ -977,7 +1020,7 @@ pub async fn get_puzzle_gallery_detail(
Ok(json_success_body(
Some(&request_context),
PuzzleGalleryDetailResponse {
item: map_puzzle_work_summary_response(&state, item),
item: map_puzzle_work_profile_response(&state, item),
},
))
}
@@ -1014,7 +1057,7 @@ pub async fn record_puzzle_gallery_like(
Ok(json_success_body(
Some(&request_context),
PuzzleGalleryDetailResponse {
item: map_puzzle_work_summary_response(&state, item),
item: map_puzzle_work_profile_response(&state, item),
},
))
}
@@ -1303,6 +1346,7 @@ pub async fn use_puzzle_runtime_prop(
"hint" => "puzzle_prop_hint",
"reference" => "puzzle_prop_preview",
"freezeTime" | "freeze_time" => "puzzle_prop_freeze_time",
"extendTime" | "extend_time" => "puzzle_prop_extend_time",
_ => {
return Err(puzzle_bad_request(
&request_context,
@@ -1646,6 +1690,7 @@ fn map_puzzle_work_summary_response(
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
publish_ready: item.publish_ready,
levels: Vec::new(),
}
}
@@ -1653,14 +1698,16 @@ fn map_puzzle_work_profile_response(
state: &AppState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkProfileResponse {
let mut summary = map_puzzle_work_summary_response(state, item.clone());
summary.levels = item
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect();
PuzzleWorkProfileResponse {
summary: map_puzzle_work_summary_response(state, item.clone()),
summary,
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
levels: item
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect(),
}
}
@@ -1675,6 +1722,14 @@ 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,
next_level_mode: run.next_level_mode,
next_level_profile_id: run.next_level_profile_id,
next_level_id: run.next_level_id,
recommended_next_works: run
.recommended_next_works
.into_iter()
.map(map_puzzle_recommended_next_work_response)
.collect(),
leaderboard_entries: run
.leaderboard_entries
.into_iter()
@@ -1683,6 +1738,19 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
}
}
fn map_puzzle_recommended_next_work_response(
item: PuzzleRecommendedNextWorkRecord,
) -> PuzzleRecommendedNextWorkResponse {
PuzzleRecommendedNextWorkResponse {
profile_id: item.profile_id,
level_name: item.level_name,
author_display_name: item.author_display_name,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
similarity_score: item.similarity_score,
}
}
async fn enrich_puzzle_run_author_name(
state: &AppState,
mut run: PuzzleRunRecord,
@@ -1717,6 +1785,14 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_level_request_record),
recommended_next_profile_id: run.recommended_next_profile_id,
next_level_mode: run.next_level_mode,
next_level_profile_id: run.next_level_profile_id,
next_level_id: run.next_level_id,
recommended_next_works: run
.recommended_next_works
.into_iter()
.map(map_puzzle_recommended_next_work_request_record)
.collect(),
leaderboard_entries: run
.leaderboard_entries
.into_iter()
@@ -1725,12 +1801,26 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec
}
}
fn map_puzzle_recommended_next_work_request_record(
item: PuzzleRecommendedNextWorkResponse,
) -> PuzzleRecommendedNextWorkRecord {
PuzzleRecommendedNextWorkRecord {
profile_id: item.profile_id,
level_name: item.level_name,
author_display_name: item.author_display_name,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
similarity_score: item.similarity_score,
}
}
fn map_puzzle_level_request_record(
level: PuzzleRuntimeLevelSnapshotResponse,
) -> PuzzleRuntimeLevelRecord {
PuzzleRuntimeLevelRecord {
run_id: level.run_id,
level_index: level.level_index,
level_id: level.level_id,
grid_size: level.grid_size,
profile_id: level.profile_id,
level_name: level.level_name,
@@ -1823,6 +1913,7 @@ fn map_puzzle_runtime_level_response(
PuzzleRuntimeLevelSnapshotResponse {
run_id: level.run_id,
level_index: level.level_index,
level_id: level.level_id,
grid_size: level.grid_size,
profile_id: level.profile_id,
level_name: level.level_name,
@@ -1933,14 +2024,14 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
}
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
build_puzzle_form_seed_text_from_parts(
payload
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
title: payload
.work_title
.as_deref()
.or(payload.seed_text.as_deref()),
payload.work_description.as_deref(),
payload.picture_description.as_deref(),
)
work_description: payload.work_description.as_deref(),
picture_description: payload.picture_description.as_deref(),
})
}
fn build_puzzle_form_seed_text_from_parts(
@@ -1948,20 +2039,11 @@ fn build_puzzle_form_seed_text_from_parts(
work_description: Option<&str>,
picture_description: Option<&str>,
) -> String {
let title = title.unwrap_or_default().trim();
let work_description = work_description.unwrap_or_default().trim();
let picture_description = picture_description.unwrap_or_default().trim();
[
("作品名称", title),
("作品描述", work_description),
("画面描述", picture_description),
]
.into_iter()
.filter(|(_, value)| !value.is_empty())
.map(|(label, value)| format!("{label}{value}"))
.collect::<Vec<_>>()
.join("\n")
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
title,
work_description,
picture_description,
})
}
async fn save_puzzle_form_payload_before_compile(
@@ -1971,7 +2053,7 @@ async fn save_puzzle_form_payload_before_compile(
owner_user_id: &str,
payload: &ExecutePuzzleAgentActionRequest,
now: i64,
) -> Result<(), Response> {
) -> Result<String, Response> {
let seed_text = build_puzzle_form_seed_text_from_parts(
payload.work_title.as_deref(),
payload.work_description.as_deref(),
@@ -1981,26 +2063,101 @@ async fn save_puzzle_form_payload_before_compile(
.or(payload.prompt_text.as_deref()),
);
if seed_text.trim().is_empty() {
return Ok(());
return Ok(session_id.to_string());
}
state
let save_result = state
.spacetime_client()
.save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
seed_text,
seed_text: seed_text.clone(),
saved_at_micros: now,
})
.await
.map(|_| ())
.map(|_| ());
match save_result {
Ok(()) => Ok(session_id.to_string()),
Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => {
create_seeded_puzzle_session_when_form_save_missing(
state,
request_context,
session_id,
owner_user_id,
seed_text,
now,
&error,
)
.await
}
Err(error) => Err(puzzle_error_response(
request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)),
}
}
async fn create_seeded_puzzle_session_when_form_save_missing(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
seed_text: String,
now: i64,
original_error: &SpacetimeClientError,
) -> Result<String, Response> {
let current_session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(|error| {
puzzle_error_response(
request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
if !current_session.seed_text.trim().is_empty() {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
owner_user_id,
error = %original_error,
"拼图表单草稿保存 procedure 缺失,沿用已有 seed_text 编译"
);
return Ok(session_id.to_string());
}
// 中文注释:旧 Maincloud 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。
let replacement_session_id = build_prefixed_uuid_id("puzzle-session-");
let replacement = state
.spacetime_client()
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
session_id: replacement_session_id.clone(),
owner_user_id: owner_user_id.to_string(),
seed_text: seed_text.clone(),
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
welcome_message_text: build_puzzle_welcome_text(&seed_text),
created_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
old_session_id = %session_id,
new_session_id = %replacement.session_id,
owner_user_id,
error = %original_error,
"拼图表单草稿保存 procedure 缺失,已创建带表单 seed 的替代 session"
);
Ok(replacement.session_id)
}
fn select_puzzle_level_for_api(
@@ -2008,15 +2165,20 @@ fn select_puzzle_level_for_api(
level_id: Option<&str>,
) -> Result<PuzzleDraftLevelRecord, AppError> {
let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty());
let level = normalized_level_id
.and_then(|target_id| {
draft
.levels
.iter()
.find(|level| level.level_id == target_id)
.cloned()
})
.or_else(|| draft.levels.first().cloned());
if let Some(target_id) = normalized_level_id {
return draft
.levels
.iter()
.find(|level| level.level_id == target_id)
.cloned()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图关卡不存在:{target_id}"),
}))
});
}
let level = draft.levels.first().cloned();
level.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -2025,6 +2187,43 @@ fn select_puzzle_level_for_api(
})
}
fn parse_puzzle_level_records_from_module_json(
value: &str,
) -> Result<Vec<PuzzleDraftLevelRecord>, AppError> {
let levels: Vec<module_puzzle::PuzzleDraftLevel> =
serde_json::from_str(value).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图关卡列表 JSON 非法:{error}"),
}))
})?;
Ok(levels
.into_iter()
.map(|level| PuzzleDraftLevelRecord {
level_id: level.level_id,
level_name: level.level_name,
picture_description: level.picture_description,
candidates: level
.candidates
.into_iter()
.map(|candidate| PuzzleGeneratedImageCandidateRecord {
candidate_id: candidate.candidate_id,
image_src: candidate.image_src,
asset_id: candidate.asset_id,
prompt: candidate.prompt,
actual_prompt: candidate.actual_prompt,
source_type: candidate.source_type,
selected: candidate.selected,
})
.collect(),
selected_candidate_id: level.selected_candidate_id,
cover_image_src: level.cover_image_src,
cover_asset_id: level.cover_asset_id,
generation_status: level.generation_status,
})
.collect())
}
fn serialize_puzzle_levels_response(
request_context: &RequestContext,
levels: &[PuzzleDraftLevelResponse],
@@ -2138,22 +2337,18 @@ async fn compile_puzzle_draft_with_initial_cover(
.ok_or_else(|| SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()))?;
let target_level = select_puzzle_level_for_api(&draft, None)
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
let image_prompt = prompt_text
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
Some(target_level.picture_description.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
})
.unwrap_or(draft.summary.as_str());
let image_prompt = resolve_puzzle_draft_cover_prompt(
prompt_text,
&target_level.picture_description,
&draft.summary,
);
// 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。
let candidates = generate_puzzle_image_candidates(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
&target_level.level_name,
image_prompt,
&image_prompt,
reference_image_src,
1,
target_level.candidates.len(),
@@ -2179,6 +2374,7 @@ async fn compile_puzzle_draft_with_initial_cover(
session_id: compiled_session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json: None,
candidates_json,
saved_at_micros: current_utc_micros(),
})
@@ -2252,6 +2448,15 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
}))
}
fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool {
matches!(error, SpacetimeClientError::Procedure(message) if
message.contains("save_puzzle_form_draft")
&& (message.contains("No such procedure")
|| message.contains("不存在")
|| message.contains("does not exist")
|| message.contains("not found")))
}
fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
let message = error.to_string();
let provider = if message.contains("DashScope") || message.contains("dashscope") {
@@ -2484,11 +2689,18 @@ async fn build_local_next_puzzle_run(
);
}
let source_session_id = payload.source_session_id.unwrap_or_default();
if let Some(next_run) =
build_same_work_local_next_puzzle_run(state, &run, &source_session_id, owner_user_id)
.await?
{
return Ok(next_run);
}
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
return Ok(build_next_run_from_puzzle_work(state, run, gallery_item));
}
let source_session_id = payload.source_session_id.unwrap_or_default();
if source_session_id.trim().is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -2551,6 +2763,7 @@ async fn build_local_next_puzzle_run(
session_id: session.session_id,
owner_user_id: owner_user_id.to_string(),
level_id: None,
levels_json: None,
candidates_json,
saved_at_micros: current_utc_micros(),
})
@@ -2578,6 +2791,101 @@ async fn build_local_next_puzzle_run(
))
}
async fn build_same_work_local_next_puzzle_run(
state: &AppState,
run: &PuzzleRunRecord,
source_session_id: &str,
owner_user_id: &str,
) -> Result<Option<PuzzleRunRecord>, AppError> {
if !should_use_same_work_next_level(run) {
return Ok(None);
}
if let Some(work) = fetch_local_current_work_detail(state, run).await? {
if let Some(level) = select_local_next_level(&work.levels, run) {
let next_after_level =
select_next_level_after_level_id(&work.levels, level.level_id.as_str())
.map(|item| item.level_id.clone());
return Ok(Some(build_next_run_from_draft_level(
run.clone(),
level,
Some(work.profile_id),
work.author_display_name,
work.theme_tags,
next_after_level,
)));
}
}
let normalized_session_id = source_session_id.trim();
if normalized_session_id.is_empty() {
return Ok(None);
}
let session = state
.spacetime_client()
.get_puzzle_agent_session(normalized_session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_puzzle_client_error)?;
let Some(draft) = session.draft.as_ref() else {
return Ok(None);
};
if let Some(level) = select_local_next_level(&draft.levels, run) {
let next_after_level =
select_next_level_after_level_id(&draft.levels, level.level_id.as_str())
.map(|item| item.level_id.clone());
return Ok(Some(build_next_run_from_draft_level(
run.clone(),
level,
Some(run.entry_profile_id.clone()),
"当前草稿".to_string(),
draft.theme_tags.clone(),
next_after_level,
)));
}
Ok(None)
}
fn should_use_same_work_next_level(run: &PuzzleRunRecord) -> bool {
run.next_level_mode == module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK
|| run
.next_level_id
.as_ref()
.is_some_and(|value| !value.trim().is_empty())
}
async fn fetch_local_current_work_detail(
state: &AppState,
run: &PuzzleRunRecord,
) -> Result<Option<PuzzleWorkProfileRecord>, AppError> {
let profile_id = run
.next_level_profile_id
.as_deref()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
run.current_level
.as_ref()
.map(|level| level.profile_id.as_str())
.filter(|value| !value.trim().is_empty())
})
.unwrap_or(run.entry_profile_id.as_str());
match state
.spacetime_client()
.get_puzzle_gallery_detail(profile_id.to_string())
.await
{
Ok(work) => Ok(Some(work)),
Err(SpacetimeClientError::Procedure(message))
if message.contains("不存在")
|| message.contains("not found")
|| message.contains("does not exist") =>
{
Ok(None)
}
Err(error) => Err(map_puzzle_client_error(error)),
}
}
async fn resolve_gallery_next_puzzle_work(
state: &AppState,
run: &PuzzleRunRecord,
@@ -2609,6 +2917,76 @@ fn pick_unused_puzzle_candidate<'a>(
})
}
fn select_local_next_level<'a>(
levels: &'a [PuzzleDraftLevelRecord],
run: &PuzzleRunRecord,
) -> Option<&'a PuzzleDraftLevelRecord> {
if levels.is_empty() {
return None;
}
if let Some(next_level_id) = run
.next_level_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Some(level) = levels.iter().find(|level| level.level_id == next_level_id) {
return Some(level);
}
}
let current_level = run.current_level.as_ref()?;
let matched_index = levels
.iter()
.position(|level| {
level.cover_image_src == current_level.cover_image_src
&& level.level_name == current_level.level_name
})
.or_else(|| {
current_level
.level_index
.checked_sub(1)
.and_then(|index| ((index as usize) < levels.len()).then_some(index as usize))
})?;
levels.get(matched_index + 1)
}
fn select_next_level_after_level_id<'a>(
levels: &'a [PuzzleDraftLevelRecord],
level_id: &str,
) -> Option<&'a PuzzleDraftLevelRecord> {
let matched_index = levels.iter().position(|level| level.level_id == level_id)?;
levels.get(matched_index + 1)
}
fn resolve_level_cover_image_src(level: &PuzzleDraftLevelRecord) -> Option<String> {
level
.cover_image_src
.as_ref()
.filter(|value| !value.trim().is_empty())
.cloned()
.or_else(|| {
level
.selected_candidate_id
.as_ref()
.and_then(|candidate_id| {
level
.candidates
.iter()
.find(|candidate| candidate.candidate_id == *candidate_id)
})
.map(|candidate| candidate.image_src.clone())
.filter(|value| !value.trim().is_empty())
})
.or_else(|| {
level
.candidates
.iter()
.find(|candidate| !candidate.image_src.trim().is_empty())
.map(|candidate| candidate.image_src.clone())
})
}
fn build_next_run_from_puzzle_work(
state: &AppState,
run: PuzzleRunRecord,
@@ -2654,6 +3032,34 @@ fn build_next_run_from_candidate(
)
}
fn build_next_run_from_draft_level(
mut run: PuzzleRunRecord,
level: &PuzzleDraftLevelRecord,
profile_id: Option<String>,
author_display_name: String,
theme_tags: Vec<String>,
next_after_level_id: Option<String>,
) -> PuzzleRunRecord {
// 中文注释:当前关卡 id 必须取本次选中的目标 level避免旧 run 的空值或脏值影响后续同作品接续。
run.next_level_id = Some(level.level_id.clone());
let fallback_profile_id = run
.current_level
.as_ref()
.map(|level| level.profile_id.clone())
.unwrap_or_else(|| level.level_id.clone());
build_next_run_from_parts_with_handoff(
run,
profile_id
.filter(|value| !value.trim().is_empty())
.unwrap_or(fallback_profile_id),
level.level_name.clone(),
author_display_name,
theme_tags,
resolve_level_cover_image_src(level),
next_after_level_id,
)
}
fn build_next_run_from_parts(
run: PuzzleRunRecord,
profile_id: String,
@@ -2661,11 +3067,32 @@ fn build_next_run_from_parts(
author_display_name: String,
theme_tags: Vec<String>,
cover_image_src: Option<String>,
) -> PuzzleRunRecord {
build_next_run_from_parts_with_handoff(
run,
profile_id,
level_name,
author_display_name,
theme_tags,
cover_image_src,
None,
)
}
fn build_next_run_from_parts_with_handoff(
run: PuzzleRunRecord,
profile_id: String,
level_name: String,
author_display_name: String,
theme_tags: Vec<String>,
cover_image_src: Option<String>,
next_after_level_id: Option<String>,
) -> PuzzleRunRecord {
let next_level_index = run.current_level_index + 1;
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 };
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
let mut played_profile_ids = run.played_profile_ids.clone();
let current_level_id = run.next_level_id.clone();
if !played_profile_ids.contains(&profile_id) {
played_profile_ids.push(profile_id.clone());
}
@@ -2681,8 +3108,9 @@ fn build_next_run_from_parts(
current_level: Some(PuzzleRuntimeLevelRecord {
run_id: run.run_id,
level_index: next_level_index,
level_id: current_level_id,
grid_size,
profile_id,
profile_id: profile_id.clone(),
level_name,
author_display_name,
theme_tags,
@@ -2702,6 +3130,13 @@ fn build_next_run_from_parts(
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
next_level_mode: next_after_level_id
.as_ref()
.map(|_| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string())
.unwrap_or_else(|| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_NONE.to_string()),
next_level_profile_id: next_after_level_id.as_ref().map(|_| profile_id),
next_level_id: next_after_level_id,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
}
}

View File

@@ -26,9 +26,9 @@ use crate::{
prompt::runtime_chat::{
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_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,
build_deterministic_hostile_breakoff_reply, 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,
@@ -137,16 +137,26 @@ pub async fn stream_runtime_npc_chat_turn(
let (npc_reply, suggestions, function_suggestions, force_exit) = match llm_result {
Some(result) => result,
None => {
let npc_reply = build_deterministic_npc_reply(
npc_name.as_str(),
player_message.as_str(),
payload.npc_initiates_conversation,
);
let force_exit = should_force_chat_exit(payload.chat_directive.as_ref())
|| should_hostile_chat_breakoff_deterministically(
let deterministic_hostile_breakoff =
should_hostile_chat_breakoff_deterministically(
player_message.as_str(),
payload.chat_directive.as_ref(),
Some(&payload.npc_state),
);
let force_exit = should_force_chat_exit(payload.chat_directive.as_ref())
|| deterministic_hostile_breakoff;
let npc_reply = if deterministic_hostile_breakoff {
build_deterministic_hostile_breakoff_reply(
npc_name.as_str(),
player_message.as_str(),
)
} else {
build_deterministic_npc_reply(
npc_name.as_str(),
player_message.as_str(),
payload.npc_initiates_conversation,
)
};
let suggestions = if force_exit {
Vec::new()
} else {
@@ -272,6 +282,7 @@ where
|| should_hostile_chat_breakoff_deterministically(
payload.player_message.as_str(),
payload.chat_directive.as_ref(),
Some(&payload.npc_state),
);
if force_exit {
@@ -618,6 +629,7 @@ fn is_hostile_model_chat(chat_directive: Option<&Value>) -> bool {
fn should_hostile_chat_breakoff_deterministically(
player_message: &str,
chat_directive: Option<&Value>,
npc_state: Option<&Value>,
) -> bool {
if !is_hostile_model_chat(chat_directive) {
return false;
@@ -631,6 +643,14 @@ fn should_hostile_chat_breakoff_deterministically(
return true;
}
// 中文注释:模型建议不可用时,后端兜底仍按敌对聊天口径避免负面挑衅被拖成闲聊。
if npc_state
.and_then(|state| read_number_field(state, "chattedCount"))
.is_some_and(|chatted_count| chatted_count >= 4.0)
{
return true;
}
let hostile_break_words = [
"动手",
"开战",
@@ -640,6 +660,18 @@ fn should_hostile_chat_breakoff_deterministically(
"闭嘴",
"少废话",
"别挡路",
"废话",
"威胁",
"找死",
"送死",
"住口",
"让开",
"滚开",
"不退",
"不会退",
"别装",
"骗子",
"叛徒",
];
count_keyword_matches(player_message, &hostile_break_words) > 0
}
@@ -812,6 +844,51 @@ mod tests {
);
}
#[test]
fn hostile_chat_breakoff_fallback_triggers_on_negative_words() {
let chat_directive = json!({
"terminationMode": "hostile_model",
"isHostileChat": true,
});
let npc_state = json!({ "chattedCount": 1 });
assert!(should_hostile_chat_breakoff_deterministically(
"少废话,让开,不然现在就动手。",
Some(&chat_directive),
Some(&npc_state),
));
}
#[test]
fn hostile_chat_breakoff_fallback_triggers_after_four_turns() {
let chat_directive = json!({
"terminationMode": "hostile_model",
"isHostileChat": true,
});
let npc_state = json!({ "chattedCount": 4 });
assert!(should_hostile_chat_breakoff_deterministically(
"我还想再问一个问题。",
Some(&chat_directive),
Some(&npc_state),
));
}
#[test]
fn hostile_chat_breakoff_fallback_ignores_non_hostile_chat() {
let chat_directive = json!({
"terminationMode": "none",
"isHostileChat": false,
});
let npc_state = json!({ "chattedCount": 6 });
assert!(!should_hostile_chat_breakoff_deterministically(
"少废话,让开。",
Some(&chat_directive),
Some(&npc_state),
));
}
#[tokio::test]
async fn npc_chat_turn_prefers_request_snapshot_over_persisted_session() {
let state = AppState::new(AppConfig::default()).expect("state should build");