- 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>
480 lines
19 KiB
Rust
480 lines
19 KiB
Rust
//! 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());
|
||
}
|