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:
co-authored by
Claude Opus 5.5
parent
3b3ae850fa
commit
840719fc98
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user