HTTP client for the Horizon RP web API. Wraps profile loading, save, sync, orders, placements, doors, vehicles, phone, bank, photos, props, weapons and staff actions as typed POST requests with an API key header, and falls back to local files when no API is configured.
namespace HorizonRP;
/// <summary>
/// Le lien entre le serveur de jeu et l'API web d'Horizon RP (stenda.fr), qui
/// garde les profils en base et sert l'interface d'administration.
///
/// L'adresse et la clé vivent dans <c>Secrets.Server.cs</c> : compilé sur le
/// serveur dédié seulement, jamais envoyé aux joueurs.
///
/// Dans l'éditeur, ce fichier n'est pas compilé : l'adresse et la clé sont lues
/// dans <c>api_editeur.json</c>, rangé dans les données locales de l'éditeur
/// (hors du projet, jamais publié), pour essayer sur la vraie base. Sans lui,
/// <see cref="IsOnline"/> est faux et les profils retombent sur des fichiers
/// locaux (<see cref="Persistence"/>).
///
/// Les noms de champs JSON sont en camelCase : c'est ce que produisent et
/// attendent les méthodes Http de s&box (réglages « Web » de System.Text.Json).
/// </summary>
public static partial class Backend
{
static string apiUrl;
static string apiKey;
static bool settingsRead;
public static bool IsOnline
{
get
{
if ( !settingsRead )
{
ReadSettings( ref apiUrl, ref apiKey );
if ( string.IsNullOrWhiteSpace( apiUrl ) && Application.IsEditor )
ReadEditorSettings( ref apiUrl, ref apiKey );
settingsRead = true;
}
return !string.IsNullOrWhiteSpace( apiUrl ) && !string.IsNullOrWhiteSpace( apiKey );
}
}
static partial void ReadSettings( ref string url, ref string key );
sealed class EditorSettings
{
public string Url { get; set; }
public string Key { get; set; }
}
/// <summary>Éditeur seulement : l'accès à l'API pour les essais, hors du projet.</summary>
static void ReadEditorSettings( ref string url, ref string key )
{
var settings = FileSystem.Data.ReadJsonOrDefault<EditorSettings>( "api_editeur.json" );
if ( settings is null )
return;
url = settings.Url;
key = settings.Key;
Log.Info( "[Horizon] Éditeur : essais sur la vraie base (api_editeur.json)" );
}
// Échanges avec l'API
public sealed class ProfileResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public PlayerProfile Profile { get; set; }
public bool Banned { get; set; }
public string BanReason { get; set; }
}
public sealed class SyncPlayer
{
public ulong SteamId { get; set; }
public string RpName { get; set; }
public string DisplayName { get; set; }
public string Job { get; set; }
public int Wallet { get; set; }
public int Bank { get; set; }
/// <summary>
/// Faux tant que le profil n'est pas chargé : l'API n'écrit alors pas
/// l'argent, qui ne serait que la valeur par défaut du prefab.
/// </summary>
public bool ProfileLoaded { get; set; }
// Photo de l'instant pour la fiche du panneau (rien de tout cela n'est sauvegardé).
public int Health { get; set; }
public int Hunger { get; set; }
public bool Frozen { get; set; }
public bool Downed { get; set; }
}
/// <summary>Un emplacement du coffre d'un véhicule sorti, pour le panneau.</summary>
public sealed class TrunkItem
{
public int Slot { get; set; }
public string Item { get; set; }
public string Name { get; set; }
public int Count { get; set; }
}
/// <summary>Un véhicule sorti, tel que le panneau le montre (page Véhicules, « En ville »).</summary>
public sealed class VehicleSnapshot
{
public string Plate { get; set; }
public string Model { get; set; }
public string Name { get; set; }
public string Color { get; set; }
public ulong OwnerId { get; set; }
public string OwnerName { get; set; }
public bool Bought { get; set; }
public string Driver { get; set; }
public int Passengers { get; set; }
public bool Locked { get; set; }
public bool EngineOn { get; set; }
public int Engine { get; set; }
public int Body { get; set; }
public int Trunk { get; set; }
public List<TrunkItem> TrunkItems { get; set; }
public float TrunkKg { get; set; }
public int X { get; set; }
public int Y { get; set; }
public int Z { get; set; }
}
/// <summary>Une porte telle que le panneau la montre : réglage, propriétaire, verrou.</summary>
public sealed class DoorSnapshot
{
public string Key { get; set; }
public string Title { get; set; }
public int Price { get; set; }
public bool Buyable { get; set; }
public string Jobs { get; set; }
public ulong OwnerId { get; set; }
public string OwnerName { get; set; }
public int CoOwners { get; set; }
public bool Locked { get; set; }
}
/// <summary>Le compte à rebours d'un arrêt ou d'un redémarrage, pour le panneau.</summary>
public sealed class ShutdownInfo
{
/// <summary>« redemarrer » ou « arreter ».</summary>
public string Mode { get; set; }
public int Seconds { get; set; }
}
public sealed class SyncRequest
{
public string Map { get; set; }
public int MaxPlayers { get; set; }
public List<SyncPlayer> Players { get; set; }
public List<LogEntry> Logs { get; set; }
/// <summary>Null tant que rien n'a changé : l'état des portes ne part que s'il a bougé.</summary>
public List<DoorSnapshot> Doors { get; set; }
/// <summary>De même pour les véhicules sortis (positions arrondies au mètre près).</summary>
public List<VehicleSnapshot> Vehicles { get; set; }
/// <summary>Null hors compte à rebours.</summary>
public ShutdownInfo Shutdown { get; set; }
}
public sealed class LogEntry
{
public string Action { get; set; }
public ulong SteamId { get; set; }
public string Details { get; set; }
}
/// <summary>Un ordre passé depuis l'interface d'administration.</summary>
public sealed class Order
{
public long Id { get; set; }
public string Type { get; set; }
public ulong SteamId { get; set; }
public int Value { get; set; }
public string Text { get; set; }
}
public sealed class SyncResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<Order> Orders { get; set; }
public List<ulong> Bans { get; set; }
/// <summary>Change quand le catalogue des props a été modifié (panneau web) : PropSystem le relit.</summary>
public string PropsVersion { get; set; }
/// <summary>De même pour le catalogue des armes (WeaponSystem).</summary>
public string WeaponsVersion { get; set; }
/// <summary>De même pour les fiches des véhicules (VehicleManager).</summary>
public string VehiclesVersion { get; set; }
/// <summary>Les réglages du serveur (hz_settings) : ServerSettings.</summary>
public Dictionary<string, string> Settings { get; set; }
}
public sealed class SimpleResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
}
static Dictionary<string, string> Headers => new() { { "X-Horizon-Key", apiKey } };
static async Task<T> Post<T>( string action, object body ) where T : class
{
if ( !IsOnline )
return null;
try
{
return await Http.RequestJsonAsync<T>( $"{apiUrl}?action={action}", "POST", Http.CreateJsonContent( body ), Headers );
}
catch ( Exception e )
{
// Une API injoignable ne doit jamais faire tomber la partie : on
// journalise et l'appelant garde ce qu'il a.
Log.Warning( $"[Horizon] API {action} : {e.Message}" );
return null;
}
}
public static Task<ProfileResponse> LoadProfile( ulong steamId, string displayName )
=> Post<ProfileResponse>( "serverLoadProfile", new { steamId, displayName } );
public static Task<SimpleResponse> SaveProfiles( IEnumerable<PlayerProfile> profiles )
=> Post<SimpleResponse>( "serverSaveProfiles", new { profiles = profiles.ToList() } );
public static Task<SyncResponse> Sync( SyncRequest request )
=> Post<SyncResponse>( "serverSync", request );
public static Task<SimpleResponse> OrderDone( long id, bool ok, string result )
=> Post<SimpleResponse>( "serverOrderDone", new { id, ok, result } );
public sealed class OrdersResponse
{
public bool Ok { get; set; }
public List<Order> Orders { get; set; }
}
/// <summary>Attente longue : l'API garde la requête ouverte jusqu'à <paramref name="wait"/> s et répond dès qu'un ordre arrive.</summary>
public static Task<OrdersResponse> WaitOrders( int wait )
=> Post<OrdersResponse>( "serverOrdersWait", new { wait } );
// Objets posés en jeu (menu F6)
/// <summary>Un objet de la ville tel qu'il est rangé en base (hz_placements).</summary>
public sealed class Placement
{
public long Id { get; set; }
public string Kind { get; set; }
public string Model { get; set; }
public string Label { get; set; }
public string Data { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public float Pitch { get; set; }
public float Yaw { get; set; }
public float Roll { get; set; }
public float Scale { get; set; } = 1;
}
public sealed class PlacementsResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<Placement> Placements { get; set; }
}
public sealed class PlacementSaveResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public long Id { get; set; }
}
public static Task<PlacementsResponse> LoadPlacements()
=> Post<PlacementsResponse>( "serverPlacements", new { } );
/// <summary>Id 0 : création ; l'API rend l'Id attribué.</summary>
public static Task<PlacementSaveResponse> SavePlacement( Placement placement, string by )
=> Post<PlacementSaveResponse>( "serverPlacementSave", new { placement, by } );
public static Task<SimpleResponse> DeletePlacement( long id, string by )
=> Post<SimpleResponse>( "serverPlacementDelete", new { id, by } );
// Réglage des portes (staff, menu des portes, touche P)
public sealed class DoorConfig
{
public string Key { get; set; }
public string Title { get; set; }
public int Price { get; set; }
public bool Buyable { get; set; } = true;
/// <summary>Id de métiers séparés par des virgules ; vide = porte ordinaire.</summary>
public string Jobs { get; set; }
}
public sealed class DoorsResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<DoorConfig> Doors { get; set; }
}
public static Task<DoorsResponse> LoadDoors()
=> Post<DoorsResponse>( "serverDoors", new { } );
public static Task<SimpleResponse> SaveDoor( DoorConfig door, string by )
=> Post<SimpleResponse>( "serverDoorSave", new { door, by } );
// Portes de la map verrouillées par le staff (hz_map_locks), par la position du battant fermé
public sealed class MapLocksResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<string> Keys { get; set; }
}
public static Task<MapLocksResponse> LoadMapLocks()
=> Post<MapLocksResponse>( "serverMapLocks", new { } );
public static Task<SimpleResponse> SetMapLock( string key, bool locked, string by )
=> Post<SimpleResponse>( "serverMapLockSet", new { key, locked, by } );
// Véhicules possédés
public sealed class VehiclesResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<VehicleManager.OwnedVehicle> Vehicles { get; set; }
}
public sealed class VehicleAddResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public VehicleManager.OwnedVehicle Vehicle { get; set; }
}
public static Task<VehiclesResponse> LoadVehicles( ulong steamId )
=> Post<VehiclesResponse>( "serverVehicles", new { steamId } );
/// <summary>Enregistre un achat déjà payé ; l'API tire la plaque.</summary>
/// <summary>Le coffre et les dégâts d'un véhicule acheté (Vehicle.SavedState, en JSON).</summary>
public static Task<SimpleResponse> SaveVehicleState( long id, ulong steamId, string state )
=> Post<SimpleResponse>( "serverVehicleState", new { id, steamId, state } );
public static Task<VehicleAddResponse> AddVehicle( ulong steamId, string modelId, string color )
=> Post<VehicleAddResponse>( "serverVehicleAdd", new { steamId, modelId, color } );
// Téléphone
public sealed class PhoneResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<PhoneContact> Contacts { get; set; }
public List<PhoneMessage> Messages { get; set; }
public List<PhoneCallLog> Calls { get; set; }
}
public sealed class SmsResponse
{
/// <summary>Faux : personne n'a ce numéro (« Numéro non attribué »).</summary>
public bool Exists { get; set; } = true;
public bool Ok { get; set; }
public string Error { get; set; }
public PhoneMessage Message { get; set; }
}
public sealed class ContactResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public PhoneContact Contact { get; set; }
}
public sealed class CallLogResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
/// <summary>Le numéro appartient à un joueur connu.</summary>
public bool Exists { get; set; }
public PhoneCallLog Call { get; set; }
}
public static Task<CallLogResponse> LogCall( string from, string to, ulong fromSteamId, bool answered, bool refused, int duration )
=> Post<CallLogResponse>( "serverCallLog", new { from, to, fromSteamId, answered, refused, duration } );
public static Task<SimpleResponse> CallsSeen( string number )
=> Post<SimpleResponse>( "serverCallsSeen", new { number } );
public static Task<PhoneResponse> LoadPhone( ulong steamId, string number )
=> Post<PhoneResponse>( "serverPhoneLoad", new { steamId, number } );
public static Task<SmsResponse> SendSms( string from, string to, ulong fromSteamId, string text )
=> Post<SmsResponse>( "serverSmsSend", new { from, to, fromSteamId, text } );
public static Task<ContactResponse> SaveContact( ulong steamId, long id, string name, string number )
=> Post<ContactResponse>( "serverContactSave", new { steamId, id, name, number } );
/// <summary>Retire de chez ce numéro un SMS (<paramref name="id"/>) ou toute la conversation avec <paramref name="peer"/>.</summary>
public static Task<SimpleResponse> DeleteSms( string number, string peer, long id )
=> Post<SimpleResponse>( "serverSmsDelete", new { number, peer, id } );
public static Task<SimpleResponse> DeleteContact( ulong steamId, long id )
=> Post<SimpleResponse>( "serverContactDelete", new { steamId, id } );
// HZ BANK (Money/Bank.cs)
public sealed class BankHistoryResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public string Account { get; set; }
public List<Bank.Op> Ops { get; set; }
}
public sealed class BankTransferResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
/// <summary>Le titulaire est en ville : c'est au jeu de le créditer.</summary>
public bool Online { get; set; }
public string SteamId { get; set; }
public string Name { get; set; }
}
public static Task<SimpleResponse> BankOps( object ops )
=> Post<SimpleResponse>( "serverBankOps", new { ops } );
public static Task<BankHistoryResponse> BankHistory( ulong steamId, int limit )
=> Post<BankHistoryResponse>( "serverBankHistory", new { steamId, limit } );
public static Task<BankTransferResponse> BankTransfer( ulong fromSteamId, string fromName, string account, int amount, string note )
=> Post<BankTransferResponse>( "serverBankTransfer", new { fromSteamId, fromName, account, amount, note } );
// Photos du téléphone
public sealed class PhotoListResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<PhonePhoto> Photos { get; set; }
}
/// <summary>Un jeton à usage unique pour que le client du joueur envoie la photo qu'il vient de prendre.</summary>
public static Task<CaptureResponse> RequestPhoto( ulong steamId, string camera )
=> Post<CaptureResponse>( "serverPhotoRequest", new { steamId, camera } );
public static Task<PhotoListResponse> PhotoList( ulong steamId )
=> Post<PhotoListResponse>( "serverPhotoList", new { steamId } );
public static Task<SimpleResponse> PhotoDelete( ulong steamId, long id )
=> Post<SimpleResponse>( "serverPhotoDelete", new { steamId, id } );
/// <summary>Où le client envoie une photo, et d'où il la relit (avec sa clé) : l'API publique.</summary>
public static string PhotoUploadUrl => IsOnline ? $"{apiUrl}?action=photoUpload" : null;
public static string PhotoBaseUrl => IsOnline ? $"{apiUrl}?action=photo" : null;
// Captures d'écran (staff)
public sealed class CaptureResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public string Token { get; set; }
}
/// <summary>Un jeton à usage unique, que le client du joueur présentera avec son image.</summary>
public static Task<CaptureResponse> RequestCapture( ulong steamId, string by )
=> Post<CaptureResponse>( "serverCaptureRequest", new { steamId, by } );
/// <summary>
/// L'adresse où le client du joueur envoie sa capture. Elle n'a rien de
/// secret (c'est l'API publique) : seule la clé, qui ne quitte pas le serveur, l'est.
/// </summary>
public static string CaptureUploadUrl => IsOnline ? $"{apiUrl}?action=captureUpload" : null;
// Catalogue des props (menu P)
public sealed class PropsResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public string Version { get; set; }
public List<PropEntry> Props { get; set; }
}
public sealed class PropSaveResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public PropEntry Prop { get; set; }
}
public static Task<PropsResponse> LoadProps()
=> Post<PropsResponse>( "serverProps", new { } );
public static Task<PropSaveResponse> SaveProp( PropEntry prop, string by )
=> Post<PropSaveResponse>( "serverPropSave", new { prop, by } );
public static Task<SimpleResponse> DeleteProp( long id, string by )
=> Post<SimpleResponse>( "serverPropDelete", new { id, by } );
// Armes
public sealed class WeaponsResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public string Version { get; set; }
public List<WeaponEntry> Weapons { get; set; }
public List<AccessoryEntry> Accessories { get; set; }
}
public sealed class WeaponSaveResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
}
public sealed class VehicleModelsResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public string Version { get; set; }
public List<VehicleModel> Models { get; set; }
}
public static Task<VehicleModelsResponse> LoadVehicleModels()
=> Post<VehicleModelsResponse>( "serverVehicleModels", new { } );
public static Task<SimpleResponse> SaveVehicleModel( VehicleModel model, string by )
=> Post<SimpleResponse>( "serverVehicleModelSave", new { model, by } );
/// <summary>Un réglage du serveur (onglet Réglages du menu admin) : l'API le vérifie et l'enregistre.</summary>
public static Task<SimpleResponse> SaveSetting( string key, object value, string by )
=> Post<SimpleResponse>( "serverSettingSave", new { key, value, by } );
public static Task<WeaponsResponse> LoadWeapons()
=> Post<WeaponsResponse>( "serverWeapons", new { } );
public static Task<WeaponSaveResponse> SaveWeapon( WeaponEntry weapon, string by )
=> Post<WeaponSaveResponse>( "serverWeaponSave", new { weapon, by } );
public static Task<WeaponSaveResponse> SaveAccessory( AccessoryEntry accessory, string by )
=> Post<WeaponSaveResponse>( "serverAccessorySave", new { accessory, by } );
// Menu d'administration en jeu : ce que montre le panneau web
/// <summary>Une ligne du journal, telle que le panneau la montre.</summary>
public sealed class StaffLog
{
public long Id { get; set; }
public string CreatedAt { get; set; }
public string Source { get; set; }
public string Actor { get; set; }
public string Action { get; set; }
public string SteamId { get; set; }
public string RpName { get; set; }
public string Details { get; set; }
}
public sealed class StaffLogsResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<StaffLog> Logs { get; set; }
}
public sealed class StaffVehicle
{
public string Model { get; set; }
public string Plate { get; set; }
public string Color { get; set; }
}
public sealed class StaffPlayerResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public string Notes { get; set; }
public string FirstSeen { get; set; }
public int Playtime { get; set; }
public List<StaffVehicle> Vehicles { get; set; }
public int Captures { get; set; }
public List<StaffLog> Logs { get; set; }
}
public static Task<StaffLogsResponse> StaffLogs( string q, ulong steamId, int limit )
=> Post<StaffLogsResponse>( "serverStaffLogs", new { q, steamId = steamId == 0 ? "" : steamId.ToString(), limit } );
public static Task<StaffPlayerResponse> StaffPlayer( ulong steamId )
=> Post<StaffPlayerResponse>( "serverStaffPlayer", new { steamId = steamId.ToString() } );
public static Task<SimpleResponse> StaffNotes( ulong steamId, string notes, string by )
=> Post<SimpleResponse>( "serverStaffNotes", new { steamId = steamId.ToString(), notes, by } );
public static Task<SimpleResponse> StaffBan( ulong steamId, int hours, string reason, string by )
=> Post<SimpleResponse>( "serverStaffBan", new { steamId = steamId.ToString(), hours, reason, by } );
}