diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index 5bf8df12..418eca08 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -1343,7 +1343,7 @@ fn auto_clear_afk(cvars: &crate::cvars::Cvars) -> bool { /// Turn `SendChatMessage` calls into sends. No slash grammar runs here: an addon sending /// `"/dance"` says six characters. An unknown chat-type token prints a system line and sends -/// nothing. +/// nothing; an empty line of any other type but AFK and DND sends nothing and prints nothing. pub(super) fn drain_addon_chat_sends( script: Option>, commands: Res, @@ -1372,6 +1372,11 @@ pub(super) fn drain_addon_chat_sends( )); continue; }; + // An empty line of any type but AFK and DND ends the call here, after the type check and + // ahead of the target checks, the AFK clear and the tutorial acknowledge (`0x49f2a1`). + if send.ends_at_empty_line() { + continue; + } // A channel target is a slot number, sent as that slot's name; with no such slot the call // ends here (`0x49f4ea`), ahead of the AFK clear and the tutorial acknowledge. if kind == super::edit::SendType::Channel { diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 95aea3f5..e4014cb3 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -2581,3 +2581,81 @@ fn a_channel_send_carries_the_numbered_slots_name() { ] ); } + +/// `SendChatMessage`'s empty-line gate through the real drain (`0x49f28d`-`0x49f2a1`): an empty +/// line, or one whose first byte is NUL, of any type but AFK and DND sends nothing and leaves a +/// standing AFK set, since the gate is ahead of the AFK clear. The type check comes first, so an +/// unknown type still reports. +#[test] +fn an_empty_line_sends_nothing_but_afk_and_dnd() { + use super::edit::{ChannelSlot, ChannelState}; + use crate::net::{ChatKind, ClientCommand, NetCommands}; + use bevy::ecs::system::RunSystemOnce; + + let mut world = bevy::prelude::World::new(); + world.insert_non_send_resource(benilla_ui::script::UiScript::new().expect("VM")); + let (tx, rx) = crossbeam_channel::unbounded(); + world.insert_resource(NetCommands(tx)); + world.init_resource::(); + world.insert_resource(super::away::AfkMirror(2)); + world.init_resource::(); + world.insert_resource(ChannelState { + joined: vec![Some(ChannelSlot::joined("General - Elwynn Forest"))], + ..Default::default() + }); + let script = |world: &bevy::prelude::World, lua: &str| { + world + .non_send_resource::() + .run(lua) + .expect("lua"); + }; + let sent = |rx: &crossbeam_channel::Receiver| -> Vec<(ChatKind, String)> { + rx.try_iter() + .map(|c| match c { + ClientCommand::Chat { kind, text, .. } => (kind, text), + other => panic!("unexpected command {other:?}"), + }) + .collect() + }; + script( + &world, + r#" + MARKED_AFK_MESSAGE = "You are now AFK: %s" + CLEARED_AFK = "You are no longer AFK." + DEFAULT_AFK_MESSAGE = "Away from Keyboard" + SendChatMessage("") + SendChatMessage("", "PARTY") + SendChatMessage("", "WHISPER", nil, "Bob") + SendChatMessage("", "CHANNEL", nil, 1) + SendChatMessage("\0hidden", "YELL") + SendChatMessage("", "BOGUS") + "#, + ); + world + .run_system_once(super::input::drain_addon_chat_sends) + .expect("drain"); + assert_eq!(sent(&rx), vec![], "no empty line reaches the wire"); + assert_eq!( + world.resource::().pending_lines(), + vec!["Unknown chat type \"BOGUS\"."], + "no AFK clear; the type check precedes the gate" + ); + assert!(world.resource::().is_afk()); + + // A line with text clears the standing AFK and says itself; an empty AFK marks it again. + script( + &world, + r#"SendChatMessage("hi") SendChatMessage("", "AFK")"#, + ); + world + .run_system_once(super::input::drain_addon_chat_sends) + .expect("drain"); + assert_eq!( + sent(&rx), + vec![ + (ChatKind::Afk, String::new()), + (ChatKind::Say, "hi".into()), + (ChatKind::Afk, "Away from Keyboard".into()), + ] + ); +} diff --git a/crates/benilla-ui/src/script/chat_send.rs b/crates/benilla-ui/src/script/chat_send.rs index d2a3c08e..e004c413 100644 --- a/crates/benilla-ui/src/script/chat_send.rs +++ b/crates/benilla-ui/src/script/chat_send.rs @@ -44,6 +44,20 @@ pub struct ChatSend { pub language: Option, } +impl ChatSend { + /// The empty-line gate (`0x49f28d`-`0x49f2a1`): a line whose first byte is NUL ends the call, + /// before the language is read or anything is sent, unless its type is AFK (`0x14`) or DND + /// (`0x15`). The chat-type lookup and its "Unknown chat type" (`0x49f27f`) come first. + pub fn ends_at_empty_line(&self) -> bool { + ends_at_empty_line(&self.text, &self.chat_type) + } +} + +/// [`ChatSend::ends_at_empty_line`] on the binding's arguments, before the line is built. +fn ends_at_empty_line(text: &str, chat_type: &str) -> bool { + text.bytes().next().unwrap_or(0) == 0 && !matches!(chat_type, "AFK" | "DND") +} + impl super::UiScript { /// Push `Languages.dbc` as `(ID, Name_lang)` rows in file order, the table `SendChatMessage` /// matches its language name against (`[0xc0db40]`, count `[0xc0db44]`). @@ -131,10 +145,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let chat_type = chat_type .unwrap_or_else(|| "SAY".into()) .to_ascii_uppercase(); - // The language is read only past the empty-line gate (`0x49f28d`-`0x49f2a1`), - // which lets an empty line through for AFK and DND alone. - let reads_language = - !text.is_empty() || matches!(chat_type.as_str(), "AFK" | "DND"); + // The language is read only past the empty-line gate. The line is queued + // anyway: the type check that precedes the gate is the app's, and the drain + // applies the gate after it. + let reads_language = !ends_at_empty_line(&text, &chat_type); let language = match super::binding_abi::optional_string(lua, &language) { Some(name) if reads_language => { let model = lua.app_data_ref::().expect("model app_data");