diff --git a/crates/world-server/src/main.rs b/crates/world-server/src/main.rs index abc060bb..df32309a 100644 --- a/crates/world-server/src/main.rs +++ b/crates/world-server/src/main.rs @@ -1040,6 +1040,14 @@ async fn main() -> Result { .await .context("Failed to load AreaTable.db2 / hotfix rows")?, ); + let area_trigger_db2_store = Arc::new( + wow_data::AreaTriggerDb2Store::load(&data_dir, &locale) + .context("Failed to load AreaTrigger.db2 — check DataDir and DBC.Locale config")?, + ); + info!( + "Loaded {} area trigger DB2 rows from AreaTrigger.db2", + area_trigger_db2_store.len() + ); let fishing_base_skill_store = Arc::new( wow_data::FishingBaseSkillStoreLikeCpp::load(world_db.as_ref(), &area_table_store) .await @@ -2796,6 +2804,22 @@ async fn main() -> Result { .await .context("Failed to load area triggers")?, ); + let tavern_area_trigger_outcome = wow_data::TavernAreaTriggerStoreLikeCpp::load_like_cpp( + world_db.as_ref(), + area_trigger_db2_store.as_ref(), + ) + .await + .context("Failed to load C++ tavern area triggers")?; + let tavern_area_trigger_store = Arc::new(tavern_area_trigger_outcome.store); + info!( + "Loaded {} C++ tavern area triggers ({} rows seen; {} skipped missing AreaTrigger.db2)", + tavern_area_trigger_store.len(), + tavern_area_trigger_outcome.report.rows_seen, + tavern_area_trigger_outcome + .report + .skipped_missing_area_trigger + .len() + ); // Load quest store (templates + objectives + NPC relations) let quest_store = Arc::new( @@ -4545,7 +4569,9 @@ async fn main() -> Result { gameobject_template_lifecycle_store: Some(Arc::clone(&gameobject_template_lifecycle_store)), area_table_store: Some(Arc::clone(&area_table_store)), fishing_base_skill_store: Some(Arc::clone(&fishing_base_skill_store)), + area_trigger_db2_store: Some(Arc::clone(&area_trigger_db2_store)), area_trigger_store: Some(Arc::clone(&area_trigger_store)), + tavern_area_trigger_store: Some(Arc::clone(&tavern_area_trigger_store)), graveyard_store: Some(Arc::clone(&graveyard_store)), area_trigger_template_store: Some(Arc::clone(&area_trigger_template_store)), chr_specialization_store: Some(Arc::clone(&chr_specialization_store)), @@ -4599,6 +4625,18 @@ async fn main() -> Result { player_xp_table: Some(Arc::clone(&player_xp_table)), exploration_base_xp_store: Some(Arc::clone(&exploration_base_xp_store)), exploration_xp_rate: world_config_f32(&world_configs, "RATE_XP_EXPLORE", 1.0), + max_player_level_config: world_config_u32(&world_configs, "CONFIG_MAX_PLAYER_LEVEL", 80), + rest_offline_wilderness_rate: world_config_f32( + &world_configs, + "Rate.Rest.Offline.InWilderness", + 1.0, + ), + rest_offline_tavern_or_city_rate: world_config_f32( + &world_configs, + "Rate.Rest.Offline.InTavernOrCity", + 1.0, + ), + rest_ingame_rate: world_config_f32(&world_configs, "Rate.Rest.InGame", 1.0), min_quest_scaled_xp_ratio: world_config_u32( &world_configs, "CONFIG_MIN_QUEST_SCALED_XP_RATIO", @@ -11639,9 +11677,15 @@ async fn create_session( if let Some(ref store) = resources.fishing_base_skill_store { session.set_fishing_base_skill_store(Arc::clone(store)); } + if let Some(ref store) = resources.area_trigger_db2_store { + session.set_area_trigger_db2_store(Arc::clone(store)); + } if let Some(ref store) = resources.area_trigger_store { session.set_area_trigger_store(Arc::clone(store)); } + if let Some(ref store) = resources.tavern_area_trigger_store { + session.set_tavern_area_trigger_store(Arc::clone(store)); + } if let Some(ref store) = resources.graveyard_store { session.set_graveyard_store(Arc::clone(store)); } @@ -11796,6 +11840,12 @@ async fn create_session( session.set_exploration_base_xp_store_like_cpp(Arc::clone(store)); } session.set_exploration_xp_rate_like_cpp(resources.exploration_xp_rate); + session.set_rested_xp_config_like_cpp( + resources.max_player_level_config, + resources.rest_offline_wilderness_rate, + resources.rest_offline_tavern_or_city_rate, + resources.rest_ingame_rate, + ); session.set_min_quest_scaled_xp_ratio_like_cpp(resources.min_quest_scaled_xp_ratio); session.set_min_discovered_scaled_xp_ratio_like_cpp(resources.min_discovered_scaled_xp_ratio); if let Some(ref registry) = resources.player_registry { diff --git a/crates/wow-data/src/area.rs b/crates/wow-data/src/area.rs index adf9ab31..7e98a33d 100644 --- a/crates/wow-data/src/area.rs +++ b/crates/wow-data/src/area.rs @@ -36,6 +36,10 @@ pub struct FishingBaseSkillStoreLikeCpp { } pub const AREA_FLAG_ALLOW_HEARTH_AND_RESURRECT_FROM_AREA_LIKE_CPP: u32 = 0x0800_0000; +pub const AREA_FLAG_LINKED_CHAT_LIKE_CPP: u32 = 0x0000_0100; +pub const AREA_FLAG_NO_PVP_LIKE_CPP: u32 = 0x0000_0800; +pub const AREA_FLAG_HORDE_RESTING_LIKE_CPP: u32 = 0x0040_0000; +pub const AREA_FLAG_ALLIANCE_RESTING_LIKE_CPP: u32 = 0x0080_0000; pub const AREA_FLAG_IS_SUBZONE_LIKE_CPP: u32 = 0x4000_0000; impl AreaTableEntry { @@ -55,6 +59,22 @@ impl AreaTableEntry { self.flags & AREA_FLAG_ALLOW_HEARTH_AND_RESURRECT_FROM_AREA_LIKE_CPP != 0 } + pub fn linked_chat_like_cpp(&self) -> bool { + self.flags & AREA_FLAG_LINKED_CHAT_LIKE_CPP != 0 + } + + pub fn is_sanctuary_like_cpp(&self) -> bool { + self.flags & AREA_FLAG_NO_PVP_LIKE_CPP != 0 + } + + pub fn alliance_resting_like_cpp(&self) -> bool { + self.flags & AREA_FLAG_ALLIANCE_RESTING_LIKE_CPP != 0 + } + + pub fn horde_resting_like_cpp(&self) -> bool { + self.flags & AREA_FLAG_HORDE_RESTING_LIKE_CPP != 0 + } + pub fn is_subzone_like_cpp(&self) -> bool { self.flags & AREA_FLAG_IS_SUBZONE_LIKE_CPP != 0 } diff --git a/crates/wow-data/src/area_trigger.rs b/crates/wow-data/src/area_trigger.rs index c4f0e931..db112c1f 100644 --- a/crates/wow-data/src/area_trigger.rs +++ b/crates/wow-data/src/area_trigger.rs @@ -17,7 +17,7 @@ use wow_database::{WorldDatabase, WorldStatements}; use crate::quest::{ QUEST_FLAGS_COMPLETION_AREA_TRIGGER_LIKE_CPP, QUEST_OBJECTIVE_AREATRIGGER_LIKE_CPP, QuestStore, }; -use crate::{ScriptIdLikeCpp, ScriptNameInternerLikeCpp, WorldSafeLocStore}; +use crate::{AreaTriggerDb2Store, ScriptIdLikeCpp, ScriptNameInternerLikeCpp, WorldSafeLocStore}; /// Area trigger shape types (from AreaTriggerShapeType). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -725,7 +725,7 @@ impl TavernAreaTriggerStoreLikeCpp { /// - stores a set consumed by `ObjectMgr::IsTavernAreaTrigger` pub async fn load_like_cpp( db: &WorldDatabase, - area_trigger_store: &AreaTriggerStore, + area_trigger_db2_store: &AreaTriggerDb2Store, ) -> Result { let mut rows = Vec::new(); let mut result = db.direct_query("SELECT id FROM areatrigger_tavern").await?; @@ -739,7 +739,7 @@ impl TavernAreaTriggerStoreLikeCpp { } Ok(Self::from_ids_like_cpp(rows, |trigger_id| { - area_trigger_store.contains_trigger_like_cpp(trigger_id) + area_trigger_db2_store.get(trigger_id).is_some() })) } @@ -967,12 +967,32 @@ mod area_trigger_script_tests { #[test] fn tavern_area_trigger_store_validates_ids_like_cpp() { - let mut area_triggers = AreaTriggerStore::new(); - area_triggers.insert(trigger(10)); + let area_triggers = AreaTriggerDb2Store::from_entries([crate::AreaTriggerDb2Entry { + id: 10, + message: String::new(), + pos: crate::maps_world::Db2Position3 { + x: 0.0, + y: 0.0, + z: 0.0, + }, + continent_id: 0, + phase_use_flags: 0, + phase_id: 0, + phase_group_id: 0, + radius: 0.0, + box_length: 0.0, + box_width: 0.0, + box_height: 0.0, + box_yaw: 0.0, + shape_type: 0, + shape_id: 0, + area_trigger_action_set_id: 0, + flags: 0, + }]); let outcome = TavernAreaTriggerStoreLikeCpp::from_ids_like_cpp([10, 11, 10], |trigger_id| { - area_triggers.contains_trigger_like_cpp(trigger_id) + area_triggers.get(trigger_id).is_some() }); assert_eq!(outcome.report.rows_seen, 3); diff --git a/crates/wow-data/src/lib.rs b/crates/wow-data/src/lib.rs index f775d832..10431b66 100644 --- a/crates/wow-data/src/lib.rs +++ b/crates/wow-data/src/lib.rs @@ -99,8 +99,9 @@ pub use access_requirement::{ AccessRequirementLoadReportLikeCpp, AccessRequirementRowLikeCpp, AccessRequirementStoreLikeCpp, }; pub use area::{ - AREA_FLAG_ALLOW_HEARTH_AND_RESURRECT_FROM_AREA_LIKE_CPP, AreaTableEntry, AreaTableStore, - FishingBaseSkillStoreLikeCpp, + AREA_FLAG_ALLIANCE_RESTING_LIKE_CPP, AREA_FLAG_ALLOW_HEARTH_AND_RESURRECT_FROM_AREA_LIKE_CPP, + AREA_FLAG_HORDE_RESTING_LIKE_CPP, AREA_FLAG_LINKED_CHAT_LIKE_CPP, AREA_FLAG_NO_PVP_LIKE_CPP, + AreaTableEntry, AreaTableStore, FishingBaseSkillStoreLikeCpp, }; pub use area_trigger::{ AreaTriggerData, AreaTriggerScriptLoadOutcomeLikeCpp, AreaTriggerScriptLoadReportLikeCpp, diff --git a/crates/wow-data/src/spell.rs b/crates/wow-data/src/spell.rs index 96c8a407..435eb4b2 100644 --- a/crates/wow-data/src/spell.rs +++ b/crates/wow-data/src/spell.rs @@ -480,6 +480,7 @@ pub mod aura_types { pub const SPELL_AURA_MOD_BATTLE_PET_XP_PCT: i32 = 420; pub const SPELL_AURA_MOD_MINIMUM_SPEED_RATE: i32 = 437; pub const SPELL_AURA_MOD_ROOT_2: i32 = 455; + pub const SPELL_AURA_MOD_RESTED_XP_CONSUMPTION: i32 = 499; } /// Selected `Targets` ids from C++ `SpellImplicitTargetInfo::_data`. @@ -6837,6 +6838,7 @@ mod tests { assert_eq!(aura_types::SPELL_AURA_MOD_SPEED_NO_CONTROL, 373); assert_eq!(aura_types::SPELL_AURA_MOD_BATTLE_PET_XP_PCT, 420); assert_eq!(aura_types::SPELL_AURA_MOD_MINIMUM_SPEED_RATE, 437); + assert_eq!(aura_types::SPELL_AURA_MOD_RESTED_XP_CONSUMPTION, 499); // C++ `SharedDefines.h`: selected SpellAttr0 anchors. assert_eq!(attributes::SPELL_ATTR0_ONLY_INDOORS, 0x0000_4000); diff --git a/crates/wow-database/src/statements/character.rs b/crates/wow-database/src/statements/character.rs index 9596859d..2cf8df66 100644 --- a/crates/wow-database/src/statements/character.rs +++ b/crates/wow-database/src/statements/character.rs @@ -1180,6 +1180,13 @@ pub enum CharStatements { /// UPDATE characters SET power1 = ?, ..., power10 = ? WHERE guid = ? UPD_CHAR_POWERS, /// C++ `CHAR_UPD_CHARACTER` persists these fields in the full save. + /// UPDATE characters SET restState = ?, playerFlags = ?, rest_bonus = ?, logout_time = ?, is_logout_resting = ? WHERE guid = ? + UPD_CHAR_REST_STATE, + /// C++ `RestMgr::GetRestBonusFor` updates in-memory rest state during online XP gain; Rust + /// persists only those fields here and leaves logout_time/is_logout_resting untouched. + /// UPDATE characters SET restState = ?, playerFlags = ?, rest_bonus = ? WHERE guid = ? + UPD_CHAR_ONLINE_REST_STATE, + /// C++ `CHAR_UPD_CHARACTER` persists these fields in the full save. /// UPDATE characters SET resettalents_cost = ?, resettalents_time = ? WHERE guid = ? UPD_CHAR_TALENT_RESET_STATE, /// UPDATE characters SET xp = ? WHERE guid = ? @@ -2750,6 +2757,12 @@ impl StatementDef for CharStatements { Self::UPD_CHAR_POWERS => { "UPDATE characters SET power1 = ?, power2 = ?, power3 = ?, power4 = ?, power5 = ?, power6 = ?, power7 = ?, power8 = ?, power9 = ?, power10 = ? WHERE guid = ?" } + Self::UPD_CHAR_REST_STATE => { + "UPDATE characters SET restState = ?, playerFlags = ?, rest_bonus = ?, logout_time = ?, is_logout_resting = ? WHERE guid = ?" + } + Self::UPD_CHAR_ONLINE_REST_STATE => { + "UPDATE characters SET restState = ?, playerFlags = ?, rest_bonus = ? WHERE guid = ?" + } Self::UPD_CHAR_TALENT_RESET_STATE => { "UPDATE characters SET resettalents_cost = ?, resettalents_time = ? WHERE guid = ?" } @@ -4597,6 +4610,14 @@ mod tests { CharStatements::UPD_CHAR_POWERS.sql(), "UPDATE characters SET power1 = ?, power2 = ?, power3 = ?, power4 = ?, power5 = ?, power6 = ?, power7 = ?, power8 = ?, power9 = ?, power10 = ? WHERE guid = ?" ); + assert_eq!( + CharStatements::UPD_CHAR_REST_STATE.sql(), + "UPDATE characters SET restState = ?, playerFlags = ?, rest_bonus = ?, logout_time = ?, is_logout_resting = ? WHERE guid = ?" + ); + assert_eq!( + CharStatements::UPD_CHAR_ONLINE_REST_STATE.sql(), + "UPDATE characters SET restState = ?, playerFlags = ?, rest_bonus = ? WHERE guid = ?" + ); assert_eq!( CharStatements::UPD_CHAR_DIFFICULTIES.sql(), "UPDATE characters SET dungeonDifficulty = ?, raidDifficulty = ?, legacyRaidDifficulty = ? WHERE guid = ?" diff --git a/crates/wow-entities/src/player.rs b/crates/wow-entities/src/player.rs index c15be485..04d5b6b8 100644 --- a/crates/wow-entities/src/player.rs +++ b/crates/wow-entities/src/player.rs @@ -697,6 +697,8 @@ pub const ACTIVE_PLAYER_DATA_INV_SLOTS_PARENT_BIT: usize = 124; pub const ACTIVE_PLAYER_DATA_INV_SLOTS_FIRST_BIT: usize = 125; pub const ACTIVE_PLAYER_DATA_EXPLORED_ZONES_PARENT_BIT: usize = 298; pub const ACTIVE_PLAYER_DATA_EXPLORED_ZONES_FIRST_BIT: usize = 299; +pub const ACTIVE_PLAYER_DATA_REST_INFO_PARENT_BIT: usize = 539; +pub const ACTIVE_PLAYER_DATA_REST_INFO_FIRST_BIT: usize = 540; pub const ACTIVE_PLAYER_DATA_BUYBACK_PARENT_BIT: usize = 549; pub const ACTIVE_PLAYER_DATA_BUYBACK_PRICE_FIRST_BIT: usize = 550; pub const ACTIVE_PLAYER_DATA_BUYBACK_TIMESTAMP_FIRST_BIT: usize = 562; @@ -3174,6 +3176,7 @@ pub struct ActivePlayerDataValues { pub num_backpack_slots: u8, pub inv_slots: [ObjectGuid; PLAYER_SLOT_END], pub explored_zones: [u64; PLAYER_EXPLORED_ZONES_SIZE_LIKE_CPP], + pub rest_info: [PlayerRestInfoValueLikeCpp; 2], pub buyback_price: [u32; BUYBACK_SLOT_COUNT], pub buyback_timestamp: [i64; BUYBACK_SLOT_COUNT], pub bank_bag_slot_flags: [u32; 7], @@ -3190,6 +3193,12 @@ pub struct ActivePlayerDataValues { pub quest_completed: [u64; QUESTS_COMPLETED_BITS_SIZE], } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PlayerRestInfoValueLikeCpp { + pub threshold: u32, + pub state_id: u8, +} + impl Default for ActivePlayerDataValues { fn default() -> Self { Self { @@ -3205,6 +3214,7 @@ impl Default for ActivePlayerDataValues { num_backpack_slots: 0, inv_slots: [ObjectGuid::EMPTY; PLAYER_SLOT_END], explored_zones: [0; PLAYER_EXPLORED_ZONES_SIZE_LIKE_CPP], + rest_info: [PlayerRestInfoValueLikeCpp::default(); 2], buyback_price: [0; BUYBACK_SLOT_COUNT], buyback_timestamp: [0; BUYBACK_SLOT_COUNT], bank_bag_slot_flags: [0; 7], @@ -3907,6 +3917,24 @@ impl Player { }); } + pub fn set_xp_rest_info_like_cpp(&mut self, threshold: u32, state_id: u8) { + self.set_rest_info_like_cpp(0, threshold, state_id); + } + + pub fn set_rest_info_like_cpp(&mut self, index: usize, threshold: u32, state_id: u8) { + let Some(rest_info) = self.active_data.rest_info.get_mut(index) else { + return; + }; + if rest_info.threshold != threshold || rest_info.state_id != state_id { + rest_info.threshold = threshold; + rest_info.state_id = state_id; + self.mark_active_player_data_section( + ACTIVE_PLAYER_DATA_REST_INFO_PARENT_BIT, + ACTIVE_PLAYER_DATA_REST_INFO_FIRST_BIT + index, + ); + } + } + pub fn set_honor_like_cpp(&mut self, honor: i32) { self.set_active_i32_in_section( ACTIVE_PLAYER_DATA_HONOR_PARENT_BIT, diff --git a/crates/wow-network/src/accept.rs b/crates/wow-network/src/accept.rs index e8d9d594..a7712235 100644 --- a/crates/wow-network/src/accept.rs +++ b/crates/wow-network/src/accept.rs @@ -311,7 +311,9 @@ pub struct SessionResources { Option>, pub area_table_store: Option>, pub fishing_base_skill_store: Option>, + pub area_trigger_db2_store: Option>, pub area_trigger_store: Option>, + pub tavern_area_trigger_store: Option>, pub graveyard_store: Option>, pub area_trigger_template_store: Option>, pub chr_specialization_store: Option>, @@ -369,6 +371,14 @@ pub struct SessionResources { pub exploration_base_xp_store: Option>, /// C++ `sWorld->getRate(RATE_XP_EXPLORE)`. pub exploration_xp_rate: f32, + /// C++ `CONFIG_MAX_PLAYER_LEVEL`. + pub max_player_level_config: u32, + /// C++ `sWorld->getRate(RATE_REST_OFFLINE_IN_WILDERNESS)`. + pub rest_offline_wilderness_rate: f32, + /// C++ `sWorld->getRate(RATE_REST_OFFLINE_IN_TAVERN_OR_CITY)`. + pub rest_offline_tavern_or_city_rate: f32, + /// C++ `sWorld->getRate(RATE_REST_INGAME)`. + pub rest_ingame_rate: f32, /// C++ `CONFIG_MIN_QUEST_SCALED_XP_RATIO`. pub min_quest_scaled_xp_ratio: u32, /// C++ `CONFIG_MIN_DISCOVERED_SCALED_XP_RATIO`. diff --git a/crates/wow-packet/src/packets/misc.rs b/crates/wow-packet/src/packets/misc.rs index 79c9f34c..b962a508 100644 --- a/crates/wow-packet/src/packets/misc.rs +++ b/crates/wow-packet/src/packets/misc.rs @@ -13035,12 +13035,12 @@ mod tests { // ── SMSG_LOG_XP_GAIN ───────────────────────────────────────────────────────── /// Floating text "+XP" on screen when player earns experience. -/// C# ref: LogXPGain +/// C++ `WorldPackets::Character::LogXPGain::Write`. pub struct LogXpGain { pub victim: ObjectGuid, - pub original: i32, // XP before bonuses + pub original: i32, // base XP plus represented bonuses pub reason: u8, // 0=Kill, 1=NoKill(quest/explore) - pub amount: i32, // XP after bonuses (what actually counts) + pub amount: i32, // base XP amount pub group_bonus: f32, } diff --git a/crates/wow-packet/src/packets/update.rs b/crates/wow-packet/src/packets/update.rs index a6b4d250..2706690a 100644 --- a/crates/wow-packet/src/packets/update.rs +++ b/crates/wow-packet/src/packets/update.rs @@ -1544,6 +1544,10 @@ pub struct PlayerCreateData { pub max_health: i64, pub faction_template: i32, pub current_area_id: u32, + /// PlayerData::PlayerFlags. + pub player_flags: u32, + /// PlayerData::PlayerFlagsEx. + pub player_flags_ex: u32, /// Primary stats: [STR, AGI, STA, INT, SPI]. pub stats: [i32; 5], /// Base armor (AGI * 2). @@ -1603,6 +1607,10 @@ pub struct PlayerCreateData { pub party_type: [u8; 2], /// Current money in copper (Coinage field in ActivePlayerData). pub coinage: u64, + /// ActivePlayerData::NextLevelXP. + pub next_level_xp: i32, + /// ActivePlayerData::RestInfo[REST_TYPE_XP/HONOR]. + pub rest_info: [RestInfoValuesUpdate; 2], /// ActivePlayerData::WatchedFactionIndex. pub watched_faction_index: i32, /// ActivePlayerData::Heirlooms. @@ -2035,8 +2043,8 @@ impl PlayerCreateData { write_empty_guid(buf); // LootTargetGUID // PlayerFlags, PlayerFlagsEx - buf.write_uint32(0); - buf.write_uint32(0); + buf.write_uint32(self.player_flags); + buf.write_uint32(self.player_flags_ex); // GuildRankID, GuildDeleteDate, GuildLevel buf.write_int32(0); @@ -2172,7 +2180,7 @@ impl PlayerCreateData { // Coinage, XP, NextLevelXP, TrialXP buf.write_int64(self.coinage as i64); buf.write_int32(0); - buf.write_int32(400); // NextLevelXP for level 1 + buf.write_int32(self.next_level_xp); buf.write_int32(0); // SkillInfo.WriteCreate: 256 entries × 7 u16s each @@ -2259,9 +2267,9 @@ impl PlayerCreateData { // RestInfo[2] (each: i32 Threshold + u8 StateID) // StateID: 1=Rested, 2=Normal, 6=RAFLinked — must NOT be 0 (invalid) - for _ in 0..2 { - buf.write_int32(0); // Threshold (no rest bonus) - buf.write_uint8(2); // StateID = Normal + for rest_info in self.rest_info { + buf.write_int32(rest_info.threshold as i32); + buf.write_uint8(rest_info.state_id); } // ModHealingDonePos, ModHealingPercent, ModHealingDonePercent, ModPeriodicHealingDonePercent @@ -3758,6 +3766,8 @@ impl UpdateObject { max_health: combat.max_health, faction_template: faction, current_area_id: zone_id, + player_flags: 0, + player_flags_ex: 0, stats: combat.stats, base_armor: combat.base_armor, max_mana: combat.max_mana, @@ -3785,6 +3795,19 @@ impl UpdateObject { action_buttons: [0; MAX_ACTION_BUTTONS], skill_info, coinage, + next_level_xp: 400, + rest_info: [ + RestInfoValuesUpdate { + rest_info_mask: 0x07, + threshold: 0, + state_id: 2, + }, + RestInfoValuesUpdate { + rest_info_mask: 0x07, + threshold: 0, + state_id: 2, + }, + ], watched_faction_index: -1, party_type, heirlooms: Vec::new(), @@ -3822,6 +3845,26 @@ impl UpdateObject { } } + /// Populate PlayerData::PlayerFlags and PlayerData::PlayerFlagsEx on the + /// self CREATE block. + /// + /// C++ `Player::LoadFromDB` restores these into `m_playerData` before + /// `Map::SendInitSelf` calls `Player::BuildCreateUpdateBlockForPlayer`. + pub fn set_player_flags_like_cpp(&mut self, player_flags: u32, player_flags_ex: u32) { + for block in &mut self.blocks { + if let UpdateBlock::CreateObject { + create_data, + is_self: true, + .. + } = block + { + create_data.player_flags = player_flags; + create_data.player_flags_ex = player_flags_ex; + return; + } + } + } + /// Populate account collection dynamic fields on the player CREATE block. /// /// C++ `CollectionMgr::LoadToys` / `LoadHeirlooms` mutates @@ -3869,6 +3912,41 @@ impl UpdateObject { } } + /// Override `ActivePlayerData::NextLevelXP` for the self player create block. + pub fn set_player_next_level_xp_like_cpp(&mut self, next_level_xp: i32) { + for block in &mut self.blocks { + if let UpdateBlock::CreateObject { + create_data, + is_self: true, + .. + } = block + { + create_data.next_level_xp = next_level_xp; + } + } + } + + /// Override `ActivePlayerData::RestInfo[index]` for the self player create block. + pub fn set_player_rest_info_like_cpp(&mut self, index: usize, threshold: u32, state_id: u8) { + for block in &mut self.blocks { + if let UpdateBlock::CreateObject { + create_data, + is_self: true, + .. + } = block + { + let Some(rest_info) = create_data.rest_info.get_mut(index) else { + return; + }; + *rest_info = RestInfoValuesUpdate { + rest_info_mask: 0x07, + threshold, + state_id, + }; + } + } + } + /// Populate C++ `Player::m_actionButtons` for the self create block. pub fn set_player_action_buttons_like_cpp( &mut self, @@ -10973,6 +11051,8 @@ mod tests { max_health: 100, faction_template: PlayerCreateData::faction_for_race(1), current_area_id: 12, + player_flags: 0, + player_flags_ex: 0, stats: [0; 5], base_armor: 0, max_mana: 0, @@ -10997,6 +11077,19 @@ mod tests { quest_log: Vec::new(), party_type: [0; 2], coinage: 0, + next_level_xp: 400, + rest_info: [ + RestInfoValuesUpdate { + rest_info_mask: 0x07, + threshold: 0, + state_id: 2, + }, + RestInfoValuesUpdate { + rest_info_mask: 0x07, + threshold: 0, + state_id: 2, + }, + ], watched_faction_index: -1, heirlooms: Vec::new(), heirloom_flags: Vec::new(), @@ -11023,6 +11116,24 @@ mod tests { ); } + #[test] + fn player_create_writes_loaded_player_flags_like_cpp() { + let mut create = test_player_create_data_with_farsight(ObjectGuid::EMPTY); + create.player_flags = 0x22; + create.player_flags_ex = 0x04; + + let mut packet = WorldPacket::new_empty(); + create.write_player_data(&mut packet, 0x03); + + assert!( + packet + .data() + .windows(8) + .any(|window| window == [0x22, 0, 0, 0, 0x04, 0, 0, 0]), + "C++ PlayerData::WriteCreate serializes loaded PlayerFlags/PlayerFlagsEx" + ); + } + #[test] fn player_create_playerdata_self_layout_ends_with_dungeon_score_like_cpp() { let create = test_player_create_data_with_farsight(ObjectGuid::EMPTY); diff --git a/crates/wow-world/src/entity_update_bridge.rs b/crates/wow-world/src/entity_update_bridge.rs index e1fe28fb..02c9a129 100644 --- a/crates/wow-world/src/entity_update_bridge.rs +++ b/crates/wow-world/src/entity_update_bridge.rs @@ -16,9 +16,9 @@ use wow_packet::packets::update::{ CorpseDataValuesUpdate, DynamicObjectDataValuesUpdate, GameObjectDataValuesUpdate, ItemBonusKeyValuesUpdate, ItemDataValuesDeltaUpdate, ItemEnchantmentValuesUpdate, ItemModListValuesUpdate, ItemModValuesUpdate, ObjectDataValuesUpdate, - PlayerDataValuesDeltaUpdate, ScaleCurveValuesUpdate, SceneObjectDataValuesUpdate, - SocketedGemValuesUpdate, UnitDataValuesDeltaUpdate, UpdateObject, VisibleItemValuesUpdate, - VisualAnimValuesUpdate, + PlayerDataValuesDeltaUpdate, RestInfoValuesUpdate, ScaleCurveValuesUpdate, + SceneObjectDataValuesUpdate, SocketedGemValuesUpdate, UnitDataValuesDeltaUpdate, UpdateObject, + VisibleItemValuesUpdate, VisualAnimValuesUpdate, }; const VISIBLE_ITEM_FULL_UPDATE_MASK: u32 = 0x0F; @@ -966,6 +966,17 @@ fn active_player_data_update_to_packet( packet_update .explored_zones .copy_from_slice(&update.values.explored_zones); + for (dst, src) in packet_update + .rest_info + .iter_mut() + .zip(update.values.rest_info.iter()) + { + *dst = RestInfoValuesUpdate { + rest_info_mask: 0x07, + threshold: src.threshold, + state_id: src.state_id, + }; + } packet_update.buyback_price = update.values.buyback_price; packet_update.buyback_timestamp = update.values.buyback_timestamp; packet_update.bank_bag_slot_flags = update.values.bank_bag_slot_flags; diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index a8462756..98c1e56c 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -64,9 +64,9 @@ use crate::session::{ ALL_ACCOUNT_DATA_CACHE_MASK_LIKE_CPP, CharacterPetAuraEffectRowLikeCpp, CharacterPetAuraRowLikeCpp, CharacterPetDeclinedNamesRowLikeCpp, CharacterPetSpellChargeRowLikeCpp, CharacterPetSpellCooldownRowLikeCpp, - CharacterPetSpellRowLikeCpp, CharacterPetStableRowLikeCpp, RepresentedAlterAppearanceLikeCpp, - RepresentedBankItemMoveLikeCpp, RepresentedConfirmBarbersChoiceLikeCpp, - RepresentedGameObjectUseState, + CharacterPetSpellRowLikeCpp, CharacterPetStableRowLikeCpp, REST_STATE_NORMAL_LIKE_CPP, + RepresentedAlterAppearanceLikeCpp, RepresentedBankItemMoveLikeCpp, + RepresentedConfirmBarbersChoiceLikeCpp, RepresentedGameObjectUseState, }; // ── Handler registration ──────────────────────────────────────────── @@ -4188,8 +4188,19 @@ impl WorldSession { self.set_represented_bonus_talent_groups_like_cpp(result.try_read::(31).unwrap_or(0)); self.set_player_create_mode_like_cpp(result.try_read::(21).unwrap_or(0)); self.set_represented_at_login_flags_like_cpp(result.try_read::(39).unwrap_or(0)); + let saved_rest_state = result + .try_read::(11) + .unwrap_or(REST_STATE_NORMAL_LIKE_CPP); + let saved_rest_bonus = result.try_read::(25).unwrap_or(0.0); + let saved_logout_time_secs = result + .try_read::(26) + .or_else(|| result.try_read::(26).map(|value| value.max(0) as u64)) + .unwrap_or(0); + let saved_logout_was_resting = result.try_read::(27).unwrap_or(0) != 0; self.load_represented_explored_zones_like_cpp(&result.read_string(64)); self.set_player_guid(Some(guid)); + self.set_loaded_player_flags_like_cpp(result.try_read::(12).unwrap_or(0)); + self.set_loaded_player_flags_ex_like_cpp(result.try_read::(13).unwrap_or(0)); self.set_loaded_player_identity_like_cpp(map_id as u16, race, class, level, gender); // C++ recalculates zone/area from terrain after AddToMap // (`Player::SendInitialPacketsAfterAddToMap`). Seed from DB until @@ -4549,8 +4560,29 @@ impl WorldSession { gender, ) { let _ = self.ensure_canonical_world_map_for_current_player_like_cpp(); + self.apply_loaded_player_flags_to_canonical_like_cpp(); let _ = self.apply_represented_group_leader_flag_like_cpp(); } + self.load_represented_xp_rest_bonus_like_cpp(saved_rest_state, saved_rest_bonus); + self.restore_represented_logout_resting_flag_like_cpp(saved_logout_was_resting); + let applied_rest_bonus = self.apply_offline_xp_rest_bonus_like_cpp( + saved_logout_time_secs, + Self::current_game_time_secs_like_cpp(), + saved_logout_was_resting, + ); + if std::env::var_os("RUSTYCORE_REST_TRACE").is_some() { + info!( + player_guid = guid.counter(), + saved_rest_state, + saved_rest_bonus, + saved_logout_time_secs, + saved_logout_was_resting, + applied_rest_bonus, + rest_bonus = self.represented_xp_rest_bonus_like_cpp(), + rest_state = self.represented_xp_rest_state_like_cpp(), + "RUST_PLAYER_REST_LOAD" + ); + } self.load_represented_character_titles_like_cpp( &result.try_read::(65).unwrap_or_default(), result.try_read::(48).unwrap_or(0), @@ -13841,7 +13873,17 @@ impl WorldSession { quest_log, self.party_member_party_type_like_cpp(), ); + let (player_flags, player_flags_ex) = + self.represented_player_flags_for_create_like_cpp(); + player_pkt.set_player_flags_like_cpp(player_flags, player_flags_ex); player_pkt.set_player_current_power0_like_cpp(current_power0); + player_pkt + .set_player_next_level_xp_like_cpp(self.player_next_level_xp_like_cpp() as i32); + player_pkt.set_player_rest_info_like_cpp( + 0, + self.represented_xp_rest_threshold_like_cpp(), + self.represented_xp_rest_state_like_cpp(), + ); if std::env::var_os("RUSTYCORE_SPELL_POWER_TRACE").is_some() { info!( guid = ?guid, diff --git a/crates/wow-world/src/handlers/misc.rs b/crates/wow-world/src/handlers/misc.rs index 39e7bf88..c226f94d 100644 --- a/crates/wow-world/src/handlers/misc.rs +++ b/crates/wow-world/src/handlers/misc.rs @@ -7,6 +7,7 @@ //! SetSelection, AreaTrigger, RequestCemeteryList, //! TaxiNodeStatusQuery, ChatJoinChannel. +use crate::map_manager::zone_and_area_for_position_like_cpp; use tracing::{debug, info, warn}; use wow_constants::unit::{NPCFlags1, Team}; use wow_constants::{ @@ -2188,6 +2189,14 @@ impl crate::session::WorldSession { quest_log, party_type, ); + let (player_flags, player_flags_ex) = self.represented_player_flags_for_create_like_cpp(); + player_pkt.set_player_flags_like_cpp(player_flags, player_flags_ex); + player_pkt.set_player_next_level_xp_like_cpp(self.player_next_level_xp_like_cpp() as i32); + player_pkt.set_player_rest_info_like_cpp( + 0, + self.represented_xp_rest_threshold_like_cpp(), + self.represented_xp_rest_state_like_cpp(), + ); player_pkt.set_player_account_guids_like_cpp( ObjectGuid::create_global(HighGuid::WowAccount, 0, self.account_id as i64), ObjectGuid::create_global(HighGuid::BNetAccount, 0, self.battlenet_account_id() as i64), @@ -2272,6 +2281,34 @@ impl crate::session::WorldSession { self.set_player_map_position_like_cpp(new_map as u16, new_pos); let _ = self.update_represented_item_level_area_based_scaling_like_cpp(); let _ = self.ensure_canonical_world_map_for_current_player_like_cpp(); + match zone_and_area_for_position_like_cpp( + &self.mmap_runtime_config_like_cpp().data_dir, + new_map, + new_pos.x, + new_pos.y, + self.area_table_store().map(|store| store.as_ref()), + |map_id| { + self.map_store() + .as_deref() + .map(|store| u32::from(store.area_table_id_like_cpp(map_id))) + .unwrap_or(0) + }, + ) { + Ok((new_zone, new_area)) => { + self.update_zone_represented_without_rest_update_packet_like_cpp( + new_zone, new_area, + ); + } + Err(error) => { + warn!( + map = new_map, + x = new_pos.x, + y = new_pos.y, + %error, + "failed to resolve C++ terrain zone/area before far-teleport SendInitSelf; preserving current represented rest flags" + ); + } + } self.update_registry_position(); self.resummon_pet_temporary_unsummoned_if_any_like_cpp(); self.process_represented_delayed_resurrection_after_teleport_like_cpp(); @@ -2359,37 +2396,71 @@ impl crate::session::WorldSession { } /// CMSG_AREA_TRIGGER — player entered an area trigger. - /// C# ref: MiscHandler.HandleAreaTrigger + /// C++ ref: `WorldSession::HandleAreaTriggerOpcode`. pub async fn handle_area_trigger(&mut self, mut pkt: wow_packet::WorldPacket) { let trigger_id: u32 = pkt.read_uint32().unwrap_or(0); + let entered = pkt.read_bit().unwrap_or(true); + let _from_client = pkt.read_bit().unwrap_or(false); info!( - "AreaTrigger: account {} trigger_id={}", - self.account_id, trigger_id + "AreaTrigger: account {} trigger_id={} entered={}", + self.account_id, trigger_id, entered ); - // Lookup in area trigger store - if let Some(store) = self.area_trigger_store() { - if let Some(trigger) = store.get_trigger(trigger_id) { - info!( - "AreaTrigger {} detected at map {} pos ({}, {}, {})", - trigger_id, trigger.map_id, trigger.pos.x, trigger.pos.y, trigger.pos.z - ); + let Some(at_entry) = self.area_trigger_db2_entry_like_cpp(trigger_id).cloned() else { + debug!("Unknown area trigger ID {}", trigger_id); + return; + }; - if let Some(ref teleport) = trigger.teleport { - let target_map = teleport.target_map; - let target_pos = teleport.target_position; - info!( - "AreaTrigger {} → teleport to map {} ({:.2}, {:.2}, {:.2})", - trigger_id, target_map, target_pos.x, target_pos.y, target_pos.z - ); - self.teleport_to(target_map, target_pos).await; - } - } else { - debug!("Unknown area trigger ID {}", trigger_id); - } - } else { - debug!("Area trigger store not available"); + let player_in_area_trigger = self.player_is_in_area_trigger_radius_like_cpp(&at_entry); + if entered && !player_in_area_trigger { + debug!( + "Area trigger {} ignored because player is too far", + trigger_id + ); + return; + } + + if !entered + && self.represented_is_tavern_area_trigger_like_cpp(trigger_id) + && player_in_area_trigger + { + debug!( + "Area trigger {} tavern leave ignored because player is still inside", + trigger_id + ); + return; + } + + if self.handle_represented_tavern_area_trigger_like_cpp(trigger_id, entered) { + return; + } + + let Some(trigger) = self + .area_trigger_store() + .and_then(|store| store.get_trigger(trigger_id).cloned()) + else { + return; + }; + + // Lookup in represented teleport store + info!( + "AreaTrigger {} detected at map {} pos ({}, {}, {})", + trigger_id, trigger.map_id, trigger.pos.x, trigger.pos.y, trigger.pos.z + ); + + if !entered { + return; + } + + if let Some(ref teleport) = trigger.teleport { + let target_map = teleport.target_map; + let target_pos = teleport.target_position; + info!( + "AreaTrigger {} → teleport to map {} ({:.2}, {:.2}, {:.2})", + trigger_id, target_map, target_pos.x, target_pos.y, target_pos.z + ); + self.teleport_to(target_map, target_pos).await; } } @@ -7047,6 +7118,7 @@ mod tests { shared::DifficultyFlags, }; use wow_core::{ObjectGuid, Position, guid::HighGuid}; + use wow_data::area::AREA_FLAG_IS_SUBZONE_LIKE_CPP; use wow_data::progression_rewards::{FactionEntry, FactionStore}; use wow_data::quest::{ QUEST_ITEM_DROP_COUNT, QUEST_REWARD_CHOICES_COUNT, QUEST_REWARD_CURRENCY_COUNT, @@ -7150,6 +7222,59 @@ mod tests { } } + fn area_entry(id: u32, parent_area_id: u16, flags: u32) -> wow_data::AreaTableEntry { + wow_data::AreaTableEntry { + id, + continent_id: 571, + parent_area_id, + area_bit: -1, + exploration_level: 0, + mount_flags: 0, + flags, + } + } + + fn unique_temp_data_dir(test_name: &str) -> std::path::PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + let data_dir = std::env::temp_dir().join(format!("rustycore-{test_name}-{unique}")); + std::fs::create_dir_all(data_dir.join("maps")).expect("create maps test dir"); + data_dir + } + + fn write_no_area_map_file_like_cpp( + data_dir: &std::path::Path, + map_id: u32, + x: f32, + y: f32, + area_id: u16, + ) { + let (grid_x, grid_y) = + crate::map_manager::terrain_grid_coords_for_wow_position_like_cpp(x, y); + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"MAPS"); + bytes.extend_from_slice(&10_u32.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&44_u32.to_le_bytes()); + bytes.extend_from_slice(&8_u32.to_le_bytes()); + for _ in 0..6 { + bytes.extend_from_slice(&0_u32.to_le_bytes()); + } + assert_eq!(bytes.len(), 44); + bytes.extend_from_slice(b"AREA"); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + bytes.extend_from_slice(&area_id.to_le_bytes()); + std::fs::write( + data_dir + .join("maps") + .join(format!("{map_id:04}_{grid_x:02}_{grid_y:02}.map")), + bytes, + ) + .expect("write test map"); + } + #[test] fn item_purchase_contents_skip_season_earned_currency_like_cpp() { let extended_cost = wow_data::item_extended_cost::ItemExtendedCostEntry { @@ -7500,6 +7625,71 @@ mod tests { ); } + #[tokio::test] + async fn world_port_response_recomputes_destination_rest_state_before_self_create_like_cpp() { + let (mut session, send_rx) = make_session(); + let canonical = shared_canonical_map_manager_for_misc_test(); + let data_dir = unique_temp_data_dir("world-port-rest-state"); + let player_guid = ObjectGuid::create_player(1, 43); + let destination = Position::new(0.0, 0.0, 9.0, 0.25); + write_no_area_map_file_like_cpp(&data_dir, 571, destination.x, destination.y, 300); + + session.set_mmap_runtime_config_like_cpp(crate::session::MMapRuntimeConfigLikeCpp { + data_dir: data_dir.to_string_lossy().to_string(), + enabled: true, + disabled_map_ids: HashSet::new(), + }); + session.set_map_store(Arc::new(MapStore::from_entries([ + map_entry(0, wow_data::map::MAP_COMMON), + map_entry(571, wow_data::map::MAP_COMMON), + ]))); + session.set_area_table_store(Arc::new(wow_data::AreaTableStore::from_entries([ + area_entry(20, 0, wow_data::AREA_FLAG_LINKED_CHAT_LIKE_CPP), + area_entry(200, 0, 0), + area_entry(300, 200, AREA_FLAG_IS_SUBZONE_LIKE_CPP), + ]))); + session.set_canonical_map_manager(Arc::clone(&canonical)); + session.attach_player_controller_like_cpp(crate::session::SessionPlayerController::new( + player_guid, + "WorldportRest".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 0, + 1, + 1, + 10, + 0, + )); + add_canonical_test_player_on_map_for_misc_test( + &canonical, + player_guid, + Position::new(1.0, 2.0, 3.0, 0.0), + 0, + 0, + ); + session.set_player_zone_area_like_cpp(10, 10); + assert!(session.update_zone_represented_like_cpp(20, 20)); + assert!( + session.represented_is_resting_like_cpp(), + "pre-teleport city rest flag should be active before moving to wilderness" + ); + while send_rx.try_recv().is_ok() {} + + session.pending_teleport = Some((571, destination)); + session.set_represented_far_teleport_pending_like_cpp(true); + session.set_state(crate::session::SessionState::Transfer); + + session + .handle_world_port_response(WorldPacket::new_empty()) + .await; + + assert_eq!(session.player_zone_area_like_cpp(), (200, 300)); + assert!( + !session.represented_is_resting_like_cpp(), + "C++ HandleMoveWorldportAck calls UpdateZone for the destination before later rest-state saves can observe flags" + ); + assert_eq!(session.state(), crate::session::SessionState::LoggedIn); + } + #[tokio::test] async fn world_port_response_activates_pvp_item_levels_for_flagged_map_like_cpp() { let (mut session, _send_rx) = make_session(); diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index 7c70d3f7..29c6a2c6 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -59,8 +59,8 @@ use wow_core::{ObjectGuid, ObjectGuidGenerator, Position, guid::HighGuid}; use wow_data::character_progression::{ChrClassesStore, ChrRacesStore}; use wow_data::trait_tree::TraitDefinitionStore; use wow_data::{ - AccessRequirementStoreLikeCpp, AdventureMapPoiStore, AreaTableStore, AreaTriggerStore, - BankBagSlotPricesStore, BattlePetBreedQualityStore, BattlePetBreedStateStore, + AccessRequirementStoreLikeCpp, AdventureMapPoiStore, AreaTableStore, AreaTriggerDb2Store, + AreaTriggerStore, BankBagSlotPricesStore, BattlePetBreedQualityStore, BattlePetBreedStateStore, BattlePetSpeciesStateStore, BattlePetSpeciesStore, BattlePetXpGameTableLikeCpp, BattlemasterListStore, ChrSpecializationStore, CinematicSequencesStore, CombatRatingsGameTableLikeCpp, ConditionEntriesByTypeStore, CreatureAddonStoreLikeCpp, @@ -103,10 +103,10 @@ use wow_data::{ SpellRangeStore, SpellRequiredStoreLikeCpp, SpellShapeshiftFormStore, SpellStore, SpellTargetPositionStoreLikeCpp, SpellThreatEntryLikeCpp, SpellThreatStoreLikeCpp, SpellTotemModelStoreLikeCpp, SummonPropertiesEntry, TactKeyStore, TalentStore, TalentTabStore, - ToyStore, TransmogSetEntry, TransmogSetItemStore, TrinityStringStoreLikeCpp, - VEHICLE_SEAT_FLAG_CAN_ATTACK, VehicleAccessoryStoreLikeCpp, VehicleSeatStore, VehicleStore, - VehicleTemplateStoreLikeCpp, calculate_battle_pet_stats_like_cpp, - is_player_meeting_condition_like_cpp, + TavernAreaTriggerStoreLikeCpp, ToyStore, TransmogSetEntry, TransmogSetItemStore, + TrinityStringStoreLikeCpp, VEHICLE_SEAT_FLAG_CAN_ATTACK, VehicleAccessoryStoreLikeCpp, + VehicleSeatStore, VehicleStore, VehicleTemplateStoreLikeCpp, + calculate_battle_pet_stats_like_cpp, is_player_meeting_condition_like_cpp, progression_rewards::{ ContentTuningStore, CurvePointStore, CurveStore, FactionEntry, FactionStore, FactionTemplateStore, FriendshipRepReactionStore, NumTalentsAtLevelStore, @@ -222,6 +222,22 @@ const PLAYER_FLAGS_GROUP_LEADER_LIKE_CPP: u32 = 0x0000_0001; const PLAYER_FLAGS_AFK_LIKE_CPP: u32 = 0x0000_0002; const PLAYER_FLAGS_DND_LIKE_CPP: u32 = 0x0000_0004; const PLAYER_FLAGS_GHOST_LIKE_CPP: u32 = 0x0000_0010; +const PLAYER_FLAGS_RESTING_LIKE_CPP: u32 = 0x0000_0020; +pub(crate) const REST_STATE_RESTED_LIKE_CPP: u8 = 1; +pub(crate) const REST_STATE_NORMAL_LIKE_CPP: u8 = 2; +const REST_STATE_RAF_LINKED_LIKE_CPP: u8 = 6; +const REST_FLAG_IN_TAVERN_LIKE_CPP: u32 = 0x1; +const REST_FLAG_IN_CITY_LIKE_CPP: u32 = 0x2; +const REST_FLAG_IN_FACTION_AREA_LIKE_CPP: u32 = 0x4; +// Session-local recovery bit for `characters.is_logout_resting`. C++ restores +// rest state through RestMgr runtime, but the DB boolean alone does not encode +// whether the player logged out in a tavern, city, or faction rest area. +const REST_FLAG_RESTORED_LOGOUT_LIKE_CPP: u32 = 0x8000_0000; +// C++ `RestMgr::SetRestBonus`: `float(next_level_xp) * 1.5f / 2`. +const REST_BONUS_MAX_NEXT_LEVEL_XP_FACTOR_LIKE_CPP: f32 = 1.5 / 2.0; +const REST_OFFLINE_WILDERNESS_BUBBLE_LIKE_CPP: f32 = 0.031; +const REST_OFFLINE_TAVERN_OR_CITY_BUBBLE_LIKE_CPP: f32 = 0.125; +const REST_ONLINE_INGAME_BUBBLE_LIKE_CPP: f32 = 0.125; const DIFFICULTY_NORMAL_LIKE_CPP: u32 = 1; const DIFFICULTY_NORMAL_RAID_LIKE_CPP: u32 = 14; const DIFFICULTY_10_N_LIKE_CPP: u32 = 3; @@ -239,6 +255,9 @@ const LANG_RESET_SPELLS_TEXT_LIKE_CPP: &str = "Your spells have been reset."; const LANG_RESET_TALENTS_TEXT_LIKE_CPP: &str = "Your talents have been reset."; pub(crate) const TRADE_STATUS_PLAYER_BUSY_LIKE_CPP: u8 = 0; const PLAYER_LOCAL_FLAG_WAR_MODE_LIKE_CPP: u32 = 0x0000_0800; +const AREA_FLAG_ENEMIES_PVP_FLAGGED_LIKE_CPP: u32 = 0x0000_0010; +const AREA_FLAG_FREE_FOR_ALL_PVP_LIKE_CPP: u32 = 0x0000_0080; +const AREA_FLAG_COMBAT_ZONE_LIKE_CPP: u32 = 0x0100_0000; const CURRENCY_DB_UNUSED_FLAGS_LIKE_CPP: u8 = 0x13; pub(crate) type TeleportToOptionsLikeCpp = u32; pub(crate) const TELE_TO_NONE_LIKE_CPP: TeleportToOptionsLikeCpp = 0x00; @@ -3914,7 +3933,9 @@ pub struct WorldSession { fishing_base_skill_store: Option>, // Area trigger store (collision detection + teleportation) + area_trigger_db2_store: Option>, area_trigger_store: Option>, + tavern_area_trigger_store: Option>, // C++ ObjectMgr::GraveyardStore loaded from graveyard_zone plus attached conditions. graveyard_store: Option>, @@ -4060,6 +4081,29 @@ pub struct WorldSession { pub(crate) total_played_time: u32, /// Time played at current level loaded from DB (seconds). pub(crate) level_played_time: u32, + /// C++ `CONFIG_MAX_PLAYER_LEVEL`, used by `RestMgr::SetRestBonus` and `Player::GiveXP`. + max_player_level_config_like_cpp: u32, + /// C++ `RestMgr::_restBonus[REST_TYPE_XP]`, loaded from `characters.rest_bonus`. + represented_rest_bonus_xp_like_cpp: f32, + /// C++ `UF::ActivePlayerData::RestInfo[REST_TYPE_XP].StateID`. + represented_rest_state_xp_like_cpp: u8, + /// C++ `RestMgr::_restFlagMask`. + represented_rest_flag_mask_like_cpp: u32, + /// C++ `RestMgr::_innAreaTriggerId`. + represented_inn_area_trigger_id_like_cpp: u32, + /// C++ `RestMgr::_restTime`, represented as Unix seconds. + represented_rest_time_secs_like_cpp: u64, + /// C++ `characters.playerFlags` as loaded by `Player::LoadFromDB`. + represented_loaded_player_flags_like_cpp: Option, + /// C++ `characters.playerFlagsEx` as loaded by `Player::LoadFromDB`. + represented_loaded_player_flags_ex_like_cpp: Option, + represented_loaded_player_flags_applied_like_cpp: bool, + /// C++ `RATE_REST_OFFLINE_IN_WILDERNESS`. + rest_offline_wilderness_rate_like_cpp: f32, + /// C++ `RATE_REST_OFFLINE_IN_TAVERN_OR_CITY`. + rest_offline_tavern_or_city_rate_like_cpp: f32, + /// C++ `RATE_REST_INGAME`. + rest_ingame_rate_like_cpp: f32, /// Player's current money in copper (1 gold = 10,000 copper). /// Loaded from `characters.money` on login; saved on logout + buy/sell. player_gold: u64, @@ -5527,6 +5571,7 @@ pub enum RepresentedAuraEffectLikeCpp { ModSpeedNoControl, ModBattlePetXpPct, ModReputationGain, + ModRestedXpConsumption, ProvideSpellFocus, Stealth, WaterWalk, @@ -5837,7 +5882,9 @@ impl WorldSession { skill_tiers_store: None, area_table_store: None, fishing_base_skill_store: None, + area_trigger_db2_store: None, area_trigger_store: None, + tavern_area_trigger_store: None, graveyard_store: None, chr_specialization_store: None, dungeon_encounter_store: None, @@ -5926,6 +5973,18 @@ impl WorldSession { login_time: None, total_played_time: 0, level_played_time: 0, + max_player_level_config_like_cpp: 80, + represented_rest_bonus_xp_like_cpp: 0.0, + represented_rest_state_xp_like_cpp: REST_STATE_NORMAL_LIKE_CPP, + represented_rest_flag_mask_like_cpp: 0, + represented_inn_area_trigger_id_like_cpp: 0, + represented_rest_time_secs_like_cpp: 0, + represented_loaded_player_flags_like_cpp: None, + represented_loaded_player_flags_ex_like_cpp: None, + represented_loaded_player_flags_applied_like_cpp: false, + rest_offline_wilderness_rate_like_cpp: 1.0, + rest_offline_tavern_or_city_rate_like_cpp: 1.0, + rest_ingame_rate_like_cpp: 1.0, player_gold: 0, represented_talent_reset_cost_like_cpp: 0, represented_talent_reset_time_secs_like_cpp: 0, @@ -20331,7 +20390,16 @@ impl WorldSession { self.tact_key_store.as_ref() } - /// Set the area trigger store for this session. + /// Set the DB2-backed area trigger store for this session. + pub fn set_area_trigger_db2_store(&mut self, store: Arc) { + self.area_trigger_db2_store = Some(store); + } + + pub(crate) fn area_trigger_db2_store(&self) -> Option<&Arc> { + self.area_trigger_db2_store.as_ref() + } + + /// Set the area trigger teleport store for this session. pub fn set_area_trigger_store(&mut self, store: Arc) { self.area_trigger_store = Some(store); } @@ -20341,6 +20409,93 @@ impl WorldSession { self.area_trigger_store.as_ref() } + pub fn set_tavern_area_trigger_store(&mut self, store: Arc) { + self.tavern_area_trigger_store = Some(store); + } + + pub(crate) fn tavern_area_trigger_store(&self) -> Option<&Arc> { + self.tavern_area_trigger_store.as_ref() + } + + pub(crate) fn represented_is_tavern_area_trigger_like_cpp(&self, trigger_id: u32) -> bool { + self.tavern_area_trigger_store() + .is_some_and(|store| store.is_tavern_area_trigger_like_cpp(trigger_id)) + } + + pub(crate) fn area_trigger_db2_entry_like_cpp( + &self, + trigger_id: u32, + ) -> Option<&wow_data::AreaTriggerDb2Entry> { + self.area_trigger_db2_store() + .and_then(|store| store.get(trigger_id)) + } + + /// C++ `Player::IsInAreaTriggerRadius`. + pub(crate) fn player_is_in_area_trigger_radius_like_cpp( + &self, + trigger: &wow_data::AreaTriggerDb2Entry, + ) -> bool { + let Some(pos) = self.player_position_like_cpp() else { + return false; + }; + + let Some(trigger_map_id) = u16::try_from(trigger.continent_id).ok() else { + return false; + }; + if self.player_map_id_like_cpp() != trigger_map_id + && !self + .represented_player_phase_shift_like_cpp() + .has_visible_map_id_like_cpp(u32::from(trigger_map_id)) + { + return false; + } + + if trigger.phase_id != 0 || trigger.phase_group_id != 0 || trigger.phase_use_flags != 0 { + let (trigger_phase_shift, _) = self.db_spawn_phase_shift_like_cpp( + trigger_map_id, + trigger.phase_use_flags as u8, + u16::try_from(trigger.phase_id).unwrap_or_default(), + u32::try_from(trigger.phase_group_id).unwrap_or_default(), + -1, + ); + if !self.can_see_phase_shift_like_cpp(&trigger_phase_shift) { + return false; + } + } + + let center = Position::new(trigger.pos.x, trigger.pos.y, trigger.pos.z, trigger.box_yaw); + if trigger.radius > 0.0 { + pos.is_within_dist(¢er, trigger.radius) + } else { + Self::position_is_within_area_trigger_box_like_cpp( + &pos, + ¢er, + trigger.box_length / 2.0, + trigger.box_width / 2.0, + trigger.box_height / 2.0, + ) + } + } + + fn position_is_within_area_trigger_box_like_cpp( + pos: &Position, + center: &Position, + half_length: f32, + half_width: f32, + half_height: f32, + ) -> bool { + let dx = pos.x - center.x; + let dy = pos.y - center.y; + let cos_yaw = center.orientation.cos(); + let sin_yaw = center.orientation.sin(); + let rel_x = dx * cos_yaw + dy * sin_yaw; + let rel_y = -dx * sin_yaw + dy * cos_yaw; + + rel_x.abs() <= half_length + && rel_y.abs() <= half_width + && (pos.z - center.z).abs() <= half_height + } + pub fn set_graveyard_store(&mut self, store: Arc) { self.graveyard_store = Some(store); } @@ -21837,6 +21992,511 @@ impl WorldSession { let _ = char_db.execute(&stmt).await; } + pub(crate) fn current_game_time_secs_like_cpp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + fn sanitize_rest_bonus_like_cpp(rest_bonus: f32) -> f32 { + if rest_bonus.is_finite() { + rest_bonus + } else { + 0.0 + } + } + + fn valid_player_rest_state_like_cpp(rest_state: u8) -> bool { + matches!( + rest_state, + REST_STATE_RESTED_LIKE_CPP + | REST_STATE_NORMAL_LIKE_CPP + | REST_STATE_RAF_LINKED_LIKE_CPP + ) + } + + fn player_is_at_configured_max_level_like_cpp(&self) -> bool { + let max_level = self.max_player_level_config_like_cpp; + max_level != 0 && u32::from(self.player_level_like_cpp()) >= max_level + } + + fn can_gain_represented_xp_rest_bonus_like_cpp(&self) -> bool { + if self.player_is_at_configured_max_level_like_cpp() { + return false; + } + + let next_level_xp = self.player_next_level_xp_like_cpp(); + next_level_xp != 0 && next_level_xp != u32::MAX + } + + fn represented_xp_rest_bonus_cap_like_cpp(&self) -> f32 { + self.player_next_level_xp_like_cpp() as f32 * REST_BONUS_MAX_NEXT_LEVEL_XP_FACTOR_LIKE_CPP + } + + fn represented_recruit_a_friend_xp_rest_state_applies_like_cpp(&self) -> bool { + self.gets_recruit_a_friend_reputation_bonus_like_cpp() + } + + pub(crate) fn load_represented_xp_rest_bonus_like_cpp( + &mut self, + rest_state: u8, + rest_bonus: f32, + ) { + self.clear_represented_rest_flags_for_character_load_like_cpp(); + if Self::valid_player_rest_state_like_cpp(rest_state) { + self.represented_rest_state_xp_like_cpp = rest_state; + } else { + self.represented_rest_state_xp_like_cpp = REST_STATE_NORMAL_LIKE_CPP; + } + self.set_represented_xp_rest_bonus_like_cpp(rest_bonus); + } + + fn clear_represented_rest_flags_for_character_load_like_cpp(&mut self) { + self.represented_rest_flag_mask_like_cpp = 0; + self.represented_inn_area_trigger_id_like_cpp = 0; + self.represented_rest_time_secs_like_cpp = 0; + self.sync_represented_rest_info_to_canonical_player_like_cpp(); + } + + pub(crate) fn restore_represented_logout_resting_flag_like_cpp( + &mut self, + was_logout_resting: bool, + ) { + if was_logout_resting { + self.set_represented_rest_flag_like_cpp(REST_FLAG_RESTORED_LOGOUT_LIKE_CPP, 0); + } + } + + fn set_represented_xp_rest_bonus_like_cpp(&mut self, rest_bonus: f32) { + let mut rest_bonus = Self::sanitize_rest_bonus_like_cpp(rest_bonus); + if !self.can_gain_represented_xp_rest_bonus_like_cpp() { + rest_bonus = 0.0; + } + + rest_bonus = rest_bonus.clamp(0.0, self.represented_xp_rest_bonus_cap_like_cpp()); + let is_raf_linked = self.represented_recruit_a_friend_xp_rest_state_applies_like_cpp(); + self.represented_rest_bonus_xp_like_cpp = rest_bonus; + self.represented_rest_state_xp_like_cpp = if is_raf_linked { + REST_STATE_RAF_LINKED_LIKE_CPP + } else if rest_bonus >= 1.0 { + REST_STATE_RESTED_LIKE_CPP + } else { + REST_STATE_NORMAL_LIKE_CPP + }; + self.sync_represented_rest_info_to_canonical_player_like_cpp(); + } + + pub(crate) fn add_represented_xp_rest_bonus_like_cpp(&mut self, rest_bonus: f32) { + let total = self.represented_rest_bonus_xp_like_cpp + rest_bonus; + self.set_represented_xp_rest_bonus_like_cpp(total); + } + + fn calc_represented_xp_rest_extra_per_sec_like_cpp(&self, bubble: f32) -> f32 { + if !self.can_gain_represented_xp_rest_bonus_like_cpp() { + return 0.0; + } + self.player_next_level_xp_like_cpp() as f32 / 72_000.0 * bubble + } + + pub(crate) fn apply_offline_xp_rest_bonus_like_cpp( + &mut self, + logout_time_secs: u64, + now_secs: u64, + was_logout_resting: bool, + ) -> f32 { + if logout_time_secs == 0 { + return 0.0; + } + let Some(time_diff) = now_secs.checked_sub(logout_time_secs) else { + return 0.0; + }; + if time_diff == 0 { + return 0.0; + } + + let bubble = if was_logout_resting { + REST_OFFLINE_TAVERN_OR_CITY_BUBBLE_LIKE_CPP + * self.rest_offline_tavern_or_city_rate_like_cpp + } else { + REST_OFFLINE_WILDERNESS_BUBBLE_LIKE_CPP * self.rest_offline_wilderness_rate_like_cpp + }; + let extra = time_diff as f32 * self.calc_represented_xp_rest_extra_per_sec_like_cpp(bubble); + self.add_represented_xp_rest_bonus_like_cpp(extra); + extra + } + + fn update_represented_online_xp_rest_bonus_like_cpp(&mut self, now_secs: u64) -> f32 { + let rest_time = self.represented_rest_time_secs_like_cpp; + if rest_time == 0 { + return 0.0; + } + let Some(time_diff) = now_secs.checked_sub(rest_time) else { + return 0.0; + }; + if time_diff < 10 { + return 0.0; + } + + self.represented_rest_time_secs_like_cpp = now_secs; + let bubble = REST_ONLINE_INGAME_BUBBLE_LIKE_CPP * self.rest_ingame_rate_like_cpp; + let extra = time_diff as f32 * self.calc_represented_xp_rest_extra_per_sec_like_cpp(bubble); + self.add_represented_xp_rest_bonus_like_cpp(extra); + extra + } + + fn tick_represented_online_xp_rest_bonus_like_cpp(&mut self, now_secs: u64) { + if self.update_represented_online_xp_rest_bonus_like_cpp(now_secs) > 0.0 { + self.send_represented_rest_info_update_like_cpp(); + } + } + + fn revalidate_represented_tavern_resting_like_cpp(&mut self) { + if (self.represented_rest_flag_mask_like_cpp & REST_FLAG_IN_TAVERN_LIKE_CPP) == 0 { + return; + } + + let Some(at_entry) = self + .area_trigger_db2_entry_like_cpp(self.represented_inn_area_trigger_id_like_cpp) + .cloned() + else { + if self.remove_represented_rest_flag_like_cpp(REST_FLAG_IN_TAVERN_LIKE_CPP) { + self.send_represented_resting_player_flag_update_like_cpp(); + } + return; + }; + + if !self.player_is_in_area_trigger_radius_like_cpp(&at_entry) + && self.remove_represented_rest_flag_like_cpp(REST_FLAG_IN_TAVERN_LIKE_CPP) + { + self.send_represented_resting_player_flag_update_like_cpp(); + } + } + + fn apply_represented_pct_modifier_to_u32_like_cpp(value: u32, pct: i32) -> u32 { + let adjusted = i64::from(value) + (i64::from(value) * i64::from(pct)) / 100; + adjusted.clamp(0, i64::from(u32::MAX)) as u32 + } + + fn take_represented_xp_rest_bonus_for_gain_like_cpp( + &mut self, + xp: u32, + victim: wow_core::ObjectGuid, + ) -> u32 { + if xp == 0 || victim.is_empty() { + return 0; + } + + let rested_bonus = (self.represented_xp_rest_bonus_like_cpp() as u32).min(xp); + if rested_bonus == 0 { + return 0; + } + + let rested_loss = Self::apply_represented_pct_modifier_to_u32_like_cpp( + rested_bonus, + self.total_represented_aura_modifier_like_cpp( + RepresentedAuraEffectLikeCpp::ModRestedXpConsumption, + ), + ); + self.set_represented_xp_rest_bonus_like_cpp( + self.represented_xp_rest_bonus_like_cpp() - rested_loss as f32, + ); + rested_bonus + } + + pub(crate) fn represented_xp_rest_bonus_like_cpp(&self) -> f32 { + self.represented_rest_bonus_xp_like_cpp + } + + pub(crate) fn represented_xp_rest_state_like_cpp(&self) -> u8 { + self.represented_rest_state_xp_like_cpp + } + + pub(crate) fn represented_xp_rest_threshold_like_cpp(&self) -> u32 { + self.represented_rest_bonus_xp_like_cpp + .clamp(0.0, u32::MAX as f32) as u32 + } + + fn represented_zone_allows_city_rest_like_cpp(&self, zone: &wow_data::AreaTableEntry) -> bool { + // C++ `AreaFlags::LinkedChat` is the capital-city gate in `Player::UpdateZone`. + if !zone.linked_chat_like_cpp() { + return false; + } + + !self.player_pvp_hostile_like_cpp || zone.is_sanctuary_like_cpp() + } + + fn update_represented_hostile_area_state_like_cpp(&mut self, zone: &wow_data::AreaTableEntry) { + let zone_hostile = if zone.is_sanctuary_like_cpp() { + false + } else if (zone.flags + & (AREA_FLAG_FREE_FOR_ALL_PVP_LIKE_CPP | AREA_FLAG_COMBAT_ZONE_LIKE_CPP)) + != 0 + { + true + } else { + (zone.flags & AREA_FLAG_ENEMIES_PVP_FLAGGED_LIKE_CPP) != 0 + }; + self.player_pvp_hostile_like_cpp = + zone_hostile || self.player_war_mode_local_active_like_cpp(); + } + + pub(crate) fn represented_is_resting_like_cpp(&self) -> bool { + self.represented_rest_flag_mask_like_cpp != 0 + } + + fn sync_represented_rest_info_to_canonical_player_like_cpp(&mut self) { + let rest_state = self.represented_xp_rest_state_like_cpp(); + let rest_threshold = self.represented_xp_rest_threshold_like_cpp(); + let is_resting = self.represented_is_resting_like_cpp(); + let _ = self.mutate_canonical_player_like_cpp(|player| { + player.set_xp_rest_info_like_cpp(rest_threshold, rest_state); + if is_resting { + player.set_player_flag(PLAYER_FLAGS_RESTING_LIKE_CPP); + } else { + player.remove_player_flag(PLAYER_FLAGS_RESTING_LIKE_CPP); + } + }); + } + + pub(crate) fn set_represented_rest_flag_like_cpp( + &mut self, + rest_flag: u32, + trigger_id: u32, + ) -> bool { + let old_mask = self.represented_rest_flag_mask_like_cpp; + self.represented_rest_flag_mask_like_cpp |= rest_flag; + if old_mask == 0 && self.represented_rest_flag_mask_like_cpp != 0 { + self.represented_rest_time_secs_like_cpp = Self::current_game_time_secs_like_cpp(); + } + if trigger_id != 0 { + self.represented_inn_area_trigger_id_like_cpp = trigger_id; + } + let changed = old_mask != self.represented_rest_flag_mask_like_cpp; + if changed { + self.sync_represented_rest_info_to_canonical_player_like_cpp(); + } + changed + } + + pub(crate) fn remove_represented_rest_flag_like_cpp(&mut self, rest_flag: u32) -> bool { + let old_mask = self.represented_rest_flag_mask_like_cpp; + self.represented_rest_flag_mask_like_cpp &= !rest_flag; + if (rest_flag & REST_FLAG_IN_TAVERN_LIKE_CPP) != 0 + && (self.represented_rest_flag_mask_like_cpp & REST_FLAG_IN_TAVERN_LIKE_CPP) == 0 + { + self.represented_inn_area_trigger_id_like_cpp = 0; + } + if old_mask != 0 && self.represented_rest_flag_mask_like_cpp == 0 { + self.represented_rest_time_secs_like_cpp = 0; + } + let changed = old_mask != self.represented_rest_flag_mask_like_cpp; + if changed { + self.sync_represented_rest_info_to_canonical_player_like_cpp(); + } + changed + } + + fn remove_represented_restored_logout_flag_like_cpp(&mut self) -> bool { + self.remove_represented_rest_flag_like_cpp(REST_FLAG_RESTORED_LOGOUT_LIKE_CPP) + } + + fn update_represented_rest_flag_like_cpp(&mut self, rest_flag: u32, active: bool) -> bool { + if active { + self.set_represented_rest_flag_like_cpp(rest_flag, 0) + } else { + self.remove_represented_rest_flag_like_cpp(rest_flag) + } + } + + pub(crate) fn set_represented_tavern_resting_like_cpp( + &mut self, + trigger_id: u32, + entered: bool, + ) -> bool { + let restored_changed = self.remove_represented_restored_logout_flag_like_cpp(); + if entered { + restored_changed + | self.set_represented_rest_flag_like_cpp(REST_FLAG_IN_TAVERN_LIKE_CPP, trigger_id) + } else { + restored_changed + | self.remove_represented_rest_flag_like_cpp(REST_FLAG_IN_TAVERN_LIKE_CPP) + } + } + + pub(crate) fn handle_represented_tavern_area_trigger_like_cpp( + &mut self, + trigger_id: u32, + entered: bool, + ) -> bool { + if !self.represented_is_tavern_area_trigger_like_cpp(trigger_id) { + return false; + } + + if self.set_represented_tavern_resting_like_cpp(trigger_id, entered) { + self.send_represented_resting_player_flag_update_like_cpp(); + } + true + } + + pub(crate) fn send_represented_resting_player_flag_update_like_cpp(&self) { + let Some(guid) = self.player_guid() else { + return; + }; + let Some(mut player) = self.player_values_update_snapshot() else { + return; + }; + + let canonical_flags = self + .canonical_player_snapshot_like_cpp(|player| player.data().player_flags) + .unwrap_or_default(); + if self.represented_is_resting_like_cpp() { + player.replace_all_player_flags(canonical_flags & !PLAYER_FLAGS_RESTING_LIKE_CPP); + player.set_player_flag(PLAYER_FLAGS_RESTING_LIKE_CPP); + } else { + player.replace_all_player_flags(canonical_flags | PLAYER_FLAGS_RESTING_LIKE_CPP); + player.remove_player_flag(PLAYER_FLAGS_RESTING_LIKE_CPP); + } + let update = player.values_update(true); + if let Some(packet) = + player_values_update_to_update_object(guid, self.player_map_id_like_cpp(), &update) + { + self.send_packet(&packet); + } + } + + pub(crate) fn send_represented_rest_info_update_like_cpp(&self) { + let Some(guid) = self.player_guid() else { + return; + }; + let Some(mut player) = self.player_values_update_snapshot() else { + return; + }; + + let canonical_flags = self + .canonical_player_snapshot_like_cpp(|player| player.data().player_flags) + .unwrap_or_default(); + player.replace_all_player_flags(canonical_flags); + player.set_xp_rest_info_like_cpp( + self.represented_xp_rest_threshold_like_cpp(), + self.represented_xp_rest_state_like_cpp(), + ); + let update = player.values_update(true); + if let Some(packet) = + player_values_update_to_update_object(guid, self.player_map_id_like_cpp(), &update) + { + self.send_packet(&packet); + } + } + + fn build_character_rest_state_save_statement_like_cpp( + rest_state: u8, + player_flags: u32, + rest_bonus: f32, + logout_time_secs: u64, + is_logout_resting: bool, + guid_counter: u64, + ) -> PreparedStatement { + let mut stmt = PreparedStatement::new(CharStatements::UPD_CHAR_REST_STATE.sql()); + stmt.set_u8(0, rest_state); + stmt.set_u32(1, player_flags); + stmt.set_f32(2, Self::sanitize_rest_bonus_like_cpp(rest_bonus)); + stmt.set_u64(3, logout_time_secs); + stmt.set_u8(4, u8::from(is_logout_resting)); + stmt.set_u64(5, guid_counter); + stmt + } + + fn build_character_online_rest_state_save_statement_like_cpp( + rest_state: u8, + player_flags: u32, + rest_bonus: f32, + guid_counter: u64, + ) -> PreparedStatement { + let mut stmt = PreparedStatement::new(CharStatements::UPD_CHAR_ONLINE_REST_STATE.sql()); + stmt.set_u8(0, rest_state); + stmt.set_u32(1, player_flags); + stmt.set_f32(2, Self::sanitize_rest_bonus_like_cpp(rest_bonus)); + stmt.set_u64(3, guid_counter); + stmt + } + + fn represented_player_flags_for_rest_state_save_like_cpp(&self) -> u32 { + let canonical_flags = + self.canonical_player_snapshot_like_cpp(|player| player.data().player_flags); + let mut player_flags = match ( + canonical_flags, + self.represented_loaded_player_flags_like_cpp, + self.represented_loaded_player_flags_applied_like_cpp, + ) { + (Some(flags), Some(_), true) | (Some(flags), None, _) => flags, + (Some(_), Some(loaded_flags), false) | (None, Some(loaded_flags), _) => loaded_flags, + (None, None, _) => 0, + }; + if self.represented_is_resting_like_cpp() { + player_flags |= PLAYER_FLAGS_RESTING_LIKE_CPP; + } else { + player_flags &= !PLAYER_FLAGS_RESTING_LIKE_CPP; + } + player_flags + } + + pub(crate) fn represented_player_flags_for_create_like_cpp(&self) -> (u32, u32) { + let player_flags = self.represented_player_flags_for_rest_state_save_like_cpp(); + let canonical_flags_ex = + self.canonical_player_snapshot_like_cpp(|player| player.data().player_flags_ex); + let player_flags_ex = match ( + canonical_flags_ex, + self.represented_loaded_player_flags_ex_like_cpp, + self.represented_loaded_player_flags_applied_like_cpp, + ) { + (Some(flags), Some(_), true) | (Some(flags), None, _) => flags, + (Some(_), Some(loaded_flags), false) | (None, Some(loaded_flags), _) => loaded_flags, + (None, None, _) => 0, + }; + (player_flags, player_flags_ex) + } + + async fn save_player_rest_state_like_cpp(&self) { + let (Some(guid), Some(char_db)) = (self.player_guid(), self.char_db().map(Arc::clone)) + else { + return; + }; + let stmt = Self::build_character_rest_state_save_statement_like_cpp( + self.represented_xp_rest_state_like_cpp(), + self.represented_player_flags_for_rest_state_save_like_cpp(), + self.represented_xp_rest_bonus_like_cpp(), + Self::current_game_time_secs_like_cpp(), + self.represented_is_resting_like_cpp(), + guid.counter() as u64, + ); + if let Err(err) = char_db.execute(&stmt).await { + warn!( + "Failed to save represented player rest state for guid {}: {err}", + guid.counter() + ); + } + } + + async fn save_player_online_rest_state_like_cpp(&self) { + let (Some(guid), Some(char_db)) = (self.player_guid(), self.char_db().map(Arc::clone)) + else { + return; + }; + let stmt = Self::build_character_online_rest_state_save_statement_like_cpp( + self.represented_xp_rest_state_like_cpp(), + self.represented_player_flags_for_rest_state_save_like_cpp(), + self.represented_xp_rest_bonus_like_cpp(), + guid.counter() as u64, + ); + if let Err(err) = char_db.execute(&stmt).await { + warn!( + "Failed to save represented online player rest state for guid {}: {err}", + guid.counter() + ); + } + } + fn build_character_health_save_statement_like_cpp( health: u32, guid_counter: u64, @@ -22096,6 +22756,7 @@ impl WorldSession { self.save_player_position_like_cpp(&snapshot).await; self.save_player_level_xp_like_cpp().await; self.save_player_gold().await; + self.save_player_rest_state_like_cpp().await; self.save_player_health_like_cpp(&snapshot).await; self.save_player_powers_like_cpp(&snapshot).await; self.save_player_talent_reset_state_like_cpp().await; @@ -22242,10 +22903,18 @@ impl WorldSession { /// Represented C++ `Player::UpdateArea` criteria branch. /// /// C++ records `EnterArea`/`LeaveArea` after updating area-dependent state when - /// `oldArea != newArea`; this represented slice records only those criteria - /// side effects. PvP/rest flags, phasing, aura checks, quest push, mount + /// `oldArea != newArea`; this represented slice records those criteria and + /// the C++ area rest flag side effects. PvP flags, phasing, aura checks, quest push, mount /// capability refresh, and chat-channel updates remain runtime gaps. pub(crate) fn update_area_represented_like_cpp(&mut self, new_area: u32) -> bool { + self.update_area_represented_with_rest_update_like_cpp(new_area, true) + } + + fn update_area_represented_with_rest_update_like_cpp( + &mut self, + new_area: u32, + send_rest_update: bool, + ) -> bool { let old_area = self.player_area_id_like_cpp; self.player_area_id_like_cpp = new_area; @@ -22253,6 +22922,25 @@ impl WorldSession { return false; } + let mut rest_changed = self.remove_represented_restored_logout_flag_like_cpp(); + let area_resting = self.area_table_store.as_ref().and_then(|store| { + store.get(new_area).map(|area| { + let team = player_team_for_race_cpp(self.player_race_like_cpp()); + match team { + Team::Alliance => area.alliance_resting_like_cpp(), + Team::Horde => area.horde_resting_like_cpp(), + Team::Other => false, + } + }) + }); + rest_changed |= self.update_represented_rest_flag_like_cpp( + REST_FLAG_IN_FACTION_AREA_LIKE_CPP, + area_resting.unwrap_or(false), + ); + if send_rest_update && rest_changed { + self.send_represented_resting_player_flag_update_like_cpp(); + } + self.represented_area_zone_criteria_like_cpp .push(RepresentedAreaZoneCriteriaLikeCpp::EnterArea(new_area)); self.represented_area_zone_criteria_like_cpp @@ -22270,6 +22958,23 @@ impl WorldSession { &mut self, new_zone: u32, new_area: u32, + ) -> bool { + self.update_zone_represented_with_rest_update_like_cpp(new_zone, new_area, true) + } + + pub(crate) fn update_zone_represented_without_rest_update_packet_like_cpp( + &mut self, + new_zone: u32, + new_area: u32, + ) -> bool { + self.update_zone_represented_with_rest_update_like_cpp(new_zone, new_area, false) + } + + fn update_zone_represented_with_rest_update_like_cpp( + &mut self, + new_zone: u32, + new_area: u32, + send_rest_update: bool, ) -> bool { if self.player_guid().is_none() { return false; @@ -22277,18 +22982,31 @@ impl WorldSession { let old_zone = self.player_zone_id_like_cpp; self.player_zone_id_like_cpp = new_zone; - self.update_area_represented_like_cpp(new_area); + self.update_area_represented_with_rest_update_like_cpp(new_area, send_rest_update); + let mut rest_changed = self.remove_represented_restored_logout_flag_like_cpp(); - if old_zone == new_zone { - return false; - } - - if !self + let zone_entry = self .area_table_store .as_ref() - .is_some_and(|store| store.get(new_zone).is_some()) - { + .and_then(|store| store.get(new_zone).copied()); + let Some(zone) = zone_entry else { + if send_rest_update && rest_changed { + self.send_represented_resting_player_flag_update_like_cpp(); + } return true; + }; + + self.update_represented_hostile_area_state_like_cpp(&zone); + rest_changed |= self.update_represented_rest_flag_like_cpp( + REST_FLAG_IN_CITY_LIKE_CPP, + self.represented_zone_allows_city_rest_like_cpp(&zone), + ); + if send_rest_update && rest_changed { + self.send_represented_resting_player_flag_update_like_cpp(); + } + + if old_zone == new_zone { + return false; } self.represented_area_zone_criteria_like_cpp.push( @@ -24060,24 +24778,30 @@ impl WorldSession { if xp == 0 { return false; } - if self.player_level_like_cpp() >= 80 { + if self.player_is_at_configured_max_level_like_cpp() { return false; } // max level + let bonus_xp = self.take_represented_xp_rest_bonus_for_gain_like_cpp(xp, victim); + let total_xp = xp.saturating_add(bonus_xp); + // Send floating XP text — C++ `WorldPackets::Character::LogXPGain`. self.send_packet(&LogXpGain { victim, - original: xp as i32, + original: total_xp.min(i32::MAX as u32) as i32, reason: if is_kill { 0 } else { 1 }, - amount: xp as i32, + amount: xp.min(i32::MAX as u32) as i32, group_bonus: 1.0, }); + if bonus_xp != 0 { + self.send_represented_rest_info_update_like_cpp(); + } - self.set_player_xp_like_cpp(self.player_xp_like_cpp().saturating_add(xp)); + self.set_player_xp_like_cpp(self.player_xp_like_cpp().saturating_add(total_xp)); // C++ `Player::GiveXP`: while (newXP >= nextLvlXP && !IsMaxLevel()). while self.player_xp_like_cpp() >= self.player_next_level_xp_like_cpp() - && self.player_level_like_cpp() < 80 + && !self.player_is_at_configured_max_level_like_cpp() { self.set_player_xp_like_cpp( self.player_xp_like_cpp() - self.player_next_level_xp_like_cpp(), @@ -24108,6 +24832,7 @@ impl WorldSession { /// C++ `Player::GiveXP(xp, victim, group_rate)`. pub(crate) async fn give_xp(&mut self, xp: u32, victim: wow_core::ObjectGuid, is_kill: bool) { let old_level = self.player_level_like_cpp(); + let old_rest_bonus = self.represented_xp_rest_bonus_like_cpp(); if !self.give_xp_runtime_like_cpp(xp, victim, is_kill) { return; } @@ -24137,6 +24862,10 @@ impl WorldSession { let _ = db.execute(&stmt).await; } } + + if self.represented_xp_rest_bonus_like_cpp() < old_rest_bonus { + self.save_player_online_rest_state_like_cpp().await; + } } /// XP reward for killing a creature. @@ -25585,6 +26314,19 @@ impl WorldSession { self.exploration_xp_rate_like_cpp = rate.max(0.0); } + pub fn set_rested_xp_config_like_cpp( + &mut self, + max_player_level_config: u32, + rest_offline_wilderness_rate: f32, + rest_offline_tavern_or_city_rate: f32, + rest_ingame_rate: f32, + ) { + self.max_player_level_config_like_cpp = max_player_level_config; + self.rest_offline_wilderness_rate_like_cpp = rest_offline_wilderness_rate.max(0.0); + self.rest_offline_tavern_or_city_rate_like_cpp = rest_offline_tavern_or_city_rate.max(0.0); + self.rest_ingame_rate_like_cpp = rest_ingame_rate.max(0.0); + } + pub fn set_min_quest_scaled_xp_ratio_like_cpp(&mut self, ratio: u32) { self.min_quest_scaled_xp_ratio_like_cpp = if ratio > 100 { 0 } else { ratio }; } @@ -26879,6 +27621,10 @@ impl WorldSession { if self.creature_tick % 4 == 0 { self.tick_auras(); } + self.revalidate_represented_tavern_resting_like_cpp(); + self.tick_represented_online_xp_rest_bonus_like_cpp( + Self::current_game_time_secs_like_cpp(), + ); self.represented_can_delay_teleport_like_cpp = false; self.process_represented_delayed_teleport_after_update_like_cpp(); } @@ -30370,39 +31116,50 @@ impl WorldSession { return; }; - // Get all triggers at the current position on the player's current map - let triggers = store.get_triggers_at_position(self.player_map_id_like_cpp(), &pos); + let (exited_trigger_id, entered_trigger) = { + // Get all triggers at the current position on the player's current map. + let triggers = store.get_triggers_at_position(self.player_map_id_like_cpp(), &pos); + let exited_trigger_id = self.active_area_trigger.filter(|prev_trigger_id| { + !triggers + .iter() + .any(|trigger| trigger.trigger_id == *prev_trigger_id) + }); + let entered_trigger = triggers + .first() + .map(|trigger| (trigger.trigger_id, trigger.teleport.clone())); + (exited_trigger_id, entered_trigger) + }; // Check if we've exited the previous trigger - if let Some(prev_trigger_id) = self.active_area_trigger { - if !triggers.iter().any(|t| t.trigger_id == prev_trigger_id) { - info!( - account = self.account_id, - trigger_id = prev_trigger_id, - "Exited area trigger" - ); - self.active_area_trigger = None; - } + if let Some(prev_trigger_id) = exited_trigger_id { + info!( + account = self.account_id, + trigger_id = prev_trigger_id, + "Exited area trigger" + ); + self.handle_represented_tavern_area_trigger_like_cpp(prev_trigger_id, false); + self.active_area_trigger = None; } // Check if we've entered a new trigger - if let Some(trigger) = triggers.first() { - let trigger_id = trigger.trigger_id; - + if let Some((trigger_id, teleport)) = entered_trigger { // Only trigger if this is a NEW trigger (wasn't active before) if self.active_area_trigger != Some(trigger_id) { info!( account = self.account_id, - trigger_id = trigger.trigger_id, - "Entered area trigger" + trigger_id, "Entered area trigger" ); self.active_area_trigger = Some(trigger_id); + if self.handle_represented_tavern_area_trigger_like_cpp(trigger_id, true) { + return; + } + // Handle teleportation if present - if let Some(ref teleport) = trigger.teleport { + if let Some(ref teleport) = teleport { info!( account = self.account_id, - trigger_id = trigger.trigger_id, + trigger_id, target_map = teleport.target_map, target_x = teleport.target_position.x, target_y = teleport.target_position.y, @@ -31932,6 +32689,38 @@ impl WorldSession { self.refresh_represented_talent_points_like_cpp(); } + pub(crate) fn set_loaded_player_flags_like_cpp(&mut self, player_flags: u32) { + self.represented_loaded_player_flags_like_cpp = Some(player_flags); + self.represented_loaded_player_flags_ex_like_cpp + .get_or_insert(0); + self.represented_loaded_player_flags_applied_like_cpp = false; + self.apply_loaded_player_flags_to_canonical_like_cpp(); + } + + pub(crate) fn set_loaded_player_flags_ex_like_cpp(&mut self, player_flags_ex: u32) { + self.represented_loaded_player_flags_ex_like_cpp = Some(player_flags_ex); + self.represented_loaded_player_flags_applied_like_cpp = false; + self.apply_loaded_player_flags_to_canonical_like_cpp(); + } + + pub(crate) fn apply_loaded_player_flags_to_canonical_like_cpp(&mut self) { + let Some(player_flags) = self.represented_loaded_player_flags_like_cpp else { + return; + }; + let player_flags_ex = self + .represented_loaded_player_flags_ex_like_cpp + .unwrap_or(0); + if self + .mutate_canonical_player_like_cpp(|player| { + player.replace_all_player_flags(player_flags); + player.replace_all_player_flags_ex(player_flags_ex); + }) + .is_some() + { + self.represented_loaded_player_flags_applied_like_cpp = true; + } + } + pub(crate) fn set_loaded_player_powers_like_cpp( &mut self, powers: [i32; MAX_POWERS_PER_CLASS], @@ -49755,6 +50544,8 @@ impl WorldSession { != wow_data::spell::aura_types::SPELL_AURA_MOD_DETECT_RANGE && effect.effect_aura != wow_data::spell::aura_types::SPELL_AURA_MOD_DETECTED_RANGE + && effect.effect_aura + != wow_data::spell::aura_types::SPELL_AURA_MOD_RESTED_XP_CONSUMPTION }) .count(); for effect in spell_info.effects().iter().filter(|effect| { @@ -49794,6 +50585,16 @@ impl WorldSession { RepresentedAuraEffectLikeCpp::ModDetectedRange, 30_000, )?; + } else if effect.effect_aura + == wow_data::spell::aura_types::SPELL_AURA_MOD_RESTED_XP_CONSUMPTION + { + self.apply_represented_aura_modifier_like_cpp( + spell_id, + player_guid, + effect, + RepresentedAuraEffectLikeCpp::ModRestedXpConsumption, + 30_000, + )?; } else if effect.effect_aura == wow_data::spell::aura_types::SPELL_AURA_MOD_SCALE { self.apply_represented_aura_modifier_like_cpp( spell_id, @@ -99674,6 +100475,827 @@ mod tests { ); } + #[test] + fn offline_rested_xp_tavern_accrues_more_than_wilderness_like_cpp() { + let (mut wilderness, _, _) = make_session(); + wilderness.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + wilderness.set_player_next_level_xp_like_cpp(72_000); + wilderness.load_represented_xp_rest_bonus_like_cpp(REST_STATE_NORMAL_LIKE_CPP, 0.0); + + let (mut tavern, _, _) = make_session(); + tavern.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + tavern.set_player_next_level_xp_like_cpp(72_000); + tavern.load_represented_xp_rest_bonus_like_cpp(REST_STATE_NORMAL_LIKE_CPP, 0.0); + + let wilderness_extra = wilderness.apply_offline_xp_rest_bonus_like_cpp(1_000, 4_600, false); + let tavern_extra = tavern.apply_offline_xp_rest_bonus_like_cpp(1_000, 4_600, true); + + assert!((wilderness_extra - 111.6).abs() < 0.01); + assert!((tavern_extra - 450.0).abs() < 0.01); + assert!(tavern_extra > wilderness_extra); + assert!( + tavern.represented_xp_rest_bonus_like_cpp() + > wilderness.represented_xp_rest_bonus_like_cpp() + ); + assert_eq!( + tavern.represented_xp_rest_state_like_cpp(), + REST_STATE_RESTED_LIKE_CPP + ); + } + + #[test] + fn rested_xp_uses_configured_rest_rates_like_cpp() { + let (mut wilderness, _, _) = make_session(); + wilderness.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + wilderness.set_player_next_level_xp_like_cpp(72_000); + wilderness.set_rested_xp_config_like_cpp(80, 2.0, 3.0, 4.0); + wilderness.load_represented_xp_rest_bonus_like_cpp(REST_STATE_NORMAL_LIKE_CPP, 0.0); + + let (mut tavern, _, _) = make_session(); + tavern.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + tavern.set_player_next_level_xp_like_cpp(72_000); + tavern.set_rested_xp_config_like_cpp(80, 2.0, 3.0, 4.0); + tavern.load_represented_xp_rest_bonus_like_cpp(REST_STATE_NORMAL_LIKE_CPP, 0.0); + + let wilderness_extra = wilderness.apply_offline_xp_rest_bonus_like_cpp(1_000, 4_600, false); + let tavern_extra = tavern.apply_offline_xp_rest_bonus_like_cpp(1_000, 4_600, true); + + assert!((wilderness_extra - 223.2).abs() < 0.01); + assert!((tavern_extra - 1_350.0).abs() < 0.01); + + let (mut online, _, _) = make_session(); + online.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + online.set_player_next_level_xp_like_cpp(72_000); + online.set_rested_xp_config_like_cpp(80, 2.0, 3.0, 4.0); + online.load_represented_xp_rest_bonus_like_cpp(REST_STATE_NORMAL_LIKE_CPP, 0.0); + assert!(online.set_represented_rest_flag_like_cpp(REST_FLAG_IN_CITY_LIKE_CPP, 0)); + online.represented_rest_time_secs_like_cpp = 1_000; + + let online_extra = online.update_represented_online_xp_rest_bonus_like_cpp(1_010); + + assert!((online_extra - 5.0).abs() < 0.01); + } + + #[test] + fn offline_rested_xp_caps_at_cpp_next_level_threshold_like_cpp() { + let (mut session, _, _) = make_session(); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.set_player_next_level_xp_like_cpp(1_000); + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_RESTED_LIKE_CPP, 700.0); + + let extra = session.apply_offline_xp_rest_bonus_like_cpp(1, 1_000_000, true); + + assert!(extra > 0.0); + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), 750.0); + assert_eq!(session.represented_xp_rest_threshold_like_cpp(), 750); + assert_eq!( + session.represented_xp_rest_state_like_cpp(), + REST_STATE_RESTED_LIKE_CPP + ); + } + + #[test] + fn rested_xp_uses_configured_max_player_level_like_cpp() { + let (mut session, _, _) = make_session(); + let victim = test_creature_guid(88); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 70, 0); + session.set_player_next_level_xp_like_cpp(1_000); + session.set_rested_xp_config_like_cpp(70, 1.0, 1.0, 1.0); + + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_RESTED_LIKE_CPP, 100.0); + + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), 0.0); + assert_eq!( + session.represented_xp_rest_state_like_cpp(), + REST_STATE_NORMAL_LIKE_CPP + ); + assert!(!session.give_xp_runtime_like_cpp(50, victim, true)); + assert_eq!(session.player_xp_like_cpp(), 0); + } + + #[test] + fn load_rested_xp_clamps_stale_db_bonus_before_rest_info_sync_like_cpp() { + let (mut capped, _, _) = make_session(); + capped.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + capped.set_player_next_level_xp_like_cpp(1_000); + + capped.load_represented_xp_rest_bonus_like_cpp(REST_STATE_RESTED_LIKE_CPP, 900.0); + + assert_eq!(capped.represented_xp_rest_bonus_like_cpp(), 750.0); + assert_eq!(capped.represented_xp_rest_threshold_like_cpp(), 750); + assert_eq!( + capped.represented_xp_rest_state_like_cpp(), + REST_STATE_RESTED_LIKE_CPP + ); + + let (mut max_level, _, _) = make_session(); + max_level.set_loaded_player_identity_like_cpp(1, 1, 8, 80, 0); + max_level.set_player_next_level_xp_like_cpp(1_000); + + max_level.load_represented_xp_rest_bonus_like_cpp(REST_STATE_RESTED_LIKE_CPP, 100.0); + + assert_eq!(max_level.represented_xp_rest_bonus_like_cpp(), 0.0); + assert_eq!(max_level.represented_xp_rest_threshold_like_cpp(), 0); + assert_eq!( + max_level.represented_xp_rest_state_like_cpp(), + REST_STATE_NORMAL_LIKE_CPP + ); + } + + #[test] + fn offline_rested_xp_zero_logout_time_does_not_accrue_like_cpp() { + let (mut session, _, _) = make_session(); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.set_player_next_level_xp_like_cpp(72_000); + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_NORMAL_LIKE_CPP, 0.0); + + let extra = session.apply_offline_xp_rest_bonus_like_cpp(0, 4_600, true); + + assert_eq!(extra, 0.0); + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), 0.0); + assert_eq!( + session.represented_xp_rest_state_like_cpp(), + REST_STATE_NORMAL_LIKE_CPP + ); + } + + #[test] + fn offline_rested_xp_invalid_or_max_level_cases_do_not_accrue_like_cpp() { + let (mut no_next_level_xp, _, _) = make_session(); + no_next_level_xp.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + no_next_level_xp.set_player_next_level_xp_like_cpp(0); + no_next_level_xp.load_represented_xp_rest_bonus_like_cpp(99, f32::NAN); + + let no_next_extra = no_next_level_xp.apply_offline_xp_rest_bonus_like_cpp(10, 3_610, true); + + assert_eq!(no_next_extra, 0.0); + assert_eq!(no_next_level_xp.represented_xp_rest_bonus_like_cpp(), 0.0); + assert_eq!( + no_next_level_xp.represented_xp_rest_state_like_cpp(), + REST_STATE_NORMAL_LIKE_CPP + ); + + let (mut max_level, _, _) = make_session(); + max_level.set_loaded_player_identity_like_cpp(1, 1, 8, 80, 0); + max_level.set_player_next_level_xp_like_cpp(72_000); + max_level.load_represented_xp_rest_bonus_like_cpp(REST_STATE_RESTED_LIKE_CPP, 100.0); + + let max_level_extra = max_level.apply_offline_xp_rest_bonus_like_cpp(10, 3_610, true); + + assert_eq!(max_level_extra, 0.0); + assert_eq!(max_level.represented_xp_rest_bonus_like_cpp(), 0.0); + assert_eq!( + max_level.represented_xp_rest_state_like_cpp(), + REST_STATE_NORMAL_LIKE_CPP + ); + } + + #[test] + fn offline_rested_xp_login_does_not_modify_current_health_or_power_like_cpp() { + let (mut session, _, _) = make_session(); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.set_player_next_level_xp_like_cpp(72_000); + session.set_player_health_like_cpp(41, 100); + session.set_loaded_player_powers_like_cpp([17, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_NORMAL_LIKE_CPP, 0.0); + + let extra = session.apply_offline_xp_rest_bonus_like_cpp(1_000, 4_600, true); + + assert!(extra > 0.0); + assert_eq!(session.player_health_like_cpp(), 41); + assert_eq!( + session + .represented_player_power_values_like_cpp() + .expect("power snapshot should be authoritative")[0], + 17 + ); + } + + #[test] + fn character_rest_state_save_statement_matches_cpp_bind_order() { + let guid = ObjectGuid::create_player(1, 5007); + + let stmt = WorldSession::build_character_rest_state_save_statement_like_cpp( + REST_STATE_RESTED_LIKE_CPP, + PLAYER_FLAGS_AFK_LIKE_CPP | PLAYER_FLAGS_RESTING_LIKE_CPP, + 123.5, + 1_723_456_789, + true, + guid.counter() as u64, + ); + + assert_eq!(stmt.sql(), CharStatements::UPD_CHAR_REST_STATE.sql()); + assert_eq!( + stmt.params()[0], + wow_database::SqlParam::U8(REST_STATE_RESTED_LIKE_CPP) + ); + assert_eq!( + stmt.params()[1], + wow_database::SqlParam::U32(PLAYER_FLAGS_AFK_LIKE_CPP | PLAYER_FLAGS_RESTING_LIKE_CPP) + ); + assert!(matches!(stmt.params()[2], wow_database::SqlParam::F32(v) if v == 123.5)); + assert_eq!(stmt.params()[3], wow_database::SqlParam::U64(1_723_456_789)); + assert_eq!(stmt.params()[4], wow_database::SqlParam::U8(1)); + assert!( + matches!(stmt.params()[5], wow_database::SqlParam::U64(v) if v == guid.counter() as u64) + ); + } + + #[test] + fn online_rest_state_save_statement_does_not_touch_logout_columns_like_cpp() { + let guid = ObjectGuid::create_player(1, 5008); + + let stmt = WorldSession::build_character_online_rest_state_save_statement_like_cpp( + REST_STATE_RESTED_LIKE_CPP, + PLAYER_FLAGS_RESTING_LIKE_CPP, + 42.5, + guid.counter() as u64, + ); + + assert_eq!(stmt.sql(), CharStatements::UPD_CHAR_ONLINE_REST_STATE.sql()); + assert_eq!( + stmt.sql(), + "UPDATE characters SET restState = ?, playerFlags = ?, rest_bonus = ? WHERE guid = ?" + ); + assert_eq!( + stmt.params()[0], + wow_database::SqlParam::U8(REST_STATE_RESTED_LIKE_CPP) + ); + assert_eq!( + stmt.params()[1], + wow_database::SqlParam::U32(PLAYER_FLAGS_RESTING_LIKE_CPP) + ); + assert!(matches!(stmt.params()[2], wow_database::SqlParam::F32(v) if v == 42.5)); + assert!( + matches!(stmt.params()[3], wow_database::SqlParam::U64(v) if v == guid.counter() as u64) + ); + } + + #[test] + fn rest_state_save_player_flags_preserves_current_bits_like_cpp() { + let (mut session, _, _) = make_session(); + let guid = ObjectGuid::create_player(1, 0xE1AF); + let canonical = Arc::new(Mutex::new(wow_map::MapManager::new(60_000, 1))); + session.set_canonical_map_manager(Arc::clone(&canonical)); + session.set_player_guid(Some(guid)); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.ensure_login_player_controller_like_cpp( + guid, + "RestFlags".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 1, + 1, + 8, + 10, + 0, + ); + insert_session_player_into_canonical_map_like_cpp(&session, &canonical, 1, 0); + session + .mutate_canonical_player_like_cpp(|player| { + player.set_player_flag(PLAYER_FLAGS_AFK_LIKE_CPP); + player.set_player_flag(PLAYER_FLAGS_DND_LIKE_CPP); + }) + .expect("canonical player exists"); + + assert_eq!( + session.represented_player_flags_for_rest_state_save_like_cpp(), + PLAYER_FLAGS_AFK_LIKE_CPP | PLAYER_FLAGS_DND_LIKE_CPP + ); + + session.set_represented_rest_flag_like_cpp(REST_FLAG_IN_CITY_LIKE_CPP, 0); + assert_eq!( + session.represented_player_flags_for_rest_state_save_like_cpp(), + PLAYER_FLAGS_AFK_LIKE_CPP | PLAYER_FLAGS_DND_LIKE_CPP | PLAYER_FLAGS_RESTING_LIKE_CPP + ); + + session.remove_represented_rest_flag_like_cpp(REST_FLAG_IN_CITY_LIKE_CPP); + assert_eq!( + session.represented_player_flags_for_rest_state_save_like_cpp(), + PLAYER_FLAGS_AFK_LIKE_CPP | PLAYER_FLAGS_DND_LIKE_CPP + ); + } + + #[test] + fn rest_state_save_player_flags_preserves_loaded_db_bits_without_canonical_like_cpp() { + let (mut session, _, _) = make_session(); + session.set_loaded_player_flags_like_cpp( + PLAYER_FLAGS_GHOST_LIKE_CPP | PLAYER_FLAGS_AFK_LIKE_CPP, + ); + + assert_eq!( + session.represented_player_flags_for_rest_state_save_like_cpp(), + PLAYER_FLAGS_GHOST_LIKE_CPP | PLAYER_FLAGS_AFK_LIKE_CPP + ); + + session.set_represented_rest_flag_like_cpp(REST_FLAG_IN_CITY_LIKE_CPP, 0); + assert_eq!( + session.represented_player_flags_for_rest_state_save_like_cpp(), + PLAYER_FLAGS_GHOST_LIKE_CPP | PLAYER_FLAGS_AFK_LIKE_CPP | PLAYER_FLAGS_RESTING_LIKE_CPP + ); + } + + #[test] + fn loaded_player_flags_rehydrate_canonical_player_like_cpp() { + let (mut session, _, _) = make_session(); + let guid = ObjectGuid::create_player(1, 0xE1B2); + let canonical = Arc::new(Mutex::new(wow_map::MapManager::new(60_000, 1))); + session.set_canonical_map_manager(Arc::clone(&canonical)); + session.set_player_guid(Some(guid)); + session.set_loaded_player_flags_like_cpp( + PLAYER_FLAGS_GHOST_LIKE_CPP | PLAYER_FLAGS_AFK_LIKE_CPP, + ); + session.ensure_login_player_controller_like_cpp( + guid, + "LoadedFlags".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 1, + 1, + 8, + 10, + 0, + ); + insert_session_player_into_canonical_map_like_cpp(&session, &canonical, 1, 0); + + session.apply_loaded_player_flags_to_canonical_like_cpp(); + + assert!( + session + .canonical_player_has_player_flag_like_cpp(guid, PLAYER_FLAGS_GHOST_LIKE_CPP) + .unwrap_or(false) + ); + assert!( + session + .canonical_player_has_player_flag_like_cpp(guid, PLAYER_FLAGS_AFK_LIKE_CPP) + .unwrap_or(false) + ); + } + + #[test] + fn player_create_flags_use_loaded_and_canonical_bits_like_cpp() { + let (mut session, _, _) = make_session(); + let guid = ObjectGuid::create_player(1, 0xE1B3); + let canonical = Arc::new(Mutex::new(wow_map::MapManager::new(60_000, 1))); + session.set_canonical_map_manager(Arc::clone(&canonical)); + session.set_player_guid(Some(guid)); + session.set_loaded_player_flags_like_cpp( + PLAYER_FLAGS_GHOST_LIKE_CPP | PLAYER_FLAGS_AFK_LIKE_CPP, + ); + session.set_loaded_player_flags_ex_like_cpp(0x04); + + assert_eq!( + session.represented_player_flags_for_create_like_cpp(), + ( + PLAYER_FLAGS_GHOST_LIKE_CPP | PLAYER_FLAGS_AFK_LIKE_CPP, + 0x04 + ) + ); + + session.ensure_login_player_controller_like_cpp( + guid, + "CreateFlags".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 1, + 1, + 8, + 10, + 0, + ); + insert_session_player_into_canonical_map_like_cpp(&session, &canonical, 1, 0); + session.apply_loaded_player_flags_to_canonical_like_cpp(); + session + .mutate_canonical_player_like_cpp(|player| { + player.set_player_flag(PLAYER_FLAGS_DND_LIKE_CPP); + }) + .expect("canonical player exists"); + session.set_represented_rest_flag_like_cpp(REST_FLAG_IN_CITY_LIKE_CPP, 0); + + assert_eq!( + session.represented_player_flags_for_create_like_cpp(), + ( + PLAYER_FLAGS_GHOST_LIKE_CPP + | PLAYER_FLAGS_AFK_LIKE_CPP + | PLAYER_FLAGS_DND_LIKE_CPP + | PLAYER_FLAGS_RESTING_LIKE_CPP, + 0x04, + ) + ); + } + + #[test] + fn load_resting_logout_state_restores_current_rest_flag_like_cpp() { + let (mut session, _, _) = make_session(); + let guid = ObjectGuid::create_player(1, 0xE1B0); + let canonical = Arc::new(Mutex::new(wow_map::MapManager::new(60_000, 1))); + session.set_canonical_map_manager(Arc::clone(&canonical)); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.ensure_login_player_controller_like_cpp( + guid, + "RestLogin".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 1, + 1, + 8, + 10, + 0, + ); + insert_session_player_into_canonical_map_like_cpp(&session, &canonical, 1, 0); + session.set_player_next_level_xp_like_cpp(72_000); + session.set_player_zone_area_like_cpp(10, 100); + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_RESTED_LIKE_CPP, 123.0); + + assert!(!session.represented_is_resting_like_cpp()); + assert!( + !session + .canonical_player_has_player_flag_like_cpp(guid, PLAYER_FLAGS_RESTING_LIKE_CPP) + .unwrap_or(false) + ); + + session.restore_represented_logout_resting_flag_like_cpp(true); + + assert!(session.represented_is_resting_like_cpp()); + assert_eq!( + session.represented_rest_flag_mask_like_cpp & REST_FLAG_IN_TAVERN_LIKE_CPP, + 0, + "logout rest restore must not synthesize a tavern trigger" + ); + assert_eq!(session.represented_inn_area_trigger_id_like_cpp, 0); + assert!( + session + .canonical_player_has_player_flag_like_cpp(guid, PLAYER_FLAGS_RESTING_LIKE_CPP) + .unwrap_or(false) + ); + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), 123.0); + assert_eq!( + session.represented_xp_rest_state_like_cpp(), + REST_STATE_RESTED_LIKE_CPP + ); + + assert!(session.update_area_represented_like_cpp(101)); + assert!(!session.represented_is_resting_like_cpp()); + assert!( + !session + .canonical_player_has_player_flag_like_cpp(guid, PLAYER_FLAGS_RESTING_LIKE_CPP) + .unwrap_or(true) + ); + } + + #[test] + fn load_rest_state_clears_stale_rest_flags_between_characters_like_cpp() { + let (mut session, _, _) = make_session(); + let guid = ObjectGuid::create_player(1, 0xE1B1); + let canonical = Arc::new(Mutex::new(wow_map::MapManager::new(60_000, 1))); + session.set_canonical_map_manager(Arc::clone(&canonical)); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.ensure_login_player_controller_like_cpp( + guid, + "RestSwap".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 1, + 1, + 8, + 10, + 0, + ); + insert_session_player_into_canonical_map_like_cpp(&session, &canonical, 1, 0); + + assert!(session.set_represented_rest_flag_like_cpp(REST_FLAG_IN_TAVERN_LIKE_CPP, 42)); + assert!(session.represented_is_resting_like_cpp()); + assert_eq!(session.represented_inn_area_trigger_id_like_cpp, 42); + assert_ne!(session.represented_rest_time_secs_like_cpp, 0); + + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_NORMAL_LIKE_CPP, 0.0); + + assert!(!session.represented_is_resting_like_cpp()); + assert_eq!(session.represented_inn_area_trigger_id_like_cpp, 0); + assert_eq!(session.represented_rest_time_secs_like_cpp, 0); + assert!( + !session + .canonical_player_has_player_flag_like_cpp(guid, PLAYER_FLAGS_RESTING_LIKE_CPP) + .unwrap_or(true) + ); + } + + #[test] + fn stale_raf_linked_rest_state_recomputes_when_bonus_is_normalized_like_cpp() { + let (mut session, _, _) = make_session(); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.set_player_next_level_xp_like_cpp(1_000); + + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_RAF_LINKED_LIKE_CPP, 0.0); + + assert_eq!( + session.represented_xp_rest_state_like_cpp(), + REST_STATE_NORMAL_LIKE_CPP + ); + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), 0.0); + + session.add_represented_xp_rest_bonus_like_cpp(25.0); + + assert_eq!( + session.represented_xp_rest_state_like_cpp(), + REST_STATE_RESTED_LIKE_CPP, + "C++ RestMgr::SetRestBonus only keeps REST_STATE_RAF_LINKED when RAF currently applies" + ); + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), 25.0); + } + + #[test] + fn tavern_area_trigger_sets_and_clears_represented_resting_like_cpp() { + let (mut session, _, _) = make_session(); + let outcome = wow_data::TavernAreaTriggerStoreLikeCpp::from_ids_like_cpp([42], |_| true); + session.set_tavern_area_trigger_store(Arc::new(outcome.store)); + + assert!(session.handle_represented_tavern_area_trigger_like_cpp(42, true)); + assert!(session.represented_is_resting_like_cpp()); + + assert!(session.handle_represented_tavern_area_trigger_like_cpp(42, false)); + assert!(!session.represented_is_resting_like_cpp()); + assert!(!session.handle_represented_tavern_area_trigger_like_cpp(77, true)); + } + + #[test] + fn online_rest_update_adds_rested_xp_after_ten_seconds_like_cpp() { + let (mut session, _, _) = make_session(); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.set_player_next_level_xp_like_cpp(72_000); + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_NORMAL_LIKE_CPP, 0.0); + assert!(session.set_represented_rest_flag_like_cpp(REST_FLAG_IN_CITY_LIKE_CPP, 0)); + session.represented_rest_time_secs_like_cpp = 1_000; + + assert_eq!( + session.update_represented_online_xp_rest_bonus_like_cpp(1_009), + 0.0 + ); + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), 0.0); + + let extra = session.update_represented_online_xp_rest_bonus_like_cpp(1_010); + + assert!(extra > 0.0); + assert_eq!(session.represented_rest_time_secs_like_cpp, 1_010); + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), extra); + assert_eq!( + session.represented_xp_rest_state_like_cpp(), + REST_STATE_RESTED_LIKE_CPP + ); + } + + fn test_db2_area_trigger_like_cpp( + trigger_id: u32, + map_id: u16, + pos: Position, + ) -> wow_data::AreaTriggerDb2Entry { + wow_data::AreaTriggerDb2Entry { + id: trigger_id, + message: String::new(), + pos: wow_data::Db2Position3 { + x: pos.x, + y: pos.y, + z: pos.z, + }, + continent_id: map_id as i16, + phase_use_flags: 0, + phase_id: 0, + phase_group_id: 0, + radius: 5.0, + box_length: 0.0, + box_width: 0.0, + box_height: 0.0, + box_yaw: 0.0, + shape_type: 0, + shape_id: 0, + area_trigger_action_set_id: 0, + flags: 0, + } + } + + #[tokio::test] + async fn area_trigger_tavern_validates_db2_trigger_and_position_like_cpp() { + let (mut session, _, _) = make_session(); + let outcome = wow_data::TavernAreaTriggerStoreLikeCpp::from_ids_like_cpp([42], |_| true); + session.set_tavern_area_trigger_store(Arc::new(outcome.store)); + session.set_area_trigger_db2_store(Arc::new(wow_data::AreaTriggerDb2Store::from_entries( + [test_db2_area_trigger_like_cpp( + 42, + 1, + Position::new(10.0, 20.0, 30.0, 0.0), + )], + ))); + session.set_player_map_position_like_cpp(1, Position::new(11.0, 20.0, 30.0, 0.0)); + let mut pkt = WorldPacket::new_empty(); + pkt.write_uint32(42); + pkt.write_bit(true); + pkt.write_bit(false); + pkt.flush_bits(); + + session.handle_area_trigger(pkt).await; + + assert!(session.represented_is_resting_like_cpp()); + } + + #[tokio::test] + async fn area_trigger_tavern_rejects_spoofed_far_enter_like_cpp() { + let (mut session, _, _) = make_session(); + let outcome = wow_data::TavernAreaTriggerStoreLikeCpp::from_ids_like_cpp([42], |_| true); + session.set_tavern_area_trigger_store(Arc::new(outcome.store)); + session.set_area_trigger_db2_store(Arc::new(wow_data::AreaTriggerDb2Store::from_entries( + [test_db2_area_trigger_like_cpp( + 42, + 1, + Position::new(10.0, 20.0, 30.0, 0.0), + )], + ))); + session.set_player_map_position_like_cpp(1, Position::new(100.0, 20.0, 30.0, 0.0)); + let mut pkt = WorldPacket::new_empty(); + pkt.write_uint32(42); + pkt.write_bit(true); + pkt.write_bit(false); + pkt.flush_bits(); + + session.handle_area_trigger(pkt).await; + + assert!(!session.represented_is_resting_like_cpp()); + } + + #[tokio::test] + async fn area_trigger_tavern_rejects_spoofed_inside_leave_like_cpp() { + let (mut session, _, _) = make_session(); + let outcome = wow_data::TavernAreaTriggerStoreLikeCpp::from_ids_like_cpp([42], |_| true); + session.set_tavern_area_trigger_store(Arc::new(outcome.store)); + session.set_area_trigger_db2_store(Arc::new(wow_data::AreaTriggerDb2Store::from_entries( + [test_db2_area_trigger_like_cpp( + 42, + 1, + Position::new(10.0, 20.0, 30.0, 0.0), + )], + ))); + session.set_player_map_position_like_cpp(1, Position::new(11.0, 20.0, 30.0, 0.0)); + + let mut enter = WorldPacket::new_empty(); + enter.write_uint32(42); + enter.write_bit(true); + enter.write_bit(false); + enter.flush_bits(); + session.handle_area_trigger(enter).await; + assert!(session.represented_is_resting_like_cpp()); + + let mut inside_leave = WorldPacket::new_empty(); + inside_leave.write_uint32(42); + inside_leave.write_bit(false); + inside_leave.write_bit(false); + inside_leave.flush_bits(); + session.handle_area_trigger(inside_leave).await; + + assert!(session.represented_is_resting_like_cpp()); + + session.set_player_map_position_like_cpp(1, Position::new(100.0, 20.0, 30.0, 0.0)); + let mut outside_leave = WorldPacket::new_empty(); + outside_leave.write_uint32(42); + outside_leave.write_bit(false); + outside_leave.write_bit(false); + outside_leave.flush_bits(); + session.handle_area_trigger(outside_leave).await; + + assert!(!session.represented_is_resting_like_cpp()); + } + + #[test] + fn tavern_rest_revalidation_clears_stale_trigger_like_cpp() { + let (mut session, _, _) = make_session(); + let guid = ObjectGuid::create_player(1, 0xE1B2); + let canonical = Arc::new(Mutex::new(wow_map::MapManager::new(60_000, 1))); + session.set_canonical_map_manager(Arc::clone(&canonical)); + session.set_player_guid(Some(guid)); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.ensure_login_player_controller_like_cpp( + guid, + "RestRecheck".to_string(), + Position::new(10.0, 20.0, 30.0, 0.0), + 1, + 1, + 8, + 10, + 0, + ); + insert_session_player_into_canonical_map_like_cpp(&session, &canonical, 1, 0); + session.set_area_trigger_db2_store(Arc::new(wow_data::AreaTriggerDb2Store::from_entries( + [test_db2_area_trigger_like_cpp( + 42, + 1, + Position::new(10.0, 20.0, 30.0, 0.0), + )], + ))); + session.set_player_map_position_like_cpp(1, Position::new(11.0, 20.0, 30.0, 0.0)); + + assert!(session.set_represented_tavern_resting_like_cpp(42, true)); + assert!(session.represented_is_resting_like_cpp()); + assert_eq!(session.represented_inn_area_trigger_id_like_cpp, 42); + assert!( + session + .canonical_player_has_player_flag_like_cpp(guid, PLAYER_FLAGS_RESTING_LIKE_CPP) + .unwrap_or(false) + ); + + session.set_player_map_position_like_cpp(1, Position::new(100.0, 20.0, 30.0, 0.0)); + session.revalidate_represented_tavern_resting_like_cpp(); + + assert!(!session.represented_is_resting_like_cpp()); + assert_eq!(session.represented_inn_area_trigger_id_like_cpp, 0); + assert!( + !session + .canonical_player_has_player_flag_like_cpp(guid, PLAYER_FLAGS_RESTING_LIKE_CPP) + .unwrap_or(true) + ); + } + + #[test] + fn give_xp_runtime_spends_rested_bonus_for_victim_like_cpp() { + let (mut session, _, send_rx) = make_session(); + let victim = test_creature_guid(77); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.set_player_next_level_xp_like_cpp(1_000); + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_RESTED_LIKE_CPP, 70.0); + + assert!(session.give_xp_runtime_like_cpp(50, victim, true)); + + assert_eq!(session.player_xp_like_cpp(), 100); + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), 20.0); + let packets = drain_server_packet_bytes(&send_rx); + let mut pkt = WorldPacket::from_bytes( + packets + .iter() + .find(|bytes| { + WorldPacket::from_bytes(bytes).server_opcode() == Some(ServerOpcodes::LogXpGain) + }) + .expect("GiveXP sends LogXPGain"), + ); + pkt.skip_opcode(); + assert_eq!(pkt.read_packed_guid().unwrap(), victim); + assert_eq!(pkt.read_int32().unwrap(), 100); + assert_eq!(pkt.read_uint8().unwrap(), 0); + assert_eq!(pkt.read_int32().unwrap(), 50); + assert!((pkt.read_float().unwrap() - 1.0).abs() < f32::EPSILON); + assert_eq!(pkt.remaining(), 0); + } + + #[test] + fn give_xp_runtime_applies_rested_xp_consumption_modifier_like_cpp() { + let (mut session, _, _) = make_session(); + let victim = test_creature_guid(79); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.set_player_next_level_xp_like_cpp(1_000); + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_RESTED_LIKE_CPP, 70.0); + let effect = wow_data::SpellEffectInfo { + effect_index: 0, + effect: wow_data::spell::spell_effect_types::SPELL_EFFECT_APPLY_AURA, + effect_aura: wow_data::spell::aura_types::SPELL_AURA_MOD_RESTED_XP_CONSUMPTION, + effect_base_points: 50, + ..Default::default() + }; + session + .apply_represented_aura_modifier_like_cpp( + 12_345, + ObjectGuid::EMPTY, + &effect, + RepresentedAuraEffectLikeCpp::ModRestedXpConsumption, + 30_000, + ) + .expect("rested consumption aura should apply"); + + assert!(session.give_xp_runtime_like_cpp(40, victim, true)); + + assert_eq!(session.player_xp_like_cpp(), 80); + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), 10.0); + } + + #[test] + fn give_xp_runtime_does_not_spend_rested_bonus_without_victim_like_cpp() { + let (mut session, _, send_rx) = make_session(); + session.set_loaded_player_identity_like_cpp(1, 1, 8, 10, 0); + session.set_player_next_level_xp_like_cpp(1_000); + session.load_represented_xp_rest_bonus_like_cpp(REST_STATE_RESTED_LIKE_CPP, 70.0); + + assert!(session.give_xp_runtime_like_cpp(50, ObjectGuid::EMPTY, false)); + + assert_eq!(session.player_xp_like_cpp(), 50); + assert_eq!(session.represented_xp_rest_bonus_like_cpp(), 70.0); + let packets = drain_server_packet_bytes(&send_rx); + let mut pkt = WorldPacket::from_bytes( + packets + .iter() + .find(|bytes| { + WorldPacket::from_bytes(bytes).server_opcode() == Some(ServerOpcodes::LogXpGain) + }) + .expect("GiveXP sends LogXPGain"), + ); + pkt.skip_opcode(); + assert_eq!(pkt.read_packed_guid().unwrap(), ObjectGuid::EMPTY); + assert_eq!(pkt.read_int32().unwrap(), 50); + assert_eq!(pkt.read_uint8().unwrap(), 1); + assert_eq!(pkt.read_int32().unwrap(), 50); + assert!((pkt.read_float().unwrap() - 1.0).abs() < f32::EPSILON); + assert_eq!(pkt.remaining(), 0); + } + #[test] fn character_position_save_uses_captured_snapshot_map_instance_like_cpp() { let guid = ObjectGuid::create_player(1, 5003); @@ -100083,6 +101705,49 @@ mod tests { assert_eq!(session.represented_area_zone_criteria_like_cpp().len(), 2); } + #[test] + fn update_area_sets_faction_area_rest_flag_like_cpp() { + let (mut session, _, _) = make_session(); + let player_guid = ObjectGuid::create_player(1, 0xE19F); + session.ensure_login_player_controller_like_cpp( + player_guid, + "AllianceRestArea".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 571, + 1, + 1, + 80, + 0, + ); + session.set_player_zone_area_like_cpp(10, 100); + session.set_area_table_store(Arc::new(wow_data::AreaTableStore::from_entries([ + wow_data::AreaTableEntry { + id: 101, + continent_id: 571, + parent_area_id: 0, + area_bit: -1, + exploration_level: 0, + mount_flags: 0, + flags: wow_data::AREA_FLAG_ALLIANCE_RESTING_LIKE_CPP, + }, + wow_data::AreaTableEntry { + id: 102, + continent_id: 571, + parent_area_id: 0, + area_bit: -1, + exploration_level: 0, + mount_flags: 0, + flags: wow_data::AREA_FLAG_HORDE_RESTING_LIKE_CPP, + }, + ]))); + + assert!(session.update_area_represented_like_cpp(101)); + assert!(session.represented_is_resting_like_cpp()); + + assert!(session.update_area_represented_like_cpp(102)); + assert!(!session.represented_is_resting_like_cpp()); + } + #[test] fn update_zone_records_area_then_top_level_criteria_like_cpp() { let (mut session, _, _) = make_session(); @@ -100124,6 +101789,82 @@ mod tests { ); } + #[test] + fn update_zone_linked_chat_sets_city_rest_only_when_not_hostile_like_cpp() { + let (mut session, _, _) = make_session(); + let player_guid = ObjectGuid::create_player(1, 0xE1A2); + session.ensure_login_player_controller_like_cpp( + player_guid, + "CityRest".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 571, + 1, + 1, + 80, + 0, + ); + session.set_player_zone_area_like_cpp(10, 100); + session.set_area_table_store(Arc::new(wow_data::AreaTableStore::from_entries([ + wow_data::AreaTableEntry { + id: 20, + continent_id: 571, + parent_area_id: 0, + area_bit: -1, + exploration_level: 0, + mount_flags: 0, + flags: wow_data::AREA_FLAG_LINKED_CHAT_LIKE_CPP, + }, + wow_data::AreaTableEntry { + id: 30, + continent_id: 571, + parent_area_id: 0, + area_bit: -1, + exploration_level: 0, + mount_flags: 0, + flags: 0, + }, + wow_data::AreaTableEntry { + id: 40, + continent_id: 571, + parent_area_id: 0, + area_bit: -1, + exploration_level: 0, + mount_flags: 0, + flags: wow_data::AREA_FLAG_LINKED_CHAT_LIKE_CPP + | AREA_FLAG_FREE_FOR_ALL_PVP_LIKE_CPP, + }, + wow_data::AreaTableEntry { + id: 50, + continent_id: 571, + parent_area_id: 0, + area_bit: -1, + exploration_level: 0, + mount_flags: 0, + flags: wow_data::AREA_FLAG_LINKED_CHAT_LIKE_CPP + | wow_data::AREA_FLAG_NO_PVP_LIKE_CPP, + }, + ]))); + + session.set_player_pvp_hostile_like_cpp(true); + assert!(session.update_zone_represented_like_cpp(20, 101)); + assert!(session.represented_is_resting_like_cpp()); + assert!( + !session.player_pvp_hostile_like_cpp, + "C++ Player::UpdateZone recalculates pvpInfo.IsHostile before city rest" + ); + + assert!(session.update_zone_represented_like_cpp(40, 102)); + assert!(!session.represented_is_resting_like_cpp()); + assert!(session.player_pvp_hostile_like_cpp); + + assert!(session.update_zone_represented_like_cpp(50, 103)); + assert!(session.represented_is_resting_like_cpp()); + + session.set_player_pvp_hostile_like_cpp(false); + assert!(session.update_zone_represented_like_cpp(30, 104)); + assert!(!session.represented_is_resting_like_cpp()); + } + #[test] fn update_zone_missing_zone_keeps_area_criteria_but_skips_top_level_like_cpp() { let (mut session, _, _) = make_session();