547 lines
19 KiB
Rust
547 lines
19 KiB
Rust
use module_inventory::{RuntimeInventorySlotRecord, RuntimeInventoryStateRecord};
|
|
use module_runtime_story::StoryRuntimeProjectionSource;
|
|
use serde_json::{Map, Value, json};
|
|
use shared_contracts::{
|
|
runtime_story::RuntimeStoryOptionView,
|
|
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<StoryRuntimeProjectionSource, SpacetimeClientError> {
|
|
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 = runtime_snapshot.version.max(story_state.session.version);
|
|
|
|
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: read_runtime_story_options(current_story)?,
|
|
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<RuntimeInventoryStateRecord>,
|
|
expected_actor_user_id: &str,
|
|
) -> Result<Value, SpacetimeClientError> {
|
|
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::<Vec<_>>();
|
|
let backpack_items = inventory_state
|
|
.backpack_items
|
|
.into_iter()
|
|
.map(|slot| runtime_inventory_slot_to_game_item(slot, &existing_items))
|
|
.collect::<Vec<_>>();
|
|
|
|
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<String, Map<String, Value>> {
|
|
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<String, Map<String, Value>>, 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<Value>) -> Map<String, Value> {
|
|
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<String, Map<String, Value>>,
|
|
) -> 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<String, Value> {
|
|
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_runtime_story_options(
|
|
current_story: Option<&Value>,
|
|
) -> Result<Vec<RuntimeStoryOptionView>, SpacetimeClientError> {
|
|
let Some(options) = current_story.and_then(|story| story.get("options")) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
|
|
serde_json::from_value::<Vec<RuntimeStoryOptionView>>(options.clone()).map_err(|error| {
|
|
SpacetimeClientError::Runtime(format!(
|
|
"currentStory.options 无法映射为后端选项投影: {error}"
|
|
))
|
|
})
|
|
}
|
|
|
|
fn read_current_story_text(current_story: Option<&Value>) -> Option<String> {
|
|
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<String> {
|
|
current_story?
|
|
.as_object()?
|
|
.get(field)?
|
|
.as_str()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
#[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 current_story_options_parse_runtime_story_options() {
|
|
let options = read_runtime_story_options(Some(&json!({
|
|
"text": "守火人抬眼看着你。",
|
|
"options": [{
|
|
"functionId": "npc_chat",
|
|
"actionText": "继续交谈",
|
|
"scope": "npc"
|
|
}]
|
|
})))
|
|
.expect("options should parse");
|
|
|
|
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<Value>,
|
|
) -> 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(),
|
|
}],
|
|
}
|
|
}
|
|
}
|