Half of the host-side police component (partial class PoliceService) for a roleplay gamemode. Pulls officers, penal code and flagged plates from the backend every 30 s and mirrors them to players as JSON, gates the police job, handles on/off duty at the locker, issues and reclaims service weapons and ammo, runs jail timers with resume/save, and pays desk fines.
namespace HorizonRP;
/// <summary>
/// Le chantier « Justice et service » côté hôte (outils/police/CONCEPTION.md, section 4) :
///
/// - <b>Synchronisation</b> avec l'API (serverPoliceSync) au démarrage, toutes les 30 s et à l'ordre
/// « police_sync » du panneau : effectifs, code pénal, véhicules signalés, publiés à tous en JSON ;
/// grade, matricule et indicatif posés sur chaque joueur. Un joueur retiré des effectifs perd le
/// métier et le service.
/// - <b>Liste blanche</b> du métier (<see cref="JobRefusal"/>, appelé par HorizonGame.TryChangeJob).
/// - <b>Service</b> : pris et rendu au vestiaire (point « police_vestiaire ») ; paie du grade en
/// service, rien hors service (<see cref="SalaryOf"/>) ; la fin du service (vestiaire, changement de
/// métier, renvoi, prison, déconnexion) reprend les armes de service et rend le véhicule de service
/// (<see cref="ReleaseServiceVehicle"/>, PoliceService.Garage.cs, chantier « Véhicules de police »).
/// - <b>Armurerie</b> (PoliceRules.Armory, selon le grade) : armes marquées « service » dans leur
/// donnée (WeaponData.Service, série « UCPD- »), munitions données avec, tout repris en fin de
/// service. Une arme de service ne se jette pas, ne se donne pas, ne se sauvegarde pas ; trouvée
/// entre les mains de quelqu'un qui n'est pas en service, elle est reprise dans la seconde.
/// - <b>Prison</b> : décompte en ligne seulement, sauvegarde régulière et à la déconnexion
/// (serverPoliceJailSave), reprise à la reconnexion (serverPoliceJail), ramené en cellule au-delà
/// de PoliceRules.JailRadius, libéré au point « police_liberation ».
///
/// Tout est appelé par HorizonGame (TickSecond, connexion, déconnexion, ordres) : aucune méthode de
/// cycle de vie n'est redéfinie ici, les autres chantiers ont leurs propres partiels du composant.
/// </summary>
public sealed partial class PoliceService
{
/// <summary>Secondes entre deux lectures des effectifs et du code pénal.</summary>
public const float SyncSeconds = 30f;
/// <summary>Une peine en cours est sauvegardée toutes les minutes (et à la déconnexion).</summary>
public const int JailSaveSeconds = 60;
/// <summary>Le plus haut grade (le chef de la police).</summary>
public static int MaxRank => PoliceRules.Grades.Max( x => x.Rank );
RealTimeSince sinceSync = 1000;
bool syncing;
/// <summary>Hôte : au moins une lecture des effectifs a réussi (avant, on ne renvoie personne).</summary>
public bool RosterReceived { get; private set; }
// ------------------------------------------------------------------ synchronisation
/// <summary>Hôte : relit effectifs et code pénal tout de suite (ordre « police_sync », tablette après une promotion).</summary>
public void RequestSync()
{
if ( !Networking.IsHost || syncing )
return;
sinceSync = 0;
_ = SyncAsync();
}
/// <summary>Hôte, chaque seconde (HorizonGame.TickSecond).</summary>
public void TickSecond()
{
if ( !Networking.IsHost )
return;
if ( sinceSync >= SyncSeconds && !syncing )
{
sinceSync = 0;
_ = SyncAsync();
}
foreach ( var player in RoleplayPlayer.All.Where( x => x.IsValid() && x.ProfileLoaded ).ToList() )
{
TickPlayer( player );
TickJail( player );
}
// Les secours (PoliceService.Ems.cs) : effectifs, fin de service qui s'impose.
TickEms();
}
async Task SyncAsync()
{
if ( !Backend.IsOnline )
return;
syncing = true;
try
{
var response = await Backend.JusticeSync();
if ( !this.IsValid() )
return;
if ( response?.Ok != true )
{
if ( response is not null )
Log.Warning( $"[Horizon] Police : synchronisation refusée ({response.Error})" );
return;
}
if ( response.Officers is not null )
{
var list = response.Officers
.Where( x => ulong.TryParse( x.SteamIdText, out _ ) )
.Select( x => new Officer( x.SteamIdText, Math.Clamp( x.Grade, 0, MaxRank ), x.Badge ?? "", x.Callsign ?? "" ) )
.ToList();
var json = Json.Serialize( list );
if ( json != OfficersJson )
OfficersJson = json;
RosterReceived = true;
}
if ( response.Penal is not null )
{
var list = response.Penal
.Where( x => !string.IsNullOrWhiteSpace( x.Id ) )
.Select( x => new PenalEntry( x.Id, x.Category ?? "Divers", x.Title ?? x.Id, x.Description ?? "", x.Class ?? "infraction", Math.Max( 0, x.Fine ), Math.Max( 0, x.JailMinutes ) ) )
.ToList();
var json = Json.Serialize( list );
if ( json != PenalJson )
PenalJson = json;
}
if ( response.Flags is not null )
{
var json = Json.Serialize( response.Flags.Where( x => !string.IsNullOrWhiteSpace( x.Plate ) ).Select( x => new PlateFlag( x.Plate, x.Reason ?? "" ) ).ToList() );
if ( json != FlagsJson )
FlagsJson = json;
}
foreach ( var player in RoleplayPlayer.All.ToList() )
ApplyRosterTo( player );
}
finally
{
syncing = false;
}
}
/// <summary>
/// L'agent de ce joueur. Partie locale (sans API, donc sans effectifs) : le staff passe pour chef
/// de la police, le temps des essais.
/// </summary>
public Officer OfficerFor( RoleplayPlayer player ) =>
player is null ? null
: OfficerOf( player.SteamId ) ?? (!Backend.IsOnline && player.IsStaff ? new Officer( player.SteamId.ToString(), MaxRank, "0000", "STAFF" ) : null);
/// <summary>Hôte : grade, matricule et indicatif d'après les effectifs ; retiré des effectifs, il redevient citoyen.</summary>
public void ApplyRosterTo( RoleplayPlayer player )
{
if ( !player.IsValid() || !player.ProfileLoaded )
return;
var officer = OfficerFor( player );
var grade = officer?.Grade ?? -1;
if ( player.PoliceGrade != grade )
player.PoliceGrade = grade;
if ( player.PoliceBadge != officer?.Badge )
player.PoliceBadge = officer?.Badge;
if ( player.PoliceCallsign != officer?.Callsign )
player.PoliceCallsign = officer?.Callsign;
if ( officer is null && player.JobId == PoliceRules.JobId && (RosterReceived || !Backend.IsOnline) )
{
EndService( player, "retiré des effectifs", notify: false );
player.JobId = Jobs.DefaultId;
player.Notify( "Vous ne figurez plus aux effectifs de l'UCPD : vous redevenez citoyen.", "annonce" );
HorizonGame.Current?.AddLog( "police_effectifs", player.SteamId, $"{player.RpName} n'est plus aux effectifs : métier remis à citoyen" );
}
}
/// <summary>
/// La liste blanche du métier (HorizonGame.TryChangeJob) : null si ce joueur peut devenir
/// policier, sinon la raison du refus.
/// </summary>
public static string JobRefusal( RoleplayPlayer player )
{
if ( player.Jailed )
return "Pas de métier de police derrière les barreaux.";
var service = Current;
if ( service is null )
return "Le système de police n'est pas chargé.";
if ( Backend.IsOnline && !service.RosterReceived )
return "Les effectifs de l'UCPD ne sont pas encore chargés : réessayez dans quelques secondes.";
if ( service.OfficerFor( player ) is null )
return "Policier est un métier sur liste blanche : seuls les agents inscrits aux effectifs de l'UCPD le prennent. Adressez-vous au staff.";
return null;
}
/// <summary>
/// Le salaire d'une paie : un policier (ou un secouriste de l'UCFD) touche celui de son grade en
/// service, rien hors service ; les autres métiers, le leur. Lu par l'hôte (paie) et par le HUD
/// (montant affiché).
/// </summary>
public static int SalaryOf( RoleplayPlayer player ) =>
player is null ? 0
: player.JobId == PoliceRules.JobId ? (player.IsCop ? PoliceRules.GradeOf( player.PoliceGrade ).Salary : 0)
: player.JobId == EmsRules.JobId ? (player.IsEms ? EmsRules.GradeOf( player.EmsGrade ).Salary : 0)
: player.Job.Salary;
/// <summary>« Policier · Sergent », « EMS · Paramédic » : la ligne de la paie.</summary>
public static string PayLabel( RoleplayPlayer player ) =>
player.IsCop ? $"{player.Job.Name} · {PoliceRules.GradeOf( player.PoliceGrade ).Name}"
: player.IsEms ? $"{player.Job.Name} · {EmsRules.GradeOf( player.EmsGrade ).Name}"
: player.Job.Name;
/// <summary>« Sergent John Doe (1042) » : la signature des écritures au casier et au journal.</summary>
public static string JusticeSignature( RoleplayPlayer cop ) =>
$"{PoliceRules.GradeOf( cop.PoliceGrade ).Name} {cop.RpName} ({(string.IsNullOrEmpty( cop.PoliceBadge ) ? cop.SteamId.ToString() : cop.PoliceBadge)})";
// ------------------------------------------------------------------ chaque seconde
/// <summary>Le siège où ce joueur est assis, ou null.</summary>
public static VehicleSeat SeatOf( RoleplayPlayer player ) => player?.GameObject.GetComponentInParent<VehicleSeat>();
/// <summary>Hôte : fait descendre ce joueur de son siège.</summary>
public static void Unseat( RoleplayPlayer player )
{
if ( SeatOf( player ) is { } seat && player.GetComponent<PlayerController>() is { } pc )
seat.Eject( pc );
}
/// <summary>Hôte : qui a menotté qui (les menottes tombent si l'agent quitte son service).</summary>
readonly Dictionary<ulong, ulong> cuffedBy = new();
public void NoteCuffs( RoleplayPlayer citizen, RoleplayPlayer cop )
{
if ( cop is null )
cuffedBy.Remove( citizen.SteamId );
else
cuffedBy[citizen.SteamId] = cop.SteamId;
}
void TickPlayer( RoleplayPlayer player )
{
// Plus policier (F4, ordre du panneau, renvoi) : fin du service.
if ( player.OnDuty && (player.JobId != PoliceRules.JobId || player.PoliceGrade < 0) )
EndService( player, "changement de métier" );
// Une arme de service entre d'autres mains (ramassée au sol, prise dans un coffre…) : reprise.
if ( !player.IsCop && TakeServiceItems( player ) > 0 )
player.Notify( "Arme de service récupérée par l'UCPD.", "info" );
// Menotté au volant : jamais (le menotté ne monte pas seul, mais on ne sait jamais).
if ( player.Cuffed && SeatOf( player ) is { IsDriver: true } )
Unseat( player );
if ( player.EscortedBy != Guid.Empty )
{
// Un agent escorte un menotté ; un secouriste en service, un blessé (menotté ou non :
// Ems/EmsActions.cs).
var officer = Scene.Directory.FindByGuid( player.EscortedBy )?.GetComponent<RoleplayPlayer>();
var byMedic = officer is not null && officer.IsEms;
var broken = officer is null || !(officer.IsCop || byMedic) || officer.OnTheGround || officer.Cuffed || SeatOf( officer ) is not null
|| (!player.Cuffed && !byMedic) || player.OnTheGround || SeatOf( player ) is not null
|| officer.WorldPosition.Distance( player.WorldPosition ) > 300;
if ( broken )
{
player.EscortedBy = Guid.Empty;
officer?.Notify( $"Vous n'escortez plus {player.RpName}.", "info" );
}
}
}
// ------------------------------------------------------------------ service
public static bool Near( RoleplayPlayer player, ServerSettings.Point point, float range ) =>
point is not null && player.WorldPosition.Distance( point.Position ) <= range;
/// <summary>Hôte : prise de service au vestiaire.</summary>
public void StartService( RoleplayPlayer player )
{
if ( player.JobId != PoliceRules.JobId || player.PoliceGrade < 0 )
{
player.Notify( "Le vestiaire est réservé aux agents de l'UCPD.", "erreur" );
return;
}
if ( player.OnDuty || player.Jailed || player.Cuffed || player.OnTheGround )
return;
if ( !Near( player, ServerSettings.PoliceLocker, PoliceRules.PointRange * 1.5f ) )
{
player.Notify( "La prise de service se fait au vestiaire du commissariat.", "erreur" );
return;
}
player.OnDuty = true;
PutOnUniform( player );
var grade = PoliceRules.GradeOf( player.PoliceGrade );
var callsign = string.IsNullOrEmpty( player.PoliceCallsign ) ? "" : $", indicatif {player.PoliceCallsign}";
player.Notify( $"Prise de service : {grade.Name}{callsign}. Armurerie au vestiaire, menu police (F6).", "info" );
HorizonGame.Current?.AddLog( "police_service", player.SteamId, $"{JusticeSignature( player )} prend son service" );
}
/// <summary>
/// Hôte : fin de service (vestiaire, changement de métier, renvoi, prison, déconnexion). Les armes
/// de service et leurs munitions reprises, la tenue civile remise, le véhicule de service rendu,
/// l'escorte lâchée, les menottes qu'il avait posées retirées.
/// </summary>
public void EndService( RoleplayPlayer player, string reason, bool notify = true )
{
if ( !player.IsValid() )
return;
var was = player.OnDuty;
player.OnDuty = false;
var returned = TakeServiceItems( player );
RestoreCivilian( player );
foreach ( var other in RoleplayPlayer.All.Where( x => x.EscortedBy == player.GameObject.Id ).ToList() )
other.EscortedBy = Guid.Empty;
foreach ( var (citizenId, copId) in cuffedBy.ToList() )
{
if ( copId != player.SteamId )
continue;
cuffedBy.Remove( citizenId );
if ( RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == citizenId ) is { Cuffed: true } citizen && !citizen.Jailed )
{
citizen.Cuffed = false;
citizen.Notify( "L'agent qui vous a menotté a quitté son service : vos menottes sont retirées.", "info" );
}
}
if ( !was )
return;
ReleaseServiceVehicle( player );
if ( notify )
player.Notify( returned > 0 ? $"Fin de service : armement rendu ({returned} objet(s))." : "Fin de service.", "info" );
HorizonGame.Current?.AddLog( "police_service", player.SteamId, $"{JusticeSignature( player )} quitte son service ({reason}){(returned > 0 ? $", {returned} objet(s) de service rendus" : "")}" );
}
// ------------------------------------------------------------------ tenue
/// <summary>
/// Hôte : la tenue de service de son grade (PoliceUniform), passée à la prise de service par-dessus
/// son corps et sa tête ; les vêtements civils sont gardés à part (CivilianAppearance, seuls
/// enregistrés en base) et rendus à la fin du service.
/// </summary>
public void PutOnUniform( RoleplayPlayer player )
{
// Coupé par PoliceUniform.Enabled (il l'a été du 29 au 30/10/2026, faute d'une tenue au niveau).
if ( !PoliceUniform.Enabled )
return;
if ( player.CivilianAppearance is not null )
return;
var uniform = PoliceUniform.For( player.AppearanceJson, player.PoliceGrade );
if ( uniform is null )
return;
player.CivilianAppearance = player.AppearanceJson;
player.AppearanceJson = uniform;
}
/// <summary>Hôte : en service, l'agent retire sa tenue (inspecteur en civil) ou la remet.</summary>
public void SetUniform( RoleplayPlayer player, bool on )
{
if ( !player.OnDuty )
return;
if ( on )
PutOnUniform( player );
else
RestoreCivilian( player );
}
public void RestoreCivilian( RoleplayPlayer player )
{
if ( player.CivilianAppearance is not { } civilian )
return;
player.AppearanceJson = civilian;
player.CivilianAppearance = null;
}
// ------------------------------------------------------------------ armurerie
/// <summary>Une case qui tient une arme de service.</summary>
public static bool IsServiceSlot( InventorySlot slot ) =>
slot is not null && WeaponItems.WeaponOf( slot.Item ) is not null && WeaponData.Read( slot.Data )?.Service == true;
/// <summary>Hôte : les munitions données par l'armurerie, par joueur, reprises avec les armes.</summary>
readonly Dictionary<ulong, Dictionary<string, int>> issuedAmmo = new();
/// <summary>Hôte : une arme de l'armurerie, prise au vestiaire.</summary>
public void IssueWeapon( RoleplayPlayer player, string weaponId )
{
var item = PoliceRules.Armory.FirstOrDefault( x => x.WeaponId == weaponId );
if ( item is null || !player.IsCop )
return;
if ( !Near( player, ServerSettings.PoliceLocker, PoliceRules.PointRange * 1.5f ) )
{
player.Notify( "L'armurerie est au vestiaire du commissariat.", "erreur" );
return;
}
if ( player.PoliceGrade < item.MinRank )
{
player.Notify( $"{item.Label} : grade de {PoliceRules.GradeOf( item.MinRank ).Name} au moins.", "erreur" );
return;
}
var itemId = WeaponItems.WeaponItemId( weaponId );
if ( player.Inventory.Any( x => x.Item == itemId && IsServiceSlot( x ) ) )
{
player.Notify( $"Vous avez déjà votre {item.Label}.", "info" );
return;
}
if ( WeaponSystem.Current?.Get( weaponId ) is not { Active: true } || Items.Get( itemId ) is not { } definition )
{
player.Notify( $"{item.Label} : arme absente du catalogue des armes.", "erreur" );
return;
}
var data = new WeaponData { Serial = WeaponData.NewServiceSerial(), Service = true };
if ( player.AddItem( definition, 1, data.Write() ) == 0 )
{
player.Notify( "Plus de place dans vos poches.", "erreur" );
return;
}
var extra = "";
if ( !string.IsNullOrEmpty( item.Ammo ) && item.AmmoCount > 0 && Items.Get( item.Ammo ) is { } ammo )
{
var given = player.AddItem( ammo, item.AmmoCount );
if ( given > 0 )
{
var mine = issuedAmmo.GetValueOrDefault( player.SteamId ) ?? new();
mine[item.Ammo] = mine.GetValueOrDefault( item.Ammo ) + given;
issuedAmmo[player.SteamId] = mine;
extra = $" et {given} × {ammo.Name}";
}
}
player.Notify( $"Armurerie : {item.Label}{extra} (n° {data.Serial}).", "info" );
HorizonGame.Current?.AddLog( "police_armurerie", player.SteamId, $"{JusticeSignature( player )} prend {item.Label} ({data.Serial}){extra}" );
}
/// <summary>Hôte : rend tout l'armement au vestiaire (sans quitter le service).</summary>
public void ReturnWeapons( RoleplayPlayer player )
{
var count = TakeServiceItems( player );
player.Notify( count > 0 ? $"Armement rendu ({count} objet(s))." : "Vous n'avez rien à rendre.", "info" );
if ( count > 0 )
HorizonGame.Current?.AddLog( "police_armurerie", player.SteamId, $"{JusticeSignature( player )} rend son armement ({count} objet(s))" );
}
/// <summary>Hôte : reprend les armes de service et les munitions données. Rend le nombre d'objets repris.</summary>
public int TakeServiceItems( RoleplayPlayer player )
{
var taken = 0;
foreach ( var slot in player.Inventory.Where( IsServiceSlot ).ToList() )
{
WeaponSystem.Current?.HolsterIfActive( player, slot );
taken += slot.Count;
player.RemoveFrom( slot, slot.Count );
}
if ( issuedAmmo.Remove( player.SteamId, out var ammo ) )
{
foreach ( var (ammoId, count) in ammo )
{
var left = count;
foreach ( var slot in player.Inventory.Where( x => x.Item == ammoId ).OrderByDescending( x => x.Slot ).ToList() )
{
var take = Math.Min( left, slot.Count );
player.RemoveFrom( slot, take );
left -= take;
taken += take;
if ( left == 0 )
break;
}
}
}
return taken;
}
// ------------------------------------------------------------------ prison
readonly Dictionary<ulong, RealTimeSince> broughtBack = new();
/// <summary>Hôte : écroue ce joueur pour <paramref name="seconds"/> secondes de jeu en ligne (la ligne hz_jail est déjà écrite par l'API).</summary>
public void Imprison( RoleplayPlayer player, int seconds, int totalSeconds = 0 )
{
player.EscortedBy = Guid.Empty;
player.Cuffed = false;
NoteCuffs( player, null );
if ( player.OnDuty || player.JobId == PoliceRules.JobId )
{
EndService( player, "écroué", notify: false );
player.JobId = Jobs.DefaultId;
}
WeaponSystem.Current?.Holster( player );
if ( Cash.IsHeld( player ) )
player.HeldKey = null;
Unseat( player );
player.JailSecondsTotal = Math.Max( seconds, totalSeconds );
player.JailSecondsLeft = seconds;
if ( ServerSettings.PolicePrison is { } prison )
player.TeleportTo( prison.Position );
broughtBack[player.SteamId] = 0;
}
void TickJail( RoleplayPlayer player )
{
if ( !player.Jailed || player.NeedsCharacter )
return;
player.JailSecondsLeft--;
if ( player.JailSecondsLeft <= 0 )
{
Release( player, "peine purgée", null );
return;
}
// En prison : ni métier de police, ni arme, ni véhicule, ni menottes.
if ( player.OnDuty || player.JobId == PoliceRules.JobId )
{
EndService( player, "en prison", notify: false );
player.JobId = Jobs.DefaultId;
}
WeaponSystem.Current?.Holster( player );
if ( player.Cuffed )
player.Cuffed = false;
if ( SeatOf( player ) is not null )
Unseat( player );
// Sorti de l'enceinte (évadé, réanimé à l'hôpital, réapparu) : ramené en cellule.
if ( ServerSettings.PolicePrison is { } prison && !player.OnTheGround
&& player.WorldPosition.Distance( prison.Position ) > PoliceRules.JailRadius
&& (!broughtBack.TryGetValue( player.SteamId, out var since ) || since > 3f) )
{
broughtBack[player.SteamId] = 0;
player.TeleportTo( prison.Position );
player.Notify( "Vous êtes ramené en cellule : la peine continue.", "erreur" );
HorizonGame.Current?.AddLog( "police_prison", player.SteamId, $"{player.RpName} ramené en cellule (hors de l'enceinte)" );
}
if ( player.JailSecondsLeft % JailSaveSeconds == 0 )
_ = Backend.JusticeJailSave( player.SteamId, player.JailSecondsLeft );
}
/// <summary>Hôte : l'ordre « liberer » du panneau (ou le staff).</summary>
public (bool, string) ReleaseFromJail( RoleplayPlayer player, string by )
{
if ( !player.Jailed )
return (false, "Le joueur n'est pas en prison");
Release( player, $"libéré par {by}", by );
return (true, $"{player.RpName} libéré");
}
void Release( RoleplayPlayer player, string why, string by )
{
player.JailSecondsLeft = 0;
player.JailSecondsTotal = 0;
_ = Backend.JusticeJailSave( player.SteamId, 0 );
if ( ServerSettings.PoliceRelease is { } exit )
player.TeleportTo( exit.Position );
else if ( HorizonGame.Current is { } game )
player.TeleportTo( game.SpawnPosition() );
player.Notify( by is null ? "Peine purgée : vous êtes libre." : "Vous êtes libéré de prison.", "annonce" );
HorizonGame.Current?.AddLog( "police_liberation", player.SteamId, $"{player.RpName} sort de prison : {why}" );
}
// ------------------------------------------------------------------ connexions
/// <summary>Hôte, profil chargé (HorizonGame.LoadProfileAsync) : effectifs, armes de service oubliées, peine en cours.</summary>
public async Task OnProfileLoaded( RoleplayPlayer player )
{
// Une arme de service sauvegardée par un ancien code ou un arrêt brutal ne survit pas à la reconnexion.
TakeServiceItems( player );
ApplyRosterTo( player );
ApplyEmsRosterTo( player );
if ( !Backend.IsOnline )
return;
var response = await Backend.JusticeJailLoad( player.SteamId );
if ( !player.IsValid() || response?.Ok != true || response.Jail is not { SecondsLeft: > 0 } jail )
return;
player.JailSecondsTotal = Math.Max( jail.SecondsTotal, jail.SecondsLeft );
player.JailSecondsLeft = jail.SecondsLeft;
if ( player.JobId == PoliceRules.JobId )
player.JobId = Jobs.DefaultId;
if ( ServerSettings.PolicePrison is { } prison && !player.NeedsCharacter )
player.TeleportTo( prison.Position );
broughtBack[player.SteamId] = 0;
player.Notify( $"Votre peine reprend : {FormatJail( jail.SecondsLeft )} de prison.", "erreur" );
}
/// <summary>Hôte, avant la sauvegarde d'un joueur qui part : fin de service, escorte, peine sauvegardée.</summary>
public void OnPlayerLeaving( RoleplayPlayer player )
{
EndService( player, "déconnexion", notify: false );
EndEmsService( player, "déconnexion", notify: false );
if ( player.Cuffed )
HorizonGame.Current?.AddLog( "police_deconnexion", player.SteamId, $"{player.RpName} s'est déconnecté menotté" );
NoteCuffs( player, null );
if ( player.Jailed )
_ = Backend.JusticeJailSave( player.SteamId, player.JailSecondsLeft );
broughtBack.Remove( player.SteamId );
}
/// <summary>Hôte, à l'arrêt du serveur : toutes les peines en cours.</summary>
public async Task SaveAllJail()
{
foreach ( var player in RoleplayPlayer.All.Where( x => x.Jailed ).ToList() )
await Backend.JusticeJailSave( player.SteamId, player.JailSecondsLeft );
}
/// <summary>« 12 min 05 s ».</summary>
public static string FormatJail( int seconds ) =>
seconds >= 60 ? $"{seconds / 60} min {seconds % 60:00} s" : $"{seconds} s";
// ------------------------------------------------------------------ accueil (amendes)
/// <summary>Hôte : les amendes impayées de ce joueur, envoyées à sa machine (accueil du commissariat).</summary>
public async Task SendDeskFines( RoleplayPlayer player )
{
if ( !Backend.IsOnline )
{
player.PoliceDeskFines( "[]", "Partie locale : les amendes sont en base, rien à payer ici." );
return;
}
var response = await Backend.JusticeFines( player.SteamId );
if ( !player.IsValid() )
return;
if ( response?.Ok != true )
{
player.PoliceDeskFines( "[]", response?.Error ?? "Le fichier ne répond pas : réessayez dans un instant." );
return;
}
var unpaid = (response.Fines ?? new()).Where( x => x.Status == "impayee" ).ToList();
player.PoliceDeskFines( Json.Serialize( unpaid ), null );
}
/// <summary>Hôte : paie une amende depuis HZ BANK, à l'accueil.</summary>
public async Task PayDeskFine( RoleplayPlayer player, long fineId )
{
if ( !Backend.IsOnline )
return;
// Le solde du jeu fait foi : l'API débite celui-là et rend le nouveau.
var response = await Backend.JusticeFinePay( player.SteamId, fineId, player.Bank );
if ( !player.IsValid() )
return;
if ( response?.Ok != true )
{
player.Notify( response?.Error ?? "Le paiement n'a pas abouti : réessayez dans un instant.", "erreur" );
return;
}
var paid = player.Bank - response.Bank;
player.Bank = Math.Max( 0, response.Bank );
player.Notify( paid > 0 ? $"Amende payée : -{Format.Money( paid )} (HZ BANK)." : "Amende payée.", "argent" );
HorizonGame.Current?.AddLog( "police_amende_payee", player.SteamId, $"{player.RpName} paie l'amende n°{fineId} à l'accueil du commissariat" );
await SendDeskFines( player );
PhoneSystem.Current?.RefreshInvoices( player );
}
}