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

local BodyCount = require "ZomboRut/ZomboRutBodyCount"
require "ZomboRut/ZR_Surrender"

local ZomboRutScene = require "ZomboRut/ZomboRutScene"

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 bumpPanicFloor(player, floor)
	if player == nil then return end
	local stats = player:getStats(); if stats == nil then return end
	local current = stats:get(CharacterStat.PANIC)
	if current < floor then stats:set(CharacterStat.PANIC, floor) end
end

local function addItemSafe(player, itemFullType)
	if player == nil then return end
	local inv = player:getInventory(); if inv == nil then return end
	pcall(function() inv:AddItem(itemFullType) end)
end

local function addXpBoost(player, perkKey, xp)
	if player == nil then return end
	local perk = Perks[perkKey]; if perk == nil then return end
	pcall(function() player:getXp():AddXP(perk, xp) end)
end

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

local function sceneCompleted(active, result)
	return result == "Released" and active ~= nil and active.outroFinished == true
		and active.struggleEscaped ~= true
end

local VIRGIN_DEFEAT_LINES = {
	"IGUI_ZR_Trait_NoGoingBack_Defeat1",
	"IGUI_ZR_Trait_NoGoingBack_Defeat2",
	"IGUI_ZR_Trait_NoGoingBack_Defeat3",
}
local function virginLineKey(active)
	if active and active.cfg and active.cfg.isDefeat then
		return VIRGIN_DEFEAT_LINES[ZombRand(#VIRGIN_DEFEAT_LINES) + 1]
	end
	return "IGUI_ZR_Trait_NoGoingBack"
end

local _encDefer = {}
local settleEncounterEffects

local function _becomeCorpseSilently(z)
	if z.becomeCorpseSilently then return z:becomeCorpseSilently() end
	if not z.dieNetwork then return nil end
	local body = z:dieNetwork(nil, nil, false, nil)
	if z.setOnDeathDone then z:setOnDeathDone(true) end
	return body
end

local SURRENDER_START = {
	{ "Wimp", function(p)
		bumpStat(p, "PANIC", 30)
		bumpStat(p, "UNHAPPINESS", 10)
	end },
	{ "dominate", function(p)
		bumpStat(p, "PANIC", 20)
	end },
}

local SCENE_START = {
	{ "Innocent", function(p) bumpStat(p, "PANIC", 30) end },
}

local SURRENDER_TICK = {
	{ "Wimp", function(p) bumpPanicFloor(p, 60) end },
}

local SCENE_TICK_RATES = {
	{ "Masochist",    { PAIN = -1.0, STRESS = -1.0 } },
	{ "Sexaddict",    { STRESS = -1.0 } },
	{ "Nymphomaniac", { STRESS = -2.0, UNHAPPINESS = -1.0 } },
}
local _sceneTickLastMs
local function sceneTickRates(player)
	local now = getTimestampMs()
	local gap = _sceneTickLastMs and (now - _sceneTickLastMs) or 0
	_sceneTickLastMs = now
	if gap <= 0 or gap > 400 then return end
	local dt = gap / 1000
	for i = 1, #SCENE_TICK_RATES do
		local entry = SCENE_TICK_RATES[i]
		if hasTrait(player, entry[1]) then
			for statKey, perSec in pairs(entry[2]) do
				bumpStat(player, statKey, perSec * dt)
			end
		end
	end
end

local VICIOUS_HP_FLOOR   = 50
local VICIOUS_HP_PER_SEC = 1.5
local _viciousLastMs
local function viciousZombieTick(player, active)
	if not hasTrait(player, "viciousZombies") then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	if active.cfg.isUtility then return end
	local bd = player:getBodyDamage(); if bd == nil then return end
	if bd:getHealth() <= VICIOUS_HP_FLOOR then return end
	local now = getTimestampMs()
	local gap = _viciousLastMs and (now - _viciousLastMs) or 0
	_viciousLastMs = now
	if gap <= 0 or gap > 400 then return end
	if bd.ReduceGeneralHealth then bd:ReduceGeneralHealth(VICIOUS_HP_PER_SEC * gap / 1000) end
end

local SCENE_END = {
	{ "Sexaddict",    function(p) bumpStat(p, "UNHAPPINESS", -10); bumpStat(p, "STRESS", -10) end },
	{ "Nymphomaniac", function(p) bumpStat(p, "UNHAPPINESS", -15); bumpStat(p, "STRESS", -15) end },
	{ "Lewd",         function(p) bumpStat(p, "UNHAPPINESS",  -5); bumpStat(p, "STRESS",  -5) end },
	{ "submissive",   function(p) bumpStat(p, "STRESS",       -3) end },
	{ "Masochist",    function(p) bumpStat(p, "UNHAPPINESS",  -5) end },
	{ "Experienced",  function(p) bumpStat(p, "UNHAPPINESS",  -3) end },

	{ "Innocent",     function(p) bumpStat(p, "UNHAPPINESS", 15); bumpStat(p, "STRESS", 10) end },
	{ "Virgin",       function(p) bumpStat(p, "UNHAPPINESS", 25); bumpStat(p, "PAIN",   10) end },
	{ "Inexperienced",function(p) bumpStat(p, "UNHAPPINESS", 10) end },
	{ "dpress",       function(p) bumpStat(p, "UNHAPPINESS",  5) end },

	{ "succubus",     function(p)
		local stats = p:getStats(); if stats == nil then return end
		bumpStat(p, "THIRST", -2)
		bumpStat(p, "HUNGER", -1)
		bumpStat(p, "ENDURANCE", 10)
	end },

	{ "betterAlive",  function(p) addXpBoost(p, "Strength", 5); addXpBoost(p, "Fitness", 5) end, releasedOnly = true },
}

local SURRENDER_END = {
	{ "Wimp", function(p)
		local stats = p:getStats(); if stats == nil then return end
		local panic = stats:get(CharacterStat.PANIC)
		if panic > 80 then
			local traits = p:getCharacterTraits()
			if traits and ZR_Trait and ZR_Trait.Traumatized
				and not ZR_Trait.has(p, "Traumatized")
			then
				traits:add(ZR_Trait.Traumatized)
				p:Say(getText("IGUI_ZR_Trait_WontForget"))
				if ZR_AutoSurrender and ZR_AutoSurrender.invalidateMods then
					ZR_AutoSurrender.invalidateMods(p)
				end
			end
		end
		bumpStat(p, "STRESS", 5)
	end },
}

local function dispatch(table_, player, ...)
	for i = 1, #table_ do
		local entry = table_[i]
		local trait, fn = entry[1], entry[2]
		if trait == "_always" then
			fn(player, ...)
		elseif hasTrait(player, trait) then
			fn(player, ...)
		end
	end
end

local function dispatchSceneEnd(player, _active, result)
	if not sceneCompleted(_active, result) then
		return
	end
	for i = 1, #SCENE_END do
		local entry = SCENE_END[i]
		local trait, fn = entry[1], entry[2]
		if entry.releasedOnly and result ~= "Released" then
		elseif trait == "_always" then
			fn(player)
		elseif hasTrait(player, trait) then
			fn(player)
		end
	end
end

local function onTick(player)
	if player == nil then return end
	if player.isNpc and player:isNpc() then return end

	if ZomboRutScene.isPlayerInScene(player) then
		local active = ZomboRutScene._active and ZomboRutScene._active[player]
		if not (active and active.cfg and active.cfg.isUtility) then
			sceneTickRates(player)
		end
		viciousZombieTick(player, active)
		return
	end

	if ZR_Surrender and ZR_Surrender.isActive and ZR_Surrender.isActive(player)
		and ZR_Surrender.getPhase(player) == "surrender"
	then
		dispatch(SURRENDER_TICK, player)
	end
end

Events.OnPlayerUpdate.Add(onTick)

local function registerSurrenderHooks()
	if not ZR_Surrender or not ZR_Surrender.registerStartCallback then return end
	if ZR_Surrender._traitEffectsRegistered then return end
	ZR_Surrender._traitEffectsRegistered = true

	ZR_Surrender.registerStartCallback(function(player, _st)
		_encDefer[player] = nil
		dispatch(SURRENDER_START, player)
	end)

	ZR_Surrender.registerEscapeCallback(function(player, _st)
		if settleEncounterEffects then settleEncounterEffects(player) end
	end)

	ZR_Surrender.registerEndCallback(function(player, _st)
		if settleEncounterEffects then settleEncounterEffects(player) end
		if _st and _st.struggleWonAmnesty then return end
		dispatch(SURRENDER_END, player)
	end)
end

registerSurrenderHooks()
Events.OnGameStart.Add(registerSurrenderHooks)

local SUCCUBUS_LIMIT = 4
local SUCCUBUS_WINDOW_HOURS = 12

local function succubusKill(player, partner, result, active)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "succubus") then return end
	if partner == nil or not partner.isZombie or not partner:isZombie() then return end
	if partner:getVariableBoolean("Bandit") then return end

	local now = getGameTime():getWorldAgeHours()
	local md = player:getModData()
	local prior = md.succubusKills or {}

	local kept = {}
	for i = 1, #prior do
		if now - prior[i] < SUCCUBUS_WINDOW_HOURS then
			kept[#kept + 1] = prior[i]
		end
	end

	if #kept >= SUCCUBUS_LIMIT then
		md.succubusKills = kept
		return
	end

	kept[#kept + 1] = now
	md.succubusKills = kept

	pcall(function() if partner.Kill then partner:Kill(nil, false) end end)
	pcall(function() _becomeCorpseSilently(partner) end)
end

local SICKNESS_BY_LEVEL = { [1] = 0, [2] = 1, [3] = 3, [4] = 6, [5] = 10 }
local SICKNESS_SOFT_CAP = 85
local function zombieSexSickness(player, partner, result, active)
	if not sceneCompleted(active, result) then return end
	if partner == nil then return end
	if not partner:isZombie() then return end
	if partner:getVariableBoolean("Bandit") then return end
	if active and active.ctx and active.ctx.condomActive then return end
	local level = (SandboxVars.ZomboRut and SandboxVars.ZomboRut.zombieSexSickness) or 3
	local base  = SICKNESS_BY_LEVEL[level] or 3
	if base == 0 then return end
	local mult = 1.0
	if hasTrait(player, "unkempt") then mult = 3.0
	elseif hasTrait(player, "neatTidy") then mult = 0.5
	elseif hasTrait(player, "lovesClean") then mult = 0.2 end
	local stats = player:getStats(); if stats == nil then return end
	local current = stats:get(CharacterStat.FOOD_SICKNESS)
	if current >= SICKNESS_SOFT_CAP then return end
	local target = current + base * mult
	if target > SICKNESS_SOFT_CAP then target = SICKNESS_SOFT_CAP end
	stats:set(CharacterStat.FOOD_SICKNESS, target)
end

local GROIN_PAIN_BASE  = 30
local GROIN_PAIN_HOURS = 24
local GROIN_PAIN_SCENE_THRESHOLD = 5

local function groinPainLevel(player, active)
	if player and player.isFemale and not player:isFemale() then return 0 end
	if hasTrait(player, "Rapist") then return 0 end
	if hasTrait(player, "Lewd")   then return 0 end
	local lvl = GROIN_PAIN_BASE * 0.5
	local wasVirgin = hasTrait(player, "Virgin") or (active and active.zrWasVirgin == true)
	if hasTrait(player, "Masochist") then lvl = lvl * 0.1
	elseif wasVirgin then lvl = lvl * 2.0
	elseif hasTrait(player, "Inexperienced") then lvl = lvl * 1.25
	end
	return lvl
end

local function applyGroinPain(player, level)
	if not level or level <= 0 then return end
	if level > 100 then level = 100 end
	local bd = player:getBodyDamage(); if bd == nil then return end
	local bp = bd:getBodyPart(BodyPartType.Groin); if bp == nil then return end
	local current = 0
	pcall(function() current = bp:getAdditionalPain() or 0 end)
	if level > current then
		pcall(function() bp:setAdditionalPain(level) end)
	end
end

local function recordGroinScene(player, now)
	local md = player:getModData()
	local times = md.zr_groinSceneTimes
	if type(times) ~= "table" then times = {} end
	local cutoff = now - GROIN_PAIN_HOURS
	local kept = {}
	for i = 1, #times do
		local t = times[i]
		if t and t >= cutoff then kept[#kept + 1] = t end
	end
	kept[#kept + 1] = now
	md.zr_groinSceneTimes = kept
	return #kept
end

local function defeatGroinPain(player, active, result)
	if not sceneCompleted(active, result) then return end
	if active and active.cfg and active.cfg.isOral then return end
	local level = groinPainLevel(player, active)
	if level <= 0 then return end

	local now = getGameTime():getWorldAgeHours()
	local sceneCount = recordGroinScene(player, now)
	if not (hasTrait(player, "Virgin") or (active and active.zrWasVirgin == true)
		or sceneCount >= GROIN_PAIN_SCENE_THRESHOLD)
	then
		return
	end

	applyGroinPain(player, level)
end

local CORPSE_UNHAPPINESS_DEFAULT  =  10
local CORPSE_UNHAPPINESS_NECRO    = -15
local function corpseSceneMood(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not active or active.liveOriginal then return end
	if hasTrait(player, "Necrophiliac") then
		bumpStat(player, "UNHAPPINESS", CORPSE_UNHAPPINESS_NECRO)
	else
		bumpStat(player, "UNHAPPINESS", CORPSE_UNHAPPINESS_DEFAULT)
	end
end

local function wimpDefeatPenalty(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "Wimp") then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	bumpStat(player, "UNHAPPINESS", 30)
	bumpStat(player, "STRESS",      20)
end

local function rapistDefeatMood(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "Rapist") then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	bumpStat(player, "STRESS",      -15)
	bumpStat(player, "UNHAPPINESS", -40)
end

local function itFollowsDeath(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "itFollows") then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	pcall(function()
		if player.Kill then player:Kill(nil, false) end
	end)
end

local function resetTraumaClock(player)
	local md = player:getModData(); if md == nil then return end
	local th = md.timedHandler
	if type(th) ~= "table" then th = {}; md.timedHandler = th end
	th.hoursSinceLastDefeat = 0
	th.traumaHours = nil
end

local function defeatTraumatize(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	resetTraumaClock(player)
	if hasTrait(player, "Traumatized") then return end
	if hasTrait(player, "Nymphomaniac") then return end
	if hasTrait(player, "dpress")        then return end
	local lewd = hasTrait(player, "Lewd")
	local trigger = false
	if hasTrait(player, "Virgin") or (active and active.zrWasVirgin == true) then
		trigger = true
	elseif (hasTrait(player, "Inexperienced") or hasTrait(player, "Experienced")) and not lewd then
		trigger = true
	end
	if not trigger then return end
	local traits = player:getCharacterTraits()
	if traits and ZR_Trait and ZR_Trait.Traumatized then
		traits:add(ZR_Trait.Traumatized)
		player:Say(getText("IGUI_ZR_Trait_WontForget"))
		if ZR_AutoSurrender and ZR_AutoSurrender.invalidateMods then
			ZR_AutoSurrender.invalidateMods(player)
		end
	end
end

local function loseVirgin(player, active, result)
	if not sceneCompleted(active, result) then return end
	if active and active.cfg and active.cfg.isOral then return end
	if not hasTrait(player, "Virgin") then return end
	local traits = player:getCharacterTraits()
	if traits and ZR_Trait and ZR_Trait.Virgin then
		traits:remove(ZR_Trait.Virgin)
		if not (active and active.zrVirginNarrated) then
			player:Say(getText(virginLineKey(active)))
		end
		if ZR_AutoSurrender and ZR_AutoSurrender.invalidateMods then
			ZR_AutoSurrender.invalidateMods(player)
		end
	end
end

local function youngBuckEffects(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "youngBuck") then return end
	bumpStat(player, "ENDURANCE", -5)
	addXpBoost(player, "Fitness", 1)
	if not (active and active.cfg and active.cfg.isDefeat) then
		bumpStat(player, "STRESS", -2)
	end
end

local function lowEffortEffects(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "lowEffort") then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	bumpStat(player, "ENDURANCE",   -5)
	bumpStat(player, "UNHAPPINESS", -2)
end

local function swingerVarietyMood(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "swinger") then return end
	local partner = active and active.liveOriginal
	if partner == nil then return end
	local wasNew = active and active.zrPartnerWasNew
	if wasNew == nil then
		wasNew = BodyCount.RunID and BodyCount.RunID(player, partner)
	end
	if wasNew then
		bumpStat(player, "STRESS",      -5)
		bumpStat(player, "UNHAPPINESS", -3)
	end
end

local INNOCENT_NIGHTMARE_HOURS = 30
local INNOCENT_NIGHTMARE_UNHAPPY_PER_HOUR = 1.0
local INNOCENT_NIGHTMARE_STRESS_PER_HOUR  = 0.5

local function innocentNightmareStart(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "Innocent") then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	pcall(function()
		player:getModData().zr_innocentNightmaresUntil =
			getGameTime():getWorldAgeHours() + INNOCENT_NIGHTMARE_HOURS
	end)
end

local SEXADDICT_GRACE_HOURS         = 12
local SEXADDICT_RISE_UNHAPPY        = 1.5
local PROMISCUOUS_GRACE_HOURS       = 18
local PROMISCUOUS_RISE_STRESS       = 1.0

local function recordSceneTimestamp(player, active, result)
	if not sceneCompleted(active, result) then return end
	pcall(function()
		player:getModData().zr_lastSceneHour = getGameTime():getWorldAgeHours()
	end)
end

local function hourlyTraitTick()
	local player = getPlayer()
	if player == nil or player:isDead() then return end
	if player.isNpc and player:isNpc() then return end
	local stats = player:getStats(); if stats == nil then return end
	local md    = player:getModData() or {}
	local now   = getGameTime():getWorldAgeHours()

	if hasTrait(player, "Innocent") then
		local until_h = md.zr_innocentNightmaresUntil
		if until_h ~= nil then
			if now < until_h then
				bumpStat(player, "UNHAPPINESS", INNOCENT_NIGHTMARE_UNHAPPY_PER_HOUR)
				bumpStat(player, "STRESS",      INNOCENT_NIGHTMARE_STRESS_PER_HOUR)
			else
				md.zr_innocentNightmaresUntil = nil
			end
		end
	end

	if hasTrait(player, "Sexaddict") then
		local last = md.zr_lastSceneHour
		if last ~= nil and (now - last) >= SEXADDICT_GRACE_HOURS then
			bumpStat(player, "UNHAPPINESS", SEXADDICT_RISE_UNHAPPY)
		end
	end

	if hasTrait(player, "promiscuous") then
		local last = md.zr_lastSceneHour
		if last ~= nil and (now - last) >= PROMISCUOUS_GRACE_HOURS then
			bumpStat(player, "STRESS", PROMISCUOUS_RISE_STRESS)
		end
	end

end

Events.EveryHours.Add(hourlyTraitTick)

local function partnerIsMale(active)
	local partner = active and active.liveOriginal
	if partner == nil then return false end
	local female = false
	pcall(function()
		if partner.isFemale then female = partner:isFemale() end
	end)
	return not female
end

local function proteinDietEffect(player, active, result)
	if not hasTrait(player, "proteinDiet") then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	if not partnerIsMale(active) then return end
	bumpStat(player, "HUNGER", -10)
	pcall(function()
		local nutr = player.getNutrition and player:getNutrition()
		if nutr == nil then return end
		if nutr.setProteins and nutr.getProteins then
			nutr:setProteins((nutr:getProteins() or 0) + 25)
		end
		if nutr.setCalories and nutr.getCalories then
			nutr:setCalories((nutr:getCalories() or 0) + 80)
		end
	end)
end

local THIRST_CHANGE_PER_SCENE = 12.5

local function hydrationJunkieEffect(player, _active, _result)
	if not hasTrait(player, "hydration") then return end
	bumpStat(player, "THIRST", -THIRST_CHANGE_PER_SCENE)
end

local function drainedEffect(player, _active, _result)
	if not hasTrait(player, "drained") then return end
	bumpStat(player, "THIRST", THIRST_CHANGE_PER_SCENE)
end

local function smokesAfterEffect(player)
	if not hasTrait(player, "smokesAfter") then return end
	addItemSafe(player, "Base.Cigarettes")
end

local SPUNK_DRAIN_BASE = { inPrime = 15, spunkDefault = 20, AARP = 35 }
local SPUNK_FITNESS_BASELINE = 5
local SPUNK_FITNESS_HALVING  = 5

local function spunkSceneDrain(player, active, result)
	if not sceneCompleted(active, result) then return end
	local base
	if     hasTrait(player, "inPrime")      then base = SPUNK_DRAIN_BASE.inPrime
	elseif hasTrait(player, "spunkDefault") then base = SPUNK_DRAIN_BASE.spunkDefault
	elseif hasTrait(player, "AARP")         then base = SPUNK_DRAIN_BASE.AARP
	end
	if not base then return end
	local fit = SPUNK_FITNESS_BASELINE
	pcall(function()
		if Perks and Perks.Fitness then fit = player:getPerkLevel(Perks.Fitness) end
	end)
	local mult = math.pow(2, (SPUNK_FITNESS_BASELINE - fit) / SPUNK_FITNESS_HALVING)
	local net  = base * mult
	local stats = player:getStats(); if stats == nil then return end
	local cur     = stats:get(CharacterStat.ENDURANCE)
	local startV  = active and active.zrEndAtStart
	local regen01 = 0
	if type(startV) == "number" and cur > startV then regen01 = cur - startV end
	local gross = net + regen01 * 100.0
	bumpStat(player, "ENDURANCE", -gross)
end

local FIGHTER_PARTS = {
	"Hand_L", "Hand_R", "ForeArm_L", "ForeArm_R",
	"UpperArm_L", "UpperArm_R", "Torso_Upper", "Torso_Lower",
	"Groin", "UpperLeg_L", "UpperLeg_R", "LowerLeg_L", "LowerLeg_R",
}
local FIGHTER_SCRATCH_CHANCE_PCT = 50
local FIGHTER_BLEED_CHANCE_PCT   =  0
local FIGHTER_KILL_CHANCE_PCT    = 50
local FIGHTER_SCRATCH_MAX        =  2

local function fighterScratches(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "fighter") then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	local bd = player:getBodyDamage(); if bd == nil then return end

	local partner = active.liveOriginal
	if partner ~= nil and ZombRand(100) < FIGHTER_KILL_CHANCE_PCT then
		pcall(function() if partner.Kill then partner:Kill(nil, false) end end)
		pcall(function() _becomeCorpseSilently(partner) end)
	end

	if ZombRand(100) >= FIGHTER_SCRATCH_CHANCE_PCT then return end

	local count = ZombRand(FIGHTER_SCRATCH_MAX) + 1
	local pickedNames = {}
	local guard = 0
	while #pickedNames < count and guard < 50 do
		guard = guard + 1
		local name = FIGHTER_PARTS[ZombRand(#FIGHTER_PARTS) + 1]
		local already = false
		for _, n in ipairs(pickedNames) do
			if n == name then already = true; break end
		end
		if not already then pickedNames[#pickedNames + 1] = name end
	end

	for _, name in ipairs(pickedNames) do
		local part = BodyPartType[name]
		if part ~= nil then
			local bp = bd:getBodyPart(part)
			if bp ~= nil then
				pcall(function() bp:setScratched(true, true) end)
				pcall(function() bp:setBleeding(false) end)
				pcall(function() bp:setBleedingTime(0) end)
			end
		end
	end
end

local GLASS_BONES_PARTS = {
	"ForeArm_L", "ForeArm_R", "UpperArm_L", "UpperArm_R",
	"UpperLeg_L", "UpperLeg_R", "LowerLeg_L", "LowerLeg_R",
}
local GLASS_BONES_CHANCE_PCT = 15

local function glassBonesFracture(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "glassBones") then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	if ZombRand(100) >= GLASS_BONES_CHANCE_PCT then return end
	local bd = player:getBodyDamage(); if bd == nil then return end
	local name = GLASS_BONES_PARTS[ZombRand(#GLASS_BONES_PARTS) + 1]
	local part = BodyPartType[name]; if part == nil then return end
	local bp = bd:getBodyPart(part); if bp == nil then return end
	pcall(function() bp:setFractureTime(ZombRand(30, 60)) end)
end

local function cardioJunkieXP(player, active, result)
	if not sceneCompleted(active, result) then return end
	if not hasTrait(player, "cardioJunkie") then return end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	addXpBoost(player, "Fitness",  3)
	addXpBoost(player, "Strength", 2)
end

settleEncounterEffects = function(player)
	local agg = _encDefer[player]
	_encDefer[player] = nil
	if not agg or agg.scenes == 0 then return end
	local active, partner = agg.lastActive, agg.lastPartner
	if agg.wasVirgin and active then active.zrWasVirgin = true end
	dispatchSceneEnd(player, active, "Released")
	zombieSexSickness(player, partner, "Released", active)
	wimpDefeatPenalty(player, active, "Released")
	rapistDefeatMood(player, active, "Released")
	defeatTraumatize(player, active, "Released")
	fighterScratches(player, active, "Released")
	glassBonesFracture(player, active, "Released")
	cardioJunkieXP(player, active, "Released")
	itFollowsDeath(player, active, "Released")
end

ZomboRutScene.registerSceneStartCallback(function(player, active)
	if active and not (active.cfg and active.cfg.isUtility) then
		pcall(function()
			local st = player:getStats()
			if st then active.zrEndAtStart = st:get(CharacterStat.ENDURANCE) end
		end)
	end
	if not (active and active.cfg and active.cfg.isDefeat) then return end
	dispatch(SCENE_START, player)
end)

ZomboRutScene.registerSceneEndCallback(function(player, active, result)
	local _surrAct = false
	pcall(function()
		_surrAct = (ZR_Surrender and ZR_Surrender.isActive
			and ZR_Surrender.isActive(player)) == true
	end)
	if active and active.cfg and active.cfg.isUtility then
		return
	end
	if active and active.struggleEscaped then
		return
	end
	local partner = active and active.liveOriginal
	if _surrAct then
		local agg = _encDefer[player] or { scenes = 0 }
		_encDefer[player] = agg
		if sceneCompleted(active, result) then
			agg.scenes      = agg.scenes + 1
			agg.lastActive  = active
			agg.lastPartner = partner
			agg.wasVirgin   = agg.wasVirgin or (active.zrWasVirgin == true)
		end
		defeatGroinPain(player, active, result)
		loseVirgin(player, active, result)
		swingerVarietyMood(player, active, result)
		innocentNightmareStart(player, active, result)
		recordSceneTimestamp(player, active, result)
		smokesAfterEffect(player)
		proteinDietEffect(player, active, result)
		hydrationJunkieEffect(player, active, result)
		drainedEffect(player, active, result)
		spunkSceneDrain(player, active, result)
		youngBuckEffects(player, active, result)
		lowEffortEffects(player, active, result)
		if partner and BodyCount.increaseCount and not (active and active.zrPartnerCounted) then
			if sceneCompleted(active, result) then
				pcall(BodyCount.increaseCount, player, partner)
			end
		end
		succubusKill(player, partner, result, active)
		return
	end
	if BodyCount.flushPendingBark then BodyCount.flushPendingBark(player) end
	dispatchSceneEnd(player, active, result)
	zombieSexSickness(player, partner, result, active)
	defeatGroinPain(player, active, result)
	corpseSceneMood(player, active, result)
	wimpDefeatPenalty(player, active, result)
	rapistDefeatMood(player, active, result)
	defeatTraumatize(player, active, result)
	loseVirgin(player, active, result)
	spunkSceneDrain(player, active, result)
	youngBuckEffects(player, active, result)
	lowEffortEffects(player, active, result)
	swingerVarietyMood(player, active, result)
	innocentNightmareStart(player, active, result)
	smokesAfterEffect(player)
	proteinDietEffect(player, active, result)
	hydrationJunkieEffect(player, active, result)
	drainedEffect(player, active, result)
	fighterScratches(player, active, result)
	glassBonesFracture(player, active, result)
	cardioJunkieXP(player, active, result)
	recordSceneTimestamp(player, active, result)
	itFollowsDeath(player, active, result)
	if partner and BodyCount.increaseCount and not (active and active.zrPartnerCounted) then
		if sceneCompleted(active, result) then
			pcall(BodyCount.increaseCount, player, partner)
		end
	end
	succubusKill(player, partner, result, active)
end)

local MID_SCENE_PROGRESS = 0.5

local function sceneProgress(active)
	local dur = active and (active.effectiveDuration or (active.cfg and active.cfg.duration))
	if type(dur) ~= "number" or dur <= 0 then return 0 end
	return (active.dragTicks or 0) / (dur * 60)
end

local function fireMidSceneReactions(player, active)
	if not active or active.zrMidReactionsFired then return end
	if active.cfg and active.cfg.isUtility then return end
	if active.struggleEscaped then
		active.zrMidReactionsFired = true
		return
	end
	if sceneProgress(active) < MID_SCENE_PROGRESS then return end
	active.zrMidReactionsFired = true

	local partner = active.liveOriginal
	if partner and BodyCount.increaseCount then
		if BodyCount.RunID then
			active.zrPartnerWasNew = BodyCount.RunID(player, partner)
		end
		active.zrPartnerCounted = true
		pcall(BodyCount.increaseCount, player, partner)
	end
end

local function virginAtMainStart(player, active)
	if not active or active.zrVirginStripped then return end
	if active.cfg and (active.cfg.isUtility or active.cfg.isOral) then return end
	if active.struggleEscaped then return end
	if ZR_Struggle and ZR_Struggle.isPending and ZR_Struggle.isPending(player) then return end
	if not hasTrait(player, "Virgin") then return end
	local introDone = false
	pcall(function()
		introDone = player:getVariableString("ZR_IntroDone") == "true"
	end)
	if not introDone then return end
	local traits = player:getCharacterTraits()
	if not (traits and ZR_Trait and ZR_Trait.Virgin) then return end
	active.zrVirginStripped = true
	active.zrWasVirgin      = true
	active.zrVirginNarrated = true
	traits:remove(ZR_Trait.Virgin)
	pcall(function() player:Say(getText(virginLineKey(active))) end)
	if ZR_AutoSurrender and ZR_AutoSurrender.invalidateMods then
		ZR_AutoSurrender.invalidateMods(player)
	end
end

ZomboRutScene.registerSceneUpdateCallback(function(player, active)
	virginAtMainStart(player, active)
	fireMidSceneReactions(player, active)
end)

ZR_TraitEffects = ZR_TraitEffects or {}
function ZR_TraitEffects.deflower(player)
	if player == nil or not hasTrait(player, "Virgin") then return false end
	local traits = player:getCharacterTraits()
	if not (traits and ZR_Trait and ZR_Trait.Virgin) then return false end
	traits:remove(ZR_Trait.Virgin)
	player:Say(getText("IGUI_ZR_Trait_NoGoingBack"))
	if ZR_AutoSurrender and ZR_AutoSurrender.invalidateMods then
		ZR_AutoSurrender.invalidateMods(player)
	end
	return true
end
