Police/PoliceService.Garage.cs

Server-side garage for police and EMS fleets in an RP gamemode. Handles the host RPCs that spawn a service vehicle at a free garage spot (checked against rank and fleet), store or release it, and generates plates from the player's badge number.

Networking
namespace HorizonRP;

/// <summary>
/// Le garage de police (chantier « Véhicules de police », 29/10/2026) : un agent en service sort
/// un véhicule de la flotte (PoliceRules.Fleet) selon son grade, depuis le menu ouvert par E au
/// point « police_garage » (UI/PoliceGarageMenu.razor), et le range près du garage.
///
/// - Il apparaît à une place libre parmi les points « police_garage_sorties » (posés en jeu par
///   le staff, hz_settings), à défaut devant le point du garage.
/// - Un seul véhicule de service par agent : en sortir un autre range le premier.
/// - Il n'appartient à personne (OwnerId 0), n'est jamais sauvegardé (VehicleId 0), porte une
///   plaque « UCPD » et le matricule de l'agent, la couleur de la flotte, et reste déverrouillé
///   (les agents montent dans la voiture d'un collègue ; L, à bord, verrouille comme partout).
/// - Il disparaît quand son agent quitte son service ou se déconnecte (Vehicle.HostPolice, ou
///   <see cref="ReleaseServiceVehicle"/> appelé par la fin de service).
///
/// Généralisé aux secours (29/10/2026) : un secouriste de l'UCFD en service sort les ambulances
/// (EmsRules.Fleet) au garage de l'hôpital (« ems_garage », sorties « ems_garage_sorties »), par le
/// même menu (UI/PoliceGarageMenu.razor). Le service se déduit du joueur : il exerce l'un ou
/// l'autre métier, jamais les deux. Le véhicule porte <c>IsEms</c> (gyrophares rouges et blancs).
/// </summary>
public sealed partial class PoliceService
{
	/// <summary>Distance du point du garage pour sortir ou ranger (pouces) : le menu s'ouvre à PointRange, on laisse un pas de marge.</summary>
	public const float GarageUseReach = PoliceRules.PointRange * 2.5f;

	/// <summary>Un véhicule de service se range s'il est à moins de ~30 m du garage ou d'une place de sortie.</summary>
	public const float GarageStoreReach = 1200f;

	/// <summary>Une place de sortie est prise s'il y a un véhicule à moins de ~4 m d'elle.</summary>
	const float GarageSpotClearance = 160f;

	/// <summary>La position demandée par l'aperçu doit être à moins de ~3 m d'une place de sortie.</summary>
	const float GarageSpotTolerance = 120f;

	/// <summary>Les points lus de hz_settings, gardés tant que leur texte ne change pas (clé → (texte, points)).</summary>
	static readonly Dictionary<string, (string Raw, List<ServerSettings.Point> Points)> garagePoints = new();

	static List<ServerSettings.Point> GaragePointsOf( string key )
	{
		var raw = ServerSettings.Raw( key );
		if ( !garagePoints.TryGetValue( key, out var known ) || known.Raw != raw )
		{
			known = (raw, ServerSettings.ParsePoints( raw ));
			// Un point posé depuis la carte du panneau arrive à z 0 et ParsePoints le pose au sol ;
			// lu avant le chargement de la carte, il reste à 0 : on ne le garde pas, on relira.
			if ( known.Points.Any( p => p.Z == 0 ) )
				return known.Points;
			garagePoints[key] = known;
		}
		return known.Points;
	}

	/// <summary>Le point du menu du garage (« police_garage », ou « ems_garage » pour les secours), null s'il n'est pas posé. Lisible partout.</summary>
	public static ServerSettings.Point GaragePointOf( bool ems ) => GaragePointsOf( ems ? EmsRules.PointGarage : PoliceRules.PointGarage ).FirstOrDefault();

	/// <summary>Les places où apparaissent les véhicules (« police_garage_sorties » ou « ems_garage_sorties »).</summary>
	public static IReadOnlyList<ServerSettings.Point> GarageSpawnPointsOf( bool ems ) => GaragePointsOf( ems ? EmsRules.PointsGarageSpawn : PoliceRules.PointsGarageSpawn );

	/// <summary>Le garage de la police (point « police_garage »).</summary>
	public static ServerSettings.Point GaragePoint => GaragePointOf( false );

	/// <summary>Les sorties du garage de la police.</summary>
	public static IReadOnlyList<ServerSettings.Point> GarageSpawnPoints => GarageSpawnPointsOf( false );

	/// <summary>Le joueur est-il au point du garage (de la police, ou des secours), à <paramref name="reach"/> près ?</summary>
	public static bool AtGarage( Vector3 position, float reach = PoliceRules.PointRange, bool ems = false ) =>
		GaragePointOf( ems ) is { } p && p.Position.Distance( position ) <= reach;

	/// <summary>Ce qui change d'un service à l'autre au garage : le sigle, le lieu, le journal.</summary>
	static (string Short, string Place, string Log) GarageService( bool ems ) =>
		ems ? (EmsRules.Short, "le garage de l'hôpital", "ems_vehicule") : (PoliceRules.Short, "le garage du commissariat", "police_vehicule");

	/// <summary>Le véhicule de service confié à cet agent (ou secouriste), ou null.</summary>
	public static Vehicle ServiceVehicleOf( ulong steamId ) =>
		steamId == 0 ? null : Vehicle.All.FirstOrDefault( x => x.IsValid() && x.IsPolice && x.ServiceOfficer == steamId );

	static RoleplayPlayer GarageCaller() =>
		Rpc.Caller is null ? null : RoleplayPlayer.All.FirstOrDefault( x => x.Network.OwnerId == Rpc.Caller.Id );

	/// <summary>
	/// Le menu du garage : sortir ce véhicule de la flotte (vérifié ici : service, grade, place).
	/// <paramref name="position"/> et <paramref name="yaw"/> sont ceux de l'aperçu (UI/PoliceShowroom.cs) :
	/// le véhicule apparaît là où le joueur l'a vu et tourné, si c'est bien une place libre du garage.
	/// </summary>
	[Rpc.Host]
	public static void GarageRequestSpawn( string fleetId, Vector3 position, float yaw )
	{
		var player = GarageCaller();
		if ( player is null || !player.ProfileLoaded )
			return;
		var (ok, message) = SpawnServiceVehicle( player, fleetId, position, yaw );
		player.Notify( message, ok ? "info" : "erreur" );
	}

	/// <summary>Une place de sortie est-elle libre (aucun véhicule à moins de ~4 m, <paramref name="ignore"/> mis à part) ?</summary>
	static bool SpotFree( ServerSettings.Point p, Vehicle ignore ) =>
		!Vehicle.All.Any( v => v.IsValid() && v != ignore && v.WorldPosition.WithZ( 0 ).Distance( p.Position.WithZ( 0 ) ) < GarageSpotClearance );

	/// <summary>
	/// Les places où un véhicule de ce garage peut sortir : les points « …_garage_sorties », ou, s'il
	/// n'y en a aucun, une place devant le point du garage. Vide si le garage n'est pas posé.
	/// </summary>
	static List<ServerSettings.Point> GarageSpots( bool ems )
	{
		var spots = GarageSpawnPointsOf( ems );
		if ( spots.Count > 0 )
			return spots.ToList();
		if ( GaragePointOf( ems ) is not { } garage )
			return new();
		var front = garage.Position + garage.Rotation.Forward * 220f;
		return new() { new ServerSettings.Point( front.x, front.y, front.z, garage.Yaw ) };
	}

	/// <summary>
	/// La place où sortira le prochain véhicule : la première libre (<paramref name="ignore"/> mis à
	/// part : le véhicule de service du joueur, qui rentre au garage), null si toutes sont prises.
	/// Lisible partout : l'aperçu du garage s'y pose (UI/PoliceShowroom.cs), l'hôte y fait sortir.
	/// </summary>
	public static ServerSettings.Point FreeGarageSpot( bool ems, Vehicle ignore = null ) =>
		GarageSpots( ems ).FirstOrDefault( p => SpotFree( p, ignore ) );

	/// <summary>Le sol sous ce point (la route d'une place de sortie), ou le point lui-même s'il n'y a rien dessous.</summary>
	public static Vector3 GroundAt( Vector3 at )
	{
		var start = at + Vector3.Up * 60f;
		var ground = Game.ActiveScene.Trace.Ray( start, start + Vector3.Down * 400f ).WithoutTags( "player", "trigger" ).Run();
		return ground.Hit ? ground.EndPosition : at;
	}

	/// <summary>La plaque que portera le prochain véhicule de service de ce joueur (montrée par le menu du garage).</summary>
	public static string ExpectedPlate( RoleplayPlayer player )
	{
		if ( player is null )
			return "";
		var ems = player.IsEms;
		return ServicePlate( ems ? EmsRules.Short : PoliceRules.Short, ems ? player.EmsBadge : player.PoliceBadge, ServiceVehicleOf( player.SteamId ) );
	}

	/// <summary>Le menu du garage : ranger son véhicule de service.</summary>
	[Rpc.Host]
	public static void GarageRequestStore()
	{
		var player = GarageCaller();
		if ( player is null )
			return;
		var ems = player.IsEms;
		if ( !player.IsCop && !ems )
		{
			player.Notify( "Le garage est réservé aux agents et aux secouristes en service.", "erreur" );
			return;
		}
		var (_, place, log) = GarageService( ems );
		var vehicle = ServiceVehicleOf( player.SteamId );
		if ( vehicle is null )
		{
			player.Notify( "Vous n'avez pas de véhicule de service dehors.", "erreur" );
			return;
		}
		if ( !AtGarage( player.WorldPosition, GarageUseReach, ems ) )
		{
			player.Notify( $"Rangez le véhicule depuis {place}.", "erreur" );
			return;
		}
		var near = AtGarage( vehicle.WorldPosition, GarageStoreReach, ems )
			|| GarageSpawnPointsOf( ems ).Any( p => p.Position.Distance( vehicle.WorldPosition ) <= GarageStoreReach );
		if ( !near )
		{
			player.Notify( $"Ramenez le {vehicle.Plate} à {place} pour le ranger.", "erreur" );
			return;
		}
		HorizonGame.Current?.AddLog( log, player.SteamId, $"{player.RpName} range {vehicle.Plate} ({vehicle.FleetId})" );
		vehicle.Remove();
		player.Notify( "Véhicule de service rangé.", "info" );
	}

	/// <summary>
	/// Hôte : sort un véhicule de la flotte pour cet agent ; (vrai, message) ou (faux, raison).
	/// <paramref name="at"/> / <paramref name="yaw"/> : la place et l'orientation choisies dans
	/// l'aperçu du garage ; une position qui n'est pas près d'une place libre est ignorée (la
	/// première place libre est prise), l'orientation est libre (on tourne la voiture sur place).
	/// </summary>
	public static (bool, string) SpawnServiceVehicle( RoleplayPlayer player, string fleetId, Vector3? at = null, float? yaw = null )
	{
		if ( !Networking.IsHost || player is null )
			return (false, "Garage indisponible.");
		var ems = player.IsEms;
		if ( !player.IsCop && !ems )
			return (false, "Le garage est réservé aux agents et aux secouristes en service.");
		var (shortName, place, log) = GarageService( ems );
		var fleet = ems ? EmsRules.FleetOf( fleetId ) : PoliceRules.FleetOf( fleetId );
		if ( fleet is null )
			return (false, "Ce véhicule n'est pas dans la flotte.");
		var rank = ems ? player.EmsGrade : player.PoliceGrade;
		if ( rank < fleet.MinRank )
			return (false, $"{fleet.Label} : grade de {(ems ? EmsRules.GradeOf( fleet.MinRank ) : PoliceRules.GradeOf( fleet.MinRank )).Name} requis.");
		var model = VehicleCatalog.Get( fleet.ModelId );
		if ( model is null )
			return (false, $"Le modèle « {fleet.ModelId} » n'est pas au catalogue des véhicules.");
		if ( GaragePointOf( ems ) is null )
			return (false, ems ? $"Le garage des secours n'est pas encore posé (point « {EmsRules.PointGarage} »)." : "Le garage de police n'est pas encore posé (point « police_garage »).");
		if ( !AtGarage( player.WorldPosition, GarageUseReach, ems ) )
			return (false, $"Sortez les véhicules de service depuis {place}.");

		// Un seul véhicule de service : le précédent rentre au garage, où qu'il soit. Il compte
		// encore dans Vehicle.All jusqu'à la fin de l'image : on l'écarte des places et des plaques.
		var old = ServiceVehicleOf( player.SteamId );

		// La place vue dans l'aperçu si c'en est bien une, et libre ; sinon la première place libre
		// (sans place posée, devant le point du garage).
		var spots = GarageSpots( ems );
		var chosen = at is { } wanted
			? spots.FirstOrDefault( p => p.Position.WithZ( 0 ).Distance( wanted.WithZ( 0 ) ) <= GarageSpotTolerance && SpotFree( p, old ) )
			: null;
		var spot = chosen ?? spots.FirstOrDefault( p => SpotFree( p, old ) );
		if ( spot is null )
			return (false, "Toutes les places de sortie du garage sont occupées.");
		// L'aperçu tourne la voiture autour de son milieu : son origine bouge un peu autour de la place.
		var origin = chosen is not null && at is { } a ? new Vector3( a.x, a.y, spot.Z ) : spot.Position;
		var facing = chosen is not null && yaw is { } y && float.IsFinite( y ) ? (y % 360f + 360f) % 360f : spot.Yaw;

		if ( old is not null )
		{
			HorizonGame.Current?.AddLog( log, player.SteamId, $"{old.Plate} rangé (remplacé par {fleet.Id})" );
			old.Remove();
		}

		var plate = ServicePlate( shortName, ems ? player.EmsBadge : player.PoliceBadge, old );
		var go = new GameObject( true, ems ? $"Ambulance {plate}" : $"Véhicule de police {plate}" );
		go.WorldPosition = GroundAt( origin ) + Vector3.Up * 24f;
		go.WorldRotation = Rotation.FromYaw( facing );

		var vehicle = go.Components.Create<Vehicle>();
		vehicle.VehicleId = 0;
		vehicle.OwnerId = 0;
		vehicle.OwnerName = shortName;
		vehicle.CatalogId = model.Id;
		vehicle.ColorHex = fleet.Color;
		vehicle.Plate = plate;
		vehicle.Locked = false;
		vehicle.IsPolice = true;
		vehicle.IsEms = ems;
		vehicle.FleetId = fleet.Id;
		vehicle.Lightbar = fleet.Lightbar;
		vehicle.ServiceOfficer = player.SteamId;
		VehicleManager.AddSeats( go, model );
		go.NetworkSpawn();
		go.Network.SetOwnerTransfer( OwnerTransfer.Fixed );

		HorizonGame.Current?.AddLog( log, player.SteamId, $"{player.RpName} sort {fleet.Label} {plate}" );
		return (true, $"{fleet.Label} ({plate}) sortie, ouverte. {Keybinds.DisplayOf( "SirenLights" )} gyrophares, {Keybinds.DisplayOf( "Siren" )} sirène.");
	}

	/// <summary>
	/// Le sigle et le matricule (« UCPD1042 », « UCFD2001 ») : la voiture se reconnaît à son unité.
	/// Sans matricule, ou s'il est déjà pris, un numéro libre (« UCPD007 »). <paramref name="ignore"/> :
	/// le véhicule que le nouveau remplace (sa plaque redevient libre).
	/// </summary>
	static string ServicePlate( string prefix, string badge, Vehicle ignore = null )
	{
		var used = Vehicle.All.Where( x => x.IsValid() && x != ignore ).Select( x => x.Plate ).ToHashSet();
		var digits = new string( (badge ?? "").Where( char.IsDigit ).Take( 4 ).ToArray() );
		if ( digits.Length > 0 && !used.Contains( $"{prefix}{digits}" ) )
			return $"{prefix}{digits}";
		for ( var n = 1; n < 1000; n++ )
		{
			var plate = $"{prefix}{n:000}";
			if ( !used.Contains( plate ) )
				return plate;
		}
		return $"{prefix}{Random.Shared.Int( 1000, 9999 )}";
	}

	/// <summary>
	/// Hôte : rend au garage le véhicule de service de cet agent (fin de service, renvoi,
	/// déconnexion). Vrai s'il en avait un. Ceux qui sont à bord descendent (Vehicle.Remove).
	/// </summary>
	public bool ReleaseServiceVehicle( RoleplayPlayer player )
	{
		if ( !Networking.IsHost || player is null || ServiceVehicleOf( player.SteamId ) is not { } vehicle )
			return false;
		RetireServiceVehicle( vehicle, "fin de service" );
		return true;
	}

	/// <summary>Hôte : un véhicule de service retiré de la ville, journalisé au nom de son agent.</summary>
	public static void RetireServiceVehicle( Vehicle vehicle, string reason )
	{
		if ( !Networking.IsHost || !vehicle.IsValid() )
			return;
		HorizonGame.Current?.AddLog( vehicle.IsEms ? "ems_vehicule" : "police_vehicule", vehicle.ServiceOfficer, $"{vehicle.Plate} rendu au garage ({reason})" );
		vehicle.Remove();
	}
}