@@ -37,6 +37,13 @@ local DT_EVIDENCE_COOLDOWN_S = 0.5
3737local REJECT_LOG_INTERVAL_S = 0.35
3838local HURT_LOG_INTERVAL_S = 1.0
3939local WATCH_BURST_SCAN_INTERVAL = 4 -- ticks between scans while waiting for post-hit burst
40+ local MAX_HURT_BURST_SCANS_TICK = 10 -- routine cap per game tick (busy fights)
41+ local MAX_HURT_BURST_SCANS_HOT = 14 -- absolute cap; hot shooters may exceed routine budget
42+
43+ local DT_FOCUS_PRIORITY_WATCH = 3 -- post-hit: waiting for simtime burst
44+ local DT_FOCUS_PRIORITY_BURST = 2 -- burst seen: waiting for hurt
45+ local DT_FOCUS_PRIORITY_SUSPECT = 1 -- already has double_tap evidence
46+ local DT_FOCUS_PRIORITY_ROUTINE = 0 -- under cap, background scan
4047
4148local cachedTickInterval = nil
4249local cachedBurstMinTicks = nil
@@ -49,6 +56,15 @@ local burstThisTick = {}
4956local lastBurstCleanTick = 0
5057local lastServerHitchTick = - math.huge
5158
59+ -- One burst-scan ProcessPlayer per game tick; hot suspects preempt the round-robin pool.
60+ local focusTick = - 1
61+ local focusedPlayerId = nil
62+ local hotRoundRobinIndex = 0
63+ local warmRoundRobinIndex = 0
64+ local coldRoundRobinIndex = 0
65+ local hurtBurstScanTick = - 1
66+ local hurtBurstScansUsed = 0
67+
5268local function getBurstThresholds ()
5369 local tickInt = globals .TickInterval ()
5470 if tickInt ~= cachedTickInterval then
@@ -176,6 +192,7 @@ local function getState(id)
176192 lastRejectTag = nil ,
177193 lastRejectLogTime = 0 ,
178194 lastHurtLogTime = 0 ,
195+ lastHurtBurstScanTick = 0 ,
179196 }
180197 end
181198 return playerState [id ]
@@ -227,6 +244,129 @@ local function isWatchingForBurst(id)
227244 return elapsed >= 0 and elapsed <= getConfirmWindowTicks ()
228245end
229246
247+ local function getDtEvidenceWeight (id )
248+ local weight = Evidence .GetMethodWeight (id , " double_tap" )
249+ if weight <= 0 then
250+ weight = Evidence .GetMethodWeight (id , " warp_dt" )
251+ end
252+ return weight
253+ end
254+
255+ local function getDtEvidenceCap ()
256+ local cap = Evidence .GetMethodScoreCap (" double_tap" )
257+ if cap <= 0 then
258+ cap = Evidence .GetMethodScoreCap (" warp_dt" )
259+ end
260+ return cap
261+ end
262+
263+ local function getDtFocusPriority (id )
264+ if not id or id :sub (1 , 4 ) == " BOT_" then
265+ return - 1
266+ end
267+ if isWatchingForBurst (id ) then
268+ return DT_FOCUS_PRIORITY_WATCH
269+ end
270+ local data = getState (id )
271+ if data .lastBurstTick > 0 then
272+ return DT_FOCUS_PRIORITY_BURST
273+ end
274+ local weight = getDtEvidenceWeight (id )
275+ local cap = getDtEvidenceCap ()
276+ if weight >= cap then
277+ return - 1
278+ end
279+ if weight > 0 then
280+ return DT_FOCUS_PRIORITY_SUSPECT
281+ end
282+ return DT_FOCUS_PRIORITY_ROUTINE
283+ end
284+
285+ local function pickRoundRobinId (pool , indexField )
286+ if # pool == 0 then
287+ return nil , indexField
288+ end
289+ local nextIndex = (indexField % # pool ) + 1
290+ return pool [nextIndex ], nextIndex
291+ end
292+
293+ -- Among simultaneous hot shooters, prefer whoever hurt most recently; RR on ties.
294+ local function pickHotPoolFocus (pool , rrIndex )
295+ local bestDamageTick = - 1
296+ for i = 1 , # pool do
297+ local damageTick = getState (pool [i ]).lastDamageTick
298+ if damageTick > bestDamageTick then
299+ bestDamageTick = damageTick
300+ end
301+ end
302+ local tied = {}
303+ for i = 1 , # pool do
304+ if getState (pool [i ]).lastDamageTick == bestDamageTick then
305+ tied [# tied + 1 ] = pool [i ]
306+ end
307+ end
308+ if # tied == 0 then
309+ return pickRoundRobinId (pool , rrIndex )
310+ end
311+ if # tied == 1 then
312+ return tied [1 ], rrIndex
313+ end
314+ return pickRoundRobinId (tied , rrIndex )
315+ end
316+
317+ local function resetHurtBurstScanBudget (curTick )
318+ if hurtBurstScanTick ~= curTick then
319+ hurtBurstScanTick = curTick
320+ hurtBurstScansUsed = 0
321+ end
322+ end
323+
324+ function DoubleTap .BeginDetectionTick (activePlayers , curTick )
325+ if not isEnabled () or focusTick == curTick then
326+ return
327+ end
328+ focusTick = curTick
329+ focusedPlayerId = nil
330+ resetHurtBurstScanBudget (curTick )
331+
332+ if not activePlayers or # activePlayers == 0 then
333+ return
334+ end
335+
336+ local hotPool , warmPool , coldPool = {}, {}, {}
337+ for i = 1 , # activePlayers do
338+ local pState = activePlayers [i ]
339+ local id = pState and pState .id
340+ if not id then
341+ goto continue
342+ end
343+ local priority = getDtFocusPriority (id )
344+ if priority == DT_FOCUS_PRIORITY_WATCH or priority == DT_FOCUS_PRIORITY_BURST then
345+ hotPool [# hotPool + 1 ] = id
346+ elseif priority == DT_FOCUS_PRIORITY_SUSPECT then
347+ warmPool [# warmPool + 1 ] = id
348+ elseif priority == DT_FOCUS_PRIORITY_ROUTINE then
349+ coldPool [# coldPool + 1 ] = id
350+ end
351+ :: continue::
352+ end
353+
354+ local chosenId
355+ if # hotPool > 0 then
356+ chosenId , hotRoundRobinIndex = pickHotPoolFocus (hotPool , hotRoundRobinIndex )
357+ elseif # warmPool > 0 then
358+ chosenId , warmRoundRobinIndex = pickRoundRobinId (warmPool , warmRoundRobinIndex )
359+ elseif # coldPool > 0 then
360+ chosenId , coldRoundRobinIndex = pickRoundRobinId (coldPool , coldRoundRobinIndex )
361+ end
362+
363+ focusedPlayerId = chosenId
364+ end
365+
366+ local function isFocusedPlayer (id )
367+ return focusedPlayerId ~= nil and id == focusedPlayerId
368+ end
369+
230370local function clearStaleBurstState (data , curTick )
231371 if data .lastBurstTick <= 0 then
232372 return
@@ -413,6 +553,9 @@ local function onBurstDetected(id, burstAmount, curTick, burstIndex)
413553end
414554
415555local function shouldScanBurstThisTick (id , curTick )
556+ if isFocusedPlayer (id ) and getDtFocusPriority (id ) >= DT_FOCUS_PRIORITY_BURST then
557+ return true
558+ end
416559 if not isWatchingForBurst (id ) then
417560 return true
418561 end
@@ -451,6 +594,25 @@ local function tryDetectBurstForPlayer(id, curTick, forceScan)
451594 onBurstDetected (id , burstAmount , curTick , burstIndex )
452595end
453596
597+ -- Hurt path: correlate every hit; burst scan at most once per attacker per tick (+ global cap).
598+ local function tryHurtBurstScan (id , curTick )
599+ resetHurtBurstScanBudget (curTick )
600+ local data = getState (id )
601+ if data .lastHurtBurstScanTick == curTick then
602+ return
603+ end
604+ local scanCap = MAX_HURT_BURST_SCANS_TICK
605+ if getDtFocusPriority (id ) >= DT_FOCUS_PRIORITY_WATCH then
606+ scanCap = MAX_HURT_BURST_SCANS_HOT
607+ end
608+ if hurtBurstScansUsed >= scanCap then
609+ return
610+ end
611+ data .lastHurtBurstScanTick = curTick
612+ hurtBurstScansUsed = hurtBurstScansUsed + 1
613+ tryDetectBurstForPlayer (id , curTick , true )
614+ end
615+
454616function DoubleTap .HasWork (playerState )
455617 if not isEnabled () then
456618 return false
@@ -459,21 +621,10 @@ function DoubleTap.HasWork(playerState)
459621 return false
460622 end
461623 local id = playerState .id
462- local weight = Evidence .GetMethodWeight (id , " double_tap" )
463- if weight <= 0 then
464- weight = Evidence .GetMethodWeight (id , " warp_dt" )
465- end
466- local cap = Evidence .GetMethodScoreCap (" double_tap" )
467- if cap <= 0 then
468- cap = Evidence .GetMethodScoreCap (" warp_dt" )
469- end
470- if weight < cap then
471- return true
472- end
473- if isWatchingForBurst (id ) then
474- return (globals .TickCount () % WATCH_BURST_SCAN_INTERVAL ) == 0
624+ if getDtFocusPriority (id ) < 0 then
625+ return false
475626 end
476- return ( globals . TickCount () % 4 ) == 0
627+ return isFocusedPlayer ( id )
477628end
478629
479630function DoubleTap .ProcessPlayer (playerState )
@@ -511,6 +662,7 @@ function DoubleTap.ProcessPlayer(playerState)
511662end
512663
513664function DoubleTap .Tick ()
665+ -- Focus is chosen in BeginDetectionTick (Main.lua) once per game tick.
514666end
515667
516668Events .Subscribe (" OnHitscanHit" , function (hit )
@@ -543,7 +695,7 @@ Events.Subscribe("OnHitscanHit", function(hit)
543695 end
544696
545697 data .lastDamageTick = curTick
546- tryDetectBurstForPlayer (attackerID , curTick , true )
698+ tryHurtBurstScan (attackerID , curTick )
547699
548700 if isDtLogEnabled () then
549701 local now = globals .RealTime ()
559711local function onPlayerGone (id )
560712 playerState [id ] = nil
561713 dtSuppressUntil [id ] = nil
714+ if focusedPlayerId == id then
715+ focusedPlayerId = nil
716+ end
562717end
563718
564719Events .Subscribe (" OnPlayerDisconnect" , onPlayerGone )
0 commit comments