diff --git a/crates/world-server/src/main.rs b/crates/world-server/src/main.rs index abc060bb..e715c65b 100644 --- a/crates/world-server/src/main.rs +++ b/crates/world-server/src/main.rs @@ -4932,6 +4932,7 @@ async fn main() -> Result { mmap_pathfinder.clone(), legacy_creature_aggro_config.clone(), map_update_interval_ms, + Some(Arc::clone(&char_db)), Arc::clone(&player_registry), ); @@ -12931,6 +12932,7 @@ fn spawn_legacy_creature_runtime_update_loop_like_cpp( mmap_pathfinder: Option>, aggro_config: wow_world::session::LegacyCreatureAggroConfigLikeCpp, tick_interval_ms: u32, + character_db: Option>, player_registry: Arc, ) -> tokio::task::JoinHandle<()> { if !enabled { @@ -12973,6 +12975,18 @@ fn spawn_legacy_creature_runtime_update_loop_like_cpp( break; }; + if let Some(character_db) = character_db.as_ref() { + for stmt in &outcome.lifecycle.respawn_db_statements { + if let Err(error) = character_db.execute(stmt).await { + warn!( + ?error, + sql = stmt.sql(), + "Failed to persist legacy creature respawn timer like C++" + ); + } + } + } + let touched_creatures = outcome.lifecycle.corpses_despawned + outcome.movement.movement_packets + outcome.aggro.aggro_starts @@ -23360,6 +23374,7 @@ mmap.enablePathFinding = 0 None, wow_world::session::LegacyCreatureAggroConfigLikeCpp::default(), 1, + None, Arc::clone(®istry), ); diff --git a/crates/wow-world/src/map_manager.rs b/crates/wow-world/src/map_manager.rs index f8c9c505..4ed13ec7 100644 --- a/crates/wow-world/src/map_manager.rs +++ b/crates/wow-world/src/map_manager.rs @@ -14,6 +14,9 @@ use wow_constants::{ UnitStandStateType, UnitState, WeaponAttackType, }; use wow_core::{ObjectGuid, Position}; +use wow_database::{ + CharStatements, CharacterDatabase, DatabaseError, PreparedStatement, StatementDef, +}; use wow_entities::{ AllowedPositionZCaps, Creature, CreatureAddonLifecycleRecordLikeCpp, CreatureAiState, DEFAULT_HEIGHT_SEARCH, DistractMovementAction, EVENT_CHARGE_PREPATH, GenericMovementInform, @@ -21,7 +24,7 @@ use wow_entities::{ PointMovementAction, PointMovementInform, RotateMovementUpdate, Z_OFFSET_FIND_HEIGHT, allowed_position_z_from_ground_like_cpp, }; -use wow_map::{GridMapTerrain, SharedStaticVMapLineOfSightProvider}; +use wow_map::{GridMapTerrain, SharedStaticVMapLineOfSightProvider, SpawnObjectType}; use wow_movement::generators::CreatureRandomMovementType as MovementCreatureRandomMovementType; use wow_movement::{ MoveSpline, MoveSplineInit, MoveSplineLaunchInput, MoveSplineStopInput, MoveSplineStopResult, @@ -81,6 +84,182 @@ const TERRAIN_GRID_COUNT_LIKE_CPP: usize = MAX_NUMBER_OF_GRIDS_LIKE_CPP as usize * MAX_NUMBER_OF_GRIDS_LIKE_CPP as usize; const SMOOTH_PATH_STEP_SIZE_LIKE_CPP: f32 = 4.0; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PersistedRespawnRowLikeCpp { + pub object_type: SpawnObjectType, + pub spawn_id: u64, + pub respawn_time: i64, + pub map_id: u16, + pub instance_id: u32, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PersistedRespawnLoadReportLikeCpp { + pub rows: usize, + pub loaded: usize, + pub invalid_type: usize, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LegacyRespawnQueueReloadReportLikeCpp { + pub rows: usize, + pub timers_loaded: usize, + pub creature_queued: usize, + pub gameobject_loaded: usize, + pub rejected_zero_spawn_id: usize, + pub rejected_unsupported_type: usize, + pub rejected_existing_later: usize, + pub missing_creature_runtime: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LegacyRespawnTimeAddOutcomeLikeCpp { + Inserted, + ReplacedExisting, + RejectedZeroSpawnId, + RejectedUnsupportedType, + RejectedExistingSoonerOrEqual, +} + +fn spawn_object_type_raw_like_cpp(object_type: SpawnObjectType) -> u16 { + u16::from(object_type as u8) +} + +pub fn respawn_time_from_instant_like_cpp(respawn_at: Instant, now: Instant, now_secs: i64) -> i64 { + let delay_secs = respawn_at + .checked_duration_since(now) + .map(|delay| i64::try_from(delay.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0); + now_secs.saturating_add(delay_secs) +} + +pub fn instant_from_respawn_time_like_cpp( + respawn_time: i64, + now: Instant, + now_secs: i64, +) -> Instant { + let delay_secs = respawn_time.saturating_sub(now_secs); + if delay_secs <= 0 { + return now; + } + + now.checked_add(Duration::from_secs( + u64::try_from(delay_secs).unwrap_or(u64::MAX), + )) + .unwrap_or(now) +} + +pub fn respawn_replace_statement_like_cpp(row: &PersistedRespawnRowLikeCpp) -> PreparedStatement { + let mut stmt = PreparedStatement::new(CharStatements::REP_RESPAWN.sql()); + // C++ `Map::SaveRespawnInfoDB`: type, spawnId, respawnTime, mapId, instanceId. + stmt.set_u16(0, spawn_object_type_raw_like_cpp(row.object_type)); + stmt.set_u64(1, row.spawn_id); + stmt.set_i64(2, row.respawn_time); + stmt.set_u16(3, row.map_id); + stmt.set_u32(4, row.instance_id); + stmt +} + +pub fn respawn_delete_statement_like_cpp( + object_type: SpawnObjectType, + spawn_id: u64, + map_id: u16, + instance_id: u32, +) -> PreparedStatement { + let mut stmt = PreparedStatement::new(CharStatements::DEL_RESPAWN.sql()); + // C++ `Map::DeleteRespawnInfoFromDB`: type, spawnId, mapId, instanceId. + stmt.set_u16(0, spawn_object_type_raw_like_cpp(object_type)); + stmt.set_u64(1, spawn_id); + stmt.set_u16(2, map_id); + stmt.set_u32(3, instance_id); + stmt +} + +pub async fn load_persisted_respawn_rows_for_map_like_cpp( + character_db: &CharacterDatabase, + map_id: u16, + instance_id: u32, +) -> Result< + ( + Vec, + PersistedRespawnLoadReportLikeCpp, + ), + DatabaseError, +> { + let mut stmt = character_db.prepare(CharStatements::SEL_RESPAWNS); + stmt.set_u16(0, map_id); + stmt.set_u32(1, instance_id); + let mut result = character_db.query(&stmt).await?; + let mut rows = Vec::new(); + let mut report = PersistedRespawnLoadReportLikeCpp::default(); + + if result.is_empty() { + return Ok((rows, report)); + } + + loop { + report.rows += 1; + let object_type_raw = result + .try_read::(0) + .or_else(|| result.try_read::(0).map(u16::from)) + .unwrap_or(u16::MAX); + let Some(object_type) = u8::try_from(object_type_raw) + .ok() + .and_then(SpawnObjectType::from_raw) + else { + report.invalid_type += 1; + if !result.next_row() { + break; + } + continue; + }; + + rows.push(PersistedRespawnRowLikeCpp { + object_type, + spawn_id: result + .try_read::(1) + .or_else(|| result.try_read::(1).map(|value| value as u64)) + .unwrap_or(0), + respawn_time: result.try_read::(2).unwrap_or(0), + map_id, + instance_id, + }); + report.loaded += 1; + + if !result.next_row() { + break; + } + } + + Ok((rows, report)) +} + +pub async fn execute_respawn_replace_like_cpp( + character_db: &CharacterDatabase, + row: &PersistedRespawnRowLikeCpp, +) -> Result { + character_db + .execute(&respawn_replace_statement_like_cpp(row)) + .await +} + +pub async fn execute_respawn_delete_like_cpp( + character_db: &CharacterDatabase, + object_type: SpawnObjectType, + spawn_id: u64, + map_id: u16, + instance_id: u32, +) -> Result { + character_db + .execute(&respawn_delete_statement_like_cpp( + object_type, + spawn_id, + map_id, + instance_id, + )) + .await +} + fn point_path_limit_for_distance_like_cpp(distance: f32) -> usize { let point_limit = if distance.is_sign_negative() { 0 @@ -2873,6 +3052,9 @@ pub struct MapInstance { pub grid_unload_timeout: Duration, pub personal_phases: MultiPersonalPhaseTracker, personal_phase_objects_to_remove: HashSet, + /// C++ `Map::_creatureRespawnTimesBySpawnId` and + /// `_gameObjectRespawnTimesBySpawnId`, represented as DB-persistable rows. + pub persisted_respawn_times: HashMap<(SpawnObjectType, u64), PersistedRespawnRowLikeCpp>, /// Creatures waiting to respawn; drained by `tick_creatures_sync`. /// C++ ref: `Map::_respawnTimes` (Map.h:748). pub respawn_queue: Vec, @@ -2887,6 +3069,7 @@ impl MapInstance { grid_unload_timeout: DEFAULT_GRID_UNLOAD_TIME, personal_phases: MultiPersonalPhaseTracker::default(), personal_phase_objects_to_remove: HashSet::new(), + persisted_respawn_times: HashMap::new(), respawn_queue: Vec::new(), } } @@ -3064,6 +3247,57 @@ impl MapInstance { // Mirrors `Map::_respawnTimes` (Map.h:748-750) ownership model. // The queue is a plain `Vec`; heap/SpawnId convergence is deferred. + pub fn add_persisted_respawn_time_like_cpp( + &mut self, + row: PersistedRespawnRowLikeCpp, + ) -> LegacyRespawnTimeAddOutcomeLikeCpp { + if row.spawn_id == 0 { + return LegacyRespawnTimeAddOutcomeLikeCpp::RejectedZeroSpawnId; + } + if !matches!( + row.object_type, + SpawnObjectType::Creature | SpawnObjectType::GameObject + ) { + return LegacyRespawnTimeAddOutcomeLikeCpp::RejectedUnsupportedType; + } + + let key = (row.object_type, row.spawn_id); + if let Some(existing) = self.persisted_respawn_times.get(&key) { + if row.respawn_time <= existing.respawn_time { + self.persisted_respawn_times.insert(key, row); + LegacyRespawnTimeAddOutcomeLikeCpp::ReplacedExisting + } else { + LegacyRespawnTimeAddOutcomeLikeCpp::RejectedExistingSoonerOrEqual + } + } else { + self.persisted_respawn_times.insert(key, row); + LegacyRespawnTimeAddOutcomeLikeCpp::Inserted + } + } + + pub fn remove_persisted_respawn_time_like_cpp( + &mut self, + object_type: SpawnObjectType, + spawn_id: u64, + ) -> Option { + self.persisted_respawn_times + .remove(&(object_type, spawn_id)) + } + + pub fn persisted_respawn_time_like_cpp( + &self, + object_type: SpawnObjectType, + spawn_id: u64, + ) -> Option { + self.persisted_respawn_times + .get(&(object_type, spawn_id)) + .map(|row| row.respawn_time) + } + + pub fn persisted_respawn_rows_like_cpp(&self) -> Vec { + self.persisted_respawn_times.values().copied().collect() + } + /// Enqueue a creature waiting to respawn. /// C++ ref: `Map::_respawnTimes` insertion path (Map.cpp:2191). pub fn push_respawn(&mut self, respawn: PendingRespawn) { @@ -3103,6 +3337,83 @@ impl MapInstance { pub fn respawn_queue_len(&self) -> usize { self.respawn_queue.len() } + + pub fn save_pending_respawn_time_like_cpp( + &mut self, + respawn: &PendingRespawn, + now: Instant, + now_secs: i64, + ) -> Option { + let row = PersistedRespawnRowLikeCpp { + object_type: SpawnObjectType::Creature, + spawn_id: respawn.spawn_id, + respawn_time: respawn_time_from_instant_like_cpp(respawn.respawn_at, now, now_secs), + map_id: self.map_id, + instance_id: self.instance_id, + }; + match self.add_persisted_respawn_time_like_cpp(row) { + LegacyRespawnTimeAddOutcomeLikeCpp::Inserted + | LegacyRespawnTimeAddOutcomeLikeCpp::ReplacedExisting => { + Some(respawn_replace_statement_like_cpp(&row)) + } + LegacyRespawnTimeAddOutcomeLikeCpp::RejectedZeroSpawnId + | LegacyRespawnTimeAddOutcomeLikeCpp::RejectedUnsupportedType + | LegacyRespawnTimeAddOutcomeLikeCpp::RejectedExistingSoonerOrEqual => None, + } + } + + pub fn load_persisted_respawns_into_queue_like_cpp( + &mut self, + rows: impl IntoIterator, + now: Instant, + now_secs: i64, + mut resolve_creature: impl FnMut(&PersistedRespawnRowLikeCpp, Instant) -> Option, + ) -> LegacyRespawnQueueReloadReportLikeCpp { + let mut report = LegacyRespawnQueueReloadReportLikeCpp::default(); + for row in rows { + report.rows += 1; + match self.add_persisted_respawn_time_like_cpp(row) { + LegacyRespawnTimeAddOutcomeLikeCpp::Inserted + | LegacyRespawnTimeAddOutcomeLikeCpp::ReplacedExisting => { + report.timers_loaded += 1; + } + LegacyRespawnTimeAddOutcomeLikeCpp::RejectedZeroSpawnId => { + report.rejected_zero_spawn_id += 1; + continue; + } + LegacyRespawnTimeAddOutcomeLikeCpp::RejectedUnsupportedType => { + report.rejected_unsupported_type += 1; + continue; + } + LegacyRespawnTimeAddOutcomeLikeCpp::RejectedExistingSoonerOrEqual => { + report.rejected_existing_later += 1; + continue; + } + } + + let respawn_at = instant_from_respawn_time_like_cpp(row.respawn_time, now, now_secs); + match row.object_type { + SpawnObjectType::Creature => { + if let Some(mut pending) = resolve_creature(&row, respawn_at) { + pending.respawn_at = respawn_at; + pending.spawn_id = row.spawn_id; + pending.map_id = row.map_id; + self.push_respawn(pending); + report.creature_queued += 1; + } else { + report.missing_creature_runtime += 1; + } + } + SpawnObjectType::GameObject => { + report.gameobject_loaded += 1; + } + SpawnObjectType::AreaTrigger => { + report.rejected_unsupported_type += 1; + } + } + } + report + } } /// Who owns the creature/combat tick for a given map at runtime. @@ -3812,6 +4123,82 @@ impl MapManager { .unwrap_or(0) } + pub fn save_pending_respawn_time_like_cpp( + &mut self, + map_id: u16, + instance_id: u32, + respawn: &PendingRespawn, + now: Instant, + now_secs: i64, + ) -> Option { + self.get_or_create_map(map_id, instance_id) + .save_pending_respawn_time_like_cpp(respawn, now, now_secs) + } + + pub fn remove_persisted_respawn_time_like_cpp( + &mut self, + map_id: u16, + instance_id: u32, + object_type: SpawnObjectType, + spawn_id: u64, + ) -> Option { + let map = self.get_map_mut(map_id, instance_id)?; + map.remove_persisted_respawn_time_like_cpp(object_type, spawn_id)?; + Some(respawn_delete_statement_like_cpp( + object_type, + spawn_id, + map_id, + instance_id, + )) + } + + pub fn persisted_respawn_time_like_cpp( + &self, + map_id: u16, + instance_id: u32, + object_type: SpawnObjectType, + spawn_id: u64, + ) -> Option { + self.get_map(map_id, instance_id)? + .persisted_respawn_time_like_cpp(object_type, spawn_id) + } + + pub fn persisted_respawn_rows_like_cpp( + &self, + map_id: u16, + instance_id: u32, + ) -> Vec { + self.get_map(map_id, instance_id) + .map(|map| map.persisted_respawn_rows_like_cpp()) + .unwrap_or_default() + } + + pub fn load_persisted_respawns_into_queue_like_cpp( + &mut self, + rows: impl IntoIterator, + now: Instant, + now_secs: i64, + mut resolve_creature: impl FnMut(&PersistedRespawnRowLikeCpp, Instant) -> Option, + ) -> LegacyRespawnQueueReloadReportLikeCpp { + let mut report = LegacyRespawnQueueReloadReportLikeCpp::default(); + for row in rows { + let row_report = self + .get_or_create_map(row.map_id, row.instance_id) + .load_persisted_respawns_into_queue_like_cpp([row], now, now_secs, |row, at| { + resolve_creature(row, at) + }); + report.rows += row_report.rows; + report.timers_loaded += row_report.timers_loaded; + report.creature_queued += row_report.creature_queued; + report.gameobject_loaded += row_report.gameobject_loaded; + report.rejected_zero_spawn_id += row_report.rejected_zero_spawn_id; + report.rejected_unsupported_type += row_report.rejected_unsupported_type; + report.rejected_existing_later += row_report.rejected_existing_later; + report.missing_creature_runtime += row_report.missing_creature_runtime; + } + report + } + pub fn player_enter_grid( &mut self, map_id: u16, @@ -7903,6 +8290,65 @@ mod tests { assert_eq!(map.respawn_queue_len(), 1); } + #[test] + fn pending_respawn_save_load_roundtrip_uses_cpp_respawn_table_statement() { + let mut manager = MapManager::new(); + let now = Instant::now(); + let now_secs = 1_700_000_000; + let mut pending = make_pending_respawn(now + Duration::from_secs(45)); + pending.spawn_id = u64::from(u32::MAX) + 17; + pending.map_id = 571; + + let stmt = manager + .save_pending_respawn_time_like_cpp(571, 0, &pending, now, now_secs) + .expect("future creature respawn should queue CHAR_REP_RESPAWN"); + + assert_eq!(stmt.sql(), CharStatements::REP_RESPAWN.sql()); + assert!(matches!(stmt.params()[0], wow_database::SqlParam::U16(0))); + assert!(matches!( + stmt.params()[1], + wow_database::SqlParam::U64(value) if value == pending.spawn_id + )); + assert!(matches!( + stmt.params()[2], + wow_database::SqlParam::I64(value) if value == now_secs + 45 + )); + assert!(matches!(stmt.params()[3], wow_database::SqlParam::U16(571))); + assert!(matches!(stmt.params()[4], wow_database::SqlParam::U32(0))); + + assert_eq!( + manager.persisted_respawn_time_like_cpp( + 571, + 0, + SpawnObjectType::Creature, + pending.spawn_id + ), + Some(now_secs + 45) + ); + + let rows = manager.persisted_respawn_rows_like_cpp(571, 0); + let mut restarted = MapManager::new(); + let report = restarted.load_persisted_respawns_into_queue_like_cpp( + rows, + now, + now_secs, + |_row, at| Some(make_pending_respawn(at)), + ); + + assert_eq!(report.rows, 1); + assert_eq!(report.timers_loaded, 1); + assert_eq!(report.creature_queued, 1); + assert_eq!(restarted.respawn_queue_len(571, 0), 1); + assert!( + restarted + .drain_ready_respawns(571, 0, now + Duration::from_secs(44)) + .is_empty() + ); + let ready = restarted.drain_ready_respawns(571, 0, now + Duration::from_secs(45)); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].spawn_id, pending.spawn_id); + } + #[test] fn push_respawn_replaces_later_duplicate_spawn_id_like_cpp() { let mut map = MapInstance::new(0, 0); @@ -8046,6 +8492,78 @@ mod tests { assert_eq!(map.respawn_queue_len(), 1); } + #[test] + fn persisted_respawn_restart_load_expired_ready_future_queued_and_gameobject_timer_loaded() { + let mut manager = MapManager::new(); + let now = Instant::now(); + let now_secs = 2_000_000; + let rows = vec![ + PersistedRespawnRowLikeCpp { + object_type: SpawnObjectType::Creature, + spawn_id: 10, + respawn_time: now_secs - 5, + map_id: 571, + instance_id: 0, + }, + PersistedRespawnRowLikeCpp { + object_type: SpawnObjectType::Creature, + spawn_id: 11, + respawn_time: now_secs + 60, + map_id: 571, + instance_id: 0, + }, + PersistedRespawnRowLikeCpp { + object_type: SpawnObjectType::GameObject, + spawn_id: 12, + respawn_time: now_secs + 90, + map_id: 571, + instance_id: 0, + }, + ]; + + let report = + manager.load_persisted_respawns_into_queue_like_cpp(rows, now, now_secs, |_row, at| { + Some(make_pending_respawn(at)) + }); + + assert_eq!(report.rows, 3); + assert_eq!(report.timers_loaded, 3); + assert_eq!(report.creature_queued, 2); + assert_eq!(report.gameobject_loaded, 1); + assert_eq!( + manager.persisted_respawn_time_like_cpp(571, 0, SpawnObjectType::GameObject, 12), + Some(now_secs + 90) + ); + + let ready = manager.drain_ready_respawns(571, 0, now); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].spawn_id, 10); + assert_eq!(manager.respawn_queue_len(571, 0), 1); + + let delete = manager + .remove_persisted_respawn_time_like_cpp(571, 0, SpawnObjectType::Creature, 10) + .expect("processed C++ respawn should queue CHAR_DEL_RESPAWN"); + assert_eq!(delete.sql(), CharStatements::DEL_RESPAWN.sql()); + assert!(matches!(delete.params()[0], wow_database::SqlParam::U16(0))); + assert!(matches!( + delete.params()[1], + wow_database::SqlParam::U64(10) + )); + assert!(matches!( + delete.params()[2], + wow_database::SqlParam::U16(571) + )); + assert!(matches!(delete.params()[3], wow_database::SqlParam::U32(0))); + assert_eq!( + manager.persisted_respawn_time_like_cpp(571, 0, SpawnObjectType::Creature, 10), + None + ); + + let future = manager.drain_ready_respawns(571, 0, now + Duration::from_secs(59)); + assert!(future.is_empty()); + assert_eq!(manager.respawn_queue_len(571, 0), 1); + } + /// Ready entries are returned in insertion order. #[test] fn drain_preserves_insertion_order_like_cpp() { diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index 7c70d3f7..0091ad39 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -2961,6 +2961,47 @@ pub(crate) fn remove_canonical_creature_map_object_on_map_like_cpp( let _ = map.map_mut().remove_from_map_like_cpp(guid, true); } +pub(crate) fn add_canonical_creature_respawn_info_and_remove_map_object_on_map_like_cpp( + manager: &SharedCanonicalMapManager, + map_id: u32, + instance_id: u32, + guid: ObjectGuid, + info: wow_map::RespawnInfoLikeCpp, +) -> (bool, bool) { + let Ok(mut manager) = manager.lock() else { + return (false, false); + }; + let Some(map) = manager.find_map_mut(map_id, instance_id) else { + return (false, false); + }; + + let respawn_added = matches!( + map.map_mut().add_respawn_info_like_cpp(info), + wow_map::AddRespawnInfoOutcomeLikeCpp::Inserted + | wow_map::AddRespawnInfoOutcomeLikeCpp::ReplacedExisting + ); + let object_removed = map.map_mut().remove_from_map_like_cpp(guid, true).is_ok(); + (respawn_added, object_removed) +} + +pub(crate) fn remove_canonical_respawn_time_on_map_like_cpp( + manager: &SharedCanonicalMapManager, + map_id: u32, + instance_id: u32, + object_type: wow_map::SpawnObjectType, + spawn_id: wow_map::SpawnId, +) -> bool { + let Ok(mut manager) = manager.lock() else { + return false; + }; + let Some(map) = manager.find_map_mut(map_id, instance_id) else { + return false; + }; + map.map_mut() + .remove_respawn_time_like_cpp(object_type, spawn_id) + .is_some() +} + #[derive(Debug, Clone)] pub struct LegacyCreatureMovementTickOutcomeLikeCpp { pub skipped_owner_not_global: bool, @@ -2978,8 +3019,11 @@ pub struct LegacyCreatureLifecycleTickOutcomeLikeCpp { pub creatures_seen: usize, pub corpses_despawned: usize, pub respawns_processed: usize, + pub respawn_db_statements: Vec, pub canonical_removes: usize, pub canonical_inserts: usize, + pub canonical_respawn_adds: usize, + pub canonical_respawn_removes: usize, /// Map instances whose sessions must recompute creature visibility. pub refresh_map_keys: Vec<(u16, u32)>, } @@ -46572,14 +46616,19 @@ pub fn run_legacy_creature_lifecycle_tick_once_like_cpp( ) -> LegacyCreatureLifecycleTickOutcomeLikeCpp { use crate::map_manager::{ RuntimeTickOwner, pending_respawn_from_world_creature_like_cpp, - world_creature_from_pending_respawn_like_cpp, world_to_grid_coords, + respawn_time_from_instant_like_cpp, world_creature_from_pending_respawn_like_cpp, + world_to_grid_coords, }; use std::collections::BTreeSet; let mut outcome = LegacyCreatureLifecycleTickOutcomeLikeCpp::default(); let mut affected_maps = BTreeSet::new(); - let mut canonical_removes: Vec<(u32, u32, ObjectGuid)> = Vec::new(); + let mut canonical_respawn_despawns: Vec<(u32, u32, ObjectGuid, wow_map::RespawnInfoLikeCpp)> = + Vec::new(); + let mut canonical_respawn_removes: Vec<(u32, u32, wow_map::SpawnObjectType, wow_map::SpawnId)> = + Vec::new(); let mut canonical_inserts: Vec<(u32, u32, wow_entities::Creature)> = Vec::new(); + let now_secs = unix_now(); { let mut manager = legacy_map_manager @@ -46622,8 +46671,30 @@ pub fn run_legacy_creature_lifecycle_tick_once_like_cpp( ); let pending = pending_respawn_from_world_creature_like_cpp(&creature, respawn_at, map_id); + let grid = wow_map::compute_grid_coord(pending.home_pos.x, pending.home_pos.y); + let canonical_respawn_info = wow_map::RespawnInfoLikeCpp { + object_type: wow_map::SpawnObjectType::Creature, + spawn_id: pending.spawn_id, + entry: pending.create_data.entry, + respawn_time: respawn_time_from_instant_like_cpp(respawn_at, now, now_secs), + grid_id: grid.get_id(), + }; + if let Some(stmt) = manager.save_pending_respawn_time_like_cpp( + map_id, + instance_id, + &pending, + now, + now_secs, + ) { + outcome.respawn_db_statements.push(stmt); + } manager.push_respawn(map_id, instance_id, pending); - canonical_removes.push((u32::from(map_id), instance_id, guid)); + canonical_respawn_despawns.push(( + u32::from(map_id), + instance_id, + guid, + canonical_respawn_info, + )); affected_maps.insert((map_id, instance_id)); outcome.corpses_despawned += 1; } @@ -46632,6 +46703,21 @@ pub fn run_legacy_creature_lifecycle_tick_once_like_cpp( for respawn in ready_respawns { let guid = respawn.create_data.guid; if manager.find_creature(map_id, instance_id, guid).is_some() { + if let Some(stmt) = manager.remove_persisted_respawn_time_like_cpp( + map_id, + instance_id, + wow_map::SpawnObjectType::Creature, + respawn.spawn_id, + ) { + outcome.respawn_db_statements.push(stmt); + } + canonical_respawn_removes.push(( + u32::from(map_id), + instance_id, + wow_map::SpawnObjectType::Creature, + respawn.spawn_id, + )); + affected_maps.insert((map_id, instance_id)); continue; } let position = @@ -46650,6 +46736,20 @@ pub fn run_legacy_creature_lifecycle_tick_once_like_cpp( let canonical_creature = world_creature.creature.clone(); let (grid_x, grid_y) = world_to_grid_coords(position.x, position.y); if manager.add_creature(map_id, instance_id, grid_x, grid_y, world_creature) { + if let Some(stmt) = manager.remove_persisted_respawn_time_like_cpp( + map_id, + instance_id, + wow_map::SpawnObjectType::Creature, + respawn.spawn_id, + ) { + outcome.respawn_db_statements.push(stmt); + } + canonical_respawn_removes.push(( + u32::from(map_id), + instance_id, + wow_map::SpawnObjectType::Creature, + respawn.spawn_id, + )); canonical_inserts.push((u32::from(map_id), instance_id, canonical_creature)); affected_maps.insert((map_id, instance_id)); outcome.respawns_processed += 1; @@ -46659,14 +46759,32 @@ pub fn run_legacy_creature_lifecycle_tick_once_like_cpp( } if let Some(canonical_map_manager) = canonical_map_manager { - for (map_id, instance_id, guid) in canonical_removes { - remove_canonical_creature_map_object_on_map_like_cpp( + for (map_id, instance_id, guid, info) in canonical_respawn_despawns { + let (respawn_added, object_removed) = + add_canonical_creature_respawn_info_and_remove_map_object_on_map_like_cpp( + canonical_map_manager, + map_id, + instance_id, + guid, + info, + ); + if respawn_added { + outcome.canonical_respawn_adds += 1; + } + if object_removed { + outcome.canonical_removes += 1; + } + } + for (map_id, instance_id, object_type, spawn_id) in canonical_respawn_removes { + if remove_canonical_respawn_time_on_map_like_cpp( canonical_map_manager, map_id, instance_id, - guid, - ); - outcome.canonical_removes += 1; + object_type, + spawn_id, + ) { + outcome.canonical_respawn_removes += 1; + } } for (map_id, instance_id, creature) in canonical_inserts { insert_canonical_creature_map_object_on_map_like_cpp( @@ -127534,8 +127652,15 @@ mod tests { assert_eq!(outcome.creatures_seen, 1); assert_eq!(outcome.corpses_despawned, 1); assert_eq!(outcome.respawns_processed, 0); + assert_eq!(outcome.respawn_db_statements.len(), 1); + assert_eq!( + outcome.respawn_db_statements[0].sql(), + CharStatements::REP_RESPAWN.sql() + ); assert_eq!(outcome.canonical_removes, 1); assert_eq!(outcome.canonical_inserts, 0); + assert_eq!(outcome.canonical_respawn_adds, 1); + assert_eq!(outcome.canonical_respawn_removes, 0); assert_eq!(outcome.refresh_map_keys, vec![(0, 0)]); { @@ -127561,6 +127686,18 @@ mod tests { .is_none(), "canonical map object must be removed outside the legacy lock" ); + assert!( + guard + .find_map(0, 0) + .unwrap() + .map() + .get_respawn_time_like_cpp( + wow_map::SpawnObjectType::Creature, + (guid.low_value() as u64) & 0xFF_FFFF_FFFF, + ) + > 0, + "canonical respawn store must retain the same timer C++ Map::SaveRespawnTime adds" + ); } } @@ -127604,7 +127741,27 @@ mod tests { now - Duration::from_secs(1), 0, ); - manager.write().unwrap().push_respawn(0, 0, pending); + { + let grid = wow_map::compute_grid_coord(pending.home_pos.x, pending.home_pos.y); + canonical + .lock() + .unwrap() + .find_map_mut(0, 0) + .unwrap() + .map_mut() + .add_respawn_info_like_cpp(wow_map::RespawnInfoLikeCpp { + object_type: wow_map::SpawnObjectType::Creature, + spawn_id: pending.spawn_id, + entry: pending.create_data.entry, + respawn_time: unix_now() + 1, + grid_id: grid.get_id(), + }); + } + { + let mut guard = manager.write().unwrap(); + guard.save_pending_respawn_time_like_cpp(0, 0, &pending, now, unix_now()); + guard.push_respawn(0, 0, pending); + } let outcome = run_legacy_creature_lifecycle_tick_once_like_cpp(&manager, Some(&canonical), now); @@ -127613,8 +127770,15 @@ mod tests { assert_eq!(outcome.maps_seen, 1); assert_eq!(outcome.corpses_despawned, 0); assert_eq!(outcome.respawns_processed, 1); + assert_eq!(outcome.respawn_db_statements.len(), 1); + assert_eq!( + outcome.respawn_db_statements[0].sql(), + CharStatements::DEL_RESPAWN.sql() + ); assert_eq!(outcome.canonical_removes, 0); assert_eq!(outcome.canonical_inserts, 1); + assert_eq!(outcome.canonical_respawn_adds, 0); + assert_eq!(outcome.canonical_respawn_removes, 1); assert_eq!(outcome.refresh_map_keys, vec![(0, 0)]); { @@ -127641,7 +127805,119 @@ mod tests { typed.unit().world().phase_shift().has_phase_like_cpp(77), "canonical respawn must preserve the captured phase shift" ); + assert_eq!( + guard + .find_map(0, 0) + .unwrap() + .map() + .get_respawn_time_like_cpp( + wow_map::SpawnObjectType::Creature, + (guid.low_value() as u64) & 0xFF_FFFF_FFFF, + ), + 0, + "ready respawn must clear the canonical timer before the grid can reload stale state" + ); + } + } + + #[test] + fn legacy_creature_lifecycle_tick_once_clears_timer_when_canonical_won_like_cpp() { + use crate::map_manager::{ + RuntimeTickOwner, pending_respawn_from_world_creature_like_cpp, world_to_grid_coords, + }; + + let manager = shared_map_manager(); + let canonical = shared_canonical_map_manager(); + canonical.lock().unwrap().create_world_map(0, 0); + manager + .write() + .unwrap() + .set_tick_owner(RuntimeTickOwner::GlobalLegacy); + + let now = Instant::now(); + let guid = test_creature_guid(90_012); + let world_creature = crate::map_manager::WorldCreature::new( + guid, + 9002, + Position::new(8.0, 9.0, 10.0, 1.5), + 35, + 4, + 5, + 9, + 20.0, + 101, + 14, + 0, + 0, + ); + let pending = pending_respawn_from_world_creature_like_cpp( + &world_creature, + now - Duration::from_secs(1), + 0, + ); + let spawn_id = pending.spawn_id; + + { + let mut guard = manager.write().unwrap(); + let (grid_x, grid_y) = + world_to_grid_coords(world_creature.position().x, world_creature.position().y); + assert!( + guard.add_creature(0, 0, grid_x, grid_y, world_creature), + "test setup simulates canonical ProcessRespawns already mirroring the creature into legacy" + ); + assert!( + guard + .save_pending_respawn_time_like_cpp(0, 0, &pending, now, unix_now()) + .is_some(), + "test setup must leave the stale persisted timer that the ready queue clears" + ); + guard.push_respawn(0, 0, pending); + assert!( + guard + .persisted_respawn_time_like_cpp( + 0, + 0, + wow_map::SpawnObjectType::Creature, + spawn_id, + ) + .is_some() + ); } + + let outcome = + run_legacy_creature_lifecycle_tick_once_like_cpp(&manager, Some(&canonical), now); + + assert!(!outcome.skipped_owner_not_global); + assert_eq!(outcome.corpses_despawned, 0); + assert_eq!( + outcome.respawns_processed, 0, + "legacy must not insert a duplicate when the creature is already present" + ); + assert_eq!(outcome.respawn_db_statements.len(), 1); + assert_eq!( + outcome.respawn_db_statements[0].sql(), + CharStatements::DEL_RESPAWN.sql() + ); + assert_eq!(outcome.canonical_inserts, 0); + assert_eq!(outcome.canonical_respawn_removes, 0); + assert_eq!(outcome.refresh_map_keys, vec![(0, 0)]); + + let guard = manager.read().unwrap(); + assert!( + guard.find_creature(0, 0, guid).is_some(), + "canonical-won creature must remain in legacy" + ); + assert_eq!(guard.respawn_queue_len(0, 0), 0); + assert_eq!( + guard.persisted_respawn_time_like_cpp( + 0, + 0, + wow_map::SpawnObjectType::Creature, + spawn_id, + ), + None, + "stale legacy persisted timer must be cleared before the next death" + ); } #[test]