Floating combat text words each outcome as the reference's emitter does (#387)
Floating combat text picks the wrong outcome word for mitigated hits, partly mitigated spell hits, damage-over-time ticks and one spell-miss code. Each now follows the reference's own emitter. Fixes #42. Fixes #43. Fixes #89. Fixes #349. - **Melee partials (#42):** tested resist (`0x629efd`), then block (`0x629f15`), then absorb (`0x629f30`); benilla tested absorb first. - **Direct spell hits (#43):** a landed hit words its first partial as `0x62cd80` does: `SPELL_RESISTED`, else `SPELL_BLOCKED` (carrying the blocked amount), else `SPELL_ABSORBED`, else `SPELL_DAMAGE`; a zero-damage hit tests absorbed, blocked, resisted. The wider cause: benilla applied the direct words to every `SMSG_SPELLNONMELEEDAMAGELOG`. The reference's handler (`0x5e85e0`) sends split damage to `SPLIT_DAMAGE` (`0x62de60`) and a periodic log, gated on `CombatLogPeriodicSpells` (`0x62d9ae`), to the periodic emitter; the text now routes the same three ways. - **Damage ticks (#89):** a landed tick words `RESIST`, else `ABSORB`, else `DAMAGE` (`0x628100`: `0x628354`, `0x62836c`, `0x62837f`); a zero-damage tick words `SPELL_ABSORBED`, else `SPELL_RESISTED`. - **Spell-miss codes (#349):** the jump table at `0x62be10` sends code 10, 1 and 12 and up to `SPELL_MISSED` (`0x62bdc3`), and code 0 fires nothing. Tests: six tests in `combat_log/text.rs`; the two over existing functions fail on the old code, and the new routing is covered by three more. `scripts/check.sh` is green. To see it (Floating Combat Text on, "Show Damage Reduction" on): a damage-over-time tick now scrolls as damage, "-N", and a partly resisted tick as "N (M resisted)"; a partly resisted spell hit reads "300 (100 resisted)" instead of "-300". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3df3303d0e
commit
1f77007977
@@ -117,6 +117,7 @@ fn on_spell_damage_log(In(ev): In<SessionEvent>, c: Ctx, mut l: Sinks) {
|
||||
&l.stores,
|
||||
c.spells.as_deref(),
|
||||
*l.damage_text,
|
||||
c.periodic.0,
|
||||
&mut l.text,
|
||||
&mut l.feedback,
|
||||
&mut l.center,
|
||||
|
||||
@@ -81,28 +81,83 @@ fn spell_feedback(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spell packet to the centre text's messageType: landed damage is always `SPELL_DAMAGE`, crit or
|
||||
/// not (`0x62cd80` has no crit type); zero damage is the `SPELL_ABSORBED`/`SPELL_RESISTED` word.
|
||||
/// The reference's partial words on a landed spell hit are not built here.
|
||||
fn spell_center_text(
|
||||
/// A centre-text line: the messageType, `arg2` and `arg3`.
|
||||
pub(crate) type CenterText = (&'static str, Option<String>, Option<String>);
|
||||
|
||||
/// `SMSG_SPELLNONMELEEDAMAGELOG`'s centre text, routed as the handler `0x5e85e0` routes the log:
|
||||
/// split damage (`hit_info & 8`, `0x5e8700`) is `0x62de60`'s `SPLIT_DAMAGE` with the damage; a
|
||||
/// periodic log (`0x5e876d`) takes the periodic tick's words, and none at all while
|
||||
/// `CombatLogPeriodicSpells` is off (`0x62d9ae`); anything else takes the direct hit's.
|
||||
fn spell_damage_center_text(s: &SpellDamageLog, log_periodic: bool) -> Option<CenterText> {
|
||||
if s.hit_info & 0x8 != 0 {
|
||||
Some(("SPLIT_DAMAGE", Some(s.damage.to_string()), None))
|
||||
} else if s.periodic {
|
||||
log_periodic
|
||||
.then(|| periodic_center_text(s.damage, s.absorb, s.resist))
|
||||
.flatten()
|
||||
} else {
|
||||
direct_spell_center_text(s.damage, s.absorb, s.resist, s.blocked)
|
||||
}
|
||||
}
|
||||
|
||||
/// The direct spell hit's words (`0x62cd80`), with no crit type. A landed hit takes the first of
|
||||
/// `SPELL_RESISTED` (damage, resisted; `0x62d048`), `SPELL_BLOCKED` (blocked; `0x62d062`) and
|
||||
/// `SPELL_ABSORBED` (damage, absorbed; `0x62d07b`), else `SPELL_DAMAGE` (`0x62d098`). Nothing
|
||||
/// through is worded absorbed, then blocked (miss code 5, `0x62cec7`), then resisted.
|
||||
fn direct_spell_center_text(
|
||||
damage: u32,
|
||||
absorb: u32,
|
||||
resist: i32,
|
||||
) -> Option<(&'static str, Option<String>)> {
|
||||
blocked: u32,
|
||||
) -> Option<CenterText> {
|
||||
let amount = |n: u32| Some(n.to_string());
|
||||
if damage > 0 {
|
||||
Some(("SPELL_DAMAGE", Some(damage.to_string())))
|
||||
} else if absorb > 0 {
|
||||
Some(("SPELL_ABSORBED", None))
|
||||
} else if resist > 0 {
|
||||
Some(("SPELL_RESISTED", None))
|
||||
Some(if resist > 0 {
|
||||
("SPELL_RESISTED", amount(damage), Some(resist.to_string()))
|
||||
} else if blocked != 0 {
|
||||
("SPELL_BLOCKED", amount(blocked), None)
|
||||
} else if absorb != 0 {
|
||||
("SPELL_ABSORBED", amount(damage), amount(absorb))
|
||||
} else {
|
||||
("SPELL_DAMAGE", amount(damage), None)
|
||||
})
|
||||
} else if absorb != 0 {
|
||||
Some(("SPELL_ABSORBED", None, None))
|
||||
} else if blocked != 0 {
|
||||
Some(("SPELL_BLOCKED", None, None))
|
||||
} else if resist != 0 {
|
||||
Some(("SPELL_RESISTED", None, None))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A periodic damage tick's words (`0x628100`), the melee family's: a landed tick takes `RESIST`
|
||||
/// (damage, resisted; `0x628354`), else `ABSORB` (damage, absorbed; `0x62836c`), else `DAMAGE`
|
||||
/// (`0x62837f`). Nothing through is `SPELL_ABSORBED`, else `SPELL_RESISTED`.
|
||||
fn periodic_center_text(damage: u32, absorb: u32, resist: i32) -> Option<CenterText> {
|
||||
let amount = |n: u32| Some(n.to_string());
|
||||
if damage > 0 {
|
||||
Some(if resist > 0 {
|
||||
("RESIST", amount(damage), Some(resist.to_string()))
|
||||
} else if absorb != 0 {
|
||||
("ABSORB", amount(damage), amount(absorb))
|
||||
} else {
|
||||
("DAMAGE", amount(damage), None)
|
||||
})
|
||||
} else if absorb != 0 {
|
||||
Some(("SPELL_ABSORBED", None, None))
|
||||
} else if resist != 0 {
|
||||
Some(("SPELL_RESISTED", None, None))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Melee packet to the centre text's messageType and args (`0x629d30`), fired at packet receive,
|
||||
/// not at the impact keyframe. A landed hit with a partial block, absorb or resist fires that
|
||||
/// word with `(damage, partial)`, tested ahead of the crit.
|
||||
/// not at the impact keyframe. A landed hit with a partial fires the first of resist
|
||||
/// (`0x629efd`), block (`0x629f15`) and absorb (`0x629f30`) with `(damage, partial)`, ahead of
|
||||
/// the crit.
|
||||
pub(crate) fn melee_center_text(
|
||||
hit_info: u32,
|
||||
victim_state: u32,
|
||||
@@ -110,7 +165,7 @@ pub(crate) fn melee_center_text(
|
||||
absorb: u32,
|
||||
resist: i32,
|
||||
blocked: u32,
|
||||
) -> Option<(&'static str, Option<String>, Option<String>)> {
|
||||
) -> Option<CenterText> {
|
||||
match victim_state {
|
||||
2 => Some(("DODGE", None, None)),
|
||||
3 => Some(("PARRY", None, None)),
|
||||
@@ -120,12 +175,12 @@ pub(crate) fn melee_center_text(
|
||||
8 => Some(("DEFLECT", None, None)),
|
||||
_ => {
|
||||
if damage > 0 {
|
||||
if absorb > 0 {
|
||||
Some(("ABSORB", Some(damage.to_string()), Some(absorb.to_string())))
|
||||
if resist > 0 {
|
||||
Some(("RESIST", Some(damage.to_string()), Some(resist.to_string())))
|
||||
} else if blocked > 0 {
|
||||
Some(("BLOCK", Some(damage.to_string()), Some(blocked.to_string())))
|
||||
} else if resist > 0 {
|
||||
Some(("RESIST", Some(damage.to_string()), Some(resist.to_string())))
|
||||
} else if absorb > 0 {
|
||||
Some(("ABSORB", Some(damage.to_string()), Some(absorb.to_string())))
|
||||
} else if hit_info & 0x80 != 0 {
|
||||
Some(("DAMAGE_CRIT", Some(damage.to_string()), None))
|
||||
} else {
|
||||
@@ -163,6 +218,7 @@ pub(super) fn spell_damage_log(
|
||||
stores: &Query<&mut ObjectStore>,
|
||||
spells: Option<&crate::ui_action::Spells>,
|
||||
gates: DamageTextGates,
|
||||
log_periodic: bool,
|
||||
text: &mut MessageWriter<CombatTextSpawn>,
|
||||
feedback: &mut MessageWriter<UnitCombatFeedback>,
|
||||
center: &mut MessageWriter<CombatTextEvent>,
|
||||
@@ -194,11 +250,11 @@ pub(super) fn spell_damage_log(
|
||||
}
|
||||
// The center combat text: self recipient only.
|
||||
if self_guid.0 == Some(s.target) {
|
||||
if let Some((message_type, data)) = spell_center_text(s.damage, s.absorb, s.resist) {
|
||||
if let Some((message_type, data, extra)) = spell_damage_center_text(&s, log_periodic) {
|
||||
center.write(CombatTextEvent {
|
||||
message_type,
|
||||
data,
|
||||
extra: None,
|
||||
extra,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -248,7 +304,7 @@ pub(super) fn periodic_aura_log(
|
||||
),
|
||||
);
|
||||
}
|
||||
// Centre text, self recipient only: damage ticks as direct damage, heal ticks `PERIODIC_HEAL`
|
||||
// Centre text, self recipient only: damage ticks the periodic words, heal ticks `PERIODIC_HEAL`
|
||||
// (arg2 the caster's name, arg3 the amount), energize ticks the power-gain family.
|
||||
if self_guid.0 == Some(s.target) {
|
||||
for tick in &s.ticks {
|
||||
@@ -258,13 +314,13 @@ pub(super) fn periodic_aura_log(
|
||||
absorb,
|
||||
resist,
|
||||
..
|
||||
} => spell_center_text(amount, absorb, resist).map(|(message_type, data)| {
|
||||
CombatTextEvent {
|
||||
} => periodic_center_text(amount, absorb, resist).map(
|
||||
|(message_type, data, extra)| CombatTextEvent {
|
||||
message_type,
|
||||
data,
|
||||
extra: None,
|
||||
}
|
||||
}),
|
||||
extra,
|
||||
},
|
||||
),
|
||||
PeriodicTick::Heal { amount } => Some(CombatTextEvent {
|
||||
message_type: "PERIODIC_HEAL",
|
||||
data: Some(
|
||||
@@ -449,11 +505,13 @@ pub(super) fn spell_energize_log(
|
||||
}
|
||||
}
|
||||
|
||||
/// Miss code (vmangos `SpellMissInfo`, 1 to 11) to the centre text's `SPELL_*` outcome word. The
|
||||
/// reference's miss-word emitter `0x62bab0` has no `SPELL_ABSORBED`; code 10's word is untraced.
|
||||
/// Miss code (vmangos `SpellMissInfo`) to the centre text's `SPELL_*` outcome word, the jump table
|
||||
/// `0x62be10` indexed by code - 2 (`0x62bb50`). Code 10 (absorb) has no word of its own: its slot
|
||||
/// and every code past the table take the default `SPELL_MISSED` (`0x62bdc3`). Code 0 fires
|
||||
/// nothing (`0x62babb`).
|
||||
fn miss_center_type(code: u8) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
1 => "SPELL_MISSED",
|
||||
0 => return None,
|
||||
2 => "SPELL_RESISTED",
|
||||
3 => "SPELL_DODGED",
|
||||
4 => "SPELL_PARRIED",
|
||||
@@ -461,9 +519,8 @@ fn miss_center_type(code: u8) -> Option<&'static str> {
|
||||
6 => "SPELL_EVADED",
|
||||
7 | 8 => "SPELL_IMMUNE",
|
||||
9 => "SPELL_DEFLECTED",
|
||||
10 => "SPELL_ABSORBED",
|
||||
11 => "SPELL_REFLECTED",
|
||||
_ => return None,
|
||||
_ => "SPELL_MISSED",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -628,6 +685,175 @@ mod tests {
|
||||
use crate::net::{Guid, SelfPlayer};
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
fn words(
|
||||
message_type: &'static str,
|
||||
data: Option<u32>,
|
||||
extra: Option<u32>,
|
||||
) -> Option<CenterText> {
|
||||
Some((
|
||||
message_type,
|
||||
data.map(|d| d.to_string()),
|
||||
extra.map(|e| e.to_string()),
|
||||
))
|
||||
}
|
||||
|
||||
/// A landed melee hit with several partials takes the first of resist, block and absorb
|
||||
/// (`0x629efd`, `0x629f15`, `0x629f30`).
|
||||
#[test]
|
||||
fn melee_partials_are_tested_resist_block_absorb() {
|
||||
// (hit_info, victim_state, damage, absorb, resist, blocked)
|
||||
assert_eq!(
|
||||
melee_center_text(0, 1, 300, 50, 0, 40),
|
||||
words("BLOCK", Some(300), Some(40))
|
||||
);
|
||||
assert_eq!(
|
||||
melee_center_text(0, 1, 300, 50, 20, 0),
|
||||
words("RESIST", Some(300), Some(20))
|
||||
);
|
||||
assert_eq!(
|
||||
melee_center_text(0x80, 1, 300, 50, 20, 40),
|
||||
words("RESIST", Some(300), Some(20))
|
||||
);
|
||||
assert_eq!(
|
||||
melee_center_text(0x80, 1, 300, 50, 0, 0),
|
||||
words("ABSORB", Some(300), Some(50))
|
||||
);
|
||||
assert_eq!(
|
||||
melee_center_text(0x80, 1, 300, 0, 0, 0),
|
||||
words("DAMAGE_CRIT", Some(300), None)
|
||||
);
|
||||
}
|
||||
|
||||
fn spell_hit(damage: u32, absorb: u32, resist: i32, blocked: u32) -> SpellDamageLog {
|
||||
SpellDamageLog {
|
||||
target: 10,
|
||||
attacker: 20,
|
||||
spell_id: 133,
|
||||
damage,
|
||||
school: 2,
|
||||
absorb,
|
||||
resist,
|
||||
periodic: false,
|
||||
blocked,
|
||||
hit_info: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A direct spell hit on you with partials takes the first of resisted, blocked and absorbed
|
||||
/// (`0x62d048`, `0x62d062`, `0x62d07b`), crit or not; blocked carries its amount alone.
|
||||
#[test]
|
||||
fn a_direct_spell_hit_words_its_first_partial() {
|
||||
let direct = |damage, absorb, resist, blocked| {
|
||||
spell_damage_center_text(&spell_hit(damage, absorb, resist, blocked), true)
|
||||
};
|
||||
assert_eq!(
|
||||
direct(300, 50, 100, 40),
|
||||
words("SPELL_RESISTED", Some(300), Some(100))
|
||||
);
|
||||
assert_eq!(
|
||||
direct(300, 50, 0, 40),
|
||||
words("SPELL_BLOCKED", Some(40), None)
|
||||
);
|
||||
assert_eq!(
|
||||
direct(300, 50, 0, 0),
|
||||
words("SPELL_ABSORBED", Some(300), Some(50))
|
||||
);
|
||||
assert_eq!(direct(300, 0, 0, 0), words("SPELL_DAMAGE", Some(300), None));
|
||||
// A crit has no word of its own.
|
||||
let crit = SpellDamageLog {
|
||||
hit_info: 0x2,
|
||||
..spell_hit(300, 0, 0, 0)
|
||||
};
|
||||
assert_eq!(
|
||||
spell_damage_center_text(&crit, true),
|
||||
words("SPELL_DAMAGE", Some(300), None)
|
||||
);
|
||||
// Nothing through: absorbed, then blocked, then resisted, bare.
|
||||
assert_eq!(direct(0, 50, 100, 40), words("SPELL_ABSORBED", None, None));
|
||||
assert_eq!(direct(0, 0, 100, 40), words("SPELL_BLOCKED", None, None));
|
||||
assert_eq!(direct(0, 0, 100, 0), words("SPELL_RESISTED", None, None));
|
||||
assert_eq!(direct(0, 0, 0, 0), None);
|
||||
}
|
||||
|
||||
/// A periodic tick takes the melee words, `RESIST` before `ABSORB` before `DAMAGE`
|
||||
/// (`0x628354`, `0x62836c`, `0x62837f`); nothing through keeps the `SPELL_*` words.
|
||||
#[test]
|
||||
fn a_periodic_tick_words_resist_absorb_damage() {
|
||||
assert_eq!(
|
||||
periodic_center_text(300, 50, 100),
|
||||
words("RESIST", Some(300), Some(100))
|
||||
);
|
||||
assert_eq!(
|
||||
periodic_center_text(300, 50, 0),
|
||||
words("ABSORB", Some(300), Some(50))
|
||||
);
|
||||
assert_eq!(
|
||||
periodic_center_text(300, 0, 0),
|
||||
words("DAMAGE", Some(300), None)
|
||||
);
|
||||
assert_eq!(
|
||||
periodic_center_text(0, 50, 100),
|
||||
words("SPELL_ABSORBED", None, None)
|
||||
);
|
||||
assert_eq!(
|
||||
periodic_center_text(0, 0, 100),
|
||||
words("SPELL_RESISTED", None, None)
|
||||
);
|
||||
assert_eq!(periodic_center_text(0, 0, 0), None);
|
||||
}
|
||||
|
||||
/// `SMSG_SPELLNONMELEEDAMAGELOG` routes as `0x5e85e0`: split damage first (`0x5e8700`), then a
|
||||
/// periodic log to the tick's words behind `CombatLogPeriodicSpells` (`0x62d9ae`), which
|
||||
/// ignore the blocked amount.
|
||||
#[test]
|
||||
fn a_spell_damage_log_routes_split_and_periodic_first() {
|
||||
let split = SpellDamageLog {
|
||||
hit_info: 0x8,
|
||||
periodic: true,
|
||||
..spell_hit(120, 0, 30, 0)
|
||||
};
|
||||
assert_eq!(
|
||||
spell_damage_center_text(&split, true),
|
||||
words("SPLIT_DAMAGE", Some(120), None)
|
||||
);
|
||||
let periodic = SpellDamageLog {
|
||||
periodic: true,
|
||||
..spell_hit(300, 50, 0, 40)
|
||||
};
|
||||
assert_eq!(
|
||||
spell_damage_center_text(&periodic, true),
|
||||
words("ABSORB", Some(300), Some(50))
|
||||
);
|
||||
assert_eq!(spell_damage_center_text(&periodic, false), None);
|
||||
}
|
||||
|
||||
/// The spell-miss word per code, as the jump table `0x62be10`: absorb (10) and every code past
|
||||
/// the table are `SPELL_MISSED`, and 0 is nothing.
|
||||
#[test]
|
||||
fn spell_miss_codes_follow_the_reference_table() {
|
||||
let got: Vec<_> = (0..=13).map(miss_center_type).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
None,
|
||||
Some("SPELL_MISSED"),
|
||||
Some("SPELL_RESISTED"),
|
||||
Some("SPELL_DODGED"),
|
||||
Some("SPELL_PARRIED"),
|
||||
Some("SPELL_BLOCKED"),
|
||||
Some("SPELL_EVADED"),
|
||||
Some("SPELL_IMMUNE"),
|
||||
Some("SPELL_IMMUNE"),
|
||||
Some("SPELL_DEFLECTED"),
|
||||
Some("SPELL_MISSED"),
|
||||
Some("SPELL_REFLECTED"),
|
||||
Some("SPELL_MISSED"),
|
||||
Some("SPELL_MISSED"),
|
||||
]
|
||||
);
|
||||
assert_eq!(miss_center_type(255), Some("SPELL_MISSED"));
|
||||
}
|
||||
|
||||
/// `SMSG_SPELLLOGMISS`'s word is spell gold: `0x5e7f66` pushes the resolved spell record, not
|
||||
/// the melee site's NULL, and unlike the GO's inline emit there is no speed test here.
|
||||
#[test]
|
||||
|
||||
@@ -42,8 +42,8 @@ pub(crate) struct UnitCombatFeedback {
|
||||
|
||||
/// One `COMBAT_TEXT_UPDATE` (event `0x21E`, via `0x703f50`), fired at packet parse by every
|
||||
/// producer, melee included (`0x6255b0` → `0x629d30`); `data`/`extra` are `arg2`/`arg3`. Fired only
|
||||
/// for the player as recipient; the reference's emit shares the chat combat log's category scope,
|
||||
/// so it can fire for other participants, by a rule untraced.
|
||||
/// when the unit the line is about is the player: each emitter tests that unit's class (`0x5efea0`)
|
||||
/// for 0 before its helper (`0x629ef1`, `0x62d046`, `0x62834b`).
|
||||
#[derive(Message, Clone)]
|
||||
pub(crate) struct CombatTextEvent {
|
||||
pub(crate) message_type: &'static str,
|
||||
|
||||
Reference in New Issue
Block a user