use axum::{ Json, extract::{Extension, State}, http::StatusCode, response::{ IntoResponse, Response, sse::{Event, Sse}, }, }; use platform_llm::{LlmMessage, LlmTextRequest}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::story::StoryRuntimeSnapshotPayload as RuntimeStorySnapshotPayload; use std::convert::Infallible; use module_runtime_story::{ RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type, normalize_required_string, read_array_field, read_field, read_i32_field, read_object_field, read_optional_string_field, read_runtime_session_id, }; use crate::{ auth::AuthenticatedAccessToken, http_error::AppError, llm_model_routing::RPG_STORY_LLM_MODEL, prompt::runtime_chat::{ NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT, NpcChatTurnPromptInput, build_deterministic_chat_suggestions, 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, }; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NpcChatTurnRequest { #[serde(default)] session_id: Option, #[serde(default)] snapshot: Option, #[serde(default)] world_type: String, #[serde(default)] character: Option, #[serde(default)] player: Option, encounter: Value, #[serde(default)] monsters: Vec, #[serde(default)] history: Vec, #[serde(default)] context: Value, #[serde(default)] conversation_history: Vec, #[serde(default)] dialogue: Vec, #[serde(default)] combat_context: Option, player_message: String, #[serde(default)] npc_state: Value, #[serde(default)] npc_initiates_conversation: bool, #[serde(default)] quest_offer_context: Option, #[serde(default)] chat_directive: Option, } pub async fn stream_runtime_npc_chat_turn( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(mut payload): Json, ) -> Result { hydrate_npc_chat_turn_request_from_session( &state, &request_context, authenticated.claims().user_id().to_string(), &mut payload, ) .await?; let npc_name = read_string_field(&payload.encounter, "npcName") .or_else(|| read_string_field(&payload.encounter, "name")) .unwrap_or_else(|| "对方".to_string()); let player_message = payload.player_message.trim().to_string(); if player_message.is_empty() && !payload.npc_initiates_conversation { return Err(runtime_chat_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-chat", "message": "playerMessage 不能为空", })), )); } let stream = async_stream::stream! { let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::(); // `platform-llm` 在当前任务内持续回调增量文本;外层用 channel 把增量转成真正的 SSE 分片。 let llm_turn = generate_llm_npc_chat_turn( &state, &payload, &npc_name, move |text| { let _ = reply_tx.send(text.to_string()); }, ); tokio::pin!(llm_turn); let llm_result = loop { // 模型尚未结束时优先把已收到的累计回复推出去,避免等完整建议生成后才一次性返回。 tokio::select! { result = &mut llm_turn => break result, maybe_text = reply_rx.recv() => { if let Some(text) = maybe_text { yield Ok::(runtime_chat_sse_json_event_or_error( "reply_delta", json!({ "text": text }), )); } } } }; while let Some(text) = reply_rx.recv().await { yield Ok::(runtime_chat_sse_json_event_or_error( "reply_delta", json!({ "text": text }), )); } let (npc_reply, suggestions, function_suggestions, force_exit) = match llm_result { Some(result) => result, None => { 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 { build_deterministic_chat_suggestions(npc_name.as_str(), player_message.as_str()) }; let function_suggestions = if force_exit { Vec::new() } else { build_fallback_function_suggestions(payload.chat_directive.as_ref()) }; yield Ok::(runtime_chat_sse_json_event_or_error( "reply_delta", json!({ "text": npc_reply }), )); (npc_reply, suggestions, function_suggestions, force_exit) } }; let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0); let affinity_delta = if payload.npc_initiates_conversation { 0 } else { compute_npc_chat_affinity_delta(player_message.as_str(), npc_reply.as_str(), chatted_count) }; let complete_payload = json!({ "npcReply": npc_reply, "affinityDelta": affinity_delta, "affinityText": describe_affinity_shift(affinity_delta), "suggestions": suggestions, "functionSuggestions": function_suggestions, "pendingQuestOffer": null, "chatDirective": build_completion_directive(payload.chat_directive.as_ref(), force_exit), }); yield Ok::(runtime_chat_sse_json_event_or_error( "complete", complete_payload, )); yield Ok::(Event::default().data("[DONE]")); }; Ok(Sse::new(stream).into_response()) } async fn generate_llm_npc_chat_turn( state: &AppState, payload: &NpcChatTurnRequest, npc_name: &str, mut on_reply_update: F, ) -> Option<(String, Vec, Vec, bool)> where F: FnMut(&str), { let llm_client = state.llm_client()?; let character = payload .character .as_ref() .or(payload.player.as_ref()) .unwrap_or(&Value::Null); let prompt_input = NpcChatTurnPromptInput { world_type: payload.world_type.as_str(), character, encounter: &payload.encounter, monsters: &payload.monsters, history: &payload.history, context: &payload.context, conversation_history: &payload.conversation_history, dialogue: &payload.dialogue, combat_context: payload.combat_context.as_ref(), player_message: payload.player_message.as_str(), npc_state: &payload.npc_state, npc_initiates_conversation: payload.npc_initiates_conversation, chat_directive: payload.chat_directive.as_ref(), }; let reply_prompt = build_npc_chat_turn_reply_prompt(&prompt_input); let mut reply_request = LlmTextRequest::new(vec![ LlmMessage::system(NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT), LlmMessage::user(reply_prompt), ]); reply_request.max_tokens = Some(700); reply_request.enable_web_search = state.config.rpg_llm_web_search_enabled; reply_request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let reply_response = llm_client .stream_text(reply_request, |delta| { on_reply_update(delta.accumulated_text.as_str()); }) .await .ok()?; let npc_reply = normalize_required_text(reply_response.content.as_str()).unwrap_or_else(|| { build_deterministic_npc_reply( npc_name, payload.player_message.as_str(), payload.npc_initiates_conversation, ) }); if should_force_chat_exit(payload.chat_directive.as_ref()) { return Some((npc_reply, Vec::new(), Vec::new(), true)); } let suggestion_prompt = build_npc_chat_turn_suggestion_prompt(&prompt_input, npc_reply.as_str()); let mut suggestion_request = LlmTextRequest::new(vec![ LlmMessage::system(NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT), LlmMessage::user(suggestion_prompt), ]); suggestion_request.max_tokens = Some(200); suggestion_request.enable_web_search = state.config.rpg_llm_web_search_enabled; suggestion_request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let suggestion_text = llm_client .request_text(suggestion_request) .await .ok() .map(|response| response.content) .unwrap_or_default(); let (mut suggestions, mut function_suggestions, should_end_chat) = parse_npc_chat_suggestion_resolution( suggestion_text.as_str(), payload.chat_directive.as_ref(), ); let force_exit = should_end_chat || should_hostile_chat_breakoff_deterministically( payload.player_message.as_str(), payload.chat_directive.as_ref(), Some(&payload.npc_state), ); if force_exit { suggestions.clear(); function_suggestions.clear(); } else if suggestions.is_empty() { suggestions = build_fallback_npc_chat_suggestions(payload.player_message.as_str()); } Some((npc_reply, suggestions, function_suggestions, force_exit)) } async fn hydrate_npc_chat_turn_request_from_session( state: &AppState, request_context: &RequestContext, user_id: String, payload: &mut NpcChatTurnRequest, ) -> Result<(), Response> { let Some(session_id) = payload .session_id .as_deref() .and_then(normalize_required_string) else { // 中文注释:旧调用没有 sessionId 时继续使用请求体字段;正式运行态由后端快照投影上下文。 return Ok(()); }; if let Some(game_state) = resolve_request_snapshot_game_state( request_context, session_id.as_str(), payload.snapshot.as_ref(), )? { apply_npc_chat_turn_game_state(payload, game_state); return Ok(()); } let record = state .get_runtime_snapshot_record(user_id) .await .map_err(|error| { runtime_chat_error_response( request_context, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })), ) })? .ok_or_else(|| { runtime_chat_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-chat", "message": "运行时快照不存在,请先初始化并保存一次游戏", })), ) })?; let game_state = record.game_state; let snapshot_session_id = read_runtime_session_id(&game_state).unwrap_or_else(|| session_id.clone()); if snapshot_session_id != session_id { return Err(runtime_chat_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-chat", "message": "请求的运行时会话与服务端快照不一致,请重新进入游戏", "sessionId": session_id, "snapshotSessionId": snapshot_session_id, })), )); } apply_npc_chat_turn_game_state(payload, game_state); Ok(()) } fn resolve_request_snapshot_game_state( request_context: &RequestContext, session_id: &str, snapshot: Option<&RuntimeStorySnapshotPayload>, ) -> Result, Response> { let Some(snapshot) = snapshot else { return Ok(None); }; if !snapshot.game_state.is_object() { return Err(runtime_chat_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-chat", "field": "snapshot.gameState", "message": "snapshot.gameState 必须是 JSON object", })), )); } let snapshot_session_id = read_runtime_session_id(&snapshot.game_state).unwrap_or_else(|| session_id.to_string()); if snapshot_session_id != session_id { return Err(runtime_chat_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-chat", "message": "请求的运行时会话与服务端快照不一致,请重新进入游戏", "sessionId": session_id, "snapshotSessionId": snapshot_session_id, })), )); } // 中文注释:预览/测试/禁存运行态只把请求 snapshot 用于本轮 prompt 投影,不写入正式存档。 Ok(Some(snapshot.game_state.clone())) } fn apply_npc_chat_turn_game_state(payload: &mut NpcChatTurnRequest, game_state: Value) { payload.world_type = current_world_type(&game_state).unwrap_or_default(); payload.character = read_field(&game_state, "playerCharacter").cloned(); payload.player = payload.character.clone(); payload.encounter = read_field(&game_state, "currentEncounter") .cloned() .unwrap_or_else(|| payload.encounter.clone()); payload.monsters = read_array_field(&game_state, "sceneHostileNpcs") .into_iter() .cloned() .collect(); payload.history = read_array_field(&game_state, "storyHistory") .into_iter() .rev() .take(12) .collect::>() .into_iter() .rev() .cloned() .collect(); payload.context = build_runtime_story_prompt_context( &game_state, RuntimeStoryPromptContextExtras { last_function_id: Some("npc_chat".to_string()), ..RuntimeStoryPromptContextExtras::default() }, ); payload.npc_state = resolve_current_request_npc_state(&game_state).unwrap_or_else(|| payload.npc_state.clone()); if let Some(quest_context) = payload.quest_offer_context.as_mut() { if let Some(object) = quest_context.as_object_mut() { object.insert("state".to_string(), game_state); } } } fn resolve_current_request_npc_state(game_state: &Value) -> Option { let encounter = read_object_field(game_state, "currentEncounter")?; let npc_name = read_optional_string_field(encounter, "npcName") .or_else(|| read_optional_string_field(encounter, "name")) .unwrap_or_else(|| "当前遭遇".to_string()); let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone()); let state = read_object_field(game_state, "npcStates").and_then(|states| { states .get(npc_id.as_str()) .or_else(|| states.get(npc_name.as_str())) })?; Some(json!({ "affinity": read_i32_field(state, "affinity").unwrap_or(0), "chattedCount": read_i32_field(state, "chattedCount").unwrap_or(0), "recruited": state.get("recruited").and_then(Value::as_bool).unwrap_or(false), })) } fn build_completion_directive(chat_directive: Option<&Value>, force_exit: bool) -> Value { let Some(directive) = chat_directive else { return Value::Null; }; let closing_mode = read_string_field(directive, "closingMode") .filter(|value| value == "foreshadow_close") .unwrap_or_else(|| "free".to_string()); let force_exit = force_exit || closing_mode == "foreshadow_close" || directive .get("forceExitAfterTurn") .and_then(Value::as_bool) .unwrap_or(false); let termination_reason = if force_exit { read_string_field(directive, "terminationReason") .filter(|value| value == "player_exit" || value == "hostile_breakoff") .or_else(|| { if is_hostile_model_chat(chat_directive) { Some("hostile_breakoff".to_string()) } else { None } }) } else { None }; json!({ "turnLimit": directive.get("turnLimit").cloned().unwrap_or(Value::Null), "remainingTurns": directive.get("remainingTurns").cloned().unwrap_or(Value::Null), "forceExit": force_exit, "closingMode": if force_exit { "foreshadow_close" } else { closing_mode.as_str() }, "terminationReason": termination_reason, }) } fn parse_npc_chat_suggestion_resolution( text: &str, chat_directive: Option<&Value>, ) -> (Vec, Vec, bool) { let normalized = text.trim(); if normalized.is_empty() { return ( Vec::new(), build_fallback_function_suggestions(chat_directive), false, ); } if let Ok(value) = serde_json::from_str::(normalized) { let should_end_chat = value .get("shouldEndChat") .and_then(Value::as_bool) .unwrap_or(false) && is_hostile_model_chat(chat_directive); let suggestions = value .get("suggestions") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|item| !item.is_empty()) .map(ToOwned::to_owned) .take(3) .collect::>() }) .unwrap_or_default(); let function_suggestions = parse_function_suggestions(value.get("functionSuggestions"), chat_directive); return (suggestions, function_suggestions, should_end_chat); } ( parse_line_list_content(normalized, 3), build_fallback_function_suggestions(chat_directive), false, ) } fn parse_function_suggestions(value: Option<&Value>, chat_directive: Option<&Value>) -> Vec { let allowed_options = read_function_options(chat_directive); let allowed_ids = allowed_options .iter() .filter_map(|item| read_string_field(item, "functionId")) .collect::>(); let mut used_ids: Vec = Vec::new(); value .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|item| { let function_id = read_string_field(item, "functionId")?; if function_id == "npc_chat" { return None; } if !allowed_ids.is_empty() && !allowed_ids.contains(&function_id) { return None; } if used_ids.contains(&function_id) { return None; } let fallback_text = allowed_options .iter() .find(|option| { read_string_field(option, "functionId").as_deref() == Some(function_id.as_str()) }) .and_then(|option| read_string_field(option, "actionText")); let action_text = read_string_field(item, "actionText") .or(fallback_text) .filter(|text| !text.trim().is_empty())?; used_ids.push(function_id.clone()); Some(json!({ "functionId": function_id, "actionText": action_text, })) }) .take(3) .collect() } 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::>()) .unwrap_or_default() } fn read_string_field(value: &Value, field: &str) -> Option { value .get(field) .and_then(Value::as_str) .map(str::trim) .filter(|text| !text.is_empty()) .map(ToOwned::to_owned) } fn read_number_field(value: &Value, field: &str) -> Option { value .get(field) .and_then(Value::as_f64) .filter(|number| number.is_finite()) } fn read_bool_field(value: &Value, field: &str) -> Option { value.get(field).and_then(Value::as_bool) } fn should_force_chat_exit(chat_directive: Option<&Value>) -> bool { let Some(directive) = chat_directive else { return false; }; read_string_field(directive, "closingMode").as_deref() == Some("foreshadow_close") || read_string_field(directive, "terminationReason").as_deref() == Some("player_exit") || directive .get("forceExitAfterTurn") .and_then(Value::as_bool) .unwrap_or(false) } fn is_hostile_model_chat(chat_directive: Option<&Value>) -> bool { let Some(directive) = chat_directive else { return false; }; read_string_field(directive, "terminationMode").as_deref() == Some("hostile_model") || read_bool_field(directive, "isHostileChat").unwrap_or(false) } 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; } let Some(directive) = chat_directive else { return false; }; if read_string_field(directive, "terminationReason").as_deref() == Some("player_exit") { 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 = [ "动手", "开战", "拔刀", "杀", "滚", "闭嘴", "少废话", "别挡路", "废话", "威胁", "找死", "送死", "住口", "让开", "滚开", "不退", "不会退", "别装", "骗子", "叛徒", ]; count_keyword_matches(player_message, &hostile_break_words) > 0 } fn normalize_required_text(value: &str) -> Option { let normalized = value.trim(); if normalized.is_empty() { return None; } Some(normalized.to_string()) } fn parse_line_list_content(text: &str, max_items: usize) -> Vec { text.replace('\r', "") .lines() .map(|line| trim_line_list_marker(line.trim()).trim().to_string()) .filter(|line| !line.is_empty()) .take(max_items) .collect() } fn trim_line_list_marker(line: &str) -> &str { line.trim_start_matches(|character: char| { character == '-' || character == '*' || character.is_ascii_digit() || character == '.' || character == ')' || character.is_whitespace() }) } fn count_keyword_matches(text: &str, keywords: &[&str]) -> i32 { keywords .iter() .filter(|keyword| text.contains(**keyword)) .count() as i32 } fn clamp_affinity_delta(value: i32) -> i32 { value.clamp(-3, 3) } fn compute_npc_chat_affinity_delta( player_message: &str, npc_reply: &str, chatted_count: f64, ) -> i32 { let positive_keywords = [ "谢谢", "辛苦", "抱歉", "理解", "相信", "放心", "一起", "帮你", "在意", "关心", ]; let negative_keywords = [ "闭嘴", "滚", "少废话", "威胁", "骗", "不信", "别装", "快说", "审问", "怀疑", ]; let warm_reply_keywords = ["可以", "愿意", "放心", "谢谢", "明白", "好"]; let cold_reply_keywords = ["没必要", "不想", "别问", "与你无关", "算了", "住口"]; let positive_score = count_keyword_matches(player_message.trim(), &positive_keywords) + count_keyword_matches(npc_reply.trim(), &warm_reply_keywords); let negative_score = count_keyword_matches(player_message.trim(), &negative_keywords) + count_keyword_matches(npc_reply.trim(), &cold_reply_keywords); if positive_score == 0 && negative_score == 0 { return if chatted_count == 0.0 { 1 } else { 0 }; } if positive_score > negative_score { let base_delta = positive_score - negative_score + if chatted_count <= 1.0 { 1 } else { 0 }; return clamp_affinity_delta(base_delta); } if negative_score > positive_score { return clamp_affinity_delta(positive_score - negative_score); } 0 } fn describe_affinity_shift(affinity_delta: i32) -> &'static str { if affinity_delta >= 8 { return "态度明显软化了下来。"; } if affinity_delta >= 5 { return "态度比刚才亲近了一些。"; } if affinity_delta > 0 { return "对话气氛稍微松动了一点。"; } if affinity_delta < 0 { return "这轮对话让气氛变得更紧了一些。"; } "这轮对话暂时没有带来明显关系变化。" } fn runtime_chat_sse_json_event_or_error(event_name: &str, payload: Value) -> Event { match serde_json::to_string(&payload) { Ok(payload_text) => Event::default().event(event_name).data(payload_text), Err(_) => runtime_chat_sse_error_event_message("SSE payload 序列化失败".to_string()), } } fn runtime_chat_sse_error_event_message(message: String) -> Event { let payload = format!( "{{\"message\":{}}}", serde_json::to_string(&message) .unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string()) ); Event::default().event("error").data(payload) } fn runtime_chat_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } #[cfg(test)] mod tests { use super::*; use crate::{config::AppConfig, request_context::RequestContext, state::AppState}; use std::time::Duration; #[test] fn npc_chat_affinity_delta_keeps_node_keyword_rules() { assert_eq!( compute_npc_chat_affinity_delta("谢谢你愿意帮忙", "放心,我明白。", 0.0), 3 ); assert_eq!( compute_npc_chat_affinity_delta("快说,别装。", "与你无关。", 2.0), -3 ); assert_eq!( compute_npc_chat_affinity_delta("这里怎么了", "我还在想。", 0.0), 1 ); assert_eq!( compute_npc_chat_affinity_delta("这里怎么了", "我还在想。", 2.0), 0 ); } #[test] fn npc_initiated_opening_keeps_neutral_affinity_delta() { // 首遇主动开场不是玩家发言结算,不能因为空 playerMessage 或占位文本触发好感变化。 let npc_initiates_conversation = true; let player_message = ""; let npc_reply = "你来了。先别急着走,我正有话想和你说。"; let affinity_delta = if npc_initiates_conversation { 0 } else { compute_npc_chat_affinity_delta(player_message, npc_reply, 0.0) }; assert_eq!(affinity_delta, 0); } #[test] fn npc_chat_suggestion_parser_strips_list_markers() { assert_eq!( parse_line_list_content("1. 继续问线索\n- 表明立场\n* 拉近关系\n4. 多余", 3), vec!["继续问线索", "表明立场", "拉近关系"] ); } #[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"); state .put_runtime_snapshot_record( "user_00000001".to_string(), 1, "adventure".to_string(), json!({ "worldType": "WUXIA", "runtimeSessionId": "runtime-main", "playerCharacter": { "id": "hero-main", "name": "旧存档" }, "currentEncounter": { "id": "npc-main", "npcName": "旧 NPC" }, "sceneHostileNpcs": [], "storyHistory": [], }), None, 1, ) .await .expect("snapshot should seed"); let request_context = test_request_context(); let mut payload = test_npc_chat_turn_payload( "runtime-preview", Some(json!({ "worldType": "CUSTOM", "runtimeSessionId": "runtime-preview", "runtimePersistenceDisabled": true, "playerCharacter": { "id": "hero-preview", "name": "临时角色" }, "currentEncounter": { "id": "npc-preview", "npcName": "临时 NPC" }, "sceneHostileNpcs": [{ "id": "monster-preview", "name": "雾影" }], "storyHistory": [{ "text": "临时故事" }], "npcStates": { "npc-preview": { "affinity": 12, "helpUsed": false, "chattedCount": 2, "giftsGiven": 0, "recruited": false } } })), ); hydrate_npc_chat_turn_request_from_session( &state, &request_context, "user_00000001".to_string(), &mut payload, ) .await .expect("request snapshot should hydrate"); assert_eq!(payload.world_type, "CUSTOM"); assert_eq!( read_optional_string_field(&payload.encounter, "npcName").as_deref(), Some("临时 NPC") ); assert_eq!(payload.monsters.len(), 1); assert_eq!(read_i32_field(&payload.npc_state, "affinity"), Some(12)); } #[tokio::test] async fn npc_chat_turn_rejects_request_snapshot_session_mismatch() { let state = AppState::new(AppConfig::default()).expect("state should build"); let request_context = test_request_context(); let mut payload = test_npc_chat_turn_payload( "runtime-preview", Some(json!({ "worldType": "WUXIA", "runtimeSessionId": "runtime-other", })), ); let response = hydrate_npc_chat_turn_request_from_session( &state, &request_context, "user_00000001".to_string(), &mut payload, ) .await .expect_err("snapshot session mismatch should fail"); assert_eq!(response.status(), StatusCode::CONFLICT); } fn test_request_context() -> RequestContext { RequestContext::new( "runtime-chat-test".to_string(), "POST /api/runtime/chat/npc/turn/stream".to_string(), Duration::ZERO, false, ) } fn test_npc_chat_turn_payload( session_id: &str, game_state: Option, ) -> NpcChatTurnRequest { NpcChatTurnRequest { session_id: Some(session_id.to_string()), snapshot: game_state.map(|game_state| RuntimeStorySnapshotPayload { saved_at: None, bottom_tab: "adventure".to_string(), game_state, current_story: None, }), world_type: String::new(), character: None, player: None, encounter: json!({ "id": "npc-request", "npcName": "请求 NPC" }), monsters: Vec::new(), history: Vec::new(), context: Value::Null, conversation_history: Vec::new(), dialogue: Vec::new(), combat_context: None, player_message: "你刚才看见了什么?".to_string(), npc_state: Value::Null, npc_initiates_conversation: false, quest_offer_context: None, chat_directive: None, } } }