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

local ZomboRutScene    = require "ZomboRut/ZomboRutScene"
local AnimationHandler = require "ZomboRut/ZomboRutAnimationHandler"
local ZR_Moodles       = require "ZomboRut/ZR_Moodles"
require "ZomboRut/ZR_Surrender"

ZR_Arousal = ZR_Arousal or {}

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

local MOODLE_NAME = "ZRArousal"
ZR_Moodles.create(MOODLE_NAME)

local TIER_STIRRED   = 30
local TIER_AROUSED   = 50
local TIER_PENTUP    = 65
local TIER_DESPERATE = 85

local DEFAULT_BUILD_HOURS = 96

local MAX_TICK_HOURS = 0.5

local TRAIT_BUILD_MULT = {
	{ "Sexaddict",    2.0  },
	{ "AARP",         0.5  },
	{ "Lewd",         1.25 },
	{ "Nymphomaniac", 3.0  },
	{ "Innocent",     0.5  },
	{ "promiscuous",  1.5  },
	{ "dpress",       0.6  },
}

local NECRO_CORPSE_MULT  = 2.0
local CORPSE_SCAN_RADIUS = 2

local NECRO_NAKED_CORPSE_FLOOR = TIER_AROUSED

local NAKED_ZOMBIE_MULT = {
	{ "Necrophiliac", 2.5  },
	{ "Nymphomaniac", 1.5  },
	{ "Sexaddict",    1.5  },
	{ "Lewd",         1.25 },
}
local NAKED_SCAN_RADIUS = 3

local SELF_NAKED_MULT = 1.5

local DEFEAT_RISE_PER_HOUR = 125

local function _defeatHeld(player)
	return player:getModData().zr_arousalDefeatHold == true
end

local SCENE_DRAIN_PER_HOUR = 480
local SOLO_DRAIN_PER_HOUR  = 300

local TUNING_MINUTES_PER_DAY = 60
local function _actScale()
	local gt = getGameTime and getGameTime()
	local mpd = gt and gt.getMinutesPerDay and gt:getMinutesPerDay()
	if type(mpd) ~= "number" or mpd <= 0 then return 1.0 end
	return mpd / TUNING_MINUTES_PER_DAY
end

local PENTUP_STRESS_PER_HOUR    = 1.5
local DESPERATE_STRESS_PER_HOUR = 3.0
local DESPERATE_UNHAPPY_PER_HOUR = 3.0
local PENTUP_UNHAPPY_PER_HOUR    = 1.5

local RELEASE_FULL_VALUE = 0
local RELIEF_MIN_SCALE   = 0.35
local RELIEF_STRESS_MAX  = 25
local RELIEF_UNHAPPY_MAX = 18
local RELIEF_BOREDOM_MAX = 30
local SOLO_RELIEF_MULT   = 0.6

local AFTERGLOW_HOURS            = 0.4
local AFTERGLOW_FATIGUE_PER_HOUR = -60
local AFTERGLOW_UNHAPPY_PER_HOUR = -60
local ADDICT_SOLO_MULT   = 0.5

local REFRACTORY_HOURS        = 3
local AFTERGLOW_MIN_REFRACTORY = 0.5

local BC_TIER_EXCEEDED = 0.1
local function _limitExceeded(player)
	local md = player:getModData()
	local untilHour, tier = md.zr_slimitMoodleUntil, md.zr_slimitMoodleTier
	if type(untilHour) ~= "number" or tier ~= BC_TIER_EXCEEDED then return false end
	local gt = getGameTime and getGameTime()
	return (gt and gt:getWorldAgeHours() or 0) < untilHour
end

local DEFEAT_KINK_TRAITS = {
	"Rapist", "Lewd", "Nymphomaniac", "Sexaddict", "Masochist", "submissive", "Necrophiliac",
}

local function hasTrait(player, name)
	return ZR_Trait and ZR_Trait.has and ZR_Trait.has(player, name)
end

local function _surrenderActive(player)
	return ZR_Surrender ~= nil and ZR_Surrender.isActive ~= nil
		and ZR_Surrender.isActive(player) == true
end

local function bumpStat(player, statKey, delta)
	if player == nil then return end
	local stats = player:getStats(); if stats == nil then return end
	local stat = CharacterStat[statKey]; if stat == nil then return end
	local scaled = delta * (stat:getMaximumValue() / 100.0)
	stats:set(stat, stats:get(stat) + scaled)
end

local function _nowHour()
	local gt = getGameTime and getGameTime()
	if not gt then return 0 end
	return gt:getWorldAgeHours()
end

local function _sandbox()
	return SandboxVars and SandboxVars.ZomboRut
end

local function _enabled()
	local sv = _sandbox()
	if not sv then return false end
	if sv.enableMod == false then return false end
	if sv.arousalEnabled == false then return false end
	return true
end

local function _buildHours()
	local sv = _sandbox()
	local h = sv and sv.arousalBuildHours
	if type(h) ~= "number" or h < 1 then h = DEFAULT_BUILD_HOURS end
	return h
end

local function _penaltiesOn()
	local sv = _sandbox()
	return not (sv and sv.arousalPenalties == false)
end

function ZR_Arousal.get(player)
	if not player then return 0 end
	local a = player:getModData().zr_arousal
	if type(a) ~= "number" then return 0 end
	if a < 0 then return 0 end
	if a > 100 then return 100 end
	return a
end

function ZR_Arousal.set(player, value)
	if not player then return end
	if type(value) ~= "number" then return end
	if value < 0 then value = 0 end
	if value > 100 then value = 100 end
	player:getModData().zr_arousal = value
end

function ZR_Arousal.add(player, delta)
	ZR_Arousal.set(player, ZR_Arousal.get(player) + (delta or 0))
end

function ZR_Arousal.tier(player)
	local a = ZR_Arousal.get(player)
	if a >= TIER_DESPERATE then return 4 end
	if a >= TIER_PENTUP    then return 3 end
	if a >= TIER_AROUSED   then return 2 end
	if a >= TIER_STIRRED   then return 1 end
	return 0
end

function ZR_Arousal.isDesperate(player)
	return ZR_Arousal.tier(player) >= 4
end

function ZR_Arousal.bypassComfortGate(player)
	return _enabled() and ZR_Arousal.isDesperate(player)
end

local TIER_MOODLE_VALUE = { [0] = 0.5, 0.4, 0.3, 0.2, 0.1 }

local function _setMoodle(player, value)
	ZR_Moodles.set(MOODLE_NAME, player, value)
end

local _genderIconCache = {}
local function _genderIcon(female)
	local key = female and "F" or "M"
	local c = _genderIconCache[key]
	if c == nil then
		local g = female and "Female" or "Male"
		c = { getTexture("media/ui/ZRArousal_" .. g .. "Desire.png") }
		_genderIconCache[key] = c
	end
	return c[1]
end

local _satisfiedCache
local function _satisfiedIcon()
	if _satisfiedCache == nil then
		_satisfiedCache = { getTexture("media/ui/ZRArousal_Satisfied.png") }
	end
	return _satisfiedCache[1]
end

local function _applyMoodleIcons(player)
	if player == nil then return end
	local icon = _genderIcon(player.isFemale and player:isFemale())
	for lvl = 1, 4 do
		ZR_Moodles.setPicture(MOODLE_NAME, player, 2, lvl, icon)
	end
	local sat = _satisfiedIcon()
	if sat then ZR_Moodles.setPicture(MOODLE_NAME, player, 1, 1, sat) end
end

local function _refreshMoodle(player)
	local value = 0.5
	if _enabled() then
		local ag = player:getModData().zr_arousalAfterglowUntil
		if type(ag) == "number" and _nowHour() < ag and ZR_Arousal.tier(player) == 0
			and not _defeatHeld(player) and not _limitExceeded(player)
		then
			value = 0.6
		else
			value = TIER_MOODLE_VALUE[ZR_Arousal.tier(player)] or 0.5
		end
	end
	pcall(_setMoodle, player, value)
	pcall(_applyMoodleIcons, player)
end

local TIER_SAY_KEY = {
	[2] = "IGUI_ZR_Arousal_Aroused",
	[3] = "IGUI_ZR_Arousal_PentUp",
	[4] = "IGUI_ZR_Arousal_Desperate",
}

local function _monologue(player, allowSay)
	local md = player:getModData()
	local said = md.zr_arousalSaidTier
	if type(said) ~= "number" then said = 0 end
	local t = ZR_Arousal.tier(player)
	if t > said then
		local key = TIER_SAY_KEY[t]
		if allowSay and key then
			pcall(function() player:Say(getText(key)) end)
		end
		md.zr_arousalSaidTier = t
	elseif t < said then
		md.zr_arousalSaidTier = t
	end
end

local BODY_MOD_NAMESPACES = { "rasbomo", "toc", "zomborut", "furrymod" }
local function _isBodyModLoc(loc)
	if loc == nil then return false end
	local s = string.lower(tostring(loc))
	for _, ns in ipairs(BODY_MOD_NAMESPACES) do
		if s:find(ns .. ":", 1, true) then return true end
	end
	return false
end

local function _scriptItemCovers(scriptItem, torsoIdx, groinIdx)
	if not scriptItem or not scriptItem.getBodyLocation
		or not scriptItem.getBloodClothingType then return false, false end
	if _isBodyModLoc(scriptItem:getBodyLocation()) then return false, false end
	local parts = BloodClothingType.getCoveredParts(scriptItem:getBloodClothingType())
	if not parts or not parts.size then return false, false end
	local torso, groin = false, false
	for j = 0, parts:size() - 1 do
		local idx = BloodBodyPartType.ToIndex(parts:get(j))
		if idx == torsoIdx then torso = true end
		if idx == groinIdx then groin = true end
	end
	return torso, groin
end

local function _visualsBare(iv, torsoIdx, groinIdx)
	local torso, groin = false, false
	for i = 0, iv:size() - 1 do
		local visual = iv:get(i)
		local t, g = _scriptItemCovers(
			visual and visual.getScriptItem and visual:getScriptItem(), torsoIdx, groinIdx)
		torso = torso or t
		groin = groin or g
		if torso and groin then return false end
	end
	return true
end

local function _wornBare(worn, torsoIdx, groinIdx)
	local torso, groin = false, false
	for i = 0, worn:size() - 1 do
		local wi = worn:get(i)
		local item = wi and wi.getItem and wi:getItem()
		local t, g = _scriptItemCovers(
			item and item.getScriptItem and item:getScriptItem(), torsoIdx, groinIdx)
		torso = torso or t
		groin = groin or g
		if torso and groin then return false end
	end
	return true
end

local function _necroSpent(body)
	local spent = false
	pcall(function()
		local md = body and body.getModData and body:getModData()
		spent = (md and md.zr_necroSpent == true) or false
	end)
	return spent
end

local function _markNecroSpent(body)
	pcall(function()
		local md = body and body.getModData and body:getModData()
		if md then md.zr_necroSpent = true end
	end)
end

local function _corpseNearby(player, torsoIdx, groinIdx)
	local sq0 = player:getSquare(); if not sq0 then return false, false end
	local cell = player:getCell();  if not cell then return false, false end
	local px, py, pz = sq0:getX(), sq0:getY(), sq0:getZ()
	local any = false
	for dx = -CORPSE_SCAN_RADIUS, CORPSE_SCAN_RADIUS do
		for dy = -CORPSE_SCAN_RADIUS, CORPSE_SCAN_RADIUS do
			local sq = cell:getGridSquare(px + dx, py + dy, pz)
			local bodies = sq and sq:getDeadBodys()
			if bodies and bodies:size() > 0 then
				for i = 0, bodies:size() - 1 do
					local body = bodies:get(i)
					if not _necroSpent(body) then
						any = true
						local bare = false
						pcall(function()
							local worn = body and body.getWornItems and body:getWornItems()
							if not worn or not worn.size then return end
							bare = _wornBare(worn, torsoIdx, groinIdx)
						end)
						if bare then return true, true end
					end
				end
			end
		end
	end
	return any, false
end

local function _nakedZombieNearby(player, torsoIdx, groinIdx)
	local sq0 = player:getSquare(); if not sq0 then return false end
	local cell = player:getCell();  if not cell then return false end
	local px, py, pz = sq0:getX(), sq0:getY(), sq0:getZ()
	for dx = -NAKED_SCAN_RADIUS, NAKED_SCAN_RADIUS do
		for dy = -NAKED_SCAN_RADIUS, NAKED_SCAN_RADIUS do
			local sq = cell:getGridSquare(px + dx, py + dy, pz)
			local moving = sq and sq.getMovingObjects and sq:getMovingObjects()
			if moving and moving.size then
				for i = 0, moving:size() - 1 do
					local o = moving:get(i)
					if o and instanceof(o, "IsoZombie") and o.getItemVisuals then
						local bare = false
						pcall(function()
							local iv = o:getItemVisuals()
							if not iv or not iv.size then return end
							bare = _visualsBare(iv, torsoIdx, groinIdx)
						end)
						if bare then return true end
					end
				end
			end
		end
	end
	return false
end

local function _selfExposed(player)
	if not (ZomboRutScene and ZomboRutScene._findItemCoveringPart) then return false end
	local exposed = false
	pcall(function()
		local torsoIdx = BloodBodyPartType.ToIndex(BloodBodyPartType.Torso_Upper)
		local groinIdx = BloodBodyPartType.ToIndex(BloodBodyPartType.Groin)
		exposed = ZomboRutScene._findItemCoveringPart(player, torsoIdx) == nil
			or ZomboRutScene._findItemCoveringPart(player, groinIdx) == nil
	end)
	return exposed
end

local function _gainMult(player)
	local mult = 1.0
	for i = 1, #TRAIT_BUILD_MULT do
		local entry = TRAIT_BUILD_MULT[i]
		if hasTrait(player, entry[1]) then mult = mult * entry[2] end
	end
	return mult
end

local function _buildRatePerHour(player)
	local rate = (100.0 / _buildHours()) * _gainMult(player)
	local torsoIdx = BloodBodyPartType.ToIndex(BloodBodyPartType.Torso_Upper)
	local groinIdx = BloodBodyPartType.ToIndex(BloodBodyPartType.Groin)
	local nakedCorpse = false
	if hasTrait(player, "Necrophiliac") then
		local corpse
		corpse, nakedCorpse = _corpseNearby(player, torsoIdx, groinIdx)
		if corpse then rate = rate * NECRO_CORPSE_MULT end
	end
	local seen = 0
	for i = 1, #NAKED_ZOMBIE_MULT do
		local entry = NAKED_ZOMBIE_MULT[i]
		if entry[2] > seen and hasTrait(player, entry[1]) then seen = entry[2] end
	end
	if seen > 0 and _nakedZombieNearby(player, torsoIdx, groinIdx) then
		rate = rate * seen
	end
	if _selfExposed(player) then
		rate = rate * SELF_NAKED_MULT
	end
	return rate, nakedCorpse
end

local function _setDefeatHold(player)
	local md = player:getModData()
	if md.zr_arousalDefeatHold == true then return end
	md.zr_arousalDefeatHold = true
	md.zr_arousalActive     = true
	_refreshMoodle(player)
	log("defeat hold ON — encounter climbing from %.0f", ZR_Arousal.get(player))
end

local function _clearDefeatHold(player)
	local md = player:getModData()
	if md.zr_arousalDefeatHold ~= true then return false, false end
	local completed = md.zr_arousalDefeatDone == true
	md.zr_arousalDefeatHold = nil
	md.zr_arousalDefeatDone = nil
	return true, completed
end

local function _actState(player)
	local sceneActive = ZomboRutScene._active and ZomboRutScene._active[player]
	local inScene = sceneActive ~= nil and not (sceneActive.cfg and sceneActive.cfg.isUtility)
	local inSolo  = player:getModData().ZomboRutSexScene == true
	return inScene, inSolo
end

function ZR_Arousal.gain(player, amount, reason)
	if not player then return end
	if not _enabled() then return end
	if type(amount) ~= "number" or amount == 0 then return end
	local mult    = _gainMult(player)
	local applied = amount * mult
	ZR_Arousal.set(player, ZR_Arousal.get(player) + applied)
	local inScene, inSolo = _actState(player)
	_monologue(player, not (inScene or inSolo) and not _defeatHeld(player))
	_refreshMoodle(player)
	log("gain %+.1f x%.2f = %+.1f [%s] → %.0f",
		amount, mult, applied, tostring(reason), ZR_Arousal.get(player))
end

local function _settleAfterMove(player)
	local inScene, inSolo = _actState(player)
	_monologue(player, not (inScene or inSolo) and not _defeatHeld(player))
	_refreshMoodle(player)
end

function ZR_Arousal.increase(player, amount, applyTraits)
	if not player then return end
	if not _enabled() then return end
	if type(amount) ~= "number" or amount <= 0 then return end
	if applyTraits then amount = amount * _gainMult(player) end
	ZR_Arousal.set(player, ZR_Arousal.get(player) + amount)
	_settleAfterMove(player)
	log("increase +%.1f (traits=%s) → %.0f", amount, tostring(applyTraits == true), ZR_Arousal.get(player))
end

function ZR_Arousal.decrease(player, amount, applyTraits)
	if not player then return end
	if not _enabled() then return end
	if type(amount) ~= "number" or amount <= 0 then return end
	if applyTraits then
		local mult = _gainMult(player)
		if mult > 0 then amount = amount / mult end
	end
	ZR_Arousal.set(player, ZR_Arousal.get(player) - amount)
	_settleAfterMove(player)
	log("decrease -%.1f (traits=%s) → %.0f", amount, tostring(applyTraits == true), ZR_Arousal.get(player))
end

function ZR_Arousal.resetForNewCharacter(player)
	if player == nil then return end
	local md = player:getModData(); if md == nil then return end
	md.zr_arousal               = 0
	md.zr_arousalStampHour      = _nowHour()
	md.zr_arousalPreScene       = nil
	md.zr_arousalActive         = nil
	md.zr_arousalSaidTier       = 0
	md.zr_arousalAfterglowUntil = nil
	md.zr_arousalDefeatHold     = nil
	md.zr_arousalDefeatDone     = nil
	md.zr_arousalLastRelease    = nil
	pcall(_refreshMoodle, player)
end

function ZR_Arousal.release(player, kind, full, isDefeat)
	if not player then return end
	if not _enabled() then return end
	local md = player:getModData()
	local pre = md.zr_arousalPreScene
	if type(pre) ~= "number" then pre = ZR_Arousal.get(player) end
	if full == nil then full = true end

	if not full then
		md.zr_arousal         = RELEASE_FULL_VALUE
		md.zr_arousalSaidTier = 0
		_refreshMoodle(player)
		log("release without relief (%s): pre %.0f", tostring(kind), pre)
		return
	end

	local scale = RELIEF_MIN_SCALE + (1.0 - RELIEF_MIN_SCALE) * (pre / 100.0)
	local mult = 1.0
	if kind == "solo" then
		mult = SOLO_RELIEF_MULT
		if hasTrait(player, "Sexaddict") or hasTrait(player, "Nymphomaniac") then
			mult = mult * ADDICT_SOLO_MULT
		end
	end

	local refractory = 1.0
	local lastRelease = md.zr_arousalLastRelease
	if type(lastRelease) == "number" then
		local since = _nowHour() - lastRelease
		if since >= 0 and since < REFRACTORY_HOURS then
			refractory = since / REFRACTORY_HOURS
		end
	end
	md.zr_arousalLastRelease = _nowHour()
	mult = mult * refractory

	bumpStat(player, "STRESS",      -RELIEF_STRESS_MAX  * scale * mult)
	bumpStat(player, "UNHAPPINESS", -RELIEF_UNHAPPY_MAX * scale * mult)
	bumpStat(player, "BOREDOM",     -RELIEF_BOREDOM_MAX * scale * mult)

	local wasPentUp = pre >= TIER_PENTUP
	md.zr_arousal         = RELEASE_FULL_VALUE
	md.zr_arousalSaidTier = 0
	if not isDefeat and refractory >= AFTERGLOW_MIN_REFRACTORY
		and not _limitExceeded(player)
	then
		md.zr_arousalAfterglowUntil = _nowHour() + AFTERGLOW_HOURS
	end
	_refreshMoodle(player)
	if wasPentUp then
		pcall(function() player:Say(getText("IGUI_ZR_Arousal_Relief")) end)
	end
	log("full release (%s): pre %.0f, relief scale %.2f x %.2f (refractory %.2f)",
		tostring(kind), pre, scale, mult, refractory)
end

local function _minuteTick()
	local player = getPlayer()
	if player == nil or player:isDead() then return end
	if player.isNpc and player:isNpc() then return end
	local md = player:getModData()

	if not _enabled() then
		if md.zr_arousal then _refreshMoodle(player) end
		md.zr_arousalStampHour = _nowHour()
		return
	end

	local now  = _nowHour()
	local last = md.zr_arousalStampHour
	if type(last) ~= "number" or last > now then last = now end
	md.zr_arousalStampHour = now
	local dh = now - last
	if dh < 0 then dh = 0 end
	if dh > MAX_TICK_HOURS then
		log("stale stamp: %.3f h gap (stamp %.3f vs now %.3f) — skipped, not banked",
			dh, last, now)
		dh = 0
	end

	if md.zr_arousalDefeatHold == true and not _surrenderActive(player) then
		_clearDefeatHold(player)
		log("defeat hold OFF — orphaned (give-up gone without an end callback)")
	end

	local a = ZR_Arousal.get(player)
	local inScene, inSolo = _actState(player)
	local inAct = inScene or inSolo
	local held  = _defeatHeld(player)

	if inAct and md.zr_arousalActive ~= true then
		md.zr_arousalPreScene = a
	end
	md.zr_arousalActive = inAct

	if inScene and held then
		a = a + DEFEAT_RISE_PER_HOUR * dh * _actScale()
	elseif inAct then
		local rate = inScene and SCENE_DRAIN_PER_HOUR or SOLO_DRAIN_PER_HOUR
		a = a - rate * dh * _actScale()
	else
		local rate, nakedCorpse = _buildRatePerHour(player)
		a = a + rate * dh
		if nakedCorpse and a < NECRO_NAKED_CORPSE_FLOOR then
			a = NECRO_NAKED_CORPSE_FLOOR
			log("naked corpse in view — arousal floored to %d", NECRO_NAKED_CORPSE_FLOOR)
		end
		if not held then
			local ag = md.zr_arousalAfterglowUntil
			if type(ag) == "number" and now < ag then
				if dh > 0 and not _limitExceeded(player) then
					bumpStat(player, "FATIGUE",     AFTERGLOW_FATIGUE_PER_HOUR * dh)
					bumpStat(player, "UNHAPPINESS", AFTERGLOW_UNHAPPY_PER_HOUR * dh)
				end
			elseif ag ~= nil then
				md.zr_arousalAfterglowUntil = nil
			end
			if _penaltiesOn() and dh > 0 then
				local t = ZR_Arousal.tier(player)
				if t >= 4 then
					bumpStat(player, "STRESS",      DESPERATE_STRESS_PER_HOUR  * dh)
					bumpStat(player, "UNHAPPINESS", DESPERATE_UNHAPPY_PER_HOUR * dh)
				elseif t == 3 then
					bumpStat(player, "STRESS",      PENTUP_STRESS_PER_HOUR * dh)
					bumpStat(player, "UNHAPPINESS", PENTUP_UNHAPPY_PER_HOUR * dh)
				end
			end
		end
	end

	if a < 0 then a = 0 end
	if a > 100 then a = 100 end
	md.zr_arousal = a

	_monologue(player, not inAct and not held)
	_refreshMoodle(player)
end

Events.EveryOneMinute.Add(_minuteTick)

local NOISE_TIER_RADIUS = { 5, 15, 35, 35 }
local NOISE_INTERVAL_MS = 5000
local _noiseLastMs = {}

local function _noiseWhileAsleep()
	local sv = _sandbox()
	return sv ~= nil and sv.arousalNoiseSleep == true
end

local function _noiseTick(player)
	if player == nil or player:isDead() then return end
	if not player:isLocalPlayer() then return end
	if not _enabled() then return end
	if not _penaltiesOn() then return end

	local tier = ZR_Arousal.tier(player)
	local radius = NOISE_TIER_RADIUS[tier]
	if radius == nil then return end
	if player:isAsleep() and not _noiseWhileAsleep() then return end

	local _, inSolo = _actState(player)
	if inSolo then return end
	if ZomboRutScene._active and ZomboRutScene._active[player] then return end
	if _surrenderActive(player) then return end

	local num = player:getPlayerNum()
	local now = getTimestampMs()
	local last = _noiseLastMs[num]
	if type(last) == "number" and now - last < NOISE_INTERVAL_MS then return end
	_noiseLastMs[num] = now

	pcall(function()
		if addSound then
			addSound(player, player:getX(), player:getY(), player:getZ(), radius, radius)
		end
	end)
	log("noise ping: tier %d, radius %d squares", tier, radius)
end

Events.OnPlayerUpdate.Add(_noiseTick)

ZomboRutScene.registerSceneStartCallback(function(player, active)
	if not _enabled() then return end
	if active and active.cfg and active.cfg.isUtility then return end
	if _surrenderActive(player) then
		_setDefeatHold(player)
		if active then active.zrArousalHeld = true end
		return
	end
	local md = player:getModData()
	if active and active.launchSource == "fromCorpse"
		and hasTrait(player, "Necrophiliac") then
		md.zr_arousal = 100
		log("corpse scene start — Necrophiliac arousal maxed")
	end
	md.zr_arousalPreScene = ZR_Arousal.get(player)
	md.zr_arousalActive   = true
	md.zr_arousalSaidTier = ZR_Arousal.tier(player)
	_refreshMoodle(player)
end)

ZomboRutScene.registerSceneEndCallback(function(player, active, result)
	if not _enabled() then return end
	if active and active.cfg and active.cfg.isUtility then return end
	if active and active.struggleEscaped then return end
	if active and active.zrArousalHeld then
		if result == "Released" and active.outroFinished then
			player:getModData().zr_arousalDefeatDone = true
		end
		return
	end
	if result ~= "Released" then return end
	if not (active and active.outroFinished) then return end
	local isDefeat = (active and active.cfg and active.cfg.isDefeat) or false
	local full = true
	if isDefeat then
		full = false
		for i = 1, #DEFEAT_KINK_TRAITS do
			if hasTrait(player, DEFEAT_KINK_TRAITS[i]) then full = true; break end
		end
	end
	if active and active.launchSource == "fromCorpse" then
		if active.partnerRef then _markNecroSpent(active.partnerRef) end
		if active.body       then _markNecroSpent(active.body)       end
		log("corpse scene completed — body=%s partner=%s no longer arouse (spent)",
			tostring(active.body), tostring(active.partnerRef))
	end
	ZR_Arousal.release(player, "scene", full, isDefeat)
end)

local function _settleEncounter(player)
		if player == nil then return end
		local held, completed = _clearDefeatHold(player)
		if not held then return end
		if not _enabled() then
			_refreshMoodle(player)
			return
		end
		if not completed then
			_refreshMoodle(player)
			log("defeat hold OFF — encounter over, no scene completed (nothing settled)")
			return
		end
		local md = player:getModData()
		md.zr_arousalPreScene = ZR_Arousal.get(player)
		local kink = false
		for i = 1, #DEFEAT_KINK_TRAITS do
			if hasTrait(player, DEFEAT_KINK_TRAITS[i]) then kink = true; break end
		end
		ZR_Arousal.release(player, "scene", kink, true)
		log("defeat hold OFF — encounter over, settled from %.0f (relief: %s)",
			md.zr_arousalPreScene, tostring(kink))
end

if ZR_Surrender and ZR_Surrender.registerEscapeCallback then
	ZR_Surrender.registerEscapeCallback(function(player, _st)
		_settleEncounter(player)
	end)
end
if ZR_Surrender and ZR_Surrender.registerEndCallback then
	ZR_Surrender.registerEndCallback(function(player, _st)
		_settleEncounter(player)
	end)
end

Events.OnGameStart.Add(function()
	local player = getPlayer()
	if player == nil then return end
	if _clearDefeatHold(player) then
		log("defeat hold OFF — stale hold cleared on load")
		_refreshMoodle(player)
	end
end)

table.insert(AnimationHandler.ActionEvents.Perform, function(action)
	local ch = action and action.character
	if ch == nil then return end
	if ch.isNpc and ch:isNpc() then return end
	ZR_Arousal.release(ch, "solo", true, false)
end)

return ZR_Arousal
