Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Only the latest release on the `main` branch receives security fixes. Older tags
Report discovered vulnerabilities privately by emailing:

```
fiveprsteam@gmail.com
hey@codemeapixel.dev
```

Include as much of the following as possible:
Expand Down
33 changes: 31 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

## [0.9.0-ptb.3] - 2026-10-10

### Added
- Fire & Rescue (LSFD): go on duty at fire stations with turnout gear, an extinguisher and a fire engine
- Fire callouts: Vehicle Fire and Brush Fire, with real fires that spread until they're put out
- EMS (SAMS): go on duty at hospitals with a medic uniform and an ambulance
- EMS callouts: Medical Emergency and Traffic Collision, where you treat the patient, load them into the ambulance and transport them to the nearest hospital
- Death and revive: downed players wait for EMS, who get an automatic 911 call and can revive them for XP; after a timer, players can respawn at a hospital. `fprs_revive` for admins; settings under `death`
- `config/fire_loadouts.json`, `fire_vehicles.json`, `ems_loadouts.json` and `ems_vehicles.json` configure each department's equipment and vehicles
- Traffic stops: pull over any AI-driven vehicle with your lights on, then ask for ID, run the plate, search, warn, cite, serve warrants, arrest or release
- AI drivers and their vehicles get generated identities that are saved to Records the first time they're checked
- `config/citations.json` sets the offences and fines offered during a traffic stop
- The server publishes its FivePRS version as the `fiveprs_version` server variable, visible in the server list and `info.json`
- Wanted levels and GTA's own AI police, ambulance and fire response are turned off by default, configurable under `world` in `config/settings.json`, with an option to stop ambient AI police spawning
- Pedestrian stops: face anyone on foot and press `E` to stop them, with the same ID, search, citation and arrest actions

### Fixed
- FivePRS no longer fails after the database starts (`Argument at index 0 was null`): it reads its resource name once at startup, and database calls hand control back to the server's main thread
- Setting `fiveprs_db_connection` to a MySQL connection string while `fiveprs_db_type` is `sqlite` (or the reverse, an empty connection with `mysql`) now explains the mismatch
- MySQL and MariaDB connections no longer fail with `NotImplementedException` when the database offers TLS; FivePRS connects without TLS, which FiveM's runtime can't do
- A resource folder the server can't write to now logs a clear message instead of an unhandled exception
- FivePRS now starts on GTA V Enhanced servers, which reject assemblies that load native code. Enhanced servers need a MySQL or MariaDB database, since SQLite needs a native library

### Changed
- Police, Fire and EMS share one department module base; police gear and vehicle config types are now `DepartmentLoadoutsConfig` and `DepartmentVehiclesConfig`
- Server console errors are one line with the cause; set `fiveprs_debug true` for full details
- Common database problems (missing database, wrong login, no access, server unreachable) are explained in plain language with the fix

## [0.9.0-ptb.2] - 2026-10-10

### Added
Expand Down Expand Up @@ -48,10 +76,11 @@ The first public test build.
- 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
- Fire and EMS aren't available yet; police is the only department you can join
- 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.2...HEAD
[Unreleased]: https://github.com/FivePRS/core/compare/v0.9.0-ptb.3...HEAD
[0.9.0-ptb.3]: https://github.com/FivePRS/core/compare/v0.9.0-ptb.2...v0.9.0-ptb.3
[0.9.0-ptb.2]: https://github.com/FivePRS/core/compare/v0.9.0-ptb.1...v0.9.0-ptb.2
[0.9.0-ptb.1]: https://github.com/FivePRS/core/releases/tag/v0.9.0-ptb.1
170 changes: 170 additions & 0 deletions FivePRS.Client/Agency/DepartmentAgency.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System.Threading.Tasks;
using CitizenFX.Core;
using FivePRS.Client.Callouts;
using FivePRS.Client.Gear;
using FivePRS.Client.Loadout;
using FivePRS.Client.Stations;
using FivePRS.Client.VehicleSpawner;
using FivePRS.Core.Config;
using FivePRS.Core.Models;

namespace FivePRS.Client.Agency
{
public abstract class DepartmentAgency : BaseAgency
{
private readonly CalloutRegistry _registry;
private readonly CalloutDispatcher _dispatcher;

private StationDef? _arrivalStation;
private PatrolVehicleConfig? _arrivalVehicle;

protected DepartmentAgency()
{
_registry = new CalloutRegistry();
_registry.Discover(GetType().Assembly);

_dispatcher = new CalloutDispatcher(Department, _registry, OnCalloutEnded);
}

protected abstract DepartmentGear Gear { get; }

protected virtual string EquipmentNoun => "uniform, gear and vehicle";

public override StationCatalog? BuildCatalog(PlayerData player) => Gear.BuildCatalog(player.Agency);

public override async Task OnDuty(PlayerData player)
{
await base.OnDuty(player);

_registry.DiscoverAll();
Debug.WriteLine($"[{AgencyName}] {_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}");
}

public override async Task OffDuty(PlayerData player)
{
await base.OffDuty(player);

_dispatcher.Stop();
StopWaitingForArrival();
Vehicles.Despawn();
await LoadoutManager.StripAsync();

Notify($"~b~{DisplayName}~w~ | ~r~OFF DUTY~w~. Gear and vehicle removed.");
}

public override async Task OnCalloutReceived(CalloutData callout)
{
await _dispatcher.HandleOfferAsync(callout);
}

private static bool ShouldEquip(DutyStartMode mode) => ConfigManager.Settings.DutyEquipment switch
{
DutyEquipmentMode.Always => true,
DutyEquipmentMode.Station => false,
_ => mode == DutyStartMode.Here,
};

private async Task<string> EquipAsync(PlayerData player, DutyStartMode mode, StationDef? station)
{
if (mode == DutyStartMode.Teleport && station is not null)
await StationService.TeleportAsync(station);

var loadout = Gear.LoadoutForRank(player.Rank, player.Agency);
await LoadoutManager.ApplyAsync(loadout);

var vehicleConfig = Gear.VehicleForRank(player.Rank, player.Agency);
return $"{loadout.Name} loadout applied.~n~{await SpawnShiftVehicleAsync(player, mode, station, vehicleConfig)}";
}

private async Task<string> 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 {EquipmentNoun}.";
}

if (mode == DutyStartMode.Teleport && station is not null)
{
await StationService.TeleportAsync(station);
return $"Collect your {EquipmentNoun} at the station.";
}

return $"Visit one of your stations to collect your {EquipmentNoun}.";
}

private async Task<string> 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 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 vehicle is marked on the map."
: "~r~Vehicle spawn failed~w~ — proceed on foot.";

private void OnCalloutEnded(CalloutBase callout, CalloutResult result)
{
if (result == CalloutResult.Completed)
Notify("~g~CALLOUT COMPLETE~w~");
else if (result == CalloutResult.Failed)
Notify("~r~CALLOUT FAILED~w~ | No XP awarded.");

Debug.WriteLine($"[{AgencyName}] Callout '{callout.Data.Name}' ended: {result}");
}
}
}
1 change: 1 addition & 0 deletions FivePRS.Client/Binds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public static class Binds
public static string Cuff => For(KeyCommands.Cuff);
public static string Uncuff => For(KeyCommands.Uncuff);
public static string Escort => For(KeyCommands.Escort);
public static string TrafficStop => For(KeyCommands.TrafficStop);

public static string For(string command) =>
$"~INPUT_{(uint)API.GetHashKey(command) | 0x80000000:X8}~";
Expand Down
11 changes: 11 additions & 0 deletions FivePRS.Client/Callouts/CalloutBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ public virtual void OnCalloutDeclined() { }

public virtual void OnCalloutFailed() { }

protected virtual void OnCleanup() { }

public virtual Vector3 GetDispatchLocation() =>
new(Data.LocationX, Data.LocationY, Data.LocationZ);

Expand Down Expand Up @@ -174,6 +176,15 @@ protected async Task FootPursuitAsync(Ped suspect, float catchDistM, float escap

internal void Cleanup(bool releaseToWorld = false)
{
try
{
OnCleanup();
}
catch (Exception ex)
{
Debug.WriteLine($"[CalloutBase] Scene cleanup failed: {ex.Message}");
}

foreach (var entity in _trackedEntities)
{
try
Expand Down
10 changes: 8 additions & 2 deletions FivePRS.Client/ClientBrain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,16 @@ public ClientBrain()

var res = API.GetCurrentResourceName();
ConfigManager.LoadSettings( API.LoadResourceFile(res, "config/settings.json"));
ConfigManager.LoadPoliceVehicles( API.LoadResourceFile(res, "config/police_vehicles.json"));
ConfigManager.LoadPoliceLoadouts( API.LoadResourceFile(res, "config/police_loadouts.json"));
foreach (var department in ConfigManager.Departments)
{
var name = ConfigManager.ConfigName(department);
ConfigManager.LoadDepartment(department,
API.LoadResourceFile(res, $"config/{name}_loadouts.json"),
API.LoadResourceFile(res, $"config/{name}_vehicles.json"));
}
ConfigManager.LoadJurisdictions( API.LoadResourceFile(res, "config/jurisdictions.json"));
ConfigManager.LoadStations( API.LoadResourceFile(res, "config/stations.json"));
ConfigManager.LoadCitations( API.LoadResourceFile(res, "config/citations.json"));

EventHandlers[EventNames.ClientReceivePlayerData] += new Action<string>(OnReceivePlayerData);
EventHandlers[EventNames.ClientDutyStatusChanged] += new Action<bool, int>(OnDutyStatusChanged);
Expand Down
Loading
Loading