diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 7190388a..383dee34 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -1,10 +1,10 @@ //! The aura feed: the `UNIT_FIELD_AURA` blocks of the player, the target, the target's target, the -//! pet and the party and their pets as the ordered [`AuraState`] lists the aura bindings read, with -//! the player's durations. The player's list keeps the reference cache's insertion order +//! pet and the party and raid and their pets as the ordered [`AuraState`] lists the aura bindings +//! read, with the player's durations. The player's list keeps the reference cache's insertion order //! (`0xbc6040`, repacked by `PlayerAuras_Update 0x4e4170`); any other unit's reads its descriptor //! by ascending slot, or, for a group member or pet with no live object, its roster record. -mod party; +mod group; use std::collections::HashMap; @@ -162,8 +162,8 @@ struct AuraFeedMemo { target_last: Option<(u64, Vec)>, pet_last: Option<(u64, Vec)>, tot_last: Option<(u64, Vec)>, - /// Each `partyN` and `partypetN` token's, keyed the same way. - party_last: HashMap<&'static str, Option<(u64, Vec)>>, + /// The `partyN`, `partypetN`, `raidN` and `raidpetN` lists and their pushes. + group: group::GroupAuras, } /// The `BENILLA_AURA_TRACE` period in seconds; a set value that is not a positive number means 1 s. @@ -272,34 +272,56 @@ fn unit_aura_state( } } +/// What one aura of a unit not the player is listed from: its slot, spell, stack count and +/// `AURAFLAGS` nibble. A list is a pure function of these and the spell catalog. +type AuraInput = (u8, u32, u8, u8); + +/// The list [`AuraInput`]s make. +fn aura_list(catalog: Option<&SpellCatalog>, inputs: &[AuraInput]) -> Vec { + inputs + .iter() + .map(|&(slot, spell, count, flags)| unit_aura_state(catalog, slot, spell, count, flags)) + .collect() +} + /// Another unit's auras as `UnitBuff 0x519500` and `UnitDebuff 0x5198f0` read them: ascending /// slot within each half, no durations, through the display filter. `buffs_visible` false drops /// the helpful half, the unit-level gate's nil at every index ([`buffs_visible_on`]). +fn other_unit_inputs<'a>( + store: &'a ObjectStore, + catalog: Option<&'a SpellCatalog>, + buffs_visible: bool, +) -> impl Iterator + 'a { + store + .0 + .unit_auras() + .filter(move |a| buffs_visible || a.slot >= UNIT_AURA_POSITIVE_SLOTS) + .filter(move |a| shown_in_aura_ui(catalog, a.spell_id)) + .map(|a| (a.slot, a.spell_id, a.stacks, a.flags)) +} + +/// [`other_unit_inputs`] as the list. fn other_unit_auras( store: &ObjectStore, catalog: Option<&SpellCatalog>, buffs_visible: bool, ) -> Vec { - store - .0 - .unit_auras() - .filter(|a| buffs_visible || a.slot >= UNIT_AURA_POSITIVE_SLOTS) - .filter(|a| shown_in_aura_ui(catalog, a.spell_id)) - .map(|a| unit_aura_state(catalog, a.slot, a.spell_id, a.stacks, a.flags)) - .collect() + let inputs: Vec = other_unit_inputs(store, catalog, buffs_visible).collect(); + aura_list(catalog, &inputs) } /// A roster record's aura block as the two bindings' roster walk reads it (`0x519763`-`0x5197d2`, /// `0x519b53`-`0x519bc5`): ascending slot, the display filter alone (`0x51979f`, `0x519b8f`), no /// unit gate and no `AURAFLAGS`, which the record does not hold. The stack count is always 1 /// (`0x51980f`, `0x519c2d`), and nothing is cancelable. -fn record_auras(slots: &[(u8, u16)], catalog: Option<&SpellCatalog>) -> Vec { +fn record_inputs<'a>( + slots: impl Iterator + 'a, + catalog: Option<&'a SpellCatalog>, +) -> impl Iterator + 'a { slots - .iter() - .map(|&(slot, spell)| (slot, u32::from(spell))) - .filter(|&(_, spell)| spell != 0 && shown_in_aura_ui(catalog, spell)) - .map(|(slot, spell)| unit_aura_state(catalog, slot, spell, 1, 0)) - .collect() + .map(|(slot, spell)| (slot, u32::from(spell))) + .filter(move |&(_, spell)| spell != 0 && shown_in_aura_ui(catalog, spell)) + .map(|(slot, spell)| (slot, spell, 1, 0)) } /// `UnitBuff 0x519500`'s unit-level gate, before any slot: a unit's buffs are listed only if it @@ -515,18 +537,18 @@ fn feed_auras( } let held = |guid: u64| index.0.get(&guid).and_then(|&e| stores.get(e).ok()); - // A live unit's rows behind `UnitBuff`'s unit gate. `unit` is never `store`, the player's, - // which is the gate's second argument. - let gated = |unit: &ObjectStore| { - let buffs = buffs_visible_on( + // `UnitBuff`'s unit gate on a live unit. `unit` is never `store`, the player's, which is the + // gate's second argument. + let buffs_visible = |unit: &ObjectStore| { + buffs_visible_on( unit, Some(store), factions.as_deref(), &reputations, |owner| held(owner).cloned(), - ); - other_unit_auras(unit, catalog, buffs) + ) }; + let gated = |unit: &ObjectStore| other_unit_auras(unit, catalog, buffs_visible(unit)); // The target's rows. A self-target mirrors the player list in cache order, where the // reference's `UnitBuff` reads by slot. @@ -567,9 +589,6 @@ fn feed_auras( gated(tot_store) }); - // `party1..4` and `partypet1..4`: the live unit's rows, else its roster record's. - let party_lists = party::party_aura_lists(group.as_deref(), held, gated, catalog); - let target_changed = aura_edge( &mut memo.target_last, selection.guid.zip(target_list.as_deref()), @@ -603,25 +622,25 @@ fn feed_auras( .flatten() .map(|l| (tot_guid, l)), ); - let party_changed: Vec<&'static str> = party_lists - .iter() - .filter(|(token, cur)| { - aura_edge( - memo.party_last.entry(token).or_default(), - cur.as_ref().map(|(guid, l)| (*guid, l.as_slice())), - ) - }) - .map(|(token, _)| *token) - .collect(); - + // `party1..4`, `partypet1..4`, `raid1..40` and `raidpet1..40`: the live unit's rows, else its + // roster record's, each pushed only as it moves. + let catalog_moved = spells.as_ref().is_some_and(|s| s.is_changed()); + let group_changed = memo.group.feed( + &mut script, + group::Inputs { + group: group.as_deref(), + me: (self_guid.0, &list, changed), + held, + buffs_visible, + catalog, + catalog_moved, + }, + ); script.set_auras("player", Some(list)); // Clearing a token fires nothing: the frames react to `PLAYER_TARGET_CHANGED` and `UNIT_PET`. script.set_auras("target", target_list); script.set_auras("pet", pet_list); script.set_auras("targettarget", tot_list); - for (token, cur) in party_lists { - script.set_auras(token, cur.map(|(_, l)| l)); - } script.set_tracking(tracking); if changed { script.fire_event("UNIT_AURA", vec![ScriptValue::Str("player".into())]); @@ -640,7 +659,7 @@ fn feed_auras( } // The reference fires these from the record's aura bits (`0x5e57d8`, the pet's `0x5e58f1`) // and from the live descriptor's aura fields, to every token naming the guid (`0x515e50`). - for token in party_changed { + for token in group_changed { script.fire_event("UNIT_AURA", vec![ScriptValue::Str(token.into())]); } } @@ -678,7 +697,7 @@ fn end_session_aura_state( script.set_auras("player", None); script.set_auras("target", None); script.set_auras("targettarget", None); - for token in party::PARTY_AURA_TOKENS { + for token in group::tokens() { script.set_auras(token, None); } script.set_tracking(None); diff --git a/crates/benilla-app/src/ui_aura/group.rs b/crates/benilla-app/src/ui_aura/group.rs new file mode 100644 index 00000000..e51c2636 --- /dev/null +++ b/crates/benilla-app/src/ui_aura/group.rs @@ -0,0 +1,775 @@ +//! The group tokens' aura lists: `partyN`, `partypetN`, `raidN` and `raidpetN`. `UnitBuff +//! 0x519500` and `UnitDebuff 0x5198f0` read a token's live object, gated as any unit's; a guid +//! with no live object diverts to the roster record's aura block instead (`0x519592`-`0x5195a4` +//! to `0x519735`, `0x519b25`). A list is built once per guid and shared by every token naming it, +//! and rebuilt only when what it is built from moves: a 40-man raid names up to 80 units. + +use std::collections::hash_map::{Entry, HashMap}; + +use benilla_formats::SpellCatalog; +use benilla_ui::script::{AuraState, UiScript}; + +use super::{aura_list, other_unit_inputs, record_inputs, AuraInput}; +use crate::net::ObjectStore; +use crate::ui_party::{raid_row_guids, GroupState, RAID_TOKENS}; + +/// `partyN` then `partypetN`. +const PARTY_AURA_TOKENS: [&str; 8] = [ + "party1", + "party2", + "party3", + "party4", + "partypet1", + "partypet2", + "partypet3", + "partypet4", +]; + +/// `raidpet1..raidpet40`, one per raid row. +#[rustfmt::skip] +const RAID_PET_TOKENS: [&str; 40] = [ + "raidpet1", "raidpet2", "raidpet3", "raidpet4", "raidpet5", "raidpet6", "raidpet7", + "raidpet8", "raidpet9", "raidpet10", "raidpet11", "raidpet12", "raidpet13", "raidpet14", + "raidpet15", "raidpet16", "raidpet17", "raidpet18", "raidpet19", "raidpet20", "raidpet21", + "raidpet22", "raidpet23", "raidpet24", "raidpet25", "raidpet26", "raidpet27", "raidpet28", + "raidpet29", "raidpet30", "raidpet31", "raidpet32", "raidpet33", "raidpet34", "raidpet35", + "raidpet36", "raidpet37", "raidpet38", "raidpet39", "raidpet40", +]; + +const TOKEN_COUNT: usize = PARTY_AURA_TOKENS.len() + RAID_TOKENS.len() + RAID_PET_TOKENS.len(); + +/// Every token this feeds, in [`token_guids`]' order. +pub(super) fn tokens() -> impl Iterator { + PARTY_AURA_TOKENS + .into_iter() + .chain(RAID_TOKENS) + .chain(RAID_PET_TOKENS) +} + +/// The guid each of [`tokens`] names, `None` for nobody. `party1..4` are the own subgroup's slots +/// and `partypetN` their pets (`0x4e81d0`); `raidN` is `GetRaidRosterInfo` row N, as every other +/// raid-token reader resolves it, and `raidpetN` that row's pet (`0x491960`). `held` is the object +/// manager's lookup (`0x468460`). +fn token_guids<'a>( + group: &GroupState, + me: u64, + held: impl Fn(u64) -> Option<&'a ObjectStore>, +) -> Vec> { + let live = |g: u64| held(g).map(|s| &s.0); + let members: Vec = group.party_slots().map(|m| m.guid).collect(); + let rows = raid_row_guids(group, Some(me)); + let mut out = Vec::with_capacity(TOKEN_COUNT); + out.extend((0..4).map(|i| members.get(i).copied())); + out.extend((0..4).map(|i| { + members + .get(i) + .and_then(|&m| group.party_pet_guid(m, live(m))) + })); + out.extend((0..RAID_TOKENS.len()).map(|i| rows.get(i).copied())); + out.extend( + (0..RAID_PET_TOKENS.len()) + .map(|i| rows.get(i).and_then(|&m| group.raid_pet_guid(m, live(m)))), + ); + out +} + +/// What [`GroupAuras::feed`] reads this frame. +pub(super) struct Inputs<'g, 'a, H, V> { + pub(super) group: Option<&'g GroupState>, + /// Our guid, the player's list, and whether that list moved this frame: a raid row naming us + /// mirrors the player's list, as a self-target does. + pub(super) me: (u64, &'g [AuraState], bool), + /// The object manager's lookup (`0x468460`). + pub(super) held: H, + /// `UnitBuff`'s unit gate on a live unit. + pub(super) buffs_visible: V, + pub(super) catalog: Option<&'a SpellCatalog>, + /// The spell catalog was replaced, so every list is rebuilt. + pub(super) catalog_moved: bool, +} + +/// One guid's list, shared by every token naming it. +struct GuidList { + /// What the list was built from; the list is rebuilt only when these move. + inputs: Vec, + list: Vec, + /// Taken from [`GroupAuras::versions`] each time the list changes. + version: u64, + /// The frame that last read it; an entry no token read this frame is dropped. + frame: u64, +} + +/// The group tokens' feed state for one VM. +#[derive(Default)] +pub(super) struct GroupAuras { + /// Each token's last push, by [`tokens`] index: the guid and that guid's list version. + pushed: Vec>, + by_guid: HashMap, + /// The last version handed out; every list change takes the next, so none repeats. + versions: u64, + /// The player's list version, for a raid row naming us. + player_version: u64, + catalog_present: bool, + frame: u64, + /// The inputs being read, kept to save the allocation. + scratch: Vec, +} + +impl GroupAuras { + /// Push each token whose unit or list moved, clear each that stopped naming anyone, and + /// return the tokens `UNIT_AURA` fires for: every token naming a unit whose list moved + /// (`0x515e50`), or that names a new unit. Clearing a token fires nothing. + pub(super) fn feed<'a, H, V>( + &mut self, + script: &mut UiScript, + inputs: Inputs<'_, 'a, H, V>, + ) -> Vec<&'static str> + where + H: Fn(u64) -> Option<&'a ObjectStore>, + V: Fn(&ObjectStore) -> bool, + { + let Inputs { + group, + me: (me, player_list, player_moved), + held, + buffs_visible, + catalog, + catalog_moved, + } = inputs; + self.frame += 1; + if catalog_moved || catalog.is_some() != self.catalog_present { + self.catalog_present = catalog.is_some(); + self.by_guid.clear(); + } + if player_moved || self.player_version == 0 { + self.versions += 1; + self.player_version = self.versions; + } + let guids = match group { + Some(group) => token_guids(group, me, &held), + None => vec![None; TOKEN_COUNT], + }; + self.pushed.resize(TOKEN_COUNT, None); + let mut fire = Vec::new(); + for (i, (token, guid)) in tokens().zip(guids).enumerate() { + let cur = guid.map(|g| { + let version = if g == me { + // Every frame, for the durations the player's list carries. + script.set_auras(token, Some(player_list.to_vec())); + self.player_version + } else { + self.refresh(g, group, &held, &buffs_visible, catalog) + }; + (g, version) + }); + if cur == self.pushed[i] { + continue; + } + if let Some((g, _)) = cur.filter(|&(g, _)| g != me) { + script.set_auras(token, Some(self.by_guid[&g].list.clone())); + } else if cur.is_none() { + script.set_auras(token, None); + } + if cur.is_some() { + fire.push(token); + } + self.pushed[i] = cur; + } + let frame = self.frame; + self.by_guid.retain(|_, e| e.frame == frame); + fire + } + + /// `guid`'s list version this frame, rebuilding the list if what it is built from moved: the + /// live unit's gated slots, else the roster record's block. + fn refresh<'a>( + &mut self, + guid: u64, + group: Option<&GroupState>, + held: &impl Fn(u64) -> Option<&'a ObjectStore>, + buffs_visible: &impl Fn(&ObjectStore) -> bool, + catalog: Option<&SpellCatalog>, + ) -> u64 { + if let Some(e) = self.by_guid.get(&guid).filter(|e| e.frame == self.frame) { + return e.version; + } + self.scratch.clear(); + match held(guid) { + Some(store) => { + self.scratch + .extend(other_unit_inputs(store, catalog, buffs_visible(store))) + } + None => { + if let Some(group) = group { + self.scratch + .extend(record_inputs(group.roster_aura_slots(guid), catalog)); + } + } + } + match self.by_guid.entry(guid) { + Entry::Occupied(mut e) => { + let e = e.get_mut(); + e.frame = self.frame; + if e.inputs != self.scratch { + std::mem::swap(&mut e.inputs, &mut self.scratch); + let list = aura_list(catalog, &e.inputs); + if list != e.list { + e.list = list; + self.versions += 1; + e.version = self.versions; + } + } + e.version + } + Entry::Vacant(e) => { + self.versions += 1; + let inputs = self.scratch.clone(); + let list = aura_list(catalog, &inputs); + e.insert(GuidList { + inputs, + list, + version: self.versions, + frame: self.frame, + }) + .version + } + } + } +} + +#[cfg(test)] +mod tests { + use benilla_protocol::messages::{ + member_status, GroupMemberEntry, ObjectFields, PartyMemberStatsInfo, + }; + use benilla_ui::script::UiScript; + use bevy::prelude::*; + + use crate::char_select::ClientState; + use crate::net::{Guid, GuidIndex, NetCommands, ObjectStore, Reputations, SelfPlayer}; + use crate::ui_party::GroupState; + + const ME: u64 = 0x10; + const MEMBER: u64 = 0x1234; + const PET: u64 = 0xF140_0000_0000_0077; + const AURA: u16 = 47; + const AURAFLAGS: u16 = 95; + const SUMMON: u16 = 8; + /// `UNIT_FIELD_FLAGS`; bit 27 is `UNIT_FLAG_AURAS_VISIBLE`, which opens `UnitBuff`'s gate. + const FLAGS: u16 = 46; + + /// The feed on a bare app with a VM, us, and a party of one, `MEMBER`, with no record. + fn party_app() -> App { + let (tx, _) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin)) + .insert_state(ClientState::InWorld) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(NetCommands(tx)) + .add_plugins(super::super::UiAuraPlugin); + app.insert_non_send_resource(UiScript::new().unwrap()); + app.world_mut() + .non_send_resource::() + .run( + r#" + SEEN = {} + local f = CreateFrame("Frame") + f:RegisterEvent("UNIT_AURA") + -- The group's tokens only: the player's list fires on the first frame too. + f:SetScript("OnEvent", function() + if arg1 ~= "player" then table.insert(SEEN, arg1) end + end) + "#, + ) + .unwrap(); + let me = app + .world_mut() + .spawn((ObjectStore(ObjectFields::default()), Guid(ME), SelfPlayer)) + .id(); + app.world_mut().resource_mut::().0.insert(ME, me); + let member = GroupMemberEntry { + name: "Brisca".into(), + guid: MEMBER, + status: member_status::ONLINE, + flags: 0, + }; + app.world_mut().resource_mut::().apply_list( + 0, + 0, + vec![member], + ME, + None, + Some(ME), + ); + app + } + + /// Stream `guid` in with these fields, as the object manager holds it. + fn stream(app: &mut App, guid: u64, fields: &[(u16, u32)]) { + let e = app + .world_mut() + .spawn(ObjectStore(ObjectFields::from_pairs(fields))) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(guid, e); + } + + fn stats(app: &mut App, full: bool, info: PartyMemberStatsInfo) { + stats_of(app, MEMBER, full, info); + } + + fn stats_of(app: &mut App, guid: u64, full: bool, info: PartyMemberStatsInfo) { + app.world_mut() + .resource_mut::() + .apply_stats(guid, full, info); + } + + /// The spell ids `UnitBuff` (or `UnitDebuff`) enumerates for `token`, index 1 up to the nil, + /// through `UnitAura`'s tenth return, as the icon needs a spell catalog. + fn listed(app: &mut App, token: &str, harmful: bool) -> Vec { + let (verb, filter) = if harmful { + ("UnitDebuff", "HARMFUL") + } else { + ("UnitBuff", "HELPFUL") + }; + app.world_mut() + .non_send_resource::() + .eval::>(&format!( + r#"local out = {{}} + for i = 1, 48 do + local _, count = {verb}("{token}", i) + if count == nil then break end + local _, _, _, _, _, _, _, _, _, id = UnitAura("{token}", i, "{filter}") + table.insert(out, id) + end + return out"# + )) + .unwrap() + } + + fn seen(app: &mut App) -> Vec { + app.world_mut() + .non_send_resource::() + .eval::>("local s = SEEN SEEN = {} return s") + .unwrap() + } + + /// A live member reads its own `UNIT_FIELD_AURA` slots; `UnitBuff` keeps the unit gate, open + /// here through `UNIT_FLAG_AURAS_VISIBLE` (`0x5195bc`), and a stale slot stays out. + #[test] + fn a_live_members_auras_come_off_its_descriptor() { + let mut app = party_app(); + stream( + &mut app, + MEMBER, + &[ + (FLAGS, 0x0800_0000), + (AURA, 1126), + (AURA + 1, 6673), // stale: nibble 0x1 has no effect bit + (AURA + 33, 589), + (AURA + 34, 11976), + (AURAFLAGS, 0x0000_0018), + (AURAFLAGS + 4, 0x0000_0880), + ], + ); + // A record that disagrees, to show the live object wins. + stats( + &mut app, + true, + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + auras_negative: Some(vec![(40, 770)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "party1", true), [589, 11976]); + assert_eq!(listed(&mut app, "party1", false), [1126]); + assert!( + listed(&mut app, "party2", true).is_empty(), + "no second member" + ); + assert_eq!(seen(&mut app), ["party1"]); + } + + /// With no live object, both bindings walk the record's block (`0x519735`, `0x519b25`), with + /// no unit gate, a stack count of 1 (`0x51980f`), and nil for an offline record (`0x519741`). + #[test] + fn an_out_of_range_member_reads_the_stats_aura_block() { + let mut app = party_app(); + stats( + &mut app, + true, + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + auras: Some(vec![(0, 1126), (3, 21562)]), + auras_negative: Some(vec![(33, 589)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!( + listed(&mut app, "party1", false), + [1126, 21562], + "buffs, ungated: no factions, which would close the live gate" + ); + assert_eq!(listed(&mut app, "party1", true), [589]); + let count = app + .world_mut() + .non_send_resource::() + .eval::(r#"local _, count = UnitDebuff("party1", 1) return count"#) + .unwrap(); + assert_eq!(count, 1); + assert_eq!(seen(&mut app), ["party1"]); + + stats( + &mut app, + false, + PartyMemberStatsInfo { + status: Some(member_status::OFFLINE), + ..Default::default() + }, + ); + app.update(); + assert!(listed(&mut app, "party1", false).is_empty()); + assert!(listed(&mut app, "party1", true).is_empty()); + } + + /// The record is slot-indexed: deltas patch named slots, the halves split at slot 32, and each + /// walk is ascending slot whatever order the slots filled in. + #[test] + fn record_auras_list_in_slot_order_and_split_buffs_from_debuffs() { + let mut app = party_app(); + stats( + &mut app, + true, + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + auras: Some(vec![(5, 1126)]), + auras_negative: Some(vec![(40, 770)]), + ..Default::default() + }, + ); + app.update(); + let _ = seen(&mut app); + // A later aura in a lower slot, then a debuff, as two deltas. + stats( + &mut app, + false, + PartyMemberStatsInfo { + auras: Some(vec![(2, 21562)]), + ..Default::default() + }, + ); + stats( + &mut app, + false, + PartyMemberStatsInfo { + auras_negative: Some(vec![(34, 589)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "party1", false), [21562, 1126]); + assert_eq!(listed(&mut app, "party1", true), [589, 770]); + assert_eq!(seen(&mut app), ["party1"]); + + // Slot 5 falls off: the delta names it with a 0, and only it goes. + stats( + &mut app, + false, + PartyMemberStatsInfo { + auras: Some(vec![(5, 0)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "party1", false), [21562]); + assert_eq!(listed(&mut app, "party1", true), [589, 770]); + assert_eq!(seen(&mut app), ["party1"]); + + // A delta that moves no aura fires nothing. + stats( + &mut app, + false, + PartyMemberStatsInfo { + cur_hp: Some(7), + ..Default::default() + }, + ); + app.update(); + assert!(seen(&mut app).is_empty()); + } + + /// `partypetN` names the member's `CHARM`, else `SUMMON`, while the member is held, else the + /// record's pet guid (`0x4e81d0`); the pet's list is its own descriptor while held, else the + /// pet record's block (`0x496420`, `+0xe4`). + #[test] + fn a_party_pet_reads_its_descriptor_or_the_pet_block() { + let mut app = party_app(); + stats( + &mut app, + true, + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + pet_guid: Some(PET), + pet_auras: Some(vec![(1, 1126)]), + pet_auras_negative: Some(vec![(32, 770)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "partypet1", false), [1126]); + assert_eq!(listed(&mut app, "partypet1", true), [770]); + assert!(listed(&mut app, "partypet2", true).is_empty()); + assert_eq!(seen(&mut app), ["party1", "partypet1"]); + + // A pet-aura delta moves the pet's list and fires for its token alone. + stats( + &mut app, + false, + PartyMemberStatsInfo { + pet_auras_negative: Some(vec![(32, 0), (33, 589)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "partypet1", true), [589]); + assert_eq!(seen(&mut app), ["partypet1"]); + + // Member and pet in view: the pet guid comes off the member's SUMMON, the list off the + // pet's own slots. + stream( + &mut app, + MEMBER, + &[(SUMMON, PET as u32), (SUMMON + 1, (PET >> 32) as u32)], + ); + stream( + &mut app, + PET, + &[(AURA + 35, 11976), (AURAFLAGS + 4, 0x0000_2000)], + ); + app.update(); + assert_eq!(listed(&mut app, "partypet1", true), [11976]); + assert!(listed(&mut app, "partypet1", false).is_empty()); + assert!(seen(&mut app).contains(&"partypet1".to_string())); + } + + // == The raid tokens == + + /// In our subgroup, so `party1` and `raid2`. + const MATE: u64 = 0x2001; + /// Subgroup 2, `raid5` only. + const FAR: u64 = 0x2004; + const FAR_PET: u64 = 0xF140_0000_0000_0099; + + /// A raid of five: us (row 1, subgroup 1), `MATE` (subgroup 1), two in subgroup 2 and `FAR` + /// in subgroup 3, in wire order, none streamed and none with a record. + fn raid_app() -> App { + let mut app = party_app(); + let member = |name: &str, guid, subgroup| GroupMemberEntry { + name: name.into(), + guid, + status: member_status::ONLINE, + flags: subgroup, + }; + app.world_mut().resource_mut::().apply_list( + crate::ui_party::GROUPTYPE_RAID, + 0, + vec![ + member("Mate", MATE, 0), + member("Two", 0x2002, 1), + member("Three", 0x2003, 1), + member("Far", FAR, 2), + ], + ME, + None, + Some(ME), + ); + app.update(); + let _ = seen(&mut app); + app + } + + fn online(auras_negative: Vec<(u8, u16)>) -> PartyMemberStatsInfo { + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + auras_negative: Some(auras_negative), + ..Default::default() + } + } + + /// A raid member in view reads its own `UNIT_FIELD_AURA` slots under `raidN`, whatever its + /// subgroup, and its buffs keep the unit gate. + #[test] + fn a_live_raid_members_auras_come_off_its_descriptor() { + let mut app = raid_app(); + stream( + &mut app, + FAR, + &[ + (AURA, 1126), + (AURA + 33, 589), + (AURAFLAGS, 0x0000_0008), + (AURAFLAGS + 4, 0x0000_0080), + ], + ); + app.update(); + assert_eq!(listed(&mut app, "raid5", true), [589]); + assert!( + listed(&mut app, "raid5", false).is_empty(), + "no factions, so `CanAssist` fails and the gate drops the buffs" + ); + assert_eq!(seen(&mut app), ["raid5"]); + for token in ["party1", "party2", "raid6"] { + assert!(listed(&mut app, token, true).is_empty(), "{token}"); + } + } + + /// Out of range, `raidN` walks the member's record, which vmangos keeps current for every raid + /// member we do not see; `raidpetN` walks the pet block the same record names. + #[test] + fn an_out_of_range_raid_member_and_its_pet_read_the_record() { + let mut app = raid_app(); + stats_of( + &mut app, + FAR, + true, + PartyMemberStatsInfo { + pet_guid: Some(FAR_PET), + pet_auras: Some(vec![(1, 1126)]), + pet_auras_negative: Some(vec![(33, 770)]), + ..online(vec![(32, 589), (35, 11976)]) + }, + ); + app.update(); + assert_eq!(listed(&mut app, "raid5", true), [589, 11976]); + let count = app + .world_mut() + .non_send_resource::() + .eval::(r#"local _, count = UnitDebuff("raid5", 1) return count"#) + .unwrap(); + assert_eq!(count, 1); + assert_eq!(listed(&mut app, "raidpet5", false), [1126]); + assert_eq!(listed(&mut app, "raidpet5", true), [770]); + assert_eq!(seen(&mut app), ["raid5", "raidpet5"]); + assert!( + listed(&mut app, "raidpet4", true).is_empty(), + "a petless member's raidpet token names nobody" + ); + } + + /// `UNIT_AURA` fires for every token naming a unit whose list moved (`0x515e50`), and for + /// nothing else; a delta that moves no aura fires nothing. + #[test] + fn unit_aura_fires_for_each_token_naming_the_member_whose_list_moved() { + let mut app = raid_app(); + stats_of(&mut app, MATE, true, online(vec![(32, 589)])); + stats_of(&mut app, FAR, true, online(vec![(32, 770)])); + app.update(); + let mut first = seen(&mut app); + first.sort(); + assert_eq!(first, ["party1", "raid2", "raid5"]); + + stats_of(&mut app, MATE, false, online(vec![(33, 11976)])); + app.update(); + assert_eq!(seen(&mut app), ["party1", "raid2"]); + assert_eq!(listed(&mut app, "raid2", true), [589, 11976]); + assert_eq!(listed(&mut app, "party1", true), [589, 11976]); + + stats_of( + &mut app, + FAR, + false, + PartyMemberStatsInfo { + cur_hp: Some(7), + ..Default::default() + }, + ); + app.update(); + assert!(seen(&mut app).is_empty()); + } + + /// A raid token past the roster, a raid token outside a raid, and an offline member's pet + /// all answer nil. + #[test] + fn a_raid_token_naming_nobody_answers_nil() { + let mut app = party_app(); + stats(&mut app, true, online(vec![(32, 589)])); + app.update(); + assert_eq!(listed(&mut app, "party1", true), [589]); + for token in ["raid1", "raid2"] { + assert!( + listed(&mut app, token, true).is_empty(), + "a party is no raid: {token}" + ); + } + + let mut app = raid_app(); + stats_of( + &mut app, + FAR, + true, + PartyMemberStatsInfo { + status: Some(member_status::OFFLINE), + pet_guid: Some(FAR_PET), + pet_auras_negative: Some(vec![(33, 770)]), + auras_negative: Some(vec![(32, 589)]), + ..Default::default() + }, + ); + app.update(); + for token in ["raid5", "raidpet5", "raid6", "raid40", "raidpet40"] { + assert!(listed(&mut app, token, true).is_empty(), "{token}"); + } + } + + /// Row 1 is us: the player's own list, as `"player"` answers it. + #[test] + fn our_raid_row_mirrors_the_player_list() { + let mut app = raid_app(); + let me = app.world_mut().resource::().0[&ME]; + *app.world_mut().get_mut::(me).unwrap() = + ObjectStore(ObjectFields::from_pairs(&[ + (AURA + 32, 589), + (AURAFLAGS + 4, 0x0000_0008), + ])); + app.update(); + assert_eq!(listed(&mut app, "player", true), [589]); + assert_eq!(listed(&mut app, "raid1", true), [589]); + assert_eq!(seen(&mut app), ["raid1"]); + } + + /// A frame where nothing moved builds no list: the feed compares the inputs, not the lists. + #[test] + fn an_unchanged_raid_rebuilds_no_list() { + let mut app = raid_app(); + stats_of(&mut app, FAR, true, online(vec![(32, 589)])); + app.update(); + let versions = |app: &mut App| { + app.world_mut() + .resource_scope(|world, mut mem: Mut| { + let script = world.non_send_resource::(); + mem.vm.get(script).group.versions + }) + }; + let before = versions(&mut app); + for _ in 0..3 { + app.update(); + } + assert_eq!(versions(&mut app), before); + stats_of(&mut app, FAR, false, online(vec![(33, 770)])); + app.update(); + assert_eq!( + versions(&mut app), + before + 1, + "one list moved, one rebuilt" + ); + } +} diff --git a/crates/benilla-app/src/ui_aura/party.rs b/crates/benilla-app/src/ui_aura/party.rs deleted file mode 100644 index e1f4a054..00000000 --- a/crates/benilla-app/src/ui_aura/party.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! The `partyN` and `partypetN` aura lists. `UnitBuff 0x519500` and `UnitDebuff 0x5198f0` read a -//! token's live object, gated as any unit's; a guid with no live object diverts to the roster -//! record's aura block instead (`0x519592`-`0x5195a4` to `0x519735`, `0x519b25`). - -use benilla_formats::SpellCatalog; -use benilla_ui::script::AuraState; - -use crate::net::ObjectStore; -use crate::ui_party::GroupState; - -/// The tokens this feeds, `partyN` then `partypetN`. -pub(super) const PARTY_AURA_TOKENS: [&str; 8] = [ - "party1", - "party2", - "party3", - "party4", - "partypet1", - "partypet2", - "partypet3", - "partypet4", -]; - -/// One unit's guid and its list. -pub(super) type UnitAuras = (u64, Vec); - -/// Each of [`PARTY_AURA_TOKENS`] with the guid it names and that unit's list, `None` for a token -/// naming nobody. `held` is the object manager's lookup (`0x468460`); `live` lists a held unit. -pub(super) fn party_aura_lists<'a>( - group: Option<&GroupState>, - held: impl Fn(u64) -> Option<&'a ObjectStore>, - live: impl Fn(&ObjectStore) -> Vec, - catalog: Option<&SpellCatalog>, -) -> Vec<(&'static str, Option)> { - let Some(group) = group else { - return PARTY_AURA_TOKENS.iter().map(|t| (*t, None)).collect(); - }; - let members: Vec = group.party_slots().map(|m| m.guid).collect(); - let list_of = |guid: u64| match held(guid) { - Some(store) => live(store), - None => super::record_auras(&group.roster_aura_slots(guid), catalog), - }; - let (member_tokens, pet_tokens) = PARTY_AURA_TOKENS.split_at(4); - let mut out = Vec::with_capacity(PARTY_AURA_TOKENS.len()); - for (i, token) in member_tokens.iter().enumerate() { - let guid = members.get(i).copied(); - out.push((*token, guid.map(|g| (g, list_of(g))))); - } - for (i, token) in pet_tokens.iter().enumerate() { - let pet = members - .get(i) - .and_then(|&m| group.party_pet_guid(m, held(m).map(|s| &s.0))); - out.push((*token, pet.map(|g| (g, list_of(g))))); - } - out -} - -#[cfg(test)] -mod tests { - use benilla_protocol::messages::{ - member_status, GroupMemberEntry, ObjectFields, PartyMemberStatsInfo, - }; - use benilla_ui::script::UiScript; - use bevy::prelude::*; - - use crate::char_select::ClientState; - use crate::net::{Guid, GuidIndex, NetCommands, ObjectStore, Reputations, SelfPlayer}; - use crate::ui_party::GroupState; - - const ME: u64 = 0x10; - const MEMBER: u64 = 0x1234; - const PET: u64 = 0xF140_0000_0000_0077; - const AURA: u16 = 47; - const AURAFLAGS: u16 = 95; - const SUMMON: u16 = 8; - /// `UNIT_FIELD_FLAGS`; bit 27 is `UNIT_FLAG_AURAS_VISIBLE`, which opens `UnitBuff`'s gate. - const FLAGS: u16 = 46; - - /// The feed on a bare app with a VM, us, and a party of one, `MEMBER`, with no record. - fn party_app() -> App { - let (tx, _) = crossbeam_channel::unbounded(); - let mut app = App::new(); - app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin)) - .insert_state(ClientState::InWorld) - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .insert_resource(NetCommands(tx)) - .add_plugins(super::super::UiAuraPlugin); - app.insert_non_send_resource(UiScript::new().unwrap()); - app.world_mut() - .non_send_resource::() - .run( - r#" - SEEN = {} - local f = CreateFrame("Frame") - f:RegisterEvent("UNIT_AURA") - -- The party's tokens only: the player's list fires on the first frame too. - f:SetScript("OnEvent", function() - if string.find(arg1, "^party") then table.insert(SEEN, arg1) end - end) - "#, - ) - .unwrap(); - let me = app - .world_mut() - .spawn((ObjectStore(ObjectFields::default()), Guid(ME), SelfPlayer)) - .id(); - app.world_mut().resource_mut::().0.insert(ME, me); - let member = GroupMemberEntry { - name: "Brisca".into(), - guid: MEMBER, - status: member_status::ONLINE, - flags: 0, - }; - app.world_mut().resource_mut::().apply_list( - 0, - 0, - vec![member], - ME, - None, - Some(ME), - ); - app - } - - /// Stream `guid` in with these fields, as the object manager holds it. - fn stream(app: &mut App, guid: u64, fields: &[(u16, u32)]) { - let e = app - .world_mut() - .spawn(ObjectStore(ObjectFields::from_pairs(fields))) - .id(); - app.world_mut() - .resource_mut::() - .0 - .insert(guid, e); - } - - fn stats(app: &mut App, full: bool, info: PartyMemberStatsInfo) { - app.world_mut() - .resource_mut::() - .apply_stats(MEMBER, full, info); - } - - /// The spell ids `UnitBuff` (or `UnitDebuff`) enumerates for `token`, index 1 up to the nil, - /// through `UnitAura`'s tenth return, as the icon needs a spell catalog. - fn listed(app: &mut App, token: &str, harmful: bool) -> Vec { - let (verb, filter) = if harmful { - ("UnitDebuff", "HARMFUL") - } else { - ("UnitBuff", "HELPFUL") - }; - app.world_mut() - .non_send_resource::() - .eval::>(&format!( - r#"local out = {{}} - for i = 1, 48 do - local _, count = {verb}("{token}", i) - if count == nil then break end - local _, _, _, _, _, _, _, _, _, id = UnitAura("{token}", i, "{filter}") - table.insert(out, id) - end - return out"# - )) - .unwrap() - } - - fn seen(app: &mut App) -> Vec { - app.world_mut() - .non_send_resource::() - .eval::>("local s = SEEN SEEN = {} return s") - .unwrap() - } - - /// A live member reads its own `UNIT_FIELD_AURA` slots; `UnitBuff` keeps the unit gate, open - /// here through `UNIT_FLAG_AURAS_VISIBLE` (`0x5195bc`), and a stale slot stays out. - #[test] - fn a_live_members_auras_come_off_its_descriptor() { - let mut app = party_app(); - stream( - &mut app, - MEMBER, - &[ - (FLAGS, 0x0800_0000), - (AURA, 1126), - (AURA + 1, 6673), // stale: nibble 0x1 has no effect bit - (AURA + 33, 589), - (AURA + 34, 11976), - (AURAFLAGS, 0x0000_0018), - (AURAFLAGS + 4, 0x0000_0880), - ], - ); - // A record that disagrees, to show the live object wins. - stats( - &mut app, - true, - PartyMemberStatsInfo { - status: Some(member_status::ONLINE), - auras_negative: Some(vec![(40, 770)]), - ..Default::default() - }, - ); - app.update(); - assert_eq!(listed(&mut app, "party1", true), [589, 11976]); - assert_eq!(listed(&mut app, "party1", false), [1126]); - assert!( - listed(&mut app, "party2", true).is_empty(), - "no second member" - ); - assert_eq!(seen(&mut app), ["party1"]); - } - - /// With no live object, both bindings walk the record's block (`0x519735`, `0x519b25`), with - /// no unit gate, a stack count of 1 (`0x51980f`), and nil for an offline record (`0x519741`). - #[test] - fn an_out_of_range_member_reads_the_stats_aura_block() { - let mut app = party_app(); - stats( - &mut app, - true, - PartyMemberStatsInfo { - status: Some(member_status::ONLINE), - auras: Some(vec![(0, 1126), (3, 21562)]), - auras_negative: Some(vec![(33, 589)]), - ..Default::default() - }, - ); - app.update(); - assert_eq!( - listed(&mut app, "party1", false), - [1126, 21562], - "buffs, ungated: no factions, which would close the live gate" - ); - assert_eq!(listed(&mut app, "party1", true), [589]); - let count = app - .world_mut() - .non_send_resource::() - .eval::(r#"local _, count = UnitDebuff("party1", 1) return count"#) - .unwrap(); - assert_eq!(count, 1); - assert_eq!(seen(&mut app), ["party1"]); - - stats( - &mut app, - false, - PartyMemberStatsInfo { - status: Some(member_status::OFFLINE), - ..Default::default() - }, - ); - app.update(); - assert!(listed(&mut app, "party1", false).is_empty()); - assert!(listed(&mut app, "party1", true).is_empty()); - } - - /// The record is slot-indexed: deltas patch named slots, the halves split at slot 32, and each - /// walk is ascending slot whatever order the slots filled in. - #[test] - fn record_auras_list_in_slot_order_and_split_buffs_from_debuffs() { - let mut app = party_app(); - stats( - &mut app, - true, - PartyMemberStatsInfo { - status: Some(member_status::ONLINE), - auras: Some(vec![(5, 1126)]), - auras_negative: Some(vec![(40, 770)]), - ..Default::default() - }, - ); - app.update(); - let _ = seen(&mut app); - // A later aura in a lower slot, then a debuff, as two deltas. - stats( - &mut app, - false, - PartyMemberStatsInfo { - auras: Some(vec![(2, 21562)]), - ..Default::default() - }, - ); - stats( - &mut app, - false, - PartyMemberStatsInfo { - auras_negative: Some(vec![(34, 589)]), - ..Default::default() - }, - ); - app.update(); - assert_eq!(listed(&mut app, "party1", false), [21562, 1126]); - assert_eq!(listed(&mut app, "party1", true), [589, 770]); - assert_eq!(seen(&mut app), ["party1"]); - - // Slot 5 falls off: the delta names it with a 0, and only it goes. - stats( - &mut app, - false, - PartyMemberStatsInfo { - auras: Some(vec![(5, 0)]), - ..Default::default() - }, - ); - app.update(); - assert_eq!(listed(&mut app, "party1", false), [21562]); - assert_eq!(listed(&mut app, "party1", true), [589, 770]); - assert_eq!(seen(&mut app), ["party1"]); - - // A delta that moves no aura fires nothing. - stats( - &mut app, - false, - PartyMemberStatsInfo { - cur_hp: Some(7), - ..Default::default() - }, - ); - app.update(); - assert!(seen(&mut app).is_empty()); - } - - /// `partypetN` names the member's `CHARM`, else `SUMMON`, while the member is held, else the - /// record's pet guid (`0x4e81d0`); the pet's list is its own descriptor while held, else the - /// pet record's block (`0x496420`, `+0xe4`). - #[test] - fn a_party_pet_reads_its_descriptor_or_the_pet_block() { - let mut app = party_app(); - stats( - &mut app, - true, - PartyMemberStatsInfo { - status: Some(member_status::ONLINE), - pet_guid: Some(PET), - pet_auras: Some(vec![(1, 1126)]), - pet_auras_negative: Some(vec![(32, 770)]), - ..Default::default() - }, - ); - app.update(); - assert_eq!(listed(&mut app, "partypet1", false), [1126]); - assert_eq!(listed(&mut app, "partypet1", true), [770]); - assert!(listed(&mut app, "partypet2", true).is_empty()); - assert_eq!(seen(&mut app), ["party1", "partypet1"]); - - // A pet-aura delta moves the pet's list and fires for its token alone. - stats( - &mut app, - false, - PartyMemberStatsInfo { - pet_auras_negative: Some(vec![(32, 0), (33, 589)]), - ..Default::default() - }, - ); - app.update(); - assert_eq!(listed(&mut app, "partypet1", true), [589]); - assert_eq!(seen(&mut app), ["partypet1"]); - - // Member and pet in view: the pet guid comes off the member's SUMMON, the list off the - // pet's own slots. - stream( - &mut app, - MEMBER, - &[(SUMMON, PET as u32), (SUMMON + 1, (PET >> 32) as u32)], - ); - stream( - &mut app, - PET, - &[(AURA + 35, 11976), (AURAFLAGS + 4, 0x0000_2000)], - ); - app.update(); - assert_eq!(listed(&mut app, "partypet1", true), [11976]); - assert!(listed(&mut app, "partypet1", false).is_empty()); - assert!(seen(&mut app).contains(&"partypet1".to_string())); - } -} diff --git a/crates/benilla-app/src/ui_party/mod.rs b/crates/benilla-app/src/ui_party/mod.rs index 8d175ed4..f59bde22 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -18,8 +18,8 @@ mod feed; pub(crate) mod net; mod records; pub(crate) use feed::{ - raid_row_guid, synthetic_raid, synthetic_roster, GROUPTYPE_RAID, GROUP_MEMBER_SUBGROUP, - PARTY_TOKENS, RAID_TOKENS, + raid_row_guid, raid_row_guids, synthetic_raid, synthetic_roster, GROUPTYPE_RAID, + GROUP_MEMBER_SUBGROUP, PARTY_TOKENS, RAID_TOKENS, }; pub(crate) struct UiPartyPlugin; diff --git a/crates/benilla-app/src/ui_party/records.rs b/crates/benilla-app/src/ui_party/records.rs index a8f67ec5..25302c0e 100644 --- a/crates/benilla-app/src/ui_party/records.rs +++ b/crates/benilla-app/src/ui_party/records.rs @@ -1,5 +1,6 @@ //! The roster records' readers for a guid the object manager does not hold: the member record -//! (`0x496400`), the pet record (`0x496420`) and the party pet's guid (`0x4e81d0`). +//! (`0x496400`), the pet record (`0x496420`) and the party and raid pets' guids (`0x4e81d0`, +//! `0x491960`). use benilla_protocol::messages::{member_status, ObjectFields, PartyMemberStatsInfo}; @@ -12,7 +13,7 @@ impl GroupState { } /// The record whose pet block names `guid` (`0x496420`); a pet is found only while its owner's - /// record reads online (`0x4e8860`). + /// record reads online, party (`0x4e8860`) or raid (`0x4bb130`). fn pet_record(&self, guid: u64) -> Option<&PartyMemberStatsInfo> { self.stats .values() @@ -23,11 +24,27 @@ impl GroupState { /// member's descriptor while it is held (`0x4e8204`-`0x4e821a`), else the record's pet guid /// while the record reads online (`0x4e8227`-`0x4e8236`). pub(crate) fn party_pet_guid(&self, member: u64, live: Option<&ObjectFields>) -> Option { + self.pet_guid_of(member, live, true) + } + + /// `raidpetN`'s guid for the raid member `member` (`0x491960`): `CHARM`, else `SUMMON`, off the + /// member's descriptor while it is held (`0x491993`-`0x4919a9`), else the record's pet guid + /// with no online test (`0x4919ae`), where `partypetN` has one. + pub(crate) fn raid_pet_guid(&self, member: u64, live: Option<&ObjectFields>) -> Option { + self.pet_guid_of(member, live, false) + } + + fn pet_guid_of( + &self, + member: u64, + live: Option<&ObjectFields>, + online_only: bool, + ) -> Option { match live { Some(fields) => fields.unit_pet_guid(), None => self .member_record(member) - .filter(|r| online(r)) + .filter(|r| !online_only || online(r)) .and_then(|r| r.pet_guid) .filter(|&g| g != 0), } @@ -37,16 +54,17 @@ impl GroupState { /// (`0x519735`, `0x519b25`), ascending, buffs then debuffs: a member record's block (`+0x1a`) /// unless it reads offline, which answers nothing and never tries the pet records /// (`0x519741`); on a miss, the block of the pet record naming the guid (`+0xe4`, `0x519760`). - pub(crate) fn roster_aura_slots(&self, guid: u64) -> Vec<(u8, u16)> { - let (buffs, debuffs) = match self.member_record(guid) { - Some(r) if !online(r) => return Vec::new(), - Some(r) => (&r.auras, &r.auras_negative), - None => match self.pet_record(guid) { - Some(r) => (&r.pet_auras, &r.pet_auras_negative), - None => return Vec::new(), - }, + pub(crate) fn roster_aura_slots(&self, guid: u64) -> impl Iterator + '_ { + let blocks = match self.member_record(guid) { + Some(r) if !online(r) => None, + Some(r) => Some((&r.auras, &r.auras_negative)), + None => self + .pet_record(guid) + .map(|r| (&r.pet_auras, &r.pet_auras_negative)), }; - buffs.iter().chain(debuffs).flatten().copied().collect() + blocks + .into_iter() + .flat_map(|(buffs, debuffs)| buffs.iter().chain(debuffs).flatten().copied()) } } @@ -97,18 +115,19 @@ mod tests { #[test] fn a_member_record_answers_its_own_block_and_an_offline_one_answers_nothing() { let g = group_with(record(member_status::ONLINE)); + let slots = |g: &GroupState, guid| g.roster_aura_slots(guid).collect::>(); assert_eq!( - g.roster_aura_slots(MEMBER), + slots(&g, MEMBER), [(0, 1126), (4, 21562), (32, 589)], "buffs then debuffs, ascending slot" ); - assert_eq!(g.roster_aura_slots(PET), [(2, 1126), (40, 770)]); - assert!(g.roster_aura_slots(0x9999).is_empty(), "a miss everywhere"); + assert_eq!(slots(&g, PET), [(2, 1126), (40, 770)]); + assert!(slots(&g, 0x9999).is_empty(), "a miss everywhere"); let g = group_with(record(member_status::OFFLINE)); - assert!(g.roster_aura_slots(MEMBER).is_empty()); + assert!(slots(&g, MEMBER).is_empty()); assert!( - g.roster_aura_slots(PET).is_empty(), + slots(&g, PET).is_empty(), "an offline owner's pet is not found" ); } @@ -135,4 +154,25 @@ mod tests { let g = group_with(record(member_status::OFFLINE)); assert_eq!(g.party_pet_guid(MEMBER, None), None); } + + /// `raidpetN` reads the same fields as `partypetN`, but its record leg has no online test + /// (`0x4919ae`, against `partypetN`'s `0x4e8227`). + #[test] + fn the_raid_pet_guid_reads_the_record_whatever_its_online_bit() { + let g = group_with(record(member_status::ONLINE)); + assert_eq!(g.raid_pet_guid(MEMBER, None), Some(PET)); + let summon = ObjectFields::from_pairs(&[(8, 0x55), (9, 0)]); + assert_eq!(g.raid_pet_guid(MEMBER, Some(&summon)), Some(0x55)); + assert_eq!( + g.raid_pet_guid(MEMBER, Some(&ObjectFields::default())), + None + ); + + let g = group_with(record(member_status::OFFLINE)); + assert_eq!(g.raid_pet_guid(MEMBER, None), Some(PET)); + assert!( + g.roster_aura_slots(PET).next().is_none(), + "named, but the pet record is not found behind an offline owner" + ); + } }