diff --git a/AGENTS.md b/AGENTS.md index 04982ba5..5982d795 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,9 @@ Work issue # (alseif0x/rustycore). 3. Smallest faithful change + focused tests (positive/negative); validate with PROTOC=... cargo check/test. 4. Git: create the branch LINKED to the issue with `gh issue develop --base 3.4.3` (not a bare `git checkout -b`), 1 issue = 1 PR into `3.4.3` (put `Closes #` in the PR body), commit per gap, NO push unless asked. -5. Do not mark "done" until capture-clean vs C++ (capture-diff harness = issue [01]/#66). + Once push is approved, open the PR immediately so CI and the configured Codex reviewer can run; creating the PR is not the same as closing/merging it. +5. Do not mark "done" until capture-clean vs C++ (capture-diff harness = issue [01]/#66) + and the required `Codex reviewer verdict` check is green for the PR's current HEAD. ``` **Linking the branch/PR to the issue:** the repo's **default branch is `3.4.3`** (the version/ @@ -113,7 +115,7 @@ Every implementation slice must follow this sequence: 7. Update migration docs/checklists with the new `#NEXT.R8.ENTITIES.xxx` item when closing a represented implementation gap. 8. Recalculate progress honestly. 9. Run validation. -10. Commit on the issue's feature branch, push, and open a PR into `3.4.3` (`Closes #`); merge after review/CI. Tag releases on `3.4.3`. +10. Commit on the issue's feature branch, push, and open a PR into `3.4.3` (`Closes #`); merge only after CI and the required `Codex reviewer verdict` check are both green on the PR's current HEAD, and every actionable reviewer comment is either fixed or explicitly documented as intentionally deferred. Tag releases on `3.4.3`. Do not do "bulk close" inventory edits. A closed `#NEXT` item must correspond to real code and tests, with exact C++ refs, Rust targets, checks run, and remaining boundaries stated. Discovering or documenting a gap is useful, but it is not an implementation closeout. @@ -284,11 +286,22 @@ git diff --check git add git commit -m "" git push origin # NO push unless asked -# open the PR into 3.4.3 with `Closes #` in the body; merge after review/CI. +# after push, open the PR into 3.4.3 with `Closes #` in the body so CI and +# the configured Codex reviewer can run. +# Do not merge or close the issue until CI is green and the required +# `Codex reviewer verdict` check is green for the PR's current HEAD. +# If Codex leaves feedback, fix or explicitly defer every actionable comment, +# resolve the review threads, push the fix, comment `@codex review`, and wait +# for `Codex reviewer verdict` to pass again on the new HEAD. ``` Only do this after the slice is genuinely validated. If the tree contains changes from another agent, audit them before building on top of them. +Branch protection on `3.4.3` requires strict status checks, linear history, conversation +resolution, and these checks: `Format`, `Check core crates`, `Focused library tests`, and +`Codex reviewer verdict`. A PR can show normal CI green while still being blocked if Codex has +not reviewed the current HEAD or has left unresolved feedback. + ## Local Context Files The `.gitignore` excludes local agent/workflow files that may exist and contain useful context, such as `AGENTS.md`, `PLAN.md`, `MIGRATION_STATUS.md`, `INVENTORY.md`, `memory/`, `.claude/`, `.agents/`, `.openclaw/`, and similar directories. Read them if useful, but do not commit ignored local context unless the user explicitly asks for it. diff --git a/crates/wow-database/src/statements/character.rs b/crates/wow-database/src/statements/character.rs index 265bbea5..9596859d 100644 --- a/crates/wow-database/src/statements/character.rs +++ b/crates/wow-database/src/statements/character.rs @@ -1173,6 +1173,9 @@ pub enum CharStatements { /// UPDATE characters SET money = ? WHERE guid = ? UPD_CHAR_MONEY, + /// C++ `CHAR_UPD_CHARACTER` persists this field immediately before powers. + /// UPDATE characters SET health = ? WHERE guid = ? + UPD_CHAR_HEALTH, /// C++ `CHAR_UPD_CHARACTER` persists these fields in the full save. /// UPDATE characters SET power1 = ?, ..., power10 = ? WHERE guid = ? UPD_CHAR_POWERS, @@ -2743,6 +2746,7 @@ impl StatementDef for CharStatements { Self::UPD_CHAR_XP => "UPDATE characters SET xp = ? WHERE guid = ?", Self::UPD_CHAR_LEVEL => "UPDATE characters SET level = ?, xp = ? WHERE guid = ?", Self::UPD_CHAR_MONEY => "UPDATE characters SET money = ? WHERE guid = ?", + Self::UPD_CHAR_HEALTH => "UPDATE characters SET health = ? WHERE guid = ?", Self::UPD_CHAR_POWERS => { "UPDATE characters SET power1 = ?, power2 = ?, power3 = ?, power4 = ?, power5 = ?, power6 = ?, power7 = ?, power8 = ?, power9 = ?, power10 = ? WHERE guid = ?" } @@ -4585,6 +4589,10 @@ mod tests { CharStatements::UPD_CHAR_MONEY.sql(), "UPDATE characters SET money = ? WHERE guid = ?" ); + assert_eq!( + CharStatements::UPD_CHAR_HEALTH.sql(), + "UPDATE characters SET health = ? WHERE guid = ?" + ); assert_eq!( CharStatements::UPD_CHAR_POWERS.sql(), "UPDATE characters SET power1 = ?, power2 = ?, power3 = ?, power4 = ?, power5 = ?, power6 = ?, power7 = ?, power8 = ?, power9 = ?, power10 = ? WHERE guid = ?" diff --git a/crates/wow-world/src/handlers/character.rs b/crates/wow-world/src/handlers/character.rs index 20e53078..a8462756 100644 --- a/crates/wow-world/src/handlers/character.rs +++ b/crates/wow-world/src/handlers/character.rs @@ -1331,6 +1331,17 @@ fn default_health_mana(class: u8) -> (u32, u32) { } } +fn max_health_u32_like_cpp(max_health: i64) -> u32 { + max_health.max(1).min(i64::from(u32::MAX)) as u32 +} + +fn restored_saved_health_like_cpp(saved_health: Option, max_health: i64) -> i64 { + let max_health = max_health_u32_like_cpp(max_health); + saved_health + .map(|health| i64::from(health.min(max_health))) + .unwrap_or(i64::from(max_health)) +} + fn default_character_power1_like_cpp(class: u8, mana: u32) -> u32 { match primary_power_type_for_class_like_cpp(class) { PowerType::Energy => 100, @@ -5502,6 +5513,9 @@ impl WorldSession { ([0i32; 5], 0, 0, 0, 0) }; + // C++ `Player::LoadFromDB` restores `fields.health` after `UpdateAllStats`, + // clamping it to the recalculated max and preserving zero as corpse state. + let saved_health = result.try_read::(51); let loaded_powers = std::array::from_fn(|index| { result .try_read::(52 + index) @@ -5571,7 +5585,7 @@ impl WorldSession { ( PlayerCombatStats { - health: max_health, + health: restored_saved_health_like_cpp(saved_health, max_health), max_health, stats: [total_str, total_agi, total_sta, total_int, total_spi], base_armor, @@ -5597,7 +5611,7 @@ impl WorldSession { let (h, m) = default_health_mana(class); ( PlayerCombatStats { - health: h as i64, + health: restored_saved_health_like_cpp(saved_health, h as i64), max_health: h as i64, max_mana: m as i64, ..PlayerCombatStats::default() @@ -5609,7 +5623,7 @@ impl WorldSession { let (h, m) = default_health_mana(class); ( PlayerCombatStats { - health: h as i64, + health: restored_saved_health_like_cpp(saved_health, h as i64), max_health: h as i64, max_mana: m as i64, ..PlayerCombatStats::default() @@ -9186,7 +9200,7 @@ impl WorldSession { // C++ first lets Battlefield own the leave flow when one exists. Rust // has no battlefield manager attached to WorldSession yet, so this // represented branch covers the AreaTable/homebind path only. - self.set_player_alive_like_cpp(true); + self.apply_represented_resurrection_percent_like_cpp(1.0); if let Some(homebind) = self.represented_homebind_like_cpp() { self.teleport_to(homebind.map_id, homebind.position).await; } @@ -12489,6 +12503,17 @@ impl WorldSession { let hp_bonus = sta64.min(20) + (sta64 - 20).max(0) * 10 + gear_health as i64; let max_health = base_hp + hp_bonus; + let computed_max_health_u32 = max_health_u32_like_cpp(max_health); + let (health, max_health_for_update) = self + .sync_canonical_player_max_health_like_cpp(computed_max_health_u32) + .unwrap_or_else(|| { + let current = self.player_health_like_cpp().min(computed_max_health_u32); + self.set_player_health_like_cpp(current, computed_max_health_u32); + (current, computed_max_health_u32) + }); + let health = i64::from(health); + let max_health = i64::from(max_health_for_update); + // MaxMana from total INT let int64 = total_int as i64; let base_mp = ls.base_mana as i64; @@ -12674,7 +12699,7 @@ impl WorldSession { }; let changes = PlayerStatChanges { - health: max_health, + health, max_health, min_damage: min_d, max_damage: max_d, @@ -13728,7 +13753,7 @@ impl WorldSession { if attached_controller { let _ = self.ensure_canonical_world_map_for_current_player_like_cpp(); } - self.set_player_health_like_cpp( + self.sync_canonical_player_health_like_cpp( combat.health.max(0).min(u32::MAX as i64) as u32, combat.max_health.max(1).min(u32::MAX as i64) as u32, ); @@ -14407,6 +14432,24 @@ mod tests { player_guid: ObjectGuid, current_mana: i32, max_mana: i32, + ) { + attach_stat_update_player_with_mana_and_health( + session, + player_guid, + current_mana, + max_mana, + 100, + 100, + ); + } + + fn attach_stat_update_player_with_mana_and_health( + session: &mut WorldSession, + player_guid: ObjectGuid, + current_mana: i32, + max_mana: i32, + current_health: u32, + max_health: u32, ) { let mut player = wow_entities::Player::new(Some(1), false); player @@ -14419,6 +14462,8 @@ mod tests { .unit_mut() .world_mut() .relocate(Position::new(10.0, 20.0, 30.0, 0.0)); + player.unit_mut().set_max_health(u64::from(max_health)); + player.unit_mut().set_health(u64::from(current_health)); player.unit_mut().set_power_index(PowerType::Mana, Some(0)); player.unit_mut().set_max_power(PowerType::Mana, max_mana); player.unit_mut().set_power(PowerType::Mana, current_mana); @@ -14443,6 +14488,17 @@ mod tests { opcodes } + #[test] + fn restored_saved_health_preserves_dead_zero_like_cpp() { + assert_eq!(restored_saved_health_like_cpp(Some(0), 110), 0); + } + + #[test] + fn restored_saved_health_clamps_to_recomputed_max_like_cpp() { + assert_eq!(restored_saved_health_like_cpp(Some(500), 110), 110); + assert_eq!(restored_saved_health_like_cpp(Some(77), 110), 77); + } + #[test] fn stat_update_preserves_current_mana_like_cpp() { let (mut session, _send_rx) = make_session_with_send_capacity(1); @@ -14487,6 +14543,64 @@ mod tests { ); } + #[test] + fn stat_update_preserves_current_health_like_cpp() { + let (mut session, _send_rx) = make_session_with_send_capacity(1); + let player_guid = ObjectGuid::create_player(1, 79); + session.set_player_guid(Some(player_guid)); + session.set_loaded_player_identity_like_cpp(571, 1, 5, 80, 0); + session.set_player_stats(Arc::new(stats_store_with_priest_level80(1000, 40))); + attach_stat_update_player_with_mana_and_health( + &mut session, + player_guid, + 777, + 1320, + 77, + 500, + ); + + let (_, changes) = session + .player_stat_changes_like_cpp() + .expect("stat changes"); + + assert_eq!(changes.health, 77); + assert_eq!(changes.max_health, 110); + assert_eq!(session.player_health_like_cpp(), 77); + assert_eq!( + session.canonical_player_health_snapshot_like_cpp(), + Some((77, 110)) + ); + } + + #[test] + fn stat_update_clamps_current_health_to_new_max_like_cpp() { + let (mut session, _send_rx) = make_session_with_send_capacity(1); + let player_guid = ObjectGuid::create_player(1, 80); + session.set_player_guid(Some(player_guid)); + session.set_loaded_player_identity_like_cpp(571, 1, 5, 80, 0); + session.set_player_stats(Arc::new(stats_store_with_priest_level80(1000, 40))); + attach_stat_update_player_with_mana_and_health( + &mut session, + player_guid, + 777, + 1320, + 500, + 500, + ); + + let (_, changes) = session + .player_stat_changes_like_cpp() + .expect("stat changes"); + + assert_eq!(changes.health, 110); + assert_eq!(changes.max_health, 110); + assert_eq!(session.player_health_like_cpp(), 110); + assert_eq!( + session.canonical_player_health_snapshot_like_cpp(), + Some((110, 110)) + ); + } + #[tokio::test] async fn show_trade_skill_is_noop_null_like_cpp() { let (mut session, send_rx) = make_session_with_send_capacity(1); diff --git a/crates/wow-world/src/handlers/misc.rs b/crates/wow-world/src/handlers/misc.rs index 5232bdf7..39e7bf88 100644 --- a/crates/wow-world/src/handlers/misc.rs +++ b/crates/wow-world/src/handlers/misc.rs @@ -2656,7 +2656,12 @@ impl crate::session::WorldSession { // full player-corpse runtime; this represented slice only clears the // ghost/dead state when the already-known C++ gates pass. self.set_player_ghost_flag_like_cpp(false); - self.set_player_alive_like_cpp(true); + let restore_percent = if self.player_in_represented_battleground_like_cpp() { + 1.0 + } else { + 0.5 + }; + self.apply_represented_resurrection_percent_like_cpp(restore_percent); } /// CMSG_ACTIVATE_TAXI. @@ -8727,6 +8732,7 @@ mod tests { ); session.set_player_alive_like_cpp(false); session.set_player_ghost_flag_like_cpp(true); + let _ = session.sync_canonical_player_health_like_cpp(0, 100); session .handle_reclaim_corpse(reclaim_corpse_packet(corpse_guid)) @@ -8734,6 +8740,10 @@ mod tests { assert!(session.player_is_alive_like_cpp()); assert!(!session.player_has_ghost_flag_like_cpp()); + assert_eq!( + session.canonical_player_health_snapshot_like_cpp(), + Some((50, 100)) + ); assert!(send_rx.try_recv().is_err()); } diff --git a/crates/wow-world/src/session.rs b/crates/wow-world/src/session.rs index dde71724..7c70d3f7 100644 --- a/crates/wow-world/src/session.rs +++ b/crates/wow-world/src/session.rs @@ -1162,6 +1162,8 @@ pub(crate) struct PlayerSaveToDbSnapshotLikeCpp { pub level: u8, pub xp: u32, pub money: u64, + pub health: u32, + pub max_health: u32, pub powers: CharacterPowerSnapshotLikeCpp, } @@ -7218,10 +7220,10 @@ impl WorldSession { // C++ has one live Player object, and Player::SaveToDB reads a // coherent snapshot from that object. Rust still has split // session/canonical state: accepted client movement updates the - // session immediately, while other gameplay fields may only - // touch the canonical typed Player. Keep canonical gameplay - // fields, but prefer the latest accepted session map/position - // for logout/disconnect persistence. + // session immediately, while gameplay fields may be fresher on + // the canonical typed Player. Keep canonical gameplay fields, + // but prefer the latest accepted session map/position for + // logout/disconnect persistence. let (map_id, instance_id, position) = if let Some((map_id, position)) = pending_teleport_destination { (map_id, 0, position) @@ -7243,6 +7245,19 @@ impl WorldSession { powers[primary_slot] = Some(player.unit().get_power(primary_power_type).max(0)); } + let canonical_max_health = player + .unit() + .data() + .max_health + .max(1) + .min(u64::from(u32::MAX)) as u32; + let canonical_health = player.unit().data().health.min(u64::from(u32::MAX)) as u32; + let health = if !self.player_alive_like_cpp || self.player_health_like_cpp == 0 { + 0 + } else { + canonical_health + }; + snapshot = Some(PlayerSaveToDbSnapshotLikeCpp { guid, map_id, @@ -7251,6 +7266,8 @@ impl WorldSession { level: player.unit().data().level.clamp(0, i32::from(u8::MAX)) as u8, xp: player.active_data().xp.max(0) as u32, money: player.active_data().coinage, + health, + max_health: canonical_max_health, powers, }); }); @@ -7280,6 +7297,8 @@ impl WorldSession { level: self.player_level_like_cpp(), xp: self.player_xp_like_cpp(), money: self.player_gold_like_cpp(), + health: self.player_health_like_cpp, + max_health: self.player_max_health_like_cpp.max(1), powers: self.represented_player_powers_like_cpp, }) } @@ -7302,6 +7321,7 @@ impl WorldSession { self.set_player_level_like_cpp(snapshot.level); self.set_player_xp_like_cpp(snapshot.xp); self.set_player_gold_like_cpp(snapshot.money); + self.set_player_health_like_cpp(snapshot.health, snapshot.max_health); self.represented_player_powers_like_cpp = snapshot.powers; Some(snapshot) } @@ -7471,6 +7491,60 @@ impl WorldSession { result } + pub(crate) fn sync_canonical_player_max_health_like_cpp( + &mut self, + max_health: u32, + ) -> Option<(u32, u32)> { + let max_health = max_health.max(1); + let result = self.mutate_canonical_player_like_cpp(|player| { + // C++ `Unit::SetMaxHealth` updates max and clamps current only if needed. + player.unit_mut().set_max_health(u64::from(max_health)); + ( + player.unit().data().health.min(u64::from(u32::MAX)) as u32, + player.unit().data().max_health.min(u64::from(u32::MAX)) as u32, + ) + }); + if let Some((current, max)) = result { + self.set_player_health_like_cpp(current, max); + } + result + } + + pub(crate) fn sync_canonical_player_health_like_cpp( + &mut self, + health: u32, + max_health: u32, + ) -> Option<(u32, u32)> { + let max_health = max_health.max(1); + let health = health.min(max_health); + let result = self.mutate_canonical_player_like_cpp(|player| { + if health == 0 { + player + .unit_mut() + .set_death_state(wow_constants::DeathState::Corpse); + } else if matches!( + player.unit().death_state(), + wow_constants::DeathState::JustDied | wow_constants::DeathState::Corpse + ) { + player + .unit_mut() + .set_death_state(wow_constants::DeathState::Alive); + } + player.unit_mut().set_max_health(u64::from(max_health)); + player.unit_mut().set_health(u64::from(health)); + ( + player.unit().data().health.min(u64::from(u32::MAX)) as u32, + player.unit().data().max_health.min(u64::from(u32::MAX)) as u32, + ) + }); + if let Some((current, max)) = result { + self.set_player_health_like_cpp(current, max); + } else { + self.set_player_health_like_cpp(health, max_health); + } + result + } + pub(crate) fn set_canonical_chosen_title_like_cpp( &mut self, title_id: i32, @@ -11330,9 +11404,8 @@ impl WorldSession { self.player_alive_like_cpp = false; } let health_after = self.player_health_like_cpp; - let _ = self.mutate_canonical_player_like_cpp(|player| { - player.unit_mut().set_health(u64::from(health_after)); - }); + let _ = self + .sync_canonical_player_health_like_cpp(health_after, self.player_max_health_like_cpp); self.sync_player_registry_state_like_cpp(); if self.player_health_like_cpp != original_health { self.send_player_health_update_like_cpp( @@ -18489,6 +18562,15 @@ impl WorldSession { }) } + pub(crate) fn canonical_player_health_snapshot_like_cpp(&self) -> Option<(u32, u32)> { + self.canonical_player_snapshot_like_cpp(|player| { + ( + player.unit().data().health.min(u64::from(u32::MAX)) as u32, + player.unit().data().max_health.min(u64::from(u32::MAX)) as u32, + ) + }) + } + pub(crate) fn canonical_player_reputation_standings_snapshot_like_cpp( &self, ) -> Vec<(u32, i32)> { @@ -21755,6 +21837,38 @@ impl WorldSession { let _ = char_db.execute(&stmt).await; } + fn build_character_health_save_statement_like_cpp( + health: u32, + guid_counter: u64, + ) -> PreparedStatement { + let mut stmt = PreparedStatement::new(CharStatements::UPD_CHAR_HEALTH.sql()); + stmt.set_u32(0, health); + stmt.set_u64(1, guid_counter); + stmt + } + + fn build_character_health_save_statement_from_snapshot_like_cpp( + snapshot: &PlayerSaveToDbSnapshotLikeCpp, + ) -> PreparedStatement { + Self::build_character_health_save_statement_like_cpp( + snapshot.health, + snapshot.guid.counter() as u64, + ) + } + + async fn save_player_health_like_cpp(&self, snapshot: &PlayerSaveToDbSnapshotLikeCpp) { + let Some(char_db) = self.char_db().map(Arc::clone) else { + return; + }; + let stmt = Self::build_character_health_save_statement_from_snapshot_like_cpp(snapshot); + if let Err(err) = char_db.execute(&stmt).await { + warn!( + "Failed to save represented player health for guid {}: {err}", + snapshot.guid.counter() + ); + } + } + fn build_character_powers_save_statement_like_cpp( powers: [i32; 10], guid_counter: u64, @@ -21982,6 +22096,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_health_like_cpp(&snapshot).await; self.save_player_powers_like_cpp(&snapshot).await; self.save_player_talent_reset_state_like_cpp().await; self.save_player_explored_zones_like_cpp().await; @@ -35312,6 +35427,12 @@ impl WorldSession { self.player_alive_like_cpp = alive; if !alive { self.player_health_like_cpp = 0; + let _ = self.mutate_canonical_player_like_cpp(|player| { + player + .unit_mut() + .set_death_state(wow_constants::DeathState::Corpse); + player.unit_mut().set_health(0); + }); } else if self.player_health_like_cpp == 0 { self.player_health_like_cpp = self.player_max_health_like_cpp.max(1); } @@ -36275,6 +36396,10 @@ impl WorldSession { self.player_health_like_cpp = health_after.min(u64::from(self.player_max_health_like_cpp)) as u32; self.player_alive_like_cpp = self.player_health_like_cpp > 0; + let _ = self.sync_canonical_player_health_like_cpp( + self.player_health_like_cpp, + self.player_max_health_like_cpp, + ); self.sync_player_registry_state_like_cpp(); } @@ -36330,9 +36455,14 @@ impl WorldSession { } pub(crate) fn apply_represented_resurrection_health_like_cpp(&mut self, health: u32) { - self.set_player_health_like_cpp(health, self.player_max_health_like_cpp); - self.player_alive_like_cpp = true; - self.sync_player_registry_state_like_cpp(); + let _ = self.sync_canonical_player_health_like_cpp(health, self.player_max_health_like_cpp); + } + + pub(crate) fn apply_represented_resurrection_percent_like_cpp(&mut self, restore_percent: f32) { + let max_health = self.player_max_health_like_cpp.max(1); + let health = + ((f64::from(max_health) * f64::from(restore_percent)).floor() as u32).min(max_health); + self.apply_represented_resurrection_health_like_cpp(health); } pub(crate) fn schedule_represented_resurrection_after_teleport_like_cpp( @@ -36457,7 +36587,10 @@ impl WorldSession { if killed_player { self.apply_represented_player_environmental_death_like_cpp(); } else { - self.sync_player_registry_state_like_cpp(); + let _ = self.sync_canonical_player_health_like_cpp( + self.player_health_like_cpp, + self.player_max_health_like_cpp, + ); } if final_damage > 0 && let Some(player_guid) = self.player_guid() @@ -36579,6 +36712,11 @@ impl WorldSession { let killed_player = self.player_health_like_cpp == 0; if killed_player { self.apply_represented_player_environmental_death_like_cpp(); + } else { + let _ = self.sync_canonical_player_health_like_cpp( + self.player_health_like_cpp, + self.player_max_health_like_cpp, + ); } if self.player_health_like_cpp != original_health && let Some(player_guid) = self.player_guid() @@ -50577,9 +50715,13 @@ impl WorldSession { let final_damage = damage.min(original_health); self.player_health_like_cpp = self.player_health_like_cpp.saturating_sub(final_damage); if self.player_health_like_cpp == 0 { - self.player_alive_like_cpp = false; + self.apply_represented_player_environmental_death_like_cpp(); + } else { + let _ = self.sync_canonical_player_health_like_cpp( + self.player_health_like_cpp, + self.player_max_health_like_cpp, + ); } - self.sync_player_registry_state_like_cpp(); if self.player_health_like_cpp != original_health { self.send_player_health_update_like_cpp( player_guid, @@ -99121,10 +99263,13 @@ mod tests { player.unit_mut().set_level(42); player.set_xp(1234); player.set_money(5678); + player.unit_mut().set_max_health(900); + player.unit_mut().set_health(456); player.unit_mut().set_max_power(PowerType::Mana, 1000); player.unit_mut().set_power(PowerType::Mana, 321); }) .unwrap(); + session.set_player_health_like_cpp(456, 900); session.set_player_position_like_cpp(latest_session_position); let snapshot = session @@ -99140,6 +99285,8 @@ mod tests { level: 42, xp: 1234, money: 5678, + health: 456, + max_health: 900, powers: loaded_character_power_snapshot_like_cpp([ 321, 222, 0, 0, 0, 0, 0, 0, 0, 0, ]), @@ -99152,6 +99299,173 @@ mod tests { assert_eq!(session.player_level_like_cpp(), 42); assert_eq!(session.player_xp_like_cpp(), 1234); assert_eq!(session.player_gold_like_cpp(), 5678); + assert_eq!(session.player_health_like_cpp(), 456); + } + + #[test] + fn logout_save_snapshot_uses_fall_damage_synced_to_canonical_health_like_cpp() { + let (mut session, _, _) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 74); + let position = Position::new(77.0, 88.0, 99.0, 2.5); + + canonical.lock().unwrap().create_world_map(571, 0); + session.set_canonical_map_manager(Arc::clone(&canonical)); + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 571, + instance_type: wow_data::map::MAP_COMMON, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + session.attach_player_controller_like_cpp(SessionPlayerController::new( + player_guid, + "Damaged".to_string(), + position, + 571, + 1, + 3, + 80, + 0, + )); + let _ = session.ensure_canonical_world_map_for_current_player_like_cpp(); + session + .mutate_canonical_player_like_cpp(|player| { + player.unit_mut().set_max_health(6_310); + player.unit_mut().set_health(6_310); + }) + .unwrap(); + session.set_player_health_like_cpp(6_310, 6_310); + session.set_fall_information_like_cpp(1_200, 120.0); + let mut fall_land = wow_packet::packets::movement::MovementInfo::default(); + fall_land.position.z = 100.0; + fall_land.jump.fall_time = 1_500; + + let fall = session + .handle_fall_like_cpp(&fall_land) + .expect("fall damage should apply"); + assert!(fall.final_damage > 0); + let damaged_health = session.player_health_like_cpp(); + assert!(damaged_health < 6_310); + assert_eq!( + session.canonical_player_health_snapshot_like_cpp(), + Some((damaged_health, 6_310)), + "session-side fall damage must sync the canonical Player before SaveToDB snapshots it" + ); + + let snapshot = session + .sync_session_from_save_to_db_snapshot_like_cpp() + .expect("snapshot should exist"); + + assert_eq!(snapshot.health, damaged_health); + assert_eq!(snapshot.max_health, 6_310); + assert_eq!(session.player_health_like_cpp(), damaged_health); + } + + #[test] + fn logout_save_snapshot_uses_canonical_health_when_session_mirror_is_stale_like_cpp() { + let (mut session, _, _) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 75); + let position = Position::new(77.0, 88.0, 99.0, 2.5); + + canonical.lock().unwrap().create_world_map(571, 0); + session.set_canonical_map_manager(Arc::clone(&canonical)); + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 571, + instance_type: wow_data::map::MAP_COMMON, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + session.attach_player_controller_like_cpp(SessionPlayerController::new( + player_guid, + "CanonicalDamaged".to_string(), + position, + 571, + 1, + 3, + 80, + 0, + )); + let _ = session.ensure_canonical_world_map_for_current_player_like_cpp(); + session.set_player_health_like_cpp(100, 100); + session + .mutate_canonical_player_like_cpp(|player| { + player.unit_mut().set_max_health(100); + player.unit_mut().set_health(41); + }) + .unwrap(); + + let snapshot = session + .sync_session_from_save_to_db_snapshot_like_cpp() + .expect("snapshot should exist"); + + assert_eq!(snapshot.health, 41); + assert_eq!(snapshot.max_health, 100); + assert_eq!(session.player_health_like_cpp(), 41); + } + + #[test] + fn logout_save_snapshot_keeps_session_death_when_canonical_health_is_stale_like_cpp() { + let (mut session, _, _) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 76); + let position = Position::new(77.0, 88.0, 99.0, 2.5); + + canonical.lock().unwrap().create_world_map(571, 0); + session.set_canonical_map_manager(Arc::clone(&canonical)); + session.set_map_store(Arc::new(wow_data::MapStore::from_entries([ + wow_data::MapEntry { + id: 571, + instance_type: wow_data::map::MAP_COMMON, + expansion_id: 0, + parent_map_id: -1, + cosmetic_parent_map_id: -1, + flags1: 0, + flags2: 0, + }, + ]))); + session.attach_player_controller_like_cpp(SessionPlayerController::new( + player_guid, + "SessionDead".to_string(), + position, + 571, + 1, + 3, + 80, + 0, + )); + let _ = session.ensure_canonical_world_map_for_current_player_like_cpp(); + session.set_player_health_like_cpp(100, 100); + session + .mutate_canonical_player_like_cpp(|player| { + player.unit_mut().set_max_health(100); + player.unit_mut().set_health(100); + }) + .unwrap(); + + // Some represented death seams still live on the session side while the + // canonical player is being phased in. C++ has a single Player object, + // so SaveToDB must not resurrect the session by reading stale canonical HP. + session.player_health_like_cpp = 0; + session.player_alive_like_cpp = false; + + let snapshot = session + .sync_session_from_save_to_db_snapshot_like_cpp() + .expect("snapshot should exist"); + + assert_eq!(snapshot.health, 0); + assert_eq!(snapshot.max_health, 100); + assert_eq!(session.player_health_like_cpp(), 0); } #[test] @@ -99188,6 +99502,7 @@ mod tests { session.set_player_level_like_cpp(42); session.set_player_xp_like_cpp(1234); session.set_player_gold_like_cpp(5678); + session.set_player_health_like_cpp(33, 100); session.set_player_position_like_cpp(moved_position); let snapshot = session @@ -99198,6 +99513,8 @@ mod tests { assert_eq!(snapshot.level, 42); assert_eq!(snapshot.xp, 1234); assert_eq!(snapshot.money, 5678); + assert_eq!(snapshot.health, 33); + assert_eq!(snapshot.max_health, 100); assert_eq!(snapshot.powers, empty_character_power_snapshot_like_cpp()); } @@ -99296,6 +99613,22 @@ mod tests { ); } + #[test] + fn character_health_save_statement_matches_cpp_bind_order() { + let guid = ObjectGuid::create_player(1, 5006); + + let stmt = WorldSession::build_character_health_save_statement_like_cpp( + 321, + guid.counter() as u64, + ); + + assert_eq!(stmt.sql(), CharStatements::UPD_CHAR_HEALTH.sql()); + assert!(matches!(stmt.params()[0], wow_database::SqlParam::U32(321))); + assert!( + matches!(stmt.params()[1], wow_database::SqlParam::U64(v) if v == guid.counter() as u64) + ); + } + #[test] fn character_powers_save_statement_matches_cpp_power_fields_like_cpp() { let guid = ObjectGuid::create_player(1, 5004); @@ -99329,6 +99662,8 @@ mod tests { level: 80, xp: 0, money: 42, + health: 77, + max_health: 100, powers: empty_character_power_snapshot_like_cpp(), }; @@ -99350,6 +99685,8 @@ mod tests { level: 80, xp: 0, money: 42, + health: 77, + max_health: 100, powers: empty_character_power_snapshot_like_cpp(), }; @@ -99458,6 +99795,168 @@ mod tests { ); } + #[test] + fn sync_canonical_player_health_sets_current_and_max_like_cpp() { + let (mut session, _, _) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 0xE112); + session.set_canonical_map_manager(Arc::clone(&canonical)); + + session.ensure_login_player_controller_like_cpp( + player_guid, + "Healthy".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 1, + 1, + 1, + 80, + 0, + ); + let player = session + .canonical_player_entity_snapshot_like_cpp() + .expect("canonical player snapshot"); + { + let mut manager = canonical.lock().expect("canonical map manager"); + let managed = manager.create_world_map(1, 0); + session.sync_canonical_player_entity_like_cpp(managed, player); + } + + assert_eq!( + session.sync_canonical_player_health_like_cpp(42, 120), + Some((42, 120)) + ); + assert_eq!(session.player_health_like_cpp(), 42); + assert_eq!( + session.canonical_player_health_snapshot_like_cpp(), + Some((42, 120)) + ); + } + + #[test] + fn sync_canonical_player_health_zero_sets_corpse_like_cpp() { + let (mut session, _, _) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 0xE113); + session.set_canonical_map_manager(Arc::clone(&canonical)); + + session.ensure_login_player_controller_like_cpp( + player_guid, + "Dead".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 1, + 1, + 1, + 80, + 0, + ); + let player = session + .canonical_player_entity_snapshot_like_cpp() + .expect("canonical player snapshot"); + { + let mut manager = canonical.lock().expect("canonical map manager"); + let managed = manager.create_world_map(1, 0); + session.sync_canonical_player_entity_like_cpp(managed, player); + } + + assert_eq!( + session.sync_canonical_player_health_like_cpp(0, 120), + Some((0, 120)) + ); + assert_eq!(session.player_health_like_cpp(), 0); + assert_eq!( + session.canonical_player_health_snapshot_like_cpp(), + Some((0, 120)) + ); + assert_eq!( + session.canonical_player_snapshot_like_cpp(|player| player.unit().death_state()), + Some(wow_constants::DeathState::Corpse) + ); + } + + #[test] + fn runtime_damage_zero_health_marks_canonical_player_dead_like_cpp() { + let (mut session, _, _) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 0xE115); + session.set_canonical_map_manager(Arc::clone(&canonical)); + + session.ensure_login_player_controller_like_cpp( + player_guid, + "RuntimeDead".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 1, + 1, + 1, + 80, + 0, + ); + let player = session + .canonical_player_entity_snapshot_like_cpp() + .expect("canonical player snapshot"); + { + let mut manager = canonical.lock().expect("canonical map manager"); + let managed = manager.create_world_map(1, 0); + session.sync_canonical_player_entity_like_cpp(managed, player); + } + let _ = session.sync_canonical_player_health_like_cpp(42, 120); + + session.set_player_health_after_runtime_damage_like_cpp(0); + + assert_eq!(session.player_health_like_cpp(), 0); + assert_eq!( + session.canonical_player_health_snapshot_like_cpp(), + Some((0, 120)) + ); + assert_eq!( + session.canonical_player_snapshot_like_cpp(|player| player.unit().death_state()), + Some(wow_constants::DeathState::Corpse) + ); + } + + #[test] + fn represented_resurrection_health_syncs_canonical_before_save_like_cpp() { + let (mut session, _, _) = make_session(); + let canonical = shared_canonical_map_manager(); + let player_guid = ObjectGuid::create_player(1, 0xE114); + session.set_canonical_map_manager(Arc::clone(&canonical)); + + session.ensure_login_player_controller_like_cpp( + player_guid, + "Resurrected".to_string(), + Position::new(1.0, 2.0, 3.0, 0.0), + 1, + 1, + 1, + 80, + 0, + ); + let player = session + .canonical_player_entity_snapshot_like_cpp() + .expect("canonical player snapshot"); + { + let mut manager = canonical.lock().expect("canonical map manager"); + let managed = manager.create_world_map(1, 0); + session.sync_canonical_player_entity_like_cpp(managed, player); + } + let _ = session.sync_canonical_player_health_like_cpp(0, 120); + + session.apply_represented_resurrection_health_like_cpp(42); + let snapshot = session + .sync_session_from_save_to_db_snapshot_like_cpp() + .expect("save snapshot"); + + assert_eq!(snapshot.health, 42); + assert_eq!(snapshot.max_health, 120); + assert_eq!( + session.canonical_player_health_snapshot_like_cpp(), + Some((42, 120)) + ); + assert_eq!( + session.canonical_player_snapshot_like_cpp(|player| player.unit().death_state()), + Some(wow_constants::DeathState::Alive) + ); + } + #[test] fn sync_canonical_player_primary_power_sets_create_mana_like_cpp() { let (mut session, _, _) = make_session();