TargetByName, TargetUnit and ClearTarget show their pick in the chunk (#1244)

In 1.12.1, `TargetByName` (`0x489d60`), `TargetUnit` (`0x4899d0`) and `ClearTarget` (`0x489ff0`) commit through `SetSelection` (`0x493540`) before they return, so the next line of the chunk reads the new target. benilla queued them, so `UnitName("target")` on the next line read the old unit.

The bindings now update the `"target"` view and the resolver's guid at once. They rank over a per-frame candidate feed that shares the app scan's candidate test and rank. A pick `SetSelection` refuses (not selectable, `0x4935ee`) leaves the view alone. The unit feed re-pushes the committed selection the next frame. A full tie goes to the lower guid, written as a deviation.

Tests: the chunk reads the pick; namesake ties, refused picks and the re-push, in benilla-ui and through the app's real feed.

Supersedes #1225, whose branch GitHub would not let us push to.

Co-authored-by: kalistoking <148718830+kalistoking@users.noreply.github.com>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-14 17:17:29 +07:00
committed by GitHub
parent b15fb9abc6
commit 94eb6a8bea
12 changed files with 876 additions and 101 deletions
+1 -1
View File
@@ -848,7 +848,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_642;
const UPDATE_ACTIONABLE_CEILING: usize = 4_603;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
+123
View File
@@ -578,6 +578,129 @@ mod tests {
assert_eq!(swings(&f), (vec![NEW], false));
}
/// `TargetByName` (`0x489d60`) commits through `SetSelection 0x493540` before it returns, so
/// `UnitName("target")` on the next line of the same chunk names the new unit, and the frame's
/// one application commits the same pick.
#[test]
fn target_by_name_names_the_new_target_in_the_same_chunk() {
let mut f = frame(true);
spawn_mob(&mut f, &[]);
let world = f.app.world_mut();
let mut names = world.resource_mut::<crate::names::NameCache>();
names.insert_player(OLD, "Old Mob".into(), None);
names.insert_player(MOB, "Red Affinity".into(), None);
names.insert_player(NEW, "Red Whelp".into(), None);
world
.run_system_once(crate::ui_unit::feed_name_candidates)
.expect("the candidate feed runs as a one-shot system");
let named = f
.app
.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<String>("TargetByName('Red Affinity', true) return UnitName('target')")
.expect("the chunk runs");
assert_eq!(named, "Red Affinity");
f.app
.world_mut()
.run_system_once(apply_script_calls)
.expect("the calls apply as a one-shot system");
assert_eq!(
selected(&f),
Some(MOB),
"the commit picks what the chunk read"
);
}
/// Two namesakes: the ranking decides, and the chunk shows the one the commit then selects --
/// the nearer, and of two at one distance the lower guid, whatever order either side walks.
#[test]
fn target_by_name_shows_the_namesake_the_commit_picks() {
/// A namesake of `MOB` on our other side, told apart by its health.
const TWIN: u64 = 0x30;
for (twin_x, picked, health) in [(-4.0, MOB, 100), (-3.0, TWIN, 40)] {
let mut f = frame(true);
spawn_mob(&mut f, &[]);
let world = f.app.world_mut();
let twin = world
.spawn((
Guid(TWIN),
Transform::from_xyz(twin_x, 0.0, 0.0),
GlobalTransform::from_translation(Vec3::new(twin_x, 0.0, 0.0)),
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(HEALTH, 40),
(28, 100),
])),
))
.id();
world.resource_mut::<GuidIndex>().0.insert(TWIN, twin);
let mut names = world.resource_mut::<crate::names::NameCache>();
names.insert_player(MOB, "Red Affinity".into(), None);
names.insert_player(TWIN, "Red Affinity".into(), None);
world
.run_system_once(crate::ui_unit::feed_name_candidates)
.expect("the candidate feed runs as a one-shot system");
let shown = f
.app
.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<i64>("TargetByName('Red Affinity') return UnitHealth('target')")
.expect("the chunk runs");
assert_eq!(shown, health, "the twin {twin_x} yd off");
f.app
.world_mut()
.run_system_once(apply_script_calls)
.expect("the calls apply as a one-shot system");
assert_eq!(selected(&f), Some(picked), "the twin {twin_x} yd off");
}
}
/// The candidate feed marks a `UNIT_FLAG_NOT_SELECTABLE` unit as the commit's `IsSelectable`
/// does (`0x4935ee`): the nearer namesake is the ranked pick and it is refused, so the chunk
/// keeps the old target's view and the commit keeps its selection, not the farther namesake.
#[test]
fn target_by_name_shows_nothing_when_the_commit_refuses_its_pick() {
/// `UNIT_FLAG_NOT_SELECTABLE`, `UNIT_FIELD_FLAGS` bit 25.
const NOT_SELECTABLE: u32 = 1 << 25;
/// A selectable namesake of `MOB` farther off, told apart by its health.
const TWIN: u64 = 0x30;
let mut f = frame(true);
spawn_mob(&mut f, &[(FLAGS, NOT_SELECTABLE)]);
let world = f.app.world_mut();
let twin = world
.spawn((
Guid(TWIN),
Transform::from_xyz(-4.0, 0.0, 0.0),
GlobalTransform::from_translation(Vec3::new(-4.0, 0.0, 0.0)),
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(HEALTH, 40),
(28, 100),
])),
))
.id();
world.resource_mut::<GuidIndex>().0.insert(TWIN, twin);
let mut names = world.resource_mut::<crate::names::NameCache>();
names.insert_player(MOB, "Red Affinity".into(), None);
names.insert_player(TWIN, "Red Affinity".into(), None);
world
.run_system_once(crate::ui_unit::feed_name_candidates)
.expect("the candidate feed runs as a one-shot system");
let shown = f
.app
.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<i64>("TargetByName('Red Affinity') return UnitHealth('target')")
.expect("the chunk runs");
assert_eq!(
shown, 0,
"the old target's view, not the twin's 40 or the mob's 100"
);
f.app
.world_mut()
.run_system_once(apply_script_calls)
.expect("the calls apply as a one-shot system");
assert_eq!(selected(&f), Some(OLD), "the commit refuses the same pick");
}
/// `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`).
+44 -85
View File
@@ -8,22 +8,20 @@
//! | `FollowByName 0x489ec0` | 0x10, players only | 2, `CanAssist` and alive | Lua arg 2 |
//!
//! Filter mode 0 underflows `0x493e40`'s jump table into accept-all: no range, facing, reaction,
//! liveness, visibility or self test. A name matches whole-string first (`0x64a4c0` → CRT
//! `0x414310`), else by the longest common prefix (`0x493cb6`-`0x493cf6`), both case-insensitive.
//!
//! Deviation: the reference ends the walk at the first whole-string hit (`0x493ca2`), so its pick
//! follows the object list's order (a tail append, `0x4646c3`-`0x4646d4`); we rank exact, then
//! longer prefix, then nearest ([`Rank::beats`]), because the nearest namesake is the better pick.
//! liveness, visibility or self test. The ranking is the VM's ([`benilla_ui::script::by_name`]),
//! which `TargetByName` runs over [`crate::ui_unit`]'s candidate feed to show its pick to the rest
//! of its chunk; the reference's walk follows the object list's order (a tail append,
//! `0x4646c3`-`0x4646d4`), the deviation documented there. Both walks measure off
//! [`GlobalTransform`] and break a full tie by guid, so the chunk shows the unit the commit picks.
//!
//! Not built: the reference's party (`0x493b20`) and raid (`0x493b9d`) pre-scans, which select an
//! unstreamed groupmate; [`Selection::target`] is an `Entity`.
use std::cmp::Ordering;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_protocol::guid;
use benilla_ui::script::by_name::{rank, Match, Rank};
use crate::names::NameCache;
use crate::net::{Guid, GuidIndex, ObjectStore, SelfPlayer};
@@ -39,6 +37,17 @@ pub(crate) enum NameSearch {
PlayerOnly,
}
/// What a by-name search makes of one unit, [`NameSearch::candidacy`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Candidacy<'a> {
/// Its guid family is outside the search's typemask.
Outside,
/// In the typemask, but its name is not cached; a sweep sends no query, so it cannot match.
Nameless,
/// A candidate under its cached name.
Named(&'a str),
}
impl NameSearch {
fn accepts(self, guid_val: u64) -> bool {
match self {
@@ -46,6 +55,18 @@ impl NameSearch {
Self::PlayerOnly => guid::is_player(guid_val),
}
}
/// Whether `guid_val` is a candidate of this search: the typemask, then a name already in the
/// cache. The one test both [`scan_units`] and the VM's candidate feed
/// (`ui_unit::held::feed_name_candidates`) apply, so the chunk's candidates are the commit's.
pub(crate) fn candidacy<'a>(self, names: &'a NameCache, guid_val: u64) -> Candidacy<'a> {
if !self.accepts(guid_val) {
return Candidacy::Outside;
}
names
.peek(guid_val)
.map_or(Candidacy::Nameless, Candidacy::Named)
}
}
/// `/target <name>`: resolve and select (`TargetByName`).
@@ -61,43 +82,6 @@ pub(crate) struct AssistRequest {
pub(crate) name: Option<String>,
}
/// How well one candidate answered the query, ordered by [`Rank::beats`].
#[derive(Clone, Copy, Debug, PartialEq)]
struct Rank {
/// A whole-string, case-insensitive match.
exact: bool,
/// The folded common-prefix length.
prefix: usize,
/// 3D centre-to-centre squared distance from the active player.
dist2: f32,
}
impl Rank {
/// Strictly better than the incumbent: exact, then the longer prefix, then the strictly nearer.
/// A tie or a NaN distance keeps the incumbent, as the reference's `fcomp`/`jp` compare does.
fn beats(self, other: Rank) -> bool {
match (self.exact, other.exact) {
(true, false) => true,
(false, true) => false,
_ => match self.prefix.cmp(&other.prefix) {
Ordering::Greater => true,
Ordering::Less => false,
Ordering::Equal => self.dist2 < other.dist2,
},
}
}
}
/// The case-folded common-prefix length, the reference's lockstep walk (`0x493cc0`-`0x493cee`)
/// through the ASCII fold `0x41089b`.
fn common_prefix_len(query: &str, name: &str) -> usize {
query
.bytes()
.zip(name.bytes())
.take_while(|(q, n)| q.eq_ignore_ascii_case(n))
.count()
}
/// The follow start gate (`0x60fed0`'s mode-3 chain) as a pure function, its order pinned by test:
///
/// | check | site | failure |
@@ -133,38 +117,6 @@ fn follow_refusal_key(
None
}
/// Whether the prefix tier is live, the resolver's exact-only argument. The unit popup's Target and
/// Follow pass 1 (`UnitPopup.lua:557`, `UnitPopup.lua:623`), so a menu naming its unit never lands
/// on a bystander sharing a first letter; the slash commands pass nothing.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Match {
/// A whole-string hit, else the longest common prefix.
PrefixOk,
/// Whole-string only.
ExactOnly,
}
/// Score one candidate against the query. The reference's best length seeds at 1, so a zero-length
/// overlap is no match.
fn rank(query: &str, name: &str, dist2: f32, mode: Match) -> Option<Rank> {
if query.eq_ignore_ascii_case(name) {
return Some(Rank {
exact: true,
prefix: query.len(),
dist2,
});
}
if mode == Match::ExactOnly {
return None;
}
let prefix = common_prefix_len(query, name);
(prefix >= 1).then_some(Rank {
exact: false,
prefix,
dist2,
})
}
/// Everything a by-name search reads.
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled param, the app's convention for big query sets
@@ -177,7 +129,7 @@ pub(crate) struct ByNameScan<'w, 's> {
(
Entity,
&'static Guid,
&'static Transform,
&'static GlobalTransform,
Option<&'static ObjectStore>,
),
With<SelfPlayer>,
@@ -206,7 +158,7 @@ impl ByNameScan<'_, '_> {
self.self_q
.single()
.ok()
.map(|(_, _, tf, _)| tf.translation)
.map(|(_, _, tf, _)| tf.translation())
}
/// The mode-2 arm (`0x493eca`): alive, and `CanAssist 0x6066f0` as a friendly reaction (4+).
@@ -257,7 +209,7 @@ impl ByNameScan<'_, '_> {
scan_units(
self.units
.iter()
.map(|(e, g, tf, store)| (e, g.0, tf.translation, store)),
.map(|(e, g, tf, store)| (e, g.0, tf.translation(), store)),
self.origin(),
&self.names,
query,
@@ -268,14 +220,16 @@ impl ByNameScan<'_, '_> {
}
}
/// One unit query's candidates, shared by [`ByNameScan`] and [`PlayerLookup`].
/// One unit query's candidates, positioned by [`GlobalTransform`] as the VM's candidate feed
/// (`ui_unit::held::feed_name_candidates`) does: nothing writes it during `Update`, so both walks
/// read one value in a frame, and it adds no undeclared order against the systems that move units.
type UnitQuery<'w, 's> = Query<
'w,
's,
(
Entity,
&'static Guid,
&'static Transform,
&'static GlobalTransform,
Option<&'static ObjectStore>,
),
>;
@@ -305,10 +259,11 @@ fn scan_units<'a>(
let mut nameless = 0usize;
let mut best: Option<(Entity, u64, Rank, String)> = None;
for (entity, guid, pos, store) in units {
if !search.accepts(guid) || !keep(store) {
let candidacy = search.candidacy(names, guid);
if candidacy == Candidacy::Outside || !keep(store) {
continue;
}
let Some(name) = names.peek(guid) else {
let Candidacy::Named(name) = candidacy else {
nameless += 1;
continue;
};
@@ -317,7 +272,10 @@ fn scan_units<'a>(
let Some(r) = rank(query, name, dist2, mode) else {
continue;
};
if best.as_ref().is_none_or(|(_, _, b, _)| r.beats(*b)) {
if best
.as_ref()
.is_none_or(|(_, g, b, _)| r.beats_at(guid, *b, *g))
{
best = Some((entity, guid, r, name.to_string()));
}
}
@@ -634,6 +592,7 @@ impl SelectCommit<'_, '_> {
#[cfg(test)]
mod tests {
use super::*;
use benilla_ui::script::by_name::common_prefix_len;
fn exact(dist2: f32) -> Rank {
Rank {
+5 -3
View File
@@ -63,13 +63,15 @@ pub(crate) use price_discount::vendor_price_discount;
#[cfg(test)]
pub(crate) use price_discount::{stormwind_fixture, HUMAN_WARRIOR};
pub(crate) use relations::{
can_assist, can_attack, can_interact, corpse_friendly, is_possessed_by, UNIT_FLAG_POSSESSED,
can_assist, can_attack, can_interact, corpse_friendly, is_possessed_by, is_selectable,
UNIT_FLAG_POSSESSED,
};
// 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, PlayerLookup, TargetByNameRequest};
// The chat layer's by-name asks (`/target`, `/assist`), and the typemask `TargetByName`'s
// candidate feed shares.
pub(crate) use by_name::{AssistRequest, Candidacy, NameSearch, PlayerLookup, TargetByNameRequest};
// The one SetSelection path, for the loot response's select (`0x48f3a0`).
pub(crate) use by_name::SelectCommit;
// The reaction decode and its faction catalog, which also tint the target frame
+120 -6
View File
@@ -136,6 +136,7 @@ impl Plugin for UiUnitPlugin {
held::feed_chain_units.after(crate::ui_aura::AuraEvents),
// After every snapshot above is pushed, so a handler reads the new reaction.
fire_faction_on_forced_table.after(crate::ui_aura::AuraEvents),
held::feed_name_candidates,
feed_player_control,
// After the control edge, whose flag the exemption is asked for.
control_gate::feed_control_bypass,
@@ -1331,6 +1332,8 @@ fn feed_units(
let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed());
// The interaction NPC moves when nothing else does, and `MERCHANT_SHOW` reads `"npc"`.
let interact_changed = interact.as_ref().is_some_and(|r| r.is_changed());
// A selection call showed its pick as `"target"` inside its chunk, past this memo.
let target_view_moved = script.take_target_view_moved();
gate::trace(
"feed_units",
&[
@@ -1344,6 +1347,7 @@ fn feed_units(
("reputations", reps_changed),
("factions", factions_changed),
("interact", interact_changed),
("target_view", target_view_moved),
],
);
let gate = gate::Gate::new(
@@ -1356,7 +1360,8 @@ fn feed_units(
|| group_changed
|| reps_changed
|| factions_changed
|| interact_changed,
|| interact_changed
|| target_view_moved,
);
stores.removed.clear();
if gate.skip() {
@@ -1429,11 +1434,12 @@ fn feed_units(
script.set_unit("player", player.clone());
}
}
let target_dirty = match (&target, memo.last.get("target")) {
(Some(cur), Some(prev)) => cur != prev,
(None, None) => false,
_ => true,
};
let target_dirty = target_view_moved
|| match (&target, memo.last.get("target")) {
(Some(cur), Some(prev)) => cur != prev,
(None, None) => false,
_ => true,
};
if target_dirty {
gate.audit("feed_units", "the target snapshot");
script.set_unit("target", target.clone());
@@ -1723,6 +1729,9 @@ fn combo_edge(last: Option<(u8, u64)>, now: (u8, u64)) -> Option<bool> {
(last != Some(now)).then(|| last.map(|(count, _)| count) != Some(now.0))
}
#[cfg(test)]
pub(crate) use held::feed_name_candidates;
/// The bare unit feed, for a test driving the stock unit frames off engine state.
#[cfg(test)]
pub(crate) fn add_unit_feed(app: &mut App) {
@@ -3633,6 +3642,111 @@ mod tests {
);
}
/// A selection call shows its pick as `"target"` to the rest of its chunk (`TargetByName`,
/// `0x489d60`); when the commit does not follow (here the call is dropped), the next frame
/// re-pushes `"target"` from the committed selection, though nothing the feed diffs moved.
#[test]
fn a_target_view_the_commit_did_not_follow_is_re_pushed_the_next_frame() {
use crate::names::CreatureRecord;
use benilla_protocol::messages::ObjectType;
use benilla_protocol::ObjectFields;
use benilla_ui::script::NameCandidate;
const OBJECT_FIELD_ENTRY: u16 = 3;
/// `HIGHGUID_UNIT` guids of entries 69, the selected wolf, and 70, a druid.
const WOLF: u64 = 0xF130_0000_4500_0001;
const DRUID: u64 = 0xF130_0000_4600_0001;
let mut app = App::new();
app.init_resource::<UnitFeedState>()
.init_resource::<Selection>()
.init_resource::<Reputations>()
.init_resource::<crate::ui_party::GroupState>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<crate::ui_guild::GuildState>();
app.add_message::<FieldChanged>();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
app.insert_non_send_resource(UiScript::new().unwrap());
let mut names = NameCache::default();
names.insert_creature(
69,
Some(CreatureRecord {
name: "Wolf".into(),
subname: None,
creature_type: 1,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
display_id: 0,
}),
);
app.insert_resource(names);
app.add_systems(Update, feed_units);
app.world_mut().spawn((
SelfPlayer,
Guid(0x77),
ObjectStore(ObjectFields::from_pairs(&[]).into_created(ObjectType::Player)),
));
let wolf = app
.world_mut()
.spawn(ObjectStore(ObjectFields::from_pairs(&[(
OBJECT_FIELD_ENTRY,
69,
)])))
.id();
*app.world_mut().resource_mut::<Selection>() = Selection {
target: Some(wolf),
guid: Some(WOLF),
..Default::default()
};
let target_name = |app: &mut App, lua: &str| -> Option<String> {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<Option<String>>(&format!("{lua} return UnitName('target')"))
.unwrap()
};
app.update();
app.update();
assert_eq!(target_name(&mut app, "").as_deref(), Some("Wolf"));
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
script.set_name_candidate(
DRUID,
Some(NameCandidate {
name: "Druid".into(),
dist2: 1.0,
state: benilla_ui::script::UnitState {
exists: true,
has_object: true,
guid: DRUID,
name: Some("Druid".into()),
..Default::default()
},
selectable: true,
}),
);
assert_eq!(
target_name(&mut app, "TargetByName('Druid')").as_deref(),
Some("Druid"),
"the chunk reads its pick"
);
let dropped = app
.world_mut()
.non_send_resource_mut::<UiScript>()
.take_script_calls();
assert_eq!(dropped.len(), 1, "the call queued for the commit");
app.update();
assert_eq!(
target_name(&mut app, "").as_deref(),
Some("Wolf"),
"the committed selection is shown again"
);
}
/// `UnitCreatureType` through the real feed (`0x51a280`, resolver `0x605570`): a shapeshifted
/// player answers the form's type before the race's, an unshifted one the race's, a form of
/// type -1 falls through to the race, and a creature keeps its template's unless a form
+124 -3
View File
@@ -7,18 +7,18 @@
//! 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 std::collections::{HashMap, HashSet};
use bevy::prelude::*;
use benilla_formats::ChrClasses;
use benilla_ui::script::{UiScript, UnitState};
use benilla_ui::script::{NameCandidate, 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::target::{Candidacy, Factions, NameSearch};
use crate::ui_script::{gate, VmMemo};
/// What a held unit's snapshot reads beside its descriptor.
@@ -43,6 +43,11 @@ impl HeldUnits<'_> {
.names
.resolve_unit(guid, Some(store), self.commands)
.map(str::to_string);
self.state_named(store, guid, name)
}
/// [`Self::state`] under a name the caller looked up, which sends no name query.
fn state_named(&self, store: &ObjectStore, guid: u64, name: Option<String>) -> UnitState {
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);
@@ -171,6 +176,122 @@ pub(super) fn feed_chain_units(
memo.ends = ends;
}
/// [`feed_name_candidates`]' per-VM memory.
#[derive(Default)]
pub(crate) struct CandidateMemo {
/// What each pushed candidate was built from: the name it was pushed under, and whether its
/// entry's creature record had answered. A creature's name is its record, but a pet's name
/// and its record (the creature type the snapshot reads) come back apart.
pushed: HashMap<u64, (String, bool)>,
/// This frame's candidates, kept between frames so the walk allocates nothing.
seen: HashSet<u64>,
}
/// Push every unit `TargetByName` can pick, by guid, so the binding resolves its name against
/// them and shows the pick as `"target"` to the rest of its chunk, as `0x489d60` commits before it
/// returns: the by-name scan's candidates (typemask 8, a cached name, no query sent) with their
/// distance each frame, and each one's snapshot when it is new, its descriptor, name or creature
/// record moved, or an input every snapshot reads did; another name's answer rebuilds nothing.
/// The snapshot has no guild leg, as a chain end's; it is a view for the rest of a chunk, which
/// [`super::feed_units`] replaces with the committed selection's next frame.
/// A move of our own descriptor does not rebuild the rest, so a reaction can lag our own faction
/// change until the unit's next update. Without an active player object nothing is a candidate,
/// as the reference resolves nothing then (`0x493ae0`).
pub(crate) fn feed_name_candidates(
script: Option<NonSendMut<UiScript>>,
tables: super::SnapshotTables,
// [`GlobalTransform`], as [`crate::target::PlayerLookup`] reads it: `Transform` would put
// this feed in an undeclared order against every system that moves a unit.
units: Query<(&Guid, &GlobalTransform, Option<Ref<ObjectStore>>)>,
self_q: Query<(&Guid, &GlobalTransform, Option<&ObjectStore>), With<SelfPlayer>>,
names: Res<NameCache>,
commands: Res<NetCommands>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
group: Res<crate::ui_party::GroupState>,
mut memo: Local<VmMemo<CandidateMemo>>,
) {
let Some(mut script) = script else {
return;
};
let (memo, vm_reset) = memo.get_reset(&script);
let rebuild = vm_reset
|| group.is_changed()
|| reputations.is_changed()
|| factions.as_ref().is_some_and(|r| r.is_changed());
let me = self_q.single().ok();
let held = HeldUnits {
names: &names,
commands: &commands,
factions: factions.as_deref(),
reputations: &reputations,
group: &group,
self_store: me.and_then(|(_, _, s)| s),
classes: tables.classes(),
types: tables.types(&names),
};
memo.seen.clear();
let origin = me.map(|(_, tf, _)| tf.translation());
let self_guid = me.map(|(g, _, _)| g.0);
for (guid, tf, store) in origin.iter().flat_map(|_| &units) {
let guid = guid.0;
let Candidacy::Named(name) = NameSearch::AnyUnit.candidacy(&names, guid) else {
continue;
};
memo.seen.insert(guid);
let dist2 = origin.map_or(0.0, |o| tf.translation().distance_squared(o));
let record = store
.as_ref()
.and_then(|s| s.0.object_entry())
.or_else(|| benilla_protocol::guid::entry(guid))
.is_some_and(|e| names.creature_record(e).is_some());
let fresh = rebuild
|| store.as_ref().is_some_and(|s| s.is_changed())
|| memo
.pushed
.get(&guid)
.is_none_or(|(n, r)| n != name || *r != record);
if !fresh {
script.set_name_candidate_dist2(guid, dist2);
continue;
}
let state = match store.as_deref() {
// The name as the target feed reads it off the descriptor, from the cache alone.
Some(store) => {
let named = names.peek_unit(guid, Some(store)).map(str::to_string);
held.state_named(store, guid, named)
}
None => UnitState {
exists: true,
has_object: true,
is_connected: true,
guid,
name: Some(name.to_string()),
is_player: benilla_protocol::guid::is_player(guid),
..Default::default()
},
};
script.set_name_candidate(
guid,
Some(NameCandidate {
name: name.to_string(),
dist2,
state,
selectable: crate::target::is_selectable(store.as_deref(), self_guid),
}),
);
memo.pushed.insert(guid, (name.to_string(), record));
}
let seen = &memo.seen;
memo.pushed.retain(|guid, _| {
let keep = seen.contains(guid);
if !keep {
script.set_name_candidate(*guid, None);
}
keep
});
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -303,6 +303,7 @@ pub use types::{
MeasureRequest, Outline, QuadContent, ScriptValue, TexCoords,
};
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
pub use unit::by_name::{self, NameCandidate};
pub use unit::{
grey_band, level_reads_unknown, parse_unit_token, power_token, unit_is_grey, PlayerRecord,
SelectionRequest, UnitBase, UnitGuids, UnitState, UnitTokenParse,
+8
View File
@@ -349,6 +349,12 @@ pub(crate) struct Model {
/// [`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>,
/// Every unit `TargetByName` can pick, by guid, as the app's by-name scan sees it this frame:
/// the binding resolves against it so the rest of its chunk reads the new selection.
pub(crate) name_candidates: HashMap<u64, super::NameCandidate>,
/// A selection call moved the `"target"` view inside a chunk; the app's unit feed re-pushes
/// `"target"` from the committed selection, whatever its memo holds.
pub(crate) target_view_moved: bool,
/// 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>,
@@ -1301,6 +1307,8 @@ impl Model {
ui_scale_request: None,
units_by_lower: HashMap::new(),
units_by_guid: HashMap::new(),
name_candidates: HashMap::new(),
target_view_moved: false,
player_auras: Vec::new(),
unit_auras: HashMap::new(),
unit_guids: Default::default(),
+17 -3
View File
@@ -986,12 +986,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// TargetUnit(unit): queue the token for the app to resolve and select; a token naming no unit
// is a no-op, as in the reference.
// is a no-op, as in the reference. A held, selectable unit is shown as `"target"` at once, as
// `0x4899d0` commits before it returns.
g.set(
"TargetUnit",
lua.create_function(|lua, token: Option<String>| {
if let Some(token) = token {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(state) = model.held_unit_view(&token) {
model.show_selection(Some(state));
}
model
.script_calls
.push(ScriptCall::Select(SelectionRequest::Unit(token)));
@@ -1038,7 +1042,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// number is taken as its string. Deviation: among whole-name matches ours picks the nearest,
// because the reference's first-walked pick is order-dependent and reads as a bug. A miss is
// silent here, where the reference shows `0x127` ERR_UNIT_NOT_FOUND, or `0xb8`
// ERR_GENERIC_NO_TARGET for an empty name.
// ERR_GENERIC_NO_TARGET for an empty name. The pick is shown as `"target"` at once, from the
// app's candidates (`by_name`), so `UnitName("target")` on the next line names it, unless it
// is not selectable and `SetSelection` returns (`0x4935ee`); the queued call still commits it.
g.set(
"TargetByName",
lua.create_function(|lua, (name, exact): (Value, Option<Value>)| {
@@ -1047,6 +1053,10 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// Argument 2 comes from `0x6f1c10` with default 0: it never raises, and 0 is absent.
let exact = super::super::binding_abi::bool_or_default(exact.as_ref(), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let pick = model.named_unit(&name, exact).filter(|c| c.selectable);
if let Some(state) = pick.map(|c| c.state.clone()) {
model.show_selection(Some(state));
}
model
.script_calls
.push(ScriptCall::TargetByName { name, exact });
@@ -1097,12 +1107,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// ClearTarget() (`0x489ff0`): 1 when it cleared a target, nil when there was none, which
// `ToggleGameMenu`'s Escape chain needs to fall through to the menu. The deselect is queued
// either way: it reads the selection as the calls before it leave it (`0x489ff0`-`0x489fff`),
// so `TargetUnit("player") ClearTarget()` ends with nothing selected.
// so `TargetUnit("player") ClearTarget()` ends with nothing selected, and `"target"` reads
// nothing for the rest of the chunk.
g.set(
"ClearTarget",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let had = model.unit("target").is_some_and(|u| u.exists);
if had {
model.show_selection(None);
}
model.script_calls.push(ScriptCall::ClearTarget);
Ok(flag(had))
})?,
@@ -0,0 +1,203 @@
//! The by-name resolver's ranking (`0x493aa0`), shared by the app's scan, which commits the pick,
//! and `TargetByName`, which shows it to the rest of its chunk: in the reference the binding
//! (`0x489d60`) commits through `SetSelection 0x493540` before it returns, so `UnitName("target")`
//! on the next line already names the new unit. A name matches whole-string first (`0x64a4c0` → CRT
//! `0x414310`), else by the longest common prefix (`0x493cb6`-`0x493cf6`), both case-insensitive.
//!
//! Deviation: the reference ends the walk at the first whole-string hit (`0x493ca2`), so its pick
//! follows the object list's order; we rank exact, then longer prefix, then nearest
//! ([`Rank::beats`]), because the nearest namesake is the better pick, and a full tie goes to the
//! lower guid ([`Rank::beats_at`]), so the VM's walk and the app's scan pick the same unit whatever
//! order each walks in.
use std::cmp::Ordering;
use super::{Model, UnitState};
/// How well one candidate answered the query, ordered by [`Rank::beats`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rank {
/// A whole-string, case-insensitive match.
pub exact: bool,
/// The folded common-prefix length.
pub prefix: usize,
/// 3D centre-to-centre squared distance from the active player.
pub dist2: f32,
}
impl Rank {
/// Strictly better than the incumbent: exact, then the longer prefix, then the strictly nearer.
/// A tie or a NaN distance keeps the incumbent, as the reference's `fcomp`/`jp` compare does.
pub fn beats(self, other: Rank) -> bool {
match (self.exact, other.exact) {
(true, false) => true,
(false, true) => false,
_ => match self.prefix.cmp(&other.prefix) {
Ordering::Greater => true,
Ordering::Less => false,
Ordering::Equal => self.dist2 < other.dist2,
},
}
}
/// [`Rank::beats`] with the guid as the last key: of two equal ranks the lower guid wins. A
/// NaN distance is equal to nothing, so it still keeps the incumbent.
///
/// Deviation: a full tie goes to the lower guid, where the reference keeps the first unit its
/// walk (`0x493aa0`) reaches, an order neither of ours has; a guid keeps the two picks equal.
pub fn beats_at(self, guid: u64, other: Rank, other_guid: u64) -> bool {
self.beats(other) || (self == other && guid < other_guid)
}
}
/// The case-folded common-prefix length, the reference's lockstep walk (`0x493cc0`-`0x493cee`)
/// through the ASCII fold `0x41089b`.
pub fn common_prefix_len(query: &str, name: &str) -> usize {
query
.bytes()
.zip(name.bytes())
.take_while(|(q, n)| q.eq_ignore_ascii_case(n))
.count()
}
/// Whether the prefix tier is live, the resolver's exact-only argument. The unit popup's Target and
/// Follow pass 1 (`UnitPopup.lua:557`, `UnitPopup.lua:623`), so a menu naming its unit never lands
/// on a bystander sharing a first letter; the slash commands pass nothing.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Match {
/// A whole-string hit, else the longest common prefix.
PrefixOk,
/// Whole-string only.
ExactOnly,
}
/// Score one candidate against the query. The reference's best length seeds at 1, so a zero-length
/// overlap is no match.
pub fn rank(query: &str, name: &str, dist2: f32, mode: Match) -> Option<Rank> {
if query.eq_ignore_ascii_case(name) {
return Some(Rank {
exact: true,
prefix: query.len(),
dist2,
});
}
if mode == Match::ExactOnly {
return None;
}
let prefix = common_prefix_len(query, name);
(prefix >= 1).then_some(Rank {
exact: false,
prefix,
dist2,
})
}
/// One unit `TargetByName` can pick (typemask 8: a creature, pet or player whose name is cached),
/// as the app's by-name scan sees it: the name it ranks, its distance, and the snapshot the
/// `"target"` token reads once it is picked.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NameCandidate {
/// The cached name the scan compares.
pub name: String,
/// Squared distance from the active player this frame.
pub dist2: f32,
/// The unit as a token naming it reads it.
pub state: UnitState,
/// `IsSelectable`, which `SetSelection` asks before it selects (`0x4935ee`): a pick that is not
/// selectable leaves the selection and so the `"target"` view as it was.
pub selectable: bool,
}
impl super::super::UiScript {
/// Push, or clear with `None`, a by-name candidate. Without an active player object the app
/// pushes none, as the reference resolves nothing then (`0x493ae0`).
pub fn set_name_candidate(&mut self, guid: u64, candidate: Option<NameCandidate>) {
let mut model = self.model_mut();
match candidate {
Some(c) => {
model.name_candidates.insert(guid, c);
}
None => {
model.name_candidates.remove(&guid);
}
}
}
/// Move a pushed candidate's distance, which changes every frame its snapshot does not.
pub fn set_name_candidate_dist2(&mut self, guid: u64, dist2: f32) {
if let Some(c) = self.model_mut().name_candidates.get_mut(&guid) {
c.dist2 = dist2;
}
}
/// Whether a selection call moved the `"target"` view since the last ask; the app's unit feed
/// then re-pushes `"target"` from the committed selection.
pub fn take_target_view_moved(&mut self) -> bool {
std::mem::take(&mut self.model_mut().target_view_moved)
}
}
impl Model {
/// The candidate `TargetByName(name, exactMatch)` ranks first, the scan's walk over this
/// frame's candidates: the query trimmed, an empty one nothing. Selectable or not, as the
/// resolver ranks before `SetSelection` refuses (`0x4935ee`).
pub(crate) fn named_unit(&self, query: &str, exact: bool) -> Option<&NameCandidate> {
let query = query.trim();
if query.is_empty() {
return None;
}
let mode = if exact {
Match::ExactOnly
} else {
Match::PrefixOk
};
let mut best: Option<(Rank, u64, &NameCandidate)> = None;
for (&guid, c) in &self.name_candidates {
let Some(r) = rank(query, &c.name, c.dist2, mode) else {
continue;
};
if best
.as_ref()
.is_none_or(|(b, g, _)| r.beats_at(guid, *b, *g))
{
best = Some((r, guid, c));
}
}
best.map(|(_, _, c)| c)
}
/// What `TargetUnit(token)` shows as `"target"`: the unit the token names when the object
/// manager holds it, the app's own commit condition, and `SetSelection` accepts it
/// (`0x4935ee`); its by-name snapshot when it has one, else the token's own push, whose
/// selectability the VM cannot see.
pub(crate) fn held_unit_view(&self, token: &str) -> Option<UnitState> {
let guid = self.unit_guids.resolve(token).ok().flatten();
match guid.and_then(|g| self.name_candidates.get(&g)) {
Some(c) => c.selectable.then(|| c.state.clone()),
None => self.unit(token).filter(|u| u.has_object).cloned(),
}
}
/// Show a selection call's outcome to the rest of its chunk: the resolver's target guid and the
/// `"target"` snapshot, `None` for nothing selected. The call itself stays queued, and the app
/// commits it (`CMSG_SET_SELECTION`, the attack re-point, the loot close) when its turn comes;
/// the next frame's feed replaces this view with the committed selection's.
pub(crate) fn show_selection(&mut self, state: Option<UnitState>) {
// Already shown: the feed's own push, which carries the guild leg, stays.
if state.as_ref().is_some_and(|s| {
s.guid != 0 && s.guid == self.unit_guids.target && self.pushed_unit("target").is_some()
}) {
return;
}
self.unit_guids.target = state.as_ref().map_or(0, |s| s.guid);
match state {
Some(s) => {
self.units_by_lower.insert("target".to_string(), s);
}
None => {
self.units_by_lower.remove("target");
}
}
self.target_view_moved = true;
}
}
+1
View File
@@ -525,6 +525,7 @@ fn unit_predicate(
/// The `Unit*` and `GetQuestGreenRange` registrations.
mod bindings;
pub mod by_name;
#[cfg(test)]
mod chain_tests;
mod resolve;
+229
View File
@@ -858,6 +858,235 @@ fn target_by_name_queues_the_name_and_the_exact_flag() {
);
}
/// A by-name candidate as the app's feed pushes it: `name` at `dist2`, held, under `guid`.
fn candidate(guid: u64, name: &str, dist2: f32) -> crate::script::NameCandidate {
crate::script::NameCandidate {
name: name.into(),
dist2,
state: UnitState {
exists: true,
has_object: true,
guid,
name: Some(name.into()),
health: 40,
max_health: 50,
..Default::default()
},
selectable: true,
}
}
/// [`candidate`] that `SetSelection` refuses (`UNIT_FLAG_NOT_SELECTABLE`, `0x4935ee`).
fn unselectable(guid: u64, name: &str, dist2: f32) -> crate::script::NameCandidate {
crate::script::NameCandidate {
selectable: false,
..candidate(guid, name, dist2)
}
}
/// `TargetByName` (`0x489d60`) commits through `SetSelection` before it returns, so the rest of its
/// chunk reads the pick as `"target"`; the call still queues for the app's commit, and the feed is
/// told to re-push `"target"` from the committed selection.
#[test]
fn target_by_name_shows_its_pick_to_the_rest_of_the_chunk() {
const OLD: u64 = 0x21;
let mut s = UiScript::new().unwrap();
s.set_unit(
"target",
Some(UnitState {
exists: true,
has_object: true,
guid: OLD,
name: Some("Old Mob".into()),
..Default::default()
}),
);
s.set_name_candidate(0x31, Some(candidate(0x31, "Red Affinity", 400.0)));
s.set_name_candidate(0x32, Some(candidate(0x32, "Red Affinity", 100.0)));
s.set_name_candidate(0x33, Some(candidate(0x33, "Redridge Gnoll", 4.0)));
s.set_name_candidate_dist2(0x31, 25.0);
assert!(!s.take_target_view_moved());
let named = |s: &UiScript, lua: &str| s.eval::<Option<String>>(lua).unwrap();
assert_eq!(
named(
&s,
"TargetByName('Red Affinity', true) return UnitName('target')"
)
.as_deref(),
Some("Red Affinity")
);
assert_eq!(
s.eval::<i64>("return UnitHealth('target')").unwrap(),
40,
"the pick's whole snapshot, not only its name"
);
assert_eq!(
s.model_ref().unit_guids.target,
0x31,
"the resolver's target guid moved with it, to the namesake nearer by the last distance"
);
assert_eq!(
s.take_target_by_name_requests(),
vec![("Red Affinity".to_string(), true)],
"the app still commits the call"
);
assert!(s.take_target_view_moved());
// The prefix tier needs no exact flag.
let guid = |s: &UiScript| {
s.model_ref()
.unit("target")
.map(|u| u.guid)
.expect("a target")
};
s.run("TargetByName('Red Affinity')").unwrap();
assert_eq!(guid(&s), 0x31);
// Two namesakes at one distance: the lower guid, whatever order the map walks in.
for (moved, at) in [(0x31, 100.0), (0x32, 100.0), (0x32, 25.0)] {
s.set_name_candidate_dist2(moved, at);
}
s.set_name_candidate(0x30, Some(candidate(0x30, "Red Affinity", 25.0)));
s.run("TargetByName('Red Affinity')").unwrap();
assert_eq!(guid(&s), 0x30, "a full tie goes to the lower guid");
s.set_name_candidate(0x30, None);
s.run("TargetByName('Red Affinity')").unwrap();
assert_eq!(guid(&s), 0x32, "the nearer namesake again");
s.run("TargetByName('redr')").unwrap();
assert_eq!(
guid(&s),
0x33,
"the longer prefix beats the nearer shorter one"
);
// A miss, an exact-only prefix and an empty name leave the view where it was.
for lua in [
"TargetByName('Nobody')",
"TargetByName('Red', 1)",
"TargetByName(' ')",
] {
s.run(lua).unwrap();
assert_eq!(guid(&s), 0x33, "{lua}");
}
}
/// `ClearTarget` (`0x489ff0` → `0x493540(0,0)`) and `TargetUnit` (`0x4899d0`) commit before they
/// return too; a token naming no held unit leaves the view alone, as the app's commit does.
#[test]
fn clear_target_and_target_unit_show_their_outcome_to_the_rest_of_the_chunk() {
let mut s = UiScript::new().unwrap();
s.set_unit_guids(&UnitGuids {
player: 0x10,
party: [0x22, 0x23, 0, 0],
target: 0x21,
..Default::default()
});
let mut mate = candidate(0x22, "Bob", 9.0).state;
s.set_unit("party1", Some(mate.clone()));
// The by-name feed's snapshot of the same unit, which `TargetUnit` shows when it has one.
let mut fed = candidate(0x22, "Bob", 9.0);
fed.state.health = 45;
s.set_name_candidate(0x22, Some(fed));
mate.guid = 0x23;
mate.has_object = false;
s.set_unit("party2", Some(mate));
s.set_unit("target", Some(candidate(0x21, "Old Mob", 1.0).state));
assert!(s
.eval::<bool>("ClearTarget() return UnitExists('target') == nil")
.unwrap());
assert!(s.take_target_view_moved());
assert_eq!(
s.eval::<Option<String>>("TargetUnit('party2') return UnitName('target')")
.unwrap(),
None,
"party2 is not held: the view mirrors the app's commit, which selects no unheld unit"
);
assert!(!s.take_target_view_moved());
assert_eq!(
s.eval::<String>("TargetUnit('party1') return UnitName('target')")
.unwrap(),
"Bob"
);
assert!(s.take_target_view_moved());
assert_eq!(
s.eval::<i64>("return UnitHealth('target')").unwrap(),
45,
"party1 resolves to 0x22, and the feed's snapshot of 0x22 is the one shown"
);
assert_eq!(s.model_ref().unit_guids.target, 0x22);
s.set_name_candidate(0x22, None);
s.run("ClearTarget() TargetUnit('party1')").unwrap();
assert_eq!(
s.eval::<i64>("return UnitHealth('target')").unwrap(),
40,
"without a feed snapshot, the token's own push"
);
}
/// `SetSelection` (`0x493540`) returns at its `IsSelectable` call (`0x4935ee`) and keeps the old
/// target, while `TargetByName` ranks (`0x493aa0`, no selectable filter) before it: a
/// non-selectable best pick leaves the view, though a selectable namesake farther off is ranked
/// below it. The call still queues for the app's commit, which refuses it the same way.
#[test]
fn target_by_name_leaves_the_view_when_its_pick_is_not_selectable() {
const OLD: u64 = 0x21;
let mut s = UiScript::new().unwrap();
let old = candidate(OLD, "Old Mob", 1.0).state;
s.set_unit("target", Some(old));
s.set_name_candidate(0x31, Some(unselectable(0x31, "Red Affinity", 4.0)));
s.set_name_candidate(0x32, Some(candidate(0x32, "Red Affinity", 100.0)));
let guid = |s: &UiScript| s.model_ref().unit("target").map(|u| u.guid);
s.run("TargetByName('Red Affinity')").unwrap();
assert_eq!(
guid(&s),
Some(OLD),
"the nearer namesake is the pick, and it is refused"
);
assert!(!s.take_target_view_moved());
assert_eq!(
s.take_target_by_name_requests(),
vec![("Red Affinity".to_string(), false)],
"the call still queues for the app's commit"
);
// Once the nearer one is selectable the same walk shows it.
s.set_name_candidate(0x31, Some(candidate(0x31, "Red Affinity", 4.0)));
s.run("TargetByName('Red Affinity')").unwrap();
assert_eq!(guid(&s), Some(0x31));
}
/// `TargetUnit` (`0x4899d0` → `0x489a40`) selects through the same `SetSelection`: a held unit
/// that is not selectable leaves the view, and does not fall back to the token's own push.
#[test]
fn target_unit_leaves_the_view_when_the_held_unit_is_not_selectable() {
let mut s = UiScript::new().unwrap();
s.set_unit_guids(&UnitGuids {
player: 0x10,
party: [0x22, 0, 0, 0],
target: 0x21,
..Default::default()
});
s.set_unit("party1", Some(candidate(0x22, "Bob", 9.0).state));
s.set_unit("target", Some(candidate(0x21, "Old Mob", 1.0).state));
s.set_name_candidate(0x22, Some(unselectable(0x22, "Bob", 9.0)));
s.run("TargetUnit('party1')").unwrap();
assert_eq!(
s.model_ref().unit("target").map(|u| u.guid),
Some(0x21),
"the old target stays"
);
assert_eq!(s.model_ref().unit_guids.target, 0x21);
assert!(!s.take_target_view_moved());
assert_eq!(
s.take_script_calls().len(),
1,
"the call still queues for the app's commit"
);
s.set_name_candidate(0x22, Some(candidate(0x22, "Bob", 9.0)));
s.run("TargetUnit('party1')").unwrap();
assert_eq!(s.model_ref().unit("target").map(|u| u.guid), Some(0x22));
}
/// `0x516cf0` tests `UNIT_FIELD_CHARMEDBY` (fields 10-11) non-zero, not a `UNIT_FIELD_FLAGS` bit.
#[test]
fn unit_is_charmed_answers_one_or_nil_and_only_for_the_charmed_side() {