Skip to content
2 changes: 1 addition & 1 deletion [core]/es_extended/client/imports/point.lua
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function Point:constructor(properties)
if self.leave then
self:leave()
end
if #nearby == 0 then
if next(nearby) == nil then
loop = false
end
end)
Expand Down
14 changes: 4 additions & 10 deletions [core]/es_extended/client/modules/actions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ end
function Actions:SlowLoop()
CreateThread(function()
while ESX.PlayerLoaded do
-- TrackPed first so ESX.PlayerData.ped is fresh before the vehicle/weapon
-- trackers read it. The ped handle rarely changes (spawn/death set it via their
-- own events), so folding it here at 500ms removes a dedicated per-frame thread.
self:TrackPed()
self:TrackPauseMenu()
self:TrackVehicle()
self:TrackWeapon()
Expand All @@ -216,18 +220,8 @@ function Actions:SlowLoop()
end)
end

function Actions:PedLoop()
CreateThread(function()
while ESX.PlayerLoaded do
self:TrackPed()
Wait(0)
end
end)
end

function Actions:Init()
self:SlowLoop()
self:PedLoop()
self:TrackPedCoordsOnce()
end

Expand Down
19 changes: 13 additions & 6 deletions [core]/es_extended/client/modules/adjustments.lua
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ function Adjustments:HealthRegeneration()
end

function Adjustments:AmmoAndVehicleRewards()
-- Both natives only affect the current frame, so the per-frame loop is required when
-- either feature is on. When both are off, don't spawn an idle Wait(0) thread at all.
if not Config.DisableDisplayAmmo and not Config.DisableVehicleRewards then
return
end

CreateThread(function()
while true do
if Config.DisableDisplayAmmo then
Expand Down Expand Up @@ -222,14 +228,15 @@ function Adjustments:DisableRadio()
end

function Adjustments:Multipliers()
local multipliers = Config.Multipliers
CreateThread(function()
while true do
SetPedDensityMultiplierThisFrame(Config.Multipliers.pedDensity)
SetScenarioPedDensityMultiplierThisFrame(Config.Multipliers.scenarioPedDensityInterior, Config.Multipliers.scenarioPedDensityExterior)
SetAmbientVehicleRangeMultiplierThisFrame(Config.Multipliers.ambientVehicleRange)
SetParkedVehicleDensityMultiplierThisFrame(Config.Multipliers.parkedVehicleDensity)
SetRandomVehicleDensityMultiplierThisFrame(Config.Multipliers.randomVehicleDensity)
SetVehicleDensityMultiplierThisFrame(Config.Multipliers.vehicleDensity)
SetPedDensityMultiplierThisFrame(multipliers.pedDensity)
SetScenarioPedDensityMultiplierThisFrame(multipliers.scenarioPedDensityInterior, multipliers.scenarioPedDensityExterior)
SetAmbientVehicleRangeMultiplierThisFrame(multipliers.ambientVehicleRange)
SetParkedVehicleDensityMultiplierThisFrame(multipliers.parkedVehicleDensity)
SetRandomVehicleDensityMultiplierThisFrame(multipliers.randomVehicleDensity)
SetVehicleDensityMultiplierThisFrame(multipliers.vehicleDensity)
Wait(0)
end
end)
Expand Down
63 changes: 35 additions & 28 deletions [core]/es_extended/client/modules/events.lua
Original file line number Diff line number Diff line change
Expand Up @@ -371,41 +371,48 @@ if not Config.CustomInventory then
CreateThread(function()
while true do
local Sleep = 1500
local playerCoords = GetEntityCoords(ESX.PlayerData.ped)
local _, closestDistance = ESX.Game.GetClosestPlayer(playerCoords)

for pickupId, pickup in pairs(pickups) do
local distance = #(playerCoords - pickup.coords)

if distance < 5 then
Sleep = 0
local label = pickup.label

if distance < 1 then
if IsControlJustReleased(0, 38) then
if IsPedOnFoot(ESX.PlayerData.ped) and (closestDistance == -1 or closestDistance > 3) and not pickup.inRange then
pickup.inRange = true

local dict, anim = "weapons@first_person@aim_rng@generic@projectile@sticky_bomb@", "plant_floor"
ESX.Streaming.RequestAnimDict(dict)
TaskPlayAnim(ESX.PlayerData.ped, dict, anim, 8.0, 1.0, 1000, 16, 0.0, false, false, false)
RemoveAnimDict(dict)
Wait(1000)

TriggerServerEvent("esx:onPickup", pickupId)
PlaySoundFrontend(-1, "PICK_UP", "HUD_FRONTEND_DEFAULT_SOUNDSET", false)
-- Skip everything when there are no pickups, and only resolve the closest player
-- at the moment of an actual pickup attempt instead of enumerating every player
-- on every frame while standing near a pickup.
if next(pickups) then
local playerCoords = GetEntityCoords(ESX.PlayerData.ped)

for pickupId, pickup in pairs(pickups) do
local distance = #(playerCoords - pickup.coords)

if distance < 5 then
Sleep = 0
local label = pickup.label

if distance < 1 then
if IsControlJustReleased(0, 38) then
local _, closestDistance = ESX.Game.GetClosestPlayer(playerCoords)
if IsPedOnFoot(ESX.PlayerData.ped) and (closestDistance == -1 or closestDistance > 3) and not pickup.inRange then
pickup.inRange = true

local dict, anim = "weapons@first_person@aim_rng@generic@projectile@sticky_bomb@", "plant_floor"
ESX.Streaming.RequestAnimDict(dict)
TaskPlayAnim(ESX.PlayerData.ped, dict, anim, 8.0, 1.0, 1000, 16, 0.0, false, false, false)
RemoveAnimDict(dict)
Wait(1000)

TriggerServerEvent("esx:onPickup", pickupId)
PlaySoundFrontend(-1, "PICK_UP", "HUD_FRONTEND_DEFAULT_SOUNDSET", false)
end
end

label = ("%s~n~%s"):format(label, TranslateCap("threw_pickup_prompt"))
end

label = ("%s~n~%s"):format(label, TranslateCap("threw_pickup_prompt"))
local textCoords = pickup.coords + vector3(0.0, 0.0, 0.25)
ESX.Game.Utils.DrawText3D(textCoords, label, 1.2, 1)
elseif pickup.inRange then
pickup.inRange = false
end

local textCoords = pickup.coords + vector3(0.0, 0.0, 0.25)
ESX.Game.Utils.DrawText3D(textCoords, label, 1.2, 1)
elseif pickup.inRange then
pickup.inRange = false
end
end

Wait(Sleep)
end
end)
Expand Down
20 changes: 11 additions & 9 deletions [core]/es_extended/client/modules/points.lua
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,18 @@ end
function StartPointsLoop()
CreateThread(function()
while true do
local coords = GetEntityCoords(ESX.PlayerData.ped)
for handle, point in pairs(points) do
if not point.hidden and #(coords - point.coords) <= point.distance then
if not point.nearby then
points[handle].nearby = true
points[handle].enter()
if next(points) ~= nil then
local coords = GetEntityCoords(ESX.PlayerData.ped)
for handle, point in pairs(points) do
if not point.hidden and #(coords - point.coords) <= point.distance then
if not point.nearby then
points[handle].nearby = true
points[handle].enter()
end
elseif point.nearby then
points[handle].nearby = false
points[handle].leave()
end
elseif point.nearby then
points[handle].nearby = false
points[handle].leave()
end
end
Wait(500)
Expand Down
9 changes: 6 additions & 3 deletions [core]/es_extended/server/functions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,12 @@ end

local function updateHealthAndArmorInMetadata(xPlayer)
local ped = GetPlayerPed(xPlayer.source)
xPlayer.setMeta("health", GetEntityHealth(ped))
xPlayer.setMeta("armor", GetPedArmour(ped))
xPlayer.setMeta("lastPlaytime", xPlayer.getPlayTime())
-- Write directly to metadata instead of setMeta: this only runs on save, and the
-- client is the source of truth for health/armor (lastPlaytime is server-only), so
-- echoing the full metadata table back to the owning client here is pure waste.
xPlayer.metadata.health = GetEntityHealth(ped)
xPlayer.metadata.armor = GetPedArmour(ped)
xPlayer.metadata.lastPlaytime = xPlayer.getPlayTime()
end

---@param xPlayer table
Expand Down
102 changes: 88 additions & 14 deletions [core]/es_extended/server/main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ end

loadPlayer = loadPlayer .. " FROM `users` WHERE identifier = ?"

-- Tracks players whose load is in-flight so a re-triggered join event cannot start a second load.
local playersLoading = {}

local function createESXPlayer(identifier, playerId, data)
local accounts = {}

Expand Down Expand Up @@ -72,6 +75,8 @@ end
---@param reason string
---@param cb function?
local function onPlayerDropped(playerId, reason, cb)
playersLoading[playerId] = nil

local p = not cb and promise:new()
local function resolve()
if cb then
Expand Down Expand Up @@ -126,13 +131,20 @@ if Config.Multichar then
else
RegisterNetEvent("esx:onPlayerJoined", function()
local _source = source
if ESX.Players[_source] or playersLoading[_source] then
return
end
playersLoading[_source] = true

while not next(ESX.Jobs) do
Wait(50)
end

if not ESX.Players[_source] then
onPlayerJoined(_source)
end
-- Flag is cleared by loadESXPlayer on success (incl. the async new-player path) or by
-- onPlayerDropped on failure; clearing it here would reopen the race before the async load lands.
end)
end

Expand Down Expand Up @@ -318,6 +330,7 @@ function loadESXPlayer(identifier, playerId, isNew)
GlobalState["playerCount"] = GlobalState["playerCount"] + 1
ESX.Players[playerId] = xPlayer
Core.playersByIdentifier[identifier] = xPlayer
playersLoading[playerId] = nil

-- Identity
if result.firstname and result.firstname ~= "" then
Expand Down Expand Up @@ -423,6 +436,18 @@ if not Config.CustomInventory then
RegisterNetEvent("esx:updateWeaponAmmo", function(weaponName, ammoCount)
local xPlayer = ESX.GetPlayerFromId(source)

ammoCount = tonumber(ammoCount)
if not ammoCount then
return
end

ammoCount = math.floor(ammoCount)
if ammoCount < 0 then
ammoCount = 0
elseif ammoCount > 9999 then
ammoCount = 9999
end

if xPlayer then
xPlayer.updateWeaponAmmo(weaponName, ammoCount)
end
Expand All @@ -438,6 +463,15 @@ if not Config.CustomInventory then
return
end

-- itemCount is client-controlled; weapon transfers derive their own count from the loadout.
if itemType ~= "item_weapon" then
itemCount = tonumber(itemCount)
if not itemCount or itemCount < 1 then
return sourceXPlayer.showNotification(TranslateCap("imp_invalid_quantity"))
end
itemCount = math.floor(itemCount)
end

if itemType == "item_standard" then
local sourceItem = sourceXPlayer.getInventoryItem(itemName)

Expand All @@ -459,7 +493,12 @@ if not Config.CustomInventory then
sourceXPlayer.showNotification(TranslateCap("gave_item", itemCount, sourceItem.label, targetXPlayer.name))
targetXPlayer.showNotification(TranslateCap("received_item", itemCount, sourceItem.label, sourceXPlayer.name))
elseif itemType == "item_account" then
if itemCount < 1 or sourceXPlayer.getAccount(itemName).money < itemCount then
local sourceAccount = itemName and sourceXPlayer.getAccount(itemName)
if not sourceAccount or not Config.Accounts[itemName] then
return
end

if itemCount < 1 or sourceAccount.money < itemCount then
return sourceXPlayer.showNotification(TranslateCap("imp_invalid_amount"))
end

Expand Down Expand Up @@ -623,9 +662,12 @@ if not Config.CustomInventory then
return
end

local count = xPlayer.getInventoryItem(itemName).count
local item = xPlayer.getInventoryItem(itemName)
if not item then
return
end

if count < 1 then
if item.count < 1 then
return xPlayer.showNotification(TranslateCap("act_imp"))
end

Expand Down Expand Up @@ -705,22 +747,44 @@ ESX.RegisterServerCallback("esx:getGameBuild", function(_, cb)
cb(tonumber(GetConvar("sv_enforceGameBuild", "1604")))
end)

ESX.RegisterServerCallback("esx:getOtherPlayerData", function(_, cb, target)
ESX.RegisterServerCallback("esx:getOtherPlayerData", function(source, cb, target)
local xPlayer = ESX.GetPlayerFromId(target)

if not xPlayer then
return
return cb(nil)
end

-- Admins (server-side ACE) get the full record at any distance, for admin tooling.
if Core.IsPlayerAdmin(source) then
return cb({
identifier = xPlayer.identifier,
accounts = xPlayer.getAccounts(),
inventory = xPlayer.getInventory(),
job = xPlayer.getJob(),
loadout = xPlayer.getLoadout(),
money = xPlayer.getMoney(),
position = xPlayer.getCoords(true),
metadata = xPlayer.getMeta(),
})
end

-- Non-admins: target is client-supplied and untrusted. Expose only a reduced, non-sensitive
-- record (no identifier/economy/inventory/metadata) and only when actually near the target,
-- bounded by Config.DistanceGetOtherPlayerData.
local sourcePed = GetPlayerPed(source)
local targetPed = GetPlayerPed(target)
if sourcePed == 0 or targetPed == 0 then
return cb(nil)
end

if #(GetEntityCoords(sourcePed) - GetEntityCoords(targetPed)) > (Config.DistanceGetOtherPlayerData or 10.0) then
return cb(nil)
end

cb({
identifier = xPlayer.identifier,
accounts = xPlayer.getAccounts(),
inventory = xPlayer.getInventory(),
playerId = target,
name = xPlayer.getName(),
job = xPlayer.getJob(),
loadout = xPlayer.getLoadout(),
money = xPlayer.getMoney(),
position = xPlayer.getCoords(true),
metadata = xPlayer.getMeta(),
})
end)

Expand All @@ -741,9 +805,19 @@ ESX.RegisterServerCallback("esx:getPlayerNames", function(source, cb, players)
end)

ESX.RegisterServerCallback("esx:spawnVehicle", function(source, cb, vehData)
vehData = type(vehData) == "table" and vehData or {}

local ped = GetPlayerPed(source)
ESX.OneSync.SpawnVehicle(vehData.model or `ADDER`, vehData.coords or GetEntityCoords(ped), vehData.coords.w or 0.0, vehData.props or {}, function(id)
if vehData.warp then
local model = (type(vehData.model) == "string" or type(vehData.model) == "number") and vehData.model or `ADDER`
local coords = vehData.coords
if type(coords) ~= "table" and type(coords) ~= "vector3" and type(coords) ~= "vector4" then
coords = GetEntityCoords(ped)
end
local heading = coords.w or 0.0
local props = type(vehData.props) == "table" and vehData.props or {}

ESX.OneSync.SpawnVehicle(model, coords, heading, props, function(id)
if vehData.warp and id then
local vehicle = NetworkGetEntityFromNetworkId(id)
local timeout = 0
while GetVehiclePedIsIn(ped, false) ~= vehicle and timeout <= 15 do
Expand Down
Loading