GuildBoostContest
=================

Version: 0.1.57
Interface: 11508 (WoW Classic Era / Hardcore)
Author: Ronnie

A WoW Classic Era addon for running weekly XP-boost contests within a single
guild. High-level guildies earn points by helping lower-level members gain XP.
No party required, no gold changes hands, no automation.

INSTALLATION
------------
1. Copy the GuildBoostContest folder into:
   World of Warcraft\_classic_era_\Interface\AddOns\
2. Launch the game. At the character select, enable GuildBoostContest in the
   AddOns list.
3. All participating guildmates must install the addon for discovery to work.

QUICK START
-----------
1. Both players log in. Wait ~30 seconds for discovery to populate.
2. Type /gboost to open the main UI, or click the minimap button.
3. Find the peer in the picker. Click "Boost Me" or "I'll Boost Them".
4. The other player gets a popup. On accept, the session begins.
5. Kill mobs. The recipient earns XP, the booster earns pts.
6. Type /gboost leave when done. Both sides confirm.

SLASH COMMANDS
--------------
  /gboost                          toggle the main UI
  /gboost sync                     open the peer picker
  /gboost invite <Name> <booster|recipient>
  /gboost accept                   accept a pending invite
  /gboost decline                  decline a pending invite
  /gboost leave                    end the current session
  /gboost cancel                   cancel an outbound invite
  /gboost status                   print sync state
  /gboost confirm                  confirm the last session
  /gboost dispute [reason]         dispute the last session
  /gboost available on|off         toggle presence broadcast
  /gboost options                  open the Blizzard options panel
  /gboost debug on|off             toggle debug logging
  /gboost debug session            dump current session stats
  /gboost debug stats              wire traffic counters
  /gboost debug peers              list known peers
  /gboost debug log                dump the in-memory log buffer
  /gboost help                     list commands

FEATURES
--------
- Peer picker UI with class colors, level, and last-seen freshness.
- Floating session indicator (drag-to-reposition) showing live pts, +XP, and
  kill count without opening the main UI. Yellow "warmup M:SS" countdown
  appears while the configured warmup window is active.
- Dashboard: Weekly Total / Today / Current Session cards, plus History,
  Leaderboard, Admin, Rules, and Settings tabs.
- Admin tools (hardcoded admin keys only): per-session override, low-confidence
  audit flagging, weekly tunables.
- Guild-wide rule sync: admin's rule changes auto-push to all addon users
  (~2 second debounce), accepted only if signed by a hardcoded admin key.
- Pet and companion damage attribution via COMBATLOG_OBJECT_AFFILIATION_MINE.
- Late-XP recovery buffer: 5-second window for XP that arrives before the
  booster's MOB_DAMAGE wire message catches up.
- Blizzard options panel + minimap button via LibDBIcon.

SCORING (DEFAULTS)
------------------
- Per-kill: 100 * (xpFromKill / xpForLevel) * levelWeight, distributed across
  contributing boosters by their damage + healCredit share.
- Healing counts at 0.5x effective.
- Level weight curve: (xpForLevel / referenceXp) ^ 0.45, clamped to [0.35, 3.0].
- Warmup window default: 0 seconds. Admin can raise via Settings panel.
- Daily and weekly per-recipient caps default to 0 (disabled).

WIRE PROTOCOL
-------------
- Addon prefix: GBOOSTC
- PRESENCE / LBSUMMARY / MOB_DAMAGE / SCORE_UPDATE: GUILD addon channel
- INVITE / INVITE_ACK / HEARTBEAT / START_SESSION / END_SESSION / SCORE_PREVIEW
  / CONFIRM_SESSION / DISPUTE_SESSION: WHISPER addon channel
- All payloads pipe-delimited (UnpackFields), field 1 = "v1" version tag.

WHAT THIS ADDON IS NOT
----------------------
- Not a paid-boost marketplace, matchmaker, or advertising tool.
- Not cross-guild: by design it only sees guildmates running the addon.
- Not a gameplay automator: no targeting, no movement, no combat input.
- Does not exfiltrate data: all wire traffic stays on the WoW addon channel.

REQUIREMENTS
------------
- WoW Classic Era / Hardcore (Interface 11508 or newer).
- Bundled libraries: LibStub, LibDataBroker-1.1, LibDBIcon-1.0.

CHANGELOG
---------
0.1.57
- Contest mode (lifetime accumulation). New setting contestMode
  defaults to "lifetime". The leaderboard now sums pointsAsBooster /
  xpHelped / sessionCount / guildiesHelped across EVERY week's bucket
  under each playerKey, so the calendar weekly rollover (Sunday →
  Monday) no longer "resets" the visible leaderboard. The data was
  never deleted in prior versions either — it was just sitting in
  the prior week's bucket while LB.MyEntry / LB.ForWeek read only
  the current week.
- LB.MyEntry (Leaderboard.lua) now branches on contestMode: lifetime
  reads every week via the new Sc.GetAllWeeklyBuckets(ownerKey)
  helper in Scoring.lua and sums. weekly mode keeps the original
  single-week semantics.
- LB.ForWeek similarly aggregates the leaderboardCache rows for each
  playerKey across all cached weeks when in lifetime mode. The
  "Today" column still reads from the current calendar week only
  (it's a daily, not a weekly, value). guildiesHelped uses max
  across weeks as a lower-bound approximation (the wire carries a
  count, not a set — strict union deferred).
- New admin command /gboost admin endcontest [yes]. Archives every
  (playerKey, weekId) bucket under archivedWeeks[playerKey], then
  wipes weeklyTotals / dailyTotals / leaderboardCache. Use this to
  start a fresh contest period — calendar week rollover no longer
  triggers a visible reset under lifetime mode. Confirmation
  required.
- A.EndContest broadcasts the "end_contest" admin action via the
  existing ADMIN_BROADCAST pipe. Receivers run A.EndContest(true)
  to archive their own partitioned data — every account's data is
  partitioned per-character, so each client only ever archives its
  own characters' buckets.
- contestMode is in S.GUILD_WIDE_KEYS so admins can push it
  guild-wide via the existing SETTINGS_PUSH mechanism.
- Recovery without code changes: just /reload after dropping the
  0.1.57 zip into AddOns. Last week's points reappear in the
  leaderboard because LB.MyEntry / LB.ForWeek now read every week's
  bucket. No manual data migration needed; the data was preserved
  the whole time.

0.1.56
- New admin command: /gboost admin purgechar <Name-Realm>. Atomically
  removes a character from the leaderboard AND wipes the local rollup
  buckets that were causing the character to keep coming back. The
  underlying root cause: migration v7 at Core.lua:302-336 (0.1.42)
  partitioned pre-0.1.42 weeklyTotals / dailyTotals / archivedWeeks
  by playerKey, but had to pick WHICH playerKey to assign the existing
  account-aggregate data to. It picked GB.PlayerKey() at the moment
  the migration first ran. If that character was a bank alt / level-1
  / never-sessioned character (like Ronnie's Housewife), it inherited
  every booster pt the account had ever earned. Migration v9 (0.1.46)
  then seeded leaderboardCache from those bad weeklyTotals entries.
  Every login of the affected character re-broadcast the bad data
  via LB.MyEntry / LB.Broadcast.
- A.PurgeCharacter wipes GB.DB.weeklyTotals[key], dailyTotals[key],
  archivedWeeks[key] on the calling client (the only client that had
  the bad partition — per-account, not guild-wide), and calls
  A.PurgeLeaderboardEntry(key, nil) to drop the cache row across
  every cached week. Broadcasts purge_char via the existing admin-
  broadcast pipe so every other client's leaderboardCache also drops
  the row.
- _onAdminBroadcast at Audit.lua handles "purge_char": receiver runs
  A.PurgeCharacter(key, true) — their own weeklyTotals are untouched
  (partition keeps each account's local data isolated), only their
  cache row drops. They Notify "X purged character Y" if quietChat is
  off.
- No wire format changes. No migration. Backward compatible with
  prior versions for everything except the new purgechar command,
  which old clients don't know about — but the cache-purge half is
  carried by the existing purge_lb broadcast that 0.1.45+ clients
  handle. Old clients will keep the row until they upgrade.

0.1.55
- Critical fix: duel acceptance was being blocked with "GuildBoostContest
  has been blocked from an action only available to the Blizzard UI" /
  "forcetaint_strong has been blocked from action". Root cause: we
  registered 8 entries in Blizzard's StaticPopupDialogs table
  (GB_INVITE_POPUP, GB_RESUME_POPUP, GB_CONFIRM_POPUP, GB_CONFIRM_RESETWEEK,
  GB_CONFIRM_CLEARALL, GB_EDIT_SESSION_PTS, GB_EDIT_LB_PTS,
  GB_CONFIRM_PURGE_LB). WoW iterates that table when showing any popup
  including secure ones like DUEL_REQUESTED, and our insecure closures
  in OnAccept/OnCancel propagated taint through the iteration. When
  AcceptDuel (protected) ran, Blizzard's defense fired forcetaint_strong
  to block the action.
- Fix: all 8 popups migrated to custom modal frames built via a new
  UI._ShowCustomPopup / UI._HideCustomPopup helper. We no longer touch
  StaticPopupDialogs at all. The custom frames live in FULLSCREEN_DIALOG
  strata, draggable, mouse-blocking, with the same Accept/Decline (or
  edit-box) semantics as before. ESC closes via UISpecialFrames for
  popups marked hideOnEscape (default true; the Invite/Resume/Confirm
  popups remain ESC-locked like the originals).
- Surface API change: ShowInvitePopup / ShowConfirmPopup / ShowResumePopup
  take the same args; admin call sites now pass body text and editbox
  initial value directly to UI._ShowCustomPopup instead of mutating the
  static popup table. Existing event hooks (GB:INVITE_RECEIVED,
  GB:EXTRA_INVITE_RECEIVED, GB:EXTRA_INVITE_ACCEPTED) unchanged.
- Legacy StaticPopupDialogs definitions kept in --[==[ block comments
  for one release as a reference; will be deleted in 0.1.56.

0.1.54
  Big drop: frictionless multi-booster join + bullet-proof resync.
  Bundled in 4 sub-phases (A: durability foundation, B: durability
  edges, C: recipient-initiated invite, D: invite UX).

Phase A — durability foundation:
- MOB_DAMAGE recipient-side dedupe. The pre-0.1.54 += accumulator
  (Sessions.lua:1025-1026) double-counted any retried MOB_DAMAGE.
  Changed to last-write-wins (= dmg, = healCredit). Wire carries
  cumulative tally per spec at plan/02_wire_protocol.md:163, so
  replace is the correct semantics. Duplicate detection logged so
  retry behavior is observable in /gboost debug log.
- Critical wire types routed through Sess._sendWithRetry:
    MOB_DAMAGE     (Sessions.lua onUnitDied path)
    SCORE_UPDATE   (Scoring.lua SendScoreUpdates path)
    RESYNC_PING    (Sessions.lua RequestResync)
    CONFIRM_SESSION + DISPUTE_SESSION (Sessions.lua)
    INVITE + INVITE_ACK (Sync.lua Invite, _sendInviteAck)
  Each previously dropped silently on combat-throttle SendAddonMessage
  returning false. Now each retry attempt is queued for 30s; queue
  drains every 5s and immediately on PLAYER_REGEN_ENABLED.
- Retry queue persistence. Sess._SaveRetryQueue / _RestoreRetryQueue
  serialize the queue to GuildBoostCharDB.retryQueue on PLAYER_LOGOUT
  and every 30 seconds, then replay (after age-trim) on PLAYER_LOGIN.
  A MOB_DAMAGE queued at second 30 of a 30-second retry window now
  survives a /reload at second 31 instead of disappearing with the
  module-local queue table.

Phase B — durability edges:
- Late SCORE_UPDATE escape hatch. The pre-0.1.54 hard guard at
  Sessions.lua:1277-1281 blocked any SCORE_UPDATE after localConfirmed,
  so a booster who confirmed the 30s placeholder popup (0 pts because
  the real SCORE_UPDATE hadn't arrived) was stuck on 0 permanently.
  Now: if incoming pts > existing pts AND status != "disputed", treat
  as a late correction — undo the prior commit's contribution to
  weeklyTotals.pointsAsBooster + perRecipient, then commit the new
  pts. Logged as "late correction" in the ring buffer.
- Three-attempt RESYNC_PING on snapshot restore. 0.1.50 sent pings at
  +2s and +15s; 0.1.54 adds a third at +45s for peers who were in
  combat or loading-screen through both prior attempts. Total resync
  window ~60s post-restore.
- Ghost session auto-close. If a restored session can't get a single
  heartbeat from any peer for sessionMaxHours/2 (default 2h),
  Sess.End("ghost") fires and clears the activeSession slot. Prevents
  zombie sessions from accumulating across days of bad reconnects.

Phase C — recipient-initiated invite:
- Sync.Invite hard-gate (was: if Sync.State ~= "S_IDLE" return false)
  relaxed for the recipient-already-tracking case. When local user is
  RECIPIENT in S_SYNCED/S_TRACKING/S_DESYNCED and inviteeRole=BOOSTER,
  inviter stays in their current state and the INVITE flies under our
  EXISTING canonical sess.id. The invited booster's _onInviteAck
  adopts the canonical sessionId via the 0.1.50 adoption code at
  Sync.lua:251.
- New slot Sync.pendingOutboundExtraInvite (separate from the regular
  pendingInvite so it doesn't clobber the fresh-session invite flow).
  60s timeout fires a Notify; no state change. Overflow is rejected
  ("another outbound booster invite is still pending").
- _onInviteAck routes outbound-extra ACKs (matched by pending slot's
  peerKey) to AppendPeer + Sess._sendStartSessionTo, not to full
  session bind. EXTRA_INVITE_ACCEPTED event fires on accept.
- Sync.Leave + Sync.Cancel both clear pendingOutboundExtraInvite.
- /gboost invite <Name> booster now works mid-session for recipients
  (was previously blocked by the same hard guard).

Phase D — invite UX:
- Modal popup parity for inbound EXTRA_INVITE_RECEIVED. The 0.1.50
  multi-booster path was chat-Notify only; users routinely missed it.
  Now the same StaticPopup GB_INVITE_POPUP fires for both initial
  and extra invites (OnAccept already routes via Sync.AcceptInvite ->
  Sync.AcceptExtraInvite at Sync.lua:319).
- "+ Add Booster" button on the sync panel, visible only when local
  role=RECIPIENT. Click toggles UI._addBoosterMode, swapping the
  dashboard from sync panel to picker view temporarily. Picker row
  buttons re-label: "Boost Me" becomes "+ Add as Booster" for
  recipient-in-tracking. EXTRA_INVITE_ACCEPTED event clears the flag
  and returns to sync panel.
- Picker button enablement honors multi-booster:
    btnA "Boost Me" enabled if (isIdle OR isRecipientTracking) AND
                                no outbound extra-invite pending
    btnB "I'll Boost Them" enabled only if isIdle
- Sync.pendingExtraInvite + Sync.pendingOutboundExtraInvite persisted
  in _SaveSnapshot, restored in _RestoreSnapshot (with age check —
  popups older than inviteTimeoutSec are dropped). A /reload mid-
  popup re-fires the EXTRA_INVITE_RECEIVED event so the popup re-
  shows after restore.

Wire protocol unchanged. No migration required. Backward compatible
with 0.1.53 clients for 1:1 sessions; the additional-booster join
path requires both ends on 0.1.50+ for the canonical-sessionId
adoption to land cleanly.

0.1.53
- Fix: multi-booster sessions blocked at the picker. Players reported
  that when a recipient was already syncing with one booster, the
  recipient's row in OTHER boosters' pickers showed greyed out
  ("unavailable"), so the second/third booster's "I'll Boost Them"
  button was disabled and they couldn't initiate the additional-
  booster invite even though the recipient was perfectly able to
  accept one via Sync.AcceptExtraInvite (added in 0.1.50).
- Discovery.lua's PRESENCE broadcast was setting available=false
  for ANY non-S_IDLE state. New logic in computeAvailable():
    S_IDLE                           → available=true
    pendingExtraInvite popup open    → available=false
    RECIPIENT in S_SYNCED/S_TRACKING
      /S_DESYNCED, no pending popup  → available=true
    BOOSTER in any session state     → available=false
    S_INVITING/S_INVITED/S_ENDED     → available=false
  Recipients in a session now advertise themselves as available so
  additional boosters' pickers show them as joinable and the invite
  button enables.
- Discovery now re-broadcasts PRESENCE immediately on state
  transitions (GB:STATE_CHANGED, GB:EXTRA_INVITE_RECEIVED,
  GB:EXTRA_INVITE_ACCEPTED, GB:PEER_LEFT) instead of waiting up to
  30s for the next ticker. Throttled to one extra broadcast per 3
  seconds so a rapid invite handshake doesn't flood the guild.
  Boosters see the recipient's availability flip within seconds.
- No wire format changes. No migration. Backward compatible — old
  clients still receive the new PRESENCE payload normally; only the
  available flag's meaning expanded.

Known UX gap (not blocking, deferred to 0.1.54):
- A recipient already mid-session has no UI button to initiate an
  additional booster invite themselves. The picker is hidden when
  in synced states. Workaround: use /gboost invite <Name> booster
  (this still hits the S_IDLE guard in Sync.Invite — also needs
  fixing). The user-reported issue is the BOOSTER-INITIATED path,
  which is fully unblocked by this release.

0.1.52
- Scoring switched from Option D pure level anchor to Option E
  level-scaled anchor. Per Ronnie's call: lowbies fly through
  early levels, so paying the same per level as a highbie skews
  efficiency toward lowbie-only boosting. Add a level-difficulty
  multiplier so booster pts/hour stays parity across bands while
  per-level pts scale with the level's actual difficulty.
- Formula:
    killPoints = α × (xpFromKill / xpForLevel)
               × (xpForLevel / 16800)^γ
  where 16800 is xpForLevel at recipient level 30 (the anchor
  point — multiplier = 1.0 at lvl 30, so α reads naturally as
  "pts per level at the midpoint").
- New settings (Settings.lua):
    levelAnchorPoints       = 50   (α, kept from Option D)
    levelDifficultyExponent = 0.5  (γ, new)
  γ tuning dial:
    γ = 0.0  → reverts to Option D (flat per level)
    γ = 0.5  → pts/hour parity across bands (default)
    γ = 1.0  → pure XP weighting (high levels pay way more)
  Both keys are guild-wide via SETTINGS_PUSH; admins can tune
  them live from the Rules tab.
- Per-level pts at default γ=0.5:
    lvl 10 → ~14 pts/level (×0.8 share = 11.5)
    lvl 30 → ~50 pts/level (×0.8 share = 40)
    lvl 59 → ~152 pts/level (×0.8 share = 122)
  Booster pts/hour: lvl 10≈23, lvl 30≈24, lvl 59≈31 — close to
  parity. Lowbie boosts still feel meaningful (a 5-lvl lowbie boost
  pays ~58 pts) while highbies don't feel penalized.
- Audit > Recompute Week guard bumped: CURRENT_FORMULA = "E".
  Option D sessions ("D" stamp) AND Option C sessions (no stamp
  but activeSecForKill present) AND pre-0.1.48 sessions all get
  preserved at their stored points. Only "E"-stamped sessions get
  rescored — same fix-forward precedent.
- Session record adds session.levelDifficultyMultiplier for
  diagnostics (the effective per-level scaling for that recipient
  level). Wire format unchanged.

0.1.51
- Scoring switched from Option C hybrid to Option D pure level anchor.
  Per Ronnie's call: "I never wanted a clock-based metric. Actions
  should equate to points; time will make it confusing." Per-kill:
    killPoints = α × (xpFromKill / xpForLevel)
  where α = levelAnchorPoints (default 50). The time anchor
  (β × activeSecForKill) is gone. Sums to exactly α per recipient
  level boosted, regardless of band. Boosts that finish faster are
  simply more efficient pts/hour; nothing rewards slow grinds.
- Settings removed: timeAnchorRate, activeSecPerKillCap. Both wiped
  from existing DBs by migration v9 (_optionDPureAnchorMigrationV1).
  settingsVersion bumped so an old admin's stale SETTINGS_PUSH can't
  resurrect them. levelAnchorPoints and healingWeightMultiplier
  remain. Rules tab in the UI now shows just the two scoring rows.
- Audit > Recompute Week guard generalized: sessions are tagged with
  scoringFormulaVersion at Sc.Finalize time ("D" as of this release).
  Recompute only re-scores sessions whose stamp matches the current
  formula. Pre-0.1.48 sessions (no activeSecForKill, no stamp) AND
  Option C sessions (activeSecForKill present, no "D" stamp) are
  preserved at their stored points — same fix-forward precedent as
  the 0.1.48 transition.
- Session record stops storing timeAnchorTotal; killPointsTotal now
  equals levelAnchorTotal exactly. boosterAwards drops pointsTime;
  pointsLevel is now the per-booster total. Wire format unchanged.
- Sessions.lua continues to record activeSecForKill per ledger entry
  for diagnostics / future analytics; it's just not multiplied into
  the score anymore.

0.1.50
- New feature: N:1 multi-booster sessions. A recipient can now have
  multiple boosters synced to the same session simultaneously. When
  a recipient already in S_SYNCED or S_TRACKING receives an INVITE
  from a new booster, instead of rejecting it the addon notifies
  the recipient and waits for /gboost accept (or /gboost decline).
  On accept, the new booster is bound into the existing session via
  Sync.AppendPeer and Sess._sendStartSessionTo bootstraps them with
  the recipient's xpStart / level / xpMaxAtStart. Sync.pendingExtraInvite
  is a single-slot inbox — overflow invites auto-decline so the user
  doesn't get a popup queue.
- Wire protocol unchanged. The recipient's INVITE_ACK for an extra
  booster carries the recipient's CANONICAL sess.id (not the booster's
  freshly-minted id). Booster's _onInviteAck adopts whatever sessionId
  comes back from the recipient, so MOB_DAMAGE / HEARTBEAT /
  SCORE_UPDATE all route through the existing sess.id == fields[3]
  dispatch with no per-peer alias bookkeeping needed. Nonce check
  still authenticates the ACK.
- Per-peer state machine. Each entry in sess.peers now carries
  state ("synced" / "desynced" / "left"), joinedAt, lastSeen,
  desyncedAt, leftAt, leftReason. Sync.AppendPeer / FindPeer /
  ActivePeers / SyncedPeers / MarkPeerLeft helpers. _makePeer
  factory builds peer entries consistently across every accept path.
- Per-peer desync watchdog. Each peer's heartbeat gap is evaluated
  independently. A booster going offline desyncs JUST that peer; the
  session continues in S_TRACKING as long as at least one booster is
  still synced. When all active peers desync the session-level state
  flips to S_DESYNCED. When all peers leave the session auto-ends
  cleanly with reason "all_peers_left". Auto-resync: a freshly-
  desynced peer triggers a RESYNC_PING (throttled to 10s per peer)
  so brief network hiccups self-heal without manual intervention.
- END_SESSION per-peer. If one booster sends END_SESSION the recipient
  marks JUST that booster as left and prints "<Name> left the session
  (N booster(s) remaining)". Other boosters keep tracking. Only when
  the last booster leaves does the recipient finalize the session.
- Resume robustness. _RestoreSnapshot seeds lastHeartbeat[p.key] =
  now for every active peer post-restore so the watchdog doesn't
  immediately desync them based on a stale pre-/reload gap. A
  RESYNC_PING fires at +2s and +15s after restore so peers wake up
  immediately instead of waiting for their own heartbeat ticker.
  Snapshot migration: peers loaded from a pre-0.1.50 snapshot get
  state="synced" / joinedAt / lastSeen back-filled lazily on first
  use.
- Retry queue for critical wire sends. C_ChatInfo.SendAddonMessage
  returns false during in-combat throttle or guild-channel
  saturation. Sess._sendWithRetry wraps the send; failed sends go
  into a queue drained every 5 seconds AND on PLAYER_REGEN_ENABLED.
  RETRY_MAX_ATTEMPTS = 3, RETRY_MAX_AGE_SEC = 30. Used by
  START_SESSION, END_SESSION (whisperToPeers), and the new per-
  peer START_SESSION send via Sess._sendStartSessionTo. wireStats
  gains a send_failed counter; dropped counter ticks on each
  exhausted-retry drop.
- New slash command: /gboost peers lists active session peers, their
  per-peer state, the seconds since each peer's last heartbeat, and
  any pending extra-invite popup. Useful for diagnosing multi-
  booster session health without opening the UI.
- New internal events: GB:PEER_JOINED, GB:PEER_LEFT, GB:PEER_DESYNCED,
  GB:PEER_RESYNCED, GB:EXTRA_INVITE_RECEIVED, GB:EXTRA_INVITE_ACCEPTED.
  UI consumers (next release) can subscribe to render the multi-card
  sync panel and the "add booster?" popup; in 0.1.50 the user-facing
  path is the chat Notify + slash commands.
- UI is intentionally unchanged in 0.1.50. The sync panel still shows
  a single peer card (peers[1]) — multi-booster sessions are fully
  functional but visually look 1:1. Multi-card sync panel + per-
  booster live breakdown ships in 0.1.52.

0.1.49
- Fix: the leaderboard could show different rows on two accounts logged
  in at the same time (e.g. one client missing Vibepally + Housewife
  while the other had them). Root cause was the gossip protocol being
  push-only with no catchup mechanism — a freshly-loaded client could
  wait up to 5 min for every other guildie's independent ticker, and
  rows for offline guildies (whose broadcasts the client missed) never
  appeared at all. LB.OnRemote also did a blind overwrite with no ts
  comparison, so a late-arriving stale gossip could clobber fresher
  data.
- New wire types LB_REQUEST + LB_FORWARD (GUILD channel). LB_REQUEST is
  sent at PLAYER_LOGIN (10s after) and on every Leaderboard tab open
  (throttled to one request per 30 seconds). Every receiving client
  responds with a jittered (0–5s) LB.Broadcast of its own row.
- Admin clients additionally forward up to FORWARD_MAX_ROWS (default
  30) cached rows via LB_FORWARD over FORWARD_JITTER_MAX seconds
  (default 20) — so rows for offline guildies (cached on the admin's
  client from prior gossip) reach freshly-loaded clients without
  waiting for the offline guildie to log back in.
- TS-aware merge in mergeEntry (Leaderboard.lua): an incoming row is
  dropped if our cache already has the same row at a later ts. Stops
  forwarded snapshots from replay-overwriting newer direct gossip,
  and stops stale admin pushes from undoing point edits.
- Sync.lua dispatch routes LB_REQUEST → LB.OnRequest, LB_FORWARD →
  LB.OnForward.
- UI.SelectTab("Leaderboard") triggers LB.SendRequest so opening the
  tab on a stale client kicks a fresh catchup pulse.
- Wire cost: per LB_REQUEST, one inbound + (1 + 30) outbound = ~31
  messages per admin response, spread over 25s. Requester-side throttle
  caps this to one request per 30s per client.

0.1.48
- Scoring formula switched to Option C hybrid (per Ronnie's "equal
  reasons to help anyone, lowbie or highbie" tuning). Per-kill points
  are now:
    killPoints = α × (xpFromKill / xpForLevel)
               + β × min(timeSincePriorKill, activeSecPerKillCap)
  Defaults: α = 50 (levelAnchorPoints), β = 0.10 (timeAnchorRate),
  cap = 60 sec (activeSecPerKillCap). The level anchor sums to α
  per recipient level regardless of band — boosting a level 12 pays
  the same per-level as boosting a level 55. The time anchor scales
  with active combat seconds so a long high-level grind still ends
  up worth more in total. AFK gaps are clipped by the per-kill cap.
- Daily-per-recipient and weekly-percent caps removed. Option C is
  self-rate-limited by grind cost; the same-pair-farming warning in
  the Audit tab still surfaces flagrant cases but no longer auto-
  clips points.
- Old machinery deleted: scoringExponent, levelWeightMin/Max,
  referenceXpMode/Override, dailyPerRecipientCap,
  weeklyPercentFromOneRecipient. GB.DB.referenceXP and
  GB.DB._refXpSamples wiped (no longer used).
- Migration v8 (_optionCHybridScoringMigrationV1) drops the old keys,
  seeds the new ones, and bumps settingsVersion so another admin's
  stale SETTINGS_PUSH can't resurrect the old values via last-writer-
  wins. Old session records keep their pre-0.1.48 scoring fields
  (basePoints, levelWeight) unchanged; only new sessions score under
  Option C.
- Audit > Recompute Week is now "fix-forward": pre-0.1.48 sessions
  are detected by the absence of activeSecForKill in their ledger
  entries and skipped during the re-score loop, so their stored
  finalPoints / boosterAwards survive. Their points are still
  re-committed into the wiped weeklyTotals bucket so leaderboard
  totals don't disappear after a Recompute. The admin gets a one-
  line Notify telling them how many pre-0.1.48 sessions were
  preserved.
- Boost-tracking telemetry per booster now carries pointsLevel /
  pointsTime alongside pointsFinal so the Audit tab can show the
  level-anchor vs time-anchor split. Session record adds
  levelAnchorTotal, timeAnchorTotal, killPointsTotal.
- Recipient kill ledger now records activeSecForKill per mob in
  Sessions.lua (UNIT_DIED handler + 3 fallback paths). lastKillTs
  resets in Sess.Begin. First kill uses (session.startedAt + warmupSec)
  as its prior-kill anchor.
- Touches: Scoring.lua (formula + finalize fields), Settings.lua
  (defaults + GUILD_WIDE_KEYS), Sessions.lua (computeActiveSecForKill
  + 4 ledger callsites), Core.lua (version + migration v8), UI.lua
  (Rules tab settings rows), Audit.lua (referenceXP cleanup), TOC
  + README version.

0.1.46
- Roll back the 0.1.44 wholesale leaderboardCache wipe and re-seed
  local rows. v8 was overkill: while it cleared pre-0.1.42 ghost
  rows, it also nuked rows for legitimately-offline guildies who
  had no way to re-broadcast until their next login. Result: the
  leaderboard looked emptier than it should after upgrade, with
  characters like Megahertz / Megamega / Vibepally missing.
- Migration v9 re-seeds leaderboardCache from local weeklyTotals.
  Every (playerKey, weekId) pair with non-zero local data writes a
  leaderboard row immediately on first 0.1.46 login. This restores
  every character on YOUR account whose totals you've ever committed
  locally — Megahertz, Megamega, Megarette, etc. all reappear
  without having to log into each one.
- For guildies on OTHER accounts whose rows the wipe killed (e.g.
  Vibepally), v9 cannot help — their data lives only on their
  client. Two manual restore paths added:
    UI: Leaderboard tab → [Add Row] button (admin-only, top-right).
        Opens a modal: Name-Realm + Weekly + Today + Sessions + XP.
        Saves and broadcasts to every guildie's client.
    Slash: /gboost admin addlbrow <Name-Realm> <weekId|now> <weekly>
           [daily] [sessions] [xpHelped]
  Both call A.UpsertLeaderboardRow which broadcasts via the existing
  ADMIN_BROADCAST pipe (new action: upsert_lb_row), so every guildie
  on 0.1.46+ sees the restored row immediately.
- Migration v8 is REMOVED. The 0.1.44 activity-gate removal and the
  0.1.45 admin Purge/Edit Pts buttons together handle ghost-alt
  cleanup without needing to scorch the cache.

0.1.45
- Admin can now edit or purge ANY leaderboard row, including ghost-
  alt rows that have no backing session on the local client (the
  case the 0.1.42-0.1.44 fixes only addressed for future broadcasts).
  Two new admin actions:
    /gboost admin setlbpts <Name-Realm> <weekId|now> <pts>
    /gboost admin purgelb <Name-Realm> [weekId]   -- omit week for all weeks
  Both broadcast guild-wide via ADMIN_BROADCAST so every admin and
  guildie's client mirrors the change immediately. If the target is
  the local character, weeklyTotals[targetKey][weekId].pointsAsBooster
  is also updated so the next LB.Broadcast doesn't restore the old
  value.
- Leaderboard tab UI: when an admin opens the Leaderboard tab, each
  row gets two new buttons on the right: [Edit Pts] (opens edit
  popup) and [Purge] (confirms, then removes the row across every
  cached week). Non-admins see the same six-column layout as before.
- Row width widened to 720 so the admin buttons don't overlap the XP
  column.

0.1.44
- Stale-alt cleanup. 0.1.42 stopped *new* alt-leak broadcasts but
  did not invalidate already-cached pre-0.1.42 LBSUMMARY entries,
  so idle alts like Housewife kept appearing on the leaderboard
  with account-aggregate numbers. Two changes in this build:
    (1) Migration v8 wipes leaderboardCache once on first 0.1.44
        load. Real participants re-broadcast and re-populate the
        cache within ~5 minutes; idle alts stay absent.
    (2) LB.Broadcast no longer skips when MyEntry returns zero.
        The 0.1.42 activity-gate was overly defensive — by NOT
        broadcasting the alt's correct zero entry, it left the
        old account-aggregate row pinned in every receiver's
        cache. Now an idle alt's zero broadcast lands on every
        receiver, ForWeek's existing filter drops the zero row
        on display, and the alt disappears from every guildie's
        leaderboard who is also on 0.1.44+.
- Caveat: guildies still on 0.1.41 or older will continue to see
  stale alt rows in their own cache until they upgrade OR until
  the affected alt logs in on 0.1.44+ (which broadcasts the
  zero overwrite to old clients too — the LBSUMMARY wire format
  is unchanged, only the gating logic differs).
- Wire-cost change: every character broadcasts every 5 minutes
  whether they have points or not. ~1 LBSUMMARY/5min/character
  on the GUILD addon channel; negligible.

0.1.43
- 3D over-head nameplate icon is back. The synced peer now gets the
  same raid-target icon stamped on their nameplate (above their head
  in the world) in addition to the existing TargetFrame badge. Both
  are independent toggles: /gboost marker on|off (TargetFrame),
  /gboost nameplate on|off (over-head). New default setting
  showPeerNameplate = true.
- Coexists cleanly with TidyPlates_ThreatPlates (and Plater if you
  install it later) because the icon is a child of the *base*
  nameplate frame returned by C_NamePlate.GetNamePlateForUnit, which
  is owned by Blizzard, not by any nameplate addon. TP/Plater build
  THEIR widgets as children of the same base frame, so ours is just
  another sibling — no hooks, no texture replacement, no fight over
  who owns the bar. The 0.1.30 conflict was caused by hooking and
  mutating nameplate textures directly; this version doesn't.
- Diagnostic: /gboost nameplate debug dumps every gate (state,
  setting, C_NamePlate API availability, nameplateShowFriends CVar,
  active-plate list with tokens/units/badge state).
- Required CVar for the badge to appear at all: nameplateShowFriends
  must be 1. Check with /run print(GetCVar("nameplateShowFriends")).
  Toggle with /run SetCVar("nameplateShowFriends", 1) — or via
  Interface Options > Names > "Friendly Players".

0.1.42
- Fix: idle alt on the same account no longer appears on the guild
  leaderboard with the main's points. weeklyTotals / dailyTotals /
  archivedWeeks are now keyed by playerKey first, so each character
  on an account owns its own slice and never reads another
  character's totals as its own. LB.Broadcast also gates on per-
  character activity — a character with zero own sessions this week
  doesn't emit LBSUMMARY at all.
- Migration v7 runs once at ADDON_LOADED: detects pre-0.1.42 shape
  (top-level keys like "YYYY-WNN" or "YYYY-MM-DD") and re-keys the
  whole table under whoever's logged in at upgrade time. Best-effort;
  alts that log in later start clean. Historical points are not re-
  splittable because the pre-0.1.42 data was never partitioned.
- Touches: Core.lua (migration), Scoring.lua (GetDailyBucket /
  GetWeeklyBucket helpers + partitioned writes in CommitBoosterTotals
  and CommitRecipientTotals), Leaderboard.lua (partitioned MyEntry +
  activity gate in Broadcast), Audit.lua (Approve/Deny/SetSessionPoints/
  DeleteSession/ResetWeek/RecomputeWeek all use sess.ownKey), UI.lua
  (summary cards partitioned), Sync.lua (debug "lb" diagnostic
  partitioned).

0.1.41
- Version bump only; no code changes from 0.1.40. Planning docs
  updated for the alt-leaderboard partition fix (Vector 3a), but
  the fix has not been applied to Leaderboard.lua / Scoring.lua
  yet — idle alts may still appear on the leaderboard in this build.

0.1.40
- Version bump only; rebundle of 0.1.39 for distribution.

0.1.39
- Fix: peer role badge wasn't appearing on TargetFrame even when the
  peer was targeted. Sync.lua:262 sets peer.name to senderKey ("Name-
  Realm") when knownPeers doesn't have the peer (e.g. you invited by
  name before discovery had populated the cache). Markers.lua then
  compared against UnitName("target") (just "Name") and the strings
  never matched. peerNameAndRole now strips "-Realm" so the compare
  works in both branches.
- New slash: /gboost marker debug dumps every gate the badge checks
  (state, setting, peer info, target name, match result, frame
  presence) so any future "badge isn't showing" report is one slash
  command away from a diagnosis.
- ThreatPlates not actually involved — TargetFrame is independent of
  the nameplate stack. The badge was never going to interfere; this
  was a name-normalization bug.

0.1.38
- Fix: a second confirm/dispute popup could appear on the booster
  after both sides had already confirmed. Most likely trigger was an
  admin running Recompute Week, which re-runs Sc.Finalize on every
  recipient session and re-emits SCORE_UPDATE — but with the admin's
  PlayerKey as senderKey, so the per-sender dedup at Sessions.lua:960
  missed it. The recomputed session could land with confidence=invalid
  if its current xpGained was 0 (e.g. mid-edit), and overwrite the
  resolved session's confidence/award fields.
- Three guards in defense:
    1. Sessions.lua:_onScoreUpdate now returns early if sess is
       resolved (localConfirmed / status == approved / disputed),
       so it never overwrites finalized fields or re-shows the popup.
    2. The 30s placeholder-popup timer in Sess.End also checks for
       resolved state — a late timer won't fire a stale popup.
    3. UI.ShowConfirmPopup at UI.lua:1764 gates on the same resolved
       state, so any future caller gets the guard for free.

0.1.37
- Debug-mode chat spam silenced by default. The [INFO] log lines that
  print every state transition, LB.Broadcast, SCORE_PREVIEW etc. come
  from GB.Log (Core.lua:57-58), which is gated by the debugMode setting.
  The in-memory ring buffer (Core.lua:44-56, 200 entries) keeps
  recording regardless of the setting — only the live chat print is
  gated.
- Migration v6 forces debugMode=false on existing characters. Default
  in Settings.lua is already false.
- New "Debug mode" checkbox in the Settings tab so you can toggle
  visually. Slash also still works: /gboost debug on | off | toggle.
- /gboost debug log dumps the captured history (up to 200 entries)
  on demand — that's the "hide live, reveal when I want it" path.
- Wire protocol, scoring, sync untouched.

0.1.36
- Fix: marker icon-picker rows reappear in the Settings tab. Removing
  the friendly-nameplate CVar checkbox in v0.1.30 left UI.lua:1162
  anchoring the booster-icon row to a now-nil cbNp reference. SetPoint
  on nil errors out and the rest of the Settings tab build aborted —
  including both icon picker rows and the bottom status text. Anchor
  changed to cbQuiet so the rest of the Settings tab builds again.

0.1.35
- Further chat-silence pass. The remaining async / event-driven chat
  prints now route through GB.Notify and are suppressed by quietChat:
    - Audit.lua admin broadcast notifications (when another admin in
      the guild resets a week, approves a session, etc.)
    - Sync.lua async invite outcomes ("X did not respond", auto-decline
      timeout, peer-declined-your-invite)
    - Sessions.lua "prior session discarded" courtesy line
- Login toast (Core.lua:239 "GuildBoost Contest vX.X.X loaded — week ...")
  default flipped from true to false. Migration v5 forces the new default
  onto existing characters. Re-enable via the loginToast setting if you
  want the version stamp on every reload.
- Slash-command output and UI-button-result prints are intentionally
  left as raw print() — you typed/clicked, you want the response.
  Real handshake errors (Sync.lua:246 nonce mismatch) stay raw too so
  a failing invite is never silent.
- Wire protocol, state machine, scoring, and commit logic untouched.

0.1.34
- Three more reliability fixes from the same QA pass:

  5. Approve / Deny symmetric for both roles. Audit.lua:97 (Approve)
     and Audit.lua:119 (Deny) previously only handled BOOSTER sessions.
     A recipient session that was denied left the self-credit
     (CommitRecipientTotals' contribution to weeklyTotals.pointsAsBooster)
     untouched, and a subsequent Approve never restored it. Worse, an
     approve-after-deny on a recipient session would have left the
     totals double-credited once Approve was fixed. Both paths now
     handle BOOSTER and RECIPIENT symmetrically.

  6. END_SESSION receiver no longer discards the peer's end fields.
     Sender at Sessions.lua:374-378 packs reason / endedAt / finalXp /
     finalLevel; receiver at _onEndSession was reading only the reason
     and dropping the other three. _onEndSession now writes peerEndedAt,
     peerXpEnd, peerLevelEnd onto the session record BEFORE calling
     Sess.End — so they persist into GB.DB.sessions[] for audit review.

  7. Snapshot now persists GB.Sync.State. Sessions.lua:155 (snapshot)
     records the live state; Sessions.lua:238 (restore) uses snap.state
     with a S_TRACKING fallback for older snapshots. Prior versions
     forced S_TRACKING regardless, which was correct for the common
     case but could mis-state a session resumed mid-handshake.

0.1.33
- Four reliability fixes from the post-0.1.32 QA pass:

  1. Migration table-name fix. The 0.1.30 and 0.1.32 cap-reset migrations
     were writing push-version timestamps to GB.DB.settingsPushMeta.versionMap,
     but Settings.lua reads from GB.DB.settingsVersion (different table).
     The cap values themselves reset correctly but the anti-resurrection
     guard was a no-op — another admin pushing the old values could
     reapply them. Migration v4 now bumps the correct settingsVersion
     table for every cap key (warmupSec, sessionMinSec, recipientMinLevel,
     recipientMaxLevel, boosterMinLevelDiff, dailyPerRecipientCap,
     weeklyPercentFromOneRecipient).

  2. 60s SCORE_UPDATE fallback double-commit guard. The fallback at
     Sessions.lua:414 committed live preview points if SCORE_UPDATE didn't
     arrive in 60 seconds, but never set the dedup flags
     (_scoreUpdateReceived and _awardCommittedFrom[recipientKey]). A late
     real SCORE_UPDATE arriving at e.g. 65s would then commit again,
     double-counting the booster's points. Both flags are now set before
     the fallback commit.

  3. Live-preview vs final-commit consistency. Scoring.lua:80 (Finalize)
     used `session.xpMaxAtStart or 10000` while ComputeLivePreview at
     line 47 used `... or session.peerXpMaxAtStart or 10000`. The two
     paths now share the same fallback order, so the live "+0.6 pts"
     readout matches the committed value when xpMaxAtStart wasn't
     hydrated locally (e.g. mid-restore).

  4. RecomputeWeek no longer silently skips pending/interrupted sessions.
     Audit.lua:261 previously gated on s.status == "approved" and
     dropped every other booster session on the floor when an admin
     re-scored a week. Now re-commits any non-disputed session;
     CommitBoosterTotals' pts<=0 gate handles the zero-point cases.

0.1.32
- Migration v3: zero out every remaining point-clipping cap. Settings
  forced to defaults on next load:
    recipientMinLevel             = 1   (was 10 in saved DB)
    recipientMaxLevel             = 60  (was 59)
    boosterMinLevelDiff           = 0   (was 5 — booster level vs recipient)
    dailyPerRecipientCap          = 0   (was 300 — per-recipient/day cap)
    weeklyPercentFromOneRecipient = 0   (was 0.4 — declared, never enforced)
  Push-version stamps for each key are bumped so an older admin push
  cannot resurrect the old values. Admins can re-enable any of these
  individually via the Rules tab.
- Together with the 0.1.30 warmup/sessionMin migration, no cap can
  silently zero a booster's earned points by default. Anti-abuse
  caps remain available as opt-in via the Rules tab.

0.1.31
- Quiet mode is now the default. Routine session-tracking messages
  ("synced with X", "tracking XP from level N", "resumed session ...",
  "+N pts from X", etc.) are suppressed in chat. The following remain
  visible and unaffected:
  - All slash-command output (/gboost status, /gboost help, etc.)
  - Confirm / dispute popup prompts
  - SCORE_UPDATE timeout fallback warnings (data-safety event)
  - Admin broadcast notifications (someone reset week, approved, etc.)
  - Login toast (still controlled by the loginToast setting)
- Migration v2: existing characters with quietChat=false get flipped
  to quietChat=true once on next load. Re-enable chatty mode via the
  Settings tab's "Quiet mode" checkbox if preferred.
- No changes to the wire protocol, state machine, scoring math, or
  commit logic. Tracking + sync reliability is byte-identical to 0.1.30.

0.1.30
- Peer marker re-anchored from the world nameplate onto the player's
  TargetFrame. The addon no longer touches C_NamePlate or hooks any
  NAME_PLATE_UNIT_* events, so it cannot interfere with
  TidyPlates_ThreatPlates or other nameplate replacements. When you
  target your synced peer, a small raid-target icon + "BOOSTER" or
  "RECIPIENT" label appears next to TargetFrame; otherwise nothing
  is drawn in the world.
- Removed the "Show friendly-player nameplates" Settings checkbox —
  the badge no longer requires nameplates.
- Migration v1: warmupSec and sessionMinSec are forced to 0 once on
  next load and their settings-push timestamps are bumped so a stale
  600/300 value cannot drift back in from another admin's old DB.
  Admins can re-enable either cap from the Rules tab when desired.
- Fixed asymmetric commit. Zero-point sessions (interrupted by the
  session-min cap, all kills inside warmup, etc.) no longer bump
  weeklyTotals.sessionCount or weeklyTotals.xpHelped. Previously
  a short session would show "3 sessions, 1046 XP helped, 0 pts"
  on the leaderboard and look like points were lost; now those
  sessions simply do not contribute to the totals on either side.
  The session itself still lives in GB.DB.sessions for history /
  admin review.

0.1.29
- New "Quiet mode" checkbox in the Settings tab. Suppresses non-critical
  chat notifications routed through the new GB.Notify helper:
    - "synced with X" / "invited X" / "X invites you to sync"
    - "+N.N pts from X (session ...)"
    - "tracking XP from level N"
    - "received N rule update(s) from X"
  Slash-command output (status, help, /gboost debug *), confirm/dispute
  popups, and warnings still print normally.
- Setting key: quietChat (default false).

0.1.28
- Reverted the 0.1.26 / 0.1.27 cross-guild accommodations. The GUILD addon
  channel is a hard requirement for MOB_DAMAGE / SCORE_UPDATE / LBSUMMARY /
  ADMIN_BROADCAST delivery, so pretending the in-guild requirement is
  optional was misleading. Reverted:
  - recipientMustBeInGuild and boosterMustBeInGuild defaults back to true.
  - Dropped both flags from GUILD_WIDE_KEYS (no longer auto-pushed) and from
    the Rules tab layout (no longer admin-tunable).
  - Peer-picker heading back to "Available guild members with the addon".
  - Dashboard "partners" back to "guildies".
- MOB_DAMAGE returns to GUILD-only send (no WHISPER fallback) — keeps the
  throttle-resistant guarantee from 0.1.8.

0.1.27
- UI language no longer assumes same-guild. The peer-picker heading now reads
  "Available addon users (guild via discovery + anyone you /gboost invite by
  name)" instead of "Available guild members with the addon". The dashboard
  cards now say "N partners" instead of "N guildies".

0.1.26
- Default eligibility no longer requires same-guild. recipientMustBeInGuild
  and boosterMustBeInGuild now ship as false. bothMustHaveAddon stays true.
- Admins can still flip these back on per-contest via the Rules tab if a
  given event is guild-internal only.

0.1.25
- History tab rows now also have Edit Pts / Approve / Deny / Delete buttons,
  visible only when the viewing character is an admin. Non-admins still see
  read-only rows. Same handlers as the Admin tab, so changes broadcast over
  ADMIN_BROADCAST in 0.1.24.

0.1.24
- Admin actions now broadcast to the whole guild via the new ADMIN_BROADCAST
  wire type. When an admin runs Clear All, Reset Week, Recompute Week,
  Approve / Deny / Delete / Edit Pts on a session, every other addon user
  in the guild applies the same change locally — no more divergent data
  between admin alts.
- Receivers verify the sender is an admin (hardcoded key OR guild rank 0/1)
  before applying. The local_only flag on each Audit function prevents
  receive-side re-broadcast loops.

0.1.23
- New /gboost admin defaults — resets every guild-wide rule on the admin's
  DB to the shipped defaults and pushes them out, clearing any stale values
  left over from prior testing (e.g. warmupSec=300 propagated by the old
  auto-PushAll). Shipped defaults are unchanged: warmupSec=0,
  recipientMinLevel=1, boosterMinLevelDiff=0, dailyPerRecipientCap=0, etc.

0.1.22
- Guild officers are now admins. GB.IsAdmin() returns true for any of:
  the hardcoded admin keys (Megahertz/Megahurts/Housewife/MegaMega/Megahunts),
  the guild leader (IsGuildLeader), or anyone with promote permission
  (CanGuildPromote).
- SETTINGS_PUSH from guild rank 0 (GM) or rank 1 (top officer) is now accepted
  by other clients, even without being on the hardcoded list. Guild-channel
  trust is the gate.
- Removed auto-PushAll at PLAYER_LOGIN. Auto-pushing every guild-wide setting
  on every admin login was rebroadcasting stale values (notably warmupSec)
  from any admin's DB. Now: rules only propagate when an admin explicitly
  edits a setting, or runs /gboost admin pushall manually.
- New /gboost admin pushall slash command — manual full re-push for onboarding
  new members or fixing a divergent peer.
- New "Show friendly-player nameplates" checkbox in the Settings tab, sets
  the nameplateShowFriends CVar. Required for head-icon markers to attach.

NOTE: weeklyTotals is written to disk only on /reload or logout (a WoW addon
limitation — there is no synchronous disk-write API). A hard client crash
between reload points can lose recent pts. The 0.1.21 booster fallback commit
covers the in-memory case; nothing covers a crash.

0.1.21
- Booster fallback commit. If SCORE_UPDATE never arrives within 60s after
  the session ends (wire drop, peer crash), the booster commits the live
  preview pts to weeklyTotals so the leaderboard doesn't silently lose them.
  Disabled if SCORE_UPDATE arrives normally.
- CommitBoosterTotals and CommitRecipientTotals now log to chat (debug-only):
    COMMIT booster: +0.30 pts -> week=2026-W25 weekly 0.60 -> 0.90 (session ...)
    COMMIT recipient: +0.30 pts -> week=2026-W25 weekly ...
- CommitRecipientTotals now runs unconditionally — even when totalFinal == 0
  — so sessionCount records and the leaderboard isn't surprised.
- Defensive nil-backfill for session.dateKey and session.weekId before commit.
  These being nil was causing silent commit failures inside the event pcall.

0.1.20
- New diagnostic: /gboost debug lb prints the leaderboard pipeline state
  (weeklyTotals -> cache -> UI), forces a broadcast + refresh, and prints
  the cache value again so the failure point is obvious.

0.1.19
- Leaderboard "Pts" column is now visually loud: yellow + larger font for any
  non-zero value, gray when zero. Header renamed from "Weekly" to "Pts (week)".
- Active-session rows append "(+X.X live)" in green so the live overlay is
  visible alongside the committed total.
- Slightly widened the leaderboard row so the new format fits cleanly.

0.1.18
- /reload-safe sessions. On PLAYER_LOGOUT the active session is snapshotted to
  CharDB (ledger, cumulative counters, xp anchors, peer info). On PLAYER_LOGIN
  a popup offers "Resume" or "Discard" if the snapshot is < 1 hour old.
  Resume restores all locals, restarts the heartbeat, and pings the peer.
- New wire type: RESYNC_PING (GUILD addon channel). Peer responds with an
  immediate heartbeat, flipping S_DESYNCED back to S_TRACKING.
- New desync banner UI. When state is S_DESYNCED, a draggable panel shows:
  - Peer name and how many seconds since last heartbeat
  - Live countdown to auto-end (threshold + grace = 135s default)
  - Three buttons: Resume Now / End & Save / Wait
  Banner auto-hides when the session recovers or ends.

0.1.17
- Settings tab now has a "Peer head-icon markers" section with an enable
  checkbox and two icon-picker rows (8 raid-target icons each — choose any
  for the booster and any for the recipient).
- New setting keys: markerBoosterIcon, markerRecipientIcon (default 1 and 5).
- Markers update live when toggled or changed — no /reload required.

0.1.16
- New slash command /gboost marker on | off | toggle to control the synced
  peer's head-icon (showPeerMarker setting). Updates the icon immediately —
  no /reload needed.
- /gboost help now lists the marker command.

0.1.15
- Markers.lua now defers nameplate attachment by one tick (C_Timer.After 0.01s)
  to avoid racing TidyPlates_ThreatPlates' AcquireUnitFrame hook, which can
  cause flicker or missing decorations on either addon's nameplate frame.

0.1.14
- New head-icon markers: while synced, the booster's peer (recipient) shows a
  moon above their nameplate; the recipient's peer (booster) shows a star.
  Uses built-in Blizzard raid-target icons. Visible only during S_SYNCED /
  S_TRACKING — hidden the moment the session ends. Toggle via the setting
  showPeerMarker (default on).
- New file: Markers.lua.

0.1.13
- The booster's "kills" counter now reports the RECIPIENT's authoritative kill
  count — only mobs that actually yielded XP to the boostee. A booster solo-
  killing a mob the recipient never tagged no longer increments the counter.
- Recipient's kill count rides on HEARTBEAT field 11 and SCORE_PREVIEW field 9.
- Booster's old local tally is preserved as stats.boosterRawKills for debug.

0.1.12
- Leaderboard now live-updates DURING active sessions. The active session's
  preview pts fold into the weekly column while in S_TRACKING / S_SYNCED;
  when the session ends and pts commit, the same amount stays in place.
- A 15-second per-session ticker rebroadcasts LBSUMMARY so other guildies
  also watch the row climb in real-time, not just on session end.
- Admin Edit Pts (added 0.1.10) is unchanged — you can still retroactively
  modify any session's award after the fact.

0.1.11
- Leaderboard now refreshes on every AUDIT_RESOLVED event (Approve, Deny,
  Delete, Edit Pts, week reset, recompute, clear). Previously these admin
  actions could leave the leaderboard tab showing stale numbers until the
  next 5-minute gossip tick.
- LB.Broadcast now writes a debug log line so /gboost debug on can confirm
  the broadcast chain is firing on session score and admin edits.

0.1.10
- Admin tab: new "Edit Pts" button per session row opens a popup to override the
  pts award. Adjusts weekly/daily totals by the delta so the leaderboard stays
  consistent.
- Removed the 200-session cap on the Admin tab. All sessions are scrollable.
- New /gboost admin setpts <sessionId> <pts> slash command.
- New /gboost admin nowarmup slash command — zeros warmupSec locally and pushes
  the change to every addon user in the guild. Use to clear a stale non-zero
  warmupSec that was propagated by an earlier admin push.

0.1.9
- Recipient now earns pts equal to the total boosters earned for the session,
  so mutual-boosting peers each grow their cumulative weekly column.
- Leaderboard hides entries that have not yet participated (no sessions and
  no pts this week). Discovery-only LBSUMMARY pings no longer pollute it.

0.1.8
- Late-XP recovery buffer for the XP-before-MOB_DAMAGE race.
- MOB_DAMAGE and SCORE_UPDATE moved to GUILD addon channel to avoid Classic
  Era whisper throttling.
- Defensive diedAt assignment when xpFromKill is set, preventing
  ComputeLivePreview from filtering out otherwise-valid entries.
- Leaderboard cache now refreshes immediately on SESSION_SCORED instead of
  waiting for the 5-minute gossip tick.
- Booster confirm-popup fallback timer extended 10s -> 30s.
- Float bar and dashboard session card show a yellow "warmup M:SS" countdown
  when warmupSec > 0.
- session.xpStart / xpMaxAtStart / levelStart now published at Sess.Begin so
  live preview scales correctly instead of falling through to the default.

0.1.7
- GUILD channel for MOB_DAMAGE transport with session-id gating on receive.
- Float bar live readout: pts, XP, kills per role.
- PREVIEW diagnostic line with ledger / withXp / withByB / both / awardsSum /
  warmup / xpMaxAtStart for debugging scoring.

FEEDBACK
--------
Report issues with /gboost debug stats output and the session id.
