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
83 changes: 83 additions & 0 deletions crates/lyracore-shared/src/item.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,76 @@
//! Item rules shared by the module and gateway.

/// Why the Module refused an item Durable Request. The tag is the whole reducer error text, so
/// neither tier matches on human prose; the detail stays in the Module log.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ItemRefusal {
/// The named slot holds no item, or the item has no template.
ItemNotFound,
/// No free backpack, bag, or bag-equip slot is left to receive the item.
InventoryFull,
/// The destination slot is out of range, is not an equipment slot, or is already taken.
WrongSlot,
/// The item cannot go on the body: the wrong slot family for its type, or a required level
/// the Character has not reached.
CannotEquip,
/// The Character's class, race, or armor and weapon proficiency does not cover the item.
NoProficiency,
/// The item needs a trained skill rank the Character does not hold.
RequiredSkill,
/// The item needs a reputation standing the Character has not earned.
RequiredReputation,
/// A dead Character cannot act on its inventory.
PlayerDead,
/// The action reaches bank space with no banker in range.
BankUnavailable,
/// The item carries no on-use spell, sits in the bank, or restores a power the class lacks.
ItemNotUsable,
/// The action is blocked for now: a live bandage cooldown, or a cast Gate.
NotRightNow,
/// The Module could not act and has no gameplay reason to name.
Internal,
}

impl ItemRefusal {
pub const ALL: [Self; 12] = [
Self::ItemNotFound,
Self::InventoryFull,
Self::WrongSlot,
Self::CannotEquip,
Self::NoProficiency,
Self::RequiredSkill,
Self::RequiredReputation,
Self::PlayerDead,
Self::BankUnavailable,
Self::ItemNotUsable,
Self::NotRightNow,
Self::Internal,
];

pub fn as_tag(self) -> &'static str {
match self {
Self::ItemNotFound => "item:item_not_found",
Self::InventoryFull => "item:inventory_full",
Self::WrongSlot => "item:wrong_slot",
Self::CannotEquip => "item:cannot_equip",
Self::NoProficiency => "item:no_proficiency",
Self::RequiredSkill => "item:required_skill",
Self::RequiredReputation => "item:required_reputation",
Self::PlayerDead => "item:player_dead",
Self::BankUnavailable => "item:bank_unavailable",
Self::ItemNotUsable => "item:item_not_usable",
Self::NotRightNow => "item:not_right_now",
Self::Internal => "item:internal",
}
}

pub fn parse_tag(tag: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|refusal| refusal.as_tag() == tag)
}
}

pub mod armor_subclass {
pub const MISC: u8 = 0;
pub const CLOTH: u8 = 1;
Expand Down Expand Up @@ -172,6 +243,18 @@ mod tests {
/// The nine playable classes (6 and 10 are unused in 1.12).
const PLAYABLE: [u8; 9] = [1, 2, 3, 4, 5, 7, 8, 9, 11];

#[test]
fn every_refusal_tag_round_trips() {
for refusal in ItemRefusal::ALL {
assert_eq!(ItemRefusal::parse_tag(refusal.as_tag()), Some(refusal));
}
assert_eq!(ItemRefusal::parse_tag("item:"), None);
assert_eq!(
ItemRefusal::parse_tag("gw_move_item reducer timed out after 10s"),
None
);
}

/// What a class can equip with both level-40 upgrades trained — the auction "usable" filter's
/// question, and the shape the pre-training class table was written in.
fn ceiling_can_equip(player_class: u8, item_class: u8, item_subclass: u8) -> bool {
Expand Down
73 changes: 72 additions & 1 deletion gateway/src/codec/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

use super::*;

use lyracore_shared::item::{item_class, weapon_subclass, Proficiency};
use lyracore_shared::item::{item_class, weapon_subclass, ItemRefusal, Proficiency};

/// An item-template row as the gateway reads it from `game_item_template`, flattened for the
/// `CMSG_ITEM_QUERY_SINGLE` reply + the item CREATE (items slice-1). Decoupled from the SDK row
Expand Down Expand Up @@ -260,6 +260,77 @@ pub fn build_inventory_change_failure() -> SMSG_INVENTORY_CHANGE_FAILURE {
}
}

/// Build `SMSG_INVENTORY_CHANGE_FAILURE` for one typed item Refusal — the vanilla result code the
/// 1.12 client turns into its own error line. The item guids stay zero: the module names the reason,
/// not the rows. `Internal` has no gameplay meaning, so it reads as the client's internal bag error.
/// A required level has no payload-free code (vanilla's level result carries the level itself), so it
/// falls back to the generic equip and use failures.
pub fn build_inventory_refusal(refusal: ItemRefusal) -> SMSG_INVENTORY_CHANGE_FAILURE {
let (item1, item2, bag_type_subclass) = (Guid::new(0), Guid::new(0), 0);
match refusal {
ItemRefusal::ItemNotFound => SMSG_INVENTORY_CHANGE_FAILURE::ItemNotFound {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::InventoryFull => SMSG_INVENTORY_CHANGE_FAILURE::InventoryFull {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::WrongSlot => SMSG_INVENTORY_CHANGE_FAILURE::ItemDoesntGoToSlot {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::CannotEquip => SMSG_INVENTORY_CHANGE_FAILURE::ItemCantBeEquipped {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::NoProficiency => SMSG_INVENTORY_CHANGE_FAILURE::NoRequiredProficiency {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::RequiredSkill => SMSG_INVENTORY_CHANGE_FAILURE::CantEquipSkill {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::RequiredReputation => SMSG_INVENTORY_CHANGE_FAILURE::CantEquipReputation {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::PlayerDead => SMSG_INVENTORY_CHANGE_FAILURE::YouAreDead {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::BankUnavailable => SMSG_INVENTORY_CHANGE_FAILURE::TooFarAwayFromBank {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::ItemNotUsable => SMSG_INVENTORY_CHANGE_FAILURE::YouCanNeverUseThatItem {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::NotRightNow => SMSG_INVENTORY_CHANGE_FAILURE::CantDoRightNow {
item1,
item2,
bag_type_subclass,
},
ItemRefusal::Internal => SMSG_INVENTORY_CHANGE_FAILURE::IntBagError {
item1,
item2,
bag_type_subclass,
},
}
}

/// Build `SMSG_BUY_FAILED` — the red on-screen error for a rejected vendor purchase.
/// Maps the module's Err string to the closest `BuyResult` code (displayed as a toast by the
/// 1.12 client). `vendor_guid` is the NPC, `item_entry` is the `c.item` from the client packet.
Expand Down
131 changes: 107 additions & 24 deletions gateway/src/stdb/reducers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::bindings::*;
use super::connection::{call_reducer, recv_reducer_on, reducer_refusal_reason, Coordinator};
use super::views::entity_view;
use crate::world::ItemActionResult;
use lyracore_shared::auction::AuctionRefusal;
use lyracore_shared::item::ItemRefusal;
use lyracore_shared::trainer::TrainerRefusal;

static NEXT_TAXI_REQUEST_ID: OnceLock<AtomicU64> = OnceLock::new();
Expand Down Expand Up @@ -2183,45 +2185,60 @@ impl Coordinator {
/// Equip the item in main-inventory `from_slot` (`CMSG_AUTOEQUIP_ITEM`) over the coordinator
/// connection. The module resolves the matching equipment slot and gates the required level.
/// Rides the coordinator connection as `gw_equip_item`.
pub fn equip_item(&self, _account_id: u64, actor_guid: u64, from_slot: u8) -> Result<()> {
if actor_guid == 0 {
return Err(anyhow!("equip_item: actor_guid unresolved"));
}
pub fn equip_item(
&self,
_account_id: u64,
actor_guid: u64,
from_slot: u8,
) -> Result<ItemActionResult> {
let Some(actor_guid) = resolved_item_actor("equip_item", actor_guid) else {
return Ok(ItemRefusal::Internal.into());
};
let coord = self.0.call_pipe();
call_reducer!(
item_action(call_reducer!(
coord.conn.reducers,
"gw_equip_item",
gw_equip_item_then(actor_guid, from_slot)
)
))
}

/// Unequip the item in equipment `from_slot` to a free backpack slot (`CMSG_AUTOSTORE_BAG_ITEM`)
/// over the coordinator connection. The module gates "is equipped" + "backpack has room".
pub fn unequip_item(&self, _account_id: u64, actor_guid: u64, from_slot: u8) -> Result<()> {
if actor_guid == 0 {
return Err(anyhow!("unequip_item: actor_guid unresolved"));
}
pub fn unequip_item(
&self,
_account_id: u64,
actor_guid: u64,
from_slot: u8,
) -> Result<ItemActionResult> {
let Some(actor_guid) = resolved_item_actor("unequip_item", actor_guid) else {
return Ok(ItemRefusal::Internal.into());
};
let coord = self.0.call_pipe();
call_reducer!(
item_action(call_reducer!(
coord.conn.reducers,
"gw_unequip_item",
gw_unequip_item_then(actor_guid, from_slot)
)
))
}

/// Use the consumable in main-inventory `slot` (`CMSG_USE_ITEM`) over the coordinator connection —
/// eat/drink/potion/bandage. The module applies the on-use effect (flat heal for slice food) and
/// decrements the stack; a gameplay `Err` (no item / not usable) is per-action.
pub fn use_item(&self, _account_id: u64, actor_guid: u64, slot: u8) -> Result<()> {
if actor_guid == 0 {
return Err(anyhow!("use_item: actor_guid unresolved"));
}
pub fn use_item(
&self,
_account_id: u64,
actor_guid: u64,
slot: u8,
) -> Result<ItemActionResult> {
let Some(actor_guid) = resolved_item_actor("use_item", actor_guid) else {
return Ok(ItemRefusal::Internal.into());
};
let coord = self.0.call_pipe();
call_reducer!(
item_action(call_reducer!(
coord.conn.reducers,
"gw_use_item",
gw_use_item_then(actor_guid, slot)
)
))
}

/// Bind the caller's hearthstone home to their current position (`CMSG_GOSSIP_SELECT_OPTION` on an
Expand All @@ -2247,16 +2264,16 @@ impl Coordinator {
actor_guid: u64,
from_slot: u8,
to_slot: u8,
) -> Result<()> {
if actor_guid == 0 {
return Err(anyhow!("move_item: actor_guid unresolved"));
}
) -> Result<ItemActionResult> {
let Some(actor_guid) = resolved_item_actor("move_item", actor_guid) else {
return Ok(ItemRefusal::Internal.into());
};
let coord = self.0.call_pipe();
call_reducer!(
item_action(call_reducer!(
coord.conn.reducers,
"gw_move_item",
gw_move_item_then(actor_guid, from_slot, to_slot)
)
))
}

/// Auto-bank/auto-store-bank the item in `slot` (`CMSG_AUTOBANK_ITEM`/`CMSG_AUTOSTORE_BANK_ITEM`)
Expand Down Expand Up @@ -3111,6 +3128,28 @@ fn trainer_refusal(error: &anyhow::Error) -> Option<TrainerRefusal> {
reducer_refusal_reason(error).and_then(TrainerRefusal::parse_tag)
}

/// The Module's typed item Refusal, on the same rule as the auction family: only a reducer the
/// Module rejected carries a tag, so a timeout or transport failure keeps its unknown outcome.
fn item_action(result: Result<()>) -> Result<ItemActionResult> {
match result {
Ok(()) => Ok(ItemActionResult::Done),
Err(error) => match reducer_refusal_reason(&error).and_then(ItemRefusal::parse_tag) {
Some(refusal) => Ok(refusal.into()),
None => Err(error),
},
}
}

/// An item action needs the caller's own entity. Without one there is nothing to request, so the
/// client gets a Refusal rather than a dead session.
fn resolved_item_actor(operation: &str, actor_guid: u64) -> Option<u64> {
if actor_guid == 0 {
log::warn!("stdb: {operation} has no resolved actor");
return None;
}
Some(actor_guid)
}

fn bid_payload_matches(hold: &AuctionBidHold, decision: &AuctionBidDecision) -> bool {
hold.operation_id == decision.operation_id
&& hold.bidder_guid == decision.bidder_guid
Expand Down Expand Up @@ -3161,6 +3200,50 @@ fn bid_outcome(hold: &AuctionBidHold) -> Result<crate::world::PlaceBidOutcome> {
})
}

#[cfg(test)]
mod item_reducer_tests {
use super::*;
use crate::stdb::connection::ReducerCallError;

#[test]
fn only_a_rejected_reducer_carries_a_typed_refusal() {
for refusal in ItemRefusal::ALL {
let rejected = Err(anyhow::Error::from(ReducerCallError::Rejected {
operation: "gw_move_item".to_string(),
reason: refusal.as_tag().to_string(),
})
.context("move phase"));
assert_eq!(
item_action(rejected).unwrap(),
ItemActionResult::Refused(refusal)
);
}

assert_eq!(item_action(Ok(())).unwrap(), ItemActionResult::Done);

let not_refusals = [
anyhow::Error::from(ReducerCallError::fatal(
"gw_move_item reducer timed out after 10s".to_string(),
)),
anyhow::Error::from(ReducerCallError::fatal(
"gw_use_item reducer failed: transport disconnected".to_string(),
)),
anyhow::Error::from(ReducerCallError::Rejected {
operation: "gw_equip_item".to_string(),
reason: "operator only".to_string(),
}),
anyhow!(
"wrapped text that mentions {}",
ItemRefusal::Internal.as_tag()
),
];
for error in not_refusals {
let text = format!("{error:#}");
assert!(item_action(Err(error)).is_err(), "{text}");
}
}
}

#[cfg(test)]
mod auction_reducer_tests {
use super::*;
Expand Down
Loading
Loading