use module_inventory::{RuntimeInventorySlotRecord, RuntimeInventoryStateRecord}; use module_runtime_story::StoryRuntimeProjectionSource; use serde_json::{Map, Value, json}; use shared_contracts::story::{StoryEventPayload, StorySessionPayload}; use std::collections::HashMap; use super::*; impl SpacetimeClient { pub async fn get_story_runtime_projection_source( &self, story_session_id: String, actor_user_id: String, ) -> Result { let story_state = self.get_story_session_state(story_session_id).await?; if story_state.session.actor_user_id != actor_user_id { return Err(SpacetimeClientError::Runtime( "story session 不属于当前用户".to_string(), )); } let runtime_snapshot = self .get_runtime_snapshot(actor_user_id.clone()) .await? .ok_or_else(|| { SpacetimeClientError::Runtime("当前用户缺少 runtime snapshot".to_string()) })?; assert_runtime_snapshot_matches_story_session(&story_state.session, &runtime_snapshot)?; let inventory_state = self .get_runtime_inventory_state( story_state.session.runtime_session_id.clone(), actor_user_id, ) .await?; let game_state = build_projection_game_state( runtime_snapshot.game_state, Some(inventory_state), &story_state.session.actor_user_id, )?; let current_story = runtime_snapshot.current_story.as_ref(); let latest_narrative_text = story_state.session.latest_narrative_text.clone(); let server_version = resolve_story_runtime_server_version(&game_state, story_state.session.version); let options = module_runtime_story::build_runtime_story_options(current_story, &game_state); Ok(StoryRuntimeProjectionSource { story_session: build_story_session_payload(story_state.session), story_events: story_state .events .into_iter() .map(build_story_event_payload) .collect(), game_state, options, server_version, current_narrative_text: read_current_story_text(current_story) .or(Some(latest_narrative_text)), action_result_text: read_current_story_string(current_story, "resultText"), toast: read_current_story_string(current_story, "toast"), }) } } fn build_projection_game_state( mut game_state: Value, inventory_state: Option, expected_actor_user_id: &str, ) -> Result { let Some(inventory_state) = inventory_state else { return Ok(game_state); }; assert_runtime_inventory_matches_game_state( &game_state, &inventory_state, expected_actor_user_id, )?; write_runtime_inventory_state(&mut game_state, inventory_state); Ok(game_state) } fn assert_runtime_inventory_matches_game_state( game_state: &Value, inventory_state: &RuntimeInventoryStateRecord, expected_actor_user_id: &str, ) -> Result<(), SpacetimeClientError> { let Some(runtime_session_id) = game_state .as_object() .and_then(|state| state.get("runtimeSessionId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return Err(SpacetimeClientError::Runtime( "runtime snapshot 缺少 runtimeSessionId".to_string(), )); }; if inventory_state.runtime_session_id != runtime_session_id { return Err(SpacetimeClientError::Runtime( "runtime inventory state 与 runtime snapshot 不匹配".to_string(), )); } if inventory_state.actor_user_id != expected_actor_user_id { return Err(SpacetimeClientError::Runtime( "runtime inventory state 不属于当前用户".to_string(), )); } Ok(()) } fn write_runtime_inventory_state( game_state: &mut Value, inventory_state: RuntimeInventoryStateRecord, ) { let existing_items = collect_existing_runtime_items(game_state); let equipment_items = inventory_state .equipment_items .into_iter() .map(|slot| runtime_inventory_slot_to_game_item(slot, &existing_items)) .collect::>(); let backpack_items = inventory_state .backpack_items .into_iter() .map(|slot| runtime_inventory_slot_to_game_item(slot, &existing_items)) .collect::>(); let root = ensure_game_state_object(game_state); root.insert("playerInventory".to_string(), Value::Array(backpack_items)); root.insert( "playerEquipment".to_string(), Value::Object(build_runtime_equipment_map(equipment_items)), ); } fn collect_existing_runtime_items(game_state: &Value) -> HashMap> { let mut items = HashMap::new(); let Some(root) = game_state.as_object() else { return items; }; if let Some(inventory) = root.get("playerInventory").and_then(Value::as_array) { for item in inventory { collect_existing_runtime_item(&mut items, item); } } if let Some(equipment) = root.get("playerEquipment").and_then(Value::as_object) { for item in equipment.values() { collect_existing_runtime_item(&mut items, item); } } items } fn collect_existing_runtime_item(items: &mut HashMap>, item: &Value) { let Some(object) = item.as_object() else { return; }; let Some(item_id) = object .get("id") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; items.insert(item_id.to_string(), object.clone()); } fn build_runtime_equipment_map(equipment_items: Vec) -> Map { let mut equipment = Map::from_iter([ ("weapon".to_string(), Value::Null), ("armor".to_string(), Value::Null), ("relic".to_string(), Value::Null), ]); for item in equipment_items { let Some(slot_id) = item .as_object() .and_then(|object| object.get("equipmentSlotId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| matches!(*value, "weapon" | "armor" | "relic")) else { continue; }; equipment.insert(slot_id.to_string(), item); } equipment } fn runtime_inventory_slot_to_game_item( slot: RuntimeInventorySlotRecord, existing_items: &HashMap>, ) -> Value { let mut item = existing_items .get(&slot.item_id) .cloned() .unwrap_or_else(Map::new); item.insert("id".to_string(), Value::String(slot.item_id)); item.insert("category".to_string(), Value::String(slot.category)); item.insert("name".to_string(), Value::String(slot.name)); if let Some(description) = slot.description { item.insert("description".to_string(), Value::String(description)); } else { item.remove("description"); } item.insert("quantity".to_string(), json!(slot.quantity)); item.insert("rarity".to_string(), Value::String(slot.rarity)); item.insert( "tags".to_string(), Value::Array(slot.tags.into_iter().map(Value::String).collect()), ); item.insert("stackable".to_string(), Value::Bool(slot.stackable)); item.insert("stackKey".to_string(), Value::String(slot.stack_key)); if let Some(equipment_slot_id) = slot.equipment_slot_id { item.insert( "equipmentSlotId".to_string(), Value::String(equipment_slot_id), ); } else { item.remove("equipmentSlotId"); } item.insert("sourceKind".to_string(), Value::String(slot.source_kind)); if let Some(source_reference_id) = slot.source_reference_id { item.insert( "sourceReferenceId".to_string(), Value::String(source_reference_id), ); } else { item.remove("sourceReferenceId"); } item.insert("inventorySlotId".to_string(), Value::String(slot.slot_id)); item.insert("inventorySlotKey".to_string(), Value::String(slot.slot_key)); item.insert("createdAt".to_string(), Value::String(slot.created_at)); item.insert("updatedAt".to_string(), Value::String(slot.updated_at)); Value::Object(item) } fn ensure_game_state_object(game_state: &mut Value) -> &mut Map { if !game_state.is_object() { *game_state = Value::Object(Map::new()); } game_state .as_object_mut() .expect("projection game_state should be object") } fn assert_runtime_snapshot_matches_story_session( session: &StorySessionRecord, snapshot: &RuntimeSnapshotRecord, ) -> Result<(), SpacetimeClientError> { let Some(runtime_session_id) = snapshot .game_state .as_object() .and_then(|state| state.get("runtimeSessionId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return Err(SpacetimeClientError::Runtime( "runtime snapshot 缺少 runtimeSessionId".to_string(), )); }; if runtime_session_id != session.runtime_session_id { return Err(SpacetimeClientError::Runtime( "runtime snapshot 与 story session 不匹配".to_string(), )); } Ok(()) } fn build_story_session_payload(record: StorySessionRecord) -> StorySessionPayload { StorySessionPayload { story_session_id: record.story_session_id, runtime_session_id: record.runtime_session_id, actor_user_id: record.actor_user_id, world_profile_id: record.world_profile_id, initial_prompt: record.initial_prompt, opening_summary: record.opening_summary, latest_narrative_text: record.latest_narrative_text, latest_choice_function_id: record.latest_choice_function_id, status: record.status, version: record.version, created_at: record.created_at, updated_at: record.updated_at, } } fn build_story_event_payload(record: StoryEventRecord) -> StoryEventPayload { StoryEventPayload { event_id: record.event_id, story_session_id: record.story_session_id, event_kind: record.event_kind, narrative_text: record.narrative_text, choice_function_id: record.choice_function_id, created_at: record.created_at, } } fn read_current_story_text(current_story: Option<&Value>) -> Option { read_current_story_string(current_story, "text") .or_else(|| read_current_story_string(current_story, "storyText")) } fn read_current_story_string(current_story: Option<&Value>, field: &str) -> Option { current_story? .as_object()? .get(field)? .as_str() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn read_current_runtime_action_version(game_state: &Value) -> Option { game_state .as_object()? .get("runtimeActionVersion")? .as_u64() .and_then(|value| u32::try_from(value).ok()) } fn resolve_story_runtime_server_version(game_state: &Value, story_session_version: u32) -> u32 { read_current_runtime_action_version(game_state) .or(Some(story_session_version)) .unwrap_or(1) } #[cfg(test)] mod tests { use serde_json::json; use super::*; #[test] fn runtime_snapshot_session_guard_accepts_matching_runtime_session() { let session = story_session_record(); let snapshot = runtime_snapshot_record(json!({ "runtimeSessionId": "runtime_1" }), None); assert!(assert_runtime_snapshot_matches_story_session(&session, &snapshot).is_ok()); } #[test] fn runtime_snapshot_session_guard_rejects_mismatched_runtime_session() { let session = story_session_record(); let snapshot = runtime_snapshot_record(json!({ "runtimeSessionId": "runtime_other" }), None); let error = assert_runtime_snapshot_matches_story_session(&session, &snapshot) .expect_err("mismatched runtime session should fail"); assert!(error.to_string().contains("不匹配")); } #[test] fn projection_game_state_overlays_runtime_inventory_facade_state() { let game_state = build_projection_game_state( json!({ "runtimeSessionId": "runtime_1", "playerInventory": [{ "id": "potion-typed", "name": "旧药", "useProfile": { "heal": 8 } }], "playerEquipment": { "weapon": null, "armor": null, "relic": null } }), Some(runtime_inventory_state_record()), "user_1", ) .expect("inventory overlay should build"); assert_eq!( game_state["playerInventory"][0]["id"], json!("potion-typed") ); assert_eq!( game_state["playerInventory"][0]["inventorySlotId"], json!("invslot_backpack") ); assert_eq!( game_state["playerInventory"][0]["useProfile"]["heal"], json!(8) ); assert_eq!( game_state["playerEquipment"]["weapon"]["id"], json!("blade-typed") ); assert_eq!(game_state["playerEquipment"]["armor"], Value::Null); assert_eq!(game_state["playerEquipment"]["relic"], Value::Null); } #[test] fn projection_game_state_rejects_mismatched_inventory_runtime_session() { let mut inventory_state = runtime_inventory_state_record(); inventory_state.runtime_session_id = "runtime_other".to_string(); let error = build_projection_game_state( json!({ "runtimeSessionId": "runtime_1" }), Some(inventory_state), "user_1", ) .expect_err("mismatched inventory state should fail"); assert!(error.to_string().contains("不匹配")); } #[test] fn projection_game_state_rejects_mismatched_inventory_actor() { let mut inventory_state = runtime_inventory_state_record(); inventory_state.actor_user_id = "user_other".to_string(); let error = build_projection_game_state( json!({ "runtimeSessionId": "runtime_1" }), Some(inventory_state), "user_1", ) .expect_err("mismatched inventory actor should fail"); assert!(error.to_string().contains("不属于当前用户")); } #[test] fn runtime_projection_source_uses_runtime_action_version() { let game_state = json!({ "runtimeSessionId": "runtime_1", "runtimeActionVersion": 1 }); assert_eq!(resolve_story_runtime_server_version(&game_state, 3), 1); } #[test] fn current_story_options_infer_scope_for_legacy_story_options() { let current_story = json!({ "text": "守火人抬眼看着你。", "options": [{ "functionId": "npc_chat", "actionText": "继续交谈" }] }); let options = module_runtime_story::build_runtime_story_options(Some(¤t_story), &json!({})); assert_eq!(options[0].function_id, "npc_chat"); assert_eq!(options[0].action_text, "继续交谈"); assert_eq!(options[0].scope, "npc"); } #[test] fn current_story_text_prefers_text_then_story_text() { assert_eq!( read_current_story_text(Some(&json!({ "text": "正文", "storyText": "备用" }))) .as_deref(), Some("正文") ); assert_eq!( read_current_story_text(Some(&json!({ "storyText": "备用" }))).as_deref(), Some("备用") ); } fn story_session_record() -> StorySessionRecord { StorySessionRecord { story_session_id: "storysess_1".to_string(), runtime_session_id: "runtime_1".to_string(), actor_user_id: "user_1".to_string(), world_profile_id: "profile_1".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), latest_narrative_text: "篝火仍然亮着。".to_string(), latest_choice_function_id: Some("npc_chat".to_string()), status: "active".to_string(), version: 3, created_at: "1.000000Z".to_string(), updated_at: "3.000000Z".to_string(), } } fn runtime_snapshot_record( game_state: Value, current_story: Option, ) -> RuntimeSnapshotRecord { RuntimeSnapshotRecord { user_id: "user_1".to_string(), version: 2, saved_at: "3.000000Z".to_string(), saved_at_micros: 3, bottom_tab: "adventure".to_string(), game_state, current_story, game_state_json: "{}".to_string(), current_story_json: None, created_at_micros: 1, updated_at_micros: 3, } } fn runtime_inventory_state_record() -> RuntimeInventoryStateRecord { RuntimeInventoryStateRecord { runtime_session_id: "runtime_1".to_string(), actor_user_id: "user_1".to_string(), backpack_items: vec![RuntimeInventorySlotRecord { slot_id: "invslot_backpack".to_string(), container_kind: "backpack".to_string(), slot_key: "invslot_backpack".to_string(), item_id: "potion-typed".to_string(), category: "消耗品".to_string(), name: "疗伤药".to_string(), description: Some("用于恢复少量气血。".to_string()), quantity: 2, rarity: "common".to_string(), tags: vec!["healing".to_string()], stackable: true, stack_key: "potion-typed".to_string(), equipment_slot_id: None, source_kind: "treasure_reward".to_string(), source_reference_id: Some("treasure_1".to_string()), created_at: "1.000000Z".to_string(), updated_at: "2.000000Z".to_string(), }], equipment_items: vec![RuntimeInventorySlotRecord { slot_id: "invslot_weapon".to_string(), container_kind: "equipment".to_string(), slot_key: "weapon".to_string(), item_id: "blade-typed".to_string(), category: "武器".to_string(), name: "逐风短剑".to_string(), description: None, quantity: 1, rarity: "rare".to_string(), tags: vec!["weapon".to_string(), "快剑".to_string()], stackable: false, stack_key: "blade-typed".to_string(), equipment_slot_id: Some("weapon".to_string()), source_kind: "story_reward".to_string(), source_reference_id: None, created_at: "1.000000Z".to_string(), updated_at: "2.000000Z".to_string(), }], } } }