ui+app: nampower's bag group, aura cancels, no-queue casts, unit-event CVars
Second tranche. Eight more verbs and six CVar rows, all from state this client already owns. The bag group — `FindPlayerItemSlot(idOrName)`, `GetBagItem(bag, slot)`, `GetItemIdCooldown(itemId)`, `UseItemIdOrName(idOrName)` — shares one lookup, deliberately: the three that search must agree about *which* of two identical stacks they mean, and they answer the first, reading bags and slots ascending, as the reference's own auto-store search walks (`0x4f8030`) and as a player reads their bags. A name matches through the item cache, case-insensitively, since a bag slot carries no name of its own. `GetItemIdCooldown` answers the stock `0, 0, 1` for a cold or uncarried item rather than nil, because the consumer does arithmetic on the triple. `UseItemIdOrName` calls `UseContainerItem` itself rather than reaching past it, so the cursor-clear and repair-mode rules the stock verb applies still happen. `CancelPlayerAuraSpellId(spellId, [ignoreMissing])` and `CancelPlayerAuraSlot(auraSlot)`: the two keys into the `CMSG_CANCEL_AURA` queue `CancelPlayerBuff` already fills, so the three cannot disagree. The slot is the **raw 0-based** descriptor slot upstream documents and the buff events report, not the Lua index. `ignoreMissing` is accepted and changes nothing — it asks upstream not to complain, and nothing here would have complained. `CastSpellNoQueue(spellId, spellBook [, unit])` and `CastSpellByNameNoQueue(name [, unit])`: the plain cast, which is the honest mapping and not a stub — benilla has no spell queue to bypass, so "cast this now, do not queue it" is exactly what happens. The point of them is the first argument: a **spell id** where stock `CastSpell` takes a book slot. `"mouseover"` routes to the queue SuperWoW's `CastSpellByName(name, "CLICK")` already uses. The six `NP_EnableUnitEventsX` switches are registered stored-only, one step further back than the four `NP_Enable*` rows beside them: benilla raises no GUID-keyed unit event at all, so there is no stream for a token filter to narrow. Registering them stops the unknown-CVar warning and lets `SuperCleveRoidMacros`'s writes persist; it claims nothing about the events arriving, and `cvars.rs`'s own reader gate carries that reason. All nine gates green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -1281,6 +1281,49 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
"nampower v2 addon-compat, STORED ONLY: benilla fires no `SPELL_START_SELF`/`_OTHER` — \
|
||||
their shape wants itemId/spellType/corpseOwnerGuid columns this client does not carry",
|
||||
),
|
||||
// The six `NP_EnableUnitEventsX` switches (nampower v2.33+): which unit tokens fire the
|
||||
// GUID-keyed unit events, so an addon can cut the stream to the units it watches.
|
||||
// `SuperCleveRoidMacros`'s `NampowerAPI.lua` writes all six at setup.
|
||||
//
|
||||
// STORED ONLY, and the reason is one step further back than the other `NP_Enable*` rows:
|
||||
// benilla fires no GUID-keyed unit event at all — no `UNIT_HEALTH_GUID`, `UNIT_MANA_GUID`,
|
||||
// `UNIT_RAGE_GUID`, `UNIT_ENERGY_GUID` or `UNIT_COMBAT_GUID` — so there is no stream for a
|
||||
// filter to narrow. Registering them is what stops the engine's unknown-CVar warning and lets
|
||||
// the addon's writes persist; it is not a claim that the events arrive. Defaults are
|
||||
// upstream's own documented `0`.
|
||||
ours(
|
||||
"NP_EnableUnitEventsGuid",
|
||||
"0",
|
||||
"nampower v2 addon-compat, STORED ONLY: benilla fires no GUID-keyed unit event, so there \
|
||||
is no stream for this switch to open",
|
||||
),
|
||||
ours(
|
||||
"NP_EnableUnitEventsGuidFiltering",
|
||||
"0",
|
||||
"nampower v2 addon-compat, STORED ONLY: the filter half of `NP_EnableUnitEventsGuid`, and \
|
||||
there is nothing to filter",
|
||||
),
|
||||
ours(
|
||||
"NP_EnableUnitEventsMouseover",
|
||||
"0",
|
||||
"nampower v2 addon-compat, STORED ONLY: which tokens fire the GUID-keyed unit events, \
|
||||
none of which this client raises",
|
||||
),
|
||||
ours(
|
||||
"NP_EnableUnitEventsParty",
|
||||
"0",
|
||||
"nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`",
|
||||
),
|
||||
ours(
|
||||
"NP_EnableUnitEventsPet",
|
||||
"0",
|
||||
"nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`",
|
||||
),
|
||||
ours(
|
||||
"NP_EnableUnitEventsRaid",
|
||||
"0",
|
||||
"nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`",
|
||||
),
|
||||
ours(
|
||||
"NP_EnableSpellGoEvents",
|
||||
"0",
|
||||
@@ -4898,6 +4941,34 @@ mod tests {
|
||||
"NP_EnableSpellHealEvents",
|
||||
"pfUI libs/libdebuff.lua writes it at PLAYER_LOGIN; no SPELL_HEAL_* here",
|
||||
),
|
||||
// The six unit-event switches: `SuperCleveRoidMacros`'s `NampowerAPI.lua` writes all
|
||||
// six at setup. One step further back than the four above — there is no GUID-keyed
|
||||
// unit event here for a token filter to narrow, so nothing could read them even in
|
||||
// principle until that family exists.
|
||||
(
|
||||
"NP_EnableUnitEventsGuid",
|
||||
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
|
||||
),
|
||||
(
|
||||
"NP_EnableUnitEventsGuidFiltering",
|
||||
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
|
||||
),
|
||||
(
|
||||
"NP_EnableUnitEventsMouseover",
|
||||
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
|
||||
),
|
||||
(
|
||||
"NP_EnableUnitEventsParty",
|
||||
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
|
||||
),
|
||||
(
|
||||
"NP_EnableUnitEventsPet",
|
||||
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
|
||||
),
|
||||
(
|
||||
"NP_EnableUnitEventsRaid",
|
||||
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
|
||||
),
|
||||
// SuperWoW-era plate cast bar: pfUI's nameplates module writes 0 at load. benilla's
|
||||
// V-key plates draw no cast bar, so nothing here reads it — see the row's own note.
|
||||
(
|
||||
|
||||
@@ -177,6 +177,52 @@ fn equipped_table(lua: &Lua, view: &crate::script::InvSlotView) -> mlua::Result<
|
||||
/// expect — BetterCharacterPanel wraps exactly these three getters in `pcall`
|
||||
/// (`BetterCharacterPanel-lib.lua:177`, `:198`, `:252`) while calling `GetEquippedItem` bare, which
|
||||
/// is only worth doing for a function that can raise.
|
||||
/// The first bag slot holding `ident` — a number is an item id, a string a case-insensitive name
|
||||
/// match — ascending bag then ascending slot, which is the order the reference's own auto-store
|
||||
/// search walks (`0x4f8030`) and the order a player reads their bags in. Backs
|
||||
/// `FindPlayerItemSlot`, `GetItemIdCooldown` and `UseItemIdOrName`, which must all agree about
|
||||
/// *which* of two identical stacks they mean.
|
||||
fn find_bag_slot(model: &crate::script::Model, ident: Option<&Value>) -> Option<(i64, u32)> {
|
||||
let wanted_id = match ident {
|
||||
Some(Value::Integer(n)) => u32::try_from(*n).ok(),
|
||||
Some(Value::Number(n)) => (*n >= 0.0).then_some(*n as u32),
|
||||
_ => None,
|
||||
};
|
||||
let wanted_name = match ident {
|
||||
Some(Value::String(s)) => Some(s.to_str().ok()?.to_lowercase()),
|
||||
_ => None,
|
||||
};
|
||||
if wanted_id.is_none_or(|id| id == 0) && wanted_name.is_none() {
|
||||
return None;
|
||||
}
|
||||
// 0 is the backpack, 1..=4 the equipped bags: `GetContainerItemInfo`'s own numbering.
|
||||
for bag in 0i64..=4 {
|
||||
let container = model.containers.get(&bag)?;
|
||||
let mut slots: Vec<u32> = container.slots.keys().copied().collect();
|
||||
slots.sort_unstable();
|
||||
for slot in slots {
|
||||
let Some(s) = container.slots.get(&slot).filter(|s| s.item_id != 0) else {
|
||||
continue;
|
||||
};
|
||||
let hit = match (&wanted_id, &wanted_name) {
|
||||
(Some(id), _) => s.item_id == *id,
|
||||
// The slot carries no name of its own; the cache row it points at does. An
|
||||
// uncached item cannot be matched by name, which is the same limit every
|
||||
// name-taking verb here has.
|
||||
(_, Some(name)) => model
|
||||
.item_templates
|
||||
.get(&s.item_id)
|
||||
.is_some_and(|t| t.name.to_lowercase() == *name),
|
||||
_ => false,
|
||||
};
|
||||
if hit {
|
||||
return Some((bag, slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `INVSLOT_TRINKET1`/`INVSLOT_TRINKET2`, the two slots `GetTrinkets` reports.
|
||||
const TRINKET_SLOTS: [usize; 2] = [13, 14];
|
||||
|
||||
@@ -738,6 +784,206 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `CancelPlayerAuraSpellId(spellId, [ignoreMissing])` and `CancelPlayerAuraSlot(auraSlot)`
|
||||
// (v2.35+): the two ways to drop one of the player's own auras without first finding its
|
||||
// 1-based buff index, which is what stock `CancelPlayerBuff` demands and what makes it awkward
|
||||
// from a macro. Both queue the spell id onto the same `CMSG_CANCEL_AURA` queue
|
||||
// `CancelPlayerBuff` and `CancelUnitBuff` use, so the three cannot disagree.
|
||||
//
|
||||
// `auraSlot` is the **raw 0-based** descriptor slot, as upstream states and as the buff and
|
||||
// debuff events report it — not the Lua index. `AuraState::slot` is that number.
|
||||
//
|
||||
// No return value on any path, as `CancelPlayerBuff` has none, and a missing aura is a silent
|
||||
// no-op whatever `ignoreMissing` says: the flag asks upstream not to complain, and there is
|
||||
// nothing here that would have complained. Accepted and ignored rather than refused, so a
|
||||
// caller passing it is not surprised.
|
||||
lua.globals().set(
|
||||
"CancelPlayerAuraSpellId",
|
||||
lua.create_function(
|
||||
|lua, (spell_id, _ignore_missing): (Option<u32>, Option<Value>)| {
|
||||
let Some(spell_id) = spell_id.filter(|&id| id != 0) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut model = lua
|
||||
.app_data_mut::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
if model
|
||||
.auras
|
||||
.get("player")
|
||||
.is_some_and(|l| l.iter().any(|a| a.spell_id == spell_id && a.cancelable))
|
||||
{
|
||||
model.cancel_aura_requests.push(spell_id);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
lua.globals().set(
|
||||
"CancelPlayerAuraSlot",
|
||||
lua.create_function(|lua, aura_slot: Option<u32>| {
|
||||
let Some(aura_slot) = aura_slot else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut model = lua
|
||||
.app_data_mut::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
let found = model.auras.get("player").and_then(|l| {
|
||||
l.iter()
|
||||
.find(|a| u32::from(a.slot) == aura_slot && a.cancelable)
|
||||
.map(|a| a.spell_id)
|
||||
});
|
||||
if let Some(spell_id) = found {
|
||||
model.cancel_aura_requests.push(spell_id);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `CastSpellNoQueue(spellId, spellBook [, unit])` (v3.1+) and `CastSpellByNameNoQueue(name
|
||||
// [, unit])` (v2.0+): cast without entering nampower's spell queue.
|
||||
//
|
||||
// **benilla has no spell queue to bypass**, so these are the plain cast — and that is the
|
||||
// honest mapping rather than a stub, because what the caller asked for ("cast this now, do not
|
||||
// queue it") is exactly what happens here. `spell/inflight.rs` carries namreeb's always-on
|
||||
// cast-ready rule, not pepo's queue windows; `docs/ADDON_COMPAT.md` records why the queue is
|
||||
// unbuilt.
|
||||
//
|
||||
// Unlike stock `CastSpell`, the first argument is a **spell id**, not a book slot — upstream's
|
||||
// whole reason for the function. The id goes on the same queue `CastSpell` fills, so a cast
|
||||
// from here and one from a bar are the same send. `"mouseover"` routes to the mouseover queue
|
||||
// SuperWoW's `CastSpellByName(name, "CLICK")` already uses; any other token takes the
|
||||
// current-selection path, which is what our `CastSpellByName` does with a token today — no
|
||||
// new deviation, and `docs/ADDON_COMPAT.md` has the row.
|
||||
for name in ["CastSpellNoQueue", "CastSpellByNameNoQueue"] {
|
||||
let by_name = name == "CastSpellByNameNoQueue";
|
||||
lua.globals().set(
|
||||
name,
|
||||
lua.create_function(move |lua, (first, second, third): (Value, Value, Value)| {
|
||||
let mut model = lua
|
||||
.app_data_mut::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
// `CastSpellByNameNoQueue(name, unit)` puts the unit second;
|
||||
// `CastSpellNoQueue(id, book, unit)` third.
|
||||
let unit = if by_name { &second } else { &third };
|
||||
let mouseover = matches!(unit, Value::String(s)
|
||||
if s.to_str().ok().as_deref() == Some("mouseover"));
|
||||
// `spell_ident` is the number-or-name resolver every other verb in this file
|
||||
// uses, so a name here resolves exactly as `IsSpellUsable`'s does: through the
|
||||
// player's own book, highest rank first.
|
||||
let spell_id = spell_ident(&model, Some(&first));
|
||||
if let Some(spell_id) = spell_id.filter(|&id| id != 0) {
|
||||
if mouseover {
|
||||
model.mouseover_spell_casts.push(spell_id);
|
||||
} else {
|
||||
model.spell_casts.push(spell_id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
// `UseItemIdOrName(itemIdOrName)` (v2.14+): use the first bag slot holding that item, through
|
||||
// the same queue `UseContainerItem` fills, so the cursor and repair-mode rules the stock verb
|
||||
// applies are not bypassed. Nothing carried is a silent no-op — the consumer's macro is
|
||||
// allowed to name an item the player does not have.
|
||||
lua.globals().set(
|
||||
"UseItemIdOrName",
|
||||
lua.create_function(|lua, ident: Option<Value>| {
|
||||
let found = {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
find_bag_slot(&model, ident.as_ref())
|
||||
};
|
||||
if let Some((bag, slot)) = found {
|
||||
let use_item: mlua::Function = lua.globals().get("UseContainerItem")?;
|
||||
use_item.call::<()>((bag, slot))?;
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `FindPlayerItemSlot(itemIdOrName)` (v2.14+) -> `bag, slot`, or nil. Bag `0` is the backpack
|
||||
// and `1..4` the equipped bags, which is `GetContainerItemInfo`'s own numbering.
|
||||
//
|
||||
// Ascending bag then ascending slot, which is the order the reference's own auto-store search
|
||||
// walks (`0x4f8030`) and so the order a player reads their bags in: two stacks of the same
|
||||
// item answer the first, not an arbitrary one. A number argument is an item id; a string is
|
||||
// matched against the slot's name, case-insensitively, because the consumer passes whatever
|
||||
// the macro said and 1.12 item names are display strings.
|
||||
lua.globals().set(
|
||||
"FindPlayerItemSlot",
|
||||
lua.create_function(|lua, ident: Option<Value>| {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
Ok(match find_bag_slot(&model, ident.as_ref()) {
|
||||
Some((bag, slot)) => (Some(bag), Some(slot)),
|
||||
None => (None, None),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetBagItem(bag, slot)` (v2.14+) -> that slot as a table, nil for an empty or unknown one.
|
||||
// The columns are the ones a bag slot actually carries here; `GetItemStats(itemId)` beside it
|
||||
// is the whole cache row, so this deliberately does not re-derive template fields it would
|
||||
// have to guess at.
|
||||
lua.globals().set(
|
||||
"GetBagItem",
|
||||
lua.create_function(|lua, (bag, slot): (Option<i64>, Option<u32>)| {
|
||||
let (Some(bag), Some(slot)) = (bag, slot) else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let found = {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
model
|
||||
.containers
|
||||
.get(&bag)
|
||||
.and_then(|c| c.slots.get(&slot))
|
||||
.filter(|s| s.item_id != 0)
|
||||
.cloned()
|
||||
};
|
||||
let Some(s) = found else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let t = lua.create_table()?;
|
||||
t.set("bag", bag)?;
|
||||
t.set("slot", slot)?;
|
||||
t.set("itemId", s.item_id)?;
|
||||
t.set("count", s.count)?;
|
||||
t.set("texture", s.texture.clone())?;
|
||||
t.set("quality", s.quality)?;
|
||||
t.set("link", s.link.clone())?;
|
||||
t.set("locked", s.locked)?;
|
||||
Ok(Value::Table(t))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetItemIdCooldown(itemId)` (v2.14+) -> `start, duration, enable` on the `GetTime` clock,
|
||||
// `GetContainerItemCooldown`'s triple for whichever bag slot holds that item. A cold or
|
||||
// absent item answers `0, 0, 1`, which is what the stock verb answers for a slot with no
|
||||
// cooldown — the consumer does arithmetic on these, so nil would be the wrong shape.
|
||||
lua.globals().set(
|
||||
"GetItemIdCooldown",
|
||||
lua.create_function(|lua, item_id: Option<Value>| {
|
||||
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
let cooldown = find_bag_slot(&model, item_id.as_ref())
|
||||
.and_then(|(bag, slot)| model.container_cooldowns.get(&(bag, slot)).copied())
|
||||
.filter(|&(start, duration, enabled)| start + duration > now || !enabled);
|
||||
Ok(match cooldown {
|
||||
Some((start, duration, enabled)) => (start, duration, i32::from(enabled)),
|
||||
None => (0.0, 0.0, 1),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetItemIconTexture(itemId)` (v2.14+): the item's inventory icon as an extensionless
|
||||
// `Interface\Icons\…` path, the sibling of `GetSpellIconTexture` above. Two hops, because an
|
||||
// item template carries a *display* id and not an icon: the cache row's `displayInfoID`, then
|
||||
@@ -859,6 +1105,173 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
mod tests {
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// The two aura cancels reach the same `CMSG_CANCEL_AURA` queue `CancelPlayerBuff` does — one
|
||||
/// by spell id, one by the **raw 0-based** descriptor slot the buff events report — and both
|
||||
/// refuse an aura the client would not let the player cancel.
|
||||
#[test]
|
||||
fn the_aura_cancels_queue_the_same_send_as_cancelplayerbuff() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let aura = |spell_id: u32, slot: u8, cancelable: bool| crate::script::AuraState {
|
||||
spell_id,
|
||||
slot,
|
||||
cancelable,
|
||||
helpful: true,
|
||||
count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
s.set_auras(
|
||||
"player",
|
||||
Some(vec![aura(100, 3, true), aura(200, 7, false)]),
|
||||
);
|
||||
|
||||
s.run("CancelPlayerAuraSpellId(100)").unwrap();
|
||||
assert_eq!(s.take_cancel_aura_requests(), vec![100]);
|
||||
|
||||
// Not cancelable, not carried, and nonsense ids are all silent no-ops.
|
||||
s.run("CancelPlayerAuraSpellId(200)").unwrap();
|
||||
s.run("CancelPlayerAuraSpellId(999)").unwrap();
|
||||
s.run("CancelPlayerAuraSpellId(0)").unwrap();
|
||||
s.run("CancelPlayerAuraSpellId()").unwrap();
|
||||
assert!(s.take_cancel_aura_requests().is_empty());
|
||||
|
||||
// The slot is raw, not the Lua index: aura 100 sits at slot 3, index 1.
|
||||
s.run("CancelPlayerAuraSlot(3)").unwrap();
|
||||
assert_eq!(s.take_cancel_aura_requests(), vec![100]);
|
||||
s.run("CancelPlayerAuraSlot(1)").unwrap();
|
||||
s.run("CancelPlayerAuraSlot(7)").unwrap();
|
||||
assert!(
|
||||
s.take_cancel_aura_requests().is_empty(),
|
||||
"slot 1 holds nothing and slot 7's aura is not cancelable"
|
||||
);
|
||||
|
||||
// `ignoreMissing` is accepted and changes nothing: there is nothing here to complain.
|
||||
s.run("CancelPlayerAuraSpellId(100, 1)").unwrap();
|
||||
assert_eq!(s.take_cancel_aura_requests(), vec![100]);
|
||||
}
|
||||
|
||||
/// The `*NoQueue` pair takes a **spell id** where stock `CastSpell` takes a book slot — the
|
||||
/// whole reason upstream has them — and reaches the same queue a bar press does, since there
|
||||
/// is no spell queue here to bypass. `"mouseover"` routes to the mouseover queue.
|
||||
#[test]
|
||||
fn the_no_queue_casts_push_a_spell_id_onto_the_ordinary_queue() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_spellbook(crate::script::SpellBookState {
|
||||
tabs: Vec::new(),
|
||||
slots: vec![crate::script::SpellSlotView {
|
||||
spell_id: 133,
|
||||
name: "Fireball".into(),
|
||||
rank: Some("Rank 1".into()),
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
|
||||
s.run("CastSpellNoQueue(133, 1)").unwrap();
|
||||
assert_eq!(s.take_spell_casts(), vec![133]);
|
||||
|
||||
// An id the book does not carry: `spell_ident` answers for ids directly, as upstream's
|
||||
// "cast by spell ID" says, so this is a send and not a refusal.
|
||||
s.run("CastSpellNoQueue(4242, 1)").unwrap();
|
||||
assert_eq!(s.take_spell_casts(), vec![4242]);
|
||||
|
||||
s.run(r#"CastSpellByNameNoQueue("Fireball")"#).unwrap();
|
||||
assert_eq!(s.take_spell_casts(), vec![133]);
|
||||
|
||||
// The unit is third for the id form and second for the name form.
|
||||
s.run(r#"CastSpellNoQueue(133, 1, "mouseover")"#).unwrap();
|
||||
assert!(s.take_spell_casts().is_empty());
|
||||
assert_eq!(s.take_mouseover_spell_casts(), vec![133]);
|
||||
s.run(r#"CastSpellByNameNoQueue("Fireball", "mouseover")"#)
|
||||
.unwrap();
|
||||
assert!(s.take_spell_casts().is_empty());
|
||||
assert_eq!(s.take_mouseover_spell_casts(), vec![133]);
|
||||
|
||||
// A name nothing answers for, and no argument at all, are silent.
|
||||
s.run(r#"CastSpellByNameNoQueue("Nothing At All")"#)
|
||||
.unwrap();
|
||||
s.run("CastSpellNoQueue()").unwrap();
|
||||
assert!(s.take_spell_casts().is_empty());
|
||||
}
|
||||
|
||||
/// The bag group — `FindPlayerItemSlot`, `GetBagItem`, `GetItemIdCooldown` — shares one
|
||||
/// lookup, so the three cannot disagree about *which* of two identical stacks they mean: the
|
||||
/// first, reading bags and slots ascending, as the reference's own auto-store search does.
|
||||
#[test]
|
||||
fn the_bag_group_finds_the_first_stack_and_agrees_about_which() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(Option<i64>, Option<u32>)>("return FindPlayerItemSlot(1234)")
|
||||
.unwrap(),
|
||||
(None, None),
|
||||
"no containers fed yet"
|
||||
);
|
||||
|
||||
// Two stacks of 1234: backpack slot 5 and bag 2 slot 1. The backpack one must win.
|
||||
let slot = |item_id: u32, count: u32| crate::script::ContainerSlot {
|
||||
item_id,
|
||||
count,
|
||||
..Default::default()
|
||||
};
|
||||
for (bag, slots) in [
|
||||
(0i64, vec![(5u32, slot(1234, 3)), (9, slot(555, 1))]),
|
||||
(1, vec![]),
|
||||
(2, vec![(1, slot(1234, 20))]),
|
||||
(3, vec![]),
|
||||
(4, vec![]),
|
||||
] {
|
||||
s.set_container(
|
||||
bag,
|
||||
Some(crate::script::ContainerState {
|
||||
name: None,
|
||||
num_slots: 16,
|
||||
slots: slots.into_iter().collect(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<(Option<i64>, Option<u32>)>("return FindPlayerItemSlot(1234)")
|
||||
.unwrap(),
|
||||
(Some(0), Some(5))
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(Option<i64>, Option<u32>)>("return FindPlayerItemSlot(555)")
|
||||
.unwrap(),
|
||||
(Some(0), Some(9))
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(Option<i64>, Option<u32>)>("return FindPlayerItemSlot(4242)")
|
||||
.unwrap(),
|
||||
(None, None),
|
||||
"not carried"
|
||||
);
|
||||
|
||||
// `GetBagItem` reports the slot it was asked about, not the found one.
|
||||
assert_eq!(
|
||||
s.eval::<u32>(r#"return GetBagItem(2, 1).count"#).unwrap(),
|
||||
20
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<u32>(r#"return GetBagItem(0, 5).itemId"#).unwrap(),
|
||||
1234
|
||||
);
|
||||
assert!(s
|
||||
.eval::<Option<mlua::Value>>("return GetBagItem(0, 7)")
|
||||
.unwrap()
|
||||
.is_none_or(|v| v.is_nil()));
|
||||
|
||||
// A cold item answers the stock triple, not nil: the consumer does arithmetic on these.
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i64)>("return GetItemIdCooldown(1234)")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i64)>("return GetItemIdCooldown(4242)")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 1),
|
||||
"not carried reads cold, as an absent slot does"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetItemIconTexture` takes the two hops an item icon needs — the cache row's display id,
|
||||
/// then `ItemDisplayInfo` — and tells a data gap apart from an unknown item: a known item
|
||||
/// whose display row is missing answers nil, an item the cache has never seen raises, as the
|
||||
|
||||
@@ -306,6 +306,23 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
// neither: its only icon path for an item is `GetContainerItemInfo` on a bag slot you
|
||||
// already hold, and it has no way at all to ask about an arbitrary item id or to read the
|
||||
// two trinket slots as a pair. Both are in `SuperCleveRoidMacros`'s `HasFeature` table.
|
||||
// nampower v2's bag group (same file), all three sharing one lookup so they cannot
|
||||
// disagree about which of two identical stacks they mean. 1.12 can ask about a bag slot
|
||||
// it names (`GetContainerItemInfo`) but has no search by item id or name, and no
|
||||
// cooldown read that is not already slot-addressed.
|
||||
// nampower v2's outbound group (same file). 1.12 cancels an aura only by its 1-based
|
||||
// buff *index* (`CancelPlayerBuff`), casts only by book slot (`CastSpell`), and uses an
|
||||
// item only by a bag slot it already names — so each of these is a different key into a
|
||||
// send that exists, not a new send. The `*NoQueue` pair is the plain cast here: there is
|
||||
// no spell queue to bypass (`docs/ADDON_COMPAT.md`).
|
||||
"CancelPlayerAuraSlot",
|
||||
"CancelPlayerAuraSpellId",
|
||||
"CastSpellByNameNoQueue",
|
||||
"CastSpellNoQueue",
|
||||
"UseItemIdOrName",
|
||||
"FindPlayerItemSlot",
|
||||
"GetBagItem",
|
||||
"GetItemIdCooldown",
|
||||
"GetItemIconTexture",
|
||||
"GetTrinkets",
|
||||
"PlayerIsMoving",
|
||||
|
||||
Reference in New Issue
Block a user