推进 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

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -64,6 +65,79 @@ pub struct StorySessionStateResponse {
pub story_events: Vec<StoryEventPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct StoryRuntimeProjectionRequest {
pub story_session_id: String,
#[serde(default)]
pub client_version: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct StoryRuntimeActorProjection {
pub hp: i32,
pub max_hp: i32,
pub mana: i32,
pub max_mana: i32,
pub currency: i32,
pub currency_text: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct StoryRuntimeInventoryProjection {
pub backpack_items: Vec<Value>,
pub equipment_slots: Vec<Value>,
pub forge_recipes: Vec<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct StoryRuntimeOptionProjection {
pub function_id: String,
pub action_text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail_text: Option<String>,
pub scope: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<Value>,
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct StoryRuntimeStatusProjection {
pub in_battle: bool,
pub npc_interaction_active: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_encounter_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_npc_battle_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_npc_battle_outcome: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct StoryRuntimeProjectionResponse {
pub story_session: StorySessionPayload,
pub story_events: Vec<StoryEventPayload>,
pub server_version: u32,
pub actor: StoryRuntimeActorProjection,
pub inventory: StoryRuntimeInventoryProjection,
pub options: Vec<StoryRuntimeOptionProjection>,
pub status: StoryRuntimeStatusProjection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_narrative_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_result_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub toast: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -161,4 +235,81 @@ mod tests {
json!("story_continued")
);
}
#[test]
fn story_runtime_projection_response_uses_new_story_runtime_contract() {
let payload = serde_json::to_value(StoryRuntimeProjectionResponse {
story_session: StorySessionPayload {
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("talk_to_npc".to_string()),
status: "active".to_string(),
version: 2,
created_at: "1.000000Z".to_string(),
updated_at: "2.000000Z".to_string(),
},
story_events: vec![StoryEventPayload {
event_id: "storyevt_2".to_string(),
story_session_id: "storysess_1".to_string(),
event_kind: "story_continued".to_string(),
narrative_text: "你看见篝火边有人招手。".to_string(),
choice_function_id: Some("talk_to_npc".to_string()),
created_at: "2.000000Z".to_string(),
}],
server_version: 2,
actor: StoryRuntimeActorProjection {
hp: 32,
max_hp: 40,
mana: 18,
max_mana: 20,
currency: 80,
currency_text: "80 铜钱".to_string(),
},
inventory: StoryRuntimeInventoryProjection {
backpack_items: vec![json!({ "id": "potion-1", "name": "疗伤药" })],
equipment_slots: vec![json!({ "slotId": "weapon", "label": "武器" })],
forge_recipes: Vec::new(),
},
options: vec![StoryRuntimeOptionProjection {
function_id: "npc_chat".to_string(),
action_text: "继续交谈".to_string(),
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
scope: "npc".to_string(),
payload: Some(json!({ "npcId": "npc_camp_firekeeper" })),
enabled: true,
reason: None,
}],
status: StoryRuntimeStatusProjection {
in_battle: false,
npc_interaction_active: true,
current_encounter_id: Some("npc_camp_firekeeper".to_string()),
current_npc_battle_mode: None,
current_npc_battle_outcome: None,
},
current_narrative_text: Some("守火人示意你继续说。".to_string()),
action_result_text: None,
toast: None,
})
.expect("payload should serialize");
assert_eq!(
payload["storySession"]["storySessionId"],
json!("storysess_1")
);
assert_eq!(payload["serverVersion"], json!(2));
assert_eq!(payload["actor"]["maxHp"], json!(40));
assert_eq!(
payload["inventory"]["backpackItems"][0]["name"],
json!("疗伤药")
);
assert_eq!(payload["options"][0]["functionId"], json!("npc_chat"));
assert!(payload.get("snapshot").is_none());
assert!(payload.get("viewModel").is_none());
assert!(payload.get("presentation").is_none());
}
}