GetAuctionItemInfo names the bidder on the player's own owner-list auctions, nil until the name lands, and 1 or nil on every other row (#259) (#492)

The auction house's Auctions tab now names the high bidder on each of your auctions, and shows "No Bids" only for an auction with none, as in 1.12.1; before, every auction read "No Bids".

`GetAuctionItemInfo`'s eleventh value follows `0x4cee40`: on the owner list, for a row the player owns (`0x4cf12a`-`0x4cf142`), it is the bidder's name from the name cache (`0x4cf144`-`0x4cf174`), nil with no bid (`0x4cf150`) or while the name has not arrived; on every other row it is 1 when the player holds the bid, else nil (`0x4cf17b`-`0x4cf1a0`). A bidder name that arrives later refreshes the list with `AUCTION_OWNED_LIST_UPDATE`, the event the reference's callback `0x4cf2b0` fires. vmangos sends the bidder guid with each row (`AuctionHouseMgr.cpp:840`).

Fixes #259

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 11:40:53 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a6cbb932fa
commit 476da9b2f0
5 changed files with 342 additions and 20 deletions
+246 -9
View File
@@ -11,8 +11,8 @@ use bevy::prelude::*;
use benilla_protocol::messages::{auction_filter, AuctionListEntry};
use benilla_ui::script::{
AuctionCategory, AuctionItemRow, AuctionListState, AuctionState, AuctionSubCategory, UiScript,
BIDDER, LIST, OWNER,
AuctionCategory, AuctionHighBidder, AuctionItemRow, AuctionListState, AuctionState,
AuctionSubCategory, UiScript, BIDDER, LIST, OWNER,
};
use crate::entities::ItemDisplays;
@@ -87,8 +87,10 @@ pub(crate) struct AuctionRow {
pub(crate) current_bid: u32,
/// The time-left bucket, `1..=4`.
pub(crate) time_left: u32,
/// Whether the player holds the high bid.
pub(crate) high_bidder: bool,
/// Whether the player holds the high bid; the status sort's key.
pub(crate) player_holds_bid: bool,
/// `GetAuctionItemInfo`'s eleventh value, per [`high_bidder`].
pub(crate) high_bidder: AuctionHighBidder,
pub(crate) owner: Option<String>,
pub(crate) link: Option<String>,
}
@@ -301,9 +303,41 @@ fn time_left_bucket(ms: u32) -> u32 {
}
}
/// One wire entry as a display row, through the ask-once template and name caches; a `None`
/// fills in when its answer lands. The link carries the row's enchant, property and suffix.
/// `GetAuctionItemInfo`'s `highBidder` for a row of `list` (`0x4cf12a`–`0x4cf1a0`). On an
/// `"owner"` row the player owns (`0x4cf131`–`0x4cf142` compare its owner guid with the player's),
/// the bidder guid (vmangos `AuctionHouseMgr.cpp:840`) goes through the name cache: nil with no
/// bid (`0x4cf150`) or until the asked name lands (`0x4cf16e`), whose arrival re-fires the owned
/// list's update (`0x4cf2b0`), here the feed's diff. Any other row: `1` if the player holds the
/// bid, else nil (`0x4cf17b`–`0x4cf1a0`).
fn high_bidder(
list: usize,
entry: &AuctionListEntry,
self_guid: Option<u64>,
names: &NameCache,
commands: &NetCommands,
) -> AuctionHighBidder {
let owned = list == OWNER && self_guid.is_some_and(|g| g == entry.owner_guid);
if owned {
if entry.bidder_guid == 0 {
return AuctionHighBidder::Nil;
}
return names
.resolve(entry.bidder_guid, commands)
.map_or(AuctionHighBidder::Nil, |n| {
AuctionHighBidder::Name(n.to_string())
});
}
if self_guid.is_some_and(|g| g == entry.bidder_guid) {
AuctionHighBidder::Player
} else {
AuctionHighBidder::Nil
}
}
/// One wire entry of `list` as a display row, through the ask-once template and name caches; a
/// `None` fills in when its answer lands. The link carries the row's enchant, property and suffix.
fn resolve_row(
list: usize,
entry: &AuctionListEntry,
self_guid: Option<u64>,
items: &Items,
@@ -343,7 +377,8 @@ fn resolve_row(
buyout: entry.buyout,
current_bid: entry.current_bid,
time_left: time_left_bucket(entry.time_left_ms),
high_bidder: self_guid.is_some_and(|g| g == entry.bidder_guid),
player_holds_bid: self_guid.is_some_and(|g| g == entry.bidder_guid),
high_bidder: high_bidder(list, entry, self_guid, names, commands),
owner: names
.resolve(entry.owner_guid, commands)
.map(str::to_string),
@@ -390,7 +425,9 @@ pub(crate) fn categories(
.collect()
}
/// List `list`'s rows in display order.
fn rows_for(
list: usize,
slot: &AuctionListSlot,
self_guid: Option<u64>,
items: &Items,
@@ -402,7 +439,7 @@ fn rows_for(
let mut rows: Vec<AuctionRow> = slot
.entries
.iter()
.map(|e| resolve_row(e, self_guid, items, icons, names, commands, rolls))
.map(|e| resolve_row(list, e, self_guid, items, icons, names, commands, rolls))
.collect();
slot.sort.apply(&mut rows);
rows
@@ -422,7 +459,7 @@ fn to_script_row(r: &AuctionRow) -> AuctionItemRow {
min_increment: r.min_increment,
buyout_price: r.buyout,
bid_amount: r.current_bid,
high_bidder: r.high_bidder,
high_bidder: r.high_bidder.clone(),
owner: r.owner.clone(),
time_left: r.time_left,
link: r.link.clone(),
@@ -512,6 +549,7 @@ fn feed_auction(
let lists = [LIST, BIDDER, OWNER].map(|i| {
let slot = &auction.lists[i];
let rows = rows_for(
i,
slot,
self_guid,
&items,
@@ -685,6 +723,7 @@ fn drain_auction(
if !bids.is_empty() || !cancels.is_empty() {
for bid in bids {
let rows = rows_for(
bid.list,
&auction.lists[bid.list],
self_guid,
&items,
@@ -703,6 +742,7 @@ fn drain_auction(
}
for index in cancels {
let rows = rows_for(
OWNER,
&auction.lists[OWNER],
self_guid,
&items,
@@ -900,6 +940,203 @@ mod tests {
assert!(!open.sell_slot_taken);
}
const ME: u64 = 0x77;
const BIDDER_GUID: u64 = 0x99;
/// A wire row owned by `owner` with `bidder` on it (0 for no bid).
fn entry(owner: u64, bidder: u64) -> AuctionListEntry {
AuctionListEntry {
auction_id: 1,
item_entry: 2589,
perm_enchant: 0,
random_property_id: 0,
suffix_factor: 0,
count: 1,
spell_charges: 0,
owner_guid: owner,
start_bid: 1000,
min_increment: if bidder == 0 { 0 } else { 125 },
buyout: 0,
time_left_ms: 60_000,
bidder_guid: bidder,
current_bid: if bidder == 0 { 0 } else { 2500 },
}
}
fn name_queries(rx: &crossbeam_channel::Receiver<ClientCommand>) -> Vec<u64> {
rx.try_iter()
.filter_map(|c| match c {
ClientCommand::NameQuery { guid } => Some(guid),
_ => None,
})
.collect()
}
/// The player's own auction names its bidder from the cache (`0x4cf144`–`0x4cf174`).
#[test]
fn an_owned_row_names_its_bidder() {
let (tx, rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut names = NameCache::default();
names.insert_player(BIDDER_GUID, "Bidder".into(), None);
assert_eq!(
high_bidder(OWNER, &entry(ME, BIDDER_GUID), Some(ME), &names, &commands),
AuctionHighBidder::Name("Bidder".into())
);
assert!(name_queries(&rx).is_empty(), "a cached name asks nothing");
}
/// An unknown bidder is nil and asked for; no bid is nil and asks nothing (`0x4cf150`).
#[test]
fn an_owned_row_is_nil_until_the_name_lands_and_with_no_bid() {
let (tx, rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut names = NameCache::default();
assert_eq!(
high_bidder(OWNER, &entry(ME, BIDDER_GUID), Some(ME), &names, &commands),
AuctionHighBidder::Nil
);
assert_eq!(
name_queries(&rx),
vec![BIDDER_GUID],
"the name goes out once"
);
assert_eq!(
high_bidder(OWNER, &entry(ME, 0), Some(ME), &names, &commands),
AuctionHighBidder::Nil
);
assert!(name_queries(&rx).is_empty(), "no bidder, no query");
names.insert_player(BIDDER_GUID, "Bidder".into(), None);
assert_eq!(
high_bidder(OWNER, &entry(ME, BIDDER_GUID), Some(ME), &names, &commands),
AuctionHighBidder::Name("Bidder".into())
);
}
/// Browse, Bids and an owner row the player does not own answer `1` or nil, never a name,
/// even with the bidder's name cached (`0x4cf17b`–`0x4cf1a0`).
#[test]
fn other_rows_answer_one_or_nil() {
let (tx, rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut names = NameCache::default();
names.insert_player(BIDDER_GUID, "Bidder".into(), None);
const SELLER: u64 = 0x55;
for list in [LIST, BIDDER] {
assert_eq!(
high_bidder(list, &entry(SELLER, ME), Some(ME), &names, &commands),
AuctionHighBidder::Player
);
assert_eq!(
high_bidder(
list,
&entry(SELLER, BIDDER_GUID),
Some(ME),
&names,
&commands
),
AuctionHighBidder::Nil
);
assert_eq!(
high_bidder(list, &entry(SELLER, 0), Some(ME), &names, &commands),
AuctionHighBidder::Nil
);
// The player's own auction, seen on a browse page, is not named either.
assert_eq!(
high_bidder(list, &entry(ME, BIDDER_GUID), Some(ME), &names, &commands),
AuctionHighBidder::Nil
);
}
// The owner list gates on the row's owner, not the list alone (`0x4cf131`).
assert_eq!(
high_bidder(OWNER, &entry(SELLER, ME), Some(ME), &names, &commands),
AuctionHighBidder::Player
);
assert_eq!(
high_bidder(
OWNER,
&entry(SELLER, BIDDER_GUID),
Some(ME),
&names,
&commands
),
AuctionHighBidder::Nil
);
assert!(name_queries(&rx).is_empty());
}
/// A bidder's name landing after the owned list re-fires `AUCTION_OWNED_LIST_UPDATE` and the
/// row then answers it (the `0x4cf2b0` callback fires event `0x1a9`).
#[test]
fn a_landed_bidder_name_refires_the_owned_list() {
let mut app = App::new();
app.init_resource::<AuctionOpen>()
.init_resource::<Items>()
.init_resource::<NameCache>()
.init_resource::<Time>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>();
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx));
app.world_mut()
.resource_mut::<NameCache>()
.insert_player(ME, "Seller".into(), None);
let script = UiScript::new().unwrap();
script
.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("AUCTION_OWNED_LIST_UPDATE")
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
"#,
)
.unwrap();
app.insert_non_send_resource(script);
app.world_mut().spawn((SelfPlayer, crate::net::Guid(ME)));
{
let mut open = app.world_mut().resource_mut::<AuctionOpen>();
open.open(0x1234, 1);
open.set_list(OWNER, vec![entry(ME, BIDDER_GUID)], 1);
}
// One registered system, so its `VmMemo` locals persist from frame to frame.
let feed = app.world_mut().register_system(feed_auction);
let step = |app: &mut App| -> (Vec<String>, Option<String>) {
app.world_mut().run_system(feed).unwrap();
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.resolve();
let seen = s.eval::<Vec<String>>("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
let bidder = s
.eval::<Option<String>>(
r#"local _,_,_,_,_,_,_,_,_,_,h = GetAuctionItemInfo("owner", 1) return h"#,
)
.unwrap();
(seen, bidder)
};
// The window opens with the page; the bidder is unknown, so nil and asked for.
let (_, bidder) = step(&mut app);
assert_eq!(bidder, None);
assert_eq!(name_queries(&rx), vec![BIDDER_GUID]);
let (seen, bidder) = step(&mut app);
assert!(seen.is_empty(), "nothing new, nothing fired: {seen:?}");
assert_eq!(bidder, None);
app.world_mut().resource_mut::<NameCache>().insert_player(
BIDDER_GUID,
"Bidder".into(),
None,
);
let (seen, bidder) = step(&mut app);
assert_eq!(bidder.as_deref(), Some("Bidder"));
assert_eq!(seen, vec!["AUCTION_OWNED_LIST_UPDATE".to_string()]);
}
#[test]
fn an_unbid_row_shows_its_opening_price() {
let unbid = AuctionRow {
+3 -3
View File
@@ -69,7 +69,7 @@ fn compare_by(key: &str, a: &AuctionRow, b: &AuctionRow) -> Ordering {
// Highest first, which also sinks a 0 (no buyout) to the bottom.
"buyout" => b.buyout.cmp(&a.buyout),
// Our own bids first; the reference also ranks another bidder's above no bid.
"status" => b.high_bidder.cmp(&a.high_bidder),
"status" => b.player_holds_bid.cmp(&a.player_holds_bid),
"name" => name_of(a).cmp(name_of(b)),
"seller" => owner_of(a).cmp(owner_of(b)),
_ => Ordering::Equal,
@@ -179,13 +179,13 @@ mod tests {
let mut rows = vec![
AuctionRow {
quality: Some(1),
high_bidder: false,
player_holds_bid: false,
name: Some("Common".into()),
..Default::default()
},
AuctionRow {
quality: Some(4),
high_bidder: true,
player_holds_bid: true,
name: Some("Epic".into()),
..Default::default()
},
+46 -3
View File
@@ -2,8 +2,8 @@
//! `AuctionState` and the app's own show and list events, and asserts what it paints and queues.
use benilla_ui::script::{
AuctionCategory, AuctionItemRow, AuctionListState, AuctionState, AuctionSubCategory, UiScript,
UnitState, BIDDER, LIST, OWNER,
AuctionCategory, AuctionHighBidder, AuctionItemRow, AuctionListState, AuctionState,
AuctionSubCategory, UiScript, UnitState, BIDDER, LIST, OWNER,
};
mod common;
@@ -78,7 +78,7 @@ fn row(name: &str, min_bid: u32, buyout: u32, bid: u32, owner: &str) -> AuctionI
min_increment: if bid > 0 { 100 } else { 0 },
buyout_price: buyout,
bid_amount: bid,
high_bidder: false,
high_bidder: AuctionHighBidder::Nil,
owner: Some(owner.to_string()),
time_left: 4,
link: Some("|cffffffff|Hitem:2589:0:0:0|h[Linen Cloth]|h|r".into()),
@@ -279,6 +279,49 @@ fn the_auctions_tab_cannot_repaint_before_it_has_been_shown() {
);
}
/// The Auctions tab's High Bidder column prints `GetAuctionItemInfo`'s eleventh value, and
/// `NO_BIDS` in red only for nil (`AuctionFrameAuctions_Update`): a bid-on auction names its
/// bidder, as the reference answers on the owner list (`0x4cf144`–`0x4cf174`).
#[test]
fn the_auctions_tab_names_the_high_bidder() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
seat_player(&mut s, 500_000);
let mut bid_on = row("Linen Cloth", 1000, 5000, 2500, "Buyer");
bid_on.auction_id = 1;
bid_on.high_bidder = AuctionHighBidder::Name("Bidder".into());
let mut unbid = row("Wool Cloth", 1000, 0, 0, "Buyer");
unbid.auction_id = 2;
let mut snapshot = state(Vec::new());
snapshot.lists[OWNER] = AuctionListState {
rows: vec![bid_on, unbid],
total: 2,
sort: Vec::new(),
};
s.set_auction(Some(snapshot));
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
s.run("AuctionFrameTab_OnClick(3)").unwrap();
s.fire_event("AUCTION_OWNED_LIST_UPDATE", vec![]);
assert_eq!(
s.eval::<String>("return AuctionsButton1HighBidder:GetText()")
.unwrap(),
"Bidder",
"a bid-on auction names its bidder, not No Bids"
);
assert!(
s.eval::<bool>(
"return AuctionsButton2HighBidder:GetText() \
== RED_FONT_COLOR_CODE..NO_BIDS..FONT_COLOR_CODE_CLOSE"
)
.unwrap(),
"an unbid auction reads No Bids in red"
);
assert!(s.errors().is_empty(), "clean repaint: {:?}", s.errors());
}
/// Each pane assigns `page` only in its `OnShow`. `AuctionFrameBid` alone is declared
/// `hidden="false"` (`Blizzard_AuctionUI.xml:960`), so it shows with the window on the first
/// `AUCTION_HOUSE_SHOW` before tab 1 hides it; the Auctions pane has no `page` until tab 3.
+46 -4
View File
@@ -59,8 +59,8 @@ pub struct AuctionItemRow {
pub buyout_price: u32,
/// The current high bid; 0 before any bid.
pub bid_amount: u32,
/// Whether the player holds the high bid: a flag, not a name.
pub high_bidder: bool,
/// `GetAuctionItemInfo`'s eleventh value, which the app picks per list and row.
pub high_bidder: AuctionHighBidder,
/// The seller's name; `None` while the name query is in flight.
pub owner: Option<String>,
/// The time-left bucket, `1..=4` (Short, Medium, Long, Very Long); 0 when not known yet.
@@ -69,6 +69,20 @@ pub struct AuctionItemRow {
pub link: Option<String>,
}
/// `GetAuctionItemInfo`'s `highBidder` (`0x4cf12a`–`0x4cf1a0`): on an `"owner"` row the player
/// owns, the bidder's name from the name cache; on any other row, `1` when the player holds the
/// bid. Nil otherwise, which the stock Auctions tab prints as `NO_BIDS`.
#[derive(Clone, Debug, Default, PartialEq)]
pub enum AuctionHighBidder {
/// No bid, another player's bid on a row the player does not own, or a name not arrived.
#[default]
Nil,
/// The player holds the bid on a row it does not own: `1`.
Player,
/// The bidder on the player's own auction, by name.
Name(String),
}
/// One of the three lists: the batch the server sent, the pre-cap match count and its sort stack.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AuctionListState {
@@ -303,7 +317,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetAuctionItemInfo(type, index) → twelve values; `highBidder` is a flag, not the seller.
// GetAuctionItemInfo(type, index) → twelve values; `highBidder` is a name, `1` or nil.
g.set(
"GetAuctionItemInfo",
lua.create_function(|lua, (kind, index): (String, usize)| {
@@ -354,7 +368,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Value::Integer(i64::from(r.min_increment)),
Value::Integer(i64::from(r.buyout_price)),
Value::Integer(i64::from(r.bid_amount)),
flag(r.high_bidder),
match &r.high_bidder {
AuctionHighBidder::Nil => Value::Nil,
AuctionHighBidder::Player => flag(true),
AuctionHighBidder::Name(n) => Value::String(lua.create_string(n)?),
},
opt_str(&r.owner)?,
]))
})?,
@@ -918,6 +936,30 @@ mod tests {
assert_eq!(n, 0, "zero values, not one nil");
}
/// The eleventh value is the name, `1` or nil as the app picked it (`0x4cf12a`–`0x4cf1a0`).
#[test]
fn high_bidder_answers_a_name_a_one_or_nil() {
let mut s = UiScript::new().unwrap();
let mut state = page(&[1, 2, 3]);
state.lists[LIST].rows[0].high_bidder = AuctionHighBidder::Name("Bidder".into());
state.lists[LIST].rows[1].high_bidder = AuctionHighBidder::Player;
s.set_auction(Some(state));
let eleventh = |i: usize| {
s.eval::<Value>(&format!(
r#"local _,_,_,_,_,_,_,_,_,_,h = GetAuctionItemInfo("list", {i}) return h"#
))
.unwrap()
};
assert_eq!(
eleventh(1)
.as_string()
.map(|v| v.to_str().unwrap().to_string()),
Some("Bidder".into())
);
assert_eq!(eleventh(2), Value::Integer(1));
assert_eq!(eleventh(3), Value::Nil);
}
/// A 9-copper stack at 5% over 24 h deposits 0 here, where vmangos charges 5.
#[test]
fn the_deposit_is_the_clients_arithmetic_truncation_and_all() {
+1 -1
View File
@@ -144,7 +144,7 @@ pub use addon::AddOnInfo;
pub use addon_enable::EnableHash;
pub use addon_message::{AddonDistribution, AddonSend};
pub use auction::{
AuctionBid, AuctionCategory, AuctionItemRow, AuctionListState, AuctionQuery,
AuctionBid, AuctionCategory, AuctionHighBidder, AuctionItemRow, AuctionListState, AuctionQuery,
AuctionStartRequest, AuctionState, AuctionSubCategory, BIDDER, LIST, OWNER, SORT_KEYS,
};
pub use aura::{AuraState, TrackingState};