MissionPlayerState.cs
using Sandbox;
using SWB.Base;
using SWB.Player;
using System;
using System.Collections.Generic;
using System.Linq;

// Owns "am I still in this job?" for one player. Put this on the player
// prefab alongside PlayerBase.
//
// During a mission there are no respawns: going down puts you out for the
// rest of the run, watching through a teammate's eyes. Escaping does the
// same thing minus the dying. Once nobody is left in the fight, MissionTimer
// wraps up and sends everyone back to the hideout.
//
// Outside a mission (i.e. in the hideout) this does nothing and normal
// SWB respawning is left alone.
public sealed class MissionPlayerState : Component
{
	public enum State
	{
		/// <summary>Still in the fight.</summary>
		Active,
		/// <summary>Went down. Out for the rest of the run.</summary>
		Eliminated,
		/// <summary>Got out with the money.</summary>
		Escaped
	}

	public State Current { get; private set; } = State.Active;

	public bool IsOut => Current != State.Active;

	/// <summary>Who we're currently watching, if anyone.</summary>
	public PlayerBase SpectateTarget { get; private set; }

	PlayerBase _player;
	CameraMovement _cameraMovement;
	bool _wasAlive = true;

	// Tracks the job starting, so a player who survived the last one is
	// put back in play at the start of the next. See ResetForNewJob.
	bool _wasInMission = false;

	// --- Incoming fire diagnostics -----------------------------------------
	//
	// Every check so far has been on the COP's side, and every one of them
	// has come back saying the shot was legitimate: the cop was where it
	// thought it was, its eye position was right, and an independent trace
	// agreed the line was clear. So this checks the other end - the one
	// moment that is not in dispute, which is you losing health.
	//
	// When health drops it asks, of every cop on the map, whether there was
	// anything solid between its eye and yours. Two possible answers:
	//
	//   at least one CLEAR   the shot was legitimate; whatever it feels
	//                        like, that cop really could see you. The wall
	//                        you think you are behind has a gap, or does
	//                        not reach eye height.
	//
	//   all BLOCKED          a bullet crossed solid geometry. That is the
	//                        real thing, and the named object is what it
	//                        came through.
	[Property]
	public bool DebugIncomingFire { get; set; } = true;

	int _lastHealth = -1;

	// Matches the cop and player Head offsets, which are both fixed
	// children at (0,0,60) - so this is where shots start and land.
	const float EyeHeight = 60f;

	protected override void OnAwake()
	{
		_player = Components.Get<PlayerBase>();
		_cameraMovement = Components.GetInChildrenOrSelf<CameraMovement>();

		if ( _player is null )
			Log.Warning( $"{GameObject.Name}: MissionPlayerState needs a PlayerBase on the same GameObject." );
	}

	// Makes sure the frame is drawn from THIS player's head, by switching off
	// any other scene camera that's also claiming to be the main camera.
	//
	// Why it's needed: scenes built from the default template come with
	// their own "Main Camera" object (the hideout's sits at -300,0,100,
	// underneath the floor). The player prefab brings its own camera, also
	// flagged as main, at the same priority. With two equal main cameras
	// the engine picks one, and which one it picks isn't something we
	// control - on the first hideout load it happened to be the player's,
	// coming back from the bank it was the scene's. That left the screen
	// showing the underside of the hideout floor (skybox only), and SWB's
	// ViewModelHandler, which copies Scene.Camera, dragged the gun camera
	// down there with it. Logged proof: [cams] lines, 2026-09-25.
	//
	// Only touches cameras that are (a) flagged main and (b) not part of
	// this player. The player's own Camera and ViewModelCamera are left
	// alone, and so is anything the editor injects that isn't flagged main.
	//
	// Swept a few times a second rather than only when Scene.Camera is
	// already wrong. A first version waited for the stray to win before
	// acting, which still allowed the bad view to flash up - and the logs
	// showed the stray sitting enabled for the whole of a hideout visit
	// where it just happened to lose. Switching it off pre-emptively
	// means the coin never gets tossed. It has to keep sweeping because
	// the player object survives scene changes: a fresh scene brings a
	// fresh stray camera with it.
	TimeSince _sinceCameraSweep = 0;

	void KeepPlayerCameraInCharge()
	{
		if ( _player.IsBot || !_player.Camera.IsValid() )
			return;

		if ( _sinceCameraSweep < 0.25f )
			return;

		_sinceCameraSweep = 0;

		foreach ( var cam in Scene.GetAllComponents<CameraComponent>().ToList() )
		{
			if ( !cam.Enabled || !cam.IsMainCamera )
				continue;

			if ( cam.GameObject.Root == GameObject.Root )
				continue;

			cam.Enabled = false;
			Log.Info( $"{GameObject.Name}: switched off scene camera '{cam.GameObject.Name}' - it was competing with the player's own." );
		}
	}

	protected override void OnUpdate()
	{
		if ( _player is null )
			return;

		// A new job puts everyone back in play.
		//
		// Nothing here ever undid being out: Current was only ever written
		// by SetOutBroadcast, the tag was only ever added, and GodMode and
		// the hidden body were only ever switched on. That was survivable
		// only as long as a player object never outlived the job - but a
		// network-spawned player persists across the scene change, so
		// escaping once left that player marked escaped, in GodMode and
		// without a body capsule for every job afterwards.
		//
		// Driven off the timer rather than OnStart, because OnStart won't
		// run again on an object that survived the scene load.
		var inMission = MissionInProgress();

		if ( inMission && !_wasInMission )
			ResetForNewJob();

		_wasInMission = inMission;

		// Runs on every client, not just the owner: someone who's out has
		// to be invisible on everyone's screen, not only their own.
		if ( IsOut )
			HideBody();

		if ( IsProxy )
			return;

		KeepPlayerCameraInCharge();

		WatchForBodyDrift();
		WatchForIncomingFire();

		if ( Current == State.Active )
		{
			// Belt and braces for the HUD: ResetForNewJob brings it back
			// when a job starts, but a player who died and then went back
			// to the hideout shouldn't have to wait for the next job to see
			// their cash. Only writes when the value actually changes.
			SetOwnHudVisible( true );
			CheckForElimination();
		}
		else
		{
			EnforceOutOfPlay();
		}

		_wasAlive = _player.IsAlive;
	}

	// Owner-side. Watches the player's own body for drifting away from the
	// root, continuously - not only when something shoots us.
	//
	// This matters more than it looks. Body carries the CapsuleCollider and
	// the ModelHitboxes, so it is the only part of a player a bullet can
	// actually hit, while the root is where the player is standing and
	// where their camera is. Cops accept a sight hit on anything whose root
	// is the player, so a drifted Body means a cop can hold a genuinely
	// clear line to a capsule that is nowhere near the person - and every
	// round it puts into that capsule damages a player standing safely
	// behind a wall.
	//
	// The same drift was already proven to happen to cops after a teleport,
	// caused by network interpolation catching up. The player is
	// network-spawned and teleported on spawn too.
	TimeSince _timeSinceDriftWarning = 10f;

	const float MaxAcceptableBodyDrift = 8f;

	void WatchForBodyDrift()
	{
		if ( !DebugIncomingFire || !_player.Body.IsValid() )
			return;

		var drift = _player.Body.WorldPosition.Distance( WorldPosition );

		if ( drift <= MaxAcceptableBodyDrift || _timeSinceDriftWarning < 2f )
			return;

		_timeSinceDriftWarning = 0;

		Log.Warning( $"[player] body has drifted {drift:0} units from the root " +
			$"(root {WorldPosition}, body {_player.Body.WorldPosition}). " +
			"Your hitbox is not where you are." );
	}

	// Owner-side. Notices health going down and reports what could have
	// done it.
	void WatchForIncomingFire()
	{
		if ( !DebugIncomingFire )
			return;

		var health = _player.Health;

		// First read, or a respawn topping us back up - nothing to report.
		if ( _lastHealth < 0 || health >= _lastHealth )
		{
			_lastHealth = health;
			return;
		}

		var damage = _lastHealth - health;
		_lastHealth = health;

		ReportIncomingFire( damage );
	}

	// Dumps, for every cop close enough to matter, exactly what a straight
	// line from its eye to yours runs into.
	//
	// The previous version collapsed this to one word and got it wrong. It
	// called a shot CLEAR whenever the trace was not stopped by a wall -
	// which included the case where the trace hit NOTHING AT ALL. A ray
	// that reaches nothing has not proved a clear line, it has proved the
	// check is looking in the wrong place, and reporting that as
	// "legitimate shot" is worse than saying nothing.
	//
	// So now every cop gets a line, naming what its ray stopped on and how
	// far along. Three outcomes, and they mean different things:
	//
	//   ->YOU        the ray reached you. The shot really was clear.
	//   ->'name'     the ray stopped on that object first. A round that
	//                still reached you came through it.
	//   ->NOTHING    the ray reached neither you nor any geometry. The
	//                trace is not measuring what we think it is, and no
	//                conclusion can be drawn from it.
	void ReportIncomingFire( int damage )
	{
		var eye = WorldPosition + Vector3.Up * EyeHeight;
		var lines = new List<string>();

		// How far the visible/shootable body has drifted from the root.
		//
		// Everything a cop can actually hit - the capsule collider, the
		// model hitboxes - hangs off Body. The root is where the player
		// believes they are and where the camera sits. When these two part
		// company, a cop shooting a legitimately visible capsule is
		// shooting somewhere the player is not.
		var bodyDrift = _player.Body.IsValid()
			? _player.Body.WorldPosition.Distance( WorldPosition )
			: -1f;

		var bodyEye = (_player.Body.IsValid() ? _player.Body.WorldPosition : WorldPosition)
			+ Vector3.Up * EyeHeight;

		// Snapshot the list before walking it. Cops destroy themselves a
		// few seconds after dying, and one doing so mid-enumeration is what
		// threw while this was being written.
		var cops = Scene.GetAllComponents<CopBotAI>().ToList();

		foreach ( var cop in cops )
		{
			if ( !cop.IsValid() || !cop.GameObject.IsValid() || !cop.GameObject.Enabled )
				continue;

			// The cop's ACTUAL firing point, not an assumed one.
			//
			// This was cop.WorldPosition + 60, which is only correct if
			// nothing about the cop has drifted - exactly the assumption
			// under investigation. Asking the cop where it sights from
			// means this report describes the line it really took.
			var from = cop.EyePosition;
			var distance = from.Distance( eye );

			// Only the ones close enough to plausibly be the shooter -
			// otherwise a long job prints a wall of text per hit.
			if ( distance > 3000f )
				continue;

			// Ignores only what genuinely is not solid to a bullet. Corpses
			// are on that list: SWB's own bullet trace skips deadPlayer, so
			// counting a ragdoll as cover reported "blocked" for shots that
			// really did have a clear line through one.
			var tr = Scene.Trace.Ray( from, eye )
				.WithoutTags( IncomingFireIgnoreTags )
				.IgnoreGameObjectHierarchy( cop.GameObject )
				.Run();

			// The same line again, but traced exactly the way SWB traces a
			// bullet - with ITS ignore list, which is not the same as ours.
			//
			// This is the comparison that matters. Ours ignores only things
			// that genuinely are not solid. SWB's also waves through
			// anything tagged playerclip or passbullets. If a wall carries
			// one of those, our trace stops on it and the bullet does not -
			// so the cop gets a clear line, fires, and the round arrives
			// through a wall that looks and behaves solid in every other
			// respect.
			var bulletTr = Weapon.TraceBullet( cop.GameObject, from, eye, 2f, Weapon.BulletTraceIgnoreTags );

			var strict = DescribeLine( tr );
			var asBullet = DescribeLine( bulletTr );

			// Only worth printing when the two disagree - that disagreement
			// IS the bug, and the tag named below is the reason.
			if ( strict != asBullet )
			{
				var blocker = tr.Hit && tr.GameObject.IsValid() ? tr.GameObject : null;

				strict += $"  <<< BULLET SEES: {asBullet}"
					+ $" [blocker's SWB-ignored tags: {SwbIgnoredTagsOn( blocker )}]";
			}

			// Second line, aimed at where the BODY actually is rather than
			// where the root says the player is.
			//
			// This is the test for the one thing that would explain a hit
			// with no clear line anywhere: the capsule and hitboxes live on
			// Body, and cops accept a sight hit on anything whose root is
			// the player. If Body has drifted from the root, a cop can hold
			// a perfectly legitimate line to a capsule that is no longer
			// where the player is standing - shoot it, and the damage lands
			// on a player who is behind a wall.
			//
			// Only printed when it disagrees with the line to the root,
			// because when they agree it tells us nothing.
			if ( bodyDrift > 1f )
			{
				var bodyTr = Scene.Trace.Ray( from, bodyEye )
					.WithoutTags( IncomingFireIgnoreTags )
					.IgnoreGameObjectHierarchy( cop.GameObject )
					.Run();

				var toBody = DescribeLine( bodyTr );

				if ( toBody != DescribeLine( tr ) )
					strict += $"   ||| TO BODY: {toBody}";
			}

			// The cop's own body drift and whether it ever passed its spawn
			// alignment checks, printed per cop. Body carries its capsule
			// and hitboxes; a drifted one is a cop whose visible position
			// and real position disagree.
			var copState = $"[drift={cop.BodyDrift:0.0} settled={cop.IsSettled}"
				+ $" eyeAt={from} root={cop.WorldPosition}]";

			lines.Add( $"{cop.GameObject.Name} {distance:0}u {copState} -> {strict}" );
		}

		if ( lines.Count == 0 )
		{
			Log.Warning( $"[hit] -{damage}hp - no cops within 3000 units. Damage came from something else." );
			return;
		}

		// Whether there was anything on us for a trace to land on. If this
		// is False then every "NOTHING" below means the trace had no target
		// to find, not that the line was clear.
		var capsule = _player.BodyCollider.IsValid() && _player.BodyCollider.Enabled;

		Log.Info( $"[hit] -{damage}hp capsule={capsule} bodyDrift={bodyDrift:0.0} | " + string.Join( " | ", lines ) );
	}

	// Four distinct outcomes, and the middle two used to be collapsed
	// into one wrong word.
	//
	// A trace can report Hit with a NULL GameObject - that is world
	// geometry which is not a GameObject. It is solid, it blocks bullets,
	// and the previous version printed it as "NOTHING", which read as
	// "the line was clear" when it meant the exact opposite. Any shot
	// labelled NOTHING in an earlier log may well have been blocked.
	// Which of the tags SWB's bullet trace skips are on this object. This
	// is the answer to "why did the round go through", in one word.
	static string SwbIgnoredTagsOn( GameObject go )
	{
		if ( !go.IsValid() )
			return "n/a";

		var found = Weapon.BulletTraceIgnoreTags.Where( go.Tags.Has ).ToList();

		return found.Count == 0 ? "NONE" : string.Join( ",", found );
	}

	string DescribeLine( SceneTraceResult tr )
	{
		// Begins inside something solid. A sphere or ray starting in a wall
		// is already past its surface, so it does not stop there and can
		// report nothing at all - which is not the same as a clear line,
		// and was previously indistinguishable from one in this report.
		if ( tr.StartedSolid )
			return "STARTED SOLID - this trace began inside geometry and cannot be trusted";

		if ( !tr.Hit )
			return "NOTHING AT ALL - not even you, so this trace proves nothing";

		if ( !tr.GameObject.IsValid() )
			return $"BLOCKED by world geometry (no GameObject) at {tr.Fraction:0.00} along";

		if ( tr.GameObject.Root == GameObject )
			return "YOU";

		return $"BLOCKED by '{tr.GameObject.Name}' "
			+ $"(root '{tr.GameObject.Root?.Name ?? "?"}') at {tr.Fraction:0.00} along";
	}

	// What a bullet genuinely passes through, and nothing else. Kept
	// deliberately short - every tag added here is a way for this report to
	// agree with a shot that should not have connected.
	static readonly string[] IncomingFireIgnoreTags =
	{
		"trigger",
		"sky",
		"viewmodel",
		"deadPlayer"
	};

	void CheckForElimination()
	{
		// Only alive -> dead counts, and only during an actual job. Dying
		// in the hideout (or before the job starts) falls through to SWB's
		// normal respawn.
		if ( _player.IsAlive || !_wasAlive )
			return;

		if ( !MissionInProgress() )
			return;

		SetOut( State.Eliminated );
	}

	// Called by EscapeZone when this player reaches the exit.
	public void MarkEscaped()
	{
		SetOut( State.Escaped );
	}

	void SetOut( State state )
	{
		SetOutBroadcast( (int)state );
	}

	// Broadcast because every client runs its own MissionTimer and reads
	// these tags to decide whether anyone is still in the fight. Applied
	// on the owner only, the rest of the crew would keep counting a dead
	// player as alive and the job would never end for them.
	[Rpc.Broadcast]
	void SetOutBroadcast( int stateValue )
	{
		Current = (State)stateValue;

		// MissionTimer and CopBotAI both read these tags to decide who
		// still counts as being in the fight.
		GameObject.Tags.Add( Current == State.Escaped ? "escaped" : "eliminated" );

		// Only our own view needs handing over to someone else; other
		// clients have their own cameras to worry about.
		if ( !IsProxy && _cameraMovement is not null )
			_cameraMovement.Enabled = false;

		Log.Info( $"{GameObject.Name} is out of the run: {Current}." );
	}

	// Runs every frame rather than once, because PlayerBase.OnDeath fires
	// a 2-second delayed respawn that we can't call off - it'll reset
	// health and teleport the body to a spawn point regardless. Rather
	// than fighting that, we let it happen and just keep re-applying the
	// out-of-play state on top, which is far less fragile than trying to
	// intercept an async respawn.
	void EnforceOutOfPlay()
	{
		// Can't be hurt, and can't die a second time (which would
		// otherwise cost an escaped player their loadout).
		_player.GodMode = true;

		// Every frame for the same reason as everything else here: SWB's
		// respawn and the loadout re-equip both happen after we've gone
		// out, and a one-shot would be undone by them.
		SetOwnHudVisible( false );

		UpdateSpectate();
	}

	// Invisible, and no hitbox for cops to shoot at.
	//
	// Only the body capsule, deliberately - blanket-disabling every
	// collider would take the CharacterController's out from under
	// PlayerBase.Move(), which still runs every tick on a player who's
	// out. GodMode is what actually makes them unkillable; this is so
	// cops' line-of-sight traces don't stop on an invisible body.
	void HideBody()
	{
		if ( _player.BodyRenderer.IsValid() )
			_player.BodyRenderer.Enabled = false;

		if ( _player.BodyCollider.IsValid() )
			_player.BodyCollider.Enabled = false;
	}

	// Undoes everything SetOut and HideBody did, so a player starting a
	// new job is genuinely back in play.
	//
	// The body capsule matters most here. Cops' line-of-sight traces are
	// required to land ON the target to count as seeing it, and that
	// capsule is most of what they land on. A player carrying a disabled
	// one into the next job is a player those traces pass straight
	// through - so a cop reads "clear line" from somewhere it plainly
	// isn't, and fires. From the receiving end that is being shot through
	// a wall.
	void ResetForNewJob()
	{
		Current = State.Active;
		SpectateTarget = null;

		GameObject.Tags.Remove( "escaped" );
		GameObject.Tags.Remove( "eliminated" );

		if ( _player.BodyRenderer.IsValid() )
			_player.BodyRenderer.Enabled = true;

		if ( _player.BodyCollider.IsValid() )
			_player.BodyCollider.Enabled = true;

		// Only the owner's own view and invulnerability to restore; other
		// clients have their own cameras and their own GodMode to worry
		// about.
		if ( IsProxy )
			return;

		_player.GodMode = false;

		if ( _cameraMovement is not null )
			_cameraMovement.Enabled = true;

		SetOwnHudVisible( true );
		_spectateCamFollowing = null;

		// _wasAlive seeds the alive->dead edge CheckForElimination looks
		// for. Left stale from the last job, a player who died at the end
		// of it would be counted out again the moment this one starts.
		_wasAlive = _player.IsAlive;
	}

	// --- Spectator camera ------------------------------------------------
	//
	// A follow camera behind whoever you're watching, rather than sitting in
	// their head. From inside their head you see exactly what they see but
	// nothing of what's happening TO them, and every flick of their mouse
	// is yours too - it's disorientating to watch. From behind you can see
	// them, what they're aiming at, and what's coming at them.

	// How far back the camera sits, along the direction they're aiming.
	[Property]
	public float SpectateDistance { get; set; } = 180f;

	// How far above their eyes. Lifts the view over their shoulder so they
	// aren't blocking the middle of the screen.
	[Property]
	public float SpectateHeight { get; set; } = 40f;

	// How quickly the camera catches up with them. Higher is snappier;
	// lower is floatier. Only applies to the camera swinging OUT - see
	// UpdateSpectate for why pulling in never waits.
	[Property]
	public float SpectateSmoothing { get; set; } = 8f;

	// The camera's pitch follows theirs, but only within this range.
	// Someone looking straight down would otherwise put the camera
	// directly overhead, and straight up would bury it in the floor.
	const float SpectatePitchMin = -30f;
	const float SpectatePitchMax = 45f;

	// Keeps the camera a little way off walls instead of right against
	// them, where it would clip into the surface.
	const float SpectateCameraRadius = 8f;

	Vector3 _spectateCamPos;
	PlayerBase _spectateCamFollowing;

	void UpdateSpectate()
	{
		// Target can die while we're watching them, so re-check rather
		// than holding onto a corpse.
		if ( !IsValidTarget( SpectateTarget ) )
			SpectateTarget = FindSpectateTarget();

		if ( SpectateTarget is null || !_player.Camera.IsValid() )
			return;

		// Orbit around their eyes, facing the way they're aiming.
		// CameraMovement was switched off in SetOut, so nothing else is
		// moving this camera.
		var pivot = SpectateTarget.EyePos;
		var aim = SpectateTarget.EyeAngles;
		aim.pitch = aim.pitch.Clamp( SpectatePitchMin, SpectatePitchMax );
		aim.roll = 0f;

		var aimRot = aim.ToRotation();
		var desired = pivot - aimRot.Forward * SpectateDistance + Vector3.Up * SpectateHeight;

		// Pull the camera in if there's a wall between them and where it
		// wants to be - otherwise backing into a corner would put the
		// view inside the wall. Swept as a small sphere, so the camera
		// stops short of the surface rather than exactly on it.
		//
		// Players are ignored by tag (the tag carries down to every part
		// of them), so neither the person being watched nor anyone
		// standing near them shoves the camera around.
		var tr = Scene.Trace.Ray( pivot, desired )
			.Radius( SpectateCameraRadius )
			.WithoutTags( "player", "trigger", "viewmodel", "weapon", "clothing", "deadPlayer" )
			.IgnoreGameObjectHierarchy( SpectateTarget.GameObject )
			.Run();

		var target = tr.Hit ? tr.EndPosition : desired;

		// Switching who we watch (or just starting to) snaps straight
		// there. Easing across the map from the last person would be a
		// long, pointless swoop.
		if ( _spectateCamFollowing != SpectateTarget )
		{
			_spectateCamFollowing = SpectateTarget;
			_spectateCamPos = target;
		}
		else
		{
			// Only ease OUTWARD. If a wall has just pulled the safe spot in
			// closer, going there gradually would mean spending those
			// frames inside the wall - so moving closer is immediate, and
			// only moving back out is smoothed.
			var closer = target.Distance( pivot ) < _spectateCamPos.Distance( pivot );
			_spectateCamPos = closer
				? target
				: _spectateCamPos.LerpTo( target, 1f - MathF.Exp( -SpectateSmoothing * Time.Delta ) );
		}

		// Look past them at whatever they're aiming at, rather than at the
		// back of their head - keeps them low in frame and shows the
		// threat they're facing.
		var lookAt = pivot + aimRot.Forward * 200f;

		_player.Camera.WorldPosition = _spectateCamPos;
		_player.Camera.WorldRotation = Rotation.LookAt( lookAt - _spectateCamPos );
	}

	// Our own HUD - health, ammo, armour, cash, prompts, waypoints - goes
	// away while we're out. None of it describes anything we can act on,
	// and ammo and health readouts for a body that isn't in play are just
	// misleading. The scene's mission banner (ELIMINATED - SPECTATING ...)
	// isn't part of the player, so it stays.
	//
	// Faded with Opacity rather than disabling anything. Switching a
	// PanelComponent or ScreenPanel off tears its panels down, and they're
	// only built once in OnStart - they'd come back empty. Opacity leaves
	// the whole UI alive underneath, so bringing it back is instant and
	// exact.
	void SetOwnHudVisible( bool visible )
	{
		var opacity = visible ? 1f : 0f;

		foreach ( var screen in Components.GetAll<ScreenPanel>( FindMode.EverythingInSelfAndDescendants ) )
		{
			if ( screen.Opacity != opacity )
				screen.Opacity = opacity;
		}
	}

	bool IsValidTarget( PlayerBase target )
	{
		if ( !target.IsValid() )
			return false;

		var go = target.GameObject;

		return target.IsAlive
			&& !go.Tags.Has( "eliminated" )
			&& !go.Tags.Has( "escaped" );
	}

	PlayerBase FindSpectateTarget()
	{
		return Scene.GetAllComponents<PlayerBase>()
			.FirstOrDefault( p => p != _player && !p.IsBot && IsValidTarget( p ) );
	}

	static bool MissionInProgress()
	{
		var timer = Game.ActiveScene?.GetAllComponents<MissionTimer>().FirstOrDefault();
		return timer?.MissionInProgress ?? false;
	}
}