Files
Genarrative/server-rs/crates/module-story/src/application.rs

464 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 剧情应用服务与读模型映射。
//!
//! 应用层负责把命令变成快照、事件和前端可消费记录它不直接调用模型、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<StorySessionSnapshot>,
pub event: Option<StoryEventSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorySessionStateProcedureResult {
pub ok: bool,
pub session: Option<StorySessionSnapshot>,
pub events: Vec<StoryEventSnapshot>,
pub error_message: Option<String>,
}
#[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<String>,
pub latest_narrative_text: String,
pub latest_choice_function_id: Option<String>,
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<String>,
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<StoryEventRecord>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RpgGameplaySettlementPlan {
/// 背包写入计划。adapter 只负责读取当前槽位并执行 mutation不再拼奖励物品字段。
pub inventory_mutations: Vec<InventoryMutationInput>,
/// 玩家经验入账计划。章节账本需要依赖执行后的等级结果,所以单独保留。
pub progression_grant: Option<PlayerProgressionGrantInput>,
/// 章节实际收益账本计划。若章节预算尚未初始化adapter 会跳过而不阻断主链。
pub chapter_ledger: Option<ChapterProgressionLedgerInput>,
}
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<StoryEventSnapshot>,
) -> StorySessionStateRecord {
StorySessionStateRecord {
session: build_story_session_record(session),
events: events
.into_iter()
.map(build_story_event_record)
.collect::<Vec<_>>(),
}
}
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::<Vec<_>>();
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::<Vec<_>>();
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<RpgGameplaySettlementPlan, StorySessionFieldError> {
let inventory_mutations = snapshot
.reward_items
.iter()
.cloned()
.enumerate()
.map(
|(index, reward_item)| -> Result<InventoryMutationInput, StorySessionFieldError> {
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::<Result<Vec<_>, _>>()?;
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)
}