Props/PropSystem.cs

Host-side prop system for a roleplay gamemode. Holds a prop catalogue (loaded from the game backend or local defaults), syncs it to clients as JSON, and validates RPC requests to place, pick up and remove placed prop objects. Also handles staff catalogue editing, returning props to inventories on disconnect, and cleaning up job service props.

Networking
🐞 When Backend.LoadProps fails (null response), version is never set, so OnUpdate's `version is null && !loading` check starts another LoadCatalog on the next frame and the host hammers the API with a failed request plus a warning every frame.
namespace HorizonRP;

/// <summary>
/// Les props façon FiveM : un prop est un objet d'inventaire (« prop:12 »). Le
/// joueur pose ce qu'il a en poche (menu P ou bouton Poser de l'inventaire) et le
/// ramasse pour le récupérer (Z ramasse le dernier). Le matériel de service d'un
/// métier se pose sans l'avoir sur soi et se range quand on quitte le métier. Le
/// staff fait apparaître tout le catalogue. Les magasins viendront plus tard : le
/// prix du catalogue sera leur prix de vente.
///
/// L'hôte tient le catalogue (lu dans l'API, relu quand sa version change) et le
/// publie en un texte JSON synchronisé ; toute pose et tout ramassage sont des
/// demandes que l'hôte vérifie : objet en poche, métier, limite, distance.
/// </summary>
[Title( "Horizon RP – Props" )]
[Icon( "chair" )]
public sealed class PropSystem : Component, Component.INetworkListener
{
	public static PropSystem Current { get; private set; }

	[Sync( SyncFlags.FromHost )] public string CatalogJson { get; set; }

	/// <summary>Hôte : le catalogue qui fait foi, et sa version côté API.</summary>
	List<PropEntry> catalog = new();
	string version;
	bool loading;
	int placedCount;

	/// <summary>Côté client : le catalogue lu dans CatalogJson.</summary>
	List<PropEntry> published = new();
	string parsedJson;

	protected override void OnAwake() => Current = this;

	protected override void OnDestroy()
	{
		if ( Current == this )
			Current = null;
	}

	/// <summary>Le catalogue, sur toutes les machines.</summary>
	public IReadOnlyList<PropEntry> Catalog
	{
		get
		{
			if ( Networking.IsHost )
				return catalog;

			if ( parsedJson != CatalogJson )
			{
				parsedJson = CatalogJson;
				published = string.IsNullOrEmpty( CatalogJson ) ? new() : Json.Deserialize<List<PropEntry>>( CatalogJson ) ?? new();
			}
			return published;
		}
	}

	public PropEntry Get( long id ) => Catalog.FirstOrDefault( x => x.Id == id );

	/// <summary>Les props d'un joueur, du plus récent au plus ancien.</summary>
	public static IEnumerable<PlacedProp> OwnedBy( ulong steamId ) => PlacedProp.All.Where( x => x.OwnerId == steamId );

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor || !Networking.IsHost || !Networking.IsActive )
			return;

		if ( version is null && !loading )
			_ = LoadCatalog();

		if ( sinceServiceCheck > 2f )
		{
			sinceServiceCheck = 0;
			PutAwayServiceProps();
		}
	}

	RealTimeSince sinceServiceCheck;

	// Catalogue (hôte)

	async Task LoadCatalog()
	{
		loading = true;
		try
		{
			if ( !Backend.IsOnline )
			{
				catalog = PropRules.Defaults();
				version = "local";
			}
			else
			{
				var response = await Backend.LoadProps();
				if ( response?.Props is null )
				{
					Log.Warning( "[Horizon] Catalogue des props indisponible (API injoignable)" );
					return;
				}
				catalog = response.Props;
				version = response.Version ?? "";
			}

			Publish();
			Log.Info( $"[Horizon] {catalog.Count} prop(s) au catalogue" );
		}
		finally
		{
			loading = false;
		}
	}

	void Publish() => CatalogJson = Json.Serialize( catalog );

	/// <summary>Hôte, à chaque échange avec l'API : un catalogue changé ailleurs (panneau web) est relu.</summary>
	public void CheckVersion( string apiVersion )
	{
		if ( !string.IsNullOrEmpty( apiVersion ) && version is not null && version != "local" && apiVersion != version && !loading )
			_ = LoadCatalog();
	}

	// Demandes des joueurs (hôte)

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

	/// <summary>
	/// Pose un prop. Un joueur pose ce qu'il a en poche (l'objet est consommé) ou
	/// le matériel de service de son métier ; le staff fait apparaître n'importe
	/// quel prop du catalogue, sans rien avoir sur lui.
	/// </summary>
	[Rpc.Host]
	public void RequestPlace( long entryId, Vector3 position, Rotation rotation )
	{
		var player = Caller();
		if ( player is null || !player.ProfileLoaded || player.NeedsCharacter )
			return;

		var entry = catalog.FirstOrDefault( x => x.Id == entryId );
		if ( entry is null )
		{
			player.Notify( "Ce prop n'existe plus au catalogue.", "erreur" );
			return;
		}

		var staff = player.IsStaff;
		var service = !staff && entry.IsServiceFor( player );
		InventorySlot slot = null;
		if ( !staff && !service )
		{
			slot = player.Inventory.Where( x => x.Item == PropItems.IdOf( entry.Id ) ).OrderByDescending( x => x.Slot ).FirstOrDefault();
			if ( slot is null )
			{
				player.Notify( $"Vous n'avez pas de {entry.Name} sur vous.", "erreur" );
				return;
			}
		}

		if ( !staff && OwnedBy( player.SteamId ).Count() >= PropRules.MaxPerPlayer )
		{
			player.Notify( $"Vous avez déjà {PropRules.MaxPerPlayer} props posés : ramassez-en un.", "erreur" );
			return;
		}

		if ( position.Distance( player.WorldPosition ) > PropRules.Reach + 100 )
		{
			player.Notify( "Trop loin pour poser ici.", "erreur" );
			return;
		}

		if ( slot is not null )
		{
			player.RemoveFrom( slot, 1 );
			// Le dernier posé : plus rien à porter (carton en main, VehicleKeySystem).
			var itemId = PropItems.IdOf( entry.Id );
			if ( player.HeldKey == itemId && player.Inventory.All( x => x.Item != itemId ) )
				player.HeldKey = null;
		}

		var go = new GameObject( true, $"Prop – {entry.Name} ({player.RpName})" );
		go.WorldPosition = position;
		go.WorldRotation = rotation;
		var prop = go.Components.Create<PlacedProp>();
		prop.OwnerId = player.SteamId;
		prop.OwnerName = player.RpName;
		prop.EntryId = entry.Id;
		prop.ModelSource = entry.Model;
		prop.Label = entry.Name;
		prop.FromItem = slot is not null;
		prop.Service = service;
		prop.Order = ++placedCount;
		go.NetworkSpawn();

		var how = staff ? "par le staff" : service ? "matériel de service" : "depuis l'inventaire";
		HorizonGame.Current?.AddLog( "prop", player.SteamId, $"{entry.Name} posé ({how})" );
	}

	/// <summary>
	/// Ramasse un prop : le sien, ou n'importe lequel pour le staff. Un prop venu
	/// des poches de son propriétaire y retourne.
	/// </summary>
	[Rpc.Host]
	public void RequestRemove( Guid propObjectId )
	{
		var player = Caller();
		var prop = Scene.Directory.FindByGuid( propObjectId )?.GetComponent<PlacedProp>();
		if ( player is null || !prop.IsValid() )
			return;

		if ( prop.OwnerId != player.SteamId && !player.IsStaff )
		{
			player.Notify( $"Ce prop appartient à {prop.OwnerName}.", "erreur" );
			return;
		}

		PickUp( prop, player );
	}

	/// <summary>Ramasse le dernier prop posé (touche Z, comme sur Garry's Mod).</summary>
	[Rpc.Host]
	public void RequestUndo()
	{
		var player = Caller();
		if ( player is null )
			return;

		var last = OwnedBy( player.SteamId ).OrderByDescending( x => x.Order ).FirstOrDefault();
		if ( last is null )
		{
			player.Notify( "Aucun prop à ramasser.", "info" );
			return;
		}

		PickUp( last, player );
	}

	[Rpc.Host]
	public void RequestRemoveAll()
	{
		var player = Caller();
		if ( player is null )
			return;

		var picked = 0;
		foreach ( var prop in OwnedBy( player.SteamId ).OrderByDescending( x => x.Order ).ToList() )
		{
			if ( !PickUp( prop, player, quiet: true ) )
				break;
			picked++;
		}

		if ( picked > 0 )
			player.Notify( $"{picked} prop(s) ramassé(s).", "info" );
	}

	/// <summary>
	/// Rend l'objet à son propriétaire s'il venait de ses poches, puis retire le
	/// prop. Le propriétaire qui n'a plus de place garde son prop par terre ; le
	/// staff le retire quand même, et l'objet est perdu si le propriétaire n'est
	/// pas là ou n'a plus de place.
	/// </summary>
	bool PickUp( PlacedProp prop, RoleplayPlayer by, bool quiet = false )
	{
		var label = prop.Label;
		var ownerId = prop.OwnerId;
		var ownerName = prop.OwnerName;
		var fromItem = prop.FromItem;
		var own = ownerId == by.SteamId;
		var owner = own ? by : RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == ownerId && x.ProfileLoaded );

		var returned = false;
		if ( fromItem )
		{
			var item = Items.Get( PropItems.IdOf( prop.EntryId ) );
			returned = owner is not null && item is not null && owner.AddItem( item, 1 ) == 1;
			if ( !returned && own )
			{
				by.Notify( $"Plus de place dans vos poches pour {label}.", "erreur" );
				return false;
			}
		}

		prop.GameObject.Destroy();

		if ( own )
		{
			if ( !quiet )
				by.Notify( returned ? $"{label} ramassé." : $"{label} rangé.", "info" );
			return true;
		}

		by.Notify( $"{label} de {ownerName} retiré{(fromItem && !returned ? " (objet perdu : propriétaire absent ou sans place)" : "")}.", "info" );
		if ( returned )
			owner.Notify( $"Le staff a ramassé votre {label} : il est dans votre inventaire.", "info" );
		HorizonGame.Current?.AddLog( "prop_retire", ownerId, $"{label} retiré par le staff ({by.RpName}){(returned ? ", rendu à l'inventaire" : "")}" );
		return true;
	}

	/// <summary>
	/// Hôte, avant la sauvegarde d'un joueur qui part (ou de tous à l'arrêt) : ses
	/// props venus des poches y retournent. Ceux qui n'y tiennent plus restent posés.
	/// </summary>
	public void ReturnToOwner( RoleplayPlayer player )
	{
		if ( !Networking.IsHost || !player.IsValid() || !player.ProfileLoaded )
			return;

		foreach ( var prop in OwnedBy( player.SteamId ).Where( x => x.FromItem ).ToList() )
		{
			var item = Items.Get( PropItems.IdOf( prop.EntryId ) );
			if ( item is not null && player.AddItem( item, 1 ) == 1 )
				prop.GameObject.Destroy();
		}
	}

	/// <summary>Hôte : le matériel de service d'un joueur qui a changé de métier est rangé.</summary>
	void PutAwayServiceProps()
	{
		foreach ( var prop in PlacedProp.All.Where( x => x.Service ).ToList() )
		{
			var owner = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == prop.OwnerId );
			var entry = Get( prop.EntryId );
			if ( owner is not null && entry is not null && entry.IsServiceFor( owner ) )
				continue;

			owner?.Notify( $"{prop.Label} rangé : ce matériel est réservé au service.", "info" );
			prop.GameObject.Destroy();
		}
	}

	// Catalogue réglé en jeu par le staff (menu d'administration)

	[Rpc.Host]
	public void StaffSaveEntry( long id, string model, string name, string category, int price, string jobs, bool staffOnly, bool active )
	{
		var staff = Caller();
		if ( staff is null || !staff.IsStaff )
			return;

		var entry = new PropEntry
		{
			Id = id, Model = (model ?? "").Trim(), Name = (name ?? "").Trim(), Category = string.IsNullOrWhiteSpace( category ) ? "Divers" : category.Trim(),
			Price = Math.Clamp( price, 0, 100000 ), Jobs = jobs ?? "", StaffOnly = staffOnly, Active = active,
		};

		if ( !ModelSources.LooksValid( entry.Model ) || entry.Name.Length == 0 )
		{
			staff.Notify( "Nom et modèle (.vmdl, mount://… ou org.paquet) obligatoires.", "erreur" );
			return;
		}

		_ = SaveEntry( staff, entry );
	}

	async Task SaveEntry( RoleplayPlayer staff, PropEntry entry )
	{
		if ( !Backend.IsOnline )
		{
			// Sans API : le changement vaut pour cette partie seulement.
			catalog.RemoveAll( x => x.Id == entry.Id );
			if ( entry.Id == 0 )
				entry.Id = catalog.Count == 0 ? 1 : catalog.Max( x => x.Id ) + 1;
			catalog.Add( entry );
			Publish();
			staff.Notify( $"{entry.Name} réglé (partie locale, non enregistré).", "info" );
			return;
		}

		var response = await Backend.SaveProp( entry, $"{staff.RpName} ({staff.SteamId})" );
		if ( response?.Ok != true )
		{
			staff.Notify( response?.Error ?? "API injoignable : catalogue non enregistré.", "erreur" );
			return;
		}

		staff.Notify( $"{response.Prop?.Name ?? entry.Name} enregistré au catalogue.", "info" );
		await LoadCatalog();
	}

	[Rpc.Host]
	public void StaffDeleteEntry( long id )
	{
		var staff = Caller();
		if ( staff is null || !staff.IsStaff )
			return;

		_ = DeleteEntry( staff, id );
	}

	async Task DeleteEntry( RoleplayPlayer staff, long id )
	{
		if ( Backend.IsOnline )
		{
			var response = await Backend.DeleteProp( id, $"{staff.RpName} ({staff.SteamId})" );
			if ( response?.Ok != true )
			{
				staff.Notify( response?.Error ?? "API injoignable.", "erreur" );
				return;
			}
			await LoadCatalog();
		}
		else
		{
			catalog.RemoveAll( x => x.Id == id );
			Publish();
		}

		staff.Notify( "Prop retiré du catalogue.", "info" );
	}

	// Connexions

	/// <summary>
	/// Le matériel de service et les props du staff partent avec leur propriétaire.
	/// Ceux venus des poches y sont déjà retournés (HorizonGame appelle
	/// <see cref="ReturnToOwner"/> avant de sauvegarder) ; ceux qui n'y tenaient
	/// plus restent posés jusqu'au redémarrage, et il pourra les ramasser.
	/// </summary>
	public void OnDisconnected( Connection channel )
	{
		if ( !Networking.IsHost )
			return;

		foreach ( var prop in OwnedBy( channel.SteamId ).Where( x => !x.FromItem ).ToList() )
			prop.GameObject.Destroy();
	}
}