From fedb8a69a9be215ee06eaeabb437c9bb24968762 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Sun, 11 Oct 2026 11:57:54 -0600 Subject: [PATCH 1/6] fix: invalid invocation failure --- CHANGELOG.md | 3 ++ FivePRS.Server/Database/SqliteNativeLoader.cs | 53 +++++++++++++------ FivePRS.Server/FivePRS.Server.csproj | 1 - .../ServerAssemblyCompatibilityTests.cs | 14 +++++ 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4490109..badff86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed +- GTA V Enhanced servers no longer log `Failed to load assembly FivePRS.Native: P/Invoke is not allowed` when using MySQL or MariaDB; the native SQLite helper is now only loaded when SQLite is used + ## [0.9.0-ptb.3] - 2026-10-10 ### Added diff --git a/FivePRS.Server/Database/SqliteNativeLoader.cs b/FivePRS.Server/Database/SqliteNativeLoader.cs index 3e4ff0c..4df44c3 100644 --- a/FivePRS.Server/Database/SqliteNativeLoader.cs +++ b/FivePRS.Server/Database/SqliteNativeLoader.cs @@ -1,6 +1,7 @@ using System; using System.IO; -using System.Runtime.CompilerServices; +using System.Reflection; +using System.Runtime.ExceptionServices; using System.Runtime.InteropServices; using SQLitePCL; @@ -9,6 +10,8 @@ namespace FivePRS.Server.Database internal static class SqliteNativeLoader { private const string LibraryName = "e_sqlite3"; + private const string NativeAssembly = "FivePRS.Native"; + private const string NativeType = "FivePRS.Native.NativeLibrary"; private static bool _loaded; @@ -21,36 +24,54 @@ public static string Load(string directory) if (!File.Exists(path)) throw new FileNotFoundException("Native SQLite library is missing from the resource.", path); - IGetFunctionPointer symbols; + var symbols = OpenLibrary(path); + + SQLite3Provider_dynamic_cdecl.Setup(LibraryName, symbols); + raw.SetProvider(new SQLite3Provider_dynamic_cdecl()); + raw.FreezeProvider(true); + + _loaded = true; + return path; + } + + private static IGetFunctionPointer OpenLibrary(string path) + { + Type library; try { - symbols = OpenLibrary(path); + library = Assembly.Load(NativeAssembly).GetType(NativeType, true)!; } - catch (Exception ex) when (!(ex is DllNotFoundException)) + catch (Exception ex) { 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); + "Use MySQL or MariaDB 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()); - raw.FreezeProvider(true); + var open = library.GetMethod("Open", BindingFlags.Public | BindingFlags.Static)!; + var symbol = library.GetMethod("Symbol", BindingFlags.Public | BindingFlags.Instance)!; - _loaded = true; - return path; - } + object handle; + try + { + handle = open.Invoke(null, new object[] { path })!; + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } - [MethodImpl(MethodImplOptions.NoInlining)] - private static IGetFunctionPointer OpenLibrary(string path) => new Symbols(FivePRS.Native.NativeLibrary.Open(path)); + return new Symbols(name => (IntPtr)symbol.Invoke(handle, new object[] { name })!); + } private sealed class Symbols : IGetFunctionPointer { - private readonly FivePRS.Native.NativeLibrary _library; + private readonly Func _lookup; - public Symbols(FivePRS.Native.NativeLibrary library) => _library = library; + public Symbols(Func lookup) => _lookup = lookup; - public IntPtr GetFunctionPointer(string name) => _library.Symbol(name); + public IntPtr GetFunctionPointer(string name) => _lookup(name); } } } diff --git a/FivePRS.Server/FivePRS.Server.csproj b/FivePRS.Server/FivePRS.Server.csproj index f3b4afd..2a86182 100644 --- a/FivePRS.Server/FivePRS.Server.csproj +++ b/FivePRS.Server/FivePRS.Server.csproj @@ -25,7 +25,6 @@ - diff --git a/FivePRS.Tests/ServerAssemblyCompatibilityTests.cs b/FivePRS.Tests/ServerAssemblyCompatibilityTests.cs index 0018c1e..0a154b6 100644 --- a/FivePRS.Tests/ServerAssemblyCompatibilityTests.cs +++ b/FivePRS.Tests/ServerAssemblyCompatibilityTests.cs @@ -25,5 +25,19 @@ public void ServerAssembly_HasNoPInvoke() Assert.Empty(pinvokes); } + + [Fact] + public void ServerAssembly_DoesNotReferenceNativeHelper() + { + using var stream = File.OpenRead(typeof(SqlStore).Assembly.Location); + using var pe = new PEReader(stream); + var metadata = pe.GetMetadataReader(); + + var references = metadata.AssemblyReferences + .Select(handle => metadata.GetString(metadata.GetAssemblyReference(handle).Name)) + .ToList(); + + Assert.DoesNotContain("FivePRS.Native", references); + } } } From d5ea2bf326106f1e906fe4dfb391cd00572973a1 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Sun, 11 Oct 2026 17:40:37 -0600 Subject: [PATCH 2/6] feat: fix coords command, implement oxmysql and more --- .gitignore | 3 + CHANGELOG.md | 22 +- FivePRS.Client/App/AppClient.cs | 25 +- FivePRS.Client/Appearance/CharacterCreator.cs | 18 +- FivePRS.Client/ClientBrain.cs | 1 + FivePRS.Client/CoordsCommand.cs | 3 +- FivePRS.Client/Dispatch/BackupClient.cs | 369 ++++++++++++++++++ FivePRS.Client/Dispatch/NpcBackupUnit.cs | 259 ++++++++++++ FivePRS.Client/Gear/DepartmentGear.cs | 18 +- FivePRS.Client/Loadout/LoadoutDefinition.cs | 4 + FivePRS.Client/Loadout/LoadoutManager.cs | 9 +- FivePRS.Client/Stations/StationMenu.cs | 18 + FivePRS.Core/Config/BackupSettings.cs | 28 ++ FivePRS.Core/Config/LoadoutConfig.cs | 32 +- FivePRS.Core/Config/ResourceSettings.cs | 3 + FivePRS.Core/Events/EventNames.cs | 5 + FivePRS.Core/Models/Call.cs | 1 + FivePRS.Core/Text/KeyCommands.cs | 1 + .../TrafficStops/TrafficStopManager.cs | 3 + FivePRS.Server/Database/AppearanceStore.cs | 2 +- FivePRS.Server/Database/AuditEntry.cs | 1 + FivePRS.Server/Database/CivilianStore.cs | 106 +++-- FivePRS.Server/Database/DatabaseErrors.cs | 51 --- FivePRS.Server/Database/DatabaseManager.cs | 21 +- FivePRS.Server/Database/DbRow.cs | 58 +++ FivePRS.Server/Database/IDatabaseProvider.cs | 21 +- FivePRS.Server/Database/Migration.cs | 46 +-- FivePRS.Server/Database/Migrator.cs | 62 +-- FivePRS.Server/Database/MySqlProvider.cs | 143 ------- FivePRS.Server/Database/OxMySqlBridge.cs | 96 +++++ FivePRS.Server/Database/OxMySqlProvider.cs | 89 +++++ FivePRS.Server/Database/PlayerStore.cs | 58 +++ FivePRS.Server/Database/PositionStore.cs | 10 +- FivePRS.Server/Database/PreferencesStore.cs | 2 +- FivePRS.Server/Database/RosterStore.cs | 14 +- FivePRS.Server/Database/SQLiteProvider.cs | 188 +++++---- FivePRS.Server/Database/SqlStatement.cs | 107 +++++ FivePRS.Server/Database/SqlStore.cs | 61 +-- FivePRS.Server/Database/SqliteNativeLoader.cs | 4 +- FivePRS.Server/Dispatch/DispatchService.cs | 77 ++++ FivePRS.Server/FivePRS.Server.csproj | 1 - FivePRS.Server/MainThread.cs | 29 +- FivePRS.Server/RetiredFiles.cs | 40 ++ FivePRS.Server/ServerBrain.Backup.cs | 65 +++ FivePRS.Server/ServerBrain.cs | 15 +- .../ClientAssemblyCompatibilityTests.cs | 1 + FivePRS.Tests/DatabaseErrorsTests.cs | 45 --- FivePRS.Tests/DispatchServiceTests.cs | 115 ++++++ FivePRS.Tests/FakeOxMySql.cs | 88 +++++ FivePRS.Tests/FivePRS.Tests.csproj | 1 + FivePRS.Tests/LoadoutConfigTests.cs | 30 ++ FivePRS.Tests/MainThreadTests.cs | 28 ++ FivePRS.Tests/MigratorTests.cs | 4 +- FivePRS.Tests/MySqlConnectionTests.cs | 52 --- FivePRS.Tests/OxMySqlProviderTests.cs | 169 ++++++++ FivePRS.Tests/RetiredFilesTests.cs | 56 +++ FivePRS.Tests/RosterServiceTests.cs | 4 +- FivePRS.Tests/SQLiteProviderTests.cs | 6 +- FivePRS.Tests/TestDatabase.cs | 5 +- README.md | 30 +- config/police_loadouts.json | 34 +- config/settings.json | 9 + fxmanifest.lua | 3 +- nui/civilian.js | 1 + nui/index.html | 1 + publish.ps1 | 54 ++- scripts/db.lua | 64 +++ 67 files changed, 2314 insertions(+), 675 deletions(-) create mode 100644 FivePRS.Client/Dispatch/BackupClient.cs create mode 100644 FivePRS.Client/Dispatch/NpcBackupUnit.cs create mode 100644 FivePRS.Core/Config/BackupSettings.cs delete mode 100644 FivePRS.Server/Database/DatabaseErrors.cs create mode 100644 FivePRS.Server/Database/DbRow.cs delete mode 100644 FivePRS.Server/Database/MySqlProvider.cs create mode 100644 FivePRS.Server/Database/OxMySqlBridge.cs create mode 100644 FivePRS.Server/Database/OxMySqlProvider.cs create mode 100644 FivePRS.Server/Database/PlayerStore.cs create mode 100644 FivePRS.Server/Database/SqlStatement.cs create mode 100644 FivePRS.Server/RetiredFiles.cs create mode 100644 FivePRS.Server/ServerBrain.Backup.cs delete mode 100644 FivePRS.Tests/DatabaseErrorsTests.cs create mode 100644 FivePRS.Tests/FakeOxMySql.cs delete mode 100644 FivePRS.Tests/MySqlConnectionTests.cs create mode 100644 FivePRS.Tests/OxMySqlProviderTests.cs create mode 100644 FivePRS.Tests/RetiredFilesTests.cs create mode 100644 scripts/db.lua diff --git a/.gitignore b/.gitignore index 44204e2..09ad4ef 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ artifacts/ .idea/ *.sln.iml +tools .ideas/ @@ -34,3 +35,5 @@ packages/ *.tmp TESTING.md + +tools/smoke/.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index badff86..eceda63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] + +## [0.9.0-ptb.4] - 2026-10-11 + +### Added +- Request backup at Code 2 or Code 3 from the stop menu, with `/backup`, or with a key you bind. Choose any unit (players first, then an NPC unit if nobody attaches), an NPC unit straight away, or players only. On a call, backup is requested for that call; otherwise a Backup Request call is created at your position. Responding units get a notification and a flashing map marker. Settings are under `backup` in `config/settings.json` +- The station locker has Edit appearance: change your character's look while on duty, then your uniform goes back on +- Creating a character while on duty explains how to set up their appearance, and the character creator opens when you next go off duty. The Characters app says why Edit appearance is unavailable on duty +- Uniforms can give female characters their own clothing under `female`, since male and female freemode characters number their clothing differently. The default police uniforms include a female version +- On start, FivePRS removes server DLLs left over from older versions (MySqlConnector and the libraries it used) + ### Fixed -- GTA V Enhanced servers no longer log `Failed to load assembly FivePRS.Native: P/Invoke is not allowed` when using MySQL or MariaDB; the native SQLite helper is now only loaded when SQLite is used +- The default police uniform no longer hides the officer's arms +- `/fprs_coords` works again instead of failing with `BadImageFormatException` + +### Changed +- The native SQLite helper is only loaded when SQLite is the database +- MySQL and MariaDB now go through [oxmysql](https://github.com/overextended/oxmysql) instead of a bundled driver. Install oxmysql, set `mysql_connection_string` as `mysql://user:password@host:port/database`, and `ensure oxmysql` before `fiveprs`. `fiveprs_db_connection` is now only for a custom SQLite path +- The server package is smaller: MySqlConnector and the six libraries it needed are no longer shipped + +### Known issues +- GTA V Enhanced servers aren't supported yet. Enhanced runs C# resources on .NET 10 in a sandbox that FivePRS doesn't run in yet; support is coming in a later release. Use a GTA V Legacy server ## [0.9.0-ptb.3] - 2026-10-10 @@ -84,6 +103,7 @@ The first public test build. - Back up your database before updating between test builds [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.3...v0.9.0-ptb.4 [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/App/AppClient.cs b/FivePRS.Client/App/AppClient.cs index 9f2adc5..67453b8 100644 --- a/FivePRS.Client/App/AppClient.cs +++ b/FivePRS.Client/App/AppClient.cs @@ -23,7 +23,10 @@ public class AppClient : BaseScript ContractResolver = new CamelCasePropertyNamesContractResolver(), }; + private const int CreatorAfterDutyMs = 2_000; + private bool _open; + private int? _creatorPending; public AppClient() { @@ -138,8 +141,15 @@ private void OnCivilianState(string json) if (_open && before is not null && CivilianPanel.CharacterCount > before) { - OpenCreator(); - return; + if (!ClientBrain.LocalPlayerData.IsOnDuty) + { + OpenCreator(); + return; + } + + _creatorPending = CivilianPanel.ActiveCharacter?.Id; + var name = CivilianPanel.ActiveCharacter?.FullName ?? "Your character"; + ClientBrain.ShowNotification($"{name} created. Use the locker at your station, or go off duty, to set up their appearance.", "Characters"); } Refresh(); @@ -252,11 +262,20 @@ private void OnRegisterVehicle() Send("civilianError", error); } - private void OnDutyChanged(bool isOnDuty, int departmentId) + private async void OnDutyChanged(bool isOnDuty, int departmentId) { DutyPanel.SetOnDuty(isOnDuty); if (!isOnDuty) RecordsPanel.Clear(); Close(); + + if (isOnDuty || _creatorPending is null) return; + + await Delay(CreatorAfterDutyMs); + var active = CivilianPanel.ActiveCharacter; + if (active is null || active.Id != _creatorPending || ClientBrain.LocalPlayerData.IsOnDuty) return; + + _creatorPending = null; + if (AppearanceManager.Current is null || AppearanceManager.CharacterId != active.Id) OpenCreator(); } private void OnDutyEnter(IDictionary data) diff --git a/FivePRS.Client/Appearance/CharacterCreator.cs b/FivePRS.Client/Appearance/CharacterCreator.cs index 2786da4..80f3c4a 100644 --- a/FivePRS.Client/Appearance/CharacterCreator.cs +++ b/FivePRS.Client/Appearance/CharacterCreator.cs @@ -72,6 +72,7 @@ public class CharacterCreator : BaseScript private static float _heading; private static string _focus = "body"; private static CharacterCreator? _instance; + private static Func? _onClosed; public static bool IsOpen => _open; @@ -91,17 +92,18 @@ public CharacterCreator() }); } - public static async void Open(int characterId, string characterName) + public static async void Open(int characterId, string characterName, bool allowOnDuty = false, Func? onClosed = null) { if (_open || _instance is null) return; - if (ClientBrain.LocalPlayerData.IsOnDuty) + if (!allowOnDuty && ClientBrain.LocalPlayerData.IsOnDuty) { - ClientBrain.ShowNotification("Go off duty before changing your appearance.", "Appearance"); + ClientBrain.ShowNotification("Go off duty, or use the locker at your station, to change your appearance.", "Appearance"); return; } _open = true; + _onClosed = onClosed; _characterId = characterId; _characterName = characterName; _original = AppearanceManager.Current; @@ -258,6 +260,14 @@ private static async Task SaveAsync() ClientEvents.TriggerServer(EventNames.ServerAppearanceSave, _characterId, JsonConvert.SerializeObject(_working)); ClientBrain.ShowNotification($"Saved {_characterName}'s appearance.", "Appearance"); Teardown(); + await RunClosedAsync(); + } + + private static async Task RunClosedAsync() + { + var onClosed = _onClosed; + _onClosed = null; + if (onClosed is not null) await onClosed(); } private static async Task CancelAsync() @@ -281,6 +291,8 @@ private static async Task CancelAsync() _busy = false; Teardown(); } + + await RunClosedAsync(); } private static void Teardown() diff --git a/FivePRS.Client/ClientBrain.cs b/FivePRS.Client/ClientBrain.cs index 3270f94..c19d62f 100644 --- a/FivePRS.Client/ClientBrain.cs +++ b/FivePRS.Client/ClientBrain.cs @@ -92,6 +92,7 @@ public ClientBrain() "~b~/er_end_callout~w~ — End active callout~n~" + "~b~/er_calls~w~ — List active calls~n~" + "~b~/er_attach [id]~w~ — Respond as backup~n~" + + "~b~/backup [2|3] [npc|players]~w~ — Request backup; /backup cancel to stand down~n~" + "~b~/er_status [available|busy]~w~ — Set unit status~n~" + "~b~/er_cuff~w~ — Cuff nearest suspect~n~" + "~b~/er_uncuff~w~ — Release cuffed suspect~n~" + diff --git a/FivePRS.Client/CoordsCommand.cs b/FivePRS.Client/CoordsCommand.cs index 6d62ea8..1b06cfb 100644 --- a/FivePRS.Client/CoordsCommand.cs +++ b/FivePRS.Client/CoordsCommand.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using CitizenFX.Core; using CitizenFX.Core.Native; using Newtonsoft.Json; @@ -19,7 +20,7 @@ public CoordsCommand() private static void Capture(string? point) { - var pointName = Array.Find(PointNames, p => string.Equals(p, point, StringComparison.OrdinalIgnoreCase)); + var pointName = PointNames.FirstOrDefault(p => string.Equals(p, point, StringComparison.OrdinalIgnoreCase)); var ped = Game.PlayerPed; var vehicle = ped.CurrentVehicle; diff --git a/FivePRS.Client/Dispatch/BackupClient.cs b/FivePRS.Client/Dispatch/BackupClient.cs new file mode 100644 index 0000000..8bc9333 --- /dev/null +++ b/FivePRS.Client/Dispatch/BackupClient.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.Menu; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using FivePRS.Core.Text; + +namespace FivePRS.Client.Dispatch +{ + public enum BackupMode + { + Any, + Npc, + Players, + } + + public class BackupClient : BaseScript + { + private const int TickMs = 500; + private const int NoUnitsWaitMs = 5_000; + private const int ConfirmTimeoutMs = 10_000; + private const int AlertBlipMs = 90_000; + + private sealed class AlertBlip + { + public AlertBlip(string callId, Blip blip, int expiresAt) + { + CallId = callId; + Blip = blip; + ExpiresAt = expiresAt; + } + + public string CallId { get; } + public Blip Blip { get; } + public int ExpiresAt { get; } + } + + private static readonly List Leaving = new(); + private static readonly List Alerts = new(); + + private static NpcBackupUnit? _npc; + private static bool _spawning; + private static bool _npcOnly; + private static BackupMode _mode; + private static int _code = 3; + private static int? _requestedAt; + + public BackupClient() + { + EventHandlers[EventNames.ClientBackupRequested] += new Action(OnBackupRequested); + EventHandlers[EventNames.LocalDutyChanged] += new Action((onDuty, _) => { if (!onDuty) Reset(false); }); + EventHandlers["onClientResourceStop"] += new Action(name => + { + if (name == API.GetCurrentResourceName()) Reset(true); + }); + + API.RegisterCommand("backup", new Action, string>((_, args, __) => OnCommand(args)), false); + API.RegisterCommand(KeyCommands.Backup, new Action, string>((_, __, ___) => Toggle()), false); + API.RegisterKeyMapping(KeyCommands.Backup, "FivePRS: Request or cancel backup (Code 3)", "keyboard", ""); + + Tick += OnTickAsync; + } + + public static bool IsActive => _npcOnly || _npc is not null || OwnRequest() is not null; + + public static void Request(BackupMode mode, int code) + { + var settings = ConfigManager.Settings.Backup; + if (!settings.Enabled) + { + ClientBrain.ShowNotification("~r~Backup requests are turned off on this server."); + return; + } + + if (DispatchClient.LocalUnit is null) + { + ClientBrain.ShowNotification("~r~You need to be on duty to request backup."); + return; + } + + if (mode != BackupMode.Players && !settings.NpcEnabled) + { + if (mode == BackupMode.Npc) + { + ClientBrain.ShowNotification("~r~NPC backup is turned off on this server."); + return; + } + mode = BackupMode.Players; + } + + _mode = mode; + _code = code == 2 ? 2 : 3; + + if (mode == BackupMode.Npc) + { + if (_npc is not null || _spawning) + { + ClientBrain.ShowNotification("~b~Backup~w~ | An NPC unit is already on the way."); + return; + } + + _npcOnly = true; + ClientBrain.ShowNotification($"~b~Backup~w~ | NPC unit responding {CodeLabel(_code)}."); + _ = SpawnNpcAsync(); + return; + } + + _npcOnly = false; + _requestedAt = API.GetGameTimer(); + ClientEvents.TriggerServer(EventNames.ServerRequestBackup, _code); + } + + public static void Cancel() + { + if (OwnRequest() is not null) ClientEvents.TriggerServer(EventNames.ServerCancelBackup); + else if (_npcOnly || _npc is not null) ClientBrain.ShowNotification("~b~Backup~w~ | Backup cancelled."); + + _npcOnly = false; + _requestedAt = null; + DismissNpc(); + } + + public static MenuItem MenuEntry() => IsActive + ? new MenuItem + { + Label = "Cancel backup", + Detail = _npc is not null ? "NPC unit on scene or en route" : CodeLabel(_code), + Select = () => + { + Cancel(); + CompactMenu.Close(); + return Task.FromResult(0); + }, + } + : new MenuItem { Label = "Request backup", Submenu = ModePage }; + + private static MenuPage ModePage() + { + var npc = ConfigManager.Settings.Backup.NpcEnabled; + var page = new MenuPage { Title = "Request backup", Subtitle = "Who should respond?" }; + + page.Items.Add(new MenuItem + { + Label = "Any unit", + Detail = npc ? "Players, then NPC if nobody comes" : "On-duty players", + Submenu = () => CodePage(BackupMode.Any), + }); + page.Items.Add(new MenuItem + { + Label = "NPC unit", + Detail = npc ? "Sent straight away" : "Turned off", + Disabled = !npc, + Submenu = () => CodePage(BackupMode.Npc), + }); + if (npc) + { + page.Items.Add(new MenuItem + { + Label = "Players only", + Detail = "No NPC unit", + Submenu = () => CodePage(BackupMode.Players), + }); + } + + return page; + } + + private static MenuPage CodePage(BackupMode mode) + { + var page = new MenuPage { Title = "Request backup", Subtitle = mode == BackupMode.Npc ? "NPC unit" : mode == BackupMode.Players ? "Players only" : "Any unit" }; + + page.Items.Add(CodeItem(mode, 3, "Lights and sirens")); + page.Items.Add(CodeItem(mode, 2, "Urgent, no lights")); + return page; + } + + private static MenuItem CodeItem(BackupMode mode, int code, string detail) => new() + { + Label = $"Code {code}", + Detail = detail, + Select = () => + { + Request(mode, code); + CompactMenu.Close(); + return Task.FromResult(0); + }, + }; + + private static void Toggle() + { + if (IsActive) Cancel(); + else Request(BackupMode.Any, 3); + } + + private static void OnCommand(List args) + { + var words = args.Select(arg => arg?.ToString()?.ToLowerInvariant() ?? string.Empty).ToList(); + if (words.Contains("cancel")) + { + Cancel(); + return; + } + + var mode = words.Contains("npc") ? BackupMode.Npc : words.Contains("players") ? BackupMode.Players : BackupMode.Any; + Request(mode, words.Contains("2") ? 2 : 3); + } + + private static CallInfo? OwnRequest() + { + var unit = DispatchClient.LocalUnit; + if (unit?.CallId is null) return null; + return DispatchClient.Snapshot.Calls.FirstOrDefault(call => call.Id == unit.CallId && call.BackupCode > 0); + } + + private async Task OnTickAsync() + { + await Delay(TickMs); + + var now = API.GetGameTimer(); + var player = Game.PlayerPed; + + ExpireAlerts(now); + UpdateLeaving(player); + + var own = OwnRequest(); + if (own is not null) _code = own.BackupCode; + + if (!_npcOnly && _requestedAt is not null && own is null && now - _requestedAt.Value > ConfirmTimeoutMs) + { + _requestedAt = null; + DismissNpc(); + } + + if (_requestedAt is not null && own is not null && _mode == BackupMode.Any && _npc is null && !_spawning && + ConfigManager.Settings.Backup.NpcEnabled && !PlayerResponded(own) && now - _requestedAt.Value >= NpcWaitMs()) + { + ClientBrain.ShowNotification(AnyOtherUnits() + ? "~b~Backup~w~ | No units have responded. An NPC unit is on the way." + : "~b~Backup~w~ | No other units are on duty. An NPC unit is on the way."); + _ = SpawnNpcAsync(); + } + + if (_npc is null) return; + + if (!_npcOnly && own is null && _requestedAt is null) + { + DismissNpc(); + return; + } + + _npc.Update(player); + if (_npc.Released) + { + _npc = null; + _npcOnly = false; + } + } + + private static bool PlayerResponded(CallInfo call) => + call.Units.Any(id => id != Game.Player.ServerId); + + private static bool AnyOtherUnits() + { + var me = DispatchClient.LocalUnit; + return DispatchClient.Snapshot.Units.Any(unit => + unit.ServerId != Game.Player.ServerId && + (unit.Department == Department.Police || (me is not null && unit.Department == me.Department))); + } + + private static int NpcWaitMs() => + AnyOtherUnits() ? Math.Max(0, ConfigManager.Settings.Backup.NpcWaitSeconds) * 1000 : NoUnitsWaitMs; + + private static async Task SpawnNpcAsync() + { + _spawning = true; + try + { + var unit = await NpcBackupUnit.SpawnAsync(Game.PlayerPed.Position, _code == 3, ConfigManager.Settings.Backup); + if (unit is null) + { + ClientBrain.ShowNotification("~r~No NPC unit could reach you. Try again somewhere with a nearby road."); + _npcOnly = false; + return; + } + + if (!_npcOnly && _requestedAt is null) + { + unit.Delete(); + return; + } + + _npc = unit; + } + finally + { + _spawning = false; + } + } + + private static void DismissNpc() + { + if (_npc is null) return; + + _npc.Dismiss(); + Leaving.Add(_npc); + _npc = null; + } + + private static void UpdateLeaving(Ped player) + { + foreach (var unit in Leaving.ToList()) + { + unit.Update(player); + if (unit.Released) Leaving.Remove(unit); + } + } + + private void OnBackupRequested(string callId, string callsign, int code, float x, float y, float z) + { + var blip = World.CreateBlip(new Vector3(x, y, z)); + blip.Color = code == 3 ? BlipColor.Red : BlipColor.Yellow; + blip.IsFlashing = true; + blip.Name = $"Backup · {callsign} · #{callId}"; + Alerts.Add(new AlertBlip(callId, blip, API.GetGameTimer() + AlertBlipMs)); + API.PlaySoundFrontend(-1, "TIMER_STOP", "HUD_MINI_GAME_SOUNDSET", true); + } + + private static void ExpireAlerts(int now) + { + var callIds = new HashSet(DispatchClient.Snapshot.Calls.Where(c => c.BackupCode > 0).Select(c => c.Id)); + foreach (var alert in Alerts.ToList()) + { + var recent = alert.ExpiresAt - now > AlertBlipMs - ConfirmTimeoutMs; + if (now < alert.ExpiresAt && (recent || callIds.Contains(alert.CallId)) && alert.Blip.Exists()) continue; + + if (alert.Blip.Exists()) alert.Blip.Delete(); + Alerts.Remove(alert); + } + } + + private static void Reset(bool delete) + { + _npcOnly = false; + _requestedAt = null; + + if (delete) + { + _npc?.Delete(); + _npc = null; + foreach (var unit in Leaving) unit.Delete(); + Leaving.Clear(); + } + else + { + DismissNpc(); + } + + foreach (var alert in Alerts.Where(a => a.Blip.Exists())) alert.Blip.Delete(); + Alerts.Clear(); + } + + private static string CodeLabel(int code) => code == 3 ? "~r~Code 3~w~" : "~y~Code 2~w~"; + } +} diff --git a/FivePRS.Client/Dispatch/NpcBackupUnit.cs b/FivePRS.Client/Dispatch/NpcBackupUnit.cs new file mode 100644 index 0000000..5a929b8 --- /dev/null +++ b/FivePRS.Client/Dispatch/NpcBackupUnit.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Config; + +namespace FivePRS.Client.Dispatch +{ + internal sealed class NpcBackupUnit + { + private const float SpawnDistance = 200f; + private const float MinSpawnDistance = 100f; + private const float ArriveDistance = 35f; + private const float RedirectDistance = 60f; + private const float FollowDistance = 10f; + private const float StandDistance = 5f; + private const int DrivingStyle = 786603; + private const int CopPedType = 6; + private const int LeaveTimeoutMs = 25_000; + + private enum Stage { Driving, OnScene, Leaving } + + private sealed class SpawnPoint + { + public SpawnPoint(Vector3 position, float heading) + { + Position = position; + Heading = heading; + } + + public Vector3 Position { get; } + public float Heading { get; } + } + + private readonly Vehicle _vehicle; + private readonly List _crew; + private readonly HashSet _following = new(); + private readonly bool _code3; + private readonly Blip _blip; + private Stage _stage = Stage.Driving; + private Vector3 _target; + private int _leavingSince; + + private NpcBackupUnit(Vehicle vehicle, List crew, Vector3 target, bool code3) + { + _vehicle = vehicle; + _crew = crew; + _code3 = code3; + + _blip = vehicle.AttachBlip(); + _blip.Sprite = BlipSprite.PoliceCar; + _blip.Color = BlipColor.Blue; + _blip.Name = "Backup unit"; + + API.SetVehicleSiren(vehicle.Handle, code3); + DriveTo(target); + } + + public bool Released { get; private set; } + + public static async Task SpawnAsync(Vector3 target, bool code3, BackupSettings settings) + { + var spawn = FindSpawn(target); + if (spawn is null) return null; + + var vehicleModel = new Model(settings.NpcVehicle); + var pedModel = new Model(settings.NpcPedModel); + if (!await vehicleModel.Request(7_000) || !await pedModel.Request(7_000)) + { + vehicleModel.MarkAsNoLongerNeeded(); + pedModel.MarkAsNoLongerNeeded(); + return null; + } + + var vehicle = await World.CreateVehicle(vehicleModel, spawn.Position, spawn.Heading); + vehicleModel.MarkAsNoLongerNeeded(); + if (vehicle is null || !vehicle.Exists()) + { + pedModel.MarkAsNoLongerNeeded(); + return null; + } + + API.SetVehicleEngineOn(vehicle.Handle, true, true, false); + + var crew = new List(); + var seats = Math.Max(1, Math.Min(settings.NpcCrew, 2)); + for (var seat = -1; seat < seats - 1; seat++) + { + var handle = API.CreatePedInsideVehicle(vehicle.Handle, CopPedType, (uint)pedModel.Hash, seat, true, false); + if (handle == 0) continue; + + var ped = new Ped(handle); + API.SetPedRelationshipGroupHash(handle, (uint)API.GetHashKey("COP")); + API.GiveWeaponToPed(handle, (uint)API.GetHashKey("WEAPON_PISTOL"), 250, false, false); + API.SetPedKeepTask(handle, true); + ped.BlockPermanentEvents = true; + crew.Add(ped); + } + pedModel.MarkAsNoLongerNeeded(); + + if (crew.Count == 0) + { + vehicle.Delete(); + return null; + } + + return new NpcBackupUnit(vehicle, crew, target, code3); + } + + public void Update(Ped player) + { + if (Released) return; + + if (!_vehicle.Exists() || _crew.All(ped => !ped.Exists() || ped.IsDead)) + { + Release(); + return; + } + + switch (_stage) + { + case Stage.Driving: + UpdateDriving(player); + break; + case Stage.OnScene: + UpdateOnScene(player); + break; + case Stage.Leaving: + UpdateLeaving(); + break; + } + } + + public void Dismiss() + { + if (Released || _stage == Stage.Leaving) return; + + _stage = Stage.Leaving; + _leavingSince = API.GetGameTimer(); + API.SetVehicleSiren(_vehicle.Handle, false); + + for (var i = 0; i < _crew.Count; i++) + { + var ped = _crew[i]; + if (!ped.Exists() || ped.IsDead || API.IsPedInVehicle(ped.Handle, _vehicle.Handle, false)) continue; + + ped.BlockPermanentEvents = true; + API.TaskEnterVehicle(ped.Handle, _vehicle.Handle, LeaveTimeoutMs, i == 0 ? -1 : i - 1, 1.5f, 1, 0); + } + } + + public void Delete() + { + if (_blip.Exists()) _blip.Delete(); + foreach (var ped in _crew.Where(ped => ped.Exists())) ped.Delete(); + if (_vehicle.Exists()) _vehicle.Delete(); + Released = true; + } + + private void UpdateDriving(Ped player) + { + var driver = _crew[0]; + if (!driver.Exists() || driver.IsDead || !API.IsPedInVehicle(driver.Handle, _vehicle.Handle, false)) + { + ArriveOnScene(); + return; + } + + if (Vector3.Distance(player.Position, _target) > RedirectDistance) + DriveTo(player.Position); + + if (Vector3.Distance(_vehicle.Position, player.Position) <= ArriveDistance && _vehicle.Speed < 3f) + ArriveOnScene(); + } + + private void ArriveOnScene() + { + _stage = Stage.OnScene; + + foreach (var ped in _crew.Where(ped => ped.Exists() && !ped.IsDead)) + { + ped.BlockPermanentEvents = false; + if (API.IsPedInVehicle(ped.Handle, _vehicle.Handle, false)) + API.TaskLeaveVehicle(ped.Handle, _vehicle.Handle, 0); + } + } + + private void UpdateOnScene(Ped player) + { + foreach (var ped in _crew.Where(ped => ped.Exists() && !ped.IsDead)) + { + if (API.IsPedInAnyVehicle(ped.Handle, false) || API.IsPedInCombat(ped.Handle, 0)) continue; + + var distance = Vector3.Distance(ped.Position, player.Position); + if (distance > FollowDistance && !_following.Contains(ped.Handle)) + { + API.TaskGoToEntity(ped.Handle, player.Handle, -1, StandDistance, 2f, 0f, 0); + _following.Add(ped.Handle); + } + else if (distance <= StandDistance + 1f && _following.Remove(ped.Handle)) + { + API.TaskTurnPedToFaceEntity(ped.Handle, player.Handle, -1); + } + } + } + + private void UpdateLeaving() + { + var aboard = _crew.Where(ped => ped.Exists() && !ped.IsDead) + .All(ped => API.IsPedInVehicle(ped.Handle, _vehicle.Handle, false)); + if (!aboard && API.GetGameTimer() - _leavingSince < LeaveTimeoutMs) return; + + var driver = _crew.FirstOrDefault(ped => ped.Exists() && !ped.IsDead && API.GetPedInVehicleSeat(_vehicle.Handle, -1) == ped.Handle); + if (driver is not null) + API.TaskVehicleDriveWander(driver.Handle, _vehicle.Handle, 15f, DrivingStyle); + + Release(); + } + + private void DriveTo(Vector3 target) + { + _target = target; + var driver = _crew[0]; + if (!driver.Exists()) return; + + API.TaskVehicleDriveToCoordLongrange(driver.Handle, _vehicle.Handle, target.X, target.Y, target.Z, + _code3 ? 30f : 17f, DrivingStyle, ArriveDistance - 10f); + } + + private void Release() + { + if (_blip.Exists()) _blip.Delete(); + foreach (var ped in _crew.Where(ped => ped.Exists())) ped.MarkAsNoLongerNeeded(); + if (_vehicle.Exists()) _vehicle.MarkAsNoLongerNeeded(); + Released = true; + } + + private static SpawnPoint? FindSpawn(Vector3 target) + { + var start = new Random().Next(360); + for (var step = 0; step < 8; step++) + { + var angle = (start + step * 45) * Math.PI / 180.0; + var probe = target + new Vector3((float)Math.Cos(angle) * SpawnDistance, (float)Math.Sin(angle) * SpawnDistance, 0f); + + var node = Vector3.Zero; + var heading = 0f; + if (!API.GetClosestVehicleNodeWithHeading(probe.X, probe.Y, probe.Z, ref node, ref heading, 1, 3f, 0)) continue; + if (Vector3.Distance(node, target) < MinSpawnDistance) continue; + + return new SpawnPoint(node, heading); + } + + return null; + } + } +} diff --git a/FivePRS.Client/Gear/DepartmentGear.cs b/FivePRS.Client/Gear/DepartmentGear.cs index d79bc9e..589b34b 100644 --- a/FivePRS.Client/Gear/DepartmentGear.cs +++ b/FivePRS.Client/Gear/DepartmentGear.cs @@ -132,18 +132,24 @@ private void AddVehicles(StationCatalog catalog, string? agencyId) 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(), + Components = ToComponents(uniform.Components), + Props = ToProps(uniform.Props), + FemaleComponents = uniform.Female is null ? null : ToComponents(uniform.Female.Components), + FemaleProps = uniform.Female is null ? null : ToProps(uniform.Female.Props), MalePedModel = NullIfEmpty(uniform.PedModels?.Male), FemalePedModel = NullIfEmpty(uniform.PedModels?.Female), FallbackMalePedModel = NullIfEmpty(Loadouts.FallbackPedModels.Male), FallbackFemalePedModel = NullIfEmpty(Loadouts.FallbackPedModels.Female), }; + private static ComponentEntry[] ToComponents(ClothingDef[] components) => components + .Select(c => new ComponentEntry { ComponentId = c.Slot, DrawableId = c.Drawable, TextureId = c.Texture }) + .ToArray(); + + private static PropEntry[] ToProps(ClothingDef[] props) => props + .Select(p => new PropEntry { PropId = p.Slot, DrawableId = p.Drawable, TextureId = p.Texture }) + .ToArray(); + private static WeaponEntry ToEntry(WeaponDef weapon) => new() { Hash = (uint)API.GetHashKey(weapon.Name), diff --git a/FivePRS.Client/Loadout/LoadoutDefinition.cs b/FivePRS.Client/Loadout/LoadoutDefinition.cs index 32b7e8a..6f27770 100644 --- a/FivePRS.Client/Loadout/LoadoutDefinition.cs +++ b/FivePRS.Client/Loadout/LoadoutDefinition.cs @@ -12,6 +12,10 @@ public sealed class LoadoutDefinition public IReadOnlyList Props { get; set; } = new List(); + public IReadOnlyList? FemaleComponents { get; set; } + + public IReadOnlyList? FemaleProps { get; set; } + public string? MalePedModel { get; set; } public string? FemalePedModel { get; set; } diff --git a/FivePRS.Client/Loadout/LoadoutManager.cs b/FivePRS.Client/Loadout/LoadoutManager.cs index e79d586..aa5abbb 100644 --- a/FivePRS.Client/Loadout/LoadoutManager.cs +++ b/FivePRS.Client/Loadout/LoadoutManager.cs @@ -23,6 +23,8 @@ public static class LoadoutManager public static bool IsInUniform => _savedComponents is not null; + public static LoadoutDefinition? Outfit { get; private set; } + public static async Task ApplyAsync(LoadoutDefinition loadout) { if (!Game.PlayerPed.Exists()) return; @@ -41,6 +43,7 @@ public static async Task WearAsync(LoadoutDefinition outfit) if (_savedComponents is null) SaveAppearance(ped.Handle); + Outfit = outfit; var male = API.IsPedMale(ped.Handle); var own = male ? outfit.MalePedModel : outfit.FemalePedModel; @@ -58,8 +61,9 @@ public static async Task WearAsync(LoadoutDefinition outfit) if (fallback) return; - ApplyComponents(ped.Handle, outfit.Components); - ApplyProps(ped.Handle, outfit.Props); + var female = !API.IsPedMale(ped.Handle); + ApplyComponents(ped.Handle, female && outfit.FemaleComponents is not null ? outfit.FemaleComponents : outfit.Components); + ApplyProps(ped.Handle, female && outfit.FemaleProps is not null ? outfit.FemaleProps : outfit.Props); } public static async Task RestoreClothingAsync() @@ -179,6 +183,7 @@ private static void ClearSavedAppearance() _savedComponents = null; _savedProps = null; _savedModel = 0; + Outfit = null; } private static async Task ChangeModelAsync(Model model) diff --git a/FivePRS.Client/Stations/StationMenu.cs b/FivePRS.Client/Stations/StationMenu.cs index fcea489..b76d94f 100644 --- a/FivePRS.Client/Stations/StationMenu.cs +++ b/FivePRS.Client/Stations/StationMenu.cs @@ -3,6 +3,8 @@ using System.Threading.Tasks; using CitizenFX.Core; using FivePRS.Client.Agency; +using FivePRS.Client.App; +using FivePRS.Client.Appearance; using FivePRS.Client.Loadout; using FivePRS.Client.Menu; using FivePRS.Core.Config; @@ -198,6 +200,22 @@ private static MenuPage Locker(StationDef station) }, }); + var character = CivilianPanel.ActiveCharacter; + page.Items.Add(new MenuItem + { + Label = "Edit appearance", + Detail = character is null ? "Choose a character first" : character.FullName, + Disabled = character is null || CharacterCreator.IsOpen, + Select = async () => + { + CompactMenu.Close(); + var outfit = LoadoutManager.Outfit; + await LoadoutManager.RestoreClothingAsync(); + CharacterCreator.Open(character!.Id, character.FullName, true, + outfit is null ? null : () => LoadoutManager.WearAsync(outfit)); + }, + }); + return page; } diff --git a/FivePRS.Core/Config/BackupSettings.cs b/FivePRS.Core/Config/BackupSettings.cs new file mode 100644 index 0000000..4df906e --- /dev/null +++ b/FivePRS.Core/Config/BackupSettings.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class BackupSettings + { + [JsonProperty("enabled")] + public bool Enabled { get; set; } = true; + + [JsonProperty("cooldownSeconds")] + public int CooldownSeconds { get; set; } = 30; + + [JsonProperty("npcEnabled")] + public bool NpcEnabled { get; set; } = true; + + [JsonProperty("npcWaitSeconds")] + public int NpcWaitSeconds { get; set; } = 45; + + [JsonProperty("npcVehicle")] + public string NpcVehicle { get; set; } = "police"; + + [JsonProperty("npcPedModel")] + public string NpcPedModel { get; set; } = "s_m_y_cop_01"; + + [JsonProperty("npcCrew")] + public int NpcCrew { get; set; } = 2; + } +} diff --git a/FivePRS.Core/Config/LoadoutConfig.cs b/FivePRS.Core/Config/LoadoutConfig.cs index f9af56f..5abc090 100644 --- a/FivePRS.Core/Config/LoadoutConfig.cs +++ b/FivePRS.Core/Config/LoadoutConfig.cs @@ -47,6 +47,15 @@ public sealed class PedModelsDef public string Female { get; set; } = string.Empty; } + public sealed class OutfitDef + { + [JsonProperty("components")] + public ClothingDef[] Components { get; set; } = new ClothingDef[0]; + + [JsonProperty("props")] + public ClothingDef[] Props { get; set; } = new ClothingDef[0]; + } + public sealed class UniformDef { [JsonProperty("components")] @@ -55,6 +64,9 @@ public sealed class UniformDef [JsonProperty("props")] public ClothingDef[] Props { get; set; } = new ClothingDef[0]; + [JsonProperty("female", NullValueHandling = NullValueHandling.Ignore)] + public OutfitDef? Female { get; set; } + [JsonProperty("pedModels", NullValueHandling = NullValueHandling.Ignore)] public PedModelsDef? PedModels { get; set; } } @@ -128,10 +140,19 @@ public sealed class DepartmentLoadoutsConfig { Components = new[] { - Clothing(3, 4), Clothing(4, 24), Clothing(6, 24), + Clothing(3, 0), Clothing(4, 24), Clothing(6, 24), Clothing(8, 58), Clothing(9, 0), Clothing(11, 55), }, Props = new[] { Clothing(0, 46), Clothing(1, -1, -1) }, + Female = new OutfitDef + { + Components = new[] + { + Clothing(3, 14), Clothing(4, 34), Clothing(6, 25), + Clothing(8, 35), Clothing(9, 0), Clothing(11, 48), + }, + Props = new[] { Clothing(0, 45), Clothing(1, -1, -1) }, + }, }; [JsonProperty("commandUniform")] @@ -143,6 +164,15 @@ public sealed class DepartmentLoadoutsConfig Clothing(8, 58), Clothing(9, 0), Clothing(11, 48), }, Props = new[] { Clothing(0, -1, -1), Clothing(1, -1, -1) }, + Female = new OutfitDef + { + Components = new[] + { + Clothing(3, 14), Clothing(4, 34), Clothing(6, 25), + Clothing(8, 35), Clothing(9, 0), Clothing(11, 48), + }, + Props = new[] { Clothing(0, -1, -1), Clothing(1, -1, -1) }, + }, }; [JsonProperty("fallbackPedModels")] diff --git a/FivePRS.Core/Config/ResourceSettings.cs b/FivePRS.Core/Config/ResourceSettings.cs index 106dfbd..ad28bdf 100644 --- a/FivePRS.Core/Config/ResourceSettings.cs +++ b/FivePRS.Core/Config/ResourceSettings.cs @@ -67,6 +67,9 @@ public sealed class ResourceSettings [JsonProperty("death")] public DeathSettings Death { get; set; } = new(); + [JsonProperty("backup")] + public BackupSettings Backup { get; set; } = new(); + [JsonProperty("terminal")] public TerminalSettings Terminal { get; set; } = new(); diff --git a/FivePRS.Core/Events/EventNames.cs b/FivePRS.Core/Events/EventNames.cs index a3e97be..bd0aadd 100644 --- a/FivePRS.Core/Events/EventNames.cs +++ b/FivePRS.Core/Events/EventNames.cs @@ -20,6 +20,7 @@ public static class EventNames public const string ClientDownedPlayers = "FivePRS:Client:DownedPlayers"; public const string ClientRevived = "FivePRS:Client:Revived"; public const string ClientEmergencyStatus = "FivePRS:Client:EmergencyStatus"; + public const string ClientBackupRequested = "FivePRS:Client:BackupRequested"; public const string ClientEmergencyError = "FivePRS:Client:EmergencyError"; public const string ClientAdminState = "FivePRS:Client:AdminState"; public const string ClientAppearance = "FivePRS:Client:Appearance"; @@ -34,6 +35,8 @@ public static class EventNames public const string ServerCalloutEnded = "FivePRS:Server:CalloutEnded"; public const string ServerSetUnitStatus = "FivePRS:Server:SetUnitStatus"; public const string ServerAttachToCall = "FivePRS:Server:AttachToCall"; + public const string ServerRequestBackup = "FivePRS:Server:RequestBackup"; + public const string ServerCancelBackup = "FivePRS:Server:CancelBackup"; public const string ServerSetAiCallouts = "FivePRS:Server:SetAiCallouts"; public const string ServerCivilianRequest = "FivePRS:Server:CivilianRequest"; public const string ServerCharacterCreate = "FivePRS:Server:CharacterCreate"; @@ -70,6 +73,8 @@ public static class EventNames public const string LocalOpenMenu = "FivePRS:Local:OpenMenu"; public const string LocalHttpRequest = "FivePRS:Local:HttpRequest"; public const string LocalHttpResponse = "FivePRS:Local:HttpResponse"; + public const string LocalDbRequest = "FivePRS:Local:DbRequest"; + public const string LocalDbResponse = "FivePRS:Local:DbResponse"; public const string PublicAppAction = "fiveprs:appAction"; public const string LocalCalloutReceived = "FivePRS:Local:CalloutReceived"; diff --git a/FivePRS.Core/Models/Call.cs b/FivePRS.Core/Models/Call.cs index 5b77c0d..c90b0d5 100644 --- a/FivePRS.Core/Models/Call.cs +++ b/FivePRS.Core/Models/Call.cs @@ -31,6 +31,7 @@ public class CallInfo public bool IsEmergency { get; set; } public string? Description { get; set; } public string? Caller { get; set; } + public int BackupCode { get; set; } } public sealed class EmergencyCallStatus diff --git a/FivePRS.Core/Text/KeyCommands.cs b/FivePRS.Core/Text/KeyCommands.cs index d2e3b81..20642f5 100644 --- a/FivePRS.Core/Text/KeyCommands.cs +++ b/FivePRS.Core/Text/KeyCommands.cs @@ -12,5 +12,6 @@ public static class KeyCommands public const string Escort = "er_escort"; public const string AiCallouts = "er_aicallouts"; public const string TrafficStop = "fiveprs_trafficstop"; + public const string Backup = "fiveprs_request_backup"; } } diff --git a/FivePRS.Police/TrafficStops/TrafficStopManager.cs b/FivePRS.Police/TrafficStops/TrafficStopManager.cs index 7bc9ca7..1016687 100644 --- a/FivePRS.Police/TrafficStops/TrafficStopManager.cs +++ b/FivePRS.Police/TrafficStops/TrafficStopManager.cs @@ -7,6 +7,7 @@ using FivePRS.Client; using FivePRS.Client.Agency; using FivePRS.Client.Arrest; +using FivePRS.Client.Dispatch; using FivePRS.Client.Menu; using FivePRS.Core.Civilian; using FivePRS.Core.Config; @@ -335,6 +336,8 @@ private static MenuPage StopPage(TrafficStop stop) } : new MenuItem { Label = "Arrest", Detail = $"Press {Binds.Cuff}", Disabled = true }); + page.Items.Add(BackupClient.MenuEntry()); + page.Items.Add(new MenuItem { Label = stop.InCustody ? "End the stop" : stop.IsVehicleStop ? "Release the driver" : "Let them go", diff --git a/FivePRS.Server/Database/AppearanceStore.cs b/FivePRS.Server/Database/AppearanceStore.cs index b12546d..5bbab1e 100644 --- a/FivePRS.Server/Database/AppearanceStore.cs +++ b/FivePRS.Server/Database/AppearanceStore.cs @@ -12,7 +12,7 @@ public AppearanceStore(IDatabaseProvider db) : base(db) public async Task GetAsync(int characterId) { var rows = await QueryAsync("SELECT data FROM fiveprs_appearances WHERE character_id = @id", - reader => reader.GetString(0), ("@id", characterId)); + row => row.GetString("data"), ("@id", characterId)); return rows.Count > 0 ? rows[0] : null; } diff --git a/FivePRS.Server/Database/AuditEntry.cs b/FivePRS.Server/Database/AuditEntry.cs index 72e7bda..833cd06 100644 --- a/FivePRS.Server/Database/AuditEntry.cs +++ b/FivePRS.Server/Database/AuditEntry.cs @@ -28,6 +28,7 @@ public static class AuditActions public const string RosterGranted = "roster.grant"; public const string RosterRevoked = "roster.revoke"; public const string PlayerRevived = "player.revive"; + public const string BackupRequested = "backup.request"; } public sealed class AuditEntry diff --git a/FivePRS.Server/Database/CivilianStore.cs b/FivePRS.Server/Database/CivilianStore.cs index 5b0f250..3d6d85e 100644 --- a/FivePRS.Server/Database/CivilianStore.cs +++ b/FivePRS.Server/Database/CivilianStore.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Data.Common; using System.Threading.Tasks; using FivePRS.Core.Civilian; @@ -17,7 +16,7 @@ public sealed class CivilianStore : SqlStore "id, character_id, type, description, fine, officer_name, officer_callsign, created_at, active, resolution"; private const string ActiveWarrantExists = - "EXISTS (SELECT 1 FROM fiveprs_records r WHERE r.character_id = c.id AND r.type = 3 AND r.active = 1)"; + "EXISTS (SELECT 1 FROM fiveprs_records r WHERE r.character_id = c.id AND r.type = 3 AND r.active = 1) AS has_warrant"; public CivilianStore(IDatabaseProvider db) : base(db) { @@ -46,9 +45,9 @@ public Task> GetCharactersAsync(string ownerLicense) => public async Task CreateCharacterAsync(CharacterInfo character) { var now = DateTime.UtcNow; - var id = await ScalarAsync( + var id = await InsertAsync( "INSERT INTO fiveprs_characters (owner_license, first_name, last_name, date_of_birth, gender, created_at, last_used_at) " + - $"VALUES (@owner, @first, @last, @dob, @gender, @now, @now); {LastInsertId};", + "VALUES (@owner, @first, @last, @dob, @gender, @now, @now)", ("@owner", character.OwnerLicense), ("@first", character.FirstName), ("@last", character.LastName), ("@dob", character.DateOfBirth), ("@gender", character.Gender), ("@now", now)); return Convert.ToInt32(id); @@ -59,21 +58,20 @@ public Task TouchCharacterAsync(int id) => ("@now", DateTime.UtcNow), ("@id", id)); public Task DeleteCharacterAsync(int id) => - ExecuteAsync( - "DELETE FROM fiveprs_records WHERE character_id = @id; " + - "DELETE FROM fiveprs_licenses WHERE character_id = @id; " + - "DELETE FROM fiveprs_vehicles WHERE character_id = @id; " + - "DELETE FROM fiveprs_characters WHERE id = @id;", - ("@id", id)); + BatchAsync( + new SqlStatement("DELETE FROM fiveprs_records WHERE character_id = @id", ("@id", id)), + new SqlStatement("DELETE FROM fiveprs_licenses WHERE character_id = @id", ("@id", id)), + new SqlStatement("DELETE FROM fiveprs_vehicles WHERE character_id = @id", ("@id", id)), + new SqlStatement("DELETE FROM fiveprs_characters WHERE id = @id", ("@id", id))); public Task> GetLicensesAsync(int characterId) => QueryAsync("SELECT character_id, type, status, issued_at FROM fiveprs_licenses WHERE character_id = @id ORDER BY type", - reader => new LicenseInfo + row => new LicenseInfo { - CharacterId = Convert.ToInt32(reader.GetValue(0)), - Type = reader.GetString(1), - Status = (LicenseStatus)Convert.ToInt32(reader.GetValue(2)), - IssuedAt = reader.GetDateTime(3), + CharacterId = row.GetInt32("character_id"), + Type = row.GetString("type"), + Status = (LicenseStatus)row.GetInt32("status"), + IssuedAt = row.GetDateTime("issued_at"), }, ("@id", characterId)); @@ -106,9 +104,9 @@ public Task> GetVehiclesAsync(int characterId) => public async Task AddVehicleAsync(VehicleInfo vehicle) { - var id = await ScalarAsync( + var id = await InsertAsync( "INSERT INTO fiveprs_vehicles (character_id, plate, model, status, registered_at) " + - $"VALUES (@character, @plate, @model, @status, @now); {LastInsertId};", + "VALUES (@character, @plate, @model, @status, @now)", ("@character", vehicle.CharacterId), ("@plate", vehicle.Plate), ("@model", vehicle.Model), ("@status", (int)vehicle.Status), ("@now", DateTime.UtcNow)); return Convert.ToInt32(id); @@ -150,9 +148,9 @@ public Task> GetRecordsAsync(int characterId) => public async Task AddRecordAsync(RecordInfo record, string officerLicense) { - var id = await ScalarAsync( + var id = await InsertAsync( "INSERT INTO fiveprs_records (character_id, type, description, fine, officer_license, officer_name, officer_callsign, created_at, active) " + - $"VALUES (@character, @type, @description, @fine, @officerLicense, @officerName, @callsign, @now, @active); {LastInsertId};", + "VALUES (@character, @type, @description, @fine, @officerLicense, @officerName, @callsign, @now, @active)", ("@character", record.CharacterId), ("@type", (int)record.Type), ("@description", record.Description), ("@fine", record.Fine), ("@officerLicense", officerLicense), ("@officerName", record.OfficerName), ("@callsign", record.OfficerCallsign), ("@now", DateTime.UtcNow), ("@active", record.Active ? 1 : 0)); @@ -163,50 +161,50 @@ public Task ResolveRecordAsync(int id, string resolution) => ExecuteAsync("UPDATE fiveprs_records SET active = 0, resolution = @resolution WHERE id = @id", ("@resolution", resolution), ("@id", id)); - private static CharacterSummary ReadSummary(DbDataReader reader) => new() + private static CharacterSummary ReadSummary(DbRow row) => new() { - Id = Convert.ToInt32(reader.GetValue(0)), - FirstName = reader.GetString(1), - LastName = reader.GetString(2), - DateOfBirth = reader.GetString(3), - Gender = reader.GetString(4), - HasActiveWarrant = Convert.ToInt32(reader.GetValue(5)) != 0, + Id = row.GetInt32("id"), + FirstName = row.GetString("first_name"), + LastName = row.GetString("last_name"), + DateOfBirth = row.GetString("date_of_birth"), + Gender = row.GetString("gender"), + HasActiveWarrant = row.GetBoolean("has_warrant"), }; - private static RecordInfo ReadRecord(DbDataReader reader) => new() - { - Id = Convert.ToInt32(reader.GetValue(0)), - CharacterId = Convert.ToInt32(reader.GetValue(1)), - Type = (RecordType)Convert.ToInt32(reader.GetValue(2)), - Description = reader.GetString(3), - Fine = Convert.ToInt32(reader.GetValue(4)), - OfficerName = reader.GetString(5), - OfficerCallsign = reader.GetString(6), - CreatedAt = reader.GetDateTime(7), - Active = Convert.ToInt32(reader.GetValue(8)) != 0, - Resolution = reader.IsDBNull(9) ? null : reader.GetString(9), + private static RecordInfo ReadRecord(DbRow row) => new() + { + Id = row.GetInt32("id"), + CharacterId = row.GetInt32("character_id"), + Type = (RecordType)row.GetInt32("type"), + Description = row.GetString("description"), + Fine = row.GetInt32("fine"), + OfficerName = row.GetString("officer_name"), + OfficerCallsign = row.GetString("officer_callsign"), + CreatedAt = row.GetDateTime("created_at"), + Active = row.GetBoolean("active"), + Resolution = row.IsNull("resolution") ? null : row.GetString("resolution"), }; - private static CharacterInfo ReadCharacter(DbDataReader reader) => new() + private static CharacterInfo ReadCharacter(DbRow row) => new() { - Id = Convert.ToInt32(reader.GetValue(0)), - OwnerLicense = reader.GetString(1), - FirstName = reader.GetString(2), - LastName = reader.GetString(3), - DateOfBirth = reader.GetString(4), - Gender = reader.GetString(5), - CreatedAt = reader.GetDateTime(6), - LastUsedAt = reader.GetDateTime(7), + Id = row.GetInt32("id"), + OwnerLicense = row.GetString("owner_license"), + FirstName = row.GetString("first_name"), + LastName = row.GetString("last_name"), + DateOfBirth = row.GetString("date_of_birth"), + Gender = row.GetString("gender"), + CreatedAt = row.GetDateTime("created_at"), + LastUsedAt = row.GetDateTime("last_used_at"), }; - private static VehicleInfo ReadVehicle(DbDataReader reader) => new() + private static VehicleInfo ReadVehicle(DbRow row) => new() { - Id = Convert.ToInt32(reader.GetValue(0)), - CharacterId = Convert.ToInt32(reader.GetValue(1)), - Plate = reader.GetString(2), - Model = reader.GetString(3), - Status = (VehicleStatus)Convert.ToInt32(reader.GetValue(4)), - RegisteredAt = reader.GetDateTime(5), + Id = row.GetInt32("id"), + CharacterId = row.GetInt32("character_id"), + Plate = row.GetString("plate"), + Model = row.GetString("model"), + Status = (VehicleStatus)row.GetInt32("status"), + RegisteredAt = row.GetDateTime("registered_at"), }; } } diff --git a/FivePRS.Server/Database/DatabaseErrors.cs b/FivePRS.Server/Database/DatabaseErrors.cs deleted file mode 100644 index f9706cf..0000000 --- a/FivePRS.Server/Database/DatabaseErrors.cs +++ /dev/null @@ -1,51 +0,0 @@ -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 107d814..941051a 100644 --- a/FivePRS.Server/Database/DatabaseManager.cs +++ b/FivePRS.Server/Database/DatabaseManager.cs @@ -19,6 +19,8 @@ public sealed class DatabaseManager public bool IsReady { get; private set; } + public PlayerStore Players { get; private set; } = null!; + public CivilianStore Civilians { get; private set; } = null!; public RosterStore Roster { get; private set; } = null!; @@ -40,9 +42,9 @@ private async Task InitializeCoreAsync(DatabaseType dbType, string? connectionSt if (dbType == DatabaseType.MySQL) { - if (connectionString is null || string.IsNullOrWhiteSpace(connectionString)) - 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); + if (connectionString is not null && !string.IsNullOrWhiteSpace(connectionString)) + Debug.WriteLine("[FivePRS] WARNING: fiveprs_db_connection is ignored with MySQL. FivePRS now connects through oxmysql, which reads mysql_connection_string. Set it as mysql://user:password@host:port/database."); + _provider = new OxMySqlProvider(new OxMySqlBridge()); } else { @@ -72,6 +74,7 @@ private async Task InitializeCoreAsync(DatabaseType dbType, string? connectionSt Debug.WriteLine($"[FivePRS] WARNING: The database is at schema version {result.FromVersion}, but this FivePRS build only knows up to {result.LatestVersion}. Update FivePRS, or restore the database backup taken before you downgraded."); SchemaVersion = result.ToVersion; + Players = new PlayerStore(_provider); Civilians = new CivilianStore(_provider); Roster = new RosterStore(_provider); Appearances = new AppearanceStore(_provider); @@ -91,16 +94,12 @@ private static async Task BackupAsync(SQLiteProvider sqlite, string dbPath, int Debug.WriteLine($"[FivePRS] Backed up the database to {path} before updating it."); } - public Task GetPlayerAsync(string license) - => MainThread.Run(() => _provider.GetPlayerAsync(license)); + public Task GetPlayerAsync(string license) => Players.GetAsync(license); - public Task SavePlayerAsync(PlayerData player) - => MainThread.Run(() => _provider.SavePlayerAsync(player)); + public Task SavePlayerAsync(PlayerData player) => Players.SaveAsync(player); - public Task UpdateDutyStatusAsync(string license, bool isOnDuty) - => MainThread.Run(() => _provider.UpdateDutyStatusAsync(license, isOnDuty)); + public Task UpdateDutyStatusAsync(string license, bool isOnDuty) => Players.UpdateDutyStatusAsync(license, isOnDuty); - public Task AddAuditAsync(AuditEntry entry) - => MainThread.Run(() => _provider.AddAuditAsync(entry)); + public Task AddAuditAsync(AuditEntry entry) => Players.AddAuditAsync(entry); } } diff --git a/FivePRS.Server/Database/DbRow.cs b/FivePRS.Server/Database/DbRow.cs new file mode 100644 index 0000000..74cf30a --- /dev/null +++ b/FivePRS.Server/Database/DbRow.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace FivePRS.Server.Database +{ + public sealed class DbRow + { + private readonly Dictionary _values; + + public DbRow(IDictionary values) + { + _values = new Dictionary(values, StringComparer.OrdinalIgnoreCase); + } + + public bool IsNull(string column) + { + var value = Raw(column); + return value is null || value is DBNull; + } + + public object Value(string column) + { + var value = Raw(column); + if (value is null || value is DBNull) + throw new InvalidCastException($"Column '{column}' is null."); + return value; + } + + public string GetString(string column) => Convert.ToString(Value(column), CultureInfo.InvariantCulture)!; + + public int GetInt32(string column) => Value(column) is bool flag ? (flag ? 1 : 0) : Convert.ToInt32(Value(column), CultureInfo.InvariantCulture); + + public long GetInt64(string column) => Value(column) is bool flag ? (flag ? 1 : 0) : Convert.ToInt64(Value(column), CultureInfo.InvariantCulture); + + public double GetDouble(string column) => Convert.ToDouble(Value(column), CultureInfo.InvariantCulture); + + public bool GetBoolean(string column) => Value(column) switch + { + bool flag => flag, + string text => text == "1" || text.Equals("true", StringComparison.OrdinalIgnoreCase), + var number => Convert.ToInt64(number, CultureInfo.InvariantCulture) != 0, + }; + + public DateTime GetDateTime(string column) => Value(column) switch + { + DateTime date => date.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(date, DateTimeKind.Utc) : date.ToUniversalTime(), + DateTimeOffset offset => offset.UtcDateTime, + string text => DateTime.Parse(text, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal), + var milliseconds => FromLocalTimestamp(Convert.ToInt64(milliseconds, CultureInfo.InvariantCulture)), + }; + + public static DateTime FromLocalTimestamp(long milliseconds) => + DateTime.SpecifyKind(DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).LocalDateTime, DateTimeKind.Utc); + + private object? Raw(string column) => _values.TryGetValue(column, out var value) ? value : null; + } +} diff --git a/FivePRS.Server/Database/IDatabaseProvider.cs b/FivePRS.Server/Database/IDatabaseProvider.cs index 5a83327..8aabc4d 100644 --- a/FivePRS.Server/Database/IDatabaseProvider.cs +++ b/FivePRS.Server/Database/IDatabaseProvider.cs @@ -1,23 +1,26 @@ -using System.Data.Common; +using System; +using System.Collections.Generic; using System.Threading.Tasks; -using FivePRS.Core.Models; namespace FivePRS.Server.Database { - public interface IDatabaseProvider + public interface ISqlExecutor { SqlDialect Dialect { get; } - DbConnection CreateConnection(); + Task ExecuteAsync(string sql, params (string Name, object? Value)[] parameters); - Task InitializeAsync(); + Task InsertAsync(string sql, params (string Name, object? Value)[] parameters); - Task GetPlayerAsync(string license); + Task> QueryAsync(string sql, params (string Name, object? Value)[] parameters); + } - Task SavePlayerAsync(PlayerData player); + public interface IDatabaseProvider : ISqlExecutor + { + Task InitializeAsync(); - Task UpdateDutyStatusAsync(string license, bool isOnDuty); + Task BatchAsync(IReadOnlyList statements); - Task AddAuditAsync(AuditEntry entry); + Task ApplySchemaAsync(Func work); } } diff --git a/FivePRS.Server/Database/Migration.cs b/FivePRS.Server/Database/Migration.cs index 525ce83..b7d5919 100644 --- a/FivePRS.Server/Database/Migration.cs +++ b/FivePRS.Server/Database/Migration.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Data.Common; using System.Threading.Tasks; namespace FivePRS.Server.Database @@ -23,27 +22,7 @@ public Migration(int version, string name, params MigrationStep[] steps) public abstract class MigrationStep { - internal abstract Task ApplyAsync(DbConnection conn, DbTransaction? transaction, SqlDialect dialect); - - internal static async Task ExecuteAsync(DbConnection conn, DbTransaction? transaction, string sql) - { - using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = sql; - await cmd.ExecuteNonQueryAsync(); - } - - internal static async Task ScalarAsync(DbConnection conn, DbTransaction? transaction, string sql, string name, object value) - { - using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = sql; - var parameter = cmd.CreateParameter(); - parameter.ParameterName = name; - parameter.Value = value; - cmd.Parameters.Add(parameter); - return await cmd.ExecuteScalarAsync(); - } + internal abstract Task ApplyAsync(ISqlExecutor db); } public sealed class SqlStep : MigrationStep @@ -58,8 +37,11 @@ public SqlStep(string sqlite, string mySql) public string MySql { get; } - internal override Task ApplyAsync(DbConnection conn, DbTransaction? transaction, SqlDialect dialect) => - ExecuteAsync(conn, transaction, dialect == SqlDialect.MySql ? MySql : Sqlite); + internal override async Task ApplyAsync(ISqlExecutor db) + { + foreach (var statement in SqlStatement.Split(db.Dialect == SqlDialect.MySql ? MySql : Sqlite)) + await db.ExecuteAsync(statement); + } } public sealed class AddColumnStep : MigrationStep @@ -80,17 +62,17 @@ public AddColumnStep(string table, string column, string sqliteDefinition, strin public string MySqlDefinition { get; } - internal override async Task ApplyAsync(DbConnection conn, DbTransaction? transaction, SqlDialect dialect) + internal override async Task ApplyAsync(ISqlExecutor db) { - var mySql = dialect == SqlDialect.MySql; - var exists = await ScalarAsync(conn, transaction, + var mySql = db.Dialect == SqlDialect.MySql; + var rows = await db.QueryAsync( mySql - ? $"SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{Table}' AND COLUMN_NAME = @column" - : $"SELECT COUNT(*) FROM pragma_table_info('{Table}') WHERE name = @column", - "@column", Column); - if (Convert.ToInt64(exists) > 0) return; + ? $"SELECT COUNT(*) AS found FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{Table}' AND COLUMN_NAME = @column" + : $"SELECT COUNT(*) AS found FROM pragma_table_info('{Table}') WHERE name = @column", + ("@column", Column)); + if (rows.Count > 0 && rows[0].GetInt64("found") > 0) return; - await ExecuteAsync(conn, transaction, + await db.ExecuteAsync( mySql ? $"ALTER TABLE `{Table}` ADD COLUMN `{Column}` {MySqlDefinition}" : $"ALTER TABLE {Table} ADD COLUMN {Column} {SqliteDefinition}"); diff --git a/FivePRS.Server/Database/Migrator.cs b/FivePRS.Server/Database/Migrator.cs index 2e60bc9..7249c65 100644 --- a/FivePRS.Server/Database/Migrator.cs +++ b/FivePRS.Server/Database/Migrator.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Data.Common; using System.Threading.Tasks; namespace FivePRS.Server.Database @@ -65,19 +64,15 @@ public Migrator(IDatabaseProvider db, IReadOnlyList migrations) public async Task CurrentVersionAsync() { - using var conn = _db.CreateConnection(); - await conn.OpenAsync(); - await EnsureSchemaTableAsync(conn); - return await ReadVersionAsync(conn); + await EnsureSchemaTableAsync(); + return await ReadVersionAsync(); } public async Task MigrateAsync(Func? beforeMigrating = null) { - using var conn = _db.CreateConnection(); - await conn.OpenAsync(); - await EnsureSchemaTableAsync(conn); + await EnsureSchemaTableAsync(); - var from = await ReadVersionAsync(conn); + var from = await ReadVersionAsync(); var current = from; var applied = new List(); @@ -88,7 +83,15 @@ public async Task MigrateAsync(Func? beforeMigrating if (applied.Count == 0 && beforeMigrating is not null) await beforeMigrating(from); - await ApplyAsync(conn, migration); + await _db.ApplySchemaAsync(async db => + { + foreach (var step in migration.Steps) + await step.ApplyAsync(db); + + await db.ExecuteAsync("INSERT INTO fiveprs_schema (version, name, applied_at) VALUES (@version, @name, @now)", + ("@version", migration.Version), ("@name", migration.Name), ("@now", DateTime.UtcNow)); + }); + applied.Add(migration); current = migration.Version; } @@ -96,42 +99,13 @@ public async Task MigrateAsync(Func? beforeMigrating return new MigrationResult(from, current, LatestVersion, applied); } - private async Task ApplyAsync(DbConnection conn, Migration migration) - { - using var transaction = _db.Dialect == SqlDialect.Sqlite ? conn.BeginTransaction() : null; - - foreach (var step in migration.Steps) - await step.ApplyAsync(conn, transaction, _db.Dialect); - - using (var cmd = conn.CreateCommand()) - { - cmd.Transaction = transaction; - cmd.CommandText = "INSERT INTO fiveprs_schema (version, name, applied_at) VALUES (@version, @name, @now)"; - AddParameter(cmd, "@version", migration.Version); - AddParameter(cmd, "@name", migration.Name); - AddParameter(cmd, "@now", DateTime.UtcNow); - await cmd.ExecuteNonQueryAsync(); - } - - transaction?.Commit(); - } - - private Task EnsureSchemaTableAsync(DbConnection conn) => - MigrationStep.ExecuteAsync(conn, null, _db.Dialect == SqlDialect.MySql ? MySqlSchemaTable : SqliteSchemaTable); - - private static async Task ReadVersionAsync(DbConnection conn) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT COALESCE(MAX(version), 0) FROM fiveprs_schema"; - return Convert.ToInt32(await cmd.ExecuteScalarAsync()); - } + private Task EnsureSchemaTableAsync() => + _db.ExecuteAsync(_db.Dialect == SqlDialect.MySql ? MySqlSchemaTable : SqliteSchemaTable); - private static void AddParameter(DbCommand cmd, string name, object value) + private async Task ReadVersionAsync() { - var parameter = cmd.CreateParameter(); - parameter.ParameterName = name; - parameter.Value = value; - cmd.Parameters.Add(parameter); + var rows = await _db.QueryAsync("SELECT COALESCE(MAX(version), 0) AS version FROM fiveprs_schema"); + return rows.Count > 0 ? rows[0].GetInt32("version") : 0; } } } diff --git a/FivePRS.Server/Database/MySqlProvider.cs b/FivePRS.Server/Database/MySqlProvider.cs deleted file mode 100644 index 5c761e3..0000000 --- a/FivePRS.Server/Database/MySqlProvider.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using System.Data.Common; -using System.Threading.Tasks; -using MySqlConnector; -using FivePRS.Core.Models; - -namespace FivePRS.Server.Database -{ - public sealed class MySqlProvider : IDatabaseProvider - { - private readonly string _connectionString; - - public SqlDialect Dialect => SqlDialect.MySql; - - public DbConnection CreateConnection() => new MySqlConnection(_connectionString); - - public MySqlProvider(string 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() - { - using var conn = new MySqlConnection(_connectionString); - await conn.OpenAsync(); - } - - public async Task GetPlayerAsync(string license) - { - using var conn = new MySqlConnection(_connectionString); - await conn.OpenAsync(); - - using var cmd = conn.CreateCommand(); - cmd.CommandText = @" - SELECT `license`, `name`, `department`, `is_on_duty`, `xp`, `rank_level`, `last_seen`, `agency`, `callsign` - FROM `ers_players` WHERE `license` = @license LIMIT 1;"; - cmd.Parameters.AddWithValue("@license", license); - - using var reader = await cmd.ExecuteReaderAsync(); - if (!await reader.ReadAsync()) return null; - - return new PlayerData - { - License = reader.GetString("license"), - Name = reader.GetString("name"), - Department = (Department)reader.GetByte("department"), - IsOnDuty = reader.GetBoolean("is_on_duty"), - XP = reader.GetInt32("xp"), - Rank = reader.GetByte("rank_level"), - LastSeen = reader.GetDateTime("last_seen"), - Agency = reader.GetString("agency"), - Callsign = reader.GetString("callsign"), - }; - } - - public async Task SavePlayerAsync(PlayerData player) - { - using var conn = new MySqlConnection(_connectionString); - await conn.OpenAsync(); - - using var cmd = conn.CreateCommand(); - cmd.CommandText = @" - INSERT INTO `ers_players` - (`license`, `name`, `department`, `agency`, `callsign`, `is_on_duty`, `xp`, `rank_level`, `last_seen`) - VALUES - (@license, @name, @department, @agency, @callsign, @onDuty, @xp, @rank, UTC_TIMESTAMP()) - ON DUPLICATE KEY UPDATE - `name` = VALUES(`name`), - `department` = VALUES(`department`), - `agency` = VALUES(`agency`), - `callsign` = VALUES(`callsign`), - `is_on_duty` = VALUES(`is_on_duty`), - `xp` = VALUES(`xp`), - `rank_level` = VALUES(`rank_level`), - `last_seen` = VALUES(`last_seen`);"; - - cmd.Parameters.AddWithValue("@license", player.License); - cmd.Parameters.AddWithValue("@name", player.Name); - cmd.Parameters.AddWithValue("@department", (byte)player.Department); - cmd.Parameters.AddWithValue("@agency", player.Agency); - cmd.Parameters.AddWithValue("@callsign", player.Callsign); - cmd.Parameters.AddWithValue("@onDuty", player.IsOnDuty); - cmd.Parameters.AddWithValue("@xp", player.XP); - cmd.Parameters.AddWithValue("@rank", player.Rank); - - await cmd.ExecuteNonQueryAsync(); - } - - public async Task AddAuditAsync(AuditEntry entry) - { - using var conn = new MySqlConnection(_connectionString); - await conn.OpenAsync(); - - using var cmd = conn.CreateCommand(); - cmd.CommandText = @" - INSERT INTO `fiveprs_audit` - (`created_at`, `action`, `actor_license`, `actor_name`, `target_license`, `details`) - VALUES - (UTC_TIMESTAMP(), @action, @actorLicense, @actorName, @targetLicense, @details);"; - - cmd.Parameters.AddWithValue("@action", entry.Action); - cmd.Parameters.AddWithValue("@actorLicense", entry.ActorLicense); - cmd.Parameters.AddWithValue("@actorName", entry.ActorName); - cmd.Parameters.AddWithValue("@targetLicense", entry.TargetLicense); - cmd.Parameters.AddWithValue("@details", Truncate(entry.Details, 255)); - - await cmd.ExecuteNonQueryAsync(); - } - - public async Task UpdateDutyStatusAsync(string license, bool isOnDuty) - { - using var conn = new MySqlConnection(_connectionString); - await conn.OpenAsync(); - - using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE `ers_players` SET `is_on_duty` = @val WHERE `license` = @license;"; - cmd.Parameters.AddWithValue("@val", isOnDuty); - cmd.Parameters.AddWithValue("@license", license); - - await cmd.ExecuteNonQueryAsync(); - } - - private static string Truncate(string value, int maxLength) => - value.Length <= maxLength ? value : value.Substring(0, maxLength); - } -} diff --git a/FivePRS.Server/Database/OxMySqlBridge.cs b/FivePRS.Server/Database/OxMySqlBridge.cs new file mode 100644 index 0000000..54eb6df --- /dev/null +++ b/FivePRS.Server/Database/OxMySqlBridge.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Events; + +namespace FivePRS.Server.Database +{ + internal sealed class OxMySqlBridge : IOxMySql + { + private const string Resource = "oxmysql"; + private const int QueryTimeoutMs = 30_000; + private const int ConnectTimeoutMs = 75_000; + private const int StartWaitMs = 15_000; + + private static readonly ConcurrentDictionary> Pending = new(); + + public async Task ConnectAsync() + { + await MainThread.Resume(); + if (string.IsNullOrWhiteSpace(API.GetConvar("mysql_connection_string", ""))) + throw new DatabaseSetupException( + "fiveprs_db_type is mysql, but mysql_connection_string isn't set. FivePRS uses oxmysql for MySQL and MariaDB, and oxmysql reads its connection from that convar. " + + "Add set mysql_connection_string \"mysql://user:password@localhost/fiveprs\" to server.cfg, above ensure oxmysql."); + + var waited = 0; + while (API.GetResourceState(Resource) != "started") + { + var state = API.GetResourceState(Resource); + if (state == "missing") + throw new DatabaseSetupException("fiveprs_db_type is mysql, but oxmysql isn't installed. Download it from https://github.com/overextended/oxmysql/releases and add ensure oxmysql above ensure fiveprs in server.cfg."); + if (waited >= StartWaitMs) + throw new DatabaseSetupException($"fiveprs_db_type is mysql, but oxmysql is {state}, not started. Add ensure oxmysql above ensure fiveprs in server.cfg."); + + await BaseScript.Delay(500); + await MainThread.Resume(); + waited += 500; + } + + try + { + await RequestAsync("connect", string.Empty, "[]", ConnectTimeoutMs); + } + catch (TimeoutException) + { + throw new DatabaseSetupException("oxmysql couldn't connect to the database. Check mysql_connection_string in server.cfg; oxmysql's own messages above say what went wrong."); + } + } + + public Task SendAsync(string kind, string sql, string parameters) => + RequestAsync(kind, sql, parameters, QueryTimeoutMs); + + private static async Task RequestAsync(string kind, string sql, string parameters, int timeoutMs) + { + await MainThread.Resume(); + + var id = Guid.NewGuid().ToString("N"); + var completion = new TaskCompletionSource(); + Pending[id] = completion; + + try + { + BaseScript.TriggerEvent(EventNames.LocalDbRequest, id, kind, sql, parameters); + + var finished = await Task.WhenAny(completion.Task, BaseScript.Delay(timeoutMs)); + await MainThread.Resume(); + if (finished != completion.Task) + throw new TimeoutException($"oxmysql didn't answer within {timeoutMs / 1000} seconds."); + + return await completion.Task; + } + finally + { + Pending.TryRemove(id, out _); + } + } + + public static void OnResponse(string id, string result, string error) + { + if (id is null || !Pending.TryGetValue(id, out var completion)) return; + + if (string.IsNullOrEmpty(error)) + completion.TrySetResult(result ?? string.Empty); + else + completion.TrySetException(new DatabaseQueryException(error)); + } + } + + public sealed class DatabaseQueryException : Exception + { + public DatabaseQueryException(string message) : base(message) + { + } + } +} diff --git a/FivePRS.Server/Database/OxMySqlProvider.cs b/FivePRS.Server/Database/OxMySqlProvider.cs new file mode 100644 index 0000000..5c8ba48 --- /dev/null +++ b/FivePRS.Server/Database/OxMySqlProvider.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace FivePRS.Server.Database +{ + public interface IOxMySql + { + Task ConnectAsync(); + + Task SendAsync(string kind, string sql, string parameters); + } + + public sealed class OxMySqlProvider : IDatabaseProvider + { + private static readonly JsonSerializerSettings ReadSettings = new() { DateParseHandling = DateParseHandling.None }; + + private readonly IOxMySql _oxmysql; + + public OxMySqlProvider(IOxMySql oxmysql) => _oxmysql = oxmysql; + + public SqlDialect Dialect => SqlDialect.MySql; + + public Task InitializeAsync() => _oxmysql.ConnectAsync(); + + public async Task ExecuteAsync(string sql, params (string Name, object? Value)[] parameters) + { + var result = await SendAsync("update", new SqlStatement(sql, parameters)); + return result.Type == JTokenType.Integer ? result.Value() : 0; + } + + public async Task InsertAsync(string sql, params (string Name, object? Value)[] parameters) + { + var result = await SendAsync("insert", new SqlStatement(sql, parameters)); + return result.Type == JTokenType.Integer ? result.Value() : 0; + } + + public async Task> QueryAsync(string sql, params (string Name, object? Value)[] parameters) => + ReadRows(await SendAsync("query", new SqlStatement(sql, parameters))); + + public async Task BatchAsync(IReadOnlyList statements) + { + var queries = statements.Select(statement => + { + var (sql, values) = statement.ToPositional(); + return new { query = sql, values = ToJsonValues(values) }; + }); + + await _oxmysql.SendAsync("batch", JsonConvert.SerializeObject(queries), "[]"); + } + + public Task ApplySchemaAsync(Func work) => work(this); + + private async Task SendAsync(string kind, SqlStatement statement) + { + var (sql, values) = statement.ToPositional(); + var json = await _oxmysql.SendAsync(kind, sql, JsonConvert.SerializeObject(ToJsonValues(values))); + return string.IsNullOrEmpty(json) ? JValue.CreateNull() : JsonConvert.DeserializeObject(json, ReadSettings) ?? JValue.CreateNull(); + } + + public static List ReadRows(JToken result) + { + var rows = new List(); + if (result is not JArray array) return rows; + + foreach (var item in array.OfType()) + { + var values = new Dictionary(); + foreach (var property in item.Properties()) + values[property.Name] = property.Value is JValue value ? value.Value : property.Value.ToString(Formatting.None); + rows.Add(new DbRow(values)); + } + return rows; + } + + private static object?[] ToJsonValues(object?[] values) => + values.Select(value => value switch + { + DateTime date => (object)date.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture), + DateTimeOffset offset => offset.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture), + Enum e => Convert.ToInt64(e, CultureInfo.InvariantCulture), + _ => value, + }).ToArray(); + } +} diff --git a/FivePRS.Server/Database/PlayerStore.cs b/FivePRS.Server/Database/PlayerStore.cs new file mode 100644 index 0000000..d3f29ef --- /dev/null +++ b/FivePRS.Server/Database/PlayerStore.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading.Tasks; +using FivePRS.Core.Models; + +namespace FivePRS.Server.Database +{ + public sealed class PlayerStore : SqlStore + { + public PlayerStore(IDatabaseProvider db) : base(db) + { + } + + public async Task GetAsync(string license) + { + var rows = await QueryAsync( + "SELECT license, name, department, is_on_duty, xp, rank_level, last_seen, agency, callsign FROM ers_players WHERE license = @license LIMIT 1", + row => new PlayerData + { + License = row.GetString("license"), + Name = row.GetString("name"), + Department = (Department)row.GetInt32("department"), + IsOnDuty = row.GetBoolean("is_on_duty"), + XP = row.GetInt32("xp"), + Rank = row.GetInt32("rank_level"), + LastSeen = row.IsNull("last_seen") ? DateTime.UtcNow : row.GetDateTime("last_seen"), + Agency = row.GetString("agency"), + Callsign = row.GetString("callsign"), + }, + ("@license", license)); + return rows.Count > 0 ? rows[0] : null; + } + + public Task SaveAsync(PlayerData player) => + ExecuteAsync( + "INSERT INTO ers_players (license, name, department, agency, callsign, is_on_duty, xp, rank_level, last_seen) " + + "VALUES (@license, @name, @department, @agency, @callsign, @onDuty, @xp, @rank, @now) " + + (IsMySql + ? "ON DUPLICATE KEY UPDATE name = VALUES(name), department = VALUES(department), agency = VALUES(agency), callsign = VALUES(callsign), " + + "is_on_duty = VALUES(is_on_duty), xp = VALUES(xp), rank_level = VALUES(rank_level), last_seen = VALUES(last_seen)" + : "ON CONFLICT(license) DO UPDATE SET name = excluded.name, department = excluded.department, agency = excluded.agency, callsign = excluded.callsign, " + + "is_on_duty = excluded.is_on_duty, xp = excluded.xp, rank_level = excluded.rank_level, last_seen = excluded.last_seen"), + ("@license", player.License), ("@name", player.Name), ("@department", (int)player.Department), + ("@agency", player.Agency), ("@callsign", player.Callsign), ("@onDuty", player.IsOnDuty ? 1 : 0), + ("@xp", player.XP), ("@rank", player.Rank), ("@now", DateTime.UtcNow)); + + public Task UpdateDutyStatusAsync(string license, bool isOnDuty) => + ExecuteAsync("UPDATE ers_players SET is_on_duty = @onDuty WHERE license = @license", + ("@onDuty", isOnDuty ? 1 : 0), ("@license", license)); + + public Task AddAuditAsync(AuditEntry entry) => + ExecuteAsync( + "INSERT INTO fiveprs_audit (created_at, action, actor_license, actor_name, target_license, details) " + + "VALUES (@now, @action, @actorLicense, @actorName, @targetLicense, @details)", + ("@now", DateTime.UtcNow), ("@action", entry.Action), ("@actorLicense", entry.ActorLicense), + ("@actorName", entry.ActorName), ("@targetLicense", entry.TargetLicense), + ("@details", entry.Details.Length <= 255 ? entry.Details : entry.Details.Substring(0, 255))); + } +} diff --git a/FivePRS.Server/Database/PositionStore.cs b/FivePRS.Server/Database/PositionStore.cs index 5c3d0d0..1ce212b 100644 --- a/FivePRS.Server/Database/PositionStore.cs +++ b/FivePRS.Server/Database/PositionStore.cs @@ -13,12 +13,12 @@ public PositionStore(IDatabaseProvider db) : base(db) public async Task GetAsync(string license) { var rows = await QueryAsync("SELECT x, y, z, heading FROM fiveprs_positions WHERE license = @license", - reader => new SavedPosition + row => new SavedPosition { - X = Convert.ToSingle(reader.GetValue(0)), - Y = Convert.ToSingle(reader.GetValue(1)), - Z = Convert.ToSingle(reader.GetValue(2)), - Heading = Convert.ToSingle(reader.GetValue(3)), + X = (float)row.GetDouble("x"), + Y = (float)row.GetDouble("y"), + Z = (float)row.GetDouble("z"), + Heading = (float)row.GetDouble("heading"), }, ("@license", license)); return rows.Count > 0 ? rows[0] : null; diff --git a/FivePRS.Server/Database/PreferencesStore.cs b/FivePRS.Server/Database/PreferencesStore.cs index 39438a0..ad9becb 100644 --- a/FivePRS.Server/Database/PreferencesStore.cs +++ b/FivePRS.Server/Database/PreferencesStore.cs @@ -12,7 +12,7 @@ public PreferencesStore(IDatabaseProvider db) : base(db) public async Task GetWallpaperAsync(string license) { var rows = await QueryAsync("SELECT wallpaper FROM fiveprs_preferences WHERE license = @license", - reader => reader.GetString(0), ("@license", license)); + row => row.GetString("wallpaper"), ("@license", license)); return rows.Count > 0 ? rows[0] : string.Empty; } diff --git a/FivePRS.Server/Database/RosterStore.cs b/FivePRS.Server/Database/RosterStore.cs index 47ca9c4..92aa44e 100644 --- a/FivePRS.Server/Database/RosterStore.cs +++ b/FivePRS.Server/Database/RosterStore.cs @@ -13,7 +13,7 @@ public RosterStore(IDatabaseProvider db) : base(db) public Task> GetAllAsync() => QueryAsync("SELECT license, department FROM fiveprs_roster", - reader => (reader.GetString(0), (Department)Convert.ToInt32(reader.GetValue(1)))); + row => (row.GetString("license"), (Department)row.GetInt32("department"))); public Task GrantAsync(string license, Department department, string grantedBy) => ExecuteAsync( @@ -28,13 +28,13 @@ public Task RevokeAsync(string license, Department department) => public Task> SearchPlayersAsync(string term, int limit) => QueryAsync( $"SELECT license, name, department, rank_level, last_seen FROM ers_players WHERE name LIKE @term ORDER BY last_seen DESC LIMIT {limit}", - reader => new RosterPlayer + row => new RosterPlayer { - License = reader.GetString(0), - Name = reader.GetString(1), - Department = (Department)Convert.ToInt32(reader.GetValue(2)), - Rank = Convert.ToInt32(reader.GetValue(3)), - LastSeen = reader.IsDBNull(4) ? DateTime.MinValue : reader.GetDateTime(4), + License = row.GetString("license"), + Name = row.GetString("name"), + Department = (Department)row.GetInt32("department"), + Rank = row.GetInt32("rank_level"), + LastSeen = row.IsNull("last_seen") ? DateTime.MinValue : row.GetDateTime("last_seen"), }, ("@term", $"%{term}%")); } diff --git a/FivePRS.Server/Database/SQLiteProvider.cs b/FivePRS.Server/Database/SQLiteProvider.cs index 35fe8cf..57c976b 100644 --- a/FivePRS.Server/Database/SQLiteProvider.cs +++ b/FivePRS.Server/Database/SQLiteProvider.cs @@ -1,10 +1,8 @@ using System; -using System.Data.Common; -using System.Globalization; +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using Microsoft.Data.Sqlite; -using FivePRS.Core.Models; namespace FivePRS.Server.Database { @@ -14,8 +12,6 @@ public sealed class SQLiteProvider : IDatabaseProvider public SqlDialect Dialect => SqlDialect.Sqlite; - public DbConnection CreateConnection() => new SqliteConnection(_connectionString); - public SQLiteProvider(string dbPath) { var dir = Path.GetDirectoryName(dbPath); @@ -25,126 +21,116 @@ public SQLiteProvider(string dbPath) _connectionString = $"Data Source={Path.GetFullPath(dbPath)};"; } - public async Task InitializeAsync() - { - using var conn = new SqliteConnection(_connectionString); - await conn.OpenAsync(); + public Task InitializeAsync() => + ExecuteAsync("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;"); - using var cmd = conn.CreateCommand(); - cmd.CommandText = @" - PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL;"; - - await cmd.ExecuteNonQueryAsync(); + public async Task ExecuteAsync(string sql, params (string Name, object? Value)[] parameters) + { + using var conn = await OpenAsync(); + return await new Session(conn, null).ExecuteAsync(sql, parameters); } - public async Task GetPlayerAsync(string license) + public async Task InsertAsync(string sql, params (string Name, object? Value)[] parameters) { - using var conn = new SqliteConnection(_connectionString); - await conn.OpenAsync(); - - using var cmd = conn.CreateCommand(); - cmd.CommandText = @" - SELECT license, name, department, is_on_duty, xp, rank_level, last_seen, agency, callsign - FROM ers_players WHERE license = $license LIMIT 1;"; - cmd.Parameters.AddWithValue("$license", license); + using var conn = await OpenAsync(); + return await new Session(conn, null).InsertAsync(sql, parameters); + } - using var reader = await cmd.ExecuteReaderAsync(); - if (!await reader.ReadAsync()) return null; + public async Task> QueryAsync(string sql, params (string Name, object? Value)[] parameters) + { + using var conn = await OpenAsync(); + return await new Session(conn, null).QueryAsync(sql, parameters); + } - return new PlayerData + public Task BatchAsync(IReadOnlyList statements) => + ApplySchemaAsync(async db => { - License = reader.GetString(0), - Name = reader.GetString(1), - Department = (Department)reader.GetInt32(2), - IsOnDuty = reader.GetInt32(3) == 1, - XP = reader.GetInt32(4), - Rank = reader.GetInt32(5), - LastSeen = DateTime.TryParse(reader.GetString(6), CultureInfo.InvariantCulture, - DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var dt) ? dt : DateTime.UtcNow, - Agency = reader.GetString(7), - Callsign = reader.GetString(8), - }; + foreach (var statement in statements) + await db.ExecuteAsync(statement.Sql, ToArray(statement.Parameters)); + }); + + public async Task ApplySchemaAsync(Func work) + { + using var conn = await OpenAsync(); + using var transaction = conn.BeginTransaction(); + await work(new Session(conn, transaction)); + transaction.Commit(); } - public async Task SavePlayerAsync(PlayerData player) + public async Task BackupAsync(string path) { - using var conn = new SqliteConnection(_connectionString); - await conn.OpenAsync(); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); - using var cmd = conn.CreateCommand(); - cmd.CommandText = @" - INSERT INTO ers_players (license, name, department, agency, callsign, is_on_duty, xp, rank_level, last_seen) - VALUES ($license, $name, $dept, $agency, $callsign, $onDuty, $xp, $rank, $seen) - ON CONFLICT(license) DO UPDATE SET - name = excluded.name, - department = excluded.department, - agency = excluded.agency, - callsign = excluded.callsign, - is_on_duty = excluded.is_on_duty, - xp = excluded.xp, - rank_level = excluded.rank_level, - last_seen = excluded.last_seen;"; - - cmd.Parameters.AddWithValue("$license", player.License); - cmd.Parameters.AddWithValue("$name", player.Name); - cmd.Parameters.AddWithValue("$dept", (int)player.Department); - cmd.Parameters.AddWithValue("$agency", player.Agency); - cmd.Parameters.AddWithValue("$callsign", player.Callsign); - cmd.Parameters.AddWithValue("$onDuty", player.IsOnDuty ? 1 : 0); - cmd.Parameters.AddWithValue("$xp", player.XP); - cmd.Parameters.AddWithValue("$rank", player.Rank); - cmd.Parameters.AddWithValue("$seen", DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)); - - await cmd.ExecuteNonQueryAsync(); + await ExecuteAsync("VACUUM INTO @path;", ("@path", Path.GetFullPath(path))); } - public async Task AddAuditAsync(AuditEntry entry) + private async Task OpenAsync() { - using var conn = new SqliteConnection(_connectionString); + var conn = new SqliteConnection(_connectionString); await conn.OpenAsync(); + return conn; + } - using var cmd = conn.CreateCommand(); - cmd.CommandText = @" - INSERT INTO fiveprs_audit (created_at, action, actor_license, actor_name, target_license, details) - VALUES ($createdAt, $action, $actorLicense, $actorName, $targetLicense, $details);"; - - cmd.Parameters.AddWithValue("$createdAt", DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)); - cmd.Parameters.AddWithValue("$action", entry.Action); - cmd.Parameters.AddWithValue("$actorLicense", (object?)entry.ActorLicense ?? DBNull.Value); - cmd.Parameters.AddWithValue("$actorName", entry.ActorName); - cmd.Parameters.AddWithValue("$targetLicense", (object?)entry.TargetLicense ?? DBNull.Value); - cmd.Parameters.AddWithValue("$details", entry.Details); - - await cmd.ExecuteNonQueryAsync(); + private static (string Name, object? Value)[] ToArray(IReadOnlyList<(string Name, object? Value)> parameters) + { + var array = new (string Name, object? Value)[parameters.Count]; + for (var i = 0; i < array.Length; i++) array[i] = parameters[i]; + return array; } - public async Task UpdateDutyStatusAsync(string license, bool isOnDuty) + private sealed class Session : ISqlExecutor { - using var conn = new SqliteConnection(_connectionString); - await conn.OpenAsync(); + private readonly SqliteConnection _conn; + private readonly SqliteTransaction? _transaction; - using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE ers_players SET is_on_duty = $val WHERE license = $license;"; - cmd.Parameters.AddWithValue("$val", isOnDuty ? 1 : 0); - cmd.Parameters.AddWithValue("$license", license); + public Session(SqliteConnection conn, SqliteTransaction? transaction) + { + _conn = conn; + _transaction = transaction; + } - await cmd.ExecuteNonQueryAsync(); - } + public SqlDialect Dialect => SqlDialect.Sqlite; - public async Task BackupAsync(string path) - { - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); + public async Task ExecuteAsync(string sql, params (string Name, object? Value)[] parameters) + { + using var cmd = Command(sql, parameters); + return await cmd.ExecuteNonQueryAsync(); + } - using var conn = new SqliteConnection(_connectionString); - await conn.OpenAsync(); + public async Task InsertAsync(string sql, params (string Name, object? Value)[] parameters) + { + await ExecuteAsync(sql, parameters); + using var cmd = Command("SELECT last_insert_rowid()"); + return Convert.ToInt64(await cmd.ExecuteScalarAsync()); + } - using var cmd = conn.CreateCommand(); - cmd.CommandText = "VACUUM INTO $path;"; - cmd.Parameters.AddWithValue("$path", Path.GetFullPath(path)); - await cmd.ExecuteNonQueryAsync(); + public async Task> QueryAsync(string sql, params (string Name, object? Value)[] parameters) + { + using var cmd = Command(sql, parameters); + using var reader = await cmd.ExecuteReaderAsync(); + + var rows = new List(); + while (await reader.ReadAsync()) + { + var values = new Dictionary(reader.FieldCount); + for (var i = 0; i < reader.FieldCount; i++) + values[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i); + rows.Add(new DbRow(values)); + } + return rows; + } + + private SqliteCommand Command(string sql, params (string Name, object? Value)[] parameters) + { + var cmd = _conn.CreateCommand(); + cmd.Transaction = _transaction; + cmd.CommandText = sql; + foreach (var (name, value) in parameters) + cmd.Parameters.AddWithValue(name, value ?? DBNull.Value); + return cmd; + } } } } diff --git a/FivePRS.Server/Database/SqlStatement.cs b/FivePRS.Server/Database/SqlStatement.cs new file mode 100644 index 0000000..c433651 --- /dev/null +++ b/FivePRS.Server/Database/SqlStatement.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace FivePRS.Server.Database +{ + public sealed class SqlStatement + { + public SqlStatement(string sql, params (string Name, object? Value)[] parameters) + { + Sql = sql; + Parameters = parameters; + } + + public string Sql { get; } + + public IReadOnlyList<(string Name, object? Value)> Parameters { get; } + + public (string Sql, object?[] Values) ToPositional() + { + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (name, value) in Parameters) + lookup[name.TrimStart('@')] = value; + + var sql = new StringBuilder(Sql.Length); + var values = new List(); + char? quote = null; + + for (var i = 0; i < Sql.Length; i++) + { + var c = Sql[i]; + + if (quote is not null) + { + sql.Append(c); + if (c == quote) quote = null; + continue; + } + + if (c == '\'' || c == '"' || c == '`') + { + quote = c; + sql.Append(c); + continue; + } + + if (c != '@' || i + 1 >= Sql.Length || !IsNameChar(Sql[i + 1])) + { + sql.Append(c); + continue; + } + + var start = i + 1; + var end = start; + while (end < Sql.Length && IsNameChar(Sql[end])) end++; + + var name = Sql.Substring(start, end - start); + if (!lookup.TryGetValue(name, out var parameter)) + throw new ArgumentException($"The query uses @{name}, but no value was given for it."); + + sql.Append('?'); + values.Add(parameter); + i = end - 1; + } + + return (sql.ToString(), values.ToArray()); + } + + public static IReadOnlyList Split(string script) + { + var statements = new List(); + var current = new StringBuilder(); + char? quote = null; + + foreach (var c in script) + { + if (quote is not null) + { + if (c == quote) quote = null; + } + else if (c == '\'' || c == '"' || c == '`') + { + quote = c; + } + else if (c == ';') + { + Add(statements, current); + continue; + } + + current.Append(c); + } + + Add(statements, current); + return statements; + } + + private static void Add(List statements, StringBuilder current) + { + var statement = current.ToString().Trim(); + if (statement.Length > 0) statements.Add(statement); + current.Clear(); + } + + private static bool IsNameChar(char c) => char.IsLetterOrDigit(c) || c == '_'; + } +} diff --git a/FivePRS.Server/Database/SqlStore.cs b/FivePRS.Server/Database/SqlStore.cs index c2c1627..8e729a3 100644 --- a/FivePRS.Server/Database/SqlStore.cs +++ b/FivePRS.Server/Database/SqlStore.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Data.Common; using System.Threading.Tasks; namespace FivePRS.Server.Database @@ -13,58 +12,22 @@ public abstract class SqlStore protected bool IsMySql => Db.Dialect == SqlDialect.MySql; - protected string LastInsertId => IsMySql ? "SELECT LAST_INSERT_ID()" : "SELECT last_insert_rowid()"; + protected Task ExecuteAsync(string sql, params (string Name, object? Value)[] parameters) => + MainThread.Run(() => Db.ExecuteAsync(sql, parameters)); - protected Task ExecuteAsync(string sql, params (string Name, object Value)[] parameters) => - MainThread.Run(() => ExecuteCoreAsync(sql, parameters)); + protected Task InsertAsync(string sql, params (string Name, object? Value)[] parameters) => + MainThread.Run(() => Db.InsertAsync(sql, parameters)); - protected Task ScalarAsync(string sql, params (string Name, object Value)[] parameters) => - MainThread.Run(() => ScalarCoreAsync(sql, parameters)); + protected Task BatchAsync(params SqlStatement[] statements) => + MainThread.Run(() => Db.BatchAsync(statements)); - 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(); - using var cmd = CreateCommand(conn, sql, parameters); - await cmd.ExecuteNonQueryAsync(); - } - - private async Task ScalarCoreAsync(string sql, (string Name, object Value)[] parameters) - { - using var conn = Db.CreateConnection(); - await conn.OpenAsync(); - using var cmd = CreateCommand(conn, sql, parameters); - return await cmd.ExecuteScalarAsync(); - } - - private async Task> QueryCoreAsync(string sql, Func read, (string Name, object Value)[] parameters) - { - using var conn = Db.CreateConnection(); - await conn.OpenAsync(); - using var cmd = CreateCommand(conn, sql, parameters); - using var reader = await cmd.ExecuteReaderAsync(); - - var rows = new List(); - while (await reader.ReadAsync()) - rows.Add(read(reader)); - return rows; - } - - private static DbCommand CreateCommand(DbConnection conn, string sql, (string Name, object Value)[] parameters) + protected async Task> QueryAsync(string sql, Func read, params (string Name, object? Value)[] parameters) { - var cmd = conn.CreateCommand(); - cmd.CommandText = sql; - foreach (var (name, value) in parameters) - { - var parameter = cmd.CreateParameter(); - parameter.ParameterName = name; - parameter.Value = value; - cmd.Parameters.Add(parameter); - } - return cmd; + var rows = await MainThread.Run(() => Db.QueryAsync(sql, parameters)); + var result = new List(rows.Count); + foreach (var row in rows) + result.Add(read(row)); + return result; } } } diff --git a/FivePRS.Server/Database/SqliteNativeLoader.cs b/FivePRS.Server/Database/SqliteNativeLoader.cs index 4df44c3..67cf53c 100644 --- a/FivePRS.Server/Database/SqliteNativeLoader.cs +++ b/FivePRS.Server/Database/SqliteNativeLoader.cs @@ -44,8 +44,8 @@ private static IGetFunctionPointer OpenLibrary(string path) catch (Exception ex) { 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 or MariaDB instead: set fiveprs_db_type \"mysql\" and fiveprs_db_connection in server.cfg.", ex); + "SQLite can't run because this server doesn't allow resources to load native libraries. " + + "GTA V Enhanced servers aren't supported yet; use a GTA V Legacy server.", ex); } var open = library.GetMethod("Open", BindingFlags.Public | BindingFlags.Static)!; diff --git a/FivePRS.Server/Dispatch/DispatchService.cs b/FivePRS.Server/Dispatch/DispatchService.cs index cc7a4c2..a79ec9c 100644 --- a/FivePRS.Server/Dispatch/DispatchService.cs +++ b/FivePRS.Server/Dispatch/DispatchService.cs @@ -64,9 +64,12 @@ private sealed class Call public string Description { get; set; } = string.Empty; public string CallerName { get; set; } = string.Empty; public int CallerId { get; set; } + public bool IsBackup { get; set; } + public int BackupCode { get; set; } } public const string EmergencyCallName = "911 Call"; + public const string BackupCallName = "Backup Request"; private const int MaxCatalogSize = 500; private const int MaxCalloutNameLength = 64; @@ -83,6 +86,7 @@ private sealed class Call private readonly Dictionary _catalog = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _lastDispatched = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _lastEmergency = new(); + private readonly Dictionary _lastBackup = new(); private int _nextCallNumber = 1000; @@ -417,6 +421,78 @@ public bool CancelEmergencyCall(int callerId, string callId) return call.CallerId; } + public (string? CallId, string? Error) RequestBackup(int unitId, int code, float x, float y, float z) + { + if (!_units.TryGetValue(unitId, out var unit)) return (null, "You need to be on duty to request backup."); + if (code != 2 && code != 3) return (null, "Backup can be Code 2 or Code 3."); + if (unit.PendingCall is not null) return (null, "Answer the call offer first."); + + var now = _clock(); + if (_lastBackup.TryGetValue(unitId, out var last) && (now - last).TotalSeconds < Settings.Backup.CooldownSeconds) + { + var wait = Settings.Backup.CooldownSeconds - (int)(now - last).TotalSeconds; + return (null, $"Please wait {wait}s before requesting backup again."); + } + + var priority = code == 3 ? CalloutPriority.High : CalloutPriority.Medium; + if (unit.Info.CallId is not null && _calls.TryGetValue(unit.Info.CallId, out var current)) + { + current.BackupCode = code; + if (current.IsBackup) current.Definition.Priority = priority; + _lastBackup[unitId] = now; + IsDirty = true; + return (current.Id, null); + } + + var call = new Call + { + Id = (_nextCallNumber++).ToString(), + Definition = new CalloutDefinition { Name = BackupCallName, Department = unit.Info.Department, Priority = priority }, + Status = CallStatus.Active, + PrimaryUnit = unitId, + OfferedUtc = now, + HasLocation = true, + Territory = _territories().Resolve(x, y)?.Id, + X = x, + Y = y, + Z = z, + IsBackup = true, + BackupCode = code, + }; + + _calls[call.Id] = call; + _lastBackup[unitId] = now; + unit.Info.CallId = call.Id; + unit.Info.Status = UnitStatus.OnScene; + IsDirty = true; + return (call.Id, null); + } + + public bool CancelBackup(int unitId) + { + if (!_units.TryGetValue(unitId, out var unit) || unit.Info.CallId is null) return false; + if (!_calls.TryGetValue(unit.Info.CallId, out var call) || call.BackupCode == 0) return false; + + if (call.IsBackup && call.PrimaryUnit == unitId) + CloseCall(call, Settings.PostCompleteCooldownSeconds); + else + call.BackupCode = 0; + + IsDirty = true; + return true; + } + + public IEnumerable BackupResponders(int requesterId) + { + if (!_units.TryGetValue(requesterId, out var requester)) return Array.Empty(); + + return _units.Values + .Where(u => u.Info.ServerId != requesterId && + (u.Info.Department == Department.Police || u.Info.Department == requester.Info.Department)) + .Select(u => u.Info.ServerId) + .ToList(); + } + public int? GetEmergencyCaller(string callId) => _calls.TryGetValue(callId, out var call) && call.IsEmergency ? call.CallerId : null; @@ -462,6 +538,7 @@ public DispatchSnapshot CreateSnapshot() IsEmergency = c.IsEmergency, Description = c.IsEmergency ? c.Description : null, Caller = c.IsEmergency ? c.CallerName : null, + BackupCode = c.BackupCode, }) .ToList(), }; diff --git a/FivePRS.Server/FivePRS.Server.csproj b/FivePRS.Server/FivePRS.Server.csproj index 2a86182..8eae95f 100644 --- a/FivePRS.Server/FivePRS.Server.csproj +++ b/FivePRS.Server/FivePRS.Server.csproj @@ -15,7 +15,6 @@ - diff --git a/FivePRS.Server/MainThread.cs b/FivePRS.Server/MainThread.cs index 140244b..aa712e0 100644 --- a/FivePRS.Server/MainThread.cs +++ b/FivePRS.Server/MainThread.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -7,7 +8,7 @@ namespace FivePRS.Server { public static class MainThread { - private static readonly ConcurrentQueue> Waiting = new(); + private static readonly ConcurrentQueue Waiting = new(); private static bool _enabled; private static int _threadId; @@ -22,14 +23,7 @@ public static void Disable() 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 Awaitable Resume() => default; public static void Tick() { @@ -39,8 +33,8 @@ public static void Tick() private static void Drain() { - while (Waiting.TryDequeue(out var completion)) - completion.TrySetResult(true); + while (Waiting.TryDequeue(out var continuation)) + continuation(); } public static async Task Run(Func work) @@ -66,5 +60,18 @@ public static async Task Run(Func> work) await Resume(); } } + + public readonly struct Awaitable : INotifyCompletion + { + public Awaitable GetAwaiter() => this; + + public bool IsCompleted => IsCurrent; + + public void OnCompleted(Action continuation) => Waiting.Enqueue(continuation); + + public void GetResult() + { + } + } } } diff --git a/FivePRS.Server/RetiredFiles.cs b/FivePRS.Server/RetiredFiles.cs new file mode 100644 index 0000000..67bdf70 --- /dev/null +++ b/FivePRS.Server/RetiredFiles.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace FivePRS.Server +{ + public static class RetiredFiles + { + public static readonly IReadOnlyList Server = new[] + { + "MySqlConnector.dll", + "Microsoft.Bcl.AsyncInterfaces.dll", + "Microsoft.Extensions.DependencyInjection.Abstractions.dll", + "Microsoft.Extensions.Logging.Abstractions.dll", + "System.Diagnostics.DiagnosticSource.dll", + "System.Threading.Tasks.Extensions.dll", + }; + + public static List Remove(string directory, Action warn) + { + var removed = new List(); + foreach (var name in Server) + { + var path = Path.Combine(directory, name); + if (!File.Exists(path)) continue; + + try + { + File.Delete(path); + removed.Add(name); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + warn($"{name} is left over from an older FivePRS version and couldn't be deleted ({ex.Message}). Delete it from {directory}."); + } + } + return removed; + } + } +} diff --git a/FivePRS.Server/ServerBrain.Backup.cs b/FivePRS.Server/ServerBrain.Backup.cs new file mode 100644 index 0000000..09639d1 --- /dev/null +++ b/FivePRS.Server/ServerBrain.Backup.cs @@ -0,0 +1,65 @@ +using System; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Server.Database; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private void RegisterBackupEvents() + { + EventHandlers[EventNames.ServerRequestBackup] += new Action(OnRequestBackup); + EventHandlers[EventNames.ServerCancelBackup] += new Action(OnCancelBackup); + } + + private void OnRequestBackup([FromSource] Player player, int code) + { + if (!Allow(player, "dispatch", l => l.Dispatch)) return; + + if (!ConfigManager.Settings.Backup.Enabled) + { + Notify(player, "~r~Backup requests are turned off on this server."); + return; + } + + var requesterId = ServerId(player); + var position = API.GetEntityCoords(API.GetPlayerPed(player.Handle)); + var (callId, error) = _dispatch.RequestBackup(requesterId, code, position.X, position.Y, position.Z); + if (error is not null) + { + Notify(player, $"~r~{error}"); + return; + } + + var unit = _dispatch.GetUnit(requesterId); + var callsign = unit?.Callsign ?? player.Name; + var urgency = code == 3 ? "~r~Code 3~w~" : "~y~Code 2~w~"; + var notified = 0; + + foreach (var responderId in _dispatch.BackupResponders(requesterId)) + { + if (!TryGetPlayer(responderId, out var responder)) continue; + + Notify(responder, $"~b~Backup requested~w~ | {callsign} · {urgency} · call ~y~#{callId}~w~. Attach in the Dispatch app or with /er_attach {callId}."); + TriggerClientEvent(responder, EventNames.ClientBackupRequested, callId, callsign, code, position.X, position.Y, position.Z); + notified++; + } + + Audit(AuditActions.BackupRequested, player, null, $"call #{callId} code {code}"); + Notify(player, notified > 0 + ? $"~b~Backup~w~ | {urgency} request sent to {notified} unit{(notified == 1 ? "" : "s")} on call ~y~#{callId}~w~." + : $"~b~Backup~w~ | {urgency} request logged on call ~y~#{callId}~w~. No other units are on duty."); + } + + private void OnCancelBackup([FromSource] Player player) + { + if (!Allow(player, "dispatch", l => l.Dispatch)) return; + + if (_dispatch.CancelBackup(ServerId(player))) + Notify(player, "~b~Backup~w~ | Request cancelled."); + } + } +} diff --git a/FivePRS.Server/ServerBrain.cs b/FivePRS.Server/ServerBrain.cs index d8a7356..a7f9b07 100644 --- a/FivePRS.Server/ServerBrain.cs +++ b/FivePRS.Server/ServerBrain.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Threading.Tasks; using CitizenFX.Core; using CitizenFX.Core.Native; @@ -33,6 +34,8 @@ public partial class ServerBrain : BaseScript public ServerBrain() { Resource.Capture(); + foreach (var name in RetiredFiles.Remove(Path.Combine(Resource.Path, "server"), warning => Debug.WriteLine($"[FivePRS] WARNING: {warning}"))) + Debug.WriteLine($"[FivePRS] Removed server/{name}, left over from an older FivePRS version."); API.SetConvarServerInfo("fiveprs_version", InstalledVersion().ToString()); MainThread.Enable(); ConfigManager.Log = message => Debug.WriteLine(message); @@ -64,6 +67,7 @@ public ServerBrain() EventHandlers[EventNames.ServerAttachToCall] += new Action(OnAttachToCall); EventHandlers[EventNames.ServerSetAiCallouts] += new Action(OnSetAiCallouts); EventHandlers[EventNames.LocalHttpResponse] += new Action(HttpBridge.OnResponse); + EventHandlers[EventNames.LocalDbResponse] += new Action(OxMySqlBridge.OnResponse); Tick += DrainMainThreadAsync; Tick += DispatchTickAsync; @@ -71,6 +75,7 @@ public ServerBrain() RegisterCivilianEvents(); RegisterLookupEvents(); RegisterEmergencyEvents(); + RegisterBackupEvents(); RegisterRosterEvents(); RegisterAppearanceEvents(); RegisterTerminalEvents(); @@ -118,13 +123,13 @@ private async Task InitDbAsync() { Debug.WriteLine($"[FivePRS] FATAL: {ex.Message} The server's user account needs write access to the fiveprs resource folder."); } + catch (DatabaseQueryException ex) + { + Debug.WriteLine($"[FivePRS] FATAL: The database didn't start: {ex.Message}"); + } catch (Exception 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); + ServerLog.Error("FATAL: The database didn't start", ex); } } diff --git a/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs b/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs index 7c748bf..37189a8 100644 --- a/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs +++ b/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs @@ -77,6 +77,7 @@ public void ClientAssembly_HasNoAttributesOnGenericParameters(string fileName) "System.Char.IsLowSurrogate", "System.Char.IsSurrogate", "System.Char.IsSurrogatePair", + "System.Array.Find", }; private static readonly string[] BlockedClientTypes = diff --git a/FivePRS.Tests/DatabaseErrorsTests.cs b/FivePRS.Tests/DatabaseErrorsTests.cs deleted file mode 100644 index a27e106..0000000 --- a/FivePRS.Tests/DatabaseErrorsTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -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 fd45265..60178df 100644 --- a/FivePRS.Tests/DispatchServiceTests.cs +++ b/FivePRS.Tests/DispatchServiceTests.cs @@ -474,6 +474,121 @@ public void RegisterCallouts_CatalogFull_RejectsNewNamesButUpdatesExisting() Assert.Equal(1, _dispatch.RegisterCallouts(new[] { Definition("Traffic Stop", Department.Police, xp: 20) })); } + [Fact] + public void Backup_OffCall_CreatesBackupCallWithRequesterOnScene() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1); + + var (callId, error) = _dispatch.RequestBackup(Officer, 3, 10f, 20f, 30f); + + Assert.Null(error); + var snapshot = _dispatch.CreateSnapshot(); + var call = Assert.Single(snapshot.Calls); + Assert.Equal(callId, call.Id); + Assert.Equal(DispatchService.BackupCallName, call.Name); + Assert.Equal(3, call.BackupCode); + Assert.Equal(CalloutPriority.High, call.Priority); + Assert.Equal(new[] { Officer }, call.Units); + Assert.Equal(UnitStatus.OnScene, snapshot.Units.Single().Status); + } + + [Fact] + public void Backup_OnCall_FlagsThatCallInsteadOfCreatingOne() + { + var callId = AcceptedCall(Officer); + + var (backupCall, error) = _dispatch.RequestBackup(Officer, 2, 0f, 0f, 0f); + + Assert.Null(error); + Assert.Equal(callId, backupCall); + var call = Assert.Single(_dispatch.CreateSnapshot().Calls); + Assert.Equal(2, call.BackupCode); + Assert.Equal("Traffic Stop", call.Name); + } + + [Fact] + public void Backup_Responder_AttachesAndEarnsNothingOnABackupCall() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1); + _dispatch.SetOnDuty(Backup, "Backup", Department.Police, 1); + var (callId, _) = _dispatch.RequestBackup(Officer, 3, 0f, 0f, 0f); + + Assert.True(_dispatch.Attach(Backup, callId!)); + var awards = _dispatch.End(Officer, callId!, CalloutResult.Completed); + + Assert.All(awards, award => Assert.Equal(0, award.Amount)); + Assert.Empty(_dispatch.CreateSnapshot().Calls); + } + + [Fact] + public void Backup_Cancel_ClosesOwnBackupCallAndFreesUnits() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1); + _dispatch.SetOnDuty(Backup, "Backup", Department.Police, 1); + var (callId, _) = _dispatch.RequestBackup(Officer, 3, 0f, 0f, 0f); + _dispatch.Attach(Backup, callId!); + + Assert.True(_dispatch.CancelBackup(Officer)); + + var snapshot = _dispatch.CreateSnapshot(); + Assert.Empty(snapshot.Calls); + Assert.All(snapshot.Units, unit => Assert.Equal(UnitStatus.Available, unit.Status)); + } + + [Fact] + public void Backup_CancelOnCall_KeepsTheCall() + { + AcceptedCall(Officer); + _dispatch.RequestBackup(Officer, 3, 0f, 0f, 0f); + + Assert.True(_dispatch.CancelBackup(Officer)); + + Assert.Equal(0, Assert.Single(_dispatch.CreateSnapshot().Calls).BackupCode); + Assert.False(_dispatch.CancelBackup(Officer)); + } + + [Fact] + public void Backup_RepeatedWithinCooldown_IsRefused() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1); + _dispatch.RequestBackup(Officer, 3, 0f, 0f, 0f); + _dispatch.CancelBackup(Officer); + + var (_, error) = _dispatch.RequestBackup(Officer, 3, 0f, 0f, 0f); + Assert.Contains("wait", error); + + Advance(_settings.Backup.CooldownSeconds); + Assert.Null(_dispatch.RequestBackup(Officer, 3, 0f, 0f, 0f).Error); + } + + [Theory] + [InlineData(1)] + [InlineData(4)] + public void Backup_InvalidCode_IsRefused(int code) + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1); + + Assert.NotNull(_dispatch.RequestBackup(Officer, code, 0f, 0f, 0f).Error); + Assert.Empty(_dispatch.CreateSnapshot().Calls); + } + + [Fact] + public void Backup_OffDuty_IsRefused() + { + Assert.NotNull(_dispatch.RequestBackup(Officer, 3, 0f, 0f, 0f).Error); + } + + [Fact] + public void Backup_Responders_ArePoliceAndTheRequestersDepartment() + { + _dispatch.SetOnDuty(Officer, "Medic", Department.EMS, 1); + _dispatch.SetOnDuty(Backup, "Cop", Department.Police, 1); + _dispatch.SetOnDuty(3, "Medic 2", Department.EMS, 1); + _dispatch.SetOnDuty(4, "Firefighter", Department.Fire, 1); + + Assert.Equal(new[] { Backup, 3 }, _dispatch.BackupResponders(Officer).OrderBy(id => id)); + } + private void Advance(int seconds) => _now = _now.AddSeconds(seconds); private static CalloutDefinition Definition(string name, Department department, int xp, int cooldown = 0) => new() diff --git a/FivePRS.Tests/FakeOxMySql.cs b/FivePRS.Tests/FakeOxMySql.cs new file mode 100644 index 0000000..e50df80 --- /dev/null +++ b/FivePRS.Tests/FakeOxMySql.cs @@ -0,0 +1,88 @@ +using System; +using System.Threading.Tasks; +using FivePRS.Server.Database; +using MySqlConnector; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace FivePRS.Tests +{ + public sealed class FakeOxMySql : IOxMySql + { + private readonly string _connectionString; + + public FakeOxMySql(string connectionString) => _connectionString = connectionString; + + public async Task ConnectAsync() + { + using var conn = new MySqlConnection(_connectionString); + await conn.OpenAsync(); + } + + public async Task SendAsync(string kind, string sql, string parameters) + { + using var conn = new MySqlConnection(_connectionString); + await conn.OpenAsync(); + + if (kind == "batch") + { + using var transaction = await conn.BeginTransactionAsync(); + foreach (var query in JArray.Parse(sql)) + { + using var cmd = Command(conn, query.Value("query")!, (JArray)query["values"]!); + cmd.Transaction = transaction; + await cmd.ExecuteNonQueryAsync(); + } + await transaction.CommitAsync(); + return "true"; + } + + using (var cmd = Command(conn, sql, JArray.Parse(parameters))) + { + switch (kind) + { + case "insert": + await cmd.ExecuteNonQueryAsync(); + return cmd.LastInsertedId.ToString(); + case "update": + return (await cmd.ExecuteNonQueryAsync()).ToString(); + case "query": + return await ReadAsync(cmd); + default: + throw new ArgumentException($"Unknown request {kind}."); + } + } + } + + private static async Task ReadAsync(MySqlCommand cmd) + { + using var reader = await cmd.ExecuteReaderAsync(); + var rows = new JArray(); + while (await reader.ReadAsync()) + { + var row = new JObject(); + for (var i = 0; i < reader.FieldCount; i++) + { + var value = reader.IsDBNull(i) ? null : reader.GetValue(i); + row[reader.GetName(i)] = value switch + { + null => JValue.CreateNull(), + DateTime date => new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Local)).ToUnixTimeMilliseconds(), + _ => JToken.FromObject(value), + }; + } + rows.Add(row); + } + return rows.ToString(Formatting.None); + } + + private static MySqlCommand Command(MySqlConnection conn, string sql, JArray values) + { + var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var value in values) + cmd.Parameters.Add(new MySqlParameter { Value = ((JValue)value).Value ?? DBNull.Value }); + return cmd; + } + } +} diff --git a/FivePRS.Tests/FivePRS.Tests.csproj b/FivePRS.Tests/FivePRS.Tests.csproj index dca594d..d7977d1 100644 --- a/FivePRS.Tests/FivePRS.Tests.csproj +++ b/FivePRS.Tests/FivePRS.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/FivePRS.Tests/LoadoutConfigTests.cs b/FivePRS.Tests/LoadoutConfigTests.cs index 56f05ce..22d91be 100644 --- a/FivePRS.Tests/LoadoutConfigTests.cs +++ b/FivePRS.Tests/LoadoutConfigTests.cs @@ -55,6 +55,36 @@ public void Loadouts_AgencyOverride_ReplacesOnlyGivenParts() Assert.Same(config.CommandUniform, config.UniformFor("lspd", LoadoutTier.Command)); } + [Fact] + public void Uniform_ConfigWithoutFemale_KeepsBuiltInFemaleOutfit() + { + var config = JsonConvert.DeserializeObject( + "{ \"uniform\": { \"components\": [ { \"slot\": 11, \"drawable\": 99, \"texture\": 0 } ], \"props\": [] } }")!; + + Assert.Equal(99, Assert.Single(config.Uniform.Components).Drawable); + Assert.Contains(config.Uniform.Female!.Components, c => c.Slot == 3 && c.Drawable == 14); + } + + [Fact] + public void Uniform_FemaleOutfit_IsReadFromConfig() + { + var config = JsonConvert.DeserializeObject( + "{ \"uniform\": { \"components\": [], \"props\": [], \"female\": { \"components\": [ { \"slot\": 11, \"drawable\": 7, \"texture\": 2 } ], \"props\": [] } } }")!; + + var top = Assert.Single(config.Uniform.Female!.Components); + Assert.Equal(7, top.Drawable); + Assert.Equal(2, top.Texture); + } + + [Fact] + public void Uniform_AgencyWithoutFemale_HasNoFemaleOutfit() + { + var config = JsonConvert.DeserializeObject( + "{ \"agencies\": { \"bcso\": { \"uniform\": { \"components\": [], \"props\": [] } } } }")!; + + Assert.Null(config.UniformFor("bcso", LoadoutTier.Officer).Female); + } + [Theory] [InlineData("police_loadouts.json", typeof(DepartmentLoadoutsConfig))] [InlineData("police_vehicles.json", typeof(DepartmentVehiclesConfig))] diff --git a/FivePRS.Tests/MainThreadTests.cs b/FivePRS.Tests/MainThreadTests.cs index bd423a2..4cebc17 100644 --- a/FivePRS.Tests/MainThreadTests.cs +++ b/FivePRS.Tests/MainThreadTests.cs @@ -47,6 +47,34 @@ public async Task Run_BeforeFirstTick_WaitsForTick() } } + [Fact] + public async Task Resume_FromAnotherThread_ContinuesOnTheTickThread() + { + MainThread.Enable(); + try + { + MainThread.Tick(); + var continuedOn = 0; + var resumed = Task.Run(async () => + { + await MainThread.Resume(); + continuedOn = System.Threading.Thread.CurrentThread.ManagedThreadId; + }); + await Task.Delay(100); + Assert.False(resumed.IsCompleted); + + var tickThread = System.Threading.Thread.CurrentThread.ManagedThreadId; + MainThread.Tick(); + await resumed; + + Assert.Equal(tickThread, continuedOn); + } + finally + { + MainThread.Disable(); + } + } + [Fact] public async Task Run_FromAnotherThread_FinishesOnlyAfterTick() { diff --git a/FivePRS.Tests/MigratorTests.cs b/FivePRS.Tests/MigratorTests.cs index 6888edb..a9abb89 100644 --- a/FivePRS.Tests/MigratorTests.cs +++ b/FivePRS.Tests/MigratorTests.cs @@ -144,12 +144,12 @@ public async Task Backup_CopiesDatabaseWithData() var provider = new SQLiteProvider(_path); await provider.InitializeAsync(); await new Migrator(provider).MigrateAsync(); - await provider.SavePlayerAsync(new PlayerData { License = "license:kept", Name = "Kept" }); + await new PlayerStore(provider).SaveAsync(new PlayerData { License = "license:kept", Name = "Kept" }); await provider.BackupAsync(_backup); var copy = new SQLiteProvider(_backup); - Assert.Equal("Kept", (await copy.GetPlayerAsync("license:kept"))!.Name); + Assert.Equal("Kept", (await new PlayerStore(copy).GetAsync("license:kept"))!.Name); Assert.Equal(Migrations.All[Migrations.All.Count - 1].Version, await new Migrator(copy).CurrentVersionAsync()); } diff --git a/FivePRS.Tests/MySqlConnectionTests.cs b/FivePRS.Tests/MySqlConnectionTests.cs deleted file mode 100644 index dc57acf..0000000 --- a/FivePRS.Tests/MySqlConnectionTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -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/OxMySqlProviderTests.cs b/FivePRS.Tests/OxMySqlProviderTests.cs new file mode 100644 index 0000000..0a3504d --- /dev/null +++ b/FivePRS.Tests/OxMySqlProviderTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Models; +using FivePRS.Server.Database; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace FivePRS.Tests +{ + public class SqlStatementTests + { + [Fact] + public void ToPositional_ReplacesNamesInOrderAndRepeats() + { + var (sql, values) = new SqlStatement( + "INSERT INTO t (a, b, c) VALUES (@now, @name, @now)", + ("@name", "Ann"), ("@now", 5)).ToPositional(); + + Assert.Equal("INSERT INTO t (a, b, c) VALUES (?, ?, ?)", sql); + Assert.Equal(new object?[] { 5, "Ann", 5 }, values); + } + + [Fact] + public void ToPositional_LeavesQuotedTextAlone() + { + var (sql, values) = new SqlStatement("SELECT '@kept', `@col`, \"@x\" FROM t WHERE a = @a", ("@a", 1)).ToPositional(); + + Assert.Equal("SELECT '@kept', `@col`, \"@x\" FROM t WHERE a = ?", sql); + Assert.Single(values); + } + + [Fact] + public void ToPositional_MissingValue_Throws() + { + Assert.Throws(() => new SqlStatement("SELECT @missing").ToPositional()); + } + + [Fact] + public void Split_SeparatesStatementsButNotQuotedSemicolons() + { + var parts = SqlStatement.Split("CREATE TABLE a (x TEXT DEFAULT ';');\n CREATE INDEX i ON a(x);\n"); + + Assert.Equal(new[] { "CREATE TABLE a (x TEXT DEFAULT ';')", "CREATE INDEX i ON a(x)" }, parts); + } + } + + public class DbRowTests + { + private static DbRow Row(string column, object? value) => new(new Dictionary { [column] = value }); + + [Fact] + public void GetBoolean_AcceptsBooleansNumbersAndText() + { + Assert.True(Row("a", true).GetBoolean("a")); + Assert.True(Row("a", 1L).GetBoolean("a")); + Assert.False(Row("a", "0").GetBoolean("a")); + } + + [Fact] + public void GetInt32_AcceptsBooleans() + { + Assert.Equal(1, Row("a", true).GetInt32("a")); + } + + [Fact] + public void GetDateTime_TextWithoutZone_IsUtc() + { + var date = Row("a", "2026-10-11 12:30:00").GetDateTime("a"); + + Assert.Equal(new DateTime(2026, 10, 11, 12, 30, 0, DateTimeKind.Utc), date); + Assert.Equal(DateTimeKind.Utc, date.Kind); + } + + [Fact] + public void GetDateTime_OxMySqlTimestamp_RestoresStoredWallClock() + { + var stored = new DateTime(2026, 10, 11, 12, 30, 0); + var timestamp = new DateTimeOffset(DateTime.SpecifyKind(stored, DateTimeKind.Local)).ToUnixTimeMilliseconds(); + + Assert.Equal(DateTime.SpecifyKind(stored, DateTimeKind.Utc), Row("a", timestamp).GetDateTime("a")); + } + + [Fact] + public void ColumnNames_AreCaseInsensitive_AndMissingColumnsAreNull() + { + var row = Row("Name", "x"); + + Assert.Equal("x", row.GetString("name")); + Assert.True(row.IsNull("resolution")); + Assert.Throws(() => row.GetString("resolution")); + } + } + + public class OxMySqlProviderTests + { + private sealed class Recorder : IOxMySql + { + public readonly List<(string Kind, string Sql, string Parameters)> Requests = new(); + public string Response = "null"; + + public Task ConnectAsync() => Task.CompletedTask; + + public Task SendAsync(string kind, string sql, string parameters) + { + Requests.Add((kind, sql, parameters)); + return Task.FromResult(Response); + } + } + + [Fact] + public async Task Query_SendsPositionalParametersAndUtcDates() + { + var ox = new Recorder { Response = "[]" }; + var provider = new OxMySqlProvider(ox); + + await provider.QueryAsync("SELECT * FROM t WHERE a = @a AND b = @b AND c = @c", + ("@a", new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc)), ("@b", Department.Police), ("@c", null)); + + var (kind, sql, parameters) = Assert.Single(ox.Requests); + Assert.Equal("query", kind); + Assert.Equal("SELECT * FROM t WHERE a = ? AND b = ? AND c = ?", sql); + Assert.Equal(new JArray("2026-01-02 03:04:05.000000", (long)Department.Police, JValue.CreateNull()).ToString(), JArray.Parse(parameters).ToString()); + } + + [Fact] + public async Task Query_ReadsOxMySqlRows() + { + var ox = new Recorder { Response = "[{\"license\":\"license:a\",\"is_on_duty\":true,\"xp\":40,\"last_seen\":\"2026-10-11T12:00:00Z\",\"resolution\":null}]" }; + + var row = Assert.Single(await new OxMySqlProvider(ox).QueryAsync("SELECT 1")); + + Assert.Equal("license:a", row.GetString("license")); + Assert.True(row.GetBoolean("is_on_duty")); + Assert.Equal(40, row.GetInt32("xp")); + Assert.Equal(new DateTime(2026, 10, 11, 12, 0, 0, DateTimeKind.Utc), row.GetDateTime("last_seen")); + Assert.True(row.IsNull("resolution")); + } + + [Fact] + public async Task Insert_ReturnsInsertId() + { + var ox = new Recorder { Response = "42" }; + + Assert.Equal(42, await new OxMySqlProvider(ox).InsertAsync("INSERT INTO t (a) VALUES (@a)", ("@a", 1))); + Assert.Equal("insert", ox.Requests.Single().Kind); + } + + [Fact] + public async Task Batch_SendsEveryStatementAsOneTransaction() + { + var ox = new Recorder { Response = "true" }; + + await new OxMySqlProvider(ox).BatchAsync(new[] + { + new SqlStatement("DELETE FROM a WHERE id = @id", ("@id", 7)), + new SqlStatement("DELETE FROM b WHERE id = @id", ("@id", 7)), + }); + + var (kind, sql, _) = Assert.Single(ox.Requests); + Assert.Equal("batch", kind); + var queries = JArray.Parse(sql); + Assert.Equal(2, queries.Count); + Assert.Equal("DELETE FROM b WHERE id = ?", queries[1]["query"]!.Value()); + Assert.Equal(7, queries[1]["values"]![0]!.Value()); + } + } +} diff --git a/FivePRS.Tests/RetiredFilesTests.cs b/FivePRS.Tests/RetiredFilesTests.cs new file mode 100644 index 0000000..2308ace --- /dev/null +++ b/FivePRS.Tests/RetiredFilesTests.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.IO; +using FivePRS.Server; +using Xunit; + +namespace FivePRS.Tests +{ + public sealed class RetiredFilesTests : IDisposable + { + private readonly string _directory = Path.Combine(Path.GetTempPath(), $"fiveprs-retired-{Guid.NewGuid():N}"); + + public RetiredFilesTests() => Directory.CreateDirectory(_directory); + + [Fact] + public void Remove_DeletesOnlyRetiredFiles() + { + File.WriteAllText(Path.Combine(_directory, "MySqlConnector.dll"), "old"); + File.WriteAllText(Path.Combine(_directory, "FivePRS.Server.net.dll"), "current"); + + var warnings = new List(); + var removed = RetiredFiles.Remove(_directory, warnings.Add); + + Assert.Equal(new[] { "MySqlConnector.dll" }, removed); + Assert.Empty(warnings); + Assert.False(File.Exists(Path.Combine(_directory, "MySqlConnector.dll"))); + Assert.True(File.Exists(Path.Combine(_directory, "FivePRS.Server.net.dll"))); + } + + [Fact] + public void Remove_CleanFolder_RemovesNothing() + { + Assert.Empty(RetiredFiles.Remove(_directory, _ => { })); + } + + [Fact] + public void Retired_NeverIncludesAShippedFile() + { + var publish = File.ReadAllText(Path.Combine(Root(), "publish.ps1")); + foreach (var name in RetiredFiles.Server) + Assert.DoesNotContain($"\"{name}\"", publish); + } + + private static string Root() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "publish.ps1"))) dir = dir.Parent; + return dir?.FullName ?? throw new DirectoryNotFoundException("publish.ps1 not found above the test folder."); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, true); + } + } +} diff --git a/FivePRS.Tests/RosterServiceTests.cs b/FivePRS.Tests/RosterServiceTests.cs index f908600..73b2cbb 100644 --- a/FivePRS.Tests/RosterServiceTests.cs +++ b/FivePRS.Tests/RosterServiceTests.cs @@ -16,8 +16,8 @@ public sealed class RosterServiceTests : IAsyncLifetime public async Task InitializeAsync() { _database = await TestDatabase.CreateAsync(); - await _database.Provider.SavePlayerAsync(new PlayerData { License = Alice, Name = "Alice Cooper", Rank = 4 }); - await _database.Provider.SavePlayerAsync(new PlayerData { License = "license:bob", Name = "Bob Ross" }); + await _database.Players.SaveAsync(new PlayerData { License = Alice, Name = "Alice Cooper", Rank = 4 }); + await _database.Players.SaveAsync(new PlayerData { License = "license:bob", Name = "Bob Ross" }); _roster = new RosterService(_database.Roster); await _roster.LoadAsync(); } diff --git a/FivePRS.Tests/SQLiteProviderTests.cs b/FivePRS.Tests/SQLiteProviderTests.cs index 1f59b52..ce204be 100644 --- a/FivePRS.Tests/SQLiteProviderTests.cs +++ b/FivePRS.Tests/SQLiteProviderTests.cs @@ -32,7 +32,7 @@ CREATE TABLE ers_players ( await provider.InitializeAsync(); await new Migrator(provider).MigrateAsync(); - var player = await provider.GetPlayerAsync("license:old"); + var player = await new PlayerStore(provider).GetAsync("license:old"); Assert.NotNull(player); Assert.Equal("Veteran", player!.Name); Assert.Equal(3, player.Rank); @@ -46,7 +46,7 @@ public async Task SavePlayerAsync_Callsign_RoundTrips() await provider.InitializeAsync(); await new Migrator(provider).MigrateAsync(); - await provider.SavePlayerAsync(new PlayerData + await new PlayerStore(provider).SaveAsync(new PlayerData { License = "license:new", Name = "Rookie", @@ -55,7 +55,7 @@ await provider.SavePlayerAsync(new PlayerData Callsign = "1-ADAM-12", }); - var player = await provider.GetPlayerAsync("license:new"); + var player = await new PlayerStore(provider).GetAsync("license:new"); Assert.Equal("1-ADAM-12", player!.Callsign); Assert.Equal("lspd", player.Agency); } diff --git a/FivePRS.Tests/TestDatabase.cs b/FivePRS.Tests/TestDatabase.cs index aeab794..a0188d4 100644 --- a/FivePRS.Tests/TestDatabase.cs +++ b/FivePRS.Tests/TestDatabase.cs @@ -13,6 +13,8 @@ public sealed class TestDatabase : IDisposable private readonly string _path = Path.Combine(Path.GetTempPath(), $"fiveprs-test-{Guid.NewGuid():N}.db"); + public PlayerStore Players { get; private set; } = null!; + public CivilianStore Store { get; private set; } = null!; public RosterStore Roster { get; private set; } = null!; @@ -38,12 +40,13 @@ public static async Task CreateAsync() else { await DropTablesAsync(mySql); - provider = new MySqlProvider(mySql); + provider = new OxMySqlProvider(new FakeOxMySql(mySql)); } await provider.InitializeAsync(); await new Migrator(provider).MigrateAsync(); database.Provider = provider; + database.Players = new PlayerStore(provider); database.Store = new CivilianStore(provider); database.Roster = new RosterStore(provider); database.Appearances = new AppearanceStore(provider); diff --git a/README.md b/README.md index 255fb71..4567b99 100644 --- a/README.md +++ b/README.md @@ -13,10 +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. 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. +FivePRS runs on Windows and Linux servers for GTA V Legacy. 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. +> **GTA V Enhanced isn't supported yet.** FiveM for Enhanced runs C# resources on .NET 10 in a sandbox that FivePRS doesn't run in yet. Enhanced support is coming in a later release; until then, use a GTA V Legacy server. 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. @@ -25,10 +25,12 @@ To update, see [Updates](#updates): FivePRS tells you when a new release is out ### Building from source On Windows, run `publish.ps1` to build everything and package `dist/fiveprs.zip`. Run the tests with `dotnet test`. +Before a release, run `tools/smoke/smoke.ps1` with Docker running and `FIVEPRS_LICENSE_KEY` set to a Cfx.re server key. It starts the package on a real Linux FXServer with SQLite, then with MySQL (MariaDB and oxmysql), and fails if FivePRS doesn't come online or the server crashes. Add `-Build` to rebuild first. + ## Server Configuration ```cfg -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_db_type "sqlite" # or "mysql" (also for MariaDB; needs oxmysql, see below) +set fiveprs_db_connection "" # optional 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 @@ -42,6 +44,19 @@ add_ace resource.fiveprs command.load_server_icon allow # lets FivePRS set the ensure fiveprs ``` +### MySQL and MariaDB +FivePRS connects to MySQL 8 and MariaDB 10.6+ through [oxmysql](https://github.com/overextended/oxmysql/releases). Install it, then set its connection string and start it before FivePRS: + +```cfg +set mysql_connection_string "mysql://user:password@localhost/fiveprs?charset=utf8mb4" +set fiveprs_db_type "mysql" + +ensure oxmysql +ensure fiveprs +``` + +If you used `fiveprs_db_connection` for MySQL before, rewrite it in the `mysql://user:password@host:port/database` form above for `mysql_connection_string`. oxmysql doesn't read every key of the old `Server=…;Database=…;Password=…` format. + | Permission | Grants | |---|---| | `fiveprs.admin` | Admin commands, the Admin app and access to every department | @@ -187,6 +202,9 @@ Fire (LSFD) and EMS (SAMS) are playable departments. Go on duty at a fire statio ## 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. +## Backup requests +On-duty units can request backup at Code 2 or Code 3 from the stop menu, with `/backup [2|3] [npc|players]`, or with a key bound under FivePRS in the key bindings; `/backup cancel` ends the request. **Any unit** alerts on-duty players and sends an NPC unit if nobody attaches in time (or after a few seconds when nobody else is on duty), **NPC unit** sends one straight away for roleplay with NPCs, and **Players only** never sends one. On a call, backup is requested for that call; otherwise a Backup Request call is created at the officer's position. Police and the requester's own department are alerted with a notification and a flashing marker. Settings are under `backup` in `config/settings.json`: `enabled`, `cooldownSeconds`, `npcEnabled`, `npcWaitSeconds`, `npcVehicle`, `npcPedModel` and `npcCrew`. + ## 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). @@ -257,10 +275,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. +**`oxmysql isn't installed` / `oxmysql couldn't connect to the database`.** With `fiveprs_db_type "mysql"`, FivePRS needs oxmysql started before it and `mysql_connection_string` set. oxmysql prints its own message explaining connection failures just above the FivePRS one. **`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`. +**`MissingMethodException`, `SecurityException` or `P/Invoke is not allowed` on startup.** The server is running FiveM for GTA V Enhanced, which FivePRS doesn't support yet. Use a GTA V Legacy server. **`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/police_loadouts.json b/config/police_loadouts.json index 88c801c..e05514f 100644 --- a/config/police_loadouts.json +++ b/config/police_loadouts.json @@ -38,7 +38,7 @@ }, "uniform": { "components": [ - { "slot": 3, "drawable": 4, "texture": 0 }, + { "slot": 3, "drawable": 0, "texture": 0 }, { "slot": 4, "drawable": 24, "texture": 0 }, { "slot": 6, "drawable": 24, "texture": 0 }, { "slot": 8, "drawable": 58, "texture": 0 }, @@ -48,7 +48,21 @@ "props": [ { "slot": 0, "drawable": 46, "texture": 0 }, { "slot": 1, "drawable": -1, "texture": -1 } - ] + ], + "female": { + "components": [ + { "slot": 3, "drawable": 14, "texture": 0 }, + { "slot": 4, "drawable": 34, "texture": 0 }, + { "slot": 6, "drawable": 25, "texture": 0 }, + { "slot": 8, "drawable": 35, "texture": 0 }, + { "slot": 9, "drawable": 0, "texture": 0 }, + { "slot": 11, "drawable": 48, "texture": 0 } + ], + "props": [ + { "slot": 0, "drawable": 45, "texture": 0 }, + { "slot": 1, "drawable": -1, "texture": -1 } + ] + } }, "commandUniform": { "components": [ @@ -62,7 +76,21 @@ "props": [ { "slot": 0, "drawable": -1, "texture": -1 }, { "slot": 1, "drawable": -1, "texture": -1 } - ] + ], + "female": { + "components": [ + { "slot": 3, "drawable": 14, "texture": 0 }, + { "slot": 4, "drawable": 34, "texture": 0 }, + { "slot": 6, "drawable": 25, "texture": 0 }, + { "slot": 8, "drawable": 35, "texture": 0 }, + { "slot": 9, "drawable": 0, "texture": 0 }, + { "slot": 11, "drawable": 48, "texture": 0 } + ], + "props": [ + { "slot": 0, "drawable": -1, "texture": -1 }, + { "slot": 1, "drawable": -1, "texture": -1 } + ] + } }, "fallbackPedModels": { "male": "s_m_y_cop_01", "female": "s_f_y_cop_01" }, "agencies": {} diff --git a/config/settings.json b/config/settings.json index 7e0174d..55eeb66 100644 --- a/config/settings.json +++ b/config/settings.json @@ -29,6 +29,15 @@ "notifyEms": true, "reviveXp": 25 }, + "backup": { + "enabled": true, + "cooldownSeconds": 30, + "npcEnabled": true, + "npcWaitSeconds": 45, + "npcVehicle": "police", + "npcPedModel": "s_m_y_cop_01", + "npcCrew": 2 + }, "world": { "disableWantedLevel": true, "disableDispatchServices": true, diff --git a/fxmanifest.lua b/fxmanifest.lua index 0b39c34..d630bb8 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -6,12 +6,13 @@ 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.3' +version '0.9.0-ptb.4' lua54 'yes' server_scripts { 'scripts/http.lua', + 'scripts/db.lua', 'server/FivePRS.Server.net.dll', } diff --git a/nui/civilian.js b/nui/civilian.js index 9018a66..0529e54 100644 --- a/nui/civilian.js +++ b/nui/civilian.js @@ -179,6 +179,7 @@ function renderCivilian(view, reset) { const appearanceButton = document.getElementById("civ-appearance"); appearanceButton.disabled = Boolean(view.onDuty); appearanceButton.title = view.onDuty ? "Go off duty to change your appearance" : ""; + document.getElementById("civ-appearance-note").hidden = !view.onDuty; const deleteButton = document.getElementById("civ-delete"); delete deleteButton.dataset.armed; deleteButton.textContent = "Delete character"; diff --git a/nui/index.html b/nui/index.html index 23d27d8..50e075c 100644 --- a/nui/index.html +++ b/nui/index.html @@ -317,6 +317,7 @@

+
diff --git a/publish.ps1 b/publish.ps1 index 85ca6a6..baa36ff 100644 --- a/publish.ps1 +++ b/publish.ps1 @@ -13,6 +13,8 @@ function Warn($msg) { Write-Host "[FivePRS] WARNING: $msg" -ForegroundColor Yell # 1. Clean dist Info "Cleaning previous dist..." if (Test-Path -LiteralPath $DistRoot) { Remove-Item -LiteralPath $DistRoot -Recurse -Force } +$BinRoot = Join-Path $Root "bin" +if (Test-Path -LiteralPath $BinRoot) { Remove-Item -LiteralPath $BinRoot -Recurse -Force } $null = New-Item -ItemType Directory -Path "$DistDir\client" $null = New-Item -ItemType Directory -Path "$DistDir\server" $null = New-Item -ItemType Directory -Path "$DistDir\plugins" @@ -41,21 +43,41 @@ Info "Copying NUI files..." Copy-Item -Recurse "$Root\nui" "$DistDir\nui" Copy-Item -Recurse "$Root\scripts" "$DistDir\scripts" -# 4. Copy client DLLs (bin\client -> client) -# CitizenFX.* is injected by FiveM at runtime - do NOT bundle it. -Info "Copying client binaries..." -Get-ChildItem "$Root\bin\client" -File | Where-Object { - $_.Extension -eq ".dll" -and - $_.Name -notlike "CitizenFX.*" -} | Copy-Item -Destination "$DistDir\client" - -# 5. Copy server DLLs (bin\server -> server) -# Native SQLite (e_sqlite3.dll on Windows, libe_sqlite3.so on Linux) sits next to the DLLs. -Info "Copying server binaries..." -Get-ChildItem "$Root\bin\server" -File | Where-Object { - ($_.Extension -eq ".dll" -or $_.Extension -eq ".so") -and - $_.Name -notlike "CitizenFX.*" -} | Copy-Item -Destination "$DistDir\server" +# 4-5. Copy the shipped binaries. Only the files listed here are packaged; a new or missing +# file stops the build so every dependency change is a deliberate decision. +$ClientFiles = @( + "FivePRS.Client.net.dll", "FivePRS.Police.net.dll", "FivePRS.Fire.net.dll", "FivePRS.EMS.net.dll", + "FivePRS.Core.dll", "Newtonsoft.Json.dll" +) +$ServerFiles = @( + "FivePRS.Server.net.dll", "FivePRS.Core.dll", "FivePRS.Native.dll", "Newtonsoft.Json.dll", + "Microsoft.Data.Sqlite.dll", "SQLitePCLRaw.batteries_v2.dll", "SQLitePCLRaw.core.dll", + "SQLitePCLRaw.provider.dynamic_cdecl.dll", "SQLitePCLRaw.provider.e_sqlite3.dll", + "System.Buffers.dll", "System.Memory.dll", "System.Numerics.Vectors.dll", "System.Runtime.CompilerServices.Unsafe.dll", + "Windows.dll", "Microsoft.Windows.SDK.NET.dll", + "e_sqlite3.dll", "libe_sqlite3.so" +) + +function Copy-Shipped($name, $from, $to, $files) { + Info "Copying $name binaries..." + $built = Get-ChildItem $from -File | Where-Object { + ($_.Extension -eq ".dll" -or $_.Extension -eq ".so") -and $_.Name -notlike "CitizenFX.*" + } | ForEach-Object { $_.Name } + + $unexpected = $built | Where-Object { $files -notcontains $_ } + $absent = $files | Where-Object { $built -notcontains $_ } + if ($unexpected -or $absent) { + $unexpected | ForEach-Object { Write-Host " NEW (not in the $name list): $_" -ForegroundColor Red } + $absent | ForEach-Object { Write-Host " MISSING from the build: $_" -ForegroundColor Red } + Write-Error "The $name binaries changed. Update the list in publish.ps1 once you've checked the change." + exit 1 + } + + $files | ForEach-Object { Copy-Item (Join-Path $from $_) -Destination $to } +} + +Copy-Shipped "client" "$Root\bin\client" "$DistDir\client" $ClientFiles +Copy-Shipped "server" "$Root\bin\server" "$DistDir\server" $ServerFiles # 6. Drop READMEs into plugins/, callouts/ and apps/ @( @@ -166,7 +188,7 @@ Write-Host "" Write-Host "Next steps:" -ForegroundColor Yellow Write-Host " 1. Extract fiveprs.zip into your server resources\ directory." -ForegroundColor Yellow Write-Host " 2. Add to server.cfg:" -ForegroundColor Yellow -Write-Host ' set fiveprs_db_type "sqlite" # or "mysql"' -ForegroundColor Gray +Write-Host ' set fiveprs_db_type "sqlite" # or "mysql" (needs oxmysql and mysql_connection_string)' -ForegroundColor Gray Write-Host ' set fiveprs_restrict_departments "false" # "true" to require ACE per department' -ForegroundColor Gray Write-Host " ensure fiveprs" -ForegroundColor Gray Write-Host "" diff --git a/scripts/db.lua b/scripts/db.lua new file mode 100644 index 0000000..f8648dc --- /dev/null +++ b/scripts/db.lua @@ -0,0 +1,64 @@ +local resource = GetCurrentResourceName() +local methods = { query = 'query', insert = 'insert', update = 'update', batch = 'transaction' } + +local function respond(id, result, err) + if err then + TriggerEvent('FivePRS:Local:DbResponse', id, '', tostring(err)) + else + TriggerEvent('FivePRS:Local:DbResponse', id, result == nil and 'null' or json.encode(result), '') + end +end + +local function positional(values) + local keyed = {} + for index, value in pairs(values or {}) do + keyed[tostring(index)] = value + end + return keyed +end + +local function awaitConnection(id) + CreateThread(function() + while true do + local ok, ready = pcall(function() return exports.oxmysql:isReady() end) + if not ok then return respond(id, nil, ready) end + if ready then return respond(id, true) end + Wait(250) + end + end) +end + +AddEventHandler('FivePRS:Local:DbRequest', function(id, kind, sql, parameters) + local invoking = GetInvokingResource() + if invoking ~= nil and invoking ~= resource then return end + + if kind == 'connect' then return awaitConnection(id) end + + local method = methods[kind] + if not method then return respond(id, nil, ("unknown database request '%s'"):format(kind)) end + + local ok, err = pcall(function() + local query, values + if kind == 'batch' then + query = json.decode(sql) + for _, statement in ipairs(query) do + statement.values = positional(statement.values) + end + values = {} + else + query = sql + values = positional(json.decode(parameters)) + end + + local oxmysql = exports.oxmysql + oxmysql[method](oxmysql, query, values, function(result, failure) + if failure then return respond(id, nil, failure) end + if kind == 'batch' and result ~= true then + return respond(id, nil, 'the transaction failed and was rolled back; oxmysql logged the reason above') + end + respond(id, result) + end, resource, true) + end) + + if not ok then respond(id, nil, err) end +end) From e79adf3ca958c7137bef4bf06a136280908355e3 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Sun, 11 Oct 2026 17:43:30 -0600 Subject: [PATCH 3/6] fix: update mission row duty point --- config/stations.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/stations.json b/config/stations.json index 34f5497..8778e8f 100644 --- a/config/stations.json +++ b/config/stations.json @@ -3,7 +3,7 @@ "stations": [ { "id": "mission_row_pd", "name": "Mission Row Police Station", "department": "Police", "agencies": ["lspd"], - "dutyPoint": [433.6, -981.8, 30.7], "heading": 90, + "dutyPoint": [440.89, -981.43, 30.69], "heading": 13.97, "armory": [451.7, -980.1, 30.7], "locker": [452.6, -992.8, 30.7], "garage": [454.6, -1017.4, 28.4], From ac141044835254c4e7c1207871cd69f407d1fd7a Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Sun, 11 Oct 2026 17:44:38 -0600 Subject: [PATCH 4/6] fix: updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eceda63..2330c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - The native SQLite helper is only loaded when SQLite is the database - MySQL and MariaDB now go through [oxmysql](https://github.com/overextended/oxmysql) instead of a bundled driver. Install oxmysql, set `mysql_connection_string` as `mysql://user:password@host:port/database`, and `ensure oxmysql` before `fiveprs`. `fiveprs_db_connection` is now only for a custom SQLite path - The server package is smaller: MySqlConnector and the six libraries it needed are no longer shipped +- Updated duty point for the Mission Row station to be infront of the front desk. ### Known issues - GTA V Enhanced servers aren't supported yet. Enhanced runs C# resources on .NET 10 in a sandbox that FivePRS doesn't run in yet; support is coming in a later release. Use a GTA V Legacy server From 297363c17b342f604fb9ed409322205866bc8275 Mon Sep 17 00:00:00 2001 From: Pixelated Date: Sun, 11 Oct 2026 17:53:05 -0600 Subject: [PATCH 5/6] Change logo URL in README.md Updated the logo URL in the README file. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4567b99..6607293 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![FivePRS Logo](https://cmap.pics/ibNxG/uXr_2c.png/raw) +![FivePRS Logo](https://cdn.fiveprs.org/fiveprs/forum/00-banner.png) **FivePRS** is a free, open-source public response framework for FiveM: police, fire and EMS roleplay in one resource, with server-side dispatch, real jurisdictions, records, civilian life and an in-car terminal. From 31c8cdcbcd800e3a41e939f1997da1b5f49d9da1 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Sun, 11 Oct 2026 23:57:23 -0600 Subject: [PATCH 6/6] feat: cleanup some logic --- CHANGELOG.md | 3 ++ FivePRS.Client/CoordsCommand.cs | 4 +-- FivePRS.Client/LastLocation.cs | 48 +++++++++++++++---------- FivePRS.Core/Config/VehicleConfig.cs | 6 ++-- FivePRS.Core/Events/EventNames.cs | 2 ++ FivePRS.Server/ServerBrain.Positions.cs | 33 ++++++++++++++++- FivePRS.Tests/LoadoutConfigTests.cs | 6 ++-- config/police_vehicles.json | 25 +++++++++++-- config/stations.json | 42 +++++++++++++++++++--- fxmanifest.lua | 1 + scripts/spawn.lua | 47 ++++++++++++++++++++++++ 11 files changed, 183 insertions(+), 34 deletions(-) create mode 100644 scripts/spawn.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 2330c82..5d461c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - The default police uniform no longer hides the officer's arms - `/fprs_coords` works again instead of failing with `BadImageFormatException` +- Joining puts you straight at your last location instead of spawning at the map's spawn point and then teleporting (with spawnmanager; other spawn scripts still get the teleport) +- You spawn as your last used character. A character with no saved look yet spawns as a plain male or female character, with a reminder to set up their appearance, instead of the map's ped ### Changed - The native SQLite helper is only loaded when SQLite is the database - MySQL and MariaDB now go through [oxmysql](https://github.com/overextended/oxmysql) instead of a bundled driver. Install oxmysql, set `mysql_connection_string` as `mysql://user:password@host:port/database`, and `ensure oxmysql` before `fiveprs`. `fiveprs_db_connection` is now only for a custom SQLite path - The server package is smaller: MySqlConnector and the six libraries it needed are no longer shipped - Updated duty point for the Mission Row station to be infront of the front desk. +- Updated vehicle list for ranking police officers (LSPD) ### Known issues - GTA V Enhanced servers aren't supported yet. Enhanced runs C# resources on .NET 10 in a sandbox that FivePRS doesn't run in yet; support is coming in a later release. Use a GTA V Legacy server diff --git a/FivePRS.Client/CoordsCommand.cs b/FivePRS.Client/CoordsCommand.cs index 1b06cfb..8df2ddc 100644 --- a/FivePRS.Client/CoordsCommand.cs +++ b/FivePRS.Client/CoordsCommand.cs @@ -10,7 +10,7 @@ namespace FivePRS.Client { public class CoordsCommand : BaseScript { - private static readonly string[] PointNames = { "Armory", "Locker", "Garage" }; + private static readonly string[] PointNames = { "Armory", "Locker", "Garage", "Parking" }; public CoordsCommand() { @@ -33,7 +33,7 @@ private static void Capture(string? point) var h = Format(entity.Heading); string label, text; - if (inVehicle) + if (inVehicle || pointName == "Parking") { label = "Parking spot"; text = $"[{x}, {y}, {z}, {h}]"; diff --git a/FivePRS.Client/LastLocation.cs b/FivePRS.Client/LastLocation.cs index b8d491c..312c5a1 100644 --- a/FivePRS.Client/LastLocation.cs +++ b/FivePRS.Client/LastLocation.cs @@ -12,6 +12,7 @@ namespace FivePRS.Client public class LastLocation : BaseScript { private const int ReplyTimeoutMs = 15_000; + private const int HandledTimeoutMs = 2_000; private const float MinimumMove = 5f; private const float RestoreDistance = 15f; @@ -20,35 +21,30 @@ public class LastLocation : BaseScript private int _requestedAt; private int _nextReport; private Vector3 _lastReported; + private TaskCompletionSource? _handled; public LastLocation() { - EventHandlers[EventNames.ClientLastPosition] += new Action(OnLastPosition); + EventHandlers[EventNames.ClientLastPosition] += new Action(OnLastPosition); + EventHandlers[EventNames.LocalSpawnHandled] += new Action(handled => _handled?.TrySetResult(handled)); Tick += OnTick; } private async Task OnTick() { - var settings = ConfigManager.Settings.Spawn; - if (!settings.RestoreLastLocation) - { - await Delay(5000); - return; - } - var ped = Game.PlayerPed; var now = API.GetGameTimer(); if (!_requested) { - if (ped.Exists() && DutyPanel.IsLoaded && API.IsScreenFadedIn() && !API.IsPlayerSwitchInProgress()) + if (DutyPanel.IsLoaded) { _requested = true; _requestedAt = now; ClientEvents.TriggerServer(EventNames.ServerLastPositionRequest); } - await Delay(1000); + await Delay(250); return; } @@ -59,7 +55,8 @@ private async Task OnTick() return; } - if (now >= _nextReport) + var settings = ConfigManager.Settings.Spawn; + if (settings.RestoreLastLocation && now >= _nextReport) { _nextReport = now + Math.Max(5, settings.SaveIntervalSeconds) * 1000; Report(ped); @@ -79,19 +76,27 @@ private void Report(Ped ped) ClientEvents.TriggerServer(EventNames.ServerReportPosition, position.X, position.Y, position.Z, ped.Heading); } - private async void OnLastPosition(bool found, float x, float y, float z, float heading) + private async void OnLastPosition(bool found, float x, float y, float z, float heading, string model) { try { - var ped = Game.PlayerPed; - var target = new Vector3(x, y, z); + _handled = new TaskCompletionSource(); + ClientEvents.TriggerLocal(EventNames.LocalSpawnTarget, found, x, y, z, heading, model ?? string.Empty); + var replied = await Task.WhenAny(_handled.Task, Delay(HandledTimeoutMs)); + var handled = replied == _handled.Task && _handled.Task.Result; - if (found && ped.Exists() && !ped.IsDead && !ped.IsInVehicle() && - Vector3.Distance(ped.Position, target) > RestoreDistance) + if (!handled) { - await Teleporter.ToAsync(target, heading); - ClientBrain.ShowNotification("Welcome back. You're where you left off.", "FivePRS"); + await WaitForSpawnAsync(); + var ped = Game.PlayerPed; + var target = new Vector3(x, y, z); + if (found && ped.Exists() && !ped.IsDead && !ped.IsInVehicle() && Vector3.Distance(ped.Position, target) > RestoreDistance) + await Teleporter.ToAsync(target, heading); } + + await WaitForSpawnAsync(); + if (AppearanceManager.Current is null && AppearanceManager.CharacterId != 0) + ClientBrain.ShowNotification("Your character doesn't have a look yet. Open the Characters app and choose Edit appearance.", "Characters"); } finally { @@ -99,5 +104,12 @@ private async void OnLastPosition(bool found, float x, float y, float z, float h _restored = true; } } + + private static async Task WaitForSpawnAsync() + { + var deadline = API.GetGameTimer() + ReplyTimeoutMs * 2; + while ((!Game.PlayerPed.Exists() || !API.IsScreenFadedIn() || API.IsPlayerSwitchInProgress()) && API.GetGameTimer() < deadline) + await Delay(250); + } } } diff --git a/FivePRS.Core/Config/VehicleConfig.cs b/FivePRS.Core/Config/VehicleConfig.cs index 0af1ebb..7320e0d 100644 --- a/FivePRS.Core/Config/VehicleConfig.cs +++ b/FivePRS.Core/Config/VehicleConfig.cs @@ -47,13 +47,13 @@ public sealed class AgencyVehicleDef public sealed class DepartmentVehiclesConfig { [JsonProperty("patrol")] - public VehicleTierDef Patrol { get; set; } = new() { Models = new[] { "police", "police2" } }; + public VehicleTierDef Patrol { get; set; } = new() { Models = new[] { "police", "police3", "police5" } }; [JsonProperty("senior")] - public VehicleTierDef Senior { get; set; } = new() { Models = new[] { "police2", "police4" } }; + public VehicleTierDef Senior { get; set; } = new() { Models = new[] { "police2", "police4", "policebuffalo" } }; [JsonProperty("command")] - public VehicleTierDef Command { get; set; } = new() { Models = new[] { "police3" } }; + public VehicleTierDef Command { get; set; } = new() { Models = new[] { "policebuffalo6", "polgauntlet", "policeb", "policeb2" } }; [JsonProperty("agencies", ObjectCreationHandling = ObjectCreationHandling.Replace)] public Dictionary Agencies { get; set; } = new() diff --git a/FivePRS.Core/Events/EventNames.cs b/FivePRS.Core/Events/EventNames.cs index bd0aadd..e0a902d 100644 --- a/FivePRS.Core/Events/EventNames.cs +++ b/FivePRS.Core/Events/EventNames.cs @@ -72,6 +72,8 @@ public static class EventNames public const string LocalDutyChanged = "FivePRS:Local:DutyChanged"; public const string LocalOpenMenu = "FivePRS:Local:OpenMenu"; public const string LocalHttpRequest = "FivePRS:Local:HttpRequest"; + public const string LocalSpawnTarget = "FivePRS:Local:SpawnTarget"; + public const string LocalSpawnHandled = "FivePRS:Local:SpawnHandled"; public const string LocalHttpResponse = "FivePRS:Local:HttpResponse"; public const string LocalDbRequest = "FivePRS:Local:DbRequest"; public const string LocalDbResponse = "FivePRS:Local:DbResponse"; diff --git a/FivePRS.Server/ServerBrain.Positions.cs b/FivePRS.Server/ServerBrain.Positions.cs index 8b65e69..c9d3187 100644 --- a/FivePRS.Server/ServerBrain.Positions.cs +++ b/FivePRS.Server/ServerBrain.Positions.cs @@ -3,9 +3,11 @@ using System.Linq; using System.Threading.Tasks; using CitizenFX.Core; +using FivePRS.Core.Civilian; using FivePRS.Core.Config; using FivePRS.Core.Events; using FivePRS.Core.Models; +using Newtonsoft.Json; namespace FivePRS.Server { @@ -53,8 +55,37 @@ private async void OnLastPositionRequest([FromSource] Player player) Debug.WriteLine($"[FivePRS] Could not load the last position for {player.Name}: {ex.Message}"); } + var model = string.Empty; + try + { + model = await SpawnModelAsync(license); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] Could not load the spawn model for {player.Name}: {ex.Message}"); + } + TriggerClientEvent(player, EventNames.ClientLastPosition, position is not null, - position?.X ?? 0f, position?.Y ?? 0f, position?.Z ?? 0f, position?.Heading ?? 0f); + position?.X ?? 0f, position?.Y ?? 0f, position?.Z ?? 0f, position?.Heading ?? 0f, model); + } + + private async Task SpawnModelAsync(string license) + { + if (_civilians is null || _appearances is null || !_db.IsReady) return string.Empty; + + var active = await _civilians.GetActiveCharacterAsync(license); + if (active is null) return string.Empty; + + var json = await _appearances.GetAsync(active.Id); + if (!string.IsNullOrEmpty(json)) + { + var model = JsonConvert.DeserializeObject(json!)?.Model; + if (!string.IsNullOrEmpty(model)) return model!; + } + + return string.Equals(active.Gender, "Female", StringComparison.OrdinalIgnoreCase) + ? AppearanceRules.FemaleFreemode + : AppearanceRules.MaleFreemode; } private async Task SavePositionAsync(string license) diff --git a/FivePRS.Tests/LoadoutConfigTests.cs b/FivePRS.Tests/LoadoutConfigTests.cs index 22d91be..5f9a1a6 100644 --- a/FivePRS.Tests/LoadoutConfigTests.cs +++ b/FivePRS.Tests/LoadoutConfigTests.cs @@ -25,8 +25,8 @@ public void Vehicles_Bcso_UsesSheriffModels() 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); - Assert.Equal(new[] { "police3" }, config.TierFor(null, LoadoutTier.Command).Models); + Assert.Same(config.Patrol, config.TierFor("lspd", LoadoutTier.Recruit)); + Assert.Same(config.Command, config.TierFor(null, LoadoutTier.Command)); } [Fact] @@ -36,7 +36,7 @@ public void Vehicles_AgencyMissingTier_FallsBackToDefault() config.Agencies["sasp"] = new AgencyVehicleDef { Patrol = new VehicleTierDef { Models = new[] { "police4" } } }; Assert.Equal(new[] { "police4" }, config.TierFor("sasp", LoadoutTier.Officer).Models); - Assert.Equal(new[] { "police3" }, config.TierFor("sasp", LoadoutTier.Command).Models); + Assert.Same(config.Command, config.TierFor("sasp", LoadoutTier.Command)); } [Fact] diff --git a/config/police_vehicles.json b/config/police_vehicles.json index 82a1e59..a04749b 100644 --- a/config/police_vehicles.json +++ b/config/police_vehicles.json @@ -1,7 +1,26 @@ { - "patrol": { "models": ["police", "police2"] }, - "senior": { "models": ["police2", "police4"] }, - "command": { "models": ["police3"] }, + "patrol": { + "models": [ + "police", + "police3", + "police5" + ] + }, + "senior": { + "models": [ + "police2", + "police4", + "policebuffalo" + ] + }, + "command": { + "models": [ + "policebuffalo6", + "polgauntlet", + "policeb", + "policeb2" + ] + }, "agencies": { "bcso": { "patrol": { "models": ["sheriff", "sheriff2"] }, diff --git a/config/stations.json b/config/stations.json index 8778e8f..3d204c0 100644 --- a/config/stations.json +++ b/config/stations.json @@ -2,14 +2,48 @@ "blips": "always", "stations": [ { - "id": "mission_row_pd", "name": "Mission Row Police Station", "department": "Police", "agencies": ["lspd"], - "dutyPoint": [440.89, -981.43, 30.69], "heading": 13.97, + "id": "mission_row_pd", + "name": "Mission Row Police Station", + "department": "Police", + "agencies": [ + "lspd" + ], + "dutyPoint": [440.89, -981.43, 30.69], + "heading": 13.97, "armory": [451.7, -980.1, 30.7], "locker": [452.6, -992.8, 30.7], "garage": [454.6, -1017.4, 28.4], - "parking": [[438.4, -1018.3, 27.7, 90], [441.0, -1024.2, 28.3, 90], [453.5, -1022.2, 28.0, 90], [450.9, -1016.5, 28.1, 90]] + "parking": [ + [438.4, -1018.3, 27.7, 90], + [441.0, -1024.2, 28.3, 90], + [453.5, -1022.2, 28.0, 90], + [450.9, -1016.5, 28.1, 90] + ] + }, + { + "id": "vespucci_pd", + "name": "Vespucci Police Station", + "department": "Police", + "agencies": ["lspd"], + "dutyPoint": [-1108.4, -845.0, 19.3], + "heading": 130, + "garage": [-1069.38, -851.58, 4.87], + "parking": [ + [-1112.0, -855.4, 13.15, 39.06], + [-1115.56, -857.49, 13.15, 36.32], + [-1122.86, -863.01, 13.18, 37.99], + [-1125.93, -865.23, 13.17, 39.99], + [-1068.91, -879.26, 4.48, 29.76], + [-1072.48, -881.0, 4.38, 28.74], + [-1075.87, -882.96, 4.29, 30.07], + [-1079.18, -884.83, 4.21, 29.75], + [-1039.28, -855.73, 4.49, 57.64], + [-1041.44, -858.76, 4.5, 55.63], + [-1044.94, -861.52, 4.53, 58.72], + [-1048.26, -864.48, 4.61, 59.69], + [-1051.36, -867.29, 4.74, 57.23] + ] }, - { "id": "vespucci_pd", "name": "Vespucci Police Station", "department": "Police", "agencies": ["lspd"], "dutyPoint": [-1108.4, -845.0, 19.3], "heading": 130, "parking": [] }, { "id": "la_mesa_pd", "name": "La Mesa Police Station", "department": "Police", "agencies": ["lspd"], "dutyPoint": [826.8, -1290.0, 28.2], "heading": 90, "parking": [] }, { "id": "davis_pd", "name": "Davis Police Station", "department": "Police", "agencies": ["lspd"], "dutyPoint": [360.9, -1584.7, 29.3], "heading": 50, "parking": [] }, { "id": "vinewood_pd", "name": "Vinewood Police Station", "department": "Police", "agencies": ["lspd"], "dutyPoint": [638.6, 1.7, 82.8], "heading": 250, "parking": [] }, diff --git a/fxmanifest.lua b/fxmanifest.lua index d630bb8..dc4f195 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -20,6 +20,7 @@ ui_page 'nui/index.html' client_scripts { 'scripts/relay.lua', + 'scripts/spawn.lua', 'client/FivePRS.Client.net.dll', 'client/FivePRS.Police.net.dll', 'client/FivePRS.Fire.net.dll', diff --git a/scripts/spawn.lua b/scripts/spawn.lua new file mode 100644 index 0000000..291fb6e --- /dev/null +++ b/scripts/spawn.lua @@ -0,0 +1,47 @@ +local registered = false +local firstDone = false +local waiting = false +local target = nil + +local function spawn(spot) + if spot and spot.found then + exports.spawnmanager:spawnPlayer({ + x = spot.x, + y = spot.y, + z = spot.z, + heading = spot.heading, + model = spot.model ~= '' and spot.model or nil, + }) + else + exports.spawnmanager:spawnPlayer() + end +end + +CreateThread(function() + if GetResourceState('spawnmanager') ~= 'started' then return end + + firstDone = not GetIsLoadingScreenActive() + exports.spawnmanager:setAutoSpawnCallback(function() + if firstDone then + exports.spawnmanager:spawnPlayer() + return + end + + firstDone = true + waiting = true + local deadline = GetGameTimer() + 20000 + while target == nil and GetGameTimer() < deadline do + Wait(100) + end + waiting = false + + spawn(target) + end) + registered = true +end) + +AddEventHandler('FivePRS:Local:SpawnTarget', function(found, x, y, z, heading, model) + local handled = registered and (not firstDone or waiting) + target = { found = found, x = x, y = y, z = z, heading = heading, model = model or '' } + TriggerEvent('FivePRS:Local:SpawnHandled', handled) +end)