推进 server-rs DDD 分层与新接口接线

This commit is contained in:
Codex
2026-04-29 15:46:16 +08:00
parent 9d3fcfae77
commit f82775b852
89 changed files with 3657 additions and 9636 deletions

View 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,
}
}
}