Files
Genarrative/server-rs/crates/module-runtime-story/src/projection.rs

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("关系有所变化。"));
}
}