/target, /assist and /follow, and right click opens a clam

- /target and /assist find units by name like the original, and /follow walks you behind your target
- right click opens clams and lockboxes, and the tooltip promises it
- the freeze thaws clean, poses release instead of sticking, and you drop to neutral standing before it grips
- unit frame art layers over the bars like the original

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-02 18:02:44 +07:00
co-authored by Claude Fable 5
parent 2f40b6a9a0
commit 94de741cb1
36 changed files with 2086 additions and 204 deletions
+10 -1
View File
@@ -71,6 +71,9 @@ pub(super) enum SlashIndex {
LootFfa,
LootRoundRobin,
LootMaster,
Target,
Assist,
Follow,
}
impl SlashIndex {
@@ -105,13 +108,16 @@ impl SlashIndex {
Self::LootFfa => "LOOT_FFA",
Self::LootRoundRobin => "LOOT_ROUNDROBIN",
Self::LootMaster => "LOOT_MASTER",
Self::Target => "TARGET",
Self::Assist => "ASSIST",
Self::Follow => "FOLLOW",
}
}
/// Every registered index — the registry proper. A reference command NOT in this list resolves
/// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference
/// (better than a registered handler that silently does nothing).
const ALL: [Self; 28] = [
const ALL: [Self; 31] = [
Self::Reply,
Self::Join,
Self::Leave,
@@ -140,6 +146,9 @@ impl SlashIndex {
Self::LootFfa,
Self::LootRoundRobin,
Self::LootMaster,
Self::Target,
Self::Assist,
Self::Follow,
];
}
+47 -9
View File
@@ -122,15 +122,23 @@ pub(super) struct ChatProbes<'w, 's> {
guids: Res<'w, crate::net::GuidIndex>,
}
/// The two setters `DoEmote` drives besides the packet, bundled (the drain is at Bevy's 16-param
/// ceiling): the **posture** (`EmoteSpecProc == 1` → `SetStandState`) and the **stow**
/// (`SetSheatheState(0, SNAP)`, unconditional on every emote that passes the gates — wow-re
/// `sheath-policy.md` §1, site `0x5ef630`). Both are queues into their subsystem's one setter,
/// never applied here.
/// Everything the drain **queues into another subsystem's one setter** rather than applying itself,
/// bundled (the drain is at Bevy's 16-param ceiling).
///
/// - `stand`/`sheath` — the two setters `DoEmote` drives besides the packet: the **posture**
/// (`EmoteSpecProc == 1` → `SetStandState`) and the **stow** (`SetSheatheState(0, SNAP)`,
/// unconditional on every emote that passes the gates — wow-re `sheath-policy.md` §1, site
/// `0x5ef630`).
/// - `target`/`assist` — the by-name selection asks (decision 0886), answered by
/// [`crate::target`]'s shared resolver so they commit through the same SetSelection path a click
/// does. Chat never writes [`Selection`] itself.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct EmoteOut<'w> {
pub(super) struct ChatOut<'w> {
stand: MessageWriter<'w, crate::player::StandStateRequest>,
sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>,
target: MessageWriter<'w, crate::target::TargetByNameRequest>,
assist: MessageWriter<'w, crate::target::AssistRequest>,
follow: MessageWriter<'w, crate::player::FollowRequest>,
}
// One parameter per concern — the chat drain fans out to every command's consumer.
@@ -156,7 +164,7 @@ pub(super) fn drain_chat_input(
mut go_targets: MessageWriter<crate::creature_anim::SpellGoTargets>,
// The command table (decision 0881) — the reference's own aliases, resolved at boot.
table: Res<super::commands::SlashCommands>,
mut emote_out: EmoteOut,
mut chat_out: ChatOut,
probes: ChatProbes,
) {
let ChatProbes {
@@ -495,6 +503,36 @@ pub(super) fn drain_chat_input(
ParsedChat::Forfeit => {
script.queue_duel_request(benilla_ui::script::DuelRequest::Cancel);
}
// The by-name selection pair (decision 0886). Both hand the name to `crate::target`'s
// shared resolver — the reference's own `0x493aa0`, parameterised per caller — so the
// commit goes through the one SetSelection path a click, TAB and `TargetUnit` share.
//
// A bare `/target` reproduces `GetSlashCmdTarget`'s fallback (your current target's
// name, iff it is a player) and is therefore a no-op re-select; a bare `/target` with a
// creature selected resolves to nothing, exactly as the reference's `if
// GetSlashCmdTarget(msg)` guard does.
ParsedChat::Target { name } => {
if let Some(name) =
name.or_else(|| target_player_name(&selection, &mut names, &commands))
{
chat_out
.target
.write(crate::target::TargetByNameRequest { name });
}
}
// A bare `/assist` is the ref's `AssistUnit("target")` — assist whoever is selected,
// creature or player — so it passes `None` straight through rather than taking the
// player-name fallback. The ref reaches the same unit by resolving its NAME first
// (`AssistByName`), which can pick a different same-named player; going by the live
// selection is the same intent without that ambiguity (recorded in 0886).
ParsedChat::Assist { name } => {
chat_out.assist.write(crate::target::AssistRequest { name });
}
// `/follow` (decision 0890) — the subject is resolved by `crate::target` and the motion
// is `crate::player`'s; chat only carries the ask. Bare follows the current selection.
ParsedChat::Follow { name } => {
chat_out.follow.write(crate::player::FollowRequest { name });
}
// The ref's handler is `SlashCmdList["PVP"] = function() TogglePVP() end` — one line
// over the same binding the popup row calls, so it enters the same intent queue
// (decision 0646 §3; the `/duel` reasoning above, verbatim).
@@ -565,7 +603,7 @@ pub(super) fn drain_chat_input(
// sheathes in the reference too. The anim layer's one setter owns the idempotency
// refusal, so an already-stowed body costs nothing.
if let Ok((entity, _, _)) = self_player.single() {
emote_out.sheath.write(crate::creature_anim::SheathRequest {
chat_out.sheath.write(crate::creature_anim::SheathRequest {
entity,
state: 0,
ceremony: false,
@@ -577,7 +615,7 @@ pub(super) fn drain_chat_input(
// the one setter in `crate::player`, which sends `CMSG_STANDSTATECHANGE`, holds the
// local commit until the echo lands, and runs the sit-stow rider.
if let Some(state) = posture {
emote_out
chat_out
.stand
.write(crate::player::StandStateRequest { state: state as u8 });
}
+42 -5
View File
@@ -159,16 +159,42 @@ pub(in crate::ui_chat) enum ParsedChat {
/// kick, leave, loot settings, raid marks — apply to the local mirror, so the whole menu
/// surface is exercisable serverless.
PartyTest { arg: String },
/// `/target [name]` — select by name (`TargetByName`, decision 0886). Resolves creatures AND
/// players, case-insensitively, by whole name or longest common prefix, with no range, cone,
/// liveness or hostility gate at all. `None` = bare, which the ref's `GetSlashCmdTarget` turns
/// into your current target's name when that target is a player (a no-op re-select), and into
/// nothing otherwise.
Target { name: Option<String> },
/// `/assist [name]` — select whatever the basis unit is targeting. A named basis resolves
/// **players only** (typemask 0x10); bare assists your current target, creature or player.
Assist { name: Option<String> },
/// `/follow [name]` — auto-follow (decision 0890). A named subject resolves players only, and
/// only living assistable ones (the ref's filter mode 2); bare follows the current selection,
/// creature or player. Client-side movement — nothing goes on the wire.
Follow { name: Option<String> },
/// A slash line matching neither a chat command nor an `EmotesText` name — dropped.
Unknown,
}
/// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658): a non-empty argument
/// is the name (first word — 1.12 names carry no spaces); empty defers to the dispatch's
/// player-target fallback.
/// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658) — the argument grammar
/// **thirteen** shipped commands share, `/target` and `/assist` among them.
///
/// Its first line is a trim, not a tokenizer: `gsub(msg, "(%s*)(.*[^%s]+)(%s*)", "%2", 1)` keeps
/// **everything between the outer whitespace**, internal spaces included. This used to take the
/// first word instead, on the reasoning that 1.12 player names carry no spaces — true, but
/// `/target` resolves *creature* names too, and "Kobold Vermin" is the common case. `args` reaches
/// us already trimmed ([`parse_line`]), which is exactly what that gsub computes.
///
/// Empty defers to the caller's fallback (a bare party command acts on the selected PLAYER —
/// `target_player_name` in [`super`], the ref's `UnitIsPlayer("target")` branch).
///
/// **Not carried: the unit-token expansion.** The ref's tail expands `player`, `target`,
/// `^party[1-4]` and `^raid[0-9]` through `UnitName` before returning, so `/assist party1` means
/// "that member's name". benilla feeds only the `player` and `target` tokens to the VM's unit
/// store, so the roster half has nothing to resolve against yet; the tokens would pass through as
/// literal names. Named in decision 0886 as a gap rather than half-built here.
fn slash_target_name(args: &str) -> Option<String> {
let name = args.split_whitespace().next().unwrap_or("");
(!name.is_empty()).then(|| name.to_string())
(!args.is_empty()).then(|| args.to_string())
}
/// Parse a trimmed slash line into an ACTION (`/join`, `/afk`, `/random`, `/wave`, the dev
@@ -271,6 +297,17 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
S::Duel => ParsedChat::Duel {
name: slash_target_name(args),
},
// The by-name selection pair (decision 0886). Both take the whole trimmed argument —
// `/target Kobold Vermin` is one name, not a name plus junk.
S::Target => ParsedChat::Target {
name: slash_target_name(args),
},
S::Assist => ParsedChat::Assist {
name: slash_target_name(args),
},
S::Follow => ParsedChat::Follow {
name: slash_target_name(args),
},
S::DuelCancel => ParsedChat::Forfeit,
S::Pvp => ParsedChat::Pvp,
// The social verbs (decision 0668). The argument is passed WHOLE: `/who`'s is a filter
+48 -8
View File
@@ -602,20 +602,60 @@ fn real_alias_table_resolves_the_shipped_commands() {
"{line} resolves to an emote"
);
}
// …and the four names the reference has NO command for, which the DBC-name match used to
// accept. `/follow` is the sharp one: it fired a text emote where the real client's
// `SlashCmdList["FOLLOW"]` follows your target (benilla has no follow yet, so it is honestly
// unknown rather than wrongly an emote).
for line in ["/follow", "/joke", "/puzzle", "/attackmytarget"] {
// …and the three names the reference has NO command for, which the DBC-name match used to
// accept as emotes.
for line in ["/joke", "/puzzle", "/attackmytarget"] {
assert_eq!(parse_line(line), ParsedChat::Unknown, "{line}");
}
// `/follow` was the sharp one in that class: the DBC-name match fired a text emote where the
// real client's `SlashCmdList["FOLLOW"]` follows your target. 0881 made it honestly unknown;
// 0890 makes it the real command, over all three shipped aliases (SLASH_FOLLOW1-6 → `/f`,
// `/follow`, `/fol`).
for line in ["/follow", "/f", "/fol"] {
assert_eq!(
parse_line(line),
ParsedChat::Follow { name: None },
"{line}"
);
}
assert_eq!(
parse_line("/follow Probeone"),
ParsedChat::Follow {
name: Some("Probeone".into())
}
);
// A command whose handler benilla does not register answers like any unknown command.
assert_eq!(parse_line("/ginvite"), ParsedChat::Unknown);
// The by-name selection pair (decision 0886) — every shipped alias, and the whole-argument
// grammar that makes a multi-word creature name ONE name. `/tar` and `/a` are the short forms
// the shipped strings carry (SLASH_TARGET2/4, SLASH_ASSIST1/3).
assert_eq!(
parse_line("/target Kobold Vermin"),
ParsedChat::Target {
name: Some("Kobold Vermin".into())
},
"the argument is trimmed WHOLE — `GetSlashCmdTarget`'s gsub, not a first-word split"
);
assert_eq!(
parse_line("/tar Hogger "),
ParsedChat::Target {
name: Some("Hogger".into())
}
);
assert_eq!(parse_line("/target"), ParsedChat::Target { name: None });
assert_eq!(
parse_line("/a Bob"),
ParsedChat::Assist {
name: Some("Bob".into())
}
);
assert_eq!(parse_line("/assist"), ParsedChat::Assist { name: None });
// The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote
// commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2`
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **55 distinct
// aliases** across the 28 registered `SlashCmdList` indices.
assert_eq!(table.counts(), (55, 225), "(slash, emote) aliases");
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **62 distinct
// aliases** across the 31 registered `SlashCmdList` indices (0886 added TARGET's `/target`
// `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`).
assert_eq!(table.counts(), (62, 225), "(slash, emote) aliases");
}
// ── The send-side posture-eligibility gate (`emote_send_eligible`) — the director-verified rows