Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
use module_inventory::{RuntimeInventorySlotRecord, RuntimeInventoryStateRecord};
|
||||
use module_runtime_story::StoryRuntimeProjectionSource;
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::{
|
||||
runtime_story::RuntimeStoryOptionView,
|
||||
story::{StoryEventPayload, StorySessionPayload},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -20,14 +22,25 @@ impl SpacetimeClient {
|
||||
));
|
||||
}
|
||||
|
||||
let runtime_snapshot =
|
||||
self.get_runtime_snapshot(actor_user_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
SpacetimeClientError::Runtime("当前用户缺少 runtime snapshot".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);
|
||||
@@ -39,7 +52,7 @@ impl SpacetimeClient {
|
||||
.into_iter()
|
||||
.map(build_story_event_payload)
|
||||
.collect(),
|
||||
game_state: runtime_snapshot.game_state,
|
||||
game_state,
|
||||
options: read_runtime_story_options(current_story)?,
|
||||
server_version,
|
||||
current_narrative_text: read_current_story_text(current_story)
|
||||
@@ -50,6 +63,200 @@ impl SpacetimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -159,6 +366,73 @@ mod tests {
|
||||
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!({
|
||||
@@ -224,4 +498,49 @@ mod tests {
|
||||
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(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user