`PickupPetAction` lifted a pet action onto the cursor while the pet was out of the client's view, because the pickup gate read an unstreamed pet's missing flags as "not possessed". The reference's `PickupPetAction` (`0x4be180`) looks the pet up first (`0x4be1f7`). With no object it returns at once (`0x4be1fe`), and only then does it test `UNIT_FLAG_POSSESSED` (`0x4be20a`). The gate now needs the pet's object before the possessed test, and three comments that cited a mid-instruction address now cite these. Tests: with the pet out of view, a pickup lifts nothing and a held action stays on the cursor. An in-view pet still lifts, and a possessed one does not. Fixes #688 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1094 lines
41 KiB
Rust
1094 lines
41 KiB
Rust
//! The pet bindings: the action bar `PetActionBarFrame.lua` drives, the stat block the pet frames
|
|
//! read, and the pet menu's predicates and verbs. The app resolves every slot and pushes it
|
|
//! ([`super::UiScript::set_pet_actions`]); presses queue back out for the app to send. A token
|
|
//! slot's name and texture are the names of globals, which the stock Lua resolves
|
|
//! (`PetActionBarFrame.lua:98-104`). Booleans are 1/nil and cooldowns go cold once elapsed, as in
|
|
//! [`super::action`].
|
|
|
|
use mlua::{Lua, MultiValue, Value};
|
|
|
|
use super::Model;
|
|
|
|
// The slot-word values the one-shot orders synthesize, restated from
|
|
// `benilla_protocol::messages::pet` because this crate does not depend on the protocol.
|
|
const PET_ACT_COMMAND: u8 = 0x07;
|
|
const PET_ACT_REACTION: u8 = 0x06;
|
|
const PET_COMMAND_STAY: u32 = 0;
|
|
const PET_COMMAND_FOLLOW: u32 = 1;
|
|
const PET_COMMAND_ATTACK: u32 = 2;
|
|
const PET_REACT_PASSIVE: u32 = 0;
|
|
const PET_REACT_DEFENSIVE: u32 = 1;
|
|
const PET_REACT_AGGRESSIVE: u32 = 2;
|
|
|
|
/// A slot word as the server packs it: the type in the top byte, the action in the low bits.
|
|
const fn order(kind: u8, action: u32) -> u32 {
|
|
(kind as u32) << 24 | action
|
|
}
|
|
|
|
/// One pet bar slot, resolved by the app.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct PetActionView {
|
|
/// `GetPetActionInfo`'s first return and the occupancy test: `None` hides the button
|
|
/// (`PetActionBarFrame.lua:122-128`). The spell's name, or for a token the name of a global.
|
|
pub name: Option<String>,
|
|
/// The second return: the spell's rank line, `None` for a token.
|
|
pub subtext: Option<String>,
|
|
/// The third return: an icon path, or for a token the name of a global.
|
|
pub texture: Option<String>,
|
|
/// A command or reaction token, whose `name` and `texture` are global names.
|
|
pub is_token: bool,
|
|
/// The slot's spell, for `GameTooltip:SetPetAction`; not a `GetPetActionInfo` return.
|
|
pub spell_id: Option<u32>,
|
|
/// The checked ring.
|
|
pub active: bool,
|
|
/// The slot can autocast: the static `UI-AutoCastableOverlay` ring.
|
|
pub autocast_allowed: bool,
|
|
/// Autocast is on: the sparkle trail.
|
|
pub autocast_enabled: bool,
|
|
/// `IsPetAttackActive`: a left click calls the pet off instead of running the slot.
|
|
pub attack_active: bool,
|
|
/// `(start_ms on the GetTime clock, duration_ms, enabled)`, as [`super::action::ActionState`].
|
|
pub cooldown: Option<(i64, u32, bool)>,
|
|
/// The slot's packed word, verbatim, for the drag ([`super::cursor::pet`]): the reference
|
|
/// compares occupants under `& 0x3FFFFFFF` and writes the source word through unchanged
|
|
/// (`0x4bc9a0`, `0x4bce00`). `0` is the empty slot.
|
|
pub packed: u32,
|
|
/// `SPELL_ATTR_PASSIVE` (`Attributes & 0x40`) on a spell slot: the drop silently refuses a
|
|
/// passive source (`0x4bc9f8`-`0x4bca2e`).
|
|
pub passive: bool,
|
|
}
|
|
|
|
/// [`PetActionView`] as stored, its cooldown converted to `GetTime` seconds at push time.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub(crate) struct StoredPetAction {
|
|
pub(crate) view: PetActionView,
|
|
pub(crate) cooldown: Option<(f64, f64, bool)>,
|
|
}
|
|
|
|
/// The pet stat block: the four hunter stat bindings and `HasPetUI`'s second return
|
|
/// (`0x4be670`), plus the family word, icon and diet. The stats share one hunter-pet gate
|
|
/// (`0x6116e0`) and fail their own ways: loyalty to nil, the pairs to `(0, 0)`, happiness to
|
|
/// `(nil, 100.0, 0.0)`. The family word has no gate (`0x51a310`); the diet shares the stats'.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct PetStats {
|
|
/// `HasPetUI`'s second return; false makes every gated binding answer its failure form.
|
|
pub hunter_pet: bool,
|
|
/// `GetPetHappiness`'s first return, bucketed `0..=3` by the client; `Some(0)` is not nil.
|
|
pub happiness: Option<u32>,
|
|
/// Return 2: the damage percentage, already scaled by 100.
|
|
pub damage_percentage: f32,
|
|
/// Return 3: the loyalty rate, unscaled and possibly negative.
|
|
pub loyalty_rate: f32,
|
|
/// `GetPetLoyalty`: the `PetLoyalty.dbc` name verbatim, `"(Loyalty Level N) "` prefix included.
|
|
pub loyalty: Option<String>,
|
|
/// `GetPetTrainingPoints`: `(totalPoints, spent)`, high word first.
|
|
pub training_points: (u16, u16),
|
|
/// `GetPetExperience`: `(currXP, nextXP)`.
|
|
pub experience: (u32, u32),
|
|
/// `UnitCreatureFamily("pet")`: the localized `CreatureFamily.dbc` word off the cached creature
|
|
/// record (`0x51a310`). `None` is nil: no record yet, id 0, or an id with no row (10, 13, 14,
|
|
/// 18 and 22 have none). `PetPaperDollFrame.lua:68-70` guards its level line on it.
|
|
pub family: Option<String>,
|
|
/// `GetPetIcon()`: the family row's `CreatureFamily.dbc` icon, `None` on the family word's
|
|
/// misses. Ungated like the family word; whether the reference gates it (`0x4beb10`) is
|
|
/// untraced, and no warlock reaches its stock callers (`PetStable.lua:51`, `161-162`).
|
|
pub icon: Option<String>,
|
|
/// `GetPetFoodTypes()`: the diet names the family's food mask selects, in record order
|
|
/// (`0x4bea10`: bit `1 << (id - 1)` of `CreatureFamily` column 7, names from `ItemPetFood`).
|
|
/// Empty for a zero mask (every warlock minion) or a failed hunter gate, which the app applies.
|
|
pub food_types: Vec<String>,
|
|
}
|
|
|
|
/// The pushed pet state: the bar's slots and bar-wide bits, the stat block, the menu predicates.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub(crate) struct PetBarState {
|
|
/// `PetHasActionBar()`: there is a bar, even one of ten empty slots.
|
|
pub(crate) has_bar: bool,
|
|
/// `GetPetActionsUsable()` (`0x4bcf70`): false desaturates the whole bar, and refuses the
|
|
/// drag's bar writes with it (`0x4bc9d0`).
|
|
pub(crate) actions_usable: bool,
|
|
pub(crate) slots: Vec<StoredPetAction>,
|
|
/// `HasPetUI`'s first return: a pet with a nonzero `UNIT_FIELD_PETNUMBER` (`0x4be697`). Not
|
|
/// `has_bar`, whose gate is the cached guid alone.
|
|
pub(crate) has_ui: bool,
|
|
pub(crate) stats: PetStats,
|
|
/// `PickupPetAction`'s gate: the pet's object resolves (`0x4be1f7`) and `UNIT_FLAG_POSSESSED`
|
|
/// is clear (`0x4be20a`), which blocks the drop as well as the pick-up. Not `actions_usable`:
|
|
/// possession does not grey the bar, and the pick-up itself does not ask it. The writes at
|
|
/// both ends do (`0x4bc9d0`), so a greyed bar with its pet in view still lifts an action onto
|
|
/// the cursor and then writes nothing.
|
|
pub(crate) pickup_allowed: bool,
|
|
/// `PetCanBeAbandoned()`: a kept pet rather than a summon. It forks the pet menu: paperdoll,
|
|
/// rename and abandon show when true, dismiss only when false (`UnitPopup.lua:402-417`).
|
|
pub(crate) can_be_abandoned: bool,
|
|
/// `PetCanBeRenamed()`, ANDed with the above for the rename row; set until the first rename.
|
|
pub(crate) can_be_renamed: bool,
|
|
/// The charm or possess expiry `[0xb714a8]` on the `GetTime` clock, in seconds: the last
|
|
/// `SMSG_PET_SPELLS` duration past its arrival, `None` for a packet whose duration is 0.
|
|
pub(crate) expiry: Option<f64>,
|
|
}
|
|
|
|
impl super::UiScript {
|
|
/// Push the whole pet bar; the app diffs and fires `PET_BAR_UPDATE`.
|
|
pub fn set_pet_actions(
|
|
&mut self,
|
|
has_bar: bool,
|
|
actions_usable: bool,
|
|
pickup_allowed: bool,
|
|
slots: Vec<PetActionView>,
|
|
) {
|
|
// Field by field: the stat block and the menu predicates beside them move on other clocks.
|
|
let bar = &mut self.model_mut().pet_bar;
|
|
bar.has_bar = has_bar;
|
|
bar.actions_usable = actions_usable;
|
|
bar.pickup_allowed = pickup_allowed;
|
|
bar.slots = slots
|
|
.into_iter()
|
|
.map(|view| {
|
|
// The start is already on the `GetTime` clock, in ms.
|
|
let cooldown = view.cooldown.map(|(start_ms, duration_ms, enabled)| {
|
|
(
|
|
start_ms as f64 / 1000.0,
|
|
f64::from(duration_ms) / 1000.0,
|
|
enabled,
|
|
)
|
|
});
|
|
StoredPetAction { view, cooldown }
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
/// Push the stat block and `HasPetUI`'s first return, which move on descriptor updates rather
|
|
/// than with the bar.
|
|
pub fn set_pet_stats(&mut self, has_ui: bool, stats: PetStats) {
|
|
let bar = &mut self.model_mut().pet_bar;
|
|
bar.has_ui = has_ui;
|
|
bar.stats = stats;
|
|
}
|
|
|
|
/// Push the pet's expiry on the `GetTime` clock, `None` for none: `SetPet` (`0x4bc7e0`)
|
|
/// stores `OsTick() + duration` for each `SMSG_PET_SPELLS` whose duration is not 0, else 0.
|
|
pub fn set_pet_expiry(&mut self, expiry: Option<f64>) {
|
|
self.model_mut().pet_bar.expiry = expiry;
|
|
}
|
|
|
|
/// Push the menu's two predicates, which move with the pet's `UNIT_FIELD_FLAGS`.
|
|
pub fn set_pet_menu(&mut self, can_be_abandoned: bool, can_be_renamed: bool) {
|
|
let bar = &mut self.model_mut().pet_bar;
|
|
bar.can_be_abandoned = can_be_abandoned;
|
|
bar.can_be_renamed = can_be_renamed;
|
|
}
|
|
|
|
/// Take the `CastPetAction` calls out of the call stream, as 1-based slots; the app decides
|
|
/// what each one sends.
|
|
pub fn take_pet_actions(&mut self) -> Vec<u32> {
|
|
self.take_calls_where(|c| match c {
|
|
super::ScriptCall::PetAction(slot) => Some(*slot),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// Drain the 1-based slot indices `TogglePetAutocast` queued.
|
|
pub fn take_pet_autocast_toggles(&mut self) -> Vec<u32> {
|
|
std::mem::take(&mut self.model_mut().pet_autocast_toggles)
|
|
}
|
|
|
|
/// Drain the count of `PetStopAttack()` calls.
|
|
pub fn take_pet_stop_attacks(&mut self) -> u32 {
|
|
std::mem::replace(&mut self.model_mut().pet_stop_attacks, 0)
|
|
}
|
|
|
|
/// Take the one-shot orders (`PetAttack` and the rest) out of the call stream, each a packed
|
|
/// slot word.
|
|
pub fn take_pet_orders(&mut self) -> Vec<u32> {
|
|
self.take_calls_where(|c| match c {
|
|
super::ScriptCall::PetOrder(packed) => Some(*packed),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// Set the flag `HasFullControl` answers.
|
|
pub fn set_player_control(&mut self, in_control: bool) {
|
|
self.model_mut().player_control = in_control;
|
|
}
|
|
|
|
/// Drain the drag's bar writes, already applied here: one `Vec` per `CMSG_PET_SET_ACTION` of
|
|
/// one or two `(0-based position, packed word)` pairs. Send each whole: the server tells the
|
|
/// two forms apart by body size.
|
|
pub fn take_pet_set_actions(&mut self) -> Vec<Vec<(u32, u32)>> {
|
|
std::mem::take(&mut self.model_mut().pet_set_actions)
|
|
}
|
|
|
|
/// Drain the `PetAbandon()` and `PetDismiss()` call counts, as `(abandons, dismisses)`.
|
|
pub fn take_pet_gives_up(&mut self) -> (u32, u32) {
|
|
let m = &mut *self.model_mut();
|
|
(
|
|
std::mem::replace(&mut m.pet_abandons, 0),
|
|
std::mem::replace(&mut m.pet_dismisses, 0),
|
|
)
|
|
}
|
|
|
|
/// Drain the names `PetRename(name)` queued, in order.
|
|
pub fn take_pet_renames(&mut self) -> Vec<String> {
|
|
std::mem::take(&mut self.model_mut().pet_renames)
|
|
}
|
|
}
|
|
|
|
/// The stored slot at a 1-based button index.
|
|
fn slot_at(model: &Model, i: u32) -> Option<&StoredPetAction> {
|
|
usize::try_from(i.checked_sub(1)?)
|
|
.ok()
|
|
.and_then(|n| model.pet_bar.slots.get(n))
|
|
}
|
|
|
|
/// Register the pet-bar globals.
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
let flag = |b: bool| if b { Value::Integer(1) } else { Value::Nil };
|
|
|
|
// GetPetTimeRemaining() (`0x4be600`): nil with no expiry, else the milliseconds to it as an
|
|
// unsigned 32-bit tick difference (`fild qword` over a zero high dword), so one past its
|
|
// expiry wraps to about 4.29e9 until the next packet. The stock caller is commented out
|
|
// (`PetFrame.lua:91-97`).
|
|
g.set(
|
|
"GetPetTimeRemaining",
|
|
lua.create_function(|lua, ()| {
|
|
let expiry = lua
|
|
.app_data_ref::<Model>()
|
|
.expect("model app_data")
|
|
.pet_bar
|
|
.expiry;
|
|
let Some(expiry) = expiry else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let now = crate::script::clock::now(lua);
|
|
#[allow(clippy::cast_possible_truncation)] // a tick difference, wrapped as the u32 is
|
|
let ms = ((expiry - now) * 1000.0).round() as i64 as u32;
|
|
Ok(Value::Number(f64::from(ms)))
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"PetHasActionBar",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(flag(model.pet_bar.has_bar))
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"GetPetActionsUsable",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(flag(model.pet_bar.actions_usable))
|
|
})?,
|
|
)?;
|
|
|
|
// An out-of-range index answers a single nil, which the Lua reads as an empty slot.
|
|
g.set(
|
|
"GetPetActionInfo",
|
|
lua.create_function(move |lua, i: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(slot) = slot_at(&model, i) else {
|
|
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
|
};
|
|
let v = &slot.view;
|
|
let text = |s: &Option<String>| match s {
|
|
Some(s) => Ok(Value::String(lua.create_string(s)?)),
|
|
None => Ok::<_, mlua::Error>(Value::Nil),
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
text(&v.name)?,
|
|
text(&v.subtext)?,
|
|
text(&v.texture)?,
|
|
flag(v.is_token),
|
|
flag(v.active),
|
|
flag(v.autocast_allowed),
|
|
flag(v.autocast_enabled),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// An elapsed or absent cooldown answers (0, 0, 1), so a re-feed never replays the sweep.
|
|
g.set(
|
|
"GetPetActionCooldown",
|
|
lua.create_function(|lua, i: u32| {
|
|
let now = crate::script::clock::now(lua);
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(match slot_at(&model, i).and_then(|s| s.cooldown) {
|
|
Some((start, duration, enabled)) if start + duration > now || !enabled => {
|
|
(start, duration, i32::from(enabled))
|
|
}
|
|
_ => (0.0, 0.0, 1),
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// The one pet binding that answers a real boolean (`0x6f39f0`), `false` even out of range.
|
|
g.set(
|
|
"IsPetAttackActive",
|
|
lua.create_function(move |lua, i: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(slot_at(&model, i).is_some_and(|s| s.view.attack_active))
|
|
})?,
|
|
)?;
|
|
|
|
// An empty slot, whose button shows only under show-grid, queues nothing; the reference sends
|
|
// its zero word (`0x4bd230` falls to the send at `0x4bd444`), which vmangos ignores
|
|
// (`PetHandler.cpp:178`).
|
|
g.set(
|
|
"CastPetAction",
|
|
lua.create_function(|lua, i: u32| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
if slot_at(&model, i).is_some_and(|s| s.view.name.is_some()) {
|
|
model.script_calls.push(super::ScriptCall::PetAction(i));
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// Only a slot that can autocast queues: the wire verb names a spell, and a token has none.
|
|
g.set(
|
|
"TogglePetAutocast",
|
|
lua.create_function(|lua, i: u32| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
if slot_at(&model, i).is_some_and(|s| s.view.autocast_allowed) {
|
|
model.pet_autocast_toggles.push(i);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// Two returns on every path (`0x4be670`), `(nil, nil)` with no pet UI.
|
|
g.set(
|
|
"HasPetUI",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let bar = &model.pet_bar;
|
|
Ok((flag(bar.has_ui), flag(bar.has_ui && bar.stats.hunter_pet)))
|
|
})?,
|
|
)?;
|
|
|
|
// The client buckets return 1 off `PetPersonality.dbc` (`0x4be900`), and bucket 0 is the
|
|
// number 0; only the gate failure is nil, and even then returns 2 and 3 are `(100.0, 0.0)`.
|
|
g.set(
|
|
"GetPetHappiness",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let s = &model.pet_bar.stats;
|
|
let happiness = match s.happiness.filter(|_| s.hunter_pet) {
|
|
Some(b) => Value::Integer(i64::from(b)),
|
|
None => Value::Nil,
|
|
};
|
|
let (dmg, rate) = if happiness == Value::Nil {
|
|
(100.0, 0.0)
|
|
} else {
|
|
(s.damage_percentage, s.loyalty_rate)
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
happiness,
|
|
Value::Number(f64::from(dmg)),
|
|
Value::Number(f64::from(rate)),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// The one stat binding that fails to nil, level 0 included.
|
|
g.set(
|
|
"GetPetLoyalty",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let s = &model.pet_bar.stats;
|
|
Ok(match s.loyalty.as_deref().filter(|_| s.hunter_pet) {
|
|
Some(name) => Value::String(lua.create_string(name)?),
|
|
None => Value::Nil,
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// The high then low word of one packed dword; a gate failure is (0, 0), not nil.
|
|
g.set(
|
|
"GetPetTrainingPoints",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let s = &model.pet_bar.stats;
|
|
let (total, spent) = if s.hunter_pet {
|
|
s.training_points
|
|
} else {
|
|
(0, 0)
|
|
};
|
|
Ok((f64::from(total), f64::from(spent)))
|
|
})?,
|
|
)?;
|
|
|
|
// Numbers on every path; the client reads both unsigned, so neither is negative.
|
|
g.set(
|
|
"GetPetExperience",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let s = &model.pet_bar.stats;
|
|
let (cur, next) = if s.hunter_pet { s.experience } else { (0, 0) };
|
|
Ok((f64::from(cur), f64::from(next)))
|
|
})?,
|
|
)?;
|
|
|
|
// One return on every path, with no class gate (`0x51a310`). Answers the `"pet"` token only:
|
|
// the reference resolves any unit off its cached creature record (`[[unit+0xb30]+0x1c]`), so
|
|
// a wild boar's `"target"` answers "Boar" there and nil here. The pet page is the only stock
|
|
// caller.
|
|
g.set(
|
|
"UnitCreatureFamily",
|
|
lua.create_function(move |lua, token: Option<String>| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let family = token
|
|
.filter(|t| t.eq_ignore_ascii_case("pet"))
|
|
.and_then(|_| model.pet_bar.stats.family.as_deref());
|
|
Ok(match family {
|
|
Some(name) => Value::String(lua.create_string(name)?),
|
|
None => Value::Nil,
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// A real nil, never "": the stable reads it as a predicate and as a texture
|
|
// (`PetStable.lua:51`, `161-162`), and an empty path would draw white.
|
|
g.set(
|
|
"GetPetIcon",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(match model.pet_bar.stats.icon.as_deref() {
|
|
Some(path) => Value::String(lua.create_string(path)?),
|
|
None => Value::Nil,
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// One return per diet and never a nil (`0x4bea10`): an empty diet is zero values, for which
|
|
// `BuildListString` answers nil (`PetPaperDollFrame.xml:269`).
|
|
g.set(
|
|
"GetPetFoodTypes",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let values: Vec<Value> = model
|
|
.pet_bar
|
|
.stats
|
|
.food_types
|
|
.iter()
|
|
.map(|f| lua.create_string(f).map(Value::String))
|
|
.collect::<mlua::Result<_>>()?;
|
|
Ok(MultiValue::from_vec(values))
|
|
})?,
|
|
)?;
|
|
|
|
// The six one-shot orders (`0x4be450`..`0x4be4a0`) each write the slot word the bar's own
|
|
// reaction or command slot carries and re-enter `0x4bd1d0`, as a `CastPetAction` press does;
|
|
// PetAttack takes the current selection. With no pet bar the order is silently dropped.
|
|
for (name, packed) in [
|
|
("PetPassiveMode", order(PET_ACT_REACTION, PET_REACT_PASSIVE)),
|
|
(
|
|
"PetDefensiveMode",
|
|
order(PET_ACT_REACTION, PET_REACT_DEFENSIVE),
|
|
),
|
|
(
|
|
"PetAggressiveMode",
|
|
order(PET_ACT_REACTION, PET_REACT_AGGRESSIVE),
|
|
),
|
|
("PetWait", order(PET_ACT_COMMAND, PET_COMMAND_STAY)),
|
|
("PetFollow", order(PET_ACT_COMMAND, PET_COMMAND_FOLLOW)),
|
|
("PetAttack", order(PET_ACT_COMMAND, PET_COMMAND_ATTACK)),
|
|
] {
|
|
g.set(
|
|
name,
|
|
lua.create_function(move |lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
if model.pet_bar.has_bar {
|
|
model.script_calls.push(super::ScriptCall::PetOrder(packed));
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
}
|
|
|
|
g.set(
|
|
"PetStopAttack",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.pet_stop_attacks += 1;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// ── The pet menu ─────────────────────────────────────────────────────────
|
|
|
|
g.set(
|
|
"PetCanBeAbandoned",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(flag(model.pet_bar.can_be_abandoned))
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"PetCanBeRenamed",
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(flag(model.pet_bar.can_be_renamed))
|
|
})?,
|
|
)?;
|
|
|
|
// The `ABANDON_PET` popup's OnAccept, after its confirm.
|
|
g.set(
|
|
"PetAbandon",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.pet_abandons += 1;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// The menu row itself, with no confirm (`UnitPopup.lua:591`).
|
|
g.set(
|
|
"PetDismiss",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.pet_dismisses += 1;
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// A number coerces, as `lua_tostring` does (`0x6f3690` → `0x6f7c80`); an empty or over-long
|
|
// name is still queued, since the reference raises `ERR_NULL_PETNAME` or truncates at send.
|
|
g.set(
|
|
"PetRename",
|
|
lua.create_function(|lua, name: Option<mlua::String>| {
|
|
let Some(name) = name else {
|
|
return Ok(());
|
|
};
|
|
let name = name.to_str()?.to_string();
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
model.pet_renames.push(name);
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{PetActionView, PetStats};
|
|
use crate::script::UiScript;
|
|
|
|
/// A charmed or possessed unit's time left in ms, nil for none, and the unsigned wrap once
|
|
/// the expiry has passed (`0x4be634`).
|
|
#[test]
|
|
fn pet_time_remaining_counts_down_to_the_expiry() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert!(s
|
|
.eval::<Option<f64>>("return GetPetTimeRemaining()")
|
|
.unwrap()
|
|
.is_none());
|
|
s.tick(10.0);
|
|
s.set_pet_expiry(Some(12.5));
|
|
assert_eq!(
|
|
s.eval::<f64>("return GetPetTimeRemaining()").unwrap(),
|
|
2500.0
|
|
);
|
|
s.tick(2.0);
|
|
assert_eq!(
|
|
s.eval::<f64>("return GetPetTimeRemaining()").unwrap(),
|
|
500.0
|
|
);
|
|
s.tick(1.0);
|
|
assert_eq!(
|
|
s.eval::<f64>("return GetPetTimeRemaining()").unwrap(),
|
|
f64::from(u32::MAX - 499),
|
|
"0.5 s past it"
|
|
);
|
|
s.set_pet_expiry(None);
|
|
assert!(s
|
|
.eval::<Option<f64>>("return GetPetTimeRemaining()")
|
|
.unwrap()
|
|
.is_none());
|
|
}
|
|
|
|
/// Attack (a token, attacking), Claw (a spell, autocasting, cooling down), an empty slot.
|
|
fn slots() -> Vec<PetActionView> {
|
|
vec![
|
|
PetActionView {
|
|
name: Some("PET_ACTION_ATTACK".into()),
|
|
texture: Some("PET_ATTACK_TEXTURE".into()),
|
|
is_token: true,
|
|
active: true,
|
|
attack_active: true,
|
|
..Default::default()
|
|
},
|
|
PetActionView {
|
|
name: Some("Claw".into()),
|
|
subtext: Some("Rank 3".into()),
|
|
texture: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
|
|
autocast_allowed: true,
|
|
autocast_enabled: true,
|
|
cooldown: Some((9400, 1500, true)),
|
|
..Default::default()
|
|
},
|
|
PetActionView::default(),
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn slot_info_reads_and_out_of_range_is_one_nil() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert!(s.eval::<bool>("return PetHasActionBar() == nil").unwrap());
|
|
assert!(s.eval::<bool>("return GetPetActionInfo(1) == nil").unwrap());
|
|
|
|
s.set_pet_actions(true, true, true, slots());
|
|
assert_eq!(s.eval::<i64>("return PetHasActionBar()").unwrap(), 1);
|
|
assert_eq!(s.eval::<i64>("return GetPetActionsUsable()").unwrap(), 1);
|
|
|
|
let (name, subtext, texture, is_token, active, allowed, enabled) = s
|
|
.eval::<(
|
|
String,
|
|
Option<String>,
|
|
String,
|
|
Option<i64>,
|
|
Option<i64>,
|
|
Option<i64>,
|
|
Option<i64>,
|
|
)>("return GetPetActionInfo(1)")
|
|
.unwrap();
|
|
assert_eq!(
|
|
(
|
|
name.as_str(),
|
|
subtext,
|
|
texture.as_str(),
|
|
is_token,
|
|
active,
|
|
allowed,
|
|
enabled
|
|
),
|
|
(
|
|
"PET_ACTION_ATTACK",
|
|
None,
|
|
"PET_ATTACK_TEXTURE",
|
|
Some(1),
|
|
Some(1),
|
|
None,
|
|
None
|
|
)
|
|
);
|
|
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local n, sub, tex, tok, act, allow, on = GetPetActionInfo(2) \
|
|
return n == 'Claw' and sub == 'Rank 3' and tok == nil and act == nil \
|
|
and allow == 1 and on == 1 and string.find(tex, 'Icons') ~= nil"
|
|
)
|
|
.unwrap());
|
|
|
|
// The empty slot has returns, unlike the out-of-range single nil, but no name.
|
|
assert!(s
|
|
.eval::<bool>("local n, _, tex = GetPetActionInfo(3) return n == nil and tex == nil")
|
|
.unwrap());
|
|
assert!(s.eval::<bool>("return GetPetActionInfo(4) == nil").unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn cooldown_triple_stamps_to_the_vm_clock_and_goes_cold() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.tick(10.0); // GetTime == 10
|
|
s.set_pet_actions(true, true, true, slots());
|
|
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(1)")
|
|
.unwrap(),
|
|
(0.0, 0.0, 1),
|
|
"no cooldown reads cold"
|
|
);
|
|
let (start, duration, enable) = s
|
|
.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)")
|
|
.unwrap();
|
|
assert!((start - 9.4).abs() < 1e-9, "start {start}");
|
|
assert!((duration - 1.5).abs() < 1e-9);
|
|
assert_eq!(enable, 1);
|
|
|
|
s.tick(2.0); // now == 12 > 9.4 + 1.5
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)")
|
|
.unwrap(),
|
|
(0.0, 0.0, 1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn intents_queue_and_the_meaningless_ones_are_dropped() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_pet_actions(true, true, true, slots());
|
|
|
|
s.run("CastPetAction(1) CastPetAction(3) CastPetAction(9)")
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.take_pet_actions(),
|
|
vec![1],
|
|
"empty + out-of-range dropped"
|
|
);
|
|
assert!(s.take_pet_actions().is_empty(), "drain empties");
|
|
|
|
s.run("TogglePetAutocast(1) TogglePetAutocast(2)").unwrap();
|
|
assert_eq!(
|
|
s.take_pet_autocast_toggles(),
|
|
vec![2],
|
|
"only the autocastable slot"
|
|
);
|
|
|
|
assert_eq!(s.take_pet_stop_attacks(), 0);
|
|
s.run("PetStopAttack() PetStopAttack()").unwrap();
|
|
assert_eq!(s.take_pet_stop_attacks(), 2);
|
|
assert_eq!(s.take_pet_stop_attacks(), 0, "drain empties");
|
|
}
|
|
|
|
#[test]
|
|
fn attack_active_is_a_per_slot_boolean() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_pet_actions(true, true, true, slots());
|
|
assert!(s.eval::<bool>("return IsPetAttackActive(1)").unwrap());
|
|
assert!(!s.eval::<bool>("return IsPetAttackActive(2)").unwrap());
|
|
assert!(s
|
|
.eval::<bool>("return IsPetAttackActive(9) == false")
|
|
.unwrap());
|
|
}
|
|
|
|
/// The predicates are checked the way `UnitPopup.lua`'s row conditions read them.
|
|
#[test]
|
|
fn the_menu_predicates_fork_the_rows_and_the_verbs_queue() {
|
|
let mut s = UiScript::new().unwrap();
|
|
|
|
// No pet pushed: every row is off.
|
|
assert!(s
|
|
.eval::<bool>("return PetCanBeAbandoned() == nil and PetCanBeRenamed() == nil")
|
|
.unwrap());
|
|
|
|
// A hunter's freshly tamed pet.
|
|
s.set_pet_menu(true, true);
|
|
assert_eq!(s.eval::<i64>("return PetCanBeAbandoned()").unwrap(), 1);
|
|
assert!(
|
|
s.eval::<bool>("return PetCanBeAbandoned() and PetCanBeRenamed()")
|
|
.unwrap(),
|
|
"the rename row wants BOTH"
|
|
);
|
|
assert!(!s.eval::<bool>("return not PetCanBeAbandoned()").unwrap());
|
|
|
|
// The same pet after one rename.
|
|
s.set_pet_menu(true, false);
|
|
assert!(s.eval::<bool>("return PetCanBeAbandoned() ~= nil").unwrap());
|
|
assert!(s.eval::<bool>("return PetCanBeRenamed() == nil").unwrap());
|
|
|
|
// A warlock's demon: only Dismiss shows.
|
|
s.set_pet_menu(false, false);
|
|
assert!(s.eval::<bool>("return not PetCanBeAbandoned()").unwrap());
|
|
|
|
assert_eq!(s.take_pet_gives_up(), (0, 0));
|
|
s.run("PetAbandon() PetDismiss() PetDismiss()").unwrap();
|
|
assert_eq!(s.take_pet_gives_up(), (1, 2));
|
|
assert_eq!(s.take_pet_gives_up(), (0, 0), "drain empties");
|
|
|
|
// An empty name is queued for the send's error, a number coerces, no argument queues none.
|
|
s.run("PetRename(\"Bruce\") PetRename(\"\") PetRename(7) PetRename()")
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.take_pet_renames(),
|
|
vec!["Bruce".to_string(), String::new(), "7".to_string()]
|
|
);
|
|
assert!(s.take_pet_renames().is_empty(), "drain empties");
|
|
}
|
|
|
|
#[test]
|
|
fn pushing_the_bar_leaves_the_stats_and_the_menu_alone() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_pet_menu(true, true);
|
|
s.set_pet_stats(
|
|
true,
|
|
PetStats {
|
|
hunter_pet: true,
|
|
happiness: Some(3),
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
s.set_pet_actions(true, true, true, slots());
|
|
|
|
assert_eq!(s.eval::<i64>("return PetCanBeAbandoned()").unwrap(), 1);
|
|
assert_eq!(s.eval::<i64>("return PetCanBeRenamed()").unwrap(), 1);
|
|
assert_eq!(s.eval::<i64>("return HasPetUI()").unwrap(), 1);
|
|
assert_eq!(s.eval::<i64>("return GetPetHappiness()").unwrap(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn a_disabled_bar_is_still_a_bar() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_pet_actions(true, false, true, slots());
|
|
assert_eq!(s.eval::<i64>("return PetHasActionBar()").unwrap(), 1);
|
|
assert!(s
|
|
.eval::<bool>("return GetPetActionsUsable() == nil")
|
|
.unwrap());
|
|
}
|
|
|
|
/// Reactions are type 6 and commands type 7, each with its state in the low byte.
|
|
#[test]
|
|
fn the_pet_one_shots_synthesize_the_bars_slot_words() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.run("PetAttack(); PetFollow(); PetWait(); PetPassiveMode()")
|
|
.unwrap();
|
|
assert!(s.take_pet_orders().is_empty(), "no bar, no order");
|
|
s.set_pet_actions(true, true, true, slots());
|
|
s.run(
|
|
"PetPassiveMode(); PetDefensiveMode(); PetAggressiveMode(); PetWait(); PetFollow(); \
|
|
PetAttack()",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.take_pet_orders(),
|
|
vec![
|
|
0x0600_0000,
|
|
0x0600_0001,
|
|
0x0600_0002,
|
|
0x0700_0000,
|
|
0x0700_0001,
|
|
0x0700_0002
|
|
]
|
|
);
|
|
assert!(s.take_pet_orders().is_empty(), "drained");
|
|
}
|
|
|
|
#[test]
|
|
fn has_full_control_follows_the_hosts_flag() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert_eq!(s.eval::<i64>("return HasFullControl()").unwrap(), 1);
|
|
s.set_player_control(false);
|
|
assert!(s.eval::<bool>("return HasFullControl() == nil").unwrap());
|
|
s.set_player_control(true);
|
|
assert_eq!(s.eval::<i64>("return HasFullControl()").unwrap(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn the_pet_icon_answers_a_path_or_a_real_nil() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert!(s.eval::<bool>("return GetPetIcon() == nil").unwrap());
|
|
|
|
s.set_pet_stats(true, hunter_stats());
|
|
assert_eq!(
|
|
s.eval::<String>("return GetPetIcon()").unwrap(),
|
|
"Interface\\Icons\\Ability_Hunter_Pet_Boar"
|
|
);
|
|
|
|
assert_eq!(s.arity("GetPetIcon()").unwrap(), 1);
|
|
}
|
|
|
|
fn hunter_stats() -> PetStats {
|
|
PetStats {
|
|
hunter_pet: true,
|
|
icon: Some("Interface\\Icons\\Ability_Hunter_Pet_Boar".into()),
|
|
happiness: Some(3),
|
|
damage_percentage: 125.0,
|
|
loyalty_rate: 20.0,
|
|
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
|
|
training_points: (170, 130),
|
|
experience: (4200, 8000),
|
|
family: Some("Boar".into()),
|
|
food_types: vec![
|
|
"Meat".into(),
|
|
"Fish".into(),
|
|
"Cheese".into(),
|
|
"Bread".into(),
|
|
"Fungus".into(),
|
|
"Fruit".into(),
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_hunters_pet_answers_every_stat_binding() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_pet_stats(true, hunter_stats());
|
|
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local h, dmg, rate = GetPetHappiness() \
|
|
return h == 3 and dmg == 125 and rate == 20"
|
|
)
|
|
.unwrap());
|
|
assert_eq!(
|
|
s.eval::<String>("return GetPetLoyalty()").unwrap(),
|
|
"(Loyalty Level 6) Best Friend",
|
|
"the shipped prefix is pushed verbatim — the client does no stripping"
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("local t, sp = GetPetTrainingPoints() return t == 170 and sp == 130")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local c, n = GetPetExperience() return c == 4200 and n == 8000")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local ui, hunter = HasPetUI() return ui == 1 and hunter == 1")
|
|
.unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn a_non_hunter_pet_fails_three_different_ways() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_pet_stats(
|
|
true,
|
|
PetStats {
|
|
hunter_pet: false,
|
|
..hunter_stats()
|
|
},
|
|
);
|
|
|
|
assert!(
|
|
s.eval::<bool>(
|
|
"local h, dmg, rate = GetPetHappiness() \
|
|
return h == nil and dmg == 100 and rate == 0"
|
|
)
|
|
.unwrap(),
|
|
"happiness is nil but its two numbers are still numbers"
|
|
);
|
|
assert!(s.eval::<bool>("return GetPetLoyalty() == nil").unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local t, sp = GetPetTrainingPoints() return t == 0 and sp == 0")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local c, n = GetPetExperience() return c == 0 and n == 0")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local ui, hunter = HasPetUI() return ui == 1 and hunter == nil")
|
|
.unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn happiness_bucket_zero_is_not_the_failure_case() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_pet_stats(
|
|
true,
|
|
PetStats {
|
|
happiness: Some(0),
|
|
damage_percentage: 100.0,
|
|
loyalty_rate: 0.0,
|
|
..hunter_stats()
|
|
},
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("local h = GetPetHappiness() return h == 0 and h ~= nil")
|
|
.unwrap());
|
|
assert!(
|
|
s.eval::<bool>("return not GetPetHappiness() == false")
|
|
.unwrap(),
|
|
"0 is truthy in Lua, so the reference's `not happiness` hide-test does NOT fire"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_pet_still_answers_two_values_from_has_pet_ui() {
|
|
let s = UiScript::new().unwrap();
|
|
assert!(s
|
|
.eval::<bool>("local ui, hunter = HasPetUI() return ui == nil and hunter == nil")
|
|
.unwrap());
|
|
assert!(s.eval::<bool>("return GetPetLoyalty() == nil").unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local h, dmg = GetPetHappiness() return h == nil and dmg == 100")
|
|
.unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn unit_creature_family_is_nil_on_every_absent_path() {
|
|
let mut s = UiScript::new().unwrap();
|
|
// No pet pushed.
|
|
assert!(s
|
|
.eval::<bool>(r#"return UnitCreatureFamily("pet") == nil"#)
|
|
.unwrap());
|
|
|
|
// Family 0 and an unanswered creature query both arrive as `None`.
|
|
s.set_pet_stats(
|
|
true,
|
|
PetStats {
|
|
family: None,
|
|
..hunter_stats()
|
|
},
|
|
);
|
|
assert!(s
|
|
.eval::<bool>(r#"return UnitCreatureFamily("pet") == nil"#)
|
|
.unwrap());
|
|
assert!(
|
|
s.eval::<bool>(r#"return UnitCreatureFamily("pet") ~= ''"#)
|
|
.unwrap(),
|
|
"nil, never an empty string — '' is TRUTHY in Lua, so it would pass the ref's guard \
|
|
and print a bare 'Level 58 ' with a trailing space"
|
|
);
|
|
|
|
// With a family: `"pet"` answers it, any other token nil.
|
|
s.set_pet_stats(true, hunter_stats());
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return UnitCreatureFamily("pet")"#)
|
|
.unwrap(),
|
|
"Boar"
|
|
);
|
|
for token in [r#""target""#, r#""player""#, "nil"] {
|
|
assert!(
|
|
s.eval::<bool>(&format!("return UnitCreatureFamily({token}) == nil"))
|
|
.unwrap(),
|
|
"{token} must answer nil"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn get_pet_food_types_returns_one_value_per_diet() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert_eq!(s.arity("GetPetFoodTypes()").unwrap(), 0);
|
|
|
|
s.set_pet_stats(true, hunter_stats());
|
|
assert_eq!(
|
|
s.arity("GetPetFoodTypes()").unwrap(),
|
|
6,
|
|
"a boar's six diets are six returns, not one string"
|
|
);
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local a, b, c = GetPetFoodTypes() \
|
|
return a == 'Meat' and b == 'Fish' and c == 'Cheese'"
|
|
)
|
|
.unwrap());
|
|
|
|
s.set_pet_stats(
|
|
true,
|
|
PetStats {
|
|
food_types: vec![],
|
|
..hunter_stats()
|
|
},
|
|
);
|
|
assert_eq!(s.arity("GetPetFoodTypes()").unwrap(), 0);
|
|
}
|
|
|
|
/// The diet's hunter gate is the app's to apply, so here a non-hunter's diet is just empty.
|
|
#[test]
|
|
fn the_family_word_answers_for_a_non_hunter_pet() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_pet_stats(
|
|
true,
|
|
PetStats {
|
|
hunter_pet: false,
|
|
family: Some("Imp".into()),
|
|
food_types: vec![],
|
|
..PetStats::default()
|
|
},
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return UnitCreatureFamily("pet")"#)
|
|
.unwrap(),
|
|
"Imp"
|
|
);
|
|
assert!(s.eval::<bool>("return GetPetLoyalty() == nil").unwrap());
|
|
assert_eq!(s.arity("GetPetFoodTypes()").unwrap(), 0);
|
|
}
|
|
}
|