Part of the police backend client. Wraps the tablet/MDT and phone-fine endpoints of an external PHP API (search, citizen file, plates, records, warrants, roster, fines, 911 dispatch history, evidence) and defines the response DTOs. Handles loose JSON value shapes via MdtText/MdtList/MdtFlag and posts through MdtPost so error bodies are read.
namespace HorizonRP;
/// <summary>
/// Les appels de l'API propres à la tablette de police (MDT, F7) et aux amendes du téléphone
/// (HZ BANK) : actions serverPolice* d'api.php (outils/police/CONCEPTION.md, section 2), clé du
/// serveur, appelées par l'hôte seul (PoliceService.Tablet.cs, PhoneSystem) après avoir revérifié
/// les droits de l'agent. Les noms commencent par « Mdt » pour ne pas croiser ceux du chantier
/// « Justice et service » (PoliceBackend.cs).
///
/// Quelques champs sont des <c>object</c> : les SteamID (BIGINT en base, ils peuvent arriver en
/// nombre JSON), la colonne JSON des charges, l'apparence peuvent arriver en texte ou en nombre,
/// ou déjà décodés. Une seule forme imprévue ferait échouer toute la fiche ; les accesseurs
/// (<see cref="MdtText"/>) lisent toutes les formes.
///
/// Les appels passent par <see cref="MdtPost{T}"/> et non par Post : l'API répond à un refus par
/// un code 4xx avec <c>{ ok: false, error }</c>, que RequestJsonAsync jette sans le lire. L'agent
/// verrait « le fichier ne répond pas » au lieu de « Aucun véhicule immatriculé 4HZR123 ».
/// </summary>
public static partial class Backend
{
/// <summary>Une valeur JSON lue sans connaître sa forme : texte tel quel, sinon le JSON brut.</summary>
public static string MdtText( object value ) => value?.ToString();
/// <summary>Une liste d'ids du code pénal : tableau JSON, ou texte « ["PC-211"] », ou « PC-211,VC-22350 ».</summary>
public static List<string> MdtList( object value )
{
var text = MdtText( value )?.Trim();
if ( string.IsNullOrEmpty( text ) || text == "null" )
return new();
if ( text.StartsWith( "[" ) )
{
try { return Json.Deserialize<List<string>>( text ) ?? new(); }
catch { return new(); }
}
return text.Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ).ToList();
}
/// <summary>
/// Un POST à l'API dont la réponse est lue quel que soit le code HTTP : un refus (400, 403, 404)
/// porte son message. Null seulement si l'API est injoignable ou rend autre chose que du JSON.
/// </summary>
static async Task<T> MdtPost<T>( string action, object body ) where T : class
{
if ( !IsOnline )
return null;
try
{
using var response = await Http.RequestAsync( $"{apiUrl}?action={action}", "POST", Http.CreateJsonContent( body ), Headers );
var text = await response.Content.ReadAsStringAsync();
return string.IsNullOrWhiteSpace( text ) ? null : Json.Deserialize<T>( text );
}
catch ( Exception e )
{
// Comme Post : une API injoignable ne fait jamais tomber la partie.
Log.Warning( $"[Horizon] API {action} : {e.Message}" );
return null;
}
}
/// <summary>Vrai pour true, 1, « oui » ou un texte non vide qui n'est pas « false » / « 0 ».</summary>
public static bool MdtFlag( object value )
{
var text = MdtText( value )?.Trim();
return !string.IsNullOrEmpty( text ) && text is not ("false" or "False" or "0" or "null");
}
/// <summary>Une personne trouvée par la recherche (nom RP, téléphone, SteamID).</summary>
public sealed class MdtPerson
{
/// <summary>Texte ou nombre JSON : lire <see cref="Steam"/>.</summary>
public object SteamId { get; set; }
public string Steam => MdtText( SteamId );
public string RpName { get; set; }
public string Phone { get; set; }
public string BirthDate { get; set; }
public string Sex { get; set; }
public string Job { get; set; }
public bool Online { get; set; }
public bool Wanted { get; set; }
public int UnpaidTotal { get; set; }
}
public sealed class MdtSearchResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<MdtPerson> People { get; set; }
}
public sealed class MdtCitizen
{
public object SteamId { get; set; }
public string Steam => MdtText( SteamId );
public string RpName { get; set; }
public string Phone { get; set; }
public string BirthDate { get; set; }
public string Sex { get; set; }
public string Job { get; set; }
/// <summary>L'apparence (JSON ClothingContainer), en texte ou déjà décodée : <see cref="AppearanceJson"/>.</summary>
public object Appearance { get; set; }
public string AppearanceJson => MdtText( Appearance );
}
/// <summary>Une ligne du casier (hz_police_records) : amende, arrestation, avertissement ou rapport.</summary>
public sealed class MdtRecord
{
public long Id { get; set; }
public string Kind { get; set; }
public object Charges { get; set; }
public int FineTotal { get; set; }
public int JailMinutes { get; set; }
public string Title { get; set; }
public string Notes { get; set; }
public string Location { get; set; }
public object OfficerSteam { get; set; }
public string OfficerName { get; set; }
public string CreatedAt { get; set; }
public List<string> ChargeIds => MdtList( Charges );
}
/// <summary>Une amende (hz_fines) : impayee, payee ou annulee.</summary>
public sealed class MdtFine
{
public long Id { get; set; }
public long RecordId { get; set; }
public int Amount { get; set; }
public string Status { get; set; }
public string IssuedAt { get; set; }
public string PaidAt { get; set; }
public string Title { get; set; }
public string CancelledBy { get; set; }
public bool Unpaid => Status == "impayee";
}
/// <summary>Un avis de recherche (hz_warrants).</summary>
public sealed class MdtWarrant
{
public long Id { get; set; }
public object SteamId { get; set; }
public string Steam => MdtText( SteamId );
public string RpName { get; set; }
public string Reason { get; set; }
public object Charges { get; set; }
public object Dangerous { get; set; }
public string Status { get; set; }
public string IssuedBy { get; set; }
public string CreatedAt { get; set; }
public string ClosedBy { get; set; }
public string ClosedAt { get; set; }
public List<string> ChargeIds => MdtList( Charges );
public bool IsDangerous => MdtFlag( Dangerous );
public bool Active => Status != "clos";
}
/// <summary>Un véhicule de la fiche d'un citoyen : Model est le nom affiché, ModelId l'id du catalogue.</summary>
public sealed class MdtCarRow
{
public string Plate { get; set; }
public string ModelId { get; set; }
public string Model { get; set; }
public string Color { get; set; }
public object Flagged { get; set; }
public string FlagReason { get; set; }
public bool IsFlagged => MdtFlag( Flagged ) || !string.IsNullOrWhiteSpace( FlagReason );
}
public sealed class MdtJail
{
public int SecondsLeft { get; set; }
public int SecondsTotal { get; set; }
}
public sealed class MdtCitizenResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public MdtCitizen Citizen { get; set; }
public List<MdtRecord> Records { get; set; }
public List<MdtFine> Fines { get; set; }
public List<MdtWarrant> Warrants { get; set; }
public List<MdtCarRow> Vehicles { get; set; }
public MdtJail Jail { get; set; }
}
public sealed class MdtPlateFlag
{
public string Reason { get; set; }
public string By { get; set; }
public string CreatedAt { get; set; }
}
public sealed class MdtPlateVehicle
{
public string Plate { get; set; }
public string ModelId { get; set; }
public string Model { get; set; }
public string Color { get; set; }
/// <summary>Texte ou nombre JSON : lire <see cref="Owner"/> (vide : pas de propriétaire).</summary>
public object OwnerSteam { get; set; }
public string Owner => MdtText( OwnerSteam );
public string OwnerName { get; set; }
public MdtPlateFlag Flag { get; set; }
}
public sealed class MdtPlateResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public MdtPlateVehicle Vehicle { get; set; }
}
public sealed class MdtRecordResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public long RecordId { get; set; }
public int FineTotal { get; set; }
public int JailMinutes { get; set; }
public long? FineId { get; set; }
}
public sealed class MdtWarrantResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public long WarrantId { get; set; }
}
public sealed class MdtWarrantsResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<MdtWarrant> Warrants { get; set; }
}
public sealed class MdtFinesResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<MdtFine> Fines { get; set; }
}
public sealed class MdtFinePayResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public int Bank { get; set; }
public long FineId { get; set; }
public int Amount { get; set; }
}
/// <summary>Nom RP, téléphone ou SteamID ; 25 personnes au plus.</summary>
public static Task<MdtSearchResponse> MdtSearch( string query )
=> MdtPost<MdtSearchResponse>( "serverPoliceSearch", new { query } );
public static Task<MdtCitizenResponse> MdtCitizenFile( ulong steamId )
=> MdtPost<MdtCitizenResponse>( "serverPoliceCitizen", new { steamId = steamId.ToString() } );
public static Task<MdtPlateResponse> MdtPlate( string plate )
=> MdtPost<MdtPlateResponse>( "serverPolicePlate", new { plate } );
/// <summary>
/// Une ligne du casier écrite depuis la tablette : <paramref name="kind"/> « rapport » ou
/// « avertissement » (amendes et arrestations passent par le menu F6, chantier Justice).
/// </summary>
public static Task<MdtRecordResponse> MdtRecordWrite( ulong steamId, string kind, List<string> charges, string title, string notes, string location, ulong officerSteam, string officerName )
=> MdtPost<MdtRecordResponse>( "serverPoliceRecord", new { steamId = steamId.ToString(), kind, charges, title, notes, location, officerSteam = officerSteam.ToString(), officerName } );
public static Task<MdtWarrantResponse> MdtWarrantIssue( ulong steamId, string reason, List<string> charges, bool dangerous, string by )
=> MdtPost<MdtWarrantResponse>( "serverPoliceWarrant", new { steamId = steamId.ToString(), reason, charges, dangerous, by } );
public static Task<SimpleResponse> MdtWarrantClose( long warrantId, string by )
=> MdtPost<SimpleResponse>( "serverPoliceWarrantClose", new { warrantId, by } );
/// <summary>
/// Les avis de recherche actifs, du plus récent au plus ancien (accueil et onglet « Avis de
/// recherche »). Action absente du contrat d'origine : sans elle, la liste reste vide et la
/// tablette le dit ; chaque fiche montre quand même les avis de la personne.
/// </summary>
public static Task<MdtWarrantsResponse> MdtWarrants()
=> MdtPost<MdtWarrantsResponse>( "serverPoliceWarrants", new { } );
/// <summary>Signale une plaque (« volé », « recherché »…) ; un motif vide retire le signalement.</summary>
public static Task<SimpleResponse> MdtFlagPlate( string plate, string reason, string by )
=> MdtPost<SimpleResponse>( "serverPoliceFlag", new { plate, reason, by } );
public static Task<SimpleResponse> MdtRecordDelete( long recordId, string by )
=> MdtPost<SimpleResponse>( "serverPoliceRecordDelete", new { recordId, by } );
public static Task<SimpleResponse> MdtFineCancel( long fineId, string by )
=> MdtPost<SimpleResponse>( "serverPoliceFineCancel", new { fineId, by } );
/// <summary>
/// Effectifs : <paramref name="grade"/> null = renvoi. L'API refuse un grade ≥ <paramref name="byGrade"/>
/// et une cible de grade ≥ <paramref name="byGrade"/> (l'hôte l'a déjà vérifié).
/// </summary>
public static Task<SimpleResponse> MdtRoster( ulong steamId, int? grade, string callsign, string by, int byGrade )
=> MdtPost<SimpleResponse>( "serverPoliceRoster", new { steamId = steamId.ToString(), grade, callsign, by, byGrade } );
/// <summary>Les amendes d'un joueur, pour l'appli Factures du téléphone.</summary>
public static Task<MdtFinesResponse> MdtFines( ulong steamId )
=> MdtPost<MdtFinesResponse>( "serverPoliceFines", new { steamId = steamId.ToString() } );
/// <summary>
/// Paie une amende depuis la banque. <paramref name="balance"/> : le solde tenu par le jeu, qui
/// fait foi (celui de la base peut être en retard d'une sauvegarde). L'API écrit l'opération
/// HZ BANK « amende », crédite le trésor et rend le nouveau solde.
/// </summary>
public static Task<MdtFinePayResponse> MdtFinePay( ulong steamId, long fineId, int balance )
=> MdtPost<MdtFinePayResponse>( "serverPoliceFinePay", new { steamId = steamId.ToString(), fineId, balance } );
// ------------------------------------------------------------------ historique du 911
/// <summary>
/// Un appel du 911 tel que l'historique le rend (hz_dispatch). Les unités arrivent en tableau
/// JSON ou en texte : lire <see cref="UnitList"/>.
/// </summary>
public sealed class MdtDispatchRow
{
public long Id { get; set; }
public string Service { get; set; }
public object CallerSteam { get; set; }
public string Caller => MdtText( CallerSteam );
public string CallerName { get; set; }
public string Number { get; set; }
public string Message { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
public string Location { get; set; }
public string Status { get; set; }
public string StatusName { get; set; }
public object Units { get; set; }
/// <summary>Qui a pris l'appel en premier (l'API le garde à part de la liste des unités).</summary>
public string TakenBy { get; set; }
public string By { get; set; }
public string ClosedBy { get; set; }
public string CreatedAt { get; set; }
public string TakenAt { get; set; }
public string ClosedAt { get; set; }
public List<string> UnitList => MdtList( Units );
public bool HasPosition => X != 0 || Y != 0;
public Vector3 Position => new( X, Y, Z );
/// <summary>Qui a clos l'appel : la colonne dédiée, sinon le dernier auteur.</summary>
public string Closer => !string.IsNullOrWhiteSpace( ClosedBy ) ? ClosedBy : Status == "clos" ? By : null;
}
public sealed class MdtDispatchCreateResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public long DispatchId { get; set; }
}
public sealed class MdtDispatchHistoryResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<MdtDispatchRow> Calls { get; set; }
public int Total { get; set; }
public int Page { get; set; }
public int PerPage { get; set; }
}
/// <summary>Un appel du 911 entre au fichier dès sa création (l'hôte garde l'id rendu).</summary>
public static Task<MdtDispatchCreateResponse> MdtDispatchCreate( string service, ulong callerSteam, string callerName, string number, string message, Vector3? at, string location )
=> MdtPost<MdtDispatchCreateResponse>( "serverDispatchCreate", new
{
service,
callerSteam = callerSteam.ToString(),
callerName,
number,
message,
x = at?.x ?? 0,
y = at?.y ?? 0,
z = at?.z ?? 0,
location,
} );
/// <summary>
/// La prise, l'ajout d'une unité ou la clôture : l'état complet de l'appel, et qui l'a changé.
/// Le fichier dit « ouvert » là où le temps réel dit « attente ».
/// </summary>
public static Task<SimpleResponse> MdtDispatchUpdate( long dispatchId, string status, List<string> units, string by )
=> MdtPost<SimpleResponse>( "serverDispatchUpdate", new { dispatchId, status = status == "attente" ? "ouvert" : status, units, by } );
/// <summary>L'historique, filtré (service « police » / « ems », statut, texte libre), 1re page = 1.</summary>
public static Task<MdtDispatchHistoryResponse> MdtDispatchHistory( string service, string status, string query, int page )
=> MdtPost<MdtDispatchHistoryResponse>( "serverDispatchHistory", new { service, status, query, page } );
// ------------------------------------------------------------------ scellés
/// <summary>
/// Un scellé (hz_evidence) : un objet saisi (ou une somme en liquide, <see cref="Item"/> vide),
/// son numéro de scellé, son propriétaire, qui l'a saisi, et sa clôture (restitué, détruit,
/// transféré). Les champs absents d'une réponse courte (serverEvidenceAdd) restent vides.
/// </summary>
public sealed class MdtEvidence
{
public long Id { get; set; }
public string Seal { get; set; }
public object OwnerSteam { get; set; }
public string Owner => MdtText( OwnerSteam );
public string OwnerName { get; set; }
public string Item { get; set; }
/// <summary>Le nom de l'objet tel que l'API le connaît (repli si le jeu ne le connaît plus).</summary>
public string ItemName { get; set; }
public int Count { get; set; }
/// <summary>Les données de l'objet (une arme : série, balles, accessoires), texte JSON ou déjà décodées.</summary>
public object Data { get; set; }
public string DataJson => Data is null ? null : MdtText( Data ) is { Length: > 0 } t && t != "null" ? t : null;
public int Cash { get; set; }
public string Status { get; set; }
public long? RecordId { get; set; }
public string Location { get; set; }
public string SeizedBy { get; set; }
public object SeizedBySteam { get; set; }
public string SeizedAt { get; set; }
public string StatusName { get; set; }
public string ClosedBy { get; set; }
public string ClosedAt { get; set; }
public string Notes { get; set; }
/// <summary>Qui a saisi, et quand (noms courts pour l'affichage).</summary>
public string By => SeizedBy;
public string CreatedAt => SeizedAt;
/// <summary>Le sort du scellé : « stocke » (au dépôt), « restitue », « detruit » ou « transfere ».</summary>
public string Outcome => Status;
/// <summary>Encore sous scellé : ni restitué, ni détruit, ni transféré.</summary>
public bool Active => Outcome is not ("restitue" or "detruit" or "transfere" or "clos");
public bool IsCash => string.IsNullOrEmpty( Item ) && Cash > 0;
}
public sealed class MdtEvidenceResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public List<MdtEvidence> Evidence { get; set; }
public int Total { get; set; }
public int Page { get; set; }
public int PerPage { get; set; }
}
/// <summary>La clôture d'un scellé rend la fiche entière, mise à jour.</summary>
public sealed class MdtEvidenceCloseResponse
{
public bool Ok { get; set; }
public string Error { get; set; }
public MdtEvidence Evidence { get; set; }
}
/// <summary>Un objet placé sous scellé, avec ses données (la série d'une arme la suit au registre).</summary>
public sealed class MdtEvidenceItem
{
public string Item { get; set; }
public int Count { get; set; }
public string Data { get; set; }
}
/// <summary>
/// Une saisie entre au registre des scellés : un scellé par objet (et un pour le liquide). Rend
/// les numéros de scellé ; l'objet n'existe plus en jeu que là.
/// </summary>
public static Task<MdtEvidenceResponse> MdtEvidenceAdd( ulong ownerSteam, string ownerName, List<MdtEvidenceItem> items, int cash, long? recordId, string location, string by, ulong bySteam )
=> MdtPost<MdtEvidenceResponse>( "serverEvidenceAdd", new
{
ownerSteam = ownerSteam.ToString(),
ownerName,
items = items.Select( x => new { item = x.Item, count = x.Count, data = x.Data } ).ToList(),
cash,
recordId,
location,
by,
bySteam = bySteam.ToString(),
} );
/// <summary>
/// Le registre : <paramref name="status"/> « stocke » (sous scellé), « restitue », « detruit »,
/// « transfere » ou vide (tout) ; <paramref name="query"/> : numéro de scellé, objet, nom.
/// </summary>
public static Task<MdtEvidenceResponse> MdtEvidenceList( string status, string ownerSteam, string query, int page )
=> MdtPost<MdtEvidenceResponse>( "serverEvidenceList", new { status, ownerSteam, query, page } );
/// <summary>
/// Clôt un scellé : <paramref name="outcome"/> restitue ou detruit (Inspecteur au moins),
/// transfere (Agent au moins). L'API n'écrit que le registre : rendre l'objet est l'affaire du jeu.
/// </summary>
public static Task<MdtEvidenceCloseResponse> MdtEvidenceClose( long id, string outcome, string by, int byGrade, string notes )
=> MdtPost<MdtEvidenceCloseResponse>( "serverEvidenceClose", new { id, outcome, by, byGrade, notes } );
}