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

require "ZomboRut/ZR_Surrender"
local ZomboRutScene = require "ZomboRut/ZomboRutScene"
require "ZomboRut/ZR_WalkToRandom"

ZR_SprinterTakedown = ZR_SprinterTakedown or {}

local DEBUG = true
local DEBUG_SHOW_HUSK = false
local function log(fmt, ...)
	if DEBUG then DebugLog.log(string.format("[ZR SprinterTakedown] " .. fmt, ...)) end
end

local SCENE_ID          = "Tackle_Drag_Drag"
local COOLDOWN_TICKS    = 600
local TRIGGER_DIST_LUNGE  = 3.0
local TRIGGER_DIST_ATTACK = 1.5
local WALK_START_DELAY  = 30
local OUTRO_HOLD_TICKS  = 20
local FACING_LOCK_MAX   = 240

local MIN_DRAG_TICKS    = 300
local MIN_DRAG_TILES    = 10
local MAX_DRAG_TICKS    = 900
local TARGET_REACH      = 3.0
local DRAG_LEG_TILES    = 6
local DRAG_BUFFER_LEGS  = 2

local CLUSTER_SEARCH_R    = 12
local CLUSTER_TIGHT       = 5
local CLUSTER_MIN         = 4
local CLUSTER_WIDE_R      = 25
local RETARGET_INTERVAL   = 60
local RETARGET_MIN_MOVE   = 2.0
local LEG_KEEP_DOT        = 0.64

local SHORT_DRAG_TILES    = 3
local NEAR_HORDE_RANGE    = 6
local NEAR_HORDE_MIN      = 3

local dragDrv       = {}
local grappleScrub  = {}
local GRAPPLE_SCRUB_TICKS = 30
local launchOpts    = {}
local facingLock    = {}
local handoff       = {}
local HANDOFF_HUSH_RANGE = 6
local lastFireFrame = -100000

local function aggroNearbyZombies(player, radius)
	pcall(function()
		local cell = getCell()
		local zlist = cell and cell:getZombieList()
		if not zlist then return end
		local r = radius or 8
		for i = 0, zlist:size() - 1 do
			local z = zlist:get(i)
			if z and z:isAlive() and not z:getVariableBoolean("Bandit")
				and z:DistTo(player) <= r then
				pcall(function() if z.setTarget then z:setTarget(player) end end)
			end
		end
	end)
end

local frame = 0
local _frameAccumMs = 0
local _frameLastMs = nil
Events.OnTick.Add(function()
	local now = getTimestampMs()
	local gap = _frameLastMs and (now - _frameLastMs) or 0
	_frameLastMs = now
	if gap > 0 and gap <= 400 then
		_frameAccumMs = _frameAccumMs + gap
		frame = math.floor(_frameAccumMs * 0.06)
	end
end)

local function applyFacingLock(player)
	local f = facingLock[player]
	if not f then return end
	if frame - (f.since or frame) > FACING_LOCK_MAX then
		facingLock[player] = nil
		return
	end
	pcall(function()
		if player.setForwardDirection and (math.abs(f.x) > 1e-3 or math.abs(f.y) > 1e-3) then
			player:setForwardDirection(f.x, f.y)
		end
	end)
end

local function hushHandoff(player)
	local since = handoff[player]
	if not since then return end
	if frame - since > FACING_LOCK_MAX then
		handoff[player] = nil
		log("hand-off hush expired after %d ticks (drag scene never started)", FACING_LOCK_MAX)
		return
	end
	pcall(function()
		local cell = getCell()
		local zlist = cell and cell:getZombieList()
		if not zlist then return end
		for i = 0, zlist:size() - 1 do
			local z = zlist:get(i)
			if z and z:isAlive() and not z:getVariableBoolean("Bandit")
				and z:DistTo(player) <= HANDOFF_HUSH_RANGE then
				pcall(function()
					local cur = z.getCurrentStateName and z:getCurrentStateName() or ""
					if cur:find("Attack") and IdleState and IdleState.instance and z.changeState then
						z:changeState(IdleState.instance())
					end
				end)
			end
		end
	end)
end

local function clearSprinterSlow(player)
	pcall(function()
		if player.getSlowFactor and player:getSlowFactor() > 0 then
			if player.setSlowFactor then player:setSlowFactor(0) end
			if player.setSlowTimer  then player:setSlowTimer(0)  end
		end
	end)
end

local function releasePlayerFromDrag(player)
	if not (player and player:isAlive()) then return end
	pcall(function()
		if player.isGrappling and player:isGrappling() and player.LetGoOfGrappled then
			player:LetGoOfGrappled("Dropped")
		end
		if player.resetGrappleStateToDefault then
			player:resetGrappleStateToDefault("")
		end
		grappleScrub[player] = frame + GRAPPLE_SCRUB_TICKS
		if IdleState and IdleState.instance and player.changeState then
			player:changeState(IdleState.instance())
		end
		if player.setBlockMovement then player:setBlockMovement(false) end
		log("drag end -> grapple released + player to idle + movement unblocked; vanilla layDownBody skipped")
	end)
end

local solidRestore  = {}
local SOLID_MAX_HOLD = MAX_DRAG_TICKS + 1200

local function dragSolidOff(player)
	pcall(function()
		if player.setSolid then
			player:setSolid(false)
			solidRestore[player] = frame
			log("solid OFF (walk phase begins)")
		end
	end)
end

local function dragSolidRestore(player, why)
	if not solidRestore[player] then return end
	solidRestore[player] = nil
	pcall(function() if player.setSolid then player:setSolid(true) end end)
	log("solid RESTORED (%s)", tostring(why))
end

Events.OnPlayerUpdate.Add(function(player)
	local ok, err = pcall(applyFacingLock, player)
	if not ok then DebugLog.log("[ZR SprinterTakedown] facingLock error: " .. tostring(err)) end
	if facingLock[player] or dragDrv[player] then clearSprinterSlow(player) end
	local okHush, errHush = pcall(hushHandoff, player)
	if not okHush then DebugLog.log("[ZR SprinterTakedown] handoff hush error: " .. tostring(errHush)) end
	local heldSince = solidRestore[player]
	if heldSince then
		if not dragDrv[player] then
			dragSolidRestore(player, "watchdog: drag state gone")
		elseif frame - heldSince > SOLID_MAX_HOLD then
			dragSolidRestore(player, "watchdog: TTL exceeded, scene wedged?")
		end
	end
	local scrubUntil = grappleScrub[player]
	if scrubUntil then
		local newGrapple = (player.isGrappling and player:isGrappling())
			or (ZomboRutScene._active and ZomboRutScene._active[player])
		if newGrapple or frame >= scrubUntil then
			grappleScrub[player] = nil
			log("grapple scrub %s (frame=%d)",
				newGrapple and "cancelled: new grapple/scene" or "expired", frame)
		else
			pcall(function() player:resetGrappleStateToDefault("") end)
		end
	end
end)

local function isSprinter(zombie)
	local wt
	pcall(function() wt = zombie:getWalkType() end)
	return wt ~= nil and string.sub(wt, 1, 6) == "sprint"
end

local function modeIsBoth()
	return ZR_Surrender and ZR_Surrender.getDefeatMode and ZR_Surrender.DEFEAT_MODE
		and ZR_Surrender.getDefeatMode() == ZR_Surrender.DEFEAT_MODE.BOTH
end

local function sandboxOk()
	local sv = SandboxVars and SandboxVars.ZomboRut
	return sv and sv.enableMod ~= false and sv.zombieDefeatEnabled ~= false
end

local function takedownEnabled()
	local sv = SandboxVars and SandboxVars.ZomboRut
	return not sv or sv.sprinterTakedownEnabled ~= false
end

local function walkStillGoing(player)
	local going = false
	pcall(function()
		local q = ISTimedActionQueue and ISTimedActionQueue.queues and ISTimedActionQueue.queues[player]
		going = (q ~= nil) and (q.queue ~= nil) and (#q.queue > 0)
	end)
	return going
end

local function queueLen(player)
	local n = 0
	pcall(function()
		local q = ISTimedActionQueue and ISTimedActionQueue.queues and ISTimedActionQueue.queues[player]
		if q and q.queue then n = #q.queue end
	end)
	return n
end

local function captureFacing(player)
	pcall(function()
		local fwd = player.getForwardDirection and player:getForwardDirection()
		if fwd then
			player:setVariable("ZR_OrigFaceX", tostring(fwd:getX()))
			player:setVariable("ZR_OrigFaceY", tostring(fwd:getY()))
		end
	end)
end

local function clearFacing(player)
	pcall(function()
		player:setVariable("ZR_OrigFaceX", "")
		player:setVariable("ZR_OrigFaceY", "")
	end)
end

local function countNearbyZombies(player, range)
	local n = 0
	pcall(function()
		local cell = getCell()
		local zl = cell and cell:getZombieList()
		if not zl then return end
		for i = 0, zl:size() - 1 do
			local z = zl:get(i)
			if z and z:isAlive() and not z:getVariableBoolean("Bandit")
				and not z:isProne() and not z:isCrawling()
				and z:DistTo(player) <= range then
				n = n + 1
			end
		end
	end)
	return n
end

function ZR_SprinterTakedown.launch(player, zombie, opts)
	if not player or not zombie then return false end
	captureFacing(player)
	pcall(function() if zombie.setNoTeeth then zombie:setNoTeeth(true) end end)
	opts = opts or {}
	if opts.maxTiles == nil then
		local near = countNearbyZombies(player, NEAR_HORDE_RANGE)
		if near >= NEAR_HORDE_MIN then
			opts.maxTiles = SHORT_DRAG_TILES
			log("launch: %d zombie(s) within %d tiles -> SHORT drag (%d tiles)",
				near, NEAR_HORDE_RANGE, SHORT_DRAG_TILES)
		else
			log("launch: caught alone (%d nearby) -> long cluster-seek drag", near)
		end
	else
		log("launch: caller-set maxTiles=%d -> SHORT drag", opts.maxTiles)
	end
	local fx = tonumber(player:getVariableString("ZR_OrigFaceX") or "")
	local fy = tonumber(player:getVariableString("ZR_OrigFaceY") or "")
	if fx and fy and (math.abs(fx) > 1e-3 or math.abs(fy) > 1e-3) then
		facingLock[player] = { x = fx, y = fy, since = frame }
	end
	local ok = false
	pcall(function()
		ok = ZR_Surrender.start(player, true, {
			firstScene            = SCENE_ID,
			firstSceneZombie      = zombie,
			instant               = true,
			reseedOnFirstSceneEnd = true,
		})
	end)
	if ok then
		launchOpts[player] = opts
		handoff[player]    = frame
		lastFireFrame      = frame
		log("takedown -> give-up force-stop + firstScene=%s on zombie id=%s (drag first, then defeats)",
			SCENE_ID, tostring(zombie.getID and zombie:getID()))
	else
		facingLock[player] = nil
		handoff[player]    = nil
		log("takedown launch deferred (give-up refused: player moving / in a transition state) — retry on next attack")
	end
	return ok
end

function ZR_SprinterTakedown.isActive(player)
	return player ~= nil
		and (dragDrv[player] ~= nil or (frame - lastFireFrame) < 10)
end

local function readOrigDir(player)
	local fx = tonumber(player:getVariableString("ZR_OrigFaceX") or "")
	local fy = tonumber(player:getVariableString("ZR_OrigFaceY") or "")
	return fx, fy
end

local function startFireScream(player)
	pcall(function()
		if ZR_SceneSounds and ZR_SceneSounds.startFireScream then
			ZR_SceneSounds.startFireScream(player)
		end
	end)
end

local function stopFireScream(player)
	pcall(function()
		if ZR_SceneSounds and ZR_SceneSounds.stopFireScream then
			ZR_SceneSounds.stopFireScream(player)
		end
	end)
end

local FALL_SCREAM_REPLAY = 150

local function playVoiceScream(player, suffix)
	pcall(function() if player.playerVoiceSound then player:playerVoiceSound(suffix) end end)
end

local function playIntroScream(player)
	playVoiceScream(player, (ZombRand(2) == 0) and "DeathEaten" or "DeathFall")
end

local function startDragScream(player, st)
	st.screamMode = (ZombRand(2) == 0) and "fire" or "fall"
	if st.screamMode == "fire" then
		startFireScream(player)
	else
		st.lastFallScream = frame
		playVoiceScream(player, "DeathFall")
	end
end

local function maintainDragScream(player, st)
	if st.screamMode == "fall"
		and frame - (st.lastFallScream or frame) >= FALL_SCREAM_REPLAY then
		st.lastFallScream = frame
		playVoiceScream(player, "DeathFall")
	end
end

local function stopDragScream(player, st)
	stopFireScream(player)
	if st then st.screamMode = nil end
end

local PING_INTERVAL = 45
local PING_RADIUS   = 30
local function pingDragLocation(player, st)
	if frame - (st.lastPing or (frame - PING_INTERVAL)) < PING_INTERVAL then return end
	st.lastPing = frame
	pcall(function()
		if addSound then
			addSound(player, player:getX(), player:getY(), player:getZ(), PING_RADIUS, PING_RADIUS)
		end
	end)
end

local function enterOutro(player, st)
	st.phase = "outro"; st.outroStart = frame
	dragSolidRestore(player, "outro: walking done")
	stopDragScream(player, st)
	pcall(function() player:setVariable("ZR_" .. SCENE_ID .. "Outro", "true") end)
end

local function updateDragFacing(player, st)
	local hasPath = false
	pcall(function() hasPath = player.hasPath and player:hasPath() or false end)
	if hasPath then
		local f = player.getForwardDirection and player:getForwardDirection()
		if f then
			local fx = f.getX and f:getX()
			local fy = f.getY and f:getY()
			if fx and fy and (math.abs(fx) > 1e-3 or math.abs(fy) > 1e-3) then
				st.faceX, st.faceY = fx, fy
			end
		end
	elseif st.faceX then
		player:setForwardDirection(st.faceX, st.faceY)
	end
end

local function driveDrag(player, st)
	local elapsed = frame - (st.dragStart or frame)
	local px, py  = player:getX(), player:getY()
	local dxS, dyS = px - (st.startX or px), py - (st.startY or py)
	local traveled = math.sqrt(dxS * dxS + dyS * dyS)

	if st.target and not st.maxTiles and not st.stopping and not st.stuck
		and frame - (st.lastRetarget or st.dragStart or frame) >= RETARGET_INTERVAL then
		st.lastRetarget = frame
		local tx, ty, kind
		pcall(function()
			tx, ty, kind = ZR_WalkToRandom.pickDragTarget(
				player, CLUSTER_SEARCH_R, CLUSTER_TIGHT, CLUSTER_MIN, CLUSTER_WIDE_R)
		end)
		if tx and ty and kind and kind ~= "none" then
			local mdx, mdy = tx - st.target.x, ty - st.target.y
			if (mdx * mdx + mdy * mdy) >= RETARGET_MIN_MOVE * RETARGET_MIN_MOVE then
				st.target = { x = tx, y = ty }
				st.targetKind = kind
				log("retarget: horde moved -> now @ (%.1f,%.1f) kind=%s dist=%.1f", tx, ty, kind,
					math.sqrt((px - tx) * (px - tx) + (py - ty) * (py - ty)))
			end
		end
	end

	local reached = false
	if st.target then
		local tdx, tdy = px - st.target.x, py - st.target.y
		reached = (tdx * tdx + tdy * tdy) <= (TARGET_REACH * TARGET_REACH)
	end

	if not st.stopping and not st.stuck then
		local timeUp     = elapsed >= MAX_DRAG_TICKS
		local minTimeMet = elapsed >= MIN_DRAG_TICKS
		local stop
		if st.maxTiles and traveled >= st.maxTiles then
			stop = true
		elseif st.targetKind == "none" or not st.target then
			stop = minTimeMet
		else
			stop = minTimeMet and (reached or traveled >= MIN_DRAG_TILES)
		end
		if stop or timeUp then
			st.stopping = true
			log("drag stop: elapsed=%d traveled=%.1f kind=%s reached=%s timeUp=%s -> draining to outro",
				elapsed, traveled, tostring(st.targetKind), tostring(reached), tostring(timeUp))
		end
	end

	local bufferLegs = st.maxTiles and 1 or DRAG_BUFFER_LEGS
	if not st.stopping and not st.stuck and not reached
		and queueLen(player) < bufferLegs then
		local from = st.nextFrom or { x = math.floor(px), y = math.floor(py) }
		local dsx, dsy
		local legMax = DRAG_LEG_TILES
		if st.target then
			dsx, dsy = st.target.x - from.x, st.target.y - from.y
			local dist = math.sqrt(dsx * dsx + dsy * dsy)
			legMax = math.max(1, math.min(DRAG_LEG_TILES, math.ceil(dist)))
			if st.lastLeg and dist > 1e-3 then
				local ll = math.sqrt(st.lastLeg.x * st.lastLeg.x + st.lastLeg.y * st.lastLeg.y)
				if ll > 1e-3
					and (dsx * st.lastLeg.x + dsy * st.lastLeg.y) / (dist * ll) >= LEG_KEEP_DOT then
					dsx, dsy = st.lastLeg.x, st.lastLeg.y
				end
			end
		else
			local fx, fy = readOrigDir(player)
			dsx, dsy = -(fx or 0), -(fy or 0)
		end
		if st.maxTiles then
			legMax = math.min(legMax, math.max(1, math.ceil(st.maxTiles - traveled)))
		end
		local pz = math.floor(player:getZ())
		local sq, ddx, ddy, lsx, lsy = ZR_WalkToRandom.pickDragLeg(
			from.x, from.y, pz, dsx, dsy, legMax)
		if sq then
			pcall(function() ZR_WalkToRandom.walkTo(player, sq) end)
			st.nextFrom = { x = ddx, y = ddy }
			if lsx or lsy then
				st.lastLeg = { x = lsx or 0, y = lsy or 0 }
			end
			st.legCount = (st.legCount or 0) + 1
		else
			st.stuck = true
			log("drag stuck: boxed in at (%d,%d) after %d leg(s) -> outro",
				from.x, from.y, st.legCount or 0)
		end
	end

	if (st.stopping or st.stuck) and not walkStillGoing(player) then
		enterOutro(player, st)
		log("drag done -> outro (legs=%d, elapsed=%d, traveled=%.1f)",
			st.legCount or 0, elapsed, traveled)
	end
end

ZomboRutScene.registerSceneStartCallback(function(player, active)
	if not (active and active.sceneId == SCENE_ID) then return end
	facingLock[player] = nil
	handoff[player]    = nil
	local o = launchOpts[player]; launchOpts[player] = nil
	dragDrv[player] = { phase = "intro", maxTiles = o and o.maxTiles }
	pcall(function() if player.setBlockMovement then player:setBlockMovement(true) end end)
	playIntroScream(player)
	log("drag scene started -> directional+cluster drag (intro scream)")
end)

ZomboRutScene.registerSceneUpdateCallback(function(player, active)
	if not (active and active.sceneId == SCENE_ID) then return end
	if DEBUG_SHOW_HUSK then
		pcall(function()
			local t = player.getGrapplingTarget and player:getGrapplingTarget()
			if t then
				if t.setDoRender    then t:setDoRender(true)   end
				if t.setAlpha       then t:setAlpha(1.0)       end
				if t.setTargetAlpha then t:setTargetAlpha(1.0) end
			end
		end)
	end
	local st = dragDrv[player]
	if not st then return end

	if DEBUG and (frame % 30 == 0) then
		pcall(function()
			local pf = player:getForwardDirection()
			local pfx = pf and (pf.getX and pf:getX()) or 0
			local pfy = pf and (pf.getY and pf:getY()) or 0
			local t = player.getGrapplingTarget and player:getGrapplingTarget()
			if t then
				local tf = t.getForwardDirection and t:getForwardDirection()
				local tfx = tf and (tf.getX and tf:getX()) or 0
				local tfy = tf and (tf.getY and tf:getY()) or 0
				log("DBG phase=%s player_face=(%.2f,%.2f) husk_pos=(%.2f,%.2f) husk_face=(%.2f,%.2f) orig=(%s,%s)",
					st.phase, pfx, pfy, t:getX(), t:getY(), tfx, tfy,
					player:getVariableString("ZR_OrigFaceX"), player:getVariableString("ZR_OrigFaceY"))
			else
				log("DBG phase=%s player_face=(%.2f,%.2f) NO-grapple-target orig=(%s,%s)",
					st.phase, pfx, pfy,
					player:getVariableString("ZR_OrigFaceX"), player:getVariableString("ZR_OrigFaceY"))
			end
		end)
	end

	if st.phase == "intro" or (st.phase == "dragging" and not st.walkFired) then
		pcall(function()
			if not (player.isDraggingCorpse and player:isDraggingCorpse()) then return end
			local ox = tonumber(player:getVariableString("ZR_OrigFaceX") or "")
			local oy = tonumber(player:getVariableString("ZR_OrigFaceY") or "")
			if ox and oy and (math.abs(ox) > 1e-3 or math.abs(oy) > 1e-3) then
				player:setForwardDirection(-ox, -oy)
			end
		end)
	elseif st.phase == "dragging" and st.walkFired then
		pcall(updateDragFacing, player, st)
	end

	if st.phase == "intro" then
		if player:getVariableString("ZR_IntroDone") == "true" then
			st.phase = "dragging"; st.dragStart = frame; st.walkFired = false
			log("intro done -> drag phase (auto-walk fires after %d-tick settle)", WALK_START_DELAY)
		end

	elseif st.phase == "dragging" then
		if not st.walkFired then
			if frame - (st.dragStart or frame) >= WALK_START_DELAY then
				st.walkFired = true
				st.legCount  = 0
				st.stopping  = false
				st.stuck     = false
				st.startX    = math.floor(player:getX())
				st.startY    = math.floor(player:getY())
				st.nextFrom  = { x = st.startX, y = st.startY }
				st.lastRetarget = frame
				dragSolidOff(player)
				local ox, oy = readOrigDir(player)
				if ox and oy then
					local ol = math.sqrt(ox * ox + oy * oy)
					if ol > 1e-3 then st.faceX, st.faceY = -ox / ol, -oy / ol end
				end
				local tx, ty, kind
				pcall(function()
					tx, ty, kind = ZR_WalkToRandom.pickDragTarget(
						player, CLUSTER_SEARCH_R, CLUSTER_TIGHT, CLUSTER_MIN, CLUSTER_WIDE_R)
				end)
				st.targetKind = kind or "none"
				st.target = (st.targetKind ~= "none") and { x = tx, y = ty } or nil
				if st.target then
					local dtx, dty = st.target.x - player:getX(), st.target.y - player:getY()
					if (dtx * dtx + dty * dty) <= (TARGET_REACH * TARGET_REACH) then
						st.targetKind = "none"
						st.target = nil
					end
				end
				startDragScream(player, st)
				pingDragLocation(player, st)
				log("settle done -> drag target kind=%s%s (drag scream=%s)", tostring(st.targetKind),
					st.target and string.format(" @ (%.1f,%.1f)", tx, ty) or "",
					tostring(st.screamMode))
			end
		else
			driveDrag(player, st)
			maintainDragScream(player, st)
			pingDragLocation(player, st)
		end

	elseif st.phase == "outro" then
		if frame - (st.outroStart or frame) >= OUTRO_HOLD_TICKS then
			st.phase = "ending"
			pcall(function()
				local t = player.getGrapplingTarget and player:getGrapplingTarget()
				if t then
					if t.setAlpha       then t:setAlpha(0.00392)       end
					if t.setTargetAlpha then t:setTargetAlpha(0.00392) end
					if t.setDoRender    then t:setDoRender(false)      end
				end
			end)
			releasePlayerFromDrag(player)
			pcall(function() ZomboRutScene.endScene(player, "Released") end)
			aggroNearbyZombies(player)
			log("outro done -> released + aggroed nearby -> ended drag (give-up reseeds into defeats)")
		end
	end
end)

ZomboRutScene.registerSceneEndCallback(function(player, active, result)
	if not (active and active.sceneId == SCENE_ID) then return end
	dragDrv[player] = nil
	dragSolidRestore(player, "scene end")
	stopFireScream(player)
	clearFacing(player)
	log("drag scene ended (%s)", tostring(result))
end)

local function onZombieUpdateInner(zombie)
	if not zombie or not zombie.getTarget then return end
	if not sandboxOk() or not modeIsBoth() then return end
	if not takedownEnabled() then return end

	local player = getPlayer()
	if not player or zombie:getTarget() ~= player then return end
	if frame - lastFireFrame < COOLDOWN_TICKS then return end
	if not player:isAlive() then return end
	if ZR_Surrender and ZR_Surrender.isActive and ZR_Surrender.isActive(player) then return end
	if ZomboRutScene.isPlayerInScene and ZomboRutScene.isPlayerInScene(player) then return end

	local lunging = false
	pcall(function()
		lunging = LungeState and LungeState.instance and zombie.isCurrentState
			and zombie:isCurrentState(LungeState.instance())
	end)
	local attacking = zombie.isZombieAttacking and zombie:isZombieAttacking(player)
	if not (lunging or attacking) then return end
	local dist = math.huge
	pcall(function() dist = zombie:DistTo(player) end)
	if dist > (lunging and TRIGGER_DIST_LUNGE or TRIGGER_DIST_ATTACK) then return end

	local sprinterBehind = false
	pcall(function() sprinterBehind = isSprinter(zombie) and zombie:isBehind(player) end)
	if not sprinterBehind then return end

	log("takedown trigger at %.2f tiles (sprinter-from-behind, %s) -> drag",
		dist, lunging and "mid-lunge" or "mid-attack")
	ZR_SprinterTakedown.launch(player, zombie)
end

Events.OnZombieUpdate.Add(function(zombie)
	local ok, err = pcall(onZombieUpdateInner, zombie)
	if not ok then DebugLog.log("[ZR SprinterTakedown] onZombieUpdate error: " .. tostring(err)) end
end)

Events.OnCharacterDeath.Add(function(character)
	dragDrv[character]    = nil
	facingLock[character] = nil
	handoff[character]    = nil
	dragSolidRestore(character, "character death")
end)

DebugLog.log("[ZR SprinterTakedown] registered (give-up + drag-first, mode-3 gated, DEBUG=" .. tostring(DEBUG) .. ", build=lunge-trigger+handoff-hush)")
