Inventory/InventoryActions.cs

Host-side RPC handlers for the roleplay inventory. Validates the caller, then handles use, move/merge/swap, drop, give and pickup, reading item and quantity from the host's own inventory copy.

Networking
namespace HorizonRP;

/// <summary>
/// Ce que l'inventaire (touche I, cases rapides 1 à 5) demande à l'hôte. L'hôte
/// relit tout depuis son propre inventaire : le client ne désigne qu'un numéro
/// de case, jamais un objet ni une quantité qu'il n'aurait pas.
/// </summary>
public static class InventoryActions
{
	/// <summary>Distance pour donner un objet ou ramasser au sol (~2,5 m).</summary>
	public const float Reach = 100f;

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

	/// <summary>Menotté (Police/) : les mains dans le dos, on ne touche plus à ses poches.</summary>
	static RoleplayPlayer FreeCaller()
	{
		var player = Caller();
		if ( player is { Cuffed: true } )
		{
			player.Notify( "Vous êtes menotté.", "erreur" );
			return null;
		}
		return player;
	}

	/// <summary>Une arme de service (Police/) ne quitte pas l'agent : ni jetée, ni donnée.</summary>
	static bool RefuseService( RoleplayPlayer player, InventorySlot slot, string what )
	{
		if ( !PoliceService.IsServiceSlot( slot ) )
			return false;
		player.Notify( $"Arme de service : elle ne se {what} pas. Rendez-la au vestiaire.", "erreur" );
		return true;
	}

	[Rpc.Host]
	public static void RequestUse( int slotIndex )
	{
		var player = FreeCaller();
		var slot = player?.SlotAt( slotIndex );
		var item = slot?.Definition;
		if ( item is null )
			return;
		if ( player.GameObject.GetComponentInParent<Vehicle>() is { } car && car.Driver == player )
		{
			player.Notify( "Pas d'inventaire en conduisant.", "info" );
			return;
		}

		// Une clé de véhicule se prend en main ou se range.
		if ( VehicleKeys.PlateOf( item.Id ) is not null )
		{
			VehicleKeys.ToggleHold( player, slot );
			return;
		}

		// Un prop se prend en main (carton à deux mains, ou le vrai digicode, le vrai terminal) ;
		// en main, le clic gauche le pose (VehicleKeySystem.LocalInput).
		if ( PropItems.EntryOf( item.Id ) > 0 )
		{
			VehicleKeys.ToggleHold( player, slot );
			return;
		}

		// Une arme se sort ou se range ; elle ne se « consomme » pas.
		if ( WeaponItems.WeaponOf( item.Id ) is not null )
		{
			WeaponSystem.Current?.ToggleWeapon( player, slot );
			return;
		}

		if ( !item.Usable )
		{
			player.Notify( $"{item.Name} ne s'utilise pas.", "info" );
			return;
		}

		if ( item.Hunger > 0 && player.Hunger >= 99 && item.Health <= 0 )
		{
			player.Notify( "Vous n'avez plus faim.", "info" );
			return;
		}

		if ( item.Health > 0 && player.Health >= 100 && item.Hunger <= 0 )
		{
			player.Notify( "Vous êtes en pleine forme.", "info" );
			return;
		}

		player.Hunger = MathF.Min( 100, player.Hunger + item.Hunger );
		player.Health = MathF.Min( 100, player.Health + item.Health );
		player.RemoveFrom( slot, 1 );
		player.Notify( item.UseMessage ?? $"{item.Name} utilisé.", "info" );
	}

	/// <summary>Déplace une case : vers une case vide, sur la même pile (fusion) ou sur un autre objet (échange).</summary>
	[Rpc.Host]
	public static void RequestMove( int from, int to )
	{
		var player = FreeCaller();
		if ( player is null || from == to || to < 0 || to >= InventoryRules.Slots )
			return;

		var source = player.SlotAt( from );
		if ( source is null )
			return;

		var target = player.SlotAt( to );
		if ( target is null )
		{
			source.Slot = to;
		}
		else if ( target.Item == source.Item )
		{
			var max = source.Definition.MaxStack;
			var moved = Math.Min( source.Count, max - target.Count );
			target.Count += moved;
			source.Count -= moved;
			if ( source.Count <= 0 )
				player.Inventory.Remove( source );
		}
		else
		{
			(source.Slot, target.Slot) = (target.Slot, source.Slot);
		}

		player.PublishInventory();
	}

	[Rpc.Host]
	public static void RequestDrop( int slotIndex, int count )
	{
		var player = FreeCaller();
		var slot = player?.SlotAt( slotIndex );
		if ( slot is null || RefuseService( player, slot, "jette" ) )
			return;

		count = Math.Clamp( count, 1, slot.Count );
		var item = slot.Definition;
		var data = slot.Data;
		WeaponSystem.Current?.HolsterIfActive( player, slot );
		player.RemoveFrom( slot, count );

		// Devant les pieds, posé au sol.
		var start = player.WorldPosition + player.WorldRotation.Forward.WithZ( 0 ).Normal * 40 + Vector3.Up * 40;
		var ground = player.Scene.Trace.Ray( start, start + Vector3.Down * 200 ).IgnoreGameObjectHierarchy( player.GameObject ).Run();
		DroppedItem.Spawn( item, count, ground.Hit ? ground.EndPosition : player.WorldPosition, data );

		player.Notify( $"{item.Name} ×{count} jeté au sol.", "info" );
	}

	[Rpc.Host]
	public static void RequestGive( int slotIndex, int count, ulong targetSteamId )
	{
		var player = FreeCaller();
		var slot = player?.SlotAt( slotIndex );
		var target = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == targetSteamId && x.ProfileLoaded );
		if ( slot is null || target is null || target == player || RefuseService( player, slot, "donne" ) )
			return;

		if ( target.WorldPosition.Distance( player.WorldPosition ) > Reach * 1.5f )
		{
			player.Notify( $"Approchez-vous de {target.RpName}.", "erreur" );
			return;
		}

		var item = slot.Definition;
		count = Math.Clamp( count, 1, slot.Count );
		WeaponSystem.Current?.HolsterIfActive( player, slot );
		var given = target.AddItem( item, count, slot.Data );
		if ( given == 0 )
		{
			player.Notify( $"{target.RpName} n'a plus de place.", "erreur" );
			return;
		}

		player.RemoveFrom( slot, given );
		player.Notify( $"Vous donnez {item.Name} ×{given} à {target.RpName}.", "info" );
		target.Notify( $"{player.RpName} vous donne {item.Name} ×{given}.", "info" );
		HorizonGame.Current?.AddLog( "objet_donne", player.SteamId, $"{item.Name} ×{given} à {target.RpName} ({target.SteamId})" );
	}

	[Rpc.Host]
	public static void RequestPickup( Guid droppedId )
	{
		var player = FreeCaller();
		var dropped = DroppedItem.All.FirstOrDefault( x => x.GameObject.Id == droppedId );
		if ( player is null || dropped is null || dropped.Definition is null )
			return;

		if ( dropped.WorldPosition.Distance( player.WorldPosition ) > Reach * 2 )
		{
			player.Notify( "Trop loin.", "erreur" );
			return;
		}

		var taken = player.AddItem( dropped.Definition, dropped.Count, dropped.Data );
		if ( taken == 0 )
		{
			player.Notify( "Inventaire plein ou trop lourd.", "erreur" );
			return;
		}

		player.Notify( $"Ramassé : {dropped.Definition.Name} ×{taken}", "info" );
		dropped.Count -= taken;
		if ( dropped.Count <= 0 )
			dropped.GameObject.Destroy();
	}
}