430 lines
16 KiB
Rust
430 lines
16 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeStorySnapshotPayload {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub saved_at: Option<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 RuntimeStoryChoiceAction {
|
|
#[serde(rename = "type")]
|
|
pub action_type: String,
|
|
pub function_id: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub target_id: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub payload: Option<Value>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeStoryActionRequest {
|
|
pub session_id: String,
|
|
#[serde(default)]
|
|
pub client_version: Option<u32>,
|
|
pub action: RuntimeStoryChoiceAction,
|
|
#[serde(default)]
|
|
pub snapshot: Option<RuntimeStorySnapshotPayload>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeStoryAiRequestOptions {
|
|
#[serde(default)]
|
|
pub available_options: Vec<Value>,
|
|
#[serde(default)]
|
|
pub option_catalog: Vec<Value>,
|
|
}
|
|
|
|
impl Default for RuntimeStoryAiRequestOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
available_options: Vec::new(),
|
|
option_catalog: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeStoryAiRequest {
|
|
pub world_type: String,
|
|
pub character: Value,
|
|
#[serde(default)]
|
|
pub monsters: Vec<Value>,
|
|
#[serde(default)]
|
|
pub history: Vec<Value>,
|
|
#[serde(default)]
|
|
pub choice: String,
|
|
pub context: Value,
|
|
#[serde(default)]
|
|
pub request_options: RuntimeStoryAiRequestOptions,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeStoryAiResponse {
|
|
pub story_text: String,
|
|
pub options: Vec<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub encounter: Option<Value>,
|
|
}
|
|
|
|
#[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>,
|
|
},
|
|
}
|
|
|
|
#[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_accepts_missing_saved_at() {
|
|
let payload: RuntimeStoryStateResolveRequest = serde_json::from_value(json!({
|
|
"sessionId": "runtime-main",
|
|
"clientVersion": 7,
|
|
"snapshot": {
|
|
"bottomTab": "adventure",
|
|
"gameState": { "runtimeSessionId": "runtime-main" },
|
|
"currentStory": { "text": "营地里的火光还没有熄灭。" }
|
|
}
|
|
}))
|
|
.expect("payload should deserialize");
|
|
|
|
assert_eq!(payload.session_id, "runtime-main");
|
|
assert_eq!(payload.client_version, Some(7));
|
|
assert_eq!(
|
|
payload.snapshot.expect("snapshot should exist").saved_at,
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_story_action_request_uses_camel_case_fields() {
|
|
let payload = serde_json::to_value(RuntimeStoryActionRequest {
|
|
session_id: "runtime-main".to_string(),
|
|
client_version: Some(8),
|
|
action: RuntimeStoryChoiceAction {
|
|
action_type: "story_choice".to_string(),
|
|
function_id: "npc_chat".to_string(),
|
|
target_id: Some("npc_camp_firekeeper".to_string()),
|
|
payload: Some(json!({ "optionText": "继续交谈" })),
|
|
},
|
|
snapshot: Some(RuntimeStorySnapshotPayload {
|
|
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
|
|
bottom_tab: "adventure".to_string(),
|
|
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
|
current_story: None,
|
|
}),
|
|
})
|
|
.expect("payload should serialize");
|
|
|
|
assert_eq!(payload["sessionId"], json!("runtime-main"));
|
|
assert_eq!(payload["clientVersion"], json!(8));
|
|
assert_eq!(payload["action"]["type"], json!("story_choice"));
|
|
assert_eq!(payload["action"]["functionId"], json!("npc_chat"));
|
|
assert_eq!(payload["action"]["targetId"], json!("npc_camp_firekeeper"));
|
|
assert_eq!(
|
|
payload["snapshot"]["savedAt"],
|
|
json!("2026-04-22T12:00:00.000Z")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_story_ai_request_defaults_optional_arrays() {
|
|
let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({
|
|
"worldType": "martial",
|
|
"character": { "name": "林迟" },
|
|
"context": { "scene": "camp" }
|
|
}))
|
|
.expect("payload should deserialize");
|
|
|
|
assert_eq!(payload.world_type, "martial");
|
|
assert!(payload.monsters.is_empty());
|
|
assert!(payload.history.is_empty());
|
|
assert!(payload.request_options.available_options.is_empty());
|
|
}
|
|
|
|
#[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: Some("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"));
|
|
}
|
|
}
|