From baf4e370703b4aa76f7b146d1c8c9435e2863945 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 19 Sep 2026 17:09:59 +0700 Subject: [PATCH] the create screen marks your pick, and a new character inherits addons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the character-create screen marks the chosen race, gender and class again — a lit square and its name on the icon, as the reference's LockHighlight draws it; a query term had filtered all eighteen icons out since 2072 - a new character inherits the addon enables the realm's other characters agree on, as the reference computes them: a Disable All saved just before no longer comes back all-checked (2311) - an auction list-update event names one list: a first search on Browse no longer throws the stock Auctions tab's page error, and a sale or outbid notification never fetches a list the window never asked for (2308) Co-Authored-By: Claude Fable 5.1 --- .../benilla-app/src/capture/probe_auction.rs | 76 +++-- crates/benilla-app/src/char_create/mod.rs | 116 +++++++- crates/benilla-app/src/char_create/refresh.rs | 32 ++- crates/benilla-app/src/char_select/addons.rs | 231 +++++++++++---- crates/benilla-app/src/glue/widgets.rs | 8 + crates/benilla-app/src/ui_auction/mod.rs | 159 +++++++++-- crates/benilla-app/src/ui_auction/net.rs | 32 ++- crates/benilla-app/src/ui_script/addons.rs | 270 +++++++++++++++--- crates/benilla-app/src/ui_script/lifecycle.rs | 10 +- crates/benilla-app/src/ui_script/manifest.rs | 8 +- crates/benilla-app/tests/auction_frame.rs | 119 ++++++++ crates/benilla-ui/src/toc.rs | 19 ++ 12 files changed, 914 insertions(+), 166 deletions(-) diff --git a/crates/benilla-app/src/capture/probe_auction.rs b/crates/benilla-app/src/capture/probe_auction.rs index b8955717..8f50be6e 100644 --- a/crates/benilla-app/src/capture/probe_auction.rs +++ b/crates/benilla-app/src/capture/probe_auction.rs @@ -161,11 +161,20 @@ struct AuctionProbe { baseline_money: u32, /// `AUCTION_OWNED_LIST_UPDATE`'s count immediately before `StartAuction` went in. /// - /// Taken THERE and not when step (6) starts, because the STARTED result queues an owner - /// re-query of its own: by the time (6) is reached the event may already have fired, and a - /// baseline taken then would be waiting for a second one that a repeated identical page will - /// never produce. + /// Taken THERE and not when step (6) starts, so that an owner re-query queued by the STARTED + /// result cannot fire the event before the baseline is read — a baseline taken after it would + /// wait for a second event that a repeated identical page will never produce. (Since 2308 that + /// re-query only happens for a list we already hold, and step 6 is what first asks for this + /// one, so the ordering is belt and braces rather than load-bearing.) owned_event_baseline: i64, + /// How many red `UI_ERROR_MESSAGE` lines the run had already raised when the auction window + /// opened — the login environment's own, not the arc's. + /// + /// Step (6d) asserts the arc raised **no** red line, and it used to read the whole tally: on a + /// `probeN` account that is never zero, because the preflight's `GM mode is ON` and + /// `GOD mode is ON` banners (0679) are red lines too, and they land before the greeting. The + /// assertion was therefore FAILING on every clean run, which is worse than not making it. + red_baseline: i64, passes: u32, fails: u32, /// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame). @@ -287,6 +296,13 @@ fn last_error(script: &UiScript) -> String { /// /// Compared against `getglobal("ERR_AUCTION_STARTED")` rather than against English: the string is /// the player's own, and this file must not carry a copy of it. +/// Red `UI_ERROR_MESSAGE` lines raised so far, as the hook's own array counts them. +fn red_count(script: &UiScript) -> i64 { + script + .eval::("return table.getn(ProbeAuctionErrors)") + .unwrap_or(-1) +} + fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) { let said = script .eval::( @@ -298,9 +314,7 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) { return 0", ) .unwrap_or(-2); - let reds = script - .eval::("return table.getn(ProbeAuctionErrors)") - .unwrap_or(-1); + let reds = red_count(script) - probe.red_baseline; match (said, reds) { (1, 0) => { info!( @@ -317,13 +331,16 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) { probe.fails += 1; } (said, reds) => { + // The reds are listed from the baseline on, for the same reason `reds` counts from + // there: the login environment's own banners are not this arc's output. let lines = script - .eval::( - "local t = {} \ + .eval::(&format!( + "local t = {{}} \ for i = 1, table.getn(ProbeAuctionChat) do table.insert(t, ProbeAuctionChat[i]) end \ - for i = 1, table.getn(ProbeAuctionErrors) do table.insert(t, \"RED:\" .. ProbeAuctionErrors[i]) end \ + for i = {} + 1, table.getn(ProbeAuctionErrors) do table.insert(t, \"RED:\" .. ProbeAuctionErrors[i]) end \ return table.concat(t, \" | \")", - ) + probe.red_baseline.max(0) + )) .unwrap_or_default(); error!( "PROBE_AUCTION: FAIL (6d chat) — wanted the STARTED line in chat and zero red \ @@ -342,8 +359,15 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) { /// coin. Derived from `probe.min_bid` rather than hardcoded, because the listing price is computed /// from the item's own vendor value and a fixed expectation would be a lie the day the item changes. /// -/// `IsShown`, not `IsVisible`: the Auctions pane is behind the Browse tab at this point in the run -/// and the rows are painted either way — visibility would test the tab, not the coins. +/// `IsShown`, not `IsVisible`: the coin buttons' own shown-ness is the subject, not whatever the +/// window is doing around them. +/// +/// The pane is the *front* tab by the time this runs, and that is load-bearing rather than +/// incidental: the row is painted by `AuctionFrameAuctions_Update`, which cannot complete before +/// the tab's `OnShow` has given it a `page` (decision 2308). This check read the player's purse +/// out of the row's money frame for as long as the probe asked for the owned list without opening +/// the tab — the frame was still on its `MoneyTypeInfo["PLAYER"]` default because nothing had ever +/// repainted it. fn owner_money_check(script: &UiScript, probe: &mut AuctionProbe) { let (gold, silver, copper) = ( probe.min_bid / 10_000, @@ -601,6 +625,9 @@ fn auction_probe( auction.auctioneer.unwrap_or(0) ); probe.passes += 1; + // Everything red before this instant is the login environment (the GM/GOD + // banners); the arc's own tally starts here. + probe.red_baseline = red_count(&script); } else { error!( "PROBE_AUCTION: FAIL (2 greet) — the session opened, but on {:?} (we \ @@ -1151,10 +1178,27 @@ fn auction_probe( probe.phase = cancel_at(now); return; } - // Re-ask on a cadence: the server drops a second in-flight list request silently, and - // the STARTED result has already queued a refresh of its own. + // Re-ask on a cadence: the server drops a second in-flight list request silently. + // + // **The FIRST ask is the tab click, not the raw binding** (decision 2308). Every + // caller of `GetOwnerAuctionItems` in the stock files lives inside the Auctions pane — + // its `OnShow` and its two page turners — and the `OnShow` is also the one place that + // assigns `AuctionFrameAuctions.page`. Calling the binding cold puts the window in a + // state no click can reach, and the reply's `AUCTION_OWNED_LIST_UPDATE` then repaints + // a tab whose `page` is still nil: this probe raised + // `Blizzard_AuctionUI.lua:837: attempt to perform arithmetic on field 'page'` twice a + // run doing exactly that, at WARN, where nothing was looking. Clicking the tab is both + // the faithful drive ("exactly as a click would", this file's header) and what makes + // step (6c) a real assertion instead of a permanently red one. if last_ask == 0.0 || now - last_ask > OWNER_REASK_SECS { - crate::ui_script::run_or_warn(&script, "GetOwnerAuctionItems()"); + let ask = if last_ask == 0.0 { + // The tab's own OnShow issues the query, once per window session + // (`AuctionFrame.gotAuctions`), and sets the page the repaint needs. + "AuctionFrameTab_OnClick(3)" + } else { + "GetOwnerAuctionItems()" + }; + crate::ui_script::run_or_warn(&script, ask); probe.phase = Phase::OwnerList { since, last_ask: now, diff --git a/crates/benilla-app/src/char_create/mod.rs b/crates/benilla-app/src/char_create/mod.rs index 820dc3fd..27044fbc 100644 --- a/crates/benilla-app/src/char_create/mod.rs +++ b/crates/benilla-app/src/char_create/mod.rs @@ -326,7 +326,7 @@ impl CreateSelection { } /// One clickable control on the screen — a single component so one query dispatches every button. -#[derive(Component, Clone, Copy, PartialEq, Eq)] +#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)] enum CreateAction { Race(u8), Gender(u8), @@ -808,4 +808,118 @@ mod tests { "the booth look must differ by class, or the preview cannot re-dress" ); } + + /// **The chosen race / gender / class icon is LOCK-HIGHLIGHTED** — the reference's own verb: + /// `SetCharacterRace`, `SetCharacterClass` and `SetCharacterGender` each call + /// `button:LockHighlight()` on the one that is chosen and `UnlockHighlight()` on the rest + /// (`CharacterCreate.lua` l.171/254/326). In 1.12 that lock *is* the whole selected visual: + /// `CharacterCreateIconButtonTemplate` has its `` commented out, leaving the + /// ADD `ButtonHilight-Square` and the HIGHLIGHT-layer `$parentHighlightText` — both of which + /// the lock is what lights. + /// + /// **The regression this exists for** (director, 2026-09-17: *"it doesn't show that mage is + /// selected"*): 2072 moved the sheen to one owner and gave this screen's visuals query a + /// `&mut LockHighlight` term — but [`crate::glue::widgets::icon_button`], the only spawn site + /// for all eighteen of these icons, inserted no such component. A `&mut T` term is a filter, + /// so the query matched **nothing**: no selected sheen and no icon name on race, gender or + /// class, for ten days. Every existing sheen test hand-spawned the flag, so the consumer was + /// covered and the producer was not — hence this one builds the icons with the real + /// `icon_button` and runs the real system. + #[test] + fn the_chosen_icons_are_lock_highlighted() { + use crate::glue::art::GlueArt; + use crate::glue::widgets::{icon_button, LockHighlight}; + + fn spawn_icons(mut commands: Commands, art: Res) { + let font = Handle::::default(); + commands.spawn(Node::default()).with_children(|p| { + for race in ALLIANCE { + icon_button( + p, + &font, + CreateAction::Race(race), + None::, + None, + None::, + "", + &art, + 1.0, + ); + } + for sex in 0..2u8 { + icon_button( + p, + &font, + CreateAction::Gender(sex), + None::, + None, + None::, + "", + &art, + 1.0, + ); + } + for slot in 0..8u8 { + icon_button( + p, + &font, + CreateAction::ClassSlot(slot), + None::, + None, + None::, + "", + &art, + 1.0, + ); + } + }); + } + + let mut app = App::new(); + app.init_resource::() + // Human, female, MAGE — the director's own case. With no catalog loaded + // `race_classes` is the full list, so mage (8) is slot 6. + .insert_resource(CreateSelection { + race: 1, + sex: 1, + class: 8, + ..default() + }) + .add_systems(Startup, spawn_icons) + .add_systems(Update, refresh::refresh_hover); + app.update(); + + let locked: Vec = app + .world_mut() + .query::<(&CreateAction, &LockHighlight)>() + .iter(app.world()) + .filter(|(_, l)| l.0) + .map(|(a, _)| *a) + .collect(); + assert_eq!( + locked.len(), + 3, + "exactly one race, one gender and one class icon is locked — got {locked:?}" + ); + assert!(locked.contains(&CreateAction::Race(1)), "Human"); + assert!(locked.contains(&CreateAction::Gender(1)), "female"); + assert!( + locked.contains(&CreateAction::ClassSlot(6)), + "mage is slot 6 of [1,2,3,4,5,7,8,9,11] — and it is the mage icon the director saw \ + unmarked" + ); + + // …and the selection moving takes the lock with it, rather than lighting a second icon. + app.world_mut().resource_mut::().class = 1; // warrior, slot 0 + app.update(); + let locked: Vec = app + .world_mut() + .query::<(&CreateAction, &LockHighlight)>() + .iter(app.world()) + .filter(|(_, l)| l.0) + .map(|(a, _)| *a) + .collect(); + assert!(locked.contains(&CreateAction::ClassSlot(0))); + assert!(!locked.contains(&CreateAction::ClassSlot(6))); + } } diff --git a/crates/benilla-app/src/char_create/refresh.rs b/crates/benilla-app/src/char_create/refresh.rs index 508d0f19..c0e6bc7f 100644 --- a/crates/benilla-app/src/char_create/refresh.rs +++ b/crates/benilla-app/src/char_create/refresh.rs @@ -219,11 +219,17 @@ pub(super) fn refresh_dynamic( } } -/// Per-frame interaction visuals the CREATE screen owns: highlight overlays (hover — and held -/// while selected, the ref's `LockHighlight`), hover labels, and the Create button's disabled -/// latch while a create is in flight. The screen-agnostic passes — up/down art swaps, the glue -/// buttons' art + caption color, outline mirroring — are [`crate::glue`]'s, registered beside -/// this in the plugin chain. +/// Per-frame interaction visuals the CREATE screen owns: which icon is *chosen* (the ref's +/// `LockHighlight`, which [`crate::glue::glue_hilights`] then renders), hover labels, the no-art +/// fallback shade, and the Create button's disabled latch while a create is in flight. The +/// screen-agnostic passes — up/down art swaps, the glue buttons' art + caption color, outline +/// mirroring — are [`crate::glue`]'s, registered beside this in the plugin chain. +/// +/// **The chosen-icon write is its own query, deliberately.** It used to be a sixth term on the +/// hover query below, which meant a button without a [`LockHighlight`] dropped out of the hover +/// visuals too — and since no spawn site on this screen had one, that query matched *nothing*: +/// no selected sheen and no icon name anywhere on the create screen. Two jobs, two queries, so a +/// missing component can only ever cost its own job. #[allow(clippy::type_complexity)] pub(super) fn refresh_hover( sel: Res, @@ -238,10 +244,10 @@ pub(super) fn refresh_hover( &Children, &mut BackgroundColor, Has, - &mut LockHighlight, ), (With