MissionTimer.cs
using Sandbox;
using System.Collections.Generic;
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
{
	// How long the crew has to hold the building before the next stage.
	//
	// 0 skips the hold entirely. On a vault map that means the gate starts
	// opening the moment the job is started and the crew goes straight to
	// bagging cash - that's how the bank is set up. On a map without a
	// vault it would open the escape immediately, so don't.
	[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; }

	// --- Optional vault stage -------------------------------------------
	//
	// Maps with loot to grab (the bank) put an extra step between the
	// robbery timer and the escape:
	//
	//     hold the building  ->  vault gate opens  ->  bag every table  ->  escape opens
	//
	// Leave MoneyTables empty and a map plays exactly as before: the timer
	// runs out and the escape opens straight away. That keeps the gas
	// station untouched by any of this.

	// Opened when the robbery timer finishes. Optional even on a vault map -
	// without one the tables are simply unlocked at that moment.
	[Property]
	public VaultGate VaultGate { get; set; }

	// Every table that has to be emptied before the escape opens.
	[Property]
	public List<MoneyTable> MoneyTables { get; set; } = new();

	// 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; }

	// What the HUD tells you to hold while the robbery timer runs - "THE
	// STORE" reads wrong in a bank. Just the noun; the HUD adds the rest.
	[Property]
	public string HoldTargetName { get; set; } = "THE STORE";

	// 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 vault opening and the last table being emptied.
	// Only ever true on a map with MoneyTables.
	public bool LootingActive => _lootingStarted && !_escapeZoneActive;

	// Totals across every table, for the HUD. Summed on read - there are
	// two tables, so there's nothing worth caching.
	public int StacksBagged => ValidTables().Sum( t => t.StacksBagged );
	public int StacksTotal => ValidTables().Sum( t => t.StackCount );

	// 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;
	bool _lootingStarted = false;

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

		// PoliceSpawns keeps the phase in a static so both the spawner and
		// respawning cops can read it without being wired together. A
		// static outlives the scene load, so without this reset a second
		// job would start already in escape phase and pull its opening
		// wave from the wrong end of the map.
		PoliceSpawns.SetPhase( escapePhase: false );

		WarnIfPlayersCantBeMarkedOut();
		WarnIfEscapeSpawnsMissing();
	}

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

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

		AdvanceRobbery();

		var robberyDone = _robberyProgress >= SurviveDuration;

		// Vault maps: the timer finishing opens the vault instead of the
		// exit. Happens once.
		if ( robberyDone && !_lootingStarted && HasLootStage )
			StartLooting();

		// The escape waits for the robbery, and on a vault map for every
		// table to be bare as well. On a map without tables AllLootBagged
		// is simply true, so this is the original behaviour.
		var readyToEscape = robberyDone && AllLootBagged();

		// 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 && readyToEscape )
		{
			_escapeZoneActive = true;
			EscapeZone?.Activate();

			// Move the police response onto its second set of spawn
			// points. The robbery-phase points cover the approaches to the
			// store; these should sit between the store and the exit, so
			// the run out is contested instead of being a walk down an
			// empty street.
			//
			// Only affects cops that spawn from here on. Anyone already on
			// the map keeps fighting from where they are, which is what
			// makes the shift feel like reinforcements cutting you off
			// rather than the map teleporting its enemies around.
			PoliceSpawns.SetPhase( escapePhase: true );

			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 );
	}

	bool HasLootStage => ValidTables().Any();

	IEnumerable<MoneyTable> ValidTables()
	{
		return MoneyTables?.Where( t => t.IsValid() ) ?? Enumerable.Empty<MoneyTable>();
	}

	// True when there's nothing left to grab - including when there was
	// never anything to grab.
	bool AllLootBagged()
	{
		return ValidTables().All( t => t.IsEmpty );
	}

	// Robbery timer's done on a vault map: swing the gate and let the crew
	// at the tables. The escape stays shut until they're empty - see
	// OnUpdate.
	void StartLooting()
	{
		_lootingStarted = true;

		VaultGate?.Open();

		foreach ( var table in ValidTables() )
			table.Unlock();

		Log.Info( $"Vault open — bag {StacksTotal} stacks across {ValidTables().Count()} tables to open the escape." );
	}

	// 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()
	{
		// Once the timer's done (whether that opened the escape or the
		// vault) there's nothing left to hold, so leaving the building
		// isn't a stall any more.
		if ( _escapeZoneActive || _lootingStarted )
		{
			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();

		// The take itself is already handled - EscapeZone pays each player
		// as they get out, so anyone who made it has banked their cut by
		// the time this runs.
		//
		// Investment dividends are the other half, and they're paid here
		// precisely because they don't care how the job went. Losing
		// everything you were carrying still leaves you with an income,
		// which is what stops a bad run turning into a dead save.
		PayInvestmentDividends();

		Log.Info( won ? "Mission over — at least one player escaped." : "Mission failed — nobody made it out." );

		// Arms the countdown; TickReturnToHideout does the rest.
		_returnToHideoutAt = ReturnToHideoutDelay;
	}

	// Counts down to the trip home once the job is over. Negative until
	// EndMission sets it, so it never fires on its own.
	TimeUntil _returnToHideoutAt = float.PositiveInfinity;

	// 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.
	//
	// Driven from OnUpdate rather than "await GameTask.DelaySeconds(...)".
	//
	// The async version threw a NullReferenceException inside
	// Game.ChangeScene every single time, with
	// ExpirableSynchronizationContext.ProcessQueue in the stack - the
	// continuation was resuming after the context that owned it had
	// expired, and changing scene from there hits an engine that has
	// already started letting go. A scene change that throws half way is
	// the worst possible outcome, because it leaves exactly the
	// partially-torn-down state that makes the next job behave oddly.
	//
	// A plain countdown in the update loop has none of that: it runs on the
	// main loop, in a live scene, at a predictable moment. It's also what
	// s&box's own SB3006 analyser warning was telling us to do.
	void TickReturnToHideout()
	{
		if ( !_returnToHideoutAt )
			return;

		// Latch it off immediately - ChangeScene doesn't take effect until
		// the end of the frame, so without this we'd ask again next tick.
		_returnToHideoutAt = float.PositiveInfinity;

		if ( HideoutScene is null )
		{
			Log.Warning( "MissionTimer: no HideoutScene set, staying on the end screen." );
			return;
		}

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

		// NOTE: options.DeleteEverything is deliberately NOT set.
		//
		// It was tried, on the theory that network-spawned objects were
		// surviving the change. It fixed nothing measurable and made
		// Game.ChangeScene itself throw. Don't re-add it without a
		// reproducing case.

		// Let go of the outgoing scene before asking the engine to drop it.
		// These caches hold a Scene reference and GameObjects inside it,
		// and anything still referencing them keeps the whole old scene
		// graph reachable.
		Crew.Reset();
		PoliceSpawns.Reset();

		Game.ChangeScene( options );
	}

	// Pays every player what the investment table owes them for this job.
	//
	// Host-gated, then routed per player. EndMission runs independently on
	// every client's own MissionTimer, so without the host check each
	// client would fire the RPC and the owner would be paid once per
	// person in the lobby. PayDividend is [Rpc.Owner], so one call from
	// the host lands exactly once, on the machine that owns that player's
	// cash.
	//
	// Everyone gets paid, including players who were eliminated - being
	// dead is not a reason to lose money that was never at risk.
	void PayInvestmentDividends()
	{
		if ( !Networking.IsHost )
			return;

		foreach ( var loadout in Scene.GetAllComponents<PlayerLoadout>() )
		{
			if ( loadout.IsValid() )
				loadout.PayDividend();
		}
	}

	// 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." );
	}

	// The escape phase falls back to the robbery spawn points when the map
	// has none of its own, so a map without them still plays - it just
	// doesn't get the second stage. Said once at the start of the job so
	// it's obvious that's what happened, rather than leaving it looking
	// like the phase switch didn't fire.
	void WarnIfEscapeSpawnsMissing()
	{
		if ( PoliceSpawns.All( Scene, PoliceSpawns.EscapeTag ).Count > 0 )
			return;

		Log.Warning( $"MissionTimer: no GameObjects tagged '{PoliceSpawns.EscapeTag}' in this map. " +
			$"The escape phase will keep using the '{PoliceSpawns.HeistTag}' points. " +
			"Tag some spawn points between the objective and the escape zone to get the second stage." );
	}
}