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)