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..a30d353b2 100644 --- a/crates/lyracore-shared/src/loot_roll.rs +++ b/crates/lyracore-shared/src/loot_roll.rs @@ -6,6 +6,7 @@ pub mod event_kind { pub const ROLL_WON: u8 = 6; pub const MASTER_LIST: u8 = 7; pub const MONEY_SHARE: u8 = 8; + pub const ALL_PASSED: u8 = 12; } // START preserves the World Shard deadline so promotion does not restart the roll clock. @@ -121,6 +122,27 @@ pub fn decode_won(payload: &str) -> Option<(u64, u8, u32, u8, u8, u32)> { )) } +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}") +} + +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 +274,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/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..a2e82d31c 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,44 @@ pub fn build_buy_failed(vendor_guid: u64, item_entry: u32, err: &str) -> SMSG_BU } } +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, + } +} + +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 + } +} + +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 } +} + +pub const SMSG_READ_ITEM_FAILED_OPCODE: u16 = 0x00AF; + +/// 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); + (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 +520,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..2589d0604 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,43 @@ pub fn build_loot_master_list(eligible: &[u64]) -> SMSG_LOOT_MASTER_LIST { guids: eligible.iter().map(|&g| Guid::new(g)).collect(), } } + +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/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/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/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/item.rs b/gateway/src/world/handlers/item.rs index 6a861f060..94a709925 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -17,7 +17,16 @@ impl From for ItemActionResult { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReadableItem { + Missing, + NoPages, + Pages { item_guid: u64 }, +} + pub(crate) trait ItemActionStore: Send + Sync { + 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 +159,43 @@ impl ProtocolFamily for Ite None => act(player, "use_item", |actor| store.use_item(actor, slot)), } } + // An unknown entry 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::>(), + )), + // 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), + 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 +207,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 +224,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 +243,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 +837,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..c113ded23 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -1442,3 +1442,57 @@ mod tests { assert!(store.item_take_requests.lock().unwrap().is_empty()); } } + +#[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..baf05f5c7 100644 --- a/gateway/src/world/handlers/unavailable.rs +++ b/gateway/src/world/handlers/unavailable.rs @@ -197,6 +197,8 @@ 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 +252,16 @@ 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..3b5e4ab17 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())); @@ -149,17 +150,32 @@ 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(actor.guid(), item_guid) else { return Ok(ProtocolReply::from(Vec::new())); }; - 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), } @@ -292,6 +308,7 @@ pub(super) mod tests { pub(crate) ring: Mutex>, pub(crate) random_properties: Vec<(u32, [u32; 3])>, pub(crate) item_slots: Vec<(u64, u8)>, + pub(crate) foreign_items: Vec, pub(crate) repair_error: Option, pub(crate) sell_error: Option, pub(crate) buyback_requests: Mutex>, @@ -348,7 +365,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) @@ -722,35 +742,84 @@ pub(super) mod tests { assert_renders_ring(&outcome.outbound, &ring); } + 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_is_silent_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!(outcome.outbound.is_empty()); assert!(actions.sell_requests.lock().unwrap().is_empty()); } #[test] - fn a_rejected_sale_is_silent_and_non_fatal() { + fn selling_or_repairing_another_characters_item_asks_the_module_nothing() { let actions = InMemoryVendorActions { item_slots: vec![(ITEM, 30)], - sell_error: Some(|| { - ReducerCallError::refused("gw_sell_item", "that item cannot be sold") - }), + foreign_items: vec![ITEM], ..Default::default() }; - let outcome = Vendor::handle(&actions, &mut player(), sell_item(ITEM).into()).unwrap(); + 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!(matches!( - outcome, - ProtocolReply { outbound, .. } if outbound.is_empty() - )); + 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] + fn selling_a_zero_item_guid_is_silent() { + let actions = InMemoryVendorActions::default(); + + let outcome = Vendor::handle(&actions, &mut player(), sell_item(0).into()).unwrap(); + + 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..5b5133610 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -59,7 +59,7 @@ pub(crate) use handlers::{ ChatOutcome, CreateAuctionOutcome, CreateAuctionRequest, GuildEventSnapshot, GuildOutcome, 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::{ 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/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/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()) } 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 } 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..09a0db80c --- /dev/null +++ b/module/tests/death_durability_notice.rs @@ -0,0 +1,58 @@ +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"); + // The reaper would delete the events under test. + shard.assert_sql("DELETE FROM game_event_reaper_schedule"); + 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..a7d2738b0 100644 --- a/module/tests/loot_roll_promotion.rs +++ b/module/tests/loot_roll_promotion.rs @@ -89,6 +89,8 @@ fn fixture(name: &str) -> Standalone { standalone.publish_module(); standalone.assert_call("claim_operator", &[]); standalone.assert_call("install_guid_range", &["0"]); + // The reaper would delete the events under test. + standalone.assert_sql("DELETE FROM game_event_reaper_schedule"); standalone } @@ -352,3 +354,49 @@ fn votes(standalone: &Standalone) -> Vec> { rows.sort_by(|a, b| a["id"].cmp(&b["id"])); rows } + +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) +} + +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} AND id > {after}" + )) + .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, 0), + ["5090003", "5090004", "5090005"].map(|roller| (roller.to_string(), payload.clone())) + ); + assert!(events_of(&standalone, ROLL_WON, 0).is_empty()); + + 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!(events_of(&standalone, ALL_PASSED, before).is_empty()); + assert_eq!(events_of(&standalone, ROLL_WON, before).len(), 3); +}