diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d461c6..0bb0255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.9.0-ptb.5] - 2026-10-14 + +### Added +- A redesigned terminal: new home screen with the time, your location, an incoming call you can accept from home, and unit, call and shift counts. Every app has a new layout, with bundled fonts so it looks the same offline +- Character photos: take an in-game mugshot, with no extra resource needed, or use an https image link. Photos show on the character, on license cards and in police Records +- Licenses are shown as ID cards with the photo, a license number, class, issue and expiry dates and a status stamp. Show a card to the person next to you from the Licenses app. Each license type's card title, issuer, colours and background art are set in `licenses.json`, along with the state name used on cards, plates and state IDs +- Records has a warrant banner, the person's photo and quick flags, and an offence picker that fills in the fine from `citations.json` +- Records for police in the field: a Current stop card to run the stopped vehicle's plate and open the driver's record, Vehicle ahead to run the plate in front of you, and Person ahead to identify the person you're facing. Searching the plate of a nearby car finds its owner even if nobody has run it before +- Records for Fire and EMS: identity, photo and medical history, with a caution banner when the person has an active warrant. They add medical entries; licenses, vehicles and the criminal record stay with police. Some people on the street now have medical conditions on file +- Patient care and incident reports: handing a patient over at the hospital, or closing a fire call, drafts a report with the address and the people or plate involved. Everyone on the call can edit and file it from the Reports tab in Records, and drafts nobody files are filed after 30 minutes. Filing a patient report adds it to the patient's medical history. Filed reports can be searched by address, call number, plate and date +- Dispatch can request backup from your active call, and shows distances to calls +- 911 shows your call's progress: received, units responding, on scene +- Duty shows your next rank and your calls, arrests and hours on duty, which are now tracked +- Admin has sections for players, the roster, active calls, the audit log and server info, with set rank, grant XP, set department, revive, teleport and force off duty for each player +- Settings has text size, a 24-hour clock, reduced motion, the AI callouts switch and your current key bindings +- Named ranks for each department in `config/departments//ranks.json`: set each rank's title, the XP to reach it and the tier of weapons, uniforms and vehicles it unlocks. Agencies can give ranks their own titles (BCSO ships with Deputy and Sheriff). The defaults keep the existing progression, and ranks are shown by name in the terminal, the station menu, notifications and the loading screen +- Errors are saved in full to `data/logs`, one file per day, kept for 14 days. The console shows one line with the cause +- Callouts and plugins can log through FivePRS: `Log` in callouts, `Logger.For(name)` anywhere else + +### Changed +- The `config/` folder is organised into `departments/`, `world/` and `civilian/`. On start, FivePRS moves files from the old layout into place and creates any missing `ranks.json` +- The console says "Created a new database" when it starts on an empty database, instead of listing the first migration +- Console messages are shorter and coloured by type: green for success, yellow for warnings, red for errors. Debug messages only show with `fiveprs_debug` on, which now also works on clients when set with `setr` +- Every built-in terminal app (Duty, Dispatch, Records, Characters, Licenses, Vehicles, 911 and Admin) is now a page app on the same framework addons use, so they all open, update and badge the same way +- Mugshots no longer need the `screenshot-basic` resource +- The shift timer on the home screen counts seconds and keeps counting through a terminal reload +- Database schema 3 adds the reports table. SQLite databases are backed up before updating + +### Fixed +- Your character's look is applied as you spawn, whichever spawn script you use, instead of leaving you as the map's ped +- Joining spawns you at your last location as your character even when the client is slow to start, instead of spawning at the map's spawn point and teleporting +- Joining after the loading screen closes early no longer spawns you at the map's spawn point instead of your last location +- The welcome message shows once after you spawn, instead of during the loading screen and again when switching characters +- The FivePRS server icon no longer reports that it failed when `load_server_icon` is just slow to apply +- Admin commands show success in green and problems in red, in game and in the console, and reject missing or extra arguments with their usage +- `fprs_setrank` only accepts ranks that exist in the player's department, and `fprs_addxp` up to 1,000,000 XP at a time +- `fprs_setdept` only accepts department names, and lists them when the name is wrong +- `fprs_revive` says when the player isn't down instead of reporting a revive +- `/backup` rejects options it doesn't know instead of sending a Code 3 request +- `/er_status` only accepts `available` or `busy`, and `/er_status` and `/er_attach` say when you're off duty +- `/fprs_coords` rejects point names it doesn't know instead of copying a different format +- `/er_profile` says when your profile hasn't loaded yet ## [0.9.0-ptb.4] - 2026-10-11 diff --git a/FivePRS.Client/Agency/BaseAgency.cs b/FivePRS.Client/Agency/BaseAgency.cs index 19b9428..7ea501d 100644 --- a/FivePRS.Client/Agency/BaseAgency.cs +++ b/FivePRS.Client/Agency/BaseAgency.cs @@ -8,6 +8,7 @@ using FivePRS.Core.Config; using FivePRS.Core.Events; using FivePRS.Core.Interfaces; +using FivePRS.Core.Logging; using FivePRS.Core.Models; using Newtonsoft.Json; @@ -15,6 +16,10 @@ namespace FivePRS.Client.Agency { public abstract class BaseAgency : BaseScript, IAgency { + private Logger? _log; + + protected Logger Log => _log ??= Logger.For(AgencyName); + private static readonly HashSet LoadedDepartments = new(); public abstract Department Department { get; } @@ -46,13 +51,13 @@ protected BaseAgency() public virtual Task OnDuty(PlayerData player) { - Debug.WriteLine($"[{AgencyName}] {player.Name} is ON DUTY."); + Log.Debug($"{player.Name} is on duty."); return Task.FromResult(0); } public virtual Task OffDuty(PlayerData player) { - Debug.WriteLine($"[{AgencyName}] {player.Name} is OFF DUTY."); + Log.Debug($"{player.Name} is off duty."); return Task.FromResult(0); } @@ -75,7 +80,7 @@ private async void OnLocalDutyChanged(bool isOnDuty, int departmentId) } catch (Exception ex) { - Debug.WriteLine($"[{AgencyName}] Duty transition failed: {ex}"); + Log.Error("Duty change failed", ex); } } @@ -91,7 +96,7 @@ private async void OnLocalCalloutReceived(string calloutJson) } catch (Exception ex) { - Debug.WriteLine($"[{AgencyName}] Callout handling failed: {ex}"); + Log.Error("Callout handling failed", ex); } } @@ -102,7 +107,7 @@ private void OnResourceStop(string resourceName) IsActive = false; if (Active == this) Active = null; try { _ = OffDuty(CurrentPlayer); } - catch (Exception ex) { Debug.WriteLine($"[{AgencyName}] Cleanup on stop failed: {ex.Message}"); } + catch (Exception ex) { Log.Warn("Cleanup on stop failed", ex); } } protected static void Notify(string message) => ClientBrain.ShowNotification(message); diff --git a/FivePRS.Client/Agency/DepartmentAgency.cs b/FivePRS.Client/Agency/DepartmentAgency.cs index 4b7066c..211fcb5 100644 --- a/FivePRS.Client/Agency/DepartmentAgency.cs +++ b/FivePRS.Client/Agency/DepartmentAgency.cs @@ -6,6 +6,7 @@ using FivePRS.Client.Stations; using FivePRS.Client.VehicleSpawner; using FivePRS.Core.Config; +using FivePRS.Core.Logging; using FivePRS.Core.Models; namespace FivePRS.Client.Agency @@ -37,7 +38,7 @@ public override async Task OnDuty(PlayerData player) await base.OnDuty(player); _registry.DiscoverAll(); - Debug.WriteLine($"[{AgencyName}] {_registry.Count} callout(s) registered."); + Log.Debug($"{_registry.Count} callouts registered."); _dispatcher.Start(); var mode = StationService.TakePendingStart(out var station); @@ -164,7 +165,7 @@ private void OnCalloutEnded(CalloutBase callout, CalloutResult result) else if (result == CalloutResult.Failed) Notify("~r~CALLOUT FAILED~w~ | No XP awarded."); - Debug.WriteLine($"[{AgencyName}] Callout '{callout.Data.Name}' ended: {result}"); + Log.Debug($"Callout '{callout.Data.Name}' ended: {result}."); } } } diff --git a/FivePRS.Client/App/AdminPanel.cs b/FivePRS.Client/App/AdminPanel.cs index 548ccbf..99bee26 100644 --- a/FivePRS.Client/App/AdminPanel.cs +++ b/FivePRS.Client/App/AdminPanel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using FivePRS.Core.Config; using FivePRS.Core.Events; using FivePRS.Core.Models; using Newtonsoft.Json; @@ -32,6 +33,10 @@ public static void SetRoster(IDictionary data) NuiData.GetBool(data, "granted"), _state?.Query ?? string.Empty); } + public static void Act(IDictionary data) => + ClientEvents.TriggerServer(EventNames.ServerAdminAction, NuiData.GetString(data, "action"), + NuiData.GetString(data, "target"), NuiData.GetString(data, "value"), _state?.Query ?? string.Empty); + public static object? BuildView() { if (_state is null) return null; @@ -39,6 +44,9 @@ public static void SetRoster(IDictionary data) return new { State = _state, + Ranks = ConfigManager.Departments.ToDictionary( + d => d.ToString(), + d => Enumerable.Range(1, ConfigManager.RanksFor(d).Count).Select(rank => ConfigManager.RanksFor(d).NameFor(rank)).ToList()), Departments = Enum.GetValues(typeof(Department)) .Cast() .Where(d => d != Department.None) diff --git a/FivePRS.Client/App/AppClient.cs b/FivePRS.Client/App/AppClient.cs index 67453b8..a2cc444 100644 --- a/FivePRS.Client/App/AppClient.cs +++ b/FivePRS.Client/App/AppClient.cs @@ -7,8 +7,11 @@ using FivePRS.Client.Callouts; using FivePRS.Client.Dispatch; using FivePRS.Client.Terminal; +using FivePRS.Client.Terminal.Apps; +using FivePRS.Core.Civilian; using FivePRS.Core.Config; using FivePRS.Core.Events; +using FivePRS.Core.Logging; using FivePRS.Core.Models; using FivePRS.Core.Text; using Newtonsoft.Json; @@ -18,12 +21,15 @@ namespace FivePRS.Client.App { public class AppClient : BaseScript { + private static readonly Logger Log = Logger.For("Terminal"); + private static readonly JsonSerializerSettings JsonSettings = new() { ContractResolver = new CamelCasePropertyNamesContractResolver(), }; private const int CreatorAfterDutyMs = 2_000; + private const int MugshotSettleMs = 250; private bool _open; private int? _creatorPending; @@ -37,53 +43,29 @@ public AppClient() EventHandlers[EventNames.ClientCivilianError] += new Action(OnCivilianError); EventHandlers[EventNames.ClientLookupResult] += new Action(OnLookupResult); EventHandlers[EventNames.ClientLookupError] += new Action(OnLookupError); + EventHandlers[EventNames.ClientReports] += new Action(OnReports); EventHandlers[EventNames.ClientEmergencyStatus] += new Action(OnEmergencyStatus); EventHandlers[EventNames.ClientEmergencyError] += new Action(OnEmergencyError); EventHandlers[EventNames.ClientAdminState] += new Action(OnAdminState); EventHandlers[EventNames.LocalDutyChanged] += new Action(OnDutyChanged); EventHandlers[EventNames.LocalOpenMenu] += new Action(OnOpenRequested); - EventHandlers[EventNames.ClientPreferences] += new Action(OnPreferences); + EventHandlers[EventNames.ClientPreferences] += new Action(OnPreferences); + EventHandlers[EventNames.ClientAdminTeleport] += new Action(OnAdminTeleport); + EventHandlers[EventNames.ClientShowCard] += new Action(OnShowCard); EventHandlers["onClientResourceStop"] += new Action(OnResourceStop); DispatchClient.SnapshotUpdated += Refresh; TerminalApps.Changed += Refresh; CalloutDispatcher.StateChanged += Refresh; + CivilianPanel.AppearanceRequested += OpenCreator; + CivilianPanel.MugshotRequested += TakeMugshot; API.RegisterCommand(KeyCommands.Menu, new Action, string>((_, __, ___) => Toggle()), false); API.RegisterKeyMapping(KeyCommands.Menu, "FivePRS: Open menu", "keyboard", "F5"); RegisterCallback("appClose", _ => Close()); - RegisterCallback("dutyEnter", data => OnDutyEnter(data)); - RegisterCallback("dutyOff", _ => DutyPanel.GoOffDuty()); - RegisterCallback("setStatus", data => DispatchPanel.SetStatus(data)); - RegisterCallback("attach", data => DispatchPanel.Attach(data)); - RegisterCallback("waypoint", data => DispatchPanel.Waypoint(data)); - RegisterCallback("offerAccept", _ => CalloutDispatcher.AcceptOffer()); - RegisterCallback("offerDecline", _ => CalloutDispatcher.DeclineOffer()); - RegisterCallback("endCall", _ => CalloutDispatcher.EndActiveCall()); - RegisterCallback("civCreateCharacter", data => CivilianPanel.CreateCharacter(data)); - RegisterCallback("civSelectCharacter", data => CivilianPanel.SelectCharacter(data)); - RegisterCallback("civDeleteCharacter", data => CivilianPanel.DeleteCharacter(data)); - RegisterCallback("civApplyLicense", data => CivilianPanel.ApplyLicense(data)); - RegisterCallback("civRegisterVehicle", _ => OnRegisterVehicle()); - RegisterCallback("civRemoveVehicle", data => CivilianPanel.RemoveVehicle(data)); - RegisterCallback("civAppearance", _ => OpenCreator()); - RegisterCallback("appOpen", data => OnAppOpen(data)); - RegisterCallback("appAction", data => OnAppAction(data)); - RegisterCallback("civSetVehicleStolen", data => CivilianPanel.SetVehicleStolen(data)); - RegisterCallback("recSearchName", data => RecordsPanel.SearchName(data)); - RegisterCallback("recSearchPlate", data => RecordsPanel.SearchPlate(data)); - RegisterCallback("recOpen", data => RecordsPanel.Open(data)); - RegisterCallback("recBack", _ => { RecordsPanel.CloseRecord(); Refresh(); }); - RegisterCallback("recIssue", data => RecordsPanel.Issue(data)); - RegisterCallback("recResolve", data => RecordsPanel.Resolve(data)); - RegisterCallback("recSetLicense", data => RecordsPanel.SetLicense(data)); - RegisterCallback("recFlagVehicle", data => RecordsPanel.FlagVehicle(data)); - RegisterCallback("emCall", data => EmergencyPanel.Call(data)); - RegisterCallback("emCancel", _ => EmergencyPanel.Cancel()); - RegisterCallback("callClear", data => EmergencyPanel.ClearCall(data)); - RegisterCallback("adminSearch", data => AdminPanel.Search(data)); - RegisterCallback("adminRoster", data => AdminPanel.SetRoster(data)); + RegisterCallback("appOpen", data => OnAppOpen(data)); + RegisterCallback("appAction", data => OnAppAction(data)); } private void OnPlayerData(string json) @@ -96,7 +78,7 @@ private void OnPlayerData(string json) } catch (Exception ex) { - Debug.WriteLine($"[AppClient] Failed to parse player data: {ex.Message}"); + Log.Error("Could not read player data", ex); return; } @@ -111,7 +93,7 @@ private void OnEntryOptions(string json) } catch (Exception ex) { - Debug.WriteLine($"[AppClient] Failed to parse entry options: {ex.Message}"); + Log.Error("Could not read entry options", ex); return; } @@ -121,7 +103,7 @@ private void OnEntryOptions(string json) private void OnRejected(string message) { if (_open) - Send("rejected", message); + DutyApp.ShowError(message); else ClientBrain.ShowNotification($"~r~{message}"); } @@ -135,7 +117,7 @@ private void OnCivilianState(string json) } catch (Exception ex) { - Debug.WriteLine($"[AppClient] Failed to parse civilian state: {ex.Message}"); + Log.Error("Could not read civilian state", ex); return; } @@ -158,7 +140,7 @@ private void OnCivilianState(string json) private void OnCivilianError(string message) { if (_open) - Send("civilianError", message); + CivilianPanel.ShowError(message); else ClientBrain.ShowNotification($"~r~{message}"); } @@ -171,7 +153,22 @@ private void OnLookupResult(string json) } catch (Exception ex) { - Debug.WriteLine($"[AppClient] Failed to parse lookup result: {ex.Message}"); + Log.Error("Could not read a lookup result", ex); + return; + } + + Refresh(); + } + + private void OnReports(string json) + { + try + { + RecordsPanel.SetReports(json); + } + catch (Exception ex) + { + Log.Error("Could not read reports", ex); return; } @@ -181,7 +178,7 @@ private void OnLookupResult(string json) private void OnLookupError(string message) { if (_open) - Send("recordsError", message); + RecordsApp.ShowError(message); else ClientBrain.ShowNotification($"~r~{message}"); } @@ -190,11 +187,11 @@ private void OnEmergencyStatus(string json) { try { - EmergencyPanel.SetStatus(json); + EmergencyApp.SetStatus(json); } catch (Exception ex) { - Debug.WriteLine($"[AppClient] Failed to parse 911 status: {ex.Message}"); + Log.Error("Could not read 911 status", ex); return; } @@ -204,7 +201,7 @@ private void OnEmergencyStatus(string json) private void OnEmergencyError(string message) { if (_open) - Send("emergencyError", message); + EmergencyApp.ShowError(message); else ClientBrain.ShowNotification($"~r~{message}"); } @@ -217,7 +214,7 @@ private void OnAdminState(string json) } catch (Exception ex) { - Debug.WriteLine($"[AppClient] Failed to parse admin state: {ex.Message}"); + Log.Error("Could not read admin state", ex); return; } @@ -240,12 +237,50 @@ private void OnAppAction(IDictionary data) Refresh(); } - private void OnPreferences(string wallpaper) + private void OnPreferences(string wallpaper, string settings) { - TerminalPanel.SetWallpaper(wallpaper); + TerminalPanel.SetPreferences(wallpaper, settings); Refresh(); } + private static void OnShowCard(string json) + { + try + { + var card = JsonConvert.DeserializeObject(json); + if (card is not null) SendTo("card", "show", card); + } + catch (Exception ex) + { + Log.Warn("Discarded a malformed license card", ex); + } + } + + private static async void OnAdminTeleport(float x, float y, float z) => + await Teleporter.ToAsync(new Vector3(x, y, z), Game.PlayerPed.Heading); + + private async void TakeMugshot(int characterId) + { + if (Game.PlayerPed.IsInVehicle()) + { + CivilianPanel.ShowError("Step out of your vehicle to take a mugshot."); + return; + } + + Close(); + await Delay(MugshotSettleMs); + + var photo = await Mugshot.CaptureAsync(); + if (photo is null) + { + ClientBrain.ShowNotification("~r~The mugshot couldn't be taken.~w~ Try again, or use an image link instead.", "Characters"); + return; + } + + CivilianPanel.SetMugshot(characterId, photo); + OpenWhenLoaded("characters"); + } + private void OpenCreator() { var active = CivilianPanel.ActiveCharacter; @@ -255,13 +290,6 @@ private void OpenCreator() CharacterCreator.Open(active.Id, active.FullName); } - private void OnRegisterVehicle() - { - var error = CivilianPanel.RegisterVehicle(); - if (error is not null) - Send("civilianError", error); - } - private async void OnDutyChanged(bool isOnDuty, int departmentId) { DutyPanel.SetOnDuty(isOnDuty); @@ -278,13 +306,6 @@ private async void OnDutyChanged(bool isOnDuty, int departmentId) if (AppearanceManager.Current is null || AppearanceManager.CharacterId != active.Id) OpenCreator(); } - private void OnDutyEnter(IDictionary data) - { - var error = DutyPanel.Enter(data); - if (error is not null) - Send("rejected", error); - } - private void Toggle() { if (_open) @@ -296,12 +317,12 @@ private void Toggle() OpenWhenLoaded(null); } - private void OnOpenRequested(string tab) + private void OnOpenRequested(string app) { - if (!_open) OpenWhenLoaded(tab); + if (!_open) OpenWhenLoaded(app); } - private void OpenWhenLoaded(string? tab) + private void OpenWhenLoaded(string? app) { if (!DutyPanel.IsLoaded) { @@ -309,19 +330,19 @@ private void OpenWhenLoaded(string? tab) return; } - Open(tab); + Open(app); } - private void Open(string? tab) + private void Open(string? app) { if (!_open) Tick += HoldFocusAsync; _open = true; NuiFocus.Take(); - Send("open", BuildState(tab)); + Send("open", BuildState(app)); CivilianPanel.RequestState(); - EmergencyPanel.RequestStatus(); + EmergencyApp.RequestStatus(); AdminPanel.Request(); } @@ -337,8 +358,10 @@ private void Close() private void Refresh() { - if (_open) - Send("update", BuildState(null)); + if (!_open) return; + + Send("update", BuildState(null)); + TerminalApps.PushOpenState(); } private async Task HoldFocusAsync() @@ -355,41 +378,27 @@ private void OnResourceStop(string resourceName) NuiFocus.Release(); } - private static object BuildState(string? requestedTab) + private static object BuildState(string? requestedApp) { - var dispatch = DispatchPanel.BuildView(); - var tabs = new List { "duty" }; - var records = RecordsPanel.BuildView(); - if (dispatch is not null) tabs.Add("dispatch"); - if (records is not null) tabs.Add("records"); - tabs.Add("civilian"); - tabs.Add("emergency"); - var admin = AdminPanel.BuildView(); - if (admin is not null) tabs.Add("admin"); - - var defaultTab = requestedTab is not null && tabs.Contains(requestedTab) ? requestedTab : null; - return new { - Tabs = tabs, Nameplate = ConfigManager.Settings.Branding.Nameplate, Icons = ConfigManager.Settings.Branding.Departments, Terminal = TerminalPanel.BuildView(), Apps = TerminalApps.BuildList(), AppView = TerminalApps.BuildOpenView(), Context = PublicState.Build(), - DefaultTab = defaultTab, - Duty = DutyPanel.BuildView(), - Dispatch = dispatch, - Records = records, - Civilian = CivilianPanel.BuildView(), - Emergency = EmergencyPanel.BuildView(), - Admin = admin, + DefaultApp = requestedApp is not null && TerminalApps.IsAvailable(requestedApp) ? requestedApp : null, + Identity = CivilianPanel.ActiveCharacter?.FullName ?? ClientBrain.LocalPlayerData.Name, + Unit = DispatchPanel.BuildStatus(), + Home = HomePanel.BuildView(), }; } - private static void Send(string type, object? payload) => - API.SendNuiMessage(JsonConvert.SerializeObject(new { screen = "app", type, payload }, JsonSettings)); + private static void Send(string type, object? payload) => SendTo("app", type, payload); + + private static void SendTo(string screen, string type, object? payload) => + API.SendNuiMessage(JsonConvert.SerializeObject(new { screen, type, payload }, JsonSettings)); private void RegisterCallback(string name, Action> handler) { @@ -402,7 +411,7 @@ private void RegisterCallback(string name, Action> h } catch (Exception ex) { - Debug.WriteLine($"[AppClient] NUI callback '{name}' failed: {ex.Message}"); + Log.Error($"Terminal action '{name}' failed", ex); } callback("ok"); diff --git a/FivePRS.Client/App/CivilianPanel.cs b/FivePRS.Client/App/CivilianPanel.cs index 320ccae..e1ffab3 100644 --- a/FivePRS.Client/App/CivilianPanel.cs +++ b/FivePRS.Client/App/CivilianPanel.cs @@ -3,7 +3,9 @@ using System.Linq; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Client.Terminal; using FivePRS.Core.Civilian; +using FivePRS.Core.Config; using FivePRS.Core.Events; using Newtonsoft.Json; @@ -21,9 +23,84 @@ public DrivenVehicle(string plate, string model) } } + internal sealed class PendingMugshot + { + public int CharacterId { get; set; } + public string Data { get; set; } = string.Empty; + } + internal static class CivilianPanel { + private const float ShowCardRangeM = 3f; + private const int MaxPlayerSlots = 256; + + private static readonly string[] AppIds = { "characters", "licenses", "vehicles" }; + private static CivilianState? _state; + private static PendingMugshot? _mugshot; + + public static event Action? AppearanceRequested; + + public static event Action? MugshotRequested; + + public static void RequestAppearance() => AppearanceRequested?.Invoke(); + + public static void RequestMugshot(IDictionary data) + { + if (NuiData.TryGetInt(data, "characterId", out var id)) MugshotRequested?.Invoke(id); + } + + public static void SetMugshot(int characterId, string data) => + _mugshot = new PendingMugshot { CharacterId = characterId, Data = data }; + + public static void DiscardMugshot() => _mugshot = null; + + public static void SetPhotoUrl(IDictionary data) + { + if (NuiData.TryGetInt(data, "characterId", out var id)) + ClientEvents.TriggerServer(EventNames.ServerCharacterPhoto, id, NuiData.GetString(data, "url").Trim()); + } + + public static void RemovePhoto(IDictionary data) + { + if (NuiData.TryGetInt(data, "characterId", out var id)) + ClientEvents.TriggerServer(EventNames.ServerCharacterPhoto, id, string.Empty); + } + + public static void ShowLicense(IDictionary data) + { + var me = Game.PlayerPed.Position; + var self = API.PlayerId(); + var nearest = -1; + var distance = ShowCardRangeM; + + for (var player = 0; player < MaxPlayerSlots; player++) + { + if (player == self || !API.NetworkIsPlayerActive(player)) continue; + + var ped = API.GetPlayerPed(player); + if (ped == 0) continue; + + var gap = Vector3.Distance(me, API.GetEntityCoords(ped, false)); + if (gap > distance) continue; + + nearest = player; + distance = gap; + } + + if (nearest < 0) + { + ShowError("Nobody is close enough. Stand next to the person you want to show it to."); + return; + } + + ClientEvents.TriggerServer(EventNames.ServerShowLicense, API.GetPlayerServerId(nearest), NuiData.GetString(data, "type")); + } + + public static void ShowError(string message) + { + foreach (var id in AppIds) TerminalApps.SendToApp(id, "error", message); + } public static void SetState(string json) => _state = JsonConvert.DeserializeObject(json); @@ -54,13 +131,16 @@ public static void DeleteCharacter(IDictionary data) public static void ApplyLicense(IDictionary data) => ClientEvents.TriggerServer(EventNames.ServerLicenseApply, NuiData.GetString(data, "type")); - public static string? RegisterVehicle() + public static void RegisterVehicle() { var vehicle = CurrentVehicle(); - if (vehicle is null) return "Get in the driver's seat of the vehicle you want to register."; + if (vehicle is null) + { + ShowError("Get in the driver's seat of the vehicle you want to register."); + return; + } ClientEvents.TriggerServer(EventNames.ServerVehicleRegister, vehicle.Model); - return null; } public static void RemoveVehicle(IDictionary data) @@ -86,6 +166,9 @@ public static object BuildView() Genders = CivilianRules.Genders, MaxNameLength = CivilianRules.MaxNameLength, CurrentVehicle = vehicle, + Icons = ConfigManager.Settings.Branding.Departments, + StateInfo = ConfigManager.Licenses.State, + Mugshot = _mugshot, }; } diff --git a/FivePRS.Client/App/DispatchPanel.cs b/FivePRS.Client/App/DispatchPanel.cs index d90a942..5017855 100644 --- a/FivePRS.Client/App/DispatchPanel.cs +++ b/FivePRS.Client/App/DispatchPanel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using CitizenFX.Core; using CitizenFX.Core.Native; using FivePRS.Client.Callouts; using FivePRS.Client.Dispatch; @@ -26,6 +27,12 @@ public static void SetStatus(IDictionary data) ClientEvents.TriggerServer(EventNames.ServerSetUnitStatus, (int)status); } + public static void ClearCall(IDictionary data) + { + if (NuiData.TryGetString(data, "callId", out var callId)) + ClientEvents.TriggerServer(EventNames.ServerCallClear, callId); + } + public static void Attach(IDictionary data) { if (NuiData.TryGetString(data, "callId", out var callId)) @@ -43,6 +50,47 @@ public static void Waypoint(IDictionary data) ClientBrain.ShowNotification($"~b~Waypoint set~w~ to call ~y~#{call.Id}~w~."); } + public static void RequestBackup(IDictionary data) + { + var code = NuiData.TryGetInt(data, "code", out var value) && value == 2 ? 2 : 3; + BackupClient.Request(BackupMode.Any, code); + } + + public static double UnixNowMs() => (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + + public static object? BuildOffer() + { + var offer = CalloutDispatcher.PendingOffer; + if (offer is null) return null; + + var hasLocation = offer.LocationX != 0f || offer.LocationY != 0f; + return new + { + offer.Id, + offer.Name, + Code = (int)offer.Priority, + offer.Description, + DistanceM = hasLocation + ? (int)Vector3.Distance(Game.PlayerPed.Position, new Vector3(offer.LocationX, offer.LocationY, offer.LocationZ)) + : (int?)null, + ExpiresAt = UnixNowMs() + Math.Max(0, CalloutDispatcher.PendingOfferExpiresAt - API.GetGameTimer()), + WindowMs = ConfigManager.Settings.AcceptWindowSeconds * 1000, + }; + } + + public static object? BuildStatus() + { + var self = DispatchClient.LocalUnit; + if (self is null) return null; + + return new + { + self.Callsign, + Agency = ConfigManager.Territories.FindAgency(self.Agency)?.Name ?? self.Department.ToString(), + Status = self.Status.ToString(), + }; + } + public static object? BuildView() { var self = DispatchClient.LocalUnit; @@ -50,24 +98,22 @@ public static void Waypoint(IDictionary data) var snapshot = DispatchClient.Snapshot; var map = ConfigManager.Territories; - var offer = CalloutDispatcher.PendingOffer; var active = CalloutDispatcher.ActiveCall; + var position = Game.PlayerPed.Position; 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, - }, + offer = BuildOffer(), activeCall = active is null ? null : new { active.Id, active.Name, + Code = (int)active.Priority, + active.Description, + Units = snapshot.Calls.FirstOrDefault(call => call.Id == active.Id)?.Units + .Select(id => snapshot.Units.FirstOrDefault(u => u.ServerId == id)?.Callsign) + .Where(callsign => callsign is not null) + .ToList(), }, self = new { @@ -89,6 +135,9 @@ public static void Waypoint(IDictionary data) Department = call.Department.ToString(), Territory = map.FindTerritory(call.Territory)?.Name, HasLocation = call.X != 0f || call.Y != 0f, + DistanceM = call.X != 0f || call.Y != 0f + ? (int)Vector3.Distance(position, new Vector3(call.X, call.Y, call.Z)) + : (int?)null, Units = call.Units .Select(id => snapshot.Units.FirstOrDefault(u => u.ServerId == id)?.Callsign) .Where(callsign => callsign is not null) @@ -106,11 +155,13 @@ public static void Waypoint(IDictionary data) unit.ServerId, unit.Callsign, unit.Name, - Agency = map.FindAgency(unit.Agency)?.Name ?? unit.Department.ToString(), - Status = unit.Status.ToString(), + Department = unit.Department.ToString(), + Agency = map.FindAgency(unit.Agency)?.Name ?? unit.Department.ToString(), + Status = unit.Status.ToString(), unit.CallId, - Territory = map.FindTerritory(unit.Territory)?.Name, + Territory = map.FindTerritory(unit.Territory)?.Name, }), + icons = ConfigManager.Settings.Branding.Departments, }; } } diff --git a/FivePRS.Client/App/DutyPanel.cs b/FivePRS.Client/App/DutyPanel.cs index c308f53..b69aa69 100644 --- a/FivePRS.Client/App/DutyPanel.cs +++ b/FivePRS.Client/App/DutyPanel.cs @@ -29,10 +29,19 @@ public static void SetAllowed(IEnumerable departmentIds) => .Select(id => (Department)id) .ToList(); + private static double? _localShiftStart; + + public static double? ShiftStartedAt => + _profile?.IsOnDuty != true ? null + : _profile.ShiftStartedAt is { } started ? (started - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds + : _localShiftStart; + public static void SetOnDuty(bool isOnDuty) { if (_profile is not null) _profile.IsOnDuty = isOnDuty; + + _localShiftStart = isOnDuty ? DispatchPanel.UnixNowMs() : (double?)null; } public static string? Enter(IDictionary data) @@ -102,15 +111,20 @@ public static object BuildView() return new { profile.Name, - profile.Rank, + Rank = profile.Ladder().Clamp(profile.Rank), + RankName = profile.RankName(), + NextRankName = profile.Ladder().IsTop(profile.Rank) ? null : profile.Ladder().NameFor(profile.Rank + 1, profile.Agency), + RankCount = profile.Ladder().Count, + Stats = new { Calls = profile.CallsCompleted, profile.Arrests, Hours = profile.DutySeconds / 3600 }, profile.XP, - XpToNext = profile.XPToNextRank, + XpToNext = profile.Ladder().XpToNext(profile.Rank), profile.IsOnDuty, DepartmentId = (int)profile.Department, Department = profile.Department.ToString(), AgencyId = profile.Agency, profile.Callsign, CallsignMaxLength = Callsign.MaxLength, + Icons = ConfigManager.Settings.Branding.Departments, Departments = departments, Unit = unit is null ? null : new { diff --git a/FivePRS.Client/App/EmergencyPanel.cs b/FivePRS.Client/App/EmergencyPanel.cs deleted file mode 100644 index e14f2b0..0000000 --- a/FivePRS.Client/App/EmergencyPanel.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FivePRS.Client.Agency; -using FivePRS.Core.Civilian; -using FivePRS.Core.Events; -using FivePRS.Core.Models; -using Newtonsoft.Json; - -namespace FivePRS.Client.App -{ - internal static class EmergencyPanel - { - private static EmergencyCallStatus? _status; - - public static void SetStatus(string json) => _status = JsonConvert.DeserializeObject(json); - - public static void RequestStatus() => ClientEvents.TriggerServer(EventNames.ServerEmergencyStatus); - - public static void Call(IDictionary data) - { - if (!NuiData.TryGetInt(data, "departmentId", out var departmentId)) return; - ClientEvents.TriggerServer(EventNames.ServerEmergencyCall, departmentId, - NuiData.GetString(data, "description"), NuiData.GetBool(data, "anonymous")); - } - - public static void Cancel() - { - if (_status is not null) - ClientEvents.TriggerServer(EventNames.ServerEmergencyCancel, _status.CallId); - } - - public static void ClearCall(IDictionary data) - { - if (NuiData.TryGetString(data, "callId", out var callId)) - ClientEvents.TriggerServer(EventNames.ServerCallClear, callId); - } - - public static object BuildView() => new - { - Departments = Enum.GetValues(typeof(Department)) - .Cast() - .Where(d => d != Department.None && BaseAgency.IsDepartmentLoaded(d)) - .Select(d => new { Id = (int)d, Name = d.ToString() }) - .ToList(), - Status = _status, - MaxLength = CivilianRules.MaxEmergencyLength, - }; - } -} diff --git a/FivePRS.Client/App/HomePanel.cs b/FivePRS.Client/App/HomePanel.cs new file mode 100644 index 0000000..409f806 --- /dev/null +++ b/FivePRS.Client/App/HomePanel.cs @@ -0,0 +1,23 @@ +using CitizenFX.Core; +using FivePRS.Client.Dispatch; + +namespace FivePRS.Client.App +{ + internal static class HomePanel + { + public static object BuildView() + { + var snapshot = DispatchClient.Snapshot; + return new + { + Offer = DispatchPanel.BuildOffer(), + Units = snapshot.Units.Count, + Calls = snapshot.Calls.Count, + ShiftStartedAt = DutyPanel.ShiftStartedAt, + Location = CurrentLocation(), + }; + } + + public static string CurrentLocation() => Reports.Describe(Game.PlayerPed.Position); + } +} diff --git a/FivePRS.Client/App/RecordsPanel.cs b/FivePRS.Client/App/RecordsPanel.cs index 0704e3c..8bc0ec6 100644 --- a/FivePRS.Client/App/RecordsPanel.cs +++ b/FivePRS.Client/App/RecordsPanel.cs @@ -1,5 +1,7 @@ -using System; using System.Collections.Generic; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.Terminal.Apps; using FivePRS.Core.Civilian; using FivePRS.Core.Config; using FivePRS.Core.Events; @@ -10,11 +12,54 @@ namespace FivePRS.Client.App { internal static class RecordsPanel { + private const float VehicleRange = 30f; + private const float FootVehicleRange = 10f; + private const float VehicleCone = 0.8f; + private const float PersonRange = 3f; + private const float PersonCone = 0.5f; + private static List? _results; private static string? _query; private static LookupRecord? _record; + private static ReportsView? _reports; + + public static int DraftCount => _reports?.Drafts.Count ?? 0; + + public static void SetReports(string json) + { + var view = JsonConvert.DeserializeObject(json); + if (view is null) return; - public static bool IsAvailable => DispatchPanel.IsAvailable && DutyPanel.Department == Department.Police; + if (view.Results is null && _reports is not null) + { + view.Results = _reports.Results; + view.Query = _reports.Query; + view.Date = _reports.Date; + } + + _reports = view; + } + + public static void RequestReports() => ClientEvents.TriggerServer(EventNames.ServerReportsRequest); + + public static void SaveReport(IDictionary data) + { + if (NuiData.TryGetInt(data, "reportId", out var id)) + ClientEvents.TriggerServer(EventNames.ServerReportSave, id, NuiData.GetString(data, "narrative")); + } + + public static void FileReport(IDictionary data) + { + if (NuiData.TryGetInt(data, "reportId", out var id)) + ClientEvents.TriggerServer(EventNames.ServerReportFile, id, NuiData.GetString(data, "narrative")); + } + + public static void SearchReports(IDictionary data) => + ClientEvents.TriggerServer(EventNames.ServerReportSearch, NuiData.GetString(data, "term"), NuiData.GetString(data, "date")); + + public static bool IsAvailable => DispatchPanel.IsAvailable && RecordAccess.CanView(DutyPanel.Department); + + private static bool Police => DutyPanel.Department == Department.Police; public static void SetResult(string json) { @@ -37,6 +82,7 @@ public static void Clear() _results = null; _query = null; _record = null; + _reports = null; } public static void CloseRecord() => _record = null; @@ -44,8 +90,68 @@ public static void Clear() public static void SearchName(IDictionary data) => ClientEvents.TriggerServer(EventNames.ServerLookupName, NuiData.GetString(data, "term")); - public static void SearchPlate(IDictionary data) => - ClientEvents.TriggerServer(EventNames.ServerLookupPlate, NuiData.GetString(data, "plate")); + public static void SearchPlate(IDictionary data) + { + var plate = NuiData.GetString(data, "plate"); + var vehicle = StreetIdentity.FindByPlate(plate); + if (vehicle is not null && IsStreetVehicle(vehicle)) + RunVehicle(vehicle); + else + ClientEvents.TriggerServer(EventNames.ServerLookupPlate, plate); + } + + public static void RunVehicleAhead() + { + if (!Police) return; + + var inVehicle = Game.PlayerPed.IsInVehicle(); + var vehicle = StreetIdentity.VehicleAhead(inVehicle ? VehicleRange : FootVehicleRange, VehicleCone, true); + if (vehicle is null) + { + RecordsApp.ShowError("There's no vehicle ahead of you."); + return; + } + + if (IsStreetVehicle(vehicle)) + RunVehicle(vehicle); + else + ClientEvents.TriggerServer(EventNames.ServerLookupPlate, StreetIdentity.PlateOf(vehicle)); + } + + public static void IdentifyPerson() + { + var ped = StreetIdentity.PersonAhead(PersonRange, PersonCone, true); + if (ped is null) + { + RecordsApp.ShowError("Face the person you want to identify and stand close to them."); + return; + } + + ClientEvents.TriggerServer(EventNames.ServerRunPerson, StreetIdentity.SeedFor(ped), StreetIdentity.IsFemale(ped)); + } + + public static void RunStopPlate() => StreetIdentity.CurrentStop?.Invoke()?.RunPlate?.Invoke(); + + private static void RunVehicle(Vehicle vehicle) + { + var driver = vehicle.Driver; + var hasDriver = driver is not null && driver.Exists() && !driver.IsPlayer && !driver.IsDead; + ClientEvents.TriggerServer(EventNames.ServerRunPlate, + StreetIdentity.PlateOf(vehicle), + StreetIdentity.VehicleName(vehicle), + hasDriver ? StreetIdentity.SeedFor(driver!) : 0, + hasDriver && StreetIdentity.IsFemale(driver!), + hasDriver); + } + + private static bool IsStreetVehicle(Vehicle vehicle) + { + var driver = vehicle.Driver; + if (driver is not null && driver.Exists() && driver.IsPlayer) return false; + + var last = API.GetLastPedInVehicleSeat(vehicle.Handle, -1); + return last == 0 || !API.IsPedAPlayer(last); + } public static void Open(IDictionary data) { @@ -86,11 +192,18 @@ public static void FlagVehicle(IDictionary data) return new { + Medical = !Police, + Reports = Police ? null : _reports, + MaxNarrative = ReportRules.MaxNarrativeLength, + AutoFileMinutes = ReportRules.AutoFileMinutes, + Stop = Police ? StreetIdentity.CurrentStop?.Invoke() : null, Results = _results, Query = _query, Record = _record, MaxFine = ConfigManager.Settings.MaxFine, MaxDescription = CivilianRules.MaxDescriptionLength, + Offences = ConfigManager.Citations.Offences, + StateInfo = ConfigManager.Licenses.State, }; } } diff --git a/FivePRS.Client/App/TerminalPanel.cs b/FivePRS.Client/App/TerminalPanel.cs index da1a425..1db7fce 100644 --- a/FivePRS.Client/App/TerminalPanel.cs +++ b/FivePRS.Client/App/TerminalPanel.cs @@ -1,5 +1,6 @@ using FivePRS.Core.Config; using FivePRS.Core.Events; +using FivePRS.Core.Models; namespace FivePRS.Client.App { @@ -7,14 +8,24 @@ internal static class TerminalPanel { public static string Wallpaper { get; private set; } = string.Empty; - public static void SetWallpaper(string wallpaper) => Wallpaper = wallpaper ?? string.Empty; + public static TerminalPreferences Preferences { get; private set; } = new(); + + public static void SetPreferences(string wallpaper, string? settings) + { + Wallpaper = wallpaper ?? string.Empty; + Preferences = TerminalPreferences.Parse(settings); + } public static void ChooseWallpaper(string value) => ClientEvents.TriggerServer(EventNames.ServerSetWallpaper, value); + public static void SavePreferences(TerminalPreferences preferences) => + ClientEvents.TriggerServer(EventNames.ServerSaveSettings, TerminalPreferences.Sanitize(preferences).ToJson()); + public static object BuildView() => new { - Wallpaper = ConfigManager.Settings.Terminal.ResolveWallpaperUrl(Wallpaper), + Wallpaper = ConfigManager.Settings.Terminal.ResolveWallpaperUrl(Wallpaper), + Preferences = Preferences, }; } } diff --git a/FivePRS.Client/Appearance/AppearanceManager.cs b/FivePRS.Client/Appearance/AppearanceManager.cs index cd478dd..862766a 100644 --- a/FivePRS.Client/Appearance/AppearanceManager.cs +++ b/FivePRS.Client/Appearance/AppearanceManager.cs @@ -8,13 +8,22 @@ using FivePRS.Core.Civilian; using FivePRS.Core.Config; using FivePRS.Core.Events; +using FivePRS.Core.Logging; using Newtonsoft.Json; namespace FivePRS.Client.Appearance { public class AppearanceManager : BaseScript { + private const int CheckIntervalMs = 250; + private const int ModelTimeoutMs = 10_000; + + private static readonly Logger Log = Logger.For("Appearance"); + private static string _currentJson = string.Empty; + private static bool _applying; + private static bool _stale; + private static int _appliedPed; public static CharacterAppearance? Current { get; private set; } @@ -23,12 +32,12 @@ public class AppearanceManager : BaseScript public AppearanceManager() { EventHandlers[EventNames.ClientAppearance] += new Action(OnAppearance); - EventHandlers["playerSpawned"] += new Action(OnSpawned); + Tick += KeepAppliedAsync; } public static IEnumerable AllowedStandardModels => ConfigManager.Settings.Creator.AllowedStandardModels; - private static async void OnAppearance(int characterId, string json) + private static void OnAppearance(int characterId, string json) { CharacterId = characterId; if (json == _currentJson) return; @@ -37,22 +46,46 @@ private static async void OnAppearance(int characterId, string json) Current = json.Length == 0 ? null : AppearanceRules.Sanitize(JsonConvert.DeserializeObject(json), AllowedStandardModels); - - if (Current is not null && !LoadoutManager.IsInUniform) - await ApplyAsync(Current); + _stale = true; } - private static async void OnSpawned() + private async Task KeepAppliedAsync() { - if (Current is not null && !LoadoutManager.IsInUniform) - await ApplyAsync(Current); + await Delay(CheckIntervalMs); + if (Current is null || _applying || LoadoutManager.IsInUniform || CharacterCreator.IsOpen) return; + + var ped = Game.PlayerPed; + if (!ped.Exists() || ped.IsDead || API.GetIsLoadingScreenActive() || API.IsPlayerSwitchInProgress()) return; + + var wearing = API.GetEntityModel(ped.Handle) == API.GetHashKey(Current.Model); + if (!_stale && ped.Handle == _appliedPed && wearing) return; + + Log.Debug($"Applying {Current.Model} to ped {ped.Handle} (stale {_stale}, last ped {_appliedPed}, model matches {wearing})."); + _stale = false; + await ApplyAsync(Current); } public static Task ApplyCurrentAsync() => Current is null ? Task.FromResult(0) : ApplyAsync(Current); public static async Task ApplyAsync(CharacterAppearance appearance) { - await SetModelAsync(appearance.Model); + _applying = true; + try + { + if (await ApplyToPedAsync(appearance)) + _appliedPed = Game.PlayerPed.Handle; + else + Log.Debug($"Couldn't change the player model to {appearance.Model}; trying again shortly."); + } + finally + { + _applying = false; + } + } + + private static async Task ApplyToPedAsync(CharacterAppearance appearance) + { + if (!await SetModelAsync(appearance.Model)) return false; var ped = Game.PlayerPed.Handle; if (appearance.IsFreemode) @@ -86,6 +119,8 @@ public static async Task ApplyAsync(CharacterAppearance appearance) if (appearance.IsFreemode) API.SetPedHairColor(ped, appearance.HairColor, appearance.HairHighlight); + + return true; } public static void ApplyOverlay(int ped, HeadOverlay overlay) @@ -112,6 +147,11 @@ public static async Task SetModelAsync(string modelName) var model = new Model((int)hash); if (!model.IsValid || !model.IsPed) return false; + model.Request(); + var deadline = API.GetGameTimer() + ModelTimeoutMs; + while (!model.IsLoaded && API.GetGameTimer() < deadline) + await Delay(0); + var changed = await Game.Player.ChangeModel(model); model.MarkAsNoLongerNeeded(); if (changed) API.SetPedDefaultComponentVariation(Game.PlayerPed.Handle); diff --git a/FivePRS.Client/Appearance/CharacterCreator.cs b/FivePRS.Client/Appearance/CharacterCreator.cs index 80f3c4a..4910a0b 100644 --- a/FivePRS.Client/Appearance/CharacterCreator.cs +++ b/FivePRS.Client/Appearance/CharacterCreator.cs @@ -8,6 +8,7 @@ using FivePRS.Core.Civilian; using FivePRS.Core.Config; using FivePRS.Core.Events; +using FivePRS.Core.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -15,6 +16,8 @@ namespace FivePRS.Client.Appearance { public class CharacterCreator : BaseScript { + private static readonly Logger Log = Logger.For("Creator"); + private const int CameraBlendMs = 500; private static readonly JsonSerializerSettings JsonSettings = new() @@ -413,7 +416,7 @@ private void RegisterCallback(string name, Action> h } catch (Exception ex) { - Debug.WriteLine($"[CharacterCreator] NUI callback '{name}' failed: {ex.Message}"); + Log.Error($"Creator action '{name}' failed", ex); } callback("ok"); diff --git a/FivePRS.Client/Appearance/Mugshot.cs b/FivePRS.Client/Appearance/Mugshot.cs new file mode 100644 index 0000000..c024f02 --- /dev/null +++ b/FivePRS.Client/Appearance/Mugshot.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using Newtonsoft.Json; + +namespace FivePRS.Client.Appearance +{ + public class Mugshot : BaseScript + { + private const string CapturedCallback = "fiveprsCaptured"; + private const int HeadBone = 31086; + private const int SettleFrames = 12; + private const int TimeoutMs = 8_000; + private const float Distance = 1.15f; + private const float FieldOfView = 30f; + + private static TaskCompletionSource? _pending; + private static int _requestId; + + public Mugshot() + { + API.RegisterNuiCallbackType(CapturedCallback); + EventHandlers[$"__cfx_nui:{CapturedCallback}"] += new Action, CallbackDelegate>(OnCaptured); + } + + private static void OnCaptured(IDictionary data, CallbackDelegate callback) + { + if (data.TryGetValue("id", out var rawId) && Convert.ToInt32(rawId) == _requestId) + _pending?.TrySetResult(data.TryGetValue("data", out var image) ? image?.ToString() ?? string.Empty : string.Empty); + + callback(new { }); + } + + public static async Task CaptureAsync() + { + if (_pending is not null) return null; + + var ped = Game.PlayerPed; + var head = API.GetPedBoneCoords(ped.Handle, HeadBone, 0f, 0f, 0f); + var eye = head + ped.ForwardVector * Distance; + + var camera = API.CreateCamWithParams("DEFAULT_SCRIPTED_CAMERA", eye.X, eye.Y, eye.Z, 0f, 0f, 0f, FieldOfView, false, 0); + API.PointCamAtCoord(camera, head.X, head.Y, head.Z - 0.04f); + API.SetCamActive(camera, true); + API.RenderScriptCams(true, false, 0, true, true); + API.FreezeEntityPosition(ped.Handle, true); + + var pending = new TaskCompletionSource(); + _pending = pending; + + try + { + for (var i = 0; i < SettleFrames; i++) + { + API.HideHudAndRadarThisFrame(); + await Delay(0); + } + + API.SendNuiMessage(JsonConvert.SerializeObject(new { fiveprsCapture = ++_requestId })); + + var deadline = API.GetGameTimer() + TimeoutMs; + while (!pending.Task.IsCompleted && API.GetGameTimer() < deadline) + { + API.HideHudAndRadarThisFrame(); + await Delay(0); + } + + return pending.Task.IsCompleted && pending.Task.Result.Length > 0 ? pending.Task.Result : null; + } + finally + { + _pending = null; + API.RenderScriptCams(false, false, 0, true, true); + API.DestroyCam(camera, false); + API.FreezeEntityPosition(ped.Handle, false); + } + } + } +} diff --git a/FivePRS.Client/Arrest/ArrestManager.cs b/FivePRS.Client/Arrest/ArrestManager.cs index 88b2887..5b4cde8 100644 --- a/FivePRS.Client/Arrest/ArrestManager.cs +++ b/FivePRS.Client/Arrest/ArrestManager.cs @@ -4,6 +4,7 @@ using CitizenFX.Core; using CitizenFX.Core.Native; using FivePRS.Core.Events; +using FivePRS.Core.Logging; namespace FivePRS.Client.Arrest { @@ -16,6 +17,8 @@ public enum ArrestState public static class ArrestManager { + private static readonly Logger Log = Logger.For("Arrest"); + public const float CuffRangeM = 2.5f; public const float EscortRangeM = 5.0f; @@ -110,7 +113,7 @@ await player.Task.PlayAnimation(OfficerAnimDict, OfficerAnimClip, ClientEvents.TriggerLocal(EventNames.LocalSuspectCuffed, suspect.Handle); - Debug.WriteLine($"[ArrestManager] Suspect (handle {suspect.Handle}) cuffed."); + Log.Debug($"Suspect {suspect.Handle} cuffed."); } public static void Uncuff() @@ -134,7 +137,7 @@ public static void Uncuff() ClientBrain.ShowNotification("~o~Suspect released."); ClientEvents.TriggerLocal(EventNames.LocalSuspectUncuffed, suspect.Handle); - Debug.WriteLine($"[ArrestManager] Suspect (handle {suspect.Handle}) released."); + Log.Debug($"Suspect {suspect.Handle} released."); } public static async Task EscortToVehicleAsync(Vehicle vehicle) @@ -173,7 +176,7 @@ public static async Task EscortToVehicleAsync(Vehicle vehicle) ClientBrain.ShowNotification("~g~Suspect secured in vehicle."); ClientEvents.TriggerLocal(EventNames.LocalSuspectEscorted, suspect.Handle, vehicle.Handle); - Debug.WriteLine($"[ArrestManager] Suspect escorted to vehicle {vehicle.Handle}."); + Log.Debug($"Suspect placed in vehicle {vehicle.Handle}."); return true; } diff --git a/FivePRS.Client/Callouts/CalloutBase.cs b/FivePRS.Client/Callouts/CalloutBase.cs index e254ba5..72ff487 100644 --- a/FivePRS.Client/Callouts/CalloutBase.cs +++ b/FivePRS.Client/Callouts/CalloutBase.cs @@ -9,12 +9,17 @@ using FivePRS.Client.Tasks; using FivePRS.Core.Config; using FivePRS.Core.Jurisdiction; +using FivePRS.Core.Logging; using FivePRS.Core.Models; namespace FivePRS.Client.Callouts { public abstract class CalloutBase { + private Logger? _log; + + protected Logger Log => _log ??= Logger.For(GetType().Name); + public CalloutData Data { get; internal set; } = new(); public CalloutState State { get; private set; } = CalloutState.Idle; @@ -182,7 +187,7 @@ internal void Cleanup(bool releaseToWorld = false) } catch (Exception ex) { - Debug.WriteLine($"[CalloutBase] Scene cleanup failed: {ex.Message}"); + Log.Warn("Scene cleanup failed", ex); } foreach (var entity in _trackedEntities) @@ -209,7 +214,7 @@ internal void Cleanup(bool releaseToWorld = false) } catch (Exception ex) { - Debug.WriteLine($"[CalloutBase] Entity cleanup failed: {ex.Message}"); + Log.Warn("Entity cleanup failed", ex); } } _trackedEntities.Clear(); @@ -217,7 +222,7 @@ internal void Cleanup(bool releaseToWorld = false) foreach (var blip in _trackedBlips) { try { blip.Delete(); } - catch (Exception ex) { Debug.WriteLine($"[CalloutBase] Blip cleanup failed: {ex.Message}"); } + catch (Exception ex) { Log.Warn("Blip cleanup failed", ex); } } _trackedBlips.Clear(); } diff --git a/FivePRS.Client/Callouts/CalloutDispatcher.cs b/FivePRS.Client/Callouts/CalloutDispatcher.cs index 0d18804..c7c605d 100644 --- a/FivePRS.Client/Callouts/CalloutDispatcher.cs +++ b/FivePRS.Client/Callouts/CalloutDispatcher.cs @@ -6,6 +6,7 @@ using CitizenFX.Core.Native; using FivePRS.Core.Config; using FivePRS.Core.Events; +using FivePRS.Core.Logging; using FivePRS.Core.Models; using Newtonsoft.Json; @@ -13,6 +14,8 @@ namespace FivePRS.Client.Callouts { public sealed class CalloutDispatcher { + private static readonly Logger Log = Logger.For("Callouts"); + private const int RegistrationBatchSize = 5; internal static volatile bool AcceptPressed; @@ -74,7 +77,7 @@ public void Start() 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."); + Log.Debug($"Registered {definitions.Count} {_department} callouts with dispatch."); } public void Stop() @@ -91,7 +94,7 @@ public void Stop() if (callout is null) return; try { callout.OnCalloutFailed(); } - catch (Exception ex) { Debug.WriteLine($"[CalloutDispatcher] OnCalloutFailed threw: {ex.Message}"); } + catch (Exception ex) { Log.Error("OnCalloutFailed failed", ex); } callout.SetState(CalloutState.Failed); callout.Cleanup(); } @@ -127,7 +130,7 @@ public async Task HandleOfferAsync(CalloutData data) var entry = _registry.FindByName(data.Name); if (entry is null) { - Debug.WriteLine($"[CalloutDispatcher] No handler registered for '{data.Name}'."); + Log.Warn($"No handler registered for '{data.Name}'."); return null; } @@ -143,7 +146,7 @@ public async Task HandleOfferAsync(CalloutData data) } catch (Exception ex) { - Debug.WriteLine($"[CalloutDispatcher] Could not create {entry.Type.Name}: {ex.Message}"); + Log.Error($"Could not create {entry.Type.Name}", ex); return null; } } @@ -170,7 +173,7 @@ private async Task RunCalloutAsync(CalloutBase callout, CancellationToken ct) Respond(callout.Data.Id, OfferResponse.Declined, Vector3.Zero); try { callout.OnCalloutDeclined(); } - catch (Exception ex) { Debug.WriteLine($"[CalloutDispatcher] OnCalloutDeclined threw: {ex.Message}"); } + catch (Exception ex) { Log.Error("OnCalloutDeclined failed", ex); } callout.Cleanup(); callout.SetState(CalloutState.Declined); @@ -208,7 +211,7 @@ void OnEnded(CalloutBase c, CalloutResult result) if (callout.State == CalloutState.Active) { try { callout.OnCalloutFailed(); } - catch (Exception ex) { Debug.WriteLine($"[CalloutDispatcher] OnCalloutFailed threw: {ex.Message}"); } + catch (Exception ex) { Log.Error("OnCalloutFailed failed", ex); } callout.SetState(CalloutState.Failed); finalResult = CalloutResult.Failed; } @@ -226,7 +229,7 @@ void OnEnded(CalloutBase c, CalloutResult result) } } - Debug.WriteLine($"[CalloutDispatcher] Call #{callout.Data.Id} '{callout.Data.Name}' ended: {finalResult}"); + Log.Debug($"Call #{callout.Data.Id} '{callout.Data.Name}' ended: {finalResult}."); } private static void Respond(string callId, OfferResponse response, Vector3 location) => @@ -268,7 +271,7 @@ private static async Task RunUpdateLoopAsync(CalloutBase callout, CancellationTo try { callout.OnUpdate(); } catch (Exception ex) { - Debug.WriteLine($"[CalloutDispatcher] OnUpdate exception in {callout.GetType().Name}: {ex.Message}"); + Log.Error($"{callout.GetType().Name}.OnUpdate failed", ex); } if (!await Timing.TryWaitAsync(1000, ct)) return; @@ -286,7 +289,7 @@ private static async Task RunScenarioAsync(CalloutBase callout, CancellationToke } catch (Exception ex) { - Debug.WriteLine($"[CalloutDispatcher] Unhandled exception in {callout.GetType().Name}.OnCalloutAccepted: {ex}"); + Log.Error($"{callout.GetType().Name}.OnCalloutAccepted failed", ex); if (callout.State == CalloutState.Active) { callout.SetState(CalloutState.Failed); diff --git a/FivePRS.Client/Callouts/CalloutRegistry.cs b/FivePRS.Client/Callouts/CalloutRegistry.cs index b6f8a2c..15e39c6 100644 --- a/FivePRS.Client/Callouts/CalloutRegistry.cs +++ b/FivePRS.Client/Callouts/CalloutRegistry.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using CitizenFX.Core; +using FivePRS.Core.Logging; using FivePRS.Core.Models; namespace FivePRS.Client.Callouts @@ -21,6 +22,8 @@ internal RegisteredCallout(Type type, CalloutInfoAttribute info) public sealed class CalloutRegistry { + private static readonly Logger Log = Logger.For("Callouts"); + private readonly List _entries = new(); private readonly object _lock = new(); @@ -58,7 +61,7 @@ public void DiscoverAll() try { Discover(asm); } catch (Exception ex) { - Debug.WriteLine($"[CalloutRegistry] Skipped {name}: {ex.Message}"); + Log.Warn($"Skipped {name}", ex); } } } @@ -82,9 +85,7 @@ private void Register(Type type, CalloutInfoAttribute attr) if (_entries.Any(e => e.Type == type)) return; _entries.Add(new RegisteredCallout(type, attr)); - Debug.WriteLine( - $"[CalloutRegistry] {attr.Name,-25} dept={attr.Department,-7} " + - $"w={attr.Weight,-4} cd={attr.CooldownSeconds}s"); + Log.Debug($"Registered {attr.Name} ({attr.Department}, weight {attr.Weight}, cooldown {attr.CooldownSeconds}s)."); } } diff --git a/FivePRS.Client/ClientBrain.cs b/FivePRS.Client/ClientBrain.cs index c19d62f..bc540b7 100644 --- a/FivePRS.Client/ClientBrain.cs +++ b/FivePRS.Client/ClientBrain.cs @@ -5,10 +5,12 @@ using CitizenFX.Core; using CitizenFX.Core.Native; using FivePRS.Client.Agency; +using FivePRS.Client.App; using FivePRS.Client.Arrest; using FivePRS.Client.Callouts; using FivePRS.Core.Config; using FivePRS.Core.Events; +using FivePRS.Core.Logging; using FivePRS.Core.Models; using FivePRS.Core.Text; using Newtonsoft.Json; @@ -17,24 +19,18 @@ namespace FivePRS.Client { public class ClientBrain : BaseScript { + private static readonly Logger Log = Logger.For("Client"); + public static PlayerData LocalPlayerData { get; private set; } = new(); public ClientBrain() { - ConfigManager.Log = message => Debug.WriteLine(message); + Logger.DebugEnabled = string.Equals(API.GetConvar("fiveprs_debug", "false"), "true", StringComparison.OrdinalIgnoreCase); + Logger.ClearSinks(); + Logger.AddSink(new ConsoleSink(message => Debug.WriteLine(message), "Add setr fiveprs_debug true to server.cfg for full error details.")); var res = API.GetCurrentResourceName(); - ConfigManager.LoadSettings( API.LoadResourceFile(res, "config/settings.json")); - foreach (var department in ConfigManager.Departments) - { - var name = ConfigManager.ConfigName(department); - ConfigManager.LoadDepartment(department, - API.LoadResourceFile(res, $"config/{name}_loadouts.json"), - API.LoadResourceFile(res, $"config/{name}_vehicles.json")); - } - ConfigManager.LoadJurisdictions( API.LoadResourceFile(res, "config/jurisdictions.json")); - ConfigManager.LoadStations( API.LoadResourceFile(res, "config/stations.json")); - ConfigManager.LoadCitations( API.LoadResourceFile(res, "config/citations.json")); + ConfigManager.LoadAll(path => API.LoadResourceFile(res, path)); EventHandlers[EventNames.ClientReceivePlayerData] += new Action(OnReceivePlayerData); EventHandlers[EventNames.ClientDutyStatusChanged] += new Action(OnDutyStatusChanged); @@ -76,10 +72,17 @@ public ClientBrain() }), false); API.RegisterCommand("er_profile", new Action, string>((_, __, ___) => { + if (!DutyPanel.IsLoaded) + { + ShowNotification("~r~Your profile hasn't loaded yet. Try again in a moment."); + return; + } + 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~ | {agency} | {d.RankName()}~n~~g~" + + (d.Ladder().IsTop(d.Rank) ? "Top rank" : $"XP: {d.XP} / {d.Ladder().XpToNext(d.Rank)}")); }), false); API.RegisterCommand("er_help", new Action, string>((_, __, ___) => { @@ -94,10 +97,11 @@ public ClientBrain() "~b~/er_attach [id]~w~ — Respond as backup~n~" + "~b~/backup [2|3] [npc|players]~w~ — Request backup; /backup cancel to stand down~n~" + "~b~/er_status [available|busy]~w~ — Set unit status~n~" + + "~b~/er_aicallouts~w~ — Turn AI callouts on or off~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~n~" + - "~b~/fprs_coords~w~ — Copy your position for stations.json"); + "~b~/fprs_coords [armory|locker|garage|parking]~w~ — Copy your position for stations.json"); }), false); API.RegisterKeyMapping(KeyCommands.Accept, "FivePRS: Accept callout", "keyboard", "Y"); @@ -135,14 +139,11 @@ private void OnReceivePlayerData(string json) if (data is null) return; LocalPlayerData = data; - Debug.WriteLine($"[FivePRS] Profile loaded: {data.Name} | Rank {data.Rank} | {data.Department}"); - - if (!data.IsOnDuty) - ShowHelp($"~y~Welcome to FivePRS!~w~ Press {Binds.Menu} to open the FivePRS menu.", 10_000); + Log.Debug($"Profile loaded: {data.Name}, {data.RankName()}, {data.Department}."); } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Failed to parse player data: {ex.Message}"); + Log.Error("Could not read player data", ex); } } @@ -157,7 +158,7 @@ private void OnDutyStatusChanged(bool isOnDuty, int departmentId) private void OnRankedUp(int newRank) { LocalPlayerData.Rank = newRank; - ShowNotification($"~g~RANK UP!~w~ You are now Rank {newRank}. Keep it up!"); + ShowNotification($"~g~Promoted!~w~ You are now {LocalPlayerData.RankName()}."); } private void OnCalloutOffered(string calloutJson) @@ -171,7 +172,7 @@ private void OnCalloutOffered(string calloutJson) } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Malformed callout payload discarded: {ex.Message}"); + Log.Warn("Discarded a malformed callout payload", ex); } } @@ -186,7 +187,7 @@ private static async void CreateNotificationLogo(string resource, string url) { if (API.GetGameTimer() > until) { - Debug.WriteLine($"[FivePRS] The notification logo page did not load from {url}."); + Log.Warn($"The notification logo didn't load from {url}."); return; } @@ -197,7 +198,7 @@ private static async void CreateNotificationLogo(string resource, string url) var txd = API.CreateRuntimeTxd(name); API.CreateRuntimeTextureFromDuiHandle(txd, name, API.GetDuiHandle(_logoDui)); _logoTxd = name; - Debug.WriteLine("[FivePRS] Notification logo ready."); + Log.Debug("Notification logo ready."); } private void OnResourceStop(string resourceName) diff --git a/FivePRS.Client/ClientEvents.cs b/FivePRS.Client/ClientEvents.cs index 1e5634e..cd7efb3 100644 --- a/FivePRS.Client/ClientEvents.cs +++ b/FivePRS.Client/ClientEvents.cs @@ -2,12 +2,15 @@ using System.Text; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Core.Logging; using Newtonsoft.Json; namespace FivePRS.Client { public static class ClientEvents { + private static readonly Logger Log = Logger.For("Events"); + private const string RelayCommand = "fiveprs_relay"; private static bool _useRelay; @@ -31,7 +34,7 @@ private static void Trigger(string eventName, object[] args, bool remote) 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."); + Log.Debug("This client blocks C# event natives, using the Lua relay."); } } diff --git a/FivePRS.Client/CoordsCommand.cs b/FivePRS.Client/CoordsCommand.cs index 8df2ddc..88e81d7 100644 --- a/FivePRS.Client/CoordsCommand.cs +++ b/FivePRS.Client/CoordsCommand.cs @@ -4,23 +4,37 @@ using System.Linq; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Core.Logging; using Newtonsoft.Json; namespace FivePRS.Client { public class CoordsCommand : BaseScript { + private static readonly Logger Log = Logger.For("Coords"); + private static readonly string[] PointNames = { "Armory", "Locker", "Garage", "Parking" }; public CoordsCommand() { - API.RegisterCommand("fprs_coords", new Action, string>((_, args, __) => - Capture(args.Count > 0 ? args[0]?.ToString() : null)), false); + API.RegisterCommand("fprs_coords", new Action, string>((_, args, __) => OnCommand(args)), false); } - private static void Capture(string? point) + private static void OnCommand(List args) { + var point = args.Count == 1 ? args[0]?.ToString()?.Trim() : null; var pointName = PointNames.FirstOrDefault(p => string.Equals(p, point, StringComparison.OrdinalIgnoreCase)); + if (args.Count > 1 || (point is not null && pointName is null)) + { + ClientBrain.ShowNotification($"~r~Usage: ~w~/fprs_coords [{string.Join("|", PointNames).ToLowerInvariant()}]"); + return; + } + + Capture(pointName); + } + + private static void Capture(string? pointName) + { var ped = Game.PlayerPed; var vehicle = ped.CurrentVehicle; @@ -49,7 +63,7 @@ private static void Capture(string? point) text = $"\"dutyPoint\": [{x}, {y}, {z}], \"heading\": {h}"; } - Debug.WriteLine($"[FivePRS] {label}: {text}"); + Log.Info($"{label}: {text}"); API.SendNuiMessage(JsonConvert.SerializeObject(new { screen = "clipboard", type = "copy", payload = text })); ClientBrain.ShowNotification($"~b~{label}~w~ copied to clipboard~n~{x}, {y}, {z}~n~Heading {h}", "Coordinates"); } diff --git a/FivePRS.Client/Dispatch/BackupClient.cs b/FivePRS.Client/Dispatch/BackupClient.cs index 8bc9333..c2d46d3 100644 --- a/FivePRS.Client/Dispatch/BackupClient.cs +++ b/FivePRS.Client/Dispatch/BackupClient.cs @@ -199,13 +199,21 @@ private static void Toggle() private static void OnCommand(List args) { - var words = args.Select(arg => arg?.ToString()?.ToLowerInvariant() ?? string.Empty).ToList(); - if (words.Contains("cancel")) + var words = args.Select(arg => arg?.ToString()?.Trim().ToLowerInvariant() ?? string.Empty).Where(word => word.Length > 0).ToList(); + if (words.Count == 1 && words[0] == "cancel") { Cancel(); return; } + var codes = words.Count(word => word == "2" || word == "3"); + var modes = words.Count(word => word == "npc" || word == "players"); + if (codes > 1 || modes > 1 || codes + modes != words.Count) + { + ClientBrain.ShowNotification("~r~Usage: ~w~/backup [2|3] [npc|players], or /backup cancel"); + return; + } + var mode = words.Contains("npc") ? BackupMode.Npc : words.Contains("players") ? BackupMode.Players : BackupMode.Any; Request(mode, words.Contains("2") ? 2 : 3); } diff --git a/FivePRS.Client/Dispatch/DispatchClient.cs b/FivePRS.Client/Dispatch/DispatchClient.cs index 5c55785..876a079 100644 --- a/FivePRS.Client/Dispatch/DispatchClient.cs +++ b/FivePRS.Client/Dispatch/DispatchClient.cs @@ -5,6 +5,7 @@ using CitizenFX.Core.Native; using FivePRS.Core.Config; using FivePRS.Core.Events; +using FivePRS.Core.Logging; using FivePRS.Core.Models; using FivePRS.Core.Text; using Newtonsoft.Json; @@ -13,6 +14,8 @@ namespace FivePRS.Client.Dispatch { public class DispatchClient : BaseScript { + private static readonly Logger Log = Logger.For("Dispatch"); + public static DispatchSnapshot Snapshot { get; private set; } = new(); public static event Action? SnapshotUpdated; @@ -43,7 +46,7 @@ private void OnSnapshot(string json) } catch (Exception ex) { - Debug.WriteLine($"[DispatchClient] Malformed snapshot discarded: {ex.Message}"); + Log.Warn("Discarded a malformed dispatch snapshot", ex); return; } @@ -101,7 +104,7 @@ 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; + var callId = args.Count == 1 ? args[0]?.ToString()?.Trim().TrimStart('#') : null; if (callId is null || callId.Length == 0) { ClientBrain.ShowNotification("~r~Usage: ~w~/er_attach [call id]"); @@ -113,15 +116,17 @@ private static void OnAttachCommand(int source, List args, string raw) private static void OnStatusCommand(int source, List args, string raw) { - var input = args.Count > 0 ? args[0]?.ToString() : null; - if (!Enum.TryParse(input, true, out var status) || - (status != UnitStatus.Available && status != UnitStatus.Busy)) + var input = args.Count == 1 ? args[0]?.ToString()?.Trim() : null; + var status = string.Equals(input, "available", StringComparison.OrdinalIgnoreCase) ? UnitStatus.Available + : string.Equals(input, "busy", StringComparison.OrdinalIgnoreCase) ? UnitStatus.Busy + : (UnitStatus?)null; + if (status is null) { ClientBrain.ShowNotification("~r~Usage: ~w~/er_status [available|busy]"); return; } - ClientEvents.TriggerServer(EventNames.ServerSetUnitStatus, (int)status); + ClientEvents.TriggerServer(EventNames.ServerSetUnitStatus, (int)status.Value); } } } diff --git a/FivePRS.Client/Gear/DepartmentGear.cs b/FivePRS.Client/Gear/DepartmentGear.cs index 589b34b..7fa8d16 100644 --- a/FivePRS.Client/Gear/DepartmentGear.cs +++ b/FivePRS.Client/Gear/DepartmentGear.cs @@ -33,9 +33,11 @@ public DepartmentGear(Department department, Func tierName, private DepartmentVehiclesConfig Vehicles => ConfigManager.VehiclesFor(_department); + private RankLadder Ranks => ConfigManager.RanksFor(_department); + public LoadoutDefinition LoadoutForRank(int rank, string? agencyId) { - var tier = DepartmentLoadoutsConfig.TierForRank(rank); + var tier = Ranks.TierFor(rank); var outfit = ToOutfit(Loadouts.UniformFor(agencyId, tier)); outfit.Name = _tierName(tier); @@ -44,7 +46,7 @@ public LoadoutDefinition LoadoutForRank(int rank, string? agencyId) } public PatrolVehicleConfig VehicleForRank(int rank, string? agencyId) => - ToConfig(Vehicles.TierFor(agencyId, DepartmentLoadoutsConfig.TierForRank(rank))); + ToConfig(Vehicles.TierFor(agencyId, Ranks.TierFor(rank))); public StationCatalog BuildCatalog(string? agencyId) { @@ -60,7 +62,7 @@ private void AddWeapons(StationCatalog catalog, string? agencyId) foreach (var tier in Tiers) { var weapons = Loadouts.WeaponsFor(agencyId, tier).Weapons; - var minRank = DepartmentLoadoutsConfig.MinRankFor(tier); + var minRank = Ranks.MinRankFor(tier); if (catalog.Kits.All(kit => kit.Name != _tierName(tier))) { @@ -97,7 +99,7 @@ private void AddUniforms(StationCatalog catalog, string? agencyId) catalog.Uniforms.Add(new GearUniform { Name = "Command uniform", - MinRank = DepartmentLoadoutsConfig.MinRankFor(LoadoutTier.Command), + MinRank = Ranks.MinRankFor(LoadoutTier.Command), Outfit = ToOutfit(command), }); } @@ -117,7 +119,7 @@ private void AddVehicles(StationCatalog catalog, string? agencyId) catalog.Vehicles.Add(new GearVehicle { Label = VehicleLabel(model), - MinRank = DepartmentLoadoutsConfig.MinRankFor(tier), + MinRank = Ranks.MinRankFor(tier), Config = ToConfig(vehicles, model), }); } diff --git a/FivePRS.Client/LastLocation.cs b/FivePRS.Client/LastLocation.cs index 312c5a1..d3925d2 100644 --- a/FivePRS.Client/LastLocation.cs +++ b/FivePRS.Client/LastLocation.cs @@ -15,6 +15,8 @@ public class LastLocation : BaseScript private const int HandledTimeoutMs = 2_000; private const float MinimumMove = 5f; private const float RestoreDistance = 15f; + private const string SpawnedKey = "fiveprsSpawned"; + private const string WelcomedKey = "fiveprsWelcomed"; private bool _requested; private bool _restored; @@ -50,7 +52,11 @@ private async Task OnTick() if (!_restored) { - if (now - _requestedAt > ReplyTimeoutMs) _restored = true; + if (now - _requestedAt > ReplyTimeoutMs) + { + _restored = true; + Welcome(); + } await Delay(500); return; } @@ -87,6 +93,7 @@ private async void OnLastPosition(bool found, float x, float y, float z, float h if (!handled) { + await WaitForFirstSpawnAsync(); await WaitForSpawnAsync(); var ped = Game.PlayerPed; var target = new Vector3(x, y, z); @@ -95,6 +102,7 @@ private async void OnLastPosition(bool found, float x, float y, float z, float h } await WaitForSpawnAsync(); + Welcome(); if (AppearanceManager.Current is null && AppearanceManager.CharacterId != 0) ClientBrain.ShowNotification("Your character doesn't have a look yet. Open the Characters app and choose Edit appearance.", "Characters"); } @@ -105,6 +113,21 @@ private async void OnLastPosition(bool found, float x, float y, float z, float h } } + private static void Welcome() + { + if (Game.Player.State[WelcomedKey] is true) return; + + Game.Player.State.Set(WelcomedKey, true, false); + ClientBrain.ShowHelp($"~y~Welcome to FivePRS!~w~ Press {Binds.Menu} to open the FivePRS menu.", 10_000); + } + + private static async Task WaitForFirstSpawnAsync() + { + var deadline = API.GetGameTimer() + ReplyTimeoutMs * 2; + while (Game.Player.State[SpawnedKey] is not true && API.GetGameTimer() < deadline) + await Delay(250); + } + private static async Task WaitForSpawnAsync() { var deadline = API.GetGameTimer() + ReplyTimeoutMs * 2; diff --git a/FivePRS.Client/Loadout/LoadoutManager.cs b/FivePRS.Client/Loadout/LoadoutManager.cs index aa5abbb..29030e9 100644 --- a/FivePRS.Client/Loadout/LoadoutManager.cs +++ b/FivePRS.Client/Loadout/LoadoutManager.cs @@ -4,11 +4,14 @@ using CitizenFX.Core; using CitizenFX.Core.Native; using FivePRS.Client.Appearance; +using FivePRS.Core.Logging; namespace FivePRS.Client.Loadout { public static class LoadoutManager { + private static readonly Logger Log = Logger.For("Loadout"); + private const int ComponentSlots = 12; private static readonly int[] PropSlots = { 0, 1, 2, 6, 7 }; @@ -33,7 +36,7 @@ public static async Task ApplyAsync(LoadoutDefinition loadout) await GiveWeaponsAsync(loadout.Weapons, true); Current = loadout; - Debug.WriteLine($"[LoadoutManager] Applied loadout '{loadout.Name}'."); + Log.Debug($"Applied loadout '{loadout.Name}'."); } public static async Task WearAsync(LoadoutDefinition outfit) @@ -166,7 +169,7 @@ public static async Task StripAsync() await RestoreClothingAsync(); Current = null; - Debug.WriteLine("[LoadoutManager] Loadout stripped."); + Log.Debug("Loadout removed."); } private static bool IsFreemode(int model) => @@ -190,7 +193,7 @@ private static async Task ChangeModelAsync(Model model) { if (!model.IsValid || !model.IsPed) { - Debug.WriteLine($"[LoadoutManager] Ped model {model.Hash} is not a valid ped."); + Log.Warn($"Model {model.Hash} is not a valid ped."); return false; } @@ -198,7 +201,7 @@ private static async Task ChangeModelAsync(Model model) model.MarkAsNoLongerNeeded(); if (!changed) - Debug.WriteLine($"[LoadoutManager] Failed to change player model to {model.Hash}."); + Log.Warn($"Could not change the player model to {model.Hash}."); return changed; } diff --git a/FivePRS.Client/Menu/CompactMenu.cs b/FivePRS.Client/Menu/CompactMenu.cs index 34fe14c..b49b641 100644 --- a/FivePRS.Client/Menu/CompactMenu.cs +++ b/FivePRS.Client/Menu/CompactMenu.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Core.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -33,6 +34,8 @@ public sealed class MenuPage public class CompactMenu : BaseScript { + private static readonly Logger Log = Logger.For("Menu"); + private const int ControlUp = 172; private const int ControlDown = 173; private const int ControlSelect = 191; @@ -198,7 +201,7 @@ private static async Task SelectAsync() } catch (Exception ex) { - Debug.WriteLine($"[CompactMenu] '{item.Label}' failed: {ex}"); + Log.Error($"Menu item '{item.Label}' failed", ex); } finally { diff --git a/FivePRS.Client/PublicState.cs b/FivePRS.Client/PublicState.cs index 99b34db..7a65133 100644 --- a/FivePRS.Client/PublicState.cs +++ b/FivePRS.Client/PublicState.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using FivePRS.Client.App; using FivePRS.Client.Callouts; using FivePRS.Client.Dispatch; using FivePRS.Core.Config; @@ -20,6 +21,13 @@ public static IDictionary Build() ["onDuty"] = player.IsOnDuty, ["department"] = player.Department.ToString(), ["rank"] = player.Rank, + ["rankName"] = player.RankName(), + ["prefs"] = new Dictionary + { + ["textSize"] = TerminalPanel.Preferences.TextSize, + ["clock24"] = TerminalPanel.Preferences.Clock24, + ["reduceMotion"] = TerminalPanel.Preferences.ReduceMotion, + }, }; if (!player.IsOnDuty) return state; diff --git a/FivePRS.Client/Reports.cs b/FivePRS.Client/Reports.cs new file mode 100644 index 0000000..4804438 --- /dev/null +++ b/FivePRS.Client/Reports.cs @@ -0,0 +1,25 @@ +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Civilian; +using FivePRS.Core.Events; +using Newtonsoft.Json; + +namespace FivePRS.Client +{ + public static class Reports + { + public static void Draft(ReportDraft draft) => + ClientEvents.TriggerServer(EventNames.ServerReportDraft, JsonConvert.SerializeObject(draft)); + + public static string Describe(Vector3 position) + { + uint street = 0, crossing = 0; + API.GetStreetNameAtCoord(position.X, position.Y, position.Z, ref street, ref crossing); + + var road = API.GetStreetNameFromHashKey(street); + var zone = API.GetLabelText(API.GetNameOfZone(position.X, position.Y, position.Z)); + if (string.IsNullOrEmpty(zone) || zone == "NULL") return road; + return string.IsNullOrEmpty(road) ? zone : $"{road}, {zone}"; + } + } +} diff --git a/FivePRS.Client/Stations/StationMenu.cs b/FivePRS.Client/Stations/StationMenu.cs index b76d94f..4f2a853 100644 --- a/FivePRS.Client/Stations/StationMenu.cs +++ b/FivePRS.Client/Stations/StationMenu.cs @@ -121,7 +121,7 @@ private static MenuPage Armory(StationDef station) private static MenuPage Kits(StationCatalog catalog) { - var rank = ClientBrain.LocalPlayerData.Rank; + var rank = ClientBrain.LocalPlayerData.Ladder().Clamp(ClientBrain.LocalPlayerData.Rank); var page = new MenuPage { Title = "Kits", Subtitle = "Replaces your current weapons" }; foreach (var kit in catalog.Kits) @@ -130,7 +130,7 @@ private static MenuPage Kits(StationCatalog catalog) page.Items.Add(new MenuItem { Label = kit.Name, - Detail = locked ? $"Rank {kit.MinRank}" : $"{kit.Weapons.Count} items", + Detail = locked ? RequiredRank(kit.MinRank) : $"{kit.Weapons.Count} items", Disabled = locked, Select = async () => { @@ -145,7 +145,7 @@ private static MenuPage Kits(StationCatalog catalog) private static MenuPage Weapons(StationDef station) { - var rank = ClientBrain.LocalPlayerData.Rank; + var rank = ClientBrain.LocalPlayerData.Ladder().Clamp(ClientBrain.LocalPlayerData.Rank); var page = new MenuPage { Title = "Weapons", Subtitle = station.Name }; foreach (var weapon in Catalog().Weapons) @@ -154,7 +154,7 @@ private static MenuPage Weapons(StationDef station) page.Items.Add(new MenuItem { Label = weapon.Label, - Detail = locked ? $"Rank {weapon.MinRank}" : LoadoutManager.HasWeapon(weapon.Weapon.Hash) ? "Issued" : null, + Detail = locked ? RequiredRank(weapon.MinRank) : LoadoutManager.HasWeapon(weapon.Weapon.Hash) ? "Issued" : null, Disabled = locked, Select = () => { @@ -170,7 +170,7 @@ private static MenuPage Weapons(StationDef station) private static MenuPage Locker(StationDef station) { - var rank = ClientBrain.LocalPlayerData.Rank; + var rank = ClientBrain.LocalPlayerData.Ladder().Clamp(ClientBrain.LocalPlayerData.Rank); var page = new MenuPage { Title = "Locker", Subtitle = station.Name }; foreach (var uniform in Catalog().Uniforms) @@ -179,7 +179,7 @@ private static MenuPage Locker(StationDef station) page.Items.Add(new MenuItem { Label = uniform.Name, - Detail = locked ? $"Rank {uniform.MinRank}" : null, + Detail = locked ? RequiredRank(uniform.MinRank) : null, Disabled = locked, Select = async () => { @@ -222,7 +222,7 @@ private static MenuPage Locker(StationDef station) private static MenuPage Garage(StationDef station) { var agency = BaseAgency.Active; - var rank = ClientBrain.LocalPlayerData.Rank; + var rank = ClientBrain.LocalPlayerData.Ladder().Clamp(ClientBrain.LocalPlayerData.Rank); var current = agency?.Vehicles.Current; var page = new MenuPage { @@ -236,7 +236,7 @@ private static MenuPage Garage(StationDef station) page.Items.Add(new MenuItem { Label = vehicle.Label, - Detail = locked ? $"Rank {vehicle.MinRank}" : null, + Detail = locked ? RequiredRank(vehicle.MinRank) : null, Disabled = locked || agency is null, Select = async () => { @@ -284,5 +284,12 @@ private static StationCatalog Catalog() => private static void Notify(string subject, string message) => ClientBrain.ShowNotification(message, subject); + + private static string RequiredRank(int minRank) + { + var player = ClientBrain.LocalPlayerData; + var ladder = player.Ladder(); + return minRank > ladder.Count ? "Unavailable" : ladder.NameFor(minRank, player.Agency); + } } } diff --git a/FivePRS.Client/StreetIdentity.cs b/FivePRS.Client/StreetIdentity.cs new file mode 100644 index 0000000..3270212 --- /dev/null +++ b/FivePRS.Client/StreetIdentity.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using Newtonsoft.Json; + +namespace FivePRS.Client +{ + public sealed class StreetStop + { + public string Kind { get; set; } = "person"; + public string Plate { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public string? Name { get; set; } + public int? CharacterId { get; set; } + public int? OwnerId { get; set; } + public bool PlateChecked { get; set; } + public bool Checking { get; set; } + + [JsonIgnore] + public Action? RunPlate { get; set; } + } + + public static class StreetIdentity + { + private const int MaxSeeds = 512; + + private static readonly Random Rng = new(); + private static readonly Dictionary Seeds = new(); + + public static Func? CurrentStop { get; set; } + + public static int SeedFor(Ped ped) + { + if (Seeds.TryGetValue(ped.Handle, out var seed)) return seed; + + if (Seeds.Count >= MaxSeeds) Prune(); + Seeds[ped.Handle] = seed = Rng.Next(); + return seed; + } + + public static bool IsFemale(Ped ped) => !API.IsPedMale(ped.Handle); + + public static string VehicleName(Vehicle vehicle) + { + var display = API.GetDisplayNameFromVehicleModel((uint)vehicle.Model.Hash); + var label = API.GetLabelText(display); + return string.IsNullOrEmpty(label) || label == "NULL" ? display : label; + } + + public static string PlateOf(Vehicle vehicle) => API.GetVehicleNumberPlateText(vehicle.Handle).Trim(); + + public static Vehicle? VehicleAhead(float range, float cone, bool includeMission) + { + var player = Game.PlayerPed; + var own = player.CurrentVehicle; + var origin = own is not null && own.Exists() ? own.Position : player.Position; + var facing = own is not null && own.Exists() ? own.ForwardVector : player.ForwardVector; + + Vehicle? best = null; + var bestDistance = range; + + foreach (var vehicle in World.GetAllVehicles()) + { + if (!vehicle.Exists() || (own is not null && vehicle.Handle == own.Handle)) continue; + if (!includeMission && API.IsEntityAMissionEntity(vehicle.Handle)) continue; + + var offset = vehicle.Position - origin; + var distance = offset.Length(); + if (distance >= bestDistance || distance < 1f) continue; + + offset.Normalize(); + if (Vector3.Dot(offset, facing) < cone) continue; + + best = vehicle; + bestDistance = distance; + } + + return best; + } + + public static Ped? PersonAhead(float range, float cone, bool includeMission) + { + var player = Game.PlayerPed; + + Ped? best = null; + var bestDistance = range; + + foreach (var ped in World.GetAllPeds()) + { + if (ped.Handle == player.Handle || !ped.Exists() || ped.IsPlayer) continue; + if (!API.IsPedHuman(ped.Handle) || (!includeMission && API.IsEntityAMissionEntity(ped.Handle))) continue; + + var offset = ped.Position - player.Position; + var distance = offset.Length(); + if (distance >= bestDistance) continue; + + offset.Normalize(); + if (Vector3.Dot(offset, player.ForwardVector) < cone) continue; + + best = ped; + bestDistance = distance; + } + + return best; + } + + public static Vehicle? FindByPlate(string plate) + { + var wanted = plate.Trim().Replace(" ", string.Empty).ToUpperInvariant(); + if (wanted.Length == 0) return null; + + foreach (var vehicle in World.GetAllVehicles()) + { + if (!vehicle.Exists()) continue; + if (PlateOf(vehicle).Replace(" ", string.Empty).ToUpperInvariant() == wanted) return vehicle; + } + + return null; + } + + private static void Prune() + { + var stale = new List(); + foreach (var handle in Seeds.Keys) + if (!API.DoesEntityExist(handle)) stale.Add(handle); + + foreach (var handle in stale) Seeds.Remove(handle); + } + } +} diff --git a/FivePRS.Client/Terminal/Apps/AdminApp.cs b/FivePRS.Client/Terminal/Apps/AdminApp.cs new file mode 100644 index 0000000..cb7c6a9 --- /dev/null +++ b/FivePRS.Client/Terminal/Apps/AdminApp.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using FivePRS.Client.App; +using FivePRS.Core.Models; + +namespace FivePRS.Client.Terminal.Apps +{ + public class AdminApp : TerminalApp + { + public override string Id => "admin"; + + public override string Label => "Admin"; + + public override string Color => "#7c3aed"; + + public override string Icon => + "M4 21v-7 M4 10V3 M12 21v-9 M12 8V3 M20 21v-5 M20 12V3 M1 14h6 M9 8h6 M17 16h6"; + + public override int Order => 90; + + public override string? Page => "nui/apps/admin/index.html"; + + public override bool IsAvailable(PlayerData player) => AdminPanel.IsAvailable; + + public override void OnOpened() => AdminPanel.Request(); + + public override object? BuildState() => AdminPanel.BuildView(); + + public override void OnAction(string action, IDictionary data) + { + switch (action) + { + case "search": + AdminPanel.Search(data); + break; + + case "roster": + AdminPanel.SetRoster(data); + break; + + case "action": + AdminPanel.Act(data); + break; + } + } + } +} diff --git a/FivePRS.Client/Terminal/Apps/CharactersApp.cs b/FivePRS.Client/Terminal/Apps/CharactersApp.cs new file mode 100644 index 0000000..188e661 --- /dev/null +++ b/FivePRS.Client/Terminal/Apps/CharactersApp.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using FivePRS.Client.App; + +namespace FivePRS.Client.Terminal.Apps +{ + public class CharactersApp : TerminalApp + { + public override string Id => "characters"; + + public override string Label => "Characters"; + + public override string Color => "#0d9488"; + + public override string Icon => "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M4 21a8 8 0 0 1 16 0"; + + public override int Order => 30; + + public override string? Page => "nui/apps/characters/index.html"; + + public override object BuildState() => CivilianPanel.BuildView(); + + public override void OnAction(string action, IDictionary data) + { + switch (action) + { + case "create": + CivilianPanel.CreateCharacter(data); + break; + case "select": + CivilianPanel.SelectCharacter(data); + break; + case "delete": + CivilianPanel.DeleteCharacter(data); + break; + case "appearance": + CivilianPanel.RequestAppearance(); + break; + case "mugshot": + CivilianPanel.RequestMugshot(data); + break; + case "discardMugshot": + CivilianPanel.DiscardMugshot(); + break; + case "photoUrl": + CivilianPanel.SetPhotoUrl(data); + break; + case "removePhoto": + CivilianPanel.RemovePhoto(data); + break; + } + } + } +} diff --git a/FivePRS.Client/Terminal/Apps/DispatchApp.cs b/FivePRS.Client/Terminal/Apps/DispatchApp.cs new file mode 100644 index 0000000..395a5c4 --- /dev/null +++ b/FivePRS.Client/Terminal/Apps/DispatchApp.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using FivePRS.Client.App; +using FivePRS.Client.Callouts; +using FivePRS.Core.Models; + +namespace FivePRS.Client.Terminal.Apps +{ + public class DispatchApp : TerminalApp + { + public override string Id => "dispatch"; + + public override string Label => "Dispatch"; + + public override string Color => "#e81010"; + + public override string Icon => + "M12 10a2 2 0 1 1 0 4 2 2 0 0 1 0-4z M7.8 7.8a6 6 0 0 0 0 8.4 M16.2 7.8a6 6 0 0 1 0 8.4 M4.9 4.9a10 10 0 0 0 0 14.2 M19.1 4.9a10 10 0 0 1 0 14.2"; + + public override int Order => 10; + + public override string? Page => "nui/apps/dispatch/index.html"; + + public override string? Badge => + CalloutDispatcher.PendingOffer is not null ? "!" : + CalloutDispatcher.ActiveCall is not null ? "1" : null; + + public override bool IsAvailable(PlayerData player) => DispatchPanel.IsAvailable; + + public override object? BuildState() => DispatchPanel.BuildView(); + + public override void OnAction(string action, IDictionary data) + { + switch (action) + { + case "status": + DispatchPanel.SetStatus(data); + break; + + case "attach": + DispatchPanel.Attach(data); + break; + + case "clear": + DispatchPanel.ClearCall(data); + break; + + case "waypoint": + DispatchPanel.Waypoint(data); + break; + + case "accept": + CalloutDispatcher.AcceptOffer(); + break; + + case "decline": + CalloutDispatcher.DeclineOffer(); + break; + + case "end": + CalloutDispatcher.EndActiveCall(); + break; + + case "backup": + DispatchPanel.RequestBackup(data); + break; + } + } + } +} diff --git a/FivePRS.Client/Terminal/Apps/DutyApp.cs b/FivePRS.Client/Terminal/Apps/DutyApp.cs new file mode 100644 index 0000000..770541a --- /dev/null +++ b/FivePRS.Client/Terminal/Apps/DutyApp.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using FivePRS.Client.App; + +namespace FivePRS.Client.Terminal.Apps +{ + public class DutyApp : TerminalApp + { + public const string AppId = "duty"; + + public override string Id => AppId; + + public override string Label => "Duty"; + + public override string Color => "#335cff"; + + public override string Icon => + "M12 2l8 3v6c0 5.2-3.4 9.4-8 11-4.6-1.6-8-5.8-8-11V5z M12 8l1.2 2.6 2.8.3-2.1 1.9.6 2.8L12 14.2l-2.5 1.4.6-2.8-2.1-1.9 2.8-.3z"; + + public override int Order => 0; + + public override string? Page => "nui/apps/duty/index.html"; + + internal static void ShowError(string message) => TerminalApps.SendToApp(AppId, "error", message); + + public override object BuildState() => DutyPanel.BuildView(); + + public override void OnAction(string action, IDictionary data) + { + switch (action) + { + case "enter": + var error = DutyPanel.Enter(data); + if (error is not null) ShowError(error); + break; + + case "off": + DutyPanel.GoOffDuty(); + break; + } + } + } +} diff --git a/FivePRS.Client/Terminal/Apps/EmergencyApp.cs b/FivePRS.Client/Terminal/Apps/EmergencyApp.cs new file mode 100644 index 0000000..aabaaba --- /dev/null +++ b/FivePRS.Client/Terminal/Apps/EmergencyApp.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FivePRS.Client.Agency; +using FivePRS.Client.App; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using Newtonsoft.Json; + +namespace FivePRS.Client.Terminal.Apps +{ + public class EmergencyApp : TerminalApp + { + private const string AppId = "emergency"; + + private static EmergencyCallStatus? _status; + + public override string Id => AppId; + + public override string Label => "911"; + + public override string Color => "#b91c1c"; + + public override string Icon => + "M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z"; + + public override int Order => 60; + + public override string? Page => "nui/apps/emergency/index.html"; + + public override string? Badge => _status is null ? null : "•"; + + internal static void SetStatus(string json) => _status = JsonConvert.DeserializeObject(json); + + internal static void RequestStatus() => ClientEvents.TriggerServer(EventNames.ServerEmergencyStatus); + + internal static void ShowError(string message) => TerminalApps.SendToApp(AppId, "error", message); + + public override void OnOpened() => RequestStatus(); + + public override object BuildState() => new + { + Departments = Enum.GetValues(typeof(Department)) + .Cast() + .Where(d => d != Department.None && BaseAgency.IsDepartmentLoaded(d)) + .Select(d => new { Id = (int)d, Name = d.ToString() }) + .ToList(), + Status = _status, + MaxLength = CivilianRules.MaxEmergencyLength, + Icons = ConfigManager.Settings.Branding.Departments, + Location = HomePanel.CurrentLocation(), + }; + + public override void OnAction(string action, IDictionary data) + { + switch (action) + { + case "call" when NuiData.TryGetInt(data, "departmentId", out var departmentId): + ClientEvents.TriggerServer(EventNames.ServerEmergencyCall, departmentId, + NuiData.GetString(data, "description"), NuiData.GetBool(data, "anonymous")); + break; + + case "cancel" when _status is not null: + ClientEvents.TriggerServer(EventNames.ServerEmergencyCancel, _status.CallId); + break; + } + } + } +} diff --git a/FivePRS.Client/Terminal/Apps/LicensesApp.cs b/FivePRS.Client/Terminal/Apps/LicensesApp.cs new file mode 100644 index 0000000..40c87e5 --- /dev/null +++ b/FivePRS.Client/Terminal/Apps/LicensesApp.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using FivePRS.Client.App; + +namespace FivePRS.Client.Terminal.Apps +{ + public class LicensesApp : TerminalApp + { + public override string Id => "licenses"; + + public override string Label => "Licenses"; + + public override string Color => "#d97706"; + + public override string Icon => + "M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M8.5 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4z M5.5 17a3 3 0 0 1 6 0 M14 10h4 M14 14h3"; + + public override int Order => 40; + + public override string? Page => "nui/apps/licenses/index.html"; + + public override object BuildState() => CivilianPanel.BuildView(); + + public override void OnAction(string action, IDictionary data) + { + if (action == "apply") CivilianPanel.ApplyLicense(data); + else if (action == "show") CivilianPanel.ShowLicense(data); + } + } +} diff --git a/FivePRS.Client/Terminal/Apps/RecordsApp.cs b/FivePRS.Client/Terminal/Apps/RecordsApp.cs new file mode 100644 index 0000000..f84015b --- /dev/null +++ b/FivePRS.Client/Terminal/Apps/RecordsApp.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using FivePRS.Client.App; +using FivePRS.Core.Models; + +namespace FivePRS.Client.Terminal.Apps +{ + public class RecordsApp : TerminalApp + { + private const string AppId = "records"; + + public override string Id => AppId; + + public override string Label => "Records"; + + public override string Color => "#4f46e5"; + + public override string Icon => + "M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z M14.5 14.5l2.5 2.5 M13 15a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"; + + public override int Order => 20; + + public override string? Badge => RecordsPanel.DraftCount > 0 ? RecordsPanel.DraftCount.ToString() : null; + + public override string? Page => "nui/apps/records/index.html"; + + internal static void ShowError(string message) => TerminalApps.SendToApp(AppId, "error", message); + + public override bool IsAvailable(PlayerData player) => RecordsPanel.IsAvailable; + + public override object? BuildState() => RecordsPanel.BuildView(); + + public override void OnAction(string action, IDictionary data) + { + switch (action) + { + case "searchName": + RecordsPanel.SearchName(data); + break; + + case "searchPlate": + RecordsPanel.SearchPlate(data); + break; + + case "vehicleAhead": + RecordsPanel.RunVehicleAhead(); + break; + + case "personAhead": + RecordsPanel.IdentifyPerson(); + break; + + case "stopPlate": + RecordsPanel.RunStopPlate(); + break; + + case "reports": + RecordsPanel.RequestReports(); + break; + + case "reportSave": + RecordsPanel.SaveReport(data); + break; + + case "reportFile": + RecordsPanel.FileReport(data); + break; + + case "reportSearch": + RecordsPanel.SearchReports(data); + break; + + case "open": + RecordsPanel.Open(data); + break; + + case "back": + RecordsPanel.CloseRecord(); + break; + + case "issue": + RecordsPanel.Issue(data); + break; + + case "resolve": + RecordsPanel.Resolve(data); + break; + + case "license": + RecordsPanel.SetLicense(data); + break; + + case "vehicle": + RecordsPanel.FlagVehicle(data); + break; + } + } + } +} diff --git a/FivePRS.Client/Terminal/Apps/VehiclesApp.cs b/FivePRS.Client/Terminal/Apps/VehiclesApp.cs new file mode 100644 index 0000000..93074b5 --- /dev/null +++ b/FivePRS.Client/Terminal/Apps/VehiclesApp.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using FivePRS.Client.App; + +namespace FivePRS.Client.Terminal.Apps +{ + public class VehiclesApp : TerminalApp + { + public override string Id => "vehicles"; + + public override string Label => "Vehicles"; + + public override string Color => "#475569"; + + public override string Icon => + "M5 17H3v-4l2-5h14l2 5v4h-2 M3 13h18 M7.5 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4z M16.5 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"; + + public override int Order => 50; + + public override string? Page => "nui/apps/vehicles/index.html"; + + public override object BuildState() => CivilianPanel.BuildView(); + + public override void OnAction(string action, IDictionary data) + { + switch (action) + { + case "register": + CivilianPanel.RegisterVehicle(); + break; + case "stolen": + CivilianPanel.SetVehicleStolen(data); + break; + case "remove": + CivilianPanel.RemoveVehicle(data); + break; + } + } + } +} diff --git a/FivePRS.Client/Terminal/SettingsApp.cs b/FivePRS.Client/Terminal/SettingsApp.cs index 553fad0..9c8c0b7 100644 --- a/FivePRS.Client/Terminal/SettingsApp.cs +++ b/FivePRS.Client/Terminal/SettingsApp.cs @@ -4,66 +4,61 @@ using FivePRS.Client.App; using FivePRS.Client.Dispatch; using FivePRS.Core.Config; +using FivePRS.Core.Models; +using FivePRS.Core.Text; namespace FivePRS.Client.Terminal { public class SettingsApp : TerminalApp { + private static readonly string[][] Bindings = + { + new[] { KeyCommands.Menu, "Open the terminal" }, + new[] { KeyCommands.Interact, "Interact" }, + new[] { KeyCommands.Accept, "Accept a call" }, + new[] { KeyCommands.Decline, "Decline a call" }, + new[] { KeyCommands.EndCall, "End your call" }, + new[] { KeyCommands.Backup, "Request backup" }, + new[] { KeyCommands.TrafficStop, "Traffic stop" }, + new[] { KeyCommands.Cuff, "Cuff" }, + new[] { KeyCommands.Uncuff, "Uncuff" }, + new[] { KeyCommands.Escort, "Escort to vehicle" }, + new[] { KeyCommands.AiCallouts, "Toggle AI callouts" }, + }; + public override string Id => "settings"; public override string Label => "Settings"; + public override string Color => "#334155"; + public override string Icon => "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M12 2v3 M12 19v3 M4.9 4.9l2.1 2.1 M17 17l2.1 2.1 M2 12h3 M19 12h3 M4.9 19.1L7 17 M17 7l2.1-2.1"; public override int Order => 1000; - public override AppScreen BuildScreen() + public override string? Page => "nui/apps/settings/index.html"; + + public override object BuildState() { - var screen = new AppScreen(); var terminal = ConfigManager.Settings.Terminal; var unit = DispatchClient.LocalUnit; - - if (unit is not null) - { - screen.Add(AppBlock.Section("Duty")) - .Add(AppBlock.Paragraph("AI callouts. Off pauses AI callouts for you; player 911 calls still reach you.", true)) - .Add(AppBlock.Buttons( - Toggle("On", unit.AiCallouts, true), - Toggle("Off", !unit.AiCallouts, false))); - } - + var version = API.GetResourceMetadata(API.GetCurrentResourceName(), "version", 0); var selected = TerminalPanel.Wallpaper.Length > 0 ? TerminalPanel.Wallpaper : $"id:{terminal.DefaultWallpaper}"; - screen.Add(AppBlock.Section("Wallpaper")) - .Add(AppBlock.List(terminal.Wallpapers.Select(option => new AppItem - { - Title = option.Label, - Image = option.Url, - Action = "wallpaper", - Data = new Dictionary { ["value"] = $"id:{option.Id}" }, - Active = selected == $"id:{option.Id}", - }), grid: true)); - if (terminal.AllowCustomWallpapers) + return new { - screen.Add(AppBlock.Form("wallpaperUrl", "Use image", new AppField - { - Name = "url", - Label = "Custom image", - Placeholder = "https://example.com/wallpaper.png", - Value = selected.StartsWith("url:") ? selected.Substring(4) : null, - Type = "url", - })); - } - - var version = API.GetResourceMetadata(API.GetCurrentResourceName(), "version", 0); - screen.Add(AppBlock.Section("About")) - .Add(AppBlock.Paragraph($"FivePRS {version}")) - .Add(AppBlock.Paragraph(version.Contains("-") - ? "Public test build. Report problems at github.com/FivePRS/core/issues" - : "fiveprs.org", true)); - - return screen; + Wallpapers = terminal.Wallpapers.Select(option => new { Value = $"id:{option.Id}", option.Label, option.Url }), + Wallpaper = selected, + AllowCustom = terminal.AllowCustomWallpapers, + Preferences = TerminalPanel.Preferences, + OnDuty = unit is not null, + AiCallouts = unit?.AiCallouts ?? AiCallouts.Enabled, + Version = version, + Nameplate = ConfigManager.Settings.Branding.Nameplate, + PreRelease = version.Contains("-"), + Bindings = Bindings.Select(binding => new { Label = binding[1], Key = KeyLabel(binding[0]) }), + }; } public override void OnAction(string action, IDictionary data) @@ -73,22 +68,35 @@ public override void OnAction(string action, IDictionary data) case "aiCallouts": AiCallouts.Set(NuiData.GetBool(data, "enabled")); break; + case "wallpaper": TerminalPanel.ChooseWallpaper(NuiData.GetString(data, "value")); break; + case "wallpaperUrl": var url = NuiData.GetString(data, "url").Trim(); if (url.Length > 0) TerminalPanel.ChooseWallpaper($"url:{url}"); break; + + case "preferences": + var current = TerminalPanel.Preferences; + TerminalPanel.SavePreferences(new TerminalPreferences + { + TextSize = NuiData.TryGetString(data, "textSize", out var size) ? size : current.TextSize, + Clock24 = data.ContainsKey("clock24") ? NuiData.GetBool(data, "clock24") : current.Clock24, + ReduceMotion = data.ContainsKey("reduceMotion") ? NuiData.GetBool(data, "reduceMotion") : current.ReduceMotion, + TerminalVolume = NuiData.TryGetInt(data, "terminalVolume", out var terminalVolume) ? terminalVolume : current.TerminalVolume, + DispatchVolume = NuiData.TryGetInt(data, "dispatchVolume", out var dispatchVolume) ? dispatchVolume : current.DispatchVolume, + HoldChannel = data.ContainsKey("holdChannel") ? NuiData.GetBool(data, "holdChannel") : current.HoldChannel, + }); + break; } } - private static AppItem Toggle(string label, bool active, bool enabled) => new() + private static string KeyLabel(string command) { - Title = label, - Action = "aiCallouts", - Data = new Dictionary { ["enabled"] = enabled }, - Active = active, - }; + var button = API.GetControlInstructionalButton(0, unchecked((int)((uint)API.GetHashKey(command) | 0x80000000)), 1); + return button is not null && button.StartsWith("t_") ? button.Substring(2) : "Not set"; + } } } diff --git a/FivePRS.Client/Terminal/TerminalApp.cs b/FivePRS.Client/Terminal/TerminalApp.cs index 1fc8837..b84da5f 100644 --- a/FivePRS.Client/Terminal/TerminalApp.cs +++ b/FivePRS.Client/Terminal/TerminalApp.cs @@ -29,6 +29,8 @@ protected TerminalApp() public virtual AppScreen? BuildScreen() => null; + public virtual object? BuildState() => null; + public virtual void OnAction(string action, IDictionary data) { } diff --git a/FivePRS.Client/Terminal/TerminalApps.cs b/FivePRS.Client/Terminal/TerminalApps.cs index a95e29e..065dde0 100644 --- a/FivePRS.Client/Terminal/TerminalApps.cs +++ b/FivePRS.Client/Terminal/TerminalApps.cs @@ -4,6 +4,7 @@ using CitizenFX.Core; using CitizenFX.Core.Native; using FivePRS.Core.Events; +using FivePRS.Core.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -11,6 +12,8 @@ namespace FivePRS.Client.Terminal { public class TerminalApps : BaseScript { + private static readonly Logger Log = Logger.For("Terminal"); + private const int MaxIdLength = 32; private static readonly JsonSerializerSettings JsonSettings = new() @@ -45,7 +48,7 @@ internal static void Register(TerminalApp app) { if (!IsValidId(app.Id) || Apps.Any(existing => existing.Id == app.Id)) { - Debug.WriteLine($"[FivePRS] Terminal app '{app.Id}' was not registered: the id is invalid or already in use."); + Log.Warn($"Terminal app '{app.Id}' was not registered: the id is invalid or already in use."); return; } @@ -61,7 +64,7 @@ private static void RegisterFromResource(IDictionary definition) if (!IsValidId(id) || Apps.Any(existing => existing.Id == id && existing.Resource != resource)) { - Debug.WriteLine($"[FivePRS] {resource} could not register terminal app '{id}': the id is invalid or already in use."); + Log.Warn($"{resource} could not register terminal app '{id}': the id is invalid or already in use."); return; } @@ -94,6 +97,16 @@ internal static void SetOpen(string? id) Apps.FirstOrDefault(app => app.Id == _openId)?.Instance?.OnClosed(); _openId = next?.Id; next?.Instance?.OnOpened(); + PushOpenState(); + } + + internal static void PushOpenState() + { + var app = Apps.FirstOrDefault(entry => entry.Id == _openId); + if (app?.Instance is null || app.Page is null) return; + + var state = app.Instance.BuildState(); + if (state is not null) SendToApp(app.Id, "state", state); } internal static void Act(string id, string action, IDictionary data) @@ -107,6 +120,12 @@ internal static void Act(string id, string action, IDictionary d TriggerEvent(EventNames.PublicAppAction, id, action, data); } + internal static bool IsAvailable(string id) + { + var player = ClientBrain.LocalPlayerData; + return Apps.Any(app => app.Id == id && (app.Instance?.IsAvailable(player) ?? app.Available)); + } + internal static object BuildList() { var player = ClientBrain.LocalPlayerData; @@ -162,7 +181,7 @@ private static void Remove(RegisteredApp? app) } catch (JsonException ex) { - Debug.WriteLine($"[FivePRS] Invalid terminal app view: {ex.Message}"); + Log.Warn("Invalid terminal app view", ex); return null; } } diff --git a/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs b/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs index fe0341e..b05d964 100644 --- a/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs +++ b/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Core.Logging; namespace FivePRS.Client.VehicleSpawner { @@ -41,6 +42,8 @@ public SpawnPoint(Vector3 position, float heading) public sealed class PatrolVehicleSpawner { + private static readonly Logger Log = Logger.For("Vehicles"); + private static readonly Random Rng = new(); private CitizenFX.Core.Vehicle? _vehicle; @@ -56,7 +59,7 @@ public sealed class PatrolVehicleSpawner if (config.ModelPool.Count == 0) { - Debug.WriteLine("[PatrolVehicleSpawner] No vehicle models configured."); + Log.Warn("No patrol vehicle models are configured."); return null; } @@ -65,7 +68,7 @@ public sealed class PatrolVehicleSpawner if (!await model.Request(10_000)) { - Debug.WriteLine($"[PatrolVehicleSpawner] Model '{modelName}' failed to load."); + Log.Warn($"Vehicle model '{modelName}' failed to load."); model.MarkAsNoLongerNeeded(); return null; } @@ -75,7 +78,7 @@ public sealed class PatrolVehicleSpawner if (_vehicle is null || !_vehicle.Exists()) { - Debug.WriteLine("[PatrolVehicleSpawner] Vehicle creation failed."); + Log.Warn("Could not create the patrol vehicle."); return null; } @@ -83,7 +86,7 @@ public sealed class PatrolVehicleSpawner if (warpIn) WarpPlayerIn(_vehicle); AddVehicleBlip(_vehicle, modelName); - Debug.WriteLine($"[PatrolVehicleSpawner] Spawned '{modelName}' at {spawn.Position}."); + Log.Debug($"Spawned '{modelName}' at {spawn.Position}."); return _vehicle; } @@ -95,7 +98,7 @@ public void Despawn() if (_vehicle is not null && _vehicle.Exists()) { _vehicle.Delete(); - Debug.WriteLine("[PatrolVehicleSpawner] Patrol vehicle despawned."); + Log.Debug("Patrol vehicle removed."); } _vehicle = null; diff --git a/FivePRS.Core/Civilian/CivilianModels.cs b/FivePRS.Core/Civilian/CivilianModels.cs index 62fea6c..5ca8fc9 100644 --- a/FivePRS.Core/Civilian/CivilianModels.cs +++ b/FivePRS.Core/Civilian/CivilianModels.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using FivePRS.Core.Config; namespace FivePRS.Core.Civilian { @@ -26,6 +27,7 @@ public sealed class CharacterInfo public string Gender { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } public DateTime LastUsedAt { get; set; } + public string? Photo { get; set; } public string FullName => $"{FirstName} {LastName}"; } @@ -59,11 +61,28 @@ public sealed class CivilianState public int MaxVehicles { get; set; } } + public sealed class LicenseCard + { + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string DateOfBirth { get; set; } = string.Empty; + public string Gender { get; set; } = string.Empty; + public string StateId { get; set; } = string.Empty; + public string? Photo { get; set; } + public StateDef State { get; set; } = new(); + public LicenseView License { get; set; } = new(); + } + public sealed class LicenseView { public string Type { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public bool SelfService { get; set; } public LicenseStatus? Status { get; set; } + public string Number { get; set; } = string.Empty; + public string Class { get; set; } = string.Empty; + public DateTime? IssuedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public LicenseCardDef Card { get; set; } = new(); } } diff --git a/FivePRS.Core/Civilian/CivilianRules.cs b/FivePRS.Core/Civilian/CivilianRules.cs index 8cf6407..dc1aa71 100644 --- a/FivePRS.Core/Civilian/CivilianRules.cs +++ b/FivePRS.Core/Civilian/CivilianRules.cs @@ -11,6 +11,9 @@ public static class CivilianRules public const int MaxDescriptionLength = 500; public const int MinSearchLength = 2; public const int MaxEmergencyLength = 300; + public const int MaxPhotoDataLength = 120_000; + public const int MaxPhotoUrlLength = 500; + public const string PhotoDataPrefix = "data:image/jpeg;base64,"; public static readonly string[] Genders = { "Male", "Female", "Other" }; @@ -76,6 +79,44 @@ public static bool TryNormalizeSearch(string? input, out string term) return true; } + public static string StateId(string abbreviation, int characterId) => + $"{abbreviation}-{10000 + characterId}"; + + public static string LicenseNumber(string type, int characterId) + { + var hash = 0; + foreach (var c in type.ToLowerInvariant()) + hash = (hash * 31 + c) % 9973; + + var prefix = type.Length > 0 ? char.ToUpperInvariant(type[0]) : 'L'; + return $"{prefix}{(characterId * 7919L + hash * 104729L) % 10000000:0000000}"; + } + + public static bool TryNormalizePhoto(string? input, out string photo) + { + photo = (input ?? string.Empty).Trim(); + if (photo.Length == 0) return true; + + if (photo.StartsWith(PhotoDataPrefix, StringComparison.Ordinal)) + { + if (photo.Length > MaxPhotoDataLength) return false; + + for (var i = PhotoDataPrefix.Length; i < photo.Length; i++) + { + var c = photo[i]; + var valid = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '='; + if (!valid) return false; + } + + return true; + } + + return photo.Length <= MaxPhotoUrlLength && + Uri.TryCreate(photo, UriKind.Absolute, out var uri) && + uri.Scheme == "https" && + photo.IndexOfAny(new[] { '"', '\'', '<', '>', '(', ')', ' ', '\\' }) < 0; + } + public static bool TryNormalizePlate(string? input, out string plate) { plate = (input ?? string.Empty).Trim().ToUpperInvariant(); diff --git a/FivePRS.Core/Civilian/LicenseViews.cs b/FivePRS.Core/Civilian/LicenseViews.cs new file mode 100644 index 0000000..4a513c9 --- /dev/null +++ b/FivePRS.Core/Civilian/LicenseViews.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FivePRS.Core.Config; + +namespace FivePRS.Core.Civilian +{ + public static class LicenseViews + { + public static List Build(int characterId, LicensesConfig config, IEnumerable? held) => + config.Types.Select(type => + { + var license = held?.FirstOrDefault(l => string.Equals(l.Type, type.Id, StringComparison.OrdinalIgnoreCase)); + return new LicenseView + { + Type = type.Id, + Name = type.Name, + SelfService = type.SelfService, + Status = license?.Status, + Number = CivilianRules.LicenseNumber(type.Id, characterId), + Class = type.Class, + IssuedAt = license?.IssuedAt, + ExpiresAt = license is null || type.ValidDays <= 0 ? null : license.IssuedAt.AddDays(type.ValidDays), + Card = type.Card, + }; + }).ToList(); + } +} diff --git a/FivePRS.Core/Civilian/RecordAccess.cs b/FivePRS.Core/Civilian/RecordAccess.cs new file mode 100644 index 0000000..67870d8 --- /dev/null +++ b/FivePRS.Core/Civilian/RecordAccess.cs @@ -0,0 +1,41 @@ +using System.Linq; +using FivePRS.Core.Models; + +namespace FivePRS.Core.Civilian +{ + public static class RecordAccess + { + public static bool CanView(Department department) => + department == Department.Police || department == Department.EMS || department == Department.Fire; + + public static bool SeesCriminalRecord(Department department) => department == Department.Police; + + public static bool CanIssue(Department department, RecordType type) => SeesCriminalRecord(department) + ? type != RecordType.Medical + : CanView(department) && type == RecordType.Medical; + + public static string? CautionFor(LookupRecord record) + { + var warrant = record.Records.FirstOrDefault(r => r.Type == RecordType.Warrant && r.Active); + return warrant is null ? null : "Active warrant. Request police before approaching."; + } + + public static LookupResult Filter(LookupResult result, Department department) + { + var record = result.Record; + if (record is null) return result; + + if (SeesCriminalRecord(department)) + { + record.Records = record.Records.Where(r => r.Type != RecordType.Medical).ToList(); + return result; + } + + record.Caution = CautionFor(record); + record.Licenses = new(); + record.Vehicles = new(); + record.Records = record.Records.Where(r => r.Type == RecordType.Medical).ToList(); + return result; + } + } +} diff --git a/FivePRS.Core/Civilian/RecordModels.cs b/FivePRS.Core/Civilian/RecordModels.cs index 603cddf..144a75c 100644 --- a/FivePRS.Core/Civilian/RecordModels.cs +++ b/FivePRS.Core/Civilian/RecordModels.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using FivePRS.Core.Models; namespace FivePRS.Core.Civilian { @@ -9,6 +10,7 @@ public enum RecordType Arrest = 1, Warning = 2, Warrant = 3, + Medical = 4, } public sealed class RecordInfo @@ -33,6 +35,7 @@ public sealed class CharacterSummary public string DateOfBirth { get; set; } = string.Empty; public string Gender { get; set; } = string.Empty; public bool HasActiveWarrant { get; set; } + public string? Photo { get; set; } } public sealed class LookupRecord @@ -42,6 +45,7 @@ public sealed class LookupRecord public List Vehicles { get; set; } = new(); public List Records { get; set; } = new(); public string? MatchedPlate { get; set; } + public string? Caution { get; set; } } public sealed class LookupResult @@ -56,5 +60,6 @@ public sealed class OfficerInfo public string License { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Callsign { get; set; } = string.Empty; + public Department Department { get; set; } = Department.Police; } } diff --git a/FivePRS.Core/Civilian/ReportModels.cs b/FivePRS.Core/Civilian/ReportModels.cs new file mode 100644 index 0000000..24dd455 --- /dev/null +++ b/FivePRS.Core/Civilian/ReportModels.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using FivePRS.Core.Models; + +namespace FivePRS.Core.Civilian +{ + public enum ReportKind + { + Patient = 0, + Incident = 1, + } + + public enum ReportStatus + { + Draft = 0, + Filed = 1, + } + + public sealed class ReportDraft + { + public ReportKind Kind { get; set; } + public string CallId { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public string Narrative { get; set; } = string.Empty; + public int? PatientSeed { get; set; } + public bool PatientFemale { get; set; } + public string? Plate { get; set; } + } + + public sealed class ReportInfo + { + public int Id { get; set; } + public ReportKind Kind { get; set; } + public Department Department { get; set; } + public string CallId { get; set; } = string.Empty; + public ReportStatus Status { get; set; } + public string Title { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public string Narrative { get; set; } = string.Empty; + public int? CharacterId { get; set; } + public string? PersonName { get; set; } + public string? Plate { get; set; } + public string Crew { get; set; } = string.Empty; + public string AuthorName { get; set; } = string.Empty; + public string AuthorCallsign { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime? FiledAt { get; set; } + public string? FiledBy { get; set; } + } + + public sealed class ReportsView + { + public List Drafts { get; set; } = new(); + public List? Results { get; set; } + public string? Query { get; set; } + public string? Date { get; set; } + } +} diff --git a/FivePRS.Core/Civilian/ReportRules.cs b/FivePRS.Core/Civilian/ReportRules.cs new file mode 100644 index 0000000..15047be --- /dev/null +++ b/FivePRS.Core/Civilian/ReportRules.cs @@ -0,0 +1,64 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace FivePRS.Core.Civilian +{ + public static class ReportRules + { + public const int MaxTitleLength = 60; + public const int MaxLocationLength = 80; + public const int MaxNarrativeLength = 2000; + public const int MaxSearchLength = 60; + public const int AutoFileMinutes = 30; + + public static bool TryNormalizeNarrative(string? input, out string narrative) + { + narrative = Clean(input, true); + return narrative.Length > 0 && narrative.Length <= MaxNarrativeLength; + } + + public static string NormalizeLine(string? input, int max, string fallback) + { + var text = Clean(input, false); + if (text.Length == 0) return fallback; + return text.Length > max ? text.Substring(0, max).TrimEnd() : text; + } + + public static bool TryParseDate(string? input, out DateTime? date) + { + date = null; + var text = (input ?? string.Empty).Trim(); + if (text.Length == 0) return true; + + if (!DateTime.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) return false; + date = parsed; + return true; + } + + public static string NormalizeSearch(string? input) => NormalizeLine(input, MaxSearchLength, string.Empty); + + public static string MedicalSummary(ReportInfo report) + { + var text = $"{report.Title} at {report.Location}. {report.Narrative}"; + return text.Length > CivilianRules.MaxDescriptionLength + ? text.Substring(0, CivilianRules.MaxDescriptionLength - 1).TrimEnd() + "…" + : text; + } + + private static string Clean(string? input, bool multiline) + { + var builder = new StringBuilder(); + foreach (var c in (input ?? string.Empty).Replace("\r\n", "\n")) + { + if (c == '\n' && multiline) builder.Append(c); + else if (char.IsControl(c)) builder.Append(' '); + else builder.Append(c); + } + + var lines = builder.ToString().Split('\n').Select(line => string.Join(" ", line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries))); + return string.Join("\n", lines).Trim(); + } + } +} diff --git a/FivePRS.Core/Config/ConfigFiles.cs b/FivePRS.Core/Config/ConfigFiles.cs new file mode 100644 index 0000000..acb432c --- /dev/null +++ b/FivePRS.Core/Config/ConfigFiles.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using FivePRS.Core.Models; + +namespace FivePRS.Core.Config +{ + public static class ConfigFiles + { + public const string Settings = "config/settings.json"; + public const string Jurisdictions = "config/world/jurisdictions.json"; + public const string Stations = "config/world/stations.json"; + public const string Licenses = "config/civilian/licenses.json"; + public const string Citations = "config/civilian/citations.json"; + + public const string Ranks = "ranks"; + public const string Loadouts = "loadouts"; + public const string Vehicles = "vehicles"; + + public static string Department(Department department, string kind) => + $"config/departments/{ConfigManager.ConfigName(department)}/{kind}.json"; + + public static IEnumerable> LegacyLocations() + { + yield return Pair("config/jurisdictions.json", Jurisdictions); + yield return Pair("config/stations.json", Stations); + yield return Pair("config/licenses.json", Licenses); + yield return Pair("config/citations.json", Citations); + + foreach (var department in ConfigManager.Departments) + { + var name = ConfigManager.ConfigName(department); + yield return Pair($"config/{name}_loadouts.json", Department(department, Loadouts)); + yield return Pair($"config/{name}_vehicles.json", Department(department, Vehicles)); + } + } + + public static string? LegacyPathFor(string path) + { + foreach (var location in LegacyLocations()) + { + if (location.Value == path) return location.Key; + } + + return null; + } + + private static KeyValuePair Pair(string legacy, string current) => new(legacy, current); + } +} diff --git a/FivePRS.Core/Config/ConfigManager.cs b/FivePRS.Core/Config/ConfigManager.cs index 7a9cfc4..ab46849 100644 --- a/FivePRS.Core/Config/ConfigManager.cs +++ b/FivePRS.Core/Config/ConfigManager.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; using FivePRS.Core.Jurisdiction; +using FivePRS.Core.Logging; using FivePRS.Core.Models; using Newtonsoft.Json; @@ -8,6 +10,8 @@ namespace FivePRS.Core.Config { public static class ConfigManager { + private static readonly Logger Log = Logger.For("Config"); + public static ResourceSettings Settings { get; private set; } = new(); public static JurisdictionConfig Jurisdictions { get; private set; } = new(); public static LicensesConfig Licenses { get; private set; } = new(); @@ -15,12 +19,11 @@ public static class ConfigManager public static CitationsConfig Citations { get; private set; } = new(); public static TerritoryMap Territories { get; private set; } = new(new JurisdictionConfig()); - public static Action? Log { get; set; } - public static readonly Department[] Departments = { Department.Police, Department.Fire, Department.EMS }; private static readonly Dictionary DepartmentLoadouts = new(); private static readonly Dictionary DepartmentVehicles = new(); + private static readonly Dictionary DepartmentRanks = new(); public static string ConfigName(Department department) => department.ToString().ToLowerInvariant(); @@ -30,13 +33,65 @@ public static DepartmentLoadoutsConfig LoadoutsFor(Department department) => public static DepartmentVehiclesConfig VehiclesFor(Department department) => DepartmentVehicles.TryGetValue(department, out var config) ? config : DefaultVehicles(department); - public static void LoadDepartment(Department department, string? loadoutsJson, string? vehiclesJson) + public static RankLadder RanksFor(Department department) + { + if (department == Department.None) department = Department.Police; + if (DepartmentRanks.TryGetValue(department, out var ladder)) return ladder; + + ladder = new RankLadder(DefaultRanks(department)); + DepartmentRanks[department] = ladder; + return ladder; + } + + public static void LoadAll(Func readFile) + { + string? Read(string path) + { + var json = readFile(path); + if (!string.IsNullOrWhiteSpace(json)) return json; + + var legacy = ConfigFiles.LegacyPathFor(path); + return legacy is null ? json : readFile(legacy); + } + + LoadSettings(Read(ConfigFiles.Settings)); + foreach (var department in Departments) + { + LoadDepartment(department, + Read(ConfigFiles.Department(department, ConfigFiles.Ranks)), + Read(ConfigFiles.Department(department, ConfigFiles.Loadouts)), + Read(ConfigFiles.Department(department, ConfigFiles.Vehicles))); + } + + LoadJurisdictions(Read(ConfigFiles.Jurisdictions)); + LoadLicenses(Read(ConfigFiles.Licenses)); + LoadStations(Read(ConfigFiles.Stations)); + LoadCitations(Read(ConfigFiles.Citations)); + } + + public static void LoadDepartment(Department department, string? ranksJson, string? loadoutsJson, string? vehiclesJson) { var name = ConfigName(department); - DepartmentLoadouts[department] = Populate(loadoutsJson, $"{name}_loadouts.json", () => DefaultLoadouts(department)); - DepartmentVehicles[department] = Populate(vehiclesJson, $"{name}_vehicles.json", () => DefaultVehicles(department)); + DepartmentLoadouts[department] = Populate(loadoutsJson, $"{name}/loadouts.json", () => DefaultLoadouts(department)); + DepartmentVehicles[department] = Populate(vehiclesJson, $"{name}/vehicles.json", () => DefaultVehicles(department)); + + var ranks = Populate(ranksJson, $"{name}/ranks.json", () => DefaultRanks(department)); + if (!RankLadder.Valid(ranks).Any()) + { + Log.Warn($"{name}/ranks.json has no valid ranks, using defaults."); + ranks = DefaultRanks(department); + } + + DepartmentRanks[department] = new RankLadder(ranks); } + public static DepartmentRanksConfig DefaultRanks(Department department) => department switch + { + Department.Fire => DepartmentRanksConfig.Fire(), + Department.EMS => DepartmentRanksConfig.Ems(), + _ => DepartmentRanksConfig.Police(), + }; + private static DepartmentLoadoutsConfig DefaultLoadouts(Department department) => department switch { Department.Fire => DepartmentLoadoutsConfig.Fire(), @@ -55,7 +110,7 @@ private static T Populate(string? json, string fileName, Func defaults) wh { if (json is null || string.IsNullOrWhiteSpace(json)) { - Log?.Invoke($"[FivePRS:Config] {fileName} missing or empty, using defaults."); + Log.Warn($"{fileName} is missing or empty, using defaults."); return defaults(); } @@ -63,12 +118,12 @@ private static T Populate(string? json, string fileName, Func defaults) wh { var config = defaults(); JsonConvert.PopulateObject(json, config); - Log?.Invoke($"[FivePRS:Config] {fileName} loaded."); + Log.Debug($"{fileName} loaded."); return config; } catch (Exception ex) { - Log?.Invoke($"[FivePRS:Config] {fileName} parse error, using defaults. ({ex.Message})"); + Log.Error($"{fileName} has an error, using defaults", ex); return defaults(); } } @@ -88,19 +143,19 @@ private static T Load(string? json, string fileName, T fallback) where T : cl { if (json is null || string.IsNullOrWhiteSpace(json)) { - Log?.Invoke($"[FivePRS:Config] {fileName} missing or empty, using defaults."); + Log.Warn($"{fileName} is missing or empty, using defaults."); return fallback; } try { var result = JsonConvert.DeserializeObject(json) ?? fallback; - Log?.Invoke($"[FivePRS:Config] {fileName} loaded."); + Log.Debug($"{fileName} loaded."); return result; } catch (Exception ex) { - Log?.Invoke($"[FivePRS:Config] {fileName} parse error, using defaults. ({ex.Message})"); + Log.Error($"{fileName} has an error, using defaults", ex); return fallback; } } diff --git a/FivePRS.Core/Config/LicenseConfig.cs b/FivePRS.Core/Config/LicenseConfig.cs index eaa1364..10f448f 100644 --- a/FivePRS.Core/Config/LicenseConfig.cs +++ b/FivePRS.Core/Config/LicenseConfig.cs @@ -4,6 +4,24 @@ namespace FivePRS.Core.Config { + public sealed class LicenseCardDef + { + [JsonProperty("title")] + public string Title { get; set; } = string.Empty; + + [JsonProperty("issuer")] + public string Issuer { get; set; } = string.Empty; + + [JsonProperty("color")] + public string Color { get; set; } = "#1d3a6e"; + + [JsonProperty("accent")] + public string Accent { get; set; } = "#f2c94c"; + + [JsonProperty("image")] + public string Image { get; set; } = string.Empty; + } + public sealed class LicenseTypeDef { [JsonProperty("id")] @@ -14,23 +32,55 @@ public sealed class LicenseTypeDef [JsonProperty("selfService")] public bool SelfService { get; set; } + + [JsonProperty("class")] + public string Class { get; set; } = string.Empty; + + [JsonProperty("validDays")] + public int ValidDays { get; set; } = 1825; + + [JsonProperty("card")] + public LicenseCardDef Card { get; set; } = new(); + } + + public sealed class StateDef + { + [JsonProperty("name")] + public string Name { get; set; } = "San Andreas"; + + [JsonProperty("abbreviation")] + public string Abbreviation { get; set; } = "SA"; } public sealed class LicensesConfig { - [JsonProperty("types")] + [JsonProperty("state")] + public StateDef State { get; set; } = new(); + + [JsonProperty("types", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List Types { get; set; } = new() { - new LicenseTypeDef { Id = "driver", Name = "Driver's License", SelfService = true }, - new LicenseTypeDef { Id = "motorcycle", Name = "Motorcycle License", SelfService = true }, - new LicenseTypeDef { Id = "commercial", Name = "Commercial Driver's License", SelfService = false }, - new LicenseTypeDef { Id = "weapon", Name = "Weapon Permit", SelfService = false }, - new LicenseTypeDef { Id = "pilot", Name = "Pilot License", SelfService = false }, - new LicenseTypeDef { Id = "boat", Name = "Boating License", SelfService = true }, - new LicenseTypeDef { Id = "hunting", Name = "Hunting License", SelfService = true }, + Type("driver", "Driver's License", true, "C", 1825, "DRIVER LICENSE", "Department of Motor Vehicles", "#1d3a6e", "#f2c94c"), + Type("motorcycle", "Motorcycle License", true, "M", 1825, "MOTORCYCLE LICENSE", "Department of Motor Vehicles", "#1d3a6e", "#f2c94c"), + Type("commercial", "Commercial Driver's License", false, "A", 1460, "COMMERCIAL DRIVER LICENSE", "Department of Motor Vehicles", "#3b2a6e", "#f2c94c"), + Type("weapon", "Weapon Permit", false, "", 1095, "WEAPON PERMIT", "Department of Justice", "#7f1d1d", "#f8d7a8"), + Type("pilot", "Pilot License", false, "", 1825, "PILOT CERTIFICATE", "Aviation Administration", "#0f4c5c", "#9be7f0"), + Type("boat", "Boating License", true, "", 1825, "BOATING LICENSE", "Fish and Wildlife", "#0e5a8a", "#bfe3ff"), + Type("hunting", "Hunting License", true, "", 365, "HUNTING LICENSE", "Fish and Wildlife", "#3f6b2a", "#e9f5c9"), }; public LicenseTypeDef? Find(string? id) => Types.FirstOrDefault(t => string.Equals(t.Id, id, System.StringComparison.OrdinalIgnoreCase)); + + private static LicenseTypeDef Type(string id, string name, bool selfService, string licenseClass, int validDays, + string title, string issuer, string color, string accent) => new() + { + Id = id, + Name = name, + SelfService = selfService, + Class = licenseClass, + ValidDays = validDays, + Card = new LicenseCardDef { Title = title, Issuer = issuer, Color = color, Accent = accent }, + }; } } diff --git a/FivePRS.Core/Config/LoadoutConfig.cs b/FivePRS.Core/Config/LoadoutConfig.cs index 5abc090..775abbf 100644 --- a/FivePRS.Core/Config/LoadoutConfig.cs +++ b/FivePRS.Core/Config/LoadoutConfig.cs @@ -205,19 +205,6 @@ private static DepartmentLoadoutsConfig Uniformed(string pedModel, WeaponTierDef }; } - public static LoadoutTier TierForRank(int rank) => - rank >= 8 ? LoadoutTier.Command : - rank >= 5 ? LoadoutTier.Senior : - rank >= 3 ? LoadoutTier.Officer : LoadoutTier.Recruit; - - public static int MinRankFor(LoadoutTier tier) => tier switch - { - LoadoutTier.Command => 8, - LoadoutTier.Senior => 5, - LoadoutTier.Officer => 3, - _ => 1, - }; - public WeaponTierDef WeaponsFor(string? agencyId, LoadoutTier tier) { var agency = FindAgency(agencyId); diff --git a/FivePRS.Core/Config/PlayerRanks.cs b/FivePRS.Core/Config/PlayerRanks.cs new file mode 100644 index 0000000..c7e25b6 --- /dev/null +++ b/FivePRS.Core/Config/PlayerRanks.cs @@ -0,0 +1,11 @@ +using FivePRS.Core.Models; + +namespace FivePRS.Core.Config +{ + public static class PlayerRanks + { + public static RankLadder Ladder(this PlayerData player) => ConfigManager.RanksFor(player.Department); + + public static string RankName(this PlayerData player) => player.Ladder().NameFor(player.Rank, player.Agency); + } +} diff --git a/FivePRS.Core/Config/RankLadder.cs b/FivePRS.Core/Config/RankLadder.cs new file mode 100644 index 0000000..ca5dea3 --- /dev/null +++ b/FivePRS.Core/Config/RankLadder.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FivePRS.Core.Models; + +namespace FivePRS.Core.Config +{ + public sealed class RankLadder + { + public const int MaxRanks = 255; + + private readonly List _ranks; + private readonly Dictionary> _agencies; + + public RankLadder(DepartmentRanksConfig config) + { + _ranks = Valid(config).ToList(); + _agencies = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var agency in config.Agencies.Where(a => a.Value is not null)) + _agencies[agency.Key] = agency.Value; + } + + public int Count => _ranks.Count; + + public static IEnumerable Valid(DepartmentRanksConfig config) => + config.Ranks + .Where(rank => rank is not null && !string.IsNullOrWhiteSpace(rank.Name) && rank.Xp >= 0) + .OrderBy(rank => rank.Xp) + .Take(MaxRanks); + + public int Clamp(int rank) => Math.Max(1, Math.Min(Count, rank)); + + public string NameFor(int rank, string? agencyId = null) + { + var index = Clamp(rank) - 1; + if (agencyId is not null && _agencies.TryGetValue(agencyId, out var names) && + index < names.Count && !string.IsNullOrWhiteSpace(names[index])) + { + return names[index].Trim(); + } + + return _ranks[index].Name.Trim(); + } + + public LoadoutTier TierFor(int rank) => _ranks[Clamp(rank) - 1].Tier; + + public int XpToNext(int rank) + { + var index = Clamp(rank) - 1; + return index + 1 >= Count ? 0 : Math.Max(1, _ranks[index + 1].Xp - _ranks[index].Xp); + } + + public bool IsTop(int rank) => Clamp(rank) >= Count; + + public int MinRankFor(LoadoutTier tier) + { + for (var i = 0; i < _ranks.Count; i++) + { + if (_ranks[i].Tier >= tier) return i + 1; + } + + return Count + 1; + } + + public bool AddXp(PlayerData player, int amount) + { + player.Rank = Clamp(player.Rank); + player.XP = (int)Math.Min(int.MaxValue, (long)player.XP + Math.Max(0, amount)); + + var rankedUp = false; + while (!IsTop(player.Rank) && player.XP >= XpToNext(player.Rank)) + { + player.XP -= XpToNext(player.Rank); + player.Rank++; + rankedUp = true; + } + + return rankedUp; + } + } +} diff --git a/FivePRS.Core/Config/RanksConfig.cs b/FivePRS.Core/Config/RanksConfig.cs new file mode 100644 index 0000000..7b9a16f --- /dev/null +++ b/FivePRS.Core/Config/RanksConfig.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; + +namespace FivePRS.Core.Config +{ + public sealed class RankDef + { + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("xp")] + public int Xp { get; set; } + + [JsonProperty("tier")] + [JsonConverter(typeof(StringEnumConverter), typeof(CamelCaseNamingStrategy))] + public LoadoutTier Tier { get; set; } + } + + public sealed class DepartmentRanksConfig + { + private static readonly int[] DefaultXp = { 0, 100, 500, 1400, 3000, 5500, 9100, 14000, 20400, 28500 }; + + private static readonly LoadoutTier[] DefaultTiers = + { + LoadoutTier.Recruit, LoadoutTier.Recruit, LoadoutTier.Officer, LoadoutTier.Officer, LoadoutTier.Senior, + LoadoutTier.Senior, LoadoutTier.Senior, LoadoutTier.Command, LoadoutTier.Command, LoadoutTier.Command, + }; + + [JsonProperty("ranks", ObjectCreationHandling = ObjectCreationHandling.Replace)] + public List Ranks { get; set; } = new(); + + [JsonProperty("agencies", ObjectCreationHandling = ObjectCreationHandling.Replace)] + public Dictionary> Agencies { get; set; } = new(); + + public static DepartmentRanksConfig Police() => Ladder( + new[] { "Cadet", "Probationary Officer", "Officer", "Senior Officer", "Corporal", "Sergeant", "Staff Sergeant", "Lieutenant", "Captain", "Chief of Police" }, + new Dictionary> + { + ["bcso"] = new() { "Deputy Trainee", "Probationary Deputy", "Deputy", "Senior Deputy", "Corporal", "Sergeant", "Staff Sergeant", "Lieutenant", "Captain", "Sheriff" }, + }); + + public static DepartmentRanksConfig Fire() => Ladder( + new[] { "Recruit Firefighter", "Probationary Firefighter", "Firefighter", "Senior Firefighter", "Engineer", "Lieutenant", "Captain", "Battalion Chief", "Deputy Chief", "Fire Chief" }); + + public static DepartmentRanksConfig Ems() => Ladder( + new[] { "EMT Trainee", "EMT", "Advanced EMT", "Paramedic", "Senior Paramedic", "Field Supervisor", "Shift Supervisor", "Operations Manager", "Deputy Director", "EMS Director" }); + + private static DepartmentRanksConfig Ladder(string[] names, Dictionary>? agencies = null) + { + var config = new DepartmentRanksConfig { Agencies = agencies ?? new Dictionary>() }; + for (var i = 0; i < names.Length; i++) + config.Ranks.Add(new RankDef { Name = names[i], Xp = DefaultXp[i], Tier = DefaultTiers[i] }); + return config; + } + } +} diff --git a/FivePRS.Core/Events/EventNames.cs b/FivePRS.Core/Events/EventNames.cs index e0a902d..215fe83 100644 --- a/FivePRS.Core/Events/EventNames.cs +++ b/FivePRS.Core/Events/EventNames.cs @@ -16,6 +16,7 @@ public static class EventNames public const string ClientLookupResult = "FivePRS:Client:LookupResult"; public const string ClientLookupError = "FivePRS:Client:LookupError"; public const string ClientStopResult = "FivePRS:Client:StopResult"; + public const string ClientReports = "FivePRS:Client:Reports"; public const string ClientPlayerDowned = "FivePRS:Client:PlayerDowned"; public const string ClientDownedPlayers = "FivePRS:Client:DownedPlayers"; public const string ClientRevived = "FivePRS:Client:Revived"; @@ -26,6 +27,8 @@ public static class EventNames public const string ClientAppearance = "FivePRS:Client:Appearance"; public const string ClientPreferences = "FivePRS:Client:Preferences"; public const string ClientLastPosition = "FivePRS:Client:LastPosition"; + public const string ClientShowCard = "FivePRS:Client:ShowCard"; + public const string ClientAdminTeleport = "FivePRS:Client:AdminTeleport"; public const string ServerPlayerConnected = "FivePRS:Server:PlayerConnected"; public const string ServerToggleDuty = "FivePRS:Server:ToggleDuty"; @@ -59,12 +62,23 @@ public static class EventNames public const string ServerCallClear = "FivePRS:Server:CallClear"; public const string ServerAdminRequest = "FivePRS:Server:AdminRequest"; public const string ServerAdminRosterSet = "FivePRS:Server:AdminRosterSet"; + public const string ServerAdminAction = "FivePRS:Server:AdminAction"; + public const string ServerCharacterPhoto = "FivePRS:Server:CharacterPhoto"; + public const string ServerShowLicense = "FivePRS:Server:ShowLicense"; + public const string ServerSaveSettings = "FivePRS:Server:SaveSettings"; public const string ServerAppearanceSave = "FivePRS:Server:AppearanceSave"; public const string ServerSetWallpaper = "FivePRS:Server:SetWallpaper"; public const string ServerReportPosition = "FivePRS:Server:ReportPosition"; public const string ServerLastPositionRequest = "FivePRS:Server:LastPositionRequest"; public const string ServerStopIdentify = "FivePRS:Server:StopIdentify"; public const string ServerStopPlate = "FivePRS:Server:StopPlate"; + public const string ServerRunPlate = "FivePRS:Server:RunPlate"; + public const string ServerRunPerson = "FivePRS:Server:RunPerson"; + public const string ServerReportDraft = "FivePRS:Server:ReportDraft"; + public const string ServerReportsRequest = "FivePRS:Server:ReportsRequest"; + public const string ServerReportSave = "FivePRS:Server:ReportSave"; + public const string ServerReportFile = "FivePRS:Server:ReportFile"; + public const string ServerReportSearch = "FivePRS:Server:ReportSearch"; public const string ServerPlayerDowned = "FivePRS:Server:PlayerDowned"; public const string ServerPlayerRespawned = "FivePRS:Server:PlayerRespawned"; public const string ServerRevivePlayer = "FivePRS:Server:RevivePlayer"; diff --git a/FivePRS.Core/Logging/ConsoleSink.cs b/FivePRS.Core/Logging/ConsoleSink.cs new file mode 100644 index 0000000..9977530 --- /dev/null +++ b/FivePRS.Core/Logging/ConsoleSink.cs @@ -0,0 +1,46 @@ +using System; + +namespace FivePRS.Core.Logging +{ + public sealed class ConsoleSink : ILogSink + { + private const string Prefix = "^5[FivePRS]^7 "; + + private readonly Action _write; + private readonly string? _detailsHint; + private bool _hinted; + + public ConsoleSink(Action write, string? detailsHint = null) + { + _write = write; + _detailsHint = detailsHint; + } + + public void Write(LogEntry entry) + { + if (entry.Level == LogLevel.Debug && !Logger.DebugEnabled) return; + + _write($"{Prefix}{Color(entry.Level)}{entry.Text}^7"); + if (entry.Exception is null) return; + + if (Logger.DebugEnabled) + { + _write(entry.Exception.ToString()); + } + else if (!_hinted && _detailsHint is not null) + { + _hinted = true; + _write($"{Prefix}{_detailsHint}"); + } + } + + public static string Color(LogLevel level) => level switch + { + LogLevel.Debug => "^6", + LogLevel.Success => "^2", + LogLevel.Warning => "^3", + LogLevel.Error => "^1", + _ => "^7", + }; + } +} diff --git a/FivePRS.Core/Logging/ILogSink.cs b/FivePRS.Core/Logging/ILogSink.cs new file mode 100644 index 0000000..bb1ee87 --- /dev/null +++ b/FivePRS.Core/Logging/ILogSink.cs @@ -0,0 +1,7 @@ +namespace FivePRS.Core.Logging +{ + public interface ILogSink + { + void Write(LogEntry entry); + } +} diff --git a/FivePRS.Core/Logging/LogEntry.cs b/FivePRS.Core/Logging/LogEntry.cs new file mode 100644 index 0000000..28683f0 --- /dev/null +++ b/FivePRS.Core/Logging/LogEntry.cs @@ -0,0 +1,40 @@ +using System; +using System.Reflection; + +namespace FivePRS.Core.Logging +{ + public sealed class LogEntry + { + public LogEntry(DateTime time, LogLevel level, string source, string message, Exception? exception) + { + Time = time; + Level = level; + Source = source; + Message = message; + Exception = exception; + } + + public DateTime Time { get; } + + public LogLevel Level { get; } + + public string Source { get; } + + public string Message { get; } + + public Exception? Exception { get; } + + public string Text => Exception is null ? Message : $"{Message}: {Cause(Exception).Message}"; + + public static Exception Cause(Exception ex) + { + while (ex is AggregateException { InnerException: not null } || ex is TargetInvocationException { InnerException: not null } || + ex is TypeInitializationException { InnerException: not null }) + { + ex = ex.InnerException!; + } + + return ex; + } + } +} diff --git a/FivePRS.Core/Logging/LogLevel.cs b/FivePRS.Core/Logging/LogLevel.cs new file mode 100644 index 0000000..be55a4b --- /dev/null +++ b/FivePRS.Core/Logging/LogLevel.cs @@ -0,0 +1,11 @@ +namespace FivePRS.Core.Logging +{ + public enum LogLevel + { + Debug, + Info, + Success, + Warning, + Error, + } +} diff --git a/FivePRS.Core/Logging/Logger.cs b/FivePRS.Core/Logging/Logger.cs new file mode 100644 index 0000000..5722ab1 --- /dev/null +++ b/FivePRS.Core/Logging/Logger.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace FivePRS.Core.Logging +{ + public sealed class Logger + { + private static readonly object Gate = new(); + private static ILogSink[] _sinks = new ILogSink[0]; + + private readonly string _source; + + private Logger(string source) + { + _source = source; + } + + public static bool DebugEnabled { get; set; } + + public static Logger For(string source) => new(source); + + public static void AddSink(ILogSink sink) + { + lock (Gate) + { + var sinks = new List(_sinks) { sink }; + _sinks = sinks.ToArray(); + } + } + + public static void ClearSinks() + { + lock (Gate) _sinks = new ILogSink[0]; + } + + public void Debug(string message) => Write(LogLevel.Debug, message, null); + + public void Info(string message) => Write(LogLevel.Info, message, null); + + public void Success(string message) => Write(LogLevel.Success, message, null); + + public void Warn(string message, Exception? exception = null) => Write(LogLevel.Warning, message, exception); + + public void Error(string message, Exception? exception = null) => Write(LogLevel.Error, message, exception); + + private void Write(LogLevel level, string message, Exception? exception) + { + var entry = new LogEntry(DateTime.Now, level, _source, message, exception); + foreach (var sink in _sinks) + { + try + { + sink.Write(entry); + } + catch (Exception) + { + } + } + } + } +} diff --git a/FivePRS.Core/Models/Call.cs b/FivePRS.Core/Models/Call.cs index c90b0d5..b070e88 100644 --- a/FivePRS.Core/Models/Call.cs +++ b/FivePRS.Core/Models/Call.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace FivePRS.Core.Models @@ -36,10 +37,13 @@ public class CallInfo public sealed class EmergencyCallStatus { - public string CallId { get; set; } = string.Empty; - public Department Department { get; set; } - public string Description { get; set; } = string.Empty; - public int Responding { get; set; } + public string CallId { get; set; } = string.Empty; + public Department Department { get; set; } + public string Description { get; set; } = string.Empty; + public int Responding { get; set; } + public bool OnScene { get; set; } + public DateTime ReceivedAt { get; set; } + public List Units { get; set; } = new(); } public class DispatchSnapshot diff --git a/FivePRS.Core/Models/PlayerData.cs b/FivePRS.Core/Models/PlayerData.cs index 8911539..1e38d6e 100644 --- a/FivePRS.Core/Models/PlayerData.cs +++ b/FivePRS.Core/Models/PlayerData.cs @@ -4,30 +4,18 @@ namespace FivePRS.Core.Models { public class PlayerData { - public string License { get; set; } = string.Empty; - 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; - public DateTime LastSeen { get; set; } = DateTime.UtcNow; - - public int XPToNextRank => Rank * Rank * 100; - - public bool AddXP(int amount) - { - XP += Math.Max(0, amount); - - var rankedUp = false; - while (XP >= XPToNextRank) - { - XP -= XPToNextRank; - Rank++; - rankedUp = true; - } - return rankedUp; - } + public string License { get; set; } = string.Empty; + 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; + public int CallsCompleted { get; set; } + public int Arrests { get; set; } + public int DutySeconds { get; set; } + public DateTime? ShiftStartedAt { get; set; } + public DateTime LastSeen { get; set; } = DateTime.UtcNow; } } diff --git a/FivePRS.Core/Models/Roster.cs b/FivePRS.Core/Models/Roster.cs index ba99dfb..cad1434 100644 --- a/FivePRS.Core/Models/Roster.cs +++ b/FivePRS.Core/Models/Roster.cs @@ -8,6 +8,11 @@ public sealed class RosterPlayer public string License { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public int Rank { get; set; } + public string RankName { get; set; } = string.Empty; + public string Callsign { get; set; } = string.Empty; + public string Agency { get; set; } = string.Empty; + public bool OnDuty { get; set; } + public bool Down { get; set; } public Department Department { get; set; } public bool Online { get; set; } public int? ServerId { get; set; } @@ -21,5 +26,51 @@ public sealed class AdminState public string? Query { get; set; } public List Online { get; set; } = new(); public List Results { get; set; } = new(); + public List Calls { get; set; } = new(); + public List Audit { get; set; } = new(); + public List Server { get; set; } = new(); + public List Warnings { get; set; } = new(); + public List Addons { get; set; } = new(); + } + + public sealed class AdminAddon + { + public string Name { get; set; } = string.Empty; + public string Kind { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string Author { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public bool Official { get; set; } + } + + public sealed class AdminCall + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public int Code { get; set; } + public Department Department { get; set; } + public string? Territory { get; set; } + public List Units { get; set; } = new(); + public bool Emergency { get; set; } + } + + public sealed class AdminAudit + { + public DateTime CreatedAt { get; set; } + public string Action { get; set; } = string.Empty; + public string ActorName { get; set; } = string.Empty; + public string Details { get; set; } = string.Empty; + } + + public sealed class AdminFact + { + public AdminFact(string label, string value) + { + Label = label; + Value = value; + } + + public string Label { get; } + public string Value { get; } } } diff --git a/FivePRS.Core/Models/TerminalPreferences.cs b/FivePRS.Core/Models/TerminalPreferences.cs new file mode 100644 index 0000000..f5ae50d --- /dev/null +++ b/FivePRS.Core/Models/TerminalPreferences.cs @@ -0,0 +1,55 @@ +using System; +using Newtonsoft.Json; + +namespace FivePRS.Core.Models +{ + public sealed class TerminalPreferences + { + private static readonly string[] TextSizes = { "s", "m", "l" }; + + [JsonProperty("textSize")] + public string TextSize { get; set; } = "m"; + + [JsonProperty("clock24")] + public bool Clock24 { get; set; } + + [JsonProperty("reduceMotion")] + public bool ReduceMotion { get; set; } + + [JsonProperty("terminalVolume")] + public int TerminalVolume { get; set; } = 70; + + [JsonProperty("dispatchVolume")] + public int DispatchVolume { get; set; } = 90; + + [JsonProperty("holdChannel")] + public bool HoldChannel { get; set; } = true; + + public static TerminalPreferences Parse(string? json) + { + TerminalPreferences? settings = null; + if (json is not null && json.Length > 0 && json.Length <= 2000) + { + try + { + settings = JsonConvert.DeserializeObject(json); + } + catch (JsonException) + { + } + } + + return Sanitize(settings ?? new TerminalPreferences()); + } + + public static TerminalPreferences Sanitize(TerminalPreferences settings) + { + if (Array.IndexOf(TextSizes, settings.TextSize) < 0) settings.TextSize = "m"; + settings.TerminalVolume = Math.Max(0, Math.Min(100, settings.TerminalVolume)); + settings.DispatchVolume = Math.Max(0, Math.Min(100, settings.DispatchVolume)); + return settings; + } + + public string ToJson() => JsonConvert.SerializeObject(this); + } +} diff --git a/FivePRS.Core/Police/StreetProfiles.cs b/FivePRS.Core/Police/StreetProfiles.cs index e35822a..6891362 100644 --- a/FivePRS.Core/Police/StreetProfiles.cs +++ b/FivePRS.Core/Police/StreetProfiles.cs @@ -23,6 +23,7 @@ public sealed class StreetPerson public string? Warrant { get; set; } public string? Contraband { get; set; } public bool WillFlee { get; set; } + public string? Medical { get; set; } public string FullName => $"{FirstName} {LastName}"; } @@ -71,6 +72,13 @@ public static class StreetProfiles "Simple assault", "Possession of a controlled substance", "Driving while suspended", }; + private static readonly string[] Conditions = + { + "Type 1 diabetes, insulin dependent", "Severe penicillin allergy", "Asthma, carries an inhaler", + "Epilepsy, history of seizures", "Heart condition, takes blood thinners", "Severe peanut allergy, carries an EpiPen", + "High blood pressure", "COPD", "Previous heart attack", "Hemophilia", + }; + private static readonly string[] Contraband = { "A small bag of cannabis", "An open container of alcohol", "Drug paraphernalia", @@ -104,6 +112,7 @@ public static StreetPerson Person(int seed, bool female) if (rng.Next(100) < 7) person.Warrant = Warrants[rng.Next(Warrants.Length)]; if (rng.Next(100) < 15) person.Contraband = Contraband[rng.Next(Contraband.Length)]; person.WillFlee = person.Warrant is not null && rng.Next(100) < 35; + if (rng.Next(100) < 20) person.Medical = Conditions[rng.Next(Conditions.Length)]; return person; } diff --git a/FivePRS.EMS/Callouts/MedicalEmergencyCallout.cs b/FivePRS.EMS/Callouts/MedicalEmergencyCallout.cs index d4afa48..5c39d86 100644 --- a/FivePRS.EMS/Callouts/MedicalEmergencyCallout.cs +++ b/FivePRS.EMS/Callouts/MedicalEmergencyCallout.cs @@ -37,6 +37,8 @@ public MedicalEmergencyCallout() protected override string BlipName => "Medical emergency"; + protected override string Findings => "Patient found on the sidewalk, conscious and in distress."; + protected override Task SetupSceneAsync(CancellationToken ct) => SpawnPatientAsync(World.GetNextPositionOnSidewalk(_location), Rng.Next(360)); diff --git a/FivePRS.EMS/Callouts/PatientCallout.cs b/FivePRS.EMS/Callouts/PatientCallout.cs index c694180..e904105 100644 --- a/FivePRS.EMS/Callouts/PatientCallout.cs +++ b/FivePRS.EMS/Callouts/PatientCallout.cs @@ -4,6 +4,7 @@ using CitizenFX.Core; using FivePRS.Client; using FivePRS.Client.Callouts; +using FivePRS.Core.Civilian; namespace FivePRS.EMS.Callouts { @@ -48,7 +49,8 @@ public override async Task OnCalloutAccepted(CancellationToken ct) return; } - blip.Position = patient.Position; + var foundAt = patient.Position; + blip.Position = foundAt; blip.ShowRoute = false; _care = new PatientCare(patient); ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | Patient located. Treat them, then transport them to hospital."); @@ -66,6 +68,7 @@ public override async Task OnCalloutAccepted(CancellationToken ct) if (_care.Update()) { + DraftReport(patient, foundAt); CalloutCompleted(); return; } @@ -84,6 +87,23 @@ public override async Task OnCalloutAccepted(CancellationToken ct) } } + protected virtual string Findings => "Patient found conscious and in distress."; + + private void DraftReport(Ped patient, Vector3 foundAt) + { + var hospital = _care?.HospitalName ?? "hospital"; + Reports.Draft(new ReportDraft + { + Kind = ReportKind.Patient, + CallId = Data.Id, + Title = Data.Name, + Location = Reports.Describe(foundAt), + Narrative = $"Dispatched: {Data.Description}\n\n{Findings} Treated on scene, stabilized and loaded for transport.\n\nTransported to {hospital} and handed over to hospital staff.", + PatientSeed = StreetIdentity.SeedFor(patient), + PatientFemale = StreetIdentity.IsFemale(patient), + }); + } + protected async Task SpawnPatientAsync(Vector3 position, float heading) { var model = new Model(PatientModels[Rng.Next(PatientModels.Length)]); diff --git a/FivePRS.EMS/Callouts/TrafficCollisionCallout.cs b/FivePRS.EMS/Callouts/TrafficCollisionCallout.cs index ae1f6b5..d199698 100644 --- a/FivePRS.EMS/Callouts/TrafficCollisionCallout.cs +++ b/FivePRS.EMS/Callouts/TrafficCollisionCallout.cs @@ -31,6 +31,8 @@ public TrafficCollisionCallout() protected override string BlipName => "Traffic collision"; + protected override string Findings => "Patient found beside one of two damaged vehicles, conscious with injuries from the collision."; + protected override async Task SetupSceneAsync(CancellationToken ct) { var heading = (float)Rng.Next(360); diff --git a/FivePRS.EMS/PatientCare.cs b/FivePRS.EMS/PatientCare.cs index 83a9c59..78ac314 100644 --- a/FivePRS.EMS/PatientCare.cs +++ b/FivePRS.EMS/PatientCare.cs @@ -51,6 +51,8 @@ public PatientCare(Ped patient) public Ped Patient => _patient; + public string? HospitalName => _hospital?.Name; + public string? Prompt() { if (!_patient.Exists() || (Game.PlayerPed.IsInVehicle() && Stage != PatientStage.Loaded)) return null; diff --git a/FivePRS.Fire/Callouts/BrushFireCallout.cs b/FivePRS.Fire/Callouts/BrushFireCallout.cs index d87dea0..7b2ed7a 100644 --- a/FivePRS.Fire/Callouts/BrushFireCallout.cs +++ b/FivePRS.Fire/Callouts/BrushFireCallout.cs @@ -28,6 +28,7 @@ public sealed class BrushFireCallout : CalloutBase private readonly Vector3 _location; private FireScene? _scene; + private int _peak; public BrushFireCallout() { @@ -54,6 +55,8 @@ public override async Task OnCalloutAccepted(CancellationToken ct) if (!FireScene.TryGround(_location.X, _location.Y, Game.PlayerPed.Position.Z, out var center)) { ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | False alarm. The smoke was from a controlled burn."); + FireReport.Draft(Data, _location, + $"Dispatched: {Data.Description}" + NL + NL + "Investigated on arrival. The smoke came from a controlled burn. No action needed."); CalloutCompleted(); return; } @@ -79,10 +82,15 @@ public override async Task OnCalloutAccepted(CancellationToken ct) while (!ct.IsCancellationRequested) { await Timing.WaitAsync(1_000, ct); + _peak = Math.Max(_peak, _scene.ActiveFires); if (_scene.IsOut()) { ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | ~g~Fire contained.~w~ Good work."); + FireReport.Draft(Data, _scene.Center, + $"Dispatched: {Data.Description}" + NL + NL + + $"Found dry brush burning and spreading, with {FireReport.Fires(Math.Max(_peak, 1))} at the height of the fire." + NL + NL + + "All fires knocked down and the area checked for hot spots. No property damage reported."); CalloutCompleted(); return; } @@ -99,6 +107,8 @@ public override async Task OnCalloutAccepted(CancellationToken ct) } } + private static readonly string NL = Environment.NewLine; + protected override void OnCleanup() => _scene?.Extinguish(); public override void OnCalloutDeclined() => ClientBrain.ShowNotification("Brush fire declined.", "~r~Dispatch"); diff --git a/FivePRS.Fire/Callouts/VehicleFireCallout.cs b/FivePRS.Fire/Callouts/VehicleFireCallout.cs index 26a3cd4..a630f2d 100644 --- a/FivePRS.Fire/Callouts/VehicleFireCallout.cs +++ b/FivePRS.Fire/Callouts/VehicleFireCallout.cs @@ -33,6 +33,7 @@ public sealed class VehicleFireCallout : CalloutBase private FireScene? _scene; private Vehicle? _vehicle; + private int _peak; public VehicleFireCallout() { @@ -84,10 +85,18 @@ public override async Task OnCalloutAccepted(CancellationToken ct) while (!ct.IsCancellationRequested) { await Timing.WaitAsync(1_000, ct); + _peak = Math.Max(_peak, _scene.ActiveFires); if (_scene.IsOut(_vehicle)) { ClientBrain.ShowNotification($"~b~{Data.Name}~w~ | ~g~Fire out.~w~ Scene secured."); + var plate = _vehicle.Exists() ? StreetIdentity.PlateOf(_vehicle) : null; + var name = _vehicle.Exists() ? StreetIdentity.VehicleName(_vehicle) : "vehicle"; + FireReport.Draft(Data, _scene.Center, + $"Dispatched: {Data.Description}" + NL + NL + + $"Found a {name} at the roadside fully involved, with {FireReport.Fires(Math.Max(_peak, 1))} burning. No occupants." + NL + NL + + "Fire knocked down and extinguished. Vehicle left secured at the scene.", + plate); CalloutCompleted(); return; } @@ -104,6 +113,8 @@ public override async Task OnCalloutAccepted(CancellationToken ct) } } + private static readonly string NL = Environment.NewLine; + protected override void OnCleanup() => _scene?.Extinguish(); public override void OnCalloutDeclined() => ClientBrain.ShowNotification("Vehicle fire declined.", "~r~Dispatch"); diff --git a/FivePRS.Fire/FireReport.cs b/FivePRS.Fire/FireReport.cs new file mode 100644 index 0000000..c8ddeec --- /dev/null +++ b/FivePRS.Fire/FireReport.cs @@ -0,0 +1,23 @@ +using CitizenFX.Core; +using FivePRS.Client; +using FivePRS.Core.Civilian; +using FivePRS.Core.Models; + +namespace FivePRS.Fire +{ + internal static class FireReport + { + public static void Draft(CalloutData data, Vector3 position, string narrative, string? plate = null) => + Reports.Draft(new ReportDraft + { + Kind = ReportKind.Incident, + CallId = data.Id, + Title = data.Name, + Location = Reports.Describe(position), + Narrative = narrative, + Plate = plate, + }); + + public static string Fires(int count) => count == 1 ? "1 fire" : $"{count} fires"; + } +} diff --git a/FivePRS.Police/Callouts/ShopliftingCallout.cs b/FivePRS.Police/Callouts/ShopliftingCallout.cs index a4bf363..6dd89a6 100644 --- a/FivePRS.Police/Callouts/ShopliftingCallout.cs +++ b/FivePRS.Police/Callouts/ShopliftingCallout.cs @@ -4,6 +4,7 @@ using CitizenFX.Core; using FivePRS.Client; using FivePRS.Client.Callouts; +using FivePRS.Core.Logging; using FivePRS.Core.Models; namespace FivePRS.Police.Callouts @@ -73,7 +74,7 @@ public override async Task OnCalloutAccepted(CancellationToken ct) var model = new Model(_suspectModelName); if (!await model.Request(7_000)) { - Debug.WriteLine($"[ShopliftingCallout] Model '{_suspectModelName}' failed to load."); + Log.Warn($"Model '{_suspectModelName}' failed to load."); CalloutFailed(); return; } @@ -84,7 +85,7 @@ public override async Task OnCalloutAccepted(CancellationToken ct) if (_suspect is null || !_suspect.Exists()) { - Debug.WriteLine("[ShopliftingCallout] Ped creation failed."); + Log.Warn("Could not create the suspect."); CalloutFailed(); return; } diff --git a/FivePRS.Police/Callouts/TrafficStopCallout.cs b/FivePRS.Police/Callouts/TrafficStopCallout.cs index 3b98816..485bf2d 100644 --- a/FivePRS.Police/Callouts/TrafficStopCallout.cs +++ b/FivePRS.Police/Callouts/TrafficStopCallout.cs @@ -6,6 +6,7 @@ using FivePRS.Client; using FivePRS.Client.Callouts; using FivePRS.Client.Tasks; +using FivePRS.Core.Logging; using FivePRS.Core.Models; namespace FivePRS.Police.Callouts @@ -92,7 +93,7 @@ public override async Task OnCalloutAccepted(CancellationToken ct) var vehModel = new Model(_vehicleModel); if (!await vehModel.Request(7_000)) { - Debug.WriteLine($"[TrafficStopCallout] Vehicle model '{_vehicleModel}' failed."); + Log.Warn($"Vehicle model '{_vehicleModel}' failed to load."); CalloutFailed(); return; } @@ -102,7 +103,7 @@ public override async Task OnCalloutAccepted(CancellationToken ct) if (_suspectVehicle is null || !_suspectVehicle.Exists()) { - Debug.WriteLine("[TrafficStopCallout] Vehicle creation failed."); + Log.Warn("Could not create the vehicle."); CalloutFailed(); return; } @@ -112,7 +113,7 @@ public override async Task OnCalloutAccepted(CancellationToken ct) var pedModel = new Model(_driverModel); if (!await pedModel.Request(7_000)) { - Debug.WriteLine($"[TrafficStopCallout] Driver model '{_driverModel}' failed."); + Log.Warn($"Driver model '{_driverModel}' failed to load."); CalloutFailed(); return; } @@ -122,7 +123,7 @@ public override async Task OnCalloutAccepted(CancellationToken ct) if (_driver is null || !_driver.Exists()) { - Debug.WriteLine("[TrafficStopCallout] Driver creation failed."); + Log.Warn("Could not create the driver."); CalloutFailed(); return; } diff --git a/FivePRS.Police/Callouts/VehiclePursuitCallout.cs b/FivePRS.Police/Callouts/VehiclePursuitCallout.cs index 885fd74..d9c71cd 100644 --- a/FivePRS.Police/Callouts/VehiclePursuitCallout.cs +++ b/FivePRS.Police/Callouts/VehiclePursuitCallout.cs @@ -6,6 +6,7 @@ using FivePRS.Client; using FivePRS.Client.Callouts; using FivePRS.Client.Tasks; +using FivePRS.Core.Logging; using FivePRS.Core.Models; namespace FivePRS.Police.Callouts @@ -93,7 +94,7 @@ public override async Task OnCalloutAccepted(CancellationToken ct) var vehModel = new Model(_vehicleModel); if (!await vehModel.Request(7_000)) { - Debug.WriteLine($"[VehiclePursuitCallout] Model '{_vehicleModel}' failed."); + Log.Warn($"Vehicle model '{_vehicleModel}' failed to load."); CalloutFailed(); return; } @@ -107,7 +108,7 @@ public override async Task OnCalloutAccepted(CancellationToken ct) var pedModel = new Model(_driverModel); if (!await pedModel.Request(7_000)) { - Debug.WriteLine($"[VehiclePursuitCallout] Driver model '{_driverModel}' failed."); + Log.Warn($"Driver model '{_driverModel}' failed to load."); CalloutFailed(); return; } diff --git a/FivePRS.Police/TrafficStops/TrafficStop.cs b/FivePRS.Police/TrafficStops/TrafficStop.cs index 343a262..50f1a23 100644 --- a/FivePRS.Police/TrafficStops/TrafficStop.cs +++ b/FivePRS.Police/TrafficStops/TrafficStop.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Client; using FivePRS.Client.Arrest; using FivePRS.Core.Civilian; using FivePRS.Core.Police; @@ -39,8 +40,8 @@ public TrafficStop(Ped driver, Vehicle? vehicle, int seed) if (vehicle is not null) { - Plate = API.GetVehicleNumberPlateText(vehicle.Handle).Trim(); - Model = VehicleName(vehicle); + Plate = StreetIdentity.PlateOf(vehicle); + Model = StreetIdentity.VehicleName(vehicle); API.SetEntityAsMissionEntity(vehicle.Handle, true, true); } else @@ -192,12 +193,5 @@ private async Task ReleaseAsync() if (Driver.Exists() && DriverInVehicle) API.TaskVehicleDriveWander(Driver.Handle, Vehicle.Handle, 15f, NormalDrivingStyle); } - - private static string VehicleName(Vehicle vehicle) - { - var display = API.GetDisplayNameFromVehicleModel((uint)vehicle.Model.Hash); - var label = API.GetLabelText(display); - return string.IsNullOrEmpty(label) || label == "NULL" ? display : label; - } } } diff --git a/FivePRS.Police/TrafficStops/TrafficStopManager.cs b/FivePRS.Police/TrafficStops/TrafficStopManager.cs index 1016687..512015e 100644 --- a/FivePRS.Police/TrafficStops/TrafficStopManager.cs +++ b/FivePRS.Police/TrafficStops/TrafficStopManager.cs @@ -9,6 +9,7 @@ using FivePRS.Client.Arrest; using FivePRS.Client.Dispatch; using FivePRS.Client.Menu; +using FivePRS.Client.Terminal; using FivePRS.Core.Civilian; using FivePRS.Core.Config; using FivePRS.Core.Events; @@ -34,9 +35,6 @@ public class TrafficStopManager : BaseScript private const int ScanIntervalMs = 250; private const string Subject = "~b~Stop"; - private static readonly Random Rng = new(); - private static readonly Dictionary Seeds = new(); - private static TrafficStop? _stop; private static Vehicle? _vehicleCandidate; private static Ped? _pedestrianCandidate; @@ -54,9 +52,30 @@ public TrafficStopManager() }); Interactions.Register(TryInteract); + StreetIdentity.CurrentStop = DescribeStop; Tick += OnTick; } + private static StreetStop? DescribeStop() + { + var stop = _stop; + if (stop is null) return null; + + var identity = stop.Identity?.Character; + return new StreetStop + { + Kind = stop.IsVehicleStop ? "vehicle" : "person", + Plate = stop.Plate, + Model = stop.Model, + Name = identity is null ? null : $"{identity.FirstName} {identity.LastName}", + CharacterId = identity?.Id, + OwnerId = stop.Registration?.Character.Id, + PlateChecked = stop.Registration is not null || stop.RegistrationError is not null, + Checking = stop.AwaitingPlate, + RunPlate = stop.IsVehicleStop ? () => RequestPlate(stop) : null, + }; + } + private static bool OnDutyAsPolice => BaseAgency.Active?.Department == Department.Police; private async Task OnTick() @@ -135,13 +154,6 @@ private async Task OnTick() return best; } - private static int SeedFor(Ped ped) - { - if (!Seeds.TryGetValue(ped.Handle, out var seed)) - Seeds[ped.Handle] = seed = Rng.Next(); - return seed; - } - private static void StartVehicleStop() { if (_stop is not null || !OnDutyAsPolice) return; @@ -151,10 +163,11 @@ private static void StartVehicleStop() if (vehicle is null || driver is null || !driver.Exists()) return; _vehicleCandidate = null; - _stop = new TrafficStop(driver, vehicle, SeedFor(driver)); + _stop = new TrafficStop(driver, vehicle, StreetIdentity.SeedFor(driver)); _stop.PullOver(); ClientBrain.ShowNotification($"Pulling over the ~y~{_stop.Model}~w~, plate ~y~{_stop.Plate}~w~.", Subject); + TerminalApps.NotifyChanged(); } private static bool StartPedestrianStop(Ped ped) @@ -162,8 +175,9 @@ private static bool StartPedestrianStop(Ped ped) if (_stop is not null || !OnDutyAsPolice || !ped.Exists() || ped.IsDead) return false; _pedestrianCandidate = null; - var stop = new TrafficStop(ped, null, SeedFor(ped)); + var stop = new TrafficStop(ped, null, StreetIdentity.SeedFor(ped)); _stop = stop; + TerminalApps.NotifyChanged(); if (stop.Person.WillFlee) { @@ -468,6 +482,7 @@ private void OnStopResult(string kind, string json, string error) } CompactMenu.Refresh(); + TerminalApps.NotifyChanged(); } private void OnLookupError(string error) @@ -505,6 +520,7 @@ private static async Task EndAsync(bool release, string? message) if (stop is null) return; _stop = null; + TerminalApps.NotifyChanged(); if (CompactMenu.IsOpen) CompactMenu.Close(); if (message is not null) ClientBrain.ShowNotification(message, Subject); diff --git a/FivePRS.Server/Civilian/CivilianService.cs b/FivePRS.Server/Civilian/CivilianService.cs index 2d27dbb..7d69dc1 100644 --- a/FivePRS.Server/Civilian/CivilianService.cs +++ b/FivePRS.Server/Civilian/CivilianService.cs @@ -12,13 +12,16 @@ public sealed class CivilianService private const int MaxModelLength = 40; private readonly CivilianStore _store; + private readonly PhotoStore _photos; private readonly Func _settings; private readonly Func _licenses; private readonly Func _clock; - public CivilianService(CivilianStore store, Func settings, Func licenses, Func clock) + public CivilianService(CivilianStore store, PhotoStore photos, Func settings, Func licenses, + Func clock) { _store = store; + _photos = photos; _settings = settings; _licenses = licenses; _clock = clock; @@ -38,13 +41,11 @@ public async Task GetStateAsync(string owner) }; var held = active is null ? null : await _store.GetLicensesAsync(active.Id); - state.Licenses = _licenses().Types.Select(type => new LicenseView - { - Type = type.Id, - Name = type.Name, - SelfService = type.SelfService, - Status = held?.FirstOrDefault(l => string.Equals(l.Type, type.Id, StringComparison.OrdinalIgnoreCase))?.Status, - }).ToList(); + state.Licenses = LicenseViews.Build(active?.Id ?? 0, _licenses(), held); + + var photos = await _photos.GetManyAsync(characters.Select(c => c.Id)); + foreach (var character in characters) + character.Photo = photos.TryGetValue(character.Id, out var photo) ? photo : null; if (active is not null) { @@ -103,6 +104,44 @@ await _store.CreateCharacterAsync(new CharacterInfo if (summary?.HasActiveWarrant == true) return "A character with an active warrant can't be deleted."; await _store.DeleteCharacterAsync(characterId); + await _photos.DeleteAsync(characterId); + return null; + } + + public async Task GetLicenseCardAsync(string owner, string? type) + { + var character = await GetActiveCharacterAsync(owner); + var config = _licenses(); + var definition = config.Find(type); + if (character is null || definition is null) return null; + + var held = await _store.GetLicensesAsync(character.Id); + var view = LicenseViews.Build(character.Id, config, held).FirstOrDefault(l => l.Type == definition.Id); + if (view?.Status is null) return null; + + return new LicenseCard + { + FirstName = character.FirstName, + LastName = character.LastName, + DateOfBirth = character.DateOfBirth, + Gender = character.Gender, + StateId = CivilianRules.StateId(config.State.Abbreviation, character.Id), + Photo = await _photos.GetAsync(character.Id), + State = config.State, + License = view, + }; + } + + public async Task SetPhotoAsync(string owner, int characterId, string? photo) + { + if (await GetOwnedCharacterAsync(owner, characterId) is null) return "Character not found."; + if (!CivilianRules.TryNormalizePhoto(photo, out var normalized)) + return "That photo can't be used. Take a mugshot, or use an https image link."; + + if (normalized.Length == 0) + await _photos.DeleteAsync(characterId); + else + await _photos.SetAsync(characterId, normalized); return null; } diff --git a/FivePRS.Server/Civilian/LookupService.cs b/FivePRS.Server/Civilian/LookupService.cs index 9731272..7d4e987 100644 --- a/FivePRS.Server/Civilian/LookupService.cs +++ b/FivePRS.Server/Civilian/LookupService.cs @@ -12,12 +12,14 @@ public sealed class LookupService private const int SearchLimit = 15; private readonly CivilianStore _store; + private readonly PhotoStore _photos; private readonly Func _settings; private readonly Func _licenses; - public LookupService(CivilianStore store, Func settings, Func licenses) + public LookupService(CivilianStore store, PhotoStore photos, Func settings, Func licenses) { _store = store; + _photos = photos; _settings = settings; _licenses = licenses; } @@ -55,6 +57,7 @@ public LookupService(CivilianStore store, Func settings, Func< public async Task IssueRecordAsync(OfficerInfo officer, int characterId, int type, string? description, int fine) { if (!Enum.IsDefined(typeof(RecordType), type)) return "Unknown record type."; + if (!RecordAccess.CanIssue(officer.Department, (RecordType)type)) return "Your department can't add that kind of entry."; if (await _store.GetCharacterAsync(characterId) is null) return "Person not found."; if (!CivilianRules.TryNormalizeDescription(description, out var text)) @@ -121,17 +124,12 @@ await _store.AddRecordAsync(new RecordInfo if (summary is null) return null; var held = await _store.GetLicensesAsync(characterId); + summary.Photo = await _photos.GetAsync(characterId); return new LookupRecord { Character = summary, - Licenses = _licenses().Types.Select(type => new LicenseView - { - Type = type.Id, - Name = type.Name, - SelfService = type.SelfService, - Status = held.FirstOrDefault(l => string.Equals(l.Type, type.Id, StringComparison.OrdinalIgnoreCase))?.Status, - }).ToList(), + Licenses = LicenseViews.Build(characterId, _licenses(), held), Vehicles = await _store.GetVehiclesAsync(characterId), Records = await _store.GetRecordsAsync(characterId), }; diff --git a/FivePRS.Server/Civilian/ReportService.cs b/FivePRS.Server/Civilian/ReportService.cs new file mode 100644 index 0000000..f968f86 --- /dev/null +++ b/FivePRS.Server/Civilian/ReportService.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Models; +using FivePRS.Server.Database; + +namespace FivePRS.Server.Civilian +{ + public sealed class CrewMember + { + public CrewMember(string license, string callsign) + { + License = license; + Callsign = callsign; + } + + public string License { get; } + public string Callsign { get; } + } + + public sealed class ReportService + { + public const string AutoFiled = "Filed automatically"; + + private const int SearchLimit = 25; + private const string DefaultNarrative = "No narrative was written for this report."; + + private static readonly ReportKind[] AllKinds = { ReportKind.Patient, ReportKind.Incident }; + + private readonly ReportStore _reports; + private readonly CivilianStore _store; + private readonly StreetService _street; + + public ReportService(ReportStore reports, CivilianStore store, StreetService street) + { + _reports = reports; + _store = store; + _street = street; + } + + public static bool CanWrite(Department department, ReportKind kind) => kind switch + { + ReportKind.Patient => department == Department.EMS || department == Department.Fire, + ReportKind.Incident => department == Department.Fire, + _ => false, + }; + + public static bool CanRead(Department department) => department == Department.EMS || department == Department.Fire; + + public async Task<(ReportInfo? Report, string? Error)> DraftAsync(OfficerInfo author, ReportDraft draft, IReadOnlyList crew) + { + if (!Enum.IsDefined(typeof(ReportKind), draft.Kind) || !CanWrite(author.Department, draft.Kind)) return (null, "Your department doesn't write that kind of report."); + if (crew.Count == 0) return (null, "You weren't on that call."); + + int? characterId = null; + if (draft.Kind == ReportKind.Patient && draft.PatientSeed is { } seed) + { + var (identity, _) = await _street.IdentifyAsync(seed, draft.PatientFemale); + characterId = identity?.Record?.Character.Id; + } + + var report = new ReportInfo + { + Kind = draft.Kind, + Department = author.Department, + CallId = draft.CallId, + Title = ReportRules.NormalizeLine(draft.Title, ReportRules.MaxTitleLength, draft.Kind == ReportKind.Patient ? "Patient care" : "Incident"), + Location = ReportRules.NormalizeLine(draft.Location, ReportRules.MaxLocationLength, "Unknown location"), + Narrative = ReportRules.TryNormalizeNarrative(draft.Narrative, out var narrative) ? narrative : DefaultNarrative, + CharacterId = characterId, + Plate = CivilianRules.TryNormalizePlate(draft.Plate, out var plate) ? plate : null, + Crew = string.Join(", ", crew.Select(member => member.Callsign).Where(callsign => callsign.Length > 0)), + AuthorName = author.Name, + AuthorCallsign = author.Callsign, + }; + + report.Id = await _reports.AddAsync(report, author.License, crew.Select(member => member.License)); + return (await _reports.GetAsync(report.Id), null); + } + + public Task> DraftsForAsync(string license) => _reports.GetDraftsForAsync(license); + + public Task> CrewOfAsync(int reportId) => _reports.CrewLicensesAsync(reportId); + + public async Task SaveAsync(OfficerInfo editor, int reportId, string? narrative) + { + var (report, error) = await EditableAsync(editor, reportId); + if (report is null) return error; + if (!ReportRules.TryNormalizeNarrative(narrative, out var text)) return NarrativeError; + + await _reports.SaveNarrativeAsync(reportId, text); + return null; + } + + public async Task<(ReportInfo? Report, string? Error)> FileAsync(OfficerInfo filer, int reportId, string? narrative) + { + var (report, error) = await EditableAsync(filer, reportId); + if (report is null) return (null, error); + if (!ReportRules.TryNormalizeNarrative(narrative, out var text)) return (null, NarrativeError); + + report.Narrative = text; + await FileAsync(report, filer.License, filer.Name, filer.Callsign, $"{filer.Name} ({filer.Callsign})"); + return (await _reports.GetAsync(reportId), null); + } + + public async Task> FileStaleAsync(DateTime now) + { + var stale = await _reports.GetStaleDraftsAsync(now.AddMinutes(-ReportRules.AutoFileMinutes)); + foreach (var report in stale) + { + var license = (await _reports.CrewLicensesAsync(report.Id)).FirstOrDefault() ?? string.Empty; + await FileAsync(report, license, report.AuthorName, report.AuthorCallsign, AutoFiled); + } + + return stale; + } + + public async Task<(ReportsView? View, string? Error)> SearchAsync(OfficerInfo reader, string? term, string? date) + { + if (!CanRead(reader.Department)) return (null, "Reports are for Fire and EMS."); + if (!ReportRules.TryParseDate(date, out var day)) return (null, "Dates look like 2026-10-13."); + + var query = ReportRules.NormalizeSearch(term); + var results = await _reports.SearchAsync(query, day, AllKinds, SearchLimit); + return (new ReportsView + { + Drafts = await _reports.GetDraftsForAsync(reader.License), + Results = results, + Query = query, + Date = day?.ToString("yyyy-MM-dd"), + }, null); + } + + private static string NarrativeError => $"Write a narrative of up to {ReportRules.MaxNarrativeLength} characters."; + + private async Task<(ReportInfo? Report, string? Error)> EditableAsync(OfficerInfo editor, int reportId) + { + var report = await _reports.GetAsync(reportId); + if (report is null || !await _reports.IsCrewAsync(reportId, editor.License)) return (null, "Report not found."); + if (report.Status != ReportStatus.Draft) return (null, "That report has already been filed."); + return (report, null); + } + + private async Task FileAsync(ReportInfo report, string license, string name, string callsign, string filedBy) + { + await _reports.FileAsync(report.Id, report.Narrative, filedBy); + + if (report.Kind != ReportKind.Patient || report.CharacterId is not { } characterId) return; + if (await _store.GetCharacterAsync(characterId) is null) return; + + await _store.AddRecordAsync(new RecordInfo + { + CharacterId = characterId, + Type = RecordType.Medical, + Description = ReportRules.MedicalSummary(report), + OfficerName = name, + OfficerCallsign = callsign, + }, license); + } + } +} diff --git a/FivePRS.Server/Civilian/StreetService.cs b/FivePRS.Server/Civilian/StreetService.cs index e370cf8..ffbfcc5 100644 --- a/FivePRS.Server/Civilian/StreetService.cs +++ b/FivePRS.Server/Civilian/StreetService.cs @@ -11,6 +11,8 @@ public sealed class StreetService private const int MaxModelLength = 40; private const string DispatchName = "Dispatch"; private const string DispatchCallsign = "DISPATCH"; + private const string HospitalName = "Pillbox Hill Medical Center"; + private const string HospitalCallsign = "PILLBOX"; private readonly CivilianStore _store; private readonly LookupService _lookup; @@ -27,7 +29,10 @@ public StreetService(CivilianStore store, LookupService lookup) return await _lookup.GetRecordAsync(id); } - public async Task<(LookupResult? Result, string? Error)> PlateAsync(string? plate, string? model, int driverSeed, bool driverFemale) + public Task<(LookupResult? Result, string? Error)> PlateAsync(string? plate, string? model, int driverSeed, bool driverFemale) => + PlateAsync(plate, model, StreetProfiles.Person(driverSeed, driverFemale)); + + public async Task<(LookupResult? Result, string? Error)> PlateAsync(string? plate, string? model, StreetPerson? driver) { if (!CivilianRules.TryNormalizePlate(plate, out var normalized)) return (null, "That plate can't be read."); @@ -36,8 +41,8 @@ public StreetService(CivilianStore store, LookupService lookup) var vehicle = StreetProfiles.Vehicle(normalized); if (!vehicle.Registered) return (null, $"No registration found for {normalized}."); - var owner = vehicle.DriverIsOwner - ? StreetProfiles.Person(driverSeed, driverFemale) + var owner = vehicle.DriverIsOwner && driver is not null + ? driver : StreetProfiles.Person(vehicle.OwnerSeed, vehicle.OwnerFemale); await _store.AddVehicleAsync(new VehicleInfo @@ -83,6 +88,18 @@ await _store.AddRecordAsync(new RecordInfo }, StreetProfiles.NpcOwner); } + if (person.Medical is not null) + { + await _store.AddRecordAsync(new RecordInfo + { + CharacterId = id, + Type = RecordType.Medical, + Description = person.Medical, + OfficerName = HospitalName, + OfficerCallsign = HospitalCallsign, + }, StreetProfiles.NpcOwner); + } + return id; } diff --git a/FivePRS.Server/CommandResult.cs b/FivePRS.Server/CommandResult.cs new file mode 100644 index 0000000..875e994 --- /dev/null +++ b/FivePRS.Server/CommandResult.cs @@ -0,0 +1,19 @@ +namespace FivePRS.Server +{ + internal readonly struct CommandResult + { + private CommandResult(bool succeeded, string message) + { + Succeeded = succeeded; + Message = message; + } + + public bool Succeeded { get; } + + public string Message { get; } + + public static CommandResult Done(string message) => new(true, message); + + public static CommandResult Failed(string message) => new(false, message); + } +} diff --git a/FivePRS.Server/ConfigLayout.cs b/FivePRS.Server/ConfigLayout.cs new file mode 100644 index 0000000..3113cee --- /dev/null +++ b/FivePRS.Server/ConfigLayout.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using FivePRS.Core.Config; +using FivePRS.Core.Logging; +using Newtonsoft.Json; + +namespace FivePRS.Server +{ + public static class ConfigLayout + { + private static readonly Logger Log = Logger.For("Config"); + + public static void Update(string resourcePath) + { + try + { + var moved = MoveLegacyFiles(resourcePath); + var created = CreateMissingRanks(resourcePath); + + if (moved > 0) + Log.Info($"Moved {moved} config files into config/departments, config/world and config/civilian."); + if (created > 0) + Log.Info($"Added ranks.json for {created} departments. Edit it to rename ranks or change promotion XP."); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + Log.Warn("Could not update the config folder layout", ex); + } + } + + public static int MoveLegacyFiles(string resourcePath) + { + var moved = 0; + foreach (var location in ConfigFiles.LegacyLocations()) + { + var from = Resolve(resourcePath, location.Key); + if (!File.Exists(from)) continue; + + var to = Resolve(resourcePath, location.Value); + if (File.Exists(to)) + { + Log.Warn($"{location.Key} is ignored because {location.Value} exists. Delete the old file."); + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(to)!); + File.Move(from, to); + moved++; + } + + return moved; + } + + public static int CreateMissingRanks(string resourcePath) + { + var created = 0; + foreach (var department in ConfigManager.Departments) + { + var path = Resolve(resourcePath, ConfigFiles.Department(department, ConfigFiles.Ranks)); + if (File.Exists(path)) continue; + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, Serialize(ConfigManager.DefaultRanks(department))); + created++; + } + + return created; + } + + private static string Serialize(object value) + { + using var text = new StringWriter(); + using var writer = new JsonTextWriter(text) { Formatting = Formatting.Indented, Indentation = 4 }; + JsonSerializer.CreateDefault().Serialize(writer, value); + return text + Environment.NewLine; + } + + private static string Resolve(string resourcePath, string relative) => + Path.Combine(resourcePath, relative.Replace('/', Path.DirectorySeparatorChar)); + } +} diff --git a/FivePRS.Server/Database/AuditEntry.cs b/FivePRS.Server/Database/AuditEntry.cs index 833cd06..607e4a4 100644 --- a/FivePRS.Server/Database/AuditEntry.cs +++ b/FivePRS.Server/Database/AuditEntry.cs @@ -1,3 +1,5 @@ +using System; + namespace FivePRS.Server.Database { public static class AuditActions @@ -20,6 +22,7 @@ public static class AuditActions public const string VehicleRegistered = "civ.vehicle.register"; public const string VehicleRemoved = "civ.vehicle.remove"; public const string RecordIssued = "records.issue"; + public const string ReportFiled = "records.report.file"; public const string WarrantResolved = "records.warrant.resolve"; public const string LicenseStatusSet = "records.license"; public const string VehicleFlagged = "records.vehicle"; @@ -29,6 +32,8 @@ public static class AuditActions public const string RosterRevoked = "roster.revoke"; public const string PlayerRevived = "player.revive"; public const string BackupRequested = "backup.request"; + public const string AdminTeleport = "admin.teleport"; + public const string PhotoSet = "civ.photo.set"; } public sealed class AuditEntry @@ -39,4 +44,14 @@ public sealed class AuditEntry public string? TargetLicense { get; set; } public string Details { get; set; } = string.Empty; } + + public sealed class AuditRecord + { + public long Id { get; set; } + public DateTime CreatedAt { get; set; } + public string Action { get; set; } = string.Empty; + public string ActorName { get; set; } = string.Empty; + public string? TargetLicense { get; set; } + public string Details { get; set; } = string.Empty; + } } diff --git a/FivePRS.Server/Database/CivilianStore.cs b/FivePRS.Server/Database/CivilianStore.cs index 3d6d85e..1b486e3 100644 --- a/FivePRS.Server/Database/CivilianStore.cs +++ b/FivePRS.Server/Database/CivilianStore.cs @@ -59,6 +59,7 @@ public Task TouchCharacterAsync(int id) => public Task DeleteCharacterAsync(int id) => BatchAsync( + new SqlStatement("UPDATE fiveprs_reports SET character_id = NULL WHERE character_id = @id", ("@id", id)), new SqlStatement("DELETE FROM fiveprs_records WHERE character_id = @id", ("@id", id)), new SqlStatement("DELETE FROM fiveprs_licenses WHERE character_id = @id", ("@id", id)), new SqlStatement("DELETE FROM fiveprs_vehicles WHERE character_id = @id", ("@id", id)), diff --git a/FivePRS.Server/Database/DatabaseManager.cs b/FivePRS.Server/Database/DatabaseManager.cs index 941051a..9f30f76 100644 --- a/FivePRS.Server/Database/DatabaseManager.cs +++ b/FivePRS.Server/Database/DatabaseManager.cs @@ -1,8 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; -using CitizenFX.Core; -using CitizenFX.Core.Native; +using FivePRS.Core.Logging; using FivePRS.Core.Models; namespace FivePRS.Server.Database @@ -15,6 +14,8 @@ public enum DatabaseType public sealed class DatabaseManager { + private static readonly Logger Log = Logger.For("Database"); + private IDatabaseProvider _provider = null!; public bool IsReady { get; private set; } @@ -29,6 +30,9 @@ public sealed class DatabaseManager public PreferencesStore Preferences { get; private set; } = null!; + public PhotoStore Photos { get; private set; } = null!; + public ReportStore Reports { get; private set; } = null!; + public PositionStore Positions { get; private set; } = null!; public int SchemaVersion { get; private set; } @@ -43,7 +47,7 @@ private async Task InitializeCoreAsync(DatabaseType dbType, string? connectionSt if (dbType == DatabaseType.MySQL) { if (connectionString is not null && !string.IsNullOrWhiteSpace(connectionString)) - Debug.WriteLine("[FivePRS] WARNING: fiveprs_db_connection is ignored with MySQL. FivePRS now connects through oxmysql, which reads mysql_connection_string. Set it as mysql://user:password@host:port/database."); + Log.Warn("fiveprs_db_connection is ignored with MySQL. Set mysql_connection_string for oxmysql instead."); _provider = new OxMySqlProvider(new OxMySqlBridge()); } else @@ -55,7 +59,7 @@ private async Task InitializeCoreAsync(DatabaseType dbType, string? connectionSt var resourcePath = Resource.Path; var nativePath = SqliteNativeLoader.Load(Path.Combine(resourcePath, "server")); - Debug.WriteLine($"[FivePRS] Native SQLite loaded from {nativePath}."); + Log.Debug($"Native SQLite loaded from {nativePath}."); var dbPath = connectionString ?? Path.Combine(resourcePath, "data", "fiveprs.db"); var sqlite = new SQLiteProvider(dbPath); @@ -68,10 +72,18 @@ private async Task InitializeCoreAsync(DatabaseType dbType, string? connectionSt await _provider.InitializeAsync(); var result = await new Migrator(_provider).MigrateAsync(backup); - foreach (var migration in result.Applied) - Debug.WriteLine($"[FivePRS] Applied database migration {migration.Version}: {migration.Name}."); + if (result.FromVersion == 0 && result.Applied.Count > 0) + { + Log.Info($"Created a new database (schema {result.ToVersion})."); + } + else + { + foreach (var migration in result.Applied) + Log.Info($"Database updated to schema {migration.Version} ({migration.Name})."); + } + if (result.DatabaseIsNewer) - Debug.WriteLine($"[FivePRS] WARNING: The database is at schema version {result.FromVersion}, but this FivePRS build only knows up to {result.LatestVersion}. Update FivePRS, or restore the database backup taken before you downgraded."); + Log.Warn($"The database is at schema {result.FromVersion}, newer than this build supports ({result.LatestVersion}). Update FivePRS or restore your backup."); SchemaVersion = result.ToVersion; Players = new PlayerStore(_provider); @@ -79,11 +91,13 @@ private async Task InitializeCoreAsync(DatabaseType dbType, string? connectionSt Roster = new RosterStore(_provider); Appearances = new AppearanceStore(_provider); Preferences = new PreferencesStore(_provider); + Photos = new PhotoStore(_provider); + Reports = new ReportStore(_provider); Positions = new PositionStore(_provider); IsReady = true; - Debug.WriteLine($"[FivePRS] Database ({dbType}) ready."); + Log.Debug($"Database ({dbType}) ready."); } private static async Task BackupAsync(SQLiteProvider sqlite, string dbPath, int version) @@ -91,7 +105,7 @@ private static async Task BackupAsync(SQLiteProvider sqlite, string dbPath, int var directory = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(dbPath)) ?? ".", "backups"); var path = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(dbPath)}-schema{version}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.db"); await sqlite.BackupAsync(path); - Debug.WriteLine($"[FivePRS] Backed up the database to {path} before updating it."); + Log.Info($"Database backed up to backups/{Path.GetFileName(path)}."); } public Task GetPlayerAsync(string license) => Players.GetAsync(license); diff --git a/FivePRS.Server/Database/Migrations.cs b/FivePRS.Server/Database/Migrations.cs index 02f31b1..bfdffd6 100644 --- a/FivePRS.Server/Database/Migrations.cs +++ b/FivePRS.Server/Database/Migrations.cs @@ -184,6 +184,66 @@ PRIMARY KEY (`license`) `updated_at` DATETIME(6) NOT NULL, PRIMARY KEY (`license`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci")), + new Migration(2, "Photos, service stats and terminal settings", + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_photos ( + character_id INTEGER PRIMARY KEY, + data TEXT NOT NULL, + updated_at TEXT NOT NULL)", + @"CREATE TABLE IF NOT EXISTS `fiveprs_photos` ( + `character_id` INT UNSIGNED NOT NULL, + `data` MEDIUMTEXT NOT NULL, + `updated_at` DATETIME(6) NOT NULL, + PRIMARY KEY (`character_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"), + new AddColumnStep("ers_players", "calls_completed", "INTEGER NOT NULL DEFAULT 0", "INT UNSIGNED NOT NULL DEFAULT 0"), + new AddColumnStep("ers_players", "arrests", "INTEGER NOT NULL DEFAULT 0", "INT UNSIGNED NOT NULL DEFAULT 0"), + new AddColumnStep("ers_players", "duty_seconds", "INTEGER NOT NULL DEFAULT 0", "INT UNSIGNED NOT NULL DEFAULT 0"), + new AddColumnStep("fiveprs_preferences", "settings", "TEXT NOT NULL DEFAULT '{}'", "VARCHAR(2000) NOT NULL DEFAULT '{}'")), + new Migration(3, "Patient and incident reports", + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind INTEGER NOT NULL, + department INTEGER NOT NULL, + call_id TEXT NOT NULL, + status INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL, + location TEXT NOT NULL, + narrative TEXT NOT NULL, + character_id INTEGER NULL, + plate TEXT NULL, + crew TEXT NOT NULL, + crew_licenses TEXT NOT NULL, + author_license TEXT NOT NULL, + author_name TEXT NOT NULL, + author_callsign TEXT NOT NULL, + created_at TEXT NOT NULL, + filed_at TEXT NULL, + filed_by TEXT NULL); + CREATE INDEX IF NOT EXISTS idx_fiveprs_reports_status ON fiveprs_reports(status, created_at); + CREATE INDEX IF NOT EXISTS idx_fiveprs_reports_character ON fiveprs_reports(character_id)", + @"CREATE TABLE IF NOT EXISTS `fiveprs_reports` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kind` TINYINT UNSIGNED NOT NULL, + `department` TINYINT UNSIGNED NOT NULL, + `call_id` VARCHAR(16) NOT NULL, + `status` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `title` VARCHAR(60) NOT NULL, + `location` VARCHAR(80) NOT NULL, + `narrative` VARCHAR(2000) NOT NULL, + `character_id` INT UNSIGNED NULL, + `plate` VARCHAR(8) NULL, + `crew` VARCHAR(200) NOT NULL, + `crew_licenses` VARCHAR(700) NOT NULL, + `author_license` VARCHAR(60) NOT NULL, + `author_name` VARCHAR(100) NOT NULL, + `author_callsign` VARCHAR(16) NOT NULL, + `created_at` DATETIME(6) NOT NULL, + `filed_at` DATETIME(6) NULL, + `filed_by` VARCHAR(100) NULL, + PRIMARY KEY (`id`), + KEY `idx_fiveprs_reports_status` (`status`, `created_at`), + KEY `idx_fiveprs_reports_character` (`character_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci")), }; private static SqlStep Sql(string sqlite, string mySql) => new SqlStep(sqlite, mySql); diff --git a/FivePRS.Server/Database/PhotoStore.cs b/FivePRS.Server/Database/PhotoStore.cs new file mode 100644 index 0000000..1a8e809 --- /dev/null +++ b/FivePRS.Server/Database/PhotoStore.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace FivePRS.Server.Database +{ + public sealed class PhotoStore : SqlStore + { + public PhotoStore(IDatabaseProvider db) : base(db) + { + } + + public async Task GetAsync(int characterId) + { + var rows = await QueryAsync("SELECT data FROM fiveprs_photos WHERE character_id = @id", + row => row.GetString("data"), ("@id", characterId)); + return rows.Count > 0 ? rows[0] : null; + } + + public async Task> GetManyAsync(IEnumerable characterIds) + { + var ids = characterIds.Distinct().ToList(); + var photos = new Dictionary(); + if (ids.Count == 0) return photos; + + var parameters = ids.Select((id, index) => ($"@id{index}", (object?)id)).ToArray(); + var rows = await QueryAsync( + $"SELECT character_id, data FROM fiveprs_photos WHERE character_id IN ({string.Join(", ", parameters.Select(p => p.Item1))})", + row => (Id: row.GetInt32("character_id"), Data: row.GetString("data")), + parameters); + + foreach (var row in rows) + photos[row.Id] = row.Data; + return photos; + } + + public Task SetAsync(int characterId, string data) => + ExecuteAsync( + "INSERT INTO fiveprs_photos (character_id, data, updated_at) VALUES (@id, @data, @now) " + + (IsMySql + ? "ON DUPLICATE KEY UPDATE data = VALUES(data), updated_at = VALUES(updated_at)" + : "ON CONFLICT(character_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at"), + ("@id", characterId), ("@data", data), ("@now", DateTime.UtcNow)); + + public Task DeleteAsync(int characterId) => + ExecuteAsync("DELETE FROM fiveprs_photos WHERE character_id = @id", ("@id", characterId)); + } +} diff --git a/FivePRS.Server/Database/PlayerStore.cs b/FivePRS.Server/Database/PlayerStore.cs index d3f29ef..2ab656c 100644 --- a/FivePRS.Server/Database/PlayerStore.cs +++ b/FivePRS.Server/Database/PlayerStore.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using FivePRS.Core.Models; @@ -13,7 +14,8 @@ public PlayerStore(IDatabaseProvider db) : base(db) public async Task GetAsync(string license) { var rows = await QueryAsync( - "SELECT license, name, department, is_on_duty, xp, rank_level, last_seen, agency, callsign FROM ers_players WHERE license = @license LIMIT 1", + "SELECT license, name, department, is_on_duty, xp, rank_level, last_seen, agency, callsign, calls_completed, arrests, duty_seconds " + + "FROM ers_players WHERE license = @license LIMIT 1", row => new PlayerData { License = row.GetString("license"), @@ -25,6 +27,9 @@ public PlayerStore(IDatabaseProvider db) : base(db) LastSeen = row.IsNull("last_seen") ? DateTime.UtcNow : row.GetDateTime("last_seen"), Agency = row.GetString("agency"), Callsign = row.GetString("callsign"), + CallsCompleted = row.GetInt32("calls_completed"), + Arrests = row.GetInt32("arrests"), + DutySeconds = row.GetInt32("duty_seconds"), }, ("@license", license)); return rows.Count > 0 ? rows[0] : null; @@ -32,21 +37,40 @@ public PlayerStore(IDatabaseProvider db) : base(db) public Task SaveAsync(PlayerData player) => ExecuteAsync( - "INSERT INTO ers_players (license, name, department, agency, callsign, is_on_duty, xp, rank_level, last_seen) " + - "VALUES (@license, @name, @department, @agency, @callsign, @onDuty, @xp, @rank, @now) " + + "INSERT INTO ers_players (license, name, department, agency, callsign, is_on_duty, xp, rank_level, last_seen, calls_completed, arrests, duty_seconds) " + + "VALUES (@license, @name, @department, @agency, @callsign, @onDuty, @xp, @rank, @now, @calls, @arrests, @dutySeconds) " + (IsMySql ? "ON DUPLICATE KEY UPDATE name = VALUES(name), department = VALUES(department), agency = VALUES(agency), callsign = VALUES(callsign), " + - "is_on_duty = VALUES(is_on_duty), xp = VALUES(xp), rank_level = VALUES(rank_level), last_seen = VALUES(last_seen)" + "is_on_duty = VALUES(is_on_duty), xp = VALUES(xp), rank_level = VALUES(rank_level), last_seen = VALUES(last_seen), " + + "calls_completed = VALUES(calls_completed), arrests = VALUES(arrests), duty_seconds = VALUES(duty_seconds)" : "ON CONFLICT(license) DO UPDATE SET name = excluded.name, department = excluded.department, agency = excluded.agency, callsign = excluded.callsign, " + - "is_on_duty = excluded.is_on_duty, xp = excluded.xp, rank_level = excluded.rank_level, last_seen = excluded.last_seen"), + "is_on_duty = excluded.is_on_duty, xp = excluded.xp, rank_level = excluded.rank_level, last_seen = excluded.last_seen, " + + "calls_completed = excluded.calls_completed, arrests = excluded.arrests, duty_seconds = excluded.duty_seconds"), ("@license", player.License), ("@name", player.Name), ("@department", (int)player.Department), ("@agency", player.Agency), ("@callsign", player.Callsign), ("@onDuty", player.IsOnDuty ? 1 : 0), - ("@xp", player.XP), ("@rank", player.Rank), ("@now", DateTime.UtcNow)); + ("@xp", player.XP), ("@rank", player.Rank), ("@now", DateTime.UtcNow), + ("@calls", player.CallsCompleted), ("@arrests", player.Arrests), ("@dutySeconds", player.DutySeconds)); public Task UpdateDutyStatusAsync(string license, bool isOnDuty) => ExecuteAsync("UPDATE ers_players SET is_on_duty = @onDuty WHERE license = @license", ("@onDuty", isOnDuty ? 1 : 0), ("@license", license)); + public Task AddArrestAsync(string license) => + ExecuteAsync("UPDATE ers_players SET arrests = arrests + 1 WHERE license = @license", ("@license", license)); + + public Task> GetAuditAsync(int limit) => + QueryAsync( + $"SELECT id, created_at, action, actor_name, target_license, details FROM fiveprs_audit ORDER BY id DESC LIMIT {Math.Max(1, Math.Min(500, limit))}", + row => new AuditRecord + { + Id = row.GetInt64("id"), + CreatedAt = row.GetDateTime("created_at"), + Action = row.GetString("action"), + ActorName = row.GetString("actor_name"), + TargetLicense = row.IsNull("target_license") ? null : row.GetString("target_license"), + Details = row.GetString("details"), + }); + public Task AddAuditAsync(AuditEntry entry) => ExecuteAsync( "INSERT INTO fiveprs_audit (created_at, action, actor_license, actor_name, target_license, details) " + diff --git a/FivePRS.Server/Database/PreferencesStore.cs b/FivePRS.Server/Database/PreferencesStore.cs index ad9becb..9ac5fe0 100644 --- a/FivePRS.Server/Database/PreferencesStore.cs +++ b/FivePRS.Server/Database/PreferencesStore.cs @@ -16,6 +16,21 @@ public async Task GetWallpaperAsync(string license) return rows.Count > 0 ? rows[0] : string.Empty; } + public async Task GetSettingsAsync(string license) + { + var rows = await QueryAsync("SELECT settings FROM fiveprs_preferences WHERE license = @license", + row => row.GetString("settings"), ("@license", license)); + return rows.Count > 0 ? rows[0] : "{}"; + } + + public Task SetSettingsAsync(string license, string settings) => + ExecuteAsync( + "INSERT INTO fiveprs_preferences (license, wallpaper, settings, updated_at) VALUES (@license, '', @settings, @now) " + + (IsMySql + ? "ON DUPLICATE KEY UPDATE settings = VALUES(settings), updated_at = VALUES(updated_at)" + : "ON CONFLICT(license) DO UPDATE SET settings = excluded.settings, updated_at = excluded.updated_at"), + ("@license", license), ("@settings", settings), ("@now", DateTime.UtcNow)); + public Task SetWallpaperAsync(string license, string wallpaper) => ExecuteAsync( "INSERT INTO fiveprs_preferences (license, wallpaper, updated_at) VALUES (@license, @wallpaper, @now) " + diff --git a/FivePRS.Server/Database/ReportStore.cs b/FivePRS.Server/Database/ReportStore.cs new file mode 100644 index 0000000..98aa356 --- /dev/null +++ b/FivePRS.Server/Database/ReportStore.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Models; + +namespace FivePRS.Server.Database +{ + public sealed class ReportStore : SqlStore + { + private const string Columns = + "r.id, r.kind, r.department, r.call_id, r.status, r.title, r.location, r.narrative, r.character_id, r.plate, r.crew, " + + "r.author_name, r.author_callsign, r.created_at, r.filed_at, r.filed_by, c.first_name, c.last_name"; + + private const string From = "FROM fiveprs_reports r LEFT JOIN fiveprs_characters c ON c.id = r.character_id"; + + public ReportStore(IDatabaseProvider db) : base(db) + { + } + + public async Task AddAsync(ReportInfo report, string authorLicense, IEnumerable crewLicenses) + { + var id = await InsertAsync( + "INSERT INTO fiveprs_reports (kind, department, call_id, status, title, location, narrative, character_id, plate, crew, crew_licenses, " + + "author_license, author_name, author_callsign, created_at) VALUES (@kind, @department, @call, @status, @title, @location, @narrative, " + + "@character, @plate, @crew, @crewLicenses, @license, @name, @callsign, @now)", + ("@kind", (int)report.Kind), ("@department", (int)report.Department), ("@call", report.CallId), + ("@status", (int)ReportStatus.Draft), ("@title", report.Title), ("@location", report.Location), + ("@narrative", report.Narrative), ("@character", report.CharacterId), ("@plate", report.Plate), + ("@crew", report.Crew), ("@crewLicenses", Pack(crewLicenses)), ("@license", authorLicense), + ("@name", report.AuthorName), ("@callsign", report.AuthorCallsign), ("@now", DateTime.UtcNow)); + return Convert.ToInt32(id); + } + + public async Task GetAsync(int id) + { + var rows = await QueryAsync($"SELECT {Columns} {From} WHERE r.id = @id", Read, ("@id", id)); + return rows.Count > 0 ? rows[0] : null; + } + + public Task> GetDraftsForAsync(string license) => + QueryAsync( + $"SELECT {Columns} {From} WHERE r.status = @draft AND r.crew_licenses LIKE @license ESCAPE '!' ORDER BY r.created_at DESC, r.id DESC", + Read, ("@draft", (int)ReportStatus.Draft), ("@license", $"%|{Escape(license)}|%")); + + public async Task IsCrewAsync(int id, string license) + { + var rows = await QueryAsync( + "SELECT id FROM fiveprs_reports WHERE id = @id AND crew_licenses LIKE @license ESCAPE '!'", + row => row.GetInt32("id"), ("@id", id), ("@license", $"%|{Escape(license)}|%")); + return rows.Count > 0; + } + + public async Task> CrewLicensesAsync(int id) + { + var rows = await QueryAsync("SELECT crew_licenses FROM fiveprs_reports WHERE id = @id", + row => row.GetString("crew_licenses"), ("@id", id)); + return rows.SelectMany(Unpack).ToList(); + } + + public Task SaveNarrativeAsync(int id, string narrative) => + ExecuteAsync("UPDATE fiveprs_reports SET narrative = @narrative WHERE id = @id AND status = @draft", + ("@narrative", narrative), ("@id", id), ("@draft", (int)ReportStatus.Draft)); + + public Task FileAsync(int id, string narrative, string filedBy) => + ExecuteAsync("UPDATE fiveprs_reports SET narrative = @narrative, status = @filed, filed_at = @now, filed_by = @by WHERE id = @id AND status = @draft", + ("@narrative", narrative), ("@filed", (int)ReportStatus.Filed), ("@now", DateTime.UtcNow), + ("@by", filedBy), ("@id", id), ("@draft", (int)ReportStatus.Draft)); + + public Task> GetStaleDraftsAsync(DateTime olderThan) => + QueryAsync($"SELECT {Columns} {From} WHERE r.status = @draft AND r.created_at < @before ORDER BY r.id", + Read, ("@draft", (int)ReportStatus.Draft), ("@before", olderThan)); + + public Task> SearchAsync(string term, DateTime? day, IReadOnlyCollection kinds, int limit) + { + var conditions = new List { "r.status = @filed" }; + var parameters = new List<(string, object?)> { ("@filed", (int)ReportStatus.Filed) }; + + if (term.Length > 0) + { + conditions.Add("(r.location LIKE @term ESCAPE '!' OR r.title LIKE @term ESCAPE '!' OR r.plate LIKE @term ESCAPE '!' OR r.call_id LIKE @term ESCAPE '!')"); + parameters.Add(("@term", $"%{Escape(term)}%")); + } + + if (day is not null) + { + conditions.Add("r.created_at >= @from AND r.created_at < @to"); + parameters.Add(("@from", DateTime.SpecifyKind(day.Value.Date, DateTimeKind.Utc))); + parameters.Add(("@to", DateTime.SpecifyKind(day.Value.Date.AddDays(1), DateTimeKind.Utc))); + } + + var kindList = kinds.Select((kind, index) => ($"@kind{index}", (object?)(int)kind)).ToList(); + conditions.Add($"r.kind IN ({string.Join(", ", kindList.Select(k => k.Item1))})"); + parameters.AddRange(kindList); + + return QueryAsync( + $"SELECT {Columns} {From} WHERE {string.Join(" AND ", conditions)} ORDER BY r.created_at DESC, r.id DESC LIMIT {limit}", + Read, parameters.ToArray()); + } + + private static string Pack(IEnumerable licenses) => + "|" + string.Join("|", licenses.Distinct()) + "|"; + + private static IEnumerable Unpack(string packed) => + packed.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + + private static string Escape(string text) => + text.Replace("!", "!!").Replace("%", "!%").Replace("_", "!_"); + + private static ReportInfo Read(DbRow row) => new() + { + Id = row.GetInt32("id"), + Kind = (ReportKind)row.GetInt32("kind"), + Department = (Department)row.GetInt32("department"), + CallId = row.GetString("call_id"), + Status = (ReportStatus)row.GetInt32("status"), + Title = row.GetString("title"), + Location = row.GetString("location"), + Narrative = row.GetString("narrative"), + CharacterId = row.IsNull("character_id") ? null : row.GetInt32("character_id"), + PersonName = row.IsNull("first_name") ? null : $"{row.GetString("first_name")} {row.GetString("last_name")}", + Plate = row.IsNull("plate") ? null : row.GetString("plate"), + Crew = row.GetString("crew"), + AuthorName = row.GetString("author_name"), + AuthorCallsign = row.GetString("author_callsign"), + CreatedAt = row.GetDateTime("created_at"), + FiledAt = row.IsNull("filed_at") ? null : row.GetDateTime("filed_at"), + FiledBy = row.IsNull("filed_by") ? null : row.GetString("filed_by"), + }; + } +} diff --git a/FivePRS.Server/Dispatch/DispatchService.cs b/FivePRS.Server/Dispatch/DispatchService.cs index a79ec9c..6d31bc8 100644 --- a/FivePRS.Server/Dispatch/DispatchService.cs +++ b/FivePRS.Server/Dispatch/DispatchService.cs @@ -284,6 +284,15 @@ public IReadOnlyList End(int unitId, string callId, CalloutResult resul return awards; } + public IReadOnlyList CrewOf(string callId, int unitId) + { + if (!_calls.TryGetValue(callId, out var call) || call.Status != CallStatus.Active) return new int[0]; + + var crew = new List { call.PrimaryUnit }; + crew.AddRange(call.Attached.Where(id => _units.ContainsKey(id) && id != call.PrimaryUnit)); + return crew.Contains(unitId) ? crew : new int[0]; + } + public int? ForceClose(string callId) { if (!_calls.TryGetValue(callId, out var call) || call.Status != CallStatus.Active) return null; @@ -505,6 +514,9 @@ public IEnumerable BackupResponders(int requesterId) Department = call.Definition.Department, Description = call.Description, Responding = call.Attached.Count(_units.ContainsKey), + OnScene = call.Attached.Any(id => _units.TryGetValue(id, out var unit) && unit.Info.Status == UnitStatus.OnScene), + ReceivedAt = call.OfferedUtc, + Units = call.Attached.Where(_units.ContainsKey).Select(id => _units[id].Info.Callsign).ToList(), }; } diff --git a/FivePRS.Server/Logging/FileLogSink.cs b/FivePRS.Server/Logging/FileLogSink.cs new file mode 100644 index 0000000..3ae88ad --- /dev/null +++ b/FivePRS.Server/Logging/FileLogSink.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using FivePRS.Core.Logging; + +namespace FivePRS.Server.Logging +{ + internal sealed class FileLogSink : ILogSink + { + private const int KeepDays = 14; + private const string Pattern = "fiveprs-*.log"; + + private readonly object _gate = new(); + private readonly string _directory; + private bool _failed; + + public FileLogSink(string directory) + { + _directory = directory; + } + + public void Prune() + { + if (!Directory.Exists(_directory)) return; + + var cutoff = DateTime.Now.AddDays(-KeepDays); + foreach (var file in Directory.GetFiles(_directory, Pattern)) + { + try + { + if (File.GetLastWriteTime(file) < cutoff) File.Delete(file); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + + public void Write(LogEntry entry) + { + if (_failed) return; + + var text = $"{entry.Time:yyyy-MM-dd HH:mm:ss} {Label(entry.Level),-7} {entry.Source}: {entry.Message}{Environment.NewLine}"; + if (entry.Exception is not null) + text += entry.Exception + Environment.NewLine; + + lock (_gate) + { + try + { + Directory.CreateDirectory(_directory); + File.AppendAllText(Path.Combine(_directory, $"fiveprs-{entry.Time:yyyy-MM-dd}.log"), text); + } + catch (Exception) + { + _failed = true; + } + } + } + + private static string Label(LogLevel level) => level switch + { + LogLevel.Debug => "DEBUG", + LogLevel.Success => "OK", + LogLevel.Warning => "WARN", + LogLevel.Error => "ERROR", + _ => "INFO", + }; + } +} diff --git a/FivePRS.Server/Logging/ServerLogging.cs b/FivePRS.Server/Logging/ServerLogging.cs new file mode 100644 index 0000000..a233cb0 --- /dev/null +++ b/FivePRS.Server/Logging/ServerLogging.cs @@ -0,0 +1,23 @@ +using System; +using System.IO; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Logging; + +namespace FivePRS.Server.Logging +{ + internal static class ServerLogging + { + public static void Start(string resourcePath) + { + Logger.DebugEnabled = string.Equals(API.GetConvar("fiveprs_debug", "false"), "true", StringComparison.OrdinalIgnoreCase); + + var file = new FileLogSink(Path.Combine(resourcePath, "data", "logs")); + file.Prune(); + + Logger.ClearSinks(); + Logger.AddSink(new ConsoleSink(message => Debug.WriteLine(message), "Full error details are saved in data/logs.")); + Logger.AddSink(file); + } + } +} diff --git a/FivePRS.Server/ServerBrain.Admin.cs b/FivePRS.Server/ServerBrain.Admin.cs index 5a156b2..fbae702 100644 --- a/FivePRS.Server/ServerBrain.Admin.cs +++ b/FivePRS.Server/ServerBrain.Admin.cs @@ -4,7 +4,9 @@ using System.Threading.Tasks; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Core.Config; using FivePRS.Core.Events; +using FivePRS.Core.Logging; using FivePRS.Core.Models; using FivePRS.Server.Database; @@ -12,6 +14,8 @@ namespace FivePRS.Server { public partial class ServerBrain { + private const int MaxXpGrant = 1_000_000; + private void RegisterAdminCommands() { RegisterAdminCommand("fprs_setrank", " ", 2, SetRankAsync); @@ -22,7 +26,7 @@ private void RegisterAdminCommands() RegisterAdminCommand("fprs_units", "", 0, ListUnitsAsync); } - private void RegisterAdminCommand(string name, string usage, int requiredArgs, Func> handler) + private void RegisterAdminCommand(string name, string usage, int requiredArgs, Func> handler) { API.RegisterCommand(name, new Action, string>(async (source, rawArgs, _) => { @@ -30,15 +34,15 @@ private void RegisterAdminCommand(string name, string usage, int requiredArgs, F if (caller is not null && !_permissions.IsAdmin(caller.Handle)) { - Reply(caller, "You do not have permission to use this command."); + Reply(caller, CommandResult.Failed("You do not have permission to use this command.")); Audit(AuditActions.PermissionDenied, caller, null, name); return; } - var args = rawArgs.Select(a => a?.ToString() ?? string.Empty).ToArray(); - if (args.Length < requiredArgs) + var args = rawArgs.Select(a => a?.ToString()?.Trim() ?? string.Empty).Where(a => a.Length > 0).ToArray(); + if (args.Length != requiredArgs) { - Reply(caller, $"Usage: {name} {usage}".TrimEnd()); + Reply(caller, CommandResult.Failed($"Usage: {name} {usage}".TrimEnd())); return; } @@ -48,76 +52,81 @@ private void RegisterAdminCommand(string name, string usage, int requiredArgs, F } catch (Exception ex) { - ServerLog.Error($"{name} failed", ex); - Reply(caller, $"{name} failed: {ex.Message}"); + Log.Error($"{name} failed", ex); + Reply(caller, CommandResult.Failed($"{name} failed: {LogEntry.Cause(ex).Message}")); } }), false); } - private async Task SetRankAsync(Player? caller, string[] args) + private async Task SetRankAsync(Player? caller, string[] args) { if (!TryGetTarget(args[0], out var target, out var data)) return TargetNotFound(args[0]); - if (!int.TryParse(args[1], out var rank) || rank < 1) return "Rank must be a positive number."; + var ladder = data.Ladder(); + if (!int.TryParse(args[1], out var rank) || rank < 1 || rank > ladder.Count) + return CommandResult.Failed($"Rank must be a number from 1 to {ladder.Count} for {data.Department}."); data.Rank = rank; data.XP = 0; await _db.SavePlayerAsync(data); + var name = data.RankName(); SendPlayerData(target, data); _dispatch.UpdateRank(ServerId(target), rank); - Notify(target, $"~y~Your rank was set to {rank} by an administrator."); - Audit(AuditActions.AdminSetRank, caller, data.License, $"rank {rank}"); + Notify(target, $"~y~An administrator made you {name}."); + Audit(AuditActions.AdminSetRank, caller, data.License, $"rank {rank} ({name})"); - return $"{data.Name} is now rank {rank}."; + return CommandResult.Done($"{data.Name} is now {name} (rank {rank})."); } - private async Task AddXpAsync(Player? caller, string[] args) + private async Task AddXpAsync(Player? caller, string[] args) { if (!TryGetTarget(args[0], out var target, out var data)) return TargetNotFound(args[0]); - if (!int.TryParse(args[1], out var amount) || amount < 1) return "Amount must be a positive number."; + if (!int.TryParse(args[1], out var amount) || amount < 1 || amount > MaxXpGrant) + return CommandResult.Failed($"Amount must be a number from 1 to {MaxXpGrant:N0}."); await GrantXpAsync(target, data, amount); Notify(target, $"~y~An administrator granted you {amount} XP."); Audit(AuditActions.AdminAddXp, caller, data.License, $"+{amount}"); - return $"Granted {amount} XP to {data.Name} (rank {data.Rank}, {data.XP} XP)."; + return CommandResult.Done($"Granted {amount} XP to {data.Name} ({data.RankName()}, {data.XP} XP)."); } - private async Task SetDepartmentAsync(Player? caller, string[] args) + private async Task SetDepartmentAsync(Player? caller, string[] args) { if (!TryGetTarget(args[0], out var target, out var data)) return TargetNotFound(args[0]); - if (!Enum.TryParse(args[1], true, out var department) || - !Enum.IsDefined(typeof(Department), department) || - department == Department.None) + var department = Enum.GetValues(typeof(Department)).Cast() + .FirstOrDefault(d => d != Department.None && string.Equals(d.ToString(), args[1], StringComparison.OrdinalIgnoreCase)); + if (department == Department.None) { - return $"Unknown department '{args[1]}'."; + var names = Enum.GetValues(typeof(Department)).Cast().Where(d => d != Department.None); + return CommandResult.Failed($"Unknown department '{args[1]}'. Use {string.Join(", ", names)}."); } await ChangeDepartmentAsync(target, data, department); Notify(target, $"~y~Your department was set to {department} by an administrator."); Audit(AuditActions.AdminSetDept, caller, data.License, department.ToString()); - return $"{data.Name} is now in {department}."; + return CommandResult.Done($"{data.Name} is now in {department}."); } - private async Task ForceOffDutyAsync(Player? caller, string[] args) + private async Task ForceOffDutyAsync(Player? caller, string[] args) { if (!TryGetTarget(args[0], out var target, out var data)) return TargetNotFound(args[0]); - if (!data.IsOnDuty) return $"{data.Name} is not on duty."; + if (!data.IsOnDuty) return CommandResult.Failed($"{data.Name} is not on duty."); await SetDutyAsync(target, data, false); Notify(target, "~y~You were placed off duty by an administrator."); Audit(AuditActions.AdminOffDuty, caller, data.License, data.Department.ToString()); - return $"{data.Name} is now off duty."; + return CommandResult.Done($"{data.Name} is now off duty."); } - private Task EndCallAsync(Player? caller, string[] args) + private Task EndCallAsync(Player? caller, string[] args) { var callId = args[0].TrimStart('#'); var emergencyCaller = _dispatch.GetEmergencyCaller(callId); var primary = _dispatch.ForceClose(callId); - if (primary is null) return Task.FromResult($"Call #{callId} is not active."); + if (primary is null) return Task.FromResult(CommandResult.Failed($"Call #{callId} is not active.")); string? targetLicense = null; if (primary.Value > 0) @@ -133,18 +142,18 @@ private Task EndCallAsync(Player? caller, string[] args) Audit(AuditActions.AdminEndCall, caller, targetLicense, $"call #{callId}"); - return Task.FromResult($"Call #{callId} closed."); + return Task.FromResult(CommandResult.Done($"Call #{callId} closed.")); } - private Task ListUnitsAsync(Player? caller, string[] args) + private Task ListUnitsAsync(Player? caller, string[] args) { var units = _dispatch.CreateSnapshot().Units; - if (units.Count == 0) return Task.FromResult("No units on duty."); + if (units.Count == 0) return Task.FromResult(CommandResult.Done("No units on duty.")); var lines = units.Select(u => $"{u.Callsign} {u.Name} [{u.Status}]{(u.CallId is null ? string.Empty : $" call #{u.CallId}")}"); - return Task.FromResult(string.Join("\n", lines)); + return Task.FromResult(CommandResult.Done(string.Join("\n", lines))); } private bool TryGetTarget(string arg, out Player target, out PlayerData data) @@ -158,14 +167,28 @@ private bool TryGetTarget(string arg, out Player target, out PlayerData data) return TryGetCached(target, out _, out data); } - private static string TargetNotFound(string arg) => $"No loaded player with server id '{arg}'."; + private static CommandResult TargetNotFound(string arg) => CommandResult.Failed($"No player with server id '{arg}' is loaded."); private void Reply(Player? caller, string message) { if (caller is null) - Debug.WriteLine($"[FivePRS] {message}"); + Log.Info(message); else Notify(caller, message.Replace("\n", "~n~")); } + + private void Reply(Player? caller, CommandResult result) + { + if (caller is null) + { + if (result.Succeeded) + Log.Success(result.Message); + else + Log.Warn(result.Message); + return; + } + + Notify(caller, (result.Succeeded ? "~g~" : "~r~") + result.Message.Replace("\n", "~n~~w~")); + } } } diff --git a/FivePRS.Server/ServerBrain.Civilian.cs b/FivePRS.Server/ServerBrain.Civilian.cs index a13616b..fe24022 100644 --- a/FivePRS.Server/ServerBrain.Civilian.cs +++ b/FivePRS.Server/ServerBrain.Civilian.cs @@ -11,6 +11,8 @@ namespace FivePRS.Server { public partial class ServerBrain { + private const float ShowCardRangeM = 4f; + private CivilianService? _civilians; private void RegisterCivilianEvents() @@ -23,6 +25,51 @@ private void RegisterCivilianEvents() EventHandlers[EventNames.ServerVehicleRegister] += new Action(OnVehicleRegister); EventHandlers[EventNames.ServerVehicleRemove] += new Action(OnVehicleRemove); EventHandlers[EventNames.ServerVehicleSetStolen] += new Action(OnVehicleSetStolen); + EventHandlers[EventNames.ServerCharacterPhoto] += new Action(OnCharacterPhoto); + EventHandlers[EventNames.ServerShowLicense] += new Action(OnShowLicense); + } + + private void OnCharacterPhoto([FromSource] Player player, int characterId, string photo) => + RunCivilian(player, (service, license) => service.SetPhotoAsync(license, characterId, photo), + AuditActions.PhotoSet, $"character {characterId}"); + + private async void OnShowLicense([FromSource] Player player, int targetId, string type) + { + if (!Allow(player, "civilian", l => l.Civilian) || _civilians is null) return; + + var license = GetLicense(player); + if (license is null) return; + + if (!TryGetPlayer(targetId, out var target) || target.Handle == player.Handle || !AreNear(player, target, ShowCardRangeM)) + { + Notify(player, "~r~Nobody is close enough to show your license to."); + return; + } + + try + { + var card = await _civilians.GetLicenseCardAsync(license, type); + if (card is null) + { + Notify(player, "~r~You don't hold that license."); + return; + } + + TriggerClientEvent(target, EventNames.ClientShowCard, JsonConvert.SerializeObject(card)); + Notify(player, $"You showed your {card.License.Name} to {target.Name}."); + } + catch (Exception ex) + { + Log.Error($"Showing {player.Name}'s license failed", ex); + } + } + + private static bool AreNear(Player first, Player second, float range) + { + var a = API.GetEntityCoords(API.GetPlayerPed(first.Handle)); + var b = API.GetEntityCoords(API.GetPlayerPed(second.Handle)); + if (a == Vector3.Zero || b == Vector3.Zero) return true; + return Vector3.Distance(a, b) <= range; } private void OnCivilianRequest([FromSource] Player player) => @@ -103,7 +150,7 @@ private async void RunCivilian(Player player, Func AdminReviveAsync(Player? caller, string[] args) + private Task AdminReviveAsync(Player? caller, string[] args) { if (!int.TryParse(args[0], out var id) || !TryGetPlayer(id, out var target)) return Task.FromResult(TargetNotFound(args[0])); + if (!_downed.Contains(id)) return Task.FromResult(CommandResult.Failed($"{target.Name} is not down.")); Revive(target, "an administrator"); Audit(AuditActions.PlayerRevived, caller, GetLicense(target), $"admin revive {target.Name}"); - return Task.FromResult($"{target.Name} revived."); + return Task.FromResult(CommandResult.Done($"{target.Name} revived.")); } private void Revive(Player target, string by) @@ -118,7 +119,7 @@ private async Task AlertEmsAsync(Player player) } catch (Exception ex) { - ServerLog.Error($"Looking up {player.Name}'s character failed", ex); + Log.Error($"Looking up {player.Name}'s character failed", ex); } } diff --git a/FivePRS.Server/ServerBrain.Diagnostics.cs b/FivePRS.Server/ServerBrain.Diagnostics.cs index 7ed57c8..0ccda0a 100644 --- a/FivePRS.Server/ServerBrain.Diagnostics.cs +++ b/FivePRS.Server/ServerBrain.Diagnostics.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using CitizenFX.Core; using CitizenFX.Core.Native; using FivePRS.Core.Config; +using FivePRS.Core.Models; namespace FivePRS.Server { @@ -18,13 +20,13 @@ private void RegisterDiagnostics() private void LogStartup() { var version = InstalledVersion(); - Debug.WriteLine($"[FivePRS] FivePRS {version} on {Platform()}, database {_databaseType} (schema {_db.SchemaVersion})."); + Log.Success($"FivePRS {version} ready on {Platform()}, {_databaseType} schema {_db.SchemaVersion}."); if (version.IsPreRelease) - Debug.WriteLine("[FivePRS] This is a public test build. Back up your database before updating, and report problems at https://github.com/FivePRS/core/issues"); + Log.Warn("Public test build. Back up your database before updating."); foreach (var warning in StartupWarnings()) - Debug.WriteLine($"[FivePRS] WARNING: {warning}"); + Log.Warn(warning); } private static List StartupWarnings() @@ -45,9 +47,22 @@ private static List StartupWarnings() return warnings; } - private Task DiagnosticsAsync(Player? caller, string[] args) + private Task DiagnosticsAsync(Player? caller, string[] args) + { + var lines = new List { "FivePRS diagnostics" }; + lines.AddRange(DiagnosticFacts().Select(fact => $"{fact.Label}: {fact.Value}")); + + var addons = Addons().Select(addon => string.IsNullOrEmpty(addon.Version) ? addon.Name : $"{addon.Name} {addon.Version}").ToList(); + lines.Add($"Addons: {(addons.Count > 0 ? string.Join(", ", addons) : "none")}"); + + var warnings = StartupWarnings(); + lines.Add(warnings.Count == 0 ? "Warnings: none" : $"Warnings: {string.Join(" | ", warnings)}"); + + return Task.FromResult(CommandResult.Done(string.Join("\n", lines))); + } + + private List DiagnosticFacts() { - var addons = StartedAddons().ToList(); var units = _dispatch.UnitIds.Count(); var players = Players.Count(); var updates = ConfigManager.Settings.Updates; @@ -56,39 +71,85 @@ private Task DiagnosticsAsync(Player? caller, string[] args) : _availableUpdate is null ? "up to date" : $"{_availableUpdate.Version} available"; - var lines = new List + return new List { - "FivePRS diagnostics", - $"Version: {InstalledVersion()}", - $"Server: {API.GetConvar("version", "unknown")}", - $"Platform: {Platform()}, runtime {Environment.Version}", - $"OneSync: {API.GetConvar("onesync", "off")}", - $"Database: {(_db.IsReady ? $"{_databaseType}, schema {_db.SchemaVersion}" : "not ready")}", - $"Players: {players} online, {units} on duty", - $"Dispatch: {_dispatch.CatalogCount} callouts, {_dispatch.ActiveCallCount} active calls", - $"Rate limits: {(ConfigManager.Settings.RateLimits.Enabled ? "on" : "off")}", - $"Updates: {update}", - $"Addons: {(addons.Count > 0 ? string.Join(", ", addons) : "none")}", + new("Version", InstalledVersion().ToString()), + new("Server", API.GetConvar("version", "unknown")), + new("Platform", $"{Platform()}, runtime {Environment.Version}"), + new("OneSync", API.GetConvar("onesync", "off")), + new("Database", _db.IsReady ? $"{_databaseType}, schema {_db.SchemaVersion}" : "not ready"), + new("Players", $"{players} online, {units} on duty"), + new("Dispatch", $"{_dispatch.CatalogCount} callouts, {_dispatch.ActiveCallCount} active calls"), + new("Rate limits", ConfigManager.Settings.RateLimits.Enabled ? "on" : "off"), + new("Updates", update), }; + } - var warnings = StartupWarnings(); - lines.Add(warnings.Count == 0 ? "Warnings: none" : $"Warnings: {string.Join(" | ", warnings)}"); + private const string OfficialRepository = "https://github.com/FivePRS/"; - return Task.FromResult(string.Join("\n", lines)); - } + private static readonly string[] DropInFolders = { "plugins", "callouts", "apps" }; - private static IEnumerable StartedAddons() + private static List Addons() { + var addons = new List(); var current = Resource.Name; + for (var i = 0; i < API.GetNumResources(); i++) { var name = API.GetResourceByFindIndex(i); - if (string.IsNullOrEmpty(name) || name == current || !name.StartsWith("fiveprs", StringComparison.OrdinalIgnoreCase)) continue; - if (API.GetResourceState(name) != "started") continue; + if (string.IsNullOrEmpty(name) || name == current || API.GetResourceState(name) != "started") continue; + if (!name.StartsWith("fiveprs", StringComparison.OrdinalIgnoreCase) && !DependsOnFivePrs(name, current)) continue; + + var repository = API.GetResourceMetadata(name, "repository", 0) ?? string.Empty; + addons.Add(new AdminAddon + { + Name = name, + Kind = "Resource", + Version = API.GetResourceMetadata(name, "version", 0) ?? string.Empty, + Author = AuthorName(API.GetResourceMetadata(name, "author", 0)), + Description = API.GetResourceMetadata(name, "description", 0) ?? string.Empty, + Official = repository.StartsWith(OfficialRepository, StringComparison.OrdinalIgnoreCase), + }); + } + + foreach (var folder in DropInFolders) + { + var path = Path.Combine(Resource.Path, folder); + if (!Directory.Exists(path)) continue; + + foreach (var file in Directory.GetFiles(path, "*.net.dll")) + { + var name = Path.GetFileName(file); + addons.Add(new AdminAddon + { + Name = name.Substring(0, name.Length - ".net.dll".Length), + Kind = char.ToUpperInvariant(folder[0]) + folder.Substring(1, folder.Length - 2), + }); + } + } + + return addons.OrderByDescending(addon => addon.Official).ThenBy(addon => addon.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } - var version = API.GetResourceMetadata(name, "version", 0); - yield return string.IsNullOrEmpty(version) ? name : $"{name} {version}"; + private static bool DependsOnFivePrs(string resource, string fiveprs) + { + foreach (var key in new[] { "dependency", "dependencies" }) + { + var count = API.GetNumResourceMetadata(resource, key); + for (var i = 0; i < count; i++) + { + if (string.Equals(API.GetResourceMetadata(resource, key, i), fiveprs, StringComparison.OrdinalIgnoreCase)) return true; + } } + + return false; + } + + private static string AuthorName(string? author) + { + var value = (author ?? string.Empty).Trim(); + var link = value.IndexOf('<'); + return (link > 0 ? value.Substring(0, link) : value).Trim(); } private static string Platform() => diff --git a/FivePRS.Server/ServerBrain.Emergency.cs b/FivePRS.Server/ServerBrain.Emergency.cs index 0cf9fdc..0b1f0b6 100644 --- a/FivePRS.Server/ServerBrain.Emergency.cs +++ b/FivePRS.Server/ServerBrain.Emergency.cs @@ -75,7 +75,7 @@ private async void OnEmergencyCall([FromSource] Player player, int departmentId, } catch (Exception ex) { - ServerLog.Error($"911 call failed for {player.Name}", ex); + Log.Error($"911 call failed for {player.Name}", ex); TriggerClientEvent(player, EventNames.ClientEmergencyError, "Something went wrong. Please try again."); } } diff --git a/FivePRS.Server/ServerBrain.Lookup.cs b/FivePRS.Server/ServerBrain.Lookup.cs index 0655023..427c21e 100644 --- a/FivePRS.Server/ServerBrain.Lookup.cs +++ b/FivePRS.Server/ServerBrain.Lookup.cs @@ -16,6 +16,7 @@ public partial class ServerBrain { private const bool Lookups = true; private const bool Records = false; + private const bool PoliceOnly = true; private LookupService? _lookup; @@ -45,14 +46,20 @@ private void OnRecordIssue([FromSource] Player player, int characterId, int type var error = await lookup.IssueRecordAsync(officer, characterId, type, description, fine); if (error is not null) return (null, error); - var label = ((RecordType)type).ToString().ToLowerInvariant(); + var label = type == (int)RecordType.Medical ? "medical report" : ((RecordType)type).ToString().ToLowerInvariant(); + if (type == (int)RecordType.Arrest && TryGetCached(player, out _, out var arresting)) + { + arresting.Arrests++; + await _db.SavePlayerAsync(arresting); + } + Audit(AuditActions.RecordIssued, player, await OwnerLicenseAsync(characterId), $"{label} character {characterId}"); await NotifyCharacterOwnerAsync(characterId, $"~o~Records update~w~ | A {label} was added to your record by {officer.Callsign}."); return await lookup.GetRecordAsync(characterId); }); private void OnRecordResolve([FromSource] Player player, int recordId, bool served) => - RunLookup(player, Records, async (lookup, officer) => + RunLookup(player, Records, PoliceOnly, async (lookup, officer) => { var (characterId, error) = await lookup.ResolveWarrantAsync(officer, recordId, served); if (error is not null || characterId is null) return (null, error ?? "Warrant not found."); @@ -63,7 +70,7 @@ private void OnRecordResolve([FromSource] Player player, int recordId, bool serv }); private void OnLicenseSetStatus([FromSource] Player player, int characterId, string type, int status) => - RunLookup(player, Records, async (lookup, officer) => + RunLookup(player, Records, PoliceOnly, async (lookup, officer) => { var error = await lookup.SetLicenseStatusAsync(characterId, type, status); if (error is not null) return (null, error); @@ -76,7 +83,7 @@ private void OnLicenseSetStatus([FromSource] Player player, int characterId, str }); private void OnVehicleFlag([FromSource] Player player, int vehicleId, bool stolen) => - RunLookup(player, Records, async (lookup, officer) => + RunLookup(player, Records, PoliceOnly, async (lookup, officer) => { var (characterId, error) = await lookup.SetVehicleStolenAsync(vehicleId, stolen); if (error is not null || characterId is null) return (null, error ?? "Vehicle not found."); @@ -86,7 +93,10 @@ private void OnVehicleFlag([FromSource] Player player, int vehicleId, bool stole return await lookup.GetRecordAsync(characterId.Value); }); - private async void RunLookup(Player player, bool lookups, Func> action) + private void RunLookup(Player player, bool lookups, Func> action) => + RunLookup(player, lookups, false, action); + + private async void RunLookup(Player player, bool lookups, bool policeOnly, Func> action) { if (lookups ? !Allow(player, "lookups", l => l.Lookups) : !Allow(player, "records", l => l.Records)) { @@ -94,9 +104,15 @@ private async void RunLookup(Player player, bool lookups, Func + TryGetResponder(player, out officer) && officer.Department == Department.Police; + + private bool TryGetResponder(Player player, out OfficerInfo officer) { officer = null!; if (!TryGetCached(player, out var license, out var data)) return false; - if (!data.IsOnDuty || data.Department != Department.Police) return false; + if (!data.IsOnDuty || !RecordAccess.CanView(data.Department)) return false; officer = new OfficerInfo { - License = license, - Name = data.Name, - Callsign = _dispatch.GetUnit(ServerId(player))?.Callsign ?? data.Callsign, + License = license, + Name = data.Name, + Callsign = _dispatch.GetUnit(ServerId(player))?.Callsign ?? data.Callsign, + Department = data.Department, }; return true; } diff --git a/FivePRS.Server/ServerBrain.Positions.cs b/FivePRS.Server/ServerBrain.Positions.cs index c9d3187..6d722ee 100644 --- a/FivePRS.Server/ServerBrain.Positions.cs +++ b/FivePRS.Server/ServerBrain.Positions.cs @@ -52,7 +52,7 @@ private async void OnLastPositionRequest([FromSource] Player player) } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Could not load the last position for {player.Name}: {ex.Message}"); + Log.Warn($"Could not load {player.Name}'s last position", ex); } var model = string.Empty; @@ -62,7 +62,7 @@ private async void OnLastPositionRequest([FromSource] Player player) } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Could not load the spawn model for {player.Name}: {ex.Message}"); + Log.Warn($"Could not load {player.Name}'s spawn model", ex); } TriggerClientEvent(player, EventNames.ClientLastPosition, position is not null, @@ -98,7 +98,7 @@ private async Task SavePositionAsync(string license) } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Could not save a position: {ex.Message}"); + Log.Warn("Could not save a position", ex); } } diff --git a/FivePRS.Server/ServerBrain.Reports.cs b/FivePRS.Server/ServerBrain.Reports.cs new file mode 100644 index 0000000..f3d845d --- /dev/null +++ b/FivePRS.Server/ServerBrain.Reports.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Core.Civilian; +using FivePRS.Core.Events; +using FivePRS.Server.Civilian; +using FivePRS.Server.Database; +using Newtonsoft.Json; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private const int ReportSweepMs = 60_000; + + private ReportService? _reports; + + private void RegisterReportEvents() + { + EventHandlers[EventNames.ServerReportDraft] += new Action(OnReportDraft); + EventHandlers[EventNames.ServerReportsRequest] += new Action(OnReportsRequest); + EventHandlers[EventNames.ServerReportSave] += new Action(OnReportSave); + EventHandlers[EventNames.ServerReportFile] += new Action(OnReportFile); + EventHandlers[EventNames.ServerReportSearch] += new Action(OnReportSearch); + Tick += ReportSweepTickAsync; + } + + private async void OnReportDraft([FromSource] Player player, string json) + { + if (_reports is null || !Allow(player, "records", l => l.Records)) return; + if (!TryGetResponder(player, out var author)) return; + + ReportDraft? draft; + try + { + draft = JsonConvert.DeserializeObject(json); + } + catch (JsonException) + { + return; + } + + if (draft is null || !IsCallId(draft.CallId)) return; + + var crew = new List(); + foreach (var unitId in _dispatch.CrewOf(draft.CallId, ServerId(player))) + { + if (!TryGetPlayer(unitId, out var member) || GetLicense(member) is not { } license) continue; + crew.Add(new CrewMember(license, _dispatch.GetUnit(unitId)?.Callsign ?? string.Empty)); + } + + try + { + var (report, error) = await _reports.DraftAsync(author, draft, crew); + if (report is null) + { + if (error is not null) Log.Debug($"Report draft from {player.Name} refused: {error}"); + return; + } + + foreach (var license in crew.Select(member => member.License)) + await PushReportsAsync(license, $"~b~Reports~w~ | {report.Title} draft ready. Review and file it in ~y~Records › Reports~w~."); + } + catch (Exception ex) + { + Log.Error($"Could not draft a report for {player.Name}", ex); + } + } + + private void OnReportsRequest([FromSource] Player player) => + RunReports(player, async (reports, reader) => + ReportService.CanRead(reader.Department) + ? (new ReportsView { Drafts = await reports.DraftsForAsync(reader.License) }, null) + : (null, "Reports are for Fire and EMS.")); + + private void OnReportSave([FromSource] Player player, int reportId, string narrative) => + RunReports(player, async (reports, editor) => + { + var error = await reports.SaveAsync(editor, reportId, narrative); + if (error is not null) return (null, error); + + await PushReportsToCrewAsync(reportId, editor.License, null); + return (new ReportsView { Drafts = await reports.DraftsForAsync(editor.License) }, null); + }); + + private void OnReportFile([FromSource] Player player, int reportId, string narrative) => + RunReports(player, async (reports, filer) => + { + var (report, error) = await reports.FileAsync(filer, reportId, narrative); + if (report is null) return (null, error); + + Audit(AuditActions.ReportFiled, player, null, $"{report.Kind.ToString().ToLowerInvariant()} report {report.Id} call {report.CallId}"); + await PushReportsToCrewAsync(reportId, filer.License, $"~b~Reports~w~ | {filer.Callsign} filed the {report.Title} report."); + return (new ReportsView { Drafts = await reports.DraftsForAsync(filer.License) }, null); + }); + + private void OnReportSearch([FromSource] Player player, string term, string date) => + RunReports(player, (reports, reader) => reports.SearchAsync(reader, term, date)); + + private async void RunReports(Player player, Func> action) + { + if (!Allow(player, "records", l => l.Records)) + { + TriggerClientEvent(player, EventNames.ClientLookupError, "Slow down. Try again in a few seconds."); + return; + } + + if (_reports is null || !TryGetResponder(player, out var officer)) + { + TriggerClientEvent(player, EventNames.ClientLookupError, "Reports are only available on duty."); + return; + } + + try + { + var (view, error) = await action(_reports, officer); + if (error is not null) + TriggerClientEvent(player, EventNames.ClientLookupError, error); + else if (view is not null) + TriggerClientEvent(player, EventNames.ClientReports, JsonConvert.SerializeObject(view)); + } + catch (Exception ex) + { + Log.Error($"Reports action failed for {player.Name}", ex); + TriggerClientEvent(player, EventNames.ClientLookupError, "Something went wrong. Please try again."); + } + } + + private async Task PushReportsToCrewAsync(int reportId, string? exceptLicense, string? message) + { + if (_reports is null) return; + foreach (var license in await _reports.CrewOfAsync(reportId)) + { + if (license == exceptLicense) continue; + await PushReportsAsync(license, message); + } + } + + private async Task PushReportsAsync(string license, string? message) + { + if (_reports is null) return; + + var player = Players.FirstOrDefault(p => GetLicense(p) == license); + if (player is null) return; + + if (message is not null) Notify(player, message); + var view = new ReportsView { Drafts = await _reports.DraftsForAsync(license) }; + TriggerClientEvent(player, EventNames.ClientReports, JsonConvert.SerializeObject(view)); + } + + private async Task ReportSweepTickAsync() + { + await Delay(ReportSweepMs); + if (_reports is null) return; + + try + { + var filed = await _reports.FileStaleAsync(DateTime.UtcNow); + if (filed.Count == 0) return; + + Log.Info($"Filed {filed.Count} report draft{(filed.Count == 1 ? "" : "s")} left unfiled for {ReportRules.AutoFileMinutes} minutes."); + foreach (var report in filed) + await PushReportsToCrewAsync(report.Id, null, null); + } + catch (Exception ex) + { + Log.Error("Could not file old report drafts", ex); + } + } + } +} diff --git a/FivePRS.Server/ServerBrain.Roster.cs b/FivePRS.Server/ServerBrain.Roster.cs index fc2a993..81f2820 100644 --- a/FivePRS.Server/ServerBrain.Roster.cs +++ b/FivePRS.Server/ServerBrain.Roster.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Config; using FivePRS.Core.Events; using FivePRS.Core.Models; using FivePRS.Server.Database; @@ -14,6 +17,7 @@ public partial class ServerBrain { private const int MaxLicenseLength = 60; private const int MaxQueryLength = 64; + private const int AdminAuditLimit = 100; private RosterService? _roster; @@ -21,6 +25,51 @@ private void RegisterRosterEvents() { EventHandlers[EventNames.ServerAdminRequest] += new Action(OnAdminRequest); EventHandlers[EventNames.ServerAdminRosterSet] += new Action(OnAdminRosterSet); + EventHandlers[EventNames.ServerAdminAction] += new Action(OnAdminAction); + } + + private async void OnAdminAction([FromSource] Player player, string action, string target, string value, string query) + { + if (!_permissions.IsAdmin(player.Handle) || !Allow(player, "admin", l => l.Admin)) return; + + target = (target ?? string.Empty).Trim(); + value = (value ?? string.Empty).Trim(); + + try + { + var result = action switch + { + "setrank" => await SetRankAsync(player, new[] { target, value }), + "addxp" => await AddXpAsync(player, new[] { target, value }), + "setdept" => await SetDepartmentAsync(player, new[] { target, value }), + "offduty" => await ForceOffDutyAsync(player, new[] { target }), + "revive" => await AdminReviveAsync(player, new[] { target }), + "endcall" => await EndCallAsync(player, new[] { target }), + "teleport" => TeleportTo(player, target), + _ => CommandResult.Failed("Unknown admin action."), + }; + + Reply(player, result); + } + catch (Exception ex) + { + Log.Error($"Admin action {action} failed for {player.Name}", ex); + Reply(player, CommandResult.Failed("That action failed. Check the server console.")); + } + + await SendAdminStateAsync(player, TrimQuery(query)); + } + + private CommandResult TeleportTo(Player admin, string target) + { + if (!int.TryParse(target, out var id) || !TryGetPlayer(id, out var player)) return TargetNotFound(target); + + var position = API.GetEntityCoords(API.GetPlayerPed(player.Handle)); + if (position == Vector3.Zero) return CommandResult.Failed("Teleporting needs OneSync to find the player."); + + TriggerClientEvent(admin, EventNames.ClientAdminTeleport, position.X, position.Y, position.Z); + Audit(AuditActions.AdminTeleport, admin, GetLicense(player), $"to {player.Name}"); + return CommandResult.Done($"Teleported to {player.Name}."); } private bool IsOnRoster(string playerHandle, Department department) @@ -56,7 +105,7 @@ private async void OnAdminRosterSet([FromSource] Player player, string license, } catch (Exception ex) { - ServerLog.Error($"Roster change failed for {player.Name}", ex); + Log.Error($"Roster change failed for {player.Name}", ex); Notify(player, "~r~Roster change failed. Check the server console."); } } @@ -111,6 +160,10 @@ private async Task SendAdminStateAsync(Player admin, string? query) Online = true, ServerId = ServerId(p.Player), Departments = _roster.DepartmentsFor(p.License!), + Callsign = cached?.Callsign ?? string.Empty, + Agency = cached is null ? string.Empty : ConfigManager.Territories.FindAgency(cached.Agency)?.Name ?? string.Empty, + OnDuty = cached?.IsOnDuty ?? false, + Down = _downed.Contains(ServerId(p.Player)), }; }) .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) @@ -118,6 +171,34 @@ private async Task SendAdminStateAsync(Player admin, string? query) var onlineLicenses = online.Select(p => p.License).ToList(); var results = (await _roster.SearchAsync(query)).Where(p => !onlineLicenses.Contains(p.License)).ToList(); + foreach (var player in online.Concat(results).Where(p => p.Department != Department.None)) + player.RankName = ConfigManager.RanksFor(player.Department).NameFor(player.Rank); + + var snapshot = _dispatch.CreateSnapshot(); + var calls = snapshot.Calls.Select(call => new AdminCall + { + Id = call.Id, + Name = call.Name, + Code = (int)call.Priority, + Department = call.Department, + Territory = ConfigManager.Territories.FindTerritory(call.Territory)?.Name, + Units = call.Units + .Select(id => snapshot.Units.FirstOrDefault(u => u.ServerId == id)?.Callsign) + .Where(callsign => callsign is not null) + .Select(callsign => callsign!) + .ToList(), + Emergency = call.IsEmergency, + }).ToList(); + + var audit = _db.IsReady + ? (await _db.Players.GetAuditAsync(AdminAuditLimit)).Select(entry => new AdminAudit + { + CreatedAt = entry.CreatedAt, + Action = entry.Action, + ActorName = entry.ActorName, + Details = entry.Details, + }).ToList() + : new List(); var state = new AdminState { @@ -125,6 +206,11 @@ private async Task SendAdminStateAsync(Player admin, string? query) Query = query, Online = online, Results = results, + Calls = calls, + Audit = audit, + Server = DiagnosticFacts(), + Warnings = StartupWarnings(), + Addons = Addons(), }; TriggerClientEvent(admin, EventNames.ClientAdminState, JsonConvert.SerializeObject(state)); diff --git a/FivePRS.Server/ServerBrain.Security.cs b/FivePRS.Server/ServerBrain.Security.cs index cbab8e2..9955eff 100644 --- a/FivePRS.Server/ServerBrain.Security.cs +++ b/FivePRS.Server/ServerBrain.Security.cs @@ -15,7 +15,7 @@ private bool Allow(Player player, string bucket, Func(OnStopIdentify); EventHandlers[EventNames.ServerStopPlate] += new Action(OnStopPlate); + EventHandlers[EventNames.ServerRunPlate] += new Action(OnRunPlate); + EventHandlers[EventNames.ServerRunPerson] += new Action(OnRunPerson); } + private void OnRunPlate([FromSource] Player player, string plate, string model, int driverSeed, bool driverFemale, bool hasDriver) => + RunLookup(player, Lookups, (_, __) => _street is null + ? Task.FromResult<(LookupResult?, string?)>((null, "Records are not available yet.")) + : _street.PlateAsync(plate, model, hasDriver ? StreetProfiles.Person(driverSeed, driverFemale) : null)); + + private void OnRunPerson([FromSource] Player player, int seed, bool female) => + RunLookup(player, Lookups, (_, __) => _street is null + ? Task.FromResult<(LookupResult?, string?)>((null, "Records are not available yet.")) + : _street.IdentifyAsync(seed, female)); + private void OnStopIdentify([FromSource] Player player, int seed, bool female) => RunStop(player, StopPerson, street => street.IdentifyAsync(seed, female)); @@ -40,14 +54,14 @@ private async void RunStop(Player player, string kind, Func(OnSetWallpaper); + EventHandlers[EventNames.ServerSaveSettings] += new Action(OnSaveSettings); } private async void OnSetWallpaper([FromSource] Player player, string value) @@ -29,18 +31,39 @@ private async void OnSetWallpaper([FromSource] Player player, string value) try { await _db.Preferences.SetWallpaperAsync(license, wallpaper); - TriggerClientEvent(player, EventNames.ClientPreferences, wallpaper); + await SendPreferencesAsync(player, license); } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Saving the wallpaper failed for {player.Name}: {ex.Message}"); + Log.Warn($"Could not save {player.Name}'s wallpaper", ex); + } + } + + private async void OnSaveSettings([FromSource] Player player, string json) + { + if (!Allow(player, "civilian", l => l.Civilian)) return; + + var license = GetLicense(player); + if (license is null || !_db.IsReady) return; + + try + { + await _db.Preferences.SetSettingsAsync(license, TerminalPreferences.Parse(json).ToJson()); + await SendPreferencesAsync(player, license); + } + catch (Exception ex) + { + Log.Warn($"Could not save {player.Name}'s terminal settings", ex); } } private async Task SendPreferencesAsync(Player player, string license) { if (!_db.IsReady) return; - TriggerClientEvent(player, EventNames.ClientPreferences, await _db.Preferences.GetWallpaperAsync(license)); + + var wallpaper = await _db.Preferences.GetWallpaperAsync(license); + var settings = TerminalPreferences.Parse(await _db.Preferences.GetSettingsAsync(license)); + TriggerClientEvent(player, EventNames.ClientPreferences, wallpaper, settings.ToJson()); } } } diff --git a/FivePRS.Server/ServerBrain.Updates.cs b/FivePRS.Server/ServerBrain.Updates.cs index ef1dabe..a2a615f 100644 --- a/FivePRS.Server/ServerBrain.Updates.cs +++ b/FivePRS.Server/ServerBrain.Updates.cs @@ -53,7 +53,7 @@ private async Task CheckForUpdateAsync() var response = await HttpBridge.GetAsync(ReleaseFeed.ReleasesUrl(settings.Repository), accept: GitHubAccept); if (!response.Ok) { - Debug.WriteLine($"[FivePRS] Could not check for updates: {response.Error}"); + Log.Warn($"Could not check for updates: {response.Error}"); return; } @@ -62,7 +62,7 @@ private async Task CheckForUpdateAsync() if (_availableUpdate is null) { - if (first) Debug.WriteLine($"[FivePRS] FivePRS {installed} is up to date."); + if (first) Log.Info("No updates available."); return; } @@ -70,10 +70,10 @@ private async Task CheckForUpdateAsync() if (_announcedVersion == version) return; _announcedVersion = version; - Debug.WriteLine($"[FivePRS] FivePRS {version} is available. You have {installed}. Release notes: {_availableUpdate.Url}"); + Log.Warn($"FivePRS {version} is available (you have {installed}). Run fiveprs_update to install it."); foreach (var line in ReleaseFeed.Summary(_availableUpdate.Notes).Split('\n').Where(line => line.Length > 0)) - Debug.WriteLine($"[FivePRS] {line}"); - Debug.WriteLine("[FivePRS] Run fiveprs_update in the server console to download it."); + Log.Info($" {line}"); + Log.Info($"Release notes: {_availableUpdate.Url}"); foreach (var player in Players.Where(player => _permissions.IsAdmin(player.Handle))) NotifyUpdate(player); @@ -85,9 +85,9 @@ private void NotifyUpdate(Player player) Notify(player, $"Update | ~g~FivePRS {_availableUpdate.Version} is available.~w~ Run fiveprs_update in the server console."); } - private async Task DownloadUpdateAsync(Player? caller, string[] args) + private async Task DownloadUpdateAsync(Player? caller, string[] args) { - if (_downloadingUpdate) return "An update is already downloading."; + if (_downloadingUpdate) return CommandResult.Failed("An update is already downloading."); _downloadingUpdate = true; try @@ -95,9 +95,9 @@ private async Task DownloadUpdateAsync(Player? caller, string[] args) await CheckForUpdateAsync(); var release = _availableUpdate; - if (release is null) return $"FivePRS {InstalledVersion()} is up to date."; + if (release is null) return CommandResult.Done($"FivePRS {InstalledVersion()} is up to date."); if (release.AssetUrl is null) - return $"FivePRS {release.Version} has no {ConfigManager.Settings.Updates.AssetName} to download. Get it from {release.Url}"; + return CommandResult.Failed($"FivePRS {release.Version} has no {ConfigManager.Settings.Updates.AssetName} to download. Get it from {release.Url}"); var version = release.Version.ToString(); var resourcePath = Resource.Path; @@ -108,18 +108,18 @@ private async Task DownloadUpdateAsync(Player? caller, string[] args) Reply(caller, $"Downloading FivePRS {version}..."); var download = await HttpBridge.GetAsync(release.AssetUrl, zipRelative, check: "zip", timeoutMs: DownloadTimeoutMs); - if (!download.Ok) return $"The download failed: {download.Error}"; + if (!download.Ok) return CommandResult.Failed($"The download failed: {download.Error}"); try { var extracted = ExtractRelease(zipPath, target); - return $"FivePRS {version} is ready in {extracted}. Stop the server, copy everything in that folder over resources/fiveprs, and start it again. " + - $"Your config/ and data/ folders are not included, so your settings and database are kept. Release notes: {release.Url}"; + return CommandResult.Done($"FivePRS {version} is ready in {extracted}. Stop the server, copy everything in that folder over resources/fiveprs, and start it again. " + + $"Your config/ and data/ folders are not included, so your settings and database are kept. Release notes: {release.Url}"); } catch (Exception ex) { - return $"FivePRS {version} was downloaded to {zipPath}, but it could not be unpacked ({ex.Message}). " + - "Unpack it yourself and copy it over resources/fiveprs without its config/ and data/ folders."; + return CommandResult.Failed($"FivePRS {version} was downloaded to {zipPath}, but it could not be unpacked ({ex.Message}). " + + "Unpack it yourself and copy it over resources/fiveprs without its config/ and data/ folders."); } } finally diff --git a/FivePRS.Server/ServerBrain.cs b/FivePRS.Server/ServerBrain.cs index a7f9b07..f87e3fa 100644 --- a/FivePRS.Server/ServerBrain.cs +++ b/FivePRS.Server/ServerBrain.cs @@ -3,15 +3,18 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Threading.Tasks; using CitizenFX.Core; using CitizenFX.Core.Native; using FivePRS.Core.Config; using FivePRS.Core.Events; +using FivePRS.Core.Logging; using FivePRS.Core.Models; using FivePRS.Server.Civilian; using FivePRS.Server.Database; using FivePRS.Server.Http; +using FivePRS.Server.Logging; using FivePRS.Server.Dispatch; using FivePRS.Server.Permissions; using Newtonsoft.Json; @@ -20,6 +23,8 @@ namespace FivePRS.Server { public partial class ServerBrain : BaseScript { + private static readonly Logger Log = Logger.For("Server"); + private const int SnapshotIntervalMs = 5_000; private const int MaxCalloutCatalogJson = 200_000; @@ -34,15 +39,14 @@ public partial class ServerBrain : BaseScript public ServerBrain() { Resource.Capture(); - foreach (var name in RetiredFiles.Remove(Path.Combine(Resource.Path, "server"), warning => Debug.WriteLine($"[FivePRS] WARNING: {warning}"))) - Debug.WriteLine($"[FivePRS] Removed server/{name}, left over from an older FivePRS version."); + ServerLogging.Start(Resource.Path); + foreach (var name in RetiredFiles.Remove(Path.Combine(Resource.Path, "server"), warning => Log.Warn(warning))) + Log.Info($"Removed server/{name}, left over from an older version."); API.SetConvarServerInfo("fiveprs_version", InstalledVersion().ToString()); MainThread.Enable(); - ConfigManager.Log = message => Debug.WriteLine(message); var resource = Resource.Name; - ConfigManager.LoadSettings(API.LoadResourceFile(resource, "config/settings.json")); - ConfigManager.LoadJurisdictions(API.LoadResourceFile(resource, "config/jurisdictions.json")); - ConfigManager.LoadLicenses(API.LoadResourceFile(resource, "config/licenses.json")); + ConfigLayout.Update(Resource.Path); + ConfigManager.LoadAll(path => API.LoadResourceFile(resource, path)); _dispatch = new DispatchService( () => DateTime.UtcNow, @@ -83,6 +87,7 @@ public ServerBrain() RegisterUpdates(); RegisterDiagnostics(); RegisterStreetEvents(); + RegisterReportEvents(); RegisterDeathEvents(); _ = ServerIcon.ApplyDefaultAsync(ConfigManager.Settings.Branding.ServerIcon); @@ -105,31 +110,31 @@ private async Task InitDbAsync() var dbType = dbTypeRaw == "mysql" ? DatabaseType.MySQL : DatabaseType.SQLite; _databaseType = dbType; await _db.InitializeAsync(dbType, string.IsNullOrEmpty(connString) ? null : connString); - _civilians = new CivilianService(_db.Civilians, () => ConfigManager.Settings, () => ConfigManager.Licenses, () => DateTime.UtcNow); - _lookup = new LookupService(_db.Civilians, () => ConfigManager.Settings, () => ConfigManager.Licenses); + _civilians = new CivilianService(_db.Civilians, _db.Photos, () => ConfigManager.Settings, () => ConfigManager.Licenses, () => DateTime.UtcNow); + _lookup = new LookupService(_db.Civilians, _db.Photos, () => ConfigManager.Settings, () => ConfigManager.Licenses); _street = new StreetService(_db.Civilians, _lookup); + _reports = new ReportService(_db.Reports, _db.Civilians, _street); _appearances = new AppearanceService(_db.Civilians, _db.Appearances, () => ConfigManager.Settings.Creator); _roster = new RosterService(_db.Roster); await _roster.LoadAsync(); LogStartup(); - Debug.WriteLine("[FivePRS] ServerBrain online."); } catch (Exception ex) when (ex is NotSupportedException || ex is DatabaseSetupException) { - Debug.WriteLine($"[FivePRS] FATAL: {ex.Message}"); + Log.Error($"FivePRS could not start: {ex.Message}"); } catch (UnauthorizedAccessException ex) { - Debug.WriteLine($"[FivePRS] FATAL: {ex.Message} The server's user account needs write access to the fiveprs resource folder."); + Log.Error($"FivePRS could not start: {ex.Message} The server needs write access to the fiveprs folder."); } catch (DatabaseQueryException ex) { - Debug.WriteLine($"[FivePRS] FATAL: The database didn't start: {ex.Message}"); + Log.Error($"The database didn't start: {ex.Message}"); } catch (Exception ex) { - ServerLog.Error("FATAL: The database didn't start", ex); + Log.Error("The database didn't start", ex); } } @@ -165,7 +170,7 @@ private async void OnPlayerConnecting( } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Error loading {playerName}: {ex.Message}"); + Log.Error($"Could not load {playerName}", ex); deferrals.done("Error loading your profile. Please try again."); } } @@ -180,7 +185,11 @@ private void OnPlayerDropped([FromSource] Player player, string reason) if (license is null) return; if (_cache.TryRemove(license, out var data) && data.IsOnDuty) - _ = _db.UpdateDutyStatusAsync(license, false); + { + data.IsOnDuty = false; + EndShift(data); + _ = _db.SavePlayerAsync(data); + } _ = ForgetPositionAsync(license); } @@ -209,7 +218,7 @@ private async void OnPlayerReady([FromSource] Player player) } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Error sending profile to {player.Name}: {ex.Message}"); + Log.Error($"Could not send {player.Name}'s profile", ex); } } @@ -265,7 +274,7 @@ private async void OnEnterService([FromSource] Player player, int departmentId, } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Error entering service for {player.Name}: {ex.Message}"); + Log.Error($"Could not put {player.Name} into service", ex); TriggerClientEvent(player, EventNames.ClientEntryRejected, "Something went wrong. Please try again."); } } @@ -296,13 +305,14 @@ private static void HandOverProfile(dynamic deferrals, PlayerData data) { ["name"] = data.Name, ["rank"] = data.Rank, + ["rankName"] = data.Department == Department.None ? string.Empty : data.RankName(), ["agency"] = agency, }, }); } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Loading screen handover failed: {ex.Message}"); + Log.Warn("Loading screen handover failed", ex); } } @@ -327,7 +337,7 @@ private async void OnToggleDuty([FromSource] Player player) } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Error toggling duty for {player.Name}: {ex.Message}"); + Log.Error($"Could not change duty for {player.Name}", ex); } } @@ -342,11 +352,11 @@ private void OnRegisterCallouts([FromSource] Player player, string json) if (definitions is null) return; if (_dispatch.RegisterCallouts(definitions, ReadMaxXp()) == 0) return; - Debug.WriteLine($"[FivePRS] Dispatch catalog has {_dispatch.CatalogCount} callout(s)."); + Log.Debug($"Dispatch catalog has {_dispatch.CatalogCount} callouts."); } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Invalid callout catalog from {player.Name}: {ex.Message}"); + Log.Warn($"Invalid callout catalog from {player.Name}", ex); } } @@ -381,18 +391,26 @@ private void OnSetUnitStatus([FromSource] Player player, int status) if (!Allow(player, "dispatch", l => l.Dispatch)) return; if (!Enum.IsDefined(typeof(UnitStatus), status)) return; - if (!_dispatch.SetStatus(ServerId(player), (UnitStatus)status)) - Notify(player, "~r~Status unchanged.~w~ End your active callout first."); + var id = ServerId(player); + if (!_dispatch.UnitIds.Contains(id)) + Notify(player, "~r~You must be on duty to set your status."); + else if (!_dispatch.SetStatus(id, (UnitStatus)status)) + Notify(player, "~r~Status unchanged.~w~ End your active callout or answer your call offer first."); } private void OnAttachToCall([FromSource] Player player, string callId) { if (!Allow(player, "dispatch", l => l.Dispatch)) return; - if (!IsCallId(callId)) return; + var id = ServerId(player); + if (!_dispatch.UnitIds.Contains(id)) + { + Notify(player, "~r~You must be on duty to respond to calls."); + return; + } - if (!_dispatch.Attach(ServerId(player), callId)) + if (!IsCallId(callId) || !_dispatch.Attach(id, callId)) { - Notify(player, $"~r~Unable to attach to call #{callId}.~w~ Check the ID and your status."); + Notify(player, $"~r~Unable to attach to call #{callId}.~w~ Check the call is active and you aren't already on a call."); return; } @@ -434,6 +452,7 @@ private async Task AwardXpAsync(int unitId, int baseAmount, string callId) if (player is null || !TryGetCached(player, out _, out var data)) return; var awardedXP = (int)Math.Min(Math.Max(baseAmount * ReadXpMultiplier(), 0), ReadMaxXp()); + data.CallsCompleted++; try { @@ -443,7 +462,7 @@ private async Task AwardXpAsync(int unitId, int baseAmount, string callId) } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Error awarding XP for call #{callId}: {ex.Message}"); + Log.Error($"Could not award XP for call #{callId}", ex); } } @@ -452,14 +471,31 @@ 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, data.Callsign); + data.ShiftStartedAt = DateTime.UtcNow; + } else + { _dispatch.SetOffDuty(ServerId(player)); + EndShift(data); + } TriggerClientEvent(player, EventNames.ClientDutyStatusChanged, onDuty, (int)data.Department); - await _db.UpdateDutyStatusAsync(data.License, onDuty); + SendPlayerData(player, data); + await _db.SavePlayerAsync(data); + if (onDuty && ReportService.CanRead(data.Department)) await PushReportsAsync(data.License, null); + + Log.Info($"{data.Name} is {(onDuty ? "on" : "off")} duty."); + } + + private void EndShift(PlayerData data) + { + if (data.ShiftStartedAt is not { } started) return; - Debug.WriteLine($"[FivePRS] {data.Name} is now {(onDuty ? "ON" : "OFF")} duty."); + data.ShiftStartedAt = null; + var seconds = (long)data.DutySeconds + (long)Math.Max(0, (DateTime.UtcNow - started).TotalSeconds); + data.DutySeconds = (int)Math.Min(int.MaxValue, seconds); } private async Task ChangeDepartmentAsync(Player player, PlayerData data, Department department) @@ -475,7 +511,7 @@ private async Task ChangeDepartmentAsync(Player player, PlayerData data, Departm private async Task GrantXpAsync(Player player, PlayerData data, int amount) { - var rankedUp = data.AddXP(amount); + var rankedUp = data.Ladder().AddXp(data, amount); await _db.SavePlayerAsync(data); SendPlayerData(player, data); @@ -483,8 +519,8 @@ private async Task GrantXpAsync(Player player, PlayerData data, int amount) _dispatch.UpdateRank(ServerId(player), data.Rank); TriggerClientEvent(player, EventNames.ClientRankedUp, data.Rank); - Audit(AuditActions.RankUp, player, data.License, $"rank {data.Rank}"); - Debug.WriteLine($"[FivePRS] {data.Name} ranked up to Rank {data.Rank}."); + Audit(AuditActions.RankUp, player, data.License, $"rank {data.Rank} ({data.RankName()})"); + Log.Success($"{data.Name} was promoted to {data.RankName()}."); } private void Audit(string action, Player? actor, string? targetLicense, string details) @@ -511,7 +547,7 @@ private async Task WriteAuditAsync(AuditEntry entry) } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Failed to write audit entry '{entry.Action}': {ex.Message}"); + Log.Error($"Could not write audit entry '{entry.Action}'", ex); } } @@ -521,7 +557,7 @@ private async Task LoadProfileAsync(string license, string playerNam if (data is null) { data = new PlayerData { License = license }; - Debug.WriteLine($"[FivePRS] New player registered: {playerName} ({license})"); + Log.Info($"New player: {playerName}."); } data.Name = playerName; diff --git a/FivePRS.Server/ServerIcon.cs b/FivePRS.Server/ServerIcon.cs index 88bb9ad..e80dd96 100644 --- a/FivePRS.Server/ServerIcon.cs +++ b/FivePRS.Server/ServerIcon.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Core.Logging; using FivePRS.Server.Http; namespace FivePRS.Server @@ -11,6 +12,9 @@ internal static class ServerIcon { private const string RelativePath = "data/server_icon.png"; private const string LoadCommandAce = "command.load_server_icon"; + private const int ApplyTimeoutMs = 2_000; + + private static readonly Logger Log = Logger.For("ServerIcon"); public static async Task ApplyDefaultAsync(string url) { @@ -18,13 +22,13 @@ public static async Task ApplyDefaultAsync(string url) { await ApplyAsync(url); } - catch (UnauthorizedAccessException ex) + catch (UnauthorizedAccessException) { - Debug.WriteLine($"[FivePRS] Could not set the server icon: {ex.Message} The server's user account needs write access to the fiveprs resource folder."); + Log.Warn("Could not set the server icon. The server needs write access to the fiveprs folder."); } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Could not set the server icon: {ex.Message}"); + Log.Warn("Could not set the server icon", ex); } } @@ -41,9 +45,13 @@ private static async Task ApplyAsync(string url) var result = await HttpBridge.GetAsync(url, RelativePath, check: "png"); if (!result.Ok) { - Debug.WriteLine($"[FivePRS] Could not download the server icon from {url}: {result.Error}"); - if (!File.Exists(path)) return; - Debug.WriteLine("[FivePRS] Using the previously downloaded server icon."); + if (!File.Exists(path)) + { + Log.Warn($"Could not download the server icon: {result.Error}"); + return; + } + + Log.Debug($"Could not download the server icon ({result.Error}), using the saved copy."); } if (HasIcon()) return; @@ -51,18 +59,20 @@ private static async Task ApplyAsync(string url) var resource = Resource.Name; if (!API.IsPrincipalAceAllowed($"resource.{resource}", LoadCommandAce)) { - Debug.WriteLine( - $"[FivePRS] FivePRS is not allowed to load the server icon. Add \"add_ace resource.{resource} {LoadCommandAce} allow\" " + - "to server.cfg, or set fiveprs_server_icon false."); + Log.Warn($"Server icon skipped. Add \"add_ace resource.{resource} {LoadCommandAce} allow\" to server.cfg, or set fiveprs_server_icon false."); return; } API.ExecuteCommand($"load_server_icon \"{path.Replace('\\', '/')}\""); - await BaseScript.Delay(0); - Debug.WriteLine(HasIcon() - ? "[FivePRS] Using the FivePRS server icon. Set your own with load_server_icon in server.cfg." - : "[FivePRS] load_server_icon did not set the server icon. Check the server console for the reason."); + var deadline = API.GetGameTimer() + ApplyTimeoutMs; + while (!HasIcon() && API.GetGameTimer() < deadline) + await BaseScript.Delay(100); + + if (HasIcon()) + Log.Info("Using the FivePRS server icon."); + else + Log.Warn("load_server_icon didn't set the server icon. Check the console above for the reason."); } private static bool IsEnabled() => diff --git a/FivePRS.Server/ServerLog.cs b/FivePRS.Server/ServerLog.cs deleted file mode 100644 index 12cbca5..0000000 --- a/FivePRS.Server/ServerLog.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Reflection; -using CitizenFX.Core; -using CitizenFX.Core.Native; - -namespace FivePRS.Server -{ - internal static class ServerLog - { - private static bool _hinted; - - public static bool DebugEnabled => - string.Equals(API.GetConvar("fiveprs_debug", "false"), "true", StringComparison.OrdinalIgnoreCase); - - public static void Error(string message, Exception ex) - { - Debug.WriteLine($"[FivePRS] {message}: {Summary(ex)}"); - - if (DebugEnabled) - { - Debug.WriteLine(ex.ToString()); - } - else if (!_hinted) - { - _hinted = true; - Debug.WriteLine("[FivePRS] Set fiveprs_debug true in server.cfg to include full error details in the console."); - } - } - - public static string Summary(Exception ex) - { - while (ex is AggregateException { InnerException: not null } || ex is TargetInvocationException { InnerException: not null } || - ex is TypeInitializationException { InnerException: not null }) - { - ex = ex.InnerException!; - } - - return ex.Message; - } - } -} diff --git a/FivePRS.Tests/AppearanceTests.cs b/FivePRS.Tests/AppearanceTests.cs index 8c01fde..a6c31d0 100644 --- a/FivePRS.Tests/AppearanceTests.cs +++ b/FivePRS.Tests/AppearanceTests.cs @@ -71,7 +71,7 @@ public sealed class AppearanceServiceTests : IAsyncLifetime public async Task InitializeAsync() { _database = await TestDatabase.CreateAsync(); - _civilians = new CivilianService(_database.Store, () => new ResourceSettings(), () => new LicensesConfig(), () => new DateTime(2026, 10, 9)); + _civilians = new CivilianService(_database.Store, _database.Photos, () => new ResourceSettings(), () => new LicensesConfig(), () => new DateTime(2026, 10, 9)); _service = new AppearanceService(_database.Store, _database.Appearances, () => new CreatorSettings()); await _civilians.CreateCharacterAsync(Owner, "Jane", "Doe", "1990-01-01", "female"); } diff --git a/FivePRS.Tests/CivilianServiceTests.cs b/FivePRS.Tests/CivilianServiceTests.cs index db70803..13e1c30 100644 --- a/FivePRS.Tests/CivilianServiceTests.cs +++ b/FivePRS.Tests/CivilianServiceTests.cs @@ -24,7 +24,7 @@ public async Task InitializeAsync() { _database = await TestDatabase.CreateAsync(); _store = _database.Store; - _service = new CivilianService(_store, () => _settings, () => new LicensesConfig(), () => new DateTime(2026, 10, 9)); + _service = new CivilianService(_store, _database.Photos, () => _settings, () => new LicensesConfig(), () => new DateTime(2026, 10, 9)); } public Task DisposeAsync() diff --git a/FivePRS.Tests/IdentityTests.cs b/FivePRS.Tests/IdentityTests.cs new file mode 100644 index 0000000..a26bc1e --- /dev/null +++ b/FivePRS.Tests/IdentityTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Core.Models; +using Xunit; + +namespace FivePRS.Tests +{ + public class IdentityTests + { + [Theory] + [InlineData("")] + [InlineData("https://cdn.example.com/photo.png")] + [InlineData("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ==")] + public void Photo_AcceptsLinksMugshotsAndRemoval(string photo) + { + Assert.True(CivilianRules.TryNormalizePhoto(photo, out _)); + } + + [Theory] + [InlineData("http://example.com/photo.png")] + [InlineData("javascript:alert(1)")] + [InlineData("https://example.com/a\"onerror=\"x.png")] + [InlineData("data:image/png;base64,AAAA")] + [InlineData("data:image/jpeg;base64, + + + + diff --git a/nui/apps/characters/app.css b/nui/apps/characters/app.css new file mode 100644 index 0000000..a4d5e00 --- /dev/null +++ b/nui/apps/characters/app.css @@ -0,0 +1,364 @@ +body { + background: var(--bg); +} + +.page { + display: grid; + grid-template-columns: 19.286rem minmax(0, 1fr); + gap: 1.143rem; + height: 100%; + padding: 1.143rem 1.286rem 1.286rem; + overflow: hidden; +} + +.rail { + display: flex; + flex-direction: column; + gap: 0.714rem; + min-height: 0; +} + +.rail-head { + display: flex; + justify-content: space-between; + padding: 0 0.143rem; +} + +.rail-list { + display: flex; + flex-direction: column; + gap: 0.714rem; + overflow-y: auto; +} + +.character { + display: flex; + align-items: center; + gap: 0.857rem; + padding: 0.714rem; + border-radius: var(--radius); + border: 1px solid var(--line); + background: var(--surface); + text-align: left; + transition: border-color var(--speed); +} + +.character:hover { + border-color: var(--line-strong); +} + +.character.selected { + border-color: var(--accent); + background: var(--accent-soft); +} + +.character .photo { + width: 3.143rem; + height: 3.857rem; + flex: none; +} + +.character-text { + display: flex; + flex-direction: column; + gap: 0.143rem; + min-width: 0; +} + +.character-name { + font-size: 1.071rem; + font-weight: 600; +} + +.character-meta { + font-family: var(--font-mono); + font-size: 0.857rem; + color: var(--muted); +} + +.character-status { + font-size: 0.857rem; + color: var(--muted); +} + +.character.selected .character-status { + color: var(--green); +} + +.new-character { + display: flex; + align-items: center; + justify-content: center; + gap: 0.571rem; + min-height: 5.429rem; + border-radius: var(--radius); + border: 1px dashed #2a3850; + background: transparent; + color: var(--muted); + font-weight: 500; +} + +.new-character:hover:not(:disabled) { + color: var(--text); + border-color: var(--line-strong); +} + +.new-character:disabled { + opacity: 0.6; +} + +.main { + min-height: 0; + overflow-y: auto; +} + +.profile { + display: flex; + flex-direction: column; + gap: 1.143rem; +} + +.identity { + display: grid; + grid-template-columns: 15rem minmax(0, 1fr); + gap: 1.571rem; + padding: 1.429rem; +} + +.photo-column { + display: flex; + flex-direction: column; + gap: 0.714rem; +} + +.identity-photo { + height: 17.857rem; +} + +.photo-actions, +.photo-url-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.571rem; +} + +.photo-url { + display: flex; + flex-direction: column; + gap: 0.571rem; +} + +.identity-info { + display: flex; + flex-direction: column; + gap: 1.143rem; + min-width: 0; +} + +.identity-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.857rem; +} + +.identity-name { + margin-top: 0.286rem; + font-size: 2.857rem; +} + +.facts { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.714rem; +} + +.facts div { + padding: 0.714rem 0.857rem; + border-radius: 0.714rem; + background: var(--surface-2); + border: 1px solid #172033; +} + +.facts dd { + margin-top: 0.286rem; + font-size: 1.071rem; +} + +.tiles { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.714rem; +} + +.tile { + display: flex; + align-items: center; + gap: 0.857rem; + padding: 0.857rem; + border-radius: 0.714rem; + border: 1px solid #172033; +} + +.tile-icon { + display: grid; + place-items: center; + width: 2.571rem; + height: 2.571rem; + flex: none; + border-radius: 0.643rem; +} + +.tile-icon svg { + width: 1.286rem; + height: 1.286rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.tile-icon.amber { + background: var(--amber-soft); + color: #f59e0b; +} + +.tile-icon.grey { + background: var(--grey-soft); + color: var(--grey); +} + +.tile-icon.green { + background: var(--green-soft); + color: var(--green); +} + +.tile-icon.red { + background: var(--red-soft); + color: var(--red-text); +} + +.tile-title { + display: block; + font-weight: 600; +} + +.tile-detail { + display: block; + font-size: 0.857rem; + color: var(--muted); +} + +.identity-actions { + display: flex; + align-items: center; + gap: 0.714rem; + margin-top: auto; +} + +.identity-actions .danger { + margin-left: auto; +} + +.timeline { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.entry { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + gap: 0.857rem; +} + +.entry-dot { + width: 0.714rem; + height: 0.714rem; + margin-top: 0.357rem; + border-radius: 50%; + background: var(--accent); +} + +.entry-dot.info { + background: var(--accent); +} + +.entry-dot.bad { + background: var(--red); +} + +.entry-dot.warn { + background: var(--amber); +} + +.entry-head { + display: flex; + align-items: center; + gap: 0.571rem; +} + +.entry-type { + font-weight: 700; +} + +.entry-meta { + font-family: var(--font-mono); + font-size: 0.857rem; + color: var(--muted); +} + +.entry-text { + margin-top: 0.214rem; + color: var(--text-2); +} + +.create { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 45rem; +} + +.create .card-head { + margin-bottom: 0; +} + +.create-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.857rem; +} + +.create-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.857rem; +} + +.review { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: rgba(3, 6, 12, 0.75); +} + +.review-card { + display: flex; + flex-direction: column; + gap: 1rem; + width: 22rem; +} + +.review-photo { + height: 25rem; +} + +.review-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.571rem; +} diff --git a/nui/apps/characters/app.js b/nui/apps/characters/app.js new file mode 100644 index 0000000..6d2a061 --- /dev/null +++ b/nui/apps/characters/app.js @@ -0,0 +1,261 @@ +const form = document.getElementById("create"); +const newButton = document.getElementById("new"); +const photoUrlForm = document.getElementById("photo-url"); + +const photoWidth = 320; +const photoHeight = 400; +const maxPhotoLength = 115000; + +let view = null; +let creating = false; +let pendingCount = null; +let reviewing = null; + +function showCreate(open) { + creating = open; + form.hidden = !open; + if (open) { + form.reset(); + form.elements.firstName.focus(); + } + if (view) render(view); +} + +function characterButton(character, selected) { + const status = selected ? "Active character" : `Last played ${formatDate(character.lastUsedAt)}`; + const button = el("button", { className: `character${selected ? " selected" : ""}`, dataset: { character: character.id } }, [ + photoFrame(character.photo), + el("span", { className: "character-text" }, [ + el("span", { className: "character-name", text: `${character.firstName} ${character.lastName}` }), + el("span", { className: "character-meta", text: `${stateId(view, character.id)} · ${age(character.dateOfBirth)} · ${sexLetter(character.gender)}` }), + el("span", { className: "character-status", text: status }), + ]), + ]); + button.type = "button"; + return button; +} + +const recordDots = ["", "bad", "warn", "bad", "info"]; + +function recordEntry(entry) { + const activeWarrant = entry.type === 3 && entry.active; + const meta = [formatDate(entry.createdAt), entry.officerCallsign, entry.fine > 0 ? `$${entry.fine}` : null].filter(Boolean).join(" · "); + return el("div", { className: "entry" }, [ + el("span", { className: `entry-dot ${recordDots[entry.type] ?? ""}` }), + el("div", {}, [ + el("div", { className: "entry-head" }, [ + el("span", { className: "entry-type", text: activeWarrant ? "Active warrant" : recordTypes[entry.type] }), + el("span", { className: "entry-meta", text: meta }), + ]), + el("p", { className: "entry-text", text: entry.description }), + entry.resolution ? el("p", { className: "entry-meta", text: entry.resolution }) : null, + ]), + ]); +} + +function renderTiles(state) { + const held = (state.licenses ?? []).filter((license) => license.status === 0); + setText("tile-licenses", held.length === 1 ? "1 license" : `${held.length} licenses`); + setText("tile-licenses-detail", held.length ? held.map((license) => license.name.replace(/ License$| Permit$/, "")).join(", ") : "None held"); + + const vehicles = state.vehicles ?? []; + setText("tile-vehicles", vehicles.length === 1 ? "1 vehicle" : `${vehicles.length} vehicles`); + setText("tile-vehicles-detail", vehicles.length ? `${vehicles[0].model} · ${vehicles[0].plate}` : "None registered"); + + const records = state.records ?? []; + const warrant = records.some((entry) => entry.type === 3 && entry.active); + setText("tile-record", records.length ? `${records.length} on record` : "No records"); + setText("tile-record-detail", warrant ? "Active warrant" : records.length ? "See below" : "Nothing on file"); + document.getElementById("tile-record-icon").className = `tile-icon ${warrant ? "red" : "green"}`; + + const pillNode = document.getElementById("record-pill"); + pillNode.className = `pill ${warrant ? "bad" : records.length ? "warn" : "good"}`; + pillNode.textContent = warrant ? "Active warrant" : records.length ? "Has a record" : "Clean record"; +} + +function cropMugshot(data) { + return new Promise((resolve) => { + const image = new Image(); + image.onload = () => { + const height = image.naturalHeight * 0.62; + const width = height * (photoWidth / photoHeight); + const canvas = document.createElement("canvas"); + canvas.width = photoWidth; + canvas.height = photoHeight; + canvas.getContext("2d").drawImage(image, (image.naturalWidth - width) / 2, image.naturalHeight * 0.14, width, height, 0, 0, photoWidth, photoHeight); + + let quality = 0.85; + let result = canvas.toDataURL("image/jpeg", quality); + while (result.length > maxPhotoLength && quality > 0.4) { + quality -= 0.1; + result = canvas.toDataURL("image/jpeg", quality); + } + resolve(result); + }; + image.onerror = () => resolve(null); + image.src = data; + }); +} + +async function renderReview(mugshot) { + if (!mugshot) { + reviewing = null; + document.getElementById("review").hidden = true; + return; + } + + const key = `${mugshot.characterId}:${mugshot.data.length}`; + if (reviewing?.key === key) return; + + const photo = await cropMugshot(mugshot.data); + if (!photo) { + showToast("That mugshot couldn't be read. Try again."); + FivePRS.action("discardMugshot"); + return; + } + + reviewing = { key, characterId: mugshot.characterId, photo }; + document.getElementById("review-image").src = photo; + document.getElementById("review").hidden = false; +} + +function render(next) { + view = next; + setDepartmentIcons(next.icons); + + const genders = form.elements.gender; + if (genders.options.length === 0) { + genders.replaceChildren(...next.genders.map((gender) => el("option", { text: gender }))); + form.elements.firstName.maxLength = next.maxNameLength; + form.elements.lastName.maxLength = next.maxNameLength; + } + + const state = next.state; + document.getElementById("loading").hidden = Boolean(state); + renderReview(next.mugshot); + if (!state) return; + + if (pendingCount !== null && state.characters.length > pendingCount) { + pendingCount = null; + creating = false; + form.hidden = true; + } + + const active = activeCharacter(next); + document.getElementById("characters").replaceChildren(...state.characters.map((character) => characterButton(character, character.id === state.activeCharacterId))); + setText("slots", `${state.characters.length} / ${state.maxCharacters}`); + const atLimit = state.characters.length >= state.maxCharacters; + newButton.disabled = atLimit || creating; + setText("new-label", atLimit ? "Character limit reached" : "New character"); + + const showProfile = Boolean(active) && !creating; + document.getElementById("no-character").hidden = Boolean(active) || creating; + document.getElementById("content").hidden = !showProfile; + if (!active) return; + + setText("citizen", `Citizen of ${next.stateInfo?.name ?? "San Andreas"}`); + setText("name", `${active.firstName} ${active.lastName}`); + setText("dob", birthDate(active.dateOfBirth)); + setText("age", age(active.dateOfBirth)); + setText("sex", active.gender); + setText("state-id", stateId(next, active.id)); + document.getElementById("photo").replaceWith(Object.assign(photoFrame(active.photo, "photo board identity-photo"), { id: "photo" })); + + document.getElementById("photo-remove").hidden = !active.photo; + + const appearance = document.getElementById("appearance"); + appearance.disabled = Boolean(next.onDuty); + document.getElementById("appearance-note").hidden = !next.onDuty; + + const remove = document.getElementById("delete"); + delete remove.dataset.armed; + remove.disabled = false; + remove.textContent = "Delete"; + + renderTiles(state); + const records = state.records ?? []; + document.getElementById("records").replaceChildren(...records.map(recordEntry)); + document.getElementById("records-empty").hidden = records.length > 0; +} + +FivePRS.on("state", render); +FivePRS.on("error", () => { + pendingCount = null; +}); + +form.addEventListener("submit", (event) => { + event.preventDefault(); + const data = new FormData(form); + showToast(null); + pendingCount = view?.state?.characters.length ?? 0; + FivePRS.action("create", { + firstName: String(data.get("firstName") ?? "").trim(), + lastName: String(data.get("lastName") ?? "").trim(), + dateOfBirth: String(data.get("dateOfBirth") ?? ""), + gender: String(data.get("gender") ?? ""), + }); +}); + +newButton.addEventListener("click", () => showCreate(true)); +document.getElementById("cancel").addEventListener("click", () => showCreate(false)); + +document.getElementById("characters").addEventListener("click", (event) => { + const target = event.target.closest("[data-character]"); + if (!target) return; + if (creating) showCreate(false); + FivePRS.action("select", { characterId: Number(target.dataset.character) }); +}); + +document.getElementById("appearance").addEventListener("click", () => FivePRS.action("appearance")); + +document.getElementById("delete").addEventListener("click", (event) => { + const button = event.currentTarget; + if (!confirmButton(button, "Click again to delete")) return; + button.disabled = true; + FivePRS.action("delete", { characterId: view?.state?.activeCharacterId }); +}); + +document.getElementById("mugshot").addEventListener("click", () => { + FivePRS.action("mugshot", { characterId: view?.state?.activeCharacterId }); +}); + +document.getElementById("photo-url-toggle").addEventListener("click", () => { + photoUrlForm.hidden = !photoUrlForm.hidden; + if (!photoUrlForm.hidden) photoUrlForm.elements.url.focus(); +}); + +photoUrlForm.addEventListener("submit", (event) => { + event.preventDefault(); + const url = photoUrlForm.elements.url.value.trim(); + if (!url) return; + FivePRS.action("photoUrl", { characterId: view?.state?.activeCharacterId, url }); + photoUrlForm.hidden = true; + photoUrlForm.reset(); +}); + +document.getElementById("photo-remove").addEventListener("click", () => { + FivePRS.action("removePhoto", { characterId: view?.state?.activeCharacterId }); + photoUrlForm.hidden = true; +}); + +document.getElementById("review-use").addEventListener("click", () => { + if (!reviewing) return; + fetch(`https://${FivePRS.resource}/fiveprsPhoto`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=UTF-8" }, + body: JSON.stringify({ characterId: reviewing.characterId, photo: reviewing.photo }), + }).catch(() => {}); + FivePRS.action("discardMugshot"); + document.getElementById("review").hidden = true; +}); + +document.getElementById("review-retake").addEventListener("click", () => { + const characterId = reviewing?.characterId; + FivePRS.action("discardMugshot"); + FivePRS.action("mugshot", { characterId }); +}); + +document.getElementById("review-cancel").addEventListener("click", () => { + FivePRS.action("discardMugshot"); + document.getElementById("review").hidden = true; +}); diff --git a/nui/apps/characters/index.html b/nui/apps/characters/index.html new file mode 100644 index 0000000..74f4419 --- /dev/null +++ b/nui/apps/characters/index.html @@ -0,0 +1,123 @@ + + + + + Characters + + + + +
+ + +
+

Loading characters…

+ + + + + + +
+ + +
+ + + + + + + + + diff --git a/nui/apps/dispatch/app.css b/nui/apps/dispatch/app.css new file mode 100644 index 0000000..874b7c4 --- /dev/null +++ b/nui/apps/dispatch/app.css @@ -0,0 +1,316 @@ +body { + background: var(--bg); +} + +.page { + display: grid; + grid-template-columns: minmax(0, 1fr) 22.857rem; + gap: 1rem; + height: 100%; + padding: 1rem 1.286rem 1.286rem; + overflow: hidden; +} + +.main { + display: flex; + flex-direction: column; + gap: 0.857rem; + min-height: 0; + min-width: 0; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.me { + display: flex; + align-items: center; + gap: 0.857rem; +} + +.me .dept-icon { + width: 2.571rem; + height: 2.571rem; +} + +.me-callsign { + font-family: var(--font-mono); + font-size: 1.286rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.status-control { + display: flex; + align-items: center; + gap: 0.857rem; +} + +.status-control .muted { + font-size: 0.857rem; + text-align: right; +} + +.offer, +.active { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + padding: 1.143rem; +} + +.offer { + background: var(--amber-soft); + border-color: #4a3410; +} + +.active { + background: #0d1a2f; + border-color: #1e3a66; +} + +.call-main { + display: flex; + flex-direction: column; + gap: 0.429rem; + min-width: 0; +} + +.call-tags { + display: flex; + align-items: center; + gap: 0.571rem; +} + +.code { + padding: 0.143rem 0.571rem; + border-radius: 0.429rem; + font-family: var(--font-mono); + font-size: 0.857rem; + font-weight: 600; + background: var(--grey-soft); + color: var(--text-2); +} + +.code.c3 { + background: var(--red-soft); + color: #fca5a5; +} + +.code.c2 { + background: var(--amber-soft); + color: var(--amber); +} + +.code.c1 { + background: var(--blue-soft); + color: var(--blue); +} + +.offer .code.c2 { + background: rgba(0, 0, 0, 0.25); +} + +.active-tag { + color: var(--accent-text); +} + +.countdown { + margin-left: auto; + color: var(--amber); +} + +.call-name { + font-size: 2rem; +} + +.call-text { + color: var(--text-2); +} + +.offer-track { + height: 0.286rem; + margin-top: 0.286rem; + border-radius: 0.286rem; + background: rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +.offer-bar { + height: 100%; + background: var(--amber); +} + +.call-buttons { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.571rem; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 0.429rem; + margin-top: 0.286rem; +} + +.chip { + padding: 0.214rem 0.643rem; + border-radius: 0.429rem; + background: var(--surface-3); + font-family: var(--font-mono); + font-size: 0.857rem; +} + +.calls { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + +.call-list { + display: flex; + flex-direction: column; + gap: 0.571rem; + overflow-y: auto; +} + +.call { + display: grid; + grid-template-columns: 4.286rem minmax(0, 1fr) 10.714rem auto; + align-items: center; + gap: 0.857rem; + padding: 0.857rem; + border-radius: 0.714rem; + background: var(--surface-2); + border: 1px solid transparent; +} + +.call.mine { + border-color: #1e3a66; + background: var(--accent-soft); +} + +.call-id { + font-family: var(--font-mono); + font-weight: 600; +} + +.call-body { + display: flex; + flex-direction: column; + gap: 0.143rem; + min-width: 0; +} + +.call-title { + font-weight: 600; +} + +.call-code { + margin-left: 0.429rem; + font-size: 0.857rem; + font-weight: 700; +} + +.call-code.c3 { + color: var(--red-text); +} + +.call-code.c2 { + color: var(--amber); +} + +.call-code.c1 { + color: var(--blue); +} + +.call-detail, +.call-units { + font-size: 0.857rem; + color: var(--muted); +} + +.call-units { + font-family: var(--font-mono); +} + +.call-actions { + display: flex; + justify-content: flex-end; + gap: 0.429rem; +} + +.units { + display: flex; + flex-direction: column; + min-height: 0; +} + +.unit-groups { + display: flex; + flex-direction: column; + gap: 0.429rem; + overflow-y: auto; +} + +.group-title { + margin: 0.571rem 0 0.143rem; +} + +.group-title:first-child { + margin-top: 0; +} + +.unit { + display: flex; + align-items: center; + gap: 0.714rem; + padding: 0.571rem 0.714rem; + border-radius: 0.643rem; + background: var(--surface-2); +} + +.unit.mine { + background: var(--accent-soft); +} + +.unit .dot.available { + color: var(--green); +} + +.unit .dot.enroute { + color: var(--blue); +} + +.unit .dot.onscene, +.unit .dot.busy { + color: var(--amber); +} + +.unit-callsign { + width: 6.571rem; + font-family: var(--font-mono); + font-weight: 600; +} + +.unit-name { + flex: 1; + min-width: 0; + overflow: hidden; + color: var(--text-2); + text-overflow: ellipsis; + white-space: nowrap; +} + +.unit-extra { + font-size: 0.857rem; + color: var(--muted); + white-space: nowrap; +} diff --git a/nui/apps/dispatch/app.js b/nui/apps/dispatch/app.js new file mode 100644 index 0000000..6e89221 --- /dev/null +++ b/nui/apps/dispatch/app.js @@ -0,0 +1,172 @@ +const statusLabels = { + Available: "Available", + EnRoute: "En route", + OnScene: "On scene", + Busy: "Busy", +}; + +const unitGroups = [ + { title: "On calls", match: (unit) => unit.callId }, + { title: "Available", match: (unit) => !unit.callId && unit.status === "Available" }, + { title: "Busy", match: (unit) => !unit.callId && unit.status !== "Available" }, +]; + +let view = null; +let offerTimer = null; +let shownOffer = null; + +function button(text, className, dataset, disabled = false) { + const node = el("button", { className, text, dataset, disabled }); + node.type = "button"; + return node; +} + +function formatDistance(meters) { + if (meters === null || meters === undefined) return ""; + const miles = meters / 1609.34; + return miles < 0.1 ? "Nearby" : `${miles.toFixed(1)} mi`; +} + +function codeTag(id, code) { + const node = document.getElementById(id); + node.className = `code c${code}`; + node.textContent = `CODE ${code}`; +} + +function tickOffer() { + const offer = view?.offer; + if (!offer) return; + const remaining = Math.max(0, offer.expiresAt - Date.now()); + document.getElementById("offer-bar").style.width = `${((remaining / Math.max(1, offer.windowMs)) * 100).toFixed(1)}%`; + setText("offer-countdown", `${Math.ceil(remaining / 1000)}s`); +} + +function renderOffer(offer) { + document.getElementById("offer").hidden = !offer; + if (!offer) { + clearInterval(offerTimer); + offerTimer = null; + shownOffer = null; + return; + } + + if (shownOffer === offer.id) return; + shownOffer = offer.id; + codeTag("offer-code", offer.code); + setText("offer-title", `#${offer.id} ${offer.name}`); + setText("offer-description", [offer.description, formatDistance(offer.distanceM)].filter(Boolean).join(" · ")); + for (const node of document.querySelectorAll("#offer button")) node.disabled = false; + clearInterval(offerTimer); + tickOffer(); + offerTimer = setInterval(tickOffer, 250); +} + +function renderActive(active, self) { + document.getElementById("active").hidden = !active; + if (!active) return; + + codeTag("active-code", active.code); + setText("active-tag", `Your call · #${active.id}`); + setText("active-title", active.name); + setText("active-description", active.description || ""); + document.getElementById("active-units").replaceChildren( + ...(active.units ?? []).map((callsign) => el("span", { className: "chip", text: callsign === self.callsign ? `${callsign} · you` : callsign })) + ); + for (const node of document.querySelectorAll("#active button")) node.disabled = false; +} + +function renderSelf(self, calls) { + setDepartmentIcon("me-icon", self.department); + setText("me-callsign", self.callsign); + setText("me-detail", `${self.name} · ${self.agency} · ${self.territory ?? "Unincorporated"}`); + + const leadsCall = calls.some((call) => call.isPrimary); + for (const node of document.querySelectorAll("[data-status]")) { + node.classList.toggle("active", node.dataset.status === self.status); + node.disabled = leadsCall; + } + setText("status-hint", leadsCall ? "End your call to change status" : statusLabels[self.status] ?? self.status); +} + +function renderCalls(calls, self) { + document.getElementById("calls").replaceChildren(...calls.map((call) => { + const mine = self.callId === call.id; + const detail = call.isEmergency + ? `“${call.description}” · ${call.caller}` + : [call.territory, formatDistance(call.distanceM)].filter(Boolean).join(" · "); + + return el("div", { className: `call${mine ? " mine" : ""}` }, [ + el("span", { className: "call-id", text: `#${call.id}` }), + el("span", { className: "call-body" }, [ + el("span", {}, [ + el("span", { className: "call-title", text: call.name }), + el("span", { className: `call-code c${call.code}`, text: `CODE ${call.code}` }), + ]), + el("span", { className: "call-detail", text: detail }), + ]), + el("span", { className: "call-units", text: call.units.length ? call.units.join(", ") : "No units" }), + el("span", { className: "call-actions" }, [ + call.canClear ? button("Clear", "button small danger", { action: "clear", call: call.id }) : null, + call.hasLocation ? button("Waypoint", "button small", { action: "waypoint", call: call.id }) : null, + mine ? null : button("Attach", "button small", { action: "attach", call: call.id }, Boolean(self.callId)), + ]), + ]); + })); + + document.getElementById("calls-empty").hidden = calls.length > 0; + setText("call-count", `${calls.length} open`); +} + +function renderUnits(units, self) { + const groups = unitGroups + .map((group) => ({ title: group.title, units: units.filter(group.match) })) + .filter((group) => group.units.length > 0); + + document.getElementById("units").replaceChildren(...groups.flatMap((group) => [ + el("p", { className: "eyebrow group-title", text: group.title }), + ...group.units.map((unit) => el("div", { className: `unit${unit.serverId === self.serverId ? " mine" : ""}` }, [ + el("span", { className: `dot ${unit.status.toLowerCase()}` }), + el("span", { className: "unit-callsign", text: unit.callsign }), + el("span", { className: "unit-name", text: unit.name }), + el("span", { className: "unit-extra", text: unit.callId ? `#${unit.callId}` : unit.territory ?? "" }), + ])), + ])); + setText("unit-count", `${units.length} on duty`); +} + +function render(next) { + view = next; + setDepartmentIcons(next.icons); + const calls = next.calls ?? []; + renderSelf(next.self, calls); + renderOffer(next.offer); + renderActive(next.activeCall, next.self); + renderCalls(calls, next.self); + renderUnits(next.units ?? [], next.self); +} + +FivePRS.on("state", render); + +document.addEventListener("click", (event) => { + const target = event.target.closest("button"); + if (!target || target.disabled) return; + const action = target.dataset.action; + + if (target.dataset.status) { + FivePRS.action("status", { status: target.dataset.status }); + } else if (action === "attach" || action === "waypoint") { + FivePRS.action(action, { callId: target.dataset.call }); + } else if (action === "clear") { + target.disabled = true; + FivePRS.action("clear", { callId: target.dataset.call }); + } else if (action === "offer-accept" || action === "offer-decline") { + for (const node of document.querySelectorAll("#offer button")) node.disabled = true; + FivePRS.action(action === "offer-accept" ? "accept" : "decline"); + } else if (action === "end-call") { + target.disabled = true; + FivePRS.action("end"); + } else if (action === "backup") { + target.disabled = true; + FivePRS.action("backup", { code: 3 }); + } +}); diff --git a/nui/apps/dispatch/index.html b/nui/apps/dispatch/index.html new file mode 100644 index 0000000..cc68b76 --- /dev/null +++ b/nui/apps/dispatch/index.html @@ -0,0 +1,85 @@ + + + + + Dispatch + + + + +
+
+
+
+ +
+

+

+
+
+
+ +
+ + +
+
+
+ + + + + +
+
+

Active calls

+ +
+
+ +
+
+ + +
+ + + + + + diff --git a/nui/apps/duty/app.css b/nui/apps/duty/app.css new file mode 100644 index 0000000..a14b661 --- /dev/null +++ b/nui/apps/duty/app.css @@ -0,0 +1,262 @@ +body { + background: var(--bg); +} + +.page { + display: grid; + grid-template-columns: 24.286rem minmax(0, 1fr); + gap: 1.286rem; + height: 100%; + padding: 1.286rem; + overflow-y: auto; +} + +.side { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.profile { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.429rem; +} + +.rank { + display: flex; + align-items: center; + gap: 1rem; +} + +.insignia { + display: grid; + place-items: center; + width: 4.571rem; + height: 4.571rem; + flex: none; + border-radius: 1rem; + background: #101a30; + border: 1px solid #22355c; + color: #f2c94c; +} + +.insignia svg { + width: 2.714rem; + height: 2.714rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.rank-name { + font-size: 2.143rem; +} + +.xp-head { + display: flex; + justify-content: space-between; + font-size: 0.857rem; + color: var(--muted); +} + +.xp-track { + height: 0.571rem; + margin-top: 0.429rem; + border-radius: 0.571rem; + background: var(--line); + overflow: hidden; +} + +.xp-bar { + height: 100%; + border-radius: inherit; + background: var(--accent); + transition: width calc(var(--speed) * 3); +} + +.stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.571rem; + padding-top: 0.857rem; + border-top: 1px solid #172033; +} + +.stat { + margin-top: 0.143rem; + font-family: var(--font-mono); + font-size: 1.286rem; +} + +.steps { + display: flex; + flex-direction: column; + gap: 1.286rem; + padding: 1.429rem; +} + +.step-label { + display: block; + margin-bottom: 0.571rem; +} + +.choice-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + gap: 0.714rem; +} + +.choice-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.714rem; +} + +.choice { + display: flex; + align-items: center; + gap: 0.857rem; + min-height: 3.714rem; + padding: 0.571rem 1rem; + border-radius: 0.786rem; + border: 1px solid var(--line); + background: var(--surface-2); + color: var(--text-2); + text-align: left; +} + +.choice:hover { + border-color: var(--line-strong); +} + +.choice.selected { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--text); +} + +.choice .dept-icon { + width: 2.286rem; + height: 2.286rem; +} + +.choice-title { + display: block; + font-size: 1.071rem; + font-weight: 600; +} + +.choice-detail { + display: block; + font-size: 0.857rem; + color: var(--muted); +} + +.choice.selected .choice-detail { + color: var(--accent-text); +} + +.step-pair { + display: grid; + grid-template-columns: minmax(0, 1fr) 16rem; + gap: 1.143rem; +} + +.callsign-field { + display: flex; + flex-direction: column; +} + +.callsign { + height: 3.714rem; + font-size: 1.286rem; + text-transform: uppercase; +} + +.hint { + margin-top: 0.429rem; + font-size: 0.786rem; +} + +.station-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.571rem; + margin-top: 0.714rem; + max-height: 12rem; + overflow-y: auto; +} + +.station { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.571rem; + height: 2.857rem; + padding: 0 0.857rem; + border-radius: 0.643rem; + border: 1px solid var(--line); + background: var(--surface-2); + color: var(--text-2); + font-weight: 600; +} + +.station.selected { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--text); +} + +.station-distance { + font-family: var(--font-mono); + font-size: 0.857rem; + font-weight: 400; + color: var(--muted); +} + +.submit { + display: flex; + align-items: center; + gap: 0.857rem; + margin-top: auto; +} + +.submit-button { + margin-left: auto; + height: 3.286rem; + padding: 0 2rem; + font-size: 1.143rem; +} + +.on-duty { + display: flex; + flex-direction: column; + gap: 1.143rem; + padding: 1.714rem; +} + +.unit { + display: flex; + align-items: center; + gap: 1.143rem; +} + +.unit-callsign { + font-family: var(--font-mono); + font-size: 2.286rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.on-duty-note { + line-height: 1.5; +} + +.off-button { + align-self: flex-start; + margin-top: auto; +} diff --git a/nui/apps/duty/app.js b/nui/apps/duty/app.js new file mode 100644 index 0000000..a7ee2a2 --- /dev/null +++ b/nui/apps/duty/app.js @@ -0,0 +1,202 @@ +const enterButton = document.getElementById("enter"); +const callsignInput = document.getElementById("callsign"); + +const startOptions = [ + { id: "here", name: "Here", detail: "Gear and car now" }, + { id: "teleport", name: "Station", detail: "Teleport and gear up" }, + { id: "drive", name: "Drive there", detail: "GPS route, car waiting" }, +]; + +let view = null; +let filled = false; +let entering = false; +let toastTimer = null; +let selectedDepartment = null; +let selectedAgency = null; +let selectedStart = "here"; +let selectedStation = null; + +function departmentById(id) { + return view?.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 === view.agencyId); + selectedAgency = (current ?? agencies[0])?.id ?? null; + } +} + +function availableStations() { + return (departmentById(selectedDepartment)?.stations ?? []).filter( + (station) => station.agencies.length === 0 || !selectedAgency || station.agencies.includes(selectedAgency) + ); +} + +function formatDistance(meters) { + const miles = meters / 1609.34; + return miles < 0.1 ? "Nearby" : `${miles.toFixed(1)} mi`; +} + +function choice(title, detail, selected, dataset, icon = null) { + const button = el("button", { className: `choice${selected ? " selected" : ""}`, dataset }, [ + icon, + el("span", {}, [el("span", { className: "choice-title", text: title }), detail ? el("span", { className: "choice-detail", text: detail }) : null]), + ]); + button.type = "button"; + return button; +} + +function showToast(message) { + entering = false; + const toast = document.getElementById("toast"); + toast.textContent = message ?? ""; + toast.hidden = !message; + clearTimeout(toastTimer); + toastTimer = message ? setTimeout(() => showToast(null), 6000) : null; + if (view) renderForm(); +} + +function renderStarts() { + const stations = availableStations(); + document.getElementById("start-step").hidden = stations.length === 0; + if (stations.length === 0) selectedStart = "here"; + if (!stations.some((station) => station.id === selectedStation)) selectedStation = stations[0]?.id ?? null; + + document.getElementById("starts").replaceChildren( + ...startOptions.map((option) => choice(option.name, option.detail, option.id === selectedStart, { start: option.id })) + ); + + const list = document.getElementById("stations"); + list.hidden = selectedStart === "here"; + list.replaceChildren(...stations.map((station) => { + const button = el("button", { className: `station${station.id === selectedStation ? " selected" : ""}`, dataset: { station: station.id } }, [ + el("span", { text: station.name }), + el("span", { className: "station-distance", text: formatDistance(station.distance) }), + ]); + button.type = "button"; + return button; + })); +} + +function renderForm() { + const departments = view.departments ?? []; + document.getElementById("departments").replaceChildren( + ...departments.map((department) => + choice(department.name, null, department.id === selectedDepartment, { department: department.id }, departmentIcon(department.name)) + ) + ); + document.getElementById("no-departments").hidden = departments.length > 0; + + const agencies = departmentById(selectedDepartment)?.agencies ?? []; + const agency = agencies.find((item) => item.id === selectedAgency); + callsignInput.placeholder = `${agency?.callsignPrefix || "UNIT"}-ID`; + document.getElementById("agency-step").hidden = agencies.length === 0; + document.getElementById("agencies").replaceChildren( + ...agencies.map((item) => choice(item.name, item.callsignPrefix, item.id === selectedAgency, { agency: item.id })) + ); + + renderStarts(); + + const station = availableStations().find((item) => item.id === selectedStation); + const callsign = callsignInput.value.trim() || callsignInput.placeholder; + setText("summary", [agency?.callsignPrefix, callsign, selectedStart === "here" ? "Here" : station?.name].filter(Boolean).join(" · ")); + + enterButton.disabled = entering || selectedDepartment === null; + enterButton.textContent = entering ? "Going on duty…" : "Go on duty"; +} + +function renderProfile() { + const returning = view.departmentId !== 0 || view.rank > 1 || view.xp > 0; + setText("greeting", returning ? `Welcome back, ${view.name}` : `Welcome to FivePRS, ${view.name}`); + setText("rank-name", view.departmentId === 0 ? "Recruit" : view.rankName); + setText("rank-detail", view.departmentId === 0 ? "Choose a department to start" : `${view.department} rank ${view.rank} of ${view.rankCount}`); + + const top = view.xpToNext === 0; + setText("next-rank", top ? "Top rank" : `Next: ${view.nextRankName}`); + setText("xp", top ? `${view.xp} XP` : `${view.xp} / ${view.xpToNext} XP`); + document.getElementById("xp-bar").style.width = top ? "100%" : `${Math.min(100, (view.xp / view.xpToNext) * 100).toFixed(1)}%`; + + setText("stat-calls", view.stats?.calls ?? 0); + setText("stat-arrests", view.stats?.arrests ?? 0); + setText("stat-hours", view.stats?.hours ?? 0); +} + +function render(next) { + view = next; + setDepartmentIcons(next.icons); + renderProfile(); + + document.getElementById("off-duty").hidden = view.isOnDuty; + document.getElementById("on-duty").hidden = !view.isOnDuty; + + if (view.isOnDuty) { + setText("unit-callsign", view.unit?.callsign ?? view.callsign); + setText("unit-agency", view.unit?.agency ?? ""); + setDepartmentIcon("unit-icon", view.department); + document.getElementById("off").disabled = false; + return; + } + + if (!filled) { + filled = true; + selectedAgency = view.agencyId || null; + selectDepartment(departmentById(view.departmentId) ? view.departmentId : view.departments[0]?.id); + callsignInput.value = view.callsign ?? ""; + callsignInput.maxLength = view.callsignMaxLength ?? 12; + } else if (selectedDepartment === null || !departmentById(selectedDepartment)) { + selectDepartment(view.departments[0]?.id); + } + + renderForm(); +} + +FivePRS.on("state", render); +FivePRS.on("error", showToast); + +document.addEventListener("click", (event) => { + const target = event.target.closest("button"); + if (!target || target.disabled || !view) return; + + if (target.dataset.department) { + selectDepartment(Number(target.dataset.department)); + showToast(null); + } else if (target.dataset.agency) { + selectedAgency = target.dataset.agency; + renderForm(); + } else if (target.dataset.start) { + selectedStart = target.dataset.start; + renderForm(); + } else if (target.dataset.station) { + selectedStation = target.dataset.station; + renderForm(); + } else if (target === enterButton) { + showToast(null); + entering = true; + renderForm(); + FivePRS.action("enter", { + departmentId: selectedDepartment, + agencyId: selectedAgency ?? "", + callsign: callsignInput.value.trim(), + start: selectedStart, + stationId: selectedStation ?? "", + }); + } else if (target.id === "off") { + target.disabled = true; + FivePRS.action("off"); + } +}); + +callsignInput.addEventListener("input", () => { + const cleaned = callsignInput.value.toUpperCase().replace(/[^A-Z0-9-]/g, ""); + if (cleaned !== callsignInput.value) callsignInput.value = cleaned; + if (view) renderForm(); +}); + +callsignInput.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !enterButton.disabled) enterButton.click(); +}); diff --git a/nui/apps/duty/index.html b/nui/apps/duty/index.html new file mode 100644 index 0000000..58a14f5 --- /dev/null +++ b/nui/apps/duty/index.html @@ -0,0 +1,83 @@ + + + + + Duty + + + + +
+ + +
+
+

1 · Department

+
+ +
+ +
+
+

2 · Agency

+
+
+ +
+ +
+

4 · Start at

+
+
+
+ +
+ + +
+
+ + +
+ + + + + + + diff --git a/nui/apps/emergency/app.css b/nui/apps/emergency/app.css new file mode 100644 index 0000000..c07d890 --- /dev/null +++ b/nui/apps/emergency/app.css @@ -0,0 +1,201 @@ +body { + background: var(--bg); +} + +.page { + display: grid; + grid-template-columns: minmax(0, 1fr) 28.571rem; + gap: 1.286rem; + height: 100%; + padding: 1.286rem; + overflow-y: auto; +} + +.call-form { + display: flex; + flex-direction: column; + gap: 1.143rem; + padding: 1.571rem; +} + +.call-title { + font-size: 2.286rem; +} + +.call-location { + margin-top: 0.429rem; +} + +.call-place { + color: var(--text); +} + +.services { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + gap: 0.857rem; +} + +.service { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.571rem; + min-height: 8.571rem; + padding: 1.143rem; + border-radius: 1rem; + border: 1px solid var(--line); + background: var(--surface-2); + text-align: left; +} + +.service:hover { + border-color: var(--line-strong); +} + +.service.selected { + border: 2px solid var(--accent); + background: var(--accent-soft); +} + +.service .dept-icon { + width: 2.571rem; + height: 2.571rem; +} + +.service-name { + font-size: 1.286rem; + font-weight: 700; +} + +.service-detail { + font-size: 0.857rem; + color: var(--muted); +} + +.call-description { + font-size: 1.071rem; +} + +.call-actions { + display: flex; + align-items: center; + gap: 1rem; + margin-top: auto; +} + +.anonymous { + display: flex; + align-items: center; + gap: 0.571rem; + color: var(--text-2); +} + +.anonymous input { + width: 1.286rem; + height: 1.286rem; + accent-color: var(--accent); +} + +.call-button { + margin-left: auto; + height: 3.571rem; + padding: 0 1.857rem; + border: 0; + border-radius: 0.857rem; + background: #dc2626; + color: #ffffff; + font-size: 1.214rem; +} + +.call-button:hover:not(:disabled) { + background: #ef4444; +} + +.call-button svg { + width: 1.429rem; + height: 1.429rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linejoin: round; +} + +.side { + display: flex; + flex-direction: column; + padding: 1.429rem; +} + +.side > div { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; +} + +.steps { + display: flex; + flex-direction: column; + gap: 0.857rem; + padding-left: 1.286rem; + color: var(--text-2); + line-height: 1.5; +} + +.side-note { + margin-top: auto; + font-size: 0.857rem; +} + +.call-quote { + color: var(--text-2); + font-style: italic; +} + +.timeline { + display: flex; + flex-direction: column; + gap: 1rem; + list-style: none; +} + +.step { + display: flex; + gap: 0.857rem; +} + +.step-mark { + width: 1.571rem; + height: 1.571rem; + flex: none; + border-radius: 50%; + border: 2px solid #33435e; +} + +.step.done .step-mark { + border-color: #16a34a; + background: #16a34a; +} + +.step.current .step-mark { + border-color: var(--accent); +} + +.step-title { + display: block; + font-weight: 600; +} + +.step:not(.done):not(.current) .step-title { + color: var(--muted); +} + +.step-detail { + font-size: 0.857rem; + color: var(--muted); +} + +.cancel { + margin-top: auto; +} diff --git a/nui/apps/emergency/app.js b/nui/apps/emergency/app.js new file mode 100644 index 0000000..cacb638 --- /dev/null +++ b/nui/apps/emergency/app.js @@ -0,0 +1,128 @@ +const form = document.getElementById("form"); +const cancel = document.getElementById("cancel"); +const description = form.elements.description; + +const serviceDetails = { + police: "Crime, danger, disputes", + fire: "Fires, rescues, hazards", + ems: "Injuries, medical help", +}; + +let view = null; +let selectedService = null; +let calling = false; +let toastTimer = null; + +function showToast(message) { + calling = false; + const toast = document.getElementById("toast"); + toast.textContent = message ?? ""; + toast.hidden = !message; + clearTimeout(toastTimer); + toastTimer = message ? setTimeout(() => showToast(null), 6000) : null; + if (view) renderForm(); +} + +function time(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function renderForm() { + const services = view.departments ?? []; + if (!services.some((service) => service.id === selectedService)) selectedService = services[0]?.id ?? null; + + document.getElementById("services").replaceChildren(...services.map((service) => { + const button = el("button", { className: `service${service.id === selectedService ? " selected" : ""}`, dataset: { service: service.id } }, [ + departmentIcon(service.name), + el("span", { className: "service-name", text: service.name }), + el("span", { className: "service-detail", text: serviceDetails[service.name.toLowerCase()] ?? "" }), + ]); + button.type = "button"; + return button; + })); + + const submit = document.getElementById("submit"); + submit.disabled = calling || selectedService === null; + setText("submit-label", calling ? "Calling…" : "Call 911"); + setText("counter", `${description.value.length} / ${view.maxLength}`); +} + +function step(title, detail, state) { + return el("li", { className: `step ${state}` }, [ + el("span", { className: "step-mark" }), + el("span", {}, [el("span", { className: "step-title", text: title }), detail ? el("span", { className: "step-detail", text: detail }) : null]), + ]); +} + +function renderStatus(status) { + const service = (view.departments ?? []).find((department) => department.id === status.department); + setText("call-id", `#${status.callId} · ${service?.name ?? "Emergency"}`); + setText("call-description", `“${status.description}”`); + + const responding = status.responding > 0; + document.getElementById("timeline").replaceChildren( + step("Call received", time(status.receivedAt), "done"), + step("Units responding", responding ? `${(status.units ?? []).join(", ")} en route` : "Waiting for a unit", responding ? "done" : "current"), + step("On scene", status.onScene ? "A unit has arrived" : responding ? "Units are on the way" : "", status.onScene ? "done" : responding ? "current" : ""), + step("Cleared", "", ""), + ); + cancel.disabled = false; +} + +function render(next) { + const hadCall = view?.status != null; + view = next; + setDepartmentIcons(next.icons); + description.maxLength = next.maxLength; + setText("location", next.location || "your current position"); + + const status = next.status; + document.getElementById("active").hidden = !status; + document.getElementById("idle").hidden = Boolean(status); + + if (status) calling = false; + else if (hadCall) { + form.reset(); + showToast(null); + } + + renderForm(); + form.querySelectorAll("button, textarea, input").forEach((node) => { + if (status) node.disabled = true; + else if (node.id !== "submit") node.disabled = false; + }); + if (status) renderStatus(status); +} + +FivePRS.on("state", render); +FivePRS.on("error", showToast); + +description.addEventListener("input", () => { + if (view) setText("counter", `${description.value.length} / ${view.maxLength}`); +}); + +form.addEventListener("submit", (event) => { + event.preventDefault(); + const data = new FormData(form); + showToast(null); + calling = true; + renderForm(); + FivePRS.action("call", { + departmentId: selectedService, + description: String(data.get("description") ?? "").trim(), + anonymous: data.get("anonymous") === "on", + }); +}); + +document.getElementById("services").addEventListener("click", (event) => { + const target = event.target.closest("[data-service]"); + if (!target || target.disabled) return; + selectedService = Number(target.dataset.service); + renderForm(); +}); + +cancel.addEventListener("click", () => { + cancel.disabled = true; + FivePRS.action("cancel"); +}); diff --git a/nui/apps/emergency/index.html b/nui/apps/emergency/index.html new file mode 100644 index 0000000..5494320 --- /dev/null +++ b/nui/apps/emergency/index.html @@ -0,0 +1,61 @@ + + + + + 911 + + + + +
+
+
+

What's your emergency?

+

Your location is sent with the call:

+
+ +
+ + + +
+ + + +
+
+ + +
+ + + + + + + diff --git a/nui/apps/licenses/app.css b/nui/apps/licenses/app.css new file mode 100644 index 0000000..e6cdaa5 --- /dev/null +++ b/nui/apps/licenses/app.css @@ -0,0 +1,135 @@ +body { + background: var(--bg); +} + +.page { + height: 100%; + padding: 1.429rem 1.286rem; + overflow-y: auto; +} + +.wallet { + display: grid; + grid-template-columns: 40rem minmax(0, 1fr); + gap: 1.571rem; + align-items: start; +} + +.showcase { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.card-empty { + aspect-ratio: 85.6 / 54; + display: grid; + place-items: center; +} + +.card-actions { + display: flex; + align-items: center; + gap: 0.714rem; +} + +.card-actions .muted { + margin-left: auto; + font-size: 0.857rem; +} + +.list { + display: flex; + flex-direction: column; + gap: 0.857rem; + min-width: 0; +} + +.license-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.714rem; +} + +.license { + display: flex; + align-items: center; + gap: 0.857rem; + padding: 0.714rem; + border-radius: var(--radius); + border: 1px solid var(--line); + background: var(--surface); + text-align: left; +} + +button.license:hover { + border-color: var(--line-strong); +} + +.license.selected { + border-color: var(--accent); + background: var(--accent-soft); +} + +.mini { + display: grid; + place-items: center; + width: 4.571rem; + height: 2.857rem; + flex: none; + border-radius: 0.357rem; + background: #e5ebf3; + border-top: 0.571rem solid var(--card-color, #1d3a6e); +} + +.mini.faded { + opacity: 0.55; +} + +.mini.empty-slot { + background: transparent; + border: 1px dashed #33435e; +} + +.mini svg { + width: 1.143rem; + height: 1.143rem; + fill: none; + stroke: var(--muted); + stroke-width: 2; +} + +.license-text { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.214rem; + min-width: 0; +} + +.license-name { + font-weight: 600; +} + +.license-status { + font-size: 0.857rem; + color: var(--muted); +} + +.license-status.good { + color: var(--green); +} + +.license-status.warn { + color: var(--amber); +} + +.license-status.bad { + color: var(--red-text); +} + +.list-note { + margin-top: 0.429rem; + font-size: 0.857rem; + line-height: 1.5; +} diff --git a/nui/apps/licenses/app.js b/nui/apps/licenses/app.js new file mode 100644 index 0000000..89f2cf3 --- /dev/null +++ b/nui/apps/licenses/app.js @@ -0,0 +1,94 @@ +const statusText = ["Valid", "Suspended", "Revoked"]; +const statusTone = ["good", "warn", "bad"]; +const lockPath = "M5 11h14v10H5z M8 11V8a4 4 0 0 1 8 0v3"; + +let view = null; +let selectedType = null; + +function lockIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", lockPath); + svg.append(path); + return svg; +} + +function expiryText(license) { + if (!license.expiresAt) return "No expiry"; + const expires = new Date(license.expiresAt); + return expires < new Date() ? `Expired ${cardDate(license.expiresAt)}` : `Expires ${cardDate(license.expiresAt)}`; +} + +function licenseRow(license) { + const held = license.status !== null && license.status !== undefined; + const mini = el("span", { className: `mini${held ? "" : " empty-slot"}${held && license.status !== 0 ? " faded" : ""}` }); + if (held) mini.style.setProperty("--card-color", license.card?.color || "#1d3a6e"); + else if (!license.selfService) mini.append(lockIcon()); + + const detail = held + ? el("span", { className: `license-status ${statusTone[license.status]}`, text: license.status === 0 ? `${statusText[0]}${license.class ? ` · Class ${license.class}` : ""}` : statusText[license.status] }) + : el("span", { className: "license-status", text: license.selfService ? "Not held" : "Issued by law enforcement" }); + + const text = el("span", { className: "license-text" }, [el("span", { className: "license-name", text: license.name }), detail]); + + if (held) { + const button = el("button", { className: `license${license.type === selectedType ? " selected" : ""}`, dataset: { select: license.type } }, [mini, text]); + button.type = "button"; + return button; + } + + const row = el("div", { className: "license" }, [mini, text]); + if (license.selfService) { + const apply = el("button", { className: "button small accent", text: "Apply", dataset: { apply: license.type } }); + apply.type = "button"; + row.append(apply); + } + return row; +} + +function render(next) { + view = next; + const active = renderCivilianFrame(next); + if (!active) return; + + const licenses = next.state.licenses ?? []; + const held = licenses.filter((license) => license.status !== null && license.status !== undefined); + if (!held.some((license) => license.type === selectedType)) selectedType = (held.find((license) => license.status === 0) ?? held[0])?.type ?? null; + + document.getElementById("licenses").replaceChildren(...licenses.map(licenseRow)); + + const selected = held.find((license) => license.type === selectedType); + document.getElementById("card-empty").hidden = Boolean(selected); + document.getElementById("card-actions").hidden = !selected; + document.getElementById("card-slot").replaceChildren(...(selected + ? [licenseCard({ ...active, stateId: stateId(next, active.id), state: next.stateInfo, license: selected })] + : [])); + + if (selected) { + document.getElementById("show").disabled = selected.status !== 0; + setText("card-note", `${expiryText(selected)} · ${selected.card?.issuer || "Issued by the state"}`); + } +} + +FivePRS.on("state", render); + +document.getElementById("licenses").addEventListener("click", (event) => { + const apply = event.target.closest("[data-apply]"); + if (apply && !apply.disabled) { + apply.disabled = true; + FivePRS.action("apply", { type: apply.dataset.apply }); + return; + } + + const select = event.target.closest("[data-select]"); + if (select) { + selectedType = select.dataset.select; + render(view); + } +}); + +document.getElementById("show").addEventListener("click", () => { + if (selectedType) FivePRS.action("show", { type: selectedType }); +}); diff --git a/nui/apps/licenses/index.html b/nui/apps/licenses/index.html new file mode 100644 index 0000000..adcdc0c --- /dev/null +++ b/nui/apps/licenses/index.html @@ -0,0 +1,40 @@ + + + + + Licenses + + + + + +
+

Loading licenses…

+ + + +
+ + + + + + + + + diff --git a/nui/apps/records/app.css b/nui/apps/records/app.css new file mode 100644 index 0000000..241866d --- /dev/null +++ b/nui/apps/records/app.css @@ -0,0 +1,471 @@ +body { + background: var(--bg); +} + +.page { + display: flex; + flex-direction: column; + gap: 1rem; + height: 100%; + padding: 1rem 1.286rem 1.286rem; + overflow: hidden; +} + +.toolbar { + display: flex; + align-items: center; + gap: 0.857rem; +} + +.tab-count:not(:empty) { + margin-left: 0.286rem; + padding: 0 0.429rem; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-family: var(--font-mono); + font-size: 0.786rem; +} + +.search { + flex: 1; + display: flex; + align-items: center; + gap: 0.571rem; +} + +.search-input { + flex: 1; + max-width: 28rem; +} + +.columns { + display: grid; + grid-template-columns: 17.143rem minmax(0, 1fr) 21.429rem; + gap: 1rem; + flex: 1; + min-height: 0; +} + +.results { + display: flex; + flex-direction: column; + gap: 0.571rem; + min-height: 0; + overflow-y: auto; +} + +.stop { + display: flex; + flex-direction: column; + gap: 0.286rem; + padding: 0.857rem; + border-color: var(--accent); +} + +.stop-title { + font-family: var(--font-mono); + font-weight: 600; +} + +.stop-sub { + font-size: 0.857rem; +} + +.stop-actions, +.field-actions { + display: flex; + flex-wrap: wrap; + gap: 0.429rem; +} + +.stop-actions:not(:empty) { + margin-top: 0.429rem; +} + +.field-actions .button { + flex: 1; +} + +.result-list { + display: flex; + flex-direction: column; + gap: 0.571rem; +} + +.result { + display: flex; + align-items: center; + gap: 0.714rem; + padding: 0.571rem; + border-radius: 0.714rem; + border: 1px solid var(--line); + background: var(--surface); + text-align: left; +} + +.result:hover { + border-color: var(--line-strong); +} + +.result.selected { + border-color: var(--accent); + background: var(--accent-soft); +} + +.initials { + display: grid; + place-items: center; + width: 2.714rem; + height: 3.286rem; + flex: none; + border-radius: 0.357rem; + background: #1c2738; + font-family: var(--font-display); + font-size: 1.143rem; + font-weight: 700; + color: var(--text-2); +} + +.result-text { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.143rem; + min-width: 0; +} + +.result-name { + font-weight: 600; +} + +.result-dob { + font-family: var(--font-mono); + font-size: 0.786rem; + color: var(--muted); +} + +.flag { + padding: 0.143rem 0.5rem; + border-radius: 0.429rem; + background: var(--red-soft); + color: #fca5a5; + font-size: 0.786rem; + font-weight: 700; + letter-spacing: 0.04em; +} + +.file { + min-height: 0; + overflow-y: auto; +} + +.placeholder { + margin-top: 0; +} + +.record { + display: flex; + flex-direction: column; + gap: 0.857rem; +} + +.banner strong { + white-space: nowrap; +} + +.banner-icon { + width: 1.286rem; + height: 1.286rem; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linejoin: round; +} + +.person { + display: grid; + grid-template-columns: 8.571rem minmax(0, 1fr); + gap: 1.143rem; + padding: 1rem; +} + +.person-photo { + height: 10.429rem; +} + +.person-info { + display: flex; + flex-direction: column; + gap: 0.714rem; + min-width: 0; +} + +.person-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.714rem; +} + +.person-name { + font-size: 2rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.person-head > div { + min-width: 0; +} + +.plate-match { + margin-top: 0.286rem; + color: var(--accent-text); +} + +.flags { + display: flex; + flex-wrap: wrap; + gap: 0.429rem; + margin-top: auto; +} + +.license-list, +.vehicle-list { + display: flex; + flex-direction: column; + gap: 0.429rem; +} + +.row { + display: flex; + align-items: center; + gap: 0.857rem; + padding: 0.571rem 0.714rem; + border-radius: 0.643rem; + background: var(--surface-2); +} + +.row.highlight { + background: var(--accent-soft); +} + +.row-main { + display: flex; + flex: 1; + align-items: center; + gap: 0.714rem; + min-width: 0; +} + +.row-title { + font-weight: 600; +} + +.row-sub { + font-family: var(--font-mono); + font-size: 0.857rem; + color: var(--muted); +} + +.row-actions { + display: flex; + gap: 0.429rem; +} + +.timeline { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.entry { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + gap: 0.857rem; +} + +.entry-dot { + width: 0.714rem; + height: 0.714rem; + margin-top: 0.357rem; + border-radius: 50%; + background: var(--accent); +} + +.entry-dot.info { + background: var(--accent); +} + +.entry-dot.bad { + background: var(--red); +} + +.entry-dot.warn { + background: var(--amber); +} + +.entry-head { + display: flex; + align-items: center; + gap: 0.571rem; +} + +.entry-type { + font-weight: 700; +} + +.entry-meta { + font-family: var(--font-mono); + font-size: 0.857rem; + color: var(--muted); +} + +.entry-text { + margin: 0.214rem 0 0.429rem; + color: var(--text-2); +} + +.entry-actions { + display: flex; + gap: 0.429rem; +} + +.entry-form { + display: flex; + flex-direction: column; + gap: 0.857rem; + min-height: 0; + overflow-y: auto; +} + +.type-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.429rem; +} + +.type-grid .button { + height: 2.429rem; +} + +.notes { + min-height: 7rem; +} + +.entry-submit { + margin-top: auto; +} + +.entry-hint { + font-size: 0.857rem; + text-align: center; +} + +[data-mode="medical"] .police-only { + display: none; +} + +[data-mode="police"] .medical-only { + display: none; +} + +.reports { + display: grid; + grid-template-columns: 21.429rem minmax(0, 1fr); + gap: 1rem; + flex: 1; + min-height: 0; +} + +.report-list { + display: flex; + flex-direction: column; + gap: 0.571rem; + min-height: 0; + overflow-y: auto; +} + +.report-search { + display: flex; + flex-direction: column; + gap: 0.571rem; + margin-top: 0.857rem; +} + +.report-search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.571rem; +} + +.report-item { + flex-direction: column; + align-items: stretch; + gap: 0.143rem; +} + +.report-item-title { + font-weight: 600; +} + +.report-item-meta { + font-size: 0.857rem; + color: var(--muted); +} + +.report-detail { + min-height: 0; + overflow-y: auto; +} + +.report { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.report-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.report-title { + margin-top: 0.286rem; + font-size: 2rem; +} + +.report-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.571rem 1.143rem; +} + +.report-facts dd { + margin-top: 0.143rem; +} + +.report-narrative { + min-height: 14rem; + resize: vertical; + line-height: 1.5; +} + +.report-text { + white-space: pre-wrap; + line-height: 1.6; +} + +.report-actions { + display: flex; + justify-content: flex-end; + gap: 0.571rem; +} + +.report-note:empty { + display: none; +} diff --git a/nui/apps/records/app.js b/nui/apps/records/app.js new file mode 100644 index 0000000..655548b --- /dev/null +++ b/nui/apps/records/app.js @@ -0,0 +1,425 @@ +const search = document.getElementById("search"); +const term = document.getElementById("term"); +const entryForm = document.getElementById("entry"); +const offence = document.getElementById("offence"); +const fine = document.getElementById("fine"); +const notes = document.getElementById("notes"); + +const recordDots = ["", "bad", "warn", "bad", "info"]; +const licenseStatus = [["Valid", "good"], ["Suspended", "warn"], ["Revoked", "bad"]]; + +let view = null; +let filled = false; +let searchMode = "name"; +let entryType = 0; +let toastTimer = null; +let shownRecordId = null; +let tab = "records"; +let reportsRequested = false; +let selectedReport = null; +let shownReportKey = null; + +const narrative = document.getElementById("report-narrative"); + +const medical = () => Boolean(view?.medical); +const defaultType = () => (medical() ? 4 : 0); + +function showToast(message) { + const toast = document.getElementById("toast"); + toast.textContent = message ?? ""; + toast.hidden = !message; + clearTimeout(toastTimer); + toastTimer = message ? setTimeout(() => showToast(null), 6000) : null; +} + +function button(text, className, dataset) { + const node = el("button", { className, text, dataset }); + node.type = "button"; + return node; +} + +function setSearchMode(mode) { + searchMode = mode; + for (const node of search.querySelectorAll("[data-mode]")) node.classList.toggle("active", node.dataset.mode === mode); + term.placeholder = mode === "plate" ? "Plate" : "First or last name"; + term.maxLength = mode === "plate" ? 8 : 49; +} + +function setEntryType(type) { + entryType = type; + for (const node of entryForm.querySelectorAll("[data-type]")) node.classList.toggle("active", Number(node.dataset.type) === type); + document.getElementById("offence-field").hidden = type !== 0 || (view?.offences ?? []).length === 0; + document.getElementById("fine-field").hidden = type !== 0; +} + +function renderOffences() { + const offences = view?.offences ?? []; + offence.replaceChildren( + el("option", { text: "Choose an offence" }), + ...offences.map((item, index) => { + const option = el("option", { text: `${item.label} · $${item.fine}` }); + option.value = String(index); + return option; + }), + ); + offence.firstChild.value = ""; +} + +function renderResults() { + const results = view.results ?? []; + const title = document.getElementById("results-title"); + title.hidden = view.results === null || view.results === undefined; + title.textContent = `${results.length} ${results.length === 1 ? "match" : "matches"} for "${view.query ?? ""}"`; + + document.getElementById("results").replaceChildren(...results.map((person) => { + const node = el("button", { className: `result${view.record?.character.id === person.id ? " selected" : ""}`, dataset: { person: person.id } }, [ + el("span", { className: "initials", text: `${person.firstName.charAt(0)}${person.lastName.charAt(0)}` }), + el("span", { className: "result-text" }, [ + el("span", { className: "result-name", text: `${person.firstName} ${person.lastName}` }), + el("span", { className: "result-dob", text: birthDate(person.dateOfBirth) }), + ]), + person.hasActiveWarrant ? el("span", { className: "flag", text: "WARRANT" }) : null, + ]); + node.type = "button"; + return node; + })); + document.getElementById("results-empty").hidden = !(view.results && results.length === 0); +} + +function renderStop() { + const stop = view.stop; + document.getElementById("stop").hidden = !stop; + if (!stop) return; + + const vehicle = stop.kind === "vehicle"; + setText("stop-title", vehicle ? `${stop.plate} · ${stop.model}` : "Person stop"); + setText("stop-sub", stop.name ? `${vehicle ? "Driver" : "Subject"}: ${stop.name}` : `${vehicle ? "Driver" : "Subject"} not identified yet`); + + const actions = []; + if (vehicle && stop.ownerId) actions.push(button("Registered owner", "button small", { person: stop.ownerId })); + else if (vehicle) { + const run = button(stop.checking ? "Running…" : "Run plate", "button small primary", { field: "stopPlate" }); + run.disabled = stop.checking || stop.plateChecked; + actions.push(run); + } + if (stop.characterId) actions.push(button(vehicle ? "Driver" : "Subject", "button small", { person: stop.characterId })); + document.getElementById("stop-actions").replaceChildren(...actions); +} + +function licenseActions(license) { + if (license.status === null || license.status === undefined) return [button("Issue", "button small", { license: license.type, status: 0 })]; + if (license.status === 0) return [button("Suspend", "button small", { license: license.type, status: 1 }), button("Revoke", "button small danger", { license: license.type, status: 2 })]; + return [button("Reinstate", "button small", { license: license.type, status: 0 })]; +} + +function renderLicenses(record) { + document.getElementById("licenses").replaceChildren(...record.licenses.map((license) => { + const held = license.status !== null && license.status !== undefined; + const [label, tone] = held ? licenseStatus[license.status] : ["Not held", ""]; + return el("div", { className: "row" }, [ + el("span", { className: "row-main" }, [ + el("span", { className: "row-title", text: license.name }), + el("span", { className: `pill ${tone}`, text: label }), + held ? el("span", { className: "row-sub", text: license.number }) : null, + ]), + el("span", { className: "row-actions" }, licenseActions(license)), + ]); + })); +} + +function renderVehicles(record) { + document.getElementById("vehicles").replaceChildren(...record.vehicles.map((vehicle) => { + const stolen = vehicle.status === 1; + return el("div", { className: `row${vehicle.plate === record.matchedPlate ? " highlight" : ""}` }, [ + el("span", { className: "row-main" }, [ + el("span", { className: "row-title mono", text: vehicle.plate }), + el("span", { text: vehicle.model }), + el("span", { className: `pill ${stolen ? "bad" : "good"}`, text: stolen ? "Stolen" : "Registered" }), + ]), + el("span", { className: "row-actions" }, [ + button(stolen ? "Mark recovered" : "Mark stolen", "button small", { vehicle: vehicle.id, stolen: String(!stolen) }), + ]), + ]); + })); + document.getElementById("vehicles-empty").hidden = record.vehicles.length > 0; +} + +function renderHistory(record) { + document.getElementById("history").replaceChildren(...record.records.map((entry) => { + const activeWarrant = entry.type === 3 && entry.active; + const meta = [formatDate(entry.createdAt), entry.officerCallsign, entry.fine > 0 ? `$${entry.fine}` : null].filter(Boolean).join(" · "); + return el("div", { className: "entry" }, [ + el("span", { className: `entry-dot ${recordDots[entry.type] ?? ""}` }), + el("div", {}, [ + el("div", { className: "entry-head" }, [ + el("span", { className: "entry-type", text: activeWarrant ? "Active warrant" : recordTypes[entry.type] }), + el("span", { className: "entry-meta", text: meta }), + ]), + el("p", { className: "entry-text", text: entry.description }), + entry.resolution ? el("p", { className: "entry-meta", text: entry.resolution }) : null, + activeWarrant ? el("div", { className: "entry-actions" }, [ + button("Mark served", "button small", { resolve: entry.id, served: "true" }), + button("Clear", "button small", { resolve: entry.id, served: "false" }), + ]) : null, + ]), + ]); + })); + document.getElementById("history-empty").hidden = record.records.length > 0; +} + +function showTab(next) { + tab = view?.medical ? next : "records"; + for (const node of document.querySelectorAll("[data-tab]")) node.classList.toggle("active", node.dataset.tab === tab); + document.getElementById("records-view").hidden = tab !== "records"; + search.hidden = tab !== "records"; + document.getElementById("reports-view").hidden = tab !== "reports"; + + if (tab === "reports" && !reportsRequested) { + reportsRequested = true; + FivePRS.action("reports"); + } +} + +function dateTime(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleString([], { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +function reportItem(report, source) { + const key = `${source}:${report.id}`; + const node = el("button", { className: `result report-item${selectedReport === key ? " selected" : ""}`, dataset: { report: key } }, [ + el("span", { className: "report-item-title", text: `${report.title} · #${report.callId}` }), + el("span", { className: "report-item-meta", text: `${report.location} · ${formatDate(report.createdAt)}` }), + ]); + node.type = "button"; + return node; +} + +function findReport(key) { + if (!key) return null; + const [source, id] = key.split(":"); + const list = source === "draft" ? view.reports?.drafts : view.reports?.results; + return (list ?? []).find((report) => report.id === Number(id)) ?? null; +} + +function fact(label, value) { + return el("div", {}, [el("dt", { className: "eyebrow", text: label }), el("dd", {}, [value])]); +} + +function renderReport(report) { + const draft = report.status === 0; + setText("report-kind", report.kind === 0 ? "Patient care report" : "Incident report"); + setText("report-title", report.title); + setText("report-meta", `Call #${report.callId} · ${dateTime(report.createdAt)}`); + + const status = document.getElementById("report-status"); + status.className = `pill ${draft ? "warn" : "good"}`; + status.textContent = draft ? "Draft" : "Filed"; + + const facts = [fact("Location", report.location), fact("Crew", report.crew || report.authorCallsign)]; + if (report.characterId) { + facts.push(fact(report.kind === 0 ? "Patient" : "Person", button(report.personName ?? "Open record", "button small", { person: report.characterId }))); + } else if (report.kind === 0) { + facts.push(fact("Patient", "Not identified")); + } + if (report.plate) facts.push(fact("Plate", el("span", { className: "mono", text: report.plate }))); + if (!draft) facts.push(fact("Filed", `${report.filedBy ?? "—"} · ${dateTime(report.filedAt)}`)); + document.getElementById("report-facts").replaceChildren(...facts); + + document.getElementById("report-editor").hidden = !draft; + document.getElementById("report-actions").hidden = !draft; + document.getElementById("report-text").hidden = draft; + setText("report-text", report.narrative); + setText("report-note", draft ? `Drafts your crew doesn't file are filed automatically ${view.autoFileMinutes} minutes after the call.` : ""); + + if (shownReportKey !== selectedReport) { + shownReportKey = selectedReport; + narrative.value = report.narrative; + narrative.maxLength = view.maxNarrative; + } + document.getElementById("report-save").disabled = false; + document.getElementById("report-file").disabled = false; +} + +function renderReports() { + const reports = view.reports ?? { drafts: [] }; + const drafts = reports.drafts ?? []; + setText("draft-count", drafts.length || ""); + document.getElementById("drafts").replaceChildren(...drafts.map((report) => reportItem(report, "draft"))); + document.getElementById("drafts-empty").hidden = drafts.length > 0; + + const results = reports.results; + document.getElementById("filed").replaceChildren(...(results ?? []).map((report) => reportItem(report, "filed"))); + document.getElementById("filed-empty").hidden = !(results && results.length === 0); + + const report = findReport(selectedReport); + if (!report) { + selectedReport = null; + shownReportKey = null; + } + document.getElementById("report-placeholder").hidden = Boolean(report); + document.getElementById("report").hidden = !report; + if (report) renderReport(report); +} + +function renderRecord(record) { + const person = record.character; + const abbreviation = view.stateInfo?.abbreviation ?? "SA"; + setText("name", `${person.lastName.toUpperCase()}, ${person.firstName.toUpperCase()}`); + setText("details", `${abbreviation}-${10000 + person.id} · DOB ${birthDate(person.dateOfBirth)} (${age(person.dateOfBirth)}) · ${String(person.gender).charAt(0)}`); + document.getElementById("photo").replaceWith(Object.assign(photoFrame(person.photo, "photo board person-photo"), { id: "photo" })); + + const plate = document.getElementById("plate-match"); + plate.hidden = !record.matchedPlate; + plate.textContent = record.matchedPlate ? `Registered owner of ${record.matchedPlate}` : ""; + + const warrant = record.records.find((entry) => entry.type === 3 && entry.active); + const caution = medical() ? record.caution : null; + document.getElementById("warrant").hidden = !(warrant || caution); + setText("warrant-title", caution ? "CAUTION" : "ACTIVE WARRANT"); + if (caution) setText("warrant-detail", caution); + else if (warrant) setText("warrant-detail", `${warrant.description} · ${warrant.officerCallsign || "on file"}, ${formatDate(warrant.createdAt)}`); + + const driver = record.licenses.find((license) => license.type === "driver"); + const flags = []; + if (driver) { + const held = driver.status !== null && driver.status !== undefined; + flags.push(el("span", { className: `pill ${held ? licenseStatus[driver.status][1] : "bad"}`, text: held ? `DL ${licenseStatus[driver.status][0].toLowerCase()}${driver.class ? ` · Class ${driver.class}` : ""}` : "No driver's license" })); + } + for (const license of record.licenses.filter((item) => item.type !== "driver" && item.status > 0)) { + flags.push(el("span", { className: `pill ${licenseStatus[license.status][1]}`, text: `${license.name} ${licenseStatus[license.status][0].toLowerCase()}` })); + } + if (record.vehicles.length) flags.push(el("span", { className: "pill", text: `${record.vehicles.length} vehicle${record.vehicles.length === 1 ? "" : "s"}` })); + if (record.vehicles.some((vehicle) => vehicle.status === 1)) flags.push(el("span", { className: "pill bad", text: "Stolen vehicle" })); + document.getElementById("flags").replaceChildren(...flags); + + renderLicenses(record); + renderVehicles(record); + renderHistory(record); + + if (shownRecordId !== person.id) { + shownRecordId = person.id; + entryForm.reset(); + setEntryType(defaultType()); + } +} + +function render(next) { + const modeChanged = Boolean(view?.medical) !== Boolean(next.medical); + view = next; + document.body.dataset.mode = next.medical ? "medical" : "police"; + setText("history-title", next.medical ? "Medical history" : "History"); + setText("history-empty", next.medical ? "No medical history on file." : "Nothing on file."); + setText("entry-title", next.medical ? "New medical entry" : "New entry"); + setText("notes-text", next.medical ? "Treatment and notes" : "Charges and notes"); + if (filled && modeChanged) setEntryType(defaultType()); + + if (!filled) { + filled = true; + fine.max = next.maxFine; + notes.maxLength = next.maxDescription; + renderOffences(); + setEntryType(defaultType()); + } + + showTab(tab); + if (next.medical) renderReports(); + renderStop(); + renderResults(); + + const record = next.record; + document.getElementById("placeholder").hidden = Boolean(record); + document.getElementById("record").hidden = !record; + document.getElementById("submit").disabled = !record; + document.getElementById("entry-hint").hidden = Boolean(record); + if (record) renderRecord(record); + else shownRecordId = null; +} + +FivePRS.on("state", render); +FivePRS.on("error", (message) => { + showToast(message); + if (view?.record) document.getElementById("submit").disabled = false; + document.getElementById("report-save").disabled = false; + document.getElementById("report-file").disabled = false; +}); + +search.addEventListener("submit", (event) => { + event.preventDefault(); + const value = term.value.trim(); + if (!value) return; + showToast(null); + if (searchMode === "plate") FivePRS.action("searchPlate", { plate: value }); + else FivePRS.action("searchName", { term: value }); +}); + +document.getElementById("report-search").addEventListener("submit", (event) => { + event.preventDefault(); + showToast(null); + FivePRS.action("reportSearch", { term: document.getElementById("report-term").value.trim(), date: document.getElementById("report-date").value }); +}); + +offence.addEventListener("change", () => { + const item = (view?.offences ?? [])[Number(offence.value)]; + if (!item) return; + fine.value = String(Math.min(item.fine, view.maxFine)); + if (!notes.value.trim()) notes.value = item.label; +}); + +entryForm.addEventListener("submit", (event) => { + event.preventDefault(); + const record = view?.record; + if (!record) return; + + document.getElementById("submit").disabled = true; + showToast(null); + shownRecordId = null; + FivePRS.action("issue", { + characterId: record.character.id, + type: entryType, + description: notes.value.trim(), + fine: entryType === 0 ? Number(fine.value || 0) : 0, + }); +}); + +document.addEventListener("click", (event) => { + const target = event.target.closest("button"); + if (!target || target.disabled || target.type === "submit") return; + const record = view?.record; + + if (target.dataset.tab) { + showTab(target.dataset.tab); + } else if (target.dataset.report) { + selectedReport = target.dataset.report; + renderReports(); + } else if (target.id === "report-save" || target.id === "report-file") { + const report = findReport(selectedReport); + if (!report) return; + target.disabled = true; + showToast(null); + FivePRS.action(target.id === "report-save" ? "reportSave" : "reportFile", { reportId: report.id, narrative: narrative.value.trim() }); + } else if (target.dataset.mode) { + setSearchMode(target.dataset.mode); + } else if (target.dataset.field) { + showToast(null); + FivePRS.action(target.dataset.field); + } else if (target.dataset.type) { + setEntryType(Number(target.dataset.type)); + } else if (target.dataset.person) { + showTab("records"); + FivePRS.action("open", { characterId: Number(target.dataset.person) }); + } else if (target.id === "back") { + FivePRS.action("back"); + } else if (target.dataset.license && record) { + target.disabled = true; + FivePRS.action("license", { characterId: record.character.id, type: target.dataset.license, status: Number(target.dataset.status) }); + } else if (target.dataset.vehicle) { + target.disabled = true; + FivePRS.action("vehicle", { vehicleId: Number(target.dataset.vehicle), stolen: target.dataset.stolen === "true" }); + } else if (target.dataset.resolve) { + target.disabled = true; + FivePRS.action("resolve", { recordId: Number(target.dataset.resolve), served: target.dataset.served === "true" }); + } +}); + +setSearchMode("name"); diff --git a/nui/apps/records/index.html b/nui/apps/records/index.html new file mode 100644 index 0000000..03e0102 --- /dev/null +++ b/nui/apps/records/index.html @@ -0,0 +1,159 @@ + + + + + Records + + + + +
+
+
+ + +
+ +
+ + + +
+ + +
+

Run a name or a plate to pull up a record.

+ + +
+ +
+

New entry

+
+ + + + +
+ + + + +

Pull up a person to add to their record.

+
+
+
+ + + + + + + + diff --git a/nui/apps/settings/app.css b/nui/apps/settings/app.css new file mode 100644 index 0000000..eb09a5f --- /dev/null +++ b/nui/apps/settings/app.css @@ -0,0 +1,178 @@ +body { + background: var(--bg); +} + +.page { + display: grid; + grid-template-columns: 14.286rem minmax(0, 1fr); + height: 100%; + overflow: hidden; +} + +.nav { + display: flex; + flex-direction: column; + gap: 0.286rem; + padding: 1rem; + border-right: 1px solid var(--line-soft); +} + +.nav-item { + height: 2.714rem; + padding: 0 0.714rem; + border: 0; + border-radius: 0.571rem; + background: transparent; + color: var(--text-2); + font-weight: 500; + text-align: left; +} + +.nav-item:hover { + background: var(--surface); +} + +.nav-item.active { + background: var(--accent-soft); + color: var(--text); + font-weight: 600; +} + +.section { + display: flex; + flex-direction: column; + gap: 1.286rem; + max-width: 64rem; + padding: 1.429rem 1.857rem; + overflow-y: auto; +} + +.wallpapers { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.857rem; + margin-top: 0.857rem; +} + +.wallpaper { + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; + border-radius: var(--radius); + border: 1px solid var(--line); + background: var(--surface); + text-align: left; +} + +.wallpaper.selected { + border: 2px solid var(--accent); +} + +.wallpaper img { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + background: var(--surface-3); +} + +.wallpaper span { + padding: 0.571rem 0.714rem; + font-weight: 600; +} + +.custom { + display: flex; + gap: 0.571rem; + margin-top: 0.857rem; +} + +.settings { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.setting { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.143rem; +} + +.setting-title { + display: block; + font-weight: 600; +} + +.setting-detail { + display: block; + margin-top: 0.143rem; + font-size: 0.857rem; + color: var(--muted); +} + +.slider { + display: grid; + grid-template-columns: 12rem minmax(0, 1fr) 3rem; +} + +.value { + text-align: right; + color: var(--muted); +} + +.bindings { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.571rem 1.714rem; +} + +.binding { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.571rem 0; + border-bottom: 1px solid var(--line-soft); +} + +.keycap { + min-width: 2.286rem; + padding: 0.214rem 0.571rem; + border: 1px solid #33435e; + border-bottom-width: 3px; + border-radius: 0.429rem; + background: var(--surface-2); + font-family: var(--font-mono); + font-size: 0.857rem; + text-align: center; +} + +.keycap.unset { + color: var(--muted); +} + +.about { + display: flex; + flex-direction: column; + gap: 0.429rem; +} + +.about-nameplate { + display: block; + align-self: flex-start; + height: 3.429rem; + width: auto; + max-width: 100%; + object-fit: contain; + margin-bottom: 0.571rem; +} + +.about-name { + font-size: 2.857rem; + letter-spacing: 0.08em; +} + +.about-accent { + color: var(--accent); +} diff --git a/nui/apps/settings/app.js b/nui/apps/settings/app.js new file mode 100644 index 0000000..26d1977 --- /dev/null +++ b/nui/apps/settings/app.js @@ -0,0 +1,100 @@ +const customForm = document.getElementById("custom"); +const customUrl = document.getElementById("custom-url"); + +let view = null; +let filled = false; +let saveTimer = null; + +function setSwitch(node, on) { + node.setAttribute("aria-checked", String(Boolean(on))); +} + +function savePreferences(changes) { + Object.assign(view.preferences, changes); + clearTimeout(saveTimer); + saveTimer = setTimeout(() => FivePRS.action("preferences", { ...view.preferences }), 150); + renderPreferences(); +} + +function renderWallpapers() { + document.getElementById("wallpapers").replaceChildren(...view.wallpapers.map((option) => { + const image = el("img"); + image.alt = ""; + image.src = option.url; + const node = el("button", { className: `wallpaper${option.value === view.wallpaper ? " selected" : ""}`, dataset: { wallpaper: option.value } }, [image, el("span", { text: option.label })]); + node.type = "button"; + return node; + })); + + customForm.hidden = !view.allowCustom; + if (!filled && view.wallpaper.startsWith("url:")) customUrl.value = view.wallpaper.slice(4); +} + +function renderPreferences() { + const prefs = view.preferences; + for (const node of document.querySelectorAll("[data-text-size]")) node.classList.toggle("active", node.dataset.textSize === prefs.textSize); + for (const node of document.querySelectorAll("[data-toggle]")) setSwitch(node, prefs[node.dataset.toggle]); + for (const node of document.querySelectorAll("[data-range]")) { + if (document.activeElement !== node) node.value = prefs[node.dataset.range]; + document.querySelector(`[data-value="${node.dataset.range}"]`).textContent = `${prefs[node.dataset.range]}%`; + } +} + +function render(next) { + view = next; + renderWallpapers(); + renderPreferences(); + + const ai = document.getElementById("ai"); + setSwitch(ai, next.aiCallouts); + ai.disabled = !next.onDuty; + document.getElementById("ai-note").hidden = next.onDuty; + + document.getElementById("bindings").replaceChildren(...next.bindings.map((binding) => el("div", { className: "binding" }, [ + el("span", { text: binding.label }), + el("span", { className: `keycap${binding.key === "Not set" ? " unset" : ""}`, text: binding.key }), + ]))); + + const nameplate = document.getElementById("nameplate"); + if (next.nameplate && nameplate.getAttribute("src") !== next.nameplate) nameplate.src = next.nameplate; + nameplate.hidden = !next.nameplate; + document.getElementById("wordmark").hidden = Boolean(next.nameplate); + setText("version", `FivePRS ${next.version}`); + setText("channel", next.preRelease ? "Public test build. Report problems at github.com/FivePRS/core/issues" : "fiveprs.org"); + filled = true; +} + +FivePRS.on("state", render); + +document.getElementById("nameplate").addEventListener("error", (event) => { + event.currentTarget.hidden = true; + document.getElementById("wordmark").hidden = false; +}); + +customForm.addEventListener("submit", (event) => { + event.preventDefault(); + const url = customUrl.value.trim(); + if (url) FivePRS.action("wallpaperUrl", { url }); +}); + +for (const node of document.querySelectorAll("[data-range]")) { + node.addEventListener("input", () => savePreferences({ [node.dataset.range]: Number(node.value) })); +} + +document.addEventListener("click", (event) => { + const target = event.target.closest("button"); + if (!target || target.disabled || !view) return; + + if (target.dataset.section) { + for (const node of document.querySelectorAll("[data-section]")) node.classList.toggle("active", node === target); + for (const name of ["appearance", "duty", "sound", "keys", "about"]) document.getElementById(`section-${name}`).hidden = name !== target.dataset.section; + } else if (target.dataset.wallpaper) { + FivePRS.action("wallpaper", { value: target.dataset.wallpaper }); + } else if (target.dataset.textSize) { + savePreferences({ textSize: target.dataset.textSize }); + } else if (target.dataset.toggle) { + savePreferences({ [target.dataset.toggle]: !view.preferences[target.dataset.toggle] }); + } else if (target.id === "ai") { + FivePRS.action("aiCallouts", { enabled: !view.aiCallouts }); + } +}); diff --git a/nui/apps/settings/index.html b/nui/apps/settings/index.html new file mode 100644 index 0000000..7f3a7cc --- /dev/null +++ b/nui/apps/settings/index.html @@ -0,0 +1,111 @@ + + + + + Settings + + + + +
+ + +
+
+

Wallpaper

+
+ +
+ +
+

Terminal

+
+ Text size +
+ + + +
+
+
+ 24-hour clock + +
+
+ Reduce motion + +
+
+
+ + + + + + + + +
+ + + + + + diff --git a/nui/apps/shared/civilian.js b/nui/apps/shared/civilian.js new file mode 100644 index 0000000..abceb30 --- /dev/null +++ b/nui/apps/shared/civilian.js @@ -0,0 +1,42 @@ +let toastTimer = null; + +function showToast(message) { + const toast = document.getElementById("toast"); + toast.textContent = message ?? ""; + toast.hidden = !message; + clearTimeout(toastTimer); + toastTimer = message ? setTimeout(() => showToast(null), 6000) : null; +} + +function confirmButton(button, label) { + if (button.dataset.armed === "true") return true; + button.dataset.armed = "true"; + button.textContent = label; + return false; +} + +function activeCharacter(view) { + const state = view.state; + return state?.characters.find((character) => character.id === state.activeCharacterId) ?? null; +} + +function stateId(view, characterId) { + return `${view.stateInfo?.abbreviation ?? "SA"}-${10000 + characterId}`; +} + +function sexLetter(gender) { + return String(gender ?? "").charAt(0).toUpperCase(); +} + +function renderCivilianFrame(view) { + setDepartmentIcons(view.icons); + const state = view.state; + const active = activeCharacter(view); + + document.getElementById("loading").hidden = Boolean(state); + document.getElementById("no-character").hidden = !state || Boolean(active); + document.getElementById("content").hidden = !active; + return active; +} + +FivePRS.on("error", showToast); diff --git a/nui/apps/vehicles/app.css b/nui/apps/vehicles/app.css new file mode 100644 index 0000000..7596214 --- /dev/null +++ b/nui/apps/vehicles/app.css @@ -0,0 +1,214 @@ +body { + background: var(--bg); +} + +.page { + height: 100%; + padding: 1.286rem; + overflow-y: auto; +} + +.garage { + display: grid; + grid-template-columns: minmax(0, 1fr) 30rem; + gap: 1.286rem; + align-items: start; +} + +.column { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; +} + +.current { + display: flex; + align-items: center; + gap: 1.143rem; + padding: 1.143rem; + border-radius: var(--radius); + background: #0d1a2f; + border: 1px solid #1e3a66; +} + +.current.idle { + background: var(--surface); + border-color: var(--line); +} + +.current-icon { + display: grid; + place-items: center; + width: 3.714rem; + height: 3.714rem; + flex: none; + border-radius: 0.857rem; + background: #13284a; + color: var(--accent-text); +} + +.current.idle .current-icon { + background: var(--grey-soft); + color: var(--muted); +} + +.current-icon svg { + width: 2rem; + height: 2rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.current-text { + flex: 1; + min-width: 0; +} + +.current-eyebrow { + color: var(--accent-text); +} + +.current-title { + margin-top: 0.143rem; + font-size: 1.286rem; + font-weight: 600; +} + +.list-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.286rem; +} + +.vehicle-list { + display: flex; + flex-direction: column; + gap: 0.714rem; +} + +.vehicle { + display: grid; + grid-template-columns: 10.714rem minmax(0, 1fr) auto; + align-items: center; + gap: 1.143rem; + padding: 1rem; + border-radius: var(--radius); + border: 1px solid var(--line); + background: var(--surface); + text-align: left; +} + +.vehicle:hover { + border-color: var(--line-strong); +} + +.vehicle.selected { + border-color: var(--accent); + background: var(--accent-soft); +} + +.plate { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 4.571rem; + border-radius: 0.571rem; + background: #f4f6f8; + border: 3px solid #1d3a6e; + color: #1d3a6e; +} + +.plate-state { + font-family: var(--font-display); + font-size: 0.786rem; + font-weight: 700; + letter-spacing: 0.2em; +} + +.plate-number { + font-family: var(--font-mono); + font-size: 1.571rem; + font-weight: 600; + color: #b91c1c; +} + +.vehicle-name { + display: block; + font-size: 1.214rem; + font-weight: 600; +} + +.vehicle-detail { + color: var(--muted); +} + +.slip { + padding: 1.286rem; + border-radius: 0.857rem; + background: #f1ede2; + color: #1f2937; + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.4); +} + +.slip-head { + display: flex; + align-items: baseline; + justify-content: space-between; + padding-bottom: 0.571rem; + border-bottom: 2px solid #1d3a6e; +} + +.slip-title { + font-family: var(--font-display); + font-size: 1.286rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #1d3a6e; +} + +.slip-issuer { + font-family: var(--font-mono); + font-size: 0.786rem; + color: #64748b; +} + +.slip-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.714rem 1rem; + margin-top: 0.857rem; + font-size: 0.929rem; +} + +.slip-grid dt { + font-size: 0.714rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #64748b; +} + +.slip-grid dd { + margin-top: 0.143rem; + font-weight: 600; +} + +.slip-status.valid { + color: #15803d; +} + +.slip-status.stolen { + color: #b91c1c; +} + +.detail-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.571rem; +} diff --git a/nui/apps/vehicles/app.js b/nui/apps/vehicles/app.js new file mode 100644 index 0000000..489bf24 --- /dev/null +++ b/nui/apps/vehicles/app.js @@ -0,0 +1,113 @@ +const STOLEN = 1; + +let view = null; +let selectedId = null; + +function plateBadge(plate, state) { + return el("span", { className: "plate" }, [ + el("span", { className: "plate-state", text: String(state?.name ?? "San Andreas").toUpperCase() }), + el("span", { className: "plate-number", text: plate }), + ]); +} + +function vehicleRow(vehicle) { + const stolen = vehicle.status === STOLEN; + const button = el("button", { className: `vehicle${vehicle.id === selectedId ? " selected" : ""}`, dataset: { vehicle: vehicle.id } }, [ + plateBadge(vehicle.plate, view.stateInfo), + el("span", {}, [ + el("span", { className: "vehicle-name", text: vehicle.model }), + el("span", { className: "vehicle-detail", text: `${stolen ? "Reported stolen" : "Registered"} ${formatDate(vehicle.registeredAt)}` }), + ]), + el("span", { className: `pill ${stolen ? "bad" : "good"}`, text: stolen ? "Stolen" : "Registered" }), + ]); + button.type = "button"; + return button; +} + +function renderCurrent(state, current) { + const registered = current && state.vehicles.some((vehicle) => vehicle.plate === current.plate); + const full = state.vehicles.length >= state.maxVehicles; + + document.getElementById("current").classList.toggle("idle", !current); + setText("current-title", current ? `${current.model} · ${current.plate}` : "Not in a vehicle"); + setText("current-detail", !current + ? "Get in the driver's seat of a vehicle to register it." + : registered + ? "Registered to you." + : full + ? `You've reached the limit of ${state.maxVehicles} vehicles.` + : "Not registered to anyone."); + + const register = document.getElementById("register"); + register.hidden = !current || registered; + register.disabled = full; +} + +function renderDetail(active, vehicle) { + document.getElementById("detail").hidden = !vehicle; + if (!vehicle) return; + + const stolen = vehicle.status === STOLEN; + setText("slip-issuer", `${view.stateInfo?.abbreviation ?? "SA"} DMV`); + setText("slip-plate", vehicle.plate); + setText("slip-model", vehicle.model); + setText("slip-owner", `${active.firstName} ${active.lastName}`); + setText("slip-id", stateId(view, active.id)); + setText("slip-issued", cardDate(vehicle.registeredAt)); + + const status = document.getElementById("slip-status"); + status.className = `slip-status ${stolen ? "stolen" : "valid"}`; + status.textContent = stolen ? "STOLEN" : "VALID"; + + const stolenButton = document.getElementById("stolen"); + stolenButton.disabled = false; + stolenButton.textContent = stolen ? "Mark recovered" : "Report stolen"; + + const remove = document.getElementById("remove"); + delete remove.dataset.armed; + remove.disabled = false; + remove.textContent = "Remove registration"; +} + +function render(next) { + view = next; + const active = renderCivilianFrame(next); + if (!active) return; + + const state = next.state; + if (!state.vehicles.some((vehicle) => vehicle.id === selectedId)) selectedId = state.vehicles[0]?.id ?? null; + + renderCurrent(state, next.currentVehicle); + setText("count", `${state.vehicles.length} / ${state.maxVehicles}`); + document.getElementById("vehicles").replaceChildren(...state.vehicles.map(vehicleRow)); + document.getElementById("vehicles-empty").hidden = state.vehicles.length > 0; + renderDetail(active, state.vehicles.find((vehicle) => vehicle.id === selectedId)); +} + +FivePRS.on("state", render); + +document.getElementById("register").addEventListener("click", (event) => { + event.currentTarget.disabled = true; + FivePRS.action("register"); +}); + +document.getElementById("vehicles").addEventListener("click", (event) => { + const target = event.target.closest("[data-vehicle]"); + if (!target) return; + selectedId = Number(target.dataset.vehicle); + render(view); +}); + +document.getElementById("stolen").addEventListener("click", (event) => { + const vehicle = view?.state?.vehicles.find((entry) => entry.id === selectedId); + if (!vehicle) return; + event.currentTarget.disabled = true; + FivePRS.action("stolen", { vehicleId: vehicle.id, stolen: vehicle.status !== STOLEN }); +}); + +document.getElementById("remove").addEventListener("click", (event) => { + const button = event.currentTarget; + if (!selectedId || !confirmButton(button, "Click again to remove")) return; + button.disabled = true; + FivePRS.action("remove", { vehicleId: selectedId }); +}); diff --git a/nui/apps/vehicles/index.html b/nui/apps/vehicles/index.html new file mode 100644 index 0000000..a36f53a --- /dev/null +++ b/nui/apps/vehicles/index.html @@ -0,0 +1,64 @@ + + + + + Vehicles + + + + +
+

Loading vehicles…

+ + + +
+ + + + + + + + + diff --git a/nui/capture.js b/nui/capture.js new file mode 100644 index 0000000..b0a6733 --- /dev/null +++ b/nui/capture.js @@ -0,0 +1,143 @@ +const gameCapture = (() => { + const maxWidth = 1280; + const warmupFrames = 3; + + const vertexSource = ` + attribute vec2 position; + varying vec2 uv; + void main() { + uv = (position + 1.0) / 2.0; + gl_Position = vec4(position, 0.0, 1.0); + }`; + + const fragmentSource = ` + precision mediump float; + varying vec2 uv; + uniform sampler2D frame; + void main() { + gl_FragColor = vec4(texture2D(frame, uv).rgb, 1.0); + }`; + + let canvas = null; + let gl = null; + let busy = false; + + function compile(type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null; + } + + function setup() { + if (gl) return true; + + canvas = document.createElement("canvas"); + gl = canvas.getContext("webgl", { preserveDrawingBuffer: true, antialias: false, alpha: false }); + if (!gl) return false; + + const vertex = compile(gl.VERTEX_SHADER, vertexSource); + const fragment = compile(gl.FRAGMENT_SHADER, fragmentSource); + if (!vertex || !fragment) { + gl = null; + return false; + } + + const program = gl.createProgram(); + gl.attachShader(program, vertex); + gl.attachShader(program, fragment); + gl.linkProgram(program); + gl.useProgram(program); + + gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW); + const position = gl.getAttribLocation(program, "position"); + gl.enableVertexAttribArray(position); + gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); + + gl.bindTexture(gl.TEXTURE_2D, gl.createTexture()); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, 1, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, new Uint8Array(3)); + gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT); + gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); + gl.uniform1i(gl.getUniformLocation(program, "frame"), 0); + return true; + } + + function nextFrame() { + return new Promise((resolve) => { + const timer = setTimeout(resolve, 100); + requestAnimationFrame(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + + function draw() { + const width = Math.max(1, Math.round(window.innerWidth * (window.devicePixelRatio || 1))); + const height = Math.max(1, Math.round(window.innerHeight * (window.devicePixelRatio || 1))); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + + gl.viewport(0, 0, width, height); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + function isBlank(source) { + const probe = document.createElement("canvas"); + probe.width = 16; + probe.height = 16; + const context = probe.getContext("2d"); + context.drawImage(source, 0, 0, 16, 16); + const pixels = context.getImageData(0, 0, 16, 16).data; + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i] > 8 || pixels[i + 1] > 8 || pixels[i + 2] > 8) return false; + } + return true; + } + + function encode(quality) { + const scale = Math.min(1, maxWidth / canvas.width); + const output = document.createElement("canvas"); + output.width = Math.round(canvas.width * scale); + output.height = Math.round(canvas.height * scale); + output.getContext("2d").drawImage(canvas, 0, 0, output.width, output.height); + return output.toDataURL("image/jpeg", quality); + } + + async function capture(quality = 0.9) { + if (busy || !setup()) return null; + busy = true; + + try { + for (let i = 0; i < warmupFrames; i++) { + await nextFrame(); + draw(); + } + + return isBlank(canvas) ? null : encode(quality); + } catch { + return null; + } finally { + busy = false; + } + } + + window.addEventListener("message", async (event) => { + const id = event.data?.fiveprsCapture; + if (typeof id !== "number") return; + + const data = await capture(); + post("fiveprsCaptured", { id, data: data ?? "" }); + }); + + return { capture }; +})(); diff --git a/nui/cards.css b/nui/cards.css new file mode 100644 index 0000000..8875ec5 --- /dev/null +++ b/nui/cards.css @@ -0,0 +1,198 @@ +.idcard { + --card-color: #1d3a6e; + --card-accent: #f2c94c; + --card-image: none; + position: relative; + width: 40rem; + aspect-ratio: 85.6 / 54; + overflow: hidden; + border-radius: 1.143rem; + color: #14213a; + background: + var(--card-image) center / cover no-repeat, + radial-gradient(circle at 78% 62%, rgba(70, 110, 170, 0.16) 0 30%, transparent 31%), + repeating-radial-gradient(circle at 78% 62%, #e9eef5 0 5px, #e2e9f2 5px 6px); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.45); + user-select: none; +} + +.idcard-head { + display: flex; + align-items: center; + gap: 0.714rem; + height: 16%; + padding: 0 1.571rem; + background: var(--card-color); + color: #ffffff; +} + +.idcard-seal { + display: grid; + place-items: center; + width: 2.429rem; + height: 2.429rem; + flex: none; + border-radius: 50%; + border: 2px solid var(--card-accent); + color: var(--card-accent); + font-family: var(--font-display); + font-size: 0.929rem; + font-weight: 700; +} + +.idcard-state { + font-family: var(--font-display); + font-size: 1.857rem; + font-weight: 700; + letter-spacing: 0.12em; +} + +.idcard-title { + margin-left: auto; + font-family: var(--font-display); + font-size: 1.214rem; + font-weight: 600; + letter-spacing: 0.16em; + color: var(--card-accent); + text-align: right; +} + +.idcard-body { + display: grid; + grid-template-columns: 27% minmax(0, 1fr); + gap: 1.286rem; + padding: 1.143rem 1.571rem 0; +} + +.idcard-photo { + aspect-ratio: 4 / 5; + background: #b8c6d8; +} + +.idcard-info { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 0; + font-family: var(--font-mono); + font-size: 0.929rem; +} + +.idcard-label { + margin-right: 0.286rem; + font-size: 0.786rem; + color: #64748b; +} + +.idcard-value.emphasis { + font-weight: 600; + color: #c0262d; +} + +.idcard-value.large { + font-size: 1.357rem; + letter-spacing: 0.04em; +} + +.idcard-name { + display: flex; + flex-direction: column; + font-family: var(--font-display); + font-size: 1.857rem; + font-weight: 700; + line-height: 1.05; + color: #14213a; +} + +.idcard-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.286rem 0.857rem; +} + +.idcard-sign { + font-family: var(--font-sign); + font-size: 1.214rem; + color: #1c2b4a; +} + +.idcard-ghost { + position: absolute; + right: 1.571rem; + bottom: 2.143rem; + width: 3.857rem; + height: 4.714rem; + background: #c9d4e2; + opacity: 0.55; +} + +.idcard-stamp { + position: absolute; + right: 6.571rem; + bottom: 2.714rem; + padding: 0.214rem 0.857rem; + border: 2px solid currentColor; + border-radius: 0.429rem; + font-family: var(--font-display); + font-size: 1.286rem; + font-weight: 700; + letter-spacing: 0.14em; + transform: rotate(-8deg); +} + +.idcard-stamp.valid { + color: #15803d; +} + +.idcard-stamp.suspended { + color: #b45309; +} + +.idcard-stamp.revoked { + color: #b91c1c; +} + +.idcard-issuer { + position: absolute; + left: 1.571rem; + bottom: 0.714rem; + font-family: var(--font-mono); + font-size: 0.714rem; + letter-spacing: 0.08em; + color: #64748b; + text-transform: uppercase; +} + +.idcard.small { + width: 28rem; + font-size: 0.7em; +} + +.card-overlay { + position: fixed; + right: 2.286rem; + top: 50%; + display: flex; + flex-direction: column; + gap: 0.714rem; + transform: translateY(-50%); + font-size: 12px; + animation: card-in calc(var(--speed) * 2) ease-out; +} + +.card-overlay-caption { + align-self: flex-start; + padding: 0.429rem 0.857rem; + border-radius: 999px; + background: rgba(10, 15, 25, 0.9); + border: 1px solid var(--line); + color: var(--text); + font-weight: 600; +} + +@keyframes card-in { + from { + opacity: 0; + transform: translate(1.5rem, -50%); + } +} diff --git a/nui/cards.js b/nui/cards.js new file mode 100644 index 0000000..d58bb1f --- /dev/null +++ b/nui/cards.js @@ -0,0 +1,103 @@ +const silhouettePath = "M19 6a9 9 0 1 1 0 18 9 9 0 0 1 0-18z M2 44c1-10 8-16 17-16s16 6 17 16z"; + +function silhouette(color = "#4a5564") { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 38 44"); + svg.setAttribute("aria-hidden", "true"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", silhouettePath); + path.setAttribute("fill", color); + svg.append(path); + return svg; +} + +function photoFrame(photo, className = "photo") { + const frame = el("div", { className }); + frame.append(silhouette()); + if (photo) { + const image = el("img"); + image.alt = ""; + image.src = photo; + image.addEventListener("error", () => image.remove()); + frame.append(image); + } + return frame; +} + +function cardDate(value) { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return "—"; + const pad = (number) => String(number).padStart(2, "0"); + return `${pad(date.getMonth() + 1)}/${pad(date.getDate())}/${date.getFullYear()}`; +} + +function birthDate(value) { + const [year, month, day] = String(value ?? "").split("-"); + return year && month && day ? `${month}/${day}/${year}` : "—"; +} + +function cardField(label, value, emphasis) { + return el("span", { className: "idcard-field" }, [ + el("span", { className: "idcard-label", text: label }), + el("span", { className: `idcard-value${emphasis ? " emphasis" : ""}`, text: value }), + ]); +} + +const cardStamps = { 0: ["VALID", "valid"], 1: ["SUSPENDED", "suspended"], 2: ["REVOKED", "revoked"] }; + +function licenseCard(card, options = {}) { + const license = card.license; + const look = license.card ?? {}; + const holder = card; + const state = card.state ?? { name: "San Andreas", abbreviation: "SA" }; + + const root = el("div", { className: `idcard${options.small ? " small" : ""}` }); + root.style.setProperty("--card-color", look.color || "#1d3a6e"); + root.style.setProperty("--card-accent", look.accent || "#f2c94c"); + if (look.image) root.style.setProperty("--card-image", `url("${String(look.image).replace(/["\\]/g, "")}")`); + + const head = el("div", { className: "idcard-head" }, [ + el("span", { className: "idcard-seal", text: state.abbreviation }), + el("span", { className: "idcard-state", text: String(state.name).toUpperCase() }), + el("span", { className: "idcard-title", text: look.title || String(license.name).toUpperCase() }), + ]); + + const name = el("div", { className: "idcard-name" }, [ + el("span", { text: String(holder.lastName ?? "").toUpperCase() }), + el("span", { text: String(holder.firstName ?? "").toUpperCase() }), + ]); + + const fields = el("div", { className: "idcard-grid" }, [ + cardField("DOB", birthDate(holder.dateOfBirth), true), + cardField(license.class ? "CLASS" : "ID", license.class || holder.stateId || "—"), + cardField("SEX", String(holder.gender ?? "").charAt(0).toUpperCase() || "—"), + cardField("ISS", cardDate(license.issuedAt)), + ]); + + const body = el("div", { className: "idcard-body" }, [ + photoFrame(holder.photo, "photo idcard-photo"), + el("div", { className: "idcard-info" }, [ + el("div", { className: "idcard-number" }, [ + el("span", { className: "idcard-label", text: "NO" }), + el("span", { className: "idcard-value emphasis large", text: license.number }), + ]), + el("div", {}, [ + el("span", { className: "idcard-label", text: "EXP " }), + el("span", { className: "idcard-value emphasis", text: license.expiresAt ? cardDate(license.expiresAt) : "NONE" }), + ]), + name, + fields, + el("span", { className: "idcard-sign", text: `${holder.firstName ?? ""} ${holder.lastName ?? ""}`.trim() }), + ]), + ]); + + const [stampText, stampTone] = cardStamps[license.status] ?? ["", ""]; + root.append( + head, + body, + photoFrame(holder.photo, "photo idcard-ghost"), + stampText ? el("span", { className: `idcard-stamp ${stampTone}`, text: stampText }) : null, + el("span", { className: "idcard-issuer", text: look.issuer || "" }), + ); + return root; +} diff --git a/nui/civilian.js b/nui/civilian.js deleted file mode 100644 index 0529e54..0000000 --- a/nui/civilian.js +++ /dev/null @@ -1,237 +0,0 @@ -const civilianTab = document.getElementById("tab-civilian"); -const characterForm = document.getElementById("civ-form"); - -const licenseStatuses = ["Valid", "Suspended", "Revoked"]; -const vehicleStatuses = ["Valid", "Stolen"]; - -let civilianView = null; -let pendingCharacterCount = null; -let civilianErrorTimer = null; - -function pill(label, tone) { - return el("span", { className: `status status-${tone}`, text: label }); -} - -function showCivilianError(message) { - const error = document.getElementById("civ-error"); - error.textContent = message ?? ""; - error.hidden = !message; - if (civilianErrorTimer !== null) clearTimeout(civilianErrorTimer); - civilianErrorTimer = message ? setTimeout(() => showCivilianError(null), 6000) : null; - if (message) pendingCharacterCount = null; -} - -function age(dateOfBirth) { - const born = new Date(`${dateOfBirth}T00:00:00`); - const now = new Date(); - let years = now.getFullYear() - born.getFullYear(); - if (now.getMonth() < born.getMonth() || (now.getMonth() === born.getMonth() && now.getDate() < born.getDate())) years -= 1; - return years; -} - -function confirmButton(button, label) { - if (button.dataset.armed === "true") return true; - button.dataset.armed = "true"; - button.dataset.label = button.textContent; - button.textContent = label; - return false; -} - -function toggleCharacterForm(open) { - characterForm.hidden = !open; - document.getElementById("civ-new").hidden = open; - if (open) { - characterForm.reset(); - characterForm.elements.firstName.focus(); - } -} - -function renderCharacters(state) { - const items = state.characters.map((character) => - el("button", { - className: `option civ-character${character.id === state.activeCharacterId ? " selected" : ""}`, - dataset: { character: character.id }, - }, [ - el("span", { className: "option-name", text: `${character.firstName} ${character.lastName}` }), - el("span", { className: "option-detail", text: character.dateOfBirth }), - ]) - ); - - document.getElementById("civ-characters").replaceChildren(...items); - setText("civ-character-count", `${state.characters.length} / ${state.maxCharacters}`); - - const atLimit = state.characters.length >= state.maxCharacters; - const newButton = document.getElementById("civ-new"); - newButton.disabled = atLimit; - newButton.textContent = atLimit ? "Character limit reached" : "New character"; -} - -function renderLicenses(licenses) { - const rows = licenses.map((license) => { - const status = license.status === null || license.status === undefined ? null : licenseStatuses[license.status]; - let action; - if (status) action = pill(status, status === "Valid" ? "available" : status === "Suspended" ? "enroute" : "revoked"); - else if (license.selfService) action = el("button", { className: "button small", text: "Apply", dataset: { license: license.type } }); - else action = el("span", { className: "muted civ-note", text: "Issued by law enforcement" }); - - return el("div", { className: "civ-license" }, [el("span", { text: license.name }), action]); - }); - - document.getElementById("civ-licenses").replaceChildren(...rows); -} - -function renderVehicles(state, currentVehicle) { - const rows = state.vehicles.map((vehicle) => { - const stolen = vehicleStatuses[vehicle.status] === "Stolen"; - return el("tr", {}, [ - el("td", { className: "mono", text: vehicle.plate }), - el("td", { text: vehicle.model }), - el("td", {}, [stolen ? pill("Stolen", "revoked") : pill("Registered", "available")]), - el("td", {}, [ - el("div", { className: "actions" }, [ - el("button", { - className: "button small", - text: stolen ? "Mark recovered" : "Report stolen", - dataset: { action: "stolen", vehicle: vehicle.id, stolen: String(!stolen) }, - }), - el("button", { className: "button small danger", text: "Remove", dataset: { action: "remove-vehicle", vehicle: vehicle.id } }), - ]), - ]), - ]); - }); - - document.getElementById("civ-vehicles").replaceChildren(...rows); - document.getElementById("civ-vehicles-empty").hidden = state.vehicles.length > 0; - setText("civ-vehicle-count", `${state.vehicles.length} / ${state.maxVehicles}`); - - const registered = currentVehicle && state.vehicles.some((vehicle) => vehicle.plate === currentVehicle.plate); - const register = document.getElementById("civ-register"); - register.disabled = !currentVehicle || registered || state.vehicles.length >= state.maxVehicles; - setText( - "civ-current-vehicle", - !currentVehicle - ? "Get in the driver's seat of a vehicle to register it." - : registered - ? `${currentVehicle.model} (${currentVehicle.plate}) is registered to you.` - : `Current vehicle: ${currentVehicle.model} (${currentVehicle.plate})` - ); -} - -function renderOwnRecords(records) { - document.getElementById("civ-records").replaceChildren( - ...records.map((entry) => { - const activeWarrant = entry.type === 3 && entry.active; - const meta = [formatDate(entry.createdAt), entry.officerCallsign, entry.fine > 0 ? `$${entry.fine}` : null].filter(Boolean).join(" · "); - return el("div", { className: "rec-entry" }, [ - el("div", { className: "rec-entry-head" }, [ - el("span", { className: `status status-${recordTones[entry.type]}`, text: activeWarrant ? "Active warrant" : recordTypes[entry.type] }), - el("span", { className: "muted rec-entry-meta", text: meta }), - ]), - el("p", { className: "rec-entry-text", text: entry.description }), - entry.resolution ? el("p", { className: "muted rec-entry-meta", text: entry.resolution }) : null, - ]); - }) - ); - document.getElementById("civ-records-empty").hidden = records.length > 0; -} - -function renderCivilian(view, reset) { - civilianView = view; - setDepartmentIcon("civ-icon", "civilian"); - if (reset) { - toggleCharacterForm(false); - showCivilianError(null); - pendingCharacterCount = null; - } - - const genderSelect = characterForm.elements.gender; - if (genderSelect.options.length === 0) { - genderSelect.replaceChildren(...view.genders.map((gender) => el("option", { text: gender }))); - characterForm.elements.firstName.maxLength = view.maxNameLength; - characterForm.elements.lastName.maxLength = view.maxNameLength; - } - - const state = view.state; - document.getElementById("civ-loading").hidden = Boolean(state); - if (!state) { - document.getElementById("civ-empty").hidden = true; - document.getElementById("civ-profile").hidden = true; - return; - } - - if (pendingCharacterCount !== null && state.characters.length > pendingCharacterCount) { - pendingCharacterCount = null; - toggleCharacterForm(false); - } - - renderCharacters(state); - - const active = state.characters.find((character) => character.id === state.activeCharacterId); - document.getElementById("civ-empty").hidden = Boolean(active); - document.getElementById("civ-profile").hidden = !active; - const context = document.getElementById("civ-context"); - context.textContent = active ? `Active character: ${active.firstName} ${active.lastName}` : ""; - context.hidden = !active; - if (!active) return; - - setText("civ-name", `${active.firstName} ${active.lastName}`); - setText("civ-details", `Born ${active.dateOfBirth} · ${age(active.dateOfBirth)} years old · ${active.gender}`); - const appearanceButton = document.getElementById("civ-appearance"); - appearanceButton.disabled = Boolean(view.onDuty); - appearanceButton.title = view.onDuty ? "Go off duty to change your appearance" : ""; - document.getElementById("civ-appearance-note").hidden = !view.onDuty; - const deleteButton = document.getElementById("civ-delete"); - delete deleteButton.dataset.armed; - deleteButton.textContent = "Delete character"; - - renderLicenses(state.licenses); - renderVehicles(state, view.currentVehicle); - renderOwnRecords(state.records ?? []); -} - -characterForm.addEventListener("submit", (event) => { - event.preventDefault(); - const data = new FormData(characterForm); - showCivilianError(null); - pendingCharacterCount = civilianView?.state?.characters.length ?? 0; - post("civCreateCharacter", { - firstName: String(data.get("firstName") ?? "").trim(), - lastName: String(data.get("lastName") ?? "").trim(), - dateOfBirth: String(data.get("dateOfBirth") ?? ""), - gender: String(data.get("gender") ?? ""), - }); -}); - -civilianTab.addEventListener("click", (event) => { - const target = event.target.closest("button"); - if (!target || target.disabled) return; - - if (target.id === "civ-new") { - toggleCharacterForm(true); - } else if (target.dataset.action === "cancel-character") { - toggleCharacterForm(false); - } else if (target.dataset.character) { - post("civSelectCharacter", { characterId: Number(target.dataset.character) }); - } else if (target.id === "civ-appearance") { - post("civAppearance"); - } else if (target.id === "civ-delete") { - if (confirmButton(target, "Click again to delete")) { - target.disabled = true; - post("civDeleteCharacter", { characterId: civilianView?.state?.activeCharacterId }); - } - } else if (target.dataset.license) { - target.disabled = true; - post("civApplyLicense", { type: target.dataset.license }); - } else if (target.id === "civ-register") { - target.disabled = true; - post("civRegisterVehicle"); - } else if (target.dataset.action === "stolen") { - target.disabled = true; - post("civSetVehicleStolen", { vehicleId: Number(target.dataset.vehicle), stolen: target.dataset.stolen === "true" }); - } else if (target.dataset.action === "remove-vehicle") { - if (confirmButton(target, "Confirm")) { - target.disabled = true; - post("civRemoveVehicle", { vehicleId: Number(target.dataset.vehicle) }); - } - } -}); diff --git a/nui/common.js b/nui/common.js index 0e0397c..5334f77 100644 --- a/nui/common.js +++ b/nui/common.js @@ -8,44 +8,6 @@ function post(name, 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 ?? "—"; -} - -let departmentIcons = {}; - -function setDepartmentIcons(icons) { - departmentIcons = icons ?? {}; -} - -function iconImage(url, className = "dept-icon") { - if (!url) return null; - const image = el("img", { className }); - image.alt = ""; - image.src = url; - image.addEventListener("error", () => image.remove()); - return image; -} - -function departmentIcon(department, className) { - return iconImage(departmentIcons[String(department ?? "").toLowerCase()], className); -} - -function setDepartmentIcon(id, department) { - const icon = departmentIcon(department); - document.getElementById(id).replaceChildren(...(icon ? [icon] : [])); -} - const screens = {}; window.addEventListener("message", (event) => { diff --git a/nui/dispatch.js b/nui/dispatch.js deleted file mode 100644 index ac9c20b..0000000 --- a/nui/dispatch.js +++ /dev/null @@ -1,162 +0,0 @@ -const statusLabels = { - Available: "Available", - EnRoute: "En route", - OnScene: "On scene", - Busy: "Busy", -}; - -const dispatchTab = document.getElementById("tab-dispatch"); -let offerDeadline = 0; -let offerTotalMs = 0; -let offerTimer = null; - -function statusPill(status) { - return el("span", { className: `status status-${status.toLowerCase()}`, text: statusLabels[status] ?? status }); -} - -function tickOffer() { - const remaining = Math.max(0, offerDeadline - Date.now()); - const fraction = offerTotalMs > 0 ? remaining / offerTotalMs : 0; - document.getElementById("offer-bar").style.width = `${(fraction * 100).toFixed(1)}%`; - document.getElementById("offer-countdown").textContent = `${Math.ceil(remaining / 1000)}s`; - if (remaining <= 0) stopOfferTimer(); -} - -function stopOfferTimer() { - if (offerTimer !== null) clearInterval(offerTimer); - offerTimer = null; -} - -function renderCallCards(offer, activeCall) { - const offerCard = document.getElementById("offer"); - offerCard.hidden = !offer; - stopOfferTimer(); - - if (offer) { - setText("offer-title", `#${offer.id} ${offer.name} · Code ${offer.code}`); - setText("offer-description", offer.description || ""); - offerTotalMs = offer.windowMs || offer.expiresInMs; - offerDeadline = Date.now() + offer.expiresInMs; - for (const button of offerCard.querySelectorAll("button")) button.disabled = false; - tickOffer(); - offerTimer = setInterval(tickOffer, 250); - } - - const activeCard = document.getElementById("active-call"); - activeCard.hidden = !activeCall; - if (activeCall) { - setText("active-title", `#${activeCall.id} ${activeCall.name}`); - activeCard.querySelector("button").disabled = false; - } -} - -function renderSelf(self, calls) { - setText("self-callsign", self.callsign); - setText("self-name", self.name); - setText("self-agency", self.agency); - setDepartmentIcon("self-icon", self.department); - setText("self-territory", self.territory ?? "Unincorporated"); - setText("self-call", self.callId ? `#${self.callId}` : "None"); - document.getElementById("self-status").replaceChildren(statusPill(self.status)); - - const leadsCall = calls.some((call) => call.isPrimary); - for (const button of dispatchTab.querySelectorAll("[data-status]")) { - button.classList.toggle("active", button.dataset.status === self.status); - button.disabled = leadsCall; - } - document.getElementById("status-hint").textContent = leadsCall - ? "End your active call to change status." - : "En route and on scene update automatically from your position."; -} - -function renderCalls(calls, self) { - const rows = calls.map((call) => { - const mine = self.callId === call.id; - - const actions = el("div", { className: "actions" }, [ - call.canClear - ? el("button", { className: "button small danger", text: "Clear", dataset: { action: "clear", call: call.id } }) - : null, - 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: Boolean(self.callId), - }), - ]); - - return 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}` }), - call.isEmergency ? el("span", { className: "sub muted em-call-text", text: `${call.caller}: ${call.description}` }) : null, - ]), - 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]), - ]); - }); - - document.getElementById("calls").replaceChildren(...rows); - document.getElementById("calls-empty").hidden = calls.length > 0; - setText("call-count", `${calls.length} active`); -} - -function renderUnits(units, self) { - 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]), - ]) - ); - - document.getElementById("units").replaceChildren(...rows); - setText("unit-count", `${units.length} on duty`); -} - -function renderDispatch(view) { - if (!view) { - stopOfferTimer(); - return; - } - - const calls = view.calls ?? []; - renderCallCards(view.offer, view.activeCall); - renderSelf(view.self, calls); - renderCalls(calls, view.self); - renderUnits(view.units ?? [], view.self); -} - -dispatchTab.addEventListener("click", (event) => { - const target = event.target.closest("button"); - if (!target || target.disabled) return; - - 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 === "clear") { - target.disabled = true; - post("callClear", { callId: target.dataset.call }); - } else if (target.dataset.action === "waypoint") { - post("waypoint", { callId: target.dataset.call }); - } else if (target.dataset.action === "offer-accept" || target.dataset.action === "offer-decline") { - for (const button of document.querySelectorAll("#offer button")) button.disabled = true; - post(target.dataset.action === "offer-accept" ? "offerAccept" : "offerDecline"); - } else if (target.dataset.action === "end-call") { - target.disabled = true; - post("endCall"); - } -}); diff --git a/nui/duty.js b/nui/duty.js deleted file mode 100644 index 925aad7..0000000 --- a/nui/duty.js +++ /dev/null @@ -1,184 +0,0 @@ -const dutyTab = document.getElementById("tab-duty"); -const dutyEnterButton = document.getElementById("duty-enter"); -const callsignInput = document.getElementById("duty-callsign"); - -let dutyView = null; -let selectedDepartment = null; -let selectedAgency = null; -let selectedStart = "here"; -let selectedStation = null; - -const startOptions = [ - { id: "here", name: "Here", detail: "Vehicle on the nearest road" }, - { id: "teleport", name: "Station", detail: "Teleport to a station" }, - { id: "drive", name: "Drive to station", detail: "GPS route, vehicle waiting" }, -]; -let entering = false; - -function departmentById(id) { - return dutyView?.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 === dutyView.agencyId); - selectedAgency = (current ?? agencies[0])?.id ?? null; - } -} - -function availableStations() { - return (departmentById(selectedDepartment)?.stations ?? []).filter( - (station) => station.agencies.length === 0 || !selectedAgency || station.agencies.includes(selectedAgency) - ); -} - -function formatDistance(meters) { - const miles = meters / 1609.34; - return miles < 0.1 ? "Nearby" : `${miles.toFixed(1)} mi`; -} - -function renderStart() { - const stations = availableStations(); - document.getElementById("duty-start-section").hidden = stations.length === 0; - - if (stations.length === 0) selectedStart = "here"; - if (!stations.some((station) => station.id === selectedStation)) selectedStation = stations[0]?.id ?? null; - - document.getElementById("duty-start").replaceChildren( - ...startOptions.map((option) => - optionButton(option.name, option.detail, option.id === selectedStart, { start: option.id }) - ) - ); - - const stationList = document.getElementById("duty-stations"); - stationList.hidden = selectedStart === "here"; - stationList.replaceChildren( - ...stations.map((station) => - optionButton(station.name, formatDistance(station.distance), station.id === selectedStation, { station: station.id }) - ) - ); -} - -function optionButton(name, detail, selected, dataset, icon = null) { - const text = el("span", {}, [ - el("span", { className: "option-name", text: name }), - detail ? el("span", { className: "option-detail", text: detail }) : null, - ]); - return el("button", { className: `option${selected ? " selected" : ""}${icon ? " with-icon" : ""}`, dataset }, [icon, text]); -} - -function showDutyError(message) { - entering = false; - const error = document.getElementById("duty-error"); - error.textContent = message ?? ""; - error.hidden = !message; - if (dutyView) renderDutyForm(); -} - -function renderDutyForm() { - const departments = dutyView.departments ?? []; - document.getElementById("duty-departments").replaceChildren( - ...departments.map((department) => - optionButton(department.name, null, department.id === selectedDepartment, { department: department.id }, departmentIcon(department.name)) - ) - ); - - const agencies = departmentById(selectedDepartment)?.agencies ?? []; - const agency = agencies.find((item) => item.id === selectedAgency); - callsignInput.placeholder = `${agency?.callsignPrefix || "UNIT"}-ID`; - document.getElementById("duty-agency-title").hidden = agencies.length === 0; - document.getElementById("duty-agencies").replaceChildren( - ...agencies.map((item) => - optionButton(item.name, item.callsignPrefix, item.id === selectedAgency, { agency: item.id }) - ) - ); - - renderStart(); - - document.getElementById("duty-empty").hidden = departments.length > 0; - dutyEnterButton.disabled = entering || selectedDepartment === null; - dutyEnterButton.textContent = entering ? "Going on duty…" : "Go on duty"; -} - -function renderDuty(view, reset) { - dutyView = view; - const returning = view.departmentId !== 0 || view.rank > 1 || view.xp > 0; - - document.getElementById("duty-title").textContent = returning - ? `Welcome back, ${view.name}` - : `Welcome to FivePRS, ${view.name}`; - setText("duty-rank", view.rank); - setText("duty-xp", `${view.xp} / ${view.xpToNext} XP`); - document.getElementById("duty-xp-bar").style.width = - `${Math.min(100, (view.xp / Math.max(1, view.xpToNext)) * 100).toFixed(1)}%`; - - document.getElementById("duty-off-form").hidden = view.isOnDuty; - document.getElementById("duty-on-panel").hidden = !view.isOnDuty; - - if (view.isOnDuty) { - setText("duty-unit-callsign", view.unit?.callsign ?? view.callsign); - setText("duty-unit-agency", view.unit?.agency ?? ""); - setDepartmentIcon("duty-unit-icon", view.department); - document.getElementById("duty-off").disabled = false; - return; - } - - if (reset) { - entering = false; - selectedAgency = view.agencyId || null; - selectDepartment(departmentById(view.departmentId) ? view.departmentId : view.departments[0]?.id); - callsignInput.value = view.callsign ?? ""; - callsignInput.maxLength = view.callsignMaxLength ?? 12; - showDutyError(null); - } else if (selectedDepartment === null || !departmentById(selectedDepartment)) { - selectDepartment(view.departments[0]?.id); - } - - renderDutyForm(); -} - -dutyTab.addEventListener("click", (event) => { - const target = event.target.closest("button"); - if (!target || target.disabled) return; - - if (target.dataset.department) { - selectDepartment(Number(target.dataset.department)); - showDutyError(null); - } else if (target.dataset.agency) { - selectedAgency = target.dataset.agency; - renderDutyForm(); - } else if (target.dataset.start) { - selectedStart = target.dataset.start; - renderDutyForm(); - } else if (target.dataset.station) { - selectedStation = target.dataset.station; - renderDutyForm(); - } else if (target.id === "duty-enter") { - showDutyError(null); - entering = true; - renderDutyForm(); - post("dutyEnter", { - departmentId: selectedDepartment, - agencyId: selectedAgency ?? "", - callsign: callsignInput.value.trim(), - start: selectedStart, - stationId: selectedStation ?? "", - }); - } else if (target.id === "duty-off") { - target.disabled = true; - post("dutyOff"); - } -}); - -callsignInput.addEventListener("input", () => { - const cleaned = callsignInput.value.toUpperCase().replace(/[^A-Z0-9-]/g, ""); - if (cleaned !== callsignInput.value) callsignInput.value = cleaned; -}); - -callsignInput.addEventListener("keydown", (event) => { - if (event.key === "Enter" && !dutyEnterButton.disabled) dutyEnterButton.click(); -}); diff --git a/nui/emergency.js b/nui/emergency.js deleted file mode 100644 index db98acd..0000000 --- a/nui/emergency.js +++ /dev/null @@ -1,89 +0,0 @@ -const emergencyTab = document.getElementById("tab-emergency"); -const emergencyForm = document.getElementById("em-form"); - -let emergencyView = null; -let selectedService = null; -let calling = false; - -function showEmergencyError(message) { - calling = false; - const error = document.getElementById("em-error"); - error.textContent = message ?? ""; - error.hidden = !message; - if (emergencyView) renderEmergencyForm(); -} - -function renderEmergencyForm() { - const services = emergencyView.departments ?? []; - if (!services.some((service) => service.id === selectedService)) selectedService = services[0]?.id ?? null; - - document.getElementById("em-services").replaceChildren( - ...services.map((service) => { - const icon = departmentIcon(service.name); - return el("button", { - className: `option${service.id === selectedService ? " selected" : ""}${icon ? " with-icon" : ""}`, - dataset: { service: service.id }, - }, [icon, el("span", { className: "option-name", text: service.name })]); - }) - ); - - const submit = document.getElementById("em-submit"); - submit.disabled = calling || selectedService === null; - submit.textContent = calling ? "Calling…" : "Call 911"; -} - -function renderEmergency(view, reset) { - emergencyView = view; - emergencyForm.elements.description.maxLength = view.maxLength; - - const status = view.status; - const hadCall = !document.getElementById("em-active").hidden; - document.getElementById("em-active").hidden = !status; - emergencyForm.hidden = Boolean(status); - - if (status) { - calling = false; - const service = (view.departments ?? []).find((department) => department.id === status.department); - setText("em-call-id", `#${status.callId}`); - setText("em-call-title", `${service?.name ?? "Emergency"} requested`); - setText("em-call-description", status.description); - setText("em-call-units", status.responding > 0 - ? `${status.responding} unit${status.responding === 1 ? "" : "s"} responding` - : "Waiting for a unit to respond"); - emergencyTab.querySelector("[data-action=em-cancel]").disabled = false; - return; - } - - if (reset || hadCall) { - emergencyForm.reset(); - showEmergencyError(null); - } - - renderEmergencyForm(); -} - -emergencyForm.addEventListener("submit", (event) => { - event.preventDefault(); - const data = new FormData(emergencyForm); - showEmergencyError(null); - calling = true; - renderEmergencyForm(); - post("emCall", { - departmentId: selectedService, - description: String(data.get("description") ?? "").trim(), - anonymous: data.get("anonymous") === "on", - }); -}); - -emergencyTab.addEventListener("click", (event) => { - const target = event.target.closest("button"); - if (!target || target.disabled) return; - - if (target.dataset.service) { - selectedService = Number(target.dataset.service); - renderEmergencyForm(); - } else if (target.dataset.action === "em-cancel") { - target.disabled = true; - post("emCancel"); - } -}); diff --git a/nui/fonts.css b/nui/fonts.css new file mode 100644 index 0000000..84d2a7a --- /dev/null +++ b/nui/fonts.css @@ -0,0 +1,87 @@ +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("fonts/barlow-400.woff2") format("woff2"); +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("fonts/barlow-500.woff2") format("woff2"); +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("fonts/barlow-600.woff2") format("woff2"); +} + +@font-face { + font-family: "Barlow"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("fonts/barlow-700.woff2") format("woff2"); +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("fonts/barlow-condensed-500.woff2") format("woff2"); +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("fonts/barlow-condensed-600.woff2") format("woff2"); +} + +@font-face { + font-family: "Barlow Condensed"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("fonts/barlow-condensed-700.woff2") format("woff2"); +} + +@font-face { + font-family: "Homemade Apple"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("fonts/homemade-apple-400.woff2") format("woff2"); +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("fonts/ibm-plex-mono-400.woff2") format("woff2"); +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("fonts/ibm-plex-mono-500.woff2") format("woff2"); +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("fonts/ibm-plex-mono-600.woff2") format("woff2"); +} diff --git a/nui/fonts/LICENSE.md b/nui/fonts/LICENSE.md new file mode 100644 index 0000000..cf07cce --- /dev/null +++ b/nui/fonts/LICENSE.md @@ -0,0 +1,12 @@ +# Fonts + +These fonts are bundled so the terminal works without an internet connection. + +| Font | Files | License | +|---|---|---| +| Barlow | `barlow-*.woff2` | SIL Open Font License 1.1 | +| Barlow Condensed | `barlow-condensed-*.woff2` | SIL Open Font License 1.1 | +| IBM Plex Mono | `ibm-plex-mono-*.woff2` | SIL Open Font License 1.1 | +| Homemade Apple | `homemade-apple-*.woff2` | Apache License 2.0 | + +The full license texts are at https://openfontlicense.org and https://www.apache.org/licenses/LICENSE-2.0. diff --git a/nui/fonts/barlow-400.woff2 b/nui/fonts/barlow-400.woff2 new file mode 100644 index 0000000..3914af4 Binary files /dev/null and b/nui/fonts/barlow-400.woff2 differ diff --git a/nui/fonts/barlow-500.woff2 b/nui/fonts/barlow-500.woff2 new file mode 100644 index 0000000..b1d06c6 Binary files /dev/null and b/nui/fonts/barlow-500.woff2 differ diff --git a/nui/fonts/barlow-600.woff2 b/nui/fonts/barlow-600.woff2 new file mode 100644 index 0000000..b1a019f Binary files /dev/null and b/nui/fonts/barlow-600.woff2 differ diff --git a/nui/fonts/barlow-700.woff2 b/nui/fonts/barlow-700.woff2 new file mode 100644 index 0000000..4ee6cd8 Binary files /dev/null and b/nui/fonts/barlow-700.woff2 differ diff --git a/nui/fonts/barlow-condensed-500.woff2 b/nui/fonts/barlow-condensed-500.woff2 new file mode 100644 index 0000000..e30e239 Binary files /dev/null and b/nui/fonts/barlow-condensed-500.woff2 differ diff --git a/nui/fonts/barlow-condensed-600.woff2 b/nui/fonts/barlow-condensed-600.woff2 new file mode 100644 index 0000000..da60c9a Binary files /dev/null and b/nui/fonts/barlow-condensed-600.woff2 differ diff --git a/nui/fonts/barlow-condensed-700.woff2 b/nui/fonts/barlow-condensed-700.woff2 new file mode 100644 index 0000000..3726e9d Binary files /dev/null and b/nui/fonts/barlow-condensed-700.woff2 differ diff --git a/nui/fonts/homemade-apple-400.woff2 b/nui/fonts/homemade-apple-400.woff2 new file mode 100644 index 0000000..cd0d068 Binary files /dev/null and b/nui/fonts/homemade-apple-400.woff2 differ diff --git a/nui/fonts/ibm-plex-mono-400.woff2 b/nui/fonts/ibm-plex-mono-400.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/nui/fonts/ibm-plex-mono-400.woff2 differ diff --git a/nui/fonts/ibm-plex-mono-500.woff2 b/nui/fonts/ibm-plex-mono-500.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/nui/fonts/ibm-plex-mono-500.woff2 differ diff --git a/nui/fonts/ibm-plex-mono-600.woff2 b/nui/fonts/ibm-plex-mono-600.woff2 new file mode 100644 index 0000000..67aeeb0 Binary files /dev/null and b/nui/fonts/ibm-plex-mono-600.woff2 differ diff --git a/nui/index.html b/nui/index.html index 50e075c..db1a6a0 100644 --- a/nui/index.html +++ b/nui/index.html @@ -3,12 +3,14 @@ FivePRS + +