- the spellbook draws the cooldown pie on a spell that's cooling down, and the active form's slot lights its checked ring - shader variants warm at startup (with a tripwire if one ever compiles live), so a first cast no longer hitches on shader compile - additive glow stacks clamp the way the original scene does, taming Frost Nova's blown-out mist, and shoulder cards split only at rigidly separable bones - the login handshake now reads the same under both SRP encodings, and cooldown events no longer race the net step Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
702 lines
30 KiB
Rust
702 lines
30 KiB
Rust
//! The spellbook (decision 0216 §8, slice 5) — the spell **source** for the cursor payload
|
||
//! system: a read-only book model the app builds from `PlayerActions.spells`
|
||
//! (`SMSG_INITIAL_SPELLS`), the same two-way seam shape as [`super::merchant`]/[`super::action`]:
|
||
//! the app pushes a **book snapshot** ([`UiScript::set_spellbook`] — tabs + the flat slot list,
|
||
//! already resolved to name/rank/icon/passive by the app's `benilla_formats::SpellCatalog` ×
|
||
//! skill-line join), and `CastSpell`/`PickupSpell` queue outbound intents the app drains
|
||
//! ([`UiScript::take_spell_casts`] / the cursor seam's own `CursorPayload::Spell` arm — decision
|
||
//! 0216 §1). The engine holds no spell KNOWLEDGE (icons/ranks/skill lines are the app's job) — a
|
||
//! slot is "a spell id, a name, a rank, a texture, and a passive bit".
|
||
//!
|
||
//! ## The book-id seam (decision 0218 §4's byte-verified 0-based book slot)
|
||
//!
|
||
//! The ref's own FrameXML computes a **1-based, per-tab-cumulative "book id"**
|
||
//! (`SpellBookFrame.lua`'s `SpellBook_GetSpellID`: `buttonId + tabOffset + 12*(page-1)`, where
|
||
//! `buttonId` is a spell button's own 1..12 `id=` attribute and `tabOffset` is
|
||
//! `GetSpellTabInfo`'s own `offset` return) and passes that SAME id, unmodified, to every one of
|
||
//! `GetSpellName`/`GetSpellTexture`/`IsSpellPassive`/`CastSpell`/`PickupSpell`. 0218 §4 byte-read
|
||
//! `PickupSpell`'s own argument as a **0-based book slot** — so the real client's Lua↔C++ glue
|
||
//! does the `-1` itself, invisibly to FrameXML. This engine keeps the ref's exact Lua-facing
|
||
//! convention (every binding below takes the SAME 1-based-cumulative `id` a transcribed
|
||
//! `SpellBookFrame.xml` computes and passes verbatim, so the transcription needs no special
|
||
//! casing) and does the byte-verified `-1` at THIS one seam ([`slot_index`]) before indexing
|
||
//! [`SpellBookState::slots`] (0-based, flat, tab order). `GetSpellTabInfo`'s pushed `offset` is
|
||
//! therefore exactly each tab's 0-based START index into `slots` — the app computes it as the
|
||
//! running sum of every earlier tab's `num_spells` (tab 1's is `0`, so its first spell's book id
|
||
//! is `1`, matching the ref's own "first tab's first spell is id 1").
|
||
//!
|
||
//! ## Pet book (named deferral)
|
||
//!
|
||
//! `BOOKTYPE_PET` is anticipated (decision 0216 §8: "pet book deferred, no pets streamed yet")
|
||
//! but answers empty everywhere: `GetNumSpellTabs`/`GetSpellTabInfo` don't even take a `bookType`
|
||
//! (matching the ref's own signature — they always read whatever `SpellBookFrame.bookType`
|
||
//! selected, and this engine only ever HOLDS the spell book, so there is no separate pet-book
|
||
//! state to switch between), and every `bookType`-taking binding treats `"pet"` as an instant
|
||
//! empty/no-op via [`slot_index`]'s own gate.
|
||
//!
|
||
//! `BOOKTYPE_SPELL`/`BOOKTYPE_PET` are installed as plain Lua globals here rather than left to the
|
||
//! transcribed XML's own `<Script>` block (the ref's actual home for them, `SpellBookFrame.lua:
|
||
//! 5-6`, and this crate's usual house rule for Era top-level constants) — the one deliberate
|
||
//! exception, so this module's OWN engine-level tests can drive the pet-deferral path without
|
||
//! loading a real `SpellBookFrame.xml`.
|
||
|
||
use mlua::{Lua, MultiValue, Value};
|
||
|
||
use super::cursor::{queue_cursor_update, CursorPayload, CursorSpell};
|
||
use super::Model;
|
||
|
||
const BOOKTYPE_SPELL: &str = "spell";
|
||
const BOOKTYPE_PET: &str = "pet";
|
||
|
||
/// One skill-line tab (`GetSpellTabInfo`'s own Era tuple shape). `offset` is the tab's 0-based
|
||
/// START index into [`SpellBookState::slots`] (module docs' book-id seam) — pushed by the app,
|
||
/// trusted here (the engine holds no spell knowledge to derive it from itself).
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct SpellTabView {
|
||
pub name: String,
|
||
pub texture: Option<String>,
|
||
pub offset: u32,
|
||
pub num_spells: u32,
|
||
}
|
||
|
||
/// One spell in the flat book (0-based [`SpellBookState::slots`] index; module docs' book-id
|
||
/// seam). Every field is pre-resolved by the app — the engine draws whatever it's given.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct SpellSlotView {
|
||
pub spell_id: u32,
|
||
pub name: String,
|
||
/// The rank/subtext line (`Spell.dbc`'s `NameSubtext`, `benilla-formats`' own pin); `None`
|
||
/// shows no second line.
|
||
pub rank: Option<String>,
|
||
pub texture: Option<String>,
|
||
/// `SPELL_ATTR_PASSIVE` (`benilla-formats`' `SpellDisplay::passive`) — grays the name in the
|
||
/// transcribed XML and refuses both [`CastSpell`]-family casts (this module) and, faithfully,
|
||
/// nothing else: a passive can still be picked up/placed on a bar (the ref never blocks that).
|
||
pub passive: bool,
|
||
/// The `IsCurrentCast` verdict for this slot — the checked ring (`SpellButton_UpdateSelection`'s
|
||
/// gold glow). The delegate `0x4b3600` has exactly two arms (wow-re
|
||
/// `spellbook-checked-predicate.md`): a shapeshift spell whose form is the player's current
|
||
/// form byte, or the open trade-skill window's own spell — never an ordinary in-flight cast.
|
||
/// App-resolved (`benilla::ui_spellbook`), pushed with the book; the app fires
|
||
/// `CURRENT_SPELL_CAST_CHANGED` on its edges.
|
||
pub current: bool,
|
||
/// The spell's running cooldown as `(start_ms on the GetTime clock, duration_ms, enabled)` —
|
||
/// the same app-computed triple [`super::ActionState::cooldown`] and the container slots
|
||
/// carry, resolved by the ONE cooldown store (`benilla::cooldowns::Cooldowns::info` — id,
|
||
/// category and GCD reads alike); `GetSpellCooldown` answers the reference's
|
||
/// `(start, duration, enable)`. `None` = cold. Frame-stable per arm (the absolute start), so
|
||
/// a running cooldown never churns the book diff.
|
||
pub cooldown: Option<(i64, u32, bool)>,
|
||
}
|
||
|
||
/// The player's known-spell book: tabs (skill lines) + the flat slot list every tab indexes into
|
||
/// (module docs). Durable player state, not a session window (like [`super::action`]'s
|
||
/// `actions` map) — never `Option`; "no known spells yet" is simply empty vectors.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct SpellBookState {
|
||
pub tabs: Vec<SpellTabView>,
|
||
pub slots: Vec<SpellSlotView>,
|
||
}
|
||
|
||
impl super::UiScript {
|
||
/// Push the whole book snapshot (tabs + flat slots), replacing whatever was there. A bare
|
||
/// setter — firing `SPELLS_CHANGED` is the app's own diff-and-fire job (mirroring
|
||
/// `set_action`/`set_container`; never auto-fired here).
|
||
pub fn set_spellbook(&mut self, state: SpellBookState) {
|
||
self.model_mut().spellbook = state;
|
||
}
|
||
|
||
/// Drain the spell ids `CastSpell` queued since the last call.
|
||
pub fn take_spell_casts(&mut self) -> Vec<u32> {
|
||
std::mem::take(&mut self.model_mut().spell_casts)
|
||
}
|
||
|
||
/// Push whether the app's cast lifecycle holds something `SpellStopCasting()` can stop — a
|
||
/// running auto-repeat or an in-flight cast, but NOT a channel (the ref's `0x6e6e80` reads
|
||
/// only the auto-repeat key `0xceac30` and the inflight id `0xceca88`, and the inflight id
|
||
/// is already 0 during a channel — wow-re `esc-stopcasting.md`). Pushed each frame by the
|
||
/// app's cast feed (`benilla::ui_cast`), before the input pass runs the ESC chain.
|
||
pub fn set_casting(&mut self, casting: bool) {
|
||
self.model_mut().casting = casting;
|
||
}
|
||
|
||
/// Drain the `SpellStopCasting()` trigger: `true` if it fired on a stoppable state since
|
||
/// the last call — the ESC leg of the local self-cancel (`benilla::ui_cast` resolves WHICH
|
||
/// thing dies: auto-repeat first, else the in-flight cast — the ref's branch order).
|
||
pub fn take_spell_stop(&mut self) -> bool {
|
||
std::mem::take(&mut self.model_mut().spell_stop)
|
||
}
|
||
|
||
/// Push whether the app's spell-targeting cursor mode is active (decision 0792) — what
|
||
/// `SpellIsTargeting()` reads and `SpellStopTargeting()` gates on. Pushed each frame by the
|
||
/// app's targeting feed (`benilla::ui_action`), before the input pass runs the ESC chain.
|
||
pub fn set_spell_targeting(&mut self, targeting: bool) {
|
||
self.model_mut().spell_targeting = targeting;
|
||
}
|
||
|
||
/// Drain the `SpellStopTargeting()` trigger: `true` if it fired while targeting since the
|
||
/// last call — the ESC-chain rung (`UIParent.lua:1490`); the app clears its targeting mode.
|
||
pub fn take_stop_targeting(&mut self) -> bool {
|
||
std::mem::take(&mut self.model_mut().spell_stop_targeting)
|
||
}
|
||
}
|
||
|
||
/// The book-id → 0-based [`SpellBookState::slots`] index seam (module docs): `None` for a
|
||
/// `bookType` other than `"spell"` (the pet deferral) or an id of `0` (the ref's ids start at 1,
|
||
/// so `id - 1` would otherwise underflow).
|
||
fn slot_index(id: u32, book_type: &str) -> Option<usize> {
|
||
if book_type != BOOKTYPE_SPELL {
|
||
return None;
|
||
}
|
||
usize::try_from(id.checked_sub(1)?).ok()
|
||
}
|
||
|
||
/// `PickupSpell(id, bookType)` — the drag/shift-click entry point (ref `SpellButton_OnClick`'s
|
||
/// other two forks, `SpellBookFrame.lua:263-290`). The book is a SOURCE, never a placement
|
||
/// target — the ref's plain click always casts unconditionally, never checking `GetCursorInfo`
|
||
/// first (unlike `UseAction`'s `checkCursor` fork) — so this refuses outright when the cursor
|
||
/// already holds ANYTHING rather than silently discarding it: the doll's own refusal precedent
|
||
/// (`cursor::doll::pickup_inventory_item`) for a payload with nowhere faithful to go, since a
|
||
/// spell button is not a fit-checked drop target the way a doll slot or bar button is.
|
||
fn pickup_spell(model: &mut Model, id: u32, book_type: &str) -> bool {
|
||
if model.cursor.is_some() {
|
||
return false;
|
||
}
|
||
let Some(slot) = slot_index(id, book_type).and_then(|i| model.spellbook.slots.get(i)) else {
|
||
return false;
|
||
};
|
||
let payload = CursorSpell {
|
||
book_slot: id,
|
||
book_type: book_type.to_string(),
|
||
spell_id: slot.spell_id,
|
||
texture: slot.texture.clone(),
|
||
passive: slot.passive,
|
||
};
|
||
model.cursor = Some(CursorPayload::Spell(payload));
|
||
queue_cursor_update(model);
|
||
true
|
||
}
|
||
|
||
/// Register the spellbook globals.
|
||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||
let g = lua.globals();
|
||
|
||
g.set("BOOKTYPE_SPELL", BOOKTYPE_SPELL)?;
|
||
g.set("BOOKTYPE_PET", BOOKTYPE_PET)?;
|
||
|
||
g.set(
|
||
"GetNumSpellTabs",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(model.spellbook.tabs.len() as i64)
|
||
})?,
|
||
)?;
|
||
|
||
// GetSpellTabInfo(i) -> name, texture, offset, numSpells (the Era flat tuple); 1-based `i`,
|
||
// out of range -> a single nil (GetMerchantItemInfo's own out-of-range shape).
|
||
g.set(
|
||
"GetSpellTabInfo",
|
||
lua.create_function(|lua, i: usize| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let Some(tab) = i.checked_sub(1).and_then(|n| model.spellbook.tabs.get(n)) else {
|
||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||
};
|
||
let texture = match &tab.texture {
|
||
Some(t) => Value::String(lua.create_string(t)?),
|
||
None => Value::Nil,
|
||
};
|
||
Ok(MultiValue::from_vec(vec![
|
||
Value::String(lua.create_string(&tab.name)?),
|
||
texture,
|
||
Value::Integer(i64::from(tab.offset)),
|
||
Value::Integer(i64::from(tab.num_spells)),
|
||
]))
|
||
})?,
|
||
)?;
|
||
|
||
// GetSpellName(id, bookType) -> name, rank (the Era tuple, `subSpellName` in the ref); pet or
|
||
// out-of-range -> a single nil.
|
||
g.set(
|
||
"GetSpellName",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let Some(slot) = slot_index(id, &book_type).and_then(|i| model.spellbook.slots.get(i))
|
||
else {
|
||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||
};
|
||
let rank = match &slot.rank {
|
||
Some(r) => Value::String(lua.create_string(r)?),
|
||
None => Value::Nil,
|
||
};
|
||
Ok(MultiValue::from_vec(vec![
|
||
Value::String(lua.create_string(&slot.name)?),
|
||
rank,
|
||
]))
|
||
})?,
|
||
)?;
|
||
|
||
g.set(
|
||
"GetSpellTexture",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let tex = slot_index(id, &book_type)
|
||
.and_then(|i| model.spellbook.slots.get(i))
|
||
.and_then(|s| s.texture.clone());
|
||
match tex {
|
||
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
|
||
None => Ok(Value::Nil),
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
// IsCurrentCast(id, bookType) — the spellbook button's checked ring (binding `0x4b4370` →
|
||
// delegate `0x4b3600`; ref
|
||
// `SpellButton_UpdateSelection` SetChecks on it). The verdict itself is app-resolved per slot
|
||
// ([`SpellSlotView::current`]); this only reads it back.
|
||
g.set(
|
||
"IsCurrentCast",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let current = slot_index(id, &book_type)
|
||
.and_then(|i| model.spellbook.slots.get(i))
|
||
.is_some_and(|s| s.current);
|
||
// The ref's binding convention: 1 or nil, never false.
|
||
match current {
|
||
true => Ok(Value::Integer(1)),
|
||
false => Ok(Value::Nil),
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
// GetSpellCooldown(id, bookType) → start, duration, enable — the book twin of
|
||
// `GetActionCooldown`/`GetContainerItemCooldown`, identical conventions: `GetTime`-clock
|
||
// `(seconds, seconds, 0/1)`, enable 0 = an on-hold record (parked, full duration), and the
|
||
// cold-at-expiry guard so an event-driven re-feed can never replay the finish flash. Pet or
|
||
// out-of-range answers the cold `(0, 0, 1)` — the ref's own no-cooldown shape.
|
||
g.set(
|
||
"GetSpellCooldown",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let cooldown = slot_index(id, &book_type)
|
||
.and_then(|i| model.spellbook.slots.get(i))
|
||
.and_then(|s| s.cooldown);
|
||
Ok(match cooldown {
|
||
Some((start_ms, duration_ms, enabled)) => {
|
||
let (start, duration) =
|
||
(start_ms as f64 / 1000.0, f64::from(duration_ms) / 1000.0);
|
||
if start + duration > now || !enabled {
|
||
(start, duration, i32::from(enabled))
|
||
} else {
|
||
(0.0, 0.0, 1)
|
||
}
|
||
}
|
||
None => (0.0, 0.0, 1),
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
g.set(
|
||
"IsSpellPassive",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(slot_index(id, &book_type)
|
||
.and_then(|i| model.spellbook.slots.get(i))
|
||
.is_some_and(|s| s.passive))
|
||
})?,
|
||
)?;
|
||
|
||
// CastSpell(id, bookType) — the plain click (ref SpellButton_OnClick's `else` branch): queues
|
||
// the resolved spell id UNLESS the slot is passive (module doc: a passive is permanent player
|
||
// state, never something the player casts) or bookType/id resolve to nothing.
|
||
g.set(
|
||
"CastSpell",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
if let Some(slot) =
|
||
slot_index(id, &book_type).and_then(|i| model.spellbook.slots.get(i))
|
||
{
|
||
if !slot.passive {
|
||
let spell_id = slot.spell_id;
|
||
model.spell_casts.push(spell_id);
|
||
}
|
||
}
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
g.set(
|
||
"PickupSpell",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
Ok(pickup_spell(&mut model, id, &book_type))
|
||
})?,
|
||
)?;
|
||
|
||
// SpellStopCasting() — the ref's Script::SpellStopCasting (`0x6e6e80`, §5-verified whole,
|
||
// wow-re `esc-stopcasting.md`): stop the FIRST of {running auto-repeat (`0x6ea080`,
|
||
// CMSG_CANCEL_AUTO_REPEAT_SPELL), in-flight cast (`AbortCast` → CMSG_CANCEL_CAST)} and
|
||
// return 1; nil when neither runs. A CHANNEL is nil — the body's whole callee closure
|
||
// never reaches the channel canceler `0x6e9b70`, and the inflight id `0xceca88` it gates
|
||
// on is already 0 mid-channel (the launch CAST_RESULT(OKAY) clears it at `0x6e7408`) —
|
||
// the vanilla "/stopcasting can't stop a channel" quirk, kept faithfully. The falsy leg is
|
||
// load-bearing ground truth from the artifact: `ToggleGameMenu`'s ESC chain (extracted
|
||
// `UIParent.lua:1489`, `elseif ( SpellStopCasting() ) then`) only reaches
|
||
// `CloseAllWindows()`/the game menu through nil, so an unconditional true would eat every
|
||
// ESC press forever. The host feeds the stoppable mirror (`set_casting`) and resolves the
|
||
// branch order at the drain (`benilla::ui_cast::local_self_cancel`).
|
||
g.set(
|
||
"SpellStopCasting",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
if model.casting {
|
||
model.spell_stop = true;
|
||
Ok(Value::Integer(1))
|
||
} else {
|
||
Ok(Value::Nil)
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
// SpellIsTargeting() — the ref's Script::SpellIsTargeting (`0x6e6cd0`, wow-re
|
||
// `wave-cast.md`): true while the targeting cursor is up (`flag_word != 0`), nil otherwise.
|
||
// Read by FrameXML (PetFrame's right-click bind fork) and by the ESC chain's callers.
|
||
g.set(
|
||
"SpellIsTargeting",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
if model.spell_targeting {
|
||
Ok(Value::Boolean(true))
|
||
} else {
|
||
Ok(Value::Nil)
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
// SpellStopTargeting() — the ref's Script::SpellStopTargeting (`0x6e6e30`: if IsTargeting →
|
||
// StopTargeting `0x6e4900` → AbortCast(0x1c), which in targeting mode just clears the word,
|
||
// no packet). The 1/nil return is load-bearing exactly like SpellStopCasting's above: the
|
||
// ESC chain's rung (`UIParent.lua:1490`, `elseif ( SpellStopTargeting() ) then`) must fall
|
||
// through to the game menu when nothing was targeting. The host drains the trigger
|
||
// (`benilla::ui_action::targeting`) and clears its mode.
|
||
g.set(
|
||
"SpellStopTargeting",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
if model.spell_targeting {
|
||
model.spell_stop_targeting = true;
|
||
Ok(Value::Integer(1))
|
||
} else {
|
||
Ok(Value::Nil)
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{SpellBookState, SpellSlotView, SpellTabView};
|
||
use crate::script::cursor::{CursorAction, CursorPayload};
|
||
use crate::script::UiScript;
|
||
|
||
/// Two tabs: "Fire" (2 spells: Fireball rank1 active, Fire Blast PASSIVE — an artificial
|
||
/// fixture just to exercise the gray/refuse gate) and "Frost" (1 spell).
|
||
fn book() -> SpellBookState {
|
||
SpellBookState {
|
||
tabs: vec![
|
||
SpellTabView {
|
||
name: "Fire".into(),
|
||
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
|
||
offset: 0,
|
||
num_spells: 2,
|
||
},
|
||
SpellTabView {
|
||
name: "Frost".into(),
|
||
texture: Some("Interface\\Icons\\Spell_Frost_FrostBolt02".into()),
|
||
offset: 2,
|
||
num_spells: 1,
|
||
},
|
||
],
|
||
slots: vec![
|
||
SpellSlotView {
|
||
spell_id: 133,
|
||
name: "Fireball".into(),
|
||
rank: Some("Rank 1".into()),
|
||
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
|
||
passive: false,
|
||
current: false,
|
||
cooldown: None,
|
||
},
|
||
SpellSlotView {
|
||
spell_id: 2136,
|
||
name: "Fire Blast".into(),
|
||
rank: Some("Rank 1".into()),
|
||
texture: Some("Interface\\Icons\\Spell_Fire_FireBolt02".into()),
|
||
passive: true, // artificial: exercises the refusal gate
|
||
current: false,
|
||
cooldown: None,
|
||
},
|
||
SpellSlotView {
|
||
spell_id: 168,
|
||
name: "Frost Armor".into(),
|
||
rank: Some("Rank 1".into()),
|
||
texture: Some("Interface\\Icons\\Spell_Frost_FrostArmor02".into()),
|
||
passive: false,
|
||
current: false,
|
||
cooldown: None,
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
/// `IsCurrentCast` reads the app-resolved per-slot verdict back as the ref's 1-or-nil.
|
||
#[test]
|
||
fn is_current_cast_reads_the_slot_verdict() {
|
||
let mut s = UiScript::new().unwrap();
|
||
assert!(s
|
||
.eval::<bool>("return IsCurrentCast(1, BOOKTYPE_SPELL) == nil")
|
||
.unwrap());
|
||
let mut b = book();
|
||
b.slots[0].current = true;
|
||
s.set_spellbook(b);
|
||
assert_eq!(
|
||
s.eval::<i64>("return IsCurrentCast(1, BOOKTYPE_SPELL)")
|
||
.unwrap(),
|
||
1
|
||
);
|
||
assert!(s
|
||
.eval::<bool>("return IsCurrentCast(2, BOOKTYPE_SPELL) == nil")
|
||
.unwrap());
|
||
assert!(s
|
||
.eval::<bool>("return IsCurrentCast(1, BOOKTYPE_PET) == nil")
|
||
.unwrap());
|
||
assert!(s
|
||
.eval::<bool>("return IsCurrentCast(99, BOOKTYPE_SPELL) == nil")
|
||
.unwrap());
|
||
}
|
||
|
||
/// `GetSpellCooldown` reads the app-pushed per-slot triple back as the ref's GetTime-clock
|
||
/// `(start, duration, enable)` — cold `(0, 0, 1)` for absent/pet/out-of-range, enable 0 for
|
||
/// an on-hold record regardless of expiry, and the cold-at-expiry guard once
|
||
/// `start + duration` passes (`GetActionCooldown`'s own conventions, the book twin).
|
||
#[test]
|
||
fn get_spell_cooldown_reads_the_slot_triple() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_spellbook(book());
|
||
s.tick(20.0); // GetTime = 20
|
||
|
||
// Cold slot: the ref's no-cooldown shape.
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(1, BOOKTYPE_SPELL)")
|
||
.unwrap(),
|
||
(0.0, 0.0, 1)
|
||
);
|
||
|
||
let mut b = book();
|
||
b.slots[0].cooldown = Some((14_000, 10_000, true)); // running: 4 s elapsed of 10
|
||
b.slots[1].cooldown = Some((2_000, 8_000, false)); // on hold: parked since t=2
|
||
b.slots[2].cooldown = Some((5_000, 10_000, true)); // elapsed at t=15 — cold
|
||
s.set_spellbook(b);
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(1, BOOKTYPE_SPELL)")
|
||
.unwrap(),
|
||
(14.0, 10.0, 1)
|
||
);
|
||
// On hold survives the expiry guard (enable 0 = the parked "hasn't begun").
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(2, BOOKTYPE_SPELL)")
|
||
.unwrap(),
|
||
(2.0, 8.0, 0)
|
||
);
|
||
// Elapsed goes cold — an event-driven re-feed can never replay the finish flash.
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(3, BOOKTYPE_SPELL)")
|
||
.unwrap(),
|
||
(0.0, 0.0, 1)
|
||
);
|
||
// The pet deferral and out-of-range answer cold too.
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(1, BOOKTYPE_PET)")
|
||
.unwrap(),
|
||
(0.0, 0.0, 1)
|
||
);
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(99, BOOKTYPE_SPELL)")
|
||
.unwrap(),
|
||
(0.0, 0.0, 1)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tab_info_shapes_and_book_id_offsets() {
|
||
let mut s = UiScript::new().unwrap();
|
||
assert_eq!(s.eval::<i64>("return GetNumSpellTabs()").unwrap(), 0);
|
||
assert!(s.eval::<bool>("return GetSpellTabInfo(1) == nil").unwrap());
|
||
|
||
s.set_spellbook(book());
|
||
assert_eq!(s.eval::<i64>("return GetNumSpellTabs()").unwrap(), 2);
|
||
|
||
let (name, texture, offset, num) = s
|
||
.eval::<(String, String, i64, i64)>("return GetSpellTabInfo(1)")
|
||
.unwrap();
|
||
assert_eq!(
|
||
(name.as_str(), texture.as_str(), offset, num),
|
||
("Fire", "Interface\\Icons\\Spell_Fire_FlameBolt", 0, 2)
|
||
);
|
||
let (name2, _tex2, offset2, num2) = s
|
||
.eval::<(String, String, i64, i64)>("return GetSpellTabInfo(2)")
|
||
.unwrap();
|
||
assert_eq!((name2.as_str(), offset2, num2), ("Frost", 2, 1));
|
||
|
||
// Out of range -> nil.
|
||
assert!(s.eval::<bool>("return GetSpellTabInfo(3) == nil").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn name_and_rank_read_through_the_book_id_seam() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_spellbook(book());
|
||
|
||
// Book id 1 (tab 1 offset 0 + button 1) -> slot 0 -> Fireball.
|
||
let (name, rank) = s
|
||
.eval::<(String, String)>(r#"return GetSpellName(1, BOOKTYPE_SPELL)"#)
|
||
.unwrap();
|
||
assert_eq!((name.as_str(), rank.as_str()), ("Fireball", "Rank 1"));
|
||
assert_eq!(
|
||
s.eval::<String>(r#"return GetSpellTexture(1, BOOKTYPE_SPELL)"#)
|
||
.unwrap(),
|
||
"Interface\\Icons\\Spell_Fire_FlameBolt"
|
||
);
|
||
|
||
// Book id 3 (tab 2 offset 2 + button 1) -> slot 2 -> Frost Armor.
|
||
let (name3, _rank3) = s
|
||
.eval::<(String, String)>(r#"return GetSpellName(3, BOOKTYPE_SPELL)"#)
|
||
.unwrap();
|
||
assert_eq!(name3, "Frost Armor");
|
||
|
||
// Out of range and the pet deferral both answer nil.
|
||
assert!(s
|
||
.eval::<bool>(r#"return GetSpellName(99, BOOKTYPE_SPELL) == nil"#)
|
||
.unwrap());
|
||
assert!(s
|
||
.eval::<bool>(r#"return GetSpellName(1, BOOKTYPE_PET) == nil"#)
|
||
.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn pickup_spell_payload_and_cursor_update() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_spellbook(book());
|
||
|
||
s.run(r#"picked = PickupSpell(1, BOOKTYPE_SPELL)"#).unwrap();
|
||
assert!(s.eval::<bool>("return picked").unwrap());
|
||
assert!(s.cursor_payload().is_some());
|
||
let (kind, book_id, book, spell_id) = s
|
||
.eval::<(String, i64, String, i64)>(
|
||
"local k, slot, book, id = GetCursorInfo() return k, slot, book, id",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
(kind.as_str(), book_id, book.as_str(), spell_id),
|
||
("spell", 1, "spell", 133)
|
||
);
|
||
|
||
// CURSOR_UPDATE fired (the shared cursor seam, not duplicated here) — a listener sees it.
|
||
// Tick first to flush the FIRST pickup's already-queued CURSOR_UPDATE before the listener
|
||
// registers, so the count below is purely about the second (refused) call.
|
||
s.tick(0.0);
|
||
s.run(
|
||
r#"
|
||
cursorUpdates = 0
|
||
local f = CreateFrame("Frame", "CursorListener")
|
||
f:RegisterEvent("CURSOR_UPDATE")
|
||
f:SetScript("OnEvent", function() cursorUpdates = cursorUpdates + 1 end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.run(r#"PickupSpell(3, BOOKTYPE_SPELL)"#).unwrap(); // already holding -> refused, no-op
|
||
s.tick(0.01);
|
||
assert_eq!(
|
||
s.eval::<i64>("return cursorUpdates").unwrap(),
|
||
0,
|
||
"refused pickup fires no CURSOR_UPDATE"
|
||
);
|
||
// Still holding spell 133 (book slot 1) from the first pickup — a refusal never clobbers
|
||
// it (GetCursorInfo's Spell arm: kind, book_slot, book_type, spell_id).
|
||
assert_eq!(
|
||
s.eval::<(String, i64, String, i64)>(
|
||
"local k, slot, book, id = GetCursorInfo() return k, slot, book, id"
|
||
)
|
||
.unwrap(),
|
||
("spell".to_string(), 1, "spell".to_string(), 133)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn pickup_spell_refuses_while_already_holding_any_payload() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_spellbook(book());
|
||
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
|
||
src_slot: 1,
|
||
kind: 0,
|
||
action: 111,
|
||
texture: None,
|
||
}));
|
||
|
||
assert!(!s
|
||
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_SPELL)"#)
|
||
.unwrap());
|
||
// The original (action) payload survives untouched.
|
||
assert_eq!(
|
||
s.eval::<String>("local k = GetCursorInfo() return k")
|
||
.unwrap(),
|
||
"action"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn passive_refuses_the_cast_but_active_queues_it() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_spellbook(book());
|
||
|
||
s.run(r#"CastSpell(1, BOOKTYPE_SPELL)"#).unwrap(); // Fireball: active
|
||
assert_eq!(s.take_spell_casts(), vec![133]);
|
||
|
||
s.run(r#"CastSpell(2, BOOKTYPE_SPELL)"#).unwrap(); // Fire Blast: passive, refused
|
||
assert!(s.take_spell_casts().is_empty());
|
||
|
||
assert!(s
|
||
.eval::<bool>(r#"return IsSpellPassive(2, BOOKTYPE_SPELL)"#)
|
||
.unwrap());
|
||
assert!(!s
|
||
.eval::<bool>(r#"return IsSpellPassive(1, BOOKTYPE_SPELL)"#)
|
||
.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn pet_book_answers_empty_everywhere() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_spellbook(book());
|
||
|
||
assert!(s
|
||
.eval::<bool>(r#"return GetSpellName(1, BOOKTYPE_PET) == nil"#)
|
||
.unwrap());
|
||
assert!(s
|
||
.eval::<bool>(r#"return GetSpellTexture(1, BOOKTYPE_PET) == nil"#)
|
||
.unwrap());
|
||
assert!(!s
|
||
.eval::<bool>(r#"return IsSpellPassive(1, BOOKTYPE_PET)"#)
|
||
.unwrap());
|
||
|
||
s.run(r#"CastSpell(1, BOOKTYPE_PET)"#).unwrap();
|
||
assert!(s.take_spell_casts().is_empty(), "pet cast is a no-op");
|
||
|
||
assert!(!s
|
||
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_PET)"#)
|
||
.unwrap());
|
||
assert!(s.cursor_payload().is_none(), "pet pickup is a no-op");
|
||
}
|
||
}
|