Repository navigation
Expand file tree
/
Copy pathProposalUI.lua
More file actions
2170 lines (1946 loc) · 77.6 KB
/
Copy pathProposalUI.lua
File metadata and controls
2170 lines (1946 loc) · 77.6 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
-- Proposal state and slash fallbacks for group run governance.
local SC = Softcore
local SEPARATOR = "\031"
local PAIR_SEPARATOR = "\030"
local PROPOSAL_TIMEOUT_SECONDS = 30 * 60
local ACCEPT_RETRY_SECONDS = 5
local ACCEPT_RETRY_LIMIT = 6
local PARTY_SYNC_NO_ADDON_GRACE_SECONDS = 30
local PARTY_SYNC_CONTINUE_SECONDS = 0.75
local PARTY_SYNC_ACK_CONTINUE_SECONDS = 0.35
local PARTY_SYNC_RESYNC_WAIT_SECONDS = 4
local PARTY_SYNC_RESYNC_ATTEMPTS = 3
local PARTY_SYNC_GOVERNANCE_SETTLE_SECONDS = 12
local function FriendlyGroupingMode(value)
if value == "SOLO_SELF_FOUND" then
return "Solo"
end
return "Group"
end
local function GetRulesetSyncOrder(self)
if self.GetRulesetSyncOrder then
return self:GetRulesetSyncOrder()
end
return {
"death",
"groupingMode",
"failedMemberBlocksParty",
"allowLateJoin",
"allowReplacementCharacters",
"requireLeaderApprovalForJoin",
"auctionHouse",
"mailbox",
"trade",
"mounts",
"flying",
"flightPaths",
"outsiderGrouping",
"unsyncedMembers",
"maxLevelGap",
"maxLevelGapValue",
"dungeonRepeat",
"gearQuality",
"selfCraftedGearAllowed",
"heirlooms",
"enchants",
"instanceWithUnsyncedPlayers",
"bank",
"warbandBank",
"guildBank",
"voidStorage",
"craftingOrders",
"vendor",
"consumables",
"instancedPvP",
"actionCam",
"explorerMode",
"maxDeaths",
"maxDeathsValue",
}
end
local RULE_WIRE_KEYS = {
death = "d",
groupingMode = "g",
failedMemberBlocksParty = "fb",
allowLateJoin = "lj",
allowReplacementCharacters = "rc",
requireLeaderApprovalForJoin = "la",
auctionHouse = "ah",
mailbox = "mb",
trade = "tr",
mounts = "mo",
flying = "fl",
flightPaths = "fp",
outsiderGrouping = "og",
unsyncedMembers = "um",
maxLevelGap = "lg",
maxLevelGapValue = "lv",
dungeonRepeat = "dr",
gearQuality = "gq",
selfCraftedGearAllowed = "sc",
heirlooms = "he",
enchants = "en",
instanceWithUnsyncedPlayers = "iu",
bank = "ba",
warbandBank = "wb",
guildBank = "gb",
voidStorage = "vs",
craftingOrders = "co",
vendor = "ve",
consumables = "cu",
instancedPvP = "pv",
actionCam = "ac",
explorerMode = "ex",
maxDeaths = "md",
maxDeathsValue = "mv",
}
local CANONICAL_RULE_KEYS = {}
for ruleName, wireKey in pairs(RULE_WIRE_KEYS) do
CANONICAL_RULE_KEYS[wireKey] = ruleName
end
local function RuleWireKey(ruleName)
return RULE_WIRE_KEYS[ruleName] or ruleName
end
local function CanonicalRuleKey(ruleName)
return CANONICAL_RULE_KEYS[ruleName] or ruleName
end
local function FriendlyAllowed(value)
if value == "ALLOWED" or value == "LOG_ONLY" then
return "Allowed"
end
return "Disallowed"
end
local function FriendlyGear(value)
if value == "ALLOWED" then return "Any gear" end
if value == "WHITE_GRAY_ONLY" then return "White/gray only" end
if value == "GREEN_OR_LOWER" or value == "COMMON_OR_UNCOMMON" then return "Green or lower" end
if value == "BLUE_OR_LOWER" then return "Blue or lower" end
if value == "EPIC_OR_LOWER" then return "Epic or lower" end
if value == "NO_EPICS" then return "Blue or lower" end
return tostring(value)
end
local function Print(message)
DEFAULT_CHAT_FRAME:AddMessage("|cff4ade80Softcore:|r " .. tostring(message))
end
local function GetDB()
local db = SC.db or SoftcoreDB or {}
SoftcoreDB = db
db.proposals = db.proposals or {}
return db
end
local function Escape(value)
if value == nil then
value = ""
else
value = tostring(value)
end
value = string.gsub(value, "%%", "%%%%")
value = string.gsub(value, SEPARATOR, "%%u")
value = string.gsub(value, PAIR_SEPARATOR, "%%p")
value = string.gsub(value, "=", "%%e")
return value
end
local function Unescape(value)
value = tostring(value or "")
value = string.gsub(value, "%%e", "=")
value = string.gsub(value, "%%p", PAIR_SEPARATOR)
value = string.gsub(value, "%%u", SEPARATOR)
value = string.gsub(value, "%%%%", "%%")
return value
end
-- Returns a table of all current party member keys including the local player.
local function GetCurrentPartyKeys()
local keys = {}
local name, realm = UnitFullName("player")
if not realm or realm == "" then
realm = GetRealmName()
end
keys[(name or "Unknown") .. "-" .. (realm or "Unknown")] = true
if IsInRaid() then
return keys
elseif IsInGroup() then
for i = 1, GetNumSubgroupMembers() do
local unit = "party" .. i
local n, r
if not UnitIsPlayer or UnitIsPlayer(unit) then
n, r = UnitFullName(unit)
end
if n then
if not r or r == "" then r = GetRealmName() end
keys[n .. "-" .. r] = true
end
end
end
return keys
end
local function ParseKeySet(serialized)
local keys = {}
for key in string.gmatch(tostring(serialized or ""), "([^;]+)") do
keys[key] = true
end
return keys
end
local function IsGroupProposal(proposal)
if not proposal then
return false
end
return proposal.proposalType == "RUN"
or proposal.proposalType == "SYNC_RUN"
or proposal.proposalType == "ADD_PARTICIPANT"
end
local function IsSupportedProposalType(proposalType)
return proposalType == "RUN"
or proposalType == "SYNC_RUN"
or proposalType == "ADD_PARTICIPANT"
end
local function ActivateAcceptedProposalParticipants(self, proposal)
if not proposal or not proposal.acceptedBy then
return
end
local db = GetDB()
if not db.run or not db.run.active then
return
end
local localKey = self:GetPlayerKey()
local currentParty = GetCurrentPartyKeys()
for playerKey, accepted in pairs(proposal.acceptedBy) do
if accepted and playerKey ~= localKey and currentParty[playerKey] then
local participant = self:GetOrCreateParticipant(playerKey)
if participant.status ~= "FAILED" and participant.status ~= "RETIRED" then
participant.status = "ACTIVE"
participant.joinedAt = participant.joinedAt or time()
participant.leftAt = nil
end
end
end
end
function SC:SerializeRuleset(ruleset)
local parts = {}
local normalized = self.NormalizeRulesetForSync and self:NormalizeRulesetForSync(ruleset or {}) or (ruleset or {})
for _, key in ipairs(GetRulesetSyncOrder(self)) do
if normalized[key] ~= nil then
table.insert(parts, Escape(RuleWireKey(key)) .. "=" .. Escape(normalized[key]))
end
end
return table.concat(parts, PAIR_SEPARATOR)
end
function SC:SerializePartialRules(rules)
local parts = {}
for key, value in pairs(rules or {}) do
table.insert(parts, Escape(RuleWireKey(key)) .. "=" .. Escape(value))
end
return table.concat(parts, PAIR_SEPARATOR)
end
function SC:DeserializePartialRules(serialized)
local rules = {}
for pair in string.gmatch(serialized or "", "([^" .. PAIR_SEPARATOR .. "]+)") do
local key, value = string.match(pair, "^([^=]+)=(.*)$")
if key then
key = CanonicalRuleKey(Unescape(key))
value = Unescape(value)
if value == "true" then
rules[key] = true
elseif value == "false" then
rules[key] = false
elseif (key == "maxLevelGapValue" or key == "maxDeathsValue") and tonumber(value) then
rules[key] = tonumber(value)
else
rules[key] = value
end
end
end
return rules
end
function SC:DeserializeRuleset(serialized)
local ruleset = self:GetDefaultRuleset()
for pair in string.gmatch(serialized or "", "([^" .. PAIR_SEPARATOR .. "]+)") do
local key, value = string.match(pair, "^([^=]+)=(.*)$")
if key then
key = CanonicalRuleKey(Unescape(key))
value = Unescape(value)
if value == "true" then
ruleset[key] = true
elseif value == "false" then
ruleset[key] = false
elseif tonumber(value) and (key == "maxLevelGapValue" or key == "maxDeathsValue") then
ruleset[key] = tonumber(value)
else
ruleset[key] = value
end
end
end
if self.NormalizeRulesetForSync then
ruleset = self:NormalizeRulesetForSync(ruleset)
elseif self.ApplyGroupingMode then
self:ApplyGroupingMode(ruleset)
end
return ruleset
end
function SC:ComputeRulesetHash(ruleset)
local oldRuleset
local db = self.db or SoftcoreDB
if self.NormalizeRulesetForSync then
ruleset = self:NormalizeRulesetForSync(ruleset or {})
end
if db and db.run then
oldRuleset = db.run.ruleset
db.run.ruleset = ruleset
end
local hash = self.GetRulesetHash and self:GetRulesetHash() or ""
if db and db.run then
db.run.ruleset = oldRuleset
end
return hash
end
local function ProposalSummary(proposal)
return "Death: Permanent"
.. "\nGrouping: " .. FriendlyGroupingMode(proposal.ruleset.groupingMode)
.. " Unsynced Party: " .. FriendlyAllowed(proposal.ruleset.unsyncedMembers)
.. " Unsynced Dungeons: " .. FriendlyAllowed(proposal.ruleset.instanceWithUnsyncedPlayers)
.. "\nAH: " .. FriendlyAllowed(proposal.ruleset.auctionHouse)
.. " Mail: " .. FriendlyAllowed(proposal.ruleset.mailbox)
.. " Trade: " .. FriendlyAllowed(proposal.ruleset.trade)
.. "\nGear: " .. FriendlyGear(proposal.ruleset.gearQuality)
.. " Heirlooms: " .. FriendlyAllowed(proposal.ruleset.heirlooms)
.. " Enchants: " .. FriendlyAllowed(proposal.ruleset.enchants)
end
local function PrintRuleDifferences(self, localRuleset, remoteRuleset)
if not self.DescribeRulesetDifferences then
return
end
local differences = self:DescribeRulesetDifferences(localRuleset, remoteRuleset)
if #differences == 0 then
return
end
Print("rule differences:")
for _, diff in ipairs(differences) do
Print(" " .. diff.ruleName .. ": local " .. tostring(diff.localValue) .. " / proposal " .. tostring(diff.remoteValue))
end
end
function SC:StoreProposal(proposal)
local db = GetDB()
db.proposals[proposal.proposalId] = proposal
db.pendingProposalId = proposal.proposalId
return proposal
end
function SC:GetPendingProposal()
local db = GetDB()
if db.pendingProposalId then
local proposal = db.proposals[db.pendingProposalId]
if not proposal then
db.pendingProposalId = nil
return nil
end
if proposal.status ~= "PENDING" and proposal.status ~= "ACCEPTED" then
db.pendingProposalId = nil
return nil
end
if IsInRaid() and IsGroupProposal(proposal) then
proposal.status = "EXPIRED"
db.pendingProposalId = nil
self:AddLog("PROPOSAL_EXPIRED", "Expired group proposal because raid groups are local-only.", {
proposalId = proposal.proposalId,
runId = proposal.runId,
})
return nil
end
local createdAt = tonumber(proposal.proposedAt) or time()
if time() - createdAt > PROPOSAL_TIMEOUT_SECONDS then
proposal.status = "EXPIRED"
db.pendingProposalId = nil
self:AddLog("PROPOSAL_EXPIRED", "Expired stale proposal: " .. tostring(proposal.runName), {
proposalId = proposal.proposalId,
runId = proposal.runId,
})
return nil
end
return proposal
end
return nil
end
function SC:ClearStalePendingProposal()
self:GetPendingProposal()
end
function SC:CanProposeRun()
local db = self.db or SoftcoreDB
local participant = db and db.run and db.run.participants and db.run.participants[self:GetPlayerKey()]
if not IsInGroup() or IsInRaid() then
return true
end
if UnitIsGroupLeader and UnitIsGroupLeader("player") then
return true
end
return participant and (participant.status == "ACTIVE" or participant.status == "WARNING")
end
-- Shared proposal/start guard for max-level characters. Softcore tracks leveling runs,
-- so max-level starts, joins, and synced legacy max-level runs stay ineligible.
function SC:GetProposalRunStartBlocker(proposal)
if not proposal then
return nil
end
local db = GetDB()
local proposalType = proposal.proposalType or "RUN"
local maxLevel = self.GetMaxLevel and self:GetMaxLevel() or 80
local proposalStartLevel = tonumber(proposal.startLevel)
if proposalStartLevel and proposalStartLevel >= maxLevel then
local label = self.FormatPlayerLabel and self:FormatPlayerLabel(proposal.proposedBy) or tostring(proposal.proposedBy or "The proposer")
return label .. " started this run at max level. It cannot be joined as a leveling run.", "MAX_LEVEL"
end
if proposal.proposedBy and self.GetRemotePeerStatus then
local peer = self:GetRemotePeerStatus(proposal.proposedBy)
if peer and self.IsLevelMaxForRun and self:IsLevelMaxForRun(peer.level) then
local label = self.FormatPlayerLabel and self:FormatPlayerLabel(proposal.proposedBy) or tostring(proposal.proposedBy)
return label .. " is already max level and cannot propose a leveling run.", "MAX_LEVEL"
end
end
if proposalType == "SYNC_RUN" then
if self.GetActiveRunLevelingBlocker then
local activeBlocker = self:GetActiveRunLevelingBlocker()
if activeBlocker then
return activeBlocker, "MAX_LEVEL"
end
end
return nil
end
if (proposalType == "RUN" or proposalType == "ADD_PARTICIPANT") and (not db.run or not db.run.active) then
if self.GetLevelingRunStartBlocker then
local blocker = self:GetLevelingRunStartBlocker()
if blocker then
return blocker, "MAX_LEVEL"
end
end
end
return nil
end
-- Returns true if all members still in the party from partyAtProposalTime have accepted.
-- Members who have left since creation are ignored because they cannot accept.
function SC:CheckAllProposalMembersAccepted(proposal)
if proposal.targetPlayerKey then
local currentParty = GetCurrentPartyKeys()
return not currentParty[proposal.targetPlayerKey] or proposal.acceptedBy[proposal.targetPlayerKey] == true
end
local party = proposal.partyAtProposalTime
if not party or not next(party) then
return true
end
local currentParty = GetCurrentPartyKeys()
for key in pairs(party) do
if currentParty[key] and not proposal.acceptedBy[key] then
return false
end
end
return true
end
local function IsProposalVoter(proposal, playerKey)
if not proposal or not playerKey or playerKey == "" then
return false
end
if proposal.targetPlayerKey then
return playerKey == proposal.targetPlayerKey
end
local party = proposal.partyAtProposalTime
if not party or not next(party) then
return true
end
return party[playerKey] == true
end
-- Called on GROUP_ROSTER_UPDATE. If the local player is the proposer and a member who
-- hadn't accepted has now left, re-evaluate whether the proposal can be confirmed.
function SC:CheckPendingProposalOnRosterUpdate()
local db = GetDB()
local proposal = self:GetPendingProposal()
if not proposal or proposal.status ~= "PENDING" then return end
if proposal.proposedBy ~= self:GetPlayerKey() then return end
if proposal.proposalType ~= "RUN" and proposal.proposalType ~= "SYNC_RUN" and proposal.proposalType ~= "ADD_PARTICIPANT" then return end
if self:CheckAllProposalMembersAccepted(proposal) then
if self.GetProposalRunStartBlocker then
local blocker = self:GetProposalRunStartBlocker(proposal)
if blocker then
Print("cannot confirm proposal: " .. blocker)
return
end
end
proposal.status = "CONFIRMED"
db.pendingProposalId = nil
if proposal.proposalType == "RUN" and (not db.run or not db.run.active) then
self:StartRun({
runId = proposal.runId,
runName = proposal.runName,
ruleset = proposal.ruleset,
preset = proposal.preset,
})
end
if self.Sync_SendRunProposalConfirmed then
self:Sync_SendRunProposalConfirmed(proposal)
end
if proposal.proposalType == "SYNC_RUN" then
Print("all present members accepted. Run sync confirmed.")
elseif proposal.proposalType == "ADD_PARTICIPANT" then
Print("all present members accepted. Party invite confirmed.")
else
Print("all present members accepted. Run started.")
end
if proposal.proposalType ~= "RUN" and self.PlayUISound then
self:PlayUISound("PROPOSAL_CONFIRMED")
end
if proposal.proposalType == "SYNC_RUN" or proposal.proposalType == "ADD_PARTICIPANT" then
self:PartySync_ScheduleContinue("PROPOSAL_CONFIRMED")
end
if self.HUD_Refresh then self:HUD_Refresh() end
end
end
function SC:ConfirmPendingProposalPeerActive(playerKey, runId, rulesetHash)
local db = GetDB()
local proposal = self:GetPendingProposal()
if not proposal or proposal.status ~= "PENDING" then return false end
if proposal.proposedBy ~= self:GetPlayerKey() then return false end
if proposal.proposalType ~= "RUN" and proposal.proposalType ~= "SYNC_RUN" and proposal.proposalType ~= "ADD_PARTICIPANT" then return false end
if not playerKey or not runId or proposal.runId ~= runId then return false end
if rulesetHash and rulesetHash ~= "" and proposal.rulesetHash and proposal.rulesetHash ~= rulesetHash then return false end
proposal.acceptedBy[playerKey] = true
if not self:CheckAllProposalMembersAccepted(proposal) then
return false
end
if self.GetProposalRunStartBlocker then
local blocker = self:GetProposalRunStartBlocker(proposal)
if blocker then
Print("cannot confirm proposal: " .. blocker)
return false
end
end
proposal.status = "CONFIRMED"
db.pendingProposalId = nil
if proposal.proposalType == "RUN" and not db.run.active then
self:StartRun({
runId = proposal.runId,
runName = proposal.runName,
ruleset = proposal.ruleset,
preset = proposal.preset,
})
end
if proposal.proposalType == "RUN" or proposal.proposalType == "ADD_PARTICIPANT" then
ActivateAcceptedProposalParticipants(self, proposal)
end
if self.Sync_SendRunProposalConfirmed then
self:Sync_SendRunProposalConfirmed(proposal)
end
self:AddLog("PROPOSAL_CONFIRMED", "Proposal confirmed from active peer status: " .. tostring(playerKey), {
proposalId = proposal.proposalId,
playerKey = playerKey,
runId = proposal.runId,
})
if proposal.proposalType == "SYNC_RUN" then
Print("run sync confirmed from party status.")
elseif proposal.proposalType == "ADD_PARTICIPANT" then
Print("party invite confirmed from party status.")
else
Print("run started from party acceptance.")
end
if proposal.proposalType ~= "RUN" and self.PlayUISound then
self:PlayUISound("PROPOSAL_CONFIRMED")
end
if proposal.proposalType == "SYNC_RUN" or proposal.proposalType == "ADD_PARTICIPANT" then
self:PartySync_ScheduleContinue("PROPOSAL_CONFIRMED")
end
if self.MasterUI_Refresh then self:MasterUI_Refresh() end
if self.HUD_Refresh then self:HUD_Refresh() end
return true
end
function SC:CreateRunProposal(runName, ruleset, proposalType, targetPlayerKey, runId, options)
if IsInRaid() then
Print("raid groups are not supported for run proposals.")
return nil
end
if not self:CanProposeRun() then
Print("only the party leader or an active participant can propose a run.")
return nil
end
local db = GetDB()
options = options or {}
local existing = self:GetPendingProposal()
if existing and (existing.status == "PENDING" or existing.status == "ACCEPTED") then
Print("finish or cancel the current proposal before creating another.")
return nil
end
if (proposalType == "RUN" or not proposalType) and db.run and db.run.active then
Print("cannot propose a new run while an active run is in progress. Use /sc reset to stop it first.")
return nil
end
if (proposalType == "RUN" or not proposalType) and self.GetLevelingRunStartBlocker then
local blocker = self:GetLevelingRunStartBlocker({ includeParty = IsInGroup() and not IsInRaid() })
if blocker then
Print(blocker)
return nil
end
elseif proposalType == "ADD_PARTICIPANT" and self.GetLevelingRunStartBlocker then
local blocker = self:GetLevelingRunStartBlocker({ skipLocal = true, includeParty = true, targetPlayerKey = targetPlayerKey })
if blocker then
Print(blocker)
return nil
end
elseif proposalType == "SYNC_RUN" and self.GetActiveRunLevelingBlocker then
local blocker = self:GetActiveRunLevelingBlocker()
if blocker then
Print(blocker)
return nil
end
end
local proposalRunId = runId or self:CreateRunId()
local proposalRuleset = self:CopyTable(ruleset or self:GetDefaultRuleset())
if self.NormalizeRulesetForSync then
proposalRuleset = self:NormalizeRulesetForSync(proposalRuleset)
elseif self.ApplyGroupingMode then
self:ApplyGroupingMode(proposalRuleset)
end
if self.NormalizePresetKey then
proposalRuleset.achievementPreset = self:NormalizePresetKey(proposalRuleset.achievementPreset)
proposalRuleset.preset = self:NormalizePresetKey(proposalRuleset.preset)
end
local proposalName = self.NormalizePresetLabel and self:NormalizePresetLabel(runName) or runName or "Softcore Run"
if not runName or runName == "Softcore Run" then
proposalName = self.GetRunDisplayName and self:GetRunDisplayName({ ruleset = proposalRuleset }, "Custom Run") or "Custom Run"
end
local proposal = {
proposalId = self:CreateProposalId(),
runId = proposalRunId,
runName = proposalName,
proposedBy = self:GetPlayerKey(),
proposedAt = time(),
ruleset = proposalRuleset,
rulesetHash = self:ComputeRulesetHash(proposalRuleset),
preset = self.NormalizePresetKey and self:NormalizePresetKey(proposalRuleset.achievementPreset or proposalRuleset.preset or "CUSTOM") or (proposalRuleset.achievementPreset or proposalRuleset.preset or "CUSTOM"),
startLevel = tonumber(options.startLevel or (db.run and db.run.active and db.run.startLevel) or (db.character and db.character.level) or UnitLevel("player") or 0) or 0,
acceptedBy = {},
declinedBy = {},
status = "PENDING",
proposalType = proposalType or "RUN",
targetPlayerKey = targetPlayerKey,
partyAtProposalTime = options.partyAtProposalTime or GetCurrentPartyKeys(),
}
proposal.acceptedBy[proposal.proposedBy] = true
db.proposals[proposal.proposalId] = proposal
db.pendingProposalId = proposal.proposalId
self:AddLog("PROPOSAL_CREATED", "Run proposal created: " .. proposal.runName, {
proposalId = proposal.proposalId,
runId = proposal.runId,
})
if self.Sync_SendRunProposal then
self:Sync_SendRunProposal(proposal)
end
if self.HUD_Refresh then
self:HUD_Refresh()
end
return proposal
end
function SC:CreateRunSyncProposal(partyAtProposalTime)
local db = GetDB()
if not IsInGroup() or IsInRaid() then
Print(IsInRaid() and "raid groups are not supported for run sync proposals." or "run sync proposals require a party.")
return nil
end
if not db.run or not db.run.active then
Print("start a run before proposing party sync.")
return nil
end
local runName = self.GetRunDisplayName and self:GetRunDisplayName(db.run, "Custom Run") or "Custom Run"
return self:CreateRunProposal(runName, db.run.ruleset, "SYNC_RUN", nil, db.run.runId, {
partyAtProposalTime = partyAtProposalTime,
startLevel = db.run.startLevel,
})
end
function SC:CreateRunInviteProposal(targetPlayerKey, partyAtProposalTime)
local db = GetDB()
if not IsInGroup() or IsInRaid() then
Print(IsInRaid() and "raid groups are not supported for party invites." or "party invites require a party.")
return nil
end
if not db.run or not db.run.active then
Print("start a run before inviting party members.")
return nil
end
local runName = self.GetRunDisplayName and self:GetRunDisplayName(db.run, "Custom Run") or "Custom Run"
return self:CreateRunProposal(runName, db.run.ruleset, "ADD_PARTICIPANT", targetPlayerKey, db.run.runId, {
partyAtProposalTime = partyAtProposalTime,
startLevel = db.run.startLevel,
})
end
local function CountRuleChanges(changes)
local count = 0
for _ in pairs(changes or {}) do
count = count + 1
end
return count
end
local function CopyLocalRulesForAlignment(self, ruleset)
if self.NormalizeRulesetForSync then
return self:NormalizeRulesetForSync(ruleset or {})
end
local changes = {}
for key, value in pairs(ruleset or {}) do
changes[key] = value
end
return changes
end
local function HasPendingRuleAmendment(db)
for _, amendment in ipairs(db and db.ruleAmendments or {}) do
if amendment.status == "PENDING" or amendment.status == "ACCEPTED" then
return true
end
end
return false
end
local function HasRecentRuleGovernance(db)
local now = time()
for _, amendment in ipairs(db and db.ruleAmendments or {}) do
local settledAt = amendment.appliedAt or amendment.declinedAt or amendment.expiredAt or amendment.noChangesAt
if settledAt and now - tonumber(settledAt) <= PARTY_SYNC_GOVERNANCE_SETTLE_SECONDS then
return true
end
end
return false
end
local function AddPartySyncVoter(voters, playerKey)
if playerKey and playerKey ~= "" then
voters[playerKey] = true
end
end
function SC:PartySync_StartPlan()
local db = GetDB()
db.partySyncPlan = {
active = true,
owner = self:GetPlayerKey(),
startedAt = time(),
resyncAttempts = 0,
continueSerial = 0,
}
if self.TraceDebug then
self:TraceDebug("PARTY_SYNC_PLAN_START", {
owner = db.partySyncPlan.owner,
})
end
if self.HUD_Refresh then self:HUD_Refresh() end
end
function SC:PartySync_StopPlan(reason)
local db = GetDB()
db.partySyncPlan = nil
if reason and reason ~= "" then
Print(reason)
end
if self.HUD_Refresh then self:HUD_Refresh() end
end
function SC:PartySync_ScheduleContinue(reason, delaySeconds)
local db = GetDB()
local plan = db.partySyncPlan
if not plan or not plan.active or plan.owner ~= self:GetPlayerKey() then
return
end
local delay = delaySeconds or PARTY_SYNC_CONTINUE_SECONDS
plan.continueSerial = (tonumber(plan.continueSerial) or 0) + 1
local continueSerial = plan.continueSerial
if self.TraceDebug then
self:TraceDebug("PARTY_SYNC_CONTINUE_SCHEDULED", {
reason = reason,
delay = delay,
serial = continueSerial,
})
end
if self.HUD_Refresh then self:HUD_Refresh() end
if C_Timer and C_Timer.After then
C_Timer.After(delay, function()
local currentPlan = GetDB().partySyncPlan
if currentPlan and currentPlan.continueSerial == continueSerial and SC.PartySync_ContinuePlan then
SC:PartySync_ContinuePlan(reason)
end
end)
else
self:PartySync_ContinuePlan(reason)
end
end
function SC:PartySync_OnFreshState(playerKey, reason, requestId)
local db = GetDB()
local plan = db.partySyncPlan
if not plan or not plan.active or plan.owner ~= self:GetPlayerKey() then
return
end
if self:GetPendingProposal() or HasPendingRuleAmendment(db) then
return
end
if self.TraceDebug then
self:TraceDebug("PARTY_SYNC_FRESH_STATE", {
playerKey = playerKey,
reason = reason,
requestId = requestId,
})
end
self:PartySync_ScheduleContinue(reason or "FRESH_STATE", PARTY_SYNC_ACK_CONTINUE_SECONDS)
end
function SC:PartySync_ContinuePlan(reason)
local db = GetDB()
local plan = db.partySyncPlan
if not plan or not plan.active or plan.owner ~= self:GetPlayerKey() then
return nil
end
local route = self:GetPartySyncAction(true)
if self.TraceDebug then
self:TraceDebug("PARTY_SYNC_CONTINUE", {
reason = reason,
action = route and route.action,
})
end
if not route or route.action == "HIDDEN" then
self:PartySync_StopPlan()
return nil
end
if route.action == "NONE" then
self:PartySync_StopPlan("Party Sync complete.")
return nil
end
if route.action == "BLOCKED" then
self:PartySync_StopPlan(route.message or "Party Sync stopped.")
return nil
end
if route.action == "RESYNC" then
plan.resyncAttempts = (tonumber(plan.resyncAttempts) or 0) + 1
if plan.resyncAttempts > PARTY_SYNC_RESYNC_ATTEMPTS then
self:PartySync_StopPlan(route.message or "Party Sync is still waiting on fresh party state.")
return nil
end
self:RunPartySyncAction(true)
self:PartySync_ScheduleContinue("RESYNC", PARTY_SYNC_RESYNC_WAIT_SECONDS)
return route
end
plan.resyncAttempts = 0
return self:RunPartySyncAction(true)
end
function SC:GetPartySyncAction(allowActivePlan)
local db = GetDB()
if IsInRaid() then
return {
action = "BLOCKED",
enabled = false,
message = "Raid groups are local-only. Convert back to party before syncing runs.",
}
end
if not IsInGroup() then
return {
action = "HIDDEN",
enabled = false,
message = "Party Sync is available in a party.",
}
end
if not db.run or not db.run.active then
if self.GetLevelingRunStartBlocker then
local blocker = self:GetLevelingRunStartBlocker({ includeParty = IsInGroup() and not IsInRaid() })
if blocker then
return {
action = "BLOCKED",
enabled = true,
message = blocker,
blockedCode = "MAX_LEVEL",
}
end
end
if IsInGroup() and not IsInRaid() then
local rows = self.Sync_GetGroupRows and self:Sync_GetGroupRows() or {}
local runCandidates = {}
local distinctRuns = {}
for _, peer in ipairs(rows) do
if peer.active and peer.runId and peer.runId ~= "" then
if not distinctRuns[peer.runId] then
distinctRuns[peer.runId] = {
runId = peer.runId,
playerKeys = {},
rulesetHash = peer.rulesetHash,
remoteRuleset = peer.remoteRuleset,
}
table.insert(runCandidates, distinctRuns[peer.runId])
end
table.insert(distinctRuns[peer.runId].playerKeys, peer.playerKey)
end
end
if #runCandidates > 0 then
local primary = runCandidates[1]
local label = self.FormatPlayerLabel and self:FormatPlayerLabel(primary.playerKeys[1]) or tostring(primary.playerKeys[1] or "a party member")
return {
action = "JOIN_RUN",
enabled = true,
message = #runCandidates == 1
and (label .. " is on a run. Join it, or start your own.")
or "Party members are on different runs. Choose one to join, or start your own.",
candidates = runCandidates,
}
end
end
return {
action = "BLOCKED",
enabled = true,
message = "Start a run before syncing with your party.",
}
end
if self.GetActiveRunLevelingBlocker then
local activeBlocker = self:GetActiveRunLevelingBlocker()
if activeBlocker then
return {
action = "BLOCKED",
enabled = true,