EscapeZone.cs
using Sandbox;
using SWB.Player;
using System;

// The extraction point. Put this on a GameObject with a trigger Collider
// covering the area players have to reach, and leave that GameObject
// DISABLED in the editor - MissionTimer turns it on once the survive
// timer runs out.
//
// Escaping is per-player: whoever walks in gets pulled out of the map and
// keeps their cut, whether or not anyone else makes it. You can leave
// friends behind.
//
// Component.ITriggerListener is s&box's interface for "tell me when
// something enters/leaves my trigger". It only does anything if there's a
// Collider on this same GameObject with IsTrigger ticked - the Collider
// defines the shape, this class decides what to do about it. The interface
// declares several overloads (Collider, GameObject, and pairs of both) but
// they have default implementations, so we only write the one we care
// about. MissionStartTrigger works the same way.
public sealed class EscapeZone : Component, Component.ITriggerListener
{
	// [Property] exposes this in the s&box editor's inspector so it can be
	// tuned per-scene without recompiling.
	//
	// What this job pays each person who actually gets out. Sits on the
	// escape zone rather than the timer because each mission map has its
	// own exit, so the job's value travels with it.
	[Property]
	public int EscapeReward { get; set; } = 1000;

	// What escaping actually pays, after the chosen job difficulty. The
	// property above stays the map's baseline so the inspector is
	// readable; harder jobs scale it up on the way out.
	public int ActualReward => (int)(EscapeReward * ActiveMission.PayoutMultiplier);

	// How many players have got out. MissionTimer reads this to tell a
	// win from a wipe.
	//
	// Counted explicitly rather than by scanning the scene for "escaped"
	// tags, because escaping disables the player GameObject and
	// Scene.GetAllObjects( true ) returns only ENABLED objects - so a
	// scan can never see anyone who escaped. That mistake is exactly why
	// a successful extraction used to report MISSION FAILED.
	public int EscapedCount { get; private set; }

	// Whether the person at THIS screen got out, and for how much. Read by
	// MissionResultHud to confirm the extraction.
	//
	// It has to be tracked here rather than on the player, because
	// escaping disables the player GameObject - which takes any HUD
	// component riding on it down too. This component lives in the scene
	// and survives.
	public bool LocalPlayerEscaped { get; private set; }
	public int LastRewardPaid { get; private set; }

	// Called by MissionTimer when the survive timer elapses. Until then
	// the whole GameObject (trigger Collider included) stays off, so
	// nobody can extract early by standing in the exit.
	//
	// Note this enables the GAMEOBJECT, not just this component. Disabling
	// the whole object switches off the Collider too - if only this
	// component were disabled the trigger would still be live, and s&box's
	// behaviour about delivering callbacks to a disabled listener isn't
	// something worth betting on.
	public void Activate()
	{
		GameObject.Enabled = true;
	}

	public void OnTriggerEnter( Collider other )
	{
		// Trigger callbacks fire on every client that simulates the
		// overlap, so only the host is allowed to resolve an extraction -
		// otherwise the same escape gets processed once per machine and
		// the payout RPC fires once per machine too.
		if ( !Networking.IsHost )
			return;

		// `other` is the Collider that touched us, which on a player is a
		// CapsuleCollider sitting on a child "Body" object - not the
		// player root. .Root walks up to the top of the hierarchy so we're
		// dealing with the actual player GameObject, which is where the
		// tags and PlayerBase/PlayerLoadout components live.
		var player = other.GameObject.Root;

		// Two guards here:
		//  - "player" tag: keeps cops, bullets, ragdolls and anything else
		//    that wanders into the zone from triggering an extraction.
		//  - "escaped" tag: OnTriggerEnter can fire more than once for the
		//    same body (re-entering, multiple colliders), and paying
		//    someone twice for one escape would be an easy exploit.
		if ( !player.Tags.Has( "player" ) || player.Tags.Has( "escaped" ) )
			return;

		// Grab the component holding this player's cash and gear. It's on
		// the player prefab next to PlayerBase.
		var loadout = player.Components.Get<PlayerLoadout>();

		if ( loadout is not null )
		{
			// Escaping is also what preserves the gear they bought: their
			// loadout is only cleared by LoseLoadout(), which fires when
			// PlayerLoadout notices them die. MissionPlayerState puts them
			// in GodMode on the way out, so they can't be killed after the
			// fact and lose what they just earned. That's the whole
			// risk/reward hook - extract and keep it, go down and lose it.
			loadout.AddCash( ActualReward );
		}
		else
		{
			// Non-fatal, but it means this player just did the hard part
			// for free - worth shouting about rather than silently
			// swallowing.
			Log.Warning( $"{player.Name} escaped but has no PlayerLoadout - no payout. Is it on the player prefab?" );
		}

		RegisterEscape( player.Id );
	}

	// Host decides, everyone applies. Every client needs the "escaped" tag
	// locally, because each one runs its own MissionTimer and would
	// otherwise keep counting this player as still in the fight - so the
	// job would never end for anybody but the host.
	[Rpc.Broadcast]
	void RegisterEscape( Guid playerId )
	{
		var player = Scene.Directory.FindByGuid( playerId );

		if ( player is null )
			return;

		EscapedCount++;

		// MissionPlayerState applies the tag and takes over from here:
		// hides the body, makes them untouchable so a stray bullet can't
		// take the loadout they just earned, and puts them on a teammate's
		// camera to watch the rest of the run.
		var state = player.Components.Get<MissionPlayerState>();

		if ( state is not null )
		{
			state.MarkEscaped();
		}
		else
		{
			// No state component - fall back to tagging directly so the
			// mission can still resolve, just without the spectate view.
			player.Tags.Add( "escaped" );
			Log.Warning( $"{player.Name} escaped but has no MissionPlayerState - no spectate view. Is it on the player prefab?" );
		}

		if ( PlayerBase.Local?.GameObject == player )
		{
			LocalPlayerEscaped = true;
			LastRewardPaid = ActualReward;
		}

		// Deliberately NOT disabling the GameObject any more. It used to be
		// switched off here, but a disabled object stops updating - which
		// killed the player's own camera along with it. They stay enabled
		// and MissionPlayerState hides them instead, so the camera lives
		// on to spectate whoever's still inside.
		Log.Info( $"{player.Name} escaped." );
	}
}