-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathScanner.lua
More file actions
3647 lines (3404 loc) · 185 KB
/
Copy pathScanner.lua
File metadata and controls
3647 lines (3404 loc) · 185 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- TOG Profession Master — Profession & Cooldown Scanner
-- Author: Pimptasty
--
-- Responsibilities:
-- • Listen for trade-skill / craft window events and scan the open profession.
-- • Scan all known profession cooldown spells on login and on window events.
-- • Store results in AceDB (factionrealm scope, keyed by "Name-Realm").
-- • Broadcast own data to the guild via DeltaSync-1.0.
-- • Receive and merge incoming guild-member data back into AceDB.
local _, addon = ...
local Ace = addon.lib -- the AceAddon object created in TOGProfessionMaster.lua
local L = LibStub("AceLocale-3.0"):GetLocale("TOGProfessionMaster")
-- ---------------------------------------------------------------------------
-- Module object
-- ---------------------------------------------------------------------------
local Scanner = {}
addon.Scanner = Scanner
--- Has `sender` gone offline since the broadcast we are answering?
---
--- ONE implementation, because there were THREE and only one of them was ever
--- asserted. Every outbound `DS:RequestData` is gated on this: DeltaSync has its
--- own internal offline check, but that races with GUILD_ROSTER_UPDATE
--- propagation, so gating here catches the common case where LibGuildRoster has
--- already seen the transition. Mirrors TOGBankClassic's DeltaComms pattern --
--- every send site gets a guard before dispatch.
---
--- **False when no roster library is loaded**, which is deliberate and is the
--- reason this is a named function rather than an inline `and`: with no roster we
--- know nothing about who is online, and refusing to send on the strength of
--- knowing nothing would disable sync entirely rather than protect it.
function Scanner:PeerIsOffline(sender)
local GR = self.GuildRoster
return (GR and not GR:IsOnline(sender)) and true or false
end
-- DELETED 2026-08-19: `GetReagentScraper` and its `_reagentScraper` upvalue.
-- A hidden GameTooltip ("TOGPMReagentScraper") built to scrape reagent item
-- links when GetTradeSkillReagentItemLink / GetCraftReagentItemLink return nil
-- on Classic Era. It had NO CALLER -- luacheck's `unused function` found it, and
-- the whole-repo lint that surfaced that had been unreadable behind ~90
-- undeclared-global warnings, which is how it survived. Whatever replaced the
-- scrape path did so without taking the frame with it. If the link APIs are ever
-- seen failing again, this is the shape the fix took, but write it back with a
-- caller attached.
-- ---------------------------------------------------------------------------
-- Module-scope merge helpers (reused by ScanTradeSkillInto, ScanCraftSkillInto,
-- and the v0.2.0 OnGuildDataReceived per-leaf merges).
-- ---------------------------------------------------------------------------
local function asString(v) return type(v) == "string" and v or nil end
-- Non-destructively merge an incoming reagent array into our existing one.
-- Preserves itemId/itemLink per-reagent when the incoming payload lacks them.
-- Returns nil when incoming is not a table so callers can preserve existing.
local function mergeReagents(existing, incoming)
if type(incoming) ~= "table" then return nil end
local byName = {}
if type(existing) == "table" then
for _, e in ipairs(existing) do
if e.name then byName[e.name] = e end
end
end
local merged = {}
for i, inE in ipairs(incoming) do
local entry = {
name = inE.name,
count = inE.count,
itemId = inE.itemId,
itemLink = asString(inE.itemLink),
}
local prev = entry.name and byName[entry.name]
if prev then
if (not entry.itemId or entry.itemId == 0)
and prev.itemId and prev.itemId > 0 then
entry.itemId = prev.itemId
end
if not entry.itemLink and type(prev.itemLink) == "string"
and prev.itemLink ~= "" then
entry.itemLink = prev.itemLink
end
end
merged[i] = entry
end
return merged
end
-- Returns a clean human-readable recipe name. Classic Era's GetTradeSkillInfo
-- can return placeholder text like "? 10002" when the underlying item info
-- hasn't loaded into the client cache yet (typical for long-tail recipes the
-- player hasn't seen recently). Item links always carry the real name in
-- their [...] field, even when the trade-skill window hasn't loaded the item
-- name — so when the raw name is one of those placeholders, extract from
-- itemLink (preferred) or recipeLink. Falls through to the raw value if
-- nothing better is available.
local function isBogusName(n)
if type(n) ~= "string" or n == "" then return true end
-- "? 10002", "?10002", "? " — Classic Era placeholder forms
if n:match("^%?") then return true end
return false
end
-- True when a name looks like a Blizzard internal/dev/placeholder string
-- that should never reach the player. Used to catch the spell-id / item-id
-- namespace collision in MergeCraftersIntoGdb and BackfillBogusRecipeNames:
-- GetItemInfo(spellId) sometimes returns a real (but obsolete/QA) ItemSparse
-- entry whose ID happens to match a recipe's spellId. Example: spell 26926
-- = "Heavy Copper Ring" (Jewelcrafting); item 26926 = "59 TEST Green Shaman
-- Chest". Without this guard the stub gets cached with the TEST name and
-- every render shows it. Mirror of _OBSOLETE_NAME_PATTERNS in
-- tools/build_authoritative_data.py — keep the two in sync.
local function isObsoleteItemName(n)
if type(n) ~= "string" or n == "" then return false end
if n:find("%f[%w]TEST%f[%W]") then return true end -- "59 TEST ..."
if n:find("%f[%w]QA%f[%W]") then return true end
if n:lower():find("%f[%w]deprecated%f[%W]") then return true end
if n:lower():find("%f[%w]unused%f[%W]") then return true end
if n:lower():match("^zz") then return true end -- "ZZOLD Design: ..."
-- "[ph]" is matched PLAINLY, so it must be written plainly: with the plain
-- flag set, string.find treats the pattern literally, so the escaped form
-- "%[ph%]" searched for a percent sign followed by a bracket and never
-- matched anything. Every "Manual: … [PH]" placeholder sailed through this
-- filter and persisted in SavedVariables.
if n:lower():find("[ph]", 1, true) then return true end -- "Manual: ... [PH]"
if n:lower():match("%s+old$") then return true end -- "Pattern: ... OLD"
return false
end
local function extractNameFromLink(link)
if type(link) ~= "string" then return nil end
local name = link:match("%[(.-)%]")
if name and name ~= "" and not isBogusName(name) then return name end
return nil
end
local function cleanRecipeName(rawName, itemLink, recipeLink)
if not isBogusName(rawName) then return rawName end
return extractNameFromLink(itemLink)
or extractNameFromLink(recipeLink)
or rawName
end
-- ---------------------------------------------------------------------------
-- Offline-test seam — the pure name/reagent helpers above. `local` because
-- nothing outside this file calls them; not used at runtime.
-- See Tests/scanner_names_spec.lua.
-- ---------------------------------------------------------------------------
Scanner._mergeReagents = mergeReagents
Scanner._isBogusName = isBogusName
Scanner._isObsoleteItemName = isObsoleteItemName
Scanner._extractNameFromLink = extractNameFromLink
Scanner._cleanRecipeName = cleanRecipeName
-- Broadcast state
Scanner._pendingBroadcast = false
Scanner._lastBroadcastAt = 0
-- Hard minimum between guild broadcasts (seconds). Dynamically sized by
-- Scanner:ScheduleAddonUserRecount based on the number of guildmates running
-- the addon (sourced from a VersionCheck-1.0 batch). Linear scale clamped to
-- [3, 30]: small guilds (or solo testing) get a 3s floor so post-scan recipe
-- hashes propagate quickly; large active guilds with many addon users keep
-- the 30s ceiling to protect the GUILD addon-message channel from saturation.
-- Default of 30 is in effect until the first recount lands ~21s after PEW.
Scanner._broadcastSeconds = 30
-- DeltaSync + GuildRoster LibStub handles (assigned in InitDeltaSync)
Scanner.DS = nil
Scanner.GuildRoster = nil
-- ---------------------------------------------------------------------------
-- English profession name → skill line ID
-- Used as a fallback for linked professions that aren't in GetProfessions().
-- Game-data facts; locale-specific servers share the same IDs but may have
-- different string keys. Additional locale strings can be appended without
-- affecting logic.
-- ---------------------------------------------------------------------------
local PROF_NAME_TO_ID = {
["Alchemy"] = 171,
["Archaeology"] = 794, -- Cata+ gathering profession (skill-line scan)
["Blacksmithing"] = 164,
["Cooking"] = 185,
["Enchanting"] = 333,
["Engineering"] = 202,
["First Aid"] = 129,
["Fishing"] = 356,
["Herbalism"] = 182,
["Inscription"] = 773,
["Jewelcrafting"] = 755,
["Leatherworking"] = 165,
["Mining"] = 186,
["Skinning"] = 393,
["Tailoring"] = 197,
}
-- ---------------------------------------------------------------------------
-- Cross-guild sister-roster sync (v0.10.1)
-- ---------------------------------------------------------------------------
-- RosterSync (in DeltaSync) pulls an allied guild's roster over whispers and
-- writes it into LibGuildRoster's sister-roster store, then fires
-- onSisterRosterUpdated. We persist a copy into SavedVariables so the data
-- survives /reload before the next live pull, and re-feed those copies on login.
-- Snapshot a sister guild's roster from LibGuildRoster into SavedVariables.
function Scanner:PersistSisterRoster(guildKey)
local GR = self.GuildRoster
if not GR or not GR.GetRoster then return end
local roster = GR:GetRoster(guildKey)
if not roster then return end
local members = {}
for charKey, m in pairs(roster) do
members[#members + 1] = {
name = charKey,
class = m and m.class,
level = m and m.level,
rank = m and m.rank,
}
end
local gdb = addon:GetGuildDb()
if not gdb then return end
gdb.sisterRosters = gdb.sisterRosters or {}
gdb.sisterRosters[guildKey] = {
members = members,
meta = (GR.GetRosterMeta and GR:GetRosterMeta(guildKey)) or nil,
fedAt = GetServerTime and GetServerTime() or nil,
}
end
-- Fired by RosterSync after a sister roster lands in LibGuildRoster: persist a
-- copy and refresh the UI.
function Scanner:OnSisterRosterUpdated(guildKey)
-- Accept gate: only federate rosters for guilds we have configured as
-- sisters. A roster pushed/served for an unlisted guild is rejected and
-- removed — nothing cross-guild happens without the guild being on the list.
if not addon:IsSisterGuildKey(guildKey) then
local GR = self.GuildRoster
if GR and GR.RemoveSisterRoster then GR:RemoveSisterRoster(guildKey) end
addon:DebugPrint("Scanner: rejected unlisted sister roster", guildKey)
return
end
self:PersistSisterRoster(guildKey)
addon:DebugPrint("Scanner: sister roster updated:", guildKey)
-- Part B: a fresh pull should reach the rest of the guild quickly rather
-- than waiting for the periodic tick. Suppression dedupes if others already
-- hold it. Gossip-relayed rosters apply via addon:OnSisterRosterReceived,
-- which does NOT route here, so this fires only for our own pulls — no echo.
if C_Timer and C_Timer.After then
C_Timer.After(10, function() addon:BroadcastSisterRosters() end)
end
if addon.callbacks then
-- A sister roster landing changes cross-guild membership/attribution.
addon.callbacks:Fire("GUILD_DATA_UPDATED", "sister:" .. tostring(guildKey),
{ altgroups = true, roster = true })
end
end
-- Re-feed persisted sister rosters into LibGuildRoster on login so cross-guild
-- queries / the visibility gate work before the first live /who-driven pull.
-- Skips (and forgets) any persisted roster whose guild is no longer on the
-- allied-guild list, so a de-configured guild can't be resurrected on login.
function Scanner:RefeedSisterRosters()
local GR = self.GuildRoster
if not GR or not GR.SetSisterRoster then return end
local gdb = addon:GetGuildDb()
local stored = gdb and gdb.sisterRosters
if type(stored) ~= "table" then return end
local n = 0
for guildKey, entry in pairs(stored) do
if not addon:IsSisterGuildKey(guildKey) then
stored[guildKey] = nil -- stale: dropped from the list since last save
elseif type(entry) == "table" and type(entry.members) == "table" then
GR:SetSisterRoster(guildKey, entry.members, entry.meta)
n = n + 1
end
end
if n > 0 then addon:DebugPrint("Scanner: re-fed", n, "persisted sister roster(s)") end
end
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- DeltaSync initialisation
-- Called on PLAYER_ENTERING_WORLD (initial login or UI reload only).
-- ---------------------------------------------------------------------------
function Scanner:InitDeltaSync()
-- DeltaSync-1.0 and LibGuildRoster-1.0 are declared as ## Dependencies in the
-- .toc. As of DeltaSync v4.0.0 (LibStub MINOR 15) the library is multi-host:
-- each consumer creates its OWN isolated host via NewHost instead of the old
-- singleton DS:Initialize (which kept per-addon state on the one shared LibStub
-- table, so two DeltaSync consumers in a client clobbered each other,
-- last-Initialize-wins). We hold our host in Scanner.DS and call everything on
-- it — every downstream site already reads Scanner.DS / self.DS, so pointing
-- that field at the host migrates them all.
local DSlib = LibStub("DeltaSync-1.0", true)
if not (DSlib and DSlib.NewHost and (DSlib.MINOR or 0) >= 15) then
-- Missing, or an older / stale DeltaSync won the shared LibStub slot. Do
-- NOT fall back to DS:Initialize (the singleton path that clobbers) —
-- disable guild sync. Requires the standalone DeltaSync addon at v4.0.0+.
addon:DebugPrint("Scanner: DeltaSync-1.0 v4 multi-host (MINOR>=15) not found — guild sync disabled")
return
end
local GuildRoster = LibStub("LibGuildRoster-1.0", true)
if not GuildRoster then
addon:DebugPrint("Scanner: LibGuildRoster-1.0 not found — guild sync disabled")
return
end
-- Create our isolated per-host object. Forward-declared so the config
-- closures below (and InitP2P / guild-mode / roster-sync further down) can
-- call back into `DS` — Lua captures it by reference as an upvalue, and those
-- callbacks only fire long after this assignment lands. Everything hereafter
-- runs on this host; never on the bare DSlib handle.
local DS
DS = DSlib:NewHost({
-- Hand DeltaSync our AceAddon instance so its sends route through
-- AceComm-3.0 + AceCommQueue-1.0 (embedded onto Ace at the addon
-- bootstrap in TOGProfessionMaster.lua) instead of falling back to
-- raw C_ChatInfo.SendAddonMessage. Without this, large chunked
-- payloads can interleave under sync load and CRC-fail silently.
aceAddon = addon.lib,
namespace = "TOGPmv3", -- v0.7.0: bare crafters leaf, no recipemeta; was TOGPmv2 in v0.2-v0.6
-- Delivery verdict (DeltaSync MINOR 17+). WoW silently discards addon
-- messages under congestion, and AceComm forwards only a boolean — so a
-- refused send is otherwise indistinguishable from a delivered one, and a
-- sync that has quietly stopped working looks exactly like an idle one.
-- DeltaSync counts every send either way; this callback is what turns a
-- refusal into something visible at the moment it happens. Ignored by an
-- older library (an unknown config key), so no version gate is needed.
onSendFailed = function(info)
Scanner._lastSendFailure = info
Scanner._sendFailureCount = (Scanner._sendFailureCount or 0) + 1
addon:DebugPrint("Scanner: send REFUSED — did NOT arrive:",
info and info.prefix, info and info.distribution,
info and info.target or "(broadcast)", info and info.bytes, "bytes")
-- Surface it in the sync log the user can actually see, but only the
-- first few: a guildless player broadcasting on GUILD would otherwise
-- generate one line per message forever, which trains people to ignore
-- the log entirely.
if addon.callbacks and Scanner._sendFailureCount <= 3 then
addon.callbacks:Fire("SYNC_SENT", "guild", 0,
"|cffff4444REFUSED|r " .. tostring(info and info.channelType or "?")
.. " — message did not arrive")
end
end,
-- A guild member is asking us for data. baseline carries the request
-- type per the v0.2.0 protocol (see docs/v0.2.0-protocol.md §5):
-- { type = "subhashes", parent = "guild:cooldowns" | "guild:accountchars" }
-- { type = "leaf-data", keys = { itemKey, ... } }
-- We respond by broadcasting the requested data on GUILD/BULK so any
-- peer with a stale hash for the same leaf merges for free.
onDataRequest = function(sender, baseline)
addon:DebugPrint("Scanner: onDataRequest ENTRY from", sender,
"type=", baseline and baseline.type or "nil",
"parent=", baseline and baseline.parent or "nil",
"keys=", baseline and baseline.keys and #baseline.keys or 0)
if type(baseline) ~= "table" then
addon:DebugPrint("Scanner: onDataRequest from", sender, "with no baseline (legacy?), ignoring")
return
end
if baseline.type == "subhashes" and baseline.parent then
Scanner:BroadcastSubhashesToGuild(baseline.parent)
elseif baseline.type == "leaf-data" and type(baseline.keys) == "table" then
local reqGdb = addon:GetGuildDb()
local myHashes = (reqGdb and reqGdb.hashes) or {}
for _, itemKey in ipairs(baseline.keys) do
if itemKey:sub(1, 9) == "cooldown:" then
-- New-framework cooldown serve: prefer-newer. The requester
-- stamps its own updatedAt for this key, or -1 = "I hold nothing"
-- (a MISSING stamp is treated as -1 too, so a bare-offer request
-- still resolves). Serve when our copy is strictly newer than
-- theirs — first acquisition works because their -1 is below any
-- real timestamp. If they already hold an equal-or-newer copy
-- they wouldn't have requested, so the gate never wrongly
-- refuses a legitimate new-client request. This is sound ONLY
-- because both sides now carry owner-minted, verbatim-adopted
-- timestamps (never recomputed) — the clean break from the
-- mixed-version drift that froze v1.0.0. BroadcastLeafToGuild
-- no-ops when we hold no backing data.
local reqStamp = tonumber(baseline.stamps and baseline.stamps[itemKey]) or -1
local mine = myHashes[itemKey]
local myTs = (mine and mine.updatedAt) or 0
if myTs > reqStamp then
Scanner:BroadcastLeafToGuild(itemKey)
end
else
Scanner:BroadcastLeafToGuild(itemKey)
end
end
elseif baseline.type == "player-subhashes" and baseline.profId then
-- Per-player drill-down under crafters:<profId>: send the per-player
-- hash list so the requester pulls only the players it differs on.
Scanner:BroadcastPlayerSubhashes(baseline.profId)
elseif baseline.type == "player-leaf" and baseline.profId
and type(baseline.players) == "table" then
-- Scoped fetch: emit ONE partial crafters:<profId> leaf PER requested
-- player, in the order asked (the requester sorts newest-scan first).
-- Separate messages — rather than one combined leaf — so AceCommQueue
-- can interleave them with other traffic instead of one big payload
-- blocking the pipe head-of-line, and the requester's tab paints
-- incrementally as each player's leaf lands. Same total wire bytes;
-- better congestion behavior and UX. Each leaf rides the existing
-- format + merge (own-scan replace when the player IS the sender,
-- union otherwise — now decided cleanly per single-player leaf).
local profKey = "crafters:" .. tostring(baseline.profId)
for _, ck in ipairs(baseline.players) do
if type(ck) == "string" then
Scanner:BroadcastLeafToGuild(profKey, { [ck] = true })
end
end
elseif baseline.type == "sister-pull" then
-- A sister-guild peer is requesting our full guild dataset for
-- cross-guild sharing. BILATERAL CONSENT GATE: serve only when
-- (a) WE list THEIR guild (baseline.parent ∈ our sisters), and
-- (b) THEY list OUR guild (our home key ∈ baseline.keys, the
-- sister-key set they attach as a consent proof).
-- This makes a one-sided config inert and refuses any stranger /
-- accidental / malicious puller from a guild we don't federate
-- with. Respond over WHISPER — they're not on our GUILD channel.
local reqHome = baseline.parent
local reqSisters = (type(baseline.keys) == "table") and baseline.keys or {}
local myHome = addon:GetGuildKey()
local consentOk = myHome and reqHome
and addon:IsSisterGuildKey(reqHome) -- we consent to them
and reqSisters[myHome] and true -- they consent to us
-- Anti-spoof: if we ALREADY hold their claimed guild's roster,
-- the requester must actually be a member of it — defeats a
-- stranger forging parent/keys for a guild we list. On first
-- contact (no roster yet) we trust the bilateral-config claim;
-- the roster is bootstrapped by the (public) roster pull.
local identityOk = true
local GR = Scanner.GuildRoster
if consentOk and GR and GR.GetRoster and GR.IsInGuildScoped then
if GR:GetRoster(reqHome) then
local who = (GR.NormalizeName and GR:NormalizeName(sender)) or sender
if not GR:IsInGuildScoped(who, reqHome) then identityOk = false end
end
end
if consentOk and identityOk then
local payload = Scanner:BuildFullGuildPayload()
if payload then DS:SendData(sender, payload, false) end
else
addon:DebugPrint("Scanner: refused sister-pull from", sender,
"— gate failed (consent=", tostring(consentOk),
"identity=", tostring(identityOk), "reqHome=", tostring(reqHome), ")")
end
end
end,
-- Incoming guild-member data — either a leaf-data broadcast (one or
-- more leaves with content) or a subhashes response. Dispatch is
-- inside OnGuildDataReceived based on the payload shape.
onDataReceived = function(sender, data, bytes)
addon:DebugPrint("Scanner: onDataReceived ENTRY from", sender,
"bytes=", bytes or 0,
"type=", data and data.type or "leaves",
"charKey=", data and data.charKey or "nil")
Scanner:OnGuildDataReceived(sender, data, bytes or 0)
end,
-- onVersionReceived: DeltaSync's own VERSION channel — not used by this
-- addon (nothing calls DS:BroadcastVersion). Version checking is handled
-- by VersionCheck-1.0 via a separate comm protocol.
})
self.DS = DS
self.GuildRoster = GuildRoster
-- Guild-only sync mode (DeltaSync MINOR >= 13). Some private/emulated servers
-- (e.g. Whitemane) never deliver addon messages over WHISPER, which silently
-- breaks DeltaSync's directed channels (QUERY/RESPONSE/DELTA/OFFER/HANDSHAKE):
-- hash offers go out on GUILD fine, but the follow-up fetch over WHISPER never
-- lands, so sync gets stuck at "broadcasts out, nothing back". Guild-mode
-- reroutes those directed channels onto GUILD (each message stamped with its
-- intended recipient so other members drop it). Opt-in, OFF by default,
-- persisted in our realm-scoped DB and re-applied on every login. Must be
-- called AFTER NewHost (channel config has to exist on the host first).
-- Feature-detected — an older embedded DeltaSync simply has no guild-mode and single-
-- guild behaviour is unchanged. User toggle: Settings → General → Sync.
if DS.InitGuildMode then
local realm = Ace.db and Ace.db.realm
DS:InitGuildMode({
enabled = (realm and realm.guildMode) and true or false,
onChanged = function(on)
if Ace.db and Ace.db.realm then
Ace.db.realm.guildMode = on and true or false
end
end,
})
addon:DebugPrint("Scanner: guild-mode available; enabled =",
(realm and realm.guildMode) and true or false)
end
-- Cross-guild ("sister roster") sync. Opt-in / feature-detected: a DeltaSync
-- build without RosterSync, or a LibGuildRoster without the sister API,
-- simply skips this and single-guild sync is unaffected. RosterSync owns the
-- wire and writes received rosters into LibGuildRoster itself; our callback
-- persists a copy and refreshes the UI. Discovery (/who) and the pull trigger
-- come in a later step — this wires the plumbing + login re-feed only.
if DS.InitRosterSync and GuildRoster.SetSisterRoster then
DS:InitRosterSync({
onSisterRosterUpdated = function(guildKey)
Scanner:OnSisterRosterUpdated(guildKey)
end,
})
self:RefeedSisterRosters()
end
-- v0.2.0 hash migration: drop legacy v0.1.x leaf keys and ensure all
-- expected v0.2.0 leaves exist. Idempotent — safe to run on every PEW.
-- Run inside a ScheduleTimer so gdb.lastScan has had a chance to populate
-- (PEW currently stamps lastScan[myKey].accountchars synchronously, but
-- profession + cooldown timestamps come from later scans; running this
-- one tick later keeps the migration consistent with whatever's there).
Ace:ScheduleTimer(function()
local gdb = addon:GetGuildDb()
if gdb then addon.HashManager:RebuildOnFirstLoad(DS, gdb) end
end, 1)
-- ── P2P catch-up sync (v0.2.0) ───────────────────────────────────────────
-- L0 broadcast carries per-profession leaves (recipemeta + crafters) plus
-- two roll-ups (guild:cooldowns, guild:accountchars). Per-character leaves
-- are drilled down on roll-up mismatch via a "subhashes" request.
--
-- onSyncAccepted: peer has different data for a leaf. The flow:
-- 1. crafters:<profId> / recipemeta:<profId> mismatch → request the leaf
-- data directly (it's already at L0 granularity).
-- 2. guild:cooldowns / guild:accountchars roll-up mismatch → request
-- the per-character sub-hashes from the peer. The receiver compares
-- sub-hashes locally and requests individual cooldown:<charKey> /
-- accountchars:<charKey> leaves.
-- 3. cooldown:<charKey> / accountchars:<charKey> direct mismatch (when
-- a peer broadcasts these explicitly) → request leaf data.
DS:InitP2P({
-- DeltaSync defaults (3 sessions, 3 sends, 10s collect window) are
-- tuned for small numbers of peers. In active guilds with 30+ online
-- members, we end up with the cap saturated by leaf fetches that
-- back up while peers wait on each other's caps too — sync grinds
-- to a halt for everything outside the first three slots. Bump
-- both inbound and outbound concurrency to 8 and stretch the
-- collect window to 30s so we accumulate offers from more peers
-- before picking one (gives a better chance of catching all the
-- broadcasters that have what we want).
maxActiveSessions = 8,
maxActiveSends = 8,
collectWindow = 30,
getMyHashes = function()
local gdb = addon:GetGuildDb()
if not gdb then return {} end
local HM = addon.HashManager
HM:RebuildOnFirstLoad(DS, gdb)
local map = HM:GetL0BroadcastMap(gdb)
-- Pad placeholders so peers offer data for professions we have
-- no local content for (Engineering / BS / LW / etc. on a char
-- that only knows Enchanting + Tailoring etc.). Without this,
-- the broadcaster-driven OFFER protocol leaves those keys
-- silently un-synced. See HashManager:PadMissingProfessionPlaceholders.
HM:PadMissingProfessionPlaceholders(DS, map)
return map
end,
hasContent = function(itemKey)
local gdb = addon:GetGuildDb()
if not gdb then return false end
return addon.HashManager:HasContent(gdb, itemKey)
end,
-- True when any online guildmate has no entry in our cooldown hash cache.
hasMissingItems = function()
local gdb = addon:GetGuildDb()
if not gdb then return false end
local me = GuildRoster:GetNormalizedPlayer()
for _, name in ipairs(GuildRoster:GetOnlineMembers()) do
if name ~= me and not (gdb.hashes and gdb.hashes["cooldown:" .. name]) then
return true
end
end
return false
end,
-- Leaf sync accepted: peer has data we need. Request the appropriate
-- payload type via baseline.type encoded in the QUERY message.
--
-- Online gate: skip if the peer went offline between their broadcast
-- and this dispatch. DeltaSync has its own internal offline check
-- but that races with GUILD_ROSTER_UPDATE propagation; gating here
-- catches the common case where GuildRoster has already seen the
-- offline transition. Mirrors the TOGBankClassic pattern from
-- DeltaComms.lua — every send site gets a guard before dispatch.
onSyncAccepted = function(itemKey, sender)
addon:DebugPrint("Scanner: onSyncAccepted itemKey=", itemKey, "sender=", sender)
if Scanner:PeerIsOffline(sender) then
addon:DebugPrint("Scanner: -> skip RequestData -- peer offline:", sender)
return
end
if itemKey == "guild:cooldowns" or itemKey == "guild:accountchars"
or itemKey == "guild:skills" or itemKey == "guild:professions" then
-- Roll-up mismatch — ask for per-character sub-hashes.
addon:DebugPrint("Scanner: → sending subhashes RequestData to", sender)
DS:RequestData(sender, { type = "subhashes", parent = itemKey })
elseif itemKey:sub(1, 9) == "crafters:" then
-- Profession mismatch. If the peer is drill-down capable (we've seen a
-- subsync-marked payload from them), fetch the per-player sub-hashes so
-- we pull only the players whose recipes differ — newest first. Else
-- fall back to the whole-profession leaf (legacy / old peer).
local profId = tonumber(itemKey:sub(10))
if profId and Scanner:PeerSupportsSubSync(sender) then
addon:DebugPrint("Scanner: → sending player-subhashes RequestData to", sender, "for prof", profId)
DS:RequestData(sender, { type = "player-subhashes", profId = profId })
else
addon:DebugPrint("Scanner: → sending leaf-data RequestData to", sender, "for", itemKey)
DS:RequestData(sender, { type = "leaf-data", keys = { itemKey } })
end
elseif itemKey:sub(1, 11) == "recipemeta:"
or itemKey:sub(1, 9) == "cooldown:"
or itemKey:sub(1, 13) == "accountchars:" then
addon:DebugPrint("Scanner: → sending leaf-data RequestData to", sender, "for", itemKey)
DS:RequestData(sender, { type = "leaf-data", keys = { itemKey } })
else
addon:DebugPrint("Scanner: → UNRECOGNIZED itemKey shape, no QUERY sent")
end
end,
})
-- Kick off the first VersionCheck batch so the broadcast debounce can
-- adapt to the number of guildmates running the addon (rather than the
-- static 30s floor inherited at file load). Updates Scanner._broadcastSeconds
-- 21 seconds after FireBatch returns; subsequent refreshes happen on the
-- 10-min periodic tick.
self:ScheduleAddonUserRecount()
addon:DebugPrint("Scanner: DeltaSync initialized for", DS.namespace)
end
--- Fire a VersionCheck-1.0 batch and, 21 seconds later, set
--- Scanner._broadcastSeconds based on how many guildmates responded.
--- Linear scale clamped to [3, 30] so small guilds sync quickly and large
--- guilds stay channel-courteous. No-ops when VersionCheck isn't loaded.
function Scanner:ScheduleAddonUserRecount()
local VC = LibStub and LibStub("VersionCheck-1.0", true)
if not VC or type(VC.FireBatch) ~= "function" then
addon:DebugPrint("Scanner: ScheduleAddonUserRecount — VersionCheck-1.0 not available")
return
end
VC:FireBatch()
-- VC10_REQ broadcasts to guild; peers reply via whisper (VC10_RSP) with
-- up to 8s jitter; VC collects for 12s. 21s buffer captures every reply.
Ace:ScheduleTimer(function()
local hostEntry = VC.hosts and VC.hosts[addon.name]
local responses = hostEntry and hostEntry.VersionResponses or {}
local n = 1 -- count self
for _ in pairs(responses) do n = n + 1 end
Scanner._broadcastSeconds = math.max(3, math.min(30, n))
addon:DebugPrint("Scanner: broadcast debounce set to",
Scanner._broadcastSeconds .. "s for", n, "addon user(s) online")
end, 21)
end
-- ---------------------------------------------------------------------------
-- Event wiring — hooked into the Ace lifecycle via hooksecurefunc
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- Chat filter: swallow "No player named X is currently playing." spam
-- ---------------------------------------------------------------------------
-- The race window: a peer broadcasts hashes on the GUILD channel, then
-- logs out. Our addon (or DeltaSync's internal P2P logic) decides to
-- whisper them an OFFER / RequestData based on that broadcast. The
-- whisper hits the server, the server can't find the player, the server
-- replies with this system message, WoW prints it to the active chat
-- frame. A guild with active sync can produce this spam dozens of times
-- per logout event.
--
-- Mirrors the TOGBankClassic pattern in Modules/Events.lua:62-77 — fast
-- plain-text prefix check first (cheap on the high-volume CHAT_MSG_SYSTEM
-- stream which carries every guild login/logout/achievement message),
-- then the full pattern match only when the prefix matched. Suppresses
-- both quote variants ("No player named 'X'..." and "No player named
-- X...") and the "Player not found" alternate phrasing seen on some
-- clients.
--
-- Trade-off: a manual /w to a player who logged off ALSO won't show the
-- error — the TOGBank precedent accepted this because the addon itself
-- generates orders of magnitude more such errors than user typos do, and
-- the lack of a typing response in the chat window already signals the
-- failed send. If the player is in your guild the offline status is
-- visible in the guild roster anyway.
local _chatFilterInstalled = false
local function InstallChatFilter()
if _chatFilterInstalled or not ChatFrame_AddMessageEventFilter then return end
_chatFilterInstalled = true
ChatFrame_AddMessageEventFilter("CHAT_MSG_SYSTEM", function(_self, _event, message)
if not message then return false end
if message:find("No player named ", 1, true) then
if message:match("^No player named .+ is currently playing%.$") then
return true -- suppress
end
end
if message:find("Player not found", 1, true) then
return true -- suppress
end
return false
end)
end
function Scanner:Init()
InstallChatFilter()
-- Trade skill window (TBC+/Wrath/Cata/MoP — most professions)
Ace:RegisterEvent("TRADE_SKILL_SHOW", function() Scanner:OnTradeSkillEvent() end)
Ace:RegisterEvent("TRADE_SKILL_UPDATE", function() Scanner:OnTradeSkillEvent() end)
-- Craft window (Vanilla enchanting and weapon crafting)
Ace:RegisterEvent("CRAFT_SHOW", function() Scanner:OnCraftEvent() end)
Ace:RegisterEvent("CRAFT_UPDATE", function() Scanner:OnCraftEvent() end)
-- Item-based cooldowns (Salt Shaker in Vanilla leatherworking)
Ace:RegisterEvent("BAG_UPDATE_COOLDOWN", function() Scanner:OnBagCooldownEvent() end)
-- Gathering-profession skill changes (Herbalism / Skinning / Fishing /
-- Archaeology have no trade-skill window, so the recipe scan never sees them).
-- SKILL_LINES_CHANGED fires whenever a skill is learned or ranks up.
Ace:RegisterEvent("SKILL_LINES_CHANGED", function() Scanner:ScanGatheringProfessions() end)
-- Trainer window. When a player opens a profession trainer, capture
-- the EXACT ReqSkillRank Blizzard's server enforces for every offered
-- spell (via GetTrainerServiceSkillReq). This is the authoritative
-- "Requires Blacksmithing (N)" value — same data the trainer's
-- tooltip displays — that emulator SQL doesn't have for apprentice-
-- tier recipes and that DBC's MinSkillLineRank reports as the
-- placeholder 1 for many recipes. One trainer visit captures every
-- spell that trainer teaches in a single event; the data syncs
-- guild-wide via DeltaSync (added in a follow-up patch) so a single
-- player's trainer visit fills the requiredSkill gap for everyone.
-- See gdb.trainerObservations table for the captured shape.
Ace:RegisterEvent("TRAINER_SHOW", function() Scanner:OnTrainerShow() end)
-- TRAINER_UPDATE fires AFTER TRAINER_SHOW once the service list is
-- actually populated. On this client GetNumTrainerServices() returns 0
-- inside the TRAINER_SHOW handler — the services arrive one frame
-- later via TRAINER_UPDATE. Both events route to the same handler;
-- the handler is idempotent (existing entries refresh their
-- observedAt timestamp rather than double-counting).
Ace:RegisterEvent("TRAINER_UPDATE", function() Scanner:OnTrainerShow() end)
-- Scan cooldowns on login after the server is ready
Ace:ScheduleTimer(function()
Scanner:ScanCooldowns()
Scanner:DetectSpecializations()
Scanner:ScanGatheringProfessions()
Scanner:ScheduleBroadcast()
-- Kick off P2P catch-up: always broadcast on login so peers can compare
-- hashes and offer fresher data. hasMissingItems() only checks for absent
-- entries, not stale ones, so gating here would prevent refreshing
-- cooldown data that changed while we were offline.
local DS = Scanner.DS
if DS and type(DS.BroadcastItemHashes) == "function" then
local p2p = DS.p2p
if p2p and p2p.cb then
local hashes = type(p2p.cb.getMyHashes) == "function" and p2p.cb.getMyHashes() or {}
DS:BroadcastItemHashes(hashes, "BULK")
end
end
end, 2)
-- v0.2.0 periodic catch-up tick: every 10 minutes, force a non-differential
-- L0 hash broadcast. Without this, an idle peer (no scans triggering
-- broadcasts) never sends its hash list, so other peers never see its
-- presence and can't push fresher data to it. The 10-min cadence matches
-- TOGBank's pattern and the v0.2.0-protocol.md design.
Ace:ScheduleRepeatingTimer(function()
-- Refresh the addon-user count so _broadcastSeconds tracks guild
-- composition changes through the day. The new count lands ~21s
-- after FireBatch returns and applies to the NEXT broadcast cycle;
-- this tick uses whatever value was set by the previous recount.
Scanner:ScheduleAddonUserRecount()
Scanner._lastBroadcastHashes = nil -- bypass differential check
Scanner._lastBroadcastAt = 0 -- bypass debounce
Scanner:BroadcastHashes()
end, 600)
addon:DebugPrint("Scanner: Init complete")
end
-- Hook into Ace OnEnable so Init() runs after AceDB is ready.
hooksecurefunc(Ace, "OnEnable", function(_self)
Scanner:Init()
end)
-- Hook into OnPlayerEnteringWorld to initialise DeltaSync once per session
-- and backfill any reagent rows missing itemId. PEW guarantees guild + realm
-- info are populated, which the 2s post-OnEnable timer cannot — there we'd
-- silently bail when GetGuildDb() returned nil.
hooksecurefunc(Ace, "OnPlayerEnteringWorld", function(_self, _event, isInitialLogin, isReloadingUi)
if isInitialLogin or isReloadingUi then
Scanner:InitDeltaSync()
-- One-time scrub of obsolete-marker names cached in gdb.recipes by
-- pre-v0.5.5 versions of the addon (before the MergeCraftersIntoGdb /
-- BackfillBogusRecipeNames / MergeRecipeMetaIntoGdb guards). Runs
-- BEFORE the backfill schedule so the backfill sees the cleared
-- names and repopulates them via GetSpellInfo. Without this, the
-- guards prevent NEW pollution but the OLD pollution (Heavy Copper
-- Ring stored as "59 TEST Green Shaman Chest", JC designs stored as
-- "ZZOLD Design: ...", etc.) persists in SavedVariables forever.
Scanner:ScrubObsoleteRecipeNames()
-- Both backfills retry several times because GetItemInfo returns nil for
-- items not yet in the client cache, and the cache fills lazily over the
-- first ~couple minutes after login. Each pass only logs when it
-- actually had something to check, so silent retries don't spam chat.
-- The reagent backfill's GetItemInfo call also kicks an async server-side
-- load, so later passes pick up resolutions kicked by earlier passes.
Ace:ScheduleTimer(function() Scanner:BackfillReagentItemIds() end, 3)
Ace:ScheduleTimer(function() Scanner:BackfillBogusRecipeNames() end, 4)
Ace:ScheduleTimer(function() Scanner:BackfillReagentItemIds() end, 30)
Ace:ScheduleTimer(function() Scanner:BackfillBogusRecipeNames() end, 30)
Ace:ScheduleTimer(function() Scanner:BackfillReagentItemIds() end, 120)
Ace:ScheduleTimer(function() Scanner:BackfillBogusRecipeNames() end, 120)
end
end)
-- Override the ForceSync stub from the main file.
function addon:ForceSync()
Scanner:ScanCooldowns()
Scanner._lastBroadcastAt = 0 -- bypass debounce
Scanner._lastBroadcastHashes = nil -- force full hash list (no diff)
Scanner:BroadcastHashes()
addon:Print(L["SlashForceSyncSent"])
end
-- /togpm status — dump comm/sync diagnostic snapshot to chat.
function addon:PrintStatus()
local sep = "|cffaaaaaa----------------------------------------|r"
addon:Print("|cffda8cffTOG Profession Master — Status|r")
addon:Print(sep)
-- ── DeltaSync ────────────────────────────────────────────────────────────
local DS = Scanner.DS
if not DS then
addon:Print("|cffff4444DeltaSync: NOT initialized|r")
addon:Print(" └ Scanner.DS is nil — was PLAYER_ENTERING_WORLD missed?")
else
addon:Print("|cff00ff00DeltaSync: initialized|r namespace=" .. tostring(DS.namespace))
-- External DeltaSync no longer exposes useAceComm/useAceCommQueue as
-- direct fields; pull them from GetCommStats() and add the LibStub
-- MINOR + a P2P-enabled flag while we're at it.
local stats = (DS.GetCommStats and DS:GetCommStats()) or {}
addon:Print(" aceComm=" .. tostring(stats.useAceComm or false)
.. " registered=" .. tostring(stats.registered or false)
.. " p2p=" .. tostring(stats.p2pEnabled or false)
.. " guildRoster=" .. tostring(Scanner.GuildRoster ~= nil))
-- Communication prefixes (7 channels)
if DS.prefixes then
local pList = {}
for k, v in pairs(DS.prefixes) do
table.insert(pList, k .. "=" .. v)
end
table.sort(pList)
addon:Print(" Prefixes: " .. table.concat(pList, " "))
end
end
addon:Print(sep)
-- ── Guild ────────────────────────────────────────────────────────────────
local guildKey = addon:GetGuildKey()
addon:Print("Guild key: " .. (guildKey or "|cffff4444(not in a guild)|r"))
local gdb = addon:GetGuildDb()
if gdb then
local memberCount, profCount, cdCount = 0, 0, 0
for _ in pairs(gdb.guildData or {}) do memberCount = memberCount + 1 end
for _ in pairs(gdb.recipes or {}) do profCount = profCount + 1 end
for _ in pairs(gdb.cooldowns or {}) do cdCount = cdCount + 1 end
addon:Print(" Stored members=" .. memberCount
.. " profession buckets=" .. profCount
.. " cooldown members=" .. cdCount)
addon:Print(" Hash cache entries: " ..
(function()
local n = 0
for _ in pairs(gdb.hashes or {}) do n = n + 1 end
return n
end)())
else
addon:Print(" |cffff4444No guild DB available|r")
end
addon:Print(sep)
-- ── Online roster ────────────────────────────────────────────────────────
-- PrintStatus runs on `addon` (function addon:PrintStatus), but the
-- GuildRoster handle is stashed on Scanner — reach across explicitly.
local GuildRoster = Scanner.GuildRoster
if GuildRoster then
local online = GuildRoster:GetOnlineMembers()
addon:Print("Online guild members: " .. #online)
for _, name in ipairs(online) do
local inGdb = gdb and gdb.guildData and gdb.guildData[name]
addon:Print(" " .. name .. (inGdb and "" or " |cffff4444(no data)|r"))
end
end
addon:Print(sep)
-- ── P2P state ─────────────────────────────────────────────────────────────
if DS and DS.p2p then
local p2p = DS.p2p
local totalSends = 0
for _, c in pairs(p2p.activeSends or {}) do totalSends = totalSends + c end
addon:Print("P2P active sessions=" .. (p2p.activeSessions or 0)
.. " active sends=" .. totalSends
.. " collecting=" .. tostring(p2p.isCollecting or false)
.. " catchUpCycles=" .. (p2p.catchUpCycles or 0))
-- List in-flight sessions
local sessions = p2p.sessions or {}
local count = 0
for _ in pairs(sessions) do count = count + 1 end
if count > 0 then
addon:Print(" Active sessions:")
for sid, s in pairs(sessions) do
addon:Print((" [%s] %s → %s (%s)"):format(
s.state or "?", s.itemKey or "?", s.peer or "?", sid))
end
else
addon:Print(" No active P2P sessions")
end
else
addon:Print("P2P: not initialized")
end
addon:Print(sep)
-- ── Broadcast debounce ───────────────────────────────────────────────────
local lastBc = Scanner._lastBroadcastAt or 0
local elapsed = (GetServerTime() - lastBc)
addon:Print("Last broadcast: "
.. (lastBc > 0 and (elapsed .. "s ago") or "never")
.. " debounce=" .. Scanner._broadcastSeconds .. "s")
-- ── Sync log summary ─────────────────────────────────────────────────────
local log = addon.guildDb and addon.guildDb.global.syncLog or {}
local sends, recvs = 0, 0
for _, e in ipairs(log) do
if e.event == "send" then sends = sends + 1
elseif e.event == "recv" then recvs = recvs + 1 end
end
addon:Print("Sync log: " .. #log .. " entries sends=" .. sends .. " recvs=" .. recvs)
addon:Print(sep)
end
--- /togpm dsstatus — focused DeltaSync multi-host health check (our /fgids).
--- Confirms the library is the v4.0.0+ multi-host build, that TOGPM created its
--- OWN isolated host via NewHost, and that the host carries our namespace, comm
--- prefixes and P2P — the migration-verification readout from the DeltaSync
--- handoff. Run it alongside another DeltaSync addon's own status command to
--- prove isolation: each should report its own namespace and prefixes.
function addon:PrintDeltaSyncStatus()
local sep = "|cffaaaaaa----------------------------------------|r"
addon:Print("|cffda8cffTOG Profession Master — DeltaSync|r")
addon:Print(sep)
local DSlib = LibStub and LibStub("DeltaSync-1.0", true)
if not DSlib then
addon:Print("|cffff4444Library: not found|r — the standalone DeltaSync addon isn't loaded.")
return
end
local minor = DSlib.MINOR or 0
local multiHost = (DSlib.NewHost ~= nil) and minor >= 15
addon:Print("Library: |cff00ff00loaded|r MINOR=" .. tostring(minor)
.. " multi-host=" ..
(multiHost and "|cff00ff00yes (NewHost, MINOR>=15)|r"
or "|cffff4444NO — update the DeltaSync addon to v4.0.0+|r"))
local DS = Scanner.DS
if not DS then
addon:Print("|cffff4444Host: not created|r (Scanner.DS is nil).")
addon:Print(" \226\148\148 " .. (multiHost
and "Was PLAYER_ENTERING_WORLD missed? Try /reload."
or "Guild sync is disabled until DeltaSync is updated to v4.0.0+."))
return
end
addon:Print("Host: |cff00ff00created via NewHost|r namespace=" .. tostring(DS.namespace))
-- Comm prefixes — should be our own togpmv-v / -d / -q / -r / -x / -o / -h,
-- distinct from any other DeltaSync consumer's (that's the whole point of
-- multi-host isolation).
if DS.prefixes then
local pList = {}
for k, v in pairs(DS.prefixes) do pList[#pList + 1] = k .. "=" .. v end
table.sort(pList)
addon:Print(" Prefixes: " .. table.concat(pList, " "))