CopBotAI.cs
using Sandbox;
using SWB.Base;
using SWB.Demo;
using SWB.Player;
using SWB.Shared;
using System;
using System.Collections.Generic;
using System.Linq;

// Sits alongside a DemoBot component on the same GameObject. DemoBot
// (from SWB) already gives us health, damage, death/respawn, and a
// weapon inventory for free — this component only adds the two things
// DemoBot doesn't: pathfinding movement and the decision to shoot.
//
// Key fact this relies on: Weapon.OnUpdate() skips its entire firing
// loop when Owner.IsBot is true, and CanShoot() bypasses the Input.Down
// checks for bots. That's SWB's intended hook for bot AI to drive a
// weapon directly instead of faking player input.
public sealed class CopBotAI : Component
{
	[RequireComponent]
	NavMeshAgent Agent { get; set; }

	DemoBot _bot;

	// Distance at which the cop stops closing in and just fights from
	// where it's standing, instead of continuing to push toward the
	// player. Tune this per-weapon later if needed.
	[Property]
	public float EngageRange { get; set; } = 800f;

	[Property]
	public float RetargetInterval { get; set; } = 0.3f;

	// What cops are allowed to carry, as WeaponRegistry ClassNames. One is
	// picked at random per spawn.
	//
	// SWB's demo player hands out ALL SIX weapons on respawn and DemoBot
	// then picks one at random, so without this a cop might be holding
	// anything.
	//
	// Pick by damage-per-second, not damage per shot - and note bots
	// ignore semi-auto restrictions (Weapon.Shoot.cs bypasses the
	// Input.Pressed check for them), so every weapon fires at its full
	// RPM in a cop's hands. That makes the Colt (25 dmg @ 700 RPM) the
	// single deadliest gun here, well above the ScarH. Veresk is the
	// gentlest of the automatics; Revolver is slow enough to be fair.
	[Property]
	public List<string> WeaponPool { get; set; } = new() { "swb_veresk" };

	// Multiplies the weapon's own damage for cops only. Applied to this
	// cop's private clone of the weapon, so the same gun in the player's
	// hands is untouched.
	[Property]
	public float DamageScale { get; set; } = 0.6f;

	// Multiplies the weapon's own spread for cops only - higher is less
	// accurate. Cops otherwise aim dead at your eye position every tick,
	// which is far more precise than any human.
	[Property]
	public float SpreadScale { get; set; } = 2.5f;

	// Shots fired before a cop pauses. Together with BurstPause this is
	// the real damage-per-second dial - far more effective than DamageScale,
	// because it controls how much lead is in the air rather than how much
	// each round hurts.
	[Property]
	public int BurstShots { get; set; } = 3;

	[Property]
	public float BurstPause { get; set; } = 0.9f;

	// Keep this in step with MissionSpawner.MinSpawnDistanceFromPlayers -
	// they're the two halves of the same rule, one for new cops and one
	// for cops coming back after being killed.
	[Property]
	public float MinSpawnDistanceFromPlayers { get; set; } = 2000f;

	int _shotsThisBurst = 0;
	TimeSince _timeSinceBurstEnded = 0;

	// How long the cop must have had unbroken line of sight before it's
	// allowed to fire - without this it snaps a shot off the instant LOS
	// goes true, which reads as robotic/unfair rather than alert.
	[Property]
	public float ReactionTime { get; set; } = 0.2f;

	// How fast a cop can swing its aim, in degrees per second.
	//
	// Aim used to snap instantly onto the target, which had a nasty side
	// effect: a cop could fire on the same tick it acquired you, with its
	// muzzle still pointing at the wall it had just come around. Those
	// rounds go into the scenery, and from the player's end that is
	// indistinguishable from cops firing at nothing.
	[Property]
	public float TurnSpeed { get; set; } = 540f;

	// How fast the visible model pivots, in degrees per second. Slower
	// than TurnSpeed on purpose - a person swings their weapon onto a
	// target before their feet catch up - but not by much, because the
	// model is the only part of this the player can actually see.
	//
	// SWB's own body rotation is far slower than this and is what made
	// cops appear not to react at all; see TurnBodyTowards.
	[Property]
	public float BodyTurnSpeed { get; set; } = 450f;

	// How far off target the aim is still allowed to be when the trigger
	// is pulled, in degrees. Nothing fires while the muzzle is further out
	// than this, so a cop has to finish turning before it shoots.
	//
	// Not zero: a little slop is what keeps cops from being pinpoint, and
	// it's also what lets them fire while strafing.
	[Property]
	public float MaxAimErrorDegrees { get; set; } = 8f;

	// Beyond this, cops hold fire even with a clear line. A cop putting
	// rounds on you from the far side of the map with an SMG is
	// technically correct and reads as gunfire coming out of nowhere.
	[Property]
	public float MaxFireRange { get; set; } = 3000f;

	// How much wider a cop's first rounds are than its settled accuracy.
	// 5 means the opening shots of an engagement go five times further
	// off than the ones a couple of seconds later.
	//
	// This is what gives you a beat to react to being shot at. It reads
	// much better than the two alternatives: a perfect first shot is
	// unfair, and permanent inaccuracy makes cops look like they aren't
	// aiming at all.
	[Property]
	public float OpeningSpreadMultiplier { get; set; } = 5f;

	// How long a cop needs to hold sight on you before its fire is fully
	// settled. Break line of sight and it starts over.
	[Property]
	public float AimSettleTime { get; set; } = 1.5f;

	// This cop's fully-settled spread, taken from the weapon registry at
	// spawn. The live value on the weapon is rewritten every shot.
	float _settledSpread = 0.1f;

	// Radius of the sight-check trace. Matches SWB's own default bullet
	// radius so the check and the shot agree about what counts as blocked
	// - a const rather than a property because they only stay in step if
	// nobody tunes one of them in isolation.
	const float SightTraceRadius = 2f;

	// --- Eye position sanity ----------------------------------------------
	//
	// PlayerBase.EyePos is Head.WorldPosition + EyeOffset, and Head is a
	// bone-driven GameObject. That makes it wrong in two situations that
	// both land squarely on cops:
	//
	//   - The frame one spawns. PlayerBase parks the object at
	//     (0, 0, -999999) to hide it until it has loaded, and the head
	//     bone doesn't catch up until the model has been animated once.
	//   - Any time the body has been disabled, which is what ragdolling
	//     does. A disabled object stops being animated, so its bones
	//     freeze wherever they were when it died.
	//
	// Every sight check and every bullet starts at the eye, so when it's
	// wrong the cop is seeing and shooting from inside a wall or from
	// under the map. A position derived from the root transform is always
	// approximately right, which beats being exactly wrong.

	const float FallbackEyeHeight = 64f;
	const float MaxEyeOffsetFromRoot = 128f;

	bool EyePosIsSane => _bot.EyePos.Distance( WorldPosition ) <= MaxEyeOffsetFromRoot;

	Vector3 SafeEyePos => EyePosIsSane
		? _bot.EyePos
		: WorldPosition + Vector3.Up * FallbackEyeHeight;

	/// <summary>
	/// Exactly the point this cop sights and fires from. Exposed so
	/// diagnostics elsewhere can use the real value instead of assuming
	/// one - an assumed eye position is how a report ends up describing a
	/// line the cop never actually took.
	/// </summary>
	public Vector3 EyePosition => _bot.IsValid() ? SafeEyePos : WorldPosition;

	/// <summary>
	/// How far this cop's Body has drifted from its root. Body carries the
	/// model, the capsule collider and the hitboxes, so anything above
	/// roughly zero means what you can see and shoot is not where the cop
	/// actually is.
	/// </summary>
	public float BodyDrift => _bot.IsValid() && _bot.Body.IsValid()
		? _bot.Body.WorldPosition.Distance( WorldPosition )
		: -1f;

	/// <summary>True once this cop has passed its spawn alignment checks.</summary>
	public bool IsSettled => _ready;

	// --- Spawn placement ---------------------------------------------------

	// How many spawn points to try before giving up and using one anyway.
	[Property]
	public int SpawnPlacementAttempts { get; set; } = 6;

	// How far around a spawn marker a cop may be placed. Keeps two cops
	// sent to the same marker from arriving on identical coordinates.
	[Property]
	public float SpawnScatterRadius { get; set; } = 96f;

	// Roughly a citizen's shoulders and standing height. Used to ask
	// whether there's actually room for a cop where we're about to put one.
	const float SpawnClearanceRadius = 16f;
	const float SpawnClearanceHeight = 68f;

	// Lifts the sweep off the floor. Without it the test sphere rests on
	// the ground and every spawn point reads as blocked.
	const float SpawnClearanceGroundGap = 6f;

	// Things that shouldn't count as "blocked" when testing for room: our
	// own trigger volumes, the sky, and any corpses lying around.
	static readonly string[] SpawnClearanceIgnoreTags =
	{
		TagsHelper.Trigger,
		TagsHelper.Sky,
		TagsHelper.DeadPlayer,
		TagsHelper.ViewModel
	};

	TimeSince _timeSinceRetarget = 0;
	GameObject _target;

	bool _hadLineOfSight = false;
	TimeSince _timeSinceLineOfSightAcquired;

	// Where we last told the agent to go. A NavMeshAgent follows its own
	// path by itself once given a destination, so re-issuing MoveTo every
	// tick just makes it throw away the path it was halfway through and
	// build a new one - which is both wasteful and worse at cornering.
	Vector3 _destination;
	bool _hasDestination = false;

	// How far the target has to move before the path is worth rebuilding.
	// Small enough that cops don't run at where you used to be, large
	// enough that ordinary strafing doesn't trigger a fresh search.
	[Property]
	public float RepathDistance { get; set; } = 64f;

	// How long the agent is allowed to report no movement at all before we
	// assume its path has gone stale and ask for another one. See the
	// stall handling in OnFixedUpdate.
	[Property]
	public float StuckTimeout { get; set; } = 0.5f;

	TimeSince _timeSinceAgentMoved = 0;

	// --- Movement personality ---------------------------------------------
	//
	// Cops that all run at identical speed along identical lines toward an
	// identical point read as one object with several bodies. These three
	// break that up without needing any real decision-making.

	// Travel speed with no contact at all, as a fraction of full. Below 1
	// because a cop crossing ground toward a reported robbery is moving
	// with purpose, not sprinting flat out.
	[Property]
	public float AdvanceSpeedScale { get; set; } = 0.7f;

	// Travel speed while the target is visible but nothing is being fired.
	// This is the "we have eyes on, move up carefully" pace.
	[Property]
	public float CombatSpeedScale { get; set; } = 0.45f;

	// Travel speed while actually engaging. Low on purpose: planting your
	// feet to shoot is most of what makes a shooter look deliberate rather
	// than like it's running on rails.
	[Property]
	public float FiringSpeedScale { get; set; } = 0.15f;

	// Inside this distance cops ease off further the closer they get, so
	// the final approach onto a building is a careful walk rather than the
	// same pace they crossed the street at.
	[Property]
	public float CautionDistance { get; set; } = 1200f;

	// What the caution ramp bottoms out at, as a fraction of whatever the
	// state above already decided. A const because it only exists to give
	// the ramp somewhere to go - CautionDistance is the dial that matters.
	const float CautionSpeedFloor = 0.5f;

	// Spread on EngageRange, so a squad doesn't all halt on one neat ring
	// at the same distance. Rolled per cop, per spawn.
	[Property]
	public float EngageRangeVariation { get; set; } = 150f;

	// How far to either side of the player a cop will pick its approach
	// point. Bigger values spread a squad wider but send them on longer
	// routes to get there.
	[Property]
	public float ApproachSpread { get; set; } = 150f;

	// Per-cop speed jitter, as a fraction either way. 0.15 means each one
	// moves somewhere between 85% and 115% of the configured speed, so a
	// group strings out instead of arriving as a block.
	[Property]
	public float SpeedVariation { get; set; } = 0.15f;

	// Distance over which the approach offset fades to nothing. A const
	// rather than a property because it only has to be "far enough out
	// that the spread has room to matter" - there's nothing to tune.
	const float ApproachSpreadFadeDistance = 600f;

	Vector3 _approachOffset;
	float _speedVariation = 1f;

	// Rolled fresh on every spawn, so a cop that dies and comes back isn't
	// the same one behaviourally - and so a squad wiped and replaced
	// doesn't rebuild itself in the same formation.
	void RollMovementPersonality()
	{
		// Random point in a disc around the target. Square-rooting the
		// radius is what keeps them evenly spread across the area: without
		// it, picking radius uniformly bunches everyone toward the middle,
		// because the outer rings are bigger and deserve proportionally
		// more of the picks.
		var angle = Random.Shared.NextSingle() * MathF.Tau;
		var radius = ApproachSpread * MathF.Sqrt( Random.Shared.NextSingle() );

		_approachOffset = new Vector3( MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius, 0f );

		// NextSingle is 0..1, so this maps to -1..1 before scaling.
		_speedVariation = 1f + (Random.Shared.NextSingle() * 2f - 1f) * SpeedVariation;

		// Where this particular cop decides it's close enough and starts
		// fighting instead of advancing. Varied so a squad holds at a
		// spread of depths rather than every one of them stopping on the
		// same circle around you.
		_engageRange = MathF.Max(
			EngageRange + (Random.Shared.NextSingle() * 2f - 1f) * EngageRangeVariation,
			64f );
	}

	float _engageRange = 500f;

	// How far from the target to go looking for a navigable point when the
	// target itself isn't standing on the navmesh. Roughly "how far below
	// or beside you a cop will settle for". Too large and they'll path to
	// somewhere unhelpfully far away; too small and they give up.
	[Property]
	public float NavSearchRadius { get; set; } = 512f;

	// Pathing diagnostics. Solved the "cops far away don't move" bug (the
	// destination wasn't on the navmesh - see NavigableDestination), so
	// it's off by default now. Tick it on if cop movement ever looks wrong
	// again; it logs what the agent is actually doing rather than leaving
	// you to guess.
	[Property]
	public bool DebugPathing { get; set; } = false;

	[Property]
	public float DebugLogInterval { get; set; } = 1f;

	TimeSince _timeSinceDebugLog = 0;
	Vector3 _debugLastPosition;
	bool _debugHasLastPosition = false;

	// Tracks IsAlive across ticks so we can catch the dead-to-alive
	// transition - see RelocateToPoliceSpawn().
	bool _wasAlive = false;

	// Set up on the first tick that can actually complete it, not once in
	// OnAwake.
	//
	// This used to be an OnAwake that gave up permanently if the DemoBot
	// component wasn't resolvable yet. Component initialisation order on a
	// freshly cloned prefab isn't guaranteed, so that could lose - and
	// when it lost it lost badly and silently. _bot stayed null forever,
	// so OnFixedUpdate bailed on its first line and the cop had no brain
	// at all. Worse, the two Agent lines below never ran either, leaving
	// UpdatePosition at the prefab's value: the NavMeshAgent then drove
	// the GameObject's transform directly, which does no collision. A cop
	// that never thinks and walks through walls - which is precisely the
	// broken spawn, and precisely why it only happened to some of them.
	//
	// Retrying costs a null check per tick and cannot fail permanently.
	bool _initialised = false;

	bool EnsureInitialised()
	{
		_bot ??= Components.Get<DemoBot>();

		if ( _bot is null )
			return false;

		if ( _initialised )
			return true;

		_initialised = true;
		MatchAgentToBotMovement();

		return true;
	}

	// DemoBot/PlayerBase already drives this GameObject's actual position
	// via its own CharacterController-based movement (it applies gravity
	// and calls CharacterController.Move() every fixed update regardless
	// of IsBot). NavMeshAgent's default behaviour is to ALSO directly
	// overwrite the GameObject's position every frame — two systems
	// fighting over the same Transform, which is what sent bots flying out
	// of the map. Turning these off makes the agent a pure pathfinder: we
	// read its desired direction and feed it into the bot's own
	// WishVelocity instead, so PlayerBase's proven movement code (gravity,
	// ground snapping, collision) does the actual moving.
	//
	// Asserted every tick rather than once at startup. It's two bool
	// writes, and the cost of it being wrong even briefly is a cop that
	// teleports along its path through solid geometry.
	void KeepAgentOffTheTransform()
	{
		// Guarded because this runs before every other check in the tick,
		// including during a scene change - at which point the agent may
		// already have been torn down while this component gets one last
		// update. That was the null reference after "Mission failed".
		if ( !Agent.IsValid() )
			return;

		Agent.UpdatePosition = false;
		Agent.UpdateRotation = false;
	}

	// The agent's steering output is capped at its own MaxSpeed, and we
	// feed that output straight into the bot as a wish velocity - so the
	// agent's speed setting silently becomes the cop's top speed.
	//
	// The prefab had it at 120 while PlayerBase.RunSpeed is 290. Cops were
	// moving slower than a WALKING player (160), which is why they could
	// never close on anyone who kept moving. Taking the number from the
	// bot means the two can't drift apart again when either is tuned.
	void MatchAgentToBotMovement()
	{
		var authoredSpeed = Agent.MaxSpeed;

		Agent.MaxSpeed = _bot.RunSpeed;

		// Acceleration is in units per second squared, so leaving it while
		// raising the top speed would mean cops take proportionally longer
		// to get up to it. Scaling by the same ratio keeps whatever
		// ramp-up feel the prefab was authored with.
		if ( authoredSpeed > 0f )
			Agent.Acceleration *= Agent.MaxSpeed / authoredSpeed;
	}

	protected override void OnFixedUpdate()
	{
		if ( !EnsureInitialised() )
			return;

		KeepAgentOffTheTransform();

		// The cop prefab is network spawned, so this component exists on
		// every client as well as the host. IsProxy is true on the
		// machines that don't own it - they receive its position over the
		// network and must not run a second copy of its brain, or two
		// machines end up pathing the same cop to two different places and
		// fighting over the result.
		//
		// The host owns every cop, so this is the only guard needed.
		if ( IsProxy )
			return;

		// First breath: put this cop where it belongs and kit it out.
		//
		// This only ever runs once now. See DespawnOnDeath for why a cop
		// never gets a second life.
		if ( _bot.IsAlive && !_wasAlive )
		{
			RelocateToPoliceSpawn();
			ApplyCopLoadout();
			RollMovementPersonality();
		}

		// Last breath: hand the corpse over and take this object out.
		if ( !_bot.IsAlive && _wasAlive )
			DespawnOnDeath();

		_wasAlive = _bot.IsAlive;

		if ( !_bot.IsAlive )
			return;

		// Nothing below here runs until this cop is verifiably in one
		// piece. See SettleAfterSpawn.
		if ( !_ready && !SettleAfterSpawn() )
			return;

		// Checked before anything else this tick does, so it compares the
		// position the controller actually settled on last tick with the
		// one it settled on this tick.
		DetectPassThrough();

		// The agent isn't moving this GameObject (UpdatePosition is off),
		// so it has no idea where the CharacterController actually put us
		// or how fast we're really going. Feeding both back every tick is
		// what keeps the engine's own path-following and crowd avoidance
		// working off reality instead of off its own dead reckoning -
		// without the velocity in particular, cops steer around each other
		// as though everyone were standing still.
		//
		// Horizontal only: the CharacterController's velocity carries
		// gravity on Z, and handing the agent a permanent -400 downward
		// component makes its steering solve for a fall it isn't doing.
		Agent.SetAgentPosition( WorldPosition );
		Agent.Velocity = _bot.Velocity.WithZ( 0f );

		// Runs every tick regardless of target/LOS state, so a cop that
		// loses sight of its target mid-reload still finishes reloading
		// instead of getting stuck (see MaintainWeapon for why this has
		// to be driven manually at all).
		MaintainWeapon();

		// Switching targets no longer needs to force a re-path: the
		// destination check below notices on its own that the new target
		// is somewhere else, so there's nothing to flag here.
		if ( _timeSinceRetarget >= RetargetInterval )
		{
			_timeSinceRetarget = 0;
			_target = FindNearestPlayer();
		}

		if ( _target is null )
		{
			_bot.WishVelocity = Vector3.Zero;
			Agent.Stop();
			_hasDestination = false;
			return;
		}

		var toTarget = _target.WorldPosition - WorldPosition;
		var distance = toTarget.Length;
		var hasLineOfSight = HasLineOfSight( _target );

		if ( hasLineOfSight && !_hadLineOfSight )
			_timeSinceLineOfSightAcquired = 0;

		// Losing sight ends the engagement rather than pausing it, so a
		// cop that re-acquires you starts a fresh burst instead of
		// resuming a half-finished one from thirty seconds ago.
		if ( !hasLineOfSight )
			_shotsThisBurst = 0;

		_hadLineOfSight = hasLineOfSight;

		// Aim only while the target is actually visible. A cop that has
		// lost sight holds its last heading rather than tracking you
		// through the wall - so stepping out of cover costs it a moment to
		// swing back onto you, instead of finding it already aimed.
		//
		// Aim from our eyes at THEIR eyes, matching the line
		// HasLineOfSight just verified. Using the root positions instead
		// would aim feet-to-feet while firing from head height, which
		// skews the shot - badly so when there's any height difference
		// between the two.
		//
		// 180 means "pointing the exact opposite way", which is the right
		// starting assumption when we can't see them: nothing fires.
		var aimError = 180f;

		if ( hasLineOfSight )
			aimError = AimAt( TargetEyePos( _target ) );

		// Every condition that has to hold before a trigger gets pulled,
		// worked out BEFORE moving, because being in a firefight is one of
		// the things that decides how fast this cop travels.
		//
		//   seen      the sight trace landed ON this player, not on a wall
		//             and not on nothing at all
		//   inRange   close enough that gunfire reads as aimed at you
		//   reacted   has held sight for ReactionTime without a break
		//   onTarget  has finished turning and the muzzle is on them
		var inRange = distance <= MaxFireRange;
		var reacted = _timeSinceLineOfSightAcquired >= ReactionTime;
		var onTarget = aimError <= MaxAimErrorDegrees;

		// SWB fires the bullet from Owner.EyePos, not from the position we
		// checked line of sight with. When the head bone is stale those
		// two disagree, and the round leaves from wherever the eye is
		// stuck - which on a freshly spawned or just-unragdolled cop can
		// be inside a wall or under the map. Sight says clear, the bullet
		// starts somewhere else entirely, and you get shot through
		// geometry. Hold fire until the body agrees with itself.
		var canTrustEye = EyePosIsSane;

		var engaging = hasLineOfSight && inRange && reacted && onTarget && canTrustEye;

		// How fast to actually travel, as a fraction of what the agent
		// asked for. People don't sprint flat out through a gunfight:
		// they move up fast while there's nothing shooting at them, slow
		// to a walk once they're in contact, and barely shift their feet
		// while they're actually putting rounds downrange.
		//
		// This does a second job too. The agent's steering cuts corners,
		// and the faster it's allowed to go the wider it cuts - so backing
		// off the throttle in contact is also what stops a cop carrying
		// its own momentum into the wall beside you.
		var speedScale = _speedVariation;

		if ( engaging )
			speedScale *= FiringSpeedScale;
		else if ( hasLineOfSight )
			speedScale *= CombatSpeedScale;
		else
			speedScale *= AdvanceSpeedScale;

		// On top of the state pace, ease off with distance. This is the
		// bit that reads as caution: a cop crossing open ground is moving,
		// and the same cop twenty feet from the door is picking its way in.
		speedScale *= MathX.Lerp(
			CautionSpeedFloor, 1f,
			MathX.Clamp( distance / CautionDistance, 0f, 1f ) );

		// Close the distance until in engage range, or chase if we've
		// lost sight of them. Otherwise hold position and fight.
		// Agent.WishVelocity is the agent's own speed-scaled desired
		// velocity along its calculated path — same field a human
		// player's input would populate, just sourced from pathing
		// instead of Input.AnalogMove.
		// Hold and fight once inside this cop's own standoff distance, so
		// long as it can see the target. Without eyes on, keep advancing
		// regardless of how close the target's last known position was -
		// standing still in a corridor you can't see down isn't caution,
		// it's a cop that's given up.
		var chasing = distance > _engageRange || !hasLineOfSight;
		var usedFallback = false;

		if ( chasing )
		{
			// Give the agent a destination and then leave it alone. It
			// runs its own path-following every tick - corners, doorways,
			// keeping clear of the other cops - and MoveTo is how you
			// change its mind, not how you drive it. Re-issuing it every
			// frame discards the path mid-follow and asks for a new
			// search, which is what made cops hug corners badly.
			//
			// So: only when the target has actually gone somewhere else.
			//
			// Each cop aims at its own point NEAR the player rather than
			// at the player exactly. Everyone pathing to one identical
			// point is what makes a squad move as a single file down the
			// same line; giving each one its own approach spreads them
			// across the space and reads as a group closing in from
			// several angles. The offset fades out as they arrive, so the
			// last stretch still comes at you rather than past you.
			var spread = MathX.Clamp( distance / ApproachSpreadFadeDistance, 0f, 1f );
			var destination = NavigableDestination( _target.WorldPosition + _approachOffset * spread );

			if ( !_hasDestination || destination.Distance( _destination ) > RepathDistance )
			{
				Agent.MoveTo( destination );
				_destination = destination;
				_hasDestination = true;
			}

			var pathVelocity = Agent.WishVelocity;

			if ( !pathVelocity.IsNearZeroLength )
				_timeSinceAgentMoved = 0;

			// A zero wish velocity means the engine has nothing to follow:
			// no path was found, or the one it had has been invalidated.
			// A single stalled tick is normal (the search is asynchronous),
			// so only a sustained stall counts as one.
			//
			// Throw the destination away when that happens so a fresh
			// request goes in next tick, and restart the clock so the new
			// path gets a fair run before we call it stalled too.
			if ( pathVelocity.IsNearZeroLength && _timeSinceAgentMoved > StuckTimeout )
			{
				_hasDestination = false;
				_timeSinceAgentMoved = 0;
			}

			// Last resort while the path is dead: walk straight at them.
			//
			// Gated on line of sight, which is the whole fix for cops
			// grinding into walls. Charging in a straight line at someone
			// you can SEE is a reasonable thing to do and the line is by
			// definition clear. Charging in a straight line at someone you
			// CAN'T see means charging at whatever is between you - and
			// since a dead path and a wall in the way tend to happen
			// together, that's precisely when the old version would pick
			// a wall and lean on it. Now a cop that's lost both its path
			// and its target holds still and waits for a new path.
			usedFallback = pathVelocity.IsNearZeroLength && hasLineOfSight;

			_bot.WishVelocity = usedFallback
				? toTarget.WithZ( 0 ).Normal * _bot.RunSpeed * speedScale
				: pathVelocity * speedScale;

			// Facing is set by the aim when there's something to aim at.
			// When there isn't, point it along the path instead - done
			// here rather than above because it needs the velocity we just
			// worked out.
			if ( !hasLineOfSight )
				FaceTravelDirection();
		}
		else
		{
			Agent.Stop();
			_bot.WishVelocity = Vector3.Zero;
			_hasDestination = false;
			_timeSinceAgentMoved = 0;
		}

		// Worked out above, before the movement block, because being in
		// contact is part of what sets the pace. All four conditions are
		// re-evaluated every tick, so any of them going false stops the
		// shooting immediately - mid-burst included.
		//
		// Firing is otherwise independent of movement, which is what lets
		// a cop shoot while still closing the distance.
		if ( engaging )
			TryShoot();

		if ( DebugPathing )
			LogPathingState( distance, chasing, hasLineOfSight, usedFallback, aimError );
	}

	// Dumps everything the agent is reporting, once per DebugLogInterval.
	// The two numbers that matter most:
	//
	//  moved=   how far this cop ACTUALLY travelled in world space since
	//           the last log. Ground truth - if this is ~0 while chasing,
	//           it's genuinely stuck regardless of what anything else says.
	//
	//  agentOff= distance between where the cop really is and where the
	//           NavMeshAgent thinks it is. Should be ~0 because we call
	//           SetAgentPosition every tick. If it's large, the agent is
	//           pathing from the wrong place entirely - and a spawn point
	//           sitting slightly off the navmesh would do exactly that
	//           while still looking fine in the editor's path checker.
	void LogPathingState( float distance, bool chasing, bool hasLineOfSight, bool usedFallback, float aimError )
	{
		if ( _timeSinceDebugLog < DebugLogInterval )
			return;

		var moved = _debugHasLastPosition ? WorldPosition.Distance( _debugLastPosition ) : 0f;
		_debugLastPosition = WorldPosition;
		_debugHasLastPosition = true;
		_timeSinceDebugLog = 0;

		var agentOffset = Agent.AgentPosition.Distance( WorldPosition );
		var target = Agent.TargetPosition.HasValue ? Agent.TargetPosition.Value.ToString() : "none";

		Log.Info(
			$"[cop] {GameObject.Name} " +
			$"dist={distance:0} " +
			$"chasing={chasing} " +
			$"los={hasLineOfSight} " +
			$"aimErr={aimError:0}deg " +
			$"moved={moved:0.0} " +
			$"agentOff={agentOffset:0.0} " +
			$"navigating={Agent.IsNavigating} " +
			$"agentWish={Agent.WishVelocity.Length:0.0} " +
			$"agentVel={Agent.Velocity.Length:0.0} " +
			$"botWish={_bot.WishVelocity.Length:0.0} " +
			$"fallback={usedFallback} " +
			$"syncPos={Agent.SyncAgentPosition} " +
			$"tgt={target}" );
	}

	// PlayerBase.Respawn() sends a cop to a random generic SpawnPoint
	// whenever it's called with no explicit transform - which is every
	// automatic call PlayerBase makes itself: OnStart()'s initial spawn
	// and OnDeath()'s 2-second delayed respawn. Trying to preempt each of
	// those calls individually is fragile (each is a different async path
	// with its own timing - that's what caused the original spawn-race
	// bug). Reacting to the result instead is robust: any time this cop
	// flips from dead to alive, Respawn() has already fully finished, so
	// correcting the position here can never lose a race, and it covers
	// every respawn path at once instead of needing a patch per call site.
	void RelocateToPoliceSpawn()
	{
		// Same no-spawn perimeter the wave spawner uses. This path matters
		// just as much: cops die constantly during a job, so without it
		// the same cop would keep popping back into existence next to you
		// however careful the initial spawn was.
		GameObject point = null;
		var position = Vector3.Zero;
		var placed = false;

		// Don't take the first answer on faith. A spawn marker can be
		// nudged into a wall in the editor, and snapping it to the navmesh
		// can land it on the far side of thin geometry - either way the
		// CharacterController starts embedded, and a controller that
		// starts embedded resolves by pushing out through the nearest
		// face. That is the cop you saw walking through objects, and it's
		// also why it then shot you through them: a sight trace that
		// begins inside a wall is already past the surface.
		//
		// So try several and take one there's genuinely room to stand in.
		for ( var attempt = 0; attempt < SpawnPlacementAttempts; attempt++ )
		{
			// Passing ourselves in so we aren't treated as occupying the
			// point we're about to leave.
			point = PoliceSpawns.Pick( Scene, MinSpawnDistanceFromPlayers, GameObject );

			if ( point is null )
			{
				Log.Warning( $"{GameObject.Name}: no GameObjects tagged '{PoliceSpawns.ActiveTag}' found in the scene." );
				return;
			}

			// Scatter around the marker instead of landing exactly on it.
			// Even with the point chosen well, two cops sent to identical
			// coordinates arrive inside one another, and the physics
			// push-out that separates overlapping bodies is perfectly
			// capable of ejecting one through a wall. A metre or two of
			// jitter removes the overlap entirely.
			var angle = Random.Shared.NextSingle() * MathF.Tau;
			var radius = SpawnScatterRadius * MathF.Sqrt( Random.Shared.NextSingle() );
			var scatter = new Vector3( MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius, 0f );

			// Land ON the navmesh rather than wherever the scatter landed,
			// so the agent has somewhere to path from - and so the jitter
			// can't push a cop off the walkable area.
			position = NavigableDestination( point.WorldPosition + scatter );

			if ( IsSpawnClear( position ) )
			{
				placed = true;
				break;
			}
		}

		if ( !placed )
		{
			// Every candidate was obstructed. Spawning anyway is still
			// better than a cop that never arrives, but say so - it means
			// the map's spawn points want moving.
			Log.Warning( $"{GameObject.Name}: no clear '{PoliceSpawns.ActiveTag}' point after " +
				$"{SpawnPlacementAttempts} tries - spawning in a blocked spot. Check the map's spawn markers." );
		}

		WorldPosition = position;
		WorldRotation = point.WorldRotation;
		_skipNextMoveCheck = true;

		// Closed until proven lined up - see SettleAfterSpawn.
		_ready = false;
		_timeSinceRelocated = 0;

		// Tell the network this was a teleport, not travel.
		//
		// The cop prefab is network spawned, and so is every child of it -
		// Body, and every bone under Body. Their transforms replicate with
		// interpolation, which smooths between the last position and the
		// new one. That is right for walking and wrong for a jump of this
		// size: without clearing it, the body spends the next few seconds
		// sliding across the map after a root that is already there, and
		// since Body carries the model, the hitboxes and the capsule
		// collider, what you see and what you can shoot both lag with it.
		//
		// PlayerBase.Respawn() does the same thing after its own teleport,
		// for the same reason. We move the cop again afterwards, so it has
		// to be done again here.
		GameObject.Network.ClearInterpolation();

		// Whatever the agent was doing before, it was doing it somewhere
		// else. Start clean from where we actually are.
		Agent.Stop();
		Agent.SetAgentPosition( WorldPosition );
		_hasDestination = false;
		_timeSinceAgentMoved = 0;
	}

	// --- Spawn settling -----------------------------------------------------
	//
	// A cop is teleported to its spawn point and, until now, turned loose in
	// the same tick. That is the root of a whole family of problems: a
	// teleport of two thousand units does not take effect everywhere at
	// once. The network transform interpolates toward the new position over
	// the next few frames, the NavMeshAgent is still holding the old one,
	// the CharacterController may carry velocity from before, and the model
	// - which owns the hitboxes and the capsule - trails behind the root
	// while all that resolves. Letting a cop walk and shoot through that
	// window is what produced bodies that were not where the cop was.
	//
	// So it does not walk and does not shoot until every one of those
	// agrees. Normally that costs a tick or two of standing still, which is
	// invisible. When it does not resolve, the console says which check
	// failed rather than leaving a subtly broken cop loose.
	bool _ready = false;
	TimeSince _timeSinceRelocated = 0;
	int _settleAttempts = 0;

	// How long to keep trying before giving up on a placement and picking a
	// different spawn point.
	const float SettleTimeout = 1f;

	// How many placements to try before letting the cop go anyway. A cop
	// standing still forever is worse than a slightly wrong one, but it
	// should never be silent about it.
	const int MaxSettleAttempts = 3;

	// Tolerances. Generous enough not to trip on ordinary animation, tight
	// enough that anything they let through is genuinely lined up.
	const float MaxBodyDrift = 8f;
	const float MaxAgentDrift = 8f;
	const float MaxNavDrift = 64f;

	// Re-asserts everything we can control, then checks whether the things
	// we cannot control have caught up yet. Returns true once the cop is
	// cleared to act.
	bool SettleAfterSpawn()
	{
		// Stand still and hold fire while settling. Both are set every tick
		// rather than once, because the agent will happily keep producing a
		// wish velocity in the meantime.
		_bot.WishVelocity = Vector3.Zero;
		Agent.Stop();

		ForceAlignment();

		if ( IsLinedUp( out var problem ) )
		{
			_ready = true;
			return true;
		}

		if ( _timeSinceRelocated < SettleTimeout )
			return false;

		// This placement is not resolving. Try a different spawn point
		// rather than waiting on the same bad one forever.
		_settleAttempts++;

		if ( _settleAttempts < MaxSettleAttempts )
		{
			Log.Warning( $"{GameObject.Name}: spawn didn't settle ({problem}) - trying another point." );
			RelocateToPoliceSpawn();
			return false;
		}

		// Out of attempts. Let it go, but say so loudly - a cop released in
		// this state is exactly the one that will misbehave, and this line
		// is the difference between knowing that and guessing.
		Log.Warning( $"{GameObject.Name}: RELEASED UNSETTLED after {MaxSettleAttempts} attempts - {problem}. " +
			"Expect this cop to behave oddly." );

		_ready = true;
		return true;
	}

	// The things we can simply set, set every tick until the cop is
	// released. Cheap, and idempotent once they are already correct.
	void ForceAlignment()
	{
		if ( _bot.Body.IsValid() )
		{
			_bot.Body.Enabled = true;
			_bot.Body.LocalPosition = Vector3.Zero;
			_bot.Body.LocalRotation = Rotation.Identity;
		}

		if ( _bot.CharacterController.IsValid() )
			_bot.CharacterController.Velocity = Vector3.Zero;

		Agent.SetAgentPosition( WorldPosition );
		Agent.Velocity = Vector3.Zero;

		// The one that actually matters for the body lag: tells the network
		// layer this was a jump, not travel, so it stops interpolating
		// toward the old position. Repeated each tick because the transform
		// can be written again between our teleport and this check.
		GameObject.Network.ClearInterpolation();
	}

	// The things we cannot force, only wait for and verify.
	bool IsLinedUp( out string problem )
	{
		problem = null;

		if ( _bot.Body.IsValid() )
		{
			var bodyDrift = _bot.Body.WorldPosition.Distance( WorldPosition );

			if ( bodyDrift > MaxBodyDrift )
			{
				problem = $"body is {bodyDrift:0} units from the root";
				return false;
			}
		}

		var eyeDrift = _bot.EyePos.Distance( WorldPosition );

		if ( eyeDrift > MaxEyeOffsetFromRoot )
		{
			problem = $"eye is {eyeDrift:0} units from the root";
			return false;
		}

		var agentDrift = Agent.AgentPosition.Distance( WorldPosition );

		if ( agentDrift > MaxAgentDrift )
		{
			problem = $"nav agent is {agentDrift:0} units from the root";
			return false;
		}

		var navPoint = Scene.NavMesh?.GetClosestPoint( WorldPosition, NavSearchRadius );

		if ( !navPoint.HasValue )
		{
			problem = "no navmesh within reach";
			return false;
		}

		var navDrift = navPoint.Value.Distance( WorldPosition );

		if ( navDrift > MaxNavDrift )
		{
			problem = $"standing {navDrift:0} units off the navmesh";
			return false;
		}

		if ( !IsSpawnClear( WorldPosition ) )
		{
			problem = "spawned inside something";
			return false;
		}

		return true;
	}

	// A dead cop is retired, not recycled.
	//
	// SWB's PlayerBase respawns a dead player in place: ragdoll it, disable
	// Body, wait two seconds, teleport it to a spawn point, re-enable
	// everything. That teardown and rebuild is where every cop bug we chased
	// actually lived - a stale physics shape, a body left displaced, a
	// network transform still interpolating toward a position from a
	// previous life. A cop on its first life was correct in every single
	// test run; every misbehaving cop was on its second or later.
	//
	// So no cop gets a second life. This one is destroyed, MissionSpawner
	// notices the gap when it prunes its list, and a brand new cop is
	// cloned at a police spawn point to replace it. The replacement is a
	// fresh object with no history, which is the state we know works.
	//
	// It also deletes a lot: there is nothing left to repair on respawn,
	// because there is no respawn.
	void DespawnOnDeath()
	{
		// Let go of the corpse before we go.
		//
		// CreateRagdoll builds the ragdoll as its own root object rather
		// than a child, but PlayerBase.OnDestroy destroys whatever
		// RagdollGO points at. Clearing the reference first leaves the body
		// lying where it fell instead of having it blink out mid-death,
		// and hands it to CleanUpRagdoll to remove on a timer.
		var corpse = _bot.RagdollGO;
		_bot.RagdollGO = null;

		if ( corpse.IsValid() )
			CleanUpRagdoll( corpse );

		// Switch the cop off rather than destroying it outright.
		//
		// Disabling stops it rendering, colliding and updating - to the
		// player it is gone the instant it dies - while leaving the object
		// valid for PlayerBase's pending respawn to land on harmlessly.
		// Destroying it here instead is what threw.
		GameObject.Enabled = false;

		DestroyAfterDelay();
	}

	// Comfortably PAST PlayerBase's own two second respawn timer.
	//
	// The first version of this destroyed the cop at one second, inside
	// that window, on the theory that killing the object would cancel the
	// pending respawn. It does not - the continuation still runs, finds
	// the components it needs already torn down, and throws. That was the
	// burst of null reference exceptions after a fight.
	//
	// Waiting it out instead is free, because the object is switched off
	// the moment it dies (see DespawnOnDeath): it is invisible, collides
	// with nothing and runs no code, so PlayerBase's respawn lands on a
	// valid-but-inert object, does its work where nobody can see it, and
	// is thrown away a moment later.
	const float DespawnDelay = 3f;

	// How long a body stays on the floor. Purely cosmetic; the cop object
	// itself is already gone.
	[Property]
	public float CorpseLifetime { get; set; } = 12f;

	async void DestroyAfterDelay()
	{
		await GameTask.DelaySeconds( DespawnDelay );

		if ( GameObject.IsValid() )
			GameObject.Destroy();
	}

	async void CleanUpRagdoll( GameObject corpse )
	{
		await GameTask.DelaySeconds( CorpseLifetime );

		if ( corpse.IsValid() )
			corpse.Destroy();
	}

	// --- Pass-through detection --------------------------------------------
	//
	// The spawn diagnostic came back clean on every cop, so whatever is
	// happening isn't a broken spawn - a healthy cop walks off and goes
	// through something later. This catches it in the act.
	//
	// Every tick, sweep from where this cop was to where it now is. The
	// CharacterController is supposed to make that sweep impossible: if
	// anything solid is in between, the cop just moved through it.
	//
	// Two possible outcomes, and they point at completely different fixes:
	//
	//   It names something   that object has collision, and the cop
	//                        crossed it anyway - the movement is at fault.
	//   It never fires       but you still SEE a cop cross a wall - then
	//                        that wall has no collider on it at all, which
	//                        is a map fix. It would also explain cops
	//                        shooting through it, since a bullet trace
	//                        can't hit a collider that isn't there.

	[Property]
	public bool DebugPassThrough { get; set; } = true;

	// --- Shot verification --------------------------------------------------
	//
	// Checks, at the moment a cop pulls the trigger, that the shot really is
	// clear - using a DIFFERENT trace to the one that authorised it.
	//
	// HasLineOfSight goes through SWB's own bullet trace, which deliberately
	// ignores a list of tags: trigger, playerclip, passbullets, viewmodel,
	// sky. If any surface in this map carries one of those - a wall built
	// from a brush tagged playerclip, a prop marked passbullets - then that
	// trace sails straight through it and reports a clear line in perfectly
	// good faith. The bullet uses the same ignore list, so it follows.
	//
	// That would look exactly like what you are seeing: cops shooting
	// through a specific wall, on any life, with pathing and positions all
	// correct, and a pass-through detector that never fires because nothing
	// ever moves through anything.
	//
	// So this re-traces with nothing ignored but triggers, the sky and view
	// models. If that ray stops on something before reaching the target, the
	// two disagree - and the object it names is the one letting cops shoot
	// through it.
	[Property]
	public bool DebugShotVerification { get; set; } = true;

	// Deliberately short: triggers and the sky are not solid, and a view
	// model is a first-person prop. EVERYTHING else counts, including the
	// tags SWB's bullet trace waves through.
	static readonly string[] ShotVerificationIgnoreTags =
	{
		TagsHelper.Trigger,
		TagsHelper.Sky,
		TagsHelper.ViewModel
	};

	TimeSince _timeSinceShotWarning = 10f;

	void VerifyShotIsClear( GameObject target )
	{
		if ( !DebugShotVerification || !target.IsValid() )
			return;

		// Rate limited, or a cop firing on full auto through a wall would
		// print a line per bullet.
		if ( _timeSinceShotWarning < 1f )
			return;

		var from = SafeEyePos;
		var to = TargetEyePos( target );

		var tr = Scene.Trace.Ray( from, to )
			.WithoutTags( ShotVerificationIgnoreTags )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		// Nothing in the way, or we stopped on the target itself: the shot is
		// as clear as HasLineOfSight said it was.
		if ( !tr.Hit || !tr.GameObject.IsValid() || tr.GameObject.Root == target.Root )
			return;

		_timeSinceShotWarning = 0;

		Log.Warning( $"[cop] {GameObject.Name} FIRED THROUGH '{tr.GameObject.Name}' " +
			$"(root '{tr.GameObject.Root.Name}') " +
			$"{from.Distance( tr.HitPosition ):0} units out of " +
			$"{from.Distance( to ):0} to the target, at {tr.HitPosition}" );
	}

	// Chest height, so the sweep tests the wall rather than the floor.
	const float PassThroughTraceHeight = 32f;

	Vector3 _lastMoveCheckPosition;
	bool _hasLastMoveCheck = false;

	// Set by RelocateToPoliceSpawn so the one teleport we do on purpose
	// isn't reported as a cop moving through the map.
	bool _skipNextMoveCheck = false;

	// Which life this cop is on. Reported alongside anything that goes
	// wrong, because "life 1 is fine and life 2 isn't" is the single most
	// useful fact we have about this bug - and if a warning never carries
	// life=2, that tells us the check itself isn't reaching the broken
	// case rather than that there's nothing to find.

	TimeSince _timeSinceLastPassThroughLog = 10f;

	void DetectPassThrough()
	{
		var here = WorldPosition;

		if ( !_hasLastMoveCheck )
		{
			_lastMoveCheckPosition = here;
			_hasLastMoveCheck = true;
			return;
		}

		var from = _lastMoveCheckPosition;
		_lastMoveCheckPosition = here;

		if ( !DebugPassThrough )
			return;

		var step = from.Distance( here );

		if ( step < 1f )
			return;

		// Only a relocation WE asked for is exempt. The previous version
		// skipped any step bigger than a tick's worth of running, on the
		// assumption that those were all deliberate teleports - which
		// meant that if something else was teleporting a cop through a
		// wall, this looked straight past the one event it was built to
		// catch. That's why it never fired.
		if ( _skipNextMoveCheck )
		{
			_skipNextMoveCheck = false;
			return;
		}

		// A step far bigger than this cop could have run in one tick is
		// worth reporting on its own, whether or not it crossed anything:
		// it means something other than the CharacterController is moving
		// this object, and collision never got a say.
		var plausibleStep = _bot.RunSpeed * Time.Delta * 4f;

		if ( step > plausibleStep && _timeSinceLastPassThroughLog >= 1f )
		{
			_timeSinceLastPassThroughLog = 0;

			Log.Warning( $"[cop] {GameObject.Name} JUMPED {step:0} units in one tick " +
				$"(max plausible {plausibleStep:0}) - something is setting its position directly. " +
				$"link={Agent.IsTraversingLink} agentDrives={Agent.UpdatePosition} from={from} to={here}" );

			return;
		}

		var tr = Scene.Trace.Ray( from + Vector3.Up * PassThroughTraceHeight, here + Vector3.Up * PassThroughTraceHeight )
			.Size( SpawnClearanceRadius * 0.5f )
			.WithoutTags( SpawnClearanceIgnoreTags )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		if ( !tr.Hit || tr.StartedSolid )
			return;

		// Rate limited: a cop stuck half inside something would otherwise
		// print this every tick and bury everything else.
		if ( _timeSinceLastPassThroughLog < 1f )
			return;

		_timeSinceLastPassThroughLog = 0;

		var hitName = tr.GameObject.IsValid() ? tr.GameObject.Name : "<no gameobject>";
		var rootName = tr.GameObject.IsValid() ? tr.GameObject.Root.Name : "<none>";

		Log.Warning( $"[cop] {GameObject.Name} onGround={_bot.IsOnGround} moved {step:0} units THROUGH '{hitName}' " +
			$"(root '{rootName}') at {tr.HitPosition} " +
			$"link={Agent.IsTraversingLink} agentDrives={Agent.UpdatePosition}" );
	}

	// Is there actually room for a cop to stand here?
	//
	// Sweeps a body-sized sphere from ankle height up to head height. Two
	// distinct failures both mean no: StartedSolid says the bottom of that
	// sweep is already buried, Hit says something is in the way further
	// up - a shelf, a counter, a doorframe.
	bool IsSpawnClear( Vector3 position )
	{
		// Another cop standing here doesn't necessarily show up in the
		// sweep below: a dead one has had its body - and with it its
		// collider - disabled, so it traces as empty space right up until
		// the moment it respawns into the same spot. Check by position.
		foreach ( var other in Scene.GetAllComponents<CopBotAI>() )
		{
			if ( !other.IsValid() || other.GameObject == GameObject )
				continue;

			if ( other.WorldPosition.DistanceSquared( position ) < SpawnClearanceRadius * SpawnClearanceRadius * 4f )
				return false;
		}

		// Sweep the body volume only, clear of the ground.
		//
		// The first version started this sphere at exactly one radius up,
		// which puts its underside flush with the floor - so it reported
		// solid at every legitimate spawn point in the map, every cop
		// failed all three attempts, and the whole gate turned into a
		// warning that fired constantly and gated nothing.
		//
		// Start a little above that, and stop a radius short of the top,
		// so the swept volume is the space a standing body needs and
		// nothing else.
		var feet = position + Vector3.Up * (SpawnClearanceRadius + SpawnClearanceGroundGap);
		var head = position + Vector3.Up * (SpawnClearanceHeight - SpawnClearanceRadius);

		// Ignore ourselves. This is called both BEFORE a move (testing a
		// candidate the cop isn't standing on yet) and AFTER one (verifying
		// where it now is) - and in the second case the sweep lands on this
		// cop's own capsule collider every single time. That is why every
		// cop reported "spawned inside something": it was standing in
		// itself.
		var tr = Scene.Trace.Ray( feet, head )
			.Size( SpawnClearanceRadius )
			.WithoutTags( SpawnClearanceIgnoreTags )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		return !tr.StartedSolid && !tr.Hit;
	}

	// Replaces whatever DemoBot handed this cop with a single weapon from
	// WeaponPool, then tones it down.
	//
	// Runs on every spawn, which matters: Inventory.Clear() destroys the
	// old weapon and AddClone makes a fresh one from the registry's master
	// copy, so the scales below always apply to unmodified base values
	// rather than compounding on an already-scaled weapon.
	void ApplyCopLoadout()
	{
		_shotsThisBurst = 0;

		if ( _bot.Inventory is null || WeaponPool.Count == 0 )
			return;

		// DemoBot already handed this cop every weapon and picked one at
		// random. Rather than tearing the inventory down and rebuilding it
		// (which churns networked weapon objects mid-spawn), just choose
		// which of the ones it already has becomes active. Items are named
		// by ClassName, which is what SetActive matches on.
		var className = WeaponPool[Random.Shared.Next( WeaponPool.Count )];
		_bot.Inventory.SetActive( className );

		var weapon = GetActiveWeapon();

		if ( weapon?.Primary is null )
			return;

		if ( weapon.ClassName != className )
		{
			Log.Warning( $"{GameObject.Name}: '{className}' isn't in this bot's inventory - check it's spelled like a WeaponRegistry ClassName." );
			return;
		}

		// Read the untouched numbers off the registry's master copy and
		// ASSIGN, rather than multiplying whatever the clone currently
		// holds.
		//
		// The old version multiplied in place on the assumption that every
		// respawn hands out a fresh clone. That held right up until the
		// spread started being rewritten per shot below - at which point
		// "multiply what's there" would have compounded a cop's own
		// settling spread into its permanent accuracy. Assigning from a
		// known base is idempotent, so it doesn't matter how often this
		// runs or what state the clone was left in.
		//
		// Scaling the clone is still safe for the player: their copy of
		// the same gun is a separate instance and keeps its real numbers.
		// Job difficulty stacks on top of the per-cop tuning, so the
		// prefab keeps showing the baseline rather than whatever the last
		// job scaled it to.
		var master = WeaponRegistry.Instance?.Get( className );

		weapon.Primary.Damage = (master?.Primary?.Damage ?? weapon.Primary.Damage)
			* DamageScale * ActiveMission.CopDamageScale;

		// Kept rather than written straight onto the weapon: TryShoot sets
		// the live spread every shot, widening it at the start of an
		// engagement and settling it back to this.
		_settledSpread = (master?.Primary?.Spread ?? weapon.Primary.Spread) * SpreadScale;
		weapon.Primary.Spread = _settledSpread;
	}

	// Weapon.OnUpdate() only ever calls Reload() and only ever completes
	// one (OnReloadFinish/OnShellReloadFinish) inside its human-input
	// branch, which is skipped entirely when Owner.IsBot is true. So a bot
	// has to drive both halves itself: start the reload when empty, and
	// finish it once the timer's elapsed - otherwise IsReloading gets
	// stuck true forever and CanShoot() fails permanently even after
	// Reload() "worked".
	void MaintainWeapon()
	{
		var weapon = GetActiveWeapon();
		if ( weapon is null )
			return;

		if ( weapon.IsReloading )
		{
			if ( weapon.TimeSinceReload >= 0 )
			{
				if ( weapon.ShellReloading )
					weapon.OnShellReloadFinish();
				else
					weapon.OnReloadFinish();
			}

			return;
		}

		if ( !weapon.HasAmmo() )
			weapon.Reload();
	}

	void TryShoot()
	{
		var weapon = GetActiveWeapon();

		if ( weapon is null || weapon.IsReloading )
		{
			_shotsThisBurst = 0;
			return;
		}

		// Trigger discipline. Without this a cop holds the trigger down
		// forever, which reads as a laser beam rather than someone
		// shooting at you. Also gives the player a window to move.
		if ( _shotsThisBurst >= BurstShots )
		{
			if ( _timeSinceBurstEnded < BurstPause )
				return;

			_shotsThisBurst = 0;
		}

		if ( !weapon.CanPrimaryShoot() )
			return;

		// THE rate limiter. CanShoot() gates firing on
		// TimeSincePrimaryShoot having exceeded the weapon's RPM interval,
		// and Weapon.OnUpdate resets this to 0 on every shot it fires -
		// but OnUpdate skips that whole branch for bots, so driving
		// Shoot() directly means resetting it here too.
		//
		// Leaving it unreset (as this did originally) means the timer
		// stays huge, CanShoot always passes, and the cop fires once per
		// tick - about 60 shots a second whatever the weapon's RPM says.
		// A Colt at 25 damage becomes ~1500 DPS and empties its magazine
		// into you in a tenth of a second.
		// Independent check on the shot we are about to take. Logs nothing
		// unless something solid is actually in the way.
		VerifyShotIsClear( _target );

		// Set the live spread for THIS shot. Cops open an engagement wide
		// and walk their fire in - see CurrentSpreadMultiplier.
		weapon.Primary.Spread = _settledSpread * CurrentSpreadMultiplier();

		weapon.TimeSincePrimaryShoot = 0;
		weapon.Shoot( weapon.Primary, true );

		_shotsThisBurst++;

		if ( _shotsThisBurst >= BurstShots )
			_timeSinceBurstEnded = 0;
	}

	// Widens the spread at the start of an engagement and settles it back
	// to normal over AimSettleTime.
	//
	// Keyed off how long sight has been held unbroken, which is the same
	// clock ReactionTime uses - so ducking back into cover and stepping
	// out again makes a cop start its walk-on from scratch rather than
	// resuming perfectly zeroed.
	float CurrentSpreadMultiplier()
	{
		if ( AimSettleTime <= 0f )
			return 1f;

		var settled = MathX.Clamp( _timeSinceLineOfSightAcquired / AimSettleTime, 0f, 1f );

		return MathX.Lerp( OpeningSpreadMultiplier, 1f, settled );
	}

	Weapon GetActiveWeapon()
	{
		var weaponGO = _bot.Inventory?.Active;
		return weaponGO?.GetComponent<Weapon>();
	}

	// NavMeshAgent.MoveTo() fails outright if the destination isn't ON the
	// navmesh - it yields no velocity at all, which is what used to freeze
	// every cop whenever the player stood somewhere the mesh doesn't cover
	// (a roof, a ledge, an upper floor). Worse, the engine burns a long
	// exhaustive search failing to find that path.
	//
	// Scene.NavMesh.GetClosestPoint snaps a world position to the nearest
	// navigable point within a radius, so cops path to the reachable spot
	// closest to you - the floor below the ledge you're on - instead of
	// giving up. Null means there's nothing navigable nearby at all (or
	// the navmesh hasn't generated yet), in which case we hand back the
	// raw position and let the straight-line fallback deal with it.
	Vector3 NavigableDestination( Vector3 worldPos )
	{
		return Scene.NavMesh?.GetClosestPoint( worldPos, NavSearchRadius ) ?? worldPos;
	}

	// target.WorldPosition is the root transform, which sits at the
	// player's feet, not eye/chest height. Both line-of-sight and aiming
	// want the eye position instead - it's the height a player judges
	// their own exposure by, and it keeps the two consistent so a cop
	// never fires along a line it didn't actually check.
	Vector3 TargetEyePos( GameObject target )
	{
		return target.Components.Get<PlayerBase>()?.EyePos ?? target.WorldPosition;
	}

	// Turns the aim toward a world position at TurnSpeed, and reports how
	// far off target the muzzle still is afterwards, in degrees.
	//
	// EyeAngles is what Shoot() fires along, so this is the whole of a
	// cop's aiming. Turning at a rate rather than snapping is what stops
	// a cop firing through the wall it is still swinging past.
	float AimAt( Vector3 worldPos )
	{
		var toTarget = worldPos - SafeEyePos;

		if ( toTarget.IsNearZeroLength )
			return 0f;

		return TurnTowards( Rotation.LookAt( toTarget ) );
	}

	// With nothing in sight there's nothing to aim at, so look where
	// you're going.
	//
	// A cop sprinting down a street with its head and weapon still pointed
	// at wherever it last saw somebody looks broken, and it's also why the
	// moment it DID acquire you never read as a turn - it was already
	// facing some arbitrary direction and only its bullets ever moved.
	void FaceTravelDirection()
	{
		var travel = _bot.WishVelocity.WithZ( 0f );

		if ( travel.IsNearZeroLength )
			return;

		TurnTowards( Rotation.LookAt( travel ) );
	}

	// Rotates both the aim and the visible model toward a rotation, each
	// at its own rate, and reports how much of the turn is left in degrees.
	float TurnTowards( Rotation desired )
	{
		var current = _bot.EyeAngles.ToRotation();

		// Rotation.Distance is the angle between two rotations in degrees.
		// Working out what fraction of the remaining turn we're allowed
		// makes this a true degrees-per-second cap, rather than the
		// framerate-dependent easing a plain Lerp( a, b, Time.Delta * k )
		// would give - that one turns faster the better your framerate is.
		var remaining = current.Distance( desired );
		var maxStep = TurnSpeed * Time.Delta;
		var fraction = remaining <= maxStep ? 1f : maxStep / remaining;

		// Slerp, not a lerp of the Angles components. Quaternion
		// interpolation always takes the SHORT way round; lerping raw
		// euler angles sends a cop turning from yaw 170 to yaw -170 the
		// long way, sweeping 340 degrees to cover 20 - most of a second
		// spent facing away from the person it's shooting at.
		var aim = Rotation.Slerp( current, desired, fraction );

		_bot.EyeAngles = aim.Angles();
		TurnBodyTowards( aim );

		// What's left after this tick's turn. A cop that covered the whole
		// angle returns 0 and is cleared to fire straight away; only one
		// that's genuinely mid-swing gets held back.
		return MathF.Max( remaining - maxStep, 0f );
	}

	// Turns the visible model.
	//
	// This exists because of SWB. PlayerBase.RotateBody() swings the model
	// toward EyeAngles with Rotation.Lerp( ..., Time.Delta * 2f ), which
	// closes about half the remaining angle every third of a second. For a
	// human that's invisible - your yaw only ever creeps, because it
	// follows your mouse. A bot acquires a target a hundred and fifty
	// degrees off its shoulder, and at that rate the model needs well over
	// a second to come round. Its bullets leave along EyeAngles the
	// instant we set them, so what you see is a cop that shoots you
	// without ever having looked at you.
	//
	// Nothing in SWB needs changing for this: RotateBody lerps toward the
	// same yaw we're setting here, so whichever of the two runs last in a
	// given frame, they agree on the destination and there's nothing to
	// fight over.
	void TurnBodyTowards( Rotation aim )
	{
		if ( !_bot.Body.IsValid() )
			return;

		// Yaw only. The model stands upright and leans its aim with the
		// animation graph's AimAngle, which SWB already feeds from
		// EyeAngles - pitching the whole body would double that up.
		var target = new Angles( 0f, aim.Angles().yaw, 0f ).ToRotation();

		var remaining = _bot.Body.WorldRotation.Distance( target );
		var maxStep = BodyTurnSpeed * Time.Delta;
		var fraction = remaining <= maxStep ? 1f : maxStep / remaining;

		_bot.Body.WorldRotation = Rotation.Slerp( _bot.Body.WorldRotation, target, fraction );

		// Put the body back on the root, every single time.
		//
		// Assigning a WORLD rotation to a child makes the engine recompute
		// that child's LOCAL transform so its world position is preserved.
		// This runs in OnFixedUpdate, which is before PlayerBase moves the
		// root in the same tick - so each call re-pinned Body to where the
		// cop was standing at the start of the tick, and the root then
		// walked out from under it. Repeat fifty times a second and the
		// body is left hundreds of units behind.
		//
		// That is the drift: it grew with distance travelled and froze the
		// moment a cop stopped, which is exactly this and nothing else.
		// SWB's own RotateBody makes the same call from OnUpdate, after the
		// move, which is why players never showed it.
		//
		// Body carries the model, the capsule collider and the hitboxes,
		// so a cop with drift is a cop whose shootable body is somewhere
		// it is not - and whose line of sight is judged from there, since
		// cops accept a sight hit on anything whose root is the player.
		// One assignment closes it.
		_bot.Body.LocalPosition = Vector3.Zero;
	}

	// Can this cop see the target, along a line its own bullets could
	// actually travel?
	//
	// Uses SWB's own bullet trace instead of a hand-rolled ray, so the
	// sight check and the shot obey identical rules: the same ignored tags
	// (triggers, player clips, pass-bullets surfaces, view models, sky),
	// the same hitboxes, the same radius. When the two disagree you get
	// cops that "see" you through one of our trigger volumes and fire into
	// it, or that refuse to shoot down a line a bullet would sail
	// straight along. Dead players are ignored as well, so a pile of
	// ragdolls doesn't hand the crew free cover.
	bool HasLineOfSight( GameObject target )
	{
		var tr = Weapon.TraceBullet(
			GameObject,
			SafeEyePos,
			TargetEyePos( target ),
			SightTraceRadius,
			Weapon.PenetrationBulletTraceIgnoreTags );

		// A trace that BEGAN inside solid geometry tells us nothing. A
		// sphere cast starting in a wall doesn't stop at that wall - it is
		// already past the surface, so it sails on and happily reports the
		// player on the other side. That is precisely how a cop embedded
		// in the scenery ends up shooting you through it.
		//
		// There's no useful answer to give here, so give the safe one.
		if ( tr.StartedSolid )
			return false;

		// The trace has to have stopped ON the target.
		//
		// This used to also accept "hit nothing at all" as a clear line,
		// and that was the bug: a trace that never registers the player is
		// not evidence the cop can see them, it's evidence the check
		// didn't do its job. Every case we don't understand - a collider
		// that isn't where we think, a target whose body has been
		// disabled, a ray that slips past a capsule - came back as
		// permission to open fire. Now they all come back as "hold".
		return tr.Hit
			&& tr.GameObject.IsValid()
			&& tr.GameObject.Root == target.Root;
	}

	// Nearest player still in the run. Anyone eliminated or escaped is
	// invisible and intangible, so shooting at them would just look like
	// cops firing at nothing - Crew handles that filtering.
	//
	// Written as a plain loop rather than OrderBy().First(): every cop on
	// the map runs this several times a second, and sorting the whole list
	// to take one element allocates an array and a comparer each time for
	// no benefit. Comparing squared distances also skips a square root per
	// player, which is safe because squaring preserves ordering.
	GameObject FindNearestPlayer()
	{
		GameObject nearest = null;
		var nearestSqr = float.MaxValue;

		foreach ( var player in Crew.ActivePlayers( Scene ) )
		{
			var distanceSqr = player.WorldPosition.DistanceSquared( WorldPosition );

			if ( distanceSqr >= nearestSqr )
				continue;

			nearestSqr = distanceSqr;
			nearest = player;
		}

		return nearest;
	}
}