Vehicles/Vehicle.Trunk.cs

Partial Vehicle class that adds a five-slot trunk inventory, synced from the host as JSON. Host RPCs handle moving items between a player's pockets and the trunk with weight and stack checks, plus a staff take/discard path, trunk open/close sounds and proximity checks for standing behind the car.

Networking
namespace HorizonRP;

/// <summary>
/// Le coffre : cinq emplacements, comme les poches. On l'ouvre par E derrière la voiture,
/// collé au pare-chocs, véhicule déverrouillé. L'hôte seul y écrit (<see cref="TrunkJson"/>) ;
/// le menu (VehicleMenu) ne fait que des demandes. Un véhicule acheté garde son coffre entre
/// deux sorties (enregistré avec ses dégâts) ; celui d'un véhicule donné par le staff disparaît
/// avec lui.
/// </summary>
public sealed partial class Vehicle
{
	public const int TrunkSize = 5;

	/// <summary>Ce que porte le coffre (kg), d'après la fiche.</summary>
	public float TrunkCapacity => Info?.TrunkKg ?? 80f;

	/// <summary>Le poids de ce qui est dans le coffre (kg).</summary>
	public float TrunkWeight => InventoryRules.WeightOf( TrunkSlots );

	/// <summary>Distance à plat entre les pieds du joueur et le milieu du pare-chocs arrière.</summary>
	public const float TrunkReach = 44f;

	[Sync( SyncFlags.FromHost )] public string TrunkJson { get; set; } = "[]";

	string parsedTrunk;
	List<InventorySlot> trunkSlots = new();

	/// <summary>Le contenu du coffre (relu quand TrunkJson change).</summary>
	public List<InventorySlot> TrunkSlots
	{
		get
		{
			if ( TrunkJson != parsedTrunk )
			{
				parsedTrunk = TrunkJson;
				try { trunkSlots = Json.Deserialize<List<InventorySlot>>( TrunkJson ?? "[]" ) ?? new(); }
				catch { trunkSlots = new(); }
			}
			return trunkSlots;
		}
	}

	/// <summary>Le milieu du pare-chocs arrière, au sol, en coordonnées du monde.</summary>
	public Vector3 TrunkPoint
	{
		get
		{
			var bb = Rig?.BodyBounds ?? new BBox( -Vector3.One * 80, Vector3.One * 80 );
			var yaw = Rotation.Identity;   // déjà dans le gréement
			return WorldTransform.PointToWorld( yaw * new Vector3( bb.Mins.x, bb.Center.y, bb.Mins.z ) );
		}
	}

	const string TrunkOpenSound = "sounds/vehicules/trunk_opening.mp3";
	const string TrunkCloseSound = "sounds/vehicules/trunk_close.mp3";

	/// <summary>Partout : le coffre qu'on ouvre ou qu'on referme (sons fournis par Julien).</summary>
	[Rpc.Broadcast]
	public void PlayTrunkSound( bool open )
	{
		if ( Application.IsDedicatedServer )
			return;
		Sounds.PlayFileAt( open ? TrunkOpenSound : TrunkCloseSound, TrunkPoint, 0.4f );
	}

	/// <summary>Le joueur est-il derrière la voiture, à toucher le coffre ?</summary>
	public bool AtTrunk( GameObject who ) =>
		Ready && who.IsValid() && who.GetComponentInParent<Vehicle>() != this
		&& who.WorldPosition.WithZ( 0 ).Distance( TrunkPoint.WithZ( 0 ) ) <= TrunkReach;

	/// <summary>Hôte : publie le coffre (et le fait enregistrer).</summary>
	void PublishTrunk( List<InventorySlot> slots )
	{
		TrunkJson = Json.Serialize( slots.OrderBy( x => x.Slot ).ToList() );
		RequestSaveState();
	}

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

	/// <summary>Hôte : le joueur peut-il se servir du coffre maintenant ?</summary>
	bool CanUseTrunk( RoleplayPlayer player )
	{
		if ( player is null || !AtTrunk( player.GameObject ) )
			return false;
		if ( Locked )
		{
			player.Notify( "Le véhicule est verrouillé.", "erreur" );
			return false;
		}
		return true;
	}

	/// <summary>Une poche du joueur vers le coffre (toute la pile, sur la même pile si elle y est déjà).</summary>
	[Rpc.Host]
	public void RequestTrunkPut( int pocket )
	{
		var player = CallerPlayer();
		if ( !CanUseTrunk( player ) )
			return;
		var slot = player.SlotAt( pocket );
		var item = slot?.Definition;
		if ( item is null )
			return;

		// Le poids : autant d'unités que le coffre en porte encore (une pile peut n'entrer qu'en partie).
		var room = TrunkCapacity - TrunkWeight;
		var fits = item.Weight <= 0 ? slot.Count : (int)MathF.Floor( (room + 0.0001f) / item.Weight );
		if ( fits <= 0 )
		{
			player.Notify( $"Le coffre est plein : {Format.Kilos( TrunkWeight )} sur {Format.Kilos( TrunkCapacity )}.", "erreur" );
			return;
		}
		var wanted = Math.Min( slot.Count, fits );

		var slots = TrunkSlots.Select( Copy ).ToList();
		var same = slot.Data is null ? slots.FirstOrDefault( x => x.Item == slot.Item && x.Data is null && x.Count < item.MaxStack ) : null;
		int moved;
		if ( same is not null )
		{
			moved = Math.Min( wanted, item.MaxStack - same.Count );
			same.Count += moved;
		}
		else
		{
			var free = Enumerable.Range( 0, TrunkSize ).Where( i => slots.All( x => x.Slot != i ) ).Select( i => (int?)i ).FirstOrDefault();
			if ( free is null )
			{
				player.Notify( "Le coffre est plein.", "erreur" );
				return;
			}
			moved = wanted;
			slots.Add( new InventorySlot { Slot = free.Value, Item = slot.Item, Count = moved, Data = slot.Data } );
		}
		if ( moved < slot.Count )
			player.Notify( $"Le coffre ne prend que {moved} sur {slot.Count} : il est presque plein.", "info" );

		WeaponSystem.Current?.HolsterIfActive( player, slot );
		player.RemoveFrom( slot, moved );
		PublishTrunk( slots );
		HorizonGame.Current?.AddLog( "coffre", player.SteamId, $"Dépose {item.Name} ×{moved} dans {Plate}" );
	}

	/// <summary>Un emplacement du coffre vers les poches du joueur.</summary>
	[Rpc.Host]
	public void RequestTrunkTake( int trunkSlot )
	{
		var player = CallerPlayer();
		if ( !CanUseTrunk( player ) )
			return;
		var slots = TrunkSlots.Select( Copy ).ToList();
		var slot = slots.FirstOrDefault( x => x.Slot == trunkSlot );
		var item = slot?.Definition;
		if ( item is null )
			return;

		var taken = player.AddItem( item, slot.Count, slot.Data );
		if ( taken == 0 )
		{
			player.Notify( "Plus de place dans vos poches.", "erreur" );
			return;
		}
		slot.Count -= taken;
		if ( slot.Count <= 0 )
			slots.Remove( slot );
		PublishTrunk( slots );
		HorizonGame.Current?.AddLog( "coffre", player.SteamId, $"Prend {item.Name} ×{taken} dans {Plate}" );
	}

	/// <summary>
	/// Hôte, pour le staff (menu en jeu, panneau web) : un emplacement du coffre pris dans les
	/// poches de qui le demande (« prendre »), ou supprimé (« jeter »). Journalisé.
	/// </summary>
	public (bool, string) StaffTrunk( int trunkSlot, string action, string by, RoleplayPlayer taker = null )
	{
		var slots = TrunkSlots.Select( Copy ).ToList();
		var slot = slots.FirstOrDefault( x => x.Slot == trunkSlot );
		var item = slot?.Definition;
		if ( item is null )
			return (false, "Cet emplacement du coffre est vide");

		var count = slot.Count;
		if ( action == "prendre" )
		{
			if ( taker is null )
				return (false, "Prendre se fait depuis le jeu");
			count = taker.AddItem( item, slot.Count, slot.Data );
			if ( count == 0 )
				return (false, "Plus de place dans vos poches");
		}
		else if ( action != "jeter" )
			return (false, $"Action inconnue : {action}");

		slot.Count -= count;
		if ( slot.Count <= 0 )
			slots.Remove( slot );
		PublishTrunk( slots );
		var what = action == "prendre" ? "pris" : "supprimés";
		HorizonGame.Current?.AddLog( "coffre", OwnerId, $"{by} : {item.Name} ×{count} {what} du coffre de {Plate}" );
		return (true, $"{item.Name} ×{count} {what} du coffre de {Plate}");
	}

	static InventorySlot Copy( InventorySlot x ) => new() { Slot = x.Slot, Item = x.Item, Count = x.Count, Data = x.Data };
}