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

// Which hand a bought weapon goes in. Vendor stock entries carry one of
// these so the shop knows which column to list them under, and so buying
// a second rifle replaces your rifle rather than stacking up.
public enum LoadoutSlot
{
	Primary,
	Secondary
}

// Per-player cash and equipped weapons - the risk/reward core. Buy gear at
// the Hideout vendor, keep it if you extract, lose it if you go down.
//
// Cash lives here rather than in its own wallet component because the two
// are always read and written together (a purchase spends cash AND equips
// a gun), and one component is one thing to wire onto the player prefab
// instead of two.
//
// Put this on the player prefab, alongside PlayerBase.
public sealed class PlayerLoadout : Component
{
	// The sidearm, always given, never lost - so a broke player who just
	// got wiped still has something to shoot with. The string is a weapon
	// ClassName as registered in SWB's WeaponRegistry ("swb_colt",
	// "swb_scarh", ...), not a prefab path.
	//
	// It lives in the SECONDARY slot on purpose. As a default primary it
	// got replaced by the first rifle anyone bought, which meant players
	// were permanently carrying exactly one gun - nothing to switch to,
	// and no reason for a weapon-swap key to exist. As a sidearm it stays
	// put, and the only thing that displaces it is buying the revolver.
	[Property]
	public string DefaultSecondary { get; set; } = "swb_colt";

	[Property]
	public int StartingCash { get; set; } = 2000;

	// How much reserve ammo each equipped weapon spawns with. Reserve is
	// the pool reloads pull from, separate from what's in the magazine.
	[Property]
	public int ReserveAmmo { get; set; } = 360;

	// [Sync] replicates these to every other client, with the owning
	// client as the authority - so other players' HUDs can read them, but
	// only the owner's writes actually stick. That's why AddCash below is
	// an RPC rather than something any machine can just call.
	//
	// Primary/Secondary hold weapon ClassNames; null means "nothing
	// bought", which ApplyToInventory reads as "fall back to default".
	[Sync] public int Cash { get; set; }
	[Sync] public string Primary { get; set; }
	[Sync] public string Secondary { get; set; }

	// Where progress is written. FileSystem.Data is this game's own
	// per-machine storage folder, so the file survives quitting.
	//
	// Not Game.Cookies, which is the other obvious candidate: cookies
	// expire a month after they're set, and a save file that silently
	// evaporates is worse than no save file.
	const string SaveFile = "loadouts.json";

	// In-memory mirror of the save file. It has two jobs:
	//
	//  - Loadouts have to survive the Hideout -> mission scene change, and
	//    GameObjects don't; loading a scene destroys everything in the old
	//    one, this component included. A static lives on the type, so it
	//    outlives the swap.
	//  - It saves re-reading the file on every lookup.
	//
	// Keyed by SteamId so progress follows the person rather than their
	// spawned pawn, and so two accounts sharing a machine don't collide.
	static Dictionary<string, SavedLoadout> _cache;

	// Plain snapshot of what's worth keeping. Deliberately not the
	// component itself - we only want the data, not a reference to a
	// GameObject that's about to be destroyed.
	//
	// Public class with public PROPERTIES because this gets JSON
	// serialized: System.Text.Json ignores public fields by default and
	// can't reach private types, so a private struct of fields would
	// quietly save as {} every time.
	public class SavedLoadout
	{
		public int Cash { get; set; }
		public string Primary { get; set; }
		public string Secondary { get; set; }
	}

	PlayerBase _player;

	// Last tick's alive state, so OnUpdate can spot the moment it flips
	// rather than just knowing the current value. See OnUpdate.
	bool _wasAlive = false;

	// OnAwake runs once when the component is created, before OnStart and
	// before any updates - the right place to cache references to things
	// on the same GameObject.
	protected override void OnAwake()
	{
		_player = Components.Get<PlayerBase>();

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

	protected override void OnStart()
	{
		// IsProxy means "this object belongs to somebody else and I'm just
		// displaying their copy". Only the owner restores their own saved
		// loadout; everyone else receives it through [Sync].
		if ( IsProxy )
			return;

		Load();
	}

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

		// SWB's InventoryDisplay equips whatever slot key you press (1-9),
		// which would let you draw a weapon in the hideout despite
		// ApplyToInventory having deliberately left everything holstered.
		// Rather than modify SWB's HUD, just put it back every frame.
		if ( InSafeArea() )
			HolsterEverything();

		// Both branches below watch for a CHANGE in alive state rather
		// than acting on the current value, because each thing should
		// happen once per transition, not every frame while it's true.
		//
		// This polling approach is used instead of overriding PlayerBase's
		// respawn/death methods, which would mean subclassing the player -
		// and swapping a component's type on a prefab loses every wired
		// reference in the s&box editor. A sibling component that watches
		// state avoids that entirely.

		// Dead -> alive. PlayerBase.Respawn() rebuilds the inventory from
		// scratch (and SWB's demo player hands out every gun in the game
		// while it's at it), so the loadout has to be re-applied
		// afterwards. By the time this reads true, Respawn() has already
		// finished, so there's no race to lose.
		if ( _player.IsAlive && !_wasAlive )
			ApplyToInventory();

		// Alive -> dead. The gear they paid for is gone, and the automatic
		// respawn 2 seconds later will re-apply an empty loadout as
		// default kit.
		//
		// Players who escaped never hit this: EscapeZone disables their
		// GameObject, and a disabled object stops updating, so this method
		// isn't running to notice anything. That's precisely why escaping
		// preserves your loadout.
		if ( !_player.IsAlive && _wasAlive )
			LoseLoadout();

		_wasAlive = _player.IsAlive;
	}

	public bool CanAfford( int cost ) => Cash >= cost;

	// Called by the vendor menu when a line is clicked. Returns false and
	// changes nothing if the purchase isn't valid, so the UI can just
	// check the result instead of pre-validating everything itself.
	public bool TryBuy( string className, int cost, LoadoutSlot slot )
	{
		if ( string.IsNullOrWhiteSpace( className ) || !CanAfford( cost ) )
			return false;

		// Verify the gun actually exists before taking any money -
		// otherwise a typo'd ClassName in the vendor's stock list would
		// charge the player for nothing.
		if ( WeaponRegistry.Instance?.Get( className ) is null )
		{
			Log.Warning( $"PlayerLoadout: '{className}' isn't in the WeaponRegistry." );
			return false;
		}

		Cash -= cost;

		// One weapon per slot - buying a new primary replaces the old one
		// rather than accumulating. Keeps loadout selection quick, which
		// is the design goal; there's deliberately no deep inventory here.
		if ( slot == LoadoutSlot.Primary )
			Primary = className;
		else
			Secondary = className;

		Save();

		// Equip immediately so the player can see what they bought in the
		// Hideout, rather than waiting until the next mission spawn.
		ApplyToInventory();
		return true;
	}

	// [Rpc.Owner] makes this run on the machine that owns this player,
	// no matter who calls it. Needed because Cash is [Sync] - a write on
	// any other machine is local-only and gets overwritten by the owner's
	// value - and because Save() has to land in the owning player's own
	// process for the static store to have it after the scene change.
	//
	// An EscapeZone trigger can fire on a machine that doesn't own the
	// player who walked in, so the payout has to be routed rather than
	// applied wherever it happened to be detected.
	[Rpc.Owner]
	public void AddCash( int amount )
	{
		Cash += amount;
		Save();

		Log.Info( $"{GameObject.Name} banked ${amount} (total ${Cash})." );
	}

	// Called when this player is eliminated: the gear they paid for is
	// gone and they drop back to default kit. Cash they've already banked
	// is untouched - it's the loadout that carries the risk, so a wipe
	// sets you back but never traps you with nothing to spend.
	public void LoseLoadout()
	{
		Primary = null;
		Secondary = null;
		Save();
	}

	// Wipes the inventory and re-gives exactly what's equipped, so the
	// player ends up holding their loadout and nothing else.
	public void ApplyToInventory()
	{
		if ( IsProxy || _player?.Inventory is null )
			return;

		// Clear() destroys the existing weapon GameObjects. This is what
		// undoes SWB's demo player handing out all six guns on respawn.
		_player.Inventory.Clear();

		// In the hideout you still carry your gear - you just can't use
		// it. Handing the weapons over without making any of them active
		// means they show up in the inventory HUD (so you can see what
		// you've bought) while Inventory.Add calls OnCarryStop on each,
		// which disables the weapon GameObject outright. A disabled weapon
		// doesn't run its firing loop, so there's nothing to suppress.
		var safe = InSafeArea();

		// The primary is purely what they've bought - there's no fallback
		// for it. Someone who hasn't bought one, or who just lost theirs,
		// goes out on the sidearm alone, which is what makes buying a
		// proper gun feel like it's worth the money.
		var hasPrimary = !string.IsNullOrWhiteSpace( Primary );

		if ( hasPrimary )
			Give( Primary, makeActive: !safe );

		// The sidearm slot is never empty: the bought secondary if there
		// is one, otherwise the default pistol. This is the "never leave
		// anyone unarmed" guarantee that used to sit on the primary.
		var secondary = string.IsNullOrWhiteSpace( Secondary ) ? DefaultSecondary : Secondary;

		// Drawn only when there's no primary to draw instead - you come
		// out of the hideout holding your best gun, not your pistol.
		Give( secondary, makeActive: !safe && !hasPrimary );
	}

	// Puts whatever's in hand away. Mirrors what Inventory.SetActive does
	// to the outgoing weapon: OnCarryStop disables the weapon GameObject,
	// which stops it running its firing loop at all.
	void HolsterEverything()
	{
		var inventory = _player.Inventory;

		if ( inventory?.Active is null )
			return;

		if ( inventory.Active.Components.TryGet<IInventoryItem>( out var item ) )
			item.OnCarryStop();

		inventory.Active = null;
		inventory.ActiveItem = null;
	}

	// A scene with no MissionTimer isn't a job, so it's somewhere safe -
	// currently just the hideout. Keyed off the timer rather than a scene
	// name or a flag component so any mission map you add later works
	// without extra wiring, and the hideout needs no marker on it.
	static bool InSafeArea()
	{
		return Game.ActiveScene?.GetAllComponents<MissionTimer>().Any() != true;
	}

	// makeActive decides whether this becomes the weapon in their hands
	// right now, versus sitting in the inventory to be switched to.
	void Give( string className, bool makeActive )
	{
		// The registry holds one disabled master copy of each weapon,
		// built at scene start from the WeaponPrefabs list.
		var weapon = WeaponRegistry.Instance?.Get( className );

		if ( weapon is null )
		{
			Log.Warning( $"PlayerLoadout: '{className}' isn't in the WeaponRegistry." );
			return;
		}

		// AddClone (not Add) because the registry entry is that shared
		// master copy - every player needs their own instance of it. This
		// also handles network-spawning the clone under this player's
		// ownership.
		_player.Inventory.AddClone( weapon.GameObject, makeActive );

		// Ammo is tracked per ammo TYPE on the player, not per weapon, so
		// two guns sharing a caliber share the same reserve pool.
		_player.SetAmmo( weapon.Primary.AmmoType, ReserveAmmo );
	}

	// Restore whatever this player was carrying, whether that's from
	// earlier this session or from a previous run of the game. First time
	// we've seen them, they get their starting stake instead.
	void Load()
	{
		if ( Cache().TryGetValue( SaveKey(), out var saved ) )
		{
			Cash = saved.Cash;
			Primary = saved.Primary;
			Secondary = saved.Secondary;
		}
		else
		{
			Cash = StartingCash;
		}
	}

	// Called after every change rather than at some "end of mission"
	// moment, because there isn't a reliable one - players can die,
	// escape, or disconnect at any point, and a disabled or destroyed
	// object won't get the chance to save on its way out.
	//
	// Writes are small and infrequent (a purchase or a payout), so going
	// to disk each time is cheaper than trying to find a safe moment to
	// flush and getting it wrong.
	void Save()
	{
		Cache()[SaveKey()] = new SavedLoadout
		{
			Cash = Cash,
			Primary = Primary,
			Secondary = Secondary
		};

		try
		{
			FileSystem.Data.WriteJson( SaveFile, _cache );
		}
		catch ( Exception e )
		{
			// A failed write shouldn't take the run down with it - the
			// in-memory cache still has the right values, so play
			// continues and only persistence is lost.
			Log.Warning( $"PlayerLoadout: couldn't write {SaveFile} - {e.Message}" );
		}
	}

	// Reads the save file once per session, then serves everything from
	// memory.
	static Dictionary<string, SavedLoadout> Cache()
	{
		if ( _cache is not null )
			return _cache;

		// ReadJsonOrDefault rather than ReadJson: a corrupt or
		// hand-edited save should start people fresh rather than throw on
		// every spawn.
		_cache = FileSystem.Data.ReadJsonOrDefault<Dictionary<string, SavedLoadout>>( SaveFile, null )
			?? new Dictionary<string, SavedLoadout>();

		return _cache;
	}

	// SteamId is stable across scene loads and reconnects, which a
	// GameObject Id isn't. The fallback covers anything without a network
	// owner (a local test pawn dropped straight into the scene); it won't
	// survive a scene change, but nothing else about that case would
	// either.
	string SaveKey()
	{
		return Network.Owner?.SteamId.ToString() ?? GameObject.Id.ToString();
	}
}