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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user