Files
benilla-octo/crates/benilla-ui/src/script/channel.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

758 lines
29 KiB
Rust

//! The chat-channel globals: the joined-channel lookups, the join, leave and management verbs the
//! app sends, and the guild-recruitment auto-join latch. `GetChannelName` (`0x4a05e0`) looks up by
//! 1-based slot or by name and returns three values: the slot (`CHAT_MSG_*` arg8), the name and
//! `instanceID` (arg10).
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// One `ChatChannels.dbc` row for the channel verbs: the id, the Shortcut a typed name is matched
/// against case-folded, the name composed for the player's zone (`None` while the zone text is
/// empty, the verbs' nil leg), and whether it is listed (a `flags & 0x10` city row only in a city).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ZoneChannelRow {
pub id: u32,
pub shortcut: String,
pub resolved: Option<String>,
pub listed: bool,
}
/// A channel verb's ask, drained by the app into its `CMSG_*`: the stock `ChatFrame.lua` slash
/// handlers' calls, one variant each.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ChannelCommand {
/// `JoinChannelByName(name, password)`, sent on both non-nil legs.
Join {
name: String,
password: String,
},
/// `LeaveChannelByName(name)`.
Leave {
name: String,
},
/// `ListChannelByName(name)`: `CMSG_CHANNEL_LIST`.
List {
name: String,
},
/// `ListChannels()`: the roster of every joined channel.
ListAll,
/// `DisplayChannelOwner(name)`: `CMSG_CHANNEL_OWNER`.
DisplayOwner {
name: String,
},
/// `SetChannelOwner(name, player)`.
SetOwner {
name: String,
player: String,
},
/// `SetChannelPassword(name, password)`.
SetPassword {
name: String,
password: String,
},
Ban {
name: String,
player: String,
},
Invite {
name: String,
player: String,
},
Kick {
name: String,
player: String,
},
Moderator {
name: String,
player: String,
},
Unmoderator {
name: String,
player: String,
},
Mute {
name: String,
player: String,
},
Unmute {
name: String,
player: String,
},
Unban {
name: String,
player: String,
},
/// `ChannelModerate(name)`: toggles moderation.
Moderate {
name: String,
},
/// `ChannelToggleAnnouncements(name)`.
ToggleAnnouncements {
name: String,
},
}
impl super::UiScript {
/// Feed the zone-channel catalog, whenever the zone, and so every resolved name, changes.
pub fn set_zone_channel_catalog(&mut self, rows: Vec<ZoneChannelRow>) {
self.model_mut().zone_channel_catalog = rows;
}
/// Channel verbs called since the last drain, in call order.
pub fn take_channel_commands(&mut self) -> Vec<ChannelCommand> {
std::mem::take(&mut self.model_mut().channel_commands)
}
/// The guild-recruitment auto-join latch: `0` STANDARD, `1` AUTO.
pub fn guild_recruitment_mode(&self) -> u8 {
self.model_ref().guild_recruitment_mode
}
/// Seat the latch from the chat cache at login. A host write is not a player gesture, so it
/// does not arm [`Self::take_guild_recruitment_change`]: a login must not save what it read.
pub fn set_guild_recruitment_mode(&mut self, mode: u8) {
let mut model = self.model_mut();
model.guild_recruitment_mode = mode;
model.guild_recruitment_changed = false;
}
/// Whether Lua moved the latch since the last drain: the chat cache's dirty signal.
pub fn take_guild_recruitment_change(&mut self) -> bool {
std::mem::take(&mut self.model_mut().guild_recruitment_changed)
}
/// Whether Lua called `SetGuildRecruitmentMode(1)` since the last drain: the cue for the
/// cascade (`0x49ea70` → `0x49ea90`).
pub fn take_guild_recruitment_cascade(&mut self) -> bool {
std::mem::take(&mut self.model_mut().guild_recruitment_cascade)
}
/// A manual join or leave of `GuildRecruitment` forces the latch to 0 (`0x49ed3d`, `0x49ef8f`)
/// and, as a player gesture, arms the save; mode 0 has no cascade, and the cascade's own join
/// passes a flag that skips this reset. Returns whether the latch moved.
pub fn reset_guild_recruitment_mode(&mut self) -> bool {
let mut model = self.model_mut();
if model.guild_recruitment_mode == 0 {
return false;
}
model.guild_recruitment_mode = 0;
model.guild_recruitment_changed = true;
true
}
}
fn push(lua: &Lua, cmd: ChannelCommand) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.channel_commands.push(cmd);
}
fn non_empty(s: Option<String>) -> Option<String> {
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
fn slot_of(model: &Model, name: &str) -> Option<usize> {
model
.joined_channels
.iter()
.position(|c| c.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)))
.map(|i| i + 1)
}
/// The name in slot `n` (1-based), `None` out of range or for a freed slot: the reference's lookup
/// `0x49bf30` requires the entry's own number to equal `n`, which a leave zeroes (`0x49bbd0`), so a
/// left channel's number answers "not joined" while every channel above it keeps its own.
fn name_at(model: &Model, n: usize) -> Option<&str> {
model.joined_channels.get(n.checked_sub(1)?)?.as_deref()
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// ── The guild-recruitment auto-join pair ──────────────────────────────────
// `GetGuildRecruitmentMode` (`0x4a0040`) and `SetGuildRecruitmentMode` (`0x4a0060`) store the
// Auto-join Guild Recruitment Channel option, which has no cvar: `UIOptionsFrame.lua` binds it
// by special arms (`:243`, `:328`, `:647`). The state is one int, `[0x843608]`, a `.data`
// initialiser of 1 written only by `0x49ea70`, and the chat cache saves it at teardown
// (`0x499a80`), never at set time.
g.set(
"GetGuildRecruitmentMode",
// Always a number, never nil: `UIOptionsFrame_Load` tests it `== 1`.
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(f64::from(model.guild_recruitment_mode))
})?,
)?;
// ── The `ecx == 1` cascade ──────────────────────────────────────────
// For mode 1 only, `0x49ea70` tail-jumps into `0x49ea90`, which acts on the wire (a guilded
// player leaves `GuildRecruitment - City`, an unguilded one in a capital joins it); that runs
// app-side in `ui_chat::recruitment`, and an acting cascade fires `UPDATE_CHAT_WINDOWS` again.
g.set(
"SetGuildRecruitmentMode",
// As `0x4a0060`: `lua_isnumber` (`0x6f34d0`, so `"1"` passes), truncation toward zero
// (`0x40a2b0`), then the 0..2 range gate (`0x4a0094`); success returns zero values.
lua.create_function(|lua, mode: Option<Value>| {
let n = match mode.as_ref() {
Some(Value::Integer(i)) => *i as f64,
Some(Value::Number(n)) => *n,
// `lua_isnumber` is true for a string `luaO_str2d` fully consumes.
Some(Value::String(s)) => s
.to_str()
.ok()
.and_then(|s| s.trim().parse::<f64>().ok())
.ok_or_else(|| mlua::Error::runtime("Usage: SetGuildRecruitmentMode(mode)"))?,
_ => return Err(mlua::Error::runtime("Usage: SetGuildRecruitmentMode(mode)")),
};
let n = n.trunc();
if !(0.0..2.0).contains(&n) {
return Err(mlua::Error::runtime(
"SetGuildRecruitmentMode: invalid mode",
));
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mode = n as u8;
if model.guild_recruitment_mode != mode {
model.guild_recruitment_mode = mode;
model.guild_recruitment_changed = true;
}
// `0x49ea79 jne` / `0x49ea7b jmp 0x49ea90`: the cascade, on the new value alone.
if mode == 1 {
model.guild_recruitment_cascade = true;
}
// `0x4a00a4`/`0x4a00a9`: `UPDATE_CHAT_WINDOWS`, on every successful call.
model
.pending_events
.push(("UPDATE_CHAT_WINDOWS".to_string(), Vec::new()));
Ok(())
})?,
)?;
g.set(
"GetChannelName",
lua.create_function(|lua, key: Value| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = match &key {
// The reference bounds-checks `1 <= n <= count` (`0x49bf30`) and answers only a
// confirmed slot; `joined` holds only those, so the bound is the whole check.
Value::Integer(_) | Value::Number(_) => {
let n = match &key {
Value::Integer(i) => *i,
Value::Number(n) => *n as i64,
_ => unreachable!(),
};
usize::try_from(n)
.ok()
.filter(|n| name_at(&model, *n).is_some())
}
// A numeric string still resolves as a number, as Lua's coercion does in the
// reference: `ChatFrame.lua:2113` passes a `gsub` result, `GetChannelName("1")`.
Value::String(s) => {
let name = s.to_str()?;
match name.trim().parse::<usize>() {
Ok(n) if name_at(&model, n).is_some() => Some(n),
Ok(_) => None,
Err(_) => slot_of(&model, &name),
}
}
_ => None,
};
let Some(slot) = slot else {
// Three values on every path, the name `lua_pushstring(NULL)`, which is nil
// (`0x4a0659`); an unconfirmed channel answers the same, as `joined` models.
return Ok(MultiValue::from_vec(vec![
Value::Integer(0),
Value::Nil,
Value::Integer(0),
]));
};
let name = name_at(&model, slot).unwrap_or_default().to_string();
Ok(MultiValue::from_vec(vec![
Value::Integer(slot as i64),
Value::String(lua.create_string(&name)?),
// `instanceID`: `YOU_JOINED`'s split index, 0 on vanilla servers; never nil.
Value::Integer(0),
]))
})?,
)?;
// GetChannelList() (`0x4a02d0`): a flat vararg of slot, name pairs in join order, as
// `FCFDropDown_LoadChannels` steps it (`FloatingChatFrame.lua:445`); none joined is zero
// returns. The slots must match `slot_of`'s, so it agrees with `GetChannelName`.
g.set(
"GetChannelList",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::with_capacity(model.joined_channels.len() * 2);
// Occupied slots only: the reference skips the cleared entries of its record array.
for (i, name) in model.joined_channels.iter().enumerate() {
let Some(name) = name.as_deref() else {
continue;
};
out.push(Value::Integer(i as i64 + 1));
out.push(Value::String(lua.create_string(name)?));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// JoinChannelByName(name [, password [, frameId]]) (`0x49ff00` → `0x49eb70`): a DBC row
// answers `(ChannelID, resolvedName)` and a custom channel `(0, nil)`, both sending; a name
// with a space, or a row whose zone text is empty, answers nil and sends nothing. A `frameId`
// that truncates to 1..=10 registers the channel in that window's list, deduplicated by name
// (`0x49ec24`), which is what brings a `/leave`-stripped channel's lines back to the window.
g.set(
"JoinChannelByName",
lua.create_function(
|lua, (name, password, frame): (Option<String>, Option<String>, Value)| {
let Some(name) = non_empty(name) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
if name.contains(' ') {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
}
let row = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.zone_channel_catalog
.iter()
.find(|r| r.shortcut.eq_ignore_ascii_case(&name))
.cloned()
};
let (id, resolved, listed_as) = match row {
None => (0, None, name.clone()),
Some(ZoneChannelRow { resolved: None, .. }) => {
return Ok(MultiValue::from_vec(vec![Value::Nil]))
}
Some(ZoneChannelRow {
id,
resolved,
shortcut,
..
}) => (id, resolved, shortcut),
};
// `0x49ff6b edi = -1` / `0x49ff8a dec edi`; `0x49ec27 cmp esi,0xa; jae` skips.
let window = match frame {
Value::Integer(n) => Some(n as f64),
Value::Number(n) => Some(n),
_ => None,
}
.map(|n| n.trunc() as i64 - 1)
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < 10);
if let Some(i) = window {
lua.app_data_mut::<Model>()
.expect("model app_data")
.register_window_channel(i, listed_as, id);
}
push(
lua,
ChannelCommand::Join {
name: resolved.clone().unwrap_or_else(|| name.clone()),
password: password.unwrap_or_default(),
},
);
Ok(MultiValue::from_vec(vec![
Value::Integer(i64::from(id)),
match resolved {
Some(r) => Value::String(lua.create_string(&r)?),
None => Value::Nil,
},
]))
},
)?,
)?;
// EnumerateServerChannels() (`0x4a1790`): the `ChatChannels.dbc` Shortcuts in row order, never
// the composed names, a `flags & 0x10` row only in a city (`AreaTable.Flags & 0x8`); zero
// values while the zone is unresolved.
g.set(
"EnumerateServerChannels",
lua.create_function(|lua, _ignored: MultiValue| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::new();
for row in model.zone_channel_catalog.iter().filter(|r| r.listed) {
out.push(Value::String(lua.create_string(&row.shortcut)?));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// LeaveChannelByName(name) (`0x4a0000` → `0x49ee70`) returns nothing and raises `Usage:` on a
// non-string, non-number. A key whose `SStrToInt` is non-zero is a joined slot, resolved
// app-side and never a window entry; a Shortcut leaves the name composed for this zone (a
// no-op with no zone text) and strips by Shortcut; anything else is a custom channel.
// Deviation: the reference composes the Shortcut with `GetRealZoneText` alone (`0x49efe8`), so
// in a capital `/leave Trade` sends `Trade - Stormwind City` and leaves nothing; ours uses the
// join's city-aware name, because the reference's form cannot leave the channel it joined.
// The slot is freed app-side when the server's `YOU_LEFT` arrives.
g.set(
"LeaveChannelByName",
lua.create_function(|lua, name: Option<Value>| {
let key = match name {
Some(Value::String(s)) => s.to_str().map(|s| s.to_string())?,
Some(Value::Integer(i)) => i.to_string(),
Some(Value::Number(n)) => format!("{n:.14}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_string(),
_ => return Err(mlua::Error::runtime(r#"Usage: LeaveChannelByName("name")"#)),
};
if super::chat_window::leading_int(&key) != 0 {
push(lua, ChannelCommand::Leave { name: key });
return Ok(());
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let row = model
.zone_channel_catalog
.iter()
.find(|r| r.shortcut.eq_ignore_ascii_case(&key))
.cloned();
let (wire, strip_key) = match row {
Some(ZoneChannelRow { resolved: None, .. }) => return Ok(()),
Some(ZoneChannelRow {
resolved: Some(r),
shortcut,
..
}) => (r, shortcut),
None => (key.clone(), key),
};
model.strip_window_channel(&strip_key);
model
.channel_commands
.push(ChannelCommand::Leave { name: wire });
Ok(())
})?,
)?;
// The one-name verbs: each queues its `CMSG_*` and returns nothing.
for (verb, make) in [
(
"ListChannelByName",
(|name| ChannelCommand::List { name }) as fn(String) -> ChannelCommand,
),
("DisplayChannelOwner", |name| ChannelCommand::DisplayOwner {
name,
}),
("ChannelModerate", |name| ChannelCommand::Moderate { name }),
("ChannelToggleAnnouncements", |name| {
ChannelCommand::ToggleAnnouncements { name }
}),
] {
g.set(
verb,
lua.create_function(move |lua, name: Option<String>| {
if let Some(name) = non_empty(name) {
push(lua, make(name));
}
Ok(())
})?,
)?;
}
g.set(
"ListChannels",
lua.create_function(|lua, _ignored: MultiValue| {
push(lua, ChannelCommand::ListAll);
Ok(())
})?,
)?;
// The two-name verbs: (channel, player), or (channel, password) for the password.
for (verb, make) in [
(
"SetChannelOwner",
(|name, player| ChannelCommand::SetOwner { name, player })
as fn(String, String) -> ChannelCommand,
),
("SetChannelPassword", |name, password| {
ChannelCommand::SetPassword { name, password }
}),
("ChannelBan", |name, player| ChannelCommand::Ban {
name,
player,
}),
("ChannelInvite", |name, player| ChannelCommand::Invite {
name,
player,
}),
("ChannelKick", |name, player| ChannelCommand::Kick {
name,
player,
}),
("ChannelModerator", |name, player| {
ChannelCommand::Moderator { name, player }
}),
("ChannelUnmoderator", |name, player| {
ChannelCommand::Unmoderator { name, player }
}),
("ChannelMute", |name, player| ChannelCommand::Mute {
name,
player,
}),
("ChannelUnmute", |name, player| ChannelCommand::Unmute {
name,
player,
}),
("ChannelUnban", |name, player| ChannelCommand::Unban {
name,
player,
}),
] {
g.set(
verb,
lua.create_function(
move |lua, (name, second): (Option<String>, Option<String>)| {
if let Some(name) = non_empty(name) {
// A password may be empty (`/password General` clears it); a player name
// may not.
let second = second.unwrap_or_default();
if verb == "SetChannelPassword" || !second.trim().is_empty() {
push(lua, make(name, second.trim().to_string()));
}
}
Ok(())
},
)?,
)?;
}
Ok(())
}
impl super::UiScript {
/// Mirror the app's confirmed-joined channels in join order, `None` for a freed slot;
/// `ui_chat::feed` pushes it on each `YOU_JOINED` and `YOU_LEFT`.
pub fn set_joined_channels(&mut self, joined: Vec<Option<String>>) {
self.model_mut().joined_channels = joined;
}
}
#[cfg(test)]
mod command_tests {
use super::{ChannelCommand, ZoneChannelRow};
use crate::script::UiScript;
fn catalog() -> Vec<ZoneChannelRow> {
vec![
ZoneChannelRow {
id: 1,
shortcut: "General".into(),
resolved: Some("General - Elwynn Forest".into()),
listed: true,
},
ZoneChannelRow {
id: 2,
shortcut: "Trade".into(),
resolved: None,
listed: false,
},
]
}
/// The row, custom-channel and nil legs, and that 0 is truthy.
#[test]
fn join_channel_by_name_answers_the_row_custom_and_nil_legs() {
let mut s = UiScript::new().unwrap();
s.set_zone_channel_catalog(catalog());
s.run(
"A = {JoinChannelByName('General', 'pw', 1)} \
B = {JoinChannelByName('MyChan', nil, 1)} \
C = {JoinChannelByName('Trade', nil, 1)} \
D = {JoinChannelByName('two words', nil, 1)}",
)
.unwrap();
assert_eq!(
s.eval::<(i64, String)>("return A[1], A[2]").unwrap(),
(1, "General - Elwynn Forest".to_string())
);
assert!(s
.eval::<bool>("return B[1] == 0 and B[2] == nil and table.getn(B) == 1")
.unwrap());
assert!(
s.eval::<bool>("return C[1] == nil").unwrap(),
"empty substitution"
);
assert!(s.eval::<bool>("return D[1] == nil").unwrap(), "a space");
assert_eq!(
s.take_channel_commands(),
vec![
ChannelCommand::Join {
name: "General - Elwynn Forest".into(),
password: "pw".into()
},
ChannelCommand::Join {
name: "MyChan".into(),
password: String::new()
},
],
"sent on both non-nil legs, the resolved name for a row"
);
}
#[test]
fn enumerate_server_channels_lists_the_shortcuts_the_zone_admits() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.arity("EnumerateServerChannels()").unwrap(),
0,
"no zone yet — 0 values"
);
s.set_zone_channel_catalog(catalog());
assert_eq!(
s.eval::<Vec<String>>("return {EnumerateServerChannels()}")
.unwrap(),
vec!["General".to_string()],
"the shortcut, never the composed name; a city row only in a city"
);
}
#[test]
fn the_management_verbs_queue_in_call_order_and_drop_empty_names() {
let mut s = UiScript::new().unwrap();
s.run(
"LeaveChannelByName('General') ListChannelByName('') ListChannels() \
SetChannelPassword('General', '') ChannelKick('General', 'Bob') \
ChannelKick('General', '') ChannelModerate('General') \
ChannelToggleAnnouncements('General') SetChannelOwner('General', 'Ann')",
)
.unwrap();
assert_eq!(
s.take_channel_commands(),
vec![
ChannelCommand::Leave {
name: "General".into()
},
ChannelCommand::ListAll,
ChannelCommand::SetPassword {
name: "General".into(),
password: String::new()
},
ChannelCommand::Kick {
name: "General".into(),
player: "Bob".into()
},
ChannelCommand::Moderate {
name: "General".into()
},
ChannelCommand::ToggleAnnouncements {
name: "General".into()
},
ChannelCommand::SetOwner {
name: "General".into(),
player: "Ann".into()
},
]
);
}
/// `frameId - 1` indexes the ten window records (`0x49ec24`); a missing, non-numeric or
/// out-of-range frame skips the registration and nothing else.
#[test]
fn join_channel_by_name_registers_the_channel_in_the_named_window() {
let mut s = UiScript::new().unwrap();
s.set_zone_channel_catalog(catalog());
s.run(
"JoinChannelByName('General', nil, 1) \
JoinChannelByName('general', nil, 1) \
JoinChannelByName('MyChan', nil, 2) \
JoinChannelByName('Trade', nil, nil) \
JoinChannelByName('LocalDefense', nil, 11) \
JoinChannelByName('WorldDefense', nil, 0)",
)
.unwrap();
let looks = s.chat_window_looks();
assert_eq!(
looks[0].channels,
vec![("General".to_string(), 1)],
"the DBC Shortcut and its id, once — the second join deduplicated by name"
);
assert_eq!(
looks[1].channels,
vec![("MyChan".to_string(), 0)],
"a custom channel: the name and id 0"
);
assert!(
looks[2..].iter().all(|l| l.channels.is_empty()),
"nil, 11 and 0 register nowhere"
);
assert_eq!(
s.take_chat_window_changes(),
vec![0, 1],
"…and both windows are owed a save"
);
// The joins went out whatever the frame argument: five, as this catalog's Trade is the
// nil leg, which registers nothing either.
assert_eq!(s.take_channel_commands().len(), 5);
}
/// `LeaveChannelByName`'s four legs and its raise (`0x4a0000` → `0x49ee70`); a Shortcut strips
/// its entry from every window.
#[test]
fn leave_channel_by_name_composes_strips_and_raises_the_way_the_reference_does() {
let mut s = UiScript::new().unwrap();
s.set_zone_channel_catalog(catalog());
s.run(
"JoinChannelByName('General', nil, 1) JoinChannelByName('MyChan', nil, 1) \
JoinChannelByName('General', nil, 2)",
)
.unwrap();
s.take_channel_commands();
s.take_chat_window_changes();
s.run("LeaveChannelByName('2')").unwrap();
assert_eq!(
s.take_channel_commands(),
vec![ChannelCommand::Leave { name: "2".into() }],
"a number goes through as typed — the app holds the slot states"
);
assert!(
s.take_chat_window_changes().is_empty(),
"…and strips no window: a slot name never equals a window entry"
);
s.run("LeaveChannelByName('general')").unwrap();
assert_eq!(
s.take_channel_commands(),
vec![ChannelCommand::Leave {
name: "General - Elwynn Forest".into()
}],
"a shortcut composes for the zone"
);
let looks = s.chat_window_looks();
assert_eq!(
looks[0].channels,
vec![("MyChan".to_string(), 0)],
"General stripped from window 1, MyChan kept"
);
assert!(
looks[1].channels.is_empty(),
"…and from window 2 — all ten are walked"
);
assert_eq!(s.take_chat_window_changes(), vec![0, 1]);
s.run("LeaveChannelByName('Trade')").unwrap();
assert!(
s.take_channel_commands().is_empty(),
"unresolvable here (no city word in this catalog): a complete no-op"
);
s.run("LeaveChannelByName('mychan')").unwrap();
assert_eq!(
s.take_channel_commands(),
vec![ChannelCommand::Leave {
name: "mychan".into()
}],
"custom: verbatim"
);
assert!(
s.chat_window_looks()[0].channels.is_empty(),
"…and stripped verbatim, case-folded"
);
let e = s.run("LeaveChannelByName()").unwrap_err();
assert!(e.to_string().contains("Usage: LeaveChannelByName"), "{e}");
assert_eq!(s.arity("LeaveChannelByName('x')").unwrap(), 0);
}
}