Doors/DoorSystem.cs

Server-authoritative roleplay door system. Scans the map's Engine Door components, tracks ownership, lock, co-owners, price and allowed jobs per door keyed by rounded world position, syncs the whole state to clients as one JSON string, and handles buy/sell/lock/co-owner/free-staff RPCs plus persistence through the backend API or a local portes.json file.

NetworkingFile AccessHttp Calls
using Sandbox.Mapping;

namespace HorizonRP;

/// <summary>
/// Les portes RP : achat, verrou, copropriétaires, portes de métier, réglage
/// par le staff. S'appuie sur le composant Door du moteur (portes de la carte),
/// dont il pilote le verrou (Door.IsLocked bloque déjà l'ouverture).
///
/// L'hôte tient l'état et le publie en un seul texte JSON synchronisé
/// (<see cref="StatesJson"/>) : quelques centaines de portes tiennent en
/// quelques dizaines de ko, renvoyés seulement quand quelque chose change.
///
/// Une porte est reconnue par sa position arrondie au moment où on la voit pour
/// la première fois, fermée : la même sur toutes les machines et d'un démarrage
/// à l'autre, tant que la carte ne change pas. (Une porte coulissante vue pour
/// la première fois ouverte serait mal reconnue : cas rare, à surveiller.)
/// </summary>
[Title( "Horizon RP – Portes" )]
[Icon( "door_front" )]
public sealed class DoorSystem : Component, Component.INetworkListener
{
	public static DoorSystem Current { get; private set; }

	public const int DefaultPrice = 100;

	/// <summary>Distance maximale pour agir sur une porte (unités s&box, ~3,8 m).</summary>
	public const float Reach = 150f;

	public const int MaxDoorsPerPlayer = 8;
	public const int MaxCoOwners = 5;

	const string LocalFile = "portes.json";

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

	/// <summary>Côté hôte : l'état qui fait foi.</summary>
	readonly Dictionary<string, DoorState> states = new();

	/// <summary>Sur toutes les machines : la porte qui porte chaque clé, et l'inverse.</summary>
	readonly Dictionary<string, Door> doors = new();
	readonly Dictionary<Door, string> keys = new();

	/// <summary>Côté client : l'état lu dans StatesJson.</summary>
	Dictionary<string, DoorState> published = new();
	string parsedJson;

	RealTimeSince sinceScan = 10;
	bool configLoaded;
	bool loggedCount;

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

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

	// Recensement des portes

	public static string ComputeKey( Door door )
	{
		var p = door.WorldPosition;
		return $"{p.x.FloorToInt()},{p.y.FloorToInt()},{p.z.FloorToInt()}";
	}

	public string KeyOf( Door door )
	{
		if ( !keys.TryGetValue( door, out var key ) )
		{
			key = ComputeKey( door );
			keys[door] = key;
			doors[key] = door;
		}
		return key;
	}

	void Scan()
	{
		foreach ( var door in Scene.GetAllComponents<Door>() )
		{
			var key = KeyOf( door );
			if ( Networking.IsHost && !states.ContainsKey( key ) )
				states[key] = new DoorState { Key = key };
		}
	}

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor )
			return;

		if ( sinceScan > 5 )
		{
			sinceScan = 0;
			Scan();

			if ( Networking.IsHost && !loggedCount && doors.Count > 0 )
			{
				loggedCount = true;
				Log.Info( $"[Horizon] {doors.Count} porte(s) trouvée(s) sur la carte" );
				Publish();
			}
		}

		if ( Networking.IsHost && Networking.IsActive && !configLoaded )
		{
			configLoaded = true;
			_ = LoadConfig();
		}
	}

	async Task LoadConfig()
	{
		List<Backend.DoorConfig> configs;

		if ( Backend.IsOnline )
		{
			var response = await Backend.LoadDoors();
			configs = response?.Doors;
			if ( configs is null )
			{
				Log.Warning( "[Horizon] Réglage des portes indisponible (API injoignable) : prix par défaut" );
				return;
			}
		}
		else
		{
			configs = FileSystem.Data.ReadJsonOrDefault<List<Backend.DoorConfig>>( LocalFile ) ?? new();
		}

		// Un réglage peut arriver avant que la porte n'ait été vue : il attend.
		foreach ( var config in configs )
		{
			if ( !states.TryGetValue( config.Key, out var state ) )
				states[config.Key] = state = new DoorState { Key = config.Key };
			state.ApplyConfig( config );
		}

		Publish();
	}

	/// <summary>Hôte : renvoie l'état à tout le monde et applique les verrous.</summary>
	void Publish()
	{
		foreach ( var (key, state) in states )
		{
			if ( doors.TryGetValue( key, out var door ) && door.IsValid() )
				door.IsLocked = state.Locked;
		}

		// Seules les portes réellement présentes partent : un réglage orphelin
		// (carte modifiée) n'intéresse pas les joueurs.
		StatesJson = Json.Serialize( states.Values.Where( x => doors.ContainsKey( x.Key ) ).ToList() );
	}

	// Lecture, sur toutes les machines

	public DoorState StateOf( string key )
	{
		if ( Networking.IsHost )
			return states.GetValueOrDefault( key );

		if ( parsedJson != StatesJson )
		{
			parsedJson = StatesJson;
			var list = string.IsNullOrEmpty( StatesJson ) ? null : Json.Deserialize<List<DoorState>>( StatesJson );
			var previous = published;
			published = list?.ToDictionary( x => x.Key ) ?? new();

			// Le déclic de la serrure, là où une porte vient d'être verrouillée ou ouverte à clé.
			foreach ( var (k, state) in published )
			{
				if ( previous.TryGetValue( k, out var before ) && before.Locked != state.Locked && doors.TryGetValue( k, out var d ) && d.IsValid() )
					Sounds.PlayAt( "porte", d.WorldPosition, 0.8f );
			}
		}

		return published.GetValueOrDefault( key );
	}

	/// <summary>La porte que le joueur local regarde, à portée, ou null.</summary>
	public (Door door, string key) LookedAt()
	{
		var camera = Scene.Camera;
		var me = RoleplayPlayer.Local;
		if ( camera is null || me is null )
			return (null, null);

		var start = camera.WorldPosition;
		var result = Scene.Trace.Ray( start, start + camera.WorldRotation.Forward * 400 )
			.IgnoreGameObjectHierarchy( me.GameObject )
			.Run();

		var door = result.GameObject?.GetComponentInParent<Door>( true );
		if ( door is null || me.WorldPosition.Distance( door.WorldPosition ) > Reach * 1.5f )
			return (null, null);

		return (door, KeyOf( door ));
	}

	// Connexions

	public void OnDisconnected( Connection channel )
	{
		ulong steamId = channel.SteamId;
		var changed = false;

		foreach ( var state in states.Values )
		{
			if ( state.OwnerId == steamId )
			{
				state.Release();
				changed = true;
			}
			else if ( state.CoOwners.Remove( steamId ) )
			{
				changed = true;
			}
		}

		if ( changed )
			Publish();
	}

	// Demandes des joueurs (sur l'hôte)

	/// <summary>Le joueur qui appelle, s'il est assez près de la porte.</summary>
	bool Resolve( string key, out RoleplayPlayer player, out DoorState state )
	{
		player = RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id );
		state = key is null ? null : states.GetValueOrDefault( key );

		if ( player is null || state is null || !doors.TryGetValue( key, out var door ) || !door.IsValid() )
			return false;

		if ( player.WorldPosition.Distance( door.WorldPosition ) > Reach * 1.5f )
		{
			player.Notify( "Approchez-vous de la porte.", "erreur" );
			return false;
		}

		return true;
	}

	[Rpc.Host]
	public void RequestBuy( string key )
	{
		if ( !Resolve( key, out var player, out var state ) || !player.ProfileLoaded )
			return;

		if ( !state.CanBuy )
		{
			player.Notify( state.IsOwned ? $"Cette porte appartient à {state.OwnerName}." : "Cette porte ne s'achète pas.", "erreur" );
			return;
		}

		if ( states.Values.Count( x => x.OwnerId == player.SteamId ) >= MaxDoorsPerPlayer )
		{
			player.Notify( $"Vous possédez déjà {MaxDoorsPerPlayer} portes.", "erreur" );
			return;
		}

		if ( player.Wallet < state.Price )
		{
			player.Notify( $"Il vous manque {Format.Money( state.Price - player.Wallet )}.", "erreur" );
			return;
		}

		player.Wallet -= state.Price;
		state.OwnerId = player.SteamId;
		state.OwnerName = player.RpName;
		state.Locked = false;
		Publish();

		player.Notify( $"{state.DisplayTitle} achetée : -{Format.Money( state.Price )}", "argent" );
		HorizonGame.Current?.AddLog( "porte", player.SteamId, $"Achat de {state.DisplayTitle} ({key}) pour {Format.Money( state.Price )}" );
	}

	[Rpc.Host]
	public void RequestSell( string key )
	{
		if ( !Resolve( key, out var player, out var state ) )
			return;

		if ( state.OwnerId != player.SteamId )
		{
			player.Notify( "Seul le propriétaire peut vendre.", "erreur" );
			return;
		}

		var refund = state.Price / 2;
		state.Release();
		player.Wallet += refund;
		Publish();

		player.Notify( $"{state.DisplayTitle} vendue : +{Format.Money( refund )}", "argent" );
		HorizonGame.Current?.AddLog( "porte", player.SteamId, $"Vente de {state.DisplayTitle} ({key}), {Format.Money( refund )} rendus" );
	}

	[Rpc.Host]
	public void RequestSetLocked( string key, bool locked )
	{
		if ( !Resolve( key, out var player, out var state ) )
			return;

		if ( !state.CanUse( player ) && !player.IsStaff )
		{
			player.Notify( "Vous n'avez pas la clé de cette porte.", "erreur" );
			return;
		}

		state.Locked = locked;
		if ( locked && doors.TryGetValue( key, out var door ) && door.IsValid() )
			door.Close();

		Publish();
		player.Notify( locked ? "Porte verrouillée." : "Porte déverrouillée.", "info" );
	}

	[Rpc.Host]
	public void RequestAddCoOwner( string key, ulong steamId )
	{
		if ( !Resolve( key, out var player, out var state ) )
			return;

		var target = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steamId );
		if ( state.OwnerId != player.SteamId || target is null || target == player )
			return;

		if ( state.CoOwners.Count >= MaxCoOwners )
		{
			player.Notify( $"{MaxCoOwners} copropriétaires au maximum.", "erreur" );
			return;
		}

		if ( !state.CoOwners.Contains( steamId ) )
			state.CoOwners.Add( steamId );

		Publish();
		player.Notify( $"{target.RpName} a maintenant la clé.", "info" );
		target.Notify( $"{player.RpName} vous a donné la clé de {state.DisplayTitle}.", "info" );
	}

	[Rpc.Host]
	public void RequestRemoveCoOwner( string key, ulong steamId )
	{
		if ( !Resolve( key, out var player, out var state ) || state.OwnerId != player.SteamId )
			return;

		if ( state.CoOwners.Remove( steamId ) )
		{
			Publish();
			player.Notify( "Clé retirée.", "info" );
		}
	}

	// Réglage par le staff

	[Rpc.Host]
	public void StaffConfigure( string key, string title, int price, bool buyable, string jobs )
	{
		if ( !Resolve( key, out var player, out var state ) )
			return;

		if ( !player.IsStaff )
		{
			Log.Warning( $"[Horizon] Réglage de porte refusé à {player.RpName} ({player.SteamId})" );
			return;
		}

		state.Title = (title ?? "").Trim();
		if ( state.Title.Length > 80 )
			state.Title = state.Title[..80];
		state.Price = Math.Clamp( price, 0, 1000000 );
		state.Buyable = buyable;
		state.Jobs = string.Join( ",", (jobs ?? "").Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ).Where( Jobs.Exists ).Distinct() );

		// Une porte devenue porte de métier ou non achetable perd son propriétaire.
		if ( state.IsOwned && (state.IsJobDoor || !state.Buyable) )
			state.Release();

		Publish();
		_ = SaveConfig( player, state );
	}

	async Task SaveConfig( RoleplayPlayer staff, DoorState state )
	{
		if ( Backend.IsOnline )
		{
			var response = await Backend.SaveDoor( state.ToConfig(), $"{staff.RpName} ({staff.SteamId})" );
			staff.Notify( response?.Ok == true ? $"{state.DisplayTitle} réglée." : "Porte réglée en jeu, mais non enregistrée (API injoignable).", response?.Ok == true ? "info" : "erreur" );
			return;
		}

		var configs = FileSystem.Data.ReadJsonOrDefault<List<Backend.DoorConfig>>( LocalFile ) ?? new();
		configs.RemoveAll( x => x.Key == state.Key );
		configs.Add( state.ToConfig() );
		FileSystem.Data.WriteJson( LocalFile, configs );
		staff.Notify( $"{state.DisplayTitle} réglée.", "info" );
	}

	[Rpc.Host]
	public void StaffRelease( string key )
	{
		if ( !Resolve( key, out var player, out var state ) || !player.IsStaff )
			return;

		var owner = state.OwnerName;
		state.Release();
		Publish();
		player.Notify( owner is null ? "La porte était libre." : $"Porte retirée à {owner}.", "info" );
	}

	// Administration : panneau web (ordres) et menu F6, sans condition de distance

	/// <summary>Toutes les portes vues sur cette machine, avec leur clé.</summary>
	public IEnumerable<(string key, Door door)> Known =>
		doors.Where( x => x.Value.IsValid() ).Select( x => (x.Key, x.Value) );

	/// <summary>Hôte : l'état de toutes les portes pour le panneau.</summary>
	public List<Backend.DoorSnapshot> Snapshot() => states.Values
		.Where( x => doors.ContainsKey( x.Key ) )
		.OrderBy( x => x.Key )
		.Select( x => new Backend.DoorSnapshot
		{
			Key = x.Key,
			Title = x.Title,
			Price = x.Price,
			Buyable = x.Buyable,
			Jobs = x.Jobs,
			OwnerId = x.OwnerId,
			OwnerName = x.OwnerName,
			CoOwners = x.CoOwners.Count,
			Locked = x.Locked,
		} ).ToList();

	/// <summary>Hôte : un réglage déjà enregistré en base par le panneau, appliqué en jeu.</summary>
	public string ApplyStaffConfig( Backend.DoorConfig config )
	{
		if ( config?.Key is null || !states.TryGetValue( config.Key, out var state ) )
			return "Porte introuvable sur la carte";

		state.ApplyConfig( config );
		if ( state.IsOwned && (state.IsJobDoor || !state.Buyable) )
			state.Release();

		Publish();
		return null;
	}

	/// <summary>Hôte : reprend la porte à son propriétaire. Rend l'ancien propriétaire, ou null.</summary>
	public string ReleaseByStaff( string key, out bool found )
	{
		found = states.TryGetValue( key ?? "", out var state );
		if ( !found )
			return null;

		var owner = state.OwnerName;
		state.Release();
		Publish();
		return owner;
	}

	/// <summary>Hôte : verrouille ou déverrouille, sans clé ni distance.</summary>
	public bool LockByStaff( string key, bool locked )
	{
		if ( !states.TryGetValue( key ?? "", out var state ) )
			return false;

		state.Locked = locked;
		if ( locked && doors.TryGetValue( key, out var door ) && door.IsValid() )
			door.Close();

		Publish();
		return true;
	}
}