first commit

This commit is contained in:
Colin-Joel Scupin
2026-08-05 01:29:39 +02:00
commit 86d4bcdc37
85 changed files with 7706 additions and 0 deletions
+381
View File
@@ -0,0 +1,381 @@
local Players = {}
local positionRateLimit = {}
local function getLicense(source)
for _, identifier in ipairs(GetPlayerIdentifiers(source)) do
if identifier:sub(1, 8) == 'license:' then
return identifier
end
end
return nil
end
local function sanitizeInteger(value, maximum)
local number = tonumber(value)
if not number then return nil end
number = math.floor(number)
if number < 0 then return nil end
if maximum and number > maximum then return nil end
return number
end
local function normalizeDateOfBirth(value)
if type(value) == 'number' then
local seconds = value > 100000000000 and math.floor(value / 1000) or math.floor(value)
return os.date('!%Y-%m-%d', seconds)
end
local text = tostring(value or '')
local numeric = tonumber(text)
if numeric then
local seconds = numeric > 100000000000 and math.floor(numeric / 1000) or math.floor(numeric)
return os.date('!%Y-%m-%d', seconds)
end
local date = text:match('^(%d%d%d%d%-%d%d%-%d%d)')
return date or text
end
local function phoneNumberFor(characterId)
return ('%s-%04d'):format(DevXConfig.PhonePrefix or '555', tonumber(characterId))
end
local function ensurePhoneNumber(character)
if character.phone_number and character.phone_number ~= '' then
return character.phone_number
end
local phoneNumber = phoneNumberFor(character.id)
MySQL.update.await(
'UPDATE devx_characters SET phone_number = ? WHERE id = ? AND (phone_number IS NULL OR phone_number = "")',
{ phoneNumber, character.id }
)
return phoneNumber
end
local function emitPlayerData(source)
local player = Players[source]
if not player then return end
TriggerClientEvent('devx_core:client:playerData', source, player)
end
local function loadCharacter(source, characterId)
local license = getLicense(source)
characterId = sanitizeInteger(characterId)
if not license or not characterId then
return nil, 'Ungültige Sitzung.'
end
local character = MySQL.single.await([[
SELECT id, slot, first_name, last_name, date_of_birth, sex, model,
phone_number, cash, position, metadata
FROM devx_characters
WHERE id = ? AND owner_license = ? AND deleted_at IS NULL
LIMIT 1
]], { characterId, license })
if not character then
return nil, 'Charakter wurde nicht gefunden.'
end
local account = MySQL.single.await([[
SELECT id, account_number, account_name, account_type, balance
FROM devx_bank_accounts
WHERE character_id = ? AND account_type = 'personal'
LIMIT 1
]], { character.id })
local metadata = {}
if character.metadata and character.metadata ~= '' then
local ok, decoded = pcall(json.decode, character.metadata)
if ok and type(decoded) == 'table' then metadata = decoded end
end
local position = nil
if character.position and character.position ~= '' then
local ok, decoded = pcall(json.decode, character.position)
if ok and type(decoded) == 'table' then position = decoded end
end
local phoneNumber = ensurePhoneNumber(character)
Players[source] = {
source = source,
license = license,
characterId = tonumber(character.id),
slot = tonumber(character.slot),
firstName = character.first_name,
lastName = character.last_name,
fullName = ('%s %s'):format(character.first_name, character.last_name),
dateOfBirth = normalizeDateOfBirth(character.date_of_birth),
sex = character.sex,
model = character.model,
phoneNumber = phoneNumber,
cash = tonumber(character.cash) or 0,
bank = account and tonumber(account.balance) or 0,
accountId = account and tonumber(account.id) or nil,
accountNumber = account and account.account_number or nil,
position = position,
metadata = metadata,
rpXp = tonumber(metadata.rpXp) or 0,
introCompleted = metadata.introCompleted == true
}
-- Jeder frisch ausgewählte Charakter startet sicher in der öffentlichen Welt.
-- So bleiben keine alten Apartment-/Missions-Buckets nach einem Reconnect hängen.
SetPlayerRoutingBucket(source, 0)
Player(source).state:set('devxCharacterId', Players[source].characterId, true)
Player(source).state:set('devxCharacterName', Players[source].fullName, true)
Player(source).state:set('devxPhoneNumber', Players[source].phoneNumber, true)
emitPlayerData(source)
return Players[source]
end
local function savePosition(source)
local player = Players[source]
if not player or not player.position then return end
MySQL.update.await(
'UPDATE devx_characters SET position = ? WHERE id = ?',
{ json.encode(player.position), player.characterId }
)
end
local function persistMetadata(source)
local player = Players[source]
if not player then return false end
local changed = MySQL.update.await(
'UPDATE devx_characters SET metadata = ? WHERE id = ?',
{ json.encode(player.metadata or {}), player.characterId }
)
return changed ~= nil
end
exports('GetLicense', getLicense)
exports('GetPlayer', function(source)
return Players[tonumber(source)]
end)
exports('GetActivePlayers', function()
return Players
end)
exports('ActivateCharacter', function(source, characterId)
return loadCharacter(tonumber(source), characterId)
end)
exports('SetMetadataValue', function(source, key, value)
source = tonumber(source)
local player = Players[source]
if not player or type(key) ~= 'string' or #key < 1 or #key > 64 then
return false
end
player.metadata = player.metadata or {}
player.metadata[key] = value
if not persistMetadata(source) then return false end
if key == 'introCompleted' then player.introCompleted = value == true end
if key == 'rpXp' then player.rpXp = tonumber(value) or 0 end
emitPlayerData(source)
return true
end)
exports('AddMetadataNumber', function(source, key, amount)
source = tonumber(source)
amount = tonumber(amount)
local player = Players[source]
if not player or not amount or type(key) ~= 'string' then return false end
player.metadata = player.metadata or {}
local newValue = math.floor((tonumber(player.metadata[key]) or 0) + amount)
player.metadata[key] = newValue
if not persistMetadata(source) then return false end
if key == 'rpXp' then player.rpXp = newValue end
emitPlayerData(source)
return true, newValue
end)
exports('SetBankCache', function(source, balance)
local player = Players[tonumber(source)]
balance = sanitizeInteger(balance)
if not player or balance == nil then return false end
player.bank = balance
emitPlayerData(tonumber(source))
return true
end)
exports('SetCash', function(source, amount, reason)
source = tonumber(source)
amount = sanitizeInteger(amount, DevXConfig.MaxMoneyOperation)
local player = Players[source]
if not player or amount == nil then return false end
local changed = MySQL.update.await(
'UPDATE devx_characters SET cash = ? WHERE id = ?',
{ amount, player.characterId }
)
if not changed or changed < 1 then return false end
player.cash = amount
emitPlayerData(source)
TriggerEvent('devx_core:server:moneyAudit', source, 'cash_set', amount, reason or 'Unbekannt')
return true
end)
exports('AddCash', function(source, amount, reason)
source = tonumber(source)
amount = sanitizeInteger(amount, DevXConfig.MaxMoneyOperation)
local player = Players[source]
if not player or not amount or amount == 0 then return false end
local newCash = player.cash + amount
local changed = MySQL.update.await(
'UPDATE devx_characters SET cash = ? WHERE id = ?',
{ newCash, player.characterId }
)
if not changed or changed < 1 then return false end
player.cash = newCash
emitPlayerData(source)
TriggerEvent('devx_core:server:moneyAudit', source, 'cash_credit', amount, reason or 'Unbekannt')
return true, newCash
end)
exports('RemoveCash', function(source, amount, reason)
source = tonumber(source)
amount = sanitizeInteger(amount, DevXConfig.MaxMoneyOperation)
local player = Players[source]
if not player or not amount or amount == 0 or player.cash < amount then return false end
local newCash = player.cash - amount
local changed = MySQL.update.await(
'UPDATE devx_characters SET cash = ? WHERE id = ? AND cash >= ?',
{ newCash, player.characterId, amount }
)
if not changed or changed < 1 then return false end
player.cash = newCash
emitPlayerData(source)
TriggerEvent('devx_core:server:moneyAudit', source, 'cash_debit', amount, reason or 'Unbekannt')
return true, newCash
end)
RegisterNetEvent('devx_core:server:updatePosition', function(position)
local source = source
local player = Players[source]
if not player or type(position) ~= 'table' then return end
local now = os.time()
if positionRateLimit[source] and now - positionRateLimit[source] < 10 then return end
positionRateLimit[source] = now
local x, y, z, heading = tonumber(position.x), tonumber(position.y),
tonumber(position.z), tonumber(position.heading)
if not x or not y or not z or not heading then return end
if math.abs(x) > 10000 or math.abs(y) > 10000 or math.abs(z) > 3000 then return end
player.position = { x = x, y = y, z = z, heading = heading }
savePosition(source)
end)
AddEventHandler('playerDropped', function()
local source = source
savePosition(source)
Players[source] = nil
positionRateLimit[source] = nil
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName ~= GetCurrentResourceName() then return end
for source in pairs(Players) do savePosition(source) end
end)
MySQL.ready(function()
MySQL.query.await([[
ALTER TABLE devx_characters
ADD COLUMN IF NOT EXISTS phone_number VARCHAR(16) NULL AFTER model
]])
MySQL.query.await([[
CREATE UNIQUE INDEX IF NOT EXISTS uq_devx_character_phone
ON devx_characters (phone_number)
]])
MySQL.query.await([[
CREATE TABLE IF NOT EXISTS devx_bans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
license VARCHAR(80) NOT NULL,
player_name VARCHAR(96) NULL,
reason VARCHAR(255) NOT NULL,
banned_by VARCHAR(96) NOT NULL,
expires_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at DATETIME NULL,
PRIMARY KEY (id),
KEY idx_devx_bans_license (license),
KEY idx_devx_bans_active (license, revoked_at, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
]])
print('[devx_core] Datenbankschema v0.4.1 geprüft.')
end)
AddEventHandler('playerConnecting', function(_, _, deferrals)
local source = source
deferrals.defer()
Wait(0)
local license = getLicense(source)
if not license then
deferrals.done('FiveM konnte keine license:-Kennung ermitteln.')
return
end
local ok, ban = pcall(function()
return MySQL.single.await([[
SELECT reason, expires_at
FROM devx_bans
WHERE license = ?
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY id DESC
LIMIT 1
]], { license })
end)
if ok and ban then
local expiry = ban.expires_at and ('\nAblauf: %s'):format(tostring(ban.expires_at)) or '\nDauer: permanent'
deferrals.done(('Du bist von DevX-City gesperrt.\nGrund: %s%s'):format(ban.reason, expiry))
return
end
deferrals.done()
end)
RegisterNetEvent('devx_core:server:returnToWorld', function()
local source = source
if not Players[source] then return end
SetPlayerRoutingBucket(source, 0)
end)
RegisterCommand('world', function(source)
if source == 0 then return end
if not Players[source] then return end
SetPlayerRoutingBucket(source, 0)
TriggerClientEvent('devx_core:client:returnedToWorld', source)
end, false)
RegisterCommand('setcash', function(source, args)
if source ~= 0 and not IsPlayerAceAllowed(source, 'devx.admin') then return end
local target = tonumber(args[1])
local amount = sanitizeInteger(args[2], DevXConfig.MaxMoneyOperation)
if not target or amount == nil or not Players[target] then return end
exports.devx_core:SetCash(target, amount, ('Admin %s'):format(source))
end, false)