diff --git a/CONTEXT.md b/CONTEXT.md index 03ccbe814..9c1bc4321 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -11,7 +11,7 @@ A World of Warcraft 1.12.1 server. Game state lives in SpacetimeDB; the gateway **B** [Base Snapshot](#packages) · [Baseline](#client-content) · [Bot Capacity Lease](#sharding-and-transfer) · [Bot Controller](#sharding-and-transfer) · [Bot Objective](#sharding-and-transfer) · [Bounded Map Slice](#realm-topology) · [Build Identity](#packages) -**C** [Cancellation](#auctions) · [Candidate](#sharding-and-transfer) · [Carrier](#procs) · [Cast Handle](#procs) · [Channel Membership](#chat) · [Channel Notice](#chat) · [Character](#accounts-characters-and-sessions) · [Chat Channel](#chat) · [Chat Flood Limiter](#chat) · [Chat Kind](#chat) · [Claim](#packages) · [Claim Conflict](#packages) · [Client Artifact](#client-content) · [Command Receipt](#sharding-and-transfer) · [Companion Order](#sharding-and-transfer) · [Compatibility Manifest](#realm-topology) · [Content Identity](#packages) · [Coordinator](#gateway-and-module) · [Counterparty](#procs) · [Creature-AI Family](#creature-ai) +**C** [Cancellation](#auctions) · [Candidate](#sharding-and-transfer) · [Carrier](#procs) · [Cast Handle](#procs) · [Cast Miss](#procs) · [Channel Membership](#chat) · [Channel Notice](#chat) · [Character](#accounts-characters-and-sessions) · [Chat Channel](#chat) · [Chat Flood Limiter](#chat) · [Chat Kind](#chat) · [Claim](#packages) · [Claim Conflict](#packages) · [Client Artifact](#client-content) · [Command Receipt](#sharding-and-transfer) · [Companion Order](#sharding-and-transfer) · [Compatibility Manifest](#realm-topology) · [Content Identity](#packages) · [Coordinator](#gateway-and-module) · [Counterparty](#procs) · [Creature-AI Family](#creature-ai) **D** [Datascript](#packages) · [Delivery Delay](#mail) · [Dismount](#mounts) · [Distraction](#creature-ai) · [Dormant Character](#accounts-characters-and-sessions) · [Durable Read](#gateway-and-module) · [Durable Request](#gateway-and-module) @@ -37,7 +37,7 @@ A World of Warcraft 1.12.1 server. Game state lives in SpacetimeDB; the gateway **R** [Raid](#sharding-and-transfer) · [Raid Quest](#sharding-and-transfer) · [Raid Slot](#sharding-and-transfer) · [Random Property](#random-properties) · [Ranged Posture](#creature-ai) · [Rank Rights](#guilds) · [Ready Check](#sharding-and-transfer) · [Realm](#realm-topology) · [Realm Chat Line](#chat) · [Realm Clock](#world-clock-and-weather) · [Realm Presence](#chat) · [Realm-core](#realm-topology) · [Recorded Revision](#packages) · [Recovery Attempt](#sharding-and-transfer) · [Recovery Scan](#sharding-and-transfer) · [Reference Datascript](#packages) · [Reference Package](#packages) · [Refusal](#gateway-and-module) · [Relay](#gateway-and-module) · [Relay Definition](#realm-topology) · [Relay Run](#realm-topology) · [Returned Mail](#mail) · [Reward Letter](#mail) · [Roster Revision](#sharding-and-transfer) · [Roster Revision Relay](#sharding-and-transfer) · [Route Path](#procs) · [Route Step](#procs) · [Rule State](#creature-ai) · [Runtime Script](#runtime-scripts) · [Runtime Script Host](#runtime-scripts) · [Runtime Script Toolchain](#packages) -**S** [Script Answer](#runtime-scripts) · [Script Artifact](#packages) · [Script Diagnostic](#runtime-scripts) · [Script Directive](#packages) · [Script Identity](#packages) · [Seam](#working-method) · [Seeker](#meeting-stones) · [Self-Resurrection Option](#death-and-resurrection) · [Service Reconciliation](#realm-topology) · [Session](#accounts-characters-and-sessions) · [Session Expiry](#accounts-characters-and-sessions) · [SessionActor](#accounts-characters-and-sessions) · [Sessionless Action Consent](#sharding-and-transfer) · [Settlement](#auctions) · [Shard](#realm-topology) · [Shard Boundary](#sharding-and-transfer) · [Shard Map](#sharding-and-transfer) · [Signature](#guilds) · [Solo Target Claim](#sharding-and-transfer) · [Soulstone](#death-and-resurrection) · [Spatial Claim](#packages) · [Speaker Facts](#chat) · [Spec](#working-method) · [Speech](#chat) · [Spell Cast Event Kind](#procs) · [Staged Effect](#runtime-scripts) · [Standalone Supervisor](#realm-topology) · [Stat Kind](#random-properties) · [Stone Add](#meeting-stones) · [Store](#working-method) · [Strategy](#sharding-and-transfer) · [Subgroup](#sharding-and-transfer) · [Suffix](#random-properties) +**S** [Script Answer](#runtime-scripts) · [Script Artifact](#packages) · [Script Diagnostic](#runtime-scripts) · [Script Directive](#packages) · [Script Identity](#packages) · [Seam](#working-method) · [Seeker](#meeting-stones) · [Self-Resurrection Option](#death-and-resurrection) · [Service Reconciliation](#realm-topology) · [Session](#accounts-characters-and-sessions) · [Session Expiry](#accounts-characters-and-sessions) · [SessionActor](#accounts-characters-and-sessions) · [Sessionless Action Consent](#sharding-and-transfer) · [Settlement](#auctions) · [Shard](#realm-topology) · [Shard Boundary](#sharding-and-transfer) · [Shard Map](#sharding-and-transfer) · [Signature](#guilds) · [Solo Target Claim](#sharding-and-transfer) · [Soulstone](#death-and-resurrection) · [Spatial Claim](#packages) · [Speaker Facts](#chat) · [Spec](#working-method) · [Speech](#chat) · [Spell Cast Event Kind](#procs) · [Staged Effect](#runtime-scripts) · [Standalone Supervisor](#realm-topology) · [Stat Kind](#random-properties) · [Stone Add](#meeting-stones) · [Store](#working-method) · [Strategy](#sharding-and-transfer) · [Subgroup](#sharding-and-transfer) · [Suffix](#random-properties) · [Swing Error](#procs) **T** [Tabard Designer](#guilds) · [Target Icon](#sharding-and-transfer) · [Ticket](#working-method) · [Tracer](#working-method) · [Trade Commit](#trading) · [Trade Session](#trading) · [Transfer](#sharding-and-transfer) · [Transfer Intent](#sharding-and-transfer) · [Transport Loss](#gateway-and-module) · [Triggered Cast](#procs) · [Trust Review](#packages) @@ -649,10 +649,22 @@ _Avoid_: proc cast, internal cast, free cast **Spell Cast Event Kind**: The append-only code naming the wire signal in one `game_spell_cast_event` row. Zero means a row -from the old schema. New rows use 1 START, 2 GO, 3 INTERRUPT, 4 PUSHBACK, or 5 PROC_LOG. A Gateway -does not guess how to decode an unknown nonzero code. +from the old schema. New rows use 1 START, 2 GO, 3 INTERRUPT, 4 PUSHBACK, 5 PROC_LOG or +6 PERIODIC_LOG. A PERIODIC_LOG row is one periodic tick, addressed at the unit it landed on. A +Gateway does not guess how to decode an unknown nonzero code. _Avoid_: cast flags, event flags, signal flags +**Cast Miss**: +The primary target's outcome when a damage spell's hit roll fails, recorded on the GO row. The +roll runs once per target, and a missed target takes none of the spell's effects. A physical spell +misses; a magic spell is resisted outright. +_Avoid_: spell miss flag, resist flag + +**Swing Error**: +Why a player's due melee swing could not land: the target is out of reach or behind the player. +Stored on the engagement row and reported to the player once per change. +_Avoid_: swing failure, attack error + **Item Exchange**: A complete plan of carried-item consumption and item grants. Capacity, uniqueness, Random Property, and identity allocation Gates pass before any inventory row changes. Quest rewards commit as one diff --git a/docs/benilla-compatibility.md b/docs/benilla-compatibility.md index 05444651c..7b240156f 100644 --- a/docs/benilla-compatibility.md +++ b/docs/benilla-compatibility.md @@ -247,6 +247,25 @@ race's opening cinematic. A second login in one World Session gets answer. The Gateway drops account-data uploads and zone reports before decoding, because the protocol library cannot read them safely. +## Combat feedback + +A player's due melee swing that finds its target out of reach or behind the player records a +Swing Error. The Gateway sends `SMSG_ATTACKSWING_NOTINRANGE` or `SMSG_ATTACKSWING_BADFACING` once +when the error changes, and a landed swing clears it. A creature that starts an Engagement sends +`SMSG_AI_REACTION` (hostile) before `SMSG_ATTACKSTART`; a reconnect replays only the attack start. +The player who lands a melee killing blow gets `SMSG_PARTYKILLLOG`. A swing that ends a Duel is no +killing blow. vmangos sends the line to the tagger or a pet's owner, and to the party. The tag +is gone from the Coordinator cache when the relay runs, so those copies and spell kills are not +sent. + +Each periodic damage, heal or energize tick sends `SMSG_PERIODICAURALOG`; a heal tick on a unit at +full health logs nothing. A damage spell rolls its hit once per target, and a missed target takes +none of its effects. The `SMSG_SPELL_GO` miss list names the target, as a miss for a physical spell +and a resist for a magic one. The caster of an instant cast already holds a GO, so it gets +`SMSG_SPELLLOGMISS` instead. A direct energize sends `SMSG_SPELLENERGIZELOG`. Bystanders of an +interrupted player cast get `SMSG_SPELL_FAILED_OTHER`. Mount refusals name the vanilla cast +failure: dead, in combat, indoors or in deep water. + ## Missing gameplay and deliberate responses These features remain unavailable. The responses below apply to valid requests @@ -260,7 +279,7 @@ per World Session. Repeated requests do not add chat lines. | Battleground spirit healers | System Message reporting unavailability. | Resurrection queues and timers. | | GM tickets | System disabled, no current ticket, creation and update refused. Deletion reports the already-absent ticket. | Durable tickets and GM workflow. | | Pet stables | Stable failure code 6 for listing, storing, retrieving, swapping and slot purchase. | Stable storage and purchases. | -| Pet controls | Specific System Messages for bar changes, autocast, aura cancellation, stop-attack, naming, abandonment and unlearning. | These operations and their durable state. Existing pet action commands and progression remain separate. | +| Pet controls | Specific System Messages for bar changes, autocast, aura cancellation, stop-attack, naming, abandonment and unlearning. A pet-bar spell cast gets `SMSG_PET_CAST_FAILED` with the unreported reason, then a System Message. A pet-info request is ignored; the pet bar arrives with the pet. | These operations and their durable state, and an owner-to-pet read to resend the pet bar. Existing pet action commands and progression remain separate. | | Item containers and gifts | Inventory Refusal 39. | Item-container loot, wrapping and unwrapping. | | Readable pages | A terminal page names the unavailable text service. | Page-text catalog import and page chains. | | Reading items | Read failure with the close reason and a System Message, for an item with page text. An item without page text gets Inventory Refusal 23. | Page-text catalog import, then the read-OK reply. | diff --git a/gateway/src/codec/combat.rs b/gateway/src/codec/combat.rs index 9aefc3219..7a765e88f 100644 --- a/gateway/src/codec/combat.rs +++ b/gateway/src/codec/combat.rs @@ -1,4 +1,5 @@ use super::*; +use wow_world_messages::vanilla::{AiReaction, SMSG_AI_REACTION, SMSG_SPELL_FAILED_OTHER}; /// The client manages the GCD from Spell.dbc; send this only for spell cooldowns. pub fn build_spell_cooldown( @@ -136,8 +137,20 @@ pub fn cast_failure_reason_for(es: &str) -> u8 { const TARGETS_DEAD: u8 = 0x65; const BAD_TARGETS: u8 = 0x0A; const DONT_REPORT: u8 = 0x17; + const AFFECTING_COMBAT: u8 = 0x00; + const CASTER_DEAD: u8 = 0x13; + const ONLY_ABOVEWATER: u8 = 0x50; + const ONLY_OUTDOORS: u8 = 0x55; if es.contains("not enough power") { NO_POWER + } else if es.contains("cannot mount while dead") { + CASTER_DEAD + } else if es.contains("cannot mount while in combat") { + AFFECTING_COMBAT + } else if es.contains("cannot mount indoors") { + ONLY_OUTDOORS + } else if es.contains("cannot mount in deep water") { + ONLY_ABOVEWATER } else if es.contains("cooldown") { NOT_READY } else if es.contains("out of range") || es.contains("another map") { @@ -295,6 +308,115 @@ pub fn build_spell_go_outcome( } } +pub const SMSG_PERIODICAURALOG_OPCODE: u16 = 0x024E; +pub const SMSG_SPELLENERGIZELOG_OPCODE: u16 = 0x0151; +pub const SMSG_SPELLLOGMISS_OPCODE: u16 = 0x024B; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PeriodicTick { + Damage { + damage: u32, + school: u8, + absorbed: u32, + resisted: u32, + }, + Heal { + amount: u32, + }, + Energize { + power_type: u8, + amount: u32, + }, +} + +/// Packed target, packed caster, spell, count 1, aura type, then u32 fields. Raw: gtker writes the +/// damage school as a u8; the client reads a u32. +pub fn build_periodic_aura_log( + target_guid: u64, + caster_guid: u64, + spell_id: u32, + tick: PeriodicTick, +) -> Vec { + const SPELL_AURA_PERIODIC_DAMAGE: u32 = 3; + const SPELL_AURA_PERIODIC_HEAL: u32 = 8; + const SPELL_AURA_PERIODIC_ENERGIZE: u32 = 24; + let mut body = Vec::with_capacity(40); + write_packed_guid_u64(&mut body, target_guid); + write_packed_guid_u64(&mut body, caster_guid); + body.extend_from_slice(&spell_id.to_le_bytes()); + body.extend_from_slice(&1u32.to_le_bytes()); + let fields: Vec = match tick { + PeriodicTick::Damage { + damage, + school, + absorbed, + resisted, + } => vec![ + SPELL_AURA_PERIODIC_DAMAGE, + damage, + u32::from(school), + absorbed, + resisted, + ], + PeriodicTick::Heal { amount } => vec![SPELL_AURA_PERIODIC_HEAL, amount], + PeriodicTick::Energize { power_type, amount } => { + vec![SPELL_AURA_PERIODIC_ENERGIZE, u32::from(power_type), amount] + } + }; + for field in fields { + body.extend_from_slice(&field.to_le_bytes()); + } + body +} + +/// Packed target, packed caster, spell, power type as u32, amount. +pub fn build_spell_energize_log( + target_guid: u64, + caster_guid: u64, + spell_id: u32, + power_type: u8, + amount: u32, +) -> Vec { + let mut body = Vec::with_capacity(30); + write_packed_guid_u64(&mut body, target_guid); + write_packed_guid_u64(&mut body, caster_guid); + body.extend_from_slice(&spell_id.to_le_bytes()); + body.extend_from_slice(&u32::from(power_type).to_le_bytes()); + body.extend_from_slice(&amount.to_le_bytes()); + body +} + +/// Spell, full caster guid, extended-info flag 0, count 1, full target guid, `SpellMissInfo` byte. +pub fn build_spell_log_miss( + caster_guid: u64, + spell_id: u32, + target_guid: u64, + miss_info: u8, +) -> Vec { + let mut body = Vec::with_capacity(26); + body.extend_from_slice(&spell_id.to_le_bytes()); + body.extend_from_slice(&caster_guid.to_le_bytes()); + body.push(0); + body.extend_from_slice(&1u32.to_le_bytes()); + body.extend_from_slice(&target_guid.to_le_bytes()); + body.push(miss_info); + body +} + +pub fn build_spell_failed_other(caster_guid: u64, spell_id: u32) -> SMSG_SPELL_FAILED_OTHER { + SMSG_SPELL_FAILED_OTHER { + caster: Guid::new(caster_guid), + id: spell_id, + } +} + +pub fn build_ai_reaction_hostile(creature_guid: u64) -> SMSG_AI_REACTION { + SMSG_AI_REACTION { + guid: Guid::new(creature_guid), + reaction: AiReaction::Hostile, + } +} + pub fn build_attack_start(attacker_guid: u64, victim_guid: u64) -> SMSG_ATTACKSTART { SMSG_ATTACKSTART { attacker: Guid::new(attacker_guid), @@ -434,6 +556,19 @@ mod tests { ); } + #[test] + fn mount_refusals_map_to_the_vanilla_cast_failures() { + // module/src/mount.rs `check_mount_cast` wording; gtker vanilla CastFailureReason values. + for (refusal, reason) in [ + ("you cannot mount while dead", 0x13), + ("you cannot mount while in combat", 0x00), + ("you cannot mount indoors", 0x55), + ("you cannot mount in deep water", 0x50), + ] { + assert_eq!(cast_failure_reason_for(refusal), reason, "{refusal}"); + } + } + #[test] fn spell_failure_carries_interrupted_for_the_caster() { let caster = 0xF130_0000_0000_0005u64; diff --git a/gateway/src/codec/combat/subscriptions.rs b/gateway/src/codec/combat/subscriptions.rs index 871670820..2ef78c96b 100644 --- a/gateway/src/codec/combat/subscriptions.rs +++ b/gateway/src/codec/combat/subscriptions.rs @@ -33,6 +33,7 @@ pub(crate) fn stealth_action(is_insert: bool, post_change_count: usize) -> Steal pub(crate) fn combat_event_outbound( created: &Mutex>, + self_guid: u64, row: &CombatEvent, ) -> Vec { if !created.lock().unwrap().contains(&row.attacker_guid) { @@ -77,12 +78,23 @@ pub(crate) fn combat_event_outbound( Box::new(stop), ))); } + // Killer only; docs/benilla-compatibility.md "Combat feedback" lists the missing audiences. + if row.killing_blow && row.attacker_guid == self_guid && !is_creature_guid(self_guid) { + use wow_world_messages::vanilla::SMSG_PARTYKILLLOG; + out.push(Outbound::One(ServerOpcodeMessage::SMSG_PARTYKILLLOG( + Box::new(SMSG_PARTYKILLLOG { + player_with_killing_blow: wow_world_messages::Guid::new(row.attacker_guid), + victim: wow_world_messages::Guid::new(row.target_guid), + }), + ))); + } out } pub(crate) fn melee_engage_outbound( created: &Mutex>, row: &MeleeAttack, + live: bool, ) -> Vec { if row.ranged_spell_id != 0 { return Vec::new(); @@ -90,10 +102,23 @@ pub(crate) fn melee_engage_outbound( if !created.lock().unwrap().contains(&row.attacker_guid) { return Vec::new(); } + let mut out = Vec::new(); + // Live inserts only: a reconnect replays the stance, not the aggro sound. + if live && is_creature_guid(row.attacker_guid) { + out.push(Outbound::One(ServerOpcodeMessage::SMSG_AI_REACTION( + Box::new(codec::build_ai_reaction_hostile(row.attacker_guid)), + ))); + } let m = codec::build_attack_start(row.attacker_guid, row.target_guid); - vec![Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART( + out.push(Outbound::One(ServerOpcodeMessage::SMSG_ATTACKSTART( Box::new(m), - ))] + ))); + out +} + +/// `HIGHGUID_UNIT` 0xF130 or `HIGHGUID_PET` 0xF140. +fn is_creature_guid(guid: u64) -> bool { + matches!(guid >> 48, 0xF130 | 0xF140) } pub(crate) fn melee_disengage_outbound(self_guid: u64, row: &MeleeAttack) -> Vec { @@ -116,12 +141,16 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec> 48, 0xF130 | 0xF140); - if is_creature || row.caster_guid == self_guid { + if is_creature_guid(row.caster_guid) || row.caster_guid == self_guid { let m = codec::build_spell_failure(row.caster_guid, row.spell_id); out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILURE( Box::new(m), ))); + } else { + let m = codec::build_spell_failed_other(row.caster_guid, row.spell_id); + out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_FAILED_OTHER( + Box::new(m), + ))); } // Teardown must precede the failed cast result to clear both cast and melee-spell state. if row.caster_guid == self_guid { @@ -159,6 +188,10 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec Vec 0 { @@ -233,6 +285,19 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec 0 { + out.push(Outbound::Raw { + opcode: codec::SMSG_SPELLENERGIZELOG_OPCODE, + body: codec::build_spell_energize_log( + row.target_guid, + row.caster_guid, + row.spell_id, + row.power_type, + row.energized, + ), + }); + } + // SMSG_SPELL_GO releases pending casts. A zero-duration cooldown can leave the button stuck. if row.cooldown_ms > 0 { let cd = codec::build_spell_cooldown(row.caster_guid, row.spell_id, row.cooldown_ms); @@ -243,6 +308,42 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec Option { + use wow_world_messages::vanilla::SpellMissInfo; + const CAST_MISS_MISS: u8 = 1; + const CAST_MISS_RESIST: u8 = 2; + match code { + CAST_MISS_MISS => Some(SpellMissInfo::Miss), + CAST_MISS_RESIST => Some(SpellMissInfo::Resist), + _ => None, + } +} + +fn periodic_log_outbound(row: &SpellCastEvent) -> Option { + let tick = if row.healed > 0 { + codec::PeriodicTick::Heal { amount: row.healed } + } else if row.energized > 0 { + codec::PeriodicTick::Energize { + power_type: row.power_type, + amount: row.energized, + } + } else if row.damage > 0 || row.absorbed > 0 || row.resisted > 0 { + codec::PeriodicTick::Damage { + damage: row.damage, + school: row.school, + absorbed: row.absorbed, + resisted: row.resisted, + } + } else { + return None; + }; + Some(Outbound::Raw { + opcode: codec::SMSG_PERIODICAURALOG_OPCODE, + body: codec::build_periodic_aura_log(row.target_guid, row.caster_guid, row.spell_id, tick), + }) +} + pub(crate) fn aura_sync( auras: impl Iterator, target_guid: u64, @@ -815,7 +916,11 @@ pub(crate) fn combat_event_appeared(view: &WorldView, shard: ShardId, row: &Comb for viewer in combat_audience(view, shard, &row) { let row = row.clone(); enqueue(viewer.clone(), move |viewer| { - crate::codec::combat::subscriptions::combat_event_outbound(&viewer.created, &row) + crate::codec::combat::subscriptions::combat_event_outbound( + &viewer.created, + viewer.self_guid, + &row, + ) }); } } @@ -836,13 +941,13 @@ pub(crate) fn melee_audience( ) } -pub(crate) fn melee_engaged(view: &WorldView, shard: ShardId, row: &MeleeAttack) { +pub(crate) fn melee_engaged(view: &WorldView, shard: ShardId, row: &MeleeAttack, live: bool) { let row = Arc::new(row.clone()); for viewer in melee_audience(view, shard, &row) { let row = row.clone(); let created = viewer.created.clone(); enqueue(viewer.clone(), move |_| { - crate::codec::combat::subscriptions::melee_engage_outbound(&created, &row) + crate::codec::combat::subscriptions::melee_engage_outbound(&created, &row, live) }); } } @@ -858,6 +963,42 @@ pub(crate) fn melee_disengaged(view: &WorldView, shard: ShardId, row: &MeleeAtta } } +/// Swing Error codes are lockstep with `SWING_ERROR_*` in `module/src/combat/engage.rs`. +pub(crate) fn swing_error_outbound(old: &MeleeAttack, row: &MeleeAttack) -> Option { + const NOT_IN_RANGE: u8 = 1; + const BAD_FACING: u8 = 2; + if old.swing_error == row.swing_error { + return None; + } + match row.swing_error { + NOT_IN_RANGE => Some(Outbound::One( + ServerOpcodeMessage::SMSG_ATTACKSWING_NOTINRANGE, + )), + BAD_FACING => Some(Outbound::One( + ServerOpcodeMessage::SMSG_ATTACKSWING_BADFACING, + )), + _ => None, + } +} + +pub(crate) fn melee_updated( + view: &WorldView, + shard: ShardId, + old: &MeleeAttack, + row: &MeleeAttack, +) { + if swing_error_outbound(old, row).is_none() { + return; + } + let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.attacker_guid)) else { + return; + }; + let (old, row) = (old.clone(), row.clone()); + enqueue(viewer, move |_| { + swing_error_outbound(&old, &row).into_iter().collect() + }); +} + pub(crate) fn aura_audience(view: &WorldView, shard: ShardId, row: &Aura) -> Vec> { let key = view .spatial @@ -1002,7 +1143,8 @@ crate::stdb::relays::declare_relays! { insert(Snapshot) => |context, _event, row| combat_event_appeared(&context.view, context.shard, row); } game_melee_attack [false] { - insert(Snapshot) => |context, _event, row| melee_engaged(&context.view, context.shard, row); + insert(Snapshot) => |context, event, row| melee_engaged(&context.view, context.shard, row, !matches!(event.event, spacetimedb_sdk::Event::SubscribeApplied)); + update(Snapshot) => |context, _event, old, row| melee_updated(&context.view, context.shard, old, row); delete(Snapshot) => |context, _event, row| melee_disengaged(&context.view, context.shard, row); } game_spell_cast_event [false] { @@ -1025,6 +1167,7 @@ pub(crate) enum SpellCastEventKind { Interrupt, Pushback, ProcLog, + PeriodicLog, } impl SpellCastEventKind { @@ -1041,6 +1184,7 @@ impl SpellCastEventKind { 3 => Some(Self::Interrupt), 4 => Some(Self::Pushback), 5 => Some(Self::ProcLog), + 6 => Some(Self::PeriodicLog), unknown => { log::warn!( "spell cast relay: unknown kind {unknown} (event {}, caster {}, spell {})", @@ -1218,5 +1362,7 @@ pub(crate) mod tests { } } +#[cfg(test)] +mod log_tests; #[cfg(test)] mod packet_tests; diff --git a/gateway/src/codec/combat/subscriptions/log_tests.rs b/gateway/src/codec/combat/subscriptions/log_tests.rs new file mode 100644 index 000000000..17d7d0ca8 --- /dev/null +++ b/gateway/src/codec/combat/subscriptions/log_tests.rs @@ -0,0 +1,298 @@ +use super::*; +use crate::stdb::bindings::{MeleeAttack, SpellCastEvent}; +use crate::world::Outbound; +use std::collections::HashSet; +use std::sync::Mutex; + +const CASTER: u64 = 7; +const TARGET: u64 = 11; +const WOLF: u64 = 0xF130_00C7_3800_0001; + +/// Big-endian size (body plus opcode), little-endian opcode, then the body. +fn frame(out: &Outbound) -> Vec { + match out { + Outbound::One(message) => { + let mut bytes = Vec::new(); + message.write_unencrypted_server(&mut bytes).unwrap(); + bytes + } + Outbound::Raw { opcode, body } => { + let mut bytes = ((body.len() + 2) as u16).to_be_bytes().to_vec(); + bytes.extend_from_slice(&opcode.to_le_bytes()); + bytes.extend_from_slice(body); + bytes + } + _ => panic!("expected a single message"), + } +} + +fn frames(out: &[Outbound]) -> Vec> { + out.iter().map(frame).collect() +} + +fn header(size: u16, opcode: u16) -> Vec { + let mut bytes = size.to_be_bytes().to_vec(); + bytes.extend_from_slice(&opcode.to_le_bytes()); + bytes +} + +fn u32s(values: &[u32]) -> Vec { + values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect() +} + +fn engagement(attacker_guid: u64, swing_error: u8) -> MeleeAttack { + MeleeAttack { + attacker_guid, + target_guid: TARGET, + last_swing_ms: 0, + ranged_spell_id: 0, + last_offhand_swing_ms: 0, + rout_ends_ms: 0, + pursuit_ends_ms: 0, + leash_x: 0.0, + leash_y: 0.0, + swing_error, + } +} + +fn cast_row(kind: u8) -> SpellCastEvent { + SpellCastEvent { + id: 1, + caster_guid: CASTER, + spell_id: 172, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + target_guid: TARGET, + cast_time_ms: 0, + is_completion: false, + damage: 0, + school: 0, + is_crit: false, + resisted: 0, + absorbed: 0, + is_interrupted: false, + cooldown_ms: 0, + delay_ms: 0, + healed: 0, + is_proc_log: false, + swing_hit_info: 0, + client_initiated: false, + map_id: 0, + instance_id: 0, + grid_x: 0, + grid_y: 0, + failure_reason: 0, + kind, + miss_info: 0, + energized: 0, + power_type: 0, + } +} + +const PERIODIC_LOG: u8 = 6; +const GO: u8 = 2; + +/// Packed target 11, packed caster 7, spell, count 1. +fn periodic_head(spell_id: u32) -> Vec { + let mut body = vec![0x01, TARGET as u8, 0x01, CASTER as u8]; + body.extend(u32s(&[spell_id, 1])); + body +} + +#[test] +fn a_swing_error_change_sends_its_message_once_and_a_cleared_error_sends_nothing() { + let none = engagement(CASTER, 0); + let far = engagement(CASTER, 1); + let behind = engagement(CASTER, 2); + let sent = + |old: &MeleeAttack, row: &MeleeAttack| swing_error_outbound(old, row).map(|o| frame(&o)); + // Empty bodies: size 2 is the opcode alone. + assert_eq!(sent(&none, &far), Some(header(2, 0x0145))); + assert_eq!(sent(&far, &behind), Some(header(2, 0x0146))); + assert_eq!(sent(&far, &far), None, "an unchanged error is not repeated"); + assert_eq!(sent(&behind, &none), None, "a landed swing clears silently"); +} + +#[test] +fn a_creature_engagement_plays_the_aggro_reaction_before_the_attack_start() { + let created = Mutex::new(HashSet::from([WOLF, CASTER])); + let out = frames(&melee_engage_outbound(&created, &engagement(WOLF, 0), true)); + let mut reaction = header(14, 0x013C); + reaction.extend_from_slice(&WOLF.to_le_bytes()); + reaction.extend(u32s(&[2])); // AI_REACTION_HOSTILE + assert_eq!(out.len(), 2); + assert_eq!(out[0], reaction); + assert_eq!( + &out[1][2..4], + &0x0143u16.to_le_bytes(), + "then SMSG_ATTACKSTART" + ); + + let player = frames(&melee_engage_outbound( + &created, + &engagement(CASTER, 0), + true, + )); + assert_eq!(player.len(), 1); + assert_eq!(&player[0][2..4], &0x0143u16.to_le_bytes()); +} + +#[test] +fn a_replayed_creature_engagement_restores_the_stance_without_the_aggro_sound() { + let created = Mutex::new(HashSet::from([WOLF])); + let out = frames(&melee_engage_outbound( + &created, + &engagement(WOLF, 0), + false, + )); + assert_eq!(out.len(), 1, "only the attack start replays"); + assert_eq!(&out[0][2..4], &0x0143u16.to_le_bytes()); +} + +#[test] +fn the_caster_of_a_synced_instant_learns_its_miss_from_the_miss_log() { + let row = SpellCastEvent { + miss_info: 2, + client_initiated: true, + ..cast_row(GO) + }; + let out = frames(&cast_event_outbound(CASTER, &row)); + // Spell, full caster, flag 0, count 1, full target, SPELL_MISS_RESIST (2). + let mut body = u32s(&[172]); + body.extend_from_slice(&CASTER.to_le_bytes()); + body.push(0); + body.extend(u32s(&[1])); + body.extend_from_slice(&TARGET.to_le_bytes()); + body.push(2); + let mut want = header(body.len() as u16 + 2, 0x024B); + want.extend(body); + assert_eq!(out, vec![want]); + + let observer = frames(&cast_event_outbound(TARGET, &row)); + assert!(observer + .iter() + .all(|frame| frame[2..4] != 0x024Bu16.to_le_bytes())); + assert!(observer + .iter() + .any(|frame| frame[2..4] == 0x0132u16.to_le_bytes())); +} + +#[test] +fn a_periodic_damage_tick_logs_damage_school_absorb_and_resist_as_words() { + let row = SpellCastEvent { + damage: 9, + school: 5, // shadow + resisted: 3, + ..cast_row(PERIODIC_LOG) + }; + let mut body = periodic_head(172); + body.extend(u32s(&[3, 9, 5, 0, 3])); // SPELL_AURA_PERIODIC_DAMAGE, damage, school, absorb, resist + let mut want = header(body.len() as u16 + 2, 0x024E); + want.extend(body); + assert_eq!(frames(&cast_event_outbound(TARGET, &row)), vec![want]); +} + +#[test] +fn periodic_heal_and_energize_ticks_log_their_own_aura_types() { + let heal = SpellCastEvent { + healed: 42, + ..cast_row(PERIODIC_LOG) + }; + let mut body = periodic_head(172); + body.extend(u32s(&[8, 42])); // SPELL_AURA_PERIODIC_HEAL, amount + let mut want = header(body.len() as u16 + 2, 0x024E); + want.extend(body); + assert_eq!(frames(&cast_event_outbound(TARGET, &heal)), vec![want]); + + let energize = SpellCastEvent { + energized: 10, + power_type: 1, // rage + ..cast_row(PERIODIC_LOG) + }; + let mut body = periodic_head(172); + body.extend(u32s(&[24, 1, 10])); // SPELL_AURA_PERIODIC_ENERGIZE, power type, amount + let mut want = header(body.len() as u16 + 2, 0x024E); + want.extend(body); + assert_eq!(frames(&cast_event_outbound(TARGET, &energize)), vec![want]); + + assert!( + cast_event_outbound(TARGET, &cast_row(PERIODIC_LOG)).is_empty(), + "a tick with no outcome logs nothing" + ); +} + +#[test] +fn a_resisted_spell_lists_its_target_as_resisted_in_the_go() { + let row = SpellCastEvent { + miss_info: 2, + school: 5, + ..cast_row(GO) + }; + let out = frames(&cast_event_outbound(TARGET, &row)); + // GO: packed item and caster, spell, flags, 0 hits, 1 miss (full guid, RESIST), unit target. + let mut body = vec![0x01, 0x07, 0x01, 0x07]; + body.extend(u32s(&[172])); + body.extend_from_slice(&0x0100u16.to_le_bytes()); + body.extend_from_slice(&[0, 1]); + body.extend_from_slice(&TARGET.to_le_bytes()); + body.push(2); + body.extend_from_slice(&0x0002u16.to_le_bytes()); + body.extend_from_slice(&[0x01, 0x0B]); + let mut go = header(body.len() as u16 + 2, 0x0132); + go.extend(body); + assert_eq!(out.len(), 2); + assert_eq!(out[1], go); +} + +#[test] +fn a_spell_that_gives_power_sends_the_energize_log_after_the_go() { + let row = SpellCastEvent { + energized: 100, + power_type: 1, + ..cast_row(GO) + }; + let out = frames(&cast_event_outbound(TARGET, &row)); + let mut body = vec![0x01, 0x0B, 0x01, 0x07]; // packed target 11, packed caster 7 + body.extend(u32s(&[172, 1, 100])); // spell, rage, amount + let mut want = header(body.len() as u16 + 2, 0x0151); + want.extend(body); + assert_eq!(out.last(), Some(&want)); +} + +#[test] +fn only_the_killing_player_gets_the_party_kill_line() { + let created = Mutex::new(HashSet::from([CASTER])); + let kill = crate::stdb::bindings::CombatEvent { + id: 1, + attacker_guid: CASTER, + target_guid: WOLF, + damage: 30, + hit_info: 0, + killing_blow: true, + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + blocked_amount: 0, + ranged_spell_id: 0, + ammo_display_id: 0, + spell_swing: false, + impact_delay_ms: 0, + map_id: 0, + instance_id: 0, + grid_x: 0, + grid_y: 0, + }; + let killer = frames(&combat_event_outbound(&created, CASTER, &kill)); + let mut line = header(18, 0x01F5); + line.extend_from_slice(&CASTER.to_le_bytes()); + line.extend_from_slice(&WOLF.to_le_bytes()); + assert_eq!(killer.len(), 3, "swing, attack stop, then the kill line"); + assert_eq!(killer[2], line); + + let bystander = combat_event_outbound(&created, TARGET, &kill); + assert_eq!( + bystander.len(), + 2, + "a bystander sees the swing and the stop only" + ); +} diff --git a/gateway/src/codec/combat/subscriptions/packet_tests.rs b/gateway/src/codec/combat/subscriptions/packet_tests.rs index 6ebe2c683..a25bc74b7 100644 --- a/gateway/src/codec/combat/subscriptions/packet_tests.rs +++ b/gateway/src/codec/combat/subscriptions/packet_tests.rs @@ -5,6 +5,8 @@ use std::collections::HashSet; use std::sync::Mutex; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; +const BYSTANDER: u64 = 99; + fn swing_event() -> CombatEvent { CombatEvent { id: 1, @@ -38,13 +40,13 @@ fn ranged_launch_relays_one_spell_go_for_a_hit_or_miss() { impact_delay_ms: 500, ..swing_event() }; - let out = combat_event_outbound(&created, &row); + let out = combat_event_outbound(&created, BYSTANDER, &row); let [Outbound::One(ServerOpcodeMessage::SMSG_SPELL_GO(go))] = out.as_slice() else { panic!("a ranged launch must emit exactly one SPELL_GO"); }; assert_eq!((go.caster.guid(), go.spell), (7, 75)); assert_eq!((go.hits.len(), go.misses.len()), (hits, misses)); - assert!(combat_event_outbound(&Mutex::new(HashSet::new()), &row).is_empty()); + assert!(combat_event_outbound(&Mutex::new(HashSet::new()), BYSTANDER, &row).is_empty()); } } @@ -76,6 +78,9 @@ fn ranged_impact_relays_only_its_authoritative_damage() { grid_y: 0, failure_reason: 0, kind: 5, + miss_info: 0, + energized: 0, + power_type: 0, }; let out = cast_event_outbound(7, &row); let [Outbound::One(ServerOpcodeMessage::SMSG_SPELLNONMELEEDAMAGELOG(log))] = out.as_slice() @@ -97,7 +102,7 @@ fn ranged_impact_relays_only_its_authoritative_damage() { fn melee_swing_relays_damage_then_stops_on_a_killing_blow() { let created = Mutex::new(HashSet::from([7])); let mut row = swing_event(); - let out = combat_event_outbound(&created, &row); + let out = combat_event_outbound(&created, BYSTANDER, &row); assert!(matches!( out.as_slice(), [Outbound::One( @@ -106,7 +111,7 @@ fn melee_swing_relays_damage_then_stops_on_a_killing_blow() { )); row.killing_blow = true; - let out = combat_event_outbound(&created, &row); + let out = combat_event_outbound(&created, BYSTANDER, &row); assert!(matches!( out.as_slice(), [ @@ -147,6 +152,9 @@ fn spell_cast_event(kind: u8) -> SpellCastEvent { grid_y: 0, failure_reason: 0, kind, + miss_info: 0, + energized: 0, + power_type: 0, } } @@ -314,12 +322,22 @@ fn a_deferred_power_failure_relays_the_teardown_then_the_failed_cast_result() { } #[test] -fn a_bystander_sees_neither_half_of_a_deferred_power_failure() { +fn a_bystander_sees_only_the_observer_teardown_of_a_deferred_power_failure() { let out = cast_event_outbound( HEROIC_STRIKE_CASTER + 1, &interrupted_cast(78, NO_POWER_CODE), ); - assert!(out.is_empty(), "both messages are caster-private"); + let [Outbound::One(message @ ServerOpcodeMessage::SMSG_SPELL_FAILED_OTHER(_))] = out.as_slice() + else { + panic!("expected the observer teardown alone"); + }; + let mut bytes = Vec::new(); + message.write_unencrypted_server(&mut bytes).unwrap(); + // Size 14 (opcode + 12-byte body), opcode 0x02A6, full caster guid, spell 78. + let mut want = vec![0x00, 0x0E, 0xA6, 0x02]; + want.extend_from_slice(&HEROIC_STRIKE_CASTER.to_le_bytes()); + want.extend_from_slice(&78u32.to_le_bytes()); + assert_eq!(bytes, want); } #[test] diff --git a/gateway/src/stdb/world_view/cell_audience_tests.rs b/gateway/src/stdb/world_view/cell_audience_tests.rs index 156c37663..7e51bc167 100644 --- a/gateway/src/stdb/world_view/cell_audience_tests.rs +++ b/gateway/src/stdb/world_view/cell_audience_tests.rs @@ -209,6 +209,7 @@ fn melee(attacker: u64, target: u64) -> MeleeAttack { pursuit_ends_ms: 0, leash_x: 0.0, leash_y: 0.0, + swing_error: 0, } } @@ -240,6 +241,9 @@ fn cast(realm: &Realm, caster: u64, target: u64) -> SpellCastEvent { grid_y, failure_reason: 0, kind: 2, + miss_info: 0, + energized: 0, + power_type: 0, } } @@ -376,7 +380,7 @@ fn melee_candidates_gate_to_exactly_the_whole_shard_recipients() { let gate = |v: &Viewer| created_contains(v, attacker); let candidates = sessions(melee_audience(&realm.view, 0, &row)); - melee_engaged(&realm.view, 0, &row); + melee_engaged(&realm.view, 0, &row, true); assert_eq!(realm.jobs_landed(), candidates); melee_disengaged(&realm.view, 0, &row); assert_eq!(realm.jobs_landed(), candidates); @@ -881,7 +885,7 @@ fn jobs_selected_before_worldport_cannot_reach_the_replacement_world_session() { ); let selected = view.viewer_of_owner(OwnerGuid(PLAYER_BASE)).unwrap(); let row = melee(PLAYER_BASE, CREATURE_BASE); - melee_engaged(&view, 0, &row); + melee_engaged(&view, 0, &row, true); drop(old_registration); tx.send(Outbound::Raw { @@ -897,9 +901,13 @@ fn jobs_selected_before_worldport_cannot_reach_the_replacement_world_session() { ); let delayed_row = row.clone(); enqueue(selected, move |viewer| { - crate::codec::combat::subscriptions::melee_engage_outbound(&viewer.created, &delayed_row) + crate::codec::combat::subscriptions::melee_engage_outbound( + &viewer.created, + &delayed_row, + true, + ) }); - melee_engaged(&view, 0, &row); + melee_engaged(&view, 0, &row, true); let Outbound::Job(before_entry) = rx.try_recv().unwrap() else { panic!("old Relay job"); diff --git a/gateway/src/stdb/world_view/relay_bench.rs b/gateway/src/stdb/world_view/relay_bench.rs index fefced4a6..3a628e52f 100644 --- a/gateway/src/stdb/world_view/relay_bench.rs +++ b/gateway/src/stdb/world_view/relay_bench.rs @@ -230,6 +230,9 @@ impl SyntheticRealm { grid_y: gy, failure_reason: 0, kind: 2, + miss_info: 0, + energized: 0, + power_type: 0, } } diff --git a/gateway/src/world/combat_tests.rs b/gateway/src/world/combat_tests.rs index d0e0426a9..03a804f49 100644 --- a/gateway/src/world/combat_tests.rs +++ b/gateway/src/world/combat_tests.rs @@ -122,3 +122,38 @@ fn refusal_is_ignored_and_the_world_session_continues() { assert!(reply.outbound.is_empty()); assert_eq!(store.sheath_of(1), None); } + +#[test] +fn growth_aura_cancel_and_pet_info_requests_are_ignored_without_a_durable_request() { + let store = CombatFake::failing(ReducerCallError::transport_lost("unexpected")); + run(&store, ClientOpcodeMessage::CMSG_CANCEL_GROWTH_AURA); + run(&store, ClientOpcodeMessage::CMSG_REQUEST_PET_INFO); + assert!( + store.answer().is_err(), + "neither request may reach the Store" + ); +} + +#[test] +fn a_pet_cast_is_refused_with_the_silent_pet_cast_failure_and_a_notice() { + use wow_world_messages::vanilla::{SpellCastTargets, CMSG_PET_CAST_SPELL}; + let request = ClientOpcodeMessage::CMSG_PET_CAST_SPELL(Box::new(CMSG_PET_CAST_SPELL { + guid: Guid::new(0xF140_0000_0000_0009), + id: 3110, + targets: SpellCastTargets::default(), + })); + let replies = handlers::unavailable_outbound(&request, &mut Default::default()) + .expect("a pet cast must be answered"); + let [Outbound::Raw { opcode, body }, Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(notice))] = + replies.as_slice() + else { + panic!("expected the pet cast failure, then the notice"); + }; + // SMSG_PET_CAST_FAILED: spell 3110 (Firebolt), status 2 (failed), reason 0x17 (not reported). + assert_eq!(*opcode, 0x0138); + assert_eq!(body, &[0x26, 0x0C, 0x00, 0x00, 0x02, 0x17]); + assert_eq!( + notice.message, + "Pet spell casts are not available on this realm." + ); +} diff --git a/gateway/src/world/handlers/combat.rs b/gateway/src/world/handlers/combat.rs index cd1b650a4..3196205c3 100644 --- a/gateway/src/world/handlers/combat.rs +++ b/gateway/src/world/handlers/combat.rs @@ -54,6 +54,10 @@ impl ProtocolFamily for Combat { ignore_refusal(&format!("set_sheathed({state})"), conn.account_id, result)?; } } + // vmangos `HandleCancelGrowthAuraOpcode` is empty. + ClientOpcodeMessage::CMSG_CANCEL_GROWTH_AURA => {} + // No owner-to-pet read exists; the pet bar arrives with the pet's CREATE. + ClientOpcodeMessage::CMSG_REQUEST_PET_INFO => {} _ => return Err(anyhow!("opcode routed to the wrong Protocol Family")), } Ok(ProtocolReply::default()) diff --git a/gateway/src/world/handlers/unavailable.rs b/gateway/src/world/handlers/unavailable.rs index ff4ef34b1..0147c9aa3 100644 --- a/gateway/src/world/handlers/unavailable.rs +++ b/gateway/src/world/handlers/unavailable.rs @@ -86,6 +86,18 @@ pub(crate) fn unavailable_outbound( ClientOpcodeMessage::CMSG_PET_CANCEL_AURA(_) => unavailable("Pet aura cancellations"), ClientOpcodeMessage::CMSG_PET_UNLEARN(_) => unavailable("Pet talent resets"), ClientOpcodeMessage::CMSG_PET_SPELL_AUTOCAST(_) => unavailable("Pet autocast changes"), + // SMSG_PET_CAST_FAILED: status 2 (failed), reason 0x17 (not reported). + ClientOpcodeMessage::CMSG_PET_CAST_SPELL(request) => { + let mut body = request.id.to_le_bytes().to_vec(); + body.extend_from_slice(&[0x02, 0x17]); + return Some(vec![ + Outbound::Raw { + opcode: 0x0138, + body, + }, + unavailable("Pet spell casts"), + ]); + } ClientOpcodeMessage::CMSG_UNLEARN_SKILL(_) => unavailable("Skill unlearning requests"), ClientOpcodeMessage::CMSG_SET_AMMO(_) => unavailable("Ammunition selections"), ClientOpcodeMessage::CMSG_MOUNTSPECIAL_ANIM => unavailable("Mount flourishes"), diff --git a/gateway/src/world/routing.rs b/gateway/src/world/routing.rs index eea7886ef..73d493345 100644 --- a/gateway/src/world/routing.rs +++ b/gateway/src/world/routing.rs @@ -55,6 +55,8 @@ protocol_families! { CMSG_PET_ACTION, CMSG_FORCE_RUN_SPEED_CHANGE_ACK, CMSG_SETSHEATHED, + CMSG_CANCEL_GROWTH_AURA, + CMSG_REQUEST_PET_INFO, ], Auction => [ MSG_AUCTION_HELLO_Client, diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index 5f5b8640e..2ee522923 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -878,13 +878,13 @@ parity_test!(parity_game_combat_event, "game_combat_event", lyracore_module::Com }); parity_test!(parity_game_melee_attack, "game_melee_attack", lyracore_module::MeleeAttack, bindings::melee_attack_type::MeleeAttack, { attacker_guid, target_guid, last_swing_ms, ranged_spell_id, last_offhand_swing_ms, rout_ends_ms, - pursuit_ends_ms, leash_x, leash_y, + pursuit_ends_ms, leash_x, leash_y, swing_error, }); parity_test!(parity_game_spell_cast_event, "game_spell_cast_event", lyracore_module::SpellCastEvent, bindings::spell_cast_event_type::SpellCastEvent, { id, caster_guid, spell_id, created_at, target_guid, cast_time_ms, is_completion, damage, school, is_crit, resisted, absorbed, is_interrupted, cooldown_ms, delay_ms, healed, is_proc_log, swing_hit_info, client_initiated, map_id, instance_id, grid_x, grid_y, - failure_reason, kind, + failure_reason, kind, miss_info, energized, power_type, }); parity_test!(parity_game_spell_impact_event, "game_spell_impact_event", lyracore_module::SpellImpactEvent, bindings::spell_impact_event_type::SpellImpactEvent, { id, caster_guid, target_guid, spell_id, created_at, damage, school, is_crit, resisted, diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index fc69fb738..12a4155ce 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -791,6 +791,24 @@ pub(crate) fn is_lethal(health: u32, dmg: u32) -> bool { dmg > 0 && health <= dmg } +/// A lethal hit between active Duel opponents ends the Duel instead: `apply_hit` floors the loser. +pub(crate) fn duel_takes_lethal_hit( + ctx: &ReducerContext, + attacker_guid: u64, + target: &WorldEntity, +) -> bool { + target.is_player() && crate::duel::active_opponents(ctx, attacker_guid, target.guid) +} + +pub(crate) fn is_killing_blow( + ctx: &ReducerContext, + attacker_guid: u64, + target: &WorldEntity, + dmg: u32, +) -> bool { + is_lethal(target.health, dmg) && !duel_takes_lethal_hit(ctx, attacker_guid, target) +} + // Absorb before lethal checks and godmode so even a zeroed hit drains shields. pub(crate) fn fold_incoming_damage( ctx: &ReducerContext, @@ -875,10 +893,7 @@ pub(crate) fn apply_hit( } } - if target_is_player - && is_lethal(target.health, dmg) - && crate::duel::active_opponents(ctx, attacker_guid, target_guid) - { + if is_lethal(target.health, dmg) && duel_takes_lethal_hit(ctx, attacker_guid, &target) { target.health = 1; entities.guid().update(target); if let Some(duel) = crate::duel::active_duel_between(ctx, attacker_guid, target_guid) { diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index 83c01285f..497f91015 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -47,6 +47,7 @@ pub(crate) fn arm_creature_engagement( pursuit_ends_ms: 0, leash_x: 0.0, leash_y: 0.0, + swing_error: 0, }); let entities = ctx.db.game_world_entity(); if let Some(mut creature) = entities.guid().find(creature_guid) { @@ -259,8 +260,15 @@ pub struct MeleeAttack { pub leash_x: f32, #[default(0.0)] pub leash_y: f32, + /// `SWING_ERROR_*`; the Gateway relays each change to the attacker. + #[default(0u8)] + pub swing_error: u8, } +pub const SWING_ERROR_NONE: u8 = 0; +pub const SWING_ERROR_NOT_IN_RANGE: u8 = 1; +pub const SWING_ERROR_BAD_FACING: u8 = 2; + #[table( accessor = game_combat_event, public, @@ -528,6 +536,7 @@ fn start_attack( pursuit_ends_ms: 0, leash_x: 0.0, leash_y: 0.0, + swing_error: 0, }; if melee.attacker_guid().find(attacker.guid).is_some() { melee.attacker_guid().update(row); @@ -618,6 +627,7 @@ pub(crate) fn apply_start_ranged_attack( pursuit_ends_ms: 0, leash_x: 0.0, leash_y: 0.0, + swing_error: 0, }; if melee.attacker_guid().find(attacker.guid).is_some() { melee.attacker_guid().update(row); diff --git a/module/src/combat/mod.rs b/module/src/combat/mod.rs index 2639ecf0e..81e7ba5a3 100644 --- a/module/src/combat/mod.rs +++ b/module/src/combat/mod.rs @@ -6,7 +6,7 @@ mod tables; pub(crate) use death::{ apply_hit, clear_lethal_damage_floor, clear_stale_lethal_damage_floor, commit_death_prevention, - final_damage, fold_incoming_damage, force_creature_death, is_lethal, kill_creature, + final_damage, fold_incoming_damage, force_creature_death, is_killing_blow, kill_creature, kill_player, lethal_floor_protects, set_lethal_damage_floor, Hit, HitSource, CORPSE_DECAY_MICROS, DRAIN_SOUL_SPELL_ID, RESPAWN_MICROS, SOUL_SHARD_ENTRY, }; @@ -19,7 +19,7 @@ pub(crate) use engage::{ apply_start_attack, apply_start_ranged_attack, arm_creature_engagement, break_own_attacks, disengage, enter_combat, is_engaged, is_hostile_target, may_harm, may_help, melee_combatant_guids, refresh_leash, request_attack, stop_attack_for, stop_duel_combat, - validate_attack_target, + validate_attack_target, SWING_ERROR_BAD_FACING, SWING_ERROR_NONE, SWING_ERROR_NOT_IN_RANGE, }; pub use engage::{ game_combat_event, game_melee_attack, game_melee_schedule, game_ranged_impact_schedule, diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 4e50d3b89..3d6ef9090 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -150,12 +150,12 @@ fn resolve_offhand_swing( let (dmg, _absorbed) = fold_incoming_damage(ctx, attacker_guid, target_guid, rolled); let damage = final_damage(ctx, target_guid, dmg); - let lethal = is_lethal(target.health, damage.amount); + let killing_blow = is_killing_blow(ctx, attacker_guid, &target, damage.amount); ctx.db.game_combat_event().insert(CombatEvent { damage: damage.amount, hit_info, - killing_blow: lethal, + killing_blow, blocked_amount: blocked, ..CombatEvent::signal_at(ctx, attacker, target_guid) }); @@ -256,9 +256,13 @@ fn resolve_swing(ctx: &ReducerContext) { target.z - attacker.z, ); let dist_sq = dx * dx + dy * dy + dz * dz; - if swing_blocked(ctx, &attacker, &target, ranged.is_some(), dist_sq, now_ms) { + if let Some(block) = + swing_blocked(ctx, &attacker, &target, ranged.is_some(), dist_sq, now_ms) + { if ranged_due { melee.attacker_guid().delete(atk.attacker_guid); + } else if ranged.is_none() && attacker.is_player() { + record_swing_error(ctx, &atk, &attacker, block.swing_error(), now_ms); } continue; } @@ -311,7 +315,7 @@ fn swing_blocked( is_ranged: bool, dist_sq: f32, now_ms: u32, -) -> bool { +) -> Option { let range_sq = if is_ranged { RANGED_RANGE_SQ } else { @@ -328,10 +332,10 @@ fn swing_blocked( } }; if dist_sq > range_sq { - return true; + return Some(SwingBlock::OutOfRange); } if is_ranged && dist_sq < MELEE_RANGE_SQ { - return true; + return Some(SwingBlock::TooClose); } if !crate::nav::has_los( ctx, @@ -340,7 +344,7 @@ fn swing_blocked( (attacker.x, attacker.y, attacker.z), (target.x, target.y, target.z), ) { - return true; + return Some(SwingBlock::NoLineOfSight); } if attacker.is_player() && !crate::spell::is_facing( @@ -351,9 +355,52 @@ fn swing_blocked( target.y, ) { - return true; + return Some(SwingBlock::BadFacing); + } + None +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SwingBlock { + OutOfRange, + TooClose, + NoLineOfSight, + BadFacing, +} + +impl SwingBlock { + /// 1.12 has no swing message for line of sight (vmangos `Unit::CanAutoAttackTarget`). + fn swing_error(self) -> u8 { + match self { + Self::OutOfRange => SWING_ERROR_NOT_IN_RANGE, + Self::BadFacing => SWING_ERROR_BAD_FACING, + Self::TooClose | Self::NoLineOfSight => SWING_ERROR_NONE, + } + } +} + +/// Writes only on a change, and only when a hand is due: vmangos reports a ready attack only. +fn record_swing_error( + ctx: &ReducerContext, + atk: &MeleeAttack, + attacker: &WorldEntity, + swing_error: u8, + now_ms: u32, +) { + let hand_due = |last_ms: u32, interval_ms: u32| { + last_ms == 0 || now_ms.wrapping_sub(last_ms) >= interval_ms + }; + let due = hand_due(atk.last_swing_ms, effective_swing_time(ctx, attacker)) + || equipped_offhand_weapon_damage(ctx, atk.attacker_guid) + .is_some_and(|(_, _, delay)| hand_due(atk.last_offhand_swing_ms, delay)); + if !due || atk.swing_error == swing_error { + return; + } + let melee = ctx.db.game_melee_attack(); + if let Some(mut fresh) = melee.attacker_guid().find(atk.attacker_guid) { + fresh.swing_error = swing_error; + melee.attacker_guid().update(fresh); } - false } fn fire_melee_swing( @@ -428,13 +475,12 @@ fn fire_melee_swing( let (dmg, _absorbed) = fold_incoming_damage(ctx, attacker_guid, target_guid, rolled); let damage = final_damage(ctx, target_guid, dmg); - let lethal = is_lethal(target.health, damage.amount); let (projected_swing, projected_seal) = split_damage_projection(damage.amount, seal_portion); events.insert(CombatEvent { damage: projected_swing, hit_info, - killing_blow: lethal, + killing_blow: is_killing_blow(ctx, attacker_guid, target, damage.amount), blocked_amount: blocked, spell_swing: swing_is_spell, ..CombatEvent::signal_at(ctx, attacker, target_guid) @@ -494,6 +540,7 @@ fn fire_melee_swing( // Re-read before stamping; the off-hand may have updated its clock on this row. if let Some(mut fresh) = melee.attacker_guid().find(attacker_guid) { fresh.last_swing_ms = now_ms; + fresh.swing_error = SWING_ERROR_NONE; melee.attacker_guid().update(fresh); } diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index d14079b22..cd8562bd1 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -1083,6 +1083,7 @@ impl PetSink for CtxWorld<'_> { pursuit_ends_ms: 0, leash_x: 0.0, leash_y: 0.0, + swing_error: 0, }); } } diff --git a/module/src/creatures/eventai/loader.rs b/module/src/creatures/eventai/loader.rs index ae515499a..54f9a85da 100644 --- a/module/src/creatures/eventai/loader.rs +++ b/module/src/creatures/eventai/loader.rs @@ -1091,6 +1091,7 @@ pub fn debug_stage_eventai_revision_fixture( pursuit_ends_ms: 0, leash_x: 0.0, leash_y: 0.0, + swing_error: 0, }); } Ok(()) diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 8922eb9a4..75c584ad2 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -773,6 +773,7 @@ pub fn debug_engage( pursuit_ends_ms: 0, leash_x: 0.0, leash_y: 0.0, + swing_error: 0, }; if melee.attacker_guid().find(attacker_guid).is_some() { melee.attacker_guid().update(row); diff --git a/module/src/loot/tag.rs b/module/src/loot/tag.rs index 0b9552fe0..7b75b4446 100644 --- a/module/src/loot/tag.rs +++ b/module/src/loot/tag.rs @@ -598,6 +598,7 @@ pub fn debug_verify_loot_tag_fixture(ctx: &ReducerContext) -> Result<(), String> pursuit_ends_ms: 0, leash_x: 0.0, leash_y: 0.0, + swing_error: 0, }); crate::combat::kill_player( ctx, diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index e4519f249..686b0567d 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -230,9 +230,15 @@ fn run_spell_effects( let mut total_healed: u32 = 0; let mut total_resisted: u32 = 0; let mut total_absorbed: u32 = 0; + let mut total_energized: u32 = 0; + let mut energized_power_type: u8 = 0; + let mut primary_missed = false; let mut any_crit = false; let mut item_grants = 0; let mut item_refused = false; + // One hit roll per target for the whole spell (CONTEXT.md "Cast Miss"); never the caster. + let rolls_hit = effects.iter().any(|e| e.kind == E_DAMAGE); + let mut missed_targets: std::collections::HashMap = std::collections::HashMap::new(); for e in effects { let points = effect_amount( e.base_points, @@ -247,6 +253,15 @@ fn run_spell_effects( if triggered && !triggered_target_admitted(origin, caster_guid, t, is_alive(ctx, t)) { continue; } + if rolls_hit + && t != caster_guid + && *missed_targets + .entry(t) + .or_insert_with(|| super::targeting::spell_misses(ctx, level, t)) + { + primary_missed |= t == target_guid; + continue; + } // Combo finishers scale from the explicit enemy target, even for self-targeted effects. let hit = apply_effect( ctx, @@ -271,6 +286,10 @@ fn run_spell_effects( total_resisted = total_resisted.saturating_add(hit.resisted); total_absorbed = total_absorbed.saturating_add(hit.absorbed); total_healed = total_healed.saturating_add(hit.healed); + if hit.energized > 0 { + total_energized = total_energized.saturating_add(hit.energized); + energized_power_type = hit.power_type; + } any_crit |= hit.crit; } } @@ -307,12 +326,24 @@ fn run_spell_effects( is_crit: any_crit, resisted: total_resisted, absorbed: total_absorbed, + miss_info: cast_miss(primary_missed, hdr.school_mask), + energized: total_energized, + power_type: energized_power_type, cooldown_ms: hdr.cooldown_ms, client_initiated, ..SpellCastEvent::signal(ctx, caster_guid, hdr.spell_id, SpellCastEventKind::Go) }); } +fn cast_miss(missed: bool, school_mask: u8) -> u8 { + const SCHOOL_MASK_PHYSICAL: u8 = 0x01; + match (missed, school_mask) { + (false, _) => crate::spell::CAST_MISS_NONE, + (true, 0 | SCHOOL_MASK_PHYSICAL) => crate::spell::CAST_MISS_MISS, + (true, _) => crate::spell::CAST_MISS_RESIST, + } +} + fn is_alive(ctx: &ReducerContext, guid: u64) -> bool { ctx.db .game_world_entity() @@ -1434,3 +1465,18 @@ mod eventai_hit_tests { assert!(caster_life_allows(true, false, false)); } } + +#[cfg(test)] +mod cast_miss_tests { + use super::*; + + #[test] + fn a_failed_hit_roll_misses_a_physical_spell_and_resists_a_magic_one() { + // School masks: 0 none, 1 physical, 4 fire, 32 shadow. + assert_eq!(cast_miss(false, 4), crate::spell::CAST_MISS_NONE); + assert_eq!(cast_miss(true, 0), crate::spell::CAST_MISS_MISS); + assert_eq!(cast_miss(true, 1), crate::spell::CAST_MISS_MISS); + assert_eq!(cast_miss(true, 4), crate::spell::CAST_MISS_RESIST); + assert_eq!(cast_miss(true, 32), crate::spell::CAST_MISS_RESIST); + } +} diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index e27068fda..1ba45d14b 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -26,6 +26,8 @@ pub(crate) struct EffectHit { pub absorbed: u32, pub crit: bool, pub healed: u32, + pub energized: u32, + pub power_type: u8, } impl EffectHit { fn none() -> Self { @@ -46,6 +48,18 @@ impl EffectHit { } } +/// An absent target counts as equal level. +pub(crate) fn spell_misses(ctx: &ReducerContext, caster_level: u8, target_guid: u64) -> bool { + let target_level = ctx + .db + .game_world_entity() + .guid() + .find(target_guid) + .map(|t| t.level) + .unwrap_or(caster_level as u32); + (ctx.random::() % 10_000) < spell_miss_chance_bp(caster_level as u32, target_level) +} + pub(crate) fn recipe_reagents(ctx: &ReducerContext, spell_id: u32) -> Vec<(u32, u32)> { ctx.db .game_spell_reagent() @@ -587,16 +601,9 @@ pub(crate) fn apply_effect( EffectKind::Damage | EffectKind::WeaponStrike | EffectKind::Judgement - | EffectKind::FinisherDamage => apply_damage_effect( - ctx, - e, - hdr, - caster_guid, - target_guid, - level, - points, - triggered, - ), + | EffectKind::FinisherDamage => { + apply_damage_effect(ctx, e, hdr, caster_guid, target_guid, points, triggered) + } EffectKind::Heal | EffectKind::HealMaxHealth | EffectKind::Energize @@ -756,29 +763,17 @@ fn hit_of( ) } -#[allow(clippy::too_many_arguments)] fn apply_damage_effect( ctx: &ReducerContext, e: &SpellEffect, hdr: &Spell, caster_guid: u64, target_guid: u64, - level: u8, points: i32, triggered: bool, ) -> EffectHit { match e.kind { E_DAMAGE => { - let target_level = ctx - .db - .game_world_entity() - .guid() - .find(target_guid) - .map(|t| t.level) - .unwrap_or(level as u32); - if (ctx.random::() % 10_000) < spell_miss_chance_bp(level as u32, target_level) { - return EffectHit::none(); - } let sp = spell_power(ctx, caster_guid, hdr.school_mask); let scaled = compose_magnitude(points, sp, SPELL_POWER_COEFF_PCT); let (dmg_flat, dmg_pct) = @@ -922,14 +917,18 @@ fn apply_restore_effect( EffectHit::none() } E_ENERGIZE => { + let mut hit = EffectHit::none(); if let Some(mut t) = ctx.db.game_world_entity().guid().find(target_guid) { t.power = energized_value(t.power, t.max_power, points); + // vmangos `EnergizeBySpell` logs the full grant, not the gain. + hit.energized = points.max(0) as u32; + hit.power_type = t.power_type(); ctx.db.game_world_entity().guid().update(t); } if e.enters_combat { crate::combat::enter_combat(ctx, caster_guid); } - EffectHit::none() + hit } E_CONVERT_RESOURCE => { convert_health_to_power(ctx, caster_guid, points); diff --git a/module/src/spell/mod.rs b/module/src/spell/mod.rs index 052412f41..719ab3ff3 100644 --- a/module/src/spell/mod.rs +++ b/module/src/spell/mod.rs @@ -96,6 +96,7 @@ pub use tables::{ DynamicObject, GroundArea, GroundAreaSchedule, PendingCast, PendingSpellImpact, ResurrectRequest, SchoolLockout, SelfResurrectOption, Spell, SpellCastEvent, SpellCd, SpellCooldown, SpellEffect, SpellImpactEvent, SpellProcEvent, SpellReagent, CAST_FAIL_NO_POWER, + CAST_MISS_MISS, CAST_MISS_NONE, CAST_MISS_RESIST, }; pub(crate) use lyracore_shared::spell::taxonomy::{ diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index 959c92fcb..64d7673f2 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -338,6 +338,12 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { .unwrap_or(0); let amount = resist_magic_damage(ctx, a.target_guid, school, a.level as u32, a.amount); + ctx.db.game_spell_cast_event().insert(SpellCastEvent { + damage: amount.max(0) as u32, + school: crate::spell::school_index(school), + resisted: a.amount.saturating_sub(amount).max(0) as u32, + ..SpellCastEvent::periodic_log(ctx, a.caster_guid, a.spell_id, &target) + }); if amount > 0 { dot_damaged.insert(a.target_guid); if !target.is_player() @@ -391,6 +397,12 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { .unwrap_or(target.health); let new_health = healed_value(cur, target.max_health, a.amount); pending.insert(a.target_guid, new_health); + if cur < target.max_health { + ctx.db.game_spell_cast_event().insert(SpellCastEvent { + healed: a.amount.max(0) as u32, + ..SpellCastEvent::periodic_log(ctx, a.caster_guid, a.spell_id, &target) + }); + } let effective = new_health.saturating_sub(cur); crate::threat::add_heal_threat(ctx, a.caster_guid, a.target_guid, effective); } @@ -403,6 +415,11 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { a.target_guid, energized_value(cur, target.max_power, a.amount), ); + ctx.db.game_spell_cast_event().insert(SpellCastEvent { + energized: a.amount.max(0) as u32, + power_type: target.power_type(), + ..SpellCastEvent::periodic_log(ctx, a.caster_guid, a.spell_id, &target) + }); if a.enters_combat { crate::combat::enter_combat(ctx, a.caster_guid); } diff --git a/module/src/spell/tables.rs b/module/src/spell/tables.rs index c6ef26b56..f46d3f48a 100644 --- a/module/src/spell/tables.rs +++ b/module/src/spell/tables.rs @@ -191,6 +191,7 @@ pub(crate) enum SpellCastEventKind { Interrupt = 3, Pushback = 4, ProcLog = 5, + PeriodicLog = 6, } #[table( @@ -254,8 +255,21 @@ pub struct SpellCastEvent { // Zero selects legacy field decoding; new rows use nonzero codes. #[default(0u8)] pub kind: u8, + // `CAST_MISS_*` for the primary target on a GO row. + #[default(0u8)] + pub miss_info: u8, + // Power given to the primary target; `power_type` is the target's power byte. + #[default(0u32)] + pub energized: u32, + #[default(0u8)] + pub power_type: u8, } +pub const CAST_MISS_NONE: u8 = 0; +pub const CAST_MISS_MISS: u8 = 1; +/// Vanilla reports a magic spell's failed hit roll as a full resist. +pub const CAST_MISS_RESIST: u8 = 2; + impl SpellCastEvent { pub(crate) fn signal( ctx: &ReducerContext, @@ -287,6 +301,25 @@ impl SpellCastEvent { ) } + /// Addressed at the target, since the caster may be far away or gone. + pub(crate) fn periodic_log( + ctx: &ReducerContext, + caster_guid: u64, + spell_id: u32, + target: &WorldEntity, + ) -> Self { + Self { + target_guid: target.guid, + ..Self::signal_addr( + ctx, + caster_guid, + spell_id, + crate::helpers::entity_addr(target), + SpellCastEventKind::PeriodicLog, + ) + } + } + fn signal_addr( ctx: &ReducerContext, caster_guid: u64, @@ -321,6 +354,9 @@ impl SpellCastEvent { grid_y, failure_reason: CAST_FAIL_NONE, kind: kind as u8, + miss_info: CAST_MISS_NONE, + energized: 0, + power_type: 0, } } } diff --git a/module/src/world.rs b/module/src/world.rs index b796f7070..979f9475a 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -246,6 +246,11 @@ impl WorldEntity { pub(crate) fn class(&self) -> u8 { ((self.unit_bytes_0 >> 8) & 0xFF) as u8 } + + /// The pool `power` holds (`lyracore_shared::packing::power_type`) — byte 3 of `unit_bytes_0`. + pub(crate) fn power_type(&self) -> u8 { + ((self.unit_bytes_0 >> 24) & 0xFF) as u8 + } } #[table( diff --git a/module/tests/combat_log.rs b/module/tests/combat_log.rs new file mode 100644 index 000000000..1984b3647 --- /dev/null +++ b/module/tests/combat_log.rs @@ -0,0 +1,268 @@ +mod support; + +use std::time::{SystemTime, UNIX_EPOCH}; + +use lyracore_shared::spell::taxonomy::{ + A_PERIODIC_DAMAGE, E_DAMAGE, E_ENERGIZE, T_SELF, T_TARGET_ENEMY, +}; + +use support::{number, poll_until, Row, Standalone, POLL_TIMEOUT}; + +const CHARACTER: u64 = 1; +const WOLF: u64 = (0xF130_u64 << 48) | (51_000_u64 << 24) | 1; +/// `SpellCastEventKind::PeriodicLog`. +const PERIODIC_LOG: u8 = 6; +/// `debug_fill_aura_slots` spell ids: `9_000_000 + slot`; debuff slots start at 32. +const HOT: u32 = 9_000_000; +const ENERGIZE: u32 = 9_000_001; +const DOT: u32 = 9_000_032; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn damage_heal_and_energize_ticks_each_log_their_outcome() { + let mut standalone = Standalone::start("periodic-aura-log"); + standalone.publish_module(); + standalone.assert_sql("DELETE FROM game_creature_move_schedule"); + standalone.assert_sql("DELETE FROM game_melee_schedule"); + standalone.assert_call("debug_spawn_player_entity", &[&CHARACTER.to_string()]); + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET health = 100, max_health = 100 WHERE guid = {WOLF}" + )); + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET health = 50, max_health = 100, power = 0, max_power = 100 \ + WHERE guid = {CHARACTER}" + )); + let character = standalone + .query_rows(&format!( + "SELECT unit_bytes_0 FROM game_world_entity WHERE guid = {CHARACTER}" + )) + .pop() + .expect("the Character must be live"); + let power_type = (number::(&character, "unit_bytes_0") >> 24) & 0xFF; + + standalone.assert_call( + "debug_fill_aura_slots", + &[&WOLF.to_string(), &CHARACTER.to_string(), "true", "1"], + ); + standalone.assert_call( + "debug_fill_aura_slots", + &[&CHARACTER.to_string(), &CHARACTER.to_string(), "false", "2"], + ); + // 0x90 periodic damage, 0x91 periodic heal, 0x92 periodic energize. + for (spell_id, kind, amount) in [(DOT, 0x90, 7), (HOT, 0x91, 7), (ENERGIZE, 0x92, 5)] { + standalone.assert_sql(&format!( + "UPDATE game_aura SET eff_kind = {kind}, amount = {amount} WHERE spell_id = {spell_id}" + )); + } + let due = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_micros() as i64 + - 1_000_000; + standalone.assert_sql(&format!( + "UPDATE game_aura SET period_ms = 600000, next_tick_micros = {due}" + )); + + assert!( + poll_until(POLL_TIMEOUT, || logs(&standalone).len() >= 3), + "every tick must leave a log, got {:?}", + logs(&standalone) + ); + let logs = logs(&standalone); + let log = |spell_id: u32| -> &Row { + logs.iter() + .find(|row| number::(row, "spell_id") == spell_id) + .unwrap_or_else(|| panic!("no log for spell {spell_id}: {logs:?}")) + }; + + let dot = log(DOT); + assert_eq!(number::(dot, "target_guid"), WOLF); + assert_eq!(number::(dot, "caster_guid"), CHARACTER); + assert_eq!( + number::(dot, "damage"), + 7, + "a physical tick is not resisted" + ); + assert_eq!(number::(dot, "healed"), 0); + + let hot = log(HOT); + assert_eq!(number::(hot, "target_guid"), CHARACTER); + assert_eq!(number::(hot, "healed"), 7); + assert_eq!(number::(hot, "damage"), 0); + + let energize = log(ENERGIZE); + assert_eq!(number::(energize, "target_guid"), CHARACTER); + assert_eq!(number::(energize, "energized"), 5); + assert_eq!(number::(energize, "power_type"), power_type); +} + +fn logs(standalone: &Standalone) -> Vec { + standalone.query_rows(&format!( + "SELECT spell_id, caster_guid, target_guid, damage, healed, energized, power_type \ + FROM game_spell_cast_event WHERE kind = {PERIODIC_LOG}" + )) +} + +const SELF_ENERGIZE: u32 = 509_0101; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_direct_energize_records_the_power_given_on_the_go() { + let mut standalone = Standalone::start("energize-log"); + standalone.publish_module(); + standalone.assert_call("debug_spawn_player_entity", &[&CHARACTER.to_string()]); + standalone.assert_sql(&format!( + "DELETE FROM game_spell WHERE spell_id = {SELF_ENERGIZE}" + )); + standalone.assert_sql(&format!( + "INSERT INTO game_spell (spell_id, name, power_type, cost, cast_time_ms, gcd_ms, \ + cooldown_ms, range_yd, duration_ms, school_mask, dispel_type, mechanic, max_stacks, \ + aura_interrupt, attributes, spell_level, max_level, is_negative, cast_flags, stances, \ + family_name, family_flags, proc_flags, proc_chance, proc_charges) \ + VALUES ({SELF_ENERGIZE}, 'Fixture Energize', 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, \ + 0, false, 0, 0, 0, 0, 0, 0, 0)" + )); + standalone.assert_sql(&format!( + "INSERT INTO game_spell_effect (id, spell_id, effect_index, kind, base_points, die_sides, \ + per_level, period_ms, target, radius_yd, chain_targets, trigger_spell, effect_mechanic, \ + p0, p0_kind, p1, script_id, enters_combat) \ + VALUES ({}, {SELF_ENERGIZE}, 0, {E_ENERGIZE}, 9, 0, 0.0, 0, {T_SELF}, 0.0, 0, 0, 0, \ + 0, 0, 0, 0, false)", + (SELF_ENERGIZE as u64) << 2 + )); + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET power = 0, max_power = 100 WHERE guid = {CHARACTER}" + )); + let character = standalone + .query_rows(&format!( + "SELECT unit_bytes_0 FROM game_world_entity WHERE guid = {CHARACTER}" + )) + .pop() + .expect("the Character must be live"); + let power_type = (number::(&character, "unit_bytes_0") >> 24) & 0xFF; + + standalone.assert_call( + "debug_cast_at", + &[ + &CHARACTER.to_string(), + &SELF_ENERGIZE.to_string(), + &CHARACTER.to_string(), + ], + ); + + let go = standalone + .query_rows(&format!( + "SELECT energized, power_type FROM game_spell_cast_event \ + WHERE spell_id = {SELF_ENERGIZE} AND kind = 2" + )) + .pop() + .expect("the energize must report its GO"); + let power: u32 = number( + &standalone + .query_rows(&format!( + "SELECT power FROM game_world_entity WHERE guid = {CHARACTER}" + )) + .pop() + .expect("the Character must be live"), + "power", + ); + assert!(power > 0, "the energize must fill the pool"); + assert_eq!( + number::(&go, "energized"), + power, + "the GO names the power given to an empty pool" + ); + assert_eq!(number::(&go, "power_type"), power_type); +} + +const FIRE_WITH_DOT: u32 = 509_0102; +/// `CAST_MISS_RESIST`. +const CAST_MISS_RESIST: u32 = 2; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_resisted_spell_lands_none_of_its_effects_and_reports_the_resist() { + let mut standalone = Standalone::start("cast-miss"); + standalone.publish_module(); + standalone.assert_sql("DELETE FROM game_creature_move_schedule"); + standalone.assert_sql("DELETE FROM game_melee_schedule"); + standalone.assert_call("debug_spawn_player_entity", &[&CHARACTER.to_string()]); + standalone.assert_call( + "debug_spawn_at_feet", + &[&CHARACTER.to_string(), "51000", "5.0"], + ); + let wolf = standalone + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000") + .iter() + .map(|row| number::(row, "guid")) + .max() + .expect("the spawned wolf must exist"); + // Level 1 against level 60 misses 99% of the time. + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET level = 1 WHERE guid = {CHARACTER}" + )); + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET level = 60, health = 100000, max_health = 100000 \ + WHERE guid = {wolf}" + )); + standalone.assert_sql(&format!( + "INSERT INTO game_spell (spell_id, name, power_type, cost, cast_time_ms, gcd_ms, \ + cooldown_ms, range_yd, duration_ms, school_mask, dispel_type, mechanic, max_stacks, \ + aura_interrupt, attributes, spell_level, max_level, is_negative, cast_flags, stances, \ + family_name, family_flags, proc_flags, proc_chance, proc_charges) \ + VALUES ({FIRE_WITH_DOT}, 'Fixture Fire', 0, 0, 0, 0, 0, 30, 12000, 4, 0, 0, 0, 0, 0, 0, \ + 0, true, 0, 0, 0, 0, 0, 0, 0)" + )); + for (index, kind, period_ms) in [(0u64, E_DAMAGE, 0), (1, A_PERIODIC_DAMAGE, 3000)] { + standalone.assert_sql(&format!( + "INSERT INTO game_spell_effect (id, spell_id, effect_index, kind, base_points, \ + die_sides, per_level, period_ms, target, radius_yd, chain_targets, trigger_spell, \ + effect_mechanic, p0, p0_kind, p1, script_id, enters_combat) \ + VALUES ({}, {FIRE_WITH_DOT}, {index}, {kind}, 9, 0, 0.0, {period_ms}, \ + {T_TARGET_ENEMY}, 0.0, 0, 0, 0, 0, 0, 0, 0, false)", + ((FIRE_WITH_DOT as u64) << 2) | index + )); + } + + let mut missed = None; + for _ in 0..10 { + standalone.assert_sql("DELETE FROM game_spell_cast_event"); + standalone.assert_sql(&format!( + "DELETE FROM game_aura WHERE spell_id = {FIRE_WITH_DOT}" + )); + standalone.assert_call( + "debug_cast_at", + &[ + &CHARACTER.to_string(), + &FIRE_WITH_DOT.to_string(), + &wolf.to_string(), + ], + ); + let go = standalone + .query_rows(&format!( + "SELECT miss_info, damage FROM game_spell_cast_event \ + WHERE spell_id = {FIRE_WITH_DOT} AND kind = 2" + )) + .pop() + .expect("the cast must report its GO"); + if number::(&go, "miss_info") != 0 { + missed = Some(go); + break; + } + } + let go = missed.expect("ten casts at a 99% miss chance must miss at least once"); + assert_eq!( + number::(&go, "miss_info"), + CAST_MISS_RESIST, + "a fire spell is resisted" + ); + assert_eq!(number::(&go, "damage"), 0); + assert!( + standalone + .query_rows(&format!( + "SELECT id FROM game_aura WHERE spell_id = {FIRE_WITH_DOT}" + )) + .is_empty(), + "a resisted spell must not apply its damage-over-time aura" + ); +} diff --git a/module/tests/duel_killing_blow.rs b/module/tests/duel_killing_blow.rs new file mode 100644 index 000000000..247fa8f28 --- /dev/null +++ b/module/tests/duel_killing_blow.rs @@ -0,0 +1,87 @@ +mod support; + +use support::{number, poll_until, Standalone, POLL_TIMEOUT}; + +const PLAYER: u64 = 1; +const DUEL: u64 = 509_0001; +/// `lyracore_shared::duel::state::ACTIVE`. +const ACTIVE: u8 = 2; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_duel_ending_swing_is_no_killing_blow() { + let mut standalone = Standalone::start("duel-killing-blow"); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + standalone.assert_call("install_guid_range", &["0"]); + standalone.assert_call("debug_seed_scenario_fixtures", &[]); + standalone.assert_call("debug_spawn_player_entity", &[&PLAYER.to_string()]); + standalone.assert_call( + "debug_create_package_character", + &[r#""duel-test""#, r#""Rival""#, "1", "1"], + ); + let rival: u64 = number( + &standalone + .query_rows("SELECT guid FROM game_character WHERE name = 'Rival'") + .remove(0), + "guid", + ); + standalone.assert_call("debug_spawn_player_entity", &[&rival.to_string()]); + + let player = standalone + .query_rows(&format!( + "SELECT map_id, instance_id, x, y, z FROM game_world_entity WHERE guid = {PLAYER}" + )) + .remove(0); + let (x, y, z): (f32, f32, f32) = ( + number(&player, "x"), + number(&player, "y"), + number(&player, "z"), + ); + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET x = {}, y = {y}, z = {z}, health = 1 WHERE guid = {rival}", + x + 1.0 + )); + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET orientation = 0.0, godmode = true WHERE guid = {PLAYER}" + )); + standalone.assert_sql(&format!( + "INSERT INTO game_duel (id, flag_guid, flag_entry, initiator_guid, challenged_guid, \ + map_id, instance_id, flag_x, flag_y, flag_z, flag_orientation, state, \ + countdown_deadline_micros, initiator_out_of_bounds_deadline_micros, \ + challenged_out_of_bounds_deadline_micros, created_at_micros) \ + VALUES ({DUEL}, 0, 21680, {PLAYER}, {rival}, {}, {}, {x}, {y}, {z}, 0.0, {ACTIVE}, \ + 0, 0, 0, 0)", + player["map_id"], player["instance_id"] + )); + + standalone.assert_call("debug_engage", &[&PLAYER.to_string(), &rival.to_string()]); + // A swing may miss or be avoided, so keep making the next one due. + assert!( + poll_until(POLL_TIMEOUT, || { + standalone.assert_sql(&format!( + "UPDATE game_melee_attack SET last_swing_ms = 1 WHERE attacker_guid = {PLAYER}" + )); + standalone + .query_rows(&format!("SELECT id FROM game_duel WHERE id = {DUEL}")) + .is_empty() + }), + "no swing ended the Duel" + ); + + let rival_row = standalone + .query_rows(&format!( + "SELECT health, dead FROM game_world_entity WHERE guid = {rival}" + )) + .remove(0); + assert_eq!(rival_row["dead"], "false", "the Duel loser must survive"); + assert_eq!(rival_row["health"], "1"); + let swings = standalone.query_rows(&format!( + "SELECT killing_blow FROM game_combat_event WHERE attacker_guid = {PLAYER}" + )); + assert!(!swings.is_empty(), "the Duel must have ended on a swing"); + assert!( + swings.iter().all(|swing| swing["killing_blow"] == "false"), + "a Duel-ending swing is no killing blow: {swings:?}" + ); +} diff --git a/module/tests/swing_error.rs b/module/tests/swing_error.rs new file mode 100644 index 000000000..82ad8d757 --- /dev/null +++ b/module/tests/swing_error.rs @@ -0,0 +1,117 @@ +mod support; + +use support::{number, poll_until, Row, Standalone, POLL_TIMEOUT}; + +const PLAYER: u64 = 1; +const TEST_WOLF_ENTRY: u32 = 51000; +const NONE: u8 = 0; +const NOT_IN_RANGE: u8 = 1; +const BAD_FACING: u8 = 2; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_due_swing_records_out_of_range_and_bad_facing_and_a_landed_swing_clears_it() { + let mut standalone = Standalone::start("swing-error"); + standalone.publish_module(); + standalone.assert_call("debug_spawn_player_entity", &[&PLAYER.to_string()]); + let wolf = spawn_wolf(&standalone, 20.0); + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET godmode = true WHERE guid = {PLAYER} OR guid = {wolf}" + )); + let player = entity(&standalone, PLAYER); + let (x, y): (f32, f32) = (number(&player, "x"), number(&player, "y")); + // Face +x, toward the wolf `debug_spawn_at_feet` placed ahead on x. + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET orientation = 0.0 WHERE guid = {PLAYER}" + )); + + standalone.assert_call("debug_engage", &[&PLAYER.to_string(), &wolf.to_string()]); + wait_for_swing_error(&standalone, NOT_IN_RANGE); + assert!( + own_swings(&standalone).is_empty(), + "an out-of-range swing must not land" + ); + + place(&standalone, wolf, x + 1.0, y); + make_swing_due(&standalone); + assert!( + poll_until(POLL_TIMEOUT, || !own_swings(&standalone).is_empty()), + "the swing in reach never landed" + ); + wait_for_swing_error(&standalone, NONE); + + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET orientation = 3.14159 WHERE guid = {PLAYER}" + )); + standalone.assert_sql("DELETE FROM game_combat_event"); + make_swing_due(&standalone); + wait_for_swing_error(&standalone, BAD_FACING); + assert!( + own_swings(&standalone).is_empty(), + "a swing at a target behind the player must not land" + ); +} + +fn spawn_wolf(standalone: &Standalone, offset: f32) -> u64 { + standalone.assert_call( + "debug_spawn_at_feet", + &[ + &PLAYER.to_string(), + &TEST_WOLF_ENTRY.to_string(), + &offset.to_string(), + ], + ); + standalone + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE entry = {TEST_WOLF_ENTRY}" + )) + .iter() + .map(|row| number::(row, "guid")) + .max() + .expect("the spawned wolf must exist") +} + +fn entity(standalone: &Standalone, guid: u64) -> Row { + standalone + .query_rows(&format!( + "SELECT x, y FROM game_world_entity WHERE guid = {guid}" + )) + .pop() + .expect("the entity must be live") +} + +fn place(standalone: &Standalone, guid: u64, x: f32, y: f32) { + standalone.assert_sql(&format!( + "UPDATE game_world_entity SET x = {x}, y = {y} WHERE guid = {guid}" + )); +} + +/// Stamping the swing clock at 1 makes the next swing tick due at once. +fn make_swing_due(standalone: &Standalone) { + standalone.assert_sql(&format!( + "UPDATE game_melee_attack SET last_swing_ms = 1 WHERE attacker_guid = {PLAYER}" + )); +} + +fn swing_error(standalone: &Standalone) -> Option { + standalone + .query_rows(&format!( + "SELECT swing_error FROM game_melee_attack WHERE attacker_guid = {PLAYER}" + )) + .pop() + .map(|row| number(&row, "swing_error")) +} + +fn wait_for_swing_error(standalone: &Standalone, expected: u8) { + assert!( + poll_until(POLL_TIMEOUT, || swing_error(standalone) == Some(expected)), + "the Swing Error must become {expected}, got {:?}", + swing_error(standalone) + ); +} + +fn own_swings(standalone: &Standalone) -> Vec { + standalone.query_rows(&format!( + "SELECT damage FROM game_combat_event WHERE attacker_guid = {PLAYER}" + )) +}