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, deathCause = deathCause,
killerServerId = killerServerId, killerServerId = killerServerId,
killerClientId = killerClientId, killerClientId = killerClientId,
damagedBones = usingAmbulance and ambulance:getBodyDamage() or {} damagedBones = usingAmbulance and ambulance:getBodyDamage() or {},
timestamp = GetCloudTimeAsInt()
} }
TriggerEvent("ND:playerEliminated", info) TriggerEvent("ND:playerEliminated", info)
TriggerServerEvent("ND:playerEliminated", info) TriggerServerEvent("ND:playerEliminated", info)

View File

@@ -4,27 +4,35 @@ end)
-- updates the money on the client. -- updates the money on the client.
RegisterNetEvent("ND:updateMoney", function(cash, bank) 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.cash = cash
NDCore.player.bank = bank NDCore.player.bank = bank
end) end)
-- Sets main character. -- Sets main character.
RegisterNetEvent("ND:characterLoaded", function(character) RegisterNetEvent("ND:characterLoaded", function(character)
if source == "" then return end
NDCore.player = character NDCore.player = character
end) end)
-- Update main character info. -- Update main character info.
RegisterNetEvent("ND:updateCharacter", function(character) RegisterNetEvent("ND:updateCharacter", function(character)
if source == "" or not NDCore.player then return end
NDCore.player = character NDCore.player = character
end) end)
-- Updates last lcoation. -- Updates last lcoation.
RegisterNetEvent("ND:updateLastLocation", function(location) 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 NDCore.player.lastLocation = location
end) 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() RegisterNetEvent("ND:revivePlayer", function()
if source == "" then return end if source == "" then return end
local oldPed = cache.ped local oldPed = cache.ped
@@ -57,8 +65,6 @@ RegisterNetEvent("ND:revivePlayer", function()
end end
end) end)
RegisterNetEvent("ND:characterUnloaded")
RegisterNetEvent("ND:clothingMenu", function() RegisterNetEvent("ND:clothingMenu", function()
if GetResourceState("fivem-appearance") ~= "started" then return end if GetResourceState("fivem-appearance") ~= "started" then return end
@@ -77,3 +83,10 @@ RegisterNetEvent("ND:clothingMenu", function()
tattoos = true tattoos = true
}) })
end) 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 end
local function getVehFromNetId(netId) local function getVehFromNetId(netId)
local time = GetCloudTimeAsInt() local startTime = GetCloudTimeAsInt()
while not NetworkDoesNetworkIdExist(netId) or not NetworkDoesEntityExistWithNetworkId(netId) and time-GetCloudTimeAsInt() < 5 do while (not NetworkDoesNetworkIdExist(netId) or not NetworkDoesEntityExistWithNetworkId(netId)) and (GetCloudTimeAsInt() - startTime < 5) do
Wait(100) Wait(100)
end end
return NetToVeh(netId) return NetToVeh(netId)
end end
RegisterNetEvent("ND_Vehicles:blip", function(netId, status)
local veh = getVehFromNetId(netId) local function setVehicleBlip(veh, status, groupVehicle)
if not veh then return end local currentBlip = GetBlipFromEntity(veh)
if not status then if currentBlip and DoesBlipExist(currentBlip) then
local blip = GetBlipFromEntity(veh) RemoveBlip(currentBlip)
if not blip or not DoesBlipExist(blip) then return end
return RemoveBlip(blip)
end end
if not status then return end
local blip = AddBlipForEntity(veh) local blip = AddBlipForEntity(veh)
SetBlipSprite(blip, getVehicleBlipSprite(veh)) SetBlipSprite(blip, getVehicleBlipSprite(veh))
SetBlipColour(blip, 0) SetBlipColour(blip, 0)
SetBlipScale(blip, 0.8) SetBlipScale(blip, 0.8)
SetBlipAsShortRange(blip, true) SetBlipAsShortRange(blip, true)
BeginTextCommandSetBlipName("STRING") BeginTextCommandSetBlipName("STRING")
AddTextComponentSubstringPlayerName(locale("personal_vehicle")) AddTextComponentSubstringPlayerName(groupVehicle and locale("group_vehicle") or locale("personal_vehicle"))
EndTextCommandSetBlipName(blip) 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) end)
RegisterNetEvent("ND_Vehicles:syncAlarm", function(netId) RegisterNetEvent("ND_Vehicles:syncAlarm", function(netId)
@@ -284,13 +290,39 @@ RegisterNetEvent("ND_VehicleSystem:setOwnedIfNot", function(netId)
end) end)
AddStateBagChangeHandler("props", nil, function(bagName, key, value, reserved, replicated) AddStateBagChangeHandler("props", nil, function(bagName, key, value, reserved, replicated)
local entity = GetEntityFromStateBagName(bagName) if not value then return end
if not value or not DoesEntityExist(entity) or NetworkGetEntityOwner(entity) ~= cache.playerId then return end
local props = value local props = value
if type(value) == "string" then if type(value) == "string" then
props = json.decode(value) props = json.decode(value)
end 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) 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) end)
local playingKey = 0 local playingKey = 0
@@ -411,6 +443,17 @@ local function hasVehicleKeys(veh, checkEngine)
return hasKey or checkEngine and state.hotwired return hasKey or checkEngine and state.hotwired
end 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 function hasVehicleKeysCheck(veh)
local time = GetCloudTimeAsInt() local time = GetCloudTimeAsInt()
if time-keyCheckTime.lastCheck < 5 then if time-keyCheckTime.lastCheck < 5 then
@@ -526,6 +569,33 @@ CreateThread(function()
end end
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 function hotwireVehicle()
local state = playerVehicle and Entity(playerVehicle).state local state = playerVehicle and Entity(playerVehicle).state
if not playerVehicle or state.hotwired then return end if not playerVehicle or state.hotwired then return end
@@ -597,34 +667,51 @@ local function lockpickVehicle()
local veh = lib.getClosestVehicle(pos, 2.5, false) local veh = lib.getClosestVehicle(pos, 2.5, false)
if not veh then return end if not veh then return end
local dificulties = { local lockPickResource = GetResourceState("lockpick") == "started" and "lockpick" or GetResourceState("t3_lockpick") == "started" and "t3_lockpick"
"easy", if lockPickResource then
"medium", local success = exports[lockPickResource]:startLockpick("lockpick_vehicle", 4, Config.lockpickTries)
"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 if not success or not DoesEntityExist(veh) or #(pos-GetEntityCoords(veh)) > 2.5 then
TriggerServerEvent("ND_Vehicles:lockpick", VehToNet(veh), false) 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 return false, true
end 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 end
veh = lib.getClosestVehicle(pos, 2.5, false) veh = lib.getClosestVehicle(pos, 2.5, false)
if not veh then return false, true end if not veh then return false, true end
TriggerServerEvent("ND_Vehicles:lockpick", VehToNet(veh), true) TriggerServerEvent("ND_Vehicles:lockpick", VehToNet(veh), true)
PlaySoundFromEntity(-1, "Remote_Control_Fob", cache.ped, "PI_Menu_Sounds", true, 0) 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 end
exports("lockpick", function(data, slot) 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` ( CREATE TABLE IF NOT EXISTS `nd_characters` (
`charid` INT(10) NOT NULL AUTO_INCREMENT, `charid` INT(10) NOT NULL AUTO_INCREMENT,
`user_id` INT(11) DEFAULT NULL,
`identifier` VARCHAR(200) NOT NULL DEFAULT '0', `identifier` VARCHAR(200) NOT NULL DEFAULT '0',
`name` VARCHAR(50) DEFAULT NULL, `name` VARCHAR(50) DEFAULT NULL,
`firstname` 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', `cash` INT(10) DEFAULT '0',
`bank` INT(10) DEFAULT '0', `bank` INT(10) DEFAULT '0',
`phonenumber` VARCHAR(20) DEFAULT NULL, `phonenumber` VARCHAR(20) DEFAULT NULL,
`deleted_at` DATETIME NULL DEFAULT NULL,
`groups` LONGTEXT DEFAULT ('[]'), `groups` LONGTEXT DEFAULT ('[]'),
`metadata` LONGTEXT DEFAULT ('[]'), `metadata` LONGTEXT DEFAULT ('[]'),
`inventory` 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" game "gta5"
lua54 "yes" lua54 "yes"
files {
"init.lua",
"compatibility/**/locale.lua",
"locales/*.json",
"ui/**"
}
ui_page "ui/index.html"
shared_script "@ox_lib/init.lua" shared_script "@ox_lib/init.lua"
client_scripts { client_scripts {
"client/main.lua", "client/main.lua",
"shared/functions.lua", "shared/functions.lua",
"client/peds.lua", "client/peds.lua",
"client/vehicle/main.lua", "client/vehicle.lua",
"client/vehicle/garages.lua",
"client/functions.lua", "client/functions.lua",
"client/events.lua", "client/events.lua",
"client/death.lua", "client/death.lua",
"client/groupadmin.lua",
"compatibility/**/client.lua" "compatibility/**/client.lua"
} }
server_scripts { server_scripts {
@@ -27,17 +36,12 @@ server_scripts {
"server/player.lua", "server/player.lua",
"server/vehicle.lua", "server/vehicle.lua",
"server/functions.lua", "server/functions.lua",
"server/groups.lua",
"server/groupadmin.lua",
"compatibility/**/server.lua", "compatibility/**/server.lua",
"server/commands.lua" "server/commands.lua"
} }
files {
"init.lua",
"client/vehicle/data.lua",
"compatibility/**/locale.lua",
"locales/*.json"
}
dependencies { dependencies {
"ox_lib", "ox_lib",
"oxmysql" "oxmysql"

View File

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

View File

@@ -66,6 +66,7 @@
"view_impounded_vehs": "Visa beslagtagna fordon", "view_impounded_vehs": "Visa beslagtagna fordon",
"view_garage": "Visa garage", "view_garage": "Visa garage",
"personal_vehicle": "Personligt fordon", "personal_vehicle": "Personligt fordon",
"group_vehicle": "Fordon",
"keybind_carkey": "Lås/öppna fordon (dubbelklick)", "keybind_carkey": "Lås/öppna fordon (dubbelklick)",
"progress_hotwiring": "Kopplar om", "progress_hotwiring": "Kopplar om",
"cruise_control": "Farthållare", "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 = {} PlayersInfo = {}
local resourceName = GetCurrentResourceName() local resourceName = GetCurrentResourceName()
local tempPlayersInfo = {} local tempPlayersInfo = {}
-- Groups are now loaded from the database in MySQL.ready via NDCore.loadGroups()
Config = { Config = {
serverName = GetConvar("core:serverName", "Unconfigured ND-Core Server"), serverName = GetConvar("core:serverName", "Unconfigured ND-Core Server"),
@@ -17,12 +18,13 @@ Config = {
discordActionText2 = GetConvar("core:discordActionText2", "STORE"), discordActionText2 = GetConvar("core:discordActionText2", "STORE"),
discordActionLink2 = GetConvar("core:discordActionLink2", "https://andyyy.tebex.io/category/fivem-scripts"), discordActionLink2 = GetConvar("core:discordActionLink2", "https://andyyy.tebex.io/category/fivem-scripts"),
characterIdentifier = GetConvar("core:characterIdentifier", "license"), characterIdentifier = GetConvar("core:characterIdentifier", "license"),
selectIdentifiers = json.decode(GetConvar("core:selectIdentifiers", '["discord", "license", "license2", "fivem"]')),
discordGuildId = GetConvar("core:discordGuildId", "false"), discordGuildId = GetConvar("core:discordGuildId", "false"),
discordBotToken = GetConvar("core:discordBotToken", "false"), discordBotToken = GetConvar("core:discordBotToken", "false"),
randomUnlockedVehicleChance = GetConvarInt("core:randomUnlockedVehicleChance", 30), randomUnlockedVehicleChance = GetConvarInt("core:randomUnlockedVehicleChance", 30),
disableVehicleAirControl = GetConvarInt("core:disableVehicleAirControl", 1) == 1, disableVehicleAirControl = GetConvarInt("core:disableVehicleAirControl", 1) == 1,
useInventoryForKeys = GetConvarInt("core:useInventoryForKeys", 1) == 1, useInventoryForKeys = GetConvarInt("core:useInventoryForKeys", 1) == 1,
groups = json.decode(GetConvar("core:groups", "[]")), groups = {},
admins = json.decode(GetConvar("core:admins", "[]")), admins = json.decode(GetConvar("core:admins", "[]")),
adminDiscordRoles = json.decode(GetConvar("core:adminDiscordRoles", "[]")), adminDiscordRoles = json.decode(GetConvar("core:adminDiscordRoles", "[]")),
groupRoles = json.decode(GetConvar("core:groupRoles", "[]")), groupRoles = json.decode(GetConvar("core:groupRoles", "[]")),
@@ -66,6 +68,71 @@ AddEventHandler("playerJoining", function(oldId)
lib.addPrincipal(("player.%s"):format(src), "group.admin") lib.addPrincipal(("player.%s"):format(src), "group.admin")
end 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 if Config.multiCharacter then return end
Wait(3000) Wait(3000)
@@ -164,14 +231,6 @@ AddEventHandler("onResourceStop", function(name)
end end
end) end)
MySQL.ready(function()
Wait(100)
NDCore.loadSQL({
"database/characters.sql",
"database/vehicles.sql"
}, resourceName)
end)
RegisterNetEvent("ND:playerEliminated", function(info) RegisterNetEvent("ND:playerEliminated", function(info)
local src = source local src = source
local player = NDCore.getPlayer(src) 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 if not player or not clothing or type(clothing) ~= "table" then return end
player.setMetadata("clothing", clothing) player.setMetadata("clothing", clothing)
end) 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 avoidSavingLastLocations = {}
local moneyLogs = {}
local function removeCharacterFunctions(character) local function removeCharacterFunctions(character)
local newData = {} local newData = {}
@@ -10,6 +11,13 @@ local function removeCharacterFunctions(character)
return newData return newData
end 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 function createCharacterTable(info)
local playerInfo = PlayersInfo[info.source] or {} local playerInfo = PlayersInfo[info.source] or {}
@@ -17,6 +25,7 @@ local function createCharacterTable(info)
id = info.id, id = info.id,
source = info.source, source = info.source,
identifier = info.identifier, identifier = info.identifier,
user_id = info.user_id,
identifiers = playerInfo.identifiers or {}, identifiers = playerInfo.identifiers or {},
discord = playerInfo.discord or {}, discord = playerInfo.discord or {},
name = info.name, name = info.name,
@@ -45,6 +54,9 @@ local function createCharacterTable(info)
self.triggerEvent("ND:updateMoney", self.cash, self.bank) self.triggerEvent("ND:updateMoney", self.cash, self.bank)
TriggerEvent("ND:moneyChange", self.source, account, amount, "remove", reason) TriggerEvent("ND:moneyChange", self.source, account, amount, "remove", reason)
end end
logMoney(self.id, "deduct", account, amount, reason, Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString()))
return true return true
end end
@@ -60,6 +72,9 @@ local function createCharacterTable(info)
self.triggerEvent("ND:updateMoney", self.cash, self.bank) self.triggerEvent("ND:updateMoney", self.cash, self.bank)
TriggerEvent("ND:moneyChange", self.source, account, amount, "add", reason) TriggerEvent("ND:moneyChange", self.source, account, amount, "add", reason)
end end
logMoney(self.id, "add", account, amount, reason, Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString()))
return true return true
end end
@@ -106,12 +121,14 @@ local function createCharacterTable(info)
for k, v in pairs(key) do for k, v in pairs(key) do
self[k] = v self[k] = v
if k == "cash" or k == "bank" then 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) TriggerEvent("ND:moneyChange", self.source, k, v, "set", reason)
end end
end end
else else
self[key] = value self[key] = value
if key == "cash" or key == "bank" then 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) TriggerEvent("ND:moneyChange", self.source, key, value, "set", reason)
end end
end end
@@ -135,14 +152,42 @@ local function createCharacterTable(info)
return self.metadata return self.metadata
end end
-- Completely delete character -- Mark character deleted
function self.delete() 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 if result and NDCore.players[self.source] then
NDCore.players[self.source] = nil NDCore.players[self.source] = nil
end end
return result return result
end 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 -- Unload and save character
function self.unload() function self.unload()
@@ -152,29 +197,7 @@ local function createCharacterTable(info)
lib.removePrincipal(self.source, ("group.%s"):format(name)) lib.removePrincipal(self.source, ("group.%s"):format(name))
end end
local ped = GetPlayerPed(self.source) self.saveLastLocation()
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.triggerEvent("ND:characterUnloaded") self.triggerEvent("ND:characterUnloaded")
TriggerEvent("ND:characterUnloaded", self.source, self) TriggerEvent("ND:characterUnloaded", self.source, self)
@@ -201,7 +224,8 @@ local function createCharacterTable(info)
return affectedRows > 0 return affectedRows > 0
end 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.name,
self.firstname, self.firstname,
self.lastname, self.lastname,
@@ -332,17 +356,36 @@ local function createCharacterTable(info)
end end
local roles = self.discord.roles local roles = self.discord.roles
if roles then if roles then
local hasAdmin = false
for i=1, #Config.adminDiscordRoles do for i=1, #Config.adminDiscordRoles do
local role = Config.adminDiscordRoles[i] local role = Config.adminDiscordRoles[i]
if lib.table.contains(roles, role) then if lib.table.contains(roles, role) then
self.addGroup("admin") hasAdmin = true
break
end end
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 local function hasRole(groupRoles)
if lib.table.contains(roles, role) then 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) self.addGroup(group)
elseif self.groups[group] then
self.removeGroup(group)
end end
end end
end end
@@ -361,11 +404,19 @@ local function createCharacterTable(info)
---@return boolean ---@return boolean
function self.addGroup(name, rank, customGroup, isJob) function self.addGroup(name, rank, customGroup, isJob)
local groupRank = tonumber(rank) or 1 local groupRank = tonumber(rank) or 1
local groupInfo = lib.table.deepclone(Config.groups?[name] or {}) local groupInfo = Config.groups?[name] or {}
local bossRank = groupInfo?.minimumBossRank local rankData = groupInfo?.ranksData?[groupRank]
for k, v in pairs(customGroup or {}) do local groupLabel = groupInfo?.label or name
groupInfo[k] = v 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 end
if isJob then if isJob then
@@ -376,12 +427,12 @@ local function createCharacterTable(info)
self.groups[name] = { self.groups[name] = {
name = name, name = name,
label = groupInfo?.label or name, label = groupLabel,
rankName = groupInfo?.ranks?[groupRank] or groupRank, rankName = rankName,
rank = groupRank, rank = groupRank,
isJob = isJob, isJob = isJob,
isBoss = bossRank and groupRank >= bossRank, isBoss = isBoss,
metadata = groupInfo.metadata or {} metadata = metadata
} }
if not isJob then if not isJob then
@@ -389,8 +440,8 @@ local function createCharacterTable(info)
TriggerEvent("ND:updateCharacter", self, "groups") TriggerEvent("ND:updateCharacter", self, "groups")
end end
TriggerEvent("ND:groupAdded", self, self.groups[name])
lib.addPrincipal(self.source, ("group.%s"):format(name)) lib.addPrincipal(self.source, ("group.%s"):format(name))
return self.groups[name] return self.groups[name]
end end
@@ -421,13 +472,18 @@ local function createCharacterTable(info)
---@param name string ---@param name string
---@param rank number ---@param rank number
---@return boolean ---@return boolean
function self.setJob(name, rank, customGroup) function self.setJob(name, rank, customGroup, keepGroup)
self.removeGroup(self.job) if not keepGroup then
self.removeGroup(self.job)
end
local job = self.addGroup(name, rank, customGroup, true) local job = self.addGroup(name, rank, customGroup, true)
if job then if job then
self.job = job.name self.job = job.name
self.jobInfo = job self.jobInfo = job
end end
self.triggerEvent("ND:updateCharacter", removeCharacterFunctions(self), "job") self.triggerEvent("ND:updateCharacter", removeCharacterFunctions(self), "job")
TriggerEvent("ND:updateCharacter", self, "job") TriggerEvent("ND:updateCharacter", self, "job")
return job return job
@@ -487,10 +543,13 @@ end
function NDCore.newCharacter(src, info) function NDCore.newCharacter(src, info)
local identifier = NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier) local identifier = NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier)
if not identifier then return end if not identifier then return end
local userId = PlayersInfo[src] and PlayersInfo[src].userId
local charInfo = { local charInfo = {
source = src, source = src,
identifier = identifier, identifier = identifier,
user_id = userId,
name = GetPlayerName(src) or "", name = GetPlayerName(src) or "",
firstname = info.firstname or "", firstname = info.firstname or "",
lastname = info.lastname or "", lastname = info.lastname or "",
@@ -516,8 +575,9 @@ function NDCore.newCharacter(src, info)
return return
end 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, identifier,
userId,
charInfo.name, charInfo.name,
charInfo.firstname, charInfo.firstname,
charInfo.lastname, charInfo.lastname,
@@ -539,17 +599,30 @@ end
function NDCore.fetchCharacter(id, src) function NDCore.fetchCharacter(id, src)
local result local result
if src then 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 else
result = MySQL.query.await("SELECT * FROM nd_characters WHERE charid = ?", {id}) result = MySQL.query.await("SELECT * FROM nd_characters WHERE charid = ?", {id})
end 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({ return createCharacterTable({
source = src, source = src,
id = info.charid, id = info.charid,
identifier = info.identifier, identifier = info.identifier,
user_id = info.user_id,
name = info.name, name = info.name,
firstname = info.firstname, firstname = info.firstname,
lastname = info.lastname, lastname = info.lastname,
@@ -567,15 +640,27 @@ end
---@param src number ---@param src number
---@return table ---@return table
function NDCore.fetchAllCharacters(src) function NDCore.fetchAllCharacters(src)
local userId = PlayersInfo[src] and PlayersInfo[src].userId
local identifier = NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier)
local characters = {} 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 for i=1, #result do
local info = result[i] 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({ characters[info.charid] = createCharacterTable({
source = src, source = src,
id = info.charid, id = info.charid,
identifier = info.identifier, identifier = info.identifier,
user_id = info.user_id,
name = info.name, name = info.name,
firstname = info.firstname, firstname = info.firstname,
lastname = info.lastname, lastname = info.lastname,
@@ -606,3 +691,30 @@ function NDCore.setActiveCharacter(src, id)
character.active() character.active()
return character return character
end 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 if not vehicle then return end
local stored = vehicle.stored == 1 local stored = vehicle.stored == 1
local impounded = vehicle.impounded == 1 local impounded = vehicle.impounded == 1
local properties = json.decode(vehicle.properties) or {}
properties.plate = vehicle.plate
return { return {
id = vehicle.id, id = vehicle.id,
owner = vehicle.owner, owner = vehicle.owner,
plate = vehicle.plate, plate = vehicle.plate,
properties = json.decode(vehicle.properties) or {}, properties = properties,
stored = stored, stored = stored,
impounded = impounded, impounded = impounded,
stolen = vehicle.stolen == 1, stolen = vehicle.stolen == 1,
@@ -97,6 +100,14 @@ local function getVehicleDatabaseInfo(vehicle)
} }
end end
function NDCore.generateVehiclePlate()
return generatePlate()
end
function NDCore.generateUniqueVehiclePlate()
return generateVehiclePlate()
end
--- get vehicle information queried from the database by the vehicle id --- get vehicle information queried from the database by the vehicle id
---@param vehicleId string | numb ---@param vehicleId string | numb
---@return table | nil ---@return table | nil
@@ -124,19 +135,44 @@ function NDCore.getVehicle(entity)
netId = NetworkGetNetworkIdFromEntity(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 --- delete the vehicle
function self.delete(saveProperties) function self.delete(saveProperties, propsToSave)
if not DoesEntityExist(entity) then return end if not DoesEntityExist(entity) then return end
if saveProperties and self.id and self.owner then if saveProperties and self.id and self.owner then
local properties = lib.callback.await("ND_Vehicles:getProps", NetworkGetEntityOwner(entity), self.netId) self.saveProperties(false, propsToSave)
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
end end
DeleteEntity(entity) if DoesEntityExist(entity) then
DeleteEntity(entity)
end
return true
end end
@@ -159,11 +195,12 @@ function NDCore.getVehicle(entity)
properties.callsign = true properties.callsign = true
end end
state.allPropsApplied = false
state.props = properties state.props = properties
self.properties = properties self.properties = properties
if not self.id or not self.owner then return end 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 end
--- set vehicle locked/unlocked --- set vehicle locked/unlocked
@@ -183,6 +220,7 @@ function NDCore.getVehicle(entity)
if self.properties then if self.properties then
self.properties.plate = plate self.properties.plate = plate
local state = Entity(entity).state local state = Entity(entity).state
state.allPropsApplied = false
state.props = self.properties state.props = self.properties
end end
@@ -194,9 +232,9 @@ function NDCore.getVehicle(entity)
--- set the vehicle availability status --- set the vehicle availability status
---@param statusType string ---@param statusType string
---@param status boolean ---@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 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 if not self.id or not self.owner then return end
local query = ("UPDATE nd_vehicles SET %s = ? WHERE id = ?"):format(statusType) local query = ("UPDATE nd_vehicles SET %s = ? WHERE id = ?"):format(statusType)
MySQL.query(query, {status and 1 or 0, self.id}) MySQL.query(query, {status and 1 or 0, self.id})
@@ -271,14 +309,15 @@ function NDCore.giveVehicleAccess(source, vehicle, access, info)
end end
if not inventoryStarted or not Config.useInventoryForKeys then return 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", { local hasKey = ox_inventory:GetSlotIdWithItem(source, "keys", {
vehId = vehicleId vehId = vehicleId
}) })
if access and not hasKey then 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, { ox_inventory:AddItem(source, "keys", 1, {
vehOwner = owner or state.owner, vehOwner = owner or state.owner,
vehId = vehicleId, vehId = vehicleId,
@@ -328,17 +367,24 @@ function NDCore.createVehicle(info)
state.locked = true state.locked = true
local keys = info.keys or {} local keys = info.keys or {}
if not properties.plate then if not properties.plate or info.plate then
properties.plate = generateVehiclePlate() properties.plate = info.plate or generateVehiclePlate()
end end
if owner then if owner then
keys[owner] = true keys[owner] = true
state.owner = owner state.owner = owner
end end
if info.blipGroups then
state.blipGroups = info.blipGroups
end
state.keys = keys state.keys = keys
state.props = properties
state.id = vehicleId state.id = vehicleId
if not info.ignoreProps then
state.props = properties
end
local vehicleName local vehicleName
if inventoryStarted and Config.useInventoryForKeys then if inventoryStarted and Config.useInventoryForKeys then
for charId, _ in pairs(keys) do for charId, _ in pairs(keys) do
@@ -354,7 +400,7 @@ function NDCore.createVehicle(info)
NDCore.giveVehicleAccess(playerSource, veh, true, { NDCore.giveVehicleAccess(playerSource, veh, true, {
vehicleId = vehicleId, vehicleId = vehicleId,
netId = netId, netId = netId,
plate = properties?.plate, plate = info.plate or properties?.plate,
model = model, model = model,
vehicleName = vehicleName, vehicleName = vehicleName,
owner = owner owner = owner
@@ -363,6 +409,13 @@ function NDCore.createVehicle(info)
end end
end end
for i=1, 3 do
if DoesEntityExist(veh) then
SetVehicleNumberPlateText(veh, properties.plate)
end
Wait(500)
end
return NDCore.getVehicle(veh) return NDCore.getVehicle(veh)
end end
@@ -423,8 +476,8 @@ end
---@param properties table ---@param properties table
---@param stored boolean ---@param stored boolean
---@return vehicleId number ---@return vehicleId number
function NDCore.setVehicleOwned(playerId, properties, stored) function NDCore.setVehicleOwned(playerId, properties, stored, setPlate)
local plate = generateVehiclePlate() local plate = setPlate or generateVehiclePlate()
properties.plate = plate 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}) return MySQL.insert.await("INSERT INTO nd_vehicles (owner, plate, properties, stored) VALUES (?, ?, ?, ?)", {playerId, plate, json.encode(properties), stored and 1 or 0})
end end
@@ -464,11 +517,11 @@ end
---@param vehicleId number ---@param vehicleId number
---@param coords vector4 ---@param coords vector4
---@return table ---@return table
function NDCore.spawnOwnedVehicle(source, vehicleId, coords, heading) function NDCore.spawnOwnedVehicle(source, vehicleId, coords, heading, _vehicle)
local player = NDCore.getPlayer(source) local player = NDCore.getPlayer(source)
if not player then return end 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 or vehicle.owner ~= player.id then return end
if not vehicle.available and not vehicle.impounded 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
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. --- inventory keys using item.
exports("keys", function(event, item, inventory, slot, data) exports("keys", function(event, item, inventory, slot, data)
if event ~= "usingItem" or not Config.useInventoryForKeys or not inventoryStarted then return end 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 player = NDCore.getPlayer(source)
local state = Entity(veh).state local state = Entity(veh).state
local owner = state.owner 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 local props = state.props
ox_inventory:AddItem(source, "keys", 1, { ox_inventory:AddItem(source, "keys", 1, {
@@ -650,32 +715,32 @@ RegisterNetEvent("ND_Vehicles:toggleVehicleLock", function(netId)
end) end)
-- locking of npc vehicles, if the players spawns inside a vehicle it won't be locked. -- locking of npc vehicles, if the players spawns inside a vehicle it won't be locked.
AddEventHandler("entityCreated", function(entity) -- AddEventHandler("entityCreated", function(entity)
local time = os.time() -- local time = os.time()
while not DoesEntityExist(entity) and os.time()-time < 5 do Wait(50) end -- 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 -- local state = Entity(entity).state
if state.owner or state.locked ~= nil then return end -- if state.owner or state.locked ~= nil then return end
time = os.time() -- time = os.time()
local driver = GetPedInVehicleSeat(entity, -1) -- local driver = GetPedInVehicleSeat(entity, -1)
while DoesEntityExist(entity) and driver == 0 and os.time()-time < 2 do -- while DoesEntityExist(entity) and driver == 0 and os.time()-time < 2 do
driver = GetPedInVehicleSeat(entity, -1) -- driver = GetPedInVehicleSeat(entity, -1)
Wait(100) -- Wait(100)
end -- end
if not DoesEntityExist(entity) then return end -- if not DoesEntityExist(entity) then return end
if DoesEntityExist(driver) and IsPedAPlayer(driver) then -- if DoesEntityExist(driver) and IsPedAPlayer(driver) then
state.locked = false -- state.locked = false
state.hotwired = true -- state.hotwired = true
end -- end
if math.random(1, 100) <= Config.randomUnlockedVehicleChance then return end -- if math.random(1, 100) <= Config.randomUnlockedVehicleChance then return end
state.locked = true -- state.locked = true
end) -- end)
-- disables inventory vehicles keys, disabled vehicles keys can no longer be used. Kinda like taking the battery out. -- disables inventory vehicles keys, disabled vehicles keys can no longer be used. Kinda like taking the battery out.
RegisterNetEvent("ND_Vehicles:disableKey", function(slot) RegisterNetEvent("ND_Vehicles:disableKey", function(slot)
@@ -717,72 +782,12 @@ RegisterNetEvent("ND_Vehicles:hotwire", function(netId)
state.hotwired = true state.hotwired = true
end) end)
RegisterNetEvent("ND_Vehicles:storeVehicle", function(netId) RegisterNetEvent("ND_Vehicles:propsApplied", function(netId)
local src = source local veh = NetworkGetEntityFromNetworkId(netId)
local vehicle = NDCore.getVehicle(NetworkGetEntityFromNetworkId(netId)) if not veh or not DoesEntityExist(veh) then return end
if not vehicle then return end
local player = NDCore.getPlayer(src) local state = Entity(veh).state
if not vehicle.setStatus("stored", true) or not player or player.id ~= vehicle.owner then state:set("allPropsApplied", true, true)
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
end) end)
lib.callback.register("ND_Vehicles:getOwnedVehicles", function(src) lib.callback.register("ND_Vehicles:getOwnedVehicles", function(src)
@@ -814,6 +819,6 @@ AddEventHandler("ND:characterLoaded", function(player)
if #vehiclesToImpound == 0 then return end 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}) MySQL.rawExecute(query, {1, player.id})
end) 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;
}