This commit is contained in:
2026-04-29 11:51:04 +08:00
parent e191619ab3
commit 412279ae11
89 changed files with 3966 additions and 491 deletions

View File

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