world pick: while a spell targets, a unit or player the word cannot take is no candidate, so the pick reaches the one behind it (0x481050, 0x480610) (#652)
While a spell was targeting, the world pick admitted every unit and player. A unit the spell could not take, standing in front of one it could, took the hover and the click: - a hostile or dead unit in front of a friend under a heal; - a living player standing on a corpse under Resurrection; - any unit in front of a chest under a lock word. In the reference, both world picks build their flags through `0x481050`. While targeting (`4810f1`-`481187`): - a word with unit bits (`0x6e6180`, `& 0x878e`) adds units and players (`0x18`); - three relation flags follow: party `0x10000` (`0x6e61e0`, `& 0x40c`), assist `0x20000` (`0x6e6200`, `& 0x500`) and attack `0x40000` (`0x6e6210`, `& 0x480`); - a dead-target word (`& 0x8600`) adds `0x200040`, and any other adds `0x100000`; - `0x6e6240` (`0x8000`) adds assist, and `0x6e6250` (`0x200`) adds attack. `0x480610` then drops a unit or player candidate when: - none of the relations the flags ask for holds (`480686`): `0x606c20`, `CanAssist 0x6066f0` or `CanAttack 0x606980`, toward the active player; - its liveness is not the one asked for (`4806fd`, signed `UNIT_FIELD_HEALTH` above 0); - it has `UNIT_FIELD_FLAGS & 0x100` (`480737`). A word with no unit bits puts no unit in the pick at all (`0x480780`'s unit and player arms need `0x8` and `0x10`). - `spell/targeting/pick.rs`: `PickFlags::of(word)` builds the flags as `0x481050` does, and `PickFlags::admits` applies the three legs. - `0x606c20` is built here, from the published party slots. - `TargetHoverState::is_candidate` routes units and players through it on both the posed pass and the box fallback. The press latches the same pick. - `CanAssist` and `CanAttack` toward a candidate become `TargetRelations::assistable` and `attackable`, shared with the binder's arms. The binder's behaviour is unchanged. - The alive test is `bind_gates::unit_alive`. Tests: - `pick.rs` covers the flag table, each relation, Resurrection, the enemy corpse word, `0x100`, the caster, and `0x606c20` for self, a party member, pets and a stranger. - `hover.rs` stands two units on the cursor's ray. The rear unit wins when the front one is refused. A lone refused unit gives no pick, and a word with no unit bits picks no unit. The press latches the rear unit. - Outside targeting the front unit still wins. Fixes #464 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
4573596e27
commit
ec5083f471
@@ -14,7 +14,8 @@ use super::cast_target::{TargetRelations, CORPSE_WORD_BITS, TF_EXPLICIT_GATE};
|
||||
/// at `6e5c16`-`6e5c1d`.
|
||||
const UNIT_FLAG_NON_ATTACKABLE_2: u32 = 0x0001_0000;
|
||||
|
||||
/// The bind's alive test (`6e5c5f`, `6e5e55`): signed `UNIT_FIELD_HEALTH` above 0. An absent field
|
||||
/// The bind's alive test (`6e5c5f`, `6e5e55`) and the world pick's (`4806fd`, `480713 setg`,
|
||||
/// [`super::targeting::PickFlags::admits`]): signed `UNIT_FIELD_HEALTH` above 0. An absent field
|
||||
/// reads 0, as the reference's descriptor does, so a dead unit whose create sent no health is dead.
|
||||
pub(super) fn unit_alive(fields: &ObjectFields) -> bool {
|
||||
fields.unit_health().unwrap_or(0) as i32 > 0
|
||||
|
||||
@@ -124,6 +124,33 @@ pub(crate) struct TargetRelations<'a> {
|
||||
pub(crate) types: CreatureTypeSources<'a>,
|
||||
}
|
||||
|
||||
impl TargetRelations<'_> {
|
||||
/// `CanAssist 0x6066f0` toward the candidate, which the caster always is for himself. The
|
||||
/// binder's assist arm and the world pick's relation leg both ask it.
|
||||
pub(super) fn assistable(&self, is_self: bool) -> bool {
|
||||
is_self
|
||||
|| can_assist(
|
||||
self.target_store,
|
||||
self.factions,
|
||||
self.reputations,
|
||||
self.self_store,
|
||||
|_| self.target_owner_store.cloned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `CanAttack 0x606980` toward the candidate, never the caster. The binder's enemy arm and the
|
||||
/// world pick's relation leg both ask it.
|
||||
pub(super) fn attackable(&self, is_self: bool) -> bool {
|
||||
!is_self
|
||||
&& can_attack(
|
||||
self.target_store,
|
||||
self.factions,
|
||||
self.reputations,
|
||||
self.self_store,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The targeting inputs [`super::send_spell_cast`] resolves with, built by both cast callers.
|
||||
pub(crate) struct CastContext<'a> {
|
||||
pub(crate) selection_guid: Option<u64>,
|
||||
@@ -317,18 +344,12 @@ 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`) accept
|
||||
/// only the player; the corpse check (`0x6067d0`) is assistable with health 0 here.
|
||||
/// 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.
|
||||
fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16 {
|
||||
let mut word = word;
|
||||
let assist = is_self
|
||||
|| can_assist(
|
||||
rel.target_store,
|
||||
rel.factions,
|
||||
rel.reputations,
|
||||
rel.self_store,
|
||||
|_| rel.target_owner_store.cloned(),
|
||||
);
|
||||
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 {
|
||||
word &= !TF_UNIT_PARTY;
|
||||
@@ -339,15 +360,7 @@ fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16
|
||||
if word & TF_UNIT_ASSIST != 0 && assist {
|
||||
word &= !TF_UNIT_ASSIST;
|
||||
}
|
||||
if word & TF_UNIT_ENEMY != 0
|
||||
&& !is_self
|
||||
&& can_attack(
|
||||
rel.target_store,
|
||||
rel.factions,
|
||||
rel.reputations,
|
||||
rel.self_store,
|
||||
)
|
||||
{
|
||||
if word & TF_UNIT_ENEMY != 0 && rel.attackable(is_self) {
|
||||
word &= !TF_UNIT_ENEMY;
|
||||
}
|
||||
// Bit 1 has no relation check: any unit binds. A dead unit's bind clears bit 10 (`6e5d89`,
|
||||
|
||||
@@ -69,6 +69,7 @@ impl Plugin for SpellPlugin {
|
||||
.init_resource::<targeting::EnchantConfirmItem>()
|
||||
.init_resource::<targeting::PicksSelf>()
|
||||
.init_resource::<targeting::CorpsePick>()
|
||||
.init_resource::<targeting::UnitPick>()
|
||||
.add_observer(cast_target::on_cvar)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -89,6 +90,7 @@ impl Plugin for SpellPlugin {
|
||||
(
|
||||
targeting::publish_picks_self,
|
||||
targeting::publish_corpse_pick,
|
||||
targeting::publish_unit_pick,
|
||||
)
|
||||
.in_set(UiFeed),
|
||||
// The item-target commit (`0x495d60`): after the input pass so a bag click
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
//! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`),
|
||||
//! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit`
|
||||
//! ([`ScriptCursor::spell_target_unit`]), and whose corpse arm is [`corpse`]'s.
|
||||
//! - [`pick`]: the world pick's unit and player filter, `0x481050`'s flags from the word and each
|
||||
//! candidate's `0x480610`, so a unit the word cannot take is out of the hover and the click.
|
||||
//! - [`item`]: the bag click (`PickupContainerItem 0x4f9b30`) and the paper-doll click
|
||||
//! (`0x4c7300`), both `IsTargeting`, `TargetingWantsItem 0x6e6330`, then `0x495d60`, whose
|
||||
//! confirm popups park the clicked guid (`0xb4e3c0`) with the word still standing.
|
||||
@@ -29,6 +31,7 @@
|
||||
mod corpse;
|
||||
mod cursor;
|
||||
mod item;
|
||||
mod pick;
|
||||
mod world;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -36,6 +39,7 @@ pub(crate) use corpse::fixture as corpse_fixture;
|
||||
pub(crate) use corpse::{corpse_pick_admits, publish_corpse_pick, CorpsePick};
|
||||
pub(crate) use cursor::{drive_targeting_cursor, ground_cast_radius};
|
||||
pub(crate) use item::{commit_item_cast_on_pick, EnchantConfirmItem};
|
||||
pub(crate) use pick::{publish_unit_pick, PickChecks, UnitPick};
|
||||
pub(crate) use world::{commit_ground_cast_on_click, commit_object_cast_on_click};
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
//! The world pick's unit and player filter while a word stands. The pick flags come from the
|
||||
//! word alone (`0x481050`'s targeting arm, `4810f1`-`481187`), and each unit and player the trace
|
||||
//! finds meets them in `0x480610`, where `0x480780`'s unit and player arms send it. A unit the
|
||||
//! spell cannot take is no candidate, so the cursor and the click reach the one behind it.
|
||||
//!
|
||||
//! - [`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.
|
||||
//! - [`PickChecks`]: what the hover reads per candidate, the relation checks the bind runs
|
||||
//! ([`crate::spell::cast_target::TargetRelations`]) and the party leg `0x606c20`.
|
||||
|
||||
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::target::Factions;
|
||||
|
||||
/// Flags `0x8` and `0x10`, which `0x480780`'s unit and player arms test (`4807e1`, `4807cb`) and
|
||||
/// `0x481050` sets together (`481116 or esi,0x18`) for a word with unit bits.
|
||||
const UNITS: u32 = 0x18;
|
||||
/// The relation flags: `0x606c20` (`0x6e61e0`), `CanAssist 0x6066f0` (`0x6e6200`) and `CanAttack
|
||||
/// 0x606980` (`0x6e6210`), of which `0x480610` needs one (`480686`).
|
||||
const PARTY: u32 = 0x1_0000;
|
||||
const ASSIST: u32 = 0x2_0000;
|
||||
const ATTACK: u32 = 0x4_0000;
|
||||
const RELATIONS: u32 = PARTY | ASSIST | ATTACK;
|
||||
/// The liveness flags: the word takes a living candidate, or a dead one (`4806fd`).
|
||||
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;
|
||||
|
||||
/// The unit-shaped bits of the pick flags `0x481050` builds from the standing word. The terrain
|
||||
/// (`0x3`), GameObject (`0x4`), self (`0x20`) and corpse (`0x40`) bits belong to their own arms.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PickFlags(u32);
|
||||
|
||||
impl PickFlags {
|
||||
/// `0x481050`'s targeting arm over the word `0xcecac0`:
|
||||
///
|
||||
/// - units and players (`0x18`) for a word in `0x878e` (`0x6e6180`), and inside that arm the
|
||||
/// relation flags for a word in `0x40c` (`0x6e61e0`, party), `0x500` (`0x6e6200`, assist)
|
||||
/// and `0x480` (`0x6e6210`, enemy), then the liveness flag: dead for a word in `0x8600`
|
||||
/// (`0x6e6230`, with the corpse flag, `0x200040`), else alive (`0x100000`);
|
||||
/// - after it, the ally corpse bit `0x8000` (`0x6e6240`) adds the assist flag and the enemy
|
||||
/// corpse bit `0x200` (`0x6e6250`) the attack flag (`0x20040`, `0x40040`).
|
||||
pub(crate) fn of(word: u16) -> Self {
|
||||
let word = u32::from(word);
|
||||
let mut flags = 0;
|
||||
if word & 0x878e != 0 {
|
||||
flags |= UNITS;
|
||||
if word & 0x040c != 0 {
|
||||
flags |= PARTY;
|
||||
}
|
||||
if word & 0x0500 != 0 {
|
||||
flags |= ASSIST;
|
||||
}
|
||||
if word & 0x0480 != 0 {
|
||||
flags |= ATTACK;
|
||||
}
|
||||
flags |= if word & 0x8600 != 0 { DEAD } else { ALIVE };
|
||||
}
|
||||
if word & 0x8000 != 0 {
|
||||
flags |= ASSIST;
|
||||
}
|
||||
if word & 0x0200 != 0 {
|
||||
flags |= ATTACK;
|
||||
}
|
||||
Self(flags)
|
||||
}
|
||||
|
||||
/// Whether a unit or player is a candidate of the pick: `0x480780`'s arms need the unit
|
||||
/// flags, then `0x480610`'s three legs:
|
||||
///
|
||||
/// - `480686`: with a relation flag set, one of the set relations must hold. The party leg
|
||||
/// `in_party` is `0x606c20`, run only when asked.
|
||||
/// - `4806fd`: with a liveness flag set, a living candidate needs `0x100000` and a dead one
|
||||
/// `0x200000`. Alive is the signed `UNIT_FIELD_HEALTH` above 0 (`480713 setg`), the bind's
|
||||
/// own test ([`unit_alive`]), and a candidate with no descriptor reads dead.
|
||||
/// - `480737`: while targeting, `UNIT_FIELD_FLAGS & 0x100` refuses.
|
||||
///
|
||||
/// The self exclusions that come first (`48062c`-`480683`) are the caller's.
|
||||
pub(crate) fn admits(
|
||||
self,
|
||||
is_self: bool,
|
||||
rel: &TargetRelations,
|
||||
in_party: impl FnOnce() -> bool,
|
||||
) -> bool {
|
||||
let flags = self.0;
|
||||
if flags & UNITS == 0 {
|
||||
return false;
|
||||
}
|
||||
let related = flags & RELATIONS == 0
|
||||
|| flags & PARTY != 0 && in_party()
|
||||
|| flags & ASSIST != 0 && rel.assistable(is_self)
|
||||
|| flags & ATTACK != 0 && rel.attackable(is_self);
|
||||
if !related {
|
||||
return false;
|
||||
}
|
||||
if flags & LIVENESS != 0 {
|
||||
let alive = rel.target_store.is_some_and(|s| unit_alive(&s.0));
|
||||
let needed = if alive { ALIVE } else { DEAD };
|
||||
if flags & needed == 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
rel.target_store
|
||||
.is_none_or(|s| s.0.unit_flags() & UNIT_FLAG_IMMUNE_TO_PLAYER == 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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>,
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// What the hover asks of a unit or player candidate while a word stands: the bind's relation
|
||||
/// checks over the same inputs, and the party leg.
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct PickChecks<'w, 's> {
|
||||
pick: Res<'w, UnitPick>,
|
||||
stores: Query<'w, 's, &'static ObjectStore>,
|
||||
index: Option<Res<'w, GuidIndex>>,
|
||||
self_q: Query<'w, 's, (Entity, Option<&'static ObjectStore>), With<SelfPlayer>>,
|
||||
factions: Option<Res<'w, Factions>>,
|
||||
reputations: Res<'w, Reputations>,
|
||||
self_guid: Res<'w, SelfGuid>,
|
||||
guids: Query<'w, 's, &'static Guid>,
|
||||
}
|
||||
|
||||
impl PickChecks<'_, '_> {
|
||||
/// Whether this unit or player is a candidate of the pick: always outside targeting,
|
||||
/// otherwise [`PickFlags::admits`]. `store` is its descriptor, if streamed.
|
||||
pub(crate) fn admits_unit(&self, entity: Entity, store: Option<&ObjectStore>) -> bool {
|
||||
let Some(flags) = self.pick.flags else {
|
||||
return true;
|
||||
};
|
||||
let is_self = self
|
||||
.self_q
|
||||
.iter()
|
||||
.next()
|
||||
.is_some_and(|(me, _)| me == entity);
|
||||
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),
|
||||
factions: self.factions.as_deref(),
|
||||
reputations: &self.reputations,
|
||||
// The pick reads no creature type.
|
||||
types: Default::default(),
|
||||
};
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::{GuidIndex, Reputations};
|
||||
use crate::spell::targeting::corpse_fixture as fx;
|
||||
use benilla_protocol::messages::GroupMemberEntry;
|
||||
use benilla_protocol::ObjectFields;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// Absolute descriptor indices: `OBJECT_FIELD_TYPE`, `UNIT_FIELD_HEALTH`,
|
||||
/// `UNIT_FIELD_FLAGS`, `UNIT_FIELD_CHARMEDBY` and `UNIT_FIELD_CREATEDBY`.
|
||||
const TYPE: u16 = 2;
|
||||
const HEALTH: u16 = 22;
|
||||
const FLAGS: u16 = 46;
|
||||
const CHARMED_BY: u16 = 10;
|
||||
const CREATED_BY: u16 = 14;
|
||||
/// `UNIT_FIELD_FACTIONTEMPLATE`, on [`fx::factions`]' rows 1 (Human) and 2 (Orc).
|
||||
const TEMPLATE: u16 = 35;
|
||||
const PLAYER_TYPE: u32 = 0x19;
|
||||
|
||||
// The words: `TARGET_FLAG_*`.
|
||||
const UNIT: u16 = 0x0002;
|
||||
const RAID: u16 = 0x0004;
|
||||
const PARTY_WORD: u16 = 0x0008;
|
||||
const ENEMY: u16 = 0x0080;
|
||||
const ASSIST_WORD: u16 = 0x0100;
|
||||
const CORPSE_ENEMY: u16 = 0x0200;
|
||||
const DEAD_ONLY: u16 = 0x0400;
|
||||
const CORPSE_ALLY: u16 = 0x8000;
|
||||
|
||||
fn unit(pairs: &[(u16, u32)]) -> ObjectStore {
|
||||
let mut pairs = pairs.to_vec();
|
||||
if !pairs.iter().any(|&(index, _)| index == HEALTH) {
|
||||
pairs.push((HEALTH, 100));
|
||||
}
|
||||
ObjectStore(ObjectFields::from_pairs(&pairs))
|
||||
}
|
||||
|
||||
/// A player of the caster's faction, which `CanAssist` passes on its player-controlled arm.
|
||||
fn friend(health: u32) -> ObjectStore {
|
||||
friend_with(health, 0)
|
||||
}
|
||||
|
||||
fn friend_with(health: u32, flags: u32) -> ObjectStore {
|
||||
unit(&[(TEMPLATE, 1), (FLAGS, 0x8 | flags), (HEALTH, health)])
|
||||
}
|
||||
|
||||
/// An Orc creature: hostile to the human caster and not player-controlled.
|
||||
fn foe(health: u32) -> ObjectStore {
|
||||
foe_with(health, 0)
|
||||
}
|
||||
|
||||
fn foe_with(health: u32, flags: u32) -> ObjectStore {
|
||||
unit(&[(TEMPLATE, 2), (FLAGS, flags), (HEALTH, health)])
|
||||
}
|
||||
|
||||
fn admits(word: u16, candidate: &ObjectStore) -> bool {
|
||||
admits_as(word, false, candidate, || false)
|
||||
}
|
||||
|
||||
fn admits_as(
|
||||
word: u16,
|
||||
is_self: bool,
|
||||
candidate: &ObjectStore,
|
||||
in_party: impl FnOnce() -> bool,
|
||||
) -> bool {
|
||||
let factions = fx::factions();
|
||||
let me = fx::caster(1.5);
|
||||
let rel = TargetRelations {
|
||||
target_store: Some(candidate),
|
||||
target_owner_store: None,
|
||||
self_store: Some(&me),
|
||||
factions: Some(&factions),
|
||||
reputations: &Reputations(Vec::new()),
|
||||
types: Default::default(),
|
||||
};
|
||||
PickFlags::of(word).admits(is_self, &rel, in_party)
|
||||
}
|
||||
|
||||
/// `0x481050`'s targeting arm, bit by bit: which unit-shaped flags each word sets.
|
||||
#[test]
|
||||
fn the_word_builds_the_flags_as_the_chooser_does() {
|
||||
let flags = |word| PickFlags::of(word).0;
|
||||
assert_eq!(flags(0), 0, "no word, no unit flag");
|
||||
for word in [0x0010, 0x0020, 0x0040, 0x4000, 0x0800, 0x4800] {
|
||||
assert_eq!(
|
||||
flags(word),
|
||||
0,
|
||||
"{word:#06x} has no unit bit: no unit is picked"
|
||||
);
|
||||
}
|
||||
assert_eq!(flags(UNIT), UNITS | ALIVE);
|
||||
assert_eq!(flags(ASSIST_WORD), UNITS | ASSIST | ALIVE);
|
||||
assert_eq!(flags(ENEMY), UNITS | ATTACK | ALIVE);
|
||||
assert_eq!(flags(PARTY_WORD), UNITS | PARTY | ALIVE);
|
||||
assert_eq!(
|
||||
flags(RAID),
|
||||
UNITS | PARTY | ALIVE,
|
||||
"raid asks `0x606c20` too"
|
||||
);
|
||||
// Resurrection: the ally corpse bit takes the assist flag through `0x6e6240`, and a word
|
||||
// in `0x8600` takes the dead flag instead of the alive one.
|
||||
assert_eq!(flags(CORPSE_ALLY), UNITS | ASSIST | DEAD);
|
||||
assert_eq!(flags(CORPSE_ENEMY), UNITS | ATTACK | DEAD);
|
||||
// Skinning's word: all three relations, and dead.
|
||||
assert_eq!(
|
||||
flags(UNIT | DEAD_ONLY),
|
||||
UNITS | PARTY | ASSIST | ATTACK | DEAD
|
||||
);
|
||||
assert_eq!(flags(ASSIST_WORD | ENEMY), UNITS | ASSIST | ATTACK | ALIVE);
|
||||
}
|
||||
|
||||
/// A heal (the assist word) takes a friend and drops a foe.
|
||||
#[test]
|
||||
fn an_assist_word_drops_the_hostile_unit_and_keeps_the_friendly_one() {
|
||||
assert!(admits(ASSIST_WORD, &friend(100)));
|
||||
assert!(!admits(ASSIST_WORD, &foe(100)));
|
||||
// A plain unit word has no relation flag: a foe is a candidate.
|
||||
assert!(admits(UNIT, &foe(100)));
|
||||
assert!(admits(UNIT, &friend(100)));
|
||||
}
|
||||
|
||||
/// An attack word (the enemy bit) takes a foe and drops a friend.
|
||||
#[test]
|
||||
fn an_enemy_word_drops_the_friendly_unit_and_keeps_the_hostile_one() {
|
||||
assert!(admits(ENEMY, &foe(100)));
|
||||
assert!(!admits(ENEMY, &friend(100)));
|
||||
// Neither is the caster's own body: never attackable, so never a candidate.
|
||||
assert!(!admits_as(ENEMY, true, &friend(100), || false));
|
||||
}
|
||||
|
||||
/// `4806fd`: a living candidate needs the alive flag, a dead one the dead flag.
|
||||
#[test]
|
||||
fn a_resurrection_word_drops_the_living_and_keeps_the_dead() {
|
||||
assert!(admits(CORPSE_ALLY, &friend(0)), "a dead friend");
|
||||
assert!(!admits(CORPSE_ALLY, &friend(100)), "a living friend");
|
||||
// The assist flag `0x6e6240` adds still applies: a dead foe is no ally's corpse.
|
||||
assert!(!admits(CORPSE_ALLY, &foe(0)), "a dead foe");
|
||||
// The dead-only word: a dead unit of any relation, never a living one.
|
||||
assert!(admits(UNIT | DEAD_ONLY, &foe(0)));
|
||||
assert!(admits(UNIT | DEAD_ONLY, &friend(0)));
|
||||
assert!(!admits(UNIT | DEAD_ONLY, &foe(100)));
|
||||
// Every other unit word takes the living only.
|
||||
assert!(
|
||||
!admits(ASSIST_WORD, &friend(0)),
|
||||
"a heal never takes a corpse"
|
||||
);
|
||||
assert!(!admits(UNIT, &foe(0)));
|
||||
// A candidate with no health streamed is dead, as in the bind.
|
||||
let bare = unit(&[(TEMPLATE, 1), (FLAGS, 0x8), (HEALTH, 0)]);
|
||||
assert!(admits(CORPSE_ALLY, &bare));
|
||||
}
|
||||
|
||||
/// The enemy corpse bit: `0x6e6250` adds the attack flag, so only a dead unit that can be
|
||||
/// attacked is a candidate; a dead friend and a living foe are not.
|
||||
#[test]
|
||||
fn the_enemy_corpse_word_takes_the_dead_that_can_be_attacked() {
|
||||
assert!(admits(CORPSE_ENEMY, &foe(0)), "a dead foe");
|
||||
assert!(!admits(CORPSE_ENEMY, &friend(0)), "a dead friend");
|
||||
assert!(!admits(CORPSE_ENEMY, &foe(100)), "a living foe");
|
||||
}
|
||||
|
||||
/// The health field is signed and an absent descriptor is dead, as the bind reads it.
|
||||
#[test]
|
||||
fn the_alive_test_is_the_binds() {
|
||||
let negative = friend(0x8000_0000);
|
||||
assert!(admits(CORPSE_ALLY, &negative), "signed health below 0");
|
||||
assert!(!admits(ASSIST_WORD, &negative));
|
||||
let empty = ObjectStore(ObjectFields::default());
|
||||
let factions = fx::factions();
|
||||
let me = fx::caster(1.5);
|
||||
let rel = TargetRelations {
|
||||
target_store: Some(&empty),
|
||||
target_owner_store: None,
|
||||
self_store: Some(&me),
|
||||
factions: Some(&factions),
|
||||
reputations: &Reputations(Vec::new()),
|
||||
types: Default::default(),
|
||||
};
|
||||
assert!(!PickFlags::of(UNIT).admits(false, &rel, || false));
|
||||
assert!(PickFlags::of(UNIT | DEAD_ONLY).admits(false, &rel, || true));
|
||||
}
|
||||
|
||||
/// `480737`: while targeting, `UNIT_FIELD_FLAGS & 0x100` is refused, whatever the word.
|
||||
#[test]
|
||||
fn a_unit_immune_to_players_is_never_a_candidate() {
|
||||
// The word, and the subject it takes: (friend or foe, health).
|
||||
type Make = fn(u32, u32) -> ObjectStore;
|
||||
let cases: [(u16, Make, u32); 5] = [
|
||||
(UNIT, friend_with, 100),
|
||||
(ASSIST_WORD, friend_with, 100),
|
||||
(ENEMY, foe_with, 100),
|
||||
(CORPSE_ALLY, friend_with, 0),
|
||||
(UNIT | DEAD_ONLY, foe_with, 0),
|
||||
];
|
||||
for (word, make, health) in cases {
|
||||
assert!(admits(word, &make(health, 0)), "{word:#06x}: the control");
|
||||
assert!(
|
||||
!admits(word, &make(health, UNIT_FLAG_IMMUNE_TO_PLAYER)),
|
||||
"{word:#06x}: flagged"
|
||||
);
|
||||
}
|
||||
// Only bit 8: another bit of the same nibble does not.
|
||||
let other = unit(&[(TEMPLATE, 1), (FLAGS, 0x0f8)]);
|
||||
assert!(admits(UNIT, &other));
|
||||
}
|
||||
|
||||
/// The party leg is asked only when its flag is set, and one relation suffices.
|
||||
#[test]
|
||||
fn one_relation_of_those_asked_is_enough() {
|
||||
assert!(admits_as(PARTY_WORD, false, &foe(100), || true));
|
||||
assert!(!admits_as(PARTY_WORD, false, &foe(100), || false));
|
||||
// The assist relation does not stand in for the party flag alone.
|
||||
assert!(!admits_as(PARTY_WORD, false, &friend(100), || false));
|
||||
// A word with both flags: either passes.
|
||||
assert!(admits_as(
|
||||
PARTY_WORD | ASSIST_WORD,
|
||||
false,
|
||||
&friend(100),
|
||||
|| false
|
||||
));
|
||||
assert!(admits_as(
|
||||
PARTY_WORD | ASSIST_WORD,
|
||||
false,
|
||||
&foe(100),
|
||||
|| true
|
||||
));
|
||||
assert!(!admits_as(
|
||||
PARTY_WORD | ASSIST_WORD,
|
||||
false,
|
||||
&foe(100),
|
||||
|| false
|
||||
));
|
||||
// No party flag, no question asked.
|
||||
assert!(admits_as(
|
||||
ASSIST_WORD,
|
||||
false,
|
||||
&friend(100),
|
||||
|| unreachable!()
|
||||
));
|
||||
}
|
||||
|
||||
/// Ourselves: the caster assists himself and never attacks himself, so a heal word takes him
|
||||
/// and an enemy word does not.
|
||||
#[test]
|
||||
fn the_caster_is_his_own_assist_and_never_his_own_attack() {
|
||||
assert!(admits_as(ASSIST_WORD, true, &friend(100), || false));
|
||||
assert!(!admits_as(ENEMY, true, &friend(100), || false));
|
||||
}
|
||||
|
||||
/// The party leg's inputs, standing in a world: us (guid 1) and whoever a case spawns.
|
||||
fn party_world() -> (World, Entity) {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<UnitPick>();
|
||||
world.init_resource::<SpellTargeting>();
|
||||
world.insert_resource(fx::factions());
|
||||
world.insert_resource(Reputations(Vec::new()));
|
||||
world.init_resource::<GuidIndex>();
|
||||
world.init_resource::<crate::ui_party::GroupState>();
|
||||
world.insert_resource(SelfGuid(Some(1)));
|
||||
let me = world
|
||||
.spawn((
|
||||
crate::net::SelfPlayer,
|
||||
Guid(1),
|
||||
GlobalTransform::default(),
|
||||
unit(&[(TYPE, PLAYER_TYPE), (TEMPLATE, 1), (FLAGS, 0x8)]),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<GuidIndex>().0.insert(1, me);
|
||||
(world, me)
|
||||
}
|
||||
|
||||
fn spawn(world: &mut World, guid: u64, store: ObjectStore) -> Entity {
|
||||
let entity = world
|
||||
.spawn((Guid(guid), GlobalTransform::default(), store))
|
||||
.id();
|
||||
world.resource_mut::<GuidIndex>().0.insert(guid, entity);
|
||||
entity
|
||||
}
|
||||
|
||||
fn seat(world: &mut World, guid: u64) {
|
||||
world
|
||||
.resource_mut::<crate::ui_party::GroupState>()
|
||||
.members
|
||||
.push(GroupMemberEntry {
|
||||
name: String::new(),
|
||||
guid,
|
||||
status: 0,
|
||||
flags: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// The pick under `word` and the roster as it stands, published as a frame does.
|
||||
fn asked(world: &mut World, word: u16, entity: Entity) -> bool {
|
||||
world.resource_mut::<SpellTargeting>().enter(
|
||||
fx::RESURRECTION,
|
||||
crate::spell::CastCommit::Spell,
|
||||
word,
|
||||
);
|
||||
world
|
||||
.run_system_once(publish_unit_pick)
|
||||
.expect("the word publishes");
|
||||
world
|
||||
.run_system_once(move |checks: PickChecks, stores: Query<&ObjectStore>| {
|
||||
checks.admits_unit(entity, stores.get(entity).ok())
|
||||
})
|
||||
.expect("the checks run")
|
||||
}
|
||||
|
||||
fn player(extra: &[(u16, u32)]) -> ObjectStore {
|
||||
let mut pairs = vec![(TYPE, PLAYER_TYPE), (TEMPLATE, 1), (FLAGS, 0x8)];
|
||||
pairs.extend_from_slice(extra);
|
||||
unit(&pairs)
|
||||
}
|
||||
|
||||
/// `0x606c20`, through the party word: us, a member of our party, our own pet and a member's
|
||||
/// pet pass; a stranger, a hostile creature, and a member who is not player-controlled do not.
|
||||
#[test]
|
||||
fn the_party_word_takes_us_and_the_people_of_our_party() {
|
||||
let (mut world, me) = party_world();
|
||||
let member = spawn(&mut world, 2, player(&[]));
|
||||
let stranger = spawn(&mut world, 3, player(&[]));
|
||||
let pet = spawn(&mut world, 4, unit(&[(FLAGS, 0x8), (CREATED_BY, 1)]));
|
||||
let member_pet = spawn(&mut world, 5, unit(&[(FLAGS, 0x8), (CHARMED_BY, 2)]));
|
||||
let stranger_pet = spawn(&mut world, 6, unit(&[(FLAGS, 0x8), (CREATED_BY, 3)]));
|
||||
let npc = spawn(&mut world, 7, foe(100));
|
||||
seat(&mut world, 2);
|
||||
|
||||
assert!(asked(&mut world, PARTY_WORD, me), "ourselves (606c3d)");
|
||||
assert!(asked(&mut world, PARTY_WORD, member), "a party member");
|
||||
assert!(asked(&mut world, PARTY_WORD, pet), "our own pet");
|
||||
assert!(asked(&mut world, PARTY_WORD, member_pet), "a member's pet");
|
||||
assert!(
|
||||
!asked(&mut world, PARTY_WORD, stranger),
|
||||
"a player outside the party"
|
||||
);
|
||||
assert!(!asked(&mut world, PARTY_WORD, stranger_pet), "his pet");
|
||||
assert!(!asked(&mut world, PARTY_WORD, npc), "a creature");
|
||||
|
||||
// Not player-controlled on either side: no party relation (606c5e, 606c76).
|
||||
world.entity_mut(member).insert(player(&[(FLAGS, 0)]));
|
||||
assert!(
|
||||
!asked(&mut world, PARTY_WORD, member),
|
||||
"a member without flag 0x8"
|
||||
);
|
||||
// A pet whose owner has not streamed answers no controller (606c92).
|
||||
let orphan = spawn(&mut world, 8, unit(&[(FLAGS, 0x8), (CREATED_BY, 99)]));
|
||||
assert!(!asked(&mut world, PARTY_WORD, orphan));
|
||||
// The raid word asks the same predicate.
|
||||
seat(&mut world, 3);
|
||||
assert!(asked(&mut world, RAID, stranger), "seated now");
|
||||
// Nobody is anybody's party once the roster is empty.
|
||||
world.init_resource::<crate::ui_party::GroupState>();
|
||||
world
|
||||
.resource_mut::<crate::ui_party::GroupState>()
|
||||
.members
|
||||
.clear();
|
||||
assert!(!asked(&mut world, PARTY_WORD, stranger));
|
||||
assert!(
|
||||
asked(&mut world, PARTY_WORD, me),
|
||||
"but we are still ourselves"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,8 +77,8 @@ pub(crate) fn commit_ground_cast_on_click(
|
||||
/// not range nor the lock: the refusal is the server's `SMSG_CAST_RESULT`. The right-click path
|
||||
/// ([`crate::target::click`], `0x5f33e0`) does resolve the lock and can refuse locally. Otherwise
|
||||
/// the picked unit goes to the unit arm ([`super::bind_target_unit`]) and the picked corpse to the
|
||||
/// corpse arm ([`super::corpse::bind_target_corpse`]); a unit click under a lock word binds nothing
|
||||
/// there, since every unit arm tests a bit the word lacks.
|
||||
/// corpse arm ([`super::corpse::bind_target_corpse`]). A lock word has no unit bit, so it puts no
|
||||
/// unit in the pick and the click reaches what stands behind one ([`super::pick`]).
|
||||
///
|
||||
/// Runs after `select_on_click`, as the terrain commit does.
|
||||
pub(crate) fn commit_object_cast_on_click(
|
||||
|
||||
@@ -96,16 +96,20 @@ pub(super) struct TargetHoverState<'w, 's> {
|
||||
/// The corpse reaction gate's inputs (`0x6067d0`).
|
||||
factions: Option<Res<'w, super::Factions>>,
|
||||
self_store: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
|
||||
/// The unit and player filter while a word stands (`0x480610`).
|
||||
units: crate::spell::targeting::PickChecks<'w, 's>,
|
||||
}
|
||||
|
||||
impl TargetHoverState<'_, '_> {
|
||||
/// Whether an object of this kind is a candidate of this pick, the posed pass and the box
|
||||
/// fallback alike: every unit and player, and a corpse (`0x480816`) always outside targeting,
|
||||
/// whose pick flags carry `0x40`, and while targeting only one `0x6e6260` passes. A corpse
|
||||
/// whose store has not streamed is refused then.
|
||||
fn is_candidate(&self, kind: EntityKind, store: Option<&ObjectStore>) -> bool {
|
||||
/// fallback alike: every unit and player outside targeting, and while targeting the ones
|
||||
/// `0x480610` passes for the word ([`crate::spell::targeting::PickChecks::admits_unit`]);
|
||||
/// a corpse (`0x480816`) always outside targeting, whose pick flags carry `0x40`, and while
|
||||
/// targeting only one `0x6e6260` passes. A corpse whose store has not streamed is refused
|
||||
/// then. The hover and the press, which latches this pick, share it.
|
||||
fn is_candidate(&self, entity: Entity, kind: EntityKind, store: Option<&ObjectStore>) -> bool {
|
||||
match kind {
|
||||
EntityKind::Unit | EntityKind::Player => true,
|
||||
EntityKind::Unit | EntityKind::Player => self.units.admits_unit(entity, store),
|
||||
EntityKind::Corpse => {
|
||||
let Some(word) = self.corpse_pick.0 else {
|
||||
return true;
|
||||
@@ -253,7 +257,7 @@ pub(super) fn update_hover(
|
||||
}
|
||||
// Units, players and corpses: the reference picks every CGObject in one trace and
|
||||
// switches on type at the end, below.
|
||||
if !target_state.is_candidate(net.kind, store) {
|
||||
if !target_state.is_candidate(entity, net.kind, store) {
|
||||
continue;
|
||||
}
|
||||
// Not drawn, not clickable. Checked before `faithful.insert`, so an undrawn unit is out by
|
||||
@@ -358,7 +362,7 @@ pub(super) fn update_hover(
|
||||
continue;
|
||||
};
|
||||
let store = roots.get(parent).ok().and_then(|root| root.6);
|
||||
if !target_state.is_candidate(parent_net.kind, store) {
|
||||
if !target_state.is_candidate(parent, parent_net.kind, store) {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) {
|
||||
@@ -770,6 +774,7 @@ pub(super) fn update_hovered_object(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::spell::targeting::corpse_fixture as fx;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
fn card(bone: u16, owner: Entity) -> BillboardCard {
|
||||
@@ -900,6 +905,8 @@ mod tests {
|
||||
world.insert_resource(crate::net::SelfGuid(Some(1)));
|
||||
world.init_resource::<crate::spell::PicksSelf>();
|
||||
world.init_resource::<crate::spell::CorpsePick>();
|
||||
world.init_resource::<crate::spell::targeting::UnitPick>();
|
||||
world.insert_resource(crate::net::Reputations(Vec::new()));
|
||||
let mut camera = Camera::default();
|
||||
camera.computed.clip_from_view = Mat4::perspective_infinite_reverse_rh(1.0, 1.0, 0.1);
|
||||
camera.computed.target_info = Some(RenderTargetInfo {
|
||||
@@ -1034,6 +1041,234 @@ mod tests {
|
||||
assert!(!hover(friend(), Some(0x0040)), "a ground word");
|
||||
}
|
||||
|
||||
/// A unit or player at depth `z` on the cursor's ray with one drawn pick box, of this store.
|
||||
fn unit_at(world: &mut World, guid: u64, z: f32, store: ObjectStore) -> Entity {
|
||||
let at = GlobalTransform::from_translation(Vec3::new(0.0, 0.0, z));
|
||||
let body = world
|
||||
.spawn((
|
||||
Guid(guid),
|
||||
NetEntity {
|
||||
kind: EntityKind::Unit,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
at,
|
||||
InheritedVisibility::VISIBLE,
|
||||
store,
|
||||
))
|
||||
.id();
|
||||
world.spawn((
|
||||
ChildOf(body),
|
||||
CreaturePickPart,
|
||||
Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)),
|
||||
at,
|
||||
InheritedVisibility::VISIBLE,
|
||||
));
|
||||
body
|
||||
}
|
||||
|
||||
/// A store of `UNIT_FIELD_FACTIONTEMPLATE` (35), `UNIT_FIELD_FLAGS` (46) and
|
||||
/// `UNIT_FIELD_HEALTH` (22), on [`fx::factions`]' rows: template 1 the caster's Human, 2 an Orc.
|
||||
fn unit_store(template: u32, flags: u32, health: u32) -> ObjectStore {
|
||||
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(35, template),
|
||||
(46, flags),
|
||||
(22, health),
|
||||
]))
|
||||
}
|
||||
|
||||
/// A living player of the caster's faction: assistable, not attackable.
|
||||
fn friend() -> ObjectStore {
|
||||
unit_store(1, 0x8, 100)
|
||||
}
|
||||
|
||||
/// A living Orc creature: attackable, not assistable.
|
||||
fn foe() -> ObjectStore {
|
||||
unit_store(2, 0, 100)
|
||||
}
|
||||
|
||||
/// The pick over `word`, the way a frame runs it: the word published, then the pick.
|
||||
fn pick_under(world: &mut World, word: Option<u16>) -> Hovered {
|
||||
let mut targeting = world.resource_mut::<crate::spell::SpellTargeting>();
|
||||
match word {
|
||||
Some(word) => targeting.enter(fx::RESURRECTION, crate::spell::CastCommit::Spell, word),
|
||||
None => targeting.clear(),
|
||||
}
|
||||
world
|
||||
.run_system_cached(crate::spell::targeting::publish_unit_pick)
|
||||
.expect("the word publishes");
|
||||
world
|
||||
.run_system_cached(update_hover)
|
||||
.expect("the pick runs");
|
||||
*world.resource::<Hovered>()
|
||||
}
|
||||
|
||||
/// Us, a human away from the ray, the faction tables, and the units a case puts on it.
|
||||
fn unit_pick_world() -> World {
|
||||
let mut world = pick_world();
|
||||
world.insert_resource(fx::factions());
|
||||
world.init_resource::<crate::spell::SpellTargeting>();
|
||||
world.spawn((
|
||||
SelfPlayer,
|
||||
Guid(1),
|
||||
GlobalTransform::from_translation(Vec3::new(50.0, 0.0, 0.0)),
|
||||
fx::caster(1.5),
|
||||
));
|
||||
world
|
||||
}
|
||||
|
||||
/// `0x480610` while a word stands: a unit the word cannot take is no candidate, so the one
|
||||
/// behind it takes the pick, where outside targeting the nearer wins whatever it is. The
|
||||
/// front unit is 5 yards nearer the camera than the rear one.
|
||||
#[test]
|
||||
fn a_unit_the_word_cannot_take_lets_the_pick_through_to_the_one_behind() {
|
||||
const HEAL: u16 = 0x0100;
|
||||
const ENEMY: u16 = 0x0080;
|
||||
const RESURRECTION: u16 = 0x8000;
|
||||
const SKINNING: u16 = 0x0402;
|
||||
// (word, the front unit it refuses, the rear unit it takes, what stands behind it)
|
||||
let cases: [(&str, u16, ObjectStore, ObjectStore); 6] = [
|
||||
("a foe before a friend, under a heal", HEAL, foe(), friend()),
|
||||
(
|
||||
"a corpse before a friend, under a heal",
|
||||
HEAL,
|
||||
unit_store(1, 0x8, 0),
|
||||
friend(),
|
||||
),
|
||||
(
|
||||
"a friend before a foe, under an enemy word",
|
||||
ENEMY,
|
||||
friend(),
|
||||
foe(),
|
||||
),
|
||||
(
|
||||
"the living before a corpse, under Resurrection",
|
||||
RESURRECTION,
|
||||
friend(),
|
||||
unit_store(1, 0x8, 0),
|
||||
),
|
||||
(
|
||||
"the living before a corpse, under Skinning",
|
||||
SKINNING,
|
||||
foe(),
|
||||
unit_store(2, 0, 0),
|
||||
),
|
||||
(
|
||||
"a unit immune to players before a friend, under a heal",
|
||||
HEAL,
|
||||
unit_store(1, 0x108, 100),
|
||||
friend(),
|
||||
),
|
||||
];
|
||||
for (what, word, front_store, rear_store) in cases {
|
||||
let mut world = unit_pick_world();
|
||||
let front = unit_at(&mut world, 10, 5.0, front_store);
|
||||
let rear = unit_at(&mut world, 11, 0.0, rear_store);
|
||||
// Outside targeting the nearer unit wins, whoever it is.
|
||||
assert_eq!(
|
||||
pick_under(&mut world, None).target,
|
||||
Some(front),
|
||||
"{what}: outside targeting"
|
||||
);
|
||||
let hovered = pick_under(&mut world, Some(word));
|
||||
assert_eq!(
|
||||
hovered.target,
|
||||
Some(rear),
|
||||
"{what}: the word takes the rear"
|
||||
);
|
||||
assert_eq!(hovered.guid, Some(11), "{what}");
|
||||
assert_eq!(
|
||||
pick_under(&mut world, None).target,
|
||||
Some(front),
|
||||
"{what}: and back outside targeting"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The control of the table above: the same front unit is the pick when the word takes it.
|
||||
#[test]
|
||||
fn a_unit_the_word_takes_keeps_the_pick_in_front_of_the_one_behind() {
|
||||
let cases: [(&str, u16, ObjectStore); 4] = [
|
||||
("a friend under a heal", 0x0100, friend()),
|
||||
("a foe under an enemy word", 0x0080, foe()),
|
||||
("a corpse under Resurrection", 0x8000, unit_store(1, 0x8, 0)),
|
||||
("a foe under a plain unit word", 0x0002, foe()),
|
||||
];
|
||||
for (what, word, front_store) in cases {
|
||||
let mut world = unit_pick_world();
|
||||
let front = unit_at(&mut world, 10, 5.0, front_store);
|
||||
let rear_store = if word == 0x8000 {
|
||||
unit_store(1, 0x8, 0)
|
||||
} else {
|
||||
friend()
|
||||
};
|
||||
unit_at(&mut world, 11, 0.0, rear_store);
|
||||
assert_eq!(
|
||||
pick_under(&mut world, Some(word)).target,
|
||||
Some(front),
|
||||
"{what}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// With nothing behind it, a unit the word cannot take leaves the pick empty, which is no
|
||||
/// refusal: `IsSelectable` grades the winner only, and there is none.
|
||||
#[test]
|
||||
fn a_lone_unit_the_word_cannot_take_is_no_pick_at_all() {
|
||||
let mut world = unit_pick_world();
|
||||
unit_at(&mut world, 10, 0.0, foe());
|
||||
let hovered = pick_under(&mut world, Some(0x0100));
|
||||
assert_eq!((hovered.target, hovered.guid), (None, None));
|
||||
assert!(!hovered.refused);
|
||||
assert_eq!(hovered.distance, f32::MAX);
|
||||
}
|
||||
|
||||
/// `0x481116`: only a word with unit bits (`0x878e`) puts units and players in the pick. A
|
||||
/// ground word, a lock word and a bag-item word pick none, so a unit in front of the ground
|
||||
/// or the chest is no obstacle, and a unit word does pick them.
|
||||
#[test]
|
||||
fn only_a_word_with_unit_bits_picks_units() {
|
||||
let mut world = unit_pick_world();
|
||||
let front = unit_at(&mut world, 10, 5.0, friend());
|
||||
for word in [0x0040, 0x0020, 0x4800, 0x0800, 0x0010] {
|
||||
let hovered = pick_under(&mut world, Some(word));
|
||||
assert_eq!(hovered.target, None, "{word:#06x}");
|
||||
assert!(!hovered.refused, "{word:#06x}");
|
||||
}
|
||||
for word in [0x0002, 0x0100] {
|
||||
assert_eq!(
|
||||
pick_under(&mut world, Some(word)).target,
|
||||
Some(front),
|
||||
"{word:#06x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The press latches the hover's pick, so the click reaches what the cursor showed: the unit
|
||||
/// behind, not the one in front the word refuses.
|
||||
#[test]
|
||||
fn the_press_latches_the_pick_the_word_left() {
|
||||
use crate::target::{latch_press_pick, PressPick};
|
||||
|
||||
let mut world = unit_pick_world();
|
||||
unit_at(&mut world, 10, 5.0, foe());
|
||||
let rear = unit_at(&mut world, 11, 0.0, friend());
|
||||
world.init_resource::<HoveredObject>();
|
||||
world.init_resource::<PressPick>();
|
||||
world.init_resource::<crate::target::WorldCursor>();
|
||||
world.init_resource::<crate::target::cursor_mode::AttackFork>();
|
||||
world.init_resource::<ButtonInput<MouseButton>>();
|
||||
|
||||
pick_under(&mut world, Some(0x0100));
|
||||
world
|
||||
.resource_mut::<ButtonInput<MouseButton>>()
|
||||
.press(MouseButton::Left);
|
||||
world
|
||||
.run_system_cached(latch_press_pick)
|
||||
.expect("the press latches");
|
||||
assert_eq!(world.resource::<PressPick>().hovered.target, Some(rear));
|
||||
}
|
||||
|
||||
/// Naxxramas's "Unholy Axe" is an `InvisibleStalker` body with no vertices holding a real axe,
|
||||
/// which is its whole pick surface.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user