use std::{error::Error, fmt}; use module_inventory::{ InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, InventoryItemSourceKind, }; use serde::{Deserialize, Serialize}; use shared_kernel::{ 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 TREASURE_RECORD_ID_PREFIX: &str = "treasure_"; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum TreasureInteractionAction { Inspect, Leave, Secure, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeItemRewardItemSnapshot { pub item_id: String, pub category: String, pub item_name: String, pub description: Option, pub quantity: u32, pub rarity: RuntimeItemRewardItemRarity, pub tags: Vec, pub stackable: bool, pub stack_key: String, pub equipment_slot_id: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeItemRewardItemRarity { Common, Uncommon, Rare, Epic, Legendary, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeItemEquipmentSlot { Weapon, Armor, Relic, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TreasureResolveInput { pub treasure_record_id: String, pub runtime_session_id: String, pub story_session_id: String, pub actor_user_id: String, pub encounter_id: String, pub encounter_name: String, pub scene_id: Option, pub scene_name: Option, pub action: TreasureInteractionAction, pub reward_items: Vec, pub reward_hp: u32, pub reward_mana: u32, pub reward_currency: u32, pub story_hint: 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 TreasureRecordSnapshot { pub treasure_record_id: String, pub runtime_session_id: String, pub story_session_id: String, pub actor_user_id: String, pub encounter_id: String, pub encounter_name: String, pub scene_id: Option, pub scene_name: Option, pub action: TreasureInteractionAction, pub reward_items: Vec, pub reward_hp: u32, pub reward_mana: u32, pub reward_currency: u32, pub story_hint: 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 TreasureRecordProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum TreasureFieldError { MissingTreasureRecordId, MissingRuntimeSessionId, MissingStorySessionId, MissingActorUserId, MissingEncounterId, MissingEncounterName, MissingRewardItemId, MissingRewardItemCategory, MissingRewardItemName, InvalidRewardItemQuantity, MissingRewardItemStackKey, RewardEquipmentItemCannotStack, RewardNonStackableItemMustStaySingleQuantity, } pub fn build_treasure_record_snapshot( input: TreasureResolveInput, ) -> Result { validate_treasure_input(&input)?; Ok(TreasureRecordSnapshot { treasure_record_id: input.treasure_record_id, runtime_session_id: input.runtime_session_id, story_session_id: input.story_session_id, actor_user_id: input.actor_user_id, encounter_id: input.encounter_id, encounter_name: input.encounter_name, scene_id: normalize_optional_value(input.scene_id), scene_name: normalize_optional_value(input.scene_name), action: input.action, reward_items: input .reward_items .into_iter() .map(normalize_reward_item) .collect::, _>>()?, reward_hp: input.reward_hp, reward_mana: input.reward_mana, reward_currency: input.reward_currency, story_hint: normalize_optional_value(input.story_hint), created_at_micros: input.created_at_micros, updated_at_micros: input.updated_at_micros, }) } pub fn build_inventory_item_snapshot_from_reward_item( treasure_record_id: &str, reward_item: RuntimeItemRewardItemSnapshot, ) -> Result { let treasure_record_id = normalize_required_value( treasure_record_id.to_string(), TreasureFieldError::MissingTreasureRecordId, )?; let reward_item = normalize_reward_item(reward_item)?; Ok(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_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_reward_item_equipment_slot), source_kind: InventoryItemSourceKind::TreasureReward, source_reference_id: Some(treasure_record_id), }) } pub fn normalize_reward_item_snapshot( reward_item: RuntimeItemRewardItemSnapshot, ) -> Result { normalize_reward_item(reward_item) } fn validate_treasure_input(input: &TreasureResolveInput) -> Result<(), TreasureFieldError> { if input.treasure_record_id.trim().is_empty() { return Err(TreasureFieldError::MissingTreasureRecordId); } if input.runtime_session_id.trim().is_empty() { return Err(TreasureFieldError::MissingRuntimeSessionId); } if input.story_session_id.trim().is_empty() { return Err(TreasureFieldError::MissingStorySessionId); } if input.actor_user_id.trim().is_empty() { return Err(TreasureFieldError::MissingActorUserId); } if input.encounter_id.trim().is_empty() { return Err(TreasureFieldError::MissingEncounterId); } if input.encounter_name.trim().is_empty() { return Err(TreasureFieldError::MissingEncounterName); } Ok(()) } fn normalize_optional_value(value: Option) -> Option { normalize_shared_optional_string(value) } fn normalize_reward_item( mut item: RuntimeItemRewardItemSnapshot, ) -> Result { item.item_id = normalize_required_value(item.item_id, TreasureFieldError::MissingRewardItemId)?; item.category = normalize_required_value(item.category, TreasureFieldError::MissingRewardItemCategory)?; item.item_name = normalize_required_value(item.item_name, TreasureFieldError::MissingRewardItemName)?; item.description = normalize_optional_value(item.description); if item.quantity == 0 { return Err(TreasureFieldError::InvalidRewardItemQuantity); } if !item.stackable && item.quantity != 1 { return Err(TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity); } if item.equipment_slot_id.is_some() && item.stackable { return Err(TreasureFieldError::RewardEquipmentItemCannotStack); } item.tags = normalize_string_list(item.tags); item.stack_key = if item.stackable { normalize_required_value( item.stack_key, TreasureFieldError::MissingRewardItemStackKey, )? } else { normalize_optional_value(Some(item.stack_key)).unwrap_or_else(|| item.item_id.clone()) }; Ok(item) } fn normalize_required_value( value: String, error: TreasureFieldError, ) -> Result { normalize_required_string(value).ok_or(error) } fn normalize_string_list(values: Vec) -> Vec { normalize_shared_string_list(values) } fn map_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_reward_item_equipment_slot(slot: RuntimeItemEquipmentSlot) -> InventoryEquipmentSlot { match slot { RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon, RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor, RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic, } } impl fmt::Display for TreasureFieldError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingTreasureRecordId => { f.write_str("treasure_record.treasure_record_id 不能为空") } Self::MissingRuntimeSessionId => { f.write_str("treasure_record.runtime_session_id 不能为空") } Self::MissingStorySessionId => f.write_str("treasure_record.story_session_id 不能为空"), Self::MissingActorUserId => f.write_str("treasure_record.actor_user_id 不能为空"), Self::MissingEncounterId => f.write_str("treasure_record.encounter_id 不能为空"), Self::MissingEncounterName => f.write_str("treasure_record.encounter_name 不能为空"), Self::MissingRewardItemId => { f.write_str("treasure_record.reward_items[].item_id 不能为空") } Self::MissingRewardItemCategory => { f.write_str("treasure_record.reward_items[].category 不能为空") } Self::MissingRewardItemName => { f.write_str("treasure_record.reward_items[].item_name 不能为空") } Self::InvalidRewardItemQuantity => { f.write_str("treasure_record.reward_items[].quantity 必须大于 0") } Self::MissingRewardItemStackKey => { f.write_str("treasure_record.reward_items[].stack_key 不能为空") } Self::RewardEquipmentItemCannotStack => { f.write_str("treasure_record.reward_items[] 可装备物品不能标记为 stackable") } Self::RewardNonStackableItemMustStaySingleQuantity => { f.write_str("treasure_record.reward_items[] 不可堆叠物品必须固定为单槽位单数量") } } } } impl Error for TreasureFieldError {} #[cfg(test)] mod tests { use super::*; #[test] fn build_treasure_record_snapshot_accepts_minimal_contract() { let snapshot = build_treasure_record_snapshot(TreasureResolveInput { treasure_record_id: "treasure_001".to_string(), runtime_session_id: "runtime_001".to_string(), story_session_id: "storysess_001".to_string(), actor_user_id: "user_001".to_string(), encounter_id: "enc_001".to_string(), encounter_name: "旧钟楼暗格".to_string(), scene_id: Some("scene_001".to_string()), scene_name: Some("旧钟楼".to_string()), action: TreasureInteractionAction::Inspect, reward_items: vec![RuntimeItemRewardItemSnapshot { item_id: "item_001".to_string(), category: "遗物".to_string(), item_name: "铜钥残片".to_string(), description: Some("带着旧钟楼铜锈味的钥片。".to_string()), quantity: 1, rarity: RuntimeItemRewardItemRarity::Rare, tags: vec!["钥片".to_string(), "钟楼".to_string()], stackable: false, stack_key: String::new(), equipment_slot_id: None, }], reward_hp: 3, reward_mana: 2, reward_currency: 10, story_hint: Some("发现了旧机关的回响。".to_string()), created_at_micros: 10, updated_at_micros: 10, }) .expect("minimal treasure snapshot should succeed"); assert_eq!(snapshot.treasure_record_id, "treasure_001"); assert_eq!(snapshot.reward_items.len(), 1); } #[test] fn build_inventory_item_snapshot_from_reward_item_keeps_inventory_fields() { let item = build_inventory_item_snapshot_from_reward_item( "treasure_001", RuntimeItemRewardItemSnapshot { item_id: "item_001".to_string(), category: "遗物".to_string(), item_name: "铜钥残片".to_string(), description: Some("带着旧钟楼铜锈味的钥片。".to_string()), quantity: 1, rarity: RuntimeItemRewardItemRarity::Rare, tags: vec!["钥片".to_string(), "钟楼".to_string()], stackable: false, stack_key: String::new(), equipment_slot_id: Some(RuntimeItemEquipmentSlot::Relic), }, ) .expect("reward item should convert into inventory item"); assert_eq!(item.item_id, "item_001"); assert_eq!(item.category, "遗物"); assert_eq!(item.name, "铜钥残片"); assert_eq!(item.rarity, InventoryItemRarity::Rare); assert_eq!(item.stack_key, "item_001"); assert_eq!(item.equipment_slot_id, Some(InventoryEquipmentSlot::Relic)); assert_eq!(item.source_kind, InventoryItemSourceKind::TreasureReward); assert_eq!(item.source_reference_id, Some("treasure_001".to_string())); } #[test] fn normalize_reward_item_snapshot_trims_and_fills_stack_key() { let item = normalize_reward_item_snapshot(RuntimeItemRewardItemSnapshot { item_id: " item_001 ".to_string(), category: " 遗物 ".to_string(), item_name: " 铜钥残片 ".to_string(), description: Some(" 带着旧钟楼铜锈味的钥片。 ".to_string()), quantity: 1, rarity: RuntimeItemRewardItemRarity::Rare, tags: vec![" 钥片 ".to_string(), "".to_string(), "钟楼".to_string()], stackable: false, stack_key: String::new(), equipment_slot_id: None, }) .expect("reward item should normalize"); assert_eq!(item.item_id, "item_001"); assert_eq!(item.category, "遗物"); assert_eq!(item.item_name, "铜钥残片"); assert_eq!( item.description.as_deref(), Some("带着旧钟楼铜锈味的钥片。") ); assert_eq!(item.tags, vec!["钥片".to_string(), "钟楼".to_string()]); assert_eq!(item.stack_key, "item_001"); } }