- the audio crackle was our own stall sampler: `/usr/bin/sample` suspends every thread of the task for each of its snapshots, CoreAudio's realtime IO thread included, and coreaudiod times the client out — every HAL overload of the session sat inside a sample of our own process (three for three by timestamp), and the watchdog fires on exactly the stalls the crackle lands on (a desktop switch's `nextDrawable` block, a world entry's compile burst); in-frame sampling now waits for a run nobody can hear; and the output stream is ours — kira's renderer runs 256-frame chunks on a realtime render thread (time-constraint policy, joined to the device's IO workgroup) into a lock-free ring, the IOProc copies one device buffer and touches nothing else (≤ 0.01 ms), the device buffer goes 2048 → 512, a lost or changed default device rebuilds the stream (cpal's no-op error callback on macOS had paused it forever), a debug allocator aborts on any allocation inside either realtime path, and per-cycle meters name a skipped cycle's layer; `SoundBufferSize` is registered at the reference's own FMOD dial, and `audiowatch.py` puts the OS side — thread bands, coreaudiod's overload verdicts — on the same timeline - right-clicking a trainer, questgiver, innkeeper, spirit healer, petitioner, tabard designer or battlemaster sent `CMSG_GOSSIP_HELLO` — eight of the reference's fourteen service arms collapsing into one packet, because the dispatch keyed on the CURSOR kind and Speak covers eight bits; the reference runs two structurally identical ladders over `UNIT_NPC_FLAGS` (one for the cursor at `0x482336`, one for the send at `0x5f0289`), so the ladder is transcribed, first match wins low to high: questgiver → `CMSG_QUESTGIVER_HELLO`, trainer → `CMSG_TRAINER_LIST`, petitioner → `CMSG_PETITION_SHOWLIST`, and the spirit healer and innkeeper send NOTHING on the click — they cache the guid and raise the confirm, whose Lua accept sends with a zero guid — both ghost-gated at entry, so a living player clicking a menuless spirit healer now gets exactly what the reference gives: nothing; the stable-master conjunct and the banker/auctioneer fork fall out of the bit order for free - the -1 blood id is unfilled, not none, and the tests now name the creatures: -1 splits families that share a directory (QuillBoar beside QuillBoarWarrior, GnollMelee beside GnollCaster) and splits single creatures shipped under two rows (a Baby Murloc is both), so a "bloodless" reading would put non-bleeding quilboar beside bleeding quilboar warriors in one Razorfen room; `violenceLevel` 2 IS the reference's default — formatted out of a per-region cap table at `0x86c3f8`, and the one capped locale is koKR, whose green-blood row the table predicts from bytes alone; a spurt whose victim authors no attachment point is dropped, not relocated, and no creature-keyed suppression exists anywhere on the spurt path (ten stop conditions, none keyed on the creature) - twenty dead copies of ours (`PanelTemplates_*`, `TabButtonTemplate`, `FauxScrollFrame_*`, `ScrollFrame_OnLoad`, `ScrollingEdit_*`) that a later chain entry had been shadowing are deleted under a gate that fails any such shadow, and deleting them turned 141 tests red on four bugs live since the kit swap: lists populated before showing (the reference gates every scroll-bar write on `IsVisible()`, so both arrows were dead on every faux list), every faux window wired to a `frame.updateFunc` the reference does not have (the bar moved, the list did not follow), a trainer pitch declared `local` where an `<OnVerticalScroll>` chunk cannot see it, and the armed-wheel guard bypassed by the template's own `<OnMouseWheel>`; nine faux windows (Skill, Craft, TradeSkill, Auction, QuestLog, Friends, ScriptLog) are still on `updateFunc` and their lists do not scroll until each is rewired Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
470 lines
18 KiB
Rust
470 lines
18 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, "Fonts.xml");
|
||
load_xml(&s, "MoneyFrame.xml");
|
||
load_xml(&s, "UiPanels.xml");
|
||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||
load_xml(&s, "DeathFrame.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()
|
||
}),
|
||
);
|
||
assert!(!s.eval::<bool>("return UnitIsDead(\"player\")").unwrap());
|
||
assert!(s.eval::<bool>("return UnitIsGhost(\"player\")").unwrap());
|
||
assert!(s
|
||
.eval::<bool>("return UnitIsDeadOrGhost(\"player\")")
|
||
.unwrap());
|
||
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());
|
||
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());
|
||
}
|