后端重写提交
This commit is contained in:
324
server-rs/crates/shared-contracts/src/runtime_story.rs
Normal file
324
server-rs/crates/shared-contracts/src/runtime_story.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStorySnapshotPayload {
|
||||
pub saved_at: String,
|
||||
pub bottom_tab: String,
|
||||
pub game_state: Value,
|
||||
#[serde(default)]
|
||||
pub current_story: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryStateResolveRequest {
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub client_version: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryOptionView {
|
||||
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 interaction: Option<RuntimeStoryOptionInteraction>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payload: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub disabled: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum RuntimeStoryOptionInteraction {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Npc {
|
||||
npc_id: String,
|
||||
action: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
quest_id: Option<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Treasure {
|
||||
action: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryPlayerViewModel {
|
||||
pub hp: i32,
|
||||
pub max_hp: i32,
|
||||
pub mana: i32,
|
||||
pub max_mana: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryCompanionViewModel {
|
||||
pub npc_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub character_id: Option<String>,
|
||||
pub joined_at_affinity: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryEncounterViewModel {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub npc_name: String,
|
||||
pub hostile: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub affinity: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recruited: Option<bool>,
|
||||
pub interaction_active: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub battle_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryStatusViewModel {
|
||||
pub in_battle: bool,
|
||||
pub npc_interaction_active: bool,
|
||||
#[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 RuntimeBattlePresentation {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub damage_dealt: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub damage_taken: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub outcome: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryViewModel {
|
||||
pub player: RuntimeStoryPlayerViewModel,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub encounter: Option<RuntimeStoryEncounterViewModel>,
|
||||
pub companions: Vec<RuntimeStoryCompanionViewModel>,
|
||||
pub available_options: Vec<RuntimeStoryOptionView>,
|
||||
pub status: RuntimeStoryStatusViewModel,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryPresentation {
|
||||
pub action_text: String,
|
||||
pub result_text: String,
|
||||
pub story_text: String,
|
||||
pub options: Vec<RuntimeStoryOptionView>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub toast: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub battle: Option<RuntimeBattlePresentation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum RuntimeStoryPatch {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StoryHistoryAppend {
|
||||
action_text: String,
|
||||
result_text: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
NpcAffinityChanged {
|
||||
npc_id: String,
|
||||
previous_affinity: i32,
|
||||
next_affinity: i32,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
BattleResolved {
|
||||
function_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
target_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
damage_dealt: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
damage_taken: Option<i32>,
|
||||
outcome: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StatusChanged {
|
||||
in_battle: bool,
|
||||
npc_interaction_active: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_npc_battle_mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_npc_battle_outcome: Option<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
EncounterChanged {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
encounter_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeStoryActionResponse {
|
||||
pub session_id: String,
|
||||
pub server_version: u32,
|
||||
pub view_model: RuntimeStoryViewModel,
|
||||
pub presentation: RuntimeStoryPresentation,
|
||||
pub patches: Vec<RuntimeStoryPatch>,
|
||||
pub snapshot: RuntimeStorySnapshotPayload,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn runtime_story_state_resolve_request_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(RuntimeStoryStateResolveRequest {
|
||||
session_id: "runtime-main".to_string(),
|
||||
client_version: Some(7),
|
||||
snapshot: Some(RuntimeStorySnapshotPayload {
|
||||
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
||||
current_story: Some(json!({ "text": "营地里的火光还没有熄灭。" })),
|
||||
}),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["sessionId"], json!("runtime-main"));
|
||||
assert_eq!(payload["clientVersion"], json!(7));
|
||||
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
|
||||
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
|
||||
assert_eq!(payload["snapshot"]["gameState"]["runtimeSessionId"], json!("runtime-main"));
|
||||
assert_eq!(
|
||||
payload["snapshot"]["currentStory"]["text"],
|
||||
json!("营地里的火光还没有熄灭。")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_story_action_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(RuntimeStoryActionResponse {
|
||||
session_id: "runtime-main".to_string(),
|
||||
server_version: 8,
|
||||
view_model: RuntimeStoryViewModel {
|
||||
player: RuntimeStoryPlayerViewModel {
|
||||
hp: 32,
|
||||
max_hp: 40,
|
||||
mana: 18,
|
||||
max_mana: 20,
|
||||
},
|
||||
encounter: Some(RuntimeStoryEncounterViewModel {
|
||||
id: "npc_camp_firekeeper".to_string(),
|
||||
kind: "npc".to_string(),
|
||||
npc_name: "守火人".to_string(),
|
||||
hostile: false,
|
||||
affinity: Some(12),
|
||||
recruited: Some(false),
|
||||
interaction_active: true,
|
||||
battle_mode: None,
|
||||
}),
|
||||
companions: vec![RuntimeStoryCompanionViewModel {
|
||||
npc_id: "npc_companion_001".to_string(),
|
||||
character_id: Some("char_companion_001".to_string()),
|
||||
joined_at_affinity: 64,
|
||||
}],
|
||||
available_options: vec![RuntimeStoryOptionView {
|
||||
function_id: "npc_chat".to_string(),
|
||||
action_text: "继续交谈".to_string(),
|
||||
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
|
||||
scope: "npc".to_string(),
|
||||
interaction: Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: "npc_camp_firekeeper".to_string(),
|
||||
action: "chat".to_string(),
|
||||
quest_id: None,
|
||||
}),
|
||||
payload: Some(json!({ "note": "server-runtime-test" })),
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}],
|
||||
status: RuntimeStoryStatusViewModel {
|
||||
in_battle: false,
|
||||
npc_interaction_active: true,
|
||||
current_npc_battle_mode: None,
|
||||
current_npc_battle_outcome: None,
|
||||
},
|
||||
},
|
||||
presentation: RuntimeStoryPresentation {
|
||||
action_text: "".to_string(),
|
||||
result_text: "".to_string(),
|
||||
story_text: "守火人抬眼看了你一瞬,示意你把想问的话继续说完。".to_string(),
|
||||
options: vec![RuntimeStoryOptionView {
|
||||
function_id: "npc_chat".to_string(),
|
||||
action_text: "继续交谈".to_string(),
|
||||
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
|
||||
scope: "npc".to_string(),
|
||||
interaction: Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: "npc_camp_firekeeper".to_string(),
|
||||
action: "chat".to_string(),
|
||||
quest_id: None,
|
||||
}),
|
||||
payload: Some(json!({ "note": "server-runtime-test" })),
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}],
|
||||
toast: None,
|
||||
battle: None,
|
||||
},
|
||||
patches: vec![RuntimeStoryPatch::StatusChanged {
|
||||
in_battle: false,
|
||||
npc_interaction_active: true,
|
||||
current_npc_battle_mode: None,
|
||||
current_npc_battle_outcome: None,
|
||||
}],
|
||||
snapshot: RuntimeStorySnapshotPayload {
|
||||
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
||||
current_story: Some(json!({
|
||||
"text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。"
|
||||
})),
|
||||
},
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["sessionId"], json!("runtime-main"));
|
||||
assert_eq!(payload["serverVersion"], json!(8));
|
||||
assert_eq!(payload["viewModel"]["player"]["maxHp"], json!(40));
|
||||
assert_eq!(
|
||||
payload["viewModel"]["availableOptions"][0]["interaction"]["npcId"],
|
||||
json!("npc_camp_firekeeper")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["presentation"]["storyText"],
|
||||
json!("守火人抬眼看了你一瞬,示意你把想问的话继续说完。")
|
||||
);
|
||||
assert_eq!(payload["patches"][0]["type"], json!("status_changed"));
|
||||
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user