MissionTimer.cs
using Sandbox;
using System.Linq;

// Drives the mission flow: hold out for SurviveDuration, then the escape
// zone opens and players have to actually reach it to get paid. Surviving
// the timer is no longer a win by itself - it just starts the run for the
// exit, with cops still spawning and fighting the whole way.
public sealed class MissionTimer : Component
{
	[Property]
	public float SurviveDuration { get; set; } = 90f;

	// Optional — if set, spawning stops once the mission is fully over
	// (everyone has escaped or died) instead of continuing forever.
	[Property]
	public MissionSpawner Spawner { get; set; }

	// The extraction point, left disabled in the scene until the survive
	// timer runs out.
	[Property]
	public EscapeZone EscapeZone { get; set; }

	// The area the crew has to hold for the robbery to progress - the gas
	// station interior. Leave unset and the clock just runs regardless of
	// where anyone is.
	[Property]
	public RobberyZone RobberyZone { get; set; }

	// Where everyone goes once the job's over, win or lose. Leave unset to
	// just stay on the end screen.
	[Property]
	public SceneFile HideoutScene { get; set; }

	// Long enough to read the result before being yanked back.
	[Property]
	public float ReturnToHideoutDelay { get; set; } = 5f;

	// Read by MissionResultHud to know what to show.
	public bool MissionEnded { get; private set; }
	public bool Won { get; private set; }

	// True once the survive timer has run out and the exit has opened.
	public bool EscapeAvailable => _escapeZoneActive;

	// True between the job starting and it ending - i.e. whether there's
	// anything worth putting on the HUD at all.
	public bool MissionInProgress => _missionStarted && !MissionEnded;

	// Seconds left before the escape opens, floored at zero. Meaningless
	// once EscapeAvailable is true.
	public float TimeUntilEscape => MathX.Clamp( SurviveDuration - _robberyProgress, 0f, SurviveDuration );

	// True while the robbery is on hold because nobody's in the store.
	// Drives the HUD warning.
	public bool RobberyStalled { get; private set; }

	// Seconds of robbery actually completed. Accumulated by hand rather
	// than using a TimeSince, because it has to PAUSE when the crew leaves
	// the store - a TimeSince counts wall-clock and can't be held.
	float _robberyProgress;
	bool _missionStarted = false;
	bool _escapeZoneActive = false;

	// Called by MissionStartTrigger once the hold-E interaction completes.
	public void StartMission()
	{
		_missionStarted = true;
		_escapeZoneActive = false;
		MissionEnded = false;
		_robberyProgress = 0f;
		RobberyStalled = false;

		WarnIfPlayersCantBeMarkedOut();
	}

	protected override void OnUpdate()
	{
		// Mission's over - stop evaluating anything. MissionResultHud is
		// still reading MissionEnded/Won to draw the end screen, so this
		// state has to stick rather than reset.
		if ( MissionEnded )
			return;

		// Nothing happens until someone triggers the job.
		if ( !_missionStarted )
			return;

		AdvanceRobbery();

		// Held out long enough - open the exit. The run isn't won yet,
		// this just gives players somewhere to run TO. Cops keep spawning
		// and fighting the whole way out, which is the point: the
		// extraction is the hard part, not the timer.
		if ( !_escapeZoneActive && _robberyProgress >= SurviveDuration )
		{
			_escapeZoneActive = true;
			EscapeZone?.Activate();
			Log.Info( "Escape zone is open — get out to secure your cut." );
		}

		// The mission is only over once nobody's left who could still
		// change the outcome. Whoever already escaped keeps their cut
		// regardless of how that shakes out.
		if ( !AnyPlayerActive() )
			EndMission( won: EscapeZone?.EscapedCount > 0 );
	}

	// The robbery only progresses while the crew is actually holding the
	// store. Leaving stalls it rather than failing outright - you can back
	// off to deal with a flank and come back, you just aren't making
	// progress while you're out there, so kiting cops around the map for
	// the whole timer isn't a strategy.
	//
	// Once the escape is open this stops applying: at that point the job
	// is done and you're supposed to be leaving.
	void AdvanceRobbery()
	{
		if ( _escapeZoneActive )
		{
			RobberyStalled = false;
			return;
		}

		// No zone assigned - behave as it did before and just run the
		// clock, so an older mission map without one still works.
		var holding = RobberyZone is null || RobberyZone.AnyPlayerInside;

		RobberyStalled = !holding;

		if ( holding )
			_robberyProgress += Time.Delta;
	}

	void EndMission( bool won )
	{
		MissionEnded = true;
		Won = won;

		// Only stops NEW waves. Any cop already on the map keeps going -
		// harmless at this point, since there's nobody left for them to
		// fight.
		Spawner?.StopSpawning();

		// Nothing to pay out here - EscapeZone pays each player as they
		// get out, so anyone who made it has already banked their cut by
		// the time this runs.
		Log.Info( won ? "Mission over — at least one player escaped." : "Mission failed — nobody made it out." );

		ReturnToHideout();
	}

	// Hold on the result screen for a beat, then head back to the hideout
	// so the next job can be picked. Loadouts and cash survive the trip
	// because PlayerLoadout keeps them in a static store keyed by SteamId,
	// not on the GameObject.
	async void ReturnToHideout()
	{
		if ( HideoutScene is null )
		{
			Log.Warning( "MissionTimer: no HideoutScene set, staying on the end screen." );
			return;
		}

		await GameTask.DelaySeconds( ReturnToHideoutDelay );

		var options = new SceneLoadOptions();
		options.SetScene( HideoutScene );
		options.ShowLoadingScreen = true;

		Game.ChangeScene( options );
	}

	// Still in the fight: not dead, not extracted.
	//
	// Both tags are applied by MissionPlayerState (escaping goes through
	// it too). Tags rather than enabled state, because players who are out
	// stay enabled - they're spectating, so their camera has to keep
	// running.
	bool AnyPlayerActive()
	{
		return Crew.ActivePlayers( Scene ).Count > 0;
	}

	// The whole end-of-job condition depends on players being marked out
	// of the run, and that is MissionPlayerState's job. Without it on the
	// player prefab nobody is ever eliminated or escaped, so AnyPlayerActive
	// is permanently true: the job never ends, the result screen never
	// shows, nobody goes back to the hideout, and the spawner keeps feeding
	// cops in for as long as the game is open.
	//
	// That's a silent failure that looks like a dozen unrelated bugs, so
	// it's worth shouting about once at the start of every job.
	void WarnIfPlayersCantBeMarkedOut()
	{
		if ( Scene.GetAllComponents<MissionPlayerState>().Any() )
			return;

		Log.Warning( "MissionTimer: no MissionPlayerState on any player. " +
			"Nobody can be marked eliminated or escaped, so this job will never end " +
			"and cops will keep spawning. Add MissionPlayerState to player.prefab." );
	}
}