- the auction house end to end: right-clicking an auctioneer opens Browse/Bids/Auctions on the server's reply — search, the category tree from your own DBCs, the reference's 50-a-page pager, multi-key sorting (directions corrected at the bytes), bid/buyout, the sell slot with deposit and duration, cancel with its fee popup; prices sit on the reference's own money-frame kit in lined-up columns, the twenty auction messages split refusals-red / outcomes-to-chat, and a won or sold auction reads as a proper mail receipt — the window's Lua re-worded in our own terms after the overlap ratchet caught the first cut leaning on the reference's text - the PvP honor system whole: the Honor tab and the inspect page over a real wire model, with current rank and highest-ever rank correctly two different fields - a day of reported fixes: WASD survives a /logout re-entry, addon settings save on a window close and Cmd+Q (exactly once — a mac close could fire the tail twice), clams open again, random-enchant items name themselves on every surface, the loot window's rows hold their seats with legible counts, idle players stop sinking through building floors, the shooter re-pulls between Auto Shots, portraits loosen ~19% to the client's own aspect-1.0 matrix, and big creatures thump the camera off the CameraShakes table - character select clicks like 1.12 — captions whiten on hover, buttons fire on release, Humans face straight ahead off the stage bone's real yaw, the facing is per-character, and hunter/warlock pets stand beside you at their family-ramp size with their own emitters (the imp's flames), the Voidwalker's sequence-hidden shackles culled - the smaller player-facing set: nameplate on/off rows in Options with the CanAttack category gate (emissaries stop plating), the bonus bar slides up with igBonusBarOpen, the camera's framing height glides on a shapeshift, the open letter evicts the centre seat, the far-band fog hull stops printing a pale horizon stripe at low view distance, the guide arrow is gold with per-source rim art, error text fades through its shadow too, and /convertraid converts the party - README: the auction house and PvP honor move out of "not built yet" Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1054 lines
44 KiB
Rust
1054 lines
44 KiB
Rust
//! The auction-house bindings (decision 1511) — the Era-shaped auctioneer surface, the same two-way
|
||
//! seam as [`super::merchant`]/[`super::mail`]: the app pushes an **auction snapshot** (three lists
|
||
//! of rows, already resolved from the wire to name/icon/quality/owner and already in display order)
|
||
//! and the Lua `QueryAuctionItems`/`PlaceAuctionBid`/`StartAuction`/… calls queue outbound
|
||
//! **intents** the app drains. The engine holds no auction knowledge: a row is a header, the sort
|
||
//! is a pushed ordering, and the category tree is a pushed table.
|
||
//!
|
||
//! ## Three lists, one API
|
||
//!
|
||
//! Every getter is keyed by a list type string — `"list"` (Browse), `"bidder"` (Bids) and
|
||
//! `"owner"` (Auctions) — and `index` is **1-based within the current 50-row batch**, not within
|
||
//! the whole result set. `GetNumAuctionItems` returns the pair `numBatchAuctions, totalAuctions`;
|
||
//! when `total > batch` the window is paging, and the page turners re-query rather than scroll.
|
||
//!
|
||
//! ## The two null shapes that are load-bearing
|
||
//!
|
||
//! `GetAuctionSellItemInfo()` **always returns six values, never zero** — an empty sell slot pushes
|
||
//! `nil, nil, 1, -1, nil, 0`, with a hard `count = 1` and `quality = -1` rather than nils. The
|
||
//! reference Lua reads that count unguarded (`if count > 1`), so a nil there throws the moment the
|
||
//! player pulls the item back out of the slot. `GetAuctionItemInfo` answers a lone `nil` for an
|
||
//! out-of-range index, which is the shape its callers test.
|
||
//!
|
||
//! ## Sorting is ours, and it is not a three-state cycle
|
||
//!
|
||
//! The wire carries no sort field, so the ordering is entirely client-side over the ≤50 rows the
|
||
//! server returned. Each list owns an **8-deep most-recently-clicked key stack** of
|
||
//! `(key, reversed)` pairs: clicking the column that is already primary toggles its direction;
|
||
//! clicking any other promotes it to primary **keeping the direction it remembers**. That is why
|
||
//! `IsAuctionSortReversed` can answer "reversed" for a column that is not currently sorting
|
||
//! anything. [`super::UiScript::take_auction_sorts`] hands the click to the app, which owns the
|
||
//! stacks and pushes both the reordered rows and the stack back. The selection rides through all
|
||
//! of it untouched, because it is stored as an auction **id** and only resolved to a row position
|
||
//! when something asks (wow-re §5 TU-5).
|
||
//!
|
||
//! ## The deposit is computed here, and it is the *client's* arithmetic
|
||
//!
|
||
//! `CalculateAuctionDeposit(minutes)` reproduces the real client's formula —
|
||
//! `floor(rate × stackValue / 100) × floor(minutes / 120)` — including the intermediate truncation
|
||
//! that makes it disagree with what the server actually charges on cheap items. Decision 1511 §7
|
||
//! records why the label follows the client rather than the server: it is a client artifact, and
|
||
//! benilla renders what the director's client renders.
|
||
|
||
use mlua::{Lua, MultiValue, Value};
|
||
|
||
use super::cursor::{self, CursorPayload};
|
||
use super::Model;
|
||
|
||
/// The three list types, in the order the app pushes them. The wire calls them nothing at all —
|
||
/// this is purely the Lua API's own keying.
|
||
pub const LIST: usize = 0;
|
||
pub const BIDDER: usize = 1;
|
||
pub const OWNER: usize = 2;
|
||
|
||
/// The eight sort keys the reference's headers pass. Order here is the API's, not the comparator's
|
||
/// (decision 1511's INTERIM: the comparator's own mode order is pinned to the in-flight wow-re §5).
|
||
pub const SORT_KEYS: [&str; 8] = [
|
||
"level", "quality", "bid", "duration", "buyout", "status", "name", "seller",
|
||
];
|
||
|
||
/// `"list"` / `"bidder"` / `"owner"` → the index the snapshot stores them at, for the other
|
||
/// modules that key off the same three names (the tooltip's `SetAuctionItem`).
|
||
pub(super) fn list_index_of(kind: &str) -> Option<usize> {
|
||
list_index(kind)
|
||
}
|
||
|
||
/// `"list"` / `"bidder"` / `"owner"` → the index the snapshot stores them at.
|
||
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 (decision 1511). Plain data — its
|
||
/// 1-based order in the window is its position in [`AuctionListState::rows`], which is already the
|
||
/// sorted display order.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct AuctionItemRow {
|
||
/// The wire auction id — what a bid or a cancel addresses. Never shown; the app maps a clicked
|
||
/// row index back through this.
|
||
pub auction_id: u32,
|
||
/// The item's template entry — the tooltip store's key and the link's identity.
|
||
pub item_id: u32,
|
||
/// The listed item's **random-suffix roll** (the wire's `randomPropertyId`) — what the row
|
||
/// hover resolves its enchant lines from, and the id whose suffix [`Self::name`] already
|
||
/// carries. `0` = unrolled. The reference's `SetAuctionItem` writes exactly this into the
|
||
/// tooltip's `+0x424` (decision 1547).
|
||
pub random_property_id: u32,
|
||
/// `None` while the item template answer is in flight (the row shows a placeholder and fills
|
||
/// in when it lands, the merchant/mail pattern).
|
||
pub name: Option<String>,
|
||
pub texture: Option<String>,
|
||
pub count: u32,
|
||
pub quality: Option<u32>,
|
||
/// `RequiredLevel` — printed red when it exceeds the player's own level.
|
||
pub level: u32,
|
||
/// The seller's opening price. This is what the row shows as the current bid while
|
||
/// [`Self::bid_amount`] is 0.
|
||
pub min_bid: u32,
|
||
/// The minimum step above the current bid — `0` while nobody has bid.
|
||
pub min_increment: u32,
|
||
/// `0` = no buyout.
|
||
pub buyout_price: u32,
|
||
/// The current high bid; `0` = no bids yet.
|
||
pub bid_amount: u32,
|
||
/// Whether *the player* holds the high bid — a flag, not a name. The Browse row's "Your bid:"
|
||
/// caption and both action-button gates read it.
|
||
pub high_bidder: bool,
|
||
/// 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` = not known yet.
|
||
pub time_left: u32,
|
||
/// The full item link, built app-side from the row's entry + enchant + random-property +
|
||
/// suffix (auction rows carry all four on the wire, so the link is the complete one).
|
||
pub link: Option<String>,
|
||
}
|
||
|
||
/// One of the three lists: the batch the server sent, the pre-cap match count, and the sort stack
|
||
/// that ordered it.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct AuctionListState {
|
||
/// The current batch, already in display order (≤50 — the server's own page cap).
|
||
pub rows: Vec<AuctionItemRow>,
|
||
/// `totalAuctions` — how many matched *before* the 50-row cap, so the pager can say
|
||
/// "( %d total )". Equal to `rows.len()` when everything fits on one page.
|
||
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 row of the Browse tab's category tree, pushed by the app from the player's own
|
||
/// `ItemClass.dbc` / `ItemSubClass.dbc` (decision 1511 §5 — the set and order of classes is a
|
||
/// structural fact; every string here comes off the player's install).
|
||
#[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,
|
||
/// `(subclass id, name, offers inventory-slot filters)`.
|
||
pub subclasses: Vec<AuctionSubCategory>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct AuctionSubCategory {
|
||
/// The wire's `subCategory`.
|
||
pub sub_id: u32,
|
||
pub name: String,
|
||
/// Whether selecting this subclass offers the 14 inventory-slot rows beneath it
|
||
/// (`GetAuctionInvTypes`). INTERIM (decision 1511): the exact predicate is pinned to the
|
||
/// in-flight §5's TU-4, and lives here as *data* precisely so correcting it is a one-line
|
||
/// change app-side rather than an engine change.
|
||
pub has_inv_types: bool,
|
||
}
|
||
|
||
/// The open auction house's snapshot. Pushed whole by the app; `None` means no auctioneer session
|
||
/// is open (the window is closed).
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct AuctionState {
|
||
/// Browse / Bids / Auctions, indexed by [`LIST`], [`BIDDER`], [`OWNER`].
|
||
pub lists: [AuctionListState; 3],
|
||
/// The Browse tab's category tree.
|
||
pub categories: Vec<AuctionCategory>,
|
||
/// `GetAuctionHouseDepositRate()` — the percentage this house charges, read from
|
||
/// `AuctionHouse.dbc` at the `houseId` the hello reply carried.
|
||
pub deposit_percent: u32,
|
||
}
|
||
|
||
/// A drained `QueryAuctionItems` — the Browse search, exactly as the reference builds it. The app
|
||
/// maps this to `CMSG_AUCTION_LIST_ITEMS` (decision 1511 P1).
|
||
#[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` sentinel).
|
||
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>,
|
||
/// The page, 0-based. The app multiplies by 50 for the wire's item offset.
|
||
pub page: u32,
|
||
pub usable_only: bool,
|
||
}
|
||
|
||
/// A drained `StartAuction` (decision 1511 P2).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct AuctionStartRequest {
|
||
pub min_bid: u32,
|
||
/// `0` = no buyout.
|
||
pub buyout: u32,
|
||
/// Minutes — only 120, 480 or 1440 are legal, and the reference refuses to send anything else.
|
||
pub duration: u32,
|
||
}
|
||
|
||
/// A drained `PlaceAuctionBid`.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct AuctionBid {
|
||
/// The list the index addresses ([`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 (or clear, with `None`) the open auction house's snapshot. Replacing a list's rows
|
||
/// **clears that list's selection**, which is why this is one call and not two: the reference's
|
||
/// selection is C-side state that cannot survive the batch it indexes into, and a stale index
|
||
/// would silently address a different auction.
|
||
pub fn set_auction(&mut self, state: Option<AuctionState>) {
|
||
let mut model = self.model_mut();
|
||
// Only opening or closing the session drops the selection. A new PAGE does not, and
|
||
// neither does a re-sort: the selection is an auction **id**, so it survives the row
|
||
// moving and simply stops resolving if the auction leaves the page (wow-re §5 TU-5).
|
||
if model.auction.is_none() || state.is_none() {
|
||
model.auction_selected = [0; 3];
|
||
}
|
||
model.auction = state;
|
||
}
|
||
|
||
/// Push `CanSendAuctionQuery()`'s answer — the app's throttle, read by the Search button's
|
||
/// every-frame `OnUpdate`. Separate from the snapshot precisely because it changes every frame
|
||
/// while throttled and would otherwise churn the snapshot's diff.
|
||
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 item currently in the sell slot — the app resolves its `(bag, slot)` to the wire item
|
||
/// guid when `StartAuction` fires (the lazy resolve mail's send uses).
|
||
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 — the app calls this once the auction is away, and on session close.
|
||
pub fn clear_auction_sell_item(&mut self) {
|
||
self.model_mut().auction_sell_item = None;
|
||
}
|
||
}
|
||
|
||
/// A `1`/`nil` boolean the way the client pushes flags.
|
||
fn flag(b: bool) -> Value {
|
||
if b {
|
||
Value::Integer(1)
|
||
} else {
|
||
Value::Nil
|
||
}
|
||
}
|
||
|
||
/// Fetch a cloned row for a list type + 1-based index, or `None` (unknown type / out of range /
|
||
/// no session open).
|
||
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)`, or `None` when empty.
|
||
/// `stackValue` is the item's vendor sell price **times the stack**, which is what the reference's
|
||
/// suggested opening price and the deposit are both computed from.
|
||
fn sell_item_info(model: &Model) -> Option<(String, Option<String>, u32, i64, bool, u32)> {
|
||
let it = model.auction_sell_item.as_ref()?;
|
||
// The TRUE 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 real client's deposit arithmetic, reproduced including its intermediate truncation
|
||
/// (decision 1511 §7): `floor(rate × stackValue / 100) × floor(minutes / 120)`. Both floors matter
|
||
/// — the inner one is what makes a cheap stack deposit zero here while the server still charges a
|
||
/// few copper, and the outer is why 120/480/1440 minutes scale as 1/4/12 rather than continuously.
|
||
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, totalAuctions. The pair is the whole paging
|
||
// model: batch is what this packet carried (≤50), total is what matched 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) → the 12-tuple. `highBidder` is a FLAG, not a name — the
|
||
// seller's name is the last return, and it is what the Browse "Seller" column shows.
|
||
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 {
|
||
// The null tail is TWELVE values, not a lone nil — an unknown type string, an
|
||
// out-of-range index and an item-cache miss all share it, with a hard `count = 1`
|
||
// and `quality = -1` (wow-re §5 TU-3, one shared exit at `0x4cf1ec`). Callers
|
||
// destructure all twelve unguarded, so a short return throws.
|
||
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 computed HERE rather than pushed with the row: it depends on the
|
||
// player's own level, class and spellbook, which move independently of the auction
|
||
// snapshot. A pushed flag would go stale the moment the player dinged mid-browse.
|
||
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)),
|
||
flag(r.high_bidder),
|
||
opt_str(&r.owner)?,
|
||
]))
|
||
})?,
|
||
)?;
|
||
|
||
// GetAuctionItemTimeLeft(type, index) → 1..4, the bucket the row's text and tooltip key off.
|
||
// 0 for a row we have no answer for yet; the reference has no live countdown (its ticking
|
||
// version is commented out), so this only moves when a fresh 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)))
|
||
})?,
|
||
)?;
|
||
|
||
// GetAuctionItemLink(type, index) → the full item link. Auction rows carry enchant, random
|
||
// property and suffix on the wire, so the app builds the complete link rather than the
|
||
// zeroed-tail one the bag path settles for.
|
||
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 (wow-re §5 TU-3). `DressUpItemLink(nil)`
|
||
// and `nil` reaching a chat insert behave differently from an empty argument list.
|
||
Ok(match link {
|
||
Some(l) => MultiValue::from_vec(vec![Value::String(lua.create_string(&l)?)]),
|
||
None => MultiValue::new(),
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// GetSelectedAuctionItem(type) → the 1-based selected row, or 0 for none.
|
||
//
|
||
// Stored as the **auction id**, resolved to a row position on the way out (wow-re §5 TU-5).
|
||
// That indirection is the whole reason a re-sort cannot silently move the selection onto a
|
||
// different auction: the id follows the row wherever the comparator puts it, and an auction
|
||
// that has left the page simply stops resolving instead of pointing at its neighbour.
|
||
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) — takes a row position and remembers the auction it
|
||
// names. An out-of-range index clears the selection rather than remembering a phantom.
|
||
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; the app owns the stacks and pushes the
|
||
// reordered rows back. The reference raises on a bad type, and that Usage string is recorded,
|
||
// so it is reproduced here rather than swallowed.
|
||
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. Answers for ANY key the stack remembers, not just
|
||
// the primary one — which is the whole reason a non-primary column's arrow can point down.
|
||
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. Polled every frame by the Search button's OnUpdate, because
|
||
// a throttled query is dropped silently and there is no failure event to react to.
|
||
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 class names, in the reference's own menu order. The ORDER and
|
||
// the set are structural (ten auctionable classes); every string is the player's own
|
||
// ItemClass.dbc row, so none of Blizzard's text ships with us (decisions 1234/1260).
|
||
g.set(
|
||
"GetAuctionItemClasses",
|
||
lua.create_function(|lua, ()| {
|
||
let names: Vec<String> = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
model.auction.as_ref().map_or_else(Vec::new, |a| {
|
||
a.categories.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 under a 1-based menu class. An
|
||
// out-of-range index returns nothing, which is the reference's own bound-with-no-error.
|
||
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");
|
||
model
|
||
.auction
|
||
.as_ref()
|
||
.and_then(|a| class_index.checked_sub(1).and_then(|i| a.categories.get(i)))
|
||
.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 14 inventory-slot GlobalString KEYS, or
|
||
// nothing. The pair only decides WHETHER the list is offered — the list itself is fixed. Which
|
||
// pairs offer it is pushed as data (`has_inv_types`), so decision 1511's INTERIM on that
|
||
// predicate is corrected app-side, not here.
|
||
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");
|
||
model
|
||
.auction
|
||
.as_ref()
|
||
.and_then(|a| class_index.checked_sub(1).and_then(|i| a.categories.get(i)))
|
||
.and_then(|c| sub_index.checked_sub(1).and_then(|i| c.subclasses.get(i)))
|
||
.is_some_and(|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 (TBC's tenth, exactMatch, does not exist on 5875).
|
||
// The level boxes hand us strings, and every filter index is 1-based into the pushed tables;
|
||
// the app turns those into the wire's class/subclass ids and its 0xFFFFFFFF sentinels.
|
||
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 mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.auction_query = Some(AuctionQuery {
|
||
name,
|
||
min_level: num(1).unwrap_or(0),
|
||
max_level: num(2).unwrap_or(0),
|
||
inv_type: num(3).filter(|n| *n > 0),
|
||
class: num(4).filter(|n| *n > 0),
|
||
sub_class: num(5).filter(|n| *n > 0),
|
||
quality,
|
||
page: num(6).unwrap_or(0),
|
||
usable_only: truthy(7),
|
||
});
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// GetOwnerAuctionItems([page]) / GetBidderAuctionItems([page]) — the other two tabs' fetches.
|
||
// Neither is throttled the way Browse is; both default to page 0.
|
||
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 not a distinct verb — it is a bid of
|
||
// exactly the buyout price, which is why the confirmation popup calls straight into this.
|
||
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(())
|
||
})?,
|
||
)?;
|
||
|
||
// GetAuctionHouseDepositRate() → the percentage, off AuctionHouse.dbc at this session's house.
|
||
// The shipped reference addon never calls it; third-party addons may, so we ship it.
|
||
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. See `deposit_for` — this is the client's own
|
||
// arithmetic, truncation included, not the server's charge.
|
||
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 —
|
||
// the empty slot pushes a hard count of 1 and a quality of -1, never nils, because the
|
||
// reference Lua reads that count unguarded the moment the slot empties.
|
||
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 take back the attached one. The
|
||
// mail send slot's exact shape (decision 0216's rails): every cursor write is followed by both
|
||
// queues, which is the law that keeps CURSOR_UPDATE and ITEM_LOCK_CHANGED honest.
|
||
g.set(
|
||
"ClickAuctionSellItemButton",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
click_auction_sell_item(&mut model);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// CloseAuctionHouse() — the client-side close. Vanilla sends nothing; the session simply ends.
|
||
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 fourteen inventory-slot rows the reference offers, as GlobalString **keys** — the Lua
|
||
/// resolves each through the player's own strings, so no English ships here. Inventory ids 1..12
|
||
/// plus CLOAK(16) and HOLDABLE(23); the order is the reference's own.
|
||
const AUCTION_INV_TYPES: [&str; 14] = [
|
||
"INVTYPE_HEAD",
|
||
"INVTYPE_NECK",
|
||
"INVTYPE_SHOULDER",
|
||
"INVTYPE_BODY",
|
||
"INVTYPE_CHEST",
|
||
"INVTYPE_WAIST",
|
||
"INVTYPE_LEGS",
|
||
"INVTYPE_FEET",
|
||
"INVTYPE_WRIST",
|
||
"INVTYPE_HAND",
|
||
"INVTYPE_FINGER",
|
||
"INVTYPE_TRINKET",
|
||
"INVTYPE_CLOAK",
|
||
"INVTYPE_HOLDABLE",
|
||
];
|
||
|
||
/// Attach the cursor's held item to the sell slot, or — with an empty cursor — pick the attached
|
||
/// one back up. A spell or action payload is refused and put back untouched, exactly as the mail
|
||
/// send slot refuses it.
|
||
fn click_auction_sell_item(model: &mut Model) {
|
||
match model.cursor.take() {
|
||
Some(CursorPayload::Item(item)) => {
|
||
let (bag, slot) = (item.bag, item.slot);
|
||
model.auction_sell_item = Some(item);
|
||
cursor::queue_cursor_update(model);
|
||
cursor::queue_lock_changed(model, bag, slot);
|
||
}
|
||
None => {
|
||
if let Some(item) = model.auction_sell_item.take() {
|
||
let (bag, slot) = (item.bag, item.slot);
|
||
model.cursor = Some(CursorPayload::Item(item));
|
||
cursor::queue_cursor_update(model);
|
||
cursor::queue_lock_changed(model, bag, slot);
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
/// The selection is an auction id, not a row position — so a re-sort moves the row under it
|
||
/// and the selection follows, rather than coming to mean whatever took that slot.
|
||
///
|
||
/// This is the case an index would get silently wrong: pick row 2, re-sort, and an
|
||
/// index-based selection is now pointing at a *different auction* that the Bid button would
|
||
/// happily bid on.
|
||
#[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 entirely (outbid, bought, or filtered out by a new search).
|
||
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
|
||
);
|
||
}
|
||
|
||
/// A WHOLE-STACK pickup carries no count of its own, so the sell slot has to read the real
|
||
/// stack size back off the source slot. Reading the cursor field directly answers 1 for a
|
||
/// stack of twenty — and since the deposit is charged per stack, the pane would quote a
|
||
/// twentieth of what the server then takes.
|
||
#[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();
|
||
// Twenty of item 2589 sitting in backpack slot 1.
|
||
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` is the "whole stack" signal, 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");
|
||
}
|
||
|
||
/// The null tail is twelve values with a hard `count = 1` and `quality = -1`. The reference
|
||
/// Lua destructures all twelve unguarded, so a short return throws rather than showing an
|
||
/// empty row.
|
||
#[test]
|
||
fn a_missing_row_still_answers_twelve_values() {
|
||
let s = UiScript::new().unwrap();
|
||
let n: i64 = s
|
||
.eval(r##"return select("#", 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, by contrast, answers with NO values on a miss — not a nil.
|
||
let n: i64 = s
|
||
.eval(r##"return select("#", GetAuctionItemLink("list", 99))"##)
|
||
.unwrap();
|
||
assert_eq!(n, 0, "zero values, not one nil");
|
||
}
|
||
|
||
/// The client's own deposit arithmetic, including the intermediate truncation that makes it
|
||
/// disagree with the server (decision 1511 §7). The cheap-stack case is the one that matters:
|
||
/// a 9-copper vendor value at 5% over 24h floors to 0 here while vmangos still charges 5, and
|
||
/// that divergence is deliberate — the label is a client artifact.
|
||
#[test]
|
||
fn the_deposit_is_the_clients_arithmetic_truncation_and_all() {
|
||
// 5% of 9c = 0 after the inner floor, so no duration can lift it off zero.
|
||
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);
|
||
// A duration under the two-hour unit floors the whole thing away — the reference refuses
|
||
// to send such a duration at all, so this is a shape check, not a reachable state.
|
||
assert_eq!(deposit_for(5, 10_000, 60), 0);
|
||
}
|
||
|
||
/// The sort stack answers for any key it remembers, not just the primary — the reason a
|
||
/// non-primary column's arrow can still point down.
|
||
#[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");
|
||
}
|
||
}
|