diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 462068e..0c0e063 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -50,24 +50,38 @@ Open `FivePRS.sln` and build with Visual Studio, or run: ``` dotnet build FivePRS.sln -c Release +dotnet test FivePRS.Tests/FivePRS.Tests.csproj -c Release --no-build ``` -Compiled outputs land in `bin/client/` and `bin/server/` as configured in each project file. +Database tests use a temporary SQLite file. To run the civilian store tests against MySQL instead, set `FIVEPRS_TEST_MYSQL` to a connection string for an empty test database, for example a throwaway container: + +``` +docker run -d --name fiveprs-mysql-test -e MYSQL_ROOT_PASSWORD=fiveprs -e MYSQL_DATABASE=fiveprs -p 33306:3306 mysql:8.4 +FIVEPRS_TEST_MYSQL="Server=127.0.0.1;Port=33306;Database=fiveprs;User=root;Password=fiveprs;" dotnet test FivePRS.Tests/FivePRS.Tests.csproj -c Release +``` + +Compiled outputs land in `bin/client/` and `bin/server/`. **Testing locally** -Copy the relevant DLLs from `bin/` to your FiveM server's `resources/FivePRS/` directory and restart the resource. There is no automated test suite yet; manual in-server testing is the current validation path. +Run `publish.ps1` from the repository root. It builds the core and writes `dist/fiveprs/` and `dist/fiveprs.zip`, ready to copy into your FiveM server's `resources/` directory. Restart the resource after copying. --- ## Project Structure +| Path | Purpose | +|---|---| +| `FivePRS.*/`, `config/`, `nui/`, `fxmanifest.lua` | Everything that ships in the `fiveprs` resource | +| `publish.ps1` | Builds the core and packages it into `dist/` | + | Project | Purpose | |---|---| | `FivePRS.Core` | Shared models, events, interfaces, and config POCOs. No FiveM API dependency. | | `FivePRS.Client` | Client-side scripting: agency management, callout dispatch, vehicle spawning, loadouts. | | `FivePRS.Server` | Server-side scripting: player data, XP, database abstraction. | | `FivePRS.Police` | Police department implementation: callouts, vehicles, loadouts. | +| `FivePRS.Tests` | xUnit tests for config, territories and client assembly compatibility with FiveM's runtime. | Callout packs and plugin extensions are built as separate class libraries and dropped into the `callouts/` or `plugins/` folders at runtime. They reference `FivePRS.Core` and `FivePRS.Client` but are never compiled into the core solution. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e97a1b7..efd1599 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,11 @@ on: jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, windows-latest ] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9a9c278..4516107 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,14 +1,3 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL Advanced" on: @@ -22,20 +11,10 @@ on: jobs: analyze: name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} permissions: - # required for all workflows security-events: write - - # required to fetch internal or private CodeQL packs packages: read - - # only required for workflows in private repositories actions: read contents: read @@ -45,43 +24,16 @@ jobs: include: - language: csharp build-mode: none - # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository uses: actions/checkout@v4 - # Add any setup steps before running the `github/codeql-action/init` action. - # This includes steps like installing compilers or runtimes (`actions/setup-node` - # or others). This is typically only required for manual builds. - # - name: Setup runtime (example) - # uses: actions/setup-example@v1 - - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - name: Run manual build steps if: matrix.build-mode == 'manual' shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..702939d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Release + +on: + push: + tags: [ "v*" ] + +permissions: + contents: write + +jobs: + release: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Check version + shell: pwsh + run: | + $tag = "${{ github.ref_name }}".TrimStart("v") + $manifest = Select-String -Path fxmanifest.lua -Pattern "^version '(.+)'" | ForEach-Object { $_.Matches[0].Groups[1].Value } + if ($manifest -ne $tag) { throw "Tag v$tag does not match fxmanifest.lua version $manifest." } + "VERSION=$tag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Build + shell: pwsh + run: ./publish.ps1 -Configuration Release + + - name: Test + run: dotnet test FivePRS.Tests/FivePRS.Tests.csproj -c Release --no-build + + - name: Release notes + shell: pwsh + run: | + $lines = Get-Content CHANGELOG.md + $start = [Array]::FindIndex($lines, [Predicate[string]]{ param($l) $l.StartsWith("## [$env:VERSION]") }) + if ($start -lt 0) { throw "CHANGELOG.md has no section for $env:VERSION." } + $notes = $lines[($start + 1)..($lines.Length - 1)] | ForEach-Object -Begin { $done = $false } -Process { + if ($done -or $_.StartsWith("## [") -or $_ -match "^\[.+\]: ") { $done = $true } else { $_ } + } + ($notes -join "`n").Trim() | Out-File -FilePath release-notes.md -Encoding utf8 + + - name: Publish release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $flags = @() + if ($env:VERSION.Contains("-")) { $flags += "--prerelease" } + gh release view "${{ github.ref_name }}" *> $null + if ($LASTEXITCODE -eq 0) { + gh release upload "${{ github.ref_name }}" dist/fiveprs.zip --clobber + } else { + gh release create "${{ github.ref_name }}" dist/fiveprs.zip --title "FivePRS $env:VERSION" --notes-file release-notes.md @flags + } diff --git a/.gitignore b/.gitignore index 079e145..44204e2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ packages/ *.log *.tmp + +TESTING.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0ba95e1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to FivePRS are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and FivePRS follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Each release on [GitHub](https://github.com/FivePRS/core/releases) is tagged `v` and uses its section below as the release notes. + +## [Unreleased] + +## [0.9.0-ptb.1] - 2026-10-10 + +The first public test build. + +### Added +- Police, fire and EMS departments with agencies, ranks, XP and callsigns, all defined in config +- Jurisdictions and territories, so calls and units are matched to the right agency +- Server-side dispatch with AI callouts, player 911 calls and a callout API for DLL callout packs +- Records: people, vehicles, warrants, citations and arrests +- Civilian characters with licenses and registered vehicles +- Character creator for freemode characters, saved per character +- The FivePRS terminal: a tablet with a home screen, apps and wallpapers, including server defaults and custom wallpaper URLs that servers can turn off +- Terminal apps from DLLs in `apps/` or from other resources through exports, with a page SDK for HTML apps +- Settings app, including silencing AI callouts while on duty +- Admin app with the department roster +- Stations on the map with going on duty at a station or in the field, and teleporting to a station +- Armory, locker room and garage points at stations, with uniforms, loadouts and stock police vehicles +- `fprs_coords` for copying coordinates, headings and station entries +- Players return to their last location with their last character when they rejoin +- Update checks against GitHub releases, admin notifications and `fiveprs_update` to download and unpack a release +- Server icon loading from a URL +- Notifications with the FivePRS logo and sender +- Plugin loading from `plugins/` and callout packs from `callouts/` +- Audit log of admin and record actions +- SQLite out of the box, and MySQL support +- Versioned database migrations that update the database on start, with an automatic backup first on SQLite +- Windows and Linux server support +- Per-player rate limits for dispatch, 911 calls, records, civilian actions and admin requests, configurable under `rateLimits` +- Validation of callout catalogs sent by clients, with XP rewards capped at `fiveprs_max_xp` + +### Known issues +- Fire and EMS can be joined, but have no callouts or department tools yet; police is the playable department +- Only Mission Row has armory, locker room and garage points +- Back up your database before updating between test builds + +[Unreleased]: https://github.com/FivePRS/core/compare/v0.9.0-ptb.1...HEAD +[0.9.0-ptb.1]: https://github.com/FivePRS/core/releases/tag/v0.9.0-ptb.1 diff --git a/FivePRS.Client/Agency/BaseAgency.cs b/FivePRS.Client/Agency/BaseAgency.cs index 888a0d7..19b9428 100644 --- a/FivePRS.Client/Agency/BaseAgency.cs +++ b/FivePRS.Client/Agency/BaseAgency.cs @@ -3,6 +3,8 @@ using System.Threading.Tasks; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Client.Stations; +using FivePRS.Client.VehicleSpawner; using FivePRS.Core.Config; using FivePRS.Core.Events; using FivePRS.Core.Interfaces; @@ -20,6 +22,12 @@ public abstract class BaseAgency : BaseScript, IAgency public bool IsActive { get; private set; } + public static BaseAgency? Active { get; private set; } + + public PatrolVehicleSpawner Vehicles { get; } = new(); + + public virtual StationCatalog? BuildCatalog(PlayerData player) => null; + protected static PlayerData CurrentPlayer => ClientBrain.LocalPlayerData; protected string DisplayName => @@ -56,6 +64,8 @@ private async void OnLocalDutyChanged(bool isOnDuty, int departmentId) if (shouldBeActive == IsActive) return; IsActive = shouldBeActive; + if (shouldBeActive) Active = this; + else if (Active == this) Active = null; try { if (shouldBeActive) @@ -90,6 +100,7 @@ private void OnResourceStop(string resourceName) if (resourceName != API.GetCurrentResourceName() || !IsActive) return; IsActive = false; + if (Active == this) Active = null; try { _ = OffDuty(CurrentPlayer); } catch (Exception ex) { Debug.WriteLine($"[{AgencyName}] Cleanup on stop failed: {ex.Message}"); } } diff --git a/FivePRS.Client/App/AdminPanel.cs b/FivePRS.Client/App/AdminPanel.cs new file mode 100644 index 0000000..548ccbf --- /dev/null +++ b/FivePRS.Client/App/AdminPanel.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using Newtonsoft.Json; + +namespace FivePRS.Client.App +{ + internal static class AdminPanel + { + private static AdminState? _state; + + public static bool IsAvailable => _state is not null; + + public static void SetState(string json) => _state = JsonConvert.DeserializeObject(json); + + public static void Request() => ClientEvents.TriggerServer(EventNames.ServerAdminRequest, _state?.Query ?? string.Empty); + + public static void Search(IDictionary data) => + ClientEvents.TriggerServer(EventNames.ServerAdminRequest, NuiData.GetString(data, "query")); + + public static void SetRoster(IDictionary data) + { + if (!NuiData.TryGetString(data, "license", out var license) || + !NuiData.TryGetInt(data, "departmentId", out var departmentId)) + { + return; + } + + ClientEvents.TriggerServer(EventNames.ServerAdminRosterSet, license, departmentId, + NuiData.GetBool(data, "granted"), _state?.Query ?? string.Empty); + } + + public static object? BuildView() + { + if (_state is null) return null; + + return new + { + State = _state, + Departments = Enum.GetValues(typeof(Department)) + .Cast() + .Where(d => d != Department.None) + .Select(d => new { Id = (int)d, Name = d.ToString() }) + .ToList(), + }; + } + } +} diff --git a/FivePRS.Client/App/AppClient.cs b/FivePRS.Client/App/AppClient.cs new file mode 100644 index 0000000..9f2adc5 --- /dev/null +++ b/FivePRS.Client/App/AppClient.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.Appearance; +using FivePRS.Client.Callouts; +using FivePRS.Client.Dispatch; +using FivePRS.Client.Terminal; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using FivePRS.Core.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace FivePRS.Client.App +{ + public class AppClient : BaseScript + { + private static readonly JsonSerializerSettings JsonSettings = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + }; + + private bool _open; + + public AppClient() + { + EventHandlers[EventNames.ClientReceivePlayerData] += new Action(OnPlayerData); + EventHandlers[EventNames.ClientEntryOptions] += new Action(OnEntryOptions); + EventHandlers[EventNames.ClientEntryRejected] += new Action(OnRejected); + EventHandlers[EventNames.ClientCivilianState] += new Action(OnCivilianState); + EventHandlers[EventNames.ClientCivilianError] += new Action(OnCivilianError); + EventHandlers[EventNames.ClientLookupResult] += new Action(OnLookupResult); + EventHandlers[EventNames.ClientLookupError] += new Action(OnLookupError); + EventHandlers[EventNames.ClientEmergencyStatus] += new Action(OnEmergencyStatus); + EventHandlers[EventNames.ClientEmergencyError] += new Action(OnEmergencyError); + EventHandlers[EventNames.ClientAdminState] += new Action(OnAdminState); + EventHandlers[EventNames.LocalDutyChanged] += new Action(OnDutyChanged); + EventHandlers[EventNames.LocalOpenMenu] += new Action(OnOpenRequested); + EventHandlers[EventNames.ClientPreferences] += new Action(OnPreferences); + EventHandlers["onClientResourceStop"] += new Action(OnResourceStop); + + DispatchClient.SnapshotUpdated += Refresh; + TerminalApps.Changed += Refresh; + CalloutDispatcher.StateChanged += Refresh; + + 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)); + } + + private void OnPlayerData(string json) + { + try + { + var profile = JsonConvert.DeserializeObject(json); + if (profile is null) return; + DutyPanel.SetProfile(profile); + } + catch (Exception ex) + { + Debug.WriteLine($"[AppClient] Failed to parse player data: {ex.Message}"); + return; + } + + Refresh(); + } + + private void OnEntryOptions(string json) + { + try + { + DutyPanel.SetAllowed(JsonConvert.DeserializeObject>(json) ?? new List()); + } + catch (Exception ex) + { + Debug.WriteLine($"[AppClient] Failed to parse entry options: {ex.Message}"); + return; + } + + Refresh(); + } + + private void OnRejected(string message) + { + if (_open) + Send("rejected", message); + else + ClientBrain.ShowNotification($"~r~{message}"); + } + + private void OnCivilianState(string json) + { + var before = CivilianPanel.CharacterCount; + try + { + CivilianPanel.SetState(json); + } + catch (Exception ex) + { + Debug.WriteLine($"[AppClient] Failed to parse civilian state: {ex.Message}"); + return; + } + + if (_open && before is not null && CivilianPanel.CharacterCount > before) + { + OpenCreator(); + return; + } + + Refresh(); + } + + private void OnCivilianError(string message) + { + if (_open) + Send("civilianError", message); + else + ClientBrain.ShowNotification($"~r~{message}"); + } + + private void OnLookupResult(string json) + { + try + { + RecordsPanel.SetResult(json); + } + catch (Exception ex) + { + Debug.WriteLine($"[AppClient] Failed to parse lookup result: {ex.Message}"); + return; + } + + Refresh(); + } + + private void OnLookupError(string message) + { + if (_open) + Send("recordsError", message); + else + ClientBrain.ShowNotification($"~r~{message}"); + } + + private void OnEmergencyStatus(string json) + { + try + { + EmergencyPanel.SetStatus(json); + } + catch (Exception ex) + { + Debug.WriteLine($"[AppClient] Failed to parse 911 status: {ex.Message}"); + return; + } + + Refresh(); + } + + private void OnEmergencyError(string message) + { + if (_open) + Send("emergencyError", message); + else + ClientBrain.ShowNotification($"~r~{message}"); + } + + private void OnAdminState(string json) + { + try + { + AdminPanel.SetState(json); + } + catch (Exception ex) + { + Debug.WriteLine($"[AppClient] Failed to parse admin state: {ex.Message}"); + return; + } + + Refresh(); + } + + private void OnAppOpen(IDictionary data) + { + TerminalApps.SetOpen(NuiData.GetString(data, "id")); + Refresh(); + } + + private void OnAppAction(IDictionary data) + { + var payload = data.TryGetValue("data", out var raw) && raw is IDictionary values + ? values + : new Dictionary(); + + TerminalApps.Act(NuiData.GetString(data, "app"), NuiData.GetString(data, "action"), payload); + Refresh(); + } + + private void OnPreferences(string wallpaper) + { + TerminalPanel.SetWallpaper(wallpaper); + Refresh(); + } + + private void OpenCreator() + { + var active = CivilianPanel.ActiveCharacter; + if (active is null || ClientBrain.LocalPlayerData.IsOnDuty) return; + + Close(); + CharacterCreator.Open(active.Id, active.FullName); + } + + private void OnRegisterVehicle() + { + var error = CivilianPanel.RegisterVehicle(); + if (error is not null) + Send("civilianError", error); + } + + private void OnDutyChanged(bool isOnDuty, int departmentId) + { + DutyPanel.SetOnDuty(isOnDuty); + if (!isOnDuty) RecordsPanel.Clear(); + Close(); + } + + private void OnDutyEnter(IDictionary data) + { + var error = DutyPanel.Enter(data); + if (error is not null) + Send("rejected", error); + } + + private void Toggle() + { + if (_open) + { + Close(); + return; + } + + OpenWhenLoaded(null); + } + + private void OnOpenRequested(string tab) + { + if (!_open) OpenWhenLoaded(tab); + } + + private void OpenWhenLoaded(string? tab) + { + if (!DutyPanel.IsLoaded) + { + ClientBrain.ShowNotification("~r~Your profile hasn't loaded yet. Please wait."); + return; + } + + Open(tab); + } + + private void Open(string? tab) + { + if (!_open) + Tick += HoldFocusAsync; + + _open = true; + NuiFocus.Take(); + Send("open", BuildState(tab)); + CivilianPanel.RequestState(); + EmergencyPanel.RequestStatus(); + AdminPanel.Request(); + } + + private void Close() + { + if (!_open) return; + + _open = false; + Tick -= HoldFocusAsync; + NuiFocus.Release(); + Send("close", null); + } + + private void Refresh() + { + if (_open) + Send("update", BuildState(null)); + } + + private async Task HoldFocusAsync() + { + if (_open) + NuiFocus.EnsureTaken(); + + await Delay(250); + } + + private void OnResourceStop(string resourceName) + { + if (resourceName == API.GetCurrentResourceName() && _open) + NuiFocus.Release(); + } + + private static object BuildState(string? requestedTab) + { + 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, + }; + } + + private static void Send(string type, object? payload) => + API.SendNuiMessage(JsonConvert.SerializeObject(new { screen = "app", type, payload }, JsonSettings)); + + private void RegisterCallback(string name, Action> handler) + { + API.RegisterNuiCallbackType(name); + EventHandlers[$"__cfx_nui:{name}"] += new Action, CallbackDelegate>((data, callback) => + { + try + { + handler(data); + } + catch (Exception ex) + { + Debug.WriteLine($"[AppClient] NUI callback '{name}' failed: {ex.Message}"); + } + + callback("ok"); + }); + } + } +} diff --git a/FivePRS.Client/App/CivilianPanel.cs b/FivePRS.Client/App/CivilianPanel.cs new file mode 100644 index 0000000..320ccae --- /dev/null +++ b/FivePRS.Client/App/CivilianPanel.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Civilian; +using FivePRS.Core.Events; +using Newtonsoft.Json; + +namespace FivePRS.Client.App +{ + internal sealed class DrivenVehicle + { + public string Plate { get; } + public string Model { get; } + + public DrivenVehicle(string plate, string model) + { + Plate = plate; + Model = model; + } + } + + internal static class CivilianPanel + { + private static CivilianState? _state; + + public static void SetState(string json) => _state = JsonConvert.DeserializeObject(json); + + public static int? CharacterCount => _state?.Characters.Count; + + public static CharacterInfo? ActiveCharacter => + _state?.Characters.FirstOrDefault(c => c.Id == _state.ActiveCharacterId); + + public static void RequestState() => ClientEvents.TriggerServer(EventNames.ServerCivilianRequest); + + public static void CreateCharacter(IDictionary data) => + ClientEvents.TriggerServer(EventNames.ServerCharacterCreate, + NuiData.GetString(data, "firstName"), NuiData.GetString(data, "lastName"), + NuiData.GetString(data, "dateOfBirth"), NuiData.GetString(data, "gender")); + + public static void SelectCharacter(IDictionary data) + { + if (NuiData.TryGetInt(data, "characterId", out var id)) + ClientEvents.TriggerServer(EventNames.ServerCharacterSelect, id); + } + + public static void DeleteCharacter(IDictionary data) + { + if (NuiData.TryGetInt(data, "characterId", out var id)) + ClientEvents.TriggerServer(EventNames.ServerCharacterDelete, id); + } + + public static void ApplyLicense(IDictionary data) => + ClientEvents.TriggerServer(EventNames.ServerLicenseApply, NuiData.GetString(data, "type")); + + public static string? RegisterVehicle() + { + var vehicle = CurrentVehicle(); + if (vehicle is null) return "Get in the driver's seat of the vehicle you want to register."; + + ClientEvents.TriggerServer(EventNames.ServerVehicleRegister, vehicle.Model); + return null; + } + + public static void RemoveVehicle(IDictionary data) + { + if (NuiData.TryGetInt(data, "vehicleId", out var id)) + ClientEvents.TriggerServer(EventNames.ServerVehicleRemove, id); + } + + public static void SetVehicleStolen(IDictionary data) + { + if (!NuiData.TryGetInt(data, "vehicleId", out var id)) return; + var stolen = NuiData.GetBool(data, "stolen"); + ClientEvents.TriggerServer(EventNames.ServerVehicleSetStolen, id, stolen); + } + + public static object BuildView() + { + var vehicle = CurrentVehicle(); + return new + { + State = _state, + OnDuty = ClientBrain.LocalPlayerData.IsOnDuty, + Genders = CivilianRules.Genders, + MaxNameLength = CivilianRules.MaxNameLength, + CurrentVehicle = vehicle, + }; + } + + private static DrivenVehicle? CurrentVehicle() + { + var ped = Game.PlayerPed; + var vehicle = API.GetVehiclePedIsIn(ped.Handle, false); + if (vehicle == 0 || API.GetPedInVehicleSeat(vehicle, -1) != ped.Handle) return null; + + var displayName = API.GetDisplayNameFromVehicleModel((uint)API.GetEntityModel(vehicle)); + var label = API.GetLabelText(displayName); + var model = string.IsNullOrEmpty(label) || label == "NULL" ? displayName : label; + + return new DrivenVehicle(API.GetVehicleNumberPlateText(vehicle).Trim(), model); + } + } +} diff --git a/FivePRS.Client/App/DispatchPanel.cs b/FivePRS.Client/App/DispatchPanel.cs new file mode 100644 index 0000000..d90a942 --- /dev/null +++ b/FivePRS.Client/App/DispatchPanel.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CitizenFX.Core.Native; +using FivePRS.Client.Callouts; +using FivePRS.Client.Dispatch; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; + +namespace FivePRS.Client.App +{ + internal static class DispatchPanel + { + public static bool IsAvailable => DispatchClient.LocalUnit is not null; + + public static void SetStatus(IDictionary data) + { + if (!NuiData.TryGetString(data, "status", out var value) || + !Enum.TryParse(value, true, out var status) || + (status != UnitStatus.Available && status != UnitStatus.Busy)) + { + return; + } + + ClientEvents.TriggerServer(EventNames.ServerSetUnitStatus, (int)status); + } + + public static void Attach(IDictionary data) + { + if (NuiData.TryGetString(data, "callId", out var callId)) + ClientEvents.TriggerServer(EventNames.ServerAttachToCall, callId); + } + + public static void Waypoint(IDictionary data) + { + if (!NuiData.TryGetString(data, "callId", out var callId)) return; + + var call = DispatchClient.Snapshot.Calls.FirstOrDefault(c => c.Id == callId); + if (call is null || (call.X == 0f && call.Y == 0f)) return; + + API.SetNewWaypoint(call.X, call.Y); + ClientBrain.ShowNotification($"~b~Waypoint set~w~ to call ~y~#{call.Id}~w~."); + } + + public static object? BuildView() + { + var self = DispatchClient.LocalUnit; + if (self is null) return null; + + var snapshot = DispatchClient.Snapshot; + var map = ConfigManager.Territories; + var offer = CalloutDispatcher.PendingOffer; + var active = CalloutDispatcher.ActiveCall; + + return new + { + offer = offer is null ? null : new + { + offer.Id, + offer.Name, + Code = (int)offer.Priority, + offer.Description, + ExpiresInMs = Math.Max(0, CalloutDispatcher.PendingOfferExpiresAt - API.GetGameTimer()), + WindowMs = ConfigManager.Settings.AcceptWindowSeconds * 1000, + }, + activeCall = active is null ? null : new + { + active.Id, + active.Name, + }, + self = new + { + self.ServerId, + self.Callsign, + self.Name, + Department = self.Department.ToString(), + Agency = map.FindAgency(self.Agency)?.Name ?? self.Department.ToString(), + Status = self.Status.ToString(), + self.CallId, + Territory = map.FindTerritory(self.Territory)?.Name, + self.AiCallouts, + }, + calls = snapshot.Calls.Select(call => new + { + call.Id, + call.Name, + Code = (int)call.Priority, + Department = call.Department.ToString(), + Territory = map.FindTerritory(call.Territory)?.Name, + HasLocation = call.X != 0f || call.Y != 0f, + Units = call.Units + .Select(id => snapshot.Units.FirstOrDefault(u => u.ServerId == id)?.Callsign) + .Where(callsign => callsign is not null) + .ToList(), + IsPrimary = call.PrimaryUnit == self.ServerId, + call.IsEmergency, + call.Description, + call.Caller, + CanClear = call.IsEmergency && self.CallId == call.Id, + }), + units = snapshot.Units + .OrderBy(u => u.Callsign, StringComparer.OrdinalIgnoreCase) + .Select(unit => new + { + unit.ServerId, + unit.Callsign, + unit.Name, + Agency = map.FindAgency(unit.Agency)?.Name ?? unit.Department.ToString(), + Status = unit.Status.ToString(), + unit.CallId, + Territory = map.FindTerritory(unit.Territory)?.Name, + }), + }; + } + } +} diff --git a/FivePRS.Client/App/DutyPanel.cs b/FivePRS.Client/App/DutyPanel.cs new file mode 100644 index 0000000..c308f53 --- /dev/null +++ b/FivePRS.Client/App/DutyPanel.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FivePRS.Client.Agency; +using FivePRS.Client.Dispatch; +using FivePRS.Client.Stations; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; + +namespace FivePRS.Client.App +{ + internal static class DutyPanel + { + private static PlayerData? _profile; + private static List? _allowed; + + public static bool IsLoaded => _profile is not null && _allowed is not null; + + public static bool HasDepartment => _profile is not null && _profile.Department != Department.None; + + public static Department Department => _profile?.Department ?? Department.None; + + public static void SetProfile(PlayerData profile) => _profile = profile; + + public static void SetAllowed(IEnumerable departmentIds) => + _allowed = departmentIds + .Where(id => Enum.IsDefined(typeof(Department), id)) + .Select(id => (Department)id) + .ToList(); + + public static void SetOnDuty(bool isOnDuty) + { + if (_profile is not null) + _profile.IsOnDuty = isOnDuty; + } + + public static string? Enter(IDictionary data) + { + if (!data.TryGetValue("departmentId", out var rawDepartment) || + !int.TryParse(rawDepartment?.ToString(), out var departmentId)) + { + return "Choose a department first."; + } + + var agencyId = data.TryGetValue("agencyId", out var rawAgency) ? rawAgency?.ToString() ?? string.Empty : string.Empty; + var rawCallsign = data.TryGetValue("callsign", out var callsignValue) ? callsignValue?.ToString() : null; + + if (!Callsign.TryNormalize(rawCallsign, out var callsign)) + return $"Callsigns can be up to {Callsign.MaxLength} letters, numbers or hyphens."; + + var error = SetStart(data, (Department)departmentId, agencyId); + if (error is not null) return error; + + ClientEvents.TriggerServer(EventNames.ServerEnterService, departmentId, agencyId, callsign); + return null; + } + + private static string? SetStart(IDictionary data, Department department, string agencyId) + { + var rawMode = NuiData.GetString(data, "start"); + if (!Enum.TryParse(rawMode, true, out var mode) || mode == DutyStartMode.Here) + { + StationService.SetPendingStart(DutyStartMode.Here, null); + return null; + } + + var station = ConfigManager.Stations.Find(NuiData.GetString(data, "stationId")); + if (station is null || !station.Serves(department, agencyId)) + return "Choose a station first."; + + StationService.SetPendingStart(mode, station.Id); + return null; + } + + public static void GoOffDuty() + { + if (_profile?.IsOnDuty == true) + ClientEvents.TriggerServer(EventNames.ServerToggleDuty); + } + + public static object BuildView() + { + var profile = _profile ?? new PlayerData(); + var map = ConfigManager.Territories; + var unit = DispatchClient.LocalUnit; + + var departments = (_allowed ?? new List()) + .Where(d => d != Department.None && BaseAgency.IsDepartmentLoaded(d)) + .Select(d => new + { + Id = (int)d, + Name = d.ToString(), + Agencies = map.AgenciesFor(d).Select(a => new { a.Id, a.Name, a.CallsignPrefix }).ToList(), + Stations = ConfigManager.Stations.For(d, null) + .Select(s => new { s.Id, s.Name, s.Agencies, Distance = (int)StationService.DistanceTo(s) }) + .OrderBy(s => s.Distance) + .ToList(), + }) + .ToList(); + + return new + { + profile.Name, + profile.Rank, + profile.XP, + XpToNext = profile.XPToNextRank, + profile.IsOnDuty, + DepartmentId = (int)profile.Department, + Department = profile.Department.ToString(), + AgencyId = profile.Agency, + profile.Callsign, + CallsignMaxLength = Callsign.MaxLength, + Departments = departments, + Unit = unit is null ? null : new + { + unit.Callsign, + Agency = map.FindAgency(unit.Agency)?.Name ?? unit.Department.ToString(), + }, + }; + } + } +} diff --git a/FivePRS.Client/App/EmergencyPanel.cs b/FivePRS.Client/App/EmergencyPanel.cs new file mode 100644 index 0000000..e14f2b0 --- /dev/null +++ b/FivePRS.Client/App/EmergencyPanel.cs @@ -0,0 +1,50 @@ +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/NuiData.cs b/FivePRS.Client/App/NuiData.cs new file mode 100644 index 0000000..3feef3a --- /dev/null +++ b/FivePRS.Client/App/NuiData.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace FivePRS.Client.App +{ + internal static class NuiData + { + public static string GetString(IDictionary data, string key) => + data.TryGetValue(key, out var raw) ? raw?.ToString() ?? string.Empty : string.Empty; + + public static bool TryGetString(IDictionary data, string key, out string value) + { + value = GetString(data, key); + return value.Length > 0; + } + + public static bool GetBool(IDictionary data, string key) => + data.TryGetValue(key, out var raw) && raw is bool value && value; + + public static bool TryGetInt(IDictionary data, string key, out int value) + { + value = 0; + if (!data.TryGetValue(key, out var raw) || raw is null) return false; + + try + { + value = Convert.ToInt32(raw); + return true; + } + catch (Exception ex) when (ex is FormatException || ex is InvalidCastException || ex is OverflowException) + { + return false; + } + } + } +} diff --git a/FivePRS.Client/App/RecordsPanel.cs b/FivePRS.Client/App/RecordsPanel.cs new file mode 100644 index 0000000..0704e3c --- /dev/null +++ b/FivePRS.Client/App/RecordsPanel.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using Newtonsoft.Json; + +namespace FivePRS.Client.App +{ + internal static class RecordsPanel + { + private static List? _results; + private static string? _query; + private static LookupRecord? _record; + + public static bool IsAvailable => DispatchPanel.IsAvailable && DutyPanel.Department == Department.Police; + + public static void SetResult(string json) + { + var result = JsonConvert.DeserializeObject(json); + if (result is null) return; + + if (result.Results is not null) + { + _results = result.Results; + _query = result.Query; + _record = null; + } + + if (result.Record is not null) + _record = result.Record; + } + + public static void Clear() + { + _results = null; + _query = null; + _record = null; + } + + public static void CloseRecord() => _record = null; + + public static void SearchName(IDictionary data) => + ClientEvents.TriggerServer(EventNames.ServerLookupName, NuiData.GetString(data, "term")); + + public static void SearchPlate(IDictionary data) => + ClientEvents.TriggerServer(EventNames.ServerLookupPlate, NuiData.GetString(data, "plate")); + + public static void Open(IDictionary data) + { + if (NuiData.TryGetInt(data, "characterId", out var id)) + ClientEvents.TriggerServer(EventNames.ServerLookupCharacter, id); + } + + public static void Issue(IDictionary data) + { + if (!NuiData.TryGetInt(data, "characterId", out var id) || !NuiData.TryGetInt(data, "type", out var type)) return; + NuiData.TryGetInt(data, "fine", out var fine); + ClientEvents.TriggerServer(EventNames.ServerRecordIssue, id, type, NuiData.GetString(data, "description"), fine); + } + + public static void Resolve(IDictionary data) + { + if (!NuiData.TryGetInt(data, "recordId", out var id)) return; + var served = NuiData.GetBool(data, "served"); + ClientEvents.TriggerServer(EventNames.ServerRecordResolve, id, served); + } + + public static void SetLicense(IDictionary data) + { + if (!NuiData.TryGetInt(data, "characterId", out var id) || !NuiData.TryGetInt(data, "status", out var status)) return; + ClientEvents.TriggerServer(EventNames.ServerLicenseSetStatus, id, NuiData.GetString(data, "type"), status); + } + + public static void FlagVehicle(IDictionary data) + { + if (!NuiData.TryGetInt(data, "vehicleId", out var id)) return; + var stolen = NuiData.GetBool(data, "stolen"); + ClientEvents.TriggerServer(EventNames.ServerVehicleFlag, id, stolen); + } + + public static object? BuildView() + { + if (!IsAvailable) return null; + + return new + { + Results = _results, + Query = _query, + Record = _record, + MaxFine = ConfigManager.Settings.MaxFine, + MaxDescription = CivilianRules.MaxDescriptionLength, + }; + } + } +} diff --git a/FivePRS.Client/App/TerminalPanel.cs b/FivePRS.Client/App/TerminalPanel.cs new file mode 100644 index 0000000..da1a425 --- /dev/null +++ b/FivePRS.Client/App/TerminalPanel.cs @@ -0,0 +1,20 @@ +using FivePRS.Core.Config; +using FivePRS.Core.Events; + +namespace FivePRS.Client.App +{ + 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 void ChooseWallpaper(string value) => + ClientEvents.TriggerServer(EventNames.ServerSetWallpaper, value); + + public static object BuildView() => new + { + Wallpaper = ConfigManager.Settings.Terminal.ResolveWallpaperUrl(Wallpaper), + }; + } +} diff --git a/FivePRS.Client/Appearance/AppearanceManager.cs b/FivePRS.Client/Appearance/AppearanceManager.cs new file mode 100644 index 0000000..cd478dd --- /dev/null +++ b/FivePRS.Client/Appearance/AppearanceManager.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.Loadout; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using Newtonsoft.Json; + +namespace FivePRS.Client.Appearance +{ + public class AppearanceManager : BaseScript + { + private static string _currentJson = string.Empty; + + public static CharacterAppearance? Current { get; private set; } + + public static int CharacterId { get; private set; } + + public AppearanceManager() + { + EventHandlers[EventNames.ClientAppearance] += new Action(OnAppearance); + EventHandlers["playerSpawned"] += new Action(OnSpawned); + } + + public static IEnumerable AllowedStandardModels => ConfigManager.Settings.Creator.AllowedStandardModels; + + private static async void OnAppearance(int characterId, string json) + { + CharacterId = characterId; + if (json == _currentJson) return; + + _currentJson = json; + Current = json.Length == 0 + ? null + : AppearanceRules.Sanitize(JsonConvert.DeserializeObject(json), AllowedStandardModels); + + if (Current is not null && !LoadoutManager.IsInUniform) + await ApplyAsync(Current); + } + + private static async void OnSpawned() + { + if (Current is not null && !LoadoutManager.IsInUniform) + 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); + var ped = Game.PlayerPed.Handle; + + if (appearance.IsFreemode) + { + var blend = appearance.HeadBlend; + API.SetPedHeadBlendData(ped, blend.ShapeFirst, blend.ShapeSecond, 0, blend.SkinFirst, blend.SkinSecond, 0, + blend.ShapeMix, blend.SkinMix, 0f, false); + + for (var i = 0; i < appearance.FaceFeatures.Count; i++) + API.SetPedFaceFeature(ped, i, appearance.FaceFeatures[i]); + + foreach (var overlay in appearance.Overlays) + ApplyOverlay(ped, overlay); + + API.SetPedEyeColor(ped, appearance.EyeColor); + } + + foreach (var item in appearance.Components) + { + if (API.IsPedComponentVariationValid(ped, item.Slot, item.Drawable, item.Texture)) + API.SetPedComponentVariation(ped, item.Slot, item.Drawable, item.Texture, 0); + } + + foreach (var prop in appearance.Props) + { + if (prop.Drawable < 0) + API.ClearPedProp(ped, prop.Slot); + else + API.SetPedPropIndex(ped, prop.Slot, prop.Drawable, Math.Max(0, prop.Texture), true); + } + + if (appearance.IsFreemode) + API.SetPedHairColor(ped, appearance.HairColor, appearance.HairHighlight); + } + + public static void ApplyOverlay(int ped, HeadOverlay overlay) + { + API.SetPedHeadOverlay(ped, overlay.Index, overlay.Style < 0 ? 255 : overlay.Style, overlay.Opacity); + + var colorType = OverlayColorType(overlay.Index); + if (colorType != 0) + API.SetPedHeadOverlayColor(ped, overlay.Index, colorType, overlay.Color, overlay.SecondColor); + } + + public static int OverlayColorType(int index) => index switch + { + 1 or 2 or 10 => 1, + 5 or 8 => 2, + _ => 0, + }; + + public static async Task SetModelAsync(string modelName) + { + var hash = (uint)API.GetHashKey(modelName); + if (API.GetEntityModel(Game.PlayerPed.Handle) == (int)hash) return true; + + var model = new Model((int)hash); + if (!model.IsValid || !model.IsPed) return false; + + var changed = await Game.Player.ChangeModel(model); + model.MarkAsNoLongerNeeded(); + if (changed) API.SetPedDefaultComponentVariation(Game.PlayerPed.Handle); + return changed; + } + + public static CharacterAppearance Capture() + { + var ped = Game.PlayerPed.Handle; + var model = API.GetEntityModel(ped); + var name = new[] { AppearanceRules.MaleFreemode, AppearanceRules.FemaleFreemode } + .Concat(AllowedStandardModels) + .FirstOrDefault(m => API.GetHashKey(m) == model); + + var appearance = new CharacterAppearance { Model = name ?? AppearanceRules.MaleFreemode }; + if (name is null) return appearance; + + appearance.FaceFeatures = Enumerable.Repeat(0f, AppearanceRules.FaceFeatureCount).ToList(); + appearance.Components = Enumerable.Range(0, AppearanceRules.ComponentCount) + .Select(slot => new ClothingItem + { + Slot = slot, + Drawable = API.GetPedDrawableVariation(ped, slot), + Texture = API.GetPedTextureVariation(ped, slot), + }) + .ToList(); + appearance.Props = AppearanceRules.PropSlots + .Select(slot => new ClothingItem + { + Slot = slot, + Drawable = API.GetPedPropIndex(ped, slot), + Texture = API.GetPedPropTextureIndex(ped, slot), + }) + .ToList(); + appearance.HairColor = Math.Max(0, API.GetPedHairColor(ped)); + appearance.HairHighlight = Math.Max(0, API.GetPedHairHighlightColor(ped)); + return appearance; + } + } +} diff --git a/FivePRS.Client/Appearance/CharacterCreator.cs b/FivePRS.Client/Appearance/CharacterCreator.cs new file mode 100644 index 0000000..2786da4 --- /dev/null +++ b/FivePRS.Client/Appearance/CharacterCreator.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.App; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace FivePRS.Client.Appearance +{ + public class CharacterCreator : BaseScript + { + private const int CameraBlendMs = 500; + + private static readonly JsonSerializerSettings JsonSettings = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + }; + + private static readonly string[] ParentNames = + { + "Benjamin", "Daniel", "Joshua", "Noah", "Andrew", "Juan", "Alex", "Isaac", "Evan", "Ethan", "Vincent", + "Angel", "Diego", "Adrian", "Gabriel", "Michael", "Santiago", "Kevin", "Louis", "Samuel", "Anthony", + "Hannah", "Audrey", "Jasmine", "Giselle", "Amelia", "Isabella", "Zoe", "Ava", "Camila", "Violet", + "Sophia", "Evelyn", "Nicole", "Ashley", "Grace", "Brianna", "Natalie", "Olivia", "Elizabeth", "Charlotte", + "Emma", "Claude", "Niko", "John", "Misty", + }; + + private static readonly string[] FaceFeatureNames = + { + "Nose width", "Nose height", "Nose length", "Nose bridge", "Nose tip", "Nose bridge shift", + "Brow height", "Brow depth", "Cheekbone height", "Cheekbone width", "Cheek width", "Eye opening", + "Lip thickness", "Jaw width", "Jaw length", "Chin height", "Chin length", "Chin width", "Chin cleft", + "Neck thickness", + }; + + private static readonly string[] OverlayNames = + { + "Blemishes", "Facial hair", "Eyebrows", "Ageing", "Makeup", "Blush", "Complexion", "Sun damage", + "Lipstick", "Freckles", "Chest hair", "Body blemishes", "More body blemishes", + }; + + private static readonly string[] ComponentNames = + { + "Face", "Mask", "Hair", "Arms", "Legs", "Bag", "Shoes", "Accessories", "Undershirt", "Body armour", "Decals", "Top", + }; + + private static readonly Dictionary PropNames = new() + { + [0] = "Hat", [1] = "Glasses", [2] = "Earrings", [6] = "Watch", [7] = "Bracelet", + }; + + private static bool _open; + private static bool _busy; + private static string? _pendingJson; + private static int _characterId; + private static string _characterName = string.Empty; + private static CharacterAppearance _working = new(); + private static CharacterAppearance? _original; + private static int _originalModel; + private static List _originalComponents = new(); + private static List _originalProps = new(); + private static int _camera; + private static Vector3 _anchor; + private static Vector3 _forward; + private static Vector3 _right; + private static float _heading; + private static string _focus = "body"; + private static CharacterCreator? _instance; + + public static bool IsOpen => _open; + + public CharacterCreator() + { + _instance = this; + + RegisterCallback("creatorUpdate", data => _ = UpdateAsync(data)); + RegisterCallback("creatorCamera", data => SetFocus(NuiData.GetString(data, "focus"))); + RegisterCallback("creatorRotate", data => Rotate(data)); + RegisterCallback("creatorSave", data => _ = SaveAsync()); + RegisterCallback("creatorCancel", data => _ = CancelAsync()); + + EventHandlers["onClientResourceStop"] += new Action(name => + { + if (name == API.GetCurrentResourceName() && _open) Teardown(); + }); + } + + public static async void Open(int characterId, string characterName) + { + if (_open || _instance is null) return; + + if (ClientBrain.LocalPlayerData.IsOnDuty) + { + ClientBrain.ShowNotification("Go off duty before changing your appearance.", "Appearance"); + return; + } + + _open = true; + _characterId = characterId; + _characterName = characterName; + _original = AppearanceManager.Current; + SnapshotPed(); + + _working = Clone(_original ?? AppearanceManager.Capture()); + await AppearanceManager.ApplyAsync(_working); + if (_working.Components.Count == 0) + { + _working = Defaults(_working.Model); + await AppearanceManager.ApplyAsync(_working); + } + + var ped = Game.PlayerPed; + _anchor = ped.Position; + _forward = ped.ForwardVector; + _right = ped.RightVector; + _heading = ped.Heading; + _focus = "body"; + + PreparePed(); + _camera = API.CreateCam("DEFAULT_SCRIPTED_CAMERA", true); + PositionCamera(); + API.RenderScriptCams(true, true, CameraBlendMs, true, true); + API.DisplayRadar(false); + API.DisplayHud(false); + + NuiFocus.Take(); + _instance.Tick += _instance.HoldFocusAsync; + Send("open", new { Name = _characterName, Appearance = _working, Options = BuildOptions() }); + } + + private async Task HoldFocusAsync() + { + if (_open) NuiFocus.EnsureTaken(); + await Delay(250); + } + + private static async Task UpdateAsync(IDictionary data) + { + if (!_open) return; + + _pendingJson = NuiData.GetString(data, "appearance"); + if (_busy) return; + + _busy = true; + try + { + while (_pendingJson is not null && _open) + { + var json = _pendingJson; + _pendingJson = null; + await ApplyUpdateAsync(json); + } + } + finally + { + _busy = false; + } + } + + private static async Task ApplyUpdateAsync(string json) + { + CharacterAppearance? next; + try + { + next = AppearanceRules.Sanitize(JsonConvert.DeserializeObject(json), AppearanceManager.AllowedStandardModels); + } + catch (JsonException) + { + return; + } + + if (next is null) return; + + if (!string.Equals(next.Model, _working.Model, StringComparison.OrdinalIgnoreCase)) + { + if (!await AppearanceManager.SetModelAsync(next.Model)) return; + + _working = Defaults(next.Model); + await AppearanceManager.ApplyAsync(_working); + PreparePed(); + Send("reset", new { Appearance = _working, Options = BuildOptions() }); + return; + } + + _working = next; + await AppearanceManager.ApplyAsync(_working); + Send("options", BuildOptions()); + } + + private static CharacterAppearance Defaults(string model) + { + var appearance = AppearanceManager.Capture(); + appearance.Model = model; + + if (appearance.IsFreemode) + { + var parent = model == AppearanceRules.FemaleFreemode ? 21 : 0; + appearance.HeadBlend = new HeadBlend { ShapeFirst = parent, ShapeSecond = parent, SkinFirst = parent, SkinSecond = parent }; + appearance.FaceFeatures = Enumerable.Repeat(0f, AppearanceRules.FaceFeatureCount).ToList(); + } + + return appearance; + } + + private static void Rotate(IDictionary data) + { + if (!_open || !NuiData.TryGetInt(data, "delta", out var delta)) return; + + _heading = (_heading + delta + 360f) % 360f; + API.SetEntityHeading(Game.PlayerPed.Handle, _heading); + } + + private static void SetFocus(string focus) + { + if (!_open) return; + + _focus = focus is "face" or "legs" ? focus : "body"; + PositionCamera(); + } + + private static void PositionCamera() + { + float distance, height, fov; + switch (_focus) + { + case "face": + distance = 0.9f; height = 0.65f; fov = 30f; + break; + case "legs": + distance = 1.6f; height = -0.45f; fov = 45f; + break; + default: + distance = 2.6f; height = 0.15f; fov = 45f; + break; + } + + var target = _anchor + new Vector3(0f, 0f, height) + _right * (distance * 0.3f); + var position = _anchor + _forward * distance + new Vector3(0f, 0f, height); + + API.SetCamCoord(_camera, position.X, position.Y, position.Z); + API.PointCamAtCoord(_camera, target.X, target.Y, target.Z); + API.SetCamFov(_camera, fov); + } + + private static async Task SaveAsync() + { + while (_open && (_busy || _pendingJson is not null)) + await Delay(0); + + if (!_open) return; + + ClientEvents.TriggerServer(EventNames.ServerAppearanceSave, _characterId, JsonConvert.SerializeObject(_working)); + ClientBrain.ShowNotification($"Saved {_characterName}'s appearance.", "Appearance"); + Teardown(); + } + + private static async Task CancelAsync() + { + _pendingJson = null; + while (_open && _busy) + await Delay(0); + + if (!_open) return; + + _busy = true; + try + { + if (_original is not null) + await AppearanceManager.ApplyAsync(_original); + else + await RestoreSnapshotAsync(); + } + finally + { + _busy = false; + Teardown(); + } + } + + private static void Teardown() + { + _open = false; + if (_instance is not null) _instance.Tick -= _instance.HoldFocusAsync; + + API.RenderScriptCams(false, true, CameraBlendMs, true, true); + if (_camera != 0) API.DestroyCam(_camera, false); + _camera = 0; + + var ped = Game.PlayerPed.Handle; + API.FreezeEntityPosition(ped, false); + API.SetEntityInvincible(ped, false); + API.DisplayRadar(true); + API.DisplayHud(true); + + NuiFocus.Release(); + Send("close", null); + } + + private static void PreparePed() + { + var ped = Game.PlayerPed.Handle; + API.ClearPedTasksImmediately(ped); + API.SetEntityHeading(ped, _heading); + API.FreezeEntityPosition(ped, true); + API.SetEntityInvincible(ped, true); + } + + private static void SnapshotPed() + { + var ped = Game.PlayerPed.Handle; + _originalModel = API.GetEntityModel(ped); + _originalComponents = Enumerable.Range(0, AppearanceRules.ComponentCount) + .Select(slot => new ClothingItem { Slot = slot, Drawable = API.GetPedDrawableVariation(ped, slot), Texture = API.GetPedTextureVariation(ped, slot) }) + .ToList(); + _originalProps = AppearanceRules.PropSlots + .Select(slot => new ClothingItem { Slot = slot, Drawable = API.GetPedPropIndex(ped, slot), Texture = API.GetPedPropTextureIndex(ped, slot) }) + .ToList(); + } + + private static async Task RestoreSnapshotAsync() + { + if (API.GetEntityModel(Game.PlayerPed.Handle) != _originalModel) + { + var model = new Model(_originalModel); + if (await Game.Player.ChangeModel(model)) model.MarkAsNoLongerNeeded(); + } + + var ped = Game.PlayerPed.Handle; + foreach (var item in _originalComponents) + API.SetPedComponentVariation(ped, item.Slot, item.Drawable, item.Texture, 0); + foreach (var prop in _originalProps) + { + if (prop.Drawable < 0) API.ClearPedProp(ped, prop.Slot); + else API.SetPedPropIndex(ped, prop.Slot, prop.Drawable, prop.Texture, true); + } + } + + private static object BuildOptions() + { + var ped = Game.PlayerPed.Handle; + var models = new List + { + new() { Model = AppearanceRules.MaleFreemode, Label = "Male" }, + new() { Model = AppearanceRules.FemaleFreemode, Label = "Female" }, + }; + if (ConfigManager.Settings.Creator.AllowStandardPeds) + models.AddRange(ConfigManager.Settings.Creator.StandardPeds); + + return new + { + Models = models, + Freemode = _working.IsFreemode, + Parents = ParentNames, + FaceFeatures = FaceFeatureNames, + Overlays = OverlayNames.Select((name, index) => new + { + Index = index, + Name = name, + Count = API.GetPedHeadOverlayNum(index), + ColorType = AppearanceManager.OverlayColorType(index), + }), + HairColors = Math.Max(1, API.GetNumHairColors()), + EyeColors = AppearanceRules.MaxEyeColor + 1, + Components = Enumerable.Range(0, AppearanceRules.ComponentCount).Select(slot => new + { + Slot = slot, + Name = ComponentNames[slot], + Drawables = API.GetNumberOfPedDrawableVariations(ped, slot), + Textures = API.GetNumberOfPedTextureVariations(ped, slot, API.GetPedDrawableVariation(ped, slot)), + }), + Props = AppearanceRules.PropSlots.Select(slot => new + { + Slot = slot, + Name = PropNames[slot], + Drawables = API.GetNumberOfPedPropDrawableVariations(ped, slot), + Textures = API.GetPedPropIndex(ped, slot) < 0 ? 0 : API.GetNumberOfPedPropTextureVariations(ped, slot, API.GetPedPropIndex(ped, slot)), + }), + }; + } + + private static CharacterAppearance Clone(CharacterAppearance appearance) => + JsonConvert.DeserializeObject(JsonConvert.SerializeObject(appearance))!; + + private static void Send(string type, object? payload) => + API.SendNuiMessage(JsonConvert.SerializeObject(new { screen = "creator", type, payload }, JsonSettings)); + + private void RegisterCallback(string name, Action> handler) + { + API.RegisterNuiCallbackType(name); + EventHandlers[$"__cfx_nui:{name}"] += new Action, CallbackDelegate>((data, callback) => + { + try + { + handler(data); + } + catch (Exception ex) + { + Debug.WriteLine($"[CharacterCreator] NUI callback '{name}' failed: {ex.Message}"); + } + + callback("ok"); + }); + } + } +} diff --git a/FivePRS.Client/Arrest/ArrestManager.cs b/FivePRS.Client/Arrest/ArrestManager.cs index e8141e4..88b2887 100644 --- a/FivePRS.Client/Arrest/ArrestManager.cs +++ b/FivePRS.Client/Arrest/ArrestManager.cs @@ -105,10 +105,10 @@ await player.Task.PlayAnimation(OfficerAnimDict, OfficerAnimClip, StartFollowTask(suspect); ClientBrain.ShowNotification( - "~g~Suspect cuffed~w~ | ~b~/er_uncuff~w~ to release " + - "| ~b~/er_escort~w~ to place in vehicle"); + "~g~Suspect cuffed."); + ClientBrain.ShowHelp($"{Binds.Uncuff} Release {Binds.Escort} Place in vehicle", 6_000); - BaseScript.TriggerEvent(EventNames.LocalSuspectCuffed, suspect.Handle); + ClientEvents.TriggerLocal(EventNames.LocalSuspectCuffed, suspect.Handle); Debug.WriteLine($"[ArrestManager] Suspect (handle {suspect.Handle}) cuffed."); } @@ -132,7 +132,7 @@ public static void Uncuff() API.SetEntityAsNoLongerNeeded(ref handle); ClientBrain.ShowNotification("~o~Suspect released."); - BaseScript.TriggerEvent(EventNames.LocalSuspectUncuffed, suspect.Handle); + ClientEvents.TriggerLocal(EventNames.LocalSuspectUncuffed, suspect.Handle); Debug.WriteLine($"[ArrestManager] Suspect (handle {suspect.Handle}) released."); } @@ -171,7 +171,7 @@ public static async Task EscortToVehicleAsync(Vehicle vehicle) State = ArrestState.Escorted; ClientBrain.ShowNotification("~g~Suspect secured in vehicle."); - BaseScript.TriggerEvent(EventNames.LocalSuspectEscorted, suspect.Handle, vehicle.Handle); + ClientEvents.TriggerLocal(EventNames.LocalSuspectEscorted, suspect.Handle, vehicle.Handle); Debug.WriteLine($"[ArrestManager] Suspect escorted to vehicle {vehicle.Handle}."); return true; diff --git a/FivePRS.Client/Binds.cs b/FivePRS.Client/Binds.cs new file mode 100644 index 0000000..147f8bc --- /dev/null +++ b/FivePRS.Client/Binds.cs @@ -0,0 +1,20 @@ +using CitizenFX.Core.Native; +using FivePRS.Core.Text; + +namespace FivePRS.Client +{ + public static class Binds + { + public static string Menu => For(KeyCommands.Menu); + public static string Interact => For(KeyCommands.Interact); + public static string Accept => For(KeyCommands.Accept); + public static string Decline => For(KeyCommands.Decline); + public static string EndCall => For(KeyCommands.EndCall); + public static string Cuff => For(KeyCommands.Cuff); + public static string Uncuff => For(KeyCommands.Uncuff); + public static string Escort => For(KeyCommands.Escort); + + public static string For(string command) => + $"~INPUT_{(uint)API.GetHashKey(command) | 0x80000000:X8}~"; + } +} diff --git a/FivePRS.Client/Callouts/CalloutBase.cs b/FivePRS.Client/Callouts/CalloutBase.cs index e47372f..767a312 100644 --- a/FivePRS.Client/Callouts/CalloutBase.cs +++ b/FivePRS.Client/Callouts/CalloutBase.cs @@ -24,11 +24,28 @@ public abstract class CalloutBase private readonly List _trackedEntities = new(); private readonly List _trackedBlips = new(); - protected T? TrackEntity(T? entity) where T : Entity + protected Ped? TrackEntity(Ped? ped) + { + Track(ped); + return ped; + } + + protected Vehicle? TrackEntity(Vehicle? vehicle) + { + Track(vehicle); + return vehicle; + } + + protected Prop? TrackEntity(Prop? prop) + { + Track(prop); + return prop; + } + + private void Track(Entity? entity) { if (entity is not null) _trackedEntities.Add(entity); - return entity; } protected Blip TrackBlip(Blip blip) @@ -79,9 +96,10 @@ protected static Vector3 PickLocation(IReadOnlyList locations, Random r return pool[rng.Next(pool.Count)]; } - protected async Task WaitForArrestAsync(Ped suspect, CancellationToken ct, string prompt = "Type ~b~/er_cuff~w~ to arrest the suspect") + protected async Task WaitForArrestAsync(Ped suspect, CancellationToken ct, string? prompt = null) { const int PollMs = 250; + var help = prompt ?? $"Press {Binds.Cuff} to arrest the suspect"; ArrestManager.RegisterSuspect(suspect); @@ -101,7 +119,7 @@ protected async Task WaitForArrestAsync(Ped suspect, CancellationToken ct, strin return; } - ClientBrain.ShowHelp(prompt, PollMs + 50); + ClientBrain.ShowHelp(help, PollMs + 50); await Timing.WaitAsync(PollMs, ct); } } @@ -133,7 +151,7 @@ protected async Task FootPursuitAsync(Ped suspect, float catchDistM, float escap if (dist <= catchDistM) { await TaskManager.AssignTaskAsync(suspect, PedTaskType.PutHandsUp); - ClientBrain.ShowNotification("~g~Suspect cornered~w~ | Type ~b~/er_cuff~w~ to arrest."); + ClientBrain.ShowNotification("~g~Suspect cornered~w~ | Move in and arrest them."); await WaitForArrestAsync(suspect, ct); return; } diff --git a/FivePRS.Client/Callouts/CalloutDispatcher.cs b/FivePRS.Client/Callouts/CalloutDispatcher.cs index 7ab89cf..0d18804 100644 --- a/FivePRS.Client/Callouts/CalloutDispatcher.cs +++ b/FivePRS.Client/Callouts/CalloutDispatcher.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using CitizenFX.Core; @@ -12,10 +13,35 @@ namespace FivePRS.Client.Callouts { public sealed class CalloutDispatcher { + private const int RegistrationBatchSize = 5; + internal static volatile bool AcceptPressed; internal static volatile bool DeclinePressed; internal static volatile bool EndCalloutPressed; + public static CalloutData? PendingOffer { get; private set; } + public static int PendingOfferExpiresAt { get; private set; } + public static CalloutData? ActiveCall { get; private set; } + + public static event Action? StateChanged; + + public static void AcceptOffer() => AcceptPressed = true; + public static void DeclineOffer() => DeclinePressed = true; + public static void EndActiveCall() => EndCalloutPressed = true; + + private static void SetOffer(CalloutData? offer, int expiresAt) + { + PendingOffer = offer; + PendingOfferExpiresAt = expiresAt; + StateChanged?.Invoke(); + } + + private static void SetActive(CalloutData? call) + { + ActiveCall = call; + StateChanged?.Invoke(); + } + private readonly Department _department; private readonly CalloutRegistry _registry; private readonly Action _onEnded; @@ -43,7 +69,11 @@ public void Start() _cts = new CancellationTokenSource(); var definitions = _registry.GetDefinitions(_department); - BaseScript.TriggerServerEvent(EventNames.ServerRegisterCallouts, JsonConvert.SerializeObject(definitions)); + for (var i = 0; i < definitions.Count; i += RegistrationBatchSize) + { + var batch = definitions.Skip(i).Take(RegistrationBatchSize).ToList(); + ClientEvents.TriggerServer(EventNames.ServerRegisterCallouts, JsonConvert.SerializeObject(batch)); + } Debug.WriteLine($"[CalloutDispatcher] Registered {definitions.Count} {_department} callout(s) with dispatch."); } @@ -56,6 +86,8 @@ public void Stop() var callout = _activeCallout; _activeCallout = null; + SetOffer(null, 0); + SetActive(null); if (callout is null) return; try { callout.OnCalloutFailed(); } @@ -127,7 +159,9 @@ private async Task RunCalloutAsync(CalloutBase callout, CancellationToken ct) AcceptPressed = false; DeclinePressed = false; + SetOffer(callout.Data, API.GetGameTimer() + ConfigManager.Settings.AcceptWindowSeconds * 1000); var accepted = await RunAcceptWindowAsync(ct); + SetOffer(null, 0); previewBlip?.Delete(); @@ -141,7 +175,7 @@ private async Task RunCalloutAsync(CalloutBase callout, CancellationToken ct) callout.SetState(CalloutState.Declined); if (!ct.IsCancellationRequested) - ClientBrain.ShowNotification("~r~[ DISPATCH ]~w~ Callout declined."); + ClientBrain.ShowNotification("Callout declined.", "~r~Dispatch"); return; } @@ -150,8 +184,9 @@ private async Task RunCalloutAsync(CalloutBase callout, CancellationToken ct) EndCalloutPressed = false; callout.SetState(CalloutState.Active); _activeCallout = callout; + SetActive(callout.Data); - ClientBrain.ShowNotification($"~g~[ DISPATCH ]~w~ Call ~y~#{callout.Data.Id}~w~ accepted: ~b~{callout.Data.Name}"); + ClientBrain.ShowNotification($"Call ~y~#{callout.Data.Id}~w~ accepted: ~b~{callout.Data.Name}", "~g~Dispatch"); var finalResult = CalloutResult.Failed; using var calloutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); @@ -179,13 +214,14 @@ void OnEnded(CalloutBase c, CalloutResult result) } callout.Cleanup(finalResult == CalloutResult.Completed); + SetActive(null); if (ReferenceEquals(_activeCallout, callout)) { _activeCallout = null; if (!ct.IsCancellationRequested) { - BaseScript.TriggerServerEvent(EventNames.ServerCalloutEnded, callout.Data.Id, (int)finalResult); + ClientEvents.TriggerServer(EventNames.ServerCalloutEnded, callout.Data.Id, (int)finalResult); _onEnded(callout, finalResult); } } @@ -194,7 +230,7 @@ void OnEnded(CalloutBase c, CalloutResult result) } private static void Respond(string callId, OfferResponse response, Vector3 location) => - BaseScript.TriggerServerEvent(EventNames.ServerCalloutResponse, callId, (int)response, location.X, location.Y, location.Z); + ClientEvents.TriggerServer(EventNames.ServerCalloutResponse, callId, (int)response, location.X, location.Y, location.Z); private static async Task RunAcceptWindowAsync(CancellationToken ct) { @@ -208,7 +244,7 @@ private static async Task RunAcceptWindowAsync(CancellationToken ct) var remainingMs = end - API.GetGameTimer(); if (remainingMs <= 0) return false; - ClientBrain.ShowHelp($"~y~[ DISPATCH ]~w~ ~g~/er_accept~w~ or ~r~/er_decline~w~ ({remainingMs / 1000 + 1}s)", -1); + ClientBrain.ShowHelp($"~y~[ DISPATCH ]~w~ {Binds.Accept} Accept {Binds.Decline} Decline ({remainingMs / 1000 + 1}s)", -1); await BaseScript.Delay(0); } @@ -223,7 +259,7 @@ private static async Task RunUpdateLoopAsync(CalloutBase callout, CancellationTo if (EndCalloutPressed) { EndCalloutPressed = false; - ClientBrain.ShowNotification("~o~[ DISPATCH ]~w~ Callout ended by officer."); + ClientBrain.ShowNotification("Callout ended by officer.", "~o~Dispatch"); callout.SetState(CalloutState.Failed); callout.RaiseEnded(CalloutResult.Failed); return; @@ -268,8 +304,8 @@ private static void ShowDispatchNotification(CalloutBase callout, Vector3 dispat : ""; ClientBrain.ShowNotification( - $"~y~[ DISPATCH ]~w~ {codeColor}Code {(int)data.Priority}~w~ ~b~{data.Name}~w~ ~y~#{data.Id}~n~" + - $"{data.Description}~n~{distance}~g~/er_accept~w~ ~r~/er_decline"); + $"~b~{data.Name}~w~ ~y~#{data.Id}~n~{data.Description}~n~{distance}", + $"{codeColor}Dispatch · Code {(int)data.Priority}"); } private static Blip? CreatePreviewBlip(Vector3 location, CalloutData data) diff --git a/FivePRS.Client/Callouts/CalloutRegistry.cs b/FivePRS.Client/Callouts/CalloutRegistry.cs index 27aa478..b6f8a2c 100644 --- a/FivePRS.Client/Callouts/CalloutRegistry.cs +++ b/FivePRS.Client/Callouts/CalloutRegistry.cs @@ -63,12 +63,16 @@ public void DiscoverAll() } } - public void Register() where T : CalloutBase + public void Register(Type type) { - var attr = typeof(T).GetCustomAttribute() + if (type is null) throw new ArgumentNullException(nameof(type)); + if (type.IsAbstract || !type.IsSubclassOf(typeof(CalloutBase))) + throw new ArgumentException($"{type.Name} must be a concrete CalloutBase subclass.", nameof(type)); + + var attr = type.GetCustomAttribute() ?? throw new InvalidOperationException( - $"{typeof(T).Name} is missing [CalloutInfo(...)]. Cannot register."); - Register(typeof(T), attr); + $"{type.Name} is missing [CalloutInfo(...)]. Cannot register."); + Register(type, attr); } private void Register(Type type, CalloutInfoAttribute attr) diff --git a/FivePRS.Client/ClientBrain.cs b/FivePRS.Client/ClientBrain.cs index bc8b9fd..47b853f 100644 --- a/FivePRS.Client/ClientBrain.cs +++ b/FivePRS.Client/ClientBrain.cs @@ -10,6 +10,7 @@ using FivePRS.Core.Config; using FivePRS.Core.Events; using FivePRS.Core.Models; +using FivePRS.Core.Text; using Newtonsoft.Json; namespace FivePRS.Client @@ -18,8 +19,6 @@ public class ClientBrain : BaseScript { public static PlayerData LocalPlayerData { get; private set; } = new(); - private bool _profileLoaded = false; - public ClientBrain() { ConfigManager.Log = message => Debug.WriteLine(message); @@ -29,39 +28,37 @@ public ClientBrain() ConfigManager.LoadPoliceVehicles( API.LoadResourceFile(res, "config/police_vehicles.json")); ConfigManager.LoadPoliceLoadouts( API.LoadResourceFile(res, "config/police_loadouts.json")); ConfigManager.LoadJurisdictions( API.LoadResourceFile(res, "config/jurisdictions.json")); + ConfigManager.LoadStations( API.LoadResourceFile(res, "config/stations.json")); EventHandlers[EventNames.ClientReceivePlayerData] += new Action(OnReceivePlayerData); EventHandlers[EventNames.ClientDutyStatusChanged] += new Action(OnDutyStatusChanged); EventHandlers[EventNames.ClientCalloutOffered] += new Action(OnCalloutOffered); EventHandlers[EventNames.ClientRankedUp] += new Action(OnRankedUp); EventHandlers[EventNames.ClientEndCallout] += new Action(() => CalloutDispatcher.EndCalloutPressed = true); + EventHandlers["onClientResourceStop"] += new Action(OnResourceStop); - API.RegisterCommand("duty", new Action, string>(OnDutyCommand), false); - API.RegisterCommand("setdept", new Action, string>(OnSetDeptCommand), false); - API.RegisterCommand("setagency", new Action, string>(OnSetAgencyCommand), false); - - API.RegisterCommand("er_accept", new Action, string>((_, __, ___) => + API.RegisterCommand(KeyCommands.Accept, new Action, string>((_, __, ___) => { CalloutDispatcher.AcceptPressed = true; }), false); - API.RegisterCommand("er_decline", new Action, string>((_, __, ___) => + API.RegisterCommand(KeyCommands.Decline, new Action, string>((_, __, ___) => { CalloutDispatcher.DeclinePressed = true; }), false); - API.RegisterCommand("er_end_callout", new Action, string>((_, __, ___) => + API.RegisterCommand(KeyCommands.EndCall, new Action, string>((_, __, ___) => { CalloutDispatcher.EndCalloutPressed = true; }), false); - API.RegisterCommand("er_cuff", new Action, string>(async (_, __, ___) => + API.RegisterCommand(KeyCommands.Cuff, new Action, string>(async (_, __, ___) => { await ArrestManager.TryCuffNearestAsync(); }), false); - API.RegisterCommand("er_uncuff", new Action, string>((_, __, ___) => + API.RegisterCommand(KeyCommands.Uncuff, new Action, string>((_, __, ___) => { ArrestManager.Uncuff(); }), false); - API.RegisterCommand("er_escort", new Action, string>(async (_, __, ___) => + API.RegisterCommand(KeyCommands.Escort, new Action, string>(async (_, __, ___) => { var vehicle = Game.PlayerPed.CurrentVehicle; if (vehicle == null || !vehicle.Exists()) @@ -82,11 +79,9 @@ public ClientBrain() { ShowNotification( "~y~FivePRS Commands~w~~n~" + - "~b~/duty~w~ — Toggle on/off duty~n~" + - "~b~/setdept [id]~w~ — Set department~n~" + - "~b~/setagency [id]~w~ — Set agency~n~" + + "~b~/fiveprs~w~ — Open the FivePRS menu (duty, dispatch and more)~n~" + "~b~/er_profile~w~ — View rank and XP~n~" + - "~b~/er_accept~w~ — Accept incoming callout~n~" + + "~b~/er_accept_call~w~ — Accept incoming callout~n~" + "~b~/er_decline~w~ — Decline incoming callout~n~" + "~b~/er_end_callout~w~ — End active callout~n~" + "~b~/er_calls~w~ — List active calls~n~" + @@ -94,20 +89,25 @@ public ClientBrain() "~b~/er_status [available|busy]~w~ — Set unit status~n~" + "~b~/er_cuff~w~ — Cuff nearest suspect~n~" + "~b~/er_uncuff~w~ — Release cuffed suspect~n~" + - "~b~/er_escort~w~ — Place suspect in your vehicle"); + "~b~/er_escort~w~ — Place suspect in your vehicle~n~" + + "~b~/fprs_coords~w~ — Copy your position for stations.json"); }), false); - API.RegisterKeyMapping("duty", "FivePRS: Toggle on/off duty", "keyboard", "F5"); - API.RegisterKeyMapping("er_accept", "FivePRS: Accept callout", "keyboard", "Y"); - API.RegisterKeyMapping("er_decline", "FivePRS: Decline callout", "keyboard", "N"); - API.RegisterKeyMapping("er_end_callout", "FivePRS: End active callout", "keyboard", "END"); - API.RegisterKeyMapping("er_cuff", "FivePRS: Cuff nearest suspect", "keyboard", "G"); - API.RegisterKeyMapping("er_uncuff", "FivePRS: Release cuffed suspect", "keyboard", ""); - API.RegisterKeyMapping("er_escort", "FivePRS: Escort suspect to vehicle", "keyboard", "H"); + API.RegisterKeyMapping(KeyCommands.Accept, "FivePRS: Accept callout", "keyboard", "Y"); + API.RegisterKeyMapping(KeyCommands.Decline, "FivePRS: Decline callout", "keyboard", "N"); + API.RegisterKeyMapping(KeyCommands.EndCall, "FivePRS: End active callout", "keyboard", "END"); + API.RegisterKeyMapping(KeyCommands.Cuff, "FivePRS: Cuff nearest suspect", "keyboard", "G"); + API.RegisterKeyMapping(KeyCommands.Uncuff, "FivePRS: Release cuffed suspect", "keyboard", ""); + API.RegisterKeyMapping(KeyCommands.Escort, "FivePRS: Escort suspect to vehicle", "keyboard", "H"); API.RegisterKeyMapping("er_profile", "FivePRS: View rank and XP", "keyboard", "F6"); API.RegisterKeyMapping("er_help", "FivePRS: Show command list", "keyboard", ""); + Exports.Add("getState", new Func>(PublicState.Build)); + Tick += WaitForSpawnTick; + Tick += DrawHelpTick; + + CreateNotificationLogo(res, ConfigManager.Settings.Branding.NotificationLogo); } private async Task WaitForSpawnTick() @@ -115,7 +115,7 @@ private async Task WaitForSpawnTick() if (Game.PlayerPed.Exists() && Game.PlayerPed.Handle != 0) { Tick -= WaitForSpawnTick; - TriggerServerEvent(EventNames.ServerPlayerConnected); + ClientEvents.TriggerServer(EventNames.ServerPlayerConnected); } await Delay(500); } @@ -128,11 +128,10 @@ private void OnReceivePlayerData(string json) if (data is null) return; LocalPlayerData = data; - _profileLoaded = true; Debug.WriteLine($"[FivePRS] Profile loaded: {data.Name} | Rank {data.Rank} | {data.Department}"); - if (data.Department == Department.None) - ShowNotification($"~y~Welcome to FivePRS!~w~ Choose your department with ~b~/setdept~w~.~n~{DepartmentOptions()}"); + if (!data.IsOnDuty) + ShowHelp($"~y~Welcome to FivePRS!~w~ Press {Binds.Menu} to open the FivePRS menu.", 10_000); } catch (Exception ex) { @@ -145,7 +144,7 @@ private void OnDutyStatusChanged(bool isOnDuty, int departmentId) LocalPlayerData.IsOnDuty = isOnDuty; LocalPlayerData.Department = (Department)departmentId; - TriggerEvent(EventNames.LocalDutyChanged, isOnDuty, departmentId); + ClientEvents.TriggerLocal(EventNames.LocalDutyChanged, isOnDuty, departmentId); } private void OnRankedUp(int newRank) @@ -161,7 +160,7 @@ private void OnCalloutOffered(string calloutJson) _ = JsonConvert.DeserializeObject(calloutJson) ?? throw new InvalidOperationException("Callout JSON was null after deserialisation."); - TriggerEvent(EventNames.LocalCalloutReceived, calloutJson); + ClientEvents.TriggerLocal(EventNames.LocalCalloutReceived, calloutJson); } catch (Exception ex) { @@ -169,105 +168,93 @@ private void OnCalloutOffered(string calloutJson) } } - private void OnDutyCommand(int source, List args, string raw) + private static async void CreateNotificationLogo(string resource, string url) { - if (!_profileLoaded) - { - ShowNotification("~r~Your profile hasn't loaded yet. Please wait."); - return; - } + if (string.IsNullOrWhiteSpace(url)) return; + + _logoDui = API.CreateDui($"nui://{resource}/nui/dui-image.html#{url}", NotificationLogoSize, NotificationLogoSize); - if (!LocalPlayerData.IsOnDuty) + var until = API.GetGameTimer() + DuiTimeoutMs; + while (!API.IsDuiAvailable(_logoDui)) { - var dept = LocalPlayerData.Department; - if (dept == Department.None) + if (API.GetGameTimer() > until) { - ShowNotification($"~r~No department selected.~w~ Use ~b~/setdept~w~ first.~n~{DepartmentOptions()}"); - return; - } - if (!BaseAgency.IsDepartmentLoaded(dept)) - { - ShowNotification($"~r~The {dept} module is not installed on this server."); + Debug.WriteLine($"[FivePRS] The notification logo page did not load from {url}."); return; } + + await Delay(100); } - TriggerServerEvent(EventNames.ServerToggleDuty); + var name = $"fiveprs_logo_{API.GetGameTimer()}"; + var txd = API.CreateRuntimeTxd(name); + API.CreateRuntimeTextureFromDuiHandle(txd, name, API.GetDuiHandle(_logoDui)); + _logoTxd = name; + Debug.WriteLine("[FivePRS] Notification logo ready."); } - private void OnSetDeptCommand(int source, List args, string raw) + private void OnResourceStop(string resourceName) { - if (args.Count < 1 || !int.TryParse(args[0]?.ToString(), out int deptId) - || !Enum.IsDefined(typeof(Department), deptId) || deptId == (int)Department.None) - { - ShowNotification($"~r~Usage: ~w~/setdept [id]~n~{DepartmentOptions()}"); - return; - } + if (resourceName != API.GetCurrentResourceName() || _logoDui == 0) return; - var dept = (Department)deptId; - if (!BaseAgency.IsDepartmentLoaded(dept)) - { - ShowNotification($"~r~The {dept} module is not installed on this server.~n~{DepartmentOptions()}"); - return; - } - - TriggerServerEvent(EventNames.ServerSetDepartment, deptId); + API.DestroyDui(_logoDui); + _logoDui = 0; } - private void OnSetAgencyCommand(int source, List args, string raw) + public static void ShowNotification(string message, string? subject = null) { - if (!_profileLoaded) + var body = message; + if (subject is null) { - ShowNotification("~r~Your profile hasn't loaded yet. Please wait."); - return; + var split = message.IndexOf(SubjectSeparator, StringComparison.Ordinal); + subject = split > 0 ? message.Substring(0, split) : string.Empty; + if (split > 0) body = message.Substring(split + SubjectSeparator.Length); } - var agencies = ConfigManager.Territories.AgenciesFor(LocalPlayerData.Department).ToList(); - if (agencies.Count == 0) - { - ShowNotification($"~r~{LocalPlayerData.Department} has no agencies configured."); - return; - } - - var agencyId = args.Count > 0 ? args[0]?.ToString() : null; - if (string.IsNullOrEmpty(agencyId)) - { - var options = agencies.Select(a => $"~b~{a.Id}~w~ {a.Name}"); - ShowNotification("~r~Usage: ~w~/setagency [id]~n~" + string.Join("~n~", options)); - return; - } - - TriggerServerEvent(EventNames.ServerSetAgency, agencyId); + API.SetNotificationTextEntry(LongTextEntry); + AddLongText(body); + API.SetNotificationMessage(_logoTxd, _logoTxd, false, 0, NotificationSender, subject); + API.DrawNotification(false, true); } - private static string DepartmentOptions() + public static void ShowHelp(string message, int durationMs) { - var options = Enum.GetValues(typeof(Department)) - .Cast() - .Where(d => d != Department.None && BaseAgency.IsDepartmentLoaded(d)) - .Select(d => $"~b~{(int)d}~w~={d}"); - return "Available: " + string.Join(" ", options); + _helpText = message; + _helpUntil = API.GetGameTimer() + Math.Max(durationMs, MinHelpDurationMs); } - public static void ShowNotification(string message) + private Task DrawHelpTick() { - API.SetNotificationTextEntry("STRING"); - AddLongText(message); - API.DrawNotification(false, true); - } + if (_helpText is null) return Delay(50); - public static void ShowHelp(string message, int durationMs) - { - API.BeginTextCommandDisplayHelp("STRING"); - AddLongText(message); - API.EndTextCommandDisplayHelp(0, false, false, durationMs); + if (API.GetGameTimer() > _helpUntil) + { + _helpText = null; + return Delay(50); + } + + API.BeginTextCommandDisplayHelp(LongTextEntry); + AddLongText(_helpText); + API.EndTextCommandDisplayHelp(0, false, false, -1); + return Task.FromResult(0); } + private const string LongTextEntry = "CELL_EMAIL_BCON"; + private const string NotificationSender = "~b~FivePRS"; + private const int NotificationLogoSize = 128; + private const int DuiTimeoutMs = 15_000; + private const string SubjectSeparator = " | "; + private const int MinHelpDurationMs = 100; + + private static long _logoDui; + private static string _logoTxd = string.Empty; + private static string? _helpText; + private static int _helpUntil; + private static void AddLongText(string text) { - const int ChunkSize = 64; - for (var i = 0; i < text.Length; i += ChunkSize) - API.AddTextComponentSubstringPlayerName(text.Substring(i, Math.Min(ChunkSize, text.Length - i))); + foreach (var part in GameText.SplitComponents(text)) + API.AddTextComponentSubstringPlayerName(part); } } } diff --git a/FivePRS.Client/ClientEvents.cs b/FivePRS.Client/ClientEvents.cs new file mode 100644 index 0000000..1e5634e --- /dev/null +++ b/FivePRS.Client/ClientEvents.cs @@ -0,0 +1,51 @@ +using System; +using System.Text; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using Newtonsoft.Json; + +namespace FivePRS.Client +{ + public static class ClientEvents + { + private const string RelayCommand = "fiveprs_relay"; + + private static bool _useRelay; + + public static void TriggerServer(string eventName, params object[] args) => Trigger(eventName, args, true); + + public static void TriggerLocal(string eventName, params object[] args) => Trigger(eventName, args, false); + + private static void Trigger(string eventName, object[] args, bool remote) + { + if (!_useRelay) + { + try + { + if (remote) + BaseScript.TriggerServerEvent(eventName, args); + else + BaseScript.TriggerEvent(eventName, args); + return; + } + catch (ArgumentException ex) when (ex.Message.Contains("expected a managed pointer")) + { + _useRelay = true; + Debug.WriteLine("[FivePRS] This client build blocks C# event natives; routing events through the Lua relay."); + } + } + + var payload = JsonConvert.SerializeObject(new { e = eventName, r = remote, n = args.Length, a = args }); + API.ExecuteCommand($"{RelayCommand} {ToHex(payload)}"); + } + + private static string ToHex(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var builder = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) + builder.Append(b.ToString("x2")); + return builder.ToString(); + } + } +} diff --git a/FivePRS.Client/CoordsCommand.cs b/FivePRS.Client/CoordsCommand.cs new file mode 100644 index 0000000..6d62ea8 --- /dev/null +++ b/FivePRS.Client/CoordsCommand.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using Newtonsoft.Json; + +namespace FivePRS.Client +{ + public class CoordsCommand : BaseScript + { + private static readonly string[] PointNames = { "Armory", "Locker", "Garage" }; + + public CoordsCommand() + { + API.RegisterCommand("fprs_coords", new Action, string>((_, args, __) => + Capture(args.Count > 0 ? args[0]?.ToString() : null)), false); + } + + private static void Capture(string? point) + { + var pointName = Array.Find(PointNames, p => string.Equals(p, point, StringComparison.OrdinalIgnoreCase)); + + var ped = Game.PlayerPed; + var vehicle = ped.CurrentVehicle; + var inVehicle = vehicle is not null && vehicle.Exists(); + var entity = inVehicle ? (Entity)vehicle! : ped; + + var x = Format(entity.Position.X); + var y = Format(entity.Position.Y); + var z = Format(entity.Position.Z); + var h = Format(entity.Heading); + + string label, text; + if (inVehicle) + { + label = "Parking spot"; + text = $"[{x}, {y}, {z}, {h}]"; + } + else if (pointName is not null) + { + label = $"{pointName} point"; + text = $"\"{pointName.ToLowerInvariant()}\": [{x}, {y}, {z}]"; + } + else + { + label = "Duty point"; + text = $"\"dutyPoint\": [{x}, {y}, {z}], \"heading\": {h}"; + } + + Debug.WriteLine($"[FivePRS] {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"); + } + + private static string Format(float value) => + Math.Round(value, 2).ToString("0.0#", CultureInfo.InvariantCulture); + } +} diff --git a/FivePRS.Client/Dispatch/AiCallouts.cs b/FivePRS.Client/Dispatch/AiCallouts.cs new file mode 100644 index 0000000..646fadd --- /dev/null +++ b/FivePRS.Client/Dispatch/AiCallouts.cs @@ -0,0 +1,29 @@ +using CitizenFX.Core.Native; +using FivePRS.Client.Callouts; +using FivePRS.Core.Events; + +namespace FivePRS.Client.Dispatch +{ + internal static class AiCallouts + { + private const string DisabledKey = "ai_callouts_off"; + + public static bool Enabled => API.GetResourceKvpInt(DisabledKey) == 0; + + public static void Set(bool enabled) + { + API.SetResourceKvpInt(DisabledKey, enabled ? 0 : 1); + ClientEvents.TriggerServer(EventNames.ServerSetAiCallouts, enabled); + + if (!enabled && CalloutDispatcher.PendingOffer is not null) + CalloutDispatcher.DeclineOffer(); + } + + public static void Toggle() => Set(!Enabled); + + public static void Restore() + { + if (!Enabled) ClientEvents.TriggerServer(EventNames.ServerSetAiCallouts, false); + } + } +} diff --git a/FivePRS.Client/Dispatch/DispatchClient.cs b/FivePRS.Client/Dispatch/DispatchClient.cs index 177892e..5c55785 100644 --- a/FivePRS.Client/Dispatch/DispatchClient.cs +++ b/FivePRS.Client/Dispatch/DispatchClient.cs @@ -6,6 +6,7 @@ using FivePRS.Core.Config; using FivePRS.Core.Events; using FivePRS.Core.Models; +using FivePRS.Core.Text; using Newtonsoft.Json; namespace FivePRS.Client.Dispatch @@ -24,11 +25,14 @@ public class DispatchClient : BaseScript public DispatchClient() { EventHandlers[EventNames.ClientDispatchSnapshot] += new Action(OnSnapshot); - EventHandlers[EventNames.ClientNotify] += new Action(ClientBrain.ShowNotification); + EventHandlers[EventNames.ClientNotify] += new Action(message => ClientBrain.ShowNotification(message)); + EventHandlers[EventNames.LocalDutyChanged] += new Action(OnDutyChanged); API.RegisterCommand("er_calls", new Action, string>(OnCallsCommand), false); API.RegisterCommand("er_attach", new Action, string>(OnAttachCommand), false); API.RegisterCommand("er_status", new Action, string>(OnStatusCommand), false); + API.RegisterCommand(KeyCommands.AiCallouts, new Action, string>((_, __, ___) => AiCallouts.Toggle()), false); + API.RegisterKeyMapping(KeyCommands.AiCallouts, "FivePRS: Toggle AI callouts", "keyboard", ""); } private void OnSnapshot(string json) @@ -55,6 +59,19 @@ private void OnSnapshot(string json) SnapshotUpdated?.Invoke(); } + private void OnDutyChanged(bool isOnDuty, int departmentId) + { + if (isOnDuty) + { + AiCallouts.Restore(); + return; + } + + Snapshot = new DispatchSnapshot(); + _lastCallId = null; + SnapshotUpdated?.Invoke(); + } + private static void OnCallsCommand(int source, List args, string raw) { if (LocalUnit is null) @@ -65,7 +82,7 @@ private static void OnCallsCommand(int source, List args, string raw) if (Snapshot.Calls.Count == 0) { - ClientBrain.ShowNotification("~y~[ DISPATCH ]~w~ No active calls."); + ClientBrain.ShowNotification("No active calls.", "~y~Dispatch"); return; } @@ -79,19 +96,19 @@ private static void OnCallsCommand(int source, List args, string raw) return $"~y~#{call.Id}~w~ {call.Name} (Code {(int)call.Priority}){area} ~b~{string.Join(", ", callsigns)}"; }); - ClientBrain.ShowNotification("~y~[ DISPATCH ]~w~ Active calls~n~" + string.Join("~n~", lines)); + ClientBrain.ShowNotification(string.Join("~n~", lines), "~y~Active calls"); } private static void OnAttachCommand(int source, List args, string raw) { var callId = args.Count > 0 ? args[0]?.ToString()?.TrimStart('#') : null; - if (string.IsNullOrEmpty(callId)) + if (callId is null || callId.Length == 0) { ClientBrain.ShowNotification("~r~Usage: ~w~/er_attach [call id]"); return; } - TriggerServerEvent(EventNames.ServerAttachToCall, callId); + ClientEvents.TriggerServer(EventNames.ServerAttachToCall, callId); } private static void OnStatusCommand(int source, List args, string raw) @@ -104,7 +121,7 @@ private static void OnStatusCommand(int source, List args, string raw) return; } - TriggerServerEvent(EventNames.ServerSetUnitStatus, (int)status); + ClientEvents.TriggerServer(EventNames.ServerSetUnitStatus, (int)status); } } } diff --git a/FivePRS.Client/FivePRS.Client.csproj b/FivePRS.Client/FivePRS.Client.csproj index 52e7bf0..14857f1 100644 --- a/FivePRS.Client/FivePRS.Client.csproj +++ b/FivePRS.Client/FivePRS.Client.csproj @@ -14,7 +14,11 @@ - + + + $(PkgNewtonsoft_Json)\lib\portable-net40+sl5+win8+wp8+wpa81\Newtonsoft.Json.dll + + diff --git a/FivePRS.Client/LastLocation.cs b/FivePRS.Client/LastLocation.cs new file mode 100644 index 0000000..b8d491c --- /dev/null +++ b/FivePRS.Client/LastLocation.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.App; +using FivePRS.Client.Appearance; +using FivePRS.Core.Config; +using FivePRS.Core.Events; + +namespace FivePRS.Client +{ + public class LastLocation : BaseScript + { + private const int ReplyTimeoutMs = 15_000; + private const float MinimumMove = 5f; + private const float RestoreDistance = 15f; + + private bool _requested; + private bool _restored; + private int _requestedAt; + private int _nextReport; + private Vector3 _lastReported; + + public LastLocation() + { + EventHandlers[EventNames.ClientLastPosition] += new Action(OnLastPosition); + Tick += OnTick; + } + + private async Task OnTick() + { + var settings = ConfigManager.Settings.Spawn; + if (!settings.RestoreLastLocation) + { + await Delay(5000); + return; + } + + var ped = Game.PlayerPed; + var now = API.GetGameTimer(); + + if (!_requested) + { + if (ped.Exists() && DutyPanel.IsLoaded && API.IsScreenFadedIn() && !API.IsPlayerSwitchInProgress()) + { + _requested = true; + _requestedAt = now; + ClientEvents.TriggerServer(EventNames.ServerLastPositionRequest); + } + + await Delay(1000); + return; + } + + if (!_restored) + { + if (now - _requestedAt > ReplyTimeoutMs) _restored = true; + await Delay(500); + return; + } + + if (now >= _nextReport) + { + _nextReport = now + Math.Max(5, settings.SaveIntervalSeconds) * 1000; + Report(ped); + } + + await Delay(1000); + } + + private void Report(Ped ped) + { + if (!ped.Exists() || ped.IsDead || CharacterCreator.IsOpen) return; + + var position = ped.Position; + if (Vector3.Distance(position, _lastReported) < MinimumMove) return; + + _lastReported = position; + ClientEvents.TriggerServer(EventNames.ServerReportPosition, position.X, position.Y, position.Z, ped.Heading); + } + + private async void OnLastPosition(bool found, float x, float y, float z, float heading) + { + try + { + var ped = Game.PlayerPed; + var target = new Vector3(x, y, z); + + if (found && ped.Exists() && !ped.IsDead && !ped.IsInVehicle() && + Vector3.Distance(ped.Position, target) > RestoreDistance) + { + await Teleporter.ToAsync(target, heading); + ClientBrain.ShowNotification("Welcome back. You're where you left off.", "FivePRS"); + } + } + finally + { + _lastReported = Game.PlayerPed.Position; + _restored = true; + } + } + } +} diff --git a/FivePRS.Client/Loadout/LoadoutDefinition.cs b/FivePRS.Client/Loadout/LoadoutDefinition.cs index b472f1f..32b7e8a 100644 --- a/FivePRS.Client/Loadout/LoadoutDefinition.cs +++ b/FivePRS.Client/Loadout/LoadoutDefinition.cs @@ -15,6 +15,10 @@ public sealed class LoadoutDefinition public string? MalePedModel { get; set; } public string? FemalePedModel { get; set; } + + public string? FallbackMalePedModel { get; set; } + + public string? FallbackFemalePedModel { get; set; } } public sealed class WeaponEntry diff --git a/FivePRS.Client/Loadout/LoadoutManager.cs b/FivePRS.Client/Loadout/LoadoutManager.cs index 2425bf1..e79d586 100644 --- a/FivePRS.Client/Loadout/LoadoutManager.cs +++ b/FivePRS.Client/Loadout/LoadoutManager.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using CitizenFX.Core; using CitizenFX.Core.Native; +using FivePRS.Client.Appearance; namespace FivePRS.Client.Loadout { @@ -13,10 +15,26 @@ public static class LoadoutManager private static List? _savedComponents; private static List? _savedProps; private static int _savedModel; + private static readonly List Issued = new(); + + private const int FullArmour = 100; public static LoadoutDefinition? Current { get; private set; } + public static bool IsInUniform => _savedComponents is not null; + public static async Task ApplyAsync(LoadoutDefinition loadout) + { + if (!Game.PlayerPed.Exists()) return; + + await WearAsync(loadout); + await GiveWeaponsAsync(loadout.Weapons, true); + + Current = loadout; + Debug.WriteLine($"[LoadoutManager] Applied loadout '{loadout.Name}'."); + } + + public static async Task WearAsync(LoadoutDefinition outfit) { var ped = Game.PlayerPed; if (!ped.Exists()) return; @@ -24,32 +42,115 @@ public static async Task ApplyAsync(LoadoutDefinition loadout) if (_savedComponents is null) SaveAppearance(ped.Handle); - var pedModel = API.IsPedMale(ped.Handle) ? loadout.MalePedModel : loadout.FemalePedModel; - if (pedModel is not null && await ChangeModelAsync(new Model(pedModel))) + var male = API.IsPedMale(ped.Handle); + var own = male ? outfit.MalePedModel : outfit.FemalePedModel; + var fallback = own is null && !IsFreemode(_savedModel); + var pedModel = own ?? (fallback ? (male ? outfit.FallbackMalePedModel : outfit.FallbackFemalePedModel) : null); + var target = pedModel is not null ? API.GetHashKey(pedModel) : _savedModel; + + if (target != 0 && API.GetEntityModel(ped.Handle) != target && await ChangeModelAsync(new Model(target))) { ped = Game.PlayerPed; API.SetPedDefaultComponentVariation(ped.Handle); + if (pedModel is null) RestoreSavedClothing(ped.Handle); + ReissueWeapons(ped.Handle); } - API.RemoveAllPedWeapons(ped.Handle, true); - await BaseScript.Delay(100); + if (fallback) return; + + ApplyComponents(ped.Handle, outfit.Components); + ApplyProps(ped.Handle, outfit.Props); + } + + public static async Task RestoreClothingAsync() + { + var ped = Game.PlayerPed; + if (!ped.Exists() || _savedComponents is null) return; + + if (AppearanceManager.Current is not null) + { + var before = API.GetEntityModel(ped.Handle); + ClearSavedAppearance(); + await AppearanceManager.ApplyCurrentAsync(); + if (API.GetEntityModel(Game.PlayerPed.Handle) != before) ReissueWeapons(Game.PlayerPed.Handle); + return; + } - uint currentWeaponHash = 0; - foreach (var entry in loadout.Weapons) + if (_savedModel != 0 && API.GetEntityModel(ped.Handle) != _savedModel && + await ChangeModelAsync(new Model(_savedModel))) { - API.GiveWeaponToPed(ped.Handle, entry.Hash, entry.Ammo, false, false); - if (entry.SetAsCurrent) - currentWeaponHash = entry.Hash; + ped = Game.PlayerPed; + ReissueWeapons(ped.Handle); } - if (currentWeaponHash != 0) - API.SetCurrentPedWeapon(ped.Handle, currentWeaponHash, true); + RestoreSavedClothing(ped.Handle); + ClearSavedAppearance(); + } - ApplyComponents(ped.Handle, loadout.Components); - ApplyProps(ped.Handle, loadout.Props); + public static async Task GiveWeaponsAsync(IEnumerable weapons, bool replace) + { + var ped = Game.PlayerPed.Handle; - Current = loadout; - Debug.WriteLine($"[LoadoutManager] Applied loadout '{loadout.Name}'."); + if (replace) + { + RemoveWeapons(); + await BaseScript.Delay(100); + } + + uint current = 0; + foreach (var entry in weapons) + { + GiveWeapon(ped, entry); + if (entry.SetAsCurrent) current = entry.Hash; + } + + if (current != 0) + API.SetCurrentPedWeapon(ped, current, true); + } + + public static void GiveWeapon(WeaponEntry entry) => GiveWeapon(Game.PlayerPed.Handle, entry); + + public static bool HasWeapon(uint hash) => API.HasPedGotWeapon(Game.PlayerPed.Handle, hash, false); + + public static int RefillAmmo() + { + var ped = Game.PlayerPed.Handle; + var refills = 0; + + foreach (var entry in Issued.Where(e => API.HasPedGotWeapon(ped, e.Hash, false))) + { + if (API.GetAmmoInPedWeapon(ped, entry.Hash) >= entry.Ammo) continue; + + API.SetPedAmmo(ped, entry.Hash, entry.Ammo); + refills++; + } + + return refills; + } + + public static void GiveArmour() => API.SetPedArmour(Game.PlayerPed.Handle, FullArmour); + + public static void RemoveWeapons() + { + API.RemoveAllPedWeapons(Game.PlayerPed.Handle, true); + Issued.Clear(); + } + + private static void GiveWeapon(int ped, WeaponEntry entry) + { + if (API.HasPedGotWeapon(ped, entry.Hash, false)) + API.SetPedAmmo(ped, entry.Hash, System.Math.Max(entry.Ammo, API.GetAmmoInPedWeapon(ped, entry.Hash))); + else + API.GiveWeaponToPed(ped, entry.Hash, entry.Ammo, false, false); + + Issued.RemoveAll(e => e.Hash == entry.Hash); + Issued.Add(entry); + } + + private static void ReissueWeapons(int ped) + { + foreach (var entry in Issued) + API.GiveWeaponToPed(ped, entry.Hash, entry.Ammo, false, false); } public static async Task StripAsync() @@ -57,25 +158,27 @@ public static async Task StripAsync() var ped = Game.PlayerPed; if (!ped.Exists()) return; - API.RemoveAllPedWeapons(ped.Handle, true); + RemoveWeapons(); + await RestoreClothingAsync(); - if (_savedModel != 0 && API.GetEntityModel(ped.Handle) != _savedModel && - await ChangeModelAsync(new Model(_savedModel))) - { - ped = Game.PlayerPed; - } + Current = null; + Debug.WriteLine("[LoadoutManager] Loadout stripped."); + } - if (_savedComponents is not null) - ApplyComponents(ped.Handle, _savedComponents); + private static bool IsFreemode(int model) => + model == API.GetHashKey("mp_m_freemode_01") || model == API.GetHashKey("mp_f_freemode_01"); - if (_savedProps is not null) - ApplyProps(ped.Handle, _savedProps); + private static void RestoreSavedClothing(int ped) + { + if (_savedComponents is not null) ApplyComponents(ped, _savedComponents); + if (_savedProps is not null) ApplyProps(ped, _savedProps); + } + private static void ClearSavedAppearance() + { _savedComponents = null; _savedProps = null; _savedModel = 0; - Current = null; - Debug.WriteLine("[LoadoutManager] Loadout stripped."); } private static async Task ChangeModelAsync(Model model) diff --git a/FivePRS.Client/Mdt/MdtClient.cs b/FivePRS.Client/Mdt/MdtClient.cs deleted file mode 100644 index 8999b95..0000000 --- a/FivePRS.Client/Mdt/MdtClient.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using CitizenFX.Core; -using CitizenFX.Core.Native; -using FivePRS.Client.Dispatch; -using FivePRS.Core.Config; -using FivePRS.Core.Events; -using FivePRS.Core.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; - -namespace FivePRS.Client.Mdt -{ - public class MdtClient : BaseScript - { - private static readonly JsonSerializerSettings JsonSettings = new() - { - ContractResolver = new CamelCasePropertyNamesContractResolver(), - }; - - private bool _open; - - public MdtClient() - { - API.RegisterCommand("mdt", new Action, string>((_, __, ___) => Toggle()), false); - API.RegisterKeyMapping("mdt", "FivePRS: Open MDT", "keyboard", "F7"); - - RegisterCallback("close", _ => Close()); - RegisterCallback("setStatus", data => SetStatus(data)); - RegisterCallback("attach", data => Attach(data)); - RegisterCallback("waypoint", data => Waypoint(data)); - - DispatchClient.SnapshotUpdated += OnSnapshotUpdated; - EventHandlers["onClientResourceStop"] += new Action(OnResourceStop); - } - - private void Toggle() - { - if (_open) - { - Close(); - return; - } - - if (DispatchClient.LocalUnit is null) - { - ClientBrain.ShowNotification("~r~You must be on duty to use the MDT."); - return; - } - - _open = true; - API.SetNuiFocus(true, true); - Send("open", BuildView()); - } - - private void Close() - { - if (!_open) return; - - _open = false; - API.SetNuiFocus(false, false); - Send("close", null); - } - - private void OnSnapshotUpdated() - { - if (!_open) return; - - if (DispatchClient.LocalUnit is null) - { - Close(); - return; - } - - Send("update", BuildView()); - } - - private static void SetStatus(IDictionary data) - { - if (!TryGetString(data, "status", out var value) || - !Enum.TryParse(value, true, out var status) || - (status != UnitStatus.Available && status != UnitStatus.Busy)) - { - return; - } - - TriggerServerEvent(EventNames.ServerSetUnitStatus, (int)status); - } - - private static void Attach(IDictionary data) - { - if (TryGetString(data, "callId", out var callId)) - TriggerServerEvent(EventNames.ServerAttachToCall, callId); - } - - private static void Waypoint(IDictionary data) - { - if (!TryGetString(data, "callId", out var callId)) return; - - var call = DispatchClient.Snapshot.Calls.FirstOrDefault(c => c.Id == callId); - if (call is null || (call.X == 0f && call.Y == 0f)) return; - - API.SetNewWaypoint(call.X, call.Y); - ClientBrain.ShowNotification($"~b~Waypoint set~w~ to call ~y~#{call.Id}~w~."); - } - - private void OnResourceStop(string resourceName) - { - if (resourceName == API.GetCurrentResourceName() && _open) - API.SetNuiFocus(false, false); - } - - private static object BuildView() - { - var snapshot = DispatchClient.Snapshot; - var self = DispatchClient.LocalUnit; - var map = ConfigManager.Territories; - - return new - { - self = self is null ? null : new - { - self.ServerId, - self.Callsign, - self.Name, - Agency = map.FindAgency(self.Agency)?.Name ?? self.Department.ToString(), - Status = self.Status.ToString(), - self.CallId, - Territory = map.FindTerritory(self.Territory)?.Name, - }, - calls = snapshot.Calls.Select(call => new - { - call.Id, - call.Name, - Code = (int)call.Priority, - Department = call.Department.ToString(), - Territory = map.FindTerritory(call.Territory)?.Name, - HasLocation = call.X != 0f || call.Y != 0f, - Units = call.Units - .Select(id => snapshot.Units.FirstOrDefault(u => u.ServerId == id)?.Callsign) - .Where(callsign => callsign is not null) - .ToList(), - IsPrimary = self is not null && call.PrimaryUnit == self.ServerId, - }), - units = snapshot.Units - .OrderBy(u => u.Callsign, StringComparer.OrdinalIgnoreCase) - .Select(unit => new - { - unit.ServerId, - unit.Callsign, - unit.Name, - Agency = map.FindAgency(unit.Agency)?.Name ?? unit.Department.ToString(), - Status = unit.Status.ToString(), - unit.CallId, - Territory = map.FindTerritory(unit.Territory)?.Name, - }), - }; - } - - private static void Send(string type, object? payload) => - API.SendNuiMessage(JsonConvert.SerializeObject(new { type, payload }, JsonSettings)); - - private void RegisterCallback(string name, Action> handler) - { - API.RegisterNuiCallbackType(name); - EventHandlers[$"__cfx_nui:{name}"] += new Action, CallbackDelegate>((data, callback) => - { - try - { - handler(data); - } - catch (Exception ex) - { - Debug.WriteLine($"[MdtClient] NUI callback '{name}' failed: {ex.Message}"); - } - - callback("ok"); - }); - } - - private static bool TryGetString(IDictionary data, string key, out string value) - { - value = data.TryGetValue(key, out var raw) ? raw?.ToString() ?? string.Empty : string.Empty; - return value.Length > 0; - } - } -} diff --git a/FivePRS.Client/Menu/CompactMenu.cs b/FivePRS.Client/Menu/CompactMenu.cs new file mode 100644 index 0000000..34fe14c --- /dev/null +++ b/FivePRS.Client/Menu/CompactMenu.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace FivePRS.Client.Menu +{ + public sealed class MenuItem + { + public string Label { get; set; } = string.Empty; + + public string? Detail { get; set; } + + public bool Disabled { get; set; } + + public Func? Select { get; set; } + + public Func? Submenu { get; set; } + } + + public sealed class MenuPage + { + public string Title { get; set; } = string.Empty; + + public string? Subtitle { get; set; } + + public List Items { get; set; } = new(); + } + + public class CompactMenu : BaseScript + { + private const int ControlUp = 172; + private const int ControlDown = 173; + private const int ControlSelect = 191; + private const int ControlBack = 177; + private const int ControlScrollUp = 15; + private const int ControlScrollDown = 14; + + private const string SoundSet = "HUD_FRONTEND_DEFAULT_SOUNDSET"; + + private static readonly int[] BlockedControls = + { + 14, 15, 16, 17, 24, 25, 27, 37, 140, 141, 142, 172, 173, 174, 175, 176, 177, + 191, 199, 200, 201, 202, 241, 242, 257, 263, + }; + + private static readonly JsonSerializerSettings JsonSettings = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + }; + + private static readonly List> Pages = new(); + private static readonly List Indices = new(); + + private static MenuPage? _page; + private static Func? _keepOpen; + private static string? _icon; + private static bool _busy; + + public static bool IsOpen => _page is not null; + + public CompactMenu() + { + EventHandlers["onClientResourceStop"] += new Action(name => + { + if (name == API.GetCurrentResourceName()) Close(); + }); + + Tick += OnTick; + } + + public static void Open(Func root, Func? keepOpen = null, string? icon = null) + { + Pages.Clear(); + Indices.Clear(); + _keepOpen = keepOpen; + _icon = icon; + Push(root); + } + + public static void Close() + { + if (_page is null) return; + + Pages.Clear(); + Indices.Clear(); + _page = null; + _keepOpen = null; + _icon = null; + Send("hide", null); + } + + public static void Refresh() + { + if (Pages.Count == 0) return; + + _page = Pages[Pages.Count - 1](); + Indices[Indices.Count - 1] = Clamp(Indices[Indices.Count - 1], _page.Items.Count); + Render(); + } + + private static void Push(Func page) + { + Pages.Add(page); + Indices.Add(0); + _page = page(); + Indices[Indices.Count - 1] = FirstEnabled(_page); + Render(); + } + + private static void Pop() + { + if (Pages.Count <= 1) + { + Close(); + return; + } + + Pages.RemoveAt(Pages.Count - 1); + Indices.RemoveAt(Indices.Count - 1); + Refresh(); + } + + private async Task OnTick() + { + if (_page is null) + { + await Delay(250); + return; + } + + if (_keepOpen is not null && !_keepOpen()) + { + Close(); + return; + } + + foreach (var control in BlockedControls) + API.DisableControlAction(0, control, true); + + if (_busy) return; + + if (Pressed(ControlUp) || Pressed(ControlScrollUp)) + Move(-1); + else if (Pressed(ControlDown) || Pressed(ControlScrollDown)) + Move(1); + else if (Pressed(ControlBack)) + { + PlaySound("BACK"); + Pop(); + } + else if (Pressed(ControlSelect)) + await SelectAsync(); + } + + private static void Move(int step) + { + if (_page is null || _page.Items.Count == 0) return; + + var count = _page.Items.Count; + var index = Indices[Indices.Count - 1]; + index = ((index + step) % count + count) % count; + + Indices[Indices.Count - 1] = index; + PlaySound("NAV_UP_DOWN"); + Render(); + } + + private static async Task SelectAsync() + { + if (_page is null || _page.Items.Count == 0) return; + + var item = _page.Items[Indices[Indices.Count - 1]]; + if (item.Disabled) + { + PlaySound("ERROR"); + return; + } + + PlaySound("SELECT"); + + if (item.Submenu is not null) + { + Push(item.Submenu); + return; + } + + if (item.Select is null) return; + + _busy = true; + try + { + await item.Select(); + } + catch (Exception ex) + { + Debug.WriteLine($"[CompactMenu] '{item.Label}' failed: {ex}"); + } + finally + { + _busy = false; + } + + Refresh(); + } + + private static void Render() + { + if (_page is null) return; + + Send("show", new + { + _page.Title, + _page.Subtitle, + Icon = _icon, + Index = Indices[Indices.Count - 1], + Back = Pages.Count > 1, + Items = _page.Items.Select(i => new { i.Label, i.Detail, i.Disabled, HasSubmenu = i.Submenu is not null }), + }); + } + + private static int FirstEnabled(MenuPage page) + { + var index = page.Items.FindIndex(i => !i.Disabled); + return index < 0 ? 0 : index; + } + + private static int Clamp(int index, int count) => count == 0 ? 0 : Math.Max(0, Math.Min(index, count - 1)); + + private static bool Pressed(int control) => API.IsDisabledControlJustPressed(0, control); + + private static void PlaySound(string name) => API.PlaySoundFrontend(-1, name, SoundSet, true); + + private static void Send(string type, object? payload) => + API.SendNuiMessage(JsonConvert.SerializeObject(new { screen = "menu", type, payload }, JsonSettings)); + } +} diff --git a/FivePRS.Client/NuiFocus.cs b/FivePRS.Client/NuiFocus.cs new file mode 100644 index 0000000..e7b97e8 --- /dev/null +++ b/FivePRS.Client/NuiFocus.cs @@ -0,0 +1,21 @@ +using CitizenFX.Core.Native; + +namespace FivePRS.Client +{ + public static class NuiFocus + { + public static void Take() + { + API.SetNuiFocus(false, false); + API.SetNuiFocus(true, true); + } + + public static void Release() => API.SetNuiFocus(false, false); + + public static void EnsureTaken() + { + if (!API.IsNuiFocused()) + Take(); + } + } +} diff --git a/FivePRS.Client/PublicState.cs b/FivePRS.Client/PublicState.cs new file mode 100644 index 0000000..99b34db --- /dev/null +++ b/FivePRS.Client/PublicState.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using FivePRS.Client.Callouts; +using FivePRS.Client.Dispatch; +using FivePRS.Core.Config; +using FivePRS.Core.Models; + +namespace FivePRS.Client +{ + internal static class PublicState + { + public static IDictionary Build() + { + var player = ClientBrain.LocalPlayerData; + var map = ConfigManager.Territories; + + var state = new Dictionary + { + ["name"] = player.Name, + ["onDuty"] = player.IsOnDuty, + ["department"] = player.Department.ToString(), + ["rank"] = player.Rank, + }; + + if (!player.IsOnDuty) return state; + + state["agency"] = map.FindAgency(player.Agency)?.Name ?? player.Department.ToString(); + state["callsign"] = player.Callsign; + + var unit = DispatchClient.LocalUnit; + if (unit is null) return state; + + state["status"] = unit.Status.ToString(); + + var territory = map.FindTerritory(unit.Territory)?.Name; + if (territory is not null) state["territory"] = territory; + + var call = DispatchClient.Snapshot.Calls.FirstOrDefault(c => c.Id == unit.CallId); + var callName = CalloutDispatcher.ActiveCall?.Name ?? call?.Name; + if (unit.CallId is not null && callName is not null) + { + state["callId"] = unit.CallId; + state["call"] = callName; + } + + return state; + } + } +} diff --git a/FivePRS.Client/Stations/StationCatalog.cs b/FivePRS.Client/Stations/StationCatalog.cs new file mode 100644 index 0000000..306eb05 --- /dev/null +++ b/FivePRS.Client/Stations/StationCatalog.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using FivePRS.Client.Loadout; +using FivePRS.Client.VehicleSpawner; + +namespace FivePRS.Client.Stations +{ + public sealed class GearKit + { + public string Name { get; set; } = string.Empty; + public int MinRank { get; set; } = 1; + public IReadOnlyList Weapons { get; set; } = new List(); + } + + public sealed class GearWeapon + { + public string Label { get; set; } = string.Empty; + public int MinRank { get; set; } = 1; + public WeaponEntry Weapon { get; set; } = new(); + } + + public sealed class GearUniform + { + public string Name { get; set; } = string.Empty; + public int MinRank { get; set; } = 1; + public LoadoutDefinition Outfit { get; set; } = new(); + } + + public sealed class GearVehicle + { + public string Label { get; set; } = string.Empty; + public int MinRank { get; set; } = 1; + public PatrolVehicleConfig Config { get; set; } = new(); + } + + public sealed class StationCatalog + { + public List Kits { get; } = new(); + public List Weapons { get; } = new(); + public List Uniforms { get; } = new(); + public List Vehicles { get; } = new(); + } +} diff --git a/FivePRS.Client/Stations/StationMarkers.cs b/FivePRS.Client/Stations/StationMarkers.cs new file mode 100644 index 0000000..dad7cd6 --- /dev/null +++ b/FivePRS.Client/Stations/StationMarkers.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.Menu; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using FivePRS.Core.Text; + +namespace FivePRS.Client.Stations +{ + public class StationMarkers : BaseScript + { + private const float DrawDistance = 25f; + private const float InteractDistance = 1.5f; + private const int ScanIntervalMs = 500; + private const int MarkerType = 1; + + private readonly List _blips = new(); + private static readonly StationPoint[] Points = + { StationPoint.Duty, StationPoint.Armory, StationPoint.Locker, StationPoint.Garage }; + + private StationDef? _nearest; + private StationPoint _nearestPoint; + private float _nearestDistance; + private int _nextScan; + private bool _blipsCreated; + + public StationMarkers() + { + EventHandlers[EventNames.LocalDutyChanged] += new Action((_, __) => RefreshBlips()); + EventHandlers["onClientResourceStop"] += new Action(OnResourceStop); + + API.RegisterCommand(KeyCommands.Interact, new Action, string>((_, __, ___) => Interact()), false); + API.RegisterKeyMapping(KeyCommands.Interact, "FivePRS: Interact", "keyboard", "E"); + + Tick += OnTick; + } + + private async Task OnTick() + { + if (!_blipsCreated) + { + _blipsCreated = true; + RefreshBlips(); + } + + var now = API.GetGameTimer(); + if (now >= _nextScan) + { + Scan(); + _nextScan = now + ScanIntervalMs; + } + + if (_nearest is null) + { + await Delay(ScanIntervalMs); + return; + } + + DrawPoint(_nearest, _nearestPoint); + + if (_nearestDistance <= InteractDistance && !CompactMenu.IsOpen) + ClientBrain.ShowHelp($"{Binds.Interact} {Prompt(_nearest, _nearestPoint)}", 0); + } + + private static string Prompt(StationDef station, StationPoint point) => point switch + { + StationPoint.Armory => "Open the armory", + StationPoint.Locker => "Open the locker", + StationPoint.Garage => "Open the garage", + _ => StationMenu.CanUse(station) ? "Open the station menu" : "Open the duty menu", + }; + + private void Scan() + { + var ped = Game.PlayerPed; + if (ped is null || !ped.Exists() || ped.IsInVehicle()) + { + _nearest = null; + return; + } + + _nearest = null; + _nearestDistance = DrawDistance; + + var position = ped.Position; + + foreach (var station in ConfigManager.Stations.Valid) + { + var canUse = StationMenu.CanUse(station); + + foreach (var point in Points) + { + if (point != StationPoint.Duty && (!canUse || !StationService.HasOwnPoint(station, point))) continue; + + var distance = Vector3.Distance(position, StationService.PositionOf(station, point)); + if (distance < _nearestDistance) + { + _nearest = station; + _nearestPoint = point; + _nearestDistance = distance; + } + } + } + } + + private void Interact() + { + if (_nearest is null || CompactMenu.IsOpen) return; + + Scan(); + if (_nearest is not null && _nearestDistance <= InteractDistance) + StationMenu.Open(_nearest, _nearestPoint); + } + + private static void DrawPoint(StationDef station, StationPoint point) + { + var position = StationService.PositionOf(station, point); + var color = MarkerColor(station.Department); + + API.DrawMarker(MarkerType, position.X, position.Y, position.Z - 0.98f, + 0f, 0f, 0f, 0f, 0f, 0f, 1.2f, 1.2f, 0.5f, + color[0], color[1], color[2], 140, false, false, 2, false, null, null, false); + } + + private static int[] MarkerColor(Department department) => department switch + { + Department.Fire => new[] { 220, 60, 50 }, + Department.EMS => new[] { 60, 200, 120 }, + _ => new[] { 60, 130, 230 }, + }; + + private void RefreshBlips() + { + ClearBlips(); + + var config = ConfigManager.Stations; + if (config.Blips == StationBlipMode.Off) return; + + var player = ClientBrain.LocalPlayerData; + var stations = config.Blips == StationBlipMode.OnDuty + ? (player.IsOnDuty ? config.For(player.Department, player.Agency) : Enumerable.Empty()) + : config.Valid; + + foreach (var station in stations) + _blips.Add(CreateBlip(station)); + } + + private static int CreateBlip(StationDef station) + { + var position = StationService.PositionOf(station); + var style = station.Blip ?? StationsConfig.DefaultBlip(station.Department); + var blip = API.AddBlipForCoord(position.X, position.Y, position.Z); + + API.SetBlipSprite(blip, style.Sprite); + API.SetBlipColour(blip, style.Color); + API.SetBlipScale(blip, 0.8f); + API.SetBlipAsShortRange(blip, true); + API.BeginTextCommandSetBlipName("STRING"); + API.AddTextComponentSubstringPlayerName(station.Name); + API.EndTextCommandSetBlipName(blip); + + return blip; + } + + private void ClearBlips() + { + foreach (var handle in _blips) + { + var blip = handle; + if (API.DoesBlipExist(blip)) + API.RemoveBlip(ref blip); + } + + _blips.Clear(); + } + + private void OnResourceStop(string resourceName) + { + if (resourceName == API.GetCurrentResourceName()) + ClearBlips(); + } + } +} diff --git a/FivePRS.Client/Stations/StationMenu.cs b/FivePRS.Client/Stations/StationMenu.cs new file mode 100644 index 0000000..fcea489 --- /dev/null +++ b/FivePRS.Client/Stations/StationMenu.cs @@ -0,0 +1,270 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Client.Agency; +using FivePRS.Client.Loadout; +using FivePRS.Client.Menu; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; + +namespace FivePRS.Client.Stations +{ + public static class StationMenu + { + private const float StayRadius = 3f; + private const float ReturnRadius = 60f; + + private static readonly StationPoint[] GearPoints = { StationPoint.Armory, StationPoint.Locker, StationPoint.Garage }; + + public static bool CanUse(StationDef station) + { + var player = ClientBrain.LocalPlayerData; + return BaseAgency.Active is not null && player.IsOnDuty && station.Serves(player.Department, player.Agency); + } + + public static void Open(StationDef station, StationPoint point) + { + if (!CanUse(station)) + { + if (point == StationPoint.Duty) + BaseScript.TriggerEvent(EventNames.LocalOpenMenu, "duty"); + else + Notify(station.Name, "Go on duty with this station's department to use it."); + return; + } + + var anchor = StationService.PositionOf(station, point); + + CompactMenu.Open(() => PageFor(station, point), () => + { + var ped = Game.PlayerPed; + return ped.Exists() && !ped.IsDead && !ped.IsInVehicle() && + Vector3.Distance(ped.Position, anchor) <= StayRadius && CanUse(station); + }, IconFor(station.Department)); + } + + private static string IconFor(Department department) + { + var icons = ConfigManager.Settings.Branding.Departments; + return department switch + { + Department.EMS => icons.Ems, + Department.Fire => icons.Fire, + _ => icons.Police, + }; + } + + private static MenuPage PageFor(StationDef station, StationPoint point) => point switch + { + StationPoint.Armory => Armory(station), + StationPoint.Locker => Locker(station), + StationPoint.Garage => Garage(station), + _ => Root(station), + }; + + private static MenuPage Root(StationDef station) + { + var page = new MenuPage { Title = station.Name, Subtitle = ClientBrain.LocalPlayerData.Callsign }; + + foreach (var point in GearPoints.Where(p => !StationService.HasOwnPoint(station, p))) + page.Items.Add(new MenuItem { Label = point.ToString(), Submenu = () => PageFor(station, point) }); + + page.Items.Add(new MenuItem + { + Label = "Duty menu", + Select = () => + { + CompactMenu.Close(); + BaseScript.TriggerEvent(EventNames.LocalOpenMenu, "duty"); + return Done; + }, + }); + + return page; + } + + private static MenuPage Armory(StationDef station) + { + var catalog = Catalog(); + var page = new MenuPage { Title = "Armory", Subtitle = station.Name }; + + if (catalog.Kits.Count > 0) + page.Items.Add(new MenuItem { Label = "Kits", Submenu = () => Kits(catalog) }); + + if (catalog.Weapons.Count > 0) + page.Items.Add(new MenuItem { Label = "Weapons", Submenu = () => Weapons(station) }); + + page.Items.Add(Action("Body armour", null, () => + { + LoadoutManager.GiveArmour(); + Notify("Armory", "Body armour equipped."); + })); + + page.Items.Add(Action("Refill ammo", null, () => + { + var refilled = LoadoutManager.RefillAmmo(); + Notify("Armory", refilled > 0 ? "Ammo refilled." : "Your ammo is already full."); + })); + + page.Items.Add(Action("Return weapons", null, () => + { + LoadoutManager.RemoveWeapons(); + Notify("Armory", "Weapons returned."); + })); + + return page; + } + + private static MenuPage Kits(StationCatalog catalog) + { + var rank = ClientBrain.LocalPlayerData.Rank; + var page = new MenuPage { Title = "Kits", Subtitle = "Replaces your current weapons" }; + + foreach (var kit in catalog.Kits) + { + var locked = rank < kit.MinRank; + page.Items.Add(new MenuItem + { + Label = kit.Name, + Detail = locked ? $"Rank {kit.MinRank}" : $"{kit.Weapons.Count} items", + Disabled = locked, + Select = async () => + { + await LoadoutManager.GiveWeaponsAsync(kit.Weapons, true); + Notify("Armory", $"{kit.Name} kit issued."); + }, + }); + } + + return page; + } + + private static MenuPage Weapons(StationDef station) + { + var rank = ClientBrain.LocalPlayerData.Rank; + var page = new MenuPage { Title = "Weapons", Subtitle = station.Name }; + + foreach (var weapon in Catalog().Weapons) + { + var locked = rank < weapon.MinRank; + page.Items.Add(new MenuItem + { + Label = weapon.Label, + Detail = locked ? $"Rank {weapon.MinRank}" : LoadoutManager.HasWeapon(weapon.Weapon.Hash) ? "Issued" : null, + Disabled = locked, + Select = () => + { + LoadoutManager.GiveWeapon(weapon.Weapon); + Notify("Armory", $"{weapon.Label} issued."); + return Done; + }, + }); + } + + return page; + } + + private static MenuPage Locker(StationDef station) + { + var rank = ClientBrain.LocalPlayerData.Rank; + var page = new MenuPage { Title = "Locker", Subtitle = station.Name }; + + foreach (var uniform in Catalog().Uniforms) + { + var locked = rank < uniform.MinRank; + page.Items.Add(new MenuItem + { + Label = uniform.Name, + Detail = locked ? $"Rank {uniform.MinRank}" : null, + Disabled = locked, + Select = async () => + { + await LoadoutManager.WearAsync(uniform.Outfit); + Notify("Locker", $"Changed into the {uniform.Name.ToLowerInvariant()}."); + }, + }); + } + + page.Items.Add(new MenuItem + { + Label = "Civilian clothes", + Disabled = !LoadoutManager.IsInUniform, + Select = async () => + { + await LoadoutManager.RestoreClothingAsync(); + Notify("Locker", "Changed into your own clothes."); + }, + }); + + return page; + } + + private static MenuPage Garage(StationDef station) + { + var agency = BaseAgency.Active; + var rank = ClientBrain.LocalPlayerData.Rank; + var current = agency?.Vehicles.Current; + var page = new MenuPage + { + Title = "Garage", + Subtitle = current is null ? station.Name : "Replaces your current vehicle", + }; + + foreach (var vehicle in Catalog().Vehicles) + { + var locked = rank < vehicle.MinRank; + page.Items.Add(new MenuItem + { + Label = vehicle.Label, + Detail = locked ? $"Rank {vehicle.MinRank}" : null, + Disabled = locked || agency is null, + Select = async () => + { + var spawned = await agency!.Vehicles.SpawnAsync(vehicle.Config, StationService.ParkingSpot(station), false); + Notify("Garage", spawned is not null + ? $"Your {vehicle.Label} is parked outside." + : "~r~The vehicle could not be spawned."); + }, + }); + } + + var garage = StationService.PositionOf(station, StationPoint.Garage); + var nearby = current is not null && Vector3.Distance(current.Position, garage) <= ReturnRadius; + page.Items.Add(new MenuItem + { + Label = "Return vehicle", + Detail = current is null ? "None out" : nearby ? null : "Bring it back first", + Disabled = !nearby, + Select = () => + { + agency!.Vehicles.Despawn(); + Notify("Garage", "Vehicle returned."); + return Done; + }, + }); + + return page; + } + + private static StationCatalog Catalog() => + BaseAgency.Active?.BuildCatalog(ClientBrain.LocalPlayerData) ?? new StationCatalog(); + + private static MenuItem Action(string label, string? detail, Action action) => new() + { + Label = label, + Detail = detail, + Select = () => + { + action(); + return Done; + }, + }; + + private static Task Done => Task.FromResult(0); + + private static void Notify(string subject, string message) => + ClientBrain.ShowNotification(message, subject); + } +} diff --git a/FivePRS.Client/Stations/StationService.cs b/FivePRS.Client/Stations/StationService.cs new file mode 100644 index 0000000..2f0337e --- /dev/null +++ b/FivePRS.Client/Stations/StationService.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Client.VehicleSpawner; +using FivePRS.Core.Config; +using FivePRS.Core.Models; + +namespace FivePRS.Client.Stations +{ + public enum DutyStartMode + { + Here, + Teleport, + Drive + } + + public static class StationService + { + public const float NearbyRadius = 75f; + public const float ArrivalRadius = 150f; + + private const float ParkingClearance = 3f; + + private const float DeriveRadius = 200f; + + private static readonly Dictionary DerivedGarages = new(); + + private static DutyStartMode _pendingMode; + private static string? _pendingStationId; + + public static void SetPendingStart(DutyStartMode mode, string? stationId) + { + _pendingMode = mode; + _pendingStationId = stationId; + } + + public static DutyStartMode TakePendingStart(out StationDef? station) + { + var mode = _pendingMode; + station = ConfigManager.Stations.Find(_pendingStationId); + + _pendingMode = DutyStartMode.Here; + _pendingStationId = null; + + return station is null ? DutyStartMode.Here : mode; + } + + public static Vector3 PositionOf(StationDef station) => + new(station.DutyPoint[0], station.DutyPoint[1], station.DutyPoint[2]); + + public static Vector3 PositionOf(StationDef station, StationPoint point) => + PointPosition(station, point) ?? PositionOf(station); + + public static bool HasOwnPoint(StationDef station, StationPoint point) => + point != StationPoint.Duty && PointPosition(station, point) is not null; + + public static Vector3? PointPosition(StationDef station, StationPoint point) + { + var configured = station.PointFor(point); + if (configured is not null) return new Vector3(configured[0], configured[1], configured[2]); + if (point != StationPoint.Garage) return null; + + var spot = station.Parking.FirstOrDefault(p => p.Length >= 3); + if (spot is not null) return new Vector3(spot[0], spot[1], spot[2]); + + return DerivedGarage(station); + } + + private static Vector3? DerivedGarage(StationDef station) + { + if (DerivedGarages.TryGetValue(station.Id, out var cached)) return cached; + if (DistanceTo(station) > DeriveRadius) return null; + + var duty = PositionOf(station); + var node = Vector3.Zero; + var heading = 0f; + if (!API.GetClosestVehicleNodeWithHeading(duty.X, duty.Y, duty.Z, ref node, ref heading, 1, 3f, 0)) return null; + + var kerb = Vector3.Zero; + var position = API.GetSafeCoordForPed(node.X, node.Y, node.Z, true, ref kerb, 16) ? kerb : node; + + DerivedGarages[station.Id] = position; + return position; + } + + public static float DistanceTo(StationDef station) => + Vector3.Distance(Game.PlayerPed.Position, PositionOf(station)); + + public static StationDef? Nearby(Department department, string? agencyId) => + ConfigManager.Stations.For(department, agencyId) + .Select(s => new { Station = s, Distance = DistanceTo(s) }) + .Where(s => s.Distance <= NearbyRadius) + .OrderBy(s => s.Distance) + .Select(s => s.Station) + .FirstOrDefault(); + + public static void SetRoute(StationDef station) + { + var position = PositionOf(station); + API.SetNewWaypoint(position.X, position.Y); + } + + public static Task TeleportAsync(StationDef station) => Teleporter.ToAsync(PositionOf(station), station.Heading); + + public static SpawnPoint ParkingSpot(StationDef station) + { + foreach (var spot in station.Parking.Where(p => p.Length >= 4)) + { + if (!API.IsPositionOccupied(spot[0], spot[1], spot[2], ParkingClearance, false, true, false, false, false, 0, false)) + return new SpawnPoint(new Vector3(spot[0], spot[1], spot[2]), spot[3]); + } + + var position = PositionOf(station, StationPoint.Garage); + var node = Vector3.Zero; + var heading = 0f; + + return API.GetClosestVehicleNodeWithHeading(position.X, position.Y, position.Z, ref node, ref heading, 1, 3f, 0) + ? new SpawnPoint(node, heading) + : new SpawnPoint(position, station.Heading); + } + } +} diff --git a/FivePRS.Client/Teleporter.cs b/FivePRS.Client/Teleporter.cs new file mode 100644 index 0000000..927b054 --- /dev/null +++ b/FivePRS.Client/Teleporter.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; + +namespace FivePRS.Client +{ + public static class Teleporter + { + private const int FadeMs = 400; + private const int CollisionTimeoutMs = 5000; + + public static async Task ToAsync(Vector3 position, float heading) + { + var ped = Game.PlayerPed.Handle; + + API.DoScreenFadeOut(FadeMs); + await BaseScript.Delay(FadeMs); + + API.RequestCollisionAtCoord(position.X, position.Y, position.Z); + API.SetEntityCoords(ped, position.X, position.Y, position.Z, false, false, false, false); + API.SetEntityHeading(ped, heading); + + var until = API.GetGameTimer() + CollisionTimeoutMs; + while (!API.HasCollisionLoadedAroundEntity(ped) && API.GetGameTimer() < until) + await BaseScript.Delay(0); + + API.DoScreenFadeIn(FadeMs); + } + } +} diff --git a/FivePRS.Client/Terminal/AppScreen.cs b/FivePRS.Client/Terminal/AppScreen.cs new file mode 100644 index 0000000..2f4c4b9 --- /dev/null +++ b/FivePRS.Client/Terminal/AppScreen.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Linq; + +namespace FivePRS.Client.Terminal +{ + public sealed class AppScreen + { + public string? Title { get; set; } + + public List Blocks { get; set; } = new(); + + public AppScreen Add(AppBlock block) + { + Blocks.Add(block); + return this; + } + } + + public sealed class AppBlock + { + public string Type { get; set; } = "text"; + + public string? Title { get; set; } + + public string? Text { get; set; } + + public bool Muted { get; set; } + + public string? Layout { get; set; } + + public string? Action { get; set; } + + public string? Submit { get; set; } + + public List Items { get; set; } = new(); + + public List Fields { get; set; } = new(); + + public static AppBlock Section(string title) => new() { Type = "section", Title = title }; + + public static AppBlock Paragraph(string text, bool muted = false) => new() { Type = "text", Text = text, Muted = muted }; + + public static AppBlock Buttons(params AppItem[] buttons) => new() { Type = "buttons", Items = buttons.ToList() }; + + public static AppBlock List(IEnumerable items, bool grid = false) => + new() { Type = "list", Layout = grid ? "grid" : "rows", Items = items.ToList() }; + + public static AppBlock Form(string action, string submit, params AppField[] fields) => + new() { Type = "form", Action = action, Submit = submit, Fields = fields.ToList() }; + } + + public sealed class AppItem + { + public string Title { get; set; } = string.Empty; + + public string? Detail { get; set; } + + public string? Image { get; set; } + + public string? Action { get; set; } + + public Dictionary Data { get; set; } = new(); + + public bool Active { get; set; } + + public bool Disabled { get; set; } + + public string? Style { get; set; } + } + + public sealed class AppField + { + public string Name { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; + + public string? Placeholder { get; set; } + + public string? Value { get; set; } + + public string Type { get; set; } = "text"; + } +} diff --git a/FivePRS.Client/Terminal/SettingsApp.cs b/FivePRS.Client/Terminal/SettingsApp.cs new file mode 100644 index 0000000..db8e1fa --- /dev/null +++ b/FivePRS.Client/Terminal/SettingsApp.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using FivePRS.Client.App; +using FivePRS.Client.Dispatch; +using FivePRS.Core.Config; + +namespace FivePRS.Client.Terminal +{ + public class SettingsApp : TerminalApp + { + public override string Id => "settings"; + + public override string Label => "Settings"; + + 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() + { + 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 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) + { + 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", + })); + } + + return screen; + } + + public override void OnAction(string action, IDictionary data) + { + switch (action) + { + 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; + } + } + + private static AppItem Toggle(string label, bool active, bool enabled) => new() + { + Title = label, + Action = "aiCallouts", + Data = new Dictionary { ["enabled"] = enabled }, + Active = active, + }; + } +} diff --git a/FivePRS.Client/Terminal/TerminalApp.cs b/FivePRS.Client/Terminal/TerminalApp.cs new file mode 100644 index 0000000..1fc8837 --- /dev/null +++ b/FivePRS.Client/Terminal/TerminalApp.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using CitizenFX.Core; +using FivePRS.Core.Models; + +namespace FivePRS.Client.Terminal +{ + public abstract class TerminalApp : BaseScript + { + protected TerminalApp() + { + TerminalApps.Register(this); + } + + public abstract string Id { get; } + + public abstract string Label { get; } + + public virtual string Icon => string.Empty; + + public virtual string Color => "#334155"; + + public virtual int Order => 500; + + public virtual string? Page => null; + + public virtual string? Badge => null; + + public virtual bool IsAvailable(PlayerData player) => true; + + public virtual AppScreen? BuildScreen() => null; + + public virtual void OnAction(string action, IDictionary data) + { + } + + public virtual void OnOpened() + { + } + + public virtual void OnClosed() + { + } + + protected static PlayerData Player => ClientBrain.LocalPlayerData; + + protected void Refresh() => TerminalApps.NotifyChanged(); + + protected void Send(string type, object? payload) => TerminalApps.SendToApp(Id, type, payload); + } +} diff --git a/FivePRS.Client/Terminal/TerminalApps.cs b/FivePRS.Client/Terminal/TerminalApps.cs new file mode 100644 index 0000000..a95e29e --- /dev/null +++ b/FivePRS.Client/Terminal/TerminalApps.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Events; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace FivePRS.Client.Terminal +{ + public class TerminalApps : BaseScript + { + private const int MaxIdLength = 32; + + private static readonly JsonSerializerSettings JsonSettings = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + }; + + private static readonly List Apps = new(); + private static string? _openId; + + public static event Action? Changed; + + public TerminalApps() + { + Exports.Add("registerApp", new Action>(RegisterFromResource)); + Exports.Add("unregisterApp", new Action(id => Remove(Find(id, API.GetInvokingResource())))); + Exports.Add("setAppAvailable", new Action((id, available) => Update(id, app => app.Available = available))); + Exports.Add("setAppBadge", new Action((id, badge) => Update(id, app => app.Badge = string.IsNullOrEmpty(badge) ? null : badge))); + Exports.Add("setAppView", new Action((id, view) => Update(id, app => app.View = ToScreen(view)))); + Exports.Add("sendAppMessage", new Action((id, type, payload) => + { + if (Find(id, API.GetInvokingResource()) is not null) SendToApp(id, type, payload); + })); + + EventHandlers["onClientResourceStop"] += new Action(resource => + { + if (Apps.RemoveAll(app => app.Resource == resource) > 0) NotifyChanged(); + }); + } + + 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."); + return; + } + + var page = app.Page is null ? null : $"https://cfx-nui-{API.GetCurrentResourceName()}/{app.Page.TrimStart('/')}"; + Apps.Add(new RegisteredApp { Id = app.Id, Instance = app, Page = page }); + NotifyChanged(); + } + + private static void RegisterFromResource(IDictionary definition) + { + var resource = API.GetInvokingResource(); + var id = Text(definition, "id"); + + 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."); + return; + } + + Apps.RemoveAll(existing => existing.Id == id); + Apps.Add(new RegisteredApp + { + Id = id, + Resource = resource, + Label = Text(definition, "label") is { Length: > 0 } label ? label : id, + Icon = Text(definition, "icon"), + Color = Text(definition, "color") is { Length: > 0 } color ? color : "#334155", + Order = definition.TryGetValue("order", out var order) && int.TryParse(order?.ToString(), out var value) ? value : 500, + Page = Text(definition, "page") is { Length: > 0 } page ? page : null, + Available = !definition.TryGetValue("available", out var available) || available is not bool flag || flag, + }); + NotifyChanged(); + } + + public static void NotifyChanged() => Changed?.Invoke(); + + public static void SendToApp(string id, string type, object? payload) => + API.SendNuiMessage(JsonConvert.SerializeObject( + new { screen = "app", type = "appMessage", payload = new { App = id, Type = type, Payload = payload } }, JsonSettings)); + + internal static void SetOpen(string? id) + { + var next = Apps.FirstOrDefault(app => app.Id == id); + if (next?.Id == _openId) return; + + Apps.FirstOrDefault(app => app.Id == _openId)?.Instance?.OnClosed(); + _openId = next?.Id; + next?.Instance?.OnOpened(); + } + + internal static void Act(string id, string action, IDictionary data) + { + var app = Apps.FirstOrDefault(entry => entry.Id == id); + if (app is null || action.Length == 0) return; + + if (app.Instance is not null) + app.Instance.OnAction(action, data); + else + TriggerEvent(EventNames.PublicAppAction, id, action, data); + } + + internal static object BuildList() + { + var player = ClientBrain.LocalPlayerData; + return Apps + .Where(app => app.Instance?.IsAvailable(player) ?? app.Available) + .Select(app => new + { + app.Id, + Label = app.Instance?.Label ?? app.Label, + Icon = app.Instance?.Icon ?? app.Icon, + Color = app.Instance?.Color ?? app.Color, + Order = app.Instance?.Order ?? app.Order, + Badge = app.Instance?.Badge ?? app.Badge, + app.Page, + }) + .OrderBy(app => app.Order) + .ToList(); + } + + internal static object? BuildOpenView() + { + var app = Apps.FirstOrDefault(entry => entry.Id == _openId); + if (app is null || app.Page is not null) return null; + + var screen = app.Instance is not null ? app.Instance.BuildScreen() : app.View; + return screen is null ? null : new { App = app.Id, Screen = screen }; + } + + private static void Update(string id, Action change) + { + var app = Find(id, API.GetInvokingResource()); + if (app is null) return; + + change(app); + NotifyChanged(); + } + + private static RegisteredApp? Find(string id, string resource) => + Apps.FirstOrDefault(app => app.Id == id && app.Resource == resource); + + private static void Remove(RegisteredApp? app) + { + if (app is not null && Apps.Remove(app)) NotifyChanged(); + } + + private static AppScreen? ToScreen(object? view) + { + if (view is null) return null; + + try + { + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(view)); + } + catch (JsonException ex) + { + Debug.WriteLine($"[FivePRS] Invalid terminal app view: {ex.Message}"); + return null; + } + } + + private static bool IsValidId(string? id) => + id is not null && id.Length > 0 && id.Length <= MaxIdLength && + id.All(c => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'); + + private static string Text(IDictionary data, string key) => + data.TryGetValue(key, out var value) ? value?.ToString() ?? string.Empty : string.Empty; + + private sealed class RegisteredApp + { + public string Id { get; set; } = string.Empty; + public string? Resource { get; set; } + public TerminalApp? Instance { get; set; } + public string Label { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public string Color { get; set; } = "#334155"; + public int Order { get; set; } = 500; + public string? Page { get; set; } + public string? Badge { get; set; } + public bool Available { get; set; } = true; + public AppScreen? View { get; set; } + } + } +} diff --git a/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs b/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs index b452189..fe0341e 100644 --- a/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs +++ b/FivePRS.Client/Vehicle/PatrolVehicleSpawner.cs @@ -10,13 +10,13 @@ public sealed class PatrolVehicleConfig { public IReadOnlyList ModelPool { get; set; } = new[] { "police" }; - public int PrimaryColor { get; set; } = 0; + public int PrimaryColor { get; set; } = -1; - public int SecondaryColor { get; set; } = 0; + public int SecondaryColor { get; set; } = -1; public int[]? NeonColor { get; set; } - public int DirtLevel { get; set; } = 0; + public int DirtLevel { get; set; } = -1; public int Livery { get; set; } = -1; @@ -27,7 +27,7 @@ public sealed class PatrolVehicleConfig public string PlateText { get; set; } = string.Empty; } - public readonly struct SpawnPoint + public sealed class SpawnPoint { public Vector3 Position { get; } public float Heading { get; } @@ -41,26 +41,16 @@ public SpawnPoint(Vector3 position, float heading) public sealed class PatrolVehicleSpawner { - private static readonly SpawnPoint[] StationSpawns = - { - new(new Vector3( 457.1f, -1016.8f, 28.0f), 90f), - new(new Vector3( 441.8f, -986.0f, 30.7f), 0f), - new(new Vector3(-1108.0f, -845.0f, 19.3f), 120f), - new(new Vector3( 372.5f, -1608.9f, 29.3f), 260f), - new(new Vector3(1853.5f, 3686.8f, 34.3f), 30f), - new(new Vector3(-448.8f, 6012.2f, 31.5f), 240f), - }; - private static readonly Random Rng = new(); - private const float StationSearchRadius = 600f; - private CitizenFX.Core.Vehicle? _vehicle; private Blip? _vehicleBlip; public bool HasVehicle => _vehicle is not null && _vehicle.Exists(); - public async Task SpawnAsync(PatrolVehicleConfig config) + public CitizenFX.Core.Vehicle? Current => HasVehicle ? _vehicle : null; + + public async Task SpawnAsync(PatrolVehicleConfig config, SpawnPoint spawn, bool warpIn) { Despawn(); @@ -80,8 +70,6 @@ public sealed class PatrolVehicleSpawner return null; } - var spawn = FindSpawnPoint(); - _vehicle = await CitizenFX.Core.World.CreateVehicle(model, spawn.Position, spawn.Heading); model.MarkAsNoLongerNeeded(); @@ -92,7 +80,7 @@ public sealed class PatrolVehicleSpawner } ConfigureVehicle(_vehicle, config); - WarpPlayerIn(_vehicle); + if (warpIn) WarpPlayerIn(_vehicle); AddVehicleBlip(_vehicle, modelName); Debug.WriteLine($"[PatrolVehicleSpawner] Spawned '{modelName}' at {spawn.Position}."); @@ -113,27 +101,9 @@ public void Despawn() _vehicle = null; } - private static SpawnPoint FindSpawnPoint() + public static SpawnPoint RoadSpawnPoint() { - var playerPos = Game.PlayerPed.Position; - - SpawnPoint? nearest = null; - var nearestDist = StationSearchRadius; - - foreach (var point in StationSpawns) - { - var dist = Vector3.Distance(playerPos, point.Position); - if (dist < nearestDist) - { - nearest = point; - nearestDist = dist; - } - } - - if (nearest.HasValue) - return nearest.Value; - - var streetPos = CitizenFX.Core.World.GetNextPositionOnStreet(playerPos); + var streetPos = CitizenFX.Core.World.GetNextPositionOnStreet(Game.PlayerPed.Position); return new SpawnPoint(streetPos, API.GetEntityHeading(Game.PlayerPed.Handle)); } @@ -147,7 +117,8 @@ private static void ConfigureVehicle(CitizenFX.Core.Vehicle vehicle, PatrolVehic if (config.PrimaryColor >= 0 && config.SecondaryColor >= 0) API.SetVehicleColours(h, config.PrimaryColor, config.SecondaryColor); - API.SetVehicleDirtLevel(h, config.DirtLevel); + if (config.DirtLevel >= 0) + API.SetVehicleDirtLevel(h, config.DirtLevel); if (config.Livery >= 0) API.SetVehicleLivery(h, config.Livery); diff --git a/FivePRS.Core/Civilian/Appearance.cs b/FivePRS.Core/Civilian/Appearance.cs new file mode 100644 index 0000000..d404ac1 --- /dev/null +++ b/FivePRS.Core/Civilian/Appearance.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FivePRS.Core.Civilian +{ + public sealed class HeadBlend + { + public int ShapeFirst { get; set; } + public int ShapeSecond { get; set; } + public int SkinFirst { get; set; } + public int SkinSecond { get; set; } + public float ShapeMix { get; set; } = 0.5f; + public float SkinMix { get; set; } = 0.5f; + } + + public sealed class HeadOverlay + { + public int Index { get; set; } + public int Style { get; set; } = -1; + public float Opacity { get; set; } = 1f; + public int Color { get; set; } + public int SecondColor { get; set; } + } + + public sealed class ClothingItem + { + public int Slot { get; set; } + public int Drawable { get; set; } + public int Texture { get; set; } + } + + public sealed class CharacterAppearance + { + public string Model { get; set; } = AppearanceRules.MaleFreemode; + public HeadBlend HeadBlend { get; set; } = new(); + public List FaceFeatures { get; set; } = new(); + public List Overlays { get; set; } = new(); + public int HairColor { get; set; } + public int HairHighlight { get; set; } + public int EyeColor { get; set; } + public List Components { get; set; } = new(); + public List Props { get; set; } = new(); + + public bool IsFreemode => AppearanceRules.IsFreemode(Model); + } + + public static class AppearanceRules + { + public const string MaleFreemode = "mp_m_freemode_01"; + public const string FemaleFreemode = "mp_f_freemode_01"; + + public const int ParentCount = 46; + public const int FaceFeatureCount = 20; + public const int OverlayCount = 13; + public const int ComponentCount = 12; + public const int MaxHairColor = 63; + public const int MaxEyeColor = 31; + public const int MaxDrawable = 1023; + public const int MaxTexture = 63; + public const int MaxOverlayStyle = 254; + public const int MaxSerializedSize = 16 * 1024; + + public static readonly int[] PropSlots = { 0, 1, 2, 6, 7 }; + + private const int MaxModelLength = 40; + + public static bool IsFreemode(string? model) => + string.Equals(model, MaleFreemode, StringComparison.OrdinalIgnoreCase) || + string.Equals(model, FemaleFreemode, StringComparison.OrdinalIgnoreCase); + + public static bool IsAllowedModel(string? model, IEnumerable standardPeds) => + IsModelName(model) && + (IsFreemode(model) || standardPeds.Any(p => string.Equals(p, model, StringComparison.OrdinalIgnoreCase))); + + private static bool IsModelName(string? model) => + model is not null && model.Length > 0 && model.Length <= MaxModelLength && + model.All(c => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'); + + public static CharacterAppearance? Sanitize(CharacterAppearance? appearance, IEnumerable standardPeds) + { + if (appearance is null) return null; + + var model = appearance.Model?.Trim().ToLowerInvariant(); + if (!IsAllowedModel(model, standardPeds)) return null; + + var blend = appearance.HeadBlend ?? new HeadBlend(); + + return new CharacterAppearance + { + Model = model!, + HeadBlend = new HeadBlend + { + ShapeFirst = Clamp(blend.ShapeFirst, 0, ParentCount - 1), + ShapeSecond = Clamp(blend.ShapeSecond, 0, ParentCount - 1), + SkinFirst = Clamp(blend.SkinFirst, 0, ParentCount - 1), + SkinSecond = Clamp(blend.SkinSecond, 0, ParentCount - 1), + ShapeMix = Clamp(blend.ShapeMix, 0f, 1f), + SkinMix = Clamp(blend.SkinMix, 0f, 1f), + }, + FaceFeatures = (appearance.FaceFeatures ?? new List()) + .Take(FaceFeatureCount) + .Select(f => Clamp(f, -1f, 1f)) + .ToList(), + Overlays = (appearance.Overlays ?? new List()) + .Where(o => o is not null && o.Index >= 0 && o.Index < OverlayCount) + .GroupBy(o => o.Index) + .Select(g => g.Last()) + .Select(o => new HeadOverlay + { + Index = o.Index, + Style = Clamp(o.Style, -1, MaxOverlayStyle), + Opacity = Clamp(o.Opacity, 0f, 1f), + Color = Clamp(o.Color, 0, MaxHairColor), + SecondColor = Clamp(o.SecondColor, 0, MaxHairColor), + }) + .OrderBy(o => o.Index) + .ToList(), + HairColor = Clamp(appearance.HairColor, 0, MaxHairColor), + HairHighlight = Clamp(appearance.HairHighlight, 0, MaxHairColor), + EyeColor = Clamp(appearance.EyeColor, 0, MaxEyeColor), + Components = Clothing(appearance.Components, slot => slot >= 0 && slot < ComponentCount, 0), + Props = Clothing(appearance.Props, slot => PropSlots.Contains(slot), -1), + }; + } + + private static List Clothing(IEnumerable? items, Func validSlot, int minimum) => + (items ?? Enumerable.Empty()) + .Where(i => i is not null && validSlot(i.Slot)) + .GroupBy(i => i.Slot) + .Select(g => g.Last()) + .Select(i => new ClothingItem + { + Slot = i.Slot, + Drawable = Clamp(i.Drawable, minimum, MaxDrawable), + Texture = Clamp(i.Texture, minimum, MaxTexture), + }) + .OrderBy(i => i.Slot) + .ToList(); + + private static int Clamp(int value, int min, int max) => Math.Max(min, Math.Min(max, value)); + + private static float Clamp(float value, float min, float max) => + float.IsNaN(value) ? min : Math.Max(min, Math.Min(max, value)); + } +} diff --git a/FivePRS.Core/Civilian/CivilianModels.cs b/FivePRS.Core/Civilian/CivilianModels.cs new file mode 100644 index 0000000..62fea6c --- /dev/null +++ b/FivePRS.Core/Civilian/CivilianModels.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace FivePRS.Core.Civilian +{ + public enum LicenseStatus + { + Valid = 0, + Suspended = 1, + Revoked = 2, + } + + public enum VehicleStatus + { + Valid = 0, + Stolen = 1, + } + + public sealed class CharacterInfo + { + public int Id { get; set; } + public string OwnerLicense { get; set; } = string.Empty; + 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 DateTime CreatedAt { get; set; } + public DateTime LastUsedAt { get; set; } + + public string FullName => $"{FirstName} {LastName}"; + } + + public sealed class LicenseInfo + { + public int CharacterId { get; set; } + public string Type { get; set; } = string.Empty; + public LicenseStatus Status { get; set; } + public DateTime IssuedAt { get; set; } + } + + public sealed class VehicleInfo + { + public int Id { get; set; } + public int CharacterId { get; set; } + public string Plate { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public VehicleStatus Status { get; set; } + public DateTime RegisteredAt { get; set; } + } + + public sealed class CivilianState + { + public List Characters { get; set; } = new(); + public int? ActiveCharacterId { get; set; } + public List Licenses { get; set; } = new(); + public List Vehicles { get; set; } = new(); + public List Records { get; set; } = new(); + public int MaxCharacters { get; set; } + public int MaxVehicles { get; set; } + } + + 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; } + } +} diff --git a/FivePRS.Core/Civilian/CivilianRules.cs b/FivePRS.Core/Civilian/CivilianRules.cs new file mode 100644 index 0000000..8cf6407 --- /dev/null +++ b/FivePRS.Core/Civilian/CivilianRules.cs @@ -0,0 +1,93 @@ +using System; +using System.Globalization; + +namespace FivePRS.Core.Civilian +{ + public static class CivilianRules + { + public const int MaxNameLength = 24; + public const int MaxPlateLength = 8; + public const int MinimumAge = 16; + public const int MaxDescriptionLength = 500; + public const int MinSearchLength = 2; + public const int MaxEmergencyLength = 300; + + public static readonly string[] Genders = { "Male", "Female", "Other" }; + + public static bool TryNormalizeName(string? input, out string name) + { + name = (input ?? string.Empty).Trim(); + if (name.Length == 0 || name.Length > MaxNameLength) return false; + + foreach (var c in name) + { + if (!char.IsLetter(c) && c != ' ' && c != '-' && c != '\'') return false; + } + + name = char.ToUpperInvariant(name[0]) + name.Substring(1); + return true; + } + + public static bool TryParseDateOfBirth(string? input, DateTime today, out string dateOfBirth) + { + dateOfBirth = string.Empty; + if (!DateTime.TryParseExact((input ?? string.Empty).Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var date)) + { + return false; + } + + if (date.Year < 1900 || date > today.AddYears(-MinimumAge)) return false; + + dateOfBirth = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + return true; + } + + public static bool TryNormalizeGender(string? input, out string gender) + { + gender = string.Empty; + foreach (var option in Genders) + { + if (string.Equals(option, (input ?? string.Empty).Trim(), StringComparison.OrdinalIgnoreCase)) + { + gender = option; + return true; + } + } + return false; + } + + public static bool TryNormalizeDescription(string? input, out string description) + { + description = (input ?? string.Empty).Trim(); + return description.Length > 0 && description.Length <= MaxDescriptionLength; + } + + public static bool TryNormalizeSearch(string? input, out string term) + { + term = (input ?? string.Empty).Trim(); + if (term.Length < MinSearchLength || term.Length > MaxNameLength * 2 + 1) return false; + + foreach (var c in term) + { + if (!char.IsLetter(c) && c != ' ' && c != '-' && c != '\'') return false; + } + + return true; + } + + public static bool TryNormalizePlate(string? input, out string plate) + { + plate = (input ?? string.Empty).Trim().ToUpperInvariant(); + if (plate.Length == 0 || plate.Length > MaxPlateLength) return false; + + foreach (var c in plate) + { + var valid = (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == ' '; + if (!valid) return false; + } + + return true; + } + } +} diff --git a/FivePRS.Core/Civilian/RecordModels.cs b/FivePRS.Core/Civilian/RecordModels.cs new file mode 100644 index 0000000..603cddf --- /dev/null +++ b/FivePRS.Core/Civilian/RecordModels.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace FivePRS.Core.Civilian +{ + public enum RecordType + { + Citation = 0, + Arrest = 1, + Warning = 2, + Warrant = 3, + } + + public sealed class RecordInfo + { + public int Id { get; set; } + public int CharacterId { get; set; } + public RecordType Type { get; set; } + public string Description { get; set; } = string.Empty; + public int Fine { get; set; } + public string OfficerName { get; set; } = string.Empty; + public string OfficerCallsign { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public bool Active { get; set; } + public string? Resolution { get; set; } + } + + public sealed class CharacterSummary + { + public int Id { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string DateOfBirth { get; set; } = string.Empty; + public string Gender { get; set; } = string.Empty; + public bool HasActiveWarrant { get; set; } + } + + public sealed class LookupRecord + { + public CharacterSummary Character { get; set; } = new(); + public List Licenses { get; set; } = new(); + public List Vehicles { get; set; } = new(); + public List Records { get; set; } = new(); + public string? MatchedPlate { get; set; } + } + + public sealed class LookupResult + { + public List? Results { get; set; } + public LookupRecord? Record { get; set; } + public string? Query { get; set; } + } + + public sealed class OfficerInfo + { + public string License { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Callsign { get; set; } = string.Empty; + } +} diff --git a/FivePRS.Core/Config/BrandingSettings.cs b/FivePRS.Core/Config/BrandingSettings.cs new file mode 100644 index 0000000..84b7d43 --- /dev/null +++ b/FivePRS.Core/Config/BrandingSettings.cs @@ -0,0 +1,34 @@ +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class BrandingSettings + { + [JsonProperty("serverIcon")] + public string ServerIcon { get; set; } = "https://cdn.fiveprs.org/fiveprs/server_icon.png"; + + [JsonProperty("notificationLogo")] + public string NotificationLogo { get; set; } = "https://cdn.fiveprs.org/fiveprs/fiveprs_logo.png"; + + [JsonProperty("nameplate")] + public string Nameplate { get; set; } = "https://cdn.fiveprs.org/fiveprs/nameplate.png"; + + [JsonProperty("departments")] + public DepartmentIcons Departments { get; set; } = new(); + } + + public sealed class DepartmentIcons + { + [JsonProperty("police")] + public string Police { get; set; } = "https://cdn.fiveprs.org/fiveprs/departments/police.png"; + + [JsonProperty("ems")] + public string Ems { get; set; } = "https://cdn.fiveprs.org/fiveprs/departments/ems.png"; + + [JsonProperty("fire")] + public string Fire { get; set; } = "https://cdn.fiveprs.org/fiveprs/departments/fire.png"; + + [JsonProperty("civilian")] + public string Civilian { get; set; } = "https://cdn.fiveprs.org/fiveprs/departments/civilian.png"; + } +} diff --git a/FivePRS.Core/Config/ConfigManager.cs b/FivePRS.Core/Config/ConfigManager.cs index 84ac7cb..34af116 100644 --- a/FivePRS.Core/Config/ConfigManager.cs +++ b/FivePRS.Core/Config/ConfigManager.cs @@ -10,6 +10,8 @@ public static class ConfigManager public static PoliceVehiclesConfig PoliceVehicles { get; private set; } = new(); public static PoliceLoadoutsConfig PoliceLoadouts { get; private set; } = new(); public static JurisdictionConfig Jurisdictions { get; private set; } = new(); + public static LicensesConfig Licenses { get; private set; } = new(); + public static StationsConfig Stations { get; private set; } = new(); public static TerritoryMap Territories { get; private set; } = new(new JurisdictionConfig()); public static Action? Log { get; set; } @@ -17,6 +19,8 @@ public static class ConfigManager public static void LoadSettings(string? json) => Settings = Load(json, "settings.json", Settings); public static void LoadPoliceVehicles(string? json) => PoliceVehicles = Load(json, "police_vehicles.json", PoliceVehicles); public static void LoadPoliceLoadouts(string? json) => PoliceLoadouts = Load(json, "police_loadouts.json", PoliceLoadouts); + public static void LoadLicenses(string? json) => Licenses = Load(json, "licenses.json", Licenses); + public static void LoadStations(string? json) => Stations = Load(json, "stations.json", Stations); public static void LoadJurisdictions(string? json) { diff --git a/FivePRS.Core/Config/CreatorSettings.cs b/FivePRS.Core/Config/CreatorSettings.cs new file mode 100644 index 0000000..e81e974 --- /dev/null +++ b/FivePRS.Core/Config/CreatorSettings.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class PedOption + { + [JsonProperty("model")] + public string Model { get; set; } = string.Empty; + + [JsonProperty("label")] + public string Label { get; set; } = string.Empty; + } + + public sealed class CreatorSettings + { + [JsonProperty("allowStandardPeds")] + public bool AllowStandardPeds { get; set; } = true; + + [JsonProperty("standardPeds", ObjectCreationHandling = ObjectCreationHandling.Replace)] + public List StandardPeds { get; set; } = new() + { + Ped("a_m_y_skater_01", "Skater"), + Ped("a_m_y_hipster_01", "Hipster (male)"), + Ped("a_f_y_hipster_01", "Hipster (female)"), + Ped("a_m_y_business_01", "Business (male)"), + Ped("a_f_y_business_01", "Business (female)"), + Ped("a_m_y_beach_01", "Beach (male)"), + Ped("a_f_y_beach_01", "Beach (female)"), + Ped("a_m_y_hiker_01", "Hiker (male)"), + Ped("a_f_y_hiker_01", "Hiker (female)"), + Ped("a_m_m_tourist_01", "Tourist (male)"), + Ped("a_f_m_tourist_01", "Tourist (female)"), + Ped("a_m_m_farmer_01", "Farmer"), + }; + + public IEnumerable AllowedStandardModels => + AllowStandardPeds ? StandardPeds.Select(p => p.Model) : Enumerable.Empty(); + + private static PedOption Ped(string model, string label) => new() { Model = model, Label = label }; + } +} diff --git a/FivePRS.Core/Config/LicenseConfig.cs b/FivePRS.Core/Config/LicenseConfig.cs new file mode 100644 index 0000000..eaa1364 --- /dev/null +++ b/FivePRS.Core/Config/LicenseConfig.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class LicenseTypeDef + { + [JsonProperty("id")] + public string Id { get; set; } = string.Empty; + + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("selfService")] + public bool SelfService { get; set; } + } + + public sealed class LicensesConfig + { + [JsonProperty("types")] + 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 }, + }; + + public LicenseTypeDef? Find(string? id) => + Types.FirstOrDefault(t => string.Equals(t.Id, id, System.StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/FivePRS.Core/Config/LoadoutConfig.cs b/FivePRS.Core/Config/LoadoutConfig.cs index 79a9c90..4877883 100644 --- a/FivePRS.Core/Config/LoadoutConfig.cs +++ b/FivePRS.Core/Config/LoadoutConfig.cs @@ -15,6 +15,9 @@ public sealed class WeaponDef [JsonProperty("setCurrent", DefaultValueHandling = DefaultValueHandling.Ignore)] public bool SetCurrent { get; set; } + + [JsonProperty("label", NullValueHandling = NullValueHandling.Ignore)] + public string? Label { get; set; } } public sealed class WeaponTierDef @@ -142,6 +145,9 @@ public sealed class PoliceLoadoutsConfig Props = new[] { Clothing(0, -1, -1), Clothing(1, -1, -1) }, }; + [JsonProperty("fallbackPedModels")] + public PedModelsDef FallbackPedModels { get; set; } = new() { Male = "s_m_y_cop_01", Female = "s_f_y_cop_01" }; + [JsonProperty("agencies", ObjectCreationHandling = ObjectCreationHandling.Replace)] public Dictionary Agencies { get; set; } = new(); @@ -150,6 +156,14 @@ public static LoadoutTier TierForRank(int rank) => 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/RateLimitSettings.cs b/FivePRS.Core/Config/RateLimitSettings.cs new file mode 100644 index 0000000..372c555 --- /dev/null +++ b/FivePRS.Core/Config/RateLimitSettings.cs @@ -0,0 +1,53 @@ +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class RateLimit + { + public RateLimit() + { + } + + public RateLimit(int limit, int perSeconds) + { + Limit = limit; + PerSeconds = perSeconds; + } + + [JsonProperty("limit")] + public int Limit { get; set; } + + [JsonProperty("perSeconds")] + public int PerSeconds { get; set; } + } + + public sealed class RateLimitSettings + { + [JsonProperty("enabled")] + public bool Enabled { get; set; } = true; + + [JsonProperty("emergency")] + public RateLimit Emergency { get; set; } = new(4, 60); + + [JsonProperty("lookups")] + public RateLimit Lookups { get; set; } = new(20, 10); + + [JsonProperty("records")] + public RateLimit Records { get; set; } = new(10, 10); + + [JsonProperty("civilian")] + public RateLimit Civilian { get; set; } = new(12, 10); + + [JsonProperty("dispatch")] + public RateLimit Dispatch { get; set; } = new(20, 10); + + [JsonProperty("admin")] + public RateLimit Admin { get; set; } = new(15, 10); + + [JsonProperty("positions")] + public RateLimit Positions { get; set; } = new(6, 10); + + [JsonProperty("requests")] + public RateLimit Requests { get; set; } = new(10, 10); + } +} diff --git a/FivePRS.Core/Config/ResourceSettings.cs b/FivePRS.Core/Config/ResourceSettings.cs index 768d561..69d1c1a 100644 --- a/FivePRS.Core/Config/ResourceSettings.cs +++ b/FivePRS.Core/Config/ResourceSettings.cs @@ -1,7 +1,15 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; namespace FivePRS.Core.Config { + public enum DutyEquipmentMode + { + ByStart, + Always, + Station + } + public sealed class ResourceSettings { [JsonProperty("dispatchIntervalMinutes")] @@ -24,5 +32,42 @@ public sealed class ResourceSettings [JsonProperty("noCalloutRetrySeconds")] public int NoCalloutRetrySeconds { get; set; } = 30; + + [JsonProperty("maxCharacters")] + public int MaxCharacters { get; set; } = 3; + + [JsonProperty("maxVehiclesPerCharacter")] + public int MaxVehiclesPerCharacter { get; set; } = 5; + + [JsonProperty("maxFine")] + public int MaxFine { get; set; } = 10000; + + [JsonProperty("emergencyCallCooldownSeconds")] + public int EmergencyCallCooldownSeconds { get; set; } = 60; + + [JsonProperty("emergencyCallTimeoutMinutes")] + public int EmergencyCallTimeoutMinutes { get; set; } = 15; + + [JsonProperty("dutyEquipment")] + [JsonConverter(typeof(StringEnumConverter), true)] + public DutyEquipmentMode DutyEquipment { get; set; } = DutyEquipmentMode.ByStart; + + [JsonProperty("spawn")] + public SpawnSettings Spawn { get; set; } = new(); + + [JsonProperty("updates")] + public UpdateSettings Updates { get; set; } = new(); + + [JsonProperty("rateLimits")] + public RateLimitSettings RateLimits { get; set; } = new(); + + [JsonProperty("terminal")] + public TerminalSettings Terminal { get; set; } = new(); + + [JsonProperty("creator")] + public CreatorSettings Creator { get; set; } = new(); + + [JsonProperty("branding")] + public BrandingSettings Branding { get; set; } = new(); } } diff --git a/FivePRS.Core/Config/SpawnSettings.cs b/FivePRS.Core/Config/SpawnSettings.cs new file mode 100644 index 0000000..238a38d --- /dev/null +++ b/FivePRS.Core/Config/SpawnSettings.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class SpawnSettings + { + [JsonProperty("restoreLastLocation")] + public bool RestoreLastLocation { get; set; } = true; + + [JsonProperty("saveIntervalSeconds")] + public int SaveIntervalSeconds { get; set; } = 30; + } +} diff --git a/FivePRS.Core/Config/StationConfig.cs b/FivePRS.Core/Config/StationConfig.cs new file mode 100644 index 0000000..592d324 --- /dev/null +++ b/FivePRS.Core/Config/StationConfig.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FivePRS.Core.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace FivePRS.Core.Config +{ + public enum StationBlipMode + { + Always, + OnDuty, + Off + } + + public enum StationPoint + { + Duty, + Armory, + Locker, + Garage + } + + public sealed class StationBlipDef + { + [JsonProperty("sprite")] + public int Sprite { get; set; } + + [JsonProperty("color")] + public int Color { get; set; } + } + + public sealed class StationDef + { + [JsonProperty("id")] + public string Id { get; set; } = string.Empty; + + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("department")] + [JsonConverter(typeof(StringEnumConverter))] + public Department Department { get; set; } + + [JsonProperty("agencies", ObjectCreationHandling = ObjectCreationHandling.Replace)] + public List Agencies { get; set; } = new(); + + [JsonProperty("dutyPoint", ObjectCreationHandling = ObjectCreationHandling.Replace)] + public float[] DutyPoint { get; set; } = new float[0]; + + [JsonProperty("heading")] + public float Heading { get; set; } + + [JsonProperty("parking", ObjectCreationHandling = ObjectCreationHandling.Replace)] + public List Parking { get; set; } = new(); + + [JsonProperty("armory", NullValueHandling = NullValueHandling.Ignore)] + public float[]? Armory { get; set; } + + [JsonProperty("locker", NullValueHandling = NullValueHandling.Ignore)] + public float[]? Locker { get; set; } + + [JsonProperty("garage", NullValueHandling = NullValueHandling.Ignore)] + public float[]? Garage { get; set; } + + [JsonProperty("blip", NullValueHandling = NullValueHandling.Ignore)] + public StationBlipDef? Blip { get; set; } + + public bool IsValid => DutyPoint.Length >= 3 && Department != Department.None; + + public float[]? PointFor(StationPoint point) + { + var position = point switch + { + StationPoint.Armory => Armory, + StationPoint.Locker => Locker, + StationPoint.Garage => Garage, + _ => DutyPoint, + }; + + return position is { Length: >= 3 } ? position : null; + } + + public bool Serves(Department department, string? agencyId) => + Department == department && + (Agencies.Count == 0 || agencyId is null || agencyId.Length == 0 || + Agencies.Any(a => string.Equals(a, agencyId, StringComparison.OrdinalIgnoreCase))); + } + + public sealed class StationsConfig + { + [JsonProperty("blips")] + [JsonConverter(typeof(StringEnumConverter))] + public StationBlipMode Blips { get; set; } = StationBlipMode.Always; + + [JsonProperty("stations", ObjectCreationHandling = ObjectCreationHandling.Replace)] + public List Stations { get; set; } = new(); + + public IEnumerable Valid => Stations.Where(s => s.IsValid); + + public StationDef? Find(string? id) => + id is null ? null : Valid.FirstOrDefault(s => string.Equals(s.Id, id, StringComparison.OrdinalIgnoreCase)); + + public IEnumerable For(Department department, string? agencyId) => + Valid.Where(s => s.Serves(department, agencyId)); + + public static StationBlipDef DefaultBlip(Department department) => department switch + { + Department.Fire => new StationBlipDef { Sprite = 436, Color = 1 }, + Department.EMS => new StationBlipDef { Sprite = 61, Color = 2 }, + _ => new StationBlipDef { Sprite = 60, Color = 3 }, + }; + } +} diff --git a/FivePRS.Core/Config/TerminalSettings.cs b/FivePRS.Core/Config/TerminalSettings.cs new file mode 100644 index 0000000..a92b9d0 --- /dev/null +++ b/FivePRS.Core/Config/TerminalSettings.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class WallpaperOption + { + [JsonProperty("id")] + public string Id { get; set; } = string.Empty; + + [JsonProperty("label")] + public string Label { get; set; } = string.Empty; + + [JsonProperty("url")] + public string Url { get; set; } = string.Empty; + } + + public sealed class TerminalSettings + { + public const int MaxWallpaperUrlLength = 512; + + private const string IdPrefix = "id:"; + private const string UrlPrefix = "url:"; + private const string HttpsScheme = "https://"; + private const string UnsafeUrlCharacters = "\"'<>\\()"; + + [JsonProperty("defaultWallpaper")] + public string DefaultWallpaper { get; set; } = "fiveprs"; + + [JsonProperty("allowCustomWallpapers")] + public bool AllowCustomWallpapers { get; set; } = true; + + [JsonProperty("wallpapers", ObjectCreationHandling = ObjectCreationHandling.Replace)] + public List Wallpapers { get; set; } = new() + { + Wallpaper("fiveprs", "FivePRS"), + Wallpaper("lightbar", "Lightbar"), + Wallpaper("hexgrid", "Hex grid"), + Wallpaper("dusk", "Los Santos dusk"), + }; + + public WallpaperOption? Find(string? id) => + Wallpapers.FirstOrDefault(w => string.Equals(w.Id, id, StringComparison.OrdinalIgnoreCase)); + + public bool TryNormalizeWallpaper(string? value, out string normalized) + { + normalized = string.Empty; + var trimmed = value?.Trim() ?? string.Empty; + if (trimmed.Length == 0) return true; + + if (trimmed.StartsWith(IdPrefix, StringComparison.OrdinalIgnoreCase)) + { + var option = Find(trimmed.Substring(IdPrefix.Length)); + if (option is null) return false; + + normalized = IdPrefix + option.Id; + return true; + } + + if (!AllowCustomWallpapers || !trimmed.StartsWith(UrlPrefix, StringComparison.OrdinalIgnoreCase)) return false; + + var url = trimmed.Substring(UrlPrefix.Length); + if (!IsSafeImageUrl(url)) return false; + + normalized = UrlPrefix + url; + return true; + } + + public string ResolveWallpaperUrl(string? preference) + { + if (TryNormalizeWallpaper(preference, out var normalized) && normalized.Length > 0) + { + if (normalized.StartsWith(UrlPrefix, StringComparison.Ordinal)) return normalized.Substring(UrlPrefix.Length); + + var chosen = Find(normalized.Substring(IdPrefix.Length)); + if (chosen is not null) return chosen.Url; + } + + return (Find(DefaultWallpaper) ?? Wallpapers.FirstOrDefault())?.Url ?? string.Empty; + } + + public static bool IsSafeImageUrl(string url) => + url.Length <= MaxWallpaperUrlLength && + url.StartsWith(HttpsScheme, StringComparison.OrdinalIgnoreCase) && + url.Length > HttpsScheme.Length && char.IsLetterOrDigit(url[HttpsScheme.Length]) && + !url.Any(c => char.IsWhiteSpace(c) || char.IsControl(c) || UnsafeUrlCharacters.IndexOf(c) >= 0); + + private static WallpaperOption Wallpaper(string id, string label) => + new() { Id = id, Label = label, Url = $"https://cdn.fiveprs.org/fiveprs/wallpapers/{id}.png" }; + } +} diff --git a/FivePRS.Core/Config/UpdateSettings.cs b/FivePRS.Core/Config/UpdateSettings.cs new file mode 100644 index 0000000..f00fd71 --- /dev/null +++ b/FivePRS.Core/Config/UpdateSettings.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace FivePRS.Core.Config +{ + public sealed class UpdateSettings + { + [JsonProperty("checkForUpdates")] + public bool CheckForUpdates { get; set; } = true; + + [JsonProperty("repository")] + public string Repository { get; set; } = "FivePRS/core"; + + [JsonProperty("assetName")] + public string AssetName { get; set; } = "fiveprs.zip"; + + [JsonProperty("checkIntervalHours")] + public int CheckIntervalHours { get; set; } = 6; + + [JsonProperty("includePreReleases")] + public bool IncludePreReleases { get; set; } + } +} diff --git a/FivePRS.Core/Config/VehicleConfig.cs b/FivePRS.Core/Config/VehicleConfig.cs index 01783a8..52ad555 100644 --- a/FivePRS.Core/Config/VehicleConfig.cs +++ b/FivePRS.Core/Config/VehicleConfig.cs @@ -11,13 +11,13 @@ public sealed class VehicleTierDef public string[] Models { get; set; } = new[] { "police" }; [JsonProperty("primaryColor")] - public int PrimaryColor { get; set; } = 0; + public int PrimaryColor { get; set; } = -1; [JsonProperty("secondaryColor")] - public int SecondaryColor { get; set; } = 0; + public int SecondaryColor { get; set; } = -1; [JsonProperty("dirtLevel")] - public int DirtLevel { get; set; } = 2; + public int DirtLevel { get; set; } = -1; [JsonProperty("livery")] public int Livery { get; set; } = -1; @@ -47,69 +47,22 @@ public sealed class AgencyVehicleDef public sealed class PoliceVehiclesConfig { [JsonProperty("patrol")] - public VehicleTierDef Patrol { get; set; } = new() - { - Models = new[] { "police", "police2" }, - PrimaryColor = 0, - SecondaryColor = 0, - DirtLevel = 2, - Livery = 0, - ForcedExtras = new[] { 1, 2 }, - DisabledExtras = new[] { 5 }, - }; + public VehicleTierDef Patrol { get; set; } = new() { Models = new[] { "police", "police2" } }; [JsonProperty("senior")] - public VehicleTierDef Senior { get; set; } = new() - { - Models = new[] { "police2", "police4" }, - PrimaryColor = 0, - SecondaryColor = 0, - DirtLevel = 1, - Livery = 0, - ForcedExtras = new[] { 1, 2 }, - }; + public VehicleTierDef Senior { get; set; } = new() { Models = new[] { "police2", "police4" } }; [JsonProperty("command")] - public VehicleTierDef Command { get; set; } = new() - { - Models = new[] { "police3" }, - PrimaryColor = 111, - SecondaryColor = 111, - DirtLevel = 0, - Livery = -1, - PlateText = "CMND", - }; + public VehicleTierDef Command { get; set; } = new() { Models = new[] { "police3" } }; [JsonProperty("agencies", ObjectCreationHandling = ObjectCreationHandling.Replace)] public Dictionary Agencies { get; set; } = new() { ["bcso"] = new AgencyVehicleDef { - Patrol = new VehicleTierDef - { - Models = new[] { "sheriff", "sheriff2" }, - PrimaryColor = -1, - SecondaryColor = -1, - DirtLevel = 3, - Livery = -1, - }, - Senior = new VehicleTierDef - { - Models = new[] { "sheriff2" }, - PrimaryColor = -1, - SecondaryColor = -1, - DirtLevel = 1, - Livery = -1, - }, - Command = new VehicleTierDef - { - Models = new[] { "sheriff2" }, - PrimaryColor = -1, - SecondaryColor = -1, - DirtLevel = 0, - Livery = -1, - PlateText = "BCSO", - }, + Patrol = new VehicleTierDef { Models = new[] { "sheriff", "sheriff2" } }, + Senior = new VehicleTierDef { Models = new[] { "sheriff2" } }, + Command = new VehicleTierDef { Models = new[] { "sheriff2" } }, }, }; diff --git a/FivePRS.Core/Events/EventNames.cs b/FivePRS.Core/Events/EventNames.cs index a64383a..576add4 100644 --- a/FivePRS.Core/Events/EventNames.cs +++ b/FivePRS.Core/Events/EventNames.cs @@ -9,18 +9,60 @@ public static class EventNames public const string ClientRankedUp = "FivePRS:Client:RankedUp"; public const string ClientNotify = "FivePRS:Client:Notify"; public const string ClientEndCallout = "FivePRS:Client:EndCallout"; + public const string ClientEntryOptions = "FivePRS:Client:EntryOptions"; + public const string ClientEntryRejected = "FivePRS:Client:EntryRejected"; + public const string ClientCivilianState = "FivePRS:Client:CivilianState"; + public const string ClientCivilianError = "FivePRS:Client:CivilianError"; + public const string ClientLookupResult = "FivePRS:Client:LookupResult"; + public const string ClientLookupError = "FivePRS:Client:LookupError"; + public const string ClientEmergencyStatus = "FivePRS:Client:EmergencyStatus"; + public const string ClientEmergencyError = "FivePRS:Client:EmergencyError"; + public const string ClientAdminState = "FivePRS:Client:AdminState"; + public const string ClientAppearance = "FivePRS:Client:Appearance"; + public const string ClientPreferences = "FivePRS:Client:Preferences"; + public const string ClientLastPosition = "FivePRS:Client:LastPosition"; public const string ServerPlayerConnected = "FivePRS:Server:PlayerConnected"; public const string ServerToggleDuty = "FivePRS:Server:ToggleDuty"; - public const string ServerSetDepartment = "FivePRS:Server:SetDepartment"; - public const string ServerSetAgency = "FivePRS:Server:SetAgency"; + public const string ServerEnterService = "FivePRS:Server:EnterService"; public const string ServerRegisterCallouts = "FivePRS:Server:RegisterCallouts"; public const string ServerCalloutResponse = "FivePRS:Server:CalloutResponse"; public const string ServerCalloutEnded = "FivePRS:Server:CalloutEnded"; public const string ServerSetUnitStatus = "FivePRS:Server:SetUnitStatus"; public const string ServerAttachToCall = "FivePRS:Server:AttachToCall"; + public const string ServerSetAiCallouts = "FivePRS:Server:SetAiCallouts"; + public const string ServerCivilianRequest = "FivePRS:Server:CivilianRequest"; + public const string ServerCharacterCreate = "FivePRS:Server:CharacterCreate"; + public const string ServerCharacterSelect = "FivePRS:Server:CharacterSelect"; + public const string ServerCharacterDelete = "FivePRS:Server:CharacterDelete"; + public const string ServerLicenseApply = "FivePRS:Server:LicenseApply"; + public const string ServerVehicleRegister = "FivePRS:Server:VehicleRegister"; + public const string ServerVehicleRemove = "FivePRS:Server:VehicleRemove"; + public const string ServerVehicleSetStolen = "FivePRS:Server:VehicleSetStolen"; + public const string ServerLookupName = "FivePRS:Server:LookupName"; + public const string ServerLookupPlate = "FivePRS:Server:LookupPlate"; + public const string ServerLookupCharacter = "FivePRS:Server:LookupCharacter"; + public const string ServerRecordIssue = "FivePRS:Server:RecordIssue"; + public const string ServerRecordResolve = "FivePRS:Server:RecordResolve"; + public const string ServerLicenseSetStatus = "FivePRS:Server:LicenseSetStatus"; + public const string ServerVehicleFlag = "FivePRS:Server:VehicleFlag"; + public const string ServerEmergencyCall = "FivePRS:Server:EmergencyCall"; + public const string ServerEmergencyCancel = "FivePRS:Server:EmergencyCancel"; + public const string ServerEmergencyStatus = "FivePRS:Server:EmergencyStatus"; + public const string ServerCallClear = "FivePRS:Server:CallClear"; + public const string ServerAdminRequest = "FivePRS:Server:AdminRequest"; + public const string ServerAdminRosterSet = "FivePRS:Server:AdminRosterSet"; + public const string ServerAppearanceSave = "FivePRS:Server:AppearanceSave"; + public const string ServerSetWallpaper = "FivePRS:Server:SetWallpaper"; + public const string ServerReportPosition = "FivePRS:Server:ReportPosition"; + public const string ServerLastPositionRequest = "FivePRS:Server:LastPositionRequest"; public const string LocalDutyChanged = "FivePRS:Local:DutyChanged"; + public const string LocalOpenMenu = "FivePRS:Local:OpenMenu"; + public const string LocalHttpRequest = "FivePRS:Local:HttpRequest"; + public const string LocalHttpResponse = "FivePRS:Local:HttpResponse"; + + public const string PublicAppAction = "fiveprs:appAction"; public const string LocalCalloutReceived = "FivePRS:Local:CalloutReceived"; public const string LocalSuspectCuffed = "FivePRS:Local:SuspectCuffed"; public const string LocalSuspectUncuffed = "FivePRS:Local:SuspectUncuffed"; diff --git a/FivePRS.Core/FivePRS.Core.csproj b/FivePRS.Core/FivePRS.Core.csproj index 87aaa61..b0c0109 100644 --- a/FivePRS.Core/FivePRS.Core.csproj +++ b/FivePRS.Core/FivePRS.Core.csproj @@ -10,7 +10,12 @@ - + + + $(PkgNewtonsoft_Json)\lib\portable-net40+sl5+win8+wp8+wpa81\Newtonsoft.Json.dll + + + diff --git a/FivePRS.Core/Models/Call.cs b/FivePRS.Core/Models/Call.cs index 11f6adc..5b77c0d 100644 --- a/FivePRS.Core/Models/Call.cs +++ b/FivePRS.Core/Models/Call.cs @@ -28,6 +28,17 @@ public class CallInfo public float X { get; set; } public float Y { get; set; } public float Z { get; set; } + public bool IsEmergency { get; set; } + public string? Description { get; set; } + public string? Caller { get; set; } + } + + 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 class DispatchSnapshot diff --git a/FivePRS.Core/Models/Callsign.cs b/FivePRS.Core/Models/Callsign.cs new file mode 100644 index 0000000..31fa8a5 --- /dev/null +++ b/FivePRS.Core/Models/Callsign.cs @@ -0,0 +1,21 @@ +namespace FivePRS.Core.Models +{ + public static class Callsign + { + public const int MaxLength = 12; + + public static bool TryNormalize(string? input, out string callsign) + { + callsign = (input ?? string.Empty).Trim().ToUpperInvariant(); + if (callsign.Length > MaxLength) return false; + + foreach (var c in callsign) + { + var valid = (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'; + if (!valid) return false; + } + + return true; + } + } +} diff --git a/FivePRS.Core/Models/PlayerData.cs b/FivePRS.Core/Models/PlayerData.cs index ecc1eae..8911539 100644 --- a/FivePRS.Core/Models/PlayerData.cs +++ b/FivePRS.Core/Models/PlayerData.cs @@ -8,6 +8,7 @@ public class PlayerData public string Name { get; set; } = string.Empty; public Department Department { get; set; } = Department.None; public string Agency { get; set; } = string.Empty; + public string Callsign { get; set; } = string.Empty; public bool IsOnDuty { get; set; } public int XP { get; set; } public int Rank { get; set; } = 1; diff --git a/FivePRS.Core/Models/Roster.cs b/FivePRS.Core/Models/Roster.cs new file mode 100644 index 0000000..ba99dfb --- /dev/null +++ b/FivePRS.Core/Models/Roster.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace FivePRS.Core.Models +{ + public sealed class RosterPlayer + { + public string License { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public int Rank { get; set; } + public Department Department { get; set; } + public bool Online { get; set; } + public int? ServerId { get; set; } + public DateTime LastSeen { get; set; } + public List Departments { get; set; } = new(); + } + + public sealed class AdminState + { + public bool RestrictDepartments { get; set; } + public string? Query { get; set; } + public List Online { get; set; } = new(); + public List Results { get; set; } = new(); + } +} diff --git a/FivePRS.Core/Models/SavedPosition.cs b/FivePRS.Core/Models/SavedPosition.cs new file mode 100644 index 0000000..525c205 --- /dev/null +++ b/FivePRS.Core/Models/SavedPosition.cs @@ -0,0 +1,27 @@ +using System; + +namespace FivePRS.Core.Models +{ + public sealed class SavedPosition + { + public const float MapLimit = 10_000f; + public const float MinHeight = -200f; + public const float MaxHeight = 3_000f; + + public float X { get; set; } + public float Y { get; set; } + public float Z { get; set; } + public float Heading { get; set; } + + public static SavedPosition? Create(float x, float y, float z, float heading) + { + if (!IsFinite(x) || !IsFinite(y) || !IsFinite(z) || !IsFinite(heading)) return null; + if (Math.Abs(x) > MapLimit || Math.Abs(y) > MapLimit || z < MinHeight || z > MaxHeight) return null; + if (Math.Abs(x) < 1f && Math.Abs(y) < 1f) return null; + + return new SavedPosition { X = x, Y = y, Z = z, Heading = ((heading % 360f) + 360f) % 360f }; + } + + private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value); + } +} diff --git a/FivePRS.Core/Models/Unit.cs b/FivePRS.Core/Models/Unit.cs index 4142e93..d4dbbf3 100644 --- a/FivePRS.Core/Models/Unit.cs +++ b/FivePRS.Core/Models/Unit.cs @@ -19,6 +19,7 @@ public class UnitInfo public int Rank { get; set; } public UnitStatus Status { get; set; } public string? CallId { get; set; } + public bool AiCallouts { get; set; } = true; public float X { get; set; } public float Y { get; set; } public float Z { get; set; } diff --git a/FivePRS.Core/Text/GameText.cs b/FivePRS.Core/Text/GameText.cs new file mode 100644 index 0000000..9cd7b0c --- /dev/null +++ b/FivePRS.Core/Text/GameText.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; + +namespace FivePRS.Core.Text +{ + public static class GameText + { + public const int MaxComponentBytes = 96; + + public static IReadOnlyList SplitComponents(string text, int maxBytes = MaxComponentBytes) + { + var parts = new List(); + var start = 0; + + while (start < text.Length) + { + var length = NextLength(text, start, maxBytes); + parts.Add(text.Substring(start, length)); + start += length; + } + + return parts; + } + + private static int NextLength(string text, int start, int maxBytes) + { + var bytes = 0; + var inToken = false; + var safeLength = 0; + var byteLimitedLength = 0; + + var i = start; + while (i < text.Length) + { + var c = text[i]; + var isPair = c >= '\uD800' && c <= '\uDBFF' && i + 1 < text.Length; + var charLength = isPair ? 2 : 1; + + bytes += isPair ? 4 : Utf8Length(c); + if (bytes > maxBytes) break; + + if (c == '~') inToken = !inToken; + + i += charLength; + byteLimitedLength = i - start; + if (!inToken) safeLength = byteLimitedLength; + } + + if (safeLength > 0) return safeLength; + return byteLimitedLength > 0 ? byteLimitedLength : 1; + } + + private static int Utf8Length(char c) + { + if (c < 0x80) return 1; + if (c < 0x800) return 2; + return 3; + } + } +} diff --git a/FivePRS.Core/Text/KeyCommands.cs b/FivePRS.Core/Text/KeyCommands.cs new file mode 100644 index 0000000..0895c49 --- /dev/null +++ b/FivePRS.Core/Text/KeyCommands.cs @@ -0,0 +1,15 @@ +namespace FivePRS.Core.Text +{ + public static class KeyCommands + { + public const string Menu = "fiveprs"; + public const string Interact = "fiveprs_interact"; + public const string Accept = "er_accept_call"; + public const string Decline = "er_decline"; + public const string EndCall = "er_end_callout"; + public const string Cuff = "er_cuff"; + public const string Uncuff = "er_uncuff"; + public const string Escort = "er_escort"; + public const string AiCallouts = "er_aicallouts"; + } +} diff --git a/FivePRS.Core/Text/WeaponLabels.cs b/FivePRS.Core/Text/WeaponLabels.cs new file mode 100644 index 0000000..9fa3fa3 --- /dev/null +++ b/FivePRS.Core/Text/WeaponLabels.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FivePRS.Core.Text +{ + public static class WeaponLabels + { + private static readonly Dictionary Known = new(StringComparer.OrdinalIgnoreCase) + { + ["WEAPON_NIGHTSTICK"] = "Nightstick", + ["WEAPON_FLASHLIGHT"] = "Flashlight", + ["WEAPON_STUNGUN"] = "Taser", + ["WEAPON_PISTOL"] = "Pistol", + ["WEAPON_COMBATPISTOL"] = "Combat Pistol", + ["WEAPON_PISTOL_MK2"] = "Pistol Mk II", + ["WEAPON_PUMPSHOTGUN"] = "Pump Shotgun", + ["WEAPON_PUMPSHOTGUN_MK2"] = "Pump Shotgun Mk II", + ["WEAPON_CARBINERIFLE"] = "Carbine Rifle", + ["WEAPON_CARBINERIFLE_MK2"] = "Carbine Rifle Mk II", + ["WEAPON_SMG"] = "SMG", + ["WEAPON_SNIPERRIFLE"] = "Sniper Rifle", + ["WEAPON_FLARE"] = "Flare", + ["WEAPON_FIREEXTINGUISHER"] = "Fire Extinguisher", + ["WEAPON_HATCHET"] = "Hatchet", + ["WEAPON_CROWBAR"] = "Crowbar", + }; + + public static string For(string weapon, string? label = null) + { + if (!string.IsNullOrWhiteSpace(label)) return label!.Trim(); + if (Known.TryGetValue(weapon, out var known)) return known; + + var name = weapon.StartsWith("WEAPON_", StringComparison.OrdinalIgnoreCase) ? weapon.Substring(7) : weapon; + return string.Join(" ", name.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Length == 0 ? part : char.ToUpperInvariant(part[0]) + part.Substring(1).ToLowerInvariant())); + } + } +} diff --git a/FivePRS.Core/Updates/ReleaseFeed.cs b/FivePRS.Core/Updates/ReleaseFeed.cs new file mode 100644 index 0000000..1d7642c --- /dev/null +++ b/FivePRS.Core/Updates/ReleaseFeed.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace FivePRS.Core.Updates +{ + public sealed class ReleaseInfo + { + public string Tag { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string Notes { get; set; } = string.Empty; + public string? AssetUrl { get; set; } + public bool PreRelease { get; set; } + public ReleaseVersion Version { get; set; } = null!; + } + + public static class ReleaseFeed + { + private sealed class GitHubAsset + { + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("browser_download_url")] + public string DownloadUrl { get; set; } = string.Empty; + } + + private sealed class GitHubRelease + { + [JsonProperty("tag_name")] + public string Tag { get; set; } = string.Empty; + + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("html_url")] + public string Url { get; set; } = string.Empty; + + [JsonProperty("body")] + public string? Body { get; set; } + + [JsonProperty("draft")] + public bool Draft { get; set; } + + [JsonProperty("prerelease")] + public bool PreRelease { get; set; } + + [JsonProperty("assets")] + public List Assets { get; set; } = new(); + } + + public static string ReleasesUrl(string repository) => $"https://api.github.com/repos/{repository}/releases?per_page=20"; + + public static ReleaseInfo? Latest(string json, string assetName, bool includePreReleases) + { + List? releases; + try + { + releases = JsonConvert.DeserializeObject>(json); + } + catch (JsonException) + { + return null; + } + + return (releases ?? new List()) + .Where(release => !release.Draft && (includePreReleases || !release.PreRelease)) + .Select(release => ReleaseVersion.TryParse(release.Tag, out var version) + ? new ReleaseInfo + { + Tag = release.Tag, + Name = string.IsNullOrWhiteSpace(release.Name) ? release.Tag : release.Name!, + Url = release.Url, + Notes = release.Body ?? string.Empty, + PreRelease = release.PreRelease, + Version = version, + AssetUrl = release.Assets + .FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase)) + ?.DownloadUrl, + } + : null) + .Where(release => release is not null) + .OrderByDescending(release => release!.Version) + .FirstOrDefault(); + } + + public static string Summary(string notes, int maxLines = 6) + { + var lines = notes + .Replace("\r\n", "\n") + .Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.StartsWith("#", StringComparison.Ordinal)) + .ToList(); + + var summary = lines.Take(maxLines).ToList(); + if (lines.Count > maxLines) summary.Add($"…and {lines.Count - maxLines} more"); + return string.Join("\n", summary); + } + } +} diff --git a/FivePRS.Core/Updates/ReleaseVersion.cs b/FivePRS.Core/Updates/ReleaseVersion.cs new file mode 100644 index 0000000..e9b3a40 --- /dev/null +++ b/FivePRS.Core/Updates/ReleaseVersion.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; + +namespace FivePRS.Core.Updates +{ + public sealed class ReleaseVersion : IComparable + { + public int Major { get; } + public int Minor { get; } + public int Patch { get; } + public string PreRelease { get; } + + public static ReleaseVersion Zero { get; } = new(0, 0, 0, string.Empty); + + private ReleaseVersion(int major, int minor, int patch, string preRelease) + { + Major = major; + Minor = minor; + Patch = patch; + PreRelease = preRelease; + } + + public static bool TryParse(string? text, out ReleaseVersion version) + { + version = new ReleaseVersion(0, 0, 0, string.Empty); + + var value = text?.Trim() ?? string.Empty; + if (value.StartsWith("v", StringComparison.OrdinalIgnoreCase)) value = value.Substring(1); + + var build = value.IndexOf('+'); + if (build >= 0) value = value.Substring(0, build); + + var dash = value.IndexOf('-'); + var preRelease = dash >= 0 ? value.Substring(dash + 1) : string.Empty; + var core = (dash >= 0 ? value.Substring(0, dash) : value).Split('.'); + + if (core.Length is < 1 or > 3 || core.Any(part => part.Length == 0 || !part.All(char.IsDigit))) return false; + + var numbers = core.Select(int.Parse).Concat(Enumerable.Repeat(0, 3)).Take(3).ToArray(); + version = new ReleaseVersion(numbers[0], numbers[1], numbers[2], preRelease); + return true; + } + + public int CompareTo(ReleaseVersion? other) + { + if (other is null) return 1; + + var core = Major != other.Major ? Major.CompareTo(other.Major) + : Minor != other.Minor ? Minor.CompareTo(other.Minor) + : Patch.CompareTo(other.Patch); + if (core != 0) return core; + + if (PreRelease.Length == 0) return other.PreRelease.Length == 0 ? 0 : 1; + if (other.PreRelease.Length == 0) return -1; + return ComparePreRelease(PreRelease, other.PreRelease); + } + + private static int ComparePreRelease(string left, string right) + { + var a = left.Split('.'); + var b = right.Split('.'); + + for (var i = 0; i < Math.Min(a.Length, b.Length); i++) + { + var aNumeric = int.TryParse(a[i], out var aNumber); + var bNumeric = int.TryParse(b[i], out var bNumber); + + var result = aNumeric && bNumeric ? aNumber.CompareTo(bNumber) + : aNumeric ? -1 + : bNumeric ? 1 + : string.CompareOrdinal(a[i], b[i]); + if (result != 0) return result; + } + + return a.Length.CompareTo(b.Length); + } + + public bool IsPreRelease => PreRelease.Length > 0; + + public bool IsNewerThan(ReleaseVersion other) => CompareTo(other) > 0; + + public override string ToString() => + PreRelease.Length == 0 ? $"{Major}.{Minor}.{Patch}" : $"{Major}.{Minor}.{Patch}-{PreRelease}"; + } +} diff --git a/FivePRS.Police/Callouts/DomesticDisturbanceCallout.cs b/FivePRS.Police/Callouts/DomesticDisturbanceCallout.cs index 1a92e22..bc6c703 100644 --- a/FivePRS.Police/Callouts/DomesticDisturbanceCallout.cs +++ b/FivePRS.Police/Callouts/DomesticDisturbanceCallout.cs @@ -202,9 +202,9 @@ private async Task ResistiveOutcomeAsync(CancellationToken ct) API.TaskCombatPed(_aggressor.Handle, Game.PlayerPed.Handle, 0, 16); ClientBrain.ShowNotification( - "~r~Aggressor resisting~w~ | Subdue and ~b~/er_cuff~w~."); + "~r~Aggressor resisting~w~ | Subdue the suspect."); - await WaitForArrestAsync(_aggressor, ct, "Subdue the suspect, then type ~b~/er_cuff~w~ to arrest"); + await WaitForArrestAsync(_aggressor, ct, $"Subdue the suspect, then press {Binds.Cuff} to arrest"); } private async Task ArmedOutcomeAsync(CancellationToken ct) @@ -229,9 +229,9 @@ private async Task ArmedOutcomeAsync(CancellationToken ct) API.TaskCombatPed(_aggressor.Handle, Game.PlayerPed.Handle, 0, 16); ClientBrain.ShowNotification( - "~r~WEAPON RAISED~w~ | Tase or shoot to disarm — then ~b~/er_cuff~w~."); + "~r~WEAPON RAISED~w~ | Tase or shoot to disarm."); - await WaitForArrestAsync(_aggressor, ct, "Subdue the suspect, then type ~b~/er_cuff~w~ to arrest"); + await WaitForArrestAsync(_aggressor, ct, $"Subdue the suspect, then press {Binds.Cuff} to arrest"); } public override void OnUpdate() @@ -245,9 +245,9 @@ public override void OnUpdate() } public override void OnCalloutDeclined() - => ClientBrain.ShowNotification("~r~[ DISPATCH ]~w~ Domestic disturbance call declined."); + => ClientBrain.ShowNotification("Domestic disturbance call declined.", "~r~Dispatch"); public override void OnCalloutFailed() - => ClientBrain.ShowNotification("~r~[ DISPATCH ]~w~ Domestic disturbance call cancelled."); + => ClientBrain.ShowNotification("Domestic disturbance call cancelled.", "~r~Dispatch"); } } diff --git a/FivePRS.Police/Callouts/ShopliftingCallout.cs b/FivePRS.Police/Callouts/ShopliftingCallout.cs index 8108ef7..a4bf363 100644 --- a/FivePRS.Police/Callouts/ShopliftingCallout.cs +++ b/FivePRS.Police/Callouts/ShopliftingCallout.cs @@ -95,9 +95,9 @@ public override async Task OnCalloutAccepted(CancellationToken ct) } public override void OnCalloutDeclined() - => ClientBrain.ShowNotification("~r~[ DISPATCH ]~w~ Shoplifting callout declined."); + => ClientBrain.ShowNotification("Shoplifting callout declined.", "~r~Dispatch"); public override void OnCalloutFailed() - => ClientBrain.ShowNotification("~r~[ DISPATCH ]~w~ Shoplifting callout cancelled."); + => ClientBrain.ShowNotification("Shoplifting callout cancelled.", "~r~Dispatch"); } } diff --git a/FivePRS.Police/Callouts/TrafficStopCallout.cs b/FivePRS.Police/Callouts/TrafficStopCallout.cs index 3bba4f4..3b98816 100644 --- a/FivePRS.Police/Callouts/TrafficStopCallout.cs +++ b/FivePRS.Police/Callouts/TrafficStopCallout.cs @@ -315,9 +315,9 @@ private async Task WarrantArrestOutcomeAsync(CancellationToken ct) await TaskManager.AssignTaskAsync(_driver, PedTaskType.PutHandsUp); ClientBrain.ShowNotification( - "~o~SUSPECT COMPLYING~w~ | Driver has a warrant, type ~b~/er_cuff~w~ to arrest."); + "~o~SUSPECT COMPLYING~w~ | Driver has an active warrant."); - await WaitForArrestAsync(_driver, ct, "Type ~b~/er_cuff~w~ to arrest the driver"); + await WaitForArrestAsync(_driver, ct, $"Press {Binds.Cuff} to arrest the driver"); } private async Task RunPursuitLoopAsync(CancellationToken ct) @@ -397,9 +397,9 @@ public override void OnUpdate() } public override void OnCalloutDeclined() - => ClientBrain.ShowNotification("~r~[ DISPATCH ]~w~ Traffic stop declined."); + => ClientBrain.ShowNotification("Traffic stop declined.", "~r~Dispatch"); public override void OnCalloutFailed() - => ClientBrain.ShowNotification("~r~[ DISPATCH ]~w~ Traffic stop cancelled."); + => ClientBrain.ShowNotification("Traffic stop cancelled.", "~r~Dispatch"); } } diff --git a/FivePRS.Police/Callouts/VehiclePursuitCallout.cs b/FivePRS.Police/Callouts/VehiclePursuitCallout.cs index 6e52259..885fd74 100644 --- a/FivePRS.Police/Callouts/VehiclePursuitCallout.cs +++ b/FivePRS.Police/Callouts/VehiclePursuitCallout.cs @@ -222,7 +222,7 @@ private async Task SurrenderOutcomeAsync(Ped driver, CancellationToken ct) await Timing.WaitAsync(1_500, ct); await TaskManager.AssignTaskAsync(driver, PedTaskType.PutHandsUp); - ClientBrain.ShowNotification("~g~Suspect surrendering~w~ | Approach and ~b~/er_cuff~w~ to arrest."); + ClientBrain.ShowNotification("~g~Suspect surrendering~w~ | Approach and arrest them."); await WaitForArrestAsync(driver, ct); } @@ -249,9 +249,9 @@ public override void OnUpdate() } public override void OnCalloutDeclined() - => ClientBrain.ShowNotification("~r~[ DISPATCH ]~w~ Vehicle pursuit declined."); + => ClientBrain.ShowNotification("Vehicle pursuit declined.", "~r~Dispatch"); public override void OnCalloutFailed() - => ClientBrain.ShowNotification("~r~[ DISPATCH ]~w~ Vehicle pursuit cancelled."); + => ClientBrain.ShowNotification("Vehicle pursuit cancelled.", "~r~Dispatch"); } } diff --git a/FivePRS.Police/Config/PoliceCatalog.cs b/FivePRS.Police/Config/PoliceCatalog.cs new file mode 100644 index 0000000..085e022 --- /dev/null +++ b/FivePRS.Police/Config/PoliceCatalog.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using CitizenFX.Core.Native; +using FivePRS.Client.Stations; +using FivePRS.Core.Config; +using FivePRS.Core.Text; + +namespace FivePRS.Police.Config +{ + public static class PoliceCatalog + { + private static readonly LoadoutTier[] Tiers = + { LoadoutTier.Recruit, LoadoutTier.Officer, LoadoutTier.Senior, LoadoutTier.Command }; + + private static readonly LoadoutTier[] VehicleTiers = + { LoadoutTier.Recruit, LoadoutTier.Senior, LoadoutTier.Command }; + + public static StationCatalog Build(string? agencyId) + { + var catalog = new StationCatalog(); + AddWeapons(catalog, agencyId); + AddUniforms(catalog, agencyId); + AddVehicles(catalog, agencyId); + return catalog; + } + + private static void AddWeapons(StationCatalog catalog, string? agencyId) + { + var loadouts = ConfigManager.PoliceLoadouts; + + foreach (var tier in Tiers) + { + var weapons = loadouts.WeaponsFor(agencyId, tier).Weapons; + var minRank = PoliceLoadoutsConfig.MinRankFor(tier); + + catalog.Kits.Add(new GearKit + { + Name = PoliceLoadouts.TierName(tier), + MinRank = minRank, + Weapons = weapons.Select(PoliceLoadouts.ToEntry).ToArray(), + }); + + foreach (var weapon in weapons) + { + if (catalog.Weapons.Any(w => string.Equals(w.Label, WeaponLabels.For(weapon.Name, weapon.Label), StringComparison.Ordinal))) + continue; + + var entry = PoliceLoadouts.ToEntry(weapon); + entry.SetAsCurrent = false; + + catalog.Weapons.Add(new GearWeapon + { + Label = WeaponLabels.For(weapon.Name, weapon.Label), + MinRank = minRank, + Weapon = entry, + }); + } + } + } + + private static void AddUniforms(StationCatalog catalog, string? agencyId) + { + var loadouts = ConfigManager.PoliceLoadouts; + var patrol = loadouts.UniformFor(agencyId, LoadoutTier.Recruit); + var command = loadouts.UniformFor(agencyId, LoadoutTier.Command); + + catalog.Uniforms.Add(new GearUniform { Name = "Patrol uniform", Outfit = PoliceLoadouts.ToOutfit(patrol) }); + + if (!ReferenceEquals(patrol, command)) + { + catalog.Uniforms.Add(new GearUniform + { + Name = "Command uniform", + MinRank = PoliceLoadoutsConfig.MinRankFor(LoadoutTier.Command), + Outfit = PoliceLoadouts.ToOutfit(command), + }); + } + } + + private static void AddVehicles(StationCatalog catalog, string? agencyId) + { + foreach (var tier in VehicleTiers) + { + var vehicles = ConfigManager.PoliceVehicles.TierFor(agencyId, tier); + + foreach (var model in vehicles.Models) + { + if (catalog.Vehicles.Any(v => string.Equals(v.Config.ModelPool[0], model, StringComparison.OrdinalIgnoreCase))) + continue; + + catalog.Vehicles.Add(new GearVehicle + { + Label = VehicleLabel(model), + MinRank = PoliceLoadoutsConfig.MinRankFor(tier), + Config = PoliceVehicles.ToConfig(vehicles, model), + }); + } + } + + foreach (var group in catalog.Vehicles.GroupBy(v => v.Label).Where(g => g.Count() > 1)) + { + foreach (var vehicle in group) + vehicle.Label = $"{vehicle.Label} ({vehicle.Config.ModelPool[0]})"; + } + } + + private static string VehicleLabel(string model) + { + var label = API.GetLabelText(API.GetDisplayNameFromVehicleModel((uint)API.GetHashKey(model))); + return string.IsNullOrEmpty(label) || label == "NULL" ? model : label; + } + } +} diff --git a/FivePRS.Police/Config/PoliceLoadouts.cs b/FivePRS.Police/Config/PoliceLoadouts.cs index c17bf6b..d9ac8fa 100644 --- a/FivePRS.Police/Config/PoliceLoadouts.cs +++ b/FivePRS.Police/Config/PoliceLoadouts.cs @@ -11,30 +11,35 @@ public static LoadoutDefinition GetForRank(int rank, string? agencyId) { var cfg = ConfigManager.PoliceLoadouts; var tier = PoliceLoadoutsConfig.TierForRank(rank); - var weapons = cfg.WeaponsFor(agencyId, tier); - var uniform = cfg.UniformFor(agencyId, tier); + var outfit = ToOutfit(cfg.UniformFor(agencyId, tier)); - return new LoadoutDefinition - { - Name = TierName(tier), - Weapons = weapons.Weapons.Select(w => new WeaponEntry - { - Hash = (uint)API.GetHashKey(w.Name), - Ammo = w.Ammo, - SetAsCurrent = w.SetCurrent, - }).ToArray(), - Components = uniform.Components - .Select(c => new ComponentEntry { ComponentId = c.Slot, DrawableId = c.Drawable, TextureId = c.Texture }) - .ToArray(), - Props = uniform.Props - .Select(p => new PropEntry { PropId = p.Slot, DrawableId = p.Drawable, TextureId = p.Texture }) - .ToArray(), - MalePedModel = NullIfEmpty(uniform.PedModels?.Male), - FemalePedModel = NullIfEmpty(uniform.PedModels?.Female), - }; + outfit.Name = TierName(tier); + outfit.Weapons = cfg.WeaponsFor(agencyId, tier).Weapons.Select(ToEntry).ToArray(); + return outfit; } - private static string TierName(LoadoutTier tier) => tier switch + public static WeaponEntry ToEntry(WeaponDef weapon) => new() + { + Hash = (uint)API.GetHashKey(weapon.Name), + Ammo = weapon.Ammo, + SetAsCurrent = weapon.SetCurrent, + }; + + public static LoadoutDefinition ToOutfit(UniformDef uniform) => new() + { + Components = uniform.Components + .Select(c => new ComponentEntry { ComponentId = c.Slot, DrawableId = c.Drawable, TextureId = c.Texture }) + .ToArray(), + Props = uniform.Props + .Select(p => new PropEntry { PropId = p.Slot, DrawableId = p.Drawable, TextureId = p.Texture }) + .ToArray(), + MalePedModel = NullIfEmpty(uniform.PedModels?.Male), + FemalePedModel = NullIfEmpty(uniform.PedModels?.Female), + FallbackMalePedModel = NullIfEmpty(ConfigManager.PoliceLoadouts.FallbackPedModels.Male), + FallbackFemalePedModel = NullIfEmpty(ConfigManager.PoliceLoadouts.FallbackPedModels.Female), + }; + + public static string TierName(LoadoutTier tier) => tier switch { LoadoutTier.Command => "Command", LoadoutTier.Senior => "Senior Officer", diff --git a/FivePRS.Police/Config/PoliceVehicles.cs b/FivePRS.Police/Config/PoliceVehicles.cs index 07a4ba9..3143af4 100644 --- a/FivePRS.Police/Config/PoliceVehicles.cs +++ b/FivePRS.Police/Config/PoliceVehicles.cs @@ -5,21 +5,19 @@ namespace FivePRS.Police.Config { public static class PoliceVehicles { - public static PatrolVehicleConfig GetForRank(int rank, string? agencyId) - { - var tier = ConfigManager.PoliceVehicles.TierFor(agencyId, PoliceLoadoutsConfig.TierForRank(rank)); + public static PatrolVehicleConfig GetForRank(int rank, string? agencyId) => + ToConfig(ConfigManager.PoliceVehicles.TierFor(agencyId, PoliceLoadoutsConfig.TierForRank(rank))); - return new PatrolVehicleConfig - { - ModelPool = tier.Models, - PrimaryColor = tier.PrimaryColor, - SecondaryColor = tier.SecondaryColor, - DirtLevel = tier.DirtLevel, - Livery = tier.Livery, - PlateText = tier.PlateText, - ForcedExtras = tier.ForcedExtras, - DisabledExtras = tier.DisabledExtras, - }; - } + public static PatrolVehicleConfig ToConfig(VehicleTierDef tier, string? model = null) => new() + { + ModelPool = model is null ? tier.Models : new[] { model }, + PrimaryColor = tier.PrimaryColor, + SecondaryColor = tier.SecondaryColor, + DirtLevel = tier.DirtLevel, + Livery = tier.Livery, + PlateText = tier.PlateText, + ForcedExtras = tier.ForcedExtras, + DisabledExtras = tier.DisabledExtras, + }; } } diff --git a/FivePRS.Police/FivePRS.Police.csproj b/FivePRS.Police/FivePRS.Police.csproj index cb5698e..691ab11 100644 --- a/FivePRS.Police/FivePRS.Police.csproj +++ b/FivePRS.Police/FivePRS.Police.csproj @@ -14,7 +14,11 @@ - + + + $(PkgNewtonsoft_Json)\lib\portable-net40+sl5+win8+wp8+wpa81\Newtonsoft.Json.dll + + diff --git a/FivePRS.Police/PoliceAgency.cs b/FivePRS.Police/PoliceAgency.cs index 3a5550e..0bf6566 100644 --- a/FivePRS.Police/PoliceAgency.cs +++ b/FivePRS.Police/PoliceAgency.cs @@ -4,7 +4,9 @@ using FivePRS.Client.Agency; using FivePRS.Client.Callouts; using FivePRS.Client.Loadout; +using FivePRS.Client.Stations; using FivePRS.Client.VehicleSpawner; +using FivePRS.Core.Config; using FivePRS.Core.Models; using FivePRS.Police.Config; @@ -15,9 +17,11 @@ public class PoliceAgency : BaseAgency public override Department Department => Department.Police; public override string AgencyName => "Police Department"; - private readonly CalloutRegistry _registry; - private readonly CalloutDispatcher _dispatcher; - private readonly PatrolVehicleSpawner _vehicleSpawner = new(); + private readonly CalloutRegistry _registry; + private readonly CalloutDispatcher _dispatcher; + + private StationDef? _arrivalStation; + private PatrolVehicleConfig? _arrivalVehicle; public PoliceAgency() { @@ -27,35 +31,123 @@ public PoliceAgency() _dispatcher = new CalloutDispatcher(Department.Police, _registry, OnCalloutEnded); } + public override StationCatalog? BuildCatalog(PlayerData player) => PoliceCatalog.Build(player.Agency); + public override async Task OnDuty(PlayerData player) { await base.OnDuty(player); + _registry.DiscoverAll(); + Debug.WriteLine($"[PoliceAgency] {_registry.Count} callout(s) registered."); + _dispatcher.Start(); + + var mode = StationService.TakePendingStart(out var station); + var message = ShouldEquip(mode) + ? await EquipAsync(player, mode, station) + : await ReportToStationAsync(mode, station); + + Notify($"~b~{DisplayName}~w~ | ~g~ON DUTY~w~~n~{message}"); + } + + private static bool ShouldEquip(DutyStartMode mode) => ConfigManager.Settings.DutyEquipment switch + { + DutyEquipmentMode.Always => true, + DutyEquipmentMode.Station => false, + _ => mode == DutyStartMode.Here, + }; + + private async Task EquipAsync(PlayerData player, DutyStartMode mode, StationDef? station) + { + if (mode == DutyStartMode.Teleport && station is not null) + await StationService.TeleportAsync(station); + var loadout = PoliceLoadouts.GetForRank(player.Rank, player.Agency); await LoadoutManager.ApplyAsync(loadout); var vehicleConfig = PoliceVehicles.GetForRank(player.Rank, player.Agency); - var vehicle = await _vehicleSpawner.SpawnAsync(vehicleConfig); + return $"{loadout.Name} loadout applied.~n~{await SpawnShiftVehicleAsync(player, mode, station, vehicleConfig)}"; + } - _registry.DiscoverAll(); - Debug.WriteLine($"[PoliceAgency] {_registry.Count} callout(s) registered."); - _dispatcher.Start(); + private static async Task ReportToStationAsync(DutyStartMode mode, StationDef? station) + { + if (mode == DutyStartMode.Drive && station is not null) + { + StationService.SetRoute(station); + return $"Head to ~y~{station.Name}~w~ to collect your uniform, weapons and vehicle."; + } - var vehicleMsg = vehicle is not null - ? "~w~ Your patrol vehicle is marked on the map." - : "~r~Vehicle spawn failed~w~ — proceed on foot."; + if (mode == DutyStartMode.Teleport && station is not null) + { + await StationService.TeleportAsync(station); + return "Collect your uniform, weapons and vehicle at the station."; + } - Notify( - $"~b~{DisplayName}~w~ | ~g~ON DUTY~w~ | " + - $"{loadout.Name} loadout applied.~n~{vehicleMsg}"); + return "Visit one of your stations to collect your uniform, weapons and vehicle."; } + private async Task SpawnShiftVehicleAsync(PlayerData player, DutyStartMode mode, StationDef? station, PatrolVehicleConfig vehicleConfig) + { + if (mode == DutyStartMode.Drive && station is not null) + { + StationService.SetRoute(station); + _arrivalStation = station; + _arrivalVehicle = vehicleConfig; + Tick += WaitForArrivalTick; + return $"Head to ~y~{station.Name}~w~. Your patrol vehicle is waiting there."; + } + + if (mode == DutyStartMode.Teleport && station is not null) + return VehicleMessage(await Vehicles.SpawnAsync(vehicleConfig, StationService.ParkingSpot(station), false)); + + var nearby = StationService.Nearby(Department, player.Agency); + var vehicle = nearby is not null + ? await Vehicles.SpawnAsync(vehicleConfig, StationService.ParkingSpot(nearby), false) + : await Vehicles.SpawnAsync(vehicleConfig, PatrolVehicleSpawner.RoadSpawnPoint(), true); + + return VehicleMessage(vehicle); + } + + private async Task WaitForArrivalTick() + { + if (_arrivalStation is null || _arrivalVehicle is null || !IsActive) + { + StopWaitingForArrival(); + return; + } + + if (StationService.DistanceTo(_arrivalStation) > StationService.ArrivalRadius) + { + await Delay(1000); + return; + } + + var station = _arrivalStation; + var config = _arrivalVehicle; + StopWaitingForArrival(); + + var vehicle = await Vehicles.SpawnAsync(config, StationService.ParkingSpot(station), false); + Notify($"~b~{station.Name}~w~ | {VehicleMessage(vehicle)}"); + } + + private void StopWaitingForArrival() + { + Tick -= WaitForArrivalTick; + _arrivalStation = null; + _arrivalVehicle = null; + } + + private static string VehicleMessage(CitizenFX.Core.Vehicle? vehicle) => + vehicle is not null + ? "Your patrol vehicle is marked on the map." + : "~r~Vehicle spawn failed~w~ — proceed on foot."; + public override async Task OffDuty(PlayerData player) { await base.OffDuty(player); _dispatcher.Stop(); - _vehicleSpawner.Despawn(); + StopWaitingForArrival(); + Vehicles.Despawn(); await LoadoutManager.StripAsync(); Notify($"~b~{DisplayName}~w~ | ~r~OFF DUTY~w~. Loadout and vehicle removed."); diff --git a/FivePRS.Server/Civilian/AppearanceService.cs b/FivePRS.Server/Civilian/AppearanceService.cs new file mode 100644 index 0000000..05b885f --- /dev/null +++ b/FivePRS.Server/Civilian/AppearanceService.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Server.Database; +using Newtonsoft.Json; + +namespace FivePRS.Server.Civilian +{ + public sealed class AppearanceService + { + private readonly CivilianStore _characters; + private readonly AppearanceStore _store; + private readonly Func _settings; + + public AppearanceService(CivilianStore characters, AppearanceStore store, Func settings) + { + _characters = characters; + _store = store; + _settings = settings; + } + + public async Task GetAsync(int characterId) + { + var json = await _store.GetAsync(characterId); + if (json is null) return null; + + var appearance = Parse(json); + return appearance is null ? null : JsonConvert.SerializeObject(appearance); + } + + public async Task SaveAsync(string owner, int characterId, string? json) + { + var character = await _characters.GetCharacterAsync(characterId); + if (character is null || character.OwnerLicense != owner) return "Character not found."; + + var appearance = Parse(json); + if (appearance is null) return "That appearance can't be saved."; + + await _store.SaveAsync(characterId, JsonConvert.SerializeObject(appearance)); + return null; + } + + public Task DeleteAsync(int characterId) => _store.DeleteAsync(characterId); + + private CharacterAppearance? Parse(string? json) + { + if (json is null || json.Length == 0 || json.Length > AppearanceRules.MaxSerializedSize) return null; + + try + { + return AppearanceRules.Sanitize(JsonConvert.DeserializeObject(json), + _settings().AllowedStandardModels); + } + catch (JsonException) + { + return null; + } + } + } +} diff --git a/FivePRS.Server/Civilian/CivilianService.cs b/FivePRS.Server/Civilian/CivilianService.cs new file mode 100644 index 0000000..2d27dbb --- /dev/null +++ b/FivePRS.Server/Civilian/CivilianService.cs @@ -0,0 +1,195 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Server.Database; + +namespace FivePRS.Server.Civilian +{ + public sealed class CivilianService + { + private const int MaxModelLength = 40; + + private readonly CivilianStore _store; + private readonly Func _settings; + private readonly Func _licenses; + private readonly Func _clock; + + public CivilianService(CivilianStore store, Func settings, Func licenses, Func clock) + { + _store = store; + _settings = settings; + _licenses = licenses; + _clock = clock; + } + + public async Task GetStateAsync(string owner) + { + var characters = await _store.GetCharactersAsync(owner); + var active = characters.OrderByDescending(c => c.LastUsedAt).ThenByDescending(c => c.Id).FirstOrDefault(); + + var state = new CivilianState + { + Characters = characters, + ActiveCharacterId = active?.Id, + MaxCharacters = _settings().MaxCharacters, + MaxVehicles = _settings().MaxVehiclesPerCharacter, + }; + + 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(); + + if (active is not null) + { + state.Vehicles = await _store.GetVehiclesAsync(active.Id); + state.Records = await _store.GetRecordsAsync(active.Id); + } + + return state; + } + + public async Task GetActiveCharacterAsync(string owner) + { + var characters = await _store.GetCharactersAsync(owner); + return characters.OrderByDescending(c => c.LastUsedAt).ThenByDescending(c => c.Id).FirstOrDefault(); + } + + public async Task CreateCharacterAsync(string owner, string? firstName, string? lastName, string? dateOfBirth, string? gender) + { + if (!CivilianRules.TryNormalizeName(firstName, out var first) || !CivilianRules.TryNormalizeName(lastName, out var last)) + return $"Names must be 1 to {CivilianRules.MaxNameLength} letters; spaces, hyphens and apostrophes are allowed."; + + if (!CivilianRules.TryParseDateOfBirth(dateOfBirth, _clock(), out var dob)) + return $"Enter a valid date of birth (YYYY-MM-DD). Characters must be at least {CivilianRules.MinimumAge}."; + + if (!CivilianRules.TryNormalizeGender(gender, out var normalizedGender)) + return "Choose a gender."; + + var existing = await _store.GetCharactersAsync(owner); + if (existing.Count >= _settings().MaxCharacters) + return $"You can have up to {_settings().MaxCharacters} characters."; + + await _store.CreateCharacterAsync(new CharacterInfo + { + OwnerLicense = owner, + FirstName = first, + LastName = last, + DateOfBirth = dob, + Gender = normalizedGender, + }); + return null; + } + + public async Task SelectCharacterAsync(string owner, int characterId) + { + if (await GetOwnedCharacterAsync(owner, characterId) is null) return "Character not found."; + + await _store.TouchCharacterAsync(characterId); + return null; + } + + public async Task DeleteCharacterAsync(string owner, int characterId) + { + if (await GetOwnedCharacterAsync(owner, characterId) is null) return "Character not found."; + + var summary = await _store.GetSummaryAsync(characterId); + if (summary?.HasActiveWarrant == true) return "A character with an active warrant can't be deleted."; + + await _store.DeleteCharacterAsync(characterId); + return null; + } + + public async Task ApplyForLicenseAsync(string owner, string? type) + { + var character = await GetActiveCharacterAsync(owner); + if (character is null) return "Create a character first."; + + var definition = _licenses().Find(type); + if (definition is null) return "Unknown license type."; + if (!definition.SelfService) return $"A {definition.Name} must be issued by law enforcement."; + + var current = (await _store.GetLicensesAsync(character.Id)) + .FirstOrDefault(l => string.Equals(l.Type, definition.Id, StringComparison.OrdinalIgnoreCase)); + + if (current is not null) + { + return current.Status == LicenseStatus.Valid + ? $"You already hold a {definition.Name}." + : $"Your {definition.Name} is {current.Status.ToString().ToLowerInvariant()}. Contact law enforcement."; + } + + await _store.SetLicenseAsync(character.Id, definition.Id, LicenseStatus.Valid); + return null; + } + + public async Task RegisterVehicleAsync(string owner, string? plate, string? model) + { + var character = await GetActiveCharacterAsync(owner); + if (character is null) return "Create a character first."; + + if (!CivilianRules.TryNormalizePlate(plate, out var normalizedPlate)) + return "This vehicle's plate can't be registered."; + + var existing = await _store.GetVehicleByPlateAsync(normalizedPlate); + if (existing is not null) + { + return existing.CharacterId == character.Id + ? $"{normalizedPlate} is already registered to you." + : $"{normalizedPlate} is already registered to someone else."; + } + + var vehicles = await _store.GetVehiclesAsync(character.Id); + if (vehicles.Count >= _settings().MaxVehiclesPerCharacter) + return $"You can register up to {_settings().MaxVehiclesPerCharacter} vehicles per character."; + + var modelName = (model ?? string.Empty).Trim(); + if (modelName.Length == 0) modelName = "Unknown"; + if (modelName.Length > MaxModelLength) modelName = modelName.Substring(0, MaxModelLength); + + await _store.AddVehicleAsync(new VehicleInfo + { + CharacterId = character.Id, + Plate = normalizedPlate, + Model = modelName, + Status = VehicleStatus.Valid, + }); + return null; + } + + public async Task RemoveVehicleAsync(string owner, int vehicleId) + { + if (await GetOwnedVehicleAsync(owner, vehicleId) is null) return "Vehicle not found."; + + await _store.DeleteVehicleAsync(vehicleId); + return null; + } + + public async Task SetVehicleStolenAsync(string owner, int vehicleId, bool stolen) + { + if (await GetOwnedVehicleAsync(owner, vehicleId) is null) return "Vehicle not found."; + + await _store.SetVehicleStatusAsync(vehicleId, stolen ? VehicleStatus.Stolen : VehicleStatus.Valid); + return null; + } + + private async Task GetOwnedCharacterAsync(string owner, int characterId) + { + var character = await _store.GetCharacterAsync(characterId); + return character is not null && character.OwnerLicense == owner ? character : null; + } + + private async Task GetOwnedVehicleAsync(string owner, int vehicleId) + { + var vehicle = await _store.GetVehicleAsync(vehicleId); + if (vehicle is null) return null; + return await GetOwnedCharacterAsync(owner, vehicle.CharacterId) is null ? null : vehicle; + } + } +} diff --git a/FivePRS.Server/Civilian/LookupService.cs b/FivePRS.Server/Civilian/LookupService.cs new file mode 100644 index 0000000..9731272 --- /dev/null +++ b/FivePRS.Server/Civilian/LookupService.cs @@ -0,0 +1,140 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Server.Database; + +namespace FivePRS.Server.Civilian +{ + public sealed class LookupService + { + private const int SearchLimit = 15; + + private readonly CivilianStore _store; + private readonly Func _settings; + private readonly Func _licenses; + + public LookupService(CivilianStore store, Func settings, Func licenses) + { + _store = store; + _settings = settings; + _licenses = licenses; + } + + public async Task<(LookupResult? Result, string? Error)> SearchByNameAsync(string? term) + { + if (!CivilianRules.TryNormalizeSearch(term, out var normalized)) + return (null, $"Enter at least {CivilianRules.MinSearchLength} letters of a name."); + + var results = await _store.SearchCharactersAsync(normalized, SearchLimit); + return (new LookupResult { Results = results, Query = normalized }, null); + } + + public async Task<(LookupResult? Result, string? Error)> SearchByPlateAsync(string? plate) + { + if (!CivilianRules.TryNormalizePlate(plate, out var normalized)) + return (null, $"Plates are 1 to {CivilianRules.MaxPlateLength} letters and numbers."); + + var vehicle = await _store.GetVehicleByPlateAsync(normalized); + if (vehicle is null) return (null, $"No registration found for {normalized}."); + + var record = await BuildRecordAsync(vehicle.CharacterId); + if (record is null) return (null, $"No registration found for {normalized}."); + + record.MatchedPlate = normalized; + return (new LookupResult { Record = record, Query = normalized }, null); + } + + public async Task<(LookupResult? Result, string? Error)> GetRecordAsync(int characterId) + { + var record = await BuildRecordAsync(characterId); + return record is null ? (null, "Person not found.") : (new LookupResult { Record = record }, null); + } + + public async Task IssueRecordAsync(OfficerInfo officer, int characterId, int type, string? description, int fine) + { + if (!Enum.IsDefined(typeof(RecordType), type)) return "Unknown record type."; + if (await _store.GetCharacterAsync(characterId) is null) return "Person not found."; + + if (!CivilianRules.TryNormalizeDescription(description, out var text)) + return $"Enter a description of up to {CivilianRules.MaxDescriptionLength} characters."; + + var recordType = (RecordType)type; + if (recordType == RecordType.Citation) + { + if (fine < 0 || fine > _settings().MaxFine) return $"Fines must be between 0 and {_settings().MaxFine}."; + } + else + { + fine = 0; + } + + await _store.AddRecordAsync(new RecordInfo + { + CharacterId = characterId, + Type = recordType, + Description = text, + Fine = fine, + OfficerName = officer.Name, + OfficerCallsign = officer.Callsign, + Active = recordType == RecordType.Warrant, + }, officer.License); + + return null; + } + + public async Task<(int? CharacterId, string? Error)> ResolveWarrantAsync(OfficerInfo officer, int recordId, bool served) + { + var record = await _store.GetRecordAsync(recordId); + if (record is null || record.Type != RecordType.Warrant) return (null, "Warrant not found."); + if (!record.Active) return (record.CharacterId, "That warrant is no longer active."); + + await _store.ResolveRecordAsync(recordId, $"{(served ? "Served" : "Cleared")} by {officer.Callsign}"); + return (record.CharacterId, null); + } + + public async Task SetLicenseStatusAsync(int characterId, string? type, int status) + { + if (await _store.GetCharacterAsync(characterId) is null) return "Person not found."; + if (!Enum.IsDefined(typeof(LicenseStatus), status)) return "Unknown license status."; + + var definition = _licenses().Find(type); + if (definition is null) return "Unknown license type."; + + await _store.SetLicenseAsync(characterId, definition.Id, (LicenseStatus)status); + return null; + } + + public async Task<(int? CharacterId, string? Error)> SetVehicleStolenAsync(int vehicleId, bool stolen) + { + var vehicle = await _store.GetVehicleAsync(vehicleId); + if (vehicle is null) return (null, "Vehicle not found."); + + await _store.SetVehicleStatusAsync(vehicleId, stolen ? VehicleStatus.Stolen : VehicleStatus.Valid); + return (vehicle.CharacterId, null); + } + + private async Task BuildRecordAsync(int characterId) + { + var summary = await _store.GetSummaryAsync(characterId); + if (summary is null) return null; + + var held = await _store.GetLicensesAsync(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(), + Vehicles = await _store.GetVehiclesAsync(characterId), + Records = await _store.GetRecordsAsync(characterId), + }; + } + } +} diff --git a/FivePRS.Server/Database/AppearanceStore.cs b/FivePRS.Server/Database/AppearanceStore.cs new file mode 100644 index 0000000..b12546d --- /dev/null +++ b/FivePRS.Server/Database/AppearanceStore.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; + +namespace FivePRS.Server.Database +{ + public sealed class AppearanceStore : SqlStore + { + public AppearanceStore(IDatabaseProvider db) : base(db) + { + } + + public async Task GetAsync(int characterId) + { + var rows = await QueryAsync("SELECT data FROM fiveprs_appearances WHERE character_id = @id", + reader => reader.GetString(0), ("@id", characterId)); + return rows.Count > 0 ? rows[0] : null; + } + + public Task SaveAsync(int characterId, string data) => + ExecuteAsync( + "INSERT INTO fiveprs_appearances (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_appearances WHERE character_id = @id", ("@id", characterId)); + } +} diff --git a/FivePRS.Server/Database/AuditEntry.cs b/FivePRS.Server/Database/AuditEntry.cs index cab004c..74743c9 100644 --- a/FivePRS.Server/Database/AuditEntry.cs +++ b/FivePRS.Server/Database/AuditEntry.cs @@ -14,6 +14,19 @@ public static class AuditActions public const string AdminSetDept = "admin.setdept"; public const string AdminOffDuty = "admin.offduty"; public const string AdminEndCall = "admin.endcall"; + public const string CharacterCreated = "civ.character.create"; + public const string CharacterDeleted = "civ.character.delete"; + public const string LicenseIssued = "civ.license.issue"; + public const string VehicleRegistered = "civ.vehicle.register"; + public const string VehicleRemoved = "civ.vehicle.remove"; + public const string RecordIssued = "records.issue"; + public const string WarrantResolved = "records.warrant.resolve"; + public const string LicenseStatusSet = "records.license"; + public const string VehicleFlagged = "records.vehicle"; + public const string EmergencyCall = "911.call"; + public const string EmergencyCleared = "911.clear"; + public const string RosterGranted = "roster.grant"; + public const string RosterRevoked = "roster.revoke"; } public sealed class AuditEntry diff --git a/FivePRS.Server/Database/CivilianStore.cs b/FivePRS.Server/Database/CivilianStore.cs new file mode 100644 index 0000000..bb2e522 --- /dev/null +++ b/FivePRS.Server/Database/CivilianStore.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; + +namespace FivePRS.Server.Database +{ + public sealed class CivilianStore : SqlStore + { + private const string CharacterColumns = + "id, owner_license, first_name, last_name, date_of_birth, gender, created_at, last_used_at"; + + private const string VehicleColumns = "id, character_id, plate, model, status, registered_at"; + + private const string RecordColumns = + "id, character_id, type, description, fine, officer_name, officer_callsign, created_at, active, resolution"; + + private const string ActiveWarrantExists = + "EXISTS (SELECT 1 FROM fiveprs_records r WHERE r.character_id = c.id AND r.type = 3 AND r.active = 1)"; + + public CivilianStore(IDatabaseProvider db) : base(db) + { + } + + public Task> GetCharactersAsync(string ownerLicense) => + QueryAsync($"SELECT {CharacterColumns} FROM fiveprs_characters WHERE owner_license = @owner ORDER BY created_at, id", + ReadCharacter, ("@owner", ownerLicense)); + + public async Task GetCharacterAsync(int id) + { + var rows = await QueryAsync($"SELECT {CharacterColumns} FROM fiveprs_characters WHERE id = @id", + ReadCharacter, ("@id", id)); + return rows.Count > 0 ? rows[0] : null; + } + + public async Task CreateCharacterAsync(CharacterInfo character) + { + var now = DateTime.UtcNow; + var id = await ScalarAsync( + "INSERT INTO fiveprs_characters (owner_license, first_name, last_name, date_of_birth, gender, created_at, last_used_at) " + + $"VALUES (@owner, @first, @last, @dob, @gender, @now, @now); {LastInsertId};", + ("@owner", character.OwnerLicense), ("@first", character.FirstName), ("@last", character.LastName), + ("@dob", character.DateOfBirth), ("@gender", character.Gender), ("@now", now)); + return Convert.ToInt32(id); + } + + public Task TouchCharacterAsync(int id) => + ExecuteAsync("UPDATE fiveprs_characters SET last_used_at = @now WHERE id = @id", + ("@now", DateTime.UtcNow), ("@id", id)); + + public Task DeleteCharacterAsync(int id) => + ExecuteAsync( + "DELETE FROM fiveprs_records WHERE character_id = @id; " + + "DELETE FROM fiveprs_licenses WHERE character_id = @id; " + + "DELETE FROM fiveprs_vehicles WHERE character_id = @id; " + + "DELETE FROM fiveprs_characters WHERE id = @id;", + ("@id", id)); + + public Task> GetLicensesAsync(int characterId) => + QueryAsync("SELECT character_id, type, status, issued_at FROM fiveprs_licenses WHERE character_id = @id ORDER BY type", + reader => new LicenseInfo + { + CharacterId = Convert.ToInt32(reader.GetValue(0)), + Type = reader.GetString(1), + Status = (LicenseStatus)Convert.ToInt32(reader.GetValue(2)), + IssuedAt = reader.GetDateTime(3), + }, + ("@id", characterId)); + + public Task SetLicenseAsync(int characterId, string type, LicenseStatus status) + { + var upsert = IsMySql + ? "ON DUPLICATE KEY UPDATE status = VALUES(status)" + : "ON CONFLICT(character_id, type) DO UPDATE SET status = excluded.status"; + + return ExecuteAsync( + $"INSERT INTO fiveprs_licenses (character_id, type, status, issued_at) VALUES (@id, @type, @status, @now) {upsert}", + ("@id", characterId), ("@type", type), ("@status", (int)status), ("@now", DateTime.UtcNow)); + } + + public Task> GetVehiclesAsync(int characterId) => + QueryAsync($"SELECT {VehicleColumns} FROM fiveprs_vehicles WHERE character_id = @id ORDER BY registered_at, id", + ReadVehicle, ("@id", characterId)); + + public async Task GetVehicleAsync(int id) + { + var rows = await QueryAsync($"SELECT {VehicleColumns} FROM fiveprs_vehicles WHERE id = @id", ReadVehicle, ("@id", id)); + return rows.Count > 0 ? rows[0] : null; + } + + public async Task GetVehicleByPlateAsync(string plate) + { + var rows = await QueryAsync($"SELECT {VehicleColumns} FROM fiveprs_vehicles WHERE plate = @plate", ReadVehicle, ("@plate", plate)); + return rows.Count > 0 ? rows[0] : null; + } + + public async Task AddVehicleAsync(VehicleInfo vehicle) + { + var id = await ScalarAsync( + "INSERT INTO fiveprs_vehicles (character_id, plate, model, status, registered_at) " + + $"VALUES (@character, @plate, @model, @status, @now); {LastInsertId};", + ("@character", vehicle.CharacterId), ("@plate", vehicle.Plate), ("@model", vehicle.Model), + ("@status", (int)vehicle.Status), ("@now", DateTime.UtcNow)); + return Convert.ToInt32(id); + } + + public Task DeleteVehicleAsync(int id) => + ExecuteAsync("DELETE FROM fiveprs_vehicles WHERE id = @id", ("@id", id)); + + public Task SetVehicleStatusAsync(int id, VehicleStatus status) => + ExecuteAsync("UPDATE fiveprs_vehicles SET status = @status WHERE id = @id", ("@status", (int)status), ("@id", id)); + + public Task> SearchCharactersAsync(string term, int limit) + { + var fullName = IsMySql ? "CONCAT(c.first_name, ' ', c.last_name)" : "(c.first_name || ' ' || c.last_name)"; + return QueryAsync( + $"SELECT c.id, c.first_name, c.last_name, c.date_of_birth, c.gender, {ActiveWarrantExists} " + + $"FROM fiveprs_characters c WHERE c.first_name LIKE @term OR c.last_name LIKE @term OR {fullName} LIKE @term " + + $"ORDER BY c.last_name, c.first_name LIMIT {limit}", + ReadSummary, ("@term", $"%{term}%")); + } + + public async Task GetSummaryAsync(int characterId) + { + var rows = await QueryAsync( + $"SELECT c.id, c.first_name, c.last_name, c.date_of_birth, c.gender, {ActiveWarrantExists} FROM fiveprs_characters c WHERE c.id = @id", + ReadSummary, ("@id", characterId)); + return rows.Count > 0 ? rows[0] : null; + } + + public Task> GetRecordsAsync(int characterId) => + QueryAsync($"SELECT {RecordColumns} FROM fiveprs_records WHERE character_id = @id ORDER BY created_at DESC, id DESC", + ReadRecord, ("@id", characterId)); + + public async Task GetRecordAsync(int id) + { + var rows = await QueryAsync($"SELECT {RecordColumns} FROM fiveprs_records WHERE id = @id", ReadRecord, ("@id", id)); + return rows.Count > 0 ? rows[0] : null; + } + + public async Task AddRecordAsync(RecordInfo record, string officerLicense) + { + var id = await ScalarAsync( + "INSERT INTO fiveprs_records (character_id, type, description, fine, officer_license, officer_name, officer_callsign, created_at, active) " + + $"VALUES (@character, @type, @description, @fine, @officerLicense, @officerName, @callsign, @now, @active); {LastInsertId};", + ("@character", record.CharacterId), ("@type", (int)record.Type), ("@description", record.Description), + ("@fine", record.Fine), ("@officerLicense", officerLicense), ("@officerName", record.OfficerName), + ("@callsign", record.OfficerCallsign), ("@now", DateTime.UtcNow), ("@active", record.Active ? 1 : 0)); + return Convert.ToInt32(id); + } + + public Task ResolveRecordAsync(int id, string resolution) => + ExecuteAsync("UPDATE fiveprs_records SET active = 0, resolution = @resolution WHERE id = @id", + ("@resolution", resolution), ("@id", id)); + + private static CharacterSummary ReadSummary(DbDataReader reader) => new() + { + Id = Convert.ToInt32(reader.GetValue(0)), + FirstName = reader.GetString(1), + LastName = reader.GetString(2), + DateOfBirth = reader.GetString(3), + Gender = reader.GetString(4), + HasActiveWarrant = Convert.ToInt32(reader.GetValue(5)) != 0, + }; + + private static RecordInfo ReadRecord(DbDataReader reader) => new() + { + Id = Convert.ToInt32(reader.GetValue(0)), + CharacterId = Convert.ToInt32(reader.GetValue(1)), + Type = (RecordType)Convert.ToInt32(reader.GetValue(2)), + Description = reader.GetString(3), + Fine = Convert.ToInt32(reader.GetValue(4)), + OfficerName = reader.GetString(5), + OfficerCallsign = reader.GetString(6), + CreatedAt = reader.GetDateTime(7), + Active = Convert.ToInt32(reader.GetValue(8)) != 0, + Resolution = reader.IsDBNull(9) ? null : reader.GetString(9), + }; + + private static CharacterInfo ReadCharacter(DbDataReader reader) => new() + { + Id = Convert.ToInt32(reader.GetValue(0)), + OwnerLicense = reader.GetString(1), + FirstName = reader.GetString(2), + LastName = reader.GetString(3), + DateOfBirth = reader.GetString(4), + Gender = reader.GetString(5), + CreatedAt = reader.GetDateTime(6), + LastUsedAt = reader.GetDateTime(7), + }; + + private static VehicleInfo ReadVehicle(DbDataReader reader) => new() + { + Id = Convert.ToInt32(reader.GetValue(0)), + CharacterId = Convert.ToInt32(reader.GetValue(1)), + Plate = reader.GetString(2), + Model = reader.GetString(3), + Status = (VehicleStatus)Convert.ToInt32(reader.GetValue(4)), + RegisteredAt = reader.GetDateTime(5), + }; + } +} diff --git a/FivePRS.Server/Database/DatabaseManager.cs b/FivePRS.Server/Database/DatabaseManager.cs index 96e26c1..fd83fd2 100644 --- a/FivePRS.Server/Database/DatabaseManager.cs +++ b/FivePRS.Server/Database/DatabaseManager.cs @@ -19,8 +19,22 @@ public sealed class DatabaseManager public bool IsReady { get; private set; } + public CivilianStore Civilians { get; private set; } = null!; + + public RosterStore Roster { get; private set; } = null!; + + public AppearanceStore Appearances { get; private set; } = null!; + + public PreferencesStore Preferences { get; private set; } = null!; + + public PositionStore Positions { get; private set; } = null!; + + public int SchemaVersion { get; private set; } + public async Task InitializeAsync(DatabaseType dbType, string? connectionString) { + Func? backup = null; + if (dbType == DatabaseType.MySQL) { if (connectionString is null || string.IsNullOrWhiteSpace(connectionString)) @@ -30,15 +44,45 @@ public async Task InitializeAsync(DatabaseType dbType, string? connectionString) else { var resourcePath = API.GetResourcePath(API.GetCurrentResourceName()); - _provider = new SQLiteProvider(connectionString ?? Path.Combine(resourcePath, "data", "fiveprs.db")); + var nativePath = SqliteNativeLoader.Load(Path.Combine(resourcePath, "server")); + Debug.WriteLine($"[FivePRS] Native SQLite loaded from {nativePath}."); + + var dbPath = connectionString ?? Path.Combine(resourcePath, "data", "fiveprs.db"); + var sqlite = new SQLiteProvider(dbPath); + _provider = sqlite; + + if (File.Exists(dbPath) && new FileInfo(dbPath).Length > 0) + backup = version => BackupAsync(sqlite, dbPath, version); } 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.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."); + SchemaVersion = result.ToVersion; + + Civilians = new CivilianStore(_provider); + Roster = new RosterStore(_provider); + Appearances = new AppearanceStore(_provider); + Preferences = new PreferencesStore(_provider); + Positions = new PositionStore(_provider); + IsReady = true; Debug.WriteLine($"[FivePRS] Database ({dbType}) ready."); } + private static async Task BackupAsync(SQLiteProvider sqlite, string dbPath, int version) + { + 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."); + } + public Task GetPlayerAsync(string license) => _provider.GetPlayerAsync(license); diff --git a/FivePRS.Server/Database/IDatabaseProvider.cs b/FivePRS.Server/Database/IDatabaseProvider.cs index 51ab773..5a83327 100644 --- a/FivePRS.Server/Database/IDatabaseProvider.cs +++ b/FivePRS.Server/Database/IDatabaseProvider.cs @@ -1,3 +1,4 @@ +using System.Data.Common; using System.Threading.Tasks; using FivePRS.Core.Models; @@ -5,6 +6,10 @@ namespace FivePRS.Server.Database { public interface IDatabaseProvider { + SqlDialect Dialect { get; } + + DbConnection CreateConnection(); + Task InitializeAsync(); Task GetPlayerAsync(string license); diff --git a/FivePRS.Server/Database/Migration.cs b/FivePRS.Server/Database/Migration.cs new file mode 100644 index 0000000..525ce83 --- /dev/null +++ b/FivePRS.Server/Database/Migration.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; + +namespace FivePRS.Server.Database +{ + public sealed class Migration + { + public Migration(int version, string name, params MigrationStep[] steps) + { + Version = version; + Name = name; + Steps = steps; + } + + public int Version { get; } + + public string Name { get; } + + public IReadOnlyList Steps { get; } + } + + public abstract class MigrationStep + { + internal abstract Task ApplyAsync(DbConnection conn, DbTransaction? transaction, SqlDialect dialect); + + internal static async Task ExecuteAsync(DbConnection conn, DbTransaction? transaction, string sql) + { + using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + } + + internal static async Task ScalarAsync(DbConnection conn, DbTransaction? transaction, string sql, string name, object value) + { + using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = sql; + var parameter = cmd.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + cmd.Parameters.Add(parameter); + return await cmd.ExecuteScalarAsync(); + } + } + + public sealed class SqlStep : MigrationStep + { + public SqlStep(string sqlite, string mySql) + { + Sqlite = sqlite; + MySql = mySql; + } + + public string Sqlite { get; } + + public string MySql { get; } + + internal override Task ApplyAsync(DbConnection conn, DbTransaction? transaction, SqlDialect dialect) => + ExecuteAsync(conn, transaction, dialect == SqlDialect.MySql ? MySql : Sqlite); + } + + public sealed class AddColumnStep : MigrationStep + { + public AddColumnStep(string table, string column, string sqliteDefinition, string mySqlDefinition) + { + Table = table; + Column = column; + SqliteDefinition = sqliteDefinition; + MySqlDefinition = mySqlDefinition; + } + + public string Table { get; } + + public string Column { get; } + + public string SqliteDefinition { get; } + + public string MySqlDefinition { get; } + + internal override async Task ApplyAsync(DbConnection conn, DbTransaction? transaction, SqlDialect dialect) + { + var mySql = dialect == SqlDialect.MySql; + var exists = await ScalarAsync(conn, transaction, + mySql + ? $"SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{Table}' AND COLUMN_NAME = @column" + : $"SELECT COUNT(*) FROM pragma_table_info('{Table}') WHERE name = @column", + "@column", Column); + if (Convert.ToInt64(exists) > 0) return; + + await ExecuteAsync(conn, transaction, + mySql + ? $"ALTER TABLE `{Table}` ADD COLUMN `{Column}` {MySqlDefinition}" + : $"ALTER TABLE {Table} ADD COLUMN {Column} {SqliteDefinition}"); + } + } +} diff --git a/FivePRS.Server/Database/Migrations.cs b/FivePRS.Server/Database/Migrations.cs new file mode 100644 index 0000000..02f31b1 --- /dev/null +++ b/FivePRS.Server/Database/Migrations.cs @@ -0,0 +1,191 @@ +using System.Collections.Generic; + +namespace FivePRS.Server.Database +{ + public static class Migrations + { + public static IReadOnlyList All { get; } = new[] + { + new Migration(1, "Initial schema", + Sql(@"CREATE TABLE IF NOT EXISTS ers_players ( + license TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + department INTEGER DEFAULT 0, + is_on_duty INTEGER DEFAULT 0, + xp INTEGER DEFAULT 0, + rank_level INTEGER DEFAULT 1, + agency TEXT NOT NULL DEFAULT '', + callsign TEXT NOT NULL DEFAULT '', + last_seen TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')))", + @"CREATE TABLE IF NOT EXISTS `ers_players` ( + `license` VARCHAR(60) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `department` TINYINT UNSIGNED DEFAULT 0, + `is_on_duty` TINYINT(1) DEFAULT 0, + `xp` INT UNSIGNED DEFAULT 0, + `rank_level` TINYINT UNSIGNED DEFAULT 1, + `agency` VARCHAR(40) NOT NULL DEFAULT '', + `callsign` VARCHAR(16) NOT NULL DEFAULT '', + `last_seen` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`license`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"), + new AddColumnStep("ers_players", "agency", "TEXT NOT NULL DEFAULT ''", "VARCHAR(40) NOT NULL DEFAULT ''"), + new AddColumnStep("ers_players", "callsign", "TEXT NOT NULL DEFAULT ''", "VARCHAR(16) NOT NULL DEFAULT ''"), + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + action TEXT NOT NULL, + actor_license TEXT, + actor_name TEXT NOT NULL, + target_license TEXT, + details TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS idx_fiveprs_audit_target ON fiveprs_audit(target_license)", + @"CREATE TABLE IF NOT EXISTS `fiveprs_audit` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `created_at` DATETIME NOT NULL, + `action` VARCHAR(40) NOT NULL, + `actor_license` VARCHAR(60) NULL, + `actor_name` VARCHAR(100) NOT NULL, + `target_license` VARCHAR(60) NULL, + `details` VARCHAR(255) NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_fiveprs_audit_target` (`target_license`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"), + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_characters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_license TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + date_of_birth TEXT NOT NULL, + gender TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS idx_fiveprs_characters_owner ON fiveprs_characters(owner_license); + CREATE INDEX IF NOT EXISTS idx_fiveprs_characters_name ON fiveprs_characters(last_name, first_name)", + @"CREATE TABLE IF NOT EXISTS `fiveprs_characters` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `owner_license` VARCHAR(60) NOT NULL, + `first_name` VARCHAR(32) NOT NULL, + `last_name` VARCHAR(32) NOT NULL, + `date_of_birth` CHAR(10) NOT NULL, + `gender` VARCHAR(16) NOT NULL, + `created_at` DATETIME(6) NOT NULL, + `last_used_at` DATETIME(6) NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_fiveprs_characters_owner` (`owner_license`), + KEY `idx_fiveprs_characters_name` (`last_name`, `first_name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"), + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_licenses ( + character_id INTEGER NOT NULL, + type TEXT NOT NULL, + status INTEGER NOT NULL DEFAULT 0, + issued_at TEXT NOT NULL, + PRIMARY KEY (character_id, type))", + @"CREATE TABLE IF NOT EXISTS `fiveprs_licenses` ( + `character_id` INT UNSIGNED NOT NULL, + `type` VARCHAR(32) NOT NULL, + `status` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `issued_at` DATETIME(6) NOT NULL, + PRIMARY KEY (`character_id`, `type`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"), + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_vehicles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_id INTEGER NOT NULL, + plate TEXT NOT NULL UNIQUE, + model TEXT NOT NULL, + status INTEGER NOT NULL DEFAULT 0, + registered_at TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS idx_fiveprs_vehicles_character ON fiveprs_vehicles(character_id)", + @"CREATE TABLE IF NOT EXISTS `fiveprs_vehicles` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character_id` INT UNSIGNED NOT NULL, + `plate` VARCHAR(8) NOT NULL, + `model` VARCHAR(40) NOT NULL, + `status` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `registered_at` DATETIME(6) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_fiveprs_vehicles_plate` (`plate`), + KEY `idx_fiveprs_vehicles_character` (`character_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"), + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_id INTEGER NOT NULL, + type INTEGER NOT NULL, + description TEXT NOT NULL, + fine INTEGER NOT NULL DEFAULT 0, + officer_license TEXT NOT NULL, + officer_name TEXT NOT NULL, + officer_callsign TEXT NOT NULL, + created_at TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 0, + resolution TEXT NULL); + CREATE INDEX IF NOT EXISTS idx_fiveprs_records_character ON fiveprs_records(character_id)", + @"CREATE TABLE IF NOT EXISTS `fiveprs_records` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character_id` INT UNSIGNED NOT NULL, + `type` TINYINT UNSIGNED NOT NULL, + `description` VARCHAR(500) NOT NULL, + `fine` INT UNSIGNED NOT NULL DEFAULT 0, + `officer_license` VARCHAR(60) NOT NULL, + `officer_name` VARCHAR(100) NOT NULL, + `officer_callsign` VARCHAR(16) NOT NULL, + `created_at` DATETIME(6) NOT NULL, + `active` TINYINT(1) NOT NULL DEFAULT 0, + `resolution` VARCHAR(120) NULL, + PRIMARY KEY (`id`), + KEY `idx_fiveprs_records_character` (`character_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"), + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_roster ( + license TEXT NOT NULL, + department INTEGER NOT NULL, + granted_by TEXT NOT NULL, + granted_at TEXT NOT NULL, + PRIMARY KEY (license, department))", + @"CREATE TABLE IF NOT EXISTS `fiveprs_roster` ( + `license` VARCHAR(60) NOT NULL, + `department` TINYINT UNSIGNED NOT NULL, + `granted_by` VARCHAR(100) NOT NULL, + `granted_at` DATETIME(6) NOT NULL, + PRIMARY KEY (`license`, `department`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"), + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_appearances ( + character_id INTEGER PRIMARY KEY, + data TEXT NOT NULL, + updated_at TEXT NOT NULL)", + @"CREATE TABLE IF NOT EXISTS `fiveprs_appearances` ( + `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"), + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_preferences ( + license TEXT PRIMARY KEY, + wallpaper TEXT NOT NULL, + updated_at TEXT NOT NULL)", + @"CREATE TABLE IF NOT EXISTS `fiveprs_preferences` ( + `license` VARCHAR(60) NOT NULL, + `wallpaper` VARCHAR(600) NOT NULL, + `updated_at` DATETIME(6) NOT NULL, + PRIMARY KEY (`license`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"), + Sql(@"CREATE TABLE IF NOT EXISTS fiveprs_positions ( + license TEXT PRIMARY KEY, + x REAL NOT NULL, + y REAL NOT NULL, + z REAL NOT NULL, + heading REAL NOT NULL, + updated_at TEXT NOT NULL)", + @"CREATE TABLE IF NOT EXISTS `fiveprs_positions` ( + `license` VARCHAR(60) NOT NULL, + `x` DOUBLE NOT NULL, + `y` DOUBLE NOT NULL, + `z` DOUBLE NOT NULL, + `heading` DOUBLE NOT NULL, + `updated_at` DATETIME(6) NOT NULL, + PRIMARY KEY (`license`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci")), + }; + + private static SqlStep Sql(string sqlite, string mySql) => new SqlStep(sqlite, mySql); + } +} diff --git a/FivePRS.Server/Database/Migrator.cs b/FivePRS.Server/Database/Migrator.cs new file mode 100644 index 0000000..2e60bc9 --- /dev/null +++ b/FivePRS.Server/Database/Migrator.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; + +namespace FivePRS.Server.Database +{ + public sealed class MigrationResult + { + public MigrationResult(int fromVersion, int toVersion, int latestVersion, IReadOnlyList applied) + { + FromVersion = fromVersion; + ToVersion = toVersion; + LatestVersion = latestVersion; + Applied = applied; + } + + public int FromVersion { get; } + + public int ToVersion { get; } + + public int LatestVersion { get; } + + public IReadOnlyList Applied { get; } + + public bool DatabaseIsNewer => FromVersion > LatestVersion; + } + + public sealed class Migrator + { + private const string SqliteSchemaTable = @"CREATE TABLE IF NOT EXISTS fiveprs_schema ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL)"; + + private const string MySqlSchemaTable = @"CREATE TABLE IF NOT EXISTS `fiveprs_schema` ( + `version` INT UNSIGNED NOT NULL, + `name` VARCHAR(100) NOT NULL, + `applied_at` DATETIME(6) NOT NULL, + PRIMARY KEY (`version`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; + + private readonly IDatabaseProvider _db; + private readonly IReadOnlyList _migrations; + + public Migrator(IDatabaseProvider db) : this(db, Migrations.All) + { + } + + public Migrator(IDatabaseProvider db, IReadOnlyList migrations) + { + var previous = 0; + foreach (var migration in migrations) + { + if (migration.Version <= previous) + throw new ArgumentException($"Migration {migration.Version} ({migration.Name}) must have a higher version than {previous}."); + previous = migration.Version; + } + + _db = db; + _migrations = migrations; + } + + public int LatestVersion => _migrations.Count == 0 ? 0 : _migrations[_migrations.Count - 1].Version; + + public async Task CurrentVersionAsync() + { + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + await EnsureSchemaTableAsync(conn); + return await ReadVersionAsync(conn); + } + + public async Task MigrateAsync(Func? beforeMigrating = null) + { + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + await EnsureSchemaTableAsync(conn); + + var from = await ReadVersionAsync(conn); + var current = from; + var applied = new List(); + + foreach (var migration in _migrations) + { + if (migration.Version <= current) continue; + + if (applied.Count == 0 && beforeMigrating is not null) + await beforeMigrating(from); + + await ApplyAsync(conn, migration); + applied.Add(migration); + current = migration.Version; + } + + return new MigrationResult(from, current, LatestVersion, applied); + } + + private async Task ApplyAsync(DbConnection conn, Migration migration) + { + using var transaction = _db.Dialect == SqlDialect.Sqlite ? conn.BeginTransaction() : null; + + foreach (var step in migration.Steps) + await step.ApplyAsync(conn, transaction, _db.Dialect); + + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = transaction; + cmd.CommandText = "INSERT INTO fiveprs_schema (version, name, applied_at) VALUES (@version, @name, @now)"; + AddParameter(cmd, "@version", migration.Version); + AddParameter(cmd, "@name", migration.Name); + AddParameter(cmd, "@now", DateTime.UtcNow); + await cmd.ExecuteNonQueryAsync(); + } + + transaction?.Commit(); + } + + private Task EnsureSchemaTableAsync(DbConnection conn) => + MigrationStep.ExecuteAsync(conn, null, _db.Dialect == SqlDialect.MySql ? MySqlSchemaTable : SqliteSchemaTable); + + private static async Task ReadVersionAsync(DbConnection conn) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COALESCE(MAX(version), 0) FROM fiveprs_schema"; + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + private static void AddParameter(DbCommand cmd, string name, object value) + { + var parameter = cmd.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + cmd.Parameters.Add(parameter); + } + } +} diff --git a/FivePRS.Server/Database/MySqlProvider.cs b/FivePRS.Server/Database/MySqlProvider.cs index 19ac0f2..f175aab 100644 --- a/FivePRS.Server/Database/MySqlProvider.cs +++ b/FivePRS.Server/Database/MySqlProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Data.Common; using System.Threading.Tasks; using MySqlConnector; using FivePRS.Core.Models; @@ -9,6 +10,10 @@ public sealed class MySqlProvider : IDatabaseProvider { private readonly string _connectionString; + public SqlDialect Dialect => SqlDialect.MySql; + + public DbConnection CreateConnection() => new MySqlConnection(_connectionString); + public MySqlProvider(string connectionString) { _connectionString = connectionString; @@ -18,37 +23,6 @@ public async Task InitializeAsync() { using var conn = new MySqlConnection(_connectionString); await conn.OpenAsync(); - - using var cmd = conn.CreateCommand(); - cmd.CommandText = @" - CREATE TABLE IF NOT EXISTS `ers_players` ( - `license` VARCHAR(60) NOT NULL, - `name` VARCHAR(100) NOT NULL, - `department` TINYINT UNSIGNED DEFAULT 0, - `is_on_duty` TINYINT(1) DEFAULT 0, - `xp` INT UNSIGNED DEFAULT 0, - `rank_level` TINYINT UNSIGNED DEFAULT 1, - `agency` VARCHAR(40) NOT NULL DEFAULT '', - `last_seen` DATETIME DEFAULT CURRENT_TIMESTAMP - ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`license`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - - CREATE TABLE IF NOT EXISTS `fiveprs_audit` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `created_at` DATETIME NOT NULL, - `action` VARCHAR(40) NOT NULL, - `actor_license` VARCHAR(60) NULL, - `actor_name` VARCHAR(100) NOT NULL, - `target_license` VARCHAR(60) NULL, - `details` VARCHAR(255) NOT NULL, - PRIMARY KEY (`id`), - KEY `idx_fiveprs_audit_target` (`target_license`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"; - - await cmd.ExecuteNonQueryAsync(); - - await EnsureColumnAsync(conn, "ers_players", "agency", "VARCHAR(40) NOT NULL DEFAULT ''"); } public async Task GetPlayerAsync(string license) @@ -58,7 +32,7 @@ PRIMARY KEY (`id`), using var cmd = conn.CreateCommand(); cmd.CommandText = @" - SELECT `license`, `name`, `department`, `is_on_duty`, `xp`, `rank_level`, `last_seen`, `agency` + SELECT `license`, `name`, `department`, `is_on_duty`, `xp`, `rank_level`, `last_seen`, `agency`, `callsign` FROM `ers_players` WHERE `license` = @license LIMIT 1;"; cmd.Parameters.AddWithValue("@license", license); @@ -75,6 +49,7 @@ PRIMARY KEY (`id`), Rank = reader.GetByte("rank_level"), LastSeen = reader.GetDateTime("last_seen"), Agency = reader.GetString("agency"), + Callsign = reader.GetString("callsign"), }; } @@ -86,13 +61,14 @@ public async Task SavePlayerAsync(PlayerData player) using var cmd = conn.CreateCommand(); cmd.CommandText = @" INSERT INTO `ers_players` - (`license`, `name`, `department`, `agency`, `is_on_duty`, `xp`, `rank_level`, `last_seen`) + (`license`, `name`, `department`, `agency`, `callsign`, `is_on_duty`, `xp`, `rank_level`, `last_seen`) VALUES - (@license, @name, @department, @agency, @onDuty, @xp, @rank, UTC_TIMESTAMP()) + (@license, @name, @department, @agency, @callsign, @onDuty, @xp, @rank, UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `department` = VALUES(`department`), `agency` = VALUES(`agency`), + `callsign` = VALUES(`callsign`), `is_on_duty` = VALUES(`is_on_duty`), `xp` = VALUES(`xp`), `rank_level` = VALUES(`rank_level`), @@ -102,6 +78,7 @@ ON DUPLICATE KEY UPDATE cmd.Parameters.AddWithValue("@name", player.Name); cmd.Parameters.AddWithValue("@department", (byte)player.Department); cmd.Parameters.AddWithValue("@agency", player.Agency); + cmd.Parameters.AddWithValue("@callsign", player.Callsign); cmd.Parameters.AddWithValue("@onDuty", player.IsOnDuty); cmd.Parameters.AddWithValue("@xp", player.XP); cmd.Parameters.AddWithValue("@rank", player.Rank); @@ -143,23 +120,6 @@ public async Task UpdateDutyStatusAsync(string license, bool isOnDuty) await cmd.ExecuteNonQueryAsync(); } - private static async Task EnsureColumnAsync(MySqlConnection conn, string table, string column, string definition) - { - using (var check = conn.CreateCommand()) - { - check.CommandText = @" - SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @table AND COLUMN_NAME = @column;"; - check.Parameters.AddWithValue("@table", table); - check.Parameters.AddWithValue("@column", column); - if (Convert.ToInt64(await check.ExecuteScalarAsync()) > 0) return; - } - - using var alter = conn.CreateCommand(); - alter.CommandText = $"ALTER TABLE `{table}` ADD COLUMN `{column}` {definition};"; - await alter.ExecuteNonQueryAsync(); - } - private static string Truncate(string value, int maxLength) => value.Length <= maxLength ? value : value.Substring(0, maxLength); } diff --git a/FivePRS.Server/Database/PositionStore.cs b/FivePRS.Server/Database/PositionStore.cs new file mode 100644 index 0000000..5c3d0d0 --- /dev/null +++ b/FivePRS.Server/Database/PositionStore.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using FivePRS.Core.Models; + +namespace FivePRS.Server.Database +{ + public sealed class PositionStore : SqlStore + { + public PositionStore(IDatabaseProvider db) : base(db) + { + } + + public async Task GetAsync(string license) + { + var rows = await QueryAsync("SELECT x, y, z, heading FROM fiveprs_positions WHERE license = @license", + reader => new SavedPosition + { + X = Convert.ToSingle(reader.GetValue(0)), + Y = Convert.ToSingle(reader.GetValue(1)), + Z = Convert.ToSingle(reader.GetValue(2)), + Heading = Convert.ToSingle(reader.GetValue(3)), + }, + ("@license", license)); + return rows.Count > 0 ? rows[0] : null; + } + + public Task SaveAsync(string license, SavedPosition position) => + ExecuteAsync( + "INSERT INTO fiveprs_positions (license, x, y, z, heading, updated_at) VALUES (@license, @x, @y, @z, @heading, @now) " + + (IsMySql + ? "ON DUPLICATE KEY UPDATE x = VALUES(x), y = VALUES(y), z = VALUES(z), heading = VALUES(heading), updated_at = VALUES(updated_at)" + : "ON CONFLICT(license) DO UPDATE SET x = excluded.x, y = excluded.y, z = excluded.z, heading = excluded.heading, updated_at = excluded.updated_at"), + ("@license", license), ("@x", (double)position.X), ("@y", (double)position.Y), ("@z", (double)position.Z), + ("@heading", (double)position.Heading), ("@now", DateTime.UtcNow)); + } +} diff --git a/FivePRS.Server/Database/PreferencesStore.cs b/FivePRS.Server/Database/PreferencesStore.cs new file mode 100644 index 0000000..39438a0 --- /dev/null +++ b/FivePRS.Server/Database/PreferencesStore.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading.Tasks; + +namespace FivePRS.Server.Database +{ + public sealed class PreferencesStore : SqlStore + { + public PreferencesStore(IDatabaseProvider db) : base(db) + { + } + + public async Task GetWallpaperAsync(string license) + { + var rows = await QueryAsync("SELECT wallpaper FROM fiveprs_preferences WHERE license = @license", + reader => reader.GetString(0), ("@license", license)); + return rows.Count > 0 ? rows[0] : string.Empty; + } + + public Task SetWallpaperAsync(string license, string wallpaper) => + ExecuteAsync( + "INSERT INTO fiveprs_preferences (license, wallpaper, updated_at) VALUES (@license, @wallpaper, @now) " + + (IsMySql + ? "ON DUPLICATE KEY UPDATE wallpaper = VALUES(wallpaper), updated_at = VALUES(updated_at)" + : "ON CONFLICT(license) DO UPDATE SET wallpaper = excluded.wallpaper, updated_at = excluded.updated_at"), + ("@license", license), ("@wallpaper", wallpaper), ("@now", DateTime.UtcNow)); + } +} diff --git a/FivePRS.Server/Database/RosterStore.cs b/FivePRS.Server/Database/RosterStore.cs new file mode 100644 index 0000000..47ca9c4 --- /dev/null +++ b/FivePRS.Server/Database/RosterStore.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FivePRS.Core.Models; + +namespace FivePRS.Server.Database +{ + public sealed class RosterStore : SqlStore + { + public RosterStore(IDatabaseProvider db) : base(db) + { + } + + public Task> GetAllAsync() => + QueryAsync("SELECT license, department FROM fiveprs_roster", + reader => (reader.GetString(0), (Department)Convert.ToInt32(reader.GetValue(1)))); + + public Task GrantAsync(string license, Department department, string grantedBy) => + ExecuteAsync( + $"INSERT {(IsMySql ? "IGNORE" : "OR IGNORE")} INTO fiveprs_roster (license, department, granted_by, granted_at) " + + "VALUES (@license, @department, @by, @now)", + ("@license", license), ("@department", (int)department), ("@by", grantedBy), ("@now", DateTime.UtcNow)); + + public Task RevokeAsync(string license, Department department) => + ExecuteAsync("DELETE FROM fiveprs_roster WHERE license = @license AND department = @department", + ("@license", license), ("@department", (int)department)); + + public Task> SearchPlayersAsync(string term, int limit) => + QueryAsync( + $"SELECT license, name, department, rank_level, last_seen FROM ers_players WHERE name LIKE @term ORDER BY last_seen DESC LIMIT {limit}", + reader => new RosterPlayer + { + License = reader.GetString(0), + Name = reader.GetString(1), + Department = (Department)Convert.ToInt32(reader.GetValue(2)), + Rank = Convert.ToInt32(reader.GetValue(3)), + LastSeen = reader.IsDBNull(4) ? DateTime.MinValue : reader.GetDateTime(4), + }, + ("@term", $"%{term}%")); + } +} diff --git a/FivePRS.Server/Database/SQLiteProvider.cs b/FivePRS.Server/Database/SQLiteProvider.cs index dc8ee1d..35fe8cf 100644 --- a/FivePRS.Server/Database/SQLiteProvider.cs +++ b/FivePRS.Server/Database/SQLiteProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Data.Common; using System.Globalization; using System.IO; using System.Threading.Tasks; @@ -11,6 +12,10 @@ public sealed class SQLiteProvider : IDatabaseProvider { private readonly string _connectionString; + public SqlDialect Dialect => SqlDialect.Sqlite; + + public DbConnection CreateConnection() => new SqliteConnection(_connectionString); + public SQLiteProvider(string dbPath) { var dir = Path.GetDirectoryName(dbPath); @@ -28,34 +33,9 @@ public async Task InitializeAsync() using var cmd = conn.CreateCommand(); cmd.CommandText = @" PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; - - CREATE TABLE IF NOT EXISTS ers_players ( - license TEXT NOT NULL PRIMARY KEY, - name TEXT NOT NULL, - department INTEGER DEFAULT 0, - is_on_duty INTEGER DEFAULT 0, - xp INTEGER DEFAULT 0, - rank_level INTEGER DEFAULT 1, - agency TEXT NOT NULL DEFAULT '', - last_seen TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) - ); - - CREATE TABLE IF NOT EXISTS fiveprs_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL, - action TEXT NOT NULL, - actor_license TEXT, - actor_name TEXT NOT NULL, - target_license TEXT, - details TEXT NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_fiveprs_audit_target ON fiveprs_audit(target_license);"; + PRAGMA synchronous = NORMAL;"; await cmd.ExecuteNonQueryAsync(); - - await EnsureColumnAsync(conn, "ers_players", "agency", "TEXT NOT NULL DEFAULT ''"); } public async Task GetPlayerAsync(string license) @@ -65,7 +45,7 @@ details TEXT NOT NULL using var cmd = conn.CreateCommand(); cmd.CommandText = @" - SELECT license, name, department, is_on_duty, xp, rank_level, last_seen, agency + SELECT license, name, department, is_on_duty, xp, rank_level, last_seen, agency, callsign FROM ers_players WHERE license = $license LIMIT 1;"; cmd.Parameters.AddWithValue("$license", license); @@ -83,6 +63,7 @@ details TEXT NOT NULL LastSeen = DateTime.TryParse(reader.GetString(6), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var dt) ? dt : DateTime.UtcNow, Agency = reader.GetString(7), + Callsign = reader.GetString(8), }; } @@ -93,12 +74,13 @@ public async Task SavePlayerAsync(PlayerData player) using var cmd = conn.CreateCommand(); cmd.CommandText = @" - INSERT INTO ers_players (license, name, department, agency, is_on_duty, xp, rank_level, last_seen) - VALUES ($license, $name, $dept, $agency, $onDuty, $xp, $rank, $seen) + INSERT INTO ers_players (license, name, department, agency, callsign, is_on_duty, xp, rank_level, last_seen) + VALUES ($license, $name, $dept, $agency, $callsign, $onDuty, $xp, $rank, $seen) ON CONFLICT(license) DO UPDATE SET name = excluded.name, department = excluded.department, agency = excluded.agency, + callsign = excluded.callsign, is_on_duty = excluded.is_on_duty, xp = excluded.xp, rank_level = excluded.rank_level, @@ -108,6 +90,7 @@ ON CONFLICT(license) DO UPDATE SET cmd.Parameters.AddWithValue("$name", player.Name); cmd.Parameters.AddWithValue("$dept", (int)player.Department); cmd.Parameters.AddWithValue("$agency", player.Agency); + cmd.Parameters.AddWithValue("$callsign", player.Callsign); cmd.Parameters.AddWithValue("$onDuty", player.IsOnDuty ? 1 : 0); cmd.Parameters.AddWithValue("$xp", player.XP); cmd.Parameters.AddWithValue("$rank", player.Rank); @@ -149,18 +132,19 @@ public async Task UpdateDutyStatusAsync(string license, bool isOnDuty) await cmd.ExecuteNonQueryAsync(); } - private static async Task EnsureColumnAsync(SqliteConnection conn, string table, string column, string definition) + public async Task BackupAsync(string path) { - using (var check = conn.CreateCommand()) - { - check.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = $column;"; - check.Parameters.AddWithValue("$column", column); - if (Convert.ToInt64(await check.ExecuteScalarAsync()) > 0) return; - } - - using var alter = conn.CreateCommand(); - alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {definition};"; - await alter.ExecuteNonQueryAsync(); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + using var conn = new SqliteConnection(_connectionString); + await conn.OpenAsync(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "VACUUM INTO $path;"; + cmd.Parameters.AddWithValue("$path", Path.GetFullPath(path)); + await cmd.ExecuteNonQueryAsync(); } } } diff --git a/FivePRS.Server/Database/SqlDialect.cs b/FivePRS.Server/Database/SqlDialect.cs new file mode 100644 index 0000000..643ce46 --- /dev/null +++ b/FivePRS.Server/Database/SqlDialect.cs @@ -0,0 +1,8 @@ +namespace FivePRS.Server.Database +{ + public enum SqlDialect + { + Sqlite, + MySql, + } +} diff --git a/FivePRS.Server/Database/SqlStore.cs b/FivePRS.Server/Database/SqlStore.cs new file mode 100644 index 0000000..2abcc40 --- /dev/null +++ b/FivePRS.Server/Database/SqlStore.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; + +namespace FivePRS.Server.Database +{ + public abstract class SqlStore + { + protected SqlStore(IDatabaseProvider db) => Db = db; + + protected IDatabaseProvider Db { get; } + + protected bool IsMySql => Db.Dialect == SqlDialect.MySql; + + protected string LastInsertId => IsMySql ? "SELECT LAST_INSERT_ID()" : "SELECT last_insert_rowid()"; + + protected async Task ExecuteAsync(string sql, params (string Name, object Value)[] parameters) + { + using var conn = Db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = CreateCommand(conn, sql, parameters); + await cmd.ExecuteNonQueryAsync(); + } + + protected async Task ScalarAsync(string sql, params (string Name, object Value)[] parameters) + { + using var conn = Db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = CreateCommand(conn, sql, parameters); + return await cmd.ExecuteScalarAsync(); + } + + protected async Task> QueryAsync(string sql, Func read, params (string Name, object Value)[] parameters) + { + using var conn = Db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = CreateCommand(conn, sql, parameters); + using var reader = await cmd.ExecuteReaderAsync(); + + var rows = new List(); + while (await reader.ReadAsync()) + rows.Add(read(reader)); + return rows; + } + + private static DbCommand CreateCommand(DbConnection conn, string sql, (string Name, object Value)[] parameters) + { + var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var (name, value) in parameters) + { + var parameter = cmd.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + cmd.Parameters.Add(parameter); + } + return cmd; + } + } +} diff --git a/FivePRS.Server/Database/SqliteNativeLoader.cs b/FivePRS.Server/Database/SqliteNativeLoader.cs new file mode 100644 index 0000000..81b3b83 --- /dev/null +++ b/FivePRS.Server/Database/SqliteNativeLoader.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using SQLitePCL; + +namespace FivePRS.Server.Database +{ + internal static class SqliteNativeLoader + { + private const string LibraryName = "e_sqlite3"; + private const int RtldNow = 2; + private const int RtldGlobal = 0x100; + + private static bool _loaded; + + public static string Load(string directory) + { + var windows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var path = Path.Combine(directory, windows ? "e_sqlite3.dll" : "libe_sqlite3.so"); + if (_loaded) return path; + + if (!File.Exists(path)) + throw new FileNotFoundException("Native SQLite library is missing from the resource.", path); + + IGetFunctionPointer symbols = windows ? WindowsLibrary.Open(path) : UnixLibrary.Open(path); + + SQLite3Provider_dynamic_cdecl.Setup(LibraryName, symbols); + raw.SetProvider(new SQLite3Provider_dynamic_cdecl()); + raw.FreezeProvider(true); + + _loaded = true; + return path; + } + + private sealed class WindowsLibrary : IGetFunctionPointer + { + private readonly IntPtr _handle; + + private WindowsLibrary(IntPtr handle) => _handle = handle; + + public static WindowsLibrary Open(string path) + { + var handle = LoadLibraryW(path); + if (handle == IntPtr.Zero) + throw new DllNotFoundException($"Could not load native SQLite from {path} (Win32 error {Marshal.GetLastWin32Error()})."); + return new WindowsLibrary(handle); + } + + public IntPtr GetFunctionPointer(string name) => GetProcAddress(_handle, name); + + [DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr LoadLibraryW(string path); + + [DllImport("kernel32", CharSet = CharSet.Ansi, ExactSpelling = true)] + private static extern IntPtr GetProcAddress(IntPtr module, string name); + } + + private sealed class UnixLibrary : IGetFunctionPointer + { + private readonly IntPtr _handle; + private readonly bool _useLibdl; + + private UnixLibrary(IntPtr handle, bool useLibdl) + { + _handle = handle; + _useLibdl = useLibdl; + } + + public static UnixLibrary Open(string path) + { + IntPtr handle; + bool useLibdl; + try + { + handle = Internal.dlopen(path, RtldNow | RtldGlobal); + useLibdl = false; + } + catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException) + { + handle = Libdl.dlopen(path, RtldNow | RtldGlobal); + useLibdl = true; + } + + if (handle == IntPtr.Zero) + { + var error = useLibdl ? Libdl.dlerror() : Internal.dlerror(); + var message = error == IntPtr.Zero ? "unknown error" : Marshal.PtrToStringAnsi(error); + throw new DllNotFoundException($"Could not load native SQLite from {path} ({message})."); + } + + return new UnixLibrary(handle, useLibdl); + } + + public IntPtr GetFunctionPointer(string name) => + _useLibdl ? Libdl.dlsym(_handle, name) : Internal.dlsym(_handle, name); + + private static class Internal + { + [DllImport("__Internal")] + public static extern IntPtr dlopen(string path, int flags); + + [DllImport("__Internal")] + public static extern IntPtr dlsym(IntPtr handle, string name); + + [DllImport("__Internal")] + public static extern IntPtr dlerror(); + } + + private static class Libdl + { + [DllImport("libdl.so.2")] + public static extern IntPtr dlopen(string path, int flags); + + [DllImport("libdl.so.2")] + public static extern IntPtr dlsym(IntPtr handle, string name); + + [DllImport("libdl.so.2")] + public static extern IntPtr dlerror(); + } + } + } +} diff --git a/FivePRS.Server/Dispatch/DispatchService.cs b/FivePRS.Server/Dispatch/DispatchService.cs index 4cd6945..ff2bf51 100644 --- a/FivePRS.Server/Dispatch/DispatchService.cs +++ b/FivePRS.Server/Dispatch/DispatchService.cs @@ -60,8 +60,19 @@ private sealed class Call public float X { get; set; } public float Y { get; set; } public float Z { get; set; } + public bool IsEmergency { get; set; } + public string Description { get; set; } = string.Empty; + public string CallerName { get; set; } = string.Empty; + public int CallerId { get; set; } } + public const string EmergencyCallName = "911 Call"; + + private const int MaxCatalogSize = 500; + private const int MaxCalloutNameLength = 64; + private const int MaxCalloutWeight = 100; + private const int MaxCalloutCooldownSeconds = 86_400; + private readonly Func _clock; private readonly Func _settings; private readonly Func _territories; @@ -71,6 +82,7 @@ private sealed class Call private readonly Dictionary _calls = new(); private readonly Dictionary _catalog = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _lastDispatched = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _lastEmergency = new(); private int _nextCallNumber = 1000; @@ -90,7 +102,7 @@ public DispatchService(Func clock, Func settings, Fu public bool IsOnDuty(int unitId) => _units.ContainsKey(unitId); - public void SetOnDuty(int unitId, string name, Department department, int rank, string? agencyId = null) + public void SetOnDuty(int unitId, string name, Department department, int rank, string? agencyId = null, string? callsign = null) { SetOffDuty(unitId); @@ -107,13 +119,17 @@ public void SetOnDuty(int unitId, string name, Department department, int rank, unit.Info.Department = department; unit.Info.Agency = agency?.Id ?? string.Empty; unit.Info.Rank = rank; - unit.Info.Callsign = $"{prefix}-{unitId}"; + unit.Info.Callsign = callsign is null || callsign.Length == 0 ? $"{prefix}-{unitId}" : callsign; unit.Info.Status = UnitStatus.Available; _units[unitId] = unit; IsDirty = true; } + public bool IsCallsignTaken(string callsign, int exceptUnitId) => + _units.Values.Any(u => u.Info.ServerId != exceptUnitId && + string.Equals(u.Info.Callsign, callsign, StringComparison.OrdinalIgnoreCase)); + public void SetOffDuty(int unitId) { if (!_units.TryGetValue(unitId, out var unit)) return; @@ -140,25 +156,41 @@ public void UpdateRank(int unitId, int rank) IsDirty = true; } - public void RegisterCallouts(IEnumerable definitions) + public int RegisterCallouts(IEnumerable definitions, int maxXp = int.MaxValue) { + var accepted = 0; foreach (var definition in definitions) { - if (string.IsNullOrWhiteSpace(definition.Name) || definition.Weight < 1) continue; + if (!IsValidCallout(definition)) continue; + if (!_catalog.ContainsKey(definition!.Name) && _catalog.Count >= MaxCatalogSize) continue; + + definition.XPReward = Math.Min(definition.XPReward, maxXp); _catalog[definition.Name] = definition; + accepted++; } + return accepted; } + private static bool IsValidCallout(CalloutDefinition? definition) => + definition is not null && + !string.IsNullOrWhiteSpace(definition.Name) && definition.Name.Length <= MaxCalloutNameLength && + Enum.IsDefined(typeof(Department), definition.Department) && definition.Department != Department.None && + Enum.IsDefined(typeof(CalloutPriority), definition.Priority) && + definition.Weight >= 1 && definition.Weight <= MaxCalloutWeight && + definition.CooldownSeconds >= 0 && definition.CooldownSeconds <= MaxCalloutCooldownSeconds && + definition.XPReward >= 0; + public IReadOnlyList Tick() { var now = _clock(); ExpireStaleOffers(now); + ExpireUnansweredEmergencies(now); var offers = new List(); foreach (var unit in _units.Values) { - if (unit.Info.Status != UnitStatus.Available) continue; + if (unit.Info.Status != UnitStatus.Available || !unit.Info.AiCallouts) continue; if (unit.Info.CallId is not null || unit.PendingCall is not null) continue; if (now < unit.NextOfferUtc) continue; if (unit.HasPosition && !TerritoryMap.IsInJurisdiction(unit.Agency, unit.Info.Territory)) continue; @@ -289,6 +321,19 @@ public bool SetStatus(int unitId, UnitStatus status) return true; } + public bool SetAiCallouts(int unitId, bool enabled) + { + if (!_units.TryGetValue(unitId, out var unit)) return false; + if (unit.Info.AiCallouts == enabled) return true; + + unit.Info.AiCallouts = enabled; + if (enabled) + unit.NextOfferUtc = Max(unit.NextOfferUtc, _clock().AddSeconds(Settings.NoCalloutRetrySeconds)); + + IsDirty = true; + return true; + } + public void UpdatePosition(int unitId, float x, float y, float z) { if (!_units.TryGetValue(unitId, out var unit)) return; @@ -316,6 +361,78 @@ public void UpdatePosition(int unitId, float x, float y, float z) IsDirty = true; } + public (string? CallId, string? Error) CreateEmergencyCall(int callerId, string callerName, Department department, + string description, float x, float y, float z) + { + var now = _clock(); + + var open = FindOpenEmergency(callerId); + if (open is not null) return (null, $"You already have an open 911 call (#{open.Id})."); + + if (_lastEmergency.TryGetValue(callerId, out var last) && + (now - last).TotalSeconds < Settings.EmergencyCallCooldownSeconds) + { + var wait = Settings.EmergencyCallCooldownSeconds - (int)(now - last).TotalSeconds; + return (null, $"Please wait {wait}s before calling 911 again."); + } + + var call = new Call + { + Id = (_nextCallNumber++).ToString(), + Definition = new CalloutDefinition { Name = EmergencyCallName, Department = department, Priority = CalloutPriority.High }, + Status = CallStatus.Active, + OfferedUtc = now, + HasLocation = true, + Territory = _territories().Resolve(x, y)?.Id, + X = x, + Y = y, + Z = z, + IsEmergency = true, + Description = description, + CallerName = callerName, + CallerId = callerId, + }; + + _calls[call.Id] = call; + _lastEmergency[callerId] = now; + IsDirty = true; + return (call.Id, null); + } + + public bool CancelEmergencyCall(int callerId, string callId) + { + if (!_calls.TryGetValue(callId, out var call) || !call.IsEmergency || call.CallerId != callerId) return false; + + CloseCall(call, Settings.PostCompleteCooldownSeconds); + return true; + } + + public int? ClearEmergencyCall(int unitId, string callId) + { + if (!_calls.TryGetValue(callId, out var call) || !call.IsEmergency || !call.Attached.Contains(unitId)) return null; + + CloseCall(call, Settings.PostCompleteCooldownSeconds); + return call.CallerId; + } + + public int? GetEmergencyCaller(string callId) => + _calls.TryGetValue(callId, out var call) && call.IsEmergency ? call.CallerId : null; + + public EmergencyCallStatus? GetEmergencyStatus(int callerId) + { + var call = FindOpenEmergency(callerId); + return call is null ? null : new EmergencyCallStatus + { + CallId = call.Id, + Department = call.Definition.Department, + Description = call.Description, + Responding = call.Attached.Count(_units.ContainsKey), + }; + } + + public IEnumerable UnitsInDepartment(Department department) => + _units.Values.Where(u => u.Info.Department == department).Select(u => u.Info.ServerId).ToList(); + public UnitInfo? GetUnit(int unitId) => _units.TryGetValue(unitId, out var unit) ? unit.Info : null; @@ -336,10 +453,13 @@ public DispatchSnapshot CreateSnapshot() Priority = c.Definition.Priority, Territory = c.Territory, PrimaryUnit = c.PrimaryUnit, - Units = new[] { c.PrimaryUnit }.Concat(c.Attached).ToList(), + Units = (c.IsEmergency ? c.Attached : new[] { c.PrimaryUnit }.Concat(c.Attached)).ToList(), X = c.X, Y = c.Y, Z = c.Z, + IsEmergency = c.IsEmergency, + Description = c.IsEmergency ? c.Description : null, + Caller = c.IsEmergency ? c.CallerName : null, }) .ToList(), }; @@ -359,6 +479,23 @@ private void ExpireStaleOffers(DateTime now) Respond(call.PrimaryUnit, call.Id, OfferResponse.Declined, 0f, 0f, 0f); } + private Call? FindOpenEmergency(int callerId) => + _calls.Values.FirstOrDefault(c => c.IsEmergency && c.CallerId == callerId); + + private void ExpireUnansweredEmergencies(DateTime now) + { + var timeout = TimeSpan.FromMinutes(Settings.EmergencyCallTimeoutMinutes); + var stale = _calls.Values + .Where(c => c.IsEmergency && now - c.OfferedUtc > timeout && !c.Attached.Any(_units.ContainsKey)) + .ToList(); + + foreach (var call in stale) + { + _calls.Remove(call.Id); + IsDirty = true; + } + } + private CalloutDefinition? PickCallout(Department department, DateTime now) { var pool = _catalog.Values @@ -420,6 +557,7 @@ private void CloseCall(Call call, int primaryCooldownSeconds) Rank = info.Rank, Status = info.Status, CallId = info.CallId, + AiCallouts = info.AiCallouts, X = info.X, Y = info.Y, Z = info.Z, diff --git a/FivePRS.Server/FivePRS.Server.csproj b/FivePRS.Server/FivePRS.Server.csproj index 52d450e..2a86182 100644 --- a/FivePRS.Server/FivePRS.Server.csproj +++ b/FivePRS.Server/FivePRS.Server.csproj @@ -19,6 +19,7 @@ + @@ -27,7 +28,7 @@ - diff --git a/FivePRS.Server/Http/HttpBridge.cs b/FivePRS.Server/Http/HttpBridge.cs new file mode 100644 index 0000000..05631c6 --- /dev/null +++ b/FivePRS.Server/Http/HttpBridge.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Core.Events; + +namespace FivePRS.Server.Http +{ + internal sealed class HttpResult + { + public int Status { get; set; } + public string Body { get; set; } = string.Empty; + public string Error { get; set; } = string.Empty; + public bool Ok => Error.Length == 0 && Status >= 200 && Status < 300; + } + + internal static class HttpBridge + { + private const int DefaultTimeoutMs = 60_000; + + private static readonly Dictionary> Pending = new(); + + public static async Task GetAsync(string url, string savePath = "", string accept = "", string check = "", int timeoutMs = DefaultTimeoutMs) + { + var id = Guid.NewGuid().ToString("N"); + var completion = new TaskCompletionSource(); + Pending[id] = completion; + + BaseScript.TriggerEvent(EventNames.LocalHttpRequest, id, url, savePath, accept, check); + + var finished = await Task.WhenAny(completion.Task, BaseScript.Delay(timeoutMs)); + Pending.Remove(id); + + return finished == completion.Task ? completion.Task.Result : new HttpResult { Error = "timed out" }; + } + + public static void OnResponse(string id, int status, string body, string error) + { + if (id is not null && Pending.TryGetValue(id, out var completion)) + completion.TrySetResult(new HttpResult { Status = status, Body = body ?? string.Empty, Error = error ?? string.Empty }); + } + } +} diff --git a/FivePRS.Server/Permissions/PermissionService.cs b/FivePRS.Server/Permissions/PermissionService.cs index cf00c3d..18e4d1f 100644 --- a/FivePRS.Server/Permissions/PermissionService.cs +++ b/FivePRS.Server/Permissions/PermissionService.cs @@ -9,13 +9,18 @@ public sealed class PermissionService private readonly Func _isAceAllowed; private readonly Func _restrictDepartments; + private readonly Func _isOnRoster; - public PermissionService(Func isAceAllowed, Func restrictDepartments) + public PermissionService(Func isAceAllowed, Func restrictDepartments, + Func? isOnRoster = null) { _isAceAllowed = isAceAllowed; _restrictDepartments = restrictDepartments; + _isOnRoster = isOnRoster ?? ((_, _) => false); } + public bool RestrictsDepartments => _restrictDepartments(); + public static string DepartmentAce(Department department) => $"fiveprs.department.{department.ToString().ToLowerInvariant()}"; @@ -26,7 +31,7 @@ public bool CanJoinDepartment(string playerId, Department department) if (department == Department.None) return false; if (!_restrictDepartments()) return true; - return IsAdmin(playerId) || _isAceAllowed(playerId, DepartmentAce(department)); + return IsAdmin(playerId) || _isAceAllowed(playerId, DepartmentAce(department)) || _isOnRoster(playerId, department); } } } diff --git a/FivePRS.Server/Permissions/RosterService.cs b/FivePRS.Server/Permissions/RosterService.cs new file mode 100644 index 0000000..f5c2e5b --- /dev/null +++ b/FivePRS.Server/Permissions/RosterService.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Models; +using FivePRS.Server.Database; + +namespace FivePRS.Server.Permissions +{ + public sealed class RosterService + { + private const int SearchLimit = 25; + + private readonly RosterStore _store; + private readonly HashSet _entries = new(StringComparer.Ordinal); + + public RosterService(RosterStore store) => _store = store; + + public async Task LoadAsync() + { + _entries.Clear(); + foreach (var (license, department) in await _store.GetAllAsync()) + _entries.Add(Key(license, department)); + } + + public bool Contains(string? license, Department department) => + license is not null && _entries.Contains(Key(license, department)); + + public List DepartmentsFor(string license) => + Enum.GetValues(typeof(Department)).Cast().Where(d => Contains(license, d)).ToList(); + + public async Task SetAsync(string license, Department department, bool granted, string grantedBy) + { + if (department == Department.None || string.IsNullOrWhiteSpace(license)) return false; + if (Contains(license, department) == granted) return false; + + if (granted) + { + await _store.GrantAsync(license, department, grantedBy); + _entries.Add(Key(license, department)); + } + else + { + await _store.RevokeAsync(license, department); + _entries.Remove(Key(license, department)); + } + + return true; + } + + public async Task> SearchAsync(string? term) + { + if (!CivilianRules.TryNormalizeSearch(term, out var normalized)) return new List(); + + var players = await _store.SearchPlayersAsync(normalized, SearchLimit); + foreach (var player in players) + player.Departments = DepartmentsFor(player.License); + return players; + } + + private static string Key(string license, Department department) => $"{license}|{(int)department}"; + } +} diff --git a/FivePRS.Server/Security/RateLimiter.cs b/FivePRS.Server/Security/RateLimiter.cs new file mode 100644 index 0000000..c7fba16 --- /dev/null +++ b/FivePRS.Server/Security/RateLimiter.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using FivePRS.Core.Config; + +namespace FivePRS.Server.Security +{ + public enum RateLimitResult + { + Allowed, + Limited, + LimitedFirst, + } + + public sealed class RateLimiter + { + private sealed class Window + { + public DateTime StartedAt; + public int Count; + public bool Reported; + } + + private readonly Func _clock; + private readonly Func _enabled; + private readonly Dictionary> _windows = new(); + + public RateLimiter(Func clock, Func enabled) + { + _clock = clock; + _enabled = enabled; + } + + public RateLimitResult Check(string key, string bucket, RateLimit limit) + { + if (!_enabled() || limit.Limit <= 0 || limit.PerSeconds <= 0) return RateLimitResult.Allowed; + + if (!_windows.TryGetValue(key, out var buckets)) + _windows[key] = buckets = new Dictionary(); + + var now = _clock(); + if (!buckets.TryGetValue(bucket, out var window) || (now - window.StartedAt).TotalSeconds >= limit.PerSeconds) + buckets[bucket] = window = new Window { StartedAt = now }; + + if (window.Count < limit.Limit) + { + window.Count++; + return RateLimitResult.Allowed; + } + + if (window.Reported) return RateLimitResult.Limited; + window.Reported = true; + return RateLimitResult.LimitedFirst; + } + + public void Forget(string key) => _windows.Remove(key); + } +} diff --git a/FivePRS.Server/ServerBrain.Admin.cs b/FivePRS.Server/ServerBrain.Admin.cs index e09525d..5b66dc1 100644 --- a/FivePRS.Server/ServerBrain.Admin.cs +++ b/FivePRS.Server/ServerBrain.Admin.cs @@ -115,13 +115,23 @@ private async Task ForceOffDutyAsync(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."); - var owner = Players[primary.Value]; - TriggerClientEvent(owner, EventNames.ClientEndCallout); - Notify(owner, $"~o~Call #{callId} was closed by dispatch."); - Audit(AuditActions.AdminEndCall, caller, GetLicense(owner), $"call #{callId}"); + string? targetLicense = null; + if (primary.Value > 0) + { + var owner = Players[primary.Value]; + TriggerClientEvent(owner, EventNames.ClientEndCallout); + Notify(owner, $"~o~Call #{callId} was closed by dispatch."); + targetLicense = GetLicense(owner); + } + + if (emergencyCaller is not null) + SendEmergencyStatus(emergencyCaller.Value, $"Your 911 call #{callId} was closed by dispatch."); + + Audit(AuditActions.AdminEndCall, caller, targetLicense, $"call #{callId}"); return Task.FromResult($"Call #{callId} closed."); } diff --git a/FivePRS.Server/ServerBrain.Appearance.cs b/FivePRS.Server/ServerBrain.Appearance.cs new file mode 100644 index 0000000..a94fcc6 --- /dev/null +++ b/FivePRS.Server/ServerBrain.Appearance.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Core.Events; +using FivePRS.Server.Civilian; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private AppearanceService? _appearances; + + private void RegisterAppearanceEvents() + { + EventHandlers[EventNames.ServerAppearanceSave] += new Action(OnAppearanceSave); + } + + private void OnAppearanceSave([FromSource] Player player, int characterId, string json) => + RunCivilian(player, (_, license) => _appearances is null + ? Task.FromResult("Appearances are not available yet.") + : _appearances.SaveAsync(license, characterId, json)); + + private async Task SendAppearanceAsync(Player player, string license) + { + if (_civilians is null || _appearances is null) return; + + var active = await _civilians.GetActiveCharacterAsync(license); + var json = active is null ? null : await _appearances.GetAsync(active.Id); + TriggerClientEvent(player, EventNames.ClientAppearance, active?.Id ?? 0, json ?? string.Empty); + } + } +} diff --git a/FivePRS.Server/ServerBrain.Civilian.cs b/FivePRS.Server/ServerBrain.Civilian.cs new file mode 100644 index 0000000..8676003 --- /dev/null +++ b/FivePRS.Server/ServerBrain.Civilian.cs @@ -0,0 +1,111 @@ +using System; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Events; +using FivePRS.Server.Civilian; +using FivePRS.Server.Database; +using Newtonsoft.Json; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private CivilianService? _civilians; + + private void RegisterCivilianEvents() + { + EventHandlers[EventNames.ServerCivilianRequest] += new Action(OnCivilianRequest); + EventHandlers[EventNames.ServerCharacterCreate] += new Action(OnCharacterCreate); + EventHandlers[EventNames.ServerCharacterSelect] += new Action(OnCharacterSelect); + EventHandlers[EventNames.ServerCharacterDelete] += new Action(OnCharacterDelete); + EventHandlers[EventNames.ServerLicenseApply] += new Action(OnLicenseApply); + EventHandlers[EventNames.ServerVehicleRegister] += new Action(OnVehicleRegister); + EventHandlers[EventNames.ServerVehicleRemove] += new Action(OnVehicleRemove); + EventHandlers[EventNames.ServerVehicleSetStolen] += new Action(OnVehicleSetStolen); + } + + private void OnCivilianRequest([FromSource] Player player) => + RunCivilian(player, (_, __) => Task.FromResult(null)); + + private void OnCharacterCreate([FromSource] Player player, string firstName, string lastName, string dateOfBirth, string gender) => + RunCivilian(player, (service, license) => service.CreateCharacterAsync(license, firstName, lastName, dateOfBirth, gender), + AuditActions.CharacterCreated, $"{firstName} {lastName}"); + + private void OnCharacterSelect([FromSource] Player player, int characterId) => + RunCivilian(player, (service, license) => service.SelectCharacterAsync(license, characterId)); + + private void OnCharacterDelete([FromSource] Player player, int characterId) => + RunCivilian(player, async (service, license) => + { + var error = await service.DeleteCharacterAsync(license, characterId); + if (error is null && _appearances is not null) await _appearances.DeleteAsync(characterId); + return error; + }, + AuditActions.CharacterDeleted, $"character {characterId}"); + + private void OnLicenseApply([FromSource] Player player, string type) => + RunCivilian(player, (service, license) => service.ApplyForLicenseAsync(license, type), + AuditActions.LicenseIssued, type); + + private void OnVehicleRegister([FromSource] Player player, string model) + { + var ped = API.GetPlayerPed(player.Handle); + var vehicle = ped == 0 ? 0 : API.GetVehiclePedIsIn(ped, false); + + if (vehicle == 0 || API.GetPedInVehicleSeat(vehicle, -1) != ped) + { + TriggerClientEvent(player, EventNames.ClientCivilianError, "Get in the driver's seat of the vehicle you want to register."); + return; + } + + var plate = API.GetVehicleNumberPlateText(vehicle); + RunCivilian(player, (service, license) => service.RegisterVehicleAsync(license, plate, model), + AuditActions.VehicleRegistered, plate?.Trim() ?? string.Empty); + } + + private void OnVehicleRemove([FromSource] Player player, int vehicleId) => + RunCivilian(player, (service, license) => service.RemoveVehicleAsync(license, vehicleId), + AuditActions.VehicleRemoved, $"vehicle {vehicleId}"); + + private void OnVehicleSetStolen([FromSource] Player player, int vehicleId, bool stolen) => + RunCivilian(player, (service, license) => service.SetVehicleStolenAsync(license, vehicleId, stolen)); + + private async void RunCivilian(Player player, Func> action, + string? auditAction = null, string? auditDetails = null) + { + var license = GetLicense(player); + if (license is null) return; + + if (!Allow(player, "civilian", l => l.Civilian)) + { + TriggerClientEvent(player, EventNames.ClientCivilianError, "Slow down. Try again in a few seconds."); + return; + } + + if (_civilians is null) + { + TriggerClientEvent(player, EventNames.ClientCivilianError, "Civilian records are not available yet."); + return; + } + + try + { + var error = await action(_civilians, license); + if (error is not null) + TriggerClientEvent(player, EventNames.ClientCivilianError, error); + else if (auditAction is not null) + Audit(auditAction, player, license, auditDetails ?? string.Empty); + + var state = await _civilians.GetStateAsync(license); + TriggerClientEvent(player, EventNames.ClientCivilianState, JsonConvert.SerializeObject(state)); + await SendAppearanceAsync(player, license); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] Civilian action failed for {player.Name}: {ex}"); + TriggerClientEvent(player, EventNames.ClientCivilianError, "Something went wrong. Please try again."); + } + } + } +} diff --git a/FivePRS.Server/ServerBrain.Emergency.cs b/FivePRS.Server/ServerBrain.Emergency.cs new file mode 100644 index 0000000..a1bc258 --- /dev/null +++ b/FivePRS.Server/ServerBrain.Emergency.cs @@ -0,0 +1,130 @@ +using System; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Civilian; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using FivePRS.Server.Database; +using Newtonsoft.Json; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private const string AnonymousCaller = "Anonymous"; + + private void RegisterEmergencyEvents() + { + EventHandlers[EventNames.ServerEmergencyCall] += new Action(OnEmergencyCall); + EventHandlers[EventNames.ServerEmergencyCancel] += new Action(OnEmergencyCancel); + EventHandlers[EventNames.ServerEmergencyStatus] += new Action(OnEmergencyStatusRequest); + EventHandlers[EventNames.ServerCallClear] += new Action(OnCallClear); + } + + private async void OnEmergencyCall([FromSource] Player player, int departmentId, string description, bool anonymous) + { + if (!Allow(player, "emergency", l => l.Emergency)) + { + TriggerClientEvent(player, EventNames.ClientEmergencyError, "You're calling too often. Wait a moment and try again."); + return; + } + + var license = GetLicense(player); + if (license is null) return; + + if (!Enum.IsDefined(typeof(Department), departmentId) || departmentId == (int)Department.None) + { + TriggerClientEvent(player, EventNames.ClientEmergencyError, "Choose which service you need."); + return; + } + + var text = (description ?? string.Empty).Trim(); + if (text.Length == 0 || text.Length > CivilianRules.MaxEmergencyLength) + { + TriggerClientEvent(player, EventNames.ClientEmergencyError, + $"Describe the emergency in up to {CivilianRules.MaxEmergencyLength} characters."); + return; + } + + try + { + var callerName = anonymous ? AnonymousCaller : player.Name; + if (!anonymous && _civilians is not null) + callerName = (await _civilians.GetActiveCharacterAsync(license))?.FullName ?? player.Name; + + var position = API.GetEntityCoords(API.GetPlayerPed(player.Handle)); + var department = (Department)departmentId; + var (callId, error) = _dispatch.CreateEmergencyCall(ServerId(player), callerName, department, text, + position.X, position.Y, position.Z); + + if (error is not null) + { + TriggerClientEvent(player, EventNames.ClientEmergencyError, error); + return; + } + + Audit(AuditActions.EmergencyCall, player, license, $"call #{callId} {department}"); + + foreach (var unitId in _dispatch.UnitsInDepartment(department)) + { + if (TryGetPlayer(unitId, out var unit)) + Notify(unit, $"~r~911 Call #{callId}~w~ | {callerName}: {text}"); + } + + SendEmergencyStatus(ServerId(player), $"~b~911~w~ | Your call #{callId} has been sent to {department}."); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] 911 call failed for {player.Name}: {ex}"); + TriggerClientEvent(player, EventNames.ClientEmergencyError, "Something went wrong. Please try again."); + } + } + + private void OnEmergencyCancel([FromSource] Player player, string callId) + { + if (!Allow(player, "emergency", l => l.Emergency) || !IsCallId(callId)) return; + + if (_dispatch.CancelEmergencyCall(ServerId(player), callId)) + SendEmergencyStatus(ServerId(player), $"~b~911~w~ | Your call #{callId} was cancelled."); + } + + private void OnEmergencyStatusRequest([FromSource] Player player) + { + if (Allow(player, "requests", l => l.Requests)) SendEmergencyStatus(ServerId(player), null); + } + + private void OnCallClear([FromSource] Player player, string callId) + { + if (!Allow(player, "dispatch", l => l.Dispatch)) return; + if (!IsCallId(callId)) return; + + var caller = _dispatch.ClearEmergencyCall(ServerId(player), callId); + if (caller is null) + { + Notify(player, "~r~You can only clear 911 calls you are attached to."); + return; + } + + Notify(player, $"~g~Call ~y~#{callId}~g~ cleared."); + Audit(AuditActions.EmergencyCleared, player, null, $"call #{callId}"); + SendEmergencyStatus(caller.Value, $"~g~911~w~ | Your call #{callId} has been resolved."); + } + + private void SendEmergencyStatus(int callerId, string? message) + { + if (!TryGetPlayer(callerId, out var player)) return; + + if (message is not null) Notify(player, message); + TriggerClientEvent(player, EventNames.ClientEmergencyStatus, JsonConvert.SerializeObject(_dispatch.GetEmergencyStatus(callerId))); + } + + private bool TryGetPlayer(int serverId, out Player player) + { + player = null!; + if (serverId <= 0 || string.IsNullOrEmpty(API.GetPlayerName(serverId.ToString()))) return false; + + player = Players[serverId]; + return player is not null; + } + } +} diff --git a/FivePRS.Server/ServerBrain.Lookup.cs b/FivePRS.Server/ServerBrain.Lookup.cs new file mode 100644 index 0000000..32ec0d3 --- /dev/null +++ b/FivePRS.Server/ServerBrain.Lookup.cs @@ -0,0 +1,157 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using FivePRS.Server.Civilian; +using FivePRS.Server.Database; +using Newtonsoft.Json; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private const bool Lookups = true; + private const bool Records = false; + + private LookupService? _lookup; + + private void RegisterLookupEvents() + { + EventHandlers[EventNames.ServerLookupName] += new Action(OnLookupName); + EventHandlers[EventNames.ServerLookupPlate] += new Action(OnLookupPlate); + EventHandlers[EventNames.ServerLookupCharacter] += new Action(OnLookupCharacter); + EventHandlers[EventNames.ServerRecordIssue] += new Action(OnRecordIssue); + EventHandlers[EventNames.ServerRecordResolve] += new Action(OnRecordResolve); + EventHandlers[EventNames.ServerLicenseSetStatus] += new Action(OnLicenseSetStatus); + EventHandlers[EventNames.ServerVehicleFlag] += new Action(OnVehicleFlag); + } + + private void OnLookupName([FromSource] Player player, string term) => + RunLookup(player, Lookups, (lookup, _) => lookup.SearchByNameAsync(term)); + + private void OnLookupPlate([FromSource] Player player, string plate) => + RunLookup(player, Lookups, (lookup, _) => lookup.SearchByPlateAsync(plate)); + + private void OnLookupCharacter([FromSource] Player player, int characterId) => + RunLookup(player, Lookups, (lookup, _) => lookup.GetRecordAsync(characterId)); + + private void OnRecordIssue([FromSource] Player player, int characterId, int type, string description, int fine) => + RunLookup(player, Records, async (lookup, officer) => + { + var error = await lookup.IssueRecordAsync(officer, characterId, type, description, fine); + if (error is not null) return (null, error); + + var label = ((RecordType)type).ToString().ToLowerInvariant(); + Audit(AuditActions.RecordIssued, player, await OwnerLicenseAsync(characterId), $"{label} character {characterId}"); + await NotifyCharacterOwnerAsync(characterId, $"~o~Records update~w~ | A {label} was added to your record by {officer.Callsign}."); + return await lookup.GetRecordAsync(characterId); + }); + + private void OnRecordResolve([FromSource] Player player, int recordId, bool served) => + RunLookup(player, Records, async (lookup, officer) => + { + var (characterId, error) = await lookup.ResolveWarrantAsync(officer, recordId, served); + if (error is not null || characterId is null) return (null, error ?? "Warrant not found."); + + Audit(AuditActions.WarrantResolved, player, await OwnerLicenseAsync(characterId.Value), $"warrant {recordId} {(served ? "served" : "cleared")}"); + await NotifyCharacterOwnerAsync(characterId.Value, $"~g~Records update~w~ | A warrant on your record was {(served ? "served" : "cleared")}."); + return await lookup.GetRecordAsync(characterId.Value); + }); + + private void OnLicenseSetStatus([FromSource] Player player, int characterId, string type, int status) => + RunLookup(player, Records, async (lookup, officer) => + { + var error = await lookup.SetLicenseStatusAsync(characterId, type, status); + if (error is not null) return (null, error); + + var label = ((LicenseStatus)status).ToString().ToLowerInvariant(); + Audit(AuditActions.LicenseStatusSet, player, await OwnerLicenseAsync(characterId), $"{type} {label} character {characterId}"); + var name = ConfigManager.Licenses.Find(type)?.Name ?? type; + await NotifyCharacterOwnerAsync(characterId, $"~y~Records update~w~ | Your {name} is now {label}."); + return await lookup.GetRecordAsync(characterId); + }); + + private void OnVehicleFlag([FromSource] Player player, int vehicleId, bool stolen) => + RunLookup(player, Records, async (lookup, officer) => + { + var (characterId, error) = await lookup.SetVehicleStolenAsync(vehicleId, stolen); + if (error is not null || characterId is null) return (null, error ?? "Vehicle not found."); + + Audit(AuditActions.VehicleFlagged, player, await OwnerLicenseAsync(characterId.Value), $"vehicle {vehicleId} {(stolen ? "stolen" : "recovered")}"); + await NotifyCharacterOwnerAsync(characterId.Value, null); + return await lookup.GetRecordAsync(characterId.Value); + }); + + private async void RunLookup(Player player, bool lookups, Func> action) + { + if (lookups ? !Allow(player, "lookups", l => l.Lookups) : !Allow(player, "records", l => l.Records)) + { + TriggerClientEvent(player, EventNames.ClientLookupError, "Slow down. Try again in a few seconds."); + return; + } + + if (!TryGetOfficer(player, out var officer)) + { + TriggerClientEvent(player, EventNames.ClientLookupError, "Records are only available to on-duty police."); + return; + } + + if (_lookup is null) + { + TriggerClientEvent(player, EventNames.ClientLookupError, "Records are not available yet."); + return; + } + + try + { + var (result, error) = await action(_lookup, officer); + if (error is not null) + TriggerClientEvent(player, EventNames.ClientLookupError, error); + else if (result is not null) + TriggerClientEvent(player, EventNames.ClientLookupResult, JsonConvert.SerializeObject(result)); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] Records action failed for {player.Name}: {ex}"); + TriggerClientEvent(player, EventNames.ClientLookupError, "Something went wrong. Please try again."); + } + } + + private bool TryGetOfficer(Player player, out OfficerInfo officer) + { + officer = null!; + if (!TryGetCached(player, out var license, out var data)) return false; + if (!data.IsOnDuty || data.Department != Department.Police) return false; + + officer = new OfficerInfo + { + License = license, + Name = data.Name, + Callsign = _dispatch.GetUnit(ServerId(player))?.Callsign ?? data.Callsign, + }; + return true; + } + + private async Task OwnerLicenseAsync(int characterId) => + (await _db.Civilians.GetCharacterAsync(characterId))?.OwnerLicense; + + private async Task NotifyCharacterOwnerAsync(int characterId, string? message) + { + if (_civilians is null) return; + + var ownerLicense = await OwnerLicenseAsync(characterId); + if (ownerLicense is null) return; + + var owner = Players.FirstOrDefault(p => GetLicense(p) == ownerLicense); + if (owner is null) return; + + if (message is not null) Notify(owner, message); + var state = await _civilians.GetStateAsync(ownerLicense); + TriggerClientEvent(owner, EventNames.ClientCivilianState, JsonConvert.SerializeObject(state)); + } + } +} diff --git a/FivePRS.Server/ServerBrain.Positions.cs b/FivePRS.Server/ServerBrain.Positions.cs new file mode 100644 index 0000000..8b65e69 --- /dev/null +++ b/FivePRS.Server/ServerBrain.Positions.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Core.Config; +using FivePRS.Core.Events; +using FivePRS.Core.Models; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private const int PositionFlushMs = 300_000; + + private readonly Dictionary _positions = new(); + private readonly HashSet _unsavedPositions = new(); + + private void RegisterPositionEvents() + { + EventHandlers[EventNames.ServerReportPosition] += new Action(OnReportPosition); + EventHandlers[EventNames.ServerLastPositionRequest] += new Action(OnLastPositionRequest); + Tick += FlushPositionsTickAsync; + } + + private void OnReportPosition([FromSource] Player player, float x, float y, float z, float heading) + { + if (!Allow(player, "positions", l => l.Positions)) return; + + var license = GetLicense(player); + var position = SavedPosition.Create(x, y, z, heading); + if (license is null || position is null) return; + + _positions[license] = position; + _unsavedPositions.Add(license); + } + + private async void OnLastPositionRequest([FromSource] Player player) + { + if (!Allow(player, "requests", l => l.Requests)) return; + + var license = GetLicense(player); + if (license is null) return; + + SavedPosition? position = null; + try + { + if (ConfigManager.Settings.Spawn.RestoreLastLocation && _db.IsReady) + position = _positions.TryGetValue(license, out var cached) ? cached : await _db.Positions.GetAsync(license); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] Could not load the last position for {player.Name}: {ex.Message}"); + } + + TriggerClientEvent(player, EventNames.ClientLastPosition, position is not null, + position?.X ?? 0f, position?.Y ?? 0f, position?.Z ?? 0f, position?.Heading ?? 0f); + } + + private async Task SavePositionAsync(string license) + { + if (!_db.IsReady || !_unsavedPositions.Remove(license) || !_positions.TryGetValue(license, out var position)) return; + + try + { + await _db.Positions.SaveAsync(license, position); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] Could not save a position: {ex.Message}"); + } + } + + private async Task ForgetPositionAsync(string license) + { + await SavePositionAsync(license); + _positions.Remove(license); + } + + private async Task FlushPositionsTickAsync() + { + await Delay(PositionFlushMs); + foreach (var license in _unsavedPositions.ToList()) + await SavePositionAsync(license); + } + } +} diff --git a/FivePRS.Server/ServerBrain.Roster.cs b/FivePRS.Server/ServerBrain.Roster.cs new file mode 100644 index 0000000..102f8d4 --- /dev/null +++ b/FivePRS.Server/ServerBrain.Roster.cs @@ -0,0 +1,133 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Core.Events; +using FivePRS.Core.Models; +using FivePRS.Server.Database; +using FivePRS.Server.Permissions; +using Newtonsoft.Json; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private const int MaxLicenseLength = 60; + private const int MaxQueryLength = 64; + + private RosterService? _roster; + + private void RegisterRosterEvents() + { + EventHandlers[EventNames.ServerAdminRequest] += new Action(OnAdminRequest); + EventHandlers[EventNames.ServerAdminRosterSet] += new Action(OnAdminRosterSet); + } + + private bool IsOnRoster(string playerHandle, Department department) + { + if (_roster is null || !int.TryParse(playerHandle, out var id)) return false; + return TryGetPlayer(id, out var player) && _roster.Contains(GetLicense(player), department); + } + + private async void OnAdminRequest([FromSource] Player player, string query) + { + if (!_permissions.IsAdmin(player.Handle) || !Allow(player, "admin", l => l.Admin)) return; + await SendAdminStateAsync(player, TrimQuery(query)); + } + + private async void OnAdminRosterSet([FromSource] Player player, string license, int departmentId, bool granted, string query) + { + if (!_permissions.IsAdmin(player.Handle) || _roster is null || !Allow(player, "admin", l => l.Admin)) return; + if (!IsLicense(license)) return; + query = TrimQuery(query); + if (!Enum.IsDefined(typeof(Department), departmentId) || departmentId == (int)Department.None) return; + + var department = (Department)departmentId; + + try + { + if (await _roster.SetAsync(license, department, granted, player.Name)) + { + Audit(granted ? AuditActions.RosterGranted : AuditActions.RosterRevoked, player, license, department.ToString()); + await ApplyRosterChangeAsync(license, department, granted); + } + + await SendAdminStateAsync(player, query); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] Roster change failed for {player.Name}: {ex}"); + Notify(player, "~r~Roster change failed. Check the server console."); + } + } + + private static bool IsLicense(string? license) => + license is not null && license.Length <= MaxLicenseLength && license.StartsWith("license:", StringComparison.Ordinal); + + private static string TrimQuery(string? query) + { + var trimmed = (query ?? string.Empty).Trim(); + return trimmed.Length > MaxQueryLength ? trimmed.Substring(0, MaxQueryLength) : trimmed; + } + + private async Task ApplyRosterChangeAsync(string license, Department department, bool granted) + { + var target = Players.FirstOrDefault(p => GetLicense(p) == license); + if (target is null) return; + + SendEntryOptions(target); + + if (granted) + { + Notify(target, $"~g~Roster~w~ | You can now join {department}."); + return; + } + + Notify(target, $"~o~Roster~w~ | You were removed from {department}."); + + if (TryGetCached(target, out _, out var data) && data.IsOnDuty && data.Department == department && + !_permissions.CanJoinDepartment(target.Handle, department)) + { + await SetDutyAsync(target, data, false); + } + } + + private async Task SendAdminStateAsync(Player admin, string? query) + { + if (_roster is null) return; + + var online = Players + .Select(p => (Player: p, License: GetLicense(p))) + .Where(p => p.License is not null) + .Select(p => + { + var cached = TryGetCached(p.Player, out _, out var data) ? data : null; + return new RosterPlayer + { + License = p.License!, + Name = cached?.Name ?? p.Player.Name, + Rank = cached?.Rank ?? 1, + Department = cached?.Department ?? Department.None, + Online = true, + ServerId = ServerId(p.Player), + Departments = _roster.DepartmentsFor(p.License!), + }; + }) + .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var onlineLicenses = online.Select(p => p.License).ToList(); + var results = (await _roster.SearchAsync(query)).Where(p => !onlineLicenses.Contains(p.License)).ToList(); + + var state = new AdminState + { + RestrictDepartments = _permissions.RestrictsDepartments, + Query = query, + Online = online, + Results = results, + }; + + TriggerClientEvent(admin, EventNames.ClientAdminState, JsonConvert.SerializeObject(state)); + } + } +} diff --git a/FivePRS.Server/ServerBrain.Security.cs b/FivePRS.Server/ServerBrain.Security.cs new file mode 100644 index 0000000..cbab8e2 --- /dev/null +++ b/FivePRS.Server/ServerBrain.Security.cs @@ -0,0 +1,28 @@ +using System; +using CitizenFX.Core; +using FivePRS.Core.Config; +using FivePRS.Server.Security; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private const int MaxCallIdLength = 32; + + private readonly RateLimiter _rateLimiter = new(() => DateTime.UtcNow, () => ConfigManager.Settings.RateLimits.Enabled); + + private bool Allow(Player player, string bucket, Func limit) + { + var result = _rateLimiter.Check(player.Handle, bucket, limit(ConfigManager.Settings.RateLimits)); + if (result == RateLimitResult.LimitedFirst) + Debug.WriteLine($"[FivePRS] {player.Name} ({player.Handle}) is sending too many {bucket} requests; ignoring them for now."); + return result == RateLimitResult.Allowed; + } + + private static bool IsCallId(string? callId) => + !string.IsNullOrEmpty(callId) && callId!.Length <= MaxCallIdLength; + + private static bool IsFinite(float value) => !float.IsNaN(value) && !float.IsInfinity(value); + } +} + diff --git a/FivePRS.Server/ServerBrain.Terminal.cs b/FivePRS.Server/ServerBrain.Terminal.cs new file mode 100644 index 0000000..d5706a1 --- /dev/null +++ b/FivePRS.Server/ServerBrain.Terminal.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using CitizenFX.Core; +using FivePRS.Core.Config; +using FivePRS.Core.Events; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private void RegisterTerminalEvents() + { + EventHandlers[EventNames.ServerSetWallpaper] += new Action(OnSetWallpaper); + } + + private async void OnSetWallpaper([FromSource] Player player, string value) + { + if (!Allow(player, "civilian", l => l.Civilian)) return; + + var license = GetLicense(player); + if (license is null || !_db.IsReady) return; + + if (!ConfigManager.Settings.Terminal.TryNormalizeWallpaper(value, out var wallpaper)) + { + Notify(player, "Terminal | ~r~That wallpaper can't be used.~w~ Use an https image link."); + return; + } + + try + { + await _db.Preferences.SetWallpaperAsync(license, wallpaper); + TriggerClientEvent(player, EventNames.ClientPreferences, wallpaper); + } + catch (Exception ex) + { + Debug.WriteLine($"[FivePRS] Saving the wallpaper failed for {player.Name}: {ex.Message}"); + } + } + + private async Task SendPreferencesAsync(Player player, string license) + { + if (!_db.IsReady) return; + TriggerClientEvent(player, EventNames.ClientPreferences, await _db.Preferences.GetWallpaperAsync(license)); + } + } +} diff --git a/FivePRS.Server/ServerBrain.Updates.cs b/FivePRS.Server/ServerBrain.Updates.cs new file mode 100644 index 0000000..53a628d --- /dev/null +++ b/FivePRS.Server/ServerBrain.Updates.cs @@ -0,0 +1,171 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Core.Config; +using FivePRS.Core.Updates; +using FivePRS.Server.Http; + +namespace FivePRS.Server +{ + public partial class ServerBrain + { + private const int FirstUpdateCheckDelayMs = 15_000; + private const int DownloadTimeoutMs = 180_000; + private const string GitHubAccept = "application/vnd.github+json"; + + private static readonly string[] PreservedFolders = { "config/", "data/" }; + + private ReleaseInfo? _availableUpdate; + private string? _announcedVersion; + private bool _updateChecked; + private bool _downloadingUpdate; + + private void RegisterUpdates() + { + RegisterAdminCommand("fiveprs_update", "", 0, DownloadUpdateAsync); + Tick += UpdateCheckTickAsync; + } + + private static ReleaseVersion InstalledVersion() + { + var text = API.GetResourceMetadata(API.GetCurrentResourceName(), "version", 0); + return ReleaseVersion.TryParse(text, out var version) ? version : ReleaseVersion.Zero; + } + + private async Task UpdateCheckTickAsync() + { + var settings = ConfigManager.Settings.Updates; + await Delay(_updateChecked ? Math.Max(1, settings.CheckIntervalHours) * 3_600_000 : FirstUpdateCheckDelayMs); + if (settings.CheckForUpdates) await CheckForUpdateAsync(); + } + + private async Task CheckForUpdateAsync() + { + var settings = ConfigManager.Settings.Updates; + var installed = InstalledVersion(); + var first = !_updateChecked; + _updateChecked = true; + + var response = await HttpBridge.GetAsync(ReleaseFeed.ReleasesUrl(settings.Repository), accept: GitHubAccept); + if (!response.Ok) + { + Debug.WriteLine($"[FivePRS] Could not check for updates: {response.Error}"); + return; + } + + var latest = ReleaseFeed.Latest(response.Body, settings.AssetName, settings.IncludePreReleases || installed.IsPreRelease); + _availableUpdate = latest is not null && latest.Version.IsNewerThan(installed) ? latest : null; + + if (_availableUpdate is null) + { + if (first) Debug.WriteLine($"[FivePRS] FivePRS {installed} is up to date."); + return; + } + + var version = _availableUpdate.Version.ToString(); + if (_announcedVersion == version) return; + _announcedVersion = version; + + Debug.WriteLine($"[FivePRS] FivePRS {version} is available. You have {installed}. Release notes: {_availableUpdate.Url}"); + 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."); + + foreach (var player in Players.Where(player => _permissions.IsAdmin(player.Handle))) + NotifyUpdate(player); + } + + private void NotifyUpdate(Player player) + { + if (_availableUpdate is null) return; + 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) + { + if (_downloadingUpdate) return "An update is already downloading."; + + _downloadingUpdate = true; + try + { + await CheckForUpdateAsync(); + + var release = _availableUpdate; + if (release is null) return $"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}"; + + var version = release.Version.ToString(); + var resourcePath = API.GetResourcePath(API.GetCurrentResourceName()); + var zipRelative = $"data/updates/fiveprs-{version}.zip"; + var zipPath = Path.Combine(resourcePath, zipRelative); + var target = Path.Combine(resourcePath, "data", "updates", version); + Directory.CreateDirectory(Path.GetDirectoryName(zipPath)!); + + 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}"; + + 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}"; + } + 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."; + } + } + finally + { + _downloadingUpdate = false; + } + } + + private static string ExtractRelease(string zipPath, string target) + { + if (Directory.Exists(target)) Directory.Delete(target, true); + Directory.CreateDirectory(target); + + var root = Path.GetFullPath(target); + using var archive = ZipFile.OpenRead(zipPath); + + var prefix = CommonFolder(archive); + foreach (var entry in archive.Entries) + { + var relative = entry.FullName.Replace('\\', '/'); + if (prefix.Length > 0) relative = relative.Substring(prefix.Length); + if (relative.Length == 0 || PreservedFolders.Any(folder => relative.StartsWith(folder, StringComparison.OrdinalIgnoreCase))) continue; + + var destination = Path.GetFullPath(Path.Combine(root, relative)); + if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase)) continue; + + if (relative.EndsWith("/")) + { + Directory.CreateDirectory(destination); + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + entry.ExtractToFile(destination, true); + } + + return root; + } + + private static string CommonFolder(ZipArchive archive) + { + var first = archive.Entries.Select(entry => entry.FullName.Replace('\\', '/')).FirstOrDefault(name => name.Contains("/")); + if (first is null) return string.Empty; + + var folder = first.Substring(0, first.IndexOf('/') + 1); + return archive.Entries.All(entry => entry.FullName.Replace('\\', '/').StartsWith(folder, StringComparison.Ordinal)) ? folder : string.Empty; + } + } +} diff --git a/FivePRS.Server/ServerBrain.cs b/FivePRS.Server/ServerBrain.cs index 7b97ee0..10afe23 100644 --- a/FivePRS.Server/ServerBrain.cs +++ b/FivePRS.Server/ServerBrain.cs @@ -8,7 +8,9 @@ using FivePRS.Core.Config; using FivePRS.Core.Events; using FivePRS.Core.Models; +using FivePRS.Server.Civilian; using FivePRS.Server.Database; +using FivePRS.Server.Http; using FivePRS.Server.Dispatch; using FivePRS.Server.Permissions; using Newtonsoft.Json; @@ -18,6 +20,7 @@ namespace FivePRS.Server public partial class ServerBrain : BaseScript { private const int SnapshotIntervalMs = 5_000; + private const int MaxCalloutCatalogJson = 200_000; private readonly ConcurrentDictionary _cache = new(); private readonly DatabaseManager _db = new(); @@ -32,6 +35,7 @@ public ServerBrain() var resource = API.GetCurrentResourceName(); ConfigManager.LoadSettings(API.LoadResourceFile(resource, "config/settings.json")); ConfigManager.LoadJurisdictions(API.LoadResourceFile(resource, "config/jurisdictions.json")); + ConfigManager.LoadLicenses(API.LoadResourceFile(resource, "config/licenses.json")); _dispatch = new DispatchService( () => DateTime.UtcNow, @@ -40,24 +44,35 @@ public ServerBrain() new Random()); _permissions = new PermissionService( (playerId, ace) => API.IsPlayerAceAllowed(playerId, ace), - () => API.GetConvar("fiveprs_restrict_departments", "false").Equals("true", StringComparison.OrdinalIgnoreCase)); + () => API.GetConvar("fiveprs_restrict_departments", "false").Equals("true", StringComparison.OrdinalIgnoreCase), + IsOnRoster); EventHandlers["playerConnecting"] += new Action(OnPlayerConnecting); EventHandlers["playerDropped"] += new Action(OnPlayerDropped); EventHandlers[EventNames.ServerPlayerConnected] += new Action(OnPlayerReady); EventHandlers[EventNames.ServerToggleDuty] += new Action(OnToggleDuty); - EventHandlers[EventNames.ServerSetDepartment] += new Action(OnSetDepartment); - EventHandlers[EventNames.ServerSetAgency] += new Action(OnSetAgency); + EventHandlers[EventNames.ServerEnterService] += new Action(OnEnterService); EventHandlers[EventNames.ServerRegisterCallouts] += new Action(OnRegisterCallouts); EventHandlers[EventNames.ServerCalloutResponse] += new Action(OnCalloutResponse); EventHandlers[EventNames.ServerCalloutEnded] += new Action(OnCalloutEnded); EventHandlers[EventNames.ServerSetUnitStatus] += new Action(OnSetUnitStatus); EventHandlers[EventNames.ServerAttachToCall] += new Action(OnAttachToCall); + EventHandlers[EventNames.ServerSetAiCallouts] += new Action(OnSetAiCallouts); + EventHandlers[EventNames.LocalHttpResponse] += new Action(HttpBridge.OnResponse); Tick += DispatchTickAsync; RegisterAdminCommands(); - + RegisterCivilianEvents(); + RegisterLookupEvents(); + RegisterEmergencyEvents(); + RegisterRosterEvents(); + RegisterAppearanceEvents(); + RegisterTerminalEvents(); + RegisterPositionEvents(); + RegisterUpdates(); + + _ = ServerIcon.ApplyDefaultAsync(ConfigManager.Settings.Branding.ServerIcon); _ = InitDbAsync(); } @@ -70,6 +85,11 @@ private async Task InitDbAsync() var dbType = dbTypeRaw == "mysql" ? DatabaseType.MySQL : DatabaseType.SQLite; 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); + _appearances = new AppearanceService(_db.Civilians, _db.Appearances, () => ConfigManager.Settings.Creator); + _roster = new RosterService(_db.Roster); + await _roster.LoadAsync(); Debug.WriteLine("[FivePRS] ServerBrain online."); } @@ -105,7 +125,8 @@ private async void OnPlayerConnecting( try { - await LoadProfileAsync(license, playerName); + var data = await LoadProfileAsync(license, playerName); + HandOverProfile(deferrals, data); deferrals.done(); } catch (Exception ex) @@ -117,6 +138,7 @@ private async void OnPlayerConnecting( private void OnPlayerDropped([FromSource] Player player, string reason) { + _rateLimiter.Forget(player.Handle); _dispatch.SetOffDuty(ServerId(player)); var license = GetLicense(player); @@ -124,10 +146,13 @@ private void OnPlayerDropped([FromSource] Player player, string reason) if (_cache.TryRemove(license, out var data) && data.IsOnDuty) _ = _db.UpdateDutyStatusAsync(license, false); + + _ = ForgetPositionAsync(license); } private async void OnPlayerReady([FromSource] Player player) { + if (!Allow(player, "requests", l => l.Requests)) return; var license = GetLicense(player); if (license is null) return; @@ -140,6 +165,11 @@ private async void OnPlayerReady([FromSource] Player player) } SendPlayerData(player, data); + SendEntryOptions(player); + await SendAppearanceAsync(player, license); + await SendPreferencesAsync(player, license); + + if (_permissions.IsAdmin(player.Handle)) NotifyUpdate(player); } catch (Exception ex) { @@ -147,93 +177,135 @@ private async void OnPlayerReady([FromSource] Player player) } } - private async void OnToggleDuty([FromSource] Player player) + private async void OnEnterService([FromSource] Player player, int departmentId, string agencyId, string callsign) { - if (!TryGetCached(player, out var license, out var data)) return; - if (!data.IsOnDuty && data.Department == Department.None) return; + if (!Allow(player, "dispatch", l => l.Dispatch)) return; + if (!TryGetCached(player, out var license, out var data) || data.IsOnDuty) return; + if (!Enum.IsDefined(typeof(Department), departmentId) || departmentId == (int)Department.None) return; - var goingOnDuty = !data.IsOnDuty; - if (goingOnDuty && !_permissions.CanJoinDepartment(player.Handle, data.Department)) + var department = (Department)departmentId; + if (!_permissions.CanJoinDepartment(player.Handle, department)) { - Notify(player, $"~r~You are not authorised to go on duty with {data.Department}."); - Audit(AuditActions.PermissionDenied, player, license, $"duty {data.Department}"); + TriggerClientEvent(player, EventNames.ClientEntryRejected, $"You are not authorised to join {department}."); + Audit(AuditActions.PermissionDenied, player, license, $"department {department}"); + return; + } + + if (!Callsign.TryNormalize(callsign, out var normalizedCallsign)) + { + TriggerClientEvent(player, EventNames.ClientEntryRejected, + $"Callsigns can be up to {Callsign.MaxLength} letters, numbers or hyphens."); + return; + } + + if (normalizedCallsign.Length > 0 && _dispatch.IsCallsignTaken(normalizedCallsign, ServerId(player))) + { + TriggerClientEvent(player, EventNames.ClientEntryRejected, $"Callsign {normalizedCallsign} is already in use."); return; } try { - await SetDutyAsync(player, data, goingOnDuty); - Audit(goingOnDuty ? AuditActions.DutyOn : AuditActions.DutyOff, player, license, data.Department.ToString()); + var agency = ConfigManager.Territories.ResolveAgency(department, agencyId)?.Id ?? string.Empty; + data.Callsign = normalizedCallsign; + + if (data.Department != department) + { + data.Department = department; + Audit(AuditActions.DepartmentSet, player, license, department.ToString()); + } + + if (data.Agency != agency) + { + data.Agency = agency; + Audit(AuditActions.AgencySet, player, license, agency); + } + + await _db.SavePlayerAsync(data); + SendPlayerData(player, data); + + await SetDutyAsync(player, data, true); + Audit(AuditActions.DutyOn, player, license, department.ToString()); } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Error toggling duty for {player.Name}: {ex.Message}"); + Debug.WriteLine($"[FivePRS] Error entering service for {player.Name}: {ex.Message}"); + TriggerClientEvent(player, EventNames.ClientEntryRejected, "Something went wrong. Please try again."); } } - private async void OnSetDepartment([FromSource] Player player, int departmentId) + private void SendEntryOptions(Player player) { - if (!Enum.IsDefined(typeof(Department), departmentId) || departmentId == (int)Department.None) return; - if (!TryGetCached(player, out var license, out var data)) return; - - var department = (Department)departmentId; - if (!_permissions.CanJoinDepartment(player.Handle, department)) + var allowed = new List(); + foreach (Department department in Enum.GetValues(typeof(Department))) { - Notify(player, $"~r~You are not authorised to join {department}."); - Audit(AuditActions.PermissionDenied, player, license, $"department {department}"); - return; + if (_permissions.CanJoinDepartment(player.Handle, department)) + allowed.Add((int)department); } + TriggerClientEvent(player, EventNames.ClientEntryOptions, JsonConvert.SerializeObject(allowed)); + } + + private static void HandOverProfile(dynamic deferrals, PlayerData data) + { try { - await ChangeDepartmentAsync(player, data, department); - Audit(AuditActions.DepartmentSet, player, license, department.ToString()); + var agency = data.Department == Department.None + ? string.Empty + : ConfigManager.Territories.FindAgency(data.Agency)?.Name ?? data.Department.ToString(); + + deferrals.handover(new Dictionary + { + ["fiveprs"] = new Dictionary + { + ["name"] = data.Name, + ["rank"] = data.Rank, + ["agency"] = agency, + }, + }); } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Error setting department for {player.Name}: {ex.Message}"); + Debug.WriteLine($"[FivePRS] Loading screen handover failed: {ex.Message}"); } } - private async void OnSetAgency([FromSource] Player player, string agencyId) + private async void OnToggleDuty([FromSource] Player player) { + if (!Allow(player, "dispatch", l => l.Dispatch)) return; if (!TryGetCached(player, out var license, out var data)) return; + if (!data.IsOnDuty && data.Department == Department.None) return; - var agency = ConfigManager.Territories.FindAgency(agencyId); - if (agency is null || agency.Department != data.Department) + var goingOnDuty = !data.IsOnDuty; + if (goingOnDuty && !_permissions.CanJoinDepartment(player.Handle, data.Department)) { - Notify(player, $"~r~Unknown agency '{agencyId}' for {data.Department}."); + Notify(player, $"~r~You are not authorised to go on duty with {data.Department}."); + Audit(AuditActions.PermissionDenied, player, license, $"duty {data.Department}"); return; } try { - if (data.IsOnDuty) - await SetDutyAsync(player, data, false); - - data.Agency = agency.Id; - await _db.SavePlayerAsync(data); - SendPlayerData(player, data); - - Notify(player, $"~g~You are now with the {agency.Name}."); - Audit(AuditActions.AgencySet, player, license, agency.Id); + await SetDutyAsync(player, data, goingOnDuty); + Audit(goingOnDuty ? AuditActions.DutyOn : AuditActions.DutyOff, player, license, data.Department.ToString()); } catch (Exception ex) { - Debug.WriteLine($"[FivePRS] Error setting agency for {player.Name}: {ex.Message}"); + Debug.WriteLine($"[FivePRS] Error toggling duty for {player.Name}: {ex.Message}"); } } private void OnRegisterCallouts([FromSource] Player player, string json) { - if (!_dispatch.IsOnDuty(ServerId(player))) return; + if (!Allow(player, "dispatch", l => l.Dispatch)) return; + if (!_dispatch.IsOnDuty(ServerId(player)) || json is null || json.Length > MaxCalloutCatalogJson) return; try { var definitions = JsonConvert.DeserializeObject>(json); if (definitions is null) return; - _dispatch.RegisterCallouts(definitions); + if (_dispatch.RegisterCallouts(definitions, ReadMaxXp()) == 0) return; Debug.WriteLine($"[FivePRS] Dispatch catalog has {_dispatch.CatalogCount} callout(s)."); } catch (Exception ex) @@ -244,20 +316,33 @@ private void OnRegisterCallouts([FromSource] Player player, string json) private void OnCalloutResponse([FromSource] Player player, string callId, int response, float x, float y, float z) { + if (!Allow(player, "dispatch", l => l.Dispatch)) return; + if (!IsCallId(callId) || !IsFinite(x) || !IsFinite(y) || !IsFinite(z)) return; if (!Enum.IsDefined(typeof(OfferResponse), response)) return; _dispatch.Respond(ServerId(player), callId, (OfferResponse)response, x, y, z); } private async void OnCalloutEnded([FromSource] Player player, string callId, int result) { - if (!Enum.IsDefined(typeof(CalloutResult), result)) return; + if (!Allow(player, "dispatch", l => l.Dispatch)) return; + if (!IsCallId(callId) || !Enum.IsDefined(typeof(CalloutResult), result)) return; foreach (var award in _dispatch.End(ServerId(player), callId, (CalloutResult)result)) await AwardXpAsync(award.UnitId, award.Amount, callId); } + private void OnSetAiCallouts([FromSource] Player player, bool enabled) + { + if (!Allow(player, "dispatch", l => l.Dispatch)) return; + if (_dispatch.SetAiCallouts(ServerId(player), enabled)) + Notify(player, enabled + ? "Dispatch | ~g~AI callouts on.~w~ Dispatch will offer you calls again." + : "Dispatch | ~o~AI callouts off.~w~ Player 911 calls still reach you."); + } + 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)) @@ -266,9 +351,20 @@ private void OnSetUnitStatus([FromSource] Player player, int status) private void OnAttachToCall([FromSource] Player player, string callId) { - Notify(player, _dispatch.Attach(ServerId(player), callId) - ? $"~g~Attached to call ~y~#{callId}~w~. Waypoint set." - : $"~r~Unable to attach to call #{callId}.~w~ Check the ID and your status."); + if (!Allow(player, "dispatch", l => l.Dispatch)) return; + if (!IsCallId(callId)) return; + + if (!_dispatch.Attach(ServerId(player), callId)) + { + Notify(player, $"~r~Unable to attach to call #{callId}.~w~ Check the ID and your status."); + return; + } + + Notify(player, $"~g~Attached to call ~y~#{callId}~w~. Waypoint set."); + + var caller = _dispatch.GetEmergencyCaller(callId); + if (caller is not null) + SendEmergencyStatus(caller.Value, "~b~911~w~ | Units are responding to your call."); } private async Task DispatchTickAsync() @@ -320,7 +416,7 @@ private async Task SetDutyAsync(Player player, PlayerData data, bool onDuty) data.IsOnDuty = onDuty; if (onDuty) - _dispatch.SetOnDuty(ServerId(player), data.Name, data.Department, data.Rank, data.Agency); + _dispatch.SetOnDuty(ServerId(player), data.Name, data.Department, data.Rank, data.Agency, data.Callsign); else _dispatch.SetOffDuty(ServerId(player)); diff --git a/FivePRS.Server/ServerIcon.cs b/FivePRS.Server/ServerIcon.cs new file mode 100644 index 0000000..6e53d69 --- /dev/null +++ b/FivePRS.Server/ServerIcon.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using CitizenFX.Core; +using CitizenFX.Core.Native; +using FivePRS.Server.Http; + +namespace FivePRS.Server +{ + internal static class ServerIcon + { + private const string RelativePath = "data/server_icon.png"; + private const string LoadCommandAce = "command.load_server_icon"; + + public static async Task ApplyDefaultAsync(string url) + { + if (!IsEnabled() || HasIcon() || string.IsNullOrWhiteSpace(url)) return; + + var resourcePath = API.GetResourcePath(API.GetCurrentResourceName()); + var path = Path.Combine(resourcePath, RelativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + await BaseScript.Delay(0); + + 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 (HasIcon()) return; + + var resource = API.GetCurrentResourceName(); + 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."); + 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."); + } + + private static bool IsEnabled() => + string.Equals(API.GetConvar("fiveprs_server_icon", "true"), "true", StringComparison.OrdinalIgnoreCase); + + private static bool HasIcon() => !string.IsNullOrEmpty(API.GetConvar("sv_icon", string.Empty)); + } +} diff --git a/FivePRS.Tests/AppearanceTests.cs b/FivePRS.Tests/AppearanceTests.cs new file mode 100644 index 0000000..8c01fde --- /dev/null +++ b/FivePRS.Tests/AppearanceTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Server.Civilian; +using Newtonsoft.Json; +using Xunit; + +namespace FivePRS.Tests +{ + public class AppearanceRulesTests + { + private static readonly string[] Standard = { "a_m_y_skater_01" }; + + [Fact] + public void Sanitize_ClampsValuesAndDropsInvalidEntries() + { + var result = AppearanceRules.Sanitize(new CharacterAppearance + { + Model = "MP_F_FREEMODE_01", + HeadBlend = new HeadBlend { ShapeFirst = 99, SkinSecond = -3, ShapeMix = 4f, SkinMix = float.NaN }, + FaceFeatures = Enumerable.Repeat(3f, 30).ToList(), + Overlays = new List + { + new() { Index = 2, Style = 500, Opacity = 2f, Color = 99 }, + new() { Index = 40, Style = 1 }, + }, + EyeColor = 90, + Components = new List { new() { Slot = 11, Drawable = 5, Texture = 2 }, new() { Slot = 12, Drawable = 1 } }, + Props = new List { new() { Slot = 0, Drawable = -5, Texture = -9 }, new() { Slot = 3, Drawable = 1 } }, + }, Standard); + + Assert.NotNull(result); + Assert.Equal(AppearanceRules.FemaleFreemode, result!.Model); + Assert.Equal(45, result.HeadBlend.ShapeFirst); + Assert.Equal(0, result.HeadBlend.SkinSecond); + Assert.Equal(1f, result.HeadBlend.ShapeMix); + Assert.Equal(0f, result.HeadBlend.SkinMix); + Assert.Equal(AppearanceRules.FaceFeatureCount, result.FaceFeatures.Count); + Assert.All(result.FaceFeatures, f => Assert.Equal(1f, f)); + var overlay = Assert.Single(result.Overlays); + Assert.Equal((2, 254, 1f, 63), (overlay.Index, overlay.Style, overlay.Opacity, overlay.Color)); + Assert.Equal(AppearanceRules.MaxEyeColor, result.EyeColor); + Assert.Equal(11, Assert.Single(result.Components).Slot); + var prop = Assert.Single(result.Props); + Assert.Equal((0, -1, -1), (prop.Slot, prop.Drawable, prop.Texture)); + } + + [Theory] + [InlineData("a_m_y_skater_01", true)] + [InlineData("mp_m_freemode_01", true)] + [InlineData("a_c_chop", false)] + [InlineData("bad model;", false)] + public void Sanitize_OnlyAllowsFreemodeAndListedPeds(string model, bool allowed) + { + Assert.Equal(allowed, AppearanceRules.Sanitize(new CharacterAppearance { Model = model }, Standard) is not null); + } + } + + [Collection(TestDatabase.Collection)] + public sealed class AppearanceServiceTests : IAsyncLifetime + { + private const string Owner = "license:owner"; + + private TestDatabase _database = null!; + private CivilianService _civilians = null!; + private AppearanceService _service = null!; + + public async Task InitializeAsync() + { + _database = await TestDatabase.CreateAsync(); + _civilians = new CivilianService(_database.Store, () => 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"); + } + + public Task DisposeAsync() + { + _database.Dispose(); + return Task.CompletedTask; + } + + private async Task CharacterIdAsync() => (await _civilians.GetActiveCharacterAsync(Owner))!.Id; + + [Fact] + public async Task Save_StoresSanitizedAppearanceAndOverwrites() + { + var id = await CharacterIdAsync(); + + Assert.Null(await _service.SaveAsync(Owner, id, JsonConvert.SerializeObject(new CharacterAppearance { Model = "mp_f_freemode_01", EyeColor = 99 }))); + Assert.Null(await _service.SaveAsync(Owner, id, JsonConvert.SerializeObject(new CharacterAppearance { Model = "a_f_y_hipster_01", EyeColor = 3 }))); + + var stored = JsonConvert.DeserializeObject((await _service.GetAsync(id))!)!; + Assert.Equal("a_f_y_hipster_01", stored.Model); + Assert.Equal(3, stored.EyeColor); + } + + [Fact] + public async Task Save_RejectsOtherOwnersInvalidJsonAndUnknownModels() + { + var id = await CharacterIdAsync(); + + Assert.NotNull(await _service.SaveAsync("license:other", id, JsonConvert.SerializeObject(new CharacterAppearance()))); + Assert.NotNull(await _service.SaveAsync(Owner, id, "{not json")); + Assert.NotNull(await _service.SaveAsync(Owner, id, JsonConvert.SerializeObject(new CharacterAppearance { Model = "a_c_chop" }))); + Assert.Null(await _service.GetAsync(id)); + } + + [Fact] + public async Task Delete_RemovesAppearance() + { + var id = await CharacterIdAsync(); + await _service.SaveAsync(Owner, id, JsonConvert.SerializeObject(new CharacterAppearance())); + + await _service.DeleteAsync(id); + + Assert.Null(await _service.GetAsync(id)); + } + } +} diff --git a/FivePRS.Tests/CallsignTests.cs b/FivePRS.Tests/CallsignTests.cs new file mode 100644 index 0000000..bbb489f --- /dev/null +++ b/FivePRS.Tests/CallsignTests.cs @@ -0,0 +1,29 @@ +using FivePRS.Core.Models; +using Xunit; + +namespace FivePRS.Tests +{ + public class CallsignTests + { + [Theory] + [InlineData("1-adam-12", "1-ADAM-12")] + [InlineData(" lspd21 ", "LSPD21")] + [InlineData("", "")] + [InlineData(null, "")] + public void TryNormalize_ValidInput_UppercasesAndTrims(string? input, string expected) + { + Assert.True(Callsign.TryNormalize(input, out var callsign)); + Assert.Equal(expected, callsign); + } + + [Theory] + [InlineData("ADAM 12")] + [InlineData("ADAM_12")] + [InlineData("ÄDAM")] + [InlineData("ABCDEFGHIJKLM")] + public void TryNormalize_InvalidInput_IsRejected(string input) + { + Assert.False(Callsign.TryNormalize(input, out _)); + } + } +} diff --git a/FivePRS.Tests/CivilianServiceTests.cs b/FivePRS.Tests/CivilianServiceTests.cs new file mode 100644 index 0000000..db70803 --- /dev/null +++ b/FivePRS.Tests/CivilianServiceTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Server.Civilian; +using FivePRS.Server.Database; +using Xunit; + +namespace FivePRS.Tests +{ + [Collection(TestDatabase.Collection)] + public sealed class CivilianServiceTests : IAsyncLifetime + { + private const string Owner = "license:owner"; + private const string Other = "license:other"; + + private readonly ResourceSettings _settings = new() { MaxCharacters = 2, MaxVehiclesPerCharacter = 2 }; + private TestDatabase _database = null!; + private CivilianStore _store = null!; + private CivilianService _service = null!; + + public async Task InitializeAsync() + { + _database = await TestDatabase.CreateAsync(); + _store = _database.Store; + _service = new CivilianService(_store, () => _settings, () => new LicensesConfig(), () => new DateTime(2026, 10, 9)); + } + + public Task DisposeAsync() + { + _database.Dispose(); + return Task.CompletedTask; + } + + private Task CreateAsync(string owner = Owner, string first = "john", string dob = "1990-05-01") => + _service.CreateCharacterAsync(owner, first, "Smith", dob, "male"); + + [Fact] + public async Task CreateCharacter_Valid_BecomesActive() + { + Assert.Null(await CreateAsync()); + + var state = await _service.GetStateAsync(Owner); + var character = Assert.Single(state.Characters); + Assert.Equal("John", character.FirstName); + Assert.Equal("Male", character.Gender); + Assert.Equal(character.Id, state.ActiveCharacterId); + } + + [Theory] + [InlineData("J0hn", "1990-05-01")] + [InlineData("John", "1990-13-01")] + [InlineData("John", "2015-01-01")] + public async Task CreateCharacter_InvalidInput_IsRejected(string first, string dob) + { + Assert.NotNull(await CreateAsync(first: first, dob: dob)); + Assert.Empty((await _service.GetStateAsync(Owner)).Characters); + } + + [Fact] + public async Task CreateCharacter_OverLimit_IsRejected() + { + Assert.Null(await CreateAsync(first: "One")); + Assert.Null(await CreateAsync(first: "Two")); + Assert.NotNull(await CreateAsync(first: "Three")); + } + + [Fact] + public async Task SelectCharacter_SwitchesActive_OnlyForOwner() + { + await CreateAsync(first: "One"); + await CreateAsync(first: "Two"); + var first = (await _service.GetStateAsync(Owner)).Characters.Single(c => c.FirstName == "One"); + + Assert.NotNull(await _service.SelectCharacterAsync(Other, first.Id)); + Assert.Null(await _service.SelectCharacterAsync(Owner, first.Id)); + Assert.Equal(first.Id, (await _service.GetStateAsync(Owner)).ActiveCharacterId); + } + + [Fact] + public async Task DeleteCharacter_RemovesLicensesAndVehicles() + { + await CreateAsync(); + await _service.ApplyForLicenseAsync(Owner, "driver"); + await _service.RegisterVehicleAsync(Owner, "abc 123", "Sultan"); + var id = (await _service.GetStateAsync(Owner)).ActiveCharacterId!.Value; + + Assert.NotNull(await _service.DeleteCharacterAsync(Other, id)); + Assert.Null(await _service.DeleteCharacterAsync(Owner, id)); + + Assert.Empty(await _store.GetLicensesAsync(id)); + Assert.Empty(await _store.GetVehiclesAsync(id)); + Assert.Null(await _store.GetVehicleByPlateAsync("ABC 123")); + } + + [Fact] + public async Task ApplyForLicense_FollowsSelfServiceAndStatusRules() + { + await CreateAsync(); + + Assert.Null(await _service.ApplyForLicenseAsync(Owner, "driver")); + Assert.NotNull(await _service.ApplyForLicenseAsync(Owner, "driver")); + Assert.NotNull(await _service.ApplyForLicenseAsync(Owner, "weapon")); + Assert.NotNull(await _service.ApplyForLicenseAsync(Owner, "spaceship")); + + var id = (await _service.GetStateAsync(Owner)).ActiveCharacterId!.Value; + await _store.SetLicenseAsync(id, "boat", LicenseStatus.Suspended); + Assert.NotNull(await _service.ApplyForLicenseAsync(Owner, "boat")); + + var licenses = (await _service.GetStateAsync(Owner)).Licenses; + Assert.Equal(LicenseStatus.Valid, licenses.Single(l => l.Type == "driver").Status); + Assert.Equal(LicenseStatus.Suspended, licenses.Single(l => l.Type == "boat").Status); + Assert.Null(licenses.Single(l => l.Type == "weapon").Status); + } + + [Fact] + public async Task RegisterVehicle_EnforcesPlateUniquenessAndLimit() + { + await CreateAsync(); + await CreateAsync(owner: Other); + + Assert.Null(await _service.RegisterVehicleAsync(Owner, " abc 123 ", "Sultan")); + Assert.NotNull(await _service.RegisterVehicleAsync(Owner, "ABC 123", "Sultan")); + Assert.NotNull(await _service.RegisterVehicleAsync(Other, "abc 123", "Sultan")); + Assert.NotNull(await _service.RegisterVehicleAsync(Owner, "TOO-LONG1", "Sultan")); + Assert.Null(await _service.RegisterVehicleAsync(Owner, "XYZ 9", "Banshee")); + Assert.NotNull(await _service.RegisterVehicleAsync(Owner, "LMN 4", "Comet")); + + var vehicles = (await _service.GetStateAsync(Owner)).Vehicles; + Assert.Equal(new[] { "ABC 123", "XYZ 9" }, vehicles.Select(v => v.Plate)); + } + + [Fact] + public async Task VehicleActions_RequireOwnership() + { + await CreateAsync(); + await _service.RegisterVehicleAsync(Owner, "ABC 123", "Sultan"); + var vehicle = (await _service.GetStateAsync(Owner)).Vehicles.Single(); + + Assert.NotNull(await _service.SetVehicleStolenAsync(Other, vehicle.Id, true)); + Assert.Null(await _service.SetVehicleStolenAsync(Owner, vehicle.Id, true)); + Assert.Equal(VehicleStatus.Stolen, (await _store.GetVehicleAsync(vehicle.Id))!.Status); + + Assert.NotNull(await _service.RemoveVehicleAsync(Other, vehicle.Id)); + Assert.Null(await _service.RemoveVehicleAsync(Owner, vehicle.Id)); + Assert.Null(await _store.GetVehicleAsync(vehicle.Id)); + } + } +} diff --git a/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs b/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs new file mode 100644 index 0000000..7c748bf --- /dev/null +++ b/FivePRS.Tests/ClientAssemblyCompatibilityTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Xunit; + +namespace FivePRS.Tests +{ + public class ClientAssemblyCompatibilityTests + { + private static readonly string[] UnsupportedEmbeddedTypes = + { + "IsReadOnlyAttribute", + "IsByRefLikeAttribute", + "IsUnmanagedAttribute", + "RequiresLocationAttribute", + "RefSafetyRulesAttribute", + "ScopedRefAttribute", + "IsExternalInit", + }; + + public static IEnumerable ClientAssemblies() => ClientFiles("FivePRS.*.dll"); + + public static IEnumerable AllShippedClientAssemblies() => ClientFiles("*.dll"); + + private static IEnumerable ClientFiles(string pattern) + { + var dir = Path.Combine(RepoRoot(), "bin", "client"); + if (!Directory.Exists(dir)) + throw new InvalidOperationException($"{dir} not found; build FivePRS.sln before running tests."); + + return Directory.GetFiles(dir, pattern) + .Where(path => !Path.GetFileName(path).StartsWith("CitizenFX.", StringComparison.Ordinal)) + .Select(path => new object[] { Path.GetFileName(path) }); + } + + [Theory] + [MemberData(nameof(ClientAssemblies))] + public void ClientAssembly_HasNoMetadataUnsupportedByFiveMMono(string fileName) + { + using var stream = File.OpenRead(Path.Combine(RepoRoot(), "bin", "client", fileName)); + using var pe = new PEReader(stream); + var reader = pe.GetMetadataReader(); + + var embedded = reader.TypeDefinitions + .Select(handle => reader.GetString(reader.GetTypeDefinition(handle).Name)) + .Where(UnsupportedEmbeddedTypes.Contains) + .ToList(); + + Assert.True(embedded.Count == 0, + $"{fileName} embeds {string.Join(", ", embedded)}, which FiveM's client runtime rejects with BadImageFormatException. " + + "Avoid readonly structs, readonly members, in/ref readonly parameters, ref structs, unmanaged constraints and init accessors in client code."); + } + + [Theory] + [MemberData(nameof(AllShippedClientAssemblies))] + public void ClientAssembly_HasNoAttributesOnGenericParameters(string fileName) + { + using var stream = File.OpenRead(Path.Combine(RepoRoot(), "bin", "client", fileName)); + using var pe = new PEReader(stream); + var reader = pe.GetMetadataReader(); + + var count = reader.CustomAttributes + .Select(handle => reader.GetCustomAttribute(handle).Parent.Kind) + .Count(kind => kind == HandleKind.GenericParameter || kind == HandleKind.GenericParameterConstraint); + + Assert.True(count == 0, + $"{fileName} has {count} custom attribute(s) on generic parameters or constraints, which FiveM's client runtime rejects with BadImageFormatException. " + + "With nullable enabled these come from constrained generics such as 'where T : SomeClass'; use non-generic overloads in client code."); + } + + private static readonly string[] BlockedClientMembers = + { + "System.Char.IsHighSurrogate", + "System.Char.IsLowSurrogate", + "System.Char.IsSurrogate", + "System.Char.IsSurrogatePair", + }; + + private static readonly string[] BlockedClientTypes = + { + "System.Text.RegularExpressions.Regex", + }; + + [Theory] + [MemberData(nameof(ClientAssemblies))] + public void ClientAssembly_DoesNotCallMembersBlockedByFiveM(string fileName) + { + using var stream = File.OpenRead(Path.Combine(RepoRoot(), "bin", "client", fileName)); + using var pe = new PEReader(stream); + var reader = pe.GetMetadataReader(); + + var used = reader.MemberReferences + .Select(handle => reader.GetMemberReference(handle)) + .Where(member => member.Parent.Kind == HandleKind.TypeReference) + .Select(member => + { + var type = reader.GetTypeReference((TypeReferenceHandle)member.Parent); + return $"{reader.GetString(type.Namespace)}.{reader.GetString(type.Name)}.{reader.GetString(member.Name)}"; + }) + .Where(name => BlockedClientMembers.Contains(name) || BlockedClientTypes.Any(type => name.StartsWith(type + ".", StringComparison.Ordinal))) + .Distinct() + .ToList(); + + Assert.True(used.Count == 0, + $"{fileName} calls {string.Join(", ", used)}, which FiveM's client runtime refuses to run (\"is not accessible\")."); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "FivePRS.sln"))) + dir = dir.Parent; + return dir!.FullName; + } + } +} diff --git a/FivePRS.Tests/DispatchServiceTests.cs b/FivePRS.Tests/DispatchServiceTests.cs index 9466161..57ed32b 100644 --- a/FivePRS.Tests/DispatchServiceTests.cs +++ b/FivePRS.Tests/DispatchServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using FivePRS.Core.Config; using FivePRS.Core.Jurisdiction; @@ -68,6 +69,24 @@ public void Tick_WithPendingOffer_DoesNotOfferAgain() Assert.Empty(_dispatch.Tick()); } + [Fact] + public void Tick_AiCalloutsOff_ReceivesNoOffersUntilTurnedBackOn() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1); + Assert.True(_dispatch.SetAiCallouts(Officer, false)); + Advance(_settings.InitialGraceSeconds); + + Assert.Empty(_dispatch.Tick()); + Assert.Contains(Officer, _dispatch.UnitsInDepartment(Department.Police)); + Assert.False(_dispatch.CreateSnapshot().Units.Single().AiCallouts); + + Assert.True(_dispatch.SetAiCallouts(Officer, true)); + Assert.Empty(_dispatch.Tick()); + Advance(_settings.NoCalloutRetrySeconds); + + Assert.Equal(Officer, Assert.Single(_dispatch.Tick()).UnitId); + } + [Fact] public void Tick_BusyUnit_ReceivesNoOffers() { @@ -271,6 +290,24 @@ public void SetOnDuty_UsesAgencyCallsignPrefix() Assert.Equal("BCSO-1", unit.Callsign); } + [Fact] + public void SetOnDuty_WithCallsign_UsesIt() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1, "lspd", "1-ADAM-12"); + + Assert.Equal("1-ADAM-12", _dispatch.GetUnit(Officer)!.Callsign); + } + + [Fact] + public void IsCallsignTaken_OtherUnitHasIt_ReturnsTrue() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1, "lspd", "1-ADAM-12"); + + Assert.True(_dispatch.IsCallsignTaken("1-adam-12", Backup)); + Assert.False(_dispatch.IsCallsignTaken("1-ADAM-12", Officer)); + Assert.False(_dispatch.IsCallsignTaken("2-ADAM-12", Backup)); + } + [Fact] public void SetOnDuty_AgencyFromOtherDepartment_FallsBackToDefault() { @@ -323,6 +360,120 @@ private string AcceptedCall(int unitId, float x = 1f, float y = 1f) return callId; } + private const int Caller = 50; + + private string CreateEmergency(int caller = Caller) + { + var (callId, error) = _dispatch.CreateEmergencyCall(caller, "Jane Doe", Department.Police, "Shots fired", 100f, -900f, 30f); + Assert.Null(error); + return callId!; + } + + [Fact] + public void EmergencyCall_AppearsInSnapshotWithCallerAndNoUnits() + { + var callId = CreateEmergency(); + + var call = Assert.Single(_dispatch.CreateSnapshot().Calls); + Assert.Equal(callId, call.Id); + Assert.True(call.IsEmergency); + Assert.Equal("Shots fired", call.Description); + Assert.Equal("Jane Doe", call.Caller); + Assert.Empty(call.Units); + } + + [Fact] + public void EmergencyCall_OnePerCaller_AndCooldownAfterClose() + { + var callId = CreateEmergency(); + Assert.NotNull(_dispatch.CreateEmergencyCall(Caller, "Jane Doe", Department.Police, "Again", 0f, 0f, 0f).Error); + + Assert.True(_dispatch.CancelEmergencyCall(Caller, callId)); + Assert.NotNull(_dispatch.CreateEmergencyCall(Caller, "Jane Doe", Department.Police, "Again", 0f, 0f, 0f).Error); + + Advance(_settings.EmergencyCallCooldownSeconds + 1); + Assert.Null(_dispatch.CreateEmergencyCall(Caller, "Jane Doe", Department.Police, "Again", 0f, 0f, 0f).Error); + } + + [Fact] + public void EmergencyCall_OnlyCallerCanCancel() + { + var callId = CreateEmergency(); + + Assert.False(_dispatch.CancelEmergencyCall(Caller + 1, callId)); + Assert.True(_dispatch.CancelEmergencyCall(Caller, callId)); + Assert.Empty(_dispatch.CreateSnapshot().Calls); + } + + [Fact] + public void EmergencyCall_AttachedUnitClears_AndAwardsNoXp() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1); + var callId = CreateEmergency(); + + Assert.Null(_dispatch.ClearEmergencyCall(Officer, callId)); + Assert.True(_dispatch.Attach(Officer, callId)); + Assert.Equal(1, _dispatch.GetEmergencyStatus(Caller)!.Responding); + Assert.Empty(_dispatch.End(Officer, callId, CalloutResult.Completed)); + + Assert.Equal(Caller, _dispatch.ClearEmergencyCall(Officer, callId)); + Assert.Null(_dispatch.GetEmergencyStatus(Caller)); + Assert.Equal(UnitStatus.Available, _dispatch.GetUnit(Officer)!.Status); + Assert.Null(_dispatch.GetUnit(Officer)!.CallId); + } + + [Fact] + public void EmergencyCall_ExpiresOnlyWhenUnanswered() + { + _dispatch.SetOnDuty(Officer, "Officer", Department.Police, 1); + var answered = CreateEmergency(); + var unanswered = CreateEmergency(Caller + 1); + _dispatch.Attach(Officer, answered); + + Advance(_settings.EmergencyCallTimeoutMinutes * 60 + 1); + _dispatch.Tick(); + + Assert.Equal(new[] { answered }, _dispatch.CreateSnapshot().Calls.Select(c => c.Id)); + } + + [Fact] + public void RegisterCallouts_InvalidDefinitions_AreRejected() + { + var tooLong = Definition(new string('x', 65), Department.Police, xp: 10); + var noDepartment = Definition("Ghost", Department.None, xp: 10); + var heavy = Definition("Heavy", Department.Police, xp: 10); + heavy.Weight = 1000; + var badPriority = Definition("Odd", Department.Police, xp: 10); + badPriority.Priority = (CalloutPriority)99; + var negativeXp = Definition("Debt", Department.Police, xp: -5); + + var accepted = _dispatch.RegisterCallouts(new[] { tooLong, noDepartment, heavy, badPriority, negativeXp, null }); + + Assert.Equal(0, accepted); + Assert.Equal(2, _dispatch.CatalogCount); + } + + [Fact] + public void RegisterCallouts_XpAboveMax_IsCapped() + { + var greedy = Definition("Greedy", Department.Police, xp: 100_000); + + Assert.Equal(1, _dispatch.RegisterCallouts(new[] { greedy }, maxXp: 500)); + Assert.Equal(500, greedy.XPReward); + } + + [Fact] + public void RegisterCallouts_CatalogFull_RejectsNewNamesButUpdatesExisting() + { + var many = new List(); + for (var i = 0; i < 600; i++) many.Add(Definition($"Callout {i}", Department.Police, xp: 10)); + + _dispatch.RegisterCallouts(many); + + Assert.Equal(500, _dispatch.CatalogCount); + Assert.Equal(1, _dispatch.RegisterCallouts(new[] { Definition("Traffic Stop", Department.Police, xp: 20) })); + } + private void Advance(int seconds) => _now = _now.AddSeconds(seconds); private static CalloutDefinition Definition(string name, Department department, int xp, int cooldown = 0) => new() diff --git a/FivePRS.Tests/GameTextTests.cs b/FivePRS.Tests/GameTextTests.cs new file mode 100644 index 0000000..ff94651 --- /dev/null +++ b/FivePRS.Tests/GameTextTests.cs @@ -0,0 +1,62 @@ +using System.Linq; +using System.Text; +using FivePRS.Core.Text; +using Xunit; + +namespace FivePRS.Tests +{ + public class GameTextTests + { + [Fact] + public void SplitComponents_ShortText_IsOnePart() + { + Assert.Equal(new[] { "~g~Suspect cuffed" }, GameText.SplitComponents("~g~Suspect cuffed")); + } + + [Theory] + [InlineData("~y~Welcome to FivePRS!~w~ Press ~b~F5~w~ or use ~b~/duty~w~ to go on duty.")] + [InlineData("~b~Los Santos Police Department~w~ | ~g~ON DUTY~w~ | Recruit loadout applied.~n~~w~ Your patrol vehicle is marked on the map.")] + [InlineData("~y~FivePRS Commands~w~~n~~b~/duty~w~ — Choose department, agency and callsign, or go off duty~n~~b~/er_profile~w~ — View rank and XP~n~~b~/er_accept~w~ — Accept incoming callout")] + public void SplitComponents_LongText_RejoinsExactlyWithinByteLimit(string text) + { + var parts = GameText.SplitComponents(text); + + Assert.Equal(text, string.Concat(parts)); + Assert.All(parts, part => Assert.True(Encoding.UTF8.GetByteCount(part) <= GameText.MaxComponentBytes)); + } + + [Fact] + public void SplitComponents_NeverSplitsInsideColourCode() + { + var text = new string('a', GameText.MaxComponentBytes - 1) + "~b~blue text"; + + var parts = GameText.SplitComponents(text); + + Assert.All(parts, part => Assert.Equal(0, part.Count(c => c == '~') % 2)); + Assert.Equal(text, string.Concat(parts)); + } + + [Fact] + public void SplitComponents_NeverSplitsSurrogatePair() + { + var text = new string('a', GameText.MaxComponentBytes - 2) + "🚓🚓"; + + var parts = GameText.SplitComponents(text); + + Assert.Equal(text, string.Concat(parts)); + Assert.All(parts, part => Assert.False(char.IsHighSurrogate(part[part.Length - 1]))); + Assert.All(parts, part => Assert.True(Encoding.UTF8.GetByteCount(part) <= GameText.MaxComponentBytes)); + } + + [Fact] + public void SplitComponents_NeverSplitsMultiByteCharacter() + { + var text = new string('a', GameText.MaxComponentBytes - 1) + "——"; + + var parts = GameText.SplitComponents(text); + + Assert.Equal(text, string.Concat(parts)); + Assert.All(parts, part => Assert.True(Encoding.UTF8.GetByteCount(part) <= GameText.MaxComponentBytes)); + } + } +} diff --git a/FivePRS.Tests/KeyCommandsTests.cs b/FivePRS.Tests/KeyCommandsTests.cs new file mode 100644 index 0000000..bf56e4a --- /dev/null +++ b/FivePRS.Tests/KeyCommandsTests.cs @@ -0,0 +1,41 @@ +using System.Linq; +using System.Reflection; +using System.Text; +using FivePRS.Core.Text; +using Xunit; + +namespace FivePRS.Tests +{ + public class KeyCommandsTests + { + [Fact] + public void PromptedCommands_HashWithHighBitSoKeyTokensRender() + { + var commands = typeof(KeyCommands) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral) + .Select(f => (string)f.GetRawConstantValue()!) + .ToList(); + + Assert.NotEmpty(commands); + Assert.All(commands, command => + Assert.True((Joaat(command) & 0x80000000) != 0, $"'{command}' needs a name whose joaat hash has the high bit set")); + } + + private static uint Joaat(string text) + { + uint hash = 0; + foreach (var b in Encoding.UTF8.GetBytes(text.ToLowerInvariant())) + { + hash += b; + hash += hash << 10; + hash ^= hash >> 6; + } + + hash += hash << 3; + hash ^= hash >> 11; + hash += hash << 15; + return hash; + } + } +} diff --git a/FivePRS.Tests/LookupServiceTests.cs b/FivePRS.Tests/LookupServiceTests.cs new file mode 100644 index 0000000..594b7f8 --- /dev/null +++ b/FivePRS.Tests/LookupServiceTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Civilian; +using FivePRS.Core.Config; +using FivePRS.Server.Civilian; +using FivePRS.Server.Database; +using Xunit; + +namespace FivePRS.Tests +{ + [Collection(TestDatabase.Collection)] + public sealed class LookupServiceTests : IAsyncLifetime + { + private const string Owner = "license:civ"; + + private static readonly OfficerInfo Officer = new() { License = "license:cop", Name = "Officer Pixel", Callsign = "1-ADAM-12" }; + + private readonly ResourceSettings _settings = new() { MaxCharacters = 3, MaxVehiclesPerCharacter = 3, MaxFine = 500 }; + private TestDatabase _database = null!; + private CivilianStore _store = null!; + private CivilianService _civilians = null!; + private LookupService _lookup = null!; + private int _characterId; + + public async Task InitializeAsync() + { + _database = await TestDatabase.CreateAsync(); + _store = _database.Store; + _civilians = new CivilianService(_store, () => _settings, () => new LicensesConfig(), () => new DateTime(2026, 10, 9)); + _lookup = new LookupService(_store, () => _settings, () => new LicensesConfig()); + + await _civilians.CreateCharacterAsync(Owner, "Trevor", "Philips", "1967-01-01", "Male"); + await _civilians.RegisterVehicleAsync(Owner, "TP 1", "Bodhi"); + _characterId = (await _civilians.GetStateAsync(Owner)).ActiveCharacterId!.Value; + } + + public Task DisposeAsync() + { + _database.Dispose(); + return Task.CompletedTask; + } + + [Theory] + [InlineData("trev")] + [InlineData("PHILIPS")] + [InlineData("Trevor Phil")] + public async Task SearchByName_FindsCharacter(string term) + { + var (result, error) = await _lookup.SearchByNameAsync(term); + + Assert.Null(error); + var match = Assert.Single(result!.Results!); + Assert.Equal(_characterId, match.Id); + Assert.False(match.HasActiveWarrant); + } + + [Theory] + [InlineData("t")] + [InlineData("tr%")] + public async Task SearchByName_InvalidTerm_IsRejected(string term) + { + var (result, error) = await _lookup.SearchByNameAsync(term); + + Assert.Null(result); + Assert.NotNull(error); + } + + [Fact] + public async Task SearchByPlate_ReturnsOwnerRecord() + { + var (result, error) = await _lookup.SearchByPlateAsync("tp 1"); + + Assert.Null(error); + Assert.Equal("TP 1", result!.Record!.MatchedPlate); + Assert.Equal("Trevor", result.Record.Character.FirstName); + Assert.Single(result.Record.Vehicles); + + Assert.NotNull((await _lookup.SearchByPlateAsync("NOPE 1")).Error); + } + + [Fact] + public async Task IssueRecord_ValidatesAndStoresOfficerDetails() + { + Assert.NotNull(await _lookup.IssueRecordAsync(Officer, _characterId, 99, "Speeding", 100)); + Assert.NotNull(await _lookup.IssueRecordAsync(Officer, _characterId, (int)RecordType.Citation, " ", 100)); + Assert.NotNull(await _lookup.IssueRecordAsync(Officer, _characterId, (int)RecordType.Citation, "Speeding", 501)); + Assert.NotNull(await _lookup.IssueRecordAsync(Officer, 9999, (int)RecordType.Warning, "Loitering", 0)); + + Assert.Null(await _lookup.IssueRecordAsync(Officer, _characterId, (int)RecordType.Citation, "Speeding 80 in a 50", 250)); + Assert.Null(await _lookup.IssueRecordAsync(Officer, _characterId, (int)RecordType.Arrest, "Assault", 300)); + + var records = (await _lookup.GetRecordAsync(_characterId)).Result!.Record!.Records; + Assert.Equal(2, records.Count); + var citation = records.Single(r => r.Type == RecordType.Citation); + Assert.Equal(250, citation.Fine); + Assert.Equal("1-ADAM-12", citation.OfficerCallsign); + Assert.Equal(0, records.Single(r => r.Type == RecordType.Arrest).Fine); + } + + [Fact] + public async Task Warrant_IsActiveUntilServed_AndBlocksCharacterDeletion() + { + Assert.Null(await _lookup.IssueRecordAsync(Officer, _characterId, (int)RecordType.Warrant, "Failure to appear", 0)); + + Assert.True(Assert.Single((await _lookup.SearchByNameAsync("Trevor")).Result!.Results!).HasActiveWarrant); + Assert.NotNull(await _civilians.DeleteCharacterAsync(Owner, _characterId)); + + var warrant = (await _lookup.GetRecordAsync(_characterId)).Result!.Record!.Records.Single(); + var (characterId, error) = await _lookup.ResolveWarrantAsync(Officer, warrant.Id, served: true); + Assert.Null(error); + Assert.Equal(_characterId, characterId); + Assert.NotNull((await _lookup.ResolveWarrantAsync(Officer, warrant.Id, served: false)).Error); + + var resolved = (await _lookup.GetRecordAsync(_characterId)).Result!.Record!.Records.Single(); + Assert.False(resolved.Active); + Assert.Equal("Served by 1-ADAM-12", resolved.Resolution); + Assert.False(Assert.Single((await _lookup.SearchByNameAsync("Trevor")).Result!.Results!).HasActiveWarrant); + Assert.Null(await _civilians.DeleteCharacterAsync(Owner, _characterId)); + } + + [Fact] + public async Task OfficerLicenseAndVehicleActions_UpdateRecord() + { + Assert.Null(await _lookup.SetLicenseStatusAsync(_characterId, "weapon", (int)LicenseStatus.Valid)); + Assert.Null(await _lookup.SetLicenseStatusAsync(_characterId, "weapon", (int)LicenseStatus.Revoked)); + Assert.NotNull(await _lookup.SetLicenseStatusAsync(_characterId, "spaceship", (int)LicenseStatus.Valid)); + Assert.NotNull(await _lookup.SetLicenseStatusAsync(_characterId, "weapon", 7)); + + var vehicle = (await _lookup.GetRecordAsync(_characterId)).Result!.Record!.Vehicles.Single(); + Assert.Equal(_characterId, (await _lookup.SetVehicleStolenAsync(vehicle.Id, true)).CharacterId); + + var record = (await _lookup.GetRecordAsync(_characterId)).Result!.Record!; + Assert.Equal(LicenseStatus.Revoked, record.Licenses.Single(l => l.Type == "weapon").Status); + Assert.Equal(VehicleStatus.Stolen, record.Vehicles.Single().Status); + + var civilianView = await _civilians.GetStateAsync(Owner); + Assert.Equal(LicenseStatus.Revoked, civilianView.Licenses.Single(l => l.Type == "weapon").Status); + } + } +} diff --git a/FivePRS.Tests/MigratorTests.cs b/FivePRS.Tests/MigratorTests.cs new file mode 100644 index 0000000..6888edb --- /dev/null +++ b/FivePRS.Tests/MigratorTests.cs @@ -0,0 +1,165 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FivePRS.Core.Models; +using FivePRS.Server.Database; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace FivePRS.Tests +{ + [Collection(TestDatabase.Collection)] + public sealed class MigratorTests : IAsyncLifetime + { + private TestDatabase _database = null!; + + public async Task InitializeAsync() => _database = await TestDatabase.CreateAsync(); + + public Task DisposeAsync() + { + _database.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Migrate_FreshDatabase_ReachesLatestVersion() + { + var migrator = new Migrator(_database.Provider); + + Assert.Equal(migrator.LatestVersion, await migrator.CurrentVersionAsync()); + } + + [Fact] + public async Task Migrate_UpToDate_AppliesNothing() + { + var result = await new Migrator(_database.Provider).MigrateAsync(); + + Assert.Empty(result.Applied); + Assert.Equal(result.LatestVersion, result.ToVersion); + Assert.False(result.DatabaseIsNewer); + } + + [Fact] + public async Task Migrate_NewMigration_AppliesOnlyThatOneAndCallsBackupFirst() + { + var next = new Migrator(_database.Provider).LatestVersion + 1; + var migrations = Migrations.All.Concat(new[] + { + new Migration(next, "Add note", new AddColumnStep("fiveprs_positions", "note", "TEXT NOT NULL DEFAULT ''", "VARCHAR(40) NOT NULL DEFAULT ''")), + }).ToList(); + + int? backedUpFrom = null; + var result = await new Migrator(_database.Provider, migrations).MigrateAsync(version => + { + backedUpFrom = version; + return Task.CompletedTask; + }); + + Assert.Equal(next, Assert.Single(result.Applied).Version); + Assert.Equal(next - 1, backedUpFrom); + Assert.Equal(next, result.ToVersion); + + await _database.Positions.SaveAsync("license:a", new SavedPosition { X = 1, Y = 2, Z = 3, Heading = 90 }); + Assert.NotNull(await _database.Positions.GetAsync("license:a")); + } + + [Fact] + public async Task Migrate_AddColumnThatAlreadyExists_Succeeds() + { + var next = new Migrator(_database.Provider).LatestVersion + 1; + var migrations = Migrations.All.Concat(new[] + { + new Migration(next, "Add callsign again", new AddColumnStep("ers_players", "callsign", "TEXT NOT NULL DEFAULT ''", "VARCHAR(16) NOT NULL DEFAULT ''")), + }).ToList(); + + var result = await new Migrator(_database.Provider, migrations).MigrateAsync(); + + Assert.Single(result.Applied); + } + + [Fact] + public async Task Migrate_DatabaseNewerThanBuild_ReportsItAndAppliesNothing() + { + var ahead = new Migrator(_database.Provider).LatestVersion + 5; + await new Migrator(_database.Provider, Migrations.All.Concat(new[] { new Migration(ahead, "Future") }).ToList()).MigrateAsync(); + + var result = await new Migrator(_database.Provider).MigrateAsync(); + + Assert.True(result.DatabaseIsNewer); + Assert.Empty(result.Applied); + Assert.Equal(ahead, result.FromVersion); + } + + [Fact] + public void Constructor_VersionsOutOfOrder_Throws() + { + var migrations = new[] { new Migration(2, "Second"), new Migration(1, "First") }; + + Assert.Throws(() => new Migrator(_database.Provider, migrations)); + } + + [Fact] + public void All_VersionsStartAtOneAndIncrease() + { + Assert.Equal(1, Migrations.All[0].Version); + Assert.Equal(Migrations.All.Select(m => m.Version).OrderBy(v => v), Migrations.All.Select(m => m.Version)); + Assert.Equal(Migrations.All.Count, Migrations.All.Select(m => m.Version).Distinct().Count()); + } + } + + public sealed class SQLiteMigrationTests : IDisposable + { + private readonly string _path = Path.Combine(Path.GetTempPath(), $"fiveprs-test-{Guid.NewGuid():N}.db"); + private readonly string _backup = Path.Combine(Path.GetTempPath(), $"fiveprs-backup-{Guid.NewGuid():N}.db"); + + [Fact] + public async Task Migrate_FailingMigration_RollsBackAndKeepsVersion() + { + var provider = new SQLiteProvider(_path); + await provider.InitializeAsync(); + await new Migrator(provider).MigrateAsync(); + + var next = Migrations.All[Migrations.All.Count - 1].Version + 1; + var broken = Migrations.All.Concat(new[] + { + new Migration(next, "Broken", + new SqlStep("CREATE TABLE fiveprs_half (id INTEGER)", "CREATE TABLE fiveprs_half (id INT)"), + new SqlStep("NOT VALID SQL", "NOT VALID SQL")), + }).ToList(); + + await Assert.ThrowsAnyAsync(() => new Migrator(provider, broken).MigrateAsync()); + + Assert.Equal(next - 1, await new Migrator(provider).CurrentVersionAsync()); + using var conn = new SqliteConnection($"Data Source={_path}"); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE name = 'fiveprs_half'"; + Assert.Equal(0L, (long)(await cmd.ExecuteScalarAsync())!); + } + + [Fact] + public async Task Backup_CopiesDatabaseWithData() + { + var provider = new SQLiteProvider(_path); + await provider.InitializeAsync(); + await new Migrator(provider).MigrateAsync(); + await provider.SavePlayerAsync(new PlayerData { License = "license:kept", Name = "Kept" }); + + await provider.BackupAsync(_backup); + + var copy = new SQLiteProvider(_backup); + Assert.Equal("Kept", (await copy.GetPlayerAsync("license:kept"))!.Name); + Assert.Equal(Migrations.All[Migrations.All.Count - 1].Version, await new Migrator(copy).CurrentVersionAsync()); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + foreach (var file in new[] { _path, _path + "-wal", _path + "-shm", _backup }) + { + if (File.Exists(file)) File.Delete(file); + } + } + } +} diff --git a/FivePRS.Tests/PermissionServiceTests.cs b/FivePRS.Tests/PermissionServiceTests.cs index a62ad84..cb832ec 100644 --- a/FivePRS.Tests/PermissionServiceTests.cs +++ b/FivePRS.Tests/PermissionServiceTests.cs @@ -8,10 +8,25 @@ namespace FivePRS.Tests public class PermissionServiceTests { private readonly HashSet _grants = new(); + private readonly HashSet _roster = new(); private bool _restrict; private PermissionService Create() => - new((player, ace) => _grants.Contains($"{player}:{ace}"), () => _restrict); + new((player, ace) => _grants.Contains($"{player}:{ace}"), () => _restrict, + (player, department) => _roster.Contains($"{player}:{department}")); + + [Fact] + public void CanJoinDepartment_Restricted_AllowsRosterMembers() + { + _restrict = true; + _roster.Add("1:EMS"); + + var permissions = Create(); + + Assert.True(permissions.CanJoinDepartment("1", Department.EMS)); + Assert.False(permissions.CanJoinDepartment("1", Department.Police)); + Assert.False(permissions.CanJoinDepartment("2", Department.EMS)); + } [Fact] public void CanJoinDepartment_Unrestricted_AllowsEveryone() diff --git a/FivePRS.Tests/PositionTests.cs b/FivePRS.Tests/PositionTests.cs new file mode 100644 index 0000000..46bbe0f --- /dev/null +++ b/FivePRS.Tests/PositionTests.cs @@ -0,0 +1,54 @@ +using System.Threading.Tasks; +using FivePRS.Core.Models; +using Xunit; + +namespace FivePRS.Tests +{ + public class SavedPositionTests + { + [Theory] + [InlineData(float.NaN, 0f, 0f)] + [InlineData(20_000f, 0f, 30f)] + [InlineData(100f, 100f, 5_000f)] + [InlineData(0f, 0f, 30f)] + public void Create_RejectsImpossiblePositions(float x, float y, float z) + { + Assert.Null(SavedPosition.Create(x, y, z, 0f)); + } + + [Fact] + public void Create_NormalisesHeading() + { + var position = SavedPosition.Create(433.6f, -981.8f, 30.7f, -90f); + Assert.Equal(270f, position!.Heading); + } + } + + [Collection(TestDatabase.Collection)] + public sealed class PositionStoreTests : IAsyncLifetime + { + private TestDatabase _database = null!; + + public async Task InitializeAsync() => _database = await TestDatabase.CreateAsync(); + + public Task DisposeAsync() + { + _database.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Save_StoresAndOverwritesThePlayersPosition() + { + Assert.Null(await _database.Positions.GetAsync("license:a")); + + await _database.Positions.SaveAsync("license:a", SavedPosition.Create(1f, 2f, 3f, 4f)!); + await _database.Positions.SaveAsync("license:a", SavedPosition.Create(-1108.4f, -845f, 19.3f, 130f)!); + + var position = await _database.Positions.GetAsync("license:a"); + Assert.Equal(-1108.4f, position!.X, 2); + Assert.Equal(-845f, position.Y, 2); + Assert.Equal(130f, position.Heading, 2); + } + } +} diff --git a/FivePRS.Tests/RateLimiterTests.cs b/FivePRS.Tests/RateLimiterTests.cs new file mode 100644 index 0000000..6b26ff5 --- /dev/null +++ b/FivePRS.Tests/RateLimiterTests.cs @@ -0,0 +1,90 @@ +using System; +using FivePRS.Core.Config; +using FivePRS.Server.Security; +using Newtonsoft.Json; +using Xunit; + +namespace FivePRS.Tests +{ + public sealed class RateLimiterTests + { + private static readonly RateLimit ThreePerTen = new(3, 10); + + private DateTime _now = new(2026, 10, 10, 12, 0, 0, DateTimeKind.Utc); + private bool _enabled = true; + private readonly RateLimiter _limiter; + + public RateLimiterTests() + { + _limiter = new RateLimiter(() => _now, () => _enabled); + } + + [Fact] + public void Check_UnderLimit_Allows() + { + for (var i = 0; i < 3; i++) + Assert.Equal(RateLimitResult.Allowed, _limiter.Check("1", "records", ThreePerTen)); + } + + [Fact] + public void Check_OverLimit_ReportsFirstThenLimits() + { + for (var i = 0; i < 3; i++) _limiter.Check("1", "records", ThreePerTen); + + Assert.Equal(RateLimitResult.LimitedFirst, _limiter.Check("1", "records", ThreePerTen)); + Assert.Equal(RateLimitResult.Limited, _limiter.Check("1", "records", ThreePerTen)); + } + + [Fact] + public void Check_AfterWindow_AllowsAgain() + { + for (var i = 0; i < 4; i++) _limiter.Check("1", "records", ThreePerTen); + + _now = _now.AddSeconds(10); + + Assert.Equal(RateLimitResult.Allowed, _limiter.Check("1", "records", ThreePerTen)); + } + + [Fact] + public void Check_PlayersAndBucketsAreSeparate() + { + for (var i = 0; i < 3; i++) _limiter.Check("1", "records", ThreePerTen); + + Assert.Equal(RateLimitResult.Allowed, _limiter.Check("2", "records", ThreePerTen)); + Assert.Equal(RateLimitResult.Allowed, _limiter.Check("1", "lookups", ThreePerTen)); + } + + [Fact] + public void Forget_ResetsPlayer() + { + for (var i = 0; i < 4; i++) _limiter.Check("1", "records", ThreePerTen); + + _limiter.Forget("1"); + + Assert.Equal(RateLimitResult.Allowed, _limiter.Check("1", "records", ThreePerTen)); + } + + [Fact] + public void Check_DisabledOrZeroLimit_AlwaysAllows() + { + for (var i = 0; i < 10; i++) + Assert.Equal(RateLimitResult.Allowed, _limiter.Check("1", "zero", new RateLimit(0, 10))); + + _enabled = false; + for (var i = 0; i < 10; i++) + Assert.Equal(RateLimitResult.Allowed, _limiter.Check("1", "records", ThreePerTen)); + } + + [Fact] + public void Settings_PartialOverride_KeepsOtherDefaults() + { + var settings = JsonConvert.DeserializeObject( + "{\"rateLimits\":{\"records\":{\"limit\":2}}}")!; + + Assert.Equal(2, settings.RateLimits.Records.Limit); + Assert.Equal(10, settings.RateLimits.Records.PerSeconds); + Assert.Equal(4, settings.RateLimits.Emergency.Limit); + Assert.True(settings.RateLimits.Enabled); + } + } +} diff --git a/FivePRS.Tests/RosterServiceTests.cs b/FivePRS.Tests/RosterServiceTests.cs new file mode 100644 index 0000000..f908600 --- /dev/null +++ b/FivePRS.Tests/RosterServiceTests.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using FivePRS.Core.Models; +using FivePRS.Server.Permissions; +using Xunit; + +namespace FivePRS.Tests +{ + [Collection(TestDatabase.Collection)] + public sealed class RosterServiceTests : IAsyncLifetime + { + private const string Alice = "license:alice"; + + private TestDatabase _database = null!; + private RosterService _roster = null!; + + public async Task InitializeAsync() + { + _database = await TestDatabase.CreateAsync(); + await _database.Provider.SavePlayerAsync(new PlayerData { License = Alice, Name = "Alice Cooper", Rank = 4 }); + await _database.Provider.SavePlayerAsync(new PlayerData { License = "license:bob", Name = "Bob Ross" }); + _roster = new RosterService(_database.Roster); + await _roster.LoadAsync(); + } + + public Task DisposeAsync() + { + _database.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task SetAsync_GrantsAndRevokes_AndPersists() + { + Assert.True(await _roster.SetAsync(Alice, Department.Police, true, "Admin")); + Assert.False(await _roster.SetAsync(Alice, Department.Police, true, "Admin")); + Assert.True(_roster.Contains(Alice, Department.Police)); + Assert.False(_roster.Contains(Alice, Department.EMS)); + + var reloaded = new RosterService(_database.Roster); + await reloaded.LoadAsync(); + Assert.True(reloaded.Contains(Alice, Department.Police)); + + Assert.True(await _roster.SetAsync(Alice, Department.Police, false, "Admin")); + Assert.False(_roster.Contains(Alice, Department.Police)); + + await reloaded.LoadAsync(); + Assert.False(reloaded.Contains(Alice, Department.Police)); + } + + [Fact] + public async Task SetAsync_RejectsNoneDepartment() + { + Assert.False(await _roster.SetAsync(Alice, Department.None, true, "Admin")); + } + + [Fact] + public async Task SearchAsync_FindsOfflinePlayersWithTheirDepartments() + { + await _roster.SetAsync(Alice, Department.Fire, true, "Admin"); + + var match = Assert.Single(await _roster.SearchAsync("alice")); + Assert.Equal("Alice Cooper", match.Name); + Assert.Equal(4, match.Rank); + Assert.Equal(new[] { Department.Fire }, match.Departments); + + Assert.Empty(await _roster.SearchAsync("a")); + } + } +} diff --git a/FivePRS.Tests/SQLiteProviderTests.cs b/FivePRS.Tests/SQLiteProviderTests.cs new file mode 100644 index 0000000..1f59b52 --- /dev/null +++ b/FivePRS.Tests/SQLiteProviderTests.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using FivePRS.Core.Models; +using FivePRS.Server.Database; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace FivePRS.Tests +{ + public sealed class SQLiteProviderTests : IDisposable + { + private readonly string _path = Path.Combine(Path.GetTempPath(), $"fiveprs-test-{Guid.NewGuid():N}.db"); + + [Fact] + public async Task Migrate_ExistingDatabaseWithoutCallsign_AddsColumnAndKeepsPlayers() + { + using (var conn = new SqliteConnection($"Data Source={_path}")) + { + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" + CREATE TABLE ers_players ( + license TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, department INTEGER DEFAULT 0, + is_on_duty INTEGER DEFAULT 0, xp INTEGER DEFAULT 0, rank_level INTEGER DEFAULT 1, + last_seen TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))); + INSERT INTO ers_players (license, name, department, xp, rank_level) VALUES ('license:old', 'Veteran', 1, 40, 3);"; + await cmd.ExecuteNonQueryAsync(); + } + + var provider = new SQLiteProvider(_path); + await provider.InitializeAsync(); + await new Migrator(provider).MigrateAsync(); + + var player = await provider.GetPlayerAsync("license:old"); + Assert.NotNull(player); + Assert.Equal("Veteran", player!.Name); + Assert.Equal(3, player.Rank); + Assert.Equal(string.Empty, player.Callsign); + } + + [Fact] + public async Task SavePlayerAsync_Callsign_RoundTrips() + { + var provider = new SQLiteProvider(_path); + await provider.InitializeAsync(); + await new Migrator(provider).MigrateAsync(); + + await provider.SavePlayerAsync(new PlayerData + { + License = "license:new", + Name = "Rookie", + Department = Department.Police, + Agency = "lspd", + Callsign = "1-ADAM-12", + }); + + var player = await provider.GetPlayerAsync("license:new"); + Assert.Equal("1-ADAM-12", player!.Callsign); + Assert.Equal("lspd", player.Agency); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + foreach (var file in new[] { _path, _path + "-wal", _path + "-shm" }) + { + if (File.Exists(file)) File.Delete(file); + } + } + } +} diff --git a/FivePRS.Tests/StationConfigTests.cs b/FivePRS.Tests/StationConfigTests.cs new file mode 100644 index 0000000..98f1a99 --- /dev/null +++ b/FivePRS.Tests/StationConfigTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FivePRS.Core.Config; +using FivePRS.Core.Models; +using Newtonsoft.Json; +using Xunit; + +namespace FivePRS.Tests +{ + public class StationConfigTests + { + private static StationsConfig Sample() => new() + { + Stations = new List + { + new() { Id = "mrpd", Department = Department.Police, Agencies = new List { "lspd" }, DutyPoint = new[] { 1f, 2f, 3f } }, + new() { Id = "sandy", Department = Department.Police, Agencies = new List { "bcso" }, DutyPoint = new[] { 1f, 2f, 3f } }, + new() { Id = "davis", Department = Department.Fire, DutyPoint = new[] { 1f, 2f, 3f } }, + new() { Id = "broken", Department = Department.Police, DutyPoint = new[] { 1f, 2f } }, + }, + }; + + [Fact] + public void For_FiltersByDepartmentAndAgency() + { + var config = Sample(); + + Assert.Equal(new[] { "mrpd" }, config.For(Department.Police, "LSPD").Select(s => s.Id)); + Assert.Equal(new[] { "mrpd", "sandy" }, config.For(Department.Police, null).Select(s => s.Id)); + Assert.Equal(new[] { "davis" }, config.For(Department.Fire, "anything").Select(s => s.Id)); + } + + [Fact] + public void Find_IgnoresCaseAndInvalidStations() + { + var config = Sample(); + + Assert.Equal("mrpd", config.Find("MRPD")?.Id); + Assert.Null(config.Find("broken")); + Assert.Null(config.Find(null)); + } + + [Fact] + public void PointFor_ReturnsOnlyCompletePoints() + { + var station = new StationDef + { + Department = Department.Police, + DutyPoint = new[] { 1f, 2f, 3f }, + Armory = new[] { 4f, 5f, 6f }, + Garage = new[] { 7f, 8f }, + }; + + Assert.Equal(new[] { 1f, 2f, 3f }, station.PointFor(StationPoint.Duty)); + Assert.Equal(new[] { 4f, 5f, 6f }, station.PointFor(StationPoint.Armory)); + Assert.Null(station.PointFor(StationPoint.Locker)); + Assert.Null(station.PointFor(StationPoint.Garage)); + } + + [Fact] + public void BundledStations_AreValidAndMatchAgencies() + { + var stations = Load("stations.json"); + var jurisdictions = Load("jurisdictions.json"); + var agencies = jurisdictions.Agencies.ToDictionary(a => a.Id, StringComparer.OrdinalIgnoreCase); + + Assert.NotEmpty(stations.Stations); + Assert.All(stations.Stations, s => Assert.True(s.IsValid, s.Id)); + Assert.Equal(stations.Stations.Count, stations.Stations.Select(s => s.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.All(stations.Stations.SelectMany(s => s.Agencies.Select(a => new { Station = s, Agency = a })), entry => + { + Assert.True(agencies.TryGetValue(entry.Agency, out var agency), $"{entry.Station.Id}: unknown agency {entry.Agency}"); + Assert.Equal(entry.Station.Department, agency!.Department); + }); + } + + private static T Load(string file) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "FivePRS.sln"))) + dir = dir.Parent; + + return JsonConvert.DeserializeObject(File.ReadAllText(Path.Combine(dir!.FullName, "config", file)))!; + } + } +} diff --git a/FivePRS.Tests/TerminalTests.cs b/FivePRS.Tests/TerminalTests.cs new file mode 100644 index 0000000..44cf51b --- /dev/null +++ b/FivePRS.Tests/TerminalTests.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using FivePRS.Core.Config; +using Xunit; + +namespace FivePRS.Tests +{ + public class TerminalSettingsTests + { + private readonly TerminalSettings _settings = new(); + + [Theory] + [InlineData("", true, "")] + [InlineData("id:HEXGRID", true, "id:hexgrid")] + [InlineData("id:missing", false, "")] + [InlineData("url:https://example.com/wall.png", true, "url:https://example.com/wall.png")] + [InlineData("url:http://example.com/wall.png", false, "")] + [InlineData("url:https://example.com/a b.png", false, "")] + [InlineData("url:https://example.com/x.png\");}", false, "")] + [InlineData("https://example.com/wall.png", false, "")] + public void TryNormalizeWallpaper_AcceptsKnownIdsAndSafeHttpsUrls(string value, bool valid, string expected) + { + Assert.Equal(valid, _settings.TryNormalizeWallpaper(value, out var normalized)); + if (valid) Assert.Equal(expected, normalized); + } + + [Fact] + public void CustomUrls_CanBeTurnedOff() + { + _settings.AllowCustomWallpapers = false; + + Assert.False(_settings.TryNormalizeWallpaper("url:https://example.com/wall.png", out _)); + Assert.Equal(_settings.Find("fiveprs")!.Url, _settings.ResolveWallpaperUrl("url:https://example.com/wall.png")); + } + + [Fact] + public void ResolveWallpaperUrl_FallsBackToDefault() + { + Assert.Equal(_settings.Find("dusk")!.Url, _settings.ResolveWallpaperUrl("id:dusk")); + Assert.Equal("https://example.com/w.png", _settings.ResolveWallpaperUrl("url:https://example.com/w.png")); + Assert.Equal(_settings.Find("fiveprs")!.Url, _settings.ResolveWallpaperUrl("id:gone")); + Assert.Equal(_settings.Find("fiveprs")!.Url, _settings.ResolveWallpaperUrl(null)); + } + } + + [Collection(TestDatabase.Collection)] + public sealed class PreferencesStoreTests : IAsyncLifetime + { + private TestDatabase _database = null!; + + public async Task InitializeAsync() => _database = await TestDatabase.CreateAsync(); + + public Task DisposeAsync() + { + _database.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Wallpaper_DefaultsToEmptyAndOverwrites() + { + Assert.Equal(string.Empty, await _database.Preferences.GetWallpaperAsync("license:a")); + + await _database.Preferences.SetWallpaperAsync("license:a", "id:dusk"); + await _database.Preferences.SetWallpaperAsync("license:a", "id:hexgrid"); + + Assert.Equal("id:hexgrid", await _database.Preferences.GetWallpaperAsync("license:a")); + } + } +} diff --git a/FivePRS.Tests/TestDatabase.cs b/FivePRS.Tests/TestDatabase.cs new file mode 100644 index 0000000..aeab794 --- /dev/null +++ b/FivePRS.Tests/TestDatabase.cs @@ -0,0 +1,73 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using FivePRS.Server.Database; +using Microsoft.Data.Sqlite; +using MySqlConnector; + +namespace FivePRS.Tests +{ + public sealed class TestDatabase : IDisposable + { + public const string Collection = "Database"; + + private readonly string _path = Path.Combine(Path.GetTempPath(), $"fiveprs-test-{Guid.NewGuid():N}.db"); + + public CivilianStore Store { get; private set; } = null!; + + public RosterStore Roster { get; private set; } = null!; + + public AppearanceStore Appearances { get; private set; } = null!; + + public PreferencesStore Preferences { get; private set; } = null!; + + public PositionStore Positions { get; private set; } = null!; + + public IDatabaseProvider Provider { get; private set; } = null!; + + public static async Task CreateAsync() + { + var database = new TestDatabase(); + var mySql = Environment.GetEnvironmentVariable("FIVEPRS_TEST_MYSQL"); + + IDatabaseProvider provider; + if (string.IsNullOrEmpty(mySql)) + { + provider = new SQLiteProvider(database._path); + } + else + { + await DropTablesAsync(mySql); + provider = new MySqlProvider(mySql); + } + + await provider.InitializeAsync(); + await new Migrator(provider).MigrateAsync(); + database.Provider = provider; + database.Store = new CivilianStore(provider); + database.Roster = new RosterStore(provider); + database.Appearances = new AppearanceStore(provider); + database.Preferences = new PreferencesStore(provider); + database.Positions = new PositionStore(provider); + return database; + } + + private static async Task DropTablesAsync(string connectionString) + { + using var conn = new MySqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DROP TABLE IF EXISTS fiveprs_records, fiveprs_licenses, fiveprs_vehicles, fiveprs_characters, fiveprs_roster, fiveprs_appearances, fiveprs_preferences, fiveprs_positions, fiveprs_audit, fiveprs_schema, ers_players;"; + await cmd.ExecuteNonQueryAsync(); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + foreach (var file in new[] { _path, _path + "-wal", _path + "-shm" }) + { + if (File.Exists(file)) File.Delete(file); + } + } + } +} diff --git a/FivePRS.Tests/UpdateTests.cs b/FivePRS.Tests/UpdateTests.cs new file mode 100644 index 0000000..27074e7 --- /dev/null +++ b/FivePRS.Tests/UpdateTests.cs @@ -0,0 +1,77 @@ +using FivePRS.Core.Updates; +using Xunit; + +namespace FivePRS.Tests +{ + public class ReleaseVersionTests + { + [Theory] + [InlineData("1.0.0", "1.0.1", -1)] + [InlineData("v1.2.0", "1.10.0", -1)] + [InlineData("2.0.0", "1.99.99", 1)] + [InlineData("1.0.0-beta.1", "1.0.0", -1)] + [InlineData("1.0.0-beta.2", "1.0.0-beta.1", 1)] + [InlineData("0.9.0-ptb.10", "0.9.0-ptb.2", 1)] + [InlineData("0.9.0-ptb.2", "0.9.0-ptb", 1)] + [InlineData("0.9.0-rc.1", "0.9.0-ptb.5", 1)] + [InlineData("0.9.0", "0.9.0-ptb.5", 1)] + [InlineData("1.0", "1.0.0", 0)] + [InlineData("v1.0.0+build.7", "1.0.0", 0)] + public void CompareTo_OrdersSemanticVersions(string left, string right, int expected) + { + Assert.True(ReleaseVersion.TryParse(left, out var a)); + Assert.True(ReleaseVersion.TryParse(right, out var b)); + Assert.Equal(expected, System.Math.Sign(a.CompareTo(b))); + } + + [Theory] + [InlineData("")] + [InlineData("latest")] + [InlineData("1..0")] + [InlineData("1.0.0.0")] + public void TryParse_RejectsInvalidVersions(string text) + { + Assert.False(ReleaseVersion.TryParse(text, out _)); + } + } + + public class ReleaseFeedTests + { + private const string Json = @"[ + { ""tag_name"": ""v1.3.0-beta.1"", ""name"": ""1.3 beta"", ""html_url"": ""https://example.com/b"", ""body"": """", ""draft"": false, ""prerelease"": true, ""assets"": [] }, + { ""tag_name"": ""v1.4.0"", ""name"": ""Draft"", ""html_url"": ""https://example.com/d"", ""body"": """", ""draft"": true, ""prerelease"": false, ""assets"": [] }, + { ""tag_name"": ""v1.2.0"", ""name"": ""FivePRS 1.2"", ""html_url"": ""https://example.com/r"", ""body"": ""## Changes\n- One\n- Two"", ""draft"": false, ""prerelease"": false, + ""assets"": [ { ""name"": ""fiveprs.zip"", ""browser_download_url"": ""https://example.com/fiveprs.zip"" } ] }, + { ""tag_name"": ""nightly"", ""name"": ""Nightly"", ""html_url"": """", ""body"": """", ""draft"": false, ""prerelease"": false, ""assets"": [] } + ]"; + + [Fact] + public void Latest_SkipsDraftsAndPreReleasesByDefault() + { + var release = ReleaseFeed.Latest(Json, "fiveprs.zip", includePreReleases: false); + + Assert.NotNull(release); + Assert.Equal("1.2.0", release!.Version.ToString()); + Assert.Equal("https://example.com/fiveprs.zip", release.AssetUrl); + Assert.Equal("- One\n- Two", ReleaseFeed.Summary(release.Notes)); + } + + [Fact] + public void Latest_CanIncludePreReleases() + { + var release = ReleaseFeed.Latest(Json, "fiveprs.zip", includePreReleases: true); + + Assert.Equal("1.3.0-beta.1", release!.Version.ToString()); + Assert.Null(release.AssetUrl); + } + + [Theory] + [InlineData("[]")] + [InlineData("{\"message\":\"Not Found\"}")] + [InlineData("not json")] + public void Latest_ReturnsNullWithoutReleases(string json) + { + Assert.Null(ReleaseFeed.Latest(json, "fiveprs.zip", false)); + } + } +} diff --git a/FivePRS.Tests/WeaponLabelsTests.cs b/FivePRS.Tests/WeaponLabelsTests.cs new file mode 100644 index 0000000..89cca3e --- /dev/null +++ b/FivePRS.Tests/WeaponLabelsTests.cs @@ -0,0 +1,18 @@ +using FivePRS.Core.Text; +using Xunit; + +namespace FivePRS.Tests +{ + public class WeaponLabelsTests + { + [Theory] + [InlineData("WEAPON_STUNGUN", null, "Taser")] + [InlineData("weapon_pumpshotgun", null, "Pump Shotgun")] + [InlineData("WEAPON_HEAVY_SNIPER", null, "Heavy Sniper")] + [InlineData("WEAPON_PISTOL", "Duty Pistol", "Duty Pistol")] + public void For_UsesLabelKnownNameOrReadableFallback(string weapon, string? label, string expected) + { + Assert.Equal(expected, WeaponLabels.For(weapon, label)); + } + } +} diff --git a/README.md b/README.md index 5abf0cb..8799ece 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,26 @@ -![FivePRS Logo](https://cmap.pics/ibNxG/uXr_2c.png/raw) +![FivePRS Logo](https://cmap.pics/ibNxG/uXr_2c.png/raw) -**FivePRS Core** is the primary framework engine designed for FiveM, providing a high performance, modular alternative for scripted emergency response gameplay. +**FivePRS** is a free, open-source public response framework for FiveM: police, fire and EMS roleplay in one resource, with server-side dispatch, real jurisdictions, records, civilian life and an in-car terminal. > [!CAUTION] -> FivePRS is not yet ready for production use and is only public at this stage for transparency and community contributions/feedback. +> FivePRS is in public testing. Expect bugs, and back up your database before updating. Report problems in [Issues](https://github.com/FivePRS/core/issues) or on [Discord](https://discord.gg/zHyzHWKuM2). -## Project Status: Under Development -The core engine is currently in the architectural phase. We are focusing on: -* **Event Routing:** Efficient handling of multi departmental calls. -* **Unified API:** A robust set of tools for developers to build callouts. -* **Performance:** Minimizing resmon usage while maximizing immersion. +The full documentation lives at **[fiveprs.org/docs](https://fiveprs.org/docs)**. -## Key Features (Planned) -- **Multi Agency Support:** Native logic for PD, Fire, and Medical. -- **Modular Design:** Load only the systems your server needs. -- **Dynamic AI:** Smarter NPC interactions and scene management. -- **Extensible:** Built to be easily expanded via the `addons` repository. +## Installation +1. Download `fiveprs.zip` from the latest [release](https://github.com/FivePRS/core/releases). +2. Extract it into your server's `resources/` folder, so you have `resources/fiveprs/fxmanifest.lua`. +3. Add the [server configuration](#server-configuration) below to `server.cfg`, ending with `ensure fiveprs`. +4. Start the server. The console shows `[FivePRS] Database (SQLite) ready.` and `[FivePRS] ServerBrain online.` + +FivePRS runs on Windows and Linux servers. It uses SQLite out of the box, so there is nothing else to set up; MySQL is supported too. + +Optional official addons (a loading screen, Discord rich presence and a Notes app) are in the [addons repository](https://github.com/FivePRS/addons): download `fiveprs_addons.zip` from its releases. + +To update, see [Updates](#updates): FivePRS tells you when a new release is out and can download it for you. What changed in each release is in the [changelog](CHANGELOG.md). + +### Building from source +On Windows, run `publish.ps1` to build everything and package `dist/fiveprs.zip`. Run the tests with `dotnet test`. ## Server Configuration ```cfg @@ -23,17 +28,23 @@ set fiveprs_db_type "sqlite" # or "mysql" set fiveprs_db_connection "" # MySQL connection string, or a custom SQLite path set fiveprs_max_xp 500 # cap on XP awarded per call set fiveprs_xp_multiplier 1.0 -set fiveprs_restrict_departments false # true requires the department ACE below +set fiveprs_restrict_departments false # true limits departments to the ACE below or the admin roster +set fiveprs_server_icon true # use the FivePRS icon unless load_server_icon sets your own add_ace group.admin fiveprs.admin allow add_ace group.police fiveprs.department.police allow +add_ace resource.fiveprs command.load_server_icon allow # lets FivePRS set the server icon + +ensure fiveprs ``` | Permission | Grants | |---|---| -| `fiveprs.admin` | Admin commands and access to every department | +| `fiveprs.admin` | Admin commands, the Admin app and access to every department | | `fiveprs.department.` | Joining that department when `fiveprs_restrict_departments` is `true` | +When `fiveprs_restrict_departments` is `true`, a player can join a department if they are an admin, hold its ACE, or are on its roster in the Admin app. + Admin commands work from the server console or in game with `fiveprs.admin`: | Command | Description | @@ -45,15 +56,168 @@ Admin commands work from the server console or in game with `fiveprs.admin`: | `fprs_offduty ` | Force a player off duty | | `fprs_endcall ` | Close an active call | -## Mobile Data Terminal -On-duty units open the MDT with `F7` or `/mdt`. It shows your unit and status, every active call with its territory and assigned units, and all units on duty. From the MDT you can set your status, attach to a call as backup and set a waypoint to a call. The interface lives in `nui/` as plain HTML, CSS and JavaScript, so it can be restyled without rebuilding the resource. +## FivePRS Terminal +Press `F5` or use `/fiveprs` to open the FivePRS terminal, a rugged in-car tablet. It opens on a home screen with a status bar (your callsign and status, or your active character) and an app for each feature you can use: Duty, Dispatch, Records, Characters, Licenses, Vehicles, 911, Admin and Settings. Apps only appear when you can use them, Dispatch shows a badge for an incoming or active call, and 911 shows one while your call is open. **Home** or `Escape` returns to the home screen; `Escape` on the home screen closes the terminal. + +### Settings +The Settings app holds per-player options. While on duty it has the **AI callouts** switch. Players choose a wallpaper from the defaults or, if allowed, paste any https image link; the choice is saved to their profile on the server. + +Wallpapers are configured under `terminal` in `config/settings.json`: `wallpapers` lists the built-in choices (`id`, `label`, `url`), `defaultWallpaper` is the id used until a player picks one, and `allowCustomWallpapers` set to `false` limits players to that list. + +### Making terminal apps +Anyone can add apps to the terminal, either as a C# DLL dropped into `apps/` or from any resource through exports. Both kinds get a home screen icon, a badge and the Home button, and can either describe a screen that FivePRS draws in the terminal's style, or show their own HTML page. + +**C# app (`apps/*.net.dll`).** Reference `client/FivePRS.Core.dll` and `client/FivePRS.Client.net.dll`, build a net452 class library named `.net.dll`, and subclass `TerminalApp`: + +```csharp +using System.Collections.Generic; +using FivePRS.Client.Terminal; +using FivePRS.Core.Models; + +public class TowApp : TerminalApp +{ + public override string Id => "tow"; + public override string Label => "Tow"; + public override string Color => "#ca8a04"; + public override string Icon => "M3 17h13l3-5h2v5 M7.5 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"; + + public override bool IsAvailable(PlayerData player) => player.IsOnDuty; + + public override AppScreen BuildScreen() => new AppScreen() + .Add(AppBlock.Section("Request a tow")) + .Add(AppBlock.Buttons(new AppItem { Title = "Call tow truck", Action = "call", Style = "primary" })); + + public override void OnAction(string action, IDictionary data) + { + if (action == "call") Refresh(); + } +} +``` + +Screens are built from blocks: `Section`, `Paragraph`, `Buttons`, `List` (rows or a grid, with optional images) and `Form`. Every button, list item and form sends its `Action` and `Data` to `OnAction`. `Icon` is SVG path data on a 24×24 grid or an image URL; `Order` places the app on the home screen (built-in apps use 0 to 80, Settings is 1000); `Badge` shows a badge. For a fully custom screen, set `Page` to an HTML file such as `apps/tow/index.html` and put the page in `apps/tow/`. + +**Resource app (any language).** Register from a client script: + +```lua +exports.fiveprs:registerApp({ + id = 'notes', label = 'Notes', color = '#0891b2', order = 600, + icon = 'M5 3h10l4 4v14H5z M9 9h6 M9 13h6', + page = ('https://cfx-nui-%s/html/index.html'):format(GetCurrentResourceName()), +}) + +AddEventHandler('fiveprs:appAction', function(app, action, data) + if app ~= 'notes' then return end + exports.fiveprs:sendAppMessage('notes', 'saved', { ok = true }) +end) +``` + +Leave out `page` and call `exports.fiveprs:setAppView(id, screen)` to have FivePRS draw a screen instead, using the same blocks as tables (`{ blocks = { { type = 'section', title = 'Notes' }, ... } }`). `setAppBadge(id, text)`, `setAppAvailable(id, bool)` and `unregisterApp(id)` update the app, and apps are removed when their resource stops. + +**App pages.** An app page includes `https://cfx-nui-fiveprs/nui/app-sdk.js`, which provides `FivePRS.on(type, handler)` for messages, `FivePRS.action(name, data)` to send an action to the app, `FivePRS.home()` to return to the home screen, and `FivePRS.context`, the player's FivePRS state (the same as `exports.fiveprs:getState()`), which is also delivered as a `context` message whenever it changes. Messages from a C# app's `Send(type, payload)` or a resource's `sendAppMessage` arrive through `FivePRS.on`. `Escape` inside an app page returns to the home screen. + +The `fiveprs_notes` addon in the [addons repository](https://github.com/FivePRS/addons) is a complete resource app to copy from. + +### Duty +The Duty app shows the player's rank and XP and lets them pick a department, agency and callsign before going on duty. Only departments the player is permitted to join are offered. Callsigns are up to 12 letters, numbers or hyphens, are saved to the player's profile, and must be unique among units on duty; leaving it blank uses the agency's default (for example `LSPD-12`). While on duty, the tab shows the player's unit and a Go off duty button. + +### Dispatch +On-duty units get a Dispatch app. It shows your unit and status, any incoming call with Accept and Decline buttons and a countdown, your active call with an End call button, every active call with its territory and assigned units, and all units on duty. From here you can set your status, attach to a call as backup and set a waypoint to a call. The AI callouts switch in the Settings app stops dispatch offering you AI callouts while player 911 calls still reach you, for servers that mix player and NPC roleplay; the choice is remembered, and `/er_aicallouts` toggles it (bindable under FivePRS in the key bindings). The accept, decline and end call keys still work alongside the buttons. + +The menu lives in `nui/` as plain HTML, CSS and JavaScript, so it can be restyled without rebuilding the resource. + +### Records +On-duty police get a Records app. Search for a person by name or a vehicle by plate to open their record: identity, an active-warrant flag, licenses, registered vehicles and history. From a record, officers can: + +- add a citation (with a fine up to `maxFine` in `config/settings.json`), arrest, warning or warrant; +- mark an active warrant as served or cleared; +- issue, suspend, revoke or reinstate any license type, including ones that are not self-service; +- mark a registered vehicle stolen or recovered. + +Every change is stamped with the officer's name and callsign and written to the audit log. If the person's owner is online, they get a notification and their Characters app updates. A character with an active warrant cannot be deleted. + +### Characters, Licenses and Vehicles +Every player has three civilian apps for roleplay records, all for their active character: + +- **Characters:** create up to `maxCharacters` characters (name, date of birth, gender) and pick the active one. Deleting a character removes its licenses and vehicles. +- **Licenses:** license types come from `config/licenses.json`. Types with `"selfService": true` can be applied for from the tab and are issued immediately; others must be issued by law enforcement. Licenses can be valid, suspended or revoked. +- **Record:** a read-only history of citations, arrests, warnings and warrants added by law enforcement. +- **Vehicles:** sit in the driver's seat and register the vehicle; the server reads the plate from the vehicle itself, so registrations always match a real car. Each character can register up to `maxVehiclesPerCharacter` vehicles, plates are unique server-wide, and owners can report a vehicle stolen or recovered. + +`maxCharacters` and `maxVehiclesPerCharacter` are set in `config/settings.json`. + +**Appearance:** each character has its own look. Creating a character opens the character creator, and **Edit appearance** on the Characters app reopens it (off duty only). Players build a custom male or female character (parents, face shape, hair, facial details, eye colour and clothing), or pick one of the standard GTA peds listed under `creator.standardPeds` in `config/settings.json` (set `creator.allowStandardPeds` to `false` to require custom characters). The look is saved per character, reapplied on every spawn and character switch, and restored when going off duty. Locker uniforms need a custom character; standard peds get the department's `fallbackPedModels` instead. + +### 911 +Every player has a 911 app to call Police, EMS or Fire from their current position, optionally as an anonymous caller. On-duty units in that department are notified and see the call in their Dispatch app with the caller and description; it has no primary unit, and any unit can attach to respond. The caller is told when units respond and when the call is cleared, and can cancel it while it is open. An attached unit clears the call from the Dispatch app when finished. + +Each player can have one open 911 call at a time, with `emergencyCallCooldownSeconds` between calls. A call that nobody attaches to closes after `emergencyCallTimeoutMinutes`. + +### Admin +Players with `fiveprs.admin` get an Admin app. It lists online players and can search offline players by name, with buttons to add or remove each player from the Police, EMS and Fire rosters. Changes apply immediately: the player is notified, their department options update, and anyone removed from the department they are on duty in is taken off duty. Every change is written to the audit log. + +The roster only takes effect when `fiveprs_restrict_departments` is `true`; the tab shows a warning while it is off. + +## Addon API +Other client resources can read the local player's FivePRS state with `exports.fiveprs:getState()`. It returns `name`, `onDuty`, `department` and `rank`. While on duty, it also returns `agency`, `callsign`, `status` and `territory`, plus `callId` and `call` while assigned to a call. The official `fiveprs_presence` addon uses it for Discord rich presence. + +## Updates +FivePRS checks the [core repository's releases](https://github.com/FivePRS/core/releases) on start and every `checkIntervalHours`. When a newer version is out, the console shows it with a summary of the release notes, and admins are told when they join. Run `fiveprs_update` in the server console (or in game with `fiveprs.admin`) to download the release and unpack it into `data/updates//`, without its `config/` and `data/` folders. Stop the server, copy that folder's contents over `resources/fiveprs`, and start it again; your settings and database are kept. If the new version changes the database, FivePRS updates it on start and logs each step (`Applied database migration ...`). With SQLite it first saves a copy to `data/backups/`; with MySQL, back up the database yourself before updating. + +Settings are under `updates` in `config/settings.json`: `checkForUpdates`, `repository`, `assetName` (the release file to download, `fiveprs.zip`), `checkIntervalHours` and `includePreReleases`. + +To publish a release, move the `Unreleased` entries in `CHANGELOG.md` under a new version heading, set the same `version` in `fxmanifest.lua`, commit, and push a tag named `v`. GitHub Actions checks the versions match, builds and tests, and creates the release with `fiveprs.zip` attached and that changelog section as its notes. Versions with a suffix, like `0.9.0-ptb.1`, are published as pre-releases, and servers running a pre-release are offered newer pre-releases automatically. + +## Rate limits +Each player can only send so many requests to the server in a short time, so one player can't flood dispatch, records or the database. Requests over the limit are ignored, the player is told to slow down where it matters (911 calls, records, civilian actions), and the console logs the first one in each window. Limits are under `rateLimits` in `config/settings.json`, as a `limit` per `perSeconds` for each kind of request; set `enabled` to `false` to turn them off. + +## Spawning +Players are returned to where they last were when they join, with their last character active. Positions are saved every `saveIntervalSeconds` while playing and when they leave. Turn it off with `spawn.restoreLastLocation` in `config/settings.json`. + +## Branding +The terminal nameplate, the notification logo, the server icon and the department icons are loaded from the FivePRS CDN. The URLs live under `branding` in `config/settings.json` and can point to your own images. The department icons under `branding.departments` (`police`, `ems`, `fire` and `civilian`) are shown in the Duty, Dispatch, Characters and 911 apps and in the station menu; a missing image is simply left out. The default terminal wallpapers are served from `https://cdn.fiveprs.org/fiveprs/wallpapers/`. + +The server icon is downloaded on start and cached in `data/server_icon.png`, which is used if the download fails. It is only applied when `fiveprs_server_icon` is `true`, no icon has been loaded with `load_server_icon`, and `server.cfg` allows FivePRS to run the command with `add_ace resource.fiveprs command.load_server_icon allow`. + +## Loading Screen +FivePRS hands each joining player's name, agency and rank to the loading screen. The official `fiveprs_loadscreen` addon in the [addons repository](https://github.com/FivePRS/addons) uses it for a welcome back card; any loading screen can read it from `window.nuiHandoverData.fiveprs`. + +## Stations +`config/stations.json` lists the police stations, fire stations and hospitals. Each has a map blip and a duty point marker. Set `blips` to `always`, `onDuty` (only your own department's stations while on duty) or `off`. + +| Field | Description | +|---|---| +| `id`, `name` | Unique ID and display name | +| `department` | `Police`, `EMS` or `Fire` | +| `agencies` | Agency IDs that use the station; empty means every agency in the department | +| `dutyPoint`, `heading` | Marker position `[x, y, z]`, also where players teleport to | +| `armory`, `locker`, `garage` | `[x, y, z]` markers for each service. Without `garage`, the first parking spot or the pavement by the nearest road is used. Without `armory` or `locker`, that service is offered at the duty point instead | +| `parking` | Optional vehicle spots `[x, y, z, heading]`; the first free one is used, otherwise the nearest road to the garage | +| `blip` | Optional `{ "sprite": 60, "color": 3 }` to override the department's default blip | + +Each service has its own marker for on-duty members of the station's department; Mission Row ships with its armory, locker room and garage set. Pressing `E` (FivePRS: Interact) on a marker opens it. On the duty point it opens the Duty app, or the station menu while on duty. The station menu sits on the right of the screen and is used with the arrow keys or mouse wheel, Enter and Backspace: + +- **Armory:** rank kits from `police_loadouts.json` (Recruit, Officer at rank 3, Senior Officer at rank 5, Command at rank 8), each weapon on its own, body armour, ammo refills and returning weapons. A weapon can set a `label` to change its menu name. +- **Locker:** the patrol uniform, the command uniform from rank 8, and your own clothes. Uniforms are freemode clothing; players using a standard GTA ped get `fallbackPedModels` from `police_loadouts.json` (the GTA police officer models by default) instead. +- **Garage:** every model from `police_vehicles.json` your rank unlocks, parked outside, and returning your vehicle when it is near the garage. You have one vehicle at a time. + +To get coordinates, stand where you want a point and run `/fprs_coords` for a duty point, or `/fprs_coords armory`, `locker` or `garage`. Sit in a parked vehicle for a parking spot. The values are copied to the clipboard in `stations.json` format and printed to the F8 console. + +When going on duty, the Duty app asks where to start: + +- **Here:** your rank's loadout, uniform and vehicle are issued. The vehicle spawns on the nearest road, or outside the station if you are within 75 m of one of your stations. +- **Station:** you are teleported to the chosen station to collect your gear. +- **Drive to station:** a GPS route is set to the chosen station to collect your gear. + +`dutyEquipment` in `config/settings.json` changes this: `byStart` (the default) works as above, `always` issues gear on every start (with Drive to station, the vehicle appears when you get close), and `station` never issues gear automatically. ## Jurisdictions -`config/jurisdictions.json` defines territories as map polygons and the agencies that patrol them. Players choose an agency within their department with `/setagency`, and dispatch only offers calls to units inside their agency's territory. An agency with no territories is unrestricted. The bundled split (Los Santos for LSPD, Blaine County for BCSO) is a coarse default; adjust the polygons to suit your server. +`config/jurisdictions.json` defines territories as map polygons and the agencies that patrol them. Players choose an agency within their department from the duty menu, and dispatch only offers calls to units inside their agency's territory. An agency with no territories is unrestricted. The bundled split (Los Santos for LSPD, Blaine County for BCSO) is a coarse default; adjust the polygons to suit your server. ## Agency Loadouts & Vehicles `config/police_loadouts.json` and `config/police_vehicles.json` define the default weapons, uniforms and patrol vehicles per rank tier. Any agency can override individual tiers under `agencies`; anything not overridden falls back to the defaults. BCSO ships with the `sheriff` and `sheriff2` vehicles. +Vehicles spawn as stock models. A tier can also set `primaryColor`, `secondaryColor`, `livery`, `dirtLevel`, `plateText`, `forcedExtras` and `disabledExtras`; anything left out is not changed. + Uniforms use freemode clothing slots by default, which keeps the player's character. To dress an agency in a ped model instead: ```json @@ -67,3 +231,8 @@ Uniforms use freemode clothing slots by default, which keeps the player's charac The original model and clothing are restored when going off duty, but freemode facial customisation is not, so ped models suit servers that do not use freemode characters. Duty changes, department changes, XP awards, rank-ups, admin actions and denied permission checks are recorded in the `fiveprs_audit` table. + +## Troubleshooting +**`Could not load assembly Windows` / `Microsoft.Windows.SDK.NET` stack traces on start.** These appear once each time the resource starts with SQLite and are harmless. `Microsoft.Data.Sqlite` checks whether it is running as a Windows Store app by looking for those assemblies; FiveM logs each failed lookup with a full stack trace, then SQLite carries on normally. Startup succeeded if `[FivePRS] Database (SQLite) ready.` follows them. MySQL does not produce these messages. + +**`Could not load native SQLite` or `DllNotFoundException: e_sqlite3`.** The resource's `server/` folder must contain `e_sqlite3.dll` (Windows) or `libe_sqlite3.so` (Linux). Re-extract `fiveprs.zip` rather than copying individual DLLs. diff --git a/config/licenses.json b/config/licenses.json new file mode 100644 index 0000000..0c36721 --- /dev/null +++ b/config/licenses.json @@ -0,0 +1,11 @@ +{ + "types": [ + { "id": "driver", "name": "Driver's License", "selfService": true }, + { "id": "motorcycle", "name": "Motorcycle License", "selfService": true }, + { "id": "commercial", "name": "Commercial Driver's License", "selfService": false }, + { "id": "weapon", "name": "Weapon Permit", "selfService": false }, + { "id": "pilot", "name": "Pilot License", "selfService": false }, + { "id": "boat", "name": "Boating License", "selfService": true }, + { "id": "hunting", "name": "Hunting License", "selfService": true } + ] +} diff --git a/config/police_loadouts.json b/config/police_loadouts.json index 4b7155c..88c801c 100644 --- a/config/police_loadouts.json +++ b/config/police_loadouts.json @@ -64,5 +64,6 @@ { "slot": 1, "drawable": -1, "texture": -1 } ] }, + "fallbackPedModels": { "male": "s_m_y_cop_01", "female": "s_f_y_cop_01" }, "agencies": {} } diff --git a/config/police_vehicles.json b/config/police_vehicles.json index 463d95a..82a1e59 100644 --- a/config/police_vehicles.json +++ b/config/police_vehicles.json @@ -1,66 +1,12 @@ { - "patrol": { - "models": ["police", "police2"], - "primaryColor": 0, - "secondaryColor": 0, - "dirtLevel": 2, - "livery": 0, - "plateText": "", - "forcedExtras": [1, 2], - "disabledExtras": [5] - }, - "senior": { - "models": ["police2", "police4"], - "primaryColor": 0, - "secondaryColor": 0, - "dirtLevel": 1, - "livery": 0, - "plateText": "", - "forcedExtras": [1, 2], - "disabledExtras": [] - }, - "command": { - "models": ["police3"], - "primaryColor": 111, - "secondaryColor": 111, - "dirtLevel": 0, - "livery": -1, - "plateText": "CMND", - "forcedExtras": [], - "disabledExtras": [] - }, + "patrol": { "models": ["police", "police2"] }, + "senior": { "models": ["police2", "police4"] }, + "command": { "models": ["police3"] }, "agencies": { "bcso": { - "patrol": { - "models": ["sheriff", "sheriff2"], - "primaryColor": -1, - "secondaryColor": -1, - "dirtLevel": 3, - "livery": -1, - "plateText": "", - "forcedExtras": [], - "disabledExtras": [] - }, - "senior": { - "models": ["sheriff2"], - "primaryColor": -1, - "secondaryColor": -1, - "dirtLevel": 1, - "livery": -1, - "plateText": "", - "forcedExtras": [], - "disabledExtras": [] - }, - "command": { - "models": ["sheriff2"], - "primaryColor": -1, - "secondaryColor": -1, - "dirtLevel": 0, - "livery": -1, - "plateText": "BCSO", - "forcedExtras": [], - "disabledExtras": [] - } + "patrol": { "models": ["sheriff", "sheriff2"] }, + "senior": { "models": ["sheriff2"] }, + "command": { "models": ["sheriff2"] } } } } diff --git a/config/settings.json b/config/settings.json index 0029518..bc2f82f 100644 --- a/config/settings.json +++ b/config/settings.json @@ -5,5 +5,71 @@ "postCompleteCooldownSeconds": 60, "postDeclineCooldownSeconds": 45, "postFailCooldownSeconds": 30, - "noCalloutRetrySeconds": 30 + "noCalloutRetrySeconds": 30, + "maxCharacters": 3, + "maxVehiclesPerCharacter": 5, + "maxFine": 10000, + "emergencyCallCooldownSeconds": 60, + "emergencyCallTimeoutMinutes": 15, + "dutyEquipment": "byStart", + "spawn": { + "restoreLastLocation": true, + "saveIntervalSeconds": 30 + }, + "updates": { + "checkForUpdates": true, + "repository": "FivePRS/core", + "assetName": "fiveprs.zip", + "checkIntervalHours": 6, + "includePreReleases": false + }, + "rateLimits": { + "enabled": true, + "emergency": { "limit": 4, "perSeconds": 60 }, + "lookups": { "limit": 20, "perSeconds": 10 }, + "records": { "limit": 10, "perSeconds": 10 }, + "civilian": { "limit": 12, "perSeconds": 10 }, + "dispatch": { "limit": 20, "perSeconds": 10 }, + "admin": { "limit": 15, "perSeconds": 10 }, + "positions": { "limit": 6, "perSeconds": 10 }, + "requests": { "limit": 10, "perSeconds": 10 } + }, + "terminal": { + "defaultWallpaper": "fiveprs", + "allowCustomWallpapers": true, + "wallpapers": [ + { "id": "fiveprs", "label": "FivePRS", "url": "https://cdn.fiveprs.org/fiveprs/wallpapers/fiveprs.png" }, + { "id": "lightbar", "label": "Lightbar", "url": "https://cdn.fiveprs.org/fiveprs/wallpapers/lightbar.png" }, + { "id": "hexgrid", "label": "Hex grid", "url": "https://cdn.fiveprs.org/fiveprs/wallpapers/hexgrid.png" }, + { "id": "dusk", "label": "Los Santos dusk", "url": "https://cdn.fiveprs.org/fiveprs/wallpapers/dusk.png" } + ] + }, + "creator": { + "allowStandardPeds": true, + "standardPeds": [ + { "model": "a_m_y_skater_01", "label": "Skater" }, + { "model": "a_m_y_hipster_01", "label": "Hipster (male)" }, + { "model": "a_f_y_hipster_01", "label": "Hipster (female)" }, + { "model": "a_m_y_business_01", "label": "Business (male)" }, + { "model": "a_f_y_business_01", "label": "Business (female)" }, + { "model": "a_m_y_beach_01", "label": "Beach (male)" }, + { "model": "a_f_y_beach_01", "label": "Beach (female)" }, + { "model": "a_m_y_hiker_01", "label": "Hiker (male)" }, + { "model": "a_f_y_hiker_01", "label": "Hiker (female)" }, + { "model": "a_m_m_tourist_01", "label": "Tourist (male)" }, + { "model": "a_f_m_tourist_01", "label": "Tourist (female)" }, + { "model": "a_m_m_farmer_01", "label": "Farmer" } + ] + }, + "branding": { + "serverIcon": "https://cdn.fiveprs.org/fiveprs/server_icon.png", + "notificationLogo": "https://cdn.fiveprs.org/fiveprs/fiveprs_logo.png", + "nameplate": "https://cdn.fiveprs.org/fiveprs/nameplate.png", + "departments": { + "police": "https://cdn.fiveprs.org/fiveprs/departments/police.png", + "ems": "https://cdn.fiveprs.org/fiveprs/departments/ems.png", + "fire": "https://cdn.fiveprs.org/fiveprs/departments/fire.png", + "civilian": "https://cdn.fiveprs.org/fiveprs/departments/civilian.png" + } + } } diff --git a/config/stations.json b/config/stations.json new file mode 100644 index 0000000..34f5497 --- /dev/null +++ b/config/stations.json @@ -0,0 +1,32 @@ +{ + "blips": "always", + "stations": [ + { + "id": "mission_row_pd", "name": "Mission Row Police Station", "department": "Police", "agencies": ["lspd"], + "dutyPoint": [433.6, -981.8, 30.7], "heading": 90, + "armory": [451.7, -980.1, 30.7], + "locker": [452.6, -992.8, 30.7], + "garage": [454.6, -1017.4, 28.4], + "parking": [[438.4, -1018.3, 27.7, 90], [441.0, -1024.2, 28.3, 90], [453.5, -1022.2, 28.0, 90], [450.9, -1016.5, 28.1, 90]] + }, + { "id": "vespucci_pd", "name": "Vespucci Police Station", "department": "Police", "agencies": ["lspd"], "dutyPoint": [-1108.4, -845.0, 19.3], "heading": 130, "parking": [] }, + { "id": "la_mesa_pd", "name": "La Mesa Police Station", "department": "Police", "agencies": ["lspd"], "dutyPoint": [826.8, -1290.0, 28.2], "heading": 90, "parking": [] }, + { "id": "davis_pd", "name": "Davis Police Station", "department": "Police", "agencies": ["lspd"], "dutyPoint": [360.9, -1584.7, 29.3], "heading": 50, "parking": [] }, + { "id": "vinewood_pd", "name": "Vinewood Police Station", "department": "Police", "agencies": ["lspd"], "dutyPoint": [638.6, 1.7, 82.8], "heading": 250, "parking": [] }, + { "id": "rockford_pd", "name": "Rockford Hills Police Station", "department": "Police", "agencies": ["lspd"], "dutyPoint": [-561.4, -131.9, 38.4], "heading": 200, "parking": [] }, + { "id": "sandy_so", "name": "Sandy Shores Sheriff's Office", "department": "Police", "agencies": ["bcso"], "dutyPoint": [1853.8, 3686.4, 34.3], "heading": 210, "parking": [] }, + { "id": "paleto_so", "name": "Paleto Bay Sheriff's Office", "department": "Police", "agencies": ["bcso"], "dutyPoint": [-448.2, 6012.4, 31.7], "heading": 45, "parking": [] }, + + { "id": "davis_fd", "name": "Davis Fire Station", "department": "Fire", "agencies": [], "dutyPoint": [199.8, -1643.9, 29.8], "heading": 320, "parking": [] }, + { "id": "rockford_fd", "name": "Rockford Hills Fire Station", "department": "Fire", "agencies": [], "dutyPoint": [-634.9, -121.8, 39.0], "heading": 80, "parking": [] }, + { "id": "el_burro_fd", "name": "El Burro Heights Fire Station", "department": "Fire", "agencies": [], "dutyPoint": [1193.5, -1464.7, 34.9], "heading": 0, "parking": [] }, + { "id": "sandy_fd", "name": "Sandy Shores Fire Station", "department": "Fire", "agencies": [], "dutyPoint": [1697.2, 3586.0, 35.6], "heading": 210, "parking": [] }, + { "id": "paleto_fd", "name": "Paleto Bay Fire Station", "department": "Fire", "agencies": [], "dutyPoint": [-379.5, 6118.4, 31.8], "heading": 45, "parking": [] }, + + { "id": "pillbox", "name": "Pillbox Hill Medical Center", "department": "EMS", "agencies": [], "dutyPoint": [298.6, -584.5, 43.3], "heading": 70, "parking": [] }, + { "id": "central_ls", "name": "Central Los Santos Medical", "department": "EMS", "agencies": [], "dutyPoint": [340.5, -1396.0, 32.5], "heading": 50, "parking": [] }, + { "id": "mount_zonah", "name": "Mount Zonah Medical Center", "department": "EMS", "agencies": [], "dutyPoint": [-449.7, -340.6, 34.5], "heading": 80, "parking": [] }, + { "id": "sandy_medical", "name": "Sandy Shores Medical Center", "department": "EMS", "agencies": [], "dutyPoint": [1839.6, 3672.9, 34.3], "heading": 210, "parking": [] }, + { "id": "paleto_medical", "name": "Paleto Bay Medical Center", "department": "EMS", "agencies": [], "dutyPoint": [-247.6, 6331.2, 32.4], "heading": 225, "parking": [] } + ] +} diff --git a/fxmanifest.lua b/fxmanifest.lua index 60abf5b..fe1e818 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -6,29 +6,47 @@ author 'Pixel ' description 'The Unified Public Response Framework for FiveM.' repository 'https://github.com/FivePRS/core' license 'AGPL-3.0-or-later' -version '1.0.0' +version '0.9.0-ptb.1' + +lua54 'yes' server_scripts { + 'scripts/http.lua', 'server/FivePRS.Server.net.dll', } ui_page 'nui/index.html' client_scripts { + 'scripts/relay.lua', 'client/FivePRS.Client.net.dll', 'client/FivePRS.Police.net.dll', 'plugins/*.net.dll', 'callouts/*.net.dll', + 'apps/*.net.dll', } files { 'nui/index.html', 'nui/style.css', + 'nui/dui-image.html', + 'nui/common.js', + 'nui/duty.js', + 'nui/dispatch.js', + 'nui/civilian.js', + 'nui/records.js', + 'nui/emergency.js', + 'nui/admin.js', + 'nui/appview.js', + 'nui/app-sdk.js', + 'nui/menu.js', + 'nui/creator.js', 'nui/app.js', 'client/FivePRS.Core.dll', 'client/Newtonsoft.Json.dll', 'plugins/*.dll', 'callouts/*.dll', + 'apps/**/*', 'config/*.json', } diff --git a/nui/admin.js b/nui/admin.js new file mode 100644 index 0000000..65e9f06 --- /dev/null +++ b/nui/admin.js @@ -0,0 +1,67 @@ +const adminTab = document.getElementById("tab-admin"); +const adminSearch = document.getElementById("admin-search"); +const adminQuery = document.getElementById("admin-query"); + +const departmentNames = ["None", "Police", "EMS", "Fire"]; + +function shortLicense(license) { + const id = license.replace(/^license:/, ""); + return id.length > 12 ? `${id.slice(0, 6)}…${id.slice(-4)}` : id; +} + +function adminRow(player, departments) { + const toggles = departments.map((department) => { + const granted = player.departments.includes(department.id); + return el("button", { + className: `button small${granted ? " active" : ""}`, + text: department.name, + dataset: { license: player.license, department: department.id, granted: String(!granted) }, + }); + }); + + return el("tr", {}, [ + el("td", {}, [ + el("span", { text: player.name }), + el("span", { + className: "sub mono muted", + text: player.online ? `ID ${player.serverId} · ${shortLicense(player.license)}` : shortLicense(player.license), + }), + ]), + el("td", { className: "mono", text: player.rank }), + el("td", { className: "muted", text: departmentNames[player.department] ?? "None" }), + el("td", {}, [el("div", { className: "actions admin-toggles" }, toggles)]), + ]); +} + +function renderAdmin(view, reset) { + if (!view) return; + + const state = view.state; + document.getElementById("admin-unrestricted").hidden = state.restrictDepartments; + + document.getElementById("admin-online").replaceChildren(...state.online.map((player) => adminRow(player, view.departments))); + setText("admin-online-count", `${state.online.length} online`); + + const searched = Boolean(state.query && state.query.trim().length >= 2); + document.getElementById("admin-results").replaceChildren(...state.results.map((player) => adminRow(player, view.departments))); + document.getElementById("admin-results-empty").hidden = !searched || state.results.length > 0; + + if (reset) adminQuery.value = state.query ?? ""; +} + +adminSearch.addEventListener("submit", (event) => { + event.preventDefault(); + post("adminSearch", { query: adminQuery.value.trim() }); +}); + +adminTab.addEventListener("click", (event) => { + const target = event.target.closest("button"); + if (!target || target.disabled || !target.dataset.license) return; + + target.disabled = true; + post("adminRoster", { + license: target.dataset.license, + departmentId: Number(target.dataset.department), + granted: target.dataset.granted === "true", + }); +}); diff --git a/nui/app-sdk.js b/nui/app-sdk.js new file mode 100644 index 0000000..08d9497 --- /dev/null +++ b/nui/app-sdk.js @@ -0,0 +1,34 @@ +(() => { + const listeners = {}; + const app = new URLSearchParams(location.search).get("app") ?? ""; + + const send = (message) => parent.postMessage({ fiveprs: true, app, ...message }, "*"); + + window.FivePRS = { + app, + context: null, + on(type, handler) { + (listeners[type] ??= []).push(handler); + if (type === "context" && window.FivePRS.context !== null) handler(window.FivePRS.context); + }, + action(name, data = {}) { + send({ kind: "action", action: name, data }); + }, + home() { + send({ kind: "home" }); + }, + }; + + window.addEventListener("message", (event) => { + const message = event.data; + if (!message || message.fiveprs !== true || message.kind !== "event") return; + if (message.type === "context") window.FivePRS.context = message.payload; + for (const handler of listeners[message.type] ?? []) handler(message.payload); + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") window.FivePRS.home(); + }); + + send({ kind: "ready" }); +})(); diff --git a/nui/app.js b/nui/app.js index 4fe2ed6..cdf9d62 100644 --- a/nui/app.js +++ b/nui/app.js @@ -1,157 +1,248 @@ -const resource = typeof GetParentResourceName === "function" ? GetParentResourceName() : "fiveprs"; - -const statusLabels = { - Available: "Available", - EnRoute: "En route", - OnScene: "On scene", - Busy: "Busy", +const app = document.getElementById("app"); +const home = document.getElementById("home"); +const appGrid = document.getElementById("app-grid"); +const appWindow = document.getElementById("app-window"); +const wallpaper = document.getElementById("wallpaper"); +const extFrame = document.getElementById("ext-frame"); + +const appIcons = { + duty: "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", + dispatch: "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", + records: "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", + characters: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M4 21a8 8 0 0 1 16 0", + licenses: "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", + vehicles: "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", + emergency: "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", + admin: "M4 21v-7 M4 10V3 M12 21v-9 M12 8V3 M20 21v-5 M20 12V3 M1 14h6 M9 8h6 M17 16h6", }; -const root = document.getElementById("mdt"); -let view = null; +const appCatalog = [ + { id: "duty", tab: "duty", label: "Duty", color: "#335cff" }, + { id: "dispatch", tab: "dispatch", label: "Dispatch", color: "#e81010" }, + { id: "records", tab: "records", label: "Records", color: "#4f46e5" }, + { id: "characters", tab: "civilian", mode: "characters", label: "Characters", color: "#0d9488" }, + { id: "licenses", tab: "civilian", mode: "licenses", label: "Licenses", color: "#d97706" }, + { id: "vehicles", tab: "civilian", mode: "vehicles", label: "Vehicles", color: "#475569" }, + { id: "emergency", tab: "emergency", label: "911", color: "#b91c1c" }, + { id: "admin", tab: "admin", label: "Admin", color: "#7c3aed" }, +].map((entry, index) => ({ ...entry, order: index * 10 })); + +let appState = null; +let activeApp = null; + +function iconFor(entry) { + const icon = entry.external ? entry.icon ?? "" : appIcons[entry.id] ?? ""; + if (/^(https?|nui):\/\//.test(icon)) return iconImage(icon, "app-image"); + + 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", icon); + svg.append(path); + return svg; +} -function post(name, data = {}) { - return fetch(`https://${resource}/${name}`, { - method: "POST", - headers: { "Content-Type": "application/json; charset=UTF-8" }, - body: JSON.stringify(data), - }).catch(() => {}); +function availableApps(state) { + const builtIn = appCatalog.filter((entry) => state.tabs.includes(entry.tab)); + const external = (state.apps ?? []).map((entry) => ({ ...entry, tab: "ext", external: true })); + return [...builtIn, ...external].sort((a, b) => a.order - b.order); } -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 findApp(id) { + return appState ? availableApps(appState).find((entry) => entry.id === id) ?? null : null; } -function statusPill(status) { - return el("span", { className: `status status-${status.toLowerCase()}`, text: statusLabels[status] ?? status }); +function badgeFor(entry, state) { + if (entry.external) return entry.badge ?? null; + if (entry.id === "dispatch" && state.dispatch?.offer) return "!"; + if (entry.id === "dispatch" && state.dispatch?.activeCall) return "1"; + if (entry.id === "emergency" && state.emergency?.status) return "•"; + return null; } -function setText(id, value) { - document.getElementById(id).textContent = value ?? "—"; +function renderHome(state) { + appGrid.replaceChildren(...availableApps(state).map((entry) => { + const badge = badgeFor(entry, state); + const tile = el("button", { className: "app-tile", dataset: { app: entry.id } }, [ + el("span", { className: "app-icon" }, [iconFor(entry)]), + el("span", { className: "app-label", text: entry.label }), + badge ? el("span", { className: "app-badge", text: badge }) : null, + ]); + tile.type = "button"; + tile.querySelector(".app-icon").style.setProperty("--tile", entry.color); + return tile; + })); } -function renderSelf(self) { - setText("self-callsign", self.callsign); - setText("self-name", self.name); - setText("self-agency", self.agency); - setText("self-territory", self.territory ?? "Unincorporated"); - setText("self-call", self.callId ? `#${self.callId}` : "None"); +function renderStatusBar(state) { + const unit = state.dispatch?.self; + const civilian = state.civilian?.state; + const character = civilian?.characters?.find((entry) => entry.id === civilian.activeCharacterId); - const status = document.getElementById("self-status"); - status.replaceChildren(statusPill(self.status)); + setText("sb-identity", unit + ? `${unit.callsign} · ${unit.agency}` + : character ? `${character.firstName} ${character.lastName}` : state.duty?.name ?? ""); - const leadsCall = (view.calls ?? []).some((call) => call.isPrimary); - for (const button of document.querySelectorAll("[data-status]")) { - button.classList.toggle("active", button.dataset.status === self.status); - button.disabled = leadsCall; + const status = document.getElementById("sb-status"); + status.hidden = !unit; + if (unit) { + status.textContent = unit.status.replace(/([a-z])([A-Z])/g, "$1 $2"); + status.dataset.status = unit.status; } - document.getElementById("status-hint").textContent = leadsCall - ? "End your active callout to change status." - : "En route and on scene update automatically from your position."; } -function renderCalls(calls, self) { - const body = document.getElementById("calls"); - const rows = calls.map((call) => { - const mine = self.callId === call.id; - const canAttach = !self.callId; - - const actions = el("div", { className: "actions" }, [ - call.hasLocation - ? el("button", { className: "button small", text: "Waypoint", dataset: { action: "waypoint", call: call.id } }) - : null, - mine - ? null - : el("button", { - className: "button small", - text: "Attach", - dataset: { action: "attach", call: call.id }, - disabled: !canAttach, - }), - ]); +function renderWallpaper(terminal) { + const url = terminal?.wallpaper ?? ""; + if (wallpaper.getAttribute("src") === url) return; + wallpaper.hidden = !url; + if (url) wallpaper.src = url; +} - const row = el("tr", { className: mine ? "mine" : "" }, [ - el("td", { className: "mono", text: `#${call.id}` }), - el("td", {}, [ - el("span", { text: call.name }), - el("span", { className: `sub code-${call.code}`, text: `Code ${call.code}` }), - ]), - el("td", { className: "muted", text: call.territory ?? "—" }), - el("td", {}, [el("div", { className: "chips" }, call.units.map((unit) => el("span", { className: "chip", text: unit })))]), - el("td", {}, [actions]), - ]); - return row; - }); +function selectTab(tab) { + for (const panel of app.querySelectorAll(".tab")) panel.hidden = panel.id !== `tab-${tab}`; +} + +function pageUrl(entry) { + return `${entry.page}${entry.page.includes("?") ? "&" : "?"}app=${encodeURIComponent(entry.id)}`; +} + +function sendToFrame(type, payload) { + extFrame.contentWindow?.postMessage({ fiveprs: true, kind: "event", type, payload }, "*"); +} + +function leaveExternalApp() { + if (!findApp(activeApp)?.external && !extFrame.getAttribute("src")) return; + extFrame.removeAttribute("src"); + extFrame.hidden = true; + extView.replaceChildren(); + post("appOpen", { id: "" }); +} + +function openApp(id) { + const entry = findApp(id); + if (!entry) return; - body.replaceChildren(...rows); - document.getElementById("calls-empty").hidden = calls.length > 0; - setText("call-count", `${calls.length} active`); + leaveExternalApp(); + activeApp = entry.id; + home.hidden = true; + appWindow.hidden = false; + setText("app-title", entry.label); + + const icon = document.getElementById("app-icon"); + icon.replaceChildren(iconFor(entry)); + icon.style.setProperty("--tile", entry.color); + + document.getElementById("tab-civilian").dataset.mode = entry.mode ?? ""; + selectTab(entry.tab); + + if (!entry.external) return; + + extView.hidden = Boolean(entry.page); + extFrame.hidden = !entry.page; + if (entry.page) extFrame.src = pageUrl(entry); + post("appOpen", { id: entry.id }); } -function renderUnits(units, self) { - const body = document.getElementById("units"); - const rows = units.map((unit) => - el("tr", { className: unit.serverId === self.serverId ? "mine" : "" }, [ - el("td", { className: "mono", text: unit.callsign }), - el("td", {}, [ - el("span", { text: unit.name }), - el("span", { className: "sub muted", text: unit.agency }), - ]), - el("td", { className: "muted", text: unit.territory ?? "—" }), - el("td", {}, [statusPill(unit.status), unit.callId ? el("span", { className: "sub mono muted", text: `#${unit.callId}` }) : null]), - ]) - ); - - body.replaceChildren(...rows); - setText("unit-count", `${units.length} on duty`); +function goHome() { + leaveExternalApp(); + activeApp = null; + appWindow.hidden = true; + home.hidden = false; } -function render() { - if (!view || !view.self) return; - renderSelf(view.self); - renderCalls(view.calls ?? [], view.self); - renderUnits(view.units ?? [], view.self); +function renderApp(state, opening) { + appState = state; + const logo = document.getElementById("brand-logo"); + if (state.nameplate && logo.getAttribute("src") !== state.nameplate) logo.src = state.nameplate; + setDepartmentIcons(state.icons); + renderWallpaper(state.terminal); + renderStatusBar(state); + renderHome(state); + + if (opening) { + const requested = appCatalog.find((entry) => entry.tab === state.defaultTab); + if (requested) openApp(requested.id); + else goHome(); + } else if (activeApp && !availableApps(state).some((entry) => entry.id === activeApp)) { + goHome(); + } + + renderDuty(state.duty, opening); + renderDispatch(state.dispatch); + renderRecords(state.records, opening); + renderCivilian(state.civilian, opening); + renderEmergency(state.emergency, opening); + renderAdmin(state.admin, opening); + + const active = findApp(activeApp); + if (active?.external && !active.page && state.appView?.app === active.id) renderAppView(state.appView); + if (active?.external && active.page) sendToFrame("context", state.context); } function tickClock() { - const now = new Date(); - document.getElementById("clock").textContent = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + document.getElementById("clock").textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } +screens.app = { + open(state) { + app.hidden = false; + tickClock(); + renderApp(state, true); + }, + update(state) { + if (!app.hidden) renderApp(state, false); + }, + rejected(message) { + showDutyError(message); + }, + recordsError(message) { + showRecordsError(message); + if (recordsView) renderRecords(recordsView, false); + }, + emergencyError(message) { + showEmergencyError(message); + }, + civilianError(message) { + showCivilianError(message); + if (civilianView) renderCivilian(civilianView, false); + }, + appMessage(message) { + if (message.app === activeApp) sendToFrame(message.type, message.payload); + }, + close() { + leaveExternalApp(); + app.hidden = true; + stopOfferTimer(); + }, +}; + window.addEventListener("message", (event) => { - const { type, payload } = event.data ?? {}; + const message = event.data; + if (event.source !== extFrame.contentWindow || !message || message.fiveprs !== true) return; - if (type === "open") { - view = payload; - root.hidden = false; - tickClock(); - render(); - } else if (type === "update") { - view = payload; - render(); - } else if (type === "close") { - root.hidden = true; - } + if (message.kind === "ready") sendToFrame("context", appState?.context ?? null); + else if (message.kind === "home") goHome(); + else if (message.kind === "action") appAction(activeApp, message.action, message.data); }); -document.addEventListener("click", (event) => { - const target = event.target.closest("button"); - if (!target || target.disabled) return; +wallpaper.addEventListener("error", () => { + wallpaper.hidden = true; +}); - if (target.id === "close") post("close"); - else if (target.dataset.status) post("setStatus", { status: target.dataset.status }); - else if (target.dataset.action === "attach") post("attach", { callId: target.dataset.call }); - else if (target.dataset.action === "waypoint") post("waypoint", { callId: target.dataset.call }); +appGrid.addEventListener("click", (event) => { + const tile = event.target.closest("[data-app]"); + if (tile) openApp(tile.dataset.app); }); +document.getElementById("home-button").addEventListener("click", goHome); +document.getElementById("home-key").addEventListener("click", goHome); +document.getElementById("close").addEventListener("click", () => post("appClose")); + document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && !root.hidden) post("close"); + if (event.key !== "Escape" || app.hidden) return; + if (activeApp) goHome(); + else post("appClose"); }); setInterval(tickClock, 15000); diff --git a/nui/appview.js b/nui/appview.js new file mode 100644 index 0000000..072b88a --- /dev/null +++ b/nui/appview.js @@ -0,0 +1,81 @@ +const extView = document.getElementById("ext-view"); + +function appAction(app, action, data) { + if (action) post("appAction", { app, action, data: data ?? {} }); +} + +function appItem(app, item) { + const image = item.image ? iconImage(item.image, "av-image") : null; + const button = el("button", { className: `av-item${item.active ? " active" : ""}` }, [ + image, + el("span", { className: "av-body" }, [ + el("span", { className: "av-title", text: item.title }), + item.detail ? el("span", { className: "av-detail", text: item.detail }) : null, + ]), + ]); + button.type = "button"; + button.disabled = Boolean(item.disabled) || !item.action; + button.addEventListener("click", () => appAction(app, item.action, item.data)); + return button; +} + +function appButton(app, item) { + const classes = ["button"]; + if (item.active) classes.push("active"); + if (item.style === "primary" || item.style === "danger") classes.push(item.style); + + const button = el("button", { className: classes.join(" "), text: item.title }); + button.type = "button"; + button.disabled = Boolean(item.disabled) || !item.action; + button.addEventListener("click", () => appAction(app, item.action, item.data)); + return button; +} + +function appForm(app, block) { + const form = el("form", { className: "av-form" }, block.fields.map((field) => { + const input = el("input"); + input.name = field.name; + input.type = field.type || "text"; + input.placeholder = field.placeholder ?? ""; + input.value = field.value ?? ""; + input.autocomplete = "off"; + input.spellcheck = false; + return el("label", { className: "av-field" }, [el("span", { text: field.label }), input]); + })); + + const submit = el("button", { className: "button primary", text: block.submit || "Save" }); + submit.type = "submit"; + form.append(submit); + + form.addEventListener("submit", (event) => { + event.preventDefault(); + appAction(app, block.action, Object.fromEntries(new FormData(form).entries())); + }); + return form; +} + +function appBlock(app, block) { + switch (block.type) { + case "section": + return el("p", { className: "panel-title av-section", text: block.title }); + case "text": + return el("p", { className: `av-text${block.muted ? " muted" : ""}`, text: block.text }); + case "buttons": + return el("div", { className: "av-buttons" }, block.items.map((item) => appButton(app, item))); + case "list": { + const grid = block.layout === "grid"; + return el("div", { className: `av-list${grid ? " grid" : ""}` }, block.items.map((item) => appItem(app, item))); + } + case "form": + return appForm(app, block); + default: + return null; + } +} + +function renderAppView(view) { + const focused = extView.contains(document.activeElement) && document.activeElement.tagName === "INPUT"; + if (focused) return; + + extView.replaceChildren(...(view?.screen?.blocks ?? []).map((block) => appBlock(view.app, block)).filter(Boolean)); +} diff --git a/nui/civilian.js b/nui/civilian.js new file mode 100644 index 0000000..9018a66 --- /dev/null +++ b/nui/civilian.js @@ -0,0 +1,236 @@ +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" : ""; + 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 new file mode 100644 index 0000000..0e0397c --- /dev/null +++ b/nui/common.js @@ -0,0 +1,65 @@ +const resource = typeof GetParentResourceName === "function" ? GetParentResourceName() : "fiveprs"; + +function post(name, data = {}) { + return fetch(`https://${resource}/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=UTF-8" }, + body: JSON.stringify(data), + }).catch(() => {}); +} + +function el(tag, options = {}, children = []) { + const node = document.createElement(tag); + if (options.className) node.className = options.className; + if (options.text !== undefined && options.text !== null) node.textContent = String(options.text); + if (options.dataset) Object.assign(node.dataset, options.dataset); + if (options.disabled) node.disabled = true; + for (const child of children) if (child) node.append(child); + return node; +} + +function setText(id, value) { + document.getElementById(id).textContent = value ?? "—"; +} + +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) => { + const { screen, type, payload } = event.data ?? {}; + screens[screen]?.[type]?.(payload); +}); + +screens.clipboard = { + copy(text) { + const area = document.createElement("textarea"); + area.value = text; + document.body.append(area); + area.select(); + document.execCommand("copy"); + area.remove(); + }, +}; diff --git a/nui/creator.js b/nui/creator.js new file mode 100644 index 0000000..909019b --- /dev/null +++ b/nui/creator.js @@ -0,0 +1,286 @@ +const creatorRoot = document.getElementById("creator"); +const creatorBody = document.getElementById("creator-body"); +const creatorTabs = document.getElementById("creator-tabs"); + +const creatorTabLabels = { model: "Model", heritage: "Heritage", face: "Face", details: "Hair & details", clothing: "Clothing" }; +const creatorTabFocus = { model: "body", heritage: "face", face: "face", details: "face", clothing: "body" }; +const hairSlot = 2; +const faceSlot = 0; + +let creatorAppearance = null; +let creatorOptions = null; +let creatorTab = "model"; +let creatorControls = []; +let creatorTimer = null; + +function creatorTabsFor() { + return creatorOptions.freemode ? ["model", "heritage", "face", "details", "clothing"] : ["model", "clothing"]; +} + +function itemFor(list, slot, fallback) { + let item = list.find((entry) => entry.slot === slot); + if (!item) { + item = { slot, drawable: fallback, texture: fallback }; + list.push(item); + } + return item; +} + +function overlayFor(index) { + let overlay = creatorAppearance.overlays.find((entry) => entry.index === index); + if (!overlay) { + overlay = { index, style: -1, opacity: 1, color: 0, secondColor: 0 }; + creatorAppearance.overlays.push(overlay); + } + return overlay; +} + +function queueCreatorUpdate() { + clearTimeout(creatorTimer); + creatorTimer = setTimeout(() => post("creatorUpdate", { appearance: JSON.stringify(creatorAppearance) }), 80); +} + +function rangeControl({ label, min, max, step = 1, get, set, format }) { + const value = el("span", { className: "creator-value" }); + const input = el("input", { className: "creator-range" }); + input.type = "range"; + input.min = String(min); + input.step = String(step); + + const control = { input, value, max, format: format ?? ((v) => String(v)) }; + + const apply = (next) => { + const limit = Number(input.max); + const clamped = Math.min(limit, Math.max(min, next)); + set(clamped); + input.value = String(clamped); + value.textContent = control.format(clamped); + queueCreatorUpdate(); + }; + + input.addEventListener("input", () => apply(Number(input.value))); + + const stepper = (direction, text) => { + const button = el("button", { className: "button small creator-step", text }); + button.type = "button"; + button.addEventListener("click", () => apply(Number(input.value) + direction * step)); + return button; + }; + + control.refresh = () => { + const limit = typeof max === "function" ? max(creatorOptions) : max; + input.max = String(Math.max(min, limit)); + const current = Math.min(Number(input.max), Math.max(min, get())); + input.value = String(current); + value.textContent = control.format(current); + const disabled = Number(input.max) <= min; + input.disabled = disabled; + }; + + creatorControls.push(control); + control.refresh(); + + return el("div", { className: "creator-row" }, [ + el("div", { className: "creator-label" }, [el("span", { text: label }), value]), + el("div", { className: "creator-input" }, [stepper(-1, "‹"), input, stepper(1, "›")]), + ]); +} + +function section(title, children) { + return el("section", { className: "creator-section" }, [el("p", { className: "panel-title", text: title }), ...children]); +} + +function percent(value) { + return `${Math.round(value * 100)}%`; +} + +function renderModelTab() { + const options = creatorOptions.models.map((model) => { + const button = el("button", { + className: `option${model.model === creatorAppearance.model ? " selected" : ""}`, + dataset: { model: model.model }, + }, [el("span", { className: "option-name", text: model.label })]); + button.type = "button"; + return button; + }); + + const custom = options.slice(0, 2); + const standard = options.slice(2); + return [ + section("Custom character", [el("div", { className: "option-row" }, custom)]), + standard.length ? section("Standard GTA ped", [el("div", { className: "option-grid creator-peds" }, standard)]) : null, + ]; +} + +function renderHeritageTab() { + const blend = creatorAppearance.headBlend; + const parentName = (index) => creatorOptions.parents[index] ?? String(index); + + return [ + section("Parents", [ + rangeControl({ + label: "Mother", min: 0, max: creatorOptions.parents.length - 1, get: () => blend.shapeFirst, + set: (v) => { blend.shapeFirst = v; blend.skinFirst = v; }, format: parentName, + }), + rangeControl({ + label: "Father", min: 0, max: creatorOptions.parents.length - 1, get: () => blend.shapeSecond, + set: (v) => { blend.shapeSecond = v; blend.skinSecond = v; }, format: parentName, + }), + ]), + section("Blend", [ + rangeControl({ label: "Resemblance", min: 0, max: 1, step: 0.01, get: () => blend.shapeMix, set: (v) => { blend.shapeMix = v; }, format: percent }), + rangeControl({ label: "Skin tone", min: 0, max: 1, step: 0.01, get: () => blend.skinMix, set: (v) => { blend.skinMix = v; }, format: percent }), + ]), + ]; +} + +function renderFaceTab() { + while (creatorAppearance.faceFeatures.length < creatorOptions.faceFeatures.length) creatorAppearance.faceFeatures.push(0); + + return [ + section("Features", creatorOptions.faceFeatures.map((name, index) => + rangeControl({ + label: name, min: -1, max: 1, step: 0.05, + get: () => creatorAppearance.faceFeatures[index], + set: (v) => { creatorAppearance.faceFeatures[index] = v; }, + format: (v) => v.toFixed(2), + }) + )), + ]; +} + +function componentMeta(slot) { + return (options) => options.components.find((entry) => entry.slot === slot); +} + +function propMeta(slot) { + return (options) => options.props.find((entry) => entry.slot === slot); +} + +function clothingControls(name, meta, item, minimum) { + return [ + rangeControl({ + label: name, min: minimum, max: (options) => meta(options).drawables - 1, + get: () => item.drawable, set: (v) => { item.drawable = v; item.texture = minimum < 0 && v < 0 ? -1 : 0; }, + format: (v) => (v < 0 ? "None" : String(v)), + }), + rangeControl({ + label: `${name} variant`, min: 0, max: (options) => meta(options).textures - 1, + get: () => Math.max(0, item.texture), set: (v) => { item.texture = v; }, + }), + ]; +} + +function renderDetailsTab() { + const hair = itemFor(creatorAppearance.components, hairSlot, 0); + const colorMax = creatorOptions.hairColors - 1; + + const overlays = creatorOptions.overlays.map((meta) => { + const overlay = overlayFor(meta.index); + const controls = [ + rangeControl({ + label: "Style", min: -1, max: (options) => options.overlays[meta.index].count - 1, + get: () => overlay.style, set: (v) => { overlay.style = v; }, + format: (v) => (v < 0 ? "None" : String(v)), + }), + rangeControl({ label: "Opacity", min: 0, max: 1, step: 0.05, get: () => overlay.opacity, set: (v) => { overlay.opacity = v; }, format: percent }), + ]; + if (meta.colorType) { + controls.push(rangeControl({ label: "Colour", min: 0, max: colorMax, get: () => overlay.color, set: (v) => { overlay.color = v; } })); + } + return section(meta.name, controls); + }); + + return [ + section("Hair", [ + ...clothingControls("Style", componentMeta(hairSlot), hair, 0), + rangeControl({ label: "Colour", min: 0, max: colorMax, get: () => creatorAppearance.hairColor, set: (v) => { creatorAppearance.hairColor = v; } }), + rangeControl({ label: "Highlight", min: 0, max: colorMax, get: () => creatorAppearance.hairHighlight, set: (v) => { creatorAppearance.hairHighlight = v; } }), + ]), + section("Eyes", [ + rangeControl({ label: "Eye colour", min: 0, max: creatorOptions.eyeColors - 1, get: () => creatorAppearance.eyeColor, set: (v) => { creatorAppearance.eyeColor = v; } }), + ]), + ...overlays, + ]; +} + +function renderClothingTab() { + const skipped = creatorOptions.freemode ? [faceSlot, hairSlot] : []; + const components = creatorOptions.components + .filter((meta) => !skipped.includes(meta.slot) && meta.drawables > 0) + .map((meta) => section(meta.name, clothingControls("Style", componentMeta(meta.slot), itemFor(creatorAppearance.components, meta.slot, 0), 0))); + const props = creatorOptions.props + .filter((meta) => meta.drawables > 0) + .map((meta) => section(meta.name, clothingControls("Style", propMeta(meta.slot), itemFor(creatorAppearance.props, meta.slot, -1), -1))); + + return [...components, ...props]; +} + +function renderCreator() { + const tabs = creatorTabsFor(); + if (!tabs.includes(creatorTab)) creatorTab = tabs[0]; + + creatorTabs.replaceChildren(...tabs.map((tab) => { + const button = el("button", { className: `tab-button${tab === creatorTab ? " active" : ""}`, text: creatorTabLabels[tab], dataset: { creatorTab: tab } }); + button.type = "button"; + return button; + })); + + creatorControls = []; + const builders = { model: renderModelTab, heritage: renderHeritageTab, face: renderFaceTab, details: renderDetailsTab, clothing: renderClothingTab }; + creatorBody.replaceChildren(...builders[creatorTab]().filter(Boolean)); + creatorBody.scrollTop = 0; +} + +function setCreatorTab(tab) { + creatorTab = tab; + renderCreator(); + post("creatorCamera", { focus: creatorTabFocus[tab] ?? "body" }); +} + +creatorRoot.addEventListener("click", (event) => { + const target = event.target.closest("button"); + if (!target || target.disabled) return; + + if (target.dataset.creatorTab) { + setCreatorTab(target.dataset.creatorTab); + } else if (target.dataset.model) { + if (target.dataset.model === creatorAppearance.model) return; + creatorAppearance.model = target.dataset.model; + post("creatorUpdate", { appearance: JSON.stringify(creatorAppearance) }); + } else if (target.dataset.focus) { + post("creatorCamera", { focus: target.dataset.focus }); + } else if (target.dataset.rotate) { + post("creatorRotate", { delta: Number(target.dataset.rotate) }); + } else if (target.id === "creator-save") { + clearTimeout(creatorTimer); + post("creatorUpdate", { appearance: JSON.stringify(creatorAppearance) }).then(() => post("creatorSave")); + } else if (target.id === "creator-cancel") { + clearTimeout(creatorTimer); + post("creatorCancel"); + } +}); + +screens.creator = { + open(payload) { + creatorAppearance = payload.appearance; + creatorOptions = payload.options; + creatorTab = "model"; + setText("creator-name", payload.name); + creatorRoot.hidden = false; + renderCreator(); + }, + reset(payload) { + creatorAppearance = payload.appearance; + creatorOptions = payload.options; + renderCreator(); + }, + options(options) { + creatorOptions = options; + for (const control of creatorControls) control.refresh(); + }, + close() { + clearTimeout(creatorTimer); + creatorRoot.hidden = true; + }, +}; diff --git a/nui/dispatch.js b/nui/dispatch.js new file mode 100644 index 0000000..ac9c20b --- /dev/null +++ b/nui/dispatch.js @@ -0,0 +1,162 @@ +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/dui-image.html b/nui/dui-image.html new file mode 100644 index 0000000..9c7c042 --- /dev/null +++ b/nui/dui-image.html @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/nui/duty.js b/nui/duty.js new file mode 100644 index 0000000..925aad7 --- /dev/null +++ b/nui/duty.js @@ -0,0 +1,184 @@ +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 new file mode 100644 index 0000000..db98acd --- /dev/null +++ b/nui/emergency.js @@ -0,0 +1,89 @@ +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/index.html b/nui/index.html index fa8f320..23d27d8 100644 --- a/nui/index.html +++ b/nui/index.html @@ -2,77 +2,495 @@ - FivePRS MDT + FivePRS -