- the action-bar cluster is the reference's own: `MainMenuBar.xml`, `ActionBarFrame.xml`, `MultiActionBars.xml` and `BonusActionBarFrame.xml` run off the player's own chain, and our `ActionBar.xml` (2047 lines), `MultiBars.xml` and `StanceBar.xml` are deleted — three, since the stock bonus-bar file declares the shapeshift bar too; one engine verb, `ChangeActionBarPage`, fires `ACTIONBAR_PAGE_CHANGED` and nothing else; one load blocker: stock `MultiActionBars.lua` writes the options window's table from its OnLoad, the reference's own load-order hack, so our options window moves to the reference's toc slot; and ten things the reference does that ours did not: an empty button is HIDDEN while showgrid is 0, the bonus overlay sits one pixel right of the main buttons, every slide takes one frame more than its time, and `ActionButton_ShowGrid` ghosts EVERY ring, occupied included (1938) - a gift can be wrapped: right-clicking wrapping paper fell through to plain use and sent `CMSG_USE_ITEM`, so nothing happened — you could receive a gift and never give one; the wrap is a displayed-cursor MODE plus a stashed slot, never a payload, so `CursorHasItem()` stays false and every stock gate on it stays shut: right-click arms it, a left-click on a bag slot sends `CMSG_WRAP_ITEM`, and the client applies no eligibility test — the server's `ERR_CANT_WRAP_*` reasons are the error line (1934); a duration-limited item counts down: `SMSG_ITEM_TIME_UPDATE` parks a deadline and the tooltip's `ITEM_DURATION` line runs the enchant countdown's bucket ladder, so "Duration: 1 days" is what the reference renders; an update for an item we do not hold is DROPPED, no queue (1933); and a renamed crowd-control refusal names its mechanic — "Can't do that while stunned" — off `SpellMechanic.dbc` (1943) - a creature paths on a boat: `SMSG_MONSTER_MOVE_TRANSPORT` carries the transport's guid and deck-local coordinates, and is the attach/detach edge; a deck sample lands on the rider or nowhere, never as a world write a few yards from the map origin; and "there is no terrain under a boat" turned out wrong: the ground probe admits GameObject meshes — a boat's `.wmo` is a GameObject, so the probe hits the deck (1936); `MSG_MOVE_TIME_SKIPPED` is read, not dropped — the reference adds the lag to the observed unit's last-wire stamp, so dropping it scheduled their next packet `lag` ms late, a hitch for every player who stalled zoning in near us; and our own settle hold reports once (1935); and the tradeskill tool order was backwards — focus FIRST, so every two-tool recipe had read "Blacksmith Hammer, Anvil" for the reference's "Anvil, Blacksmith Hammer"; the focus's `hasTool` is 1.0 in the reference too (1942) - the crowd's second sitting, instruments first: the probe line carries the tail — cpu p95/p99 and the six worst frames — because a mean under budget hides the one frame in twenty over it (raid legs railed at 16.67 with p95 at 18–20 ms) and whether that frame was work or a wait is the first question about a stutter, and the visible meshes are counted by lane; then two cuts — a terrain tile furnishes as 4×4 cells of 4×4 chunks, one mesh, one entity, one draw per 133-yd cell instead of per 33-yd chunk (a city horizon is over a thousand chunks a frame, and a cell changes nothing a pixel can see; Stormwind 10.55 → 10.32 `cpu_ms`), and an authored point light is our own `WorldPointLight`, not Bevy's `PointLight`, which was only a data carrier while Bevy ran its whole light lane over every lamp, ~2 % of the alone frame; the sitting's record is 1945, and the code says so (1945) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
732 lines
31 KiB
Rust
732 lines
31 KiB
Rust
//! 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.
|
|
///
|
|
/// **The bank-bag band (64..=69) rides this too**, and only through one door: `PutItemInBag`'s
|
|
/// empty-slot leg IS this function in the reference (`0x4c7c00` calls `0x4c7300` there), which is
|
|
/// how a held bag reaches a bank bag slot at all. Its fit rule is one band wider than
|
|
/// `equip_slots` alone — see [`super::bag_verbs::fits_slot`].
|
|
///
|
|
/// - 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) + the six BANK bag slots
|
|
// (64..=69, reached only through `PutItemInBag`); ammo (0) is out of scope.
|
|
if !(1..=23).contains(&id) && !super::bag_verbs::BANK_BAG_INV_SLOTS.contains(&id) {
|
|
return false;
|
|
}
|
|
// The targeting cursor's item half, on the doll (decision 0923). The reference's doll pickup
|
|
// `0x4c7300` carries the byte-IDENTICAL rung to the bag one — `4c76df: call 0x6e48a0`
|
|
// (IsTargeting), `4c76e8: call 0x6e6330` (TargetingWantsItem), `4c76fb: call 0x495d60` (bind
|
|
// this item), then return with nothing picked up — and it is what makes poisoning or
|
|
// sharpening the weapon you are *wearing* possible at all. Reported in the ONE bag space
|
|
// ([`EQUIPMENT_BAG`] + the 1-based slot), so the app's drain resolves both seams identically.
|
|
// A held payload wins, exactly as in the bag path (the ref's own `4c73af` pair precedes it).
|
|
if model.item_pick_armed && model.cursor.is_none() {
|
|
model.item_picks.push((EQUIPMENT_BAG, id));
|
|
return false;
|
|
}
|
|
match model.cursor.take() {
|
|
None => {
|
|
let picked = model
|
|
.inv_slot("player", id as usize)
|
|
.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,
|
|
bar_placeable: s.bar_placeable,
|
|
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() || !super::bag_verbs::fits_slot(&held, id) {
|
|
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
|
|
}
|
|
// Mode 5 — the buy, aimed at an equipment slot. `0x4c78fd` tests `[0xb4d900] == 5` inside
|
|
// the paper-doll click and sends the same `CMSG_BUY_ITEM_IN_SLOT 0x1a3` the bag drop does;
|
|
// the server equips it or refuses. Reported in the ONE bag space ([`EQUIPMENT_BAG`] + the
|
|
// 1-based inventory slot), the seam this file already uses for every other doll intent.
|
|
//
|
|
// Of the reference's three drop consumers this is the one that handles a stale vendor row
|
|
// correctly — the other two dereference it (`0x4f9f35`, `0x4c7ea5`) — so its refusal is
|
|
// what we reproduce in all three.
|
|
Some(CursorPayload::Merchant(held)) => {
|
|
let entry = model
|
|
.merchant
|
|
.as_ref()
|
|
.and_then(|m| m.items.get(held.row as usize))
|
|
.filter(|it| it.item_id == held.item_id)
|
|
.map(|it| it.item_id);
|
|
if let Some(entry) = entry {
|
|
model.merchant_slot_buys.push((EQUIPMENT_BAG, id, entry));
|
|
}
|
|
queue_cursor_update(model);
|
|
true
|
|
}
|
|
Some(
|
|
other @ (CursorPayload::Spell(_)
|
|
| CursorPayload::Action(_)
|
|
| CursorPayload::Macro(_)
|
|
| CursorPayload::PetAction(_)
|
|
// Mode 10 (decision 1677) — a stabled pet refuses a bag/doll slot exactly as the
|
|
// spell/action family does, and stays on the cursor for the stable window to take.
|
|
| CursorPayload::StablePet(_)),
|
|
) => {
|
|
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)) => super::bag_verbs::fits_slot(item, 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).
|
|
///
|
|
/// Reads through [`Model::inv_slot`], not the doll array, so it answers over the WHOLE live-id
|
|
/// space that binding covers — the reference's `BankFrameItemButton_UpdateLock` calls it with
|
|
/// `BankButtonIDToInvSlotID(...)` for every bank slot, and a lock the vault feed reports has to
|
|
/// dim the button the same as an equipped one.
|
|
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.inv_slot("player", i))
|
|
.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 {
|
|
duration_ms: None,
|
|
already_bound: false,
|
|
bar_placeable: true,
|
|
durability: None,
|
|
flags: 0,
|
|
item_id: 1234,
|
|
icon: Some("Interface\\Icons\\INV_Helmet_01".into()),
|
|
count: 1,
|
|
contents_count: None,
|
|
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,
|
|
enchants: Vec::new(),
|
|
});
|
|
slots[11] = Some(InvSlotView {
|
|
duration_ms: None,
|
|
already_bound: false,
|
|
bar_placeable: true,
|
|
durability: None,
|
|
flags: 0,
|
|
item_id: 555,
|
|
icon: Some("Interface\\Icons\\INV_Jewelry_Ring_01".into()),
|
|
count: 1,
|
|
contents_count: None,
|
|
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,
|
|
enchants: Vec::new(),
|
|
});
|
|
slots[19] = Some(InvSlotView {
|
|
duration_ms: None,
|
|
already_bound: false,
|
|
bar_placeable: true,
|
|
durability: None,
|
|
flags: 0,
|
|
item_id: 999,
|
|
icon: Some("Interface\\Icons\\INV_Shirt_White_01".into()),
|
|
count: 1,
|
|
contents_count: None,
|
|
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,
|
|
enchants: Vec::new(),
|
|
});
|
|
slots
|
|
}
|
|
|
|
fn one_fitting_bag_item() -> ContainerState {
|
|
let mut slots = std::collections::HashMap::new();
|
|
slots.insert(
|
|
1,
|
|
ContainerSlot {
|
|
duration_ms: None,
|
|
petition: None,
|
|
already_bound: false,
|
|
bar_placeable: true,
|
|
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,
|
|
enchants: Vec::new(),
|
|
},
|
|
);
|
|
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 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("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 {
|
|
duration_ms: None,
|
|
already_bound: false,
|
|
bar_placeable: true,
|
|
durability: None,
|
|
flags: 0,
|
|
item_id: 4496,
|
|
icon: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
|
|
count: 1,
|
|
contents_count: None,
|
|
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,
|
|
enchants: Vec::new(),
|
|
});
|
|
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 {
|
|
duration_ms: None,
|
|
petition: None,
|
|
already_bound: false,
|
|
bar_placeable: true,
|
|
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,
|
|
enchants: Vec::new(),
|
|
},
|
|
);
|
|
s.set_container(
|
|
0,
|
|
Some(ContainerState {
|
|
name: Some("Backpack".into()),
|
|
num_slots: 16,
|
|
slots,
|
|
}),
|
|
);
|
|
s.run("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("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 {
|
|
passive: false,
|
|
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("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("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,
|
|
}]
|
|
);
|
|
}
|
|
|
|
/// The targeting cursor's item half reroutes BOTH pickup seams (decision 0923), and the
|
|
/// held-payload check precedes it in both — the reference's own order (`4f9c38` before
|
|
/// `4f9c54`; `4c73af` before `4c76df`). Without the doll seam a rogue could not poison the
|
|
/// weapon they are wearing; without the payload gate, dragging an item across a bag while a
|
|
/// poison is armed would silently fire the cast at whatever you dropped it on.
|
|
#[test]
|
|
fn the_armed_item_half_reroutes_bag_and_doll_clicks() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_inventory_slots(doll_slots());
|
|
s.set_container(0, Some(one_fitting_bag_item()));
|
|
|
|
// Unarmed, both seams do their ordinary gesture and queue nothing.
|
|
assert!(s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
|
|
assert!(s.cursor_item().is_some());
|
|
assert!(s.take_item_picks().is_empty());
|
|
|
|
// Armed but HOLDING: the payload wins, exactly as the reference orders it.
|
|
s.set_item_pick_armed(true);
|
|
s.run("PickupContainerItem(0, 1)").unwrap();
|
|
assert!(
|
|
s.take_item_picks().is_empty(),
|
|
"a click while carrying an item is a place, not a bind"
|
|
);
|
|
|
|
// Armed with an empty cursor: both seams bind instead of picking up, and neither
|
|
// disturbs the cursor.
|
|
s.run("ClearCursor()").unwrap();
|
|
assert!(s.cursor_item().is_none());
|
|
assert!(!s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
|
|
assert!(!s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
|
|
assert_eq!(
|
|
s.take_item_picks(),
|
|
vec![(EQUIPMENT_BAG, 1), (0, 1)],
|
|
"the doll reports in the ONE bag space; the bag reports its own id"
|
|
);
|
|
assert!(s.cursor_item().is_none(), "a bind never stages a payload");
|
|
}
|
|
}
|