Files
ND_Core/server/player.lua
2026-03-20 14:58:54 +01:00

721 lines
23 KiB
Lua

local avoidSavingLastLocations = {}
local moneyLogs = {}
local function removeCharacterFunctions(character)
local newData = {}
for k, v in pairs(character) do
if type(v) ~= "function" then
newData[k] = v
end
end
return newData
end
local function logMoney(charId, action, account, amount, reason, trace)
if not trace or (action == "set" and not reason) then return end
local traceText = trace:match("%((.-)%)")
traceText = traceText and traceText:gsub("%^%d", "") or traceText or ""
table.insert(moneyLogs, { charId, action, account, amount, reason or "[NO REASON GIVEN]", traceText })
end
local function createCharacterTable(info)
local playerInfo = PlayersInfo[info.source] or {}
local self = {
id = info.id,
source = info.source,
identifier = info.identifier,
user_id = info.user_id,
identifiers = playerInfo.identifiers or {},
discord = playerInfo.discord or {},
name = info.name,
firstname = info.firstname,
lastname = info.lastname,
fullname = ("%s %s"):format(info.firstname, info.lastname),
dob = info.dob,
gender = info.gender,
cash = info.cash,
bank = info.bank,
phonenumber = info.phonenumber,
groups = info.groups,
metadata = info.metadata,
inventory = info.inventory
}
---@param account string
---@param amount number
---@param reason string|nil
---@return boolean
function self.deductMoney(account, amount, reason)
local amount = tonumber(amount)
if not amount or amount <= 0 or account ~= "bank" and account ~= "cash" then return end
self[account] -= amount
if NDCore.players[self.source] then
self.triggerEvent("ND:updateMoney", self.cash, self.bank)
TriggerEvent("ND:moneyChange", self.source, account, amount, "remove", reason)
end
logMoney(self.id, "deduct", account, amount, reason, Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString()))
return true
end
---@param account string
---@param amount number
---@param reason string|nil
---@return boolean
function self.addMoney(account, amount, reason)
local amount = tonumber(amount)
if not amount or amount <= 0 or account ~= "bank" and account ~= "cash" then return end
self[account] += amount
if NDCore.players[self.source] then
self.triggerEvent("ND:updateMoney", self.cash, self.bank)
TriggerEvent("ND:moneyChange", self.source, account, amount, "add", reason)
end
logMoney(self.id, "add", account, amount, reason, Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString()))
return true
end
---@param amount number
---@return boolean
function self.depositMoney(amount)
local amount = tonumber(amount)
if not amount or self.cash < amount or amount <= 0 then return end
return self.deductMoney("cash", amount, locale("deposit")) and self.addMoney("bank", amount, locale("deposit"))
end
---@param amount number
---@return boolean
function self.withdrawMoney(amount)
local amount = tonumber(amount)
if not amount or self.bank < amount or amount <= 0 then return end
return self.deductMoney("bank", amount, locale("withdraw")) and self.addMoney("cash", amount, locale("withdraw"))
end
---@param data string
---@return any
function self.getData(data)
return self[data]
end
---@param metadata string|table
---@return any
function self.getMetadata(metadata)
if type(metadata) ~= "table" then
return self.metadata[metadata]
end
local returnData = {}
for i=1, #metadata do
local data = metadata[i]
returnData[data] = self.metadata[data]
end
return returnData
end
---@param key string|table
---@param value any
function self.setData(key, value, reason)
if type(key) == "table" then
for k, v in pairs(key) do
self[k] = v
if k == "cash" or k == "bank" then
logMoney(self.id, "set", k, value, reason, Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString()))
TriggerEvent("ND:moneyChange", self.source, k, v, "set", reason)
end
end
else
self[key] = value
if key == "cash" or key == "bank" then
logMoney(self.id, "set", key, value, reason, Citizen.InvokeNative(`FORMAT_STACK_TRACE` & 0xFFFFFFFF, nil, 0, Citizen.ResultAsString()))
TriggerEvent("ND:moneyChange", self.source, key, value, "set", reason)
end
end
self.triggerEvent("ND:updateCharacter", removeCharacterFunctions(self), key)
TriggerEvent("ND:updateCharacter", self, key)
end
---@param key string|table
---@param value any
---@return table
function self.setMetadata(key, value)
if type(key) == "table" then
for k, v in pairs(key) do
self.metadata[k] = v
end
else
self.metadata[key] = value
end
self.triggerEvent("ND:updateCharacter", removeCharacterFunctions(self), "metadata")
TriggerEvent("ND:updateCharacter", self, "metadata")
return self.metadata
end
-- Mark character deleted
function self.delete()
local result = MySQL.update.await("UPDATE nd_characters SET deleted_at = NOW() WHERE charid = ?", {self.id})
if result and NDCore.players[self.source] then
NDCore.players[self.source] = nil
end
return result
end
--save last location
function self.saveLastLocation()
local ped = GetPlayerPed(self.source)
if not ped or ped == 0 then return end
local coords = GetEntityCoords(ped)
local heading = GetEntityHeading(ped)
local saveLocation = true
for i=1, #avoidSavingLastLocations do
local loc = avoidSavingLastLocations[i]
if #(loc.coords-coords) < loc.dist then
saveLocation = false
end
end
if not saveLocation then return end
self.setMetadata("location", {
x = coords.x,
y = coords.y,
z = coords.z,
w = heading
})
return true
end
-- Unload and save character
function self.unload()
if not NDCore.players[self.source] then return end
for name, _ in pairs(self.groups) do
lib.removePrincipal(self.source, ("group.%s"):format(name))
end
self.saveLastLocation()
self.triggerEvent("ND:characterUnloaded")
TriggerEvent("ND:characterUnloaded", self.source, self)
local saved = self.save()
NDCore.players[self.source] = nil
return saved
end
-- Save character information to database
function self.save(key)
if key then
local charData = self[key]
if not charData or type(charData) == "function" then
return lib.print.error(key, "Is not valid character data.")
end
if type(charData) == "table" then
charData = json.encode(charData)
end
local query = ("UPDATE nd_characters SET `%s` = ? WHERE charid = ?"):format(key)
local affectedRows = MySQL.update.await(query, {charData, self.id})
return affectedRows > 0
end
local affectedRows = MySQL.update.await("UPDATE nd_characters SET user_id = ?, name = ?, firstname = ?, lastname = ?, dob = ?, gender = ?, cash = ?, bank = ?, phonenumber = ?, `groups` = ?, metadata = ? WHERE charid = ?", {
self.user_id,
self.name,
self.firstname,
self.lastname,
self.dob,
self.gender,
self.cash,
self.bank,
self.phonenumber,
json.encode(self.groups),
json.encode(self.metadata),
self.id
})
return affectedRows > 0
end
---Create a license/permit for the character
---@param licenseType string
---@param expire number
function self.createLicense(licenseType, expire)
local expireIn = tonumber(expire) or 2592000
local time = os.time()
local licenses = self.metadata.licenses
local identifier = {}
for i=1, 16 do
identifier[i] = math.random(0, 1) == 1 and string.char(math.random(65, 90)) or math.random(0, 9)
end
local license = {
type = licenseType,
status = "valid",
issued = time,
expires = time+expireIn,
identifier = table.concat(identifier)
}
if licenses then
local found = false
for i=1, #licenses do
if licenses[i].type == licenseType then
self.metadata.licenses[i] = license
found = true
break
end
end
if not found then
self.metadata.licenses[#licenses+1] = license
end
else
self.metadata.licenses = {license}
end
self.triggerEvent("ND:updateCharacter", removeCharacterFunctions(self), "metadata")
TriggerEvent("ND:updateCharacter", self, "metadata")
end
function self.getLicense(identifier)
local licenses = self.metadata.licenses or {}
for i=1, #licenses do
local data = licenses[i]
if data.identifier == identifier then
return data, i
end
end
end
function self.updateLicense(identifier, newData)
local data, i = self.getLicense(identifier)
if not data then return end
for k, v in pairs(newData) do
data[k] = v
end
self.save()
end
---@param coords vector3|vector4
---@return boolean
function self.setCoords(coords)
if not self.source or not coords then return end
local ped = GetPlayerPed(self.source)
if not DoesEntityExist(ped) then return end
SetEntityCoords(ped, coords.x, coords.y, coords.z)
if coords.w then
SetEntityHeading(ped, coords.w)
end
return true
end
---@param eventName string
---@param ... any
---@return boolean
function self.triggerEvent(eventName, ...)
if not self.source then return end
TriggerClientEvent(eventName, self.source, ...)
return true
end
function self.notify(...)
if not self.source then return end
TriggerClientEvent("ox_lib:notify", self.source, ...)
return true
end
function self.revive()
self.triggerEvent("ND:revivePlayer")
self.setMetadata({
dead = false,
deathInfo = false,
})
end
---@param reason string
function self.drop(reason)
if not self.source then return end
DropPlayer(self.source, reason)
end
-- Set the character as the players active character/currently playing character
function self.active()
local char = NDCore.players[self.source]
if char and char.id == self.id then return end
if char then char.unload() end
for identifierType, identifier in pairs(self.identifiers) do
if lib.table.contains(Config.admins, ("%s:%s"):format(identifierType, identifier)) then
self.addGroup("admin")
end
end
local roles = self.discord.roles
if roles then
local hasAdmin = false
for i=1, #Config.adminDiscordRoles do
local role = Config.adminDiscordRoles[i]
if lib.table.contains(roles, role) then
hasAdmin = true
break
end
end
if hasAdmin then
self.addGroup("admin")
elseif self.groups["admin"] then
self.removeGroup("admin")
end
local function hasRole(groupRoles)
for i=1, #groupRoles do
local role = groupRoles[i]
if lib.table.contains(roles, role) then
return true
end
end
end
for group, groupRoles in pairs(Config.groupRoles) do
if hasRole(groupRoles) then
self.addGroup(group)
elseif self.groups[group] then
self.removeGroup(group)
end
end
end
for name, _ in pairs(self.groups) do
lib.addPrincipal(self.source, ("group.%s"):format(name))
end
NDCore.players[self.source] = self
TriggerEvent("ND:characterLoaded", self)
self.triggerEvent("ND:characterLoaded", removeCharacterFunctions(self))
end
---@param name string
---@param rank number
---@param isJob boolean
---@return boolean
function self.addGroup(name, rank, customGroup, isJob)
local groupRank = tonumber(rank) or 1
local groupInfo = Config.groups?[name] or {}
local rankData = groupInfo?.ranksData?[groupRank]
local groupLabel = groupInfo?.label or name
local rankName = rankData?.label or groupRank
local isBoss = rankData?.isBoss or false
local metadata = groupInfo?.metadata or {}
if customGroup then
if customGroup.label then groupLabel = customGroup.label end
if customGroup.rankName then rankName = customGroup.rankName end
if customGroup.isBoss ~= nil then isBoss = customGroup.isBoss end
if customGroup.metadata then metadata = customGroup.metadata end
end
if isJob then
for _, group in pairs(self.groups) do
group.isJob = nil
end
end
self.groups[name] = {
name = name,
label = groupLabel,
rankName = rankName,
rank = groupRank,
isJob = isJob,
isBoss = isBoss,
metadata = metadata
}
if not isJob then
self.triggerEvent("ND:updateCharacter", removeCharacterFunctions(self), "groups")
TriggerEvent("ND:updateCharacter", self, "groups")
end
TriggerEvent("ND:groupAdded", self, self.groups[name])
lib.addPrincipal(self.source, ("group.%s"):format(name))
return self.groups[name]
end
---@param name string
---@return table
function self.getGroup(name)
return self.groups[name]
end
---@param name string
function self.removeGroup(name)
local group = self.groups[name]
if not group then return end
if self.job == name then
self.job = nil
self.jobInfo = nil
end
self.groups[name] = nil
self.triggerEvent("ND:updateCharacter", removeCharacterFunctions(self), "groups")
TriggerEvent("ND:updateCharacter", self, "groups")
TriggerEvent("ND:groupRemoved", self, group)
lib.removePrincipal(self.source, ("group.%s"):format(name))
return group
end
---@param name string
---@param rank number
---@return boolean
function self.setJob(name, rank, customGroup, keepGroup)
if not keepGroup then
self.removeGroup(self.job)
end
local job = self.addGroup(name, rank, customGroup, true)
if job then
self.job = job.name
self.jobInfo = job
end
self.triggerEvent("ND:updateCharacter", removeCharacterFunctions(self), "job")
TriggerEvent("ND:updateCharacter", self, "job")
return job
end
---@param job string
---@return boolean
function self.getJob()
for name, group in pairs(self.groups) do
if group.isJob then
return name, group
end
end
end
local jobName, jobInfo = self.getJob()
if jobInfo then
self.job = jobName
self.jobInfo = jobInfo
end
return self
end
function NDCore.avoidSavingLocation(coords, dist)
avoidSavingLastLocations[#avoidSavingLastLocations+1] = { coords = coords, dist = dist}
end
function NDCore.validateCharacterData(charInfo)
local errors = {}
local function checkLength(field, value, maxLength)
if type(value) ~= "string" then return end
if #value > maxLength then
table.insert(errors, string.format("%s is too long (max %d, got %d)", field, maxLength, #value))
end
end
if charInfo.name then checkLength("game name", charInfo.name, 50) end
if charInfo.firstname then checkLength("firstname", charInfo.firstname, 50) end
if charInfo.lastname then checkLength("lastname", charInfo.lastname, 50) end
if charInfo.dob then checkLength("dob", charInfo.dob, 50) end
if charInfo.gender then checkLength("gender", charInfo.gender, 50) end
if type(charInfo.cash) ~= "number" then table.insert(errors, "cash must be a number") end
if type(charInfo.bank) ~= "number" then table.insert(errors, "bank must be a number") end
if #errors > 0 then
return false, errors
else
return true, {}
end
end
---@param src number
---@param info table
---@return table
function NDCore.newCharacter(src, info)
local identifier = NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier)
if not identifier then return end
local userId = PlayersInfo[src] and PlayersInfo[src].userId
local charInfo = {
source = src,
identifier = identifier,
user_id = userId,
name = GetPlayerName(src) or "",
firstname = info.firstname or "",
lastname = info.lastname or "",
dob = info.dob or "",
gender = info.gender or "",
cash = info.cash or 0,
bank = info.bank or 0,
phonenumber = info.phonenumber,
groups = info.groups or {},
metadata = info.metadata or {},
inventory = info.inventory or {}
}
local valid, issues = NDCore.validateCharacterData(charInfo)
if not valid then
for _, err in ipairs(issues) do
Wait(100)
TriggerClientEvent("ND:notifyClient", src, {
description = err,
type = "error"
})
end
return
end
charInfo.id = MySQL.insert.await("INSERT INTO nd_characters (identifier, user_id, name, firstname, lastname, dob, gender, cash, bank, phonenumber, `groups`, metadata, inventory) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", {
identifier,
userId,
charInfo.name,
charInfo.firstname,
charInfo.lastname,
charInfo.dob,
charInfo.gender,
charInfo.cash,
charInfo.bank,
charInfo.phonenumber,
json.encode(charInfo.groups),
json.encode(charInfo.metadata),
json.encode(charInfo.inventory)
})
return createCharacterTable(charInfo)
end
---@param id number
---@return table
function NDCore.fetchCharacter(id, src)
local result
if src then
local identifier = NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier)
local userId = PlayersInfo[src] and PlayersInfo[src].userId
result = MySQL.query.await("SELECT * FROM nd_characters WHERE charid = ? AND (identifier = ? OR user_id = ?)", {id, identifier, userId})
else
result = MySQL.query.await("SELECT * FROM nd_characters WHERE charid = ?", {id})
end
local info = result?[1]
if not info then return end
-- Check if character has user_id, if not assign it
if not info.user_id and src then
local userId = PlayersInfo[src] and PlayersInfo[src].userId
if userId then
MySQL.update.await("UPDATE nd_characters SET user_id = ? WHERE charid = ?", {userId, info.charid})
info.user_id = userId
end
end
return createCharacterTable({
source = src,
id = info.charid,
identifier = info.identifier,
user_id = info.user_id,
name = info.name,
firstname = info.firstname,
lastname = info.lastname,
dob = info.dob,
gender = info.gender,
cash = info.cash,
bank = info.bank,
phonenumber = info.phonenumber,
groups = json.decode(info.groups),
metadata = json.decode(info.metadata),
inventory = json.decode(info.inventory)
})
end
---@param src number
---@return table
function NDCore.fetchAllCharacters(src)
local userId = PlayersInfo[src] and PlayersInfo[src].userId
local identifier = NDCore.getPlayerIdentifierByType(src, Config.characterIdentifier)
local characters = {}
-- Query by either identifier OR user_id, excluding deleted characters
local result = MySQL.query.await("SELECT * FROM nd_characters WHERE (identifier = ? OR user_id = ?) AND deleted_at IS NULL", {identifier, userId})
for i=1, #result do
local info = result[i]
-- Check if character has user_id, if not assign it
if not info.user_id and userId then
MySQL.update.await("UPDATE nd_characters SET user_id = ? WHERE charid = ?", {userId, info.charid})
info.user_id = userId
end
characters[info.charid] = createCharacterTable({
source = src,
id = info.charid,
identifier = info.identifier,
user_id = info.user_id,
name = info.name,
firstname = info.firstname,
lastname = info.lastname,
dob = info.dob,
gender = info.gender,
cash = info.cash,
bank = info.bank,
phonenumber = info.phonenumber,
groups = json.decode(info.groups),
metadata = json.decode(info.metadata),
inventory = json.decode(info.inventory)
})
end
return characters
end
---@param src number
---@param id number
---@return table
function NDCore.setActiveCharacter(src, id)
local char = NDCore.players[src]
if not src or char and char.id == id then return end
local character = NDCore.fetchCharacter(id, src)
if not character then return end
character.name = GetPlayerName(src)
character.active()
return character
end
-- runs every 10 minutes, saves all characters and money logs.
lib.cron.new("*/10 * * * *", function()
local players = NDCore.getPlayers()
for _, player in pairs(players) do
player.saveLastLocation()
player.save()
end
if #moneyLogs == 0 then return end
local placeholders = {}
local params = {}
local logs = moneyLogs
moneyLogs = {}
for i=1, #logs do
local row = logs[i]
for j = 1, 6 do
table.insert(params, row[j])
end
table.insert(placeholders, "(?, ?, ?, ?, ?, ?)")
end
local query = "INSERT INTO nd_money_log (character_id, action, account, amount, reason, trace) VALUES " .. table.concat(placeholders, ",")
MySQL.prepare(query, params)
end)