Files
benilla-octo/crates/benilla-app/src/spell/queue.rs
T
gromandClaude Opus 5 5a824f1625 app: the press queue was switched off in play — a derived Default is the live config
`cvars.rs` announces a `CvarChanged` only when a value *moves*, and `config.toml`
holds only the rows that left their defaults ("CVar values that moved off their
defaults" — its own header). So a row the player never touched never reaches an
`on_cvar` observer, and the resource's own `Default` is what the client runs with
for the whole session.

`PressRules` and `SpellQueue` both derived `Default`. Every toggle was false, every
window 0 and every buffer 0, so the press-interception queue held nothing, the mount
refusal never fired, and `SpellQueue`'s zero window expired each explicit entry on
the frame it was queued — the day after all of it was committed. Fourteen of the
sixteen rows have a non-zero registered default.

grom's own `WoWCombatLog.txt` is what named it, with no live run to ask for: 147
`Sunder Armor: Not yet recovered` against 163 landed Sunders, in bursts 0.17-0.23 s
apart — every press refused, none held. Sunder Armor rank 1 on this realm is
`rec=0 cat=0 catrec=0 gcdcat=133 gcd=1500`, read off its own `Spell.dbc`: a global
cooldown and nothing else, which is precisely the case the queue must hold.

Both now hand-write `Default` with the registered values, which is the pattern
`cvars.rs::registered_defaults_mirror_the_code_truths` already welds for the engine
knobs; `np_press.rs::the_default_is_the_registry` welds these sixteen the same way.
The test-only `defaults_for_tests()` constructors are gone — they spelled out the
registered numbers for tests to read while the client could not reach them, which is
the whole shape of the bug. A sweep of the other nine CVar-cached resources (list
every `fn on_cvar`, take its `ResMut` types, check for a hand-written `Default`, look
up each CVar it names in `REGISTERED`) found no further victims.

Two things so the next session can speak for itself:

- `a_press_made_during_the_global_cooldown_is_held_and_sent_when_it_ends` — the
  end-to-end cycle with the GCD as the only guard, which is what a class with no cast
  bar presses into all day. The existing end-to-end test arms `PendingCast` by hand
  and so never starts a GCD at all; this one presses through the real ladder, so the
  commit arms it the way a cast does.
- one `info!` a minute from the drain, and only when something moved: held, sent,
  dropped, retried, fell-through, channels cut — plus the live windows and buffers.
  Every other line this half writes is `debug!` and the default filter is INFO, so a
  67-minute session could not say one word about the queue. A line of zeroed knobs
  now diagnoses this class of fault by itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 09:46:50 -06:00

743 lines
31 KiB
Rust

//! nampower's explicit spell queue: the three slots behind `QueueSpellByName`, `QueueScript` and
//! `ChannelStopCastingNextTick`.
//!
//! **What this is, and what it deliberately is not.** Upstream's queue has two halves. One is the
//! Lua surface — "remember this spell and send it when the client will accept it" — and that is
//! what is built here. The other intercepts **every ordinary press**, so a keypress during a cast
//! is held rather than refused; that half is governed by `NP_QueueCastTimeSpells`,
//! `NP_QueueInstantSpells`, `NP_QueueTargetingSpells` and friends, and it is **not** built. It is
//! not a binding: it changes how every key in the game feels, it is the one piece whose bugs are
//! double-casts and dropped casts, and it is not something to switch on as a side effect of
//! porting three function names. Those CVars stay stored-only in `cvars.rs`, which says so at
//! each row.
//!
//! So: a spell reaches a slot only because Lua asked, and leaves it the moment the client would
//! accept that press anyway.
//!
//! **Three slots, because upstream has three and its own event says so.** `SPELL_QUEUE_EVENT`'s
//! first argument is `0` on-swing queued, `1` on-swing popped, `2` normal queued, `3` normal
//! popped, `4` non-GCD queued, `5` non-GCD popped — so the queue is keyed by what *kind* of cast a
//! spell is, and one slot per kind is the shape a consumer reads. The kind comes off the spell's
//! own `Spell.dbc` row, the same three tests the send path makes.
use std::time::{Duration, Instant};
use bevy::prelude::*;
/// `SPELL_QUEUE_EVENT`'s first argument. Numbers, not an enum, because they cross to Lua as
/// numbers and an addon compares them against these literals.
pub(crate) mod queue_event {
pub(crate) const ON_SWING_QUEUED: i64 = 0;
pub(crate) const ON_SWING_POPPED: i64 = 1;
pub(crate) const NORMAL_QUEUED: i64 = 2;
pub(crate) const NORMAL_POPPED: i64 = 3;
pub(crate) const NON_GCD_QUEUED: i64 = 4;
pub(crate) const NON_GCD_POPPED: i64 = 5;
}
/// Which slot a spell belongs in, from its own `Spell.dbc` row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Kind {
/// `Attributes & 0x404`: Heroic Strike, Cleave, Raptor Strike.
OnSwing,
/// `StartRecoveryTime == 0`: it costs no global cooldown, so it need not wait for one.
NonGcd,
/// Everything else.
Normal,
}
impl Kind {
/// The same three tests the send path makes, in the same order: the on-next-swing attribute
/// first (it outranks everything, since such a spell never occupies the cast slot at all),
/// then the GCD pair.
pub(crate) fn of(spell: Option<&benilla_formats::SpellDisplay>) -> Self {
match spell {
Some(d) if d.on_next_swing() => Kind::OnSwing,
Some(d) if d.start_recovery_ms == 0 => Kind::NonGcd,
// An uncatalogued spell is Normal, which is the slot that waits for the most: a queue
// that fires something early is worse than one that fires it late.
_ => Kind::Normal,
}
}
fn queued_code(self) -> i64 {
match self {
Kind::OnSwing => queue_event::ON_SWING_QUEUED,
Kind::NonGcd => queue_event::NON_GCD_QUEUED,
Kind::Normal => queue_event::NORMAL_QUEUED,
}
}
fn popped_code(self) -> i64 {
match self {
Kind::OnSwing => queue_event::ON_SWING_POPPED,
Kind::NonGcd => queue_event::NON_GCD_POPPED,
Kind::Normal => queue_event::NORMAL_POPPED,
}
}
}
/// One occupied slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Slot {
spell_id: u32,
/// Where the window is measured from: the latest guard end this entry has been told about.
///
/// **It ratchets forward**, in [`SpellQueue::expire`], while something is still guarding.
/// A fixed `expires` stamped at queue time looks right and has a hole in it: a cast pushed
/// back twice (`SMSG_SPELL_DELAYED`) ends after the window a press made at its start was
/// given, so the entry would time out while the thing it is waiting for is still running and
/// the press would vanish with no packet and no error. The same hole swallows a press held
/// through a cast whose global cooldown then outlasts the window.
base: Instant,
/// How long past [`Self::base`] this entry survives: one of nampower's four windows, picked
/// by what guarded the press (`np_press.rs`'s `window_for`).
window: Duration,
/// The earliest [`SpellQueue::pop`] will take it. The moment it was queued for an ordinary
/// entry, which is to say no constraint at all; later only for a retry
/// (`NP_RetryServerRejectedSpells`), where the point is to not re-send inside the same
/// millisecond the server just refused.
not_before: Instant,
}
impl Slot {
/// Whether the window has run out, `base` having already been brought up to date.
fn expired(&self, now: Instant) -> bool {
now >= self.base + self.window
}
}
/// One queued script, with the priority that orders it.
#[derive(Clone, Debug, PartialEq, Eq)]
struct Script {
priority: i64,
chunk: String,
expires: Instant,
}
/// What the press queue did in the reporting period, for the one line it prints at `info`.
///
/// **Every other line this half of the client writes is `debug!`, which the default filter drops**
/// — so without this a whole session says nothing about whether a press was held, popped, dropped
/// or refused, and the only evidence left is the player's own combat log. The period is long and
/// the line is skipped when nothing moved, so an idle session stays quiet.
#[derive(Default)]
pub(crate) struct Tally {
/// Presses held by [`super::np_press::PressRules::admit`].
pub(crate) held: u32,
/// Held presses that reached the wire.
pub(crate) sent: u32,
/// Held presses that gave up: the window ran out, or the spell was cold with
/// `NP_QueueSpellsOnCooldown` off. The failure mode a player cannot describe.
pub(crate) dropped: u32,
/// Casts re-sent after a server refusal (`NP_RetryServerRejectedSpells`).
pub(crate) retried: u32,
/// Presses the rules declined to hold while something still guarded — each one is an error
/// line the player read. A high count beside a low `held` is the queue not doing its job.
pub(crate) fell_through: u32,
/// Presses that ended a channel instead (`NP_InterruptChannelsOutsideQueueWindow`).
pub(crate) interrupted: u32,
/// When the period began; `None` until the first event.
since: Option<Instant>,
}
impl Tally {
fn moved(&self) -> bool {
self.held | self.sent | self.dropped | self.retried | self.fell_through | self.interrupted
!= 0
}
}
/// The queue: one slot per [`Kind`], plus the scripts.
///
/// `pub(crate)` fields are avoided on purpose — every mutation goes through [`Self::queue`] or
/// [`Self::pop`], so the `SPELL_QUEUE_EVENT` rows cannot be forgotten by a caller that writes a
/// slot directly.
#[derive(Resource)]
pub(crate) struct SpellQueue {
on_swing: Option<Slot>,
non_gcd: Option<Slot>,
normal: Option<Slot>,
scripts: Vec<Script>,
/// `(code, spellId)` rows for `SPELL_QUEUE_EVENT`, drained by the one system that holds the
/// VM. A queue rather than a direct fire for the reason every producer here uses one: this
/// resource is written from the send path, which holds no VM. (Naming the VM's type here —
/// even in a comment — puts this file in `ui_script_wall.rs`'s gated family, which is the
/// gate working as intended: this module really is gameplay state and not a feed.)
events: Vec<(i64, u32)>,
/// `NP_SpellQueueWindowMs`, the one window this half of the queue has a use for: how long an
/// entry is held before it gives up. [`Default`] carries the registered 500, because a row
/// the player never moved never reaches [`on_cvar`] — see [`super::np_press::PressRules`].
window_ms: u32,
/// `NP_QueueSpellsOnCooldown`: whether an entry may sit in the queue while the spell itself is
/// on cooldown, or is dropped on the spot.
allow_on_cooldown: bool,
/// `ChannelStopCastingNextTick`: the channel is cancelled on the next frame, not this one.
stop_channel: bool,
/// The spell a server refusal has already been retried for. See [`Self::queue_retry`].
retried: Option<u32>,
/// The period's counters, reported by [`Self::take_report`].
tally: Tally,
}
/// `NP_SpellQueueWindowMs` 500 and `NP_QueueSpellsOnCooldown` 1 — the values `cvars.rs` registers,
/// which is what a player who touched neither row runs. A derived `Default` gave a window of 0,
/// and an entry whose window is 0 expires on the frame it was queued.
impl Default for SpellQueue {
fn default() -> Self {
Self {
on_swing: None,
non_gcd: None,
normal: None,
scripts: Vec::new(),
events: Vec::new(),
window_ms: 500,
allow_on_cooldown: true,
stop_channel: false,
retried: None,
tally: Tally::default(),
}
}
}
impl SpellQueue {
/// Put `spell_id` in the slot its `kind` implies, replacing whatever was there.
///
/// **Replacing, not refusing**, which is upstream's own behaviour — its log line is
/// `Replacing queued nonGcd spell X with Y`. The replaced entry raises no popped event: it was
/// never sent, and a consumer counting pops against queues would see one too many.
///
/// The expiry is `max(now, in_flight_until) + window`: an entry queued during a three-second
/// cast is held until that cast's own end plus the window, so a player who queues early is not
/// punished for it, and one that nothing ever pops gives up rather than firing minutes later
/// out of nowhere. That last part is the whole reason the window exists here.
pub(crate) fn queue(
&mut self,
spell_id: u32,
kind: Kind,
now: Instant,
in_flight_until: Option<Instant>,
) {
self.queue_with_window(spell_id, kind, now, in_flight_until, self.window_ms);
}
/// [`Self::queue`] with the window named by the caller, for the press-interception half: a
/// held **press** gives up on one of four windows picked by what guarded it
/// (`np_press.rs`'s `window_for`), where an explicit `QueueSpellByName` has only ever had the
/// one. Both land in the same slot and raise the same event, because upstream has one queue.
pub(crate) fn queue_with_window(
&mut self,
spell_id: u32,
kind: Kind,
now: Instant,
in_flight_until: Option<Instant>,
window_ms: u32,
) {
let slot = Slot {
spell_id,
base: in_flight_until.unwrap_or(now).max(now),
window: Duration::from_millis(u64::from(window_ms)),
not_before: now,
};
*self.slot_mut(kind) = Some(slot);
self.events.push((kind.queued_code(), spell_id));
}
/// Put `spell_id` back after the **server** refused it for a timing reason
/// (`NP_RetryServerRejectedSpells`), held `delay_ms` before it may be sent again.
///
/// **Once per spell, and that bound is the whole design.** An unbounded retry of a refusal
/// that keeps arriving is a packet a second forever, which is worse than the dropped cast it
/// was meant to save — upstream's own `Retry cast after cancel still failed` says it gives up
/// too. [`Self::clear_retry`] forgets the memo on any cast the server accepts, so a second
/// rejection later in the fight is retried again; what cannot happen is two retries of one
/// press.
///
/// Returns whether it was queued, for the caller's log line.
pub(crate) fn queue_retry(
&mut self,
spell_id: u32,
kind: Kind,
now: Instant,
delay_ms: u32,
window_ms: u32,
) -> bool {
if self.retried == Some(spell_id) {
return false;
}
self.retried = Some(spell_id);
self.tally.since.get_or_insert(now);
self.tally.retried += 1;
let not_before = now + Duration::from_millis(u64::from(delay_ms));
*self.slot_mut(kind) = Some(Slot {
spell_id,
base: not_before,
window: Duration::from_millis(u64::from(window_ms)),
not_before,
});
self.events.push((kind.queued_code(), spell_id));
true
}
/// The counter pokes. Each is one line at its site, so a new path that holds or drops a press
/// cannot forget to be countable — see [`Tally`] for why counting matters at all.
pub(crate) fn note_held(&mut self, now: Instant) {
self.tally.since.get_or_insert(now);
self.tally.held += 1;
}
pub(crate) fn note_fell_through(&mut self, now: Instant) {
self.tally.since.get_or_insert(now);
self.tally.fell_through += 1;
}
pub(crate) fn note_interrupted(&mut self, now: Instant) {
self.tally.since.get_or_insert(now);
self.tally.interrupted += 1;
}
/// The period's line, or `None` while it is too young or empty. Resets on the way out.
///
/// 60 s, because the question it answers ("is the queue working at all") does not need a
/// finer grain, and a line a minute is a size a session log can carry.
pub(crate) fn take_report(&mut self, now: Instant) -> Option<Tally> {
let since = self.tally.since?;
if !self.tally.moved() || now.duration_since(since) < Duration::from_secs(60) {
return None;
}
Some(std::mem::take(&mut self.tally))
}
/// Forget the retry memo: a cast the server accepted means the next refusal is a new one.
pub(crate) fn clear_retry(&mut self) {
self.retried = None;
}
/// Queue a chunk for [`Self::take_ready_scripts`].
pub(crate) fn queue_script(&mut self, chunk: String, priority: i64, now: Instant) {
self.scripts.push(Script {
priority,
chunk,
expires: now + Duration::from_millis(u64::from(self.window_ms)),
});
}
fn slot_mut(&mut self, kind: Kind) -> &mut Option<Slot> {
match kind {
Kind::OnSwing => &mut self.on_swing,
Kind::NonGcd => &mut self.non_gcd,
Kind::Normal => &mut self.normal,
}
}
/// Take the spell in `kind`'s slot if it is still live, raising its popped event.
///
/// `now` is read only to honour a retry's hold ([`Self::queue_retry`]); an ordinary entry is
/// never held, so this is the same unconditional take it always was for one.
pub(crate) fn pop(&mut self, kind: Kind, now: Instant) -> Option<u32> {
if !self.ready(kind, now) {
return None;
}
let spell_id = self.slot_mut(kind).take()?.spell_id;
self.tally.since.get_or_insert(now);
self.tally.sent += 1;
self.events.push((kind.popped_code(), spell_id));
Some(spell_id)
}
/// Take `kind`'s slot and count it as given up rather than sent — the queue's own decision to
/// abandon an entry, as against [`Self::expire`]'s window running out. The `*_POPPED` row still
/// goes out, because an addon tracking the slot has to see it empty either way.
pub(crate) fn discard(&mut self, kind: Kind, now: Instant) -> Option<u32> {
let spell_id = self.slot_mut(kind).take()?.spell_id;
self.tally.since.get_or_insert(now);
self.tally.dropped += 1;
self.events.push((kind.popped_code(), spell_id));
Some(spell_id)
}
/// What is in `kind`'s slot, without taking it.
pub(crate) fn peek(&self, kind: Kind) -> Option<u32> {
self.slot(kind).map(|s| s.spell_id)
}
/// Whether `kind`'s slot holds something [`Self::pop`] would take now.
pub(crate) fn ready(&self, kind: Kind, now: Instant) -> bool {
self.slot(kind).is_some_and(|s| now >= s.not_before)
}
fn slot(&self, kind: Kind) -> Option<Slot> {
match kind {
Kind::OnSwing => self.on_swing,
Kind::NonGcd => self.non_gcd,
Kind::Normal => self.normal,
}
}
/// Drop every entry whose window has passed.
///
/// **No popped event for an expiry**, and that is the point of the distinction: a consumer
/// reads `*_POPPED` as "this spell was sent". An entry that timed out was not. Upstream has
/// no code for it either.
pub(crate) fn expire(&mut self, now: Instant, guard_until: Option<Instant>) {
for kind in [Kind::OnSwing, Kind::NonGcd, Kind::Normal] {
let Some(slot) = self.slot_mut(kind) else {
continue;
};
// Ratchet the base forward while something still guards the press: the window is
// "this long past the guard's end", and the guard's end can move — pushback stretches
// a cast, and a global cooldown outlives the cast that armed it. See [`Slot::base`].
if let Some(guard) = guard_until {
slot.base = slot.base.max(guard);
}
if slot.expired(now) {
let dropped = self.slot_mut(kind).take();
if let Some(s) = dropped {
self.tally.since.get_or_insert(now);
self.tally.dropped += 1;
debug!(
"spell-queue: dropping {:?} spell {} — nothing popped it inside the window",
kind, s.spell_id
);
}
}
}
// The scripts keep a fixed deadline: their contract is "after queued spells", and a
// script whose spells never fired has nothing left to run after.
self.scripts.retain(|s| now < s.expires);
}
/// The queued scripts in priority order, taken.
///
/// Ascending priority, which is the order `SuperCleveRoidMacros` assumes when it passes `3`
/// for "after queued spells" (`Core.lua:312`) — a bigger number is later. Taken all at once
/// because the caller runs them in one breath once the queue has drained.
pub(crate) fn take_ready_scripts(&mut self) -> Vec<String> {
let mut ready = std::mem::take(&mut self.scripts);
// Stable, so two scripts at one priority keep the order they were queued in.
ready.sort_by_key(|s| s.priority);
ready.into_iter().map(|s| s.chunk).collect()
}
/// Whether every **spell** slot is empty — the gate the queued scripts wait behind, since
/// their contract is "after queued spells" and not "after everything".
pub(crate) fn no_spells_waiting(&self) -> bool {
self.on_swing.is_none() && self.non_gcd.is_none() && self.normal.is_none()
}
/// Whether anything at all is waiting, spells or scripts. Tests only, for now: the drain's own
/// gate is [`Self::no_spells_waiting`].
#[cfg(test)]
fn is_empty(&self) -> bool {
self.no_spells_waiting() && self.scripts.is_empty()
}
/// Take the `SPELL_QUEUE_EVENT` rows.
pub(crate) fn take_events(&mut self) -> Vec<(i64, u32)> {
std::mem::take(&mut self.events)
}
/// `NP_QueueSpellsOnCooldown`'s applied value.
pub(crate) fn allows_on_cooldown(&self) -> bool {
self.allow_on_cooldown
}
/// Arm `ChannelStopCastingNextTick`.
pub(crate) fn request_channel_stop(&mut self) {
self.stop_channel = true;
}
/// Take the channel-stop request.
pub(crate) fn take_channel_stop(&mut self) -> bool {
std::mem::take(&mut self.stop_channel)
}
}
/// `NP_SpellQueueWindowMs` and `NP_QueueSpellsOnCooldown`, the two rows this half of the queue
/// reads. Every other `NP_Queue*`/`NP_*BufferTimeMs` row tunes the press-interception half, which
/// is not built — `cvars.rs` says so at each.
pub(crate) const CVAR_QUEUE_WINDOW: &str = "NP_SpellQueueWindowMs";
pub(crate) const CVAR_QUEUE_ON_COOLDOWN: &str = "NP_QueueSpellsOnCooldown";
/// Apply a change of either row. The registry fires this at boot with the registered default, so
/// neither field is read before it has been seeded.
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut queue: ResMut<SpellQueue>) {
if ev.is(CVAR_QUEUE_WINDOW) {
// Clamped at 10 s: the window's whole job is to stop an entry firing out of nowhere, and
// a player who types a huge number into the settings panel should not lose that.
queue.window_ms = ev.num().clamp(0.0, 10_000.0) as u32;
}
if ev.is(CVAR_QUEUE_ON_COOLDOWN) {
queue.allow_on_cooldown = ev.flag();
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::SpellDisplay;
fn display(attributes: u32, start_recovery_ms: u32) -> SpellDisplay {
SpellDisplay {
attributes,
start_recovery_ms,
..Default::default()
}
}
/// The period line counts what happened and holds its tongue until the period is up — and
/// `Default` is the registry, which is the bug this file shipped with.
#[test]
fn the_tally_reports_once_a_period_and_only_when_something_moved() {
let mut q = SpellQueue::default();
assert_eq!(q.window_ms, 500, "the registered NP_SpellQueueWindowMs");
assert!(
q.allow_on_cooldown,
"the registered NP_QueueSpellsOnCooldown"
);
let t0 = Instant::now();
assert!(q.take_report(t0).is_none(), "nothing has happened yet");
q.note_held(t0);
q.note_fell_through(t0);
assert!(
q.take_report(t0).is_none(),
"the period is a minute long: two events in the first millisecond print nothing"
);
let later = t0 + Duration::from_secs(61);
let r = q.take_report(later).expect("the period is up and it moved");
assert_eq!((r.held, r.fell_through), (1, 1));
assert!(
q.take_report(later + Duration::from_secs(61)).is_none(),
"the counters reset, so a quiet minute stays out of the log"
);
}
/// The three kinds, off the same tests the send path makes. `0x404` is the on-next-swing pair.
#[test]
fn a_spells_kind_is_its_own_dbc_row() {
assert_eq!(Kind::of(Some(&display(0x404, 0))), Kind::OnSwing);
assert_eq!(
Kind::of(Some(&display(0x404, 1500))),
Kind::OnSwing,
"the on-swing attribute outranks a GCD pair: such a spell never occupies the cast slot"
);
assert_eq!(Kind::of(Some(&display(0, 0))), Kind::NonGcd);
assert_eq!(Kind::of(Some(&display(0, 1500))), Kind::Normal);
assert_eq!(
Kind::of(None),
Kind::Normal,
"an uncatalogued spell waits for the most — firing early is worse than firing late"
);
}
/// Queue, replace, pop — and the events each raises.
#[test]
fn a_replacement_raises_no_pop_and_an_expiry_raises_nothing() {
let t0 = Instant::now();
let mut q = SpellQueue {
window_ms: 500,
..Default::default()
};
q.queue(133, Kind::Normal, t0, None);
assert_eq!(q.peek(Kind::Normal), Some(133));
q.queue(116, Kind::Normal, t0, None);
assert_eq!(
q.peek(Kind::Normal),
Some(116),
"a second queue replaces, as upstream's own log line says"
);
assert_eq!(
q.take_events(),
vec![
(queue_event::NORMAL_QUEUED, 133),
(queue_event::NORMAL_QUEUED, 116),
],
"two queues, and NO pop for the replaced entry — it was never sent, and a consumer \
counting pops against queues would see one too many"
);
assert_eq!(q.pop(Kind::Normal, t0), Some(116));
assert_eq!(q.peek(Kind::Normal), None);
assert_eq!(q.take_events(), vec![(queue_event::NORMAL_POPPED, 116)]);
assert_eq!(q.pop(Kind::Normal, t0), None, "an empty slot pops nothing");
assert!(q.take_events().is_empty());
// The three slots are independent: a normal spell does not displace a strike.
q.queue(78, Kind::OnSwing, t0, None);
q.queue(133, Kind::Normal, t0, None);
q.queue(2139, Kind::NonGcd, t0, None);
assert_eq!(
(
q.peek(Kind::OnSwing),
q.peek(Kind::Normal),
q.peek(Kind::NonGcd)
),
(Some(78), Some(133), Some(2139))
);
let codes: Vec<i64> = q.take_events().into_iter().map(|(c, _)| c).collect();
assert_eq!(
codes,
vec![
queue_event::ON_SWING_QUEUED,
queue_event::NORMAL_QUEUED,
queue_event::NON_GCD_QUEUED
]
);
// An expiry raises nothing: `*_POPPED` means "this was sent", and this was not.
q.expire(t0 + Duration::from_millis(499), None);
assert_eq!(q.peek(Kind::Normal), Some(133), "inside the window");
q.expire(t0 + Duration::from_millis(500), None);
assert!(q.is_empty(), "every slot gave up at the window");
assert!(
q.take_events().is_empty(),
"and an expiry is not a pop — upstream has no code for one either"
);
}
/// The expiry is measured from the **end of the cast in flight**, not from the moment of
/// queueing: a player who queues early during a long cast is not punished for it.
#[test]
fn an_entry_queued_during_a_cast_is_held_until_that_casts_own_end() {
let t0 = Instant::now();
let mut q = SpellQueue {
window_ms: 500,
..Default::default()
};
let cast_ends = t0 + Duration::from_secs(3);
q.queue(133, Kind::Normal, t0, Some(cast_ends));
q.expire(t0 + Duration::from_millis(600), None);
assert_eq!(
q.peek(Kind::Normal),
Some(133),
"600 ms in, the cast is still running — the window has not started"
);
q.expire(cast_ends + Duration::from_millis(499), None);
assert_eq!(q.peek(Kind::Normal), Some(133));
q.expire(cast_ends + Duration::from_millis(500), None);
assert_eq!(q.peek(Kind::Normal), None);
// A deadline already in the past is not a shorter window than a fresh queue's: the `max`
// is what stops a lapsed cast making the entry expire instantly.
q.queue(133, Kind::Normal, t0, Some(t0 - Duration::from_secs(10)));
q.expire(t0 + Duration::from_millis(499), None);
assert_eq!(q.peek(Kind::Normal), Some(133));
}
/// The window is measured from the **live** guard end, not the one stamped at queue time.
///
/// A fixed deadline has a hole in it: a cast pushed back twice ends after the window a press
/// made at its start was given, so the entry would time out while the thing it is waiting for
/// is still running, and the press would vanish with no packet and no error line. The same
/// hole swallows a press held through a cast whose global cooldown then outlasts the window.
#[test]
fn a_held_entry_cannot_time_out_while_something_still_guards_it() {
let t0 = Instant::now();
let mut q = SpellQueue {
window_ms: 500,
..Default::default()
};
let cast_ends = t0 + Duration::from_secs(3);
q.queue(133, Kind::Normal, t0, Some(cast_ends));
// Two pushbacks of 500 ms each: the cast now ends at t0 + 4 s, well past the entry's
// original t0 + 3.5 s deadline.
let pushed = cast_ends + Duration::from_secs(1);
q.expire(t0 + Duration::from_millis(3_600), Some(pushed));
assert_eq!(
q.peek(Kind::Normal),
Some(133),
"past where the window would have ended, but the cast is still running"
);
// And once it really ends, the window runs from there.
q.expire(pushed + Duration::from_millis(499), None);
assert_eq!(q.peek(Kind::Normal), Some(133));
q.expire(pushed + Duration::from_millis(500), None);
assert_eq!(q.peek(Kind::Normal), None, "500 ms past the real end");
// The base only ever moves forward: a guard that reads *earlier* than the one already
// recorded cannot shorten an entry's life.
q.queue(133, Kind::Normal, t0, Some(cast_ends));
q.expire(t0, Some(t0));
q.expire(cast_ends + Duration::from_millis(499), None);
assert_eq!(
q.peek(Kind::Normal),
Some(133),
"a shorter guard end does not pull the deadline in"
);
}
/// `NP_RetryServerRejectedSpells`: a refused cast goes back in its slot, held for the delay,
/// and **once** — a retry that could itself be retried is a packet a second forever.
#[test]
fn a_retry_is_held_for_its_delay_and_happens_once() {
let t0 = Instant::now();
let mut q = SpellQueue {
window_ms: 500,
..Default::default()
};
assert!(q.queue_retry(133, Kind::Normal, t0, 55, 500));
assert_eq!(q.peek(Kind::Normal), Some(133));
assert!(
!q.ready(Kind::Normal, t0 + Duration::from_millis(40)),
"inside the delay the entry is there but not sendable — re-sending inside the \
millisecond the server refused it is the one thing a retry must not do"
);
assert_eq!(q.pop(Kind::Normal, t0 + Duration::from_millis(40)), None);
assert!(q.ready(Kind::Normal, t0 + Duration::from_millis(55)));
assert_eq!(
q.pop(Kind::Normal, t0 + Duration::from_millis(55)),
Some(133)
);
// The same spell refused again is not retried a second time.
assert!(
!q.queue_retry(133, Kind::Normal, t0, 55, 500),
"once per rejection chain"
);
assert_eq!(q.peek(Kind::Normal), None, "and nothing was put back");
// A cast the server accepted forgets the memo, so a later rejection is its own.
q.clear_retry();
assert!(q.queue_retry(133, Kind::Normal, t0, 55, 500));
// An ordinary queue is never held: `ready` is about retries alone.
let mut plain = SpellQueue {
window_ms: 500,
..Default::default()
};
plain.queue(133, Kind::Normal, t0, None);
assert!(plain.ready(Kind::Normal, t0));
}
/// Scripts come back in ascending priority, and two at one priority keep their order —
/// `SuperCleveRoidMacros` passes `3` for "after queued spells", so a bigger number is later.
#[test]
fn queued_scripts_come_back_in_priority_order() {
let t0 = Instant::now();
let mut q = SpellQueue {
window_ms: 500,
..Default::default()
};
q.queue_script("third".into(), 3, t0);
q.queue_script("first".into(), 1, t0);
q.queue_script("also-third".into(), 3, t0);
q.queue_script("second".into(), 2, t0);
assert_eq!(
q.take_ready_scripts(),
vec!["first", "second", "third", "also-third"]
);
assert!(q.is_empty(), "taking them empties the list");
// And they expire on the same window as a spell does.
q.queue_script("gone".into(), 1, t0);
q.expire(t0 + Duration::from_millis(500), None);
assert!(q.take_ready_scripts().is_empty());
}
}