191 lines
7.5 KiB
Rust
191 lines
7.5 KiB
Rust
use serde_json::{Value, to_value};
|
|
|
|
use shared_contracts::{
|
|
runtime_story::RuntimeStoryOptionView,
|
|
story::{
|
|
StoryEventPayload, StoryRuntimeActorProjection, StoryRuntimeInventoryProjection,
|
|
StoryRuntimeOptionProjection, StoryRuntimeProjectionResponse, StoryRuntimeStatusProjection,
|
|
StorySessionPayload,
|
|
},
|
|
};
|
|
|
|
use crate::{
|
|
current_encounter_id, read_bool_field, read_i32_field, read_optional_string_field,
|
|
view_model::build_runtime_story_inventory,
|
|
};
|
|
|
|
pub struct StoryRuntimeProjectionSource {
|
|
pub story_session: StorySessionPayload,
|
|
pub story_events: Vec<StoryEventPayload>,
|
|
pub game_state: Value,
|
|
pub options: Vec<RuntimeStoryOptionView>,
|
|
pub server_version: u32,
|
|
pub current_narrative_text: Option<String>,
|
|
pub action_result_text: Option<String>,
|
|
pub toast: Option<String>,
|
|
}
|
|
|
|
/// 将领域快照折成前端可直接消费的新 story runtime 投影。
|
|
pub fn build_story_runtime_projection(
|
|
source: StoryRuntimeProjectionSource,
|
|
) -> StoryRuntimeProjectionResponse {
|
|
let inventory = build_runtime_story_inventory(&source.game_state);
|
|
|
|
StoryRuntimeProjectionResponse {
|
|
story_session: source.story_session,
|
|
story_events: source.story_events,
|
|
server_version: source.server_version,
|
|
game_state: source.game_state.clone(),
|
|
actor: StoryRuntimeActorProjection {
|
|
hp: read_i32_field(&source.game_state, "playerHp").unwrap_or(0),
|
|
max_hp: read_i32_field(&source.game_state, "playerMaxHp").unwrap_or(1),
|
|
mana: read_i32_field(&source.game_state, "playerMana").unwrap_or(0),
|
|
max_mana: read_i32_field(&source.game_state, "playerMaxMana").unwrap_or(1),
|
|
currency: inventory.player_currency,
|
|
currency_text: inventory.currency_text.clone(),
|
|
},
|
|
inventory: StoryRuntimeInventoryProjection {
|
|
backpack_items: inventory
|
|
.backpack_items
|
|
.into_iter()
|
|
.map(|item| to_value(item).expect("runtime inventory item should serialize"))
|
|
.collect(),
|
|
equipment_slots: inventory
|
|
.equipment_slots
|
|
.into_iter()
|
|
.map(|slot| to_value(slot).expect("runtime equipment slot should serialize"))
|
|
.collect(),
|
|
forge_recipes: inventory
|
|
.forge_recipes
|
|
.into_iter()
|
|
.map(|recipe| to_value(recipe).expect("runtime forge recipe should serialize"))
|
|
.collect(),
|
|
},
|
|
options: source
|
|
.options
|
|
.into_iter()
|
|
.map(build_story_runtime_option_projection)
|
|
.collect(),
|
|
status: StoryRuntimeStatusProjection {
|
|
in_battle: read_bool_field(&source.game_state, "inBattle").unwrap_or(false),
|
|
npc_interaction_active: read_bool_field(&source.game_state, "npcInteractionActive")
|
|
.unwrap_or(false),
|
|
current_encounter_id: current_encounter_id(&source.game_state),
|
|
current_npc_battle_mode: read_optional_string_field(
|
|
&source.game_state,
|
|
"currentNpcBattleMode",
|
|
),
|
|
current_npc_battle_outcome: read_optional_string_field(
|
|
&source.game_state,
|
|
"currentNpcBattleOutcome",
|
|
),
|
|
},
|
|
current_narrative_text: source.current_narrative_text,
|
|
action_result_text: source.action_result_text,
|
|
toast: source.toast,
|
|
}
|
|
}
|
|
|
|
fn build_story_runtime_option_projection(
|
|
option: RuntimeStoryOptionView,
|
|
) -> StoryRuntimeOptionProjection {
|
|
let disabled = option.disabled.unwrap_or(false);
|
|
|
|
StoryRuntimeOptionProjection {
|
|
function_id: option.function_id,
|
|
action_text: option.action_text,
|
|
detail_text: option.detail_text,
|
|
scope: option.scope,
|
|
payload: option.payload,
|
|
enabled: !disabled,
|
|
reason: option.reason,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use super::*;
|
|
|
|
fn story_session() -> StorySessionPayload {
|
|
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("npc_chat".to_string()),
|
|
status: "active".to_string(),
|
|
version: 3,
|
|
created_at: "1.000000Z".to_string(),
|
|
updated_at: "3.000000Z".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn projection_builds_frontend_ready_story_runtime_shape() {
|
|
let projection = build_story_runtime_projection(StoryRuntimeProjectionSource {
|
|
story_session: story_session(),
|
|
story_events: vec![StoryEventPayload {
|
|
event_id: "storyevt_1".to_string(),
|
|
story_session_id: "storysess_1".to_string(),
|
|
event_kind: "story_continued".to_string(),
|
|
narrative_text: "篝火仍然亮着。".to_string(),
|
|
choice_function_id: Some("npc_chat".to_string()),
|
|
created_at: "3.000000Z".to_string(),
|
|
}],
|
|
game_state: json!({
|
|
"worldType": "WUXIA",
|
|
"playerCharacter": { "id": "hero-1", "name": "沈砺" },
|
|
"playerHp": 28,
|
|
"playerMaxHp": 40,
|
|
"playerMana": 12,
|
|
"playerMaxMana": 20,
|
|
"playerCurrency": 80,
|
|
"playerInventory": [{
|
|
"id": "potion-1",
|
|
"category": "消耗品",
|
|
"name": "疗伤药",
|
|
"quantity": 2,
|
|
"rarity": "common",
|
|
"tags": ["healing"]
|
|
}],
|
|
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
|
|
"currentEncounter": { "id": "npc_firekeeper", "npcName": "守火人" },
|
|
"inBattle": false,
|
|
"npcInteractionActive": true
|
|
}),
|
|
options: vec![RuntimeStoryOptionView {
|
|
function_id: "npc_chat".to_string(),
|
|
action_text: "继续交谈".to_string(),
|
|
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
|
|
scope: "npc".to_string(),
|
|
interaction: None,
|
|
payload: Some(json!({ "npcId": "npc_firekeeper" })),
|
|
disabled: None,
|
|
reason: None,
|
|
}],
|
|
server_version: 3,
|
|
current_narrative_text: Some("守火人示意你继续说。".to_string()),
|
|
action_result_text: None,
|
|
toast: Some("关系有所变化。".to_string()),
|
|
});
|
|
|
|
assert_eq!(projection.story_session.story_session_id, "storysess_1");
|
|
assert_eq!(projection.game_state["worldType"], json!("WUXIA"));
|
|
assert_eq!(projection.actor.hp, 28);
|
|
assert_eq!(projection.actor.currency_text, "80 铜钱");
|
|
assert_eq!(projection.inventory.backpack_items.len(), 1);
|
|
assert_eq!(projection.options[0].function_id, "npc_chat");
|
|
assert!(projection.options[0].enabled);
|
|
assert_eq!(
|
|
projection.status.current_encounter_id.as_deref(),
|
|
Some("npc_firekeeper")
|
|
);
|
|
assert_eq!(projection.toast.as_deref(), Some("关系有所变化。"));
|
|
}
|
|
}
|