diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index 83658bb7..9b0e1334 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -27,7 +27,8 @@ use super::GroupState; /// What the feed last pushed: the values its event edges fire on. #[derive(Default)] pub(super) struct FedParty { - roster: Vec, + /// The group lists the feed has answered with `PARTY_MEMBERS_CHANGED`. + lists_applied: u32, leader: u64, loot: Option, invite: Option, @@ -64,6 +65,18 @@ fn saved_instances_moved(saved: &[SavedInstanceInfo], answers: u32, fed: &FedPar saved != fed.saved || answers != fed.saved_answers } +/// The `PARTY_MEMBERS_CHANGED`s the lists applied since the feed's last look owe: one each, as the +/// reference signals once per packet (`0x5e6c61`). A fresh VM saw none of them, so it gets one +/// catch-up for a group whose list landed before it, never the history; a count that ran backwards +/// is a new session's, all of whose lists are new. +fn members_changed_owed(applied: u32, seen: u32, fresh_vm: bool) -> u32 { + match (fresh_vm, applied.checked_sub(seen)) { + (true, _) => u32::from(applied != 0), + (false, Some(owed)) => owed, + (false, None) => applied, + } +} + /// The `raid1..raid40` unit tokens, one per `MAX_RAID_MEMBERS` (40, `RaidFrame.lua:2`). #[rustfmt::skip] pub(crate) const RAID_TOKENS: [&str; 40] = [ @@ -433,11 +446,18 @@ pub(super) fn feed_party( ); } - let roster: Vec = group.members.iter().map(|m| m.guid).collect(); - if roster != fed.roster { - gate.audit("feed_party", "the roster edge"); + // ── PARTY_MEMBERS_CHANGED ─────────────────────────────────────────────── + // + // Signalled after every `SMSG_GROUP_LIST`'s member loop (`0x5e6c61`), whatever the list changed: + // a status, the flags, the leader or nothing at all, and an empty list reaches it too + // (`0x5e6b3c`). Each list in this drain fires its own, after the snapshots above. + let owed = members_changed_owed(group.lists_applied, fed.lists_applied, vm_reset); + fed.lists_applied = group.lists_applied; + if owed != 0 { + gate.audit("feed_party", "a group list"); + } + for _ in 0..owed { script.fire_event("PARTY_MEMBERS_CHANGED", vec![]); - fed.roster = roster; } if group.leader != fed.leader { gate.audit("feed_party", "the leader edge"); @@ -1006,6 +1026,24 @@ fn test_apply_local( self_guid: Option, target_guid: Option, ) -> bool { + // The server answers each of these with a fresh `SMSG_GROUP_LIST`, which the reference signals + // `PARTY_MEMBERS_CHANGED` for whether or not it changed anything (`0x5e6c61`). + if matches!( + req, + PartyRequest::Leave + | PartyRequest::UninviteUnit(_) + | PartyRequest::PromoteUnit(_) + | PartyRequest::LootMethod { .. } + | PartyRequest::LootThreshold(_) + | PartyRequest::ConvertToRaid + | PartyRequest::SetSubgroup { .. } + | PartyRequest::SwapSubgroup { .. } + | PartyRequest::UninviteRaid(_) + | PartyRequest::PromoteName(_) + | PartyRequest::AssistantLeader { .. } + ) { + group.count_list(); + } match req { PartyRequest::Leave => { // The all-zero list's reset: the group facts and sandbox flag, not the session state. @@ -1405,7 +1443,7 @@ pub(crate) fn synthetic_raid( #[cfg(test)] mod tests { - use super::super::pets::tests::{app, flag, frame, member, party, stream, ME}; + use super::super::pets::tests::{app, app_with, flag, frame, member, party, stream, ME}; use super::*; #[test] @@ -1626,6 +1664,262 @@ mod tests { assert_eq!(flag(&mut app, "UnitIsConnected('raid1')"), Some(1.0)); } + /// A frame that counts the `PARTY_MEMBERS_CHANGED`s the VM is told of; [`changed`] takes them. + fn watch_members_changed(app: &mut App) { + let script = app.world_mut().non_send_resource_mut::(); + script + .run( + r#" + CHANGED = 0 + local f = CreateFrame("Frame") + f:RegisterEvent("PARTY_MEMBERS_CHANGED") + f:SetScript("OnEvent", function() CHANGED = CHANGED + 1 end) + "#, + ) + .unwrap(); + } + + /// Run a frame and count the `PARTY_MEMBERS_CHANGED`s it fired. + fn changed(app: &mut App) -> u32 { + frame(app); + let script = app.world_mut().non_send_resource_mut::(); + let fired: f64 = script.eval("return CHANGED").unwrap(); + script.run("CHANGED = 0").unwrap(); + fired as u32 + } + + /// `SMSG_GROUP_LIST` as the server re-sends it: party members `1..` with their status byte, + /// under `leader` and `loot`, and nothing else told to the client. + fn resend(app: &mut App, statuses: &[u8], leader: u64, loot: Option) { + let list = statuses + .iter() + .enumerate() + .map(|(i, &status)| GroupMemberEntry { + name: format!("M{}", i + 1), + guid: member(i as u64 + 1), + status, + flags: 0, + }) + .collect(); + app.world_mut() + .resource_mut::() + .apply_list(0, 0, list, leader, loot, Some(ME)); + } + + const ON: u8 = member_status::ONLINE; + const OFF: u8 = member_status::OFFLINE; + + /// The reported shape: a member out of view logs off, and the server sends the list again with + /// their status alone moved. The reference signals after every list (`0x5e6c61`), so the party + /// frame rereads them. + #[test] + fn a_list_that_moves_only_a_members_status_fires_party_members_changed() { + let mut app = app(); + watch_members_changed(&mut app); + party(&mut app, 2, true); + assert_eq!(changed(&mut app), 1, "the first look answers the list"); + assert_eq!(changed(&mut app), 0, "a steady frame is silent"); + + resend(&mut app, &[OFF, ON], ME, None); + assert_eq!(changed(&mut app), 1, "member 1 went offline"); + resend(&mut app, &[OFF, ON | member_status::AFK], ME, None); + assert_eq!(changed(&mut app), 1, "member 2 went away"); + assert_eq!(changed(&mut app), 0); + } + + /// The leader and the loot method ride the same list, and neither moves a guid. + #[test] + fn a_list_that_moves_only_the_leader_or_the_loot_method_fires_party_members_changed() { + let mut app = app(); + watch_members_changed(&mut app); + party(&mut app, 2, true); + changed(&mut app); + + resend(&mut app, &[ON, ON], member(1), None); + assert_eq!(changed(&mut app), 1, "a new leader"); + let master = GroupLootInfo { + method: 2, + master: member(1), + threshold: 3, + }; + resend(&mut app, &[ON, ON], member(1), Some(master)); + assert_eq!(changed(&mut app), 1, "a new loot method"); + let raised = GroupLootInfo { + threshold: 4, + ..master + }; + resend(&mut app, &[ON, ON], member(1), Some(raised)); + assert_eq!(changed(&mut app), 1, "a new threshold"); + } + + /// The handler has no compare against the roster it holds (`0x5e6a40`): a list that says what + /// the last one said is signalled all the same. + #[test] + fn an_identical_list_fires_party_members_changed_again() { + let mut app = app(); + watch_members_changed(&mut app); + party(&mut app, 2, true); + changed(&mut app); + + resend(&mut app, &[ON, ON], ME, None); + assert_eq!(changed(&mut app), 1); + resend(&mut app, &[ON, ON], ME, None); + assert_eq!(changed(&mut app), 1); + } + + /// Each packet signals, so the lists one drain applies fire once apiece, after the snapshots + /// the last of them left. + #[test] + fn each_list_of_one_drain_fires_its_own_party_members_changed() { + let mut app = app(); + watch_members_changed(&mut app); + party(&mut app, 2, true); + changed(&mut app); + + resend(&mut app, &[OFF, ON], ME, None); + resend(&mut app, &[OFF, OFF], ME, None); + resend(&mut app, &[ON, ON], ME, None); + assert_eq!(changed(&mut app), 3); + assert_eq!(flag(&mut app, "UnitIsConnected('party1')"), Some(1.0)); + } + + /// An empty list jumps straight to the signal (`0x5e6b3c`), the all-zero "you left" list and a + /// solo leader's alike, and a second one to a group already empty is not skipped. + #[test] + fn an_empty_list_fires_party_members_changed() { + let mut app = app(); + watch_members_changed(&mut app); + party(&mut app, 1, true); + changed(&mut app); + + app.world_mut() + .resource_mut::() + .apply_list(0, 0, vec![], 0, None, Some(ME)); + assert_eq!(changed(&mut app), 1, "the leave list"); + app.world_mut() + .resource_mut::() + .apply_list(0, 0, vec![], 0, None, Some(ME)); + assert_eq!( + changed(&mut app), + 1, + "and again, with nobody left to change" + ); + app.world_mut() + .resource_mut::() + .apply_list(0, 0, vec![], ME, None, Some(ME)); + assert_eq!(changed(&mut app), 1, "a leader with no one yet"); + } + + /// A VM minted after the lists landed was told of none of them: one catch-up for a group, not + /// the history, and silence for a player who never had one. + #[test] + fn a_fresh_vm_answers_the_lists_that_landed_before_it_once() { + let mut app = app(); + watch_members_changed(&mut app); + party(&mut app, 2, true); + resend(&mut app, &[OFF, ON], ME, None); + resend(&mut app, &[OFF, OFF], ME, None); + assert_eq!(changed(&mut app), 1); + assert_eq!(changed(&mut app), 0); + + let mut solo = self::app(); + watch_members_changed(&mut solo); + assert_eq!(changed(&mut solo), 0, "no list, no event"); + } + + #[test] + fn the_lists_owed_are_the_count_since_the_last_look() { + assert_eq!(members_changed_owed(3, 3, false), 0, "nothing landed"); + assert_eq!(members_changed_owed(5, 3, false), 2, "one apiece"); + assert_eq!( + members_changed_owed(2, 4, false), + 2, + "the session ended and began again" + ); + assert_eq!(members_changed_owed(0, 4, false), 0); + assert_eq!( + members_changed_owed(7, 0, true), + 1, + "a fresh VM: the group, once" + ); + assert_eq!(members_changed_owed(0, 0, true), 0); + } + + /// The sandbox stands in for the server, whose echo of each group intent is a list. + #[test] + fn the_sandbox_answers_a_group_intent_with_a_party_members_changed() { + let mut app = app(); + watch_members_changed(&mut app); + synthetic_roster(&mut app.world_mut().resource_mut::(), None); + assert_eq!(changed(&mut app), 1); + + let (me, mob) = (Some(ME), None); + let intents = [ + PartyRequest::UninviteUnit("party2".into()), + PartyRequest::PromoteUnit("party1".into()), + PartyRequest::LootThreshold(4), + PartyRequest::Leave, + ]; + for req in intents { + let mut group = app.world_mut().resource_mut::(); + assert!(test_apply_local(&mut group, &req, me, mob)); + assert_eq!(changed(&mut app), 1, "{req:?}"); + } + let mut group = app.world_mut().resource_mut::(); + let mark = PartyRequest::SetRaidTarget { + unit: "player".into(), + index: 1, + }; + assert!(test_apply_local(&mut group, &mark, me, mob)); + assert_eq!(changed(&mut app), 0, "a mark is no list"); + } + + /// The stock party frame, loaded whole off the player's own chain: a member out of view logs + /// off, and the server sends the list again with their status alone moved. The stock frame + /// repaints on the event that list signals: `UnitFrameManaBar_Update` greys the bar for a + /// member who is not connected, and `PartyMemberFrame_UpdatePet` hides their pet frame + /// (`PartyMemberFrame.lua:73`). + #[test] + fn the_stock_party_frame_greys_a_member_who_logs_off_out_of_view() { + benilla_formats::wow_data_or_skip!(); + let mut script = UiScript::new().unwrap(); + script.set_screen_size(1024.0, 768.0); + let failures = crate::ui_script::load_default_ui(&script); + assert!(failures.is_empty(), "load failures: {failures:?}"); + let mut app = app_with(script); + let bar = |app: &mut App| { + app.world_mut() + .non_send_resource::() + .eval::<(f64, f64, f64)>("return PartyMemberFrame1ManaBar:GetStatusBarColor()") + .unwrap() + }; + + party(&mut app, 1, true); + frame(&mut app); + assert_eq!(bar(&mut app), (0.0, 0.0, 1.0), "the mana colour"); + assert_eq!( + flag(&mut app, "PartyMemberFrame1PetFrame:IsShown()"), + Some(1.0) + ); + + // The list alone, as the server sends it: the member's record is not touched. + resend(&mut app, &[OFF], ME, None); + frame(&mut app); + assert_eq!(flag(&mut app, "UnitIsConnected('party1')"), None); + assert_eq!(bar(&mut app), (0.5, 0.5, 0.5), "the disconnected grey"); + assert_eq!(flag(&mut app, "PartyMemberFrame1PetFrame:IsShown()"), None); + + resend(&mut app, &[ON], ME, None); + frame(&mut app); + assert_eq!(bar(&mut app), (0.0, 0.0, 1.0), "back online"); + assert_eq!( + flag(&mut app, "PartyMemberFrame1PetFrame:IsShown()"), + Some(1.0) + ); + let errors = app.world().non_send_resource::().errors(); + assert!(errors.is_empty(), "script errors: {errors:?}"); + } + #[test] fn a_second_empty_answer_is_still_a_saved_instance_edge() { let mut fed = FedParty::default(); diff --git a/crates/benilla-app/src/ui_party/mod.rs b/crates/benilla-app/src/ui_party/mod.rs index ecf273ce..8f121cd7 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -52,6 +52,10 @@ pub struct GroupState { pub own_flags: u8, /// The other members in wire order; the list never contains the recipient. pub members: Vec, + /// How many `SMSG_GROUP_LIST`s landed, the all-zero and an unchanged one included. The + /// reference signals `PARTY_MEMBERS_CHANGED` after each (`0x5e6c61`), with no compare against + /// the roster it holds, and an empty list reaches it too (`0x5e6b3c`). + pub lists_applied: u32, /// The leave ack, `SMSG_GROUP_UNINVITE` and `SMSG_GROUP_DESTROYED` empty the reference's party /// slots and leader (`0x4e84a0`, `0x4e8250(0)`) and keep its raid roster; the next list then /// finds no slot held. @@ -103,6 +107,8 @@ impl GroupState { loot: Option, self_guid: Option, ) -> ListOutcome { + // Every path of the handler reaches the event, the leave list and a party's list alike. + self.count_list(); let mut out = ListOutcome::default(); let raid = group_type == GROUPTYPE_RAID; // The two stores as this list finds them: the party slots unless an opcode emptied them, @@ -199,6 +205,13 @@ impl GroupState { out } + /// Count one `SMSG_GROUP_LIST`, which the feed answers with one `PARTY_MEMBERS_CHANGED`. The + /// `/partytest` sandbox calls it for each group intent whose echo the server would send as a + /// list. + pub(super) fn count_list(&mut self) { + self.lists_applied = self.lists_applied.wrapping_add(1); + } + /// The `party1..party4` slots: our own subgroup in packet order, at most four (`0x5e6baa` /// compares the flags under `0x7f`). A slot's member can change on any resync. pub fn party_slots(&self) -> impl Iterator { @@ -375,6 +388,7 @@ impl GroupState { // nothing on a disband (`Group::RemoveAllInvites`). pending_invite, // Session state the feed fires edges on: kept until `clear_session`. + lists_applied: _, saved_instances: _, saved_instances_answers: _, ready_check: _, @@ -473,6 +487,38 @@ mod tests { assert!(g.saved_instances.is_empty()); } + #[test] + fn every_list_counts_whatever_it_changes() { + let mut g = GroupState::default(); + let list = |g: &mut GroupState, members: Vec, leader| { + g.apply_list(0, 0, members, leader, None, Some(ME)); + g.lists_applied + }; + assert_eq!(g.lists_applied, 0, "no list yet"); + + let alice = member("Alice", 0xA11CE); + assert_eq!(list(&mut g, vec![alice.clone()], 0xA11CE), 1); + assert_eq!( + list(&mut g, vec![alice.clone()], 0xA11CE), + 2, + "the same list again" + ); + let away = GroupMemberEntry { + status: 0, + ..alice.clone() + }; + assert_eq!(list(&mut g, vec![away], 0xA11CE), 3, "a status alone"); + assert_eq!(list(&mut g, vec![alice], ME), 4, "a leader alone"); + assert_eq!(list(&mut g, vec![], ME), 5, "a leader with no members"); + assert_eq!(list(&mut g, vec![], 0), 6, "the all-zero list"); + assert_eq!(list(&mut g, vec![], 0), 7, "and another, with nobody held"); + + g.leave_group(); + assert_eq!(g.lists_applied, 7, "leaving is not a list"); + g.clear_session(); + assert_eq!(g.lists_applied, 0); + } + #[test] fn leaving_the_group_keeps_the_session_tickets() { let lockout = benilla_protocol::messages::RaidInstanceEntry { diff --git a/crates/benilla-app/src/ui_party/pets.rs b/crates/benilla-app/src/ui_party/pets.rs index 4109aff4..ecd03d9a 100644 --- a/crates/benilla-app/src/ui_party/pets.rs +++ b/crates/benilla-app/src/ui_party/pets.rs @@ -262,7 +262,7 @@ pub(in crate::ui_party) mod tests { /// The frames of a group feed running [`super::super::feed::feed_party`] on a VM whose one /// spy frame logs the unit events pets raise, as `"EVENT:token"`. - fn app_with(script: UiScript) -> App { + pub(in crate::ui_party) fn app_with(script: UiScript) -> App { let mut app = App::new(); app.add_message::() .init_resource::()