652 lines
24 KiB
Rust
652 lines
24 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[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 {
|
|
#[serde(default)]
|
|
pub session_id: Option<String>,
|
|
#[serde(default)]
|
|
pub client_version: Option<u32>,
|
|
#[serde(default)]
|
|
pub world_type: String,
|
|
#[serde(default)]
|
|
pub character: Value,
|
|
#[serde(default)]
|
|
pub monsters: Vec<Value>,
|
|
#[serde(default)]
|
|
pub history: Vec<Value>,
|
|
#[serde(default)]
|
|
pub choice: String,
|
|
#[serde(default)]
|
|
pub context: Value,
|
|
#[serde(default)]
|
|
pub request_options: RuntimeStoryAiRequestOptions,
|
|
#[serde(default)]
|
|
pub last_function_id: Option<String>,
|
|
#[serde(default)]
|
|
pub observe_signs_requested: bool,
|
|
#[serde(default)]
|
|
pub recent_action_result: Option<String>,
|
|
}
|
|
|
|
#[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 RuntimeStoryInventoryActionView {
|
|
pub function_id: String,
|
|
pub action_text: 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 RuntimeStoryInventoryItemActionsView {
|
|
#[serde(rename = "use")]
|
|
pub use_item: RuntimeStoryInventoryActionView,
|
|
pub equip: RuntimeStoryInventoryActionView,
|
|
pub dismantle: RuntimeStoryInventoryActionView,
|
|
pub reforge: RuntimeStoryInventoryActionView,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeStoryInventoryItemView {
|
|
pub item: Value,
|
|
pub actions: RuntimeStoryInventoryItemActionsView,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeStoryEquipmentSlotView {
|
|
pub slot_id: String,
|
|
pub label: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub item: Option<Value>,
|
|
pub unequip: RuntimeStoryInventoryActionView,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeStoryForgeRequirementView {
|
|
pub id: String,
|
|
pub label: String,
|
|
pub quantity: i32,
|
|
pub owned: i32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeStoryForgeRecipeView {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub description: String,
|
|
pub result_label: String,
|
|
pub currency_cost: i32,
|
|
pub currency_text: String,
|
|
pub requirements: Vec<RuntimeStoryForgeRequirementView>,
|
|
pub can_craft: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub disabled_reason: Option<String>,
|
|
pub action: RuntimeStoryInventoryActionView,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeStoryInventoryViewModel {
|
|
pub player_currency: i32,
|
|
pub currency_text: String,
|
|
pub in_battle: bool,
|
|
pub backpack_items: Vec<RuntimeStoryInventoryItemView>,
|
|
pub equipment_slots: Vec<RuntimeStoryEquipmentSlotView>,
|
|
pub forge_recipes: Vec<RuntimeStoryForgeRecipeView>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeNpcTradeItemView {
|
|
pub item_id: String,
|
|
pub item: Value,
|
|
pub mode: String,
|
|
pub unit_price: i32,
|
|
pub max_quantity: i32,
|
|
pub can_submit: 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 RuntimeNpcGiftItemView {
|
|
pub item_id: String,
|
|
pub item: Value,
|
|
pub affinity_gain: i32,
|
|
pub can_submit: 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 RuntimeNpcTradeView {
|
|
pub buy_items: Vec<RuntimeNpcTradeItemView>,
|
|
pub sell_items: Vec<RuntimeNpcTradeItemView>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeNpcGiftView {
|
|
pub items: Vec<RuntimeNpcGiftItemView>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeNpcInteractionView {
|
|
pub npc_id: String,
|
|
pub npc_name: String,
|
|
pub player_currency: i32,
|
|
pub currency_name: String,
|
|
pub trade: RuntimeNpcTradeView,
|
|
pub gift: RuntimeNpcGiftView,
|
|
}
|
|
|
|
#[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 inventory: RuntimeStoryInventoryViewModel,
|
|
pub available_options: Vec<RuntimeStoryOptionView>,
|
|
pub status: RuntimeStoryStatusViewModel,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub npc_interaction: Option<RuntimeNpcInteractionView>,
|
|
}
|
|
|
|
#[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>,
|
|
},
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[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": "继续交谈" })),
|
|
},
|
|
})
|
|
.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"));
|
|
}
|
|
|
|
#[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_ai_request_accepts_session_only_payload() {
|
|
let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({
|
|
"sessionId": "runtime-main",
|
|
"clientVersion": 3,
|
|
"choice": "继续向前",
|
|
"lastFunctionId": "idle_explore_forward",
|
|
"requestOptions": {
|
|
"optionCatalog": [{
|
|
"functionId": "idle_observe_signs",
|
|
"actionText": "观察周围迹象"
|
|
}]
|
|
}
|
|
}))
|
|
.expect("payload should deserialize");
|
|
|
|
assert_eq!(payload.session_id.as_deref(), Some("runtime-main"));
|
|
assert_eq!(payload.client_version, Some(3));
|
|
assert_eq!(payload.world_type, "");
|
|
assert_eq!(payload.context, Value::Null);
|
|
assert_eq!(
|
|
payload.last_function_id.as_deref(),
|
|
Some("idle_explore_forward")
|
|
);
|
|
assert_eq!(payload.request_options.option_catalog.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_story_presentation_uses_camel_case_fields() {
|
|
let 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,
|
|
}],
|
|
inventory: RuntimeStoryInventoryViewModel {
|
|
player_currency: 80,
|
|
currency_text: "80 铜钱".to_string(),
|
|
in_battle: false,
|
|
backpack_items: vec![RuntimeStoryInventoryItemView {
|
|
item: json!({
|
|
"id": "potion-1",
|
|
"name": "疗伤药",
|
|
"category": "消耗品",
|
|
"quantity": 2,
|
|
"rarity": "common",
|
|
"tags": ["healing"]
|
|
}),
|
|
actions: RuntimeStoryInventoryItemActionsView {
|
|
use_item: RuntimeStoryInventoryActionView {
|
|
function_id: "inventory_use".to_string(),
|
|
action_text: "使用疗伤药".to_string(),
|
|
payload: Some(json!({ "itemId": "potion-1" })),
|
|
enabled: true,
|
|
reason: None,
|
|
},
|
|
equip: RuntimeStoryInventoryActionView {
|
|
function_id: "equipment_equip".to_string(),
|
|
action_text: "装备疗伤药".to_string(),
|
|
payload: Some(json!({ "itemId": "potion-1" })),
|
|
enabled: false,
|
|
reason: Some("该物品不能装备。".to_string()),
|
|
},
|
|
dismantle: RuntimeStoryInventoryActionView {
|
|
function_id: "forge_dismantle".to_string(),
|
|
action_text: "拆解疗伤药".to_string(),
|
|
payload: Some(json!({ "itemId": "potion-1" })),
|
|
enabled: false,
|
|
reason: Some("该物品不能拆解。".to_string()),
|
|
},
|
|
reforge: RuntimeStoryInventoryActionView {
|
|
function_id: "forge_reforge".to_string(),
|
|
action_text: "重铸疗伤药".to_string(),
|
|
payload: Some(json!({ "itemId": "potion-1" })),
|
|
enabled: false,
|
|
reason: Some("该物品不能重铸。".to_string()),
|
|
},
|
|
},
|
|
}],
|
|
equipment_slots: vec![RuntimeStoryEquipmentSlotView {
|
|
slot_id: "weapon".to_string(),
|
|
label: "武器".to_string(),
|
|
item: None,
|
|
unequip: RuntimeStoryInventoryActionView {
|
|
function_id: "equipment_unequip".to_string(),
|
|
action_text: "卸下武器".to_string(),
|
|
payload: Some(json!({ "slotId": "weapon" })),
|
|
enabled: false,
|
|
reason: Some("武器位当前没有装备。".to_string()),
|
|
},
|
|
}],
|
|
forge_recipes: vec![RuntimeStoryForgeRecipeView {
|
|
id: "synthesis-refined-ingot".to_string(),
|
|
name: "压炼锭材".to_string(),
|
|
kind: "synthesis".to_string(),
|
|
description: "把零散残片和基础材料压成稳定可用的金属锭材。".to_string(),
|
|
result_label: "精炼锭材".to_string(),
|
|
currency_cost: 18,
|
|
currency_text: "18 铜钱".to_string(),
|
|
requirements: vec![RuntimeStoryForgeRequirementView {
|
|
id: "material:any".to_string(),
|
|
label: "任意材料".to_string(),
|
|
quantity: 3,
|
|
owned: 0,
|
|
}],
|
|
can_craft: false,
|
|
disabled_reason: Some("材料不足。".to_string()),
|
|
action: RuntimeStoryInventoryActionView {
|
|
function_id: "forge_craft".to_string(),
|
|
action_text: "制作精炼锭材".to_string(),
|
|
payload: Some(json!({ "recipeId": "synthesis-refined-ingot" })),
|
|
enabled: false,
|
|
reason: Some("材料不足。".to_string()),
|
|
},
|
|
}],
|
|
},
|
|
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,
|
|
},
|
|
npc_interaction: Some(RuntimeNpcInteractionView {
|
|
npc_id: "npc_camp_firekeeper".to_string(),
|
|
npc_name: "守火人".to_string(),
|
|
player_currency: 80,
|
|
currency_name: "铜钱".to_string(),
|
|
trade: RuntimeNpcTradeView {
|
|
buy_items: vec![RuntimeNpcTradeItemView {
|
|
item_id: "npc-potion".to_string(),
|
|
item: json!({
|
|
"id": "npc-potion",
|
|
"name": "疗伤药",
|
|
"category": "消耗品",
|
|
"quantity": 2,
|
|
"rarity": "common",
|
|
"tags": ["healing"]
|
|
}),
|
|
mode: "buy".to_string(),
|
|
unit_price: 20,
|
|
max_quantity: 2,
|
|
can_submit: true,
|
|
reason: None,
|
|
}],
|
|
sell_items: Vec::new(),
|
|
},
|
|
gift: RuntimeNpcGiftView {
|
|
items: vec![RuntimeNpcGiftItemView {
|
|
item_id: "potion-1".to_string(),
|
|
item: json!({
|
|
"id": "potion-1",
|
|
"name": "疗伤药",
|
|
"category": "消耗品",
|
|
"quantity": 2,
|
|
"rarity": "common",
|
|
"tags": ["healing"]
|
|
}),
|
|
affinity_gain: 10,
|
|
can_submit: true,
|
|
reason: None,
|
|
}],
|
|
},
|
|
}),
|
|
};
|
|
let 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,
|
|
};
|
|
let patches = vec![RuntimeStoryPatch::StatusChanged {
|
|
in_battle: false,
|
|
npc_interaction_active: true,
|
|
current_npc_battle_mode: None,
|
|
current_npc_battle_outcome: None,
|
|
}];
|
|
let payload = serde_json::to_value(json!({
|
|
"viewModel": view_model,
|
|
"presentation": presentation,
|
|
"patches": patches
|
|
}))
|
|
.expect("payload should serialize");
|
|
|
|
assert_eq!(payload["viewModel"]["player"]["maxHp"], json!(40));
|
|
assert_eq!(
|
|
payload["viewModel"]["availableOptions"][0]["interaction"]["npcId"],
|
|
json!("npc_camp_firekeeper")
|
|
);
|
|
assert_eq!(
|
|
payload["viewModel"]["inventory"]["backpackItems"][0]["actions"]["use"]["functionId"],
|
|
json!("inventory_use")
|
|
);
|
|
assert_eq!(
|
|
payload["viewModel"]["inventory"]["forgeRecipes"][0]["canCraft"],
|
|
json!(false)
|
|
);
|
|
assert_eq!(
|
|
payload["presentation"]["storyText"],
|
|
json!("守火人抬眼看了你一瞬,示意你把想问的话继续说完。")
|
|
);
|
|
assert_eq!(payload["patches"][0]["type"], json!("status_changed"));
|
|
}
|
|
}
|