Partial PoliceService class implementing the 911 dispatch system for a roleplay gamemode. Players call the emergency number from the phone, the host records the call, alerts on-duty police or EMS, syncs the call list to all clients as JSON, and stores create/take/close events through the Backend MDT API. Also holds unit radio status (10-8, 10-76, etc.) and the 911 history query. DispatchCall is the call record.
namespace HorizonRP;
/// <summary>
/// Un appel d'urgence (911) : qui appelle, d'où, pour quoi, et qui y répond. Le temps réel est
/// en mémoire chez l'hôte, publié à tous (<see cref="PoliceService.DispatchJson"/>) ; chaque appel
/// est aussi écrit au fichier (hz_dispatch : création, prise, clôture), où l'historique se consulte
/// depuis l'ordinateur et la tablette (« Historique 911 »).
/// </summary>
public sealed class DispatchCall
{
public long Id { get; set; }
/// <summary>Son numéro au fichier (serverDispatchCreate), 0 tant que l'API n'a pas répondu ou si elle est injoignable.</summary>
public long DbId { get; set; }
/// <summary>Le lieu en clair (« Près du Commissariat »), d'après la position transmise.</summary>
public string Location { get; set; }
/// <summary>L'heure de la ville à l'appel (« 14:05 »), et l'instant réel (secondes Unix) pour l'ancienneté.</summary>
public string At { get; set; }
public long CreatedUnix { get; set; }
public ulong CallerSteam { get; set; }
public string CallerName { get; set; }
public string Number { get; set; }
public string Message { get; set; }
/// <summary>Faux si l'appelant n'a pas partagé sa position.</summary>
public bool HasPosition { get; set; }
public float X { get; set; }
public float Y { get; set; }
public float Z { get; set; }
/// <summary>« police » ou « ems » (les secours, métier « medecin »).</summary>
public string Service { get; set; }
/// <summary>« attente », « pris » ou « clos ».</summary>
public string Status { get; set; }
/// <summary>Les unités qui ont pris l'appel (indicatifs, ou noms des secouristes), dans l'ordre.</summary>
public List<string> TakenBy { get; set; } = new();
public List<ulong> TakenBySteam { get; set; } = new();
public string ClosedBy { get; set; }
public long ClosedUnix { get; set; }
// Hôte seul : l'écriture au fichier en cours, et un changement survenu pendant qu'elle courait.
[System.Text.Json.Serialization.JsonIgnore] public bool DbCreating { get; set; }
[System.Text.Json.Serialization.JsonIgnore] public bool DbDirty { get; set; }
[System.Text.Json.Serialization.JsonIgnore] public string DbBy { get; set; }
public Vector3 Position => new( X, Y, Z );
public bool Open => Status != "clos";
public string ServiceName => Service == "ems" ? "Secours médicaux" : "Police";
public string Units => TakenBy is { Count: > 0 } ? string.Join( ", ", TakenBy ) : null;
/// <summary>Minutes écoulées depuis l'appel (horloge réelle).</summary>
public int AgeMinutes => (int)Math.Max( 0, (DateTimeOffset.UtcNow.ToUnixTimeSeconds() - CreatedUnix) / 60 );
}
/// <summary>
/// Le 911 (chantier « Tablette et 911 ») : on compose le numéro d'urgence au téléphone
/// (PoliceRules.EmergencyNumber), on choisit Police ou Secours médicaux, on décrit, on envoie sa
/// position. L'hôte prévient les agents en service (police) ou les EMS (métier « medecin »),
/// qui prennent l'appel depuis la tablette (agents) ou le téléphone (EMS) : le GPS se pose sur
/// le lieu, l'appel porte l'indicatif de qui y va, visible de tous. Clos à la main, ou de
/// lui-même après <see cref="DispatchMinutes"/> minutes.
/// </summary>
public sealed partial class PoliceService
{
/// <summary>Un appel resté ouvert plus longtemps se clôt de lui-même.</summary>
public const int DispatchMinutes = 15;
/// <summary>Un même joueur ne rappelle le 911 qu'après ce délai (secondes).</summary>
public const float EmergencyCooldown = 30f;
public const int EmergencyMessageMax = 300;
/// <summary>Les appels clos restent visibles ce temps-là (secondes), puis disparaissent.</summary>
const int ClosedKeepSeconds = 180;
[Sync( SyncFlags.FromHost )] public string DispatchJson { get; set; }
readonly List<DispatchCall> dispatch = new();
long nextDispatchId;
bool dispatchLoop;
static readonly Dictionary<ulong, RealTimeSince> lastEmergency = new();
string parsedDispatch;
List<DispatchCall> dispatchView = new();
/// <summary>Les appels en cours, lus sur toutes les machines (le plus récent en tête).</summary>
public IReadOnlyList<DispatchCall> Dispatch
{
get
{
if ( Networking.IsHost )
return dispatch;
if ( parsedDispatch != DispatchJson )
{
parsedDispatch = DispatchJson;
dispatchView = string.IsNullOrEmpty( DispatchJson ) ? new() : Json.Deserialize<List<DispatchCall>>( DispatchJson ) ?? new();
}
return dispatchView;
}
}
/// <summary>
/// Peut répondre aux appels de ce service : agent en service (police), secouriste de l'UCFD en
/// service (EMS, Police/PoliceService.Ems.cs) — hors service, ni l'un ni l'autre n'est alerté.
/// </summary>
public static bool CanAnswer( RoleplayPlayer player, string service ) =>
player is not null && (service == "ems" ? player.IsEms : player.IsCop);
/// <summary>Ce qui s'affiche d'une unité qui prend l'appel : son indicatif, sinon son nom.</summary>
static string UnitName( RoleplayPlayer player ) =>
player.IsCop && !string.IsNullOrWhiteSpace( player.PoliceCallsign ) ? player.PoliceCallsign
: player.IsEms && !string.IsNullOrWhiteSpace( player.EmsCallsign ) ? player.EmsCallsign
: player.RpName;
void PublishDispatch() => DispatchJson = Json.Serialize( dispatch );
static RoleplayPlayer DispatchCaller() =>
Rpc.Caller is null ? null : RoleplayPlayer.All.FirstOrDefault( x => x.Network.OwnerId == Rpc.Caller.Id );
// Appeler
/// <summary>
/// Le téléphone envoie un appel au 911. La position est celle du corps du joueur chez l'hôte,
/// jamais une valeur du client.
/// </summary>
[Rpc.Host]
public static void RequestEmergency( string service, string message, bool sharePosition )
{
var player = DispatchCaller();
var system = Current;
if ( player is null || system is null || !player.ProfileLoaded )
return;
service = service == "ems" ? "ems" : "police";
message = (message ?? "").Trim();
if ( message.Length > EmergencyMessageMax )
message = message[..EmergencyMessageMax];
if ( message.Length == 0 )
{
player.Notify( "911 : décrivez la situation en quelques mots.", "erreur" );
system.SendEmergencyResult( player, false, 0 );
return;
}
if ( lastEmergency.TryGetValue( player.SteamId, out var since ) && since < EmergencyCooldown )
{
player.Notify( $"911 : votre appel est déjà transmis. Patientez {Math.Ceiling( EmergencyCooldown - since )} s avant de rappeler.", "erreur" );
system.SendEmergencyResult( player, false, 0 );
return;
}
lastEmergency[player.SteamId] = 0;
var at = player.WorldPosition;
var call = new DispatchCall
{
Id = ++system.nextDispatchId,
At = GameClock.Text,
CreatedUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
CallerSteam = player.SteamId,
CallerName = player.RpName,
Number = player.PhoneNumber,
Message = message,
HasPosition = sharePosition,
X = sharePosition ? at.x : 0,
Y = sharePosition ? at.y : 0,
Z = sharePosition ? at.z : 0,
Service = service,
Status = "attente",
Location = sharePosition ? LocationOf( at ) : "Position non transmise",
};
system.dispatch.Insert( 0, call );
// Une liste bornée : au-delà, les plus anciens appels clos puis ouverts partent.
while ( system.dispatch.Count > 40 )
system.dispatch.RemoveAt( system.dispatch.Count - 1 );
system.PublishDispatch();
system.StartDispatchLoop();
// Les unités du service, et elles seules : notification et tonalité d'alerte.
var responders = RoleplayPlayer.All.Where( x => CanAnswer( x, service ) && x != player ).ToList();
var text = $"911 · {call.ServiceName} n°{call.Id} : {call.CallerName} — « {message} »";
foreach ( var unit in responders )
if ( unit.Network.Owner is { } connection )
using ( Rpc.FilterInclude( connection ) )
ToDispatchAlert( text );
player.Notify( responders.Count > 0
? $"911 : appel transmis ({call.ServiceName}). Restez où vous êtes si vous le pouvez."
: $"911 : appel enregistré, mais aucune unité ({call.ServiceName}) n'est disponible pour le moment.", "info" );
system.SendEmergencyResult( player, true, call.Id );
HorizonGame.Current?.AddLog( "appel_911", player.SteamId, $"{call.ServiceName} n°{call.Id} : {message}{(sharePosition ? "" : " (sans position)")}" );
_ = DispatchCreateAsync( call, sharePosition ? at : null );
}
void SendEmergencyResult( RoleplayPlayer player, bool ok, long id )
{
if ( player.Network.Owner is not { } connection )
return;
using ( Rpc.FilterInclude( connection ) )
ToEmergencySent( ok, id );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
static void ToEmergencySent( bool ok, long id )
{
PhoneClient.EmergencyOk = ok;
PhoneClient.EmergencyId = id;
PhoneClient.EmergencySerial++;
PhoneClient.Version++;
}
/// <summary>Chez une unité : l'alerte d'un nouvel appel (bandeau du HUD et tonalité).</summary>
[Rpc.Broadcast( NetFlags.HostOnly )]
static void ToDispatchAlert( string text )
{
HudNotifications.Push( text, "annonce" );
// Le double bip du central, par-dessus le carillon de l'annonce : on ne le confond pas.
Sounds.Play( "verrou", 0.6f );
}
// Répondre
/// <summary>Une unité prend l'appel : son indicatif s'ajoute à l'appel (plusieurs unités peuvent y aller).</summary>
[Rpc.Host]
public static void RequestTakeCall( long id )
{
var player = DispatchCaller();
var system = Current;
var call = system?.dispatch.FirstOrDefault( x => x.Id == id );
if ( player is null || call is null )
return;
if ( !CanAnswer( player, call.Service ) )
{
player.Notify( call.Service == "ems" ? "Seuls les secouristes en service prennent les appels de secours." : "Seuls les agents en service prennent les appels de police.", "erreur" );
return;
}
if ( !call.Open )
{
player.Notify( $"L'appel n°{id} est clos.", "erreur" );
return;
}
if ( call.TakenBySteam.Contains( player.SteamId ) )
return;
var unit = UnitName( player );
call.TakenBy.Add( unit );
call.TakenBySteam.Add( player.SteamId );
call.Status = "pris";
system.PublishDispatch();
if ( player.IsCop )
player.UnitStatus = UnitEnRoute;
DispatchSave( call, $"{unit} ({player.SteamId})" );
// L'appelant sait qu'on vient ; les autres unités voient qui y va (liste publiée).
RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == call.CallerSteam )?
.Notify( call.Service == "ems" ? $"911 : les secours sont en route ({unit})." : $"911 : une unité est en route ({unit}).", "info" );
HorizonGame.Current?.AddLog( "appel_911_pris", call.CallerSteam, $"{call.ServiceName} n°{call.Id} pris par {unit} ({player.SteamId})" );
}
[Rpc.Host]
public static void RequestCloseCall( long id )
{
var player = DispatchCaller();
var system = Current;
var call = system?.dispatch.FirstOrDefault( x => x.Id == id );
if ( player is null || call is null || !call.Open )
return;
if ( !CanAnswer( player, call.Service ) )
{
player.Notify( "Seule une unité du service peut clore cet appel.", "erreur" );
return;
}
call.Status = "clos";
call.ClosedBy = UnitName( player );
call.ClosedUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
system.PublishDispatch();
system.ReleaseUnits( call );
DispatchSave( call, $"{call.ClosedBy} ({player.SteamId})" );
player.Notify( $"Appel n°{id} clos.", "info" );
HorizonGame.Current?.AddLog( "appel_911_clos", call.CallerSteam, $"{call.ServiceName} n°{call.Id} clos par {call.ClosedBy} ({player.SteamId})" );
}
// Ménage : clôture d'office et retrait des appels clos
void StartDispatchLoop()
{
if ( dispatchLoop )
return;
_ = DispatchLoopAsync();
}
/// <summary>
/// Une boucle qui ne tourne que tant qu'il y a des appels : pas d'OnFixedUpdate ici, il
/// appartient au fichier principal de PoliceService (un second dans un partial ne compilerait pas).
/// </summary>
async Task DispatchLoopAsync()
{
dispatchLoop = true;
try
{
while ( this.IsValid() && dispatch.Count > 0 )
{
await GameTask.Delay( 5_000 );
if ( !this.IsValid() )
return;
ExpireDispatch();
}
}
finally
{
dispatchLoop = false;
}
}
void ExpireDispatch()
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var changed = false;
foreach ( var call in dispatch.Where( x => x.Open && now - x.CreatedUnix > DispatchMinutes * 60 ) )
{
call.Status = "clos";
call.ClosedBy = "Clôture automatique";
call.ClosedUnix = now;
ReleaseUnits( call );
DispatchSave( call, call.ClosedBy );
changed = true;
}
DetectArrivals();
changed |= dispatch.RemoveAll( x => !x.Open && now - x.ClosedUnix > ClosedKeepSeconds ) > 0;
if ( changed )
PublishDispatch();
}
// ------------------------------------------------------------------ le fichier des appels
/// <summary>
/// Le lieu d'un appel en clair : le lieu connu le plus proche (commissariat, hôpital, garages,
/// distributeurs) s'il est à moins de 400 m, sinon les coordonnées. Écrit au fichier : un
/// historique se relit sans carte.
/// </summary>
public static string LocationOf( Vector3 at )
{
var place = Gps.Places( at ).FirstOrDefault();
if ( place is not null )
{
var meters = place.Position.WithZ( 0 ).Distance( at.WithZ( 0 ) ) * 0.0254f;
if ( meters < 40 )
return place.Name;
if ( meters < 400 )
return $"À {Gps.FormatDistance( meters )} de : {place.Name}";
}
return $"Union City ({at.x:0}, {at.y:0})";
}
/// <summary>Hôte : l'appel entre au fichier ; s'il a changé pendant l'écriture, son état suit aussitôt.</summary>
static async Task DispatchCreateAsync( DispatchCall call, Vector3? at )
{
if ( !Backend.IsOnline )
return;
call.DbCreating = true;
try
{
var response = await Backend.MdtDispatchCreate( call.Service, call.CallerSteam, call.CallerName, call.Number, call.Message, at, call.Location );
if ( response?.Ok == true && response.DispatchId > 0 )
call.DbId = response.DispatchId;
else
Log.Warning( $"[Horizon] 911 n°{call.Id} non écrit au fichier : {response?.Error ?? "API injoignable"}" );
}
finally
{
call.DbCreating = false;
}
if ( call.DbId > 0 && call.DbDirty )
{
call.DbDirty = false;
await DispatchUpdateAsync( call, call.DbBy );
}
Current?.PublishDispatch();
}
/// <summary>Hôte : la prise, l'ajout d'une unité ou la clôture partent au fichier.</summary>
static void DispatchSave( DispatchCall call, string by )
{
call.DbBy = by;
if ( call.DbCreating )
{
// La création n'a pas encore répondu : la mise à jour partira derrière elle.
call.DbDirty = true;
return;
}
if ( call.DbId > 0 )
_ = DispatchUpdateAsync( call, by );
}
static async Task DispatchUpdateAsync( DispatchCall call, string by )
{
var response = await Backend.MdtDispatchUpdate( call.DbId, call.Status, call.TakenBy.Take( 12 ).ToList(), by );
if ( response?.Ok != true )
Log.Warning( $"[Horizon] 911 n°{call.Id} (fichier {call.DbId}) : mise à jour refusée — {response?.Error ?? "API injoignable"}" );
}
/// <summary>La tablette ou l'ordinateur demande une page de l'historique du 911.</summary>
[Rpc.Host]
public static void MdtRequestHistory( string service, string status, string query, int page )
{
var cop = MdtCaller();
if ( cop is null )
return;
service = service is "police" or "ems" ? service : "";
status = status is "ouvert" or "pris" or "clos" ? status : "";
_ = MdtHistoryAsync( cop, service, status, MdtClip( query, 64 ), Math.Clamp( page, 1, 500 ) );
}
static async Task MdtHistoryAsync( RoleplayPlayer cop, string service, string status, string query, int page )
{
var response = await Backend.MdtDispatchHistory( service, status, query, page );
var json = response?.Ok == true ? Json.Serialize( response.Calls ?? new() ) : null;
var error = response is null ? "Le fichier ne répond pas." : response.Ok ? null : response.Error;
var key = $"{service}|{status}|{query}|{page}";
MdtSend( cop, () => MdtToHistory( key, json, response?.Total ?? 0, response?.PerPage ?? 0, error ) );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
static void MdtToHistory( string key, string json, int total, int perPage, string error )
{
PoliceTabletClient.HistoryPerPage = perPage;
PoliceTabletClient.History = json is null ? null : Json.Deserialize<List<Backend.MdtDispatchRow>>( json ) ?? new();
PoliceTabletClient.HistoryFor = key;
PoliceTabletClient.HistoryTotal = total;
PoliceTabletClient.HistoryError = error;
PoliceTabletClient.Version++;
}
// ------------------------------------------------------------------ statut des unités
/// <summary>Le statut radio d'une unité, comme sur les ordinateurs de bord américains (code 10-8, 10-97…).</summary>
public sealed record UnitState( string Id, string Name, string Code, string Icon, string Color );
public const string UnitAvailable = "disponible";
public const string UnitEnRoute = "en_route";
public const string UnitOnScene = "sur_place";
public const string UnitBusy = "occupe";
public static UnitState[] UnitStates => new UnitState[]
{
new( UnitAvailable, "Disponible", "10-8", "check_circle", "#22c55e" ),
new( UnitEnRoute, "En route", "10-76", "directions_car", "#3b82f6" ),
new( UnitOnScene, "Sur place", "10-97", "place", "#f59e0b" ),
new( UnitBusy, "Occupé", "10-6", "do_not_disturb_on", "#ef4444" ),
};
/// <summary>Le statut d'un agent (un inconnu ou un vide vaut « Disponible »).</summary>
public static UnitState UnitStateOf( RoleplayPlayer cop ) =>
UnitStates.FirstOrDefault( x => x.Id == cop?.UnitStatus ) ?? UnitStates[0];
/// <summary>L'ordinateur de bord (ou la tablette) change le statut de l'unité : vu de tous les agents.</summary>
[Rpc.Host]
public static void RequestUnitStatus( string status )
{
var player = DispatchCaller();
if ( player is null || !player.IsCop || UnitStates.All( x => x.Id != status ) )
return;
if ( player.UnitStatus == status )
return;
player.UnitStatus = status;
HorizonGame.Current?.AddLog( "police_statut", player.SteamId, $"{UnitName( player )} : {UnitStateOf( player ).Name}" );
}
/// <summary>Hôte : l'appel est clos, ses unités redeviennent disponibles (sauf celles prises par un autre appel).</summary>
void ReleaseUnits( DispatchCall call )
{
foreach ( var steam in call.TakenBySteam )
{
var unit = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steam );
if ( unit is null || !unit.IsCop || unit.UnitStatus is not (UnitEnRoute or UnitOnScene) )
continue;
if ( dispatch.Any( x => x != call && x.Open && x.TakenBySteam.Contains( steam ) ) )
continue;
unit.UnitStatus = UnitAvailable;
}
}
/// <summary>Distance à plat où une unité en route passe d'elle-même « sur place » (pouces, ~40 m).</summary>
const float ArrivalRadius = 1600f;
/// <summary>Hôte, toutes les 5 s tant qu'il y a des appels : une unité en route arrivée sur les lieux passe « sur place ».</summary>
void DetectArrivals()
{
foreach ( var call in dispatch.Where( x => x.Open && x.HasPosition ) )
{
foreach ( var steam in call.TakenBySteam )
{
var unit = RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steam );
if ( unit is null || !unit.IsCop || unit.UnitStatus != UnitEnRoute )
continue;
if ( unit.WorldPosition.WithZ( 0 ).Distance( call.Position.WithZ( 0 ) ) > ArrivalRadius )
continue;
unit.UnitStatus = UnitOnScene;
unit.Notify( $"Sur place : appel n°{call.Id} ({call.Location ?? "lieu de l'appel"}).", "info" );
}
}
}
}