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
56 changes: 56 additions & 0 deletions crates/lyracore-shared/src/trainer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,50 @@ pub mod trainer_type {
/// it does not echo the offering's marker id back as a learned spell.
pub const RIDING_SKILL_LINE: u32 = 762;

/// Why the Module refused a trainer Durable Request. The tag is the whole reducer error text, so
/// neither tier matches on human prose.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrainerRefusal {
/// The trainer will not serve this interaction at all: missing, not a trainer, on another map,
/// out of range, refusing the buyer's class, or asked for riding while teaching something else.
Unavailable,
/// The trainer's offering list does not carry that spell.
NotOffered,
AlreadyKnown,
LevelTooLow,
/// A rank purchase whose `game_spell_chain` predecessor is not known yet.
PreviousRankMissing,
NotEnoughMoney,
}

impl TrainerRefusal {
pub const ALL: [Self; 6] = [
Self::Unavailable,
Self::NotOffered,
Self::AlreadyKnown,
Self::LevelTooLow,
Self::PreviousRankMissing,
Self::NotEnoughMoney,
];

pub fn as_tag(self) -> &'static str {
match self {
Self::Unavailable => "trainer:unavailable",
Self::NotOffered => "trainer:not_offered",
Self::AlreadyKnown => "trainer:already_known",
Self::LevelTooLow => "trainer:level_too_low",
Self::PreviousRankMissing => "trainer:previous_rank_missing",
Self::NotEnoughMoney => "trainer:not_enough_money",
}
}

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

/// Will this trainer serve a character of `player_class`?
///
/// Refuses only when all three hold: the trainer is a `CLASS` trainer, its `trainer_class` is
Expand All @@ -47,6 +91,18 @@ mod tests {
/// Every class the 1.12 client can create. 6 and 10 do not exist in vanilla.
const CLASSES: [u8; 9] = [1, 2, 3, 4, 5, 7, 8, 9, 11];

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

#[test]
fn a_class_trainer_serves_its_own_class_and_refuses_every_other() {
for &trainer_class in &CLASSES {
Expand Down
37 changes: 9 additions & 28 deletions gateway/src/codec/trainer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,14 @@ pub fn build_trainer_buy_succeeded(trainer_guid: u64, spell_id: u32) -> SMSG_TRA
}
}

/// Build `SMSG_TRAINER_BUY_FAILED`. `reason` is the gtker `TrainingFailureReason` int the module tagged
/// onto its `Err` ([1]=money, [2]=level/req, else generic). gtker vanilla has only 3 reasons, so
/// already-known/range/flag failures fall to Unavailable — cosmetic, since the client gates the Learn
/// button on the Green state from the list.
/// Build `SMSG_TRAINER_BUY_FAILED`. The caller picks `error` from the Module's typed Refusal; gtker
/// vanilla carries only 3 reasons, so several Refusals share Unavailable — cosmetic, since the client
/// gates the Learn button on the Green state from the list.
pub fn build_trainer_buy_failed(
trainer_guid: u64,
spell_id: u32,
reason: u32,
error: TrainingFailureReason,
) -> SMSG_TRAINER_BUY_FAILED {
let error = match reason {
1 => TrainingFailureReason::NotEnoughMoney,
2 => TrainingFailureReason::NotEnoughSkill,
_ => TrainingFailureReason::Unavailable,
};
SMSG_TRAINER_BUY_FAILED {
guid: Guid::new(trainer_guid),
id: spell_id,
Expand Down Expand Up @@ -157,23 +151,10 @@ mod tests {
}

#[test]
fn trainer_buy_failed_maps_reason_code_to_the_closest_failure_reason() {
let money = build_trainer_buy_failed(42, 100, 1);
assert_eq!(money.error, TrainingFailureReason::NotEnoughMoney);
assert_eq!(money.guid, Guid::new(42));
assert_eq!(money.id, 100);
let skill = build_trainer_buy_failed(42, 100, 2);
assert_eq!(skill.error, TrainingFailureReason::NotEnoughSkill);
// gtker vanilla has only 3 reasons; any other code (already-known/range/flag failures)
// degrades to Unavailable — cosmetic, since the client gates the Learn button on the list's
// Green state anyway.
for other in [0u32, 3, 99] {
let msg = build_trainer_buy_failed(42, 100, other);
assert_eq!(
msg.error,
TrainingFailureReason::Unavailable,
"reason {other} must degrade to Unavailable"
);
}
fn trainer_buy_failed_addresses_the_trainer_and_the_offering() {
let msg = build_trainer_buy_failed(42, 100, TrainingFailureReason::NotEnoughMoney);
assert_eq!(msg.guid, Guid::new(42));
assert_eq!(msg.id, 100);
assert_eq!(msg.error, TrainingFailureReason::NotEnoughMoney);
}
}
64 changes: 58 additions & 6 deletions gateway/src/stdb/reducers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use super::bindings::*;
use super::connection::{call_reducer, recv_reducer_on, reducer_refusal_reason, Coordinator};
use super::views::entity_view;
use lyracore_shared::auction::AuctionRefusal;
use lyracore_shared::trainer::TrainerRefusal;

static NEXT_TAXI_REQUEST_ID: OnceLock<AtomicU64> = OnceLock::new();

Expand Down Expand Up @@ -1977,25 +1978,32 @@ impl Coordinator {
}

/// Learn `spell_id` from trainer `trainer_guid` (`CMSG_TRAINER_BUY_SPELL`) over the coordinator
/// connection. The module gates it (range / level / cost / not-already-known) and charges copper;
/// the `Err` message carries the module's `[N]` gtker failure-reason tag for the dispatch to forward.
/// connection. The module gates it (range / level / cost / not-already-known) and charges copper.
/// A Refusal the Module tagged comes back as an outcome; anything else stays an error.
/// Rides the coordinator connection as `gw_trainer_buy`.
pub fn buy_trainer_spell(
&self,
_account_id: u64,
actor_guid: u64,
trainer_guid: u64,
spell_id: u32,
) -> Result<()> {
) -> Result<crate::world::TrainerBuyOutcome> {
if actor_guid == 0 {
return Err(anyhow!("buy_trainer_spell: actor_guid unresolved"));
}
let coord = self.0.call_pipe();
call_reducer!(
let result: Result<()> = call_reducer!(
coord.conn.reducers,
"gw_trainer_buy",
gw_trainer_buy_then(actor_guid, trainer_guid, spell_id)
)
);
match result {
Ok(()) => Ok(crate::world::TrainerBuyOutcome::Learned),
Err(error) => match trainer_refusal(&error) {
Some(refusal) => Ok(refusal.into()),
None => Err(error),
},
}
}

/// Buy the next bank bag slot from `banker_guid` (`CMSG_BUY_BANK_SLOT`) over the coordinator
Expand Down Expand Up @@ -3097,6 +3105,12 @@ fn auction_refusal(error: &anyhow::Error) -> Option<AuctionRefusal> {
reducer_refusal_reason(error).and_then(AuctionRefusal::parse_tag)
}

/// The Module's typed trainer Refusal. Only a reducer the Module rejected carries a tag; a timeout,
/// transport, or SDK failure stays an error with an unknown outcome.
fn trainer_refusal(error: &anyhow::Error) -> Option<TrainerRefusal> {
reducer_refusal_reason(error).and_then(TrainerRefusal::parse_tag)
}

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 @@ -3271,7 +3285,10 @@ mod auction_reducer_tests {
fn refused_listing_binding_matches_the_generated_commit_listing_shape() {
let expected = include_str!("bindings/realm_auction_commit_listing_reducer.rs")
.replace("RealmAuctionCommitListing", "RealmAuctionRefundListing")
.replace("realm_auction_commit_listing", "realm_auction_refund_listing");
.replace(
"realm_auction_commit_listing",
"realm_auction_refund_listing",
);
assert_eq!(
include_str!("bindings/realm_auction_refund_listing_reducer.rs"),
expected,
Expand Down Expand Up @@ -3304,3 +3321,38 @@ mod auction_reducer_tests {
);
}
}

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

#[test]
fn only_a_rejected_reducer_carries_a_typed_refusal() {
for refusal in TrainerRefusal::ALL {
let error = anyhow::Error::from(ReducerCallError::Rejected {
operation: "gw_trainer_buy".to_string(),
reason: refusal.as_tag().to_string(),
})
.context("trainer buy");
assert_eq!(trainer_refusal(&error), Some(refusal));
}

let not_refusals = [
anyhow::Error::from(ReducerCallError::fatal(
"gw_trainer_buy reducer timed out after 10s".to_string(),
)),
anyhow::Error::from(ReducerCallError::Rejected {
operation: "gw_trainer_buy".to_string(),
reason: "operator only".to_string(),
}),
anyhow!(
"wrapped text that mentions {}",
TrainerRefusal::AlreadyKnown.as_tag()
),
];
for error in not_refusals {
assert_eq!(trainer_refusal(&error), None, "{error:#}");
}
}
}
2 changes: 1 addition & 1 deletion gateway/src/stdb/world_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,7 @@ impl WorldStore for Coordinator {
self_guid: u64,
trainer_guid: u64,
spell_id: u32,
) -> Result<()> {
) -> Result<crate::world::TrainerBuyOutcome> {
self.buy_trainer_spell(account_id, self_guid, trainer_guid, spell_id)
}

Expand Down
2 changes: 1 addition & 1 deletion gateway/src/world/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ pub(crate) use taxi::{
dispatch_taxi_action, queue_reply_then_arm, TaxiActionOutcome, TaxiActionPlayer, TaxiActionStore,
};
pub(crate) use trade::handle_trade;
pub(crate) use trainer::handle_trainer;
pub(crate) use trainer::{handle_trainer, TrainerBuyOutcome};
pub(crate) use vendor::{
dispatch_vendor_action, VendorActionOutcome, VendorActionPlayer, VendorActionStore,
};
Expand Down
52 changes: 36 additions & 16 deletions gateway/src/world/handlers/trainer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,38 @@
//! `world/mod.rs`.

use super::super::*;
use lyracore_shared::trainer::TrainerRefusal;
use wow_world_messages::vanilla::TrainingFailureReason;

/// How the Module answered a trainer purchase. A Refusal is a gameplay answer the client can render;
/// a failed Durable Request is not, so it never reaches here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TrainerBuyOutcome {
Learned,
Refused(TrainingFailureReason),
}

// gtker vanilla carries three failure reasons, so several gameplay Refusals share one. The client
// pre-gates the Learn button on the list's Green state, which is what keeps the collapse cosmetic.
impl From<TrainerRefusal> for TrainerBuyOutcome {
fn from(refusal: TrainerRefusal) -> Self {
Self::Refused(match refusal {
TrainerRefusal::NotEnoughMoney => TrainingFailureReason::NotEnoughMoney,
TrainerRefusal::LevelTooLow | TrainerRefusal::PreviousRankMissing => {
TrainingFailureReason::NotEnoughSkill
}
TrainerRefusal::Unavailable
| TrainerRefusal::NotOffered
| TrainerRefusal::AlreadyKnown => TrainingFailureReason::Unavailable,
})
}
}

/// Class-trainer family: open the trainer window (`CMSG_TRAINER_LIST` → `SMSG_TRAINER_LIST`, each spell
/// Green/Red/Gray) and learn a spell (`CMSG_TRAINER_BUY_SPELL` → the module buy →
/// `SMSG_TRAINER_BUY_*` + a live `SMSG_LEARNED_SPELL` so it hits the action bar without a relog).
/// Needs the in-world player guid (a trainer is only clicked in-world); in CharSelect the opcodes
/// pass through. A buy rejection is per-action — surfaced as `SMSG_TRAINER_BUY_FAILED` (reason
/// parsed from the module's `[N]` tag).
/// pass through. A buy Refusal is per-action — surfaced as `SMSG_TRAINER_BUY_FAILED`.
pub(crate) fn handle_trainer<St: WorldStore + ?Sized>(
tx: &SessionTx,
store: &St,
Expand Down Expand Up @@ -50,8 +75,10 @@ pub(crate) fn handle_trainer<St: WorldStore + ?Sized>(
ClientOpcodeMessage::CMSG_TRAINER_BUY_SPELL(c) => {
let trainer_guid = c.guid.guid();
let spell_id = c.id;
match store.buy_trainer_spell(conn.account_id, self_guid, trainer_guid, spell_id) {
Ok(()) => {
// A Refusal arrives as an outcome. An error leaves the durable result unknown, so it
// ends the session instead of posing as a gameplay answer.
match store.buy_trainer_spell(conn.account_id, self_guid, trainer_guid, spell_id)? {
TrainerBuyOutcome::Learned => {
// Confirm + push the spell live so it appears on the action bar without a relog.
send(
tx,
Expand Down Expand Up @@ -110,18 +137,9 @@ pub(crate) fn handle_trainer<St: WorldStore + ?Sized>(
send_armor_proficiency(tx, store, self_guid)?;
}
}
Err(e) => {
// The module tags its Err with a [N] gtker failure-reason; 1=money, 2=level/req, else generic.
let es = e.to_string();
let reason = if es.contains("[1]") {
1
} else if es.contains("[2]") {
2
} else {
0
};
TrainerBuyOutcome::Refused(reason) => {
log::debug!(
"world: buy_trainer_spell failed (account {}): {es}",
"world: trainer buy refused (account {}): {reason:?}",
conn.account_id
);
send(
Expand Down Expand Up @@ -160,7 +178,9 @@ pub(crate) fn handle_trainer<St: WorldStore + ?Sized>(
ClientOpcodeMessage::CMSG_SET_FACTION_ATWAR(c) => {
let reputation_index = c.faction.as_int() as u32;
let at_war = c.flags.is_at_war();
if let Err(e) = store.set_faction_at_war(conn.account_id, self_guid, reputation_index, at_war) {
if let Err(e) =
store.set_faction_at_war(conn.account_id, self_guid, reputation_index, at_war)
{
log::debug!(
"world: set_faction_at_war ignored (account {}): {e}",
conn.account_id
Expand Down
2 changes: 1 addition & 1 deletion gateway/src/world/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ use handlers::{
};
pub(crate) use handlers::{
zone_weather_message, AuctionBrowseRequest, AuctionPage, AuctionQuery, CreateAuctionOutcome,
CreateAuctionRequest, PlaceBidOutcome, PlaceBidRequest, WeatherStore,
CreateAuctionRequest, PlaceBidOutcome, PlaceBidRequest, TrainerBuyOutcome, WeatherStore,
};
use login_queue::{Admission, LoginQueue};
use social::handle_social;
Expand Down
5 changes: 3 additions & 2 deletions gateway/src/world/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,14 +428,15 @@ pub trait WorldStore:
) -> Result<Vec<codec::TrainerSpellView>>;

/// Buy/learn `spell_id` from trainer `trainer_guid` (`CMSG_TRAINER_BUY_SPELL`). The module gates it
/// (range / level / cost / not-already-known); `Err` carries a `[N]` gtker failure-reason tag.
/// (range / level / cost / not-already-known) and a Refusal comes back as an outcome; `Err` means
/// the durable result is unknown.
fn buy_trainer_spell(
&self,
account_id: u64,
self_guid: u64,
trainer_guid: u64,
spell_id: u32,
) -> Result<()>;
) -> Result<TrainerBuyOutcome>;

/// The skill line a trainer offering teaches (`game_trainer_spell.learn_skill_line`), or 0 for an
/// ordinary spell offering. The buy path reads it to tell a SKILL purchase from a SPELL purchase:
Expand Down
Loading
Loading