spell bind: BindTarget's party and raid arms ask 0x606c20 and 0x606d20 and CanAssist, so a group member binds Power Word: Shield, the Blessings and the Seals, and the world pick shares the party predicate (#655)

The binder cleared the targeting word's party bit `0x8` (implicit target 35) and raid bit `0x4` (targets 57 and 61) only for the caster, so no group member ever bound these spells. Affected spells:
- Power Word: Shield;
- the Blessings of Protection, Salvation and Sacrifice;
- Amplify Magic, Dampen Magic, Divine Intervention;
- every Greater Blessing;
- Seal of Protection and Seal of Sacrifice.

With a party member selected, they fell back to you or raised a cursor whose click bound nothing.

**The reference.** `BindTarget 0x6e5b40`'s party arm (`6e5cad`-`6e5cec`) asks `0x606c20(caster, unit)` and then `CanAssist 0x6066f0`. Its raid arm (`6e5cf1`-`6e5d2f`) asks `0x606d20` and `CanAssist`. The two predicates differ only in their membership call:
- The unit itself answers true.
- Otherwise both units need `UNIT_FIELD_FLAGS & 0x8`.
- Each side's controlling player is `0x606170`: charmer, else creator, else itself, and it must be a player.
- Either controller being you makes the answer the other's membership, tried both ways round.
- Membership is `0x4e7f70` for `0x606c20`: you or one of the four party slots. It is `0x4918e0` for `0x606d20`: that, or a raid roster row (`0x4baee0`).

**Changes:**
- `spell/group_relation.rs` builds both predicates as `TargetRelations::in_party` and `in_group`, over one controller helper.
- `GroupRoster` holds the party slots and the raid roster, and is published from `GroupState` before the input pass. No cast or hover system reads `GroupState` itself, so there are no new schedule orders.
- The binder's party and raid arms clear their bits for an assistable group member.
- The world pick's party leg (`4806b8`) shares `in_party`.

**Tests:**
- `group_relation.rs` covers the roster and each predicate leg: both player-controlled, pets through their owner, and the mirror.
- `the_shipped_party_and_raid_spells_bind_a_party_member` runs 11 real `Spell.dbc` rows.
- `the_press_the_click_and_the_hover_bind_the_same_group_members` runs 8 candidates through the party and raid words on the press, `SpellTargetUnit` and `SpellCanTargetUnit`, fed through the real `GroupState` publish. The candidates are a party member, a raid-only member, a stranger, pets, a friendly creature, the other faction and yourself.

Fixes #648

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 03:36:20 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3b3ae850fa
commit 840719fc98
7 changed files with 1094 additions and 115 deletions
@@ -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 }),
@@ -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,
+42 -15
View File
@@ -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<Res<'w, Factions>>,
reputations: Res<'w, Reputations>,
/// The party slots and raid roster as the frame began; a harness without them has no group.
roster: Option<Res<'w, GroupRoster>>,
/// The creature-type gate's template cache and form table; a harness without them resolves
/// no type.
names: Option<Res<'w, NameCache>>,
@@ -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(),
}
}
}
@@ -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<u64>,
/// The raid roster `[[0xb712a8 + 4i]]`, `i < [0xb713e0]` (`4baee0`), us included; none outside
/// a raid.
pub(crate) raid: Vec<u64>,
}
impl GroupRoster {
/// `0x4e7f70`: a nonzero guid that is the active player's (`4e7f84`) or in a party slot.
fn in_party(&self, me: Option<u64>, 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<u64>, 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<Res<GroupState>>,
self_guid: Res<SelfGuid>,
mut roster: ResMut<GroupRoster>,
) {
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<u64>,
/// The candidate's guid.
pub(crate) target_guid: Option<u64>,
/// 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>, 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<u64> {
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::<GroupRoster>())
};
let mut world = World::new();
world.init_resource::<GroupRoster>();
world.init_resource::<GroupState>();
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::<GroupState>().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::<GroupState>();
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::<GroupState>().slots_emptied = true;
assert_eq!(
publish(&mut world),
GroupRoster {
party: Vec::new(),
raid: vec![ME, MEMBER, RAIDER, STRANGER],
}
);
}
}
+9 -1
View File
@@ -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::<targeting::PicksSelf>()
.init_resource::<targeting::CorpsePick>()
.init_resource::<targeting::UnitPick>()
.init_resource::<group_relation::GroupRoster>()
.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.
+244 -10
View File
@@ -62,8 +62,11 @@ pub(crate) struct BindChecks<'w, 's> {
(Entity, Option<&'static crate::net::ObjectStore>),
With<crate::net::SelfPlayer>,
>,
guids: Query<'w, 's, &'static crate::net::Guid>,
factions: Option<Res<'w, crate::target::Factions>>,
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<Res<'w, super::group_relation::GroupRoster>>,
names: Option<Res<'w, crate::names::NameCache>>,
/// 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<ObjectStore>,
(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::<Entity, With<SelfPlayer>>()
.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::<crate::net::GuidIndex>()
.0
.insert(OWNER, owner);
}
world.insert_resource(fx::factions());
world.insert_resource(crate::net::SelfGuid(Some(ME)));
world.init_resource::<crate::spell::AutoSelfCast>();
world.init_resource::<GroupRoster>();
let member = |flags| GroupMemberEntry {
name: String::new(),
guid: grouped,
status: 0,
flags,
};
{
let mut group = world.resource_mut::<crate::ui_party::GroupState>();
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::<crate::target::Selection>().target = Some(entity);
world.resource_mut::<crate::target::Selection>().guid = Some(guid);
let def = || SpellDisplay {
range_index: 5,
implicit_target_a1: implicit,
..Default::default()
};
let word = cast_target_mask(&def());
world.resource_mut::<crate::ui_action::Spells>().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::<UiScript>()
.eval::<bool>(&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]
+32 -89
View File
@@ -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<PickFlags>,
/// The guids of [`crate::ui_party::GroupState::party_slots`], empty outside targeting.
party: Vec<u64>,
}
/// 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<SpellTargeting>,
group: Option<Res<crate::ui_party::GroupState>>,
mut pick: ResMut<UnitPick>,
) {
/// Publish [`UnitPick`] before the input pass, beside [`super::PicksSelf`].
pub(crate) fn publish_unit_pick(targeting: Res<SpellTargeting>, mut pick: ResMut<UnitPick>) {
let flags = targeting
.0
.as_ref()
.map(|targeting| PickFlags::of(targeting.word));
let party: Vec<u64> = 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<Res<'w, GroupRoster>>,
}
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<u64> {
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::<UnitPick>();
world.init_resource::<GroupRoster>();
world.init_resource::<SpellTargeting>();
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())