benilla: initial public release

A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-24 14:27:01 +07:00
co-authored by Claude Fable 5
commit 2ad0043523
778 changed files with 295134 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
//! The action bar (decision 0216 §7, slice 4; byte-verified 0218 §2/§4): `PickupAction`/
//! `PlaceAction` join the ONE payload space as the [`CursorAction`] arm. Two things set this
//! surface apart from bags/doll:
//!
//! - **The bar is client-authoritative and actions HOP** (0218 §4, `PlaceAction 0x4e62e0`) — the
//! opposite of the post-0218 item swap: placing onto an occupied slot puts the DISPLACED action
//! on the cursor rather than clearing, because there is no server round-trip to wait on (the
//! 120-slot table is ours; [`place_action`] IS the mutation, not a request for one).
//! - **The engine owns the payload + transition, the app owns the 120-table** (0216 §7's
//! ownership split): `model.actions` here is the engine's own OPTIMISTIC mirror of the app's
//! authoritative store, kept only so `HasAction`/`GetActionTexture`/&c. read right the instant a
//! local pickup/place happens, without waiting a frame for the app to re-feed. Every mutation
//! also queues `(lua id, packed)` onto [`Model::action_sets`] — the wire intent the app drains
//! into `CMSG_SET_ACTION_BUTTON`, one send per queued entry (0218 §4: a drag-swap is two sends,
//! never atomic — this module never coalesces them).
use mlua::Lua;
use crate::script::action::{ACTION_KIND_ITEM, ACTION_KIND_SPELL};
use crate::script::{ActionSlot, Model};
use super::{queue_cursor_update, CursorAction, CursorPayload};
/// Pack `(kind, action)` into the wire's `u32` slot word (`kind<<24 | action`, decision 0216 §1).
fn pack(kind: u8, action: u32) -> u32 {
(u32::from(kind) << 24) | (action & 0x00FF_FFFF)
}
/// `PickupAction(id)` — the action-bar slot's shift-click/drag-start entry point (ref
/// `ActionBarFrame.xml:12-38`'s `IsShiftKeyDown()` fork, `OnDragStart`).
///
/// - **A payload is already held** → falls through to [`place_action`] (the reference's own
/// contract: a shift-click while carrying just places, `ActionButtonTemplate`'s `OnClick` never
/// special-cases it).
/// - **Empty cursor, an occupied slot** → picks it up: payload `Action { src_slot: id, kind,
/// action, texture }`, the slot removed from `model.actions` (optimistic — the app re-pushes an
/// agreeing snapshot once `action_sets` lands), and `action_sets.push((id, 0))` queued — picking
/// an action OFF the bar empties it immediately on the wire too (the classic drag-off).
/// - **Empty cursor, an empty slot** → no-op (nothing to pick up).
///
/// Returns whether the caller should repaint (mirrors the container/doll pickup contract).
pub(super) fn pickup_action(model: &mut Model, id: u32) -> bool {
if model.cursor.is_some() {
return place_action(model, id);
}
let Some(slot) = model.actions.get(&id) else {
return false;
};
let payload = CursorAction {
src_slot: id,
kind: slot.kind,
action: slot.action,
texture: slot.texture.clone(),
};
model.actions.remove(&id);
model.cursor = Some(CursorPayload::Action(payload));
model.action_sets.push((id, 0));
queue_cursor_update(model);
true
}
/// `PlaceAction(id)` — the action-bar slot's click-with-payload/`OnReceiveDrag` entry point (ref
/// `UseAction(id, checkCursor=1)`'s place fork, `ActionBarFrame.xml`'s `OnReceiveDrag`).
///
/// Every arm writes the held action into `model.actions[id]` optimistically and queues
/// `action_sets.push((id, packed))`; what happens to the cursor afterward is the byte-verified
/// divergence from every other surface (0218 §4): an OCCUPIED destination puts the DISPLACED
/// action on the cursor (referencing `id` as its new `src_slot` — the slot it can now be placed
/// FROM), an empty destination just clears.
///
/// - Payload **Action** → `(kind, action)` straight off the held payload.
/// - Payload **Item** → `packed = item_id | ITEM<<24`; the item came from a BAG and STAYS there —
/// a bar item action is a reference, not a move, so no container move is queued here (the app's
/// `drain_action_sets` never touches `container_moves` either).
/// - Payload **Spell** → `packed = spell_id | SPELL<<24` (the producer, `PickupSpell`, lands in
/// slice 5 — this arm already works once something populates the payload).
/// - **Empty cursor** → no-op.
///
/// Returns whether the caller should repaint.
pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
let Some(held) = model.cursor.take() else {
return false;
};
let (kind, action, texture) = match &held {
CursorPayload::Action(a) => (a.kind, a.action, a.texture.clone()),
CursorPayload::Item(i) => (ACTION_KIND_ITEM, i.item_id, i.texture.clone()),
CursorPayload::Spell(s) => (ACTION_KIND_SPELL, s.spell_id, s.texture.clone()),
};
let displaced = model.actions.insert(
id,
ActionSlot {
texture,
kind,
action,
count: 0, // the app's next-frame re-feed resolves the real bag count for an ITEM slot
},
);
model.action_sets.push((id, pack(kind, action)));
model.cursor = displaced.map(|d| {
CursorPayload::Action(CursorAction {
src_slot: id,
kind: d.kind,
action: d.action,
texture: d.texture,
})
});
queue_cursor_update(model);
true
}
/// Register the action-bar's cursor globals — top-level, matching the reference
/// (`PickupAction`/`PlaceAction` are not namespaced any more than `PickupContainerItem`'s cursor
/// siblings are).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"PickupAction",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(pickup_action(&mut model, id))
})?,
)?;
g.set(
"PlaceAction",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(place_action(&mut model, id))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::cursor::{CursorAction, CursorItem, CursorPayload, CursorSpell};
use crate::script::{ActionSlot, UiScript};
fn action_slot(texture: &str, kind: u8, action: u32) -> ActionSlot {
ActionSlot {
texture: Some(texture.into()),
kind,
action,
count: 0,
}
}
#[test]
fn pickup_action_empties_the_slot_and_queues_a_clear() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 133)));
assert!(s.eval::<bool>("return PickupAction(1)").unwrap());
assert!(
!s.eval::<bool>("return HasAction(1)").unwrap(),
"removed from the engine's optimistic mirror"
);
let (kind, id) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
assert_eq!((kind.as_str(), id), ("action", 1));
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
}
#[test]
fn pickup_action_on_an_empty_slot_is_a_no_op() {
let mut s = UiScript::new().unwrap();
assert!(!s.eval::<bool>("return PickupAction(5)").unwrap());
assert!(s.cursor_payload().is_none());
assert!(s.take_action_sets().is_empty());
}
#[test]
fn place_action_onto_empty_writes_the_slot_and_clears_cursor() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 3,
kind: 0x00,
action: 133,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(s.eval::<bool>("return PlaceAction(7)").unwrap());
assert!(s.cursor_payload().is_none(), "empty destination clears");
assert!(s.eval::<bool>("return HasAction(7)").unwrap());
assert_eq!(
s.eval::<String>("return GetActionTexture(7)").unwrap(),
"Interface\\Icons\\Spell_A"
);
// 0x00<<24 | 133 == 133.
assert_eq!(s.take_action_sets(), vec![(7, 133)]);
}
/// The byte-verified divergence from bags/doll (0218 §4): placing onto an OCCUPIED action
/// slot HOPS the displaced action onto the cursor — two `action_sets` entries across the
/// gesture (the pickup's clear, then the place's write), never a container move.
#[test]
fn place_action_onto_occupied_hops_the_displaced_action() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
assert!(s.eval::<bool>("return PickupAction(1)").unwrap());
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
assert!(s.eval::<bool>("return PlaceAction(2)").unwrap());
// Slot 2 now shows the placed action (111).
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A"
);
// The displaced action (222) is now the held payload, sourced from slot 2.
let (kind, src) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
assert_eq!((kind.as_str(), src), ("action", 2));
assert_eq!(
s.cursor_payload(),
Some(CursorPayload::Action(CursorAction {
src_slot: 2,
kind: 0x00,
action: 222,
texture: Some("Interface\\Icons\\Spell_B".into()),
}))
);
assert_eq!(s.take_action_sets(), vec![(2, 111)]);
// A hop is Some→Some: no HIDE+SHOW churn out of one gesture.
// (Exercised directly by the SHOWGRID/HIDEGRID test below.)
}
#[test]
fn place_action_item_payload_packs_the_item_kind_and_leaves_the_bag_untouched() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bag: 0,
slot: 1,
item_id: 117,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
link: None,
count: None,
quality: Some(1),
equip_slots: Vec::new(),
}));
assert!(s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(s.cursor_payload().is_none());
// 0x80<<24 | 117.
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000 | 117)]);
assert!(
s.take_container_moves().is_empty(),
"a bar item action is a reference, not a move"
);
}
#[test]
fn place_action_spell_payload_packs_the_spell_kind() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 3,
book_type: "spell".into(),
spell_id: 133,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(s.eval::<bool>("return PlaceAction(9)").unwrap());
assert_eq!(s.take_action_sets(), vec![(9, 133)]); // 0x00<<24 | 133
}
#[test]
fn place_action_with_an_empty_cursor_is_a_no_op() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
assert!(!s.eval::<bool>("return PlaceAction(1)").unwrap());
assert!(s.eval::<bool>("return HasAction(1)").unwrap(), "untouched");
assert!(s.take_action_sets().is_empty());
}
/// Shift-click while ALREADY holding just places (the reference's own `OnClick` never
/// special-cases it): `PickupAction` on a held cursor routes straight to [`super::place_action`].
#[test]
fn pickup_action_with_a_payload_held_falls_through_to_place() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 1,
kind: 0x00,
action: 111,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(s.eval::<bool>("return PickupAction(5)").unwrap());
assert!(s.cursor_payload().is_none(), "placed onto the empty slot 5");
assert!(s.eval::<bool>("return HasAction(5)").unwrap());
assert_eq!(s.take_action_sets(), vec![(5, 111)]);
}
/// `ACTIONBAR_SHOWGRID`/`ACTIONBAR_HIDEGRID` fire on the cursor's None↔Some edges (decision
/// 0216 §7) — a pickup shows the grid, a place-onto-empty hides it, and a HOP (Some→Some)
/// fires neither (no HIDE+SHOW churn out of one gesture).
#[test]
fn showgrid_hidegrid_fire_on_gain_and_loss_not_on_a_hop() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
s.run(
r#"
shows, hides = 0, 0
local f = CreateFrame("Frame", "GridListener")
f:RegisterEvent("ACTIONBAR_SHOWGRID")
f:RegisterEvent("ACTIONBAR_HIDEGRID")
f:SetScript("OnEvent", function()
if event == "ACTIONBAR_SHOWGRID" then shows = shows + 1 end
if event == "ACTIONBAR_HIDEGRID" then hides = hides + 1 end
end)
"#,
)
.unwrap();
s.run("PickupAction(1)").unwrap(); // None -> Some: SHOW
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 0);
s.run("PlaceAction(2)").unwrap(); // Some -> Some (hop): neither
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 0);
s.run("ClearCursor()").unwrap(); // Some -> None: HIDE
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 1);
}
}
+617
View File
@@ -0,0 +1,617 @@
//! The paper doll (decision 0208 phase 1b): equipment joins the ONE payload space via
//! [`super::EQUIPMENT_BAG`]. One extra transition ([`pickup_inventory_item`]) plus four small
//! satellites ([`equip_cursor_item`], [`cursor_can_go_in_slot`], [`auto_equip_cursor_item`],
//! [`use_inventory_item`], [`is_inventory_item_locked`]) — everything else (cancel, clear, the
//! world drop, `SplitContainerItem`) already generalizes from [`super`] because it only ever
//! matches on the `CursorPayload` enum, never on `bag`.
use mlua::Lua;
use crate::script::container::ContainerMove;
use crate::script::Model;
use super::{queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload, EQUIPMENT_BAG};
/// `PickupInventoryItem(id)` — the paper-doll slot's left-click entry point (decision 0208 phase
/// 1b). Mirrors `container::pickup_container_item`'s click model exactly, keyed on the sentinel
/// [`EQUIPMENT_BAG`] rather than a real bag id (decision 0216 §1's ONE payload space, extended to
/// equipment). `id` is the 1-based live-API inventory slot (`GetInventorySlotInfo`'s own
/// numbering — HeadSlot=1 … TabardSlot=19, the four equipped-bag icons Bag0Slot=20 … Bag3Slot=23).
/// The bag icons ride the SAME transition (a bag's `equip_slots` is `[20,21,22,23]` from
/// `find_equip_slot(INVTYPE_BAG)`, so the fit rule already routes a bag onto any bag slot, and
/// `wire_pos`'s `EQUIPMENT_BAG` arm maps live id 20..23 → the wire's equipped-bag inventory slots
/// 19..22); ammo (0) stays a named deferral — refused outright, cursor untouched.
///
/// - empty cursor + an occupied, UNLOCKED doll slot → picks it up (payload `Item { bag:
/// EQUIPMENT_BAG, slot: id, … }`), the same lock/event pair as a bag pickup.
/// - holding + the SAME slot → cancel (mirrors `pickup_container_item`'s own same-slot branch).
/// - holding an Item payload whose `equip_slots` contains `id` (the fit rule — `equip_slots` rides
/// the payload from wherever it was picked up, doll or bag alike) → queues the move (dst =
/// `(EQUIPMENT_BAG, id)`) and CLEARS (decision 0218: a plain clear, no hop) — UNLESS it's a
/// split carry (`count: Some(_)`): refused outright, kept (you can't equip a partial stack).
/// - holding a non-fitting Item, or any Spell/Action payload → no-op, kept (mirrors
/// `pickup_container_item`'s own refusal of a payload a bag slot can't take).
///
/// Returns whether the caller should repaint (the source lock, or the held payload, changed).
pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
// Equipment slots (1..=19) + the four equipped-bag icons (20..=23); ammo (0) is out of scope.
if !(1..=23).contains(&id) {
return false;
}
match model.cursor.take() {
None => {
let picked = model
.inventory_slots
.get(id as usize)
.and_then(|s| s.as_ref())
.filter(|s| s.item_id != 0 && !s.locked)
.map(|s| CursorItem {
bag: EQUIPMENT_BAG,
slot: id,
item_id: s.item_id,
texture: s.icon.clone(),
link: s.link.clone(),
quality: Some(u32::try_from(s.quality).unwrap_or(0)),
count: None,
equip_slots: s.equip_slots.clone(),
});
match picked {
Some(item) => {
model.cursor = Some(CursorPayload::Item(item));
queue_cursor_update(model);
queue_lock_changed(model, EQUIPMENT_BAG, id);
true
}
None => false,
}
}
Some(CursorPayload::Item(held)) if held.bag == EQUIPMENT_BAG && held.slot == id => {
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
true
}
Some(CursorPayload::Item(held)) => {
// A split carry can't equip a partial stack, and a non-fitting item has nowhere to
// land on this slot — both refuse, kept exactly as picked up (no event: nothing
// transitioned).
if held.count.is_some() || !held.equip_slots.contains(&(id as u8)) {
model.cursor = Some(CursorPayload::Item(held));
return false;
}
model.container_moves.push(ContainerMove {
src_bag: held.bag,
src_slot: held.slot,
dst_bag: EQUIPMENT_BAG,
dst_slot: id,
count: None,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
true
}
Some(other @ (CursorPayload::Spell(_) | CursorPayload::Action(_))) => {
model.cursor = Some(other);
false
}
}
}
/// `EquipCursorItem(id)` — the ref's item-context "Equip" popup entry (not built this slice: no
/// context menu ships yet). Same transition as placing the held item directly onto doll slot
/// `id` — [`pickup_inventory_item`]'s place arm already IS that transition, so this simply routes
/// there (decision 0208 phase 1b: "route both through the same transition").
pub(super) fn equip_cursor_item(model: &mut Model, id: u32) -> bool {
pickup_inventory_item(model, id)
}
/// `CursorCanGoInSlot(id)` — whether the held payload could be dropped on doll slot `id`
/// (decision 0208 phase 1b; the reference's `CURSOR_UPDATE` → `LockHighlight`/`UnlockHighlight`
/// driver, `PaperDollFrame.lua:609-615`). An Item payload whose `equip_slots` contains `id`;
/// empty cursor or a Spell/Action payload both answer `false` (the same fit-blind refusal
/// [`pickup_inventory_item`] gives them — asking a hypothetical doesn't relax the rule).
///
/// INTERIM: the byte body's terminal equip-fit check (`0x5da1d0`) was left unresolved by 0216 §5
/// (0218's own residual, §4 "confirmed as built" — everything else on that list is pinned, this
/// one function wasn't among the byte-verified findings). `equip_slots` derives from the item
/// template's `inventoryType` via the SERVER's own mapping instead (`ui_items::find_equip_slot`,
/// transcribed from vmangos `Player::FindEquipSlot`/`ItemPrototype::GetAllowedEquipSlots`) — a
/// verifiable authority `SMSG_INVENTORY_CHANGE_FAILURE` referees either way, corrected if a
/// future pin disagrees with the client's own table.
pub(super) fn cursor_can_go_in_slot(model: &Model, id: u32) -> bool {
match &model.cursor {
Some(CursorPayload::Item(item)) => {
u8::try_from(id).is_ok_and(|id| item.equip_slots.contains(&id))
}
_ => false,
}
}
/// `AutoEquipCursorItem()` — the model-pane's click-with-payload path (decision 0208 phase 1b,
/// ref `PaperDollFrame.xml`'s frame-level `OnReceiveDrag`/`OnMouseUp`): an Item payload picked up
/// from a CONTAINER (`bag >= 0`, a whole stack) queues its `(bag, slot)` source as a NEW
/// `container_autoequips` intent (the app sends `CMSG_AUTOEQUIP_ITEM` — the server picks the
/// destination slot itself and swaps any displaced piece back, vmangos `ItemHandler.cpp:138-228`)
/// and clears the cursor — a plain clear, the same contract as every other placement since
/// decision 0218. A payload already carried FROM the equipment (`bag == EQUIPMENT_BAG` — a
/// doll→doll re-drop has nothing for the server to "auto" pick) or a split carry (can't
/// auto-equip a partial stack) is a no-op, kept; so is any Spell/Action payload.
///
/// Returns whether the caller should repaint.
pub(super) fn auto_equip_cursor_item(model: &mut Model) -> bool {
match model.cursor.take() {
Some(CursorPayload::Item(item)) if item.bag >= 0 && item.count.is_none() => {
model.container_autoequips.push((item.bag, item.slot));
queue_cursor_update(model);
queue_lock_changed(model, item.bag, item.slot);
true
}
other => {
model.cursor = other;
false
}
}
}
/// `UseInventoryItem(id)` — the doll slot's right-click entry point (ref
/// `PaperDollItemSlotButton_OnClick`, `PaperDollFrame.lua:658-659`): queues `id` for the app to
/// resolve to the equipped item's guid and send (`CMSG_USE_ITEM`, bag 255 + the 0-based wire slot
/// — vmangos `HandleUseItemOpcode` takes equipped positions the same as bag ones). No engine-side
/// refusal — an empty/out-of-range slot is a harmless no-op the app's drain silently drops
/// (mirrors `UseContainerItem`'s own contract: it never checks the slot either).
pub(super) fn use_inventory_item(model: &mut Model, id: u32) {
model.inventory_uses.push(id);
}
/// `IsInventoryItemLocked(id)` — true while `id` is the ACTIVE cursor payload's source (the
/// picked-up slot dims immediately, no server round-trip — the doll twin of
/// `GetContainerItemInfo`'s `held_here` derivation) OR the app's fed `InvSlotView.locked` says so
/// (an outstanding pending op the app's `PendingItemOps` tracks, decision 0216 §4/0218 §3).
pub(super) fn is_inventory_item_locked(model: &Model, id: u32) -> bool {
let held_here = matches!(&model.cursor, Some(CursorPayload::Item(c)) if c.bag == EQUIPMENT_BAG && c.slot == id);
let fed = usize::try_from(id)
.ok()
.and_then(|i| model.inventory_slots.get(i))
.and_then(|s| s.as_ref())
.is_some_and(|s| s.locked);
held_here || fed
}
/// Register the paper-doll's cursor globals — all top-level, matching the reference
/// (`PickupInventoryItem` &c. are not namespaced any more than `PickupContainerItem`'s cursor
/// siblings are).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"PickupInventoryItem",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(pickup_inventory_item(&mut model, id))
})?,
)?;
g.set(
"EquipCursorItem",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(equip_cursor_item(&mut model, id))
})?,
)?;
g.set(
"CursorCanGoInSlot",
lua.create_function(|lua, id: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(cursor_can_go_in_slot(&model, id))
})?,
)?;
g.set(
"AutoEquipCursorItem",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
Ok(auto_equip_cursor_item(&mut model))
})?,
)?;
g.set(
"UseInventoryItem",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
use_inventory_item(&mut model, id);
Ok(())
})?,
)?;
g.set(
"IsInventoryItemLocked",
lua.create_function(|lua, id: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(is_inventory_item_locked(&model, id))
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::cursor::{CursorAction, CursorPayload, CursorSpell, EQUIPMENT_BAG};
use crate::script::{ContainerMove, ContainerSlot, ContainerState, InvSlotView, UiScript};
/// Slots 1 (Head) and 19 (Tabard) occupied, plus the two finger rings (11/12) — a doll
/// fixture wide enough to exercise the fit rule, the same-slot cancel, and a doll↔doll swap.
fn doll_slots() -> crate::script::InventorySlots {
let mut slots: crate::script::InventorySlots = Default::default();
slots[1] = Some(InvSlotView {
durability: None,
flags: 0,
item_id: 1234,
icon: Some("Interface\\Icons\\INV_Helmet_01".into()),
count: 1,
quality: 2,
name: Some("Test Helm".into()),
link: Some("|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r".into()),
locked: false,
equip_slots: vec![1],
creator: None,
});
slots[11] = Some(InvSlotView {
durability: None,
flags: 0,
item_id: 555,
icon: Some("Interface\\Icons\\INV_Jewelry_Ring_01".into()),
count: 1,
quality: 1,
name: Some("Test Ring".into()),
link: Some("|cffffffff|Hitem:555:0:0:0|h[Test Ring]|h|r".into()),
locked: false,
equip_slots: vec![11, 12], // FINGER1|FINGER2 — the doll↔doll swap fixture
creator: None,
});
slots[19] = Some(InvSlotView {
durability: None,
flags: 0,
item_id: 999,
icon: Some("Interface\\Icons\\INV_Shirt_White_01".into()),
count: 1,
quality: 1,
name: Some("Test Tabard".into()),
link: Some("|cffffffff|Hitem:999:0:0:0|h[Test Tabard]|h|r".into()),
locked: false,
equip_slots: vec![19],
creator: None,
});
slots
}
fn one_fitting_bag_item() -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
durability: None,
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
count: 1,
quality: Some(3),
item_id: 2000,
link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()),
locked: false,
equip_slots: vec![1], // fits HeadSlot only
cooldown: None,
readable: false,
creator: None,
flags: 0,
},
);
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}
}
#[test]
fn pickup_from_doll_slot_holds_and_locks() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
let held = s.cursor_item().expect("picked up");
assert_eq!(
(held.bag, held.slot, held.item_id),
(EQUIPMENT_BAG, 1, 1234)
);
assert_eq!(held.equip_slots, vec![1]);
assert!(s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
}
#[test]
fn pickup_same_doll_slot_cancels_no_move() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(s.cursor_item().is_none());
assert!(s.take_container_moves().is_empty());
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
}
#[test]
fn place_fitting_bag_item_onto_doll_slot_queues_move_and_clears() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
s.set_container(0, Some(one_fitting_bag_item()));
assert!(s
.eval::<bool>("return C_Container.PickupContainerItem(0, 1)")
.unwrap());
assert_eq!(s.cursor_item().unwrap().equip_slots, vec![1]);
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
assert!(s.cursor_item().is_none(), "a plain clear, no hop");
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: EQUIPMENT_BAG,
dst_slot: 1,
count: None,
}]
);
}
#[test]
fn place_non_fitting_item_is_a_no_op() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
// The bag item only fits HeadSlot (1); try placing it on NeckSlot (2).
s.set_container(0, Some(one_fitting_bag_item()));
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
assert!(!s.eval::<bool>("return PickupInventoryItem(2)").unwrap());
let held = s.cursor_item().expect("kept — doesn't fit slot 2");
assert_eq!(held.item_id, 2000);
assert!(s.take_container_moves().is_empty());
}
#[test]
fn doll_to_doll_ring_swap_fits_via_shared_equip_slots() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
assert!(s.eval::<bool>("return PickupInventoryItem(11)").unwrap());
assert_eq!(s.cursor_item().unwrap().equip_slots, vec![11, 12]);
assert!(s.eval::<bool>("return PickupInventoryItem(12)").unwrap());
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: EQUIPMENT_BAG,
src_slot: 11,
dst_bag: EQUIPMENT_BAG,
dst_slot: 12,
count: None,
}]
);
}
#[test]
fn split_carry_refuses_to_equip() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
let mut state = one_fitting_bag_item();
state.slots.get_mut(&1).unwrap().count = 5;
s.set_container(0, Some(state));
s.run("SplitContainerItem(0, 1, 2)").unwrap();
let held = s.cursor_item().expect("a split carry");
assert_eq!(held.count, Some(2));
assert!(!s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
let held = s.cursor_item().expect("kept — can't equip a partial stack");
assert_eq!(held.count, Some(2));
assert!(s.take_container_moves().is_empty());
}
#[test]
fn pickup_inventory_item_refuses_ammo_but_takes_a_bag_slot() {
let mut s = UiScript::new().unwrap();
let mut slots = doll_slots();
slots[0] = Some(InvSlotView {
durability: None,
flags: 0,
item_id: 2512,
equip_slots: Vec::new(),
..Default::default()
});
// An equipped bag in Bag0Slot (id 20): its equip_slots is the four bag slots.
slots[20] = Some(InvSlotView {
durability: None,
flags: 0,
item_id: 4496,
icon: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 1,
quality: 1,
name: Some("Small Brown Pouch".into()),
link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()),
locked: false,
equip_slots: vec![20, 21, 22, 23],
creator: None,
});
s.set_inventory_slots(slots);
// Ammo (0) is still refused; the bag icon (20) now picks up the equipped bag.
assert!(!s.eval::<bool>("return PickupInventoryItem(0)").unwrap());
assert!(s.eval::<bool>("return PickupInventoryItem(20)").unwrap());
let held = s.cursor_item().expect("bag picked up from the bar");
assert_eq!(
(held.bag, held.slot, held.item_id),
(EQUIPMENT_BAG, 20, 4496)
);
}
/// Drag-to-equip: a bag carried from the backpack drops onto an empty bag slot (id 21), queuing
/// the move to `(EQUIPMENT_BAG, 21)` — the same transition the paper doll uses, and the wire the
/// app maps onto equipped-bag inventory slot 20.
#[test]
fn place_a_bag_onto_a_bag_slot_queues_the_equip_move() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
let mut slots = std::collections::HashMap::new();
slots.insert(
3,
ContainerSlot {
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 1,
quality: Some(1),
item_id: 4496,
link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()),
locked: false,
equip_slots: vec![20, 21, 22, 23], // INVTYPE_BAG → any bag slot
cooldown: None,
readable: false,
creator: None,
flags: 0,
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.run("C_Container.PickupContainerItem(0, 3)").unwrap();
assert!(s.eval::<bool>("return CursorCanGoInSlot(21)").unwrap());
assert!(s.eval::<bool>("return PickupInventoryItem(21)").unwrap());
assert!(s.cursor_item().is_none(), "a plain clear on place");
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 3,
dst_bag: EQUIPMENT_BAG,
dst_slot: 21,
count: None,
}]
);
}
#[test]
fn cursor_can_go_in_slot_per_arm() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
// Empty cursor: false everywhere.
assert!(!s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
// Item arm: true only for a slot its equip_slots names.
s.set_container(0, Some(one_fitting_bag_item()));
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
assert!(!s.eval::<bool>("return CursorCanGoInSlot(2)").unwrap());
s.run("ClearCursor()").unwrap();
// Spell/Action arms: always false (the fit-blind refusal).
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 1,
texture: None,
}));
assert!(!s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 1,
kind: 0,
action: 1,
texture: None,
}));
assert!(!s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
}
#[test]
fn auto_equip_cursor_item_queues_source_and_clears() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(one_fitting_bag_item()));
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return AutoEquipCursorItem()").unwrap());
assert!(s.cursor_item().is_none());
assert_eq!(s.take_container_autoequips(), vec![(0, 1)]);
}
#[test]
fn auto_equip_cursor_item_is_a_no_op_from_the_doll_or_a_split_carry() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
// From the doll itself: nothing for the server to "auto" pick.
s.run("PickupInventoryItem(1)").unwrap();
assert!(!s.eval::<bool>("return AutoEquipCursorItem()").unwrap());
assert!(s.cursor_item().is_some(), "kept");
assert!(s.take_container_autoequips().is_empty());
s.run("ClearCursor()").unwrap();
// A split carry: can't auto-equip a partial stack.
let mut state = one_fitting_bag_item();
state.slots.get_mut(&1).unwrap().count = 5;
s.set_container(0, Some(state));
s.run("SplitContainerItem(0, 1, 2)").unwrap();
assert!(!s.eval::<bool>("return AutoEquipCursorItem()").unwrap());
assert!(s.cursor_item().is_some(), "kept");
assert!(s.take_container_autoequips().is_empty());
}
#[test]
fn use_inventory_item_queues_the_id() {
let mut s = UiScript::new().unwrap();
s.run("UseInventoryItem(1)").unwrap();
s.run("UseInventoryItem(19)").unwrap();
assert_eq!(s.take_inventory_uses(), vec![1, 19]);
assert!(s.take_inventory_uses().is_empty(), "drained");
}
#[test]
fn is_inventory_item_locked_reads_held_or_fed() {
let mut s = UiScript::new().unwrap();
let mut slots = doll_slots();
slots[19].as_mut().unwrap().locked = true; // the app's own PendingItemOps feed
s.set_inventory_slots(slots);
assert!(
s.eval::<bool>("return IsInventoryItemLocked(19)").unwrap(),
"fed lock"
);
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
s.run("PickupInventoryItem(1)").unwrap();
assert!(
s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap(),
"held-here lock"
);
}
#[test]
fn equip_cursor_item_routes_through_pickup() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
s.set_container(0, Some(one_fitting_bag_item()));
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return EquipCursorItem(1)").unwrap());
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: EQUIPMENT_BAG,
dst_slot: 1,
count: None,
}]
);
}
}
+421
View File
@@ -0,0 +1,421 @@
//! The drag gesture (decision 0216 §3) — `RegisterForDrag`/`OnDragStart`/`OnDragStop`/
//! `OnReceiveDrag`'s mechanics: arm on press, start past a threshold, resolve on release. Split
//! out of [`super`] purely for size — [`super::pointer`](crate::script::pointer) is the only
//! external caller (via [`super`]'s re-exports), and the payload types/transition seam this
//! drives live one level up.
use crate::script::Model;
use crate::widget::FrameHandle;
/// INTERIM pixel drag-start threshold (0216 §5 (d) is the byte-verified trigger; the reference
/// almost certainly uses a small OS/engine drag-distance constant, not this one). Compared with
/// **strict** `>` — a move exactly at the threshold does not start the gesture.
pub(crate) const DRAG_START_THRESHOLD: f32 = 4.0;
/// An in-flight drag gesture: armed at mouse-down on a [`Model::drag_registered`] frame,
/// `started` once the cursor has moved past [`DRAG_START_THRESHOLD`] from the press point.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct DragGesture {
pub(crate) button: String,
pub(crate) source: FrameHandle,
pub(crate) start: (f32, f32),
pub(crate) started: bool,
}
/// A resolved drag release (a gesture whose button matched the release) — [`Model::drag`] is
/// always cleared alongside this, win or lose.
pub(crate) struct DragRelease {
/// Whether the gesture crossed [`DRAG_START_THRESHOLD`] (so `OnDragStart` fired). A released
/// gesture that never started fires nothing — the normal click path proceeds untouched.
pub(crate) started: bool,
pub(crate) source: FrameHandle,
}
/// Arm (or clear) the drag gesture on a mouse-DOWN. `hit` is the pressed frame (`None` = the
/// press hit nothing); a press always REPLACES any leftover in-flight gesture — its matching
/// release should already have cleared it, but a press is the one moment we know for certain no
/// gesture should still be armed against a *different* earlier press.
pub(crate) fn arm_drag(model: &mut Model, hit: Option<FrameHandle>, button: &str, pos: (f32, f32)) {
model.drag = hit
.filter(|&h| {
model
.drag_registered
.get(&h)
.is_some_and(|set| set.iter().any(|b| b.eq_ignore_ascii_case(button)))
})
.map(|h| DragGesture {
button: button.to_string(),
source: h,
start: pos,
started: false,
});
}
/// Advance the armed gesture on a mouse-move: once the cursor has moved past
/// [`DRAG_START_THRESHOLD`] from the press point, mark it started. Returns `(source id, button)`
/// to fire `OnDragStart` with, exactly once per gesture (already-started and no-gesture both
/// answer `None`); a source that died before starting fires nothing.
pub(crate) fn maybe_start_drag(model: &mut Model, pos: (f32, f32)) -> Option<(u32, String)> {
let (source, button) = {
let g = model.drag.as_ref()?;
if g.started {
return None;
}
let (sx, sy) = g.start;
let dist = ((pos.0 - sx).powi(2) + (pos.1 - sy).powi(2)).sqrt();
if dist <= DRAG_START_THRESHOLD {
return None;
}
(g.source, g.button.clone())
};
model.drag.as_mut().expect("checked Some above").started = true;
let id = model
.arena
.frame(source)
.is_some()
.then(|| model.frame_id(source))?;
Some((id, button))
}
/// Resolve (and clear) the in-flight drag gesture matching a mouse-button release's `button`, if
/// any (case-insensitive, the `RegisterForClicks` precedent).
pub(crate) fn take_drag(model: &mut Model, button: &str) -> Option<DragRelease> {
let g = model
.drag
.take_if(|g| g.button.eq_ignore_ascii_case(button))?;
Some(DragRelease {
started: g.started,
source: g.source,
})
}
#[cfg(test)]
mod tests {
use crate::script::cursor::{CursorItem, CursorPayload};
use crate::script::UiScript;
fn drag_script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s
}
#[test]
fn drag_start_stop_and_receive_suppress_onclick() {
let mut s = drag_script();
s.run(
r#"
drag_starts, drag_stops, receives = 0, 0, 0
click_a, click_b = 0, 0
drag_button = nil
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton")
a:SetScript("OnDragStart", function(self, button) drag_starts = drag_starts + 1; drag_button = button end)
a:SetScript("OnDragStop", function(self) drag_stops = drag_stops + 1 end)
a:SetScript("OnClick", function(self) click_a = click_a + 1 end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 400, 0); b:SetSize(400, 600); b:EnableMouse(true)
b:SetScript("OnReceiveDrag", function(self) receives = receives + 1 end)
b:SetScript("OnClick", function(self) click_b = click_b + 1 end)
"#,
)
.unwrap();
s.resolve();
s.mouse_button(100.0, 300.0, "LeftButton", true); // press inside A
s.mouse_move(102.0, 300.0); // 2px — under the threshold
assert_eq!(s.eval::<i64>("return drag_starts").unwrap(), 0);
s.mouse_move(110.0, 300.0); // 10px from the press point — starts
assert_eq!(s.eval::<i64>("return drag_starts").unwrap(), 1);
assert_eq!(
s.eval::<String>("return drag_button").unwrap(),
"LeftButton"
);
s.mouse_move(120.0, 300.0); // further movement doesn't re-fire
assert_eq!(s.eval::<i64>("return drag_starts").unwrap(), 1);
s.mouse_move(600.0, 300.0); // over B now
let consumed = s.mouse_button(600.0, 300.0, "LeftButton", false); // release over B
assert!(consumed);
assert_eq!(
s.eval::<i64>("return drag_stops").unwrap(),
1,
"OnDragStop on the source"
);
assert_eq!(
s.eval::<i64>("return receives").unwrap(),
1,
"OnReceiveDrag on the target"
);
assert_eq!(
s.eval::<i64>("return click_a").unwrap(),
0,
"no OnClick on the source"
);
assert_eq!(
s.eval::<i64>("return click_b").unwrap(),
0,
"no OnClick on the target"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn drag_release_on_the_source_still_suppresses_onclick() {
// Without the suppression, a same-frame press+release Up would normally fire OnClick
// (a plain Frame's OnClick fires on any same-frame release — see `input.rs`'s click
// tests) — proving the drag path takes precedence over that default.
let mut s = drag_script();
s.run(
r#"
clicks = 0
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton")
a:SetScript("OnClick", function(self) clicks = clicks + 1 end)
"#,
)
.unwrap();
s.resolve();
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_move(110.0, 300.0); // starts the drag
s.mouse_button(100.0, 300.0, "LeftButton", false); // released back on A
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn drag_not_started_leaves_the_ordinary_click_path_untouched() {
let mut s = drag_script();
s.run(
r#"
clicks = 0
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton")
a:SetScript("OnClick", function(self) clicks = clicks + 1 end)
"#,
)
.unwrap();
s.resolve();
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_move(101.0, 300.0); // 1px — never starts
s.mouse_button(100.0, 300.0, "LeftButton", false);
assert_eq!(
s.eval::<i64>("return clicks").unwrap(),
1,
"a plain click still fires"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A drag released over the world does NOTHING to the payload — no popup, no clear (decision
/// 0218, byte-verified: `0x495300` runs on the WorldFrame click release only; a drag release
/// routes as a drag, never a click — the director's "it should require an outside up+down").
#[test]
fn drag_release_over_nothing_keeps_carrying_no_popup() {
let mut s = drag_script();
s.run(
r#"
heard = 0
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton")
local f = CreateFrame("Frame", "Listener")
f:RegisterEvent("DELETE_ITEM_CONFIRM")
f:SetScript("OnEvent", function() heard = heard + 1 end)
"#,
)
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bag: 0,
slot: 1,
item_id: 117,
texture: None,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
count: None,
quality: Some(3),
equip_slots: Vec::new(),
}));
s.mouse_button(100.0, 300.0, "LeftButton", true); // press inside A
s.mouse_move(110.0, 300.0); // starts the drag
s.mouse_button(-50.0, -50.0, "LeftButton", false); // released over the world
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 0, "no popup");
assert!(s.cursor_item().is_some(), "the payload keeps carrying");
// The follow-up CLICK on the world (down + up, both over nothing) is the trigger.
s.mouse_button(-50.0, -50.0, "LeftButton", true);
assert_eq!(
s.eval::<i64>("return heard").unwrap(),
0,
"the press alone is not the trigger"
);
let consumed = s.mouse_button(-50.0, -50.0, "LeftButton", false);
assert!(consumed, "a world-drop click consumes the event");
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 1);
assert!(
s.cursor_item().is_some(),
"the payload stays until the popup decides"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn world_click_over_nothing_drops_a_held_item() {
let mut s = drag_script();
s.run(
r#"
heard, name, quality = 0, nil, nil
local f = CreateFrame("Frame", "Listener")
f:RegisterEvent("DELETE_ITEM_CONFIRM")
f:SetScript("OnEvent", function(self, event, n, q) heard = heard + 1; name = n; quality = q end)
"#,
)
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bag: 0,
slot: 1,
item_id: 117,
texture: None,
link: None, // no link ⇒ an empty name, not an error
count: None,
quality: None, // unknown quality ⇒ 0
equip_slots: Vec::new(),
}));
s.mouse_button(-50.0, -50.0, "LeftButton", true);
let consumed = s.mouse_button(-50.0, -50.0, "LeftButton", false); // the completed click
assert!(consumed, "a world drop consumes the event");
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 1);
assert_eq!(s.eval::<String>("return name").unwrap(), "");
assert_eq!(s.eval::<i64>("return quality").unwrap(), 0);
assert!(s.cursor_item().is_some(), "the payload stays held");
}
/// A world object (unit/GameObject) under the cursor suppresses the world drop entirely
/// (decisions 0571 + 0574): the reference's object-leg dispatcher (`0x492ce0`) keeps every
/// real payload and runs SELECT — no `DELETE_ITEM_CONFIRM`, payload untouched. The app
/// feeds the pick (`set_world_pick`); tests/captures default `Nothing`.
#[test]
fn world_click_over_a_world_object_is_not_a_world_drop() {
let mut s = drag_script();
s.run(
r#"
heard = 0
local f = CreateFrame("Frame", "Listener")
f:RegisterEvent("DELETE_ITEM_CONFIRM")
f:SetScript("OnEvent", function() heard = heard + 1 end)
"#,
)
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bag: 0,
slot: 1,
item_id: 117,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
}));
s.set_world_pick(crate::script::WorldPick::Object);
s.mouse_button(-50.0, -50.0, "LeftButton", true);
let consumed = s.mouse_button(-50.0, -50.0, "LeftButton", false);
assert!(
!consumed,
"over an object the click is the world's, not a drop"
);
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 0);
assert!(s.cursor_item().is_some(), "the item payload survives");
}
/// A press over a FRAME whose release lands on the world is neither a click on the frame nor
/// a world click — the payload is untouched.
#[test]
fn frame_press_world_release_is_not_a_world_drop() {
let mut s = drag_script();
s.run(
r#"
heard = 0
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
local f = CreateFrame("Frame", "Listener")
f:RegisterEvent("DELETE_ITEM_CONFIRM")
f:SetScript("OnEvent", function() heard = heard + 1 end)
"#,
)
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bag: 0,
slot: 1,
item_id: 117,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
}));
s.mouse_button(100.0, 300.0, "LeftButton", true); // press on A (no RegisterForDrag)
s.mouse_button(-50.0, -50.0, "LeftButton", false); // release over the world
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 0);
assert!(s.cursor_item().is_some());
}
/// A spell/action payload world-CLICK clears silently (`0x495300`'s non-item arms); a drag
/// release over the world keeps it carrying, same as an item.
#[test]
fn world_click_with_a_spell_payload_clears_silently() {
let mut s = drag_script();
s.run(
r#"
heard = 0
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
a:RegisterForDrag("LeftButton")
local f = CreateFrame("Frame", "Listener")
f:RegisterEvent("DELETE_ITEM_CONFIRM")
f:SetScript("OnEvent", function() heard = heard + 1 end)
"#,
)
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Spell(crate::script::cursor::CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 1,
texture: None,
}));
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_move(110.0, 300.0);
s.mouse_button(-50.0, -50.0, "LeftButton", false); // drag release: keeps carrying
assert!(s.cursor_payload().is_some(), "a drag release never drops");
s.mouse_button(-50.0, -50.0, "LeftButton", true);
s.mouse_button(-50.0, -50.0, "LeftButton", false); // the world click clears
s.tick(0.01);
assert_eq!(
s.eval::<i64>("return heard").unwrap(),
0,
"no delete popup for a spell"
);
assert!(s.cursor_payload().is_none(), "cleared silently");
}
}