Files
Genarrative/server-rs/crates/shared-contracts/src/runtime_story.rs
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

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"));
}
}