Merge remote-tracking branch 'origin/master' into codex/ddd

# Conflicts:
#	docs/technical/README.md
#	docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md
#	docs/technical/SPACETIMEDB_TABLE_CATALOG.md
#	scripts/generate-spacetime-bindings.mjs
#	server-rs/crates/api-server/src/app.rs
#	server-rs/crates/api-server/src/assets.rs
#	server-rs/crates/api-server/src/big_fish.rs
#	server-rs/crates/api-server/src/custom_world_ai.rs
#	server-rs/crates/api-server/src/llm.rs
#	server-rs/crates/api-server/src/main.rs
#	server-rs/crates/api-server/src/puzzle.rs
#	server-rs/crates/api-server/src/runtime_profile.rs
#	server-rs/crates/api-server/src/runtime_story/compat/ai.rs
#	server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs
#	server-rs/crates/api-server/src/runtime_story/compat/presentation.rs
#	server-rs/crates/api-server/src/runtime_story/compat/tests.rs
#	server-rs/crates/api-server/src/state.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/module-big-fish/src/lib.rs
#	server-rs/crates/module-custom-world/src/lib.rs
#	server-rs/crates/module-puzzle/src/lib.rs
#	server-rs/crates/module-runtime/src/lib.rs
#	server-rs/crates/spacetime-client/src/big_fish.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	server-rs/crates/spacetime-client/src/mapper.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/mod.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	server-rs/crates/spacetime-module/src/custom_world/mod.rs
#	server-rs/crates/spacetime-module/src/lib.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	server-rs/crates/spacetime-module/src/puzzle.rs
#	server-rs/crates/spacetime-module/src/runtime/profile.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/aiService.ts
#	src/services/puzzle-runtime/puzzleRuntimeClient.ts
This commit is contained in:
kdletters
2026-05-02 03:35:59 +08:00
513 changed files with 52813 additions and 6013 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::{
@@ -21,12 +22,13 @@ use module_runtime_story::{
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_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,
@@ -38,6 +40,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>,
@@ -133,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 {
@@ -224,6 +238,7 @@ where
]);
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| {
@@ -251,6 +266,7 @@ where
]);
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
@@ -266,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 {
@@ -292,6 +309,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 +355,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 +431,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> {
@@ -561,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;
@@ -574,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 = [
"动手",
"开战",
@@ -583,6 +660,18 @@ fn should_hostile_chat_breakoff_deterministically(
"闭嘴",
"少废话",
"别挡路",
"废话",
"威胁",
"找死",
"送死",
"住口",
"让开",
"滚开",
"不退",
"不会退",
"别装",
"骗子",
"叛徒",
];
count_keyword_matches(player_message, &hostile_break_words) > 0
}
@@ -709,6 +798,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 +843,174 @@ mod tests {
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<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,
}
}
}