diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 6daf1ea32..c969e8b49 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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) { diff --git a/crates/benilla-app/src/script_calls.rs b/crates/benilla-app/src/script_calls.rs index 48ec86316..cc3ab8337 100644 --- a/crates/benilla-app/src/script_calls.rs +++ b/crates/benilla-app/src/script_calls.rs @@ -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::(); + 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::() + .eval::("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::().0.insert(TWIN, twin); + let mut names = world.resource_mut::(); + 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::() + .eval::("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::().0.insert(TWIN, twin); + let mut names = world.resource_mut::(); + 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::() + .eval::("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`). diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs index f301f57cb..5c2ac976d 100644 --- a/crates/benilla-app/src/target/by_name.rs +++ b/crates/benilla-app/src/target/by_name.rs @@ -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 `: resolve and select (`TargetByName`). @@ -61,43 +82,6 @@ pub(crate) struct AssistRequest { pub(crate) name: Option, } -/// 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 { - 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, @@ -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 { diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 92f963ca5..585d4c540 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -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 diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index ba1b882e4..955a8b441 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -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 { (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::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::(); + app.add_message::(); + 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 { + target: Some(wolf), + guid: Some(WOLF), + ..Default::default() + }; + let target_name = |app: &mut App, lua: &str| -> Option { + app.world_mut() + .non_send_resource_mut::() + .eval::>(&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::(); + 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::() + .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 diff --git a/crates/benilla-app/src/ui_unit/held.rs b/crates/benilla-app/src/ui_unit/held.rs index 5242357fd..2d9cf0ff1 100644 --- a/crates/benilla-app/src/ui_unit/held.rs +++ b/crates/benilla-app/src/ui_unit/held.rs @@ -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) -> 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, + /// This frame's candidates, kept between frames so the walk allocates nothing. + seen: HashSet, +} + +/// 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>, + 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>)>, + self_q: Query<(&Guid, &GlobalTransform, Option<&ObjectStore>), With>, + names: Res, + commands: Res, + factions: Option>, + reputations: Res, + group: Res, + mut memo: Local>, +) { + 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::*; diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index a33eb0245..215caf636 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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, diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 7bb095dcf..e7c253984 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, + /// 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, + /// 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, @@ -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(), diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 5d98fb418..6abfdd19d 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -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| { if let Some(token) = token { let mut model = lua.app_data_mut::().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)| { @@ -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::().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::().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)) })?, diff --git a/crates/benilla-ui/src/script/unit/by_name.rs b/crates/benilla-ui/src/script/unit/by_name.rs new file mode 100644 index 000000000..e3bbbe546 --- /dev/null +++ b/crates/benilla-ui/src/script/unit/by_name.rs @@ -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 { + 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) { + 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 { + 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) { + // 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; + } +} diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index d97058493..d8127d168 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -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; diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 73908d7f5..2c640ece9 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -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::>(lua).unwrap(); + assert_eq!( + named( + &s, + "TargetByName('Red Affinity', true) return UnitName('target')" + ) + .as_deref(), + Some("Red Affinity") + ); + assert_eq!( + s.eval::("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::("ClearTarget() return UnitExists('target') == nil") + .unwrap()); + assert!(s.take_target_view_moved()); + assert_eq!( + s.eval::>("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::("TargetUnit('party1') return UnitName('target')") + .unwrap(), + "Bob" + ); + assert!(s.take_target_view_moved()); + assert_eq!( + s.eval::("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::("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() {