From 54dae218e4beb162c40904ed9e104c169e1c2fa2 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 15 Oct 2026 08:17:55 +0200 Subject: [PATCH 1/6] feat(module): tell rollers when all pass and the victim when gear loses durability Co-Authored-By: Claude Sonnet 5.5 --- crates/lyracore-shared/src/group.rs | 8 +++- crates/lyracore-shared/src/loot_roll.rs | 38 ++++++++++++++++ module/src/combat/death.rs | 10 ++++- module/src/items/ops.rs | 5 ++- module/src/loot/rolls.rs | 9 ++++ module/tests/death_durability_notice.rs | 59 +++++++++++++++++++++++++ module/tests/loot_roll_promotion.rs | 38 ++++++++++++++++ 7 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 module/tests/death_durability_notice.rs diff --git a/crates/lyracore-shared/src/group.rs b/crates/lyracore-shared/src/group.rs index 15e6e07b9..53335752d 100644 --- a/crates/lyracore-shared/src/group.rs +++ b/crates/lyracore-shared/src/group.rs @@ -141,6 +141,7 @@ pub mod event_kind { pub const LOOT_ROLL_RESERVED_START: u8 = 4; // Send SMSG_GROUP_SET_LEADER before the LIST naming the new leader. pub const SET_LEADER: u8 = 20; + pub const DEATH_DURABILITY: u8 = 13; pub const READY_CHECK: u8 = 21; pub const READY_CHECK_ANSWER: u8 = 22; pub const TARGET_ICON_UPDATE: u8 = 23; @@ -464,6 +465,8 @@ mod tests { roll::ROLL_WON, roll::MASTER_LIST, roll::MONEY_SHARE, + roll::ALL_PASSED, + event_kind::DEATH_DURABILITY, share::QUEST_SHARE, share::QUEST_PUSH_RESULT, event_kind::SET_LEADER, @@ -482,7 +485,10 @@ mod tests { sorted.sort_unstable(); assert_eq!( sorted, - [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30] + [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, + 30 + ] ); assert_eq!(event_kind::LOOT_ROLL_RESERVED_START, roll::ROLL_START); } diff --git a/crates/lyracore-shared/src/loot_roll.rs b/crates/lyracore-shared/src/loot_roll.rs index d95407bf9..e8b7c1bd5 100644 --- a/crates/lyracore-shared/src/loot_roll.rs +++ b/crates/lyracore-shared/src/loot_roll.rs @@ -6,6 +6,10 @@ pub mod event_kind { pub const ROLL_WON: u8 = 6; pub const MASTER_LIST: u8 = 7; pub const MONEY_SHARE: u8 = 8; + /// Every eligible member passed on one item → `SMSG_LOOT_ALL_PASSED`, to every roller. Outside + /// the reserved `4..=8` block (kind 9 is retired); the byte space is listed in + /// `crate::group::event_kind`. + pub const ALL_PASSED: u8 = 12; } // START preserves the World Shard deadline so promotion does not restart the roll clock. @@ -121,6 +125,29 @@ pub fn decode_won(payload: &str) -> Option<(u64, u8, u32, u8, u8, u32)> { )) } +/// Encode an `ALL_PASSED` payload: `corpse_guid,slot,item_entry,random_property_id`. +pub fn encode_all_passed( + corpse_guid: u64, + slot: u8, + item_entry: u32, + random_property_id: u32, +) -> String { + format!("{corpse_guid},{slot},{item_entry},{random_property_id}") +} + +/// Decode an `ALL_PASSED` payload back to `(corpse_guid, slot, item_entry, random_property_id)`. +pub fn decode_all_passed(payload: &str) -> Option<(u64, u8, u32, u32)> { + let mut p = payload.split(','); + let corpse_guid: u64 = p.next()?.parse().ok()?; + let slot: u8 = p.next()?.parse().ok()?; + let item_entry: u32 = p.next()?.parse().ok()?; + let random_property_id: u32 = p.next()?.parse().ok()?; + if p.next().is_some() { + return None; + } + Some((corpse_guid, slot, item_entry, random_property_id)) +} + pub fn encode_master_list(corpse_guid: u64, eligible: &[u64]) -> String { let guids: Vec = eligible.iter().map(|g| g.to_string()).collect(); format!("{corpse_guid}|{}", guids.join(",")) @@ -252,6 +279,17 @@ mod tests { assert_eq!(kinds, (0u8..=8).collect::>()); } + #[test] + fn all_passed_round_trips_and_fails_closed() { + assert_eq!( + decode_all_passed(&encode_all_passed(555, 2, 1234, 117)), + Some((555, 2, 1234, 117)) + ); + assert!(decode_all_passed("555,2,1234").is_none()); + assert!(decode_all_passed("555,2,1234,0,9").is_none()); + assert!(decode_all_passed("x,2,1234,0").is_none()); + } + #[test] fn money_share_round_trips() { let wire = encode_money_share(37); diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index 797d99237..fc69fb738 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -706,7 +706,15 @@ pub(crate) fn kill_player(ctx: &ReducerContext, victim_guid: u64, killer_guid: u crate::spell::record_self_resurrect_option(ctx, victim_guid); crate::spell::remove_auras_on_death(ctx, victim_guid); crate::trade::cancel_trade_for(ctx, victim_guid); - crate::items::apply_death_durability_loss(ctx, victim_guid); + if crate::items::apply_death_durability_loss(ctx, victim_guid) { + crate::group::push_event( + ctx, + victim_guid, + lyracore_shared::group::event_kind::DEATH_DURABILITY, + 0, + String::new(), + ); + } crate::hooks::fire_on_death( ctx, &crate::hooks::DeathPayload { diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index 10fb56ca4..6af1bf0a9 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -691,7 +691,8 @@ pub(crate) fn death_durability_loss(max_durability: u32, durability: u32) -> u32 durability.saturating_sub(loss) } -pub(crate) fn apply_death_durability_loss(ctx: &ReducerContext, player_guid: u64) { +pub(crate) fn apply_death_durability_loss(ctx: &ReducerContext, player_guid: u64) -> bool { + let mut any_lost = false; let instances = ctx.db.game_item_instance(); let templates = ctx.db.game_item_template(); let equipped: Vec<_> = instances @@ -705,9 +706,11 @@ pub(crate) fn apply_death_durability_loss(ctx: &ReducerContext, player_guid: u64 if new_durability != inst.durability { inst.durability = new_durability; instances.guid().update(inst); + any_lost = true; } } } + any_lost } pub(crate) fn request_take_loot( diff --git a/module/src/loot/rolls.rs b/module/src/loot/rolls.rs index 8c7576300..bf547745c 100644 --- a/module/src/loot/rolls.rs +++ b/module/src/loot/rolls.rs @@ -461,6 +461,15 @@ fn resolve_roll(ctx: &ReducerContext, roll: &LootRoll, votes: &[LootRollVote], r let tier = contending_tier(&kinds); if tier.is_empty() { unlock_row_ffa(ctx, roll.corpse_guid, roll.slot); + let payload = lyracore_shared::loot_roll::encode_all_passed( + roll.corpse_guid, + roll.slot, + roll.item_entry, + roll.random_property_id, + ); + for &guid in recipients { + crate::group::push_event(ctx, guid, roll_event_kind::ALL_PASSED, 0, payload.clone()); + } cleanup_roll(ctx, roll.id); return; } diff --git a/module/tests/death_durability_notice.rs b/module/tests/death_durability_notice.rs new file mode 100644 index 000000000..b3db8cade --- /dev/null +++ b/module/tests/death_durability_notice.rs @@ -0,0 +1,59 @@ +//! A player death that damages worn gear queues the one-recipient notice the Gateway relays as +//! `SMSG_DURABILITY_DAMAGE_DEATH`. A death with nothing to damage queues none. + +mod support; + +use support::Standalone; + +const BLADE: &str = "5090050"; +const NOTICE_KIND: u8 = lyracore_shared::group::event_kind::DEATH_DURABILITY; + +fn fixture(name: &str) -> Standalone { + let mut shard = Standalone::start(name); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + shard.assert_sql("DELETE FROM game_item_instance"); + shard +} + +fn notices(shard: &Standalone) -> Vec { + shard + .query_rows(&format!( + "SELECT recipient_guid FROM game_group_event WHERE kind = {NOTICE_KIND}" + )) + .into_iter() + .map(|row| row["recipient_guid"].clone()) + .collect() +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_death_that_damages_worn_gear_tells_the_victim_once() { + let shard = fixture("death-durability-notice"); + shard.assert_call("debug_grant_item", &["1", BLADE, "1"]); + shard.assert_sql(&format!( + "UPDATE game_item_template SET max_durability = 100 WHERE entry = {BLADE}" + )); + shard.assert_sql( + "UPDATE game_item_instance SET slot = 15, durability = 100 WHERE owner_guid = 1", + ); + + shard.assert_call("debug_set_health", &["1", "0"]); + + let worn = shard.query_rows("SELECT durability FROM game_item_instance WHERE owner_guid = 1"); + assert_eq!(worn[0]["durability"], "90", "ten percent of the maximum"); + assert_eq!(notices(&shard), ["1"]); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_death_with_nothing_to_damage_sends_no_notice() { + let shard = fixture("death-durability-bare"); + + shard.assert_call("debug_set_health", &["1", "0"]); + + assert!(notices(&shard).is_empty()); +} diff --git a/module/tests/loot_roll_promotion.rs b/module/tests/loot_roll_promotion.rs index 7d2e2364e..e2ef72999 100644 --- a/module/tests/loot_roll_promotion.rs +++ b/module/tests/loot_roll_promotion.rs @@ -352,3 +352,41 @@ fn votes(standalone: &Standalone) -> Vec> { rows.sort_by(|a, b| a["id"].cmp(&b["id"])); rows } + +/// `(recipient, payload)` of every event of `kind`, in recipient order. +fn events_of(standalone: &Standalone, kind: u8) -> Vec<(String, String)> { + let mut events: Vec<_> = standalone + .query_rows(&format!( + "SELECT recipient_guid, payload FROM game_group_event WHERE kind = {kind}" + )) + .into_iter() + .map(|row| (row["recipient_guid"].clone(), row["payload"].clone())) + .collect(); + events.sort_unstable(); + events +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn an_all_pass_resolution_tells_every_roller_and_a_win_does_not() { + use lyracore_shared::loot_roll::event_kind::{ALL_PASSED, ROLL_WON}; + let standalone = fixture("loot-roll-all-passed"); + let mut promotion = Promotion::default(); + promotion.send(&standalone); + resolve(&standalone); + let payload = format!("{CORPSE},0,{ITEM},117"); + assert_eq!( + events_of(&standalone, ALL_PASSED), + ["5090003", "5090004", "5090005"].map(|roller| (roller.to_string(), payload.clone())) + ); + assert!(events_of(&standalone, ROLL_WON).is_empty()); + + // A contested roll announces its winner and adds no all-passed notice. + promotion.source_roll_id = 11; + promotion.send(&standalone); + vote(&standalone, "5090003", "1"); + vote(&standalone, "5090004", "0"); + vote(&standalone, "5090005", "0"); + assert_eq!(events_of(&standalone, ALL_PASSED).len(), 3); + assert_eq!(events_of(&standalone, ROLL_WON).len(), 3); +} From c73f3b6ad449ae4d96d9642d8e68a90065b19f29 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 15 Oct 2026 08:17:55 +0200 Subject: [PATCH 2/6] feat(gateway): answer item name queries, sale refusals and loot notices Add CMSG_ITEM_NAME_QUERY, SMSG_SELL_ITEM refusals, CMSG_READ_ITEM failure, SMSG_LOOT_ALL_PASSED and SMSG_DURABILITY_DAMAGE_DEATH. Ignore the unhandled vanilla requests as vmangos does. Co-Authored-By: Claude Sonnet 5.5 --- docs/benilla-compatibility.md | 3 + gateway/examples/benilla_inventory.rs | 5 + gateway/src/codec/item.rs | 112 +++++++++++ gateway/src/codec/loot.rs | 44 ++++- gateway/src/codec/social/subscriptions.rs | 18 ++ gateway/src/stdb/store/item.rs | 39 +++- gateway/src/world/handlers/item.rs | 176 +++++++++++++++++- gateway/src/world/handlers/loot.rs | 55 ++++++ gateway/src/world/handlers/mod.rs | 2 +- gateway/src/world/handlers/unavailable.rs | 12 ++ gateway/src/world/handlers/vendor.rs | 95 +++++++--- gateway/src/world/mod.rs | 2 +- gateway/src/world/routing.rs | 2 + .../src/world/test_support/world_fake/item.rs | 13 ++ 14 files changed, 550 insertions(+), 28 deletions(-) diff --git a/docs/benilla-compatibility.md b/docs/benilla-compatibility.md index cc566bbc8..b303195d4 100644 --- a/docs/benilla-compatibility.md +++ b/docs/benilla-compatibility.md @@ -250,6 +250,9 @@ feature once per World Session. Repeated requests do not add chat lines. | Pet controls | Specific System Messages for bar changes, autocast, aura cancellation, stop-attack, naming, abandonment and unlearning. | These operations and their durable state. Existing pet action commands and progression remain separate. | | Item containers and gifts | Inventory Refusal 39. | Item-container loot, wrapping and unwrapping. | | Readable pages | A terminal page names the unavailable text service. | Page-text catalog import and page chains. | +| Reading items | Read failure with the close reason and a System Message, for an item with page text. An item without page text gets Inventory Refusal 23. | Page-text catalog import, then the read-OK reply. | +| Limited vendor stock, item timers and item cooldowns | No `SMSG_BUY_ITEM`, `SMSG_ITEM_TIME_UPDATE`, `SMSG_ITEM_ENCHANT_TIME_UPDATE` or `SMSG_ITEM_COOLDOWN`. | Stock depletion and restock, item duration and the equip cooldown. | +| Skill points, ground-item and loot-slot stores, lottery | Ignored without a reply, as vmangos and cmangos do. | Nothing in 1.12. | | Explicit vendor destination | Inventory Refusal 39. | An atomic purchase into the requested bag and slot. Ordinary vendor purchase uses the existing path. | | Automatic bag placement | Equipment-to-backpack uses the existing path; other requests receive Inventory Refusal 39. | Automatic placement into a chosen bag and movement of nonempty equipped bags. Explicit supported slot moves work. | | Character presentation | Specific System Messages. | Stand state, mount flourish, helm and cloak visibility preferences. | diff --git a/gateway/examples/benilla_inventory.rs b/gateway/examples/benilla_inventory.rs index 76a4f5f08..a06fa7c8f 100644 --- a/gateway/examples/benilla_inventory.rs +++ b/gateway/examples/benilla_inventory.rs @@ -255,6 +255,11 @@ fn row( opcode, 0x005a | 0x00f9 + | 0x0107 + | 0x0109 + | 0x021f + | 0x0220 + | 0x0336 | 0x0125 | 0x01ed | 0x0200 diff --git a/gateway/src/codec/item.rs b/gateway/src/codec/item.rs index 78a9e1bb3..cf473c852 100644 --- a/gateway/src/codec/item.rs +++ b/gateway/src/codec/item.rs @@ -1,5 +1,7 @@ use super::*; use lyracore_shared::item::{item_class, weapon_subclass, ItemRefusal, Proficiency}; +pub use wow_world_messages::vanilla::SellItemResult; +use wow_world_messages::vanilla::{SMSG_ITEM_NAME_QUERY_RESPONSE, SMSG_SELL_ITEM}; pub fn inventory_slot(bag: u8, slot: u8) -> Option { match (bag, slot) { @@ -332,6 +334,53 @@ pub fn build_buy_failed(vendor_guid: u64, item_entry: u32, err: &str) -> SMSG_BU } } +/// Build `SMSG_SELL_ITEM`, the vendor's refusal of a sale (vmangos `SendSellError`: vendor guid, +/// item guid, result byte). The client prints the matching sale error and keeps the item. +pub fn build_sell_item_refusal( + vendor_guid: u64, + item_guid: u64, + result: SellItemResult, +) -> SMSG_SELL_ITEM { + SMSG_SELL_ITEM { + guid: Guid::new(vendor_guid), + item: Guid::new(item_guid), + result, + } +} + +/// Map the Module's sale Refusal text to the closest vanilla sale error. Vendor and range +/// Gates come first, a missing item next, and every other Gate means the vendor will not +/// take the item. +pub fn sell_refusal_result(err: &str) -> SellItemResult { + if err.contains("vendor") || err.contains("dead players") { + SellItemResult::CantFindVendor + } else if err.contains("no item in slot") { + SellItemResult::CantFindItem + } else { + SellItemResult::CantSellItem + } +} + +/// Build `SMSG_ITEM_NAME_QUERY_RESPONSE` (vanilla: item entry, name; cmangos `HandleItemNameQueryOpcode`). +pub fn build_item_name_query_response( + item: u32, + item_name: String, +) -> SMSG_ITEM_NAME_QUERY_RESPONSE { + SMSG_ITEM_NAME_QUERY_RESPONSE { item, item_name } +} + +/// `SMSG_READ_ITEM_FAILED` opcode (vanilla 5875). +pub const SMSG_READ_ITEM_FAILED_OPCODE: u16 = 0x00AF; + +/// Build a raw `SMSG_READ_ITEM_FAILED`. gtker's typed message stops at the item guid, but the 5875 +/// client reads a reason byte after it (benilla-re objectlayer/inventory, Reading items): 0 and 1 +/// open the text, any other value closes it. Reason 2 refuses the read. +pub fn build_read_item_failed_raw(item_guid: u64) -> (u16, Vec) { + let mut body = item_guid.to_le_bytes().to_vec(); + body.push(2); + (SMSG_READ_ITEM_FAILED_OPCODE, body) +} + pub fn build_show_bank(banker_guid: u64) -> SMSG_SHOW_BANK { SMSG_SHOW_BANK { guid: Guid::new(banker_guid), @@ -480,6 +529,69 @@ fn weapon_subclass_mask(player_class: u8) -> u32 { mod tests { use super::*; + fn framed(message: ServerOpcodeMessage) -> Vec { + let mut wire = Vec::new(); + message.write_unencrypted_server(&mut wire).unwrap(); + wire + } + + #[test] + fn sell_refusal_is_a_seventeen_byte_body_under_its_size_header() { + let reply = build_sell_item_refusal( + 0xF130_0000_0000_0001, + 0x4000_0000_0000_0007, + SellItemResult::CantSellItem, + ); + assert_eq!( + framed(ServerOpcodeMessage::SMSG_SELL_ITEM(Box::new(reply))), + [ + 0x00, 0x13, 0xA1, 0x01, // size 19 (big endian), opcode 0x01A1 + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xF1, // vendor guid + 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, // item guid + 0x02, // CantSellItem + ] + ); + } + + #[test] + fn sale_refusals_map_to_the_vendor_item_or_sellable_errors() { + for (text, expected) in [ + ("no such vendor", SellItemResult::CantFindVendor), + ("target is not a vendor", SellItemResult::CantFindVendor), + ("vendor out of range", SellItemResult::CantFindVendor), + ("vendor on another map", SellItemResult::CantFindVendor), + ("dead players cannot sell", SellItemResult::CantFindVendor), + ("no item in slot 40", SellItemResult::CantFindItem), + ("cannot sell an equipped item", SellItemResult::CantSellItem), + ("cannot sell a banked item", SellItemResult::CantSellItem), + ("item has no sell value", SellItemResult::CantSellItem), + ] { + assert_eq!(sell_refusal_result(text), expected, "{text}"); + } + } + + #[test] + fn item_name_response_is_the_entry_then_a_terminated_name() { + let reply = build_item_name_query_response(25, "Axe".into()); + assert_eq!( + framed(ServerOpcodeMessage::SMSG_ITEM_NAME_QUERY_RESPONSE( + Box::new(reply) + )), + [ + 0x00, 0x0A, 0xC5, 0x02, // size 10, opcode 0x02C5 + 0x19, 0x00, 0x00, 0x00, // item entry 25 + b'A', b'x', b'e', 0x00, + ] + ); + } + + #[test] + fn read_item_failure_carries_the_guid_and_a_closing_reason() { + let (opcode, body) = build_read_item_failed_raw(0x4000_0000_0000_0009); + assert_eq!(opcode, 0x00AF); + assert_eq!(body, [0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x02]); + } + #[test] fn hearthstone_query_names_the_client_use_spell() { let template = ItemTemplateView { diff --git a/gateway/src/codec/loot.rs b/gateway/src/codec/loot.rs index 47884ba1f..ff35553f2 100644 --- a/gateway/src/codec/loot.rs +++ b/gateway/src/codec/loot.rs @@ -1,7 +1,7 @@ use super::*; use lyracore_shared::loot_roll::vote_kind; use wow_world_messages::vanilla::{ - LootMethodError, SMSG_LOOT_RESPONSE_LootMethod, SMSG_LOOT_RESPONSE, + LootMethodError, SMSG_LOOT_RESPONSE_LootMethod, SMSG_LOOT_ALL_PASSED, SMSG_LOOT_RESPONSE, }; const SMSG_LOOT_RESPONSE_OPCODE: u16 = 0x0160; @@ -128,3 +128,45 @@ pub fn build_loot_master_list(eligible: &[u64]) -> SMSG_LOOT_MASTER_LIST { guids: eligible.iter().map(|&g| Guid::new(g)).collect(), } } + +/// Build `SMSG_LOOT_ALL_PASSED` — every roller passed, so the item stays in the corpse. The +/// suffix is always 0 here (vmangos `Group::SendLootAllPassed`). +pub fn build_loot_all_passed( + corpse_guid: u64, + loot_slot: u8, + item_entry: u32, + random_property_id: u32, +) -> SMSG_LOOT_ALL_PASSED { + SMSG_LOOT_ALL_PASSED { + looted_target: Guid::new(corpse_guid), + loot_slot: loot_slot as u32, + item: item_entry, + item_random_property_id: random_property_id, + item_random_suffix_id: 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_passed_is_the_corpse_slot_item_property_and_suffix_under_its_header() { + let reply = build_loot_all_passed(0xF130_0000_0000_0007, 3, 1234, 117); + let mut wire = Vec::new(); + ServerOpcodeMessage::SMSG_LOOT_ALL_PASSED(Box::new(reply)) + .write_unencrypted_server(&mut wire) + .unwrap(); + assert_eq!( + wire, + [ + 0x00, 0x1A, 0x9E, 0x02, // size 26 (big endian), opcode 0x029E + 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xF1, // corpse guid + 0x03, 0x00, 0x00, 0x00, // loot slot + 0xD2, 0x04, 0x00, 0x00, // item entry 1234 + 0x75, 0x00, 0x00, 0x00, // random property id 117 + 0x00, 0x00, 0x00, 0x00, // random suffix id + ] + ); + } +} diff --git a/gateway/src/codec/social/subscriptions.rs b/gateway/src/codec/social/subscriptions.rs index de5f6f77c..068d0867e 100644 --- a/gateway/src/codec/social/subscriptions.rs +++ b/gateway/src/codec/social/subscriptions.rs @@ -140,6 +140,24 @@ pub(crate) fn group_event_outbound< None } }, + roll_kind::ALL_PASSED => { + match lyracore_shared::loot_roll::decode_all_passed(&row.payload) { + Some((corpse, slot, entry, property_id)) => { + Some(ServerOpcodeMessage::SMSG_LOOT_ALL_PASSED(Box::new( + codec::build_loot_all_passed(corpse, slot, entry, property_id), + ))) + } + None => { + log::warn!( + "loot ALL_PASSED relay: unparseable payload {:?} (event {})", + row.payload, + row.id + ); + None + } + } + } + group_kind::DEATH_DURABILITY => Some(ServerOpcodeMessage::SMSG_DURABILITY_DAMAGE_DEATH), roll_kind::MASTER_LIST => { match lyracore_shared::loot_roll::decode_master_list(&row.payload) { Some((_corpse_guid, eligible)) => Some(ServerOpcodeMessage::SMSG_LOOT_MASTER_LIST( diff --git a/gateway/src/stdb/store/item.rs b/gateway/src/stdb/store/item.rs index 977d01919..a2faa3bab 100644 --- a/gateway/src/stdb/store/item.rs +++ b/gateway/src/stdb/store/item.rs @@ -4,9 +4,46 @@ use lyracore_shared::item::ItemRefusal; use crate::stdb::bindings::*; use crate::stdb::connection::{call_reducer, classify, DurableFailure}; use crate::stdb::Coordinator; -use crate::world::{Actor, ItemActionResult, ItemActionStore}; +use crate::world::{Actor, ItemActionResult, ItemActionStore, ReadableItem}; +use spacetimedb_sdk::Table; impl ItemActionStore for Coordinator { + fn item_name(&self, entry: u32) -> Result> { + Ok(self + .0 + .coord() + .conn + .db + .game_item_template() + .entry() + .find(&entry) + .map(|template| template.name)) + } + + fn readable_item(&self, owner_guid: u64, slot: u8) -> Result { + let guard = self.0.coord(); + let db = &guard.conn.db; + let Some(item) = db + .game_item_instance() + .iter() + .find(|i| i.owner_guid == owner_guid && i.slot == slot) + else { + return Ok(ReadableItem::Missing); + }; + let has_pages = db + .game_item_template() + .entry() + .find(&item.entry) + .is_some_and(|template| template.page_text != 0); + Ok(if has_pages { + ReadableItem::Pages { + item_guid: item.guid, + } + } else { + ReadableItem::NoPages + }) + } + fn equip_item(&self, actor: Actor, from_slot: u8) -> Result { let coord = self.0.call_pipe(); item_action(call_reducer!( diff --git a/gateway/src/world/handlers/item.rs b/gateway/src/world/handlers/item.rs index 6a861f060..f22a18576 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -17,7 +17,21 @@ impl From for ItemActionResult { } } +/// What a bag position holds, as `CMSG_READ_ITEM` needs to know it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReadableItem { + /// The position holds no item. + Missing, + /// The item has no page text to read. + NoPages, + /// The item opens page text. The realm has no page text catalog. + Pages { item_guid: u64 }, +} + pub(crate) trait ItemActionStore: Send + Sync { + /// The template name for `CMSG_ITEM_NAME_QUERY`, from the catalog `CMSG_ITEM_QUERY_SINGLE` reads. + fn item_name(&self, entry: u32) -> Result>; + fn readable_item(&self, owner_guid: u64, slot: u8) -> Result; fn destroy_item(&self, actor: Actor, slot: u8, count: u32) -> Result; fn equip_item(&self, actor: Actor, from_slot: u8) -> Result; fn unequip_item(&self, actor: Actor, from_slot: u8) -> Result; @@ -150,6 +164,44 @@ impl ProtocolFamily for Ite None => act(player, "use_item", |actor| store.use_item(actor, slot)), } } + // An entry the catalog lacks gets no reply, as in vmangos. + ClientOpcodeMessage::CMSG_ITEM_NAME_QUERY(c) => Ok(ProtocolReply::from( + store + .item_name(c.item)? + .map(|name| { + Outbound::One(ServerOpcodeMessage::SMSG_ITEM_NAME_QUERY_RESPONSE( + Box::new(codec::build_item_name_query_response(c.item, name)), + )) + }) + .into_iter() + .collect::>(), + )), + // vmangos `HandleReadItemOpcode`: an item without page text is not found. Page text + // has no catalog here, so a readable item gets the failure reply, never the OK. + ClientOpcodeMessage::CMSG_READ_ITEM(c) => { + let (Some(slot), Some(owner)) = ( + codec::inventory_slot(c.bag_index, c.slot), + player.self_guid(), + ) else { + return Ok(refuse(player, "read_item", ItemRefusal::ItemNotFound)); + }; + match store.readable_item(owner, slot)? { + ReadableItem::Pages { item_guid } => { + let (opcode, body) = codec::build_read_item_failed_raw(item_guid); + Ok(ProtocolReply::from(vec![ + Outbound::Raw { opcode, body }, + Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( + codec::build_gm_system_message( + "Item page text is not available on this realm.".to_string(), + ), + ))), + ])) + } + ReadableItem::Missing | ReadableItem::NoPages => { + Ok(refuse(player, "read_item", ItemRefusal::ItemNotFound)) + } + } + } other => Err(anyhow!("opcode routed to wrong Protocol Family: {other}")), } } @@ -161,8 +213,8 @@ pub(super) mod tests { use crate::stdb::{classify, DurableFailure, ReducerCallError}; use std::sync::Mutex; use wow_world_messages::vanilla::{ - ItemSlot, CMSG_AUTOEQUIP_ITEM, CMSG_AUTOSTORE_BAG_ITEM, CMSG_SWAP_INV_ITEM, CMSG_SWAP_ITEM, - CMSG_USE_ITEM, + Guid, ItemSlot, CMSG_AUTOEQUIP_ITEM, CMSG_AUTOSTORE_BAG_ITEM, CMSG_ITEM_NAME_QUERY, + CMSG_READ_ITEM, CMSG_SWAP_INV_ITEM, CMSG_SWAP_ITEM, CMSG_USE_ITEM, }; #[derive(Default)] @@ -178,6 +230,9 @@ pub(super) mod tests { pub(crate) transport_lost: bool, pub(crate) start_quest: Option<(u64, u32)>, pub(crate) quest_detail: Option, + pub(crate) names: Vec<(u32, String)>, + pub(crate) readable: Option, + pub(crate) read_requests: Mutex>, } impl InMemoryItemActions { @@ -194,6 +249,22 @@ pub(super) mod tests { } impl ItemActionStore for InMemoryItemActions { + fn item_name(&self, entry: u32) -> Result> { + if self.transport_lost { + return Err(ReducerCallError::transport_lost("item_name").into()); + } + Ok(self + .names + .iter() + .find(|(known, _)| *known == entry) + .map(|(_, name)| name.clone())) + } + + fn readable_item(&self, owner_guid: u64, slot: u8) -> Result { + self.read_requests.lock().unwrap().push((owner_guid, slot)); + Ok(self.readable.unwrap_or(ReadableItem::Missing)) + } + fn destroy_item(&self, _actor: Actor, _slot: u8, _count: u32) -> Result { Ok(ItemRefusal::ItemNotFound.into()) } @@ -772,4 +843,105 @@ pub(super) mod tests { }; inventory_failure(Item::handle(&refusing, &mut player(), equip(MAIN_BAG).into()).unwrap()); } + + fn name_query(item: u32) -> ClientOpcodeMessage { + ClientOpcodeMessage::CMSG_ITEM_NAME_QUERY(Box::new(CMSG_ITEM_NAME_QUERY { + item, + guid: Guid::new(0), + })) + } + + fn read(bag_index: u8, slot: u8) -> ClientOpcodeMessage { + ClientOpcodeMessage::CMSG_READ_ITEM(CMSG_READ_ITEM { bag_index, slot }) + } + + #[test] + fn name_query_answers_the_catalog_name() { + let actions = InMemoryItemActions { + names: vec![(25, "Worn Shortsword".into())], + ..Default::default() + }; + + let outbound = + handled_outbound(Item::handle(&actions, &mut player(), name_query(25).into()).unwrap()); + + let [Outbound::One(ServerOpcodeMessage::SMSG_ITEM_NAME_QUERY_RESPONSE(reply))] = + outbound.as_slice() + else { + panic!("expected one name response, got {} packets", outbound.len()); + }; + assert_eq!( + (reply.item, reply.item_name.as_str()), + (25, "Worn Shortsword") + ); + } + + #[test] + fn name_query_for_an_unknown_entry_stays_silent() { + let actions = InMemoryItemActions::default(); + + handled_without_outbound( + Item::handle(&actions, &mut player(), name_query(99).into()).unwrap(), + ); + } + + #[test] + fn name_query_transport_loss_ends_the_session() { + let actions = InMemoryItemActions { + transport_lost: true, + ..Default::default() + }; + + assert!(Item::handle(&actions, &mut player(), name_query(25).into()).is_err()); + } + + #[test] + fn reading_an_item_with_page_text_fails_with_the_raw_layout_and_a_notice() { + let actions = InMemoryItemActions { + readable: Some(ReadableItem::Pages { + item_guid: 0x4000_0000_0000_0009, + }), + ..Default::default() + }; + + let outbound = handled_outbound( + Item::handle(&actions, &mut player(), read(MAIN_BAG, 24).into()).unwrap(), + ); + + assert_eq!( + actions.read_requests.lock().unwrap().as_slice(), + &[(42, 24)] + ); + let [Outbound::Raw { opcode, body }, Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(_))] = + outbound.as_slice() + else { + panic!( + "expected a read failure and a notice, got {} packets", + outbound.len() + ); + }; + assert_eq!(*opcode, 0x00AF); + assert_eq!(body.as_slice(), [9, 0, 0, 0, 0, 0, 0, 0x40, 2]); + } + + #[test] + fn reading_a_missing_or_plain_item_is_an_item_not_found_failure() { + for readable in [ReadableItem::Missing, ReadableItem::NoPages] { + let actions = InMemoryItemActions { + readable: Some(readable), + ..Default::default() + }; + inventory_failure( + Item::handle(&actions, &mut player(), read(MAIN_BAG, 24).into()).unwrap(), + ); + } + } + + #[test] + fn reading_from_an_unmapped_position_asks_the_store_nothing() { + let actions = InMemoryItemActions::default(); + + inventory_failure(Item::handle(&actions, &mut player(), read(7, 3).into()).unwrap()); + assert!(actions.read_requests.lock().unwrap().is_empty()); + } } diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index 85084d680..fdca5a8c8 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -1442,3 +1442,58 @@ mod tests { assert!(store.item_take_requests.lock().unwrap().is_empty()); } } + +/// The Group event relay for loot notices, through the shared Fake. +#[cfg(test)] +mod relay_tests { + use super::*; + + fn relayed(kind: u8, payload: String) -> Vec { + let row = crate::stdb::bindings::GroupEvent { + id: 1, + recipient_identity: spacetimedb_sdk::Identity::ZERO, + kind, + other_guid: 0, + other_name: String::new(), + created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, + payload, + recipient_guid: 42, + }; + crate::codec::social::subscriptions::group_event_outbound( + &crate::world::test_support::WorldFake::default(), + 42, + &row, + ) + } + + #[test] + fn an_all_passed_event_relays_the_loot_notice() { + use lyracore_shared::loot_roll::{encode_all_passed, event_kind::ALL_PASSED}; + let packets = relayed(ALL_PASSED, encode_all_passed(7, 3, 1234, 117)); + let [Outbound::One(ServerOpcodeMessage::SMSG_LOOT_ALL_PASSED(notice))] = packets.as_slice() + else { + panic!( + "expected one SMSG_LOOT_ALL_PASSED, got {} packets", + packets.len() + ); + }; + assert_eq!( + notice.as_ref(), + &codec::build_loot_all_passed(7, 3, 1234, 117), + "slot, item and property come from the payload" + ); + assert!(relayed(ALL_PASSED, "garbled".into()).is_empty()); + } + + #[test] + fn a_death_durability_event_relays_the_empty_notice_under_its_header() { + use lyracore_shared::group::event_kind::DEATH_DURABILITY; + let packets = relayed(DEATH_DURABILITY, String::new()); + let [Outbound::One(notice)] = packets.as_slice() else { + panic!("expected one notice, got {} packets", packets.len()); + }; + let mut wire = Vec::new(); + notice.write_unencrypted_server(&mut wire).unwrap(); + assert_eq!(wire, [0x00, 0x02, 0xBD, 0x02]); + } +} diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index 4b2889ab1..4c99902e1 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -60,7 +60,7 @@ pub(crate) use guild::{ }; #[cfg(test)] pub(crate) use item::tests::InMemoryItemActions; -pub(crate) use item::{Item, ItemActionResult, ItemActionStore}; +pub(crate) use item::{Item, ItemActionResult, ItemActionStore, ReadableItem}; pub(crate) use loot::{ Loot, LootActionStatus, LootWindow, LootWindowRefusal, LootWindowRequestStatus, LootWindowStore, OpenLootState, diff --git a/gateway/src/world/handlers/unavailable.rs b/gateway/src/world/handlers/unavailable.rs index bd092ee6c..4d83b426b 100644 --- a/gateway/src/world/handlers/unavailable.rs +++ b/gateway/src/world/handlers/unavailable.rs @@ -197,6 +197,11 @@ pub(crate) fn raw_unavailable_outbound(opcode: u32, body: &[u8]) -> Result Ok(Some(vec![])), 0x005a => { anyhow::ensure!(body.len() == 12, "invalid CMSG_PAGE_TEXT_QUERY body"); Ok(Some(vec![page_unavailable(u32::from_le_bytes( @@ -250,6 +255,13 @@ pub(crate) fn is_control_receipt(msg: &ClientOpcodeMessage) -> bool { mod tests { use super::raw_unavailable_outbound; + #[test] + fn unhandled_vanilla_requests_are_ignored_without_a_reply() { + for opcode in [0x0107, 0x0109, 0x021f, 0x0220, 0x0336] { + assert!(raw_unavailable_outbound(opcode, &[0; 8]).unwrap().unwrap().is_empty()); + } + } + #[test] fn ticket_refusals_require_complete_packet_framing() { for body in [ diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index 672846ce2..d10b02e82 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -149,17 +149,34 @@ impl ProtocolFamily for Vendor { let Some(actor) = actor else { return Ok(ProtocolReply::from(Vec::new())); }; - let Some(slot) = store.vendor_item_slot(c.item.guid()) else { + let (vendor_guid, item_guid) = (c.vendor.guid(), c.item.guid()); + let sell_refused = |result| { + vec![Outbound::One(ServerOpcodeMessage::SMSG_SELL_ITEM( + Box::new(codec::build_sell_item_refusal( + vendor_guid, + item_guid, + result, + )), + ))] + }; + if item_guid == 0 { return Ok(ProtocolReply::from(Vec::new())); + } + let Some(slot) = store.vendor_item_slot(item_guid) else { + return Ok(ProtocolReply::from(sell_refused( + codec::SellItemResult::CantFindItem, + ))); }; - match store.vendor_sell(actor, c.vendor.guid(), slot) { + match store.vendor_sell(actor, vendor_guid, slot) { Ok(()) => Ok(ProtocolReply::from(build_buyback_view(store, actor.guid()))), Err(e) if is_refusal(&e) => { log::debug!( - "world: sell_item ignored (account {}): {e}", + "world: sell_item refused (account {}): {e}", player.account_id ); - Ok(ProtocolReply::from(Vec::new())) + Ok(ProtocolReply::from(sell_refused( + codec::sell_refusal_result(&e.to_string()), + ))) } Err(e) => Err(e), } @@ -722,35 +739,69 @@ pub(super) mod tests { assert_renders_ring(&outcome.outbound, &ring); } + /// The one SMSG_SELL_ITEM a refused sale must answer, as (vendor, item, result byte). + fn sell_refusal(outcome: ProtocolReply) -> (u64, u64, u8) { + let [Outbound::One(ServerOpcodeMessage::SMSG_SELL_ITEM(reply))] = + outcome.outbound.as_slice() + else { + panic!( + "expected one SMSG_SELL_ITEM, got {} packets", + outcome.outbound.len() + ); + }; + (reply.guid.guid(), reply.item.guid(), reply.result.as_int()) + } + #[test] - fn selling_an_unknown_item_guid_is_a_harmless_no_op() { + fn selling_an_unknown_item_guid_answers_cant_find_item_without_a_durable_request() { let actions = InMemoryVendorActions::default(); let outcome = Vendor::handle(&actions, &mut player(), sell_item(0x99).into()).unwrap(); - assert!(matches!( - outcome, - ProtocolReply { outbound, .. } if outbound.is_empty() - )); + assert_eq!(sell_refusal(outcome), (VENDOR, 0x99, 1)); assert!(actions.sell_requests.lock().unwrap().is_empty()); } #[test] - fn a_rejected_sale_is_silent_and_non_fatal() { - let actions = InMemoryVendorActions { - item_slots: vec![(ITEM, 30)], - sell_error: Some(|| { - ReducerCallError::refused("gw_sell_item", "that item cannot be sold") - }), - ..Default::default() - }; + fn selling_a_zero_item_guid_is_silent() { + let actions = InMemoryVendorActions::default(); - let outcome = Vendor::handle(&actions, &mut player(), sell_item(ITEM).into()).unwrap(); + let outcome = Vendor::handle(&actions, &mut player(), sell_item(0).into()).unwrap(); - assert!(matches!( - outcome, - ProtocolReply { outbound, .. } if outbound.is_empty() - )); + assert!(outcome.outbound.is_empty()); + assert!(actions.sell_requests.lock().unwrap().is_empty()); + } + + #[test] + fn a_rejected_sale_answers_the_matching_sell_error_and_stays_non_fatal() { + for (reason, code) in [ + ("that item cannot be sold", 2), + ("item has no sell value", 2), + ("vendor out of range", 3), + ("no item in slot 30", 1), + ] { + let sell_error: Failure = match reason { + "that item cannot be sold" => { + || ReducerCallError::refused("gw_sell_item", "that item cannot be sold") + } + "item has no sell value" => { + || ReducerCallError::refused("gw_sell_item", "item has no sell value") + } + "vendor out of range" => { + || ReducerCallError::refused("gw_sell_item", "vendor out of range") + } + _ => || ReducerCallError::refused("gw_sell_item", "no item in slot 30"), + }; + let actions = InMemoryVendorActions { + item_slots: vec![(ITEM, 30)], + sell_error: Some(sell_error), + ..Default::default() + }; + + let outcome = Vendor::handle(&actions, &mut player(), sell_item(ITEM).into()).unwrap(); + + assert_eq!(sell_refusal(outcome), (VENDOR, ITEM, code), "{reason}"); + } } #[test] diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 3cce7f556..d838346c5 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -57,7 +57,7 @@ pub(crate) use handlers::{ AuctionHousePolicy, AuctionInteraction, AuctionPage, AuctionQuery, CancelAuctionOutcome, CancelAuctionRequest, ChannelOutcome, ChannelRequest, ChannelRoster, CharacterFacts, ChatOutcome, CreateAuctionOutcome, CreateAuctionRequest, GuildEventSnapshot, GuildOutcome, - GuildRequest, ItemActionResult, LootActionStatus, LootWindowRefusal, LootWindowRequestStatus, + GuildRequest, ItemActionResult, ReadableItem, LootActionStatus, LootWindowRefusal, LootWindowRequestStatus, MeetingStoneOutcome, MemberPresence, MemberStatsRecord, MemberStatsStore, PlaceBidOutcome, PlaceBidRequest, RealmChatRequest, ResolvedTarget, SeekerFacts, SpeakerFacts, TrainerBuyOutcome, WeatherStore, WhisperRequest, WhisperTargetFacts, diff --git a/gateway/src/world/routing.rs b/gateway/src/world/routing.rs index dbc97b881..546d2b4ea 100644 --- a/gateway/src/world/routing.rs +++ b/gateway/src/world/routing.rs @@ -86,6 +86,8 @@ protocol_families! { CMSG_SWAP_INV_ITEM, CMSG_SWAP_ITEM, CMSG_USE_ITEM, + CMSG_ITEM_NAME_QUERY, + CMSG_READ_ITEM, ], Quest => [ CMSG_QUESTGIVER_STATUS_QUERY, diff --git a/gateway/src/world/test_support/world_fake/item.rs b/gateway/src/world/test_support/world_fake/item.rs index 86328e81f..290141a2f 100644 --- a/gateway/src/world/test_support/world_fake/item.rs +++ b/gateway/src/world/test_support/world_fake/item.rs @@ -1,6 +1,19 @@ use super::super::*; impl ItemActionStore for WorldFake { + fn item_name(&self, entry: u32) -> Result> { + Ok(self + .cast + .item_templates + .iter() + .find(|template| template.entry == entry) + .map(|template| template.name.clone())) + } + + fn readable_item(&self, _owner_guid: u64, _slot: u8) -> Result { + Ok(ReadableItem::Missing) + } + fn destroy_item(&self, _actor: Actor, _slot: u8, _count: u32) -> Result { Ok(lyracore_shared::item::ItemRefusal::ItemNotFound.into()) } From 83b2df1d3cbd1ebed543ed383c217af21d3892d0 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 15 Oct 2026 08:23:21 +0200 Subject: [PATCH 3/6] style: format with rustfmt Co-Authored-By: Claude Opus 5.5 --- gateway/src/world/handlers/unavailable.rs | 5 ++++- gateway/src/world/mod.rs | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/gateway/src/world/handlers/unavailable.rs b/gateway/src/world/handlers/unavailable.rs index 4d83b426b..5399b72c3 100644 --- a/gateway/src/world/handlers/unavailable.rs +++ b/gateway/src/world/handlers/unavailable.rs @@ -258,7 +258,10 @@ mod tests { #[test] fn unhandled_vanilla_requests_are_ignored_without_a_reply() { for opcode in [0x0107, 0x0109, 0x021f, 0x0220, 0x0336] { - assert!(raw_unavailable_outbound(opcode, &[0; 8]).unwrap().unwrap().is_empty()); + assert!(raw_unavailable_outbound(opcode, &[0; 8]) + .unwrap() + .unwrap() + .is_empty()); } } diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index d838346c5..5b5133610 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -57,9 +57,9 @@ pub(crate) use handlers::{ AuctionHousePolicy, AuctionInteraction, AuctionPage, AuctionQuery, CancelAuctionOutcome, CancelAuctionRequest, ChannelOutcome, ChannelRequest, ChannelRoster, CharacterFacts, ChatOutcome, CreateAuctionOutcome, CreateAuctionRequest, GuildEventSnapshot, GuildOutcome, - GuildRequest, ItemActionResult, ReadableItem, LootActionStatus, LootWindowRefusal, LootWindowRequestStatus, + GuildRequest, ItemActionResult, LootActionStatus, LootWindowRefusal, LootWindowRequestStatus, MeetingStoneOutcome, MemberPresence, MemberStatsRecord, MemberStatsStore, PlaceBidOutcome, - PlaceBidRequest, RealmChatRequest, ResolvedTarget, SeekerFacts, SpeakerFacts, + PlaceBidRequest, ReadableItem, RealmChatRequest, ResolvedTarget, SeekerFacts, SpeakerFacts, TrainerBuyOutcome, WeatherStore, WhisperRequest, WhisperTargetFacts, }; pub(crate) use handlers::{ From fbb36ff418df7bfc245977ca31194cfff3386e34 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 15 Oct 2026 08:39:30 +0200 Subject: [PATCH 4/6] test(module): keep group events through the durable loot and death tests Disarm the event reaper in both fixtures and count new events by id. Co-Authored-By: Claude Sonnet 5.5 --- module/tests/death_durability_notice.rs | 3 +++ module/tests/loot_roll_promotion.rs | 28 ++++++++++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/module/tests/death_durability_notice.rs b/module/tests/death_durability_notice.rs index b3db8cade..de462f698 100644 --- a/module/tests/death_durability_notice.rs +++ b/module/tests/death_durability_notice.rs @@ -16,6 +16,9 @@ fn fixture(name: &str) -> Standalone { shard.assert_call("debug_seed_scenario_fixtures", &[]); shard.assert_call("debug_spawn_player_entity", &["1"]); shard.assert_sql("DELETE FROM game_item_instance"); + // The event reaper deletes group events older than a second, which would make the + // no-notice test pass vacuously. + shard.assert_sql("DELETE FROM game_event_reaper_schedule"); shard } diff --git a/module/tests/loot_roll_promotion.rs b/module/tests/loot_roll_promotion.rs index e2ef72999..601fde452 100644 --- a/module/tests/loot_roll_promotion.rs +++ b/module/tests/loot_roll_promotion.rs @@ -89,6 +89,9 @@ fn fixture(name: &str) -> Standalone { standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("install_guid_range", &["0"]); + // The event reaper deletes group events older than a second, which would empty the + // assertions below. + standalone.assert_sql("DELETE FROM game_event_reaper_schedule"); standalone } @@ -353,11 +356,21 @@ fn votes(standalone: &Standalone) -> Vec> { rows } -/// `(recipient, payload)` of every event of `kind`, in recipient order. -fn events_of(standalone: &Standalone, kind: u8) -> Vec<(String, String)> { +/// The newest group event id, 0 when none exists. +fn newest_event(standalone: &Standalone) -> u64 { + standalone + .query_rows("SELECT id FROM game_group_event") + .iter() + .map(|row| row["id"].parse::().unwrap()) + .max() + .unwrap_or(0) +} + +/// `(recipient, payload)` of every event of `kind` newer than `after`, in recipient order. +fn events_of(standalone: &Standalone, kind: u8, after: u64) -> Vec<(String, String)> { let mut events: Vec<_> = standalone .query_rows(&format!( - "SELECT recipient_guid, payload FROM game_group_event WHERE kind = {kind}" + "SELECT recipient_guid, payload FROM game_group_event WHERE kind = {kind} AND id > {after}" )) .into_iter() .map(|row| (row["recipient_guid"].clone(), row["payload"].clone())) @@ -376,17 +389,18 @@ fn an_all_pass_resolution_tells_every_roller_and_a_win_does_not() { resolve(&standalone); let payload = format!("{CORPSE},0,{ITEM},117"); assert_eq!( - events_of(&standalone, ALL_PASSED), + events_of(&standalone, ALL_PASSED, 0), ["5090003", "5090004", "5090005"].map(|roller| (roller.to_string(), payload.clone())) ); - assert!(events_of(&standalone, ROLL_WON).is_empty()); + assert!(events_of(&standalone, ROLL_WON, 0).is_empty()); // A contested roll announces its winner and adds no all-passed notice. + let before = newest_event(&standalone); promotion.source_roll_id = 11; promotion.send(&standalone); vote(&standalone, "5090003", "1"); vote(&standalone, "5090004", "0"); vote(&standalone, "5090005", "0"); - assert_eq!(events_of(&standalone, ALL_PASSED).len(), 3); - assert_eq!(events_of(&standalone, ROLL_WON).len(), 3); + assert!(events_of(&standalone, ALL_PASSED, before).is_empty()); + assert_eq!(events_of(&standalone, ROLL_WON, before).len(), 3); } From 0dfe8644dab4a362c2d726b7665bf27962281ff3 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 15 Oct 2026 08:39:31 +0200 Subject: [PATCH 5/6] fix(gateway): sell and repair only the acting Character's own items A guid that resolves to no item now stays silent on sale, as in vmangos. Co-Authored-By: Claude Sonnet 5.5 --- gateway/src/stdb/reads/items.rs | 5 ++- gateway/src/stdb/store/cast.rs | 4 +- gateway/src/stdb/store/vendor.rs | 4 +- gateway/src/world/handlers/cast/manual.rs | 2 +- gateway/src/world/handlers/cast/mod.rs | 7 ++-- gateway/src/world/handlers/vendor.rs | 38 ++++++++++++++----- .../src/world/test_support/world_fake/cast.rs | 2 +- .../world/test_support/world_fake/vendor.rs | 2 +- 8 files changed, 43 insertions(+), 21 deletions(-) diff --git a/gateway/src/stdb/reads/items.rs b/gateway/src/stdb/reads/items.rs index 076b8fa86..07cf582cd 100644 --- a/gateway/src/stdb/reads/items.rs +++ b/gateway/src/stdb/reads/items.rs @@ -41,14 +41,15 @@ impl Coordinator { .collect() } - pub fn item_slot_by_guid(&self, item_guid: u64) -> Option { + /// Slot of `item_guid` when `owner_guid` holds it; another Character's item is `None`. + pub fn item_slot_by_guid(&self, owner_guid: u64, item_guid: u64) -> Option { let guard = self.0.coord(); let slots: Vec = guard .conn .db .game_item_instance() .iter() - .filter(|i| i.guid == item_guid) + .filter(|i| i.guid == item_guid && i.owner_guid == owner_guid) .map(|i| i.slot) .collect(); slots.into_iter().next() diff --git a/gateway/src/stdb/store/cast.rs b/gateway/src/stdb/store/cast.rs index c7bfcaedb..6d9862274 100644 --- a/gateway/src/stdb/store/cast.rs +++ b/gateway/src/stdb/store/cast.rs @@ -203,8 +203,8 @@ impl CastStore for Coordinator { ) } - fn item_slot_by_guid(&self, item_guid: u64) -> Option { - Coordinator::item_slot_by_guid(self, item_guid) + fn item_slot_by_guid(&self, owner_guid: u64, item_guid: u64) -> Option { + Coordinator::item_slot_by_guid(self, owner_guid, item_guid) } fn player_items(&self, owner_guid: u64) -> Result> { diff --git a/gateway/src/stdb/store/vendor.rs b/gateway/src/stdb/store/vendor.rs index 6d41c04c5..2d229cc24 100644 --- a/gateway/src/stdb/store/vendor.rs +++ b/gateway/src/stdb/store/vendor.rs @@ -81,8 +81,8 @@ impl VendorActionStore for crate::stdb::Coordinator { property_enchant_ids(&guard.conn.db, random_property_id) } - fn vendor_item_slot(&self, item_guid: u64) -> Option { - crate::stdb::Coordinator::item_slot_by_guid(self, item_guid) + fn vendor_item_slot(&self, owner_guid: u64, item_guid: u64) -> Option { + crate::stdb::Coordinator::item_slot_by_guid(self, owner_guid, item_guid) } fn vendor_repair(&self, actor: Actor, npc_guid: u64, slot: u8) -> Result<()> { diff --git a/gateway/src/world/handlers/cast/manual.rs b/gateway/src/world/handlers/cast/manual.rs index 66714e022..c8bfb603a 100644 --- a/gateway/src/world/handlers/cast/manual.rs +++ b/gateway/src/world/handlers/cast/manual.rs @@ -40,7 +40,7 @@ pub(super) fn manual_completion_cast( if item_guid == 0 { break 'request Err("enchant: no item target in cast".to_string()); } - let Some(slot) = store.item_slot_by_guid(item_guid) else { + let Some(slot) = store.item_slot_by_guid(actor.guid(), item_guid) else { break 'request Err(format!( "enchant: item {item_guid} not in the Character inventory" )); diff --git a/gateway/src/world/handlers/cast/mod.rs b/gateway/src/world/handlers/cast/mod.rs index b74d2ac3d..d895aaefc 100644 --- a/gateway/src/world/handlers/cast/mod.rs +++ b/gateway/src/world/handlers/cast/mod.rs @@ -42,7 +42,8 @@ pub(crate) trait CastStore: MeleeActionStore + Send + Sync { fn start_ranged_attack(&self, actor: Actor, target_guid: u64, spell_id: u32) -> Result<()>; - fn item_slot_by_guid(&self, item_guid: u64) -> Option; + /// Slot of `item_guid` when `owner_guid` holds it; another Character's item is `None`. + fn item_slot_by_guid(&self, owner_guid: u64, item_guid: u64) -> Option; fn disenchant_item(&self, actor: Actor, slot: u8) -> Result<()>; @@ -185,7 +186,7 @@ fn ordinary_cast( break 'request Some(NO_ACTOR.to_string()); }; let result = if item_guid != 0 { - let Some(slot) = store.item_slot_by_guid(item_guid) else { + let Some(slot) = store.item_slot_by_guid(actor.guid(), item_guid) else { break 'request Some(format!( "item target {item_guid} is not in the Character's bag" )); @@ -354,7 +355,7 @@ pub(super) mod tests { self.answer("gw_cast_item_target", &self.cast_error) } - fn item_slot_by_guid(&self, item_guid: u64) -> Option { + fn item_slot_by_guid(&self, _owner_guid: u64, item_guid: u64) -> Option { self.item_slots .iter() .find(|(g, _)| *g == item_guid) diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index d10b02e82..de3c9005d 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -13,7 +13,8 @@ pub(crate) trait VendorActionStore: Send + Sync { fn random_property_enchant_ids(&self, random_property_id: u32) -> [u32; 3]; - fn vendor_item_slot(&self, item_guid: u64) -> Option; + /// Slot of `item_guid` when `owner_guid` holds it; another Character's item is `None`. + fn vendor_item_slot(&self, owner_guid: u64, item_guid: u64) -> Option; fn vendor_repair(&self, actor: Actor, npc_guid: u64, slot: u8) -> Result<()>; @@ -124,7 +125,7 @@ impl ProtocolFamily for Vendor { let slot = if item_guid == 0 { Some(u8::MAX) } else { - store.vendor_item_slot(item_guid) + store.vendor_item_slot(actor.guid(), item_guid) }; let Some(slot) = slot else { return Ok(ProtocolReply::from(Vec::new())); @@ -162,10 +163,8 @@ impl ProtocolFamily for Vendor { if item_guid == 0 { return Ok(ProtocolReply::from(Vec::new())); } - let Some(slot) = store.vendor_item_slot(item_guid) else { - return Ok(ProtocolReply::from(sell_refused( - codec::SellItemResult::CantFindItem, - ))); + let Some(slot) = store.vendor_item_slot(actor.guid(), item_guid) else { + return Ok(ProtocolReply::from(Vec::new())); }; match store.vendor_sell(actor, vendor_guid, slot) { Ok(()) => Ok(ProtocolReply::from(build_buyback_view(store, actor.guid()))), @@ -309,6 +308,8 @@ pub(super) mod tests { pub(crate) ring: Mutex>, pub(crate) random_properties: Vec<(u32, [u32; 3])>, pub(crate) item_slots: Vec<(u64, u8)>, + /// Guids in `item_slots` that another Character holds. + pub(crate) foreign_items: Vec, pub(crate) repair_error: Option, pub(crate) sell_error: Option, pub(crate) buyback_requests: Mutex>, @@ -365,7 +366,10 @@ pub(super) mod tests { .map_or([0; 3], |&(_, enchant_ids)| enchant_ids) } - fn vendor_item_slot(&self, item_guid: u64) -> Option { + fn vendor_item_slot(&self, _owner_guid: u64, item_guid: u64) -> Option { + if self.foreign_items.contains(&item_guid) { + return None; + } self.item_slots .iter() .find(|(g, _)| *g == item_guid) @@ -753,13 +757,29 @@ pub(super) mod tests { } #[test] - fn selling_an_unknown_item_guid_answers_cant_find_item_without_a_durable_request() { + fn selling_an_unknown_item_guid_is_silent_without_a_durable_request() { let actions = InMemoryVendorActions::default(); let outcome = Vendor::handle(&actions, &mut player(), sell_item(0x99).into()).unwrap(); - assert_eq!(sell_refusal(outcome), (VENDOR, 0x99, 1)); + assert!(outcome.outbound.is_empty()); + assert!(actions.sell_requests.lock().unwrap().is_empty()); + } + + #[test] + fn selling_or_repairing_another_characters_item_asks_the_module_nothing() { + let actions = InMemoryVendorActions { + item_slots: vec![(ITEM, 30)], + foreign_items: vec![ITEM], + ..Default::default() + }; + + let sold = Vendor::handle(&actions, &mut player(), sell_item(ITEM).into()).unwrap(); + let repaired = Vendor::handle(&actions, &mut player(), repair_item(ITEM).into()).unwrap(); + + assert!(sold.outbound.is_empty() && repaired.outbound.is_empty()); assert!(actions.sell_requests.lock().unwrap().is_empty()); + assert!(actions.repair_requests.lock().unwrap().is_empty()); } #[test] diff --git a/gateway/src/world/test_support/world_fake/cast.rs b/gateway/src/world/test_support/world_fake/cast.rs index b73b34323..2de83be74 100644 --- a/gateway/src/world/test_support/world_fake/cast.rs +++ b/gateway/src/world/test_support/world_fake/cast.rs @@ -55,7 +55,7 @@ impl CastStore for WorldFake { ) -> Result<()> { Ok(()) } - fn item_slot_by_guid(&self, _item_guid: u64) -> Option { + fn item_slot_by_guid(&self, _owner_guid: u64, _item_guid: u64) -> Option { None } fn disenchant_item(&self, _actor: Actor, _slot: u8) -> Result<()> { diff --git a/gateway/src/world/test_support/world_fake/vendor.rs b/gateway/src/world/test_support/world_fake/vendor.rs index 63092e495..2aaa2269e 100644 --- a/gateway/src/world/test_support/world_fake/vendor.rs +++ b/gateway/src/world/test_support/world_fake/vendor.rs @@ -34,7 +34,7 @@ impl VendorActionStore for WorldFake { [0; 3] } - fn vendor_item_slot(&self, _item_guid: u64) -> Option { + fn vendor_item_slot(&self, _owner_guid: u64, _item_guid: u64) -> Option { None } From af1ddf464a5aa9d763ded8389fa4b4eb46fb3d29 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 15 Oct 2026 12:42:42 +0200 Subject: [PATCH 6/6] style: trim comments to the contract policy Co-Authored-By: Claude Sonnet 5.5 --- crates/lyracore-shared/src/loot_roll.rs | 5 ----- gateway/src/codec/item.rs | 11 +---------- gateway/src/codec/loot.rs | 2 -- gateway/src/world/handlers/item.rs | 10 ++-------- gateway/src/world/handlers/loot.rs | 1 - gateway/src/world/handlers/unavailable.rs | 5 +---- gateway/src/world/handlers/vendor.rs | 2 -- module/tests/death_durability_notice.rs | 6 +----- module/tests/loot_roll_promotion.rs | 6 +----- 9 files changed, 6 insertions(+), 42 deletions(-) diff --git a/crates/lyracore-shared/src/loot_roll.rs b/crates/lyracore-shared/src/loot_roll.rs index e8b7c1bd5..a30d353b2 100644 --- a/crates/lyracore-shared/src/loot_roll.rs +++ b/crates/lyracore-shared/src/loot_roll.rs @@ -6,9 +6,6 @@ pub mod event_kind { pub const ROLL_WON: u8 = 6; pub const MASTER_LIST: u8 = 7; pub const MONEY_SHARE: u8 = 8; - /// Every eligible member passed on one item → `SMSG_LOOT_ALL_PASSED`, to every roller. Outside - /// the reserved `4..=8` block (kind 9 is retired); the byte space is listed in - /// `crate::group::event_kind`. pub const ALL_PASSED: u8 = 12; } @@ -125,7 +122,6 @@ pub fn decode_won(payload: &str) -> Option<(u64, u8, u32, u8, u8, u32)> { )) } -/// Encode an `ALL_PASSED` payload: `corpse_guid,slot,item_entry,random_property_id`. pub fn encode_all_passed( corpse_guid: u64, slot: u8, @@ -135,7 +131,6 @@ pub fn encode_all_passed( format!("{corpse_guid},{slot},{item_entry},{random_property_id}") } -/// Decode an `ALL_PASSED` payload back to `(corpse_guid, slot, item_entry, random_property_id)`. pub fn decode_all_passed(payload: &str) -> Option<(u64, u8, u32, u32)> { let mut p = payload.split(','); let corpse_guid: u64 = p.next()?.parse().ok()?; diff --git a/gateway/src/codec/item.rs b/gateway/src/codec/item.rs index cf473c852..a2e82d31c 100644 --- a/gateway/src/codec/item.rs +++ b/gateway/src/codec/item.rs @@ -334,8 +334,6 @@ pub fn build_buy_failed(vendor_guid: u64, item_entry: u32, err: &str) -> SMSG_BU } } -/// Build `SMSG_SELL_ITEM`, the vendor's refusal of a sale (vmangos `SendSellError`: vendor guid, -/// item guid, result byte). The client prints the matching sale error and keeps the item. pub fn build_sell_item_refusal( vendor_guid: u64, item_guid: u64, @@ -348,9 +346,6 @@ pub fn build_sell_item_refusal( } } -/// Map the Module's sale Refusal text to the closest vanilla sale error. Vendor and range -/// Gates come first, a missing item next, and every other Gate means the vendor will not -/// take the item. pub fn sell_refusal_result(err: &str) -> SellItemResult { if err.contains("vendor") || err.contains("dead players") { SellItemResult::CantFindVendor @@ -361,7 +356,6 @@ pub fn sell_refusal_result(err: &str) -> SellItemResult { } } -/// Build `SMSG_ITEM_NAME_QUERY_RESPONSE` (vanilla: item entry, name; cmangos `HandleItemNameQueryOpcode`). pub fn build_item_name_query_response( item: u32, item_name: String, @@ -369,12 +363,9 @@ pub fn build_item_name_query_response( SMSG_ITEM_NAME_QUERY_RESPONSE { item, item_name } } -/// `SMSG_READ_ITEM_FAILED` opcode (vanilla 5875). pub const SMSG_READ_ITEM_FAILED_OPCODE: u16 = 0x00AF; -/// Build a raw `SMSG_READ_ITEM_FAILED`. gtker's typed message stops at the item guid, but the 5875 -/// client reads a reason byte after it (benilla-re objectlayer/inventory, Reading items): 0 and 1 -/// open the text, any other value closes it. Reason 2 refuses the read. +/// 5875 body: item guid, then a reason byte; gtker omits the byte. Reasons above 1 close the text. pub fn build_read_item_failed_raw(item_guid: u64) -> (u16, Vec) { let mut body = item_guid.to_le_bytes().to_vec(); body.push(2); diff --git a/gateway/src/codec/loot.rs b/gateway/src/codec/loot.rs index ff35553f2..2589d0604 100644 --- a/gateway/src/codec/loot.rs +++ b/gateway/src/codec/loot.rs @@ -129,8 +129,6 @@ pub fn build_loot_master_list(eligible: &[u64]) -> SMSG_LOOT_MASTER_LIST { } } -/// Build `SMSG_LOOT_ALL_PASSED` — every roller passed, so the item stays in the corpse. The -/// suffix is always 0 here (vmangos `Group::SendLootAllPassed`). pub fn build_loot_all_passed( corpse_guid: u64, loot_slot: u8, diff --git a/gateway/src/world/handlers/item.rs b/gateway/src/world/handlers/item.rs index f22a18576..94a709925 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -17,19 +17,14 @@ impl From for ItemActionResult { } } -/// What a bag position holds, as `CMSG_READ_ITEM` needs to know it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ReadableItem { - /// The position holds no item. Missing, - /// The item has no page text to read. NoPages, - /// The item opens page text. The realm has no page text catalog. Pages { item_guid: u64 }, } pub(crate) trait ItemActionStore: Send + Sync { - /// The template name for `CMSG_ITEM_NAME_QUERY`, from the catalog `CMSG_ITEM_QUERY_SINGLE` reads. fn item_name(&self, entry: u32) -> Result>; fn readable_item(&self, owner_guid: u64, slot: u8) -> Result; fn destroy_item(&self, actor: Actor, slot: u8, count: u32) -> Result; @@ -164,7 +159,7 @@ impl ProtocolFamily for Ite None => act(player, "use_item", |actor| store.use_item(actor, slot)), } } - // An entry the catalog lacks gets no reply, as in vmangos. + // An unknown entry gets no reply, as in vmangos. ClientOpcodeMessage::CMSG_ITEM_NAME_QUERY(c) => Ok(ProtocolReply::from( store .item_name(c.item)? @@ -176,8 +171,7 @@ impl ProtocolFamily for Ite .into_iter() .collect::>(), )), - // vmangos `HandleReadItemOpcode`: an item without page text is not found. Page text - // has no catalog here, so a readable item gets the failure reply, never the OK. + // No page text catalog: a readable item gets the failure reply, never the OK. ClientOpcodeMessage::CMSG_READ_ITEM(c) => { let (Some(slot), Some(owner)) = ( codec::inventory_slot(c.bag_index, c.slot), diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index fdca5a8c8..c113ded23 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -1443,7 +1443,6 @@ mod tests { } } -/// The Group event relay for loot notices, through the shared Fake. #[cfg(test)] mod relay_tests { use super::*; diff --git a/gateway/src/world/handlers/unavailable.rs b/gateway/src/world/handlers/unavailable.rs index 5399b72c3..baf05f5c7 100644 --- a/gateway/src/world/handlers/unavailable.rs +++ b/gateway/src/world/handlers/unavailable.rs @@ -197,10 +197,7 @@ pub(crate) fn raw_unavailable_outbound(opcode: u32, body: &[u8]) -> Result Ok(Some(vec![])), 0x005a => { anyhow::ensure!(body.len() == 12, "invalid CMSG_PAGE_TEXT_QUERY body"); diff --git a/gateway/src/world/handlers/vendor.rs b/gateway/src/world/handlers/vendor.rs index de3c9005d..3b5e4ab17 100644 --- a/gateway/src/world/handlers/vendor.rs +++ b/gateway/src/world/handlers/vendor.rs @@ -308,7 +308,6 @@ pub(super) mod tests { pub(crate) ring: Mutex>, pub(crate) random_properties: Vec<(u32, [u32; 3])>, pub(crate) item_slots: Vec<(u64, u8)>, - /// Guids in `item_slots` that another Character holds. pub(crate) foreign_items: Vec, pub(crate) repair_error: Option, pub(crate) sell_error: Option, @@ -743,7 +742,6 @@ pub(super) mod tests { assert_renders_ring(&outcome.outbound, &ring); } - /// The one SMSG_SELL_ITEM a refused sale must answer, as (vendor, item, result byte). fn sell_refusal(outcome: ProtocolReply) -> (u64, u64, u8) { let [Outbound::One(ServerOpcodeMessage::SMSG_SELL_ITEM(reply))] = outcome.outbound.as_slice() diff --git a/module/tests/death_durability_notice.rs b/module/tests/death_durability_notice.rs index de462f698..09a0db80c 100644 --- a/module/tests/death_durability_notice.rs +++ b/module/tests/death_durability_notice.rs @@ -1,6 +1,3 @@ -//! A player death that damages worn gear queues the one-recipient notice the Gateway relays as -//! `SMSG_DURABILITY_DAMAGE_DEATH`. A death with nothing to damage queues none. - mod support; use support::Standalone; @@ -16,8 +13,7 @@ fn fixture(name: &str) -> Standalone { shard.assert_call("debug_seed_scenario_fixtures", &[]); shard.assert_call("debug_spawn_player_entity", &["1"]); shard.assert_sql("DELETE FROM game_item_instance"); - // The event reaper deletes group events older than a second, which would make the - // no-notice test pass vacuously. + // The reaper would delete the events under test. shard.assert_sql("DELETE FROM game_event_reaper_schedule"); shard } diff --git a/module/tests/loot_roll_promotion.rs b/module/tests/loot_roll_promotion.rs index 601fde452..a7d2738b0 100644 --- a/module/tests/loot_roll_promotion.rs +++ b/module/tests/loot_roll_promotion.rs @@ -89,8 +89,7 @@ fn fixture(name: &str) -> Standalone { standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("install_guid_range", &["0"]); - // The event reaper deletes group events older than a second, which would empty the - // assertions below. + // The reaper would delete the events under test. standalone.assert_sql("DELETE FROM game_event_reaper_schedule"); standalone } @@ -356,7 +355,6 @@ fn votes(standalone: &Standalone) -> Vec> { rows } -/// The newest group event id, 0 when none exists. fn newest_event(standalone: &Standalone) -> u64 { standalone .query_rows("SELECT id FROM game_group_event") @@ -366,7 +364,6 @@ fn newest_event(standalone: &Standalone) -> u64 { .unwrap_or(0) } -/// `(recipient, payload)` of every event of `kind` newer than `after`, in recipient order. fn events_of(standalone: &Standalone, kind: u8, after: u64) -> Vec<(String, String)> { let mut events: Vec<_> = standalone .query_rows(&format!( @@ -394,7 +391,6 @@ fn an_all_pass_resolution_tells_every_roller_and_a_win_does_not() { ); assert!(events_of(&standalone, ROLL_WON, 0).is_empty()); - // A contested roll announces its winner and adds no all-passed notice. let before = newest_event(&standalone); promotion.source_roll_id = 11; promotion.send(&standalone);