14 Commits

Author SHA1 Message Date
Andyyy7666
0e6caf7996 fix(groups): group data & client getting 2026-03-20 14:58:54 +01:00
Andyyy7666
b06df2aae6 fix(core): group data updating 2026-03-20 07:30:53 +01:00
Andyyy7666
b837771300 refactor(vehicles): move garages to separate resource keep api
- Moved traffic locking to client
- removed garages (moved to ND_Garages resource)
- optimized and improved the api
- fixed smaller bugs
2026-03-16 08:07:43 +01:00
Andyyy7666
b7222a1f8f feat: admin group panel 2026-03-16 08:04:08 +01:00
Andyyy7666
ec05cce082 Update player.lua 2026-03-16 07:21:15 +01:00
Andyyy7666
7ecd8fa883 Create moneylogs.sql 2026-03-16 05:47:23 +01:00
Andyyy7666
72a0b8c53f feat(server): money logs
logging of all transaction with a stack trace
2026-03-16 05:46:44 +01:00
Andyyy7666
df0d0a8160 fix(server/player): updating admin & group roles on character activate 2026-03-16 05:45:49 +01:00
Andyyy7666
ba2d4951b9 feat(server/player): parameter to keeping group if job removed 2026-03-16 05:45:05 +01:00
Andyyy7666
8a13170999 feat(server/player): server event for group added 2026-03-16 05:44:05 +01:00
Andyyy7666
2ab26fe51d refactor(server/player): saving last location into separate function 2026-03-16 05:43:18 +01:00
Andyyy7666
6939566cf4 feat: user data for db & character fetching 2026-03-16 05:42:38 +01:00
Andyyy7666
075fb087f2 feat(client/events): event reliablity 2026-03-16 05:36:53 +01:00
Andyyy7666
3d5c1c32bb feat(client/death): timestamp on death 2026-03-16 05:36:14 +01:00
25 changed files with 1705 additions and 803 deletions

View File

@@ -15,7 +15,8 @@ local function PlayerEliminated(deathCause, killerServerId, killerClientId)
deathCause = deathCause,
killerServerId = killerServerId,
killerClientId = killerClientId,
damagedBones = usingAmbulance and ambulance:getBodyDamage() or {}
damagedBones = usingAmbulance and ambulance:getBodyDamage() or {},
timestamp = GetCloudTimeAsInt()
}
TriggerEvent("ND:playerEliminated", info)
TriggerServerEvent("ND:playerEliminated", info)

View File

@@ -4,27 +4,35 @@ end)
-- updates the money on the client.
RegisterNetEvent("ND:updateMoney", function(cash, bank)
if not NDCore.player then return end
if source == "" or not NDCore.player then return end
NDCore.player.cash = cash
NDCore.player.bank = bank
end)
-- Sets main character.
RegisterNetEvent("ND:characterLoaded", function(character)
if source == "" then return end
NDCore.player = character
end)
-- Update main character info.
RegisterNetEvent("ND:updateCharacter", function(character)
if source == "" or not NDCore.player then return end
NDCore.player = character
end)
-- Updates last lcoation.
RegisterNetEvent("ND:updateLastLocation", function(location)
if not NDCore.player then return end
if source == "" or not NDCore.player then return end
NDCore.player.lastLocation = location
end)
-- Unsets main character.
RegisterNetEvent("ND:characterUnloaded", function()
if source == "" or not NDCore.player then return end
NDCore.player = nil
end)
RegisterNetEvent("ND:revivePlayer", function()
if source == "" then return end
local oldPed = cache.ped
@@ -57,8 +65,6 @@ RegisterNetEvent("ND:revivePlayer", function()
end
end)
RegisterNetEvent("ND:characterUnloaded")
RegisterNetEvent("ND:clothingMenu", function()
if GetResourceState("fivem-appearance") ~= "started" then return end
@@ -77,3 +83,10 @@ RegisterNetEvent("ND:clothingMenu", function()
tattoos = true
})
end)
RegisterNetEvent("ND:groupsUpdated", function(simplified)
if source == "" then return end
if not simplified then return end
Config.groups = simplified
end)

32
client/groupadmin.lua Normal file
View File

@@ -0,0 +1,32 @@
local isOpen = false
RegisterNUICallback("hide", function(_, cb)
isOpen = false
SetNuiFocus(false, false)
cb("ok")
end)
RegisterNUICallback("groups:create", function(data, cb)
local result = lib.callback.await("ND_Core:groups:create", false, data)
cb(result or {})
end)
RegisterNUICallback("groups:edit", function(data, cb)
local result = lib.callback.await("ND_Core:groups:edit", false, data)
cb(result or {})
end)
RegisterNUICallback("groups:delete", function(data, cb)
local result = lib.callback.await("ND_Core:groups:delete", false, data)
cb(result or {})
end)
RegisterNetEvent("ND:openGroupAdmin", function(groups)
if isOpen then return end
isOpen = true
SetNuiFocus(true, true)
SendNUIMessage({
type = "groups:open",
groups = groups
})
end)

View File

@@ -242,30 +242,36 @@ local function getVehicleBlipSprite(entity)
end
local function getVehFromNetId(netId)
local time = GetCloudTimeAsInt()
while not NetworkDoesNetworkIdExist(netId) or not NetworkDoesEntityExistWithNetworkId(netId) and time-GetCloudTimeAsInt() < 5 do
local startTime = GetCloudTimeAsInt()
while (not NetworkDoesNetworkIdExist(netId) or not NetworkDoesEntityExistWithNetworkId(netId)) and (GetCloudTimeAsInt() - startTime < 5) do
Wait(100)
end
return NetToVeh(netId)
end
RegisterNetEvent("ND_Vehicles:blip", function(netId, status)
local veh = getVehFromNetId(netId)
if not veh then return end
if not status then
local blip = GetBlipFromEntity(veh)
if not blip or not DoesBlipExist(blip) then return end
return RemoveBlip(blip)
local function setVehicleBlip(veh, status, groupVehicle)
local currentBlip = GetBlipFromEntity(veh)
if currentBlip and DoesBlipExist(currentBlip) then
RemoveBlip(currentBlip)
end
if not status then return end
local blip = AddBlipForEntity(veh)
SetBlipSprite(blip, getVehicleBlipSprite(veh))
SetBlipColour(blip, 0)
SetBlipScale(blip, 0.8)
SetBlipAsShortRange(blip, true)
BeginTextCommandSetBlipName("STRING")
AddTextComponentSubstringPlayerName(locale("personal_vehicle"))
AddTextComponentSubstringPlayerName(groupVehicle and locale("group_vehicle") or locale("personal_vehicle"))
EndTextCommandSetBlipName(blip)
end
RegisterNetEvent("ND_Vehicles:blip", function(netId, status, groupVehicle)
local veh = getVehFromNetId(netId)
if not veh then return end
setVehicleBlip(veh, status, groupVehicle)
end)
RegisterNetEvent("ND_Vehicles:syncAlarm", function(netId)
@@ -284,13 +290,39 @@ RegisterNetEvent("ND_VehicleSystem:setOwnedIfNot", function(netId)
end)
AddStateBagChangeHandler("props", nil, function(bagName, key, value, reserved, replicated)
local entity = GetEntityFromStateBagName(bagName)
if not value or not DoesEntityExist(entity) or NetworkGetEntityOwner(entity) ~= cache.playerId then return end
if not value then return end
local props = value
if type(value) == "string" then
props = json.decode(value)
end
if not props or type(props) ~= "table" then return end
local entity = GetEntityFromStateBagName(bagName)
if not DoesEntityExist(entity) or NetworkGetEntityOwner(entity) ~= cache.playerId then return end
lib.setVehicleProperties(entity, props)
TriggerServerEvent("ND_Vehicles:propsApplied", DoesEntityExist(entity) and NetworkGetNetworkIdFromEntity(entity))
end)
local function hasBlipGroup(player, groups)
groups = groups or {}
for i=1, #groups do
local group = groups[i]
if player.groups[group] then
return true
end
end
end
AddStateBagChangeHandler("blipGroups", nil, function(bagName, key, value, reserved, replicated)
local entity = GetEntityFromStateBagName(bagName)
if not value or not DoesEntityExist(entity) then return end
local player = NDCore.getPlayer()
if not player then return end
setVehicleBlip(entity, hasBlipGroup(player, value), true)
end)
local playingKey = 0
@@ -411,6 +443,17 @@ local function hasVehicleKeys(veh, checkEngine)
return hasKey or checkEngine and state.hotwired
end
AddStateBagChangeHandler("owner", nil, function(bagName, key, value, reserved, replicated)
local entity = GetEntityFromStateBagName(bagName)
if not value or not DoesEntityExist(entity) or GetEntityType(entity) ~= 2 then return end
local player = NDCore.getPlayer()
if not player or player.id ~= value then return end
local hasKey = hasVehicleKeys(entity)
setVehicleBlip(entity, hasKey)
end)
local function hasVehicleKeysCheck(veh)
local time = GetCloudTimeAsInt()
if time-keyCheckTime.lastCheck < 5 then
@@ -526,6 +569,33 @@ CreateThread(function()
end
end)
CreateThread(function()
while true do
Wait(100)
local veh = cache.vehicle
if veh then goto skip end
local ped = cache.ped
local vehEntering = GetVehiclePedIsEntering(ped)
if not DoesEntityExist(vehEntering) or NetworkGetEntityOwner(vehEntering) == cache.serverId then goto skip end
local state = Entity(vehEntering).state
if state.owner or state.locked ~= nil then goto skip end
local driver = GetPedInVehicleSeat(vehEntering, -1)
if DoesEntityExist(driver) and IsPedAPlayer(driver) then
state.locked = false
state.hotwired = true
goto skip
end
if math.random(1, 100) <= Config.randomUnlockedVehicleChance then return end
state.locked = true
::skip::
end
end)
local function hotwireVehicle()
local state = playerVehicle and Entity(playerVehicle).state
if not playerVehicle or state.hotwired then return end
@@ -597,34 +667,51 @@ local function lockpickVehicle()
local veh = lib.getClosestVehicle(pos, 2.5, false)
if not veh then return end
local dificulties = {
"easy",
"medium",
"hard"
}
local dificultyTime = {
easy = 500,
medium = 800,
hard = 1000
}
lib.requestAnimDict("veh@break_in@0h@p_m_one@")
for i=1, Config.lockpickTries do
TaskPlayAnimAdvanced(cache.ped, "veh@break_in@0h@p_m_one@", "std_force_entry_ds", pos.x, pos.y, pos.z+0.025, rot.x, rot.y, rot.z, 8.0, 8.0, 1800, 28, 0.1)
local dificulty = dificulties[math.random(1, #dificulties)]
local success = lib.skillCheck(dificulty)
local lockPickResource = GetResourceState("lockpick") == "started" and "lockpick" or GetResourceState("t3_lockpick") == "started" and "t3_lockpick"
if lockPickResource then
local success = exports[lockPickResource]:startLockpick("lockpick_vehicle", 4, Config.lockpickTries)
if not success or not DoesEntityExist(veh) or #(pos-GetEntityCoords(veh)) > 2.5 then
TriggerServerEvent("ND_Vehicles:lockpick", VehToNet(veh), false)
if success == nil then -- nil would mean the minigame was cancelled
return false, false
end
return false, true
end
Wait(dificultyTime[dificulty])
else
local dificulties = {
"easy",
"medium",
"hard"
}
local dificultyTime = {
easy = 500,
medium = 800,
hard = 1000
}
lib.requestAnimDict("veh@break_in@0h@p_m_one@")
for i=1, Config.lockpickTries do
TaskPlayAnimAdvanced(cache.ped, "veh@break_in@0h@p_m_one@", "std_force_entry_ds", pos.x, pos.y, pos.z+0.025, rot.x, rot.y, rot.z, 8.0, 8.0, 1800, 28, 0.1)
local dificulty = dificulties[math.random(1, #dificulties)]
local success = lib.skillCheck(dificulty)
if not success or not DoesEntityExist(veh) or #(pos-GetEntityCoords(veh)) > 2.5 then
TriggerServerEvent("ND_Vehicles:lockpick", VehToNet(veh), false)
return false, true
end
Wait(dificultyTime[dificulty])
end
end
veh = lib.getClosestVehicle(pos, 2.5, false)
if not veh then return false, true end
TriggerServerEvent("ND_Vehicles:lockpick", VehToNet(veh), true)
PlaySoundFromEntity(-1, "Remote_Control_Fob", cache.ped, "PI_Menu_Sounds", true, 0)
return true, true
if lockPickResource then
return true, false
else
return true, true
end
end
exports("lockpick", function(data, slot)

View File

@@ -1,301 +0,0 @@
return {
{
garageType = "land",
groups = {"lsfd"},
ped = vector4(370.9027, -593.8090, 28.8681, 73.6009),
vehicleSpawns = {
vec4(366.7840, -591.4883, 28.7261, 339.6823),
vec4(365.0753, -582.8641, 28.7156, 135.0064),
vec4(357.4239, -604.7719, 28.6767, 177.1355),
}
},
{
garageType = "land",
groups = {"sahp", "lspd", "bcso"},
ped = vector4(452.83, -1027.79, 28.54, 2.49),
vehicleSpawns = {
vector4(446.19, -1025.47, 28.24, 185.96),
vector4(438.75, -1026.09, 28.38, 184.29),
vector4(434.98, -1026.61, 28.46, 185.99),
vector4(431.26, -1027.31, 28.53, 185.59),
vector4(427.52, -1026.84, 28.58, 184.25)
}
},
{
garageType = "land",
impound = true,
ped = vector4(407.99, -1624.74, 29.29, 229.93),
vehicleSpawns = {
vector4(396.34, -1644.28, 28.86, 319.10),
vector4(398.56, -1646.42, 28.8616, 319.18),
vector4(400.68, -1648.86, 28.86, 140.78),
vector4(403.32, -1650.54, 28.86, 319.92),
vector4(403.21, -1650.68, 28.86, 139.40)
}
},
{
garageType = "plane",
ped = vector4(-941.05, -2966.04, 13.95, 136.06),
vehicleSpawns = {
vector4(-974.98, -2977.90, 14.55, 59.91)
}
},
{
garageType = "heli",
ped = vector4(-731.00, -1394.54, 5.00, 245.52),
vehicleSpawns = {
vector4(-746.01, -1469.39, 5.68, 139.27),
vector4(-725.26, -1444.72, 5.68, 139.58)
}
},
{
garageType = "plane",
ped = vector4(1742.77, 3298.25, 41.22, 132.91),
vehicleSpawns = {
vector4(1734.26, 3250.98, 41.96, 81.11),
vector4(1729.32, 3270.69, 41.74, 145.39)
}
},
{
garageType = "plane",
ped = vector4(-1241.26, -3391.48, 13.94, 35.03),
vehicleSpawns = {
vector4(-1254.71, -3388.15, 14.54, 330.04),
vector4(-1270.36, -3378.80, 14.54, 330.20),
vector4(-1286.05, -3369.44, 14.54, 329.99)
}
},
{
garageType = "plane",
ped = vector4(-1621.10, -3151.63, 13.99, 41.26),
vehicleSpawns = {
vector4(-1634.04, -3147.84, 14.60, 330.28),
vector4(-1649.75, -3138.34, 14.60, 329.20),
vector4(-1665.42, -3129.26, 14.60, 330.10)
}
},
{
garageType = "heli",
ped = vector4(-1121.87, -2839.88, 13.95, 150.58),
vehicleSpawns = {
vector4(-1178.71, -2846.51, 14.62, 150.16),
vector4(-1146.40, -2865.22, 14.62, 151.08),
vector4(-1112.85, -2884.66, 14.62, 149.70)
}
},
{
garageType = "water",
ped = vector4(-831.03, -1359.53, 5.00, 299.80),
vehicleSpawns = {
vector4(-846.16, -1362.07, 0.39, 110.54),
vector4(-842.53, -1372.00, 0.39, 111.54),
vector4(-849.29, -1353.26, 0.38, 109.53),
vector4(-852.26, -1345.09, 0.41, 110.52),
vector4(-855.49, -1336.60, 0.40, 107.43),
vector4(-858.63, -1328.30, 0.40, 109.82),
vector4(-839.35, -1380.30, 0.39, 109.27),
vector4(-836.38, -1388.93, 0.41, 110.11),
vector4(-833.29, -1397.32, 0.40, 110.80),
vector4(-830.34, -1405.65, 0.37, 110.16)
}
},
{
garageType = "land",
ped = vector4(-280.32, -888.42, 31.32, 250.68),
vehicleSpawns = {
vector4(-282.36, -915.11, 30.38, 68.81),
vector4(-284.27, -918.37, 30.38, 70.58),
vector4(-285.44, -921.82, 30.38, 250.22),
vector4(-285.70, -887.62, 30.38, 167.04),
vector4(-292.70, -885.93, 30.38, 167.09),
vector4(-285.76, -887.47, 30.38, 169.59),
vector4(-300.44, -885.14, 30.38, 347.05),
vector4(-303.59, -883.71, 30.38, 167.69),
vector4(-309.38, -896.88, 30.38, 167.15),
vector4(-312.98, -896.30, 30.38, 166.46),
vector4(-316.68, -896.26, 30.37, 347.94),
vector4(-311.06, -881.94, 30.38, 168.21),
vector4(-314.78, -881.86, 30.37, 348.45)
}
},
{
garageType = "land",
ped = vector4(597.53, 91.08, 93.13, 250.54),
vehicleSpawns = {
vector4(598.53, 98.37, 92.27, 69.47),
vector4(599.79, 102.00, 92.27, 249.38),
vector4(608.21, 103.90, 92.18, 248.98),
vector4(600.60, 111.38, 92.27, 73.07),
vector4(609.91, 107.59, 92.23, 68.77),
vector4(601.36, 114.99, 92.27, 250.19),
vector4(611.07, 111.39, 92.29, 249.69),
vector4(603.52, 118.52, 92.26, 67.62),
vector4(612.74, 114.94, 92.28, 69.01),
vector4(604.00, 122.58, 92.27, 249.76),
vector4(613.78, 118.83, 92.29, 248.82),
vector4(622.43, 115.48, 91.99, 70.39),
vector4(628.56, 110.25, 91.47, 249.30),
vector4(620.67, 111.89, 92.03, 250.24),
vector4(618.41, 104.51, 91.97, 72.22),
vector4(624.87, 99.25, 91.36, 63.32),
vector4(616.59, 100.82, 91.97, 249.00)
}
},
{
garageType = "land",
ped = vector4(100.64, -1072.88, 29.37, 341.55),
vehicleSpawns = {
vector4(106.13, -1063.24, 28.51, 66.86),
vector4(107.82, -1059.73, 28.51, 66.99),
vector4(112.39, -1049.71, 28.52, 67.40),
vector4(110.76, -1053.09, 28.51, 68.13),
vector4(109.06, -1056.38, 28.51, 66.45),
vector4(117.53, -1081.03, 28.50, 181.40),
vector4(119.09, -1069.44, 28.50, 181.12),
vector4(121.22, -1081.42, 28.50, 0.57),
vector4(122.32, -1070.46, 28.50, 0.67),
vector4(125.62, -1069.54, 28.50, 179.25),
vector4(124.83, -1081.17, 28.50, 180.37),
vector4(128.62, -1081.71, 28.50, 1.36),
vector4(128.93, -1070.62, 28.50, 359.89),
vector4(132.24, -1069.35, 28.50, 181.54),
vector4(132.31, -1081.45, 28.50, 180.86),
vector4(135.93, -1081.35, 28.50, 358.82),
vector4(135.64, -1070.75, 28.50, 0.43),
vector4(139.75, -1081.91, 28.50, 182.18),
vector4(138.86, -1070.37, 28.50, 181.90),
vector4(143.55, -1081.41, 28.50, 0.89),
vector4(147.13, -1081.48, 28.50, 179.02),
vector4(150.95, -1081.49, 28.50, 358.79),
vector4(154.64, -1081.32, 28.50, 179.51),
vector4(158.38, -1082.03, 28.50, 1.57),
vector4(162.10, -1081.32, 28.50, 179.88)
}
},
{
garageType = "land",
ped = vector4(214.84, -806.24, 30.81, 342.23),
vehicleSpawns = {
vector4(251.26, -774.63, 29.98, 67.34),
vector4(245.36, -772.34, 30.02, 66.81),
vector4(219.19, -765.93, 30.14, 249.78),
vector4(228.11, -768.94, 30.10, 249.66),
vector4(233.68, -771.18, 30.07, 249.82),
vector4(244.01, -775.06, 29.99, 249.41),
vector4(249.53, -777.07, 29.95, 251.09),
vector4(248.74, -779.52, 29.92, 69.14),
vector4(243.64, -777.63, 29.96, 69.23),
vector4(233.47, -773.91, 30.05, 69.88),
vector4(218.62, -768.58, 30.14, 69.81),
vector4(217.02, -770.83, 30.16, 248.11),
vector4(226.09, -773.95, 30.09, 249.30),
vector4(231.82, -776.14, 30.04, 249.17),
vector4(242.05, -779.91, 29.93, 246.99),
vector4(247.72, -782.02, 29.88, 251.04),
vector4(216.19, -773.65, 30.16, 67.90),
vector4(225.82, -776.85, 30.08, 65.64),
vector4(230.77, -778.89, 30.03, 69.40),
vector4(241.72, -782.84, 29.90, 69.13),
vector4(247.12, -784.93, 29.84, 67.56),
vector4(246.23, -787.13, 29.82, 247.56),
vector4(239.40, -784.68, 29.90, 248.06),
vector4(230.42, -781.41, 30.01, 247.48),
vector4(224.14, -779.01, 30.07, 247.39),
vector4(215.32, -775.95, 30.17, 248.99),
vector4(243.81, -792.19, 29.77, 246.23),
vector4(238.08, -789.95, 29.85, 250.62),
vector4(228.14, -786.30, 30.01, 249.07),
vector4(221.98, -783.92, 30.08, 248.25),
vector4(213.41, -781.01, 30.19, 249.21),
vector4(214.77, -778.70, 30.17, 67.86),
vector4(224.02, -781.91, 30.07, 68.82),
vector4(229.34, -784.05, 30.01, 66.57),
vector4(239.87, -787.83, 29.86, 69.37),
vector4(245.18, -789.87, 29.79, 68.00),
vector4(241.85, -797.20, 29.72, 245.51),
vector4(236.57, -795.07, 29.82, 247.54),
vector4(226.12, -791.34, 29.99, 247.84),
vector4(220.61, -789.19, 30.08, 248.09),
vector4(212.00, -786.22, 30.21, 248.93),
vector4(212.91, -783.71, 30.19, 68.61),
vector4(222.11, -786.89, 30.07, 70.10),
vector4(227.84, -789.16, 29.99, 67.55),
vector4(237.73, -792.73, 29.82, 69.40),
vector4(243.54, -794.88, 29.73, 68.74),
vector4(237.03, -812.64, 29.59, 243.63),
vector4(207.66, -798.70, 30.29, 70.06),
vector4(216.56, -801.92, 30.10, 70.31),
vector4(222.28, -804.17, 29.98, 69.97),
vector4(232.41, -807.95, 29.74, 69.42),
vector4(238.16, -810.17, 29.60, 68.95),
vector4(238.32, -807.43, 29.63, 248.28),
vector4(233.32, -805.44, 29.75, 248.13),
vector4(222.46, -801.41, 29.98, 248.50),
vector4(216.85, -799.11, 30.10, 248.54),
vector4(207.60, -795.90, 30.29, 248.64),
vector4(209.28, -793.74, 30.25, 69.82),
vector4(218.31, -796.92, 30.08, 68.55),
vector4(224.50, -799.34, 29.96, 68.63),
vector4(234.40, -803.02, 29.76, 66.67),
vector4(240.09, -805.27, 29.64, 69.02),
vector4(240.53, -802.44, 29.67, 248.95),
vector4(234.46, -800.13, 29.79, 249.40),
vector4(224.30, -796.51, 29.98, 248.31),
vector4(218.23, -794.00, 30.09, 249.09),
vector4(209.84, -791.10, 30.24, 251.56),
vector4(211.09, -788.68, 30.22, 69.53),
vector4(220.17, -791.73, 30.06, 70.56),
vector4(225.84, -794.12, 29.98, 67.53),
vector4(236.38, -797.85, 29.79, 69.39),
vector4(216.56, -801.90, 30.79, 69.39)
}
},
{
garageType = "land",
ped = vector4(587.5941, 2744.2844, 42.0691, 181.0514),
vehicleSpawns = {
vector4(584.3921, 2721.3599, 41.6483, 184.3260),
vector4(577.1069, 2736.2322, 41.6097, 184.6912),
vector4(577.9459, 2721.0010, 41.6481, 185.1414),
vector4(574.1685, 2735.7288, 41.6397, 184.5562)
}
},
{
garageType = "land",
ped = vector4(2746.1726, 3458.7378, 55.8272, 245.1009),
vehicleSpawns = {
vector4(2768.2136, 3456.2710, 55.2840, 67.7897),
vector4(2759.3218, 3451.1448, 55.4786, 248.0150),
vector4(2763.6526, 3445.0544, 55.4741, 66.9754),
vector4(2787.2947, 3467.1001, 54.9407, 247.4356),
vector4(2773.9514, 3471.6238, 55.0407, 246.8264)
}
},
{
garageType = "land",
ped = vector4(120.4042, 6623.5190, 31.9593, 228.8283),
vehicleSpawns = {
vector4(155.9047, 6593.3252, 31.4343, 359.3789),
vector4(155.7900, 6602.6978, 31.4463, 358.8232),
vector4(145.8267, 6614.0996, 31.4007, 178.7948),
vector4(145.6977, 6602.8950, 31.4397, 181.0616),
vector4(132.4042, 6585.2139, 31.5501, 91.0906)
}
},
{
garageType = "heli",
groups = {"lspd"},
ped = vector4(465.6194, -985.4730, 43.6918, 0.8591),
vehicleSpawns = {
vector4(449.3105, -981.1057, 44.0788, 359.6042)
}
},
{
garageType = "heli",
groups = {"lsfd"},
ped = vector4(339.6271, -581.0325, 74.1656, 267.8232),
vehicleSpawns = {
vector4(351.3463, -588.1304, 74.1698, 108.5617)
}
}
}

View File

@@ -1,291 +0,0 @@
local locations = require "client.vehicle.data"
local sprite = {
["water"] = 356,
["heli"] = 360,
["plane"] = 359,
["land"] = 357
}
local garageTypes = {
["water"] = 14,
["heli"] = 15,
["plane"] = 16
}
local clothing = {
{
face = {
drawable = 1,
texture = 1
},
undershirt = {
drawable = 0,
texture = 0
},
torso = {
drawable = 1,
texture = 1
},
leg = {
drawable = 0,
texture = 0
},
glasses = {
drawable = 1,
texture = 0
},
hat = {
drawable = -1,
texture = -1
},
},
{
leg = {
drawable = 0,
texture = 1
},
undershirt = {
drawable = 0,
texture = 0
},
face = {
drawable = 0,
texture = 0
},
torso = {
drawable = 0,
texture = 2
},
glasses = {
drawable = -1,
texture = -1
},
hat = {
drawable = 0,
texture = 0
},
},
{
face = {
drawable = 0,
texture = 2
},
undershirt = {
drawable = 0,
texture = 0
},
torso = {
drawable = 1,
texture = 2
},
leg = {
drawable = 0,
texture = 0
},
hat = {
drawable = -1,
texture = -1
},
glasses = {
drawable = -1,
texture = -1
},
}
}
local function getClosestOwnedVehicle()
local coords = GetEntityCoords(cache.ped)
local vehicles = lib.getNearbyVehicles(coords, 50.0, true)
local nearestVeh = {}
local function setNearestVehicle(veh)
local state = Entity(veh.vehicle).state
if not state.owner or state.owner ~= NDCore.player?.id then return end
local nearestDist = nearestVeh.dist
local dist = #(coords-veh.coords)
if not nearestDist or dist < nearestDist then
nearestVeh.dist = dist
nearestVeh.coords = veh.coords
nearestVeh.entity = veh.vehicle
end
end
for i=1, #vehicles do
setNearestVehicle(vehicles[i])
end
return nearestVeh.entity, nearestVeh.coords, nearestVeh.dist
end
local function parkVehicle(veh)
if not veh or not DoesEntityExist(veh) then
return NDCore.notify({
title = locale("garage"),
description = locale("no_owned_veh_nearby"),
type = "error",
position = "bottom",
duration = 3000
})
end
if GetPedInVehicleSeat(veh, -1) ~= 0 then
NDCore.notify({
title = locale("garage"),
description = locale("player_in_veh"),
type = "error",
position = "bottom",
duration = 3000
})
return
end
local properties = lib.getVehicleProperties(veh)
properties.class = GetVehicleClass(veh)
TriggerServerEvent("ND_Vehicles:storeVehicle", VehToNet(veh))
end
local function isVehicleAvailable(vehicle, garageType, impound)
local class = vehicle.properties.class
local available = vehicle.available and not impound or vehicle.impounded and impound
if available and not garageTypes[garageType] then return true end
for garType, garClass in pairs(garageTypes) do
if available and garType == garageType and garClass == class then
return true
end
end
end
local function getEngineStatus(health)
if health > 950 then
return locale("perfect")
elseif health > 750 then
return locale("good")
elseif health > 500 then
return locale("bad")
end
return locale("very_bad")
end
local function createMenuOptions(vehicle, vehicleSpawns)
local props = vehicle.properties
local makeName = GetLabelText(GetMakeNameFromVehicleModel(props.model))
local modelName = GetLabelText(GetDisplayNameFromVehicleModel(props.model))
local metadata = {}
if not makeName or makeName == "NULL" then
makeName = ""
else
metadata[#metadata+1] = {label = locale("veh_make_brand"), value = makeName}
makeName = makeName .. " "
end
if not modelName or modelName == "NULL" then
modelName = ""
else
metadata[#metadata+1] = {label = locale("veh_model"), value = modelName}
end
if props?.plate then
metadata[#metadata+1] = {label = locale("veh_plate"), value = props.plate}
end
if props?.engineHealth then
metadata[#metadata+1] = {
label = locale("engine_status"),
value = getEngineStatus(props.engineHealth),
progress = props.engineHealth/10,
colorScheme = "blue"
}
end
if props?.fuelLevel then
metadata[#metadata+1] = {
label = "Fuel",
value = ("%d%s"):format(props.fuelLevel, "%"),
progress = props.fuelLevel,
colorScheme = "yellow"
}
end
return {
title = ("%s: %s%s\n%s: %s"):format(locale("vehicle"), makeName, modelName, locale("veh_plate"), props?.plate or locale("not_found")),
metadata = metadata,
onSelect = function(args)
TriggerServerEvent("ND_Vehicles:takeVehicle", vehicle.id, vehicleSpawns)
end,
}
end
local function createMenu(vehicles, garageType, vehicleSpawns, impound)
local options = {}
if not impound then
options[#options+1] = {
title = locale("park_veh"),
onSelect = function(args)
local veh = getClosestOwnedVehicle()
parkVehicle(veh)
end
}
end
for _, vehicle in ipairs(vehicles) do
if isVehicleAvailable(vehicle, garageType, impound) then
options[#options+1] = createMenuOptions(vehicle, vehicleSpawns)
end
end
if impound and #options == 0 then
options[#options+1] = {
title = locale("no_vehs_found"),
readOnly = true
}
end
return {
id = ("garage_%s"):format(garageType),
title = impound and locale("vehicle_impound") or locale("parking_garage"),
options = options,
onExit = function()
garageOpen = false
end
}
end
for i=1, #locations do
local location = locations[i]
NDCore.createAiPed({
model = `s_m_y_airworker`,
coords = location.ped,
distance = 45.0,
clothing = clothing[math.random(1, #clothing)],
blip = {
label = location.impound and locale("impound_w_location", location.garageType) or locale("garage_w_location", location.garageType),
sprite = location.impound and 285 or sprite[location.garageType],
scale = 0.7,
color = 3,
groups = location.groups
},
anim = {
dict = "anim@amb@casino@valet_scenario@pose_d@",
clip = "base_a_m_y_vinewood_01"
},
options = {
{
name = "nd_core:garagePed",
icon = "fa-solid fa-warehouse",
label = location.impound and locale("view_impounded_vehs") or locale("view_garage"),
distance = 2.0,
canInteract = function(entity, distance, coords, name, bone)
if not location.groups then return true end
local groups = location.groups
local playerGroups = NDCore.player?.groups
for i=1, #groups do
if playerGroups?[groups[i]] then
return true
end
end
end,
onSelect = function(data)
local vehicles = lib.callback.await("ND_Vehicles:getOwnedVehicles") or {}
local menu = createMenu(vehicles, location.garageType, location.vehicleSpawns, location.impound)
lib.registerContext(menu)
lib.showContext(menu.id)
end
}
},
})
end

View File

@@ -1,5 +1,6 @@
CREATE TABLE IF NOT EXISTS `nd_characters` (
`charid` INT(10) NOT NULL AUTO_INCREMENT,
`user_id` INT(11) DEFAULT NULL,
`identifier` VARCHAR(200) NOT NULL DEFAULT '0',
`name` VARCHAR(50) DEFAULT NULL,
`firstname` VARCHAR(50) DEFAULT NULL,
@@ -9,8 +10,11 @@ CREATE TABLE IF NOT EXISTS `nd_characters` (
`cash` INT(10) DEFAULT '0',
`bank` INT(10) DEFAULT '0',
`phonenumber` VARCHAR(20) DEFAULT NULL,
`deleted_at` DATETIME NULL DEFAULT NULL,
`groups` LONGTEXT DEFAULT ('[]'),
`metadata` LONGTEXT DEFAULT ('[]'),
`inventory` LONGTEXT DEFAULT ('[]'),
PRIMARY KEY (`charid`) USING BTREE
PRIMARY KEY (`charid`) USING BTREE,
INDEX `idx_nd_characters_user_id` (`user_id`),
CONSTRAINT `fk_nd_characters_user_id` FOREIGN KEY (`user_id`) REFERENCES `nd_users` (`user_id`) ON UPDATE CASCADE ON DELETE SET NULL
);

10
database/group_ranks.sql Normal file
View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS `nd_group_ranks` (
`id` INT NOT NULL AUTO_INCREMENT,
`group_name` VARCHAR(50) NOT NULL,
`label` VARCHAR(100) NOT NULL,
`weight` INT NOT NULL DEFAULT 1,
`isBoss` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_group_name` (`group_name`),
CONSTRAINT `fk_group_ranks_group` FOREIGN KEY (`group_name`) REFERENCES `nd_groups`(`name`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

6
database/groups.sql Normal file
View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS `nd_groups` (
`name` VARCHAR(50) NOT NULL,
`label` VARCHAR(100) NOT NULL,
`isJob` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

13
database/moneylogs.sql Normal file
View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS `nd_money_log` (
`log_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`character_id` INT(10) NOT NULL,
`action` VARCHAR(50) DEFAULT NULL,
`account` ENUM('cash', 'bank') NOT NULL,
`amount` INT(10) NOT NULL,
`reason` VARCHAR(255) DEFAULT NULL,
`trace` TEXT DEFAULT NULL,
`date_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`log_id`),
KEY `idx_character_id` (`character_id`),
CONSTRAINT `fk_moneylog_character` FOREIGN KEY (`character_id`) REFERENCES `nd_characters` (`charid`) ON UPDATE CASCADE ON DELETE CASCADE
);

12
database/users.sql Normal file
View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS `nd_users` (
`user_id` INT(11) NOT NULL AUTO_INCREMENT,
`id_steam` LONGTEXT NULL DEFAULT NULL,
`id_discord` LONGTEXT NULL DEFAULT NULL,
`id_xbl` LONGTEXT NULL DEFAULT NULL,
`id_live` LONGTEXT NULL DEFAULT NULL,
`id_license` LONGTEXT NULL DEFAULT NULL,
`id_license2` LONGTEXT NULL DEFAULT NULL,
`id_fivem` LONGTEXT NULL DEFAULT NULL,
`id_ip` LONGTEXT NULL DEFAULT NULL,
PRIMARY KEY (`user_id`) USING BTREE
) COLLATE='utf8mb4_unicode_ci';

View File

@@ -8,16 +8,25 @@ fx_version "cerulean"
game "gta5"
lua54 "yes"
files {
"init.lua",
"compatibility/**/locale.lua",
"locales/*.json",
"ui/**"
}
ui_page "ui/index.html"
shared_script "@ox_lib/init.lua"
client_scripts {
"client/main.lua",
"shared/functions.lua",
"client/peds.lua",
"client/vehicle/main.lua",
"client/vehicle/garages.lua",
"client/vehicle.lua",
"client/functions.lua",
"client/events.lua",
"client/death.lua",
"client/groupadmin.lua",
"compatibility/**/client.lua"
}
server_scripts {
@@ -27,17 +36,12 @@ server_scripts {
"server/player.lua",
"server/vehicle.lua",
"server/functions.lua",
"server/groups.lua",
"server/groupadmin.lua",
"compatibility/**/server.lua",
"server/commands.lua"
}
files {
"init.lua",
"client/vehicle/data.lua",
"compatibility/**/locale.lua",
"locales/*.json"
}
dependencies {
"ox_lib",
"oxmysql"

View File

@@ -66,6 +66,7 @@
"view_impounded_vehs": "View impounded vehicles",
"view_garage": "View garage",
"personal_vehicle": "Personal vehicle",
"group_vehicle": "Vehicle",
"keybind_carkey": "Unlock/lock vehicle (double click)",
"progress_hotwiring": "Hotwiring",
"cruise_control": "Cruise control",

View File

@@ -66,6 +66,7 @@
"view_impounded_vehs": "Visa beslagtagna fordon",
"view_garage": "Visa garage",
"personal_vehicle": "Personligt fordon",
"group_vehicle": "Fordon",
"keybind_carkey": "Lås/öppna fordon (dubbelklick)",
"progress_hotwiring": "Kopplar om",
"cruise_control": "Farthållare",

60
server/groupadmin.lua Normal file
View File

@@ -0,0 +1,60 @@
lib.addCommand("groupadmin", {
help = "Open the group management admin panel.",
restricted = "group.admin"
}, function(source, args, raw)
if not source or source == 0 then return end
local groups = NDCore.getAllGroups()
TriggerClientEvent("ND:openGroupAdmin", source, groups)
end)
lib.callback.register("ND_Core:groups:create", function(source, data)
local player = NDCore.getPlayer(source)
if not player or not player.groups["admin"] then return { success = false } end
if not data or not data.name or not data.label then
return { success = false }
end
local success, err = NDCore.createNewGroup(data.name, data.label, data.isJob, data.ranks)
if not success then
return { success = false, error = err }
end
return { success = true, groups = NDCore.getAllGroups() }
end)
lib.callback.register("ND_Core:groups:edit", function(source, data)
local player = NDCore.getPlayer(source)
if not player or not player.groups["admin"] then return { success = false } end
if not data or not data.name then
return { success = false }
end
local success, err = NDCore.editGroupData(data.name, {
label = data.label,
isJob = data.isJob,
ranks = data.ranks
})
if not success then
return { success = false, error = err }
end
return { success = true, groups = NDCore.getAllGroups() }
end)
lib.callback.register("ND_Core:groups:delete", function(source, data)
local player = NDCore.getPlayer(source)
if not player or not player.groups["admin"] then return { success = false } end
if not data or not data.name then
return { success = false }
end
local success = NDCore.deleteGroup(data.name)
if not success then
return { success = false }
end
return { success = true, groups = NDCore.getAllGroups() }
end)

233
server/groups.lua Normal file
View File

@@ -0,0 +1,233 @@
-- Cache of all groups loaded from DB: Config.groups[name] = { label, isJob, ranks = { [weight] = label }, ranksData = { [weight] = { id, label, weight, isBoss } } }
local function loadGroupsFromDB()
local groups = {}
local rows = MySQL.query.await("SELECT * FROM nd_groups")
if not rows then return groups end
for _, row in ipairs(rows) do
groups[row.name] = {
label = row.label,
isJob = row.isJob,
ranks = {},
ranksData = {}
}
end
local ranks = MySQL.query.await("SELECT * FROM nd_group_ranks ORDER BY weight ASC")
if ranks then
for _, rank in ipairs(ranks) do
local g = groups[rank.group_name]
if g then
g.ranks[rank.weight] = rank.label
g.ranksData[rank.weight] = {
id = rank.id,
label = rank.label,
weight = rank.weight,
isBoss = rank.isBoss
}
end
end
end
return groups
end
--- Check if a group exists in the database/cache.
---@param name string
---@return boolean
function NDCore.doesGroupExist(name)
return Config.groups[name] ~= nil
end
--- Get group data from cache.
---@param name string
---@return table|nil
function NDCore.getGroupData(name)
return Config.groups[name]
end
--- Create a new group in the database and cache.
---@param name string Unique key
---@param label string Display name
---@param isJob boolean
---@param ranks table Array of { label = string, weight = number, isBoss = boolean }
---@return boolean success
---@return string|nil error
function NDCore.createNewGroup(name, label, isJob, ranks)
if not name or name == "" then return false, "name is required" end
if Config.groups[name] then return false, "group already exists" end
MySQL.insert.await("INSERT INTO nd_groups (`name`, `label`, `isJob`) VALUES (?, ?, ?)", {
name, label or name, isJob and 1 or 0
})
Config.groups[name] = {
label = label or name,
isJob = isJob or false,
ranks = {},
ranksData = {}
}
if ranks and #ranks > 0 then
for _, rank in ipairs(ranks) do
local id = MySQL.insert.await("INSERT INTO nd_group_ranks (`group_name`, `label`, `weight`, `isBoss`) VALUES (?, ?, ?, ?)", {
name, rank.label, rank.weight, rank.isBoss and 1 or 0
})
Config.groups[name].ranks[rank.weight] = rank.label
Config.groups[name].ranksData[rank.weight] = {
id = id,
label = rank.label,
weight = rank.weight,
isBoss = rank.isBoss or false
}
end
end
NDCore.syncGroupsToClients()
return true
end
--- Edit an existing group in the database and cache.
---@param name string Group key
---@param data table { label?, isJob?, ranks? }
---@return boolean success
---@return string|nil error
function NDCore.editGroupData(name, data)
if not name or not Config.groups[name] then return false, "group does not exist" end
if data.label ~= nil or data.isJob ~= nil then
local label = data.label or Config.groups[name].label
local isJob = data.isJob ~= nil and data.isJob
MySQL.update.await("UPDATE nd_groups SET `label` = ?, `isJob` = ? WHERE `name` = ?", {
label, isJob and 1 or 0, name
})
Config.groups[name].label = label
Config.groups[name].isJob = isJob
end
if data.ranks then
-- Delete all old ranks and replace with new set
MySQL.update.await("DELETE FROM nd_group_ranks WHERE `group_name` = ?", { name })
Config.groups[name].ranks = {}
Config.groups[name].ranksData = {}
for _, rank in ipairs(data.ranks) do
local id = MySQL.insert.await("INSERT INTO nd_group_ranks (`group_name`, `label`, `weight`, `isBoss`) VALUES (?, ?, ?, ?)", {
name, rank.label, rank.weight, rank.isBoss and 1 or 0
})
Config.groups[name].ranks[rank.weight] = rank.label
Config.groups[name].ranksData[rank.weight] = {
id = id,
label = rank.label,
weight = rank.weight,
isBoss = rank.isBoss or false
}
end
end
NDCore.syncGroupsToClients()
return true
end
--- Delete a group from the database and cache.
---@param name string
---@return boolean
function NDCore.deleteGroup(name)
if not name or not Config.groups[name] then return false end
MySQL.update.await("DELETE FROM nd_groups WHERE `name` = ?", { name })
Config.groups[name] = nil
NDCore.syncGroupsToClients()
return true
end
--- Get all groups formatted for UI or external use.
---@return table
function NDCore.getAllGroups()
local result = {}
for name, group in pairs(Config.groups) do
local ranks = {}
for weight, rank in pairs(group.ranksData) do
ranks[#ranks+1] = {
id = rank.id,
label = rank.label,
weight = rank.weight,
isBoss = rank.isBoss
}
end
table.sort(ranks, function(a, b) return a.weight < b.weight end)
result[#result+1] = {
name = name,
label = group.label,
isJob = group.isJob,
ranks = ranks
}
end
table.sort(result, function(a, b) return a.name < b.name end)
return result
end
--- Sync groups convar to all clients after changes.
function NDCore.syncGroupsToClients()
-- Build a simplified map for the convar (backwards compatible format)
local simplified = {}
for name, group in pairs(Config.groups) do
local rankLabels = {}
local sortedWeights = {}
for weight in pairs(group.ranks) do
sortedWeights[#sortedWeights+1] = weight
end
table.sort(sortedWeights)
for _, weight in ipairs(sortedWeights) do
rankLabels[#rankLabels+1] = group.ranks[weight]
end
simplified[name] = {
label = group.label,
isJob = group.isJob,
ranks = rankLabels
}
end
SetConvarReplicated("core:groups", json.encode(simplified))
TriggerClientEvent("ND:groupsUpdated", -1, simplified)
end
--- Load groups from DB into Config.groups. Called at startup.
function NDCore.loadGroups()
Config.groups = loadGroupsFromDB()
NDCore.syncGroupsToClients()
end
--- Seed the database from the old JSON config if tables are empty.
function NDCore.seedGroupsFromJson()
local count = MySQL.scalar.await("SELECT COUNT(*) FROM nd_groups")
if count and count > 0 then return false end
local groupsJson = lib.loadJson("_config.groups") or json.decode(GetConvar("core:groups", "[]"))
if not groupsJson then return false end
for name, group in pairs(groupsJson) do
MySQL.insert.await("INSERT INTO nd_groups (`name`, `label`, `isJob`) VALUES (?, ?, ?)", {
name, group.label or name, (group.isJob and 1 or 0)
})
if group.ranks then
for i, rankLabel in ipairs(group.ranks) do
local isBoss = false
if group.minimumBossRank and i >= group.minimumBossRank then
isBoss = true
end
MySQL.insert.await("INSERT INTO nd_group_ranks (`group_name`, `label`, `weight`, `isBoss`) VALUES (?, ?, ?, ?)", {
name, rankLabel, i, isBoss and 1 or 0
})
end
end
end
return true
end
for name, func in pairs(NDCore) do
if type(func) == "function" then
exports(name, func)
end
end

View File

@@ -4,6 +4,7 @@ NDCore.players = {}
PlayersInfo = {}
local resourceName = GetCurrentResourceName()
local tempPlayersInfo = {}
-- Groups are now loaded from the database in MySQL.ready via NDCore.loadGroups()
Config = {
serverName = GetConvar("core:serverName", "Unconfigured ND-Core Server"),
@@ -17,12 +18,13 @@ Config = {
discordActionText2 = GetConvar("core:discordActionText2", "STORE"),
discordActionLink2 = GetConvar("core:discordActionLink2", "https://andyyy.tebex.io/category/fivem-scripts"),
characterIdentifier = GetConvar("core:characterIdentifier", "license"),
selectIdentifiers = json.decode(GetConvar("core:selectIdentifiers", '["discord", "license", "license2", "fivem"]')),
discordGuildId = GetConvar("core:discordGuildId", "false"),
discordBotToken = GetConvar("core:discordBotToken", "false"),
randomUnlockedVehicleChance = GetConvarInt("core:randomUnlockedVehicleChance", 30),
disableVehicleAirControl = GetConvarInt("core:disableVehicleAirControl", 1) == 1,
useInventoryForKeys = GetConvarInt("core:useInventoryForKeys", 1) == 1,
groups = json.decode(GetConvar("core:groups", "[]")),
groups = {},
admins = json.decode(GetConvar("core:admins", "[]")),
adminDiscordRoles = json.decode(GetConvar("core:adminDiscordRoles", "[]")),
groupRoles = json.decode(GetConvar("core:groupRoles", "[]")),
@@ -66,6 +68,71 @@ AddEventHandler("playerJoining", function(oldId)
lib.addPrincipal(("player.%s"):format(src), "group.admin")
end
local identifiers = PlayersInfo[src] and PlayersInfo[src].identifiers or getIdentifierList(src)
local whereParts = {}
local params = {}
for identifierType, identifier in pairs(identifiers) do
local isSelected = false
for i=1, #Config.selectIdentifiers do
local selectedType = Config.selectIdentifiers[i]
if identifierType == selectedType then
isSelected = true
break
end
end
if isSelected then
local columnName = "id_" .. identifierType
local cleanId = identifier:gsub("^[^:]*:", "")
table.insert(whereParts, columnName .. " = ?")
table.insert(params, cleanId)
end
end
local user = nil
if #whereParts > 0 then
local query = "SELECT user_id FROM nd_users WHERE " .. table.concat(whereParts, " OR ") .. " LIMIT 1"
user = MySQL.query.await(query, params)
end
if user and user[1] then
PlayersInfo[src].userId = user[1].user_id
local updateParts = {}
local updateParams = {}
for identifierType, identifier in pairs(identifiers) do
local columnName = "id_" .. identifierType
local cleanId = identifier:gsub("^[^:]*:", "")
table.insert(updateParts, columnName .. " = ?")
table.insert(updateParams, cleanId)
end
if #updateParts > 0 then
table.insert(updateParams, user[1].user_id)
local updateQuery = "UPDATE nd_users SET " .. table.concat(updateParts, ", ") .. " WHERE user_id = ?"
MySQL.update.await(updateQuery, updateParams)
end
else
local columns = {}
local values = {}
local insertParams = {}
for identifierType, identifier in pairs(identifiers) do
local columnName = "id_" .. identifierType
local cleanId = identifier:gsub("^[^:]*:", "")
table.insert(columns, columnName)
table.insert(values, "?")
table.insert(insertParams, cleanId)
end
local insertQuery = "INSERT INTO nd_users (" .. table.concat(columns, ", ") .. ") VALUES (" .. table.concat(values, ", ") .. ")"
local user_id = MySQL.insert.await(insertQuery, insertParams)
PlayersInfo[src].userId = user_id
end
if Config.multiCharacter then return end
Wait(3000)
@@ -164,14 +231,6 @@ AddEventHandler("onResourceStop", function(name)
end
end)
MySQL.ready(function()
Wait(100)
NDCore.loadSQL({
"database/characters.sql",
"database/vehicles.sql"
}, resourceName)
end)
RegisterNetEvent("ND:playerEliminated", function(info)
local src = source
local player = NDCore.getPlayer(src)
@@ -188,3 +247,23 @@ RegisterNetEvent("ND:updateClothing", function(clothing)
if not player or not clothing or type(clothing) ~= "table" then return end
player.setMetadata("clothing", clothing)
end)
MySQL.ready(function()
Wait(100)
NDCore.loadSQL({
"database/users.sql",
"database/characters.sql",
"database/vehicles.sql",
"database/moneylogs.sql",
"database/groups.sql",
"database/group_ranks.sql"
}, resourceName)
Wait(200)
NDCore.seedGroupsFromJson()
NDCore.loadGroups()
end)
-- Hourly cron, purge soft-deleted characters older than 30 days.
lib.cron.new("0 * * * *", function()
MySQL.update.await("DELETE FROM nd_characters WHERE deleted_at IS NOT NULL AND deleted_at < DATE_SUB(NOW(), INTERVAL 30 DAY)")
end)

View File

@@ -1,4 +1,5 @@
local avoidSavingLastLocations = {}
local moneyLogs = {}
local function removeCharacterFunctions(character)
local newData = {}
@@ -10,6 +11,13 @@ local function removeCharacterFunctions(character)
return newData
end
local function logMoney(charId, action, account, amount, reason, trace)
if not trace or (action == "set" and not reason) then return end
local traceText = trace:match("%((.-)%)")
traceText = traceText and traceText:gsub("%^%d", "") or traceText or ""
table.insert(moneyLogs, { charId, action, account, amount, reason or "[NO REASON GIVEN]", traceText })
end
local function createCharacterTable(info)
local playerInfo = PlayersInfo[info.source] or {}
@@ -17,6 +25,7 @@ local function createCharacterTable(info)
id = info.id,
source = info.source,
identifier = info.identifier,
user_id = info.user_id,
identifiers = playerInfo.identifiers or {},
discord = playerInfo.discord or {},
name = info.name,
@@ -45,6 +54,9 @@ local function createCharacterTable(info)
self.triggerEvent("ND:updateMoney", self.cash, self.bank)
TriggerEvent("ND:moneyChange", self.source, account, amount, "remove", reason)
end
logMoney(self.id, "deduct", account, amount, reason, Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString()))
return true
end
@@ -60,6 +72,9 @@ local function createCharacterTable(info)
self.triggerEvent("ND:updateMoney", self.cash, self.bank)
TriggerEvent("ND:moneyChange", self.source, account, amount, "add", reason)
end
logMoney(self.id, "add", account, amount, reason, Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString()))
return true
end
@@ -106,12 +121,14 @@ local function createCharacterTable(info)
for k, v in pairs(key) do
self[k] = v
if k == "cash" or k == "bank" then
logMoney(self.id, "set", k, value, reason, Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString()))
TriggerEvent("ND:moneyChange", self.source, k, v, "set", reason)
end
end
else
self[key] = value
if key == "cash" or key == "bank" then
logMoney(self.id, "set", key, value, reason, Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString()))
TriggerEvent("ND:moneyChange", self.source, key, value, "set", reason)
end
end
@@ -135,14 +152,42 @@ local function createCharacterTable(info)
return self.metadata
end
-- Completely delete character
-- Mark character deleted
function self.delete()
local result = MySQL.query.await("DELETE FROM nd_characters WHERE charid = ?", {self.id})
local result = MySQL.update.await("UPDATE nd_characters SET deleted_at = NOW() WHERE charid = ?", {self.id})
if result and NDCore.players[self.source] then
NDCore.players[self.source] = nil
end
return result
end
--save last location
function self.saveLastLocation()
local ped = GetPlayerPed(self.source)
if not ped or ped == 0 then return end
local coords = GetEntityCoords(ped)
local heading = GetEntityHeading(ped)
local saveLocation = true
for i=1, #avoidSavingLastLocations do
local loc = avoidSavingLastLocations[i]
if #(loc.coords-coords) < loc.dist then
saveLocation = false
end
end
if not saveLocation then return end
self.setMetadata("location", {
x = coords.x,
y = coords.y,
z = coords.z,
w = heading
})
return true
end
-- Unload and save character
function self.unload()
@@ -152,29 +197,7 @@ local function createCharacterTable(info)
lib.removePrincipal(self.source, ("group.%s"):format(name))
end
local ped = GetPlayerPed(self.source)
if ped then
local coords = GetEntityCoords(ped)
local heading = GetEntityHeading(ped)
local saveLocation = true
for i=1, #avoidSavingLastLocations do
local loc = avoidSavingLastLocations[i]
if #(loc.coords-coords) < loc.dist then
saveLocation = false
end
end
if saveLocation then
self.setMetadata("location", {
x = coords.x,
y = coords.y,
z = coords.z,
w = heading
})
end
end
self.saveLastLocation()
self.triggerEvent("ND:characterUnloaded")
TriggerEvent("ND:characterUnloaded", self.source, self)
@@ -201,7 +224,8 @@ local function createCharacterTable(info)
return affectedRows > 0
end
local affectedRows = MySQL.update.await("UPDATE nd_characters SET name = ?, firstname = ?, lastname = ?, dob = ?, gender = ?, cash = ?, bank = ?, phonenumber = ?, `groups` = ?, metadata = ? WHERE charid = ?", {
local affectedRows = MySQL.update.await("UPDATE nd_characters SET user_id = ?, name = ?, firstname = ?, lastname = ?, dob = ?, gender = ?, cash = ?, bank = ?, phonenumber = ?, `groups` = ?, metadata = ? WHERE charid = ?", {
self.user_id,
self.name,
self.firstname,
self.lastname,
@@ -332,17 +356,36 @@ local function createCharacterTable(info)
end
local roles = self.discord.roles
if roles then
if roles then
local hasAdmin = false
for i=1, #Config.adminDiscordRoles do
local role = Config.adminDiscordRoles[i]
if lib.table.contains(roles, role) then
self.addGroup("admin")
hasAdmin = true
break
end
end
if hasAdmin then
self.addGroup("admin")
elseif self.groups["admin"] then
self.removeGroup("admin")
end
for group, role in pairs(Config.groupRoles) do
if lib.table.contains(roles, role) then
local function hasRole(groupRoles)
for i=1, #groupRoles do
local role = groupRoles[i]
if lib.table.contains(roles, role) then
return true
end
end
end
for group, groupRoles in pairs(Config.groupRoles) do
if hasRole(groupRoles) then
self.addGroup(group)
elseif self.groups[group] then
self.removeGroup(group)
end
end
end
@@ -361,11 +404,19 @@ local function createCharacterTable(info)
---@return boolean
function self.addGroup(name, rank, customGroup, isJob)
local groupRank = tonumber(rank) or 1
local groupInfo = lib.table.deepclone(Config.groups?[name] or {})
local bossRank = groupInfo?.minimumBossRank
local groupInfo = Config.groups?[name] or {}
local rankData = groupInfo?.ranksData?[groupRank]
for k, v in pairs(customGroup or {}) do
groupInfo[k] = v
local groupLabel = groupInfo?.label or name
local rankName = rankData?.label or groupRank
local isBoss = rankData?.isBoss or false
local metadata = groupInfo?.metadata or {}
if customGroup then
if customGroup.label then groupLabel = customGroup.label end
if customGroup.rankName then rankName = customGroup.rankName end
if customGroup.isBoss ~= nil then isBoss = customGroup.isBoss end
if customGroup.metadata then metadata = customGroup.metadata end
end
if isJob then
@@ -376,12 +427,12 @@ local function createCharacterTable(info)
self.groups[name] = {
name = name,
label = groupInfo?.label or name,
rankName = groupInfo?.ranks?[groupRank] or groupRank,
label = groupLabel,
rankName = rankName,
rank = groupRank,
isJob = isJob,
isBoss = bossRank and groupRank >= bossRank,
metadata = groupInfo.metadata or {}
isBoss = isBoss,
metadata = metadata
}
if not isJob then
@@ -389,8 +440,8 @@ local function createCharacterTable(info)
TriggerEvent("ND:updateCharacter", self, "groups")
end
TriggerEvent("ND:groupAdded", self, self.groups[name])
lib.addPrincipal(self.source, ("group.%s"):format(name))
return self.groups[name]
end
@@ -421,13 +472,18 @@ local function createCharacterTable(info)
---@param name string
---@param rank number
---@return boolean
function self.setJob(name, rank, customGroup)
self.removeGroup(self.job)
function self.setJob(name, rank, customGroup, keepGroup)
if not keepGroup then
self.removeGroup(self.job)
end
local job = self.addGroup(name, rank, customGroup, true)
if job then
self.job = job.name
self.jobInfo = job
end
self.triggerEvent("ND:updateCharacter", removeCharacterFunctions(self), "job")
TriggerEvent("ND:updateCharacter", self, "job")
return job
@@ -487,10 +543,13 @@ end
function NDCore.newCharacter(src, info)
local identifier = NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier)
if not identifier then return end
local userId = PlayersInfo[src] and PlayersInfo[src].userId
local charInfo = {
source = src,
identifier = identifier,
user_id = userId,
name = GetPlayerName(src) or "",
firstname = info.firstname or "",
lastname = info.lastname or "",
@@ -516,8 +575,9 @@ function NDCore.newCharacter(src, info)
return
end
charInfo.id = MySQL.insert.await("INSERT INTO nd_characters (identifier, name, firstname, lastname, dob, gender, cash, bank, phonenumber, `groups`, metadata, inventory) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", {
charInfo.id = MySQL.insert.await("INSERT INTO nd_characters (identifier, user_id, name, firstname, lastname, dob, gender, cash, bank, phonenumber, `groups`, metadata, inventory) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", {
identifier,
userId,
charInfo.name,
charInfo.firstname,
charInfo.lastname,
@@ -539,17 +599,30 @@ end
function NDCore.fetchCharacter(id, src)
local result
if src then
result = MySQL.query.await("SELECT * FROM nd_characters WHERE charid = ? and identifier = ?", {id, NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier)})
local identifier = NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier)
local userId = PlayersInfo[src] and PlayersInfo[src].userId
result = MySQL.query.await("SELECT * FROM nd_characters WHERE charid = ? AND (identifier = ? OR user_id = ?)", {id, identifier, userId})
else
result = MySQL.query.await("SELECT * FROM nd_characters WHERE charid = ?", {id})
end
if not result then return end
local info = result[1]
local info = result?[1]
if not info then return end
-- Check if character has user_id, if not assign it
if not info.user_id and src then
local userId = PlayersInfo[src] and PlayersInfo[src].userId
if userId then
MySQL.update.await("UPDATE nd_characters SET user_id = ? WHERE charid = ?", {userId, info.charid})
info.user_id = userId
end
end
return createCharacterTable({
source = src,
id = info.charid,
identifier = info.identifier,
user_id = info.user_id,
name = info.name,
firstname = info.firstname,
lastname = info.lastname,
@@ -567,15 +640,27 @@ end
---@param src number
---@return table
function NDCore.fetchAllCharacters(src)
local userId = PlayersInfo[src] and PlayersInfo[src].userId
local identifier = NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier)
local characters = {}
local result = MySQL.query.await("SELECT * FROM nd_characters WHERE identifier = ?", {NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier)})
-- Query by either identifier OR user_id, excluding deleted characters
local result = MySQL.query.await("SELECT * FROM nd_characters WHERE (identifier = ? OR user_id = ?) AND deleted_at IS NULL", {identifier, userId})
for i=1, #result do
local info = result[i]
-- Check if character has user_id, if not assign it
if not info.user_id and userId then
MySQL.update.await("UPDATE nd_characters SET user_id = ? WHERE charid = ?", {userId, info.charid})
info.user_id = userId
end
characters[info.charid] = createCharacterTable({
source = src,
id = info.charid,
identifier = info.identifier,
user_id = info.user_id,
name = info.name,
firstname = info.firstname,
lastname = info.lastname,
@@ -606,3 +691,30 @@ function NDCore.setActiveCharacter(src, id)
character.active()
return character
end
-- runs every 10 minutes, saves all characters and money logs.
lib.cron.new("*/10 * * * *", function()
local players = NDCore.getPlayers()
for _, player in pairs(players) do
player.saveLastLocation()
player.save()
end
if #moneyLogs == 0 then return end
local placeholders = {}
local params = {}
local logs = moneyLogs
moneyLogs = {}
for i=1, #logs do
local row = logs[i]
for j = 1, 6 do
table.insert(params, row[j])
end
table.insert(placeholders, "(?, ?, ?, ?, ?, ?)")
end
local query = "INSERT INTO nd_money_log (character_id, action, account, amount, reason, trace) VALUES " .. table.concat(placeholders, ",")
MySQL.prepare(query, params)
end)

View File

@@ -84,11 +84,14 @@ local function getVehicleDatabaseInfo(vehicle)
if not vehicle then return end
local stored = vehicle.stored == 1
local impounded = vehicle.impounded == 1
local properties = json.decode(vehicle.properties) or {}
properties.plate = vehicle.plate
return {
id = vehicle.id,
owner = vehicle.owner,
plate = vehicle.plate,
properties = json.decode(vehicle.properties) or {},
properties = properties,
stored = stored,
impounded = impounded,
stolen = vehicle.stolen == 1,
@@ -97,6 +100,14 @@ local function getVehicleDatabaseInfo(vehicle)
}
end
function NDCore.generateVehiclePlate()
return generatePlate()
end
function NDCore.generateUniqueVehiclePlate()
return generateVehiclePlate()
end
--- get vehicle information queried from the database by the vehicle id
---@param vehicleId string | numb
---@return table | nil
@@ -124,19 +135,44 @@ function NDCore.getVehicle(entity)
netId = NetworkGetNetworkIdFromEntity(entity)
}
-- save properties to db
function self.saveProperties(props, propsToSave)
local properties = {}
local vehProps = props or lib.callback.await("ND_Vehicles:getProps", NetworkGetEntityOwner(entity), self.netId)
if propsToSave then
properties = self.properties
if vehProps then
for prop, value in pairs(vehProps) do
if lib.table.contains(propsToSave, prop) then
properties[prop] = value
end
end
end
else
properties = vehProps
end
if self.properties?.callsign then
properties.callsign = true
end
self.properties = properties
state.props = properties
MySQL.query("UPDATE nd_vehicles SET properties = ? WHERE id = ?", {json.encode(properties), self.id})
end
--- delete the vehicle
function self.delete(saveProperties)
function self.delete(saveProperties, propsToSave)
if not DoesEntityExist(entity) then return end
if saveProperties and self.id and self.owner then
local properties = lib.callback.await("ND_Vehicles:getProps", NetworkGetEntityOwner(entity), self.netId)
if properties then
if self.properties?.callsign then
properties.callsign = true
end
MySQL.query("UPDATE nd_vehicles SET properties = ? WHERE id = ?", {json.encode(properties), self.id})
end
self.saveProperties(false, propsToSave)
end
DeleteEntity(entity)
if DoesEntityExist(entity) then
DeleteEntity(entity)
end
return true
end
@@ -159,11 +195,12 @@ function NDCore.getVehicle(entity)
properties.callsign = true
end
state.allPropsApplied = false
state.props = properties
self.properties = properties
if not self.id or not self.owner then return end
MySQL.query("UPDATE nd_vehicles SET properties = ? WHERE id = ?", {json.encode(properties), self.id})
self.saveProperties(properties)
end
--- set vehicle locked/unlocked
@@ -183,6 +220,7 @@ function NDCore.getVehicle(entity)
if self.properties then
self.properties.plate = plate
local state = Entity(entity).state
state.allPropsApplied = false
state.props = self.properties
end
@@ -194,9 +232,9 @@ function NDCore.getVehicle(entity)
--- set the vehicle availability status
---@param statusType string
---@param status boolean
function self.setStatus(statusType, status)
function self.setStatus(statusType, status, keepEntity)
if not lib.table.contains({"stored", "impounded", "stolen"}, statusType) then return end
if statusType ~= "stolen" then self.delete(true) end
if statusType ~= "stolen" and not keepEntity then self.delete(true) end
if not self.id or not self.owner then return end
local query = ("UPDATE nd_vehicles SET %s = ? WHERE id = ?"):format(statusType)
MySQL.query(query, {status and 1 or 0, self.id})
@@ -271,14 +309,15 @@ function NDCore.giveVehicleAccess(source, vehicle, access, info)
end
if not inventoryStarted or not Config.useInventoryForKeys then return end
local plate = info?.plate or GetVehicleNumberPlateText(vehicle)
local model = info?.model or GetEntityModel(vehicle)
local modelName = info?.modelName or model and lib.callback.await("ND_Vehicles:getVehicleModelMakeLabel", source, model) or ""
local hasKey = ox_inventory:GetSlotIdWithItem(source, "keys", {
vehId = vehicleId
})
if access and not hasKey then
local plate = info?.plate or GetVehicleNumberPlateText(vehicle)
local model = info?.model or GetEntityModel(vehicle)
local modelName = info?.modelName or model and lib.callback.await("ND_Vehicles:getVehicleModelMakeLabel", source, model) or ""
ox_inventory:AddItem(source, "keys", 1, {
vehOwner = owner or state.owner,
vehId = vehicleId,
@@ -328,17 +367,24 @@ function NDCore.createVehicle(info)
state.locked = true
local keys = info.keys or {}
if not properties.plate then
properties.plate = generateVehiclePlate()
if not properties.plate or info.plate then
properties.plate = info.plate or generateVehiclePlate()
end
if owner then
keys[owner] = true
state.owner = owner
end
if info.blipGroups then
state.blipGroups = info.blipGroups
end
state.keys = keys
state.props = properties
state.id = vehicleId
if not info.ignoreProps then
state.props = properties
end
local vehicleName
if inventoryStarted and Config.useInventoryForKeys then
for charId, _ in pairs(keys) do
@@ -354,7 +400,7 @@ function NDCore.createVehicle(info)
NDCore.giveVehicleAccess(playerSource, veh, true, {
vehicleId = vehicleId,
netId = netId,
plate = properties?.plate,
plate = info.plate or properties?.plate,
model = model,
vehicleName = vehicleName,
owner = owner
@@ -363,6 +409,13 @@ function NDCore.createVehicle(info)
end
end
for i=1, 3 do
if DoesEntityExist(veh) then
SetVehicleNumberPlateText(veh, properties.plate)
end
Wait(500)
end
return NDCore.getVehicle(veh)
end
@@ -423,8 +476,8 @@ end
---@param properties table
---@param stored boolean
---@return vehicleId number
function NDCore.setVehicleOwned(playerId, properties, stored)
local plate = generateVehiclePlate()
function NDCore.setVehicleOwned(playerId, properties, stored, setPlate)
local plate = setPlate or generateVehiclePlate()
properties.plate = plate
return MySQL.insert.await("INSERT INTO nd_vehicles (owner, plate, properties, stored) VALUES (?, ?, ?, ?)", {playerId, plate, json.encode(properties), stored and 1 or 0})
end
@@ -464,11 +517,11 @@ end
---@param vehicleId number
---@param coords vector4
---@return table
function NDCore.spawnOwnedVehicle(source, vehicleId, coords, heading)
function NDCore.spawnOwnedVehicle(source, vehicleId, coords, heading, _vehicle)
local player = NDCore.getPlayer(source)
if not player then return end
local vehicle = NDCore.getVehicleById(vehicleId)
local vehicle = _vehicle or NDCore.getVehicleById(vehicleId)
if not vehicle or vehicle.owner ~= player.id then return end
if not vehicle.available and not vehicle.impounded then return end
@@ -577,6 +630,16 @@ local function lockNearestVehicle(source, vehId, metadata)
end
end
local function hasBlipGroup(player, groups)
groups = groups or {}
for i=1, #groups do
local group = groups[i]
if player.groups[group] then
return true
end
end
end
--- inventory keys using item.
exports("keys", function(event, item, inventory, slot, data)
if event ~= "usingItem" or not Config.useInventoryForKeys or not inventoryStarted then return end
@@ -603,7 +666,9 @@ RegisterCommand("getkeys", function(source, args, rawCommand)
local player = NDCore.getPlayer(source)
local state = Entity(veh).state
local owner = state.owner
if not owner or owner ~= player.id then return end
local blipGroups = state.blipGroups
if not state.id or (not owner or owner ~= player.id) and (not hasBlipGroup(player, blipGroups)) then return end
local props = state.props
ox_inventory:AddItem(source, "keys", 1, {
@@ -650,32 +715,32 @@ RegisterNetEvent("ND_Vehicles:toggleVehicleLock", function(netId)
end)
-- locking of npc vehicles, if the players spawns inside a vehicle it won't be locked.
AddEventHandler("entityCreated", function(entity)
local time = os.time()
while not DoesEntityExist(entity) and os.time()-time < 5 do Wait(50) end
-- AddEventHandler("entityCreated", function(entity)
-- local time = os.time()
-- while not DoesEntityExist(entity) and os.time()-time < 5 do Wait(50) end
if not DoesEntityExist(entity) or GetEntityType(entity) ~= 2 then return end
-- if not DoesEntityExist(entity) or GetEntityType(entity) ~= 2 then return end
local state = Entity(entity).state
if state.owner or state.locked ~= nil then return end
-- local state = Entity(entity).state
-- if state.owner or state.locked ~= nil then return end
time = os.time()
local driver = GetPedInVehicleSeat(entity, -1)
while DoesEntityExist(entity) and driver == 0 and os.time()-time < 2 do
driver = GetPedInVehicleSeat(entity, -1)
Wait(100)
end
-- time = os.time()
-- local driver = GetPedInVehicleSeat(entity, -1)
-- while DoesEntityExist(entity) and driver == 0 and os.time()-time < 2 do
-- driver = GetPedInVehicleSeat(entity, -1)
-- Wait(100)
-- end
if not DoesEntityExist(entity) then return end
-- if not DoesEntityExist(entity) then return end
if DoesEntityExist(driver) and IsPedAPlayer(driver) then
state.locked = false
state.hotwired = true
end
-- if DoesEntityExist(driver) and IsPedAPlayer(driver) then
-- state.locked = false
-- state.hotwired = true
-- end
if math.random(1, 100) <= Config.randomUnlockedVehicleChance then return end
state.locked = true
end)
-- if math.random(1, 100) <= Config.randomUnlockedVehicleChance then return end
-- state.locked = true
-- end)
-- disables inventory vehicles keys, disabled vehicles keys can no longer be used. Kinda like taking the battery out.
RegisterNetEvent("ND_Vehicles:disableKey", function(slot)
@@ -717,72 +782,12 @@ RegisterNetEvent("ND_Vehicles:hotwire", function(netId)
state.hotwired = true
end)
RegisterNetEvent("ND_Vehicles:storeVehicle", function(netId)
local src = source
local vehicle = NDCore.getVehicle(NetworkGetEntityFromNetworkId(netId))
if not vehicle then return end
RegisterNetEvent("ND_Vehicles:propsApplied", function(netId)
local veh = NetworkGetEntityFromNetworkId(netId)
if not veh or not DoesEntityExist(veh) then return end
local player = NDCore.getPlayer(src)
if not vehicle.setStatus("stored", true) or not player or player.id ~= vehicle.owner then
return player.notify({
title = locale("garage"),
description = locale("no_owned_veh_nearby"),
type = "error",
position = "bottom",
duration = 3000
})
end
player.notify({
title = locale("garage"),
description = locale("veh_stored_in_garage"),
type = "success",
position = "bottom",
duration = 3000
})
NDCore.giveVehicleAccess(src, vehicle.entity, false, {
vehicleId = vehicle.id,
netId = vehicle.netId,
owner = vehicle.owner
})
end)
local function isParkingAvailable(locations)
for i=1, #locations do
local loc = locations[math.random(1, #locations)]
if #getNearbyVehicles(vec3(loc.x, loc.y, loc.z), 2.0) == 0 then
return loc
end
end
end
RegisterNetEvent("ND_Vehicles:takeVehicle", function(vehId, locations)
local src = source
local vehicle = NDCore.getVehicleById(vehId)
local player = NDCore.getPlayer(src)
if not player or not vehicle or vehicle.owner ~= player.id then return end
local info = NDCore.spawnOwnedVehicle(src, vehicle.id, isParkingAvailable(locations))
if not info then return end
TriggerClientEvent("ND_Vehicles:blip", src, info.netId, true)
if vehicle.impounded then
local reclaimPrice = vehicle.metadata.impoundReclaimPrice or 200
if not player.deductMoney("bank", reclaimPrice, locale("impound_reclaim")) then
return player.notify({
title = locale("impound"),
description = locale("impound_not_enough", reclaimPrice),
type = "error",
position = "bottom"
})
end
player.notify({
title = locale("impound"),
description = locale("impound_paid", reclaimPrice),
type = "success",
position = "bottom"
})
MySQL.query.await("UPDATE nd_vehicles SET impounded = ? WHERE id = ?", {0, vehicle.id})
end
local state = Entity(veh).state
state:set("allPropsApplied", true, true)
end)
lib.callback.register("ND_Vehicles:getOwnedVehicles", function(src)
@@ -814,6 +819,6 @@ AddEventHandler("ND:characterLoaded", function(player)
if #vehiclesToImpound == 0 then return end
local query = ("UPDATE nd_vehicles SET impounded = ? WHERE owner = ? AND id IN (%s)"):format(table.concat(vehiclesToImpound, ", "))
local query = ("UPDATE nd_vehicles SET stored = ? WHERE owner = ? AND id IN (%s)"):format(table.concat(vehiclesToImpound, ", "))
MySQL.rawExecute(query, {1, player.id})
end)

13
ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="style.css">
<script type="module" src="main.js" defer></script>
<title>Group Management</title>
</head>
<body>
</body>
</html>

283
ui/main.js Normal file
View File

@@ -0,0 +1,283 @@
import van from "https://cdn.jsdelivr.net/gh/vanjs-org/van/public/van-1.5.3.min.js";
import { nuiMessage } from "./modules/listener.js";
import { fetchCallback } from "./modules/fetch.js";
import { closeUI, setOpen } from "./modules/close.js";
const { div, h1, h2, h3, p, button, input, label, span, i: icon } = van.tags;
let groups = [];
let container = null;
let editPanel = null;
let currentEdit = null;
function createGroupCard(group) {
const card = div({ class: "group-card", onclick: () => openEditPanel(group) },
div({ class: "group-card-header" },
h3(group.label),
span({ class: "group-name" }, group.name)
),
div({ class: "group-card-tags" },
group.isJob ? span({ class: "tag tag-job" }, "Job") : null,
span({ class: "tag tag-ranks" }, `${group.ranks.length} rank${group.ranks.length !== 1 ? "s" : ""}`)
)
);
return card;
}
function renderGroupList() {
if (!container) return;
container.innerHTML = "";
const header = div({ class: "list-header" },
h1("Group Management"),
button({ class: "btn btn-create", onclick: () => openCreatePanel() },
icon({ class: "fa-solid fa-plus" }),
" New Group"
)
);
container.appendChild(header);
const searchRow = div({ class: "search-row" },
input({ class: "search-input", type: "text", placeholder: "Search groups...", oninput: (e) => filterGroups(e.target.value) })
);
container.appendChild(searchRow);
const grid = div({ class: "group-grid", id: "group-grid" });
groups.forEach(g => grid.appendChild(createGroupCard(g)));
container.appendChild(grid);
}
function filterGroups(query) {
const grid = document.getElementById("group-grid");
if (!grid) return;
grid.innerHTML = "";
const lower = query.toLowerCase();
const filtered = groups.filter(g => g.name.toLowerCase().includes(lower) || g.label.toLowerCase().includes(lower));
filtered.forEach(g => grid.appendChild(createGroupCard(g)));
}
function createRankRow(rank, index, ranksList) {
const row = div({ class: "rank-row", "data-index": index },
div({ class: "rank-order" },
button({ class: "btn-icon", title: "Move up", onclick: () => moveRank(ranksList, index, -1) },
icon({ class: "fa-solid fa-chevron-up" })
),
span({ class: "rank-weight" }, String(rank.weight)),
button({ class: "btn-icon", title: "Move down", onclick: () => moveRank(ranksList, index, 1) },
icon({ class: "fa-solid fa-chevron-down" })
)
),
input({ class: "rank-label-input", type: "text", value: rank.label, placeholder: "Rank label", oninput: (e) => {
ranksList[index].label = e.target.value;
}}),
div({ class: "rank-boss-toggle" },
label({ class: "checkbox-label" },
input({ type: "checkbox", checked: rank.isBoss, onchange: (e) => {
ranksList[index].isBoss = e.target.checked;
}}),
" Boss"
)
),
button({ class: "btn-icon btn-danger", title: "Delete rank", onclick: () => {
ranksList.splice(index, 1);
recalculateWeights(ranksList);
rerenderRanks(ranksList);
}},
icon({ class: "fa-solid fa-trash" })
)
);
return row;
}
function moveRank(ranksList, index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= ranksList.length) return;
const temp = ranksList[index];
ranksList[index] = ranksList[newIndex];
ranksList[newIndex] = temp;
recalculateWeights(ranksList);
rerenderRanks(ranksList);
}
function recalculateWeights(ranksList) {
for (let i = 0; i < ranksList.length; i++) {
ranksList[i].weight = i + 1;
}
}
function rerenderRanks(ranksList) {
const ranksContainer = document.getElementById("ranks-container");
if (!ranksContainer) return;
ranksContainer.innerHTML = "";
ranksList.forEach((rank, idx) => {
ranksContainer.appendChild(createRankRow(rank, idx, ranksList));
});
}
function openEditPanel(group) {
currentEdit = JSON.parse(JSON.stringify(group));
showEditPanel(false);
}
function openCreatePanel() {
currentEdit = { name: "", label: "", isJob: false, ranks: [] };
showEditPanel(true);
}
function showEditPanel(isNew) {
if (!editPanel) return;
editPanel.innerHTML = "";
editPanel.classList.add("open");
const ranksList = currentEdit.ranks.map(r => ({ ...r }));
const panelContent = div({ class: "edit-content" },
div({ class: "edit-header" },
h2(isNew ? "Create Group" : "Edit Group"),
button({ class: "btn-icon", onclick: () => closeEditPanel() },
icon({ class: "fa-solid fa-xmark" })
)
),
div({ class: "edit-form" },
div({ class: "form-group" },
label("Name (unique key)"),
input({
class: "form-input",
type: "text",
value: currentEdit.name,
disabled: !isNew,
placeholder: "e.g. polis",
oninput: (e) => { currentEdit.name = e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""); e.target.value = currentEdit.name; }
})
),
div({ class: "form-group" },
label("Label"),
input({
class: "form-input",
type: "text",
value: currentEdit.label,
placeholder: "e.g. Polis",
oninput: (e) => { currentEdit.label = e.target.value; }
})
),
div({ class: "form-group form-row" },
label({ class: "checkbox-label" },
input({
type: "checkbox",
checked: currentEdit.isJob,
onchange: (e) => { currentEdit.isJob = e.target.checked; }
}),
" Is Job"
)
),
div({ class: "form-group" },
div({ class: "ranks-header" },
h3("Ranks"),
button({ class: "btn btn-small", onclick: () => {
ranksList.push({ label: "", weight: ranksList.length + 1, isBoss: false });
rerenderRanks(ranksList);
}},
icon({ class: "fa-solid fa-plus" }),
" Add Rank"
)
),
div({ class: "ranks-info" },
p("Rank 1 is the lowest. Higher weight = higher rank. Boss ranks grant boss permissions.")
),
div({ id: "ranks-container", class: "ranks-container" })
)
),
div({ class: "edit-actions" },
!isNew ? button({ class: "btn btn-danger", onclick: () => deleteGroup(currentEdit.name) },
icon({ class: "fa-solid fa-trash" }),
" Delete Group"
) : null,
div({ class: "edit-actions-right" },
button({ class: "btn btn-secondary", onclick: () => closeEditPanel() }, "Cancel"),
button({ class: "btn btn-primary", onclick: () => saveGroup(isNew, ranksList) },
icon({ class: "fa-solid fa-floppy-disk" }),
" Save"
)
)
)
);
editPanel.appendChild(panelContent);
rerenderRanks(ranksList);
}
function closeEditPanel() {
if (!editPanel) return;
editPanel.classList.remove("open");
editPanel.innerHTML = "";
currentEdit = null;
}
function saveGroup(isNew, ranksList) {
if (!currentEdit.name || !currentEdit.label) return;
const ranks = ranksList.map((r, idx) => ({
label: r.label,
weight: idx + 1,
isBoss: r.isBoss
}));
if (isNew) {
fetchCallback("groups:create", {
name: currentEdit.name,
label: currentEdit.label,
isJob: currentEdit.isJob,
ranks: ranks
}, (result) => {
if (result && result.success) {
groups = result.groups || groups;
renderGroupList();
closeEditPanel();
}
});
} else {
fetchCallback("groups:edit", {
name: currentEdit.name,
label: currentEdit.label,
isJob: currentEdit.isJob,
ranks: ranks
}, (result) => {
if (result && result.success) {
groups = result.groups || groups;
renderGroupList();
closeEditPanel();
}
});
}
}
function deleteGroup(name) {
if (!name) return;
fetchCallback("groups:delete", { name: name }, (result) => {
if (result && result.success) {
groups = result.groups || groups;
renderGroupList();
closeEditPanel();
}
});
}
function init() {
container = div({ class: "main-page" });
editPanel = div({ class: "edit-panel" });
document.body.appendChild(container);
document.body.appendChild(editPanel);
}
nuiMessage("groups:open", (info) => {
setOpen(true);
document.body.style.opacity = "1";
groups = info.groups || [];
renderGroupList();
});
nuiMessage("groups:close", () => {
closeUI();
});
init();

46
ui/modules/close.js Normal file
View File

@@ -0,0 +1,46 @@
import { fetchCallback } from "./fetch.js";
let isOpen = false;
export function closeUI() {
if (!isOpen) return;
isOpen = false;
document.body.style.opacity = "0";
setTimeout(() => {
fetchCallback("hide");
}, 400);
}
export function setOpen(state) {
isOpen = state;
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeUI();
});
document.addEventListener("click", (event) => {
const mainPage = document.querySelector(".main-page");
const editPanel = document.querySelector(".edit-panel");
if (!mainPage || !editPanel) return;
const mainRect = mainPage.getBoundingClientRect();
const editRect = editPanel.getBoundingClientRect();
const insideMainPage =
event.clientX >= mainRect.left &&
event.clientX <= mainRect.right &&
event.clientY >= mainRect.top &&
event.clientY <= mainRect.bottom;
const insideEditPanel =
event.clientX >= editRect.left &&
event.clientX <= editRect.right &&
event.clientY >= editRect.top &&
event.clientY <= editRect.bottom;
if (!insideMainPage && !insideEditPanel) {
closeUI();
}
});

9
ui/modules/fetch.js Normal file
View File

@@ -0,0 +1,9 @@
export function fetchCallback(name, data, cb) {
fetch(`https://${GetParentResourceName()}/${name}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(data)
}).then(resp => resp.json()).then(resp => {
if (cb) cb(resp);
});
}

15
ui/modules/listener.js Normal file
View File

@@ -0,0 +1,15 @@
const nuiMessages = {};
window.addEventListener("message", function(event) {
const item = event.data;
const listener = nuiMessages[item.type];
if (!listener) return;
for (let i = 0; i < listener.length; i++) {
listener[i](item);
}
});
export function nuiMessage(name, cb) {
if (!nuiMessages[name]) nuiMessages[name] = [];
nuiMessages[name].push(cb);
}

455
ui/style.css Normal file
View File

@@ -0,0 +1,455 @@
@import url('https://fonts.googleapis.com/css2?family=Abel&display=swap');
:root {
--font: "Abel", sans-serif;
--bg-primary: #1a1a1bfd;
--bg-secondary: #202020;
--bg-tertiary: #262627;
--bg-hover: #2e2e2f;
--text-primary: #d4d4d3;
--text-secondary: #999;
--accent: #0063b1;
--accent-hover: #0078d4;
--danger: #c42b1c;
--danger-hover: #e03e2d;
--border: #333;
--success: #107c10;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
user-select: none;
opacity: 0;
transition: opacity 0.4s;
font-family: var(--font);
color: var(--text-primary);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
}
.main-page {
position: relative;
background-color: var(--bg-primary);
border-radius: 12px;
width: 60rem;
max-height: 45rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.list-header h1 {
font-size: 1.6rem;
font-weight: 400;
}
.search-row {
margin-bottom: 1rem;
}
.search-input {
width: 100%;
padding: 0.6rem 1rem;
border-radius: 8px;
border: 1px solid var(--border);
background-color: var(--bg-secondary);
color: var(--text-primary);
font-family: var(--font);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: var(--accent);
}
.group-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.75rem;
overflow-y: auto;
padding-right: 0.25rem;
max-height: 35rem;
}
.group-grid::-webkit-scrollbar {
width: 5px;
}
.group-grid::-webkit-scrollbar-track {
background: transparent;
}
.group-grid::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.group-card {
background-color: var(--bg-secondary);
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: background-color 0.2s, transform 0.15s;
border: 1px solid transparent;
}
.group-card:hover {
background-color: var(--bg-tertiary);
border-color: var(--border);
transform: translateY(-1px);
}
.group-card-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.group-card-header h3 {
font-size: 1.15rem;
font-weight: 400;
}
.group-name {
font-size: 0.85rem;
color: var(--text-secondary);
}
.group-card-tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.tag {
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
border-radius: 4px;
background-color: var(--bg-tertiary);
color: var(--text-secondary);
}
.tag-job {
background-color: rgba(0, 99, 177, 0.25);
color: var(--accent-hover);
}
.tag-ranks {
background-color: rgba(16, 124, 16, 0.2);
color: #4caf50;
}
/* Edit Panel */
.edit-panel {
position: fixed;
top: 0;
right: -35rem;
width: 35rem;
height: 100vh;
background-color: var(--bg-primary);
border-left: 1px solid var(--border);
transition: right 0.3s ease;
z-index: 100;
display: flex;
flex-direction: column;
}
.edit-panel.open {
right: 0;
}
.edit-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 1.5rem;
}
.edit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.edit-header h2 {
font-size: 1.4rem;
font-weight: 400;
}
.edit-form {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
padding-right: 0.25rem;
}
.edit-form::-webkit-scrollbar {
width: 5px;
}
.edit-form::-webkit-scrollbar-track {
background: transparent;
}
.edit-form::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-group > label {
font-size: 0.9rem;
color: var(--text-secondary);
}
.form-row {
flex-direction: row;
align-items: center;
}
.form-input {
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border);
background-color: var(--bg-secondary);
color: var(--text-primary);
font-family: var(--font);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.form-input:focus {
border-color: var(--accent);
}
.form-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
font-size: 0.95rem;
}
.checkbox-label input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--accent);
cursor: pointer;
}
/* Ranks */
.ranks-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.ranks-header h3 {
font-size: 1.1rem;
font-weight: 400;
}
.ranks-info {
margin-bottom: 0.5rem;
}
.ranks-info p {
font-size: 0.8rem;
color: var(--text-secondary);
}
.ranks-container {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.rank-row {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--bg-secondary);
padding: 0.5rem 0.6rem;
border-radius: 8px;
transition: background-color 0.15s;
}
.rank-row:hover {
background-color: var(--bg-tertiary);
}
.rank-order {
display: flex;
align-items: center;
gap: 0.15rem;
min-width: 4rem;
justify-content: center;
}
.rank-weight {
font-size: 0.85rem;
color: var(--text-secondary);
min-width: 1.5rem;
text-align: center;
}
.rank-label-input {
flex: 1;
padding: 0.35rem 0.6rem;
border-radius: 5px;
border: 1px solid var(--border);
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
.rank-label-input:focus {
border-color: var(--accent);
}
.rank-boss-toggle {
min-width: 3.5rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 1rem;
border: none;
border-radius: 7px;
cursor: pointer;
font-family: var(--font);
font-size: 0.95rem;
transition: background-color 0.2s, opacity 0.2s;
}
.btn:hover {
opacity: 0.9;
}
.btn-primary {
background-color: var(--accent);
color: var(--text-primary);
}
.btn-primary:hover {
background-color: var(--accent-hover);
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover {
background-color: var(--bg-hover);
}
.btn-create {
background-color: var(--accent);
color: var(--text-primary);
}
.btn-create:hover {
background-color: var(--accent-hover);
}
.btn-danger {
background-color: var(--danger);
color: var(--text-primary);
}
.btn-danger:hover {
background-color: var(--danger-hover);
}
.btn-small {
padding: 0.3rem 0.7rem;
font-size: 0.85rem;
background-color: var(--accent);
color: var(--text-primary);
}
.btn-icon {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.2rem;
font-size: 0.85rem;
border-radius: 4px;
transition: color 0.2s, background-color 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
}
.btn-icon:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
.btn-icon.btn-danger {
background: none;
color: var(--text-secondary);
}
.btn-icon.btn-danger:hover {
color: var(--danger-hover);
background-color: rgba(196, 43, 28, 0.15);
}
/* Edit Actions */
.edit-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid var(--border);
margin-top: 1rem;
}
.edit-actions-right {
display: flex;
gap: 0.5rem;
}