unit getters: chained tokens like raid3target answer their unit (#716)
The `Unit*` getters and `SpellCanTargetUnit` answered nil for chained tokens like `raid3target` or `party1target`, so a raid-assist addon's main-tank frame stayed empty.
Every getter and `SpellCanTargetUnit` resolves its token through `0x515970` and then reads the guid's object (`0x468460`); see `UnitHealth 0x517511`/`0x51752d` and `SpellCanTargetUnit 0x6e6d3a`.
A token with target hops now resolves to a guid in the VM and reads a per-guid snapshot. A new feed pushes one for each held unit a chain ends on. `SpellCanTargetUnit` keeps its verdicts by guid. The three target, target-of-target and NPC snapshot builders are folded into one.
Tests: `UnitName`/`UnitHealth("raid3target")` and `UnitExists("pettarget")` answer the chained unit, `SpellCanTargetUnit("party1target")` answers its verdict, and plain tokens are unchanged.
Fixes #693
Fixes #694
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
29c1ec6f0a
commit
010e41e600
@@ -88,10 +88,12 @@ impl Plugin for SpellPlugin {
|
||||
// The state push runs before the input pass's `ToggleGameMenu`, whose
|
||||
// `SpellStopTargeting` lands with the script calls after it. After the
|
||||
// old-target clear, the pet bar's writer in the feed: `"pet"` resolves off
|
||||
// the bar.
|
||||
// the bar. After the aura feed, whose resolver inputs list the units the
|
||||
// hover verdict is asked for.
|
||||
targeting::feed_targeting_to_vm
|
||||
.in_set(UnitFeed)
|
||||
.after(crate::ui_pet::pet_stop_on_old_target_clear),
|
||||
.after(crate::ui_pet::pet_stop_on_old_target_clear)
|
||||
.after(crate::ui_aura::AuraEvents),
|
||||
(
|
||||
targeting::publish_picks_self,
|
||||
targeting::publish_corpse_pick,
|
||||
|
||||
@@ -369,7 +369,6 @@ pub(crate) fn feed_targeting_to_vm(
|
||||
targeting: Res<SpellTargeting>,
|
||||
checks: BindChecks,
|
||||
tokens: crate::ui_unit::UnitTokens,
|
||||
selection: Res<crate::target::Selection>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<u32>>>,
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
) {
|
||||
@@ -377,12 +376,19 @@ pub(crate) fn feed_targeting_to_vm(
|
||||
let last = last.get(&script);
|
||||
script.set_spell_targeting(targeting.active());
|
||||
script.set_item_pick_armed(targeting.wants(TargetingWants::Item));
|
||||
// `SpellCanTargetUnit(unit)`: resolve each token and ask `0x6e6460`'s unit leg.
|
||||
script.set_spell_targetable_units(crate::ui_unit::reach_tokens().filter(|token| {
|
||||
tokens
|
||||
.resolve(token, &selection)
|
||||
.is_some_and(|(entity, _)| targeting.can_target_unit(entity, &checks))
|
||||
}));
|
||||
// `SpellCanTargetUnit(unit)`: the VM resolves the token to a guid (`0x6e6d3a`), and
|
||||
// `0x6e6460`'s unit leg answers for that guid. The units to ask are the held ones the
|
||||
// resolver's inputs list, the `target` chains' included.
|
||||
let targetable: Vec<u64> = script
|
||||
.held_unit_guids()
|
||||
.into_iter()
|
||||
.filter(|&guid| {
|
||||
tokens
|
||||
.held(guid)
|
||||
.is_some_and(|(entity, _)| targeting.can_target_unit(entity, &checks))
|
||||
})
|
||||
.collect();
|
||||
script.set_spell_targetable_units(targetable);
|
||||
if *last != targeting.spell() {
|
||||
*last = targeting.spell();
|
||||
script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
|
||||
@@ -565,6 +571,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// What the aura feed pushes each frame for the units this world holds, us and the selection:
|
||||
/// the resolver's inputs, and the object manager's index of them. `SpellCanTargetUnit`
|
||||
/// resolves its token through the inputs, and the feed asks the verdict of each unit they list.
|
||||
fn publish_guids(world: &mut World) {
|
||||
let me = world
|
||||
.query_filtered::<Entity, With<SelfPlayer>>()
|
||||
.single(world)
|
||||
.expect("the player");
|
||||
let selection = world.resource::<crate::target::Selection>();
|
||||
let selected = selection.target.zip(selection.guid);
|
||||
let mut guids = benilla_ui::script::UnitGuids {
|
||||
player: ME,
|
||||
held: HashMap::from([(ME, 0)]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut index = world.resource_mut::<crate::net::GuidIndex>();
|
||||
index.0.insert(ME, me);
|
||||
if let Some((entity, guid)) = selected {
|
||||
index.0.insert(guid, entity);
|
||||
guids.target = guid;
|
||||
guids.held.insert(guid, 0);
|
||||
}
|
||||
world
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.set_unit_guids(&guids);
|
||||
}
|
||||
|
||||
/// Queue `SpellTargetUnit(token)` in the VM, then apply it.
|
||||
fn spell_target_unit(world: &mut World, token: &str) {
|
||||
world
|
||||
@@ -717,6 +750,68 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpellCanTargetUnit` resolves a chain as `SpellTargetUnit` does and answers the verdict of
|
||||
/// the unit it ends on (`0x6e6d3a` into `0x515970`, then `0x6e6460`): the ally we and our pet
|
||||
/// target, in range of the spell or not, and nobody past a unit with no target.
|
||||
#[test]
|
||||
fn spell_can_target_unit_follows_a_target_chain() {
|
||||
const PET: u64 = 0xF140_0000_0000_0077;
|
||||
// `OBJECT_FIELD_TYPE`, `UNIT_FIELD_HEALTH` and `UNIT_FIELD_TARGET`, the last a 2-field guid.
|
||||
let unit = |kind: u32, target: u64| {
|
||||
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(2, kind),
|
||||
(22, 100),
|
||||
(16, target as u32),
|
||||
(17, (target >> 32) as u32),
|
||||
]))
|
||||
};
|
||||
let can = |distance: f32, token: &str| {
|
||||
let (mut world, _rx, ally) = unit_world(distance);
|
||||
// We and our pet target the ally; the ally targets nobody.
|
||||
let me = world
|
||||
.query_filtered::<Entity, With<SelfPlayer>>()
|
||||
.single(&world)
|
||||
.unwrap();
|
||||
world.entity_mut(me).insert(unit(0x19, ALLY));
|
||||
world.entity_mut(ally).insert(unit(0x09, 0));
|
||||
let pet = world
|
||||
.spawn((Guid(PET), GlobalTransform::default(), unit(0x09, ALLY)))
|
||||
.id();
|
||||
let mut bar = crate::ui_pet::PetBar::default();
|
||||
bar.spells.pet_guid = PET;
|
||||
world.insert_resource(bar);
|
||||
let mut index = world.resource_mut::<crate::net::GuidIndex>();
|
||||
index.0.insert(PET, pet);
|
||||
arm(&mut world, HEAL, 0x0002);
|
||||
// The index of us and the ally, then the aura feed's inputs with the chain's units:
|
||||
// each held unit and the target it names.
|
||||
publish_guids(&mut world);
|
||||
world.non_send_resource_mut::<UiScript>().set_unit_guids(
|
||||
&benilla_ui::script::UnitGuids {
|
||||
player: ME,
|
||||
pet: PET,
|
||||
target: ALLY,
|
||||
held: HashMap::from([(ME, ALLY), (PET, ALLY), (ALLY, 0)]),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
world
|
||||
.run_system_cached(feed_targeting_to_vm)
|
||||
.expect("the feed runs");
|
||||
world
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<bool>(&format!("return SpellCanTargetUnit({token:?}) == true"))
|
||||
.expect("a boolean")
|
||||
};
|
||||
for token in ["pettarget", "playertarget", "PETTARGET", "target"] {
|
||||
assert!(can(10.0, token), "{token}: the ally in range");
|
||||
assert!(!can(40.0, token), "{token}: the ally out of range");
|
||||
}
|
||||
for token in ["pettargettarget", "playertargettarget", "party1target"] {
|
||||
assert!(!can(10.0, token), "{token}: past the ally, nobody");
|
||||
}
|
||||
}
|
||||
|
||||
/// The press (`resolve_cast_target`), the click and `SpellTargetUnit` ([`bind_target_unit`]) and
|
||||
/// the hover verdict (`SpellCanTargetUnit`) all ask [`crate::spell::cast_target::unit_binds`],
|
||||
/// so one table of units decides all three: `BindTarget`'s gates refuse the same units at
|
||||
@@ -887,6 +982,7 @@ mod tests {
|
||||
arm(&mut world, SPELL, word);
|
||||
|
||||
// The hover verdict.
|
||||
publish_guids(&mut world);
|
||||
world
|
||||
.run_system_cached(feed_targeting_to_vm)
|
||||
.expect("the feed runs");
|
||||
@@ -1124,6 +1220,7 @@ mod tests {
|
||||
arm(&mut world, SPELL, word);
|
||||
|
||||
// The hover verdict.
|
||||
publish_guids(&mut world);
|
||||
world
|
||||
.run_system_cached(feed_targeting_to_vm)
|
||||
.expect("the feed runs");
|
||||
@@ -1176,6 +1273,7 @@ mod tests {
|
||||
let can = |distance: f32, spell: u32, token: &str| {
|
||||
let (mut world, _rx, _) = unit_world(distance);
|
||||
arm(&mut world, spell, 0x0002);
|
||||
publish_guids(&mut world);
|
||||
world
|
||||
.run_system_cached(feed_targeting_to_vm)
|
||||
.expect("the feed runs");
|
||||
|
||||
@@ -21,6 +21,8 @@ use crate::net::{
|
||||
use crate::target::{ring_reaction, Factions, Selection};
|
||||
use crate::ui_script::{gate, UiInput};
|
||||
|
||||
mod held;
|
||||
|
||||
/// The unit-feed pass, gated so none of its login one-shots or per-VM memos runs before the in-game
|
||||
/// interface exists; the demo override orders itself after it.
|
||||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -152,6 +154,8 @@ impl Plugin for UiUnitPlugin {
|
||||
feed_units,
|
||||
// After the aura feed, whose resolver inputs it measures.
|
||||
feed_unit_reach.after(crate::ui_aura::AuraEvents),
|
||||
// After the same feed, whose guids it covers.
|
||||
held::feed_chain_units.after(crate::ui_aura::AuraEvents),
|
||||
feed_player_control,
|
||||
feed_farsight_focus,
|
||||
melee_unit_combat,
|
||||
@@ -683,17 +687,6 @@ impl UnitTokens<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tokens the `SpellCanTargetUnit` feed answers for, `"player"` included: the bases and one
|
||||
/// hop off the target, not the chains a script can spell.
|
||||
pub(crate) fn reach_tokens() -> impl Iterator<Item = &'static str> {
|
||||
["player", "target", "targettarget", "mouseover", "pet"]
|
||||
.into_iter()
|
||||
.chain(crate::ui_party::PARTY_TOKENS)
|
||||
.chain(crate::ui_party::PARTY_PET_TOKENS)
|
||||
.chain(crate::ui_party::RAID_TOKENS)
|
||||
.chain(crate::ui_party::RAID_PET_TOKENS)
|
||||
}
|
||||
|
||||
/// Squared distance as the reference sums it: `f32` widened to `f64`, `(dz² + dx²) + dy²`
|
||||
/// (`0x48a26f..0x48a27d`, shared by `CanInspect` `0x48a1b0` and `CheckInteractDistance`
|
||||
/// `0x48ba00`). Bevy's axes change it by at most a last ulp, which matters only on a threshold.
|
||||
@@ -1297,37 +1290,21 @@ fn feed_units(
|
||||
}
|
||||
feed.warned_sideless = sideless;
|
||||
}
|
||||
// What the three snapshots below (target, target-of-target, NPC) read besides a descriptor.
|
||||
let held = held::HeldUnits {
|
||||
names: &names,
|
||||
commands: &commands,
|
||||
factions: factions.as_deref(),
|
||||
reputations: &reputations,
|
||||
group: &group,
|
||||
self_store: self_pair.map(|(s, _)| s),
|
||||
classes: chr,
|
||||
types,
|
||||
};
|
||||
let target = selection.target.zip(selection.guid).and_then(|(e, guid)| {
|
||||
let store = stores.all.get(e).ok()?;
|
||||
let name = names
|
||||
.resolve_unit(guid, Some(store), &commands)
|
||||
.map(str::to_string);
|
||||
let reaction = unit_reaction(
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
store,
|
||||
self_pair.map(|(s, _)| s),
|
||||
);
|
||||
let mut s = snapshot(store, guid, name, reaction, chr, types);
|
||||
s.raid_target = group.raid_target_index(guid);
|
||||
s.faction_group = faction_group(store, factions.as_deref());
|
||||
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
|
||||
// `CanAttack` (`0x606980`); `UnitCanAttack` gates the target frame's level colour.
|
||||
s.can_attack = crate::target::can_attack(
|
||||
Some(store),
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
self_pair.map(|(s, _)| s),
|
||||
);
|
||||
let mut s = held.state(store, guid);
|
||||
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
|
||||
enrich_unit(
|
||||
&mut s,
|
||||
guid,
|
||||
&names,
|
||||
store,
|
||||
factions.as_deref(),
|
||||
self_pair.map(|(s, _)| s),
|
||||
);
|
||||
Some(s)
|
||||
});
|
||||
|
||||
@@ -1339,37 +1316,7 @@ fn feed_units(
|
||||
.and_then(|s| s.0.unit_target())
|
||||
.filter(|guid| *guid != 0)
|
||||
.and_then(|guid| Some((*index.as_ref()?.0.get(&guid)?, guid)))
|
||||
.and_then(|(entity, guid)| {
|
||||
let store = stores.all.get(entity).ok()?;
|
||||
let name = names
|
||||
.resolve_unit(guid, Some(store), &commands)
|
||||
.map(str::to_string);
|
||||
let reaction = unit_reaction(
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
store,
|
||||
self_pair.map(|(s, _)| s),
|
||||
);
|
||||
let mut s = snapshot(store, guid, name, reaction, chr, types);
|
||||
s.raid_target = group.raid_target_index(guid);
|
||||
s.faction_group = faction_group(store, factions.as_deref());
|
||||
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
|
||||
s.can_attack = crate::target::can_attack(
|
||||
Some(store),
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
self_pair.map(|(s, _)| s),
|
||||
);
|
||||
enrich_unit(
|
||||
&mut s,
|
||||
guid,
|
||||
&names,
|
||||
store,
|
||||
factions.as_deref(),
|
||||
self_pair.map(|(s, _)| s),
|
||||
);
|
||||
Some(s)
|
||||
});
|
||||
.and_then(|(entity, guid)| Some(held.state(stores.all.get(entity).ok()?, guid)));
|
||||
|
||||
// `"player"` is pushed only while its descriptor exists: the roster seat stands in before
|
||||
// arrival, and `PLAYER_LOGOUT` handlers still read `UnitName("player")`, as the reference's do.
|
||||
@@ -1405,37 +1352,7 @@ fn feed_units(
|
||||
let npc = interact
|
||||
.as_deref()
|
||||
.and_then(|i| Some((i.0?, i.1?)))
|
||||
.and_then(|(entity, guid)| {
|
||||
let store = stores.all.get(entity).ok()?;
|
||||
let name = names
|
||||
.resolve_unit(guid, Some(store), &commands)
|
||||
.map(str::to_string);
|
||||
let reaction = unit_reaction(
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
store,
|
||||
self_pair.map(|(s, _)| s),
|
||||
);
|
||||
let mut s = snapshot(store, guid, name, reaction, chr, types);
|
||||
s.raid_target = group.raid_target_index(guid);
|
||||
s.faction_group = faction_group(store, factions.as_deref());
|
||||
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
|
||||
s.can_attack = crate::target::can_attack(
|
||||
Some(store),
|
||||
factions.as_deref(),
|
||||
&reputations,
|
||||
self_pair.map(|(s, _)| s),
|
||||
);
|
||||
enrich_unit(
|
||||
&mut s,
|
||||
guid,
|
||||
&names,
|
||||
store,
|
||||
factions.as_deref(),
|
||||
self_pair.map(|(s, _)| s),
|
||||
);
|
||||
Some(s)
|
||||
});
|
||||
.and_then(|(entity, guid)| Some(held.state(stores.all.get(entity).ok()?, guid)));
|
||||
// Closing the window must clear the token, so the memo is written here, not only read. No
|
||||
// `fire_transitions`: nothing draws `"npc"` as a unit frame.
|
||||
let npc_dirty = match (&npc, memo.last.get("npc")) {
|
||||
@@ -2008,20 +1925,6 @@ mod tests {
|
||||
assert_eq!(resolve(&mut app, "partypet1"), None);
|
||||
}
|
||||
|
||||
/// The `SpellCanTargetUnit` feed walks [`reach_tokens`], so a token it should answer for
|
||||
/// must be in it, or `SpellCanTargetUnit("partypet1")` answers nil.
|
||||
#[test]
|
||||
fn the_reach_tokens_cover_every_group_pet() {
|
||||
let tokens: Vec<_> = reach_tokens().collect();
|
||||
assert_eq!(tokens.len(), 5 + 4 + 4 + 40 + 40);
|
||||
for i in 1..=4 {
|
||||
assert!(tokens.contains(&format!("partypet{i}").as_str()));
|
||||
}
|
||||
for i in 1..=40 {
|
||||
assert!(tokens.contains(&format!("raidpet{i}").as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A party pet in view gets a distance, so the range verbs answer for it: the feed measures
|
||||
/// the held units the resolver's inputs list, and the VM resolves the token to one of them.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
//! The snapshot of a held unit that a token names other than the player, and the per-guid feed of
|
||||
//! the units a `target` chain ends on.
|
||||
//!
|
||||
//! The reference resolves a token to a guid (`0x515970`) and reads that guid's object, so a unit it
|
||||
//! holds answers a `Unit*` getter however the token spelled it. The app pushes a snapshot
|
||||
//! for each base token by name ([`UiScript::set_unit`]); a chain such as `party1target` names a
|
||||
//! unit no base does, so the VM resolves it to a guid and reads that guid's snapshot, which
|
||||
//! [`feed_chain_units`] pushes for the guids [`UiScript::chain_end_guids`] lists.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::ChrClasses;
|
||||
use benilla_ui::script::{UiScript, UnitState};
|
||||
|
||||
use super::{enrich_unit, faction_group, faction_group_localized, snapshot, unit_reaction};
|
||||
use crate::creature_type::CreatureTypeSources;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{Guid, NetCommands, ObjectStore, Reputations, SelfPlayer};
|
||||
use crate::target::Factions;
|
||||
use crate::ui_script::{gate, VmMemo};
|
||||
|
||||
/// What a held unit's snapshot reads beside its descriptor.
|
||||
pub(super) struct HeldUnits<'a> {
|
||||
pub(super) names: &'a NameCache,
|
||||
pub(super) commands: &'a NetCommands,
|
||||
pub(super) factions: Option<&'a Factions>,
|
||||
pub(super) reputations: &'a Reputations,
|
||||
pub(super) group: &'a crate::ui_party::GroupState,
|
||||
/// Our own descriptor, which reaction, attackability and the faction line read.
|
||||
pub(super) self_store: Option<&'a ObjectStore>,
|
||||
pub(super) classes: Option<&'a ChrClasses>,
|
||||
pub(super) types: CreatureTypeSources<'a>,
|
||||
}
|
||||
|
||||
impl HeldUnits<'_> {
|
||||
/// The snapshot of the held unit `guid`, whose descriptor is `store`, as a token naming a unit
|
||||
/// other than the player carries it. It has no guild leg, so `GetGuildInfo` on a chain answers
|
||||
/// nothing, where the reference resolves the token and reads the object's guild (`0x4c9330`).
|
||||
pub(super) fn state(&self, store: &ObjectStore, guid: u64) -> UnitState {
|
||||
let name = self
|
||||
.names
|
||||
.resolve_unit(guid, Some(store), self.commands)
|
||||
.map(str::to_string);
|
||||
let reaction = unit_reaction(self.factions, self.reputations, store, self.self_store);
|
||||
let mut s = snapshot(store, guid, name, reaction, self.classes, self.types);
|
||||
s.raid_target = self.group.raid_target_index(guid);
|
||||
s.faction_group = faction_group(store, self.factions);
|
||||
s.faction_group_localized = faction_group_localized(store, self.factions);
|
||||
// `CanAttack` (`0x606980`); `UnitCanAttack` gates the target frame's level colour.
|
||||
s.can_attack = crate::target::can_attack(
|
||||
Some(store),
|
||||
self.factions,
|
||||
self.reputations,
|
||||
self.self_store,
|
||||
);
|
||||
enrich_unit(
|
||||
&mut s,
|
||||
guid,
|
||||
self.names,
|
||||
store,
|
||||
self.factions,
|
||||
self.self_store,
|
||||
);
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// [`feed_chain_units`]' per-VM memory.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ChainMemo {
|
||||
names_generation: gate::Watch,
|
||||
/// The guids the last build covered, sorted.
|
||||
ends: Vec<u64>,
|
||||
/// What each was last pushed as.
|
||||
pushed: HashMap<u64, UnitState>,
|
||||
}
|
||||
|
||||
/// Push the snapshot of each held unit a `target` chain can end on, by guid, so `UnitName`,
|
||||
/// `UnitHealth` and the other getters answer for `party1target`, `raid3target` and `pettarget`
|
||||
/// (`0x515970` resolves the token, then each getter reads the guid's object, `0x468460`). The
|
||||
/// guids are the resolver's own inputs ([`UiScript::chain_end_guids`]), which the aura feed pushes
|
||||
/// each frame, so this runs after it. A guid the object manager does not hold, or holds as
|
||||
/// anything but a unit, gets no snapshot. The reference still answers such a guid: `UnitExists`
|
||||
/// from the roster (`0x491900`), `UnitHealth` from the roster or pet record (`0x496400`,
|
||||
/// `0x496420`), and `UnitName` from the pet record or the name cache, else `UNKNOWNOBJECT`
|
||||
/// (`0x5171da`-`0x517216`); those legs are not built for a chain's end. A token without a hop reads its own push, not these.
|
||||
pub(super) fn feed_chain_units(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
tables: super::SnapshotTables,
|
||||
// The object manager's lookup (`0x468460`) on its own, not through [`super::UnitTokens`],
|
||||
// whose pet-bar read would order this feed against the pet feed it does not depend on.
|
||||
index: Option<Res<crate::net::GuidIndex>>,
|
||||
stores: Query<&ObjectStore>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
factions: Option<Res<Factions>>,
|
||||
reputations: Res<Reputations>,
|
||||
group: Res<crate::ui_party::GroupState>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
changed: Query<(), (Changed<ObjectStore>, Without<crate::items::ItemObject>)>,
|
||||
mut removed: RemovedComponents<Guid>,
|
||||
mut memo: Local<VmMemo<ChainMemo>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let (memo, vm_reset) = memo.get_reset(&script);
|
||||
let ends = script.chain_end_guids();
|
||||
|
||||
// The gate: every input read below, despawns included (invisible to `Changed`), and the
|
||||
// guids themselves, which move with the selection, the hover, the group and every held
|
||||
// unit's target.
|
||||
let names_moved = memo.names_generation.moved(names.generation());
|
||||
let ends_moved = memo.ends != ends;
|
||||
let stores_changed = !changed.is_empty();
|
||||
let stores_removed = !removed.is_empty();
|
||||
let group_changed = group.is_changed();
|
||||
let reps_changed = reputations.is_changed();
|
||||
let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed());
|
||||
removed.clear();
|
||||
let gate = gate::Gate::new(
|
||||
vm_reset
|
||||
|| names_moved
|
||||
|| ends_moved
|
||||
|| stores_changed
|
||||
|| stores_removed
|
||||
|| group_changed
|
||||
|| reps_changed
|
||||
|| factions_changed,
|
||||
);
|
||||
if gate.skip() {
|
||||
return;
|
||||
}
|
||||
|
||||
let held = HeldUnits {
|
||||
names: &names,
|
||||
commands: &commands,
|
||||
factions: factions.as_deref(),
|
||||
reputations: &reputations,
|
||||
group: &group,
|
||||
self_store: self_q.iter().next(),
|
||||
classes: tables.classes(),
|
||||
types: tables.types(&names),
|
||||
};
|
||||
let built: HashMap<u64, UnitState> = ends
|
||||
.iter()
|
||||
.filter_map(|&guid| {
|
||||
let entity = *index.as_ref()?.0.get(&guid)?;
|
||||
let store = stores.get(entity).ok().filter(|s| s.is_unit())?;
|
||||
Some((guid, held.state(store, guid)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
memo.pushed.retain(|guid, _| {
|
||||
let keep = built.contains_key(guid);
|
||||
if !keep {
|
||||
gate.audit("feed_chain_units", "a chain end's clear");
|
||||
script.set_unit_by_guid(*guid, None);
|
||||
}
|
||||
keep
|
||||
});
|
||||
for (guid, state) in built {
|
||||
if memo.pushed.get(&guid) != Some(&state) {
|
||||
gate.audit("feed_chain_units", "a chain end's snapshot");
|
||||
script.set_unit_by_guid(guid, Some(state.clone()));
|
||||
memo.pushed.insert(guid, state);
|
||||
}
|
||||
}
|
||||
memo.ends = ends;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::names::CreatureRecord;
|
||||
use crate::net::GuidIndex;
|
||||
use crate::target::Selection;
|
||||
use crate::ui_party::{GroupState, GROUPTYPE_RAID};
|
||||
use benilla_protocol::messages::{member_status, GroupMemberEntry};
|
||||
use benilla_protocol::ObjectFields;
|
||||
|
||||
const ME: u64 = 0x10;
|
||||
/// party1 and raid2, party2 and raid3, party3 and raid4.
|
||||
const A: u64 = 0x1001;
|
||||
const B: u64 = 0x1002;
|
||||
const C: u64 = 0x1003;
|
||||
/// Our target, which targets party1.
|
||||
const MOB: u64 = 0xF130_0000_4500_0001;
|
||||
/// What party1 targets.
|
||||
const BOSS: u64 = 0xF130_0000_4600_0001;
|
||||
/// What raid3 targets, and a unit that targets nobody.
|
||||
const ADD: u64 = 0xF130_0000_4700_0001;
|
||||
const MY_PET: u64 = 0xF140_0000_0000_0010;
|
||||
/// What our pet targets.
|
||||
const FOE: u64 = 0xF130_0000_4800_0001;
|
||||
/// What `BOSS` targets, and nobody holds.
|
||||
const GONE: u64 = 0xF130_0000_4900_0001;
|
||||
/// Held from the start, and named by no token until it is selected.
|
||||
const LONER: u64 = 0xF130_0000_4A00_0001;
|
||||
/// What `LONER` targets.
|
||||
const EXTRA: u64 = 0xF130_0000_4B00_0001;
|
||||
|
||||
/// `OBJECT_FIELD_TYPE`, `UNIT_FIELD_TARGET`, `_HEALTH`, `_MAXHEALTH` and `_LEVEL`.
|
||||
const OBJECT_TYPE: u16 = 2;
|
||||
const TARGET: u16 = 16;
|
||||
const HEALTH: u16 = 22;
|
||||
const MAXHEALTH: u16 = 28;
|
||||
const LEVEL: u16 = 34;
|
||||
|
||||
fn unit(kind: u32, target: u64, health: u32) -> ObjectStore {
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
(OBJECT_TYPE, kind),
|
||||
(TARGET, target as u32),
|
||||
(TARGET + 1, (target >> 32) as u32),
|
||||
(HEALTH, health),
|
||||
(MAXHEALTH, 5000),
|
||||
(LEVEL, 60),
|
||||
]))
|
||||
}
|
||||
|
||||
fn player(target: u64, health: u32) -> ObjectStore {
|
||||
unit(0x19, target, health)
|
||||
}
|
||||
|
||||
fn creature(target: u64, health: u32) -> ObjectStore {
|
||||
unit(0x09, target, health)
|
||||
}
|
||||
|
||||
fn spawn(app: &mut App, guid: u64, store: ObjectStore) -> Entity {
|
||||
let e = app.world_mut().spawn((Guid(guid), store)).id();
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(guid, e);
|
||||
e
|
||||
}
|
||||
|
||||
fn group_entry(guid: u64) -> GroupMemberEntry {
|
||||
GroupMemberEntry {
|
||||
name: format!("M{guid:x}"),
|
||||
guid,
|
||||
status: member_status::ONLINE,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The world: we target `MOB`, which targets party1 (`A`), who targets `BOSS`, which targets
|
||||
/// `GONE`; raid3 (`B`) targets `ADD`, our pet `FOE`, and `C` nobody. `LONER` is held and
|
||||
/// targets `EXTRA`. The group is a raid of us, `A`, `B` and `C`, all in our subgroup, so the
|
||||
/// three are also party slots 1 to 3. The aura feed, whose resolver inputs the chain feed
|
||||
/// reads, and the chain feed run.
|
||||
fn app() -> App {
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
|
||||
.insert_state(crate::char_select::ClientState::InWorld)
|
||||
.init_resource::<Selection>()
|
||||
.init_resource::<GuidIndex>()
|
||||
.init_resource::<Reputations>()
|
||||
.init_resource::<NameCache>()
|
||||
.init_resource::<GroupState>()
|
||||
.insert_resource(NetCommands(tx))
|
||||
.add_plugins(crate::ui_aura::UiAuraPlugin)
|
||||
.add_systems(Update, feed_chain_units.after(crate::ui_aura::AuraEvents));
|
||||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||||
{
|
||||
let mut names = app.world_mut().resource_mut::<NameCache>();
|
||||
for (guid, name) in [(ME, "Meg"), (A, "Alia"), (B, "Bram"), (C, "Cole")] {
|
||||
names.insert_player(guid, name.to_string(), None);
|
||||
}
|
||||
for (guid, name) in [
|
||||
(MOB, "Mob"),
|
||||
(BOSS, "Boss"),
|
||||
(ADD, "Add"),
|
||||
(FOE, "Foe"),
|
||||
(LONER, "Loner"),
|
||||
(EXTRA, "Extra"),
|
||||
] {
|
||||
names.insert_creature(
|
||||
benilla_protocol::guid::entry(guid).unwrap(),
|
||||
Some(CreatureRecord {
|
||||
name: name.to_string(),
|
||||
subname: None,
|
||||
creature_type: 0,
|
||||
pet_family: 0,
|
||||
rank: 0,
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
let me = spawn(&mut app, ME, player(MOB, 900));
|
||||
app.world_mut().entity_mut(me).insert(SelfPlayer);
|
||||
spawn(&mut app, A, player(BOSS, 800));
|
||||
spawn(&mut app, B, player(ADD, 700));
|
||||
spawn(&mut app, C, player(0, 600));
|
||||
let mob = spawn(&mut app, MOB, creature(A, 5000));
|
||||
spawn(&mut app, BOSS, creature(GONE, 4000));
|
||||
spawn(&mut app, ADD, creature(0, 250));
|
||||
spawn(&mut app, MY_PET, creature(FOE, 300));
|
||||
spawn(&mut app, FOE, creature(0, 120));
|
||||
spawn(&mut app, LONER, creature(EXTRA, 90));
|
||||
spawn(&mut app, EXTRA, creature(0, 45));
|
||||
app.world_mut().resource_mut::<GroupState>().apply_list(
|
||||
GROUPTYPE_RAID,
|
||||
0,
|
||||
vec![group_entry(A), group_entry(B), group_entry(C)],
|
||||
ME,
|
||||
None,
|
||||
Some(ME),
|
||||
);
|
||||
app.insert_resource(Selection {
|
||||
target: Some(mob),
|
||||
guid: Some(MOB),
|
||||
..Default::default()
|
||||
});
|
||||
let mut bar = crate::ui_pet::PetBar::default();
|
||||
bar.spells.pet_guid = MY_PET;
|
||||
app.insert_resource(bar);
|
||||
app
|
||||
}
|
||||
|
||||
fn script(app: &App) -> &UiScript {
|
||||
app.world().non_send_resource::<UiScript>()
|
||||
}
|
||||
|
||||
fn name(app: &App, token: &str) -> Option<String> {
|
||||
script(app)
|
||||
.eval(&format!("return UnitName({token:?})"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn number(app: &App, call: &str, token: &str) -> i64 {
|
||||
script(app)
|
||||
.eval(&format!("return {call}({token:?})"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn health(app: &App, token: &str) -> i64 {
|
||||
number(app, "UnitHealth", token)
|
||||
}
|
||||
|
||||
/// Whether the Lua expression holds.
|
||||
fn holds(app: &App, expr: &str) -> bool {
|
||||
script(app).eval(&format!("return {expr}")).unwrap()
|
||||
}
|
||||
|
||||
/// The getters answer for a chain through the feeds that run in the client: the aura feed's
|
||||
/// resolver inputs name the guid, and this feed's snapshot of it answers, so `party1target`,
|
||||
/// `raid3target` and `pettarget` read the unit at the end of the chain and any break in the
|
||||
/// chain reads nobody. No token names `ADD` or `FOE` but a chain.
|
||||
#[test]
|
||||
fn the_getters_answer_for_the_unit_a_chain_ends_on() {
|
||||
let mut app = app();
|
||||
app.update();
|
||||
for (token, want, hp) in [
|
||||
("party1target", "Boss", 4000),
|
||||
("raid2target", "Boss", 4000),
|
||||
("PARTY1TARGET", "Boss", 4000),
|
||||
("raid3target", "Add", 250),
|
||||
("pettarget", "Foe", 120),
|
||||
("playertarget", "Mob", 5000),
|
||||
("targettarget", "Alia", 800),
|
||||
("targettargettarget", "Boss", 4000),
|
||||
] {
|
||||
assert_eq!(
|
||||
name(&app, token).as_deref(),
|
||||
Some(want),
|
||||
"UnitName({token})"
|
||||
);
|
||||
assert_eq!(health(&app, token), hp, "UnitHealth({token})");
|
||||
assert_eq!(number(&app, "UnitHealthMax", token), 5000, "{token}");
|
||||
assert_eq!(number(&app, "UnitLevel", token), 60, "{token}");
|
||||
assert!(
|
||||
holds(&app, &format!("UnitExists({token:?}) == 1")),
|
||||
"UnitExists({token})"
|
||||
);
|
||||
}
|
||||
assert!(holds(
|
||||
&app,
|
||||
r#"UnitIsUnit("raid2target", "party1target") == 1"#
|
||||
));
|
||||
assert!(holds(
|
||||
&app,
|
||||
r#"UnitIsUnit("raid2target", "raid3target") == nil"#
|
||||
));
|
||||
// `BOSS` targets `GONE`, which nobody holds: 0 and nil, and a name the unbuilt name-cache leg
|
||||
// would give (`0x5171eb`), so it is not asserted.
|
||||
assert_eq!(health(&app, "party1targettarget"), 0);
|
||||
assert!(holds(&app, r#"UnitExists("party1targettarget") == nil"#));
|
||||
// `C` targets nobody; party4 is nobody.
|
||||
for token in ["raid4target", "party4target", "raid9target"] {
|
||||
assert_eq!(name(&app, token), None, "UnitName({token})");
|
||||
assert_eq!(health(&app, token), 0, "UnitHealth({token})");
|
||||
assert!(
|
||||
holds(&app, &format!("UnitExists({token:?}) == nil")),
|
||||
"UnitExists({token})"
|
||||
);
|
||||
}
|
||||
// The plain tokens read their own push and nothing else: this world pushes none, so
|
||||
// `raid2` answers nil though a chain ends on it, and a token pushed by name wins.
|
||||
assert_eq!(name(&app, "raid2"), None);
|
||||
assert_eq!(name(&app, "target"), None);
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.set_unit(
|
||||
"target",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
name: Some("Pushed".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(name(&app, "target").as_deref(), Some("Pushed"));
|
||||
}
|
||||
|
||||
/// Each input of the feed's gate reaches the getters: a descriptor that moves, a chain that
|
||||
/// gains an end with no descriptor moving, and a unit that leaves the object manager.
|
||||
#[test]
|
||||
fn the_feed_follows_the_world() {
|
||||
let mut app = app();
|
||||
app.update();
|
||||
assert_eq!(health(&app, "party1target"), 4000);
|
||||
|
||||
// A descriptor moves.
|
||||
let boss = app.world().resource::<GuidIndex>().0[&BOSS];
|
||||
app.world_mut()
|
||||
.entity_mut(boss)
|
||||
.insert(creature(GONE, 1000));
|
||||
app.update();
|
||||
assert_eq!(health(&app, "party1target"), 1000);
|
||||
|
||||
// Selecting `LONER` gives `targettarget` a unit that no chain ended on, and moves no
|
||||
// descriptor and no name.
|
||||
assert_eq!(name(&app, "targettarget").as_deref(), Some("Alia"));
|
||||
let loner = app.world().resource::<GuidIndex>().0[&LONER];
|
||||
app.insert_resource(Selection {
|
||||
target: Some(loner),
|
||||
guid: Some(LONER),
|
||||
..Default::default()
|
||||
});
|
||||
app.update();
|
||||
assert_eq!(name(&app, "targettarget").as_deref(), Some("Extra"));
|
||||
assert_eq!(health(&app, "targettarget"), 45);
|
||||
|
||||
// `ADD` leaves the object manager, and `raid3target` with it. The chain is still
|
||||
// `raid3`'s target, so the resolver's guid holds; the snapshot is what goes.
|
||||
assert_eq!(name(&app, "raid3target").as_deref(), Some("Add"));
|
||||
let add = app.world_mut().resource_mut::<GuidIndex>().0.remove(&ADD);
|
||||
app.world_mut().despawn(add.unwrap());
|
||||
app.update();
|
||||
// The name would be the name cache's (`0x5171eb`), which a chain's end does not read yet.
|
||||
assert_eq!(health(&app, "raid3target"), 0);
|
||||
}
|
||||
|
||||
/// The chain feed reads the resolver inputs the aura feed pushes in the same frame, so the
|
||||
/// declared order puts the feed after it: read off the schedule graph.
|
||||
#[test]
|
||||
fn the_chain_feed_runs_after_the_aura_feed_it_reads() {
|
||||
use crate::game_plugins::schedule_tests::{census, headless_client, SyncPoints};
|
||||
|
||||
let mut app = headless_client();
|
||||
let c = census(&mut app, Update, SyncPoints::Declared);
|
||||
if !c.systems.values().any(|s| s.name.contains("benilla_app::")) {
|
||||
eprintln!("skipped: this build carries no type names");
|
||||
return;
|
||||
}
|
||||
let one = |suffix: &str| {
|
||||
let mut hits = c.systems.iter().filter(|(_, s)| s.name.ends_with(suffix));
|
||||
match (hits.next(), hits.next()) {
|
||||
(Some((k, _)), None) => *k,
|
||||
_ => panic!("exactly one system named `…{suffix}` in Update"),
|
||||
}
|
||||
};
|
||||
let aura = one("::ui_aura::feed_auras");
|
||||
for feed in [
|
||||
"::ui_unit::held::feed_chain_units",
|
||||
"::targeting::feed_targeting_to_vm",
|
||||
] {
|
||||
assert!(
|
||||
c.dependencies.contains(&(aura, one(feed))),
|
||||
"{feed} is declared after feed_auras"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,10 +403,11 @@ impl super::UiScript {
|
||||
self.model_mut().item_pick_armed = armed;
|
||||
}
|
||||
|
||||
/// Push the unit tokens for which the armed word's unit checks all pass.
|
||||
pub fn set_spell_targetable_units<'a>(&mut self, tokens: impl IntoIterator<Item = &'a str>) {
|
||||
self.model_mut().spell_targetable_units =
|
||||
tokens.into_iter().map(str::to_ascii_lowercase).collect();
|
||||
/// Push the guids of the units for which the armed word's unit checks all pass.
|
||||
/// `SpellCanTargetUnit` resolves its token to a guid, as `0x515970` does, and asks this set,
|
||||
/// so a chain such as `"party1target"` reads the unit it ends on.
|
||||
pub fn set_spell_targetable_units(&mut self, guids: impl IntoIterator<Item = u64>) {
|
||||
self.model_mut().spell_targetable_units = guids.into_iter().collect();
|
||||
}
|
||||
|
||||
/// Drain the `(bag, slot)` picks since the last call; a paper-doll pick reports as
|
||||
|
||||
@@ -6,10 +6,10 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
|
||||
use super::{
|
||||
auction, backdrop, bank, bind_confirm, camera_view, char_stats, chat_window, colorselect,
|
||||
container, craft, cursor, death, duel, follow, gossip, guild, inspect, item_text, loot,
|
||||
loot_roll, macros, mail, merchant, party, petition, pvp, quest, quest_log, reputation, session,
|
||||
simplehtml, skills, slider, social, spellbook, stable, taxi, trade, tradeskill, trainer,
|
||||
weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, MusicRequest,
|
||||
PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
|
||||
loot_roll, macros, mail, merchant, parse_unit_token, party, petition, pvp, quest, quest_log,
|
||||
reputation, session, simplehtml, skills, slider, social, spellbook, stable, taxi, trade,
|
||||
tradeskill, trainer, weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView,
|
||||
MusicRequest, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState, UnitTokenParse,
|
||||
};
|
||||
|
||||
// ── The Rust-side model ──
|
||||
@@ -238,6 +238,10 @@ pub(crate) struct Model {
|
||||
/// Each unit token's state as pushed this frame, keyed lowercased because 1.12's resolver
|
||||
/// (`0x515970`) matches with `SStrCmpI`: read it only through `unit`, where the fold lives.
|
||||
pub(crate) units_by_lower: HashMap<String, UnitState>,
|
||||
/// The snapshot of each unit a `target` chain can end on, by guid, which the app pushes for
|
||||
/// [`super::UnitGuids::chain_ends`]: a token with a hop that no push names reads its
|
||||
/// resolved guid's entry here.
|
||||
pub(crate) units_by_guid: HashMap<u64, UnitState>,
|
||||
/// The player's auras in the reference cache's insertion order (`0xbc6040`), durations joined:
|
||||
/// the `GetPlayerBuff` family's list, and any token naming the player's.
|
||||
pub(crate) player_auras: Vec<AuraState>,
|
||||
@@ -453,9 +457,10 @@ pub(crate) struct Model {
|
||||
/// Spell targeting is on (`SpellIsTargeting`, `0x6e6cd0`); it gates `SpellStopTargeting()`,
|
||||
/// whose nil the ESC chain falls through on (`UIParent.lua:1490`).
|
||||
pub(crate) spell_targeting: bool,
|
||||
/// Tokens for which `SpellCanTargetUnit`'s armed unit word clears fully, used by stock unit
|
||||
/// frames before they call `SpellTargetUnit`.
|
||||
pub(crate) spell_targetable_units: HashSet<String>,
|
||||
/// The units, by guid, for which `SpellCanTargetUnit`'s armed unit word clears fully, which
|
||||
/// stock unit frames ask before they call `SpellTargetUnit`; a token resolves to its guid
|
||||
/// through [`Self::unit_guids`].
|
||||
pub(crate) spell_targetable_units: HashSet<u64>,
|
||||
|
||||
pub(crate) talents: super::talent::TalentUiState,
|
||||
/// `LearnTalent(tab, index)` calls queued.
|
||||
@@ -999,9 +1004,16 @@ impl Model {
|
||||
self.warnings.push(msg);
|
||||
}
|
||||
|
||||
/// A unit token's state, folded as 1.12's resolver `0x515970` folds (`_strnicmp`: `A`..`Z`
|
||||
/// only, never a byte of 0x80 or more), so `to_ascii_lowercase`, never `to_lowercase`.
|
||||
/// The state of the unit `token` names, as the `Unit*` getters read it: the snapshot the app
|
||||
/// pushed under the token, else, for a token with a `target` hop, the chain's end.
|
||||
pub(crate) fn unit(&self, token: &str) -> Option<&UnitState> {
|
||||
self.pushed_unit(token).or_else(|| self.chained_unit(token))
|
||||
}
|
||||
|
||||
/// The snapshot pushed under `token` itself, folded as 1.12's resolver `0x515970` folds
|
||||
/// (`_strnicmp`: `A`..`Z` only, never a byte of 0x80 or more), so `to_ascii_lowercase`, never
|
||||
/// `to_lowercase`.
|
||||
pub(crate) fn pushed_unit(&self, token: &str) -> Option<&UnitState> {
|
||||
if token.bytes().any(|b| b.is_ascii_uppercase()) {
|
||||
self.units_by_lower.get(&token.to_ascii_lowercase())
|
||||
} else {
|
||||
@@ -1009,6 +1021,20 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit a token with a `target` hop and no push of its own names: the resolver's guid
|
||||
/// (`0x515970`, which the getters call before their own object lookup, `0x468460`), then that
|
||||
/// guid's snapshot. A token without a hop is the app's to push, base by base, and reads
|
||||
/// nothing here.
|
||||
fn chained_unit(&self, token: &str) -> Option<&UnitState> {
|
||||
match parse_unit_token(token) {
|
||||
UnitTokenParse::Unit { base, hops } if hops > 0 => {
|
||||
let guid = self.unit_guids.resolve_unit(base, hops)?;
|
||||
self.units_by_guid.get(&guid)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new() -> Model {
|
||||
Model {
|
||||
addons: Vec::new(),
|
||||
@@ -1083,6 +1109,7 @@ impl Model {
|
||||
// 1024x768 until the host calls `set_screen_size`; y-up `[bottom, left, top, right]`.
|
||||
screen: Rect::new(0.0, 0.0, 768.0, 1024.0),
|
||||
units_by_lower: HashMap::new(),
|
||||
units_by_guid: HashMap::new(),
|
||||
player_auras: Vec::new(),
|
||||
unit_auras: HashMap::new(),
|
||||
unit_guids: Default::default(),
|
||||
|
||||
@@ -617,17 +617,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the standing
|
||||
// word clears against that resolved unit.
|
||||
// SpellCanTargetUnit("unit") (`0x6e6d00`): the argument is a string or a number (`0x6e6d0e`,
|
||||
// else `Usage:`), resolved through `0x515970` (`0x6e6d3a`), which raises for a token it does
|
||||
// not know; a token naming nobody answers nil (`0x6e6d43`), and a guid asks `0x6e6460`'s unit
|
||||
// leg whether the standing word clears against it.
|
||||
g.set(
|
||||
"SpellCanTargetUnit",
|
||||
lua.create_function(|lua, unit: Option<String>| {
|
||||
lua.create_function(|lua, unit: Value| {
|
||||
let token = crate::script::binding_abi::string_arg(
|
||||
lua,
|
||||
unit,
|
||||
r#"Usage: SpellCanTargetUnit("unit")"#,
|
||||
)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let can = unit.is_some_and(|unit| {
|
||||
model
|
||||
.spell_targetable_units
|
||||
.contains(&unit.to_ascii_lowercase())
|
||||
});
|
||||
let can = model
|
||||
.unit_guids
|
||||
.guid_of(&token)?
|
||||
.is_some_and(|guid| model.spell_targetable_units.contains(&guid));
|
||||
if can {
|
||||
Ok(Value::Boolean(true))
|
||||
} else {
|
||||
|
||||
@@ -235,7 +235,7 @@ fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) {
|
||||
pub(super) fn on_unit_push(lua: &Lua, token: &str) {
|
||||
let (unit, hits): (UnitState, Vec<FrameHandle>) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(unit) = model.unit(token).filter(|u| u.exists && u.guid != 0) else {
|
||||
let Some(unit) = model.pushed_unit(token).filter(|u| u.exists && u.guid != 0) else {
|
||||
return;
|
||||
};
|
||||
// Registered frames only, as `update_bar` writes layout.
|
||||
|
||||
@@ -9,8 +9,9 @@ use super::super::binding_abi::flag;
|
||||
use super::super::calls::ScriptCall;
|
||||
use super::super::Model;
|
||||
use super::{
|
||||
check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token,
|
||||
unit_predicate, unknownobject, with_unit, PlayerRecord, SelectionRequest,
|
||||
check_unit_token, classification_word, grey_band, level_reads_unknown, parse_unit_token,
|
||||
pick_unit_token, unit_predicate, unknownobject, with_unit, PlayerRecord, SelectionRequest,
|
||||
UnitBase, UnitTokenParse,
|
||||
};
|
||||
|
||||
/// The `"player"` fast path of `UnitName`, `UnitRace`, `UnitClass` and `UnitSex`, never
|
||||
@@ -523,7 +524,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
if !u.exists || !grouped {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
let hit = (t.starts_with("party") && !t.starts_with("partypet"))
|
||||
// A `partyN` token itself names a member; a chain off one (`party1target`) names
|
||||
// whoever that member targets, which the guid arms below judge.
|
||||
let party_slot = matches!(
|
||||
parse_unit_token(&t),
|
||||
UnitTokenParse::Unit {
|
||||
base: UnitBase::Party(_),
|
||||
hops: 0
|
||||
}
|
||||
);
|
||||
let hit = party_slot
|
||||
|| t.eq_ignore_ascii_case("player")
|
||||
|| (u.guid != 0
|
||||
&& (model.unit("player").is_some_and(|p| p.guid == u.guid)
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
//! The getters and `SpellCanTargetUnit` over a `target` chain: the token resolves to a guid
|
||||
//! through [`UnitGuids`] (`0x515970`), and the guid's snapshot answers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::script::{UiScript, UnitGuids, UnitState};
|
||||
|
||||
const ME: u64 = 0x10;
|
||||
const PET: u64 = 0xF140_0000_0000_0077;
|
||||
/// Our target.
|
||||
const MOB: u64 = 0xF130_0000_0000_0001;
|
||||
/// party1 and raid2, who targets `BOSS`.
|
||||
const P1: u64 = 0x21;
|
||||
/// party2, held by no one.
|
||||
const FAR: u64 = 0x22;
|
||||
/// raid3, who targets `ADD`.
|
||||
const R3: u64 = 0x33;
|
||||
/// What party1 targets, and the unit `MOB`'s hop lands on party1 from.
|
||||
const BOSS: u64 = 0xF130_0000_0000_00B0;
|
||||
/// What raid3 targets: held, and never named by a base token.
|
||||
const ADD: u64 = 0xF130_0000_0000_00AD;
|
||||
/// What our pet targets.
|
||||
const PET_FOE: u64 = 0xF130_0000_0000_00F0;
|
||||
/// What `BOSS` targets, and nobody holds.
|
||||
const GONE: u64 = 0xF130_0000_0000_0099;
|
||||
/// Held, targets nobody, and no snapshot is pushed for it.
|
||||
const SILENT: u64 = 0xF130_0000_0000_0055;
|
||||
/// raid4, who targets `SILENT`.
|
||||
const R4: u64 = 0x44;
|
||||
|
||||
fn state(name: &str, guid: u64, health: u32) -> UnitState {
|
||||
UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
guid,
|
||||
name: Some(name.into()),
|
||||
health,
|
||||
max_health: 1000,
|
||||
level: 60,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// We target the mob, which targets party1, who targets the boss, which targets a unit nobody
|
||||
/// holds. raid3 targets the add, raid4 a held unit with no snapshot, our pet its foe, and party2
|
||||
/// is out of range. Pushed by name: the bases. Pushed by guid: the units a chain ends on.
|
||||
fn world() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_unit_guids(&UnitGuids {
|
||||
player: ME,
|
||||
pet: PET,
|
||||
target: MOB,
|
||||
party: [P1, FAR, 0, 0],
|
||||
raid: vec![ME, P1, R3, R4],
|
||||
held: HashMap::from([
|
||||
(ME, MOB),
|
||||
(PET, PET_FOE),
|
||||
(MOB, P1),
|
||||
(P1, BOSS),
|
||||
(BOSS, GONE),
|
||||
(R3, ADD),
|
||||
(R4, SILENT),
|
||||
(ADD, 0),
|
||||
(PET_FOE, 0),
|
||||
(SILENT, 0),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
for (token, unit) in [
|
||||
("player", state("Me", ME, 900)),
|
||||
("target", state("Mob", MOB, 500)),
|
||||
("party1", state("Pia", P1, 800)),
|
||||
("pet", state("Pet", PET, 300)),
|
||||
] {
|
||||
s.set_unit(token, Some(unit));
|
||||
}
|
||||
for unit in [
|
||||
state("Boss", BOSS, 4000),
|
||||
state("Add", ADD, 250),
|
||||
state("Foe", PET_FOE, 120),
|
||||
// The hops that land on a unit a base names.
|
||||
state("Mob", MOB, 500),
|
||||
state("Pia", P1, 800),
|
||||
] {
|
||||
s.set_unit_by_guid(unit.guid, Some(unit));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn eval<T: mlua::FromLuaMulti>(s: &UiScript, chunk: &str) -> T {
|
||||
s.eval(chunk).unwrap_or_else(|e| panic!("{chunk}: {e}"))
|
||||
}
|
||||
|
||||
/// The getters read the snapshot of the unit the chain ends on, whatever the base is, and however
|
||||
/// many hops the token takes.
|
||||
#[test]
|
||||
fn a_chain_answers_the_snapshot_of_the_unit_it_ends_on() {
|
||||
let s = world();
|
||||
for (chunk, want) in [
|
||||
(r#"return UnitName("party1target")"#, "Boss"),
|
||||
(r#"return UnitName("raid2target")"#, "Boss"),
|
||||
(r#"return UnitName("raid3target")"#, "Add"),
|
||||
(r#"return UnitName("pettarget")"#, "Foe"),
|
||||
(r#"return UnitName("playertarget")"#, "Mob"),
|
||||
(r#"return UnitName("targettarget")"#, "Pia"),
|
||||
(r#"return UnitName("targettargettarget")"#, "Boss"),
|
||||
(r#"return UnitName("playertargettargettarget")"#, "Boss"),
|
||||
// The compares fold case, the hops' too.
|
||||
(r#"return UnitName("PARTY1TARGET")"#, "Boss"),
|
||||
(r#"return UnitName("Raid3Target")"#, "Add"),
|
||||
] {
|
||||
assert_eq!(eval::<String>(&s, chunk), want, "{chunk}");
|
||||
}
|
||||
assert_eq!(eval::<i64>(&s, r#"return UnitHealth("raid3target")"#), 250);
|
||||
assert_eq!(
|
||||
eval::<i64>(&s, r#"return UnitHealth("party1target")"#),
|
||||
4000
|
||||
);
|
||||
assert_eq!(
|
||||
eval::<i64>(&s, r#"return UnitHealthMax("party1target")"#),
|
||||
1000
|
||||
);
|
||||
assert_eq!(eval::<i64>(&s, r#"return UnitLevel("pettarget")"#), 60);
|
||||
assert!(eval::<bool>(&s, r#"return UnitExists("pettarget") == 1"#));
|
||||
assert!(eval::<bool>(
|
||||
&s,
|
||||
r#"return UnitExists("party1target") == 1"#
|
||||
));
|
||||
assert!(eval::<bool>(
|
||||
&s,
|
||||
r#"return UnitIsUnit("targettarget", "party1") == 1"#
|
||||
));
|
||||
}
|
||||
|
||||
/// A chain that names nobody answers what the getter answers for no unit: nil, 0 and nil. A chain
|
||||
/// to a guid nothing holds reads 0 and nil too, but its name is the reference's name-cache leg
|
||||
/// (`0x5171eb`, else `UNKNOWNOBJECT` at `0x517216`), which is not built, so it is not asserted.
|
||||
#[test]
|
||||
fn a_chain_to_a_unit_nobody_holds_answers_nothing() {
|
||||
let s = world();
|
||||
// `BOSS` targets `GONE`, which is not held.
|
||||
for (lua, want) in [
|
||||
(r#"UnitHealth("party1targettarget")"#, "0"),
|
||||
(r#"UnitExists("party1targettarget")"#, "nil"),
|
||||
] {
|
||||
assert_eq!(
|
||||
eval::<String>(&s, &format!("return tostring({lua})")),
|
||||
want,
|
||||
"{lua}"
|
||||
);
|
||||
}
|
||||
for token in [
|
||||
// `party2` is not held, so a hop off it names nobody.
|
||||
"party2target",
|
||||
// `SILENT` is held, and nothing pushed its snapshot.
|
||||
"raid4target",
|
||||
// A hop off a unit with no target, and a base past the roster.
|
||||
"raid3targettarget",
|
||||
"raid9target",
|
||||
"partypet1target",
|
||||
] {
|
||||
assert!(
|
||||
eval::<bool>(&s, &format!(r#"return UnitName("{token}") == nil"#)),
|
||||
"{token}: name"
|
||||
);
|
||||
assert_eq!(
|
||||
eval::<i64>(&s, &format!(r#"return UnitHealth("{token}")"#)),
|
||||
0,
|
||||
"{token}: health"
|
||||
);
|
||||
assert!(
|
||||
eval::<bool>(&s, &format!(r#"return UnitExists("{token}") == nil"#)),
|
||||
"{token}: exists"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain tokens read the snapshot the app pushed under them, never a guid's: a base with no
|
||||
/// push answers nothing even where its guid has an entry, and `"target"` is not `"party1target"`.
|
||||
#[test]
|
||||
fn a_token_without_a_hop_still_reads_its_own_push() {
|
||||
let mut s = world();
|
||||
for (chunk, want) in [
|
||||
(r#"return UnitName("player")"#, "Me"),
|
||||
(r#"return UnitName("target")"#, "Mob"),
|
||||
(r#"return UnitName("party1")"#, "Pia"),
|
||||
(r#"return UnitName("pet")"#, "Pet"),
|
||||
(r#"return UnitName("TARGET")"#, "Mob"),
|
||||
] {
|
||||
assert_eq!(eval::<String>(&s, chunk), want, "{chunk}");
|
||||
}
|
||||
assert_eq!(eval::<i64>(&s, r#"return UnitHealth("target")"#), 500);
|
||||
|
||||
// A guid entry for the unit a plain token resolves to is not that token's snapshot: the
|
||||
// app pushes each base by name.
|
||||
s.set_unit_by_guid(R3, Some(state("Raider", R3, 700)));
|
||||
assert!(eval::<bool>(&s, r#"return UnitName("raid3") == nil"#));
|
||||
assert_eq!(eval::<i64>(&s, r#"return UnitHealth("raid3")"#), 0);
|
||||
// A push under the token wins over a guid entry, which is what `targettarget` is.
|
||||
s.set_unit("targettarget", Some(state("Tot", P1, 1)));
|
||||
assert_eq!(
|
||||
eval::<String>(&s, r#"return UnitName("targettarget")"#),
|
||||
"Tot"
|
||||
);
|
||||
// Cleared, it falls to the chain.
|
||||
s.set_unit("targettarget", None);
|
||||
assert_eq!(
|
||||
eval::<String>(&s, r#"return UnitName("targettarget")"#),
|
||||
"Pia"
|
||||
);
|
||||
// And the entry is dropped with `None`.
|
||||
s.set_unit_by_guid(P1, None);
|
||||
assert!(eval::<bool>(
|
||||
&s,
|
||||
r#"return UnitName("targettarget") == nil"#
|
||||
));
|
||||
}
|
||||
|
||||
/// The junk after a hop and an unknown token behave as the resolver does: the first names
|
||||
/// nobody, the second raises.
|
||||
#[test]
|
||||
fn a_malformed_chain_is_nobody_and_an_unknown_token_raises() {
|
||||
let s = world();
|
||||
for token in [
|
||||
"party1targetfoo",
|
||||
"party1 target",
|
||||
"party1targettarge",
|
||||
"targetfoo",
|
||||
] {
|
||||
assert!(
|
||||
eval::<bool>(&s, &format!(r#"return UnitName("{token}") == nil"#)),
|
||||
"{token}"
|
||||
);
|
||||
}
|
||||
for chunk in [
|
||||
r#"return UnitName("npctarget")"#,
|
||||
r#"return UnitHealth("bogustarget")"#,
|
||||
] {
|
||||
let err = s.eval::<mlua::Value>(chunk).expect_err(chunk).to_string();
|
||||
assert!(err.contains("Unknown unit name"), "{chunk}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `UnitInParty` judges a chain by the unit it ends on, not by the `party` in its text.
|
||||
#[test]
|
||||
fn unit_in_party_judges_the_unit_a_party_chain_ends_on() {
|
||||
let mut s = world();
|
||||
s.set_party(crate::script::PartyState {
|
||||
members: vec![crate::script::PartyMemberInfo {
|
||||
guid: P1,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(eval::<bool>(&s, r#"return UnitInParty("party1") == 1"#));
|
||||
// The boss is no member.
|
||||
assert!(eval::<bool>(
|
||||
&s,
|
||||
r#"return UnitInParty("party1target") == nil"#
|
||||
));
|
||||
// `MOB` targets party1, so the chain ends on a member.
|
||||
assert!(eval::<bool>(
|
||||
&s,
|
||||
r#"return UnitInParty("targettarget") == 1"#
|
||||
));
|
||||
}
|
||||
|
||||
/// `SpellCanTargetUnit` resolves its token to a guid and asks the verdicts by guid: a chain reads
|
||||
/// the unit it ends on, and two tokens naming one unit answer alike.
|
||||
#[test]
|
||||
fn spell_can_target_unit_answers_the_verdict_of_the_unit_the_token_names() {
|
||||
let mut s = world();
|
||||
s.set_spell_targetable_units([BOSS, P1]);
|
||||
let can = |s: &UiScript, token: &str| {
|
||||
s.eval::<bool>(&format!("return SpellCanTargetUnit({token:?}) == true"))
|
||||
.unwrap()
|
||||
};
|
||||
let refused = |s: &UiScript, token: &str| {
|
||||
s.eval::<bool>(&format!("return SpellCanTargetUnit({token:?}) == nil"))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(can(&s, "party1target"), "the boss");
|
||||
assert!(can(&s, "raid2target"), "the boss by another base");
|
||||
assert!(can(&s, "PARTY1TARGET"), "case folds");
|
||||
assert!(can(&s, "targettarget"), "party1, by a hop off the target");
|
||||
assert!(can(&s, "party1"), "a base, by its guid");
|
||||
assert!(can(&s, "raid2"), "another token, one unit");
|
||||
assert!(refused(&s, "raid3target"), "the add is not cleared");
|
||||
assert!(refused(&s, "pettarget"));
|
||||
assert!(refused(&s, "target"), "the mob is not cleared");
|
||||
assert!(refused(&s, "party1targettarget"), "a unit nobody holds");
|
||||
assert!(refused(&s, "party2target"), "a hop off a unit not held");
|
||||
assert!(refused(&s, "party1targetfoo"), "junk after a hop");
|
||||
assert!(refused(&s, ""), "no token names nobody");
|
||||
|
||||
// The set moves with the push, and clearing it clears every token.
|
||||
s.set_spell_targetable_units([ADD]);
|
||||
assert!(can(&s, "raid3target"));
|
||||
assert!(refused(&s, "party1target"));
|
||||
s.set_spell_targetable_units([]);
|
||||
assert!(refused(&s, "raid3target"));
|
||||
}
|
||||
|
||||
/// The reference gates the argument (`0x6e6d0e`) and resolves through `0x515970`, which raises for
|
||||
/// a token it does not know.
|
||||
#[test]
|
||||
fn spell_can_target_unit_raises_as_the_resolver_and_the_gate_do() {
|
||||
let s = world();
|
||||
let err = |chunk: &str| s.eval::<mlua::Value>(chunk).expect_err(chunk).to_string();
|
||||
for chunk in [
|
||||
"return SpellCanTargetUnit()",
|
||||
"return SpellCanTargetUnit(nil)",
|
||||
"return SpellCanTargetUnit({})",
|
||||
] {
|
||||
assert!(
|
||||
err(chunk).contains(r#"Usage: SpellCanTargetUnit("unit")"#),
|
||||
"{chunk}"
|
||||
);
|
||||
}
|
||||
for chunk in [
|
||||
r#"return SpellCanTargetUnit("bogus")"#,
|
||||
r#"return SpellCanTargetUnit("npctarget")"#,
|
||||
"return SpellCanTargetUnit(5)",
|
||||
] {
|
||||
assert!(err(chunk).contains("Unknown unit name"), "{chunk}");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The `Unit*` bindings. The app pushes each token's [`UnitState`] every frame through
|
||||
//! [`UiScript::set_unit`], and the globals read that plain data, which keeps this crate free of
|
||||
//! [`UiScript::set_unit`], and the units a `target` chain ends on by guid through
|
||||
//! [`UiScript::set_unit_by_guid`]; the globals read that plain data, which keeps this crate free of
|
||||
//! the ECS. Every predicate answers the number `1` or nil, never a Lua boolean.
|
||||
|
||||
use mlua::Lua;
|
||||
@@ -46,8 +47,9 @@ pub struct PlayerRecord {
|
||||
pub sex: u8,
|
||||
}
|
||||
|
||||
/// One unit token's snapshot, pushed by the app each frame and read by the `Unit*` bindings;
|
||||
/// plain data, with no mlua handles or ECS types.
|
||||
/// One unit's snapshot, pushed by the app each frame under a token, or under a guid for a unit a
|
||||
/// `target` chain ends on, and read by the `Unit*` bindings; plain data, with no mlua handles or
|
||||
/// ECS types.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct UnitState {
|
||||
/// `UnitExists`, true for an out-of-range party member through the roster fallback. The
|
||||
@@ -190,7 +192,7 @@ pub struct UnitState {
|
||||
pub raid_target: u8,
|
||||
/// Whether the player can attack the unit, `UnitCanAttack("player", unit)`, which the binding
|
||||
/// (`0x516c50`, delegating to `CanAttack` `0x606980`) answers for both argument orders. Fed for
|
||||
/// `target`, `targettarget` and `npc`; other tokens read false.
|
||||
/// `target`, `targettarget`, `npc` and the units a chain ends on; other tokens read false.
|
||||
pub can_attack: bool,
|
||||
/// `UnitIsCorpse` (`0x5161c0`): the token names a `TYPEID_CORPSE` object, which a dead unit
|
||||
/// is not. No feed sets it.
|
||||
@@ -315,6 +317,32 @@ impl super::UiScript {
|
||||
super::tooltip_unit::on_unit_push(&self.lua, token);
|
||||
}
|
||||
|
||||
/// Push, or clear with `None`, the snapshot of a unit a `target` chain can end on, by guid. A
|
||||
/// token with a hop that no push names (`"party1target"`, `"raid3targettarget"`) resolves
|
||||
/// through [`UnitGuids`] to a guid and reads that guid's entry, the reference's order
|
||||
/// (`0x515970`, then each getter's object lookup `0x468460`); the guids to cover are
|
||||
/// [`Self::chain_end_guids`]. A token without a hop reads its own push, not this.
|
||||
pub fn set_unit_by_guid(&mut self, guid: u64, state: Option<UnitState>) {
|
||||
let mut model = self.model_mut();
|
||||
match state {
|
||||
Some(s) => {
|
||||
model.units_by_guid.insert(guid, s);
|
||||
}
|
||||
None => {
|
||||
model.units_by_guid.remove(&guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The guids the chains a script can spell end on ([`UnitGuids::chain_ends`]), each once: the
|
||||
/// units whose snapshots [`Self::set_unit_by_guid`] serves.
|
||||
pub fn chain_end_guids(&self) -> Vec<u64> {
|
||||
let mut ends: Vec<u64> = self.model_ref().unit_guids.chain_ends().collect();
|
||||
ends.sort_unstable();
|
||||
ends.dedup();
|
||||
ends
|
||||
}
|
||||
|
||||
/// Push the player's copper (`PLAYER_FIELD_COINAGE`), read by `GetMoney`.
|
||||
pub fn set_money(&mut self, copper: u64) {
|
||||
self.model_mut().money = copper;
|
||||
@@ -492,6 +520,8 @@ fn unit_predicate(
|
||||
|
||||
/// The `Unit*` and `GetQuestGreenRange` registrations.
|
||||
mod bindings;
|
||||
#[cfg(test)]
|
||||
mod chain_tests;
|
||||
mod resolve;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -172,11 +172,15 @@ impl UnitGuids {
|
||||
/// match, where the reference raises `Unknown unit name: %s` (`0x515c14`). An empty token is
|
||||
/// nobody (`0x51599d`).
|
||||
pub(crate) fn resolve(&self, token: &str) -> Result<Option<u64>, ()> {
|
||||
let (base, hops) = match parse_unit_token(token) {
|
||||
UnitTokenParse::Unknown => return Err(()),
|
||||
UnitTokenParse::Nobody => return Ok(None),
|
||||
UnitTokenParse::Unit { base, hops } => (base, hops),
|
||||
};
|
||||
match parse_unit_token(token) {
|
||||
UnitTokenParse::Unknown => Err(()),
|
||||
UnitTokenParse::Nobody => Ok(None),
|
||||
UnitTokenParse::Unit { base, hops } => Ok(self.resolve_unit(base, hops)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The guid a parsed token names: its base's guid, then each `target` hop. `None` is nobody.
|
||||
pub(crate) fn resolve_unit(&self, base: UnitBase, hops: usize) -> Option<u64> {
|
||||
// The indexed bases: the table's row for the parsed number.
|
||||
let at = |table: &[u64], row: u32| {
|
||||
usize::try_from(row)
|
||||
@@ -187,7 +191,7 @@ impl UnitGuids {
|
||||
};
|
||||
let guid = match base {
|
||||
UnitBase::Player => self.player,
|
||||
UnitBase::Pet if self.player == 0 => return Ok(None),
|
||||
UnitBase::Pet if self.player == 0 => return None,
|
||||
UnitBase::Pet => self.pet,
|
||||
UnitBase::Target => self.target,
|
||||
UnitBase::Npc => self.npc,
|
||||
@@ -199,10 +203,19 @@ impl UnitGuids {
|
||||
// Capped at the roster count (`0x491940`, `0x491960`).
|
||||
UnitBase::RaidPet(row) => at(&self.raid_pets, row),
|
||||
UnitBase::Raid(row) => at(&self.raid, row),
|
||||
UnitBase::Mouseover if !self.held.contains_key(&self.mouseover) => return Ok(None),
|
||||
UnitBase::Mouseover if !self.held.contains_key(&self.mouseover) => return None,
|
||||
UnitBase::Mouseover => self.mouseover,
|
||||
};
|
||||
Ok(self.follow(guid, hops))
|
||||
self.follow(guid, hops)
|
||||
}
|
||||
|
||||
/// The guids a `target` hop can end on: each held unit's target, when it names one. `held`
|
||||
/// holds the bases' units and each unit a hop reaches from them, so a token with a hop names
|
||||
/// one of these or nobody, and a per-guid feed over them covers the chains a script can spell.
|
||||
/// A guid repeats when two units share a target, and the last hop's unit need not be held: the
|
||||
/// lookup that finds its object is the reader's.
|
||||
pub(crate) fn chain_ends(&self) -> impl Iterator<Item = u64> + '_ {
|
||||
self.held.values().copied().filter(|&guid| guid != 0)
|
||||
}
|
||||
|
||||
/// [`Self::resolve`] with the raise as the Lua error it is.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The `Unit*` binding tests.
|
||||
|
||||
use crate::script::{PartyState, PlayerRecord, UiScript, UnitState};
|
||||
use crate::script::{PartyState, PlayerRecord, UiScript, UnitGuids, UnitState};
|
||||
|
||||
fn player() -> UnitState {
|
||||
UnitState {
|
||||
@@ -61,7 +61,13 @@ fn spell_target_unit_checks_in_the_references_order() {
|
||||
#[test]
|
||||
fn spell_can_target_unit_answers_per_validated_token() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_spell_targetable_units(["player", "party1"]);
|
||||
s.set_unit_guids(&UnitGuids {
|
||||
player: 0x10,
|
||||
party: [0x21, 0, 0, 0],
|
||||
target: 0x30,
|
||||
..Default::default()
|
||||
});
|
||||
s.set_spell_targetable_units([0x10, 0x21]);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return SpellCanTargetUnit("player") == true"#)
|
||||
.unwrap());
|
||||
|
||||
Reference in New Issue
Block a user