FastGuildInvite-v2.10.10
What's new
<FGI> FastGuildInvite
[v2.10.10] (2026-07-29) — announcement rotations: ordered round-robin announce sets with a guild-synced cursor
Rotations — an enhancement to Announce, not a new feature
A rotation is a new kind of announce entry: instead of a message it carries an ordered set of existing announcements that take turns, one per interval, cycling. Ten announcements on a 5-minute rotation means each comes back around roughly every 50 minutes.
Deliberately built as a layer inside the announce engine rather than beside it:
- No timers, and no changes to
Modules/Wingman.lua. The horn, the tab's Send button, and Wingman's existingannouncestep all drive rotations unchanged.Announce:Send()already posts exactly one eligible (profile, channel) pair per call and walks the set across clicks — a rotation only changes which profiles are eligible on a given call. Every post therefore still lands inside a hardware event, so this inherits v2.10.3's retail-safe posting with no new exposure. - The interval is an eligibility gate, not a scheduler: "every 5 minutes" means "on the first click after 5 minutes have elapsed". Posts drift later than nominal, never earlier.
Design doc: docs/ROTATIONS_DESIGN.md.
Data model — same array, told apart by kind
Rotations live in the same DB.factionrealm.announce.profiles array as announcements, keyed by a
new kind field. An absent kind reads as "announcement", so every pre-v2.10.10 profile keeps
working with no migration. One array means one RowList, one Save path, one delete path, one Guild
tag — no parallel CRUD.
{ id, name, enabled, kind = "rotation", cooldown = <interval>, activity, channels,
members = { "<profileId>", ... } } -- ORDERED; policy snapshots carry {id,name,message}
Modules/Announce.lua—rotationMembers(normalises the personal bare-id and policy inline-snapshot shapes, dropping members with no body),rotationMemberIds,rotationNext,rotationDue,memberChannelReady, plus the publicIsRotation/RotationMembers/RotationMemberIds/RotationStatus/RotationStateKey.- The behavioural change is confined to
gatherEligible: an announcement in no rotation behaves exactly as before; one that belongs to an enabled rotation is skipped in the normal loop (so it can never also fire on its own cooldown); each rotation past its interval contributes only the member its cursor points at, against the rotation's channels. - Kind guards added to every loop over
profiles—GetStatus,HasActiveProfiles,ProfileReadyIn,bumpActivity, and the policy snapshot path. Unguarded, a rotation would have been treated as an announcement with an empty message:IsEligiblereturns"empty"for that, so it would have been inert by accident. Guarded explicitly rather than left resting on that. CreateProfiletakes akind;DuplicateProfilecarrieskind+members;DeleteProfilenow strips the deleted id from every rotation that listed it, so a dangling member can't silently shorten a rotation or strand its cursor.
The cursor — persisted and guild-synced
Stored as the profile id that posted last, never an index — an index misaddresses the moment members are reordered or one is deleted, whereas an id resolves against the current order and takes whatever now follows it.
State lives at the synthetic key "<rotationId>:_rotation", which reuses the existing
"<id>:<channelKey>" layout and therefore inherits its personal-vs-shared routing for free: a
personal rotation's cursor is per-character, a guild rotation's is the shared
DB.factionrealm.announceState. Cursors advance at round finish — when the round's last post
lands — so the interval measures from the completed round and a rotation whose channels were all
unavailable doesn't skip a message.
Modules/FGI_AnnounceSync.lua— two new message kinds:T(rotation posted: key/epoch/cursor) andB(rotation catch-up triples, whispered in reply toR). Merged MAX-wins on epoch likelastPosted, with the winner's cursor adopted — so whoever posted most recently also decides where the rotation sits. Additive: a pre-v2.10.10 client falls through theif kind ==chain and ignores them, degrading to "cooldowns sync, cursors don't".- Rotation keys are excluded from
serializeStateso a rotation can never arrive viaAfirst —Awould setlastPosted, and theBcarrying the same epoch would then lose the MAX comparison and drop the cursor.
This is the point of syncing: when the designated announcer logs off and duty passes to the next member, they resume at message 7 rather than restarting at the head.
Guild policy — tagging a rotation ships its members too
GUI/Tabs/Announce.lua—setGuildAnnounceMarkgains a rotation branch that snapshots the rotation and every member's text inline. A recipient holds no copy of the editor's personal announcements, so bare ids would resolve to nothing; the ids still travel because the synced cursor keys off them, and they originate from the editor's profiles so they're guild-stable.- Saving an announcement that belongs to a tagged rotation now re-snapshots that rotation, or the guild would keep broadcasting the pre-edit wording.
- Enforced guild rotations render as one read-only row, not one row per member.
UI
- Type dropdown on the form strip (
Announcement/Rotation). SelectingRotationhides the Message input and byte readout and reveals the Members picker; they share row 2. The CD label retitles to Interval, since the same field means both. - Membership in the dropdown, order in the rows. The Members dropdown is a plain checkable
multi-select backed by an ordered array (tick appends, untick closes the gap) whose closed label
shows only a count. Order is set in the list: a rotation row carries a
+/-expander in a new leftmost column, and expanding splices its members in as indented child rows — position, name, and a one-line body preview — with up/down arrows in the action strip. Swap-with-neighbour, clamped, the end arrows hidden rather than inert. - Rejected in use: an earlier build chose position in the dropdown too, via a level-2 submenu with the running order baked into the labels and summary. The summary grew without bound as members were added, and re-numbering every label per edit meant re-initialising the open list under the cursor.
GUI/RowList.luagains two opt-in, additive features; flat lists are unaffected. Anexpander = truecolumn renders a per-row+/-and callsonToggle(entry, newState); tri-state by design (trueexpanded /falsecollapsed /nilnot expandable, button hidden) so leaf rows in the same list carry no value for the key. And anonSortChangedcallback fired from the header click — hierarchical lists must collapse first, because sorting reorders the flat array and would tear child rows away from the parent they were spliced in after. The tab's own name sort runs before the splice for the same reason.- Type column in the RowList showing
Rotation (n); a rotation's Status shows its countdown plus which member posts next (6/10); an announcement claimed by a rotation reads(in rotation)instead of a cooldown that no longer governs it. 6/10disambiguated. It means "message 6 of 10 goes out next", but reads naturally as "6 of 10 are set up". Both the Status and Type header tooltips now say which it is, and the expanded rows mark the actual next member in gold with anexttag in its Type cell — so the collapsed number and the expanded list always corroborate each other.- A member's own On is not required. Membership is the inclusion switch and the rotation's On is the master; a member's On, Channels and CD are all inert while it belongs to an enabled rotation (they govern again once it leaves, or the rotation is switched off). Requiring the member's On as well was a double-gate with nothing behind it — the member is already excluded from the normal eligibility loop, so its On governed nothing else.
- Dropdown list labels and the member summary use ASCII separators (
1. Name/- Name,A > B > C). U+2192 and U+2014 aren't in the bundled fonts' coverage and rendered as boxes. MIN_WIDTH720 → 840 to fit the new dropdown on row 1.
Packaging: MoP Classic TOC added, .pkgmeta ignore list corrected
FastGuildInvite_Mists.toc (new). .build.info reports the installed wow_classic product as
5.5.4.68806 — MoP Classic — and FGI shipped no _Mists.toc. That client therefore fell back to
the base FastGuildInvite.toc (Interface 11509, Classic Era) and was flagged out of date, loading
only with "Load out of date AddOns" ticked. Confirmed against the install rather than assumed: other
addons there (TOGProfessionMaster, TOGTools, ProfessionDB, GuildRoster) all carry _Mists.toc.
Interface 50504; file list identical to the other Classic TOCs.
TOC audit otherwise clean — all six file lists match (the only difference is _Mainline's extra
Libs\LibWho\LibWho_Retail.lua, which is deliberate), and every ## Version: remains
FastGuildInvite-v2.10.10 for the packager to substitute from the tag. Interface numbers verified against
.build.info: retail 12.0.7.68887 → _Mainline already lists 120007; Classic Era 1.15.9.68940
→ base TOC 11509. _BCC / _Wrath / _Cata cover products not installed here, so they were left
alone rather than guessed at.
.pkgmeta — removed eight ignore entries that were no-ops: .git, .github, .vscode,
.claude, .busted, .luarc.json, .markdownlint.json, .markdownlintignore. The packager's
copy_directory_tree() prunes dotfiles unconditionally (find ... -name ".*" -prune), so these were
already excluded and listing them implied coverage the file wasn't providing. The remaining entries
follow the enforced syntax — folders bare (no trailing slash, which would produce docs//* and match
nothing), globs quoted with a single star (no recursive ** support in match_pattern's case
globbing), specific files as plain repo-relative paths. Rules recorded inline so they survive the next
edit.
[v2.10.9] (2026-07-28) — enforced guild announcements & filters shown read-only in the recipient's tabs; Classic/TBC live-status fix; TOC bumps
Enforced guild announcements & filters are now visible (read-only) in the destination
Reported (@Vishiswaz): guild-tagged, enforced announcements auto-post on a recipient's client but don't appear in her Announce tab, so she can't confirm the content matches the author's. Same gap for enforced guild filters — they drive her scans but never show in her Filters tab. Members now SEE the enforced guild items, read-only, with live (guild-synced) timers.
Modules/Announce.lua— newAnnounce:EnforcedGuildProfiles()exposes the synced policy announcement snapshots while enforced.GUI/Tabs/Announce.lua—buildRowsnow folds in enforced guild announcements. A profile the member owns AND that's enforced shows the guild status (active, shared countdown) instead of the member's personal On — fixing the confusing "(paused)" on a tagged-but-personally-off announcement. Guild announcements NOT owned locally (recipient view) appear as read-only rows (teal name +(guild)tag, visible even to non-editors who don't get the Guild column), with the shared countdown; clicking one loads its content into the form to verify, but it can't be edited or deleted (a Save would create the member's own copy, never the guild one).GUI/Tabs/Filters.lua— enforced guild filters (gmPolicy.filters.list) now show as read-only rows (marked(guild)) with their class/race/level criteria, so members can verify what they're scanning with. Deduped by name against the member's own filters.GUI/RowList.lua— reusable per-row read-only support: a row flagged_readonlygreys and disables its checkboxes; an action carrying ahidden(entry)predicate is hidden on rows it doesn't apply to (e.g. the delete X on a guild row).- The "only changeable by authorized folks" framework is the existing
canEditPolicy+ Push: recipients get a read-only view; policy editors change the items through their own tagged profiles/filters and re-push.
Live announcement status wasn't updating on Classic/TBC
GUI/Tabs/Announce.lua— the v2.10.8 live-countdown ticker started fromparent:OnShow, which didn't fire reliably on the Classic/TBC tab path, so the status column froze ("works in retail, not TBC"). It now starts at Render, gated onparent:IsShown(), independent ofOnShow. Toggling the On or Guild checkbox also refreshes the row immediately instead of waiting up to a second for the ticker.
Announce (horn) button is a plain button again
GUI/MainWindow.lua+Modules/compactFrame.lua+Modules/Announce.lua— the horn button on the main window and compact tray no longer shows a seconds-countdown overlay (which hid the icon while on cooldown). The announce countdown now lives ONLY in the per-row timers on the Announce tab. Removed the overlay font-strings and the OnClick cooldown guards;MainWindow:SetAnnounceCooldown,cf.setAnnounceCooldown, andAnnounce:StartCooldownTickerare now no-op stubs so their callers (Send's round-finish, FGI_AnnounceSync, the module-load kick) stay safe without edits. The button always shows the horn and always firesSend(which self-gates on each profile's cooldown), so no 1 Hz UI ticker runs for it anymore.
Wingman: only lock what it's driving
Reported (@Vishiswaz): with Wingman on for just one step (say announce), the manual scan/invite buttons were still greyed, so you couldn't recruit by hand alongside it. Now Wingman only locks the manual triggers for the steps it's actually driving.
Modules/Wingman.lua— newWingman:DrivesScan()/DrivesInvite()/DrivesAnnounce();LockControlslocks the compact-tray scan/invite buttons only for the driven step.GUI/Tabs/Scan.lua—ApplyWingmanLockcategorised: scan button locks only if Wingman drives scan; invite buttons only if it drives invite; queue-management buttons only if it drives either.GUI/Tabs/Filters.lua+GUI/Tabs/CustomScan.lua— the targeting freeze now applies only while Wingman drives scan (an announce/invite-only session leaves filters + custom scans editable).functions.lua—fn:nextSearchblocks manual callers only while Wingman drives scan;fn:invitePlayeronly while it drives invite (Wingman's own drain still bypasses viaIsDraining).FGI_Core.lua— the/fgi inviteand/fgi nextSearchslash/keybind dispatch match: allowed unless Wingman is driving that step.
Pass-the-baton: temporarily yield designate duty
Requested (@Vishiswaz): let a currently-online designate hand off to the next member in line without logging off.
Modules/FGI_Recruiter.lua— a synced, expiring "stepped aside" state (name → expiry epoch); the election (electFrom) skips stepped-aside members, so every client elects the next-in-line. New API:StepAside(minutes)/StepBack/AmISteppedAside/AmIAnyDesignate/SteppedAsideMap/ApplyRemoteStepAside/ApplyRemoteStepBack. A change re-runs Wingman's designate evaluation (the yielder stands down; the next-in-line is offered it) and refreshes the Guild Policy readout.Modules/FGI_Baton.lua(new) — isolated guild sync for the stepped-aside state (own prefixFGIBaton; broadcast on step-aside/back + login catch-up with a whispered reply). Kept separate from the DeltaSync engine (transient, auto-expiring state). Added to all 5 TOCs.FGI_Core.lua—/fgi baton(toggle),/fgi stepaside [minutes],/fgi stepback.GUI/SettingsPanel.lua— a Step aside / Step back button on the Guild Policy tab (enabled when you're a designate, or to resume). Applies to all your designate roles at once.
TOC
- Classic Era interface version bumped to 11509; TBC Classic to 20506.
[v2.10.8] (2026-07-27) — guild announcement cooldowns sync across announcers; 1D2H30M0S cooldown entry + live per-row countdowns
Guild announcement cooldowns now shared across announcers
Reported (@Vishiswaz): the per-announcement cooldown state was per-character, so switching announcer — logging off one character and onto another, or a different member taking over — re-posted a guild announcement that had just gone out (the new character started with a clean cooldown). Separately, the cooldown input on the Announce tab was capped at 4 characters, so a 24-hour (86400) value couldn't be typed.
GUI/Tabs/Announce.lua— cooldown entered as1D2H30M0S, not raw seconds. Entering long cadences in seconds ("math is hard") is replaced by a days/hours/minutes/seconds field:parseDurationaccepts1D2H30M0S, any subset (2H30M,90M), case-insensitive, and a bare number still means seconds (back-compat);formatDurationshows stored seconds back in that form. The profile still stores seconds, so the eligibility engine is unchanged. Clamped to[60 s, 99D23H59M59S](2-digit days). The field widened (CD_INPUT_W) and droppedSetNumeric(it now carries D/H/M/S letters). Supersedes the interim raw-seconds bumps (4 → 5 → 7 digits) from earlier in this cycle.GUI/Tabs/Announce.lua+Modules/Announce.lua— live per-announcement countdowns. The Announce list's Status column now shows a live countdown (in 5M30S) of when each announcement can next fire, ticking down once a second while the tab is open (apreserveScrollrefresh that stops on hide, so it costs nothing elsewhere and never yanks the list while you scroll). New publicAnnounce:ProfileReadyIn(profile, now)supplies the per-profile remaining time (reads the shared store, so guild-announcement countdowns reflect the guild-synced cooldown). Clearer than the single "next ready" overlay on the horn button when several announcements have different cooldowns.Modules/Announce.lua— GUILD (policy) announcement cooldown state now lives in a shared, faction-realm-scoped store (DB.factionrealm.announceState) instead of per-characterDB.char, so it's consistent across all of a member's characters. Personal announcements keep their per-character state;getProfileChannelStateroutes byisPolicyAnnounceId. The round-finish stamp broadcasts each posted guild announcement's cooldown to the guild.Modules/FGI_AnnounceSync.lua(new) — an isolated guild-wide sync for guild-announcement cooldowns, kept deliberately SEPARATE from the DeltaSync engine (it's a soft, best-effort anti-spam nudge, not reconciled persistent data, so it must not be able to destabilise the blacklist / anti-spam / policy sync). Own addon-message prefix (FGIAnnCD) and a tiny latest-wins protocol: broadcastS <key> <epoch>when a guild announcement posts; a loginRcatch-up request; anAreply whispered back with the responder's full cooldown map. Every merge isMAX(local, received)onlastPosted, and merging in a peer's post repaints the horn cooldown. So a different-player handover (or an alt logging in later) respects the cooldown. Trust-gated to guildmates (reuses the DeltaSync bridge gate); degrades to local-only if AceComm is unavailable. Added to all 5 TOCs afterAnnounce.lua.
Net: whoever announces next — your own alt or a different designated announcer — sees when the guild announcement last went out and won't immediately re-spam it.
[v2.10.7] (2026-07-25) — guild announcements reworked to the guild-filter model (tag on the Announce tab; no config in Settings)
Forced announcements → tagged guild announcements
Reported (@Vishiswaz): on a smaller Classic/TBC screen the Guild Policy page's scrollbar was pushed off and the settings below it couldn't be reached. The immediate cause was the wide cooldown control in the in-panel forced-announcement editor overflowing the narrow options frame — but the deeper issue was a design mismatch: announcements were authored in Settings, unlike guild filters, which are tagged from the Filters tab. This aligns announcements with the filter model and removes announcement configuration from Settings entirely.
GUI/Tabs/Announce.lua— policy editors get a "Guild" checkbox column on the announcement RowList (mirrors the Filters tab's guild-filter checkbox;canEditPolicy-gated, so non-editors never see it). Ticking snapshots the profile's config (message, channels, cooldown, activity;enabled = true) intogmPolicy.announcements.profileskeyed by the profile id; unticking removes it. Saving an edit to a tagged profile refreshes its snapshot, and deleting a tagged profile clears it — so the policy never holds a stale or orphaned announcement.GUI/SettingsPanel.lua— removed the in-panel forced-announcement editor entirely (policyAnnounceProfiles/policyAnnounceArgs/rebuildPolicyAnnounceArgs/addPolicyAnnounce, the dynamic inline group, the "Add announcement" button, thePLAYER_LOGINpopulate, and thePOLICY_ANNOUNCE_*constants — including the wide cooldown slider that overflowed the frame). Replaced with the filter-style trio: an Enforce guild announcements toggle (gmPolicy.announcements.active), a read-only summary of tagged announcement names, and a Clear guild announcements button.addon.RebuildPolicyAnnounceUI(still called byModules/FGI_DeltaSync.lua) now justNotifyChanges the panel so the summary repaints on sync.Modules/Announce.lua—getPolicyProfilesnow gates ongmPolicy.announcements.activein addition togmPolicy.active(mirrorsguildFiltersEnforced), so a member's own announcements are unaffected until the GM both enables the policy and turns enforcement on. The v2.10.5 override (enforced guild announcements replace the member's personal ones) still applies when enforced.GUI/SettingsPanel.lua— the push guard gained a second block: pushing with Enforce guild announcements on but nothing tagged is refused with aStaticPopup(FGI_POLICY_ENFORCE_NO_ANNOUNCE), same pattern as the empty-guild-filter block.
Net: the wide slider (and all announcement configuration) is gone from Settings, so the Guild Policy page fits the narrow Classic/TBC options frame again.
Migration: guilds that authored forced announcements the old way will have leftover entries that are no longer editable in Settings and won't fire until enforcement is turned on. Hit Clear guild announcements once, then re-tag the desired announcements from the Announce tab.
[v2.10.6] (2026-07-25) — announcement cooldown caps raised to 24 hours (personal + guild policy)
Longer announcement cooldowns
Requested (@Vishiswaz): post a recruitment announcement once every few hours. The maximum cooldown
was capped low — 1 hour for personal profiles, 30 minutes for guild-policy forced
announcements — arbitrary input/slider limits, not anything the engine needs: the eligibility gate
reads math.max(cooldown, floor) with no upper bound. Both raised to 24 hours (86400 s); the
60 s floor is unchanged.
GUI/Tabs/Announce.lua— the personal cooldown input clamp raised from3600to86400. Type any exact value up to 24h in the cooldown field.GUI/SettingsPanel.lua— the guild-policy forced-announcement cooldown slidermaxraised from1800to86400, withbigStep = 300so dragging the now-wide range is manageable andstep = 15preserving near-floor precision when typing an exact value in the slider's editbox.
[v2.10.5] (2026-07-24) — forced guild announcements override the member's personal announcements
Forced announcements now replace personal ones instead of running alongside them
Reported (@Vishiswaz): with a forced announcement pushed and enforced, the designated announcer's Wingman fired her own old personal Announce-tab profile instead of the guild's forced announcement. (She was the active designate; the pusher was logged off.)
Root cause: effectiveProfiles() in Modules/Announce.lua concatenated the member's personal
profiles first and then appended the policy ones — forced announcements were additive, not an
override. Because Send() posts one profile per click walking that combined queue, the personal
profile (first in line) is what went out. This is the same oversight the v2.10.4 level-range fix
addressed: the guild-policy version didn't replace the member's local version.
Modules/Announce.lua— newhasEnabledPolicyAnnouncement()helper, andeffectiveProfiles()now overrides rather than concatenates: when the guild has at least one enabled forced announcement, ONLY the policy profiles fire (the member's personal ones are suppressed) — for both Wingman and the manual horn, for every member, sinceeffectiveProfiles()feeds Send / GetStatus / HasActiveProfiles / bumpActivity alike. When the pushed policy has no enabled forced announcement, it falls back to the member's personal profiles, so a half-configured policy never leaves anyone unable to announce (same guard principle as the empty-guild-filter push block). MirrorseffectiveFilters(). Editor CRUD (Create/Delete/Duplicate/GetProfile) still targets the member's owngetProfiles(), so personal profiles stay visible and editable in the Announce tab — they're just out of the fire queue while a forced announcement is active.
[v2.10.4] (2026-07-24) — Wingman no longer starves scan/invite on an unpostable announce channel (Trade outside a city); enforced guild filters override the member's level range
Wingman only announced, never scanned or invited, when out of a city
Reported (@Vishiswaz, TBC): with the Wingman priority order announce → invite → scan and a forced announcement targeting Trade + LookingForGroup, Wingman would only announce while the player was out in the world — no scans, no invites — until they walked into a city, at which point it "spammed Trade and started doing scans and invites."
Root cause traced end-to-end:
Announce:IsEligiblechecked the profile's cooldown/activity but never whether the target channel is currently joined. Outside a city the Trade channel isn't joined, soresolveChannelIndex("trade")returns nil andsendToChannelsilently fails — but the per-(profile, channel) cooldown is only stamped on a successful send, so Trade stayed "eligible" forever.Wingman.tryAnnouncesawAnnounce:GetNextReadyIn() == 0(Trade "ready"), calledSend(), and returnedtrueunconditionally — consuming the click regardless of whether anything actually posted. Announce is first in the priority order, so it ate every click and scan/invite (lower priority) never ran. LookingForGroup is global, so it did post — which is why the user saw announcements but nothing else. Entering a city let Trade resolve, unblocking the whole chain.
Fixed in two places (both needed):
Modules/Announce.lua—IsEligiblenow treats a public channel the player isn't currently joined to as ineligible (resolveChannelIndex(channelKey) == nil→false, "not-joined"), so the announce engine and Wingman simply skip attempting Trade when you're not in a city instead of retrying a send that can't go out. Scoped to public channels — Guild/Officer are not channel-indexed (resolveChannelIndexreturns nil for them by design) and are unaffected. This also flows throughgatherEligible(queue),GetStatus, andGetNextReadyInsince they all funnel throughIsEligible.Modules/Wingman.lua—tryAnnouncenow consumes the click only if a post actually went out (Announce:Send()returned> 0).GetNextReadyIncan still read 0 while nothing is truly postable (an unjoined channel withlastPosted == 0reports remaining 0), so gating on Send's return keeps a no-op announce from eating the click — it falls through to scan/invite.
Cross-version: Classic/TBC and Retail all reach this code; inChatLockdown was already
feature-detected (false on Classic), so the change is correct on every target.
Enforced guild filters now override the member's level range entirely
Reported (@Vishiswaz): a GM should be able to force a recruiting level band on the whole guild without members' own level settings fighting it. The intended model — "when the guild enforces a filter, the member's Scan-tab level range goes non-functional" — was only partly true: the member's own level range still governed what got queried.
functions.lua— newfn.guildFiltersEnforced()(policyactive,filters.active, and a non-emptyfilters.list) factored out ofeffectiveFilters().getEffectiveLevelRange()now prefers the enforced filters' level range over everything while a guild filter set is enforced — over the member's strip (DB.global.lowLimit/highLimit) and over their ownlevelRangePriority("strip"/"filters") setting. The union is already computed fromeffectiveFilters()(which returns the guild list while enforced), so the override just prefers it unconditionally, tagged"guild". Only fires when the enforced filters actually define a level range; a guild filter with no level band falls through to the member's normal strip/priority resolution, so nothing that isn't being forced is touched.GUI/Tabs/Scan.lua— the wheel-scrollable Lvl readout locks in the new"guild"source mode (bothadjustMin/adjustMaxbail), and the hover tooltip gains a"guild"branch that says the range is set by the guild and can't be changed here while Enforce guild filters is on — distinct from the"filters"tooltip, which tells the member to flip a setting they have no power over under enforcement.GUI/SettingsPanel.lua— hard guard against pushing an empty enforcement. "Push policy to guild" now refuses to push whenfilters.activeis on but no guild filter is marked (filters.listempty). BecauseguildFiltersEnforced()needs a non-empty list, that combination enforces nothing — every member silently falls back to their own filters — so pushing it would mislead the GM into thinking a guild standard is live and could break the intended recruitment. The block surfaces aStaticPopup(FGI_POLICY_ENFORCE_NO_FILTER: "mark a Guild filter, or turn off Enforce guild filters, then push again") rather than a chat print that would scroll away in a busy guild. Popup registered by index-assignment (taint-safe);OKAYadded to.luarc.jsonglobals.
Net: the GM enforces one guild filter with min 58 / max 70 and nothing else, and every member
scans 58–70 — their local level range is genuinely non-functional, whatever they've set it to.
Only the enforced-policy path is affected; a member's own filters keep honouring the
levelRangePriority setting exactly as before.
[v2.10.3] (2026-07-17) — retail announce fixes (one-per-click, custom channels, confirmed delivery), forced-welcome blank fix, GM guild filters
Announce: one post per click, no timer — the actual retail fix
v2.10.2 tried to make the v2.10.0 staggered drip retail-safe by routing its C_Timer sends
through C_ChatInfo.SendChatMessage. That was wrong: the Blizzard docs
(Deprecated_ChatInfo.lua) show the global SendChatMessage is just a fallback that calls
C_ChatInfo.SendChatMessage — the same function, declared HasRestrictions = true /
RestrictedForMacroChatMessages = true. Both raise ADDON_ACTION_BLOCKED when a post to a
public channel fires outside a hardware event. So the drip's timer posts (2..N) were still
blocked on retail (@Vishiswaz's report, Announce.lua:632 drip ticker). There is no
timer-safe chat send on retail.
Rebuilt the announce send to match the invite queue — one announcement per click, no timer, no staggering:
Modules/Announce.lua—Send()now posts exactly one eligible (profile, channel) post per call, walking the enabled set across successive clicks (activeSetholds the round in progress). Every send therefore runs inside the click's hardware event, so it's never blocked — on retail or Classic. Removed the whole drip (sendTicker/drainOne/draining,buildQueue's reservation →gatherEligiblewith no stamping, the failed-channelretryAfterbackoff).- Cooldown starts after the LAST post — the per-(profile, channel) cooldown is stamped only when the round's final post fires, so the whole enabled set becomes eligible again together ("the refresh timer starts after you send the last one"), never mid-round.
- Removed the "Announcement spacing" slider (
DB.global.wingman.announceSpacing) — it only controlled the now-deleted timer staggering. FGI_APICompat.lua— corrected theAPI.SendChatMessagedoc comment: it is NOT timer-safe and does not make sends timer-safe; the global andC_ChatInfoversions are the same restricted function. It only picks the modern API; the caller must already be in a hardware event. (Kept for the guild welcome / officer notices / whispers, which use chat types — GUILD/OFFICER/WHISPER — that aren't the macro-restricted public-channel kind.)
Announce: custom / community channels now actually receive the post
Reported: an announcement to a custom LFG channel (slot 4) said it posted but nothing appeared
in the channel. Root cause validated against Blizzard's own source: resolveChannelIndex
returned GetChannelName(i)'s first value (id) as SendChatMessage's "CHANNEL" target, but
Blizzard's ChatFrameUtil.GetCommunitiesChannel uses the loop index i as the channel
number and discards that id. For a standard channel id == slot, so it worked; for a
custom / community channel the returned id differs from the slot, so FGI targeted the wrong
channel number and the post went nowhere.
Modules/Announce.lua—resolveChannelIndexnow returns the sloti(the channel number, per Blizzard's semantics) and keys presence off the channel name, notid > 0(a community channel can have id 0 while joined). Standard channels are unchanged (id == slot), so Classic is unaffected.
Announce: "posted" is now confirmed by the chat echo, never assumed
SendChatMessage returns nothing, so FGI's old "Announce posted to N channel(s)" was printed the
moment a channel resolved and the send was called — with no proof it went out. A send that was
blocked or aimed at the wrong channel still claimed success. Now FGI reports only what it can
verify:
Modules/Announce.lua— each attempted post is recorded pending; a successful post echoes back to us as aCHAT_MSG_GUILD/OFFICER/CHANNELevent from ourselves (the same events the activity tracker already receives). On that echo → "Announce posted to<channel>." If no echo arrives within 5 s → "Announce to<channel>did NOT go out …" instead of a false success. Echo matching is guarded against retail secret strings.- The per-channel success/failure line replaces the old aggregate count; known pre-send failures (not in the channel / not in a guild / no officer perms) still report immediately as before.
Guild Policy: a blank forced-welcome field no longer resurrects the member's own welcome
Reported (@Vishiswaz): welcome whispers were being forced even when left blank. Root cause:
fn.getWelcomeWhisper / getWelcomeMessage (FR-009 Phase 5) treated a blank forced field as
"fall back to the member's own DB.global text." Combined with FGI_Core's welcome gate forcing
the whisper path on whenever gmPolicy.welcome.active, a GM who enabled the standard welcome but
left the whisper blank made every member re-send their own personal whisper (even with their
own whisper toggle off).
functions.lua— when the forced welcome is active, each field is now a standalone override: a set field standardizes it for everyone, a blank field sends nothing for that part — no fall-back to the member's own text. The member's own welcome is used only when the forced welcome is inactive. So "Enforce a standard welcome" with a blank whisper means no whisper for anyone, as intended (FGI_Core'swhisperText ~= ""gate then closes on its own).
Guild Policy: the GM can push standard scan filters to the whole guild (FR-009)
New forced mechanism, alongside forced announcements / welcome. A policy editor marks existing filters as "guild filters"; while enforced, every member scans with that set instead of their own.
- Authoring (
GUI/Tabs/Filters.lua) — reuses the existing filter storage/UI: a GM-only Guild checkbox column (shown only to policy editors) marks a v2 filter, snapshotting its criteria intogmPolicy.filters.list(keyed by name — the policy carries the values because peers don't have your filter list;boundMessageis dropped since it references local templates). v1 (legacy) filters can't be marked. - Enforcement (
gmPolicy.filters = { active, list }) — mirrors the forced-welcome shape with its own Enforce guild filters toggle + summary on the Guild Policy tab (SettingsPanel.lua), riding the existing policy push (LWW bysetAt; no DeltaSync changes). A newfn.effectiveFilters()returns the forcedlistwhengmPolicy.active+filters.active+ non-empty, else the member's ownDB.realm.filtersList. All six scan-engine reads route through it — the result-filter loop,isQueryFiltered,soleAllowedClass, the scan-estimate gate,getEffectiveLevelRange, and the bound-message resolver — plus the Wingman scope readout. Guild filters combine with the engine's existing OR semantics, exactly like personal filters (kept if any one matches). - No lock — a member's own filters stay fully editable; they're just not consulted while the
policy is in force (the member's list is never modified).
canEditGuildPolicyis exposed fromSettingsPanel.luaso the Filters tab gates the Guild column on the same permission.
[v2.10.2] (2026-07-16) — retail chat-block fix, roster-based designate election, Wingman duty follow
Retail: chat sends from timers/events no longer blocked (ADDON_ACTION_BLOCKED)
Retail 11.x deprecated the global SendChatMessage into a protected shim
(Blizzard_DeprecatedChatInfo) that raises ADDON_ACTION_BLOCKED when called outside a
hardware event. Every FGI chat send that fires from a C_Timer or an event handler (rather
than a click) was therefore at risk on retail. A new shared wrapper
addon.API.SendChatMessage (FGI_APICompat.lua) prefers C_ChatInfo.SendChatMessage
(the supported, timer-safe API) and falls back to the global only on Classic-family clients
where it isn't deprecated. Every raw send was routed through it (or the equivalent
feature-detect):
- Announce staggered drip (v2.10.0 regression) — the reported error. The first post, still
inside the click, went out; posts 2..N from the drip ticker were blocked.
Modules/Announce.lua'ssendToChannelnow uses the timer-safe path. - Guild welcome (guild-chat line). Fired from a 2.5 s timer via the global (the welcome
whisper already used
C_ChatInfo); the guild-chat line now does too. - Auto-blacklist officer-chat notices. The "Player X has been blacklisted" officer post
fires from the guild-leave roster event (non-hardware) when auto-blacklist-on-leave is on,
so it was blocked; also the manual blacklist/unblacklist officer notices and the
!fgichat-command officer responses now route through the wrapper. - Conditional invite whisper (latent bug). The recruit whisper had an
isTBCspecial-case that fell through to the global on retail; the Conditional (whisper-on-decline) mode fires that whisper from the decline EVENT (non-hardware), so it was blocked on retail. Unified through the wrapper — TBC keeps usingC_ChatInfoexactly as before.
Classic Era / TBC / Wrath / Cata are unaffected — the global isn't deprecated there and the wrapper is feature-detected.
Wingman: mute announce "skip" notices (on by default)
When a member clicks with Wingman on but their announcement can't fire — they're not the
designated announcer, or they've left a targeted channel — the "why it didn't post" line
could repeat on many clicks (reported by @Adestar). A new Mute announce skip notices
toggle on the Wingman settings page (DB.global.wingman.muteAnnounceSkips, on by
default) suppresses all five of those status lines at once — held/not-designate, "couldn't
post to X (not in that channel)", all-on-cooldown, starting-up, and chat-queue-busy — via a
shared announceSkip gate in Modules/Announce.lua. The "posted to N channels" success line
is never affected, and it applies to the manual horn too. No change to Wingman's action
logic — it still picks the highest-priority actionable step per click as before; this only
gates the chat notices.
Wingman follows your designate duty — offered when it's yours, stopped when it isn't (FR-009)
Requested on Discord: a prompt so the designated recruiter can turn Wingman on in one click instead of hunting for the button, and — the follow-up — Wingman should only run while you're the designate. Wingman still starts off every session (unchanged); this ties its on/off to whether you're currently the guild's on-duty announcer/inviter.
- Offer on the rising edge of "I am the active designate" (
amActiveDesignate=AmIDesignatedannouncer orAmIDesignatedInviter, false during warmup):- Login — a post-warmup kick (
firstEvaluation, reschedules untilWarmupRemaining == 0) so the election has settled before we judge it. - Handover — registered
LibGuildRosterOnMemberOffline/OnMemberOnlinecallbacks re-evaluate on every roster change; when the member above you logs off and duty falls to you, the edge fires the prompt. - Popup (
FGI_WINGMAN_LOGIN_PROMPT) offers Turn on Wingman / Not now / Don't ask again, names which role(s) you hold, and is never shown while Wingman runs.
- Login — a post-warmup kick (
- Auto-stop when held out of BOTH actions —
Wingman:OffDutyUnderPolicy()isnot CanPostPublic() and not CanInvite(): true only when a recruiter policy names someone else AND an inviter policy names someone else, so both of Wingman's recruiting steps are gated. Then, while Wingman is running, it turns itself off ("no longer the designated announcer/inviter"). Driven off theLibGuildRosterOnMemberOffline/OnMemberOnlinecallbacks (which run outside any click — deliberately NOT fromDrain, to keep the propagated hardware-event click path free of extra addon work). Never fires with no policy or during warmup. If either action is still open to you (you hold that role, or there's no policy for it), Wingman keeps running. - The
wasDesignateedge guard collapses repeat calls so a flapping roster can't re-nag or re-stop while your duty state is unchanged. Both behaviours shareWingman:EvaluateDesignateState(). - Welcomer intentionally excluded — Wingman drives announce/scan/invite, never the guild-join welcome, so being only the welcomer neither offers nor keeps Wingman on.
- The offer is opt-out via Ask at login when I'm the designated recruiter
(
DB.global.wingman.loginPrompt, on by default) / the popup's Don't ask again; the auto-stop always applies (it enforces the guild's single-recruiter policy).
Designated election is now roster-only — the leak is gone, and the presence machinery with it
Reported on Discord (@Vishiswaz): with a designated-announcer policy set for LFG, a member who
was not the designate still had their own public announcement go through. Root cause was the
election's second requirement: it elected the highest-priority member who was both online
and confirmed running FGI. The "confirmed running FGI" half was tracked by a comm-stamped
presence set + a version-check heartbeat, and it only converges eventually — two already-in-sync
members who log in at different times never exchange a message, so until the slow (10 min) heartbeat
landed a client could fail to see the real designate and fall open, letting everyone post. Worse
on retail, where the login version-check / DeltaSync catch-up round-trips slowly.
The fix removes the unreliable half entirely: the election now gates on GR:IsOnline alone —
LibGuildRoster's live, in-memory roster, which the game keeps current in real time and every client
reads identically. No round-trip, no convergence window: the designate is simply the highest-priority
member the roster shows online, decided the same on every client the instant the roster changes.
Modules/FGI_Recruiter.lua—electFromdropped therunning[norm](presence) condition; it now returns the first list entry withGR:IsOnline(raw) == true.amDesignateForreverted to the simple form (warmup → identity → elect →desig == me, fall open only when nobody listed is online).- Removed the whole presence subsystem that only fed that condition: the
presencetable + TTL,NotePresence,runningSet,refreshRunningSet/RefreshRunning, theOnMemberOnlineversion-poll, and the 10-minute + 20-second version-check heartbeat timers. This also stops the periodic guild version-check traffic those timers generated. The DeltaSync bridge's fourNotePresencecalls are nil-guarded, so they become harmless no-ops (left in place; not load-bearing). - Warmup kept — the 30 s post-login hold now guards only against acting before LibGuildRoster has
built the roster (when
IsOnlinewould wrongly report everyone above you as offline), not presence convergence. It still drives the Wingman "loading" countdown. - Trade-off (deliberate): a listed member who is online but not running FGI is now treated as the designate, so members below them hold while that member is online. Accepted — the list is the GM's curated recruiters, who run FGI when online — in exchange for eliminating the leak. Members can see who their client considers on duty under Settings → Guild Policy.
Dev: offline unit tests via the WoWAPITesting harness
FGI is now wired into the shared TOG WoWAPITesting harness so pure logic can be unit-tested
offline (busted / the bundled zero-dep runner), no game client. This does not ship — the whole
Tests/ tree and .busted are in the packager ignore: list.
- Submodule at
Tests/wowapi(.gitmodules→github.com/Pimptasty/WoWAPITesting); a one-line.bustedshim defers to the sharedbusted_config;.pkgmetaignore:gainsTests+.busted. Tests/recruiter_spec.lua— 9 specs pinning the reworked roster-only election (Modules/FGI_Recruiter.lua): highest-priority-online wins, a non-designate whose superior is online is held (the @Vishiswaz leak), immediate handover when the superior logs off, the cold-start warmup hold, announcer/inviter independence, and inactive-policy fall-open. The spec seeds the globalFGInamespace, stubsLibGuildRoster-1.0via LibStub, and installs a controllableGetTime(the one harness gap; noted upstream). 9 passed, 0 failed.- The election has no version branches (pure roster logic, identical on Classic/TBC/Wrath/Cata/
Retail), so a single harness run covers every target; the harness can simulate any build via
GetBuildInfofor future version-branching specs. - Run from the FGI root:
lua Tests/wowapi/run.lua(orbusted). Fresh clones:git submodule update --init.
[v2.10.1] (2026-07-09) — Designated welcomer + forced guild welcome (FR-009 Phase 5)
@Vishiswaz (Discord): the guild-join welcome should be sent by only one FGI user at a
time, plus a GM force option to standardize it. Both map straight onto the FR-009
designate election + gmPolicy machinery.
Single-sender welcome — designated welcomer
When several members run FGI with auto-welcome on, a new joiner gets N identical welcomes.
A new dedicated gmPolicy.welcomerPriority list (separate from the announcer/inviter)
elects one designated welcomer; only they send the guild-join welcome.
Modules/FGI_Recruiter.lua—welcomerList()+HasWelcomerPolicy/GetDesignatedWelcomer/AmIDesignatedWelcomer/CanWelcome, reusing the sharedelectFromelection, the presence heartbeat, and the cold-start warmup (folded intopolicyActiveandonMemberOnline).FGI_Core.luawelcome path — gated onRecruiter:CanWelcome(). When a welcomer policy names someone, only the designate welcomes, and they welcome all joiners (the per-member "only my invitees" filter is bypassed for the guild greeter). Fails open (each member's own welcome) when no list is set or nobody eligible is online.
Forced / standardized welcome text
New gmPolicy.welcome = { active, message, whisperMessage } leaf. When active, the policy
text (guild-chat line + private whisper, NAME placeholder) replaces every member's own
welcome wording and is enforced-on regardless of their auto-welcome toggle — exactly like
the forced recruitment message. fn.getWelcomeMessage() / fn.getWelcomeWhisper() do the
override (mirroring getMsgForName). Pair with a designated welcomer for one identical
welcome per join.
Guild Policy UI
A Guild welcome section on the Guild Policy tab: an Enforce a standard welcome toggle +
guild-chat and whisper text inputs, plus a Designated welcomer priority list + Clear
button + live "current welcomer" readout, with the same Affects / Does-NOT-affect header
tooltips as the announcer/inviter. Both new leaves ride the existing gmPolicy LWW sync (no
DeltaSync change). New enUS strings added; other locales fall back to enUS until translated.
Caveat (same soft edge as the announcer): for ~30s after the sole designate's own login, the cold-start warmup holds them — so a join in that window could go un-welcomed (a welcome is one-shot). Bounded, rare, and covered by any other online designate.
[v2.10.0] (2026-07-09) — Announce staggering + retry backoff + designated inviter/announcer (FR-009 Phase 4) + presence heartbeat, warmup & full localization
Three items from @Vishiswaz (Discord): stagger the burst of announcements that fires on a single click; stop the "couldn't post to X" line spamming every click when you're not in a channel; and detail exactly what the designated-recruiter / forced-policy features do and do not do (plus split invite scope from announce scope).
Announce — staggered drip instead of a one-click burst (feature)
Clicking the horn (or Wingman's announce step) used to fire every eligible
(profile, channel) post synchronously in one burst — a wall of identical lines hit
LFG/Trade/General/Guild in the same second. Now the eligible set is built once and
dripped one post per the new Announcement-spacing gap: the first post fires
immediately (staying inside the click's hardware-event context that the Wingman announce
step relies on), the rest are scheduled on a C_Timer ticker. The burst walks the set
exactly once and never loops back.
- Dedicated setting, NOT the scan interval. A new Announcement spacing (seconds)
slider on the Wingman settings page (
DB.global.wingman.announceSpacing, default 2 s, range 0.5–10) drives the drip. An earlier draft reusedDB.global.scanInterval, which was wrong — that's the/whoserver-rate-limit knob (shared by the manual scan buttons), a different domain from "how far apart FGI drips its own chat posts". Announcement frequency was already controlled per-profile (Cooldown + Activity); the only genuinely new timing is the intra-burst spacing, so it gets its own knob.
Modules/Announce.lua:
- New drip state — module-level
drainQueue/draining/sendTicker/lastUnavailableNotice, and anannounceInterval()helper (readsannounceSpacing, floored at 0.5 s so a blank/zero setting can't spin a zero-delay ticker). buildQueue(now)collects the eligible posts and reserves each immediately (stampslastPosted = now, resetsmsgsSinceLastPost; capturesprevLastPosted/prevMsgsfor rollback). Reservation makes the in-flight channels ineligible so a re-entrantSendduring the drip can't re-queue them, and makesGetNextReadyIn()reflect the whole burst at once (so Wingman'stryAnnouncewon't re-fire mid-drip).Announce:Send(opts)rewritten: bails early whendraining(the "only go through once, don't repeat" guard); builds the queue; on an empty queue prints the same no-active / blocked-public / all-on-cooldown reasons as before; otherwisedrainOne()fires the first post now and a ticker drains the rest one per gap, callingfinish()when the queue empties. Starts the horn cooldown ticker at drip-start (not just at the end). Returns the queued count now (all callers use it for side effects only).announcePrint(opts, key, ...)helper folds the repeatedopts.muted/DB.global.muteAnnounce/fn.chatLoc()gate that every status line shared.
Announce — not-in-channel: short retry backoff (bug fix)
@Vishiswaz: "if not in a channel, FGI will keep trying to spam it … with every single
click … shouldn't there be a timeout?" — correct. When a channel send failed because you
weren't joined, the old loop recorded the failure and printed "couldn't post to X" but
never advanced lastPosted, so the eligibility gate stayed open → every click retried,
failed, and re-printed.
Fix: on a send failure, drainOne rolls the reservation back (lastPosted /
msgsSinceLastPost restored from the captured prev*) and applies a short
retryAfter = now + announceSpacing backoff instead of the full cooldown. IsEligible
gates on retryAfter, and GetStatus folds it into the remaining calc. So the channel
keeps retrying on later clicks (one spacing-gap apart) and recovers the moment you're
back in it — while the "couldn't post" notice is throttled to once per 30 s, so the
retries stay silent between prints (the notice text now says "will keep retrying").
sendToChannel never calls SendChatMessage on a channel we're not in, so the retries
generate no chat traffic.
Guild Policy — designated inviter (FR-009 Phase 4)
The designated-recruiter list (Phase 3) gates public-channel announcements. Phase 4
adds an independent designated-inviter list gating random invites — because the
alt built to eat server-wide /ignore (the one that should send random invites) is usually
not the real main that should announce.
Modules/FGI_Recruiter.lua— newgmPolicy.inviterPriorityleaf (rides the existinggmPolicyLWW DeltaSync record, no new sync item). Election refactored into a sharedelectFrom(list)+amDesignateFor(list)(online ∩ running-FGI ∩ highest-priority, fails open when nobody eligible), reused by both lists. New public API:HasInviterPolicy,GetDesignatedInviter,AmIDesignatedInviter,CanInvite.onMemberOnlinenow refreshes the running set when a member on either list logs on.functions.lua—fn:invitePlayergained theCanInvite()gate right after the Wingman-lock check: when an inviter policy names someone else, the random invite is held (throttled 30 sfn.inviterHeldNotice()names the current designate).noInv(decline) andtestingModeare exempt; fails open when nobody eligible is online.Modules/Wingman.lua—tryInvitechecksCanInvite()in its can-act guard so a non-designate's click falls through to scan/announce instead of being consumed by a held invite (and stays quiet — the notice only comes from the manual path). Testing mode is exempt to matchfn:invitePlayer's gate (a solo tester still sees the invite step run).
Guild Policy — presence heartbeat so the election actually converges
The designated-announcer/inviter election needs a consistent "who is running FGI" set on every client, or two members can each self-elect and both post. The prior code only refreshed VersionCheck when a listed member came online — leaving long divergence windows. This adds a presence heartbeat that piggybacks on comms FGI already sends (no new traffic):
Modules/FGI_Recruiter.lua— a rollingpresencetable (normName → GetTime()), stamped via a newRecruiter:NotePresence(name)from every FGI message received, plus VersionCheck responses.runningSet()returns everyone stamped within a 30-min TTL (long-but-safe: the election also requires liveGR:IsOnline, which filters anyone who logged off regardless of stamp age). A slow VC backstop timer (10 min, gated on an active policy) catches online-but-silent members and corrects drift — deliberately slow because VC responses are whispered to the querier, so a fast per-client poll would be O(members²) guild traffic. A one-shot collect ~25 s post-login covers a peer VC's own login batch missed.Modules/FGI_DeltaSync.lua— all four bridge receive hooks callNotePresence(sender):OnVersionReceived(the login item-hash catch-up broadcast — the big one),OnDataReceived,OnDataRequest, andOnSyncAccepted. So a guildmate logging in or doing any sync exchange with us lands in the running set within the login handshake, for free.
Guild Policy — cold-start warmup + "loading" countdown on the Wingman button
The one window presence is genuinely blind is right after your own login/reload (your
presence table is empty until the catch-up + first version check land). So:
Modules/FGI_Recruiter.lua— while a policy is active, the designate actions are held forWARMUP(30 s) after load (amDesignateForreturns false;WarmupRemaining/IsWarmingUpexpose it). Established members keep covering; only the just-logged-in client waits, so it can't self-elect into an empty set. Trade-off: a sole designate has a ≤30 s coverage gap right after their own login (fine — they just logged in). The post-login VC collect was moved to 20 s so presence is fresh before the warmup ends.Visible "loading" state — the Wingman toggle (main window
_wingmanIcon+ compact traywingmanBtn) shows a seconds countdown during warmup and is held (OnClick no-ops), mirroring the scan>>/ announce-horn cooldown overlays, so it reads as initialising, not broken. Driven by a 1 HzWingmanwarmup ticker offRecruiter:WarmupRemaining(); the hover tooltip and the Guild Policy status readouts show a matching "Starting up — syncing guild presence…" line, and a manual horn/invite click during warmup prints a "starting up" notice instead of "only the designated announcer may post."Honest limitation retained: the warmup assumes presence fills within its window (it does once the catch-up + 20 s VC land). If presence genuinely never fills — VersionCheck and the DeltaSync catch-up both failing on a client version — the warmup only delays a self-elect by ~30 s rather than preventing it. Still a strong-but-soft guarantee. Documented in the module header.
Guild Policy — "Designated recruiter" renamed to "Designated announcer"
The public-channel gate is about announcements, so the user-facing label is now
Designated announcer (header, priority input, status readout, and the two held-post
chat notices). The internal DB leaf stays recruiterPriority and the module stays
Recruiter / GetDesignated / CanPostPublic — no schema change, so v2.9.0 policy
records keep syncing unchanged. A naming note was added atop FGI_Recruiter.lua.
Guild Policy — detailed tooltips ON THE HEADERS, Clear buttons, multi-realm names
Per @Vishiswaz's third ask ("the addon should detail exactly what the feature does and
does not do") and follow-up feedback, GUI/SettingsPanel.lua:
- Tooltips moved to the section headers. The full Affects / Does NOT affect
explanation for both lists now lives on the
FGI_TooltipHeaderwidget's hover-tooltip (hover the header title), and the name-entry guidance (one per line, Name-Realm) was folded in there too. Thedescwas removed from the priority input boxes so there's no box-mouseover tooltip — matching the panel's "explanation on the header" convention. - Clear buttons. Each list gets a full-width Clear announcer list / Clear inviter
list execute (with a confirm prompt), disabled when the caller can't edit policy or the
list is already empty (
policyListHasNames). Clearing sets the leaf to nil so the list drops out of the synced record (opens the gate back to everyone). - Multi-realm name handling. Both lists now parse through a shared
parsePolicyNameshelper that normalizes each entered name viaLibGuildRoster:NormalizeNameto the canonical "Name-Realm" form (falling back to first-letter capitalization if the lib isn't loaded) and de-dupes case-insensitively. So a connected-realm entry matches the roster + the designated-* election regardless of typed casing / whether the realm was included — same realm can be entered bare, connected realm asName-Realm. This fixes a latent connected-realm bug:LibGuildRoster:NormalizeNameappends the local realm to a bare name, so a bare list entry resolved to a differentName-Realmon each realm's clients and the election disagreed. Storing the canonical form at save time makes every client agree. Note: existing v2.9.0 lists keep their old bare names until re-saved — connected-realm guilds should re-open Guild Policy and re-save (or Clear + re-enter) their announcer/inviter lists once after updating. Single-realm guilds are unaffected. - New Designated inviter group (header + priority input + Clear + live status readout), mirroring the announcer block.
- Forced announcements desc de-staled — it claimed the designated recruiter was "a later update"; it shipped in v2.9.0, so it now points at the (renamed) setting below.
- Wingman → Post announcements desc points at the new Announcement-spacing slider.
- Interval tooltips disambiguated. The Scanning header and Scan interval slider
now spell out that
scanIntervalis the/whorate-limit pacing shared by manual scanning and Wingman's scan step — not a per-click or announcement knob — and Minimum seconds between actions (Wingman) now names itself the per-click throttle, distinct from Scan interval. This heads off the exact conflation that produced the wrong scanInterval-reuse draft.
Localization — all 30 languages, not just enUS fallback
All 32 new/changed strings this release — the announcer rename, the inviter feature,
the Clear buttons, the Announcement spacing slider, the sharpened Scan-interval /
Minimum-seconds tooltips, and the chat notices (inviteMsgNotInviter /
inviteMsgNotInviterNone / announceMsgWarmup / inviteMsgWarmup) — were translated
and appended to every one of the 30 non-English locale files, following each file's
established style (real translation for the CJK / RTL / European locales, transliteration
where the file already transliterates). Appended as override blocks (Lua last-assignment-
wins) so any older copy of a key is superseded, with all WoW markup (|cff..|r, %s/%d,
\n, em-dashes) preserved and keys kept byte-identical to enUS. Anything still missing in a
locale continues to fall back to enUS via the key-fallback metatable.
[v2.9.1] (2026-07-05) — post-release fixes
Fixed
- Guild Policy → "Add announcement" threw a Lua error. The
Announce:GenerateProfileIdwrapper added in v2.9.0 called the file-localgenerateProfileId, but the wrapper is declared above that local inModules/Announce.lua, so the reference compiled to a nil global — clicking Add announcement raisedattempt to call global 'generateProfileId' (a nil value)and no announcement was added. Made the wrapper self-contained (same"%d_%d"time()/math.randomid format), so it no longer depends on declaration order.
[v2.9.0] (2026-07-05) — Wingman action priority (FR-008) + Guild Policy expansion: own tab, delegated editors, forced announcements & designated recruiter (FR-009 Phases 0-3)
Guild Policy — dedicated tab + delegated edit permission (FR-009 Phase 0)
Guild Policy moves out of the Guild settings tab into its own top-level "Guild Policy" tab, and the Guild Master can now delegate policy-edit rights to a guild rank and/or to specific named members — instead of the previous hard-coded "GM or rank 0/1 only" gate. This is Phase 0 of the larger Guild Policy expansion (FR-009); it lays the tab + permission foundation the later phases (forced announcements, designated-recruiter election) build on.
GUI/SettingsPanel.lua:
- New top-level tab. The
gmPolicy*option group was lifted out of theguildgroup into a newguildPolicytop-level group (order 3; Advanced/Wingman/Messages/ Credits shift to 4-7). The Guild tab keeps its member-facing integration settings (welcome, notes, tooltips, auto-blacklist). - Delegation record. New
DB.factionrealm.gmPolicy.editors = { minRank = <0-based rank index>|nil, members = { [name]=true } }, authored only byIsGuildLeader()(an officer who can edit policy still can't change who edits). canEditGuildPolicy()rewrite. GM always; else, when aneditorsrecord exists, allow a named member or anyone at/above the granted rank (rankIndex <= minRank); with no record, the historical default (GM or rank 0/1) stands, so existing guilds are unchanged until a GM opts in. Emptying both rank and members drops theeditorsrecord so the default transparently returns.- "Who can edit" subsection (GM-only): a rank select built from the guild roster
(
guildRankChoices()→ 0-based rankIndex→name, correct on every client version), an "add member by name" input (Title-cases to matchUnitName), and a multiselect of granted members (untick to revoke). The enforced-settings section (forced message + anti-spam floor + Push) is unchanged below it. - Delegation rides the existing
guild:gmPolicyDeltaSync item —editorsis part of the whole-record LWW payload and the content hash, so it propagates on Push policy to guild exactly like the message/anti-spam settings.
Guild Policy — forced announcements (FR-009 Phase 1)
The Guild Policy tab can now carry forced announcements — full Announce configs (message, channels, cooldown, activity threshold) the whole guild broadcasts through FGI's existing Announce feature. This is the second piece of FR-009 (the policy now does more than the single forced recruit whisper).
Modules/Announce.lua:
- Effective-profile merge. New
getPolicyProfiles()(returnsgmPolicy.announcements.profiles, gated ongmPolicy.active) andeffectiveProfiles()(personal + policy, as one array). The four fire/status/ activity paths —Send,GetStatus,HasActiveProfiles,bumpActivity— now iterateeffectiveProfiles(), so policy announcements behave exactly like personal ones. The member's own profile CRUD (Create/Delete/Duplicate/GetProfile) still operates only on personal profiles, so the personal Announce tab is unchanged. - Policy profile ids are guild-stable (authored once, synced), so each member's
per-character cooldown state keys off them consistently. New public
Announce:GenerateProfileId()so the authoring UI mints ids in the engine's shape (kept distinct with agp_prefix).
GUI/SettingsPanel.lua:
- Dynamic editor. A "Forced announcements" section on the Guild Policy tab: an
"Add announcement" button plus a rebuildable inline group (
policyAnnounceArgs+rebuildPolicyAnnounceArgs→AceConfigRegistry:NotifyChange) with per-profile Label / Active / Message / Channels / Cooldown / Activity threshold / Delete. Channels are a fixed recruitment set (General, Trade, LookingForGroup, Guild, Officer) so a GM can force Trade/LFG even when not currently joined to them. Editable by anyone who passescanEditGuildPolicy()(enforced setting). - Populated on
PLAYER_LOGIN;addon.RebuildPolicyAnnounceUI(called from the DeltaSync bridge'sApplyPolicy) refreshes it live when a policy syncs in while the panel is open.
Enforcement / sync: announcements ride the whole-record gmPolicy last-write-wins
sync, so Push policy to guild distributes them. Not yet gated to a single
poster — that's FR-009 Phase 3 (designated-recruiter election); until then each
member fires the guild announcements on their own horn click / Wingman announce step,
so several members announcing means several posts.
Version-keyed policy hash (Modules/FGI_DeltaSync.lua). As gmPolicy grew (editors,
announcements), the old whole-record content hash became a liability: the P2P offer
cycle fires on hash difference (P2PSession dropped the updatedAt gate), so every
edit read as a divergence, and two same-second pushes with equal setAt but different
content produced differing hashes that offered forever while newAt >= curAt kept
re-accepting — a flip-flop. Bridge:ItemHash now keys the gmPolicy hash to the
version (setAt), never the content: an unpushed draft (no setAt) hashes the same
as "no policy" (0) so editing settings never syncs a draft out; a Push stamps a new
setAt → the hash changes → the offer cycle propagates it; two peers on the same
version share a hash → converged, no offers, no flip-flop. updatedAt (= setAt)
still orders which peer to pull from. No DeltaSync library changes. (Residual: two GMs
pushing different content in the exact same second share a setAt, so that one tie
resolves by live-push order and heals on the next push — vanishingly rare, and strictly
better than the old permanent flip-flop.)
Guild Policy — presence groundwork (FR-009 Phase 2)
Groundwork for the designated-recruiter election (Phase 3). No new presence system is being built: the two signals the election needs already exist —
- Who's online →
LibGuildRoster-1.0(liveisOnline+OnMemberOnline/Offlinecallbacks; no new addon traffic). - Who's running FGI →
VersionCheck-1.0, whoseVersionResponsesset is populated by its login GUILD batch — every guildmate running FGI whispers back their version.
The one change this phase makes: VersionCheck is now enabled on retail too
(FGI_Core.lua — dropped the not isRetail gate on both the addon.VC assignment and
VC:Enable, and the /fgi vcheck + Settings "Trigger version check" button no longer
short-circuit on retail). VersionCheck's transport is plain AceComm GUILD/WHISPER, which
works identically on retail, so this also gives retail users the existing "FGI is out of
date" notice for free. (Needs an in-client retail sanity check that the batch fires.)
The Phase 3 election will read recruiterPriority (gmPolicy) ∩ online (LibGuildRoster) ∩
VersionResponses (VersionCheck), refreshing the VC snapshot on demand via
TriggerVersionCheck rather than on a timer.
Guild Policy — designated recruiter (FR-009 Phase 3)
The policy can now name an ordered recruiter priority list, and when it's set, only one member — the highest-priority one who is online and running FGI — may broadcast Announce to public channels (LFG / Trade / General / custom). Guild and Officer chat are never restricted. This gives a guild one consistent recruiting drip instead of every FGI member spamming Trade at once.
Modules/FGI_Recruiter.lua (new). The election is a purely local computation from
three signals every client already shares, so all clients independently agree on the same
designate — no consensus protocol:
- the list →
gmPolicy.recruiterPriority(the GM DeltaSync leaf), - online →
LibGuildRoster-1.0(IsOnline,OnMemberOnlinecallback), - running FGI →
VersionCheck-1.0VersionResponses(∪ self).
GetDesignated() returns the first listed member who is both online and running FGI;
CanPostPublic() is the enforcement predicate (true when no policy, or you're the
designate; fails open when the list is set but nobody eligible is online, so
recruiting doesn't die when your recruiters are offline). When a listed member comes
online, a throttled TriggerVersionCheck refreshes the running-FGI snapshot (on demand,
30 s throttle — no timer). Names are matched through LibGuildRoster:NormalizeName so
short names, Name-Realm, and whisper senders all reconcile.
Modules/Announce.lua. Send() gates public channels on
Recruiter:CanPostPublic(); Guild/Officer post regardless. When everything targeted was
public and we're not the designate, a new status line names who is
(announceMsgNotRecruiter).
GUI/SettingsPanel.lua. A Designated recruiter section on the Guild Policy tab: a
priority list (one name per line, highest first; editable by any policy editor) plus a
live readout — "You are the designated recruiter" / "Current designated recruiter: X" /
"nobody eligible → open to everyone". Rides the gmPolicy LWW sync like the rest of the
policy.
Retail: works because Phase 2 enabled VersionCheck there. Not yet built: the separate random-invite priority list (Phase 4).
Wingman — user-orderable action priority (FR-008)
Wingman's three per-click actions — announce, scan, invite — can now be
put in any order in Settings → Wingman → Priority order. Each click runs the
highest-priority action that can act right now; the rest wait their turn. The
headline use case: rank Invite above Scan so a session drains the whole
invite queue before firing any new /who scan (the previous hard-coded behaviour
always scanned whenever the scan cooldown was clear, interleaving invites with
fresh scans).
Before
Wingman:Drain hard-coded the order announce → scan → invite: announce was checked
first (so a full queue couldn't starve it), then scan ran whenever cf.scanCooldown
was clear, otherwise the next candidate was invited. There was no way to make
invites take precedence over scanning.
The change — Modules/Wingman.lua
- Configurable order. New
DB.global.wingman.prioritystores the three step keys as an ordered array.getPriority()returns it, validated to always be a full permutation of{ "announce", "scan", "invite" }— any missing/corrupt/old config falls back to the defaultannounce → scan → invite, soDrainnever sees a partial or unknown list. - Drain refactor. The old
if scan … elseif invite …block became three smalltryAnnounce/tryScan/tryInviteclosures, each returningtruewhen it actually acted.DrainwalksgetPriority()and runs the first step that returnstrue, then consumes the throttle (lastDrainAt) once. A click with nothing to do stays a no-op that doesn't consume the throttle — unchanged. The hardware-event-critical calls (SendChatMessagevia announce,nextSearch,invitePlayer) are still invoked directly, never through pcall. - Honest readout.
Wingman:StepsSummary()now lists the enabled steps in the configured order (e.g.invite + scan), so the/fgi wingmanscope line and the toggle-button tooltips reflect the real precedence.
The UI — GUI/SettingsPanel.lua
- New Priority order header plus three 1st / 2nd / 3rd priority selects on
the Wingman page (between the action toggles and Timing). Picking a step for one
rank swaps it with whatever rank currently held it (
setWingmanPriority), so the stored order is always a valid permutation — you can't create a duplicate or a gap. The toggles still choose which actions run; the selects choose the order.
Notes
- Announce always self-gates on its own per-profile cooldown, so its rank never causes it to flood — ranking it 1st just means an announcement fires on the first click after each cooldown elapses, ahead of a due scan/invite that click.
- New locale keys added to
Locale/enUS.lua(English; other locales fall back to English until translated, per the project's best-effort locale policy).
[v2.8.1] (2026-07-05) — Announce: zone-independent channel keying (General means General in every zone) + honest send-failure status
Announce profiles now key their channel selection by the base channel name instead of the zone-suffixed one, so a profile that targets "General" posts to whatever zone's General the player is currently standing in — the whole point of the feature — instead of pinning to the one zone it was saved in. Also fixes the misleading "all active profiles are on cooldown" message that a failed send used to produce.
The bug
WoW's zone/city channels report a zone-suffixed name from GetChannelName():
"General - Durotar", "LocalDefense - Orgrimmar", etc. Announce stored the
channel selection keyed by the full lowercased name ("general - durotar") and
resolved it back with GetChannelName("general - durotar"). The moment the player
changed zones, that key no longer resolved:
sendToChannel→resolveChannelIndexreturned nil →SendChatMessagenever fired and the function returnedfalse.- In
Announce:Send()thatfalsewas silently swallowed;postedstayed 0. - With
posted == 0and an active profile present, the only remaining status branch was the cooldown one — so it printed "all active profiles are on cooldown" even though nothing was on cooldown and the real problem was the channel key not resolving. (This is the symptom the user reported: announce worked in the zone where the profile was created, then only produced the cooldown line after moving zones.)
A related latent bug: the activity-bypass tracker bumped state keyed on the
event's base channel name (CHAT_MSG_CHANNEL arg9 = "General"), which never
matched the profile's zone-suffixed "general - durotar" key — so the activity
bypass never fired for zone channels. Fixed by the same base-keying change.
The fix — Modules/Announce.lua
- Base channel keying. New
baseChannelKey(name)/baseChannelDisplay(name)helpers split off the zone suffix ("General - Durotar"→ key"general", display"General")."_guild"/"_officer"and suffix-less custom channels pass through unchanged. resolveChannelIndex(baseKey)now scans the joined-channel list (1-10) and matches onbaseChannelKey(name)rather than callingGetChannelName(fullName), so a stored"general"resolves to the current zone's General index.EnumerateChannelskeys and displays the base form and dedupes by base key, so the Channels dropdown shows"General"(not"General - Durotar").- Activity tracker normalizes
channelBaseNamethroughbaseChannelKeybefore bumping, aligning the activity key with the selection key. - Honest send-failure status.
Send()now tracks channels that were eligible (not on cooldown) but failed to send, and prints a newannounceMsgChannelUnavailableline naming them ("couldn't post to General — you're not currently in that channel") instead of the false cooldown message. Genuine cooldown blocks still print the cooldown line. - One-time migration.
Announce:MigrateChannelKeys()(run onPLAYER_LOGIN) folds any pre-v2.8.1 zone-suffixed keys in saved profiles to their base form, deduping collapsed duplicates. Idempotent; leaves the old per-(profile, channel) cooldown state to lazily re-init atlastPosted = 0(harmless — allows an immediate first post to the now-base key).
UI — GUI/Tabs/Announce.lua
buildChannelSummarymaps stored base keys to display names viaEnumerateChannels(base-keyed) instead of rawGetChannelNamefull names, with a Title-cased fallback for channels the player isn't currently joined to.- The "(not joined)" orphan-channel dropdown label is Title-cased for consistency.
Locale
- New
L["announceMsgChannelUnavailable"]inLocale/enUS.lua(other locales fall back to enUS).
[v2.8.0] (2026-07-02) — Guild sync rebuilt on DeltaSync (delta + P2P), hard cutover, Inviter/Blacklister columns, Wingman action toggles + announce, sync progress bar
The guild-sharing engine is rebuilt from scratch. The hand-rolled CHAT_MSG_ADDON
sync (a ~1300-line LISTEN/ESTABLISHED/CHECK state machine with custom chunking) is
gone, replaced by the maintained DeltaSync-1.0 + AceCommQueue-1.0 stack on
AceComm-3.0: per-host isolation, delta compression (only diffs on the wire), P2P
login catch-up (no single-banker bottleneck), and CRC message integrity. This is a
hard cutover — a v2.8.0 client does not sync with a v2.7.x client; the whole
guild must update. On top of the engine: Inviter/Blacklister attribution columns
(shared guild-wide) and a live list-refresh so lists redraw the instant they change.
New sync engine — DeltaSync-1.0 (Modules/FGI_DeltaSync.lua)
- Bridge to DeltaSync per-host.
Bridge:Init(PLAYER_LOGIN) creates an isolated host viaDS:NewHost(requires DeltaSync v4.0.0 / LibStub MINOR 15+ — the multi-host release, so FGI, TOGBank, TOGPM etc. no longer clobber each other). Feature-detected; refuses to fall back to the singletonInitialize. - 5 synced items, one per data table:
alreadySended,blackList,blackListRemoved(tombstones),leave, andgmPolicy. Each is a name→value map bridged to DeltaSync's array codec; convergence hashes cover the name set for the four tables (matching the write-if-absent merge — hashing values would diverge forever on enrichment-only diffs) and content-hashgmPolicy(LWW). - Merge semantics preserved in host callbacks: write-if-absent (first-writer-wins),
tombstone-wins for blacklist,
gmPolicylast-write-wins by a newsetAtstamp, guild-membership trust gate. - Login catch-up (P2P):
InitP2P+ a 12 s post-loginBroadcastItemHashes; peers offer differing items and the delta is pulled via keys-baselineRequestData→onDataRequest→SendData, applied inonDataReceived. - Live per-action propagation:
PushName/PushPolicybroadcast a single changed entry (markedlive=trueso it can't complete an unrelated in-flight P2P session) from the write sites —rememberPlayer,fn:blackList, real-time guild-leave, and the GM-policy push.rememberPlayergained afromSyncguard so a peer-received entry isn't re-broadcast. - Dev diagnostics:
/fgids(host status + item hashes),/fgids sync(force catch-up), and a full trace behind/fgi debug(bridge lines + DeltaSync's own P2P tab).
Hard cutover — legacy engine removed
- The entire legacy sync engine was deleted (~1266 lines from
functions.lua): theSyncstate machine, hash/serialize helpers,IsTrustedPlayer, theCHAT_MSG_ADDONreceive handler,startSync, thesyncUI/onSync*callbacks, and theFGISYNC_*prefixes. Survivors:fn.syncDisabled(master switch, still honoured by the bridge), the live-refresh bus, and a no-opupdateTableForSyncstub so its scattered callers needed no edits. - New hard dependencies:
AceCommQueue-1.0andDeltaSync(added to all 5 TOCs +.pkgmeta;GuildRoster/LibGuildRoster was already a dependency).init.luanow mixes inAceComm-3.0and embeds AceCommQueue on the AceAddon. - "Sync now" (Settings) and the GM-policy push route through DeltaSync.
Inviter / Blacklister columns
- Anti-Spam gains an Inviter column, Blacklist a Blacklister column — which FGI user first invited or blacklisted each player. Attributed first-writer-wins (the original actor sticks), carried end-to-end over DeltaSync so it fills from your own actions and from guildmates' synced entries. Blank for pre-v2.8.0 entries. Tab min-widths bumped to fit.
Live list-refresh bus
fn.registerListRefresh/fn.refreshList— list tabs redraw the instant an entry changes (local write or a DeltaSync apply), not only on tab-show. Gated on the per-tab frame'sIsVisible()so only the active tab redraws;rl:SetDataonly, so the search box + its focus are preserved. Wired for Blacklist + Anti-Spam.
Sync UI — progress bar + per-tab Sync buttons
- Guild-sync progress in the bottom status bar — the existing shared status bar (the
one that shows scan progress / the version string) now also shows sync progress, in a
teal fill to distinguish it from the orange scan fill, with no new widget. Scoped to
the two tabs it concerns: it paints only on the Blacklist + Anti-Spam tabs; the Scan
tab still shows scan progress and every other tab still shows just the version. Because
RefreshStatusBarruns on every tab switch (OnGroupSelected), leaving those two tabs re-evaluates and drops the teal fill back to the version string. DeltaSync exposes no byte/chunk %, so progress is determinate by item — one segment per synced table (5: anti-spam, blacklist, tombstones, leavers, GM-policy). The bridge tracks a cycle (BeginSyncProgressfires on every catch-up broadcast — manual and login); each item's segment fills when its delta arrives (OnDataReceived), and after the collect window (~11 s) a timed one-per-tick sweep resolves the rest (converged items no peer offered) so the bar always counts up and reaches full. It then stays full at "Sync complete" until the next cycle (mirrors the scan bar persisting at 100%). Driven by a 0.25 s ticker →MainWindow:RefreshStatusBar; the sync branch wins whenever a scan isn't occupying the bar. Text: "Checking guild…" → "N/5 tables synced" → "Sync complete". - "Sync now" button on both list tabs — Blacklist (row 2, after Import GIL) and
Anti-Spam (after Clear All). Fires the same
Bridge:ForceCatchUpas the Settings button and greys out under "Disable all sync".
Wingman — announce step + action toggles
- New Wingman settings tab (Settings → Wingman, order 4) — the first UI for the
previously code-only
DB.global.wingmanconfig. Three action toggles pick which steps each click drives — scan, invite, announce — so you can run invite-only, announce-only, etc. Scan + invite default on; announce is opt-in (off) so existing Wingman users don't suddenly start broadcasting. The pre-existingminInterval/autoStopMinutessliders are surfaced here too for the first time. - Wingman drives Announce.
Wingman:Draingained an announce step, checked first (a full invite queue can't starve it): whendoAnnounceis on andAnnounce:GetNextReadyIn() == 0, it callsAnnounce:Send()and consumes the click.Announce:Sendalready self-gates on its 60 s+ per-profile cooldown, so at most one click per cadence posts. Called directly (no pcall) soSendChatMessageruns in the click's hardware-event context; skipped in anInChatMessagingLockdown(it's a chat message like a whisper).Drainreads all three flags nil-safe so pre-v2.8.0 configs behave;PrintScopenow leads its on-message with the active steps (scan + invite, …). - Toggle-button tooltips updated (main window + compact tray) — the description now
reflects the configurable steps ("choose which in Settings → Wingman") and adds a live
"Now driving: scan + invite" line, driven by a shared
Wingman:StepsSummary(), so a hover tells you exactly what a click will do under your current settings.
Quiet Zones — PvP group
- The Quiet Zones Continent dropdown gains a synthetic PvP entry that lists every
battleground + arena in one place, so a recruiter can silence them as a group instead
of hunting for individual instance zones.
FGI_CONST.areaswas split into named groups (pvpAreas/raidAreas/dungeonAreas) and recombined —fn.getStaticAreas()sees the identical set. Names resolve from those area IDs viaC_Map.GetAreaInfo(localized). These zones are already silenced by the built-in instance filter; adding them here surfaces them as explicit, removable rows (the PvP section bypasses the usual static-area add rejection). NewL["PvP"]key.
Fixes
- Protected-state recruiting pause (retail naked-invite fix). In a chat-messaging
lockdown (Mythic+/raid encounter/delve/rated PvP)
SendChatMessage's player-nametargetis a tainted-caller secret (AllowedWhenUntaintedin ChatInfoDocumentation;message/chatType/languageIDareNeverSecret,targetisn't), so whisper- bearing invite modes can't complete there: Message+Invite / Message Only would fire a message-less "naked" invite (Message Only also marked the player contacted with nothing sent), and Conditional's whisper-on-decline would be blocked too. Newfn.recruitPausedByProtectionpauses recruiting for modes 2/3/4 — gatingfn:invitePlayer,fn:nextSearch, and Wingman's drain — so neither the manual flow nor Wingman can create taint (ADDON_ACTION_BLOCKED) or send a naked invite. Invite Only (Type 1, no whisper) keeps working; a throttled notice explains the pause and it resumes automatically on leaving the instance. Feature-detected → Classic unaffected. updateTableForSynccrash when the legacy sync tables weren't built (surfaced by the DeltaSync-only test mode; would also have hit "Disable all sync" + blacklist).- Settings tab labels off-centre on reopen.
SettingsPanel:ApplyLocaleFontfonted the AceConfigDialog tab strip with the generic per-button walk, whose auto-fit forced a cached width onto the AceGUI TabGroup tab buttons — fighting the TabGroup's own per-rebuild sizing, so the labels drifted off-centre on each Settings close→reopen. Now the settings TabGroup is fonted throughLLO:AttachTabGroupFont(the same resize-aware path the main window uses), and the blanket font walk is scoped to the tab content so it never touches the tab buttons. Bonus: the settings tabs now size per-locale correctly. - Removed a per-tooltip
<FGI-tt>font+hex debug dump that flooded chat on every hover whenever/fgi debugwas on.
Older releases (v2.7.1 and earlier) have been moved to CHANGELOG_ARCHIVE.md.
This mod has no additional files