In 1.12.1 an event a Lua verb raises runs its handlers before the verb returns: `SignalEvent` (`0x703e50`) walks the listeners in place. About 35 benilla binding sites queued theirs for a later drain instead. - Verbs now raise inside `tick::verb` steps, and the events fire as each step's model borrow drops. A step follows the reference's split wherever it signals before changing state a getter reads: the money pickup, bag pickup and place, merchant grab, repair cursor, item use, action and pet-bar pickup and drop. - The grid events fire before `CURSOR_UPDATE`, and a clear's unlock before both, as at `0x4952b3`/`0x495455`. `ITEM_LOCK_CHANGED` carries no arguments. - `pending_events` is host-only now. Tests pin handler order per family, nesting from a handler, and the order changes. Fixes #999 Fixes #582 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1080 lines
41 KiB
Rust
1080 lines
41 KiB
Rust
//! The auction-house bindings: the app pushes a snapshot of three lists, rows already resolved and
|
||
//! in display order, and the Lua calls queue intents the app drains. Getters are keyed by `"list"`
|
||
//! (Browse), `"bidder"` (Bids) or `"owner"` (Auctions), with `index` 1-based in the 50-row batch.
|
||
//!
|
||
//! The wire carries no sort: each list keeps an 8-deep stack of `(key, reversed)`, where clicking
|
||
//! the primary column toggles it and another column is promoted with the direction it remembers.
|
||
|
||
use mlua::{Lua, MultiValue, Value};
|
||
|
||
use super::binding_abi::flag;
|
||
use super::cursor::{self, CursorPayload};
|
||
use super::Model;
|
||
|
||
/// The three list types, in the order the app pushes them.
|
||
pub const LIST: usize = 0;
|
||
pub const BIDDER: usize = 1;
|
||
pub const OWNER: usize = 2;
|
||
|
||
/// The eight sort keys the reference's column headers pass, in the API's order.
|
||
pub const SORT_KEYS: [&str; 8] = [
|
||
"level", "quality", "bid", "duration", "buyout", "status", "name", "seller",
|
||
];
|
||
|
||
/// A list type's snapshot index, for the other modules (the tooltip's `SetAuctionItem`).
|
||
pub(super) fn list_index_of(kind: &str) -> Option<usize> {
|
||
list_index(kind)
|
||
}
|
||
|
||
fn list_index(kind: &str) -> Option<usize> {
|
||
match kind {
|
||
"list" => Some(LIST),
|
||
"bidder" => Some(BIDDER),
|
||
"owner" => Some(OWNER),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// One auction row, resolved by the app from a wire record.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct AuctionItemRow {
|
||
/// The wire auction id a bid or a cancel addresses; never shown.
|
||
pub auction_id: u32,
|
||
pub item_id: u32,
|
||
/// The wire's `randomPropertyId`, 0 when unrolled; [`Self::name`] already carries its suffix,
|
||
/// and the reference's `SetAuctionItem` writes it into the tooltip's `+0x424`.
|
||
pub random_property_id: u32,
|
||
/// `None` while the item template query is in flight.
|
||
pub name: Option<String>,
|
||
pub texture: Option<String>,
|
||
pub count: u32,
|
||
pub quality: Option<u32>,
|
||
/// `RequiredLevel`, printed red above the player's level.
|
||
pub level: u32,
|
||
/// The seller's opening price, shown as the current bid while [`Self::bid_amount`] is 0.
|
||
pub min_bid: u32,
|
||
/// The minimum step above the current bid; 0 before any bid.
|
||
pub min_increment: u32,
|
||
/// `0` = no buyout.
|
||
pub buyout_price: u32,
|
||
/// The current high bid; 0 before any bid.
|
||
pub bid_amount: u32,
|
||
/// `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.
|
||
pub time_left: u32,
|
||
/// The full item link: auction rows carry entry, enchant, random property and suffix.
|
||
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 {
|
||
/// The current batch in display order, at most 50 (the server's page cap).
|
||
pub rows: Vec<AuctionItemRow>,
|
||
/// `totalAuctions`: the match count before the 50-row cap.
|
||
pub total: u32,
|
||
/// The most-recently-clicked sort stack, primary first: `(key, reversed)`.
|
||
pub sort: Vec<(String, bool)>,
|
||
}
|
||
|
||
impl AuctionListState {
|
||
/// This list's remembered direction for `key`, or `false` for a key it has never sorted by.
|
||
fn reversed(&self, key: &str) -> bool {
|
||
self.sort
|
||
.iter()
|
||
.find(|(k, _)| k == key)
|
||
.is_some_and(|(_, rev)| *rev)
|
||
}
|
||
}
|
||
|
||
/// One class of the Browse tab's category tree, from the player's `ItemClass.dbc`.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct AuctionCategory {
|
||
/// The wire's `mainCategory`: an item class id, not the menu position.
|
||
pub class_id: u32,
|
||
pub name: String,
|
||
/// The `0x807060` table's second column. False (Consumable, Trade Goods, Reagent,
|
||
/// Miscellaneous) empties `GetAuctionItemSubClasses` and `GetAuctionInvTypes`, but
|
||
/// `QueryAuctionItems` never reads it.
|
||
pub has_subclass_filter: bool,
|
||
/// The class's `ItemSubClass.dbc` rows with `DisplayFlags & 2` clear, in file order: what both
|
||
/// the menu and the query's index scan walk.
|
||
pub subclasses: Vec<AuctionSubCategory>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct AuctionSubCategory {
|
||
/// The wire's `subCategory`.
|
||
pub sub_id: u32,
|
||
pub name: String,
|
||
/// `ItemSubClass.Flags & 0x200` (`0x4cfb63`): whether the subclass offers the 14
|
||
/// inventory-slot rows.
|
||
pub has_inv_types: bool,
|
||
}
|
||
|
||
/// The open auction house's snapshot, pushed whole by the app.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct AuctionState {
|
||
/// Browse / Bids / Auctions, indexed by [`LIST`], [`BIDDER`], [`OWNER`].
|
||
pub lists: [AuctionListState; 3],
|
||
/// `GetAuctionHouseDepositRate()`: the house's `AuctionHouse.dbc` deposit percentage.
|
||
pub deposit_percent: u32,
|
||
}
|
||
|
||
/// A drained `QueryAuctionItems`, the `CMSG_AUCTION_LIST_ITEMS` filters: each is already a wire id,
|
||
/// mapped from the Lua's menu position as `0x4ce980` does.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct AuctionQuery {
|
||
pub name: String,
|
||
/// `0` = no filter, for both.
|
||
pub min_level: u32,
|
||
pub max_level: u32,
|
||
/// `None` = no filter (the wire's `0xFFFFFFFF`), for all three.
|
||
pub inv_type: Option<u32>,
|
||
pub class: Option<u32>,
|
||
pub sub_class: Option<u32>,
|
||
/// A minimum quality, not an equality; `None` = no filter (the dropdown's All, -1).
|
||
pub quality: Option<u32>,
|
||
/// 0-based; the app multiplies by 50 for the wire's item offset.
|
||
pub page: u32,
|
||
pub usable_only: bool,
|
||
}
|
||
|
||
/// A drained `StartAuction`.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct AuctionStartRequest {
|
||
pub min_bid: u32,
|
||
/// `0` = no buyout.
|
||
pub buyout: u32,
|
||
/// Minutes: 120, 480 or 1440, the only values the reference sends.
|
||
pub duration: u32,
|
||
}
|
||
|
||
/// A drained `PlaceAuctionBid`.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct AuctionBid {
|
||
/// [`LIST`] or [`BIDDER`]: the Auctions tab has no bid button.
|
||
pub list: usize,
|
||
/// 1-based row within the current batch; the app maps it to the wire auction id.
|
||
pub index: u32,
|
||
pub amount: u32,
|
||
}
|
||
|
||
impl super::UiScript {
|
||
/// Push the Browse tab's class tree. Login-scoped: the stock `AuctionFrameBrowse_OnLoad` reads
|
||
/// the classes when the addon loads, before any auctioneer session.
|
||
pub fn set_auction_item_classes(&mut self, classes: Vec<AuctionCategory>) {
|
||
self.model_mut().auction_item_classes = classes;
|
||
}
|
||
|
||
pub fn set_auction(&mut self, state: Option<AuctionState>) {
|
||
let mut model = self.model_mut();
|
||
// Only opening or closing the session drops the selection: it is an auction id, so it
|
||
// survives a new page or a re-sort and stops resolving when the auction leaves the page.
|
||
if model.auction.is_none() || state.is_none() {
|
||
model.auction_selected = [0; 3];
|
||
}
|
||
model.auction = state;
|
||
}
|
||
|
||
/// Push `CanSendAuctionQuery()`'s answer, the app's query throttle.
|
||
pub fn set_auction_can_query(&mut self, can: bool) {
|
||
self.model_mut().auction_can_query = can;
|
||
}
|
||
|
||
/// Drain the Browse search the Lua queued, if any.
|
||
pub fn take_auction_query(&mut self) -> Option<AuctionQuery> {
|
||
self.model_mut().auction_query.take()
|
||
}
|
||
|
||
/// Drain the owner-list page request (`GetOwnerAuctionItems`), 0-based.
|
||
pub fn take_auction_owner_query(&mut self) -> Option<u32> {
|
||
self.model_mut().auction_owner_query.take()
|
||
}
|
||
|
||
/// Drain the bidder-list page request (`GetBidderAuctionItems`), 0-based.
|
||
pub fn take_auction_bidder_query(&mut self) -> Option<u32> {
|
||
self.model_mut().auction_bidder_query.take()
|
||
}
|
||
|
||
/// Drain the bids placed since the last drain.
|
||
pub fn take_auction_bids(&mut self) -> Vec<AuctionBid> {
|
||
std::mem::take(&mut self.model_mut().auction_bids)
|
||
}
|
||
|
||
/// Drain the 1-based owner-row indices `CancelAuction` was called on.
|
||
pub fn take_auction_cancels(&mut self) -> Vec<u32> {
|
||
std::mem::take(&mut self.model_mut().auction_cancels)
|
||
}
|
||
|
||
/// Drain a pending `StartAuction`.
|
||
pub fn take_auction_start(&mut self) -> Option<AuctionStartRequest> {
|
||
self.model_mut().auction_start.take()
|
||
}
|
||
|
||
/// Drain the header clicks: `(list index, sort key)`, in click order. The app owns the stacks.
|
||
pub fn take_auction_sorts(&mut self) -> Vec<(usize, String)> {
|
||
std::mem::take(&mut self.model_mut().auction_sorts)
|
||
}
|
||
|
||
/// Whether `CloseAuctionHouse` was called since the last drain (and clear the flag).
|
||
pub fn take_auction_close(&mut self) -> bool {
|
||
std::mem::take(&mut self.model_mut().auction_close)
|
||
}
|
||
|
||
/// The sell slot's `(bag, slot)`, resolved by the app to the item guid on `StartAuction`.
|
||
pub fn auction_sell_item(&mut self) -> Option<(i64, u32)> {
|
||
self.model_mut()
|
||
.auction_sell_item
|
||
.as_ref()
|
||
.map(|it| (it.bag, it.slot))
|
||
}
|
||
|
||
/// Empty the sell slot, once the auction is sent and on session close.
|
||
pub fn clear_auction_sell_item(&mut self) {
|
||
self.model_mut().auction_sell_item = None;
|
||
}
|
||
}
|
||
|
||
fn row_at(model: &Model, kind: &str, index: usize) -> Option<AuctionItemRow> {
|
||
let i = list_index(kind)?;
|
||
model
|
||
.auction
|
||
.as_ref()
|
||
.and_then(|a| index.checked_sub(1).and_then(|n| a.lists[i].rows.get(n)))
|
||
.cloned()
|
||
}
|
||
|
||
/// The sell slot's `(name, texture, count, quality, canUse, stackValue)`, `stackValue` being the
|
||
/// vendor price times the stack: the base of the suggested opening price and the deposit.
|
||
fn sell_item_info(model: &Model) -> Option<(String, Option<String>, u32, i64, bool, u32)> {
|
||
let it = model.auction_sell_item.as_ref()?;
|
||
// The real stack size, not the split-carry field: the deposit is per stack.
|
||
let count = cursor::held_count(model, it);
|
||
let template = model.item_templates.get(&it.item_id);
|
||
let name = cursor::item_link_name(it.link.as_deref());
|
||
let quality = it.quality.map_or(-1, i64::from);
|
||
let sell_price = template.map_or(0, |v| v.sell_price);
|
||
let can_use = super::item_stats::item_usable_by_id(model, it.item_id);
|
||
Some((
|
||
name,
|
||
it.texture.clone(),
|
||
count,
|
||
quality,
|
||
can_use,
|
||
sell_price.saturating_mul(count),
|
||
))
|
||
}
|
||
|
||
/// The reference client's deposit, `floor(rate × stackValue / 100) × floor(minutes / 120)`: the
|
||
/// inner floor zeroes a cheap stack the server still charges a few copper for.
|
||
fn deposit_for(rate: u32, stack_value: u32, minutes: u32) -> u32 {
|
||
let per_unit = u64::from(rate).saturating_mul(u64::from(stack_value)) / 100;
|
||
let units = u64::from(minutes) / 120;
|
||
u32::try_from(per_unit.saturating_mul(units)).unwrap_or(u32::MAX)
|
||
}
|
||
|
||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||
let g = lua.globals();
|
||
|
||
// GetNumAuctionItems(type) → numBatchAuctions (at most 50), totalAuctions (before the cap).
|
||
g.set(
|
||
"GetNumAuctionItems",
|
||
lua.create_function(|lua, kind: String| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let Some((batch, total)) = list_index(&kind).and_then(|i| {
|
||
model
|
||
.auction
|
||
.as_ref()
|
||
.map(|a| (a.lists[i].rows.len() as i64, i64::from(a.lists[i].total)))
|
||
}) else {
|
||
return Ok(MultiValue::from_vec(vec![
|
||
Value::Integer(0),
|
||
Value::Integer(0),
|
||
]));
|
||
};
|
||
Ok(MultiValue::from_vec(vec![
|
||
Value::Integer(batch),
|
||
Value::Integer(total.max(batch)),
|
||
]))
|
||
})?,
|
||
)?;
|
||
|
||
// GetAuctionItemInfo(type, index) → twelve values; `highBidder` is a name, `1` or nil.
|
||
g.set(
|
||
"GetAuctionItemInfo",
|
||
lua.create_function(|lua, (kind, index): (String, usize)| {
|
||
let row = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
row_at(&model, &kind, index)
|
||
};
|
||
let Some(r) = row else {
|
||
// A miss (unknown type, out of range, not cached) still answers twelve values, with
|
||
// `count = 1` and `quality = -1` (one shared exit, `0x4cf1ec`), read unguarded.
|
||
return Ok(MultiValue::from_vec(vec![
|
||
Value::Nil, // name
|
||
Value::Nil, // texture
|
||
Value::Integer(1), // count
|
||
Value::Integer(-1), // quality
|
||
Value::Nil, // canUse
|
||
Value::Integer(0), // level
|
||
Value::Integer(0), // minBid
|
||
Value::Integer(0), // minIncrement
|
||
Value::Integer(0), // buyoutPrice
|
||
Value::Integer(0), // bidAmount
|
||
Value::Nil, // highBidder
|
||
Value::Nil, // owner
|
||
]));
|
||
};
|
||
// `canUse` is per call, not pushed: it follows the player's level, class and spellbook.
|
||
let can_use = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
super::item_stats::item_usable_by_id(&model, r.item_id)
|
||
};
|
||
let opt_str = |s: &Option<String>| -> mlua::Result<Value> {
|
||
Ok(match s {
|
||
Some(v) => Value::String(lua.create_string(v)?),
|
||
None => Value::Nil,
|
||
})
|
||
};
|
||
Ok(MultiValue::from_vec(vec![
|
||
opt_str(&r.name)?,
|
||
opt_str(&r.texture)?,
|
||
Value::Integer(i64::from(r.count)),
|
||
match r.quality {
|
||
Some(q) => Value::Integer(i64::from(q)),
|
||
None => Value::Nil,
|
||
},
|
||
flag(can_use),
|
||
Value::Integer(i64::from(r.level)),
|
||
Value::Integer(i64::from(r.min_bid)),
|
||
Value::Integer(i64::from(r.min_increment)),
|
||
Value::Integer(i64::from(r.buyout_price)),
|
||
Value::Integer(i64::from(r.bid_amount)),
|
||
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)?,
|
||
]))
|
||
})?,
|
||
)?;
|
||
|
||
// GetAuctionItemTimeLeft(type, index) → the 1..4 bucket, 0 when unknown. The reference has no
|
||
// live countdown: it moves only when a new list lands.
|
||
g.set(
|
||
"GetAuctionItemTimeLeft",
|
||
lua.create_function(|lua, (kind, index): (String, usize)| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(row_at(&model, &kind, index).map_or(0, |r| i64::from(r.time_left)))
|
||
})?,
|
||
)?;
|
||
|
||
g.set(
|
||
"GetAuctionItemLink",
|
||
lua.create_function(|lua, (kind, index): (String, usize)| {
|
||
let link = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
row_at(&model, &kind, index).and_then(|r| r.link)
|
||
};
|
||
// A miss returns no values at all, not nil (`0x4cf45b`).
|
||
Ok(match link {
|
||
Some(l) => MultiValue::from_vec(vec![Value::String(lua.create_string(&l)?)]),
|
||
None => MultiValue::new(),
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// GetSelectedAuctionItem(type) → the selected row, 0 for none. Stored as the auction id and
|
||
// resolved on the way out (`0x4cfda0`/`0x4cfec0`), so it follows its auction through a re-sort.
|
||
g.set(
|
||
"GetSelectedAuctionItem",
|
||
lua.create_function(|lua, kind: String| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let Some(i) = list_index(&kind) else {
|
||
return Ok(0i64);
|
||
};
|
||
let id = model.auction_selected[i];
|
||
if id == 0 {
|
||
return Ok(0i64);
|
||
}
|
||
Ok(model.auction.as_ref().map_or(0, |a| {
|
||
a.lists[i]
|
||
.rows
|
||
.iter()
|
||
.position(|r| r.auction_id == id)
|
||
.map_or(0, |p| p as i64 + 1)
|
||
}))
|
||
})?,
|
||
)?;
|
||
|
||
// SetSelectedAuctionItem(type, index): remembers the auction at that row; out of range clears.
|
||
g.set(
|
||
"SetSelectedAuctionItem",
|
||
lua.create_function(|lua, (kind, index): (String, usize)| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
let Some(i) = list_index(&kind) else {
|
||
return Ok(());
|
||
};
|
||
let id = model
|
||
.auction
|
||
.as_ref()
|
||
.and_then(|a| index.checked_sub(1).and_then(|n| a.lists[i].rows.get(n)))
|
||
.map_or(0, |r| r.auction_id);
|
||
model.auction_selected[i] = id;
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// SortAuctionItems(type, key): queue the header click for the app, which pushes the reordered
|
||
// rows back. The reference raises this `Usage:` only for an argument neither string nor number
|
||
// (`0x6f3510`) and matches both strings case-insensitively (`0x64a4c0`), an unknown one sorting
|
||
// nothing (`0x4cfd90`); here an unknown or differently cased type or column raises.
|
||
g.set(
|
||
"SortAuctionItems",
|
||
lua.create_function(|lua, (kind, key): (String, String)| {
|
||
let Some(i) = list_index(&kind) else {
|
||
return Err(mlua::Error::RuntimeError(
|
||
"Usage: SortAuctionItems(\"type\", \"sort\")".into(),
|
||
));
|
||
};
|
||
if !SORT_KEYS.contains(&key.as_str()) {
|
||
return Err(mlua::Error::RuntimeError(
|
||
"Usage: SortAuctionItems(\"type\", \"sort\")".into(),
|
||
));
|
||
}
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.auction_sorts.push((i, key));
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// IsAuctionSortReversed(type, key) → 1/nil, for any key the stack remembers.
|
||
g.set(
|
||
"IsAuctionSortReversed",
|
||
lua.create_function(|lua, (kind, key): (String, String)| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let rev = list_index(&kind)
|
||
.zip(model.auction.as_ref())
|
||
.is_some_and(|(i, a)| a.lists[i].reversed(&key));
|
||
Ok(flag(rev))
|
||
})?,
|
||
)?;
|
||
|
||
// CanSendAuctionQuery() → 1/nil; a throttled query is dropped silently.
|
||
g.set(
|
||
"CanSendAuctionQuery",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(flag(model.auction_can_query))
|
||
})?,
|
||
)?;
|
||
|
||
// GetAuctionItemClasses() → the ten auctionable class names in the reference's menu order, off
|
||
// the player's ItemClass.dbc; pushed at login, no session needed.
|
||
g.set(
|
||
"GetAuctionItemClasses",
|
||
lua.create_function(|lua, ()| {
|
||
let names: Vec<String> = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
model
|
||
.auction_item_classes
|
||
.iter()
|
||
.map(|c| c.name.clone())
|
||
.collect()
|
||
};
|
||
let mut out = Vec::with_capacity(names.len());
|
||
for n in &names {
|
||
out.push(Value::String(lua.create_string(n)?));
|
||
}
|
||
Ok(MultiValue::from_vec(out))
|
||
})?,
|
||
)?;
|
||
|
||
// GetAuctionItemSubClasses(classIndex) → the subclass names; an out-of-range index or a class
|
||
// whose `0x807060` flag is 0 returns nothing, without an error (`0x4cfa00`, `0x4cfa0e`).
|
||
g.set(
|
||
"GetAuctionItemSubClasses",
|
||
lua.create_function(|lua, class_index: usize| {
|
||
let names: Vec<String> = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
class_index
|
||
.checked_sub(1)
|
||
.and_then(|i| model.auction_item_classes.get(i))
|
||
.filter(|c| c.has_subclass_filter)
|
||
.map_or_else(Vec::new, |c| {
|
||
c.subclasses.iter().map(|s| s.name.clone()).collect()
|
||
})
|
||
};
|
||
let mut out = Vec::with_capacity(names.len());
|
||
for n in &names {
|
||
out.push(Value::String(lua.create_string(n)?));
|
||
}
|
||
Ok(MultiValue::from_vec(out))
|
||
})?,
|
||
)?;
|
||
|
||
// GetAuctionInvTypes(classIndex, subclassIndex) → the fixed 14 inventory-slot GlobalString
|
||
// keys, or nothing (`0x4cfab0`): nothing without a class (or with its `0x807060` flag 0); a
|
||
// found subclass needs `Flags & 0x200`, but an index the scan runs past skips that gate (the
|
||
// exhaust edge at `0x4cfb61` lands past the test at `0x4cfb63`).
|
||
g.set(
|
||
"GetAuctionInvTypes",
|
||
lua.create_function(|lua, (class_index, sub_index): (usize, usize)| {
|
||
let offers = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
class_index
|
||
.checked_sub(1)
|
||
.and_then(|i| model.auction_item_classes.get(i))
|
||
.filter(|c| c.has_subclass_filter)
|
||
.is_some_and(|c| {
|
||
sub_index
|
||
.checked_sub(1)
|
||
.and_then(|i| c.subclasses.get(i))
|
||
.is_none_or(|s| s.has_inv_types)
|
||
})
|
||
};
|
||
if !offers {
|
||
return Ok(MultiValue::new());
|
||
}
|
||
let mut out = Vec::with_capacity(AUCTION_INV_TYPES.len());
|
||
for (_, key) in AUCTION_INV_TYPES {
|
||
out.push(Value::String(lua.create_string(key)?));
|
||
}
|
||
Ok(MultiValue::from_vec(out))
|
||
})?,
|
||
)?;
|
||
|
||
// QueryAuctionItems(name, minLevel, maxLevel, invTypeIndex, classIndex, subclassIndex, page,
|
||
// isUsable, qualityIndex): nine arguments, no `exactMatch` in 1.12. Each filter is a 1-based
|
||
// menu position mapped to the wire id as `0x4ce980` does: invType through the `0x8070b0` rows,
|
||
// class through `0x807060`, subclass as the class's Nth non-excluded row (a scan that never
|
||
// reads the class's filter flag). Anything unresolved is the wire's "any".
|
||
g.set(
|
||
"QueryAuctionItems",
|
||
lua.create_function(|lua, args: MultiValue| {
|
||
let a: Vec<Value> = args.into_iter().collect();
|
||
let num = |i: usize| -> Option<u32> {
|
||
match a.get(i) {
|
||
Some(Value::Integer(n)) => u32::try_from(*n).ok(),
|
||
Some(Value::Number(n)) => (*n >= 0.0).then_some(*n as u32),
|
||
// The min/max level boxes are edit boxes: their text arrives as a string.
|
||
Some(Value::String(s)) => s.to_str().ok()?.trim().parse::<u32>().ok(),
|
||
_ => None,
|
||
}
|
||
};
|
||
let name = match a.first() {
|
||
Some(Value::String(s)) => s.to_str().map(|s| s.to_string()).unwrap_or_default(),
|
||
_ => String::new(),
|
||
};
|
||
let truthy = |i: usize| {
|
||
!matches!(
|
||
a.get(i),
|
||
None | Some(Value::Nil) | Some(Value::Boolean(false))
|
||
)
|
||
};
|
||
// qualityIndex arrives as the dropdown's value: -1 for All, else the quality itself.
|
||
let quality = match a.get(8) {
|
||
Some(Value::Integer(n)) if *n >= 0 => u32::try_from(*n).ok(),
|
||
Some(Value::Number(n)) if *n >= 0.0 => Some(*n as u32),
|
||
_ => None,
|
||
};
|
||
let position = |i: usize| num(i).and_then(|n| (n as usize).checked_sub(1));
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
let inv_type = position(3)
|
||
.and_then(|i| AUCTION_INV_TYPES.get(i))
|
||
.map(|&(id, _)| id);
|
||
let category = position(4).and_then(|i| model.auction_item_classes.get(i));
|
||
let class = category.map(|c| c.class_id);
|
||
let sub_class = category.and_then(|c| {
|
||
position(5)
|
||
.and_then(|i| c.subclasses.get(i))
|
||
.map(|s| s.sub_id)
|
||
});
|
||
model.auction_query = Some(AuctionQuery {
|
||
name,
|
||
min_level: num(1).unwrap_or(0),
|
||
max_level: num(2).unwrap_or(0),
|
||
inv_type,
|
||
class,
|
||
sub_class,
|
||
quality,
|
||
page: num(6).unwrap_or(0),
|
||
usable_only: truthy(7),
|
||
});
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// GetOwnerAuctionItems([page]) / GetBidderAuctionItems([page]): unthrottled, page 0 by default.
|
||
g.set(
|
||
"GetOwnerAuctionItems",
|
||
lua.create_function(|lua, page: Option<u32>| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.auction_owner_query = Some(page.unwrap_or(0));
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"GetBidderAuctionItems",
|
||
lua.create_function(|lua, page: Option<u32>| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.auction_bidder_query = Some(page.unwrap_or(0));
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// PlaceAuctionBid(type, index, bidAmount); a buyout is a bid of exactly the buyout price.
|
||
g.set(
|
||
"PlaceAuctionBid",
|
||
lua.create_function(|lua, (kind, index, amount): (String, u32, u32)| {
|
||
let Some(list) = list_index(&kind) else {
|
||
return Ok(());
|
||
};
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.auction_bids.push(AuctionBid {
|
||
list,
|
||
index,
|
||
amount,
|
||
});
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// StartAuction(minBid, buyoutPrice, runTime), runTime in minutes.
|
||
g.set(
|
||
"StartAuction",
|
||
lua.create_function(|lua, (min_bid, buyout, duration): (u32, u32, u32)| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.auction_start = Some(AuctionStartRequest {
|
||
min_bid,
|
||
buyout,
|
||
duration,
|
||
});
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// CancelAuction(index), always an "owner" row.
|
||
g.set(
|
||
"CancelAuction",
|
||
lua.create_function(|lua, index: u32| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.auction_cancels.push(index);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
g.set(
|
||
"GetAuctionHouseDepositRate",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(model
|
||
.auction
|
||
.as_ref()
|
||
.map_or(0, |a| i64::from(a.deposit_percent)))
|
||
})?,
|
||
)?;
|
||
|
||
// CalculateAuctionDeposit(runTime) → copper, by the client's arithmetic (`deposit_for`).
|
||
g.set(
|
||
"CalculateAuctionDeposit",
|
||
lua.create_function(|lua, minutes: u32| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let rate = model.auction.as_ref().map_or(0, |a| a.deposit_percent);
|
||
let stack_value = sell_item_info(&model).map_or(0, |(_, _, _, _, _, v)| v);
|
||
Ok(i64::from(deposit_for(rate, stack_value, minutes)))
|
||
})?,
|
||
)?;
|
||
|
||
// GetAuctionSellItemInfo() → name, texture, count, quality, canUse, price: six values always.
|
||
// An empty slot answers count 1 and quality -1: the stock Lua reads `count > 1` unguarded.
|
||
g.set(
|
||
"GetAuctionSellItemInfo",
|
||
lua.create_function(|lua, ()| {
|
||
let info = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
sell_item_info(&model)
|
||
};
|
||
let Some((name, texture, count, quality, can_use, price)) = info else {
|
||
return Ok(MultiValue::from_vec(vec![
|
||
Value::Nil,
|
||
Value::Nil,
|
||
Value::Integer(1),
|
||
Value::Integer(-1),
|
||
Value::Nil,
|
||
Value::Integer(0),
|
||
]));
|
||
};
|
||
Ok(MultiValue::from_vec(vec![
|
||
if name.is_empty() {
|
||
Value::Nil
|
||
} else {
|
||
Value::String(lua.create_string(&name)?)
|
||
},
|
||
match &texture {
|
||
Some(t) => Value::String(lua.create_string(t)?),
|
||
None => Value::Nil,
|
||
},
|
||
Value::Integer(i64::from(count)),
|
||
Value::Integer(quality),
|
||
flag(can_use),
|
||
Value::Integer(i64::from(price)),
|
||
]))
|
||
})?,
|
||
)?;
|
||
|
||
// ClickAuctionSellItemButton(): attach the cursor's item, or pick the attached one back up;
|
||
// every cursor write raises both CURSOR_UPDATE and ITEM_LOCK_CHANGED.
|
||
g.set(
|
||
"ClickAuctionSellItemButton",
|
||
lua.create_function(|lua, ()| {
|
||
super::tick::verb(lua, click_auction_sell_item);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// CloseAuctionHouse(): the client-side close; nothing goes on the wire.
|
||
g.set(
|
||
"CloseAuctionHouse",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.auction_close = true;
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// The `0x8070b0` table's fourteen `{u32 invTypeId; char name[0x20]}` rows, in its order: the id
|
||
/// goes on the wire, the name is a GlobalString key.
|
||
const AUCTION_INV_TYPES: [(u32, &str); 14] = [
|
||
(1, "INVTYPE_HEAD"),
|
||
(2, "INVTYPE_NECK"),
|
||
(3, "INVTYPE_SHOULDER"),
|
||
(4, "INVTYPE_BODY"),
|
||
(5, "INVTYPE_CHEST"),
|
||
(6, "INVTYPE_WAIST"),
|
||
(7, "INVTYPE_LEGS"),
|
||
(8, "INVTYPE_FEET"),
|
||
(9, "INVTYPE_WRIST"),
|
||
(10, "INVTYPE_HAND"),
|
||
(11, "INVTYPE_FINGER"),
|
||
(12, "INVTYPE_TRINKET"),
|
||
(16, "INVTYPE_CLOAK"),
|
||
(23, "INVTYPE_HOLDABLE"),
|
||
];
|
||
|
||
/// Attach the cursor's item to the sell slot, or with an empty cursor pick the attached one back
|
||
/// up; any other payload stays on the cursor.
|
||
fn click_auction_sell_item(model: &mut Model) {
|
||
match model.cursor.take() {
|
||
Some(CursorPayload::Item(item)) => {
|
||
model.auction_sell_item = Some(item);
|
||
cursor::raise_cursor_update(model);
|
||
cursor::raise_lock_changed(model);
|
||
}
|
||
None => {
|
||
if let Some(item) = model.auction_sell_item.take() {
|
||
model.cursor = Some(CursorPayload::Item(item));
|
||
cursor::raise_cursor_update(model);
|
||
cursor::raise_lock_changed(model);
|
||
}
|
||
}
|
||
Some(other) => {
|
||
model.cursor = Some(other);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::script::UiScript;
|
||
|
||
/// A session holding one browse page of the given auction ids.
|
||
fn page(ids: &[u32]) -> AuctionState {
|
||
let rows: Vec<AuctionItemRow> = ids
|
||
.iter()
|
||
.map(|&auction_id| AuctionItemRow {
|
||
auction_id,
|
||
item_id: 1000 + auction_id,
|
||
count: 1,
|
||
..Default::default()
|
||
})
|
||
.collect();
|
||
let mut state = AuctionState::default();
|
||
state.lists[LIST] = AuctionListState {
|
||
total: rows.len() as u32,
|
||
rows,
|
||
sort: Vec::new(),
|
||
};
|
||
state
|
||
}
|
||
|
||
#[test]
|
||
fn the_selection_follows_its_auction_through_a_resort() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_auction(Some(page(&[11, 22])));
|
||
|
||
s.eval::<()>(r#"SetSelectedAuctionItem("list", 2)"#)
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.eval::<i64>(r#"return GetSelectedAuctionItem("list")"#)
|
||
.unwrap(),
|
||
2
|
||
);
|
||
|
||
// The same two auctions, re-sorted into the opposite order.
|
||
s.set_auction(Some(page(&[22, 11])));
|
||
assert_eq!(
|
||
s.eval::<i64>(r#"return GetSelectedAuctionItem("list")"#)
|
||
.unwrap(),
|
||
1,
|
||
"auction 22 moved to row 1 and the selection went with it"
|
||
);
|
||
|
||
// It leaves the page.
|
||
s.set_auction(Some(page(&[33, 44])));
|
||
assert_eq!(
|
||
s.eval::<i64>(r#"return GetSelectedAuctionItem("list")"#)
|
||
.unwrap(),
|
||
0,
|
||
"gone means nothing selected, never its neighbour"
|
||
);
|
||
|
||
// Closing the session drops it outright.
|
||
s.set_auction(Some(page(&[33])));
|
||
s.eval::<()>(r#"SetSelectedAuctionItem("list", 1)"#)
|
||
.unwrap();
|
||
s.set_auction(None);
|
||
assert_eq!(
|
||
s.eval::<i64>(r#"return GetSelectedAuctionItem("list")"#)
|
||
.unwrap(),
|
||
0
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_sell_slot_reads_a_whole_stacks_real_size() {
|
||
use crate::script::container::{ContainerSlot, ContainerState};
|
||
use crate::script::cursor::CursorItem;
|
||
|
||
let mut s = UiScript::new().unwrap();
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1u32,
|
||
ContainerSlot {
|
||
count: 20,
|
||
item_id: 2589,
|
||
..Default::default()
|
||
},
|
||
);
|
||
s.set_container(
|
||
0,
|
||
Some(ContainerState {
|
||
name: None,
|
||
num_slots: 16,
|
||
slots,
|
||
}),
|
||
);
|
||
// Picked up whole: `count: None` means the whole stack, not one.
|
||
{
|
||
let mut model = s.model_mut();
|
||
model.auction_sell_item = Some(CursorItem {
|
||
bag: 0,
|
||
slot: 1,
|
||
item_id: 2589,
|
||
texture: None,
|
||
link: None,
|
||
count: None,
|
||
quality: None,
|
||
equip_slots: Vec::new(),
|
||
bar_placeable: false,
|
||
});
|
||
}
|
||
let count: i64 = s
|
||
.eval(r#"local _, _, c = GetAuctionSellItemInfo() return c"#)
|
||
.unwrap();
|
||
assert_eq!(count, 20, "the whole stack, not one of it");
|
||
}
|
||
|
||
#[test]
|
||
fn a_missing_row_still_answers_twelve_values() {
|
||
let s = UiScript::new().unwrap();
|
||
let n = s.arity(r#"GetAuctionItemInfo("list", 99)"#).unwrap();
|
||
assert_eq!(n, 12, "twelve, even with no session open at all");
|
||
let (count, quality): (i64, i64) = s
|
||
.eval(r#"local _, _, c, q = GetAuctionItemInfo("list", 99) return c, q"#)
|
||
.unwrap();
|
||
assert_eq!((count, quality), (1, -1));
|
||
|
||
// The link answers no values on a miss, not a nil.
|
||
let n = s.arity(r#"GetAuctionItemLink("list", 99)"#).unwrap();
|
||
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() {
|
||
assert_eq!(deposit_for(5, 9, 1440), 0);
|
||
// Once the inner floor clears, the duration scales it 1 / 4 / 12.
|
||
assert_eq!(deposit_for(5, 10_000, 120), 500);
|
||
assert_eq!(deposit_for(5, 10_000, 480), 2_000);
|
||
assert_eq!(deposit_for(5, 10_000, 1440), 6_000);
|
||
// Blackwater's 25% on the same stack.
|
||
assert_eq!(deposit_for(25, 10_000, 120), 2_500);
|
||
// Under the two-hour unit it floors to 0; the reference never sends such a duration.
|
||
assert_eq!(deposit_for(5, 10_000, 60), 0);
|
||
}
|
||
|
||
/// Weapon and Armor, then Consumable (flag 0: no subclass rows, but the query scans them).
|
||
fn tree() -> Vec<AuctionCategory> {
|
||
let sub = |sub_id: u32, name: &str, has_inv_types: bool| AuctionSubCategory {
|
||
sub_id,
|
||
name: name.into(),
|
||
has_inv_types,
|
||
};
|
||
vec![
|
||
AuctionCategory {
|
||
class_id: 2,
|
||
name: "Weapon".into(),
|
||
has_subclass_filter: true,
|
||
subclasses: vec![sub(0, "Axe", false), sub(1, "Axe", false)],
|
||
},
|
||
AuctionCategory {
|
||
class_id: 4,
|
||
name: "Armor".into(),
|
||
has_subclass_filter: true,
|
||
subclasses: vec![sub(1, "Cloth", true), sub(6, "Shield", false)],
|
||
},
|
||
AuctionCategory {
|
||
class_id: 0,
|
||
name: "Consumable".into(),
|
||
has_subclass_filter: false,
|
||
subclasses: vec![sub(0, "Consumable", false)],
|
||
},
|
||
]
|
||
}
|
||
|
||
#[test]
|
||
fn query_sends_ids_not_menu_positions() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_auction_item_classes(tree());
|
||
|
||
// Weapon / its first subclass / Back (the 13th inventory row).
|
||
s.run(r#"QueryAuctionItems("", "", "", 13, 1, 1, 0, nil, -1)"#)
|
||
.unwrap();
|
||
let q = s.take_auction_query().expect("queued");
|
||
assert_eq!(q.inv_type, Some(16), "INVTYPE_CLOAK is inventory type 16");
|
||
assert_eq!(q.class, Some(2), "Weapon is item class 2, not position 1");
|
||
assert_eq!(q.sub_class, Some(0), "the first weapon subclass is id 0");
|
||
|
||
// Armor / its second subclass skips the id gap: Shield is 6, not 2.
|
||
s.run(r#"QueryAuctionItems("", "", "", 14, 2, 2, 0, nil, -1)"#)
|
||
.unwrap();
|
||
let q = s.take_auction_query().expect("queued");
|
||
assert_eq!(
|
||
(q.inv_type, q.class, q.sub_class),
|
||
(Some(23), Some(4), Some(6))
|
||
);
|
||
|
||
// The scan ignores the filter flag: Consumable offers no subclass rows, yet resolves one.
|
||
s.run(r#"QueryAuctionItems("", "", "", 0, 3, 1, 0, nil, -1)"#)
|
||
.unwrap();
|
||
let q = s.take_auction_query().expect("queued");
|
||
assert_eq!((q.inv_type, q.class, q.sub_class), (None, Some(0), Some(0)));
|
||
|
||
// Out of range, or no class to scan under: the wire's "any".
|
||
s.run(r#"QueryAuctionItems("", "", "", 15, 11, 1, 0, nil, -1)"#)
|
||
.unwrap();
|
||
let q = s.take_auction_query().expect("queued");
|
||
assert_eq!((q.inv_type, q.class, q.sub_class), (None, None, None));
|
||
s.run(r#"QueryAuctionItems("", "", "", nil, 1, 3, 0, nil, -1)"#)
|
||
.unwrap();
|
||
let q = s.take_auction_query().expect("queued");
|
||
assert_eq!((q.inv_type, q.class, q.sub_class), (None, Some(2), None));
|
||
}
|
||
|
||
/// The gates of `0x4cf9c0` (subclasses) and `0x4cfab0` (inventory types).
|
||
#[test]
|
||
fn subclass_and_inv_type_menus_follow_the_reference_gates() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_auction_item_classes(tree());
|
||
assert_eq!(s.arity("GetAuctionItemSubClasses(1)").unwrap(), 2);
|
||
assert_eq!(
|
||
s.arity("GetAuctionItemSubClasses(3)").unwrap(),
|
||
0,
|
||
"Consumable's flag is 0: no subclass rows"
|
||
);
|
||
assert_eq!(s.arity("GetAuctionInvTypes(2, 1)").unwrap(), 14, "Cloth");
|
||
assert_eq!(s.arity("GetAuctionInvTypes(2, 2)").unwrap(), 0, "Shield");
|
||
assert_eq!(s.arity("GetAuctionInvTypes(3, 1)").unwrap(), 0, "flag 0");
|
||
assert_eq!(s.arity("GetAuctionInvTypes(11, 1)").unwrap(), 0, "no class");
|
||
assert_eq!(
|
||
s.arity("GetAuctionInvTypes(1, 9)").unwrap(),
|
||
14,
|
||
"an exhausted scan skips the gate"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reversed_answers_for_any_remembered_key() {
|
||
let list = AuctionListState {
|
||
sort: vec![
|
||
("bid".into(), false),
|
||
("quality".into(), true),
|
||
("level".into(), false),
|
||
],
|
||
..Default::default()
|
||
};
|
||
assert!(!list.reversed("bid"), "the primary, not reversed");
|
||
assert!(list.reversed("quality"), "remembered, and reversed");
|
||
assert!(!list.reversed("level"));
|
||
assert!(!list.reversed("seller"), "never sorted by: not reversed");
|
||
}
|
||
}
|