Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

@@ -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(),
}],
}
}
}