A friendly or resurrection spell with no usable target brings up the targeting cursor, and autoSelfCast boots at 1.12.1's 0 (#445)
A friendly or resurrection spell that binds nothing now enters the unit targeting cursor, as `TryCast` does for any residual word without the enemy bit (`0x6e507b`–`0x6e50ab`). `autoSelfCast` boots at its reference default of `"0"`, and its `Deviates` row is gone. - **Binding.** The cursor binds a unit through a world click, and through `SpellTargetUnit` from the unit frames (`BindTarget 0x6e5b40`). The selection never moves. - **Relation checks.** Heals and buffs ask `CanAssist 0x6066f0` instead of reaction alone, so a friendly NPC without the PvP flag is no longer assistable. That is part of #50. - **Range.** `SpellCanTargetUnit` and the cursor answer per unit through `0x6e6460`'s unit leg, including the spell's min/max range (`6e677c`–`6e6802`). A click on a unit out of range raises "Out of range." or "Target too close" and keeps the cursor armed. The reference's abort there also strands the cursor, and that is not copied. - **Your own model** joins the world pick only for a word `0x6e61a0` admits (`0x850e`, no `AttributesEx 0x80000`), and never in first person. Fixes #136 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 5.5
Sam
parent
6982109d86
commit
e4cc695f84
@@ -234,14 +234,7 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
// `autoSelfCast` (register site `0x6e731d`, record `[0xceac34]`, read at `0x6e53d7`; `0x870dc0`
|
||||
// is its name string): a friendly cast that binds nothing falls back to the caster.
|
||||
// `TOGGLEAUTOSELFCAST` toggles it.
|
||||
deviates(
|
||||
"autoSelfCast",
|
||||
"1",
|
||||
"0",
|
||||
"with it off, an unbindable friendly cast falls into the reference's \
|
||||
targeting-cursor machine, which is unmodeled — leaving no path at all. Flip when that \
|
||||
machine lands",
|
||||
),
|
||||
same("autoSelfCast", "0"),
|
||||
// The five saved camera views and the live index, at the reference's names and default strings;
|
||||
// owned by [`crate::player::camera_view`]. Registered so a `SaveView` persists.
|
||||
same(
|
||||
@@ -1524,7 +1517,6 @@ mod tests {
|
||||
names,
|
||||
vec![
|
||||
"SoundReverb",
|
||||
"autoSelfCast",
|
||||
"frillDensity",
|
||||
"gxApi",
|
||||
"gxColorBits",
|
||||
|
||||
@@ -662,12 +662,17 @@ pub(crate) mod schedule_tests {
|
||||
/// `Player`: it reads `control_lost`, and they write only `settling` and `world_stale`;
|
||||
/// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and
|
||||
/// `ui_tooltip::feed_spell_tooltips` over `Items` and the VM: it only reads templates, which
|
||||
/// those two do not write, and a chat line commutes with their pushes.
|
||||
/// those two do not write, and a chat line commutes with their pushes;
|
||||
/// - `spell::targeting::drain_spell_target_unit` against the lone `.after(UiInput)` cast
|
||||
/// drains over the `CastLadder`, and the party, pet-book and chat drains over the unit-token
|
||||
/// resolver: the class `drop_item_on_unit` and the world click's legs already carry, as none
|
||||
/// of those drains orders against the target chain. A cast press and a unit-frame bind in one
|
||||
/// frame take either order until the cast drains share a set that does.
|
||||
///
|
||||
/// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the
|
||||
/// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource
|
||||
/// that commutes by construction belongs in [`Classes`].
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 5_463;
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 5_481;
|
||||
const UPDATE_ACTIONABLE_SLACK: usize = 40;
|
||||
|
||||
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
|
||||
|
||||
@@ -101,6 +101,8 @@ pub(crate) enum TargetedBind {
|
||||
Item(u64),
|
||||
/// The world click's GameObject guid: a chest, door, vein or herb.
|
||||
Object(u64),
|
||||
/// The world click's unit guid.
|
||||
Unit(u64),
|
||||
}
|
||||
|
||||
impl CastLadder<'_, '_> {
|
||||
@@ -126,6 +128,10 @@ impl CastLadder<'_, '_> {
|
||||
TargetedBind::Object(go_guid) => {
|
||||
ClientCommand::CastSpellGameObject { spell_id, go_guid }
|
||||
}
|
||||
TargetedBind::Unit(unit_guid) => ClientCommand::CastSpell {
|
||||
spell_id,
|
||||
target: Some(unit_guid),
|
||||
},
|
||||
},
|
||||
CastCommit::Item {
|
||||
bag_index,
|
||||
@@ -141,6 +147,7 @@ impl CastLadder<'_, '_> {
|
||||
TargetedBind::Source(src) => UseItemTarget::Source(src),
|
||||
TargetedBind::Item(guid) => UseItemTarget::Item(guid),
|
||||
TargetedBind::Object(guid) => UseItemTarget::Object(guid),
|
||||
TargetedBind::Unit(guid) => UseItemTarget::Unit(guid),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -404,18 +411,7 @@ fn send_spell_cast(
|
||||
if let Some(d) = def {
|
||||
if target.is_some() && target == ctx.selection_guid && target != ctx.self_guid {
|
||||
let row = spells.and_then(|s| s.ranges.get(d.range_index));
|
||||
let dist_sq = ctx
|
||||
.range
|
||||
.self_pos
|
||||
.zip(ctx.range.target_pos)
|
||||
.map(|(a, b)| a.distance_squared(b));
|
||||
if let Some(reason) = validator::cast_range_refusal(
|
||||
d,
|
||||
row,
|
||||
ctx.range.self_reach,
|
||||
ctx.range.target_reach,
|
||||
dist_sq,
|
||||
) {
|
||||
if let Some(reason) = ctx.range.refusal(d, row) {
|
||||
debug!("ui_action: cast {spell_id} refused locally — range ({reason:#x})");
|
||||
cast_errors.push_local(spell_id, reason);
|
||||
return;
|
||||
@@ -723,6 +719,7 @@ mod tests {
|
||||
auto_self_cast: false,
|
||||
rel: cast_target::TargetRelations {
|
||||
target_store: None,
|
||||
target_owner_store: None,
|
||||
self_store: None,
|
||||
factions: None,
|
||||
reputations: &EMPTY_REPUTATIONS,
|
||||
|
||||
@@ -9,18 +9,20 @@
|
||||
//! - otherwise each bit is cleared against the selection by its relation check, then against the
|
||||
//! player behind `autoSelfCast` (`0x6e53d7`); only a fully cleared word commits, as a unit guid.
|
||||
//! - a word with item, lock, GameObject or location bits enters the targeting cursor carrying
|
||||
//! the whole word: its three predicates (`0x6e6320` `& 0x60`, `0x6e6330` `& 0x4010`, `0x6e62d0`
|
||||
//! `& 0x4800`) can hold at once, and the click picks the leg.
|
||||
//! the whole word: its location (`0x6e6320`, `& 0x60`), item (`0x6e6330`, `& 0x4010`),
|
||||
//! GameObject (`0x6e62d0`, `& 0x4800`) and unit (`0x6e6460`) predicates can hold at once, and
|
||||
//! the click picks the leg.
|
||||
//!
|
||||
//! Not built: the unit hand cursor the reference enters for a residual unit word, and the STRING
|
||||
//! bit 13; both refuse locally with the client's "You have no target." or "Invalid target".
|
||||
//! The unit hand cursor receives a residual unit word without the enemy bit. A word carrying
|
||||
//! `0x80` instead raises the local no-target/invalid-target refusal; STRING bit 13 remains
|
||||
//! unmodeled and refuses locally with "Invalid target".
|
||||
|
||||
use benilla_formats::SpellDisplay;
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::{ObjectStore, Reputations, SelfGuid, SelfPlayer};
|
||||
use crate::target::{can_attack, ring_reaction, Factions, Selection};
|
||||
use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid, SelfPlayer};
|
||||
use crate::target::{can_assist, can_attack, Factions, Selection};
|
||||
|
||||
/// `TARGET_FLAG_*` bits of the client's targeting flag word (`0xcecac0`).
|
||||
const TF_UNIT: u16 = 0x0002;
|
||||
@@ -41,8 +43,7 @@ const UNIT_BITS: u16 = TF_UNIT
|
||||
| TF_EXPLICIT_GATE
|
||||
| TF_CORPSE_ALLY;
|
||||
|
||||
/// Client cast-failed reasons "You have no target." and "Invalid target", raised locally where
|
||||
/// the reference would enter its unit targeting cursor.
|
||||
/// Client cast-failed reasons used when an enemy word cannot bind.
|
||||
pub(crate) const ERR_NO_TARGET: u8 = 0x09;
|
||||
pub(crate) const ERR_INVALID_TARGET: u8 = 0x0A;
|
||||
|
||||
@@ -82,8 +83,7 @@ pub(crate) enum CastWireTarget {
|
||||
/// `IsTargeting 0x6e48a0`). Each click seam tests the word with its own mask, so one word can
|
||||
/// serve several: Opening and Pick Lock take a bag item or a world GameObject.
|
||||
Targeting(u16),
|
||||
/// Do not send; show this client error. The reference raises nothing here: it enters its unit
|
||||
/// targeting cursor, which is not built.
|
||||
/// Do not send; show this client error.
|
||||
Refused(u8),
|
||||
/// A refusal the reference raises in its cast tail after `ArmCast` returns false (`0x6e5045`).
|
||||
/// That tail runs after the requirement validator `0x6094f0`, so it fires at the cursor-entry
|
||||
@@ -108,6 +108,8 @@ pub(crate) struct CastCandidates {
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct TargetRelations<'a> {
|
||||
pub(crate) target_store: Option<&'a ObjectStore>,
|
||||
/// The target's charmer, creator or summoner store, for `CanAssist`'s `IsPvP` owner chase.
|
||||
pub(crate) target_owner_store: Option<&'a ObjectStore>,
|
||||
pub(crate) self_store: Option<&'a ObjectStore>,
|
||||
pub(crate) factions: Option<&'a Factions>,
|
||||
pub(crate) reputations: &'a Reputations,
|
||||
@@ -140,6 +142,24 @@ pub(crate) struct RangeInputs {
|
||||
pub(crate) target_reach: Option<f32>,
|
||||
}
|
||||
|
||||
impl RangeInputs {
|
||||
/// The one range compare: the squared 3D distance against `GetMinMaxRange 0x6e3480`'s bounds,
|
||||
/// "Out of range." (0x59) past max², "Target too close" (0x76) inside a nonzero min². The
|
||||
/// pre-send gate (`IsTargetInRange 0x6e47b0`) and the targeting cursor's unit leg (`0x6e6460`,
|
||||
/// `6e677c`–`6e6802`) both ask it. No row or no position passes; the server judges.
|
||||
pub(crate) fn refusal(
|
||||
&self,
|
||||
def: &SpellDisplay,
|
||||
row: Option<&benilla_formats::SpellRange>,
|
||||
) -> Option<u8> {
|
||||
let dist_sq = self
|
||||
.self_pos
|
||||
.zip(self.target_pos)
|
||||
.map(|(a, b)| a.distance_squared(b));
|
||||
super::validator::cast_range_refusal(def, row, self.self_reach, self.target_reach, dist_sq)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RangeInputs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -158,6 +178,7 @@ pub(crate) struct CastTargeting<'w, 's> {
|
||||
pub(crate) selection: Res<'w, Selection>,
|
||||
pub(crate) self_store: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
|
||||
stores: Query<'w, 's, &'static ObjectStore>,
|
||||
index: Option<Res<'w, GuidIndex>>,
|
||||
self_guid: Res<'w, SelfGuid>,
|
||||
auto_self_cast: Res<'w, AutoSelfCast>,
|
||||
factions: Option<Res<'w, Factions>>,
|
||||
@@ -173,12 +194,21 @@ 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());
|
||||
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(),
|
||||
factions: self.factions.as_deref(),
|
||||
reputations: &self.reputations,
|
||||
@@ -212,9 +242,7 @@ impl CastTargeting<'_, '_> {
|
||||
const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
|
||||
|
||||
/// The `autoSelfCast` CVar (name `0x870dc0`, read at `0x6e53d7`; reference default `"0"`).
|
||||
/// Deviation: defaults on, because with it off an unbindable friendly cast needs the unit
|
||||
/// targeting cursor, which is not built.
|
||||
#[derive(bevy::prelude::Resource)]
|
||||
#[derive(bevy::prelude::Resource, Default)]
|
||||
pub(crate) struct AutoSelfCast(pub(crate) bool);
|
||||
|
||||
/// `autoSelfCast`'s change callback: a flag.
|
||||
@@ -224,12 +252,6 @@ pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut auto: ResMut<AutoSe
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoSelfCast {
|
||||
fn default() -> Self {
|
||||
Self(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The flag word: `Targets`, then one arm keyed on `EffectImplicitTargetA[0]` (`0x6e525a`-
|
||||
/// `0x6e52ef`); every implicit target not matched below leaves the word as it is.
|
||||
pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 {
|
||||
@@ -253,18 +275,20 @@ pub(crate) fn cast_target_mask(def: &SpellDisplay) -> u16 {
|
||||
|
||||
/// `BindTarget 0x6e5b40`'s unit branch: clear every flag-word bit the candidate satisfies.
|
||||
///
|
||||
/// The reference asks `CanAssist 0x6066f0` for the assist bit; this uses reaction alone (>= 4).
|
||||
/// 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.
|
||||
fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16 {
|
||||
let mut word = word;
|
||||
let reaction = ring_reaction(
|
||||
rel.factions,
|
||||
rel.reputations,
|
||||
rel.target_store,
|
||||
rel.self_store,
|
||||
);
|
||||
let assist = is_self || reaction >= 4;
|
||||
let assist = is_self
|
||||
|| can_assist(
|
||||
rel.target_store,
|
||||
rel.factions,
|
||||
rel.reputations,
|
||||
rel.self_store,
|
||||
|_| rel.target_owner_store.cloned(),
|
||||
);
|
||||
let dead = rel
|
||||
.target_store
|
||||
.is_some_and(|s| s.0.unit_health() == Some(0));
|
||||
@@ -304,6 +328,12 @@ fn clear_satisfied_bits(word: u16, is_self: bool, rel: &TargetRelations) -> u16
|
||||
word
|
||||
}
|
||||
|
||||
/// Whether `BindTarget 0x6e5b40`'s unit arm clears the whole standing word for this unit.
|
||||
/// The initial selection, a world click and `SpellTargetUnit` all use this one predicate.
|
||||
pub(super) fn unit_word_binds(word: u16, is_self: bool, rel: &TargetRelations) -> bool {
|
||||
word & UNIT_BITS != 0 && clear_satisfied_bits(word, is_self, rel) == 0
|
||||
}
|
||||
|
||||
/// The wire target for casting `def`. An unknown spell sends the selection as is, or no target
|
||||
/// without one, and the server validates.
|
||||
pub(crate) fn resolve_cast_target(
|
||||
@@ -361,7 +391,7 @@ pub(crate) fn resolve_cast_target(
|
||||
// The selection (`0xb4e2d8`, `0x6e539f`).
|
||||
if let Some(guid) = cand.selection {
|
||||
let is_self = cand.caster == Some(guid);
|
||||
if clear_satisfied_bits(word, is_self, rel) == 0 {
|
||||
if unit_word_binds(word, is_self, rel) {
|
||||
return CastWireTarget::Unit(guid);
|
||||
}
|
||||
}
|
||||
@@ -370,19 +400,25 @@ pub(crate) fn resolve_cast_target(
|
||||
if let Some(guid) = cand.caster {
|
||||
let self_rel = TargetRelations {
|
||||
target_store: rel.self_store,
|
||||
target_owner_store: None,
|
||||
..*rel
|
||||
};
|
||||
if clear_satisfied_bits(word, true, &self_rel) == 0 {
|
||||
if unit_word_binds(word, true, &self_rel) {
|
||||
return CastWireTarget::Unit(guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The reference enters its unit targeting cursor here; not built, so refuse.
|
||||
CastWireTarget::Refused(if cand.selection.is_some() {
|
||||
ERR_INVALID_TARGET
|
||||
// A standing enemy word refuses locally. Every other residual unit word proceeds to
|
||||
// `BindTarget`'s hand cursor.
|
||||
if word & TF_UNIT_ENEMY != 0 {
|
||||
CastWireTarget::Refused(if cand.selection.is_some() {
|
||||
ERR_INVALID_TARGET
|
||||
} else {
|
||||
ERR_NO_TARGET
|
||||
})
|
||||
} else {
|
||||
ERR_NO_TARGET
|
||||
})
|
||||
CastWireTarget::Targeting(word)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -406,6 +442,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_self_cast_boots_at_the_reference_default() {
|
||||
assert!(
|
||||
!AutoSelfCast::default().0,
|
||||
"autoSelfCast defaults to the reference's disabled state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_follows_the_arm_map() {
|
||||
assert_eq!(cast_target_mask(&spell(0, 1)), 0, "Ice Armor: self");
|
||||
@@ -442,6 +486,7 @@ mod tests {
|
||||
let it = crate::net::ObjectStore(ObjectFields::from_pairs(&[(35, 0)]));
|
||||
let rel = TargetRelations {
|
||||
target_store: Some(&it),
|
||||
target_owner_store: None,
|
||||
self_store: Some(&me),
|
||||
factions: None,
|
||||
reputations: &Reputations(Vec::new()),
|
||||
@@ -455,7 +500,8 @@ mod tests {
|
||||
let fireball = spell(0, 6);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&fireball), &cands(None, Some(1)), true, &rel),
|
||||
CastWireTarget::Refused(ERR_NO_TARGET)
|
||||
CastWireTarget::Refused(ERR_NO_TARGET),
|
||||
"enemy words retain the no-target refusal"
|
||||
);
|
||||
// Neutral (3) is attackable by the mixed arm's `< 4` and not assistable by `>= 4`.
|
||||
assert_eq!(
|
||||
@@ -463,6 +509,10 @@ mod tests {
|
||||
CastWireTarget::Unit(42)
|
||||
);
|
||||
let intellect = spell(0, 21);
|
||||
assert!(
|
||||
!unit_word_binds(TF_UNIT_ASSIST, false, &rel),
|
||||
"the cursor's unit binder must reject the same neutral unit as the initial cast arm"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&intellect), &cands(Some(42), Some(1)), true, &rel),
|
||||
CastWireTarget::Unit(1),
|
||||
@@ -470,8 +520,8 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&intellect), &cands(Some(42), Some(1)), false, &rel),
|
||||
CastWireTarget::Refused(ERR_INVALID_TARGET),
|
||||
"autoSelfCast off: the fallback is gated"
|
||||
CastWireTarget::Targeting(TF_UNIT_ASSIST),
|
||||
"autoSelfCast off leaves the friendly word for the unit cursor"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_cast_target(Some(&intellect), &cands(None, Some(1)), true, &rel),
|
||||
@@ -490,6 +540,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assist_word_requires_the_npc_pvp_flag_not_only_a_friendly_reaction() {
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let factions = Factions::from_catalog(
|
||||
benilla_formats::load_faction_catalog(&mut chain).expect("FactionTemplate.dbc"),
|
||||
);
|
||||
// Faction templates 35 and 1 are a real friendly pair. Field 46 is UNIT_FIELD_FLAGS.
|
||||
let me = ObjectStore(ObjectFields::from_pairs(&[(35, 1), (46, 0x8)]));
|
||||
let quiet = ObjectStore(ObjectFields::from_pairs(&[(35, 35), (46, 0)]));
|
||||
let pvp = ObjectStore(ObjectFields::from_pairs(&[(35, 35), (46, 0x1000)]));
|
||||
let reputations = Reputations(Vec::new());
|
||||
let rel = |target| TargetRelations {
|
||||
target_store: Some(target),
|
||||
target_owner_store: None,
|
||||
self_store: Some(&me),
|
||||
factions: Some(&factions),
|
||||
reputations: &reputations,
|
||||
};
|
||||
|
||||
assert!(
|
||||
!unit_word_binds(TF_UNIT_ASSIST, false, &rel(&quiet)),
|
||||
"a friendly ambient NPC is not assistable"
|
||||
);
|
||||
assert!(
|
||||
unit_word_binds(TF_UNIT_ASSIST, false, &rel(&pvp)),
|
||||
"the same friendly NPC becomes assistable with UNIT_FLAG_PVP"
|
||||
);
|
||||
}
|
||||
|
||||
/// Flamestrike (implicit 16) defers even with a selection, Blizzard (implicit 28) by its
|
||||
/// bare DEST word; a word of 0 still commits first (`0x6e5338`).
|
||||
#[test]
|
||||
@@ -660,6 +742,7 @@ mod tests {
|
||||
static EMPTY: Reputations = Reputations(Vec::new());
|
||||
TargetRelations {
|
||||
target_store: None,
|
||||
target_owner_store: None,
|
||||
self_store: None,
|
||||
factions: None,
|
||||
reputations: &EMPTY,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::ClientState;
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
use crate::ui_unit::UnitFeed;
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
@@ -32,7 +32,7 @@ pub(crate) use inflight::{
|
||||
};
|
||||
pub(crate) use mods::{SpellModifiers, OP_COST};
|
||||
// `TargetingWants` is exported for the ground reticle, which draws for the location word alone.
|
||||
pub(crate) use targeting::{ground_cast_radius, SpellTargeting, TargetingWants};
|
||||
pub(crate) use targeting::{ground_cast_radius, PicksSelf, SpellTargeting, TargetingWants};
|
||||
|
||||
/// The local self-cancel's set: a reader of the in-flight state orders `.after(LocalCancel)` so a
|
||||
/// cast ended by a move, jump or Esc drops its cast bar the same frame.
|
||||
@@ -53,6 +53,7 @@ impl Plugin for SpellPlugin {
|
||||
.init_resource::<AutoSelfCast>()
|
||||
.init_resource::<SpellTargeting>()
|
||||
.init_resource::<targeting::EnchantConfirmItem>()
|
||||
.init_resource::<targeting::PicksSelf>()
|
||||
.add_observer(cast_target::on_cvar)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -65,7 +66,12 @@ impl Plugin for SpellPlugin {
|
||||
.before(UnitFeed),
|
||||
// The state push runs before the input pass's `ToggleGameMenu` and the drain
|
||||
// after it, so an Esc cancel lands before next frame's cursor reads the mode.
|
||||
targeting::feed_targeting_to_vm.in_set(UnitFeed),
|
||||
// After the old-target clear, the pet bar's writer in the feed: `"pet"`
|
||||
// resolves off the bar.
|
||||
targeting::feed_targeting_to_vm
|
||||
.in_set(UnitFeed)
|
||||
.after(crate::ui_pet::pet_stop_on_old_target_clear),
|
||||
targeting::publish_picks_self.in_set(UiFeed),
|
||||
targeting::drain_stop_targeting.after(UiInput),
|
||||
// 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
|
||||
|
||||
@@ -75,12 +75,13 @@ pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level:
|
||||
/// its ray (`0x4812c8`) and the world cursor stays grey.
|
||||
///
|
||||
/// The object arm is `0x6e6460`'s GameObject leg: `word & 0x4800`, the lock predicate `0x5f8260`,
|
||||
/// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit, world-item and
|
||||
/// corpse legs are unreachable here: a unit-target spell never enters targeting mode.
|
||||
/// then the same min/max range test through `GetMinMaxRange 0x6e3480`. Its unit leg is
|
||||
/// [`SpellTargeting::can_target_unit`]. The world-item and corpse legs are not built.
|
||||
///
|
||||
/// Every seam shows the `Cast` kind, so this reads the whole-word [`SpellTargeting::spell`].
|
||||
pub(crate) fn drive_targeting_cursor(
|
||||
targeting: Res<SpellTargeting>,
|
||||
unit_checks: super::UnitBindChecks,
|
||||
occlusion: Res<PickOcclusion>,
|
||||
hovered: Res<crate::target::Hovered>,
|
||||
hovered_object: Res<crate::target::HoveredObject>,
|
||||
@@ -102,8 +103,8 @@ pub(crate) fn drive_targeting_cursor(
|
||||
};
|
||||
let row = range_row(spells.as_deref(), spell_id);
|
||||
let me = self_tf.single().ok().map(|tf| tf.translation);
|
||||
// "A GameObject is the nearest pick" is the same test the click uses
|
||||
// ([`super::world::commit_object_cast_on_click`]).
|
||||
// The arm the click would take ([`super::world::commit_object_cast_on_click`]): a GameObject
|
||||
// word's nearest GameObject, else the picked unit, else the terrain.
|
||||
let able = if targeting.wants(TargetingWants::GameObject)
|
||||
&& crate::target::go_is_nearest(&hovered, &hovered_object)
|
||||
{
|
||||
@@ -116,6 +117,11 @@ pub(crate) fn drive_targeting_cursor(
|
||||
row,
|
||||
me,
|
||||
)
|
||||
} else if let Some(unit) = hovered
|
||||
.target
|
||||
.filter(|_| targeting.wants(TargetingWants::Unit))
|
||||
{
|
||||
targeting.can_target_unit(unit, &unit_checks)
|
||||
} else if targeting.wants(TargetingWants::Location) {
|
||||
// `0x4820f0`. No ground hit (sky, mouselook) is state 0, UnableCast.
|
||||
match (occlusion.point, me) {
|
||||
@@ -289,6 +295,7 @@ mod tests {
|
||||
world.init_resource::<crate::go_templates::GameObjectTemplates>();
|
||||
world.init_resource::<crate::items::Items>();
|
||||
world.init_resource::<crate::net::GuidIndex>();
|
||||
world.insert_resource(crate::net::Reputations(Vec::new()));
|
||||
world.insert_resource(PickOcclusion {
|
||||
distance: 10.0,
|
||||
point,
|
||||
@@ -341,4 +348,89 @@ mod tests {
|
||||
// A lock word that also carries DEST (`0x4840`) keeps its terrain handler off a GameObject.
|
||||
assert!(verdict(0x4840, Some(Vec3::ZERO), None));
|
||||
}
|
||||
|
||||
/// `0x6e6460`'s unit leg over a hovered unit: the relation checks, then min² ≤ d² ≤ max²
|
||||
/// (`6e677c`–`6e6802`). Row 5 is 0 to 30 yd and row 114 is 8 to 35 yd, both padded by the two
|
||||
/// 1.5 combat reaches: 0 to 33 and 11 to 38.
|
||||
#[test]
|
||||
fn the_unit_leg_is_grey_out_of_range_or_off_relation() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const HEAL: u32 = 2050;
|
||||
const SHOT: u32 = 75;
|
||||
let verdict_for = |spell: u32, word: u16, distance: f32| {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<WorldCursor>();
|
||||
world.init_resource::<SpellTargeting>();
|
||||
world.init_resource::<crate::target::HoveredObject>();
|
||||
world.init_resource::<crate::go_templates::GameObjectTemplates>();
|
||||
world.init_resource::<crate::items::Items>();
|
||||
world.init_resource::<crate::net::GuidIndex>();
|
||||
world.insert_resource(crate::net::Reputations(Vec::new()));
|
||||
world.init_resource::<PickOcclusion>();
|
||||
let display = |range_index| benilla_formats::SpellDisplay {
|
||||
range_index,
|
||||
..Default::default()
|
||||
};
|
||||
let row = |min, max| SpellRange { min, max, flags: 0 };
|
||||
let mut spells = Spells::empty_for_tests();
|
||||
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([
|
||||
(HEAL, display(5)),
|
||||
(SHOT, display(114)),
|
||||
]));
|
||||
spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([
|
||||
(5, row(0.0, 30.0)),
|
||||
(114, row(8.0, 35.0)),
|
||||
]));
|
||||
world.insert_resource(spells);
|
||||
let empty = || crate::net::ObjectStore(benilla_protocol::ObjectFields::default());
|
||||
world.spawn((
|
||||
SelfPlayer,
|
||||
Transform::default(),
|
||||
GlobalTransform::default(),
|
||||
empty(),
|
||||
));
|
||||
let unit = world
|
||||
.spawn((
|
||||
GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)),
|
||||
empty(),
|
||||
))
|
||||
.id();
|
||||
world.insert_resource(crate::target::Hovered {
|
||||
target: Some(unit),
|
||||
guid: Some(0xF130_0000_0000_0001),
|
||||
distance: 5.0,
|
||||
..Default::default()
|
||||
});
|
||||
world.resource_mut::<SpellTargeting>().enter(
|
||||
spell,
|
||||
crate::spell::CastCommit::Spell,
|
||||
word,
|
||||
);
|
||||
world
|
||||
.run_system_once(drive_targeting_cursor)
|
||||
.expect("the targeting cursor drives");
|
||||
!world.resource::<WorldCursor>().unable
|
||||
};
|
||||
let verdict = |word, distance| verdict_for(HEAL, word, distance);
|
||||
|
||||
// `TARGET_FLAG_UNIT` (0x2) binds any unit, so only range decides.
|
||||
assert!(verdict(0x0002, 10.0), "a valid unit in range → Cast");
|
||||
assert!(
|
||||
!verdict(0x0002, 40.0),
|
||||
"the same unit out of range → UnableCast"
|
||||
);
|
||||
// An assist word over a unit with no faction catalog: neutral, not assistable.
|
||||
assert!(
|
||||
!verdict(0x0100, 10.0),
|
||||
"a unit that fails the relation → UnableCast"
|
||||
);
|
||||
// Inside the minimum is out of range too.
|
||||
assert!(verdict_for(SHOT, 0x0002, 20.0), "inside the band → Cast");
|
||||
assert!(
|
||||
!verdict_for(SHOT, 0x0002, 5.0),
|
||||
"inside the minimum → UnableCast"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,21 @@
|
||||
//! word and each click seam asks it its own mask test ([`TargetingWants`]).
|
||||
//!
|
||||
//! - [`cursor`]: the hover verdict per seam. Terrain is `0x4820f0`'s `CheckGroundPointInRange
|
||||
//! 0x6e6810`, a GameObject is `0x4828d0`'s `0x6e6460` (the spell-vs-lock predicate `0x5f8260`,
|
||||
//! then range), and a word no seam handles is UnableCast.
|
||||
//! 0x6e6810`, a GameObject or a unit is `0x4828d0`'s `0x6e6460` (the spell-vs-lock predicate
|
||||
//! `0x5f8260` or [`SpellTargeting::can_target_unit`], then range), and a word no seam handles is
|
||||
//! UnableCast.
|
||||
//! - [`world`]: the world-click dispatcher `0x492ce0`, its terrain leg (`0x492580` → `BindLocation
|
||||
//! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`).
|
||||
//! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`),
|
||||
//! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit` ([`drain_spell_target_unit`]).
|
||||
//! - [`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.
|
||||
//!
|
||||
//! Every seam commits through [`crate::spell::CastLadder::commit_targeted`] (`SendCast 0x6e54f0`).
|
||||
//! Neither world leg has a range gate: the click sends and the server judges range, and
|
||||
//! `CheckGroundPointInRange` only colours the cursor. While targeting, the pick flags come from
|
||||
//! the word alone, so a click over a unit with a dest-only word commits on the ground behind it.
|
||||
//! Only the unit arm has a range gate: the terrain and GameObject clicks send and the server
|
||||
//! judges range, and `CheckGroundPointInRange` only colours the cursor. While targeting, the pick
|
||||
//! flags come from the word alone, so a click over a unit with a dest-only word commits on the
|
||||
//! ground behind it.
|
||||
//!
|
||||
//! Cancels: ESC through `UIParent.lua:1490` ([`feed_targeting_to_vm`], [`drain_stop_targeting`]),
|
||||
//! the right-button down edge ([`cancel_targeting_on_right_press`]), a new spell's press, which
|
||||
@@ -34,11 +37,104 @@ use bevy::prelude::*;
|
||||
|
||||
use benilla_world::interact::WorldRightPress;
|
||||
|
||||
/// The inputs of the cursor's two unit functions, [`SpellTargeting::can_target_unit`] and
|
||||
/// [`bind_target_unit`]: the relation checks the cast arm's selection bind runs
|
||||
/// ([`super::cast_target::unit_word_binds`]) and the pre-send range gate's inputs
|
||||
/// ([`super::cast_target::RangeInputs`]), read as [`super::cast_target::CastTargeting`] reads them.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct UnitBindChecks<'w, 's> {
|
||||
stores: Query<'w, 's, &'static crate::net::ObjectStore>,
|
||||
index: Option<Res<'w, crate::net::GuidIndex>>,
|
||||
self_q: Query<
|
||||
'w,
|
||||
's,
|
||||
(Entity, Option<&'static crate::net::ObjectStore>),
|
||||
With<crate::net::SelfPlayer>,
|
||||
>,
|
||||
factions: Option<Res<'w, crate::target::Factions>>,
|
||||
reputations: Res<'w, crate::net::Reputations>,
|
||||
/// 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
|
||||
/// `publish_camera_pose` rewrites mid-frame.
|
||||
poses: Query<'w, 's, &'static GlobalTransform, Without<benilla_world::view::WorldCamera>>,
|
||||
spells: Option<Res<'w, crate::ui_action::Spells>>,
|
||||
}
|
||||
|
||||
impl UnitBindChecks<'_, '_> {
|
||||
fn is_self(&self, entity: Entity) -> bool {
|
||||
self.self_q
|
||||
.iter()
|
||||
.next()
|
||||
.is_some_and(|(me, _)| me == entity)
|
||||
}
|
||||
|
||||
/// `BindTarget 0x6e5b40`'s relation checks: whether this unit clears the whole word.
|
||||
fn relations_clear(&self, 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 rel = super::cast_target::TargetRelations {
|
||||
target_store,
|
||||
target_owner_store,
|
||||
self_store: self.self_q.iter().next().and_then(|(_, store)| store),
|
||||
factions: self.factions.as_deref(),
|
||||
reputations: &self.reputations,
|
||||
};
|
||||
super::cast_target::unit_word_binds(word, self.is_self(entity), &rel)
|
||||
}
|
||||
|
||||
/// The spell's min/max range against this unit, through the one compare the pre-send gate
|
||||
/// asks ([`super::cast_target::RangeInputs::refusal`]); our own body is distance 0. An
|
||||
/// unknown spell or a missing row passes.
|
||||
fn range_refusal(&self, spell_id: u32, entity: Entity) -> Option<u8> {
|
||||
let spells = self.spells.as_deref()?;
|
||||
let def = spells.catalog.get(spell_id)?;
|
||||
let me = self.self_q.iter().next();
|
||||
let mut range = super::cast_target::RangeInputs {
|
||||
self_pos: me
|
||||
.and_then(|(e, _)| self.poses.get(e).ok())
|
||||
.map(GlobalTransform::translation),
|
||||
target_pos: self
|
||||
.poses
|
||||
.get(entity)
|
||||
.ok()
|
||||
.map(GlobalTransform::translation),
|
||||
target_reach: self
|
||||
.stores
|
||||
.get(entity)
|
||||
.ok()
|
||||
.map(|s| s.0.unit_combat_reach()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some((_, Some(store))) = me {
|
||||
range.self_reach = store.0.unit_combat_reach();
|
||||
}
|
||||
range.refusal(def, spells.ranges.get(def.range_index))
|
||||
}
|
||||
|
||||
/// The caster under a spell with `AttributesEx & 0x80000` (`6e6507`).
|
||||
fn excluded_caster(&self, spell_id: u32, entity: Entity) -> bool {
|
||||
self.is_self(entity)
|
||||
&& self
|
||||
.spells
|
||||
.as_deref()
|
||||
.and_then(|s| s.catalog.get(spell_id))
|
||||
.is_some_and(|d| d.excludes_caster())
|
||||
}
|
||||
}
|
||||
|
||||
/// A click seam's mask test on the flag_word `0xcecac0`, one per reference predicate:
|
||||
///
|
||||
/// - `Location`: `TargetingWantsLocation 0x6e6320`, `word & 0x60`, the terrain click.
|
||||
/// - `Item`: `TargetingWantsItem 0x6e6330`, `word & 0x4010`, the bag and paper-doll clicks.
|
||||
/// - `GameObject`: `TargetingWantsGameObject 0x6e62d0`, `word & 0x4800`, the world object click.
|
||||
/// - `Unit`: `SpellCanTargetUnit 0x6e6460`, the unit arm of the world click.
|
||||
///
|
||||
/// The masks overlap on `TARGET_FLAG_LOCKED`, so a lock spell answers both the item and the
|
||||
/// GameObject seam; the reference settles it only at the click, where `BindTarget 0x6e5b40` picks
|
||||
@@ -48,10 +144,11 @@ pub(crate) enum TargetingWants {
|
||||
Location,
|
||||
Item,
|
||||
GameObject,
|
||||
Unit,
|
||||
}
|
||||
|
||||
/// The unit-shaped bits of the flag_word, what `SpellCanTargetUnit` tests. The resolver binds or
|
||||
/// refuses a unit word before it reaches the cursor, so none is set today.
|
||||
/// The unit-shaped bits of the flag_word, the first gate `SpellCanTargetUnit` tests before it runs
|
||||
/// the candidate through the unit arm's relation and liveness checks.
|
||||
const UNIT_WORD_BITS: u16 = 0x0002 | 0x0004 | 0x0008 | 0x0080 | 0x0100 | 0x0200 | 0x0400 | 0x8000;
|
||||
|
||||
impl TargetingWants {
|
||||
@@ -60,11 +157,16 @@ impl TargetingWants {
|
||||
Self::Location => 0x0060,
|
||||
Self::Item => 0x4010,
|
||||
Self::GameObject => 0x4800,
|
||||
Self::Unit => UNIT_WORD_BITS,
|
||||
};
|
||||
word & mask != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The word bits `0x6e61a0` lets the world pick take the local player for: unit, raid, party,
|
||||
/// assist, the explicit gate and the ally corpse, never enemy (`0x80`) or enemy corpse (`0x200`).
|
||||
const SELF_PICK_BITS: u16 = 0x850e;
|
||||
|
||||
/// The targeting mode, `Some` while the flag_word is nonzero. Entered by the cast-send path
|
||||
/// ([`super::cast_target::CastWireTarget::Targeting`]), cleared by a commit or a cancel.
|
||||
#[derive(Resource, Default)]
|
||||
@@ -96,6 +198,36 @@ impl SpellTargeting {
|
||||
self.0.as_ref().is_some_and(|t| wants.matches(t.word))
|
||||
}
|
||||
|
||||
/// `SpellCanTargetUnit`'s predicate `0x6e6460`, its unit leg, with the range flag both callers
|
||||
/// pass (the world hover `0x4828d0` at `48290b`, the Lua `SpellCanTargetUnit 0x6e6d00`):
|
||||
/// `BindTarget`'s relation checks, the caster refused under `AttributesEx & 0x80000`
|
||||
/// (`6e6507`), then min² ≤ d² ≤ max² (`6e677c`–`6e6802`), so a unit inside the minimum is out
|
||||
/// too. There is no line-of-sight test.
|
||||
pub(crate) fn can_target_unit(&self, entity: Entity, checks: &UnitBindChecks) -> bool {
|
||||
self.0.as_ref().is_some_and(|t| {
|
||||
checks.relations_clear(t.word, entity)
|
||||
&& !checks.excluded_caster(t.spell_id, entity)
|
||||
&& checks.range_refusal(t.spell_id, entity).is_none()
|
||||
})
|
||||
}
|
||||
|
||||
/// `0x6e61a0`: whether the world pick takes the local player, the pick flag `0x20` that
|
||||
/// `0x480610` tests at `48062c`. Never outside targeting (`480638`); while targeting, a word in
|
||||
/// `0x850e` whose spell lacks `AttributesEx & 0x80000` (`6e61cf`). An unknown spell passes.
|
||||
pub(crate) fn picks_self(&self, spells: Option<&crate::ui_action::Spells>) -> bool {
|
||||
self.0.as_ref().is_some_and(|t| {
|
||||
t.word & SELF_PICK_BITS != 0
|
||||
&& !spells
|
||||
.and_then(|s| s.catalog.get(t.spell_id))
|
||||
.is_some_and(|d| d.excludes_caster())
|
||||
})
|
||||
}
|
||||
|
||||
/// The pending cast and its standing word, whatever seam it wants.
|
||||
fn pending(&self) -> Option<(u32, super::cast_send::CastCommit, u16)> {
|
||||
self.0.as_ref().map(|t| (t.spell_id, t.commit, t.word))
|
||||
}
|
||||
|
||||
/// The pending spell when the standing word answers `wants`. A surface that draws or binds for
|
||||
/// one seam reads this, never [`Self::spell`], or an armed lockpick gets an AoE reticle.
|
||||
pub(crate) fn spell_for(&self, wants: TargetingWants) -> Option<u32> {
|
||||
@@ -163,6 +295,24 @@ pub(crate) fn cancel_targeting_on_right_press(
|
||||
targeting.clear();
|
||||
}
|
||||
|
||||
/// [`SpellTargeting::picks_self`] as the frame began, which the world pick reads
|
||||
/// ([`crate::target`]'s hover): the word the VM's `SpellIsTargeting` answers from, in a declared
|
||||
/// order against every cast drain that arms or ends the cursor after the input pass.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PicksSelf(pub(crate) bool);
|
||||
|
||||
/// Publish [`PicksSelf`] before the input pass.
|
||||
pub(crate) fn publish_picks_self(
|
||||
targeting: Res<SpellTargeting>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
mut picks: ResMut<PicksSelf>,
|
||||
) {
|
||||
let now = targeting.picks_self(spells.as_deref());
|
||||
if picks.0 != now {
|
||||
picks.0 = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the targeting state into the VM each frame, before the input pass, so a word armed last
|
||||
/// frame stands for this frame's clicks: the ESC chain's word, the bag and doll pickup reroute's
|
||||
/// item half, and `CURRENT_SPELL_CAST_CHANGED` on each edge. That event hides the enchant confirm
|
||||
@@ -171,6 +321,9 @@ pub(crate) fn cancel_targeting_on_right_press(
|
||||
/// and re-arm in one frame still counts.
|
||||
pub(crate) fn feed_targeting_to_vm(
|
||||
targeting: Res<SpellTargeting>,
|
||||
checks: UnitBindChecks,
|
||||
tokens: crate::ui_unit::UnitTokens,
|
||||
selection: Res<crate::target::Selection>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
) {
|
||||
@@ -178,13 +331,12 @@ pub(crate) fn feed_targeting_to_vm(
|
||||
let last = last.get(&script);
|
||||
script.set_spell_targeting(targeting.active());
|
||||
script.set_item_pick_armed(targeting.wants(TargetingWants::Item));
|
||||
// `SpellCanTargetUnit`, `0x6e6460`'s unit leg, read off the word.
|
||||
script.set_spell_can_target_unit(
|
||||
targeting
|
||||
.0
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.word & UNIT_WORD_BITS != 0),
|
||||
);
|
||||
// `SpellCanTargetUnit(unit)`: resolve each token and ask `0x6e6460`'s unit leg.
|
||||
script.set_spell_targetable_units(crate::ui_unit::reach_tokens().filter(|token| {
|
||||
tokens
|
||||
.resolve(token, &selection)
|
||||
.is_some_and(|(entity, _)| targeting.can_target_unit(entity, &checks))
|
||||
}));
|
||||
if *last != targeting.spell() {
|
||||
*last = targeting.spell();
|
||||
script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
|
||||
@@ -207,9 +359,292 @@ pub(crate) fn drain_stop_targeting(
|
||||
}
|
||||
}
|
||||
|
||||
/// `BindTarget 0x6e5b40`'s unit arm, for the world click (`0x493540` at `4935d5`) and
|
||||
/// `SpellTargetUnit` (`0x6e5b10`): the caster under `AttributesEx & 0x80000` (`6e5bf7`) or a unit
|
||||
/// failing the relation checks binds nothing and the cursor stays up; one out of the spell's range
|
||||
/// raises "Out of range." or "Target too close" (`6e6063`); otherwise the cast commits at it. The
|
||||
/// player's selection never moves.
|
||||
fn bind_target_unit(
|
||||
ladder: &mut crate::spell::CastLadder,
|
||||
checks: &UnitBindChecks,
|
||||
entity: Entity,
|
||||
guid: u64,
|
||||
) {
|
||||
let Some((spell_id, commit, word)) = ladder.ground.pending() else {
|
||||
return;
|
||||
};
|
||||
if checks.excluded_caster(spell_id, entity) || !checks.relations_clear(word, entity) {
|
||||
return;
|
||||
}
|
||||
if let Some(reason) = checks.range_refusal(spell_id, entity) {
|
||||
// The reference's abort here (`6e6089`) also sends CMSG_CANCEL_CAST and strands the
|
||||
// cursor (`6e5b93`) until a cancel: a bug, not copied; targeting stays armed and working.
|
||||
debug!("ui_action: cast {spell_id} not bound at {guid:#x} — range ({reason:#x})");
|
||||
ladder.cast_errors.push_local(spell_id, reason);
|
||||
return;
|
||||
}
|
||||
debug!("ui_action: cast {spell_id} committed at unit {guid:#x}");
|
||||
ladder.commit_targeted(spell_id, commit, super::cast_send::TargetedBind::Unit(guid));
|
||||
}
|
||||
|
||||
/// Drain `SpellTargetUnit(unit)` (`0x6e6d90`) after the UI pass. The binding already raised the
|
||||
/// usage and unknown-token errors and dropped a call made while not targeting. Here a token that
|
||||
/// names no unit raises "Out of range." (0x59) and ends targeting, as the reference's abort
|
||||
/// clears the word, and a unit goes to [`bind_target_unit`].
|
||||
pub(crate) fn drain_spell_target_unit(
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
tokens: crate::ui_unit::UnitTokens,
|
||||
selection: Res<crate::target::Selection>,
|
||||
checks: UnitBindChecks,
|
||||
mut ladder: crate::spell::CastLadder,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
for token in script.take_spell_target_unit() {
|
||||
// An earlier token this frame may have bound or ended the cast: not targeting, no-op.
|
||||
let Some(spell_id) = ladder.ground.spell() else {
|
||||
continue;
|
||||
};
|
||||
match tokens.resolve(&token, &selection) {
|
||||
Some((entity, guid)) => bind_target_unit(&mut ladder, &checks, entity, guid),
|
||||
None => {
|
||||
debug!("ui_action: SpellTargetUnit({token}) names no unit — cast {spell_id} ends");
|
||||
ladder
|
||||
.cast_errors
|
||||
.push_local(spell_id, super::validator::ERR_OUT_OF_RANGE);
|
||||
ladder.ground.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
|
||||
use benilla_ui::script::UiScript;
|
||||
use crossbeam_channel::Receiver;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const HEAL: u32 = 2050;
|
||||
/// A spell that excludes its caster (`AttributesEx & 0x80000`).
|
||||
const NOT_SELF: u32 = 2051;
|
||||
/// A spell on row 114, 8 to 35 yd, which the reaches pad to 11 to 38.
|
||||
const BANDED: u32 = 75;
|
||||
const ME: u64 = 0x10;
|
||||
const ALLY: u64 = 0xF130_0000_0000_0001;
|
||||
|
||||
/// The cast ladder, the unit-token resolver and a VM, with us at the origin and an ally
|
||||
/// selected at `distance`. [`HEAL`] and [`NOT_SELF`] use row 5, 0 to 30 yd, which both 1.5
|
||||
/// combat reaches pad to 33.
|
||||
fn unit_world(distance: f32) -> (World, Receiver<ClientCommand>, Entity) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut world = World::new();
|
||||
world.insert_resource(NetCommands(tx));
|
||||
world.init_resource::<crate::items::Items>();
|
||||
world.init_resource::<crate::net::GuidIndex>();
|
||||
world.insert_resource(crate::net::Reputations(Vec::new()));
|
||||
world.init_resource::<crate::spell::PendingCast>();
|
||||
world.init_resource::<crate::spell::QueuedMeleeSpell>();
|
||||
world.init_resource::<crate::spell::Cooldowns>();
|
||||
world.init_resource::<crate::spell::SpellModifiers>();
|
||||
world.init_resource::<crate::ui_action::CastErrors>();
|
||||
world.init_resource::<crate::spell::AutoRepeatActive>();
|
||||
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
|
||||
world.init_resource::<SpellTargeting>();
|
||||
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
|
||||
world.init_resource::<crate::ui_party::GroupState>();
|
||||
let mut spells = crate::ui_action::Spells::empty_for_tests();
|
||||
let row5 = |attributes_ex| benilla_formats::SpellDisplay {
|
||||
range_index: 5,
|
||||
attributes_ex,
|
||||
..Default::default()
|
||||
};
|
||||
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([
|
||||
(HEAL, row5(0)),
|
||||
(NOT_SELF, row5(0x0008_0000)),
|
||||
(
|
||||
BANDED,
|
||||
benilla_formats::SpellDisplay {
|
||||
range_index: 114,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]));
|
||||
let row = |min, max| benilla_formats::SpellRange { min, max, flags: 0 };
|
||||
spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([
|
||||
(5, row(0.0, 30.0)),
|
||||
(114, row(8.0, 35.0)),
|
||||
]));
|
||||
world.insert_resource(spells);
|
||||
let empty = || ObjectStore(benilla_protocol::ObjectFields::default());
|
||||
world.spawn((SelfPlayer, Guid(ME), GlobalTransform::default(), empty()));
|
||||
let ally = world
|
||||
.spawn((
|
||||
Guid(ALLY),
|
||||
GlobalTransform::from_translation(Vec3::new(distance, 0.0, 0.0)),
|
||||
empty(),
|
||||
))
|
||||
.id();
|
||||
world.insert_resource(crate::target::Selection {
|
||||
target: Some(ally),
|
||||
guid: Some(ALLY),
|
||||
});
|
||||
let mut script = UiScript::new().expect("a VM");
|
||||
script.set_spell_targeting(true);
|
||||
world.insert_non_send_resource(script);
|
||||
(world, rx, ally)
|
||||
}
|
||||
|
||||
fn arm(world: &mut World, spell: u32, word: u16) {
|
||||
world.resource_mut::<SpellTargeting>().enter(
|
||||
spell,
|
||||
super::super::cast_send::CastCommit::Spell,
|
||||
word,
|
||||
);
|
||||
}
|
||||
|
||||
/// Queue `SpellTargetUnit(token)` in the VM, then run the host's drain.
|
||||
fn spell_target_unit(world: &mut World, token: &str) {
|
||||
world
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run(&format!("SpellTargetUnit({token:?})"))
|
||||
.expect("a known token");
|
||||
world
|
||||
.run_system_cached(drain_spell_target_unit)
|
||||
.expect("the drain runs");
|
||||
}
|
||||
|
||||
fn errors(world: &mut World) -> Vec<crate::ui_action::CastFail> {
|
||||
std::mem::take(&mut world.resource_mut::<crate::ui_action::CastErrors>().0)
|
||||
}
|
||||
|
||||
/// `0x6e6d90` past the binding's checks: a token naming no unit ends the cast with "Out of
|
||||
/// range."; a unit goes to `BindTarget`, which waits on a relation failure, raises "Out of
|
||||
/// range." for a unit out of range with the cursor still up, and commits otherwise. The
|
||||
/// selection never moves.
|
||||
#[test]
|
||||
fn spell_target_unit_ends_waits_or_binds_as_the_reference() {
|
||||
let fail = |reason| vec![crate::ui_action::CastFail::local(HEAL, reason)];
|
||||
|
||||
// The VM queued a token, but the host is no longer targeting: nothing.
|
||||
let (mut world, rx, _) = unit_world(10.0);
|
||||
spell_target_unit(&mut world, "target");
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(errors(&mut world).is_empty());
|
||||
|
||||
// A known token naming no unit: "Out of range." and targeting ends.
|
||||
let (mut world, rx, _) = unit_world(10.0);
|
||||
arm(&mut world, HEAL, 0x0002);
|
||||
spell_target_unit(&mut world, "party1");
|
||||
assert!(rx.try_recv().is_err(), "no send");
|
||||
assert_eq!(errors(&mut world), fail(0x59));
|
||||
assert!(
|
||||
!world.resource::<SpellTargeting>().active(),
|
||||
"the cast ends"
|
||||
);
|
||||
|
||||
// A unit the relation refuses (assist, neutral with no catalog): silent, still armed.
|
||||
let (mut world, rx, _) = unit_world(10.0);
|
||||
arm(&mut world, HEAL, 0x0100);
|
||||
spell_target_unit(&mut world, "target");
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(errors(&mut world).is_empty(), "no error");
|
||||
assert!(world.resource::<SpellTargeting>().active(), "still armed");
|
||||
|
||||
// Ourselves under a spell that excludes its caster: silent, still armed.
|
||||
let (mut world, rx, _) = unit_world(10.0);
|
||||
arm(&mut world, NOT_SELF, 0x0002);
|
||||
spell_target_unit(&mut world, "player");
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"never ourselves under AttributesEx 0x80000"
|
||||
);
|
||||
assert!(errors(&mut world).is_empty(), "no error");
|
||||
assert!(world.resource::<SpellTargeting>().active(), "still armed");
|
||||
|
||||
// In relation but out of range: "Out of range." and still armed.
|
||||
let (mut world, rx, _) = unit_world(40.0);
|
||||
arm(&mut world, HEAL, 0x0002);
|
||||
spell_target_unit(&mut world, "target");
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert_eq!(errors(&mut world), fail(0x59));
|
||||
assert!(world.resource::<SpellTargeting>().active(), "still armed");
|
||||
|
||||
// In range: the cast goes to the unit and the cursor comes down.
|
||||
let (mut world, rx, ally) = unit_world(10.0);
|
||||
arm(&mut world, HEAL, 0x0002);
|
||||
spell_target_unit(&mut world, "target");
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::CastSpell {
|
||||
spell_id: HEAL,
|
||||
target: Some(ALLY),
|
||||
})
|
||||
));
|
||||
assert!(errors(&mut world).is_empty());
|
||||
assert!(!world.resource::<SpellTargeting>().active());
|
||||
let selection = world.resource::<crate::target::Selection>();
|
||||
assert_eq!((selection.target, selection.guid), (Some(ally), Some(ALLY)));
|
||||
}
|
||||
|
||||
/// `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]
|
||||
fn spell_can_target_unit_is_nil_out_of_range() {
|
||||
let can = |distance: f32, spell: u32, token: &str| {
|
||||
let (mut world, _rx, _) = unit_world(distance);
|
||||
arm(&mut world, spell, 0x0002);
|
||||
world
|
||||
.run_system_cached(feed_targeting_to_vm)
|
||||
.expect("the feed runs");
|
||||
world
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<bool>(&format!("return SpellCanTargetUnit({token:?}) == true"))
|
||||
.expect("a boolean")
|
||||
};
|
||||
assert!(can(10.0, HEAL, "target"), "in range");
|
||||
assert!(!can(40.0, HEAL, "target"), "out of range answers nil");
|
||||
assert!(can(40.0, HEAL, "player"), "ourselves at distance 0");
|
||||
assert!(
|
||||
!can(10.0, NOT_SELF, "player"),
|
||||
"never ourselves under AttributesEx 0x80000"
|
||||
);
|
||||
assert!(can(10.0, NOT_SELF, "target"));
|
||||
assert!(can(20.0, BANDED, "target"), "inside the band");
|
||||
assert!(
|
||||
!can(5.0, BANDED, "target"),
|
||||
"inside the minimum answers nil"
|
||||
);
|
||||
}
|
||||
|
||||
/// `0x6e61a0`: the pick takes us only while targeting, for a word in `0x850e` whose spell
|
||||
/// lacks `AttributesEx & 0x80000`; [`PicksSelf`] carries it to the picker.
|
||||
#[test]
|
||||
fn the_pick_takes_us_only_for_a_friendly_word_that_admits_the_caster() {
|
||||
let (mut world, _rx, _) = unit_world(10.0);
|
||||
let picks = |world: &mut World| {
|
||||
world
|
||||
.run_system_cached(publish_picks_self)
|
||||
.expect("the flag publishes");
|
||||
world.resource::<PicksSelf>().0
|
||||
};
|
||||
world.init_resource::<PicksSelf>();
|
||||
assert!(!picks(&mut world), "never outside targeting");
|
||||
for word in [0x0002, 0x0004, 0x0008, 0x0100, 0x0400, 0x8000, 0x0102] {
|
||||
arm(&mut world, HEAL, word);
|
||||
assert!(picks(&mut world), "{word:#06x} takes us");
|
||||
}
|
||||
for word in [0x0080, 0x0200, 0x0040, 0x0010, 0x4800] {
|
||||
arm(&mut world, HEAL, word);
|
||||
assert!(!picks(&mut world), "{word:#06x} never takes us");
|
||||
}
|
||||
arm(&mut world, NOT_SELF, 0x0100);
|
||||
assert!(!picks(&mut world), "a spell that excludes its caster");
|
||||
world.resource_mut::<SpellTargeting>().clear();
|
||||
assert!(!picks(&mut world), "and off again when the cursor ends");
|
||||
}
|
||||
|
||||
/// A terrain click while a poison is armed must not ship a DEST block for an item spell.
|
||||
#[test]
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
//! the pick flags (`0x481050`) come only from the word `0xcecac0`, so the word picks the leg:
|
||||
//!
|
||||
//! - terrain (`0x492c90` → `0x492580` → `BindLocation 0x6e60f0`): [`commit_ground_cast_on_click`]
|
||||
//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`):
|
||||
//! [`commit_object_cast_on_click`]
|
||||
//! - object (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), whose GameObject or
|
||||
//! unit arm the picked object's type chooses: [`commit_object_cast_on_click`]
|
||||
//!
|
||||
//! A location-only word yields pick flags 3, whose `& 0x7c == 0` disables the object pick, so an
|
||||
//! AoE reticle clicks through a chest. Neither leg gates on range, validity or the lock: the
|
||||
//! server judges and its `SMSG_CAST_RESULT` is the refusal.
|
||||
//! AoE reticle clicks through a chest. The terrain and GameObject arms gate on nothing, not range,
|
||||
//! validity or the lock: the server judges and its `SMSG_CAST_RESULT` is the refusal. The unit arm
|
||||
//! runs `BindTarget`'s relation and range checks ([`super::bind_target_unit`]).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -68,21 +69,21 @@ pub(crate) fn commit_ground_cast_on_click(
|
||||
/// 0x493540`, whose first act while targeting is `BindTarget 0x6e5b40` and return, so the click
|
||||
/// never changes the player's target and never reaches the GameObject's unselectable check.
|
||||
///
|
||||
/// `BindTarget` picks its arm by the clicked object's typemask; the GameObject arm tests the word's
|
||||
/// `0x4800`, writes wire bit `TARGET_FLAG_GAMEOBJECT` (`0x800`), clears those `0x4800` word bits,
|
||||
/// parks the guid (`0xceac60`), and the zero word lets it call `SendCast 0x6e54f0`. A unit click
|
||||
/// under a lock word binds nothing, since every unit arm tests a bit the word lacks; this is
|
||||
/// [`go_is_nearest`] here.
|
||||
///
|
||||
/// There is no gate before the send, 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.
|
||||
/// `BindTarget` picks its arm by the clicked object's typemask. The GameObject arm, when a word in
|
||||
/// `0x4800` puts GameObjects in the pick and one is the nearest hit ([`go_is_nearest`]), writes
|
||||
/// wire bit `TARGET_FLAG_GAMEOBJECT` (`0x800`), clears those `0x4800` word bits, parks the guid
|
||||
/// (`0xceac60`), and the zero word lets it call `SendCast 0x6e54f0`, with no gate before the send,
|
||||
/// 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`]); a unit click under a lock
|
||||
/// word binds nothing there, since every unit arm tests a bit the word lacks.
|
||||
///
|
||||
/// Runs after `select_on_click`, as the terrain commit does.
|
||||
pub(crate) fn commit_object_cast_on_click(
|
||||
mut clicks: MessageReader<WorldClick>,
|
||||
// The press's pick: the object the gesture started on.
|
||||
press: Res<crate::target::PressPick>,
|
||||
checks: super::UnitBindChecks,
|
||||
mut ladder: crate::spell::CastLadder,
|
||||
) {
|
||||
let (hovered, hovered_object) = (press.hovered, press.object);
|
||||
@@ -94,14 +95,18 @@ pub(crate) fn commit_object_cast_on_click(
|
||||
if clicks.read().last().is_none() {
|
||||
return;
|
||||
}
|
||||
// `TargetingWantsGameObject 0x6e62d0`: a poison's bare `0x10` word binds nothing here.
|
||||
// `TargetingWantsGameObject 0x6e62d0`: without it the pick holds no GameObject.
|
||||
if !(ladder.ground.wants(TargetingWants::GameObject)
|
||||
&& go_is_nearest(&hovered, &hovered_object))
|
||||
{
|
||||
if let Some((entity, guid)) = hovered.target.zip(hovered.guid) {
|
||||
super::bind_target_unit(&mut ladder, &checks, entity, guid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((spell_id, commit)) = ladder.ground.pending_for(TargetingWants::GameObject) else {
|
||||
return;
|
||||
};
|
||||
// The object leg only wins when a GameObject is the nearest hit.
|
||||
if !go_is_nearest(&hovered, &hovered_object) {
|
||||
return;
|
||||
}
|
||||
let Some(guid) = hovered_object.guid else {
|
||||
return;
|
||||
};
|
||||
@@ -130,6 +135,7 @@ mod tests {
|
||||
world.insert_resource(NetCommands(tx));
|
||||
world.init_resource::<crate::items::Items>();
|
||||
world.init_resource::<crate::net::GuidIndex>();
|
||||
world.insert_resource(crate::net::Reputations(Vec::new()));
|
||||
world.init_resource::<crate::spell::PendingCast>();
|
||||
world.init_resource::<crate::spell::QueuedMeleeSpell>();
|
||||
world.init_resource::<crate::spell::Cooldowns>();
|
||||
@@ -266,6 +272,158 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A unit word waits for a valid unit click and sends that unit in the ordinary
|
||||
/// `TARGET_FLAG_UNIT` spell shape. An assist word does not bind a neutral unit.
|
||||
#[test]
|
||||
fn a_click_on_a_hovered_unit_commits_the_hand_cursor_cast() {
|
||||
const HEAL: u32 = 2050;
|
||||
const ALLY: u64 = 0xF130_0000_0000_0001;
|
||||
let (mut world, rx, id) = fixture();
|
||||
world.resource_mut::<super::super::SpellTargeting>().enter(
|
||||
HEAL,
|
||||
crate::spell::cast_send::CastCommit::Spell,
|
||||
0x0002,
|
||||
);
|
||||
world.resource_mut::<crate::target::PressPick>().hovered = Hovered {
|
||||
target: Some(Entity::from_raw_u32(1).unwrap()),
|
||||
guid: Some(ALLY),
|
||||
distance: 5.0,
|
||||
..Hovered::default()
|
||||
};
|
||||
click(&mut world, id);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::CastSpell {
|
||||
spell_id: HEAL,
|
||||
target: Some(ALLY),
|
||||
})
|
||||
));
|
||||
assert!(
|
||||
!world.resource::<super::super::SpellTargeting>().active(),
|
||||
"the unit bind clears the hand cursor"
|
||||
);
|
||||
|
||||
// With no faction catalog the target's reaction is neutral (3): an ASSIST word must stay
|
||||
// armed and send nothing, exactly as when that unit was selected before the cast.
|
||||
let (mut world, rx, id) = fixture();
|
||||
world.resource_mut::<super::super::SpellTargeting>().enter(
|
||||
HEAL,
|
||||
crate::spell::cast_send::CastCommit::Spell,
|
||||
0x0100,
|
||||
);
|
||||
world.resource_mut::<crate::target::PressPick>().hovered = Hovered {
|
||||
target: Some(Entity::from_raw_u32(1).unwrap()),
|
||||
guid: Some(ALLY),
|
||||
distance: 5.0,
|
||||
..Hovered::default()
|
||||
};
|
||||
click(&mut world, id);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"a neutral unit must not receive the heal"
|
||||
);
|
||||
assert!(
|
||||
world.resource::<super::super::SpellTargeting>().active(),
|
||||
"an invalid unit click leaves the targeting word standing"
|
||||
);
|
||||
|
||||
// A unit word puts no GameObject in the pick, so a chest nearer than the unit is no
|
||||
// obstacle: the unit arm still binds.
|
||||
let (mut world, rx, id) = fixture();
|
||||
world.resource_mut::<super::super::SpellTargeting>().enter(
|
||||
HEAL,
|
||||
crate::spell::cast_send::CastCommit::Spell,
|
||||
0x0002,
|
||||
);
|
||||
hover_go(&mut world, 2.0);
|
||||
world.resource_mut::<crate::target::PressPick>().hovered = Hovered {
|
||||
target: Some(Entity::from_raw_u32(1).unwrap()),
|
||||
guid: Some(ALLY),
|
||||
distance: 5.0,
|
||||
..Hovered::default()
|
||||
};
|
||||
click(&mut world, id);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::CastSpell {
|
||||
spell_id: HEAL,
|
||||
target: Some(ALLY),
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
/// `BindTarget`'s range leg (`6e6063`): a unit the relation checks accept but out of the
|
||||
/// spell's range raises "Out of range." and the cursor stays armed; a click in range commits.
|
||||
/// Row 5 is 0 to 30 yd, padded by both 1.5 combat reaches to 33.
|
||||
#[test]
|
||||
fn an_out_of_range_unit_click_raises_and_keeps_the_cursor() {
|
||||
use std::collections::HashMap;
|
||||
const HEAL: u32 = 2050;
|
||||
const ALLY: u64 = 0xF130_0000_0000_0001;
|
||||
let (mut world, rx, id) = fixture();
|
||||
let mut spells = crate::ui_action::Spells::empty_for_tests();
|
||||
spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([(
|
||||
HEAL,
|
||||
benilla_formats::SpellDisplay {
|
||||
range_index: 5,
|
||||
..Default::default()
|
||||
},
|
||||
)]));
|
||||
spells.ranges = benilla_formats::SpellRangeCatalog::from_rows(HashMap::from([(
|
||||
5,
|
||||
benilla_formats::SpellRange {
|
||||
min: 0.0,
|
||||
max: 30.0,
|
||||
flags: 0,
|
||||
},
|
||||
)]));
|
||||
world.insert_resource(spells);
|
||||
world.spawn((crate::net::SelfPlayer, GlobalTransform::default()));
|
||||
let ally = world
|
||||
.spawn(GlobalTransform::from_translation(Vec3::new(40.0, 0.0, 0.0)))
|
||||
.id();
|
||||
world.resource_mut::<super::super::SpellTargeting>().enter(
|
||||
HEAL,
|
||||
crate::spell::cast_send::CastCommit::Spell,
|
||||
0x0002,
|
||||
);
|
||||
world.resource_mut::<crate::target::PressPick>().hovered = Hovered {
|
||||
target: Some(ally),
|
||||
guid: Some(ALLY),
|
||||
distance: 5.0,
|
||||
..Hovered::default()
|
||||
};
|
||||
|
||||
click(&mut world, id);
|
||||
assert!(rx.try_recv().is_err(), "out of range sends nothing");
|
||||
assert_eq!(
|
||||
std::mem::take(&mut world.resource_mut::<crate::ui_action::CastErrors>().0),
|
||||
vec![crate::ui_action::CastFail::local(HEAL, 0x59)],
|
||||
"\"Out of range.\""
|
||||
);
|
||||
assert!(
|
||||
world.resource::<super::super::SpellTargeting>().active(),
|
||||
"the cursor stays armed"
|
||||
);
|
||||
|
||||
world
|
||||
.entity_mut(ally)
|
||||
.insert(GlobalTransform::from_translation(Vec3::new(10.0, 0.0, 0.0)));
|
||||
click(&mut world, id);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(ClientCommand::CastSpell {
|
||||
spell_id: HEAL,
|
||||
target: Some(ALLY),
|
||||
})
|
||||
));
|
||||
assert!(world
|
||||
.resource::<crate::ui_action::CastErrors>()
|
||||
.0
|
||||
.is_empty());
|
||||
assert!(!world.resource::<super::super::SpellTargeting>().active());
|
||||
}
|
||||
|
||||
/// Every way the object click must not commit.
|
||||
#[test]
|
||||
fn the_object_commit_holds_its_fire() {
|
||||
|
||||
@@ -83,6 +83,37 @@ pub(super) struct PickPose<'w, 's> {
|
||||
rigs: Query<'w, 's, &'static benilla_world::rig_palette::RigSkin>,
|
||||
}
|
||||
|
||||
/// The local identity and whether the pick takes it, one [`SystemParam`] under the picker's param
|
||||
/// limit.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(super) struct TargetHoverState<'w> {
|
||||
/// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause.
|
||||
self_guid: Res<'w, crate::net::SelfGuid>,
|
||||
/// `0x6e61a0`'s pick flag `0x20`, as the frame began.
|
||||
picks_self: Res<'w, crate::spell::PicksSelf>,
|
||||
}
|
||||
|
||||
/// The posed pick's geometry under one model root: its skinned parts. Ours are dropped while
|
||||
/// hidden: first person hides them ([`crate::player::apply_self_model_fade`] at alpha 0), where the
|
||||
/// reference's camera fade clears its player-visible flag `[0xc4d74c]` (`0x482c41`) and
|
||||
/// `ShouldRender 0x5ec8f0` refuses the active player, so it is never picked.
|
||||
#[allow(clippy::type_complexity)] // the picker's own part query, borrowed as-is
|
||||
fn posed_parts<'a>(
|
||||
kids: &Children,
|
||||
parts: &'a Query<(
|
||||
&Mesh3d,
|
||||
&benilla_world::rig_palette::RigPart,
|
||||
Option<&InheritedVisibility>,
|
||||
)>,
|
||||
is_self: bool,
|
||||
) -> Vec<(&'a Mesh3d, &'a benilla_world::rig_palette::RigPart)> {
|
||||
kids.iter()
|
||||
.filter_map(|c| parts.get(c).ok())
|
||||
.filter(|(_, _, drawn)| !is_self || drawn.is_none_or(|v| v.get()))
|
||||
.map(|(mesh, part, _)| (mesh, part))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The unit under the cursor, as the reference's pick (`0x7089c0`) finds it. Broad phase: the ray
|
||||
/// against the playing sequence's bounds sphere. Pass 1: the ray against the posed render mesh,
|
||||
/// nearest wins. Pass 2, the mouse pick's retry when pass 1 hit nothing anywhere: every vertex
|
||||
@@ -102,8 +133,8 @@ pub(super) fn update_hover(
|
||||
mut hovered: ResMut<Hovered>,
|
||||
mesh_assets: Res<Assets<Mesh>>,
|
||||
pose: PickPose,
|
||||
// For `IsSelectable`'s `UNIT_FIELD_CREATEDBY` clause.
|
||||
self_guid: Res<crate::net::SelfGuid>,
|
||||
// Our own body joins the pick only while a targeting word takes it (`0x6e61a0`).
|
||||
target_state: TargetHoverState,
|
||||
// Last frame's pick, which outranks everything in pass 2 (the reference's anti-flicker cache).
|
||||
mut last_pick: Local<Option<Entity>>,
|
||||
// Unit roots. An undrawn body is not in the reference's draw list, so it takes no mouseover,
|
||||
@@ -111,6 +142,7 @@ pub(super) fn update_hover(
|
||||
roots: Query<
|
||||
(
|
||||
Entity,
|
||||
Has<SelfPlayer>,
|
||||
&GlobalTransform,
|
||||
&NetEntity,
|
||||
Option<&ModelAnimations>,
|
||||
@@ -121,11 +153,15 @@ pub(super) fn update_hover(
|
||||
Option<&InheritedVisibility>,
|
||||
Option<&HeldAttached>,
|
||||
),
|
||||
(With<Guid>, Without<SelfPlayer>),
|
||||
With<Guid>,
|
||||
>,
|
||||
// The part children of every pick model root: body, worn items and mount.
|
||||
child_sets: Query<&Children>,
|
||||
parts: Query<(&Mesh3d, &benilla_world::rig_palette::RigPart)>,
|
||||
parts: Query<(
|
||||
&Mesh3d,
|
||||
&benilla_world::rig_palette::RigPart,
|
||||
Option<&InheritedVisibility>,
|
||||
)>,
|
||||
// The box fallback: every `CreaturePickPart`, the fallback cube included.
|
||||
meshes: Query<
|
||||
(
|
||||
@@ -137,7 +173,7 @@ pub(super) fn update_hover(
|
||||
With<CreaturePickPart>,
|
||||
>,
|
||||
// The pick's guid and kind; the kind decides which slot of `Hovered` a hit lands in.
|
||||
units: Query<(&Guid, &NetEntity), Without<SelfPlayer>>,
|
||||
units: Query<(&Guid, &NetEntity)>,
|
||||
) {
|
||||
hovered.target = None;
|
||||
hovered.guid = None;
|
||||
@@ -178,7 +214,12 @@ pub(super) fn update_hover(
|
||||
let mut candidates: Vec<(Entity, u8, Vec<AssetId<Mesh>>, Vec<Mat4>)> = Vec::new();
|
||||
// Units with skinned parts: out of the box fallback even when the broad phase rejects them.
|
||||
let mut faithful: HashSet<Entity> = HashSet::new();
|
||||
for (entity, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots {
|
||||
let picks_self = target_state.picks_self.0;
|
||||
for (entity, is_self, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots
|
||||
{
|
||||
if is_self && !picks_self {
|
||||
continue;
|
||||
}
|
||||
// Units, players and corpses: the reference picks every CGObject in one trace and
|
||||
// switches on type at the end, below.
|
||||
if !matches!(
|
||||
@@ -241,7 +282,7 @@ pub(super) fn update_hover(
|
||||
let Ok(kids) = child_sets.get(root) else {
|
||||
continue;
|
||||
};
|
||||
let sk: Vec<_> = kids.iter().filter_map(|c| parts.get(c).ok()).collect();
|
||||
let sk = posed_parts(kids, &parts, is_self);
|
||||
let Some(palette) = palette_of(&sk) else {
|
||||
continue;
|
||||
};
|
||||
@@ -280,6 +321,9 @@ pub(super) fn update_hover(
|
||||
if faithful.contains(&parent) {
|
||||
continue; // posed-mesh-tested above
|
||||
}
|
||||
if !picks_self && roots.get(parent).is_ok_and(|root| root.1) {
|
||||
continue;
|
||||
}
|
||||
// The same kinds as the posed pick. A bone pile lands here: its corpse model has no
|
||||
// skeleton, so only this box test can pick it.
|
||||
let Ok((_, parent_net)) = units.get(parent) else {
|
||||
@@ -331,13 +375,13 @@ pub(super) fn update_hover(
|
||||
if net.kind == EntityKind::Corpse {
|
||||
hovered.corpse = Some(entity);
|
||||
hovered.corpse_guid = Some(guid.0);
|
||||
} else if selectable_pick(entity, &roots, self_guid.0) {
|
||||
} else if selectable_pick(entity, &roots, target_state.self_guid.0) {
|
||||
hovered.target = Some(entity);
|
||||
hovered.guid = Some(guid.0);
|
||||
} else {
|
||||
hovered.refused = true;
|
||||
}
|
||||
} else if selectable_pick(entity, &roots, self_guid.0) {
|
||||
} else if selectable_pick(entity, &roots, target_state.self_guid.0) {
|
||||
hovered.target = Some(entity);
|
||||
} else {
|
||||
hovered.refused = true;
|
||||
@@ -364,6 +408,7 @@ fn selectable_pick(
|
||||
roots: &Query<
|
||||
(
|
||||
Entity,
|
||||
Has<SelfPlayer>,
|
||||
&GlobalTransform,
|
||||
&NetEntity,
|
||||
Option<&ModelAnimations>,
|
||||
@@ -374,11 +419,11 @@ fn selectable_pick(
|
||||
Option<&InheritedVisibility>,
|
||||
Option<&HeldAttached>,
|
||||
),
|
||||
(With<Guid>, Without<SelfPlayer>),
|
||||
With<Guid>,
|
||||
>,
|
||||
self_guid: Option<u64>,
|
||||
) -> bool {
|
||||
let store = roots.get(entity).ok().and_then(|r| r.5);
|
||||
let store = roots.get(entity).ok().and_then(|r| r.6);
|
||||
super::relations::is_selectable(store, self_guid)
|
||||
}
|
||||
|
||||
@@ -766,6 +811,121 @@ mod tests {
|
||||
assert_eq!(resolved[4], net, "the net entity resolves to itself");
|
||||
}
|
||||
|
||||
/// First person hides our own parts (`Visibility::Hidden`), and the posed pick drops them, as
|
||||
/// `ShouldRender 0x5ec8f0` refuses the active player then; anyone else's stay the root's call.
|
||||
#[test]
|
||||
fn our_hidden_parts_leave_the_posed_pick() {
|
||||
let mut world = World::new();
|
||||
let root = world.spawn_empty().id();
|
||||
let rig = world.spawn_empty().id();
|
||||
let part = |world: &mut World, drawn| {
|
||||
world
|
||||
.spawn((
|
||||
ChildOf(root),
|
||||
Mesh3d(Handle::default()),
|
||||
benilla_world::rig_palette::RigPart(rig),
|
||||
drawn,
|
||||
))
|
||||
.id()
|
||||
};
|
||||
let shown = part(&mut world, InheritedVisibility::VISIBLE);
|
||||
part(&mut world, InheritedVisibility::HIDDEN);
|
||||
let count = move |is_self: bool| {
|
||||
move |kids: Query<&Children>,
|
||||
parts: Query<(
|
||||
&Mesh3d,
|
||||
&benilla_world::rig_palette::RigPart,
|
||||
Option<&InheritedVisibility>,
|
||||
)>| {
|
||||
posed_parts(kids.get(root).expect("the parts"), &parts, is_self).len()
|
||||
}
|
||||
};
|
||||
assert_eq!(world.run_system_once(count(false)).unwrap(), 2);
|
||||
assert_eq!(
|
||||
world.run_system_once(count(true)).unwrap(),
|
||||
1,
|
||||
"ours: only the drawn part"
|
||||
);
|
||||
world.entity_mut(shown).insert(InheritedVisibility::HIDDEN);
|
||||
assert_eq!(
|
||||
world.run_system_once(count(true)).unwrap(),
|
||||
0,
|
||||
"first person: nothing of ours to pick"
|
||||
);
|
||||
}
|
||||
|
||||
/// Our own body joins the unit pick only while [`crate::spell::PicksSelf`] says the word takes
|
||||
/// it (`0x480610` at `480638`), here through the box fallback of a body with no skinned parts.
|
||||
#[test]
|
||||
fn our_body_joins_the_pick_only_while_the_word_takes_it() {
|
||||
use bevy::camera::RenderTargetInfo;
|
||||
use bevy::window::WindowResolution;
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<CameraControl>();
|
||||
world.init_resource::<PointerOverUi>();
|
||||
world.insert_resource(PickOcclusion {
|
||||
distance: f32::INFINITY,
|
||||
point: None,
|
||||
});
|
||||
world.init_resource::<crate::vplates::PlateHover>();
|
||||
world.init_resource::<Hovered>();
|
||||
world.init_resource::<Assets<Mesh>>();
|
||||
world.init_resource::<benilla_world::rig_palette::RigPalettes>();
|
||||
world.insert_resource(crate::net::SelfGuid(Some(1)));
|
||||
world.init_resource::<crate::spell::PicksSelf>();
|
||||
// Ten yards up the Z axis, looking down it through a 100-pixel square.
|
||||
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 {
|
||||
physical_size: UVec2::splat(100),
|
||||
scale_factor: 1.0,
|
||||
});
|
||||
world.spawn((
|
||||
camera,
|
||||
GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 10.0)),
|
||||
WorldCamera,
|
||||
));
|
||||
let mut window = Window {
|
||||
resolution: WindowResolution::new(100, 100),
|
||||
..Default::default()
|
||||
};
|
||||
window.set_cursor_position(Some(Vec2::splat(50.0)));
|
||||
world.spawn((window, PrimaryWindow));
|
||||
// Us, at the origin under the cursor, with one drawn pick box.
|
||||
let me = world
|
||||
.spawn((
|
||||
SelfPlayer,
|
||||
Guid(1),
|
||||
NetEntity {
|
||||
kind: EntityKind::Player,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
GlobalTransform::default(),
|
||||
InheritedVisibility::VISIBLE,
|
||||
))
|
||||
.id();
|
||||
world.spawn((
|
||||
ChildOf(me),
|
||||
CreaturePickPart,
|
||||
Aabb::from_min_max(Vec3::splat(-1.0), Vec3::splat(1.0)),
|
||||
GlobalTransform::default(),
|
||||
InheritedVisibility::VISIBLE,
|
||||
));
|
||||
let hover = |world: &mut World, picks: bool| {
|
||||
world.resource_mut::<crate::spell::PicksSelf>().0 = picks;
|
||||
world
|
||||
.run_system_cached(update_hover)
|
||||
.expect("the pick runs");
|
||||
world.resource::<Hovered>().target
|
||||
};
|
||||
|
||||
assert_eq!(hover(&mut world, false), None, "not ours to pick");
|
||||
assert_eq!(hover(&mut world, true), Some(me), "a word that takes us");
|
||||
assert_eq!(hover(&mut world, false), None, "and not once it ends");
|
||||
}
|
||||
|
||||
/// Naxxramas's "Unholy Axe" is an `InvisibleStalker` body with no vertices holding a real axe,
|
||||
/// which is its whole pick surface.
|
||||
#[test]
|
||||
|
||||
@@ -310,13 +310,18 @@ impl Plugin for TargetPlugin {
|
||||
crate::spell::targeting::commit_object_cast_on_click,
|
||||
click::act_on_right_click,
|
||||
click::clear_target_requests,
|
||||
// The unit-token asks (`TargetUnit`, `AssistUnit`, `TargetLastEnemy`: one
|
||||
// drain, as the reference has one `0x489a40`) and `DropItemOnUnit`'s pet leg,
|
||||
// independent of each other.
|
||||
// The UI's unit-token drains. `SpellTargetUnit` first: it binds the cursor's
|
||||
// cast and never moves the selection. Then the selection asks (`TargetUnit`,
|
||||
// `AssistUnit`, `TargetLastEnemy`: one drain, as the reference has one
|
||||
// `0x489a40`) and `DropItemOnUnit`'s pet leg, independent of each other.
|
||||
(
|
||||
click::selection_requests,
|
||||
crate::ui_action::drop_item::drop_item_on_unit,
|
||||
),
|
||||
crate::spell::targeting::drain_spell_target_unit,
|
||||
(
|
||||
click::selection_requests,
|
||||
crate::ui_action::drop_item::drop_item_on_unit,
|
||||
),
|
||||
)
|
||||
.chain(),
|
||||
// The by-name asks: `/target`, the Lua `TargetByName` and `/assist` commit
|
||||
// through `scan::commit`; `/follow` hands its subject to `crate::player`.
|
||||
(
|
||||
|
||||
@@ -202,7 +202,7 @@ pub(super) fn old_target_cleared(previous: Option<u64>, now: Option<u64>) -> boo
|
||||
/// The old-target clear's `PetStopAttack` (`0x493910`, at `0x493a18` when `0x5ee5a0` finds a
|
||||
/// possessed unit at `0x493a0f`). It sits before the notify branch, so every selection writer runs
|
||||
/// it; a re-select runs no clear (`0x493540`).
|
||||
pub(super) fn pet_stop_on_old_target_clear(
|
||||
pub(crate) fn pet_stop_on_old_target_clear(
|
||||
selection: Res<Selection>,
|
||||
mut previous: Local<Option<u64>>,
|
||||
mut bar: ResMut<PetBar>,
|
||||
|
||||
@@ -19,7 +19,8 @@ mod net;
|
||||
mod unit;
|
||||
|
||||
use bar::feed_pet_bar;
|
||||
use drain::{drain_pet_actions, pet_stop_on_old_target_clear};
|
||||
use drain::drain_pet_actions;
|
||||
pub(crate) use drain::pet_stop_on_old_target_clear;
|
||||
use menu::{drain_pet_menu, feed_pet_menu};
|
||||
use unit::feed_pet_unit;
|
||||
|
||||
|
||||
@@ -624,7 +624,7 @@ impl UnitTokens<'_, '_> {
|
||||
}
|
||||
|
||||
/// Every token [`UnitTokens`] resolves, `"player"` included (the reference answers d² = 0).
|
||||
fn reach_tokens() -> impl Iterator<Item = &'static str> {
|
||||
pub(crate) fn reach_tokens() -> impl Iterator<Item = &'static str> {
|
||||
["player", "target", "targettarget", "mouseover", "pet"]
|
||||
.into_iter()
|
||||
.chain(crate::ui_party::PARTY_TOKENS)
|
||||
|
||||
@@ -366,6 +366,12 @@ impl SpellDisplay {
|
||||
self.attributes_ex3 & ATTR_EX3_NO_CHANNEL_BAR != 0
|
||||
}
|
||||
|
||||
/// `AttributesEx & 0x80000`: the targeting cursor never takes the caster, neither the world
|
||||
/// pick (`0x6e61a0` at `6e61cf`) nor `SpellCanTargetUnit`'s unit leg (`0x6e6460` at `6e6507`).
|
||||
pub fn excludes_caster(&self) -> bool {
|
||||
self.attributes_ex & ATTR_EX_EXCLUDE_CASTER != 0
|
||||
}
|
||||
|
||||
/// `AttributesEx & 0x2000_0000` (`0x6e759a`): the channel bar shows the spell's own name
|
||||
/// (`0x6e75a9`), else `CHANNELING` (`0x6e75bc`); nine channels set it, Fishing among them.
|
||||
pub fn channel_bar_own_name(&self) -> bool {
|
||||
|
||||
@@ -197,6 +197,9 @@ const ATTR_DO_NOT_DISPLAY: u32 = 0x80;
|
||||
pub const SPELL_ATTR_IS_TRADESKILL: u32 = 0x20;
|
||||
/// `SPELL_ATTR_EX_NO_AURA_ICON`: hidden from the aura bar, as all three warrior stances are.
|
||||
const ATTR_EX_NO_AURA_ICON: u32 = 0x1000_0000;
|
||||
/// `SPELL_ATTR_EX_EXCLUDE_CASTER` (vmangos `SpellDefines.h:851`): the targeting cursor never takes
|
||||
/// the caster (`0x6e61cf`, `0x6e6507`).
|
||||
const ATTR_EX_EXCLUDE_CASTER: u32 = 0x0008_0000;
|
||||
|
||||
/// `SPELL_EFFECT_ATTACK`: only 6603 "Attack" has it, but the client tests the effect, not the id.
|
||||
const SPELL_EFFECT_ATTACK: u32 = 78;
|
||||
|
||||
@@ -92,6 +92,11 @@ impl SpellRangeCatalog {
|
||||
self.ranges.get(&index)
|
||||
}
|
||||
|
||||
/// Fixture constructor for tests; the live path is [`load_spell_ranges`].
|
||||
pub fn from_rows(ranges: HashMap<u32, SpellRange>) -> Self {
|
||||
Self { ranges }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.ranges.len()
|
||||
}
|
||||
|
||||
@@ -403,9 +403,10 @@ impl super::UiScript {
|
||||
self.model_mut().item_pick_armed = armed;
|
||||
}
|
||||
|
||||
/// Push what `SpellCanTargetUnit` answers: whether the armed targeting cast can take a unit.
|
||||
pub fn set_spell_can_target_unit(&mut self, can: bool) {
|
||||
self.model_mut().spell_can_target_unit = can;
|
||||
/// Push the unit tokens for which the armed word's unit checks all pass.
|
||||
pub fn set_spell_targetable_units<'a>(&mut self, tokens: impl IntoIterator<Item = &'a str>) {
|
||||
self.model_mut().spell_targetable_units =
|
||||
tokens.into_iter().map(str::to_ascii_lowercase).collect();
|
||||
}
|
||||
|
||||
/// Drain the `(bag, slot)` picks since the last call; a paper-doll pick reports as
|
||||
|
||||
@@ -243,6 +243,8 @@ pub(crate) struct Model {
|
||||
pub(crate) target_clear: bool,
|
||||
/// `DropItemOnUnit` tokens (`0x48d960`), gated by the app; a refusal silently keeps the item.
|
||||
pub(crate) drop_item_on_unit: Vec<String>,
|
||||
/// `SpellTargetUnit` tokens: a unit frame binds the spell currently waiting on the cursor.
|
||||
pub(crate) spell_target_unit: Vec<String>,
|
||||
|
||||
/// Channels the server confirmed, in join order: the numbers `GetChannelName` answers.
|
||||
pub(crate) joined_channels: Vec<Option<String>>,
|
||||
@@ -432,9 +434,9 @@ pub(crate) struct Model {
|
||||
/// Spell targeting is on (`SpellIsTargeting`, `0x6e6cd0`); it gates `SpellStopTargeting()`,
|
||||
/// whose nil the ESC chain falls through on (`UIParent.lua:1490`).
|
||||
pub(crate) spell_targeting: bool,
|
||||
/// `SpellCanTargetUnit` (`0x6e6d00`, `0x6e6460`): false while targeting models only location,
|
||||
/// item and object words; a unit-target spell resolves or refuses without entering targeting.
|
||||
pub(crate) spell_can_target_unit: bool,
|
||||
/// Tokens for which `SpellCanTargetUnit`'s armed unit word clears fully, used by stock unit
|
||||
/// frames before they call `SpellTargetUnit`.
|
||||
pub(crate) spell_targetable_units: HashSet<String>,
|
||||
/// `SpellStopTargeting()` fired while targeting: the ESC targeting cancel.
|
||||
pub(crate) spell_stop_targeting: bool,
|
||||
|
||||
@@ -1061,6 +1063,7 @@ impl Model {
|
||||
target_nearest_friend_requests: Vec::new(),
|
||||
target_by_name_requests: Vec::new(),
|
||||
drop_item_on_unit: Vec::new(),
|
||||
spell_target_unit: Vec::new(),
|
||||
target_clear: false,
|
||||
joined_channels: Vec::new(),
|
||||
party: party::PartyState::default(),
|
||||
@@ -1148,7 +1151,7 @@ impl Model {
|
||||
casting: false,
|
||||
spell_stop: false,
|
||||
spell_targeting: false,
|
||||
spell_can_target_unit: false,
|
||||
spell_targetable_units: HashSet::new(),
|
||||
spell_stop_targeting: false,
|
||||
talents: super::talent::TalentUiState::default(),
|
||||
talent_learns: Vec::new(),
|
||||
|
||||
@@ -596,14 +596,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the targeting
|
||||
// word can take the unit. The token is not read: no word benilla can arm (location, item,
|
||||
// gameobject) takes a unit, and unit words are not built.
|
||||
// SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the standing
|
||||
// word clears against that resolved unit.
|
||||
g.set(
|
||||
"SpellCanTargetUnit",
|
||||
lua.create_function(|lua, _unit: Option<String>| {
|
||||
lua.create_function(|lua, unit: Option<String>| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
if model.spell_can_target_unit {
|
||||
let can = unit.is_some_and(|unit| {
|
||||
model
|
||||
.spell_targetable_units
|
||||
.contains(&unit.to_ascii_lowercase())
|
||||
});
|
||||
if can {
|
||||
Ok(Value::Boolean(true))
|
||||
} else {
|
||||
Ok(Value::Nil)
|
||||
|
||||
@@ -1079,12 +1079,31 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SpellTargetUnit(unit): a no-op. A unit-target spell never enters targeting mode here, and for
|
||||
// the location, item and gameobject modes this client arms, the reference's `BindTarget`
|
||||
// (`0x6e5b40`) rejects a unit too. Stock `PetFrame_OnClick` calls it, so it must exist.
|
||||
// SpellTargetUnit(unit) (`0x6e6d90`): an argument that is not a string or a number raises
|
||||
// `Usage:`; while not targeting the call is a silent no-op, before the token is read; an
|
||||
// unknown token then raises `Unknown unit name`. The token queues for the host, which ends
|
||||
// targeting with "Out of range." when it names no unit and otherwise runs `BindTarget`
|
||||
// (`0x6e5b10` → `0x6e5b40`).
|
||||
g.set(
|
||||
"SpellTargetUnit",
|
||||
lua.create_function(|_, _token: Option<String>| Ok(()))?,
|
||||
lua.create_function(|lua, token: Value| {
|
||||
let token = crate::script::binding_abi::string_arg(
|
||||
lua,
|
||||
token,
|
||||
r#"Usage: SpellTargetUnit("unit")"#,
|
||||
)?;
|
||||
if !lua
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.spell_targeting
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
check_unit_token(&Some(token.clone()))?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.spell_target_unit.push(token);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ClearTarget(): 1 when it cleared a target, nil when there was none, which `ToggleGameMenu`'s
|
||||
|
||||
@@ -411,6 +411,11 @@ impl super::UiScript {
|
||||
pub fn take_drop_item_on_unit(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.model_mut().drop_item_on_unit)
|
||||
}
|
||||
|
||||
/// Drain `SpellTargetUnit` calls for the host's pending unit-target spell binder.
|
||||
pub fn take_spell_target_unit(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.model_mut().spell_target_unit)
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-`"player"` token of a two-unit call such as `UnitIsEnemy(a, b)`, which the snapshot
|
||||
|
||||
@@ -23,6 +23,56 @@ fn player() -> UnitState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x6e6d90`'s order: the `Usage:` check, the not-targeting no-op, the token check, the queue.
|
||||
#[test]
|
||||
fn spell_target_unit_checks_in_the_references_order() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let usage = |s: &mut UiScript, call: &str| {
|
||||
let err = s.run(call).expect_err(call).to_string();
|
||||
assert!(
|
||||
err.contains(r#"Usage: SpellTargetUnit("unit")"#),
|
||||
"{call}: {err}"
|
||||
);
|
||||
};
|
||||
|
||||
// Not targeting: the argument is still type-checked, then nothing, not even the token check.
|
||||
usage(&mut s, "SpellTargetUnit({})");
|
||||
usage(&mut s, "SpellTargetUnit()");
|
||||
s.run(r#"SpellTargetUnit("not-a-unit") SpellTargetUnit("player")"#)
|
||||
.expect("a bad token is no error while not targeting");
|
||||
assert!(s.take_spell_target_unit().is_empty(), "and nothing queues");
|
||||
|
||||
// Targeting: an unknown token raises, a known one queues, resolved or not.
|
||||
s.set_spell_targeting(true);
|
||||
usage(&mut s, "SpellTargetUnit(nil)");
|
||||
let err = s
|
||||
.run(r#"SpellTargetUnit("not-a-unit")"#)
|
||||
.expect_err("an unknown token while targeting")
|
||||
.to_string();
|
||||
assert!(err.contains("Unknown unit name: not-a-unit"), "{err}");
|
||||
s.run(r#"SpellTargetUnit("player") SpellTargetUnit("party1")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_spell_target_unit(),
|
||||
vec!["player".to_string(), "party1".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spell_can_target_unit_answers_per_validated_token() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_spell_targetable_units(["player", "party1"]);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return SpellCanTargetUnit("player") == true"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return SpellCanTargetUnit("PARTY1") == true"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return SpellCanTargetUnit("target") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_reaction_reports_the_scale_value_or_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user