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.
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;
}
}