推进 server-rs DDD 分层与新接口接线
This commit is contained in:
188
server-rs/crates/module-runtime-story/src/projection.rs
Normal file
188
server-rs/crates/module-runtime-story/src/projection.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
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,
|
||||
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.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("关系有所变化。"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user