/wave <name> targets the named player; two DoEmote gates follow 1.12.1 (#769)
Three ways `DoEmote` and the chat talk gesture differed from 1.12.1: - `/wave Bob` ignored the name and targeted the selection. 1.12.1 resolves a name that does not start with `%` as a player, at any range (`0x49fdb1`-`0x49fdf0`, `0x493aa0`), and sends the emote untargeted on a miss. `DoEmote(token, name)` from Lua does the same. - A polymorphed, stunned or sleeping speaker still played the talk gesture. `UNIT_FIELD_FLAGS 0x20000000` suppresses it when the sender's name is cached (`0x49d7f7`). - `DoEmote` sent while our unit was possessed (`0x5ef57e`). A chat-only emote now reads `Emotes.dbc` row 0's flags, so it is refused while asleep or dead (`0x5ef5b1`, `0x47db8e`). Tests cover the name, miss, `%t` and self cases, the gesture gate, and both `DoEmote` gates against the install's row 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fbea2d9f3a
commit
2dc7d8a9e6
@@ -170,16 +170,7 @@ fn rank(query: &str, name: &str, dist2: f32, mode: Match) -> Option<Rank> {
|
||||
#[allow(clippy::type_complexity)] // one bundled param, the app's convention for big query sets
|
||||
pub(crate) struct ByNameScan<'w, 's> {
|
||||
/// Every known unit, our own avatar included: the reference has no self-exclusion here.
|
||||
units: Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
Entity,
|
||||
&'static Guid,
|
||||
&'static Transform,
|
||||
Option<&'static ObjectStore>,
|
||||
),
|
||||
>,
|
||||
units: UnitQuery<'w, 's>,
|
||||
self_q: Query<
|
||||
'w,
|
||||
's,
|
||||
@@ -255,8 +246,7 @@ impl ByNameScan<'_, '_> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve a name to a unit, logging one `by-name:` line per call, ungated since it runs per
|
||||
/// command: the candidates counted, the winner and on what.
|
||||
/// Resolve a name to a unit through [`scan_units`], the mode argument deciding the filter.
|
||||
fn resolve(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -264,51 +254,116 @@ impl ByNameScan<'_, '_> {
|
||||
filter: Filter,
|
||||
mode: Match,
|
||||
) -> Option<(Entity, u64, String)> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
scan_units(
|
||||
self.units
|
||||
.iter()
|
||||
.map(|(e, g, tf, store)| (e, g.0, tf.translation, store)),
|
||||
self.origin(),
|
||||
&self.names,
|
||||
query,
|
||||
search,
|
||||
mode,
|
||||
|store| filter != Filter::AssistableAlive || self.assistable_alive(store),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One unit query's candidates, shared by [`ByNameScan`] and [`PlayerLookup`].
|
||||
type UnitQuery<'w, 's> = Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
Entity,
|
||||
&'static Guid,
|
||||
&'static Transform,
|
||||
Option<&'static ObjectStore>,
|
||||
),
|
||||
>;
|
||||
|
||||
/// The resolver's walk (`0x493aa0`): a name to a unit, logging one `by-name:` line per call,
|
||||
/// ungated since it runs per command: the candidates counted, the winner and on what. `keep` is
|
||||
/// the per-candidate filter mode; `origin` is the active player's position, without which the
|
||||
/// reference resolves nothing (`0x493ae0`).
|
||||
fn scan_units<'a>(
|
||||
units: impl Iterator<Item = (Entity, u64, Vec3, Option<&'a ObjectStore>)>,
|
||||
origin: Option<Vec3>,
|
||||
names: &NameCache,
|
||||
query: &str,
|
||||
search: NameSearch,
|
||||
mode: Match,
|
||||
keep: impl Fn(Option<&ObjectStore>) -> bool,
|
||||
) -> Option<(Entity, u64, String)> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let Some(origin) = origin else {
|
||||
info!("by-name: \"{query}\" — no active player object; nothing resolves");
|
||||
return None;
|
||||
};
|
||||
let mut considered = 0usize;
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
let Some(origin) = self.origin() else {
|
||||
info!("by-name: \"{query}\" — no active player object; nothing resolves");
|
||||
return None;
|
||||
let Some(name) = names.peek(guid) else {
|
||||
nameless += 1;
|
||||
continue;
|
||||
};
|
||||
let mut considered = 0usize;
|
||||
let mut nameless = 0usize;
|
||||
let mut best: Option<(Entity, u64, Rank, String)> = None;
|
||||
for (entity, guid, tf, store) in &self.units {
|
||||
if !search.accepts(guid.0) {
|
||||
continue;
|
||||
}
|
||||
if filter == Filter::AssistableAlive && !self.assistable_alive(store) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = self.names.peek(guid.0) else {
|
||||
nameless += 1;
|
||||
continue;
|
||||
};
|
||||
considered += 1;
|
||||
let dist2 = tf.translation.distance_squared(origin);
|
||||
let Some(r) = rank(query, name, dist2, mode) else {
|
||||
continue;
|
||||
};
|
||||
if best.as_ref().is_none_or(|(_, _, b, _)| r.beats(*b)) {
|
||||
best = Some((entity, guid.0, r, name.to_string()));
|
||||
}
|
||||
considered += 1;
|
||||
let dist2 = pos.distance_squared(origin);
|
||||
let Some(r) = rank(query, name, dist2, mode) else {
|
||||
continue;
|
||||
};
|
||||
if best.as_ref().is_none_or(|(_, _, b, _)| r.beats(*b)) {
|
||||
best = Some((entity, guid, r, name.to_string()));
|
||||
}
|
||||
match &best {
|
||||
Some((_, guid, r, name)) => info!(
|
||||
"by-name: \"{query}\" ({search:?}) -> \"{name}\" guid {guid:#x} \
|
||||
({}, prefix {}, {:.1} yd) over {considered} named candidates ({nameless} unnamed)",
|
||||
if r.exact { "exact" } else { "prefix" },
|
||||
r.prefix,
|
||||
r.dist2.sqrt(),
|
||||
),
|
||||
None => info!(
|
||||
"by-name: \"{query}\" ({search:?}) -> NO MATCH over {considered} named candidates \
|
||||
({nameless} unnamed, {mode:?}); target left untouched"
|
||||
),
|
||||
}
|
||||
best.map(|(e, g, _, name)| (e, g, name))
|
||||
}
|
||||
match &best {
|
||||
Some((_, guid, r, name)) => info!(
|
||||
"by-name: \"{query}\" ({search:?}) -> \"{name}\" guid {guid:#x} \
|
||||
({}, prefix {}, {:.1} yd) over {considered} named candidates ({nameless} unnamed)",
|
||||
if r.exact { "exact" } else { "prefix" },
|
||||
r.prefix,
|
||||
r.dist2.sqrt(),
|
||||
),
|
||||
None => info!(
|
||||
"by-name: \"{query}\" ({search:?}) -> NO MATCH over {considered} named candidates \
|
||||
({nameless} unnamed, {mode:?}); target left untouched"
|
||||
),
|
||||
}
|
||||
best.map(|(e, g, _, name)| (e, g, name))
|
||||
}
|
||||
|
||||
/// The player-only name search for a caller that holds the [`NameCache`] mutably and so cannot
|
||||
/// take [`ByNameScan`]: `DoEmote`'s target (`0x49fdb1`).
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct PlayerLookup<'w, 's> {
|
||||
/// World positions, [`GlobalTransform`]: the chat drain already reads it, where `Transform`
|
||||
/// would put it in an undeclared order against every system that moves a unit.
|
||||
units: Query<'w, 's, (Entity, &'static Guid, &'static GlobalTransform)>,
|
||||
self_q: Query<'w, 's, &'static GlobalTransform, With<SelfPlayer>>,
|
||||
}
|
||||
|
||||
impl PlayerLookup<'_, '_> {
|
||||
/// `0x493aa0` with typemask `0x10`, filter mode 0 and no exact-only flag, as `/assist <name>`
|
||||
/// calls it: any player in the object list, whole name or longest prefix. Selects nothing.
|
||||
pub(crate) fn find(&self, names: &NameCache, name: &str) -> Option<(Entity, u64)> {
|
||||
let origin = self.self_q.single().ok().map(|tf| tf.translation());
|
||||
scan_units(
|
||||
self.units
|
||||
.iter()
|
||||
.map(|(e, g, tf)| (e, g.0, tf.translation(), None)),
|
||||
origin,
|
||||
names,
|
||||
name,
|
||||
NameSearch::PlayerOnly,
|
||||
Match::PrefixOk,
|
||||
|_| true,
|
||||
)
|
||||
.map(|(e, g, _)| (e, g))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ pub(crate) use relations::{
|
||||
// pet bar's; the melee probe's press with nothing selected.
|
||||
pub(crate) use scan::{AttackNearestRequest, AttackPick};
|
||||
// The chat layer's by-name asks (`/target`, `/assist`).
|
||||
pub(crate) use by_name::{AssistRequest, TargetByNameRequest};
|
||||
pub(crate) use by_name::{AssistRequest, PlayerLookup, TargetByNameRequest};
|
||||
// The reaction decode and its faction catalog, which also tint the target frame
|
||||
// (`TargetFrame_CheckFaction`); `duel_rung` is the same walk, for `/reaction`.
|
||||
pub(crate) use ring::{duel_rung, ring_reaction, ring_variant, Factions, RingVariant};
|
||||
|
||||
@@ -596,6 +596,17 @@ pub(super) struct SpeakerEffects<'w> {
|
||||
gestures: ResMut<'w, crate::creature_anim::GestureQueue>,
|
||||
}
|
||||
|
||||
/// `UNIT_FIELD_FLAGS` bit `0x20000000` (`PREVENT_ANIM`: Polymorph, Kidney Shot, Gouge, Sleep).
|
||||
const UNIT_FLAG_PREVENT_ANIM: u32 = 0x2000_0000;
|
||||
|
||||
/// Whether the speaker's flags suppress the chat talk gesture. Only the handler's own copy of
|
||||
/// the selector tests the bit (`0x49d7f7`, the one `0xa0` test of `0x20000000` in the image),
|
||||
/// which runs when the sender's name is already cached (`tries == 0`); a line held for the name
|
||||
/// query gestures from the deferred copies (`0x49cf2b`, `0x49d3bb`), which do not.
|
||||
pub(super) fn gesture_prevented(tries: u16, speaker_flags: Option<u32>) -> bool {
|
||||
tries == 0 && speaker_flags.is_some_and(|f| f & UNIT_FLAG_PREVENT_ANIM != 0)
|
||||
}
|
||||
|
||||
/// Drain [`ChatLog`]: resolve names (ask-once, bounded), build the events and [`route`] them.
|
||||
pub(super) fn feed_chat(
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
@@ -758,6 +769,12 @@ pub(super) fn feed_chat(
|
||||
// The gesture reads the raw type and `plain`, not the garbled text: the selector is
|
||||
// in the parser (`0x49d560`, `0x49d820`-`0x49d8ae`) on the buffer `0x49dbc2` hands
|
||||
// `0x49a870`, so a Horde `lol` laughs for every observer.
|
||||
// The immediate path alone refuses a speaker that cannot animate (`0x49d7f7`).
|
||||
let speaker_flags = guids
|
||||
.0
|
||||
.get(&msg.sender_guid)
|
||||
.and_then(|e| stores.get(*e).ok())
|
||||
.map(|s| s.0.unit_flags());
|
||||
if let Some(gesture) =
|
||||
crate::creature_anim::select_gesture(msg.chat_type, &plain, |n| {
|
||||
script
|
||||
@@ -766,6 +783,7 @@ pub(super) fn feed_chat(
|
||||
.get::<String>(format!("LAUGH_WORD{n}"))
|
||||
.ok()
|
||||
})
|
||||
.filter(|_| !gesture_prevented(tries, speaker_flags))
|
||||
{
|
||||
speaker.gestures.push(msg.sender_guid, gesture);
|
||||
}
|
||||
|
||||
@@ -29,12 +29,27 @@ fn target_player_name(
|
||||
names.resolve(guid, commands).map(str::to_string)
|
||||
}
|
||||
|
||||
/// The target guid a `CMSG_TEXT_EMOTE` carries: the selection, except that an emote at yourself
|
||||
/// goes out untargeted (`0x5ef611`), so 1.12 has no self-emote sentence. Compared by entity, which
|
||||
/// agrees with the guid: [`Selection`]'s one writer sets both from one streamed entity.
|
||||
pub(super) fn emote_target(selection: &Selection, me: Option<Entity>) -> u64 {
|
||||
match selection.guid {
|
||||
Some(guid) if !(me.is_some() && selection.target == me) => guid,
|
||||
/// The target guid a `CMSG_TEXT_EMOTE` carries (`DoEmote`'s caller, `0x49fdb1`): the selection,
|
||||
/// unless the argument is a non-empty word not starting with `%`, which names a player through
|
||||
/// `named` (the `0x493aa0` player search) and sends guid 0 on a miss. Either way an emote at
|
||||
/// yourself goes out untargeted (`0x5ef611`), so 1.12 has no self-emote sentence. Compared by
|
||||
/// entity, which agrees with the guid: [`Selection`]'s one writer sets both from one streamed
|
||||
/// entity.
|
||||
pub(super) fn emote_target(
|
||||
arg: Option<&str>,
|
||||
selection: &Selection,
|
||||
named: impl FnOnce(&str) -> Option<(Entity, u64)>,
|
||||
me: Option<Entity>,
|
||||
) -> u64 {
|
||||
let (entity, guid) = match arg.filter(|a| !a.is_empty() && !a.starts_with('%')) {
|
||||
Some(name) => match named(name) {
|
||||
Some((entity, guid)) => (Some(entity), Some(guid)),
|
||||
None => (None, None),
|
||||
},
|
||||
None => (selection.target, selection.guid),
|
||||
};
|
||||
match guid {
|
||||
Some(guid) if !(me.is_some() && entity == me) => guid,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -73,6 +88,8 @@ pub(super) struct ChatOut<'w, 's> {
|
||||
target: MessageWriter<'w, crate::target::TargetByNameRequest>,
|
||||
assist: MessageWriter<'w, crate::target::AssistRequest>,
|
||||
follow: MessageWriter<'w, crate::player::FollowRequest>,
|
||||
/// `/wave <name>`'s player search, which selects nothing.
|
||||
players: crate::target::PlayerLookup<'w, 's>,
|
||||
/// `/partytest ping` seats a group member's ping through the wire arm's `seat`.
|
||||
ping: ResMut<'w, crate::minimap::MinimapPing>,
|
||||
/// The red error line by GlobalStrings key. The system's only `UiErrorKeys` access: a second
|
||||
@@ -91,7 +108,13 @@ fn engine_verbs(
|
||||
for e in script.take_emote_requests() {
|
||||
// The token is the `EmotesText.dbc` name (`EMOTE<i>_TOKEN`, "WAVE").
|
||||
match emotes.and_then(|c| c.text_id(&e.token)) {
|
||||
Some(id) => out.push((String::new(), ParsedChat::TextEmote(id))),
|
||||
Some(text_id) => out.push((
|
||||
String::new(),
|
||||
ParsedChat::TextEmote {
|
||||
text_id,
|
||||
arg: e.target,
|
||||
},
|
||||
)),
|
||||
None => warn!(
|
||||
"chat: DoEmote({:?}): no EmotesText row for that token",
|
||||
e.token
|
||||
@@ -650,22 +673,25 @@ pub(super) fn drain_chat_input(
|
||||
}
|
||||
}
|
||||
// `DoEmote` (`0x5ef560`): the gates in the client's order, then posture and packet.
|
||||
ParsedChat::TextEmote(text_id) => {
|
||||
ParsedChat::TextEmote { text_id, arg } => {
|
||||
let (stand_state, flags) = self_player
|
||||
.single()
|
||||
.map_or((0, 0), |(_, m, _)| (m.stand_state, m.flags));
|
||||
let swimming = flags & move_flags::SWIMMING != 0;
|
||||
let emote_id = emotes.as_deref().and_then(|e| e.text_emote(text_id));
|
||||
// A chat-only text emote (no Emotes.dbc row) has no flags or posture: it sends.
|
||||
let posture = emote_id.and_then(|id| emotes.as_deref()?.posture_state(id));
|
||||
// Gate A (`CheckEmoteEligible`, `0x47db40`) suppresses the anim and the packet,
|
||||
// except its `0x4000` arm, which refuses out loud.
|
||||
// `DoEmote`'s gates (`0x5ef57e`-`0x5ef5c3`): a possessed unit, then the `Emotes.dbc`
|
||||
// row, then `CheckEmoteEligible` (`0x47db40`); only its `0x4000` arm refuses out
|
||||
// loud. A chat-only text emote (`EmoteID` 0) reads row 0.
|
||||
let self_unit_flags = self_store.single().map_or(0, |s| s.0.unit_flags());
|
||||
let gate = match emotes.as_deref() {
|
||||
Some(e) => emote_id
|
||||
.and_then(|id| e.emote_flags(id))
|
||||
.map_or(EmoteGate::Send, |f| {
|
||||
emote_send_eligible(f, stand_state, swimming, flags)
|
||||
}),
|
||||
Some(e) => emote_gate(
|
||||
e.emote_flags(emote_id.unwrap_or(0)),
|
||||
self_unit_flags,
|
||||
stand_state,
|
||||
swimming,
|
||||
flags,
|
||||
),
|
||||
None => EmoteGate::Send,
|
||||
};
|
||||
// `DoEmote`'s half of the `0x4000` arm (`0x5ef5d0`): the red line only while
|
||||
@@ -713,7 +739,9 @@ pub(super) fn drain_chat_input(
|
||||
.write(crate::player::StandStateRequest { state: state as u8 });
|
||||
}
|
||||
let target = emote_target(
|
||||
arg.as_deref(),
|
||||
&selection,
|
||||
|name| chat_out.players.find(&names, name),
|
||||
self_player.single().ok().map(|(entity, _, _)| entity),
|
||||
);
|
||||
match commands
|
||||
@@ -1316,6 +1344,28 @@ pub(super) fn emote_send_eligible(
|
||||
EmoteGate::Send
|
||||
}
|
||||
|
||||
/// `UNIT_FLAG_POSSESSED`, `UNIT_FIELD_FLAGS` bit 24: `DoEmote` returns silently for a unit that
|
||||
/// has it (`0x5ef57e`, `descriptor+0xa3 & 1`).
|
||||
const UNIT_FLAG_POSSESSED: u32 = 0x0100_0000;
|
||||
|
||||
/// `DoEmote`'s entry gates (`0x5ef560`) in the client's order: the unit's own `UNIT_FIELD_FLAGS`
|
||||
/// (`0x5ef57e`), the `Emotes.dbc` row by `EmoteID` (`0x5ef59c`-`0x5ef5b3`; `None` is a missing
|
||||
/// row, which returns), then `CheckEmoteEligible` over the row's flags.
|
||||
pub(super) fn emote_gate(
|
||||
row_flags: Option<u32>,
|
||||
unit_flags: u32,
|
||||
stand_state: u8,
|
||||
swimming: bool,
|
||||
move_flags: u32,
|
||||
) -> EmoteGate {
|
||||
match row_flags {
|
||||
Some(flags) if unit_flags & UNIT_FLAG_POSSESSED == 0 => {
|
||||
emote_send_eligible(flags, stand_state, swimming, move_flags)
|
||||
}
|
||||
_ => EmoteGate::Suppressed,
|
||||
}
|
||||
}
|
||||
|
||||
/// `PLAYER_FLAGS_DND` (`0x4`) off our live descriptor. Live, not mirrored, as in the reference
|
||||
/// (`0x49f3f0`): AFK keeps an optimistic global and DND nothing, so a second `/dnd` before the
|
||||
/// server's update re-marks where a second `/afk` clears.
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(in crate::ui_chat) enum ParsedChat {
|
||||
/// `/help`.
|
||||
Help,
|
||||
/// An `EmotesText` command (`/wave` is 101), sent as `CMSG_TEXT_EMOTE` at the selection.
|
||||
TextEmote(u32),
|
||||
TextEmote { text_id: u32, arg: Option<String> },
|
||||
/// A channel verb the stock handler parsed and the VM queued; never from [`parse_line`].
|
||||
Channel(benilla_ui::script::ChannelCommand),
|
||||
/// `/castvis`: a locally synthesized [`crate::creature_anim::CastEvent`], at the selection
|
||||
@@ -128,7 +128,10 @@ pub(in crate::ui_chat) fn parse_line(table: &SlashCommands, line: &str) -> Parse
|
||||
match table.lookup(cmd) {
|
||||
Some(Command::Slash(index)) => slash_command(index, args),
|
||||
// The alias table did the `DoEmote(token)` resolve at boot: `/lol` arrives as LAUGH's id.
|
||||
Some(Command::Emote { text_id }) => ParsedChat::TextEmote(text_id),
|
||||
Some(Command::Emote { text_id }) => ParsedChat::TextEmote {
|
||||
text_id,
|
||||
arg: (!args.is_empty()).then(|| args.to_string()),
|
||||
},
|
||||
Some(Command::Dev(dev)) => dev_command(dev, args),
|
||||
// `HELP_TEXT_SIMPLE`'s case, after the drain offers the line to `SlashCmdList`.
|
||||
None => ParsedChat::Unknown,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::event::{default_color, ChatEvent, ChatEventKind as K};
|
||||
use super::input::{emote_send_eligible, emote_target, EmoteGate, ParsedChat};
|
||||
use super::input::{emote_gate, emote_send_eligible, emote_target, EmoteGate, ParsedChat};
|
||||
|
||||
thread_local! {
|
||||
/// The shipped `GlobalStrings.lua`, run in a VM once per test thread; built lazily, so a
|
||||
@@ -165,23 +165,60 @@ fn emoting_at_your_own_selection_sends_an_untargeted_emote() {
|
||||
guid: Some(0xdead_beef),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(emote_target(&sel, Some(me)), 0);
|
||||
assert_eq!(emote_target(None, &sel, no_name, Some(me)), 0);
|
||||
|
||||
let sel = Selection {
|
||||
target: Some(them),
|
||||
guid: Some(0xdead_beef),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(emote_target(&sel, Some(me)), 0xdead_beef);
|
||||
assert_eq!(emote_target(None, &sel, no_name, Some(me)), 0xdead_beef);
|
||||
|
||||
// No selection is untargeted, and with no self entity yet a selection goes out untouched.
|
||||
assert_eq!(emote_target(&Selection::default(), Some(me)), 0);
|
||||
assert_eq!(
|
||||
emote_target(None, &Selection::default(), no_name, Some(me)),
|
||||
0
|
||||
);
|
||||
let sel = Selection {
|
||||
target: Some(them),
|
||||
guid: Some(0xdead_beef),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(emote_target(&sel, None), 0xdead_beef);
|
||||
assert_eq!(emote_target(None, &sel, no_name, None), 0xdead_beef);
|
||||
}
|
||||
|
||||
fn no_name(_: &str) -> Option<(bevy::prelude::Entity, u64)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `0x49fdb1`: a word not starting with `%` names a player and replaces the selection; a miss is
|
||||
/// guid 0; no argument or a `%` word is the selection.
|
||||
#[test]
|
||||
fn an_emote_argument_targets_the_named_player_and_not_the_selection() {
|
||||
use crate::target::Selection;
|
||||
use bevy::prelude::Entity;
|
||||
|
||||
let me = Entity::from_raw_u32(7).unwrap();
|
||||
let bob = Entity::from_raw_u32(9).unwrap();
|
||||
let sel = Selection {
|
||||
target: Some(Entity::from_raw_u32(11).unwrap()),
|
||||
guid: Some(0x5e1),
|
||||
..Default::default()
|
||||
};
|
||||
let find = |n: &str| (n == "Bob").then_some((bob, 0xb0b));
|
||||
assert_eq!(emote_target(Some("Bob"), &sel, find, Some(me)), 0xb0b);
|
||||
assert_eq!(
|
||||
emote_target(Some("Nobody"), &sel, find, Some(me)),
|
||||
0,
|
||||
"a miss is untargeted"
|
||||
);
|
||||
for arg in [None, Some(""), Some("%t"), Some("%x")] {
|
||||
let panics = |_: &str| -> Option<(Entity, u64)> { panic!("no search for {arg:?}") };
|
||||
assert_eq!(emote_target(arg, &sel, panics, Some(me)), 0x5e1, "{arg:?}");
|
||||
}
|
||||
// Naming yourself is the self-target edge: untargeted.
|
||||
let own = |_: &str| Some((me, 0x111));
|
||||
assert_eq!(emote_target(Some("Me"), &sel, own, Some(me)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1188,16 +1225,24 @@ fn action_commands_parse() {
|
||||
);
|
||||
}
|
||||
|
||||
fn emote(text_id: u32, arg: Option<&str>) -> ParsedChat {
|
||||
ParsedChat::TextEmote {
|
||||
text_id,
|
||||
arg: arg.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emote_aliases_resolve_through_the_table() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let t = stub_table();
|
||||
let parse_line = |line: &str| super::input::parse_line(&t, line);
|
||||
assert_eq!(parse_line("/wave"), ParsedChat::TextEmote(101));
|
||||
assert_eq!(parse_line("/wave"), emote(101, None));
|
||||
// An alias that is not its token's `EmotesText` name resolves too, as `/lol` (LAUGH) does.
|
||||
assert_eq!(parse_line("/hello"), ParsedChat::TextEmote(101));
|
||||
assert_eq!(parse_line("/hello"), emote(101, None));
|
||||
// An emote takes an argument (`DoEmote(token, msg)`): the command is the first word only.
|
||||
assert_eq!(parse_line("/wave Bob"), ParsedChat::TextEmote(101));
|
||||
assert_eq!(parse_line("/wave Bob"), emote(101, Some("Bob")));
|
||||
assert_eq!(parse_line("/wave %t"), emote(101, Some("%t")));
|
||||
assert_eq!(parse_line("/nosuch"), ParsedChat::Unknown);
|
||||
}
|
||||
|
||||
@@ -1329,7 +1374,7 @@ fn real_alias_table_resolves_the_shipped_commands() {
|
||||
let parse_line = |line: &str| super::input::parse_line(&table, line);
|
||||
|
||||
// `/sit` is EmotesText 86, whose `Emotes.dbc` row 13 (STATE_SIT) sets stand state 1.
|
||||
assert_eq!(parse_line("/sit"), ParsedChat::TextEmote(86));
|
||||
assert_eq!(parse_line("/sit"), emote(86, None));
|
||||
assert_eq!(
|
||||
cat.text_emote(86).and_then(|e| cat.posture_state(e)),
|
||||
Some(1)
|
||||
@@ -1341,7 +1386,7 @@ fn real_alias_table_resolves_the_shipped_commands() {
|
||||
("/liedown", 3),
|
||||
("/kneel", 8),
|
||||
] {
|
||||
let ParsedChat::TextEmote(text_id) = parse_line(line) else {
|
||||
let ParsedChat::TextEmote { text_id, .. } = parse_line(line) else {
|
||||
panic!("{line} is an emote");
|
||||
};
|
||||
let posture = cat.text_emote(text_id).and_then(|e| cat.posture_state(e));
|
||||
@@ -1363,7 +1408,7 @@ fn real_alias_table_resolves_the_shipped_commands() {
|
||||
"/strong",
|
||||
] {
|
||||
assert!(
|
||||
matches!(parse_line(line), ParsedChat::TextEmote(_)),
|
||||
matches!(parse_line(line), ParsedChat::TextEmote { .. }),
|
||||
"{line} resolves to an emote"
|
||||
);
|
||||
}
|
||||
@@ -1566,6 +1611,52 @@ const CHEER: u32 = 0x0800;
|
||||
const SALUTE: u32 = 0x0800;
|
||||
const LAUGH: u32 = 0x0980;
|
||||
|
||||
/// `0x5ef57e`: a unit with `UNIT_FLAG_POSSESSED` (`UNIT_FIELD_FLAGS & 0x01000000`) refuses every
|
||||
/// emote, silently; a missing `Emotes.dbc` row returns too (`0x5ef5b1`).
|
||||
#[test]
|
||||
fn a_possessed_unit_or_a_missing_row_refuses_the_emote() {
|
||||
assert_eq!(emote_gate(Some(0), 0, 0, false, 0), EmoteGate::Send);
|
||||
assert_eq!(
|
||||
emote_gate(Some(0), 0x0100_0000, 0, false, 0),
|
||||
EmoteGate::Suppressed
|
||||
);
|
||||
assert_eq!(
|
||||
emote_gate(Some(0), 0x0100_0008, 0, false, 0),
|
||||
EmoteGate::Suppressed
|
||||
);
|
||||
assert_eq!(
|
||||
emote_gate(Some(0), 0x0200_0000, 0, false, 0),
|
||||
EmoteGate::Send,
|
||||
"a neighbour bit"
|
||||
);
|
||||
assert_eq!(emote_gate(None, 0, 0, false, 0), EmoteGate::Suppressed);
|
||||
}
|
||||
|
||||
/// A chat-only text emote has `EmoteID` 0, so `DoEmote` reads `Emotes.dbc` row 0 (flags 0), which
|
||||
/// lacks `0x200`: dead (7) and asleep (3) refuse it, as they refuse a `/wave` (`0x47db8e`).
|
||||
#[test]
|
||||
fn a_chat_only_emote_reads_row_zero_and_is_refused_dead_or_asleep() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let cat = benilla_formats::load_emote_sound_catalog(&mut chain).expect("emote catalog");
|
||||
let smile = cat.text_id("smile").expect("SMILE");
|
||||
assert_eq!(cat.text_emote(smile), None, "SMILE is chat-only");
|
||||
let row = cat.emote_flags(cat.text_emote(smile).unwrap_or(0));
|
||||
assert_eq!(row, Some(0), "row 0, no flags");
|
||||
assert_eq!(
|
||||
emote_gate(row, 0, 7, false, 0),
|
||||
EmoteGate::Suppressed,
|
||||
"dead"
|
||||
);
|
||||
assert_eq!(
|
||||
emote_gate(row, 0, 3, false, 0),
|
||||
EmoteGate::Suppressed,
|
||||
"asleep"
|
||||
);
|
||||
assert_eq!(emote_gate(row, 0, 0, false, 0), EmoteGate::Send, "standing");
|
||||
assert_eq!(emote_gate(row, 0, 1, false, 0), EmoteGate::Send, "sitting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seated_stand_required_emotes_are_suppressed() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -1824,6 +1915,42 @@ fn the_talk_gesture_reads_the_plaintext_not_the_garbled_line() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `0x49d7f7`: a speaker whose `UNIT_FIELD_FLAGS` hold `0x20000000` (Polymorph, Kidney Shot, ...)
|
||||
/// plays no talk gesture on the immediate path; the copies behind a name query (`0x49ccc0`,
|
||||
/// `0x49d230`) have no such test.
|
||||
#[test]
|
||||
fn a_speaker_that_cannot_animate_queues_no_talk_gesture() {
|
||||
use super::feed::gesture_prevented;
|
||||
use crate::creature_anim::select_gesture;
|
||||
use benilla_protocol::messages::CHAT_MSG_SAY;
|
||||
|
||||
let laugh_words = |n: u32| (n == 1).then(|| "lol".to_string());
|
||||
let speak = |tries: u16, flags: Option<u32>| {
|
||||
select_gesture(CHAT_MSG_SAY, "lol", laugh_words)
|
||||
.filter(|_| !gesture_prevented(tries, flags))
|
||||
.into_iter()
|
||||
.count()
|
||||
};
|
||||
assert_eq!(speak(0, Some(0x2000_0000)), 0, "a polymorphed speaker");
|
||||
assert_eq!(speak(0, Some(0x2000_0000 | 0x8)), 0, "among other bits");
|
||||
assert_eq!(speak(0, Some(0)), 1, "an unflagged speaker laughs");
|
||||
assert_eq!(
|
||||
speak(0, Some(0x1000_0000 | 0x0100_0000)),
|
||||
1,
|
||||
"neighbouring bits do not gate"
|
||||
);
|
||||
assert_eq!(
|
||||
speak(0, None),
|
||||
1,
|
||||
"an unstreamed speaker is the queue's call, not this gate"
|
||||
);
|
||||
assert_eq!(
|
||||
speak(3, Some(0x2000_0000)),
|
||||
1,
|
||||
"a line held for the name has no such test"
|
||||
);
|
||||
}
|
||||
|
||||
// ── the combat log reaches addons ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Combat-log addons parse arg1 with patterns built from the GlobalStrings, so arg1 is the whole
|
||||
|
||||
@@ -230,6 +230,14 @@ mod tests {
|
||||
let cat = load_emote_sound_catalog(&mut chain).expect("load emote catalog");
|
||||
assert_eq!(cat.text_id("wave"), Some(101), "case-insensitive by name");
|
||||
assert_eq!(cat.text_emote(101), Some(3), "WAVE plays anim emote 3");
|
||||
// A chat-only text emote has `EmoteID` 0, which `DoEmote` (`0x5ef591`) reads as row 0.
|
||||
assert_eq!(
|
||||
cat.text_emote(cat.text_id("smile").unwrap()),
|
||||
None,
|
||||
"SMILE is chat-only"
|
||||
);
|
||||
assert_eq!(cat.emote_flags(0), Some(0), "row 0 exists with no flags");
|
||||
assert_eq!(cat.spec_proc(0), Some(0), "row 0 is no posture emote");
|
||||
assert_eq!(cat.anim(2), Some(66), "ONESHOT_BOW (id 2) plays AnimID 66");
|
||||
// The posture-gate rows (`0x47db40`), ids from vmangos `SharedDefines.h`'s `Emote` enum.
|
||||
assert_eq!(cat.emote_flags(2), Some(0x4801), "ONESHOT_BOW EmoteFlags");
|
||||
|
||||
@@ -8,7 +8,8 @@ use mlua::{Lua, MultiValue, Value};
|
||||
use super::Model;
|
||||
|
||||
/// `DoEmote(token [, target])`: the `EmotesText.dbc` name token (`"WAVE"`, not `/wave`) and an
|
||||
/// optional target name.
|
||||
/// optional target string (`0x49fd30` reads arg 2 with `lua_tostring`): a player name, or a `%`
|
||||
/// word for the selection. A non-string, non-number second argument reads as none.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EmoteRequest {
|
||||
pub token: String,
|
||||
@@ -79,14 +80,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
g.set(
|
||||
"DoEmote",
|
||||
lua.create_function(|lua, (token, target): (Option<String>, Option<String>)| {
|
||||
lua.create_function(|lua, (token, target): (Option<String>, Value)| {
|
||||
let Some(token) = token.filter(|t| !t.is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.emote_requests.push(EmoteRequest {
|
||||
token: token.to_ascii_uppercase(),
|
||||
target: target.filter(|t| !t.trim().is_empty()),
|
||||
target: match target {
|
||||
Value::String(s) => Some(s.to_string_lossy()),
|
||||
Value::Integer(i) => Some(i.to_string()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|t| !t.is_empty()),
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -208,6 +215,24 @@ mod tests {
|
||||
assert!(s.take_emote_requests().is_empty(), "drained");
|
||||
}
|
||||
|
||||
/// The second argument is a string, never a unit token: `"target"` is a name like any other,
|
||||
/// and `%t` reaches the app's selection arm untouched (`0x49fdb1`).
|
||||
#[test]
|
||||
fn do_emote_second_argument_is_passed_as_a_string() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run("DoEmote('WAVE', 'target') DoEmote('WAVE', '%t') DoEmote('WAVE', {})")
|
||||
.unwrap();
|
||||
let targets: Vec<_> = s
|
||||
.take_emote_requests()
|
||||
.into_iter()
|
||||
.map(|e| e.target)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
targets,
|
||||
vec![Some("target".into()), Some("%t".into()), None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_roll_coerces_the_strings_the_stock_handler_passes() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user