use std::{error::Error, fmt}; use serde::{Deserialize, Serialize}; use shared_kernel::{ build_prefixed_seed_id, 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; pub const INVENTORY_SLOT_ID_PREFIX: &str = "invslot_"; pub const INVENTORY_MUTATION_ID_PREFIX: &str = "invmut_"; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum InventoryContainerKind { Backpack, Equipment, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum InventoryItemRarity { Common, Uncommon, Rare, Epic, Legendary, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum InventoryEquipmentSlot { Weapon, Armor, Relic, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum InventoryItemSourceKind { StoryReward, QuestReward, TreasureReward, NpcGift, NpcTrade, CombatDrop, ForgeCraft, ForgeReforge, ManualPatch, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct InventoryItemSnapshot { pub item_id: String, pub category: String, pub name: String, pub description: Option, pub quantity: u32, pub rarity: InventoryItemRarity, pub tags: Vec, pub stackable: bool, pub stack_key: String, pub equipment_slot_id: Option, pub source_kind: InventoryItemSourceKind, pub source_reference_id: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct InventorySlotSnapshot { pub slot_id: String, pub runtime_session_id: String, pub story_session_id: Option, pub actor_user_id: String, pub container_kind: InventoryContainerKind, pub slot_key: String, pub item_id: String, pub category: String, pub name: String, pub description: Option, pub quantity: u32, pub rarity: InventoryItemRarity, pub tags: Vec, pub stackable: bool, pub stack_key: String, pub equipment_slot_id: Option, pub source_kind: InventoryItemSourceKind, pub source_reference_id: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct GrantInventoryItemInput { pub slot_id: String, pub item: InventoryItemSnapshot, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConsumeInventoryItemInput { pub slot_id: String, pub quantity: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EquipInventoryItemInput { pub slot_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct UnequipInventoryItemInput { pub slot_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum InventoryMutation { GrantItem(GrantInventoryItemInput), ConsumeItem(ConsumeInventoryItemInput), EquipItem(EquipInventoryItemInput), UnequipItem(UnequipInventoryItemInput), } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct InventoryMutationInput { pub mutation_id: String, pub runtime_session_id: String, pub story_session_id: Option, pub actor_user_id: String, pub mutation: InventoryMutation, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeInventoryStateQueryInput { pub runtime_session_id: String, pub actor_user_id: String, } #[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, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum InventoryMutationFieldError { MissingMutationId, MissingRuntimeSessionId, MissingActorUserId, MissingSlotId, MissingItemId, MissingCategory, MissingName, InvalidQuantity, MissingStackKey, NonStackableItemMustStaySingleQuantity, EquipmentItemCannotStack, SlotScopeMismatch, ItemNotFound, ItemNotInBackpack, ItemNotEquipped, InsufficientQuantity, ItemNotEquippable, } impl InventoryEquipmentSlot { pub fn as_str(self) -> &'static str { match self { Self::Weapon => "weapon", Self::Armor => "armor", Self::Relic => "relic", } } } pub fn generate_inventory_slot_id(seed_micros: i64) -> String { build_prefixed_seed_id(INVENTORY_SLOT_ID_PREFIX, seed_micros) } pub fn generate_inventory_mutation_id(seed_micros: i64) -> String { build_prefixed_seed_id(INVENTORY_MUTATION_ID_PREFIX, seed_micros) } 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", } } impl fmt::Display for InventoryMutationFieldError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingMutationId => f.write_str("inventory_mutation.mutation_id 不能为空"), Self::MissingRuntimeSessionId => { f.write_str("inventory_mutation.runtime_session_id 不能为空") } Self::MissingActorUserId => f.write_str("inventory_mutation.actor_user_id 不能为空"), Self::MissingSlotId => f.write_str("inventory_slot.slot_id 不能为空"), Self::MissingItemId => f.write_str("inventory_item.item_id 不能为空"), Self::MissingCategory => f.write_str("inventory_item.category 不能为空"), Self::MissingName => f.write_str("inventory_item.name 不能为空"), Self::InvalidQuantity => f.write_str("inventory_item.quantity 必须大于 0"), Self::MissingStackKey => f.write_str("可堆叠物品必须提供 stack_key"), Self::NonStackableItemMustStaySingleQuantity => { f.write_str("不可堆叠物品必须固定为单槽位单数量") } Self::EquipmentItemCannotStack => f.write_str("可装备物品不能标记为 stackable"), Self::SlotScopeMismatch => { f.write_str("当前 inventory_slot 不属于本次 mutation 作用域") } Self::ItemNotFound => f.write_str("目标 inventory_slot 不存在"), Self::ItemNotInBackpack => f.write_str("目标物品当前不在背包中"), Self::ItemNotEquipped => f.write_str("目标物品当前不在装备位上"), Self::InsufficientQuantity => f.write_str("当前背包数量不足,无法完成扣减"), Self::ItemNotEquippable => f.write_str("目标物品当前不可装备"), } } } impl Error for InventoryMutationFieldError {} #[cfg(test)] mod tests { use super::*; fn build_stackable_item(quantity: u32) -> InventoryItemSnapshot { InventoryItemSnapshot { item_id: "consumable_heal_potion".to_string(), category: "消耗品".to_string(), name: "疗伤药".to_string(), description: Some("用于恢复少量气血。".to_string()), quantity, rarity: InventoryItemRarity::Common, tags: vec!["healing".to_string()], stackable: true, stack_key: "heal_potion".to_string(), equipment_slot_id: None, source_kind: InventoryItemSourceKind::TreasureReward, source_reference_id: Some("treasure_001".to_string()), } } fn build_weapon_item(slot_id: &str, name: &str) -> InventorySlotSnapshot { InventorySlotSnapshot { slot_id: slot_id.to_string(), runtime_session_id: "runtime_001".to_string(), story_session_id: Some("storysess_001".to_string()), actor_user_id: "user_001".to_string(), container_kind: InventoryContainerKind::Backpack, slot_key: slot_id.to_string(), item_id: format!("weapon:{slot_id}"), category: "武器".to_string(), name: name.to_string(), description: Some("测试武器".to_string()), quantity: 1, rarity: InventoryItemRarity::Rare, tags: vec!["weapon".to_string(), "快剑".to_string()], stackable: false, stack_key: format!("weapon:{slot_id}"), equipment_slot_id: Some(InventoryEquipmentSlot::Weapon), source_kind: InventoryItemSourceKind::StoryReward, source_reference_id: Some("storyevt_001".to_string()), created_at_micros: 1, updated_at_micros: 1, } } fn build_mutation_input(mutation: InventoryMutation) -> InventoryMutationInput { InventoryMutationInput { mutation_id: "invmut_001".to_string(), runtime_session_id: "runtime_001".to_string(), story_session_id: Some("storysess_001".to_string()), actor_user_id: "user_001".to_string(), mutation, updated_at_micros: 10, } } #[test] fn grant_item_merges_existing_stackable_slot() { let current = vec![InventorySlotSnapshot { slot_id: "invslot_existing".to_string(), runtime_session_id: "runtime_001".to_string(), story_session_id: Some("storysess_001".to_string()), actor_user_id: "user_001".to_string(), container_kind: InventoryContainerKind::Backpack, slot_key: "invslot_existing".to_string(), item_id: "consumable_heal_potion".to_string(), category: "消耗品".to_string(), name: "疗伤药".to_string(), description: None, quantity: 2, rarity: InventoryItemRarity::Common, tags: vec!["healing".to_string()], stackable: true, stack_key: "heal_potion".to_string(), equipment_slot_id: None, source_kind: InventoryItemSourceKind::TreasureReward, source_reference_id: Some("treasure_000".to_string()), created_at_micros: 1, updated_at_micros: 1, }]; let outcome = apply_inventory_mutation( current, build_mutation_input(InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id: "invslot_new".to_string(), item: build_stackable_item(3), })), ) .expect("grant should merge stackable row"); assert!(outcome.changed); assert_eq!(outcome.next_slots.len(), 1); assert_eq!(outcome.next_slots[0].quantity, 5); assert_eq!( outcome.updated_slot_ids, vec!["invslot_existing".to_string()] ); } #[test] fn grant_non_stackable_item_requires_single_quantity() { let error = apply_inventory_mutation( vec![], build_mutation_input(InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id: "invslot_weapon".to_string(), item: InventoryItemSnapshot { item_id: "weapon_001".to_string(), category: "武器".to_string(), name: "试作短剑".to_string(), description: None, quantity: 2, rarity: InventoryItemRarity::Rare, tags: vec!["weapon".to_string()], stackable: false, stack_key: String::new(), equipment_slot_id: Some(InventoryEquipmentSlot::Weapon), source_kind: InventoryItemSourceKind::StoryReward, source_reference_id: None, }, })), ) .expect_err("non-stackable item quantity must stay single"); assert_eq!( error, InventoryMutationFieldError::NonStackableItemMustStaySingleQuantity ); } #[test] fn consume_item_removes_slot_when_quantity_exhausted() { let current = vec![InventorySlotSnapshot { slot_id: "invslot_potion".to_string(), runtime_session_id: "runtime_001".to_string(), story_session_id: Some("storysess_001".to_string()), actor_user_id: "user_001".to_string(), container_kind: InventoryContainerKind::Backpack, slot_key: "invslot_potion".to_string(), item_id: "consumable_heal_potion".to_string(), category: "消耗品".to_string(), name: "疗伤药".to_string(), description: None, quantity: 1, rarity: InventoryItemRarity::Common, tags: vec!["healing".to_string()], stackable: true, stack_key: "heal_potion".to_string(), equipment_slot_id: None, source_kind: InventoryItemSourceKind::TreasureReward, source_reference_id: None, created_at_micros: 1, updated_at_micros: 1, }]; let outcome = apply_inventory_mutation( current, build_mutation_input(InventoryMutation::ConsumeItem(ConsumeInventoryItemInput { slot_id: "invslot_potion".to_string(), quantity: 1, })), ) .expect("consume should remove exhausted slot"); assert!(outcome.next_slots.is_empty()); assert_eq!(outcome.removed_slot_ids, vec!["invslot_potion".to_string()]); } #[test] fn equip_item_swaps_existing_equipment_back_to_backpack() { let equipped = InventorySlotSnapshot { container_kind: InventoryContainerKind::Equipment, slot_key: InventoryEquipmentSlot::Weapon.as_str().to_string(), ..build_weapon_item("invslot_old_weapon", "旧佩剑") }; let backpack_weapon = build_weapon_item("invslot_new_weapon", "逐风短剑"); let outcome = apply_inventory_mutation( vec![equipped, backpack_weapon], build_mutation_input(InventoryMutation::EquipItem(EquipInventoryItemInput { slot_id: "invslot_new_weapon".to_string(), })), ) .expect("equip should swap weapon"); assert!(outcome.changed); assert_eq!( outcome.affected_equipment_slot, Some(InventoryEquipmentSlot::Weapon) ); let weapon_slot = outcome .next_slots .iter() .find(|slot| slot.slot_id == "invslot_new_weapon") .expect("new weapon slot should exist"); assert_eq!( weapon_slot.container_kind, InventoryContainerKind::Equipment ); assert_eq!(weapon_slot.slot_key, "weapon"); let old_weapon_slot = outcome .next_slots .iter() .find(|slot| slot.slot_id == "invslot_old_weapon") .expect("old weapon slot should exist"); assert_eq!( old_weapon_slot.container_kind, InventoryContainerKind::Backpack ); assert_eq!(old_weapon_slot.slot_key, "invslot_old_weapon"); } #[test] fn unequip_item_moves_equipment_back_to_backpack() { let equipped = InventorySlotSnapshot { container_kind: InventoryContainerKind::Equipment, slot_key: InventoryEquipmentSlot::Relic.as_str().to_string(), equipment_slot_id: Some(InventoryEquipmentSlot::Relic), ..build_weapon_item("invslot_relic", "旧誓护符") }; let outcome = apply_inventory_mutation( vec![equipped], build_mutation_input(InventoryMutation::UnequipItem(UnequipInventoryItemInput { slot_id: "invslot_relic".to_string(), })), ) .expect("unequip should move relic back to backpack"); assert!(outcome.changed); assert_eq!( outcome.affected_equipment_slot, Some(InventoryEquipmentSlot::Relic) ); assert_eq!(outcome.next_slots.len(), 1); assert_eq!( outcome.next_slots[0].container_kind, InventoryContainerKind::Backpack ); assert_eq!(outcome.next_slots[0].slot_key, "invslot_relic"); } #[test] fn build_runtime_inventory_state_query_input_trims_scope_fields() { let input = build_runtime_inventory_state_query_input( " runtime_001 ".to_string(), " user_001 ".to_string(), ) .expect("query input should build"); assert_eq!(input.runtime_session_id, "runtime_001"); assert_eq!(input.actor_user_id, "user_001"); } #[test] fn build_runtime_inventory_state_snapshot_splits_backpack_and_equipment() { let snapshot = build_runtime_inventory_state_snapshot( RuntimeInventoryStateQueryInput { runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), }, vec![ InventorySlotSnapshot { container_kind: InventoryContainerKind::Equipment, slot_key: "weapon".to_string(), ..build_weapon_item("invslot_weapon", "逐风短剑") }, InventorySlotSnapshot { slot_id: "invslot_potion".to_string(), runtime_session_id: "runtime_001".to_string(), story_session_id: Some("storysess_001".to_string()), actor_user_id: "user_001".to_string(), container_kind: InventoryContainerKind::Backpack, slot_key: "invslot_potion".to_string(), item_id: "consumable_heal_potion".to_string(), category: "消耗品".to_string(), name: "疗伤药".to_string(), description: Some("用于恢复少量气血。".to_string()), quantity: 2, rarity: InventoryItemRarity::Common, tags: vec!["healing".to_string()], stackable: true, stack_key: "heal_potion".to_string(), equipment_slot_id: None, source_kind: InventoryItemSourceKind::TreasureReward, source_reference_id: Some("treasure_001".to_string()), created_at_micros: 1, updated_at_micros: 2, }, ], ); assert_eq!(snapshot.backpack_items.len(), 1); assert_eq!(snapshot.equipment_items.len(), 1); assert_eq!(snapshot.backpack_items[0].slot_id, "invslot_potion"); assert_eq!(snapshot.equipment_items[0].slot_id, "invslot_weapon"); } }