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:
@@ -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) {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user