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:
co-authored by
Claude Opus 5.5
Sam
parent
33c2c5aa70
commit
b8cb886210
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user