diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 3ace46f4..4277318c 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -304,6 +304,9 @@ struct AuraFeedMemo { /// ClassicAPI's focus pair (`docs/ADDON_COMPAT.md`), keyed like the three above. focus_last: Option<(u64, Vec)>, focus_target_last: Option<(u64, Vec)>, + /// `party1`..`party4`, keyed like the rest: the guid makes a roster reshuffle re-fire even + /// where two members carry the same auras. + party_last: [Option<(u64, Vec)>; 4], } // ─── nampower v2's DEBUFF_ADDED_OTHER / DEBUFF_REMOVED_OTHER ────────────────────────────────── @@ -682,7 +685,9 @@ fn feed_auras( stores: Query<&ObjectStore>, spells: Option>, pet: Res, - index: Res, + // The guid index and the party roster, one parameter because bevy's `IntoSystem` tops out at + // sixteen: both are "who is who" lookups this body only reads. + (index, group): (Res, Res), // The `UnitBuff` gate's inputs, the same reaction the selection ring and nameplates use. factions: Option>, reputations: Res, @@ -961,6 +966,36 @@ fn feed_auras( focus_target_cur.is_some() && focus_target_cur != memo.focus_target_last; memo.focus_target_last = focus_target_cur; + // `party1`..`party4`. **The absence of these was a defect grom reported from play**: no buffs + // on party members in the party frames, because pfUI's group header reads + // `UnitBuff("party1", i)` (`api/unitframes.lua`) and this feed pushed six tokens, none of them + // a party one, so `nth_aura` found no list at all and every party buff row stayed empty. The + // stock party frames show buffs too (`PartyMemberFrame_UpdateAuras`), so nothing about this is + // pfUI's. + // + // Nothing new is computed per unit: `other_list` is the same builder the target, pet and focus + // tokens go through, with the same `buffs_visible_on` gate, so a party member shows exactly + // what a targeted party member shows. Four slots in roster order + // (`GroupState::party_slots`), which is the order the tokens themselves mean; an empty slot + // clears its token. + // + // **Not the raid tokens.** `raid1`..`raid40` would be ten times this work every frame — each + // list walks the unit's descriptor and re-runs the visibility gate — and the cost has not been + // measured, where the party's four are bounded and are what was reported. Left as its own + // piece of work with that measurement first; `ui_party::RAID_TOKENS` is the list. + let party_guids: Vec = group.party_slots().map(|m| m.guid).collect(); + let party_lists: Vec>> = crate::ui_party::PARTY_TOKENS + .iter() + .enumerate() + .map(|(i, _)| { + party_guids + .get(i) + .copied() + .filter(|g| *g != 0) + .and_then(other_list) + }) + .collect(); + script.set_auras("player", Some(list)); // Clearing a token fires nothing: the frames react to `PLAYER_TARGET_CHANGED` and `UNIT_PET`, // and the focus pair to `PLAYER_FOCUS_CHANGED`. @@ -969,6 +1004,26 @@ fn feed_auras( script.set_auras("targettarget", tot_list); script.set_auras("focus", focus_list); script.set_auras("focustarget", focus_target_list); + // The four party tokens, with their own edge keys: a party member's auras change on their + // own clock, so a `"target"` fire tells a party frame nothing. + let party_changed: Vec = party_lists + .iter() + .enumerate() + .map(|(i, list)| { + let cur = party_guids + .get(i) + .copied() + .filter(|g| *g != 0) + .zip(list.as_deref()) + .map(|(guid, l)| (guid, projection_of(l))); + let moved = cur != mem.vm.get(&script).party_last[i]; + mem.vm.get(&script).party_last[i] = cur; + moved + }) + .collect(); + for (token, list) in crate::ui_party::PARTY_TOKENS.iter().zip(party_lists) { + script.set_auras(token, list); + } script.set_tracking(tracking); if changed { script.fire_event("UNIT_AURA", vec![ScriptValue::Str("player".into())]); @@ -991,6 +1046,11 @@ fn feed_auras( if focus_target_changed { script.fire_event("UNIT_AURA", vec![ScriptValue::Str("focustarget".into())]); } + for (token, moved) in crate::ui_party::PARTY_TOKENS.iter().zip(&party_changed) { + if *moved { + script.fire_event("UNIT_AURA", vec![ScriptValue::Str((*token).to_string())]); + } + } // ── nampower v2's `DEBUFF_ADDED_OTHER` / `DEBUFF_REMOVED_OTHER` ────────────────────────── // @@ -1141,6 +1201,9 @@ fn end_session_aura_state( script.set_auras("targettarget", None); script.set_auras("focus", None); script.set_auras("focustarget", None); + for token in crate::ui_party::PARTY_TOKENS { + script.set_auras(token, None); + } script.set_tracking(None); } cache.auras.clear(); @@ -1180,6 +1243,8 @@ mod tests { .init_resource::() // A required system parameter, unlike `Factions`. .init_resource::() + // Likewise: the feed reads the party roster for the four party tokens' lists. + .init_resource::() .insert_resource(NetCommands(tx)) .add_plugins(UiAuraPlugin); app @@ -1462,6 +1527,104 @@ mod tests { ); } + /// The defect grom reported from play: **no buffs on party members in the party frames**. + /// + /// This feed pushed six tokens — player, target, pet, targettarget and ClassicAPI's focus pair + /// — and not one party token, so `UnitBuff("party1", i)` found no list at all and every party + /// buff row stayed empty, for pfUI's group header and for the stock + /// `PartyMemberFrame_UpdateAuras` alike. Nothing was wrong with `UnitBuff`; the state it reads + /// was never written. + #[test] + fn a_party_member_gets_its_own_aura_list_and_its_own_unit_aura_fire() { + const ME: u64 = 0x0000_0000_0000_0001; + const MATE: u64 = 0xF130_0000_9700_0009; + + let mut app = aura_app(); + let me = app + .world_mut() + .spawn(( + crate::net::SelfPlayer, + ObjectStore(benilla_protocol::ObjectFields::default()), + crate::net::Guid(ME), + )) + .id(); + // Auras visible, so the helpful half enumerates and the list is not empty by gate. + let mate = app + .world_mut() + .spawn(ridge_stalker_slots(UNIT_FLAG_AURAS_VISIBLE)) + .id(); + let mut index = app.world_mut().resource_mut::(); + index.0.insert(ME, me); + index.0.insert(MATE, mate); + + // One party member in slot 1: same subgroup as us, which is what `party_slots` filters on. + app.world_mut() + .resource_mut::() + .members = vec![benilla_protocol::messages::GroupMemberEntry { + name: "Mate".into(), + guid: MATE, + status: 1, + flags: 0, + }]; + + let script = UiScript::new().unwrap(); + script + .run( + r#" + SEEN = {} + local f = CreateFrame("Frame") + f:RegisterEvent("UNIT_AURA") + f:SetScript("OnEvent", function() table.insert(SEEN, arg1) end) + "#, + ) + .unwrap(); + app.insert_non_send_resource(script); + + app.update(); + + let mut script = app.world_mut().non_send_resource_mut::(); + // By spell id, as the focus test does: this app has no `Spells` catalog, so no aura + // resolves an icon and `UnitBuff`'s texture return would be nil either way. + let ids = |script: &mut UiScript, filter: &str| -> Vec { + script + .eval::>(&format!( + r#"local out = {{}} local i = 1 + while true do + local a = C_UnitAuras.GetAuraDataByIndex("party1", i, "{filter}") + if not a then break end + table.insert(out, a.spellId) i = i + 1 + end + return out"# + )) + .unwrap() + }; + assert_eq!( + (ids(&mut script, "HELPFUL"), ids(&mut script, "HARMFUL")), + (vec![5916], vec![22766]), + "party1 enumerates that member's own auras" + ); + // And the verb the party frames actually call, which is what was empty in play. By + // **arity**, not by the texture: `UnitBuff` answers `(texture, stacks, ...)` and the + // texture comes from the spell catalog, which this app has no `Spells` for — so the icon + // would be nil here whatever the list holds. Arity is the whole question anyway: nothing + // at all is what `nth_aura` returns for a token with no list, and nothing at all is what + // pfUI's `if texture then` and the stock `PartyMemberFrame_UpdateAuras` loop stop on. + assert!( + script.arity(r#"UnitBuff("party1", 1)"#).unwrap() > 0, + "UnitBuff answers for party1 instead of returning nothing" + ); + assert_eq!( + script.arity(r#"UnitBuff("party2", 1)"#).unwrap(), + 0, + "an empty slot still answers nothing" + ); + let seen = script.eval::>("return SEEN").unwrap(); + assert!( + seen.iter().any(|t| t == "party1"), + "the party frame hears only its own token: {seen:?}" + ); + } + /// ClassicAPI's focus (`docs/ADDON_COMPAT.md`): pfUI's focus frame draws debuff buttons and /// hears only `UNIT_AURA` with its OWN token — the `"target"` fire tells it nothing — so the /// list has to be pushed per token and the event fired per token.