promotional bannermobile promotional banner

VibeCheckLoot

Unlisted
VibeCheck is a full-featured EPGP loot management and attendance tracking addon built for World of Warcraft: The Burning Crusade Classic Anniversary

File Details

v1.4.6-bcc

  • R
  • May 23, 2026
  • 284.33 KB
  • 0
  • 2.5.5
  • Classic TBC

File Name

VCLoot-v1.4.6-bcc.zip

Supported Versions

  • 2.5.5

# VibeCheck Changelog

## 1.4.6 — May 23, 2026

### Cross-system audit follow-up — 5 fixes

Audit of all v1.3.20 → v1.4.5 changes surfaced one real correctness bug, one race-window concern, and three smaller hardening items. All fixed.

### Bug #1 — `_FinalizeRaidStart` could broadcast for a deleted raid

The undo toast (v1.4.5 D) deferred `_FinalizeRaidStart` by 5 seconds. If `EndRaid` or a raid-swap ran during that window, the local raid record got marked ended — but the deferred timer would still fire and broadcast `RaidStart` + `BATCH_EP` for that "ended" raid. Peers received `RaidEnd` first (dropped — no raid yet), then `RaidStart` (created a phantom raid), then `BATCH_EP` (applied EP for the phantom). Net: peers had a half-broken raid record with start but no end.

**Fix.** Refactored: pending auto-start raids now live in memory only during the 5-second window. They are **not** committed to `ns.DB:Raids()` until the toast confirms. Discarding a pending raid is just discarding the closure — no DB cleanup needed, nothing was committed.

The toast's `onCancel` callback prints a chat message + UI refresh. A new module-level closure (`Attendance._cancelPendingAutoStart`) is stashed by `StartRaid` and consulted by `EndRaid` so the user invoking `/vcl raid end` mid-window cleanly cancels (instead of getting "No active raid" while the toast still confirms 5 s later).

### Bug #2 — peer FULL-sync during the 5-second undo window

`DB:StartRaid` bumped `dbRev` immediately. If `GROUP_ROSTER_UPDATE` fired during the window (likely — common during raid setup), an HB went out with the new rev. Peers requested a FULL. Our `SendFull` included the un-confirmed raid. If the user then clicked Cancel, the local raid was deleted but peers had already pulled it via FULL → divergent state.

**Fix.** Closed by the same refactor as Bug #1. With pending raids living in memory only, `DB:Bump()` doesn't fire until the toast confirms. Peers' HB-rev comparison stays equal during the window. No FULL request, no peer sees the un-confirmed raid.

**Trade-off:** `OnBossKilled` during the 5-second window finds `CurrentRaid()` nil and bails. Acceptable — no real TBC boss dies in 5 seconds, and trash mob kills aren't in `BOSS_LIST` anyway.

### Limitation #3 — officer-fallback race window widened

Two officers both running the fallback could fire their jittered timers within ~200 ms of each other (priority-queue + receive latency), resulting in two raids broadcast. With the previous 5-15 s jitter, collision probability ≈ **2%**.

Widened to **5-30 s**. Same math: 200 ms / 25,000 ms ≈ **0.8%**. Trade-off: a worst-case 30-second delay before an officer takes over from a leader-without-addon. Acceptable — the leader still might join with the addon in those 30 s, in which case we cleanly back off.

### Edge case #5 — late joiner via boss-kill snapshot now gets retro EP

`SnapshotRaid` is called from three sites: `StartRaid`, `OnBossKilled`, and `GROUP_ROSTER_UPDATE`. The on-time retro EP (v1.4.5 A) was only wired into the `GROUP_ROSTER_UPDATE` site. If a late joiner first appeared via the `OnBossKilled` snapshot path (rare — typically GROUP_ROSTER_UPDATE fires first when they zone in), they'd miss the retro EP.

**Fix.** `OnBossKilled` now also calls `AwardLateJoinerRaidStartEP` after its snapshot.

### Doc #4 — test count corrected

v1.4.5 changelog stated "29 tests" but the actual count was **39**. The drift came from earlier versions where the running total didn't update on every test addition. Corrected to 39 (plus 3 new in v1.4.6 → 42).

### Architectural side-effects of the refactor

`SnapshotRaid` is now a thin wrapper around `_SnapshotIntoRaid(raid)`. The split lets the auto-start path snapshot into an in-memory pending raid table; the public `SnapshotRaid(raidId)` still works for in-DB raids (boss-kill path, GROUP_ROSTER_UPDATE path).

`_CancelPendingRaidStart` (v1.4.5) is removed. With pending raids in memory only, "cancel" is just "let the closure go out of scope" — no DB cleanup needed.

New silent UI helper: `UI:CancelAutoStartUndoToast()` hides the toast and cancels its timers without firing `onConfirm` or `onCancel`. Used by Attendance's orphan handling (when a fresh auto-start replaces a previous one mid-window) so the user doesn't see a "Auto-start cancelled" chat message for the supplanted raid.

### Regression coverage

Three new tests bring the suite to **42**:

- `1.4.6: pending auto-start raid is NOT in ns.DB:Raids() during undo window` — verifies the in-memory-only invariant + post-commit shape.
- `1.4.6: _cancelPendingAutoStart is nil by default` — guard against accidental load-time truthiness that would route EndRaid into a phantom callback.
- `1.4.6: SnapshotRaid / _SnapshotIntoRaid both available` — confirms both functions exist and the bare-table form works without error.

### Known limitations (not fixed in this release)

- **Per-zone opt-out checkboxes don't auto-refresh after a peer SETTINGS push.** The grid reads state once at frame build. Matches the existing convention used by `flatCheck`, `themedSlider`, `themedDropdown` etc. — a broader settings-UI refresh mechanism is out of scope for a hotfix release. Workaround: `/reload`.

### Wire format

No protocol changes. `PROTO_VERSION` stays at `1`.

### Version

- Bumped from **1.4.5** to **1.4.6** — audit follow-up fixes.

---

## 1.4.5 — May 23, 2026

### Raid auto-tracking overhaul — 9 improvements from the audit

Implements every item from the auto-tracking audit. Some are bug fixes (the `onTimeWindowSec` setting was exposed but did nothing), some are usability (one-shot skip + undo toast), some are reliability (officer fallback when the leader has no addon, raid-swap detection). All sender-side and additive — no protocol changes.

### A. `onTimeWindowSec` wired (real bug)

The setting has been exposed in Settings UI for several versions, synced via `SETTINGS`, included in `SETTINGS_BOUNDS` — but `epPerTimeOnTime` ignored it entirely. The on-time bonus was awarded to *whoever was in the group at raid-start time*, full stop. Late joiners got nothing even if the configured window was 10 minutes.

**Fix.** `SnapshotRaid` now returns the list of newly-added attendees. `Core.lua`'s `GROUP_ROSTER_UPDATE` hook calls the new `Attendance:AwardLateJoinerRaidStartEP(raid, newNames)`, which retro-awards `epPerRaidStart` + `epPerTimeOnTime` to anyone added within the configured window. Broadcast as targeted `BATCH_EP` carrying only the new joiners (so existing attendees aren't double-credited).

### B. Configurable `minAutoTrackSize`

The hardcoded `< 20` floor in `OnPlayerEnterCombat`, `OnEncounterStart`, `OnEncounterEnd`, and `OnCombatLog` is now a setting (default 20). Lower to 10 to also auto-track Karazhan / Zul'Aman; raise above 20 to require a full 25-man before fire. UI slider in Officer Settings → EP → Auto-tracking. Synced to peers via the normal `SETTINGS` push so everyone agrees.

### C. One-shot `Skip next auto-start` toggle

New `/vcl raid skip` slash and "Skip next auto-start" button (Officer Settings → EP → Auto-tracking). Flips a transient flag; the next `PLAYER_REGEN_DISABLED` / `ENCOUNTER_START` clears the flag and bails. Lets the LM run a fun pull / alt night / strat test without flipping `autoRaidTracking` off and forgetting to flip it back.

### D. 5-second undo toast on auto-start

When an auto-start fires, a small bottom-anchored toast appears with a 5-second countdown and a Cancel button. Clicking Cancel within the window rolls back the local raid record — **no `BroadcastRaidStart` or `BATCH_EP` was sent yet**, so peers never saw the false-positive. After 5 seconds the toast confirms automatically and the broadcasts go out normally.

Implementation: `StartRaid` is split into setup (record creation + snapshot) and `_FinalizeRaidStart` (broadcasts + EP awards). The toast holds the call to `_FinalizeRaidStart`; Cancel calls `_CancelPendingRaidStart` which deletes the local record + clears `currentRaidId`.

### E. Raid-swap detection (BT → SWP teleport)

Edge case: group runs Black Temple, kills boss, walks out, teleports to Sunwell Plateau. `OnPlayerEnterCombat` used to see `CurrentRaid()` exists and bail — meaning Sunwell wasn't tracked until the BT raid auto-ended on the 10-min leave timer (10-minute gap with no tracking on the new instance).

**Fix.** When `CurrentRaid()` exists but `GetRealZoneText()` returns a different TBC raid zone, end the previous raid immediately and start the new one. Logs a `"Detected raid swap (X → Y). Ending previous raid."` line so the LM sees what happened.

### F. Officer fallback when leader has no addon

Previously only the actual raid leader (`UnitIsGroupLeader("player")`) ran auto-start. If the leader was a pug or a guildie without VibeCheck installed, no auto-start fired and the raid had to be started manually.

**Fix.** When the local player is an officer (not leader) AND the leader has no recent `HEARTBEAT` in `Sync.presence` (within 120 s), the officer schedules an auto-start with a 5–15 s jittered delay. If by the time the timer fires `CurrentRaid()` exists (someone else got there first), bail. If the leader's HB arrives in the meantime, bail. Race-safe: multiple officers each have an independent jittered timer; first one to fire creates the raid, the rest no-op.

### G. Per-zone auto-track opt-out

New table-valued setting `zoneAutoTrackOptOut` synced as a comma-separated list of escaped zone names. UI in Officer Settings → EP → Auto-tracking shows a checkbox per TBC raid (Karazhan, Gruul, Mag, SSC, TK, Hyjal, BT, SWP, Zul'Aman). Unchecking a zone makes auto-start refuse it even when all other conditions are met. Manual `/vcl raid start` still works for opted-out zones — only the *automatic* triggers are gated. Useful for guilds running e.g. weekend Karazhan fun runs that shouldn't count for main-raid standings.

### H. Explain-why hint when auto-start declines

When auto-start fails a gate that the LM might reasonably have expected to pass (size floor, classic raid, zone opted out, skip flag set, disabled, etc.), a one-time-per-(reason, zone) chat hint surfaces the reason. Silent bail-outs (target is a critter, not in a raid, not the leader and leader has addon) stay silent — they're noise. Dedup resets on `/reload`.

### I. First-ever auto-start intro popup

The first time the addon auto-starts a raid for an account, a one-time popup explains what just happened with [OK] and [Disable auto-tracking] buttons. Flag lives in `ns.DB.db.firstAutoStartShown`. Subsequent auto-starts are silent (no popup). Doesn't conflict with the undo toast — popup appears 500 ms after the toast so they coexist visually.

### New settings (synced)

- `minAutoTrackSize` (number, 5–40, default 20) — minimum group size for auto-start.
- `zoneAutoTrackOptOut` (table, lowercase-zone → true) — per-zone auto-track blocklist.

Bounds added to `SETTINGS_BOUNDS`. Table serialization (`serializeZoneOptOut` / `deserializeZoneOptOut`) follows the same pattern as `lootRules` / `decayExcludeRanks` / `bossEPOverrides`. Both keys included in `SendFull` and `BroadcastSettings`.

### New runtime state

- `Attendance._skipNextAutoStart` — boolean, transient, not persisted.
- `Attendance._autoStartTriggered` — internal flag set by auto-start path before `StartRaid` to opt-in to the deferred-broadcast undo-toast flow.
- `Attendance._officerFallbackPending` — debounces concurrent officer-fallback schedules.
- `Attendance._explainedReasons` — per-session dedup for the explain-why hint.

### New UI

- Officer Settings → EP → **Auto-tracking** section (between EP awards and Phase gate): minimum-size slider, on-time-window slider, skip-next button, per-zone opt-out checkbox grid.
- Bottom-of-screen auto-start undo toast (5 s countdown + Cancel).
- First-run auto-start intro popup with [OK] / [Disable auto-tracking].

### Regression coverage

Four new tests:

- `1.4.5: minAutoTrackSize setting present + sane bounds`
- `1.4.5: zoneAutoTrackOptOut table exists + survives serialization`
- `1.4.5: skip-next-auto-start flag exists on Attendance`
- `1.4.5: AwardLateJoinerRaidStartEP is a no-op when window=0`

Total suite now 39 tests. (Earlier changelogs drifted on this count; v1.4.6 corrected the running total.)

### Wire format

No protocol changes. `PROTO_VERSION` stays at `1`. Two new keys added to the existing `SETTINGS`/`FULL` payload format (`minAutoTrackSize`, `zoneAutoTrackOptOut`) — older receivers ignore unknown keys, newer receivers handle them via `applySettingLine`.

### Version

- Bumped from **1.4.4** to **1.4.5** — auto-tracking audit follow-through.

---

## 1.4.4 — May 23, 2026

### Sync priority audit for raid responsiveness

Followed up on 1.4.3's priority-lane work with a full audit of all 14 message types to make sure every raid-critical send is in the right tier. Found four gaps where time-sensitive traffic was still landing in the normal queue behind FULL-sync chunks.

### Tier definitions (recap)

- **`SEND_URGENT`** — message body ≤240 B AND in this set → ship via `SendAddon` directly, skipping the queue entirely. Only safe for messages that are (a) tiny single-fragment and (b) don't burst (no scenario fires N > ~5 in one frame).
- **`SEND_PRIORITY`** — chunked (>240 B) OR queued (≤240 B but not urgent) AND in this set → priority queue lane that drains ahead of the normal lane.
- Neither — normal lane, FIFO behind everything else.

### Gaps closed

**1. `DELTA` was in the normal lane.** This is the per-award GP charge — when the LM clicks Award, a `DELTA` carries the GP debit + history row to peers. Stale `DELTA` means stale PR on the next roll's leaderboard. Now in `SEND_PRIORITY` (but not `SEND_URGENT`, because `/vcl decay` and mass-edit can legitimately fire 50+ DELTAs in one frame, and bypassing the throttle there would burst WoW's per-second channel quota).

**2. `ROLL_START` and `ROLL_END` were `SEND_URGENT` only.** The urgent path skips the queue *only when the body is ≤240 B*. A typical roll fits, but a long item link (random suffix, multiple gems, enchant) can push it over 240 B → falls through to the chunked path → went to the normal queue. Added both to `SEND_PRIORITY` so the chunked-long-itemlink case also stays in the fast lane.

**3. `HIST_DEL` was in the normal lane.** This is the rollback broadcast. When an officer rolls back a misclick award mid-raid, the cluster needs to clear that history row *before* the officer might double-rollback. Added to both `SEND_URGENT` and `SEND_PRIORITY` (small + rare + raid-time-sensitive).

**4. `BATCH_EP` priority-lane treatment formalized.** 1.4.3 added it ad-hoc; the new comment block documents why it's in both `SEND_URGENT` (small/10-player case) and `SEND_PRIORITY` (chunked 25-player case).

### Final tier table

| Message    | URGENT | PRIORITY | Rationale |
|------------|--------|----------|-----------|
| `HB`       | ✅    | —        | 30 s keepalive, ~40 B, never bursts |
| `RS`       | ✅    | ✅      | popup must pop; long links chunk |
| `RE`       | ✅    | ✅      | winner announce; long links chunk |
| `BEP`      | ✅    | ✅      | boss-kill EP; 25-man chunks |
| `HDEL`     | ✅    | ✅      | rollback during raid; rare, one-shot |
| `RAID`     | —     | ✅      | kill carries attendee CSV → chunks often |
| `DELTA`    | —     | ✅      | post-award GP charge; decay/mass-edit can burst |
| `REQ`      | ✅    | —        | tiny handshake, single send |
| `FULL`     | —     | —        | huge background catch-up; would clog lanes |
| `SET`      | —     | —        | officer-driven, can wait a second |
| `PROF`     | —     | —        | already debounced 2 s + 15 s cooldown |
| `DREQ`     | —     | —        | catch-up, by definition not urgent |
| `DRSP`     | —     | —        | catch-up response |
| `VER`      | —     | —        | background diagnostic |
| `RDEL`     | —     | —        | officer admin, can wait; mass-prune avoids burst |

### Regression coverage

Added two tests to `Test.lua`:

- **`1.4.3: NON_TBC_RAIDS blocklist covers Classic raids`** — verifies `ns.isClassicRaid()` flags the seven Classic raids (MC, Onyxia, BWL, ZG, AQ20, AQ40, Naxx) and does *not* flag any TBC raid, with case-insensitive lookup and nil/empty handling.
- **`1.4.4: sync priority audit — raid-critical types are urgent or priority`** — exports `Sync:_priorityAudit()` (test-only accessor returning shallow copies of `SEND_URGENT`/`SEND_PRIORITY`) and asserts that (a) every raid-critical message type is in at least one tier, and (b) no background traffic accidentally slipped into the lanes.

Both pass; total test count now 25.

### Wire format

No protocol changes. `PROTO_VERSION` stays at `1`. Priority is purely sender-side scheduling — receivers are unaware which lane a message came from.

### Version

- Bumped from **1.4.3** to **1.4.4** — sync priority audit + regression coverage.

---

## 1.4.3 — May 23, 2026

### Three raid-report fixes

User feedback after the post-1.4.2 raid: EP took noticeably long to land on viewers after boss kills, a Zul'Gurub fun run was auto-tracked for attendance, and Unicode arrows in the new Officer Cheat Sheet rendered as tofu boxes.

### 1. Boss-kill EP lands within ~150 ms regardless of background traffic

**Symptom.** After a boss kill, viewers' EP totals took "awhile" to update — sometimes long enough that the LM started awarding loot from stale standings.

**Root cause.** A typical 25-player `BATCH_EP` payload is ~400 bytes (raid ID + ep + reason + by + timestamp + bench list + attendee CSV with 25 names). That gets **chunked** into 2-3 fragments queued at 50 ms intervals — fine on its own (~150 ms total). But if a FULL sync was in flight when the boss died (which is common — peers request FULL on join, after a /reload, etc.), the queue could already have 50-300 FULL chunks ahead of `BATCH_EP`, adding **2.5-15 seconds of queue lag** on top.

**Fix.** Replaced the single FIFO send queue with a **priority lane** dispatcher:

- New `_priorityQ` table that drains ahead of the normal `_normalQ`.
- New `SEND_PRIORITY` set: `BATCH_EP` (boss-kill EP) and `RAID` events (start/end/kill broadcasts) take the priority lane.
- `enqueueSend` accepts a `priority` flag; `Sync:Send` passes `SEND_PRIORITY[msgType]` automatically.
- Order between consecutive priority sends is preserved (priority queue is FIFO).
- A FULL sync queued ahead of a boss kill no longer delays the EP broadcast — `BATCH_EP` chunks slot in front and ship in their natural ~150 ms.

Also: `BATCH_EP` is now in `SEND_URGENT`. For smaller raids (Karazhan etc.) the BATCH_EP body fits under 240 bytes — it now ships immediately via the urgent path instead of even touching the queue.

### 2. Classic raids excluded from auto-attendance

**Symptom.** Guild ran a Zul'Gurub fun run; it was tracked for attendance and polluted TBC standings.

**Root cause.** Auto-attendance triggers on `IsInRaid() + GetInstanceInfo()=="raid" + groupSize >= 20`. Zul'Gurub matches all three. The phase-gate logic was only looking up *known TBC* zones in `RAID_PHASES`; unknown zones defaulted to "track everything" (safer for missing-from-list TBC zones, wrong for Classic content).

**Fix.** New `ns.C.NON_TBC_RAIDS` blocklist + `ns.isClassicRaid(zone)` helper covering:

- Molten Core, Onyxia's Lair, Blackwing Lair
- Zul'Gurub
- Ruins of Ahn'Qiraj (AQ20), Temple of Ahn'Qiraj (AQ40)
- Naxxramas (Classic 40-man)

Both auto-start sites (`OnPlayerEnterCombat` and `OnEncounterStart`) consult the blocklist after the existing checks and bail without calling `StartRaid`. A one-line chat message tells the LM that auto-tracking was skipped and points them at the manual `/vcl raid start` command if they *want* the fun run counted.

Manual raid start still works for these zones — only the *automatic* triggers are gated, so a deliberate "we're doing this raid for points" decision remains possible.

### 3. Cheat sheet — replaced Unicode arrows with ASCII and tightened the auto-open gate

Two issues in the new (v1.4.0) Officer Cheat Sheet:

**A. Tofu boxes where arrows should render.**

The cheat sheet body used `Settings → Officer Settings → EP` formatting with the rightwards-arrow `→` (U+2192). TBC's default font does not include a glyph for that codepoint, so it rendered as a `□` ("tofu") box. Replaced 24 occurrences of `→` with the ASCII `->` in the displayed strings — comments and section dividers (`──`) in source code are unchanged because they don't render in the UI. Also applied the same fix to a handful of other v1.4.0 dialogs that used the arrow in non-comment code.

**B. Auto-open fired for non-officers.**

The auto-open guard at PLAYER_LOGIN used `ns.isOfficer()`, which checks guild rank index ≤ 3 (Vibe Master / Officer / Officer Alt / Loot Master). In some guilds rank 3 is given to regular raiders rather than an actual officer, so they saw the popup on login.

**Fix.** Replaced the auto-open gate with WoW's permission-based checks: `IsGuildLeader()` OR `CanGuildPromote()` OR `CanGuildDemote()`. These return true only for accounts with actual officer permissions — strictly tighter than the rank-index check. The addon's `isOfficer()` semantics are unchanged everywhere else (loot workflow, settings editing, etc.) — this gate is only used for the popup.

The manual "Open Officer Cheat Sheet" button in Settings → Officer Settings still uses `isOfficer()`, so anyone the addon considers an officer can read the sheet on demand.

### Wire format

No protocol changes. `PROTO_VERSION` stays at `1`. The priority queue is purely sender-side scheduling — receivers are unaware.

### Version

- Bumped from **1.4.2** to **1.4.3** — three reliability/UX fixes from raid feedback.

---

## 1.4.2 — May 23, 2026

### Reliability — Belt-and-suspenders ROLL_START resend

Recurring user report across multiple raids: when the Loot Master starts a roll, the popup pops for some viewers but not others, and the people who miss it are different each time. Single dropped messages, not a configuration issue with any particular player.

**Root cause.** ROLL_START is marked `SEND_URGENT`, so we bypass our own outbound queue and call `C_ChatInfo.SendAddonMessage` immediately. But WoW's **server-side** anti-flood rate limiter doesn't care about our urgent flag. The boss kill that just dropped the loot has already sent BATCH_EP (small) plus a flurry of DELTAs / chunked FULLs (large) into the same RAID addon channel. If the channel's send quota for the second is already exhausted, the server **silently drops** the ROLL_START — no error code, no return value, the message just vanishes. From the LM's perspective the broadcast went out (our local popup opens unconditionally); from the viewer's perspective nothing ever arrived.

This was the single most common "popup never popped" failure mode and had been recurring for many raids.

**Fix.** `Sync:BroadcastRollStart` now sends the message twice — once immediately, then once more 2 seconds later. The 2-second gap is well past the addon channel's per-second quota window, so the resend almost always lands even when the first one was dropped.

The resend is **gated** on the local session still being open with the same `lockId`, so if the LM cancelled or awarded within 2 s of starting the roll the resend is skipped.

**Receiver-side dedup** via lockId prevents popup flicker for viewers who got the first send cleanly:

- New `_rollStartLocks` TTL table (5-min TTL) records every ROLL_START lockId on first receipt.
- Subsequent ROLL_STARTs carrying the same lockId are dropped at the message-handler layer, before the session tear-down logic — so viewers never see the brief close-and-reopen flicker.
- Records BEFORE the popup-open call: safer of two imperfect options. If the handler errored partway, the resend will *not* re-attempt; the user-visible end state is identical either way (no popup).

**Backward compat.** Pre-1.3.20 senders ship an empty lockId, so the resend is suppressed for them (no point — receivers couldn't dedup it). And pre-1.4.2 receivers don't dedup the resend, so they see a popup flicker — annoying but vastly better than missing the popup entirely.

**Wire format unchanged.** No new fields, no new message types. `PROTO_VERSION` stays at 1.

### Version

- Bumped from **1.4.1** to **1.4.2** — reliability hotfix for ROLL_START delivery.

---

## 1.4.1 — May 23, 2026

### Hotfix — `AWARD_LOCK_TTL` nil-arithmetic error in `pruneStaleEntries`

v1.4.0 added a periodic cleanup pass for the award-lockId TTL set, but `_awardLocks` and `AWARD_LOCK_TTL` were declared as locals **after** the `pruneStaleEntries` function body that referenced them. In Lua, local-variable scope only extends forward from the declaration, so the references inside `pruneStaleEntries` fell through to globals → **nil**.

Symptom (visible in chat every ~30s during a Heartbeat tick):

> [VibeCheck] Error in handler CHAT_MSG_ADDON: Sync.lua:1174: attempt to perform arithmetic on global 'AWARD_LOCK_TTL' (a nil value)

The error fired before the loop body, so the lockId table never got pruned and no lockIds were actually dropped — but core sync still worked because the lazy self-prune in `awardLockSeen` (declared correctly, after the locals) kept lookups honest.

**Fix:** moved the `_awardLocks = {}` and `AWARD_LOCK_TTL = 600` declarations up to live next to the other TTL constants (`PRESENCE_TTL`, `REQ_SENT_TTL`) ahead of `pruneStaleEntries`. The `RecordAwardLock` / `awardLockSeen` functions and the DELTA-receive path are unchanged — they now read the same single declaration from a few hundred lines higher in the file.

### Version

- Bumped from **1.4.0** to **1.4.1** — hotfix only, no protocol or feature changes.

---

## 1.4.0 — May 23, 2026

### Audit-roadmap completion release — 7 features

Second and final batch from the post-audit roadmap. All audit items the user approved are now shipped. v1.3.20 covered six low-level protocol/UX additions; v1.4.0 adds the higher-level features built on top of that foundation.

All wire-format changes are **additive trailing fields**, so an officer on v1.4.0 still syncs cleanly with peers on v1.3.20. `PROTO_VERSION` stays at `1`.

### 1. Loot History tab (audit task #7)

New sub-tab under **Logs → Loot History** with two filter fields: **player** (substring match on winner) and **item** (substring match on item link). Pulls from the GP history rows tagged as awards (`reason` begins with `MS/OS/MINOR/Manual`), shows time, player, item, GP charged, and reason. Right-click a row to reuse the existing log-entry actions (rollback, hide, etc.).

Decoupled from the catch-all Logs tab so officers can answer "who got the Atiesh fragment three weeks ago" in two keystrokes instead of scrolling.

### 2. Officer Cheat Sheet & first-run popup (audit task #9)

New `UI:OpenCheatSheetDialog` covering the eight things an officer actually needs to do their job:

1. First-time setup (rank table, sync channel)
2. Starting a raid (boss list, phase gate)
3. Awarding loot (roll → award → recompute)
4. Reassigning a misclick (rollback flow)
5. Decay (when, how, exclusions)
6. Syncing (FULL vs DELTA, when to push)
7. Backups (snapshot / restore)
8. Slash commands

**First-run autoshow:** ten seconds after `PLAYER_LOGIN`, if the user is an officer AND `db.cheatSheetShown` is still nil, the dialog pops once. A "Don't show again" checkbox in the footer flips the flag. Manual access via the new Help button in the main window and `/vcl cheatsheet`.

### 3. Conflict resolution UI for offline-divergence (audit task #10)

When two officers run separate raids while one is offline, the FULL merge on reconnect now **captures locally-unique history rows in `Sync.recentReconciliations`** (capped, with metadata). A new **Recent Reconciliations indicator** appears in Sync & Data Status when the list is non-empty, with a **Review** button opening `UI:OpenReconciliationDialog`.

The dialog lists each captured row (time, peer source, player, amount, reason) with a right-click rollback option. Officers can scan what merged in and undo individual rows without touching the rest of the history.

Tombstone-based; rolled-back rows propagate to peers via the standard history-tombstone path.

### 4. Per-encounter loot rules engine (audit task #12)

Per-item GP modifier rules so officers can encode guild policy in one place instead of hand-fixing GP at award time. Each rule:

- `matchItemId` (exact integer) OR `matchNameContains` (case-insensitive substring)
- `gpMult` — multiplier applied to formula GP (e.g. 1.5x tier-token markup, 0.0 for free items)
- `note` — short label shown in the roll popup ("Tier purchase", "Tank priority")

**Application order:** base GP formula → rule.gpMult → roll-type multiplier. So a 1.5x tier rule combined with an OS roll (0.5x base) lands at 0.75x base GP, which is usually what officers want.

New officer UI at **Settings → Officer Settings → GP → Edit Loot Rules**. List with delete-per-row, add-rule form (id box, substring box, multiplier box, note box, validation), "Push to guild" button to sync via standard `BroadcastSettings`.

**First match wins.** Rules sync as a list replacement, not a merge, so the officer who pushes is authoritative.

### 5. Raid sign-up CSV import (audit task #13)

Two new buttons in **Logs → Raids** action bar:

- **Sign-ups** — opens `UI:OpenSignupImportDialog` with a multi-line paste box. Accepts comma-separated, newline-separated, or whitespace-separated player lists; normalizes each via `ns.normName`. Stores on the raid as `raid.signups = { [name]=true, ... }`.
- **Absent** — opens `UI:OpenSignupAbsentDialog` showing who signed up but didn't show, with a one-click **bench** action (records the absence with reason "Signed up, no-show" so streak bonuses skip them correctly).

Sign-up indicator under the raid action bar: **"X total, Y present, Z absent"** updates live as players check in.

Per the original audit decision: **no partial attendance credit** for signing up without showing. The data is purely informational + drives the absence record.

`serializeRaids` / `deserializeRaids` / `mergeRaids` extended with a 9th trailing field for signups (backward-compat — older receivers ignore it).

### 6. Incremental sync (delta-since-rev) (audit task #14)

Officers no longer need to send a full 12 KB payload to catch a peer up after a brief disconnect. New addon-message types:

- **`DREQ`** (Delta Request) — peer sends "I'm at dbRev N, give me everything since N"
- **`DRSP`** (Delta Response) — sender ships only the events with dbRev > N

`Sync:RequestDeltaSince(target, sinceRev)` is rate-limited to one request per peer per 30 seconds (avoids req-storms during reconnect). `Sync:RespondDeltaSince` reads the local history/roster tail, packages just the missing events, and sends them through the existing DELTA pipeline so lockId dedup still applies.

UI hook: new **Quick Sync (events only)** button in Sync & Data Status. Falls back to FULL automatically if the peer's `dbRev` is unknown.

### 7. Regression test suite expansion (audit task #8)

`Test.lua` grew from 564 → ~770 lines. Eleven new regression tests covering the 1.3.20 fix points and 1.4.0 additions:

- Lock-id dedup on duplicate DELTA receive
- Lock-id miss on legacy ROLL_START (backward-compat fallback)
- Recompute survey preserves lifetime GP
- Recompute roll-type detection (MS/OS/MINOR vs Manual)
- Settings string deserializer respects whitelist + maxLen
- Wishlist marker matches by itemId, not name
- Sign-up CSV parser handles commas, newlines, mixed whitespace
- Loot rule findRule order — first match wins
- Loot rule findRule case-insensitivity
- Loot rule findRule misses on no-match (returns nil)
- Delta-since-rev returns empty for sinceRev >= current

Run via `/vcl test` — all 23 tests pass.

### Deferred

**UI.lua module split refactor** (originally tracked for v1.4.0) is **deferred to a dedicated refactor release**. UI.lua is now ~12,100 lines; splitting it into per-section modules will create a 12 KB code-move diff with significant regression risk that should ship in a feature-free release where any UI breakage is unambiguous. Tracked as a separate task; will land as v1.4.1 or v1.5.0.

### Version

- Bumped from **1.3.20** to **1.4.0** — audit roadmap completion.
- `PROTO_VERSION` unchanged at **1** (all wire changes are additive trailing fields).

---

## 1.3.20 — May 17, 2026

### Roadmap Batch — 6 audit-driven improvements

First batch from the post-audit roadmap. Six features in one release.

### 1. Settings sync audit

Closed the gap between `Constants.DEFAULTS` and `SYNCABLE_SETTINGS`. Previously a number of guild-policy settings only existed on the officer who configured them — peers stayed on defaults, causing "the roll timer popped 30s for me but 20s for you" mismatches.

**Newly synced (guild policy):** `rollDurationSec`, `rollCountdownAt`, `rollAutoAnnounce`, `rollAnnounceChannel`, `rollAwardGPFromRoll`, `rollShowPRToAll`, `rollMasterLoot`, `minItemQuality`, `guildOnlyRoster`, `attendanceStreakThreshold`, `attendanceStreakBonusEP`, `onTimeWindowSec`, `whisperBidEnabled`, `whisperStandby`.

**Intentionally NOT synced (per-user / per-officer):** `tooltipGP`, `rollAutoOpen`, `autoRaidTracking`, `chatPrefix`, `mainWindowScale`, `lootHotkey`, `bagRollModifier`, `minimapButton`. Inline comment in `Sync.lua` documents the rationale for each.

Settings deserializer now handles **strings** (whisperStandby, rollAnnounceChannel) with an explicit `SETTINGS_STRING_RULES` whitelist: exact-value sets or `maxLen` caps. Numeric ranges added to `SETTINGS_BOUNDS` for the new keys.

### 2. Award reason free-text field

The Award confirm popup now has an optional context edit box. Anything you type (up to 120 chars) gets appended to the GP history entry's `reason` field, separated by `—`. The audit trail can now answer "why did Foo get this item over Bar?" with the officer's own words.

Empty = identical to pre-1.3.20 (reason = just the roll type).

### 3. Wishlist highlight on roll rows

Each row in the roll leaderboard now shows a gold `*` next to the player's name when that player has the **current item on their wishlist**. Purely informational — **no priority math change**, the roll value still decides who wins. Lets officers see at a glance who pre-marked interest.

New helper: `ns.DB:PlayerWantsItem(name, itemId)`. Hover the row → tooltip explains the marker.

### 4. Concurrent award lock (lockId)

Closes the "two officers click Award nearly simultaneously" race that could double-charge a winner. `ROLL_START` now carries a unique-per-session `lockId`. Award `DELTA`s include the same lockId. Receivers maintain a TTL set (`_awardLocks`, 10-min TTL) of recently-applied lockIds and **DROP duplicates**. Only one charge applies regardless of how many officers click Award in the same second.

Sender-side: `Sync:RecordAwardLock(lockId)` pre-records the lockId BEFORE the local `AssignGP` call, so the leader's receive path also catches the other officer's duplicate DELTA.

Backward-compat: pre-1.3.20 senders don't include lockId; ROLL_START / DELTA still work, just without the new protection.

### 5. Protocol version field

New constant `ns.C.PROTO_VERSION = 1` distinct from the addon version. The HEARTBEAT payload now carries the proto version as a trailing tab-separated field. Older parsers ignore it (strsplit grabs fields 1-3 as before). Newer receivers store `Sync.presence[name].proto` and surface a **one-time chat warning** when a peer is on a strictly higher proto:

> [VibeCheck] Protocol mismatch: Thrall is running a newer wire format (proto 2). Some sync messages may not work correctly. Please update VibeCheck via CurseForge.

Bump the constant on incompatible wire-format changes (renamed/reordered/removed fields). Additive changes (new trailing fields) don't need a bump — older receivers just ignore the extras.

### 6. Recompute GP charges action

New officer action at **Settings → Officer Settings → GP → GP formula recompute**. Walks the loot history, recomputes per-item GP at the **current formula** (after the officer edited `gpBase`, `ilvlStep`, `slotFactors`, etc.), and applies the cumulative delta to each player's GP.

**Preview before apply** — a dialog shows every affected player with the GP delta (gold for increases, green for refunds) and item count. Officer confirms via StaticPopup before any change.

**Audit-preserving:** original history entries are **not modified**. Each recompute inserts a NEW history row per affected player with `amount = cumulativeDelta` and `reason = "GP formula recompute (N items, +/-X net)"`. Lifetime GP is preserved — adjusting `lifeGP` would misrepresent historical contribution (the player did pay the old amount when the loot was awarded).

**Roll-type detection:** parses the leading uppercase word from each entry's `reason`. Manual / gift / decay entries are skipped (recompute only applies to standard MS/OS/MINOR formula awards).

After applying, an automatic `SendFull` 0.5s later pushes the recomputed state to peers.

### Version

- Bumped from **1.3.19** to **1.3.20** — first audit-roadmap batch.

---

## 1.3.19 — May 17, 2026

### UX — Sync Debug Start/Stop Button (Default OFF)

Replaced the **Pause / Resume** toggle in the Sync Debug popup with a proper **Start / Stop** button that controls the actual recording state, and removed the auto-enable on window open.

**Before (1.3.18 and earlier):**
- Opening the popup auto-enabled recording — opening the window to look at past data started filling the buffer whether you wanted to or not
- The Pause button only froze the UI rendering; the underlying log kept growing (which was confusing — "I paused but the stats keep climbing")

**After (1.3.19):**
- Opening the popup leaves recording state alone. If it was off, it stays off; if it was on (e.g. from `/vcl macro` or a previous Start), the window picks up where it left off.
- The button is now **Start** (green when stopped) / **Stop** (red when recording).
- Clicking it flips `ns.SyncDebug.enabled` directly — single source of truth, no UI/state disconnect.
- The footer hint now reads: `Press Start to begin recording · filter buttons narrow categories · Copy >> paste log to Discord`

**Why this is better:**
- The default behavior matches user expectation: open the window → it's idle → click Start when you want data
- The button label tells you what will happen next (Start = "press to start", Stop = "press to stop"), not what state you're in
- Color cue reinforces it: green = action available is Start, red = action available is Stop
- No more "I closed the popup but it's still recording" confusion — Stop is now a single button click, and you can see at a glance whether it's on

The recording state itself, the log buffer, and the periodic tick (1.3.18) all still work identically. Only the button semantics changed.

### Version

- Bumped from **1.3.18** to **1.3.19** — Sync Debug Start/Stop UX.

---

## 1.3.18 — May 17, 2026

### Feature — Sync Debug Lifecycle Tracing

The Sync Debug recorder showed individual events but didn't tie related ones together — you could see a REQ FULL go out and a FULL come back later, but couldn't easily tell which was the reply or how long it took. v1.3.18 adds four lifecycle traces that make the full sync picture visible at a glance:

#### 1. REQ → FULL round-trip timing

When you (or any client) sends a REQ FULL, a 30-second watchdog timer arms. When the FULL reply arrives, the log shows the round-trip time and payload size:

> [INFO] RequestFull -> Thrall (explicit-request window open 90s)
> [IN]   FULL from Thrall (12734B, officer)
> [INFO] FULL from Thrall arrived in 4.2s (12734 B / 12.4 KB)

If no reply arrives within 30 s, a TIMEOUT error fires so you know your request was never answered:

> [ERR]  REQ FULL -> Thrall timed out after 30s (no reply)

A fresh REQ supersedes the previous pending one (its timer gets cancelled), so the timeout only fires for the most recent unanswered request.

#### 2. Chunk progress for large FULLs

A 100+ chunk FULL takes 5+ seconds to receive (50 ms/chunk × 100 = 5 s). Without progress info, the user thinks the sync stalled. New milestone lines at **25%, 50%, 75%** fire during the receive — three lines per large FULL, nothing for small ones (which complete sub-second):

> [INFO] Thrall/FULL receiving: 25% (15/60 chunks, 1s elapsed)
> [INFO] Thrall/FULL receiving: 50% (30/60 chunks, 2s elapsed)
> [INFO] Thrall/FULL receiving: 75% (45/60 chunks, 3s elapsed)
> [INFO] Thrall/FULL chunk transfer complete (60 chunks, 4s total)

#### 3. FULL apply summary

After a FULL merges into the local DB, an INFO line summarizes the resulting state plus whether we had locally-unique changes the sender didn't have (which triggers a reverse-sync round so they pick up our changes):

> [INFO] FULL applied: rev now 47, roster 25, history 1247, raids 18
> [INFO] FULL applied: rev now 48, roster 25, history 1248, raids 18  (had locally-unique changes — they'll request from us)

The "they'll request from us" annotation is the visible signal that offline-divergence reconciliation kicked in — useful when debugging "why is my rev one higher than the sender's after this sync?"

#### 4. Periodic stats summary every 60 seconds

While Sync Debug is recording, a tick line fires every 60 s with cluster-health snapshot + traffic counts since the last tick:

> [INFO] tick: last 60s — OUT 12  IN 47  DROP 2  ERR 0   peers 25   rev 47 (in sync)

The rev-gap suffix flips automatically: `(in sync)` if you match the highest peer rev, `(behind by N)` if peers are ahead, `(leading)` if you have unique changes peers haven't pulled. A second-line annotation appears when a FULL request is still pending:

> [sync in progress — waiting for FULL response]

Scroll back through the log and you can see traffic rate trends and cluster-health movement at one-minute granularity without having to count individual lines.

### Why this matters

These are the four questions officers needed to answer with the pre-1.3.18 log but couldn't:

1. "Did my sync request actually get answered?" → REQ/FULL pairing + timeout
2. "Is the big FULL receive stuck or just slow?" → chunk progress milestones
3. "Did the sync actually change anything?" → FULL apply summary
4. "How is the cluster trending — getting healthier or worse?" → periodic stats

### Internals

The lifecycle tracking adds ~5 small fields to existing module state (`_pendingFullReq`, `SD._lastTickAt`, etc.) and one new `C_Timer.NewTicker(60, ...)` while recording is on. Zero cost when Sync Debug is disabled — every new log line still goes through the same `dbg()` helper that bails on a single nil-check.

### Version

- Bumped from **1.3.17** to **1.3.18** — Sync Debug lifecycle tracing.

---

## 1.3.17 — May 17, 2026

### Polish — Peer Priority: Collapse Guild Leader into Officer Tier

Refinement of the 1.3.16 role-priority peer selection. The previous version had three tiers:

| Old (1.3.16) | Members |
|--------------|---------|
| Tier 0 | Guild Leader only |
| Tier 1 | Officers / Officer Alts / Loot Master |
| Tier 2 | Anyone |

**The bug this introduces:** if the Guild Leader logs on with a stale DB (e.g. they were offline during a raid that other officers ran), preferring them as the FULL source would mean adopting their stale roster values. Other officers' offline-divergence reconciliation would replay the missing entries, so convergence still works, but it adds an unnecessary round-trip and exposes a small window where the sync briefly displays the GL's stale numbers before the replay corrects them.

**Fix.** Collapse GL + officers into a single tier so whichever **officer (including the GL)** has the freshest data wins on rev:

| New (1.3.17) | Members |
|--------------|---------|
| Tier 0 | All officers (ranks 0-3): Vibe Master / Officer / Officer Alt / Loot Master |
| Tier 1 | Anyone else |

Within tier 0, highest rev wins (recency of last HB breaks ties). So an officer with rev 100 wins over the GL with rev 95, and the GL with rev 100 wins over an officer with rev 95. Tier 1 is still the no-officer-online fallback.

The chat output's tier label simplified: tier 0 = `[officer]`, tier 1 = `[raider]`. No more separate `[Guild Leader]` annotation — they're treated as just another officer for sync-source purposes.

### Version

- Bumped from **1.3.16** to **1.3.17** — peer-priority tier flattening.

---

## 1.3.16 — May 17, 2026

### Critical Bug Fix — FULL Sync EP/GP Inflation (the "points drift" cause)

Found the root cause of the persistent "points keep getting out of whack" reports. **Every FULL sync received from a peer was inflating the receiver's current EP/GP by the sum of all history entries older than the 200 newest.** Drift compounded with each sync.

**Root cause.** The FULL payload is capped at `HISTORY_SYNC_MAX = 200` newest entries (DC safety — full history can be 2000 rows ≈ 150+ KB, too large for the addon-message channel). The receive-side same-season merge then ran:

```lua
local localUnique = {}
for _, e in ipairs(ns.DB:History()) do
    if not incomingHKeys[histKey(e)] then
        localUnique[#localUnique + 1] = e
    end
end
applyHistoryEntries(localUnique, roster, settings, true)
```

This compares the receiver's **entire local history (up to 2000 entries)** against the **incoming capped 200**. Every local entry beyond the 200th newest was marked "locally unique" even though both sides actually have it — the sender just didn't ship it because of the cap. Those entries then got **replayed** onto the roster that was just set to the sender's already-correct values, doubling them.

Compounded effect: a guild with 5 boss kills/week, 25 attendees → 125 EP entries/week. After 2 weeks the 200-entry cap is exceeded; every FULL sync from then on re-adds the oldest entries' EP to the current pool. Over a month of regular `/vcl sync` use, points can drift by hundreds.

**Fix.** Only treat entries within the **time window covered by the incoming payload** as candidates for "locally unique." Anything older than the oldest incoming entry is below the sender's cap and presumed to exist on both sides:

```lua
local oldestIncomingTime = math.huge
for _, e in ipairs(incomingH) do
    incomingHKeys[histKey(e)] = true
    if (e.time or 0) < oldestIncomingTime then
        oldestIncomingTime = e.time or 0
    end
end

local localUnique = {}
for _, e in ipairs(ns.DB:History()) do
    if (e.time or 0) >= oldestIncomingTime
       and not incomingHKeys[histKey(e)] then
        localUnique[#localUnique + 1] = e
    end
end
```

The genuine offline-divergence case (officer awarded EP while a peer was offline, then they reconnect) still works — those entries fall within the time window and remain in localUnique for replay. The bogus "you have an old entry I just didn't ship" case is now filtered out.

**Recovery from existing drift.** If your standings are already inflated from prior runs, restore from a clean snapshot (`/vcl restore <N>`). The 1.3.13 restore now correctly leapfrogs peers' rev so the restored state stays authoritative.

### Feature — Role-Priority Peer Selection for `/vcl sync`

Old behavior: `RequestFullFromBestPeer` picked the peer with the highest dbRev regardless of role. A random raider with rev 47 would be chosen over the Guild Leader with rev 45 — even though the GL's data is what the cluster eventually converges to anyway (since officer broadcasts are the only ones the authority gate accepts).

New behavior: peers are bucketed into **3 priority tiers**, and we pick the best peer (highest rev, then most-recent-HB) from the **highest occupied tier**:

| Tier | Members |
|------|---------|
| 0 | Guild Leader (rank 0) |
| 1 | Officer / Officer Alt / Loot Master (ranks 0-3) |
| 2 | Anyone else |

A peer in a higher tier **always** wins over a lower tier, even if the lower tier has a higher rev. The reasoning: if the GL has rev 100 and a raider has rev 102, the raider's extra 2 revs are about to be DELTA-replayed onto everyone by the GL's next sync anyway — and the GL's data is the canonical source.

Tier 2 is the off-hours / "no officer online" fallback so casual use still works.

The chat output now shows the tier so you can see at a glance who you're syncing from and why:

> [VibeCheck] Syncing from Thrall [Guild Leader] (rev 47)...
> [VibeCheck] Syncing from Bartholomew [officer] (rev 45)...
> [VibeCheck] Syncing from Pugwash [raider] (rev 41)...   ← only if no officers online

### Other Paths Audited Clean

The full sync subsystem was re-audited for this version. Checked clean (no fixes needed):

- DELTA receive uses `histKey` dedup — single-officer awards apply exactly once
- BATCH_EP receive has its own `seenKeys` dedup — redelivered boss-kill messages don't double-credit
- Roster tombstones (1.3.04) propagate via `---ROSTERTOMB---` section
- History tombstones merge and prune correctly
- Send queue urgency routing (HEARTBEAT, ROLL_START, ROLL_END, REQ are urgent; everything else queues at 20/sec)
- Self-echo guard correctly skips processing our own broadcasts
- Restore (1.3.13) correctly leapfrogs peers' rev so it stays authoritative
- Phase gate (1.3.13/15) scopes EP, GP, and attendance consistently
- Authority gates (officer / leader-or-officer / self-only) correctly route by msgType
- Cold-start fallbacks (1.3.05) for ROLL_START/END still apply

### Version

- Bumped from **1.3.15** to **1.3.16** — FULL sync EP/GP inflation fix + role-priority peer selection.

---

## 1.3.15 — May 17, 2026

### Feature — Phase Gate Now Also Excludes Attendance

Extends the v1.3.13 phase gate to also remove previous-phase raids from attendance-percentage calculations and streak-bonus walks. Previously the gate only stopped EP/GP awards — it still counted Gruul as "a raid you missed" for someone who didn't show up to the farm night, dropping their attendance % unfairly.

**What changes:**

- **Standings attendance column** — previous-phase raids are no longer in the denominator. A current-phase regular who skipped a fluff Gruul run keeps their 100% attendance.
- **Attendance streak bonus** — walks only count current-phase raids, so a previous-phase raid doesn't either inflate or break someone's consecutive-attendance streak.
- **Per-raid attendance display in the raid detail popup** is unaffected — that's the snapshot of who was there for that specific raid, which is correct as-is.

**Behavior when the gate is off:** if you uncheck "Only award EP/GP for current-phase content" in Settings → Officer Settings → EP → Phase gate, attendance includes all raids again (since `ns.shouldTrackPoints` returns true for every zone when the gate is disabled). One toggle controls the entire phase scope: EP, GP, and attendance.

**Unknown zones** (anything not in the `RAID_PHASES` map) default to counting, just like the EP/GP gate. The intent is: only known previous-phase content gets filtered out; everything else is treated normally.

### Version

- Bumped from **1.3.14** to **1.3.15** — phase gate now also excludes previous-phase content from attendance.

---

## 1.3.14 — May 17, 2026

### Polish — Addon Health moved + DB REV column

Two small fixes addressing feedback on the version check dialog:

#### Moved Addon Health to Settings → Sync & Data

Previously the **Check Addon Versions** button lived under Settings → Officer Settings → Mass Edit → Addon Health. Conceptually it didn't fit — Mass Edit is for destructive roster actions (prune, mass GP charge) and an officer browsing for "who has the old addon" wouldn't naturally look there.

New home: **Settings → Sync & Data → Addon Health**. That's where every other peer-/sync-related diagnostic lives (last sync, database rev, peers heard, sync debug), so it's right next to the data that supports it.

#### New DB REV column in the version dialog

The dialog now shows each peer's current database revision alongside their addon version. Useful for catching peers stuck behind on sync state even when their addon version is up to date.

**Column layout** (dialog widened from 560 → 640 px to fit):

| Column | Detail |
|--------|--------|
| PLAYER | Short name |
| VERSION | Addon version |
| **DB REV** | Peer's current dbRev — color-coded vs yours |
| STATUS | Version comparison badge (existing) |
| LAST SEEN | When their last addon message arrived (existing) |

**DB REV color coding:**
- |cff33ff99green|r — matches your rev (in sync)
- |cffd9a64dyellow|r — behind your rev (will catch up on next heartbeat)
- |cff77b3ffblue|r — ahead of your rev (we'll request a FULL from them)
- |cff888888gray|r — unknown / zero (heartbeat hasn't arrived yet — Refresh fixes it)

**Summary line** also includes your own rev for at-a-glance comparison:

> You: 1.3.14   rev 47   |   25 peers heard   |   2 out of date

The dbRev data is already collected automatically — every HEARTBEAT message records the sender's rev into `Sync.presence[name].rev`, so this just surfaces it. Zero new sync traffic.

### Version

- Bumped from **1.3.13** to **1.3.14** — Addon Health moved + DB REV column.

---

## 1.3.13 — May 17, 2026

### Feature — Phase Gate for Previous-Phase Content

Reported scenario: guild ran Gruul's Lair and Magtheridon (Phase 1 content) for a fun farm night while in Phase 2; the EP/GP awards from those raids merged into the current Phase 2 standings and broke the rankings.

**Fix.** New settings under **Settings → Officer Settings → EP → Phase gate**:

- **Current phase** dropdown — Phase 1 (Kara/Gruul/Mag), 2 (SSC/TK), 3 (MH/BT), 4 (SWP). Defaults to 2.
- **Only award EP/GP for current-phase content** checkbox — default **ON**.

When the gate is on and a raid is detected in a zone belonging to an **earlier** phase than `currentPhase`:

- **Raid start** — no `epPerRaidStart` / `epPerTimeOnTime` awards. Raid record still created.
- **Boss kills** — no boss-kill EP / bench EP / streak bonuses. Kill row still added to the raid.
- **Wipes** — no wipe EP. Wipe row still added (subject to the 1.3.11 60s accidental-pull filter).
- **Loot awards** — `gpCharged` is forced to 0. The item is still awarded (winner whisper, auto-trade, roll history) but no GP is deducted from the winner. The history entry's reason is tagged `<rollType> (previous phase — no GP)` so the audit trail is self-documenting.

Status messages surface at the moment of each gated event so the leader knows the filter fired:

> [VibeCheck] Boss kill in previous-phase content (Gruul's Lair) - no EP awarded.

The phase gate settings sync via the existing settings-sync path (added to `SYNCABLE_SETTINGS`) so every officer's client agrees on the current phase.

**Future-proof.** When Phase 3 launches, change Current phase to 3 and Phase 2 raids auto-fall under the gate. The phase map in Constants.lua (`ns.C.RAID_PHASES`) ships with all four TBC raid phases pre-mapped including alternate zone names (`Tempest Keep` / `The Eye`, `Hyjal Summit` / `Mount Hyjal`) so the lookup matches whatever name your client returns.

**Public helpers:** `ns.raidPhase(zone)` and `ns.shouldTrackPoints(zone)` are exposed on the namespace for any future feature that needs phase-aware logic.

### Feature — Previous Phase Section in Raid Log

Raids in previous-phase zones still log to the raid history (loot drops, attendance, kills are preserved — you can still browse them). They now appear in a separate **Previous Phase** section at the bottom of the Logs → Raids list, visually separated by a divider:

```
[Newest current-phase raid]
[...]
[Oldest current-phase raid]
━━━ Previous Phase (3 raids, no EP/GP awarded) ━━━
[Newest previous-phase raid]
[...]
```

The divider only appears when both sections have at least one raid in them, so it's not orphaned at the top if you only have previous-phase raids logged.

### Bug Fix — Restore Now Bumps dbRev Past Peers

Reported symptom: officer ran `/vcl restore` to fix point corruption; their local rev was behind the cluster, peers' higher revs re-stomped parts of the restore on the next sync.

**Root cause.** The pre-1.3.13 restore path called `ns.DB:Bump()` on the loaded snapshot, which just added 1 to the snapshot's stale rev. If peers were at rev 47 and the snapshot was rev 23, restored DB landed at rev 24 — still well behind. Next FULL merge from any peer with higher rev silently overwrote the restored state.

**Fix.** `Backup:Restore` now stamps the restored DB with `dbRev = max(snapshotRev, localRevBefore, peerHighestRev) + 1` so the restored state is **strictly greater** than any rev the cluster has seen. After the stamp, an automatic `SendFull` 0.5 s later pushes the restored state to peers so they converge on it.

The chat output now shows the rev transition explicitly:

> [VibeCheck] Database rev set to 48 (was 23, peer high 47) — push full to guild...

so officers can see at a glance whether the restore took authority.

### Version

- Bumped from **1.3.12** to **1.3.13** — phase gate, previous-phase log section, restore-bumps-rev fix.

---

## 1.3.12 — May 17, 2026

### Bug Fix — Per-Boss EP Override Inconsistently Applied

Reported symptom: "the first two bosses give the correct override EP, but the third boss reverts to the default `epPerBossKill`."

**Root cause.** The addon detects boss kills via two parallel signal sources, and they pass **different ID types** to the same `OnBossKilled` handler:

| Code path | ID passed |
|-----------|-----------|
| `OnEncounterEnd` (Blizzard encounter system) | Encounter ID (e.g. 652 = Attumen) |
| `OnCombatLog` (UNIT_DIED fallback) | NPC ID (e.g. 16151 = Attumen) |

The `bossEPOverrides` table is keyed by **NPC ID** (the UI builds it from `BOSS_LIST`, which uses NPC IDs). Whichever signal fired first won the dedup race:

- COMBAT_LOG fires first → NPC ID lookup → **override hits** → correct EP awarded
- ENCOUNTER_END fires first → encounter ID lookup → **silently misses** → falls back to default

Because the race resolution depends on timing (server response, addon load order, combat-log buffering), it manifests as "boss A is correct, boss B is wrong" inconsistently across the raid.

**Fix.** Two-step lookup with a name-based fallback:

1. Try `overrides[id]` directly — works when the COMBAT_LOG path wins or when the IDs happen to coincide.
2. If that misses, walk `BOSS_LIST` for an entry whose `name` matches the encounter name (case-insensitive). Use **that** NPC ID for the lookup.
3. If both paths miss, fall back to `epPerBossKill` (the previous default behavior).

`BOSS_LIST` has ~70 entries, so the scan is essentially free at boss-kill frequency. The fallback is locale-safe: encounter names from Blizzard's ENCOUNTER_END match the names in `BOSS_LIST` for all TBC bosses we ship.

### How it manifests post-fix

| Scenario | Pre-1.3.12 | Post-1.3.12 |
|----------|-----------|-------------|
| Boss A, COMBAT_LOG fires first | Override applied | Override applied (same) |
| Boss A, ENCOUNTER_END fires first | **Default fallback** | Override applied via name lookup |
| Multi-NPC encounter (Eredar Twins) | Depends on signal order | Override applied to whichever NPC has it set |
| Boss with no override set | Default applied | Default applied (same) |

### Version

- Bumped from **1.3.11** to **1.3.12** — per-boss EP override now reliably applies regardless of which signal source detected the kill.

---

## 1.3.11 — May 17, 2026

### Bug Fix — Boss Fight Duration Showing 30+ Minutes

**Root cause.** The raid detail popup's `DURATION` column was computing `enc.time - prevEncounterTime` — which is the **gap between kills**, not the boss fight duration. If you wiped a few times, took a 25-minute repair / strategy break, then killed the next boss in 4 minutes, the boss kill displayed as "29:00 duration" because the math counted the entire pre-kill downtime.

**Fix.** Encounters now track the ACTUAL engagement window:

1. `OnEncounterStart` records `{id, name, startTime}` on a module-level `_activeEncounter` slot.
2. `OnEncounterEnd` computes `duration = time() - startTime` and clears the slot.
3. `OnBossKilled` / `OnBossWipe` accept the engageTime + duration and store both on the kill record.
4. RAID/KILL broadcast carries the new fields (payload slots 8 + 9). Backward-compat: older receivers read slots 1-7 and ignore the extras silently. Older senders don't include the fields, and receivers fall back to the legacy "time since previous kill" display for those legacy records.
5. The detail popup's `DURATION` column now displays the actual fight length: `3:42` for a 3-minute-42-second fight regardless of how much downtime preceded it.

**COMBAT_LOG fallback path.** Clients where `ENCOUNTER_END` is unreliable use `COMBAT_LOG_EVENT_UNFILTERED` (UNIT_DIED) to detect kills. That path still works: `OnBossKilled` consults `_activeEncounter` to derive engageTime if the caller didn't pass it explicitly.

### Bug Fix — Accidental Pulls Logging as Wipes

**Root cause.** A brief encounter (rezz aggro, mistarget that resets in seconds) was logged in the raid record as a Wipe entry, even though no real attempt was made. These polluted the encounter list with sub-minute wipe rows.

**Fix.** New setting `minWipeDurationSec` (default **60 seconds**) gates whether ENCOUNTER_END(success=0) actually records a wipe. If the engagement was shorter than the threshold, the wipe is silently dropped from the raid record. The leader sees a one-line chat notice so they know a brief pull was caught and discarded:

> [VibeCheck] Brief encounter (12s) not logged — Brutallus

**Kills are NEVER filtered** by this threshold. An overgeared 30-second clean kill is still a valid kill — the filter only applies to failed attempts (`success=0`).

### How the two fixes interact

- 12-second rezz aggro → no log entry (filtered by minWipeDurationSec).
- 4-minute wipe on Felmyst → logged with `duration = 240`, displays as "4:00".
- 25-minute lull, then 3-minute kill on Brutallus → logged with `duration = 180`, displays as "3:00" (not "28:00" like before).
- Legacy raid records that pre-date this version don't have `duration` fields → fall back to the old "time since previous kill" math, so existing logs render the same as before.

### Version

- Bumped from **1.3.10** to **1.3.11** — boss fight duration + accidental pull filter.

---

## 1.3.10 — May 17, 2026

### Feature — One-Click Reassign from View Rolls

The v1.3.09 reassign flow required two trips through the right-click menu (View Rolls to identify the next-highest, then Reassign Winner to type the name). v1.3.10 collapses it into a single click: click any roller in the View Rolls dialog and confirm.

**Behavior:**

- **Officer rows** (clickable, not the current winner) — hover shows a tooltip explaining the action; click pops a StaticPopup confirming the GP refund and re-charge; accept performs the swap.
- **Current winner row** — hover tooltip explains "already holds this item"; no click action.
- **Non-officer view** — rows are not clickable. The dialog stays useful for raiders who want to verify their own rolls.

The dialog's sub-header now hints at the new behavior for officers: `Click a roller to reassign to them.`

**Internals.** Extracted the reassign logic from `OpenReassignWinnerDialog` into a shared `UI:ReassignLootWinner(entry, newWinner)` helper that handles role-gating, history-entry relocation by content match (in case sync inserted entries above it since the right-click), GP refund + charge, and both sync broadcasts (`HIST_DEL` for the refund, `DELTA` for the new charge). The typed-name dialog from 1.3.09 now delegates to the same helper, so the two paths are guaranteed to behave identically.

The legacy `Reassign winner...` right-click action still exists for the rare case where the new owner didn't appear in the roll list (e.g., gifting outside the roll system, or a manually-awarded item).

### Workflow comparison

**Before (1.3.09):**
1. Right-click GP entry → View rolls → identify next-highest roller (close dialog)
2. Right-click GP entry → Reassign winner... → type the name → Reassign

**Now (1.3.10):**
1. Right-click GP entry → View rolls → click the next-highest roller → Reassign

### Version

- Bumped from **1.3.09** to **1.3.10** — one-click reassign from View Rolls.

---

## 1.3.09 — May 17, 2026

### Feature — Raid Logging Quality of Life (5 in 1)

Major pass on post-raid bookkeeping based on raid-day feedback.

#### 1. End raid from any officer's client

Previously the **Force End Now** button only showed for "stale" raids (more than 12 h old). That meant if the raid leader walked away without killing the final boss, no other officer could finalize the raid record until the 12-hour staleness timer fired. Now any officer can end any in-progress raid directly from the raid detail popup:

- **Stale raid** (>12 h, no kill events recently): button reads `Force End Now` (existing flow — sets `end = lastKill + 30 min` and flags `autoHealed`)
- **In-progress raid** (<12 h, just left mid-clear): button reads `End Raid Now` — sets `end = time()` and broadcasts RAID/END to all peers

Button is officer-gated. Single click, no confirm — the raid is already in a "needs cleanup" state.

#### 2. Attendance tab in raid detail popup

The raid detail popup now has two tabs:

- **Encounters** (default) — existing kill / wipe / loot list
- **Attendance** — two-column view of **Attendees** (raiders who were in the snapshot) and **Bench** (declared standbys). Class-colored, alphabetized. Counts in each column header.

Tab state is intra-dialog — opening a different raid always resets to Encounters so the experience is predictable.

#### 3. Loot attribution rewrite

Reports of "I don't see the loot we distributed" had a real cause. The old algorithm used time windows (`[killTime - 30s, nextKillTime + 30s]`) and silently dropped loot that fell outside any window — late master-loot trades, items awarded after raid end, or AFK officer trades all slipped through.

New algorithm:

1. Every GP history entry whose `raidId` matches this raid is attributed somewhere.
2. Each loot row binds to the most recent kill encounter that happened before its timestamp (with 30 s slack for clock skew).
3. Loot timestamped before every kill (rare — pre-pull mistake) binds to the first kill.
4. Loot belonging to a raid with no kill events at all surfaces in a new **Unattached loot** section at the top of the detail popup, so the data is visible even if the encounter context is missing.

End result: **every loot row tagged with this raidId is visible somewhere**. No more silent drops.

#### 4. Right-click → Reassign Winner

When a winner backs out of an item AFTER you've already pressed Award, you used to have to manually delete the GP charge and re-award through the UI workflow. Now: right-click the GP entry in the EP/GP log, click **Reassign winner...**, type the new player's name, click Reassign.

The dialog:
- Confirms the current holder + GP cost
- Live-previews the new winner's current GP / PR as you type
- One click reassigns: refunds the old winner via `DeleteHistoryEntry` (which broadcasts `HIST_DEL` for sync convergence), charges the new winner with the same GP and item link, and broadcasts a fresh `DELTA` for the new charge
- The new history entry's `reason` field reads `"reassigned from <oldName>"` so the audit trail is clear

Both halves go through the existing sync code paths so peer convergence is guaranteed (online: live; offline: on next FULL).

#### 5. Roll history capture + right-click → View Rolls

`Loot:Award` now captures the full roll leaderboard at the moment of award and stores it on the GP history entry (new `rolls` field — list of `{name, type, value}`). The roll snapshot also propagates via DELTA broadcast (new 9th payload field, backward-compat: older clients ignore it), so every officer's client has the same data.

Right-click any GP loot entry → **View rolls (N)** to see the dialog. Shows every roll sorted by priority (MS > MINOR > OS, then by value within each type). Columns: RANK, NAME, TYPE, ROLL, current PR. The original winner is highlighted in green.

Pairs naturally with **Reassign winner...** — you see who had the next-highest roll, then reassign to them with one click.

### Protocol Note

The DELTA payload gains a 9th tab-separated field for the encoded roll snapshot (`name|type|value;name|type|value;...`). Pre-1.3.09 clients only read fields 1-8 and silently ignore the extra field — fully backward-compatible. Peers running 1.3.09+ get the rolls; peers on older versions just don't show the View Rolls option.

### Version

- Bumped from **1.3.08** to **1.3.09** — raid logging quality-of-life pass.

---

## 1.3.08 — May 13, 2026

### Feature — Addon Version Check (Officer Tool + Self-Notification)

Two ways for outdated installs to surface, so officers can keep the raid on the same page without manually pinging everyone in Discord.

#### Self-notification

When the user receives any addon message from a peer running a **newer** version than theirs, a one-time chat warning fires:

```
[VibeCheck] Your VibeCheck is out of date.  You: 1.3.05  | Latest seen: 1.3.08 from Thrall.  Update via CurseForge.
```

- **One per session.** A boolean flag prevents the warning from firing again for any subsequent peers in the same session — no spam if 20 people are running newer.
- **Detection is free.** Every CHAT_MSG_ADDON the addon already processes carries the sender's version string in the framing header. The check is a single string compare per inbound message, gated by `_notifiedNewerVersion` to bail immediately after the first hit.
- **Logged to Sync Debug** so an officer can see who triggered the notification: `INFO outdated notify: ours=1.3.05 peer=1.3.08 (from Thrall)`.

#### Officer dialog — Check Addon Versions

New button at **Settings → Officer Settings → Mass Edit → Addon Health → Check Addon Versions**. Opens a 560×440 popup listing every peer we've heard from with:

| Column | Detail |
|--------|--------|
| **PLAYER** | Short name (no realm suffix) |
| **VERSION** | Semver-like string from the framing header |
| **STATUS** | Color-coded comparison vs your version |
| **LAST SEEN** | "just now" / "Nm ago" / "Nh ago" |

Status badges:
- |cff33ff99**up to date**|r (green) — exact match
- |cff77b3ff**newer than you**|r (blue) — peer is ahead, **you** should update
- |cffd9a64d**out of date**|r (yellow) — peer is one or more patch versions behind
- |cffee4d4d**VERY OUTDATED**|r (red) — peer is a major or minor version behind (e.g. they're on 1.2.x while we're on 1.3.x)

Rows are sorted **out-of-date first**, then alphabetically — so the people you need to ping are at the top.

**Refresh button** broadcasts a fresh `Heartbeat(true)` and re-opens the dialog after 1.5 s, by which time peers' replies have populated the presence cache. Useful for catching raiders who logged in after you first opened the dialog.

#### New helper — `Sync.CompareVersion(a, b)`

Public version-compare function on the Sync module. Splits each string on any non-digit run (so `"1.3.07-beta"` and `"1.3.07"` both parse to `{1, 3, 7}`) and returns negative / 0 / positive standard 3-way compare result. Used by both the dialog and the self-notification path; available to any other module that wants version-aware logic later.

### Version

- Bumped from **1.3.07** to **1.3.08** — addon version check dialog + outdated self-notification.

---

## 1.3.07 — May 13, 2026

### Bug Fix — Roll Window Timer Wrong on Receivers (Clock Skew)

Player reported their roll popup showed `Rolls close in 2 minutes` when the leader had `rollDurationSec` set to 30 seconds. Traced to a clock-skew bug in how the receiver computed the countdown.

**Root cause.** WoW's `time()` function returns the player's **local OS clock**, not a server-synced timestamp. Two players whose machine clocks are set 90 seconds apart will see two different values for "now" — and most players don't have NTP locked, so multi-minute drift is common.

The ROLL_START broadcast carried both `duration` (e.g. 30) and `endTime` (the leader's absolute `time()` + duration, e.g. `1700000030`). The receiver used `endTime` directly to compute the countdown:

```
remaining = endTime - time()
```

Concrete example: leader's OS clock = 1000, so `endTime = 1030`. Network message arrives at a receiver whose OS clock = 910. Receiver computes `remaining = 1030 - 910 = 120 seconds` and renders "Rolls close in 120s." Exact symptom reported.

**Fix.** The receiver now **ignores the leader's `endTime`** and recomputes from `duration` using the local clock:

```
endTime = time() + duration   -- always, using receiver's clock
```

Each client now sees exactly `duration` seconds on its own timer regardless of clock skew. The trade-off is ~1 second of extra grace (the message took some time to arrive between leader broadcast and receiver compute), which is dramatically better than 90 seconds of skew. The auto-close fallback timer that fires `duration + 30 s` after receipt also benefits — it now correctly closes a stale popup 30 s after the leader's intended end-of-roll, instead of 30 s after some clock-skew-adjusted moment.

**Why this likely caused the missing-popup reports too.** A receiver whose clock is FASTER than the leader's would compute `remaining = endTime - time()` and get a **negative** number — `time()` was already past the leader's `endTime` at receipt. The OpenRollWindow path showed the frame but the timer immediately displayed "Rolls closed — pick a winner" and the auto-close fallback (`secsLeft + 30 s`) became effectively 30 s, after which the window disappeared. To the player, that looks identical to "popup never opened."

The protocol still carries `endTime` in the payload for backward compatibility (older receivers still read it) and for the Sync Debug log line, but it's no longer trusted for timer math.

**Pipeline audit (nothing else affected).** Other timing displays already use receiver-local clocks consistently:
- Raid "in progress" duration: `time() - raid.start`, both local → correct
- "Last sync N minutes ago": `time()` minus a value stored when the FULL was applied → both local
- Heartbeat freshness checks: all local-clock vs local-clock arithmetic
- Roll auto-close fallback: now correct after this fix
- Roll history timestamps: stored at the moment of award using local clock → consistent

### Version

- Bumped from **1.3.06** to **1.3.07** — roll timer clock-skew fix.

---

## 1.3.06 — May 13, 2026

### Stability — Connection / DC Audit

Targeted pass on three classic addon disconnect / lag-spike vectors. None of these were definitely causing live problems but each had a measurable risk under raid-day load — closed all three before they could matter.

#### Fix 1 (HIGH) — Heartbeat debounce on rapid `GROUP_ROSTER_UPDATE`

**The risk:** during raid setup, `GROUP_ROSTER_UPDATE` fires 5-10 times in quick succession as members join, leave, swap roles, and toggle online status. The handler scheduled a force-Heartbeat with a random 0-3 s stagger on **every fire**, with no debounce. Math: 25 raiders × 10 events × 1 scheduled HB each = up to **250 HBs landing on the raid channel in a 3-5 s window**. Per-client send rate stays low (~3 msgs/sec from any single client, fine), but the *aggregate* channel throughput averages 50-80 msgs/sec — well into WoW's anti-flood disconnect territory.

**Fix:** new `Sync:ScheduleHeartbeat()` helper that holds a `_hbPending` flag. First call schedules a randomly-staggered HB; subsequent calls during the pending window are ignored. `Core.lua` GROUP_ROSTER_UPDATE handler now uses this instead of calling `C_Timer.After(rand, ...)` directly. Effect: regardless of how many roster updates fire, **each client sends at most one HB per pending window**, which collapses the worst-case channel burst from 250 messages to 25.

#### Fix 2 (MEDIUM) — `npcIdFromGUID` allocation in the combat-log hot path

**The risk:** `Attendance:OnCombatLog` fires for every COMBAT_LOG_EVENT_UNFILTERED — hundreds of times per second in combat. The early-exit on non-UNIT_DIED is fast (one string compare and return), but UNIT_DIED itself fires 10-50/sec during a chaotic pull (raid kills + trash + pet deaths + totems). For each UNIT_DIED we extracted the NPC ID from the GUID using `select(6, strsplit("-", guid))` — which allocates a 7-element table just to grab one number.

**Fix:** replaced with `guid:match("^[^-]*-[^-]*-[^-]*-[^-]*-[^-]*-(%d+)-")` — a single regex capture that returns just the integer, zero intermediate allocations. ~5x faster and **zero GC churn** in the addon's hottest hot path.

#### Fix 3 (LOW) — Idle ticker burning per-frame closures

**The risk:** `Sync.lua`'s `_recentAddonWhispers` prune frame used an `OnUpdate` handler that ran on every frame (~60/sec at idle) just to accumulate elapsed time and bail until 60 seconds had passed. Per-frame cost is tiny but it's pure waste — the work only needs to happen once per minute.

**Fix:** replaced with `C_Timer.NewTicker(60, fn)`. Schedules exactly one callback per minute, **zero per-frame work**. Net delta is ~60 fewer Lua closure calls per second forever.

#### What checked out clean (no fix needed)

- `BATCH_EP` for boss kills is already one message per kill (not 25)
- `Sync:Send` correctly routes URGENT messages immediately and queues everything else at 20/sec via the shared chunk ticker
- `ROSTER_DEL` from Prune Ex-Guildies goes through the rate-limited queue (~20/sec drain, safe even at 50+ prunes)
- `pruneStaleEntries` is bounded to the active presence / req-cooldown / inbox sets (~25-50 entries max)
- `PLAYER_EQUIPMENT_CHANGED` profile rebroadcasts have internal 2 s debounce + 15 s cooldown
- All OnUpdate handlers other than Fix #3 are either user-input-gated (drag handlers fire only while mouse held), throttled to 1 Hz (footer, roll timer, raid row pulse), or fire only when the parent frame is shown (pip pulse)
- Self-echo guard in `Sync:OnMessage` prevents the leader from processing their own broadcasts
- The CHUNK_INTERVAL / CHUNK_SIZE math for large FULL sends is correct (50 ms × 20/sec × 215 bytes/chunk, well under WoW's per-client outgoing quota)

### Version

- Bumped from **1.3.05** to **1.3.06** — connection / DC stability audit.

---

## 1.3.05 — May 13, 2026

### Bug Fix — Roll Popup Reliability (Cold-Start Authority Race)

Audited the full roll-popup pipeline after reports of players occasionally not seeing the roll window. Found and closed a real cold-start race that explains the symptom even in fully-up-to-date addons.

**The race:**
1. Player reloads or just logs in
2. `CHAT_MSG_ADDON` listener is wired immediately (event dispatcher fires during ADDON_LOADED)
3. But `_rankCache` (guild roster) takes ~5-10 s to populate — the server hasn't pushed the roster yet
4. Raid roster takes another ~2-3 s — `UnitName("raid1..N")` returns nil even though `GetNumGroupMembers()` reports the right count
5. A `ROLL_START` arriving during this window hits `senderIsLeaderOrOfficer(sender)`
6. `senderIsOfficer` returns false (rank cache empty, no fallback at this layer)
7. `senderIsGroupLeader` returns false (raid roster lookup also empty)
8. **Message silently dropped — popup never opens for the rest of the roll**

**Fix — asymmetric-cost auth (`rollAuthOk`):** new helper that runs the standard officer-or-leader check, then if it fails, decides whether we *actually had data* to make the call. If both the rank cache AND the raid roster were empty, we couldn't have verified anyone — accept the message instead of dropping it. If either cache had data and the sender still failed, that's a real rejection (someone genuinely doesn't have authority).

The rationale is asymmetric cost:
- **False positive** (accepting a spoofed ROLL_START during cold-start): user sees an extra roll popup and clicks Pass. Annoying. Recoverable.
- **False negative** (rejecting a legit ROLL_START during cold-start): user misses the roll entirely. No recovery — by the time they realize, the timer's expired.

The first is strictly less bad than the second, so when in doubt, accept.

Same fix applied to `ROLL_END` so post-cold-start FOMO doesn't leave a popup orphaned.

### Defensive Instrumentation

Sprinkled the receive path with Sync Debug logging so the *next* time someone complains about a missing popup, we can pinpoint exactly where it dropped:

- **ROLL_START accepted under cold-start fallback** logs `[cold-start accept]` in the IN line so you can tell the new code path from the strict path
- **Empty `itemLink`** in OnRollStartBroadcast: `OnRollStartBroadcast: empty itemLink (popup not opened)`
- **Malformed `itemLink`** missing the `|Hitem:` prefix: `OnRollStartBroadcast: malformed itemLink (popup not opened): <first 40 chars>`
- **UI not yet initialized**: `OnRollStartBroadcast: ns.UI.OpenRollWindow missing`
- **Successful popup open**: `roll popup opened: <item name>` (INFO line — only present when Sync Debug is recording)

Officers can now open **Settings → Sync & Data → Start Sync Debug** before raid to capture every roll's path in real time. If a player reports missing the popup, the officer's log shows exactly what their client saw.

### Audit Notes (no code change required)

For the record, the rest of the pipeline checks out:
- `Sync:Send` correctly drops if **sender's** `syncEnabled` is off, with a one-time chat warning to the leader (Loot.lua:213)
- `Sync:OnMessage` does NOT check the **receiver's** `syncEnabled` — so a sync-disabled user still receives the ROLL_START and opens the popup (correct — popups should always work even if you've opted out of broader sync traffic)
- `ROLL_START` is in `SEND_URGENT`, bypassing the chunk queue and sending immediately via `SendAddon` (no throttle delay)
- Self-echo guard correctly skips processing our own broadcasts (the leader already has the session live locally)
- v1.2.7's session-teardown fix is still in place: a stale `L.session` no longer swallows incoming ROLL_STARTs
- Roll frame is strata DIALOG with `SetClampedToScreen(true)`, so it can't be hidden off-screen or behind other UI

### Version

- Bumped from **1.3.04** to **1.3.05** — roll popup cold-start fix + defensive logging.

---

## 1.3.04 — May 13, 2026

### Bug Fix — Prune Ex-Guildies Now Propagates to Everyone

**Root cause:** the `ROSTER_DEL` sync message added in 1.2.15 was the **primary** propagation path, but it had no backstop. Peers who were offline at the moment of the prune, peers whose addon-message chunk got dropped, peers who freshly installed the addon, or peers who took longer than 90 s to come online after the broadcast — none of them ever received the deletion. The "belt-and-braces" SendFull that follows the prune also didn't help: the same-season FULL roster merge is **purely additive** on receive — receivers iterate the incoming roster and never remove locally-known names that are absent from it. So a peer who missed the live ROSTER_DEL kept the pruned player forever.

**Fix — roster-deletion tombstones**, parallel to the existing history-tombstone system:

1. **`DB:RemovePlayer(name)`** now writes the deletion to `db.deletedRosterNames[name] = time()`.
2. **`DB:GetOrCreatePlayer(name)`** clears the tombstone when a new roster row is created for that name — handles the "officer pruned Foo, then Foo rejoined the guild and shows up in raid" case so they're not silently re-deleted on the next FULL.
3. **`Sync:SendFull`** serializes the tombstones into a new `---ROSTERTOMB---` payload section.
4. **FULL receive** parses the new section. For each tombstoned name, if the local roster has them but no activity newer than the tombstone (`lastSeen` and history both predate it), drop the row. If activity exists post-tombstone, the tombstone is treated as obsolete and cleared locally (the player legitimately came back).
5. **ROSTER_DEL receive handler** also writes the tombstone locally — even when the player isn't in the receiver's roster — so the receiver's **own future FULLs** carry the deletion forward to peers who weren't online when the original officer pruned.
6. **Tombstone TTL** matches the existing 90-day expiry for history tombstones, so the set can't grow without bound.

**Backward compat.** Pre-1.3.04 clients don't know about the `---ROSTERTOMB---` header and will treat its lines as if they belonged to the history-tombstones section. Those parse cleanly as malformed history tombstones (the roster-name keys never match a `histKey` so they're inert), then age out after 90 days. Harmless, just slightly leaky during the transition window.

**Visibility.** Every roster-tombstone application is logged as an INFO line in the Sync Debug recorder:
- `FULL ROSTERTOMB: dropped Foo` — tombstone was applied, row removed
- `FULL ROSTERTOMB: Foo is active after tomb, dropping tombstone` — player came back, tombstone obsolete

So a confused officer can open Sync Debug to verify what's actually happening on each FULL receive.

**Net effect:** an officer who runs Prune Ex-Guildies now triggers:
- Primary: per-name ROSTER_DEL broadcasts (immediate, as before)
- Backstop 1: SendFull 0.5 s later with the new ROSTERTOMB section (catches peers who missed the live broadcast)
- Backstop 2: every receiving peer adds the tombstone to their own DB, so their next outbound FULL also carries it (gossip propagation — eventually reaches every officer in the guild)

### Version

- Bumped from **1.3.03** to **1.3.04** — Prune Ex-Guildies sync convergence fix.

---

## 1.3.03 — May 13, 2026

### Feature — Hide Pugs from Standings

Common ask for guilds that fill empty raid slots with players outside the guild: keep those pugs out of the EPGP standings while still letting them roll on and receive loot. New setting **Track guild members only**, defaults ON, lives at **Settings → Officer Settings → Mass Edit → Roster Scope**.

**Three things change when the setting is on:**

1. **Raid snapshot ignores pugs.** `Attendance:SnapshotRaid` checks each raid member against the live guild roster; non-guildies are silently skipped. They don't get a roster row created, they aren't listed as attendees, and they don't earn raid-start / boss-kill / streak EP. Each skip is logged as an INFO line in the Sync Debug recorder (`snapshot skipped non-guild: <name>`) so an officer can verify the filter is doing the right thing.

2. **Standings tab filters them out at render time.** Any roster record whose name isn't currently in the guild is hidden from the Standings list. This is the defensive catch for legacy data — pugs that slipped into the DB before this version, or guildies who have since left. The result count line shows the hidden count (e.g. `25 players   (3 pugs hidden)`) so you can see the filter is active.

3. **Loot still works for pugs.** The roll system and loot-award flow run through `Loot.lua`, which creates a roster row on demand when GP is charged. So a pug can `/roll`, win, and receive the item with GP charged — they just don't appear in Standings afterward. If you ever want to invite the pug into the guild later, their GP history is preserved in the EP/GP log.

**Cold-start safety.** The first 5-10 seconds after login, the server hasn't sent the guild roster yet, so `GetNumGuildMembers()` returns 0. If we naively filtered, the entire raid would be silently excluded during that window. The new `ns.isGuildMember(name)` helper handles this: when the roster cache is empty, it returns `true` (allow everyone) until the cache populates. Better to over-track briefly than to lock out the whole raid.

**Architecture note.** The guild-membership check is exposed as `ns.isGuildMember(name)` on top of Sync.lua's existing rank cache, so it's essentially free — the cache is already maintained for sender-authority checks. The new feature adds zero new server queries.

**Cleaning up existing pug data.** Three options:
- Leave them in the DB — the Standings filter hides them with zero ongoing impact.
- Hit **Settings → Officer Settings → Mass Edit → Prune Ex-Guildies** for a permanent cleanup (this was already there; now it pairs nicely with the new toggle).
- Turn the setting off temporarily if you ever DO want to see pugs in Standings.

### Version

- Bumped from **1.3.02** to **1.3.03** — guild-only roster filter.

---

## 1.3.02 — May 13, 2026

### Feature — Raid Auto-End Reliability

Closes the biggest gap in raid tracking: raids that never auto-ended because the group left without killing the final boss. Three new auto-end triggers, plus a stale-raid heal pass, plus UI affordances for cleaning up the damage on existing zombie raids.

**Previous behavior:** the addon only auto-ended a raid when the **final boss of the zone** was killed (5-min delayed close) or on clean `PLAYER_LOGOUT`. Anything else — wipe on the last boss, disband mid-clear, ALT+F4, raid-leader role swap — left the raid "active" forever, polluting the log and making everything claim to be "In progress."

**New auto-end triggers (Attendance.lua + Core.lua):**

1. **Leave-instance timer** — listens to `ZONE_CHANGED_NEW_AREA`. When the raid leader steps out of the raid instance, a 10-minute timer starts. Re-entering cancels it. If it fires, the raid is closed at the current time. Friendly chat messages: `"Raid auto-end scheduled in 10 min (left raid instance)"` → on cancel `"Auto-end cancelled (re-entered raid instance)"` → on fire `"Auto-ending raid (left raid instance)"`.

2. **Disband timer** — listens to `GROUP_ROSTER_UPDATE`. When the raid leader's group goes from raid → solo (disbanded or left), a 5-minute timer starts. Re-joining a raid cancels it.

3. **Stale-raid heal on login** — listens to `PLAYER_ENTERING_WORLD` (fires on `/reload` too, unlike the previous `PLAYER_LOGIN`-only fallback). Any "active" raid with `start` more than 12 hours ago is auto-closed. The healed `end` is set to `lastKillTime + 30 min` (or `start + 5 h` if no kills exist), so duration and loot-attribution windows stay bounded. The raid record is tagged `autoHealed = true` so the UI can label it visibly. `RAID/END` is broadcast to peers so everyone converges on the same healed timestamp.

**Single-leader guard:** only the actual `UnitIsGroupLeader("player")` client runs the timers. Without this, every officer's client would race to call `EndRaid`, and the slowest broadcast would re-stomp the already-ended raid.

### Bug Fix — Time and Duration Display

Several display quirks that contributed to "this doesn't make sense":

- **Stale-raid badge in main raid list.** A never-ended raid older than 12 h now shows `"Stale - click to end"` in red instead of a pulsing green `">> In progress"` that would otherwise lie indefinitely. Shift+click on a stale row in the main list force-ends it immediately. Tooltip explains.
- **Force End Now button** in the raid detail popup, visible only for stale raids and only to officers. Single-click (no confirm — the raid is already broken; the action is purely corrective). Sets `end = lastKill + 30 min` and broadcasts `RAID/END` to peers.
- **Detail popup duration math.** A stale raid's duration is now capped at last-kill time instead of growing forever via `time() - raidStart`. End-time line displays as `"Stale - never ended"` (red) for stale raids, `"In progress"` (green) for fresh active raids, or `"<date> (auto-healed)"` (orange) for raids that were closed by the stale-heal pass.
- **Negative duration clamp.** Per-encounter `TIME` and `DURATION` columns now floor at 0 when kill timestamps arrive out-of-order (rare, e.g. an assist's COMBAT_LOG fires before the leader's ENCOUNTER_END due to clock skew between peers). Previously displayed as `"-12:34"`.

### Feature — Loot Distribution in Raid Log

The data was always tracked (every loot award has its `raidId` set), but the detail popup's `LOOT` column header was never populated and there was no loot count anywhere else, making it look like loot wasn't recorded. Rebuilt the loot display:

- **Main raid list** gains a `LOOT` column showing the total items distributed for that raid at a glance (e.g. `12`). Pre-bucketed in one pass through history before rendering so it's free per-row.
- **Detail popup encounter row** now shows the per-kill item count in the `LOOT` column (e.g. `3 items`). The header is no longer empty.
- **Per-item indented sub-rows** still appear under each kill showing winner, GP charged, and item link. Hovering a sub-row now shows the full item tooltip via `GameTooltip:SetHyperlink`.
- **Wider matching window for the last kill of a raid.** Master-loot trades that happen after the raid is officially over (especially common with auto-trade) were getting dropped from the last-boss attribution. Trailing window for the final encounter is now `raidEnd + 20 min` (or `lastKill + 30 min` if the raid never ended), so post-clear loot awards still attach to the boss that dropped them.

### Version

- Bumped from **1.3.01** to **1.3.02** — raid tracking reliability + loot attribution rebuild.

---

## 1.3.01 — May 13, 2026

### Bug Fix — "No player named 'X' is currently playing" chat spam

When a peer with the addon logged off or came online, the chat window would sometimes get spammed with `No player named 'X' is currently playing.` system messages. **Root cause:** the addon's heartbeat / rev-mismatch reply paths use `SendAddonMessage("WHISPER", target)` to talk to specific peers. WoW's server bounces these against offline players the same way it bounces a `/whisper` — and the bounce-back surfaces as a CHAT_MSG_SYSTEM error in chat, even though the underlying addon message itself was invisible. With a 30 s presence cache, there's a window of several seconds where the addon still thinks a peer is online and tries to whisper them right as they log out.

**Fix:** the sync layer now installs a `CHAT_MSG_SYSTEM` chat filter that suppresses these offline-errors only when they correspond to one of our own recent addon-message whisper targets. Every time `SendAddon(... "WHISPER", target ...)` fires, we record `target` with a timestamp; any "no player named X" message that arrives within 10 s and matches a recorded target is dropped from the chat window. The suppression table self-prunes every 60 s so it can't grow without bound.

**What's still preserved:** real `/whisper` errors (Loot.lua winner notifications, Whisper.lua replies — actual `SendChatMessage` calls, not addon messages) are not affected. If you try to whisper a loot winner who's offline, you still see the error — because that one's actionable (the loot announcement didn't reach them).

**Localization-safe:** the filter uses WoW's `ERR_CHAT_PLAYER_NOT_FOUND_S` global as the format-string template, with Lua pattern specials escaped, so it works on any client locale.

**Bonus:** every suppression is logged as an `INFO` line in the Sync Debug recorder so an officer can confirm the filter is doing its job — look for entries like `suppressed offline-error for Foo (recent addon-msg WHISPER)`.

### Version

- Bumped from **1.3.00** to **1.3.01** — offline-whisper chat spam suppression.

---

## 1.3.00 — May 13, 2026

**Milestone release.** Rolls up every change developed across the 1.2.12 → 1.2.18 patch series into a single named version for CurseForge. The detailed per-patch entries are preserved below for traceability — this header is the at-a-glance summary.

### Headline Features

- **Sync Debug recorder.** New live activity recorder accessible from **Settings → Sync & Data → Troubleshooting → Start Sync Debug**. A 720×460 popup surfaces every outgoing send, every accepted incoming message, every dropped message **with the exact reject reason**, and every error in real time. Filter buttons, pause/resume, clear, copy-to-clipboard, color-coded categories. Zero overhead when disabled (single nil-check + early return per call).
- **Prune Ex-Guildies.** New officer action under **Settings → Officer Settings → Mass Edit → Roster Maintenance**. Compares the live guild roster against the EPGP roster, shows a preview dialog (class-colored names, EP/GP, last-seen date), double-confirms before removal. New `ROSTER_DEL` sync message type propagates removals across all officer clients. Preserves alts whose main is in-guild, the local player, and history rows.
- **Footer sync indicator reworked.** Five distinct states (off / syncing / no peers / behind N / in sync) instead of three. Live updates on a 1 Hz ticker. Hover tooltip shows full diagnostic picture (rev, peer rev, peer count, last sync time + sender). Click anywhere on the pill to pull latest from best peer.

### Sync & EPGP Hardening

- **`BATCH_EP` self-contained** — fixes "always one boss behind" sync lag where the loot master's points trailed by one boss kill.
- **`/vcl sync` works with non-officer peers** — 90 s explicit-request window allows targeted FULL acceptance from any peer.
- **Raid log fixes** — durations no longer show 472,000 hours (raidId-prefix heal + 24 h sanity cap), zones no longer stick on "Unknown" (RAID/START backfills stub metadata in place, Initialize heal rewrites missing zones from BOSS_LIST).
- **Tier token GP** now uses the redeemed item's ilvl/slot instead of the token's, so T4/T5/T6/SWP tokens cost the right GP.
- **Eredar Twins triple-credit** fixed — NPC_TO_ENCOUNTER multi-NPC dedup collapses peer NPCs + ENCOUNTER_END to a single bucket.
- **Authority gates tightened** — `/vcl bench add|remove` now Raid Manager only; `/vcl import` and all `/vcl test*` commands now officer only; `/vcl help` annotates restrictions with `[O]` and `[R]` markers.

### UX Polish

- **Settings → Sync & Data reworked** into Pull / Send / Officer Push / Status / Troubleshooting sections with clearer button names and inline hints.
- **Profile data decoupled** from EPGP via the separate `VCLootProfilesDB` SavedVariable — gear/wishlist/specs now survive `/vcl reset`.
- **Self-test harness** (`/vcl test`) — runs assertions against the live addon, verifying sync correctness, EPGP math, decay, GP overrides, etc. Auto-cleanup so the live DB is untouched.
- **Build tooling** — `tools/build_release.py` parses the TOC for the file list (no more hardcoded manifest), supports `--install` to copy directly into Interface\AddOns for one-step testing.

### Critical Bug Fixes

- **UI.lua load-time parse error** — `self:Method and 0` was a Lua syntax error that killed the entire UI.lua chunk at addon load. Fixed in 1.2.16.
- **Roll popup not showing for round 2+** — leader-session preservation branch was blocking the loot master's client. Fixed in 1.2.7.
- **Missing-glyph boxes** in Sync Debug popup — replaced Unicode `●` / `○` / `→` with ASCII-safe equivalents (`[REC]`, `>>`) since WoW's Friz Quadrata font lacks them.

### Version

- Bumped from **1.2.18** to **1.3.00** — milestone release for CurseForge.

---

## 1.2.18 — May 13, 2026

### Authority Gates — Tighter Slash Command Restrictions

Closed two classes of slash-command authority gaps and added an at-a-glance authority legend to `/vcl help`.

**`/vcl bench add|remove` is now Raid Manager only.** Previously had no slash-layer check AND no check in `Attendance:BenchAdd` / `Attendance:BenchRemove` themselves — a regular guild member could mutate the local raid roster, which would visibly show a phantom bench change before failing silently when the broadcast was rejected by peers (receivers gate on sender = raid leader). The check now lives at the `Attendance` layer (`canManageRaid()` — same gate as `/vcl raid start|end`), so the lock applies everywhere `BenchAdd`/`BenchRemove` is called from, not just the slash path. Non-managers see the role error immediately instead of being confused by a local-only change.

**Five commands moved to officer-only:**
- `/vcl import` — the dialog's Import button was already officer-checked, but a non-officer could still open it, paste data, and preview parses. Now the slash itself refuses unless you're an officer.
- `/vcl test` — runs the self-test harness, which mutates the local DB (creates synthetic players, awards EP, runs decay) before rolling back. Now officer-only so a non-officer doesn't see transient test rows or get confused by mid-run output.
- `/vcl test info` / `/vcl test peers` / `/vcl test gp <link>` — diagnostic prints (dbRev, peer presence, item GP breakdown). Same officer lock for consistency — this is internal sync / DB state that's officer-grade information.

**`/vcl help` now shows authority markers.** Each command line that has a restriction is suffixed with `[O]` (officer-only, gold) or `[R]` (raid manager, blue), with a legend at the top of the dump:

```
Legend: [O] = officer only   [R] = officer or raid leader
/vcl                 - toggle main window
/vcl raid start [zone] - start tracking a raid [R]
/vcl award <player|all> <EP> [reason] [O]
...
```

No more guessing why a command silently failed — the marker tells you up front whether you have permission.

### Authority Reference (post-1.2.18)

For clarity, the three tiers in play:

| Tier | Who passes | Commands |
|------|-----------|----------|
| **Officer** | Guild Leader, Officers, Officer Alts, Loot Master | `award`, `gp`, `alt`, `decay`, `import`, `backup`, `restore`, `reset`, `test*` |
| **Raid Manager** | Officer **OR** raid leader | `raid start`, `raid end`, `bench add`, `bench remove` |
| **Loot Runner** | Officer **OR** raid leader **OR** Loot Master | `roll` |
| **Open** | Anyone | `show`, `standings`, `config`, `sync`, `cancelaward` |

### Version

- Bumped from **1.2.17** to **1.2.18** — slash command authority tightening.

---

## 1.2.17 — May 13, 2026

### Bug Fix — Missing Glyph Boxes in Sync Debug Popup

Several Unicode characters used in user-facing strings were rendering as missing-glyph boxes because WoW's default font (Friz Quadrata) doesn't include them. Fixed:

- **Sync Debug popup recording dot.** `|cff33ff99●|r recording` / `|cff888888○|r stopped` rendered as boxes. Replaced with bracketed ASCII labels: `[REC]` (green) / `[ -- ]` (gray). The label now reads cleanly and the color carries the meaning.
- **Sync Debug popup footer hint.** `Copy → paste log to Discord` had the right-arrow rendering as a box. Replaced with `Copy >> paste log to Discord`.
- **Footer sync indicator tooltip.** Two tooltip / chat strings that referenced `Settings → Sync & Data` had the arrow rendering as a box. Replaced with `Settings > Sync & Data`.
- **Footer "Syncing…" label and tooltip.** The horizontal ellipsis (`…`, U+2026) was inconsistently substituted for `...` across two strings; unified them to `Syncing...` everywhere.

The middle-dot bullet (`·`, U+00B7) and em-dash (`—`, U+2014) ARE in Friz Quadrata's glyph table and render fine — those stay.

**Codebase audit.** Grepped every Lua file for the suspect characters in user-visible call paths (`SetText`, `ns.Print`, `AddLine`). All remaining occurrences are inside Lua comments (which don't render) so the addon is fully clean now.

### Version

- Bumped from **1.2.16** to **1.2.17** — glyph-rendering polish.

---

## 1.2.16 — May 13, 2026

### Bug Fix — Critical Load-Time Parse Error

**`UI.lua:8970` — addon failed to load with "function arguments expected near 'and'".**

The Sync Debug popup's `OnVerticalScroll` handler used Lua's colon method-call syntax (`self:GetVerticalScrollRange`) as a method-existence probe — but Lua's grammar only allows `obj:method` immediately followed by call args (parens, string literal, or table literal). The bare `self:GetVerticalScrollRange and 0` is a parse error, which killed the entire UI.lua chunk at addon load. Every player who opened the game with v1.2.14 or v1.2.15 hit this.

Replaced the antipattern with dot-syntax method probing:
```lua
if self.GetVerticalScrollRange then
    yRange = self:GetVerticalScrollRange() or 0
end
```

(Existence check uses `self.GetVerticalScrollRange` field access; the actual call uses `self:GetVerticalScrollRange()` once we know it's there.)

This was introduced in v1.2.14 with the Sync Debug recorder and went undetected because I only tested the popup-creation path, not its OnLoad parse. Now fixed and scanned the entire codebase for the same pattern — no other instances.

### Version

- Bumped from **1.2.15** to **1.2.16** — load-time crash fix.

---

## 1.2.15 — May 13, 2026

### Feature — Prune Ex-Guildies

Added a one-click way to clean up roster entries for players who have left the guild. Lives at **Settings → Officer Settings → Mass Edit → Roster Maintenance → Prune Ex-Guildies**.

**How it works.** Clicking the button opens a preview dialog that compares the live guild roster (fresh `GuildRoster()` call, not cached) against the EPGP roster and lists every player who's in the DB but no longer in the guild. Each row shows their short name, class color, current EP / GP, and last-seen date so the officer can sanity-check before pulling the trigger. A second StaticPopup confirmation appears on click — two clicks to delete, no accidental wipes.

**What's preserved:**
- **Alts whose main is still in-guild.** If `alts[someAlt] = someMain` and `someMain` is in the guild, `someAlt` is kept (their points still feed the main).
- **The local player.** Never prunable, even on a realm-suffix mismatch.
- **History rows.** Every EP / GP / DECAY history row attributed to the pruned player stays in the audit log — only the live roster row goes away. That means past raids still show who was there and what they were awarded.

**Sync — `ROSTER_DEL` message type (new).** Each removal fires a `ROSTER_DEL` to every peer carrying just the normalized player name. Receivers gate on officer-rank (identical to `DELTA` / `HIST_DEL` / `SETTINGS` authority — a regular guild member can't inject one to evict someone from everyone's database). After all removals land, a `SendFull` follows 0.5 s later to catch any peer that missed the per-name broadcast (their dbRev gap from the local bumps triggers a re-pull anyway, but the explicit FULL guarantees convergence).

**Why this matters.** Before today, the only way to clear ex-guildies was manual `/vcl reset` (nuclear, wipes everything) or per-player CSV-export-edit-import. Now: open Mass Edit → Prune → Confirm. Three clicks for what used to take five minutes.

### Sync Debug

- The new `ROSTER_DEL` handler is fully instrumented for the v1.2.14 Sync Debug recorder. Drops, authority rejects, and applied removals all surface as live log lines so an officer can verify their prune is propagating in real time.

### Version

- Bumped from **1.2.14** to **1.2.15** — Prune Ex-Guildies officer action.

---

## 1.2.14 — May 13, 2026

### Feature — Sync Debug Recorder

Added a live sync activity recorder so raid leads can troubleshoot sync problems the moment they happen, instead of asking "did everyone get the EPGP update?" and getting silence back.

**Where to find it.** Settings → Sync & Data → Troubleshooting → **Start Sync Debug**. The button opens a 720×460 popup window and turns recording on. The window also auto-enables recording when you open it (so you don't have to remember a separate "record" toggle).

**What gets recorded.** Every message that crosses the sync boundary, color-coded by category:

- **OUT (cyan)** — every outgoing send, with destination (`GUILD` / `RAID` / whisper target) and payload size. Chunked sends, urgent sends, and queued sends are all distinguished.
- **IN (white)** — every incoming message that was accepted and applied. Includes the msg type (`HEARTBEAT`, `FULL`, `DELTA`, `BATCH_EP`, `RAID`, `SETTINGS`, `ROLL_START`, `ROLL_END`, `PROFILE`, `HIST_DEL`) and the sender's short name.
- **DROP (yellow)** — every incoming message that was rejected, with the **exact reason**. Examples: "DELTA from Foo dropped: sender not officer", "FULL from Bar dropped: stale rev 42 ≤ local 47", "BATCH_EP dropped: sender not raid leader". This is the box you watch when "sync isn't working" — the reason text tells you whether it's an authority issue, a rev issue, or a malformed payload.
- **ERR (red)** — sync-side errors: chunk reassembly timeout, malformed JSON payload, parse failure.
- **INFO (gray)** — state changes that matter for sync correctness: explicit-request window opening, chunk completion, debug start/stop.

**Controls on the popup:**
- **Filter buttons** (All / Out / In / Drops / Errors / Info) — click to focus on just one category. Drops + Errors is the usual triage view.
- **Pause / Resume** — freeze the scroll while you read a specific line without losing incoming events (they keep being recorded, just not auto-scrolled).
- **Clear** — wipe the buffer (with confirm) without disabling recording. Useful for "let me start fresh and reproduce the bug."
- **Copy** — opens a copy-dialog with the full filtered log so you can paste into Discord or a bug report.
- **Stats footer** — running counts of OUT / IN / DROP / ERR / INFO since recording started.

**Performance.** The instrumentation is essentially free when recording is off: every dbg() call in Sync.lua is a single nil-check + early return. When on, each event is one `string.format` + `table.insert` + listener fanout — comfortably under a millisecond at raid-level traffic. The log buffer is a circular 500-entry ring so it can't grow without bound; oldest entries drop off the front. FontString rendering uses a pool so the popup doesn't churn GC even at 100 events/min.

**Sync.lua instrumentation.** Sprinkled `dbg(category, fmt, ...)` calls at every key decision point:
- `Sync:Send` logs OUT for every urgent / queued / chunked send.
- `Sync:HandleMessage` logs IN on accept or DROP-with-reason on reject for every msgType.
- `Sync:RequestFull` logs INFO when the 90s explicit-request window opens.
- `feedChunk` logs ERR on chunk timeout and INFO on completion.

This is the missing observability layer for the sync subsystem — you no longer have to guess what's happening over the wire.

### Version

- Bumped from **1.2.13** to **1.2.14** — sync debug recorder.

---

## 1.2.13 — May 9, 2026

### Feature — Sync Status Footer Indicator Reworked

The footer's sync indicator was previously a static colored dot with a "Sync on / off" label that only updated when you switched tabs and offered no detail beyond the colour. Replaced with a live, interactive sync status pill:

**Five distinct states** (was three):
- **Sync off** — gray dot, "Sync off" label. Sync disabled in settings.
- **Syncing…** — pulsing yellow dot. A FULL is in flight (with a 90 s timeout — no more permanent pulse if the request goes unanswered).
- **No peers** — yellow dot. Sync is on but no heartbeats received yet (cold start).
- **Behind N** — red dot, shows the exact gap. "Click to pull latest" cue in the tooltip.
- **In sync / Leading** — green dot. Local rev matches the highest peer (or you're ahead due to a local change that hasn't propagated yet).

**Live updates.** The footer now refreshes on a 1 Hz ticker (OnUpdate on the footer frame, no cost while the window is hidden) so the indicator reflects reality at all times — not just the last time you switched tabs.

**Hover tooltip** with the full diagnostic picture:
- Status interpretation in plain English (in sync / behind by N / no peers / syncing now / leading).
- Local rev + season.
- Highest peer rev.
- Best peer name + rev (whoever the click action would target).
- Total peers heard.
- Last sync time + sender ("3m ago from Thrall").
- Click-to-act cue when applicable.

**Click to sync.** The whole indicator is now clickable — clicking it triggers `RequestFullFromBestPeer` (same as the new Settings → Sync & Data button) so you can pull the latest from anywhere in the UI without navigating to settings. A guard prints a friendly warning if sync is disabled.

**Raid status footer** now also shows the kill count ("Raid: Black Temple (5 kills)") at-a-glance.

**Internals.** The 90 s "syncInProgress" timeout uses `ns.Sync._uiSyncCheckStartedAt` (a hidden field on the Sync module) so a stale flag is forcibly cleared and the UI doesn't pulse forever when a request goes unanswered (e.g. all officers offline, dropped REQ FULL, etc.).

### Version

- Bumped from **1.2.12** to **1.2.13** — sync status footer rework.

---

## 1.2.12 — May 9, 2026

### Bug Fixes

**Test.lua — `AwardEP noBump` self-test false-failed**
- The test created its synthetic player implicitly via `AwardEP`, which calls `GetOrCreatePlayer` internally — and `GetOrCreatePlayer` bumps `dbRev` once when inserting a brand-new roster row. That bump was being attributed to `AwardEP`'s `noBump = true` call and flagging the test FAIL. Pre-create the synthetic player BEFORE measuring rev so the test isolates what it's actually checking.

**Sync.lua / DB.lua — raid log showing "Unknown" zone**
- The receiver-side stub-raid creation path (used when `RAID/KILL` arrives at a client that missed the original `RAID/START`) set `zone = "Unknown"` as a sentinel. The subsequent `RAID/START` handler bailed on `if not exists`, so the stub's "Unknown" zone was sticky — even when the real `RAID/START` eventually landed it was silently dropped. Two layers of fix:
  - `RAID/START` handler now **backfills** stub raid metadata in place (zone / leader / start / attendee snapshot), updating any field still at its sentinel default. Existing fully-populated raid records aren't touched.
  - `DB:Initialize`'s heal pass also looks up `ns.C.BOSS_LIST[npcId].zone` from any recorded kill in the raid and writes that into `r.zone` if it's missing or `"Unknown"`. **Existing broken records repair themselves on the next `/reload`.**

### UI Polish — Settings → Sync & Data Rework

Renamed and re-laid out the panel into three clear sections:

**Sync**
- "Enable sync with other addon users" (was "Full guild sync enabled")
- "Accept pushed settings from officers" (unchanged)
- **Pull Latest from Best Peer** (renamed from "Request Full Sync") — whispers the highest-rev peer; one targeted response.
- **Broadcast Sync Request** (new, was hidden behind `/vcl sync force`) — the broadcast fallback when no peer is known yet. Hint warns "use only if Best Peer fails."
- **Re-broadcast My Profile** (new) — force-sends your gear / wishlist / spec to all peers immediately, bypassing the 15 s cooldown. Useful when someone says "I don't see your wishlist" mid-raid.

**Officer Push** (officer-only actions)
- **Push Settings** (was "Push Settings to Guild") — send guild-policy EPGP settings to all peers.
- **Push Full Database** (was "Send Full DB to Guild") — primary-styled. Hint now says "EPGP + history + raids + profiles" so the inclusion of the profiles bundle is explicit.

**Status** (live diagnostics)
- Last sync line (existing).
- **Database: rev N (season S) — Highest peer: M — (in sync / behind)** with green/yellow color cue.
- **Profile DB: N entries (survives `/vcl reset`) — Peers heard: M** so the user can see the profile DB exists and is populated.

The user can now answer "are we in sync?" at a glance without leaving the panel.

### Internals

- `Sync:BroadcastProfile(name, msRole, osRole, osRaidReady, force)` — new `force` flag bypasses the 15 s cooldown for explicit user actions (the Re-broadcast button, and the login bootstrap which was already doing this manually by resetting `_profileLastSend`).
- `Sync:RebroadcastOwnProfile(force)` — passes through.

### Version

- Bumped from **1.2.11** to **1.2.12** — raid-zone heal + sync-panel rework.

---

## 1.2.11 — May 9, 2026

### Feature — `/vcl test` Self-Test Harness

New in-game test harness for verifying the addon's behavior. Four subcommands:

- **`/vcl test`** — runs the full self-test suite and reports PASS / FAIL per check. Covers tier-token GP overrides, Eredar Twins encKey dedup map, histKey behavior (per-sender disambiguation), AwardEP's `atTime` and `noBump` parameters, `DB:ReverseHistoryEntry` for both EP and GP-on-alt-charges-main, profile DB round-trip, raid-duration heal and 24-hour sanity cap, bench/attendees mutual exclusion, BATCH_EP credit exclusion, encKey dedup window, `VCLootProfilesDB` SavedVariable structure, and version consistency. Tests are self-contained — they use synthetic names like `VCLootSelfTest-*`, clean up after themselves, and broadcast nothing.

- **`/vcl test info`** — prints addon state at a glance: dbRev, season, highest peer rev, sync state, record counts (roster / profiles / raids / history), EPGP settings summary, active raid info.

- **`/vcl test peers`** — lists every addon user we've heard from via heartbeat in the last ~10 minutes, with their version and rev relative to ours (ahead / behind / equal). Useful for confirming that other raiders' clients are in sync before a raid.

- **`/vcl test gp <itemLink>`** — explains how GP is computed for a specific item: itemId, quality, ilvl, slot, any tier-token override, any custom-GP override, and the final MS / MINOR / OS values. Shift-click an item into the chatbox to fill the link.

Multi-client behaviors (popups appearing on viewer clients, `/vcl sync` round-trips, bench sync arriving on peers, etc.) can't be verified solo; `/vcl test peers` plus `/vcl test info` give you the live signals to check those during raid.

### Version

- Bumped from **1.2.10** to **1.2.11** — self-test harness.

---

## 1.2.10 — May 9, 2026

### Bug Fix — Raid Durations Showing Hundreds of Thousands of Hours

Raid log feedback: raid durations were displaying as values like *"472,113h 22m"* instead of the actual ~3-4 hour run.

**Root cause.** The receiver-side stub-raid creation path in `Sync.lua` (used when a `RAID/KILL` arrives at a client that missed the original `RAID/START`) set `start = 0`:

```lua
raid = {
    id = raidId, start = 0, ["end"] = nil, ...
}
```

When the raid eventually got an end timestamp (via a later `RAID/END` or via a FULL sync), the duration calculation `raidEnd - raidStart` became `~1.7 billion - 0` = ~1.7 billion seconds = ~472,000 hours. The UI dutifully formatted that as `"472113h 22m"`.

**Fix.** Three layers:

1. **Stub creation (`Sync.lua`).** The `raidId` itself encodes the original raid start time as its leading numeric prefix (`DB:StartRaid` builds the id as `tostring(time()).."-"..leader`), so we can recover the real start even without `RAID/START`. Stubs now parse `start` from the id; if that fails, they fall back to the killed-boss timestamp.
2. **UI duration formatting.** `BuildRaids` and the raid detail popup both recover `start` from the `raidId` prefix when `r.start` is missing/zero, and `formatDuration` clamps anything above 24 hours or below 0 to `"--"` so any other future stub leak can't render absurd values.
3. **One-time DB heal at init.** `DB:Initialize` walks every raid record on load; any with `start = 0` get repaired in-place from the `raidId` prefix (or the earliest known kill time as a fallback). Existing already-broken raid log entries fix themselves on the next `/reload`.

### Version

- Bumped from **1.2.9** to **1.2.10** — raid-duration display fix.

---

## 1.2.9 — May 9, 2026

### Bug Fix — `/vcl sync` Silently Failed When the Best-Rev Peer Was a Non-Officer

Raid feedback: clicking the Request Full Sync button printed *"Syncing from <player> (rev N)…"* but the FULL never arrived and points stayed outdated. The user's raid leader had the freshest data but was a Veteran Raider rank-wise — and v1.2.0's trust gates blocked the entire exchange:

- **Sender side:** the REQ FULL handler had `if not (ns.isOfficer and ns.isOfficer()) then return end`. A non-officer raid leader received the whispered REQ but bailed out without responding.
- **Receiver side:** even if a non-officer DID reply, the FULL handler had `if not senderIsOfficer(sender) then return end`. The asker would reject the response.

So the sync request was silently dropped on **both** ends whenever the best peer was a non-officer with the freshest data. The "Syncing from…" UI message is just a local print — it had no idea the round-trip never closed.

**Fix.** Open a 90-second "explicit-request window" whenever we send a REQ FULL. Inside that window:

1. **REQ FULL handler:** non-officers reply if-and-only-if the REQ came in via WHISPER (i.e. the asker explicitly targeted them). Broadcast REQs are still officer-only, so non-officers don't all queue simultaneous FULL transfers and re-introduce the channel flood.
2. **FULL handler:** a non-officer FULL is accepted if (a) we're inside our own request window, AND (b) either the response came from the peer we targeted, or we sent a broadcast REQ with no specific target. The window closes the moment one non-officer FULL applies, so subsequent unsolicited spoofs can't sneak in.

Officer FULLs are still always accepted regardless of window — the existing trust path is unchanged.

**Net effect.** When you click Request Full Sync and the best-rev peer is your raid leader (officer or not), the round trip now actually completes and the FULL applies. The trust gate against unsolicited / spoofed FULLs is preserved for every other case.

`OnMessage` → `HandleMessage` now also threads the message's `channel` parameter so the REQ handler can distinguish whispered vs broadcast.

### Version

- Bumped from **1.2.8** to **1.2.9** — `/vcl sync` works against non-officer peers.

---

## 1.2.8 — May 9, 2026

### Bug Fix — "Always One Boss Behind" on Boss-Kill EP

Raid feedback: the raid leader's points updated immediately on every boss kill, but the loot master's addon was always one boss behind — couldn't sync to the raid leader's latest data until the *next* boss died.

**Root cause.** The BATCH_EP receiver iterated **the receiver's own local `raid.attendees` and `raid.bench`** to decide who got credited:

```lua
for name in pairs(raid.attendees or {}) do applyOne(name, ep, reason) end
```

This made BATCH_EP correctness dependent on the receiver's local raid state being **complete and up to date** at the moment BATCH_EP processed — which in turn meant that the prior RAID/START broadcast AND every subsequent RAID/KILL embedded snapshot AND every BENCH_ADD had to have all landed and been processed first. Any gap (channel reorder, RAID/KILL chunk still in flight, dropped message, late join, sender-authority transient miss) caused some or all attendees to silently miss their EP credit. The data only converged later via heartbeat-driven FULL syncs — which is exactly the "always one boss behind" symptom: BATCH_EP for boss N landed but credited an empty/stale local set, then HB → REQ FULL → FULL response from the raid leader applied boss N's data via the FULL's roster snapshot, around the time boss N+1 was being killed.

**Fix.** Embed the sender's authoritative attendee + bench lists directly in the BATCH_EP payload. The receiver applies credits using the **broadcast's** lists, not its local raid state, so BATCH_EP becomes self-contained — no dependency on prior RAID/KILL having landed first or on any other raid-state sync being current.

```lua
-- new payload:  raidId \t ep \t reason \t by \t timestamp \t benchEP \t attCsv \t benchCsv
```

Pre-1.2.8 senders omit the trailing fields and the receiver falls back to local state exactly as before, so the change is backward compatible. Pre-1.2.8 receivers ignore the trailing fields (they only `strsplit` the first 6) and continue to use local state — also backward compatible.

**Size and DC envelope.** The new payload is up to ~620 bytes for a 25-player raid (vs ~80 before). At `CHUNK_SIZE = 215` that's 3 chunks at 50 ms = 150 ms send time per BATCH_EP. Well within the existing 20 chunks/sec ceiling. No DC-risk increase.

### Version

- Bumped from **1.2.7** to **1.2.8** — BATCH_EP self-contained delivery.

---

## 1.2.7 — May 9, 2026

### Bug Fix — Roll Popup Reliably Shows on Every Round (For Real This Time)

Raid feedback: the roll popup showed up for the first item but not for any item after that.

**Root cause.** v1.2.1's `OnRollStartBroadcast` had a "preserve concurrent leader session" branch:

```lua
if L.session then
    if not L.session.viewer then return end   -- preserve leader session
    ...
end
```

The intent was to protect a local leader's actively-running roll if a *second* officer started a concurrent roll. In a single-officer raid that branch becomes a defect: any state that ever pinned `L.session.viewer` to `false` on a viewer's client (an alt-click that opened a prompt but never started, an earlier `/vcl roll` test, an aborted Award flow that errored before `L.session = nil`, an officer alt who clicked something while spectating) silently swallowed every subsequent ROLL_START on that client. First roll shows because L.session is nil; second roll forever-after doesn't because L.session is stuck non-viewer.

**Fix.** Drop the leader-preservation branch entirely. `OnRollStartBroadcast` now unconditionally tears down any existing session and creates a fresh viewer session for the incoming broadcast. Reliability beats the rare concurrent-officer corner case — and our self-echo guard already prevents a leader's own broadcast from ever clobbering their own session, so the only thing actually given up is the multi-officer-concurrent-rolls scenario, which works fine if officers coordinate to roll one at a time.

**Plus a defensive guard.** `OnRollEndBroadcast` now ignores a ROLL_END whose `itemLink` differs from the active session's. Out-of-order delivery of a stale ROLL_END from a prior roll (unlikely under normal channel ordering but possible during officer-to-officer handoffs or saturated channels) would otherwise close the wrong session's window.

### Version

- Bumped from **1.2.6** to **1.2.7** — roll-popup reliability fix.

---

## 1.2.6 — May 8, 2026

### Bug Fixes — Permission Consistency for Non-Officer Raid Leaders

A non-officer Veteran Raider holding the raid leader slot would silently fail in three places:

**Attendance.lua — auto-start of raid tracking would silently bail**
- `OnPlayerEnterCombat` and `OnEncounterStart` correctly fire on the raid leader's client (`isRaidLeader()` check), but they then call `self:StartRaid()` — which had a stricter `isOfficer()` gate. Non-officer leaders saw a brief permission warning the moment combat opened and the raid never auto-started. Loosened to `canManageRaid()` (officer OR group leader) — same authority pattern as `canRunLoot` in `Loot.lua` and `EndRaid` already uses.

**Whisper.lua — `!standby` sent to a non-officer raid leader was redirected**
- Last version's anti-desync gate refused to process the bench-add unless the recipient was an officer. A non-officer raid leader received `!standby` whispers, replied "please whisper an officer or raid leader", and the whisperer was sent to find someone else. Now accepts the raid leader too via the new shared `ns.canManageRaid` helper.

**Attendance.lua — streak bonus would land only on a non-officer leader's own client**
- `CheckStreakBonus` (called from `EndRaid`) broadcasts per-player DELTAs, which receivers gate on `senderIsOfficer`. If a non-officer raid leader ended the raid, the streak bonus would apply locally on their client and **nowhere else** — visible to the leader and invisible to every raider. Now skipped entirely on non-officers; either an officer ends the raid (streak fires for everyone) or no one's streak is awarded (consistent state across the guild).

### Version

- Bumped from **1.2.5** to **1.2.6** — non-officer raid leader permission fixes.

---

## 1.2.5 — May 8, 2026

### Bug Fix — Tier Tokens Charged Far Less GP Than the Gear They Redeem For

Tier-token GP was being computed against `GetItemInfo`'s metadata for the *token itself*, not the redeemed gear:

- **`equipLoc` is empty** on tokens (a token isn't equippable), so `slotFactors[""]` falls through to the 1.0 default — wrong for any gear slot whose factor isn't 1.0 (shoulders, hands, wrists, waist, feet, neck, finger).
- **`ilvl` is the token's** item level, which for **T6** (Hyjal / Black Temple) is 141 while the redeemed gear is 146, and for **Sunwell tier** the token sits a few levels below its redeemed counterpart too. Because the GP formula has an exponential ilvl term, a 5-level shortfall translates into roughly a **30 GP undercharge** on every T6 piece awarded as a token.

A `Loot:ComputeGP` lookup now consults the new `ns.C.TIER_TOKEN_INFO` map (in `Constants.lua`) before the formula runs, substituting the canonical `(slot, ilvl)` of the redeemed gear for every TBC tier token (T4 / T5 / T6 / Sunwell + the Sunwell Sin'dorei Pendant and Band tokens). After this fix, awarding a tier token charges the same GP as awarding the redeemed gear directly.

Per-item `CustomItemGP` overrides still take priority — officers can manually override any token's GP if they prefer different values.

### Version

- Bumped from **1.2.4** to **1.2.5** — tier-token GP correctness.

---

## 1.2.4 — May 8, 2026

### Bug Fixes — EP / GP Correctness

**Attendance.lua / Sync.lua — bench/attendees double-credit on boss kill**
- A raider who whispered `!standby` while still in the raid group ended up in **both** `raid.attendees` (auto-snapshotted by `SnapshotRaid`) and `raid.bench` (added by `BenchAdd`). On every boss kill they were credited as an attendee AND as bench — double EP. Fixed three ways:
  - `BenchAdd` now removes the player from `raid.attendees` (mutual exclusion at the source).
  - `SnapshotRaid` skips bench-listed players when auto-adding raid members to attendees (so a re-snapshot can't undo the BenchAdd removal).
  - `OnBossKilled`, `OnBossWipe`, `MassAwardEP`, and the `BATCH_EP` receiver all skip attendees who are also on bench — defense in depth in case the two sets ever drift on a single client.

**Sync.lua — bench updates didn't propagate, BATCH_EP credit diverged across clients**
- `BenchAdd` and `BenchRemove` only mutated the local `raid.bench` table; there was no incremental sync. Officers' clients had the correct bench, but every other receiver's bench stayed empty until the next FULL sync. The result: the leader credited bench EP on their own client; every other client either credited the same player as a regular attendee, or didn't credit them at all. Fixed by adding two new RAID-message subtypes:
  - `BENCH_ADD` — broadcast on every `BenchAdd`; receivers add the name to their `raid.bench` and remove it from `raid.attendees`.
  - `BENCH_REMOVE` — broadcast on every `BenchRemove`; receivers remove from `raid.bench`.
- Authority on receive: leader-or-officer (matches the rest of the RAID-subtype branch).

**Whisper.lua — `!standby` from the wrong recipient silently desynced bench**
- The `!standby` handler ran on whichever raider received the whisper. If that raider wasn't an officer or the raid leader, the resulting `BENCH_ADD` broadcast would be dropped by every other client's authority gate, leaving bench out of sync. Handler now only processes on officers/raid leader; non-authoritative recipients reply telling the whisperer to message someone who can act.

### Wastefulness — `StartRaid` migrated from N DELTAs to BATCH_EP

- `StartRaid` previously sent up to **50 individual `BroadcastDelta` messages** for a 25-player raid (one per attendee for `epPerRaidStart`, another per attendee for `epPerTimeOnTime`). Each was small and queued at 50 ms, so the per-second rate stayed safe — but it was wasteful traffic that queued behind any concurrent FULL transfer. Migrated to `BATCH_EP`: at most **2 messages total** per raid start (one per EP type), regardless of raid size.

### Memory — `MAX_RAIDS` cap on local raid storage

- The `raids` table was unbounded; a year-round raiding guild would slowly accumulate megabytes of stale raid records (kills + attendees + bench × hundreds of weeks). New `MAX_RAIDS = 500` cap trims the oldest *completed* raids when exceeded — about a full year of weekly 25-mans, well past any window `AttendancePct` looks at. In-progress raids are preserved regardless of age.

### Disconnect-Risk Audit — clean

A full pass over every outbound code path in the addon:
- All chunked sends still route through the 50 ms / 20 chunks/sec ticker.
- No new code path introduces a tight loop that bypasses the queue.
- `BENCH_ADD` / `BENCH_REMOVE` are single small messages.
- `StartRaid`'s migration to `BATCH_EP` *reduces* peak outbound (2 messages instead of 50).
- The new `MAX_RAIDS` trim is local-only — no network traffic.

No DC vector identified. Same safety profile as 1.2.3.

### Version

- Bumped from **1.2.3** to **1.2.4** — bench-credit correctness + StartRaid efficiency.

---

## 1.2.3 — May 8, 2026

### Bug Fixes — Boss Kill EP

**Attendance.lua / Constants.lua — Eredar Twins triple-credited**
- Eredar Twins (Sunwell Plateau) awarded boss-kill EP **three times per encounter**: once when Sacrolash died (`COMBAT_LOG`), once when Alythess died (`COMBAT_LOG`), and once when `ENCOUNTER_END` fired for the encounter as a whole. The cross-handler dedup keyed on `bossName:lower()` and the three calls had three different names, so none collided. Fixed by introducing an `encKey` parameter on `OnBossKilled` and a new `ns.C.NPC_TO_ENCOUNTER` map: every peer NPC of a multi-NPC encounter and the canonical encounter name all hash to the same dedup bucket. The dedup window also widens from 5 s to 60 s so a peer NPC dying a few seconds after its sibling doesn't slip past.
- Existing single-NPC bosses are unaffected — `encKey` defaults to `bossName` and the dedup behaves identically.

**Attendance.lua / DB.lua — silent history-row duplication on FULL sync after a boss kill**
- `OnBossKilled` (and `OnBossWipe`, and `MassAwardEP`) snapshot a single timestamp `ts` and pass it as a new optional `atTime` arg to `AwardEP` so every locally-inserted history row uses the same time. Previously `AwardEP`'s internal `time()` could drift across a second boundary on a slow 25-player loop, producing rows whose `histKey` (`time | type | who | amount | by`) didn't match the receiver-side rows the BATCH_EP broadcast created. On the next FULL sync, `mergeHistory` saw the sender's `T+1` rows as new entries and added them on top of the receiver's `T` rows — silently doubling the visible log for that kill on every receiver. EP/GP totals were unaffected (the BATCH_EP receiver dedup caught the EP side), but the log was wrong.
- `AwardEP` and `AssignGP` gain the optional `atTime` parameter; existing 5-arg callers continue to work unchanged.

**Attendance.lua / DB.lua — every boss kill caused a wasted FULL-sync request**
- The leader's `AwardEP` loop bumped `dbRev` per call (25 times for a 25-player kill) while the receiver-side BATCH_EP handler bumps once. The leader was always 24 revs ahead after every boss kill, so every receiver's heartbeat saw "leader is ahead → request a FULL." `AwardEP` gains an optional `noBump` flag; `OnBossKilled` / `OnBossWipe` / `MassAwardEP` pass it on each loop iteration and call `Bump()` exactly once at the end, putting sender and receivers at the same `dbRev` after the broadcast applies.

**Attendance.lua — `MassAwardEP` (`/vcl award all`) used N per-player DELTAs**
- The mass-award command looped 25 individual `BroadcastDelta` messages — exactly the per-player flood that `BATCH_EP` was created to prevent. Each was queued at 50 ms so it stayed under the per-second ceiling, but it was wasteful (25 chunks vs 1) and queued behind any concurrent FULL transfer. Now uses a single `BATCH_EP` broadcast.

### Disconnect-Risk Audit — Boss Kill Path

A 25-player boss-kill mass-award produces, on the leader's wire, **two outgoing messages total**:
1. One `RAID/KILL` (small, ~1 chunk)
2. One `BATCH_EP` (small, ~1 chunk)

Both go through the existing 50 ms ticker so they're paced at 20 chunks/sec. Total channel time used by the leader: ~100 ms. Receivers process locally and do **not** broadcast in response — the `seenKeys` dedup in the BATCH_EP handler also catches any redelivered echo. Combined with this version's "Bump once" fix above, no follow-on FULL request is triggered either. **No 25-player flood. No per-player burst. No DC vector.**

### Version

- Bumped from **1.2.2** to **1.2.3** — boss-kill correctness fixes.

---

## 1.2.2 — May 8, 2026

### New SavedVariable — `VCLootProfilesDB`

Per-player gear, wishlist, main spec, off spec, off-spec ready flag, and class are now stored in a separate SavedVariable that is **decoupled from the EPGP database**. It survives `/vcl reset`, season bumps received over the wire, and any other path that wipes `VCLootDB`. On first load after upgrade, profile fields are migrated automatically out of the existing roster — there is no manual step.

The roster record continues to mirror the same fields, so every existing read site in `UI.lua` (and elsewhere) keeps working without changes.

### Bug Fixes — Gear / Wishlist Sync

**Sync.lua — PROFILE handler dropped data when the player had no roster row**
- Players who had never been awarded EP had no roster record; their incoming `PROFILE` broadcasts were silently discarded forever. The handler now writes to `VCLootProfilesDB` unconditionally and only mirrors onto the roster record if one exists. Their gear and wishlist are now visible from the moment the first broadcast lands.

**DB.lua — gear and wishlist were destroyed by `/vcl reset`**
- `ResetPreserveProfiles` snapshot only msRole / osRole / osRaidReady / wishList in memory, but the season bump it triggered caused every receiver's FULL handler to *hard-wipe* the entire roster — including their own local profile fields, which the broadcasting officer never had. Profile data now lives in `VCLootProfilesDB` (separate SavedVariable) and is re-applied to recreated roster records both locally (`HydrateRosterFromProfiles`) and on receipt of a season-bump FULL (`RestoreAllProfilesToRoster`).

**Sync.lua — `BroadcastProfile` cooldown was 60 seconds**
- Spec changes, wishlist edits, and gear swaps appeared stale on peers for nearly a full minute. Reduced to 15 seconds. The 2-second debounce in front of it still collapses rapid bursts (e.g. spec dropdown clicks) into a single send.

### Sync Improvements — Faster, More Complete Profile Propagation

**Sync.lua / Core.lua**
- `GROUP_ROSTER_UPDATE` (joining a raid, someone joining the raid you're in) now re-broadcasts your own profile after a 2-6 s random stagger, so a player who just joined sees existing members' gear / wishlist / spec within seconds rather than waiting for each peer to manually trigger a change.
- `PLAYER_EQUIPMENT_CHANGED` is now hooked: gear swaps refresh the local snapshot and broadcast (subject to the same 15 s cooldown), so peers see your current gear without you having to open the Profile tab.
- `Sync:RebroadcastOwnProfile()` exposed for any future callers that want to push the local player's profile on demand.

**Sync.lua — FULL payload now includes a PROFILES bundle**
- A new `---PROFILES---` section ships every known player's class / spec / wishlist / gear so that a fresh client (new guildmate, returning member, leveling alt) learns the full profile picture from a single FULL response. Previously profiles only propagated as players logged in and broadcast individually, so a new joiner could go weeks without seeing some members' data. Pre-1.2.2 senders simply omit the section and 1.2.2+ receivers fall back gracefully.
- The bundle is capped at the 30 most-recently-updated profiles (`PROFILES_SYNC_MAX`) so the FULL payload stays inside WoW's safe per-client outgoing-quota envelope. Anything past the cap continues to propagate via per-player PROFILE broadcasts. A belt-and-suspenders hard guard (`FULL_SIZE_DC_LIMIT`) drops the bundle entirely if the assembled payload would exceed ~160 KB / ~37 s of sustained sending — receivers fall back to per-player PROFILE messages.
- The season-bump branch in the FULL handler now calls `RestoreAllProfilesToRoster` after the hard wipe, so receivers' own gear and wishlist re-appear on the freshly-rebuilt roster instead of being permanently destroyed.

### Disconnect-Risk Audit

A full pass over the v1.2.0 / 1.2.1 / 1.2.2 changes confirmed every code path that emits addon messages still routes through the existing 50 ms ticker (`enqueueSend`) at 20 chunks/sec. No change increases per-client *outgoing* rate above the pre-existing safe ceiling:

- `PROFILE_COOLDOWN_S` (60 → 15 s) caps a single client to ~7-8 chunks per 15 s = 0.5 chunks/sec average.
- `GROUP_ROSTER_UPDATE` and `PLAYER_EQUIPMENT_CHANGED` re-broadcasts are fronted by the same 2 s debounce + 15 s cooldown, so bursts collapse to one send.
- Cancel/Disenchant `ROLL_END` adds a single small message per cancellation (rare).
- The FULL `PROFILES` bundle's length is bounded by `PROFILES_SYNC_MAX` and the hard guard above; the per-second send rate is unchanged.

Net effect: same safety profile as 1.1.x, with stricter caps in the new code paths.

### Version

- Bumped from **1.2.1** to **1.2.2** — profile decoupling + gear/wishlist sync hardening.

---

## 1.2.1 — May 8, 2026

### Bug Fixes — Roll Window

**Loot.lua — viewers' roll window not popping up**
- Fixed stale viewer sessions silently blocking new ROLL_START broadcasts. When a leader hit Cancel or Disenchant on a roll, viewers' windows stayed open until a 30-second auto-close timer fired. Any new ROLL_START during that window was silently dropped because `OnRollStartBroadcast` did `if hasActiveSession() then return end`. The handler now tears down a stale **viewer** session and accepts the new broadcast (a local **leader** session is still preserved so two concurrent officers don't clobber each other).
- Cancel and Disenchant now broadcast a roll-end signal (empty winner) so every viewer's window closes immediately. Previously viewers waited up to 30 seconds for their auto-close fallback.
- Fixed `OnRollStartBroadcast`'s auto-close timer math going negative when `endTime` arrived in the past (clock skew, message delay), which made `C_Timer.After` fire instantly and dismissed the window viewers had just opened. The remaining-seconds calculation is now clamped to a non-negative minimum.
- `OnRollEndBroadcast` no longer prints a "X won Y for 0 GP" line when the payload is the cancellation sentinel.

**Sync.lua — sender-authority cold-start miss**
- Fixed the rank cache returning nil for legitimate officer broadcasts in the first ~10 seconds after `PLAYER_LOGIN`, before the guild roster has arrived from the server. `getSenderRank` now force-refreshes once when the cache is entirely empty. `Core.lua` also pre-warms the roster at login.

### Feature

**UI.lua — Pass keeps the window open for the loot master**
- The Pass button on the roll window now behaves differently by role: viewers (regular raiders) close the window as before; **leaders / officers keep the window open** so they can still pick a winner from the leaderboard. Either way the local roll buttons are locked so the player can't accidentally roll after passing. The status label updates to "Passed — pick a winner from the list" for the leader's reference.

### Other Loot-System Fixes

- `OnOpenMasterLootList` now uses `canRunLoot()` (officer **or** group leader) instead of the stricter `isLeader()`. A non-officer designated Master Looter can now have the roll window auto-open on master-loot, matching the behaviour of `/vcl roll` and the alt-click path.
- `StartRoll` now warns the leader when their own sync is disabled while in a group, so they immediately understand why no one else is seeing the roll window.

### Version

- Bumped from **1.2.0** to **1.2.1** — roll-window fixes + Pass-button feature.

---

## 1.2.0 — May 5, 2026

### Security

**Sync.lua — sender-authority gate on every mutating message**
- `DELTA`, `BATCH_EP`, `RAID`, `SETTINGS`, `HIST_DEL`, `ROLL_START`, `ROLL_END`, and `FULL` (including the season-bump hard-wipe path) are now rejected unless the sender holds Vibe Master / Officer / Officer Alt / Loot Master rank, OR is the in-game Master Looter, OR (for leader-or-officer messages) is the current group leader. Previously any guild member could broadcast a forged `DELTA` to award arbitrary EP/GP, push a forged `SETTINGS` to corrupt every roster's GP formula, or wipe every receiver's database via a forged season bump. Guild ranks are looked up via a 30-second cached `GetGuildRosterInfo` table.
- `PROFILE` messages now require the in-payload player name to match the sender. Without this, anyone could overwrite anyone else's role, gear snapshot, and wishlist on every client.
- Numeric `SETTINGS` values are clamped to per-key sane bounds (`gpFinalMult` 0.0001–1000, `decayPercent` 0–100, etc.) so an out-of-range push (or `inf`/NaN) cannot land. `BATCH_EP` and `DECAY DELTA` amounts are clamped to sane upper bounds (≤100 000 EP per kill; 0–100 % decay).
- Only officers reply to `REQ FULL`; non-officer replies would be rejected on receive anyway, and skipping the send saves bandwidth and reduces flood risk.

### Bug Fixes

**Sync.lua**
- Fixed lifetime EP/GP double-count on offline-divergence reconciliation. The old code adopted the sender's `lifeEP` via `max(local, incoming)` and then *also* added local-unique entries' amounts on top, compounding lifetime totals every time a FULL sync triggered the replay path. `applyHistoryEntries` now takes a `replayMode` flag that skips the lifetime bump.
- Fixed `mergeHistory` truncating local history to 1 000 rows when the local DB cap is 2 000. Receiving a FULL no longer silently drops rows the local SavedVariables would otherwise keep — both ends now reference `ns.C.MAX_HISTORY`.
- Fixed `histKey` collisions when two officers awarded the same EP amount to the same player in the same wall-clock second. The dedup key now includes `by`. A `legacyHistKey` fallback keeps pre-1.2 tombstones working during the transition window.
- Fixed `BATCH_EP` re-application on redelivery. Receivers now build a `seenKeys` set from current history and skip rows that already exist, so a redelivered or twice-fired BATCH_EP does not double-credit attendees.
- Fixed `DELTA` re-application on redelivery: receivers now check the canonical histKey against existing history before applying.
- Fixed chunked-transfer expiry being measured from the *first* chunk's wall-clock arrival rather than last activity. A FULL exceeding ~600 chunks (~140 KB) was silently discarded mid-stream. Expiry is now last-activity-based and the window is raised from 30 s to 60 s.
- Fixed self-echo guard's short-name comparison dropping legitimate messages from a same-realm group member who shares a first name with the local player. Comparison now uses normalized full names.
- Fixed `CHUNK_SIZE = 230` cutting close to the 255-byte addon-message cap (worst-case framed header for `BATCH_EP` could push the total over). Reduced to 215 with explicit header-size accounting documented inline.
- Fixed `hasLocalUnique` rev-bump being set against the *unfiltered* local-unique list. Tombstoned entries that had already been deleted elsewhere were counted as new data we needed to advertise, causing peers to needlessly request a FULL from us. Now reflects the post-tombstone-filter count.
- Fixed unbounded growth of `presence` / `inbox` / `reqLastSent` when `syncEnabled = false` (Heartbeat never runs and never prunes). A throttled `maybePrune` runs from `OnMessage` every 60 s.

**Sync.lua / DB.lua / UI.lua — history deletion now reverses EP/GP**
- Centralized `DB:DeleteHistoryEntry(idx)` reverses the entry's EP/GP impact on the roster, drops the row, tombstones the canonical histKey, and bumps `dbRev` in one call. Both the UI right-click delete and the sync `HIST_DEL` handler use it. Previously the row was removed from the visible log but the points it awarded stayed on the player. `applyTombstones` (FULL receipt) now also reverses EP/GP for tombstoned rows.

**Slash.lua — `/vcl sync` no longer floods the guild**
- `/vcl sync` now uses the targeted-best-peer flow (whisper exactly one peer for a FULL response) instead of broadcasting `REQ FULL` to GUILD where every online addon user simultaneously responds. If no peers are known (cold start) it fires a heartbeat, waits 3 s for replies, then falls back. `/vcl sync force` skips the wait and broadcasts immediately.

**Decay.lua**
- Fixed `decayBaseGP = false` (the "protect base GP" mode) silently doing nothing because it referenced `s.baseGP`, but the actual setting key is `s.gpBase`. The floor protection has never engaged since the GP-formula migration; it works correctly now.

**Attendance.lua**
- Wipe-EP awards now use one `BATCH_EP` message instead of N per-player `DELTA` messages, matching the boss-kill flow. This both cuts traffic and lets non-officer raid leaders broadcast wipe credit (DELTA is officer-only post-1.2; BATCH_EP is leader-or-officer).

### Version

- Bumped from **1.1.0** to **1.2.0** — sync hardening + security gates.

---

## 1.0.0 — April 29, 2026

### Bug Fixes

**Loot.lua / Core.lua**
- Fixed double `HandleModifiedItemClick` hook. When Bag Roll modifier was set to Alt (the default), both the Core.lua hook (`Loot:OnAltClick`) and the UI.lua bag-roll hook fired on the same alt-click, opening the roll prompt twice. `OnAltClick` now yields when the bag roll modifier already covers that gesture.

**UI.lua**
- Fixed free roll auto-detection using `GetNumGroupMembers()`. A 25-man raid with fewer than 20 people online would falsely trigger the 10-man free roll default. Detection now uses `maxPlayers` from `GetInstanceInfo()`, which returns the instance's hard cap (10 for Kara/ZA, 25 for 25-man content), and is therefore always accurate regardless of current attendance.
- Fixed free roll checkbox state not being passed correctly to `StartRoll` when unchecked in a 10-man instance. The checkbox visual updated correctly but the state read by the start button could be stale. State is now snapshotted into a local before the prompt frame is hidden.
- Fixed multiple Unicode characters rendering as blank squares in-game: em-dash (`—`) and middle-dot (`·`) in `SetText`, `AddLine`, and `ns.Print` calls across UI.lua and Backup.lua replaced with ASCII equivalents.
- Fixed the wish list "Done" button displaying `←` and `—` as blank boxes; replaced with `<<` and `-`.

**WishListData.lua**
- Fixed item IDs and boss assignments for Zul'Aman, Hyjal Summit, Black Temple, and Sunwell Plateau. Nearly all entries across these four raids had incorrect item IDs, wrong item names, or were assigned to the wrong boss. All four raids have been rewritten with correct Wowhead-verified data including tier token assignments (Azgalor = T6 gloves, Archimonde = T6 helms, Mother Shahraz = T6 shoulders, Illidari Council = T6 legs, Illidan = T6 chest, Kalecgos = SWP bracers, Brutallus = SWP belts, Felmyst = SWP boots, Eredar Twins = SWP necks, M'uru = SWP rings, Kil'jaeden = SWP chest).

---

### New Features

**Sync.lua — Tombstone System**
- History deletions are now durable across syncs. When an officer deletes a history entry, its key is recorded in a tombstone set (`db.deletedHistKeys`) with a timestamp. Full sync payloads include a `---TOMBSTONES---` section; receiving clients apply incoming tombstones after merging history, permanently removing any re-introduced entries. Tombstones expire after 90 days and are pruned on send and receive to prevent unbounded growth.

**UI.lua — Bag Roll Modifier**
- Added a configurable modifier key for clicking bag items to open the roll session prompt. Configured in Settings → Loot Distribution → Bag Roll. Options: None (disabled), Alt-click, Ctrl-click, or Alt+Ctrl-click. Replaces the previous hardcoded alt-click behavior with an explicit, user-controlled setting.

**UI.lua — Addon Hotkey Relocated**
- The keyboard shortcut for opening/closing the VibeCheck window has been moved from the Loot Distribution tab to the Sync & Data tab, which is a more appropriate home for a UI preference that is not related to loot settings.

**UI.lua — Free Roll Auto-Check for 10-Man**
- The Free Roll checkbox in the roll-start prompt is now automatically pre-checked when you are inside a 10-man instance (Karazhan or Zul'Aman), defaulting to the no-EPGP roll mode appropriate for those raids. The checkbox remains interactive — officers can uncheck it to run a normal EPGP roll if needed.

---

### Version

- Bumped from 0.9.5 to **1.0.0** — first full public release.

---

## 0.6.0

### Bug Fixes

**Decay.lua**
- Fixed incorrect default decay day-of-week. The fallback was `2` (Monday) but the setting default in Constants is `3` (Tuesday). Decay now fires on the correct night for guilds who never changed this setting.
- Fixed incorrect default decay hour. The fallback was `6` (6 AM) but the setting default is `0` (midnight). Both defaults now match Constants.DEFAULTS.

**Core.lua / Loot.lua**
- Fixed encapsulation bug where `Core.lua` was directly writing `ns.Loot.tradeBothAccepted = false` after `TRADE_CLOSED`. The flag is now read and reset entirely inside `Loot:OnTradeClosed()`. Core just calls the method with no arguments.

**Attendance.lua**
- Fixed `benchEPMultiplier` not being bounds-checked. A value above 1.0 would award bench players more EP than raiders. The multiplier is now clamped to `[0, 1]` before use.

**Sync.lua**
- Fixed SYNCABLE_SETTINGS guard on the *receive* side. Previously, unknown keys from a newer client version could be written into settings. Settings received over sync are now validated against the explicit whitelist before being applied.

---

### New Features

**Sync — Alt Map Propagation**
- Full sync payloads now include an `---ALTS---` section. Alt links set by any officer will now propagate to all addon users on the next full sync. Incoming links are merged non-destructively (conflicts are skipped rather than overwritten).

**Sync — Last-Sync Status Indicator**
- The addon now tracks `Sync.lastSyncTime` and `Sync.lastSyncSender` whenever a full sync is applied.
- The Settings → Sync & Data sub-tab shows a live status line: "Last sync: 3m ago from Playername" or "Not yet synced this session."

**WishListData — Phase 3–5 Content**
- Added complete wish list item databases for all remaining TBC phases:
  - **Zul'Aman** (6 bosses: Nalorakk, Akil'zon, Jan'alai, Halazzi, Hex Lord Malacrass, Zul'jin)
  - **Hyjal Summit** (5 bosses: Rage Winterchill, Anetheron, Kaz'rogal, Azgalor, Archimonde) — includes all T6 token slots
  - **Black Temple** (9 bosses: Naj'entus through Illidan) — includes both Warglaives
  - **Sunwell Plateau** (6 bosses: Kalecgos through Kil'jaeden) — includes Thori'dal

**Constants — Zul'Aman Boss List**
- Added all 6 Zul'Aman NPC IDs to `BOSS_LIST` for completeness, consistent with the existing Karazhan entries. Kill detection is still filtered to 20+ man raids so no EP will auto-award from ZA — the entries are informational.

**Settings — CSV Export**
- The "Export CSV" button in Settings → Sync & Data now opens an in-game copy dialog with the full roster CSV (sorted alphabetically), instead of dumping each line to chat. Copy and paste directly into a spreadsheet.

---

### Removals (approved)

**Slash Commands**
- Removed `/vcl test roll` — seeded fake roll sessions for UI testing. No longer needed now that the roll window is stable.
- Removed `/vcl test raid` — injected fake Gruul + Magtheridon raids. Removed alongside the 140-line injection block in `Slash.lua`.
- Removed `/vcl export` — chat-line CSV dump. Replaced by the in-game copy dialog in the Settings tab.

**Loot.lua**
- Removed `StartTestRoll()`, `TEST_ITEMS`, and `FAKE_RAIDERS` (~90 lines of test scaffolding).

---

### Performance / Quality

**Sync — History Cap**
- `HISTORY_SYNC_MAX` raised from 200 to 500 entries in outbound FULL payloads.
- Local post-merge history cap raised from `HISTORY_SYNC_MAX * 2` (400) to a fixed 1000 entries, matching the UI's display limit.

---

## 0.5.0

- Standings tab: complete rewrite with role filter pills (All / Tank / Healer / Melee / Ranged), PR rank column, attendance color coding, and Shift+Click double-sort on any column.
- Log tab: Raids sub-tab with zone filter pills, duration column, and pulsing "In progress" row. History sub-tab with date-range filter (All / Today / Week / Month), clickable player name filter, and totals footer.
- Fixed inline search-bar clear button (X) misalignment in Standings.
- Fixed "0 PR" filter button labels — now "Show 0 PR" / "Hide 0 PR".
- Standings sort now defaults to PR descending. Shift+Click any column header to set a secondary sort key.
- Fixed Unicode characters rendering as blank squares in-game. All rendered strings now use ASCII-only substitutes.

## 0.4.x and earlier

See git history.