diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 4277318c..05e79bab 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -1079,10 +1079,22 @@ fn feed_auras( // a burst exactly where it hurts, on streaming into a crowd). The seed is what makes the next // real application an *add* rather than being swallowed as a first-time diff. for e in &arrived { - if let Ok((store, _guid)) = watched.get(e) { - // The player is seeded here too now that it has events of its own: without this its - // first real edge would be swallowed as a first-time diff, which is one lost - // `BUFF_ADDED_SELF` per login. + if let Ok((store, guid)) = watched.get(e) { + // The player's HELPFUL half is seeded here too, because it has events of its own. + // This was described here before it was done: the loop below treats an unseeded + // `own_buffs` as first sight and fires nothing, so without this seed the player's + // first buff edge of the session is swallowed as a first-time diff. + // + // That loss does not heal. A later cast of a buff that is still up refreshes its slot + // with the same spell id, which is not an edge this watch sees at all (see + // `AURA_ID_SPAN`), so nothing ever reports the aura again until it drops completely. + // DoiteAuras keeps its own table purely from these events, so the buff read as missing + // for the rest of the session — and a warrior's Battle Shout, re-cast while it is + // still running, is exactly the buff that never drops. + if guid.0 == self_guid.0 && !*own_buffs_seeded { + collect_aura_slots(store, own_buffs, false); + *own_buffs_seeded = true; + } if !debuffs.contains_key(&e) { collect_debuff_slots(store, debuff_scratch); debuffs.insert(e, debuff_scratch.clone()); @@ -2110,6 +2122,97 @@ end) assert!(seen(&mut app).is_empty()); } + /// The player's own HELPFUL half, which only it has events for. + const SELF_BUFF_EVENTS: &str = r#" +seen = {} +local f = CreateFrame("Frame") +f:RegisterEvent("BUFF_ADDED_SELF") +f:RegisterEvent("BUFF_REMOVED_SELF") +f:SetScript("OnEvent", function() + table.insert(seen, string.format("%s|%d|%d|%d|%d", event, arg3, arg4, arg6, arg7)) +end) +"#; + + /// [`aura_update`] over the **helpful** slots: the edges the net path raises for a buff. + fn buff_update(app: &mut App, entity: Entity, guid: u64, rows: &[(u8, u32, u8, u8)]) { + *app.world_mut().get_mut::(entity).unwrap() = unit_with_debuffs(rows); + for slot in 0..UNIT_AURA_POSITIVE_SLOTS { + app.world_mut().write_message(crate::net::FieldChanged { + entity, + guid, + kind: benilla_protocol::messages::ObjectType::Unit, + index: F_AURA + u16::from(slot), + old: 0, + new: 0, + }); + } + app.update(); + } + + /// The player's first buff edge of a session has to be an *add*, not a seed. + /// + /// The arrival frame seeds the helpful half, so a buff applied afterwards reports + /// `BUFF_ADDED_SELF`. This was described in the feed before it was done: with no seed, the + /// first edge was taken for first sight and fired nothing. + /// + /// And that loss does not heal, which is the second half of this test: a later cast of a buff + /// that is still up refreshes its slot with the same spell id, and that is not an edge this + /// watch sees at all — so nothing reports the aura again until it drops completely. + /// DoiteAuras keeps its own aura table purely from these four events + /// (`Modules/DoitePlayerAuras.lua:403-490`), so the buff read as missing for the rest of the + /// session. A warrior's Battle Shout, re-cast while it is still running, never drops. + #[test] + fn the_players_first_buff_of_a_session_is_an_add_and_a_refresh_is_not_an_edge() { + let mut app = aura_app(); + let me = app + .world_mut() + .spawn(( + crate::net::SelfPlayer, + unit_with_debuffs(&[]), + crate::net::Guid(PLAYER), + )) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(PLAYER, me); + let mut script = UiScript::new().unwrap(); + script.set_unit( + "player", + Some(benilla_ui::script::UnitState { + exists: true, + guid: PLAYER, + ..Default::default() + }), + ); + script.run(SELF_BUFF_EVENTS).unwrap(); + app.insert_non_send_resource(script); + + // The arrival frame: the player is `Added` here, so the memory seeds. + app.update(); + assert!( + seen(&mut app).is_empty(), + "arriving with no buffs is no edge" + ); + + // Battle Shout, rank 1, in the first helpful slot. + buff_update(&mut app, me, PLAYER, &[(0, 6673, 1, 60)]); + let rows = seen(&mut app); + assert_eq!( + rows, + vec!["BUFF_ADDED_SELF|6673|1|0|0".to_string()], + "the session's first buff is an add, not a swallowed first-time diff" + ); + + // Re-cast while it is still up: same slot, same id, no edge — so an add missed above + // would never be reported again. + buff_update(&mut app, me, PLAYER, &[(0, 6673, 1, 60)]); + assert!( + seen(&mut app).is_empty(), + "a refresh that keeps the slot's spell is not an edge" + ); + } + /// The cost property, stated as a test: no aura-id field edge, no work and no event — even /// though the descriptor itself changed. This is what keeps the pair off the per-frame budget. #[test]