1
This commit is contained in:
@@ -10,6 +10,7 @@ use axum::{
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime_story::RuntimeStorySnapshotPayload;
|
||||
use std::convert::Infallible;
|
||||
|
||||
use module_runtime_story_compat::{
|
||||
@@ -38,6 +39,8 @@ pub struct NpcChatTurnRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
character: Option<Value>,
|
||||
@@ -292,6 +295,16 @@ async fn hydrate_npc_chat_turn_request_from_session(
|
||||
// 中文注释:旧调用没有 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
|
||||
@@ -328,6 +341,49 @@ async fn hydrate_npc_chat_turn_request_from_session(
|
||||
));
|
||||
}
|
||||
|
||||
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<Option<Value>, 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();
|
||||
@@ -361,8 +417,6 @@ async fn hydrate_npc_chat_turn_request_from_session(
|
||||
object.insert("state".to_string(), game_state);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_current_request_npc_state(game_state: &Value) -> Option<Value> {
|
||||
@@ -709,6 +763,8 @@ fn runtime_chat_error_response(request_context: &RequestContext, error: AppError
|
||||
#[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() {
|
||||
@@ -752,4 +808,129 @@ mod tests {
|
||||
vec!["继续问线索", "表明立场", "拉近关系"]
|
||||
);
|
||||
}
|
||||
|
||||
#[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<Value>,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user