1
This commit is contained in:
@@ -35,7 +35,7 @@ use crate::{
|
||||
big_fish::{
|
||||
create_big_fish_session, delete_big_fish_work, execute_big_fish_action,
|
||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play,
|
||||
stream_big_fish_message, submit_big_fish_message,
|
||||
remix_big_fish_gallery_work, stream_big_fish_message, submit_big_fish_message,
|
||||
},
|
||||
character_animation_assets::{
|
||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||
@@ -56,6 +56,7 @@ use crate::{
|
||||
get_custom_world_gallery_detail_by_code, get_custom_world_library,
|
||||
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
|
||||
publish_custom_world_library_profile, put_custom_world_library_profile,
|
||||
record_custom_world_gallery_play, remix_custom_world_gallery_profile,
|
||||
stream_custom_world_agent_message, submit_custom_world_agent_message,
|
||||
unpublish_custom_world_library_profile,
|
||||
},
|
||||
@@ -84,8 +85,8 @@ use crate::{
|
||||
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
|
||||
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
|
||||
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
|
||||
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
|
||||
submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||
remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message,
|
||||
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||
},
|
||||
refresh_session::refresh_session,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
@@ -522,6 +523,20 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}",
|
||||
get(get_custom_world_gallery_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix",
|
||||
post(remix_custom_world_gallery_profile).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play",
|
||||
post(record_custom_world_gallery_play).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-gallery/by-code/{code}",
|
||||
get(get_custom_world_gallery_detail_by_code),
|
||||
@@ -634,6 +649,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)),
|
||||
)
|
||||
.route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery))
|
||||
.route(
|
||||
"/api/runtime/big-fish/gallery/{session_id}/remix",
|
||||
post(remix_big_fish_gallery_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/big-fish/works/{session_id}",
|
||||
delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state(
|
||||
@@ -712,6 +734,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/runtime/puzzle/gallery/{profile_id}",
|
||||
get(get_puzzle_gallery_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/puzzle/gallery/{profile_id}/remix",
|
||||
post(remix_puzzle_gallery_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/puzzle/runs",
|
||||
post(start_puzzle_run).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -35,7 +35,7 @@ use spacetime_client::{
|
||||
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
|
||||
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput,
|
||||
BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord,
|
||||
BigFishWorkSummaryRecord, SpacetimeClientError,
|
||||
BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, SpacetimeClientError,
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -251,6 +251,36 @@ pub async fn record_big_fish_play(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn remix_big_fish_gallery_work(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||||
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.remix_big_fish_work(BigFishWorkRemixRecordInput {
|
||||
source_session_id: session_id,
|
||||
target_session_id: build_prefixed_uuid_id("big-fish-session-"),
|
||||
target_owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
welcome_message_id: build_prefixed_uuid_id("big-fish-message-"),
|
||||
remixed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
BigFishSessionResponse {
|
||||
session: map_big_fish_session_response(session),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn submit_big_fish_message(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
@@ -906,12 +936,15 @@ fn map_big_fish_work_summary_response(
|
||||
cover_image_src: item.cover_image_src,
|
||||
status: item.status,
|
||||
updated_at: current_timestamp_micros_to_string(item.updated_at_micros),
|
||||
published_at: item.published_at_micros.map(current_timestamp_micros_to_string),
|
||||
publish_ready: item.publish_ready,
|
||||
level_count: item.level_count,
|
||||
level_main_image_ready_count: item.level_main_image_ready_count,
|
||||
level_motion_ready_count: item.level_motion_ready_count,
|
||||
background_ready: item.background_ready,
|
||||
play_count: item.play_count,
|
||||
remix_count: item.remix_count,
|
||||
like_count: item.like_count,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ use spacetime_client::{
|
||||
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
|
||||
CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput,
|
||||
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
||||
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
||||
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||
@@ -758,6 +759,82 @@ pub async fn get_custom_world_gallery_detail_by_code(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn remix_custom_world_gallery_profile(
|
||||
State(state): State<AppState>,
|
||||
Path((owner_user_id, profile_id)): Path<(String, String)>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() {
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-gallery",
|
||||
"message": "ownerUserId and profileId are required",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let mutation = state
|
||||
.spacetime_client()
|
||||
.remix_custom_world_profile(CustomWorldProfileRemixRecordInput {
|
||||
source_owner_user_id: owner_user_id,
|
||||
source_profile_id: profile_id,
|
||||
target_owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
target_profile_id: build_prefixed_uuid_id("custom-world-profile-"),
|
||||
author_display_name: resolve_author_display_name(&state, &authenticated),
|
||||
remixed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CustomWorldLibraryMutationResponse {
|
||||
entry: map_custom_world_library_entry_response(mutation.entry.clone()),
|
||||
entries: vec![map_custom_world_library_entry_response(mutation.entry)],
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn record_custom_world_gallery_play(
|
||||
State(state): State<AppState>,
|
||||
Path((owner_user_id, profile_id)): Path<(String, String)>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() {
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-gallery",
|
||||
"message": "ownerUserId and profileId are required",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let mutation = state
|
||||
.spacetime_client()
|
||||
.record_custom_world_profile_play(CustomWorldProfilePlayReportRecordInput {
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
played_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CustomWorldGalleryDetailResponse {
|
||||
entry: map_custom_world_library_entry_response(mutation.entry),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_custom_world_agent_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -2632,6 +2709,9 @@ fn map_custom_world_library_entry_response(
|
||||
theme_mode: entry.theme_mode,
|
||||
playable_npc_count: entry.playable_npc_count,
|
||||
landmark_count: entry.landmark_count,
|
||||
play_count: entry.play_count,
|
||||
remix_count: entry.remix_count,
|
||||
like_count: entry.like_count,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2654,6 +2734,9 @@ fn map_custom_world_gallery_card_response(
|
||||
theme_mode: entry.theme_mode,
|
||||
playable_npc_count: entry.playable_npc_count,
|
||||
landmark_count: entry.landmark_count,
|
||||
play_count: entry.play_count,
|
||||
remix_count: entry.remix_count,
|
||||
like_count: entry.like_count,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ use spacetime_client::{
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord,
|
||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tokio::time::sleep;
|
||||
@@ -882,6 +882,49 @@ pub async fn get_puzzle_gallery_detail(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn remix_puzzle_gallery_work(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(
|
||||
&request_context,
|
||||
PUZZLE_GALLERY_PROVIDER,
|
||||
&profile_id,
|
||||
"profileId",
|
||||
)?;
|
||||
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.remix_puzzle_work(PuzzleWorkRemixRecordInput {
|
||||
source_profile_id: profile_id,
|
||||
target_owner_user_id: owner_user_id,
|
||||
target_session_id: build_prefixed_uuid_id("puzzle-session-"),
|
||||
target_profile_id: build_prefixed_uuid_id("puzzle-profile-"),
|
||||
target_work_id: build_prefixed_uuid_id("puzzle-work-"),
|
||||
author_display_name: resolve_author_display_name(&state, &authenticated),
|
||||
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
|
||||
remixed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_GALLERY_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleAgentSessionResponse {
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn start_puzzle_run(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1354,6 +1397,8 @@ fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWork
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
play_count: item.play_count,
|
||||
remix_count: item.remix_count,
|
||||
like_count: item.like_count,
|
||||
publish_ready: item.publish_ready,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 crate::{
|
||||
@@ -27,6 +28,8 @@ pub struct RuntimeCharacterChatRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
player_character: Value,
|
||||
@@ -54,6 +57,8 @@ pub struct RuntimeNpcDialogueRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
character: Value,
|
||||
@@ -77,6 +82,8 @@ pub struct RuntimeNpcRecruitDialogueRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
character: Value,
|
||||
@@ -346,6 +353,7 @@ async fn hydrate_character_chat_request_from_session(
|
||||
request_context,
|
||||
user_id,
|
||||
payload.session_id.as_deref(),
|
||||
payload.snapshot.as_ref(),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
@@ -382,6 +390,7 @@ async fn hydrate_npc_dialogue_request_from_session(
|
||||
request_context,
|
||||
user_id,
|
||||
payload.session_id.as_deref(),
|
||||
payload.snapshot.as_ref(),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
@@ -430,6 +439,7 @@ async fn hydrate_npc_recruit_request_from_session(
|
||||
request_context,
|
||||
user_id,
|
||||
payload.session_id.as_deref(),
|
||||
payload.snapshot.as_ref(),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
@@ -472,11 +482,19 @@ async fn resolve_runtime_chat_game_state(
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
session_id: Option<&str>,
|
||||
snapshot: Option<&RuntimeStorySnapshotPayload>,
|
||||
) -> Result<Option<Value>, Response> {
|
||||
let Some(session_id) = session_id.and_then(normalize_required_string) else {
|
||||
// 中文注释:未携带 sessionId 的旧调用仅保留兼容,后续正式运行态应全部走后端快照。
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if let Some(game_state) =
|
||||
resolve_request_snapshot_game_state(request_context, session_id.as_str(), snapshot)?
|
||||
{
|
||||
return Ok(Some(game_state));
|
||||
}
|
||||
|
||||
let record = state
|
||||
.get_runtime_snapshot_record(user_id)
|
||||
.await
|
||||
@@ -516,6 +534,43 @@ async fn resolve_runtime_chat_game_state(
|
||||
Ok(Some(game_state))
|
||||
}
|
||||
|
||||
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_plain_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_plain_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 构造上下文,不把它写回 runtime_snapshot。
|
||||
Ok(Some(snapshot.game_state.clone()))
|
||||
}
|
||||
|
||||
async fn request_runtime_plain_text(
|
||||
state: &AppState,
|
||||
system_prompt: &'static str,
|
||||
|
||||
Reference in New Issue
Block a user