Files
benilla-octo/crates/benilla-protocol/tests/auction.rs
T
SamandClaude Fable 5 cf581aa2ac the auction house opens, the Honor tab lands, and a day of fixes
- 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>
2026-08-22 19:04:27 +07:00

745 lines
29 KiB
Rust

//! Oracle-free golden tests for the auction house arc's protocol layer (decision 1511 phase P0):
//! byte-exact goldens for all seven CMSG bodies, the `MSG_AUCTION_HELLO` reply, the four
//! `SMSG_AUCTION_COMMAND_RESULT` tail shapes, the one 64-byte record and one frame the three list
//! results share (including that `total_count` rides *after* the records, and that a body shorter
//! than its own `count` claims still yields the records it does carry), the two deliberately
//! different notifications, and the removal notice. Same idioms as `tests/mail.rs` — `hx(...)`
//! golden CMSG bodies, hand-built SMSG bodies round-tripped through `parse_server`, and a
//! `decode()` bridge assertion.
use benilla_protocol::events::{decode, SessionEvent};
use benilla_protocol::messages::{
self, auction_action, auction_duration, auction_error, auction_filter,
AuctionBidderNotification, AuctionCommandTail, AuctionListEntry, AuctionOwnerNotification,
AUCTION_RECORD_BYTES,
};
use benilla_protocol::ServerPacket;
fn hx(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
const AUCTIONEER: u64 = 0x00F1_3000_0000_0055;
#[test]
fn auction_send_bodies_golden() {
// MSG_AUCTION_HELLO: one full auctioneer guid, nothing else.
assert_eq!(
messages::auction_hello(AUCTIONEER),
hx("550000000030f100"),
"MSG_AUCTION_HELLO body"
);
// CMSG_AUCTION_SELL_ITEM: u64 auctioneer, u64 itemGuid, u32 bid, u32 buyout, u32 etime.
assert_eq!(
messages::auction_sell_item(
AUCTIONEER,
0x0000_0000_0100_00AB,
10_000,
50_000,
auction_duration::MEDIUM_MINUTES,
),
hx(concat!(
"550000000030f100", // auctioneer
"ab00000100000000", // item guid
"10270000", // bid 10000
"50c30000", // buyout 50000
"e0010000", // etime 480 minutes
)),
"CMSG_AUCTION_SELL_ITEM body"
);
// CMSG_AUCTION_REMOVE_ITEM: u64 auctioneer, u32 auctionId.
assert_eq!(
messages::auction_remove_item(AUCTIONEER, 4242),
hx("550000000030f10092100000"),
"CMSG_AUCTION_REMOVE_ITEM body"
);
// CMSG_AUCTION_PLACE_BID: u64 auctioneer, u32 auctionId, u32 price.
assert_eq!(
messages::auction_place_bid(AUCTIONEER, 4242, 12_345),
hx("550000000030f1009210000039300000"),
"CMSG_AUCTION_PLACE_BID body"
);
// CMSG_AUCTION_LIST_OWNER_ITEMS: u64 auctioneer, u32 listfrom.
assert_eq!(
messages::auction_list_owner_items(AUCTIONEER, 50),
hx("550000000030f10032000000"),
"CMSG_AUCTION_LIST_OWNER_ITEMS body"
);
}
#[test]
fn auction_list_items_body_golden() {
// CMSG_AUCTION_LIST_ITEMS with every filter set: u64 auctioneer, u32 listfrom, cstr name,
// u8 levelmin, u8 levelmax, u32 slotId, u32 mainCategory, u32 subCategory, u32 quality,
// u8 usable. TEN fields — no sort column, no sort count, no trailing padding on 5875.
assert_eq!(
messages::auction_list_items(AUCTIONEER, 50, "Copper", 10, 20, 1, 2, 3, 4, 1),
hx(concat!(
"550000000030f100", // auctioneer
"32000000", // listfrom 50
"436f7070657200", // "Copper\0"
"0a", // levelmin 10
"14", // levelmax 20
"01000000", // slotId
"02000000", // mainCategory
"03000000", // subCategory
"04000000", // quality (a MINIMUM, not an equality)
"01", // usable
)),
"CMSG_AUCTION_LIST_ITEMS body (filters set)"
);
// The default browse: every filter at its sentinel and an EMPTY search name — which is just
// the lone NUL byte between `listfrom` and `levelmin`, nothing more. This exact body is the
// one that puts vmangos on its no-filter fast path.
let body = messages::auction_list_items(
AUCTIONEER,
0,
"",
auction_filter::ANY_LEVEL,
auction_filter::ANY_LEVEL,
auction_filter::ANY,
auction_filter::ANY,
auction_filter::ANY,
auction_filter::ANY,
auction_filter::ANY_USABILITY,
);
assert_eq!(
body,
hx(concat!(
"550000000030f100", // auctioneer
"00000000", // listfrom 0
"00", // "" — the lone NUL, and the whole of the name field
"00", // levelmin 0
"00", // levelmax 0
"ffffffff", // slotId ANY
"ffffffff", // mainCategory ANY
"ffffffff", // subCategory ANY
"ffffffff", // quality ANY
"00", // usable 0
)),
"CMSG_AUCTION_LIST_ITEMS body (empty name, all sentinels)"
);
// 8 + 4 + 1 + 1 + 1 + 4*4 + 1 — the tightest possible browse body. Stated as a number so an
// invented trailing field cannot slip in behind a hex string nobody re-counts.
assert_eq!(body.len(), 32, "no trailing sort bytes ride this opcode");
}
#[test]
fn auction_list_bidder_items_body_golden() {
// CMSG_AUCTION_LIST_BIDDER_ITEMS with NO refresh ids: u64 auctioneer, u32 listfrom, u32 0.
// The count field is always present even when the list is empty.
assert_eq!(
messages::auction_list_bidder_items(AUCTIONEER, 0, &[]),
hx("550000000030f1000000000000000000"),
"CMSG_AUCTION_LIST_BIDDER_ITEMS body (no ids)"
);
// ...and with several: the count, then that many u32 auction ids.
assert_eq!(
messages::auction_list_bidder_items(AUCTIONEER, 50, &[7, 4242, 0xDEAD_BEEF]),
hx(concat!(
"550000000030f100", // auctioneer
"32000000", // listfrom 50
"03000000", // 3 ids follow
"07000000", // id 7
"92100000", // id 4242
"efbeadde", // id 0xDEADBEEF
)),
"CMSG_AUCTION_LIST_BIDDER_ITEMS body (3 ids)"
);
}
#[test]
fn auction_hello_reply_wire() {
// MSG_AUCTION_HELLO's reply (same opcode as our request): u64 auctioneerGuid, u32 houseId.
let mut body = AUCTIONEER.to_le_bytes().to_vec();
body.extend_from_slice(&6u32.to_le_bytes()); // houseId 6 (Orgrimmar), AuctionHouse.dbc 1..7
match messages::parse_server(messages::opcode::MSG_AUCTION_HELLO, &body).unwrap() {
ServerPacket::AuctionHello {
auctioneer,
house_id,
} => assert_eq!((auctioneer, house_id), (AUCTIONEER, 6)),
other => panic!("auction hello, got {}", other.name()),
}
// The decode() bridge carries both fields through unchanged.
let packet = messages::parse_server(messages::opcode::MSG_AUCTION_HELLO, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::AuctionHello {
auctioneer,
house_id,
} => assert_eq!((auctioneer, house_id), (AUCTIONEER, 6)),
other => panic!("auction hello event, got {other:?}"),
}
}
/// Build one `SMSG_AUCTION_COMMAND_RESULT` head: `u32 auctionId, u32 action, u32 error`.
fn command_head(auction_id: u32, action: u32, error: u32) -> Vec<u8> {
let mut body = auction_id.to_le_bytes().to_vec();
body.extend_from_slice(&action.to_le_bytes());
body.extend_from_slice(&error.to_le_bytes());
body
}
fn parse_command(body: &[u8]) -> (u32, u32, u32, AuctionCommandTail) {
match messages::parse_server(messages::opcode::SMSG_AUCTION_COMMAND_RESULT, body).unwrap() {
ServerPacket::AuctionCommandResult {
auction_id,
action,
error,
tail,
} => (auction_id, action, error, tail),
other => panic!("auction command result, got {}", other.name()),
}
}
#[test]
fn auction_command_result_bare_wire() {
// The bare 3-u32 form: OK + STARTED carries NO tail (the server's OK arm writes one only for
// BID_PLACED). This is the shape a successful listing and a successful cancel both take.
let body = command_head(4242, auction_action::STARTED, auction_error::OK);
assert_eq!(body.len(), 12);
assert_eq!(
parse_command(&body),
(
4242,
auction_action::STARTED,
auction_error::OK,
AuctionCommandTail::Empty
)
);
// OK + REMOVED, likewise bare.
assert_eq!(
parse_command(&command_head(
4242,
auction_action::REMOVED,
auction_error::OK
))
.3,
AuctionCommandTail::Empty
);
// A named error with no tail of its own (NOT_ENOUGH_MONEY) — bare too, and `auction_id` is
// `0` because the server had no AuctionEntry to name.
assert_eq!(
parse_command(&command_head(
0,
auction_action::BID_PLACED,
auction_error::NOT_ENOUGH_MONEY
)),
(
0,
auction_action::BID_PLACED,
auction_error::NOT_ENOUGH_MONEY,
AuctionCommandTail::Empty
)
);
// An UNNAMED error code (6, 8, 9, 11, 12 have no vmangos enumerator) also decodes bare rather
// than erroring — the real client falls through to a generic failure branch for these.
assert_eq!(
parse_command(&command_head(0, auction_action::BID_PLACED, 9)).3,
AuctionCommandTail::Empty
);
// The decode() bridge, asserted on this shape.
let packet = messages::parse_server(
messages::opcode::SMSG_AUCTION_COMMAND_RESULT,
&command_head(4242, auction_action::STARTED, auction_error::OK),
)
.unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::AuctionCommandResult {
auction_id,
action,
error,
tail,
} => assert_eq!(
(auction_id, action, error, tail),
(
4242,
auction_action::STARTED,
auction_error::OK,
AuctionCommandTail::Empty
)
),
other => panic!("auction command result event, got {other:?}"),
}
}
#[test]
fn auction_command_result_bid_placed_tail_wire() {
// OK **and** BID_PLACED: one trailing u32 newMinOutBid. Both fields have to match — an OK on
// any other action carries nothing (asserted in the bare test above).
let mut body = command_head(4242, auction_action::BID_PLACED, auction_error::OK);
body.extend_from_slice(&617u32.to_le_bytes());
assert_eq!(
parse_command(&body),
(
4242,
auction_action::BID_PLACED,
auction_error::OK,
AuctionCommandTail::BidPlaced {
new_min_outbid: 617
}
)
);
}
#[test]
fn auction_command_result_inventory_tail_wire() {
// INVENTORY: one trailing u32 InventoryResult (EQUIP_ERR_*). Keyed on the ERROR alone — the
// action here is STARTED, which carries no tail under OK.
let mut body = command_head(0, auction_action::STARTED, auction_error::INVENTORY);
body.extend_from_slice(&2u32.to_le_bytes()); // EQUIP_ERR_* code
assert_eq!(
parse_command(&body),
(
0,
auction_action::STARTED,
auction_error::INVENTORY,
AuctionCommandTail::Inventory { result: 2 }
)
);
}
#[test]
fn auction_command_result_higher_bid_tail_wire() {
// HIGHER_BID: u64 newBidder, u32 newBid, u32 newMinOutBid — 16 trailing bytes, the longest of
// the three tails and the one a reader most easily under-reads.
let mut body = command_head(4242, auction_action::BID_PLACED, auction_error::HIGHER_BID);
body.extend_from_slice(&0x0000_0000_0000_07D1u64.to_le_bytes()); // new bidder
body.extend_from_slice(&25_000u32.to_le_bytes()); // new bid
body.extend_from_slice(&1_250u32.to_le_bytes()); // new min outbid
assert_eq!(body.len(), 12 + 16);
assert_eq!(
parse_command(&body),
(
4242,
auction_action::BID_PLACED,
auction_error::HIGHER_BID,
AuctionCommandTail::HigherBid {
new_bidder_guid: 0x07D1,
new_bid: 25_000,
new_min_outbid: 1_250,
}
)
);
}
/// Append one 64-byte list-result record in `AuctionEntry::BuildAuctionInfo`'s order.
fn push_record(body: &mut Vec<u8>, e: &AuctionListEntry) {
let before = body.len();
body.extend_from_slice(&e.auction_id.to_le_bytes());
body.extend_from_slice(&e.item_entry.to_le_bytes());
body.extend_from_slice(&e.perm_enchant.to_le_bytes());
body.extend_from_slice(&e.random_property_id.to_le_bytes());
body.extend_from_slice(&e.suffix_factor.to_le_bytes());
body.extend_from_slice(&e.count.to_le_bytes());
body.extend_from_slice(&e.spell_charges.to_le_bytes());
body.extend_from_slice(&e.owner_guid.to_le_bytes());
body.extend_from_slice(&e.start_bid.to_le_bytes());
body.extend_from_slice(&e.min_increment.to_le_bytes());
body.extend_from_slice(&e.buyout.to_le_bytes());
body.extend_from_slice(&e.time_left_ms.to_le_bytes());
body.extend_from_slice(&e.bidder_guid.to_le_bytes());
body.extend_from_slice(&e.current_bid.to_le_bytes());
// The record's width is load-bearing (it is the reader's short-buffer bound) and decision
// 1511's prose miscounts it as 60; pin it here so the two can never drift apart silently.
assert_eq!(body.len() - before, AUCTION_RECORD_BYTES);
assert_eq!(AUCTION_RECORD_BYTES, 64);
}
/// A no-bids row: `min_increment`/`bidder_guid`/`current_bid` all zero, a buyout set.
fn unbid_row(auction_id: u32) -> AuctionListEntry {
AuctionListEntry {
auction_id,
item_entry: 2589, // Linen Cloth
perm_enchant: 0,
random_property_id: 0,
suffix_factor: 0,
count: 20,
spell_charges: 0,
owner_guid: 0x0000_0000_0000_0101,
start_bid: 5_000,
min_increment: 0,
buyout: 20_000,
time_left_ms: 7_200_000,
bidder_guid: 0,
current_bid: 0,
}
}
fn parse_list(opcode: u16, body: &[u8]) -> (Vec<AuctionListEntry>, u32) {
match messages::parse_server(opcode, body).unwrap() {
ServerPacket::AuctionListResult {
auctions,
total_count,
}
| ServerPacket::AuctionOwnerListResult {
auctions,
total_count,
}
| ServerPacket::AuctionBidderListResult {
auctions,
total_count,
} => (auctions, total_count),
other => panic!("auction list result, got {}", other.name()),
}
}
#[test]
fn auction_list_result_empty_wire() {
// count 0, no records, then the trailing totalCount — which can still be nonzero (page 2 of a
// one-page result set asked past the end).
let mut body = 0u32.to_le_bytes().to_vec();
body.extend_from_slice(&37u32.to_le_bytes());
assert_eq!(body.len(), 8);
// All three list opcodes share this frame and this record; assert on each so a future arm
// cannot be wired to the wrong reader.
for opcode in [
messages::opcode::SMSG_AUCTION_LIST_RESULT,
messages::opcode::SMSG_AUCTION_OWNER_LIST_RESULT,
messages::opcode::SMSG_AUCTION_BIDDER_LIST_RESULT,
] {
let (auctions, total_count) = parse_list(opcode, &body);
assert!(auctions.is_empty());
assert_eq!(total_count, 37);
}
}
#[test]
fn auction_list_result_records_wire() {
// Three rows through the real parse_server entry point, then the trailing totalCount.
let rows = [
unbid_row(1),
AuctionListEntry {
auction_id: 2,
item_entry: 12_640, // Lionheart Helm
perm_enchant: 2_504,
random_property_id: 0,
suffix_factor: 0,
count: 1,
spell_charges: 0,
owner_guid: 0x0000_0000_0000_0202,
start_bid: 1_000_000,
min_increment: 60_000,
buyout: 0, // no buyout
time_left_ms: 86_400_000,
bidder_guid: 0x0000_0000_0000_0303,
current_bid: 1_200_000,
},
unbid_row(3),
];
let mut body = (rows.len() as u32).to_le_bytes().to_vec();
for row in &rows {
push_record(&mut body, row);
}
body.extend_from_slice(&129u32.to_le_bytes()); // totalCount, well past the 50-row page cap
assert_eq!(body.len(), 4 + 3 * AUCTION_RECORD_BYTES + 4);
let (auctions, total_count) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
assert_eq!(auctions, rows);
assert_eq!(total_count, 129);
// Field semantics worth pinning rather than assuming: `min_increment` is 0 while nobody has
// bid, `buyout` 0 means no buyout, and `start_bid` is NOT the current bid.
assert_eq!(auctions[0].min_increment, 0);
assert_eq!(auctions[0].current_bid, 0);
assert_eq!(auctions[1].buyout, 0);
assert_ne!(auctions[1].start_bid, auctions[1].current_bid);
// The decode() bridge carries the rows and the total through unchanged.
let packet = messages::parse_server(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::AuctionListResult {
auctions,
total_count,
} => {
assert_eq!(auctions.len(), 3);
assert_eq!(total_count, 129);
}
other => panic!("auction list result event, got {other:?}"),
}
// The owner/bidder opcodes land on their own variants off the same bytes.
let packet =
messages::parse_server(messages::opcode::SMSG_AUCTION_OWNER_LIST_RESULT, &body).unwrap();
assert!(matches!(
decode(packet).pop().unwrap(),
SessionEvent::AuctionOwnerListResult { .. }
));
let packet =
messages::parse_server(messages::opcode::SMSG_AUCTION_BIDDER_LIST_RESULT, &body).unwrap();
assert!(matches!(
decode(packet).pop().unwrap(),
SessionEvent::AuctionBidderListResult { .. }
));
}
#[test]
fn auction_list_result_total_count_rides_after_the_records() {
// `totalCount` is at the very END of the body, not beside the leading `count`. Build ONE
// record whose every field differs from the total, so a reader that took the total from the
// front (or from any record field) is caught: the leading count is 1, the total is 9999, and
// no record field holds 9999.
let row = AuctionListEntry {
auction_id: 11,
item_entry: 12,
perm_enchant: 13,
random_property_id: 14,
suffix_factor: 15,
count: 16,
spell_charges: 17,
owner_guid: 18,
start_bid: 19,
min_increment: 20,
buyout: 21,
time_left_ms: 22,
bidder_guid: 23,
current_bid: 24,
};
let mut body = 1u32.to_le_bytes().to_vec();
push_record(&mut body, &row);
body.extend_from_slice(&9_999u32.to_le_bytes());
let (auctions, total_count) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
assert_eq!(auctions, [row], "the record reads intact");
assert_eq!(
total_count, 9_999,
"totalCount comes from AFTER the records, not from the head or a record field"
);
}
#[test]
fn auction_list_record_round_trips_negative_signed_fields() {
// `random_property_id` and `spell_charges` are SIGNED (the server casts int32 through uint32).
// A negative random property id is a random *suffix* ("of the Bear"); a negative charge count
// means "N charges then destroy". Read unsigned, both become ~4.29-billion nonsense.
let row = AuctionListEntry {
auction_id: 77,
item_entry: 7_078,
perm_enchant: 0,
random_property_id: -19, // a suffix id
suffix_factor: 143,
count: 1,
spell_charges: -5, // 5 charges, then destroy
owner_guid: 0x0000_0000_0000_0404,
start_bid: 1,
min_increment: 0,
buyout: 0,
time_left_ms: 1_000,
bidder_guid: 0,
current_bid: 0,
};
let mut body = 1u32.to_le_bytes().to_vec();
push_record(&mut body, &row);
body.extend_from_slice(&1u32.to_le_bytes());
let (auctions, _) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
assert_eq!(auctions[0].random_property_id, -19);
assert_eq!(auctions[0].spell_charges, -5);
assert_eq!(auctions[0].suffix_factor, 143);
// And the unclamped time_left: an expired-but-unswept auction wraps rather than clamping to
// 0. The reader must pass it through faithfully; judging it is the consumer's job.
let mut expired = unbid_row(78);
expired.time_left_ms = u32::MAX - 500; // (expire - now) * 1000 on a negative difference
let mut body = 1u32.to_le_bytes().to_vec();
push_record(&mut body, &expired);
body.extend_from_slice(&1u32.to_le_bytes());
let (auctions, _) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
assert_eq!(auctions[0].time_left_ms, u32::MAX - 500);
}
#[test]
fn auction_list_result_survives_a_count_larger_than_the_records() {
// vmangos's browse fast path increments `count` for a record it then fails to write (a stale
// auction whose item row is gone writes ZERO bytes and still counts). A body claiming 3 and
// carrying 2 must yield those 2 rather than failing the whole page.
let rows = [unbid_row(1), unbid_row(2)];
let mut body = 3u32.to_le_bytes().to_vec(); // the server's inflated count
for row in &rows {
push_record(&mut body, row);
}
body.extend_from_slice(&2u32.to_le_bytes()); // totalCount still rides at the end
let (auctions, total_count) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
assert_eq!(auctions, rows, "the records that DID arrive come back");
assert_eq!(total_count, 2);
// The harsher variant: truncated past the last record, so even the trailing total is missing.
// Still not an error — the total falls back to what we read.
let mut body = 3u32.to_le_bytes().to_vec();
for row in &rows {
push_record(&mut body, row);
}
let (auctions, total_count) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
assert_eq!(auctions, rows);
assert_eq!(total_count, 2, "falls back to the records actually read");
// And a count that is pure nonsense (a hostile/desynced body) must not allocate on its word
// alone or error — it yields the one record the buffer holds.
let mut body = u32::MAX.to_le_bytes().to_vec();
push_record(&mut body, &rows[0]);
let (auctions, _) = parse_list(messages::opcode::SMSG_AUCTION_LIST_RESULT, &body);
assert_eq!(auctions, [rows[0]]);
}
#[test]
fn auction_bidder_notification_wire() {
// SMSG_AUCTION_BIDDER_NOTIFICATION: u32 houseId, u32 auctionId, u64 bidderGuid,
// u32 bidOrZero, u32 outBid, u32 itemEntry, i32 randomPropertyId — houseId FIRST and the
// guid THIRD, unlike the owner notification.
let mut body = 6u32.to_le_bytes().to_vec(); // houseId
body.extend_from_slice(&4242u32.to_le_bytes()); // auctionId
body.extend_from_slice(&0x0000_0000_0000_0505u64.to_le_bytes()); // bidderGuid
body.extend_from_slice(&15_000u32.to_le_bytes()); // bidOrZero — nonzero: OUTBID
body.extend_from_slice(&750u32.to_le_bytes()); // outBid
body.extend_from_slice(&12_640u32.to_le_bytes()); // itemEntry
body.extend_from_slice(&(-19i32).to_le_bytes()); // randomPropertyId, signed
let expected = AuctionBidderNotification {
house_id: 6,
auction_id: 4242,
bidder_guid: 0x0505,
bid_or_zero: 15_000,
out_bid: 750,
item_entry: 12_640,
random_property_id: -19,
};
match messages::parse_server(messages::opcode::SMSG_AUCTION_BIDDER_NOTIFICATION, &body).unwrap()
{
ServerPacket::AuctionBidderNotification(n) => assert_eq!(n, expected),
other => panic!("auction bidder notification, got {}", other.name()),
}
// bidOrZero == 0 means WON, not "no bid" — the one field whose zero is a *state*, and the
// reason this notification cannot share a struct with the owner one.
let mut won = body.clone();
won[16..20].copy_from_slice(&0u32.to_le_bytes());
match messages::parse_server(messages::opcode::SMSG_AUCTION_BIDDER_NOTIFICATION, &won).unwrap()
{
ServerPacket::AuctionBidderNotification(n) => {
assert_eq!(n.bid_or_zero, 0, "0 = WON");
assert_eq!(n.auction_id, 4242, "the rest of the row is unmoved");
assert_eq!(n.out_bid, 750);
}
other => panic!("auction bidder notification (won), got {}", other.name()),
}
// The two notifications must never share a reader. Feed these same bytes to the OWNER opcode
// (28 bytes needed, 32 available, so it parses rather than EOF-ing) and every field lands
// somewhere else: the house id is eaten as the auction id and the guid slides four bytes.
match messages::parse_server(messages::opcode::SMSG_AUCTION_OWNER_NOTIFICATION, &body).unwrap()
{
ServerPacket::AuctionOwnerNotification(n) => {
assert_eq!(
n.auction_id, 6,
"the owner reader eats houseId as auctionId"
);
assert_ne!(n.bidder_guid, expected.bidder_guid, "the guid slides");
assert_ne!(n.item_entry, expected.item_entry);
}
other => panic!("auction owner notification, got {}", other.name()),
}
let packet =
messages::parse_server(messages::opcode::SMSG_AUCTION_BIDDER_NOTIFICATION, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::AuctionBidderNotification(n) => assert_eq!(n, expected),
other => panic!("auction bidder notification event, got {other:?}"),
}
}
#[test]
fn auction_owner_notification_wire() {
// SMSG_AUCTION_OWNER_NOTIFICATION: u32 auctionId, u32 bid, u32 outBid, u64 bidderGuid,
// u32 itemEntry, i32 randomPropertyId — NO houseId, and the guid sits FOURTH. Deliberately a
// different shape from the bidder notification above.
let mut body = 4242u32.to_le_bytes().to_vec(); // auctionId
body.extend_from_slice(&15_000u32.to_le_bytes()); // bid
body.extend_from_slice(&750u32.to_le_bytes()); // outBid
body.extend_from_slice(&0x0000_0000_0000_0505u64.to_le_bytes()); // bidderGuid
body.extend_from_slice(&12_640u32.to_le_bytes()); // itemEntry
body.extend_from_slice(&(-19i32).to_le_bytes()); // randomPropertyId, signed
assert_eq!(body.len(), 28, "four bytes shorter than the bidder notice");
let expected = AuctionOwnerNotification {
auction_id: 4242,
bid: 15_000,
out_bid: 750,
bidder_guid: 0x0505,
item_entry: 12_640,
random_property_id: -19,
};
match messages::parse_server(messages::opcode::SMSG_AUCTION_OWNER_NOTIFICATION, &body).unwrap()
{
ServerPacket::AuctionOwnerNotification(n) => assert_eq!(n, expected),
other => panic!("auction owner notification, got {}", other.name()),
}
// An all-zero bidder guid is the "sold" signal, not a missing bidder.
let mut sold = body.clone();
sold[12..20].copy_from_slice(&0u64.to_le_bytes());
match messages::parse_server(messages::opcode::SMSG_AUCTION_OWNER_NOTIFICATION, &sold).unwrap()
{
ServerPacket::AuctionOwnerNotification(n) => {
assert_eq!(n.bidder_guid, 0, "0 = sold");
assert_eq!(n.item_entry, 12_640, "the rest of the row is unmoved");
}
other => panic!("auction owner notification (sold), got {}", other.name()),
}
let packet =
messages::parse_server(messages::opcode::SMSG_AUCTION_OWNER_NOTIFICATION, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::AuctionOwnerNotification(n) => assert_eq!(n, expected),
other => panic!("auction owner notification event, got {other:?}"),
}
}
#[test]
fn auction_removed_notification_wire() {
// SMSG_AUCTION_REMOVED_NOTIFICATION: u32 auctionId, u32 itemEntry, i32 randomPropertyId.
let mut body = 4242u32.to_le_bytes().to_vec();
body.extend_from_slice(&2589u32.to_le_bytes());
body.extend_from_slice(&(-7i32).to_le_bytes());
match messages::parse_server(messages::opcode::SMSG_AUCTION_REMOVED_NOTIFICATION, &body)
.unwrap()
{
ServerPacket::AuctionRemovedNotification {
auction_id,
item_entry,
random_property_id,
} => assert_eq!(
(auction_id, item_entry, random_property_id),
(4242, 2589, -7)
),
other => panic!("auction removed notification, got {}", other.name()),
}
let packet =
messages::parse_server(messages::opcode::SMSG_AUCTION_REMOVED_NOTIFICATION, &body).unwrap();
match decode(packet).pop().unwrap() {
SessionEvent::AuctionRemovedNotification {
auction_id,
item_entry,
random_property_id,
} => assert_eq!(
(auction_id, item_entry, random_property_id),
(4242, 2589, -7)
),
other => panic!("auction removed notification event, got {other:?}"),
}
}