- the social window's third tab is live — the roster in both its views, the member card with its promote/demote/remove verbs, the rank editor's thirteen permissions, the guild information board, and the invite, leave, kick and note dialogs behind them - setting your hearthstone at an innkeeper now asks before it takes and says so afterwards, and the innkeeper's gossip lines get their own icons back - unavailable talents grey out, and every greyed icon in the client now goes through the same luminance fold the original used - the buff tooltip's remaining-time line reads gold with the client's own hour/minute ladder, the macro editor takes a shift-click to paste a spell, trainer rows repaint after a purchase, and a long book page stops flickering its ellipsis Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
469 lines
23 KiB
Rust
469 lines
23 KiB
Rust
//! The app-side **trainer feed** (decision 0237 phase 3) — the inward half of the trainer seam
|
|
//! around [`benilla_ui::script`]'s `trainer` module, the twin of [`crate::ui_merchant`]'s merchant
|
|
//! feed.
|
|
//!
|
|
//! The net bridge fills [`TrainerOpen`] from the wire (`SMSG_TRAINER_LIST` → the trainer's services +
|
|
//! greeting, reached through the gossip trainer option). Each frame [`feed_trainer`] resolves each
|
|
//! wire [`TrainerSpell`] to a Lua-facing [`TrainerService`] — name/subtext from the spell catalog
|
|
//! (`Spell.dbc`, loaded whole at startup), the **icon** from its own byte-verified law
|
|
//! ([`service_icon`], which at a *tradeskill* trainer fronts the taught recipe's created item and so
|
|
//! does need the ask-once item-template query the item rows use), the skill-requirement name from
|
|
//! the skill-line catalog, the green/red/gray state straight off the wire
|
|
//! `state` byte — pushes the snapshot ([`UiScript::set_trainer`]), and fires `TRAINER_SHOW` on open /
|
|
//! `TRAINER_UPDATE` on a content change / `TRAINER_CLOSED` on clear. [`drain_trainer`] pulls the Lua
|
|
//! intents back out: the Train button's `BuyTrainerService` → `CMSG_TRAINER_BUY_SPELL` for the open
|
|
//! trainer, and `CloseTrainer` → a local clear (vanilla's client-side close sends no packet). A
|
|
//! successful buy answers `SMSG_TRAINER_BUY_SUCCEEDED` — the spell itself lands via
|
|
//! `SMSG_LEARNED_SPELL` (already in the book), and the net apply re-requests the list to repaint the
|
|
//! bought row green→gray; a refusal (`SMSG_TRAINER_BUY_FAILED`) stages into [`TrainerErrors`] for the
|
|
//! feed to surface on the window's red error line.
|
|
//!
|
|
//! The standardized NPC-session range guard ([`crate::ui_session`]) client-side-closes the window
|
|
//! when the player walks out of the trainer's service range (or the trainer despawns) — the same
|
|
//! `CloseTrainer` clear.
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
use benilla_formats::{SkillLineCatalog, SpellCatalog};
|
|
use benilla_protocol::messages::TrainerSpell;
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_ui::script::{
|
|
ScriptValue, TrainerAbilityReq, TrainerService, TrainerServiceCategory, TrainerSkillReq,
|
|
TrainerState, UiScript,
|
|
};
|
|
|
|
use crate::entities::ItemDisplays;
|
|
use crate::items::Items;
|
|
use crate::names::NameCache;
|
|
use crate::net::{ClientCommand, NetCommands};
|
|
use crate::ui_action::{PlayerActions, Spells};
|
|
use crate::ui_script::UiInput;
|
|
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
|
|
use crate::ui_spellbook::SkillLines;
|
|
|
|
/// `SMSG_TRAINER_LIST`'s `trainer_type` for a tradeskill trainer (0 class · 1 mount · 2 tradeskill ·
|
|
/// 3 pet). Three separate laws fork on it here — the **icon** ([`service_icon`]), the **group key**
|
|
/// ([`service_group`]), and the Era `IsTrainerServiceTradeSkill` flag, which remains a whole-trainer
|
|
/// approximation (per-service typing from the spell's effects is a later refinement; the reference
|
|
/// window's Lua never calls it).
|
|
const TRAINER_TYPE_TRADESKILL: u32 = 2;
|
|
/// `SMSG_TRAINER_LIST`'s `trainer_type` for a mount trainer — the type the client's own vocabulary
|
|
/// calls "talent" (`IsTalentTrainer 0x4d8ed0`), and the one whose grouping folds already-known
|
|
/// services into a "My Talents" bucket ([`service_group`], decision 1124).
|
|
const TRAINER_TYPE_MOUNT: u32 = 1;
|
|
|
|
/// The open trainer, filled by the net bridge ([`crate::net`]) and read by [`feed_trainer`]. Holds
|
|
/// the trainer guid and its services exactly as the wire delivered them (`SMSG_TRAINER_LIST`), plus
|
|
/// the window-framing type and the greeting title. Cleared on a client-side close and on disconnect.
|
|
#[derive(Resource, Default)]
|
|
pub(crate) struct TrainerOpen {
|
|
/// The trainer whose window is open; `None` = no trainer open.
|
|
pub(crate) trainer: Option<u64>,
|
|
/// The wire services (order = 1-based display order).
|
|
pub(crate) services: Vec<TrainerSpell>,
|
|
/// The window-framing kind (0 class · 1 mount · 2 tradeskill · 3 pet).
|
|
pub(crate) trainer_type: u32,
|
|
/// The trainer's greeting line (`SMSG_TRAINER_LIST`'s trailing string).
|
|
pub(crate) greeting: String,
|
|
/// A `SMSG_TRAINER_LIST` that **begins a window session** has landed and the feed has not yet
|
|
/// handed it to the engine. Drives the engine's filter/collapse reset
|
|
/// ([`UiScript::reset_trainer_filter`]) — the reference's builder rewrites both masks on every
|
|
/// list packet (decision 1128). It is not the same edge as a snapshot change: those re-push the
|
|
/// same list.
|
|
///
|
|
/// "Begins a session", not "a packet arrived", because the two are the same thing in the
|
|
/// reference and are **not** in benilla (B253/B256's arc): the reference gets a list packet only
|
|
/// when a trainer opens — it repaints a purchase from a client-side state re-derivation
|
|
/// (`0x4d7d40`, decision 1128 §4.2) — while we ask for a fresh list after every buy. Letting our
|
|
/// own refresh carry the reference's per-packet reset made the state filter (and the collapse
|
|
/// set) evaporate on learning a spell, which is not something the reference can do. See
|
|
/// [`Self::refresh_pending`].
|
|
pub(crate) fresh_list: bool,
|
|
/// The post-buy re-list **we asked for** ([`crate::net::apply`]'s `trainer_buy_succeeded`) is in
|
|
/// flight: the answering packet repaints the open window rather than opening one, so it must not
|
|
/// reset the filter/collapse masks. Cleared by the packet it belongs to, and by any close.
|
|
pub(crate) refresh_pending: bool,
|
|
}
|
|
|
|
impl TrainerOpen {
|
|
/// Open (or replace) the window with a trainer's freshly-listed services.
|
|
pub(crate) fn open(
|
|
&mut self,
|
|
trainer: u64,
|
|
trainer_type: u32,
|
|
services: Vec<TrainerSpell>,
|
|
greeting: String,
|
|
) {
|
|
// A repaint of the trainer already on screen, asked for by our own post-buy re-request, is
|
|
// not a new window session — so it carries no mask reset (the field's own doc says why).
|
|
let refresh = self.trainer == Some(trainer) && std::mem::take(&mut self.refresh_pending);
|
|
self.trainer = Some(trainer);
|
|
self.trainer_type = trainer_type;
|
|
self.services = services;
|
|
self.greeting = greeting;
|
|
self.fresh_list = !refresh;
|
|
}
|
|
|
|
/// Close the open window (a client-side close). Keeps nothing — a re-open re-lists.
|
|
pub(crate) fn clear(&mut self) {
|
|
self.trainer = None;
|
|
self.services.clear();
|
|
self.trainer_type = 0;
|
|
self.greeting.clear();
|
|
self.fresh_list = false;
|
|
// An in-flight refresh dies with the window: the next list is a real open, and must reset.
|
|
self.refresh_pending = false;
|
|
}
|
|
|
|
/// Disconnect: drop the open window (mirrors the gossip/merchant session clears).
|
|
pub(crate) fn clear_session(&mut self) {
|
|
self.clear();
|
|
}
|
|
}
|
|
|
|
/// Trainer purchase refusals (`SMSG_TRAINER_BUY_FAILED`), staged by the net apply for the feed to
|
|
/// surface on the window's red error line — the merchant [`crate::ui_merchant::MerchantErrors`]
|
|
/// twin. Each entry is a [`benilla_protocol::messages::train_fail`] code.
|
|
#[derive(Resource, Default)]
|
|
pub(crate) struct TrainerErrors(pub Vec<u32>);
|
|
|
|
pub(crate) struct UiTrainerPlugin;
|
|
|
|
impl Plugin for UiTrainerPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<TrainerOpen>()
|
|
.init_resource::<TrainerErrors>()
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
// Range-close before the feed so the clear turns into TRAINER_CLOSED the same frame;
|
|
// push before the input pass so an open/close is on screen the same frame; drain
|
|
// after it (mirrors ui_merchant/ui_gossip).
|
|
close_npc_session_out_of_range::<TrainerOpen>.before(feed_trainer),
|
|
feed_trainer.before(UiInput),
|
|
drain_trainer.after(UiInput),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The red error-line text for a trainer refusal (`SMSG_TRAINER_BUY_FAILED`'s
|
|
/// [`benilla_protocol::messages::train_fail`] code). The money case is the exact enUS `GlobalString`
|
|
/// (`ERR_NOT_ENOUGH_MONEY`); the skill/unavailable cases are best-effort English — the real client
|
|
/// builds these in C++, so their exact wording is a wow-re confirm item (rarely reached: the Train
|
|
/// button is disabled unless the service is available and affordable).
|
|
fn train_error_text(code: u32) -> String {
|
|
use benilla_protocol::messages::train_fail;
|
|
match code {
|
|
train_fail::NOT_ENOUGH_MONEY => "You don't have enough money.",
|
|
train_fail::NOT_ENOUGH_SKILL => "You do not have the required skill.",
|
|
_ => "That trainer service is not available.",
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
/// Resolve one wire [`TrainerSpell`] into the Lua-facing [`TrainerService`]: name/subtext/icon from
|
|
/// the spell catalog (`None` only before `Spell.dbc` has loaded — the row shows a placeholder), the
|
|
/// skill-req name from the skill-line catalog (falling back to `Skill <id>` if it hasn't loaded),
|
|
/// the ability-req names + rank from the same spell catalog, the state/cost/gates straight off the
|
|
/// wire. `known` is the player's known-spell set ([`PlayerActions::spells`]) — each prerequisite
|
|
/// ability is coloured by whether the player already knows that specific spell (see below).
|
|
#[allow(clippy::too_many_arguments)] // the resolver's full catalog set
|
|
fn resolve_service(
|
|
wire: &TrainerSpell,
|
|
trainer_type: u32,
|
|
spells: &SpellCatalog,
|
|
skill_lines: Option<&SkillLineCatalog>,
|
|
known: &BTreeSet<u32>,
|
|
icons: Option<&ItemDisplays>,
|
|
items: &mut Items,
|
|
commands: &NetCommands,
|
|
) -> TrainerService {
|
|
// The trainer offers a LEARN wrapper (decision 0247); the ability it teaches is the taught
|
|
// spell, and the tree GROUPS by that hop (`0x4d7c60` → `[skillrec+4]`). It is the only thing
|
|
// that hops: the row's **displayed name and subtext are the WIRE spell's own** `Spell.dbc`
|
|
// columns — `GetTrainerServiceInfo` reads `row[+0]` for both returns (`0x4d8aa0` → `[+0x1e0]`,
|
|
// `0x4d8b50` → `[+0x204]`), with no `EffectTriggerSpell` deref anywhere in either body. 0247
|
|
// recorded the display as hopping too; decision 1124 refutes that at the bytes, and it is not a
|
|
// cosmetic difference: 1607 of the 4711 shipped learn wrappers (34.1 %) disagree with what they
|
|
// teach, and that set is *every profession-learn row* — spell 2020 is "Apprentice Blacksmith"
|
|
// with no subtext where the taught 2018 is "Blacksmithing"/"Apprentice". Pet rows agree on both
|
|
// columns, which is how the wrong hop survived this long.
|
|
// `wire.spell` (the wrapper) stays the buy id below (CMSG_TRAINER_BUY_SPELL names it); a spell
|
|
// that teaches nothing (a plain ability, or before Spell.dbc loads) resolves to itself.
|
|
let taught = spells.learned_spell(wire.spell).unwrap_or(wire.spell);
|
|
let display = spells.get(wire.spell);
|
|
let cat = category(wire.state);
|
|
// The SKILL gate has no per-gate "met" bit on the 5875 wire, so approximate it from the service's
|
|
// overall category (an unavailable service has some unmet gate). The real client computes the
|
|
// skill gate locally too — player skill value ≥ required, like the level gate the XML already
|
|
// checks with `UnitLevel` — so a faithful per-gate skill check waits only on threading the
|
|
// player's skill values here; the ability gate below already does its own per-gate check, so a
|
|
// skill-gated service is the one remaining coarse case (decision 0253).
|
|
let skill_met = cat != TrainerServiceCategory::Unavailable;
|
|
let skill_req = (wire.req_skill != 0).then(|| TrainerSkillReq {
|
|
name: skill_lines
|
|
.and_then(|c| c.line(wire.req_skill))
|
|
.map(|l| l.name.clone())
|
|
.unwrap_or_else(|| format!("Skill {}", wire.req_skill)),
|
|
rank: wire.req_skill_value,
|
|
met: skill_met,
|
|
});
|
|
// Each prerequisite ability is coloured by whether the player already KNOWS that specific spell —
|
|
// the byte-verified real-client mechanism (wow-re `system/ui/scratch/trainer-requirement.md`):
|
|
// `GetTrainerServiceAbilityReq`'s hasReq is `IsSpellKnown(reqSpellId)`, evaluated per-requirement
|
|
// and INDEPENDENT of the service's overall category — so a spell gated only by LEVEL still shows
|
|
// its already-learned prev-rank prerequisite WHITE, not red. The req id is a real ability id (not
|
|
// a learn wrapper — verified there too), so there's no hop: look it up directly. The name carries
|
|
// its rank exactly as the client does — `"Name (Rank)"` when the spell has a rank subtext, else
|
|
// the bare name (the client's `"%s (%s)"`). The client also ORs `KnownHigherRank`; benilla has no
|
|
// rank chain, and sequential trainer ranks never reach that clause, so the direct known-check
|
|
// covers every real case.
|
|
let ability_reqs = wire
|
|
.req_spells
|
|
.iter()
|
|
.filter(|&&s| s != 0)
|
|
.map(|&s| {
|
|
let name = match spells.get(s) {
|
|
Some(d) => match d.rank.as_deref() {
|
|
Some(rank) if !rank.is_empty() => format!("{} ({})", d.name, rank),
|
|
_ => d.name.clone(),
|
|
},
|
|
None => format!("Spell {s}"),
|
|
};
|
|
TrainerAbilityReq {
|
|
name,
|
|
met: known.contains(&s),
|
|
}
|
|
})
|
|
.collect();
|
|
// The tree's grouping key — its own byte-verified law, and per trainer type ([`service_group`],
|
|
// decision 1124). Only the skill-line arm (types 0/1/3) can fail: the wire wrapper id itself is
|
|
// never in `SkillLineAbility`, so that arm MUST go through the taught-spell hop above, and an
|
|
// unresolved `0` drops the service from the tree exactly as the client's builder does. Log the
|
|
// genuine miss (catalog present but no line) so DBC gaps surface rather than silently swallowing
|
|
// a service the server offered. The tradeskill arm resolves no line at all and cannot miss.
|
|
let (group_key, group_name) =
|
|
service_group(wire.spell, taught, trainer_type, cat, spells, skill_lines);
|
|
if group_key == 0 && skill_lines.is_some() {
|
|
debug!(
|
|
"ui_trainer: trainer spell {} (teaches {taught}) has no skill line — dropped from the tree",
|
|
wire.spell
|
|
);
|
|
}
|
|
TrainerService {
|
|
spell_id: wire.spell,
|
|
name: display.map(|d| d.name.clone()),
|
|
subtext: display.and_then(|d| d.rank.clone()),
|
|
// The icon is its own byte-verified law over the WIRE spell ([`service_icon`]) — as, since
|
|
// 1124, are the name and subtext above. Only the GROUP key still hops to the taught spell,
|
|
// and only because the wrapper is not in `SkillLineAbility` at all.
|
|
texture: service_icon(wire.spell, trainer_type, spells, icons, items, commands),
|
|
// The detail pane's description body, left empty by design. The real
|
|
// `GetTrainerServiceDescription` returns the spell's *tooltip* — `Spell.dbc`'s Description
|
|
// with its `$s1`/`$o1`/`$d`/`$a1` tokens substituted from the spell's effect base points,
|
|
// duration, and radius. Feeding the raw column would render the unsubstituted tokens
|
|
// (broken-looking, not faithful), so descriptions wait on a spell-tooltip token engine —
|
|
// its own arc, shared with the spellbook's hover tooltips — not a lone `SpellCatalog` column.
|
|
description: String::new(),
|
|
cost: wire.cost,
|
|
prof_first_rank: wire.is_primary_prof_first_rank,
|
|
category: cat,
|
|
level_req: u32::from(wire.req_level),
|
|
skill_req,
|
|
ability_reqs,
|
|
is_trade_skill: trainer_type == TRAINER_TYPE_TRADESKILL,
|
|
group_key,
|
|
group_name,
|
|
tooltip: service_tooltip(wire.spell, spells),
|
|
}
|
|
}
|
|
|
|
/// Build the Lua-facing snapshot from [`TrainerOpen`] + the spell/skill catalogs — `None` when no
|
|
/// trainer is open.
|
|
#[allow(clippy::too_many_arguments)] // the resolver's full catalog set
|
|
fn snapshot(
|
|
open: &TrainerOpen,
|
|
spells: &SpellCatalog,
|
|
skill_lines: Option<&SkillLineCatalog>,
|
|
known: &BTreeSet<u32>,
|
|
icons: Option<&ItemDisplays>,
|
|
items: &mut Items,
|
|
commands: &NetCommands,
|
|
) -> Option<TrainerState> {
|
|
open.trainer?;
|
|
Some(TrainerState {
|
|
greeting: open.greeting.clone(),
|
|
trainer_type: open.trainer_type,
|
|
services: open
|
|
.services
|
|
.iter()
|
|
.map(|w| {
|
|
resolve_service(
|
|
w,
|
|
open.trainer_type,
|
|
spells,
|
|
skill_lines,
|
|
known,
|
|
icons,
|
|
items,
|
|
commands,
|
|
)
|
|
})
|
|
.collect(),
|
|
// The engine synthesizes the tree in `set_trainer` — the app pushes only the flat services
|
|
// (each carrying its resolved group key above).
|
|
groups: Vec::new(),
|
|
})
|
|
}
|
|
|
|
/// Push the current trainer into the VM and fire the show/update/close events on a transition (or a
|
|
/// content change). Diffed against a `Local` memory, exactly like the gossip/merchant feeds. A
|
|
/// different trainer while the window is already open is a real close+open (the client's `ShowUIPanel`
|
|
/// early-returns when visible, so the open sound only re-plays after a hide — decision 0096).
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn feed_trainer(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
// ResMut only to consume the fresh-packet latch below — the feed never authors trainer content.
|
|
mut open: ResMut<TrainerOpen>,
|
|
actions: Res<PlayerActions>,
|
|
spells: Option<Res<Spells>>,
|
|
skill_lines: Option<Res<SkillLines>>,
|
|
// A tradeskill trainer's rows front the CREATED ITEM's icon, so the feed needs the ask-once
|
|
// template cache + `ItemDisplayInfo.dbc` — the tradeskill window's own pair ([`service_icon`]).
|
|
icons: Option<Res<ItemDisplays>>,
|
|
mut items: ResMut<Items>,
|
|
mut errors: ResMut<TrainerErrors>,
|
|
commands: Res<NetCommands>,
|
|
mut names: ResMut<NameCache>,
|
|
mut last: Local<crate::ui_script::VmMemo<Option<TrainerState>>>,
|
|
mut last_trainer: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
|
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
let last = last.get(&script);
|
|
let last_trainer = last_trainer.get(&script);
|
|
let last_name = last_name.get(&script);
|
|
// Refusals surface as the client's red error line (the merchant/equip/cast path's exact shape).
|
|
for code in errors.0.drain(..) {
|
|
script.fire_event(
|
|
"UI_ERROR_MESSAGE",
|
|
vec![ScriptValue::Str(train_error_text(code))],
|
|
);
|
|
}
|
|
// Nothing to resolve a name/icon from yet — try again once Spell.dbc lands.
|
|
let Some(spells) = spells.as_deref() else {
|
|
return;
|
|
};
|
|
// The tree groups by skill line (decision 0247), so the skill-line catalog is required, not
|
|
// optional: without it every service resolves to skill_line 0 and drops. A trainer only opens
|
|
// well after world-entry, by when both DBCs have loaded, so this gate never actually delays a
|
|
// real window — it just refuses to render an all-dropped empty tree.
|
|
let Some(skill_lines) = skill_lines.as_deref() else {
|
|
return;
|
|
};
|
|
// A new list packet resets the state filter and the collapse set in the engine, exactly as the
|
|
// reference's builder does (decision 1128) — before the snapshot goes in, so `TRAINER_SHOW`
|
|
// finds the reset mask and the window's own show handler pushes the SAVED filter back over it.
|
|
if open.fresh_list {
|
|
script.reset_trainer_filter(open.trainer_type);
|
|
open.fresh_list = false;
|
|
}
|
|
let fresh = snapshot(
|
|
&open,
|
|
&spells.catalog,
|
|
Some(&skill_lines.catalog),
|
|
&actions.spells,
|
|
icons.as_deref(),
|
|
&mut items,
|
|
&commands,
|
|
);
|
|
// The trainer's name resolves through the NameCache (a creature-name query, ask-once — the
|
|
// real client's `UnitName("npc")`). `None`/empty while in flight; the title shows the static
|
|
// "Trainer" until it lands, then re-fires TRAINER_UPDATE with the name as arg1 (the diff below
|
|
// tracks the name too, so a name-only change still repaints the title). It rides an event arg
|
|
// rather than a `TrainerState` field so no benilla-ui engine change is needed — the merchant
|
|
// vendor-name pattern.
|
|
let trainer_name = open
|
|
.trainer
|
|
.and_then(|g| names.resolve(g, &commands).map(str::to_string));
|
|
let name_changed = *last_name != trainer_name;
|
|
let switched = npc_switched(*last_trainer, open.trainer);
|
|
if fresh == *last && !name_changed && !switched {
|
|
return;
|
|
}
|
|
script.set_trainer(fresh.clone());
|
|
let name_arg = || vec![ScriptValue::Str(trainer_name.clone().unwrap_or_default())];
|
|
if switched {
|
|
// Close the old trainer, open the new: the frame hides then shows. The TRAINER_CLOSED routes
|
|
// through the window's OnHide → CloseTrainer, which queues a close intent — consume it here so
|
|
// the drain does NOT clear the trainer we just re-opened to (the merchant switch pattern).
|
|
script.fire_event("TRAINER_CLOSED", vec![]);
|
|
script.fire_event("TRAINER_SHOW", name_arg());
|
|
let _ = script.take_trainer_close();
|
|
} else {
|
|
match (&*last, &fresh) {
|
|
(None, Some(_)) => script.fire_event("TRAINER_SHOW", name_arg()),
|
|
(Some(_), Some(_)) => script.fire_event("TRAINER_UPDATE", name_arg()),
|
|
(Some(_), None) => script.fire_event("TRAINER_CLOSED", vec![]),
|
|
(None, None) => {}
|
|
}
|
|
}
|
|
*last = fresh;
|
|
*last_trainer = open.trainer;
|
|
*last_name = trainer_name;
|
|
}
|
|
|
|
/// The trainer window is an NPC session: the standardized range guard ([`crate::ui_session`])
|
|
/// client-side-closes it — the exact `CloseTrainer` clear — when the player walks out of the
|
|
/// trainer's service range or the trainer despawns.
|
|
impl NpcSession for TrainerOpen {
|
|
fn npc(&self) -> Option<u64> {
|
|
self.trainer
|
|
}
|
|
|
|
fn close(&mut self) {
|
|
self.clear();
|
|
}
|
|
}
|
|
|
|
/// Drain the Lua intents: the Train button's buys (`BuyTrainerService` queues the chosen row's spell
|
|
/// id) → `CMSG_TRAINER_BUY_SPELL` for the open trainer; a close → a local clear (no packet, vanilla).
|
|
/// The server answers a buy with `SMSG_TRAINER_BUY_SUCCEEDED` (→ the spell lands via
|
|
/// `SMSG_LEARNED_SPELL`, and the apply re-requests the list to repaint the row gray) or
|
|
/// `SMSG_TRAINER_BUY_FAILED` (→ the window's error line via [`TrainerErrors`]).
|
|
fn drain_trainer(
|
|
script: Option<NonSendMut<UiScript>>,
|
|
mut open: ResMut<TrainerOpen>,
|
|
commands: Res<NetCommands>,
|
|
) {
|
|
let Some(mut script) = script else {
|
|
return;
|
|
};
|
|
for spell_id in script.take_trainer_buys() {
|
|
let Some(trainer) = open.trainer else {
|
|
debug!(
|
|
"ui_trainer: BuyTrainerService(spell {spell_id}) with no open trainer — ignored"
|
|
);
|
|
continue;
|
|
};
|
|
debug!("ui_trainer: buy service (trainer {trainer:#x}, spell {spell_id})");
|
|
let _ = commands
|
|
.0
|
|
.send(ClientCommand::TrainerBuySpell { trainer, spell_id });
|
|
}
|
|
if script.take_trainer_close() {
|
|
debug!("ui_trainer: client-side close (no packet)");
|
|
open.clear();
|
|
}
|
|
}
|
|
|
|
mod law;
|
|
use law::{category, service_group, service_icon, service_tooltip};
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|