Police/PoliceService.Dispatch.cs

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.

Networking
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" );
			}
		}
	}
}