-- @author DoomTero 2026
-- @copyright 2026 DoomTero - ZomboRut original work. ref:516eed4f
-- @license Proprietary. Not licensed for reuse, redistribution or derivative works without written permission from DoomTero. Ask - permission is obtainable. See ZomboRut/LICENSE.txt

ZR_CrowdControl = ZR_CrowdControl or {}

local DEBUG = true
local DEBUG_PROBE = false

local function log(fmt, ...)
	if DEBUG then DebugLog.log(string.format("[ZR Crowd] " .. fmt, ...)) end
end

local function modEnabled()
	if not SandboxVars or not SandboxVars.ZomboRut then return true end
	return SandboxVars.ZomboRut.enableMod ~= false
end

local DISPERSE_DURATION_MS = 10000
local REPATH_INTERVAL_MS   = 600
local DISPERSE_RANGE       = 12
local MARCH_STEP           = 10
local MARCH_NUDGE_STEP     = 3

local OUTER_WATCH_RANGE   = 25
local OUTER_CLOSING_MIN   = 0.3

local REACH_CLIPS = {
	Zombie_Idle_Lunge         = -0.35,
	Zombie_Idle_LungeTurn90L  = -0.35,
	Zombie_Idle_LungeTurn90R  = -0.35,
	Zombie_Lunge              =  0.35,
	Zombie_Bite_Start         =  0.35,
	Zombie_Bite_Fail          =  0.35,
	Zombie_Bite_Success       =  0.35,
	Zombie_CrawlLunge_Start   = -0.35,
	Zombie_CrawlLunge_Fail    = -0.35,
	Zombie_CrawlLunge_Success = -0.35,
}

local REACH_LAYER_MAX = 1
local REACH_TRACK_MAX = 8

local REACH_MIN_WEIGHT = 0.55

local REACH_WEIGHT_EPS = 0.08

local REACH_HOLD_MS = 200

local AFTER_HOLD_MS = 700

local AFTERCARE_MS = 15000

local REACH_ARM_MS = 400

local REACH_DROP_RANGE = 3.0

local REACH_REDROP_MS  = 9000
local REACH_MAX_DROPS  = 3

local PUSH_TILES_PER_SEC = 2.5
local PUSH_WINDOW_MS     = 400
local PUSH_MAX_STEP_MS   = 100

local STUCK_SWEEPS     = 8
local MIN_STUCK_SWEEPS = 5

local STUCK_ANCHOR_RADIUS = 0.25

local STUCK_DROP_RANGE = 2.5

local POST_DROP_REPORT_MS = 3000

local PROBE_LOG_MS = 1000

ZR_CrowdControl.active = ZR_CrowdControl.active or {}
ZR_CrowdControl.after  = ZR_CrowdControl.after  or {}

local function fallbackDir(z)
	local id = 0
	pcall(function() id = z:getID() or 0 end)
	local a = (id % 8) * (math.pi / 4)
	return math.cos(a), math.sin(a)
end

local function cannotWalkYet(z, stateName)
	if z.isUseless and z:isUseless() then return true end
	if z.getVariableBoolean and z:getVariableBoolean("alerted") then return true end
	if z.getTarget and z:getTarget() then return true end
	if z.getThumpTarget and z:getThumpTarget() then return true end
	if z.getEatBodyTarget and z:getEatBodyTarget() then return true end
	if z.isBeingGrappled and z:isBeingGrappled() then return true end
	return stateName:find("Climb") ~= nil
end

local function isAlreadyDown(z)
	return (z.isKnockedDown and z:isKnockedDown())
		or (z.isProne and z:isProne())
		or (z.isGettingUp and z:isGettingUp())
end

local function isDownExceptCrawling(z)
	if z.isKnockedDown and z:isKnockedDown() then return true end
	if z.isGettingUp and z:isGettingUp() then return true end
	if z.isCrawling and z:isCrawling() then return false end
	return (z.isProne and z:isProne()) or false
end

local function clearPathPipeline(z)
	if z.Wander then z:Wander() end
	local pfb = z.getPathFindBehavior2 and z:getPathFindBehavior2()
	if pfb and pfb.cancel then pfb:cancel() end
	if z.setPath2 then z:setPath2(nil) end
	if pfb and pfb.reset then pfb:reset() end
end

local function placeMarchOrder(cell, z, zx, zy, dx, dy, step)
	if not (cell and z.pathToLocation) then return false end
	local zz = z:getZ()
	while step >= 1.0 do
		local tx, ty = zx + dx * step, zy + dy * step
		local sq = cell:getGridSquare(tx, ty, zz)
		if sq and sq:isFree(false) then
			z:pathToLocation(math.floor(tx), math.floor(ty), math.floor(zz))
			return true
		end
		step = step * 0.5
	end
	return false
end

local function trackImmobility(state, zid, zx, zy, reanchor, sweepNo)
	state.marchTrack = state.marchTrack or {}
	local t = state.marchTrack[zid]
	if not t then
		state.marchTrack[zid] = { x = zx, y = zy, sweeps = 0, seen = sweepNo }
		return 0
	end
	t.seen = sweepNo
	local dx, dy = zx - t.x, zy - t.y
	if reanchor or (dx * dx + dy * dy) > (STUCK_ANCHOR_RADIUS * STUCK_ANCHOR_RADIUS) then
		t.x, t.y, t.sweeps = zx, zy, 0
		return 0
	end
	t.sweeps = t.sweeps + 1
	return t.sweeps
end

local function pruneTracks(ctx, now, sweepNo)
	if ctx.marchTrack and sweepNo then
		for zid, t in pairs(ctx.marchTrack) do
			if (sweepNo - (t.seen or 0)) > 30 then ctx.marchTrack[zid] = nil end
		end
	end
	if ctx.reach then
		for zid, r in pairs(ctx.reach) do
			if (now - (r.seen or 0)) > 2000 then ctx.reach[zid] = nil end
		end
	end
	if ctx.pushing then
		for zid, p in pairs(ctx.pushing) do
			if now >= p.untilMs then ctx.pushing[zid] = nil end
		end
	end
	if ctx.dropped then
		for zid, rec in pairs(ctx.dropped) do
			if rec.reported and (now - rec.ms) > (REACH_REDROP_MS + 30000) then
				ctx.dropped[zid] = nil
			end
		end
	end
end

local function probeReachClip(z)
	if not z.dbgGetAnimTrackName then return nil, 0, 0, false end
	local bestName, bestW, bestDot = nil, 0, 0
	local sawTrack = false
	for layer = 0, REACH_LAYER_MAX do
		for t = 0, REACH_TRACK_MAX do
			local name = z:dbgGetAnimTrackName(layer, t)
			if not name or name == "" then break end
			if name ~= "!Empty!" then
				sawTrack = true
				local dot = REACH_CLIPS[name]
				if dot then
					local w = 1.0
					if z.dbgGetAnimTrackWeight then
						w = z:dbgGetAnimTrackWeight(layer, t) or 1.0
					end
					if w > bestW then bestName, bestW, bestDot = name, w, dot end
				end
			end
		end
	end
	return bestName, bestW, bestDot, sawTrack
end

local function aimedAt(z, ax, ay, minDot)
	local tx, ty = ax - z:getX(), ay - z:getY()
	local len = math.sqrt(tx * tx + ty * ty)
	if len < 0.01 then return true end
	tx, ty = tx / len, ty / len
	if not (z.getLookDirectionX and z.getLookDirectionY) then return true end
	local lx, ly = z:getLookDirectionX(), z:getLookDirectionY()
	if not lx or not ly then return true end
	return (tx * lx + ty * ly) >= minDot
end

local function reachVetoed(z, aiState)
	if isDownExceptCrawling(z) then return true end
	if z.isBeingGrappled and z:isBeingGrappled() then return true end
	if z.getThumpTarget and z:getThumpTarget() then return true end
	return aiState:find("Climb") ~= nil
end

local function pushAndDrop(z, ctx, zid, ax, ay, now, why)
	local zx, zy = z:getX(), z:getY()
	local dx, dy = zx - ax, zy - ay
	local len = math.sqrt(dx * dx + dy * dy)
	if len < 0.5 then
		dx, dy = fallbackDir(z)
	else
		dx, dy = dx / len, dy / len
	end

	local crawling = (z.isCrawling and z:isCrawling()) or false

	if not crawling then
		if z.setTargetAndCurrentDirection then
			z:setTargetAndCurrentDirection(-dx, -dy)
		end
		if z.knockDown then z:knockDown(false) end
		if z.setPlayerAttackPosition then
			z:setPlayerAttackPosition("FRONT")
		end
	end

	ctx.pushing = ctx.pushing or {}
	ctx.pushing[zid] = { z = z, dx = dx, dy = dy, untilMs = now + PUSH_WINDOW_MS }
	ctx.lastPushMs = now

	ctx.dropped = ctx.dropped or {}
	local prev = ctx.dropped[zid]
	ctx.dropped[zid] = {
		z = z, ms = now, x = zx, y = zy, reported = false, why = why,
		count = (prev and prev.count or 0) + 1,
		crawl = crawling,
	}
	return ctx.dropped[zid].count
end

local function servicePush(ctx, now)
	if not ctx.pushing then return end
	local any = false
	for _ in pairs(ctx.pushing) do any = true end
	if not any then ctx.lastPushMs = now return end
	local dtMs = now - (ctx.lastPushMs or now)
	ctx.lastPushMs = now
	if dtMs < 1 then dtMs = 1 end
	if dtMs > PUSH_MAX_STEP_MS then dtMs = PUSH_MAX_STEP_MS end
	local step = PUSH_TILES_PER_SEC * (dtMs / 1000)
	for zid, p in pairs(ctx.pushing) do
		local keep = false
		pcall(function()
			local z = p.z
			if not z or not (z.isAlive and z:isAlive()) then return end
			if now >= p.untilMs then return end
			if z.isOnFloor and z:isOnFloor() then return end
			if z.isCollidable and not z:isCollidable() then return end
			if z.setImpulsex then z:setImpulsex(p.dx * step) end
			if z.setImpulsey then z:setImpulsey(p.dy * step) end
			keep = true
		end)
		if not keep then ctx.pushing[zid] = nil end
	end
end

local function reportDrops(ctx, player, now, force)
	if not ctx.dropped then return end
	for zid, rec in pairs(ctx.dropped) do
		if not rec.reported and (force or (now - rec.ms) >= POST_DROP_REPORT_MS) then
			rec.reported = true
			pcall(function()
				local z = rec.z
				if not z then return end
				local ddx, ddy = z:getX() - rec.x, z:getY() - rec.y
				log("POST-DROP id=%s why=%s #%d +%.1fs: moved %.2f tiles, d=%.1f, ai=%s act=%s clip=%s",
					tostring(zid), tostring(rec.why), rec.count or 1,
					(now - rec.ms) / 1000,
					math.sqrt(ddx * ddx + ddy * ddy),
					z:DistTo(player),
					z.getCurrentStateName and z:getCurrentStateName() or "?",
					z.getActionStateName and z:getActionStateName() or "?",
					(probeReachClip(z)) or "-")
			end)
		end
	end
end

local function poseCheck(z, zid, player, ctx, now, px, py, pz, distP)
	local reach = ctx.reach
	if now < (ctx.armAtMs or 0) then reach[zid] = nil return end

	local zx, zy = z:getX(), z:getY()
	local centerDist = math.abs(zx - (ctx.cx or px)) + math.abs(zy - (ctx.cy or py))
	local nearDist = distP
	local aimX, aimY = px, py
	if centerDist < nearDist then
		nearDist = centerDist
		aimX, aimY = (ctx.cx or px), (ctx.cy or py)
	end
	if nearDist > REACH_DROP_RANGE or math.abs(z:getZ() - pz) >= 0.5 then
		reach[zid] = nil
		return
	end

	local rec = ctx.dropped[zid]
	if rec then
		if (rec.count or 1) >= REACH_MAX_DROPS then reach[zid] = nil return end
		if (now - rec.ms) < REACH_REDROP_MS then reach[zid] = nil return end
	end

	if z.getTarget and z:getTarget() ~= nil then reach[zid] = nil return end

	local ai = z.getCurrentStateName and z:getCurrentStateName() or ""
	if reachVetoed(z, ai) then reach[zid] = nil return end

	local clip, w, minDot, sawTrack = probeReachClip(z)

	if DEBUG_PROBE then
		local r0 = reach[zid]
		if not r0 or (now - (r0.loggedMs or 0)) >= PROBE_LOG_MS then
			pcall(function()
				log("probe id=%s d=%.1f c=%.1f clip=%s w=%.2f ai=%s act=%s anm=%s tracks=%s",
					tostring(zid), distP, centerDist, tostring(clip), w, ai,
					z.getActionStateName and z:getActionStateName() or "?",
					z.getAnimationStateName and z:getAnimationStateName() or "?",
					tostring(sawTrack))
			end)
			if r0 then r0.loggedMs = now end
		end
	end

	if not clip then
		if not sawTrack then ctx.probeBlind = (ctx.probeBlind or 0) + 1 end
		reach[zid] = nil
		return
	end

	if w < REACH_MIN_WEIGHT or not aimedAt(z, aimX, aimY, minDot) then
		reach[zid] = nil
		return
	end

	local r = reach[zid]
	if not r or r.clip ~= clip then
		reach[zid] = { sinceMs = now, seen = now, clip = clip, wPeak = w, loggedMs = now }
		return
	end
	r.seen = now
	if w < (r.wPeak - REACH_WEIGHT_EPS) then
		reach[zid] = nil
		return
	end
	if w > r.wPeak then r.wPeak = w end
	if (now - r.sinceMs) < (ctx.holdMs or REACH_HOLD_MS) then return end

	local act = z.getActionStateName and z:getActionStateName() or "?"
	local held = now - r.sinceMs
	reach[zid] = nil
	local n = pushAndDrop(z, ctx, zid, aimX, aimY, now, ctx.tag or "reach")
	log("REACH-DROP id=%s #%d d=%.1f c=%.1f clip=%s w=%.2f held=%dms ai=%s act=%s — %s",
		tostring(zid), n, distP, centerDist, clip, w, held, ai, act,
		(z.isCrawling and z:isCrawling()) and "shoved out (crawler, push-only)"
			or "shoved out + stagger-fall")
end

local function marchOutward(player, state, now)
	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return end
	now = now or getTimestampMs()
	local px, py, pz = player:getX(), player:getY(), player:getZ()
	local ocx = state.cx or px
	local ocy = state.cy or py
	state.sweepNo = (state.sweepNo or 0) + 1
	local sweepNo = state.sweepNo
	state.marchTrack = state.marchTrack or {}
	state.dropped    = state.dropped or {}

	reportDrops(state, player, now, false)

	local needSweeps = math.max(MIN_STUCK_SWEEPS,
		math.min(STUCK_SWEEPS,
			math.floor((state.durationMs or DISPERSE_DURATION_MS) / REPATH_INTERVAL_MS / 3)))
	local nudgeAt = math.max(2, math.floor(needSweeps / 2))

	local marched, dropped, broken, nudged = 0, 0, 0, 0
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z:isAlive()
			and not z:getVariableBoolean("Bandit")
			and not (z.getModData and z:getModData().zrSubdued)
		then
			local distP = z:DistTo(player)
			local zx, zy = z:getX(), z:getY()
			local ccx, ccy = zx - ocx, zy - ocy
			local centerDist = math.sqrt(ccx * ccx + ccy * ccy)
			if distP <= DISPERSE_RANGE then
				pcall(function()
				local zid = z.getID and z:getID() or nil
				local cur = z.getCurrentStateName and z:getCurrentStateName() or ""
				local down = isAlreadyDown(z)

				local sameZ    = math.abs(z:getZ() - pz) < 0.5
				local nearDist = math.min(distP, centerDist)
				local eligible = sameZ and nearDist <= STUCK_DROP_RANGE

				local stuckN = 0
				if zid then
					stuckN = trackImmobility(state, zid, zx, zy,
						down or not eligible or cannotWalkYet(z, cur), sweepNo)
				end

				if zid and stuckN >= needSweeps
					and eligible
					and not down
					and not state.dropped[zid]
					and z.knockDown
				then
					pushAndDrop(z, state, zid, px, py, now, "immobile")
					dropped = dropped + 1
					log("DROP id=%s d=%.1f c=%.1f state=%s — never left a %.2f-tile circle for %d sweeps (%.1fs)",
						tostring(zid), distP, centerDist, cur,
						STUCK_ANCHOR_RADIUS, stuckN, stuckN * REPATH_INTERVAL_MS / 1000)
					return
				end

				if down then return end
				if zid and state.pushing and state.pushing[zid] then return end

				local dx = zx - px
				local dy = zy - py
				local len = math.sqrt(dx * dx + dy * dy)
				if len < 0.5 then
					dx, dy = fallbackDir(z)
				else
					dx, dy = dx / len, dy / len
				end

				local step = MARCH_STEP
				if stuckN >= nudgeAt then
					clearPathPipeline(z)
					step = MARCH_NUDGE_STEP
					if stuckN == nudgeAt then
						nudged = nudged + 1
						log("nudge id=%s d=%.1f state=%s — %d/%d immobile sweeps, short order + pipeline reset",
							tostring(zid), distP, cur, stuckN, needSweeps)
					end
				elseif (cur == "ZombieIdleState" or cur == "IdleState") and z.Wander then
					z:Wander()
				end

				if placeMarchOrder(cell, z, zx, zy, dx, dy, step) then
					marched = marched + 1
				elseif zid and state.marchTrack[zid] then
					local t = state.marchTrack[zid]
					t.x, t.y, t.sweeps = zx, zy, 0
				end
				end)
			elseif distP <= OUTER_WATCH_RANGE or centerDist <= OUTER_WATCH_RANGE then
				pcall(function()
				local zid = z.getID and z:getID() or nil
				if not zid then return end
				state.outerTrack = state.outerTrack or {}
				local prev = state.outerTrack[zid]
				state.outerTrack[zid] = centerDist
				if not prev or (prev - centerDist) <= OUTER_CLOSING_MIN then return end
				local cur = z.getCurrentStateName and z:getCurrentStateName() or ""
				if (z.hasPath and z:hasPath())
					or cur:find("WalkToward") or cur:find("PathFind")
				then
					clearPathPipeline(z)
					broken = broken + 1
					log("outer break-off id=%s c=%.1f (inbound, was %s)",
						tostring(zid), centerDist, cur)
				end
				end)
			end
		end
	end
	pruneTracks(state, now, sweepNo)
	if marched > 0 or dropped > 0 or broken > 0 or nudged > 0 then
		log("march: %d ordered outward, %d nudged, %d dropped, %d inbound approach(es) broken",
			marched, nudged, dropped, broken)
	end
end

local function perTickSweep(player, state, now, ghosted)
	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return end
	local px, py, pz = player:getX(), player:getY(), player:getZ()
	state.reach   = state.reach   or {}
	state.dropped = state.dropped or {}
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z:isAlive()
			and not z:getVariableBoolean("Bandit")
			and not (z.getModData and z:getModData().zrSubdued)
		then
			pcall(function()
				if z.getTarget and z:getTarget() == player then
					z:clearAggroList()
					z:setTarget(nil)
				end
				local distP = z:DistTo(player)
				if distP > DISPERSE_RANGE then return end
				if z.isUseless and z:isUseless() then z:setUseless(false) end
				if not ghosted then return end
				local zid = z.getID and z:getID() or nil
				if not zid then return end
				poseCheck(z, zid, player, state, now, px, py, pz, distP)
			end)
		end
	end
end

local function aftercareSweep(player, ac, now)
	local cell = getCell()
	local zList = cell and cell:getZombieList()
	if not zList then return end
	local px, py, pz = player:getX(), player:getY(), player:getZ()
	ac.reach   = ac.reach   or {}
	ac.dropped = ac.dropped or {}
	for i = 0, zList:size() - 1 do
		local z = zList:get(i)
		if z and z:isAlive()
			and not z:getVariableBoolean("Bandit")
			and not (z.getModData and z:getModData().zrSubdued)
		then
			pcall(function()
				local distP = z:DistTo(player)
				if distP > REACH_DROP_RANGE then return end
				local zid = z.getID and z:getID() or nil
				if not zid then return end
				poseCheck(z, zid, player, ac, now, px, py, pz, distP)
			end)
		end
	end
end

local function armAftercare(player, state, now)
	ZR_CrowdControl.after[player] = {
		untilMs    = now + AFTERCARE_MS,
		armAtMs    = now + REACH_ARM_MS,
		holdMs     = AFTER_HOLD_MS,
		tag        = "reach-after",
		lastPushMs = now,
		probeBlind = 0,
		reach      = {},
		pushing    = {},
		dropped    = {},
		cx         = state.cx,
		cy         = state.cy,
	}
end

function ZR_CrowdControl.disperse(player, durationMs)
	if not modEnabled() then return end
	if not player then return end
	local now = getTimestampMs()
	for p, _ in pairs(ZR_CrowdControl.active) do
		if p ~= player then ZR_CrowdControl.active[p] = nil end
	end
	for p, _ in pairs(ZR_CrowdControl.after) do
		ZR_CrowdControl.after[p] = nil
	end
	ZR_CrowdControl.active[player] = {
		startMs     = now,
		durationMs  = durationMs or DISPERSE_DURATION_MS,
		lastMarchMs = now,
		lastPushMs  = now,
		sweepNo     = 0,
		armAtMs     = now + REACH_ARM_MS,
		holdMs      = REACH_HOLD_MS,
		tag         = "reach",
		probeBlind  = 0,
		marchTrack  = {},
		reach       = {},
		pushing     = {},
		dropped     = {},
		outerTrack  = {},
		cx          = player:getX(),
		cy          = player:getY(),
	}
	local ghosted = false
	pcall(function()
		ghosted = (player.isGhostMode and player:isGhostMode()) or false
	end)
	perTickSweep(player, ZR_CrowdControl.active[player], now, ghosted)
	marchOutward(player, ZR_CrowdControl.active[player], now)
	log("disperse started (%dms) — force-march %d tiles out, range %d, repath %dms; reach-drop armed in %dms (hold %dms, range %.1f)",
		durationMs or DISPERSE_DURATION_MS, MARCH_STEP, DISPERSE_RANGE,
		REPATH_INTERVAL_MS, REACH_ARM_MS, REACH_HOLD_MS, REACH_DROP_RANGE)
end

function ZR_CrowdControl.stop(player)
	local state = ZR_CrowdControl.active[player]
	if state then
		ZR_CrowdControl.active[player] = nil
		local dead = false
		pcall(function() dead = (player.isDead and player:isDead()) or false end)
		if dead then
			log("disperse stopped (player dead) — no aftercare")
			return
		end
		armAftercare(player, state, getTimestampMs())
		log("disperse stopped early — aftercare pose watch armed for %dms", AFTERCARE_MS)
	elseif ZR_CrowdControl.after[player] then
		ZR_CrowdControl.after[player] = nil
		log("aftercare pose watch stopped")
	end
end

function ZR_CrowdControl.isDispersing(player)
	return ZR_CrowdControl.active[player] ~= nil
end

local function onPlayerUpdate(player)
	local now = getTimestampMs()
	local state = ZR_CrowdControl.active[player]

	if state then
		if now - state.startMs >= state.durationMs then
			pcall(function()
				local cell = getCell()
				local zList = cell and cell:getZombieList()
				if not zList or not state.cx then return end
				local swept = 0
				for i = 0, zList:size() - 1 do
					local z = zList:get(i)
					if z and z:isAlive()
						and not z:getVariableBoolean("Bandit")
						and not (z.getModData and z:getModData().zrSubdued)
						and (z:getTarget() == nil or z:getTarget() == player)
					then
						local ddx = z:getX() - state.cx
						local ddy = z:getY() - state.cy
						if (ddx * ddx + ddy * ddy)
							<= OUTER_WATCH_RANGE * OUTER_WATCH_RANGE then
							local cur = z.getCurrentStateName and z:getCurrentStateName() or ""
							if cur:find("WalkToward") or cur:find("PathFind") then
								clearPathPipeline(z)
								swept = swept + 1
							end
						end
					end
				end
				if swept > 0 then
					log("final sweep: %d stale chaser(s) broken off the old player position", swept)
				end
			end)
			reportDrops(state, player, now, true)
			if (state.probeBlind or 0) > 0 then
				log("clip probe found NO anim tracks on %d zombie-ticks in the ring — if this is large, the probe is blind here and the reach path is inert",
					state.probeBlind)
			end
			armAftercare(player, state, now)
			ZR_CrowdControl.active[player] = nil
			log("disperse finished — aftercare pose watch armed for %dms", AFTERCARE_MS)
			return
		end

		local ghosted = false
		pcall(function()
			ghosted = (player.isGhostMode and player:isGhostMode()) or false
		end)
		perTickSweep(player, state, now, ghosted)
		servicePush(state, now)
		if now - state.lastMarchMs >= REPATH_INTERVAL_MS then
			state.lastMarchMs = now
			marchOutward(player, state, now)
		end
		return
	end

	local ac = ZR_CrowdControl.after[player]
	if not ac then return end
	if now >= ac.untilMs then
		reportDrops(ac, player, now, true)
		if (ac.probeBlind or 0) > 0 then
			log("aftercare: clip probe found NO anim tracks on %d zombie-ticks", ac.probeBlind)
		end
		ZR_CrowdControl.after[player] = nil
		log("aftercare pose watch ended")
		return
	end
	aftercareSweep(player, ac, now)
	servicePush(ac, now)
	reportDrops(ac, player, now, false)
	pruneTracks(ac, now, nil)
end

if ZR_CrowdControl.onPlayerUpdate then
	Events.OnPlayerUpdate.Remove(ZR_CrowdControl.onPlayerUpdate)
end
ZR_CrowdControl.onPlayerUpdate = onPlayerUpdate
Events.OnPlayerUpdate.Add(onPlayerUpdate)

return ZR_CrowdControl