diff --git a/crates/wow-database/src/statements/character.rs b/crates/wow-database/src/statements/character.rs index 9596859d..f53890cf 100644 --- a/crates/wow-database/src/statements/character.rs +++ b/crates/wow-database/src/statements/character.rs @@ -1877,7 +1877,8 @@ impl StatementDef for CharStatements { Self::SEL_MAX_GUID => "SELECT MAX(guid) FROM characters", Self::SEL_CHAR_EQUIPMENT => { "SELECT ci.slot, ii.itemEntry, ci.item, ii.count, ii.durability, ii.context, \ - ii.flags, ii.playedTime, ir.paidMoney, ir.paidExtendedCost \ + ii.flags, ii.playedTime, ii.enchantments, ii.randomPropertiesId, \ + ii.randomPropertiesSeed, ir.paidMoney, ir.paidExtendedCost \ FROM character_inventory ci \ JOIN item_instance ii ON ci.item = ii.guid \ LEFT JOIN item_refund_instance ir \ @@ -3106,7 +3107,8 @@ impl StatementDef for CharStatements { } Self::SEL_CHAR_BAG_CONTENTS => { "SELECT bag_ci.slot, ci.slot, ii.itemEntry, ci.item, ii.count, ii.durability, ii.context, \ - ii.flags, ii.playedTime, ir.paidMoney, ir.paidExtendedCost \ + ii.flags, ii.playedTime, ii.enchantments, ii.randomPropertiesId, \ + ii.randomPropertiesSeed, ir.paidMoney, ir.paidExtendedCost \ FROM character_inventory ci \ JOIN character_inventory bag_ci \ ON bag_ci.guid = ci.guid AND bag_ci.item = ci.bag \ @@ -6014,6 +6016,21 @@ mod tests { .count(), 1 ); + assert!( + CharStatements::SEL_CHAR_EQUIPMENT + .sql() + .contains("ii.enchantments") + ); + assert!( + CharStatements::SEL_CHAR_EQUIPMENT + .sql() + .contains("ii.randomPropertiesId") + ); + assert!( + CharStatements::SEL_CHAR_EQUIPMENT + .sql() + .contains("ii.randomPropertiesSeed") + ); assert_eq!( CharStatements::INS_ITEM_INSTANCE_WITH_RANDOM_CONTEXT .sql() @@ -6061,6 +6078,21 @@ mod tests { .count(), 1 ); + assert!( + CharStatements::SEL_CHAR_BAG_CONTENTS + .sql() + .contains("ii.enchantments") + ); + assert!( + CharStatements::SEL_CHAR_BAG_CONTENTS + .sql() + .contains("ii.randomPropertiesId") + ); + assert!( + CharStatements::SEL_CHAR_BAG_CONTENTS + .sql() + .contains("ii.randomPropertiesSeed") + ); assert_eq!( CharStatements::DEL_ITEM_REFUND_INSTANCE .sql() diff --git a/crates/wow-packet/src/packets/update.rs b/crates/wow-packet/src/packets/update.rs index a6b4d250..cccb39fe 100644 --- a/crates/wow-packet/src/packets/update.rs +++ b/crates/wow-packet/src/packets/update.rs @@ -1369,6 +1369,7 @@ pub struct ItemCreateData { pub max_durability: u32, pub random_properties_seed: i32, pub random_properties_id: i32, + pub enchantments: [ItemEnchantmentValuesUpdate; 13], pub context: u8, /// Non-zero for `Bag` objects. C++ writes those as TYPEID_CONTAINER /// and appends `ContainerData::WriteCreate` after `ItemData::WriteCreate`. @@ -5317,13 +5318,13 @@ fn write_item_create_block( // DynamicFlags val_buf.write_uint32(data.dynamic_flags); - // 13 x ItemEnchantment (all zeros) - for _ in 0..13 { - val_buf.write_int32(0); // ID - val_buf.write_int32(0); // Duration - val_buf.write_int16(0); // Charges - val_buf.write_uint8(0); // Field_A - val_buf.write_uint8(0); // Field_B + // 13 x ItemEnchantment + for enchantment in data.enchantments { + val_buf.write_int32(enchantment.id); + val_buf.write_uint32(enchantment.duration); + val_buf.write_int16(enchantment.charges); + val_buf.write_uint8(enchantment.field_a); + val_buf.write_uint8(enchantment.field_b); } // PropertySeed, RandomPropertiesID @@ -9546,6 +9547,16 @@ mod tests { max_durability: 20, random_properties_seed: 456, random_properties_id: -77, + enchantments: { + let mut enchantments = [ItemEnchantmentValuesUpdate::default(); 13]; + enchantments[0] = ItemEnchantmentValuesUpdate { + id: 2673, + duration: 0, + charges: 0, + ..Default::default() + }; + enchantments + }, context: 2, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -9577,6 +9588,11 @@ mod tests { .any(|window| window == (-77i32).to_le_bytes()) ); assert!(bytes.windows(4).any(|window| window == 2i32.to_le_bytes())); + assert!( + bytes + .windows(4) + .any(|window| window == 2673i32.to_le_bytes()) + ); } #[test] @@ -9599,6 +9615,7 @@ mod tests { max_durability: 0, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: 0, container_slots: 16, container_item_guids, diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index a8462756..bee5b281 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -36,11 +36,12 @@ use wow_database::{ }; use wow_entities::{ BANK_SLOT_BAG_END, BANK_SLOT_BAG_START, BUYBACK_SLOT_START, - CreatureAddonLifecycleRecordLikeCpp, GAMEOBJECT_TYPE_FISHING_HOLE, GAMEOBJECT_TYPE_QUESTGIVER, - GameObjectTemplateData, INVENTORY_DEFAULT_SIZE, INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_BAG_END, - INVENTORY_SLOT_BAG_START, INVENTORY_SLOT_ITEM_START, MAX_BAG_SIZE, MAX_GAMEOBJECT_DATA, - MovementGeneratorType, NULL_BAG, NULL_SLOT, REAGENT_BAG_SLOT_END, REAGENT_BAG_SLOT_START, - WorldObject, is_equipment_pos, is_inventory_pos, + CreatureAddonLifecycleRecordLikeCpp, EQUIPMENT_SLOT_END, GAMEOBJECT_TYPE_FISHING_HOLE, + GAMEOBJECT_TYPE_QUESTGIVER, GameObjectTemplateData, INVENTORY_DEFAULT_SIZE, + INVENTORY_SLOT_BAG_0, INVENTORY_SLOT_BAG_END, INVENTORY_SLOT_BAG_START, + INVENTORY_SLOT_ITEM_START, MAX_BAG_SIZE, MAX_GAMEOBJECT_DATA, MovementGeneratorType, NULL_BAG, + NULL_SLOT, PROFESSION_SLOT_END, PROFESSION_SLOT_START, REAGENT_BAG_SLOT_END, + REAGENT_BAG_SLOT_START, WorldObject, is_equipment_pos, is_inventory_pos, normalize_creature_chase_movement_type_like_cpp, normalize_creature_random_movement_type_like_cpp, }; @@ -64,7 +65,8 @@ use crate::session::{ ALL_ACCOUNT_DATA_CACHE_MASK_LIKE_CPP, CharacterPetAuraEffectRowLikeCpp, CharacterPetAuraRowLikeCpp, CharacterPetDeclinedNamesRowLikeCpp, CharacterPetSpellChargeRowLikeCpp, CharacterPetSpellCooldownRowLikeCpp, - CharacterPetSpellRowLikeCpp, CharacterPetStableRowLikeCpp, RepresentedAlterAppearanceLikeCpp, + CharacterPetSpellRowLikeCpp, CharacterPetStableRowLikeCpp, + LoadedEquippedItemEnchantmentsOutcomeLikeCpp, RepresentedAlterAppearanceLikeCpp, RepresentedBankItemMoveLikeCpp, RepresentedConfirmBarbersChoiceLikeCpp, RepresentedGameObjectUseState, }; @@ -80,6 +82,7 @@ const GO_SPAWN_EFFECTIVE_FLAGS_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + const GO_SPAWN_EFFECTIVE_FACTION_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + 5; const GO_SPAWN_OVERRIDE_SOURCE_KNOWN_COLUMN: usize = GO_SPAWN_PHASE_USE_FLAGS_COLUMN + 6; const WORLDSTATE_ANY_MAP_LIKE_CPP: i32 = -1; +const ITEM_ENCHANTMENT_DB_FIELDS: usize = 3; fn primary_power_type_for_class_like_cpp(class_id: u8) -> PowerType { match class_id { @@ -98,6 +101,98 @@ fn primary_max_power_for_class_like_cpp(class_id: u8, max_mana: i64) -> i32 { } } +fn apply_loaded_item_instance_fields_like_cpp( + item: &mut wow_entities::Item, + enchantments: &[ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT], + random_properties_id: i32, + random_properties_seed: i32, +) { + if random_properties_id != 0 { + item.set_random_properties_id(random_properties_id); + item.set_property_seed(random_properties_seed); + } + + for (slot_index, enchantment) in enchantments.iter().enumerate() { + let Some(slot) = ::from_usize(slot_index) + else { + continue; + }; + item.set_enchantment( + slot, + enchantment.id, + enchantment.duration, + enchantment.charges, + ); + } +} + +fn loaded_item_slot_applies_equipped_enchantments_like_cpp(slot: u8) -> bool { + slot < EQUIPMENT_SLOT_END || (PROFESSION_SLOT_START..PROFESSION_SLOT_END).contains(&slot) +} + +fn loaded_item_effective_enchantments_like_cpp( + loaded_enchantments: Option<&[ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT]>, + random_properties_id: i32, + item_random_properties_store: Option<&wow_data::ItemRandomPropertiesStore>, + item_random_suffix_store: Option<&wow_data::ItemRandomSuffixStore>, +) -> [ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT] { + let mut values = [ItemEnchantmentValuesUpdate::default(); wow_entities::MAX_ENCHANTMENT_SLOT]; + + if random_properties_id > 0 { + if let Some(entry) = + item_random_properties_store.and_then(|store| store.get(random_properties_id as u32)) + { + for (offset, enchantment_id) in entry.enchantments.iter().take(3).enumerate() { + values[EnchantmentSlot::Property2 as usize + offset].id = + i32::from(*enchantment_id); + } + } + } else if random_properties_id < 0 { + if let Some(entry) = item_random_suffix_store + .and_then(|store| store.get(random_properties_id.unsigned_abs())) + { + for (offset, enchantment_id) in entry.enchantments.iter().take(3).enumerate() { + values[EnchantmentSlot::Property0 as usize + offset].id = + i32::from(*enchantment_id); + } + } + } + + if let Some(loaded_enchantments) = loaded_enchantments + && loaded_enchantments.iter().any(|enchantment| { + enchantment.id != 0 || enchantment.duration != 0 || enchantment.charges != 0 + }) + { + values = *loaded_enchantments; + } + + values +} + +fn loaded_item_enchantments_like_cpp( + enchantments: &str, +) -> Option<[ItemEnchantmentValuesUpdate; wow_entities::MAX_ENCHANTMENT_SLOT]> { + let mut values = [ItemEnchantmentValuesUpdate::default(); wow_entities::MAX_ENCHANTMENT_SLOT]; + let tokens: Vec<&str> = enchantments.split_whitespace().collect(); + if tokens.len() != wow_entities::MAX_ENCHANTMENT_SLOT * ITEM_ENCHANTMENT_DB_FIELDS { + return None; + } + + for slot_index in 0..wow_entities::MAX_ENCHANTMENT_SLOT { + let base = slot_index * ITEM_ENCHANTMENT_DB_FIELDS; + values[slot_index] = ItemEnchantmentValuesUpdate { + item_enchantment_mask: 0, + id: tokens[base].parse::().unwrap_or(0), + duration: tokens[base + 1].parse::().unwrap_or(0), + charges: tokens[base + 2].parse::().unwrap_or(0), + field_a: 0, + field_b: 0, + }; + } + + Some(values) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct LoginWorldStateTemplateLikeCpp { id: i32, @@ -4567,6 +4662,7 @@ impl WorldSession { let mut inv_slots = [ObjectGuid::EMPTY; 141]; let mut item_creates: Vec = Vec::new(); let mut login_bag_create_index_by_slot: HashMap = HashMap::new(); + let mut loaded_equipped_item_guids: Vec = Vec::new(); let realm_id = self.realm_id(); self.clear_inventory_items_and_objects_like_cpp(); self.clear_player_currencies_like_cpp(); @@ -4589,11 +4685,25 @@ impl WorldSession { .unwrap_or(ItemContext::None); let item_flags = eq_result.try_read::(6).unwrap_or(0); let item_played_time = eq_result.try_read::(7).unwrap_or(0); + let item_enchantments = + eq_result.try_read::(8).unwrap_or_default(); + let item_enchantment_values = + loaded_item_enchantments_like_cpp(&item_enchantments); + let random_properties_id = eq_result.try_read::(9).unwrap_or(0); + let random_properties_seed = eq_result.try_read::(10).unwrap_or(0); + let item_create_enchantments = + loaded_item_effective_enchantments_like_cpp( + item_enchantment_values.as_ref(), + random_properties_id, + self.item_random_properties_store() + .map(|store| store.as_ref()), + self.item_random_suffix_store().map(|store| store.as_ref()), + ); let refund_decision = loaded_item_refund_decision( item_flags, item_played_time, - eq_result.try_read::(8), - eq_result.try_read::(9), + eq_result.try_read::(11), + eq_result.try_read::(12), ); if item_entry > 0 && (slot as usize) < 141 { let item_max_durability = self @@ -4650,8 +4760,9 @@ impl WorldSession { dynamic_flags: stored_flags, durability: item_durability, max_durability: item_max_durability, - random_properties_seed: 0, - random_properties_id: 0, + random_properties_seed, + random_properties_id, + enchantments: item_create_enchantments, context: item_context as u8, container_slots, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -4680,6 +4791,12 @@ impl WorldSession { slot, ); item_object.set_create_played_time(item_played_time); + apply_loaded_item_instance_fields_like_cpp( + &mut item_object, + &item_create_enchantments, + random_properties_id, + random_properties_seed, + ); item_object.replace_all_item_flags( ItemFieldFlags::from_bits_retain(stored_flags), ); @@ -4697,10 +4814,16 @@ impl WorldSession { &item_object, ); item_object.set_state(ItemUpdateState::Unchanged); + let visible_item_fields = ((slot as usize) < 19).then(|| { + self.loaded_inventory_item_visible_fields_like_cpp(&item_object) + }); self.insert_inventory_item_object(item_object); + if loaded_item_slot_applies_equipped_enchantments_like_cpp(slot) { + loaded_equipped_item_guids.push(item_guid); + } // Slots 0-18 also populate VisibleItems for character model - if (slot as usize) < 19 { - visible_items[slot as usize] = (item_entry as i32, 0, 0); + if let Some(fields) = visible_item_fields { + visible_items[slot as usize] = fields; } } if !eq_result.next_row() { @@ -4745,6 +4868,22 @@ impl WorldSession { .unwrap_or(ItemContext::None); let item_flags = bag_result.try_read::(7).unwrap_or(0); let item_played_time = bag_result.try_read::(8).unwrap_or(0); + let item_enchantments = + bag_result.try_read::(9).unwrap_or_default(); + let item_enchantment_values = + loaded_item_enchantments_like_cpp(&item_enchantments); + let random_properties_id = + bag_result.try_read::(10).unwrap_or(0); + let random_properties_seed = + bag_result.try_read::(11).unwrap_or(0); + let item_create_enchantments = + loaded_item_effective_enchantments_like_cpp( + item_enchantment_values.as_ref(), + random_properties_id, + self.item_random_properties_store() + .map(|store| store.as_ref()), + self.item_random_suffix_store().map(|store| store.as_ref()), + ); if item_entry > 0 && is_represented_bag_slot(bag_slot) { if let Some(bag_item_guid) = self .inventory_items_like_cpp() @@ -4766,6 +4905,12 @@ impl WorldSession { inner_slot, ); item_object.set_create_played_time(item_played_time); + apply_loaded_item_instance_fields_like_cpp( + &mut item_object, + &item_create_enchantments, + random_properties_id, + random_properties_seed, + ); item_object.replace_all_item_flags( ItemFieldFlags::from_bits_retain(item_flags), ); @@ -4793,8 +4938,9 @@ impl WorldSession { dynamic_flags: item_flags, durability: item_durability, max_durability: item_max_durability, - random_properties_seed: 0, - random_properties_id: 0, + random_properties_seed, + random_properties_id, + enchantments: item_create_enchantments, context: item_context as u8, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -5239,6 +5385,12 @@ impl WorldSession { if loaded_skill_records_like_cpp { self.set_player_skill_records_like_cpp(skill_records); } + let mut loaded_enchantment_updates = + LoadedEquippedItemEnchantmentsOutcomeLikeCpp::default(); + for item_guid in loaded_equipped_item_guids { + loaded_enchantment_updates + .append(self.apply_loaded_equipped_item_enchantments_like_cpp(item_guid)); + } // ── Load talents from character_talent ── // C++ `Player::LoadFromDB` calls `_LoadTalents` before `_LoadSpells`; @@ -5778,6 +5930,7 @@ impl WorldSession { self.account_mount_rows_like_cpp(), ) .await; + self.send_loaded_equipped_item_enchantment_updates_like_cpp(&loaded_enchantment_updates); self.apply_represented_login_spell_reset_if_needed_like_cpp(); self.apply_represented_login_talent_reset_if_needed_like_cpp(); if self.apply_represented_first_login_flag_if_needed_like_cpp() { @@ -10515,6 +10668,7 @@ impl WorldSession { max_durability, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: 0, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -11064,6 +11218,7 @@ impl WorldSession { max_durability, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: 0, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -11563,6 +11718,7 @@ impl WorldSession { max_durability: stack.max_durability, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: 0, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -14130,6 +14286,223 @@ mod tests { }; use wow_packet::packets::quest::quest_giver_status; + fn test_item_enchantments_db_string(entries: &[(usize, i32, u32, i16)]) -> String { + let mut fields = vec!["0".to_string(); wow_entities::MAX_ENCHANTMENT_SLOT * 3]; + for &(slot, id, duration, charges) in entries { + let base = slot * 3; + fields[base] = id.to_string(); + fields[base + 1] = duration.to_string(); + fields[base + 2] = charges.to_string(); + } + fields.join(" ") + } + + #[test] + fn loaded_item_instance_fields_preserve_enchantments_and_random_suffix_like_cpp() { + let mut item = wow_entities::Item::default(); + let enchantments = test_item_enchantments_db_string(&[ + (EnchantmentSlot::EnhancementPermanent as usize, 2673, 0, 0), + ( + EnchantmentSlot::EnhancementTemporary as usize, + 3826, + 30_000, + 3, + ), + (EnchantmentSlot::Property2 as usize, 901, 0, 0), + ]); + let enchantments = + loaded_item_enchantments_like_cpp(&enchantments).expect("valid C++ enchantment string"); + let effective_enchantments = + loaded_item_effective_enchantments_like_cpp(Some(&enchantments), -77, None, None); + + apply_loaded_item_instance_fields_like_cpp(&mut item, &effective_enchantments, -77, 456); + + assert_eq!(item.data().random_properties_id, -77); + assert_eq!(item.data().property_seed, 456); + assert_eq!( + item.data().enchantments[EnchantmentSlot::EnhancementPermanent as usize].id, + 2673 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::EnhancementTemporary as usize].duration, + 30_000 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::EnhancementTemporary as usize].charges, + 3 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property2 as usize].id, + 901 + ); + } + + #[test] + fn loaded_item_instance_fields_ignore_short_enchantment_string_like_cpp() { + let mut item = wow_entities::Item::default(); + let enchantments = loaded_item_enchantments_like_cpp("2673 0 0"); + assert!(enchantments.is_none()); + let effective_enchantments = + loaded_item_effective_enchantments_like_cpp(enchantments.as_ref(), 0, None, None); + + apply_loaded_item_instance_fields_like_cpp(&mut item, &effective_enchantments, 0, 0); + + assert!( + item.data() + .enchantments + .iter() + .all(|enchantment| *enchantment == wow_entities::ItemEnchantment::default()) + ); + assert_eq!(item.data().random_properties_id, 0); + assert_eq!(item.data().property_seed, 0); + } + + #[test] + fn loaded_item_instance_fields_rebuild_random_suffix_slots_like_cpp() { + let mut item = wow_entities::Item::default(); + let suffixes = + wow_data::ItemRandomSuffixStore::from_entries([wow_data::ItemRandomSuffixEntry { + id: 77, + enchantments: [901, 902, 903, 904, 905], + allocation_pct: [1000, 2000, 3000, 4000, 5000], + }]); + let effective_enchantments = + loaded_item_effective_enchantments_like_cpp(None, -77, None, Some(&suffixes)); + + apply_loaded_item_instance_fields_like_cpp(&mut item, &effective_enchantments, -77, 456); + + assert_eq!(item.data().random_properties_id, -77); + assert_eq!(item.data().property_seed, 456); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property0 as usize].id, + 901 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property1 as usize].id, + 902 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property2 as usize].id, + 903 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property3 as usize].id, + 0 + ); + } + + #[test] + fn loaded_item_instance_fields_rebuild_random_property_slots_like_cpp() { + let mut item = wow_entities::Item::default(); + let properties = wow_data::ItemRandomPropertiesStore::from_entries([ + wow_data::ItemRandomPropertiesEntry { + id: 77, + enchantments: [1001, 1002, 1003, 1004, 1005], + }, + ]); + let effective_enchantments = + loaded_item_effective_enchantments_like_cpp(None, 77, Some(&properties), None); + + apply_loaded_item_instance_fields_like_cpp(&mut item, &effective_enchantments, 77, 0); + + assert_eq!(item.data().random_properties_id, 77); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property0 as usize].id, + 0 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property2 as usize].id, + 1001 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property3 as usize].id, + 1002 + ); + assert_eq!( + item.data().enchantments[EnchantmentSlot::Property4 as usize].id, + 1003 + ); + } + + #[test] + fn loaded_item_instance_fields_rebuild_random_property_slots_for_zero_db_string_like_cpp() { + let properties = wow_data::ItemRandomPropertiesStore::from_entries([ + wow_data::ItemRandomPropertiesEntry { + id: 77, + enchantments: [1001, 1002, 1003, 0, 0], + }, + ]); + let enchantments = test_item_enchantments_db_string(&[]); + let enchantments = + loaded_item_enchantments_like_cpp(&enchantments).expect("valid C++ enchantment string"); + + let effective_enchantments = loaded_item_effective_enchantments_like_cpp( + Some(&enchantments), + 77, + Some(&properties), + None, + ); + + assert_eq!( + effective_enchantments[EnchantmentSlot::Property2 as usize].id, + 1001 + ); + assert_eq!( + effective_enchantments[EnchantmentSlot::Property3 as usize].id, + 1002 + ); + assert_eq!( + effective_enchantments[EnchantmentSlot::Property4 as usize].id, + 1003 + ); + } + + #[test] + fn loaded_item_db_enchantments_override_random_property_slots_like_cpp() { + let properties = wow_data::ItemRandomPropertiesStore::from_entries([ + wow_data::ItemRandomPropertiesEntry { + id: 77, + enchantments: [1001, 1002, 1003, 0, 0], + }, + ]); + let enchantments = + test_item_enchantments_db_string(&[(EnchantmentSlot::Property2 as usize, 555, 0, 0)]); + let enchantments = + loaded_item_enchantments_like_cpp(&enchantments).expect("valid C++ enchantment string"); + + let effective_enchantments = loaded_item_effective_enchantments_like_cpp( + Some(&enchantments), + 77, + Some(&properties), + None, + ); + + assert_eq!( + effective_enchantments[EnchantmentSlot::Property2 as usize].id, + 555 + ); + assert_eq!( + effective_enchantments[EnchantmentSlot::Property3 as usize].id, + 0 + ); + } + + #[test] + fn loaded_item_slots_apply_equipped_enchantments_for_equipment_and_profession_like_cpp() { + assert!(loaded_item_slot_applies_equipped_enchantments_like_cpp( + EQUIPMENT_SLOT_END - 1 + )); + assert!(loaded_item_slot_applies_equipped_enchantments_like_cpp( + PROFESSION_SLOT_START + )); + assert!(loaded_item_slot_applies_equipped_enchantments_like_cpp( + PROFESSION_SLOT_END - 1 + )); + assert!(!loaded_item_slot_applies_equipped_enchantments_like_cpp( + INVENTORY_SLOT_BAG_START + )); + } + #[test] fn sql_creature_template_speed_defaults_match_cpp_check_creature_template() { assert_eq!(normalize_creature_template_speed_walk_like_cpp(0.0), 1.0); diff --git a/crates/wow-world/src/handlers/loot.rs b/crates/wow-world/src/handlers/loot.rs index e296da1e..fd8e0c87 100644 --- a/crates/wow-world/src/handlers/loot.rs +++ b/crates/wow-world/src/handlers/loot.rs @@ -81,7 +81,7 @@ use wow_packet::packets::loot::{ LootResponse, LootRoll, LootRollBroadcast, LootRollWon, LootUnit, MasterLootCandidateList, MasterLootItem, NotNormalLootItem, SLootRelease, SetLootSpecialization, StartLootRoll, }; -use wow_packet::packets::update::{ItemCreateData, UpdateObject}; +use wow_packet::packets::update::{ItemCreateData, ItemEnchantmentValuesUpdate, UpdateObject}; use wow_packet::{ClientPacket, ServerPacket}; use crate::session::{ @@ -6821,6 +6821,7 @@ impl WorldSession { max_durability: stack.max_durability, random_properties_seed: stack.random_properties_seed, random_properties_id: stack.random_properties_id, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: stack.item_context, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], diff --git a/crates/wow-world/src/handlers/quest.rs b/crates/wow-world/src/handlers/quest.rs index d2b5bf95..f5956e07 100644 --- a/crates/wow-world/src/handlers/quest.rs +++ b/crates/wow-world/src/handlers/quest.rs @@ -57,7 +57,7 @@ use wow_packet::packets::quest::{ QuestRewardsBlock, QuestUpdateComplete, WorldQuestUpdateResponse, quest_giver_status, quest_push_reason, }; -use wow_packet::packets::update::{ItemCreateData, UpdateObject}; +use wow_packet::packets::update::{ItemCreateData, ItemEnchantmentValuesUpdate, UpdateObject}; use wow_packet::{ClientPacket, ServerPacket}; use crate::conditions::{ @@ -1869,6 +1869,7 @@ impl WorldSession { max_durability: stack.max_durability, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: ItemContext::None as u8, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], @@ -2228,6 +2229,7 @@ impl WorldSession { max_durability: stack.max_durability, random_properties_seed: 0, random_properties_id: 0, + enchantments: [ItemEnchantmentValuesUpdate::default(); 13], context: ItemContext::QuestReward as u8, container_slots: 0, container_item_guids: [ObjectGuid::EMPTY; 36], diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index 7c70d3f7..1c740bc8 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -129,11 +129,12 @@ use wow_database::{ SqlTransaction, StatementDef, WorldDatabase, }; use wow_entities::{ - AccessorObjectKind, ActiveState, ApplyEnchantmentArgs, ApplyEnchantmentEffectAction, - ApplyEnchantmentEffectRef, ApplyEnchantmentPlan, ApplyEnchantmentRandomSuffixRef, - ApplyEnchantmentTemplateRef, BANK_SLOT_BAG_END, BANK_SLOT_BAG_START, BUYBACK_SLOT_COUNT, - BUYBACK_SLOT_END, BUYBACK_SLOT_START, BagTemplateRef, CanEquipItemArgs, CanEquipUniqueItemArgs, - CanStoreItemArgs, CanUnequipItemArgs, CanUseItemArgs, CanUseItemTemplateArgs, + AccessorObjectKind, ActiveState, ApplyEnchantmentArgs, ApplyEnchantmentDurationAction, + ApplyEnchantmentEffectAction, ApplyEnchantmentEffectRef, ApplyEnchantmentPlan, + ApplyEnchantmentRandomSuffixRef, ApplyEnchantmentResult, ApplyEnchantmentTemplateRef, + BANK_SLOT_BAG_END, BANK_SLOT_BAG_START, BUYBACK_SLOT_COUNT, BUYBACK_SLOT_END, + BUYBACK_SLOT_START, BagTemplateRef, CanEquipItemArgs, CanEquipUniqueItemArgs, CanStoreItemArgs, + CanUnequipItemArgs, CanUseItemArgs, CanUseItemTemplateArgs, CreatureAddonLifecycleRecordLikeCpp, EQUIPMENT_SLOT_BACK, EQUIPMENT_SLOT_BODY, EQUIPMENT_SLOT_CHEST, EQUIPMENT_SLOT_END, EQUIPMENT_SLOT_FEET, EQUIPMENT_SLOT_FINGER1, EQUIPMENT_SLOT_FINGER2, EQUIPMENT_SLOT_HANDS, EQUIPMENT_SLOT_HEAD, EQUIPMENT_SLOT_LEGS, @@ -3241,6 +3242,29 @@ impl RepresentedCharacterSpellChargeLikeCpp { } } +#[derive(Debug, Clone, Default)] +pub(crate) struct LoadedEquippedItemEnchantmentsOutcomeLikeCpp { + pub plans: Vec, + pub duration_updates: Vec, + pub send_stat_update: bool, + pub visible_item_changes: Vec<(u8, i32, u16, u16)>, + pub effect_actions: Vec, + pub unrepresented_effect_actions: Vec, +} + +impl LoadedEquippedItemEnchantmentsOutcomeLikeCpp { + pub(crate) fn append(&mut self, mut other: Self) { + self.plans.append(&mut other.plans); + self.duration_updates.append(&mut other.duration_updates); + self.send_stat_update |= other.send_stat_update; + self.visible_item_changes + .append(&mut other.visible_item_changes); + self.effect_actions.append(&mut other.effect_actions); + self.unrepresented_effect_actions + .append(&mut other.unrepresented_effect_actions); + } +} + #[allow(dead_code)] fn represented_skill_records_from_values_like_cpp( skill_values: &HashMap, @@ -16438,6 +16462,26 @@ impl WorldSession { true } + fn represented_item_bonus_action_updates_stats_like_cpp( + action: ApplyEnchantmentEffectAction, + ) -> bool { + matches!( + action, + ApplyEnchantmentEffectAction::UnitModifier { .. } + | ApplyEnchantmentEffectAction::UpdateStatBuffMod(_) + | ApplyEnchantmentEffectAction::RatingModifier { .. } + | ApplyEnchantmentEffectAction::ManaRegenBonus { .. } + | ApplyEnchantmentEffectAction::SpellPowerBonus { .. } + | ApplyEnchantmentEffectAction::HealthRegenBonus { .. } + | ApplyEnchantmentEffectAction::SpellPenetrationBonus { .. } + | ApplyEnchantmentEffectAction::BaseModFlatValue { .. } + | ApplyEnchantmentEffectAction::SetShieldBlockValue { .. } + | ApplyEnchantmentEffectAction::SetBaseWeaponDamage { .. } + | ApplyEnchantmentEffectAction::SetBaseAttackTime { .. } + | ApplyEnchantmentEffectAction::UpdateDamagePhysical { .. } + ) + } + fn apply_represented_item_bonus_action_state_like_cpp( &mut self, action: ApplyEnchantmentEffectAction, @@ -20312,6 +20356,209 @@ impl WorldSession { plan } + /// C++ `Player::_LoadInventory` finishes by `_ApplyAllItemMods`, which in + /// turn calls `ApplyEnchantment(m_items[i], true)` for equipped items. + pub(crate) fn apply_loaded_equipped_item_enchantments_like_cpp( + &mut self, + item_guid: ObjectGuid, + ) -> LoadedEquippedItemEnchantmentsOutcomeLikeCpp { + let slots = self + .inventory_item_objects_like_cpp() + .get(&item_guid) + .map(|item| { + item.data() + .enchantments + .iter() + .enumerate() + .filter_map(|(slot_index, enchantment)| { + if enchantment.id == 0 { + return None; + } + ::from_usize(slot_index) + }) + .collect::>() + }) + .unwrap_or_default(); + + let mut outcome = LoadedEquippedItemEnchantmentsOutcomeLikeCpp::default(); + for slot in slots { + if let Some(plan) = self.apply_current_player_item_enchantment_plan_like_cpp( + item_guid, + slot, + ApplyEnchantmentArgs::apply(), + ) { + if let ApplyEnchantmentResult::Applied { + enchantment_id, + apply, + effects_allowed, + update_permanent_visible_item, + duration_action, + .. + } = plan.result + { + if let Some(ApplyEnchantmentDurationAction::Added(duration_update)) = + duration_action + { + outcome.duration_updates.push(duration_update); + } + if effects_allowed { + let (changed_stats, mut effect_actions, mut unrepresented_effect_actions) = + self.apply_loaded_equipped_item_enchantment_effects_like_cpp( + item_guid, + slot, + enchantment_id, + apply, + ); + outcome.send_stat_update |= changed_stats; + outcome.effect_actions.append(&mut effect_actions); + outcome + .unrepresented_effect_actions + .append(&mut unrepresented_effect_actions); + } + if update_permanent_visible_item + && let Some(visible_item_update) = + self.loaded_inventory_item_visible_update_like_cpp(item_guid) + { + outcome.visible_item_changes.push(visible_item_update); + } + } + outcome.plans.push(plan); + } + } + outcome + } + + pub(crate) fn send_loaded_equipped_item_enchantment_updates_like_cpp( + &self, + outcome: &LoadedEquippedItemEnchantmentsOutcomeLikeCpp, + ) { + if let Some(owner_guid) = self.player_guid() { + self.send_item_enchant_time_update_plans(owner_guid, &outcome.duration_updates); + } + if !outcome.visible_item_changes.is_empty() { + self.send_player_values_update_from_entity_bridge( + &[], + &outcome.visible_item_changes, + &[], + &[], + None, + ); + } + if outcome.send_stat_update { + self.send_represented_item_bonus_player_stat_update_like_cpp(); + } + } + + pub(crate) fn loaded_inventory_item_visible_fields_like_cpp( + &self, + item: &Item, + ) -> (i32, u16, u16) { + ( + item.object().entry() as i32, + 0, + item.visible_item_visual(0, |enchantment_id| { + self.spell_item_enchantment_store() + .and_then(|store| store.get(enchantment_id)) + .map(|entry| entry.item_visual) + }), + ) + } + + fn loaded_inventory_item_visible_update_like_cpp( + &self, + item_guid: ObjectGuid, + ) -> Option<(u8, i32, u16, u16)> { + let item = self.inventory_item_objects_like_cpp().get(&item_guid)?; + let slot = item.slot(); + if slot >= EQUIPMENT_SLOT_END { + return None; + } + let (item_id, appearance_mod_id, item_visual) = + self.loaded_inventory_item_visible_fields_like_cpp(item); + Some((slot, item_id, appearance_mod_id, item_visual)) + } + + fn apply_loaded_equipped_item_enchantment_effects_like_cpp( + &mut self, + item_guid: ObjectGuid, + slot: EnchantmentSlot, + enchantment_id: i32, + apply: bool, + ) -> ( + bool, + Vec, + Vec, + ) { + let Some(item) = self + .inventory_item_objects_like_cpp() + .get(&item_guid) + .cloned() + else { + return (false, Vec::new(), Vec::new()); + }; + let Some(effects) = u32::try_from(enchantment_id) + .ok() + .and_then(|enchantment_id| self.apply_enchantment_effect_refs(enchantment_id)) + else { + return (false, Vec::new(), Vec::new()); + }; + let item_template = self.item_storage_template(item.object().entry()); + let random_suffix = + self.apply_enchantment_random_suffix_ref(item.data().random_properties_id); + let actions = self + .mutate_canonical_player_like_cpp(|player| { + player.apply_enchantment_effect_actions_for_enchantment( + &item, + item_template.as_ref(), + slot, + enchantment_id, + random_suffix, + apply, + &effects, + ) + }) + .unwrap_or_default(); + + let mut changed_stats = false; + let mut represented_actions = Vec::new(); + let mut unrepresented_actions = Vec::new(); + for action in actions { + if matches!(action, ApplyEnchantmentEffectAction::Noop) { + continue; + } + changed_stats |= Self::represented_item_bonus_action_updates_stats_like_cpp(action); + let represented_action = RepresentedItemBonusActionLikeCpp { + item_guid, + slot: slot as u8, + action, + }; + self.represented_item_bonus_actions_like_cpp + .push(represented_action.clone()); + if Self::loaded_enchantment_effect_action_is_unrepresented_like_cpp(action) { + unrepresented_actions.push(represented_action.clone()); + } + represented_actions.push(represented_action); + self.apply_represented_item_bonus_action_state_like_cpp(action); + } + (changed_stats, represented_actions, unrepresented_actions) + } + + fn loaded_enchantment_effect_action_is_unrepresented_like_cpp( + action: ApplyEnchantmentEffectAction, + ) -> bool { + matches!( + action, + ApplyEnchantmentEffectAction::DeferredCombatSpell + | ApplyEnchantmentEffectAction::DeferredUseSpell + | ApplyEnchantmentEffectAction::UpdateDamageDoneMods { .. } + | ApplyEnchantmentEffectAction::CastEquipSpell { .. } + | ApplyEnchantmentEffectAction::RemoveEquipSpellAura { .. } + | ApplyEnchantmentEffectAction::UnhandledStatModifier { .. } + | ApplyEnchantmentEffectAction::MissingItemTemplateForAttack { .. } + | ApplyEnchantmentEffectAction::Unknown { .. } + ) + } + /// Set the hotfix blob cache for this session. pub fn set_hotfix_blob_cache(&mut self, cache: Arc) { self.hotfix_blob_cache = Some(cache); @@ -25998,12 +26245,7 @@ impl WorldSession { let class = self.player_class_like_cpp(); let gender = self.player_gender_like_cpp(); let level = self.player_level_like_cpp(); - let mut visible_items = [(0i32, 0u16, 0u16); 19]; - for (slot, item) in self.inventory_items_like_cpp() { - if (*slot as usize) < 19 { - visible_items[*slot as usize] = (item.entry_id as i32, 0u16, 0u16); - } - } + let visible_items = self.loaded_player_visible_items_for_create_like_cpp(); // Fallback to 0 (world/default instance) when no canonical map key is // available — mirrors C++ world-map phase where instance_id == 0. let instance_id = self @@ -48696,6 +48938,22 @@ impl WorldSession { .unwrap_or(RuntimeTickOwner::Session) } + fn loaded_player_visible_items_for_create_like_cpp(&self) -> [(i32, u16, u16); 19] { + let mut visible_items = [(0i32, 0u16, 0u16); 19]; + for (slot, item) in self.inventory_items_like_cpp() { + if (*slot as usize) < 19 { + visible_items[*slot as usize] = self + .inventory_item_objects_like_cpp() + .get(&item.guid) + .map(|item_object| { + self.loaded_inventory_item_visible_fields_like_cpp(item_object) + }) + .unwrap_or((item.entry_id as i32, 0u16, 0u16)); + } + } + visible_items + } + /// Broadcast the newly logged-in player's CREATE block to nearby players on the same map. /// /// Called after login is complete. Iterates through all players in the registry @@ -48730,12 +48988,7 @@ impl WorldSession { let level = self.player_level_like_cpp(); // Build visible_items from this player's equipped inventory. - let mut visible_items = [(0i32, 0u16, 0u16); 19]; - for (slot, item) in self.inventory_items_like_cpp() { - if (*slot as usize) < 19 { - visible_items[*slot as usize] = (item.entry_id as i32, 0u16, 0u16); - } - } + let visible_items = self.loaded_player_visible_items_for_create_like_cpp(); let empty_inv_slots = [ObjectGuid::EMPTY; 141]; let empty_skills = Vec::new(); @@ -84301,6 +84554,58 @@ mod tests { } } + #[test] + fn loaded_player_visible_items_for_create_includes_loaded_enchant_visual_like_cpp() { + let (mut session, _, _) = make_session(); + let player_guid = ObjectGuid::create_player(1, 90_526); + let item_guid = ObjectGuid::create_item(1, 90_527); + session.set_player_guid(Some(player_guid)); + session.set_spell_item_enchantment_store(Arc::new( + SpellItemEnchantmentStore::from_entries([SpellItemEnchantmentEntry { + id: 908, + effect_arg: [0; 3], + effect_points_min: [0; 3], + item_visual: 44, + flags: SpellItemEnchantmentFlags::empty(), + required_skill_id: 0, + required_skill_rank: 0, + item_level: 1, + charges: 0, + effect: [ItemEnchantmentType::None as u8; 3], + condition_id: 0, + min_level: 1, + max_level: 0, + }]), + )); + session.insert_inventory_item_like_cpp( + EQUIPMENT_SLOT_MAINHAND, + InventoryItem { + guid: item_guid, + entry_id: 700, + db_guid: 90_527, + inventory_type: Some(InventoryType::Weapon as u8), + }, + ); + let mut item = session.make_inventory_item_object( + item_guid, + 700, + player_guid, + 1, + 0, + ItemContext::None, + EQUIPMENT_SLOT_MAINHAND, + ); + item.set_enchantment(EnchantmentSlot::EnhancementPermanent, 908, 0, 0); + session.insert_inventory_item_object(item); + + let visible_items = session.loaded_player_visible_items_for_create_like_cpp(); + + assert_eq!( + visible_items[EQUIPMENT_SLOT_MAINHAND as usize], + (700, 0, 44) + ); + } + #[test] fn create_player_broadcast_skips_out_of_visibility_range_like_cpp() { let (mut session, _, _) = make_session(); @@ -116239,6 +116544,293 @@ mod tests { ); } + #[test] + fn loaded_equipped_item_enchantments_apply_and_send_durations_like_cpp() { + let (mut session, _, send_rx) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 90_520); + let player_position = Position::new(1.0, 2.0, 3.0, 0.0); + let item_guid = ObjectGuid::create_item(1, 90_521); + session.set_player_guid(Some(player_guid)); + session.set_loaded_player_identity_like_cpp(571, 1, 1, 80, 0); + session.set_canonical_map_manager(Arc::clone(&canonical)); + add_canonical_test_player_on_map(&canonical, player_guid, player_position, 571, 0); + session + .mutate_canonical_player_like_cpp(|player| player.unit_mut().set_level(80)) + .unwrap(); + session.set_spell_item_enchantment_store(Arc::new( + SpellItemEnchantmentStore::from_entries([ + SpellItemEnchantmentEntry { + id: 903, + effect_arg: [0; 3], + effect_points_min: [0; 3], + item_visual: 0, + flags: SpellItemEnchantmentFlags::empty(), + required_skill_id: 0, + required_skill_rank: 0, + item_level: 1, + charges: 0, + effect: [ItemEnchantmentType::None as u8; 3], + condition_id: 0, + min_level: 1, + max_level: 0, + }, + SpellItemEnchantmentEntry { + id: 904, + effect_arg: [0; 3], + effect_points_min: [0; 3], + item_visual: 44, + flags: SpellItemEnchantmentFlags::empty(), + required_skill_id: 0, + required_skill_rank: 0, + item_level: 1, + charges: 0, + effect: [ItemEnchantmentType::None as u8; 3], + condition_id: 0, + min_level: 1, + max_level: 0, + }, + ]), + )); + + let mut item = session.make_inventory_item_object( + item_guid, + 700, + player_guid, + 1, + 0, + ItemContext::None, + EQUIPMENT_SLOT_MAINHAND, + ); + item.set_enchantment(EnchantmentSlot::EnhancementPermanent, 904, 0, 0); + item.set_enchantment(EnchantmentSlot::EnhancementTemporary, 903, 6_000, 0); + session.insert_inventory_item_object(item); + + let outcome = session.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); + + assert_eq!(outcome.plans.len(), 2); + assert!(outcome.plans.iter().any(|plan| matches!( + plan.result, + ApplyEnchantmentResult::Applied { + item_guid: applied_item_guid, + slot: EnchantmentSlot::EnhancementPermanent, + enchantment_id: 904, + apply: true, + update_permanent_visible_item: true, + .. + } if applied_item_guid == item_guid + ))); + assert!(outcome.plans.iter().any(|plan| matches!( + plan.result, + ApplyEnchantmentResult::Applied { + item_guid: applied_item_guid, + slot: EnchantmentSlot::EnhancementTemporary, + enchantment_id: 903, + apply: true, + duration_action: Some(ApplyEnchantmentDurationAction::Added( + PlayerEnchantTimeUpdate { + item_guid: duration_item_guid, + slot: EnchantmentSlot::EnhancementTemporary, + duration_secs: 6, + } + )), + .. + } if applied_item_guid == item_guid && duration_item_guid == item_guid + ))); + assert_eq!( + outcome.visible_item_changes, + vec![(EQUIPMENT_SLOT_MAINHAND, 700, 0, 44)] + ); + assert_eq!( + outcome.duration_updates, + vec![PlayerEnchantTimeUpdate { + item_guid, + slot: EnchantmentSlot::EnhancementTemporary, + duration_secs: 6, + }] + ); + assert_eq!( + session + .canonical_player_snapshot_like_cpp(|player| player.enchant_durations().to_vec()), + Some(vec![PlayerEnchantDuration { + item_guid, + slot: EnchantmentSlot::EnhancementTemporary, + left_duration_ms: 6_000, + }]) + ); + assert!( + send_rx.try_recv().is_err(), + "loaded enchant replay queues packets until after login CREATE" + ); + session.send_loaded_equipped_item_enchantment_updates_like_cpp(&outcome); + assert_eq!( + send_rx.try_recv().unwrap(), + ItemEnchantTimeUpdate { + owner_guid: player_guid, + item_guid, + duration_left: 6, + slot: EnchantmentSlot::EnhancementTemporary as u32, + } + .to_bytes() + ); + assert!( + drain_server_packet_bytes(&send_rx) + .iter() + .any(|bytes| WorldPacket::from_bytes(bytes).server_opcode() + == Some(ServerOpcodes::UpdateObject)), + "permanent enchant visual update is emitted after login CREATE" + ); + } + + #[test] + fn loaded_equipped_item_enchantments_apply_effect_actions_like_cpp() { + let (mut session, _, send_rx) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 90_522); + let player_position = Position::new(1.0, 2.0, 3.0, 0.0); + let item_guid = ObjectGuid::create_item(1, 90_523); + session.set_player_guid(Some(player_guid)); + session.set_loaded_player_identity_like_cpp(571, 1, 1, 80, 0); + session.set_canonical_map_manager(Arc::clone(&canonical)); + add_canonical_test_player_on_map(&canonical, player_guid, player_position, 571, 0); + session + .mutate_canonical_player_like_cpp(|player| player.unit_mut().set_level(80)) + .unwrap(); + session.set_spell_item_enchantment_store(Arc::new( + SpellItemEnchantmentStore::from_entries([SpellItemEnchantmentEntry { + id: 906, + effect_arg: [ItemModType::Health as u32, 0, 0], + effect_points_min: [17, 0, 0], + item_visual: 0, + flags: SpellItemEnchantmentFlags::empty(), + required_skill_id: 0, + required_skill_rank: 0, + item_level: 1, + charges: 0, + effect: [ + ItemEnchantmentType::Stat as u8, + ItemEnchantmentType::None as u8, + ItemEnchantmentType::None as u8, + ], + condition_id: 0, + min_level: 1, + max_level: 0, + }]), + )); + + let mut item = session.make_inventory_item_object( + item_guid, + 701, + player_guid, + 1, + 0, + ItemContext::None, + EQUIPMENT_SLOT_CHEST, + ); + item.set_enchantment(EnchantmentSlot::EnhancementPermanent, 906, 0, 0); + session.insert_inventory_item_object(item); + + let outcome = session.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); + + assert!(matches!( + outcome.plans.first().map(|plan| plan.result), + Some(ApplyEnchantmentResult::Applied { + enchantment_id: 906, + apply: true, + effects_allowed: true, + .. + }) + )); + assert_eq!( + session.represented_item_bonus_state_like_cpp().health_base, + 17 + ); + assert_eq!(outcome.effect_actions.len(), 1); + assert!(matches!( + outcome.effect_actions[0].action, + ApplyEnchantmentEffectAction::UnitModifier { .. } + )); + assert!(outcome.unrepresented_effect_actions.is_empty()); + assert!(outcome.send_stat_update); + assert!( + send_rx.try_recv().is_err(), + "loaded enchant stat update is queued until after login CREATE" + ); + session.send_loaded_equipped_item_enchantment_updates_like_cpp(&outcome); + assert!( + drain_server_packet_bytes(&send_rx) + .iter() + .any(|bytes| WorldPacket::from_bytes(bytes).server_opcode() + == Some(ServerOpcodes::UpdateObject)), + "loaded enchantment effects send a represented player stat update" + ); + } + + #[test] + fn loaded_equipped_item_enchantments_records_unrepresented_effect_actions_like_cpp() { + let (mut session, _, _) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 90_524); + let player_position = Position::new(1.0, 2.0, 3.0, 0.0); + let item_guid = ObjectGuid::create_item(1, 90_525); + session.set_player_guid(Some(player_guid)); + session.set_loaded_player_identity_like_cpp(571, 1, 1, 80, 0); + session.set_canonical_map_manager(Arc::clone(&canonical)); + add_canonical_test_player_on_map(&canonical, player_guid, player_position, 571, 0); + session + .mutate_canonical_player_like_cpp(|player| player.unit_mut().set_level(80)) + .unwrap(); + session.set_spell_item_enchantment_store(Arc::new( + SpellItemEnchantmentStore::from_entries([SpellItemEnchantmentEntry { + id: 907, + effect_arg: [1234, 0, 0], + effect_points_min: [0, 0, 0], + item_visual: 0, + flags: SpellItemEnchantmentFlags::empty(), + required_skill_id: 0, + required_skill_rank: 0, + item_level: 1, + charges: 0, + effect: [ + ItemEnchantmentType::EquipSpell as u8, + ItemEnchantmentType::None as u8, + ItemEnchantmentType::None as u8, + ], + condition_id: 0, + min_level: 1, + max_level: 0, + }]), + )); + + let mut item = session.make_inventory_item_object( + item_guid, + 702, + player_guid, + 1, + 0, + ItemContext::None, + EQUIPMENT_SLOT_CHEST, + ); + item.set_enchantment(EnchantmentSlot::EnhancementPermanent, 907, 0, 0); + session.insert_inventory_item_object(item); + + let outcome = session.apply_loaded_equipped_item_enchantments_like_cpp(item_guid); + + assert!(matches!( + outcome.effect_actions.first().map(|action| action.action), + Some(ApplyEnchantmentEffectAction::CastEquipSpell { + spell_id: 1234, + item_guid: action_item_guid, + }) if action_item_guid == item_guid + )); + assert_eq!(outcome.unrepresented_effect_actions, outcome.effect_actions); + assert!(!outcome.send_stat_update); + assert_eq!( + session.represented_item_bonus_actions_like_cpp(), + outcome.effect_actions.as_slice() + ); + } + #[test] fn send_new_item_plan_maps_entity_fields_to_item_push_result_like_cpp() { let plan = send_new_item_plan(SendNewItemDelivery::Direct); diff --git a/docs/migration/EXISTING-CODE-DEFECTS.md b/docs/migration/EXISTING-CODE-DEFECTS.md index 07048063..625913e9 100644 --- a/docs/migration/EXISTING-CODE-DEFECTS.md +++ b/docs/migration/EXISTING-CODE-DEFECTS.md @@ -22,8 +22,15 @@ mutates DB" ≠ "computes the right result / can't lose or dupe data." - [ ] **D-C1 Item enchantments not loaded on relog.** `SEL_CHAR_EQUIPMENT`/`SEL_CHAR_BAG_CONTENTS` select enchantment cols but the load hardcodes 0 → equipped/bagged enchants vanish on logout. `handlers/character.rs:4617-4618,4760-4761`. C++ `Player::_LoadInventory`. + - 2026-07-01 issue #20 local slice: Rust now selects `item_instance.enchantments` for the + specialized equipment/bag login queries, parses the 13x `(id,duration,charges)` fields like + C++ `Item::LoadFromDB`, applies them to runtime `Item` objects, and includes them in item + `CREATE_OBJECT` blocks. Kept open until capture-diff/live relog QA is run. - [ ] **D-C2 Item random properties not loaded on relog.** Same query gap → magical items become non-magical. `handlers/character.rs:4617`. + - 2026-07-01 issue #20 local slice: the same login path now loads `randomPropertiesId` and + `randomPropertiesSeed` for equipped and bagged items into runtime item state and login create + data. Kept open until capture-diff/live relog QA is run. - [ ] **D-C3 Bank contents never persisted.** Bank moves recorded in-memory only (`represented_bank_item_moves`), no DB write → 100% bank loss on logout. `session.rs:31575`. - [ ] **D-C4 Inventory swap not transactional.** Two separate `execute()` calls; mid-fail