Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion crates/lyracore-shared/src/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
33 changes: 33 additions & 0 deletions crates/lyracore-shared/src/loot_roll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String> = eligible.iter().map(|g| g.to_string()).collect();
format!("{corpse_guid}|{}", guids.join(","))
Expand Down Expand Up @@ -252,6 +274,17 @@ mod tests {
assert_eq!(kinds, (0u8..=8).collect::<Vec<u8>>());
}

#[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);
Expand Down
3 changes: 3 additions & 0 deletions docs/benilla-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
5 changes: 5 additions & 0 deletions gateway/examples/benilla_inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ fn row(
opcode,
0x005a
| 0x00f9
| 0x0107
| 0x0109
| 0x021f
| 0x0220
| 0x0336
| 0x0125
| 0x01ed
| 0x0200
Expand Down
103 changes: 103 additions & 0 deletions gateway/src/codec/item.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
match (bag, slot) {
Expand Down Expand Up @@ -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<u8>) {
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),
Expand Down Expand Up @@ -480,6 +520,69 @@ fn weapon_subclass_mask(player_class: u8) -> u32 {
mod tests {
use super::*;

fn framed(message: ServerOpcodeMessage) -> Vec<u8> {
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 {
Expand Down
42 changes: 41 additions & 1 deletion gateway/src/codec/loot.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
]
);
}
}
18 changes: 18 additions & 0 deletions gateway/src/codec/social/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions gateway/src/stdb/reads/items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,15 @@ impl Coordinator {
.collect()
}

pub fn item_slot_by_guid(&self, item_guid: u64) -> Option<u8> {
/// 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<u8> {
let guard = self.0.coord();
let slots: Vec<u8> = 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()
Expand Down
4 changes: 2 additions & 2 deletions gateway/src/stdb/store/cast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,8 @@ impl CastStore for Coordinator {
)
}

fn item_slot_by_guid(&self, item_guid: u64) -> Option<u8> {
Coordinator::item_slot_by_guid(self, item_guid)
fn item_slot_by_guid(&self, owner_guid: u64, item_guid: u64) -> Option<u8> {
Coordinator::item_slot_by_guid(self, owner_guid, item_guid)
}

fn player_items(&self, owner_guid: u64) -> Result<Vec<codec::ItemInstanceView>> {
Expand Down
39 changes: 38 additions & 1 deletion gateway/src/stdb/store/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<String>> {
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<ReadableItem> {
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<ItemActionResult> {
let coord = self.0.call_pipe();
item_action(call_reducer!(
Expand Down
4 changes: 2 additions & 2 deletions gateway/src/stdb/store/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
crate::stdb::Coordinator::item_slot_by_guid(self, item_guid)
fn vendor_item_slot(&self, owner_guid: u64, item_guid: u64) -> Option<u8> {
crate::stdb::Coordinator::item_slot_by_guid(self, owner_guid, item_guid)
}

fn vendor_repair(&self, actor: Actor, npc_guid: u64, slot: u8) -> Result<()> {
Expand Down
Loading
Loading