ServerSettings.cs

Static settings store for the HorizonRP gamemode. Holds server tuning values received from the backend API as a key/value map, exposes typed accessors for death, hunger, payday, vehicle and world settings, parses whitelist and police/EMS point data from JSON, and resolves zero-height points onto the map with a scene trace.

Networking
namespace HorizonRP;

/// <summary>
/// Les réglages du serveur, tels que l'API les envoie à chaque échange (hz_settings, SETTINGS
/// d'api.php) : mort et réanimation, points de l'hôpital, faim, paie, véhicules. Sans API
/// (partie locale), les valeurs par défaut — les mêmes que dans SETTINGS.
///
/// <see cref="Meta"/> reprend la liste d'api.php pour l'onglet Réglages du menu admin en jeu :
/// la changer dans les deux. L'API revérifie de toute façon types et bornes.
/// </summary>
public static class ServerSettings
{
	public sealed record Point( float X, float Y, float Z, float Yaw )
	{
		public Vector3 Position => new( X, Y, Z );
		public Rotation Rotation => Rotation.FromYaw( Yaw );
	}

	public sealed record Setting( string Key, string Label, string Group, string Type, float Default, float Min, float Max );

	// Une propriété, pas un champ : un tableau statique garde son ancienne valeur après un
	// rechargement à chaud dans l'éditeur (son initialiseur n'est pas rejoué).
	public static Setting[] Meta => new Setting[]
	{
		// « steamids » : une liste de { steamId, name }, éditée à part dans l'onglet Réglages (RequestWhitelist).
		new( "liste_blanche_active", "Liste blanche : seuls les joueurs listés entrent", "Accès au serveur", "bool", 0, 0, 1 ),
		new( "liste_blanche", "Joueurs autorisés", "Accès au serveur", "steamids", 0, 0, 0 ),
		new( "mort_delai", "Temps inconscient avant la mort (s)", "Mort et réanimation", "int", 300, 30, 1800 ),
		new( "mort_reanimation", "Durée d'une réanimation par un EMS (s)", "Mort et réanimation", "int", 6, 1, 30 ),
		new( "mort_perd_inventaire", "Mort définitive : perdre ses affaires", "Mort et réanimation", "bool", 1, 0, 1 ),
		new( "mort_perd_argent", "Mort définitive : perdre l'argent sur soi", "Mort et réanimation", "bool", 1, 0, 1 ),
		new( "mort_staff_reanime", "Le staff peut réanimer (en plus des EMS)", "Mort et réanimation", "bool", 1, 0, 1 ),
		new( "faim_par_minute", "Faim perdue par minute", "Faim", "float", 5, 0, 50 ),
		new( "faim_degats_minute", "Vie perdue par minute le ventre vide", "Faim", "float", 10, 0, 100 ),
		new( "paie_intervalle", "Intervalle de la paie (s)", "Économie", "int", 300, 60, 3600 ),
		new( "vehicule_feu_secondes", "Avant qu'un véhicule en feu explose (s)", "Véhicules", "int", 60, 5, 600 ),
		new( "jour_duree_minutes", "Durée d'une journée en jeu (minutes réelles)", "Monde", "int", 48, 10, 1440 ),
		new( "heure_decalage", "Décalage de l'horloge du jeu (minutes)", "Monde", "int", 0, 0, 1439 ),
	};

	static readonly Dictionary<string, string> values = new();

	/// <summary>Change à chaque nouvelle valeur reçue : l'onglet Réglages se redessine.</summary>
	public static int Version { get; private set; }

	public static float Number( string key ) =>
		values.TryGetValue( key, out var v ) && float.TryParse( v, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var f )
			? f : Meta.FirstOrDefault( x => x.Key == key )?.Default ?? 0;

	public static bool Flag( string key ) => Number( key ) >= 0.5f;

	public static float DownSeconds => Number( "mort_delai" );
	public static float ReviveSeconds => Number( "mort_reanimation" );
	public static bool LoseInventory => Flag( "mort_perd_inventaire" );
	public static bool LoseCash => Flag( "mort_perd_argent" );
	public static bool StaffRevives => Flag( "mort_staff_reanime" );
	public static float HungerPerMinute => Number( "faim_par_minute" );
	public static float StarvingDamagePerMinute => Number( "faim_degats_minute" );
	public static int PaydayInterval => (int)Number( "paie_intervalle" );
	public static float VehicleBurnSeconds => Number( "vehicule_feu_secondes" );

	/// <summary>Au moins un échange avec l'API a apporté les réglages (la liste blanche en dépend).</summary>
	public static bool Received { get; private set; }

	/// <summary>Liste blanche : seuls les SteamID listés entrent (HorizonGame.AcceptConnection).</summary>
	public static bool WhitelistOn => Flag( "liste_blanche_active" );

	public static bool IsWhitelisted( ulong steamId )
	{
		var json = Raw( "liste_blanche" );
		if ( string.IsNullOrWhiteSpace( json ) )
			return false;
		try
		{
			using var doc = System.Text.Json.JsonDocument.Parse( json );
			foreach ( var e in doc.RootElement.EnumerateArray() )
				if ( e.TryGetProperty( "steamId", out var id ) && id.GetString() == steamId.ToString() )
					return true;
		}
		catch { }
		return false;
	}

	public sealed record WhitelistEntry( string SteamId, string Name );

	/// <summary>La liste blanche telle que l'API l'envoie (une copie : la modifier ne change rien).</summary>
	public static List<WhitelistEntry> Whitelist
	{
		get
		{
			var list = new List<WhitelistEntry>();
			var json = Raw( "liste_blanche" );
			if ( string.IsNullOrWhiteSpace( json ) )
				return list;
			try
			{
				using var doc = System.Text.Json.JsonDocument.Parse( json );
				if ( doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array )
					return list;
				foreach ( var e in doc.RootElement.EnumerateArray() )
				{
					if ( e.ValueKind != System.Text.Json.JsonValueKind.Object || !e.TryGetProperty( "steamId", out var id ) || id.ValueKind != System.Text.Json.JsonValueKind.String )
						continue;
					var name = e.TryGetProperty( "name", out var n ) && n.ValueKind == System.Text.Json.JsonValueKind.String ? n.GetString() : "";
					list.Add( new WhitelistEntry( id.GetString(), name ) );
				}
			}
			catch { }
			return list;
		}
	}

	/// <summary>Le réveil après une réanimation (dans l'hôpital), ou null.</summary>
	public static Point WakePoint => ParsePoints( Raw( "point_reveil" ) ).FirstOrDefault();

	/// <summary>Les points de réapparition après une mort (devant l'hôpital).</summary>
	public static List<Point> RespawnPoints => ParsePoints( Raw( "points_reapparition" ) );

	// Les points du commissariat (PoliceRules.Point*), posés en jeu par le staff (menu police F6,
	// « Points du commissariat ») : hz_settings, un point chacun, plusieurs pour les sorties du garage.

	/// <summary>Un point du commissariat (clé PoliceRules.Point*), ou null s'il n'est pas posé.</summary>
	public static Point PolicePoint( string key ) => ParsePoints( Raw( key ) ).FirstOrDefault();

	/// <summary>Tous les points d'une clé (les sorties du garage de la police).</summary>
	public static List<Point> PolicePointList( string key ) => ParsePoints( Raw( key ) );

	/// <summary>Le vestiaire : prise de service, tenue, armurerie.</summary>
	public static Point PoliceLocker => PolicePoint( PoliceRules.PointLocker );

	/// <summary>Le menu du garage de la police.</summary>
	public static Point PoliceGarage => PolicePoint( PoliceRules.PointGarage );

	/// <summary>Où apparaissent les véhicules de service.</summary>
	public static List<Point> PoliceGarageSpawns => PolicePointList( PoliceRules.PointsGarageSpawn );

	/// <summary>Où l'on écroue (près des cellules).</summary>
	public static Point PoliceBooking => PolicePoint( PoliceRules.PointBooking );

	/// <summary>Dans la prison : là où arrive un détenu, et le centre de l'enceinte (PoliceRules.JailRadius).</summary>
	public static Point PolicePrison => PolicePoint( PoliceRules.PointJail );

	/// <summary>La sortie de prison, peine purgée.</summary>
	public static Point PoliceRelease => PolicePoint( PoliceRules.PointRelease );

	/// <summary>L'accueil du commissariat : payer ses amendes.</summary>
	public static Point PoliceFrontDesk => PolicePoint( PoliceRules.PointFrontDesk );

	/// <summary>Clé des postes informatiques du commissariat (plusieurs points), SETTINGS d'api.php.</summary>
	public const string PolicePointComputers = "police_ordinateur";

	/// <summary>Clé de la salle des scellés (un point), SETTINGS d'api.php.</summary>
	public const string PolicePointEvidence = "police_scelles";

	/// <summary>Les postes informatiques du commissariat (l'ordinateur de la police).</summary>
	public static List<Point> PoliceComputers => PolicePointList( PolicePointComputers );

	/// <summary>La salle des scellés : dépôt et restitution des objets saisis.</summary>
	public static Point PoliceEvidenceRoom => PolicePoint( PolicePointEvidence );

	// Les points de l'hôpital pour les secours (EmsRules.Point*), posés en jeu par le staff (menu F6,
	// « Points de l'hôpital ») : hz_settings, un point chacun, plusieurs pour les sorties du garage.

	/// <summary>Le vestiaire des secours : prise de service, matériel.</summary>
	public static Point EmsLocker => PolicePoint( EmsRules.PointLocker );

	/// <summary>Le menu du garage des secours (ambulances).</summary>
	public static Point EmsGarage => PolicePoint( EmsRules.PointGarage );

	/// <summary>Où apparaissent les ambulances.</summary>
	public static List<Point> EmsGarageSpawns => PolicePointList( EmsRules.PointsGarageSpawn );

	/// <summary>L'accueil de l'hôpital : se faire soigner, payer ses factures de soins.</summary>
	public static Point EmsDesk => PolicePoint( EmsRules.PointDesk );

	public static string Raw( string key ) => values.TryGetValue( key, out var v ) ? v : null;

	// La garde-robe (onglets Garde-robe et Tenues du menu admin, WardrobePage) : hz_settings
	// « garde_robe_masques » (liste de chemins) et « tenues_service » ({ service : { grade : [chemins] } }).

	public const string HiddenClothingKey = "garde_robe_masques";
	public const string UniformsKey = "tenues_service";

	static string hiddenRaw;
	static HashSet<string> hidden = new( StringComparer.OrdinalIgnoreCase );

	/// <summary>Les vêtements que la création de personnage ne propose pas (chemins .clothing).</summary>
	public static HashSet<string> HiddenClothing
	{
		get
		{
			var raw = Raw( HiddenClothingKey );
			if ( raw == hiddenRaw )
				return hidden;
			hiddenRaw = raw;
			hidden = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
			try
			{
				if ( !string.IsNullOrWhiteSpace( raw ) )
					foreach ( var p in Json.Deserialize<List<string>>( raw ) ?? new() )
						hidden.Add( p );
			}
			catch { }
			return hidden;
		}
	}

	static string uniformsRaw;
	static Dictionary<string, Dictionary<int, List<string>>> uniforms = new();

	/// <summary>Les tenues réglées, lues une fois par valeur du réglage (la garde-robe les interroge par centaines).</summary>
	static Dictionary<string, Dictionary<int, List<string>>> UniformsCache
	{
		get
		{
			var raw = Raw( UniformsKey );
			if ( raw == uniformsRaw )
				return uniforms;
			uniformsRaw = raw;
			uniforms = new();
			if ( string.IsNullOrWhiteSpace( raw ) || raw == "[]" || raw == "null" )
				return uniforms;
			try
			{
				using var doc = System.Text.Json.JsonDocument.Parse( raw );
				if ( doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object )
					return uniforms;
				foreach ( var service in doc.RootElement.EnumerateObject() )
				{
					if ( service.Value.ValueKind != System.Text.Json.JsonValueKind.Object )
						continue;
					var grades = new Dictionary<int, List<string>>();
					foreach ( var g in service.Value.EnumerateObject() )
					{
						if ( !int.TryParse( g.Name, out var grade ) || g.Value.ValueKind != System.Text.Json.JsonValueKind.Array )
							continue;
						grades[grade] = g.Value.EnumerateArray().Where( x => x.ValueKind == System.Text.Json.JsonValueKind.String ).Select( x => x.GetString() ).ToList();
					}
					uniforms[service.Name] = grades;
				}
			}
			catch { }
			return uniforms;
		}
	}

	/// <summary>Toutes les tenues réglées : service → grade → chemins. Une copie neuve, modifiable.</summary>
	public static Dictionary<string, Dictionary<int, List<string>>> Uniforms =>
		UniformsCache.ToDictionary( s => s.Key, s => s.Value.ToDictionary( g => g.Key, g => g.Value.ToList() ) );

	/// <summary>Les pièces réglées pour ce grade de ce service, ou null (la tenue d'origine). Ne pas modifier.</summary>
	public static List<string> UniformOf( string service, int grade ) =>
		UniformsCache.TryGetValue( service, out var grades ) && grades.TryGetValue( grade, out var pieces ) && pieces.Count > 0 ? pieces : null;

	/// <summary>Le réglage « tenues_service » à enregistrer, tel que l'API l'attend.</summary>
	public static object UniformsValue( Dictionary<string, Dictionary<int, List<string>>> all ) =>
		all.Where( s => s.Value.Any( g => g.Value.Count > 0 ) ).ToDictionary( s => s.Key,
			s => s.Value.Where( g => g.Value.Count > 0 ).ToDictionary( g => g.Key.ToString(), g => g.Value ) );

	/// <summary>Hôte : les réglages reçus de l'API (serverSync), puis transmis à tous (HorizonGame.SettingsJson).</summary>
	public static void Apply( Dictionary<string, string> received )
	{
		if ( received is null )
			return;
		Received = true;
		var changed = false;
		foreach ( var (k, v) in received )
		{
			if ( values.TryGetValue( k, out var old ) && old == v )
				continue;
			values[k] = v;
			changed = true;
		}
		if ( changed )
			Publish();
	}

	/// <summary>Hôte : une valeur changée en jeu, tenue pour vraie en attendant le prochain échange.</summary>
	public static void SetLocal( string key, string value )
	{
		values[key] = value;
		Publish();
	}

	/// <summary>
	/// Les clients reçoivent les réglages par le réseau : seul l'hôte parle à l'API, or le staff
	/// (onglet Réglages) et les EMS (durée d'une réanimation) sont des clients sur un serveur dédié.
	/// </summary>
	static void Publish()
	{
		Version++;
		if ( HorizonGame.Current is { } game )
			game.SettingsJson = Json.Serialize( values );
	}

	static string appliedJson;

	/// <summary>Chaque client : les réglages publiés par l'hôte.</summary>
	public static void Receive( string json )
	{
		if ( string.IsNullOrEmpty( json ) || json == appliedJson || Networking.IsHost )
			return;
		appliedJson = json;
		try
		{
			var d = Json.Deserialize<Dictionary<string, string>>( json );
			values.Clear();
			foreach ( var (k, v) in d )
				values[k] = v;
			Version++;
		}
		catch { }
	}

	public static string PointJson( Point p ) => p is null ? "null" : Json.Serialize( new { x = p.X, y = p.Y, z = p.Z, yaw = p.Yaw } );

	public static string PointsJson( IEnumerable<Point> points ) =>
		"[" + string.Join( ",", points.Select( PointJson ) ) + "]";

	/// <summary>
	/// Un ou plusieurs points ({ x, y, z, yaw }, seul ou en liste) tels que l'API les envoie. Un
	/// point de hauteur 0 vient de la page Carte du panneau, qui ne connaît que x et y : il est posé
	/// au sol ici (<see cref="Ground"/>), au moment où il est lu. Rend toujours une liste neuve : la
	/// modifier ne touche ni le cache ni les réglages.
	/// </summary>
	public static List<Point> ParsePoints( string json )
	{
		var list = ReadPoints( json );
		if ( !list.Any( NeedsGround ) )
			return list;
		if ( grounded.TryGetValue( json, out var known ) )
			return new List<Point>( known );

		var complete = true;
		for ( var i = 0; i < list.Count; i++ )
		{
			if ( !NeedsGround( list[i] ) )
				continue;
			var g = Ground( list[i] );
			if ( g is null )
				complete = false;   // carte pas encore chargée, ou rien sous le point : on réessaiera
			else
				list[i] = g;
		}
		// Seul un résultat complet est gardé, par texte du réglage : un point redéplacé depuis le
		// panneau change le texte, donc se repose au sol.
		if ( complete )
		{
			if ( grounded.Count > 64 )
				grounded.Clear();
			grounded[json] = new List<Point>( list );
		}
		return list;
	}

	/// <summary>Les points déjà posés au sol, par texte du réglage (voir <see cref="ParsePoints"/>).</summary>
	static readonly Dictionary<string, List<Point>> grounded = new();

	/// <summary>La ville est vers z 5400 : une hauteur nulle n'est jamais une vraie position, c'est « au sol ».</summary>
	static bool NeedsGround( Point p ) => MathF.Abs( p.Z ) < 0.5f;

	/// <summary>D'où part la trace qui cherche le sol, et jusqu'où elle descend.</summary>
	const float GroundTraceFrom = 6000f;
	const float GroundTraceDepth = 3000f;

	/// <summary>
	/// Le point posé sur le premier obstacle sous lui, en ignorant joueurs, véhicules et
	/// déclencheurs (comme l'éclairage public de DayNight), ou null si la trace ne touche rien.
	/// Le premier obstacle d'en haut : pour un point dans un bâtiment, c'est le toit — d'où la
	/// hauteur gardée quand on déplace un point qui en avait une, et le champ z de la page Carte.
	/// Fait chez l'hôte (qui pose et téléporte) comme chez les clients (même carte, même calcul).
	/// </summary>
	static Point Ground( Point p )
	{
		var scene = Game.ActiveScene;
		if ( scene is null || !scene.IsValid() )
			return null;
		var from = new Vector3( p.X, p.Y, GroundTraceFrom );
		var hit = scene.Trace.Ray( from, from + Vector3.Down * GroundTraceDepth ).WithoutTags( "player", "vehicule", "trigger" ).Run();
		return hit.Hit ? p with { Z = hit.EndPosition.z } : null;
	}

	/// <summary>Le texte d'un réglage de points, lu tel quel (hauteur 0 comprise).</summary>
	static List<Point> ReadPoints( string json )
	{
		var list = new List<Point>();
		if ( string.IsNullOrWhiteSpace( json ) || json == "null" )
			return list;
		try
		{
			using var doc = System.Text.Json.JsonDocument.Parse( json );
			var root = doc.RootElement;
			IEnumerable<System.Text.Json.JsonElement> items = root.ValueKind == System.Text.Json.JsonValueKind.Array ? root.EnumerateArray() : new[] { root };
			foreach ( var e in items )
			{
				if ( e.ValueKind != System.Text.Json.JsonValueKind.Object )
					continue;
				float F( string n ) => e.TryGetProperty( n, out var x ) && x.TryGetSingle( out var f ) ? f : 0;
				list.Add( new Point( F( "x" ), F( "y" ), F( "z" ), F( "yaw" ) ) );
			}
		}
		catch { }
		return list;
	}
}