diff --git a/crates/benilla-app/src/spell/bind_gates.rs b/crates/benilla-app/src/spell/bind_gates.rs index 8686bc4d..a5f9218e 100644 --- a/crates/benilla-app/src/spell/bind_gates.rs +++ b/crates/benilla-app/src/spell/bind_gates.rs @@ -106,6 +106,7 @@ mod tests { factions: None, reputations: &EMPTY, types, + group: Default::default(), }; bind_gates(def, word, is_caster, &rel) } @@ -138,6 +139,7 @@ mod tests { factions: None, reputations: &Reputations(Vec::new()), types: CreatureTypeSources::default(), + group: Default::default(), }; assert!(!bind_gates(&excluding, WORD_ASSIST, true, &rel)); assert!(bind_gates(&excluding, WORD_ASSIST, false, &rel)); @@ -369,6 +371,7 @@ mod tests { names: Some(&names), forms: None, }, + group: Default::default(), }; let candidates = CastCandidates { selection: Some(if selection_is_self { ME } else { THEM }), diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index 1e0b8661..4195e2c7 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -807,6 +807,7 @@ mod tests { factions: None, reputations: &EMPTY_REPUTATIONS, types: Default::default(), + group: Default::default(), }, range: cast_target::RangeInputs::default(), main_hand_item: None, diff --git a/crates/benilla-app/src/spell/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs index b84f1682..5f0a8f68 100644 --- a/crates/benilla-app/src/spell/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -23,10 +23,12 @@ use bevy::ecs::system::SystemParam; use bevy::prelude::*; use super::bind_gates::{bind_gates, unit_alive}; +use super::group_relation::{GroupInputs, GroupRoster}; use crate::creature_type::CreatureTypeSources; use crate::names::NameCache; use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid, SelfPlayer}; use crate::target::{can_assist, can_attack, Factions, Selection}; +use benilla_protocol::messages::OwnerFallback; /// `TARGET_FLAG_*` bits of the client's targeting flag word (`0xcecac0`). const TF_UNIT: u16 = 0x0002; @@ -122,6 +124,19 @@ pub(crate) struct TargetRelations<'a> { pub(crate) reputations: &'a Reputations, /// The creature-type gate's inputs, for the candidate and for the player alike. pub(crate) types: CreatureTypeSources<'a>, + /// The party and raid predicates' inputs (`0x606c20`, `0x606d20`). + pub(crate) group: GroupInputs<'a>, +} + +/// A unit's owner as `CanAssist`'s `IsPvP` chase (`0x5ee5a0`) and `0x606170` read it, its charmer +/// else its creator: that object's descriptor, if it has streamed (`0x468460`). +pub(super) fn owner_store<'a>( + store: Option<&ObjectStore>, + index: Option<&GuidIndex>, + stores: &'a Query<&ObjectStore>, +) -> Option<&'a ObjectStore> { + let owner = store?.0.unit_owner(OwnerFallback::CreatedBy)?; + stores.get(*index?.0.get(&owner)?).ok() } impl TargetRelations<'_> { @@ -176,6 +191,7 @@ impl CastContext<'_> { if on_self { self.selection_guid = self.self_guid; self.rel.target_store = self.rel.self_store; + self.rel.group.target_guid = self.self_guid; } self } @@ -241,6 +257,8 @@ pub(crate) struct CastTargeting<'w, 's> { auto_self_cast: Res<'w, AutoSelfCast>, factions: Option>, reputations: Res<'w, Reputations>, + /// The party slots and raid roster as the frame began; a harness without them has no group. + roster: Option>, /// The creature-type gate's template cache and form table; a harness without them resolves /// no type. names: Option>, @@ -256,28 +274,28 @@ impl CastTargeting<'_, '_> { /// This frame's [`CastContext`]. pub(crate) fn context(&self) -> CastContext<'_> { let target_store = self.selection.target.and_then(|e| self.stores.get(e).ok()); - let target_owner_store = target_store - .and_then(|store| { - store - .0 - .unit_owner(benilla_protocol::messages::OwnerFallback::CreatedBy) - }) - .and_then(|guid| self.index.as_ref()?.0.get(&guid).copied()) - .and_then(|entity| self.stores.get(entity).ok()); + let owner_store = |store| owner_store(store, self.index.as_deref(), &self.stores); + let self_store = self.self_store.iter().next(); CastContext { selection_guid: self.selection.guid, self_guid: self.self_guid.0, auto_self_cast: self.auto_self_cast.0, rel: TargetRelations { target_store, - target_owner_store, - self_store: self.self_store.iter().next(), + target_owner_store: owner_store(target_store), + self_store, factions: self.factions.as_deref(), reputations: &self.reputations, types: CreatureTypeSources { names: self.names.as_deref(), forms: self.spells.as_deref().map(|s| &s.forms), }, + group: GroupInputs { + self_guid: self.self_guid.0, + target_guid: self.selection.guid, + self_owner_store: owner_store(self_store), + roster: self.roster.as_deref(), + }, }, range: RangeInputs { self_pos: self.self_transform.iter().next().map(|t| t.translation), @@ -344,17 +362,19 @@ pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 { /// /// The assist bit asks `CanAssist 0x6066f0`: selectable, friendly or better, and an NPC's owner /// (or the NPC itself) PvP-enabled. This keeps friendly ambient NPCs and critters unbindable. -/// The enemy bit asks `CanAttack` (`0x606980`). Party and raid (`0x606c20`, `0x606d20`, the player -/// or a member of the group) clear for the caster alone; the corpse check (`0x6067d0`) is -/// assistable with health 0 here. +/// The enemy bit asks `CanAttack` (`0x606980`). The party bit needs `0x606c20` and then `CanAssist` +/// (`6e5cad`-`6e5cec`): the player, a member of his party, or a unit that answers to one. The raid +/// bit needs `0x606d20` and `CanAssist` (`6e5cf1`-`6e5d2f`): the same, with the raid roster +/// counting as well as the party slots. The corpse check (`0x6067d0`) is assistable with health 0 +/// here. fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16 { let mut word = word; let assist = rel.assistable(is_self); let dead = rel.target_store.is_some_and(|s| !unit_alive(&s.0)); - if word & TF_UNIT_PARTY != 0 && is_self { + if word & TF_UNIT_PARTY != 0 && assist && rel.in_party(is_self) { word &= !TF_UNIT_PARTY; } - if word & TF_UNIT_RAID != 0 && is_self { + if word & TF_UNIT_RAID != 0 && assist && rel.in_group(is_self) { word &= !TF_UNIT_RAID; } if word & TF_UNIT_ASSIST != 0 && assist { @@ -466,6 +486,10 @@ pub(crate) fn resolve_cast_target( let self_rel = TargetRelations { target_store: rel.self_store, target_owner_store: None, + group: GroupInputs { + target_guid: Some(guid), + ..rel.group + }, ..*rel }; if unit_binds(Some(def), word, true, &self_rel) { @@ -557,6 +581,7 @@ mod tests { factions: None, reputations: &Reputations(Vec::new()), types: Default::default(), + group: Default::default(), }; let ice_armor = spell(0, 1); assert_eq!( @@ -628,6 +653,7 @@ mod tests { factions: Some(&factions), reputations: &reputations, types: Default::default(), + group: Default::default(), }; assert!( @@ -839,6 +865,7 @@ mod tests { factions: None, reputations: &EMPTY, types: Default::default(), + group: Default::default(), } } } diff --git a/crates/benilla-app/src/spell/group_relation.rs b/crates/benilla-app/src/spell/group_relation.rs new file mode 100644 index 00000000..78754b6f --- /dev/null +++ b/crates/benilla-app/src/spell/group_relation.rs @@ -0,0 +1,763 @@ +//! The group relations the party and raid words ask: `BindTarget 0x6e5b40`'s party arm +//! (`6e5cad`-`6e5cec`) needs `0x606c20(caster, unit)` and `CanAssist`, its raid arm +//! (`6e5cf1`-`6e5d2f`) `0x606d20(caster, unit)` and `CanAssist`, and the world pick's party leg +//! (`480686`, `4806b8`) asks `0x606c20`. +//! +//! - [`GroupRoster`]: the guids the reference's two tables hold, and [`publish_group_roster`], +//! which copies them out of `GroupState` before the input pass, so no cast or hover system +//! reads that resource and orders against the drains that write it. +//! - [`GroupInputs`]: what a [`TargetRelations`] carries beside its stores for the two +//! predicates, and [`TargetRelations::in_party`] and [`TargetRelations::in_group`], which ask +//! them. + +use bevy::prelude::*; + +use benilla_protocol::messages::{ObjectType, OwnerFallback}; + +use super::cast_target::TargetRelations; +use crate::net::{ObjectStore, SelfGuid}; +use crate::ui_party::{raid_row_guids, GroupState}; + +/// `UNIT_FIELD_FLAGS & 0x8`, `UNIT_FLAG_PLAYER_CONTROLLED` (vmangos `UnitDefines.h:548`), which +/// both predicates test on both units (`606c5e`, `606c76`, `606d5e`, `606d76`). +const UNIT_FLAG_PLAYER_CONTROLLED: u32 = 0x8; + +/// The two tables `0x4e7f70` and `0x4baee0` read: the guids in the party slots and in the raid +/// roster, as the frame began. +#[derive(Resource, Default, Debug, PartialEq, Eq)] +pub(crate) struct GroupRoster { + /// The party slots `[0xbc6f48 + 8i]`, `i < 4` (`4e7f96`-`4e7fb0`); none once an opcode emptied + /// them. + pub(crate) party: Vec, + /// The raid roster `[[0xb712a8 + 4i]]`, `i < [0xb713e0]` (`4baee0`), us included; none outside + /// a raid. + pub(crate) raid: Vec, +} + +impl GroupRoster { + /// `0x4e7f70`: a nonzero guid that is the active player's (`4e7f84`) or in a party slot. + fn in_party(&self, me: Option, guid: u64) -> bool { + guid != 0 && (me == Some(guid) || self.party.contains(&guid)) + } + + /// `0x4918e0`: [`Self::in_party`], or a nonzero guid in the raid roster (`4918ee`). + fn in_group(&self, me: Option, guid: u64) -> bool { + self.in_party(me, guid) || guid != 0 && self.raid.contains(&guid) + } +} + +/// Publish [`GroupRoster`] before the input pass, beside the pick's other published inputs. The +/// party slots are the ones `partyN` reads (`GroupState::party_slots`), none after a leave or a +/// kick (`slots_emptied`, `0x4e84a0`); the raid roster is the rows `raidN` reads +/// ([`raid_row_guids`]). +pub(crate) fn publish_group_roster( + group: Option>, + self_guid: Res, + mut roster: ResMut, +) { + let Some(group) = group else { return }; + if !group.is_changed() && !self_guid.is_changed() { + return; + } + let party = if group.slots_emptied { + Vec::new() + } else { + group.party_slots().map(|m| m.guid).collect() + }; + let now = GroupRoster { + party, + raid: raid_row_guids(&group, self_guid.0), + }; + if *roster != now { + *roster = now; + } +} + +/// What the two predicates read beside a [`TargetRelations`]' stores. +#[derive(Clone, Copy, Default)] +pub(crate) struct GroupInputs<'a> { + /// The active player's guid (`0x468550`), whom both predicates compare controllers to. + pub(crate) self_guid: Option, + /// The candidate's guid. + pub(crate) target_guid: Option, + /// The caster's charmer or creator store, as [`TargetRelations::target_owner_store`] is the + /// candidate's. + pub(crate) self_owner_store: Option<&'a ObjectStore>, + /// `None` in a harness that publishes none: nobody is anybody's group. + pub(crate) roster: Option<&'a GroupRoster>, +} + +impl TargetRelations<'_> { + /// `0x606c20(caster, candidate)`: the candidate is the caster (`606c3d`), or a member of his + /// party, or a unit that answers to one ([`Self::related`]). + pub(super) fn in_party(&self, is_self: bool) -> bool { + self.related(is_self, GroupRoster::in_party) + } + + /// `0x606d20(caster, candidate)`: `0x606c20` with `0x4918e0` in place of `0x4e7f70`, so a + /// member of the raid roster counts as well as one of the party slots. + pub(super) fn in_group(&self, is_self: bool) -> bool { + self.related(is_self, GroupRoster::in_group) + } + + /// The shape both predicates share: the caster is the candidate, or both are player-controlled + /// (`606c4f`-`606c79`), each answers to a player ([`controller`]), and one of the two + /// controllers is the active player and the other a member (`606ca3`-`606d02`), tried both + /// ways round. A member's pet is such a unit. Whoever has not streamed answers no controller + /// and no relation. + fn related(&self, is_self: bool, member: fn(&GroupRoster, Option, u64) -> bool) -> bool { + if is_self { + return true; + } + let group = &self.group; + let (Some(me), Some(target), Some(me_store), Some(store)) = ( + group.self_guid, + group.target_guid, + self.self_store, + self.target_store, + ) else { + return false; + }; + if me_store.0.unit_flags() & store.0.unit_flags() & UNIT_FLAG_PLAYER_CONTROLLED == 0 { + return false; + } + let (Some(mine), Some(theirs)) = ( + controller(me_store, me, group.self_owner_store), + controller(store, target, self.target_owner_store), + ) else { + return false; + }; + let member = |guid| { + group + .roster + .is_some_and(|roster| member(roster, Some(me), guid)) + }; + mine == me && member(theirs) || theirs == me && member(mine) + } +} + +/// `0x606170`: the player a unit answers to, its charmer else its creator (`606170`-`60619f`) +/// or the unit itself when neither is set, and only when that object is a player (`6061c6`- +/// `6061cf`, `TYPEMASK_PLAYER`). `owner_store` is the charmer's or creator's descriptor, `None` +/// while it has not streamed. +fn controller(store: &ObjectStore, guid: u64, owner_store: Option<&ObjectStore>) -> Option { + let (owner, owner_store) = match store.0.unit_owner(OwnerFallback::CreatedBy) { + Some(owner) => (owner, owner_store), + None => (guid, Some(store)), + }; + owner_store + .is_some_and(|s| s.0.object_type() == Some(ObjectType::Player)) + .then_some(owner) +} + +/// The stores the group tests stand up, over the faction rows of [`super::targeting::corpse_fixture`] +/// (template 1 Human, the caster's, and 2 Orc). +#[cfg(test)] +pub(crate) mod fixture { + use super::*; + use benilla_protocol::ObjectFields; + + /// Absolute descriptor indices: `OBJECT_FIELD_TYPE`, `UNIT_FIELD_CHARMEDBY`, + /// `UNIT_FIELD_CREATEDBY`, `UNIT_FIELD_HEALTH`, `UNIT_FIELD_FACTIONTEMPLATE` and + /// `UNIT_FIELD_FLAGS`. + pub(crate) const TYPE: u16 = 2; + pub(crate) const CHARMED_BY: u16 = 10; + pub(crate) const CREATED_BY: u16 = 14; + pub(crate) const HEALTH: u16 = 22; + pub(crate) const TEMPLATE: u16 = 35; + pub(crate) const FLAGS: u16 = 46; + /// `TYPEMASK_OBJECT | UNIT | PLAYER`, and `TYPEMASK_OBJECT | UNIT`. + pub(crate) const PLAYER_TYPE: u32 = 0x19; + pub(crate) const CREATURE_TYPE: u32 = 0x09; + /// `UNIT_FLAG_PLAYER_CONTROLLED`, and `UNIT_FLAG_PVP` (`0x1000`), which makes a friendly + /// creature assistable. + pub(crate) const CONTROLLED: u32 = 0x8; + pub(crate) const PVP: u32 = 0x1000; + + pub(crate) fn store(pairs: &[(u16, u32)]) -> ObjectStore { + ObjectStore(ObjectFields::from_pairs(pairs)) + } + + /// A living, player-controlled player of this faction template. + pub(crate) fn player(template: u32) -> ObjectStore { + store(&[ + (TYPE, PLAYER_TYPE), + (TEMPLATE, template), + (FLAGS, CONTROLLED), + (HEALTH, 100), + ]) + } + + /// A living, player-controlled creature of this template that answers to `owner`, as a pet + /// does: its creator, or its charmer when `charmed`. + pub(crate) fn pet_of(owner: u64, template: u32, charmed: bool) -> ObjectStore { + store(&[ + (TYPE, CREATURE_TYPE), + (TEMPLATE, template), + (FLAGS, CONTROLLED), + (HEALTH, 100), + (if charmed { CHARMED_BY } else { CREATED_BY }, owner as u32), + ]) + } + + /// A living creature of this template that nobody controls, with these unit flags. + pub(crate) fn npc(template: u32, flags: u32) -> ObjectStore { + store(&[ + (TYPE, CREATURE_TYPE), + (TEMPLATE, template), + (FLAGS, flags), + (HEALTH, 100), + ]) + } +} + +#[cfg(test)] +mod tests { + use super::fixture::*; + use super::*; + use crate::net::Reputations; + use crate::spell::cast_target::{ + cast_target_mask, resolve_cast_target, unit_binds, CastCandidates, CastWireTarget, + }; + use crate::spell::targeting::corpse_fixture as fx; + use benilla_formats::SpellDisplay; + use benilla_protocol::messages::GroupMemberEntry; + use bevy::ecs::system::RunSystemOnce; + + const ME: u64 = 1; + const MEMBER: u64 = 2; + const RAIDER: u64 = 3; + const STRANGER: u64 = 4; + const MEMBER_PET: u64 = 5; + const NPC: u64 = 6; + const ORC_MEMBER: u64 = 7; + + /// `TARGET_FLAG_UNIT_RAID` and `TARGET_FLAG_UNIT_PARTY`, the words implicit targets 57 and 35 + /// set. + const RAID_WORD: u16 = 0x4; + const PARTY_WORD: u16 = 0x8; + + /// Us and our party of one, a raider in our raid and a stranger in neither. + fn roster() -> GroupRoster { + GroupRoster { + party: vec![MEMBER], + raid: vec![ME, MEMBER, RAIDER], + } + } + + /// `(0x606c20, 0x606d20)` toward a candidate, with the stores that stand behind it. + fn asked( + roster: &GroupRoster, + me: &ObjectStore, + me_owner: Option<&ObjectStore>, + target: (u64, &ObjectStore, Option<&ObjectStore>), + ) -> (bool, bool) { + let (guid, store, owner) = target; + let rel = TargetRelations { + target_store: Some(store), + target_owner_store: owner, + self_store: Some(me), + factions: None, + reputations: &Reputations(Vec::new()), + types: Default::default(), + group: GroupInputs { + self_guid: Some(ME), + target_guid: Some(guid), + self_owner_store: me_owner, + roster: Some(roster), + }, + }; + (rel.in_party(false), rel.in_group(false)) + } + + /// `0x606c20` and `0x606d20` on players: the party slots satisfy both, the raid roster only the + /// second, and a player in neither answers neither. + #[test] + fn the_raid_roster_adds_to_the_party_slots_for_the_second_predicate_alone() { + let (roster, me, other) = (roster(), player(1), player(1)); + let ask = |guid| asked(&roster, &me, None, (guid, &other, None)); + assert_eq!(ask(MEMBER), (true, true), "a party member"); + assert_eq!(ask(RAIDER), (false, true), "a raid member of another group"); + assert_eq!(ask(STRANGER), (false, false), "a player in neither"); + assert_eq!( + ask(0), + (false, false), + "no guid is nobody's (`4e7f75`, `4baee9`)" + ); + assert_eq!( + asked(&GroupRoster::default(), &me, None, (MEMBER, &other, None)), + (false, false), + "an empty roster" + ); + } + + /// The caster is the candidate (`606c3d`, `606d3d`) whatever the roster holds, and a candidate + /// with no guid or no descriptor streamed answers no relation. + #[test] + fn the_caster_is_his_own_group_and_a_missing_input_is_none() { + let (me, other) = (player(1), player(1)); + let roster = roster(); + let rel = TargetRelations { + target_store: Some(&other), + target_owner_store: None, + self_store: Some(&me), + factions: None, + reputations: &Reputations(Vec::new()), + types: Default::default(), + group: GroupInputs { + self_guid: Some(ME), + target_guid: Some(MEMBER), + self_owner_store: None, + roster: Some(&roster), + }, + }; + assert!(rel.in_party(true) && rel.in_group(true), "the caster"); + assert!(rel.in_party(false) && rel.in_group(false), "the control"); + let no_guid = TargetRelations { + group: GroupInputs { + target_guid: None, + ..rel.group + }, + ..rel + }; + assert!(!no_guid.in_party(false) && !no_guid.in_group(false)); + let no_store = TargetRelations { + target_store: None, + ..rel + }; + assert!(!no_store.in_party(false) && !no_store.in_group(false)); + let no_roster = TargetRelations { + group: GroupInputs { + roster: None, + ..rel.group + }, + ..rel + }; + assert!(!no_roster.in_party(false) && !no_roster.in_group(false)); + } + + /// `606c5e`-`606c76`: both units must be player-controlled, so a member who is not, or a caster + /// who is not, has no party. + #[test] + fn both_sides_must_be_player_controlled() { + let roster = roster(); + let (me, member) = (player(1), player(1)); + let unflagged = store(&[(TYPE, PLAYER_TYPE)]); + assert_eq!( + asked(&roster, &me, None, (MEMBER, &unflagged, None)), + (false, false) + ); + assert_eq!( + asked(&roster, &unflagged, None, (MEMBER, &member, None)), + (false, false) + ); + } + + /// `0x606170`: a pet is the group member it answers to, so a party member's pet is in the party + /// and a raider's in the raid; a pet of a stranger, a creature that answers to nobody and a pet + /// whose owner has not streamed are in neither. + #[test] + fn a_pet_is_the_group_member_it_answers_to() { + let roster = roster(); + let (me, member, raider, stranger) = (player(1), player(1), player(1), player(1)); + let ask = |guid: u64, pet: &ObjectStore, owner: Option<&ObjectStore>| { + asked(&roster, &me, None, (guid, pet, owner)) + }; + assert_eq!( + ask(10, &pet_of(MEMBER, 1, false), Some(&member)), + (true, true), + "a member's pet" + ); + assert_eq!( + ask(11, &pet_of(ME, 1, false), Some(&me)), + (true, true), + "our own pet" + ); + assert_eq!( + ask(12, &pet_of(RAIDER, 1, false), Some(&raider)), + (false, true), + "a raider's pet" + ); + assert_eq!( + ask(13, &pet_of(STRANGER, 1, false), Some(&stranger)), + (false, false), + "a stranger's pet" + ); + assert_eq!( + ask(14, &pet_of(MEMBER, 1, false), None), + (false, false), + "an owner that has not streamed answers no controller (`606c92`)" + ); + // A creature nobody controls: the flag says player-controlled, but it is no player. + let orphan = store(&[(TYPE, CREATURE_TYPE), (FLAGS, CONTROLLED)]); + assert_eq!( + ask(15, &orphan, None), + (false, false), + "no owner, no player" + ); + assert_eq!( + ask(16, &npc(1, PVP), None), + (false, false), + "an NPC is not player-controlled" + ); + // Only a player counts as a controller (`6061c6`-`6061cf`): a creature standing in the + // roster, and a pet that answers to one, have none. + let controlled = store(&[(TYPE, CREATURE_TYPE), (FLAGS, CONTROLLED)]); + assert_eq!( + ask(MEMBER, &controlled, None), + (false, false), + "a controlled creature whose own guid is in the party" + ); + assert_eq!( + ask(18, &pet_of(MEMBER, 1, false), Some(&controlled)), + (false, false), + "a pet whose owner in the party is no player" + ); + // A pet answers to its charmer before its creator: `CHARMEDBY` decides. + let charmed = store(&[ + (TYPE, CREATURE_TYPE), + (FLAGS, CONTROLLED), + (CHARMED_BY, STRANGER as u32), + (CREATED_BY, MEMBER as u32), + ]); + assert_eq!( + ask(17, &charmed, Some(&stranger)), + (false, false), + "charmed by a stranger" + ); + } + + /// `606dce`-`606e02`: the controllers are tried both ways round, so a caster who answers to a + /// member (a charmed player) is in the group of a unit that answers to us. + #[test] + fn the_controllers_are_tried_both_ways_round() { + let roster = roster(); + let (member, stranger) = (player(1), player(1)); + let charmed_by = |guid: u64| { + store(&[ + (TYPE, PLAYER_TYPE), + (FLAGS, CONTROLLED), + (CHARMED_BY, guid as u32), + ]) + }; + // Our pet answers to us (its creator is the active player) and we answer to a member: the + // caster's controller is not the active player, the candidate's is. + let (charmed_me, ours) = (charmed_by(MEMBER), pet_of(ME, 1, false)); + assert_eq!( + asked( + &roster, + &charmed_me, + Some(&member), + (10, &ours, Some(&charmed_me)) + ), + (true, true) + ); + // Charmed by a stranger instead: the other side is no member. + let charmed_by_stranger = charmed_by(STRANGER); + assert_eq!( + asked( + &roster, + &charmed_by_stranger, + Some(&stranger), + (10, &ours, Some(&charmed_by_stranger)) + ), + (false, false) + ); + // Neither controller is the active player: no relation, whatever they are. + let theirs = pet_of(RAIDER, 1, false); + assert_eq!( + asked( + &roster, + &charmed_me, + Some(&member), + (11, &theirs, Some(&member)) + ), + (false, false) + ); + } + + /// The party bit `0x8` and the raid bit `0x4` clear for the group the predicates name and + /// `CanAssist` passes (`6e5cad`-`6e5d2f`), and only the raid bit for a raid member outside + /// the party. + #[test] + fn the_party_and_raid_bits_clear_for_the_group_and_only_when_assistable() { + let factions = fx::factions(); + let reputations = Reputations(Vec::new()); + let roster = GroupRoster { + party: vec![MEMBER, ORC_MEMBER], + raid: vec![ME, MEMBER, RAIDER, ORC_MEMBER], + }; + let me = player(1); + let (member, raider, stranger) = (player(1), player(1), player(1)); + let orc_member = player(2); + let member_pet = pet_of(MEMBER, 1, false); + // A friendly creature that can be assisted but answers to nobody. + let npc = npc(1, PVP); + let binds = |word: u16, is_self: bool, guid, store: &ObjectStore, owner| { + let rel = TargetRelations { + target_store: Some(store), + target_owner_store: owner, + self_store: Some(&me), + factions: Some(&factions), + reputations: &reputations, + types: Default::default(), + group: GroupInputs { + self_guid: Some(ME), + target_guid: Some(guid), + self_owner_store: None, + roster: Some(&roster), + }, + }; + unit_binds(None, word, is_self, &rel) + }; + // (label, guid, store, owner, binds under the party word, under the raid word) + type Case<'a> = ( + &'a str, + u64, + &'a ObjectStore, + Option<&'a ObjectStore>, + bool, + bool, + ); + let cases: [Case; 6] = [ + ("a party member", MEMBER, &member, None, true, true), + ( + "a raid member of another group", + RAIDER, + &raider, + None, + false, + true, + ), + ( + "a player in neither", + STRANGER, + &stranger, + None, + false, + false, + ), + ( + "a party member's pet", + MEMBER_PET, + &member_pet, + Some(&member), + true, + true, + ), + ("a friendly creature", NPC, &npc, None, false, false), + // In the party, and no friend: `CanAssist` refuses after the predicate passes. + ( + "a member of the other faction", + ORC_MEMBER, + &orc_member, + None, + false, + false, + ), + ]; + for (label, guid, store, owner, party, raid) in cases { + assert_eq!( + binds(PARTY_WORD, false, guid, store, owner), + party, + "{label}: the party word" + ); + assert_eq!( + binds(RAID_WORD, false, guid, store, owner), + raid, + "{label}: the raid word" + ); + } + // The caster binds both, in no group at all. + for word in [PARTY_WORD, RAID_WORD] { + assert!(binds(word, true, ME, &me, None), "the caster, {word:#x}"); + } + } + + /// Power Word: Shield (implicit target 57, the raid word) on the press: a party member binds + /// and goes on the wire, a friendly player in no group does not, and the fallback to ourselves + /// is `autoSelfCast`'s. + #[test] + fn a_raid_word_spell_pressed_on_a_party_member_binds_him() { + let factions = fx::factions(); + let reputations = Reputations(Vec::new()); + let roster = GroupRoster { + party: vec![MEMBER], + raid: Vec::new(), + }; + let (me, member, stranger) = (player(1), player(1), player(1)); + let shield = SpellDisplay { + implicit_target_a1: 57, + ..Default::default() + }; + assert_eq!(cast_target_mask(&shield), RAID_WORD); + let press = |target: (u64, &ObjectStore), auto_self_cast| { + let rel = TargetRelations { + target_store: Some(target.1), + target_owner_store: None, + self_store: Some(&me), + factions: Some(&factions), + reputations: &reputations, + types: Default::default(), + group: GroupInputs { + self_guid: Some(ME), + target_guid: Some(target.0), + self_owner_store: None, + roster: Some(&roster), + }, + }; + let candidates = CastCandidates { + selection: Some(target.0), + caster: Some(ME), + main_hand_item: None, + }; + resolve_cast_target(Some(&shield), &candidates, auto_self_cast, &rel) + }; + for auto_self_cast in [false, true] { + assert_eq!( + press((MEMBER, &member), auto_self_cast), + CastWireTarget::Unit(MEMBER), + "a party member, autoSelfCast {auto_self_cast}" + ); + } + assert_eq!( + press((STRANGER, &stranger), false), + CastWireTarget::Targeting(RAID_WORD), + "a stranger leaves the word standing for the cursor" + ); + assert_eq!( + press((STRANGER, &stranger), true), + CastWireTarget::Unit(ME), + "and autoSelfCast falls back to the caster" + ); + } + + /// The shipped rows the party and raid words serve: each carries its bit, and a party member + /// selected when the row is pressed is the bound target, a stranger not. + #[test] + fn the_shipped_party_and_raid_spells_bind_a_party_member() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"); + let factions = fx::factions(); + let reputations = Reputations(Vec::new()); + let roster = GroupRoster { + party: vec![MEMBER], + raid: Vec::new(), + }; + let (me, member, stranger) = (player(1), player(1), player(1)); + // The party word (target 35), then the raid word (targets 57 and 61). + let rows = [ + (13903, "Seal of Sacrifice", PARTY_WORD), + (17177, "Seal of Protection", PARTY_WORD), + (17, "Power Word: Shield", RAID_WORD), + (1022, "Blessing of Protection", RAID_WORD), + (1038, "Blessing of Salvation", RAID_WORD), + (6940, "Blessing of Sacrifice", RAID_WORD), + (604, "Dampen Magic", RAID_WORD), + (1008, "Amplify Magic", RAID_WORD), + (19752, "Divine Intervention", RAID_WORD), + (25782, "Greater Blessing of Might", RAID_WORD), + (25898, "Greater Blessing of Kings", RAID_WORD), + ]; + for (id, name, bit) in rows { + let def = catalog.get(id).unwrap_or_else(|| panic!("{name} ({id})")); + assert_eq!(def.name, name, "spell {id}"); + assert_eq!(cast_target_mask(def), bit, "{name}: the word"); + let press = |target: (u64, &ObjectStore)| { + let rel = TargetRelations { + target_store: Some(target.1), + target_owner_store: None, + self_store: Some(&me), + factions: Some(&factions), + reputations: &reputations, + types: Default::default(), + group: GroupInputs { + self_guid: Some(ME), + target_guid: Some(target.0), + self_owner_store: None, + roster: Some(&roster), + }, + }; + let candidates = CastCandidates { + selection: Some(target.0), + caster: Some(ME), + main_hand_item: None, + }; + resolve_cast_target(Some(def), &candidates, false, &rel) + }; + assert_eq!( + press((MEMBER, &member)), + CastWireTarget::Unit(MEMBER), + "{name}: a party member" + ); + assert_eq!( + press((STRANGER, &stranger)), + CastWireTarget::Targeting(bit), + "{name}: a stranger" + ); + } + } + + fn entry(guid: u64, flags: u8) -> GroupMemberEntry { + GroupMemberEntry { + name: String::new(), + guid, + status: 0, + flags, + } + } + + /// The published tables: the party slots are our own subgroup, at most four, and the raid + /// roster every row, us first, none outside a raid. A leave or a kick empties the slots and + /// keeps the roster. + #[test] + fn the_roster_publishes_the_slots_and_rows_the_reference_holds() { + let publish = |world: &mut World| { + world + .run_system_once(publish_group_roster) + .expect("the roster publishes"); + std::mem::take(&mut *world.resource_mut::()) + }; + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(SelfGuid(Some(ME))); + assert_eq!(publish(&mut world), GroupRoster::default(), "no group"); + + // A party: everyone in our subgroup, and no raid roster. + world.resource_mut::().members = + vec![entry(MEMBER, 0), entry(RAIDER, 0), entry(STRANGER, 0)]; + assert_eq!( + publish(&mut world), + GroupRoster { + party: vec![MEMBER, RAIDER, STRANGER], + raid: Vec::new(), + } + ); + + // A raid: the party slots are our subgroup's, the roster all rows with us first. + { + let mut group = world.resource_mut::(); + group.group_type = crate::ui_party::GROUPTYPE_RAID; + group.members = vec![entry(MEMBER, 0), entry(RAIDER, 1), entry(STRANGER, 0)]; + } + assert_eq!( + publish(&mut world), + GroupRoster { + party: vec![MEMBER, STRANGER], + raid: vec![ME, MEMBER, RAIDER, STRANGER], + } + ); + + // A kick empties the slots and keeps the roster (`0x4e84a0`). + world.resource_mut::().slots_emptied = true; + assert_eq!( + publish(&mut world), + GroupRoster { + party: Vec::new(), + raid: vec![ME, MEMBER, RAIDER, STRANGER], + } + ); + } +} diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index 63577058..7e495f83 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -1,6 +1,7 @@ //! The spell, the reference's `Spell_C`: the cast ladder and commit ([`cast_send`], `TryCast //! 0x6e4b60` and `SendCast 0x6e54f0`), the target bind ([`cast_target`], `ArmCast 0x6e5250` and -//! `BindTarget 0x6e5b40`), the requirement validator ([`validator`], `0x6094f0`), the usable walk +//! `BindTarget 0x6e5b40`), the party and raid relations its arms ask ([`group_relation`], +//! `0x606c20`, `0x606d20`), the requirement validator ([`validator`], `0x6094f0`), the usable walk //! ([`usable`], `IsSpellUsableNow 0x6e3d60`), the targeting cursor ([`targeting`], `0xcecac0`), //! the in-flight slot, the cooldowns, the talent modifiers and their packet handlers ([`net`]). @@ -15,6 +16,7 @@ mod bind_gates; mod cast_send; pub(crate) mod cast_target; pub(crate) mod cooldowns; +mod group_relation; mod inflight; mod mods; pub(crate) mod net; @@ -70,6 +72,7 @@ impl Plugin for SpellPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_observer(cast_target::on_cvar) .add_systems( Update, @@ -93,6 +96,11 @@ impl Plugin for SpellPlugin { targeting::publish_unit_pick, ) .in_set(UiFeed), + // The party slots and raid roster the party and raid words read, before + // the feed's targeting push asks them. + group_relation::publish_group_roster + .in_set(UiFeed) + .before(UnitFeed), // The item-target commit (`0x495d60`): after the input pass so a bag click // binds the same frame; outside the target chain, as its clicks never reach // the world. diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 90b33a9b..db1a1d88 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -62,8 +62,11 @@ pub(crate) struct BindChecks<'w, 's> { (Entity, Option<&'static crate::net::ObjectStore>), With, >, + guids: Query<'w, 's, &'static crate::net::Guid>, factions: Option>, reputations: Res<'w, crate::net::Reputations>, + /// The party slots and raid roster as the frame began; a harness without them has no group. + roster: Option>, names: Option>, /// The range leg's positions: the pose the hover picks against, as last frame propagated it, /// so the VM feed, the cursor and both binds read one pose. Never the camera's, which @@ -86,24 +89,28 @@ impl BindChecks<'_, '_> { /// spell has no row to gate on. fn unit_binds(&self, spell_id: u32, word: u16, entity: Entity) -> bool { let target_store = self.stores.get(entity).ok(); - let target_owner_store = target_store - .and_then(|store| { - store - .0 - .unit_owner(benilla_protocol::messages::OwnerFallback::CreatedBy) - }) - .and_then(|guid| self.index.as_ref()?.0.get(&guid).copied()) - .and_then(|owner| self.stores.get(owner).ok()); + let owner_store = + |store| super::cast_target::owner_store(store, self.index.as_deref(), &self.stores); + let me = self.self_q.iter().next(); + let self_store = me.and_then(|(_, store)| store); let rel = super::cast_target::TargetRelations { target_store, - target_owner_store, - self_store: self.self_q.iter().next().and_then(|(_, store)| store), + target_owner_store: owner_store(target_store), + self_store, factions: self.factions.as_deref(), reputations: &self.reputations, types: crate::creature_type::CreatureTypeSources { names: self.names.as_deref(), forms: self.spells.as_deref().map(|s| &s.forms), }, + group: super::group_relation::GroupInputs { + self_guid: me + .and_then(|(me, _)| self.guids.get(me).ok()) + .map(|guid| guid.0), + target_guid: self.guids.get(entity).ok().map(|guid| guid.0), + self_owner_store: owner_store(self_store), + roster: self.roster.as_deref(), + }, }; let def = self.spells.as_deref().and_then(|s| s.catalog.get(spell_id)); super::cast_target::unit_binds(def, word, self.is_self(entity), &rel) @@ -866,6 +873,7 @@ mod tests { names: Some(&names), forms: None, }, + group: Default::default(), }, ); assert_eq!( @@ -876,6 +884,232 @@ mod tests { } } + /// The party word (target 35) and the raid word (target 57) over the same three surfaces: the + /// press through [`crate::spell::cast_target::CastTargeting`], the click through + /// `SpellTargetUnit` and the hover verdict through `SpellCanTargetUnit`, each fed the group + /// as `GroupState` holds it and the frame publishes it. A party member, and a pet that + /// answers to one, take both words; a raid member outside the party only the raid word; a + /// friendly player in no group, a friendly creature and a member we cannot assist neither; + /// the caster both. + #[test] + fn the_press_the_click_and_the_hover_bind_the_same_group_members() { + use crate::spell::cast_target::{ + cast_target_mask, resolve_cast_target, CastCandidates, CastTargeting, CastWireTarget, + }; + use crate::spell::group_relation::{fixture as gf, publish_group_roster, GroupRoster}; + use crate::spell::targeting::corpse_fixture as fx; + use benilla_formats::SpellDisplay; + use benilla_protocol::messages::GroupMemberEntry; + use bevy::ecs::system::RunSystemOnce; + + const SPELL: u32 = 3001; + /// The guid a pet answers to. + const OWNER: u64 = 0x20; + #[derive(Clone, Copy)] + enum Standing { + None, + /// In our subgroup, so in a party slot. + Party, + /// In the raid roster and another subgroup. + Raid, + } + use Standing::{None as Nowhere, Party, Raid}; + + // (label, the candidate, its owner, who stands in the group and where, the caster is + // the candidate, binds under the party word, under the raid word) + #[allow(clippy::type_complexity)] + let cases: Vec<( + &str, + ObjectStore, + Option, + (u64, Standing), + bool, + bool, + bool, + )> = vec![ + ( + "a party member", + gf::player(1), + None, + (ALLY, Party), + false, + true, + true, + ), + ( + "a raid member", + gf::player(1), + None, + (ALLY, Raid), + false, + false, + true, + ), + ( + "a player in no group", + gf::player(1), + None, + (ALLY, Nowhere), + false, + false, + false, + ), + ( + "a party member's pet", + gf::pet_of(OWNER, 1, false), + Some(gf::player(1)), + (OWNER, Party), + false, + true, + true, + ), + ( + "a raid member's pet", + gf::pet_of(OWNER, 1, true), + Some(gf::player(1)), + (OWNER, Raid), + false, + false, + true, + ), + ( + "a friendly creature", + gf::npc(1, gf::PVP), + None, + (ALLY, Nowhere), + false, + false, + false, + ), + ( + "a party member of the other faction", + gf::player(2), + None, + (ALLY, Party), + false, + false, + false, + ), + ( + "the caster", + gf::player(1), + None, + (ME, Nowhere), + true, + true, + true, + ), + ]; + + for (label, store, owner, (grouped, standing), on_self, party, raid) in cases { + for (word_label, implicit, binds) in [("party", 35, party), ("raid", 57, raid)] { + let label = format!("{label}, the {word_label} word"); + let (mut world, rx, ally) = unit_world(10.0); + let me = world + .query_filtered::>() + .single(&world) + .expect("the player"); + world.entity_mut(me).insert(gf::player(1)); + let (entity, guid, token) = if on_self { + (me, ME, "player") + } else { + (ally, ALLY, "target") + }; + if !on_self { + world.entity_mut(ally).insert(store.clone()); + } + if let Some(owner) = &owner { + let owner = world + .spawn((Guid(OWNER), GlobalTransform::default(), owner.clone())) + .id(); + world + .resource_mut::() + .0 + .insert(OWNER, owner); + } + world.insert_resource(fx::factions()); + world.insert_resource(crate::net::SelfGuid(Some(ME))); + world.init_resource::(); + world.init_resource::(); + let member = |flags| GroupMemberEntry { + name: String::new(), + guid: grouped, + status: 0, + flags, + }; + { + let mut group = world.resource_mut::(); + match standing { + Nowhere => {} + Party => group.members = vec![member(0)], + Raid => { + group.group_type = crate::ui_party::GROUPTYPE_RAID; + group.members = vec![member(1)]; + } + } + } + world + .run_system_once(publish_group_roster) + .expect("the roster publishes"); + world.resource_mut::().target = Some(entity); + world.resource_mut::().guid = Some(guid); + + let def = || SpellDisplay { + range_index: 5, + implicit_target_a1: implicit, + ..Default::default() + }; + let word = cast_target_mask(&def()); + world.resource_mut::().catalog = + benilla_formats::SpellCatalog::from_displays(HashMap::from([(SPELL, def())])); + arm(&mut world, SPELL, word); + + // The hover verdict. + world + .run_system_cached(feed_targeting_to_vm) + .expect("the feed runs"); + let hover = world + .non_send_resource::() + .eval::(&format!("return SpellCanTargetUnit({token:?}) == true")) + .expect("a boolean"); + assert_eq!(hover, binds, "{label}: the hover verdict"); + + // The click, through `SpellTargetUnit`. + spell_target_unit(&mut world, token); + let clicked = matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: SPELL, + target: Some(sent), + }) if sent == guid + ); + assert_eq!(clicked, binds, "{label}: the cursor's click"); + + // The press, through the context the action bar builds. + let def = def(); + let pressed = world + .run_system_once(move |targeting: CastTargeting| { + resolve_cast_target( + Some(&def), + &CastCandidates { + selection: Some(guid), + caster: Some(ME), + main_hand_item: None, + }, + false, + &targeting.context().rel, + ) + }) + .expect("the press runs"); + assert_eq!( + pressed == CastWireTarget::Unit(guid), + binds, + "{label}: the press ({pressed:?})" + ); + } + } + } + /// `SpellCanTargetUnit` asks `0x6e6460`'s unit leg per token: nil out of range, inside the /// minimum included, and nil for ourselves under a spell that excludes its caster. #[test] diff --git a/crates/benilla-app/src/spell/targeting/pick.rs b/crates/benilla-app/src/spell/targeting/pick.rs index f74c48f7..78b86e67 100644 --- a/crates/benilla-app/src/spell/targeting/pick.rs +++ b/crates/benilla-app/src/spell/targeting/pick.rs @@ -5,21 +5,20 @@ //! //! - [`PickFlags`]: the unit-shaped bits of the flags, [`PickFlags::of`], and the legs of //! `0x480610` they switch on, [`PickFlags::admits`]. -//! - [`UnitPick`]: the flags and the party slots as the frame began, [`publish_unit_pick`], -//! beside [`super::PicksSelf`] (flag `0x20`) and [`super::CorpsePick`] (flag `0x40`), which own -//! the other two bits the flags carry for these arms. +//! - [`UnitPick`]: the flags as the frame began, [`publish_unit_pick`], beside +//! [`super::PicksSelf`] (flag `0x20`) and [`super::CorpsePick`] (flag `0x40`), which own the +//! other two bits the flags carry for these arms. //! - [`PickChecks`]: what the hover reads per candidate, the relation checks the bind runs -//! ([`crate::spell::cast_target::TargetRelations`]) and the party leg `0x606c20`. +//! ([`crate::spell::cast_target::TargetRelations`]), the party leg `0x606c20` among them. use bevy::ecs::system::SystemParam; use bevy::prelude::*; -use benilla_protocol::messages::{ObjectType, OwnerFallback}; - use super::SpellTargeting; use crate::net::{Guid, GuidIndex, ObjectStore, Reputations, SelfGuid, SelfPlayer}; use crate::spell::bind_gates::unit_alive; -use crate::spell::cast_target::TargetRelations; +use crate::spell::cast_target::{owner_store, TargetRelations}; +use crate::spell::group_relation::{GroupInputs, GroupRoster}; use crate::target::Factions; /// Flags `0x8` and `0x10`, which `0x480780`'s unit and player arms test (`4807e1`, `4807cb`) and @@ -36,9 +35,6 @@ const ALIVE: u32 = 0x10_0000; const DEAD: u32 = 0x20_0000; const LIVENESS: u32 = ALIVE | DEAD; -/// `UNIT_FLAG_PLAYER_CONTROLLED` (vmangos `UnitDefines.h:548`), tested for both units at -/// `606c5e` and `606c76`. -const UNIT_FLAG_PLAYER_CONTROLLED: u32 = 0x8; /// `UNIT_FLAG_IMMUNE_TO_PLAYER` (vmangos `UnitDefines.h:553`): the `[desc+0xa0]` bit 8 that /// `0x480737` refuses while targeting. const UNIT_FLAG_IMMUNE_TO_PLAYER: u32 = 0x100; @@ -122,34 +118,22 @@ impl PickFlags { } } -/// The unit pick's inputs as the frame began, which [`crate::target`]'s hover reads: the flags, -/// `None` outside targeting, where they carry `0x5c` and the filter's three legs never run; and -/// while targeting the party slots, the table `0x4e7f70` reads. The press latches the hover's -/// pick, so both read this. +/// The unit pick's flags as the frame began, which [`crate::target`]'s hover reads: `None` +/// outside targeting, where they carry `0x5c` and the filter's three legs never run. The press +/// latches the hover's pick, so both read this. #[derive(Resource, Default)] pub(crate) struct UnitPick { pub(crate) flags: Option, - /// The guids of [`crate::ui_party::GroupState::party_slots`], empty outside targeting. - party: Vec, } -/// Publish [`UnitPick`] before the input pass, beside [`super::PicksSelf`]. The party slots are -/// read here and not by the hover, so a party command in the input pass never orders against it. -pub(crate) fn publish_unit_pick( - targeting: Res, - group: Option>, - mut pick: ResMut, -) { +/// Publish [`UnitPick`] before the input pass, beside [`super::PicksSelf`]. +pub(crate) fn publish_unit_pick(targeting: Res, mut pick: ResMut) { let flags = targeting .0 .as_ref() .map(|targeting| PickFlags::of(targeting.word)); - let party: Vec = match (flags, group.as_deref()) { - (Some(_), Some(group)) => group.party_slots().map(|m| m.guid).collect(), - _ => Vec::new(), - }; - if pick.flags != flags || pick.party != party { - *pick = UnitPick { flags, party }; + if pick.flags != flags { + pick.flags = flags; } } @@ -165,6 +149,8 @@ pub(crate) struct PickChecks<'w, 's> { reputations: Res<'w, Reputations>, self_guid: Res<'w, SelfGuid>, guids: Query<'w, 's, &'static Guid>, + /// The party slots and raid roster as the frame began; a harness without them has no group. + roster: Option>, } impl PickChecks<'_, '_> { @@ -179,74 +165,24 @@ impl PickChecks<'_, '_> { .iter() .next() .is_some_and(|(me, _)| me == entity); + let self_store = self.self_q.iter().next().and_then(|(_, store)| store); let rel = TargetRelations { target_store: store, // `CanAssist`'s `IsPvP` owner chase: charmer, else creator. - target_owner_store: store - .and_then(|s| s.0.unit_owner(OwnerFallback::CreatedBy)) - .and_then(|owner| self.store_of(owner)), - self_store: self.self_q.iter().next().and_then(|(_, store)| store), + target_owner_store: owner_store(store, self.index.as_deref(), &self.stores), + self_store, factions: self.factions.as_deref(), reputations: &self.reputations, // The pick reads no creature type. types: Default::default(), + group: GroupInputs { + self_guid: self.self_guid.0, + target_guid: self.guids.get(entity).ok().map(|guid| guid.0), + self_owner_store: owner_store(self_store, self.index.as_deref(), &self.stores), + roster: self.roster.as_deref(), + }, }; - flags.admits(is_self, &rel, || self.in_my_party(&rel, entity, is_self)) - } - - /// A guid's descriptor, whatever kind of object holds it (`0x468460`). - fn store_of(&self, guid: u64) -> Option<&ObjectStore> { - let entity = *self.index.as_ref()?.0.get(&guid)?; - self.stores.get(entity).ok() - } - - /// `0x606c20(active player, candidate)`: the candidate is the active player (`606c3d`), or - /// both are player-controlled (`606c4f`-`606c79`) and one side's controlling player - /// ([`Self::controller`], `0x606170`) is the active player and the other's is in his party - /// (`606ca3`-`606d02`, [`Self::in_party`]). A group member's pet is such a unit. - fn in_my_party(&self, rel: &TargetRelations, entity: Entity, is_self: bool) -> bool { - if is_self { - return true; - } - let (Some(me), Some(me_store), Some(store), Ok(&Guid(guid))) = ( - self.self_guid.0, - rel.self_store, - rel.target_store, - self.guids.get(entity), - ) else { - return false; - }; - let both = me_store.0.unit_flags() & store.0.unit_flags(); - if both & UNIT_FLAG_PLAYER_CONTROLLED == 0 { - return false; - } - let (Some(mine), Some(theirs)) = - (self.controller(me_store, me), self.controller(store, guid)) - else { - return false; - }; - mine == me && self.in_party(theirs) || theirs == me && self.in_party(mine) - } - - /// `0x606170`: the player a unit answers to, its charmer else its creator (`606170`-`60619f`) - /// or the unit itself when neither is set, and only when that object is a player (`6061c6`- - /// `6061cf`, `TYPEMASK_PLAYER`); a controller that has not streamed answers none. - fn controller(&self, store: &ObjectStore, guid: u64) -> Option { - let owner = store.0.unit_owner(OwnerFallback::CreatedBy).unwrap_or(guid); - let owner_store = if owner == guid { - Some(store) - } else { - self.store_of(owner) - }; - owner_store - .is_some_and(|s| s.0.object_type() == Some(ObjectType::Player)) - .then_some(owner) - } - - /// `0x4e7f70`: a nonzero guid that is the active player's (`4e7f84`) or one of the four - /// party slots' (`4e7f96`-`4e7fb0`, [`UnitPick::party`]). - fn in_party(&self, guid: u64) -> bool { - guid != 0 && (self.self_guid.0 == Some(guid) || self.pick.party.contains(&guid)) + flags.admits(is_self, &rel, || rel.in_party(is_self)) } } @@ -254,6 +190,7 @@ impl PickChecks<'_, '_> { mod tests { use super::*; use crate::net::{GuidIndex, Reputations}; + use crate::spell::group_relation::publish_group_roster; use crate::spell::targeting::corpse_fixture as fx; use benilla_protocol::messages::GroupMemberEntry; use benilla_protocol::ObjectFields; @@ -325,6 +262,7 @@ mod tests { factions: Some(&factions), reputations: &Reputations(Vec::new()), types: Default::default(), + group: Default::default(), }; PickFlags::of(word).admits(is_self, &rel, in_party) } @@ -428,6 +366,7 @@ mod tests { factions: Some(&factions), reputations: &Reputations(Vec::new()), types: Default::default(), + group: Default::default(), }; assert!(!PickFlags::of(UNIT).admits(false, &rel, || false)); assert!(PickFlags::of(UNIT | DEAD_ONLY).admits(false, &rel, || true)); @@ -504,6 +443,7 @@ mod tests { fn party_world() -> (World, Entity) { let mut world = World::new(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.insert_resource(fx::factions()); world.insert_resource(Reputations(Vec::new())); @@ -552,6 +492,9 @@ mod tests { world .run_system_once(publish_unit_pick) .expect("the word publishes"); + world + .run_system_once(publish_group_roster) + .expect("the roster publishes"); world .run_system_once(move |checks: PickChecks, stores: Query<&ObjectStore>| { checks.admits_unit(entity, stores.get(entity).ok())