The player's Attack runs the attack validator's target pick: a friend selected retargets the nearest enemy in call order, a corpse or a fruitless scan says "You cannot attack that target.", nothing selected and no enemy says "There is nothing to attack." (#249) (#501)
The player's Attack (the Attack Target key, `AttackTarget()` and the Attack action button) now picks its target as 1.12.1's validator `0x612df0` does, where before it swung at whatever was selected, a friendly unit or a corpse included. - With nothing selected, or a unit the player is friendly toward (reaction ≥ 4, `0x6130a3`), it runs the Tab cycler for the nearest enemy (`0x6130b5` → `0x493f60`), which commits the new selection at once, so `AttackTarget()` then `/cast` in one script casts at the new target. - It then reads the selection again (`0x6130c1`): none raises "There is nothing to attack." (`0x6130d9`); a unit neither alive nor feigning, or one `CanAttack` (`0x606980`) rejects, raises "You cannot attack that target." (`0x613171`). A friendly unit with no enemy near stays selected and meets that second gate. - The swing toggle runs only once a target is accepted (`0x6131a0`), so Attack at a corpse mid-swing gives the error and keeps the swing. - The pet's attack order shares the same pick. vmangos stops a swing at a friendly or dead target server-side (`CombatHandler.cpp:46-59`), which the client no longer sends. Fixes #249 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c6767d6a79
commit
bacbb791db
@@ -1,7 +1,7 @@
|
||||
//! The melee live probe (`WOW_PROBE=melee`), inert without the env: once in-world it fires the
|
||||
//! attack-nearest core ([`AttackNearestRequest`]) every few seconds, so the character fights
|
||||
//! whatever is closest while `WOW_MOVE_TRACE=<path>` records the swing, impact and combat-text
|
||||
//! timeline.
|
||||
//! The melee live probe (`WOW_PROBE=melee`), inert without the env: once in-world it presses the
|
||||
//! player's Attack with nothing selected ([`AttackNearestRequest`]) every few seconds, so the
|
||||
//! character acquires and fights whatever is nearest while `WOW_MOVE_TRACE=<path>` records the
|
||||
//! swing, impact and combat-text timeline.
|
||||
//!
|
||||
//! Never run it unattended (`docs/METHOD.md`, "The local server"): it fights with no health
|
||||
//! awareness and the character dies.
|
||||
@@ -21,7 +21,7 @@ impl Plugin for ProbeMeleePlugin {
|
||||
}
|
||||
|
||||
/// Every 3 s: swing at the selection (idempotent while auto-attacking, and it covers an attacker
|
||||
/// the acquire core skips), or with none, run the acquire core.
|
||||
/// the acquire skips), or with none, press Attack, which acquires.
|
||||
fn melee_probe(
|
||||
time: ProbeClock,
|
||||
mut last: Local<f64>,
|
||||
|
||||
@@ -674,7 +674,7 @@ pub(crate) mod schedule_tests {
|
||||
/// 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 = 4_956;
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 4_890;
|
||||
const UPDATE_ACTIONABLE_SLACK: usize = 40;
|
||||
|
||||
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(crate) fn apply_script_calls(
|
||||
crate::ui_action::ActionPress,
|
||||
crate::ui_items::ScriptItemUse,
|
||||
crate::ui_pet::PetPress,
|
||||
crate::ui_action::AttackPress,
|
||||
)>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
@@ -43,7 +44,7 @@ pub(crate) fn apply_script_calls(
|
||||
}
|
||||
ScriptCall::TargetLastTarget => appliers.p0().target_last_target(),
|
||||
ScriptCall::ClearTarget => appliers.p0().clear_target(),
|
||||
ScriptCall::AttackTarget => appliers.p4().attack_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(),
|
||||
@@ -54,7 +55,10 @@ pub(crate) fn apply_script_calls(
|
||||
crate::ui_shapeshift::cast_form(&mut appliers.p3(), spell_id);
|
||||
}
|
||||
ScriptCall::UseAction(press) => {
|
||||
crate::ui_action::use_action(&mut appliers.p4(), script, 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);
|
||||
@@ -116,6 +120,17 @@ mod tests {
|
||||
const NEW: u64 = 0x22;
|
||||
/// Not in the (absent) spell catalog, so the cast's wire target is the selection as it stands.
|
||||
const HEAL: u32 = 2050;
|
||||
/// A mob the TAB scan can pick: it alone carries a `NetEntity`.
|
||||
const MOB: u64 = 0x31;
|
||||
/// `UNIT_FIELD_FACTIONTEMPLATE`, `UNIT_FIELD_HEALTH`, `UNIT_FIELD_FLAGS`.
|
||||
const TEMPLATE: u16 = 35;
|
||||
const HEALTH: u16 = 22;
|
||||
const FLAGS: u16 = 46;
|
||||
/// The fixture's templates: ours (group 1, friendly toward group 2, enemy of group 4), a
|
||||
/// friendly NPC's (group 2) and a hostile mob's (group 4).
|
||||
const OURS: u32 = 1;
|
||||
const FRIEND: u32 = 2;
|
||||
const FOE: u32 = 3;
|
||||
|
||||
struct Frame {
|
||||
app: App,
|
||||
@@ -141,7 +156,17 @@ mod tests {
|
||||
])),
|
||||
)
|
||||
};
|
||||
// We are a player (`OBJECT_FIELD_TYPE` 0x19, `UNIT_FLAG_PVP_ATTACKABLE`), which selects
|
||||
// `CanAttack`'s player arm: with no `FactionTemplate.dbc` a plain unit is attackable.
|
||||
let me = world.spawn((SelfPlayer, unit(ME, 0.0))).id();
|
||||
world
|
||||
.entity_mut(me)
|
||||
.insert(ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(2, 0x19),
|
||||
(22, 100),
|
||||
(28, 100),
|
||||
(46, 0x8),
|
||||
])));
|
||||
let old = world.spawn(unit(OLD, 5.0)).id();
|
||||
let new = world.spawn(unit(NEW, 6.0)).id();
|
||||
let index = &mut world.resource_mut::<GuidIndex>().0;
|
||||
@@ -220,6 +245,99 @@ mod tests {
|
||||
f.app.world().resource::<Selection>().guid
|
||||
}
|
||||
|
||||
/// `MOB` at 3 yd, alive, with `fields` over its health.
|
||||
fn spawn_mob(f: &mut Frame, fields: &[(u16, u32)]) {
|
||||
let world = f.app.world_mut();
|
||||
let mut pairs = vec![(HEALTH, 100), (28, 100)];
|
||||
pairs.extend_from_slice(fields);
|
||||
let mob = world
|
||||
.spawn((
|
||||
Guid(MOB),
|
||||
crate::net::NetEntity {
|
||||
kind: benilla_protocol::EntityKind::Unit,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
Transform::from_xyz(3.0, 0.0, 0.0),
|
||||
GlobalTransform::from_translation(Vec3::new(3.0, 0.0, 0.0)),
|
||||
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&pairs)),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<GuidIndex>().0.insert(MOB, mob);
|
||||
}
|
||||
|
||||
/// Give the world a faction table, us [`OURS`] and `OLD` the `old` template with `fields`
|
||||
/// over it.
|
||||
fn with_factions(f: &mut Frame, old: u32, fields: &[(u16, u32)]) {
|
||||
use benilla_formats::{FactionCatalog, FactionTemplate};
|
||||
use std::collections::HashMap;
|
||||
let tpl = |faction, group_mask, friend_group_mask, enemy_group_mask| FactionTemplate {
|
||||
faction,
|
||||
group_mask,
|
||||
friend_group_mask,
|
||||
enemy_group_mask,
|
||||
enemies: [0; 4],
|
||||
friends: [0; 4],
|
||||
};
|
||||
let world = f.app.world_mut();
|
||||
world.insert_resource(crate::target::Factions::from_catalog(
|
||||
FactionCatalog::from_rows(
|
||||
HashMap::from([
|
||||
(OURS, tpl(OURS, 1, 2, 4)),
|
||||
(FRIEND, tpl(FRIEND, 2, 0, 0)),
|
||||
(FOE, tpl(FOE, 4, 0, 0)),
|
||||
]),
|
||||
HashMap::new(),
|
||||
),
|
||||
));
|
||||
let index = &world.resource::<GuidIndex>().0;
|
||||
let (me, old_unit) = (index[&ME], index[&OLD]);
|
||||
world
|
||||
.entity_mut(me)
|
||||
.insert(ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(2, 0x19),
|
||||
(HEALTH, 100),
|
||||
(28, 100),
|
||||
(FLAGS, 0x8),
|
||||
(TEMPLATE, OURS),
|
||||
])));
|
||||
let mut pairs = vec![(HEALTH, 100), (28, 100), (TEMPLATE, old)];
|
||||
pairs.extend_from_slice(fields);
|
||||
world
|
||||
.entity_mut(old_unit)
|
||||
.insert(ObjectStore(benilla_protocol::ObjectFields::from_pairs(
|
||||
&pairs,
|
||||
)));
|
||||
}
|
||||
|
||||
/// The `CMSG_ATTACKSWING`s sent, and whether a `CMSG_ATTACKSTOP` went out.
|
||||
fn swings(f: &Frame) -> (Vec<u64>, bool) {
|
||||
let mut stopped = false;
|
||||
let swings =
|
||||
f.rx.try_iter()
|
||||
.filter_map(|c| match c {
|
||||
ClientCommand::AttackSwing { guid } => Some(guid),
|
||||
ClientCommand::AttackStop => {
|
||||
stopped = true;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(swings, stopped)
|
||||
}
|
||||
|
||||
/// The keys of the error lines raised.
|
||||
fn errors(f: &Frame) -> Vec<&'static str> {
|
||||
f.app
|
||||
.world()
|
||||
.resource::<crate::ui_action::UiErrorKeys>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|e| e.key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `/target` then `/cast`: `TargetUnit` commits through `SetSelection 0x493540` before it
|
||||
/// returns, and `CastSpellByName` (`0x4b4ab0`) casts at the selection as it stands then.
|
||||
#[test]
|
||||
@@ -265,39 +383,9 @@ mod tests {
|
||||
/// it returns, so the cast after it in the same script goes to the unit the TAB cycle picked.
|
||||
#[test]
|
||||
fn a_cast_after_target_nearest_enemy_goes_to_the_picked_unit() {
|
||||
const MOB: u64 = 0x31;
|
||||
let mut f = frame(true);
|
||||
let world = f.app.world_mut();
|
||||
// With no `FactionTemplate.dbc` a plain unit is attackable; OLD and NEW carry no
|
||||
// `NetEntity`, so the scan's one candidate is the mob.
|
||||
let mob = world
|
||||
.spawn((
|
||||
Guid(MOB),
|
||||
crate::net::NetEntity {
|
||||
kind: benilla_protocol::EntityKind::Unit,
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
Transform::from_xyz(3.0, 0.0, 0.0),
|
||||
GlobalTransform::from_translation(Vec3::new(3.0, 0.0, 0.0)),
|
||||
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(22, 100),
|
||||
(28, 100),
|
||||
])),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<GuidIndex>().0.insert(MOB, mob);
|
||||
// We are a player (`OBJECT_FIELD_TYPE` 0x19, `UNIT_FLAG_PVP_ATTACKABLE`), which selects
|
||||
// `CanAttack`'s player arm.
|
||||
let me = world.resource::<GuidIndex>().0[&ME];
|
||||
world
|
||||
.entity_mut(me)
|
||||
.insert(ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(2, 0x19),
|
||||
(22, 100),
|
||||
(28, 100),
|
||||
(46, 0x8),
|
||||
])));
|
||||
// OLD and NEW carry no `NetEntity`, so the scan's one candidate is the mob.
|
||||
spawn_mob(&mut f, &[]);
|
||||
run(&mut f, r#"TargetNearestEnemy() CastSpellByName("Heal")"#);
|
||||
assert_eq!(casts(&f), vec![(HEAL, Some(MOB))]);
|
||||
assert_eq!(selected(&f), Some(MOB));
|
||||
@@ -331,20 +419,117 @@ mod tests {
|
||||
/// left it.
|
||||
#[test]
|
||||
fn attack_target_swings_at_the_selection_in_call_order() {
|
||||
let swings = |f: &Frame| -> Vec<u64> {
|
||||
f.rx.try_iter()
|
||||
.filter_map(|c| match c {
|
||||
ClientCommand::AttackSwing { guid } => Some(guid),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut f = frame(true);
|
||||
run(&mut f, r#"AttackTarget() TargetUnit("party1")"#);
|
||||
assert_eq!(swings(&f), vec![OLD]);
|
||||
assert_eq!(swings(&f), (vec![OLD], false));
|
||||
let mut f = frame(true);
|
||||
run(&mut f, r#"TargetUnit("party1") AttackTarget()"#);
|
||||
assert_eq!(swings(&f), vec![NEW]);
|
||||
assert_eq!(swings(&f), (vec![NEW], false));
|
||||
}
|
||||
|
||||
/// `0x6130a3`: a unit we are friendly toward is dropped (`0x6130a8`) and `TargetNearestEnemy`
|
||||
/// runs (`0x6130b5`), moving the selection; the swing goes at what it picked (`0x6130c1`).
|
||||
/// The Attack button reaches the same `0x6131a0` through `TryCast` (`0x6e4c90`).
|
||||
#[test]
|
||||
fn attack_with_a_friend_selected_swings_at_the_nearest_enemy_instead() {
|
||||
for lua in ["AttackTarget()", "UseAction(1)"] {
|
||||
let mut f = frame(true);
|
||||
with_factions(&mut f, FRIEND, &[]);
|
||||
spawn_mob(&mut f, &[(TEMPLATE, FOE)]);
|
||||
f.app
|
||||
.world_mut()
|
||||
.resource_mut::<crate::ui_action::PlayerActions>()
|
||||
.buttons
|
||||
.insert(
|
||||
0,
|
||||
ActionButton {
|
||||
slot: 0,
|
||||
action: crate::ui_action::SPELL_ATTACK,
|
||||
kind: benilla_protocol::messages::ACTION_KIND_SPELL,
|
||||
},
|
||||
);
|
||||
run(&mut f, lua);
|
||||
assert_eq!(selected(&f), Some(MOB), "{lua}");
|
||||
assert_eq!(swings(&f), (vec![MOB], false), "{lua}");
|
||||
assert_eq!(errors(&f), Vec::<&str>::new(), "{lua}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The retarget commits before `AttackTarget` returns, so a cast after it in the same script
|
||||
/// goes to the enemy it picked, from a friend or from nothing selected.
|
||||
#[test]
|
||||
fn a_cast_after_attack_target_goes_to_the_enemy_it_picked() {
|
||||
for selected_before in [true, false] {
|
||||
let mut f = frame(selected_before);
|
||||
with_factions(&mut f, FRIEND, &[]);
|
||||
spawn_mob(&mut f, &[(TEMPLATE, FOE)]);
|
||||
run(&mut f, r#"AttackTarget() CastSpellByName("Heal")"#);
|
||||
assert_eq!(
|
||||
casts(&f),
|
||||
vec![(HEAL, Some(MOB))],
|
||||
"selected before: {selected_before}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// With no enemy in reach the scan moves nothing (`0x493f60`), so the selection re-read at
|
||||
/// `0x6130c1` is the friend, which the final gate's `CanAttack` refuses (`0x613171`); only an
|
||||
/// empty selection is "There is nothing to attack." (`0x6130d9`). Neither swings.
|
||||
#[test]
|
||||
fn attack_with_no_enemy_near_refuses_by_what_is_selected() {
|
||||
let mut f = frame(true);
|
||||
with_factions(&mut f, FRIEND, &[]);
|
||||
run(&mut f, "AttackTarget()");
|
||||
assert_eq!(selected(&f), Some(OLD));
|
||||
assert_eq!(errors(&f), vec!["ERR_INVALID_ATTACK_TARGET"]);
|
||||
assert_eq!(swings(&f), (vec![], false));
|
||||
|
||||
let mut f = frame(false);
|
||||
with_factions(&mut f, FRIEND, &[]);
|
||||
run(&mut f, "AttackTarget()");
|
||||
assert_eq!(selected(&f), None);
|
||||
assert_eq!(errors(&f), vec!["ERR_NO_ATTACK_TARGET"]);
|
||||
assert_eq!(swings(&f), (vec![], false));
|
||||
}
|
||||
|
||||
/// A hostile corpse is kept (`0x6130a3` reads the reaction alone), so no scan runs past it to
|
||||
/// the live mob, and the final gate refuses a unit neither alive nor feigning (`0x613152`–
|
||||
/// `0x613165`): "You cannot attack that target.", and no swing, which vmangos would stop
|
||||
/// (`CombatHandler.cpp:53-58`).
|
||||
#[test]
|
||||
fn attack_at_a_corpse_cannot_attack_it() {
|
||||
let mut f = frame(true);
|
||||
with_factions(&mut f, FOE, &[(HEALTH, 0)]);
|
||||
spawn_mob(&mut f, &[(TEMPLATE, FOE)]);
|
||||
run(&mut f, "AttackTarget()");
|
||||
assert_eq!(selected(&f), Some(OLD));
|
||||
assert_eq!(errors(&f), vec!["ERR_INVALID_ATTACK_TARGET"]);
|
||||
assert_eq!(swings(&f), (vec![], false));
|
||||
}
|
||||
|
||||
/// `0x6131a0` toggles only past the validator: attacking a live foe stops, and a press the
|
||||
/// validator refuses neither stops nor swings.
|
||||
#[test]
|
||||
fn attack_toggles_off_only_past_the_validator() {
|
||||
for (health, stopped, refused) in [
|
||||
(100, true, None),
|
||||
(0, false, Some("ERR_INVALID_ATTACK_TARGET")),
|
||||
] {
|
||||
let mut f = frame(true);
|
||||
with_factions(&mut f, FOE, &[(HEALTH, health)]);
|
||||
let world = f.app.world_mut();
|
||||
let me = world.resource::<GuidIndex>().0[&ME];
|
||||
world
|
||||
.entity_mut(me)
|
||||
.insert(crate::creature_anim::Engaged(OLD));
|
||||
run(&mut f, "AttackTarget()");
|
||||
assert_eq!(swings(&f), (vec![], stopped), "health {health}");
|
||||
assert_eq!(
|
||||
errors(&f),
|
||||
refused.into_iter().collect::<Vec<_>>(),
|
||||
"health {health}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A macro's lines run inside `UseAction` (`0x4e6098 call 0x4f1460`), so what they call lands
|
||||
|
||||
@@ -62,10 +62,10 @@ pub(crate) use flash::CombatFlash;
|
||||
pub(crate) use price_discount::vendor_price_discount;
|
||||
#[cfg(test)]
|
||||
pub(crate) use price_discount::{stormwind_fixture, HUMAN_WARRIOR};
|
||||
// The attack-with-no-target request, and the same nearest-enemy core called synchronously for the
|
||||
// pet bar's Attack, whose order must leave in the frame it was pressed.
|
||||
pub(crate) use relations::{can_assist, can_attack, can_interact, corpse_friendly};
|
||||
pub(crate) use scan::{attack_order_target, AttackNearestRequest, TargetScan};
|
||||
// The attack validator's target half, run in the call that asks, for the player's Attack and the
|
||||
// pet bar's; the melee probe's press with nothing selected.
|
||||
pub(crate) use scan::{AttackNearestRequest, AttackPick};
|
||||
// The chat layer's by-name asks (`/target`, `/assist`).
|
||||
pub(crate) use by_name::{AssistRequest, TargetByNameRequest};
|
||||
// The reaction decode and its faction catalog, which also tint the target frame
|
||||
@@ -337,7 +337,8 @@ impl Plugin for TargetPlugin {
|
||||
// The TAB keys' cycler (`0x493f60`, mode 1); the `TargetNearest*` Lua calls
|
||||
// run the same cycle as script calls.
|
||||
scan::tab_target,
|
||||
scan::acquire_and_attack,
|
||||
// The ATTACKTARGET binding and the melee probe's press, the player's Attack.
|
||||
crate::ui_action::attack_target_binding,
|
||||
flash::drive_flash,
|
||||
// The last-enemy stamp before the ring's death-clear, so a hostile that dies
|
||||
// selected is still remembered (the reference's `TargetLastEnemy` has no
|
||||
|
||||
@@ -1104,7 +1104,7 @@ mod tests {
|
||||
use crossbeam_channel::Receiver;
|
||||
|
||||
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::target::{attack_order_target, Selection, TargetScan};
|
||||
use crate::target::{AttackPick, Selection};
|
||||
use benilla_world::model_fade::DespawnFade;
|
||||
|
||||
const ME: u64 = 0x0000_0000_0000_0007;
|
||||
@@ -1123,7 +1123,10 @@ mod tests {
|
||||
transport_progress: None,
|
||||
transport: None,
|
||||
spline: None,
|
||||
// `OBJECT_FIELD_TYPE` (2) 0x9, object and unit, as a creature's create carries it:
|
||||
// `SetSelection`'s `IsSelectable` refuses any other type.
|
||||
fields: ObjectFields::from_pairs(&[
|
||||
(2, 0x9),
|
||||
(FIELD_UNIT_HEALTH, 100),
|
||||
(FIELD_UNIT_MAXHEALTH, 100),
|
||||
(FIELD_UNIT_LEVEL, 9),
|
||||
@@ -1223,11 +1226,11 @@ mod tests {
|
||||
*world.resource_mut::<Selection>() = Selection::default();
|
||||
world
|
||||
.run_system_once(
|
||||
|scan: TargetScan,
|
||||
|mut pick: AttackPick,
|
||||
mut sel: ResMut<Selection>,
|
||||
mut seam: crate::creature_anim::AttackSeam,
|
||||
mut errors: ResMut<crate::ui_action::UiErrorKeys>| {
|
||||
attack_order_target(&scan, &mut sel, &mut seam, &mut errors)
|
||||
pick.target(&mut sel, &mut seam, &mut errors)
|
||||
},
|
||||
)
|
||||
.expect("the scan runs on the built client")
|
||||
|
||||
@@ -289,33 +289,6 @@ impl TargetScan<'_, '_> {
|
||||
self.stores.get(entity).ok()
|
||||
}
|
||||
|
||||
/// `0x6130a3`'s keep test: the actor's reaction to the held guid alone (`0x61309b`), not the
|
||||
/// final gate's `CanAttack`; an unstreamed guid is not hostile (`0x613099`). 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`).
|
||||
fn reaction_hostile(&self, guid: u64) -> bool {
|
||||
let self_store = self.self_store();
|
||||
self.units
|
||||
.iter()
|
||||
.find(|(_, _, g, _, _)| g.0 == guid)
|
||||
.is_some_and(|(_, _, _, _, store)| {
|
||||
reaction_from_player(
|
||||
self.factions.as_deref(),
|
||||
&self.reputations,
|
||||
store,
|
||||
self_store,
|
||||
) < 4
|
||||
})
|
||||
}
|
||||
|
||||
fn unit_by_guid(&self, guid: u64) -> Option<(Entity, Option<&ObjectStore>)> {
|
||||
self.units
|
||||
.iter()
|
||||
.find(|(_, _, g, _, _)| g.0 == guid)
|
||||
.map(|(e, _, _, _, store)| (e, store))
|
||||
}
|
||||
|
||||
/// Filter, project, score and sort every known unit, fresh each press.
|
||||
fn build(&self, side: ScanSide) -> Vec<Candidate> {
|
||||
let Ok((self_tf, self_store, self_guid)) = self.self_q.single() else {
|
||||
@@ -637,73 +610,101 @@ pub(super) fn tab_target(
|
||||
);
|
||||
}
|
||||
|
||||
/// The attack acquire (`0x6130b5`) is `TargetNearestEnemy()` itself, `0x493f60(0, 1)`, so it moves
|
||||
/// the player's target. Deviation: a repeated acquire returns the same head, where the reference's
|
||||
/// cursor walks on, because this module re-scores the live world on every call.
|
||||
fn acquire_nearest_enemy(
|
||||
scan: &TargetScan,
|
||||
selection: &mut Selection,
|
||||
seam: &mut crate::creature_anim::AttackSeam,
|
||||
errors: &mut crate::ui_action::UiErrorKeys,
|
||||
) -> Option<(Entity, u64)> {
|
||||
let cands = scan.build(ScanSide::Enemy);
|
||||
let Some(c) = cands.first() else {
|
||||
// `0x6130d9`: still nothing after the acquire, error `0xa0`.
|
||||
debug!("attack acquire: nothing to attack (ERR_NO_ATTACK_TARGET)");
|
||||
errors
|
||||
.0
|
||||
.push(crate::ui_action::UiError::key("ERR_NO_ATTACK_TARGET"));
|
||||
return None;
|
||||
};
|
||||
// Not engaged: every path here held no selection or a non-hostile one.
|
||||
commit(
|
||||
selection,
|
||||
seam,
|
||||
c.entity,
|
||||
c.guid,
|
||||
scan.store_at(c.entity),
|
||||
false,
|
||||
scan.self_guid(),
|
||||
false,
|
||||
);
|
||||
Some((c.entity, c.guid))
|
||||
/// The target half of the attack validator `0x612df0` (`0x61305d`–`0x613198`), shared by the
|
||||
/// player's Attack (`0x6131aa`) and the pet bar's (`0x4bd40d`): what it reads beyond the
|
||||
/// selection, the cycler it runs as `TargetNearestEnemy` (`0x6130b5`, `0x493f60(0, 1)`), and
|
||||
/// whether we are swinging, which that cycle's selection switch reads.
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct AttackPick<'w, 's> {
|
||||
scan: TargetScan<'w, 's>,
|
||||
history: ResMut<'w, TabHistory>,
|
||||
time: Res<'w, Time>,
|
||||
engaged: Query<'w, 's, (), (With<Engaged>, With<SelfPlayer>)>,
|
||||
}
|
||||
|
||||
/// `0x612df0`'s target arm: the selection (`0x61306b`) if the actor is hostile to it (`0x6130a3`),
|
||||
/// else an acquire (`0x6130b5`), then the final gate (`0x613167`). The result is the guid
|
||||
/// `CMSG_PET_ACTION` carries (`0x4bd491`); a dead selection is `ERR_INVALID_ATTACK_TARGET`, not a
|
||||
/// reason to acquire.
|
||||
pub(crate) fn attack_order_target(
|
||||
scan: &TargetScan,
|
||||
selection: &mut Selection,
|
||||
seam: &mut crate::creature_anim::AttackSeam,
|
||||
errors: &mut crate::ui_action::UiErrorKeys,
|
||||
) -> Option<u64> {
|
||||
let guid = match keeps_held_target(selection.guid, |g| scan.reaction_hostile(g)) {
|
||||
Some(kept) => kept,
|
||||
None => acquire_nearest_enemy(scan, selection, seam, errors)?.1,
|
||||
};
|
||||
// `0x613167`'s target legs; its actor legs (`0x61312e`) ran in the caller.
|
||||
let store = scan.unit_by_guid(guid).and_then(|(_, s)| s);
|
||||
if !attack_target_valid(
|
||||
store,
|
||||
scan.factions.as_deref(),
|
||||
&scan.reputations,
|
||||
scan.self_store(),
|
||||
) {
|
||||
debug!("attack order: {guid:#x} fails the final gate (ERR_INVALID_ATTACK_TARGET)");
|
||||
errors
|
||||
.0
|
||||
.push(crate::ui_action::UiError::key("ERR_INVALID_ATTACK_TARGET"));
|
||||
return None;
|
||||
impl AttackPick<'_, '_> {
|
||||
/// Whether we are attacking (`0x60ecb0`), as the server last echoed it.
|
||||
pub(crate) fn engaged(&self) -> bool {
|
||||
!self.engaged.is_empty()
|
||||
}
|
||||
Some(guid)
|
||||
}
|
||||
|
||||
/// `0x6130a3`: keep a selection the actor is hostile to. A friendly or neutral one is dropped
|
||||
/// (`0x6130a8`) and the acquire runs, so pet-Attack with a quest giver selected retargets a mob.
|
||||
fn keeps_held_target(selection: Option<u64>, hostile: impl Fn(u64) -> bool) -> Option<u64> {
|
||||
selection.filter(|&g| hostile(g))
|
||||
/// Our own descriptor and guid, the actor of the player's Attack.
|
||||
pub(crate) fn player(&self) -> (Option<&ObjectStore>, Option<u64>) {
|
||||
(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`).
|
||||
pub(crate) fn target(
|
||||
&mut self,
|
||||
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)) {
|
||||
let engaged = self.engaged();
|
||||
cycle(
|
||||
ScanSide::Enemy,
|
||||
false,
|
||||
self.time.elapsed_secs_f64(),
|
||||
&self.scan,
|
||||
&mut self.history,
|
||||
selection,
|
||||
seam,
|
||||
engaged,
|
||||
);
|
||||
}
|
||||
let Some(guid) = selection.guid else {
|
||||
debug!("attack: nothing to attack (ERR_NO_ATTACK_TARGET)");
|
||||
errors
|
||||
.0
|
||||
.push(crate::ui_action::UiError::key("ERR_NO_ATTACK_TARGET"));
|
||||
return None;
|
||||
};
|
||||
// `0x613118`, the second resolve: a miss refuses with no message (`0x613123`).
|
||||
let Some(store) = self.scan.store_of(guid) else {
|
||||
debug!("attack: {guid:#x} does not resolve, refused silently");
|
||||
return None;
|
||||
};
|
||||
// The final gate's target legs; its actor legs (`0x61312e`, `0x613139`) repeat phase A's
|
||||
// dead and mounted refusals, which the caller ran.
|
||||
if !attack_target_valid(
|
||||
Some(store),
|
||||
self.scan.factions.as_deref(),
|
||||
&self.scan.reputations,
|
||||
self.scan.self_store(),
|
||||
) {
|
||||
debug!("attack: {guid:#x} fails the final gate (ERR_INVALID_ATTACK_TARGET)");
|
||||
errors
|
||||
.0
|
||||
.push(crate::ui_action::UiError::key("ERR_INVALID_ATTACK_TARGET"));
|
||||
return None;
|
||||
}
|
||||
Some(guid)
|
||||
}
|
||||
|
||||
/// `0x6130a3`'s keep test: the actor's reaction to the held unit alone (`0x61309b`), not the
|
||||
/// final gate's `CanAttack`, so a neutral mob is kept. A guid that does not resolve
|
||||
/// (`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`).
|
||||
fn keeps(&self, guid: u64) -> bool {
|
||||
self.scan.store_of(guid).is_none_or(|store| {
|
||||
reaction_from_player(
|
||||
self.scan.factions.as_deref(),
|
||||
&self.scan.reputations,
|
||||
Some(store),
|
||||
self.scan.self_store(),
|
||||
) < 4
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The final gate's target legs (`0x613152`–`0x613169`): alive, where a zero-health target passes
|
||||
@@ -721,49 +722,10 @@ fn attack_target_valid(
|
||||
alive && can_attack(store, factions, reputations, self_store)
|
||||
}
|
||||
|
||||
/// The attack action fired with no selection: acquire and swing (`0x612df0` at `0x6130b5`).
|
||||
/// The melee probe's press with nothing selected: the player's Attack, which acquires.
|
||||
#[derive(Message)]
|
||||
pub(crate) struct AttackNearestRequest;
|
||||
|
||||
/// Acquire the best candidate and start attacking it; the TAB history is untouched.
|
||||
pub(super) fn acquire_and_attack(
|
||||
mut requests: MessageReader<AttackNearestRequest>,
|
||||
scan: TargetScan,
|
||||
mut selection: ResMut<Selection>,
|
||||
mut seam: crate::creature_anim::AttackSeam,
|
||||
self_store: Query<&crate::net::ObjectStore, With<SelfPlayer>>,
|
||||
mut ui_error_keys: ResMut<crate::ui_action::UiErrorKeys>,
|
||||
) {
|
||||
if requests.read().last().is_none() {
|
||||
return;
|
||||
}
|
||||
if selection.guid.is_some() {
|
||||
return; // selected since the action: the normal path owns it
|
||||
}
|
||||
// `0x612df0`'s actor checks precede the acquire: a mounted, stunned or dead press never scans.
|
||||
let self_guid = scan
|
||||
.self_q
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|(_, _, g)| g)
|
||||
.map(|g| g.0);
|
||||
if crate::ui_action::attack_actor_refusal(
|
||||
self_store.iter().next(),
|
||||
self_guid,
|
||||
&mut ui_error_keys,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let Some((_, guid)) =
|
||||
acquire_nearest_enemy(&scan, &mut selection, &mut seam, &mut ui_error_keys)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
debug!("attack acquire: best candidate {guid:#x} → select + swing");
|
||||
// `0x6131a0`: StartAttack through the seam, with no stop in flight.
|
||||
seam.start(guid, false, false);
|
||||
}
|
||||
|
||||
/// `TargetLastEnemy`'s memory, the reference's last-attackable guid `[0xb4e2e8]`, read at
|
||||
/// `0x489b45` and written at `0x49377d` in `SetSelection`. Never cleared: an unresolved guid is a
|
||||
/// bare `ret` in the select helper `0x489a40`, not a deselect.
|
||||
@@ -1171,15 +1133,6 @@ mod tests {
|
||||
assert_eq!(pick_forward(&[], &[], None), None);
|
||||
}
|
||||
|
||||
/// `0x6130a3`: a friendly selection is a reason to acquire, not an error.
|
||||
#[test]
|
||||
fn phase_b_keeps_only_a_hostile_selection() {
|
||||
let hostile = |g: u64| g == 0xBAD;
|
||||
assert_eq!(keeps_held_target(Some(0xBAD), hostile), Some(0xBAD));
|
||||
assert_eq!(keeps_held_target(Some(0x600D), hostile), None, "friendly");
|
||||
assert_eq!(keeps_held_target(None, hostile), None, "no selection");
|
||||
}
|
||||
|
||||
/// `0x613152`–`0x613169`: a zero-health target is invalid unless dynamic-flag bit 5 is set.
|
||||
#[test]
|
||||
fn the_attack_orders_final_gate_reads_health_then_can_attack() {
|
||||
|
||||
@@ -55,61 +55,67 @@ pub(super) fn item_action_route(
|
||||
}
|
||||
}
|
||||
|
||||
/// The ATTACKTARGET binding (default T), whose stock body is `AttackTarget()`: the same toggle as
|
||||
/// the Lua call ([`ActionPress::attack_target`]).
|
||||
pub(super) fn attack_target_binding(
|
||||
/// The ATTACKTARGET binding (default T), whose stock body is `AttackTarget()`, and the melee
|
||||
/// probe's press with nothing selected: both the player's Attack ([`AttackPress::attack_target`]).
|
||||
pub(crate) fn attack_target_binding(
|
||||
binds: Res<crate::bindings::BindingsState>,
|
||||
targeting: cast_target::CastTargeting,
|
||||
mut acquire: MessageWriter<crate::target::AttackNearestRequest>,
|
||||
mut ui_errors: ResMut<UiErrorKeys>,
|
||||
mut ladder: CastLadder,
|
||||
mut probe: MessageReader<crate::target::AttackNearestRequest>,
|
||||
mut press: AttackPress,
|
||||
) {
|
||||
if binds.fired(crate::bindings::cmd::ATTACK_TARGET) {
|
||||
attack_target(&targeting, &mut acquire, &mut ui_errors, &mut ladder);
|
||||
let probed = probe.read().last().is_some() && press.selection.guid.is_none();
|
||||
if binds.fired(crate::bindings::cmd::ATTACK_TARGET) || probed {
|
||||
press.attack_target();
|
||||
}
|
||||
}
|
||||
|
||||
/// `AttackTarget` (`0x489b50` → `0x6131a0(0,0)`): the action bar's attack arm without a slot, as
|
||||
/// `UseAction`'s Attack also lands in `0x612df0`. A held selection toggles the swing at it; none
|
||||
/// acquires the nearest enemy.
|
||||
fn attack_target(
|
||||
targeting: &cast_target::CastTargeting,
|
||||
acquire: &mut MessageWriter<crate::target::AttackNearestRequest>,
|
||||
ui_errors: &mut UiErrorKeys,
|
||||
ladder: &mut CastLadder,
|
||||
) {
|
||||
if attack_actor_refusal(
|
||||
targeting.self_store.iter().next(),
|
||||
targeting.context().self_guid,
|
||||
ui_errors,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
match targeting.selection.guid {
|
||||
Some(guid) => {
|
||||
let Ok((e, engaged)) = ladder.self_player.single() else {
|
||||
return;
|
||||
};
|
||||
debug!(
|
||||
"AttackTarget {} at {guid:#x}",
|
||||
if engaged { "toggled off" } else { "swing" }
|
||||
);
|
||||
// The action button's own toggle (`0x6131a0`).
|
||||
crate::creature_anim::toggle_attack_local(
|
||||
e,
|
||||
guid,
|
||||
engaged,
|
||||
&mut ladder.queued_melee,
|
||||
&mut ladder.auto_repeat,
|
||||
&mut ladder.sheath,
|
||||
&mut ladder.ecs,
|
||||
&ladder.commands,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
debug!("AttackTarget with no target — acquiring nearest");
|
||||
acquire.write(crate::target::AttackNearestRequest);
|
||||
/// What the player's Attack reads and writes: `AttackTarget` (`0x489b50`), the ATTACKTARGET
|
||||
/// binding and `UseAction`'s Attack slot (through `TryCast`, `0x6e4c90`) all run `0x6131a0`.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct AttackPress<'w, 's> {
|
||||
pick: crate::target::AttackPick<'w, 's>,
|
||||
selection: ResMut<'w, crate::target::Selection>,
|
||||
seam: crate::creature_anim::AttackSeam<'w, 's>,
|
||||
ui_errors: ResMut<'w, UiErrorKeys>,
|
||||
}
|
||||
|
||||
impl AttackPress<'_, '_> {
|
||||
/// `0x6131a0`: the attack validator `0x612df0` (`0x6131aa`) runs first, its actor checks then
|
||||
/// its target pick, which may move the selection; a refusal ends the press. Then the toggle:
|
||||
/// attacking (`0x60ecb0`) stops (`0x5ecac0`), else it starts at the validator's target
|
||||
/// (`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 {
|
||||
return;
|
||||
};
|
||||
let Ok(e) = seam.me.single() else {
|
||||
return;
|
||||
};
|
||||
let engaged = pick.engaged();
|
||||
debug!(
|
||||
"attack {} at {guid:#x}",
|
||||
if engaged { "toggled off" } else { "swing" }
|
||||
);
|
||||
crate::creature_anim::toggle_attack_local(
|
||||
e,
|
||||
guid,
|
||||
engaged,
|
||||
&mut seam.queued_melee,
|
||||
&mut seam.auto_repeat,
|
||||
&mut seam.sheath,
|
||||
&mut seam.ecs,
|
||||
&seam.net,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +185,6 @@ fn self_bound<'a>(
|
||||
pub(crate) struct ActionPress<'w, 's> {
|
||||
actions: Res<'w, PlayerActions>,
|
||||
targeting: cast_target::CastTargeting<'w, 's>,
|
||||
acquire: MessageWriter<'w, crate::target::AttackNearestRequest>,
|
||||
// The by-key error line. Not a `CastLadder` field: Bevy panics, at runtime and not in unit
|
||||
// tests, on a resource reachable twice from one system.
|
||||
ui_errors: ResMut<'w, UiErrorKeys>,
|
||||
@@ -187,30 +192,27 @@ pub(crate) struct ActionPress<'w, 's> {
|
||||
gate: crate::ui_bind_confirm::BindGate<'w>,
|
||||
}
|
||||
|
||||
impl ActionPress<'_, '_> {
|
||||
/// `AttackTarget()` from the call queue, at the selection the calls before it left.
|
||||
pub(crate) fn attack_target(&mut self) {
|
||||
attack_target(
|
||||
&self.targeting,
|
||||
&mut self.acquire,
|
||||
&mut self.ui_errors,
|
||||
&mut self.ladder,
|
||||
);
|
||||
}
|
||||
/// What a `UseAction` press leaves to another applier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[must_use]
|
||||
pub(crate) enum UseOutcome {
|
||||
Done,
|
||||
/// The Attack slot passed `TryCast`'s dead rung: the player's Attack ([`AttackPress`]) runs.
|
||||
Attack,
|
||||
}
|
||||
|
||||
/// `UseAction` (`0x4e5ee0`) on the pressed slot: a cast, a swing, an item use or a macro run, at
|
||||
/// the selection as the calls before it left it. A macro's lines run inside it (`0x4e6098 call
|
||||
/// 0x4f1460`), so the calls they make queue for [`crate::script_calls`] to apply next.
|
||||
/// `UseAction` (`0x4e5ee0`) on the pressed slot: a cast, an item use or a macro run, at the
|
||||
/// selection as the calls before it left it, or the Attack slot, which it hands back for the
|
||||
/// player's Attack to run. A macro's lines run inside it (`0x4e6098 call 0x4f1460`), so the calls
|
||||
/// they make queue for [`crate::script_calls`] to apply next.
|
||||
pub(crate) fn use_action(
|
||||
p: &mut ActionPress,
|
||||
script: &mut UiScript,
|
||||
press: benilla_ui::script::ActionUse,
|
||||
) {
|
||||
) -> UseOutcome {
|
||||
let ActionPress {
|
||||
actions,
|
||||
targeting,
|
||||
acquire,
|
||||
ui_errors,
|
||||
ladder,
|
||||
gate,
|
||||
@@ -219,54 +221,17 @@ pub(crate) fn use_action(
|
||||
let action = press.action;
|
||||
let slot = match u8::try_from(action.saturating_sub(1)) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
Err(_) => return UseOutcome::Done,
|
||||
};
|
||||
match actions.buttons.get(&slot) {
|
||||
Some(b) if b.kind == ACTION_KIND_SPELL && b.action == SPELL_ATTACK => {
|
||||
// `UseAction` casts Attack through TryCast, so its dead rung comes first: "You are
|
||||
// dead", where the binding, which skips TryCast, reads "Can't attack while dead."
|
||||
// Then TryCast's effect-0x4e short-circuit runs the Attack toggle (`0x6e4c90`).
|
||||
if ladder.dead_refusal(SPELL_ATTACK, targeting.self_store.iter().next()) {
|
||||
return;
|
||||
}
|
||||
// The attack validator's actor gates (`0x612df0`) precede both the swing and the
|
||||
// nearest-enemy scan (`0x6130b5`), so both arms gate here.
|
||||
if attack_actor_refusal(
|
||||
targeting.self_store.iter().next(),
|
||||
targeting.context().self_guid,
|
||||
ui_errors,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
match selection.guid {
|
||||
Some(guid) => {
|
||||
// A toggle (`0x6131a0`, via `TryCast`'s effect-0x4e short-circuit
|
||||
// `0x6e4c7a`): attacking (`0x60ecb0`) stops (`0x5ecac0`), else starts
|
||||
// (`0x5ecb70`). Only the start cancels auto-repeat (`0x5ecd8c`), so
|
||||
// stopping melee leaves Auto Shot running.
|
||||
let Ok((e, engaged)) = ladder.self_player.single() else {
|
||||
return;
|
||||
};
|
||||
debug!(
|
||||
"ui_action: attack {} at {guid:#x}",
|
||||
if engaged { "toggled off" } else { "swing" }
|
||||
);
|
||||
crate::creature_anim::toggle_attack_local(
|
||||
e,
|
||||
guid,
|
||||
engaged,
|
||||
&mut ladder.queued_melee,
|
||||
&mut ladder.auto_repeat,
|
||||
&mut ladder.sheath,
|
||||
&mut ladder.ecs,
|
||||
&ladder.commands,
|
||||
);
|
||||
}
|
||||
// No target: the reference swings at the nearest enemy (`0x6130b5`).
|
||||
None => {
|
||||
debug!("ui_action: attack with no target — acquiring nearest");
|
||||
acquire.write(crate::target::AttackNearestRequest);
|
||||
}
|
||||
return UseOutcome::Done;
|
||||
}
|
||||
return UseOutcome::Attack;
|
||||
}
|
||||
Some(b) if b.kind == ACTION_KIND_SPELL => {
|
||||
// In `UseAction 0x4e5ee0` only: re-pressing the spell whose targeting cursor is
|
||||
@@ -278,7 +243,7 @@ pub(crate) fn use_action(
|
||||
b.action
|
||||
);
|
||||
ladder.ground.clear();
|
||||
return;
|
||||
return UseOutcome::Done;
|
||||
}
|
||||
// A live `ActiveIconID` spell re-pressed cancels its aura (`0x4e55f0`, cancel
|
||||
// `0x4e60c1`). The form-match toggle is `CastSpell`'s alone, not `UseAction`'s.
|
||||
@@ -290,7 +255,7 @@ pub(crate) fn use_action(
|
||||
.commands
|
||||
.0
|
||||
.send(crate::net::ClientCommand::CancelAura { spell_id: b.action });
|
||||
return;
|
||||
return UseOutcome::Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,7 +275,7 @@ pub(crate) fn use_action(
|
||||
// red error line: nothing was attempted.
|
||||
Some(b) if b.kind == ACTION_KIND_ITEM => {
|
||||
let Some(store) = targeting.self_store.iter().next() else {
|
||||
return;
|
||||
return UseOutcome::Done;
|
||||
};
|
||||
let template = ladder
|
||||
.items
|
||||
@@ -322,7 +287,7 @@ pub(crate) fn use_action(
|
||||
"ui_action: item action {action} (entry {}) has no template yet — skipped",
|
||||
b.action
|
||||
);
|
||||
return;
|
||||
return UseOutcome::Done;
|
||||
};
|
||||
let route = item_action_route(&template, |s| {
|
||||
crate::ui_items::find_item(&store.0, &ladder.objects, b.action, s)
|
||||
@@ -335,7 +300,7 @@ pub(crate) fn use_action(
|
||||
"ui_action: item action {action} (entry {}) is nowhere in the inventory — skipped",
|
||||
b.action
|
||||
);
|
||||
return;
|
||||
return UseOutcome::Done;
|
||||
}
|
||||
};
|
||||
if equip {
|
||||
@@ -402,6 +367,7 @@ pub(crate) fn use_action(
|
||||
}
|
||||
None => debug!("ui_action: UseAction({action}) on an empty slot"),
|
||||
}
|
||||
UseOutcome::Done
|
||||
}
|
||||
|
||||
/// Applies the queued `PickupAction`/`PlaceAction` writes (packed 0 clears) to the store, which
|
||||
|
||||
@@ -17,7 +17,7 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||||
mod cast_fail;
|
||||
mod drain;
|
||||
// A `UseAction` press, applied in call order by `crate::script_calls`.
|
||||
pub(crate) use drain::{use_action, ActionPress};
|
||||
pub(crate) use drain::{attack_target_binding, use_action, ActionPress, AttackPress, UseOutcome};
|
||||
#[cfg(test)]
|
||||
mod drain_tests;
|
||||
pub(crate) mod drop_item;
|
||||
@@ -242,8 +242,6 @@ impl Plugin for UiActionPlugin {
|
||||
.in_set(CooldownEvents)
|
||||
.after(feed::feed_actions),
|
||||
drain::drain_action_sets.after(UiInput),
|
||||
// The ATTACKTARGET binding, after the dispatch wrote this frame's key fires.
|
||||
drain::attack_target_binding.after(UiInput),
|
||||
// The target chain queues the openers earlier in the frame.
|
||||
drain::drain_go_openers.after(UiInput),
|
||||
// The latches must be current before the target chain's cursor reads them.
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(crate) struct PetPress<'w, 's> {
|
||||
pub(crate) pet: PetUnit<'w, 's>,
|
||||
pub(crate) spells: Option<Res<'w, Spells>>,
|
||||
ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>,
|
||||
scan: crate::target::TargetScan<'w, 's>,
|
||||
pick: crate::target::AttackPick<'w, 's>,
|
||||
seam: crate::creature_anim::AttackSeam<'w, 's>,
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ impl PetPress<'_, '_> {
|
||||
pet,
|
||||
spells,
|
||||
ui_errors,
|
||||
scan,
|
||||
pick,
|
||||
seam,
|
||||
} = self;
|
||||
let pet_guid = bar.spells.pet_guid;
|
||||
@@ -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 crate::target::attack_order_target(scan, selection, seam, ui_errors) {
|
||||
|| match pick.target(selection, seam, ui_errors) {
|
||||
Some(guid) => {
|
||||
target_guid = guid;
|
||||
false
|
||||
|
||||
@@ -24,8 +24,8 @@ pub enum ScriptCall {
|
||||
TargetLastTarget,
|
||||
/// `ClearTarget()` (`0x489ff0` → `0x493540(0,0)`), a no-op when nothing is selected by then.
|
||||
ClearTarget,
|
||||
/// `AttackTarget()` (`0x489b50` → `0x6131a0(0,0)`): the attack toggle at the selection as it
|
||||
/// stands then.
|
||||
/// `AttackTarget()` (`0x489b50` → `0x6131a0(0,0)`): the attack validator's pick from the
|
||||
/// selection as it stands then, which may move it to the nearest enemy, then the toggle.
|
||||
AttackTarget,
|
||||
/// `SpellTargetUnit(unit)` (`0x6e6d90`), made while the cursor was up.
|
||||
SpellTargetUnit(String),
|
||||
|
||||
Reference in New Issue
Block a user