From 2cb26f0e143d6e93f2e4f380d396bd37516af5e7 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Thu, 8 Oct 2026 12:05:50 -0600 Subject: [PATCH 01/13] fix some known issues --- FivePRS.Client/ClientBrain.cs | 40 +--- FivePRS.Client/Entry/EntryClient.cs | 209 ++++++++++++++++++ FivePRS.Client/Mdt/MdtClient.cs | 4 +- .../Vehicle/PatrolVehicleSpawner.cs | 6 +- FivePRS.Core/Config/ResourceSettings.cs | 5 +- FivePRS.Core/Events/EventNames.cs | 3 + FivePRS.Server/ServerBrain.cs | 83 ++++++- .../ClientAssemblyCompatibilityTests.cs | 59 +++++ README.md | 6 + config/settings.json | 3 +- fxmanifest.lua | 4 +- loadscreen/Magz.otf | Bin 0 -> 15748 bytes loadscreen/app.js | 70 ++++++ loadscreen/config.js | 12 + loadscreen/fxmanifest.lua | 20 ++ loadscreen/index.html | 40 ++++ loadscreen/style.css | 193 ++++++++++++++++ loadscreen/topo.svg | 1 + nui/common.js | 30 +++ nui/entry.js | 111 ++++++++++ nui/index.html | 41 +++- nui/mdt.js | 133 +++++++++++ nui/style.css | 151 +++++++++++++ publish.ps1 | 23 +- 24 files changed, 1192 insertions(+), 55 deletions(-) create mode 100644 FivePRS.Client/Entry/EntryClient.cs create mode 100644 FivePRS.Tests/ClientAssemblyCompatibilityTests.cs create mode 100644 loadscreen/Magz.otf create mode 100644 loadscreen/app.js create mode 100644 loadscreen/config.js create mode 100644 loadscreen/fxmanifest.lua create mode 100644 loadscreen/index.html create mode 100644 loadscreen/style.css create mode 100644 loadscreen/topo.svg create mode 100644 nui/common.js create mode 100644 nui/entry.js create mode 100644 nui/mdt.js diff --git a/FivePRS.Client/ClientBrain.cs b/FivePRS.Client/ClientBrain.cs index bc8b9fd..5c8112e 100644 --- a/FivePRS.Client/ClientBrain.cs +++ b/FivePRS.Client/ClientBrain.cs @@ -28,17 +28,14 @@ public ClientBrain() ConfigManager.LoadSettings( API.LoadResourceFile(res, "config/settings.json")); ConfigManager.LoadPoliceVehicles( API.LoadResourceFile(res, "config/police_vehicles.json")); ConfigManager.LoadPoliceLoadouts( API.LoadResourceFile(res, "config/police_loadouts.json")); - ConfigManager.LoadJurisdictions( API.LoadResourceFile(res, "config/jurisdictions.json")); EventHandlers[EventNames.ClientReceivePlayerData] += new Action(OnReceivePlayerData); EventHandlers[EventNames.ClientDutyStatusChanged] += new Action(OnDutyStatusChanged); - EventHandlers[EventNames.ClientCalloutOffered] += new Action(OnCalloutOffered); + EventHandlers[EventNames.ClientCalloutStarted] += new Action(OnCalloutStarted); EventHandlers[EventNames.ClientRankedUp] += new Action(OnRankedUp); - EventHandlers[EventNames.ClientEndCallout] += new Action(() => CalloutDispatcher.EndCalloutPressed = true); API.RegisterCommand("duty", new Action, string>(OnDutyCommand), false); API.RegisterCommand("setdept", new Action, string>(OnSetDeptCommand), false); - API.RegisterCommand("setagency", new Action, string>(OnSetAgencyCommand), false); API.RegisterCommand("er_accept", new Action, string>((_, __, ___) => { @@ -74,9 +71,8 @@ public ClientBrain() API.RegisterCommand("er_profile", new Action, string>((_, __, ___) => { var d = LocalPlayerData; - var agency = ConfigManager.Territories.FindAgency(d.Agency)?.Name ?? d.Department.ToString(); ShowNotification( - $"~b~{d.Name}~w~ | {agency} | Rank {d.Rank}~n~~g~XP: {d.XP} / {d.XPToNextRank}"); + $"~b~{d.Name}~w~ | {d.Department} | Rank {d.Rank}~n~~g~XP: {d.XP}"); }), false); API.RegisterCommand("er_help", new Action, string>((_, __, ___) => { @@ -84,14 +80,10 @@ public ClientBrain() "~y~FivePRS Commands~w~~n~" + "~b~/duty~w~ — Toggle on/off duty~n~" + "~b~/setdept [id]~w~ — Set department~n~" + - "~b~/setagency [id]~w~ — Set agency~n~" + "~b~/er_profile~w~ — View rank and XP~n~" + "~b~/er_accept~w~ — Accept incoming callout~n~" + "~b~/er_decline~w~ — Decline incoming callout~n~" + "~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~/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~" + "~b~/er_escort~w~ — Place suspect in your vehicle"); @@ -154,7 +146,7 @@ private void OnRankedUp(int newRank) ShowNotification($"~g~RANK UP!~w~ You are now Rank {newRank}. Keep it up!"); } - private void OnCalloutOffered(string calloutJson) + private void OnCalloutStarted(string calloutJson) { try { @@ -214,32 +206,6 @@ private void OnSetDeptCommand(int source, List args, string raw) TriggerServerEvent(EventNames.ServerSetDepartment, deptId); } - private void OnSetAgencyCommand(int source, List args, string raw) - { - if (!_profileLoaded) - { - ShowNotification("~r~Your profile hasn't loaded yet. Please wait."); - return; - } - - var agencies = ConfigManager.Territories.AgenciesFor(LocalPlayerData.Department).ToList(); - if (agencies.Count == 0) - { - ShowNotification($"~r~{LocalPlayerData.Department} has no agencies configured."); - return; - } - - var agencyId = args.Count > 0 ? args[0]?.ToString() : null; - if (string.IsNullOrEmpty(agencyId)) - { - var options = agencies.Select(a => $"~b~{a.Id}~w~ {a.Name}"); - ShowNotification("~r~Usage: ~w~/setagency [id]~n~" + string.Join("~n~", options)); - return; - } - - TriggerServerEvent(EventNames.ServerSetAgency, agencyId); - } - private static string DepartmentOptions() { var options = Enum.GetValues(typeof(Department)) diff --git a/FivePRS.Client/Entry/EntryClient.cs b/FivePRS.Client/Entry/EntryClient.cs new file mode 100644 index 0000000..ae2bc30 --- /dev/null +++ b/FivePRS.Client/Entry/EntryClient.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.Agency; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace FivePRS.Client.Entry +{ + public class EntryClient : BaseScript + { + private static readonly JsonSerializerSettings JsonSettings = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + }; + + private PlayerData? _profile; + private List? _allowed; + private bool _shownOnJoin; + private bool _open; + + public EntryClient() + { + EventHandlers[EventNames.ClientReceivePlayerData] += new Action(OnPlayerData); + EventHandlers[EventNames.ClientEntryOptions] += new Action(OnEntryOptions); + EventHandlers[EventNames.ClientEntryRejected] += new Action(OnRejected); + EventHandlers[EventNames.LocalDutyChanged] += new Action(OnDutyChanged); + EventHandlers["onClientResourceStop"] += new Action(OnResourceStop); + + API.RegisterCommand("fiveprs", new Action, string>((_, __, ___) => OpenFromCommand()), false); + + RegisterCallback("entryClose", _ => Close()); + RegisterCallback("entryEnter", data => Enter(data)); + } + + private void OnPlayerData(string json) + { + try + { + _profile = JsonConvert.DeserializeObject(json) ?? _profile; + } + catch (Exception ex) + { + Debug.WriteLine($"[EntryClient] Failed to parse player data: {ex.Message}"); + return; + } + + if (_open && _profile is not null && !_profile.IsOnDuty) + Send("open", BuildView()); + + _ = ShowOnJoinAsync(); + } + + private void OnEntryOptions(string json) + { + try + { + _allowed = JsonConvert.DeserializeObject>(json)? + .Where(id => Enum.IsDefined(typeof(Department), id)) + .Select(id => (Department)id) + .ToList(); + } + catch (Exception ex) + { + Debug.WriteLine($"[EntryClient] Failed to parse entry options: {ex.Message}"); + return; + } + + _ = ShowOnJoinAsync(); + } + + private async Task ShowOnJoinAsync() + { + if (_shownOnJoin || _profile is null || _allowed is null) return; + _shownOnJoin = true; + + if (!ConfigManager.Settings.ShowEntryScreen || _profile.IsOnDuty) return; + + while (API.GetIsLoadingScreenActive() || API.IsScreenFadedOut() || !Game.PlayerPed.Exists()) + await Delay(250); + + await Delay(1000); + if (_profile is not null && !_profile.IsOnDuty) + Open(); + } + + private void OpenFromCommand() + { + if (_profile is null || _allowed is null) + { + ClientBrain.ShowNotification("~r~Your profile hasn't loaded yet. Please wait."); + return; + } + + if (_profile.IsOnDuty) + { + ClientBrain.ShowNotification("~r~Go off duty before changing your department or agency."); + return; + } + + Open(); + } + + private void Open() + { + _open = true; + API.SetNuiFocus(true, true); + Send("open", BuildView()); + } + + private void Close() + { + if (!_open) return; + + _open = false; + API.SetNuiFocus(false, false); + Send("close", null); + } + + private static void Enter(IDictionary data) + { + if (!data.TryGetValue("departmentId", out var rawDepartment) || + !int.TryParse(rawDepartment?.ToString(), out var departmentId)) + { + return; + } + + var agencyId = data.TryGetValue("agencyId", out var rawAgency) ? rawAgency?.ToString() ?? string.Empty : string.Empty; + TriggerServerEvent(EventNames.ServerEnterService, departmentId, agencyId); + } + + private void OnRejected(string message) + { + if (_open) + Send("rejected", message); + else + ClientBrain.ShowNotification($"~r~{message}"); + } + + private void OnDutyChanged(bool isOnDuty, int departmentId) + { + if (_profile is not null) + _profile.IsOnDuty = isOnDuty; + + if (isOnDuty) + Close(); + } + + private void OnResourceStop(string resourceName) + { + if (resourceName == API.GetCurrentResourceName() && _open) + API.SetNuiFocus(false, false); + } + + private object BuildView() + { + var profile = _profile ?? new PlayerData(); + var map = ConfigManager.Territories; + + var departments = (_allowed ?? new List()) + .Where(d => d != Department.None && BaseAgency.IsDepartmentLoaded(d)) + .Select(d => new + { + Id = (int)d, + Name = d.ToString(), + Agencies = map.AgenciesFor(d).Select(a => new { a.Id, a.Name, a.CallsignPrefix }).ToList(), + }) + .ToList(); + + return new + { + profile.Name, + profile.Rank, + profile.XP, + XpToNext = profile.XPToNextRank, + DepartmentId = (int)profile.Department, + AgencyId = profile.Agency, + Departments = departments, + }; + } + + private static void Send(string type, object? payload) => + API.SendNuiMessage(JsonConvert.SerializeObject(new { screen = "entry", type, payload }, JsonSettings)); + + private void RegisterCallback(string name, Action> handler) + { + API.RegisterNuiCallbackType(name); + EventHandlers[$"__cfx_nui:{name}"] += new Action, CallbackDelegate>((data, callback) => + { + try + { + handler(data); + } + catch (Exception ex) + { + Debug.WriteLine($"[EntryClient] NUI callback '{name}' failed: {ex.Message}"); + } + + callback("ok"); + }); + } + } +} diff --git a/FivePRS.Client/Mdt/MdtClient.cs b/FivePRS.Client/Mdt/MdtClient.cs index 8999b95..f767fba 100644 --- a/FivePRS.Client/Mdt/MdtClient.cs +++ b/FivePRS.Client/Mdt/MdtClient.cs @@ -26,7 +26,7 @@ public MdtClient() API.RegisterCommand("mdt", new Action, string>((_, __, ___) => Toggle()), false); API.RegisterKeyMapping("mdt", "FivePRS: Open MDT", "keyboard", "F7"); - RegisterCallback("close", _ => Close()); + RegisterCallback("mdtClose", _ => Close()); RegisterCallback("setStatus", data => SetStatus(data)); RegisterCallback("attach", data => Attach(data)); RegisterCallback("waypoint", data => Waypoint(data)); @@ -159,7 +159,7 @@ private static object BuildView() } private static void Send(string type, object? payload) => - API.SendNuiMessage(JsonConvert.SerializeObject(new { type, payload }, JsonSettings)); + API.SendNuiMessage(JsonConvert.SerializeObject(new { screen = "mdt", type, payload }, JsonSettings)); private void RegisterCallback(string name, Action> handler) { diff --git a/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs b/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs index b452189..2bdb46a 100644 --- a/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs +++ b/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs @@ -27,7 +27,7 @@ public sealed class PatrolVehicleConfig public string PlateText { get; set; } = string.Empty; } - public readonly struct SpawnPoint + public sealed class SpawnPoint { public Vector3 Position { get; } public float Heading { get; } @@ -130,8 +130,8 @@ private static SpawnPoint FindSpawnPoint() } } - if (nearest.HasValue) - return nearest.Value; + if (nearest is not null) + return nearest; var streetPos = CitizenFX.Core.World.GetNextPositionOnStreet(playerPos); return new SpawnPoint(streetPos, API.GetEntityHeading(Game.PlayerPed.Handle)); diff --git a/FivePRS.Core/Config/ResourceSettings.cs b/FivePRS.Core/Config/ResourceSettings.cs index 768d561..d2737e6 100644 --- a/FivePRS.Core/Config/ResourceSettings.cs +++ b/FivePRS.Core/Config/ResourceSettings.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; namespace FivePRS.Core.Config { @@ -24,5 +24,8 @@ public sealed class ResourceSettings [JsonProperty("noCalloutRetrySeconds")] public int NoCalloutRetrySeconds { get; set; } = 30; + + [JsonProperty("showEntryScreen")] + public bool ShowEntryScreen { get; set; } = true; } } diff --git a/FivePRS.Core/Events/EventNames.cs b/FivePRS.Core/Events/EventNames.cs index a64383a..344752d 100644 --- a/FivePRS.Core/Events/EventNames.cs +++ b/FivePRS.Core/Events/EventNames.cs @@ -9,11 +9,14 @@ public static class EventNames public const string ClientRankedUp = "FivePRS:Client:RankedUp"; public const string ClientNotify = "FivePRS:Client:Notify"; public const string ClientEndCallout = "FivePRS:Client:EndCallout"; + public const string ClientEntryOptions = "FivePRS:Client:EntryOptions"; + public const string ClientEntryRejected = "FivePRS:Client:EntryRejected"; public const string ServerPlayerConnected = "FivePRS:Server:PlayerConnected"; public const string ServerToggleDuty = "FivePRS:Server:ToggleDuty"; public const string ServerSetDepartment = "FivePRS:Server:SetDepartment"; public const string ServerSetAgency = "FivePRS:Server:SetAgency"; + public const string ServerEnterService = "FivePRS:Server:EnterService"; public const string ServerRegisterCallouts = "FivePRS:Server:RegisterCallouts"; public const string ServerCalloutResponse = "FivePRS:Server:CalloutResponse"; public const string ServerCalloutEnded = "FivePRS:Server:CalloutEnded"; diff --git a/FivePRS.Server/ServerBrain.cs b/FivePRS.Server/ServerBrain.cs index 7b97ee0..d3a20ad 100644 --- a/FivePRS.Server/ServerBrain.cs +++ b/FivePRS.Server/ServerBrain.cs @@ -49,6 +49,7 @@ public ServerBrain() EventHandlers[EventNames.ServerToggleDuty] += new Action(OnToggleDuty); EventHandlers[EventNames.ServerSetDepartment] += new Action(OnSetDepartment); EventHandlers[EventNames.ServerSetAgency] += new Action(OnSetAgency); + EventHandlers[EventNames.ServerEnterService] += new Action(OnEnterService); EventHandlers[EventNames.ServerRegisterCallouts] += new Action(OnRegisterCallouts); EventHandlers[EventNames.ServerCalloutResponse] += new Action(OnCalloutResponse); EventHandlers[EventNames.ServerCalloutEnded] += new Action(OnCalloutEnded); @@ -105,7 +106,8 @@ private async void OnPlayerConnecting( try { - await LoadProfileAsync(license, playerName); + var data = await LoadProfileAsync(license, playerName); + HandOverProfile(deferrals, data); deferrals.done(); } catch (Exception ex) @@ -140,6 +142,7 @@ private async void OnPlayerReady([FromSource] Player player) } SendPlayerData(player, data); + SendEntryOptions(player); } catch (Exception ex) { @@ -147,6 +150,84 @@ private async void OnPlayerReady([FromSource] Player player) } } + private async void OnEnterService([FromSource] Player player, int departmentId, string agencyId) + { + if (!TryGetCached(player, out var license, out var data) || data.IsOnDuty) return; + if (!Enum.IsDefined(typeof(Department), departmentId) || departmentId == (int)Department.None) return; + + var department = (Department)departmentId; + if (!_permissions.CanJoinDepartment(player.Handle, department)) + { + TriggerClientEvent(player, EventNames.ClientEntryRejected, $"You are not authorised to join {department}."); + Audit(AuditActions.PermissionDenied, player, license, $"department {department}"); + return; + } + + try + { + var agency = ConfigManager.Territories.ResolveAgency(department, agencyId)?.Id ?? string.Empty; + + if (data.Department != department) + { + data.Department = department; + Audit(AuditActions.DepartmentSet, player, license, department.ToString()); + } + + if (data.Agency != agency) + { + data.Agency = agency; + Audit(AuditActions.AgencySet, player, license, agency); + } + + await _db.SavePlayerAsync(data); + SendPlayerData(player, data); + + await SetDutyAsync(player, data, true); + Audit(AuditActions.DutyOn, player, license, department.ToString()); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] Error entering service for {player.Name}: {ex.Message}"); + TriggerClientEvent(player, EventNames.ClientEntryRejected, "Something went wrong. Please try again."); + } + } + + private void SendEntryOptions(Player player) + { + var allowed = new List(); + foreach (Department department in Enum.GetValues(typeof(Department))) + { + if (_permissions.CanJoinDepartment(player.Handle, department)) + allowed.Add((int)department); + } + + TriggerClientEvent(player, EventNames.ClientEntryOptions, JsonConvert.SerializeObject(allowed)); + } + + private static void HandOverProfile(dynamic deferrals, PlayerData data) + { + try + { + var agency = data.Department == Department.None + ? string.Empty + : ConfigManager.Territories.FindAgency(data.Agency)?.Name ?? data.Department.ToString(); + + deferrals.handover(new Dictionary + { + ["fiveprs"] = new Dictionary + { + ["name"] = data.Name, + ["rank"] = data.Rank, + ["agency"] = agency, + }, + }); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] Loading screen handover failed: {ex.Message}"); + } + } + private async void OnToggleDuty([FromSource] Player player) { if (!TryGetCached(player, out var license, out var data)) return; diff --git a/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs b/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs new file mode 100644 index 0000000..d3ae7bb --- /dev/null +++ b/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Xunit; + +namespace FivePRS.Tests +{ + public class ClientAssemblyCompatibilityTests + { + private static readonly string[] UnsupportedEmbeddedTypes = + { + "IsReadOnlyAttribute", + "IsByRefLikeAttribute", + "IsUnmanagedAttribute", + "RequiresLocationAttribute", + "RefSafetyRulesAttribute", + "ScopedRefAttribute", + "IsExternalInit", + }; + + public static IEnumerable ClientAssemblies() + { + var dir = Path.Combine(RepoRoot(), "bin", "client"); + if (!Directory.Exists(dir)) + throw new InvalidOperationException($"{dir} not found; build FivePRS.sln before running tests."); + + return Directory.GetFiles(dir, "FivePRS.*.dll").Select(path => new object[] { Path.GetFileName(path) }); + } + + [Theory] + [MemberData(nameof(ClientAssemblies))] + public void ClientAssembly_HasNoMetadataUnsupportedByFiveMMono(string fileName) + { + using var stream = File.OpenRead(Path.Combine(RepoRoot(), "bin", "client", fileName)); + using var pe = new PEReader(stream); + var reader = pe.GetMetadataReader(); + + var embedded = reader.TypeDefinitions + .Select(handle => reader.GetString(reader.GetTypeDefinition(handle).Name)) + .Where(UnsupportedEmbeddedTypes.Contains) + .ToList(); + + Assert.True(embedded.Count == 0, + $"{fileName} embeds {string.Join(", ", embedded)}, which FiveM's client runtime rejects with BadImageFormatException. " + + "Avoid readonly structs, readonly members, in/ref readonly parameters, ref structs, unmanaged constraints and init accessors in client code."); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "FivePRS.sln"))) + dir = dir.Parent; + return dir!.FullName; + } + } +} diff --git a/README.md b/README.md index 5abf0cb..8df0297 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ Admin commands work from the server console or in game with `fiveprs.admin`: | `fprs_offduty ` | Force a player off duty | | `fprs_endcall ` | Close an active call | +## Joining & Entry Screen +When a player spawns, an entry screen shows their rank and XP and lets them pick a department and agency, then go on duty or continue as a civilian. Only departments the player is permitted to join are offered. Reopen it with `/fiveprs` while off duty, or turn it off with `"showEntryScreen": false` in `config/settings.json`. + +## Loading Screen +`fiveprs_loadscreen` is an optional companion resource with a FivePRS-branded loading screen: load progress, rotating tips and a welcome back card with the player's agency and rank. Add `ensure fiveprs_loadscreen` to `server.cfg` to use it, and set your server name and tips in its `config.js`. Leave it out if your server already has a loading screen. + ## Mobile Data Terminal On-duty units open the MDT with `F7` or `/mdt`. It shows your unit and status, every active call with its territory and assigned units, and all units on duty. From the MDT you can set your status, attach to a call as backup and set a waypoint to a call. The interface lives in `nui/` as plain HTML, CSS and JavaScript, so it can be restyled without rebuilding the resource. diff --git a/config/settings.json b/config/settings.json index 0029518..ff6af9d 100644 --- a/config/settings.json +++ b/config/settings.json @@ -5,5 +5,6 @@ "postCompleteCooldownSeconds": 60, "postDeclineCooldownSeconds": 45, "postFailCooldownSeconds": 30, - "noCalloutRetrySeconds": 30 + "noCalloutRetrySeconds": 30, + "showEntryScreen": true } diff --git a/fxmanifest.lua b/fxmanifest.lua index 60abf5b..8a836e0 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -24,7 +24,9 @@ client_scripts { files { 'nui/index.html', 'nui/style.css', - 'nui/app.js', + 'nui/common.js', + 'nui/mdt.js', + 'nui/entry.js', 'client/FivePRS.Core.dll', 'client/Newtonsoft.Json.dll', 'plugins/*.dll', diff --git a/loadscreen/Magz.otf b/loadscreen/Magz.otf new file mode 100644 index 0000000000000000000000000000000000000000..72027a70c4f47fe6baf994487dba6690bc567f02 GIT binary patch literal 15748 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 { + tipElement.textContent = tips[tipIndex % tips.length]; + tipElement.classList.remove("fading"); + tipIndex += 1; + }, 400); +} + +function showWelcome() { + const profile = window.nuiHandoverData?.fiveprs; + if (!profile || !profile.name) return; + + document.getElementById("welcome-name").textContent = profile.name; + + const details = [profile.agency, profile.rank ? `Rank ${profile.rank}` : null].filter(Boolean); + document.getElementById("welcome-detail").textContent = + details.length > 0 ? details.join(" · ") : "Choose your department once you spawn."; + + document.getElementById("welcome").hidden = false; +} + +const stages = { + startInitFunctionOrder: "Initialising", + startDataFileEntries: "Loading game data", + performMapLoadFunction: "Loading map", +}; + +window.addEventListener("message", (event) => { + const data = event.data ?? {}; + + if (data.eventName === "loadProgress") { + setProgress(data.loadFraction ?? 0); + } else if (stages[data.eventName]) { + setStatus(stages[data.eventName]); + } else if (data.eventName === "onLogLine" && typeof data.message === "string") { + setStatus(data.message.replace(/\.+$/, "")); + } +}); + +showWelcome(); +showTip(); +setInterval(showTip, 7000); diff --git a/loadscreen/config.js b/loadscreen/config.js new file mode 100644 index 0000000..b2034bd --- /dev/null +++ b/loadscreen/config.js @@ -0,0 +1,12 @@ +window.FIVEPRS_LOADSCREEN = { + serverName: "", + tips: [ + "Press F5 to go on or off duty.", + "Press F7 to open the Mobile Data Terminal while on duty.", + "Use /er_calls to see active calls and /er_attach to respond as backup.", + "Press G near a suspect to cuff them and H in a vehicle to escort them.", + "Use /setagency to switch between agencies in your department.", + "Units only receive calls inside their agency's jurisdiction.", + "Press F6 to check your rank and XP progress.", + ], +}; diff --git a/loadscreen/fxmanifest.lua b/loadscreen/fxmanifest.lua new file mode 100644 index 0000000..892bf77 --- /dev/null +++ b/loadscreen/fxmanifest.lua @@ -0,0 +1,20 @@ +fx_version 'cerulean' +game 'gta5' + +name 'fiveprs_loadscreen' +author 'Pixel ' +description 'Optional FivePRS loading screen.' +repository 'https://github.com/FivePRS/core' +license 'AGPL-3.0-or-later' +version '1.0.0' + +loadscreen 'index.html' + +files { + 'index.html', + 'style.css', + 'config.js', + 'app.js', + 'topo.svg', + 'Magz.otf', +} diff --git a/loadscreen/index.html b/loadscreen/index.html new file mode 100644 index 0000000..ec7948d --- /dev/null +++ b/loadscreen/index.html @@ -0,0 +1,40 @@ + + + + + FivePRS + + + + + + + +
+

+

fiveprs

+

FiveM Public Response System

+ + +
+ +
+

+ +
+ Connecting + 0% +
+
+ + + + + diff --git a/loadscreen/style.css b/loadscreen/style.css new file mode 100644 index 0000000..623b9ef --- /dev/null +++ b/loadscreen/style.css @@ -0,0 +1,193 @@ +@font-face { + font-family: "Magz"; + src: url("Magz.otf") format("opentype"); +} + +:root { + --bg: #07090f; + --text: #eef2fb; + --muted: rgba(238, 242, 251, 0.62); + --subtle: rgba(238, 242, 251, 0.42); + --line: rgba(255, 255, 255, 0.1); + --red: #ef4444; + --blue: #3b82f6; + --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; +} + +[hidden] { + display: none !important; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; + overflow: hidden; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + user-select: none; +} + +.backdrop, +.topo, +.siren { + position: fixed; + inset: 0; + pointer-events: none; +} + +.topo { + background: url("topo.svg") center / cover no-repeat; + mask-image: radial-gradient(ellipse 70% 65% at 50% 45%, black 30%, transparent 85%); + -webkit-mask-image: radial-gradient(ellipse 70% 65% at 50% 45%, black 30%, transparent 85%); +} + +.siren { + opacity: 0.3; + animation: siren 3.2s ease-in-out infinite; +} + +.siren-red { + background: radial-gradient(ellipse 35% 55% at 36% 20%, rgba(239, 68, 68, 0.2), transparent 70%); +} + +.siren-blue { + background: radial-gradient(ellipse 35% 55% at 64% 20%, rgba(59, 130, 246, 0.22), transparent 70%); + animation-delay: -1.6s; +} + +@keyframes siren { + 0%, 100% { opacity: 0.3; } + 10% { opacity: 1; } + 20% { opacity: 0.4; } + 30% { opacity: 0.9; } + 50% { opacity: 0.3; } +} + +.lightbar { + position: fixed; + inset: 0 0 auto 0; + height: 4px; + background: linear-gradient(90deg, var(--red) 0 50%, var(--blue) 50% 100%); +} + +.content { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-bottom: 12vh; + text-align: center; +} + +.eyebrow { + font-family: var(--mono); + font-size: 13px; + letter-spacing: 0.3em; + text-transform: uppercase; + color: var(--blue); +} + +.wordmark { + margin-top: 18px; + font-family: "Magz", system-ui, sans-serif; + font-weight: 400; + font-size: clamp(72px, 12vw, 180px); + line-height: 0.9; +} + +.wordmark .red { color: var(--red); } +.wordmark .blue { color: var(--blue); } + +.tagline { + margin-top: 20px; + font-family: var(--mono); + font-size: 15px; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--muted); +} + +.welcome { + margin-top: 44px; + padding: 16px 26px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(12, 16, 25, 0.75); +} + +.welcome-title { + font-size: 18px; + font-weight: 600; +} + +.welcome-detail { + margin-top: 6px; + font-size: 14px; + color: var(--muted); +} + +.footer { + position: fixed; + left: 50%; + bottom: 7vh; + width: min(720px, 80vw); + transform: translateX(-50%); +} + +.tip { + min-height: 1.5em; + margin-bottom: 18px; + text-align: center; + font-size: 15px; + color: var(--muted); + transition: opacity 0.4s ease; +} + +.tip.fading { + opacity: 0; +} + +.progress { + height: 4px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.progress-bar { + width: 0; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--red), var(--blue)); + transition: width 0.4s ease; +} + +.status { + display: flex; + justify-content: space-between; + gap: 16px; + margin-top: 10px; + font-family: var(--mono); + font-size: 12px; + color: var(--subtle); +} + +#status-text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +@media (prefers-reduced-motion: reduce) { + .siren { animation: none; } + .tip, .progress-bar { transition: none; } +} diff --git a/loadscreen/topo.svg b/loadscreen/topo.svg new file mode 100644 index 0000000..5a0d855 --- /dev/null +++ b/loadscreen/topo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nui/common.js b/nui/common.js new file mode 100644 index 0000000..3b6c291 --- /dev/null +++ b/nui/common.js @@ -0,0 +1,30 @@ +const resource = typeof GetParentResourceName === "function" ? GetParentResourceName() : "fiveprs"; + +function post(name, data = {}) { + return fetch(`https://${resource}/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=UTF-8" }, + body: JSON.stringify(data), + }).catch(() => {}); +} + +function el(tag, options = {}, children = []) { + const node = document.createElement(tag); + if (options.className) node.className = options.className; + if (options.text !== undefined && options.text !== null) node.textContent = String(options.text); + if (options.dataset) Object.assign(node.dataset, options.dataset); + if (options.disabled) node.disabled = true; + for (const child of children) if (child) node.append(child); + return node; +} + +function setText(id, value) { + document.getElementById(id).textContent = value ?? "—"; +} + +const screens = {}; + +window.addEventListener("message", (event) => { + const { screen, type, payload } = event.data ?? {}; + screens[screen]?.[type]?.(payload); +}); diff --git a/nui/entry.js b/nui/entry.js new file mode 100644 index 0000000..478bb4d --- /dev/null +++ b/nui/entry.js @@ -0,0 +1,111 @@ +const entry = document.getElementById("entry"); +const enterButton = document.getElementById("entry-enter"); +let entryView = null; +let selectedDepartment = null; +let selectedAgency = null; +let entering = false; + +function departmentById(id) { + return entryView?.departments.find((department) => department.id === id) ?? null; +} + +function selectDepartment(id) { + const department = departmentById(id); + selectedDepartment = department ? department.id : null; + + const agencies = department?.agencies ?? []; + if (!agencies.some((agency) => agency.id === selectedAgency)) { + const current = agencies.find((agency) => agency.id === entryView.agencyId); + selectedAgency = (current ?? agencies[0])?.id ?? null; + } +} + +function optionButton(name, detail, selected, dataset) { + return el("button", { className: `option${selected ? " selected" : ""}`, dataset }, [ + el("span", { className: "option-name", text: name }), + detail ? el("span", { className: "option-detail", text: detail }) : null, + ]); +} + +function renderEntry() { + const view = entryView; + const returning = view.departmentId !== 0 || view.rank > 1 || view.xp > 0; + + document.getElementById("entry-title").textContent = returning + ? `Welcome back, ${view.name}` + : `Welcome to FivePRS, ${view.name}`; + setText("entry-rank", view.rank); + setText("entry-xp", `${view.xp} / ${view.xpToNext} XP`); + document.getElementById("entry-xp-bar").style.width = + `${Math.min(100, (view.xp / Math.max(1, view.xpToNext)) * 100).toFixed(1)}%`; + + const departments = view.departments ?? []; + document.getElementById("entry-departments").replaceChildren( + ...departments.map((department) => + optionButton(department.name, null, department.id === selectedDepartment, { department: department.id }) + ) + ); + + const agencies = departmentById(selectedDepartment)?.agencies ?? []; + document.getElementById("entry-agency-title").hidden = agencies.length === 0; + document.getElementById("entry-agencies").replaceChildren( + ...agencies.map((agency) => + optionButton(agency.name, agency.callsignPrefix, agency.id === selectedAgency, { agency: agency.id }) + ) + ); + + document.getElementById("entry-empty").hidden = departments.length > 0; + enterButton.disabled = entering || selectedDepartment === null; + enterButton.textContent = entering ? "Going on duty…" : "Go on duty"; +} + +function showError(message) { + const error = document.getElementById("entry-error"); + error.textContent = message ?? ""; + error.hidden = !message; +} + +screens.entry = { + open(payload) { + entryView = payload; + entering = false; + selectedAgency = payload.agencyId || null; + selectDepartment(departmentById(payload.departmentId) ? payload.departmentId : payload.departments[0]?.id); + showError(null); + entry.hidden = false; + renderEntry(); + }, + rejected(message) { + entering = false; + showError(message); + renderEntry(); + }, + close() { + entry.hidden = true; + }, +}; + +entry.addEventListener("click", (event) => { + const target = event.target.closest("button"); + if (!target || target.disabled) return; + + if (target.dataset.department) { + selectDepartment(Number(target.dataset.department)); + showError(null); + renderEntry(); + } else if (target.dataset.agency) { + selectedAgency = target.dataset.agency; + renderEntry(); + } else if (target.id === "entry-civilian") { + post("entryClose"); + } else if (target.id === "entry-enter") { + entering = true; + showError(null); + renderEntry(); + post("entryEnter", { departmentId: selectedDepartment, agencyId: selectedAgency ?? "" }); + } +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !entry.hidden && !entering) post("entryClose"); +}); diff --git a/nui/index.html b/nui/index.html index fa8f320..4f04b38 100644 --- a/nui/index.html +++ b/nui/index.html @@ -6,6 +6,43 @@ + + - + + + diff --git a/nui/mdt.js b/nui/mdt.js new file mode 100644 index 0000000..3d691b7 --- /dev/null +++ b/nui/mdt.js @@ -0,0 +1,133 @@ +const statusLabels = { + Available: "Available", + EnRoute: "En route", + OnScene: "On scene", + Busy: "Busy", +}; + +const root = document.getElementById("mdt"); +let view = null; + +function statusPill(status) { + return el("span", { className: `status status-${status.toLowerCase()}`, text: statusLabels[status] ?? status }); +} + +function renderSelf(self) { + setText("self-callsign", self.callsign); + setText("self-name", self.name); + setText("self-agency", self.agency); + setText("self-territory", self.territory ?? "Unincorporated"); + setText("self-call", self.callId ? `#${self.callId}` : "None"); + + const status = document.getElementById("self-status"); + status.replaceChildren(statusPill(self.status)); + + const leadsCall = (view.calls ?? []).some((call) => call.isPrimary); + for (const button of document.querySelectorAll("[data-status]")) { + button.classList.toggle("active", button.dataset.status === self.status); + button.disabled = leadsCall; + } + document.getElementById("status-hint").textContent = leadsCall + ? "End your active callout to change status." + : "En route and on scene update automatically from your position."; +} + +function renderCalls(calls, self) { + const body = document.getElementById("calls"); + const rows = calls.map((call) => { + const mine = self.callId === call.id; + const canAttach = !self.callId; + + const actions = el("div", { className: "actions" }, [ + call.hasLocation + ? el("button", { className: "button small", text: "Waypoint", dataset: { action: "waypoint", call: call.id } }) + : null, + mine + ? null + : el("button", { + className: "button small", + text: "Attach", + dataset: { action: "attach", call: call.id }, + disabled: !canAttach, + }), + ]); + + const row = el("tr", { className: mine ? "mine" : "" }, [ + el("td", { className: "mono", text: `#${call.id}` }), + el("td", {}, [ + el("span", { text: call.name }), + el("span", { className: `sub code-${call.code}`, text: `Code ${call.code}` }), + ]), + el("td", { className: "muted", text: call.territory ?? "—" }), + el("td", {}, [el("div", { className: "chips" }, call.units.map((unit) => el("span", { className: "chip", text: unit })))]), + el("td", {}, [actions]), + ]); + return row; + }); + + body.replaceChildren(...rows); + document.getElementById("calls-empty").hidden = calls.length > 0; + setText("call-count", `${calls.length} active`); +} + +function renderUnits(units, self) { + const body = document.getElementById("units"); + const rows = units.map((unit) => + el("tr", { className: unit.serverId === self.serverId ? "mine" : "" }, [ + el("td", { className: "mono", text: unit.callsign }), + el("td", {}, [ + el("span", { text: unit.name }), + el("span", { className: "sub muted", text: unit.agency }), + ]), + el("td", { className: "muted", text: unit.territory ?? "—" }), + el("td", {}, [statusPill(unit.status), unit.callId ? el("span", { className: "sub mono muted", text: `#${unit.callId}` }) : null]), + ]) + ); + + body.replaceChildren(...rows); + setText("unit-count", `${units.length} on duty`); +} + +function render() { + if (!view || !view.self) return; + renderSelf(view.self); + renderCalls(view.calls ?? [], view.self); + renderUnits(view.units ?? [], view.self); +} + +function tickClock() { + const now = new Date(); + document.getElementById("clock").textContent = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +screens.mdt = { + open(payload) { + view = payload; + root.hidden = false; + tickClock(); + render(); + }, + update(payload) { + view = payload; + render(); + }, + close() { + root.hidden = true; + }, +}; + +root.addEventListener("click", (event) => { + const target = event.target.closest("button"); + if (!target || target.disabled) return; + + if (target.id === "close") post("mdtClose"); + else if (target.dataset.status) post("setStatus", { status: target.dataset.status }); + else if (target.dataset.action === "attach") post("attach", { callId: target.dataset.call }); + else if (target.dataset.action === "waypoint") post("waypoint", { callId: target.dataset.call }); +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !root.hidden) post("mdtClose"); +}); + +setInterval(tickClock, 15000); diff --git a/nui/style.css b/nui/style.css index 7d40c4c..b19eed0 100644 --- a/nui/style.css +++ b/nui/style.css @@ -324,3 +324,154 @@ tr.mine td { background: rgba(255, 255, 255, 0.12); border-radius: 3px; } + +.entry { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(ellipse at center, rgba(7, 9, 15, 0.55), rgba(7, 9, 15, 0.85)); +} + +.entry-card { + position: relative; + width: min(620px, 92vw); + padding: 34px 32px 28px; + overflow: hidden; + background: var(--bg); + border: 1px solid var(--line-strong); + border-radius: 16px; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6); +} + +.entry-title { + margin-top: 8px; + font-size: 26px; + font-weight: 650; + letter-spacing: -0.01em; +} + +.entry-profile { + display: grid; + grid-template-columns: auto 1fr; + gap: 28px; + align-items: end; + margin-top: 22px; + padding: 16px 18px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 10px; +} + +.entry-label { + font-size: 12px; + color: var(--subtle); +} + +.entry-rank { + margin-top: 2px; + font-family: var(--mono); + font-size: 24px; + font-weight: 700; +} + +.entry-xp-head { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.xp-track { + height: 6px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.xp-bar { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--red), var(--blue)); +} + +.entry-section { + margin: 24px 0 10px; +} + +.option-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.option-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 8px; +} + +.option { + padding: 10px 14px; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: transparent; + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.option:hover { + background: rgba(255, 255, 255, 0.05); +} + +.option.selected { + border-color: rgba(59, 130, 246, 0.7); + background: rgba(59, 130, 246, 0.14); +} + +.option-name { + display: block; + font-weight: 600; +} + +.option-detail { + display: block; + margin-top: 2px; + font-family: var(--mono); + font-size: 11px; + color: var(--subtle); +} + +.entry-message { + margin-top: 18px; + font-size: 13px; + color: var(--muted); +} + +.entry-error { + color: #fca5a5; +} + +.entry-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 28px; +} + +.entry-actions .button { + height: 40px; + padding: 0 18px; +} + +.button.primary { + border-color: #fff; + background: #fff; + color: #000; +} + +.button.primary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.88); +} diff --git a/publish.ps1 b/publish.ps1 index 76566eb..f070d62 100644 --- a/publish.ps1 +++ b/publish.ps1 @@ -2,8 +2,10 @@ $ErrorActionPreference = "Stop" $Root = $PSScriptRoot -$DistDir = Join-Path $Root "dist\FivePRS" -$ZipOut = Join-Path $Root "dist\FivePRS.zip" +$DistDir = Join-Path $Root "dist\FivePRS" +$ZipOut = Join-Path $Root "dist\FivePRS.zip" +$LoadscreenDir = Join-Path $Root "dist\fiveprs_loadscreen" +$LoadscreenZip = Join-Path $Root "dist\fiveprs_loadscreen.zip" function Info($msg) { Write-Host "[FivePRS] $msg" -ForegroundColor Cyan } function Ok($msg) { Write-Host "[FivePRS] $msg" -ForegroundColor Green } @@ -108,14 +110,18 @@ if ($missing.Count -gt 0) { Ok "All manifest references satisfied." } -# 8. Zip the dist folder -Info "Creating zip archive..." -if (Test-Path $ZipOut) { Remove-Item $ZipOut -Force } +# 8. Copy the optional loading screen resource +Info "Copying loading screen resource..." +Copy-Item -Recurse "$Root\loadscreen" $LoadscreenDir + +# 9. Zip both resources +Info "Creating zip archives..." Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::CreateFromDirectory($DistDir, $ZipOut) -Ok "Zip created -> $ZipOut" +[System.IO.Compression.ZipFile]::CreateFromDirectory($LoadscreenDir, $LoadscreenZip) +Ok "Zips created -> $ZipOut, $LoadscreenZip" -# 9. Summary +# 10. Summary $fileCount = (Get-ChildItem -Recurse -File $DistDir).Count $bytes = (Get-ChildItem -Recurse -File $DistDir | Measure-Object -Property Length -Sum).Sum $distMB = [math]::Round($bytes / 1MB, 2) @@ -126,12 +132,13 @@ Ok "Published -> $DistDir ($fileCount files, ~${distMB} MB)" Ok "Zipped -> $ZipOut (~${zipMB} MB)" Write-Host "" Write-Host "Next steps:" -ForegroundColor Yellow -Write-Host " 1. Extract FivePRS.zip into your server resources\ directory." -ForegroundColor Yellow +Write-Host " 1. Extract FivePRS.zip (and optionally fiveprs_loadscreen.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_connection "" # MySQL only' -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 " ensure fiveprs_loadscreen # optional branded loading screen" -ForegroundColor Gray Write-Host "" Write-Host " plugins\ <- drop functionality extensions here" -ForegroundColor Yellow Write-Host " callouts\ <- drop scenario packs here" -ForegroundColor Yellow From 4432e1626eb3ee459b916bb54dd2a7a4fbc0ff75 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Fri, 9 Oct 2026 09:52:44 -0600 Subject: [PATCH 02/13] updated some more stuff --- .github/CONTRIBUTING.md | 12 ++- FivePRS.Client/Callouts/CalloutBase.cs | 21 ++++- FivePRS.Client/Callouts/CalloutRegistry.cs | 12 ++- FivePRS.Client/ClientBrain.cs | 40 ++++++++- FivePRS.Server/Database/DatabaseManager.cs | 1 + FivePRS.Server/Database/SqliteNativeLoader.cs | 57 ++++++++++++ FivePRS.Server/FivePRS.Server.csproj | 2 +- .../ClientAssemblyCompatibilityTests.cs | 17 ++++ README.md | 15 +++- .../fiveprs_loadscreen}/Magz.otf | Bin .../fiveprs_loadscreen}/app.js | 0 .../fiveprs_loadscreen}/config.js | 0 .../fiveprs_loadscreen}/fxmanifest.lua | 0 .../fiveprs_loadscreen}/index.html | 0 .../fiveprs_loadscreen}/style.css | 0 .../fiveprs_loadscreen}/topo.svg | 0 publish.ps1 | 83 ++++++++++++------ 17 files changed, 221 insertions(+), 39 deletions(-) create mode 100644 FivePRS.Server/Database/SqliteNativeLoader.cs rename {loadscreen => addons/fiveprs_loadscreen}/Magz.otf (100%) rename {loadscreen => addons/fiveprs_loadscreen}/app.js (100%) rename {loadscreen => addons/fiveprs_loadscreen}/config.js (100%) rename {loadscreen => addons/fiveprs_loadscreen}/fxmanifest.lua (100%) rename {loadscreen => addons/fiveprs_loadscreen}/index.html (100%) rename {loadscreen => addons/fiveprs_loadscreen}/style.css (100%) rename {loadscreen => addons/fiveprs_loadscreen}/topo.svg (100%) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 462068e..8709d71 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -50,24 +50,32 @@ Open `FivePRS.sln` and build with Visual Studio, or run: ``` dotnet build FivePRS.sln -c Release +dotnet test FivePRS.Tests/FivePRS.Tests.csproj -c Release --no-build ``` -Compiled outputs land in `bin/client/` and `bin/server/` as configured in each project file. +Compiled outputs land in `bin/client/` and `bin/server/`. **Testing locally** -Copy the relevant DLLs from `bin/` to your FiveM server's `resources/FivePRS/` directory and restart the resource. There is no automated test suite yet; manual in-server testing is the current validation path. +Run `publish.ps1` from the repository root. It builds the core and writes `dist/fiveprs/` and `dist/[fiveprs_addons]/`, ready to copy into your FiveM server's `resources/` directory. Restart the resource after copying. --- ## Project Structure +| Path | Purpose | +|---|---| +| `FivePRS.*/`, `config/`, `nui/`, `fxmanifest.lua` | Everything that ships in the `fiveprs` resource | +| `addons//` | Optional standalone resources bundled into `fiveprs_addons` | +| `publish.ps1` | Builds the core and packages both bundles into `dist/` | + | Project | Purpose | |---|---| | `FivePRS.Core` | Shared models, events, interfaces, and config POCOs. No FiveM API dependency. | | `FivePRS.Client` | Client-side scripting: agency management, callout dispatch, vehicle spawning, loadouts. | | `FivePRS.Server` | Server-side scripting: player data, XP, database abstraction. | | `FivePRS.Police` | Police department implementation: callouts, vehicles, loadouts. | +| `FivePRS.Tests` | xUnit tests for config, territories and client assembly compatibility with FiveM's runtime. | Callout packs and plugin extensions are built as separate class libraries and dropped into the `callouts/` or `plugins/` folders at runtime. They reference `FivePRS.Core` and `FivePRS.Client` but are never compiled into the core solution. diff --git a/FivePRS.Client/Callouts/CalloutBase.cs b/FivePRS.Client/Callouts/CalloutBase.cs index e47372f..ea020fe 100644 --- a/FivePRS.Client/Callouts/CalloutBase.cs +++ b/FivePRS.Client/Callouts/CalloutBase.cs @@ -24,11 +24,28 @@ public abstract class CalloutBase private readonly List _trackedEntities = new(); private readonly List _trackedBlips = new(); - protected T? TrackEntity(T? entity) where T : Entity + protected Ped? TrackEntity(Ped? ped) + { + Track(ped); + return ped; + } + + protected Vehicle? TrackEntity(Vehicle? vehicle) + { + Track(vehicle); + return vehicle; + } + + protected Prop? TrackEntity(Prop? prop) + { + Track(prop); + return prop; + } + + private void Track(Entity? entity) { if (entity is not null) _trackedEntities.Add(entity); - return entity; } protected Blip TrackBlip(Blip blip) diff --git a/FivePRS.Client/Callouts/CalloutRegistry.cs b/FivePRS.Client/Callouts/CalloutRegistry.cs index 27aa478..b6f8a2c 100644 --- a/FivePRS.Client/Callouts/CalloutRegistry.cs +++ b/FivePRS.Client/Callouts/CalloutRegistry.cs @@ -63,12 +63,16 @@ public void DiscoverAll() } } - public void Register() where T : CalloutBase + public void Register(Type type) { - var attr = typeof(T).GetCustomAttribute() + if (type is null) throw new ArgumentNullException(nameof(type)); + if (type.IsAbstract || !type.IsSubclassOf(typeof(CalloutBase))) + throw new ArgumentException($"{type.Name} must be a concrete CalloutBase subclass.", nameof(type)); + + var attr = type.GetCustomAttribute() ?? throw new InvalidOperationException( - $"{typeof(T).Name} is missing [CalloutInfo(...)]. Cannot register."); - Register(typeof(T), attr); + $"{type.Name} is missing [CalloutInfo(...)]. Cannot register."); + Register(type, attr); } private void Register(Type type, CalloutInfoAttribute attr) diff --git a/FivePRS.Client/ClientBrain.cs b/FivePRS.Client/ClientBrain.cs index 5c8112e..bc8b9fd 100644 --- a/FivePRS.Client/ClientBrain.cs +++ b/FivePRS.Client/ClientBrain.cs @@ -28,14 +28,17 @@ public ClientBrain() ConfigManager.LoadSettings( API.LoadResourceFile(res, "config/settings.json")); ConfigManager.LoadPoliceVehicles( API.LoadResourceFile(res, "config/police_vehicles.json")); ConfigManager.LoadPoliceLoadouts( API.LoadResourceFile(res, "config/police_loadouts.json")); + ConfigManager.LoadJurisdictions( API.LoadResourceFile(res, "config/jurisdictions.json")); EventHandlers[EventNames.ClientReceivePlayerData] += new Action(OnReceivePlayerData); EventHandlers[EventNames.ClientDutyStatusChanged] += new Action(OnDutyStatusChanged); - EventHandlers[EventNames.ClientCalloutStarted] += new Action(OnCalloutStarted); + EventHandlers[EventNames.ClientCalloutOffered] += new Action(OnCalloutOffered); EventHandlers[EventNames.ClientRankedUp] += new Action(OnRankedUp); + EventHandlers[EventNames.ClientEndCallout] += new Action(() => CalloutDispatcher.EndCalloutPressed = true); API.RegisterCommand("duty", new Action, string>(OnDutyCommand), false); API.RegisterCommand("setdept", new Action, string>(OnSetDeptCommand), false); + API.RegisterCommand("setagency", new Action, string>(OnSetAgencyCommand), false); API.RegisterCommand("er_accept", new Action, string>((_, __, ___) => { @@ -71,8 +74,9 @@ public ClientBrain() API.RegisterCommand("er_profile", new Action, string>((_, __, ___) => { var d = LocalPlayerData; + var agency = ConfigManager.Territories.FindAgency(d.Agency)?.Name ?? d.Department.ToString(); ShowNotification( - $"~b~{d.Name}~w~ | {d.Department} | Rank {d.Rank}~n~~g~XP: {d.XP}"); + $"~b~{d.Name}~w~ | {agency} | Rank {d.Rank}~n~~g~XP: {d.XP} / {d.XPToNextRank}"); }), false); API.RegisterCommand("er_help", new Action, string>((_, __, ___) => { @@ -80,10 +84,14 @@ public ClientBrain() "~y~FivePRS Commands~w~~n~" + "~b~/duty~w~ — Toggle on/off duty~n~" + "~b~/setdept [id]~w~ — Set department~n~" + + "~b~/setagency [id]~w~ — Set agency~n~" + "~b~/er_profile~w~ — View rank and XP~n~" + "~b~/er_accept~w~ — Accept incoming callout~n~" + "~b~/er_decline~w~ — Decline incoming callout~n~" + "~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~/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~" + "~b~/er_escort~w~ — Place suspect in your vehicle"); @@ -146,7 +154,7 @@ private void OnRankedUp(int newRank) ShowNotification($"~g~RANK UP!~w~ You are now Rank {newRank}. Keep it up!"); } - private void OnCalloutStarted(string calloutJson) + private void OnCalloutOffered(string calloutJson) { try { @@ -206,6 +214,32 @@ private void OnSetDeptCommand(int source, List args, string raw) TriggerServerEvent(EventNames.ServerSetDepartment, deptId); } + private void OnSetAgencyCommand(int source, List args, string raw) + { + if (!_profileLoaded) + { + ShowNotification("~r~Your profile hasn't loaded yet. Please wait."); + return; + } + + var agencies = ConfigManager.Territories.AgenciesFor(LocalPlayerData.Department).ToList(); + if (agencies.Count == 0) + { + ShowNotification($"~r~{LocalPlayerData.Department} has no agencies configured."); + return; + } + + var agencyId = args.Count > 0 ? args[0]?.ToString() : null; + if (string.IsNullOrEmpty(agencyId)) + { + var options = agencies.Select(a => $"~b~{a.Id}~w~ {a.Name}"); + ShowNotification("~r~Usage: ~w~/setagency [id]~n~" + string.Join("~n~", options)); + return; + } + + TriggerServerEvent(EventNames.ServerSetAgency, agencyId); + } + private static string DepartmentOptions() { var options = Enum.GetValues(typeof(Department)) diff --git a/FivePRS.Server/Database/DatabaseManager.cs b/FivePRS.Server/Database/DatabaseManager.cs index 96e26c1..0238599 100644 --- a/FivePRS.Server/Database/DatabaseManager.cs +++ b/FivePRS.Server/Database/DatabaseManager.cs @@ -30,6 +30,7 @@ public async Task InitializeAsync(DatabaseType dbType, string? connectionString) else { var resourcePath = API.GetResourcePath(API.GetCurrentResourceName()); + SqliteNativeLoader.Load(Path.Combine(resourcePath, "server")); _provider = new SQLiteProvider(connectionString ?? Path.Combine(resourcePath, "data", "fiveprs.db")); } diff --git a/FivePRS.Server/Database/SqliteNativeLoader.cs b/FivePRS.Server/Database/SqliteNativeLoader.cs new file mode 100644 index 0000000..96fa14d --- /dev/null +++ b/FivePRS.Server/Database/SqliteNativeLoader.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using CitizenFX.Core; + +namespace FivePRS.Server.Database +{ + internal static class SqliteNativeLoader + { + private const int RtldNow = 2; + private const int RtldGlobal = 0x100; + + private static bool _loaded; + + [DllImport("kernel32", EntryPoint = "LoadLibraryW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr LoadLibrary(string path); + + [DllImport("libc", EntryPoint = "dlopen")] + private static extern IntPtr DlopenLibc(string path, int flags); + + [DllImport("libdl.so.2", EntryPoint = "dlopen")] + private static extern IntPtr DlopenLibdl(string path, int flags); + + public static void Load(string directory) + { + if (_loaded) return; + + var windows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var path = Path.Combine(directory, windows ? "e_sqlite3.dll" : "libe_sqlite3.so"); + + if (!File.Exists(path)) + throw new FileNotFoundException("Native SQLite library is missing from the resource.", path); + + var handle = windows ? LoadLibrary(path) : Dlopen(path); + if (handle == IntPtr.Zero) + { + var detail = windows ? $"Win32 error {Marshal.GetLastWin32Error()}" : "dlopen returned null"; + throw new DllNotFoundException($"Could not load native SQLite from {path} ({detail})."); + } + + _loaded = true; + Debug.WriteLine($"[FivePRS] Native SQLite loaded from {path}."); + } + + private static IntPtr Dlopen(string path) + { + try + { + return DlopenLibc(path, RtldNow | RtldGlobal); + } + catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException) + { + return DlopenLibdl(path, RtldNow | RtldGlobal); + } + } + } +} diff --git a/FivePRS.Server/FivePRS.Server.csproj b/FivePRS.Server/FivePRS.Server.csproj index 52d450e..a613b01 100644 --- a/FivePRS.Server/FivePRS.Server.csproj +++ b/FivePRS.Server/FivePRS.Server.csproj @@ -27,7 +27,7 @@ - diff --git a/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs b/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs index d3ae7bb..de4948f 100644 --- a/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs +++ b/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs @@ -48,6 +48,23 @@ public void ClientAssembly_HasNoMetadataUnsupportedByFiveMMono(string fileName) "Avoid readonly structs, readonly members, in/ref readonly parameters, ref structs, unmanaged constraints and init accessors in client code."); } + [Theory] + [MemberData(nameof(ClientAssemblies))] + public void ClientAssembly_HasNoAttributesOnGenericParameters(string fileName) + { + using var stream = File.OpenRead(Path.Combine(RepoRoot(), "bin", "client", fileName)); + using var pe = new PEReader(stream); + var reader = pe.GetMetadataReader(); + + var count = reader.CustomAttributes + .Select(handle => reader.GetCustomAttribute(handle).Parent.Kind) + .Count(kind => kind == HandleKind.GenericParameter || kind == HandleKind.GenericParameterConstraint); + + Assert.True(count == 0, + $"{fileName} has {count} custom attribute(s) on generic parameters or constraints, which FiveM's client runtime rejects with BadImageFormatException. " + + "With nullable enabled these come from constrained generics such as 'where T : SomeClass'; use non-generic overloads in client code."); + } + private static string RepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/README.md b/README.md index 8df0297..543a441 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,16 @@ The core engine is currently in the architectural phase. We are focusing on: - **Dynamic AI:** Smarter NPC interactions and scene management. - **Extensible:** Built to be easily expanded via the `addons` repository. +## Installation +Run `publish.ps1` to build two bundles in `dist/`: + +| Bundle | Contents | +|---|---| +| `fiveprs.zip` | The `fiveprs` resource, required | +| `fiveprs_addons.zip` | A `[fiveprs_addons]` folder with every optional addon, such as `fiveprs_loadscreen` | + +Extract both into your server's `resources/` folder, then `ensure fiveprs` and each addon you want after the configuration below. Addons live in `addons//` in this repository; any folder there with an `fxmanifest.lua` is bundled automatically. + ## Server Configuration ```cfg set fiveprs_db_type "sqlite" # or "mysql" @@ -27,6 +37,9 @@ set fiveprs_restrict_departments false # true requires the department ACE b add_ace group.admin fiveprs.admin allow add_ace group.police fiveprs.department.police allow + +ensure fiveprs +ensure fiveprs_loadscreen ``` | Permission | Grants | @@ -49,7 +62,7 @@ Admin commands work from the server console or in game with `fiveprs.admin`: When a player spawns, an entry screen shows their rank and XP and lets them pick a department and agency, then go on duty or continue as a civilian. Only departments the player is permitted to join are offered. Reopen it with `/fiveprs` while off duty, or turn it off with `"showEntryScreen": false` in `config/settings.json`. ## Loading Screen -`fiveprs_loadscreen` is an optional companion resource with a FivePRS-branded loading screen: load progress, rotating tips and a welcome back card with the player's agency and rank. Add `ensure fiveprs_loadscreen` to `server.cfg` to use it, and set your server name and tips in its `config.js`. Leave it out if your server already has a loading screen. +`fiveprs_loadscreen` is an optional addon in `fiveprs_addons` with a FivePRS-branded loading screen: load progress, rotating tips and a welcome back card with the player's agency and rank. Add `ensure fiveprs_loadscreen` to `server.cfg` to use it, and set your server name and tips in its `config.js`. Leave it out if your server already has a loading screen. ## Mobile Data Terminal On-duty units open the MDT with `F7` or `/mdt`. It shows your unit and status, every active call with its territory and assigned units, and all units on duty. From the MDT you can set your status, attach to a call as backup and set a waypoint to a call. The interface lives in `nui/` as plain HTML, CSS and JavaScript, so it can be restyled without rebuilding the resource. diff --git a/loadscreen/Magz.otf b/addons/fiveprs_loadscreen/Magz.otf similarity index 100% rename from loadscreen/Magz.otf rename to addons/fiveprs_loadscreen/Magz.otf diff --git a/loadscreen/app.js b/addons/fiveprs_loadscreen/app.js similarity index 100% rename from loadscreen/app.js rename to addons/fiveprs_loadscreen/app.js diff --git a/loadscreen/config.js b/addons/fiveprs_loadscreen/config.js similarity index 100% rename from loadscreen/config.js rename to addons/fiveprs_loadscreen/config.js diff --git a/loadscreen/fxmanifest.lua b/addons/fiveprs_loadscreen/fxmanifest.lua similarity index 100% rename from loadscreen/fxmanifest.lua rename to addons/fiveprs_loadscreen/fxmanifest.lua diff --git a/loadscreen/index.html b/addons/fiveprs_loadscreen/index.html similarity index 100% rename from loadscreen/index.html rename to addons/fiveprs_loadscreen/index.html diff --git a/loadscreen/style.css b/addons/fiveprs_loadscreen/style.css similarity index 100% rename from loadscreen/style.css rename to addons/fiveprs_loadscreen/style.css diff --git a/loadscreen/topo.svg b/addons/fiveprs_loadscreen/topo.svg similarity index 100% rename from loadscreen/topo.svg rename to addons/fiveprs_loadscreen/topo.svg diff --git a/publish.ps1 b/publish.ps1 index f070d62..786d3a4 100644 --- a/publish.ps1 +++ b/publish.ps1 @@ -1,11 +1,12 @@ param([string]$Configuration = "Release") $ErrorActionPreference = "Stop" -$Root = $PSScriptRoot -$DistDir = Join-Path $Root "dist\FivePRS" -$ZipOut = Join-Path $Root "dist\FivePRS.zip" -$LoadscreenDir = Join-Path $Root "dist\fiveprs_loadscreen" -$LoadscreenZip = Join-Path $Root "dist\fiveprs_loadscreen.zip" +$Root = $PSScriptRoot +$DistRoot = Join-Path $Root "dist" +$DistDir = Join-Path $DistRoot "fiveprs" +$CoreZip = Join-Path $DistRoot "fiveprs.zip" +$AddonsDir = Join-Path $DistRoot "[fiveprs_addons]" +$AddonsZip = Join-Path $DistRoot "fiveprs_addons.zip" function Info($msg) { Write-Host "[FivePRS] $msg" -ForegroundColor Cyan } function Ok($msg) { Write-Host "[FivePRS] $msg" -ForegroundColor Green } @@ -13,7 +14,7 @@ function Warn($msg) { Write-Host "[FivePRS] WARNING: $msg" -ForegroundColor Yell # 1. Clean dist Info "Cleaning previous dist..." -if (Test-Path (Join-Path $Root "dist")) { Remove-Item -Recurse -Force (Join-Path $Root "dist") } +if (Test-Path -LiteralPath $DistRoot) { Remove-Item -LiteralPath $DistRoot -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" @@ -24,8 +25,10 @@ Info "Building ($Configuration)..." dotnet build "$Root\FivePRS.sln" -c $Configuration --nologo if ($LASTEXITCODE -ne 0) { Write-Error "Build failed."; exit 1 } -# 3. Copy fxmanifest + config +# 3. Copy fxmanifest, license, readme + config Copy-Item "$Root\fxmanifest.lua" "$DistDir\fxmanifest.lua" +Copy-Item "$Root\LICENSE" "$DistDir\LICENSE" +Copy-Item "$Root\README.md" "$DistDir\README.md" Info "Copying config files..." if (Test-Path "$Root\config") { @@ -70,7 +73,7 @@ Get-ChildItem "$Root\bin\server" -File | Where-Object { " - Reference client\FivePRS.Core.dll and client\FivePRS.Client.net.dll", " - Subclass BaseScript for any systems you need", " - Build as a net452 Class Library named .net.dll", -" - Drop the output DLL here and restart FivePRS" +" - Drop the output DLL here and restart fiveprs" ) | Set-Content "$DistDir\plugins\README.txt" -Encoding ASCII @( @@ -89,7 +92,7 @@ Get-ChildItem "$Root\bin\server" -File | Where-Object { " - Reference client\FivePRS.Core.dll and client\FivePRS.Client.net.dll", " - Subclass CalloutBase and annotate with [CalloutInfo(Name, Dept, Weight)]", " - Build as a net452 Class Library named .net.dll", -" - Drop the output DLL here and restart FivePRS - no core recompile needed" +" - Drop the output DLL here and restart fiveprs - no core recompile needed" ) | Set-Content "$DistDir\callouts\README.txt" -Encoding ASCII # 7. Verify fxmanifest references (skip comments and wildcard globs) @@ -110,35 +113,63 @@ if ($missing.Count -gt 0) { Ok "All manifest references satisfied." } -# 8. Copy the optional loading screen resource -Info "Copying loading screen resource..." -Copy-Item -Recurse "$Root\loadscreen" $LoadscreenDir +# 8. Copy addon resources (every addons\\ with an fxmanifest.lua) +Info "Copying addon resources..." +$null = New-Item -ItemType Directory -Path $AddonsDir +$addons = @(Get-ChildItem -LiteralPath (Join-Path $Root "addons") -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "fxmanifest.lua") }) +foreach ($addon in $addons) { + $addonDest = Join-Path $AddonsDir $addon.Name + Copy-Item -LiteralPath $addon.FullName -Destination $addonDest -Recurse + if (-not (Test-Path -LiteralPath (Join-Path $addonDest "LICENSE"))) { + Copy-Item -LiteralPath (Join-Path $Root "LICENSE") -Destination (Join-Path $addonDest "LICENSE") + } +} +if ($addons.Count -eq 0) { Warn "No addons found under addons\." } -# 9. Zip both resources +# 9. Zip both bundles (each zip contains its top-level folder) Info "Creating zip archives..." +Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem -[System.IO.Compression.ZipFile]::CreateFromDirectory($DistDir, $ZipOut) -[System.IO.Compression.ZipFile]::CreateFromDirectory($LoadscreenDir, $LoadscreenZip) -Ok "Zips created -> $ZipOut, $LoadscreenZip" + +function New-ResourceZip($sourceDir, $zipPath) { + $source = (Get-Item -LiteralPath $sourceDir).FullName + $prefix = Split-Path $source -Leaf + $zip = [System.IO.Compression.ZipFile]::Open($zipPath, [System.IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($file in Get-ChildItem -LiteralPath $source -Recurse -File) { + $entry = $prefix + "/" + $file.FullName.Substring($source.Length + 1).Replace("\", "/") + $null = [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $zip, $file.FullName, $entry, [System.IO.Compression.CompressionLevel]::Optimal) + } + } finally { + $zip.Dispose() + } +} + +New-ResourceZip $DistDir $CoreZip +New-ResourceZip $AddonsDir $AddonsZip # 10. Summary -$fileCount = (Get-ChildItem -Recurse -File $DistDir).Count -$bytes = (Get-ChildItem -Recurse -File $DistDir | Measure-Object -Property Length -Sum).Sum -$distMB = [math]::Round($bytes / 1MB, 2) -$zipMB = [math]::Round((Get-Item $ZipOut).Length / 1MB, 2) +$fileCount = (Get-ChildItem -LiteralPath $DistDir -Recurse -File).Count +$coreMB = [math]::Round((Get-Item -LiteralPath $CoreZip).Length / 1MB, 2) +$addonsMB = [math]::Round((Get-Item -LiteralPath $AddonsZip).Length / 1MB, 2) Write-Host "" -Ok "Published -> $DistDir ($fileCount files, ~${distMB} MB)" -Ok "Zipped -> $ZipOut (~${zipMB} MB)" +Ok "Core -> $DistDir ($fileCount files)" +Ok "Zipped -> $CoreZip (~${coreMB} MB)" +Ok "Addons -> $AddonsDir ($($addons.Count): $(($addons | ForEach-Object Name) -join ', '))" +Ok "Zipped -> $AddonsZip (~${addonsMB} MB)" Write-Host "" Write-Host "Next steps:" -ForegroundColor Yellow -Write-Host " 1. Extract FivePRS.zip (and optionally fiveprs_loadscreen.zip) into your server resources\ directory." -ForegroundColor Yellow +Write-Host " 1. Extract fiveprs.zip (and optionally fiveprs_addons.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_connection "" # MySQL only' -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 " ensure fiveprs_loadscreen # optional branded loading screen" -ForegroundColor Gray +Write-Host " ensure fiveprs" -ForegroundColor Gray +foreach ($addon in $addons) { + Write-Host " ensure $($addon.Name)" -ForegroundColor Gray +} Write-Host "" Write-Host " plugins\ <- drop functionality extensions here" -ForegroundColor Yellow Write-Host " callouts\ <- drop scenario packs here" -ForegroundColor Yellow From 02b94ba7b5d1467d63f5dc5e52be884047b4a63e Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Fri, 9 Oct 2026 11:30:57 -0600 Subject: [PATCH 03/13] updated a ton of functionality --- .gitignore | 2 + FivePRS.Client/Arrest/ArrestManager.cs | 9 +- FivePRS.Client/Binds.cs | 20 +++ FivePRS.Client/Callouts/CalloutBase.cs | 7 +- FivePRS.Client/Callouts/CalloutDispatcher.cs | 46 ++++++- FivePRS.Client/ClientBrain.cs | 128 ++++-------------- FivePRS.Client/ClientEvents.cs | 51 +++++++ FivePRS.Client/Dispatch/DispatchClient.cs | 16 ++- FivePRS.Client/Entry/EntryClient.cs | 74 +++++----- FivePRS.Client/FivePRS.Client.csproj | 6 +- FivePRS.Client/Mdt/MdtClient.cs | 37 ++++- FivePRS.Client/NuiFocus.cs | 21 +++ FivePRS.Core/Config/ResourceSettings.cs | 3 - FivePRS.Core/Events/EventNames.cs | 2 - FivePRS.Core/FivePRS.Core.csproj | 7 +- FivePRS.Core/Models/Callsign.cs | 21 +++ FivePRS.Core/Models/PlayerData.cs | 1 + FivePRS.Core/Text/GameText.cs | 49 +++++++ .../Callouts/DomesticDisturbanceCallout.cs | 8 +- FivePRS.Police/Callouts/TrafficStopCallout.cs | 4 +- .../Callouts/VehiclePursuitCallout.cs | 2 +- FivePRS.Police/FivePRS.Police.csproj | 6 +- FivePRS.Server/Database/DatabaseManager.cs | 3 +- FivePRS.Server/Database/MySqlProvider.cs | 11 +- FivePRS.Server/Database/SQLiteProvider.cs | 11 +- FivePRS.Server/Database/SqliteNativeLoader.cs | 113 ++++++++++++---- FivePRS.Server/Dispatch/DispatchService.cs | 8 +- FivePRS.Server/FivePRS.Server.csproj | 1 + FivePRS.Server/ServerBrain.cs | 75 +++------- FivePRS.Tests/CallsignTests.cs | 29 ++++ .../ClientAssemblyCompatibilityTests.cs | 12 +- FivePRS.Tests/DispatchServiceTests.cs | 18 +++ FivePRS.Tests/GameTextTests.cs | 50 +++++++ FivePRS.Tests/SQLiteProviderTests.cs | 70 ++++++++++ README.md | 13 +- addons/fiveprs_loadscreen/app.js | 78 +++++++++++ addons/fiveprs_loadscreen/config.js | 13 +- addons/fiveprs_loadscreen/fxmanifest.lua | 3 + addons/fiveprs_loadscreen/index.html | 9 ++ addons/fiveprs_loadscreen/music/README.txt | 15 ++ .../music/radio_chatter.mp3 | Bin 0 -> 500852 bytes addons/fiveprs_loadscreen/style.css | 41 ++++++ config/settings.json | 3 +- fxmanifest.lua | 3 + nui/entry.js | 23 +++- nui/index.html | 25 +++- nui/mdt.js | 48 +++++++ nui/style.css | 90 ++++++++++++ publish.ps1 | 1 + scripts/relay.lua | 33 +++++ 50 files changed, 1049 insertions(+), 270 deletions(-) create mode 100644 FivePRS.Client/Binds.cs create mode 100644 FivePRS.Client/ClientEvents.cs create mode 100644 FivePRS.Client/NuiFocus.cs create mode 100644 FivePRS.Core/Models/Callsign.cs create mode 100644 FivePRS.Core/Text/GameText.cs create mode 100644 FivePRS.Tests/CallsignTests.cs create mode 100644 FivePRS.Tests/GameTextTests.cs create mode 100644 FivePRS.Tests/SQLiteProviderTests.cs create mode 100644 addons/fiveprs_loadscreen/music/README.txt create mode 100644 addons/fiveprs_loadscreen/music/radio_chatter.mp3 create mode 100644 scripts/relay.lua diff --git a/.gitignore b/.gitignore index 079e145..44204e2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ packages/ *.log *.tmp + +TESTING.md diff --git a/FivePRS.Client/Arrest/ArrestManager.cs b/FivePRS.Client/Arrest/ArrestManager.cs index e8141e4..da8be4e 100644 --- a/FivePRS.Client/Arrest/ArrestManager.cs +++ b/FivePRS.Client/Arrest/ArrestManager.cs @@ -105,10 +105,9 @@ await player.Task.PlayAnimation(OfficerAnimDict, OfficerAnimClip, StartFollowTask(suspect); ClientBrain.ShowNotification( - "~g~Suspect cuffed~w~ | ~b~/er_uncuff~w~ to release " + - "| ~b~/er_escort~w~ to place in vehicle"); + $"~g~Suspect cuffed~w~ | {Binds.Uncuff} release | {Binds.Escort} place in vehicle"); - BaseScript.TriggerEvent(EventNames.LocalSuspectCuffed, suspect.Handle); + ClientEvents.TriggerLocal(EventNames.LocalSuspectCuffed, suspect.Handle); Debug.WriteLine($"[ArrestManager] Suspect (handle {suspect.Handle}) cuffed."); } @@ -132,7 +131,7 @@ public static void Uncuff() API.SetEntityAsNoLongerNeeded(ref handle); ClientBrain.ShowNotification("~o~Suspect released."); - BaseScript.TriggerEvent(EventNames.LocalSuspectUncuffed, suspect.Handle); + ClientEvents.TriggerLocal(EventNames.LocalSuspectUncuffed, suspect.Handle); Debug.WriteLine($"[ArrestManager] Suspect (handle {suspect.Handle}) released."); } @@ -171,7 +170,7 @@ public static async Task EscortToVehicleAsync(Vehicle vehicle) State = ArrestState.Escorted; ClientBrain.ShowNotification("~g~Suspect secured in vehicle."); - BaseScript.TriggerEvent(EventNames.LocalSuspectEscorted, suspect.Handle, vehicle.Handle); + ClientEvents.TriggerLocal(EventNames.LocalSuspectEscorted, suspect.Handle, vehicle.Handle); Debug.WriteLine($"[ArrestManager] Suspect escorted to vehicle {vehicle.Handle}."); return true; diff --git a/FivePRS.Client/Binds.cs b/FivePRS.Client/Binds.cs new file mode 100644 index 0000000..ec15c8a --- /dev/null +++ b/FivePRS.Client/Binds.cs @@ -0,0 +1,20 @@ +using CitizenFX.Core.Native; + +namespace FivePRS.Client +{ + public static class Binds + { + public static string Duty => For("duty"); + public static string Mdt => For("mdt"); + public static string Profile => For("er_profile"); + public static string Accept => For("er_accept"); + public static string Decline => For("er_decline"); + public static string EndCall => For("er_end_callout"); + public static string Cuff => For("er_cuff"); + public static string Uncuff => For("er_uncuff"); + public static string Escort => For("er_escort"); + + public static string For(string command) => + $"~INPUT_{(uint)API.GetHashKey(command) | 0x80000000:X8}~"; + } +} diff --git a/FivePRS.Client/Callouts/CalloutBase.cs b/FivePRS.Client/Callouts/CalloutBase.cs index ea020fe..4080078 100644 --- a/FivePRS.Client/Callouts/CalloutBase.cs +++ b/FivePRS.Client/Callouts/CalloutBase.cs @@ -96,9 +96,10 @@ protected static Vector3 PickLocation(IReadOnlyList locations, Random r return pool[rng.Next(pool.Count)]; } - protected async Task WaitForArrestAsync(Ped suspect, CancellationToken ct, string prompt = "Type ~b~/er_cuff~w~ to arrest the suspect") + protected async Task WaitForArrestAsync(Ped suspect, CancellationToken ct, string? prompt = null) { const int PollMs = 250; + var help = prompt ?? $"Press {Binds.Cuff} to arrest the suspect"; ArrestManager.RegisterSuspect(suspect); @@ -118,7 +119,7 @@ protected async Task WaitForArrestAsync(Ped suspect, CancellationToken ct, strin return; } - ClientBrain.ShowHelp(prompt, PollMs + 50); + ClientBrain.ShowHelp(help, PollMs + 50); await Timing.WaitAsync(PollMs, ct); } } @@ -150,7 +151,7 @@ protected async Task FootPursuitAsync(Ped suspect, float catchDistM, float escap if (dist <= catchDistM) { await TaskManager.AssignTaskAsync(suspect, PedTaskType.PutHandsUp); - ClientBrain.ShowNotification("~g~Suspect cornered~w~ | Type ~b~/er_cuff~w~ to arrest."); + ClientBrain.ShowNotification($"~g~Suspect cornered~w~ | Press {Binds.Cuff} to arrest."); await WaitForArrestAsync(suspect, ct); return; } diff --git a/FivePRS.Client/Callouts/CalloutDispatcher.cs b/FivePRS.Client/Callouts/CalloutDispatcher.cs index 7ab89cf..1138821 100644 --- a/FivePRS.Client/Callouts/CalloutDispatcher.cs +++ b/FivePRS.Client/Callouts/CalloutDispatcher.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using CitizenFX.Core; @@ -12,10 +13,35 @@ namespace FivePRS.Client.Callouts { public sealed class CalloutDispatcher { + private const int RegistrationBatchSize = 5; + internal static volatile bool AcceptPressed; internal static volatile bool DeclinePressed; internal static volatile bool EndCalloutPressed; + public static CalloutData? PendingOffer { get; private set; } + public static int PendingOfferExpiresAt { get; private set; } + public static CalloutData? ActiveCall { get; private set; } + + public static event Action? StateChanged; + + public static void AcceptOffer() => AcceptPressed = true; + public static void DeclineOffer() => DeclinePressed = true; + public static void EndActiveCall() => EndCalloutPressed = true; + + private static void SetOffer(CalloutData? offer, int expiresAt) + { + PendingOffer = offer; + PendingOfferExpiresAt = expiresAt; + StateChanged?.Invoke(); + } + + private static void SetActive(CalloutData? call) + { + ActiveCall = call; + StateChanged?.Invoke(); + } + private readonly Department _department; private readonly CalloutRegistry _registry; private readonly Action _onEnded; @@ -43,7 +69,11 @@ public void Start() _cts = new CancellationTokenSource(); var definitions = _registry.GetDefinitions(_department); - BaseScript.TriggerServerEvent(EventNames.ServerRegisterCallouts, JsonConvert.SerializeObject(definitions)); + for (var i = 0; i < definitions.Count; i += RegistrationBatchSize) + { + var batch = definitions.Skip(i).Take(RegistrationBatchSize).ToList(); + ClientEvents.TriggerServer(EventNames.ServerRegisterCallouts, JsonConvert.SerializeObject(batch)); + } Debug.WriteLine($"[CalloutDispatcher] Registered {definitions.Count} {_department} callout(s) with dispatch."); } @@ -56,6 +86,8 @@ public void Stop() var callout = _activeCallout; _activeCallout = null; + SetOffer(null, 0); + SetActive(null); if (callout is null) return; try { callout.OnCalloutFailed(); } @@ -127,7 +159,9 @@ private async Task RunCalloutAsync(CalloutBase callout, CancellationToken ct) AcceptPressed = false; DeclinePressed = false; + SetOffer(callout.Data, API.GetGameTimer() + ConfigManager.Settings.AcceptWindowSeconds * 1000); var accepted = await RunAcceptWindowAsync(ct); + SetOffer(null, 0); previewBlip?.Delete(); @@ -150,6 +184,7 @@ private async Task RunCalloutAsync(CalloutBase callout, CancellationToken ct) EndCalloutPressed = false; callout.SetState(CalloutState.Active); _activeCallout = callout; + SetActive(callout.Data); ClientBrain.ShowNotification($"~g~[ DISPATCH ]~w~ Call ~y~#{callout.Data.Id}~w~ accepted: ~b~{callout.Data.Name}"); @@ -179,13 +214,14 @@ void OnEnded(CalloutBase c, CalloutResult result) } callout.Cleanup(finalResult == CalloutResult.Completed); + SetActive(null); if (ReferenceEquals(_activeCallout, callout)) { _activeCallout = null; if (!ct.IsCancellationRequested) { - BaseScript.TriggerServerEvent(EventNames.ServerCalloutEnded, callout.Data.Id, (int)finalResult); + ClientEvents.TriggerServer(EventNames.ServerCalloutEnded, callout.Data.Id, (int)finalResult); _onEnded(callout, finalResult); } } @@ -194,7 +230,7 @@ void OnEnded(CalloutBase c, CalloutResult result) } private static void Respond(string callId, OfferResponse response, Vector3 location) => - BaseScript.TriggerServerEvent(EventNames.ServerCalloutResponse, callId, (int)response, location.X, location.Y, location.Z); + ClientEvents.TriggerServer(EventNames.ServerCalloutResponse, callId, (int)response, location.X, location.Y, location.Z); private static async Task RunAcceptWindowAsync(CancellationToken ct) { @@ -208,7 +244,7 @@ private static async Task RunAcceptWindowAsync(CancellationToken ct) var remainingMs = end - API.GetGameTimer(); if (remainingMs <= 0) return false; - ClientBrain.ShowHelp($"~y~[ DISPATCH ]~w~ ~g~/er_accept~w~ or ~r~/er_decline~w~ ({remainingMs / 1000 + 1}s)", -1); + ClientBrain.ShowHelp($"~y~[ DISPATCH ]~w~ {Binds.Accept} Accept {Binds.Decline} Decline ({remainingMs / 1000 + 1}s)", -1); await BaseScript.Delay(0); } @@ -269,7 +305,7 @@ private static void ShowDispatchNotification(CalloutBase callout, Vector3 dispat ClientBrain.ShowNotification( $"~y~[ DISPATCH ]~w~ {codeColor}Code {(int)data.Priority}~w~ ~b~{data.Name}~w~ ~y~#{data.Id}~n~" + - $"{data.Description}~n~{distance}~g~/er_accept~w~ ~r~/er_decline"); + $"{data.Description}~n~{distance}{Binds.Accept} ~g~Accept~w~ {Binds.Decline} ~r~Decline"); } private static Blip? CreatePreviewBlip(Vector3 location, CalloutData data) diff --git a/FivePRS.Client/ClientBrain.cs b/FivePRS.Client/ClientBrain.cs index bc8b9fd..f2a2ea3 100644 --- a/FivePRS.Client/ClientBrain.cs +++ b/FivePRS.Client/ClientBrain.cs @@ -10,6 +10,7 @@ using FivePRS.Core.Config; using FivePRS.Core.Events; using FivePRS.Core.Models; +using FivePRS.Core.Text; using Newtonsoft.Json; namespace FivePRS.Client @@ -18,8 +19,6 @@ public class ClientBrain : BaseScript { public static PlayerData LocalPlayerData { get; private set; } = new(); - private bool _profileLoaded = false; - public ClientBrain() { ConfigManager.Log = message => Debug.WriteLine(message); @@ -36,10 +35,6 @@ public ClientBrain() EventHandlers[EventNames.ClientRankedUp] += new Action(OnRankedUp); EventHandlers[EventNames.ClientEndCallout] += new Action(() => CalloutDispatcher.EndCalloutPressed = true); - API.RegisterCommand("duty", new Action, string>(OnDutyCommand), false); - API.RegisterCommand("setdept", new Action, string>(OnSetDeptCommand), false); - API.RegisterCommand("setagency", new Action, string>(OnSetAgencyCommand), false); - API.RegisterCommand("er_accept", new Action, string>((_, __, ___) => { CalloutDispatcher.AcceptPressed = true; @@ -82,9 +77,7 @@ public ClientBrain() { ShowNotification( "~y~FivePRS Commands~w~~n~" + - "~b~/duty~w~ — Toggle on/off duty~n~" + - "~b~/setdept [id]~w~ — Set department~n~" + - "~b~/setagency [id]~w~ — Set agency~n~" + + "~b~/duty~w~ — Choose department, agency and callsign, or go off duty~n~" + "~b~/er_profile~w~ — View rank and XP~n~" + "~b~/er_accept~w~ — Accept incoming callout~n~" + "~b~/er_decline~w~ — Decline incoming callout~n~" + @@ -97,7 +90,6 @@ public ClientBrain() "~b~/er_escort~w~ — Place suspect in your vehicle"); }), false); - API.RegisterKeyMapping("duty", "FivePRS: Toggle on/off duty", "keyboard", "F5"); API.RegisterKeyMapping("er_accept", "FivePRS: Accept callout", "keyboard", "Y"); API.RegisterKeyMapping("er_decline", "FivePRS: Decline callout", "keyboard", "N"); API.RegisterKeyMapping("er_end_callout", "FivePRS: End active callout", "keyboard", "END"); @@ -108,6 +100,7 @@ public ClientBrain() API.RegisterKeyMapping("er_help", "FivePRS: Show command list", "keyboard", ""); Tick += WaitForSpawnTick; + Tick += DrawHelpTick; } private async Task WaitForSpawnTick() @@ -115,7 +108,7 @@ private async Task WaitForSpawnTick() if (Game.PlayerPed.Exists() && Game.PlayerPed.Handle != 0) { Tick -= WaitForSpawnTick; - TriggerServerEvent(EventNames.ServerPlayerConnected); + ClientEvents.TriggerServer(EventNames.ServerPlayerConnected); } await Delay(500); } @@ -128,11 +121,10 @@ private void OnReceivePlayerData(string json) if (data is null) return; LocalPlayerData = data; - _profileLoaded = true; Debug.WriteLine($"[FivePRS] Profile loaded: {data.Name} | Rank {data.Rank} | {data.Department}"); - if (data.Department == Department.None) - ShowNotification($"~y~Welcome to FivePRS!~w~ Choose your department with ~b~/setdept~w~.~n~{DepartmentOptions()}"); + if (!data.IsOnDuty) + ShowNotification($"~y~Welcome to FivePRS!~w~ Press {Binds.Duty} to go on duty."); } catch (Exception ex) { @@ -145,7 +137,7 @@ private void OnDutyStatusChanged(bool isOnDuty, int departmentId) LocalPlayerData.IsOnDuty = isOnDuty; LocalPlayerData.Department = (Department)departmentId; - TriggerEvent(EventNames.LocalDutyChanged, isOnDuty, departmentId); + ClientEvents.TriggerLocal(EventNames.LocalDutyChanged, isOnDuty, departmentId); } private void OnRankedUp(int newRank) @@ -161,7 +153,7 @@ private void OnCalloutOffered(string calloutJson) _ = JsonConvert.DeserializeObject(calloutJson) ?? throw new InvalidOperationException("Callout JSON was null after deserialisation."); - TriggerEvent(EventNames.LocalCalloutReceived, calloutJson); + ClientEvents.TriggerLocal(EventNames.LocalCalloutReceived, calloutJson); } catch (Exception ex) { @@ -169,105 +161,45 @@ private void OnCalloutOffered(string calloutJson) } } - private void OnDutyCommand(int source, List args, string raw) + public static void ShowNotification(string message) { - if (!_profileLoaded) - { - ShowNotification("~r~Your profile hasn't loaded yet. Please wait."); - return; - } - - if (!LocalPlayerData.IsOnDuty) - { - var dept = LocalPlayerData.Department; - if (dept == Department.None) - { - ShowNotification($"~r~No department selected.~w~ Use ~b~/setdept~w~ first.~n~{DepartmentOptions()}"); - return; - } - if (!BaseAgency.IsDepartmentLoaded(dept)) - { - ShowNotification($"~r~The {dept} module is not installed on this server."); - return; - } - } - - TriggerServerEvent(EventNames.ServerToggleDuty); + API.SetNotificationTextEntry(LongTextEntry); + AddLongText(message); + API.DrawNotification(false, true); } - private void OnSetDeptCommand(int source, List args, string raw) + public static void ShowHelp(string message, int durationMs) { - if (args.Count < 1 || !int.TryParse(args[0]?.ToString(), out int deptId) - || !Enum.IsDefined(typeof(Department), deptId) || deptId == (int)Department.None) - { - ShowNotification($"~r~Usage: ~w~/setdept [id]~n~{DepartmentOptions()}"); - return; - } - - var dept = (Department)deptId; - if (!BaseAgency.IsDepartmentLoaded(dept)) - { - ShowNotification($"~r~The {dept} module is not installed on this server.~n~{DepartmentOptions()}"); - return; - } - - TriggerServerEvent(EventNames.ServerSetDepartment, deptId); + _helpText = message; + _helpUntil = API.GetGameTimer() + Math.Max(durationMs, MinHelpDurationMs); } - private void OnSetAgencyCommand(int source, List args, string raw) + private Task DrawHelpTick() { - if (!_profileLoaded) - { - ShowNotification("~r~Your profile hasn't loaded yet. Please wait."); - return; - } + if (_helpText is null) return Delay(50); - var agencies = ConfigManager.Territories.AgenciesFor(LocalPlayerData.Department).ToList(); - if (agencies.Count == 0) + if (API.GetGameTimer() > _helpUntil) { - ShowNotification($"~r~{LocalPlayerData.Department} has no agencies configured."); - return; + _helpText = null; + return Delay(50); } - var agencyId = args.Count > 0 ? args[0]?.ToString() : null; - if (string.IsNullOrEmpty(agencyId)) - { - var options = agencies.Select(a => $"~b~{a.Id}~w~ {a.Name}"); - ShowNotification("~r~Usage: ~w~/setagency [id]~n~" + string.Join("~n~", options)); - return; - } - - TriggerServerEvent(EventNames.ServerSetAgency, agencyId); - } - - private static string DepartmentOptions() - { - var options = Enum.GetValues(typeof(Department)) - .Cast() - .Where(d => d != Department.None && BaseAgency.IsDepartmentLoaded(d)) - .Select(d => $"~b~{(int)d}~w~={d}"); - return "Available: " + string.Join(" ", options); + API.BeginTextCommandDisplayHelp(LongTextEntry); + AddLongText(_helpText); + API.EndTextCommandDisplayHelp(0, false, false, -1); + return Task.FromResult(0); } - public static void ShowNotification(string message) - { - API.SetNotificationTextEntry("STRING"); - AddLongText(message); - API.DrawNotification(false, true); - } + private const string LongTextEntry = "CELL_EMAIL_BCON"; + private const int MinHelpDurationMs = 100; - public static void ShowHelp(string message, int durationMs) - { - API.BeginTextCommandDisplayHelp("STRING"); - AddLongText(message); - API.EndTextCommandDisplayHelp(0, false, false, durationMs); - } + private static string? _helpText; + private static int _helpUntil; private static void AddLongText(string text) { - const int ChunkSize = 64; - for (var i = 0; i < text.Length; i += ChunkSize) - API.AddTextComponentSubstringPlayerName(text.Substring(i, Math.Min(ChunkSize, text.Length - i))); + foreach (var part in GameText.SplitComponents(text)) + API.AddTextComponentSubstringPlayerName(part); } } } diff --git a/FivePRS.Client/ClientEvents.cs b/FivePRS.Client/ClientEvents.cs new file mode 100644 index 0000000..1e5634e --- /dev/null +++ b/FivePRS.Client/ClientEvents.cs @@ -0,0 +1,51 @@ +using System; +using System.Text; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using Newtonsoft.Json; + +namespace FivePRS.Client +{ + public static class ClientEvents + { + private const string RelayCommand = "fiveprs_relay"; + + private static bool _useRelay; + + public static void TriggerServer(string eventName, params object[] args) => Trigger(eventName, args, true); + + public static void TriggerLocal(string eventName, params object[] args) => Trigger(eventName, args, false); + + private static void Trigger(string eventName, object[] args, bool remote) + { + if (!_useRelay) + { + try + { + if (remote) + BaseScript.TriggerServerEvent(eventName, args); + else + BaseScript.TriggerEvent(eventName, args); + return; + } + catch (ArgumentException ex) when (ex.Message.Contains("expected a managed pointer")) + { + _useRelay = true; + Debug.WriteLine("[FivePRS] This client build blocks C# event natives; routing events through the Lua relay."); + } + } + + var payload = JsonConvert.SerializeObject(new { e = eventName, r = remote, n = args.Length, a = args }); + API.ExecuteCommand($"{RelayCommand} {ToHex(payload)}"); + } + + private static string ToHex(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var builder = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) + builder.Append(b.ToString("x2")); + return builder.ToString(); + } + } +} diff --git a/FivePRS.Client/Dispatch/DispatchClient.cs b/FivePRS.Client/Dispatch/DispatchClient.cs index 177892e..a7cf001 100644 --- a/FivePRS.Client/Dispatch/DispatchClient.cs +++ b/FivePRS.Client/Dispatch/DispatchClient.cs @@ -25,6 +25,7 @@ public DispatchClient() { EventHandlers[EventNames.ClientDispatchSnapshot] += new Action(OnSnapshot); EventHandlers[EventNames.ClientNotify] += new Action(ClientBrain.ShowNotification); + EventHandlers[EventNames.LocalDutyChanged] += new Action(OnDutyChanged); API.RegisterCommand("er_calls", new Action, string>(OnCallsCommand), false); API.RegisterCommand("er_attach", new Action, string>(OnAttachCommand), false); @@ -55,6 +56,15 @@ private void OnSnapshot(string json) SnapshotUpdated?.Invoke(); } + private void OnDutyChanged(bool isOnDuty, int departmentId) + { + if (isOnDuty) return; + + Snapshot = new DispatchSnapshot(); + _lastCallId = null; + SnapshotUpdated?.Invoke(); + } + private static void OnCallsCommand(int source, List args, string raw) { if (LocalUnit is null) @@ -85,13 +95,13 @@ private static void OnCallsCommand(int source, List args, string raw) private static void OnAttachCommand(int source, List args, string raw) { var callId = args.Count > 0 ? args[0]?.ToString()?.TrimStart('#') : null; - if (string.IsNullOrEmpty(callId)) + if (callId is null || callId.Length == 0) { ClientBrain.ShowNotification("~r~Usage: ~w~/er_attach [call id]"); return; } - TriggerServerEvent(EventNames.ServerAttachToCall, callId); + ClientEvents.TriggerServer(EventNames.ServerAttachToCall, callId); } private static void OnStatusCommand(int source, List args, string raw) @@ -104,7 +114,7 @@ private static void OnStatusCommand(int source, List args, string raw) return; } - TriggerServerEvent(EventNames.ServerSetUnitStatus, (int)status); + ClientEvents.TriggerServer(EventNames.ServerSetUnitStatus, (int)status); } } } diff --git a/FivePRS.Client/Entry/EntryClient.cs b/FivePRS.Client/Entry/EntryClient.cs index ae2bc30..b71af08 100644 --- a/FivePRS.Client/Entry/EntryClient.cs +++ b/FivePRS.Client/Entry/EntryClient.cs @@ -22,7 +22,6 @@ public class EntryClient : BaseScript private PlayerData? _profile; private List? _allowed; - private bool _shownOnJoin; private bool _open; public EntryClient() @@ -33,7 +32,8 @@ public EntryClient() EventHandlers[EventNames.LocalDutyChanged] += new Action(OnDutyChanged); EventHandlers["onClientResourceStop"] += new Action(OnResourceStop); - API.RegisterCommand("fiveprs", new Action, string>((_, __, ___) => OpenFromCommand()), false); + API.RegisterCommand("duty", new Action, string>((_, __, ___) => OnDutyCommand()), false); + API.RegisterKeyMapping("duty", "FivePRS: Duty menu", "keyboard", "F5"); RegisterCallback("entryClose", _ => Close()); RegisterCallback("entryEnter", data => Enter(data)); @@ -53,8 +53,6 @@ private void OnPlayerData(string json) if (_open && _profile is not null && !_profile.IsOnDuty) Send("open", BuildView()); - - _ = ShowOnJoinAsync(); } private void OnEntryOptions(string json) @@ -72,26 +70,18 @@ private void OnEntryOptions(string json) return; } - _ = ShowOnJoinAsync(); + if (_open) + Send("open", BuildView()); } - private async Task ShowOnJoinAsync() + private void OnDutyCommand() { - if (_shownOnJoin || _profile is null || _allowed is null) return; - _shownOnJoin = true; - - if (!ConfigManager.Settings.ShowEntryScreen || _profile.IsOnDuty) return; - - while (API.GetIsLoadingScreenActive() || API.IsScreenFadedOut() || !Game.PlayerPed.Exists()) - await Delay(250); - - await Delay(1000); - if (_profile is not null && !_profile.IsOnDuty) - Open(); - } + if (_open) + { + Close(); + return; + } - private void OpenFromCommand() - { if (_profile is null || _allowed is null) { ClientBrain.ShowNotification("~r~Your profile hasn't loaded yet. Please wait."); @@ -100,7 +90,7 @@ private void OpenFromCommand() if (_profile.IsOnDuty) { - ClientBrain.ShowNotification("~r~Go off duty before changing your department or agency."); + ClientEvents.TriggerServer(EventNames.ServerToggleDuty); return; } @@ -109,8 +99,11 @@ private void OpenFromCommand() private void Open() { + if (!_open) + Tick += HoldFocusAsync; + _open = true; - API.SetNuiFocus(true, true); + NuiFocus.Take(); Send("open", BuildView()); } @@ -119,11 +112,20 @@ private void Close() if (!_open) return; _open = false; - API.SetNuiFocus(false, false); + Tick -= HoldFocusAsync; + NuiFocus.Release(); Send("close", null); } - private static void Enter(IDictionary data) + private async Task HoldFocusAsync() + { + if (_open) + NuiFocus.EnsureTaken(); + + await Delay(250); + } + + private void Enter(IDictionary data) { if (!data.TryGetValue("departmentId", out var rawDepartment) || !int.TryParse(rawDepartment?.ToString(), out var departmentId)) @@ -131,8 +133,16 @@ private static void Enter(IDictionary data) return; } - var agencyId = data.TryGetValue("agencyId", out var rawAgency) ? rawAgency?.ToString() ?? string.Empty : string.Empty; - TriggerServerEvent(EventNames.ServerEnterService, departmentId, agencyId); + var agencyId = data.TryGetValue("agencyId", out var rawAgency) ? rawAgency?.ToString() ?? string.Empty : string.Empty; + var rawCallsign = data.TryGetValue("callsign", out var callsignValue) ? callsignValue?.ToString() : null; + + if (!Callsign.TryNormalize(rawCallsign, out var callsign)) + { + Send("rejected", $"Callsigns can be up to {Callsign.MaxLength} letters, numbers or hyphens."); + return; + } + + ClientEvents.TriggerServer(EventNames.ServerEnterService, departmentId, agencyId, callsign); } private void OnRejected(string message) @@ -155,7 +165,7 @@ private void OnDutyChanged(bool isOnDuty, int departmentId) private void OnResourceStop(string resourceName) { if (resourceName == API.GetCurrentResourceName() && _open) - API.SetNuiFocus(false, false); + NuiFocus.Release(); } private object BuildView() @@ -178,10 +188,12 @@ private object BuildView() profile.Name, profile.Rank, profile.XP, - XpToNext = profile.XPToNextRank, - DepartmentId = (int)profile.Department, - AgencyId = profile.Agency, - Departments = departments, + XpToNext = profile.XPToNextRank, + DepartmentId = (int)profile.Department, + AgencyId = profile.Agency, + profile.Callsign, + CallsignMaxLength = Callsign.MaxLength, + Departments = departments, }; } diff --git a/FivePRS.Client/FivePRS.Client.csproj b/FivePRS.Client/FivePRS.Client.csproj index 52e7bf0..14857f1 100644 --- a/FivePRS.Client/FivePRS.Client.csproj +++ b/FivePRS.Client/FivePRS.Client.csproj @@ -14,7 +14,11 @@ - + + + $(PkgNewtonsoft_Json)\lib\portable-net40+sl5+win8+wp8+wpa81\Newtonsoft.Json.dll + + diff --git a/FivePRS.Client/Mdt/MdtClient.cs b/FivePRS.Client/Mdt/MdtClient.cs index f767fba..5f223cb 100644 --- a/FivePRS.Client/Mdt/MdtClient.cs +++ b/FivePRS.Client/Mdt/MdtClient.cs @@ -3,6 +3,7 @@ using System.Linq; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Client.Callouts; using FivePRS.Client.Dispatch; using FivePRS.Core.Config; using FivePRS.Core.Events; @@ -30,8 +31,12 @@ public MdtClient() RegisterCallback("setStatus", data => SetStatus(data)); RegisterCallback("attach", data => Attach(data)); RegisterCallback("waypoint", data => Waypoint(data)); + RegisterCallback("offerAccept", _ => CalloutDispatcher.AcceptOffer()); + RegisterCallback("offerDecline", _ => CalloutDispatcher.DeclineOffer()); + RegisterCallback("endCall", _ => CalloutDispatcher.EndActiveCall()); DispatchClient.SnapshotUpdated += OnSnapshotUpdated; + CalloutDispatcher.StateChanged += OnCalloutStateChanged; EventHandlers["onClientResourceStop"] += new Action(OnResourceStop); } @@ -50,7 +55,7 @@ private void Toggle() } _open = true; - API.SetNuiFocus(true, true); + NuiFocus.Take(); Send("open", BuildView()); } @@ -59,7 +64,7 @@ private void Close() if (!_open) return; _open = false; - API.SetNuiFocus(false, false); + NuiFocus.Release(); Send("close", null); } @@ -76,6 +81,12 @@ private void OnSnapshotUpdated() Send("update", BuildView()); } + private void OnCalloutStateChanged() + { + if (_open && DispatchClient.LocalUnit is not null) + Send("update", BuildView()); + } + private static void SetStatus(IDictionary data) { if (!TryGetString(data, "status", out var value) || @@ -85,13 +96,13 @@ private static void SetStatus(IDictionary data) return; } - TriggerServerEvent(EventNames.ServerSetUnitStatus, (int)status); + ClientEvents.TriggerServer(EventNames.ServerSetUnitStatus, (int)status); } private static void Attach(IDictionary data) { if (TryGetString(data, "callId", out var callId)) - TriggerServerEvent(EventNames.ServerAttachToCall, callId); + ClientEvents.TriggerServer(EventNames.ServerAttachToCall, callId); } private static void Waypoint(IDictionary data) @@ -108,7 +119,7 @@ private static void Waypoint(IDictionary data) private void OnResourceStop(string resourceName) { if (resourceName == API.GetCurrentResourceName() && _open) - API.SetNuiFocus(false, false); + NuiFocus.Release(); } private static object BuildView() @@ -116,9 +127,25 @@ private static object BuildView() var snapshot = DispatchClient.Snapshot; var self = DispatchClient.LocalUnit; var map = ConfigManager.Territories; + var offer = CalloutDispatcher.PendingOffer; + var active = CalloutDispatcher.ActiveCall; return new { + offer = offer is null ? null : new + { + offer.Id, + offer.Name, + Code = (int)offer.Priority, + offer.Description, + ExpiresInMs = Math.Max(0, CalloutDispatcher.PendingOfferExpiresAt - API.GetGameTimer()), + WindowMs = ConfigManager.Settings.AcceptWindowSeconds * 1000, + }, + activeCall = active is null ? null : new + { + active.Id, + active.Name, + }, self = self is null ? null : new { self.ServerId, diff --git a/FivePRS.Client/NuiFocus.cs b/FivePRS.Client/NuiFocus.cs new file mode 100644 index 0000000..e7b97e8 --- /dev/null +++ b/FivePRS.Client/NuiFocus.cs @@ -0,0 +1,21 @@ +using CitizenFX.Core.Native; + +namespace FivePRS.Client +{ + public static class NuiFocus + { + public static void Take() + { + API.SetNuiFocus(false, false); + API.SetNuiFocus(true, true); + } + + public static void Release() => API.SetNuiFocus(false, false); + + public static void EnsureTaken() + { + if (!API.IsNuiFocused()) + Take(); + } + } +} diff --git a/FivePRS.Core/Config/ResourceSettings.cs b/FivePRS.Core/Config/ResourceSettings.cs index d2737e6..82e6438 100644 --- a/FivePRS.Core/Config/ResourceSettings.cs +++ b/FivePRS.Core/Config/ResourceSettings.cs @@ -24,8 +24,5 @@ public sealed class ResourceSettings [JsonProperty("noCalloutRetrySeconds")] public int NoCalloutRetrySeconds { get; set; } = 30; - - [JsonProperty("showEntryScreen")] - public bool ShowEntryScreen { get; set; } = true; } } diff --git a/FivePRS.Core/Events/EventNames.cs b/FivePRS.Core/Events/EventNames.cs index 344752d..8d334c1 100644 --- a/FivePRS.Core/Events/EventNames.cs +++ b/FivePRS.Core/Events/EventNames.cs @@ -14,8 +14,6 @@ public static class EventNames public const string ServerPlayerConnected = "FivePRS:Server:PlayerConnected"; public const string ServerToggleDuty = "FivePRS:Server:ToggleDuty"; - public const string ServerSetDepartment = "FivePRS:Server:SetDepartment"; - public const string ServerSetAgency = "FivePRS:Server:SetAgency"; public const string ServerEnterService = "FivePRS:Server:EnterService"; public const string ServerRegisterCallouts = "FivePRS:Server:RegisterCallouts"; public const string ServerCalloutResponse = "FivePRS:Server:CalloutResponse"; diff --git a/FivePRS.Core/FivePRS.Core.csproj b/FivePRS.Core/FivePRS.Core.csproj index 87aaa61..b0c0109 100644 --- a/FivePRS.Core/FivePRS.Core.csproj +++ b/FivePRS.Core/FivePRS.Core.csproj @@ -10,7 +10,12 @@ - + + + $(PkgNewtonsoft_Json)\lib\portable-net40+sl5+win8+wp8+wpa81\Newtonsoft.Json.dll + + + diff --git a/FivePRS.Core/Models/Callsign.cs b/FivePRS.Core/Models/Callsign.cs new file mode 100644 index 0000000..31fa8a5 --- /dev/null +++ b/FivePRS.Core/Models/Callsign.cs @@ -0,0 +1,21 @@ +namespace FivePRS.Core.Models +{ + public static class Callsign + { + public const int MaxLength = 12; + + public static bool TryNormalize(string? input, out string callsign) + { + callsign = (input ?? string.Empty).Trim().ToUpperInvariant(); + if (callsign.Length > MaxLength) return false; + + foreach (var c in callsign) + { + var valid = (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'; + if (!valid) return false; + } + + return true; + } + } +} diff --git a/FivePRS.Core/Models/PlayerData.cs b/FivePRS.Core/Models/PlayerData.cs index ecc1eae..8911539 100644 --- a/FivePRS.Core/Models/PlayerData.cs +++ b/FivePRS.Core/Models/PlayerData.cs @@ -8,6 +8,7 @@ public class PlayerData public string Name { get; set; } = string.Empty; public Department Department { get; set; } = Department.None; public string Agency { get; set; } = string.Empty; + public string Callsign { get; set; } = string.Empty; public bool IsOnDuty { get; set; } public int XP { get; set; } public int Rank { get; set; } = 1; diff --git a/FivePRS.Core/Text/GameText.cs b/FivePRS.Core/Text/GameText.cs new file mode 100644 index 0000000..1f682ca --- /dev/null +++ b/FivePRS.Core/Text/GameText.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Text; + +namespace FivePRS.Core.Text +{ + public static class GameText + { + public const int MaxComponentBytes = 96; + + public static IReadOnlyList SplitComponents(string text, int maxBytes = MaxComponentBytes) + { + var parts = new List(); + var start = 0; + + while (start < text.Length) + { + var length = NextLength(text, start, maxBytes); + parts.Add(text.Substring(start, length)); + start += length; + } + + return parts; + } + + private static int NextLength(string text, int start, int maxBytes) + { + var bytes = 0; + var inToken = false; + var safeLength = 0; + var byteLimitedLength = 0; + + for (var i = start; i < text.Length; i++) + { + var charLength = char.IsHighSurrogate(text[i]) && i + 1 < text.Length ? 2 : 1; + bytes += Encoding.UTF8.GetByteCount(text.Substring(i, charLength)); + if (bytes > maxBytes) break; + + if (text[i] == '~') inToken = !inToken; + + i += charLength - 1; + byteLimitedLength = i - start + 1; + if (!inToken) safeLength = byteLimitedLength; + } + + if (safeLength > 0) return safeLength; + return byteLimitedLength > 0 ? byteLimitedLength : 1; + } + } +} diff --git a/FivePRS.Police/Callouts/DomesticDisturbanceCallout.cs b/FivePRS.Police/Callouts/DomesticDisturbanceCallout.cs index 1a92e22..188914c 100644 --- a/FivePRS.Police/Callouts/DomesticDisturbanceCallout.cs +++ b/FivePRS.Police/Callouts/DomesticDisturbanceCallout.cs @@ -202,9 +202,9 @@ private async Task ResistiveOutcomeAsync(CancellationToken ct) API.TaskCombatPed(_aggressor.Handle, Game.PlayerPed.Handle, 0, 16); ClientBrain.ShowNotification( - "~r~Aggressor resisting~w~ | Subdue and ~b~/er_cuff~w~."); + $"~r~Aggressor resisting~w~ | Subdue and press {Binds.Cuff}."); - await WaitForArrestAsync(_aggressor, ct, "Subdue the suspect, then type ~b~/er_cuff~w~ to arrest"); + await WaitForArrestAsync(_aggressor, ct, $"Subdue the suspect, then press {Binds.Cuff} to arrest"); } private async Task ArmedOutcomeAsync(CancellationToken ct) @@ -229,9 +229,9 @@ private async Task ArmedOutcomeAsync(CancellationToken ct) API.TaskCombatPed(_aggressor.Handle, Game.PlayerPed.Handle, 0, 16); ClientBrain.ShowNotification( - "~r~WEAPON RAISED~w~ | Tase or shoot to disarm — then ~b~/er_cuff~w~."); + $"~r~WEAPON RAISED~w~ | Tase or shoot to disarm, then press {Binds.Cuff}."); - await WaitForArrestAsync(_aggressor, ct, "Subdue the suspect, then type ~b~/er_cuff~w~ to arrest"); + await WaitForArrestAsync(_aggressor, ct, $"Subdue the suspect, then press {Binds.Cuff} to arrest"); } public override void OnUpdate() diff --git a/FivePRS.Police/Callouts/TrafficStopCallout.cs b/FivePRS.Police/Callouts/TrafficStopCallout.cs index 3bba4f4..382b904 100644 --- a/FivePRS.Police/Callouts/TrafficStopCallout.cs +++ b/FivePRS.Police/Callouts/TrafficStopCallout.cs @@ -315,9 +315,9 @@ private async Task WarrantArrestOutcomeAsync(CancellationToken ct) await TaskManager.AssignTaskAsync(_driver, PedTaskType.PutHandsUp); ClientBrain.ShowNotification( - "~o~SUSPECT COMPLYING~w~ | Driver has a warrant, type ~b~/er_cuff~w~ to arrest."); + $"~o~SUSPECT COMPLYING~w~ | Driver has a warrant, press {Binds.Cuff} to arrest."); - await WaitForArrestAsync(_driver, ct, "Type ~b~/er_cuff~w~ to arrest the driver"); + await WaitForArrestAsync(_driver, ct, $"Press {Binds.Cuff} to arrest the driver"); } private async Task RunPursuitLoopAsync(CancellationToken ct) diff --git a/FivePRS.Police/Callouts/VehiclePursuitCallout.cs b/FivePRS.Police/Callouts/VehiclePursuitCallout.cs index 6e52259..9a26a38 100644 --- a/FivePRS.Police/Callouts/VehiclePursuitCallout.cs +++ b/FivePRS.Police/Callouts/VehiclePursuitCallout.cs @@ -222,7 +222,7 @@ private async Task SurrenderOutcomeAsync(Ped driver, CancellationToken ct) await Timing.WaitAsync(1_500, ct); await TaskManager.AssignTaskAsync(driver, PedTaskType.PutHandsUp); - ClientBrain.ShowNotification("~g~Suspect surrendering~w~ | Approach and ~b~/er_cuff~w~ to arrest."); + ClientBrain.ShowNotification($"~g~Suspect surrendering~w~ | Approach and press {Binds.Cuff} to arrest."); await WaitForArrestAsync(driver, ct); } diff --git a/FivePRS.Police/FivePRS.Police.csproj b/FivePRS.Police/FivePRS.Police.csproj index cb5698e..691ab11 100644 --- a/FivePRS.Police/FivePRS.Police.csproj +++ b/FivePRS.Police/FivePRS.Police.csproj @@ -14,7 +14,11 @@ - + + + $(PkgNewtonsoft_Json)\lib\portable-net40+sl5+win8+wp8+wpa81\Newtonsoft.Json.dll + + diff --git a/FivePRS.Server/Database/DatabaseManager.cs b/FivePRS.Server/Database/DatabaseManager.cs index 0238599..66d76ae 100644 --- a/FivePRS.Server/Database/DatabaseManager.cs +++ b/FivePRS.Server/Database/DatabaseManager.cs @@ -30,7 +30,8 @@ public async Task InitializeAsync(DatabaseType dbType, string? connectionString) else { var resourcePath = API.GetResourcePath(API.GetCurrentResourceName()); - SqliteNativeLoader.Load(Path.Combine(resourcePath, "server")); + var nativePath = SqliteNativeLoader.Load(Path.Combine(resourcePath, "server")); + Debug.WriteLine($"[FivePRS] Native SQLite loaded from {nativePath}."); _provider = new SQLiteProvider(connectionString ?? Path.Combine(resourcePath, "data", "fiveprs.db")); } diff --git a/FivePRS.Server/Database/MySqlProvider.cs b/FivePRS.Server/Database/MySqlProvider.cs index 19ac0f2..cd3fcb4 100644 --- a/FivePRS.Server/Database/MySqlProvider.cs +++ b/FivePRS.Server/Database/MySqlProvider.cs @@ -29,6 +29,7 @@ public async Task InitializeAsync() `xp` INT UNSIGNED DEFAULT 0, `rank_level` TINYINT UNSIGNED DEFAULT 1, `agency` VARCHAR(40) NOT NULL DEFAULT '', + `callsign` VARCHAR(16) NOT NULL DEFAULT '', `last_seen` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`license`) @@ -49,6 +50,7 @@ PRIMARY KEY (`id`), await cmd.ExecuteNonQueryAsync(); await EnsureColumnAsync(conn, "ers_players", "agency", "VARCHAR(40) NOT NULL DEFAULT ''"); + await EnsureColumnAsync(conn, "ers_players", "callsign", "VARCHAR(16) NOT NULL DEFAULT ''"); } public async Task GetPlayerAsync(string license) @@ -58,7 +60,7 @@ PRIMARY KEY (`id`), using var cmd = conn.CreateCommand(); cmd.CommandText = @" - SELECT `license`, `name`, `department`, `is_on_duty`, `xp`, `rank_level`, `last_seen`, `agency` + 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); @@ -75,6 +77,7 @@ PRIMARY KEY (`id`), Rank = reader.GetByte("rank_level"), LastSeen = reader.GetDateTime("last_seen"), Agency = reader.GetString("agency"), + Callsign = reader.GetString("callsign"), }; } @@ -86,13 +89,14 @@ public async Task SavePlayerAsync(PlayerData player) using var cmd = conn.CreateCommand(); cmd.CommandText = @" INSERT INTO `ers_players` - (`license`, `name`, `department`, `agency`, `is_on_duty`, `xp`, `rank_level`, `last_seen`) + (`license`, `name`, `department`, `agency`, `callsign`, `is_on_duty`, `xp`, `rank_level`, `last_seen`) VALUES - (@license, @name, @department, @agency, @onDuty, @xp, @rank, UTC_TIMESTAMP()) + (@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`), @@ -102,6 +106,7 @@ ON DUPLICATE KEY UPDATE 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); diff --git a/FivePRS.Server/Database/SQLiteProvider.cs b/FivePRS.Server/Database/SQLiteProvider.cs index dc8ee1d..b9582a2 100644 --- a/FivePRS.Server/Database/SQLiteProvider.cs +++ b/FivePRS.Server/Database/SQLiteProvider.cs @@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS ers_players ( xp INTEGER DEFAULT 0, rank_level INTEGER DEFAULT 1, agency TEXT NOT NULL DEFAULT '', + callsign TEXT NOT NULL DEFAULT '', last_seen TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); @@ -56,6 +57,7 @@ details TEXT NOT NULL await cmd.ExecuteNonQueryAsync(); await EnsureColumnAsync(conn, "ers_players", "agency", "TEXT NOT NULL DEFAULT ''"); + await EnsureColumnAsync(conn, "ers_players", "callsign", "TEXT NOT NULL DEFAULT ''"); } public async Task GetPlayerAsync(string license) @@ -65,7 +67,7 @@ details TEXT NOT NULL using var cmd = conn.CreateCommand(); cmd.CommandText = @" - SELECT license, name, department, is_on_duty, xp, rank_level, last_seen, agency + 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); @@ -83,6 +85,7 @@ details TEXT NOT NULL 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), }; } @@ -93,12 +96,13 @@ public async Task SavePlayerAsync(PlayerData player) using var cmd = conn.CreateCommand(); cmd.CommandText = @" - INSERT INTO ers_players (license, name, department, agency, is_on_duty, xp, rank_level, last_seen) - VALUES ($license, $name, $dept, $agency, $onDuty, $xp, $rank, $seen) + 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, @@ -108,6 +112,7 @@ ON CONFLICT(license) DO UPDATE SET 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); diff --git a/FivePRS.Server/Database/SqliteNativeLoader.cs b/FivePRS.Server/Database/SqliteNativeLoader.cs index 96fa14d..81b3b83 100644 --- a/FivePRS.Server/Database/SqliteNativeLoader.cs +++ b/FivePRS.Server/Database/SqliteNativeLoader.cs @@ -1,56 +1,121 @@ using System; using System.IO; using System.Runtime.InteropServices; -using CitizenFX.Core; +using SQLitePCL; namespace FivePRS.Server.Database { internal static class SqliteNativeLoader { + private const string LibraryName = "e_sqlite3"; private const int RtldNow = 2; private const int RtldGlobal = 0x100; private static bool _loaded; - [DllImport("kernel32", EntryPoint = "LoadLibraryW", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern IntPtr LoadLibrary(string path); - - [DllImport("libc", EntryPoint = "dlopen")] - private static extern IntPtr DlopenLibc(string path, int flags); - - [DllImport("libdl.so.2", EntryPoint = "dlopen")] - private static extern IntPtr DlopenLibdl(string path, int flags); - - public static void Load(string directory) + public static string Load(string directory) { - if (_loaded) return; - var windows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); var path = Path.Combine(directory, windows ? "e_sqlite3.dll" : "libe_sqlite3.so"); + if (_loaded) return path; if (!File.Exists(path)) throw new FileNotFoundException("Native SQLite library is missing from the resource.", path); - var handle = windows ? LoadLibrary(path) : Dlopen(path); - if (handle == IntPtr.Zero) + IGetFunctionPointer symbols = windows ? WindowsLibrary.Open(path) : UnixLibrary.Open(path); + + SQLite3Provider_dynamic_cdecl.Setup(LibraryName, symbols); + raw.SetProvider(new SQLite3Provider_dynamic_cdecl()); + raw.FreezeProvider(true); + + _loaded = true; + return path; + } + + private sealed class WindowsLibrary : IGetFunctionPointer + { + private readonly IntPtr _handle; + + private WindowsLibrary(IntPtr handle) => _handle = handle; + + public static WindowsLibrary Open(string path) { - var detail = windows ? $"Win32 error {Marshal.GetLastWin32Error()}" : "dlopen returned null"; - throw new DllNotFoundException($"Could not load native SQLite from {path} ({detail})."); + var handle = LoadLibraryW(path); + if (handle == IntPtr.Zero) + throw new DllNotFoundException($"Could not load native SQLite from {path} (Win32 error {Marshal.GetLastWin32Error()})."); + return new WindowsLibrary(handle); } - _loaded = true; - Debug.WriteLine($"[FivePRS] Native SQLite loaded from {path}."); + public IntPtr GetFunctionPointer(string name) => GetProcAddress(_handle, name); + + [DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr LoadLibraryW(string path); + + [DllImport("kernel32", CharSet = CharSet.Ansi, ExactSpelling = true)] + private static extern IntPtr GetProcAddress(IntPtr module, string name); } - private static IntPtr Dlopen(string path) + private sealed class UnixLibrary : IGetFunctionPointer { - try + private readonly IntPtr _handle; + private readonly bool _useLibdl; + + private UnixLibrary(IntPtr handle, bool useLibdl) + { + _handle = handle; + _useLibdl = useLibdl; + } + + public static UnixLibrary Open(string path) { - return DlopenLibc(path, RtldNow | RtldGlobal); + IntPtr handle; + bool useLibdl; + try + { + handle = Internal.dlopen(path, RtldNow | RtldGlobal); + useLibdl = false; + } + catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException) + { + handle = Libdl.dlopen(path, RtldNow | RtldGlobal); + useLibdl = true; + } + + if (handle == IntPtr.Zero) + { + var error = useLibdl ? Libdl.dlerror() : Internal.dlerror(); + var message = error == IntPtr.Zero ? "unknown error" : Marshal.PtrToStringAnsi(error); + throw new DllNotFoundException($"Could not load native SQLite from {path} ({message})."); + } + + return new UnixLibrary(handle, useLibdl); } - catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException) + + public IntPtr GetFunctionPointer(string name) => + _useLibdl ? Libdl.dlsym(_handle, name) : Internal.dlsym(_handle, name); + + private static class Internal { - return DlopenLibdl(path, RtldNow | RtldGlobal); + [DllImport("__Internal")] + public static extern IntPtr dlopen(string path, int flags); + + [DllImport("__Internal")] + public static extern IntPtr dlsym(IntPtr handle, string name); + + [DllImport("__Internal")] + public static extern IntPtr dlerror(); + } + + private static class Libdl + { + [DllImport("libdl.so.2")] + public static extern IntPtr dlopen(string path, int flags); + + [DllImport("libdl.so.2")] + public static extern IntPtr dlsym(IntPtr handle, string name); + + [DllImport("libdl.so.2")] + public static extern IntPtr dlerror(); } } } diff --git a/FivePRS.Server/Dispatch/DispatchService.cs b/FivePRS.Server/Dispatch/DispatchService.cs index 4cd6945..a219e41 100644 --- a/FivePRS.Server/Dispatch/DispatchService.cs +++ b/FivePRS.Server/Dispatch/DispatchService.cs @@ -90,7 +90,7 @@ public DispatchService(Func clock, Func settings, Fu public bool IsOnDuty(int unitId) => _units.ContainsKey(unitId); - public void SetOnDuty(int unitId, string name, Department department, int rank, string? agencyId = null) + public void SetOnDuty(int unitId, string name, Department department, int rank, string? agencyId = null, string? callsign = null) { SetOffDuty(unitId); @@ -107,13 +107,17 @@ public void SetOnDuty(int unitId, string name, Department department, int rank, unit.Info.Department = department; unit.Info.Agency = agency?.Id ?? string.Empty; unit.Info.Rank = rank; - unit.Info.Callsign = $"{prefix}-{unitId}"; + unit.Info.Callsign = callsign is null || callsign.Length == 0 ? $"{prefix}-{unitId}" : callsign; unit.Info.Status = UnitStatus.Available; _units[unitId] = unit; IsDirty = true; } + public bool IsCallsignTaken(string callsign, int exceptUnitId) => + _units.Values.Any(u => u.Info.ServerId != exceptUnitId && + string.Equals(u.Info.Callsign, callsign, StringComparison.OrdinalIgnoreCase)); + public void SetOffDuty(int unitId) { if (!_units.TryGetValue(unitId, out var unit)) return; diff --git a/FivePRS.Server/FivePRS.Server.csproj b/FivePRS.Server/FivePRS.Server.csproj index a613b01..2a86182 100644 --- a/FivePRS.Server/FivePRS.Server.csproj +++ b/FivePRS.Server/FivePRS.Server.csproj @@ -19,6 +19,7 @@ + diff --git a/FivePRS.Server/ServerBrain.cs b/FivePRS.Server/ServerBrain.cs index d3a20ad..102974c 100644 --- a/FivePRS.Server/ServerBrain.cs +++ b/FivePRS.Server/ServerBrain.cs @@ -47,9 +47,7 @@ public ServerBrain() EventHandlers[EventNames.ServerPlayerConnected] += new Action(OnPlayerReady); EventHandlers[EventNames.ServerToggleDuty] += new Action(OnToggleDuty); - EventHandlers[EventNames.ServerSetDepartment] += new Action(OnSetDepartment); - EventHandlers[EventNames.ServerSetAgency] += new Action(OnSetAgency); - EventHandlers[EventNames.ServerEnterService] += new Action(OnEnterService); + EventHandlers[EventNames.ServerEnterService] += new Action(OnEnterService); EventHandlers[EventNames.ServerRegisterCallouts] += new Action(OnRegisterCallouts); EventHandlers[EventNames.ServerCalloutResponse] += new Action(OnCalloutResponse); EventHandlers[EventNames.ServerCalloutEnded] += new Action(OnCalloutEnded); @@ -150,7 +148,7 @@ private async void OnPlayerReady([FromSource] Player player) } } - private async void OnEnterService([FromSource] Player player, int departmentId, string agencyId) + private async void OnEnterService([FromSource] Player player, int departmentId, string agencyId, string callsign) { if (!TryGetCached(player, out var license, out var data) || data.IsOnDuty) return; if (!Enum.IsDefined(typeof(Department), departmentId) || departmentId == (int)Department.None) return; @@ -163,9 +161,23 @@ private async void OnEnterService([FromSource] Player player, int departmentId, return; } + if (!Callsign.TryNormalize(callsign, out var normalizedCallsign)) + { + TriggerClientEvent(player, EventNames.ClientEntryRejected, + $"Callsigns can be up to {Callsign.MaxLength} letters, numbers or hyphens."); + return; + } + + if (normalizedCallsign.Length > 0 && _dispatch.IsCallsignTaken(normalizedCallsign, ServerId(player))) + { + TriggerClientEvent(player, EventNames.ClientEntryRejected, $"Callsign {normalizedCallsign} is already in use."); + return; + } + try { var agency = ConfigManager.Territories.ResolveAgency(department, agencyId)?.Id ?? string.Empty; + data.Callsign = normalizedCallsign; if (data.Department != department) { @@ -252,59 +264,6 @@ private async void OnToggleDuty([FromSource] Player player) } } - private async void OnSetDepartment([FromSource] Player player, int departmentId) - { - if (!Enum.IsDefined(typeof(Department), departmentId) || departmentId == (int)Department.None) return; - if (!TryGetCached(player, out var license, out var data)) return; - - var department = (Department)departmentId; - if (!_permissions.CanJoinDepartment(player.Handle, department)) - { - Notify(player, $"~r~You are not authorised to join {department}."); - Audit(AuditActions.PermissionDenied, player, license, $"department {department}"); - return; - } - - try - { - await ChangeDepartmentAsync(player, data, department); - Audit(AuditActions.DepartmentSet, player, license, department.ToString()); - } - catch (Exception ex) - { - Debug.WriteLine($"[FivePRS] Error setting department for {player.Name}: {ex.Message}"); - } - } - - private async void OnSetAgency([FromSource] Player player, string agencyId) - { - if (!TryGetCached(player, out var license, out var data)) return; - - var agency = ConfigManager.Territories.FindAgency(agencyId); - if (agency is null || agency.Department != data.Department) - { - Notify(player, $"~r~Unknown agency '{agencyId}' for {data.Department}."); - return; - } - - try - { - if (data.IsOnDuty) - await SetDutyAsync(player, data, false); - - data.Agency = agency.Id; - await _db.SavePlayerAsync(data); - SendPlayerData(player, data); - - Notify(player, $"~g~You are now with the {agency.Name}."); - Audit(AuditActions.AgencySet, player, license, agency.Id); - } - catch (Exception ex) - { - Debug.WriteLine($"[FivePRS] Error setting agency for {player.Name}: {ex.Message}"); - } - } - private void OnRegisterCallouts([FromSource] Player player, string json) { if (!_dispatch.IsOnDuty(ServerId(player))) return; @@ -401,7 +360,7 @@ private async Task SetDutyAsync(Player player, PlayerData data, bool onDuty) data.IsOnDuty = onDuty; if (onDuty) - _dispatch.SetOnDuty(ServerId(player), data.Name, data.Department, data.Rank, data.Agency); + _dispatch.SetOnDuty(ServerId(player), data.Name, data.Department, data.Rank, data.Agency, data.Callsign); else _dispatch.SetOffDuty(ServerId(player)); diff --git a/FivePRS.Tests/CallsignTests.cs b/FivePRS.Tests/CallsignTests.cs new file mode 100644 index 0000000..bbb489f --- /dev/null +++ b/FivePRS.Tests/CallsignTests.cs @@ -0,0 +1,29 @@ +using FivePRS.Core.Models; +using Xunit; + +namespace FivePRS.Tests +{ + public class CallsignTests + { + [Theory] + [InlineData("1-adam-12", "1-ADAM-12")] + [InlineData(" lspd21 ", "LSPD21")] + [InlineData("", "")] + [InlineData(null, "")] + public void TryNormalize_ValidInput_UppercasesAndTrims(string? input, string expected) + { + Assert.True(Callsign.TryNormalize(input, out var callsign)); + Assert.Equal(expected, callsign); + } + + [Theory] + [InlineData("ADAM 12")] + [InlineData("ADAM_12")] + [InlineData("ÄDAM")] + [InlineData("ABCDEFGHIJKLM")] + public void TryNormalize_InvalidInput_IsRejected(string input) + { + Assert.False(Callsign.TryNormalize(input, out _)); + } + } +} diff --git a/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs b/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs index de4948f..619b5da 100644 --- a/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs +++ b/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs @@ -21,13 +21,19 @@ public class ClientAssemblyCompatibilityTests "IsExternalInit", }; - public static IEnumerable ClientAssemblies() + public static IEnumerable ClientAssemblies() => ClientFiles("FivePRS.*.dll"); + + public static IEnumerable AllShippedClientAssemblies() => ClientFiles("*.dll"); + + private static IEnumerable ClientFiles(string pattern) { var dir = Path.Combine(RepoRoot(), "bin", "client"); if (!Directory.Exists(dir)) throw new InvalidOperationException($"{dir} not found; build FivePRS.sln before running tests."); - return Directory.GetFiles(dir, "FivePRS.*.dll").Select(path => new object[] { Path.GetFileName(path) }); + return Directory.GetFiles(dir, pattern) + .Where(path => !Path.GetFileName(path).StartsWith("CitizenFX.", StringComparison.Ordinal)) + .Select(path => new object[] { Path.GetFileName(path) }); } [Theory] @@ -49,7 +55,7 @@ public void ClientAssembly_HasNoMetadataUnsupportedByFiveMMono(string fileName) } [Theory] - [MemberData(nameof(ClientAssemblies))] + [MemberData(nameof(AllShippedClientAssemblies))] public void ClientAssembly_HasNoAttributesOnGenericParameters(string fileName) { using var stream = File.OpenRead(Path.Combine(RepoRoot(), "bin", "client", fileName)); diff --git a/FivePRS.Tests/DispatchServiceTests.cs b/FivePRS.Tests/DispatchServiceTests.cs index 9466161..24fa3f2 100644 --- a/FivePRS.Tests/DispatchServiceTests.cs +++ b/FivePRS.Tests/DispatchServiceTests.cs @@ -271,6 +271,24 @@ public void SetOnDuty_UsesAgencyCallsignPrefix() Assert.Equal("BCSO-1", unit.Callsign); } + [Fact] + public void SetOnDuty_WithCallsign_UsesIt() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1, "lspd", "1-ADAM-12"); + + Assert.Equal("1-ADAM-12", _dispatch.GetUnit(Officer)!.Callsign); + } + + [Fact] + public void IsCallsignTaken_OtherUnitHasIt_ReturnsTrue() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1, "lspd", "1-ADAM-12"); + + Assert.True(_dispatch.IsCallsignTaken("1-adam-12", Backup)); + Assert.False(_dispatch.IsCallsignTaken("1-ADAM-12", Officer)); + Assert.False(_dispatch.IsCallsignTaken("2-ADAM-12", Backup)); + } + [Fact] public void SetOnDuty_AgencyFromOtherDepartment_FallsBackToDefault() { diff --git a/FivePRS.Tests/GameTextTests.cs b/FivePRS.Tests/GameTextTests.cs new file mode 100644 index 0000000..6410df8 --- /dev/null +++ b/FivePRS.Tests/GameTextTests.cs @@ -0,0 +1,50 @@ +using System.Linq; +using System.Text; +using FivePRS.Core.Text; +using Xunit; + +namespace FivePRS.Tests +{ + public class GameTextTests + { + [Fact] + public void SplitComponents_ShortText_IsOnePart() + { + Assert.Equal(new[] { "~g~Suspect cuffed" }, GameText.SplitComponents("~g~Suspect cuffed")); + } + + [Theory] + [InlineData("~y~Welcome to FivePRS!~w~ Press ~b~F5~w~ or use ~b~/duty~w~ to go on duty.")] + [InlineData("~b~Los Santos Police Department~w~ | ~g~ON DUTY~w~ | Recruit loadout applied.~n~~w~ Your patrol vehicle is marked on the map.")] + [InlineData("~y~FivePRS Commands~w~~n~~b~/duty~w~ — Choose department, agency and callsign, or go off duty~n~~b~/er_profile~w~ — View rank and XP~n~~b~/er_accept~w~ — Accept incoming callout")] + public void SplitComponents_LongText_RejoinsExactlyWithinByteLimit(string text) + { + var parts = GameText.SplitComponents(text); + + Assert.Equal(text, string.Concat(parts)); + Assert.All(parts, part => Assert.True(Encoding.UTF8.GetByteCount(part) <= GameText.MaxComponentBytes)); + } + + [Fact] + public void SplitComponents_NeverSplitsInsideColourCode() + { + var text = new string('a', GameText.MaxComponentBytes - 1) + "~b~blue text"; + + var parts = GameText.SplitComponents(text); + + Assert.All(parts, part => Assert.Equal(0, part.Count(c => c == '~') % 2)); + Assert.Equal(text, string.Concat(parts)); + } + + [Fact] + public void SplitComponents_NeverSplitsMultiByteCharacter() + { + var text = new string('a', GameText.MaxComponentBytes - 1) + "——"; + + var parts = GameText.SplitComponents(text); + + Assert.Equal(text, string.Concat(parts)); + Assert.All(parts, part => Assert.True(Encoding.UTF8.GetByteCount(part) <= GameText.MaxComponentBytes)); + } + } +} diff --git a/FivePRS.Tests/SQLiteProviderTests.cs b/FivePRS.Tests/SQLiteProviderTests.cs new file mode 100644 index 0000000..40f97db --- /dev/null +++ b/FivePRS.Tests/SQLiteProviderTests.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using FivePRS.Core.Models; +using FivePRS.Server.Database; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace FivePRS.Tests +{ + public sealed class SQLiteProviderTests : IDisposable + { + private readonly string _path = Path.Combine(Path.GetTempPath(), $"fiveprs-test-{Guid.NewGuid():N}.db"); + + [Fact] + public async Task InitializeAsync_ExistingDatabaseWithoutCallsign_AddsColumnAndKeepsPlayers() + { + using (var conn = new SqliteConnection($"Data Source={_path}")) + { + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" + CREATE TABLE ers_players ( + license TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, department INTEGER DEFAULT 0, + is_on_duty INTEGER DEFAULT 0, xp INTEGER DEFAULT 0, rank_level INTEGER DEFAULT 1, + last_seen TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))); + INSERT INTO ers_players (license, name, department, xp, rank_level) VALUES ('license:old', 'Veteran', 1, 40, 3);"; + await cmd.ExecuteNonQueryAsync(); + } + + var provider = new SQLiteProvider(_path); + await provider.InitializeAsync(); + + var player = await provider.GetPlayerAsync("license:old"); + Assert.NotNull(player); + Assert.Equal("Veteran", player!.Name); + Assert.Equal(3, player.Rank); + Assert.Equal(string.Empty, player.Callsign); + } + + [Fact] + public async Task SavePlayerAsync_Callsign_RoundTrips() + { + var provider = new SQLiteProvider(_path); + await provider.InitializeAsync(); + + await provider.SavePlayerAsync(new PlayerData + { + License = "license:new", + Name = "Rookie", + Department = Department.Police, + Agency = "lspd", + Callsign = "1-ADAM-12", + }); + + var player = await provider.GetPlayerAsync("license:new"); + Assert.Equal("1-ADAM-12", player!.Callsign); + Assert.Equal("lspd", player.Agency); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + foreach (var file in new[] { _path, _path + "-wal", _path + "-shm" }) + { + if (File.Exists(file)) File.Delete(file); + } + } + } +} diff --git a/README.md b/README.md index 543a441..0d6aad3 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,19 @@ Admin commands work from the server console or in game with `fiveprs.admin`: | `fprs_offduty ` | Force a player off duty | | `fprs_endcall ` | Close an active call | -## Joining & Entry Screen -When a player spawns, an entry screen shows their rank and XP and lets them pick a department and agency, then go on duty or continue as a civilian. Only departments the player is permitted to join are offered. Reopen it with `/fiveprs` while off duty, or turn it off with `"showEntryScreen": false` in `config/settings.json`. +## Going On Duty +Press `F5` or use `/duty` to open the duty menu. It shows the player's rank and XP and lets them pick a department, agency and callsign before going on duty. Only departments the player is permitted to join are offered. Callsigns are up to 12 letters, numbers or hyphens, are saved to the player's profile, and must be unique among units on duty; leaving it blank uses the agency's default (for example `LSPD-12`). While on duty, `F5` / `/duty` takes the player off duty. ## Loading Screen `fiveprs_loadscreen` is an optional addon in `fiveprs_addons` with a FivePRS-branded loading screen: load progress, rotating tips and a welcome back card with the player's agency and rank. Add `ensure fiveprs_loadscreen` to `server.cfg` to use it, and set your server name and tips in its `config.js`. Leave it out if your server already has a loading screen. +For background music, put `.mp3` or `.ogg` files in the addon's `music/` folder and list them under `music.tracks` in `config.js`, along with `volume` and `shuffle`. Direct links to audio files also work. No music ships with FivePRS, so only use tracks you have the rights to. Players can mute it from the button in the top-right corner, and the choice is remembered. + ## Mobile Data Terminal On-duty units open the MDT with `F7` or `/mdt`. It shows your unit and status, every active call with its territory and assigned units, and all units on duty. From the MDT you can set your status, attach to a call as backup and set a waypoint to a call. The interface lives in `nui/` as plain HTML, CSS and JavaScript, so it can be restyled without rebuilding the resource. ## Jurisdictions -`config/jurisdictions.json` defines territories as map polygons and the agencies that patrol them. Players choose an agency within their department with `/setagency`, and dispatch only offers calls to units inside their agency's territory. An agency with no territories is unrestricted. The bundled split (Los Santos for LSPD, Blaine County for BCSO) is a coarse default; adjust the polygons to suit your server. +`config/jurisdictions.json` defines territories as map polygons and the agencies that patrol them. Players choose an agency within their department from the duty menu, and dispatch only offers calls to units inside their agency's territory. An agency with no territories is unrestricted. The bundled split (Los Santos for LSPD, Blaine County for BCSO) is a coarse default; adjust the polygons to suit your server. ## Agency Loadouts & Vehicles `config/police_loadouts.json` and `config/police_vehicles.json` define the default weapons, uniforms and patrol vehicles per rank tier. Any agency can override individual tiers under `agencies`; anything not overridden falls back to the defaults. BCSO ships with the `sheriff` and `sheriff2` vehicles. @@ -86,3 +88,8 @@ Uniforms use freemode clothing slots by default, which keeps the player's charac The original model and clothing are restored when going off duty, but freemode facial customisation is not, so ped models suit servers that do not use freemode characters. Duty changes, department changes, XP awards, rank-ups, admin actions and denied permission checks are recorded in the `fiveprs_audit` table. + +## Troubleshooting +**`Could not load assembly Windows` / `Microsoft.Windows.SDK.NET` stack traces on start.** These appear once each time the resource starts with SQLite and are harmless. `Microsoft.Data.Sqlite` checks whether it is running as a Windows Store app by looking for those assemblies; FiveM logs each failed lookup with a full stack trace, then SQLite carries on normally. Startup succeeded if `[FivePRS] Database (SQLite) ready.` follows them. MySQL does not produce these messages. + +**`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/addons/fiveprs_loadscreen/app.js b/addons/fiveprs_loadscreen/app.js index 7203fb6..1343f61 100644 --- a/addons/fiveprs_loadscreen/app.js +++ b/addons/fiveprs_loadscreen/app.js @@ -65,6 +65,84 @@ window.addEventListener("message", (event) => { } }); +function setupMusic() { + const music = config.music ?? {}; + const tracks = (Array.isArray(music.tracks) ? music.tracks : []).filter(Boolean); + if (tracks.length === 0) return; + + if (music.shuffle) { + for (let i = tracks.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [tracks[i], tracks[j]] = [tracks[j], tracks[i]]; + } + } + + const audio = new Audio(); + audio.volume = Math.max(0, Math.min(1, Number(music.volume ?? 0.3))); + let index = 0; + + const toggle = document.getElementById("music-toggle"); + const label = document.getElementById("music-label"); + const waves = document.getElementById("music-waves"); + const mutedIcon = document.getElementById("music-muted"); + + let muted = false; + try { + muted = localStorage.getItem("fiveprs-loadscreen-muted") === "1"; + } catch { + muted = false; + } + + function render() { + audio.muted = muted; + label.textContent = muted ? "Music off" : "Music on"; + toggle.setAttribute("aria-pressed", String(muted)); + waves.hidden = muted; + mutedIcon.hidden = !muted; + } + + function play() { + audio.play().catch(() => { + document.addEventListener("click", () => audio.play().catch(() => {}), { once: true }); + }); + } + + function load(i) { + index = i % tracks.length; + audio.src = tracks[index]; + play(); + } + + let failures = 0; + audio.addEventListener("playing", () => { + failures = 0; + }); + audio.addEventListener("ended", () => load(index + 1)); + audio.addEventListener("error", () => { + failures += 1; + if (failures < tracks.length) { + load(index + 1); + } else { + toggle.hidden = true; + } + }); + + toggle.addEventListener("click", (event) => { + event.stopPropagation(); + muted = !muted; + try { + localStorage.setItem("fiveprs-loadscreen-muted", muted ? "1" : "0"); + } catch {} + render(); + if (!muted && audio.paused) play(); + }); + + toggle.hidden = false; + render(); + load(0); +} + showWelcome(); showTip(); setInterval(showTip, 7000); +setupMusic(); diff --git a/addons/fiveprs_loadscreen/config.js b/addons/fiveprs_loadscreen/config.js index b2034bd..d41e40b 100644 --- a/addons/fiveprs_loadscreen/config.js +++ b/addons/fiveprs_loadscreen/config.js @@ -1,12 +1,19 @@ window.FIVEPRS_LOADSCREEN = { - serverName: "", + serverName: "FivePRS Test Server", tips: [ - "Press F5 to go on or off duty.", + "Press F5 to choose your department, agency and callsign, then go on duty.", "Press F7 to open the Mobile Data Terminal while on duty.", "Use /er_calls to see active calls and /er_attach to respond as backup.", "Press G near a suspect to cuff them and H in a vehicle to escort them.", - "Use /setagency to switch between agencies in your department.", + "Press F5 again while on duty to go off duty.", "Units only receive calls inside their agency's jurisdiction.", "Press F6 to check your rank and XP progress.", ], + music: { + tracks: [ + "music/radio_chatter.mp3" + ], + volume: 0.3, + shuffle: true, + }, }; diff --git a/addons/fiveprs_loadscreen/fxmanifest.lua b/addons/fiveprs_loadscreen/fxmanifest.lua index 892bf77..79578f5 100644 --- a/addons/fiveprs_loadscreen/fxmanifest.lua +++ b/addons/fiveprs_loadscreen/fxmanifest.lua @@ -9,6 +9,7 @@ license 'AGPL-3.0-or-later' version '1.0.0' loadscreen 'index.html' +loadscreen_cursor 'yes' files { 'index.html', @@ -17,4 +18,6 @@ files { 'app.js', 'topo.svg', 'Magz.otf', + 'music/*.mp3', + 'music/*.ogg', } diff --git a/addons/fiveprs_loadscreen/index.html b/addons/fiveprs_loadscreen/index.html index ec7948d..210dcb2 100644 --- a/addons/fiveprs_loadscreen/index.html +++ b/addons/fiveprs_loadscreen/index.html @@ -25,6 +25,15 @@

fiveprs< + +