464 lines
18 KiB
Rust
464 lines
18 KiB
Rust
//! 剧情应用服务与读模型映射。
|
|
//!
|
|
//! 应用层负责把命令变成快照、事件和前端可消费记录;它不直接调用模型、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)
|
|
}
|