SendChatMessage resolves its language name against Languages.dbc, raising "Unknown language" on a miss, and the chat send carries that id, so a racial language picked in the chat menu is spoken (#164) (#498)
`SendChatMessage`'s language argument is spoken, as in 1.12.1: a racial language picked from the chat menu (`ChatFrame.lua:2404`, passed on by `ChatEdit_SendText` at `:1950-1954`) or named by an addon goes out in `CMSG_MESSAGECHAT`, where before every line went in the faction tongue. - `0x49f1e0` starts from the speaker's default language (`0x5ec890`); when argument 3 passes `lua_isstring` (a string or a number) it is matched by name against every `Languages.dbc` row, case-insensitively (`0x49f8a0`, `SStrCmpI`), and a miss raises "Unknown language" (`0x49f2e1`). nil, an absent argument, a table or a boolean keep the default. A language the character has not learned is sent and left to the server, which drops it (vmangos `ChatHandler.cpp:175`). - The packet builder writes the type, the language and a target for a whisper or a channel only, then the text (`0x49f6c3`-`0x49f72a`); fourteen per-type writers become one, with byte-exact goldens. - Each new UI state is seeded with the language table. Checked live against the local vmangos: a say, a yell, a whisper to self and a GM command round-trip through the new builder; a channel line reaches the server's channel lookup; an unknown language raises in Lua. Fixes #164 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
65a5e44d48
commit
c6767d6a79
@@ -431,6 +431,7 @@ fn auction_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} 0"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Settling { sent_at: now };
|
||||
}
|
||||
@@ -781,6 +782,7 @@ fn auction_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".additem {ITEM_ENTRY} {ITEM_COUNT}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::EnsureItem {
|
||||
since: now,
|
||||
|
||||
@@ -202,6 +202,7 @@ fn bank_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} 0"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Settling { sent_at: now };
|
||||
}
|
||||
@@ -276,6 +277,7 @@ fn bank_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".additem {ITEM_ENTRY} 1"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::EnsureItem {
|
||||
since: now,
|
||||
@@ -395,6 +397,7 @@ fn bank_probe(
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
kind: ChatKind::Say,
|
||||
text: format!(".modify money {grant}"),
|
||||
language: None,
|
||||
target: None,
|
||||
});
|
||||
probe.phase = Phase::BuySlot {
|
||||
@@ -482,6 +485,7 @@ fn bank_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {by} {bz} 0"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Refusal {
|
||||
since: now,
|
||||
|
||||
@@ -291,6 +291,7 @@ fn gm(net: &NetCommands, text: impl Into<String>) {
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: text.into(),
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ fn bg_queue_probe(
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text: ".revive".to_string(),
|
||||
language: None,
|
||||
});
|
||||
// A body under the bracket floor is refused at the hello, with no list to join from.
|
||||
let level = store.0.unit_level().unwrap_or(0);
|
||||
@@ -96,6 +97,7 @@ fn bg_queue_probe(
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".levelup {}", QUEUE_LEVEL - level),
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
let [x, y, z] = BATTLEMASTER_AT;
|
||||
@@ -104,6 +106,7 @@ fn bg_queue_probe(
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} 0"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Hopped { sent_at: now };
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ fn binder_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} {INNKEEPER_MAP}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Settling { sent_at: now };
|
||||
}
|
||||
|
||||
@@ -239,6 +239,7 @@ fn book_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} 0"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Settling { sent_at: now };
|
||||
}
|
||||
|
||||
@@ -371,6 +371,7 @@ fn charter_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".modify money {FUND_COPPER}"),
|
||||
language: None,
|
||||
});
|
||||
info!(
|
||||
"PROBE_CHARTER: 0 (precheck) — funding the body with {FUND_COPPER} copper so the \
|
||||
@@ -1046,6 +1047,7 @@ fn hop(probe: &mut CharterProbe, net: &NetCommands, now: f64) {
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} {REGISTRAR_MAP}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Settling { sent_at: now };
|
||||
}
|
||||
|
||||
@@ -200,6 +200,7 @@ fn chest_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} {map}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Settling { sent_at: now };
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ fn finish(net: &NetCommands, entry: u32, now: f64) -> Phase {
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".additem {entry} -1"),
|
||||
language: None,
|
||||
});
|
||||
Phase::Cleanup { since: now }
|
||||
}
|
||||
@@ -197,6 +198,7 @@ fn clam_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".additem {entry}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Stocking { sent_at: now };
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ fn crossing_probe(
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} {}", m as i32),
|
||||
language: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -167,6 +168,7 @@ fn crossing_probe(
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {} ", z + DROP_HEIGHT),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Boarding {
|
||||
boat: guid.0,
|
||||
|
||||
@@ -147,6 +147,7 @@ fn level_to(net: &NetCommands, from: u32, to: u32) {
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".levelup {delta}"),
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,6 +210,7 @@ fn goquest_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} {map}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Settling { sent_at: now };
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ fn guard_poi_probe(
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} 0"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Hopped { sent_at: now };
|
||||
}
|
||||
|
||||
@@ -232,6 +232,7 @@ fn mail_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text,
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
probe.phase = Phase::Settling { sent_at: now };
|
||||
|
||||
@@ -641,6 +641,7 @@ fn service_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".additem {SELL_ITEM_ENTRY} 1"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::VendorArm { since: now, guid };
|
||||
} else if now - since > WINDOW_TIMEOUT_SECS {
|
||||
@@ -813,6 +814,7 @@ fn hop(probe: &mut ServiceProbe, net: &NetCommands, i: usize, h: usize, now: f64
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} {}", leg.map),
|
||||
language: None,
|
||||
});
|
||||
probe.polls = 0;
|
||||
probe.phase = Phase::Hop {
|
||||
@@ -837,6 +839,7 @@ fn next(probe: &mut ServiceProbe, net: &NetCommands, i: usize, now: f64) {
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} {VENDOR_MAP}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::VendorHop { sent_at: now };
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ fn stone_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} {map}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Settling { sent_at: now };
|
||||
}
|
||||
@@ -251,6 +252,7 @@ fn stone_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".character level {LEVEL_BELOW}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::WaitLevel {
|
||||
want: LEVEL_BELOW,
|
||||
@@ -302,6 +304,7 @@ fn stone_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".character level {want}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::WaitLevel {
|
||||
want,
|
||||
|
||||
@@ -170,6 +170,7 @@ fn taxi_probe(
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text,
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
probe.phase = Phase::Hopped { sent_at: now };
|
||||
|
||||
@@ -173,6 +173,7 @@ fn vendor_swap_probe(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text: format!(".go xyz {x} {y} {z} {MAP}"),
|
||||
language: None,
|
||||
});
|
||||
probe.phase = Phase::Settle { sent_at: now };
|
||||
}
|
||||
|
||||
@@ -678,23 +678,62 @@ pub(crate) fn addon_wire_chat_type(distribution: benilla_ui::script::AddonDistri
|
||||
/// `HandleChatMessageOpcode`). `Whisper` and `Channel` name their target in `target`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum ChatKind {
|
||||
/// `/say`; GM dot-commands go out this way, parsed after the language gate.
|
||||
Say,
|
||||
/// `/yell` (`CHAT_MSG_YELL`, `SharedDefines.h:1199`).
|
||||
Yell,
|
||||
/// A custom `/emote`, shown verbatim as `"PlayerName <text>"`.
|
||||
Emote,
|
||||
/// `/whisper`: the target's name precedes the text (`Chat.cpp:3-12`).
|
||||
Whisper,
|
||||
/// `/p`, dropped silently when ungrouped (`ChatHandler.cpp:472-493`).
|
||||
Party,
|
||||
/// `/ra`; needs a raid (`ChatHandler.cpp:514-536`).
|
||||
Raid,
|
||||
/// `/rl`, leader only (`ChatHandler.cpp:538-559`).
|
||||
RaidLeader,
|
||||
/// `/rw`, leader or assistant only (`ChatHandler.cpp:561-576`).
|
||||
RaidWarning,
|
||||
/// `/g`; needs a guild (`ChatHandler.cpp:494-503`).
|
||||
Guild,
|
||||
/// `/o`; needs a guild (`ChatHandler.cpp:504-513`).
|
||||
Officer,
|
||||
/// Needs a battleground group (`ChatHandler.cpp:579-593`).
|
||||
Battleground,
|
||||
/// Battleground leader only (`ChatHandler.cpp:595-609`).
|
||||
BattlegroundLeader,
|
||||
/// Toggles AFK, the text the auto-reply if any; it clears DND (`ChatHandler.cpp:611-630`).
|
||||
Afk,
|
||||
/// Toggles DND, which clears AFK (`ChatHandler.cpp:632-648`).
|
||||
Dnd,
|
||||
/// A channel line by name, dropped unless we are on it (`ChatHandler.cpp:255-327`).
|
||||
Channel,
|
||||
}
|
||||
|
||||
impl ChatKind {
|
||||
/// The `CMSG_MESSAGECHAT` `type` field.
|
||||
pub(crate) fn chat_type(self) -> u32 {
|
||||
use benilla_protocol::messages as m;
|
||||
match self {
|
||||
Self::Say => m::CHAT_TYPE_SAY,
|
||||
Self::Yell => m::CHAT_TYPE_YELL,
|
||||
Self::Emote => m::CHAT_TYPE_EMOTE,
|
||||
Self::Whisper => m::CHAT_TYPE_WHISPER,
|
||||
Self::Party => m::CHAT_TYPE_PARTY,
|
||||
Self::Raid => m::CHAT_TYPE_RAID,
|
||||
Self::RaidLeader => m::CHAT_TYPE_RAID_LEADER,
|
||||
Self::RaidWarning => m::CHAT_TYPE_RAID_WARNING,
|
||||
Self::Guild => m::CHAT_TYPE_GUILD,
|
||||
Self::Officer => m::CHAT_TYPE_OFFICER,
|
||||
Self::Battleground => m::CHAT_TYPE_BATTLEGROUND,
|
||||
Self::BattlegroundLeader => m::CHAT_TYPE_BATTLEGROUND_LEADER,
|
||||
Self::Afk => m::CHAT_TYPE_AFK,
|
||||
Self::Dnd => m::CHAT_TYPE_DND,
|
||||
Self::Channel => m::CHAT_TYPE_CHANNEL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_CAST_TRACE=1`: log our own cast packets and every outbound movement packet. vmangos
|
||||
/// interrupts a cast on a movement report whose position differs at all (`Player::SetPosition`).
|
||||
pub(crate) static CAST_TRACE: std::sync::LazyLock<bool> =
|
||||
@@ -780,6 +819,8 @@ pub(crate) enum ClientCommand {
|
||||
kind: ChatKind,
|
||||
target: Option<String>,
|
||||
text: String,
|
||||
/// The `Languages.dbc` id `SendChatMessage` named; `None` speaks the character's own.
|
||||
language: Option<u32>,
|
||||
},
|
||||
/// `SendAddonMessage`: a `CMSG_MESSAGECHAT` in `LANG_ADDON` (1.12 has no addon opcode and no
|
||||
/// whispered addon message). `text` is `prefix` TAB `message`.
|
||||
|
||||
@@ -25,7 +25,7 @@ use benilla_protocol::{
|
||||
};
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
|
||||
use super::{CharRequest, ChatKind, ClientCommand, RealmRequest};
|
||||
use super::{CharRequest, ClientCommand, RealmRequest};
|
||||
|
||||
/// The inbound census: every packet off the world socket, and the unix ms of the latest, which
|
||||
/// tell a silent server from a dead socket for the runaway watch ([`crate::net::motion`]).
|
||||
@@ -920,27 +920,12 @@ fn writer_loop(
|
||||
ClientCommand::CancelAutoRepeat => w.cancel_auto_repeat(),
|
||||
ClientCommand::CancelCast { spell_id } => w.cancel_cast(spell_id),
|
||||
ClientCommand::CancelChannelling { spell_id } => w.cancel_channelling(spell_id),
|
||||
ClientCommand::Chat { kind, target, text } => match kind {
|
||||
ChatKind::Say => w.send_chat(&text),
|
||||
ChatKind::Yell => w.send_yell(&text),
|
||||
ChatKind::Emote => w.send_emote_chat(&text),
|
||||
ChatKind::Whisper => {
|
||||
w.send_whisper(target.as_deref().unwrap_or_default(), &text)
|
||||
}
|
||||
ChatKind::Party => w.send_party(&text),
|
||||
ChatKind::Raid => w.send_raid(&text),
|
||||
ChatKind::RaidLeader => w.send_raid_leader(&text),
|
||||
ChatKind::RaidWarning => w.send_raid_warning(&text),
|
||||
ChatKind::Guild => w.send_guild(&text),
|
||||
ChatKind::Officer => w.send_officer(&text),
|
||||
ChatKind::Battleground => w.send_battleground(&text),
|
||||
ChatKind::BattlegroundLeader => w.send_battleground_leader(&text),
|
||||
ChatKind::Afk => w.send_afk(&text),
|
||||
ChatKind::Dnd => w.send_dnd(&text),
|
||||
ChatKind::Channel => {
|
||||
w.send_channel(target.as_deref().unwrap_or_default(), &text)
|
||||
}
|
||||
},
|
||||
ClientCommand::Chat {
|
||||
kind,
|
||||
target,
|
||||
text,
|
||||
language,
|
||||
} => w.send_message_chat(kind.chat_type(), language, target.as_deref(), &text),
|
||||
// The distribution is an enum, so its map to a chat type is total.
|
||||
ClientCommand::AddonMessage { distribution, text } => {
|
||||
w.send_addon_message(super::addon_wire_chat_type(distribution), &text)
|
||||
|
||||
@@ -83,6 +83,7 @@ pub(crate) fn land_here(
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
text,
|
||||
language: None,
|
||||
});
|
||||
*pending = Some(time.elapsed_secs() + LAND_TIMEOUT);
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ pub(crate) fn movement_clears_afk(
|
||||
kind: crate::net::ChatKind::Afk,
|
||||
target: None,
|
||||
text: String::new(),
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ pub(crate) fn idle_handler(
|
||||
kind: crate::net::ChatKind::Afk,
|
||||
target: None,
|
||||
text: out.body,
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
// `0x482eee call 0x5ab0d0; 0x482ef5 jne`: while a logout is pending the whole leg is skipped,
|
||||
@@ -542,7 +543,7 @@ mod tests {
|
||||
assert!(
|
||||
matches!(
|
||||
sent.as_slice(),
|
||||
[ClientCommand::Chat { kind: crate::net::ChatKind::Afk, target: None, text }]
|
||||
[ClientCommand::Chat { kind: crate::net::ChatKind::Afk, target: None, text, .. }]
|
||||
if text == "Away from Keyboard"
|
||||
),
|
||||
"{sent:?}"
|
||||
|
||||
@@ -183,6 +183,7 @@ pub(super) fn drain_chat_input(
|
||||
kind: super::edit::SendType::Say.wire(),
|
||||
target: None,
|
||||
text: msg.to_string(),
|
||||
language: None,
|
||||
};
|
||||
if commands.0.send(cmd).is_err() {
|
||||
warn!("chat: not connected; line dropped");
|
||||
@@ -1414,6 +1415,7 @@ pub(super) fn drain_addon_chat_sends(
|
||||
kind: crate::net::ChatKind::Afk,
|
||||
target: None,
|
||||
text: String::new(),
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1442,6 +1444,12 @@ pub(super) fn drain_addon_chat_sends(
|
||||
kind: wire,
|
||||
target: send.target,
|
||||
text,
|
||||
// Every type but AFK carries the language the binding resolved (`0x49f6f9`); the AFK
|
||||
// arm's own builder, `SetAFK` (`0x5eb740`), never reads the argument.
|
||||
language: match wire {
|
||||
crate::net::ChatKind::Afk => None,
|
||||
_ => send.language,
|
||||
},
|
||||
};
|
||||
if commands.0.send(cmd).is_err() {
|
||||
warn!("chat: not connected; addon line dropped");
|
||||
|
||||
@@ -2456,6 +2456,56 @@ fn afk_then_dnd_clears_the_afk_first() {
|
||||
assert!(!world.resource::<super::away::AfkMirror>().is_afk());
|
||||
}
|
||||
|
||||
/// `SendChatMessage`'s language through the real drain: the id the binding resolved rides every
|
||||
/// type's packet (`0x49f6f9`) but AFK's, whose `SetAFK` (`0x5eb740`) never reads it.
|
||||
#[test]
|
||||
fn a_named_language_rides_the_chat_command() {
|
||||
use crate::net::{ChatKind, ClientCommand, NetCommands};
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let mut world = bevy::prelude::World::new();
|
||||
let mut script = benilla_ui::script::UiScript::new().expect("VM");
|
||||
script.set_language_table(vec![(2, "Darnassian".into()), (7, "Common".into())]);
|
||||
world.insert_non_send_resource(script);
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
world.insert_resource(NetCommands(tx));
|
||||
world.init_resource::<super::feed::ChatLog>();
|
||||
world.init_resource::<super::away::AfkMirror>();
|
||||
world.init_resource::<crate::cvars::Cvars>();
|
||||
world.init_resource::<super::edit::ChannelState>();
|
||||
world
|
||||
.non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.run(
|
||||
r#"
|
||||
SendChatMessage("ishnu", "SAY", "darnassian")
|
||||
SendChatMessage("hi", "WHISPER", "Darnassian", "Bob")
|
||||
SendChatMessage("hi")
|
||||
DEFAULT_AFK_MESSAGE = "Away from Keyboard"
|
||||
SendChatMessage("brb", "AFK", "Darnassian")
|
||||
"#,
|
||||
)
|
||||
.expect("lua");
|
||||
world
|
||||
.run_system_once(super::input::drain_addon_chat_sends)
|
||||
.expect("drain");
|
||||
let sent: Vec<(ChatKind, Option<u32>)> = rx
|
||||
.try_iter()
|
||||
.map(|c| match c {
|
||||
ClientCommand::Chat { kind, language, .. } => (kind, language),
|
||||
other => panic!("unexpected command {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sent,
|
||||
vec![
|
||||
(ChatKind::Say, Some(2)),
|
||||
(ChatKind::Whisper, Some(2)),
|
||||
(ChatKind::Say, None),
|
||||
(ChatKind::Afk, None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `SendChatMessage`'s `CHANNEL` target through the real drain: `SStrToInt` into `0x49be50`
|
||||
/// (`0x49f4d9`-`0x49f4ea`), so the packet carries the numbered slot's name, and a number naming
|
||||
/// no confirmed slot, or a name, sends nothing at all.
|
||||
@@ -2508,7 +2558,9 @@ fn a_channel_send_carries_the_numbered_slots_name() {
|
||||
let sent: Vec<(ChatKind, Option<String>, String)> = rx
|
||||
.try_iter()
|
||||
.map(|c| match c {
|
||||
ClientCommand::Chat { kind, target, text } => (kind, target, text),
|
||||
ClientCommand::Chat {
|
||||
kind, target, text, ..
|
||||
} => (kind, target, text),
|
||||
other => panic!("unexpected command {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -166,6 +166,37 @@ fn input_editbox_enter_drains_the_typed_line() {
|
||||
assert!(s.take_chat_sends().is_empty(), "drained");
|
||||
}
|
||||
|
||||
/// The chat menu's Language list sets `editBox.language` (`ChatFrame.lua:2403-2405`) and
|
||||
/// `ChatEdit_SendText` passes it on every send (`:1950-1954`), so `/s` and `/y` speak the pick.
|
||||
#[test]
|
||||
fn the_edit_box_speaks_the_language_picked_in_the_chat_menu() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut s = chat_frame();
|
||||
s.set_language_table(vec![(2, "Darnassian".into()), (7, "Common".into())]);
|
||||
s.set_known_languages(vec!["Darnassian".into(), "Common".into()]);
|
||||
for line in ["/s before", "/s ishnu", "/y alah"] {
|
||||
if line == "/s ishnu" {
|
||||
s.run("ChatFrameEditBox.language = GetLanguageByIndex(1)")
|
||||
.unwrap();
|
||||
}
|
||||
assert!(s.focus_editbox("ChatFrameEditBox"));
|
||||
s.char_input(line);
|
||||
assert!(s.key_input("ENTER"));
|
||||
}
|
||||
let sends = s.take_chat_sends();
|
||||
assert_eq!(
|
||||
sends
|
||||
.iter()
|
||||
.map(|c| (c.text.as_str(), c.chat_type.as_str(), c.language))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("before", "SAY", None),
|
||||
("ishnu", "SAY", Some(2)),
|
||||
("alah", "YELL", Some(2)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_escape_closes_without_submitting() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
|
||||
@@ -329,11 +329,13 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
.is_none_or(crate::cvars::Cvars::addon_version_check);
|
||||
// Per-VM `Update` claims the load itself reads, seeded now: the zone-channel catalog (empty,
|
||||
// `General` would be filed and joined as a custom channel), the binding table (stock
|
||||
// `ActionButton_OnLoad` paints the hotkeys) and the default language (`ChatFrame_OnEvent`'s
|
||||
// `PLAYER_ENTERING_WORLD` arm stores it, `ChatFrame.lua:1275-1276`).
|
||||
// `ActionButton_OnLoad` paints the hotkeys), the default language (`ChatFrame_OnEvent`'s
|
||||
// `PLAYER_ENTERING_WORLD` arm stores it, `ChatFrame.lua:1275-1276`) and the language table a
|
||||
// file-scope `SendChatMessage` names its tongue from.
|
||||
crate::ui_chat::seed_zone_channel_catalog(world, &mut script);
|
||||
crate::bindings::seed_bindings_for_vm(world, &mut script);
|
||||
crate::ui_unit::seed_default_language(world, &mut script);
|
||||
crate::ui_unit::seed_language_table(world, &mut script);
|
||||
// The world map's continent and zone lists: static DBC data in the reference, read at file
|
||||
// scope (Astrolabe builds its whole zone table from `GetMapContinents`/`GetMapZones` there).
|
||||
crate::ui_world_map::seed_world_map_catalog(world, &mut script);
|
||||
|
||||
@@ -310,6 +310,21 @@ pub(crate) fn seed_default_language(world: &mut World, script: &mut UiScript) {
|
||||
script.set_default_language(langs.0.name(u32::from(row.race), 0).map(str::to_string));
|
||||
}
|
||||
|
||||
/// Seed a new VM with `Languages.dbc`, the static table `SendChatMessage` resolves its language
|
||||
/// name against (`0x49f8a0`); locale column 0, as [`feed_default_language`] reads.
|
||||
pub(crate) fn seed_language_table(world: &World, script: &mut UiScript) {
|
||||
let Some(langs) = world.get_resource::<LanguagesRes>() else {
|
||||
return;
|
||||
};
|
||||
script.set_language_table(
|
||||
langs
|
||||
.0
|
||||
.names(0)
|
||||
.map(|(id, name)| (id, name.to_string()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Push `GetDefaultLanguage()`'s string on a change of race; `None` is the reference's zero value.
|
||||
/// Locale column 0 (the client's slot is `[0xc0e080]`): only enUS is populated in the 1.12 data.
|
||||
fn feed_default_language(
|
||||
@@ -1657,6 +1672,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A new VM is seeded with the shipped `Languages.dbc`, so each race's own tongue resolves by
|
||||
/// name to the id vmangos keys `KnowsLanguage` on (`SharedDefines.h:253-269`).
|
||||
#[test]
|
||||
fn a_new_vm_resolves_every_racial_language_by_name() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let mut world = World::new();
|
||||
world.insert_resource(LanguagesRes(
|
||||
benilla_formats::load_languages(&mut chain).expect("Languages.dbc"),
|
||||
));
|
||||
let mut script = UiScript::new().expect("VM");
|
||||
seed_language_table(&world, &mut script);
|
||||
let want = [
|
||||
("Orcish", 1),
|
||||
("Darnassian", 2),
|
||||
("Taurahe", 3),
|
||||
("Dwarvish", 6),
|
||||
("Common", 7),
|
||||
("Gnomish", 13),
|
||||
("Troll", 14),
|
||||
("Gutterspeak", 33),
|
||||
];
|
||||
for (name, _) in want {
|
||||
script
|
||||
.run(&format!(r#"SendChatMessage("hi", "SAY", "{name}")"#))
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
}
|
||||
let got: Vec<Option<u32>> = script
|
||||
.take_chat_sends()
|
||||
.iter()
|
||||
.map(|c| c.language)
|
||||
.collect();
|
||||
let ids: Vec<Option<u32>> = want.iter().map(|&(_, id)| Some(id)).collect();
|
||||
assert_eq!(got, ids);
|
||||
}
|
||||
|
||||
/// [`race_pvp_team`] against the shipped DBCs, walked as `0x5efe00` walks them.
|
||||
#[test]
|
||||
fn race_pvp_team_matches_the_shipped_tables() {
|
||||
|
||||
@@ -778,6 +778,7 @@ fn dev_map_jump(
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text,
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The chat sends. Chat speaks [`WorldWriter::chat_language`]: vmangos drops `Universal` from
|
||||
//! clients and any tongue the character does not know.
|
||||
//! The chat sends. A line speaks the language `SendChatMessage` named, else
|
||||
//! [`WorldWriter::chat_language`]: vmangos drops `Universal` outside AFK and DND
|
||||
//! (`ChatHandler.cpp:105`) and any tongue the character does not know (`:175`).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -16,120 +17,18 @@ impl WorldWriter {
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a `/yell` line (`CHAT_MSG_YELL`, `SharedDefines.h:1199`).
|
||||
pub fn send_yell(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(messages::CHAT_TYPE_YELL, self.chat_language, message),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a custom `/emote` line (`CHAT_MSG_EMOTE`), shown verbatim as `"PlayerName <text>"`.
|
||||
pub fn send_emote_chat(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(messages::CHAT_TYPE_EMOTE, self.chat_language, message),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a `/whisper` line; the body carries the name before the message (`Chat.cpp:3-12`).
|
||||
pub fn send_whisper(&mut self, target: &str, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat_whisper(self.chat_language, target, message),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a `/p` party line, dropped silently when ungrouped (`ChatHandler.cpp:472-493`).
|
||||
pub fn send_party(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(messages::CHAT_TYPE_PARTY, self.chat_language, message),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a `/ra` raid line (`CHAT_MSG_RAID`); needs a raid (`ChatHandler.cpp:514-536`).
|
||||
pub fn send_raid(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(messages::CHAT_TYPE_RAID, self.chat_language, message),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a `/g` guild line (`CHAT_MSG_GUILD`); needs a guild (`ChatHandler.cpp:494-503`).
|
||||
pub fn send_guild(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(messages::CHAT_TYPE_GUILD, self.chat_language, message),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a `/o` officer line (`CHAT_MSG_OFFICER`); needs a guild (`ChatHandler.cpp:504-513`).
|
||||
pub fn send_officer(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(messages::CHAT_TYPE_OFFICER, self.chat_language, message),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a `/rl` raid-leader line, leader only (`ChatHandler.cpp:538-559`).
|
||||
pub fn send_raid_leader(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(messages::CHAT_TYPE_RAID_LEADER, self.chat_language, message),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a `/rw` raid warning, leader or assistant only (`ChatHandler.cpp:561-576`).
|
||||
pub fn send_raid_warning(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(
|
||||
messages::CHAT_TYPE_RAID_WARNING,
|
||||
self.chat_language,
|
||||
message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a battleground line; needs a battleground group (`ChatHandler.cpp:579-593`).
|
||||
pub fn send_battleground(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(
|
||||
messages::CHAT_TYPE_BATTLEGROUND,
|
||||
self.chat_language,
|
||||
message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a battleground-leader line, leader only (`ChatHandler.cpp:595-609`).
|
||||
pub fn send_battleground_leader(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(
|
||||
messages::CHAT_TYPE_BATTLEGROUND_LEADER,
|
||||
self.chat_language,
|
||||
message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Toggle AFK, `message` the auto-reply if any; it clears DND (`ChatHandler.cpp:611-630`).
|
||||
pub fn send_afk(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(messages::CHAT_TYPE_AFK, self.chat_language, message),
|
||||
)
|
||||
}
|
||||
|
||||
/// Toggle DND (`CHAT_MSG_DND`), which clears AFK (`ChatHandler.cpp:632-648`).
|
||||
pub fn send_dnd(&mut self, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat(messages::CHAT_TYPE_DND, self.chat_language, message),
|
||||
)
|
||||
/// Send a chat line of any `ChatMsg` type, as the reference's generic builder writes it
|
||||
/// (`0x49f6c3`-`0x49f72a`). `language` is the `Languages.dbc` id `SendChatMessage` resolved;
|
||||
/// `None` is the character's own tongue, [`WorldWriter::chat_language`].
|
||||
pub fn send_message_chat(
|
||||
&mut self,
|
||||
chat_type: u32,
|
||||
language: Option<u32>,
|
||||
target: Option<&str>,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
let body = message_chat_body(self.chat_language, chat_type, language, target, message);
|
||||
self.send(opcode::CMSG_MESSAGECHAT, &body)
|
||||
}
|
||||
|
||||
/// Send an addon message (`SendAddonMessage`): `text` is the caller's composed `prefix` TAB
|
||||
@@ -143,14 +42,6 @@ impl WorldWriter {
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a channel line by name, dropped unless we are on it (`ChatHandler.cpp:255-327`).
|
||||
pub fn send_channel(&mut self, channel: &str, message: &str) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_MESSAGECHAT,
|
||||
&messages::messagechat_channel(self.chat_language, channel, message),
|
||||
)
|
||||
}
|
||||
|
||||
/// Tell the server we ignore `guid`; that player gets a `CHAT_MSG_IGNORED` notice.
|
||||
pub fn chat_ignored(&mut self, guid: u64) -> Result<()> {
|
||||
self.send(opcode::CMSG_CHAT_IGNORED, &messages::full_guid(guid))
|
||||
@@ -174,3 +65,58 @@ impl WorldWriter {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `CMSG_MESSAGECHAT` body: type, language (`0x49f6f9`), the target cstring only for a whisper
|
||||
/// or a channel (`0x49f701`-`0x49f712`, types `6` and `0xe`), then the text. `speaker` is the
|
||||
/// language a send that names none carries.
|
||||
fn message_chat_body(
|
||||
speaker: u32,
|
||||
chat_type: u32,
|
||||
language: Option<u32>,
|
||||
target: Option<&str>,
|
||||
message: &str,
|
||||
) -> Vec<u8> {
|
||||
let target = matches!(
|
||||
chat_type,
|
||||
messages::CHAT_TYPE_WHISPER | messages::CHAT_TYPE_CHANNEL
|
||||
)
|
||||
.then(|| target.unwrap_or_default());
|
||||
messages::messagechat_kind(chat_type, language.unwrap_or(speaker), target, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_named_language_lands_in_the_body() {
|
||||
// A Night Elf's `SendChatMessage("hi", "SAY", "Darnassian")`: type 0, language 2.
|
||||
assert_eq!(
|
||||
message_chat_body(7, messages::CHAT_TYPE_SAY, Some(2), None, "hi"),
|
||||
[0, 0, 0, 0, 2, 0, 0, 0, b'h', b'i', 0]
|
||||
);
|
||||
// No language named: the speaker's Common (7).
|
||||
assert_eq!(
|
||||
message_chat_body(7, messages::CHAT_TYPE_YELL, None, None, "hi"),
|
||||
[5, 0, 0, 0, 7, 0, 0, 0, b'h', b'i', 0]
|
||||
);
|
||||
// A Tauren's Taur-ahe (3) whisper: the name before the text.
|
||||
assert_eq!(
|
||||
message_chat_body(1, messages::CHAT_TYPE_WHISPER, Some(3), Some("Bo"), "yo"),
|
||||
[6, 0, 0, 0, 3, 0, 0, 0, b'B', b'o', 0, b'y', b'o', 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_whisper_or_a_channel_carries_a_target() {
|
||||
assert_eq!(
|
||||
message_chat_body(1, messages::CHAT_TYPE_PARTY, None, Some("Bo"), "x"),
|
||||
[1, 0, 0, 0, 1, 0, 0, 0, b'x', 0]
|
||||
);
|
||||
assert_eq!(
|
||||
message_chat_body(1, messages::CHAT_TYPE_CHANNEL, Some(14), None, "x"),
|
||||
[0xe, 0, 0, 0, 14, 0, 0, 0, 0, b'x', 0],
|
||||
"a channel with no name still writes the empty cstring"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +63,9 @@ pub struct WorldWriter {
|
||||
/// Every packet that reached the socket since the last drain, as `(opcode, body length)`;
|
||||
/// `None` until [`Self::watch_sends`]. Pushed only after a successful write.
|
||||
pub(super) sent: Option<Vec<(u16, usize)>>,
|
||||
/// The character's faction language, set at login from its race; every chat send carries it.
|
||||
/// vmangos drops the whole message, dot-commands included, when the character does not know it.
|
||||
/// The character's faction language, set at login from its race, which a chat send naming no
|
||||
/// language carries. vmangos drops the whole message, dot-commands included, when the
|
||||
/// character does not know the language.
|
||||
pub(super) chat_language: u32,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
//! grammar: an addon's `SendChatMessage("/dance")` says the characters. The reference splits the
|
||||
//! same way, `ChatEdit_SendText` parsing and `SendChatMessage` not.
|
||||
//!
|
||||
//! `language` is accepted and dropped, so every line goes in the speaker's default tongue: the
|
||||
//! app's chat command has no language field. The reference sends the named language.
|
||||
//! `language` starts as the speaker's race base language (`0x5ec890`, at `0x49f2aa`). A string or
|
||||
//! number third argument is matched by name, case-insensitively, against every `Languages.dbc`
|
||||
//! row (`0x49f8a0`), known to the character or not, and a miss raises `Unknown language`
|
||||
//! (`0x844afc`, at `0x49f2e1`): a number is its text, so `7` raises. Refusing a tongue the
|
||||
//! character never learned is the server's (vmangos `ChatHandler.cpp:175`).
|
||||
//!
|
||||
//! Deviation: no truncation at 255 characters (`0x49f604`), because vmangos refuses a longer line
|
||||
//! itself (`Chat.cpp:2177`): it is dropped where the reference sends its first 255. The server
|
||||
@@ -37,6 +40,25 @@ pub struct ChatSend {
|
||||
/// The fourth argument as `lua_tostring` gives it (`0x49f306`): the whisper target, or the
|
||||
/// channel number the app resolves to a joined channel's name.
|
||||
pub target: Option<String>,
|
||||
/// The `Languages.dbc` id the third argument named; `None` is the speaker's default.
|
||||
pub language: Option<u32>,
|
||||
}
|
||||
|
||||
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]`).
|
||||
pub fn set_language_table(&mut self, rows: Vec<(u32, String)>) {
|
||||
self.model_mut().language_table = rows;
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x49f8a0`: the first row, in file order, whose name equals `name` ignoring case (`SStrCmpI`
|
||||
/// `0x64a4c0`, unbounded length), checked against every row, not only the known languages.
|
||||
fn language_id(table: &[(u32, String)], name: &str) -> Option<u32> {
|
||||
table
|
||||
.iter()
|
||||
.find(|(_, row)| row.eq_ignore_ascii_case(name))
|
||||
.map(|&(id, _)| id)
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
@@ -105,10 +127,24 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.globals().set(
|
||||
"SendChatMessage",
|
||||
lua.create_function(
|
||||
|lua, (text, chat_type, _language, target): (String, Option<String>, Value, Value)| {
|
||||
|lua, (text, chat_type, language, target): (String, Option<String>, Value, Value)| {
|
||||
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");
|
||||
let language = match super::binding_abi::optional_string(lua, &language) {
|
||||
Some(name) if reads_language => {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let id = language_id(&model.language_table, &name);
|
||||
Some(
|
||||
id.ok_or_else(|| mlua::Error::RuntimeError("Unknown language".into()))?,
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// `lua_isstring` then `lua_tostring` (`0x49f2f6`, `0x49f306`): a number is its
|
||||
// text, so `2.7` reaches the channel's `SStrToInt` as "2.7", which reads 2.
|
||||
let target = super::binding_abi::optional_string(lua, &target);
|
||||
@@ -119,6 +155,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
text,
|
||||
chat_type,
|
||||
target,
|
||||
language,
|
||||
});
|
||||
Ok(())
|
||||
},
|
||||
@@ -151,11 +188,13 @@ mod tests {
|
||||
text: "hello".into(),
|
||||
chat_type: "SAY".into(),
|
||||
target: None,
|
||||
language: None,
|
||||
},
|
||||
ChatSend {
|
||||
text: "/dance".into(),
|
||||
chat_type: "SAY".into(),
|
||||
target: None,
|
||||
language: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -188,12 +227,67 @@ mod tests {
|
||||
assert_eq!(sent[1].chat_type, "CHANNEL");
|
||||
}
|
||||
|
||||
/// Three `Languages.dbc` rows, in the shipped file's order.
|
||||
fn with_languages(s: &mut UiScript) {
|
||||
s.set_language_table(vec![
|
||||
(1, "Orcish".into()),
|
||||
(2, "Darnassian".into()),
|
||||
(7, "Common".into()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_language_argument_is_accepted_and_ignored() {
|
||||
fn a_language_name_sends_its_languages_dbc_id() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(r#"SendChatMessage("hi", "SAY", 7)"#).unwrap();
|
||||
assert_eq!(s.take_chat_sends().len(), 1);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
with_languages(&mut s);
|
||||
s.run(r#"SendChatMessage("hi", "SAY", "Darnassian")"#)
|
||||
.unwrap();
|
||||
s.run(r#"SendChatMessage("hi", "YELL", "dARNASSIAN")"#)
|
||||
.unwrap();
|
||||
s.run(r#"SendChatMessage("hi", "WHISPER", "orcish", "Bob")"#)
|
||||
.unwrap();
|
||||
let sent = s.take_chat_sends();
|
||||
assert_eq!(sent[0].language, Some(2), "Darnassian's row ID");
|
||||
assert_eq!(sent[1].language, Some(2), "SStrCmpI ignores case");
|
||||
assert_eq!(
|
||||
sent[2].language,
|
||||
Some(1),
|
||||
"a language the character may not know is sent: the server refuses it"
|
||||
);
|
||||
assert_eq!(sent[2].target.as_deref(), Some("Bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_language_argument_speaks_the_default() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
with_languages(&mut s);
|
||||
s.run(r#"SendChatMessage("hi")"#).unwrap();
|
||||
s.run(r#"SendChatMessage("hi", "SAY", nil)"#).unwrap();
|
||||
s.run(r#"SendChatMessage("hi", "SAY", {})"#).unwrap();
|
||||
s.run(r#"SendChatMessage("hi", "SAY", true)"#).unwrap();
|
||||
let sent = s.take_chat_sends();
|
||||
assert_eq!(sent.len(), 4);
|
||||
assert!(
|
||||
sent.iter().all(|c| c.language.is_none()),
|
||||
"not a string, so `lua_isstring` keeps the default: {sent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_language_raises_and_sends_nothing() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
with_languages(&mut s);
|
||||
for arg in [r#""Klingon""#, r#""""#, "7", r#""Darnassian ""#] {
|
||||
let err = s
|
||||
.run(&format!(r#"SendChatMessage("hi", "SAY", {arg})"#))
|
||||
.expect_err(&format!("{arg} names no row"));
|
||||
assert!(err.to_string().contains("Unknown language"), "{arg}: {err}");
|
||||
}
|
||||
assert!(s.take_chat_sends().is_empty());
|
||||
// AFK and DND pass the empty-line gate, so their language is read.
|
||||
assert!(s.run(r#"SendChatMessage("", "AFK", "Klingon")"#).is_err());
|
||||
// An empty line of any other type ends before the language is read.
|
||||
s.run(r#"SendChatMessage("", "SAY", "Klingon")"#).unwrap();
|
||||
}
|
||||
|
||||
/// The count matters: an addon passes the result straight to `SendChatMessage` as `language`.
|
||||
|
||||
@@ -295,6 +295,8 @@ pub(crate) struct Model {
|
||||
pub(crate) chat_colors_changed: bool,
|
||||
/// The languages this character knows, in `Languages.dbc` row order.
|
||||
pub(crate) known_languages: Vec<String>,
|
||||
/// `Languages.dbc`'s `(ID, Name_lang)` rows in file order, whatever the character knows.
|
||||
pub(crate) language_table: Vec<(u32, String)>,
|
||||
pub(crate) zone_channel_catalog: Vec<super::channel::ZoneChannelRow>,
|
||||
pub(crate) channel_commands: Vec<super::channel::ChannelCommand>,
|
||||
/// The recruitment auto-join latch `[0x843608]`: 0 `STANDARD`, 1 `AUTO` (`0x49ea70` maps any
|
||||
@@ -1078,6 +1080,7 @@ impl Model {
|
||||
chat_colors: super::chat_types::seed(),
|
||||
chat_colors_changed: false,
|
||||
known_languages: Vec::new(),
|
||||
language_table: Vec::new(),
|
||||
zone_channel_catalog: Vec::new(),
|
||||
channel_commands: Vec::new(),
|
||||
guild_recruitment_mode: 1,
|
||||
|
||||
Reference in New Issue
Block a user