diff --git a/.github/SECURITY.md b/.github/SECURITY.md index c4712cd..df6d89e 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -18,7 +18,7 @@ Only the latest release on the `main` branch receives security fixes. Older tags Report discovered vulnerabilities privately by emailing: ``` -fiveprsteam@gmail.com +hey@codemeapixel.dev ``` Include as much of the following as possible: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6398875..4490109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.9.0-ptb.3] - 2026-10-10 + +### Added +- Fire & Rescue (LSFD): go on duty at fire stations with turnout gear, an extinguisher and a fire engine +- Fire callouts: Vehicle Fire and Brush Fire, with real fires that spread until they're put out +- EMS (SAMS): go on duty at hospitals with a medic uniform and an ambulance +- EMS callouts: Medical Emergency and Traffic Collision, where you treat the patient, load them into the ambulance and transport them to the nearest hospital +- Death and revive: downed players wait for EMS, who get an automatic 911 call and can revive them for XP; after a timer, players can respawn at a hospital. `fprs_revive` for admins; settings under `death` +- `config/fire_loadouts.json`, `fire_vehicles.json`, `ems_loadouts.json` and `ems_vehicles.json` configure each department's equipment and vehicles +- Traffic stops: pull over any AI-driven vehicle with your lights on, then ask for ID, run the plate, search, warn, cite, serve warrants, arrest or release +- AI drivers and their vehicles get generated identities that are saved to Records the first time they're checked +- `config/citations.json` sets the offences and fines offered during a traffic stop +- The server publishes its FivePRS version as the `fiveprs_version` server variable, visible in the server list and `info.json` +- Wanted levels and GTA's own AI police, ambulance and fire response are turned off by default, configurable under `world` in `config/settings.json`, with an option to stop ambient AI police spawning +- Pedestrian stops: face anyone on foot and press `E` to stop them, with the same ID, search, citation and arrest actions + +### Fixed +- FivePRS no longer fails after the database starts (`Argument at index 0 was null`): it reads its resource name once at startup, and database calls hand control back to the server's main thread +- Setting `fiveprs_db_connection` to a MySQL connection string while `fiveprs_db_type` is `sqlite` (or the reverse, an empty connection with `mysql`) now explains the mismatch +- MySQL and MariaDB connections no longer fail with `NotImplementedException` when the database offers TLS; FivePRS connects without TLS, which FiveM's runtime can't do +- A resource folder the server can't write to now logs a clear message instead of an unhandled exception +- FivePRS now starts on GTA V Enhanced servers, which reject assemblies that load native code. Enhanced servers need a MySQL or MariaDB database, since SQLite needs a native library + +### Changed +- Police, Fire and EMS share one department module base; police gear and vehicle config types are now `DepartmentLoadoutsConfig` and `DepartmentVehiclesConfig` +- Server console errors are one line with the cause; set `fiveprs_debug true` for full details +- Common database problems (missing database, wrong login, no access, server unreachable) are explained in plain language with the fix + ## [0.9.0-ptb.2] - 2026-10-10 ### Added @@ -48,10 +76,11 @@ The first public test build. - Validation of callout catalogs sent by clients, with XP rewards capped at `fiveprs_max_xp` ### Known issues -- Fire and EMS can be joined, but have no callouts or department tools yet; police is the playable department +- Fire and EMS aren't available yet; police is the only department you can join - Only Mission Row has armory, locker room and garage points - Back up your database before updating between test builds -[Unreleased]: https://github.com/FivePRS/core/compare/v0.9.0-ptb.2...HEAD +[Unreleased]: https://github.com/FivePRS/core/compare/v0.9.0-ptb.3...HEAD +[0.9.0-ptb.3]: https://github.com/FivePRS/core/compare/v0.9.0-ptb.2...v0.9.0-ptb.3 [0.9.0-ptb.2]: https://github.com/FivePRS/core/compare/v0.9.0-ptb.1...v0.9.0-ptb.2 [0.9.0-ptb.1]: https://github.com/FivePRS/core/releases/tag/v0.9.0-ptb.1 diff --git a/FivePRS.Client/Agency/DepartmentAgency.cs b/FivePRS.Client/Agency/DepartmentAgency.cs new file mode 100644 index 0000000..4b7066c --- /dev/null +++ b/FivePRS.Client/Agency/DepartmentAgency.cs @@ -0,0 +1,170 @@ +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Client.Callouts; +using FivePRS.Client.Gear; +using FivePRS.Client.Loadout; +using FivePRS.Client.Stations; +using FivePRS.Client.VehicleSpawner; +using FivePRS.Core.Config; +using FivePRS.Core.Models; + +namespace FivePRS.Client.Agency +{ + public abstract class DepartmentAgency : BaseAgency + { + private readonly CalloutRegistry _registry; + private readonly CalloutDispatcher _dispatcher; + + private StationDef? _arrivalStation; + private PatrolVehicleConfig? _arrivalVehicle; + + protected DepartmentAgency() + { + _registry = new CalloutRegistry(); + _registry.Discover(GetType().Assembly); + + _dispatcher = new CalloutDispatcher(Department, _registry, OnCalloutEnded); + } + + protected abstract DepartmentGear Gear { get; } + + protected virtual string EquipmentNoun => "uniform, gear and vehicle"; + + public override StationCatalog? BuildCatalog(PlayerData player) => Gear.BuildCatalog(player.Agency); + + public override async Task OnDuty(PlayerData player) + { + await base.OnDuty(player); + + _registry.DiscoverAll(); + Debug.WriteLine($"[{AgencyName}] {_registry.Count} callout(s) registered."); + _dispatcher.Start(); + + var mode = StationService.TakePendingStart(out var station); + var message = ShouldEquip(mode) + ? await EquipAsync(player, mode, station) + : await ReportToStationAsync(mode, station); + + Notify($"~b~{DisplayName}~w~ | ~g~ON DUTY~w~~n~{message}"); + } + + public override async Task OffDuty(PlayerData player) + { + await base.OffDuty(player); + + _dispatcher.Stop(); + StopWaitingForArrival(); + Vehicles.Despawn(); + await LoadoutManager.StripAsync(); + + Notify($"~b~{DisplayName}~w~ | ~r~OFF DUTY~w~. Gear and vehicle removed."); + } + + public override async Task OnCalloutReceived(CalloutData callout) + { + await _dispatcher.HandleOfferAsync(callout); + } + + private static bool ShouldEquip(DutyStartMode mode) => ConfigManager.Settings.DutyEquipment switch + { + DutyEquipmentMode.Always => true, + DutyEquipmentMode.Station => false, + _ => mode == DutyStartMode.Here, + }; + + private async Task EquipAsync(PlayerData player, DutyStartMode mode, StationDef? station) + { + if (mode == DutyStartMode.Teleport && station is not null) + await StationService.TeleportAsync(station); + + var loadout = Gear.LoadoutForRank(player.Rank, player.Agency); + await LoadoutManager.ApplyAsync(loadout); + + var vehicleConfig = Gear.VehicleForRank(player.Rank, player.Agency); + return $"{loadout.Name} loadout applied.~n~{await SpawnShiftVehicleAsync(player, mode, station, vehicleConfig)}"; + } + + private async Task ReportToStationAsync(DutyStartMode mode, StationDef? station) + { + if (mode == DutyStartMode.Drive && station is not null) + { + StationService.SetRoute(station); + return $"Head to ~y~{station.Name}~w~ to collect your {EquipmentNoun}."; + } + + if (mode == DutyStartMode.Teleport && station is not null) + { + await StationService.TeleportAsync(station); + return $"Collect your {EquipmentNoun} at the station."; + } + + return $"Visit one of your stations to collect your {EquipmentNoun}."; + } + + private async Task SpawnShiftVehicleAsync(PlayerData player, DutyStartMode mode, StationDef? station, PatrolVehicleConfig vehicleConfig) + { + if (mode == DutyStartMode.Drive && station is not null) + { + StationService.SetRoute(station); + _arrivalStation = station; + _arrivalVehicle = vehicleConfig; + Tick += WaitForArrivalTick; + return $"Head to ~y~{station.Name}~w~. Your vehicle is waiting there."; + } + + if (mode == DutyStartMode.Teleport && station is not null) + return VehicleMessage(await Vehicles.SpawnAsync(vehicleConfig, StationService.ParkingSpot(station), false)); + + var nearby = StationService.Nearby(Department, player.Agency); + var vehicle = nearby is not null + ? await Vehicles.SpawnAsync(vehicleConfig, StationService.ParkingSpot(nearby), false) + : await Vehicles.SpawnAsync(vehicleConfig, PatrolVehicleSpawner.RoadSpawnPoint(), true); + + return VehicleMessage(vehicle); + } + + private async Task WaitForArrivalTick() + { + if (_arrivalStation is null || _arrivalVehicle is null || !IsActive) + { + StopWaitingForArrival(); + return; + } + + if (StationService.DistanceTo(_arrivalStation) > StationService.ArrivalRadius) + { + await Delay(1000); + return; + } + + var station = _arrivalStation; + var config = _arrivalVehicle; + StopWaitingForArrival(); + + var vehicle = await Vehicles.SpawnAsync(config, StationService.ParkingSpot(station), false); + Notify($"~b~{station.Name}~w~ | {VehicleMessage(vehicle)}"); + } + + private void StopWaitingForArrival() + { + Tick -= WaitForArrivalTick; + _arrivalStation = null; + _arrivalVehicle = null; + } + + private static string VehicleMessage(CitizenFX.Core.Vehicle? vehicle) => + vehicle is not null + ? "Your vehicle is marked on the map." + : "~r~Vehicle spawn failed~w~ — proceed on foot."; + + private void OnCalloutEnded(CalloutBase callout, CalloutResult result) + { + if (result == CalloutResult.Completed) + Notify("~g~CALLOUT COMPLETE~w~"); + else if (result == CalloutResult.Failed) + Notify("~r~CALLOUT FAILED~w~ | No XP awarded."); + + Debug.WriteLine($"[{AgencyName}] Callout '{callout.Data.Name}' ended: {result}"); + } + } +} diff --git a/FivePRS.Client/Binds.cs b/FivePRS.Client/Binds.cs index 147f8bc..d1fa5d3 100644 --- a/FivePRS.Client/Binds.cs +++ b/FivePRS.Client/Binds.cs @@ -13,6 +13,7 @@ public static class Binds public static string Cuff => For(KeyCommands.Cuff); public static string Uncuff => For(KeyCommands.Uncuff); public static string Escort => For(KeyCommands.Escort); + public static string TrafficStop => For(KeyCommands.TrafficStop); public static string For(string command) => $"~INPUT_{(uint)API.GetHashKey(command) | 0x80000000:X8}~"; diff --git a/FivePRS.Client/Callouts/CalloutBase.cs b/FivePRS.Client/Callouts/CalloutBase.cs index 767a312..e254ba5 100644 --- a/FivePRS.Client/Callouts/CalloutBase.cs +++ b/FivePRS.Client/Callouts/CalloutBase.cs @@ -63,6 +63,8 @@ public virtual void OnCalloutDeclined() { } public virtual void OnCalloutFailed() { } + protected virtual void OnCleanup() { } + public virtual Vector3 GetDispatchLocation() => new(Data.LocationX, Data.LocationY, Data.LocationZ); @@ -174,6 +176,15 @@ protected async Task FootPursuitAsync(Ped suspect, float catchDistM, float escap internal void Cleanup(bool releaseToWorld = false) { + try + { + OnCleanup(); + } + catch (Exception ex) + { + Debug.WriteLine($"[CalloutBase] Scene cleanup failed: {ex.Message}"); + } + foreach (var entity in _trackedEntities) { try diff --git a/FivePRS.Client/ClientBrain.cs b/FivePRS.Client/ClientBrain.cs index 47b853f..3270f94 100644 --- a/FivePRS.Client/ClientBrain.cs +++ b/FivePRS.Client/ClientBrain.cs @@ -25,10 +25,16 @@ public ClientBrain() var res = API.GetCurrentResourceName(); ConfigManager.LoadSettings( API.LoadResourceFile(res, "config/settings.json")); - ConfigManager.LoadPoliceVehicles( API.LoadResourceFile(res, "config/police_vehicles.json")); - ConfigManager.LoadPoliceLoadouts( API.LoadResourceFile(res, "config/police_loadouts.json")); + foreach (var department in ConfigManager.Departments) + { + var name = ConfigManager.ConfigName(department); + ConfigManager.LoadDepartment(department, + API.LoadResourceFile(res, $"config/{name}_loadouts.json"), + API.LoadResourceFile(res, $"config/{name}_vehicles.json")); + } ConfigManager.LoadJurisdictions( API.LoadResourceFile(res, "config/jurisdictions.json")); ConfigManager.LoadStations( API.LoadResourceFile(res, "config/stations.json")); + ConfigManager.LoadCitations( API.LoadResourceFile(res, "config/citations.json")); EventHandlers[EventNames.ClientReceivePlayerData] += new Action(OnReceivePlayerData); EventHandlers[EventNames.ClientDutyStatusChanged] += new Action(OnDutyStatusChanged); diff --git a/FivePRS.Client/DeathManager.cs b/FivePRS.Client/DeathManager.cs new file mode 100644 index 0000000..e7dc3be --- /dev/null +++ b/FivePRS.Client/DeathManager.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.Stations; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using Newtonsoft.Json; + +namespace FivePRS.Client +{ + public class DeathManager : BaseScript + { + private const string DownedDict = "dead"; + private const string DownedClip = "dead_a"; + private const int SettleMs = 1_000; + private const int SpawnGraceMs = 15_000; + private const int RespawnControl = 38; + private const int MaxHealth = 200; + private const int DownedHealth = 150; + + private static readonly int[] AllowedControls = { 1, 2, 245, 249, 199, 200 }; + private static readonly HashSet Downed = new(); + + private readonly int _startedAt = API.GetGameTimer(); + private bool _spawned; + private bool _down; + private int _downAt; + + public DeathManager() + { + EventHandlers["playerSpawned"] += new Action(() => _spawned = true); + EventHandlers[EventNames.ClientPlayerDowned] += new Action(OnPlayerDowned); + EventHandlers[EventNames.ClientDownedPlayers] += new Action(OnDownedPlayers); + EventHandlers[EventNames.ClientRevived] += new Action(OnRevived); + EventHandlers["onClientResourceStop"] += new Action(name => + { + if (name == API.GetCurrentResourceName() && _down) GetUp(); + }); + + Tick += WatchTick; + } + + public static bool IsDowned(int serverId) => Downed.Contains(serverId); + + public static IEnumerable DownedPlayers => Downed; + + private static DeathSettings Settings => ConfigManager.Settings.Death; + + private async Task WatchTick() + { + await Delay(250); + if (!Settings.Enabled || _down) return; + + var ready = _spawned || (API.GetGameTimer() - _startedAt > SpawnGraceMs && API.IsScreenFadedIn()); + if (ready && Game.PlayerPed.IsDead) + await GoDownAsync(); + } + + private async Task GoDownAsync() + { + _down = true; + await Delay(SettleMs); + + var ped = Game.PlayerPed; + var position = ped.Position; + API.NetworkResurrectLocalPlayer(position.X, position.Y, position.Z, ped.Heading, true, false); + await Delay(0); + + ped = Game.PlayerPed; + ped.Health = DownedHealth; + ped.IsInvincible = true; + await PlayDownedAsync(ped); + + _downAt = API.GetGameTimer(); + ClientEvents.TriggerServer(EventNames.ServerPlayerDowned); + Tick += DownedTick; + } + + private async Task DownedTick() + { + API.DisableAllControlActions(0); + foreach (var control in AllowedControls) + API.EnableControlAction(0, control, true); + + var ped = Game.PlayerPed; + if (!API.IsEntityPlayingAnim(ped.Handle, DownedDict, DownedClip, 3)) + await PlayDownedAsync(ped); + + var remaining = Settings.RespawnSeconds - (API.GetGameTimer() - _downAt) / 1000; + if (remaining > 0) + { + ClientBrain.ShowHelp($"You're unconscious. EMS has been notified.~n~You can respawn in {remaining / 60}:{remaining % 60:00}", 0); + return; + } + + ClientBrain.ShowHelp("You're unconscious. EMS has been notified.~n~Press ~INPUT_CONTEXT~ to respawn at the hospital", 0); + if (API.IsDisabledControlJustPressed(0, RespawnControl)) + await RespawnAsync(); + } + + private async Task RespawnAsync() + { + var hospital = Hospitals.Nearest(Game.PlayerPed.Position); + GetUp(); + ClientEvents.TriggerServer(EventNames.ServerPlayerRespawned); + + if (hospital is not null) + await Teleporter.ToAsync(StationService.PositionOf(hospital), hospital.Heading); + + ClientBrain.ShowNotification("You were treated at the hospital.", "~b~EMS"); + } + + private void OnRevived(string by) + { + if (!_down) return; + GetUp(); + ClientBrain.ShowNotification($"You were revived by {by}.", "~b~EMS"); + } + + private void GetUp() + { + Tick -= DownedTick; + _down = false; + + var ped = Game.PlayerPed; + ped.Task.ClearAll(); + ped.IsInvincible = false; + ped.Health = MaxHealth; + } + + private static void OnPlayerDowned(int serverId, bool downed) + { + if (downed) Downed.Add(serverId); + else Downed.Remove(serverId); + } + + private static void OnDownedPlayers(string json) + { + Downed.Clear(); + foreach (var id in JsonConvert.DeserializeObject>(json) ?? new List()) + Downed.Add(id); + } + + private static async Task PlayDownedAsync(Ped ped) + { + API.RequestAnimDict(DownedDict); + for (var i = 0; i < 100 && !API.HasAnimDictLoaded(DownedDict); i++) + await Delay(50); + + await ped.Task.PlayAnimation(DownedDict, DownedClip, 8f, -8f, -1, AnimationFlags.Loop, 0f); + } + } +} diff --git a/FivePRS.Client/GameWorld.cs b/FivePRS.Client/GameWorld.cs new file mode 100644 index 0000000..dae5906 --- /dev/null +++ b/FivePRS.Client/GameWorld.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Config; + +namespace FivePRS.Client +{ + public class GameWorld : BaseScript + { + private const int DefaultMaxWantedLevel = 5; + private const int DispatchServiceCount = 15; + private const int CheckIntervalMs = 1_000; + + public GameWorld() + { + EventHandlers["onClientResourceStop"] += new Action(name => + { + if (name == API.GetCurrentResourceName()) Restore(); + }); + + Tick += OnTickAsync; + } + + private static WorldSettings Settings => ConfigManager.Settings.World; + + private async Task OnTickAsync() + { + var settings = Settings; + var player = API.PlayerId(); + + if (settings.DisableWantedLevel) + { + API.SetMaxWantedLevel(0); + if (API.GetPlayerWantedLevel(player) > 0) + { + API.ClearPlayerWantedLevel(player); + API.SetPlayerWantedLevelNow(player, false); + } + } + + if (settings.DisableDispatchServices) + { + for (var service = 1; service <= DispatchServiceCount; service++) + API.EnableDispatchService(service, false); + } + + if (settings.DisableRandomCops) + { + API.SetCreateRandomCops(false); + API.SetCreateRandomCopsNotOnScenarios(false); + API.SetCreateRandomCopsOnScenarios(false); + } + + await Delay(CheckIntervalMs); + } + + private static void Restore() + { + var settings = Settings; + + if (settings.DisableWantedLevel) API.SetMaxWantedLevel(DefaultMaxWantedLevel); + + if (settings.DisableDispatchServices) + { + for (var service = 1; service <= DispatchServiceCount; service++) + API.EnableDispatchService(service, true); + } + + if (settings.DisableRandomCops) + { + API.SetCreateRandomCops(true); + API.SetCreateRandomCopsNotOnScenarios(true); + API.SetCreateRandomCopsOnScenarios(true); + } + } + } +} diff --git a/FivePRS.Client/Gear/DepartmentGear.cs b/FivePRS.Client/Gear/DepartmentGear.cs new file mode 100644 index 0000000..d79bc9e --- /dev/null +++ b/FivePRS.Client/Gear/DepartmentGear.cs @@ -0,0 +1,174 @@ +using System; +using System.Linq; +using CitizenFX.Core.Native; +using FivePRS.Client.Loadout; +using FivePRS.Client.Stations; +using FivePRS.Client.VehicleSpawner; +using FivePRS.Core.Config; +using FivePRS.Core.Models; +using FivePRS.Core.Text; + +namespace FivePRS.Client.Gear +{ + public sealed class DepartmentGear + { + private static readonly LoadoutTier[] Tiers = + { LoadoutTier.Recruit, LoadoutTier.Officer, LoadoutTier.Senior, LoadoutTier.Command }; + + private static readonly LoadoutTier[] VehicleTiers = + { LoadoutTier.Recruit, LoadoutTier.Senior, LoadoutTier.Command }; + + private readonly Department _department; + private readonly Func _tierName; + private readonly string _uniformName; + + public DepartmentGear(Department department, Func tierName, string uniformName) + { + _department = department; + _tierName = tierName; + _uniformName = uniformName; + } + + private DepartmentLoadoutsConfig Loadouts => ConfigManager.LoadoutsFor(_department); + + private DepartmentVehiclesConfig Vehicles => ConfigManager.VehiclesFor(_department); + + public LoadoutDefinition LoadoutForRank(int rank, string? agencyId) + { + var tier = DepartmentLoadoutsConfig.TierForRank(rank); + var outfit = ToOutfit(Loadouts.UniformFor(agencyId, tier)); + + outfit.Name = _tierName(tier); + outfit.Weapons = Loadouts.WeaponsFor(agencyId, tier).Weapons.Select(ToEntry).ToArray(); + return outfit; + } + + public PatrolVehicleConfig VehicleForRank(int rank, string? agencyId) => + ToConfig(Vehicles.TierFor(agencyId, DepartmentLoadoutsConfig.TierForRank(rank))); + + public StationCatalog BuildCatalog(string? agencyId) + { + var catalog = new StationCatalog(); + AddWeapons(catalog, agencyId); + AddUniforms(catalog, agencyId); + AddVehicles(catalog, agencyId); + return catalog; + } + + private void AddWeapons(StationCatalog catalog, string? agencyId) + { + foreach (var tier in Tiers) + { + var weapons = Loadouts.WeaponsFor(agencyId, tier).Weapons; + var minRank = DepartmentLoadoutsConfig.MinRankFor(tier); + + if (catalog.Kits.All(kit => kit.Name != _tierName(tier))) + { + catalog.Kits.Add(new GearKit + { + Name = _tierName(tier), + MinRank = minRank, + Weapons = weapons.Select(ToEntry).ToArray(), + }); + } + + foreach (var weapon in weapons) + { + var label = WeaponLabels.For(weapon.Name, weapon.Label); + if (catalog.Weapons.Any(w => string.Equals(w.Label, label, StringComparison.Ordinal))) + continue; + + var entry = ToEntry(weapon); + entry.SetAsCurrent = false; + catalog.Weapons.Add(new GearWeapon { Label = label, MinRank = minRank, Weapon = entry }); + } + } + } + + private void AddUniforms(StationCatalog catalog, string? agencyId) + { + var standard = Loadouts.UniformFor(agencyId, LoadoutTier.Recruit); + var command = Loadouts.UniformFor(agencyId, LoadoutTier.Command); + + catalog.Uniforms.Add(new GearUniform { Name = _uniformName, Outfit = ToOutfit(standard) }); + + if (!ReferenceEquals(standard, command)) + { + catalog.Uniforms.Add(new GearUniform + { + Name = "Command uniform", + MinRank = DepartmentLoadoutsConfig.MinRankFor(LoadoutTier.Command), + Outfit = ToOutfit(command), + }); + } + } + + private void AddVehicles(StationCatalog catalog, string? agencyId) + { + foreach (var tier in VehicleTiers) + { + var vehicles = Vehicles.TierFor(agencyId, tier); + + foreach (var model in vehicles.Models) + { + if (catalog.Vehicles.Any(v => string.Equals(v.Config.ModelPool[0], model, StringComparison.OrdinalIgnoreCase))) + continue; + + catalog.Vehicles.Add(new GearVehicle + { + Label = VehicleLabel(model), + MinRank = DepartmentLoadoutsConfig.MinRankFor(tier), + Config = ToConfig(vehicles, model), + }); + } + } + + foreach (var group in catalog.Vehicles.GroupBy(v => v.Label).Where(g => g.Count() > 1)) + { + foreach (var vehicle in group) + vehicle.Label = $"{vehicle.Label} ({vehicle.Config.ModelPool[0]})"; + } + } + + private LoadoutDefinition ToOutfit(UniformDef uniform) => new() + { + Components = uniform.Components + .Select(c => new ComponentEntry { ComponentId = c.Slot, DrawableId = c.Drawable, TextureId = c.Texture }) + .ToArray(), + Props = uniform.Props + .Select(p => new PropEntry { PropId = p.Slot, DrawableId = p.Drawable, TextureId = p.Texture }) + .ToArray(), + MalePedModel = NullIfEmpty(uniform.PedModels?.Male), + FemalePedModel = NullIfEmpty(uniform.PedModels?.Female), + FallbackMalePedModel = NullIfEmpty(Loadouts.FallbackPedModels.Male), + FallbackFemalePedModel = NullIfEmpty(Loadouts.FallbackPedModels.Female), + }; + + private static WeaponEntry ToEntry(WeaponDef weapon) => new() + { + Hash = (uint)API.GetHashKey(weapon.Name), + Ammo = weapon.Ammo, + SetAsCurrent = weapon.SetCurrent, + }; + + private static PatrolVehicleConfig ToConfig(VehicleTierDef tier, string? model = null) => new() + { + ModelPool = model is null ? tier.Models : new[] { model }, + PrimaryColor = tier.PrimaryColor, + SecondaryColor = tier.SecondaryColor, + DirtLevel = tier.DirtLevel, + Livery = tier.Livery, + PlateText = tier.PlateText, + ForcedExtras = tier.ForcedExtras, + DisabledExtras = tier.DisabledExtras, + }; + + private static string VehicleLabel(string model) + { + var label = API.GetLabelText(API.GetDisplayNameFromVehicleModel((uint)API.GetHashKey(model))); + return string.IsNullOrEmpty(label) || label == "NULL" ? model : label; + } + + private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + } +} diff --git a/FivePRS.Client/Interactions.cs b/FivePRS.Client/Interactions.cs new file mode 100644 index 0000000..810fc39 --- /dev/null +++ b/FivePRS.Client/Interactions.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FivePRS.Client +{ + public static class Interactions + { + private static readonly List> Handlers = new(); + + public static void Register(Func handler) + { + if (!Handlers.Contains(handler)) Handlers.Add(handler); + } + + public static void Unregister(Func handler) => Handlers.Remove(handler); + + internal static bool TryHandle() => Handlers.ToList().Any(handler => handler()); + } +} diff --git a/FivePRS.Client/Positions.cs b/FivePRS.Client/Positions.cs new file mode 100644 index 0000000..db4d47b --- /dev/null +++ b/FivePRS.Client/Positions.cs @@ -0,0 +1,14 @@ +using System; +using CitizenFX.Core; + +namespace FivePRS.Client +{ + public static class Positions + { + public static Vector3 Around(Vector3 center, float distance, Random rng) + { + var angle = rng.NextDouble() * Math.PI * 2; + return new Vector3(center.X + (float)Math.Cos(angle) * distance, center.Y + (float)Math.Sin(angle) * distance, center.Z); + } + } +} diff --git a/FivePRS.Client/Stations/Hospitals.cs b/FivePRS.Client/Stations/Hospitals.cs new file mode 100644 index 0000000..51bd0d0 --- /dev/null +++ b/FivePRS.Client/Stations/Hospitals.cs @@ -0,0 +1,16 @@ +using System.Linq; +using CitizenFX.Core; +using FivePRS.Core.Config; +using FivePRS.Core.Models; + +namespace FivePRS.Client.Stations +{ + public static class Hospitals + { + public static StationDef? Nearest(Vector3 position) => + ConfigManager.Stations.Stations + .Where(s => s.Department == Department.EMS && s.DutyPoint.Length >= 3) + .OrderBy(s => Vector3.DistanceSquared(position, StationService.PositionOf(s))) + .FirstOrDefault(); + } +} diff --git a/FivePRS.Client/Stations/StationMarkers.cs b/FivePRS.Client/Stations/StationMarkers.cs index dad7cd6..f10ed58 100644 --- a/FivePRS.Client/Stations/StationMarkers.cs +++ b/FivePRS.Client/Stations/StationMarkers.cs @@ -110,7 +110,7 @@ private void Scan() private void Interact() { - if (_nearest is null || CompactMenu.IsOpen) return; + if (CompactMenu.IsOpen || Interactions.TryHandle() || _nearest is null) return; Scan(); if (_nearest is not null && _nearestDistance <= InteractDistance) diff --git a/FivePRS.Core/Config/CitationsConfig.cs b/FivePRS.Core/Config/CitationsConfig.cs new file mode 100644 index 0000000..55a0f6f --- /dev/null +++ b/FivePRS.Core/Config/CitationsConfig.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class OffenceDef + { + [JsonProperty("label")] + public string Label { get; set; } = string.Empty; + + [JsonProperty("fine")] + public int Fine { get; set; } + } + + public sealed class CitationsConfig + { + [JsonProperty("offences", ObjectCreationHandling = ObjectCreationHandling.Replace)] + public List Offences { get; set; } = new() + { + new OffenceDef { Label = "Speeding", Fine = 250 }, + new OffenceDef { Label = "Reckless driving", Fine = 750 }, + new OffenceDef { Label = "Running a red light", Fine = 300 }, + new OffenceDef { Label = "Failure to signal", Fine = 100 }, + new OffenceDef { Label = "Illegal lane change", Fine = 150 }, + new OffenceDef { Label = "Driving without a license", Fine = 500 }, + new OffenceDef { Label = "Driving with a suspended license", Fine = 1000 }, + new OffenceDef { Label = "Unregistered vehicle", Fine = 400 }, + new OffenceDef { Label = "Broken tail light", Fine = 75 }, + new OffenceDef { Label = "Open container", Fine = 200 }, + }; + } +} diff --git a/FivePRS.Core/Config/ConfigManager.cs b/FivePRS.Core/Config/ConfigManager.cs index 34af116..7a9cfc4 100644 --- a/FivePRS.Core/Config/ConfigManager.cs +++ b/FivePRS.Core/Config/ConfigManager.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using FivePRS.Core.Jurisdiction; +using FivePRS.Core.Models; using Newtonsoft.Json; namespace FivePRS.Core.Config @@ -7,20 +9,74 @@ namespace FivePRS.Core.Config public static class ConfigManager { public static ResourceSettings Settings { get; private set; } = new(); - public static PoliceVehiclesConfig PoliceVehicles { get; private set; } = new(); - public static PoliceLoadoutsConfig PoliceLoadouts { get; private set; } = new(); public static JurisdictionConfig Jurisdictions { get; private set; } = new(); public static LicensesConfig Licenses { get; private set; } = new(); public static StationsConfig Stations { get; private set; } = new(); + public static CitationsConfig Citations { get; private set; } = new(); public static TerritoryMap Territories { get; private set; } = new(new JurisdictionConfig()); public static Action? Log { get; set; } + public static readonly Department[] Departments = { Department.Police, Department.Fire, Department.EMS }; + + private static readonly Dictionary DepartmentLoadouts = new(); + private static readonly Dictionary DepartmentVehicles = new(); + + public static string ConfigName(Department department) => department.ToString().ToLowerInvariant(); + + public static DepartmentLoadoutsConfig LoadoutsFor(Department department) => + DepartmentLoadouts.TryGetValue(department, out var config) ? config : DefaultLoadouts(department); + + public static DepartmentVehiclesConfig VehiclesFor(Department department) => + DepartmentVehicles.TryGetValue(department, out var config) ? config : DefaultVehicles(department); + + public static void LoadDepartment(Department department, string? loadoutsJson, string? vehiclesJson) + { + var name = ConfigName(department); + DepartmentLoadouts[department] = Populate(loadoutsJson, $"{name}_loadouts.json", () => DefaultLoadouts(department)); + DepartmentVehicles[department] = Populate(vehiclesJson, $"{name}_vehicles.json", () => DefaultVehicles(department)); + } + + private static DepartmentLoadoutsConfig DefaultLoadouts(Department department) => department switch + { + Department.Fire => DepartmentLoadoutsConfig.Fire(), + Department.EMS => DepartmentLoadoutsConfig.Ems(), + _ => new DepartmentLoadoutsConfig(), + }; + + private static DepartmentVehiclesConfig DefaultVehicles(Department department) => department switch + { + Department.Fire => DepartmentVehiclesConfig.Fire(), + Department.EMS => DepartmentVehiclesConfig.Ems(), + _ => new DepartmentVehiclesConfig(), + }; + + private static T Populate(string? json, string fileName, Func defaults) where T : class + { + if (json is null || string.IsNullOrWhiteSpace(json)) + { + Log?.Invoke($"[FivePRS:Config] {fileName} missing or empty, using defaults."); + return defaults(); + } + + try + { + var config = defaults(); + JsonConvert.PopulateObject(json, config); + Log?.Invoke($"[FivePRS:Config] {fileName} loaded."); + return config; + } + catch (Exception ex) + { + Log?.Invoke($"[FivePRS:Config] {fileName} parse error, using defaults. ({ex.Message})"); + return defaults(); + } + } + public static void LoadSettings(string? json) => Settings = Load(json, "settings.json", Settings); - public static void LoadPoliceVehicles(string? json) => PoliceVehicles = Load(json, "police_vehicles.json", PoliceVehicles); - public static void LoadPoliceLoadouts(string? json) => PoliceLoadouts = Load(json, "police_loadouts.json", PoliceLoadouts); public static void LoadLicenses(string? json) => Licenses = Load(json, "licenses.json", Licenses); public static void LoadStations(string? json) => Stations = Load(json, "stations.json", Stations); + public static void LoadCitations(string? json) => Citations = Load(json, "citations.json", Citations); public static void LoadJurisdictions(string? json) { diff --git a/FivePRS.Core/Config/DeathSettings.cs b/FivePRS.Core/Config/DeathSettings.cs new file mode 100644 index 0000000..9872567 --- /dev/null +++ b/FivePRS.Core/Config/DeathSettings.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class DeathSettings + { + [JsonProperty("enabled")] + public bool Enabled { get; set; } = true; + + [JsonProperty("respawnSeconds")] + public int RespawnSeconds { get; set; } = 180; + + [JsonProperty("notifyEms")] + public bool NotifyEms { get; set; } = true; + + [JsonProperty("reviveXp")] + public int ReviveXp { get; set; } = 25; + } +} diff --git a/FivePRS.Core/Config/JurisdictionConfig.cs b/FivePRS.Core/Config/JurisdictionConfig.cs index 9b7cb5b..11046e4 100644 --- a/FivePRS.Core/Config/JurisdictionConfig.cs +++ b/FivePRS.Core/Config/JurisdictionConfig.cs @@ -82,6 +82,22 @@ public sealed class JurisdictionConfig CallsignPrefix = "BCSO", Territories = new List { "blaine_county" }, }, + new AgencyDef + { + Id = "lsfd", + Name = "Los Santos Fire Department", + Department = Department.Fire, + CallsignPrefix = "LSFD", + Territories = new List { "los_santos", "blaine_county" }, + }, + new AgencyDef + { + Id = "sams", + Name = "San Andreas Medical Services", + Department = Department.EMS, + CallsignPrefix = "SAMS", + Territories = new List { "los_santos", "blaine_county" }, + }, }; } } diff --git a/FivePRS.Core/Config/LoadoutConfig.cs b/FivePRS.Core/Config/LoadoutConfig.cs index 4877883..f9af56f 100644 --- a/FivePRS.Core/Config/LoadoutConfig.cs +++ b/FivePRS.Core/Config/LoadoutConfig.cs @@ -88,7 +88,7 @@ public enum LoadoutTier Command } - public sealed class PoliceLoadoutsConfig + public sealed class DepartmentLoadoutsConfig { [JsonProperty("recruit")] public WeaponTierDef Recruit { get; set; } = Tier( @@ -151,6 +151,30 @@ public sealed class PoliceLoadoutsConfig [JsonProperty("agencies", ObjectCreationHandling = ObjectCreationHandling.Replace)] public Dictionary Agencies { get; set; } = new(); + public static DepartmentLoadoutsConfig Fire() => Uniformed("s_m_y_fireman_01", + Tier(Weapon("WEAPON_FIREEXTINGUISHER", 2000, setCurrent: true), Weapon("WEAPON_FLASHLIGHT", 1)), + Tier(Weapon("WEAPON_FIREEXTINGUISHER", 2000, setCurrent: true), Weapon("WEAPON_FLASHLIGHT", 1), Weapon("WEAPON_HATCHET", 1))); + + public static DepartmentLoadoutsConfig Ems() => Uniformed("s_m_m_paramedic_01", + Tier(Weapon("WEAPON_FLASHLIGHT", 1, setCurrent: true)), + Tier(Weapon("WEAPON_FLASHLIGHT", 1, setCurrent: true), Weapon("WEAPON_FIREEXTINGUISHER", 1000))); + + private static DepartmentLoadoutsConfig Uniformed(string pedModel, WeaponTierDef junior, WeaponTierDef senior) + { + UniformDef Outfit() => new() { PedModels = new PedModelsDef { Male = pedModel, Female = pedModel } }; + + return new DepartmentLoadoutsConfig + { + Recruit = junior, + Officer = junior, + Senior = senior, + Command = senior, + Uniform = Outfit(), + CommandUniform = Outfit(), + FallbackPedModels = new PedModelsDef { Male = pedModel, Female = pedModel }, + }; + } + public static LoadoutTier TierForRank(int rank) => rank >= 8 ? LoadoutTier.Command : rank >= 5 ? LoadoutTier.Senior : diff --git a/FivePRS.Core/Config/ResourceSettings.cs b/FivePRS.Core/Config/ResourceSettings.cs index 69d1c1a..106dfbd 100644 --- a/FivePRS.Core/Config/ResourceSettings.cs +++ b/FivePRS.Core/Config/ResourceSettings.cs @@ -61,6 +61,12 @@ public sealed class ResourceSettings [JsonProperty("rateLimits")] public RateLimitSettings RateLimits { get; set; } = new(); + [JsonProperty("world")] + public WorldSettings World { get; set; } = new(); + + [JsonProperty("death")] + public DeathSettings Death { get; set; } = new(); + [JsonProperty("terminal")] public TerminalSettings Terminal { get; set; } = new(); diff --git a/FivePRS.Core/Config/VehicleConfig.cs b/FivePRS.Core/Config/VehicleConfig.cs index 52ad555..0af1ebb 100644 --- a/FivePRS.Core/Config/VehicleConfig.cs +++ b/FivePRS.Core/Config/VehicleConfig.cs @@ -44,7 +44,7 @@ public sealed class AgencyVehicleDef public VehicleTierDef? Command { get; set; } } - public sealed class PoliceVehiclesConfig + public sealed class DepartmentVehiclesConfig { [JsonProperty("patrol")] public VehicleTierDef Patrol { get; set; } = new() { Models = new[] { "police", "police2" } }; @@ -66,6 +66,18 @@ public sealed class PoliceVehiclesConfig }, }; + public static DepartmentVehiclesConfig Fire() => Single("firetruk"); + + public static DepartmentVehiclesConfig Ems() => Single("ambulance"); + + private static DepartmentVehiclesConfig Single(string model) => new() + { + Patrol = new VehicleTierDef { Models = new[] { model } }, + Senior = new VehicleTierDef { Models = new[] { model } }, + Command = new VehicleTierDef { Models = new[] { model } }, + Agencies = new Dictionary(), + }; + public VehicleTierDef TierFor(string? agencyId, LoadoutTier tier) { var agency = agencyId is null diff --git a/FivePRS.Core/Config/WorldSettings.cs b/FivePRS.Core/Config/WorldSettings.cs new file mode 100644 index 0000000..1616d3c --- /dev/null +++ b/FivePRS.Core/Config/WorldSettings.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class WorldSettings + { + [JsonProperty("disableWantedLevel")] + public bool DisableWantedLevel { get; set; } = true; + + [JsonProperty("disableDispatchServices")] + public bool DisableDispatchServices { get; set; } = true; + + [JsonProperty("disableRandomCops")] + public bool DisableRandomCops { get; set; } + } +} diff --git a/FivePRS.Core/Events/EventNames.cs b/FivePRS.Core/Events/EventNames.cs index 576add4..a3e97be 100644 --- a/FivePRS.Core/Events/EventNames.cs +++ b/FivePRS.Core/Events/EventNames.cs @@ -15,6 +15,10 @@ public static class EventNames public const string ClientCivilianError = "FivePRS:Client:CivilianError"; public const string ClientLookupResult = "FivePRS:Client:LookupResult"; public const string ClientLookupError = "FivePRS:Client:LookupError"; + public const string ClientStopResult = "FivePRS:Client:StopResult"; + public const string ClientPlayerDowned = "FivePRS:Client:PlayerDowned"; + public const string ClientDownedPlayers = "FivePRS:Client:DownedPlayers"; + public const string ClientRevived = "FivePRS:Client:Revived"; public const string ClientEmergencyStatus = "FivePRS:Client:EmergencyStatus"; public const string ClientEmergencyError = "FivePRS:Client:EmergencyError"; public const string ClientAdminState = "FivePRS:Client:AdminState"; @@ -56,6 +60,11 @@ public static class EventNames public const string ServerSetWallpaper = "FivePRS:Server:SetWallpaper"; public const string ServerReportPosition = "FivePRS:Server:ReportPosition"; public const string ServerLastPositionRequest = "FivePRS:Server:LastPositionRequest"; + public const string ServerStopIdentify = "FivePRS:Server:StopIdentify"; + public const string ServerStopPlate = "FivePRS:Server:StopPlate"; + public const string ServerPlayerDowned = "FivePRS:Server:PlayerDowned"; + public const string ServerPlayerRespawned = "FivePRS:Server:PlayerRespawned"; + public const string ServerRevivePlayer = "FivePRS:Server:RevivePlayer"; public const string LocalDutyChanged = "FivePRS:Local:DutyChanged"; public const string LocalOpenMenu = "FivePRS:Local:OpenMenu"; diff --git a/FivePRS.Core/Police/StreetProfiles.cs b/FivePRS.Core/Police/StreetProfiles.cs new file mode 100644 index 0000000..e35822a --- /dev/null +++ b/FivePRS.Core/Police/StreetProfiles.cs @@ -0,0 +1,149 @@ +using System; +using System.Globalization; +using FivePRS.Core.Civilian; + +namespace FivePRS.Core.Police +{ + public enum StreetLicense + { + Valid, + None, + Suspended, + Revoked, + } + + public sealed class StreetPerson + { + public int Seed { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string DateOfBirth { get; set; } = string.Empty; + public string Gender { get; set; } = string.Empty; + public StreetLicense DriverLicense { get; set; } + public string? Warrant { get; set; } + public string? Contraband { get; set; } + public bool WillFlee { get; set; } + + public string FullName => $"{FirstName} {LastName}"; + } + + public sealed class StreetVehicle + { + public string Plate { get; set; } = string.Empty; + public bool Registered { get; set; } + public bool Stolen { get; set; } + public bool DriverIsOwner { get; set; } + public int OwnerSeed { get; set; } + public bool OwnerFemale { get; set; } + } + + public static class StreetProfiles + { + public const string NpcOwner = "npc"; + public const string DriverLicenseType = "driver"; + + private static readonly string[] MaleNames = + { + "James", "Michael", "Robert", "David", "William", "Richard", "Joseph", "Thomas", "Daniel", "Matthew", + "Anthony", "Mark", "Steven", "Paul", "Andrew", "Joshua", "Kevin", "Brian", "Jason", "Ryan", + "Eric", "Jacob", "Tyler", "Aaron", "Luis", "Carlos", "Marcus", "Trevor", "Dwayne", "Hector", + }; + + private static readonly string[] FemaleNames = + { + "Mary", "Jennifer", "Linda", "Elizabeth", "Susan", "Jessica", "Sarah", "Karen", "Lisa", "Nancy", + "Ashley", "Emily", "Michelle", "Amanda", "Melissa", "Stephanie", "Rebecca", "Laura", "Amy", "Angela", + "Brittany", "Megan", "Rachel", "Maria", "Sofia", "Tanisha", "Monica", "Denise", "Crystal", "Vanessa", + }; + + private static readonly string[] LastNames = + { + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", + "Hernandez", "Lopez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee", + "Thompson", "White", "Harris", "Clark", "Lewis", "Robinson", "Walker", "Young", "Allen", "King", + "Wright", "Scott", "Torres", "Hill", "Flores", "Green", "Adams", "Nelson", "Baker", "Hall", + "Rivera", "Campbell", "Mitchell", "Carter", "Roberts", "Reyes", "Cruz", "Morales", "Ortiz", "Price", + }; + + private static readonly string[] Warrants = + { + "Failure to appear in court", "Unpaid traffic fines", "Probation violation", "Petty theft", + "Simple assault", "Possession of a controlled substance", "Driving while suspended", + }; + + private static readonly string[] Contraband = + { + "A small bag of cannabis", "An open container of alcohol", "Drug paraphernalia", + "An unregistered pistol", "Several stolen credit cards", "A switchblade", + }; + + public static StreetPerson Person(int seed, bool female) + { + var rng = new Random(seed); + var names = female ? FemaleNames : MaleNames; + + var person = new StreetPerson + { + Seed = seed, + FirstName = names[rng.Next(names.Length)], + LastName = LastNames[rng.Next(LastNames.Length)], + Gender = female ? "Female" : "Male", + }; + + var year = 1950 + rng.Next(57); + var month = 1 + rng.Next(12); + var day = 1 + rng.Next(DateTime.DaysInMonth(year, month)); + person.DateOfBirth = new DateTime(year, month, day).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + var license = rng.Next(100); + person.DriverLicense = license < 82 ? StreetLicense.Valid + : license < 88 ? StreetLicense.None + : license < 96 ? StreetLicense.Suspended + : StreetLicense.Revoked; + + if (rng.Next(100) < 7) person.Warrant = Warrants[rng.Next(Warrants.Length)]; + if (rng.Next(100) < 15) person.Contraband = Contraband[rng.Next(Contraband.Length)]; + person.WillFlee = person.Warrant is not null && rng.Next(100) < 35; + + return person; + } + + public static StreetVehicle Vehicle(string plate) + { + var normalized = (plate ?? string.Empty).Trim().ToUpperInvariant(); + var rng = new Random(StableHash(normalized)); + + var vehicle = new StreetVehicle + { + Plate = normalized, + Registered = rng.Next(100) < 94, + DriverIsOwner = rng.Next(100) < 80, + OwnerSeed = rng.Next(), + OwnerFemale = rng.Next(2) == 0, + }; + vehicle.Stolen = vehicle.Registered && rng.Next(100) < 4; + if (vehicle.Stolen) vehicle.DriverIsOwner = false; + + return vehicle; + } + + public static LicenseStatus? ToLicenseStatus(StreetLicense license) => license switch + { + StreetLicense.Valid => LicenseStatus.Valid, + StreetLicense.Suspended => LicenseStatus.Suspended, + StreetLicense.Revoked => LicenseStatus.Revoked, + _ => null, + }; + + public static int StableHash(string text) + { + unchecked + { + var hash = (int)2166136261; + foreach (var c in text) + hash = (hash ^ c) * 16777619; + return hash & 0x7FFFFFFF; + } + } + } +} diff --git a/FivePRS.Core/Text/KeyCommands.cs b/FivePRS.Core/Text/KeyCommands.cs index 0895c49..d2e3b81 100644 --- a/FivePRS.Core/Text/KeyCommands.cs +++ b/FivePRS.Core/Text/KeyCommands.cs @@ -11,5 +11,6 @@ public static class KeyCommands public const string Uncuff = "er_uncuff"; public const string Escort = "er_escort"; public const string AiCallouts = "er_aicallouts"; + public const string TrafficStop = "fiveprs_trafficstop"; } } diff --git a/FivePRS.EMS/Callouts/MedicalEmergencyCallout.cs b/FivePRS.EMS/Callouts/MedicalEmergencyCallout.cs new file mode 100644 index 0000000..d4afa48 --- /dev/null +++ b/FivePRS.EMS/Callouts/MedicalEmergencyCallout.cs @@ -0,0 +1,47 @@ +using System.Threading; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Client; +using FivePRS.Client.Callouts; +using FivePRS.Core.Models; + +namespace FivePRS.EMS.Callouts +{ + [CalloutInfo( + name: "Medical Emergency", + department: Department.EMS, + priority: CalloutPriority.High, + weight: 30, + cooldownSeconds: 180, + xpReward: 80)] + public sealed class MedicalEmergencyCallout : PatientCallout + { + private static readonly string[] Conditions = + { + "A person collapsed and is struggling to breathe.", + "Caller reports a person with severe chest pains.", + "A diabetic person is confused and barely responsive.", + "An elderly person fell and can't get up.", + "A person is having a seizure on the sidewalk.", + }; + + private readonly Vector3 _location; + + public MedicalEmergencyCallout() + { + _location = World.GetNextPositionOnStreet(Positions.Around(Game.PlayerPed.Position, 250f + Rng.Next(200), Rng)); + Data.Description = Conditions[Rng.Next(Conditions.Length)]; + } + + protected override Vector3 Location => _location; + + protected override string BlipName => "Medical emergency"; + + protected override Task SetupSceneAsync(CancellationToken ct) => + SpawnPatientAsync(World.GetNextPositionOnSidewalk(_location), Rng.Next(360)); + + public override void OnCalloutDeclined() => ClientBrain.ShowNotification("Medical emergency declined.", "~r~Dispatch"); + + public override void OnCalloutFailed() => ClientBrain.ShowNotification("Medical emergency ended.", "~r~Dispatch"); + } +} diff --git a/FivePRS.EMS/Callouts/PatientCallout.cs b/FivePRS.EMS/Callouts/PatientCallout.cs new file mode 100644 index 0000000..c694180 --- /dev/null +++ b/FivePRS.EMS/Callouts/PatientCallout.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Client; +using FivePRS.Client.Callouts; + +namespace FivePRS.EMS.Callouts +{ + public abstract class PatientCallout : CalloutBase + { + private const float ArrivalDistance = 120f; + private const int TimeoutMinutes = 30; + + protected static readonly string[] PatientModels = + { + "a_m_m_business_01", "a_f_m_business_02", "a_m_y_hipster_01", "a_f_y_hipster_01", + "a_m_m_tourist_01", "a_f_y_tourist_01", "a_m_o_soucent_01", "a_f_o_soucent_01", + }; + + protected readonly Random Rng = new(); + + private PatientCare? _care; + + protected abstract Vector3 Location { get; } + + protected abstract string BlipName { get; } + + protected abstract Task SetupSceneAsync(CancellationToken ct); + + public override Vector3 GetDispatchLocation() => Location; + + public override async Task OnCalloutAccepted(CancellationToken ct) + { + var blip = TrackBlip(World.CreateBlip(Location)); + blip.Sprite = BlipSprite.Standard; + blip.Color = BlipColor.Red; + blip.Name = BlipName; + blip.ShowRoute = true; + + while (Vector3.Distance(Game.PlayerPed.Position, Location) > ArrivalDistance) + await Timing.WaitAsync(1_000, ct); + + var patient = await SetupSceneAsync(ct); + if (patient is null || !patient.Exists()) + { + CalloutFailed(); + return; + } + + blip.Position = patient.Position; + blip.ShowRoute = false; + _care = new PatientCare(patient); + ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | Patient located. Treat them, then transport them to hospital."); + + var deadline = DateTime.UtcNow.AddMinutes(TimeoutMinutes); + while (!ct.IsCancellationRequested) + { + await Timing.WaitAsync(400, ct); + + if (!patient.Exists()) + { + CalloutFailed(); + return; + } + + if (_care.Update()) + { + CalloutCompleted(); + return; + } + + if (_care.Stage == PatientStage.Loaded && blip.Exists()) blip.Delete(); + + if (DateTime.UtcNow > deadline) + { + ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | ~r~The patient was taken by another unit."); + CalloutFailed(); + return; + } + + var prompt = _care.Prompt(); + if (prompt is not null) ClientBrain.ShowHelp(prompt, 500); + } + } + + protected async Task SpawnPatientAsync(Vector3 position, float heading) + { + var model = new Model(PatientModels[Rng.Next(PatientModels.Length)]); + if (!await model.Request(7_000)) return null; + + var ped = TrackEntity(await World.CreatePed(model, position, heading)); + model.MarkAsNoLongerNeeded(); + return ped; + } + + protected override void OnCleanup() => _care?.Dispose(); + } +} diff --git a/FivePRS.EMS/Callouts/TrafficCollisionCallout.cs b/FivePRS.EMS/Callouts/TrafficCollisionCallout.cs new file mode 100644 index 0000000..ae1f6b5 --- /dev/null +++ b/FivePRS.EMS/Callouts/TrafficCollisionCallout.cs @@ -0,0 +1,66 @@ +using System.Threading; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client; +using FivePRS.Client.Callouts; +using FivePRS.Core.Models; + +namespace FivePRS.EMS.Callouts +{ + [CalloutInfo( + name: "Traffic Collision", + department: Department.EMS, + priority: CalloutPriority.High, + weight: 25, + cooldownSeconds: 240, + xpReward: 100)] + public sealed class TrafficCollisionCallout : PatientCallout + { + private static readonly string[] VehicleModels = { "asea", "emperor", "fugitive", "ingot", "premier", "stanier", "tailgater" }; + + private readonly Vector3 _location; + + public TrafficCollisionCallout() + { + _location = World.GetNextPositionOnStreet(Positions.Around(Game.PlayerPed.Position, 250f + Rng.Next(200), Rng)); + Data.Description = "Two-vehicle collision with one person injured. Treat and transport the patient."; + } + + protected override Vector3 Location => _location; + + protected override string BlipName => "Traffic collision"; + + protected override async Task SetupSceneAsync(CancellationToken ct) + { + var heading = (float)Rng.Next(360); + var first = await SpawnWreckAsync(_location, heading); + var second = await SpawnWreckAsync(_location + new Vector3(3.5f, 2.5f, 0f), heading + 70f); + if (first is null || second is null) return null; + + return await SpawnPatientAsync(first.Position + first.RightVector * 2.5f, heading + 90f); + } + + private async Task SpawnWreckAsync(Vector3 position, float heading) + { + var model = new Model(VehicleModels[Rng.Next(VehicleModels.Length)]); + if (!await model.Request(7_000)) return null; + + var vehicle = TrackEntity(await World.CreateVehicle(model, position, heading)); + model.MarkAsNoLongerNeeded(); + if (vehicle is null || !vehicle.Exists()) return null; + + API.SetVehicleEngineHealth(vehicle.Handle, 150f); + API.SetVehicleDamage(vehicle.Handle, 0f, 1.5f, 0.3f, 800f, 300f, true); + API.SmashVehicleWindow(vehicle.Handle, 0); + API.SmashVehicleWindow(vehicle.Handle, 1); + API.SetVehicleDoorsLocked(vehicle.Handle, 2); + vehicle.IsEngineRunning = false; + return vehicle; + } + + public override void OnCalloutDeclined() => ClientBrain.ShowNotification("Traffic collision declined.", "~r~Dispatch"); + + public override void OnCalloutFailed() => ClientBrain.ShowNotification("Traffic collision call ended.", "~r~Dispatch"); + } +} diff --git a/FivePRS.EMS/EmsAgency.cs b/FivePRS.EMS/EmsAgency.cs new file mode 100644 index 0000000..53ddfb1 --- /dev/null +++ b/FivePRS.EMS/EmsAgency.cs @@ -0,0 +1,24 @@ +using FivePRS.Client.Agency; +using FivePRS.Client.Gear; +using FivePRS.Core.Config; +using FivePRS.Core.Models; + +namespace FivePRS.EMS +{ + public class EmsAgency : DepartmentAgency + { + public override Department Department => Department.EMS; + + public override string AgencyName => "Emergency Medical Services"; + + protected override DepartmentGear Gear { get; } = new(Department.EMS, tier => tier switch + { + LoadoutTier.Command => "Supervisor", + LoadoutTier.Senior => "Paramedic", + LoadoutTier.Officer => "EMT", + _ => "EMT Trainee", + }, "Medic uniform"); + + protected override string EquipmentNoun => "uniform, equipment and ambulance"; + } +} diff --git a/FivePRS.EMS/FivePRS.EMS.csproj b/FivePRS.EMS/FivePRS.EMS.csproj new file mode 100644 index 0000000..c0295ae --- /dev/null +++ b/FivePRS.EMS/FivePRS.EMS.csproj @@ -0,0 +1,30 @@ + + + + net452 + 10 + enable + Library + FivePRS.EMS + FivePRS.EMS.net + false + $(MSBuildThisFileDirectory)..\bin\client\ + true + + + + + + + $(PkgNewtonsoft_Json)\lib\portable-net40+sl5+win8+wp8+wpa81\Newtonsoft.Json.dll + + + + + + + + + + + diff --git a/FivePRS.EMS/PatientCare.cs b/FivePRS.EMS/PatientCare.cs new file mode 100644 index 0000000..83a9c59 --- /dev/null +++ b/FivePRS.EMS/PatientCare.cs @@ -0,0 +1,166 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client; +using FivePRS.Client.Stations; +using FivePRS.Core.Config; + +namespace FivePRS.EMS +{ + public enum PatientStage + { + Injured, + Treating, + Stable, + Loaded, + Delivered, + } + + public sealed class PatientCare : IDisposable + { + private const float TreatRange = 2.2f; + private const float LoadRange = 8f; + private const float DeliverRange = 30f; + private const int TreatMs = 8_000; + private const int EmergencyClass = 18; + + private const string InjuredDict = "combat@damage@writhe"; + private const string InjuredClip = "writhe_loop"; + private const string StableDict = "amb@world_human_bum_slumped@male@laying_on_left_side@base"; + private const string StableClip = "base"; + + private static readonly int[] RearSeats = { 1, 2, 0 }; + + private readonly Ped _patient; + private Blip? _hospitalBlip; + private StationDef? _hospital; + private Vehicle? _transport; + + public PatientCare(Ped patient) + { + _patient = patient; + _patient.BlockPermanentEvents = true; + _patient.IsInvincible = true; + _ = Animations.PlayAsync(_patient, InjuredDict, InjuredClip); + Interactions.Register(TryInteract); + } + + public PatientStage Stage { get; private set; } = PatientStage.Injured; + + public Ped Patient => _patient; + + public string? Prompt() + { + if (!_patient.Exists() || (Game.PlayerPed.IsInVehicle() && Stage != PatientStage.Loaded)) return null; + + var distance = Vector3.Distance(Game.PlayerPed.Position, _patient.Position); + return Stage switch + { + PatientStage.Injured when distance <= TreatRange => $"{Binds.Interact} Treat the patient", + PatientStage.Stable when distance <= LoadRange && FindTransport() is { } vehicle => + $"{Binds.Interact} Load the patient into the {VehicleName(vehicle)}", + PatientStage.Stable when distance <= LoadRange => "Bring an ambulance closer to load the patient", + PatientStage.Loaded when _hospital is not null => $"Take the patient to ~y~{_hospital.Name}", + _ => null, + }; + } + + public bool Update() + { + if (Stage != PatientStage.Loaded || _hospital is null || _transport is null || !_transport.Exists()) return Stage == PatientStage.Delivered; + + var atHospital = Vector3.Distance(_transport.Position, StationService.PositionOf(_hospital)) <= DeliverRange; + if (!atHospital || _transport.Speed > 1f) return false; + + Stage = PatientStage.Delivered; + _hospitalBlip?.Delete(); + _patient.Task.LeaveVehicle(_transport, false); + ClientBrain.ShowNotification($"Patient handed over to ~y~{_hospital.Name}~w~.", "~b~EMS"); + return true; + } + + private bool TryInteract() + { + if (!_patient.Exists() || Game.PlayerPed.IsInVehicle()) return false; + + var distance = Vector3.Distance(Game.PlayerPed.Position, _patient.Position); + if (Stage == PatientStage.Injured && distance <= TreatRange) + { + _ = TreatAsync(); + return true; + } + + if (Stage == PatientStage.Stable && distance <= LoadRange && FindTransport() is { } vehicle) + { + Load(vehicle); + return true; + } + + return false; + } + + private async Task TreatAsync() + { + Stage = PatientStage.Treating; + Game.PlayerPed.Task.TurnTo(_patient); + + var treated = await Treatment.RunAsync(() => _patient.Exists() ? _patient.Position : (Vector3?)null, TreatMs, "Treating the patient"); + if (!treated) + { + Stage = PatientStage.Injured; + return; + } + + _patient.Task.ClearAll(); + await Animations.PlayAsync(_patient, StableDict, StableClip); + Stage = PatientStage.Stable; + ClientBrain.ShowNotification("Patient ~g~stabilized~w~. Load them into the ambulance.", "~b~EMS"); + } + + private void Load(Vehicle vehicle) + { + var seat = RearSeats.FirstOrDefault(s => API.IsVehicleSeatFree(vehicle.Handle, s)); + _patient.Task.ClearAll(); + API.TaskWarpPedIntoVehicle(_patient.Handle, vehicle.Handle, seat); + + _transport = vehicle; + _hospital = Hospitals.Nearest(vehicle.Position); + Stage = PatientStage.Loaded; + + if (_hospital is null) + { + ClientBrain.ShowNotification("Patient loaded, but no hospital is configured.", "~b~EMS"); + return; + } + + _hospitalBlip = World.CreateBlip(StationService.PositionOf(_hospital)); + _hospitalBlip.Sprite = BlipSprite.Hospital; + _hospitalBlip.Color = BlipColor.Red; + _hospitalBlip.Name = _hospital.Name; + _hospitalBlip.ShowRoute = true; + ClientBrain.ShowNotification($"Patient loaded. Transport them to ~y~{_hospital.Name}~w~.", "~b~EMS"); + } + + private Vehicle? FindTransport() => + World.GetAllVehicles() + .Where(v => v.Exists() && API.GetVehicleClass(v.Handle) == EmergencyClass && + Vector3.Distance(v.Position, _patient.Position) <= LoadRange && + RearSeats.Any(s => API.IsVehicleSeatFree(v.Handle, s))) + .OrderBy(v => Vector3.Distance(v.Position, _patient.Position)) + .FirstOrDefault(); + + private static string VehicleName(Vehicle vehicle) + { + var label = API.GetLabelText(API.GetDisplayNameFromVehicleModel((uint)vehicle.Model.Hash)); + return string.IsNullOrEmpty(label) || label == "NULL" ? "vehicle" : label.ToLowerInvariant(); + } + + public void Dispose() + { + Interactions.Unregister(TryInteract); + _hospitalBlip?.Delete(); + } + } +} diff --git a/FivePRS.EMS/ReviveManager.cs b/FivePRS.EMS/ReviveManager.cs new file mode 100644 index 0000000..293d5d8 --- /dev/null +++ b/FivePRS.EMS/ReviveManager.cs @@ -0,0 +1,85 @@ +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client; +using FivePRS.Client.Agency; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; + +namespace FivePRS.EMS +{ + public class ReviveManager : BaseScript + { + private const float ReviveRange = 2.5f; + private const int ReviveMs = 6_000; + private const int ScanIntervalMs = 200; + + private static int? _target; + private static bool _reviving; + + public ReviveManager() + { + Interactions.Register(TryRevive); + Tick += PromptTick; + } + + private static bool CanRevive => + ConfigManager.Settings.Death.Enabled && BaseAgency.Active?.Department == Department.EMS && !Game.PlayerPed.IsInVehicle() && !_reviving; + + private async Task PromptTick() + { + await Delay(ScanIntervalMs); + + _target = CanRevive ? FindDowned() : null; + if (_target is not null) + ClientBrain.ShowHelp($"{Binds.Interact} Revive {API.GetPlayerName(API.GetPlayerFromServerId(_target.Value))}", ScanIntervalMs + 100); + } + + private static int? FindDowned() + { + var own = API.GetPlayerServerId(API.PlayerId()); + foreach (var serverId in DeathManager.DownedPlayers.Where(id => id != own)) + { + var position = PositionOf(serverId); + if (position is not null && Vector3.Distance(Game.PlayerPed.Position, position.Value) <= ReviveRange) + return serverId; + } + + return null; + } + + private static Vector3? PositionOf(int serverId) + { + var player = API.GetPlayerFromServerId(serverId); + if (player < 0) return null; + + var ped = API.GetPlayerPed(player); + return ped == 0 ? null : API.GetEntityCoords(ped, true); + } + + private static bool TryRevive() + { + if (_target is null || !CanRevive) return false; + + var target = _target.Value; + _ = ReviveAsync(target); + return true; + } + + private static async Task ReviveAsync(int serverId) + { + _reviving = true; + try + { + if (await Treatment.RunAsync(() => DeathManager.IsDowned(serverId) ? PositionOf(serverId) : null, ReviveMs, "Reviving")) + ClientEvents.TriggerServer(EventNames.ServerRevivePlayer, serverId); + } + finally + { + _reviving = false; + } + } + } +} diff --git a/FivePRS.EMS/Treatment.cs b/FivePRS.EMS/Treatment.cs new file mode 100644 index 0000000..02b4a84 --- /dev/null +++ b/FivePRS.EMS/Treatment.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client; + +namespace FivePRS.EMS +{ + public static class Treatment + { + private const string MedicDict = "amb@medic@standing@tendtodead@idle_a"; + private const string MedicClip = "idle_a"; + private const float AbortRange = 3.5f; + + public static async Task RunAsync(Func patientPosition, int durationMs, string label) + { + var medic = Game.PlayerPed; + await Animations.PlayAsync(medic, MedicDict, MedicClip); + + var started = API.GetGameTimer(); + try + { + while (API.GetGameTimer() - started < durationMs) + { + var position = patientPosition(); + if (position is null || Vector3.Distance(medic.Position, position.Value) > AbortRange) + { + ClientBrain.ShowNotification("Treatment interrupted.", "~b~EMS"); + return false; + } + + ClientBrain.ShowHelp($"{label}… ~g~{(API.GetGameTimer() - started) * 100 / durationMs}%", 300); + await BaseScript.Delay(250); + } + + return true; + } + finally + { + medic.Task.ClearAnimation(MedicDict, MedicClip); + } + } + } + + public static class Animations + { + public static async Task PlayAsync(Ped ped, string dict, string clip) + { + API.RequestAnimDict(dict); + for (var i = 0; i < 100 && !API.HasAnimDictLoaded(dict); i++) + await BaseScript.Delay(50); + + if (ped.Exists()) + await ped.Task.PlayAnimation(dict, clip, 8f, -8f, -1, AnimationFlags.Loop, 0f); + } + } +} diff --git a/FivePRS.Fire/Callouts/BrushFireCallout.cs b/FivePRS.Fire/Callouts/BrushFireCallout.cs new file mode 100644 index 0000000..d87dea0 --- /dev/null +++ b/FivePRS.Fire/Callouts/BrushFireCallout.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Client; +using FivePRS.Client.Callouts; +using FivePRS.Core.Config; +using FivePRS.Core.Models; + +namespace FivePRS.Fire.Callouts +{ + [CalloutInfo( + name: "Brush Fire", + department: Department.Fire, + priority: CalloutPriority.High, + weight: 20, + cooldownSeconds: 300, + xpReward: 120)] + public sealed class BrushFireCallout : CalloutBase + { + private const string Countryside = "blaine_county"; + private const float IgniteDistance = 150f; + private const float SceneRadius = 45f; + private const int FireCount = 6; + private const int TimeoutMinutes = 25; + + private readonly Random _rng = new(); + private readonly Vector3 _location; + + private FireScene? _scene; + + public BrushFireCallout() + { + _location = Positions.Around(Game.PlayerPed.Position, 300f + _rng.Next(250), _rng); + Data.Description = "Smoke reported from dry brush. The fire is spreading. Contain it before it reaches property."; + } + + public override Vector3 GetDispatchLocation() => _location; + + public override bool CanBeDispatched() => + ConfigManager.Territories.Resolve(Game.PlayerPed.Position.X, Game.PlayerPed.Position.Y)?.Id == Countryside; + + public override async Task OnCalloutAccepted(CancellationToken ct) + { + var blip = TrackBlip(World.CreateBlip(_location)); + blip.Sprite = BlipSprite.Standard; + blip.Color = BlipColor.Red; + blip.Name = "Brush fire"; + blip.ShowRoute = true; + + while (Vector3.Distance(Game.PlayerPed.Position, _location) > IgniteDistance) + await Timing.WaitAsync(1_000, ct); + + if (!FireScene.TryGround(_location.X, _location.Y, Game.PlayerPed.Position.Z, out var center)) + { + ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | False alarm. The smoke was from a controlled burn."); + CalloutCompleted(); + return; + } + + _scene = new FireScene(center, SceneRadius); + for (var i = 0; i < FireCount; i++) + { + var spot = Positions.Around(center, 2f + (float)_rng.NextDouble() * 12f, _rng); + if (FireScene.TryGround(spot.X, spot.Y, center.Z, out var ground)) + _scene.Ignite(ground); + } + + if (!_scene.Ignited) + { + CalloutCompleted(); + return; + } + + blip.Position = center; + ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | The brush is ~r~burning and spreading~w~. Knock down every fire."); + + var deadline = DateTime.UtcNow.AddMinutes(TimeoutMinutes); + while (!ct.IsCancellationRequested) + { + await Timing.WaitAsync(1_000, ct); + + if (_scene.IsOut()) + { + ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | ~g~Fire contained.~w~ Good work."); + CalloutCompleted(); + return; + } + + if (DateTime.UtcNow > deadline) + { + ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | ~r~The fire spread out of control."); + CalloutFailed(); + return; + } + + if (Vector3.Distance(Game.PlayerPed.Position, _scene.Center) < 90f) + ClientBrain.ShowHelp($"~r~Fires~w~: {_scene.ActiveFires}", 1_100); + } + } + + protected override void OnCleanup() => _scene?.Extinguish(); + + public override void OnCalloutDeclined() => ClientBrain.ShowNotification("Brush fire declined.", "~r~Dispatch"); + + public override void OnCalloutFailed() => ClientBrain.ShowNotification("Brush fire call ended.", "~r~Dispatch"); + } +} diff --git a/FivePRS.Fire/Callouts/VehicleFireCallout.cs b/FivePRS.Fire/Callouts/VehicleFireCallout.cs new file mode 100644 index 0000000..26a3cd4 --- /dev/null +++ b/FivePRS.Fire/Callouts/VehicleFireCallout.cs @@ -0,0 +1,113 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client; +using FivePRS.Client.Callouts; +using FivePRS.Core.Models; + +namespace FivePRS.Fire.Callouts +{ + [CalloutInfo( + name: "Vehicle Fire", + department: Department.Fire, + priority: CalloutPriority.Medium, + weight: 30, + cooldownSeconds: 180, + xpReward: 80)] + public sealed class VehicleFireCallout : CalloutBase + { + private const float IgniteDistance = 150f; + private const float SceneRadius = 20f; + private const int TimeoutMinutes = 20; + + private static readonly string[] Models = + { + "asea", "emperor", "fugitive", "ingot", "primo", "stanier", "tailgater", "regina", "manana", "picador", + }; + + private readonly Random _rng = new(); + private readonly Vector3 _location; + private readonly string _model; + + private FireScene? _scene; + private Vehicle? _vehicle; + + public VehicleFireCallout() + { + _model = Models[_rng.Next(Models.Length)]; + _location = World.GetNextPositionOnStreet(Positions.Around(Game.PlayerPed.Position, 250f + _rng.Next(200), _rng)); + Data.Description = "A vehicle is on fire at the roadside. No occupants reported. Put the fire out before it spreads."; + } + + public override Vector3 GetDispatchLocation() => _location; + + public override async Task OnCalloutAccepted(CancellationToken ct) + { + var blip = TrackBlip(World.CreateBlip(_location)); + blip.Sprite = BlipSprite.Standard; + blip.Color = BlipColor.Red; + blip.Name = "Vehicle fire"; + blip.ShowRoute = true; + + while (Vector3.Distance(Game.PlayerPed.Position, _location) > IgniteDistance) + await Timing.WaitAsync(1_000, ct); + + var model = new Model(_model); + if (!await model.Request(7_000)) + { + CalloutFailed(); + return; + } + + _vehicle = TrackEntity(await World.CreateVehicle(model, _location, _rng.Next(360))); + model.MarkAsNoLongerNeeded(); + if (_vehicle is null || !_vehicle.Exists()) + { + CalloutFailed(); + return; + } + + _vehicle.IsEngineRunning = false; + API.SetVehicleEngineHealth(_vehicle.Handle, 50f); + API.SetVehicleDoorsLocked(_vehicle.Handle, 2); + + _scene = new FireScene(_vehicle.Position, SceneRadius); + API.StartEntityFire(_vehicle.Handle); + _scene.Ignite(_vehicle.Position + _vehicle.ForwardVector * 2f); + _scene.Ignite(_vehicle.Position - _vehicle.ForwardVector * 2.5f); + + ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | The vehicle is ~r~burning~w~. Put it out with your extinguisher or the engine's water cannon."); + + var deadline = DateTime.UtcNow.AddMinutes(TimeoutMinutes); + while (!ct.IsCancellationRequested) + { + await Timing.WaitAsync(1_000, ct); + + if (_scene.IsOut(_vehicle)) + { + ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | ~g~Fire out.~w~ Scene secured."); + CalloutCompleted(); + return; + } + + if (DateTime.UtcNow > deadline) + { + ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | ~r~The fire burned out of control."); + CalloutFailed(); + return; + } + + if (Vector3.Distance(Game.PlayerPed.Position, _scene.Center) < 60f) + ClientBrain.ShowHelp($"~r~Fires~w~: {_scene.ActiveFires}", 1_100); + } + } + + protected override void OnCleanup() => _scene?.Extinguish(); + + public override void OnCalloutDeclined() => ClientBrain.ShowNotification("Vehicle fire declined.", "~r~Dispatch"); + + public override void OnCalloutFailed() => ClientBrain.ShowNotification("Vehicle fire call ended.", "~r~Dispatch"); + } +} diff --git a/FivePRS.Fire/FireAgency.cs b/FivePRS.Fire/FireAgency.cs new file mode 100644 index 0000000..632d144 --- /dev/null +++ b/FivePRS.Fire/FireAgency.cs @@ -0,0 +1,24 @@ +using FivePRS.Client.Agency; +using FivePRS.Client.Gear; +using FivePRS.Core.Config; +using FivePRS.Core.Models; + +namespace FivePRS.Fire +{ + public class FireAgency : DepartmentAgency + { + public override Department Department => Department.Fire; + + public override string AgencyName => "Fire Department"; + + protected override DepartmentGear Gear { get; } = new(Department.Fire, tier => tier switch + { + LoadoutTier.Command => "Captain", + LoadoutTier.Senior => "Engineer", + LoadoutTier.Officer => "Firefighter", + _ => "Probationary Firefighter", + }, "Turnout gear"); + + protected override string EquipmentNoun => "turnout gear, equipment and fire engine"; + } +} diff --git a/FivePRS.Fire/FireScene.cs b/FivePRS.Fire/FireScene.cs new file mode 100644 index 0000000..2f78657 --- /dev/null +++ b/FivePRS.Fire/FireScene.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using CitizenFX.Core; +using CitizenFX.Core.Native; + +namespace FivePRS.Fire +{ + public sealed class FireScene + { + private const int MaxSpread = 25; + + private readonly List _fires = new(); + private int _quietChecks; + + public FireScene(Vector3 center, float radius) + { + Center = center; + Radius = radius; + } + + public Vector3 Center { get; } + + public float Radius { get; } + + public bool Ignited => _fires.Count > 0; + + public void Ignite(Vector3 position, bool gas = false) => + _fires.Add(API.StartScriptFire(position.X, position.Y, position.Z, MaxSpread, gas)); + + public int ActiveFires => API.GetNumberOfFiresInRange(Center.X, Center.Y, Center.Z, Radius); + + public bool IsOut(Entity? burning = null) + { + var quiet = ActiveFires == 0 && (burning is null || !burning.Exists() || !API.IsEntityOnFire(burning.Handle)); + _quietChecks = quiet ? _quietChecks + 1 : 0; + return _quietChecks >= 3; + } + + public void Extinguish() + { + foreach (var fire in _fires) + API.RemoveScriptFire(fire); + _fires.Clear(); + API.StopFireInRange(Center.X, Center.Y, Center.Z, Radius); + } + + public static bool TryGround(float x, float y, float nearZ, out Vector3 ground) + { + var z = 0f; + var found = API.GetGroundZFor_3dCoord(x, y, nearZ + 50f, ref z, false); + ground = new Vector3(x, y, z); + + var water = 0f; + return found && !API.GetWaterHeight(x, y, z, ref water); + } + } +} diff --git a/FivePRS.Fire/FivePRS.Fire.csproj b/FivePRS.Fire/FivePRS.Fire.csproj new file mode 100644 index 0000000..4c645f0 --- /dev/null +++ b/FivePRS.Fire/FivePRS.Fire.csproj @@ -0,0 +1,30 @@ + + + + net452 + 10 + enable + Library + FivePRS.Fire + FivePRS.Fire.net + false + $(MSBuildThisFileDirectory)..\bin\client\ + true + + + + + + + $(PkgNewtonsoft_Json)\lib\portable-net40+sl5+win8+wp8+wpa81\Newtonsoft.Json.dll + + + + + + + + + + + diff --git a/FivePRS.Native/FivePRS.Native.csproj b/FivePRS.Native/FivePRS.Native.csproj new file mode 100644 index 0000000..9e63c0b --- /dev/null +++ b/FivePRS.Native/FivePRS.Native.csproj @@ -0,0 +1,13 @@ + + + + netstandard2.0 + 10 + enable + FivePRS.Native + FivePRS.Native + false + $(MSBuildThisFileDirectory)..\bin\server\ + + + diff --git a/FivePRS.Native/NativeLibraries.cs b/FivePRS.Native/NativeLibraries.cs new file mode 100644 index 0000000..c6eb8b7 --- /dev/null +++ b/FivePRS.Native/NativeLibraries.cs @@ -0,0 +1,86 @@ +using System; +using System.Runtime.InteropServices; + +namespace FivePRS.Native +{ + public sealed class NativeLibrary + { + private const int RtldNow = 2; + private const int RtldGlobal = 0x100; + + private readonly IntPtr _handle; + private readonly Func _symbol; + + private NativeLibrary(IntPtr handle, Func symbol) + { + _handle = handle; + _symbol = symbol; + } + + public IntPtr Symbol(string name) => _symbol(_handle, name); + + public static NativeLibrary Open(string path) => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? OpenWindows(path) : OpenUnix(path); + + private static NativeLibrary OpenWindows(string path) + { + var handle = Windows.LoadLibraryW(path); + if (handle == IntPtr.Zero) + throw new DllNotFoundException($"Could not load {path} (Win32 error {Marshal.GetLastWin32Error()})."); + return new NativeLibrary(handle, Windows.GetProcAddress); + } + + private static NativeLibrary OpenUnix(string path) + { + try + { + return OpenUnix(path, Internal.dlopen(path, RtldNow | RtldGlobal), Internal.dlerror, Internal.dlsym); + } + catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException) + { + return OpenUnix(path, Libdl.dlopen(path, RtldNow | RtldGlobal), Libdl.dlerror, Libdl.dlsym); + } + } + + private static NativeLibrary OpenUnix(string path, IntPtr handle, Func error, Func symbol) + { + if (handle != IntPtr.Zero) return new NativeLibrary(handle, symbol); + + var message = error(); + throw new DllNotFoundException($"Could not load {path} ({(message == IntPtr.Zero ? "unknown error" : Marshal.PtrToStringAnsi(message))})."); + } + + private static class Windows + { + [DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr LoadLibraryW(string path); + + [DllImport("kernel32", CharSet = CharSet.Ansi, ExactSpelling = true)] + public static extern IntPtr GetProcAddress(IntPtr module, string name); + } + + private static class Internal + { + [DllImport("__Internal")] + public static extern IntPtr dlopen(string path, int flags); + + [DllImport("__Internal")] + public static extern IntPtr dlsym(IntPtr handle, string name); + + [DllImport("__Internal")] + public static extern IntPtr dlerror(); + } + + private static class Libdl + { + [DllImport("libdl.so.2")] + public static extern IntPtr dlopen(string path, int flags); + + [DllImport("libdl.so.2")] + public static extern IntPtr dlsym(IntPtr handle, string name); + + [DllImport("libdl.so.2")] + public static extern IntPtr dlerror(); + } + } +} diff --git a/FivePRS.Police/Config/PoliceCatalog.cs b/FivePRS.Police/Config/PoliceCatalog.cs deleted file mode 100644 index 085e022..0000000 --- a/FivePRS.Police/Config/PoliceCatalog.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Linq; -using CitizenFX.Core.Native; -using FivePRS.Client.Stations; -using FivePRS.Core.Config; -using FivePRS.Core.Text; - -namespace FivePRS.Police.Config -{ - public static class PoliceCatalog - { - private static readonly LoadoutTier[] Tiers = - { LoadoutTier.Recruit, LoadoutTier.Officer, LoadoutTier.Senior, LoadoutTier.Command }; - - private static readonly LoadoutTier[] VehicleTiers = - { LoadoutTier.Recruit, LoadoutTier.Senior, LoadoutTier.Command }; - - public static StationCatalog Build(string? agencyId) - { - var catalog = new StationCatalog(); - AddWeapons(catalog, agencyId); - AddUniforms(catalog, agencyId); - AddVehicles(catalog, agencyId); - return catalog; - } - - private static void AddWeapons(StationCatalog catalog, string? agencyId) - { - var loadouts = ConfigManager.PoliceLoadouts; - - foreach (var tier in Tiers) - { - var weapons = loadouts.WeaponsFor(agencyId, tier).Weapons; - var minRank = PoliceLoadoutsConfig.MinRankFor(tier); - - catalog.Kits.Add(new GearKit - { - Name = PoliceLoadouts.TierName(tier), - MinRank = minRank, - Weapons = weapons.Select(PoliceLoadouts.ToEntry).ToArray(), - }); - - foreach (var weapon in weapons) - { - if (catalog.Weapons.Any(w => string.Equals(w.Label, WeaponLabels.For(weapon.Name, weapon.Label), StringComparison.Ordinal))) - continue; - - var entry = PoliceLoadouts.ToEntry(weapon); - entry.SetAsCurrent = false; - - catalog.Weapons.Add(new GearWeapon - { - Label = WeaponLabels.For(weapon.Name, weapon.Label), - MinRank = minRank, - Weapon = entry, - }); - } - } - } - - private static void AddUniforms(StationCatalog catalog, string? agencyId) - { - var loadouts = ConfigManager.PoliceLoadouts; - var patrol = loadouts.UniformFor(agencyId, LoadoutTier.Recruit); - var command = loadouts.UniformFor(agencyId, LoadoutTier.Command); - - catalog.Uniforms.Add(new GearUniform { Name = "Patrol uniform", Outfit = PoliceLoadouts.ToOutfit(patrol) }); - - if (!ReferenceEquals(patrol, command)) - { - catalog.Uniforms.Add(new GearUniform - { - Name = "Command uniform", - MinRank = PoliceLoadoutsConfig.MinRankFor(LoadoutTier.Command), - Outfit = PoliceLoadouts.ToOutfit(command), - }); - } - } - - private static void AddVehicles(StationCatalog catalog, string? agencyId) - { - foreach (var tier in VehicleTiers) - { - var vehicles = ConfigManager.PoliceVehicles.TierFor(agencyId, tier); - - foreach (var model in vehicles.Models) - { - if (catalog.Vehicles.Any(v => string.Equals(v.Config.ModelPool[0], model, StringComparison.OrdinalIgnoreCase))) - continue; - - catalog.Vehicles.Add(new GearVehicle - { - Label = VehicleLabel(model), - MinRank = PoliceLoadoutsConfig.MinRankFor(tier), - Config = PoliceVehicles.ToConfig(vehicles, model), - }); - } - } - - foreach (var group in catalog.Vehicles.GroupBy(v => v.Label).Where(g => g.Count() > 1)) - { - foreach (var vehicle in group) - vehicle.Label = $"{vehicle.Label} ({vehicle.Config.ModelPool[0]})"; - } - } - - private static string VehicleLabel(string model) - { - var label = API.GetLabelText(API.GetDisplayNameFromVehicleModel((uint)API.GetHashKey(model))); - return string.IsNullOrEmpty(label) || label == "NULL" ? model : label; - } - } -} diff --git a/FivePRS.Police/Config/PoliceLoadouts.cs b/FivePRS.Police/Config/PoliceLoadouts.cs deleted file mode 100644 index d9ac8fa..0000000 --- a/FivePRS.Police/Config/PoliceLoadouts.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Linq; -using CitizenFX.Core.Native; -using FivePRS.Client.Loadout; -using FivePRS.Core.Config; - -namespace FivePRS.Police.Config -{ - public static class PoliceLoadouts - { - public static LoadoutDefinition GetForRank(int rank, string? agencyId) - { - var cfg = ConfigManager.PoliceLoadouts; - var tier = PoliceLoadoutsConfig.TierForRank(rank); - var outfit = ToOutfit(cfg.UniformFor(agencyId, tier)); - - outfit.Name = TierName(tier); - outfit.Weapons = cfg.WeaponsFor(agencyId, tier).Weapons.Select(ToEntry).ToArray(); - return outfit; - } - - public static WeaponEntry ToEntry(WeaponDef weapon) => new() - { - Hash = (uint)API.GetHashKey(weapon.Name), - Ammo = weapon.Ammo, - SetAsCurrent = weapon.SetCurrent, - }; - - public static LoadoutDefinition ToOutfit(UniformDef uniform) => new() - { - Components = uniform.Components - .Select(c => new ComponentEntry { ComponentId = c.Slot, DrawableId = c.Drawable, TextureId = c.Texture }) - .ToArray(), - Props = uniform.Props - .Select(p => new PropEntry { PropId = p.Slot, DrawableId = p.Drawable, TextureId = p.Texture }) - .ToArray(), - MalePedModel = NullIfEmpty(uniform.PedModels?.Male), - FemalePedModel = NullIfEmpty(uniform.PedModels?.Female), - FallbackMalePedModel = NullIfEmpty(ConfigManager.PoliceLoadouts.FallbackPedModels.Male), - FallbackFemalePedModel = NullIfEmpty(ConfigManager.PoliceLoadouts.FallbackPedModels.Female), - }; - - public static string TierName(LoadoutTier tier) => tier switch - { - LoadoutTier.Command => "Command", - LoadoutTier.Senior => "Senior Officer", - LoadoutTier.Officer => "Officer", - _ => "Recruit", - }; - - private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; - } -} diff --git a/FivePRS.Police/Config/PoliceVehicles.cs b/FivePRS.Police/Config/PoliceVehicles.cs deleted file mode 100644 index 3143af4..0000000 --- a/FivePRS.Police/Config/PoliceVehicles.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FivePRS.Client.VehicleSpawner; -using FivePRS.Core.Config; - -namespace FivePRS.Police.Config -{ - public static class PoliceVehicles - { - public static PatrolVehicleConfig GetForRank(int rank, string? agencyId) => - ToConfig(ConfigManager.PoliceVehicles.TierFor(agencyId, PoliceLoadoutsConfig.TierForRank(rank))); - - public static PatrolVehicleConfig ToConfig(VehicleTierDef tier, string? model = null) => new() - { - ModelPool = model is null ? tier.Models : new[] { model }, - PrimaryColor = tier.PrimaryColor, - SecondaryColor = tier.SecondaryColor, - DirtLevel = tier.DirtLevel, - Livery = tier.Livery, - PlateText = tier.PlateText, - ForcedExtras = tier.ForcedExtras, - DisabledExtras = tier.DisabledExtras, - }; - } -} diff --git a/FivePRS.Police/PoliceAgency.cs b/FivePRS.Police/PoliceAgency.cs index 0bf6566..0c33e17 100644 --- a/FivePRS.Police/PoliceAgency.cs +++ b/FivePRS.Police/PoliceAgency.cs @@ -1,175 +1,24 @@ -using System; -using System.Threading.Tasks; -using CitizenFX.Core; using FivePRS.Client.Agency; -using FivePRS.Client.Callouts; -using FivePRS.Client.Loadout; -using FivePRS.Client.Stations; -using FivePRS.Client.VehicleSpawner; +using FivePRS.Client.Gear; using FivePRS.Core.Config; using FivePRS.Core.Models; -using FivePRS.Police.Config; namespace FivePRS.Police { - public class PoliceAgency : BaseAgency + public class PoliceAgency : DepartmentAgency { public override Department Department => Department.Police; - public override string AgencyName => "Police Department"; - private readonly CalloutRegistry _registry; - private readonly CalloutDispatcher _dispatcher; + public override string AgencyName => "Police Department"; - private StationDef? _arrivalStation; - private PatrolVehicleConfig? _arrivalVehicle; - - public PoliceAgency() - { - _registry = new CalloutRegistry(); - _registry.Discover(GetType().Assembly); - - _dispatcher = new CalloutDispatcher(Department.Police, _registry, OnCalloutEnded); - } - - public override StationCatalog? BuildCatalog(PlayerData player) => PoliceCatalog.Build(player.Agency); - - public override async Task OnDuty(PlayerData player) - { - await base.OnDuty(player); - - _registry.DiscoverAll(); - Debug.WriteLine($"[PoliceAgency] {_registry.Count} callout(s) registered."); - _dispatcher.Start(); - - var mode = StationService.TakePendingStart(out var station); - var message = ShouldEquip(mode) - ? await EquipAsync(player, mode, station) - : await ReportToStationAsync(mode, station); - - Notify($"~b~{DisplayName}~w~ | ~g~ON DUTY~w~~n~{message}"); - } - - private static bool ShouldEquip(DutyStartMode mode) => ConfigManager.Settings.DutyEquipment switch - { - DutyEquipmentMode.Always => true, - DutyEquipmentMode.Station => false, - _ => mode == DutyStartMode.Here, - }; - - private async Task EquipAsync(PlayerData player, DutyStartMode mode, StationDef? station) - { - if (mode == DutyStartMode.Teleport && station is not null) - await StationService.TeleportAsync(station); - - var loadout = PoliceLoadouts.GetForRank(player.Rank, player.Agency); - await LoadoutManager.ApplyAsync(loadout); - - var vehicleConfig = PoliceVehicles.GetForRank(player.Rank, player.Agency); - return $"{loadout.Name} loadout applied.~n~{await SpawnShiftVehicleAsync(player, mode, station, vehicleConfig)}"; - } - - private static async Task ReportToStationAsync(DutyStartMode mode, StationDef? station) - { - if (mode == DutyStartMode.Drive && station is not null) - { - StationService.SetRoute(station); - return $"Head to ~y~{station.Name}~w~ to collect your uniform, weapons and vehicle."; - } - - if (mode == DutyStartMode.Teleport && station is not null) - { - await StationService.TeleportAsync(station); - return "Collect your uniform, weapons and vehicle at the station."; - } - - return "Visit one of your stations to collect your uniform, weapons and vehicle."; - } - - private async Task SpawnShiftVehicleAsync(PlayerData player, DutyStartMode mode, StationDef? station, PatrolVehicleConfig vehicleConfig) - { - if (mode == DutyStartMode.Drive && station is not null) - { - StationService.SetRoute(station); - _arrivalStation = station; - _arrivalVehicle = vehicleConfig; - Tick += WaitForArrivalTick; - return $"Head to ~y~{station.Name}~w~. Your patrol vehicle is waiting there."; - } - - if (mode == DutyStartMode.Teleport && station is not null) - return VehicleMessage(await Vehicles.SpawnAsync(vehicleConfig, StationService.ParkingSpot(station), false)); - - var nearby = StationService.Nearby(Department, player.Agency); - var vehicle = nearby is not null - ? await Vehicles.SpawnAsync(vehicleConfig, StationService.ParkingSpot(nearby), false) - : await Vehicles.SpawnAsync(vehicleConfig, PatrolVehicleSpawner.RoadSpawnPoint(), true); - - return VehicleMessage(vehicle); - } - - private async Task WaitForArrivalTick() - { - if (_arrivalStation is null || _arrivalVehicle is null || !IsActive) - { - StopWaitingForArrival(); - return; - } - - if (StationService.DistanceTo(_arrivalStation) > StationService.ArrivalRadius) - { - await Delay(1000); - return; - } - - var station = _arrivalStation; - var config = _arrivalVehicle; - StopWaitingForArrival(); - - var vehicle = await Vehicles.SpawnAsync(config, StationService.ParkingSpot(station), false); - Notify($"~b~{station.Name}~w~ | {VehicleMessage(vehicle)}"); - } - - private void StopWaitingForArrival() - { - Tick -= WaitForArrivalTick; - _arrivalStation = null; - _arrivalVehicle = null; - } - - private static string VehicleMessage(CitizenFX.Core.Vehicle? vehicle) => - vehicle is not null - ? "Your patrol vehicle is marked on the map." - : "~r~Vehicle spawn failed~w~ — proceed on foot."; - - public override async Task OffDuty(PlayerData player) - { - await base.OffDuty(player); - - _dispatcher.Stop(); - StopWaitingForArrival(); - Vehicles.Despawn(); - await LoadoutManager.StripAsync(); - - Notify($"~b~{DisplayName}~w~ | ~r~OFF DUTY~w~. Loadout and vehicle removed."); - } - - public override async Task OnCalloutReceived(CalloutData callout) - { - await _dispatcher.HandleOfferAsync(callout); - } - - private void OnCalloutEnded(CalloutBase callout, CalloutResult result) + protected override DepartmentGear Gear { get; } = new(Department.Police, tier => tier switch { - if (result == CalloutResult.Completed) - { - Notify("~g~CALLOUT COMPLETE~w~"); - } - else if (result == CalloutResult.Failed) - { - Notify("~r~CALLOUT FAILED~w~ | No XP awarded."); - } + LoadoutTier.Command => "Command", + LoadoutTier.Senior => "Senior Officer", + LoadoutTier.Officer => "Officer", + _ => "Recruit", + }, "Patrol uniform"); - Debug.WriteLine($"[PoliceAgency] Callout '{callout.Data.Name}' ended: {result}"); - } + protected override string EquipmentNoun => "uniform, weapons and vehicle"; } } diff --git a/FivePRS.Police/TrafficStops/TrafficStop.cs b/FivePRS.Police/TrafficStops/TrafficStop.cs new file mode 100644 index 0000000..343a262 --- /dev/null +++ b/FivePRS.Police/TrafficStops/TrafficStop.cs @@ -0,0 +1,203 @@ +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.Arrest; +using FivePRS.Core.Civilian; +using FivePRS.Core.Police; + +namespace FivePRS.Police.TrafficStops +{ + public enum StopPhase + { + PullingOver, + Stopped, + Fleeing, + } + + public sealed class TrafficStop + { + private const int NormalDrivingStyle = 786603; + private const int RecklessDrivingStyle = 786468; + private const int BrakeAction = 27; + private const int RightIndicator = 0; + private const int LeaveDoorOpen = 256; + private const float PullOverSpeed = 7f; + private const float RoadsideOffset = 3.5f; + private const float AheadDistance = 22f; + + private readonly Blip _blip; + + public TrafficStop(Ped driver, Vehicle? vehicle, int seed) + { + Driver = driver; + Vehicle = vehicle; + Seed = seed; + Female = !API.IsPedMale(driver.Handle); + Person = StreetProfiles.Person(seed, Female); + StartedAt = API.GetGameTimer(); + + if (vehicle is not null) + { + Plate = API.GetVehicleNumberPlateText(vehicle.Handle).Trim(); + Model = VehicleName(vehicle); + API.SetEntityAsMissionEntity(vehicle.Handle, true, true); + } + else + { + Phase = StopPhase.Stopped; + } + + API.SetEntityAsMissionEntity(driver.Handle, true, true); + driver.BlockPermanentEvents = true; + ArrestManager.RegisterSuspect(driver); + + _blip = vehicle is not null ? vehicle.AttachBlip() : driver.AttachBlip(); + _blip.Sprite = vehicle is not null ? BlipSprite.PersonalVehicleCar : BlipSprite.Standard; + _blip.Color = BlipColor.Yellow; + _blip.Scale = 0.8f; + _blip.Name = vehicle is not null ? $"Traffic stop · {Plate}" : "Stopped person"; + } + + public Ped Driver { get; } + public Vehicle? Vehicle { get; } + public int Seed { get; } + public bool Female { get; } + public StreetPerson Person { get; } + public string Plate { get; } = string.Empty; + public string Model { get; } = string.Empty; + public int StartedAt { get; } + public StopPhase Phase { get; private set; } = StopPhase.PullingOver; + + public LookupRecord? Identity { get; set; } + public LookupRecord? Registration { get; set; } + public string? RegistrationError { get; set; } + public bool AwaitingIdentity { get; set; } + public bool AwaitingPlate { get; set; } + public bool Searched { get; set; } + + public bool IsVehicleStop => Vehicle is not null; + + public bool IsValid => Driver.Exists() && !Driver.IsDead && (Vehicle is null || Vehicle.Exists()); + + public bool DriverInVehicle => Vehicle is not null && Driver.IsInVehicle(Vehicle); + + public bool InCustody => ArrestManager.IsInCustody(Driver); + + public Entity Anchor => (Entity?)Vehicle ?? Driver; + + public int? CharacterId => Identity?.Character.Id; + + public RecordInfo[] ActiveWarrants => + Identity?.Records.Where(r => r.Type == RecordType.Warrant && r.Active).ToArray() ?? new RecordInfo[0]; + + public void PullOver() + { + if (Vehicle is null) return; + + var target = Vehicle.Position + Vehicle.ForwardVector * AheadDistance + Vehicle.RightVector * RoadsideOffset; + API.SetVehicleIndicatorLights(Vehicle.Handle, RightIndicator, true); + API.TaskVehicleDriveToCoordLongrange(Driver.Handle, Vehicle.Handle, target.X, target.Y, target.Z, + PullOverSpeed, NormalDrivingStyle, 2f); + } + + public void Hold() + { + Phase = StopPhase.Stopped; + + if (Vehicle is not null && DriverInVehicle) + { + API.TaskVehicleTempAction(Driver.Handle, Vehicle.Handle, BrakeAction, int.MaxValue); + return; + } + + if (InCustody) return; + Driver.Task.ClearAll(); + API.TaskTurnPedToFaceEntity(Driver.Handle, Game.PlayerPed.Handle, 1_500); + Driver.Task.StandStill(-1); + } + + public void Flee() + { + Phase = StopPhase.Fleeing; + _blip.Color = BlipColor.Red; + _blip.Name = "Fleeing suspect"; + + if (Vehicle is not null && DriverInVehicle) + { + API.SetVehicleIndicatorLights(Vehicle.Handle, RightIndicator, false); + API.SetDriverAggressiveness(Driver.Handle, 1f); + API.SetDriverAbility(Driver.Handle, 1f); + API.TaskVehicleDriveWander(Driver.Handle, Vehicle.Handle, 35f, RecklessDrivingStyle); + return; + } + + API.SetPedFleeAttributes(Driver.Handle, 0, false); + API.TaskSmartFleePed(Driver.Handle, Game.PlayerPed.Handle, 300f, -1, false, false); + } + + public void StepOut() + { + if (Vehicle is not null) API.TaskLeaveVehicle(Driver.Handle, Vehicle.Handle, LeaveDoorOpen); + } + + public void GetBackIn() + { + if (Vehicle is not null) API.TaskEnterVehicle(Driver.Handle, Vehicle.Handle, -1, -1, 1f, 1, 0); + } + + public async Task EndAsync(bool release) + { + if (_blip.Exists()) _blip.Delete(); + if (Vehicle is not null && Vehicle.Exists()) API.SetVehicleIndicatorLights(Vehicle.Handle, RightIndicator, false); + + if (Driver.Exists() && !InCustody) + { + ArrestManager.UnregisterSuspect(Driver); + Driver.BlockPermanentEvents = false; + + if (release && !Driver.IsDead) + await ReleaseAsync(); + + if (Driver.Exists()) + { + var driver = Driver.Handle; + API.SetEntityAsNoLongerNeeded(ref driver); + } + } + + if (Vehicle is not null && Vehicle.Exists()) + { + var vehicle = Vehicle.Handle; + API.SetEntityAsNoLongerNeeded(ref vehicle); + } + } + + private async Task ReleaseAsync() + { + if (Vehicle is null || !Vehicle.Exists()) + { + Driver.Task.ClearAll(); + API.TaskWanderStandard(Driver.Handle, 10f, 10); + return; + } + + if (!DriverInVehicle) + { + GetBackIn(); + for (var i = 0; i < 20 && Driver.Exists() && !DriverInVehicle; i++) + await BaseScript.Delay(500); + } + + if (Driver.Exists() && DriverInVehicle) + API.TaskVehicleDriveWander(Driver.Handle, Vehicle.Handle, 15f, NormalDrivingStyle); + } + + private static string VehicleName(Vehicle vehicle) + { + var display = API.GetDisplayNameFromVehicleModel((uint)vehicle.Model.Hash); + var label = API.GetLabelText(display); + return string.IsNullOrEmpty(label) || label == "NULL" ? display : label; + } + } +} diff --git a/FivePRS.Police/TrafficStops/TrafficStopManager.cs b/FivePRS.Police/TrafficStops/TrafficStopManager.cs new file mode 100644 index 0000000..7bc9ca7 --- /dev/null +++ b/FivePRS.Police/TrafficStops/TrafficStopManager.cs @@ -0,0 +1,511 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client; +using FivePRS.Client.Agency; +using FivePRS.Client.Arrest; +using FivePRS.Client.Menu; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using FivePRS.Core.Police; +using FivePRS.Core.Text; +using Newtonsoft.Json; + +namespace FivePRS.Police.TrafficStops +{ + public class TrafficStopManager : BaseScript + { + private const float TargetRange = 30f; + private const float TargetCone = 0.85f; + private const float PedestrianRange = 2.5f; + private const float PedestrianCone = 0.5f; + private const float TalkRange = 3f; + private const float MenuRange = 4.5f; + private const float AbandonRange = 150f; + private const float VehicleEscapeRange = 400f; + private const float FootEscapeRange = 150f; + private const int PullOverTimeoutMs = 15_000; + private const int ScanIntervalMs = 250; + private const string Subject = "~b~Stop"; + + private static readonly Random Rng = new(); + private static readonly Dictionary Seeds = new(); + + private static TrafficStop? _stop; + private static Vehicle? _vehicleCandidate; + private static Ped? _pedestrianCandidate; + + public TrafficStopManager() + { + API.RegisterCommand(KeyCommands.TrafficStop, new Action, string>((_, __, ___) => StartVehicleStop()), false); + API.RegisterKeyMapping(KeyCommands.TrafficStop, "FivePRS: Traffic stop", "keyboard", "LSHIFT"); + + EventHandlers[EventNames.ClientStopResult] += new Action(OnStopResult); + EventHandlers[EventNames.ClientLookupError] += new Action(OnLookupError); + EventHandlers["onClientResourceStop"] += new Action(name => + { + if (name == API.GetCurrentResourceName()) _ = EndAsync(true, null); + }); + + Interactions.Register(TryInteract); + Tick += OnTick; + } + + private static bool OnDutyAsPolice => BaseAgency.Active?.Department == Department.Police; + + private async Task OnTick() + { + await Delay(ScanIntervalMs); + + if (_stop is not null) + { + await UpdateStopAsync(_stop); + return; + } + + _vehicleCandidate = OnDutyAsPolice ? FindVehicle() : null; + _pedestrianCandidate = OnDutyAsPolice && _vehicleCandidate is null && !CompactMenu.IsOpen ? FindPedestrian() : null; + + if (_vehicleCandidate is not null) + ClientBrain.ShowHelp($"{Binds.TrafficStop} Pull over this vehicle", ScanIntervalMs + 100); + else if (_pedestrianCandidate is not null) + ClientBrain.ShowHelp($"{Binds.Interact} Stop and talk to this person", ScanIntervalMs + 100); + } + + private static Vehicle? FindVehicle() + { + var player = Game.PlayerPed; + var own = player.CurrentVehicle; + if (own is null || !own.Exists() || own.Driver != player || !API.IsVehicleSirenOn(own.Handle)) return null; + + Vehicle? best = null; + var bestDistance = TargetRange; + + foreach (var vehicle in World.GetAllVehicles()) + { + if (vehicle.Handle == own.Handle || !vehicle.Exists() || API.IsEntityAMissionEntity(vehicle.Handle)) continue; + + var driver = vehicle.Driver; + if (driver is null || !driver.Exists() || driver.IsPlayer || driver.IsDead) continue; + + var offset = vehicle.Position - own.Position; + var distance = offset.Length(); + if (distance >= bestDistance || distance < 1f) continue; + + offset.Normalize(); + if (Vector3.Dot(offset, own.ForwardVector) < TargetCone) continue; + + best = vehicle; + bestDistance = distance; + } + + return best; + } + + private static Ped? FindPedestrian() + { + var player = Game.PlayerPed; + if (player.IsInVehicle() || player.IsDead || ArrestManager.HasCustody) return null; + + Ped? best = null; + var bestDistance = PedestrianRange; + + foreach (var ped in World.GetAllPeds()) + { + if (ped.Handle == player.Handle || !ped.Exists() || ped.IsPlayer || ped.IsDead || ped.IsInVehicle()) continue; + if (!API.IsPedHuman(ped.Handle) || API.IsEntityAMissionEntity(ped.Handle)) continue; + + var offset = ped.Position - player.Position; + var distance = offset.Length(); + if (distance >= bestDistance) continue; + + offset.Normalize(); + if (Vector3.Dot(offset, player.ForwardVector) < PedestrianCone) continue; + + best = ped; + bestDistance = distance; + } + + return best; + } + + private static int SeedFor(Ped ped) + { + if (!Seeds.TryGetValue(ped.Handle, out var seed)) + Seeds[ped.Handle] = seed = Rng.Next(); + return seed; + } + + private static void StartVehicleStop() + { + if (_stop is not null || !OnDutyAsPolice) return; + + var vehicle = _vehicleCandidate ?? FindVehicle(); + var driver = vehicle?.Driver; + if (vehicle is null || driver is null || !driver.Exists()) return; + + _vehicleCandidate = null; + _stop = new TrafficStop(driver, vehicle, SeedFor(driver)); + _stop.PullOver(); + + ClientBrain.ShowNotification($"Pulling over the ~y~{_stop.Model}~w~, plate ~y~{_stop.Plate}~w~.", Subject); + } + + private static bool StartPedestrianStop(Ped ped) + { + if (_stop is not null || !OnDutyAsPolice || !ped.Exists() || ped.IsDead) return false; + + _pedestrianCandidate = null; + var stop = new TrafficStop(ped, null, SeedFor(ped)); + _stop = stop; + + if (stop.Person.WillFlee) + { + stop.Flee(); + ClientBrain.ShowNotification("~r~They're running!~w~ Catch them and press " + Binds.Cuff + " to cuff them.", Subject); + return true; + } + + stop.Hold(); + OpenMenu(stop); + return true; + } + + private async Task UpdateStopAsync(TrafficStop stop) + { + if (!OnDutyAsPolice || !stop.IsValid) + { + await EndAsync(true, stop.IsValid ? null : "The stop has ended."); + return; + } + + var distance = Vector3.Distance(Game.PlayerPed.Position, stop.Anchor.Position); + + switch (stop.Phase) + { + case StopPhase.PullingOver: + var elapsed = API.GetGameTimer() - stop.StartedAt; + if (stop.Person.WillFlee && stop.Vehicle!.Speed < 4f) + { + stop.Flee(); + ClientBrain.ShowNotification($"~r~The driver is fleeing!~w~ Pursue the ~y~{stop.Model}~w~.", Subject); + } + else if ((stop.Vehicle!.Speed < 0.5f && elapsed > 2_000) || elapsed > PullOverTimeoutMs) + { + stop.Hold(); + ClientBrain.ShowNotification($"Vehicle stopped. Approach the driver and press {Binds.Interact}.", Subject); + } + break; + + case StopPhase.Fleeing: + if (stop.InCustody) + { + stop.Hold(); + ClientBrain.ShowNotification($"Suspect in custody. Press {Binds.Interact} near them to continue the stop.", Subject); + return; + } + + if (distance > (stop.IsVehicleStop && stop.DriverInVehicle ? VehicleEscapeRange : FootEscapeRange)) + await EndAsync(false, "~r~The suspect got away."); + break; + + case StopPhase.Stopped: + if (distance > AbandonRange) + { + await EndAsync(true, $"You left the stop, so {Noun(stop)} was released."); + return; + } + + if (!CompactMenu.IsOpen && !Game.PlayerPed.IsInVehicle() && DriverDistance(stop) <= TalkRange) + ClientBrain.ShowHelp($"{Binds.Interact} Talk to {Noun(stop)}", ScanIntervalMs + 100); + break; + } + } + + private static string Noun(TrafficStop stop) => stop.IsVehicleStop ? "the driver" : "the person"; + + private static float DriverDistance(TrafficStop stop) => + Vector3.Distance(Game.PlayerPed.Position, stop.Driver.Position); + + private static bool TryInteract() + { + var stop = _stop; + if (stop is null) + { + var ped = _pedestrianCandidate; + return ped is not null && DistanceTo(ped) <= TalkRange && StartPedestrianStop(ped); + } + + if (stop.Phase != StopPhase.Stopped || !stop.IsValid) return false; + if (Game.PlayerPed.IsInVehicle() || DriverDistance(stop) > TalkRange) return false; + + OpenMenu(stop); + return true; + } + + private static float DistanceTo(Ped ped) => ped.Exists() ? Vector3.Distance(Game.PlayerPed.Position, ped.Position) : float.MaxValue; + + private static void OpenMenu(TrafficStop stop) => + CompactMenu.Open(() => StopPage(stop), () => + _stop == stop && stop.IsValid && !Game.PlayerPed.IsInVehicle() && DriverDistance(stop) <= MenuRange, + ConfigManager.Settings.Branding.Departments.Police); + + private static MenuPage StopPage(TrafficStop stop) + { + var identified = stop.CharacterId is not null; + var name = identified ? $"{stop.Identity!.Character.FirstName} {stop.Identity.Character.LastName}" : null; + var page = new MenuPage + { + Title = stop.IsVehicleStop ? "Traffic stop" : "Pedestrian stop", + Subtitle = stop.IsVehicleStop ? $"{stop.Model} · {stop.Plate}" : name ?? "Unidentified", + }; + + page.Items.Add(new MenuItem + { + Label = "Ask for ID", + Detail = name ?? (stop.AwaitingIdentity ? "Checking…" : null), + Disabled = stop.AwaitingIdentity, + Select = () => Run(() => RequestIdentity(stop)), + }); + + if (stop.IsVehicleStop) + { + page.Items.Add(new MenuItem + { + Label = "Run plate", + Detail = stop.Registration is not null ? "Done" : stop.RegistrationError is not null ? "No record" : stop.AwaitingPlate ? "Checking…" : stop.Plate, + Disabled = stop.AwaitingPlate, + Select = () => Run(() => RequestPlate(stop)), + }); + + if (!stop.InCustody) + { + page.Items.Add(stop.DriverInVehicle + ? new MenuItem { Label = "Ask to step out", Select = () => Run(stop.StepOut) } + : new MenuItem { Label = "Ask to get back in", Select = () => Run(stop.GetBackIn) }); + } + } + + page.Items.Add(new MenuItem + { + Label = stop.IsVehicleStop ? "Search the driver" : "Search them", + Detail = stop.DriverInVehicle ? "Step out first" : stop.Searched ? "Done" : null, + Disabled = stop.DriverInVehicle, + Select = () => Run(() => Search(stop)), + }); + + page.Items.Add(new MenuItem + { + Label = "Issue a warning", + Detail = identified ? null : "ID first", + Disabled = !identified, + Submenu = () => OffencePage(stop, RecordType.Warning), + }); + + page.Items.Add(new MenuItem + { + Label = "Issue a citation", + Detail = identified ? null : "ID first", + Disabled = !identified, + Submenu = () => OffencePage(stop, RecordType.Citation), + }); + + foreach (var warrant in stop.ActiveWarrants) + { + page.Items.Add(new MenuItem + { + Label = "Serve warrant", + Detail = warrant.Description, + Select = () => Run(() => ServeWarrant(warrant)), + }); + } + + page.Items.Add(stop.InCustody + ? new MenuItem + { + Label = "Record the arrest", + Detail = identified ? null : "ID first", + Disabled = !identified, + Select = () => Run(() => RecordArrest(stop)), + } + : new MenuItem { Label = "Arrest", Detail = $"Press {Binds.Cuff}", Disabled = true }); + + page.Items.Add(new MenuItem + { + Label = stop.InCustody ? "End the stop" : stop.IsVehicleStop ? "Release the driver" : "Let them go", + Select = async () => + { + CompactMenu.Close(); + await EndAsync(!stop.InCustody, stop.InCustody ? "Stop ended." : "Released."); + }, + }); + + return page; + } + + private static MenuPage OffencePage(TrafficStop stop, RecordType type) + { + var page = new MenuPage + { + Title = type == RecordType.Citation ? "Citation" : "Warning", + Subtitle = stop.Identity is null ? null : $"{stop.Identity.Character.FirstName} {stop.Identity.Character.LastName}", + }; + + foreach (var offence in ConfigManager.Citations.Offences) + { + var fine = type == RecordType.Citation ? Math.Max(0, Math.Min(offence.Fine, ConfigManager.Settings.MaxFine)) : 0; + page.Items.Add(new MenuItem + { + Label = offence.Label, + Detail = type == RecordType.Citation ? $"${fine}" : null, + Select = () => Run(() => Issue(stop, type, offence.Label, fine)), + }); + } + + return page; + } + + private static Task Run(Action action) + { + action(); + return Task.FromResult(0); + } + + private static void RequestIdentity(TrafficStop stop) + { + if (stop.AwaitingIdentity) return; + stop.AwaitingIdentity = true; + ClientEvents.TriggerServer(EventNames.ServerStopIdentify, stop.Seed, stop.Female); + } + + private static void RequestPlate(TrafficStop stop) + { + if (stop.AwaitingPlate) return; + stop.AwaitingPlate = true; + ClientEvents.TriggerServer(EventNames.ServerStopPlate, stop.Plate, stop.Model, stop.Seed, stop.Female); + } + + private static void Search(TrafficStop stop) + { + stop.Searched = true; + ClientBrain.ShowNotification(stop.Person.Contraband is null + ? "You found nothing of interest." + : $"~o~Found:~w~ {stop.Person.Contraband}.", "~b~Search"); + } + + private static void Issue(TrafficStop stop, RecordType type, string offence, int fine) + { + if (stop.CharacterId is null) return; + + ClientEvents.TriggerServer(EventNames.ServerRecordIssue, stop.CharacterId.Value, (int)type, offence, fine); + ClientBrain.ShowNotification(type == RecordType.Citation + ? $"Citation issued: {offence} (~g~${fine}~w~)." + : $"Warning issued: {offence}.", Subject); + OpenMenu(stop); + } + + private static void ServeWarrant(RecordInfo warrant) + { + ClientEvents.TriggerServer(EventNames.ServerRecordResolve, warrant.Id, true); + warrant.Active = false; + ClientBrain.ShowNotification($"Warrant served: {warrant.Description}.", Subject); + } + + private static void RecordArrest(TrafficStop stop) + { + if (stop.CharacterId is null) return; + + var warrants = stop.ActiveWarrants; + var where = stop.IsVehicleStop ? "a traffic stop" : "a pedestrian stop"; + var reason = warrants.Length > 0 + ? $"Arrested during {where} on a warrant: {warrants[0].Description}" + : $"Arrested during {where}"; + ClientEvents.TriggerServer(EventNames.ServerRecordIssue, stop.CharacterId.Value, (int)RecordType.Arrest, reason, 0); + ClientBrain.ShowNotification("Arrest recorded.", Subject); + } + + private void OnStopResult(string kind, string json, string error) + { + var stop = _stop; + if (stop is null) return; + + var record = string.IsNullOrEmpty(json) ? null : JsonConvert.DeserializeObject(json)?.Record; + + if (kind == "person") + { + stop.AwaitingIdentity = false; + if (record is null) + { + ClientBrain.ShowNotification($"~r~{error}", "~b~ID check"); + } + else + { + stop.Identity = record; + ClientBrain.ShowNotification(DescribePerson(record), "~b~ID check"); + } + } + else + { + stop.AwaitingPlate = false; + if (record is null) + { + stop.RegistrationError = error; + ClientBrain.ShowNotification($"~o~{error}", "~b~Plate check"); + } + else + { + stop.Registration = record; + ClientBrain.ShowNotification(DescribeVehicle(record, stop.Plate), "~b~Plate check"); + } + } + + CompactMenu.Refresh(); + } + + private void OnLookupError(string error) + { + if (_stop is not null && CompactMenu.IsOpen) + ClientBrain.ShowNotification($"~r~{error}", Subject); + } + + private static string DescribePerson(LookupRecord record) + { + var person = record.Character; + var license = record.Licenses.FirstOrDefault(l => l.Type == StreetProfiles.DriverLicenseType)?.Status; + var licenseText = license switch + { + LicenseStatus.Valid => "~g~Valid license", + LicenseStatus.Suspended => "~o~License suspended", + LicenseStatus.Revoked => "~r~License revoked", + _ => "~r~No license", + }; + var warrant = person.HasActiveWarrant ? "~r~ACTIVE WARRANT" : "~g~No warrants"; + return $"{person.FirstName} {person.LastName} · DOB {person.DateOfBirth}~n~{licenseText}~w~ · {warrant}"; + } + + private static string DescribeVehicle(LookupRecord record, string plate) + { + var vehicle = record.Vehicles.FirstOrDefault(v => string.Equals(v.Plate, record.MatchedPlate ?? plate, StringComparison.OrdinalIgnoreCase)); + var status = vehicle?.Status == VehicleStatus.Stolen ? "~r~REPORTED STOLEN" : "~g~Valid registration"; + var owner = record.Character.HasActiveWarrant ? " ~r~(owner has a warrant)" : string.Empty; + return $"{plate} · {vehicle?.Model ?? "Unknown"}~n~Registered to {record.Character.FirstName} {record.Character.LastName}{owner}~n~{status}"; + } + + private static async Task EndAsync(bool release, string? message) + { + var stop = _stop; + if (stop is null) return; + + _stop = null; + if (CompactMenu.IsOpen) CompactMenu.Close(); + if (message is not null) ClientBrain.ShowNotification(message, Subject); + + await stop.EndAsync(release); + } + } +} diff --git a/FivePRS.Server/Civilian/StreetService.cs b/FivePRS.Server/Civilian/StreetService.cs new file mode 100644 index 0000000..e370cf8 --- /dev/null +++ b/FivePRS.Server/Civilian/StreetService.cs @@ -0,0 +1,97 @@ +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Police; +using FivePRS.Server.Database; + +namespace FivePRS.Server.Civilian +{ + public sealed class StreetService + { + private const int MaxModelLength = 40; + private const string DispatchName = "Dispatch"; + private const string DispatchCallsign = "DISPATCH"; + + private readonly CivilianStore _store; + private readonly LookupService _lookup; + + public StreetService(CivilianStore store, LookupService lookup) + { + _store = store; + _lookup = lookup; + } + + public async Task<(LookupResult? Result, string? Error)> IdentifyAsync(int seed, bool female) + { + var id = await GetOrCreateAsync(StreetProfiles.Person(seed, female)); + return await _lookup.GetRecordAsync(id); + } + + public async Task<(LookupResult? Result, string? Error)> PlateAsync(string? plate, string? model, int driverSeed, bool driverFemale) + { + if (!CivilianRules.TryNormalizePlate(plate, out var normalized)) return (null, "That plate can't be read."); + + if (await _store.GetVehicleByPlateAsync(normalized) is null) + { + var vehicle = StreetProfiles.Vehicle(normalized); + if (!vehicle.Registered) return (null, $"No registration found for {normalized}."); + + var owner = vehicle.DriverIsOwner + ? StreetProfiles.Person(driverSeed, driverFemale) + : StreetProfiles.Person(vehicle.OwnerSeed, vehicle.OwnerFemale); + + await _store.AddVehicleAsync(new VehicleInfo + { + CharacterId = await GetOrCreateAsync(owner), + Plate = normalized, + Model = NormalizeModel(model), + Status = vehicle.Stolen ? VehicleStatus.Stolen : VehicleStatus.Valid, + }); + } + + return await _lookup.SearchByPlateAsync(normalized); + } + + private async Task GetOrCreateAsync(StreetPerson person) + { + var existing = await _store.FindCharacterAsync(StreetProfiles.NpcOwner, person.FirstName, person.LastName, person.DateOfBirth); + if (existing is not null) return existing.Id; + + var id = await _store.CreateCharacterAsync(new CharacterInfo + { + OwnerLicense = StreetProfiles.NpcOwner, + FirstName = person.FirstName, + LastName = person.LastName, + DateOfBirth = person.DateOfBirth, + Gender = person.Gender, + }); + + var license = StreetProfiles.ToLicenseStatus(person.DriverLicense); + if (license is not null) + await _store.SetLicenseAsync(id, StreetProfiles.DriverLicenseType, license.Value); + + if (person.Warrant is not null) + { + await _store.AddRecordAsync(new RecordInfo + { + CharacterId = id, + Type = RecordType.Warrant, + Description = person.Warrant, + OfficerName = DispatchName, + OfficerCallsign = DispatchCallsign, + Active = true, + }, StreetProfiles.NpcOwner); + } + + return id; + } + + private static string NormalizeModel(string? model) + { + var text = (model ?? string.Empty).Trim(); + return text.Length > 0 && text.Length <= MaxModelLength && text.All(c => char.IsLetterOrDigit(c) || c == ' ' || c == '-') + ? text + : "Unknown"; + } + } +} diff --git a/FivePRS.Server/Database/AuditEntry.cs b/FivePRS.Server/Database/AuditEntry.cs index 74743c9..72e7bda 100644 --- a/FivePRS.Server/Database/AuditEntry.cs +++ b/FivePRS.Server/Database/AuditEntry.cs @@ -27,6 +27,7 @@ public static class AuditActions public const string EmergencyCleared = "911.clear"; public const string RosterGranted = "roster.grant"; public const string RosterRevoked = "roster.revoke"; + public const string PlayerRevived = "player.revive"; } public sealed class AuditEntry diff --git a/FivePRS.Server/Database/CivilianStore.cs b/FivePRS.Server/Database/CivilianStore.cs index bb2e522..5b0f250 100644 --- a/FivePRS.Server/Database/CivilianStore.cs +++ b/FivePRS.Server/Database/CivilianStore.cs @@ -34,6 +34,15 @@ public Task> GetCharactersAsync(string ownerLicense) => return rows.Count > 0 ? rows[0] : null; } + public async Task FindCharacterAsync(string ownerLicense, string firstName, string lastName, string dateOfBirth) + { + var rows = await QueryAsync( + $"SELECT {CharacterColumns} FROM fiveprs_characters " + + "WHERE owner_license = @owner AND first_name = @first AND last_name = @last AND date_of_birth = @dob", + ReadCharacter, ("@owner", ownerLicense), ("@first", firstName), ("@last", lastName), ("@dob", dateOfBirth)); + return rows.Count > 0 ? rows[0] : null; + } + public async Task CreateCharacterAsync(CharacterInfo character) { var now = DateTime.UtcNow; diff --git a/FivePRS.Server/Database/DatabaseErrors.cs b/FivePRS.Server/Database/DatabaseErrors.cs new file mode 100644 index 0000000..f9706cf --- /dev/null +++ b/FivePRS.Server/Database/DatabaseErrors.cs @@ -0,0 +1,51 @@ +using System; +using MySqlConnector; + +namespace FivePRS.Server.Database +{ + public static class DatabaseErrors + { + public static string? Describe(Exception ex, string? connectionString) + { + var mySql = ex as MySqlException ?? ex.InnerException as MySqlException; + return mySql is null ? null : Describe(mySql.Number, connectionString); + } + + public static string? Describe(int errorNumber, string? connectionString) + { + var settings = Settings(connectionString); + var database = string.IsNullOrEmpty(settings?.Database) ? "the database" : $"'{settings!.Database}'"; + var user = string.IsNullOrEmpty(settings?.UserID) ? "the user" : $"'{settings!.UserID}'"; + var server = settings is null ? "the database server" : $"{settings.Server}:{settings.Port}"; + + return (MySqlErrorCode)errorNumber switch + { + MySqlErrorCode.UnknownDatabase => + $"The database {database} doesn't exist. Create it (CREATE DATABASE {Name(settings?.Database)} CHARACTER SET utf8mb4;), or fix Database= in fiveprs_db_connection.", + MySqlErrorCode.AccessDenied => + $"The database server rejected the login for {user}. Check User ID and Password in fiveprs_db_connection.", + MySqlErrorCode.DatabaseAccessDenied => + $"The user {user} isn't allowed to use {database}. Grant access (GRANT ALL PRIVILEGES ON {Name(settings?.Database)}.* TO '{settings?.UserID ?? "your_user"}'@'localhost';) or use another user.", + MySqlErrorCode.UnableToConnectToHost => + $"Couldn't reach the database server at {server}. Check that MySQL or MariaDB is running and that Server and Port in fiveprs_db_connection are right.", + _ => null, + }; + } + + private static string Name(string? value) => string.IsNullOrEmpty(value) ? "fiveprs" : value!; + + private static MySqlConnectionStringBuilder? Settings(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) return null; + + try + { + return new MySqlConnectionStringBuilder(connectionString!); + } + catch (ArgumentException) + { + return null; + } + } + } +} diff --git a/FivePRS.Server/Database/DatabaseManager.cs b/FivePRS.Server/Database/DatabaseManager.cs index fd83fd2..107d814 100644 --- a/FivePRS.Server/Database/DatabaseManager.cs +++ b/FivePRS.Server/Database/DatabaseManager.cs @@ -31,19 +31,27 @@ public sealed class DatabaseManager public int SchemaVersion { get; private set; } - public async Task InitializeAsync(DatabaseType dbType, string? connectionString) + public Task InitializeAsync(DatabaseType dbType, string? connectionString) => + MainThread.Run(() => InitializeCoreAsync(dbType, connectionString)); + + private async Task InitializeCoreAsync(DatabaseType dbType, string? connectionString) { Func? backup = null; if (dbType == DatabaseType.MySQL) { if (connectionString is null || string.IsNullOrWhiteSpace(connectionString)) - throw new ArgumentException("[FivePRS] MySQL selected but fiveprs_db_connection is empty."); + throw new DatabaseSetupException("fiveprs_db_type is mysql, but fiveprs_db_connection is empty. Set it to your MySQL or MariaDB connection string."); _provider = new MySqlProvider(connectionString); } else { - var resourcePath = API.GetResourcePath(API.GetCurrentResourceName()); + if (connectionString is not null && connectionString.Contains("=")) + throw new DatabaseSetupException( + "fiveprs_db_connection is set to a MySQL connection string, but fiveprs_db_type is sqlite. " + + "Set fiveprs_db_type \"mysql\" to use MySQL or MariaDB, or clear fiveprs_db_connection to use SQLite."); + + var resourcePath = Resource.Path; var nativePath = SqliteNativeLoader.Load(Path.Combine(resourcePath, "server")); Debug.WriteLine($"[FivePRS] Native SQLite loaded from {nativePath}."); @@ -84,15 +92,15 @@ private static async Task BackupAsync(SQLiteProvider sqlite, string dbPath, int } public Task GetPlayerAsync(string license) - => _provider.GetPlayerAsync(license); + => MainThread.Run(() => _provider.GetPlayerAsync(license)); public Task SavePlayerAsync(PlayerData player) - => _provider.SavePlayerAsync(player); + => MainThread.Run(() => _provider.SavePlayerAsync(player)); public Task UpdateDutyStatusAsync(string license, bool isOnDuty) - => _provider.UpdateDutyStatusAsync(license, isOnDuty); + => MainThread.Run(() => _provider.UpdateDutyStatusAsync(license, isOnDuty)); public Task AddAuditAsync(AuditEntry entry) - => _provider.AddAuditAsync(entry); + => MainThread.Run(() => _provider.AddAuditAsync(entry)); } } diff --git a/FivePRS.Server/Database/DatabaseSetupException.cs b/FivePRS.Server/Database/DatabaseSetupException.cs new file mode 100644 index 0000000..e2e560a --- /dev/null +++ b/FivePRS.Server/Database/DatabaseSetupException.cs @@ -0,0 +1,11 @@ +using System; + +namespace FivePRS.Server.Database +{ + public sealed class DatabaseSetupException : Exception + { + public DatabaseSetupException(string message) : base(message) + { + } + } +} diff --git a/FivePRS.Server/Database/MySqlProvider.cs b/FivePRS.Server/Database/MySqlProvider.cs index f175aab..5c761e3 100644 --- a/FivePRS.Server/Database/MySqlProvider.cs +++ b/FivePRS.Server/Database/MySqlProvider.cs @@ -16,7 +16,24 @@ public sealed class MySqlProvider : IDatabaseProvider public MySqlProvider(string connectionString) { - _connectionString = connectionString; + _connectionString = PrepareConnectionString(connectionString); + } + + public static string PrepareConnectionString(string connectionString) + { + var builder = new MySqlConnectionStringBuilder(connectionString); + + if (!builder.ContainsKey("SslMode")) + builder.SslMode = MySqlSslMode.None; + else if (builder.SslMode != MySqlSslMode.None) + throw new NotSupportedException( + "FiveM's server runtime can't make encrypted (TLS) connections to MySQL or MariaDB. " + + "Remove SslMode from fiveprs_db_connection or set SslMode=None, and keep the database on the same machine or a private network."); + + if (!builder.ContainsKey("AllowPublicKeyRetrieval")) + builder.AllowPublicKeyRetrieval = true; + + return builder.ConnectionString; } public async Task InitializeAsync() diff --git a/FivePRS.Server/Database/SqlStore.cs b/FivePRS.Server/Database/SqlStore.cs index 2abcc40..c2c1627 100644 --- a/FivePRS.Server/Database/SqlStore.cs +++ b/FivePRS.Server/Database/SqlStore.cs @@ -15,7 +15,16 @@ public abstract class SqlStore protected string LastInsertId => IsMySql ? "SELECT LAST_INSERT_ID()" : "SELECT last_insert_rowid()"; - protected async Task ExecuteAsync(string sql, params (string Name, object Value)[] parameters) + protected Task ExecuteAsync(string sql, params (string Name, object Value)[] parameters) => + MainThread.Run(() => ExecuteCoreAsync(sql, parameters)); + + protected Task ScalarAsync(string sql, params (string Name, object Value)[] parameters) => + MainThread.Run(() => ScalarCoreAsync(sql, parameters)); + + protected Task> QueryAsync(string sql, Func read, params (string Name, object Value)[] parameters) => + MainThread.Run(() => QueryCoreAsync(sql, read, parameters)); + + private async Task ExecuteCoreAsync(string sql, (string Name, object Value)[] parameters) { using var conn = Db.CreateConnection(); await conn.OpenAsync(); @@ -23,7 +32,7 @@ protected async Task ExecuteAsync(string sql, params (string Name, object Value) await cmd.ExecuteNonQueryAsync(); } - protected async Task ScalarAsync(string sql, params (string Name, object Value)[] parameters) + private async Task ScalarCoreAsync(string sql, (string Name, object Value)[] parameters) { using var conn = Db.CreateConnection(); await conn.OpenAsync(); @@ -31,7 +40,7 @@ protected async Task ExecuteAsync(string sql, params (string Name, object Value) return await cmd.ExecuteScalarAsync(); } - protected async Task> QueryAsync(string sql, Func read, params (string Name, object Value)[] parameters) + private async Task> QueryCoreAsync(string sql, Func read, (string Name, object Value)[] parameters) { using var conn = Db.CreateConnection(); await conn.OpenAsync(); diff --git a/FivePRS.Server/Database/SqliteNativeLoader.cs b/FivePRS.Server/Database/SqliteNativeLoader.cs index 81b3b83..3e4ff0c 100644 --- a/FivePRS.Server/Database/SqliteNativeLoader.cs +++ b/FivePRS.Server/Database/SqliteNativeLoader.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using SQLitePCL; @@ -8,8 +9,6 @@ namespace FivePRS.Server.Database internal static class SqliteNativeLoader { private const string LibraryName = "e_sqlite3"; - private const int RtldNow = 2; - private const int RtldGlobal = 0x100; private static bool _loaded; @@ -22,7 +21,17 @@ public static string Load(string directory) if (!File.Exists(path)) throw new FileNotFoundException("Native SQLite library is missing from the resource.", path); - IGetFunctionPointer symbols = windows ? WindowsLibrary.Open(path) : UnixLibrary.Open(path); + IGetFunctionPointer symbols; + try + { + symbols = OpenLibrary(path); + } + catch (Exception ex) when (!(ex is DllNotFoundException)) + { + throw new NotSupportedException( + "This server doesn't allow resources to load native libraries (GTA V Enhanced servers block them), so SQLite can't run. " + + "Use MySQL instead: set fiveprs_db_type \"mysql\" and fiveprs_db_connection in server.cfg.", ex); + } SQLite3Provider_dynamic_cdecl.Setup(LibraryName, symbols); raw.SetProvider(new SQLite3Provider_dynamic_cdecl()); @@ -32,91 +41,16 @@ public static string Load(string directory) return path; } - private sealed class WindowsLibrary : IGetFunctionPointer - { - private readonly IntPtr _handle; - - private WindowsLibrary(IntPtr handle) => _handle = handle; + [MethodImpl(MethodImplOptions.NoInlining)] + private static IGetFunctionPointer OpenLibrary(string path) => new Symbols(FivePRS.Native.NativeLibrary.Open(path)); - public static WindowsLibrary Open(string path) - { - var handle = LoadLibraryW(path); - if (handle == IntPtr.Zero) - throw new DllNotFoundException($"Could not load native SQLite from {path} (Win32 error {Marshal.GetLastWin32Error()})."); - return new WindowsLibrary(handle); - } - - public IntPtr GetFunctionPointer(string name) => GetProcAddress(_handle, name); - - [DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern IntPtr LoadLibraryW(string path); - - [DllImport("kernel32", CharSet = CharSet.Ansi, ExactSpelling = true)] - private static extern IntPtr GetProcAddress(IntPtr module, string name); - } - - private sealed class UnixLibrary : IGetFunctionPointer + private sealed class Symbols : IGetFunctionPointer { - private readonly IntPtr _handle; - private readonly bool _useLibdl; - - private UnixLibrary(IntPtr handle, bool useLibdl) - { - _handle = handle; - _useLibdl = useLibdl; - } - - public static UnixLibrary Open(string path) - { - IntPtr handle; - bool useLibdl; - try - { - handle = Internal.dlopen(path, RtldNow | RtldGlobal); - useLibdl = false; - } - catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException) - { - handle = Libdl.dlopen(path, RtldNow | RtldGlobal); - useLibdl = true; - } - - if (handle == IntPtr.Zero) - { - var error = useLibdl ? Libdl.dlerror() : Internal.dlerror(); - var message = error == IntPtr.Zero ? "unknown error" : Marshal.PtrToStringAnsi(error); - throw new DllNotFoundException($"Could not load native SQLite from {path} ({message})."); - } + private readonly FivePRS.Native.NativeLibrary _library; - return new UnixLibrary(handle, useLibdl); - } - - public IntPtr GetFunctionPointer(string name) => - _useLibdl ? Libdl.dlsym(_handle, name) : Internal.dlsym(_handle, name); - - private static class Internal - { - [DllImport("__Internal")] - public static extern IntPtr dlopen(string path, int flags); + public Symbols(FivePRS.Native.NativeLibrary library) => _library = library; - [DllImport("__Internal")] - public static extern IntPtr dlsym(IntPtr handle, string name); - - [DllImport("__Internal")] - public static extern IntPtr dlerror(); - } - - private static class Libdl - { - [DllImport("libdl.so.2")] - public static extern IntPtr dlopen(string path, int flags); - - [DllImport("libdl.so.2")] - public static extern IntPtr dlsym(IntPtr handle, string name); - - [DllImport("libdl.so.2")] - public static extern IntPtr dlerror(); - } + public IntPtr GetFunctionPointer(string name) => _library.Symbol(name); } } } diff --git a/FivePRS.Server/FivePRS.Server.csproj b/FivePRS.Server/FivePRS.Server.csproj index 2a86182..f3b4afd 100644 --- a/FivePRS.Server/FivePRS.Server.csproj +++ b/FivePRS.Server/FivePRS.Server.csproj @@ -25,6 +25,7 @@ + diff --git a/FivePRS.Server/MainThread.cs b/FivePRS.Server/MainThread.cs new file mode 100644 index 0000000..140244b --- /dev/null +++ b/FivePRS.Server/MainThread.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace FivePRS.Server +{ + public static class MainThread + { + private static readonly ConcurrentQueue> Waiting = new(); + private static bool _enabled; + private static int _threadId; + + public static void Enable() => _enabled = true; + + public static void Disable() + { + _enabled = false; + _threadId = 0; + Drain(); + } + + public static bool IsCurrent => !_enabled || (_threadId != 0 && Thread.CurrentThread.ManagedThreadId == _threadId); + + public static Task Resume() + { + if (IsCurrent) return Task.FromResult(true); + + var completion = new TaskCompletionSource(); + Waiting.Enqueue(completion); + return completion.Task; + } + + public static void Tick() + { + _threadId = Thread.CurrentThread.ManagedThreadId; + Drain(); + } + + private static void Drain() + { + while (Waiting.TryDequeue(out var completion)) + completion.TrySetResult(true); + } + + public static async Task Run(Func work) + { + try + { + await work(); + } + finally + { + await Resume(); + } + } + + public static async Task Run(Func> work) + { + try + { + return await work(); + } + finally + { + await Resume(); + } + } + } +} diff --git a/FivePRS.Server/Resource.cs b/FivePRS.Server/Resource.cs new file mode 100644 index 0000000..a8d4a27 --- /dev/null +++ b/FivePRS.Server/Resource.cs @@ -0,0 +1,17 @@ +using CitizenFX.Core.Native; + +namespace FivePRS.Server +{ + internal static class Resource + { + public static string Name { get; private set; } = "fiveprs"; + + public static string Path { get; private set; } = string.Empty; + + public static void Capture() + { + Name = API.GetCurrentResourceName(); + Path = API.GetResourcePath(Name); + } + } +} diff --git a/FivePRS.Server/ServerBrain.Admin.cs b/FivePRS.Server/ServerBrain.Admin.cs index 5b66dc1..5a156b2 100644 --- a/FivePRS.Server/ServerBrain.Admin.cs +++ b/FivePRS.Server/ServerBrain.Admin.cs @@ -48,7 +48,7 @@ private void RegisterAdminCommand(string name, string usage, int requiredArgs, F } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] {name} failed: {ex}"); + ServerLog.Error($"{name} failed", ex); Reply(caller, $"{name} failed: {ex.Message}"); } }), false); diff --git a/FivePRS.Server/ServerBrain.Civilian.cs b/FivePRS.Server/ServerBrain.Civilian.cs index 8676003..a13616b 100644 --- a/FivePRS.Server/ServerBrain.Civilian.cs +++ b/FivePRS.Server/ServerBrain.Civilian.cs @@ -103,7 +103,7 @@ private async void RunCivilian(Player player, Func _downed = new(); + + private void RegisterDeathEvents() + { + EventHandlers[EventNames.ServerPlayerDowned] += new Action(OnPlayerDowned); + EventHandlers[EventNames.ServerPlayerRespawned] += new Action(OnPlayerRespawned); + EventHandlers[EventNames.ServerRevivePlayer] += new Action(OnRevivePlayer); + RegisterAdminCommand("fprs_revive", "", 1, AdminReviveAsync); + } + + private void OnPlayerDowned([FromSource] Player player) + { + if (!ConfigManager.Settings.Death.Enabled || !Allow(player, "requests", l => l.Requests)) return; + + var id = ServerId(player); + if (!_downed.Add(id)) return; + + TriggerClientEvent(EventNames.ClientPlayerDowned, id, true); + if (ConfigManager.Settings.Death.NotifyEms) _ = AlertEmsAsync(player); + } + + private void OnPlayerRespawned([FromSource] Player player) + { + if (!Allow(player, "requests", l => l.Requests)) return; + ClearDowned(ServerId(player)); + } + + private async void OnRevivePlayer([FromSource] Player medic, int targetId) + { + if (!Allow(medic, "dispatch", l => l.Dispatch)) return; + if (!_downed.Contains(targetId) || !TryGetPlayer(targetId, out var target)) return; + + var isMedic = TryGetCached(medic, out var license, out var data) && data.IsOnDuty && data.Department == Department.EMS; + if (!isMedic && !_permissions.IsAdmin(medic.Handle)) + { + Notify(medic, "~r~Only on-duty EMS can revive people."); + return; + } + + var distance = Vector3.Distance(API.GetEntityCoords(API.GetPlayerPed(medic.Handle)), API.GetEntityCoords(API.GetPlayerPed(target.Handle))); + if (distance > ReviveRange) + { + Notify(medic, "~r~Get closer to the patient first."); + return; + } + + Revive(target, medic.Name); + Notify(medic, $"~g~{target.Name} revived."); + Audit(AuditActions.PlayerRevived, medic, GetLicense(target), $"revived {target.Name}"); + + var xp = ConfigManager.Settings.Death.ReviveXp; + if (isMedic && xp > 0) + { + try + { + await GrantXpAsync(medic, data, xp); + Notify(medic, $"~y~+{xp} XP"); + } + catch (Exception ex) + { + ServerLog.Error($"Revive XP failed for {medic.Name}", ex); + } + } + } + + private Task AdminReviveAsync(Player? caller, string[] args) + { + if (!int.TryParse(args[0], out var id) || !TryGetPlayer(id, out var target)) return Task.FromResult(TargetNotFound(args[0])); + + Revive(target, "an administrator"); + Audit(AuditActions.PlayerRevived, caller, GetLicense(target), $"admin revive {target.Name}"); + return Task.FromResult($"{target.Name} revived."); + } + + private void Revive(Player target, string by) + { + ClearDowned(ServerId(target)); + TriggerClientEvent(target, EventNames.ClientRevived, by); + } + + private void ClearDowned(int id) + { + if (_downed.Remove(id)) + TriggerClientEvent(EventNames.ClientPlayerDowned, id, false); + } + + private void SendDownedPlayers(Player player) => + TriggerClientEvent(player, EventNames.ClientDownedPlayers, JsonConvert.SerializeObject(_downed.ToList())); + + private async Task AlertEmsAsync(Player player) + { + var license = GetLicense(player); + var name = player.Name; + if (license is not null && _civilians is not null) + { + try + { + name = (await _civilians.GetActiveCharacterAsync(license))?.FullName ?? name; + } + catch (Exception ex) + { + ServerLog.Error($"Looking up {player.Name}'s character failed", ex); + } + } + + var position = API.GetEntityCoords(API.GetPlayerPed(player.Handle)); + var (callId, error) = _dispatch.CreateEmergencyCall(ServerId(player), name, Department.EMS, PersonDown, position.X, position.Y, position.Z); + if (error is not null) return; + + foreach (var unitId in _dispatch.UnitsInDepartment(Department.EMS)) + { + if (TryGetPlayer(unitId, out var unit)) + Notify(unit, $"~r~Person down~w~ | Call #{callId}: {name} is unconscious."); + } + } + } +} diff --git a/FivePRS.Server/ServerBrain.Diagnostics.cs b/FivePRS.Server/ServerBrain.Diagnostics.cs index cd4875b..7ed57c8 100644 --- a/FivePRS.Server/ServerBrain.Diagnostics.cs +++ b/FivePRS.Server/ServerBrain.Diagnostics.cs @@ -79,7 +79,7 @@ private Task DiagnosticsAsync(Player? caller, string[] args) private static IEnumerable StartedAddons() { - var current = API.GetCurrentResourceName(); + var current = Resource.Name; for (var i = 0; i < API.GetNumResources(); i++) { var name = API.GetResourceByFindIndex(i); diff --git a/FivePRS.Server/ServerBrain.Emergency.cs b/FivePRS.Server/ServerBrain.Emergency.cs index a1bc258..0cf9fdc 100644 --- a/FivePRS.Server/ServerBrain.Emergency.cs +++ b/FivePRS.Server/ServerBrain.Emergency.cs @@ -75,7 +75,7 @@ private async void OnEmergencyCall([FromSource] Player player, int departmentId, } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] 911 call failed for {player.Name}: {ex}"); + ServerLog.Error($"911 call failed for {player.Name}", ex); TriggerClientEvent(player, EventNames.ClientEmergencyError, "Something went wrong. Please try again."); } } diff --git a/FivePRS.Server/ServerBrain.Lookup.cs b/FivePRS.Server/ServerBrain.Lookup.cs index 32ec0d3..0655023 100644 --- a/FivePRS.Server/ServerBrain.Lookup.cs +++ b/FivePRS.Server/ServerBrain.Lookup.cs @@ -116,7 +116,7 @@ private async void RunLookup(Player player, bool lookups, Func(OnStopIdentify); + EventHandlers[EventNames.ServerStopPlate] += new Action(OnStopPlate); + } + + private void OnStopIdentify([FromSource] Player player, int seed, bool female) => + RunStop(player, StopPerson, street => street.IdentifyAsync(seed, female)); + + private void OnStopPlate([FromSource] Player player, string plate, string model, int driverSeed, bool driverFemale) => + RunStop(player, StopVehicle, street => street.PlateAsync(plate, model, driverSeed, driverFemale)); + + private async void RunStop(Player player, string kind, Func> action) + { + if (!Allow(player, "lookups", l => l.Lookups)) return; + + if (!TryGetOfficer(player, out _) || _street is null) + { + TriggerClientEvent(player, EventNames.ClientStopResult, kind, string.Empty, "Records are only available to on-duty police."); + return; + } + + try + { + var (result, error) = await action(_street); + var json = result is null ? string.Empty : JsonConvert.SerializeObject(result); + + if (result is not null) TriggerClientEvent(player, EventNames.ClientLookupResult, json); + TriggerClientEvent(player, EventNames.ClientStopResult, kind, json, error ?? string.Empty); + } + catch (Exception ex) + { + ServerLog.Error($"Traffic stop check failed for {player.Name}", ex); + TriggerClientEvent(player, EventNames.ClientStopResult, kind, string.Empty, "Something went wrong. Please try again."); + } + } + } +} diff --git a/FivePRS.Server/ServerBrain.Updates.cs b/FivePRS.Server/ServerBrain.Updates.cs index 53a628d..ef1dabe 100644 --- a/FivePRS.Server/ServerBrain.Updates.cs +++ b/FivePRS.Server/ServerBrain.Updates.cs @@ -32,7 +32,7 @@ private void RegisterUpdates() private static ReleaseVersion InstalledVersion() { - var text = API.GetResourceMetadata(API.GetCurrentResourceName(), "version", 0); + var text = API.GetResourceMetadata(Resource.Name, "version", 0); return ReleaseVersion.TryParse(text, out var version) ? version : ReleaseVersion.Zero; } @@ -100,7 +100,7 @@ private async Task DownloadUpdateAsync(Player? caller, string[] args) return $"FivePRS {release.Version} has no {ConfigManager.Settings.Updates.AssetName} to download. Get it from {release.Url}"; var version = release.Version.ToString(); - var resourcePath = API.GetResourcePath(API.GetCurrentResourceName()); + var resourcePath = Resource.Path; var zipRelative = $"data/updates/fiveprs-{version}.zip"; var zipPath = Path.Combine(resourcePath, zipRelative); var target = Path.Combine(resourcePath, "data", "updates", version); diff --git a/FivePRS.Server/ServerBrain.cs b/FivePRS.Server/ServerBrain.cs index 29dd238..d8a7356 100644 --- a/FivePRS.Server/ServerBrain.cs +++ b/FivePRS.Server/ServerBrain.cs @@ -32,8 +32,11 @@ public partial class ServerBrain : BaseScript public ServerBrain() { + Resource.Capture(); + API.SetConvarServerInfo("fiveprs_version", InstalledVersion().ToString()); + MainThread.Enable(); ConfigManager.Log = message => Debug.WriteLine(message); - var resource = API.GetCurrentResourceName(); + var resource = Resource.Name; ConfigManager.LoadSettings(API.LoadResourceFile(resource, "config/settings.json")); ConfigManager.LoadJurisdictions(API.LoadResourceFile(resource, "config/jurisdictions.json")); ConfigManager.LoadLicenses(API.LoadResourceFile(resource, "config/licenses.json")); @@ -62,6 +65,7 @@ public ServerBrain() EventHandlers[EventNames.ServerSetAiCallouts] += new Action(OnSetAiCallouts); EventHandlers[EventNames.LocalHttpResponse] += new Action(HttpBridge.OnResponse); + Tick += DrainMainThreadAsync; Tick += DispatchTickAsync; RegisterAdminCommands(); RegisterCivilianEvents(); @@ -73,23 +77,32 @@ public ServerBrain() RegisterPositionEvents(); RegisterUpdates(); RegisterDiagnostics(); + RegisterStreetEvents(); + RegisterDeathEvents(); _ = ServerIcon.ApplyDefaultAsync(ConfigManager.Settings.Branding.ServerIcon); _ = InitDbAsync(); } + private static Task DrainMainThreadAsync() + { + MainThread.Tick(); + return Task.FromResult(0); + } + private async Task InitDbAsync() { + var connString = API.GetConvar("fiveprs_db_connection", ""); try { var dbTypeRaw = API.GetConvar("fiveprs_db_type", "sqlite").ToLowerInvariant(); - var connString = API.GetConvar("fiveprs_db_connection", ""); var dbType = dbTypeRaw == "mysql" ? DatabaseType.MySQL : DatabaseType.SQLite; _databaseType = dbType; await _db.InitializeAsync(dbType, string.IsNullOrEmpty(connString) ? null : connString); _civilians = new CivilianService(_db.Civilians, () => ConfigManager.Settings, () => ConfigManager.Licenses, () => DateTime.UtcNow); _lookup = new LookupService(_db.Civilians, () => ConfigManager.Settings, () => ConfigManager.Licenses); + _street = new StreetService(_db.Civilians, _lookup); _appearances = new AppearanceService(_db.Civilians, _db.Appearances, () => ConfigManager.Settings.Creator); _roster = new RosterService(_db.Roster); await _roster.LoadAsync(); @@ -97,9 +110,21 @@ private async Task InitDbAsync() LogStartup(); Debug.WriteLine("[FivePRS] ServerBrain online."); } + catch (Exception ex) when (ex is NotSupportedException || ex is DatabaseSetupException) + { + Debug.WriteLine($"[FivePRS] FATAL: {ex.Message}"); + } + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"[FivePRS] FATAL: {ex.Message} The server's user account needs write access to the fiveprs resource folder."); + } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] FATAL: DB init failed: {ex}"); + var problem = DatabaseErrors.Describe(ex, connString); + if (problem is not null) + Debug.WriteLine($"[FivePRS] FATAL: {problem}"); + else + ServerLog.Error("FATAL: The database didn't start", ex); } } @@ -143,6 +168,7 @@ private async void OnPlayerConnecting( private void OnPlayerDropped([FromSource] Player player, string reason) { _rateLimiter.Forget(player.Handle); + ClearDowned(ServerId(player)); _dispatch.SetOffDuty(ServerId(player)); var license = GetLicense(player); @@ -170,6 +196,7 @@ private async void OnPlayerReady([FromSource] Player player) SendPlayerData(player, data); SendEntryOptions(player); + SendDownedPlayers(player); await SendAppearanceAsync(player, license); await SendPreferencesAsync(player, license); diff --git a/FivePRS.Server/ServerIcon.cs b/FivePRS.Server/ServerIcon.cs index 6e53d69..88bb9ad 100644 --- a/FivePRS.Server/ServerIcon.cs +++ b/FivePRS.Server/ServerIcon.cs @@ -13,10 +13,26 @@ internal static class ServerIcon private const string LoadCommandAce = "command.load_server_icon"; public static async Task ApplyDefaultAsync(string url) + { + try + { + await ApplyAsync(url); + } + catch (UnauthorizedAccessException ex) + { + Debug.WriteLine($"[FivePRS] Could not set the server icon: {ex.Message} The server's user account needs write access to the fiveprs resource folder."); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] Could not set the server icon: {ex.Message}"); + } + } + + private static async Task ApplyAsync(string url) { if (!IsEnabled() || HasIcon() || string.IsNullOrWhiteSpace(url)) return; - var resourcePath = API.GetResourcePath(API.GetCurrentResourceName()); + var resourcePath = Resource.Path; var path = Path.Combine(resourcePath, RelativePath); Directory.CreateDirectory(Path.GetDirectoryName(path)!); @@ -32,7 +48,7 @@ public static async Task ApplyDefaultAsync(string url) if (HasIcon()) return; - var resource = API.GetCurrentResourceName(); + var resource = Resource.Name; if (!API.IsPrincipalAceAllowed($"resource.{resource}", LoadCommandAce)) { Debug.WriteLine( diff --git a/FivePRS.Server/ServerLog.cs b/FivePRS.Server/ServerLog.cs new file mode 100644 index 0000000..12cbca5 --- /dev/null +++ b/FivePRS.Server/ServerLog.cs @@ -0,0 +1,41 @@ +using System; +using System.Reflection; +using CitizenFX.Core; +using CitizenFX.Core.Native; + +namespace FivePRS.Server +{ + internal static class ServerLog + { + private static bool _hinted; + + public static bool DebugEnabled => + string.Equals(API.GetConvar("fiveprs_debug", "false"), "true", StringComparison.OrdinalIgnoreCase); + + public static void Error(string message, Exception ex) + { + Debug.WriteLine($"[FivePRS] {message}: {Summary(ex)}"); + + if (DebugEnabled) + { + Debug.WriteLine(ex.ToString()); + } + else if (!_hinted) + { + _hinted = true; + Debug.WriteLine("[FivePRS] Set fiveprs_debug true in server.cfg to include full error details in the console."); + } + } + + public static string Summary(Exception ex) + { + while (ex is AggregateException { InnerException: not null } || ex is TargetInvocationException { InnerException: not null } || + ex is TypeInitializationException { InnerException: not null }) + { + ex = ex.InnerException!; + } + + return ex.Message; + } + } +} diff --git a/FivePRS.Tests/DatabaseErrorsTests.cs b/FivePRS.Tests/DatabaseErrorsTests.cs new file mode 100644 index 0000000..a27e106 --- /dev/null +++ b/FivePRS.Tests/DatabaseErrorsTests.cs @@ -0,0 +1,45 @@ +using FivePRS.Server.Database; +using MySqlConnector; +using Xunit; + +namespace FivePRS.Tests +{ + public class DatabaseErrorsTests + { + private const string Connection = "Server=127.0.0.1;Port=3306;Database=fiveprs;User ID=fivem;Password=secret;"; + + [Fact] + public void UnknownDatabase_NamesTheDatabaseAndTheFix() + { + var message = DatabaseErrors.Describe((int)MySqlErrorCode.UnknownDatabase, Connection)!; + + Assert.Contains("'fiveprs' doesn't exist", message); + Assert.Contains("CREATE DATABASE fiveprs", message); + } + + [Fact] + public void AccessDenied_NamesTheUser() + { + Assert.Contains("'fivem'", DatabaseErrors.Describe((int)MySqlErrorCode.AccessDenied, Connection)); + } + + [Fact] + public void DatabaseAccessDenied_SuggestsAGrant() + { + Assert.Contains("GRANT ALL PRIVILEGES ON fiveprs.* TO 'fivem'@'localhost'", DatabaseErrors.Describe((int)MySqlErrorCode.DatabaseAccessDenied, Connection)); + } + + [Fact] + public void UnableToConnect_NamesTheServer() + { + Assert.Contains("127.0.0.1:3306", DatabaseErrors.Describe((int)MySqlErrorCode.UnableToConnectToHost, Connection)); + } + + [Fact] + public void OtherErrorsAndBadConnectionStrings_FallBack() + { + Assert.Null(DatabaseErrors.Describe((int)MySqlErrorCode.LockDeadlock, Connection)); + Assert.Contains("the database", DatabaseErrors.Describe((int)MySqlErrorCode.UnknownDatabase, "not a connection string")!); + } + } +} diff --git a/FivePRS.Tests/DispatchServiceTests.cs b/FivePRS.Tests/DispatchServiceTests.cs index 57ed32b..fd45265 100644 --- a/FivePRS.Tests/DispatchServiceTests.cs +++ b/FivePRS.Tests/DispatchServiceTests.cs @@ -277,7 +277,7 @@ public void SetOnDuty_AssignsDepartmentCallsign() { _dispatch.SetOnDuty(7, "Medic", Department.EMS, 1); - Assert.Equal("EMS-7", _dispatch.GetUnit(7)!.Callsign); + Assert.Equal("SAMS-7", _dispatch.GetUnit(7)!.Callsign); } [Fact] diff --git a/FivePRS.Tests/LoadoutConfigTests.cs b/FivePRS.Tests/LoadoutConfigTests.cs index 1be31b0..56f05ce 100644 --- a/FivePRS.Tests/LoadoutConfigTests.cs +++ b/FivePRS.Tests/LoadoutConfigTests.cs @@ -16,13 +16,13 @@ public class LoadoutConfigTests [InlineData(20, LoadoutTier.Command)] public void TierForRank_MapsRankBands(int rank, LoadoutTier expected) { - Assert.Equal(expected, PoliceLoadoutsConfig.TierForRank(rank)); + Assert.Equal(expected, DepartmentLoadoutsConfig.TierForRank(rank)); } [Fact] public void Vehicles_Bcso_UsesSheriffModels() { - var config = new PoliceVehiclesConfig(); + var config = new DepartmentVehiclesConfig(); Assert.Equal(new[] { "sheriff", "sheriff2" }, config.TierFor("BCSO", LoadoutTier.Recruit).Models); Assert.Equal(new[] { "police", "police2" }, config.TierFor("lspd", LoadoutTier.Recruit).Models); @@ -32,7 +32,7 @@ public void Vehicles_Bcso_UsesSheriffModels() [Fact] public void Vehicles_AgencyMissingTier_FallsBackToDefault() { - var config = new PoliceVehiclesConfig(); + var config = new DepartmentVehiclesConfig(); config.Agencies["sasp"] = new AgencyVehicleDef { Patrol = new VehicleTierDef { Models = new[] { "police4" } } }; Assert.Equal(new[] { "police4" }, config.TierFor("sasp", LoadoutTier.Officer).Models); @@ -42,7 +42,7 @@ public void Vehicles_AgencyMissingTier_FallsBackToDefault() [Fact] public void Loadouts_AgencyOverride_ReplacesOnlyGivenParts() { - var config = new PoliceLoadoutsConfig(); + var config = new DepartmentLoadoutsConfig(); var sheriffUniform = new UniformDef { PedModels = new PedModelsDef { Male = "s_m_y_sheriff_01", Female = "s_f_y_sheriff_01" }, @@ -56,8 +56,8 @@ public void Loadouts_AgencyOverride_ReplacesOnlyGivenParts() } [Theory] - [InlineData("police_loadouts.json", typeof(PoliceLoadoutsConfig))] - [InlineData("police_vehicles.json", typeof(PoliceVehiclesConfig))] + [InlineData("police_loadouts.json", typeof(DepartmentLoadoutsConfig))] + [InlineData("police_vehicles.json", typeof(DepartmentVehiclesConfig))] [InlineData("settings.json", typeof(ResourceSettings))] public void BundledConfigFile_MatchesBuiltInDefaults(string file, Type type) { diff --git a/FivePRS.Tests/MainThreadTests.cs b/FivePRS.Tests/MainThreadTests.cs new file mode 100644 index 0000000..bd423a2 --- /dev/null +++ b/FivePRS.Tests/MainThreadTests.cs @@ -0,0 +1,71 @@ +using System.Threading.Tasks; +using FivePRS.Server; +using Xunit; + +namespace FivePRS.Tests +{ + [Collection(TestDatabase.Collection)] + public class MainThreadTests + { + [Fact] + public void Resume_NotEnabled_CompletesImmediately() + { + Assert.True(MainThread.Resume().IsCompleted); + } + + [Fact] + public void Resume_OnTickThread_CompletesImmediately() + { + MainThread.Enable(); + try + { + MainThread.Tick(); + Assert.True(MainThread.Resume().IsCompleted); + } + finally + { + MainThread.Disable(); + } + } + + [Fact] + public async Task Run_BeforeFirstTick_WaitsForTick() + { + MainThread.Enable(); + try + { + var run = MainThread.Run(() => Task.FromResult(42)); + Assert.False(run.IsCompleted); + + MainThread.Tick(); + + Assert.Equal(42, await run); + } + finally + { + MainThread.Disable(); + } + } + + [Fact] + public async Task Run_FromAnotherThread_FinishesOnlyAfterTick() + { + MainThread.Enable(); + try + { + MainThread.Tick(); + var run = Task.Run(() => MainThread.Run(() => Task.FromResult(7))); + await Task.Delay(100); + Assert.False(run.IsCompleted); + + MainThread.Tick(); + + Assert.Equal(7, await run); + } + finally + { + MainThread.Disable(); + } + } + } +} diff --git a/FivePRS.Tests/MySqlConnectionTests.cs b/FivePRS.Tests/MySqlConnectionTests.cs new file mode 100644 index 0000000..dc57acf --- /dev/null +++ b/FivePRS.Tests/MySqlConnectionTests.cs @@ -0,0 +1,52 @@ +using System; +using FivePRS.Server.Database; +using MySqlConnector; +using Xunit; + +namespace FivePRS.Tests +{ + public class MySqlConnectionTests + { + private const string Base = "Server=127.0.0.1;Port=3306;Database=fiveprs;User ID=fivem;Password=secret;"; + + [Fact] + public void Prepare_NoSslMode_DisablesTlsAndAllowsKeyRetrieval() + { + var builder = new MySqlConnectionStringBuilder(MySqlProvider.PrepareConnectionString(Base)); + + Assert.Equal(MySqlSslMode.None, builder.SslMode); + Assert.True(builder.AllowPublicKeyRetrieval); + Assert.Equal("fiveprs", builder.Database); + } + + [Theory] + [InlineData("SslMode=None;")] + [InlineData("Ssl Mode=none;")] + [InlineData("sslmode=disabled;")] + public void Prepare_SslModeNone_IsKept(string ssl) + { + var builder = new MySqlConnectionStringBuilder(MySqlProvider.PrepareConnectionString(Base + ssl)); + + Assert.Equal(MySqlSslMode.None, builder.SslMode); + } + + [Theory] + [InlineData("SslMode=Required;")] + [InlineData("SslMode=Preferred;")] + [InlineData("Ssl Mode=VerifyFull;")] + public void Prepare_TlsRequested_ExplainsItIsUnsupported(string ssl) + { + var error = Assert.Throws(() => MySqlProvider.PrepareConnectionString(Base + ssl)); + + Assert.Contains("SslMode=None", error.Message); + } + + [Fact] + public void Prepare_ExplicitKeyRetrieval_IsKept() + { + var builder = new MySqlConnectionStringBuilder(MySqlProvider.PrepareConnectionString(Base + "AllowPublicKeyRetrieval=false;")); + + Assert.False(builder.AllowPublicKeyRetrieval); + } + } +} diff --git a/FivePRS.Tests/ServerAssemblyCompatibilityTests.cs b/FivePRS.Tests/ServerAssemblyCompatibilityTests.cs new file mode 100644 index 0000000..0018c1e --- /dev/null +++ b/FivePRS.Tests/ServerAssemblyCompatibilityTests.cs @@ -0,0 +1,29 @@ +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using FivePRS.Server.Database; +using Xunit; + +namespace FivePRS.Tests +{ + public class ServerAssemblyCompatibilityTests + { + [Fact] + public void ServerAssembly_HasNoPInvoke() + { + using var stream = File.OpenRead(typeof(SqlStore).Assembly.Location); + using var pe = new PEReader(stream); + var metadata = pe.GetMetadataReader(); + + var pinvokes = metadata.MethodDefinitions + .Select(metadata.GetMethodDefinition) + .Where(method => (method.Attributes & MethodAttributes.PinvokeImpl) != 0) + .Select(method => metadata.GetString(method.Name)) + .ToList(); + + Assert.Empty(pinvokes); + } + } +} diff --git a/FivePRS.Tests/StreetTests.cs b/FivePRS.Tests/StreetTests.cs new file mode 100644 index 0000000..3818833 --- /dev/null +++ b/FivePRS.Tests/StreetTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Core.Police; +using FivePRS.Server.Civilian; +using Newtonsoft.Json; +using Xunit; + +namespace FivePRS.Tests +{ + public sealed class StreetProfilesTests + { + [Fact] + public void Person_SameSeed_IsIdentical() + { + var a = StreetProfiles.Person(1234, female: true); + var b = StreetProfiles.Person(1234, female: true); + + Assert.Equal(JsonConvert.SerializeObject(a), JsonConvert.SerializeObject(b)); + Assert.Equal("Female", a.Gender); + } + + [Fact] + public void Person_ProducesValidCharacterFields() + { + for (var seed = 0; seed < 500; seed++) + { + var person = StreetProfiles.Person(seed, seed % 2 == 0); + + Assert.True(CivilianRules.TryNormalizeName(person.FirstName, out _)); + Assert.True(CivilianRules.TryNormalizeName(person.LastName, out _)); + Assert.True(CivilianRules.TryParseDateOfBirth(person.DateOfBirth, new DateTime(2026, 10, 10), out _)); + Assert.False(person.WillFlee && person.Warrant is null); + } + } + + [Fact] + public void Person_OutcomesHaveSensibleRates() + { + var people = Enumerable.Range(0, 4000).Select(seed => StreetProfiles.Person(seed, false)).ToList(); + + Assert.InRange(people.Count(p => p.DriverLicense == StreetLicense.Valid) / 40.0, 75, 89); + Assert.InRange(people.Count(p => p.Warrant is not null) / 40.0, 4, 10); + Assert.InRange(people.Count(p => p.Contraband is not null) / 40.0, 11, 19); + } + + [Fact] + public void Vehicle_SamePlate_IsIdenticalIgnoringCaseAndSpaces() + { + var a = StreetProfiles.Vehicle("ab12cd "); + var b = StreetProfiles.Vehicle(" AB12CD"); + + Assert.Equal(JsonConvert.SerializeObject(a), JsonConvert.SerializeObject(b)); + Assert.Equal("AB12CD", a.Plate); + } + + [Fact] + public void Vehicle_StolenIsNeverOwnedByDriver() + { + for (var i = 0; i < 2000; i++) + { + var vehicle = StreetProfiles.Vehicle($"P{i}"); + Assert.False(vehicle.Stolen && vehicle.DriverIsOwner); + Assert.False(vehicle.Stolen && !vehicle.Registered); + } + } + + [Fact] + public void Citations_ConfigReplacesDefaults() + { + var config = JsonConvert.DeserializeObject("{\"offences\":[{\"label\":\"Jaywalking\",\"fine\":50}]}")!; + + Assert.Equal("Jaywalking", Assert.Single(config.Offences).Label); + } + } + + [Collection(TestDatabase.Collection)] + public sealed class StreetServiceTests : IAsyncLifetime + { + private TestDatabase _database = null!; + private StreetService _street = null!; + + public async Task InitializeAsync() + { + _database = await TestDatabase.CreateAsync(); + var lookup = new LookupService(_database.Store, () => new ResourceSettings(), () => new LicensesConfig()); + _street = new StreetService(_database.Store, lookup); + } + + public Task DisposeAsync() + { + _database.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Identify_CreatesPersonOnceWithLicenseAndWarrant() + { + var seed = Enumerable.Range(0, 1000).First(s => StreetProfiles.Person(s, false) is { Warrant: not null, DriverLicense: StreetLicense.Suspended }); + var person = StreetProfiles.Person(seed, false); + + var (first, error) = await _street.IdentifyAsync(seed, false); + var (second, _) = await _street.IdentifyAsync(seed, false); + + Assert.Null(error); + Assert.Equal(first!.Record!.Character.Id, second!.Record!.Character.Id); + Assert.Equal(person.FirstName, first.Record.Character.FirstName); + Assert.True(first.Record.Character.HasActiveWarrant); + Assert.Equal(LicenseStatus.Suspended, first.Record.Licenses.Single(l => l.Type == "driver").Status); + Assert.Single(second.Record.Records); + } + + [Fact] + public async Task Plate_Registered_CreatesVehicleForOwner() + { + var plate = Enumerable.Range(0, 1000).Select(i => $"FP{i}").First(p => StreetProfiles.Vehicle(p) is { Registered: true, DriverIsOwner: true, Stolen: false }); + + var (result, error) = await _street.PlateAsync(plate, "Sultan", 42, false); + + Assert.Null(error); + Assert.Equal(StreetProfiles.Person(42, false).LastName, result!.Record!.Character.LastName); + var vehicle = Assert.Single(result.Record.Vehicles); + Assert.Equal(plate, vehicle.Plate); + Assert.Equal("Sultan", vehicle.Model); + } + + [Fact] + public async Task Plate_Unregistered_ReturnsNoRecord() + { + var plate = Enumerable.Range(0, 1000).Select(i => $"NR{i}").First(p => !StreetProfiles.Vehicle(p).Registered); + + var (result, error) = await _street.PlateAsync(plate, "Sultan", 1, false); + + Assert.Null(result); + Assert.Contains(plate, error); + } + + [Fact] + public async Task Plate_PlayerRegistration_TakesPriority() + { + var ownerId = await _database.Store.CreateCharacterAsync(new CharacterInfo + { + OwnerLicense = "license:player", FirstName = "Jane", LastName = "Player", DateOfBirth = "1990-01-01", Gender = "Female", + }); + await _database.Store.AddVehicleAsync(new VehicleInfo { CharacterId = ownerId, Plate = "JANE1", Model = "Comet" }); + + var (result, _) = await _street.PlateAsync("jane1", "Sultan", 7, true); + + Assert.Equal(ownerId, result!.Record!.Character.Id); + } + + [Fact] + public async Task Plate_UnreadablePlate_ReturnsError() + { + var (result, error) = await _street.PlateAsync("!!!", "Sultan", 1, false); + + Assert.Null(result); + Assert.NotNull(error); + } + } +} diff --git a/FivePRS.Tests/TerritoryMapTests.cs b/FivePRS.Tests/TerritoryMapTests.cs index 8373ded..9593809 100644 --- a/FivePRS.Tests/TerritoryMapTests.cs +++ b/FivePRS.Tests/TerritoryMapTests.cs @@ -46,7 +46,7 @@ public void ResolveAgency_UsesRequestedAgencyWhenDepartmentMatches() { Assert.Equal("bcso", _map.ResolveAgency(Department.Police, "BCSO")?.Id); Assert.Equal("lspd", _map.ResolveAgency(Department.Police, null)?.Id); - Assert.Null(_map.ResolveAgency(Department.Fire, "bcso")); + Assert.Equal("lsfd", _map.ResolveAgency(Department.Fire, "bcso")?.Id); } [Fact] diff --git a/FivePRS.Tests/WorldSettingsTests.cs b/FivePRS.Tests/WorldSettingsTests.cs new file mode 100644 index 0000000..1785fbe --- /dev/null +++ b/FivePRS.Tests/WorldSettingsTests.cs @@ -0,0 +1,28 @@ +using FivePRS.Core.Config; +using Newtonsoft.Json; +using Xunit; + +namespace FivePRS.Tests +{ + public class WorldSettingsTests + { + [Fact] + public void Defaults_DisableWantedLevelAndDispatchButKeepRandomCops() + { + var world = new ResourceSettings().World; + + Assert.True(world.DisableWantedLevel); + Assert.True(world.DisableDispatchServices); + Assert.False(world.DisableRandomCops); + } + + [Fact] + public void PartialOverride_KeepsOtherDefaults() + { + var world = JsonConvert.DeserializeObject("{\"world\":{\"disableRandomCops\":true}}")!.World; + + Assert.True(world.DisableRandomCops); + Assert.True(world.DisableWantedLevel); + } + } +} diff --git a/FivePRS.sln b/FivePRS.sln index cb38e33..b4d5866 100644 --- a/FivePRS.sln +++ b/FivePRS.sln @@ -19,6 +19,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Windows", "Stubs\Windows\Wi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Windows.SDK.NET", "Stubs\Microsoft.Windows.SDK.NET\Microsoft.Windows.SDK.NET.csproj", "{C341AFE3-CE49-42AE-92A5-4D19E5047AC6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FivePRS.Native", "FivePRS.Native\FivePRS.Native.csproj", "{D24B9A11-445A-41BE-8715-DD67DD497072}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FivePRS.Fire", "FivePRS.Fire\FivePRS.Fire.csproj", "{555768FE-12A3-455E-A92D-D95EB0A37523}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FivePRS.EMS", "FivePRS.EMS\FivePRS.EMS.csproj", "{14B5C125-EFD6-4257-BF68-ADF044752424}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -113,6 +119,42 @@ Global {C341AFE3-CE49-42AE-92A5-4D19E5047AC6}.Release|x64.Build.0 = Release|Any CPU {C341AFE3-CE49-42AE-92A5-4D19E5047AC6}.Release|x86.ActiveCfg = Release|Any CPU {C341AFE3-CE49-42AE-92A5-4D19E5047AC6}.Release|x86.Build.0 = Release|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Debug|x64.ActiveCfg = Debug|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Debug|x64.Build.0 = Debug|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Debug|x86.ActiveCfg = Debug|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Debug|x86.Build.0 = Debug|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Release|Any CPU.Build.0 = Release|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Release|x64.ActiveCfg = Release|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Release|x64.Build.0 = Release|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Release|x86.ActiveCfg = Release|Any CPU + {D24B9A11-445A-41BE-8715-DD67DD497072}.Release|x86.Build.0 = Release|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Debug|Any CPU.Build.0 = Debug|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Debug|x64.ActiveCfg = Debug|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Debug|x64.Build.0 = Debug|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Debug|x86.ActiveCfg = Debug|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Debug|x86.Build.0 = Debug|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Release|Any CPU.ActiveCfg = Release|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Release|Any CPU.Build.0 = Release|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Release|x64.ActiveCfg = Release|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Release|x64.Build.0 = Release|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Release|x86.ActiveCfg = Release|Any CPU + {555768FE-12A3-455E-A92D-D95EB0A37523}.Release|x86.Build.0 = Release|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Debug|x64.ActiveCfg = Debug|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Debug|x64.Build.0 = Debug|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Debug|x86.ActiveCfg = Debug|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Debug|x86.Build.0 = Debug|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Release|Any CPU.ActiveCfg = Release|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Release|Any CPU.Build.0 = Release|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Release|x64.ActiveCfg = Release|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Release|x64.Build.0 = Release|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Release|x86.ActiveCfg = Release|Any CPU + {14B5C125-EFD6-4257-BF68-ADF044752424}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index e9ba85e..255fb71 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ The full documentation lives at **[fiveprs.org/docs](https://fiveprs.org/docs)** 3. Add the [server configuration](#server-configuration) below to `server.cfg`, ending with `ensure fiveprs`. 4. Start the server. The console shows `[FivePRS] Database (SQLite) ready.` and `[FivePRS] ServerBrain online.` -FivePRS runs on Windows and Linux servers. It uses SQLite out of the box, so there is nothing else to set up; MySQL is supported too. +FivePRS runs on Windows and Linux servers. On GTA V Legacy servers it uses SQLite out of the box, so there is nothing else to set up; MySQL 8 and MariaDB 10.6+ are supported too. + +> [!IMPORTANT] +> **GTA V Enhanced servers need a MySQL or MariaDB database.** FiveM for Enhanced doesn't let resources load native libraries, so SQLite can't run there. Set `fiveprs_db_type "mysql"` and `fiveprs_db_connection` (see [Server Configuration](#server-configuration)) before the first start. Optional official addons (a loading screen, Discord rich presence and a Notes app) are in the [addons repository](https://github.com/FivePRS/addons): download `fiveprs_addons.zip` from its releases. @@ -24,12 +27,13 @@ On Windows, run `publish.ps1` to build everything and package `dist/fiveprs.zip` ## Server Configuration ```cfg -set fiveprs_db_type "sqlite" # or "mysql" +set fiveprs_db_type "sqlite" # or "mysql" (required on GTA V Enhanced; also for MariaDB) set fiveprs_db_connection "" # MySQL connection string, or a custom SQLite path set fiveprs_max_xp 500 # cap on XP awarded per call set fiveprs_xp_multiplier 1.0 set fiveprs_restrict_departments false # true limits departments to the ACE below or the admin roster set fiveprs_server_icon true # use the FivePRS icon unless load_server_icon sets your own +set fiveprs_debug false # true adds full error details to the console add_ace group.admin fiveprs.admin allow add_ace group.police fiveprs.department.police allow @@ -136,6 +140,11 @@ On-duty police get a Records app. Search for a person by name or a vehicle by pl Every change is stamped with the officer's name and callsign and written to the audit log. If the person's owner is online, they get a notification and their Characters app updates. A character with an active warrant cannot be deleted. +### Traffic stops +On duty as police, with your emergency lights on, get behind an AI-driven vehicle and press `Left Shift` to pull it over. Walk up to the driver and press `E` to ask for ID, run the plate, have them step out, search them, issue a warning or citation, serve a warrant, record an arrest or release them. AI drivers get a generated identity (name, date of birth, license status and sometimes a warrant) that is saved the first time it's checked, so citations and arrests show up in Records. Offences and fines are in `config/citations.json`; fines are capped at `maxFine`. + +On foot, face any pedestrian and press `E` to stop them. The same menu opens without the vehicle actions. People and vehicles that belong to an active callout can't be stopped this way. + ### Characters, Licenses and Vehicles Every player has three civilian apps for roleplay records, all for their active character: @@ -168,6 +177,19 @@ Settings are under `updates` in `config/settings.json`: `checkForUpdates`, `repo To publish a release, move the `Unreleased` entries in `CHANGELOG.md` under a new version heading, set the same `version` in `fxmanifest.lua`, commit, and push a tag named `v`. GitHub Actions checks the versions match, builds and tests, and creates the release with `fiveprs.zip` attached and that changelog section as its notes. Versions with a suffix, like `0.9.0-ptb.1`, are published as pre-releases, and servers running a pre-release are offered newer pre-releases automatically. +## Fire and EMS +Fire (LSFD) and EMS (SAMS) are playable departments. Go on duty at a fire station or hospital like police do. + +- **Fire** callouts (Vehicle Fire, Brush Fire) start real fires that spread until they're put out with the extinguisher or the fire engine's water cannon. +- **EMS** callouts (Medical Emergency, Traffic Collision) have a patient to treat with `E`, load into the ambulance with `E`, and drive to the nearest hospital. +- Equipment and vehicles are in `config/fire_loadouts.json`, `fire_vehicles.json`, `ems_loadouts.json` and `ems_vehicles.json`, in the same format as the police files. Uniforms default to GTA's firefighter and paramedic peds; swap in freemode outfits if your server uses custom characters. + +## Death and revive +When a player dies they stay down where they fell instead of respawning, and EMS gets an automatic 911 call. On-duty EMS revive them with `E`; after `respawnSeconds` the player can press `E` to respawn at the nearest hospital. Admins can use `fprs_revive `. Settings are under `death` in `config/settings.json`: `enabled`, `respawnSeconds`, `notifyEms` and `reviveXp`. Turn it off if you use another death script. + +## Game world +FivePRS turns off wanted levels and GTA's own AI police, ambulance and fire response, so they don't interfere with roleplay. Set these under `world` in `config/settings.json`: `disableWantedLevel` and `disableDispatchServices` (both `true` by default) and `disableRandomCops` (`false` by default; `true` also stops ambient AI police from spawning). + ## Rate limits Each player can only send so many requests to the server in a short time, so one player can't flood dispatch, records or the database. Requests over the limit are ignored, the player is told to slow down where it matters (911 calls, records, civilian actions), and the console logs the first one in each window. Limits are under `rateLimits` in `config/settings.json`, as a `limit` per `perSeconds` for each kind of request; set `enabled` to `false` to turn them off. @@ -235,4 +257,10 @@ Duty changes, department changes, XP awards, rank-ups, admin actions and denied ## Troubleshooting +**`NotImplementedException` at `KeyExchangeStrength` when connecting to MySQL or MariaDB.** FiveM's server runtime can't make encrypted database connections. FivePRS now adds `SslMode=None` automatically; on older builds, add it to `fiveprs_db_connection` yourself. Keep the database on the same machine or a private network. + +**`Access to the path … is denied`.** The server's user account can't write to `resources/fiveprs`. On Linux, `chown -R resources/fiveprs`. + +**`SQLite can't run` / `P/Invoke is not allowed` on a GTA V Enhanced server.** Enhanced servers block resources from loading native libraries, and SQLite is one. Use MySQL: set `fiveprs_db_type "mysql"` and `fiveprs_db_connection` in `server.cfg`. + **`Could not load native SQLite` or `DllNotFoundException: e_sqlite3`.** The resource's `server/` folder must contain `e_sqlite3.dll` (Windows) or `libe_sqlite3.so` (Linux). Re-extract `fiveprs.zip` rather than copying individual DLLs. diff --git a/config/citations.json b/config/citations.json new file mode 100644 index 0000000..ac611b7 --- /dev/null +++ b/config/citations.json @@ -0,0 +1,14 @@ +{ + "offences": [ + { "label": "Speeding", "fine": 250 }, + { "label": "Reckless driving", "fine": 750 }, + { "label": "Running a red light", "fine": 300 }, + { "label": "Failure to signal", "fine": 100 }, + { "label": "Illegal lane change", "fine": 150 }, + { "label": "Driving without a license", "fine": 500 }, + { "label": "Driving with a suspended license", "fine": 1000 }, + { "label": "Unregistered vehicle", "fine": 400 }, + { "label": "Broken tail light", "fine": 75 }, + { "label": "Open container", "fine": 200 } + ] +} diff --git a/config/ems_loadouts.json b/config/ems_loadouts.json new file mode 100644 index 0000000..1d457e6 --- /dev/null +++ b/config/ems_loadouts.json @@ -0,0 +1,28 @@ +{ + "recruit": { + "weapons": [ + { "name": "WEAPON_FLASHLIGHT", "ammo": 1, "setCurrent": true } + ] + }, + "officer": { + "weapons": [ + { "name": "WEAPON_FLASHLIGHT", "ammo": 1, "setCurrent": true } + ] + }, + "senior": { + "weapons": [ + { "name": "WEAPON_FLASHLIGHT", "ammo": 1, "setCurrent": true }, + { "name": "WEAPON_FIREEXTINGUISHER", "ammo": 1000 } + ] + }, + "command": { + "weapons": [ + { "name": "WEAPON_FLASHLIGHT", "ammo": 1, "setCurrent": true }, + { "name": "WEAPON_FIREEXTINGUISHER", "ammo": 1000 } + ] + }, + "uniform": { "pedModels": { "male": "s_m_m_paramedic_01", "female": "s_m_m_paramedic_01" } }, + "commandUniform": { "pedModels": { "male": "s_m_m_paramedic_01", "female": "s_m_m_paramedic_01" } }, + "fallbackPedModels": { "male": "s_m_m_paramedic_01", "female": "s_m_m_paramedic_01" }, + "agencies": {} +} diff --git a/config/ems_vehicles.json b/config/ems_vehicles.json new file mode 100644 index 0000000..2c44857 --- /dev/null +++ b/config/ems_vehicles.json @@ -0,0 +1,6 @@ +{ + "patrol": { "models": ["ambulance"] }, + "senior": { "models": ["ambulance"] }, + "command": { "models": ["ambulance"] }, + "agencies": {} +} diff --git a/config/fire_loadouts.json b/config/fire_loadouts.json new file mode 100644 index 0000000..0813784 --- /dev/null +++ b/config/fire_loadouts.json @@ -0,0 +1,32 @@ +{ + "recruit": { + "weapons": [ + { "name": "WEAPON_FIREEXTINGUISHER", "ammo": 2000, "setCurrent": true }, + { "name": "WEAPON_FLASHLIGHT", "ammo": 1 } + ] + }, + "officer": { + "weapons": [ + { "name": "WEAPON_FIREEXTINGUISHER", "ammo": 2000, "setCurrent": true }, + { "name": "WEAPON_FLASHLIGHT", "ammo": 1 } + ] + }, + "senior": { + "weapons": [ + { "name": "WEAPON_FIREEXTINGUISHER", "ammo": 2000, "setCurrent": true }, + { "name": "WEAPON_FLASHLIGHT", "ammo": 1 }, + { "name": "WEAPON_HATCHET", "ammo": 1 } + ] + }, + "command": { + "weapons": [ + { "name": "WEAPON_FIREEXTINGUISHER", "ammo": 2000, "setCurrent": true }, + { "name": "WEAPON_FLASHLIGHT", "ammo": 1 }, + { "name": "WEAPON_HATCHET", "ammo": 1 } + ] + }, + "uniform": { "pedModels": { "male": "s_m_y_fireman_01", "female": "s_m_y_fireman_01" } }, + "commandUniform": { "pedModels": { "male": "s_m_y_fireman_01", "female": "s_m_y_fireman_01" } }, + "fallbackPedModels": { "male": "s_m_y_fireman_01", "female": "s_m_y_fireman_01" }, + "agencies": {} +} diff --git a/config/fire_vehicles.json b/config/fire_vehicles.json new file mode 100644 index 0000000..3347e4b --- /dev/null +++ b/config/fire_vehicles.json @@ -0,0 +1,6 @@ +{ + "patrol": { "models": ["firetruk"] }, + "senior": { "models": ["firetruk"] }, + "command": { "models": ["firetruk"] }, + "agencies": {} +} diff --git a/config/jurisdictions.json b/config/jurisdictions.json index a12176c..32f13d7 100644 --- a/config/jurisdictions.json +++ b/config/jurisdictions.json @@ -25,6 +25,20 @@ "department": "Police", "callsignPrefix": "BCSO", "territories": ["blaine_county"] + }, + { + "id": "lsfd", + "name": "Los Santos Fire Department", + "department": "Fire", + "callsignPrefix": "LSFD", + "territories": ["los_santos", "blaine_county"] + }, + { + "id": "sams", + "name": "San Andreas Medical Services", + "department": "EMS", + "callsignPrefix": "SAMS", + "territories": ["los_santos", "blaine_county"] } ] } diff --git a/config/settings.json b/config/settings.json index bc2f82f..7e0174d 100644 --- a/config/settings.json +++ b/config/settings.json @@ -23,6 +23,17 @@ "checkIntervalHours": 6, "includePreReleases": false }, + "death": { + "enabled": true, + "respawnSeconds": 180, + "notifyEms": true, + "reviveXp": 25 + }, + "world": { + "disableWantedLevel": true, + "disableDispatchServices": true, + "disableRandomCops": false + }, "rateLimits": { "enabled": true, "emergency": { "limit": 4, "perSeconds": 60 }, diff --git a/fxmanifest.lua b/fxmanifest.lua index 83a4702..0b39c34 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -6,7 +6,7 @@ author 'Pixel ' description 'The Unified Public Response Framework for FiveM.' repository 'https://github.com/FivePRS/core' license 'AGPL-3.0-or-later' -version '0.9.0-ptb.2' +version '0.9.0-ptb.3' lua54 'yes' @@ -21,6 +21,8 @@ client_scripts { 'scripts/relay.lua', 'client/FivePRS.Client.net.dll', 'client/FivePRS.Police.net.dll', + 'client/FivePRS.Fire.net.dll', + 'client/FivePRS.EMS.net.dll', 'plugins/*.net.dll', 'callouts/*.net.dll', 'apps/*.net.dll', @@ -55,4 +57,5 @@ convar_policy { 'fiveprs_db_connection', 'fiveprs_max_xp', 'fiveprs_xp_multiplier', + 'fiveprs_debug', }