the guild window opens: roster, ranks, notes and the message of the day

- the social window's third tab is live — the roster in both its views, the member card with its promote/demote/remove verbs, the rank editor's thirteen permissions, the guild information board, and the invite, leave, kick and note dialogs behind them
- setting your hearthstone at an innkeeper now asks before it takes and says so afterwards, and the innkeeper's gossip lines get their own icons back
- unavailable talents grey out, and every greyed icon in the client now goes through the same luminance fold the original used
- the buff tooltip's remaining-time line reads gold with the client's own hour/minute ladder, the macro editor takes a shift-click to paste a spell, trainer rows repaint after a purchase, and a long book page stops flickering its ellipsis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-16 17:38:54 +07:00
co-authored by Claude Opus 5
parent 04340a6893
commit 8d3d805ec8
78 changed files with 13155 additions and 271 deletions
@@ -723,3 +723,255 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap());
}
/// A profession trainer's list: one skill line, a long recipe name and a short one, each with a rank
/// subtext — the shape of the B253 report (a Leatherworking trainer at "Handstitched Leather Pants").
fn recipe_menu() -> TrainerState {
let mut long = service(
3756,
"Handstitched Leather Pants",
TrainerServiceCategory::Available,
50,
1,
165,
"Leatherworking",
None,
vec![],
);
long.subtext = Some("Rank 1".into());
let mut short = service(
2149,
"Belt",
TrainerServiceCategory::Available,
50,
1,
165,
"Leatherworking",
None,
vec![],
);
short.subtext = Some("Rank 1".into());
TrainerState {
greeting: "Can I teach you how to turn beast hides into armor?".into(),
trainer_type: 2,
groups: Vec::new(),
services: vec![long, short],
}
}
/// **B253 — a long row name drew two lines over the row beneath it.** The row is a FLOW, not two
/// fixed columns: the name carries no width (a FontString given one wraps at it, and a 16 px row
/// cannot grow), and the rank follows the name's right edge rather than sitting at an invented
/// x=188. Both halves are the reference's own row
/// (`ClassTrainerFrameTemplates.xml`'s `<ButtonText>` at width 0 + the per-row
/// `SetPoint("LEFT", <row>Text, "RIGHT", 10, 0)`), read off the player's chain.
///
/// The mutation check is the first assertion: put a `<Size>` back on `$parentName` and the request
/// carries a wrap width, the long name measures two lines, and its height passes the row height.
#[test]
fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
let mut s = trainer_script();
s.set_money(5000);
s.set_trainer(Some(recipe_menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Nadyia".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The host measure: 6 px per character, 14 px per line, wrapped at whatever width was asked for.
// Capture the row names' requests on the way past — what they ASK is the structural fact.
let mut name_wraps: Vec<Option<f32>> = Vec::new();
let mut answer = |s: &mut UiScript, collect: bool| {
let reqs = s.fontstrings_needing_measure();
if collect {
name_wraps.extend(
reqs.iter()
.filter(|r| r.text.contains("Handstitched"))
.map(|r| r.wrap_width),
);
}
let answers: Vec<(u32, f32, f32, u64)> = reqs
.into_iter()
.map(|r| {
let ink = r.text.chars().count() as f32 * 6.0;
match r.wrap_width {
Some(w) => (r.id, ink.min(w), (ink / w).ceil().max(1.0) * 14.0, r.key),
None => (r.id, ink, 14.0, r.key),
}
})
.collect();
s.set_measured_text_unwrapped(&answers);
};
answer(&mut s, true);
s.resolve();
s.tick(0.016);
answer(&mut s, false);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!name_wraps.is_empty() && name_wraps.iter().all(|w| w.is_none()),
"the row name must ask for NO wrap width — it is a single line whatever the name is; \
got {name_wraps:?}"
);
// Find each name's row by what it painted (the group comparator owns the order, not this test).
let row_of = |s: &mut UiScript, needle: &str| -> i64 {
s.eval::<i64>(&format!(
"for i = 1, 11 do local b = getglobal('BenillaTrainerService' .. i) \
local t = b.name:GetText() if t and strfind(t, '{needle}', 1, 1) then return i end \
end return 0"
))
.unwrap()
};
let long_row = row_of(&mut s, "Handstitched");
let short_row = row_of(&mut s, "Belt");
assert!(long_row > 0 && short_row > 0, "both services painted");
let geom = |s: &mut UiScript, row: i64| -> (f32, f32, f32, f32) {
s.eval::<(f32, f32, f32, f32)>(&format!(
"local b = getglobal('BenillaTrainerService{row}') \
return b.name:GetHeight(), b.name:GetRight(), b.subtext:GetLeft(), b:GetHeight()"
))
.unwrap()
};
let (long_h, long_name_right, long_sub_left, row_h) = geom(&mut s, long_row);
let (short_h, short_name_right, short_sub_left, _) = geom(&mut s, short_row);
assert!(
long_h <= row_h + 0.5,
"the long name is one line inside its own row: name {long_h} px, row {row_h} px"
);
assert!((long_h - short_h).abs() < 0.5, "and so is the short one");
for (name, right, left) in [
("long", long_name_right, long_sub_left),
("short", short_name_right, short_sub_left),
] {
assert!(
(left - right - 10.0).abs() < 0.5,
"the {name} row's rank sits 10 px past its NAME's right edge (the reference's own \
offset), not at a fixed column: name right {right}, subtext left {left}"
);
}
assert!(
long_sub_left > short_sub_left + 50.0,
"so a longer name pushes its rank along instead of running under it: {long_sub_left} vs \
{short_sub_left}"
);
}
/// **B256 — "filter to Available, learn a spell, the filter comes back partly reset."** The window
/// is driven here through the real [`crate::ui_trainer::TrainerOpen`], so the packet→reset decision
/// under test is the app's own and not this test's: the closure below is the trainer feed's three
/// lines (`if open.fresh_list { reset } ; set_trainer ; fire`), and everything else is the shipped
/// Lua.
///
/// The reference cannot produce this bug because it never gets a list packet with the window open —
/// it repaints a purchase from a client-side state re-derivation (`0x4d7d40`, decision 1128 §4.2).
/// benilla re-asks the server instead, so the reference's per-packet mask reset (`0x4d75d9`) was
/// riding in on a packet the reference never sends, and taking the player's choice — and their
/// collapsed groups, which 1128 recorded as "a collapse does not survive a purchase" — with it.
#[test]
fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
use crate::ui_trainer::TrainerOpen;
const DAZALAR: u64 = 0xabc;
let mut s = trainer_script();
// The reference's own file-scope defaults (1128), then the player's choice below.
s.run(
"TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 0 \
BenillaTrainerFrame_ApplyFilter()",
)
.unwrap();
s.set_money(5000);
// The trainer feed (`ui_trainer::feed_trainer`), reduced to the part this bug lives in.
fn feed(s: &mut UiScript, open: &mut TrainerOpen, state: TrainerState, event: &str) {
if open.fresh_list {
s.reset_trainer_filter(open.trainer_type);
open.fresh_list = false;
}
s.set_trainer(Some(state));
s.fire_event(event, vec![ScriptValue::Str("Dazalar".into())]);
}
let filter_on = |s: &mut UiScript, kind: &str| {
s.eval::<bool>(&format!(
"return GetTrainerServiceTypeFilter('{kind}') == 1"
))
.unwrap()
};
let rows = |s: &mut UiScript| s.eval::<i64>("return GetNumTrainerServices()").unwrap();
// He opens the trainer and filters the list down to what he can actually learn.
let mut open = TrainerOpen::default();
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
feed(&mut s, &mut open, menu(), "TRAINER_SHOW");
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
assert!(
!filter_on(&mut s, "unavailable"),
"his choice reached the engine"
);
assert_eq!(
rows(&mut s),
4,
"two headers over the two learnable services"
);
// …and folds the first group away while he is at it.
s.run("CollapseTrainerSkillLine(1)").unwrap();
assert_eq!(rows(&mut s), 3, "the folded group keeps its header only");
// He trains. The app re-asks for the list (`trainer_buy_succeeded`) and marks its own answer as
// the repaint it is; the bought service comes back gray.
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
open.refresh_pending = true;
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!filter_on(&mut s, "unavailable"),
"learning a spell is a repaint, not a new window: his filter stands"
);
assert_eq!(
rows(&mut s),
2,
"and the list is still his — the folded Arms group is empty of learnables now, so it goes \
with its header, leaving Fury over Thunder Clap"
);
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
assert!(
!s.eval::<bool>("return DropDownList1Button2Check:IsVisible() and true or false")
.unwrap(),
"the dropdown agrees with the list, instead of claiming a filter the list ignores"
);
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
// He walks away and comes back: THIS packet opens a window, so the reference's reset lands —
// and the window's own OnShow/TRAINER_SHOW puts the saved globals straight back over it. The
// filter survives (it is saved), the collapse does not (it is engine-side, and the reset clears
// it) — the reference's own split.
open.clear();
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
feed(&mut s, &mut open, learned, "TRAINER_SHOW");
assert!(
!filter_on(&mut s, "unavailable"),
"the saved filter is pushed back over the packet reset"
);
assert!(
filter_on(&mut s, "available"),
"and the states he never turned off are still on"
);
assert_eq!(
rows(&mut s),
2,
"nothing collapsed now — but still filtered"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}