Repository navigation
Expand file tree
/
Copy pathClicks.lua
More file actions
1106 lines (1027 loc) · 49.5 KB
/
Copy pathClicks.lua
File metadata and controls
1106 lines (1027 loc) · 49.5 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
-- Manners -- what became of a press: the click parked until the game says
-- whether a cast went out, what that settles or rewinds, the global cooldown,
-- and the /click macro the options page offers.
local ns = select(2, ...)
local L = ns.L
local addon = ns.addon
local InCombatLockdown = _G.InCombatLockdown
local GetTime = _G.GetTime
-- Core.lua's and Queue.lua's, which load first.
local plain, safecall, caps, SpellNameFor = ns.plain, ns.safecall, ns.caps, ns.SpellNameFor
local owed, LiveExpiry, SaveDebts = ns.owed, ns.DebtExpiry, ns.SaveDebts
local TellLedger, ListedAs = ns.TellLedger, ns.ListedAs
---------------------------------------------------------------------------
-- the pending click and its settle
---------------------------------------------------------------------------
-- The game reports on casts directly. UNIT_SPELLCAST_SENT firing at all means
-- the macro resolved a target and tried; its absence means no clause matched.
-- UI_ERROR_MESSAGE carries a reason for something, not necessarily for ours.
-- A click parks its debt in ns.pendingClick, and one of four outcomes resolves
-- it: a cast event settles it, an error rewinds what it wrote, a second press
-- abandons it as unknown, and the clock running out says nothing was cast.
-- How long a parked click waits for the game to answer: latency plus a wide
-- margin. One number, so the settle, the abandon and the sweep agree on when
-- a record is dead.
local SETTLE_SECONDS = 2
-- How late a cast event can still be this press's own answer: an accepted
-- cast reports in the same frame, a queued one within the spell-queue window
-- (0.4 s by default, the floor here; a player who set it wider gets that
-- window plus a margin, in SettlePendingClick). Anything later is a hand on
-- the action bar, and on this client that event names nobody.
-- SETTLE_SECONDS still decides when a record is dead.
local SENT_SECONDS = 0.5
-- Said the same way wherever a click comes to nothing: "still owed" only of
-- somebody who is, "was not buffed" only where something says so. `unknown`
-- (a format string handed the name) is the line for somebody not owed when
-- nothing does -- a press abandoned before the game answered, whose cast may
-- yet land -- and `unknownSelf` that line for a press on yourself.
local function SayStillOwed(name, why, unknown, unknownSelf)
local db = addon.db and addon.db.profile
if not (db and db.verbose) then return end
local debt = owed[name]
if debt and LiveExpiry(debt) > GetTime() then
addon:Print(L["|cffff8080%s is still owed|r -- %s."]:format(name, why))
elseif ns.IsPlayerName(name) then
-- A press on yourself: your name in the third person reads as somebody
-- else who shares it. Asked before `unknown`, which names the person.
addon:Print(unknown and unknownSelf or L["|cffff8080you were not buffed|r -- %s."]:format(why))
elseif unknown then
addon:Print(unknown:format(name))
else
addon:Print(L["|cffff8080%s was not buffed|r -- %s."]:format(name, why))
end
end
-- The outcome of a click on the panel as well as in chat. Three honest kinds:
-- "cast" is the game naming the person we aimed at, "sent" is our spell going
-- out with the client not saying who got it, and "failed" is reason to believe
-- nothing reached them. Guarded: cosmetic, and must not take the settle with it.
local function ShowOutcome(kind, name, detail)
if not (ns.Prompt and ns.Prompt.ShowOutcome and name) then return end
ns.Guard("prompt outcome", ns.Prompt.ShowOutcome, ns.Prompt, kind, name, detail)
end
-- Did the id the game reported belong to the buff we armed? Every rank and the
-- raid-wide version count. nil (the client would not say) settles: one secret
-- value must not make every favour permanent.
local function SpellIsOurs(spellId, buffKey)
if spellId == nil or not buffKey then return true end
-- One of your class's own (Buffs.lua, VANILLA_OWN): its ranks, or any
-- spell by the name the macro cast, since that is what the game picked
-- the rank by -- a rank the table lacks is still the press's own cast.
local own = ns.FindOwnSpell(buffKey)
-- A mage's scroll by the spell its use casts, which two scrolls can share
-- (Buffs.lua, Spellbreak), so neither the lookup nor the name will do.
if own and own.item then return own.ranks[1] == spellId end
if own then
return ns.OWN_BY_ID[spellId] == own or SpellNameFor(spellId) == ns.BuffName(own)
end
local buff = ns.FindBuff(caps.class, buffKey)
if not buff then return true end
return ns.BUFF_BY_ID[spellId] == buff
end
-- A spell id as somebody reads it; the number where the client will not name it.
local function SpellLabel(spellId)
return SpellNameFor(spellId) or tostring(spellId)
end
-- Nothing reached them, so what the click optimistically wrote is cut back:
-- the whole-person block to two seconds (so the prompt does not march down
-- their list), the per-buff cooldown to the same, and the rotation pointer
-- put back. One owner for all three, or a refused cast walks somebody off
-- their own buff list.
local function RewindClick(pending)
-- Extends only, so a right-press skip written moments earlier outlives it.
ns.BlockPerson(pending.name, 2, true)
-- Not extend-only: cutting back what this click wrote is the point.
ns.MarkAttempted(pending.name, pending.buffKey, 2)
-- The rotation pointer as the click found it (nil for a first one), behind
-- the gate the click went through.
if ns.RotatesBuffs() then ns.lastGave[pending.name] = pending.gave end
-- A group cast blocked everybody it covered; none of them got it either.
if pending.group then
for _, name in ipairs(pending.group.members) do
ns.MarkAttempted(name, pending.buffKey, 2)
end
end
end
-- The first-name fallback is gone on purpose (STATUS.md). It switched a
-- macro to `/target <first name>` after failed casts, but the full name was
-- confirmed to resolve in game, and its failure mode is the worst on offer: a
-- cast and a spoken line aimed at another player sharing a first name. A cast
-- that does not land is now reported instead. CHANGELOG 1.3.0 asks for both
-- spellings; do not rebuild it from there.
-- Retiring a record whose window has run out, wherever that is noticed. One
-- owner, so a record is never discarded without rewinding what the click
-- wrote. What the user is told is the caller's (`why`): the settle path holds
-- a late cast event, proof a spell went out, just not this press's. Only
-- SweepPendingClick, watching the window run out, writes the panel.
local function ExpirePendingClick(pending, why)
ns.pendingClick = nil
-- An error inside the window already rewound this record and said so;
-- RewindClick writes from now, so running it twice doubles the block.
if pending.answered then return end
RewindClick(pending)
-- Nothing reached them, so a spoken line the macro carried (and ran
-- anyway) is held; a silent press holds nothing, or the press that lands
-- once they are back would go out silent too. No back-off, since nothing
-- here says the game refused them.
ns.NoteRefusal(pending.name, nil, true, pending.spoke)
SayStillOwed(pending.name, why or L["the game answered that press with nothing at all"])
end
-- A second press while the first is still waiting for the game. There is one
-- slot, and the next cast event would be judged against the newest record, so
-- the first is abandoned: what it did is unknown, not failed, so what it wrote
-- is put back and the debt stays. Wrong that way costs one extra offer; wrong
-- the other way loses the favour.
local function AbandonPendingClick()
local pending = ns.pendingClick
if not pending then return end
-- Answered already, by an error that rewound it and said so.
if pending.answered then
ns.pendingClick = nil
return
end
-- Past its window the outcome is known, and only the tick has not come
-- round yet; the slot is about to be reused, so it is retired now.
if GetTime() - pending.at > SETTLE_SECONDS then
ExpirePendingClick(pending)
return
end
ns.pendingClick = nil
SayStillOwed(pending.name, L["another press arrived before the game answered that one"],
L["no answer yet for the press on |cffffffff%s|r -- another press arrived first."],
L["no answer yet for the press on yourself -- another press arrived first."])
RewindClick(pending)
end
ns.AbandonPendingClick = AbandonPendingClick
-- An error the game raised just after a click: evidence something failed,
-- none about what (full bags and all the rest), so it takes back what the
-- click wrote and no more. The record stays parked: a cast going out after
-- all still settles it. Returns the name it rewound, for the panel; nothing
-- without a live click parked, or for a second error.
local function FailPendingClick(message)
local pending = ns.pendingClick
if not pending then return nil end
-- Past its window this error cannot be about that click, but the record
-- still needs retiring; the panel gets nothing.
if GetTime() - pending.at > SETTLE_SECONDS then
ExpirePendingClick(pending)
return nil
end
if pending.answered then return nil end
pending.answered = true
-- The game's sentence brings its own full stop, and this line adds one.
SayStillOwed(pending.name, type(message) == "string"
and L["the game said: %s"]:format((message:gsub("%.$", ""))) or L["the game refused it"])
RewindClick(pending)
ns.NoteRefusal(pending.name, message, nil, pending.spoke)
return pending.name
end
-- The clock running out on a parked click: a record that sat the whole window
-- saw no cast event, the one proof that nothing went out. Swept on the tick,
-- because in this case there is no next event.
local function SweepPendingClick(now)
local pending = ns.pendingClick
if not pending then return end
if now - pending.at <= SETTLE_SECONDS then return end
ExpirePendingClick(pending)
-- An error answered this press inside its window, and the panel flashed
-- the game's own words then.
if pending.answered then return end
-- The panel only from here: this is the one path that notices the window
-- running out as it happens. The other callers find the record long dead,
-- with the panel moved on (or mid-arming, inside PostClick).
ShowOutcome("failed", pending.name, L["nothing was cast"])
end
ns.SweepPendingClick = SweepPendingClick
-- What an inferred settle is inferring, read by both the chat line and the
-- panel so they make the same claim. `said` finishes "X counted as repaid --
-- " and is handed the spell's name; `sub` goes under the name on the prompt.
-- A confirmed settle (the client naming the person) has nothing to qualify.
local SETTLE_INFERENCE = {
targeted = {
said = L["our spell went out and the macro aimed at them, but this client would not say who received it"],
sub = L["cast -- this client will not confirm who to"],
},
selfcast = {
said = L["%s is cast on you, not on them, so whether it reached them depends on where they were standing"],
sub = L["cast -- it has no target, so nothing says it reached them"],
},
}
-- Settled casts still inside the window in which the server may refuse them,
-- oldest first. See UnsettleLateRefusal.
local settledRecent = {}
-- A refusal is matched to the cast it answers by the cast guid, and only by
-- it: UNIT_SPELLCAST_SENT carries it third, UNIT_SPELLCAST_FAILED second. A
-- spell id cannot tell a new attempt (a mashed press, the same buff from an
-- action bar) from the answer to one that landed, so no guid is no evidence,
-- and no evidence means no action. Whether this client fills the guid in is
-- untested (STATUS.md); if not, a real late refusal goes unnoticed and the
-- favour stays repaid -- quieter, never a false sentence.
local function PruneSettled(now)
now = now or GetTime()
for i = #settledRecent, 1, -1 do
local record = settledRecent[i]
if now - record.at > SETTLE_SECONDS then
table.remove(settledRecent, i)
-- Nothing refused it inside the window, so it landed, and whatever the
-- game refused on this person before is over. Not at the settle
-- itself: SENT is the client sending, and the refusal that repeats
-- comes after it.
if record.landed then ns.NoteLanded(record.name) end
end
end
end
ns.PruneSettled = PruneSettled
local function RememberSettled(record)
PruneSettled(record.at)
settledRecent[#settledRecent + 1] = record
end
-- A mage's scroll or a weapon enhancement (Buffs.lua) is the one press on
-- yourself with a cast time: every scroll's use takes three seconds
-- (SpellMisc's CastingTimeIndex 14), and so does a stone's, an oil's and a
-- poison's, a lure's five (index 6; two on Mists), where your other own buffs
-- are instant. SENT settles it as the cast starts, so a cast cut short --
-- moving, most often -- has used nothing up, and only INTERRUPTED (or a FAILED
-- after the settle's window) says so. The press is kept here until the cast
-- succeeds or stops, or for its length, pushback and latency at most: the item,
-- the settle's record, its cast, and the press it replaced (Core.lua,
-- NoteScrollPress). Six seconds for a scroll; eight for an enhancement, whose
-- longest use is the lure's five.
local SCROLL_SECONDS = 6
local ENHANCE_SECONDS = 8
local castingScroll
-- The scroll being cast, if this event is about it: by the cast guid where
-- both sides carry one, else by the spell its use casts (nil `spellId` asks
-- for the guid alone). One cast runs at a time, so an interrupt or a success
-- with the scroll's spell is its. A cast past its length is let go.
local function ScrollCast(castGUID, spellId)
local cast = castingScroll
if not cast then return nil end
if GetTime() - cast.record.at > cast.seconds then
castingScroll = nil
return nil
end
if cast.castGUID ~= nil and castGUID ~= nil then
if cast.castGUID ~= castGUID then return nil end
elseif spellId == nil or spellId ~= cast.spellId then
return nil
end
return cast
end
-- The scroll's cast cut short: the press is taken back as a refused one is
-- (RewindClick), and the scroll is offered again once that block is out.
-- Out of the settled list too, which would note it as landed. Returns the
-- name, for the panel.
local function ScrollCastStopped(castGUID, spellId, why)
local cast = ScrollCast(castGUID, spellId)
if not cast then return nil end
castingScroll = nil
for i, record in ipairs(settledRecent) do
if record == cast.record then
table.remove(settledRecent, i)
break
end
end
RewindClick(cast.record)
ns.UndoScrollPress(cast.spell, cast.before)
SayStillOwed(cast.record.name, why)
return cast.record.name
end
-- Which record a refusal answers, or nil for "nothing here says". A record
-- that settled with no guid of its own never compares equal to one, so a guid
-- on the refusal side alone matches nothing either.
local function MatchSettled(castGUID)
if castGUID == nil then return nil end
for i, record in ipairs(settledRecent) do
-- Both sides named the cast. That is an answer, not a guess, and a
-- guid naming none of ours means the failure was not ours at all.
if record.castGUID == castGUID then return i end
end
return nil
end
-- Everybody else one cast reached (`names`), on the same evidence as the
-- person it was aimed at: a favour any of them did you is returned by it, the
-- ledger filing each with `covered`, whatever they asked for is answered, and
-- they wait out the cooldown. Returns what a late refusal needs to undo it,
-- and the names repaid, for the line.
local function SettleMembers(names, pending, covered, spellId)
local records, repaid = {}, {}
for _, name in ipairs(names) do
local debt = owed[name]
ns.MarkAttempted(name, pending.buffKey)
if debt then
ns.SettleFavour(name)
TellLedger("Settled", name, debt, covered, spellId)
repaid[#repaid + 1] = ns.ShortName(name)
end
ns.ServeRequest(name, pending.buffKey)
records[#records + 1] = { name = name, owed = debt,
listedAtSettle = debt ~= nil and ListedAs(name) ~= nil }
end
return records, repaid
end
local function SayAlsoRepaid(spell, repaid)
local db = addon.db and addon.db.profile
if #repaid > 0 and db and db.verbose then
addon:Print(L["|cffffd100%s|r returned the favour to %s as well."]:format(
spell, table.concat(repaid, ", ")))
end
end
-- Everybody else a group cast covered (SettleMembers). The favours go to the
-- ledger, and the cast itself once, as one buff given: under the anchor
-- normally, but where the anchor's own favour took that row (`anchorOwed`),
-- under the first it reached who was owed nothing -- or who was picked as the
-- anchor would decide whether a group cast counts. Returns what a late
-- refusal needs to undo it: the members, and whom the given row is under.
local function SettleGroup(pending, spellId, anchorOwed)
local covered = { buffKey = pending.buffKey, group = pending.group, inGroup = true }
local records, repaid = SettleMembers(pending.group.members, pending, covered, spellId)
local givenAs
if anchorOwed then
for _, record in ipairs(records) do
-- Never under your own name: the cast covered you too, but a buff
-- on yourself is no gift to anybody (see SettleSelf).
if not record.owed and not ns.IsPlayerName(record.name) then
givenAs = record.name
TellLedger("Settled", givenAs, nil, covered, spellId)
break
end
end
end
SayAlsoRepaid(SpellLabel(pending.group.spell), repaid)
return records, givenAs
end
-- Everybody else a shout reached: the party members the press's scan measured
-- inside its reach (Prompt/Press.lua). A shout lands on the whole party,
-- so it returns every favour in earshot -- left owed, they were offered a
-- second shout, then let go as run out. Each goes to the ledger as a plain
-- return; one shout is one cast, under the anchor. Returns what a late
-- refusal needs to undo it, as SettleGroup does. `names`, when given, is
-- everybody else a cast on one of them reached on Mists and retail
-- (pending.wideMembers), settled the same way.
local function SettleShout(pending, spellId, names)
local records, repaid = SettleMembers(names or pending.shoutMembers, pending,
{ buffKey = pending.buffKey, inGroup = true }, spellId)
local buff = pending.buffKey and ns.FindBuff(caps.class, pending.buffKey)
SayAlsoRepaid((spellId and SpellNameFor(spellId)) or (buff and ns.BuffName(buff)) or L["the spell"],
repaid)
return records
end
-- A late refusal of a group cast or a shout: the favours it returned are owed
-- again, as the anchor's is, and the ledger takes back each by the settle's
-- clock.
local function UnsettleGroup(settled)
-- The cast's own given row, where it was filed under one of them.
if settled.givenAs then TellLedger("Refused", settled.givenAs, settled.at) end
for _, member in ipairs(settled.members or {}) do
-- A shout's members were put on the retry cooldown by its settle, not
-- by the press, so RewindClick (which knows a group cast's) misses them.
if not settled.group then ns.MarkAttempted(member.name, settled.buffKey, 2) end
if member.owed then
TellLedger("Refused", member.name, settled.at)
if not member.listedAtSettle and ListedAs(member.name) then
-- Listed since: let go, as the anchor's is (see below).
TellLedger("LetGo", member.name, "never")
else
local standing = owed[member.name]
if not standing or LiveExpiry(standing) < LiveExpiry(member.owed) then
owed[member.name] = member.owed
end
end
end
end
SaveDebts()
end
-- A press on yourself ([@player]): nobody else can have been reached, so our
-- spell going out is the whole answer. A buff on yourself is no favour and no
-- gift, so the ledger (and with it the milestones and In character's memory),
-- the debts and the requests are not told; the retry cooldown and the rotation
-- pointer are. Kept for a late refusal like any settle, which backs you off as
-- it would anybody.
local function SettleSelf(pending, spellId, castGUID)
ns.pendingClick = nil
if not SpellIsOurs(spellId, pending.buffKey) then
local why = L["|cffffffff%s|r went out instead"]:format(SpellLabel(spellId))
SayStillOwed(pending.name, why)
RewindClick(pending)
ShowOutcome("failed", pending.name, (why:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")))
return
end
ShowOutcome("cast", pending.name)
ns.MarkAttempted(pending.name, pending.buffKey)
-- Behind the same gate as everybody's: a paladin's walk never reads it.
if ns.RotatesBuffs() and pending.buffKey then ns.lastGave[pending.name] = pending.buffKey end
-- An error inside the window told chat you were not buffed
-- (FailPendingClick); the cast went out after it, so that is taken back,
-- as it is for anybody, or chat and the panel disagree. Only with
-- verbose, where it was said.
if pending.answered then
local db = addon.db and addon.db.profile
if db and db.verbose then
addon:Print(L["|cffffd100you were buffed after all|r -- the error before it was about something else."])
end
end
local record = { name = pending.name, buffKey = pending.buffKey,
gave = pending.gave, at = GetTime(), castGUID = castGUID,
landed = true, onSelf = true }
RememberSettled(record)
-- A scroll: its cast has only begun (castingScroll), and which one it is
-- tells two scrolls making the same imbue apart (Core.lua, ImbueScroll).
local own = ns.FindOwnSpell(pending.buffKey)
if own and own.item then
castingScroll = { record = record, spell = own, castGUID = castGUID, spellId = spellId,
before = ns.NoteScrollPress(own),
seconds = own.family.enhance and ENHANCE_SECONDS or SCROLL_SECONDS }
end
end
local function SettlePendingClick(landedOn, spellId, castGUID)
local pending = ns.pendingClick
if not pending then return end
-- A cast with no unit to aim at (on Forever the mage's Arcane Brilliance, the
-- priest's Prayers and the druid's Gift of the Wild: target 56, the caster's
-- raid) has no name to report, and the event's target is a cstring that cannot
-- be nil, so it reads "". That names nobody: it is "the client would not say",
-- as a withheld name is, not a person the cast went to.
if landedOn == "" then landedOn = nil end
-- Past the window this cast belongs to something else (an accepted cast
-- answers in the same frame), so the record is retired, not settled -- with
-- its own sentence, since something was cast, just too late to be this.
if GetTime() - pending.at > SETTLE_SECONDS then
ExpirePendingClick(pending,
L["the game never answered that press, and this cast came too late to be its answer"])
return
end
-- Inside the window, but too late to be this press's answer (SENT_SECONDS,
-- or the player's own queue window where that is wider -- a press queued
-- that far out casts when the cooldown ends): no evidence either way, so
-- the sweep still owns the record.
local late = math.min(SETTLE_SECONDS, math.max(SENT_SECONDS, ns.SpellQueueWindow() + 0.1))
if GetTime() - pending.at > late then return end
if pending.onSelf then return SettleSelf(pending, spellId, castGUID) end
local ours = SpellIsOurs(spellId, pending.buffKey)
-- `why` is the reason this favour is still owed, nil if it is not.
-- `inferred` is nil where the client itself named the person, otherwise
-- which inference carries the settle: the panel and chat say which.
local why, inferred
-- Our shout went out, but nothing measured them inside its reach.
local unheard = false
if pending.selfCast then
-- A selfCast buff's macro has no /target line: the spell lands on the
-- caster and reaches the party from there, so whether our own spell
-- went out is the only thing to check. (This is every repayment a
-- warrior can make.)
if not ours then
why = L["|cffffffff%s|r went out instead"]:format(SpellLabel(spellId))
else
-- Inferred, never confirmed: nothing ties the shout to the person
-- but where they were standing. Where no signal measured them within
-- reach, the press counts but the favour is kept.
inferred = "selfcast"
unheard = not pending.withinShout
end
-- A /target for a name the game cannot resolve is a no-op, and the cast
-- goes to whoever was already targeted. Four spellings are accepted as the
-- person: the one the macro aimed at, the key, the bare first name and
-- the name without a cross-realm suffix.
elseif landedOn and landedOn ~= pending.aimedAt
and landedOn ~= pending.name
and landedOn ~= (ns.FirstName and ns.FirstName(pending.name))
and landedOn ~= (ns.ShortName and ns.ShortName(pending.name)) then
-- Somebody else got it: our /target did nothing.
why = L["it went to |cffffffff%s|r"]:format(tostring(landedOn))
elseif not ours then
-- Right person, wrong spell: anything else on a bar can beat the
-- macro's own /cast to the click.
why = L["|cffffffff%s|r went out instead"]:format(SpellLabel(spellId))
elseif landedOn then
-- Our spell, and the client named the person we aimed at: the one
-- confirmed settle, empty on purpose (`why` and `inferred` both nil).
elseif pending.targeted then
-- Our spell, and the client would not say who got it, which is the
-- ordinary answer here. The macro's /target of ours at this person,
-- recorded at press time, carries the inference; the verbose line
-- says it is one.
inferred = "targeted"
else
-- Our spell went out to nobody known, and the macro aimed at nobody (a
-- /manners try template): nothing ties the press to the person.
why = L["this client would not say who received it, and the macro aimed at nobody"]
end
if why then
SayStillOwed(pending.name, why)
RewindClick(pending)
-- The macro's line thanked them for a buff that went elsewhere or never
-- went, so it is held (a silent press held nothing); no back-off,
-- since the game refused nobody.
ns.NoteRefusal(pending.name, nil, true, pending.spoke)
ns.pendingClick = nil
-- The chat line's sentence, without colour codes: the sub-line is
-- already tinted, and a nested one renders as literal text.
ShowOutcome("failed", pending.name, (why:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")))
return
end
-- Read before SettleFavour clears it: an undo puts back the entry that
-- stood, including when the favour was done, which the grace window reads.
local wasOwed = owed[pending.name]
if unheard and wasOwed and pending.outOfShout then
SayStillOwed(pending.name, L["the shout went out, but they were too far away to hear it"])
elseif unheard and wasOwed then
SayStillOwed(pending.name, L["the shout went out, but nothing could tell whether they were close enough to hear it"])
elseif inferred and wasOwed then
local db = addon.db and addon.db.profile
if db and db.verbose then
local buff = pending.buffKey and ns.FindBuff(caps.class, pending.buffKey)
addon:Print(L["|cffffd100%s counted as repaid|r -- %s."]:format(pending.name,
SETTLE_INFERENCE[inferred].said:format(buff and ns.BuffName(buff) or L["the spell"])))
end
elseif pending.answered then
-- An error inside the window said this person was not buffed; this cast went
-- out after it, so that is taken back (with verbose, where it was said).
local db = addon.db and addon.db.profile
if db and db.verbose then
local line = wasOwed and L["|cffffd100%s counted as repaid after all|r -- the error before it was about something else."]
or inferred and L["|cffffd100the spell for %s went out after all|r -- the error before it was about something else."]
or L["|cffffd100%s was buffed after all|r -- the error before it was about something else."]
addon:Print(line:format(pending.name))
end
end
-- "cast" only where the client named the person; "sent" for an inference.
local how = inferred and SETTLE_INFERENCE[inferred]
ShowOutcome(how and "sent" or "cast", pending.name, how and how.sub)
-- Put back what PostClick wrote, which an error inside the window may have
-- rewound; written unconditionally, as what a landed cast leaves behind.
ns.MarkAttempted(pending.name, pending.buffKey)
if pending.buffKey and ns.RotatesBuffs() then
ns.lastGave[pending.name] = pending.buffKey
end
if not unheard then ns.SettleFavour(pending.name) end
if not unheard then ns.ServeRequest(pending.name, pending.buffKey) end
if not unheard then TellLedger("Settled", pending.name, wasOwed, pending, spellId) end
-- A group cast settles everybody else it covered with the same evidence.
-- A shout is never one, so `unheard` cannot be true here.
local members, givenAs
if pending.group then members, givenAs = SettleGroup(pending, spellId, wasOwed ~= nil) end
-- A shout settles everybody else measured inside its reach, even with the
-- one it was aimed at unheard: each was measured on their own. Our shout
-- went out, or `why` above returned.
if pending.selfCast and pending.shoutMembers then members = SettleShout(pending, spellId) end
-- A cast that landed on the whole party and raid (Mists, retail) settles
-- everybody else in it the press's scan offered it, as a shout does.
if pending.wideMembers then members = SettleShout(pending, spellId, pending.wideMembers) end
-- The client sent the cast; the server has not answered yet. Keep the
-- record so a refusal arriving a moment from now has something to be about.
-- Whether they were on the never-offer list already, which a favour owed
-- ignores (STATUS.md); only a listing after the settle lets it go.
RememberSettled({ name = pending.name, buffKey = pending.buffKey,
gave = pending.gave, at = GetTime(), owed = wasOwed, castGUID = castGUID,
listedAtSettle = ListedAs(pending.name) ~= nil, spoke = pending.spoke,
-- A shout nothing measured them inside of is not a cast on them.
landed = not unheard,
group = pending.group, members = members, givenAs = givenAs })
ns.pendingClick = nil
end
-- A refusal arriving after the settle let the record go: SENT is the client
-- sending, not the server taking it, and out of range or line of sight come
-- back a moment later, so the record is kept SETTLE_SECONDS. Driven by
-- UNIT_SPELLCAST_FAILED alone: UI_ERROR_MESSAGE carries no spell id. Returns
-- the name, for the panel.
local function UnsettleLateRefusal(castGUID)
PruneSettled()
-- No match is no evidence about a cast that settled, and does nothing.
local index = MatchSettled(castGUID)
if not index then return nil end
-- Consumed before anything is undone with it: one refusal, one cast. A
-- scroll's cast is over with it, and so is the press it noted.
local settled = table.remove(settledRecent, index)
if castingScroll and castingScroll.record == settled then
ns.UndoScrollPress(castingScroll.spell, castingScroll.before)
castingScroll = nil
end
-- Nothing for a switched-off addon. Only `enabled`: the owed source
-- decides only whether there was a debt, and settled.owed says that.
local db = addon.db and addon.db.profile
if not db or not db.enabled then return nil end
-- Everybody else a group cast covered, whichever way the anchor goes
-- below; RewindClick puts back their cooldowns with the anchor's.
if settled.members then UnsettleGroup(settled) end
-- A shift-right-click since the settle let this favour go, and a refusal
-- must not bring it back: owed people are exempt from the list. The row
-- goes from returned back to let go, and nothing claims a debt still kept.
local listed = settled.owed and not settled.listedAtSettle and ListedAs(settled.name)
if listed then
TellLedger("Refused", settled.name, settled.at)
TellLedger("LetGo", settled.name, "never")
RewindClick(settled)
return settled.name
end
-- Written to disk, as SettleFavour's clearing was. A newer favour filed
-- since is kept if it lasts longer: the refusal only means you still owe.
if settled.owed then
local standing = owed[settled.name]
if not standing or LiveExpiry(standing) < LiveExpiry(settled.owed) then
owed[settled.name] = settled.owed
end
SaveDebts()
end
TellLedger("Refused", settled.name, settled.at)
RewindClick(settled)
-- This is the refusal that repeats for somebody the game will never let
-- you buff (beta.8): every press settles on SENT and comes back here.
ns.NoteRefusal(settled.name, nil, nil, settled.spoke)
SayStillOwed(settled.name, L["the game refused the cast after sending it"])
return settled.name
end
-- The global cooldown, read where the client will say and tracked where not.
-- On the retail line Forever descends from, spell 61304 IS the global
-- cooldown for every class, and addons on Forever read it (EnhanceQoL's
-- GCD bar); Classic Era and Burning Crusade Classic have no such spell, and
-- GlobalCooldownReading reads one of our buffs instead. It may be withheld, in
-- a fight most of all, so the fallback tracks it: once a cast is sent, nothing
-- else casts for about 1.5 seconds.
local GCD_FALLBACK = 1.5
local GCD_SPELL = 61304
local castBlockedUntil = 0
-- When the tracked block began, for the prompt's sweep where the client
-- gives no figure.
local castBlockedFrom = 0
-- The spell whose cast began the tracked block, for SPELL_UPDATE_COOLDOWN to ask
-- whether it is on the global cooldown at all. nil once the client has said.
local castBlockedBy
local function NoteCastWentOut(spellId)
local now = GetTime()
local seconds = GCD_FALLBACK
-- C_Spell.GetSpellCooldown answers with a table, whose duration for an
-- instant buff IS the global cooldown -- used only when readable and sane,
-- since a secret or a zero would unblock the button at once. Its isOnGCD is
-- not read here: the client documents the field as not to be trusted outside
-- SPELL_UPDATE_COOLDOWN, and a cast that has only been sent has not started
-- its cooldown yet, so a false now says nothing (and out of a fight it would
-- leave a press 0.3 s on to go straight into a running cooldown). Every cast
-- arms the guess; DropGuessOffGcd takes it back once the client can say.
local get = C_Spell and C_Spell.GetSpellCooldown
if get and spellId then
local ok, info = pcall(get, spellId)
if ok and type(info) == "table" then
local duration = plain(info.duration)
if type(duration) == "number" and duration > 0 and duration <= 3 then
seconds = duration
end
end
end
-- Extended, never shortened: a second cast event inside a running cooldown
-- can report a smaller figure of its own.
if now + seconds > castBlockedUntil then
castBlockedUntil = now + seconds
castBlockedFrom = now
castBlockedBy = spellId
end
end
-- The guess taken back for a cast the client says is off the global cooldown (a
-- healthstone, a potion, a trinket): SPELL_UPDATE_COOLDOWN is the one moment
-- its isOnGCD is to be trusted, and it is the only word on the matter where the
-- client's own figure is withheld (a fight on Forever: the start and length are
-- secret, the flag is not). A false drops the guess; a true keeps it, and the
-- client is not asked again; nil, the client saying nothing, keeps it and asks
-- at the next event. A spell with a cooldown of its own that is also on the
-- global one reads false as well once its own begins, and nothing can tell
-- that from a spell off it: the guess goes, as it did when SENT read a false.
local function DropGuessOffGcd(now)
local spellId = castBlockedBy
if not spellId then return end
-- Run out already, nothing left to take back.
if castBlockedUntil <= now then
castBlockedBy = nil
return
end
local get = C_Spell and C_Spell.GetSpellCooldown
if not get then return end
local ok, info = pcall(get, spellId)
if not ok or type(info) ~= "table" then return end
local onGcd = plain(info.isOnGCD)
if onGcd == nil then return end
castBlockedBy = nil
if onGcd == false then
castBlockedUntil, castBlockedFrom = 0, 0
end
end
-- How long before the global cooldown ends the client will accept a /cast and
-- hold it, casting on its own when the cooldown runs out: a press in that
-- window lands. The client's own setting, since players tune it; 400 ms is
-- the default on the retail line Forever descends from.
function ns.SpellQueueWindow()
local get = _G.GetCVar
if type(get) == "function" then
local ok, value = pcall(get, "SpellQueueWindow")
value = ok and tonumber(plain(value)) or nil
if value and value >= 0 and value <= 1000 then return value / 1000 end
end
return 0.4
end
-- A spell's cooldown by the client's own figure, as a start and a length, or
-- nil where it gives none. An idle spell reads a zero start and duration: a
-- readable 0.
local function CooldownReading(spell)
local get = C_Spell and C_Spell.GetSpellCooldown
if not get then return nil end
local ok, info = pcall(get, spell)
if not ok or type(info) ~= "table" then return nil end
local start, duration = plain(info.startTime), plain(info.duration)
if type(start) ~= "number" or type(duration) ~= "number" then return nil end
return start, duration
end
-- What is left of a spell's cooldown by that figure, or nil where it gives none.
local function CooldownLeft(spell, now)
local start, duration = CooldownReading(spell)
if not start then return nil end
local left = start + duration - now
if left < 0 then left = 0 end
return left
end
-- The global cooldown by the client's own figure, as a start and a length, or
-- nil where it gives none. Spell 61304 stands for it where the client has the
-- spell. Classic Era and Burning Crusade Classic have no row for it in their
-- spell data: there the global cooldown is a property of each spell, and the
-- client's own action bars draw it from the spell's own cooldown. So one of our
-- buffs stands in, the first the player knows with a reading no longer than a
-- global cooldown (3 s, as NoteCastWentOut bounds it): none of them has a
-- cooldown of its own, and a longer one, a spell's own, never passes for the
-- global one. Asked only where 61304 says nothing, so a client that answers for
-- it is read exactly as before.
local function GlobalCooldownReading()
local start, duration = CooldownReading(GCD_SPELL)
if start then return start, duration end
local buffs = caps.buffs
for _, buff in ipairs(caps.class and ns.GetClassBuffs(caps.class) or {}) do
local info = buffs and buffs[buff.key]
local id = info and info.known and info.topRank
if id then
start, duration = CooldownReading(id)
if start and duration <= 3 then return start, duration end
-- A client that gives no figure for one of them gives none for the
-- rest (every figure withheld in a fight), and the sweep asks at every
-- cooldown update: one more read is all it costs there.
if not start then break end
end
end
return nil
end
-- ...and what is left of the global cooldown.
local function GlobalCooldownLeft(now)
local start, duration = GlobalCooldownReading()
if not start then return nil end
local left = start + duration - now
if left < 0 then left = 0 end
return left
end
-- The global cooldown as a start and a length, for the sweep the prompt draws
-- over its icon, or nil when none is running. The same sources as CastReady,
-- so the sweep and the "ready in" line agree about when the button is ready.
local function GcdSpan(now)
local start, duration = GlobalCooldownReading()
if start then
if duration > 0 and start + duration > now then return start, duration end
return nil
end
if castBlockedUntil > now and castBlockedUntil > castBlockedFrom then
return castBlockedFrom, castBlockedUntil - castBlockedFrom
end
return nil
end
function ns.GlobalCooldownSpan(now)
now = now or GetTime()
local start, duration = GcdSpan(now)
-- The player's own cast, read exactly as CastReady reads it.
local casting = _G.UnitCastingInfo
if type(casting) == "function" then
local ok, _, _, _, startMS, endMS = pcall(casting, "player")
if ok then startMS, endMS = plain(startMS), plain(endMS) else startMS, endMS = nil, nil end
if type(endMS) == "number" and endMS / 1000 > now
and (not start or endMS / 1000 > start + duration) then
local from = start or (type(startMS) == "number" and startMS / 1000) or now
return from, endMS / 1000 - from
end
end
return start, duration
end
-- Whether a press right now could reach the server, and how long until it
-- could, so the prompt can say so. A spell with a cast time (a conjure, a
-- Hearthstone) blocks a /cast past the global cooldown, so the player's own
-- cast is read too. Channels are left out: a new cast interrupts one.
--
-- `spell`, an id, adds that spell's own cooldown: the press asks it only to
-- decide the spoken line (Prompt/Press.lua), since a /cast of a spell still
-- cooling down reaches the server and is refused there.
function ns.CastReady(spell)
local now = GetTime()
-- The client's figure where it gives one, in place of the tracked guess
-- (which a cast off the global cooldown arms wrongly), not beside it.
local left = GlobalCooldownLeft(now) or (castBlockedUntil - now)
local casting = _G.UnitCastingInfo
if type(casting) == "function" then
local ok, _, _, _, _, endMS = pcall(casting, "player")
if ok then endMS = plain(endMS) else endMS = nil end
if type(endMS) == "number" and endMS / 1000 - now > left then
left = endMS / 1000 - now
end
end
if spell then
local own = CooldownLeft(spell, now)
if own and own > left then left = own end
end
if left <= 0 then return true, 0 end
return false, left
end
function addon:UNIT_SPELLCAST_SENT(_, unit, target, castGUID, spellId)
if unit ~= "player" then return end
NoteCastWentOut(plain(spellId))
-- The sweep over the prompt's icon starts with the cooldown this cast
-- began, whichever button sent it.
if ns.Prompt and ns.Prompt.SyncCooldown then
ns.Guard("cooldown sweep", ns.Prompt.SyncCooldown, ns.Prompt)
end
SettlePendingClick(plain(target), plain(spellId), plain(castGUID))
if not self.db.profile.debugClicks then return end
self:Print(("|cff80ff80" .. L["CAST SENT %s -> %s"] .. "|r"):format(
tostring(plain(spellId)), tostring(plain(target))))
end
function addon:UNIT_SPELLCAST_SUCCEEDED(_, unit, castGUID, spellId)
if unit ~= "player" then return end
-- A scroll's cast done: the scroll is used, and nothing can stop it now.
if castingScroll and ScrollCast(plain(castGUID), plain(spellId)) then castingScroll = nil end
-- Again here, for a cast with a cast time: its global cooldown is running
-- by now, and the client's figure for it is the one to draw.
if ns.Prompt and ns.Prompt.SyncCooldown then
ns.Guard("cooldown sweep", ns.Prompt.SyncCooldown, ns.Prompt)
end
if self.db.profile.debugClicks then
self:Print(L["|cff00ff00CAST OK|r %s"]:format(tostring(plain(spellId))))
end
end
-- The sweep read again, for the three moments the global cooldown or the
-- player's cast changes without a cast going out.
local function SyncSweep()
if ns.Prompt and ns.Prompt.SyncCooldown then
ns.Guard("cooldown sweep", ns.Prompt.SyncCooldown, ns.Prompt)
end
end
-- A cast with a cast time: UnitCastingInfo may not say anything yet at SENT,
-- so the sweep is read again once the cast has started.
function addon:UNIT_SPELLCAST_START(_, unit)
if unit ~= "player" then return end
SyncSweep()
end
-- A cast stopped part-way: whatever it held up is over. A scroll's cast
-- (castingScroll) used nothing up, so its press is taken back.
function addon:UNIT_SPELLCAST_INTERRUPTED(_, unit, castGUID, spellId)
if unit ~= "player" then return end
SyncSweep()
if not castingScroll then return end
local stopped = ScrollCastStopped(plain(castGUID), plain(spellId), L["the cast was interrupted"])
if stopped then ShowOutcome("failed", stopped, L["the cast was interrupted"]) end
end
-- Pushback: being hit while casting moves the cast's end later, and only this
-- event says so.
function addon:UNIT_SPELLCAST_DELAYED(_, unit)
if unit ~= "player" then return end
SyncSweep()
end
-- The client's own word that the cooldowns changed: whether the cast that armed
-- the guess is on the global cooldown at all, and then the sweep, drawn from
-- what that leaves.
function addon:SPELL_UPDATE_COOLDOWN()
DropGuessOffGcd(GetTime())
SyncSweep()
end
function addon:UNIT_SPELLCAST_FAILED(_, unit, castGUID, spellId)
if unit ~= "player" then return end
-- First: a refusal after SENT has the client take back the global
-- cooldown the sweep is drawn from.
SyncSweep()
spellId = plain(spellId)
castGUID = plain(castGUID)
-- The server refusing a cast the client already reported sending. Only
-- with no record parked: a parked one is UI_ERROR_MESSAGE's to answer. This
-- event names the cast, which is why only it may undo a settle.
if not ns.pendingClick then
local late = UnsettleLateRefusal(castGUID)
if late then ShowOutcome("failed", late, L["the game refused the cast"]) end
end
-- A scroll's cast failing after the settle's window: nothing was used up
-- either. By the guid alone, as a refusal is: the same scroll pressed
-- again from a bar mid-cast fails with its spell id too.
if castingScroll then
local stopped = ScrollCastStopped(castGUID, nil, L["the game refused the cast"])
if stopped then ShowOutcome("failed", stopped, L["the game refused the cast"]) end
end
if self.db.profile.verbose and ns.lastClickTime and (GetTime() - ns.lastClickTime) <= 1 then
self:Print(L["|cffff8080could not cast|r %s"]:format(SpellLabel(spellId)))
end
end
-- Only errors in the moment after our own click, and printed only when asked
-- for: this event carries everything the game raises.
function addon:UI_ERROR_MESSAGE(_, _, message)
message = plain(message)
-- A reason to doubt a click still parked, and nothing more: it carries no
-- spell id, so it never undoes a settle (see UnsettleLateRefusal).
local failed = FailPendingClick(message)
-- After the line above, which hands its refusal the words itself; this is
-- for a refusal that arrived first, with none (UNIT_SPELLCAST_FAILED).
ns.NoteGameError(message)
-- The game's own words go on the panel's sub-line: localised, and often
-- the only thing that says why.