Money/Cash.cs

Host-side money system for the roleplay gamemode. Holds the wallet hold state, and handles host RPCs to give cash to another player, drop a bundle on the ground, pick up a bundle, and search/loot a downed player's cash and inventory slots, with logging and saves.

namespace HorizonRP;

/// <summary>
/// L'argent liquide qui change de main. Il n'est pas un objet d'inventaire : c'est le compteur
/// du portefeuille (<see cref="RoleplayPlayer.Wallet"/>). On le tient en main par la case
/// « Billets » de la barre (après les cinq poches, à la molette), comme une clé : la liasse dans
/// la main (VehicleKeySystem ; rien à 0 $), et le clic droit ouvre <see cref="CashMenu"/> : donner
/// au joueur qu'on regarde, ou poser au sol (<see cref="CashDrop"/>), après quoi on revient aux
/// mains. Ce qu'on ramasse retourne droit au portefeuille.
///
/// Sur un corps : un inconscient garde son argent, on le prend en le fouillant (E maintenu,
/// RoleplayPlayer.Death.cs) ; à sa mort, ce qu'il avait sur lui reste par terre en liasse.
///
/// L'hôte relit tout : le client ne désigne qu'un montant et une cible, jamais un solde.
/// Chaque mouvement va au journal.
/// </summary>
public static class Cash
{
	/// <summary>La valeur de <see cref="RoleplayPlayer.HeldKey"/> quand on tient ses billets.</summary>
	public const string HoldId = "argent:liasse";
	public const string ModelPath = "models/horizon/billets/liasse.vmdl";

	/// <summary>Portée pour donner ou ramasser (~2,5 m), la même que pour les objets.</summary>
	public const float Reach = InventoryActions.Reach;

	public static bool IsHeld( RoleplayPlayer player ) => player?.HeldKey == HoldId;

	static RoleplayPlayer Caller() =>
		RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id && x.ProfileLoaded );

	static bool Busy( RoleplayPlayer p ) => p.OnTheGround || p.NeedsCharacter || p.Restrained;

	/// <summary>Prend ses billets en main (l'arme et la clé tenues sont rangées), ou les range.</summary>
	[Rpc.Host]
	public static void RequestHold( bool hold )
	{
		var player = Caller();
		if ( player is null || Busy( player ) )
			return;
		if ( !hold )
		{
			if ( IsHeld( player ) )
				player.HeldKey = null;
			return;
		}
		if ( player.GameObject.GetComponentInParent<Vehicle>() is not null )
			return;
		WeaponSystem.Current?.Holster( player );
		player.HeldKey = HoldId;
	}

	/// <summary>Donne une somme au joueur visé, à portée.</summary>
	[Rpc.Host]
	public static void RequestGive( Guid targetId, int amount )
	{
		var player = Caller();
		var target = Game.ActiveScene.Directory.FindByGuid( targetId )?.GetComponent<RoleplayPlayer>();
		if ( player is null || target is null || target == player || Busy( player ) || !target.ProfileLoaded )
			return;
		if ( target.WorldPosition.Distance( player.WorldPosition ) > Reach * 1.5f )
		{
			player.Notify( $"{target.RpName} est trop loin.", "erreur" );
			return;
		}
		amount = Math.Clamp( amount, 0, player.Wallet );
		if ( amount <= 0 )
		{
			player.Notify( "Vous n'avez pas cette somme sur vous.", "erreur" );
			return;
		}
		player.Wallet -= amount;
		target.Wallet += amount;
		// L'argent donné, on revient aux mains (la barre suit chez lui : CashMenu).
		player.HeldKey = null;
		player.Notify( $"Vous donnez {Format.Money( amount )} à {target.RpName}.", "argent" );
		target.Notify( $"{player.RpName} vous donne {Format.Money( amount )}.", "argent" );
		HorizonGame.Current?.AddLog( "argent_donne", player.SteamId, $"{player.RpName} donne {Format.Money( amount )} en liquide à {target.RpName} ({target.SteamId})" );
		HorizonGame.Current?.SaveNow( player );
		HorizonGame.Current?.SaveNow( target );
	}

	/// <summary>Pose une somme au sol, devant soi.</summary>
	[Rpc.Host]
	public static void RequestDrop( int amount )
	{
		var player = Caller();
		if ( player is null || Busy( player ) || player.GameObject.GetComponentInParent<Vehicle>() is not null )
			return;
		amount = Math.Clamp( amount, 0, player.Wallet );
		if ( amount <= 0 )
		{
			player.Notify( "Vous n'avez pas cette somme sur vous.", "erreur" );
			return;
		}
		player.Wallet -= amount;
		player.HeldKey = null;
		var at = player.WorldPosition + player.WorldRotation.Forward.WithZ( 0 ).Normal * 26f;
		CashDrop.Spawn( amount, at, $"posé par {player.RpName} ({player.SteamId})" );
		player.Notify( $"Vous posez {Format.Money( amount )} au sol.", "argent" );
		HorizonGame.Current?.AddLog( "argent_pose", player.SteamId, $"{player.RpName} pose {Format.Money( amount )} en liquide au sol" );
		HorizonGame.Current?.SaveNow( player );
	}

	/// <summary>Ramasse une liasse : tout va au portefeuille.</summary>
	[Rpc.Host]
	public static void RequestPickup( Guid dropId )
	{
		var player = Caller();
		var drop = Game.ActiveScene.Directory.FindByGuid( dropId )?.GetComponent<CashDrop>();
		if ( player is null || drop is null || Busy( player ) || drop.Amount <= 0 )
			return;
		if ( drop.WorldPosition.Distance( player.WorldPosition ) > Reach * 1.5f )
			return;
		var amount = drop.Amount;
		drop.Amount = 0;
		drop.GameObject.Destroy();
		player.Wallet += amount;
		player.Notify( $"Vous ramassez {Format.Money( amount )}.", "argent" );
		HorizonGame.Current?.AddLog( "argent_ramasse", player.SteamId, $"{player.RpName} ramasse {Format.Money( amount )} au sol ({drop.Origin})" );
		HorizonGame.Current?.SaveNow( player );
	}

	/// <summary>Fouille un inconscient : on prend l'argent qu'il a sur lui.</summary>
	[Rpc.Host]
	public static void RequestSearch( Guid targetId )
	{
		var player = Caller();
		var target = Game.ActiveScene.Directory.FindByGuid( targetId )?.GetComponent<RoleplayPlayer>();
		if ( player is null || target is null || target == player || Busy( player ) || !target.Downed )
			return;
		if ( target.BodyPosition.Distance( player.WorldPosition ) > Reach * 1.5f )
			return;
		var amount = target.Wallet;
		if ( amount <= 0 )
		{
			player.Notify( $"{target.RpName} n'a pas d'argent sur lui.", "info" );
			HorizonGame.Current?.AddLog( "fouille", player.SteamId, $"{player.RpName} fouille {target.RpName} ({target.SteamId}) inconscient : rien" );
			return;
		}
		target.Wallet = 0;
		player.Wallet += amount;
		player.Notify( $"Vous prenez {Format.Money( amount )} sur {target.RpName}.", "argent" );
		HorizonGame.Current?.AddLog( "fouille", player.SteamId, $"{player.RpName} prend {Format.Money( amount )} sur {target.RpName} ({target.SteamId}) inconscient" );
		HorizonGame.Current?.SaveNow( player );
		HorizonGame.Current?.SaveNow( target );
	}

	/// <summary>L'inconscient qu'on fouille, s'il l'est encore et qu'on est à côté.</summary>
	static (RoleplayPlayer Player, RoleplayPlayer Target) SearchPair( Guid targetId )
	{
		var player = Caller();
		var target = Game.ActiveScene.Directory.FindByGuid( targetId )?.GetComponent<RoleplayPlayer>();
		if ( player is null || target is null || target == player || Busy( player ) || !target.Downed )
			return (null, null);
		return target.BodyPosition.Distance( player.WorldPosition ) > Reach * 1.5f ? (null, null) : (player, target);
	}

	/// <summary>Prend une poche d'un inconscient (SearchMenu) : ce qui tient dans ses propres poches.</summary>
	[Rpc.Host]
	public static void RequestTakeItem( Guid targetId, int slotIndex )
	{
		var (player, target) = SearchPair( targetId );
		if ( player is null || TakeSlot( player, target, slotIndex ) is not { } taken )
			return;
		player.Notify( $"Vous prenez {taken} sur {target.RpName}.", "info" );
		HorizonGame.Current?.AddLog( "fouille", player.SteamId, $"{player.RpName} prend {taken} sur {target.RpName} ({target.SteamId}) inconscient" );
		HorizonGame.Current?.SaveNow( player );
		HorizonGame.Current?.SaveNow( target );
	}

	/// <summary>Tout prendre : l'argent, puis chaque poche, tant qu'il y a de la place.</summary>
	[Rpc.Host]
	public static void RequestTakeAll( Guid targetId )
	{
		var (player, target) = SearchPair( targetId );
		if ( player is null )
			return;
		var taken = new List<string>();
		if ( target.Wallet > 0 )
		{
			taken.Add( Format.Money( target.Wallet ) );
			player.Wallet += target.Wallet;
			target.Wallet = 0;
		}
		foreach ( var slot in target.Inventory.OrderBy( x => x.Slot ).Select( x => x.Slot ).ToList() )
			if ( TakeSlot( player, target, slot ) is { } t )
				taken.Add( t );
		var left = target.Inventory.Count;
		if ( taken.Count == 0 )
		{
			player.Notify( left > 0 ? "Plus de place dans vos poches." : $"{target.RpName} n'a rien sur lui.", "info" );
			return;
		}
		player.Notify( $"Vous prenez sur {target.RpName} : {string.Join( ", ", taken )}." + (left > 0 ? " Le reste ne tient pas dans vos poches." : ""), "info" );
		HorizonGame.Current?.AddLog( "fouille", player.SteamId, $"{player.RpName} vide {target.RpName} ({target.SteamId}) inconscient : {string.Join( ", ", taken )}" );
		HorizonGame.Current?.SaveNow( player );
		HorizonGame.Current?.SaveNow( target );
	}

	/// <summary>Une poche passe d'un corps à l'autre (avec ses données : une arme garde sa série). Rend ce qui a été pris.</summary>
	static string TakeSlot( RoleplayPlayer player, RoleplayPlayer target, int slotIndex )
	{
		var slot = target.SlotAt( slotIndex );
		var item = slot?.Definition;
		if ( item is null )
			return null;
		if ( target.HeldKey == slot.Item )
			target.HeldKey = null;
		WeaponSystem.Current?.HolsterIfActive( target, slot );
		var count = player.AddItem( item, slot.Count, slot.Data );
		if ( count <= 0 )
		{
			player.Notify( $"Pas de place pour {item.Name}.", "erreur" );
			return null;
		}
		target.RemoveFrom( slot, count );
		return count > 1 ? $"{item.Name} ×{count}" : item.Name;
	}
}