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
@@ -0,0 +1,41 @@
local pendingCallbacks = {}
local callbackCounter = 0
local resourceName = GetCurrentResourceName()
local responseEvent = ('devx_core:client:callback:%s'):format(resourceName)
function DevXTriggerCallback(name, payload, callback)
assert(type(name) == 'string' and name:match('^[%w_%-]+:[%w_%-]+$'),
'Callbackname ist ungültig.')
assert(type(callback) == 'function', 'Clientcallback muss eine Funktion sein.')
callbackCounter = callbackCounter + 1
local requestId = ('%s:%s:%s'):format(resourceName, GetGameTimer(), callbackCounter)
pendingCallbacks[requestId] = callback
TriggerServerEvent(
('devx_core:server:callback:%s:%s'):format(resourceName, name),
requestId,
payload or {}
)
SetTimeout(22000, function()
local pending = pendingCallbacks[requestId]
if not pending then return end
pendingCallbacks[requestId] = nil
pending({
ok = false,
error = ('Keine Antwort auf %s. Prüfe die txAdmin- und F8-Konsole.'):format(name)
})
end)
end
RegisterNetEvent(responseEvent)
AddEventHandler(responseEvent, function(requestId, result)
local callback = pendingCallbacks[requestId]
if not callback then return end
pendingCallbacks[requestId] = nil
callback(result or { ok = false, error = 'Leere Serverantwort.' })
end)
@@ -0,0 +1,63 @@
local PlayerData = nil
local function notify(message)
BeginTextCommandThefeedPost('STRING')
AddTextComponentSubstringPlayerName(message)
EndTextCommandThefeedPostTicker(false, false)
end
exports('GetPlayerData', function()
return PlayerData
end)
exports('IsPlayerLoaded', function()
return PlayerData ~= nil
end)
exports('Notify', notify)
RegisterNetEvent('devx_core:client:playerData', function(data)
PlayerData = data
LocalPlayer.state:set('devxCharacterId', data.characterId, true)
TriggerEvent('devx_core:client:playerLoaded', data)
end)
CreateThread(function()
while true do
Wait(DevXConfig.PositionSaveIntervalMs)
if PlayerData then
local ped = PlayerPedId()
if DoesEntityExist(ped) and not IsEntityDead(ped) then
local coords = GetEntityCoords(ped)
TriggerServerEvent('devx_core:server:updatePosition', {
x = coords.x,
y = coords.y,
z = coords.z,
heading = GetEntityHeading(ped)
})
end
end
end
end)
RegisterCommand('coords', function()
local ped = PlayerPedId()
local coords = GetEntityCoords(ped)
local text = ('vector4(%.2f, %.2f, %.2f, %.2f)'):format(
coords.x, coords.y, coords.z, GetEntityHeading(ped)
)
print(text)
notify(text)
end, false)
RegisterCommand('unstuck', function()
if not PlayerData then return end
local spawn = DevXConfig.StreetSpawn
SetEntityCoords(PlayerPedId(), spawn.x, spawn.y, spawn.z, false, false, false, false)
SetEntityHeading(PlayerPedId(), spawn.heading)
notify('Du wurdest zum Straßenstart teleportiert.')
end, false)
RegisterNetEvent('devx_core:client:returnedToWorld', function()
notify('Du wurdest in die öffentliche Welt zurückgesetzt.')
end)
+23
View File
@@ -0,0 +1,23 @@
fx_version 'cerulean'
game 'gta5'
author 'Colin-Joel / DevX Studios'
description 'DevX RP core sessions, callbacks and player state'
version '0.4.3'
lua54 'yes'
shared_script 'shared/config.lua'
-- This helper is loaded by dependent resources using
-- @devx_core/client/callbacks.lua. It must be part of the client packfile.
files {
'client/callbacks.lua'
}
client_script 'client/main.lua'
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua'
}
@@ -0,0 +1,79 @@
local registeredCallbacks = {}
local pendingExecutions = {}
local resourceName = GetCurrentResourceName()
print(('[%s] DevX RPC v0.4.1 aktiv.'):format(resourceName))
local function callbackEventName(name)
return ('devx_core:server:callback:%s:%s'):format(resourceName, name)
end
local function responseEventName()
return ('devx_core:client:callback:%s'):format(resourceName)
end
local function sendResult(requestSource, requestId, result)
TriggerClientEvent(responseEventName(), requestSource, requestId, result)
end
function DevXRegisterCallback(name, handler)
assert(type(name) == 'string' and name:match('^[%w_%-]+:[%w_%-]+$'),
'Callbackname ist ungültig.')
assert(type(handler) == 'function', 'Callbackhandler muss eine Funktion sein.')
assert(not registeredCallbacks[name], ('Callback %s wurde doppelt registriert.'):format(name))
registeredCallbacks[name] = true
local eventName = callbackEventName(name)
RegisterNetEvent(eventName)
AddEventHandler(eventName, function(requestId, payload)
local requestSource = source
if type(requestId) ~= 'string' or #requestId > 160 or type(payload) ~= 'table' then
sendResult(requestSource, tostring(requestId or ''), {
ok = false,
error = 'Ungültige Serveranfrage.'
})
return
end
local executionKey = ('%s:%s'):format(requestSource, requestId)
if pendingExecutions[executionKey] then
sendResult(requestSource, requestId, {
ok = false,
error = 'Diese Serveranfrage wird bereits verarbeitet.'
})
return
end
pendingExecutions[executionKey] = true
local completed = false
local function finish(result)
if completed then return end
completed = true
pendingExecutions[executionKey] = nil
if result == nil then result = { ok = true } end
sendResult(requestSource, requestId, result)
end
print(('[%s] RPC empfangen: %s von Spieler %s.'):format(resourceName, name, requestSource))
SetTimeout(20000, function()
if completed then return end
print(('[%s] RPC %s für Spieler %s hat nach 20 Sekunden nicht geantwortet.'):format(
resourceName, name, requestSource
))
finish({
ok = false,
error = ('Serververarbeitung für %s abgelaufen. Prüfe die txAdmin-Konsole.'):format(name)
})
end)
CreateThread(function()
local result = handler(requestSource, payload)
finish(result)
end)
end)
end
+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)
@@ -0,0 +1,28 @@
DevXConfig = {
MaxCharacters = 3,
StartCash = 150,
StartBank = 500,
StreetSpawn = {
x = -1037.72,
y = -2737.88,
z = 20.17,
heading = 329.0
},
PhonePrefix = '555',
PositionSaveIntervalMs = 30000,
MaxMoneyOperation = 100000000,
AdminWeapons = {
WEAPON_PISTOL = 'Pistole',
WEAPON_COMBATPISTOL = 'Kampfpistole',
WEAPON_SMG = 'MP',
WEAPON_CARBINERIFLE = 'Karabiner',
WEAPON_PUMPSHOTGUN = 'Pumpgun',
WEAPON_STUNGUN = 'Taser',
WEAPON_FLASHLIGHT = 'Taschenlampe',
WEAPON_NIGHTSTICK = 'Schlagstock',
WEAPON_KNIFE = 'Messer'
}
}