//! 剧情应用服务与读模型映射。 //! //! 应用层负责把命令变成快照、事件和前端可消费记录;它不直接调用模型、HTTP、 //! SpacetimeDB 或旧 Node 兼容服务。 use crate::commands::{StoryContinueInput, StorySessionInput, normalize_optional_value}; use crate::domain::{INITIAL_STORY_SESSION_VERSION, StorySessionSnapshot, StorySessionStatus}; use crate::errors::StorySessionFieldError; use crate::events::{StoryEventKind, StoryEventSnapshot}; use module_combat::{BattleStateSnapshot, CombatOutcome}; use module_inventory::{ GrantInventoryItemInput, InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, InventoryItemSourceKind, InventoryMutation, InventoryMutationInput, generate_inventory_mutation_id, generate_inventory_slot_id, }; use module_progression::{ ChapterProgressionLedgerInput, PlayerProgressionGrantInput, PlayerProgressionGrantSource, }; use module_quest::{ QuestRecordSnapshot, QuestRewardEquipmentSlot, QuestRewardItem, QuestRewardItemRarity, }; use module_runtime_item::{ RuntimeItemEquipmentSlot, RuntimeItemRewardItemRarity, RuntimeItemRewardItemSnapshot, TreasureRecordSnapshot, build_inventory_item_snapshot_from_reward_item, }; use serde::{Deserialize, Serialize}; use shared_kernel::format_timestamp_micros; #[cfg(feature = "spacetime-types")] use spacetimedb::SpacetimeType; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct StorySessionProcedureResult { pub ok: bool, pub session: Option, pub event: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct StorySessionStateProcedureResult { pub ok: bool, pub session: Option, pub events: Vec, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StorySessionRecord { pub story_session_id: String, pub runtime_session_id: String, pub actor_user_id: String, pub world_profile_id: String, pub initial_prompt: String, pub opening_summary: Option, pub latest_narrative_text: String, pub latest_choice_function_id: Option, pub status: String, pub version: u32, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoryEventRecord { pub event_id: String, pub story_session_id: String, pub event_kind: String, pub narrative_text: String, pub choice_function_id: Option, pub created_at: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StorySessionResultRecord { pub session: StorySessionRecord, pub event: StoryEventRecord, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StorySessionStateRecord { pub session: StorySessionRecord, pub events: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RpgGameplaySettlementPlan { /// 背包写入计划。adapter 只负责读取当前槽位并执行 mutation,不再拼奖励物品字段。 pub inventory_mutations: Vec, /// 玩家经验入账计划。章节账本需要依赖执行后的等级结果,所以单独保留。 pub progression_grant: Option, /// 章节实际收益账本计划。若章节预算尚未初始化,adapter 会跳过而不阻断主链。 pub chapter_ledger: Option, } pub fn build_story_session_snapshot(input: StorySessionInput) -> StorySessionSnapshot { StorySessionSnapshot { story_session_id: input.story_session_id, runtime_session_id: input.runtime_session_id, actor_user_id: input.actor_user_id, world_profile_id: input.world_profile_id, initial_prompt: input.initial_prompt, opening_summary: normalize_optional_value(input.opening_summary), latest_narrative_text: String::new(), latest_choice_function_id: None, status: StorySessionStatus::Active, version: INITIAL_STORY_SESSION_VERSION, created_at_micros: input.created_at_micros, updated_at_micros: input.created_at_micros, } } pub fn apply_story_continue( current: StorySessionSnapshot, input: StoryContinueInput, ) -> Result<(StorySessionSnapshot, StoryEventSnapshot), StorySessionFieldError> { crate::commands::validate_story_continue_input(&input)?; if current.version == 0 { return Err(StorySessionFieldError::InvalidVersion); } let event = StoryEventSnapshot { event_id: input.event_id, story_session_id: current.story_session_id.clone(), event_kind: StoryEventKind::StoryContinued, narrative_text: input.narrative_text.clone(), choice_function_id: normalize_optional_value(input.choice_function_id), created_at_micros: input.updated_at_micros, }; let next = StorySessionSnapshot { latest_narrative_text: input.narrative_text, latest_choice_function_id: event.choice_function_id.clone(), version: current.version + 1, updated_at_micros: input.updated_at_micros, ..current }; Ok((next, event)) } pub fn build_story_session_record(snapshot: StorySessionSnapshot) -> StorySessionRecord { StorySessionRecord { story_session_id: snapshot.story_session_id, runtime_session_id: snapshot.runtime_session_id, actor_user_id: snapshot.actor_user_id, world_profile_id: snapshot.world_profile_id, initial_prompt: snapshot.initial_prompt, opening_summary: snapshot.opening_summary, latest_narrative_text: snapshot.latest_narrative_text, latest_choice_function_id: snapshot.latest_choice_function_id, status: snapshot.status.as_str().to_string(), version: snapshot.version, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), } } pub fn build_story_event_record(snapshot: StoryEventSnapshot) -> StoryEventRecord { StoryEventRecord { event_id: snapshot.event_id, story_session_id: snapshot.story_session_id, event_kind: snapshot.event_kind.as_str().to_string(), narrative_text: snapshot.narrative_text, choice_function_id: snapshot.choice_function_id, created_at: format_timestamp_micros(snapshot.created_at_micros), } } pub fn build_story_session_result_record( session: StorySessionSnapshot, event: StoryEventSnapshot, ) -> StorySessionResultRecord { StorySessionResultRecord { session: build_story_session_record(session), event: build_story_event_record(event), } } pub fn build_story_session_state_record( session: StorySessionSnapshot, events: Vec, ) -> StorySessionStateRecord { StorySessionStateRecord { session: build_story_session_record(session), events: events .into_iter() .map(build_story_event_record) .collect::>(), } } pub fn build_combat_victory_settlement_plan( snapshot: &BattleStateSnapshot, ) -> RpgGameplaySettlementPlan { // 非胜利结果不触发战利品、敌对经验或章节 hostile 账本,避免逃脱/切磋混入击杀收益。 if snapshot.last_outcome != CombatOutcome::Victory { return empty_settlement_plan(); } let inventory_mutations = snapshot .reward_items .iter() .cloned() .enumerate() .map(|(index, reward_item)| { let seed = build_reward_seed(snapshot.updated_at_micros, index); InventoryMutationInput { mutation_id: generate_inventory_mutation_id(seed), runtime_session_id: snapshot.runtime_session_id.clone(), story_session_id: Some(snapshot.story_session_id.clone()), actor_user_id: snapshot.actor_user_id.clone(), mutation: InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id: generate_inventory_slot_id(seed), item: build_inventory_item_snapshot_from_battle_reward_item( &snapshot.battle_state_id, reward_item, ), }), updated_at_micros: snapshot.updated_at_micros, } }) .collect::>(); let progression_grant = (snapshot.experience_reward > 0).then(|| PlayerProgressionGrantInput { user_id: snapshot.actor_user_id.clone(), amount: snapshot.experience_reward, source: PlayerProgressionGrantSource::HostileNpc, updated_at_micros: snapshot.updated_at_micros, }); let chapter_ledger = progression_grant .as_ref() .and_then(|_| match snapshot.chapter_id.as_deref() { Some(chapter_id) if !chapter_id.trim().is_empty() => { Some(ChapterProgressionLedgerInput { user_id: snapshot.actor_user_id.clone(), chapter_id: chapter_id.trim().to_string(), granted_quest_xp: 0, granted_hostile_xp: snapshot.experience_reward, hostile_defeat_increment: 1, level_at_exit: None, updated_at_micros: snapshot.updated_at_micros, }) } _ => None, }); RpgGameplaySettlementPlan { inventory_mutations, progression_grant, chapter_ledger, } } /// 任务交付后只生成结算计划,不在领域层直接写背包、成长或章节表。 pub fn build_quest_turn_in_settlement_plan( snapshot: &QuestRecordSnapshot, ) -> RpgGameplaySettlementPlan { let inventory_mutations = snapshot .reward .items .iter() .cloned() .enumerate() .map(|(index, reward_item)| { let seed = build_reward_seed(snapshot.updated_at_micros, index); InventoryMutationInput { mutation_id: generate_inventory_mutation_id(seed), runtime_session_id: snapshot.runtime_session_id.clone(), story_session_id: snapshot.story_session_id.clone(), actor_user_id: snapshot.actor_user_id.clone(), mutation: InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id: generate_inventory_slot_id(seed), item: build_inventory_item_snapshot_from_quest_reward_item( &snapshot.quest_id, reward_item, ), }), updated_at_micros: snapshot.updated_at_micros, } }) .collect::>(); let reward_experience = snapshot.reward.experience.unwrap_or(0); let progression_grant = (reward_experience > 0).then(|| PlayerProgressionGrantInput { user_id: snapshot.actor_user_id.clone(), amount: reward_experience, source: PlayerProgressionGrantSource::Quest, updated_at_micros: snapshot.updated_at_micros, }); let chapter_ledger = progression_grant .as_ref() .and_then(|_| match snapshot.chapter_id.as_deref() { Some(chapter_id) if !chapter_id.trim().is_empty() => { Some(ChapterProgressionLedgerInput { user_id: snapshot.actor_user_id.clone(), chapter_id: chapter_id.trim().to_string(), granted_quest_xp: reward_experience, granted_hostile_xp: 0, hostile_defeat_increment: 0, level_at_exit: None, updated_at_micros: snapshot.updated_at_micros, }) } _ => None, }); RpgGameplaySettlementPlan { inventory_mutations, progression_grant, chapter_ledger, } } /// 宝箱记录由 `module-runtime-item` 建模,这里只把奖励转成 story gameplay 的背包写入计划。 pub fn build_treasure_settlement_plan( snapshot: &TreasureRecordSnapshot, ) -> Result { let inventory_mutations = snapshot .reward_items .iter() .cloned() .enumerate() .map( |(index, reward_item)| -> Result { Ok(InventoryMutationInput { mutation_id: build_treasure_inventory_mutation_id( &snapshot.treasure_record_id, index, ), runtime_session_id: snapshot.runtime_session_id.clone(), story_session_id: Some(snapshot.story_session_id.clone()), actor_user_id: snapshot.actor_user_id.clone(), mutation: InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id: build_treasure_inventory_slot_id( &snapshot.treasure_record_id, index, ), item: build_inventory_item_snapshot_from_reward_item( &snapshot.treasure_record_id, reward_item, ) .map_err(|_| StorySessionFieldError::InvalidGameplayReward)?, }), updated_at_micros: snapshot.updated_at_micros, }) }, ) .collect::, _>>()?; Ok(RpgGameplaySettlementPlan { inventory_mutations, progression_grant: None, chapter_ledger: None, }) } fn empty_settlement_plan() -> RpgGameplaySettlementPlan { RpgGameplaySettlementPlan { inventory_mutations: Vec::new(), progression_grant: None, chapter_ledger: None, } } fn build_inventory_item_snapshot_from_battle_reward_item( battle_state_id: &str, reward_item: RuntimeItemRewardItemSnapshot, ) -> InventoryItemSnapshot { InventoryItemSnapshot { item_id: reward_item.item_id, category: reward_item.category, name: reward_item.item_name, description: reward_item.description, quantity: reward_item.quantity, rarity: map_runtime_reward_item_rarity(reward_item.rarity), tags: reward_item.tags, stackable: reward_item.stackable, stack_key: reward_item.stack_key, equipment_slot_id: reward_item .equipment_slot_id .map(map_runtime_reward_equipment_slot), source_kind: InventoryItemSourceKind::CombatDrop, source_reference_id: Some(battle_state_id.to_string()), } } fn build_inventory_item_snapshot_from_quest_reward_item( quest_id: &str, reward_item: QuestRewardItem, ) -> InventoryItemSnapshot { InventoryItemSnapshot { item_id: reward_item.item_id, category: reward_item.category, name: reward_item.name, description: reward_item.description, quantity: reward_item.quantity, rarity: map_quest_reward_item_rarity(reward_item.rarity), tags: reward_item.tags, stackable: reward_item.stackable, stack_key: reward_item.stack_key, equipment_slot_id: reward_item .equipment_slot_id .map(map_quest_reward_equipment_slot), source_kind: InventoryItemSourceKind::QuestReward, source_reference_id: Some(quest_id.to_string()), } } fn map_quest_reward_item_rarity(rarity: QuestRewardItemRarity) -> InventoryItemRarity { match rarity { QuestRewardItemRarity::Common => InventoryItemRarity::Common, QuestRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, QuestRewardItemRarity::Rare => InventoryItemRarity::Rare, QuestRewardItemRarity::Epic => InventoryItemRarity::Epic, QuestRewardItemRarity::Legendary => InventoryItemRarity::Legendary, } } fn map_runtime_reward_item_rarity(rarity: RuntimeItemRewardItemRarity) -> InventoryItemRarity { match rarity { RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common, RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon, RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare, RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic, RuntimeItemRewardItemRarity::Legendary => InventoryItemRarity::Legendary, } } fn map_quest_reward_equipment_slot(slot: QuestRewardEquipmentSlot) -> InventoryEquipmentSlot { match slot { QuestRewardEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, QuestRewardEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, QuestRewardEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, } } fn map_runtime_reward_equipment_slot(slot: RuntimeItemEquipmentSlot) -> InventoryEquipmentSlot { match slot { RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, } } fn build_reward_seed(updated_at_micros: i64, index: usize) -> i64 { updated_at_micros.saturating_add(index as i64 + 1) } fn build_treasure_inventory_slot_id(treasure_record_id: &str, reward_index: usize) -> String { format!("invslot_{}_{}", treasure_record_id, reward_index) } fn build_treasure_inventory_mutation_id(treasure_record_id: &str, reward_index: usize) -> String { format!("invmut_{}_{}", treasure_record_id, reward_index) }