From 0c593a5f517aedb4022235d8ca4bb7b9e3e08062 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Tue, 13 Oct 2026 03:20:01 -0600 Subject: [PATCH 1/4] fix: updated license --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index e3979fb..c3ccc18 100644 --- a/LICENSE +++ b/LICENSE @@ -629,7 +629,7 @@ to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. -Copyright (C) 2026 Tyler H +Copyright (C) 2026 Pixel Development This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published From bfebfefcab5b7e741803843c302b46fce3f74d87 Mon Sep 17 00:00:00 2001 From: TheRealToxicDev Date: Tue, 13 Oct 2026 19:54:41 -0600 Subject: [PATCH 2/4] feat: updated a bunch of stuff for ptb5 --- CHANGELOG.md | 32 + FivePRS.Client/Agency/BaseAgency.cs | 15 +- FivePRS.Client/Agency/DepartmentAgency.cs | 5 +- FivePRS.Client/App/AdminPanel.cs | 8 + FivePRS.Client/App/AppClient.cs | 183 +- FivePRS.Client/App/CivilianPanel.cs | 91 +- FivePRS.Client/App/DispatchPanel.cs | 77 +- FivePRS.Client/App/DutyPanel.cs | 13 +- FivePRS.Client/App/EmergencyPanel.cs | 50 - FivePRS.Client/App/HomePanel.cs | 34 + FivePRS.Client/App/RecordsPanel.cs | 2 + FivePRS.Client/App/TerminalPanel.cs | 15 +- .../Appearance/AppearanceManager.cs | 58 +- FivePRS.Client/Appearance/CharacterCreator.cs | 5 +- FivePRS.Client/Appearance/Mugshot.cs | 72 + FivePRS.Client/Arrest/ArrestManager.cs | 9 +- FivePRS.Client/Callouts/CalloutBase.cs | 11 +- FivePRS.Client/Callouts/CalloutDispatcher.cs | 21 +- FivePRS.Client/Callouts/CalloutRegistry.cs | 9 +- FivePRS.Client/ClientBrain.cs | 44 +- FivePRS.Client/ClientEvents.cs | 5 +- FivePRS.Client/CoordsCommand.cs | 22 +- FivePRS.Client/Dispatch/BackupClient.cs | 12 +- FivePRS.Client/Dispatch/DispatchClient.cs | 17 +- FivePRS.Client/Gear/DepartmentGear.cs | 12 +- FivePRS.Client/Loadout/LoadoutManager.cs | 11 +- FivePRS.Client/Menu/CompactMenu.cs | 5 +- FivePRS.Client/PublicState.cs | 8 + FivePRS.Client/Stations/StationMenu.cs | 23 +- FivePRS.Client/Terminal/Apps/AdminApp.cs | 46 + FivePRS.Client/Terminal/Apps/CharactersApp.cs | 53 + FivePRS.Client/Terminal/Apps/DispatchApp.cs | 69 + FivePRS.Client/Terminal/Apps/DutyApp.cs | 42 + FivePRS.Client/Terminal/Apps/EmergencyApp.cs | 71 + FivePRS.Client/Terminal/Apps/LicensesApp.cs | 29 + FivePRS.Client/Terminal/Apps/RecordsApp.cs | 68 + FivePRS.Client/Terminal/Apps/VehiclesApp.cs | 39 + FivePRS.Client/Terminal/SettingsApp.cs | 100 +- FivePRS.Client/Terminal/TerminalApp.cs | 2 + FivePRS.Client/Terminal/TerminalApps.cs | 25 +- .../Vehicle/PatrolVehicleSpawner.cs | 13 +- FivePRS.Core/Civilian/CivilianModels.cs | 19 + FivePRS.Core/Civilian/CivilianRules.cs | 41 + FivePRS.Core/Civilian/LicenseViews.cs | 28 + FivePRS.Core/Civilian/RecordModels.cs | 1 + FivePRS.Core/Config/ConfigFiles.cs | 48 + FivePRS.Core/Config/ConfigManager.cs | 77 +- FivePRS.Core/Config/LicenseConfig.cs | 66 +- FivePRS.Core/Config/LoadoutConfig.cs | 13 - FivePRS.Core/Config/PlayerRanks.cs | 11 + FivePRS.Core/Config/RankLadder.cs | 81 + FivePRS.Core/Config/RanksConfig.cs | 58 + FivePRS.Core/Events/EventNames.cs | 8 + FivePRS.Core/Logging/ConsoleSink.cs | 46 + FivePRS.Core/Logging/ILogSink.cs | 7 + FivePRS.Core/Logging/LogEntry.cs | 40 + FivePRS.Core/Logging/LogLevel.cs | 11 + FivePRS.Core/Logging/Logger.cs | 61 + FivePRS.Core/Models/Call.cs | 12 +- FivePRS.Core/Models/PlayerData.cs | 37 +- FivePRS.Core/Models/Roster.cs | 51 + FivePRS.Core/Models/TerminalPreferences.cs | 55 + FivePRS.Police/Callouts/ShopliftingCallout.cs | 5 +- FivePRS.Police/Callouts/TrafficStopCallout.cs | 9 +- .../Callouts/VehiclePursuitCallout.cs | 5 +- FivePRS.Server/Civilian/CivilianService.cs | 55 +- FivePRS.Server/Civilian/LookupService.cs | 13 +- FivePRS.Server/CommandResult.cs | 19 + FivePRS.Server/ConfigLayout.cs | 81 + FivePRS.Server/Database/AuditEntry.cs | 14 + FivePRS.Server/Database/DatabaseManager.cs | 30 +- FivePRS.Server/Database/Migrations.cs | 15 + FivePRS.Server/Database/PhotoStore.cs | 49 + FivePRS.Server/Database/PlayerStore.cs | 36 +- FivePRS.Server/Database/PreferencesStore.cs | 15 + FivePRS.Server/Dispatch/DispatchService.cs | 3 + FivePRS.Server/Logging/FileLogSink.cs | 72 + FivePRS.Server/Logging/ServerLogging.cs | 23 + FivePRS.Server/ServerBrain.Admin.cs | 87 +- FivePRS.Server/ServerBrain.Civilian.cs | 49 +- FivePRS.Server/ServerBrain.Death.cs | 9 +- FivePRS.Server/ServerBrain.Diagnostics.cs | 113 +- FivePRS.Server/ServerBrain.Emergency.cs | 2 +- FivePRS.Server/ServerBrain.Lookup.cs | 8 +- FivePRS.Server/ServerBrain.Positions.cs | 6 +- FivePRS.Server/ServerBrain.Roster.cs | 88 +- FivePRS.Server/ServerBrain.Security.cs | 2 +- FivePRS.Server/ServerBrain.Street.cs | 2 +- FivePRS.Server/ServerBrain.Terminal.cs | 29 +- FivePRS.Server/ServerBrain.Updates.cs | 28 +- FivePRS.Server/ServerBrain.cs | 101 +- FivePRS.Server/ServerIcon.cs | 36 +- FivePRS.Server/ServerLog.cs | 41 - FivePRS.Tests/AppearanceTests.cs | 2 +- FivePRS.Tests/CivilianServiceTests.cs | 2 +- FivePRS.Tests/IdentityTests.cs | 113 ++ FivePRS.Tests/LoadoutConfigTests.cs | 23 +- FivePRS.Tests/LoggingTests.cs | 72 + FivePRS.Tests/LookupServiceTests.cs | 4 +- FivePRS.Tests/PlayerDataTests.cs | 39 - FivePRS.Tests/RankLadderTests.cs | 198 ++ FivePRS.Tests/StationConfigTests.cs | 4 +- FivePRS.Tests/StreetTests.cs | 2 +- FivePRS.Tests/TerritoryMapTests.cs | 2 +- FivePRS.Tests/TestDatabase.cs | 3 + README.md | 24 +- config/{ => civilian}/citations.json | 0 config/civilian/licenses.json | 33 + .../ems/loadouts.json} | 0 config/departments/ems/ranks.json | 15 + .../ems/vehicles.json} | 0 .../fire/loadouts.json} | 0 config/departments/fire/ranks.json | 15 + .../fire/vehicles.json} | 0 .../police/loadouts.json} | 0 config/departments/police/ranks.json | 28 + .../police/vehicles.json} | 0 config/licenses.json | 11 - config/{ => world}/jurisdictions.json | 0 config/{ => world}/stations.json | 0 fxmanifest.lua | 17 +- nui/admin.js | 67 - nui/app-sdk.js | 14 +- nui/app.js | 195 +- nui/apps/admin/app.css | 264 +++ nui/apps/admin/app.js | 228 +++ nui/apps/admin/index.html | 124 ++ nui/apps/characters/app.css | 360 ++++ nui/apps/characters/app.js | 264 +++ nui/apps/characters/index.html | 123 ++ nui/apps/dispatch/app.css | 316 +++ nui/apps/dispatch/app.js | 172 ++ nui/apps/dispatch/index.html | 85 + nui/apps/duty/app.css | 262 +++ nui/apps/duty/app.js | 202 ++ nui/apps/duty/index.html | 83 + nui/apps/emergency/app.css | 201 ++ nui/apps/emergency/app.js | 128 ++ nui/apps/emergency/index.html | 61 + nui/apps/licenses/app.css | 135 ++ nui/apps/licenses/app.js | 94 + nui/apps/licenses/index.html | 40 + nui/apps/records/app.css | 312 +++ nui/apps/records/app.js | 265 +++ nui/apps/records/index.html | 100 + nui/apps/settings/app.css | 175 ++ nui/apps/settings/app.js | 100 + nui/apps/settings/index.html | 111 ++ nui/apps/shared/civilian.js | 42 + nui/apps/vehicles/app.css | 214 ++ nui/apps/vehicles/app.js | 113 ++ nui/apps/vehicles/index.html | 64 + nui/cards.css | 198 ++ nui/cards.js | 103 + nui/civilian.js | 237 --- nui/common.js | 38 - nui/dispatch.js | 162 -- nui/duty.js | 184 -- nui/emergency.js | 89 - nui/fonts.css | 87 + nui/fonts/LICENSE.md | 12 + nui/fonts/barlow-400.woff2 | Bin 0 -> 22196 bytes nui/fonts/barlow-500.woff2 | Bin 0 -> 22008 bytes nui/fonts/barlow-600.woff2 | Bin 0 -> 22772 bytes nui/fonts/barlow-700.woff2 | Bin 0 -> 22788 bytes nui/fonts/barlow-condensed-500.woff2 | Bin 0 -> 21424 bytes nui/fonts/barlow-condensed-600.woff2 | Bin 0 -> 22308 bytes nui/fonts/barlow-condensed-700.woff2 | Bin 0 -> 22444 bytes nui/fonts/homemade-apple-400.woff2 | Bin 0 -> 48344 bytes nui/fonts/ibm-plex-mono-400.woff2 | Bin 0 -> 14708 bytes nui/fonts/ibm-plex-mono-500.woff2 | Bin 0 -> 14888 bytes nui/fonts/ibm-plex-mono-600.woff2 | Bin 0 -> 15620 bytes nui/index.html | 448 +---- nui/kit.js | 59 + nui/records.js | 218 -- nui/style.css | 1751 +++++------------ nui/theme.css | 541 +++++ scripts/mugshot.lua | 21 + scripts/spawn.lua | 51 +- 179 files changed, 9549 insertions(+), 3413 deletions(-) delete mode 100644 FivePRS.Client/App/EmergencyPanel.cs create mode 100644 FivePRS.Client/App/HomePanel.cs create mode 100644 FivePRS.Client/Appearance/Mugshot.cs create mode 100644 FivePRS.Client/Terminal/Apps/AdminApp.cs create mode 100644 FivePRS.Client/Terminal/Apps/CharactersApp.cs create mode 100644 FivePRS.Client/Terminal/Apps/DispatchApp.cs create mode 100644 FivePRS.Client/Terminal/Apps/DutyApp.cs create mode 100644 FivePRS.Client/Terminal/Apps/EmergencyApp.cs create mode 100644 FivePRS.Client/Terminal/Apps/LicensesApp.cs create mode 100644 FivePRS.Client/Terminal/Apps/RecordsApp.cs create mode 100644 FivePRS.Client/Terminal/Apps/VehiclesApp.cs create mode 100644 FivePRS.Core/Civilian/LicenseViews.cs create mode 100644 FivePRS.Core/Config/ConfigFiles.cs create mode 100644 FivePRS.Core/Config/PlayerRanks.cs create mode 100644 FivePRS.Core/Config/RankLadder.cs create mode 100644 FivePRS.Core/Config/RanksConfig.cs create mode 100644 FivePRS.Core/Logging/ConsoleSink.cs create mode 100644 FivePRS.Core/Logging/ILogSink.cs create mode 100644 FivePRS.Core/Logging/LogEntry.cs create mode 100644 FivePRS.Core/Logging/LogLevel.cs create mode 100644 FivePRS.Core/Logging/Logger.cs create mode 100644 FivePRS.Core/Models/TerminalPreferences.cs create mode 100644 FivePRS.Server/CommandResult.cs create mode 100644 FivePRS.Server/ConfigLayout.cs create mode 100644 FivePRS.Server/Database/PhotoStore.cs create mode 100644 FivePRS.Server/Logging/FileLogSink.cs create mode 100644 FivePRS.Server/Logging/ServerLogging.cs delete mode 100644 FivePRS.Server/ServerLog.cs create mode 100644 FivePRS.Tests/IdentityTests.cs create mode 100644 FivePRS.Tests/LoggingTests.cs delete mode 100644 FivePRS.Tests/PlayerDataTests.cs create mode 100644 FivePRS.Tests/RankLadderTests.cs rename config/{ => civilian}/citations.json (100%) create mode 100644 config/civilian/licenses.json rename config/{ems_loadouts.json => departments/ems/loadouts.json} (100%) create mode 100644 config/departments/ems/ranks.json rename config/{ems_vehicles.json => departments/ems/vehicles.json} (100%) rename config/{fire_loadouts.json => departments/fire/loadouts.json} (100%) create mode 100644 config/departments/fire/ranks.json rename config/{fire_vehicles.json => departments/fire/vehicles.json} (100%) rename config/{police_loadouts.json => departments/police/loadouts.json} (100%) create mode 100644 config/departments/police/ranks.json rename config/{police_vehicles.json => departments/police/vehicles.json} (100%) delete mode 100644 config/licenses.json rename config/{ => world}/jurisdictions.json (100%) rename config/{ => world}/stations.json (100%) delete mode 100644 nui/admin.js create mode 100644 nui/apps/admin/app.css create mode 100644 nui/apps/admin/app.js create mode 100644 nui/apps/admin/index.html create mode 100644 nui/apps/characters/app.css create mode 100644 nui/apps/characters/app.js create mode 100644 nui/apps/characters/index.html create mode 100644 nui/apps/dispatch/app.css create mode 100644 nui/apps/dispatch/app.js create mode 100644 nui/apps/dispatch/index.html create mode 100644 nui/apps/duty/app.css create mode 100644 nui/apps/duty/app.js create mode 100644 nui/apps/duty/index.html create mode 100644 nui/apps/emergency/app.css create mode 100644 nui/apps/emergency/app.js create mode 100644 nui/apps/emergency/index.html create mode 100644 nui/apps/licenses/app.css create mode 100644 nui/apps/licenses/app.js create mode 100644 nui/apps/licenses/index.html create mode 100644 nui/apps/records/app.css create mode 100644 nui/apps/records/app.js create mode 100644 nui/apps/records/index.html create mode 100644 nui/apps/settings/app.css create mode 100644 nui/apps/settings/app.js create mode 100644 nui/apps/settings/index.html create mode 100644 nui/apps/shared/civilian.js create mode 100644 nui/apps/vehicles/app.css create mode 100644 nui/apps/vehicles/app.js create mode 100644 nui/apps/vehicles/index.html create mode 100644 nui/cards.css create mode 100644 nui/cards.js delete mode 100644 nui/civilian.js delete mode 100644 nui/dispatch.js delete mode 100644 nui/duty.js delete mode 100644 nui/emergency.js create mode 100644 nui/fonts.css create mode 100644 nui/fonts/LICENSE.md create mode 100644 nui/fonts/barlow-400.woff2 create mode 100644 nui/fonts/barlow-500.woff2 create mode 100644 nui/fonts/barlow-600.woff2 create mode 100644 nui/fonts/barlow-700.woff2 create mode 100644 nui/fonts/barlow-condensed-500.woff2 create mode 100644 nui/fonts/barlow-condensed-600.woff2 create mode 100644 nui/fonts/barlow-condensed-700.woff2 create mode 100644 nui/fonts/homemade-apple-400.woff2 create mode 100644 nui/fonts/ibm-plex-mono-400.woff2 create mode 100644 nui/fonts/ibm-plex-mono-500.woff2 create mode 100644 nui/fonts/ibm-plex-mono-600.woff2 create mode 100644 nui/kit.js delete mode 100644 nui/records.js create mode 100644 nui/theme.css create mode 100644 scripts/mugshot.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d461c6..70b4e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### 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 (needs the `screenshot-basic` resource) 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` +- 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, sound volumes, 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 + +### 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 +- 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..1e260b2 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; @@ -42,48 +48,23 @@ public AppClient() 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 +77,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 +92,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 +102,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 +116,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 +139,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 +152,7 @@ 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; } @@ -181,7 +162,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 +171,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 +185,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 +198,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 +221,56 @@ 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 (!Mugshot.IsAvailable) + { + CivilianPanel.ShowError("Mugshots need the screenshot-basic resource on this server. Use an image link instead."); + return; + } + + 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.", "Characters"); + return; + } + + CivilianPanel.SetMugshot(characterId, photo); + OpenWhenLoaded("characters"); + } + private void OpenCreator() { var active = CivilianPanel.ActiveCharacter; @@ -255,13 +280,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 +296,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 +307,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 +320,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 +348,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 +368,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 +401,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..a919559 100644 --- a/FivePRS.Client/App/CivilianPanel.cs +++ b/FivePRS.Client/App/CivilianPanel.cs @@ -3,7 +3,10 @@ using System.Linq; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Client.Appearance; +using FivePRS.Client.Terminal; using FivePRS.Core.Civilian; +using FivePRS.Core.Config; using FivePRS.Core.Events; using Newtonsoft.Json; @@ -21,9 +24,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 +132,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 +167,10 @@ public static object BuildView() Genders = CivilianRules.Genders, MaxNameLength = CivilianRules.MaxNameLength, CurrentVehicle = vehicle, + Icons = ConfigManager.Settings.Branding.Departments, + StateInfo = ConfigManager.Licenses.State, + CanMugshot = Mugshot.IsAvailable, + 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..9f236aa 100644 --- a/FivePRS.Client/App/DutyPanel.cs +++ b/FivePRS.Client/App/DutyPanel.cs @@ -29,10 +29,14 @@ public static void SetAllowed(IEnumerable departmentIds) => .Select(id => (Department)id) .ToList(); + public static double? ShiftStartedAt { get; private set; } + public static void SetOnDuty(bool isOnDuty) { if (_profile is not null) _profile.IsOnDuty = isOnDuty; + + ShiftStartedAt = isOnDuty ? DispatchPanel.UnixNowMs() : (double?)null; } public static string? Enter(IDictionary data) @@ -102,15 +106,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..2e53fef --- /dev/null +++ b/FivePRS.Client/App/HomePanel.cs @@ -0,0 +1,34 @@ +using CitizenFX.Core; +using CitizenFX.Core.Native; +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() + { + var position = Game.PlayerPed.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/App/RecordsPanel.cs b/FivePRS.Client/App/RecordsPanel.cs index 0704e3c..3e3b351 100644 --- a/FivePRS.Client/App/RecordsPanel.cs +++ b/FivePRS.Client/App/RecordsPanel.cs @@ -91,6 +91,8 @@ public static void FlagVehicle(IDictionary data) 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..fd796c7 --- /dev/null +++ b/FivePRS.Client/Appearance/Mugshot.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Events; + +namespace FivePRS.Client.Appearance +{ + public class Mugshot : BaseScript + { + private const string ScreenshotResource = "screenshot-basic"; + 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; + + public Mugshot() + { + EventHandlers[EventNames.LocalScreenshotTaken] += new Action(data => _pending?.TrySetResult(data ?? string.Empty)); + } + + public static bool IsAvailable => API.GetResourceState(ScreenshotResource) == "started"; + + public static async Task CaptureAsync() + { + if (!IsAvailable || _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); + } + + ClientEvents.TriggerLocal(EventNames.LocalCaptureScreenshot); + + 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..eb53d75 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,14 @@ private void OnReceivePlayerData(string json) if (data is null) return; LocalPlayerData = data; - Debug.WriteLine($"[FivePRS] Profile loaded: {data.Name} | Rank {data.Rank} | {data.Department}"); + Log.Debug($"Profile loaded: {data.Name}, {data.RankName()}, {data.Department}."); if (!data.IsOnDuty) ShowHelp($"~y~Welcome to FivePRS!~w~ Press {Binds.Menu} to open the FivePRS menu.", 10_000); } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Failed to parse player data: {ex.Message}"); + Log.Error("Could not read player data", ex); } } @@ -157,7 +161,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 +175,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 +190,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 +201,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/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/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/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..8b821ab --- /dev/null +++ b/FivePRS.Client/Terminal/Apps/RecordsApp.cs @@ -0,0 +1,68 @@ +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? 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 "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/RecordModels.cs b/FivePRS.Core/Civilian/RecordModels.cs index 603cddf..5a6603a 100644 --- a/FivePRS.Core/Civilian/RecordModels.cs +++ b/FivePRS.Core/Civilian/RecordModels.cs @@ -33,6 +33,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 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..fe4038e 100644 --- a/FivePRS.Core/Events/EventNames.cs +++ b/FivePRS.Core/Events/EventNames.cs @@ -26,6 +26,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,6 +61,10 @@ 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"; @@ -74,6 +80,8 @@ public static class EventNames public const string LocalHttpRequest = "FivePRS:Local:HttpRequest"; public const string LocalSpawnTarget = "FivePRS:Local:SpawnTarget"; public const string LocalSpawnHandled = "FivePRS:Local:SpawnHandled"; + public const string LocalCaptureScreenshot = "FivePRS:Local:CaptureScreenshot"; + public const string LocalScreenshotTaken = "FivePRS:Local:ScreenshotTaken"; public const string LocalHttpResponse = "FivePRS:Local:HttpResponse"; public const string LocalDbRequest = "FivePRS:Local:DbRequest"; public const string LocalDbResponse = "FivePRS:Local:DbResponse"; diff --git a/FivePRS.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..5419f69 100644 --- a/FivePRS.Core/Models/PlayerData.cs +++ b/FivePRS.Core/Models/PlayerData.cs @@ -4,30 +4,17 @@ 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 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.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.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..f26429b 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; } @@ -121,17 +123,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/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..ddbbced 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 @@ -29,6 +31,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 +43,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/DatabaseManager.cs b/FivePRS.Server/Database/DatabaseManager.cs index 941051a..dbd37f2 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,8 @@ public sealed class DatabaseManager public PreferencesStore Preferences { get; private set; } = null!; + public PhotoStore Photos { get; private set; } = null!; + public PositionStore Positions { get; private set; } = null!; public int SchemaVersion { get; private set; } @@ -43,7 +46,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 +58,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 +71,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 +90,12 @@ private async Task InitializeCoreAsync(DatabaseType dbType, string? connectionSt Roster = new RosterStore(_provider); Appearances = new AppearanceStore(_provider); Preferences = new PreferencesStore(_provider); + Photos = new PhotoStore(_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 +103,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..c2a112d 100644 --- a/FivePRS.Server/Database/Migrations.cs +++ b/FivePRS.Server/Database/Migrations.cs @@ -184,6 +184,21 @@ 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 '{}'")), }; 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/Dispatch/DispatchService.cs b/FivePRS.Server/Dispatch/DispatchService.cs index a79ec9c..927aa32 100644 --- a/FivePRS.Server/Dispatch/DispatchService.cs +++ b/FivePRS.Server/Dispatch/DispatchService.cs @@ -505,6 +505,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..66c3a94 100644 --- a/FivePRS.Server/ServerBrain.Lookup.cs +++ b/FivePRS.Server/ServerBrain.Lookup.cs @@ -46,6 +46,12 @@ private void OnRecordIssue([FromSource] Player player, int characterId, int type if (error is not null) return (null, error); var label = ((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); @@ -116,7 +122,7 @@ private async void RunLookup(Player player, bool lookups, Func(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(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..20d43dc 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,10 +23,13 @@ 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; private readonly ConcurrentDictionary _cache = new(); + private readonly Dictionary _shiftStarted = new(); private readonly DatabaseManager _db = new(); private readonly DispatchService _dispatch; private readonly PermissionService _permissions; @@ -34,15 +40,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, @@ -105,31 +110,30 @@ 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); _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 +169,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 +184,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 +217,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 +273,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 +304,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 +336,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 +351,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 +390,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 +451,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 +461,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 +470,29 @@ 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); + _shiftStarted[data.License] = DateTime.UtcNow; + } else + { _dispatch.SetOffDuty(ServerId(player)); + EndShift(data); + } TriggerClientEvent(player, EventNames.ClientDutyStatusChanged, onDuty, (int)data.Department); - await _db.UpdateDutyStatusAsync(data.License, onDuty); + await _db.SavePlayerAsync(data); + + Log.Info($"{data.Name} is {(onDuty ? "on" : "off")} duty."); + } + + private void EndShift(PlayerData data) + { + if (!_shiftStarted.TryGetValue(data.License, out var started)) return; - Debug.WriteLine($"[FivePRS] {data.Name} is now {(onDuty ? "ON" : "OFF")} duty."); + _shiftStarted.Remove(data.License); + 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 +508,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 +516,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 +544,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 +554,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..775a946 --- /dev/null +++ b/nui/apps/characters/app.css @@ -0,0 +1,360 @@ +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.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..a547070 --- /dev/null +++ b/nui/apps/characters/app.js @@ -0,0 +1,264 @@ +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"]; + +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" })); + + const mugshot = document.getElementById("mugshot"); + mugshot.disabled = !next.canMugshot; + mugshot.title = next.canMugshot ? "" : "This server doesn't have screenshot-basic. Use an image link instead."; + 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..a5295ca --- /dev/null +++ b/nui/apps/records/app.css @@ -0,0 +1,312 @@ +body { + background: var(--bg); +} + +.page { + display: flex; + flex-direction: column; + gap: 1rem; + height: 100%; + padding: 1rem 1.286rem 1.286rem; + overflow: hidden; +} + +.search { + 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; +} + +.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.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; +} diff --git a/nui/apps/records/app.js b/nui/apps/records/app.js new file mode 100644 index 0000000..256e58e --- /dev/null +++ b/nui/apps/records/app.js @@ -0,0 +1,265 @@ +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"]; +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; + +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 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 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); + document.getElementById("warrant").hidden = !warrant; + 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(0); + } +} + +function render(next) { + view = next; + + if (!filled) { + filled = true; + fine.max = next.maxFine; + notes.maxLength = next.maxDescription; + renderOffences(); + setEntryType(0); + } + + 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; +}); + +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 }); +}); + +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.mode) { + setSearchMode(target.dataset.mode); + } else if (target.dataset.type) { + setEntryType(Number(target.dataset.type)); + } else if (target.dataset.person) { + 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..b25c77d --- /dev/null +++ b/nui/apps/records/index.html @@ -0,0 +1,100 @@ + + + + + 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..5b54569 --- /dev/null +++ b/nui/apps/settings/app.css @@ -0,0 +1,175 @@ +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; + height: 3.429rem; + width: auto; + 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..4962c9c --- /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/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 0000000000000000000000000000000000000000..3914af46036a03369558f46123f337ce0e8c5ce6 GIT binary patch literal 22196 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 H0ssI2wBg|@ literal 0 HcmV?d00001 diff --git a/nui/fonts/barlow-500.woff2 b/nui/fonts/barlow-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b1d06c6db025bd438736e9192a33569d353906c6 GIT binary patch literal 22008 zcmV)2K+L~)Pew8T0RR9109E(^5dZ)H0K^yo09BO$0RR9100000000000000000000 z0000QfjS$m6dZ|m24Db%4hVrD37i!X2nvFY9D(N%3xpy70X7081BgHbAO(Xs2Z(kI zfmR#6ttZgic9a6j8%XV)P4=rh%tqAUJP6qiN6Lg7Lk}AVKo81z_W%E9EEPFyf+y{U z!8}AI3$rStsb?v1KdR~+S)l9e&QkQ|JFCYDisy=YVq_6~&| z59n_w^k|EXyL~b63OyzXLZJk=%E@b;MDdHYCBNtq95C<2;##$xIAta4UQo1=x!x+konK5SX5^CR z|ML&teeS$(2Y_pURaQCx9i*=+ojgC!Z}ZQ+?@|woFi?jGj8w`(Oi*zm#!#0ojKmIrA|Frg2mfA{~I>%UtV z5+WkWgeXN-fte)QsT4zpJg?99O~~untkZ7OJt3{0|Ns89ckg}9g{LP2$v^`Hl4nM; z2KB-INgRcg=b1!bQ-#n=0sk&l57?!sQ>VwG{N$}Qld`(n^uZ})K1e&+<78Q3b1Vlm zHr3yGpmhKHvk}KMPdvGZJKk6Eq}Coo=^%jcO1< zu-NqPO|?qj>jo%5W)~Pso)y+oV|?W#&sdjUO?cJ#|2MjS|85XyfEu7dX(&L_kl+}A z;7n1ZW(p00x~ADHj^+$CvuFGoV?aWqrIFzX(wOCXtvTmh=zM5=5bcGRU65Xqy6l29 z*zlr*DE9kR-doHLyu=?GLnW3k%3^j%W{9F8c?qP-5D6vIIz0c+)%cz$9Ka7auI;a7 z#XuUc7RoTe;V6#6AgD7nlg$F%WISkRnDnK*)c(T7vtnyeE9i$*`1d5L@5eT{9n>0e zszyYN-}e4n&e+!@rHI@HNyQyRTyaH$3|YB^erI=CGYKT%SSOB!5Cweq*I{fr*t2-4 zpNJ?(w1V6F*sJn+)QMRCr7}Yufwh>m|TS0OKKp zXc#CMm@tOkbq_$$5Kq1ke-en47NQpoF*8FfQXqEe5Ql6?t_hGyDj;sPka|s!7A=rA zoscfGU=KWiJ@N?h#8b#~&mlkf0WxS16+i(h1hIgEfU(S4;2m~y=7adBsHqOT5I`S- zf>0Rt(Lo`Yr=K{r4x%;rLu~_Hpr+QRP?Mra5uI9AGf9(Z4^bHl? z^>*=xr>&!lb-)bnZd==WC5Eo4D5}WH_GSARd#nXZVphv|OFq=ls#~eyL~D%VF4LWL z^yc<6o7^nlNMutr3G%IiC6d#q?RgGcD-%3ef7LJbUA?Ji^`LGEuUh9xKUtSmf9D$-R=Rfm;UG4aAgAEl^=bIZX~!9+CsQ_&=arAox& z5+Z@cteFuMWAq>XVg0m3zR9aRS@~XWP93*%BNv^D3t47uH^_i1!w3l zsXi(1><1IW0`Rbb%V?-bYKuZ%Qw(PxLajuhR)h^0v>+x_MM@PgQt>2W+5=$*a$*>* z8EG_#F^gr^3YWn&eX@@18O7ZZ~!d*3$J&b_${f3>Jav+st^ZU1q_dNg~)S1V4c1V5)X-n7$6#m z99T-`Y#^<{{4bidJW(!}??`2L!xGMaIU^QeY>e^ABb}8fl@Cma0P6^ZV9=jKkQXqD zT{fghCFFt&5Db(-1mxogv2@rBK`51;+yb}FU)h@rn=iL{Fnejm^JNO&Cyt$53C)q8 z4#RPu8{k`uxbPTw@c_3r{T0{EqC_gpofj5Bosm^*GVx3IcNl}dl_u$bdWr`}pS}8> zaNP|z-E$ung5cutAR)Qw%c}i#TivQhBn=hULgX z$(0M4XcAhHN?4U@v>G)KH#b_XT3q$&Q5rNMnzdkP)yhYk4h%EQMCsPu#4Psr3NeDN z2A(jc{xEhT%yd0Wxe+FA3PyPwMR1iPpj}!0Joy7O0txU>qRP}yw1w=Vb2IvmoBrGe z&87VIzE%otpQB`m@TYh^i^(Ed!hN(AiG>PDFy;s2vaefR{p@P;hQ)XhS}#bN@wb6p z-hXXci&tiWNpn9;`KvOi@7H$!J*Ekk@zV-gL@s?V-Ohx|u67qr3g73JWzqW!Sz>ca z?fv3gIA~^7sU05kyIO)oNs?LFq(~F&pEKmhHImDCh5hhzkzyrEl_^)z71l_&^_&0q zHtjleyWnpb5RP}7rq7Vub1rq&K|#nFk5|;uj|qG=>iC-T{b$j6&h#9UhE&FGqm~gr zUzUoHGv4F0Y&_5StG{XZjkEoxcSj}%|CG4v@Eu^)JC#_>-EKH?&N44-_8C&3H0+vn zP_wL8&-`r2E2R~oB!oaTm0~7y<|;Qy4&*-j%U8dBtB`;>1nlh7K zo!V^*+yv`x+t)j>voW`o^ye>qf0&5DSJ$l7{qxIs*hAL$^7zIpK+ya3GIKp~M_gWE z7u2?}XSS)l)v5=3B`MH1NXZHLk(s+@ARgi9|-iSo7akbjA2nzIbilT4)SC|sAqqU6I zyh%a>{tX^CTP!(ch#J-v;n6JvZ1-lw9JWqMSOX8|mc@6t@L5XPr zE)Pj5Oi{Tkr>v$DjEw*`Z#VP})P^TG8JCEfrC?kOgX_u&g-t~hJ#l|MBkN3=CD9cb z+-OL712o7qoXgo|F?4FEVxE#kONnQRDYLjU%K~dFz4VZEJfSIz8CNO;ix|^T^ESW~TnWY1Na0$fas9T#4O{%`Wq|uch;1j^$0UU)fhxZ!>IykHUp*oq29K)*NgkLPiOKJ0~;Fv|SD27A#^ zUP61CX+?5ycmr}Tb&Vh{dny-N0>i>#;en5%rz`BjO{o0O@mE3k>@GYPBPQ4!B?1SZ zkAy8Y#Zd=rx6$(d?B{y!Z_Wi~>C&P>wL&fk&s_7&x4=S+EVjf_J-)KWS{rS)(_z>E zPXTL%^2NY%e5>#vJ9h_3ZKj|V%JxpPecoDd1>(f^*;00?^;oi7Sv{t-V3a}}7959P z_){J5Uv0P*bKAqxB=G5*HQ&NB-(2n0@78g4{^zPa@za%gwkptvU}N#-FT`-+q9mvj zF?sPLCKo1B{3IBMNp~JbMk_)bPDFSR3MQptVwp}v;SvZEO3f&C8i&#N0;SFwm~ko= zgo8&!NiXL39$8l%%|`$Q-K&tq?~>XEbW&fBPN=J|Mg7(PR@#RX+I_t`qpG3^ZGCKg zi5sn#SIS??AIm-0rv+y<*G@&V_&J|1Ce#J#g7>GEO9A02d&n%AA!C`6kxXPJdWp8H z39c>JR0*c$r#$))eTej?Cmkm;B`-7JbBi=kK>UfAl$Y7&ytML#FMXxgGRv*7(kiQy zR1$Itin{R^vd(%NY_!Q{TWme7OlP8b4TuS_Bajol?TQSpj#t#1oc~7j55E6Pjh~e;DJ=REAxhxlOfXlH&*qt<~FD$2!W=>yf|Hvg!P0wU3QcUz42B>Rm46wU- zo^ljZw12aA8}&9h;KMc+j)Uc1shiylhC}?l4A5AN}mUQ%*bPrM)ib zb7Gty`r2FXT>P{LIG(((TcR@@KvWq*B9wFwVZ&_B$TU>963ZgqIVYX>y({s6gd?qs zM{sz@-uG4=V~K`)(fU)w|9k9=f1LH5hYoq&Pe5pxKtY1}kWm>^wP+C{MTrxxj#Z*0 zDU#WwN|!B@Q~cK!VNcFb=`n#W^?wf z-S@ZhR{-67Nt!IkdkM9I+NDdI+((^n>zw`kB1ODk;^i>KXjnMCr&o&c7LVnhzf{IQ z|HpR9ME}$H2z>=Mu1GrHpkDq8+*l^Nk2BdU~+0^9^CL<7Kj*q;hC;4qk)Xf!#OA#1$NVfayGBrYB75Dnr9YMUZdHY zyJtAc#MeS<@k-)Ecnwpn^#GUvMg zJ4BY7d{(}m-8rvqrf)Z=_lg4cqzR`=Z76Tj!dOtjj}pv~|5^{I^5_{h-l)Dw2_%`Q z8n+4sr^qqsJ&+zwxpj4;r1nVbFkNM3Ol){%WnXoYl!!)E9(0{7Q@lDBfIgV_GyzL| zSd5-|rpy~_;|s;HXln%_ipA@DY>BR>k)5CnZ7G#1*|QIioXGaqXNs~Ia+%M2A?}*A zuR+u*$S(tO^OwZrIGSS#tZ<=%;A1GSTrn>oIGc&wpAm0OhF-69DuAM;6315}`y~f- zPKtJ?VsHDJs;HG|>GXZaMh(>5QDll7vM7Wbpm=ngJg6R}mbRhm4dqmpUT&=njlY^b z_H(HZO>+1FY0@K*h&Cms;iTC1!}8XM)sA&+pa`vPK26!Wf1!l((a{}hPF^cUHl)IDP>Xdu3Ju5k}$}}Y4sHInf$_$5v zBBM+VPu1hUH4DKs>k%Yf|Du#Gm)CHkkg$Cim^Y6;qj`{_vA`>etKKq^sF&4+n=m8y z#1F&?j>?E%M=6eu`L|eprH(oZXt9DJAuuHY+ENZWQXaZe0eVsq`cerjsSE?DvU?2i z!jjaoaTo**(es>93h$(2Q`z^?b-HQn`+R}exWP|)cK9ZXB}$r^ zFOURiF;qQ6%FNFq#}%4Ktz+6RL9o%-v1=4T8jeOgBxlYHuI0D8_A4fYk;%Pd&DWiT zX76`Y`3402+vt+wBkhwZfm|E&WF-iygZDtwE&%>$cUE9pX ze7B=M-ws!a>`*6UP#speWQ~~f*xJ+}&o~hlm?kEANhX6v%y%{Evq_Lxl4Xz`s_uaH4T+Qsmus}!pw zeE?)a62SO1o@&CJtaw-X78VRClJW9l$UTPMYXypbU$5?~M|1`pSr( za%$=u^KXVjQiuZ;yL^RV*X(ORt4&!Xb>XG^_M`5b-Pw2J>ZX2%GjJ1=mF~fd$aYQA z>kFB8`jxTBhKpo4q(DziGdn$#7*tGea(#P2i$)fCR~}KSgYNqu;&^_i7$z_=hm^3V zY-Q2dROz#-BAj-^CLF?V9i{HGa;zGn>5q4&Hs|e1i(9|P1(M$T=6>)21<)nQ73z=g ze7(Tau|VGzG#KPTb(H;#XjUGgN7)OiIyh=HNf2xn^im)U!wDlv5N{Stq?D&P1;_|9 zS&(lLEaX6W9w4k_LA^z=kz=&t@E~;rLcdvXkOE^EZgG+X^Jc+Cida>`y}Xev>{|p6 zIpPEa;3W(0ErO4n+g=o$_r%vo(tT0*G)lu*oI+H(4O$?B^F#K2K)8WWju?FulaGoF z=nWS22C5&>{NTOY#Blf+P9MYNW4KiShaYhI!7tdv@%C|id>mgN$4>?D`vJ51S3fHJ z4ZpR++hwWCHQyT84q}Z}_01j24b8^xe5L+N>naZbJUTK30GuWQbc~3T!1_19i9Z1U z5$IDm_df#w06MzcN3b9HZxo0TJaIL()2gwXnRY)pHGQig>|%qu%G6^umBw#!pZZ}4rmXt%4cO(N7Oc8L7+$)PqLn(fmRM}g6yH{)Sr|NsN?{{xM7?mQ+A}7Tfz9gY zJHnIM_{AQ0P@Tr&f<6+9W#$T0-0rLDq>;H>Z14%kkOPAxo~~sRAKT)CCLdhN>r|OZ zJB*8Pi)S;HWc$7;%WhjSsGGhl@{7)yrqrP~ zlPiwIzUT-NH~b9kKt2K?p&1`xn{;2oGuQ>B*EKe7LLSi!Eznffh!WJ6 zc^PbSZacAy%E*%QBqCW9$AF?|ByXSkpK($E9ZM_OZ}?dEArCA_c2~9XKQ0Tc$SeDr zxB$%;pdE$`Y4bBSqdkZW@?@SF>#WFRJ{X>?3KRuLTU*E!HL&L1DD;Xpwi%!`8W9wB zd6EF&(kF=WC5tLWMEl%ZAletiDgXE)lVJ%B3DP-pr0I(mhEq?FVWvt#D~N4kEoabi zbN<^&DLbm#WbRobxXq{6wJ$VD3}9EI_1YJjie4Wp@EV>v4Xrw|K7_eCk})%_RK%k6 ziq;&#Zl*z{z^6DlEmWYuF*(?3%rxE1S)EO5%2fDD)RtV9+mX}aZFW{#Lc$wer{U{D z!2{_uYF0Ek>B5%M5s9~PI^V(^MZ1SIJ}lTyrO4rHyv@#rMDp66S3N|+ zc~ePcB`MLUWmO2W^Mzt4(Rgj81tQmST?O3X^QMy7MfAu-+FqL`N~&Xt1aazs>Dz(- zsuJ{!Oc%P1n?5p|TIsWLwwW*wA(^o=f!UB+!z5hGWt2uUtQJZ&*$h7~**c5Ura&)H zJBxe;e2$|wJPl`EZ$c1((()}jT>{BB9@u;G2;`^Ih}b$VQozwU$_#c_$zZa; z?#H*d2r;p?Wy7hjsXG)hpv%V63m>PpWHp{h%{FSA`yMw$4`RpsV!=9Ia#oR>K!bpA znd^>w9#mw%z^%OPC30)1p?mIV`2>BX5D#hA`5ItZI)7Eq7Dyf|irn1tys?H@(T-?Y zD`0UKjbG_z>~e^l9K;+;H)iWb=lyPq}~06T*^=HoQ7D^jc8G)%*C$(p-zhs-X%4`3U6&gnRu8NH$c0$ z$R4m0f${m=m)sKE%=`rG6As(*VE~qwy1@*n0fjz)`s;WZNO!cM|7*hm`-gYsas$JoviMg`E;-GieBg)x}9 zw<+KyE*&#+$$*ptiCv>8tz1-^tBZ`C5}ttZSEIG9Kf6%f=b&j3HVL~oKgm8J{v1NaU*cgn~r^7SoUT!kM&io&0S z#iY`-p4X+D84*Z5olWXyJoEEpFz)V~g%+~4vTK5SO?NL?UQ}hY+u4%RHN*R&^R=u2 zV*2`lmUNF?`gIj5eKmS_u<1@N&R?$OCV?^e$gE#$8B}*!=`}jyZD4<&x5!V2)}z?l zGN9KxV;o5H3S^$`dF0o8B!f_(_;mk6cbX9~B)QQuzmAikAFPT>>C8Rvm<6JSi|7qg zEi0i%{N?|)mX5Il{iePF(lo2e>cBM29?A5KQAusHO{kUT%B7aUWj_Q{2Gg#JR+U!x zEN&;EDa6p8*Uyoj93^4n8GKF;s=2|NT|Hf{c~=TM+Zo){2R)X>#2ZZ2{_4Fz;hp8W zP4D5%d1vDmc}M6FfMDD~AH(jGtl8{INOXajmyh21d07I{y4dxLME#rzgFa5zNLF#^ zmB>IV72~v+w6HL^aLCYUUZu(`DTWB1(lA^E@Re%xIub0APM~oTwkZuHZ8D%Mdaw`r5lyVGX=NnP^vui@((zxRF%QK6umv?aZB z>A1SyZN6^1??VX}ypLP$q9MnczQ8klqOIE$U#QUxtSGjkPi`Bp&INa~*t(-`{7Hp? zijy!@qT#8&PKLY{Ytetitg}!;h+&K`1X`$C3v(q%8adC9uk=M>B2pX}+F&P0QIlb~ zqeufUxx7HLCYJX}k%wLWIg>p;r$@88Cle;=0`lUI@>+`ZptYM|Etk}IxMrJn!>ifM z4O5gV*AP_F;=uk!9|x`5EkU2ImXga}JXPJY0_O!w7E(NhNqL+XLa5H4QJd0Lt9D?x zzv9DPu|w`_pe5HB4Ea*A4#--tk!lCif1uGT%0J_l!S_gRE<_$>k}M;WvYdnm-8|(8 z^5`W*?wg=5hMvo!9LAxI{0d`RN5&ulcVvtPbdIhY91YP`x6`$zM1(}zN((a!c#%Ez zV268B8%I(T=h9IfwEajyvn?{02-LVZwWVZBl@}N@=B7M@0+Yo;$an!iEbt=EgE%NY zlJh|k45b}FJ0w5M7=LHOwdPEC^qHCaMeDlX^@CoQGQf)~a$sC`pam6DE)83#3sRvq zz_mgGsT=`QH2`UN?x(Q5Mnh2BRC~T^1{*r2&B}J?&RkCFx%KfqbjmGn*L4`%MWMJevv zxv5GN2h_Nni|WqqX4Fwlc$ArN*HkY|H&SqCg1n@MkFKnmQHNb`fx!Y;G|X^Qjki}- z{hAqnZdSy>`Ft5~L_uSdKsP=i@e@QFBtFLJ7Bl*!qW2+o4$x*>#kp`?u;*bx!`TF~ z@S%NwHIpBMxZm(oq`{|~!8906v^W8w?J{COaB%b7b~)0!#t=Sz?11`qfP1ilXNfxh!DLrO){% zl9^(h2%d(DCd1y+zR32AsW`*FD+U?%h|cN}tmc2K zBtT>Zgr@zT!DAK4;Wfh(3!Fk&8z1gFZmpnxe;%I(jPLJzIzK@o`&V%?Z-Z)S2?WG^ zxqE9f3Y-rc?vB$XrgA$E#;tL45j~ zz^-&z1;LA4#w1&IZ%?S)*HdK4R>CrN-( ztg`+@7b-Ge2weCCHG!bA$x2$7Dj}pd*ZMfkuW`UQpm82=#5Wf)o32H<*?8mQA#m@j zO;(!pEAl8IuMi}nZ}6Sp(4oBF{=9Q4C}gQXu!B1bLIQ;MILaeFPf5_OYO_37!{9)u zMV|XYzsBnRThQ9_NB?h-JC#!#YWtcb%)iJ@LRMOEt4@#GZ7WX*Z7!3^=d!N@ zo{u01s%v#(ZBJ2mW@E^vW$7Jo3C>JUWb;!MSz!(AP4?|Sdhv>gC7gc=Cd0PrBZGv{`E zS_-T!93`Z?6}kt1AY`pYS7bZ+U9i>3^3U?maw|VrQe?ZO9?^M8a*KQy?iQpF#>sdf zC-|1)w0FvJO- z0>@`WtH>t9_biP3J4b+!okb~u6Lti$@+L3{m-@Jv61+%G{-`Xdq_ZRW2nB@O%9FPTaO?%MOxet$9@yqji(e_7l)IGL`AAjtnm(44ILTO;lXjc#iS z=}Q^3_xbRe(3&-jVO{5j2NXs7y;;EBV!tT~XXFTfYr8s0x1V`s6x;M{H}v^bJ}QoS zU-rTJWkm>&cDqchax`1boj3`flx?CzRmS;_7K(_8i@N_+WtF-xccRSgvPsP4wDO<2 z@<5?ZSL<--YZ|n!eSj304*z>VGo^mqYck`wc_$#>60+0qcyU}u#%?zT9BaLggd83j z+Nk#6?&y%){488Mi)SOd->b3MyMup^E1g$yj|S8RO4nSJTPE|jJ*+&92|e}NmR;uY z*qwOR>#^jgM2lqzae@#r_aD~xI5JvojXfA{@=&$(2|0mCK}%I#;36SAqyCMI`Y+xf zKTBy)JF4s)2qO@(^i|$OAYfG&+5JKEt$wh4*uFxNJhM#k?=Zrnp_C|lT~MNcKMIunjE*fN`1ca zQt$Elz|qwBt`|GP3%>KG@^;Z2Q;vzZNrFHCO^=kaOUG%xXk58czU)1Acq6mvkz)cb z{W%os{K+gz&rZJtquD=~#7CqJefOLe;_^|9FYk%2ff5k}3M`Sg%&+JSUd(a|4GoK+Tx#QLfzci<{3jgldV{ZqyMOJs?~I+kek-(1O0KDCk4DN2#o*`W;=o^qkP~$^Cv}fO%0vyfVonR>ygn`nd`Qe zoFjxTn|oLz+dEf5-E}K7LaP9v(cu${_*Kf+emdA=!r7jNU|29!P2(ZFX#H#M0uH5B ze=5yvj4-`rA7#1jT#GlqyWW*tbj)J=aOxRWO`PHVJ~Qn~k~Rz!Y zOntW^$~a@ill@|)d17U51HbN*6Xmv>->9Gj@Zj?T!m11+ zij|?_u~|$(eJ6vkrM#OgxPPt0wos!pShdmF_-ubDYhk-Lh=+TkIUaf-$n=-%jYh=D1LxP7Ezf`4 zua&|vR$FXa-?*#|yA>d9z{%Dk{OYkxd#H;}-}??ukNoRr?Tf}Oui~KQJ5m5aem*&3 zor|C$?D$zVWC~>mK3<;qM|^@|gpfnnJJ>Sp;Y$vD9JMI?-hWkPmy9gN;YOp|R3ZC7 zDE}a((TBU^OwXISvS5c>CL&sBe_tSb|Jz<0?MtZBjaCS40OWdC4Tj&0r26O2-Jq5} zwX_5GKH2)DTDoEEb)Tn}ndktVCxmTZwnr2D#qJ1B3vG?z1vLkH=JhAO&YxlVg?Z_< zGp?2Tm!+9)YMwX|*oAsnP& z)&!|+_o!|B?*jGQ!c2VH?kLV=MM9(^-Cd~iJz44h3fC{ZA(gPMm^CYkZejW#xpdhm z58roxBaur`1WMuu7~=$mqHGAm5RwW$QHPHeavU3qyUsp+$ty-w|N{J8#AtZ8^ zX%)>JPGZvvXJMVz-&0y+3c=e&4Fhe>W1UqxX>i1gKWC_QHZJHIG#ns45~tmdJ8f>< zV}#HGp;5O=qn$FFd`|OSY-;xGrriKwvb2QLx4|7RXWSH(1K<>3FE{}hlWNZx0DWFj zI{G)ADLp~WixDnuETv5rYa_-A_zh?_^y+j6^k)Bi%GI|h7M8}u{oMpW=<60^sjPCf z%{P^tG;go?>06i$OE6YL4}nN%>S1AlGuYA{Xz4DAXN4WXL{b^M1Qj=z2Lg3vEtm2N zOv3Fy@cG++6tb{#)HwPAxW>(&Ms*=5pgcr_kW~CVh!930U&&Q}sQyuqp|7uB2e}v8+h+c+o4!S;R$(VDUD;ru}7~)zp&eAVBDZP&1*v@l`%Q_`isf zxUZl<&#{{KWImqHjTh?poB}MUA7dBa4GvyL62L6#6y{grk_Y*lS!usY?+7||XCN(v zkdAO^(@CZyDpSPf&RK6N5(D6oepeVID_YnJ2@onET4(j*ID`v72MF=`mTinN!x-tHrugaUNQH+u0-DQ{j?6kdC|N1fKz?b5P52*c+EK{a)zn2US{_+s`V*js%B`U|NcJ9H+jj%zbBO8;c!=^za1Gt+n_~A!W4r@6uj`7oCF2Hl2*f{;L3G8I#(Dl$n69g#37Ue zutzq1&jy;7OeE3!yA)^{Daf2!h4{$Fge>oZ<=+f)^Lb3KeqG%YjYc{{iM0fySSf~* zo!ffE(kE7y7V~P`AJK@vc|wJjLHHO=kBK~o1ps7SkG9dPGJ9KKrsurO;dFL2IReoR zU*c9wWOBH<&YqELOZ3c1&Ml@~snUL;nT~L2dy`|@df$uT`od2Fvck`Qiqw4mm0@^f zuleC1^KuoUBX_==gQNXk2-p7Ih7aUk$|%uP>1lYt4|g?aJ)UOW(|Z!l_4PZf_-}t7 zjJ8vkR?E$n3i;Bkhsjj_d$@H)z4TX+O18`C_=2J>`0(TZLqq<#aBX$8X6DOw|31B5 zSNfGL@pI$N93_LAQ)kGdZA#kH>ib@CI8tB;Z>jpCu}E*& z(c;{k>@k&W*NRHMpr(CNE4jRGBQHW;U?4mU4^tTG2+Z(b_fNF$Yv>5YqP?NStI@{f z9Qs6#TDk5m`i5Hjt)?3ZrS<-%N^SJ^rGK;NBx>Izl-csKAt5~P{ zRU((a|GPK@yT+AqKl&tG##Np=x+X^Q!Xc|zq&fJhHq!MKp8Q4B{oEL1a-_M=E^%m5 zt-)CjtM7&?tpTr_vfB3bw}oQS?qEr3iKe*wp-#D2tY@xD)M}gPkjNifY;L850*+nw zsk6%!4cK=%8o1Cl?#tYEUQxRlQ&ho~m2G7i6-+c#LRd=#uVUf{1*WwxgQZYR@rTXc zg(i<6&&A=W^Y6)Kt>hMkt!8`Bt<~whf2BAGbYw>!UF#2%beBwg?#L;1*gv;(1L+=? zkSr`a>Z|zSl@P%P-z(@>eTXD|lurcurWV>4c~z^|XE%8XApTmKLF)jQ2wT6nmJnq#h)L!tqJ?Ox}Z(nVezNQb@ zZ6=>hjWPd*Be4H#2h$3?2#@xrRCMay*qj`yf+#M(;_G`XS;(d;$-=U4gnrkO(eWU2 zJVT%(O9&A{$OEW^yFUUXAornqF{+JmpDkpMy=0Xt<-gsWADFj#?Jg;Q%c_t4*w24` zBP{Ibky)bLC2xKp>qFq?>xu%RQS={+}~5)VYNz6Nea{SqOsuL)!lHGTMW*<&fPFWV(k*i;`7Ag+B*Vx0q2ICUMp`NXw zhtl?3qfN?F`+7c5Dz|C0uPO8354XSJIc9yjLFKINZ%ryLtYWE2`AP^AlrOm~baJN~ zN#&PK`{~0y-SGUQKT@1$NY{U$mQ0ATiv$>t0?J*ge(`Y< zV68_vu`E9jym|$*eKp%6TxxYL4d}O8(4UR`7ci+qO4ti93s1MCO9!7 zDc-Zga#GRS>hVg)G5O~A-lt>X#Hr1iBaSLtMB+91mf&mpWe2VJf%*EWkcF>#A)=Ly zzaC$Z|DNEm`r!jM@4eRc2ni2z)8<;@r%V>RD%tDkYX%NuDI9((D;r(%*Dqei zP@Ub{R?2}e@bhsb5r(X1%e&?EoMEAs@m30LCieay^`b zW}PQ9Qj3)!l<`aqp|I*aCXLC9o4o*B<&rm-qlV&TZ?dHW1AKm8A77m}#Yf2pTBF#lLqlY%f)etR z9ic4NXyl?|?*CWOiOeWS?%ERK{Ep|c4L!6SC?|}LSRP3x>&7E!gw*C;ey|t~P+Cie zc}ddEBWC~-w8knLbuCvX2p&!=WN9`MxgD*W)Uf% zlC%5jaKRru;mgxMHT~C|FuV$hMyjU3#JpvFsQ8(AL=(U3zonq25%n*e8|fgkJ=@?+oD*snRYb;jc3Z7feQ@TJq*J z6qwd~LZB$-3yXEdB7Je{;t)xKVhSl3j53Og1rbUS9shUxEN|?|jFM$z^JFs`YCFS% z(64g2u&TfkG-hZp7f-!C=!{UCHqm$p6~)w~N@Cu}BrX@_hzxzNiz_%d)cq=J^GO zS2niB=_s3B%9-j$iDa~r5|Glh*IK2P%t$&Jh&SMJE#K@D1Vu_kI4LnEyK5rOaOO{g ztiH0vKmb|$vO=Rz&lw}fyAVVuHG4)H|C9^&ye8$%k_rTDv6vSz@%gN;D@K$fT5Y3L zvRl{r$6=Ztg3th=27oaDG!Pmge1(vgCpio)PLH*{)8>;c|8o2oLhSF-b2L+NR3x8P zgphxEdi}#;?K<1eoj;7UJrfnvDU{K2xiQh2$$BpA_YI#dxhx!hMWp^}+}#@r;pR1a zE+m^8R81OnI3+Qfy5=62s%B59So2=K6&iYUyCLJDY%Q$cTHap2Wo=|)BR+^_Ssre!q_{d3DBY(bGA(XDT@=` zqzO87ttFR5p|lhlJVH^iQ2G=sXizX8JAd(;qb9!0<|Ht&^=*U=t*AJ0SVC?7BVb7p zF-Ro(fQb?e^!`KLz=w%!z3fjMcHy#K5#}R{gu&h*#mH4TOooy^BUXVK1>Mhrz0?pR z0D>t-jUBvI4&1BJ;2w?EYfs*2yk6~69h;3g|L2yuWp8rS;%sg}Pp9ywI)d0=!+4L> zYPB(d7Da33JuV|L-jEk+X172!ga}0M6jiIkH0JEcKfF5fv`*q}`O;~g(VU*RK+3qq zvyyzI@{H)2>$<-`ISLF4g&i*_uzytS{$J`J{w6|Vt4I4DJzJNa6C??km*r&qf)oK6 zg%G;sSVJqq=*2S(y61pSKun?4fuKh%FCGK(cq)$@@;J5?g)mGJT1ANp01vm)#QdO~ z>&khe{C)r>pkr1MxTlGSn)q7N7E5q?YYE|4mN05umjNrnXiH7%o&#EgVhYs+g4VW< zc&yh!pAI(YV52rK1de(57kP;~7y{Z*0JwW9d?~v+LR4)#DXSH$EdrY?V!uU+y9Bh! zDgrxgV53>ly`g(U_Xex2>bn1Y>X$XGlTs!;AfB%|t-}!y+z<+B>pI|0ZJSRCk_ z%s4RQOIG09=Sl9>_i9~|M%C4znI`Pc_sSeMx)ypTFeHUajjrIU%7yC33u> ztZe`PZ`Vjr9|^ASrHAy%64)S!Red=NhDRvD&#?UN^xyPd$;Zjx$L}CFmyKb;EgT?R z^ihp~m_i&iNI>d0NC|@qNO2{T-A2qO!0;d+GUEW*d{{&X7yw$572BTJo{LXmTym>1UyGnt zZeOP<7kvyjxg7z;6*}sWPyyNpJ5;qpoje)DMe})PDxoZLvq!`tXas*dL{owZf+=Mr z-$U-5P>f-f5T4}83C7gO#mn+Q3mzC7RY~({0a6bcnZa+1T@qnwztqTeAw+po-D1o) zRV1>PLy=74h`S9cm^B|lN|Spn;rel(xkIZ5HI;QOuDIox698n?3xsVUm>A_eghMx(tb=Gr=B`GlW^fD*+`;}!=z(FGC(j>WVTNUJi`&{+bo zsy@eYVTPJHBdb*wfxXcE$h?u%;tH2KXMBW)qBjJ9{rYB#qA@cv?J4h;u;{v4&7A&H z34}PFN@hK!NO_d+c55u=BF$?Pp2At=-O8{%9_NM%Fk^FCWG5uM6`glL z@7*jc=!dPTl#btP#7E+?w*s*AM`=H;re)?;uETntgq`$^a!Ll+MRP*<7AD2v${FuB4o_84GzO(G*CFF_C z0qeG6Q|oQ`F@41z47M5()sxJLHZVd|7)mgfO}bCZh!Ir)RNq(6aXts0R>`rV>VG97 z*BApjH)r8|3=`u`OyY`Li61_juV>z>0K5e2EoiXbVFbSFGunJJmR_9HQ$md%;KRDG zRn0)t?o-KE2(jus;q#Nz^JOUy9F&X&C`-(?1e>^>8Z-+N%dom-# z?L8Ma+m#O2h%#3zg!Uvy<1i;r$uW{VL0zQoda)09;@v0rJASzd zmm}NMi>@Ims%?msr^Y7p!4cA|V4o%*B7ydj>Lkgom{Ci3_QB1?gp9gHr~yOu5azVE zW+cXg@$bGp-p^-ax1c@o2{sS~uj(S!y1qV4SUQUpV`QZ7fnaz`WUqwl!jR;ys4@VL zo`TLyykvgP*+ouf!%x+SCxS&Im^eYowBQCCYNcZA7h0<6Nj(cYnv)2j6WF9>&NlMu zbdgaSElO@Z=6(;s*#k&q6-hl)(5z;FgDIl)6PK@;*IcU3}nPAz(%wLE^H)GjE^wKD2X@tDTM72wGLGc_(@8 zJplUwP(kr*<}) z$;X7M@}h-cAg=>(j=@%PNOJKHdET_$aQKmiJx|h6vNTPn% zZNP7bNX6cEz)4=o@Q{7jrWGRqE-X2r(Hf5*0I%Z1`Tgbgczt=+mBb^y=vIwFNK{m# zKw7*B&m+7dQ#POMXqJ}LDy#)-Ypgm*?D~>my+(vd&9zyxV&;=Dq4zJn)^7`fk{T0Y zGr(ZSm3(jnccB}d3jb;&T4-su9|71i)X;88dThrY*)Fu96>J)Hx+^_)L$_`C$9tn1 zj1WakZlfyI-p{Jk?dl+IS=+!|c!|-gl7RJaXhXt+%3@mk<02TB&tkhI%1~=_OX1jj zcQ`lo6o%rDb}#$~$7BoOc6i?BM_nyatU4W;(WMy>uMDCx@fG__#|L)hn_;`PLJngsF7iqYh`M$$#ttE@FDdo?G zU;6@O_FCkes?I5;h(!T|TDh983rv!RI_{1cA0IorD2N;`Ir~dX8P;?Lwp>TrZ5npw zrQ*agP^)RDRIwbCrMWa)szSEt@T{*We#g_T%NIa$s)dcrm7Jed3};<8W(i0_wOS+~ zJf)MXYrcQB>4vCP3+U^ge)e`c?D*w6i6*`?>=-SX-x#vd6-m_;c9%}D54hVkv>L7V z<*LI-+@;=k?M&o&Apg(wZwzqi9F7-LkD87-w7Oy?DCc|rF3LZ0aI6N7>QS^P8-suo zqZ*D7J&YPeSc*21a!z|v$^$v&_XY~oy0nUDNQ0RVW23Feb89{!{KF0e88j+kk#ysT zhv0kM!=OU&@ZQK&73kc=mb0-L3RL|1HuI}(kATV5s9tJ8i2CW9er~kl0|q95InzP_ zgj_5l)iw|!z(zqhr8wMh2>mq$T(LCYlU?KhY4IvXCgE?vXx@=zKMe`y z$wp%AaKhk(A#)QqK9-M%$l`Za=F~UFvh8LTw0KmEl_Q}zrmj;szFG#UM#8M2eiZ`% z1HWg%FSda6nP%o;ZM?6D|0ZFS0cfYdX|PKTExP)CR0v0TaOBskcsA*_*KQ~_kcnv1 zCL9_g1{h=Xc6vLhV2t7zkNc3x$Gz$`u=oGr1y{3WOg_BsPd*^2wEd4zQqSGYJmOOvVaKnj{EQEu+jLi|`*}F> z5Sti*)SfkQsuH3P=IO%+4Rvj-RG8dTHAbY7qXWJ`f9Lv#cmuXAwXM#)nJ%w@de{K# zd2RMTB=flpR>4h7vp0V>3)(&seK}nK?&R7>MhhGzNgF_4A14Kl$V`2bRZO-6OZ$lk z+7$OZdmKp3ZB~(cjW*m^+*H!CPX|Jzl@Z?9$~i6I);+I_h`c^0iLkJ{5p)*cEby@+ z&0&532(bp-v4j>4=|v_M%qHcd&B!miWyFA!=K>7jvJ!&!hJ|?ag!?p*Fr!!ML6TqAz?G+)o0il`N}!>J@jSJ?e0ygenL7;N)Z6oD7DQq+C&WFJ)}v;4bR=MTUA z)z3fPo*qtne!WREXOAQ7WVT%G1T~1P1wk4|u;YzFqL=$OJ?%Nsm*+A=Q6(zydu(Y$>cI9E$ zJr-s^|F#oagXU%&jxsf6*N$Qt#N*c??rO_DRONolqWB?U$mo_vl4-Q$$xhD`BhAth6%t;2x4yvNX^s;!{>n%q%d z!h&0B&w;49_*;fyafFvG@PYv0Ta7IF$RsbGZ;x@k3DIJ(aiV1k;@cOa zgEPJ2lAhkfi%Bq_`fOx(+qzQq@HjhZrq(RkA}ZLcfCT&YhsCrUa}A9I4g|O>6%UR# zmrfRvR*a}^-Ipd1hsYmoVnMGZ9j9nzB4`*QL(XQ1h}hkEJsLr(Q_?|)|1nh1+J-P5 zjgXlJLT{zMT$wu?*5|miZjP*8AlXVwjP-4+7Cb5){D%bIm;+VP9RI`A=jDZ1tlhs3 zkoaAYut!Jp(0e_0DO`^q7305l;ezZ@?G>&83-z&J#K;;_l);@HPUvlVnCdaDn$qQ^ zqq7H9sDFrYLx6*7Jy^`Pe31i3mJ#IFu5Az}*?5BSRyWgqU4s=6r_Ua)2gOLj=+haF zYX~(m298AJBIdW;v4n^n38w7oJQ(hEPGnTifwLf72y~9T- zgu+#fIwvO)9V0QN*cW4}q?mSk9)VFIE5@fhiC8Z3b}X#;`~0Ur{$2yeA18znpU}E9 zd(!69=_(*Yr6iUpc&yhjjju{cNSL$wqNKN%PA^7&q9WW^SB}RtJ$@X&rH6DsrL7WZ zM5uUcw8yfSy(DY zEsV7Ovfa4fU^VWp*s=_C?2euBt4&04;+VSfK(7BJKlSmTRo2YV;--WY#^uO1%um+K_->G=Hq zxEs5xsgqNJ`Dsy%8mA!Xx7h^}{U4*iL2ZO^nfxbq(vfI$ZiOdJ(f7Y65K1e8Kmos{MB#yvr_?f@@+{xApI=# zRgBM0E4mzqPK!maQA|~wm9USHIdfLbTZZbTX9`L4Bz4?$%G?&*8-j9IK+f`;uKFHv z2ZS_Jc44mDFTR~2hRFQ(=8*;o(jmAeiD_agS7c{ESZ%b;1`8|4=8}-k0#ok+U1y6P zn2QDsM~Ock!!YX4PmI9@>*t?1Nqj<5Cm=dOv6F;@n?%8}3*fl?i+Hm+C+2@j@yGis zGo*(Nv8m&Z*{IW>xe%vAaV0~j{ODu5^FA4T z9_Y5?WSXeeVq=99K6O}wis0h0m5)vlQyz$sY9b#Hg^=E&+o@pz|LWN%7co_NA@Cw;4o$F#mcPo99BsmIPv!uL3)D;9-l;cF4$wcxZanclE)V{y@JDf!Z>0gjJ_DIcoGu~@OOg|rLzMovJp!4m0mCvZm|V7{ zz*Wz_2m*{&j)DdbfLk439*+cC|9H&H=Z~itRC3p|PkH7Vv}Y_T3Rzsj#K0(qME(QIYJ~D!W$RU^qJ*2ZY7S9Z z;BAd0R28ur?|H?Sc(culjhD1|Z4$25uu!i!azPE7Kc~Kz$+vqZgA`yCxeLxIwoQrc z-Zz{=DW$KelsWIBOVl*9!d!O6Ro8@{Cxw^$O4kjjaMK5N*ePPXzLBCti(z)lZI$kb zWf3Rdw-TfxTdnR|Yo#=H_0oM~goeVu^`^++kjd$;CM{Y+L9?t7$d(fZ~X{ySKO9*;+`HU9(e5jL-x}PoK8-a&Rt*P z&Ua<9n(L|zrf`+-E3I#0XH0VF+*K7tbor$6NmXC6doR0UQuUp`%3%E;@gthbne}yL zcXm^&CD> z|3ij3DP*^iZMF^MjhV=7pUJe0&q+ry?cSP9muK1Y>f5DsvhBi%MSAyD<>PThj~c2i z<`k8jLI!oa6_n3q%W0}|mzEV#y{o>y#$8&nrQlIo-(-`P%#H)G1J3)fD?Q4%?66A1 zE-b%KT2vvRm(vAKa6`Py_EJkama1oKt4)&Rtk{q_cdWN^z5P%55vAL7oi4TsC?lBZ zgU2mn)t_L%_Fs%%@0>sRpU+EpBeDMfmJyA>cIPew8T0RR9109f<@5dZ)H0MEDp09cU#0RR9100000000000000000000 z0000QfjS$mG8~C^24Db%4hVrD37i!X2nvFv5P|u83xpy70X7081BgHbAO(X&2Z(kI zfmRz*w+F=24)VCWRi)4&=-L8`o&rwh0-bi>BM*cl$m{{K%)Dl%r%Nz$&v z0aMi9=qMt>VRk7hGEKwwr_$ze*52V-b5_5`JZmroB22qr#aiiwvH8{I%vym3q6yyE z<=pC)Be!_?eSAzfjj56)1`-M19<*?7h$gB#X(#^tw>MhK%guD}XKYy9$OjK(%gsLw zg(`s%yn!Gh4`btak#+;2_zxL}gv1l~3L~tnY$0wl`X^b+f4rF5g!(SpfHsTsTL6Uq!^8n-)XPZMc;1GE+Q{nT+NlMxNuQ-+jZ`G z{{P$3y71orRn;wfh6Tm}K(#?&8R|#|(o%0}pr@B8)afNm^3A*%P`dGEn*}~5qQ+o* z_lP%Iqlq?#M3pAm5<{gCDmnO3ffvqRp5Uo~T9c}l<9{t(b~d!D&KwSnP^}sld(Zxz z2!mx1s!9|QYU_OFl1?pY_HaMnk$u95T6G8vbP4VTaO5_ZY0(uhtN-aZAO5%X|7&l; z0z^ebQ7mFB>KKKIG1Ja?TJC)j@$+}y-_M*^ZQ4{nO{s@Uc>yc}2~FtP84z?Vv=;$w z0Gy=QhdS+Ul~!_+BEhzH{$Fbsm_j9R9gv;9hdNU_Ilv}NB3pNo?GUuAtu$=YLtEe4 z+p^c7tJX3TvRCqqdz4mo5B_*C5+@PD08jt>wYlGH#bi)Rdr0y=Qt{d;fe39W*yaZj zL<~#9!s=3qXQ3_SqDokSQ8qw#0Ww@jPe#xNCI9)0`ioj!HIfm3)vjLpwZN> z)CFr(O@bg@*a55iVaQH3tkMQzts_)emUK1=yK zXMWBgoBsiizayOj6LQ{#5YK+{V%5u8rrqw(dKSxlFUNm=tzYHN`DW)UGzOK>M3bfR zv*_Kk51UVTY$R=52G>w5lL(C{W?3}SiKL$3`S)*p2p@=rU1@EJ^uYSd>2uT)3RFC8 zTH9y`!{Kjq=zlF%4~C)3a>-U1d=Qiuv>k%By3lWZXy%!oUxi3mBrMxQtYjFQ z^{C6_%58|KL@8M7pS8VxrP{d;m%+J$43cd`LL-d?X+$1jewTfyiw**jx<_b-`DKwU z8@n=j_1hUH1=3PUDzJwP=Ij-487KyY#^y(lOMnRg-JnJY0u%&%G!ppiU5g=xWP%|L z9x{o8Y*HW>7vzx(6)1#CR6teQK^=63dg%@IH3%AF2sF${Xp{+vOD-X(RCF3V4y>yheo?&U-EPz-_Ct39f<5;X+Gea1i#G zJs4f8fn~73Ge!?)!DC?r#zX&;59Aej0(~KS79P>qoxTFP4PAwX zXd@M&br$XE-~Q;Q7wDUOmXGovALM2(=Um>wPJ~yn|G^78vpZX}9$V$>&%zR~ugi-` zQSHL3zbOR336O@X-8As9tKd_h7wNu!jwqzm9nu->KkrGC=+wKNFi*P(e4ppCp>7Laj87|e>RJCfK<5QMH!l*1=^j{-exA+rhUC$ z&g$U>vUnu1ieX})Zd83QNCDJZnp&^wegp(aG+Fd@QMHOTDfo6^T^SkaP0tAOIZ!$r zOgFbf7RU^Yu)?jte(QUo{sApfiz7)J#0~`J`u!LMKnStjvM$BymPd&Q!T_`AAp+_J z2;D+Z1uCE#-Jk{y5CMutc#!XtNCfl?o+FdhXoB23G5FG9%2dgVCk@#$uY(3!w|ZbKYtTLiY4j z7~$Vj#t_+NyM5}NaLQ>HTttLnNQlVDDQIXY7#T^K#Za(_TePyeJt*gR6;3J_0gp67 z8S=;!k3=P;s`9A44#X|kpXja!v7Qk1qF8TA^nDQ3&mfG!hF}eSJXvX&5m;l5Lyb4S ziIMn78wf52G6ZKIiF$HQkUB-iX|eUaA0nj-1~k_?L`DOc`;)v~8LG#VN+gXhJ8<~y%$cB!k>KSSBY2|yfS6UmLpfbVc)t~r7G2Y+GyJkx2{pEPA8pp(X}ho z)56vF@IQK(;YJv*!N=f$2=6x0J_C~vlrNtY5P^*<)j_FNlxah$whHP$i8@cJo`YQM zUgEk9;>Z~pk~pwYWfoG}#{NP(?4-6A2B@b(14T|2NY^zAt`-b*DhL*sSM$n6g&XZC zgb;y5%4d=SVoCIJ>ZPt2r9`U`u@JD}B&bC~l2EvaG%*rD5(oOXoaPY*w02z3QV9h* z3V^ed^b##a6A;#VDy7gN00>X7{JS5_PRIJS+YYa36*IV#A@8^bjeE>T$b4lRVQX*d zWm`SpD4y-Cm`fSz0(sLOsz2aUT2IkowK?c$Hw2T&R_t97dR_UxHBJ|%D6k$?z%VRM zOu=wtM2V*ge!kLc7F?Iy6dU+_ZwtMdH{UnHOt5PKE;l3F*az%g8s~U76Ro6&A$;m@ zlO2|}ew$q_DXmsTza57zggmDy=8BrQ-rYI zrfB-&+Ek7jN!1aI<&s-qisk+X!r?meGi@_;Q!-r_XE&^NC+Y;IW`K}DdNbB zIi--O7p2@#CQtJAK@NF}kmqVJsLv<|9_h|$Ms-Vi`J5AaRPFHTJPgX)v?uj9LLy?) z%AR$?#7kghW9N}E;{WbrsC|%gpYUbO@67zp&hOm7`GJjr&4I0f3wtQsL(v|J_fTR= zl_^(IRl{o3s!ON0&bsKTTYC20dzQJ`JNDd8tquARoZm0(B~!n*-luBZ9F5z1Nlaac z6^?+Ah?peOL6538$cbWy20DgROsu4JW!(Qvgb|&Ilqk_+Si~h>G^2eFW$YhbWXh5) zN3OgoSWclL#Y&VaQ?6oOxs$4ewckXCM`}>3PN#H67hQEr_dY#H&$(Xn-dpK2UH4A< zp}zqJ8dQUKHY7v0VTNY}J#xrcGj1Heb+&D}?bMDPvh!NMqZ+o;M_vTy{|9pKIwht) z!h$0pBqAnZU)-^`EW5*H?$ooB($a1F;$#7b7+BO32JH`f=+3VkvOMFId79eOt$7Tv zLJWeLqH8`47Pt{{%WVWTHAvhdb=z<#^a%#eV>TM@WaA%BH%r7g0-5+6@5kkJEfQ0G z+n$Tz58ZGwcF21%yC8g2eD;lcJ7AX$R{8A{e=ukLu4yJ1Wr%)yXv+twOg6<-(@ZzR zOtZ{3$Iq5pW|cKI*^BhRtEo^w?z!J+EEWK5+Ib{j8blAO8h_bGCQsQDB!^SKwQ=%x zt}Qle|Kdx0Z&4{6YXA|RpO$yMC~s9CyY4#N>K78@ySrStIPR`L#Om?%?+~Nu^Qg)D z+-HcG`LeGexG=wVm`DkdWhC~;kyA4=iG* z0)+?{%fY=A5MlxJc=y@4&{yF_ zbnJhfww7?LSG{RB%Q!l9SnXjyc49NuVnuIS^lsjtV4i>Yxj>kK5*v+ZYZSs~9I8@VV{hc++87yilkTFu;G;IW-=*IXM?ZtB z??BL8KL8zoB8nJ=0w608pf>{jXbDK0Vd(%awa#+>rqX;4&#iZt1qs^B7;`z^&db-Wz4%_R$iPdLni6I>@!{7uqxTLej&gbzKP@Ru;5rZ;YdDPqzR3(BqR z1Y0X}h%axy4}?M983Lf zHT1WL{~r6>Czm~O*&bKiaMewp{Td3qG2a=F{qJo?!F%7>U}H$|%qyouhV>>nYm3dc z+DC}R5fc!RkP8+dP>@i1AqLKRAtDc4d{ z%`{i6qt0s8>8g`^x)`Lt0R|ebhY{j*^VPrp4*^a&?U)lzI?fjdU03uXNadWqY#KF4 z(COXO3(BUMF&TxOuO5Z{>Jh$qPW;-O99Qbk+KAEGR!T{WMAKnkXH7%=qAY z3anm2xmu-o^UHI*PWBW=NFN?aJzm8{D0M?QGS(6iagc5Sk=)=a60lZA6)D7ut;hlG z3Ab=#!gZJ7qPe zUiC1yIsYD?KPfU8j4hc`VscFC^F9&$KLaDA&vk>O9&Wsb=E-kq3e!YTOyw`a{L$;!1(;-xA>?7WwP)T*{oNn%!{2R%sY6sHvhpa=3ETQJ7JLX4SX+?=sC zJyj}-rj`j}EXT$UAyL&ZqLV{{LUDr>MmF&9iB$W2O_BRuhkV}lVavoF^`ch6@Wzv6 zEOv;ow~8?^!B+*ej3K^!!kmC;x=1=6k$e^j#zrog03^*;IK1SgHXJUKVNnnZ-S8tN zQ4;f#+jB3i(?H7YM21A6mxTBR$a^?#O3jMa&^FA{kav0N42_i_=TW0u{7mGmQQ996 zMI%s%G8rfGtkAu{($s zpBczWmxRQokd$Ui=P{8%_x3!q=Fp=urYRc*zpQlawnQMQ7skpYR1?=GTtlp&X`cxC zAhI^;^AEB9NGs~d$iJGQAQ3Pm3Pp*7lEi~42~d_qs7Nueq&QTi#1%fI6lzi|t9xEh zlf2a#*_L+_>UIBj+itz<|9LyqFu`;v%k=c0k+DlRtYZxusMthB8xLa3s(?;vtWdLA+G98jQC!KrgSZ*|jAsY{6 zrMKsHd*Wb}g|yi7Z&gEcWSWxksWI?L>J1Z2fNA#l?TRGiV5k+Lp8^1m-()V2Q(n)v z%M@rZkUc>hjBGzZGt{@5it}_9i8q`&IY6eOeu7#jiDsQiFQ?B3){aH^Bh&UyHdp3} zEM9-9vKJt97OE)k9Zi$Sk{stv@zrta8PKFTw6p9&P0ONq>mW>m-)UJUhgbfD*V&pG z$MS4RU3@=y}5UGPYOVv>9oKysZMmG;!ZN7E{@vV#s!T*x3H&D?YSI>sbU z>jPm(DloD^Q$?7S<=czA_ca=^MxAiTWb%no(N7i2H!!ROi+Z|JQQX`;vRzTjD{~(1 zay-@TC|@hY%4-YC{n7>cEwiH@jn=uJv{#Y!cb{^{Y)!w9Q!YW?#W|47zU`+3JZ%DPwAr#Y91aTIOiy-ykPD?$Y1D?);1xcrek2bYXKF znKRZ`;#OsZ+bghmWfA@EB6eY`jFb>1#$+}br?a5}mm59vo_kVBL+(w^da zFv01=LGKA_^i#81%6^73%YpEg^@0N2Xgo`S6})p6;5!9FD1=r~0kQiNoDd{1si<@V znWXF?KrSlZKq1{CKO~nnP`QALQ*{WHnpM4khI>t!5YyUF?FKsOb-@C9QT+x6$&0pm zh)gLxF^WaWTK^b1J~22Akk_VWKj@udKG2LPV{QePtcrEYJRq_|%AR(sEj zHCELXTgMx6J6q$P`X6jn4iHwqqk!}Rlu;l(07iZVjQb8~y9&zxQl|d`3_z*tEJ7h8 z=|qGeaK+V;BkwV+hQ*1=w->k5jkF~O**S?+dd0bxdsuwIew`e3rbVifvw)o4AfuVp zwTpMSmeir2n%ir`kSv$V%Sjr>^M{{0E5&3hksM5hj!8x;i7Qa0Rp3K-m85LROwLnz zS#DqZO0C>+rX97`E%e>fkEgCA?G;0=$`!?tS60pN+cnph4{6!S1aDA1hIV?=)tRKK zQa;~PL!)Wqbb6+x9n~o~s=Zi@Sh3UbZ0Vw2T&b(+lBDKavZ`sSqLfFw$9}I?Syt`x zF{Np1z0S03R}U8Zy_>cku?lUFzsOeZgQ~wwSGoU&q^QN>Xk=Mx1M(S?q7*X()whh_ptCA$kZ>_Wx(~7(7SsOObU9Kc@IE@=u$e2g-YPfl!- z+Pc@v)&4CS^|pjigg+_!qwpvt7CBm)o(_h0Ohs7Zj5{CUF!ZwT%*pO%iYG(EP91zu^d9 z7B5OBa&)F}+r8uiTF_zC%9mNzXDdWaY6zZm&Q;Srn+(}&y zuJS=9gUG!|C)>{=1g?UeCa5+Rjie^Ib|wjkcr`BeN=kL11US&n`da$E#yvX`Ipy6I zTw_cof&|g~qXC2K03ivJ);f`EtY5drnx!>#)s#NI;8P3o4bbu{K0ggpmkl}9awz5K z%FDCi42t=z{Ou4CG{!t;-nBYh8LF{>h<84^7MxIth;CGF3eDX#g(3uww$t6EU&`|5 zHR}xwa_$WOSI_Zu5YPM{@aS_~sI7;Lr>e8Hr3x)iYYoV01G!&UDP=x>x099 zyro$canScEtbq#D*!Ha^crt6|s|Yeoxpg}qH96on@L_XN?hfTc>Hax1&X6D+(8buC z+d3o7&K_~I3($dc(cV+R@|>SIpz&4bnDhZG68&AZ7!V{G_~s$aClvU+t@1ozzIXcv zkKjzO35ifn7DFyaawt!X2_(n{GH@`SF^fSf5yST9%Ej&suz(`{e|N*6+BEbVV?eZ= zlDgfBfK4i{SrZe6mloyCl2*m{J*V&Q4=L+O#olFlX--j?TFvicMsDN4DMVQCt8NRkC#jgB|83>+@pcTsQ#eo z-3ErfEgRP?+?)2q4{|`bX5lvQbNL_xhb6WwI4SCrIV)gUbQ6iMAMiZejX`ABA;`in z-1dV2RX)&5?q%$X3VQdk>tIdVH}mZPN6P+gm73tsc$trYv_iB7OL$9;ryq|&eRJYZ zoO4K~TXMi(;uNeeg@F^KlC@xXCFW($O5_9pXpA3u;7->yn`-QdJY|ZGNRW+nH|Z{J zR==;&?j)(x9N0X#a9R!h*F2Ys;Z((r8q*gTj z@%%*gJ9T3BO8NOvZDQSc7HDrNR?LeVlD1dN@!QBu#xJ;_-q7(WU0q68qC%g1#x%yk}ak!>Yqw6h{YiWd5LV|Ps z$`Leh@W^WDGM4#=+q<}aMi71N%EzmWQz<+uSc zZH`B(%&0P@0$rueMN03<@3(3TCfzw&9+xMRjklaRdLB7*2d^;}98^7kdB)o&Q(zi{ z!Mnmz3Xw2DC`;o?4Lg(80aOD0D~0iIK?wjtX<5Z~Rek+BC_@+NmX9Rlj8@}`oDnQF z>ku>-M{}TbxvCT$o!%*n(u-4v%QBAT^N1WRNTyl~0x!k6?(V7$P+CiH977u5%P#cS zV#1ZP##&Ei5!NT=IkEts&%XkmtyCSesh=UCMyBH;e_lLt@6xD5Evc{$7EE9v(evS# zFmYTFJ>yacoX^YMGog66het8lnOdp|%dzxWHo?EQK&Byo1JsK!K8 z_r={mnwCg@d&c86718bL+oI539u}N$qfOjldiQ%((x?8u14%m)LGpk{be2a}owPhc zF)9=iJpn>hNgw6JrKn>?^d??fX#?$uNMFia31Lm(X%<}^Wf5ETL5==NISHhDUYc~A z*kj|8i8DB~n^7Nsn%l+KzDTv6MG{AoCQ96izR9kJ)Y>g+KgP`Xl9Gy${87mY>>Im; zmE^K{^nf@z)6W2m%RZ1c+EWPpG0L1(%h2edGKE{cqZM=!2@Ow!2CM5h#$jd67(%eI zIN>)Zo02qoZEQ#01NP1V18#{9^V0L1U^`e})dv$+O`?R1k9^3Eu_>h_#9}{b*3M0P z*W-<|)B`v78sf_c%e7$??eBt4gubnYgT#JZH1*uL7@&PTl=!z;y6MzgHRny8)wO*{ z8eQ^W7NM2#D8?8gNW&Jm2-GeHIB})>eTupE{}8shTqJP*9O}E^nc4P$F`{GblSc+qv0a zk~!xh6BD-c8k_?m_aYtI+;Sk)waU_}DsPii(Oq}5;6ikCw(c-YDaV0)woKisU;(XA z|HRt*k8(P&za7~t^(^d+!f%wg(E1EE=Ss<0ZAbgNNXa5-i||gA8|^-FU-pjC^w6wT zIs!9;=2$0|P8$rD_guE^}~Pjq|~i_6&tx~?Oej+dBl82W!NUGVdygOgql77 zdF^4^uLD@8`eK!oMPL%RcUcZTCMMpS4xYBF>E!Y~IaMtm}mru5}51{OlPhiekh&u)|sSRP1_3MLeIo zIDmu*t2WG4?k@B3Dc2x5)_|Df!=59u&549=Bpb?TH52}0O17U2YGa{Sjp2q!OlPiv zG3V@-_2V{L5n!8n6mgKd% zh@3CiL!Cc)#iJ!D7oR)MLugAgTtPubmxI_`(1`Me=2fFd;YmFLX|%>qVs2&}7ekcW z2N1{iJ@=~|It!IzA&(bzW)qcTc27^UmkIE28JObJDA?tRxLnwsdTE!_)NoOb(FfBa zRvxZ7UJw;yXomnZwyO5ml#(XZR4vr7$)y3FFFch6h@GvU{v)^~w|52+675SR1vILB z2%USgEI?c7l@Nc7G2)avO_GPLa{c49tx~VXk%(&k*lZgeLo8GIbi32**@0gix8 zJaLYlk`^6QPv#V2>P`iO3)B!NFuo6MqEUATY5!7!Nn+Nl5F-*1}p01OPaNqjv!YVl2*mn(MNJ=1akE(5SK=S$)QyQ?+$Jm_kj zV2eg3SX|Ay00T)pmEs7YE>{?JyjX;n`9nbmg_?vxGv4pQQoiUBr|NOjUlDcFreZ`M zOuMQXC7+|>r`?~A1gUp+lW2tu##aM09r`QenH!UgZ|J2YLAoMt^H* z1j|frAMrnTj?cUlfNvC6PbEe}zoifA0FX|-R)Db$*abvKJe7ill&M*ThN{>dKxrT? zPf(*oiQNOA`#ukp;IO$D+A&{Q>3ng!#UiUnmQFe^tOm_!5B`F>+7=mddTg%79MT}% z4+3*2kS`Z<{+%8Vxt)Hu#SOspM?Hf0<#pyvdU^Il-=z};GI*yf0A8?RK%^t$={l3C zmpMn!Yn>%_njMY4-1#E8X1~vFMSbSSQoP&8K4)W>8P#W83ni?h!TM72c!UUxK1&zU ze;>od5s_9hddXlCg-aOl9ino6`Vka>gdJXENHq2oS+rl@hp-o#aTX_ws^`zQ0 z=(BK6H&2SW8>4a0*WSv?aYKXmYme<#8DX*50V1GlLlAtzPqakw9i6}|TCEQ#7gQJ% zk8rXv}%2LTuah(*4X{VM2hbGHR=p)=lf zgu^aZA9uWaGH@&CYKz)YnQuHbzs0`42SzB36!|(bgooCW<&=6t8S~B_Inu-*k)PZP zlPL{>(1a`Ljw^DcZVyemx^4GWIi-5E>ge9-Fe&oMParMa{X~wGOT_yhl_k@XxJfQrZ6T4A+i8xiYLmQc8i6(TngQSt2u zPL=uBm_<@ypoEC#e@tV*Xb0teer;tAp;l>#=?YZ+j^x2w8mNxeMWcBgrH$hD+@)8$&Cuna|5^p zz&QDYutZ{;pk%-GELW=%0Z?K(&(Ffk_W$Sep<<@DK8yx0(seM`fB!9b2H@*U_qaR1n`Y_X*7C<3={}+!025DOOValgJd5ezd z4A&l41ZSMJ8O}C*Wke&Fl3n3wq%&0YAQ-xL^};H-%<>+OPtW=n`LT5zElIusCeHKI zy`o=sAMsETzJY|r4EzF?mLy*fljga`GE%h4hUsl@+ieTxutXx(oIZ4NN`*B8~D?jg2`Yosxzbc=TO1J?) zx^q4%bH`p_9sfup0E-lp=YHS@2ykyp_ws}B%wUSpVlacF#HkS=?a%3b9{n(Sf98s| z$=nZiQlSFM=;5?z&>kegTH4a6x80kXdplC}kF!4HOPr?b;B?3)kV<%o9->5)w)2$J zy>UlKEPXx}n@^~#Eei2|ue2JgQi*DGt^*%l7g|tcuJiYCxm(NVNAoKjg;rh;OJAA* z?7*oaT@?8>fEuBmoAb9W1t$3Uz+qWIjj$M3tG{_!sy-tmE3ADraXpO&#hpJO0CE;S$MQ+BB;y(3?*rzd|UjDvI{o*E48EiG{*V6J8 zeCfFoJDQKiQLR}NO zo2Hu?sJTzP@p3FY^>v$Z2Nq8(+M+WZYW$_3FnXBgYs$-wFQ92*$1*#h{@~jm-MVO? zKBDZrVU$XZH7liJ;b+@$_~Uro_PZjnWp>xJ=WX7YmNpoOuu4H_kkvJY(Bpq4Wu8dafO5zy{iQdF_5EN0t$v8Z1jHkV922Jie1UKiShr4m`-XCx<8EK%%8*3pv)L1L0)DnWM z*G8oWvjc^hF`|k@5@Mi)?Ejq?ASr1UxX`sqNF{&sOvK+$x0$Ta3j!@%ZYT9^VtN2* zwHvhJDy)K4B4-)s`-OR37TzmMclea00WBK+xcM~{RfBfy=;BN z;bFo`89V^&l6S_ji5W9HF+dBl8T^iDnBNi5QEz2yPyZ9t>NL68l-JeDWB&$pngkh} z#uGR7l36TrZ=WD@CdN)%bru8qrfUN55PXBkwBBY;)3nL0turP!ahhlJwi=qA7)8cD zYPjj%{7r-Ez$s##!H>)e>Blx^jfL?60C|x~?IzCk7WlCRWBRLV6(Mp-G{14m7s)n@ ze2qpkU&-nHez+)^wnT%{mD{K@m6{7n+y3e7t8hNea9feCR(~|o5$Qfdqf)D>RPMx; zc%g6UO^xB%1`q9m4hDgzA{ZF%@+p;ZYrYl-g2?bO4po)U#9)LKLV96Ns%2wAHa zax>&tpvV8ouZzxh&*hnDtZ0( zD#ul$G$xEJW=}6s=94R$6rHe+wlU%;*KIG~FISH_>u|6BUMs)W>&sc^?2kXmWfJZK zpw<||CAjY&xAFcN z+?IpNxdwenMEYf_0O_}y-~L?6I&k@&tc-Ww99p5*{55;)ID%+38Alw}jix6|Q5*Df zC3Z=N)?me8#O3O5$w8xS{?5k58p+LGpJ&}OiJE)X?q2QOkMz3InZ1*noBOr?Q?IsZ zEA(yEWglN7Ta|GN0jOK>v_VUz6d?m=eVmIn!0{hyEg3&;OJ1`5SI}HgukyH? z70QDc~^j5kL9;<4Yp{D{xIcJhpi-UJ>PAPR+#48)W&45-K@!LU7d7B`K`;fx|8 z1gl|7q7a2@K{yKUaR&dpH7xer zZ~0?~+O3EkVBnMMjxO)NRdxeVO(;Ao60iLwCf~*5_l@{fYL!>{*<#*t1L|!FMW?-1Z4fy4 zCnRoTmu{Y9gS9oj9vR9L^xnx;YV^y?jij=w5}>nFk2?+2*vae3Q-kKU`z(lqIOCe{ zHYbCut)z;cBGmj*D*H1z#hOK*?tjAPQh7Wom-oHEzYPQFj}S`1XqE1f0x>J*e2W3( zO;4t?%pmUT7A_a-pf|6Nt2%#S=z?SS2T|Eiu}VOEB=Hk>F(B9eePYxsp|nP&nIL7p zCO^CuFveynZ`6A0Ct)S39~a7GspAAWdMNh_o708u{e8{H!C3Fwg|+X-Sg~BAUw>5V5gJ@AFk@ zy>d~RXr3==9IJyH0R~hL_#~Cz*bEaJsF zc}~^~$y9+}z4(XV*E!E0%-($ZtPR>%c*7Vw-2AEiw-s++c%2$RcHMbfInqB>FI$X( zJm!<>#BKm?1JFZd_(OfqJal~=yUiH!@M68>?-)23?B`0p8ky72U^;w_QSh_C;$|{> z<8K4-TR}=DeO$^4o|hxu(W>lWIi-Bd-$dHBQ>IOzw`?#TLW-naCX@KFg&7}~Q38Y@ zu<$??@LdfuI1lXkh7q1C( ze{T`E?!~9H;Fwo{ivn$D9*TcEc&%Sepi>KQ;maR@lpx;wusrnhyTY0|Up&7+=`ThA zZHF|A~AqNSxwp1wr>1`5MFn5-jV_ zrQu&Q2h={FCfn;BJ!(#9d|vlqkdFH1QpAf-@yZ0gtkO{pSb|-O;`j2+ac8b$&x`zV zY?qH+q_(7ERrLvqT-76pi)h8r*q)$?lGpUjA&Qz*}kHt;O==^`}tru`)5SzGoku1%MNbYRsJc;z6F{U@ijY zmN~;sO+@iv*^7K!CN0g#=LRKJdlymg33m%CQb-$a5Y`xYR`y464aQ)NxfTIyuNp8) z{wGx63fxu40p5UNG(4@jSwCqq;Gv@MSzus8i3!3rjUR7}oWj1fjT0@LLF?6{&lV@Q3;Nj)jpL)OhDek$WWHc}VWD8!OXM=*Cy2r0*#xbX`U5)r~v@e&v!u7KhpKe%si%IUH<~X z0^9R4y^0;1@qQMIaqN8Sy8R1mtdK^j<*MS#dgYV^MZUbZC5H&F`A`GZbCIaD=lOGZ z$NQk?E8x9cKzPf14bJOB|CPz|g5D!ppf@6%9{KJpw?EmMp1`AFKW)P1kIHi z8Y@wFZcNOe(Vy`XG7+kUN@bq|l&s6+1RBp$Yev_+{?5W>X!&A7O&Sumb+tLd+7jj* z9}N4y5W&D_pTu=1mnPz?>qn0xcTGHStP(SPsa&6|P&&qv$1%T6XSXky(`X+{W=klX zm;BwAdf1eqQ8D@Bq(Sqs!x|7$sdUp{ zH?GvCR{Ae6YJ?du0RRiYKo^QT4${j0Nn6AOLIB1hLu6zK8T$e@KSv(^ioB6h09beZ zz#1(~vyhdWiMBiY{}k5I4N;U#C8NeDT?c=fj^H%qJsreo05?oVS3HiYR8ftZnxaaL zrb!u9Yh=vQh+{&DB$EtFTTh&dCvM3o{!uE+Ejgu%eNL6Kf;X@6nl|4FQhs2#MVc~! z0c|xgnBXc`X)|#~gW2%UroYh|{A+j;QbmzlhG8-#K#|3Vd1KrCFWYKKh#`oC_{gZg z`3gQyf?_dak`cE>JY`rY31Z0PTM8xncy6cF6dfNVw~~V}uMkf}f@D#y3xjO`;DYpa>Nht!k>qh{jKJvzSAzZ zP_nFkJP@8Qm0pYU*DxzzP{sjRJk`=74az)Ra(LBfif+_s*ZU>{5DfSTfF3_2P^e{T zY5{F#<-AL(uA8NqnI&1BtYr5`SCTT=1t=d3AhZIy;P+fWKy>8d6-KIZj*6ez7bFB3 z9H54i2?Np*5pUQZ5PeA@_-ca2d^IEB)k<;QVDM2?c4Q z0Pn}NGcPrBMl*LcuaB_i!ZaY{GV-Dcr zO0n-?a6KgzaqUiUf0yI9C9F=99F#U8+$L)p6+isv8i41Ywm0>@?H5#KI*4MQHEghk zgVu1;nyjNPz%Kh`JsLTHMoyr?+6+_uw}60-(Q6Z)mEhz{@gUox`x--F->(xgEM0p1 z2?ycNI23=u;pVR?{0#@=?>I8B$XuL@OUw}^e6N_3e)%oVWEn8lCU=3x&$$8;fnWVA zj!SN=5|P<{_}{6Mo0r>qR77fDd7MUHIqck@LUp{Z3 z@ZlLc{*~78k2`CcMHb_QrFiM5VebeSxe+&txWJ1?Y%eezl#^%bN40fv;FHFgiuJLT z2UV;?(0{@4(ewo``lJrzai4NubjO*=wRbalQMGk(>zQ(A%;ts8b!g;aLqGnmy3-+4 zd4S`OIQtyu-uH9mL7flK>;itVc8yR99?=JOFCGVc(2;%B-^)E~~7p5tAk8)oTF=v|)0L;L&bj&t6wG~<2Fq||b3xq82Xx*}oYRO*@cas}^w znUP9be$Mq=0yyXvp}A#qhyG_40p$lF&2f+%ccni@C!Etou0rHH@U8m<{ebkd&k`?^ zce=F!$QU>0UeZ!Vaj*2OwNKU$wYF8usH>}ZbULM1+d0*qfsB~J*xJBqi#)YAdzAZD#TbItneWu3;_1{nc6vI-p8hp z189LYvAOGfEz1KDwgNHX7K_%xN#!BPGN6=l;mkNd#Cbly@{YB3COH16JdVam3uN`M zF$J(XLE;pK&U703?sTgn{%DzR$>-;}P`p+J>fMutE29@sA*(n;gr{-rb%R>LRX>x%`xZ?@K?wG!iidA8y;%|+e z*d-vSR|I8BkAlL9z3Vy@$$>3vY}-8YX&kBwNJu@+9W!RpOOzo-DVt(qg7I6~HIp89 z#~yvp@33+bk87(MlzJq@)QksMoj8n|&|U1P8Z~SoWfAO=BoN8;HH2GtIdgt*u^Wgls4&4Q{UbAMUON^r?_)P6{Qsw!nI>t+ks9QY*I z{9P=YWyn_jb(XLURBzZl+2Gg0YBrZSxjgSPr-Y9Z;r{Wp@pK7GpxQd2aKOfm6CyYc z9k6RAeA?76f9_EzZNlleGmYF0`2WKEc{QFcp}7m@0>#^sPj|qfH8XsHEo>R)@|)Tf zMGPVh?+o7IH~{?ivT;g=vSSdmlG4t!?OduH$?`nfHV3GaE6i}1aRZu0S-4bQ(E{lx z3bXqr|7HK&X1ZOIe5MA2>)d=gJ%sS66Y=X3QQ+k5R+$Z3Fu>sxNI=^2 z2F(HH$_*>SoWTcbH!4p(KZnQ@Aet&7dh|D#$-z2B%Tsf3)wj$>V~XLZp-t-scSv9> z2f`o)e?2@zga$|(Cy?6vrIuK@v?I!Kib8O2N%1HJMy9YwMM;lf0YL(9BI}BV1`ha8&sw1#Pg=^LZO~<*rRbsE&=UPn~l^=G!eDsX#K) zazBdh@qc!sk?G5gr)^o9MzND&hJFc9lsZ_C!5pIYcx?Uj7NCPq1$&c^=yGh0?=}@hDg8-0T zR~6tuV!kk)K(p07P)G|Htmy9Wn+|ORo0$OSn6XS4(2TAp+);{LE0B5a(cINOHLVZ7Q}&lMQQQjPdHO#yS(! zW0>keIOuOoiE0T7KfHUqzv^~4kQaLyTTiML3?heUVy1DEF#v9KtqL&BZnnRub84u@ zhAjLXS1q3L<^Y^(U}N;iEGTDGAxs!V?6YYyKc#1a;3oWK6p#mY9l%F{ZwVMJD<&(G z!kq7L0v*^kl#-#$;(4BM6sDHDM$3rgxI3YCRN~>MU_4?Abg!P+9ED2M#`abTg$u&a_;Q79Mer|;*R0L)*@K_4nGkV%ZlGfk8{I7=G z#3jo@$R3hnK<%(Ythf{A0TB!b=hgqJe}mmqvpeRQm|f|t`~ zg-z=eeB$RiDY{95@}{qhqeMoJ29Zt7QlUywrMHho(rSnO_y{~kkC(S^?rt_KHpRpx zL;c+P{#w*>2%%9UEffJva{NcaKbo$AeYp{^QM=Qj1<)HmTw_^*a6r1oH;VlD3STH zmi=f&uQLnn+;3_T`IeI?J-#>h$jD!sfI6U~=+Xgl$=4l<2xYMxY0-p$@d<^nu_4QG z@|-ru)Xz{;b7Dl|Z|+PpXS2sjVzIW{vkd6o-Lh@2_j=oNe6%JyS+O&+tI3$R&{u#7;$9xv;&Dcl_w9TtDS+jb45MC!H8={j(=C(l2g( zM<0bQ@cZBX{HNdj*5%`y^W)t<8e{yzGmZ z+T|i%E>$u56irhHDN?PWrxd0MZw5f7ynQs^|L)h(hqq4;*T<qeOkeJIUap_kYNq@&bL0kf4I9ote4OGzE`!} zVhGH({@xx;^)N z`{3jyCO3IHYw;HM7RFy`HQnBq71`oeXzJ)%2_!O|<+hhZB^en2fB;yR)h7(|#zo+SSwR5+^VD5V42c`h{u zA4r&^L0-JSXUI?wIjmSKkSNZfyaUpcbN!KD_W+41)`W)wwxq! zj6yfek{eZ!{F#f!TIc0fy)Cl;slCg+dv{6J4zTIF6; zWysbYx(>s;ldl1W9=Px8T5)mpTq^ z&3OUu{eQvM-=~^NvTG~1hv~>{H=byTkzlf1&CbCLQ(r?$q1D8<^v+QbMSs1M?FQJp zMWxpzMf++hQ7Wo{N$lFT`<~a@S3>(e^C*FU#8i&u6=!X;nBaKSxtI4AcE4mDxx(k& zlYe#KvS+kZ>Bm3%?sqytz z4Cu_Z4n>T2B)As3%6Goz$f}Yd?b}nn_atx55dGfcDC%+vm7oFv3PAwCu|NRU zV9G3ETc~3h5LYVeXvH`4-Qi#)oo&G@S?GTIDywnypYm5^w>fEU{Y#c8i%jNik?Zm-H+jqH6DQ4$ zLlISs%T~**?f^yP<=&n4HVq~$AbW0BeVk26Q_MF_GkPUKvM0CXSR-`HcdL%=H^;K} z$!1}~DrSTDgk5{0G#wn8k)1ckYT3TG#C`)-eWQi>KJrS*6$qP3^*=zaKm@CGe!e-b zm%Ad7xJICDdh~?A$a9I*^za0QC7gNF|2%z{3W7Rxs?|yjqatdaF=M>iN zv%@%u^n}Pq5tlLy8T?a{DP`1n%*#0hfPH050gAo*+ue3_rkx(L@9dwBNU7(28s%9W zyC~M&wwWq1HWxtu{_F4t(e!$t~hIB0$iH{WQag2G8fYj=7i z(ym7$-W_|cBfi!I>-oW2gtL!>N(%f!oQR}DohD%k&e)P!*CyuOc{z!7uYz$iu*YN*;qlYCv+QY*H~ z8Rc#7kqp~@4o$8!3k%HT8~tM|o$TeWD_5Fey4MML#SG2!UCSt0BR(2Y$v0cz32gTP zi3cOjTQ=;O<~8?5V8?YiQshz$)7&!h2&K!75(+=^Kd+^yOX#PV9a)ld6f|vVz7cIl zN+coKw%*KDY~?hgobKI4v3Is;5vXI-x*3aVuF2w4*R9tKi|fTP+_XP-A1 zia2Cx9CRbb zeP(ZOgW$(kw^48tTz8$zD=M~RCEuGzs&Ya5YIqM++$xfB8%-yEJ<}q}4vq^%s`qU$ z`A1{~E=ss8P->KVn4lsm4go@Z?^PM0gE;K$o*9lyu#Je~Z=XOE7SCtk{GIc+FCVdo z-TCx%Fhn@Dp*4QoB3ag{jWilG>1KhZE}{}#2;xLFm5j1n9kX?9=P68je`+=lk~}J2 zuhm?ap>|z6?ly}L*Yly*=x*F~8qYbVs%)($x8QBvI?$j!O*U^48RXBJ z^|+l5oqh-;Ya1T-Dci(*o!oN@KbrbN#XSneqD4(fzi2`rvHvp#GUccv`Cm0##+##E z<>Z2Oc0&zp86@GDf#=sAh%=5p3Wi`rn*hPkorikBsKJ~O_DDvoY zS%=H>z99$e9uH|6(f__=KS|!=UI{fP6*iQ1VkU^Hq@m}UTW<;N?&#RJ?r3FZ167+F z@!Xp*3{#u*pp-a+=`3Mon7cA)f`h!9CxS!DB}G@MVQT5226zz`W07rIu%57$A%{pX zKdzsUL!?tIYbgRaup5e+*Ym2~nab>{S^DZ4XJ3!tuW*?(-1!=7wi7e;`BKgK#{=Yh z(!O-yyS`KKmzQK{^v>}H>-wWY79f!0pMLRb70!R0NFM-xxcu+8{Kx9A5bOW_crDMA z3qhbG0ubFBo_7;s0(pPgP^W_)%3`6@Sr?(rNG)-J`Z<62tU6nFRe<)cAw?qOzk z2nhL~!_{p-Uh=8if$jir1h#$Zom|N-dT5;;tkWX7^^8W@+C2S*POvnWsWaqa@dn1z zMc488mMvdZ3Xw&C4<7$ZHCtvG?DB4Ol>1sTIh%o_O25#GSIJMXpTYp%DJFvyO1M|y2q~KYuAHS)b9$V& z@wrr|hRL_)tkGGEhml~nzG(i#YB@(*Ae+$^@Gho>79nJ0SEhnJJ((L2>}SDwNYkRn z>Cq^uYhmeht~My{Ybwg8&$sxuD5Sx#;(`RZ<> z$~1|En8DNzhz!FZO|zi=T|%`Nw$Niv{8Trl{+{q}IQYY|_N2=~{0!`xZCu%BE6*GC69@dvW8T5;W0SZ{6jP z_R>jn4an-_@B836SS7J@eJz@%DW4pzPN$IhVpoHI2MCViEt~h;EcG*7yt-C(W1f?p zLn>a=n{_@xzE>&s)uU7$H)b20>Q=1}JU7Lt69(Rvy6V0e2dkCWjl6fE9(w5`TX}8a zR4l@OVRbvFSb7a=Am2;O8vLu)dUZCKCsLG7qCFI&v;X|>uvivx;vI3+F~=nw^LWy` z7-FfkP* zuULsvWzHL9h@pXDu=3!cLS+C{sWyzya3hT}!eXOs*2FW@rtgXAT4-sEamE{~mDbv5 z>xn(uIix-W6rd1AD28ACCQy)IUn%DL^#5z2pcG{&M+GXQMUPQx%vhzB9y?Cs$|$p} zxbfmANSG*blBCI!r$}j^H-7gA8ii0sYcZBGS>jJq$#4Q>l8|bzBd;vVsBE!Sj=%hE zo9%YkX|**DIEW=qLK>6oCy32t-$hIAxfkBT3m0agWol$cM<{~GG*iqp!z>e{&!B?` zF=on~C2O|qIdbO8U5?qVxnPc5mt1x614UjEi;-J39o>x`#f4Ggl&)5-el+8IP2H18 z;o69%qqU_T!D_ME)|szknAB3+Rt8!*@#`++OvVV^UCpIExo=63jH$!Oi7~2a9&7F0 zchaQRei|2>q}2YJbg{11lLh%&B(9rID%pgHAP`XrvS>@hjv=B@u^AORh={^O;fd28YIAlU*=Adde~&q`FrwEF zCg*gS2obyuf{8YJUGaVH*ylUd(4X%(OZ?EcuxPZifn4AChD??1=5YKLcI3&e9ZkKw zvE1tJZnxMbTT;}f?#a=$$-*~q$hh)%{MS!?A^g++pSaSj@S88F8-K<21a$!~v7b=7 zB7v6*B_W>Fb3)}-9LZ_&;EEKH8TAtOmFmgtt?+n(H8vn8zGpIO!keca1G z7EW3J(979wZ&-2^-bM8yJpNP?j*s*B;stQ-#&PAu|{xUqjx!`{u z@cAsr6=UQ^)_^f87c7ghHLEhGeR?|MPOpr4q28S{YTlh*8hurIQ3=nNuJZj>OFRU7 z8eENFfkAr)Y6GX{B-_gp2jN97hYrpEe}4e`wa*KpED1EhFlz!!E6wBs>7V=4Z|nbv zn=s&j5=Jx77ADr1vATI*=Kn|3F zfZT+Qb4X<#NVZ1<2ka=Nk_QglqIH#uQl(0VrgKpls%NewgIdkvwn-*1lQm_Y#PwwcpN( z=r@0;g}O&hq)rLMivO=am4~Ti6|VX^*+;)XfnSoT<%IQ$QvBm#_AqOkt`w%=<`ao1 z+M2fMj@GmpyKCvFNfE%=*RKOb4wmpzKCAj=J4$%I+sb>4%E|%IQFA$16N8(Z>eV$&_rNq0+sj{my{m0)0cK-$e zSP(h{DgY4g2vD{F!qkwEbS6NY42l;dI;8{x<;h0dk6fVkAIGlAsj1unPEKRaOM6rUqEebinGY4^}rLu=*N<)!#I%!B!&f zx{J8)KJ?Hd=!qxL2Oppc6{r9PsL;p(1%U-6=?mI>->SvH{@tQ?S4c(!x?h7R%Dd(wH{^-h7ZiM_E|y$}o5o0k_*D;rU1mk*mtnB;mXl)(Ab9#>qJfsk#bnbY za0};RXQYPq?;_NB=b*is8Q3uY{rqb7YL?nQq^?iD@%aD-X=IT{F-lPZEEt{*GdvY$ zcs$JTNSNWlFvEReu_|1bVUPtjbgWiZ&~FgYu&?aX5DaFz>fnIp)NClHz9HiE%duAY zBlx`lZx}a6&@&9h-s2-~k2QMoiMi zR-hV=ZpmTT3)^9{vgu$2ECQavk~Sus3X{f%!)>O{^rZ#{H zG@K1&Os=;$PD&<@(7Y{N^u}bu6C&V0zM3 zs>gHk_+WGP<-zPkxy5WOpT7ZmHV815dP(`;lH~DV_K^kq)15*N|KyswqBxdr> z0@(S|Lo4yeAzG^%Q8IYDX-2vbio=`?m8sxxrMgrXs^i2OFp7fB1>&TXSuRCNE~D~g zQ&<(MQbS)OtIV-e({VaShJMb=&de)XC?_jDjJo(P2B0A|8je%j*NYQCFzt=Cd({%^ z$3pGB824W$>Q8*YjW!2X8k7LJp>W6rtP1-|z_td_S*wL2za zzZ>_&M;d|*6`LM!6BBd)vRjX6=E0t#zXhX@TD6TwnHbw@kM=yq*mZ2HXhv#_&egi* zQJkz_yV0xtKeIahfF_STG`Gc^5E=O{rH;7ls%vh$gK(fph{(t(7`!Xk4@EftvzXI0X z2%J8~0R4R?BgdbW) z|3svy6s!;}<||42%e|Sf3U6E}Fe4_1hv46CgmLnP3AMcDN^;4%fA1Q3S^2oE6@hyo&U2sLO5 z;?pwfYqmFNFg$voqGAKW@xA5Ep^p7ZKC)*;M%axz6Riq_s>Lm;%9R<6x!?K9<_0e> zNj@U9;yZ({;s}y2A#Zk}U`vKzShL~;PWQkg1LI}1Ca^LS{R)`du4$bc*}FrKqYx@!hAK*H}*4N+~ah}Xzg;#)+C={Qx~PKn(pyDF~^HmgXrC8 z(ep^FB;n-6aGJlod&i}uYA{Q9Yta@~Ds2y`lB5eaWGA__@v=Xqyfh}Pw-yLYS`zgK z7qw;eV-O!=MFkZx$bH*+0s>IRX3qM<@AN`liZwpe_RwH^mEijy8X4x=7~Pk=xxRE^ zssr_mWbALTw}sLO_luiFN@kHK#bQvRJuX%1I;}#u-yRJrNwK(zj2xj@+(bps#30TI zyCcVm7EXaoZ=OUl5eYYmcEatHSm?Jyk+e?z!k;qf?YGJJ<@^PBB4X0S{tq#6h!iDS47U_>#js5^>RP`_!(RBf z4Jjkam_jKN%9Jvr%qdIggMJu*K^THz7=cBwgx&&MVH=FWb{K~puoL#s`}kJSPimgh zyad4q>-QTtfOq5!S4dpLL&MM32(J4u-G}WyT=(I-kEr{IyN{&%NY@u_$St`;4swx) zd=#J%MP%t|gIXj>Adyt0CVO%qM|2=h!%Qo{Y*-;?#~hdwb75}G3w*#20w4%NAPgd) z2o&Qbwi>0Iq+38MXah0O4&tB#bb>DFZs{Idz0!RW{YU~Izz6Xmd>9|WN8uP8$0y(< zoPyJ|W%zP@g{|W@PuM)^_LSQT0++y_Fylh7{xT@}J}J??9Mj9Oy&Tue@x7d=mlOAL zl3p(M42dTsosfJ&%4MZq*3KK-bz>;`B$SMSj0dJg&#jjcJla)L74y@V1vId$Py`M7 zp^uUU(?1^(Pd!6W#&{>5I^~&ROZNzC0NY4)U3wYVY9nXKuvqabmszv%fitl@I?G-O};<_Z|U?fBJJWe%7u+ z==QQV!JU|2>JTJKymZRvSaN!HE|I*_b}-JQOKk#0h?5GRqe*F5Ife5`*?o>8pUo(NBx37He|as)mGwM<;?M`yuxqIS%y)vpG|r(3anl?4bVYLqo9?p zf!dC1-h<5yEFwm-q5j8>l-0RR4Bh!hG=J}k+x)HOs|din8S4%( z<-aM9O!?$@Wy-~OvOj$7`{g?~g#g~h9VN4E2^*Is(%v`=e5P)2duVz*W!Q>ud?fO8FzHnX8vo=x?(BukOzl%r02 z??PlqcWKS?3+C5Et}Zv0HV|p!=OX^^v6uXD(mVI;ci&?VJn`4pNZ=cv>)tx+dt~s_ z8=Gy32p@cLFa$O!al>}o>`;owV2N==B;<6|G_-8YEUc6Qg$fcZT!?aE62$R}mnuqE z4mO!`**UrS6bT87s8+5*rGRGbTD9pA^O?3r3>q?QQnx8m?fvVYim-6iH5XlW#U&5= z@tBJ}t1KSl4EfUsDoaSrk&1)VQD4lfYBU%4|{0=AmCVw${KDhX%=R%YFA4~Qh6$QenW1$=b=9qPLT?)NVzzq2{|Db@zN}bTv z;DgykyY8s#h76T4;*?wh*$GlxDn>D*?xW*$S(hod;H52vYL}Ga$)P>c3fQH1b+myI zs4aEVB9~i1FN&U?4j60CM>9vKsj)^LFK*$%AXU{(*~zP)cGib!_wB*16M6FWckr^_ zHtMt;;+9D}8bqyv=hs22#w3n%jOOTqKKm7>jggn=M=u~#vqV+QC?!7Q?mJ@wnkH8{ zz0^`1+vnhUrr?c3Z(RYZKOr3Xp6xX#%K!#N7v9M>DIs*N7s2{ z`Dq!e53|1{m82fdZU6)Ns7fl6a8enQ<~|4Itr4p|>sUikja~jM2|L!&D?qTuFNy8{ z@#vLx0xnv;t55^K#Re&hOD$ziayA%|zt@Bhk(6!X>s~0j8tP+KYMEE4wRk&x-M#b} z+q131w^5L|uQazbO77WktxjB-FSRSE_o6HaXxXHergKw&2lK1u8Ls!OTycKshKH0r zh&P?e8Ydkldm~Fr<hN(k5GJ6I?%`|JfaqsNHVt&^2VyNwlXS7jn4 zzvwS5%CuY)zY-%T?OTLiL1_;;-}&kzb?l-bdqO=%P@hp~js}Bvl-D7HyZmLwl&tG!3o&Nh} z*#Iy>o3$oo#QkvG;!w*oDEEpO) zM9M>E>*xHsAAL6=9GR*2@aXG)2=mu_YR^Lu<-51kv^`N|phU8N(~$k+yc&Tv?_(K9 zO*E=PI#oq-2>gCxIsE)GoM>HDvvt$ziu&nxIv{c)9m;s<>2S=7IOWmO^cI@26WUDO z;OOX@EaG$*D?gY7!jDKYPHRROa7<+p3%F>6F9cj^ir5l^wxtj&Fhre7pqp!L_H?Qg zVuj2mvcQLVGQXevhy>TmR#@dR{s*dPx0ihd&-WPcESqU69k*g06x;*5~ z%IH?EwfNosiFxdNLLsvAa8E1GJUxi0ydsocL4$ElazmLf=*{@R{z-a^Y7p^SkeDF3 z7C>QwR4eg;#sulL00xs}6DI`YGGMVmb{&Aj2DwuS7Cbh{uLB6!s9|y7VIqQ1Tnmse zL8+DaA!CB_T7ZIyR_X+9wjv&?>i`-yS_=fAV}trSfPu}@5ruYcJ#8X9Ie;VMU6_bI|+5p@1lNu&pKy6!Qv|X8zd*H2huy5JX06Q6Q&&zyPyOde&T4uFd znbqz#*uwyO8gO5j&uQ;6r+vzt_APVT&j$M&Cl>ZX&!Sr}loKY&^LA7BR8!qi-_=~tk-^Q#YgC=@gr z3GjprBD;L0_%2dhCOhLMCfd~vS(12Utvc~=$x*XynXG73Bf7NLDDF-I4^GEwZheTv zj>!v=i3Z~26p}m^cSYOmyvmeX6ptw)YM&0{lNEP~2u{|P!oYtHj}Ubz&33s2&pR_o zC}cd+@o>RctA-@hgsGtMc|I%X{4iJ0T&rNX{36B^+&U9HOBh1SlP!cXlI02>TZu$7 z(MrS!CJMoNzu&Zyo@40f9lvAb$udG%wh>Omu^^;6v-Z@-c}v3n_kyiyd*UlaJ1+X_ zgHP7{)KQq;vf{`BLOc#U_QD?Ce*R8Y)mAH)!z;@pg;#}fp7!YZtIf0E;SRz`)6`P3 zj)k$;s}=|nIg3)oDEpT9Tn!_{^H<$51Vzi&6~P2$u(Lp;6~+umnV@U{0kcjYLYxyi z1rid|{s=B*8%$JdwjYf?Qq;RBgmx0PNmB@91H^bOe6m3*try^O-t!a(pEMy6nCOqp z9hw@+m*-E8gi(>@0#pU8Pt@qOPcGM>#Q%NX{0Owf>KWuP1&&{3H}Qq|)SiE%JchL6 z=nR2nK7{cCAsj*$kl6Trx(FIELCYdPjLC73rY?r`v)Mfp$pqt&VOtLvL?T(Y;PSvX z5p}hqMfjICi(g$@d7(kIhEd{83gJ3RkdaJ$(_s2D`ZpRDOJt&)%$q6@HHr@hw5-(q z4u2@h*YHCVc`sjn3HOx9SSy(&*2gvlm&N>eM&c*?s+0z`aKp)Azm$GXGZB)w54qHV zIZFjm8fjVOLD6(*HjExj2!tW-;(Qk2Sg?lHxs3e@9W>nH6S2U|S(Fnoh1Cd>_4+5F>N}AnXIxeaBZdp zawvOfO|2BZC&}fy0%W+=kzFQ1bCEtSVeKFY;6zL%X>ezKD`eFA?p)i$PZb~pDxCYh z(0@t7s=GR)8UspTZMy(U{g?IxGwO%ngevo4L=!~Y4U7hPs~4g#;-T^2sUQP(*-{Q$ zXU}_G(H|Cr{;paPB*|Rd1@A*j4iSYWLiq-;z-W&tI&vm$9%U4aln`KPSxEPFtTeSiONA%0-SXb(yuhVz+mlfC&6=cCUd+`3k%w_W{i#xJS<;c;PJh@qEc- zOI0sAd3YhtUFbp(Gd}^$lM6V{O{W0UQ-9WO=NpkRk;X7{N){|FrRJwCy7r*ca2^BZ zk}`FT?NJz9WlZOR6G>Gl64igO>OP&PxmWQ-lF_4L(E`lPl=uBjuO#4*?glgf8hTXW z+x&p!D#1Wh~2_@b5>HZEUb)PR`f`xdbs(2B=f#)?)U1#pFtzqB!h1 z12P^ma(9b4-e)7~aM%mGxZ%9gmAOuckRAK(=UD~Z;Dup?-Jv&TT&4$Ir?GIqS#rlc9 zlx~HS6K7GB4bU;^^Vib@^;rbeU8H*A8!84&*!}Z3u8`WR1PM)2(~k!9cPlTLL$;0^ zCv{p~=vpR>A|Licl$uZm!vF=6OOY23~E~(-G`viaYKZ&3%Tt*Whhib-B=B;S}C0z1P3+w zD@)E#K9W{(c7|R_S)b zAmTE&U=K2pi$O#yV2cAu75i$5Mfq`)g!NBsmP5kT0CA#S9RpT%F# zKjdUc^2nr676zi)T$j||?6j1UXir&b5`&RH~NRGTQGmlQxY_{mxg zq$~7yk`Y#tnc|5%{?<5MsS}U+siCIKL3TcKiD@18brGqFJ);DGJ!O~m{qJ>Noy8K4 z{pkTebXcGM^GPYM0@?oyB zRp3&P=vSgKzkMaa+F!DJ(;L6OP_KJ;p+Tq3aw|rix1bJdzWaWGtELEllejDciTD6- z>+tw6cKj#vbF2^(Z9U3>XB)8|(&ryiEX@#Likf?(?S~Yr_@0?4i^7-l8BBkp$H+TA zd%-cGYbhFIW$kq_{oPZNFQZKJ?9CfqF6@F01b@Yz?C*?Fo}%7>ieR=}VTW z?IaL;-uLFo?Q~|Yd-=(B{Y*;Vr20~Q6ovdlp-cAKj>g_3$(}T|+EE+3`hruSEC2b7C!|=F zR^fLtceGL<1ZZn#^DwrVz3ec4KvG})z{UoDnh_=Y#g&rb@8sEVIM0$j<|GzGq^hqg zr6{sI4gE!s`3BqB8U{q(qEI%4D=Gi#*1km1c^ESGTBfFISql6Iz)|petDO2J4(I~2 z=;)J-v@8}ol1?O(LlEcyI7dTCC{P`?$!7!0CuvC_4kE}S-=}AMm(f+4{=e2gh_hR0 z@NaO*Y7vu`;VYSM?oa-QNMgNB2qWk+!h7Hdo>3!$RZx{kMtm4Y{=Q%w*Z_C{fa@R* zT0oovU>~R@g1$)AGy!l0M^m+$NGk~5+{$@BLgI{nyq^ls0U*cWsM}>_uXjZoRYNVa ztv#@&^Whr(``h!eatY;i@E`!AN=t5@RXKX_qD?HeDQ5t96o4g#pAbf^g?;|d{bqcl zmZHdr$=>Nf7Dn(83Dw&R^#-_q)PWjyWppTc1P9u29(&hFex$k-uZsLiHc!sPkW$@R zIa#`*fXBJK&)hhIM{Mp^Gr>i?ZDCqS!W3Elm^eJJqz}_FY6R;;nt&#AE#U3vt zNECt<|LF~Or{N2O=asB`LoEkj;1{hQ;Foo*3Ku3u%n|32;~9$hRpa`I@l-Kaa@j+8ilkB=khgFo%*p@S-*+9>-Vt z+h|_O&Ry~}n3`8Dv+aF5qmo9K$J|LY-VVk=e&=%+wTt$`<->CoFkm(1w@{kM+HNxs z`0x76iT{b%lUm6Vm-3y0H74$XWN^ZYpJQw?P`9(L0_9mgNW$#Pfl?o9!vbbQn89l(@}SrH`L+_wsMvjFLQi zQDNiy-Z8siae>y3V*sdiz^NTP1t??uA^MguVzKx7F4E(d^me=c*|_w>ebL8Y){Ka3 z;`aJhd~H8PLYJcARi8sRXyxZ(bQ)j9xVb-!w3K^Id}JO*&>51raVLy}>vl*;lc>0D zC#3r797~q%pO-4*M?(9N!i`U7qKO<{mHtAQN(}`VM06R+x8f6_@DcHmmJ(=n5Clun z$HK~wi?V;g#KFHfu05oIqI%N|f~lB^w4C70z3~v@{vD;8$tLE-K`+tkJVD{ZrXB`2 z#QjrYX)8+|NW07wQI%En7UIHc>duCurcf}_Qyz;0z~qjIa#oi~woIff;PcI{W}%h& z_%i|l0Dd_)C##V&v69dwoEqq<{2PtVyHCvC8I|rGNw&v<$>OLN7)&*?5Hi%CSD?p6sLQzZWkuaH5w@?nJBi7lObg5P%4Com0dq_InQl;Qm1=p(*;h>oqG_6 zfx-(zl&uJ{g6^CM73+GB{QP|gv&R1pjH5iEAWK)sdECqUEY0ZcIdNtMgioCtj4oCL zO@$1XJv429if~~SWoN(}i!fZU$45`J1_S;T+i6C&Qd9l}1Ss)z21tvW4wY@!>a@bpTzppi&+HgLHvt_dLUZka^+@JG@ zB^6x|3NMJH7K7d&3A?`3&|taB<1g}`3h&=-wyfM*Z zL~>Kog}vn}?C%nv&u^vD>P~($w#jlOo*lQ!s=L+@S~RGp6jo8zJ`^XeCJ|u4(mgb2 zyLFJG=_0LPV(r+%Vj47eKdi{IaQ8$Ok*bD@(Eds?@gscxb0Ybg{VX!k5kBXDx}e+= z{?^Td)zo_`H9@gKsILy@DH|I}PN4DO?kjtqFkZz7ePFbFzzmF+l}Cr8X@MV&h7TB# zvFdWp#G7-&awRi+U+3~(+S*Sk@0|ObC;M=oT3Fy0(>&~;c?5XaSz=#JmsU}!r2SdB z4T%HG#JlUp!GZ_duUy?Dxo^%TZtfHP;svnQZC=-bwkhoTD&bFj?cK6mWn`#0&Hev) z`5)6cToh533GZsPccVgO@mML{@i(TAOxECVYEX;5(JMAiTcWV{hE{ChULoQ#_ z`v2C+bDiprm$N$4ah_Q@GOC|A0&P^3^IA|4%Y@Pe&h+7E>=%Dd3slX)3_ z{hsfr)KG{96G=-DU(Gtb@KL~rtRa~6k9-F~k2n=aM3%-{gi*HqWy8UH+7|S}02bc= zqdsVfQ0C&xT)Q-d_c9L?(*0V01GkbOBNB7spods_=Qj`G)ho0kz*zTgLe=NL>G7tc zomhE=RCCmNj>kVVec8x50Af&slMgI64aVBw3}Kk~$kdZlR_p13bB}vsxtPssx=^Cc z-uvnvSzXDT8~;(CD#YhC%(J@&!Hdk-E}f}uh87i^zN(fB-;WvShw%3jaaE(B97`rQ zq2#R{imz`kBIf^gs+Y~GznL>SgntaDv8)PnN9KC{4~l%X3(MXz0Dxw4vKiQjWQ%rz zZG3EMZ13eQ+s-1<|=H8{M`9Hrv$+J`q#VEF68 zU~gwXF}`xV?uDyN6;!x$RR-ESqyX&KR@wgn?SxA3qGQ`kq$LDz?xSN^8gxGZGh8%t zol4DrCf#WNNDK#y!bAS0yTTAko+L?t5!yAK#d>J>Hcs}Mt_N7GKS#u*4Ge5E9xB0& znFau6n_G6(;_dV{6#Xvrn&rFuT%kz9yE)4sH<+UBGN+?WW+>6Ka_fU|7x}_(_>FWe ztaMu&4f{uS2}OUdbsPL&iUk+z47wg`U*s(X1HTA>Vu3mj<`DxY9{lZi(VH3(x}@ee z@NV5O*KH1Z6Q&bOO~wQSG}^(W4vlZ%ROGN{$ygu-{3*)O-LRI@MT zFt=?R%Sw-LpC6G5->Na{s=pBBA?+GftNwFZzT9BcwS^H~X?&000IL~S`OR*Nbwj@^ z6dv$y-g~k0OYz|b>I}N+ulc#?T9tF2G-FW*&P?|7%-|y2Bc8~`0l5+h^T{wJGR7hi zGK7<=ssz$HNUVeI;E~H zitt}%pmAQ)_qk-}+rJg5{#~M{ihO1d_nl>)S#^x6RAIUMA4{HPz#@QyjpGC((LP$+zmi`$tX{8Qz|fT^m{1hj*{d&K~PJ#AYF}01|S0P-eq+SWT(Q*X<2Q z{$&*yKHuCUP#hliZ~nu$J1{PDIbvK^Ga~njKtSdjop^GX<6p-LfgEK>g|!^V#7`|HOafZ`xNM@b@Q9Te45=hkl`k z`LD;c&EIH|SZxiGH)i=i_gzcB2t@B@uwEZgXSD5kJ)5~>|2QjS&Gtvf# zALk1HSrzN%{YcRoIa?TKzAF%xa<@4ixNEa~ys2lvT;|L&xYnmRS!;ICa+@Kg~=#Lni^;RivwnEf#ykqs#gjte z)sI~wxxyp=eJGPmc9GE>W28Sd>!mFg_TH1Mq*J9>;3nuHoV)tk3>aRm{<>LNM2rU< zb%N@vrHRN{4dxCi0M&Y-YI=N_kOD92_`*c;5{!(x*RV9G6aWVoIj&8rSvt?nx$(10 zq)>RIzcwi$lmrHNjD4L@ zT`H|N)Sr@htjiXeb};ZU0G4LZsF*`pzg0*Y6DuSnRiqZvf3Zs{FWyXb5kARSXD=|TI#zsUUyt&tTM~K zJ7qA@GSfD+>9h~8@_37U{=v-sR_m`H8BFts8J#@^KOBB^4{>_yQJGu15cs{)>r}Rn z&pxm&4lp&ano_1xLj=Py8cb*{lR2Gb5{FoDOt8{frc71Cn5zD5hZ|FMZboXEvH{;( zF5RzOCa5S(&Df5myW=2)2g$7fXF4kQdV_dCnYeo3lReYr1G9GDz?V zd(V9IQSYf^@W>GRwcl89Ys#zsQ=i;97NN47!*dQ>!42u9j~66Web?3?LXzd%Oo^oqGp!av+i_5ti-QNW@$J3VahAg9R22SBvt;AuAY7evriFrA?Hc}9 z2z(-L&*6U%85{S|M)V;xgF{5_oJ<2{zH0y~$wP>5S2BH<#H8|Mnf#K3lsL3!ykCGM zne+2FRDm=s;3d zT!L@MM=y|ELMdh8Ep!RS;nEw|WD(qOjrW?=_2-!Q53?Y(|PX%IzR0O~)_$tO-G3i7D{lF`0{u85p|N!c^!`jj_mEmb>>a3$si@{k$LP zAk>vssGB#DIw==A_3c;>z}iuV$zI45ZUby+*YUrPeA|Jyo}32{0i3qEnS!GNgcLM6(mC?b6j^G-^_%*VngU&m`0K9qHN;QmxLH zl;{*Hp}3N{eo~CKqT+idu`q%*k;J7xK^9cJMQO;QoNBvhdC!O0GiEOCNl%Oq5y`|o2zE(TS*1HW;94bX! z|2ihDQ>dS$s^A$le3L_OzA7#+-E9V-8DM*&~0P~rxM2LXS zB9&cKM}2-z<4kpx%UAbWnUj^{%vEZN>Sfk~X1PA9x4CQQo!aV{R$DV#yR%YtphoPt z$@4V#%<62J$?9~P!w|^KY-FRaFVt~<)LD6+>d;q-Awt%9LA!i$=1_}g4;B0g04Y#{ zj&7?H*KRho*c_Jjf&5T#$Bm9!C_*Qb520=%j)Bc`W~E(SSxns~-Jv&LS>7}nZBzNw zN?mb-+-k0uYKygL#}T)9j-JKRvbbff)9+X9a_bJQpW?vWOER}oXAC!stkTsPM>4XW ztL9U`-?TI>?MOhdTSeI4(6GNF*06WJKHd>qK#^79QH!G;O9domn!k4?BjAxdzK z1;eh%dS^qrDwj>SMCU9pnH>2VxD9a0M8^FL<~qhY`a0%2NXNylDpNbn#xu;98nn6^ zlUdi$pv%v%*Cn^vRPI*2Hou^%G+LO~zHB=8Tx9pwO!e;2~&0ZKC*h+#fysDTLF@fOsAC6o7s8_HBA2B9n!c3XM}hp$unUp~Of~a%!(~dCxuLyvO6d z$1z=5tQLJ;gjOfZ46lpP(To=t3z**5mUZ-+d*IX_e5itvr=$ss<3QCy4(!hnZnb*R zo`i1cuX z7|i$RjQ5xf+<>N8>*@4BE}B!}#sU7uPT${yJldshwb+Q}5Oe_W!)$z&NHU4c^1TND=khr$P@ncDL0&V$5bZ1|YB+K(8+?mgy);)8 z=Qf4)wE^3|d~@b-Qz?$c*wf1ZUYxx^`K?~rKfeUMct=M`Ik3L1aH2uRi1^8Vq7@%=1goVXUb2YPXqYKa2Cvd z<6P_=&c%MvtVZU-7_C4oeZ!%Jl^_G1o-J@aQ20*kTc-W4- z{qUFp+`l!2y{FN*hppTiar+E{rzCakQLZw3m{BJUYvSs#(OjMUFx1KqU$+OKV6*YZ zdfN_?SD?{MEA}MUg5AZnV9#>2-2~6AS)-B56i;>SczL`ETX|;SOJ#6YTQy|nG7s9R#m~h z#Jx0;mpH8i*0R+rpjlwNEEH~$W%WL}z2jG$ltmY2v+obg`3VU^hq!e6_q0-f8MUa_PUR`=LD;&JZt^F9l>?m$n~&E|`G3A8Uw zA3Cz$0sr3hEW+nCm1JaX^cd?!hSIGlZPv14k7;8>37fj~R2)$mC9*n$9o2A@Z)x1L zR$9~nbxeX668Bi~+*m0bdkdh*?kJl}8fdx`1S^5InrrJ>aW`_se64{Hd_xM&PaW_1{g7&mdnx8wYphxiW8=$FwX8~ z7lvm|B@|6d$OQX00aha9|D-n<+6C2AV?I-VyFanEoL31OxR9+uZZ2D}aEo;a7riHt za-`lP0|C|;Dd+CgBWJY0#p+J$Zdzj)P#yzkkq(Km3)IC12gLCPX{2vWkz-cCa6sPB z-;w^b-X}j5e}0dz&?OiAQ#I#K1bN8`BQ{YSm#k1S#1Ck`=!(+#32qM#T)PNt5O z9+wc|Gu$8PY1KR=Rn8h6nv@x(;mC-|)L@1da9o1K78c)iX-VC!dcT-mI^uTbkaGFu zicz4`Ae;KUvcgM8!oWY3?a>61xu}B}AD;dE+OnCY896QwG z1mLX3rgTljo79wl`yDLrxgyX;)WeIu%QeQ@#>w}-+C>}v0_Q)&o^p}7V)E~7Bj z-!j&K{RaSAyJH9%3|g49BqbsDoL7HxqsqfnhNeeApi zB3xM#*a$7lOJieY$?ag4wc_K_?IMbrkUphGBvXJB=;i3~)`WvboZ6EY2yxN1YrV1N zW?G(z)ZOOvMzgAG81o^zlgE;0b2s@GGj>`CJ^3-#lnBkSTP6`{P|v6J9$mj)jc8K2 z4q6J|mkMFc(;ul1-f12Le9P=<3GV8UG(+q&e8o!4jasseIs+Gr41L_Mp(BhkXi;C7 zk3WvANCv(F|3)JlAOqH|D5H_o`^HuKTJ+!BBndjJp|<#EaSGV{*1NsfMo zbg*Pq!txZVDmqNcf|xdH&ZiWEAsf708g!ZuDoxka)AZbF=IMU&$Q9*Z8uTmqOAu?nR6Kzl8G&tx<3BWd6h141z$_S}pCu1@6 z0R;*avjsD`ZZHE}uu|@3lVSgQ7F8Th>)m2vI}As~ZA;daoapqx$Ih9M;tWCT%KJnW z^DkOE-R-OegUT&xCq^|8=Csea;$%bmCztKI>i00w2{W{*M_ignn0+8B-LE@mPfE5f zcg0JrM`$$sa~#+-{MwRPsJiJ*Tm~H;M5Xt44u)F}cG~z-4Aj=a9trFgOOk#vTVV|# z>P@M0G_IEzC7;VttZ)z?a#DvJmt#*?8WoB@wiu+S+i~dIO@Y=JAxX&EghDZ@OCu-C z072Bjf4v$o93ECxjI78!^|}*dImC*^Gy%lFvcUdQy`e&SJm)ziM19g4XT(D-&z|wO zEwV07ow*gvB9eE3IeH$8X$oMf)=}1qjTxFiLm)aD9qB^H?v!z3r3Zq(K(+ye)t+0% zgW9xGzbT25IG07M{t=REIgY9e-?>M1fnVxGumvOBpBdxW%H& z2lH?G2WWG+;oFH~?cB#QCoP)Av+Xs_0GYV2SsP5vQiQJ>5xkT-(R4U9EbV62??KyH zO-k&Fk)-#;OIxhl6;t3*d>;Br$%4M{eIR32^}`16@cHKTd|J*2A=zdtMUBH+OWdvb`V+$3Tn=ust56H%@e{pKaddy}Mi*SpEL7Pbc21=Tw2D~;2OB{yQtH7BX zV4kKVWJ)0g66P}69BFs{(F)-u1{FEmWY%J>mI5mt%PQq`?Lygr)B zjdVTPa*rEx2H$OmkSh`%8;yVs5XaG`S#0u5U4p>_tY3Hjc!;^f>9~CSz9{*30cVOr zydb$4G7@7}1pYVDP+a<)pJT(U+0PE@)6MTmy6!E^*kZWV;_T2R7IxHZ+LW_tF7^Ri zUWOr~g^q2cNSK-Syq#rIlKc&UA-ez-CQ+CmZ{~2M6T?_qIa+NHr`5n)aBZg~}4@0dlnBPv3m@@p9R37xOm35PT5b?dm3-eWTWCVjvkHtWiNz z^g~_Spz8>QU@%xIMaKFO&C|Wv&|@xLy6zn=K(A6lRox4@#cd#B+A4hX(rzP2x@-Ns zb-mtO?WCar>RUQWdG1XH?MM#z?wgnEcDnjGw zMM-g}W=c^Bj?gma#>xl3FoKXM|bP4rvoX- z3+P}q*e_<>=vg=$fHJ6eu|&GD$0K|V_h<=^)pmlgyL}ja^ua6g^FeND4!)X{tR6`A z*B+^0c*==`h8I!-xUt|h8!5;mB}_{hwUL)(hz z78w!oK*}jc+RZsY_Z3*YH^|-hq;wj6$31>!U0U6nVhNwAqgiwIdqI~(I(psYE^t|j zd*n3wV3r3@2f5Llu`g1FdA6RED8TDpwi+KWuMdmq$y9cda5c!>78|3x8ct7{Vs^fPYHSI!V!kz)O9sy)c|w?nhvPzKP!XK$-ayY z-Gefp<^m+PWuwM;5x5S=Z6baG@B6@ z#Ldd1eOCfR=+pU1YSsoAS>ief1|*mORxg6d_zq>{;^mC3s|2{*YP>9lKAv#@LSY-v z$>9XLDVvw-{u?W$EiTs_dYSfXzeWjc$sTiLb|tw}zhf7YtB0QqI^rf}aOQ}y$WZn? zzGMJp>_FiZhd^BO#~M|>n#&_slZW7t)c*2$xDSrYdB5j0a{}8kl|0%;F^P`h%hGzG zgxNus<(x32gXgRRRTh1IqF-|Gcjq0kX78L2$d}oDNOL%XtmZ(?;i3OotZA@46;fTi zUOO*{(orGSOej*N!Bs$r>V{eGsUviy=cmXrseCg{5;4 z5-=Y(S3%v)e;c1(Vkyk=#6RmZsDAft^UjG&6^V8WkLfmIT~+lrHuz2ZcBP4-Y5!#l zRZoE5Rzg+PvSJD~g|UVWtWWgYwP*#4Tp~9ktnlWJh{UxoL8@)MyVR=rpsAmJ{O0So z*UM?QUCzeC0R5YP!$8etbU zRYvZd31njl)Y$_$MRQlRuV_|)v8-&uoR!skUn8EIy=p6*BCdvu;$`Ltrq5bZQ5Q!2 z2h7fhb~BUj!Jw*;8w*`~vz@^$x|9DbV*$7$|MPY#?+S;`cV+7)>aXTub5v8~v4y`) zOpuzhI^(N3PI!p`h2KYKMJLwu>GgEzbv(zqQ`rnv9C+9mj~$4c!VQWQrU!!wk0CZC zdIU&1p`*0I>ceU>4$e5L>CXLL1~lbION^&en6H$%tR>q6^>LjUGUXvATL&k5PTzTW zMU&e+Km&3p&dGUc|4ts4Ji}o$p*Qd`R?%sBLp6YJ`E?2=vXRU9ry1U`PmcU%*@il^ zcr;>U&wCMrw)rBwA(ExA#h6wonC7IkxbSCk+s83Sf6>x)x*2jJZtu8`C0^;7>FlcF zZr*qxlLFs3C}=L4N#;n3EF&L3?bZWQ;iI}DQ%+D2Y(V~$>MZU3QtJiQBnGT8uZ>!z z80L^ss=6HhQS`;QF;}%isq^<@mDf)gb4=7gDoCeA+!!;;f$p~1F0$(Zo7AR4ORI;Gen98xk@-2#) zf0ayZ+a=QyA)sJ%dlImSKAMn1k0ho{QXLXJFP`EC6_K(-WGxCd9eL}BxEaugj_IN-dir3;m!2lHkW5mhiscSz74~Ue$y@5(m%3P0_>` zXRCG74+Lh?!EsmeO~6Wv^)P8 zces=P>nj=jxJ;e`8jSaF`Gbow*r9ON9w0Yaq7n$5y>8p6>IG3vaz%M>FFSW9jxr-j zLljtvOhjcW0sVpmV0m&&3n_cb>pmHtu?~dz=I=R#nTMXgbTMt-A?T#s-)RTv-ELVMq zkv4s6G|mE+@V%J!8$Q3wqMV$er9y4ngvm`|J{g6t>-jJ!uq(TaL)bzrn}aP&U0TxP z3&t&1Z^z2=9Q8J-gInD4cctBmf8#?aQ96WjpFY3Zx;=AVdqgmr4jzJ=kqCN8gY9$b*#kuJC`e2L!y|p7>A_4Ok5x_ z{X)Ug5V-p1ZFLcDvo_NaAq9Q{h4`l1i#jdd9Rw`GZ2k6~t}(7&B<-3bl^B@KIHoMQ zxWJpELfVo+11^+_EZlD3z`((jM)-wr6W2=UuX3i??=tGyIf+$kA2dJQXC`%{O51VbO$xOk0LyoP|-eegnf%ZsEThyBs)KBdidl3P~ zo27%r@r1>@=7S2>r#lbW$fYtLM|#mJb*735imc0dd>b(8-2>k(${lBBE-W4{To1@M z`c$8l-n&z9RSYMw%^ zB0~Tmz-)8qUm4WlMG?cnvX%eQ|6M;p>L2!(wEksf^#x<~DOWn%XN=X~PUr<(rN86R z5A!5LptJj*qbq%Yu0pg8fm#7qxLxPnM;VMuG48hyM_2{ZJb_Z;))&5DWdsXgZIlUp z`(?d8=^N)lU9$1Ck`tE7#6REhW{|K9Avjw@m-}XT!p6asYL><6gd1kNU*8R_G4 zk1rdEpnEA}ER(iBsQR;OdX;f2m^YsCiWI!7<-~ZfaRl|Z*n*PMTE@(xYbBUU*jNJA zfV(Ua%MjiVw96JW8uK_p$5C}0u5kp6Ls5dr4xA8D&QSo}QHH9pa_43Zpk|@OwM~pq zA-GqCONM;}4{ovX7@j%M?Oe6V5ElZW^@?Sc_sVFiR*eIOfOUK}Z-rFA*1hz5T1;=# zJR0Mw>*^?qn!soG9Mr#EZ0J2<+qzKDthx*_OfCY$hVd2JhWTG&a!$n*)urwoQmm!$ zA+06W(`0NkvvO^Ua_tKCl$=Q!`iyNId{%%N5d4^t-X_$-_3-kgKdsTW=>;UX!!2D++^oEc{EhW57jfuWa@CQ2fLD?JllF_3 z$2#kkIfNljR1``a#3jZkvs01#=##w!0UTynsRc)Byg(vjjW)q}6Zz6atC=s-MIS?q zF(p6dSYk~nT2WwUZ&-toS-2SpX(Fw&*yO|{)zLzVH^rN}9!kAa#Ez!!rO_n_f0Kp1M&kHHfBUjNGQJ~;^PILw5T-wkeWPp4 zcG+;(1WxlB1kTwSt{eSbKP-_@Fy%HuRCo^xAV~_9L=;|uA{S5yRP=y~9t8?P3Z}>g zpnCv-fQkTD1ONc92&4!A0$gE6M^(;zV`$3S$2r{8&dLAjir3WB(@4kbD%@`$2V0vo z8nxc1smD*9+r!Tvo66O4x=hJ=rB-FS(1`pY-Yn6!zE-5A?Fww{)4s;MfCCxn>W$s( zqf6>cBzoqa4JSI}SYl+#*|7B~n9ym=#Ura1u-MHtN3gS<`IvzP4B_%5#-qn@0Y-w) zu#1!)L3d5JSlf#e|ufmC- bOaPK4r?LNU5VvUoI@o>0RR9100000000000000000000 z0000QfjS$hY8;7n24Db%4hVrD37i!X2nvFaIDzXF3xpy70X7081BgHbAO(YP2Z(kI zfmR#NttHCs6Wk7voyPSxGuSrl-K~0>r~*7PkjDXJ_qfIXpK+jujQukY*i^Nw0SL*e zX7;4KR(J2mc`dE`I@D_Fc&w&A&YE@$oAwQ^4KqR=vB$Fz=b=>DYp6wp`e$Y3DTeM~ z+4T8aN^g{}<2VkMAmZGp;@x&*EnzW5M2tpEoMPr!C0Mx`xoQ{LESg2Ni!3GnW4|x^ z-1nC$K#IW;xT)1amJtRBB(gAxON3q*Va84lQL&`;%-$On0HM&?eW6oG){=$7XcP*S zCEW0|&4;@O+&z;}0RfK_II?U)G6igO&Q-d7zuVl}eSgKWf93Www_nviHCNvQGZaXR z0Tqc=6v0R^b~~@N!^GhH6n2k$IREqBF7^+p9{m0PH?`e+-+TYhA8A9>h8PI;26;ss zf@pkH4I_dWSH^@=X9%XNDJfcf{%!hp@57llAV7QkWm$lhNivb(lOshxJjpUuTW`r# z&)ig7)j6_Mbaq;sN{5$T@07n~J7uCbI}q)#j1&NP0sJ3cn$uKouzbNvkZ7Vex=1Rv zew4Nqq*rU2%M~A5+3_e02vcE^WQ_T>yAxpxI6gGKAk^>gXEwwo{QviJv({Ik22O_< z32EkjpUD>x<~GiGNEVwl{GhGPY^YkB+$4w>^+>1Qt^X@2NW87-+OCEo8IS@f$^NG; z-E)$n0#e{9KfJRWAOq^i2>L77C$OowY~yx)xuzZ(P^APqD`i6Tgu1gRBB z$TCb!U^hTd6VkXS*_xu{NkN-4g98ORP@*cDTRoa{4-*g`GaZ02N9`M3!PHLS2S)UZAhCIdcM8xEz8i_r8|j7 zn!G}tJQcN+<6BWUWQ6=U9U&3!Lv14VUOE9%Sdd_y({KCA=INx((LxW#RNTTXM2v`t zxtsm%*=t!VGmwY~D#H~Z(0P2??%vB#wd9{VMmBs&i1@4<#@y3qV!gNTu0eu|N`W%A zP&fX6NPqy`008izHe3V%L4emr;79LvXk19Z8nUH?RJ0cz8R3SfW=jVz!b0Io2#$c;zR zyMW?xV|6(Q(Ey;m9R*Ps{tF!_bVeKqfum|G(z}Y)x=uH^2GW0SwZ} zL^g7fk0M|K#4`6|N;$=tG+C-`sTp*m7d6v~f$%A3!d@+=Q>J6Cf)Cv!OaV29g`{cD%p zEYHHshN(I`tkVWlNYgZD6%NO9qDHe$A>bCb z#v3F%__9xJRSpblbkhpV1^#rm2R$xUtHA4UEyv5sYsj@KpClQt4|d>9ZV#OsfK&N8 zP2Shn_tko$IdT0SlhA}8R6c_7J;~L2UcqoPc?;i*QJ!xzq`sQ{%)g#EAn6O*m;A7) zQTg2?`46lR$Z24kPFxR|0LxrP^{G-0e8==rK3c%@;a}Qf;jse3m+r!|E;g^0p_sYj zC+yxG%NGEIXhH+9utJU+AAAW#hn{r0(w)9j{iepT9r~>*{X0XtMA5Fz6Pj?uauu_N#CRnY1d-Y;+t_u6!KBMf(w*S1b_cinoM{6R>9GV~8vu*y!zVHu}^=h{OX8$obJ@kpUH(?$biEk$(v&bZ;(R zZjlVX!HemvZ`0n)lWoI4{){qBGPwC{t-0gNZY-g2_785@#!U$)I{9)DQ>pV6ZSL{g zc$pahoL82FnzQIk`E>bVn(NS;g%3y@;@Dv)iz6<(>YCf`Al%V-IQXC95&}XZQ$oy! zgsq4uM94{)l%7S!r6Xz{V_v+Y`bZ@FL_{N20{#Sr3PXhJh!QCtSE5V|S(gZ8f1cJ) ze>_Ho7>X1pT7u9@g{MsT%0;5$^8%HsiPfmZRDtQKYiX=e~<^Hrf&21_+m>n<&R zT-|g!&rec-%9w_`=){fChm1s^xlK0Pud9`T(ULfux2#v>cun?5C+V#C2~1GmIW>_Q z^_cqh`af=*dLR#x^rA$I5i3r@B%GBZOSVo7diT+6Ba90Djw zmqHi*cWZiaXbF-owBQK2`Beh@8}uye$R}vUU%3yf@xc+voHvyvV@s~n8Wa3E<xoC!W(@-PZvnQ}QLj)b3F)QaKaENRM+3US$SLtbW7CV5tz3C8A7BnTRqO3MaGXr!Kzvw|%M{_Q33| z$MKh))A&~i{#TvAN(z}ek}~mhbzq4c21k|{CnGS4#;N|}5DuxTQPFz?=xT6ZQmHG2 z#rx{x3C`f^RRg?l3GvRTc|=6lGIK%_LYcP#R5KegwG)_HdsHXrkmB>X?H5_NWNK*G z#cVx0SDFo}_KOc;}L#fL9H8sV~(t5m1yBjiU>oC=hE@Gpmy z1dt9FI)Xzgu~bqZnG{kZ*iS>Nvh1 zht=GKmbP2uG`k@f!2Bxq6vae?kB}_}7|0>=`~?WLKsyj(g0NwV9|e>=?ZT4oQu4@X zqisv95MX`c{#UW%4%%b0wU(P}y6IYsF-)UsCVdznu`$LPXS@j}YB9-VE3CBEI_sGo z@f0z(QcE9D3jo@@DvN3?W@i~DWbOts10sMR0O7~O@kqd8uI^ugYJ{bopUbMnb-=pwIb>PfX0Nv>T!6hM= z#e_x}EbHs!X)$~5{Dtn%!m7w-#f}@lJ4An9PFW=$b;Iapc|{po&i>wDRfOBZ>S0lV z5jSR6EnCr*V&K$_C*GeR|&zHY4=1l4^?qK!Ol_STG<^AT-P(lzMBB zN6J=T0VMg9){8qZPshScPUC%PBM%?unxK*!jI1V9ghm>*u4bP37FcMJ#gYc&t4+NTGE&wI~!VMP% zNgzJ}2tbTL^DGApbcmL;t*{Ysx;Y6pAK~;7{Yys1n^1^kw8;ZAD3y3&nmim*5kkYnzD{j$dcQu7c{l*|u{s3vB`T|Lw#c0PyIO)1Q2_Xz8Qu;rQU{ zgA0HF073v5+y(ev=Gxlu4c{cbmg#=8Quey-um?W+?2coOJLIXI&e-9IU3PozrC0X* zgvKWzB4)#u9eWB+l$@!k`S9aUBY;+@FgoEPoO0A@?_CHVA|*(aEOjTw=;?MJ(^a(p z!Yd80+mHT#kDc(xN$=dVPpe1nd+e{T;lVc}UH8^m-y?vZ-q>tQ*zmy@2f~3(M!8{| zt+umZV_@Q9;ouRnCLtx`NUj41R$O>+<;IJ<=RAc76eL(jzRJbWixMYVtawQ>rAe2e zhc3G6CdB}GOmY;+^+LXC0}WE8L9s^O3jOy_TbOXwH5XlW#U-0%cjL_QuGXu-K0jrE zLFXT5VgR{y)g+mio7b7iCTjs&@nS#aDtKDD8-Z9M8spF+9H7;$YO76kH2 zHP-$_+j10cL5-2l5dJ4SQN`l10Lic?5@lmSBwZ(^vs#1m3IzO!qFF!C5w_PBft~h8z*^ zc;abmSXMV$sEx0k#p`Ss!S3T)hq7uDm2}lSKpzX1DNZYDpbsjG4QRRTY1PqFXRMtp zt`Edq8gq25LfINX_aUohI`o$JC^yl@A17Ia#5t99_D1MP&i~)GTN)caZRMnDqr8aLZDf%<4Hhp2$dwtyM(L6~vU* zQfE;Q=xp0tBUV3Hs>mRV%7R}JAE=mx)d6mXt7c+(R-N{!3E$z_qCrkB88&ZnT&pmW znuR$Aq%R0_>ZD>GYx_Q{pPs&Abd=MI1+4A(wY^U%W35=&x`>Q?_dw656UgG2b4{!$ z%;n4hvlZl6Kxy90C?=^IZu%*lAIR>kOvf860|F6C*r`;R;gDH~^?W$1hsk9#P>>!; z;;v=n@zP2tGQ@1(Gp`PP#_AX;b#`EpZyLyQRh@RgF76i2IXd*>IssOYi>)DlRxVy@ z#WFHVWdRj|P!$Ah2@_&`mX$8IR-EUyZsU;Wty)aW*Q3MMA`M{xUmr6Vm#hu z9ZP-xNpt06l%@bIOn~q4_nx9q$Jl#zNsp*@Yg3fZ0RZ!Pnaq<|%J_%n44B0*i!3oS zzmF=OqkcGB@uEYL?8l}mFOfy4e~o%~iRRZcw32>0)K@XWADdCQQeAryS-ST@kG%p> zX5~&Pit5^C*T!s2Q76SD9fLGmLz`J#$g8Qj%m4CyjL-2QygO(bsJT ztSlL0b#oIZIc+iV)pe;-D1sR>RY%rI9(BDFH-s4Ib?cmdsS)2k+xakTo;>zvG*DoayWX zL`+o5$ntwet|GCUOo=&Z}+AZnTPQvMv-)!I(o!ovQr7}hdnMw8juvodLH)pT2p z7GCVbRY{HUl9OGxm9?6ah6KK{N?*UzVwmf9yWz9l?z|p)LI!Ioo}>Pj(74QCd3PxH z1vLh#StxT>kada;UslFs`D_D7+%a1~=+uIM1_7aS8$c3v5fHkzAYeBE$9DJG4ghh4 zJw$|_8xgRVh|qfz0Ae2zq3=cn>?fkx-!mo%184!p76dd22sv#4$(So3spL!@o23q@jmD3UiQcELvV0!UDU`R7$>EwOX94sXT=!HCB73 zb15#1v1@#^Pm$;J6V_5AhC_v@svtL4Z?d0m!;4YaYNxU4R#Ti_j`aaupR^#Yo2Wf} zp+}lJN#0EUkUFqFqR%iUGiQ@qo1%1q%(uTG&luNFR7%F&uwit(-aD2c8u_wrtt$4D z3r!27tgRTm*sP&wdBkn1edCXfOKkEB_5Y8(M*uo0DGamyQH{ReV@7)!kk!ycjm z-eyKXYxr&FF?^_xamrajH(g*IZPesKSb^+N8bXk9 zMvimns`HmtC0(6Qdk*LnWO%r=6UpY_(C9XNrA0&{ABiQfiVUxVaB+p2ZhXu9K5 zn}I9zY#Mk9E$~&CFCt{&xc`wdhf8&G0BD3%gLo0>#L?nmIG9hbnHYrXwFs`vu&|X( z7406@O)#rk`+k*BSJx=HH89@DSW0YK>cZubreSF&wd}lsP#wgUPGopIM{&A!$<$|7 zXDdvg!)yz*`7`t*lGBc5PmHD%{9KB&h)t+SM~a# z)ujc;d1YSgX>Nj*Z^)nB5^yFL-(Fg^uHsN=qFxZ+%E>5lz8I5QKh&- z0BV5qt&bG1#CV!NA{)4#cW#7H+1&;CYh3F8W3bbjXlChZC(Om;bAj3+j%uU*(b791Q%BMghJAMU#M(B8 zFvC?5XdcXgxDUf!Bb)dXfKC`mt~&|Sf)P4oO0L}jm>l)i4*(j6($o3d;3~q4 zb!HmNufgfTA}ws~>7inWW_>{xob-`il0b(Ci{ z%dU>{g9B@H^5`a8u|daciA-egGoK0aq%s=aziZKjsx__i>0CyiyvAIGCP0=dS&yYl zD5di*k$kjOk>%mG$B*U(q>7!&54W0rQLuhX!aeH$8oF*7?_T z8}l`dpX7}}^%}e4?!*fIDe`r|)2rm%G-UhY=PebwHk|G{B#BJvA(*y7@ewm6b zOfZd6-=!X=@l^^rjMUX@^^S)uU}Lsx=02t4tOWlvkaT3F`4MM511ce-`2aF(^N{f0 zwa{=76*HwTm-X7!eIglT!OEIgE&f|~tQf3v#{vhXcj0Nw#TfHt<@mxYDquHIV2j>t zSq==eZ(i@-Gnb~Z`?eZmLN9P-252Os1X+}8E=s9as7O8slg};R>nwtXP8FeNa_3bl zno!K~CXnoT&!(Cv5^AAYF%0Odq)nskxq-Bc_x^W8!d9%;kJ67lltXANsF8C#*)T@; zp(%V)dj-~msD-lA<=KWHSRWz3;HU{_* z)aAeclo|R;M?eL@dZ&9|8p)+^<%ESPoni762zfpv_q##PL_-mX%w5t@Xu95q#wK7G z5ik_+TN_6uZRUlPkw%Mm&y%>u69rHEh@(3>q#ek-0$|By#JVW1b|(f4&JylzAf}L^ zwT|j@e6^Qku=zdCEWDdRu}O~XDEYu%g_spw8?x0g%Ne^~CZQI#++py$C)r4rey|B{ zIoy9p^Fy68SLMAgxcF~&)ZGm6?ZKD~x;5VQ=mCD>T0Irvoi|}}7|m_3w=cKk4|0GR zYb^(+$4Rt%2HaUm$G)@ld?pPgC6-F2e5wDgvHQ1WgH7$dipJ%n$6PeVRl#r>PZZ-` zIg?9LlpZy~GRgpj!SZlUp=j$%7Bsn_QXHtk`8traNZkU;&h1H(L+4N2!=N-g@LCr-|s zpw^(uUXb>@dYVAEwpV*iGomVDnviI8T8&YUJ+Dx1T&dh@iYnm3o zpHvhsjZ(GErV~`5On3DQV%O|vWAw-M3 z67*w)mw?+hq-OeOeOLaI2LFO0oz_KfV{<0UYYFQJCwzQLP(?V?z1{}V`cV}_idL*Q z!bEFEmCVJmX$yum7!vZ>zw~lbWnKCXQ*xd+bFn_UqOajPwP%Drxqcr00J=GgUa8*< zcA~$38@xX^JHh-)DbV2!_=}i7%2>@vbklbN1zWuu31y(OQ07T2xf&@Ye?=+B^q7^V z7D8o!r!zg%Hh%C9gB596JeFQW`Hkuqgr@d#Wxtg^_L*@?KVx4j67hZtL4P;ZAR4t) zwQo?h$wE^x6lrX$t9Jkm7C4|w;PWIFj@x?O-DIGd_ukR-hex@L;vsaf8=Mg&;qX#q)PS7d_7vdH* zNO0()45shMQV>vyHYyT3B<+AtZu1*X#WZ^@P!%v|MiB67TaDB@*px2MYYCB^tZx+* zneiHY@3kn3J1tB>-6jWPh_yeh>Ii}lEwn2w7UNBS#6R``=3n**Rzi@TOlmEw614KS z6a6&>nzgffF-_mW3iFJCG~b7PTC?&h>8)@d8Rc0i+&5N!ppnKNl zNsg8Bgl8h~WdQB~&yV`-mryF*di~f)W7Fdef=`KB2!90cGpgFULr#a=-=_QcUbBWP zhR_7Ax`D^L{($ZC?cn7C{JQ0c6gf=m!ds5PD8mP?KF-IB5WD6$g5&XX&!SsU;4RY; zx~FWzZl$Nk;fDacsoG~C>}sC@;4S`PLwh@f%YQlCig1i8DJ;S8^O}!S*a(DOpB?5P zmjOT0i5*huy`geM@pp?7SccVSfNF->T5Z51cMD*(6*PK&M8;9Aa$wX|AX zdOrYP0pPP;$tsuABkA&RN^g~}g)oBNn^5(kLh+(%sp>_A;zO12Q>rE{e`)R4{|fsh z2crXS=q4wutfuv6#XPVPOJE0$%-Zfc>S;P~!6OiK=yY{PTzVukJg3Lg6JKEFNG=V6 zG{s%SX?dFp0Z^@-b9M-TL(L%c0w91;0ighZ=HB|l!(nBx@EQPJte^~TJ9!~Q750G8xh*$P(A0*@6=O>5q&~} zUBwz1$@FJNM$?(m(Oe(p=AqKRXvQ?ZD5+1p`w6AZulnSpDv1w4CR$<3w(l}>MjV@t zt)z$uhcnk{<$T(nHFDC<&DYmHj{^GD89H}LV^TN9IK1XZVzEZ~i^hf(_u_eols)0r z26gJRz2C0R1a(^1cD59$EBcA+sQ8w7&wp-UajDR|ATj^|nIfEA@$G^xiQQpxslot! z3P9MXk;lyHQk%GSVWdAe!zXO-D+*jSE20rUTf%C1ba-wy>+=rnlcN1Q#4fHMmG1Wp z`m)*KxryYh>c?cV$JFC5KdvT1^{NomUnL(Tw_9Q+b*Z(>()SpN%rT=ZJbvdm_yB;g zN@BH3RAI|j)=)VqgfI+Yo&GhM>=TW6=f)T1*H95lYH%Qy9Ga66KptkJ?lpPyEn4MW z-=2Gt@aVI{qY3-=KC4~RmFKTs>Nc1Hq1W?~q_?<1w}S3;bj z)ip4)5oO^?oXT?PfFA1A)^fDBmLpld}FTZ3Lyf*>ri_rV!1_Jc; zMzT461gS@)Y*lwl-j(SYPv%+y5UZCH0=62$6oiT1L^u1MHf1*e9m>B{GK=KihAZ>^ z!JHQWr6$!ZnpWX3f?@F={L+0O;fT*Wm8SWtnAUrEv!;5bUsk(iCLlwrAJ)_gUdqk4I-vJOf=mfhAp7| zwAQ$L)3G^|Mc8VbzWBXva;{bPYcm&Y#5&QeYa;)Nc=Jqj@TM>AayYY7Fy>uAEh1%0 zf8=|j`M`y6H0$C2n8w8O>ao#|4?9LhA-|1fv&6i%7}CBF*&%TahDy13yp#*wucv~3 zN@Ln%X6(J3D~<4sOsWTEF2BW1>RZ_Wv48+8w>N+1KsZ_dj%g^Otg@ReeRoPq%*t++ z%@Wcn*{!9EUV|S2V3$o1qEwi|LMX$BGF{nVC|1e^FGOA%FZKksJb7Wpu>l^y)+cqI zqUBrrAv8nihm#P2lU&{m@(U0aL(xnXgdzx`^evr5@K3sr^(XTNm#;c-Ids8_6w>aG z)FA|0+~x6SLr;YRS-1ONwiFR6_Aiv3lPw~MsjT{9Vrd8J$;Nx|`Yk5Z9fUN>XVN1a z<69MNZe28L5s9Llm=veFzRSPVp~cL&^0#h5031_AuL!$cB=JI$Hkg6^F4lDN!x2@e zqM@k_DsKh4ow)~lyNTg#)={rwHq zJHKN8{i>}HPR*OMY?ONMbX{fWPx9}_?DV9?IoEGtmGZ&!f!>~2JXtt!KG>U&H)Zz7 zACc|VT?3mvBEN*tZmeqHK11hjwVoHsO2t=hG#1(-Hnrd?=aUWs*|_cn3Xx)5)DWcOeoTwZgB<`9<+$-&ny)`hW6xnyF%E|KNCm7jC^X zTnFB7Z&SM5^$o~1Ds zytSWWupr&YpZ^7d;~|K36zB-Ov%tqa5OYC{fII>gJbDqF-x;7I^!5M-gUWh4Z_Rfc z&YJJ(C=1Ktir{WKpoexV@C>8B-Ng3@GVvW`T0(mtkG%>kOHfbPKpTWVxQEw+_1_~H zOhYU2oR6nx_Bd?ka=PUnPh%&86hS{rexHZVL2iVw8^T5i11$N2*Wq69R*;6J zQHw}y{!v12WsPq{0PwNO^qIqrFd94n;R+m3G*@OKffStup%FqBag&a~j`c9S0h@V} zFj{x`-d(g&KK+NO_OEB`+aT#9;lgYfAn?9}PM&#As%p zFBaSH>2De4;7yQee;8?AwzxZ`1i?Fh-E9^C_@edowHrGz_%Oo00rt{}w0#Pzb*gG_2w+8iMaaD2J!39xjaEW7=+b+G>5;u-){O)$u^X z`i2L1bxm*G#BOZbSGZ>J;W@cd4&79${0rr3dHPlDnD$kL{BV?9yZS6M$-&iy5Rz6X z8<~6uULMp6ugY3YhQ`HGSDirr23WW^@|L`wl|bmY=@oPHuAc@4Zu4H!T!0vsm1TdT)xf! zDH~fDp`JvzJ0HRW{Yske?f!vSpfr%EAM~BUQKd!FjjF{A&vV-DT!!T<&Tnp7T;*~& zs=NseWvmK0#ew|zh!317(nzuo0A=Elq^(ro|Ip8i2u_q5@MOZY)MNQ2?y|owOSU>4 z$rjm{whkd2F`I2ju%#{FjAQJiY&n(NWsA!|OoyOi=^EH*fr8VW6bH?#V;uP!d(cC?RQRt$9qdvRl0wnj;$ zJlZ5qt((v}kIr;9RzGP*fhC^{Y&*y1Ag}Gj?&Ris>Oc6}kKAondT#m_{|sFLJFd+^ zEF;c>@n&S&*>78$7EU%|7kqlj?;=w6uX&<1 zy8c$w$g02@OoNX9l@l|YDuVr^IM&TI`eI}lk#V`QVsl;t#kYw>W!G*;JWGtQg+wc zR=(|zn<&ZhDU(Q)S!~<6>BH(MV^pn)Tj~C|Ba-Gv-+uF(+QLEHgVIXJ@-Akml!yj; zf-ty5v2stfFR5w@RE0ZHQ-twH~1(W%OY?1d(!CApeg4sIWZe4lm zmcuL~gqK68gRq2UVmBL;wySJgvM1GJo(5}}QcR20hZcB_a z&qn7UD-kN28v1TXsoUJbt$1j-w8-TSPj4sO?*f~RwD%ssoyBvSfP%5MJEw200A5bEK}|H zWLKla*s@5g?Ta?Ft)LRjC7MRMgo4iHlIo6jC1xP_oZm<3x~v0$wp>WrX|nl-z?yIO zN=T2tq*giCxSMDX&#yP?))*^Dy>qboNDXH}eFP!cHj=#0-!+7GuUab~B_b&IQY0ewK-Ou>=>Va(PtrE{1z5ga%wUsN-|O@qk#gMkrV#5(h-igE^G@Iu1Py zA5grGLA|>7bBH}Cp9T)SEwoQ!7kbZY_dR-{{N;e+Q`jQq4|MtYU=R4Vo`+^#3-K?~ z)vc7uuGQr$o7ZQD#uCxqaz?wJb^X29H0pl4ULbb1x3S~fpGc>TFMMHD^yY6kJQk0Y z=2(o4T;unf&HnC0@X5?dCD+Zs1&Fk^Xm>Mg1Q%ht`yI8Q)|IRbN{g$Cke4D0^O!@l z1yagpV^WZY8J=rs0K;r4N}ErMsuhQ!ivc)00BW^Me{yaHsNaj>5gK?O)a?T3#WlCp zmZu}w!S(6!8Wu=ByxTJbc6JwAg+B1e2?~oanhI`?g~13$)oi3{&MlAF)e-Ak>o(Zl z(?sl8s9@bRrGMk1wffhZlPHEq_`CkZyW;IBA>5iYy!k}K>&=N(Mft_)Rj;Z~9ht^k z;D$o-!s>-Cx|in*4wHB3x9xR%)Wx&p5dr_+3NF2D`g|1)!XxBa{q{W`_ug&#yU4@B zhQ4d)TD1CtYWM9MU<*Ed`)>8Cs}`Ar({LSxHzAd00r(hz9GSkGmE?5HuJGJV$Z{8Y z|2k5{s?tBfQ&%GT@yJy$NhKmhM8bs*cgO1UQsA`DW6whrZm&=q9>idHTJ2Kc5cEaa z6=&6M9QwY2kB6`8LA4=!19lEO89tzSvN6HsQ`n)mfWTWSD)?Y7|KNTO#))HP zDbp?++=n1%YBxFn0bu|KFn~icoLu&GXAQ%Z_~w%db2tsTsB=g}A42S5h=*VT4`-~K zU?JRD_J3y>WZhf>(YZ zQMuhD3M^mzbtT`D((k+4-;t8{jm`ODZtfkDW2x#t4z{>l!k_S5yP>^{taNovgxqfZ4(<(y?_sIQWhc(A?qSPXo=ZjDLmu{RP$o!Eou%3Gky8 zg0J}<|6m~Bx-ySFTp)$hA$SFbd3$2sNU5AIY+whnUA{_c;ArreHm+5SLJ(cWc6iEKw^s#phK|6$(0B{LxTuQ_{ zrsu=c36j!rj4YSQLmNisrhIJ-^U))0KK~S?g$K4n+puUW*>ek<-@t*CxuHMzIJ*V4 zf*@9|TbE9|xkp$b#;9lhjU$*38?m#QyELcxrZO=S$W?|8`qHW7{vzl#cL?sg;VR+ zpe(!-r?Pc)K+n}tcn8%~hA878G?Lua=(k!Pdk;&KmE*_vcqoNerA&rA3MJ)cT0KgY zT-+5U+vArlKB2%(%w03@OLiq?mn-~~(yws8-C0oTR8VT#XO&sUlk>~vqyG!DE3W)| z)fRtfIww$94i3)`bzvm=>g^52JB@6Y1;u=uXH3<9KT9LR!f*p4kMbX3R5tU(N?T+- zRqtz=JMQp>H$<}AVUB8uv@INUwCh3*8_hJt$VRvb!=o%qT9Q+Nz}6DSQ8_mM(7{rl z>ErL7Gy)v0UJiKU-1-`o%!jeO!bSD}D>y(bAkE6vlB6psF{TId>F$n!62X;XCxqdi zLLk^%40m)ECJC;=+Y;wmpzh4^r(I2 zK(P>1&8xdt(k$$g@*W>LN=Qxs)eRQb(SiQMqoe(Yj`q(PJJLUJNT2V(;UfcMImb*Q zK9kMHcg)1|`I&fp$EMG8)}E&}iv&QhvWu?cTkIL}uwI z3*+L{Vg#)OpdG?m2oFN&x<#QFhBh@od4Bmst#qC~O*j=)$|z%Ri5 z532rp49FcUJcjc%44KoQUT?(0NuAh}-`Mq@HA%B$O5ZllrVG8#_Z$W$6uaRdSm zpHE)z6q4^8>zOC;R6K@NsrPZ0nQxzCe}1o^_UvD#zkjWVt(Q8lIcR1A`PXlB?H9_> z0O9>`_h>7EN;~i5*tyHAt2{OA_)CRVOIP)DtRKi63Yf>muW6u%<@15>j|n&ph6reXbQF%QI+#-I_xb8v!sAjPa4sL%knAY!aT z)E)%Oz8tirMP*?7b>^-{8k0# z+YIN4xp}56X?tdE$5WcH%Ap;BxRzPEo!J}R?Kiv0-q9@Ez4|r|yhbfbde}CU9$)oAR|`NFh6||` zgRgMi(N49jj=eZ)!XGo5rYfY5YSe(*?2VE@~R>CaAP;sUqwV=TTTrtYF!@P~A z(nob&z!nz(yHo2Dyoy!hb<3~==0?_wF|LgPqg$62-pTw_@B*nM-=wMbD1xRBTlG8D zDwDl1dEH;w|80-ZS52qZlXj8%-s2>(6YVAG;A6U99^=|1Z`KYkO}0`$KBnt zCcQa^jV}DVs{Wf}A!y$ig<)cqWL{2j3`GLdjiUoukn23HZwMSfgcyGP93&PE111#KP4Z=n5eFjpF)H`Ot zE*x`4>k%1WjWZGoPw%p;&5E3aM;&UJMz^jp(rA=U4l#8Fr3nc&h7Qp26NEOF6)AF) ztC|VeX$zzb8$L;p+Dl_ekBlWK&YZtJ}ZP(u>xRQ*u1d>Y2F&T zZ>rT~vDoFif~MKgKRS}TFvL`)iwy)kTfn~Pz3P2Z~38OjdTfB5kQF|?SRh^-=;;hegqT2_l7VqEdXE()d|fjR4wK^ zBfYK!HVkBZSxVXz&>cEx^?An4U;qy4TS-tw4w>n=>`q~k(Y7ot{e%!8#389{bH$ON zH9Q>3g2P;DNn+XBpO@X41cd(uA`FKsG^79fj@;rZj)C}Wyj{{E!uYyzlkaV9nA)Nc zU!252S?WY6VAep09moPPcfZvcR@a-^#4@9@>6JV?8|xFw$0HHxJe8?Q8c`M&)^xd- zGwEk9@0#!gXTT#M1a~rBO1{jz)2gfzJmwg`3l3*3C6>~&2h&cy@G5QdvlI#R%|{0< z9!fuBYaPmO*Eivk2f}}p>@E&*V$`Zukfv5sT-d@aQP2T}UES>WAdl>2)|UFOWKpHT z!+d2K00S#@Y_>fBYD45)d1@KO^IX@35jR&*O}?UJSSkPv(Qoso zwd&6NphupeNGDkAMPXRR`YxT0ENsM%ZB);fljc?&w*=5$A5sIrN8~|>AIc1f!yog0 z_Mvj2&1Owhc8bCak*#?;>+2J8f6;2bSB8|8`U86IY}s(F;g0)o3lX=%PN$}!xxF~< zV1SVAnesYY6zucuat`Q@c{+`ugZ7xwAeY=(kDWi6W|g!pPeAX{CTkKnjI9*?7dQn$ zP#9ug7%>u}cU=D^XUk@&lyVn&%6ZG`Q^8HQ0376xn8ooJ{8CeNpFP7p;OWC>AK$*b zzuiB@r*Mm#KBLUfqCrin4b99mZc*8RB7_D*S}B@k>ffkx>&)mG9V``Q4ILyBb_J6I zG)++h^t+w|H9Q-ym{NWJ&(7-lBczeRKJ#55E~MohCddK^f9za}>eJRkH&ncgJ5vBr zvuj3S0fQ5K>~g=&gvzcqMC~Iotl`NOfC)e}WY{?ws=Y|c2g770!`TwE)$5^Z;*7_P zXY=YAm5XDqSEi0VubQWGx<286x8#=q=4-M5mGLiV0AWtsS%66lUqF7b!2LK0!ioV% z6rM7S=of*N;XlGVD4!V~4xraV_1H;PjQPw77?zn^0@6_ms)0!+o=^R3H9AG$ehC(! zd|ddQ+((|>mE5X}8b>LU_+_}O@m@4Jo3dwf=@-)~Tc5DI^cB&0Hd%y$ zvT8`30YC`{lc7(O_QACNW;n-LH_Pek1sm@C@Q)g4LvvjKnJ*${s$_YOCp8xI3@Iw_ zhyIwN&IFb48**>&Gs=p&qK@1-d${lQvKrC|aia=7CZj%34X~E9;bT;$`j)FcWFqh{OR?iMr0ip%k_FW5L~5;QHw z0%bIC$~oqcFQ`w79rUYEakst2E5<01I=2#c@gAfql?o9uVidiYMnVFwO*JvU`z=j+ z)N4OPze-#>xOE-MRJA;Y_vcQ5LC<$#7q#(fh;Vb)hOymQWZSqjA(|2OPjAv1a(gsI0?>VZuj0WhKPo(W4y>4Y8%629q~NcV_y=mFYHb;TAFTs zdK9}^;nZ{!g3G+ORU&5(aw*6dc1!VP^Z@qqWAHD35^T2iSq+AU(!Cn#9=jWb?&^NS zX&ddUz61;1E#Fi*yVcl!r6;?)X1clUL<{Q683ytCy}5~_++VO^sO zJ=yRZG=aS4*0i=S{c%v4tQ@>(8cVQnYR>vY8DuF_aQryb({Hz?6pgMvKAiv`efjkL zH(!2u`|A1q&Gmk_&KA>2QDUUu(KX^YgnYStHxN->aOKK6Pw-_{RqjGdVoftkMYO)r zKBFj~@jMA~2*(@C4!6@0xG8q$(*ES`h%6p3Qo~i-^IRF$x)i=Q8zMmmeV)0tM;}I}hA74FvjQicDNN1BU zut)u#uCW$lWp>3tlEsc0!6xkP$Q(N6eg~>9WL16atrGOzn`Tp_f&MSe7YDRXX+9fA z-0%(88j?y_&eMR=TL!-=F?g3YsR0pS46y9ke1HH7y~{#k@ZY~z*?V}URNL^X(!#IG zSG^E$cfBscJj`ZO=%Ulfna@aYc8FI12!nb{OQains0iPN@@PGV$5%V18Us6BlX|m#+@AE zt`JnbN9k8cvj);1qiO)<)Vg8ix%ga@GeE~@&I%^KBa+AWew=VM*2qQBu@hx>>lj@r zt?f9Pbe4zQuR6>HnGe;9m`9Y+d|JVN7c9)P^)PArzMh|@31u+KE)4&uQ(I${@0Wrw zH|;?ol1IN0fazy3$zsU2--l+A6s1l#pgCuu`U^^BLNy7qcUL_i4BP`y2C*SBwv6_Z z+R&7xe+J|HXmww0%5||E%x2xy|*aZ5rLp_thx!Xxa2CX=-5HJlOzy?|dCv>I=e< z9^w|Pb~<4C1%4#_*eswtkA>GvUC{Jih~1s=&#dleSoaeTxLM_K*Ji? zfco|^NYRX|9#jn-rRWqfW5aUooxKOVPY2?Pl<;g~ov7Iq8xFd=kO7uePwW62#wQ;qtFGjD= z(Q43H;Kb_dvaaX0_p5BPo{GYR{HV{V-5OdnfuAJi;aB&sc9~BSGm5(8$L~Rtc`tVp zR3+*~rd*5O57~9J(lUwp%^sltnEdiI2TD~ByjE4aFIeL(gvP(xgyTvc-t6zN>?nCh#4Wg$>>-mGaGS+{9K$?#|F0=4${i6vE2U!Y0c- zcbxFWBpGS7^0=OYVrm3~1sI~RiD1*BQHn~=5`fm<7d<8{0-D&XzcDbo4@|G66>r97 zj9&2p{P+Jn4Gy{Q2sUW|7iBf_?cDD-Me>)meuo}$zh@DgTtIz8zYY@D?Jt4OcIX@B^*K4AZ-@&*ceW5`m+IT<*uErJ(J>;wgaRz%pKHs^&eBr^W zx6XDs4@;7-npCzYeoV;3y4o40D&J~Nh~H;%V+nuMW$brBi;oTv8Zk2f+EkW zgCWFL65$qk0q8_?bb!G8@zHKQ8`I6Q3OZho1(UNTyDs;^qZbyx?%Gi!axC_+`DMgG zh2yb@TpGapc<=2OPw$S`M?YYGoIXZ{h)?^>7jzntXbzn$g$|Gz{Lz2bW+rzZOu~ctj=z{$%d5-&~fN9VlgE$=mq>yu|bSOaJH} z@|eFZ#jaTeh<%X1x2b4O4Uty0Oau)pkQDLPqR`(&eKG*$0_#;MFo%BI3;5(84-AYz zWSY`YTlJnFj9$QkLY@qaOVrS5bKl|%vD4?;8WbYF-)htFEc4li?Te=UT{j!qtR4C- z13pP3j)ZdA5n>)?VdEB7BtL&Tll4bv0C0bnY&W(uQ_Oe8ugd?g0mMJG3@8_h$WLD} zGaJL1qitP)uSOERk=Pp>Jf2sgVpFpUUA4bdp0KKY*#1Wg13It)wurvnRM>+Wv7 zzI%K3=AXpmC%pa4Bf~qs|CNN${^KS1xPmyAC4P2D^O@71DDBYL8zR$#q?m&AwAK3) zFxgXH`^@l+^-GA*6g}Z%%ppPY@B3OfxNaUoiv1@~0I$*~r#G+QC0?A&#}V^sF6pAO z!QXlVI#nsw$;-Lg1t1*ip^(B;4?AYDTy_E)X^UV}4ekizJi#4-R5g&E?5(ywtSv>) zaWoB3f8pF69_==HI!!p!Y|S#dB94(}2dd>q+Unt+sQ4*199v~bmmM_SD&O@OuYWfpTvrQnTzI9!hXk|7%yib;dj zlK%FTKw@!m1lajfA$7^sdA^84bEiWCDkx6;OfshpWN?zfAkK+eR5Y8xY@!WpfNkbb zUzp?{e1)UPjZR1D6JfrXCSHW`NfpCZQTxV3S^ycSMpip*M2ReNF3~PTWmG-EQm~cF=CeQ((1*+prU> zzJ&V`=Tc>uoiW-`6>dXzrcNMu8LDn`$JSjfg`lIgL&Vivo=f5Th03Uu{*Y}~* zUlj4T&Z1rcfZJRCy}!M8@mpDI{#}nw>kts7=uJUQ;(2qn!zB63sMVi6{ zarJaFJLH(i9F^@)`UHK}Q)f3t?$hC}&v2SC?s_gS(X#c{1|=Tq0c>)dcN|7Z*Fn5?Lk4AsUYa%rv@V#&xgd?H=Zf z$Sius?Q~gBlkJ9PG(3_^_8vcYDeIVN^p+{CC+a{ZlMK47@M+69a~L*qKG*A4!BD4+nUDunK^^7ucb}E&vj?hdGZ&{nc`kSuf*Dy4tf`9iZ|?h)djN z3fC|o4s8?IouAxA?(ycBBGTzjlM>d~7Ol3meFu^8(m~Wec|f8$LS1;WQyDj&uLo7E z0iF)&0GJLtFxP}PkBvY7M2U@r%y=@;kWpt9MoQAX?pvB6T6Bbvl+$dohoj>*bM|Tj zZ&lBx$WB2EyOHbXJweoY!7;PAErB%IK{^5uC7(2*FbQOqaDtNU5Cz)45@~P5JR>K? zABNX~-_alMoO>{ivNZ}F1luH&Qfi+a28NZS62TS(xVL~|?(|uUTBb<=X6?2{qXDqx zqu`2DEBlvzD)Mvosf3W_sgziaL7Mqg2Dt%GW%3bZ2-h$`q3YdHDV+bd=2xmOmoKS4CY z{AcW_3xilaZ?ObuFbd$Mk)HTtDNVGJSoul~CG&z>%&(-S5g?ROmmKqagpF+FDip(W zqH-~64qjNJ!&Q_#YHC**qhJv)SHomRPTwf2+KN@m3Yt7wOVzdphiY$$dnZZ zWb34!L4zjE8Z9%}R-Jv&MOWR7*If@o3^UwNJ@wLCAMfnb*I`G(L>97es5l0efWU-D-Y-1Pu$kQPXaf~8PQN}r{xI`V-xWzpl@r+lz zS$y-|4`L#Y6gHr(pt9UCV+k-xMB(Ai!R~mn21D1 zpD2?&aNA^Y?z->JD)Ur&I;UuFX+=(H7egAW=5dimYjJ?mn-$gUlq;!zr9}lsZvK`0 z{zXrSr337OE5(acWU_w8_;JXt=9Qh(_i9<2s_xaZtCl6j zjvz}9>GFF&hjT>f>Z44yWsk`cIM*`EIhNg%RoG-1m}THxt2~fpP%K?76oEY^FeqS_ zf!#AO5ZJu}W)(0nuzOb9UX4e$O4?B1S?+jAVZ;9@!|H!lUQp!+91Z>3G|^LoTCK?z zoe5@M39iLF4z1F-Zcm&i#y%m?=seMZ|693Y{oyhb$9Us!@>PayUVi{Lr}|!L?pkK8 zDtuB=a+F#M75KSNs;{7yp1K!E`xB?vdTb?5K4<2^FPc2P6|Cz&BjE~&VNc@@?&E>H zR#)DE>8PsJ^)rjC#MDgGKF}XzO8%|?2=sQ(k6oUpZN*655;x{Z^~AQ;2k-omn|+pl P@hkDC@xQ5j#o*l>+`3n* literal 0 HcmV?d00001 diff --git a/nui/fonts/barlow-condensed-600.woff2 b/nui/fonts/barlow-condensed-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..da60c9a90e758f7924dfac415f24961ee74c59c9 GIT binary patch literal 22308 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 PKP}bZXI*N7khDDj=5*Dq literal 0 HcmV?d00001 diff --git a/nui/fonts/barlow-condensed-700.woff2 b/nui/fonts/barlow-condensed-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..3726e9dccc00171c2b54534fc148d5dc5dcfddf7 GIT binary patch literal 22444 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∈O^}TOnePn$~be<1*n}VI)~a{MTqt z@Nw`G_L5p}=$wGn<#2?5k9KHFOD=PjnOZHzrRle?RahG^_$!^LD5z09+eLwBgNP;h zS`0j~pd}tR1OYo;H!nZo`9!xMC%b47E|jL+AnG_TeY*;sq<{`{)o9)t%8KB+)}?)9 zuwqy6M(%`|B7?Yflcpyny^PYcA$@(5QZvr9)`fiRa>#5XkXY?h*awKp!~xd1TPHTK zguJij8zdC)mQjP0gF#V0Bsa*0N(v!LI~O1=G?+I7QMk@TPa?ZmK@w!y+fy2$-&4-I zc>M#oUE-@Ntt`=}SSHF2NJM^@M!^Xg2Khm9OY1UvyD(8OO60A zz`v3-9g9GR=(FbpXrPr`d=k}25!D?%ZD(2-T^t0HXJW#mu411Sk6t+CiOB5JAoNVb ztQtzvQqzYo;rKx0-e}Vq0QM|wNk1sAzI-;L9gc_kAvYk&`fH>MIg#lLGm^?l2|i%G zSf-{^kCi0v4-60f1g!k^^fv(bjvt>%NCv;eK@tBDi1-@8CTAwRoG}DT{J^(W;k7;+ zSFc4@0qk?zqn;uQl??Nia0@>phC30)BS_(CY87#5fua~RPON!~Cng6Gr7x(QL@v|A zp>pNY7v=}4jS29=IS>H3H!~rHoKA2xj3UoR#2gjWxNGXUJVoPgoMu%ahqwBOH?Fwu zzytX7;V<=|l<=ufG{g}CYds+sLS&175qaN`cAH7Bu%$w9I*Cr1!`bw}+8q@RaR7Q~ z($a24K9rbtybUfJ;p`0?Or|1kZJsm;5NX%#M*glcOz^2bc1kpv*piwC z*!Lq2{XbcqUj<+j{}Lcm6e=4~I$x5K>~DB2<3M+0@zkbq(BJJNVZ))9GIV;O8KuiA-^rxKhLqI}0&$(%~;xHtUjQ+k<4Z#|X&E(!lhFbZVuk zc@C4u4FE*-9V17#B#RLuT2oTS{t5pS5PKbNZh|bkyDa)ojGktQ(M0>rjdN5MhF~1WA2xL@#da#7KL|SLC+k>41CI z-a2Dd0Hgu=hL~5jMpf)?sm{i-9YmghO*Lvidzszb_`yc^ebV&egNz=(FK%R{L73e) z{Q!Ztian*AEY3))M&gudgeIZ`Ji2ec_30@K8Q)qVR?b-+U<8Z;3e6iP*tK&60}}d} zxu`a|=~YfcyM!(--&}k@($~O1XzU|o=$J)9ZZcvO^ z?lfK)5h`-RGV= zUHY1!DP6y_xoXmUoXXTCyNMpFYr1l&WgM5AG3(?qK&WnjVNxP?*SsEQ4ZWO{Xzm?Ek|dR%4Z_ulu$}qOaq=06f7bg?yxQ6+_f0cj2Ro8-g!T2 zrlSZ@aH6rE2zqfErGnft_-i@5V8#b3tnP0Ekt)ck8!OgZiDcB}&dYnEALx+j;FDXu zj!z5&05z%~YSVuv1p%Q^MO->M@U+Fu%N?gnWp&OjkxGdL*O8?Iy@2m6!LC68s^h#otmDz`oo!cYl%^_hlc!Oz@6hYObwThTftJlDoVh!7BZqmse~$^BVoZg~ZonI7v` zXF^u?5-a7zpQ6VSuTT4kShgu`0YMAhG71J-9PRjdMj4pTcr7bR z=SGs1TI^@`Pqi)STm6f(PC8A!%lNT zqt7wu*a>6zt-2QHET5Mp{Z*lppF;`O= zmvh;)KoRk%qKvEW$5V8Z(6x7Q6Fi6iG*k|+l%H=XY zdAa{JIQQE0{97^d%;$)JBkj9i(l|a}X%})Cw+8_D`-yv)sQ;aTVsUAipMc)B^-Y6c zSo`mOS^F&QQ{qxb*$S{W+d1#BN&gdKJ#lXiGQzUrJVmyYSYmspc(LU`VIA@Wr&{{6bP zM@PX(k4Rx(-CRo_e5uL7l%ID?xcfao8YMP_$fnY}DnQvi(8( zB%KNmJT1GW*+Y)UBNPQ=+eM}<`Zo0si_aKe;yJ=lSx2Q{KSV%_yW|fmDwm`xWjpnp zaOL#-Ofp1`mm2t|P{e(rE7J0=aIW|;Q)?+5Lm-fl%bS75Lkdz2$|D*G`O&5}T|s*T zb!GC_1UR~x|5MVloX}wr0(GI4c7cQpXPAwf{t(mHxiY!F9<3{j_np33xDl!>EnS9Z z0UCo})@Ro^L{YChac<#9N|uNSTpO0%kLv-130@LmOCVF!AG0cer6-FjJ|iyL>t+G5 zWemHRjausYVx4s@A>;Iy>(;x z`q+2J)8oUpCMQn%HX%t*h%i$#1Ff47ACX}P(FPzswDPTF*HkJbGb&|*_D#m@&-)WPyz6(KA|=8*Gv zdoOc#c^8g<@y=^mr~*K(r55z$jeonbr1E>q|Bi)~civfZxbI$BoLHXTDYd%mm#6k# zS2>bhAztmGKY_=D!Aq(V7`7JNpkzg>F!VymYsIZ}ye(0RJseNBa zwI>JaT#MZ#m4n!!{&m*%JRO+AY#Da@jMp-sm)19JO)?vFIyIJpc8p39tDxSdo&Qks zGoIj~?71P~?2rYCyA#`g{H_g~6cUh~CWxg1od z>ilcYi!C}V-CfpTbHlzDabrd_cK@z$paoK&1;(WK6{sHax0-f5oL0^;szVixT-v+n zx3EC>HO$zED@53&wTfe>qYkk3jvqkxBfu~1bC+}!zCZPZ8I?6qfkthY0o|9&l2A^G zc=*!b)4sI7bk>HS+Fw0u`14WRi1OJoz_IeJN_*}_eyPd7|2Im0{)$WQ=ExX}A+Yhb zMw@$e>dgPA|GC2qK%>LjJBf#jiZ|jHeo6sL--bPQAy<*Gp}0?iPu{Ha$!}jHwj&;2 zgl;|B?_C{Ao7|G-I#XjP|Ne5pHxe74YM7;WyBC6cd;2Rc7jI)U|N72U?YQ+m1FE36 zv5zTlD23A(S)i$$6HR#DNAZ$?=jv?n)FhMb_jfg z#LZT+AsKy2S0-@+*4w@sM+xSWj?rUwW`wlaDC!r10bT#|E0@=`wvr}LkMr9C88-yDA6+IY46Nda| z5O6P{y#_#mo_@ zJnn@J22yu`Vx8$19Jqmjrp7;n*;M5>5?OM zG&Z$Aa=d1uu3~D`cvk8J0IbYjdemC$0Yf1VUstkw20o+JUk*WVUlKvH!J1wuW=9|x z0z*TR>(?izr+Niic`fp=9dFL$pg4MUV;Q(>AYLF?1&d%@Iz6~aBRsBW!P7jJQirPv{={GXT^UDwt-E zajN#&)bp_UFhkA#pD$fAK6#XrS_V*epWnO4`SEa-!wB=Y8l7g*k@Evcr2zNA5Gu(B z@cdmNWto|jp@u!34@xOg@WPSh#@qrTMQM?n{=9*aVg@3}@p8|5o|nX-Xi6#)!=QBmy3S3lX08>0Db^%{~syXPMsU$V(BVz-uBwS2$L&u;bnsXqE zNQ6rE*@74Xdq#&CtrQ5N|NQs$(D4RK&7tT45jqu((Iy%g^lF6*n-SR!=;-kvo5$8m zaVk(z!ASPj9$4%?oN9gg4Tw@y_Q?;K+}+&FJ#h0DW8SHq0FA4JEFPo8%vKmcV9pVG zAkXc;v)=0LY$N;!^)m~7;yTlBt;k$&MoTJ-5tPAE0*%UL1NASQig7`{5N+uTjui5wSs47O{{O>>2JO0gFcbP?Ze_AzeUuw2?asfro+o4ung03ya zZO-({+8uSl6t5m{_CQr<^{;exenUFbMS13Gl!J?^|M%r9IsD=9%E*Cj@~A@>&+EK) zNrC3|2yaLxnv$tL<{A&3C$c+P>oZq0#~ilF(nt}ydCq$<6V%i7nV+gfs)-BAk=XJ3 z@5*au_h<*Vckkk zrELOl)Xhg7$tgCGiJ=${*d8&A=!pwZVOjr)3-j*mhDSOCPoYhzh~e zA~H+pCDvWunE9c_Y(_QO};y54I?&648!U-@I(O=5aoa_<~(8!&F049wPLNU++3)_s=Es5gJ`hE-^RL)6?;k5Is z>{x|%wR*&UR~M=|s@YnJ9iO?C2}RT77P0G^{4fMdQ9)146R90(m8uQu6*SRz`ujVd z)$6PpcGLo&8!9HLNPtA_oW)^uSmGu~k4tf7n7~8DO)r>=5SFLn$xBhc%awy2%8e8g ziBGt2JOe?2fyhop;_Q;i!wD25_8kd#&j+I=#l@V3>VsTkBW%gdxS@@);ASb(Jl#$w z`M(=d>Kz8!&Lp^7+GVvviYoU{(jeIXt|`t_>W}w7qu$@al?&{I>a1LRo?MirMF@nE zImA;Z{Iq5N1_0LIAUcK~(=LEW;t4J~FO1sxoBNXn4Y?kPD3QT)V) zdo($Ro?EoNOH`!JSupP}M(ZA;$+K>9hUfr)RA8W5z&n5xA~nO?ynw55!Zo-tA8-n9PugPFNc?Wvxx=P4-$AZ4@+lYkUfP)D8M$4d%@-L~$a#VYQ@{U! zcBKtt)+Sv{T%gm2`+6VjB6G7-&--g_juTdlnM6;l0I@#VmhGx zOrh=cchh|pd1tqC*@|YuE}K0RN2|N%d>}bN-42i+{BwJ8D0$~mtxgU^S=C(eID#BSAaW&fYsOv-3~2QKL}6&5!i#VV>e*higSHQcluXRrPXA? z6E`YYv;(XGOY_mqrz(1DJ#`b_m@4AdCxqv_JV7l3z`rINQl1Ize%?<6A{$ToQjcbl zjot%|`QS-U!#_!jS`7_-n@wNJv}fd>9y&sm9B;6KQ!0oXV3gtvaf`mo(`057nG`XM zaP*d`a;o78Qe9UnoSx3H#S2cD2&`A}8^!0v%rZ9Tu*sC6gR$VhoIZh%#<}oumovFK zprGc6Ey#jnhBI|G5umsCIa_0xr0#H{EDQQfPydL$4V7ZmiUi$9hSr|qsqMChBEjb*jieb^!^puorJpcd@A~wAm zYPB9Z;YX2=7!?eIFlc*X0m+rjB$0sqzMs#m2;3f?n4`c0{yuMRZlEn&t40|BK!+D! zj#m$KH_m_z%sf4ICjfu=QlQk*i}Y%W#>MWnlE(U{hpT+}-~qKXMx+!d0h+6)dC-LV zBI86hiin8_=sXX9pQbYjl8Dv#mOtGnSUQ~EqAs1POnOjn3+i&R~VcYYKQDHf(W|*;SiSL?(CQd zvr3A~2&=XZGTkz;5Sa%1=ZOd?0yX&>p+%WdOtr#VNADC4);9L4DJo{41GTb!XuuNn zXsXokiMedUR}X*lBBFC8M@WWCn8dllF`yiuMi?Rpz&a=q9G$onF)pp@X~f7~)djA3 zOXk%&Y;H``_JkyxAf0kA#av;fSe}N=5JKcNdU2-fV`I)Krc)bdAH$)44Qa1+gcBwsWwacA!XXua7)k zNr$3n`cEV(E?HY^#N`pZEo=G@`C>H9eiA1dhb2UEoQ_jhl7hrE z=w4v!rVv=O%{w1?xSimocUcdZ-n&>j;Ef>BbApD(>NL+?0=e?_El8qk;cF^ALfvkH2-s{AUHzNs zb>ggn>4jD{D3MD<9r;0k(v=$GNi$l!-!YJ5Gx-%TS&(*N9vXSr2d7yFNmsUL@4qN0 zOpe`Jq|9{DLe?tuuN;?%)jXf-_&skcL^x5{j@!H7mFED%XWf6duf67FDN2OJXL-b| z7=~Dl?6*bkgZWzNyRXYK9@HB8BJN#gB#VY*7TP^g2l<$|6!^A4Yu553_j5OKHwO65 zK=hprJ4gJskM`nh56)s%#b{~QQ^>oDwd<^UaoGUob#TjSv`42fz9`zSxP7)VI&-1( zd*|+gH)T{kbbbka-gdZ3pm~LL5zA$d`zyXJFu~LPk@}(RJs(-RZj5IfZ1%saM%|g| z(d!*GS-5BanuLJUR8Er~%?}FzoUC^_RcWcost5kg@Ho!fNwG39UmxJomyVN2Cobtlr0`J^6&z_#82hsKl#;#>~Q)=54$v> z3{no1o5|Y6+e*kR6Z9LWoj+5q)cU(w1!ZA3L#O1`cq7q~Pkz1>wEZ*F{6Ua9RQeINOesRR?cw8dzb@w>`9O9eehAe*(pF|Fba}p9bZSDy_b_-wH?miF&EwfoRUQx7f;A6_VE^S6zM?8ud~}H=f(T?!b=p7T4ExRE+DL_s$MXibk1lOYo&h zc3tLVXXDOCNVbh(XFItE``U~gSL_T`@fjHlbyuz#Qz5xIGbU8IGaC^UzKWGKa&I(c z$=y!$-LW_!Xr1Rg+)azV8gED6R`m6?I@r)sLRIl z-S9?EKCaprQgCB^>oLC|!;5dOQ&Fy8n+w5AlMsP{0L^#cwt`?4>ctspK2dbYrS};b6sf;3Ka{S8%mRAOlg2ys#{jM$x~#mMWZ?VtH zqDYQKvnu-IVeQlY#Aw0t+wd)Y(mDj%{SmtOyqCTQ5gyIJCdTv>e8on&0BT+F9MSe8m_)p=~uwT z#`_=+fOmu_`R5;JxntYEi661-Qnn(mXs-1+-+8%I`Ctw>{UKBUq9-#c;tmLsMG?Q= zte2bG{?#KsA?@xKTxm2#vQrmcC?j47hs0d_Bv&63pE*^6{7%7Xjc4C+$Bou*`j&g_!l|Klu zpCaiy$PYvB-#o=Vb3YA&Kv2iZ(7Bd{(>B=c|nhZt@vJTpBuL$s_}++wxMCyZ7d5JPTbiBFV$K z(iZ=As|zW*2$Pd#rO0wf^s@dS#yLVHL3f8S;YwZo{It{q-D^)TO7%B&^5yn=*5z>udMqp}b!6?_q7ply+bIWeVc%vxOR z*m(Q{IcWgW)0%$-;*}t-a9^B;l+VTZymm|OW<~GZ371Vac|w_4MthPnV&CL8 zV|MGh^UtD=9M;!Q_`+4UD4TM^ZLV7y+PtZC{6op5yI1u{Vb|RVZT9B<8tQ??NpWxx zu~2POidURhidGBg595$}@c7pbC=Vv)iWfN*S-bGrzX5<1KbMz|SWLTsD2sJ-_Zi8# ztH;@Cd~o|N+#4XLck0m1A?E-mkzOSI#Lg|28aG8|OGpS%m=FNNFAt1aC}&U3BTeN> zMQ*YN-AT#;3SYzo$364cfRBU)TeDaHw=#)>pqwInWP(Wcco zq)(rgqT5|$;?kyv=s%9gP?I4zbOxzjJ4`Y=}{f%ZG!TxsPri zN&~2Wl3bo-ZoVHYGun3KJbX_}EwFY5lyNlrRF&K7ZzCMXB9(jRfBYl6d}!&vSl`Nt z1AAm$#ilo4;@v;C)W~yPT!j$@Y7UsScJ*}RW_;UMTgy}UbG`zFR-FEWo#Uwg4V*fP>ZU6nTBFFFA@%plA!J;1s6P1ki+ z(d+!`%a%9#=0H5mA)dOG{P|u%y`Y3Z9dqL%@}p?Oq9D6ST`hitT;tB?3Dke=$nX?S ztsvOfqh0EnB0}Pic%Q#siwFfJWvr_AT2|KVD}c3!pgm~PVau&+eOFm$6Rj)=B%AWZ z7Oru;PnTVFKc+-Ane0&S*>={LdH-W(Dzg{Np0L%nxA`Rm+rEGY>!{%1pCc!X6Jg-K zCV$S3JN;jPmuH}C-TxPOs2ot$fYhzQ;Me!v!C@-DiRy0CYsUY|Hy`ur*8)JWkCA_t zG}Yherdus+5;cKHrsmEh^a-_Enwfz(dyve(^IlhdyXIO|w~K4nd=gTzg=o7# zrlu^ssM-~G&q2iP!w37M7}_mthKX89EWTyIQnsFx_B0KeQCdu3ce0beE7v2Rlj<7Q zZn7&ktmcSXInPtVmekI;*a=@RK18klmM5=s$U2c7&X>@QJ+O_iJ1}d3V4>pjZ=Nw5 zv1xp4F@AApE#=!~y4%FJr>U4Q|ornh|>I#WB0dMF6OMdfmkky%Y;+Bzb@3 zKxw}h5#$7!RDzOA4$b~G+HJ9i81MjpUqluGC!??&%-;XlWin&YJ`U(p9w%Kywag1!@Ue}T1kPFi-ePnkBfWj<_^RFK`4}p98(?UeR)MHp( z_W>F|mHeTm#4o^KaF=$XxTvNkxt5(lR_#O%2Q-a=zh8ekmOR%S($g5x!mp}sk><7X zPa`p0`W-3nod;MAgAy5uXfjA1m2VKIvYW7_*+M6bTjb{#<)G!r-kF233Pm%mD<>tz zP9| zpcE=B5G@sJH}780LUXDrY3!ln2N{0Cl#~iyiy*~T<*kTdBr+2Bq-UV9bv#L*D2>~l zCQ9qF1&}xf#Dx%Oo2x6)nvAiFr2JxqqGxm_CCb;2TE$sa-pFNq{`03*%ZyYCDKb*% z`NZkBY#PfpZE|^(w%y(RR4gZj%&=KL+a{aTdEB*DeFo9LQX-|bDGIgSIk{d03k6tV zKtVwY%&eLa+?$T{!;)~6_ zz98kt5DREKDje}x1A-;ze&@^S$MdDO>uKXeBP|Ky6-t=b=9wy#;3p8fe52LP3?gjw>2q+1c7DRpHoF&gh zyZAiJDQX=~q{aS{v6+^8an);p0KgF_d*_`275>X&T4(Sy< zLAi763RDCA(W-+*eVj|%k9_UXlLY<*!F#UEe|`n*NHwlk%Bz}BhWoeloK|>Lx<$4L z9XK5I!jXN2!kmZhx(Em#Bd};DYFoN)Q*EQm-jwXg$%qNF7}=o;J-X<_#^WJegBVSZ zM@YW553xhH1WAI#xg@fxOOWr|A09?aa9|dFWF$6<34nTYWod!3%cPA+bKFs;c^x4j zDR}PrtFCV#rdy_nB^up6HYc4D46IK{>xWt(<;SK|27nX0$*5Mh2!=9`fYZ@&)!%7f ze~iF$U=5Y0=$Jj%PpD!N2sV^>#ASfU6#7PMjFVs{!Q-{Xd&-Mj0i@ko&u_VZ6EaD@jwB z){z@2FQLN0MLDSYCZ~_a()k?pd6QVSZ3{(KB?F7q&6z=1)d zF9T3HE_n)$8icV(7UzGr|M;Usr*zyaxBUN4qb|6Di_$0w?xS=FHmZ<~%ZVsE-5Fz} zR4j!d0A-G--Cd&P4(FYZ8GoFdHQ)$l@y>W^^6xS1LpcD(i}f~Z!U!l_8Kgd8eJS~Q!j3B-0CAlYg@DJWS$^cL z$KAe!yZ3kOm;~_O7jNVz(HccpM4Wa$+rsNh00=%HDF0mT%L0HqC;;>xEd`vxNxX>z zB3tvYJ8}`@*9DjTav7XtN3y*>ZY2Qs2eyX{s5T;@{g^C0r3x&pfYJ9;m!AJfY}w%_UPG3;8gB#p8MGne9gzzHCK4|-~ z#3)HUxEeC3Zm(*p>lV$RXgP@H?5w5xHn#Rl0r_UruRxcFJ&@26g&O*?{Jc@^8%_EB zt_>#J7>6L`(%UuTnm`h~yRhrL5%I8qxk@M-oHsDV54s<82pt+a@#;PRYt1#FR3g=% zkidD{z5iDaoh5eD&X}EXD2{)h5ObkIhHOrrthwt1hogpG_j$v1iXp3F$f$i~0Ew7DUSHH_7 zwA_o0$XmK=qL_b@b?IH|a-Aje5EZdla1OMxTh5Jg>>+Lt6hW2Yr zU;u_UWULKmTp^~03)m=-1Oc>t-sA2nQ#C?;SKc!^72+<6yZb2$PalkMH+sLtqgGqD z8C#2&vAkP3!!OE&y2%<3?UrvPqzvYeWw}|~1mMbA`W4&cACO0DpoY?cIhj>km zZ9!Sr|H^}VFN&-Rv#FCbtCEdj#5|(kvC7JToe!ZE54yj)7OS^Z;v5zsZ|I%5qL&1m zu>C(5w&&xC9T-TU%?*`BsUip-7)6}}4#q;8U~tjUpv2^B4DK|ztr9amfIkox z38Sfosa4-zOmp2GlHo#p%ww$-?|MP@8Eh}ad0nkdjZJw8BA~bR~&0<2fCxugCJ zPB2H{yegh)x%HoUIv-WfPv-_(Fx$~45^`lefTZh{{9&Y>!*?zkHSbkBnPkIAqUm7_lL|Vf|3ilCC(p7nC;%_%val zBI?%FHj2{}FLD}!03=0DBcj-Rttu;TZv$L;7?kH;S+yJT2k_Utec)z9^DcOwimd&V z5_;hrm_@@@IlVhd0U@}%MQ5eex`7T_T->McAi^dOCpX+CFE7u%eGaV7ih~WPS&T@E zV9#9-Y7Pln)9aEeVCT$n1mEN4qW1CypAh=5)(utfX8~1Rpxt?*J36Y&#TQ3WgztZ~xIfnd3=*Ix&6|XeE)sXw71EAG3SWbKz>DDcG0Cv;p8_C8klbdArICJBt3m` zFAM-6Ex+dnh(>6fnj^hnMNu;x+C1WOfNVbZ@c*C(dwwF0$fNYgG-76gtO$Z1Ru77} zzNwKQH4LXlJ3BvtFce~9eGgTWVQ^P5tNZp zTf17lZYI;aj=pfJHD^aN*7FVt|m^MTPr7P;0?ujY;G{>H-8z z(@$+Bpk7PV_%^V2i{-dV5SeHxYMBMGSJWr~)+kD~y6}>?jywCT3Tbmzjg69-+6YE2 zMnNY ziXX1Th7_0p2;%O|&f6<Gc8G9m5H;XOI1M6>uU=Yz>5c;D`iG;t#d`b?DheUb;pMyTUv}T7X$#T)!eRP z+XRrsD0Zk{LKQp+=SY*txZ_X zkuwU~_}1K16R8r^s&db}MGQp&qa9^e{sDn2Fk#loC z&?OW?(}53ZNQDZCuj{fA#SIb=>H{x3(Dx=3lcMEMTpSwSX|nGao0E^ zJxD0ykTggDfQ5i8kfWKC1aMTCsEEuQB7qFT8NSu0vC?n{YOI4gRV+Sd%z-Yq0RMsX znY$JnVq2>ns$0daEl$0?iFhwhGcAYE>sUdpM2Nxe)eT)xcBAHFjvWH^hxPS?<{1dw zNP!0UV4RdE?(TAb+l`&2MmEu)VU?yW8m}n*h|Z@mF>%Vq$XOmr2o)64-#KupZE)~g zDf`xa5T(d`vDB=Bi=X9?P{}{=Vg~CI8k(A(m~^ko$VkupT9(0q2uR!rMaMfN1aU}%sx9xkVj*J@nI!PV#YdCS2Q}u< zf$B6bF&qjXoQe6WEZIheFx7 z#MqSTNBSwx5P>qvrbGrt04$A&(ikq_3Kt)UgW1QioDsQoo1Mff8j!DKFpLrk1kum& z-wz#cSce*tw~J5(SOmdHgaCb12mnZ|31kC63MP3Shp4eBt}bYh@{yQ81QIkZFUssA zedkoahir6kYkt*6ua3Hd4R5}&{=dgo85-GgbrKqj*^U86`IiS{Uo-*{TuiC(o~L<5 z1H-ufE;1>nCA0ubY7vDQ;93>{2ndWsj@;YA&*R@In{CrW;s!VvmJ~J|Y9qc!Fn9z4 z3MK?VC6AnKn(8%V;Yz!k9_UVr76KzmgfI-3KoC|ejG~w5sk!0~z#ITf6Qis7cqAG@ zFd&LjVNfB+4+?|_HW(OoGMTX|pb+N>$Yv!}LI{&fYWRGZdI3@?uwV1PS7=;R^dxQL z`a=i`?JsvRnyNXt>zk`YGjmp8aZwKdfKrm@^j;2VE(?z_0Ei+LbM@8h?xQ=3EJ-L5 zJKZc1upgp;`#2VjqUlN@%m^R=BJaIB3_HjG0Q*dwskZ2UZScj*J9nn71T-`Zy6b(Q zYNg;oL_*LDcaJs?j~Ea4HV>lOy4`WEQIRMkD1DJ2T`Bo65>AWoEhs1`S#OFaX31aQ(=j-@TCT%RZ3QGaAv0>De9P0s%RN(>P_3 zqFAo>jN_gVx|Ov4+80uj;ux>hrHNO|SHLR<*UVI2r4z6DdAl|TBWXZG3K}4aBm~<1 z86n_>C7nwyFm^*46`+Azs+dX&n+sEnB(Y1ZxGhzAv&hD;iHVCj<%?+Il|u0~H!M#v zKQaJgvLSGFi)Mie?yYwEMnm_Y)U~OADBY(25LKUnh^%J@I)V_fA_+pS3(_VLt@LAh zIv8qoy~NT5NW_;}FeFTd;$A}CT$T=DX{6W-2~^S`HsU(5#aPj7L(K%k{538lO5?($ zZ+7ly%doHre?7)euC47RIj3&RqkkhcFH$eoF!Yk_}fh4*SW`3VVi69_^N%w7I{&I2I z4{QJ`!|s`!9LAH!eKs`^J(*d0vSVx;oAQjiyx>_VY?$&|Gk8YVkTJp;w6 zM-x1fb6z9%YaGwZXH1IS6IY0Y^gs7N_Upu)!%g5VuiVG%pjpl_tT^P}d}a|byLua+u_TUdES)iI6lDpzLWmm6UVhgQK3P;N5GYfO$--xVEl2|+0wMrs%u?cC++$MbVN;!BM5_);b5RLMzA&AFPA zNg7N5PHZs52*i>rnv$iO#uiP<78c85tdr`@#deI3WRgbt^JErf28e+VD4>7pdn7zV zp#cO0X#lm!_8?VbY~)uPH6tYHM>cBE63HnRHj>m`WpU=?opPvdYXXc3#36*bNp`yT zSuDhkoXDY;p3DXS6&9EL0*P}8LeC<+lZXst>e%36?m32t9obx%Pgg<}%C@MK_e#)y zM<%4$i3euK4XTK_P`~ObkQPG2zbY>o7(g7KQCBG(7B4^ljuYD2lQ30{7qwkKVz`S$ z>7e~X%?A+->Mvt;HAdeYLWhn~biFfZVk007hh!$7=)|v1&^?0G6u=Nz6Zz|LbC=BI zL{}059i?6P_ii|<)!=?C`&AWb%)!gBS9?Kd*lyOuM=3AfJ6{|0 z_O}LpvI#X)eIp1attxz}IT`=YEpXiZxyJJM)?L;R#8I#D6P9@$QtLh%cyRjs%ng^( z!EO?5qd#`T9VD_bZvm^FXk2pp^~<9jD7FgjCtB|^)7TEuvqfdKqO_B7oAQ|9x^a(}c`h+&UjHKs5x&2)tmZ@ytTo|EJ<&x$E&03nJh|Gpe|(ZC z+)pk(AcUDj%J5aKdjtOd(`PnwBu#~!ow0G~>J@FV@1h{@E#TUk0$}bVKocIhspnsZ zS4=*wKjURI+qTZQWyBm<+h{8>`k77g8?hyTq)U(!UldU-~SyQ00p@j zi5;&1!pl!@PY>KQz^7DBYocCHC(dHyg?J&bjhE9BuLiX*5`TBV^xFN2tFY-Ajsu_ zO|!pEXJE);XK85ez{=UTDkMKg>Lfp_l86+ki=`IIg^8rj4`)3+_Zq+q`hE9a#3)}Aa;ywB*_Z$fUBcaDQ31wvN~rTf)~`sihe%DZ{YT8evDBoP*O?4R%5$4_{iH z+<%so+jpB`N??laZWZqo?>^GqaILFr*Kq2?Y7ha_oMm3Ww!H?C1MF-uk&WymU~EPb z!C^EuV;S4)KWA-`O!fLC1ky}$RG;|!Vb>iHpoa!KJ3=}-tUCsGw-1E04;*WKIq@wO zz>lfI!7;pu0{{R53~?f0J~(feZh#KkO-Il7{C8O0DY;?CAKz#cBxh zewbZdTt+mbn-qZCpH9y6Kb3YN>-Zjj0c<>z!>y3ut$t7pt>O91tS~n{P~2%{lLcYv zl1(HQ*z)s1$H(-Ny;)f~+3{_G9+*_tUROpHIew{j$GxYZ92^+&boX3ON%L}?y5(Bd zj-3q#Ze-0p#Bbi<(B=cjehdvBzYHQEBJpvId6){6Wf|0w!pJnEE#2ubn!{y7kx!mo zY=avqhcq=fdrPfX{c}IE>4CIuq)I?uK(nbC|W< zS-Udhw#09_6PRFK04q^!hI2*n6+csCIDq>8kIX;}b=nm#A6?)~EyjWrP*w|FRhL?) zX^J(2MI<+7n@CNpYD!WvqX?g1+}GE8otJZuWHZ-8)vZ*eJ8s}+2CSQxsC`q5?NHS@ zd=Q$0y)CZdFp!5}Y)JVZLioscz|PGU(f%#Y`)vCx8P{7Nx6n5d z^C>ZO8O>Gpt9Gig`XxMe4vMB9T*mIZ|6MkeTb9uq>cd}2JW~fPi>T5Jfm4_F-`^oaHP;YlSb_R3IhgW z7v4R!ktj%%?kl&5JldDbrHQ88$#!*E zxcSm^*@Yt?0n0^S)uL?5wV0+hPfJa5)yDkX3k}4LZGq;N1@!196J33UhCWrCT!utf z!b8(y&eSS69?`U9s^jd$v!{&Z#Yc0=dgXeDX0@`BpuFQp_ntK~3&sgC(JMErCoLZ8 zurA_9ZvOM+Z5sK3O{^Ee*R>uq(i9^Tsw=OBgd4G#MIY}jMrr&z6+E1TXjJDM6tFn* z=BLM*Ed*$#G;vpo9q3ODtW0O027SoSkAO< zVezhM4drzJlJi(Us_tI?=qhDa zT4YDfU%TP$Z#aWyxc{kLmxy}NhN=5*RP50?fSF~T7(V*fnm!Q+4Z_rgOn6=b=!V}& z(7-81t~rVeN8W1oxYrfU=Uo-I#CobH$WI-8mn0bbc z6kJmZeXsRKJ@oGmrpr*XnLAc7wi`XiW**nEGs5Asw09o5q;Bn%#%H;eG}ISIC3}W8 zAJ7=$?Nap(@(gqqaH-{IgG27vJXh<(wHjV2&wa4v_QKAH5OOj(r__=)A-atw$ioh= z+h~7OF*7|CyDdehy=>S()t@UJD^QdlIex9N*{VLf0I=dMN^J*r?gp|KXGuAtEYWcv z{g{^##Zb|)u7j}`ZW}617U?$;p7UTV`K=IpQx3uoPzaDp^c1q1a5`%)A_(KG zIHFL&y;o5*+?*0*u5B>U;_lAzS)&44FYCVL{^QMm1%lsyp1e5}&~~}37Njm+G3`hE zkkfP)j-$lpv-@!W`%OtQO%U|S4Z?YIAR|qrXPG)3Qto=RtF;{xzaelwdhyT_AT_PC z{7-O=`FjEm#79N8)NXJlDdA-XMJna9bc{Mh&NU2|f? z>}UN!07x2b9!(a5ji5L_rglwb#TX1GTzm#LQ<}Om41RjQXv+Dl=^7u817dR~00JI8 z+l*|(HmfJ9JRW8I%^w(Rr@3F5#hj!v9R5Up|?O!~8SF12X z>l8^<<_fwTG{iWd;i>UxIQtv{9@Ats?j12CoLfW9?EWste~#IImu z4{H(Lv83j&Tir_+AzX^n`*E_-4F_yUC6honKitI!D1YY+aSX$M(VnNA1cyELXsDH? zuHkWYQMg@BdY!70s)i-c*@(TqJDzX_NrUv7s<}fBjkJ7=l<4!|-No#GbWZ>K9k9NY zTkw+#GS|3_i9# zlVWs_9NqN}z20J{#iFNMg3D;E$8khhVNlNav!9|Po)B_0DeRMJU&^Q(;^LW@tudEk z%47c-rp;@+Da$vla^wgqq&Y*~dk+q&KaBO}lm1`bXkX@0*cp^T%MHW!#Z7{hq zo}HqVWuec@h|mA9q$@TByEMczMbi_ijd4FPW-(rh(koPs z_a!s34L9_Re_BRiUFE0gOv5P(KwE0;x46DqSo ztMITmeDny%FgN2DWt+O_lMlb8N#n`_{Wb2^+o(rf;ZP{#26kKVEYo8Qe+AGmITW5= z-#ywgezHKvQ~O|TxX8Q6o~M0lVsPPGpWlRDchPqAV<+y_-*0f|lSNf4(W=_aSC9K! z0pmk1U*LFEiuIqbqOUk69V)fdPQ*P%&^q+sXwKy;$o7#v zgxE7jKJ|rKJ|PJwAsR_6MW$6xO}Sm4jozaemtj#?1&K4ZCt4g`qPp-Itqt?*Y`sZ5 z@9^Ite4S-+o?O*+TjR3u<2@_Jc-JaA5|_{Pjxdkn{aE)4hzK4pV4>%dLT~rTY?JM+ zlmAszI=?vf&%Y*5Uq3-vtN7mi%8(_$P(AXk4>Q82*j|8vseK!JH^4=e*q-3^+~C?{ znfA-fBG1vstv=D{h*r^(Zqokm$&=x@@ua z5rC<=pn$DL3#JlNzCm!$O0xucK5%(?Y|YOfFhW>T9ff#KY1#0rpAmb|G}+TdS29sE zavEV1F*Ta2_gr(_YwG5I5qTs=5`6x_PQ6vu&rkxPJSTzQ8cF>jer23<&BW|W+?`OhF>W+olm4tSr#*W<`4>+{EG@y8j|M|Vz zn~|<#m{p&^HHVlkD?_oY3Zpk&)bI9~Eav$&E?rYscXn6`yA?m4oIz zJpDZw=~me$S-^}p7xrFmy;VNL=*Z3&-{TH^qJtxk1-Jkwi(k11Y%g0;MQuuJw2Q## z*_B}R___LTchzGiYht6?W4U`azq?N@iY?o-JJxl&#XQHQO3_@K3NB93;Y4x-r(a)D zySv89UFBH$>LqdvFaON(ovPIASuNd}!ki2vl_ zq01nW)f>s-V=OF3G#n~zX!9UqfIfMgM@fQr5;~&~i%uW$b!|$_ynS;M=6Czv>Tw59 z_55R~E+d;ilR`(m140!Bd1AO@vUE(E<8-`Cj}U$E6dKVo;BH1nm5&GBb-6K_w7vL& zc1pe3n#a#ik4`qAX1e364kT=tgR&}%Gpw0hPW-uNue{Ca$kW5t1Oy;3X2@(Rir1CA zc%g@pcwj$HUw8AQE>5fvmEK$=n^PHN4Dg zOGYLt>T%{gKONst&Q$RYwu;K(AOJux@KpEWZp(IUKah*CUCAFwAHVjtyk%w7<(Az% z3uUB$&u$N^`NHAV`#0^{y7132RGvw8rkYHse|W?1C-ZF*kRwK(yzjZy;c-Fvgf}+k z;=(TAP#QxsSesB>4Alx5N}~;^Y_0^so@+ypbWBQelJH)h8AS+^k!HUdUEg3om@ah& zt_}~6Ob??MSi&?zQf#$9B-vjxN|LfQ#7jG&&1+4vr^h8n5bh)Mchz}#OnEq_Xv=n) zI<`1+ePyz4$Nnw-c6(iuU8D3K+3jp=X8JjqW6Movuas?s?Y+?rPA=Y+p0Q8@+<&_W zgnUltf7g6zZAIWutyf+6JpVbQu%K<5M^>D9{u=Q9=nVr|j&>Kmx!J`>bWN&463D{u z%Rn8yZu9E;(C-+K{QAlx(6}%+b|O8-(&Inu-det9brg649FR0v7w^zWsAGYiFF=Ea zx!972Ydwr;ZCK+!<8W2$3{hsX0k+pU_>@5S1&zOowa>RU0<=w0Tm z?PG+`?+*d#?STz}wVu?OMZl%0_`kWg-1#?u=V~}UG^X=jED8%mXa83D=&Q>wIrX{z z(yoWw0xdE$PHFNz>hG-eZiBKaso6)4%rQnfw_Vb2T1DhG`?;x&j$GMoqbV-@PpJIgHo90SEvA;_)#5tv`c z!V-Z?vyTb*gHvC~_GSu+FBn{3kw_aZ`ad5yw+#1$#q-)q?dB$Gtl=8fGdznCWmu+9oKc!y+w~aZT2)dnng4JnDV4ic*X`S9 zI`}#qByoY@F^c1Tf+(pOAg@gL{IBjxwri_?%Q~|@Wb46$lg{&dQT?w|6aC8a$SZ1| z25Z~M6$36*Y~eEB#qH^<5DN>?%T^hw7^rc)h7z}V;SDZ>#Aqn`}|c7U3<4Ha-n-GQZaPcKon-Jv{rs!0lJ@#57^M7kCbc% zzV=hF7p{KC9|7L(;&z%PalSG)Hqtw`s#B~5Cq#(5mLx=L!4k7$?qkP9F7Q07p^4f)V zq?UjBm$2^ztemrlo-J7mS4z_Iy7E%R@rAeXi1Ts+aOA>CsPR^zuVM5UyPISB>IQ(= z^nQlg2kap2eY`s&^;Ruqlk?MaV891i-KQBKBhQ4~p!;X2>IVQQyUkF_xts<#LW1$G z>qt%HO__`j6KWB)adqzyuz$7j9Yp%0nfo2r?og9MyRyU}bJ5{zG7fUDb$y5&>XNYRxSUwyEH z@a@}un@)eb))i-}5@Zeopy&3R-RQubi6+S6x3sz$29owB^*Xoev?<7j7Y{XL8dXxZm;p$BtmKe+ma}{KN&Y zOc=GWrV8*zKs(fkfZb(rV6C2fvRPs}8IKmw41k#hc|?vp{pj-CoFB)N(FAf4C*38! zHkJNmF;OiQCjPDj@`87}h$~rZ{x&;=bDv?k)Y4E2YNZ z@OU2qY%7fg4Q32_Lz)I~r+b@mlADdn6@`DH0_t{`!0`YS+jFfA*nZsV##Z*sv3UPH ze-DR{uoP9VST9+wqjq{#ig%GLTO=})VHwFa2)s<@jpBKFSo#vl7ERcCpq3_<7z+qc zI}jK&5HCLNOLP-!4tRh7Y?!J`%ax}SdOBwZ&nWtV-q7B}w$?rc?92O-4;cbTz;5(~ zkDD9}QDuM~TM7x#Jsgs)u{mSS%(BU2smI?K5fR~*<{O&(F%j97m&0Z1s-wyAV`qs+BU1=F ztE?O0CA3YIT9};NG_~%uk2Q`h?rt~5RU+kB;wU7Px5LlmjxGsj;gR3h;he-aKTTED z?DSI7=^j3zpRR{@Ha4!UgjV3~YQybr<=oL#CT+trg-1t?@)ZD<9Xtr0&zvGIJzfTY zWe2+<{Uz$3X9BxWU+T&DvkZDFBJv5nPsd;#(p)P$;bMcb%$3Z{mzmJ}=NrJnj$q=X zP%kKsFY)p6vH+FWQ@^cDY|!9Y)R)dGo+?gHHLIT4#Jyu^VJ#_f(>MS>+bO2`_&h-& zXi$pE*PjkWK_c))yL3_cSYnAQ==duuL3cOsdX@MVew936lI)$Y6KAqwVx;NHrqlag z(mE(oP)4__j3jgTg} zz!WV@>i%9m3RUp%K|ph&b06Ju?3Wt~`*J-P>hgKnIx2BflDo~pfMVSHCMf~MC`t5@ zXi_q{Mx-iMHAh4-n_aLxzXz$R0W(6kar3mKC8M%S*4J@eoi<}F%Neconu zL6nxR6oy#tPW`shw~;l>Bg}3rs&>GyQ95gJsXK+g?0QZIi zVUV5V3}fz^>N0*5%(^12{D4BJn%GXrJ3EVkM!g&Yzeegxw#r0UQJ1e8#hWo7ULGfT z&4Bf8MOUkqfdAmEpU4LBzBK*1Skw_FCX+2H6B1DMFV`C;h{U_9{m}6d;UlW=`V&g6 zVU@p;sWH)r6A#EHQF#G;((w_L%hMCp^db^BdhcEiqJ5){04iHuF(i`ctn^sm=sHxg zJ!w|Q+KY3<8|@{T;#x=m;Q{0>+XpwUKFC+_(KXlnn>dz*x5Rs2I5xDjH~w(5$|ka` zjeX_TdHR)8%!-CvAJY%|M&%y%;{}HGAJXx9!Sn7D3zIZ@{e!>#qjh$DB=Gfm-hTOU zz;_T85PL!H($$F9VQfFA(0sx&_<0Havs{YcSP%8Kw^%{RDXXF~wBraFNryU$YrFwdxnqNd>pWk1|MYOMV zAtLltSF99?*4~k;A|%Zkv%R=nyf!wYhwWoVPhDqFro(@}8Q_|ELrCrfHnf6qYOLS4 zK7W2-tk9;qJ(ndKR18Yfy>h*=9<>Xu>L^{oGl-aO2@HvI3M$nqhxP3`jjay@7|uStzmlj zo*1|2+L)O6TBjg3AOD5VuDj(d-uPM$!s(^7I`k3wbP-*e>$2&NQI#<&rjT6&jl{Py>X8(#q6SdpXObQoDCM1NQ5v{$LFHQ9x&Uk~@ zdbMB0(ni#Xy!A}}0qnb0u=f>SX+akR5{>E6H&(IxQ)2uQi`=Qs$wGGt%!ZGTvkYUB z?c!U`lpARREnq|=qsU(5GmsUXj73_}#5)ls3XXCA{W-`*B5k zX03-|2&2WMS{)hxzQE05c9K?V+!pHIcWt;cz2RPo_FR8qva!eAO{rUEYW8J#|)$Hc~_(st@#aTFcrr;rF{ zfZB~PMM9B!$+APT1jFVeUb!G5#=3&f@9Zu^_+(%)s6yx>Z&<1sk6d4u)aD&v=n@qbjmP+o zyt|l15Y2GoA3Mb=NP#Yrg50$#SU3(-^ zS?`U~GH?7F&k#4ZB}wxH?O&>;b^MAeLzMTVkq_^iK~$i^sKngdWS8!|`TT-NnPcIF zE46~+C{Gm2MNYZZ%cEc+xACja=UP?3BH`IrmMhL{U-dT}@h`;coq!YYd&9K+vm|oS zVfCo3K8+>M_Kqppx4z6UQZ+0c-6kXfh!)HLIEWA`pCMx#PDjI#gj&26;2FjF*F+0z zJu~&%_@ctI5Q4x}66fQ>u_cuQ#W$y_f(?P`Egj4^O8)~uvv$Jr5DJ@x?P$$|mq3k1 zM21v?2CQ-+Scx9f6n@_09~Io;Hc`6cRt9}i=`c7z(Y~RMY8LX={tPnsBgW94WuS7( z$l?llbyt*{#PC++%p`%~;CoZ@hlAH1b8GVF_Awg;O-3qD7TC|9JyKC9Xj-K>s`K84dO%mH?D_+se7_$ zb`Gk>5s;8=OOhJb#X<^#MuT?tw6F*YirxXka~Zz*QH7t2K3BgNVDa#S;Hi~#V+5&% z0c#UOTP0IKf>QqWKn+)6Ufxasz&QcBJZUBQrv>BINV|_XiDbrtgk&bOlS06t4YK6% z;TuFA9yD!OgSb2JdZxK0l$?F`qM9luGfD#n;Bv1@9I ztXozWsTaFNdUHd-lahE4Hft^lZz zOuuq%FwU^@kz7PKQ7D455*8E$&>(~gER&{mAT7%RWsage*N@%ErfE05$~Y)WB~T04 zK6^jTP9Sp)NKTVC(<#JgVh}3GHA(>iFgIN)Z-EoyMj=_Pwqbjfn4oginf}>Tq%_Fc zo_hwQ0Qaq!?U%1BjK(kq75U+WO9SE%o5w_P)U;OwiD)d&nk>Lnp?P073+n`MjsO4w zfB*tOl3J6N#bTN?B!)(Xa>?L{Z+MP0Uurw95zG$?fJMv-)U^O{-)S(Rz-6ZIL`>Mc z9uDX1X`qR?C?jgANy%^vym*F*qG8bCnF-bf#fVOsvgUI;gNF#S;qZx&^6Ix@dk|;i zATuTgAyb7YAW20TzEsm>lfA}w(`fNiLApE-*<@UA{oDP)i0CSkm>er6OSQl8@XaG2 zVHQYRD*1>F%0SOOQ(D_h%v$0dSkm9dNke2itW!YsQ~pWkEJ}Jd<#2f(79?QJI=Y?X z6w?bywG$aVp}B2WO}S|g2(NXgznFp3D;9+k5dl&mF`|i#wMI3{uE==cXkR%Pk(FvS zBfx2nh{O0iE9GOVTvaZ^jlk~q=HtAW>kBFZpKGE3Za)jg7REyE{3CnN!Kc@H_+?y9 zFWaf+pNV0IHv|89IeBeb%9Vco@yi-qv&y^L@m9rTinc* z(ND0qva*wU9p5-jagLm#O0f?E!P|nTEgBw)PUM&VL>?xcsfZ+x(z@jamOg=I?J zLYKzXthUegv|M(=jedwAgRS!^5z$Mx(hP1G1qQWy$BugIMWh30_(Qd-r|s;X-?<{3 zICS-sopATL>u`SV=h|0VK?JsZTmT_f-@@t+9-#HxC(@ZrKR18D3&Q2%)V7+4cA=PC zgXdrV1U|j?Y^>^Z-};{3$QEgSbyXOzn$K@R;;*T%4LpzCArBlNAe!KdPq|Uu_1l_n zyZh!%9NQxrDRw;fS!2-pO|J%@1Tzc{k;DzFuOBJ1Ch@a*c?X}8U0O4bm3&TQv!teiB@+aAdT%9IP{Sx}2II(SKY&??)%RI#NLl-O7(HwEdgGRX zJW69E&M*nrP;A{&RJ?yT8Jbvsn(z%$QS}v|sd~Q3`)%y}Ss`Oc2Yi%z=;^$%5T^1g zLm?jzSorm8g2j;Fa@HT3*glBXx|JxrfiS)GvA!EGA17ibryw*C5n46mH! z&K)+G#M%r! zkV0N0PDZX+bGm0NMc~WuV5)>U_2P<(VTW5UgnQGiq*itwsq>(3((X;`i?!9Z)%~rbv5}wM13h9`R!terWpcSe~UyCR2f(nKwNWYZfS4@}hzAIx-5DU%pSPFpT>@1^^2;wj2Dx z;^EhSm8!+@n{GA4!1w?z7`uuy>Nwwh=z#v#!=8gp8738qREN9(^|Ok5&n>b;{_+iw z_#`nF#cr=$%-ziGO9-5UgrmIu=UjE`kFNx7Yu3(AAZ=E)ZMd*@EPkm&MDcevZCbV< z`NZ5Mg*Cnx0=W?`oUy>hR9##CDMHx0iALrk6P{@qNuC5D19Z@bh|x9r84Q6H8W9PB z08ex}>3I>0mpjcyt4z-iQ2~Z3J{%Ythe*x!gZl%TE1hEMNSS)-X&Cj9Yg^2aLTZZj zSDIsr(+1-}^t~Yicb8!qO}EFkzW%dYwuq7tn;p;pOVhD0pFK;5daZhJ*}(IJM=ps( z71;HV0A{_>^I|#6M1Yn-idJxU6_*pM%DDXCoN$>Oj1268A(?5uwwlU9L6K0qLTMOc zo@Yn>`YG~iBssUHhE6ZB^1%_Y!gyU5_KDT#ZHWGYUP-(E>N40ULJ>xCYj+@Jj^WTT z(kf_yS+4$M)QZy{eefksck@*#-hyJC`P@wRyn6pD{3$VsUXt~?o}qyvXq2PKe~NjN zt`!tv`Zl*WuGz#VIMZv<_x4hvWJX$#M%(WmncO?498vqA0>+{lxAn|9Pou6RdZ@lW zT>lPr`D&}o{||zxLGFOT9N)@gEJp68)`-n{$eO{xmfIR+x}7dbWWeG2M&}w-lTnN6 z?Ha`6`&r7wsFXy1#BPv+5COFzE2$}q_{H>@s2MOtD zOhFpm?i#^$DS+O!ywyQRC1CqjHC8B-gx`rw%mV;ei+xJ_u9K@M6Fvr_Zn@S`E%Fw8 z73KGPr}j3eIq^zs^ww|P`Xg7fz0Z_-&8>d@#2OQ=zt2!w^X}eRZWwQFp@7UlT8R3S zDiY@C&|UnD4UXLY-0DJyp%qjWAD=HfCKH0DJQL3ntpmAYmeNc%A-NdZe&lZ(_X5-GioC?s;dO|4yUa1l3aKXGU+Mdz1g$h!Vut(BYT;DP= zi>NH#u^G;_{cgPVlY<21&Z3sFGsYf<1^@4s_4c<5*)4pb+J!@lzF%({Xt^veZACTw zB^fxKXS&%7g*Nr|v7?NlZ2OYr$YIhQG1Yj{|NHM}POX*$Hs?=nM;<37S7F!s=DYEY zh!$S|v!`hno_F(o&NX~U7qiUsHC8vd^$9Sd)-zbQ>ZjTp@-L@n z8L|akJ2$wH0HBm_Pcg~J`dn-9J+*_2r}l&JK&C+fcdpa$ipGpLUswWa92Z5gQRlV)E7Kp?HQAkUzJ zM*a5LP-*JxiM0t+Ezgv^9@v=6&!8@(D$^Nz^>q_2UVwtI11S)2jcwD&9JbM)pw5aW zTOXNULTnTz8TS5qe%vm1nLzaTh;NW=vqN**3Ip~T`sCjAj1#N-s<{5eFdtr1k zRi{Azok*g->^i#hVxMVr?0#`!+vL2(qwTbwo)a3>@f+$bAN}`uU5?4fQ7sS?`{1mR zc660V-hWBnti8MIc=50s=KsCQ9@KDX*kTfK3qD;)Z{vG>*@Hx0;FKH18LfTirvy%6 zFG*bK3u`vhrnMILa~*p+66XS>oM8WRYg)gy&|0O}55W4cp*#K7>!YlWdq1nR)i;wZ zKK+~l2}$+|g<~jHXpqN(ai&x@Qqfv?3Y|caXDVedEV+n&09j39TB(!iLL2eF6mm)8 z9UE5_6!mC6Lf(vRNn-Gbc@ebS&|)K*Gsa!#sq9YX`pgw-!eQHNN?ew^$7B#Qp6Sr1 zeEH9<2SduOG$6hojq|OY87{@jaKpApbP{Z`o8KdPz?*2w_saiKWnFiwa=#9U7X2F6 zs|Bw#wu@HN5vCIeaBi z=oO0|dvZ~)P~X$HRVX56uGPol!SS2Nl#WQU@XFNR_9Xm{hy{^}y!u)BSNG>er|jNC zo}&Y#2QL)v2`-w-bEX=$P$$4v%UBQ;4W`01bs0M_uGVD> zjJ_3Y){sbA3ceJu+>`1IHDxxqzOYxHpsSHeh@dr5;5MW<1D@BX-e3v-`PCuJv2ud7 zU@{6Z@mPFwc7UlUs;jr3vDr{dD2lWxBq@9%gf6yrCMY0!XbRFIcDCLGO2>MT%kL(`_YXB`Q7lNQKq_tSN6MF5FT&Qnh%K9wToR09oM;B; zuB1_?=H4(90F%q zcm7xBvi}qeg4I-tRn@khPza;Edyo>GTO03(tyuUK#nfED0HRPUr1~KcA=n8{>%jRH zda;L*!&`?QQmh`=3P;^GIzK(0t>D?ZZVH@>^21nu1N9_)#Sj`{lN?s~bh;d4;qf48-xKKrT$ zPp3S8glRlfYri9ZH{QlQw3F>5Bj5c_CO$=&S}D7M;9$3nvzb|4KT1(H!b#8|Sfi9U z?8x{*z@{QZ6jOY;*}+$@FHwk0L|bZkpJ33r=Sww)EBPO44L|B1oXv%`uSe~NHX?eW zku|U~Jo52-;*6cgdi#v+LDEo`_Wo0Xnq>cNg~|*UQ?WF-I@YMC+#!&YW&G<$lNZt# zT%1jt$8SAp#AF+n`s3<1`#I-C#EG;-;nhStvCV(A>-Tg5Th$Zww&1%Ic>bSx+=~j| z)v14Gw%mx(i>)uD*O3j@NgMZ{G~{xOOZ;)goBh|zWf@%Q`>IkOPr6ahQO6)g)+{Xs zzH-3MEwPAj>&tpz#t5_nzs+9~ItCi7Hb5o`e?q(%dIp%;g`E%f z>X{udR0%0io|>>jydy>7uYa$NHr*b;ipwojvK!~8dBQ|ox3c!iNURxvX~6lUe;4E; z;G+}Dv@TD8{~TY@R}n@N@G)v1fpCN9I|td`uU?mQHjV-X3v|eXe1?l{0MhucQvK%kX-IM#A&6* zOi#?1lQY4`Nt0>n!iV3dl7Hm3h*~d6FGZHqWrti6f1z=P!jzZvUf}qkkFXs-OoIWl z=)DaU?KNGgvU!0bk9L=!BLDbD!y?mMNswA-XLS2orM_*|S~pDolsrI^ikR`+)Gw2h zSmuVSXU>&At$p5Cn{&t-WX(n}x1#yQ_=Zv%V*T4iJ{$k*x%@!+HQ?PILC6Q~; znsELKmmpT>(?r!3__l>JcdXjdXq}2tYboDre4askq9Lv>$@!;>H30nh**RB1Yenmn z2Q$(B7~2=JIhcK9!Kv zI4n;aD)&H&x`p7xqss?Jw+WU6S?YUb2TCD4j+PdNwnW`-tLolS1nEbx`iSop&?N7| zhO4Kvd5Ri5|`e$Pcrl z|9}4iSjwsY2l(6d<*rRbmiTD9o*TCKQ~9=hQhCkA;M_ zpSxPig}+m0u)zI>v?fK*D9s%U`SGO@5 z>E2npdh1#K_sE^Us}H?cut$Nl{-^$_ZNv#WLf$F@`Em1gbuZBYuw_er;h=_U$qLj8 zcGQ?297lVnm7*<&l~q$JL9ny{#y?&|$#aj6=+?wfT?T$6-tI&Tu3(H~MCg z8}JwDCP|Xy_O%z8kMYQs+asEme^Q2QagNdPK!Tq(If@)IXY4R4`Q)8x zH(hbkMu{?>WvoW0kKvkrxL#>HwZG%xJP@_%cis60I-Dua1?1W&Or{K`)q5q$0)_AT zEDNmWl zSL`g)_PtxVHXZM5)1%qBn)@WXXMfxX;1a_**{ZM(oKS@$r(;T)5Z zmfb7wbwb#-In*Mwa3e6_b#T^TM3=K8Z2j8QSl$BC4e=HjwIFVRnr2b+?M@x?v)BUB1LQNLacSy!&p9l(Ub9N#g5BH(A`bY#SQ3h*Qta-k(X^ z-wkbJ?eS}fxZ4oARBNnk=1yL}E3`cY8PPaSOn}GJ7>i&cVr?U^+IPcc^y2CauLYtn zvdwQ`z69W zeeu~V|M&<#pcMU;z`(-6Q=v+YIsyn02^ob3O;lQQ|JO#Bo>lbG&@mWbGGv6smySd4h%Tqq*HqI>T_B@f9HA!4H9{e-Er4~d+uBG$m1Y@^IlI=e&Vbm`5Ekm;{V!Z literal 0 HcmV?d00001 diff --git a/nui/fonts/ibm-plex-mono-400.woff2 b/nui/fonts/ibm-plex-mono-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0804aaff92260b5359330f63e05b9ac25ccabeee GIT binary patch literal 14708 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 literal 0 HcmV?d00001 diff --git a/nui/index.html b/nui/index.html index 50e075c..64c2f2c 100644 --- a/nui/index.html +++ b/nui/index.html @@ -3,12 +3,14 @@ FivePRS + +