diff --git a/crates/benilla-app/src/ui_loot_roll.rs b/crates/benilla-app/src/ui_loot_roll.rs index 00f826a6..0068c28d 100644 --- a/crates/benilla-app/src/ui_loot_roll.rs +++ b/crates/benilla-app/src/ui_loot_roll.rs @@ -9,8 +9,20 @@ //! within [`TEMPLATE_HOLD_MAX_MS`], opens the frame unresolved, because a frame that never opens //! cannot even be passed on; the reference's callback fires on success only. //! -//! A held roll's `arg2` is still the wire's countdown (`0x61b430` pushes `node+0x3c`); only the bar -//! is short, as `GetLootRollTimeLeft` counts from the packet's arrival. +//! At most [`MAX_OPEN`] rolls are open at once (`0x61b430` tests `[0xc4dda8]` against 4). A roll +//! whose template is in finds the slots full and waits, deferred, in arrival order; every close +//! (our vote `0x61bf46`, a win `0x61bc6b`, all-passed `0x61b74c`) walks the list oldest first and +//! opens deferred rolls while a slot is free (`0x61b760`). Stock `GroupLootFrame_OpenNewFrame` has +//! four frames and drops a fifth silently, so an unqueued fifth could never be voted. +//! +//! Deviation: the open count is the number of rolls announced and not closed. The reference keeps +//! a counter that its server-close paths decrement for any roll whose closed byte is clear +//! (`0x61b6f8`, `0x61bc17`), a deferred roll that never counted up included, so it under-counts +//! and lets a fifth frame open; here a deferred roll's close changes nothing. +//! +//! A promoted roll's `arg2` is still the wire's countdown (`0x61b430` and `0x61b79a` push +//! `node+0x3c`, which nothing rewrites); only the bar is short, as `GetLootRollTimeLeft` counts +//! from the packet's arrival. //! //! `rollID` is a client-local serial; the wire addresses a roll by `(lootedTarget, itemSlot)`. Our //! own vote closes our frame at once, as `RollOnLoot` (`0x61bdf0`) sends the CMSG and fires @@ -41,6 +53,22 @@ const TEMPLATE_HOLD_MAX_MS: u32 = 2_000; /// gets `GroupLootFrame_OnShow`'s gold backdrop. const BIND_WHEN_PICKED_UP: u32 = 1; +/// Rolls open at once (`0x61b435`, `0x61b765`); a fifth waits for a close. +const MAX_OPEN: usize = 4; + +/// Where a roll stands between its packet and its frame. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Phase { + /// Its item template is not in, so `0x61b430` has not run on it. + Waiting, + /// `0x61b430` found four open: the node's queued byte `+0x39`, the oldest promoted first. + Deferred, + /// Counted among the open, its `START_LOOT_ROLL` not yet fired. + Starting, + /// `START_LOOT_ROLL` has gone out. + Open, +} + /// One group loot roll currently open on our screen. struct ActiveRoll { /// The id Lua addresses the roll by. @@ -57,8 +85,7 @@ struct ActiveRoll { remaining_ms: u32, /// How long the roll has waited for its item template. held_ms: u32, - /// Whether `START_LOOT_ROLL` has gone out. - announced: bool, + phase: Phase, } /// One queued chat announcement, awaiting the names it needs. @@ -112,10 +139,45 @@ impl LootRolls { countdown_ms: p.countdown_ms, remaining_ms: p.countdown_ms, held_ms: 0, - announced: false, + phase: Phase::Waiting, }); } + /// Rolls counted open (`[0xc4dda8]`): announced, or about to be, and not closed. + fn open_count(&self) -> usize { + self.active + .iter() + .filter(|r| matches!(r.phase, Phase::Starting | Phase::Open)) + .count() + } + + /// `0x61b430` on every waiting roll `ready` accepts, oldest first: a free slot opens it, else + /// it is deferred. `ready` is the template's arrival, which runs it (`0x61b460`). + fn reach_ready(&mut self, mut ready: impl FnMut(&ActiveRoll) -> bool) { + for i in 0..self.active.len() { + if self.active[i].phase != Phase::Waiting || !ready(&self.active[i]) { + continue; + } + self.active[i].phase = if self.open_count() < MAX_OPEN { + Phase::Starting + } else { + Phase::Deferred + }; + } + } + + /// `0x61b760`: while a slot is free, open the oldest deferred roll. Run after every close. + fn promote(&mut self) { + for i in 0..self.active.len() { + if self.open_count() >= MAX_OPEN { + return; + } + if self.active[i].phase == Phase::Deferred { + self.active[i].phase = Phase::Starting; + } + } + } + /// `SMSG_LOOT_ROLL`: queue the chat line. pub(crate) fn announce(&mut self, p: LootRoll) { self.pending.push(PendingLine { @@ -152,6 +214,7 @@ impl LootRolls { { let r = self.active.remove(i); self.cancelled.push(r.roll_id); + self.promote(); } } @@ -160,14 +223,15 @@ impl LootRolls { let i = self.active.iter().position(|r| r.roll_id == roll_id)?; let r = self.active.remove(i); self.cancelled.push(r.roll_id); + self.promote(); Some((r.looted_target, r.item_slot)) } - /// Tick every open roll's bar down, and the template hold of every unannounced roll up. + /// Tick every open roll's bar down, and the template hold of every waiting roll up. fn tick(&mut self, delta_ms: u32) { for r in &mut self.active { r.remaining_ms = r.remaining_ms.saturating_sub(delta_ms); - if !r.announced { + if r.phase == Phase::Waiting { r.held_ms = r.held_ms.saturating_add(delta_ms); } } @@ -516,16 +580,14 @@ fn feed_loot_rolls( *last = fresh; } - // The hold: a fresh roll announces once its template is in the snapshot above. - for r in &mut rolls.active { - if r.announced { - continue; - } + // The hold: a fresh roll reaches `0x61b430` once its template is in the snapshot above, and + // takes a free slot or is deferred. + rolls.reach_ready(|r| { if items.template(r.item_id, 0, &commands).is_none() { // The module doc's deviation: an entry the server does not know, or an expired hold. let unknown = items.template_answered_unknown(r.item_id); if !unknown && r.held_ms < TEMPLATE_HOLD_MAX_MS { - continue; + return false; } debug!( "ui_loot_roll: roll {} opening unresolved ({})", @@ -537,7 +599,19 @@ fn feed_loot_rolls( } ); } - r.announced = true; + true + }); + // Closes first: a promoted roll's frame needs the closed one's free, or the stock four-frame + // `GroupLootFrame_OpenNewFrame` drops it. + for roll_id in std::mem::take(&mut rolls.cancelled) { + debug!("ui_loot_roll: roll {roll_id} cancelled"); + script.fire_event("CANCEL_LOOT_ROLL", vec![ScriptValue::Int(roll_id as i64)]); + } + for r in &mut rolls.active { + if r.phase != Phase::Starting { + continue; + } + r.phase = Phase::Open; debug!( "ui_loot_roll: roll {} opened ({} ms, held {} ms)", r.roll_id, r.countdown_ms, r.held_ms @@ -550,10 +624,6 @@ fn feed_loot_rolls( ], ); } - for roll_id in std::mem::take(&mut rolls.cancelled) { - debug!("ui_loot_roll: roll {roll_id} cancelled"); - script.fire_event("CANCEL_LOOT_ROLL", vec![ScriptValue::Int(roll_id as i64)]); - } } /// Drain the Lua votes: each `RollOnLoot` is one `CMSG_LOOT_ROLL` and closes our frame. @@ -844,7 +914,7 @@ mod tests { assert_eq!(roll.countdown_ms, 60_000); assert_eq!(roll.remaining_ms, 60_000); assert!( - !roll.announced, + roll.phase == Phase::Waiting, "the packet does not announce — the template does" ); assert_eq!(roll.held_ms, 0); @@ -932,13 +1002,13 @@ mod tests { } #[test] - fn tick_ages_the_hold_only_while_the_roll_is_unannounced() { + fn tick_ages_the_hold_only_while_the_roll_is_waiting() { let mut r = LootRolls::default(); r.start(start(0xAA, 0, 17182)); r.tick(500); r.tick(250); assert_eq!(r.active[0].held_ms, 750); - r.active[0].announced = true; + r.active[0].phase = Phase::Open; r.tick(1_000); assert_eq!(r.active[0].held_ms, 750, "the hold is over"); assert_eq!(r.active[0].remaining_ms, 60_000 - 1_750, "the bar is not"); @@ -984,6 +1054,13 @@ mod tests { f:SetScript(\"OnEvent\", function()\n\ local _, name = GetLootRollItemInfo(arg1)\n\ tinsert(STARTS, arg1 .. \":\" .. arg2 .. \":\" .. (name or \"\"))\n\ + end)\n\ + EVENTS = {}\n\ + local g = CreateFrame(\"Frame\")\n\ + g:RegisterEvent(\"START_LOOT_ROLL\")\n\ + g:RegisterEvent(\"CANCEL_LOOT_ROLL\")\n\ + g:SetScript(\"OnEvent\", function()\n\ + tinsert(EVENTS, (event == \"START_LOOT_ROLL\" and \"S\" or \"C\") .. arg1)\n\ end)", ) .unwrap(); @@ -1004,6 +1081,19 @@ mod tests { .collect() } + /// Every roll event seen so far, in firing order: `S` a start, `C` a cancel. + fn events(app: &mut App) -> Vec { + let script = app.world_mut().non_send_resource_mut::(); + let n = script.eval::("return getn(EVENTS)").unwrap(); + (1..=n) + .map(|i| { + script + .eval::(&format!("return EVENTS[{i}]")) + .unwrap() + }) + .collect() + } + fn advance(app: &mut App, ms: u64) { app.world_mut() .resource_mut::