//! 背包应用编排。 //! //! 这里只返回背包变更结果和领域事件,不直接访问持久化。 use crate::commands::{ ConsumeInventoryItemInput, EquipInventoryItemInput, GrantInventoryItemInput, InventoryMutation, InventoryMutationInput, RuntimeInventoryStateQueryInput, UnequipInventoryItemInput, }; use crate::domain::{ InventoryContainerKind, InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, InventoryItemSourceKind, InventorySlotSnapshot, }; use crate::errors::InventoryMutationFieldError; use serde::{Deserialize, Serialize}; use shared_kernel::{ format_timestamp_micros, normalize_optional_string as normalize_shared_optional_string, normalize_required_string, normalize_string_list as normalize_shared_string_list, }; #[cfg(feature = "spacetime-types")] use spacetimedb::SpacetimeType; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeInventoryStateSnapshot { pub runtime_session_id: String, pub actor_user_id: String, pub backpack_items: Vec, pub equipment_items: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeInventoryStateProcedureResult { pub ok: bool, pub snapshot: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RuntimeInventorySlotRecord { pub slot_id: String, pub container_kind: String, pub slot_key: String, pub item_id: String, pub category: String, pub name: String, pub description: Option, pub quantity: u32, pub rarity: String, pub tags: Vec, pub stackable: bool, pub stack_key: String, pub equipment_slot_id: Option, pub source_kind: String, pub source_reference_id: Option, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RuntimeInventoryStateRecord { pub runtime_session_id: String, pub actor_user_id: String, pub backpack_items: Vec, pub equipment_items: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct InventoryMutationOutcome { pub next_slots: Vec, pub changed: bool, pub updated_slot_ids: Vec, pub removed_slot_ids: Vec, pub affected_equipment_slot: Option, } pub fn normalize_optional_text(value: Option) -> Option { normalize_shared_optional_string(value) } pub fn normalize_string_list(values: Vec) -> Vec { normalize_shared_string_list(values) } pub fn build_runtime_inventory_state_query_input( runtime_session_id: String, actor_user_id: String, ) -> Result { let input = RuntimeInventoryStateQueryInput { runtime_session_id: normalize_required_text( runtime_session_id, InventoryMutationFieldError::MissingRuntimeSessionId, )?, actor_user_id: normalize_required_text( actor_user_id, InventoryMutationFieldError::MissingActorUserId, )?, }; Ok(input) } pub fn build_runtime_inventory_state_snapshot( input: RuntimeInventoryStateQueryInput, slots: Vec, ) -> RuntimeInventoryStateSnapshot { let mut backpack_items = Vec::new(); let mut equipment_items = Vec::new(); for slot in slots { match slot.container_kind { InventoryContainerKind::Backpack => backpack_items.push(slot), InventoryContainerKind::Equipment => equipment_items.push(slot), } } backpack_items.sort_by(|left, right| { left.slot_key .cmp(&right.slot_key) .then(left.slot_id.cmp(&right.slot_id)) }); equipment_items.sort_by(|left, right| { equipment_slot_order(left.equipment_slot_id) .cmp(&equipment_slot_order(right.equipment_slot_id)) .then(left.slot_id.cmp(&right.slot_id)) }); RuntimeInventoryStateSnapshot { runtime_session_id: input.runtime_session_id, actor_user_id: input.actor_user_id, backpack_items, equipment_items, } } pub fn apply_inventory_mutation( current_slots: Vec, input: InventoryMutationInput, ) -> Result { let _mutation_id = normalize_required_text( input.mutation_id, InventoryMutationFieldError::MissingMutationId, )?; let runtime_session_id = normalize_required_text( input.runtime_session_id, InventoryMutationFieldError::MissingRuntimeSessionId, )?; let actor_user_id = normalize_required_text( input.actor_user_id, InventoryMutationFieldError::MissingActorUserId, )?; let story_session_id = normalize_optional_text(input.story_session_id); let mut slots = current_slots; for slot in &slots { if slot.runtime_session_id != runtime_session_id || slot.actor_user_id != actor_user_id { return Err(InventoryMutationFieldError::SlotScopeMismatch); } } let outcome = match input.mutation { InventoryMutation::GrantItem(grant) => apply_grant_item( &mut slots, runtime_session_id, story_session_id, actor_user_id, grant, input.updated_at_micros, )?, InventoryMutation::ConsumeItem(consume) => { apply_consume_item(&mut slots, consume, input.updated_at_micros)? } InventoryMutation::EquipItem(equip) => { apply_equip_item(&mut slots, equip, input.updated_at_micros)? } InventoryMutation::UnequipItem(unequip) => { apply_unequip_item(&mut slots, unequip, input.updated_at_micros)? } }; Ok(InventoryMutationOutcome { next_slots: sort_inventory_slots(slots), changed: outcome.changed, updated_slot_ids: sort_string_list(outcome.updated_slot_ids), removed_slot_ids: sort_string_list(outcome.removed_slot_ids), affected_equipment_slot: outcome.affected_equipment_slot, }) } #[derive(Clone, Debug, PartialEq, Eq)] struct InventoryMutationInternalOutcome { changed: bool, updated_slot_ids: Vec, removed_slot_ids: Vec, affected_equipment_slot: Option, } fn apply_grant_item( slots: &mut Vec, runtime_session_id: String, story_session_id: Option, actor_user_id: String, grant: GrantInventoryItemInput, updated_at_micros: i64, ) -> Result { let slot_id = normalize_required_text(grant.slot_id, InventoryMutationFieldError::MissingSlotId)?; let item = normalize_inventory_item_snapshot(grant.item)?; if item.stackable { if let Some(existing) = slots.iter_mut().find(|slot| { slot.container_kind == InventoryContainerKind::Backpack && slot.stackable && slot.item_id == item.item_id && slot.stack_key == item.stack_key }) { existing.category = item.category; existing.name = item.name; existing.description = item.description; existing.quantity += item.quantity; existing.rarity = item.rarity; existing.tags = item.tags; existing.stackable = item.stackable; existing.stack_key = item.stack_key; existing.equipment_slot_id = item.equipment_slot_id; existing.source_kind = item.source_kind; existing.source_reference_id = item.source_reference_id; existing.updated_at_micros = updated_at_micros; return Ok(InventoryMutationInternalOutcome { changed: true, updated_slot_ids: vec![existing.slot_id.clone()], removed_slot_ids: vec![], affected_equipment_slot: None, }); } } slots.push(InventorySlotSnapshot { slot_id: slot_id.clone(), runtime_session_id, story_session_id, actor_user_id, container_kind: InventoryContainerKind::Backpack, slot_key: build_backpack_slot_key(&slot_id), item_id: item.item_id, category: item.category, name: item.name, description: item.description, quantity: item.quantity, rarity: item.rarity, tags: item.tags, stackable: item.stackable, stack_key: item.stack_key, equipment_slot_id: item.equipment_slot_id, source_kind: item.source_kind, source_reference_id: item.source_reference_id, created_at_micros: updated_at_micros, updated_at_micros, }); Ok(InventoryMutationInternalOutcome { changed: true, updated_slot_ids: vec![slot_id], removed_slot_ids: vec![], affected_equipment_slot: None, }) } fn apply_consume_item( slots: &mut Vec, consume: ConsumeInventoryItemInput, updated_at_micros: i64, ) -> Result { let slot_id = normalize_required_text(consume.slot_id, InventoryMutationFieldError::MissingSlotId)?; if consume.quantity == 0 { return Err(InventoryMutationFieldError::InvalidQuantity); } let slot_index = slots .iter() .position(|slot| slot.slot_id == slot_id) .ok_or(InventoryMutationFieldError::ItemNotFound)?; if slots[slot_index].container_kind != InventoryContainerKind::Backpack { return Err(InventoryMutationFieldError::ItemNotInBackpack); } if slots[slot_index].quantity < consume.quantity { return Err(InventoryMutationFieldError::InsufficientQuantity); } if slots[slot_index].quantity == consume.quantity { slots.remove(slot_index); return Ok(InventoryMutationInternalOutcome { changed: true, updated_slot_ids: vec![], removed_slot_ids: vec![slot_id], affected_equipment_slot: None, }); } slots[slot_index].quantity -= consume.quantity; slots[slot_index].updated_at_micros = updated_at_micros; Ok(InventoryMutationInternalOutcome { changed: true, updated_slot_ids: vec![slots[slot_index].slot_id.clone()], removed_slot_ids: vec![], affected_equipment_slot: None, }) } fn apply_equip_item( slots: &mut [InventorySlotSnapshot], equip: EquipInventoryItemInput, updated_at_micros: i64, ) -> Result { let slot_id = normalize_required_text(equip.slot_id, InventoryMutationFieldError::MissingSlotId)?; let source_index = slots .iter() .position(|slot| slot.slot_id == slot_id) .ok_or(InventoryMutationFieldError::ItemNotFound)?; let target_slot = slots[source_index] .equipment_slot_id .ok_or(InventoryMutationFieldError::ItemNotEquippable)?; if slots[source_index].stackable { return Err(InventoryMutationFieldError::EquipmentItemCannotStack); } if slots[source_index].quantity != 1 { return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity); } if slots[source_index].container_kind != InventoryContainerKind::Backpack { if slots[source_index].container_kind == InventoryContainerKind::Equipment { return Ok(InventoryMutationInternalOutcome { changed: false, updated_slot_ids: vec![], removed_slot_ids: vec![], affected_equipment_slot: Some(target_slot), }); } return Err(InventoryMutationFieldError::ItemNotInBackpack); } let occupied_index = slots.iter().position(|slot| { slot.container_kind == InventoryContainerKind::Equipment && slot.slot_key == build_equipment_slot_key(target_slot) }); let mut updated_slot_ids = vec![slot_id.clone()]; if let Some(occupied_index) = occupied_index { // 首版装备互换直接在同一条 slot 真相记录上切容器,不生成临时副本。 slots[occupied_index].container_kind = InventoryContainerKind::Backpack; slots[occupied_index].slot_key = build_backpack_slot_key(&slots[occupied_index].slot_id); slots[occupied_index].updated_at_micros = updated_at_micros; updated_slot_ids.push(slots[occupied_index].slot_id.clone()); } slots[source_index].container_kind = InventoryContainerKind::Equipment; slots[source_index].slot_key = build_equipment_slot_key(target_slot); slots[source_index].updated_at_micros = updated_at_micros; Ok(InventoryMutationInternalOutcome { changed: true, updated_slot_ids, removed_slot_ids: vec![], affected_equipment_slot: Some(target_slot), }) } fn apply_unequip_item( slots: &mut [InventorySlotSnapshot], unequip: UnequipInventoryItemInput, updated_at_micros: i64, ) -> Result { let slot_id = normalize_required_text(unequip.slot_id, InventoryMutationFieldError::MissingSlotId)?; let slot_index = slots .iter() .position(|slot| slot.slot_id == slot_id) .ok_or(InventoryMutationFieldError::ItemNotFound)?; if slots[slot_index].container_kind != InventoryContainerKind::Equipment { return Err(InventoryMutationFieldError::ItemNotEquipped); } let affected_equipment_slot = slots[slot_index].equipment_slot_id; slots[slot_index].container_kind = InventoryContainerKind::Backpack; slots[slot_index].slot_key = build_backpack_slot_key(&slot_id); slots[slot_index].updated_at_micros = updated_at_micros; Ok(InventoryMutationInternalOutcome { changed: true, updated_slot_ids: vec![slot_id], removed_slot_ids: vec![], affected_equipment_slot, }) } fn normalize_inventory_item_snapshot( item: InventoryItemSnapshot, ) -> Result { let item_id = normalize_required_text(item.item_id, InventoryMutationFieldError::MissingItemId)?; let category = normalize_required_text(item.category, InventoryMutationFieldError::MissingCategory)?; let name = normalize_required_text(item.name, InventoryMutationFieldError::MissingName)?; if item.quantity == 0 { return Err(InventoryMutationFieldError::InvalidQuantity); } if !item.stackable && item.quantity != 1 { return Err(InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity); } if item.equipment_slot_id.is_some() && item.stackable { return Err(InventoryMutationFieldError::EquipmentItemCannotStack); } let stack_key = if item.stackable { normalize_required_text(item.stack_key, InventoryMutationFieldError::MissingStackKey)? } else { normalize_optional_text(Some(item.stack_key)).unwrap_or_else(|| item_id.clone()) }; Ok(InventoryItemSnapshot { item_id, category, name, description: normalize_optional_text(item.description), quantity: item.quantity, rarity: item.rarity, tags: normalize_string_list(item.tags), stackable: item.stackable, stack_key, equipment_slot_id: item.equipment_slot_id, source_kind: item.source_kind, source_reference_id: normalize_optional_text(item.source_reference_id), }) } fn normalize_required_text( value: String, error: InventoryMutationFieldError, ) -> Result { normalize_required_string(value).ok_or(error) } fn sort_inventory_slots(mut slots: Vec) -> Vec { slots.sort_by(|left, right| { container_order(left.container_kind) .cmp(&container_order(right.container_kind)) .then(left.slot_key.cmp(&right.slot_key)) .then(left.slot_id.cmp(&right.slot_id)) }); slots } fn sort_string_list(mut values: Vec) -> Vec { values.sort(); values } fn container_order(kind: InventoryContainerKind) -> u8 { match kind { InventoryContainerKind::Equipment => 0, InventoryContainerKind::Backpack => 1, } } fn equipment_slot_order(slot: Option) -> u8 { match slot { Some(InventoryEquipmentSlot::Weapon) => 0, Some(InventoryEquipmentSlot::Armor) => 1, Some(InventoryEquipmentSlot::Relic) => 2, None => 3, } } fn build_backpack_slot_key(slot_id: &str) -> String { slot_id.to_string() } fn build_equipment_slot_key(slot: InventoryEquipmentSlot) -> String { slot.as_str().to_string() } pub fn build_runtime_inventory_state_record( snapshot: RuntimeInventoryStateSnapshot, ) -> RuntimeInventoryStateRecord { RuntimeInventoryStateRecord { runtime_session_id: snapshot.runtime_session_id, actor_user_id: snapshot.actor_user_id, backpack_items: snapshot .backpack_items .into_iter() .map(build_runtime_inventory_slot_record) .collect(), equipment_items: snapshot .equipment_items .into_iter() .map(build_runtime_inventory_slot_record) .collect(), } } fn build_runtime_inventory_slot_record(slot: InventorySlotSnapshot) -> RuntimeInventorySlotRecord { RuntimeInventorySlotRecord { slot_id: slot.slot_id, container_kind: format_inventory_container_kind(slot.container_kind).to_string(), slot_key: slot.slot_key, item_id: slot.item_id, category: slot.category, name: slot.name, description: slot.description, quantity: slot.quantity, rarity: format_inventory_item_rarity(slot.rarity).to_string(), tags: slot.tags, stackable: slot.stackable, stack_key: slot.stack_key, equipment_slot_id: slot .equipment_slot_id .map(|value| value.as_str().to_string()), source_kind: format_inventory_item_source_kind(slot.source_kind).to_string(), source_reference_id: slot.source_reference_id, created_at: format_timestamp_micros(slot.created_at_micros), updated_at: format_timestamp_micros(slot.updated_at_micros), } } fn format_inventory_container_kind(value: InventoryContainerKind) -> &'static str { match value { InventoryContainerKind::Backpack => "backpack", InventoryContainerKind::Equipment => "equipment", } } fn format_inventory_item_rarity(value: InventoryItemRarity) -> &'static str { match value { InventoryItemRarity::Common => "common", InventoryItemRarity::Uncommon => "uncommon", InventoryItemRarity::Rare => "rare", InventoryItemRarity::Epic => "epic", InventoryItemRarity::Legendary => "legendary", } } fn format_inventory_item_source_kind(value: InventoryItemSourceKind) -> &'static str { match value { InventoryItemSourceKind::StoryReward => "story_reward", InventoryItemSourceKind::QuestReward => "quest_reward", InventoryItemSourceKind::TreasureReward => "treasure_reward", InventoryItemSourceKind::NpcGift => "npc_gift", InventoryItemSourceKind::NpcTrade => "npc_trade", InventoryItemSourceKind::CombatDrop => "combat_drop", InventoryItemSourceKind::ForgeCraft => "forge_craft", InventoryItemSourceKind::ForgeReforge => "forge_reforge", InventoryItemSourceKind::ManualPatch => "manual_patch", } }