fix: select the nearest enemy when a combat ability is pressed with no target (#503)

- Sinister Strike, Heroic Strike, Judgement and the other combat-starting spells pressed with no target select the nearest enemy, as the Attack button does
- A finisher pressed with no combo points refuses before the attack pick, and a self-cast press always Tabs past the caster
- The plain cast send skips the attack pick because no 1.12 item, form, recipe or chained spell carries predicate 0x6e5200, and a real-catalog test pins it

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:
Mathih13
2026-09-29 09:31:42 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Sam
parent 33c2c5aa70
commit b8cb886210
14 changed files with 452 additions and 74 deletions
+161 -31
View File
@@ -36,39 +36,51 @@ pub(crate) fn apply_script_calls(
let Some(mut script) = script else {
return;
};
in_call_order(&mut script, |script, call| match call {
ScriptCall::Select(request) => appliers.p0().select(request),
ScriptCall::TargetByName { name, exact } => appliers.p0().target_by_name(&name, exact),
ScriptCall::TargetNearest { mode, reverse } => {
appliers.p0().target_nearest(mode, reverse);
}
ScriptCall::TargetLastTarget => appliers.p0().target_last_target(),
ScriptCall::ClearTarget => appliers.p0().clear_target(),
ScriptCall::AttackTarget => appliers.p7().attack_target(),
ScriptCall::SpellTargetUnit(token) => appliers.p1().spell_target_unit(&token),
ScriptCall::SpellStopTargeting => appliers.p1().stop_targeting(),
ScriptCall::SpellStopCasting => appliers.p2().stop_casting(),
ScriptCall::CastSpell(spell_id) => {
crate::ui_spellbook::cast_spell(&mut appliers.p3(), spell_id);
}
ScriptCall::CastShapeshiftForm(spell_id) => {
crate::ui_shapeshift::cast_form(&mut appliers.p3(), spell_id);
}
ScriptCall::UseAction(press) => {
let used = crate::ui_action::use_action(&mut appliers.p4(), script, press);
if used == crate::ui_action::UseOutcome::Attack {
appliers.p7().attack_target();
in_call_order(&mut script, |script, call| {
match call {
ScriptCall::Select(request) => appliers.p0().select(request),
ScriptCall::TargetByName { name, exact } => appliers.p0().target_by_name(&name, exact),
ScriptCall::TargetNearest { mode, reverse } => {
appliers.p0().target_nearest(mode, reverse);
}
ScriptCall::TargetLastTarget => appliers.p0().target_last_target(),
ScriptCall::ClearTarget => appliers.p0().clear_target(),
ScriptCall::AttackTarget => appliers.p7().attack_target(),
ScriptCall::SpellTargetUnit(token) => appliers.p1().spell_target_unit(&token),
ScriptCall::SpellStopTargeting => appliers.p1().stop_targeting(),
ScriptCall::SpellStopCasting => appliers.p2().stop_casting(),
ScriptCall::CastSpell(spell_id) => {
crate::ui_spellbook::cast_spell(&mut appliers.p3(), spell_id);
}
ScriptCall::CastShapeshiftForm(spell_id) => {
crate::ui_shapeshift::cast_form(&mut appliers.p3(), spell_id);
}
ScriptCall::UseAction(press) => {
let used = crate::ui_action::use_action(&mut appliers.p4(), script, press);
if used == crate::ui_action::UseOutcome::Attack {
appliers.p7().attack_target();
}
}
ScriptCall::UseContainerItem { bag, slot } => {
appliers.p5().use_container_item(script, bag, slot);
}
ScriptCall::UseInventoryItem(id) => appliers.p5().use_inventory_item(script, id),
ScriptCall::CastPetSpell(spell_id) => {
crate::ui_pet_book::cast_pet_spell(&mut appliers.p6(), spell_id);
}
ScriptCall::PetAction(slot) => appliers.p6().press_slot(slot),
ScriptCall::PetOrder(packed) => appliers.p6().order(packed),
}
ScriptCall::UseContainerItem { bag, slot } => {
appliers.p5().use_container_item(script, bag, slot);
// TryCast's attack pick can move the selection mid-cast (`0x6e4efb`), so a press held
// there is picked and resumed before the next call reads the selection.
let Some(held) = appliers.p3().ladder.take_held() else {
return;
};
if appliers.p7().pick_for_cast(held) {
let mut cast = appliers.p3();
let ctx = cast.targeting.context();
cast.ladder.resume_after_pick(held, &ctx);
}
ScriptCall::UseInventoryItem(id) => appliers.p5().use_inventory_item(script, id),
ScriptCall::CastPetSpell(spell_id) => {
crate::ui_pet_book::cast_pet_spell(&mut appliers.p6(), spell_id);
}
ScriptCall::PetAction(slot) => appliers.p6().press_slot(slot),
ScriptCall::PetOrder(packed) => appliers.p6().order(packed),
});
}
@@ -108,7 +120,9 @@ mod tests {
ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer,
};
use crate::target::Selection;
use benilla_protocol::messages::{ActionButton, GroupMemberEntry, ACTION_KIND_MACRO};
use benilla_protocol::messages::{
ActionButton, GroupMemberEntry, ACTION_KIND_MACRO, ACTION_KIND_SPELL,
};
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView};
use bevy::ecs::system::RunSystemOnce;
use crossbeam_channel::Receiver;
@@ -568,4 +582,120 @@ mod tests {
assert_eq!(casts(&f), vec![(HEAL, Some(OLD))]);
assert_eq!(selected(&f), Some(NEW));
}
/// Sinister Strike: `AttributesEx & 0x200`, one of predicate `0x6e5200`'s bits, and the enemy
/// word (implicit target 6).
const SINISTER_STRIKE: u32 = 1752;
/// Sinister Strike in the catalog and on action slot 1.
fn with_strike(f: &mut Frame) {
let world = f.app.world_mut();
let strike = benilla_formats::SpellDisplay {
attributes_ex: 0x200,
implicit_target_a1: 6,
..Default::default()
};
world.insert_resource(crate::ui_action::Spells {
catalog: benilla_formats::SpellCatalog::from_displays(
[(SINISTER_STRIKE, strike)].into(),
),
..crate::ui_action::Spells::empty_for_tests()
});
world
.resource_mut::<crate::ui_action::PlayerActions>()
.buttons
.insert(
0,
ActionButton {
slot: 0,
action: SINISTER_STRIKE,
kind: ACTION_KIND_SPELL,
},
);
}
/// The selections, strikes and swings sent, in wire order.
fn strike_wire(f: &Frame) -> Vec<(&'static str, u64)> {
f.rx.try_iter()
.filter_map(|c| match c {
ClientCommand::SetSelection { guid } => Some(("select", guid)),
ClientCommand::CastSpell {
spell_id: SINISTER_STRIKE,
target: Some(guid),
} => Some(("cast", guid)),
ClientCommand::AttackSwing { guid } => Some(("swing", guid)),
_ => None,
})
.collect()
}
/// TryCast's attack pick (`0x6e4efb` → `0x612df0`): a strike pressed with no target selects the
/// nearest enemy before its bind, so the selection goes out ahead of the cast and its swing,
/// and the call after it reads the selection only once the cast is done.
#[test]
fn a_strike_with_no_target_selects_the_nearest_enemy_and_casts_at_it() {
let mut f = frame(false);
spawn_mob(&mut f, &[]);
with_strike(&mut f);
run(&mut f, r#"UseAction(1) TargetUnit("party1")"#);
assert_eq!(
strike_wire(&f),
vec![
("select", MOB),
("cast", MOB),
("swing", MOB),
("select", NEW)
]
);
}
/// A self-cast press passes the caster to the pick (`0x4e610e`), and the reaction to oneself is
/// 4 (`0x606200`), so the pick Tabs to the enemy. With no faction table the caster would read
/// neutral and be kept.
#[test]
fn a_self_cast_strike_selects_the_nearest_enemy() {
let mut f = frame(false);
spawn_mob(&mut f, &[]);
with_strike(&mut f);
run(&mut f, "UseAction(1, 0, 1)");
assert_eq!(
strike_wire(&f),
vec![("select", MOB), ("cast", MOB), ("swing", MOB)]
);
}
/// The pick's keep test (`0x6130a3`) drops a friend, so a strike at one retargets the nearest
/// enemy, where the bind alone would refuse it as an invalid target.
#[test]
fn a_strike_at_a_friend_retargets_the_nearest_enemy() {
let mut f = frame(true);
with_factions(&mut f, FRIEND, &[]);
spawn_mob(&mut f, &[(TEMPLATE, FOE)]);
with_strike(&mut f);
run(&mut f, "UseAction(1)");
assert_eq!(
strike_wire(&f),
vec![("select", MOB), ("cast", MOB), ("swing", MOB)]
);
}
/// `0x6130d9`: with no enemy to acquire, the strike stops at the pick with "There is nothing to
/// attack.", not the bind's "You have no target.".
#[test]
fn a_strike_with_nothing_to_attack_says_so_and_sends_nothing() {
let mut f = frame(false);
with_strike(&mut f);
run(&mut f, "UseAction(1)");
assert_eq!(casts(&f), vec![]);
assert_eq!(selected(&f), None);
let world = f.app.world();
assert_eq!(
world.resource::<crate::ui_action::UiErrorKeys>().0,
vec![crate::ui_action::UiError::key("ERR_NO_ATTACK_TARGET")]
);
assert!(world
.resource::<crate::ui_action::CastErrors>()
.0
.is_empty());
}
}
+178 -10
View File
@@ -3,11 +3,11 @@
//!
//! The rungs: the auto-repeat toggle-off (the reference's action-button handler, ahead of TryCast;
//! its order against the profession intercept is unobservable), the profession intercept, dead or a
//! ghost, the targeting abort, in-flight, reagents and totems, the equipped item, target binding
//! and range, then the validator `0x6094f0` (not-ready and GCD, power, crowd control, mounted,
//! water, moving, form), the deferred cast-arm refusal and the targeting cursor. The commit tail
//! follows: ranged stance, auto-repeat arm, the send, the auto-attack start, the GCD. A refusal is
//! local and pre-commit: no packet, no GCD, no pending arm, only the red line.
//! ghost, the targeting abort, in-flight, reagents and totems, the equipped item, combo points, the
//! attack pick, target binding and range, then the validator `0x6094f0` (not-ready and GCD, power,
//! crowd control, mounted, water, moving, form), the deferred cast-arm refusal and the targeting
//! cursor. The commit tail follows: ranged stance, auto-repeat arm, the send, the auto-attack start,
//! the GCD. A refusal is local and pre-commit: no packet, no GCD, no pending arm, only the red line.
//!
//! An item use takes the same ladder: `CGItem::Use 0x5d8d00` calls `0x6e5a90`, whose body is
//! `call 0x6e4b60` with the item as TryCast's third argument (read at `6e4d76` and `6e4f33`). Three
@@ -64,6 +64,22 @@ impl CastCommit {
}
}
/// The class byte TryCast's finisher rung tests (`6e4e98`).
const CLASS_ROGUE: u8 = 4;
/// A player's spell press held at TryCast's attack pick (`6e4edf` → `0x612df0`), which can move
/// the selection the ladder only reads. [`crate::script_calls`] runs the pick and resumes it.
#[derive(Resource, Default)]
pub(crate) struct HeldForPick(Option<HeldCast>);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct HeldCast {
pub(crate) spell_id: u32,
/// Pressed with the self-cast modifier: TryCast's guid argument is then the caster
/// (`0x4e610e`), which the pick tries before the selection.
pub(crate) on_self: bool,
}
/// Everything the cast-send path reads, as one [`SystemParam`]: [`CastLadder::send`] is the only
/// way into [`send_spell_cast`], for every caster surface.
#[derive(SystemParam)]
@@ -86,6 +102,7 @@ pub(crate) struct CastLadder<'w, 's> {
pub(crate) auto_repeat: ResMut<'w, AutoRepeatActive>,
pub(crate) trade_skill_opens: ResMut<'w, crate::ui_tradeskill::TradeSkillOpens>,
pub(crate) ground: ResMut<'w, super::targeting::SpellTargeting>,
pub(crate) held_for_pick: ResMut<'w, HeldForPick>,
}
/// What a targeting-cursor click bound: what `BindLocation 0x6e60f0` or `BindTarget 0x6e5b40`
@@ -189,14 +206,38 @@ impl CastLadder<'_, '_> {
caster_dead_refusal(spell_id, def, self_store, &mut self.cast_errors)
}
/// Run the ladder for `spell_id` and commit as `commit` says.
/// Run the ladder for `spell_id` and commit as `commit` says, with no attack pick: no 1.12
/// item use, form, craft or `modalNextSpell` chain casts a spell of predicate `0x6e5200`.
pub(crate) fn send(
&mut self,
spell_id: u32,
ctx: &cast_target::CastContext,
commit: CastCommit,
) {
self.send_bound(spell_id, ctx, commit, None);
self.send_bound(spell_id, ctx, commit, None, None);
}
/// A player's spell press (`UseAction`, `CastSpell`, `CastSpellByName`): the ladder with
/// TryCast's attack pick, where the cast may stop in [`HeldForPick`].
pub(crate) fn send_spell(
&mut self,
spell_id: u32,
ctx: &cast_target::CastContext,
on_self: bool,
) {
let held = HeldCast { spell_id, on_self };
self.send_bound(spell_id, ctx, CastCommit::Spell, None, Some(held));
}
/// The press held at the attack pick, if one is.
pub(crate) fn take_held(&mut self) -> Option<HeldCast> {
self.held_for_pick.0.take()
}
/// A held press after its pick, at the unit the pick left selected. The rungs above the pick
/// run again; none reads the selection, so each passes as it did.
pub(crate) fn resume_after_pick(&mut self, held: HeldCast, ctx: &cast_target::CastContext) {
self.send_bound(held.spell_id, ctx, CastCommit::Spell, None, None);
}
/// The ladder for a cast the caller already bound to a GameObject (a lock opener): TryCast
@@ -208,7 +249,7 @@ impl CastLadder<'_, '_> {
ctx: &cast_target::CastContext,
go_guid: u64,
) {
self.send_bound(spell_id, ctx, CastCommit::Spell, Some(go_guid));
self.send_bound(spell_id, ctx, CastCommit::Spell, Some(go_guid), None);
}
fn send_bound(
@@ -217,12 +258,14 @@ impl CastLadder<'_, '_> {
ctx: &cast_target::CastContext,
commit: CastCommit,
on_object: Option<u64>,
hold: Option<HeldCast>,
) {
send_spell_cast(
spell_id,
ctx,
commit,
on_object,
hold,
&self.commands,
&self.self_player,
self.spells.as_deref(),
@@ -238,6 +281,7 @@ impl CastLadder<'_, '_> {
&mut self.auto_repeat,
&mut self.trade_skill_opens,
&mut self.ground,
&mut self.held_for_pick,
);
}
}
@@ -275,6 +319,8 @@ fn send_spell_cast(
commit: CastCommit,
// A caller-bound GameObject for a spell commit; an item commit carries its own.
bound_object: Option<u64>,
// Set for a player's spell press, which parks in `held_for_pick` at the attack pick.
hold: Option<HeldCast>,
commands: &NetCommands,
self_player: &Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
spells: Option<&Spells>,
@@ -290,6 +336,7 @@ fn send_spell_cast(
auto_repeat: &mut AutoRepeatActive,
trade_skill_opens: &mut crate::ui_tradeskill::TradeSkillOpens,
ground: &mut super::targeting::SpellTargeting,
held_for_pick: &mut HeldForPick,
) {
let now = Instant::now();
let def = spells.and_then(|s| s.catalog.get(spell_id));
@@ -358,6 +405,34 @@ fn send_spell_cast(
}
}
}
// The finisher rung (`6e4e50`–`6e4ecd`), above the attack pick: a finishing move
// (`AttributesEx & 0x500000`) with the combo-point byte at 0 refuses 0x46 "That ability
// requires combo points" for a rogue (class byte 4, `6e4e98`), else 0x12. Like the button
// greying, it reads no target.
if let (Some(d), Some(store)) = (def, ctx.rel.self_store) {
if d.needs_combo_points() && store.0.player_combo_points().unwrap_or(0) == 0 {
let reason = if store.0.unit_class() == Some(CLASS_ROGUE) {
0x46
} else {
0x12
};
debug!("ui_action: cast {spell_id} refused locally — no combo points ({reason:#x})");
cast_errors.push_local(spell_id, reason);
return;
}
}
// TryCast's attack pick (`6e4edf`–`6e4f02`): a spell of predicate `0x6e5200`, pressed while
// not attacking (`0x60ecb0`), goes through the attack validator `0x612df0`, which keeps a
// hostile selection or selects the nearest enemy. It sits above the bind and the range gate,
// so an acquired target out of reach reads "Out of range.".
if let Some(held) = hold {
let engaged = self_player.single().is_ok_and(|(_, engaged)| engaged);
if def.is_some_and(|d| d.initiates_combat()) && !engaged {
debug!("ui_action: cast {spell_id} holds for the attack pick");
held_for_pick.0 = Some(held);
return;
}
}
// ArmCast (`0x6e5250`) resolves the wire target from the spell's targeting constraints, never
// the raw selection. A caller-bound GameObject skips the walk, as TryCast's resolve (`6e4ef4` →
// `0x612df0`) takes a passed guid pair.
@@ -754,6 +829,7 @@ mod tests {
world.init_resource::<AutoRepeatActive>();
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
world.init_resource::<crate::spell::SpellTargeting>();
world.init_resource::<crate::spell::HeldForPick>();
// A bare World has no message storage until it is asked for.
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
@@ -926,11 +1002,13 @@ mod tests {
const AUTO_SHOT: u32 = 75;
const RAPTOR_STRIKE: u32 = 2973;
const SERPENT_STING: u32 = 1978;
const EVISCERATE: u32 = 2098;
const MOB: u64 = 0x0000_0000_0000_00f1;
/// Auto Shot (auto-repeat `AttributesEx2 & 0x20`, ranged `Attributes & 0x2`), Raptor Strike
/// (on-next-swing `0x404`) and Serpent Sting, as the 1.12 rows classify them. `Targets & 0x2`
/// binds the selection without faction data.
/// (on-next-swing `0x404`), Serpent Sting and Eviscerate (a finisher, `AttributesEx 0x100000`,
/// that starts combat, `0x200`), as the 1.12 rows classify them. `Targets & 0x2` binds the
/// selection without faction data.
fn combat_catalog() -> Spells {
use std::collections::HashMap;
let auto_shot = benilla_formats::SpellDisplay {
@@ -953,11 +1031,17 @@ mod tests {
modal_next_spell: AUTO_SHOT,
..Default::default()
};
let eviscerate = benilla_formats::SpellDisplay {
targets: 0x2,
attributes_ex: 0x0010_0200,
..Default::default()
};
Spells {
catalog: benilla_formats::SpellCatalog::from_displays(HashMap::from([
(AUTO_SHOT, auto_shot),
(RAPTOR_STRIKE, raptor_strike),
(SERPENT_STING, serpent_sting),
(EVISCERATE, eviscerate),
])),
..Spells::empty_for_tests()
}
@@ -1076,6 +1160,90 @@ mod tests {
.expect("the ladder runs as a one-shot system");
}
/// TryCast's attack pick (`6e4edf`): a press of a `0x6e5200` spell stops there, above the
/// bind, for its owner to pick a unit. Attacking skips the pick (`0x60ecb0`), and neither a
/// spell without the bits nor a send that is not a press stops.
#[test]
fn a_combat_press_holds_at_the_attack_pick_unless_attacking() {
let press = |world: &mut World, spell_id: u32| {
world
.run_system_once(move |mut ladder: CastLadder| {
ladder.send_spell(spell_id, &ctx(), false);
ladder.take_held()
})
.expect("the ladder runs as a one-shot system")
};
let (mut world, rx) = combat_world(false);
assert_eq!(
press(&mut world, RAPTOR_STRIKE),
Some(HeldCast {
spell_id: RAPTOR_STRIKE,
on_self: false
})
);
assert!(rx.try_recv().is_err(), "a held press sends nothing");
assert!(world.resource::<CastErrors>().0.is_empty());
assert_eq!(press(&mut world, SERPENT_STING), None, "no 0x6e5200 bit");
let (mut world, _rx) = combat_world(true);
assert_eq!(press(&mut world, RAPTOR_STRIKE), None, "attacking");
let (mut world, _rx) = combat_world(false);
send(&mut world, RAPTOR_STRIKE, CastCommit::Spell);
assert_eq!(world.resource::<HeldForPick>().0, None, "not a press");
}
/// The rungs above the pick refuse before it: a press mid-cast (`6e4d97`) and a finisher with no
/// combo points (`6e4e50`) hold nothing, so they never select a unit. A rogue reads 0x46, any
/// other class 0x12 (`6e4e98`).
#[test]
fn the_rungs_above_the_attack_pick_refuse_before_it() {
use benilla_protocol::ObjectFields;
let (mut world, rx) = combat_world(false);
world
.resource_mut::<crate::spell::PendingCast>()
.arm(SERPENT_STING, Instant::now(), true);
let held = world
.run_system_once(|mut ladder: CastLadder| {
ladder.send_spell(RAPTOR_STRIKE, &ctx(), false);
ladder.take_held()
})
.expect("the ladder runs as a one-shot system");
assert_eq!(held, None, "in flight");
assert_eq!(
world.resource::<CastErrors>().0,
vec![CastFail::local(RAPTOR_STRIKE, 0x61)]
);
assert!(rx.try_recv().is_err());
// `UNIT_FIELD_BYTES_0` (field 36) byte 1 is the class; `PLAYER_FIELD_BYTES` holds no
// combo points.
for (class, reason) in [(4u32, 0x46), (11, 0x12)] {
let (mut world, rx) = combat_world(false);
let held = world
.run_system_once(move |mut ladder: CastLadder| {
let me = crate::net::ObjectStore(ObjectFields::from_pairs(&[(36, class << 8)]));
let ctx = cast_target::CastContext {
rel: cast_target::TargetRelations {
self_store: Some(&me),
..ctx().rel
},
..ctx()
};
ladder.send_spell(EVISCERATE, &ctx, false);
ladder.take_held()
})
.expect("the ladder runs as a one-shot system");
assert_eq!(held, None, "class {class}: no combo points");
assert_eq!(
world.resource::<CastErrors>().0,
vec![CastFail::local(EVISCERATE, reason)],
"class {class}"
);
assert!(rx.try_recv().is_err());
}
}
/// The commit's StopAttack (`0x6e5976`): `CMSG_ATTACKSTOP` (`0x624370`), then `CancelQueuedCast
/// 0x6e6f30` hands the queued strike to `CancelCast 0x6e4940(dl=1)` and its `CMSG_CANCEL_CAST`.
#[test]
+2 -1
View File
@@ -23,7 +23,7 @@ pub(crate) mod validator;
// The one cast path: every caster takes [`CastLadder`] and commits through [`CastCommit`];
// `send_spell_cast` is private to `cast_send`, so no second send path can exist.
pub(crate) use cast_send::{CastCommit, CastLadder, TargetedBind};
pub(crate) use cast_send::{CastCommit, CastLadder, HeldCast, HeldForPick, TargetedBind};
pub(crate) use cast_target::AutoSelfCast;
pub(crate) use cooldowns::Cooldowns;
pub(crate) use inflight::{
@@ -62,6 +62,7 @@ impl Plugin for SpellPlugin {
.init_resource::<SpellModifiers>()
.init_resource::<AutoSelfCast>()
.init_resource::<SpellTargeting>()
.init_resource::<HeldForPick>()
.init_resource::<targeting::EnchantConfirmItem>()
.init_resource::<targeting::PicksSelf>()
.init_resource::<targeting::CorpsePick>()
@@ -505,6 +505,7 @@ mod tests {
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
world.init_resource::<SpellTargeting>();
world.init_resource::<crate::spell::HeldForPick>();
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();
@@ -149,6 +149,7 @@ mod tests {
world.init_resource::<crate::spell::AutoRepeatActive>();
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
world.init_resource::<super::super::SpellTargeting>();
world.init_resource::<crate::spell::HeldForPick>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<WorldClick>>();
// The commit legs read the press latch, not the live hover.
+1 -1
View File
@@ -1230,7 +1230,7 @@ mod tests {
mut sel: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
mut errors: ResMut<crate::ui_action::UiErrorKeys>| {
pick.target(&mut sel, &mut seam, &mut errors)
pick.target(None, &mut sel, &mut seam, &mut errors)
},
)
.expect("the scan runs on the built client")
+17 -10
View File
@@ -633,21 +633,24 @@ impl AttackPick<'_, '_> {
(self.scan.self_store(), self.scan.self_guid())
}
/// The guid the swing or the pet's order goes at, or `None` once the refusal is raised. The
/// candidate is the selection (`0x61306b`): `AttackTarget` passes none (`0x489b50` →
/// `0x6131a0(0,0)`), nor does the pet bar (`0x4be42b`). An empty selection (`0x61307f`) or a
/// held unit the actor is friendly toward (`0x6130a3`, dropped at `0x6130a8`) runs
/// `TargetNearestEnemy` (`0x6130b5`), which commits its pick before this returns, so a call
/// after this one reads it. The target is then the selection as it stands (`0x6130c1`): a
/// fruitless scan leaves a friendly unit selected for the final gate to refuse, and only an
/// empty selection is `ERR_NO_ATTACK_TARGET` (`0x6130d9`).
/// The guid the swing, the pet's order or the cast goes at, or `None` once the refusal is
/// raised. The candidate is the guid the caller passed, else the selection (`0x61306b`):
/// `AttackTarget` passes none (`0x489b50` → `0x6131a0(0,0)`), nor does the pet bar
/// (`0x4be42b`); TryCast passes the caster on a self-cast press (`0x4e610e`). An empty
/// candidate (`0x61307f`) or a unit the actor is friendly toward (`0x6130a3`, dropped at
/// `0x6130a8`), the caster included, runs `TargetNearestEnemy` (`0x6130b5`), which commits its
/// pick before this returns, so a call after this one reads it. The target is then the
/// selection as it stands (`0x6130c1`): a fruitless scan leaves a friendly unit selected for
/// the final gate to refuse, and only an empty selection is `ERR_NO_ATTACK_TARGET`
/// (`0x6130d9`).
pub(crate) fn target(
&mut self,
passed: Option<u64>,
selection: &mut Selection,
seam: &mut crate::creature_anim::AttackSeam,
errors: &mut crate::ui_action::UiErrorKeys,
) -> Option<u64> {
if !selection.guid.is_some_and(|g| self.keeps(g)) {
if !passed.or(selection.guid).is_some_and(|g| self.keeps(g)) {
let engaged = self.engaged();
cycle(
ScanSide::Enemy,
@@ -694,8 +697,12 @@ impl AttackPick<'_, '_> {
/// (`0x613099`) is kept as well, for the second resolve to refuse. Actor → target is
/// [`reaction_from_player`] (the at-war bit), never `ring_reaction`. The reference's actor is
/// the caller, a pet on the pet arm (`0x4bd40d`), which vmangos gives its owner's faction
/// (`Pet.cpp:248`).
/// (`Pet.cpp:248`). The reaction to oneself is 4 before any faction rung (`0x606200`), so a
/// passed caster is never kept, FFA flag or not.
fn keeps(&self, guid: u64) -> bool {
if self.scan.self_guid() == Some(guid) {
return false;
}
self.scan.store_of(guid).is_none_or(|store| {
reaction_from_player(
self.scan.factions.as_deref(),
+28 -14
View File
@@ -11,7 +11,7 @@ use benilla_ui::script::UiScript;
use crate::net::{ClientCommand, NetCommands};
use crate::spell::{cast_target, CastCommit, CastLadder};
use crate::spell::{cast_target, CastLadder};
use super::{attack_actor_refusal, PlayerActions, UiErrorKeys, SPELL_ATTACK};
@@ -85,19 +85,10 @@ impl AttackPress<'_, '_> {
/// (`0x5ecb70`). Only the start cancels auto-repeat (`0x5ecd8c`), so stopping melee leaves
/// Auto Shot running.
pub(crate) fn attack_target(&mut self) {
let Self {
pick,
selection,
seam,
ui_errors,
} = self;
let (me, my_guid) = pick.player();
if attack_actor_refusal(me, my_guid, ui_errors) {
return;
}
let Some(guid) = pick.target(selection, seam, ui_errors) else {
let Some(guid) = self.validate(None) else {
return;
};
let Self { pick, seam, .. } = self;
let Ok(e) = seam.me.single() else {
return;
};
@@ -117,6 +108,29 @@ impl AttackPress<'_, '_> {
&seam.net,
);
}
/// TryCast's attack pick (`0x6e4efb`) for a spell press held there
/// ([`crate::spell::HeldForPick`]): the validator alone, with no swing after it. Whether the
/// cast goes on, at the unit the pick left selected.
pub(crate) fn pick_for_cast(&mut self, held: crate::spell::HeldCast) -> bool {
let caster = self.pick.player().1.filter(|_| held.on_self);
self.validate(caster).is_some()
}
/// The attack validator `0x612df0`: its actor checks, then its target pick.
fn validate(&mut self, passed: Option<u64>) -> Option<u64> {
let Self {
pick,
selection,
seam,
ui_errors,
} = self;
let (me, my_guid) = pick.player();
if attack_actor_refusal(me, my_guid, ui_errors) {
return None;
}
pick.target(passed, selection, seam, ui_errors)
}
}
/// Sends the queued GameObject openers through the cast ladder. The reference reaches `TryCast`
@@ -265,10 +279,10 @@ pub(crate) fn use_action(
selection.guid,
if press.on_self { ", on self" } else { "" }
);
ladder.send(
ladder.send_spell(
b.action,
&self_bound(targeting.context(), press),
CastCommit::Spell,
press.on_self,
);
}
// An item action names an entry, so the click finds a copy. A miss only logs, with no
@@ -445,6 +445,7 @@ mod tests {
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::ActiveChannel>()
.init_resource::<crate::spell::SpellTargeting>()
.init_resource::<crate::spell::HeldForPick>()
.init_resource::<Selection>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::net::Reputations>()
@@ -539,6 +540,7 @@ mod tests {
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::ActiveChannel>()
.init_resource::<crate::spell::SpellTargeting>()
.init_resource::<crate::spell::HeldForPick>()
.init_resource::<Selection>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::net::Reputations>()
+2
View File
@@ -803,6 +803,7 @@ mod tests {
.init_resource::<crate::spell::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::spell::targeting::SpellTargeting>()
.init_resource::<crate::spell::HeldForPick>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx));
@@ -1747,6 +1748,7 @@ mod bind_confirm_tests {
.init_resource::<crate::spell::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::spell::targeting::SpellTargeting>()
.init_resource::<crate::spell::HeldForPick>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx));
+1 -1
View File
@@ -90,7 +90,7 @@ impl PetPress<'_, '_> {
let mut target_guid = selection.guid.unwrap_or(0);
let refused = if is_attack_order(entry) {
crate::ui_action::attack_actor_refusal(pet_store, pet.self_guid.0, ui_errors)
|| match pick.target(selection, seam, ui_errors) {
|| match pick.target(None, selection, seam, ui_errors) {
Some(guid) => {
target_guid = guid;
false
+1 -2
View File
@@ -21,7 +21,6 @@ use benilla_ui::script::{ScriptValue, SpellBookState, SpellSlotView, SpellTabVie
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::NetCommands;
use crate::spell::CastCommit;
use crate::ui_action::{melee_auto_attack_icon, ranged_weapon_icon, PlayerActions, Spells};
use crate::ui_script::gate;
use crate::ui_unit::UnitFeed;
@@ -461,7 +460,7 @@ pub(crate) fn cast_spell(cast: &mut crate::spell::ScriptCast, spell_id: u32) {
"ui_spellbook: cast {spell_id} (target {:?})",
targeting.selection.guid
);
ladder.send(spell_id, &targeting.context(), CastCommit::Spell);
ladder.send_spell(spell_id, &targeting.context(), false);
}
#[cfg(test)]
@@ -619,7 +619,8 @@ fn real_spell_catalog_reads_tooltip_columns() {
/// The attack-start masks, rows from vmangos: [`SpellDisplay::on_next_swing`] (`0x404`),
/// [`SpellDisplay::initiates_auto_attack`] (adding `AttributesEx & 0x200`) and
/// [`SpellDisplay::initiates_auto_attack_at_go`] (`AttributesEx2 & 0x100000`).
/// [`SpellDisplay::initiates_auto_attack_at_go`] (`AttributesEx2 & 0x100000`), whose union is
/// [`SpellDisplay::initiates_combat`] (`0x6e5200`).
#[test]
fn real_spell_catalog_classifies_combat_initiation() {
let data = crate::wow_data_or_skip!();
@@ -634,6 +635,7 @@ fn real_spell_catalog_classifies_combat_initiation() {
(772, "Rend", false, true, false), // Ex 0x8000200
(7386, "Sunder Armor", false, true, false), // Ex 0x8000200
(1464, "Slam", false, true, false), // Ex 0x8000200
(1752, "Sinister Strike", false, true, false), // Ex 0x8000200
(100, "Charge", false, false, false), // Ex 0x400: neither bit, bit 20 clear
(6673, "Battle Shout", false, false, false), // Ex 0x0
(6603, "Attack", false, false, false), // the auto-attack pseudo-spell itself
@@ -677,6 +679,14 @@ fn real_spell_catalog_classifies_combat_initiation() {
"{name} ({id}) initiates_auto_attack_at_go (Ex2 {:#x})",
d.attributes_ex2
);
assert_eq!(
d.initiates_combat(),
next_swing || initiates || at_go,
"{name} ({id}) initiates_combat (Attributes {:#x}, Ex {:#x}, Ex2 {:#x})",
d.attributes,
d.attributes_ex,
d.attributes_ex2
);
// Bit 20 suppresses the send-time start, so no spell starts the attack twice.
assert!(
!(d.initiates_auto_attack() && d.initiates_auto_attack_at_go()),
@@ -717,6 +727,42 @@ fn real_spell_catalog_classifies_combat_initiation() {
);
}
/// TryCast's attack pick (`0x6e4edf`) is built for a spell press alone, so no form, recipe
/// (`SPELL_EFFECT_CREATE_ITEM` 24, `SPELL_EFFECT_ENCHANT_ITEM` 53) or `modalNextSpell` chain may
/// carry predicate `0x6e5200`. No item's use spell does either (vmangos `item_template`).
#[test]
fn real_spell_catalog_keeps_the_attack_pick_to_spell_presses() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon");
let (mut checked, mut chained) = (0, 0);
for (id, d) in cat.iter() {
let recipe = d.effects.iter().any(|&e| e == 24 || e == 53);
if d.shapeshift_form.is_some() || recipe {
checked += 1;
assert!(
!d.initiates_combat(),
"{} ({id}) is a form or a recipe of predicate 0x6e5200",
d.name
);
}
if d.modal_next_spell != 0 {
chained += 1;
let next = cat.get(d.modal_next_spell);
assert!(
!next.is_some_and(|n| n.initiates_combat()),
"{} ({id}) chains {} of predicate 0x6e5200",
d.name,
d.modal_next_spell
);
}
}
assert!(
checked > 0 && chained > 0,
"the forms, recipes and Auto Shot's chain are read"
);
}
/// `modalNextSpell`, column 38, which `0x6e7447` reads to start Auto Shot after a hunter shot:
/// nonzero on 57 of 22357 rows, 52 of them naming spell 75.
#[test]
+10 -3
View File
@@ -459,13 +459,20 @@ impl SpellDisplay {
self.attributes_ex & ATTR_EX_CHANNELED != 0
}
/// Predicate `0x6e5200`: an on-next-swing, `INITIATES_COMBAT` or `INITIATE_COMBAT_POST_CAST`
/// spell. TryCast runs the attack validator's target pick for it (`0x6e4edf`), so a press
/// with no hostile selection acquires one, as the Attack button does.
pub fn initiates_combat(&self) -> bool {
self.attributes & ATTR_ON_NEXT_SWING != 0
|| self.attributes_ex & ATTR_EX_INITIATES_COMBAT != 0
|| self.attributes_ex2 & ATTR_EX2_INITIATE_COMBAT_POST_CAST != 0
}
/// Casting this starts melee auto-attack at the send unless one runs (`TryCast` tail
/// `0x6e51b5`): predicate `0x6e5200` with `AttributesEx2` bit 20 clear, so an on-next-swing
/// or `INITIATES_COMBAT` spell. Every cast path shares the tail; there is no macro opt-out.
pub fn initiates_auto_attack(&self) -> bool {
(self.attributes & ATTR_ON_NEXT_SWING != 0
|| self.attributes_ex & ATTR_EX_INITIATES_COMBAT != 0)
&& self.attributes_ex2 & ATTR_EX2_INITIATE_COMBAT_POST_CAST == 0
self.initiates_combat() && !self.initiates_auto_attack_at_go()
}
/// This spell's own `SMSG_SPELL_GO` starts melee auto-attack (`0x6131a0`) at its first hit