Partial of PoliceService that handles the EMS/fire service: syncs the medic roster from the backend to all clients as JSON, applies grade/badge/callsign to players, gates the EMS job behind the whitelist, runs the hospital locker duty start/end with kit issue and return, and serves the hospital reception (bill payment and paid healing when no medic is on duty).
namespace HorizonRP;
/// <summary>
/// Les secours (UCFD EMS, Ems/EmsRules.cs pour les règles), côté hôte, sur le modèle exact de la
/// police (PoliceService.Service.cs). Un partiel de PoliceService plutôt qu'un nouveau composant :
/// celui-ci est déjà dans la scène, et il devient ainsi le système des « services publics ».
///
/// - <b>Synchronisation</b> des effectifs (serverEmsSync) au démarrage, toutes les 30 s et à
/// l'ordre « ems_sync » du panneau, publiés à tous en JSON ; grade, matricule et indicatif posés
/// sur chaque joueur. Un secouriste retiré des effectifs perd le métier et le service.
/// - <b>Liste blanche</b> du métier « EMS » (<see cref="EmsJobRefusal"/>, HorizonGame.TryChangeJob).
/// - <b>Service</b> au vestiaire de l'hôpital (point « ems_vestiaire ») ; paie du grade en service
/// (<see cref="SalaryOf"/>) ; la fin du service reprend le matériel non utilisé et rend
/// l'ambulance (garage commun, PoliceService.Garage.cs).
/// - <b>Accueil de l'hôpital</b> (« ems_accueil ») : payer ses factures, et se faire soigner contre
/// paiement quand aucun secouriste n'est en service.
///
/// Les actions de secours sur un patient (F6) sont dans Ems/EmsActions.cs.
/// </summary>
public sealed partial class PoliceService
{
/// <summary>Un secouriste inscrit (hz_ems_staff).</summary>
public sealed record Medic( string SteamId, int Grade, string Badge, string Callsign );
[Sync( SyncFlags.FromHost )] public string EmsStaffJson { get; set; }
string parsedEms;
List<Medic> medics = new();
public IReadOnlyList<Medic> EmsStaff
{
get
{
if ( parsedEms != EmsStaffJson )
{
parsedEms = EmsStaffJson;
medics = string.IsNullOrEmpty( EmsStaffJson ) ? new() : Json.Deserialize<List<Medic>>( EmsStaffJson ) ?? new();
}
return medics;
}
}
/// <summary>Le secouriste inscrit sous ce SteamID, ou null : la liste blanche du métier.</summary>
public Medic MedicOf( ulong steamId ) => EmsStaff.FirstOrDefault( x => x.SteamId == steamId.ToString() );
/// <summary>Partie locale (sans API, donc sans effectifs) : le staff passe pour chef des secours, le temps des essais.</summary>
public Medic MedicFor( RoleplayPlayer player ) =>
player is null ? null
: MedicOf( player.SteamId ) ?? (!Backend.IsOnline && player.IsStaff ? new Medic( player.SteamId.ToString(), EmsRules.MaxRank, "0000", "STAFF" ) : null);
/// <summary>Hôte : au moins une lecture des effectifs des secours a réussi (avant, on ne renvoie personne).</summary>
public bool EmsRosterReceived { get; private set; }
RealTimeSince sinceEmsSync = 1000;
bool emsSyncing;
/// <summary>Hôte : relit les effectifs des secours tout de suite (ordre « ems_sync », promotion en jeu).</summary>
public void RequestEmsSync()
{
if ( !Networking.IsHost || emsSyncing )
return;
sinceEmsSync = 0;
_ = EmsSyncAsync();
}
async Task EmsSyncAsync()
{
if ( !Backend.IsOnline )
return;
emsSyncing = true;
try
{
var response = await Backend.EmsSync();
if ( !this.IsValid() )
return;
if ( response?.Ok != true || response.Staff is null )
{
if ( response is not null )
Log.Warning( $"[Horizon] Secours : synchronisation refusée ({response.Error})" );
return;
}
var list = response.Staff
.Where( x => ulong.TryParse( x.SteamIdText, out _ ) )
.Select( x => new Medic( x.SteamIdText, Math.Clamp( x.Grade, 0, EmsRules.MaxRank ), x.Badge ?? "", x.Callsign ?? "" ) )
.ToList();
var json = Json.Serialize( list );
if ( json != EmsStaffJson )
EmsStaffJson = json;
EmsRosterReceived = true;
foreach ( var player in RoleplayPlayer.All.ToList() )
ApplyEmsRosterTo( player );
}
finally
{
emsSyncing = false;
}
}
/// <summary>Hôte : grade, matricule et indicatif d'après les effectifs ; retiré, il redevient citoyen.</summary>
public void ApplyEmsRosterTo( RoleplayPlayer player )
{
if ( !player.IsValid() || !player.ProfileLoaded )
return;
var medic = MedicFor( player );
var grade = medic?.Grade ?? -1;
if ( player.EmsGrade != grade )
player.EmsGrade = grade;
if ( player.EmsBadge != medic?.Badge )
player.EmsBadge = medic?.Badge;
if ( player.EmsCallsign != medic?.Callsign )
player.EmsCallsign = medic?.Callsign;
if ( medic is null && player.JobId == EmsRules.JobId && (EmsRosterReceived || !Backend.IsOnline) )
{
EndEmsService( player, "retiré des effectifs", notify: false );
player.JobId = Jobs.DefaultId;
player.Notify( $"Vous ne figurez plus aux effectifs de l'{EmsRules.Short} {EmsRules.Service} : vous redevenez citoyen.", "annonce" );
HorizonGame.Current?.AddLog( "ems_effectifs", player.SteamId, $"{player.RpName} n'est plus aux effectifs des secours : métier remis à citoyen" );
}
}
/// <summary>La liste blanche du métier « EMS » : null s'il peut le prendre, sinon la raison du refus.</summary>
public static string EmsJobRefusal( RoleplayPlayer player )
{
if ( player.Jailed )
return "Pas de métier de secours derrière les barreaux.";
var service = Current;
if ( service is null )
return "Le système des secours n'est pas chargé.";
if ( Backend.IsOnline && !service.EmsRosterReceived )
return $"Les effectifs de l'{EmsRules.Short} {EmsRules.Service} ne sont pas encore chargés : réessayez dans quelques secondes.";
if ( service.MedicFor( player ) is null )
return $"EMS est un métier sur liste blanche : seuls les secouristes inscrits aux effectifs de l'{EmsRules.Short} le prennent. Adressez-vous au staff.";
return null;
}
/// <summary>« Paramédic Jane Doe (2001) » : la signature des factures et du journal.</summary>
public static string EmsSignature( RoleplayPlayer medic ) =>
$"{EmsRules.GradeOf( medic.EmsGrade ).Name} {medic.RpName} ({(string.IsNullOrEmpty( medic.EmsBadge ) ? medic.SteamId.ToString() : medic.EmsBadge)})";
// ------------------------------------------------------------------ chaque seconde
/// <summary>Les stabilisations déjà faites sur un inconscient, pendant cette chute (EmsRules.StabilizeMax).</summary>
readonly Dictionary<ulong, int> stabilized = new();
public int StabilizedCount( RoleplayPlayer patient ) => stabilized.GetValueOrDefault( patient.SteamId );
public void NoteStabilized( RoleplayPlayer patient ) => stabilized[patient.SteamId] = StabilizedCount( patient ) + 1;
/// <summary>Hôte, chaque seconde (TickSecond) : synchro des effectifs, fin de service qui s'impose.</summary>
void TickEms()
{
if ( sinceEmsSync >= SyncSeconds && !emsSyncing )
{
sinceEmsSync = 0;
_ = EmsSyncAsync();
}
foreach ( var player in RoleplayPlayer.All.Where( x => x.IsValid() && x.ProfileLoaded ).ToList() )
{
// Plus secouriste (F4, ordre du panneau, renvoi) : fin du service.
if ( player.EmsOnDuty && (player.JobId != EmsRules.JobId || player.EmsGrade < 0) )
EndEmsService( player, "changement de métier" );
// En prison : ni métier de secours, ni service.
if ( player.Jailed && player.JobId == EmsRules.JobId )
{
EndEmsService( player, "en prison", notify: false );
player.JobId = Jobs.DefaultId;
}
if ( !player.Downed )
stabilized.Remove( player.SteamId );
}
}
// ------------------------------------------------------------------ service
/// <summary>Hôte : prise de service au vestiaire de l'hôpital.</summary>
public void StartEmsService( RoleplayPlayer player )
{
if ( player.JobId != EmsRules.JobId || player.EmsGrade < 0 )
{
player.Notify( $"Le vestiaire est réservé aux secouristes de l'{EmsRules.Short}.", "erreur" );
return;
}
if ( player.EmsOnDuty || player.Jailed || player.Cuffed || player.OnTheGround )
return;
if ( !Near( player, ServerSettings.EmsLocker, EmsRules.PointRange * 1.5f ) )
{
player.Notify( "La prise de service se fait au vestiaire de l'hôpital.", "erreur" );
return;
}
player.EmsOnDuty = true;
var grade = EmsRules.GradeOf( player.EmsGrade );
var callsign = string.IsNullOrEmpty( player.EmsCallsign ) ? "" : $", indicatif {player.EmsCallsign}";
player.Notify( $"Prise de service : {grade.Name}{callsign}. Matériel au vestiaire, menu des secours ({Keybinds.DisplayOf( "PoliceMenu" )}).", "info" );
HorizonGame.Current?.AddLog( "ems_service", player.SteamId, $"{EmsSignature( player )} prend son service" );
}
/// <summary>
/// Hôte : fin de service (vestiaire, changement de métier, renvoi, prison, déconnexion). Le
/// matériel non utilisé repris, l'ambulance rendue, l'escorte lâchée.
/// </summary>
public void EndEmsService( RoleplayPlayer player, string reason, bool notify = true )
{
if ( !player.IsValid() )
return;
var was = player.EmsOnDuty;
player.EmsOnDuty = false;
var returned = TakeKit( player );
foreach ( var other in RoleplayPlayer.All.Where( x => x.EscortedBy == player.GameObject.Id ).ToList() )
other.EscortedBy = Guid.Empty;
if ( !was )
return;
ReleaseServiceVehicle( player );
if ( notify )
player.Notify( returned > 0 ? $"Fin de service : matériel rendu ({returned} objet(s))." : "Fin de service.", "info" );
HorizonGame.Current?.AddLog( "ems_service", player.SteamId, $"{EmsSignature( player )} quitte son service ({reason}){(returned > 0 ? $", {returned} objet(s) de matériel rendus" : "")}" );
}
// ------------------------------------------------------------------ matériel
/// <summary>Hôte : le matériel donné au vestiaire, par secouriste, repris en fin de service s'il n'a pas servi.</summary>
readonly Dictionary<ulong, Dictionary<string, int>> issuedKit = new();
/// <summary>Hôte : le matériel du vestiaire (EmsRules.Kit), complété jusqu'à la dotation.</summary>
public void IssueKit( RoleplayPlayer player )
{
if ( !player.IsEms )
return;
if ( !Near( player, ServerSettings.EmsLocker, EmsRules.PointRange * 1.5f ) )
{
player.Notify( "Le matériel est au vestiaire de l'hôpital.", "erreur" );
return;
}
var mine = issuedKit.GetValueOrDefault( player.SteamId ) ?? new();
var given = new List<string>();
foreach ( var kit in EmsRules.Kit.Where( x => player.EmsGrade >= x.MinRank ) )
{
if ( Items.Get( kit.ItemId ) is not { } item )
continue;
// La dotation complète, pas davantage : ce qui est déjà dans ses poches compte.
var held = player.Inventory.Where( x => x.Item == kit.ItemId ).Sum( x => x.Count );
var want = kit.Count - held;
if ( want <= 0 )
continue;
var added = player.AddItem( item, want );
if ( added <= 0 )
continue;
mine[kit.ItemId] = mine.GetValueOrDefault( kit.ItemId ) + added;
given.Add( $"{added} × {item.Name}" );
}
issuedKit[player.SteamId] = mine;
if ( given.Count == 0 )
{
player.Notify( "Votre sac est déjà complet (ou vos poches sont pleines).", "info" );
return;
}
player.Notify( $"Matériel : {string.Join( ", ", given )}.", "info" );
HorizonGame.Current?.AddLog( "ems_materiel", player.SteamId, $"{EmsSignature( player )} prend {string.Join( ", ", given )}" );
}
/// <summary>Hôte : rend le matériel au vestiaire (sans quitter le service).</summary>
public void ReturnKit( RoleplayPlayer player )
{
var count = TakeKit( player );
player.Notify( count > 0 ? $"Matériel rendu ({count} objet(s))." : "Vous n'avez rien à rendre.", "info" );
if ( count > 0 )
HorizonGame.Current?.AddLog( "ems_materiel", player.SteamId, $"{EmsSignature( player )} rend son matériel ({count} objet(s))" );
}
/// <summary>Hôte : reprend le matériel donné et pas encore utilisé. Rend le nombre d'objets repris.</summary>
public int TakeKit( RoleplayPlayer player )
{
if ( !issuedKit.Remove( player.SteamId, out var kit ) )
return 0;
var taken = 0;
foreach ( var (itemId, count) in kit )
{
var left = count;
foreach ( var slot in player.Inventory.Where( x => x.Item == itemId ).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;
}
/// <summary>Hôte : un objet du matériel consommé par un soin (il ne sera plus repris).</summary>
public void NoteKitUsed( RoleplayPlayer player, string itemId )
{
if ( issuedKit.TryGetValue( player.SteamId, out var kit ) && kit.GetValueOrDefault( itemId ) > 0 )
kit[itemId]--;
}
// ------------------------------------------------------------------ accueil de l'hôpital
/// <summary>Un secouriste est-il en service (autre que ce joueur) ? L'accueil ne soigne que sans eux.</summary>
public static bool MedicsOnDuty( RoleplayPlayer except = null ) => RoleplayPlayer.All.Any( x => x.IsEms && x != except );
/// <summary>Hôte : les factures impayées de ce joueur, envoyées à sa machine (accueil de l'hôpital).</summary>
public async Task SendEmsDesk( RoleplayPlayer player )
{
var price = EmsRules.TariffOf( EmsRules.DeskTariff )?.Price ?? 0;
if ( !Backend.IsOnline )
{
player.EmsDeskBills( "[]", null, price, MedicsOnDuty( player ) );
return;
}
var response = await Backend.EmsBills( player.SteamId );
if ( !player.IsValid() )
return;
if ( response?.Ok != true )
{
player.EmsDeskBills( "[]", response?.Error ?? "Le secrétariat ne répond pas : réessayez dans un instant.", price, MedicsOnDuty( player ) );
return;
}
var unpaid = (response.Bills ?? new()).Where( x => x.Unpaid ).ToList();
player.EmsDeskBills( Json.Serialize( unpaid ), null, price, MedicsOnDuty( player ) );
}
/// <summary>Hôte : paie une facture depuis HZ BANK, à l'accueil.</summary>
public async Task PayEmsBillAtDesk( RoleplayPlayer player, long billId )
{
if ( !Backend.IsOnline )
return;
var response = await Backend.EmsBillPay( player.SteamId, billId, 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;
}
player.Bank = Math.Max( 0, response.Bank );
HorizonGame.Current?.SaveNow( player );
player.Notify( $"Facture de soins n°{billId} réglée : -{Format.Money( response.Amount )} (HZ BANK).", "argent" );
HorizonGame.Current?.AddLog( "ems_facture_payee", player.SteamId, $"{player.RpName} paie la facture de soins n°{billId} à l'accueil de l'hôpital" );
await SendEmsDesk( player );
PhoneSystem.Current?.RefreshInvoices( player );
}
readonly HashSet<ulong> deskCaring = new();
/// <summary>
/// Hôte : l'accueil soigne contre paiement (forfait EmsRules.DeskTariff, débité de HZ BANK par
/// l'API), seulement si aucun secouriste n'est en service : sinon, ce sont eux qu'on appelle.
/// </summary>
public async Task DeskCare( RoleplayPlayer player )
{
if ( player.Health >= 100 )
{
player.Notify( "L'infirmière vous examine : vous n'avez besoin d'aucun soin.", "info" );
return;
}
if ( MedicsOnDuty( player ) )
{
player.Notify( "Des secouristes sont en service : appelez le 911 (secours), ils viennent vous soigner.", "erreur" );
return;
}
if ( !deskCaring.Add( player.SteamId ) )
return;
try
{
var price = EmsRules.TariffOf( EmsRules.DeskTariff )?.Price ?? 0;
if ( Backend.IsOnline )
{
var response = await Backend.EmsBillCreate( player.SteamId, new List<string> { EmsRules.DeskTariff }, "Accueil de l'hôpital", "Accueil de l'hôpital", 0, payNow: true, balance: player.Bank );
if ( !player.IsValid() )
return;
if ( response?.Ok != true )
{
player.Notify( response?.Error ?? "Le secrétariat ne répond pas : réessayez dans un instant.", "erreur" );
return;
}
if ( response.Bank is { } bank )
player.Bank = Math.Max( 0, bank );
price = response.Amount;
}
player.Notify( "Les infirmières vous prennent en charge…", "info" );
await GameTask.DelaySeconds( 3f );
if ( !player.IsValid() )
return;
player.Health = 100;
HorizonGame.Current?.SaveNow( player );
player.Notify( Backend.IsOnline ? $"Vous êtes soigné. {Format.Money( price )} débités de votre compte HZ BANK." : "Vous êtes soigné (partie locale : rien n'est facturé).", "argent" );
HorizonGame.Current?.AddLog( "ems_accueil", player.SteamId, $"{player.RpName} soigné à l'accueil de l'hôpital ({Format.Money( price )})" );
}
finally
{
deskCaring.Remove( player.SteamId );
}
}
// ------------------------------------------------------------------ effectifs en jeu
/// <summary>
/// Hôte : un gradé (EmsRules.RankRoster) recrute, promeut, rétrograde ou renvoie le joueur
/// visé. Jamais au-dessus de son propre grade, jamais sur un égal ou un supérieur, jamais sur
/// soi ; l'API refait la même vérification.
/// </summary>
public async Task EmsRosterWrite( RoleplayPlayer chief, RoleplayPlayer target, int? grade )
{
var before = MedicOf( target.SteamId );
var response = await Backend.EmsRoster( target.SteamId, grade, null, EmsSignature( chief ), chief.EmsGrade );
if ( !chief.IsValid() )
return;
if ( response?.Ok != true )
{
chief.Notify( response?.Error ?? "Le secrétariat ne répond pas : effectifs inchangés.", "erreur" );
return;
}
string what;
if ( grade is null )
what = $"{target.RpName} est renvoyé de l'{EmsRules.Short} {EmsRules.Service}.";
else if ( before is null )
what = $"{target.RpName} rejoint l'{EmsRules.Short} {EmsRules.Service} : {EmsRules.GradeOf( grade.Value ).Name}.";
else if ( grade > before.Grade )
what = $"{target.RpName} est promu {EmsRules.GradeOf( grade.Value ).Name}.";
else
what = $"{target.RpName} est rétrogradé {EmsRules.GradeOf( grade.Value ).Name}.";
chief.Notify( what, "info" );
if ( target.IsValid() )
target.Notify( $"{EmsRules.Short} {EmsRules.Service} : {what}", "annonce" );
HorizonGame.Current?.AddLog( "ems_effectifs", target.SteamId, $"{what} Par {EmsSignature( chief )}" );
RequestEmsSync();
}
}