推进 server-rs DDD 分层与新接口接线
This commit is contained in:
227
server-rs/crates/spacetime-client/src/story_runtime.rs
Normal file
227
server-rs/crates/spacetime-client/src/story_runtime.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use module_runtime_story::StoryRuntimeProjectionSource;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::{
|
||||
runtime_story::RuntimeStoryOptionView,
|
||||
story::{StoryEventPayload, StorySessionPayload},
|
||||
};
|
||||
|
||||
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)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
SpacetimeClientError::Runtime("当前用户缺少 runtime snapshot".to_string())
|
||||
})?;
|
||||
assert_runtime_snapshot_matches_story_session(&story_state.session, &runtime_snapshot)?;
|
||||
|
||||
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: runtime_snapshot.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 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 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user