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.
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();
}
}