Files
benilla-octo/crates/benilla-app/src/ui_script/death_tests.rs
T
SamandClaude Fable 5.1 8f0bece3be loot and pet refusals become message ids, and waterfalls flow again
- a loot refusal is a message id, not a sentence: `loot_error_text` composed eight sentences for `SMSG_LOOT_RESPONSE`'s refusals and six of them said something 1.12 never says — "too far away to loot that." for a string that ends "that corpse.", "You can't loot that from there." for "You must be facing the corpse to loot it.", and an invented "Those pockets are already empty." for a code the client has no string for; it and the `LootErrors` queue are deleted, and the refusal is raised by GlobalStrings key into `UiErrorKeys` at the net bridge, the way `fish_verdict` two functions above already did, off the reference's own eleven-entry jump table at `0x5ebc64`; a composed string had no record to read a surface or a voice off, so all eleven — `MsgKind::Error`, the red `UIErrorsFrame` line — had been going into the chat window as yellow SYSTEM lines, and the four spoken in the player's own race and gender (`DIDNT_KILL`, `BAD_FACING`, `LOCKED`, `TOO_FAR`) were silent; a second bug fell out of reading the leg: codes 4–9, 11 and the default fall through a release tail at `0x5ebac2` that zeroes `[player+0x1d28]` — our `LootLatch` — while 10, 12, 13 and 14 jump past it, so a master looter refused a hand-off no longer stands up out of the kneel at a still-open corpse; the default arm's `ja 0x5ebabd` lands on code 7's arm, so every unnamed code prints `ERR_LOOT_DIDNT_KILL` and releases (2035); and the pet has its own two message tables, not the player's with a flag: `SMSG_PET_ACTION_FEEDBACK`'s handler `0x4bdb70` decrements the byte, bounds it at 3 and jump-tables it through `0x4bdbe8` to `ERR_PET_SPELL_DEAD`, `ERR_NO_ATTACK_TARGET`, `ERR_INVALID_ATTACK_TARGET`, `ERR_PET_SPELL_NOPATH` — one per vmangos `PetFeedback` value, where our two-entry map had printed "No path available for your pet" for a dead pet, "Out of range" for no target and nothing for 3 and 4; `SMSG_PET_CAST_FAILED` is `0x6e8eb0`, a separate 142-byte index table that overrides ten reasons, six of them to `ERR_PET_SPELL_*` rows with no other raise site in the client, so `CastFail` grows a `Caster` and picks its table from it while layers 1 and 3 stay shared, as the binary shares them; `0x6e8eb0` calls neither the combat-log formatter `0x62c360` nor the error sound `0x458a50`, so a pet's refused Growl no longer prints "You fail to cast Growl: …" as the player's own failure; and the source walk that exists to catch a non-catalog key was blind to both wrong keys because all 465 catalog keys begin `ERR_`, so the tripwire moves to the funnel — `Shown::keyed` warns on a key `by_key` cannot resolve (2033)
- the hunter-pet feedback cluster, five opcodes carved off the binary where four had sat name-table-only: `SMSG_PET_TAME_FAILURE` (`0x173`) is two GlobalStrings lookups in order — `0x6e6a20` maps `reason − 1` through the eleven-arm jump table at `0x6e6ac0` to a `PETTAME_*` key and hands the resolved string to `DisplayError(ERR_TAME_FAILED)`, anything outside `1..=11` taking `PETTAME_UNKNOWNERROR`, and Call Pet and Revive Pet raise it too; `SMSG_PET_NAME_INVALID` (`0x178`) raises `ERR_INVALID_PETNAME` from the shared dispatcher `0x5e38c0`'s arm `0x5e3e33` — 1066 had called a refused rename silent on a carve that gave `0x178` the handler `0x4bdc00`, which is `0x2AF SMSG_PET_BROKEN`'s and raises `ERR_PET_BROKEN`, body unread; `SMSG_PET_ACTION_SOUND` (`0x324`) is a bark, not a sound id — its two talk values reach the bark dispatcher `0x623a40` at states 1 and 2, i.e. `CreatureSoundData` columns 28 and 27, and the shipped DBC names them from the other side: exactly four of its 406 rows carry them, the Imp, Succubus, Doomguard and Voidwalker voices, as `A_<demon>_ORDER`/`A_<demon>_KILL`; `Latch::Voice` now carries the bark state as a number and every one-shot creature vocal goes through one `play_bark` that is `0x623a40` — aggro at 0, the pet barks at 1 and 2, the death cry at 4, which had been on a bare `play_kit` off the slot so a pet bark could talk over it — with the reference's `jle` priority rule, because under the old boolean slot a demon's voice would have lost every race against its own aggro flares and been inaudible; it is a warlock-demon surface end to end — vmangos only sends it for a summoned pet, at a 10% roll per order, and a hunter's wolf has neither the columns nor the packet; and the fifth opcode surfaced because the cross-check went looking for column 29's consumer: `SMSG_PET_DISMISS_SOUND` (`0x325`), handler `0x604140`, reads column 29 twice (`0x6041c4`/`0x6041dc`) off a row resolved fresh by `CreatureModelData` id rather than the cached `[unit+0xb40]` — which is why a census over the cache had called the column dead — and plays `A_<demon>_DISMISS` at the packet's point with `z + 1.0`, no unit and no latch; column 29 is loaded, the column nothing reads is 22 (zero on all 406 rows), and the dismiss handler is built although vmangos never sends the packet (2039)
- every UV scroll and animated tint in the world had been frozen for three days: 1940's deferred realization parks a freshly built `WowModelMaterial` behind a reserved handle until something view-visible binds it, and `doodad_anim::register_uv`/`register_tint` still reached for the store alone — `Assets::get_mut` answered `None` for every registration, the slot was freed, the registry stayed empty and the shader read the pinned-zero identity row for every batch, silently, because the `else` branch was a bare `return`; the fix is the address, not the timing: `Parked::value_mut`/`value`/`holds` reach a value wherever it currently lives and `lazy::with_material_mut`/`with_material`/`holds` publish that, so the one-shot build stamp lands in the parked value and rides realization in — realizing it to stamp it would have thrown away exactly what deferral buys; the tick's eviction on `Assets::contains` read a parked material as dead and asks `lazy::holds` now, the empty-registry early-out says so out loud when marked parts exist, and two instruments ship — `WOW_MATANIM_PROBE=<secs>` prints per animated part whether it is registered, whether its loop moves, what its table row holds and whether it is drawn, and `WOW_WORLDVIEW_AT=x,y[,z]` opens the server-less viewer anywhere; measured at the reported waterfall: 0 uv / 0 tint entries before, 87 uv / 6 tint around the Elwynn valley after, its two sheets on slots 1 and 2 with live rows −0.2122 and −0.4246 — the authored 1:2 ratio (2038); and the paper doll and the inspect pane are lit from the model's right: `CGLight+0x24` is the direction the light PROPAGATES — the `CharacterModelBase` ctor `0x505680` writes `(0, 1, 0)` and `0x71bce0` negates all three components before building the SH basis — so `model_pane_light()` hands the packer the ctor's vector as written where 0638 had negated it on the way in and lit both panes from the left; the ambient `(0.7, 0.7, 0.7)`, the diffuse `(0.8, 0.8, 0.64)` and the side-light analysis all hold, which is why the A/B that approved 0638 could not tell the two readings apart (2034)
- a unit tooltip whose name query has not answered titles `UNKNOWNOBJECT`, not a blank gold row: the reference's builder `0x529fe0` reads its NAME line through `CGUnit_C::GetUnitName 0x609210` — the same function `UnitName 0x517020` delegates to — and every miss on its three legs, the null creature record included, ends at the one `FrameScript_GetText("UNKNOWNOBJECT")` tail at `0x609324`, so `unknownobject` moves into `unit` proper and the plate and the verb share it, read out of the VM's `_G` with the binary's "Unknown Being" behind a missing global; the faction line stops waiting for the creature record — its entry gate `0x612610` returns 1 when there is none, so it resolves off `UNIT_FIELD_FACTIONTEMPLATE` and keeps only `HIDE_FACTION_TOOLTIP` for when the record lands; the reference's placeholder sticks for the whole hover (stock `GameTooltip.xml` registers `UPDATE_MOUSEOVER_UNIT` alone) and our line-change repaint, which replaces it when the answer lands, is kept (2040); a unit predicate answers the NUMBER 1 or nil, never a Lua boolean: `UnitExists`, `UnitIsDead`, `UnitIsGhost`, `UnitIsDeadOrGhost` and `UnitOnTaxi` pushed a boolean and twenty-three more open-coded the right shape in eighteen places, where the reference pushes through `lua_pushnumber 0x6f3810` or `lua_pushnil 0x6f37f0`; all twenty-eight now reach 1830's one push site `binding_abi::predicate` — via `unit::unit_predicate` for a `UnitState` field, directly otherwise — `UnitIsCharmed`'s `Number(1.0)` and `ClearTarget`'s hand-branched nil included, and `UnitOnTaxi` gains the reference's `Usage:` raise (gate `0x517a48`) where a nil token had raised mlua's type error; truthiness is why it survived (`if UnitIsDead(u)` reads the same either way, and `== nil` inverts), nine `eval::<bool>` tests were vacuous under mlua's coercion and `taxi.rs` had pinned `== false`/`== true` — the one shape the reference cannot emit — so a table-driven test covers all twenty-eight on both legs, and `UnitIsAFK`/`UnitIsDND` now say they are ours (zero occurrences image-wide) (2043); and the quest log's stock title width cap — `275 − 15 − tagWidth`, live off the chain since 1944 — ellipsizes on one line rather than wrapping into the 16-unit row: 1873 declined it reading an explicit width as a wrap width, but the title FontString declares `y="10"`, and a declared height arms both overflow regimes, so a 258 px title in a 206 px box paints as one line ending in three ASCII dots — the reference's own result, its ellipsis gate at `0x771ec0` being `boxW > 0 && (maxLines != 0 || boxH > 0)`, which only an auto-height FontString escapes; two tests pin the render law on the real FRIZQT face and the geometry the stock file hands it, including the cap's per-row-button stickiness, which is the reference's too (2036)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:39:11 +07:00

480 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The death-arc UI (decisions 0308/1746, DeathFrame.xml): the DEATH release popup's
//! show/countdown/release flow, its self-resurrect button, the resurrect-offer popup pick, and
//! the spirit-healer XP_LOSS two-step — the Lua wiring between the engine's death events and the
//! StaticPopup engine, driven exactly as `death.rs`'s feed does it (set_death → fire_event).
use benilla_ui::script::{DeathAction, DeathUiState, ScriptValue, UiScript};
use super::test_ui::load_ui as load_xml;
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s
}
/// PLAYER_DEAD with a running release timer: the DEATH popup shows, its OnShow seeds the countdown
/// from GetReleaseTimeRemaining, the engine's per-tick DEATH text renders, Release Spirit queues
/// the Repop intent, and PLAYER_ALIVE (the release landing) hides it.
#[test]
fn death_popup_counts_down_and_release_queues_repop() {
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
..Default::default()
});
s.fire_event("PLAYER_DEAD", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"PLAYER_DEAD shows the DEATH popup"
);
s.tick(0.05);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"5 Minutes until release",
"the countdown renders through the engine's DEATH per-tick text"
);
// ESC must NOT close it (no hideOnEscape on DEATH — the ref's law).
s.run("ToggleGameMenu()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the DEATH popup ignores ESC"
);
// Button2 (soulstone) is hidden — HasSoulstone() is nil.
assert!(
!s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
"no soulstone ⇒ single-button release dialog"
);
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::Repop]);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"Release Spirit hides the dialog"
);
// Re-shown (still dead), then the release lands: PLAYER_ALIVE hides it.
s.fire_event("PLAYER_DEAD", vec![]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
s.fire_event("PLAYER_ALIVE", vec![]);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"PLAYER_ALIVE (the release) hides the DEATH popup"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The self-resurrect leg (decision 1746): with `PLAYER_SELF_RES_SPELL` naming a spell, the DEATH
/// popup grows its second button, the button wears the SPELL's name rather than the registry's
/// "Reincarnate" default, and clicking it queues `UseSoulstone` — while button1 still releases.
#[test]
fn death_popup_offers_the_self_resurrect_and_spends_it() {
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
self_res_label: Some("Use Soulstone".into()),
..Default::default()
});
s.fire_event("PLAYER_DEAD", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
"a self-res owed ⇒ DisplayButton2 shows the second button"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Button2:GetText()")
.unwrap(),
"Use Soulstone",
"OnShow stamps the Spell.dbc name over the registry's vestigial \"Reincarnate\""
);
// Button1 is still the release, and it is NOT the self-res.
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::Repop]);
// Button2 spends it. The dialog is raised again first, because clicking either button hides it.
s.fire_event("PLAYER_DEAD", vec![]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::UseSoulstone]);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the self-res hides the dialog; the resurrection itself lands as descriptor deltas"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A shaman's Reincarnation is the same path with a different name — and the button is decided by
/// `HasSoulstone()` at SHOW time, so a self-res that arrives after the popup is up does not
/// retro-fit a button onto it (the reference's own StaticPopup engine re-evaluates neither
/// `DisplayButton2` nor `OnShow` per tick).
#[test]
fn death_popup_button2_is_decided_at_show_time() {
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
..Default::default()
});
s.fire_event("PLAYER_DEAD", vec![]);
assert!(!s
.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap());
// The field lands late: the standing dialog does not grow a button…
s.set_death(DeathUiState {
release_remaining: Some(300.0),
self_res_label: Some("Reincarnation".into()),
..Default::default()
});
s.tick(0.05);
assert!(
!s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
"no per-tick re-evaluation — the button set is fixed at StaticPopup_Show"
);
// …but the OnCancel arm re-asks, so a click on a button that is not there cannot mis-route,
// and the next raise picks it up.
s.run("StaticPopup_Hide(\"DEATH\")").unwrap();
s.fire_event("PLAYER_DEAD", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1Button2:GetText()")
.unwrap(),
"Reincarnation"
);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::UseSoulstone]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The no-timer variant: GetReleaseTimeRemaining() == −1 (an instanceable map) swaps in the
/// DEATH_RELEASE_NOTIMER text and never counts down.
#[test]
fn death_popup_no_timer_shows_the_static_text() {
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: None,
..Default::default()
});
s.fire_event("PLAYER_DEAD", vec![]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
s.tick(0.05);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"You have died. Release to the nearest graveyard?",
"−1 picks the no-timer text, untouched by ticks"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// RESURRECT_REQUEST picks its popup by the offer bits (the ref's three-way), formats the offerer
/// name in, gates Accept behind the recovery delay, and Accept/Decline queue their intents.
#[test]
fn resurrect_request_picks_variant_and_answers() {
let mut s = setup();
// No sickness + timer, delay already elapsed: RESURRECT_NO_SICKNESS with an armed Accept.
s.set_death(DeathUiState {
resurrect_sickness: false,
resurrect_has_timer: true,
recovery_delay: 0.0,
..Default::default()
});
s.fire_event("RESURRECT_REQUEST", vec![ScriptValue::Str("Pone".into())]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"RESURRECT_NO_SICKNESS"
);
s.tick(0.05); // the zero StartDelay expires: real text + enabled button
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Pone wants to resurrect you",
"the offerer name formats into the no-sickness text"
);
assert!(s
.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap());
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::AcceptResurrect]);
// Sickness variant declines: the intent queues and (still dead) DEATH re-shows.
s.set_death(DeathUiState {
resurrect_sickness: true,
resurrect_has_timer: true,
release_remaining: Some(200.0),
..Default::default()
});
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
max_health: 100,
health: 0,
dead: true,
..Default::default()
}),
);
s.fire_event("RESURRECT_REQUEST", vec![ScriptValue::Str("Pone".into())]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"RESURRECT"
);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::DeclineResurrect]);
// The re-shown DEATH lands on the free SECOND instance (Show runs inside the decline's
// OnCancel, before the click's own hide frees instance 1) — the exact case that sized
// STATICPOPUP_NUMDIALOGS at 2.
assert_eq!(
s.eval::<String>("return StaticPopup_Visible(\"DEATH\") or \"none\"")
.unwrap(),
"StaticPopup2",
"declining while dead re-shows the DEATH popup (the ref's OnCancel)"
);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the answered RESURRECT dialog itself closed"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The spirit healer's CONFIRM_XP_LOSS two-step: first Accept rewrites to the "Are you sure" text
/// and keeps the dialog, the second queues AcceptXPLoss; walking out of range auto-hides.
#[test]
fn xp_loss_two_step_confirm_then_range_hide() {
let mut s = setup();
s.set_death(DeathUiState {
sickness_duration: Some("8 Minutes".into()),
spirit_healer_in_range: true,
..Default::default()
});
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"XP_LOSS"
);
let text = s
.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap();
assert!(
text.contains("afflicted by 8 Minutes of Resurrection Sickness"),
"the sickness duration formats into CONFIRM_XP_LOSS: {text}"
);
// First Accept: the AGAIN text swaps in, the dialog stays, nothing queues. XP_LOSS's verbatim
// OnAccept reads `this:GetParent()` (the ref's button-handler context), so the click is driven
// as the XML OnClick does — with `this` bound to the button.
s.run("this = StaticPopup1Button1 StaticPopup_OnClick(StaticPopup1, 1) this = nil")
.unwrap();
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(s.take_death_actions().is_empty());
let text = s
.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap();
assert!(
text.starts_with("Remember, if you find your corpse"),
"the second-ask text: {text}"
);
// Second Accept: the activate intent queues and the dialog closes.
s.run("this = StaticPopup1Button1 StaticPopup_OnClick(StaticPopup1, 1) this = nil")
.unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::AcceptXpLoss]);
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
// Below the sickness level: the NO_SICKNESS variant. Out of range: the OnUpdate poll hides.
s.set_death(DeathUiState {
sickness_duration: None,
spirit_healer_in_range: true,
..Default::default()
});
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"XP_LOSS_NO_SICKNESS"
);
s.set_death(DeathUiState {
sickness_duration: None,
spirit_healer_in_range: false,
..Default::default()
});
s.tick(0.05);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"leaving the spirit healer's range auto-hides the confirm"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A cancelled confirm comes back on the next ask (decision 1068, B80): every CONFIRM_XP_LOSS
/// fire re-shows the dialog — the feed fires per SMSG, and this guards the Lua half of that
/// contract. And the XP_LOSS dialogs carry the ref's alert dress (StaticPopup.lua l.1579-1589):
/// width 420 with the AlertIcon shown, reset to 320/hidden for a non-alert entry on the same
/// instance.
#[test]
fn xp_loss_cancel_then_reconfirm_reshows_with_the_alert_dress() {
let mut s = setup();
s.set_death(DeathUiState {
sickness_duration: Some("8 Minutes".into()),
spirit_healer_in_range: true,
..Default::default()
});
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert_eq!(
s.eval::<f64>("return StaticPopup1:GetWidth()").unwrap(),
420.0,
"showAlert widens the dialog to the ref's 420"
);
assert!(
s.eval::<bool>("return StaticPopup1AlertIcon:IsShown()")
.unwrap(),
"showAlert shows the DialogAlertIcon"
);
// Cancel: nothing queues, nothing on the wire — the dialog just hides.
s.run("this = StaticPopup1Button2 StaticPopup_OnClick(StaticPopup1, 2) this = nil")
.unwrap();
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(s.take_death_actions().is_empty());
// Asking the healer again = a fresh SMSG = the feed fires again: the confirm is back.
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"a fresh confirm re-shows after a Cancel (B80's deadlock)"
);
s.run("StaticPopup_Hide(\"XP_LOSS\")").unwrap();
// A non-alert entry on the same instance resets the dress — Show's unconditional reset.
s.run(concat!(
"StaticPopupDialogs[\"TEST_PLAIN\"] = { text = \"x\", button1 = \"OK\", timeout = 0, ",
"whileDead = 1 } StaticPopup_Show(\"TEST_PLAIN\")"
))
.unwrap();
assert_eq!(
s.eval::<f64>("return StaticPopup1:GetWidth()").unwrap(),
320.0,
"a non-alert Show resets the width"
);
assert!(
!s.eval::<bool>("return StaticPopup1AlertIcon:IsShown()")
.unwrap(),
"a non-alert Show hides the icon"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// UnitIsGhost/UnitIsDeadOrGhost — the trio's ghost legs (a ghost has health 1, so UnitIsDead is
/// false for it; decision 0308 §1).
#[test]
fn the_ghost_predicates() {
let mut s = setup();
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
max_health: 100,
health: 1,
dead: false,
ghost: true,
..Default::default()
}),
);
// The trio's shape is 1/nil, never a boolean (decision 2043), so these read the value rather
// than its truthiness — `== nil` is the comparison a boolean would invert.
assert!(s
.eval::<bool>("return UnitIsDead(\"player\") == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return UnitIsGhost(\"player\")").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return UnitIsDeadOrGhost(\"player\")")
.unwrap(),
1
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The corpse-run range events (decision 0308 §5): CORPSE_IN_RANGE shows RECOVER_CORPSE with its
/// StartDelay countdown gating Accept, Accept queues the reclaim intent (and keeps the dialog —
/// the server's descriptor deltas close the loop), CORPSE_OUT_OF_RANGE hides it; the instance
/// variant is the buttonless notice.
#[test]
fn corpse_range_events_drive_recover_corpse() {
let mut s = setup();
s.set_death(DeathUiState {
recovery_delay: 2.0,
..Default::default()
});
s.fire_event("CORPSE_IN_RANGE", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"RECOVER_CORPSE"
);
assert!(
!s.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap(),
"Accept is delay-gated"
);
s.tick(0.5);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"2 Seconds until resurrection"
);
s.tick(1.6);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Resurrect now?"
);
assert!(s
.eval::<bool>("return StaticPopup1Button1:IsEnabled() ~= 0")
.unwrap());
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::RetrieveCorpse]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"RetrieveCorpse returns 1 — the dialog stays until the server answers"
);
s.fire_event("CORPSE_OUT_OF_RANGE", vec![]);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"leaving range hides the recover dialog"
);
// The dungeon-corpse notice: buttonless (the ref entry has no button1), whileDead.
s.fire_event("CORPSE_IN_INSTANCE", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"RECOVER_CORPSE_INSTANCE"
);
assert!(!s
.eval::<bool>("return StaticPopup1Button1:IsShown()")
.unwrap());
s.fire_event("CORPSE_OUT_OF_RANGE", vec![]);
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// GetCorpseMapPosition: the feed's corpse UV surfaces (0,0) = hidden when absent — the reference
/// WorldMapFrame.lua:443-452 law the map's update block branches on.
#[test]
fn corpse_map_position_binding() {
let mut s = setup();
let (x, y) = s
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
.unwrap();
assert_eq!((x, y), (0.0, 0.0), "no corpse ⇒ the (0,0) hide sentinel");
s.set_world_map_feed(None, None, 0.0, Some((0.25, 0.75)), Vec::new(), Vec::new());
let (x, y) = s
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
.unwrap();
assert!((x - 0.25).abs() < 1e-6 && (y - 0.75).abs() < 1e-6);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}