> For the complete documentation index, see [llms.txt](https://docs.ak4y.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ak4y.com/scripts/ak4y-multicharacter-v3/editable-files/sv_utils.lua.md).

# sv\_utils.lua

```lua
CORE = exports["ak4y-core"]
FrameworkName = nil
Framework = nil

PhotoWebhook = ""

CreateThread(function()
    if not FrameworkName then
        if GetResourceState("qbx_core") == "started" then
            FrameworkName = "qb"
            Framework = exports['qb-core']:GetCoreObject()
        elseif GetResourceState("es_extended") == "started" then
            FrameworkName = "esx"
            Framework = exports['es_extended']:getSharedObject()
        elseif GetResourceState("qb-core") == "started" then
            FrameworkName = "qb"
            Framework = exports['qb-core']:GetCoreObject()
        end
    end
end)

CORE:Register('ak4y-multicharacter-v3:GetSkinData', function(source, cid)
    local src = source
    local skinData = nil

    if FrameworkName == "esx" then
        -- ESX: Return skin as string (same as old script line 32-33)
        skinData = CORE:ExecuteSql("SELECT skin FROM users WHERE identifier = '" .. cid .. "'")
        if skinData[1] and skinData[1].skin then
            return skinData[1].skin  -- Return string, client will decode
        end
        return nil
    else
        -- QB: Return table array
        skinData = CORE:ExecuteSql("SELECT * FROM playerskins WHERE citizenid = '" .. cid .. "' AND active = 1")
        return skinData
    end
end)

CORE:Register('ak4y-multicharacter-v3:GetPhotoWebhook', function(source)
    return PhotoWebhook or ""
end)

CreateThread(function()
    local hasDonePreloading = {}
    local awaitingRegistration = {}
    local playerIdentity = {}
    local playerLoginTime = {}

    if FrameworkName == "qb" then

        AddEventHandler('QBCore:Server:PlayerLoaded', function(Player)
            Wait(1000)
            hasDonePreloading[Player.PlayerData.source] = true

            local citizenid = Player.PlayerData.citizenid
            if citizenid then
                playerLoginTime[citizenid] = os.time()
            end
        end)

        AddEventHandler('QBCore:Server:OnPlayerUnload', function(src)
            hasDonePreloading[src] = false
        end)

        AddEventHandler('playerDropped', function(reason)
            local src = source
            if Framework then
                local Player = Framework.Functions.GetPlayer(src)
                if Player then
                    local citizenid = Player.PlayerData.citizenid
                    if citizenid and playerLoginTime[citizenid] then
                        local loginTime = playerLoginTime[citizenid]
                        local currentTime = os.time()
                        local playtimeSeconds = currentTime - loginTime

                        local result = CORE:ExecuteSql("SELECT playtime FROM ak4y_multicharacter_v3 WHERE identifier = '" .. citizenid .. "'")
                        local currentPlaytime = 0
                        if result[1] and result[1].playtime then
                            currentPlaytime = tonumber(result[1].playtime) or 0
                        end

                        local newPlaytime = currentPlaytime + playtimeSeconds

                        CORE:ExecuteSql("UPDATE ak4y_multicharacter_v3 SET playtime = " .. newPlaytime .. " WHERE identifier = '" .. citizenid .. "'")

                        playerLoginTime[citizenid] = nil
                    end
                end
            end
        end)

        RegisterNetEvent('ak4y-multicharacter-v3:server:loadUserData', function(cData)
            print("loadUserData", cData)
            local src = source
            if Framework then
                if Framework.Player.Login(src, cData) then
                    repeat
                        Wait(10)
                    until hasDonePreloading[src]

                    print('^2[qb-core]^7 '..GetPlayerName(src)..' (Citizen ID: '..cData..') has succesfully loaded!')
                    Framework.Commands.Refresh(src)

                    if Config.LastPositionLoad then
                        local Player = Framework.Functions.GetPlayer(src)
                        local spawnCoords = Config.DefaultSpawn

                        if Player and Player.PlayerData and Player.PlayerData.position then
                            local position = Player.PlayerData.position
                            if type(position) == "string" then
                                position = json.decode(position)
                            end
                            if position and position.x and position.y and position.z then
                                spawnCoords = vector3(position.x, position.y, position.z)
                                if position.w or position.heading then
                                    spawnCoords = vector4(position.x, position.y, position.z, position.w or position.heading or 0.0)
                                end
                            end
                        end

                        TriggerClientEvent('ak4y-multicharacter-v3:client:closeNUIdefault', src, spawnCoords)
                    else
                        if Config.SpawnSelector then
                            Config.SpawnSelector(src, cData, "qb")
                        else
                            TriggerClientEvent('ak4y-multicharacter-v3:client:closeNUIdefault', src, Config.DefaultSpawn)
                        end
                    end

                    TriggerClientEvent("ak4y-multicharacter-v3:client:closeNUI", src)
                end
            end
        end)
    end

    if FrameworkName == "esx" then
        AddEventHandler('esx:playerLoaded', function(source, xPlayer)
            local identifier = xPlayer.identifier
            if identifier then
                playerLoginTime[identifier] = os.time()
            end
        end)

        AddEventHandler('playerDropped', function(reason)
            local src = source
            awaitingRegistration[src] = nil
            if Framework and Framework.Players then
                Framework.Players[GetIdentifierForESX(src)] = nil
            end

            if Framework then
                local xPlayer = Framework.GetPlayerFromId(src)
                if xPlayer then
                    local identifier = xPlayer.identifier
                    if identifier and playerLoginTime[identifier] then
                        local loginTime = playerLoginTime[identifier]
                        local currentTime = os.time()
                        local playtimeSeconds = currentTime - loginTime

                        local result = CORE:ExecuteSql("SELECT playtime FROM ak4y_multicharacter_v3 WHERE identifier = '" .. identifier .. "'")
                        local currentPlaytime = 0
                        if result[1] and result[1].playtime then
                            currentPlaytime = tonumber(result[1].playtime) or 0
                        end

                        local newPlaytime = currentPlaytime + playtimeSeconds

                        CORE:ExecuteSql("UPDATE ak4y_multicharacter_v3 SET playtime = " .. newPlaytime .. " WHERE identifier = '" .. identifier .. "'")

                        playerLoginTime[identifier] = nil
                    end
                end
            end
        end)

        RegisterNetEvent('ak4y-multicharacter-v3:server:loadUserData', function(charid, isNew)
            local src = source
            if Framework then
                -- charid should be the full identifier string like "char1:f77e98f..."
                -- Same as old script: ak4y-multicharacter-v2/editable/sv_utils.lua:110-113
                local identifier = tostring(charid)

                -- If identifier doesn't contain ":", it might be database id or invalid
                -- Get actual identifier from character data
                if not identifier:find(":") then
                    local license = GetIdentifierForESX(src)
                    if license then
                        -- Get character by identifier field matching the charid (which should be identifier)
                        local charsData = CORE:ExecuteSql("SELECT identifier FROM users WHERE identifier = '" .. identifier .. "'")
                        if charsData and charsData[1] then
                            identifier = charsData[1].identifier
                        else
                            -- If still not found, try to get by license
                            local allChars = CORE:ExecuteSql("SELECT identifier FROM users WHERE identifier LIKE 'char%:" .. license .. "' ORDER BY identifier")
                            if allChars and allChars[1] then
                                identifier = allChars[1].identifier
                            else
                                print("^1[ERROR]^7 Could not find character identifier for: " .. tostring(charid))
                                return
                            end
                        end
                    else
                        print("^1[ERROR]^7 Could not get license for player " .. src)
                        return
                    end
                end

                -- Extract "char1" from "char1:f77e98f..." (exactly like old script line 111-112)
                local position = identifier:find(":")
                local result = nil
                if position then
                    result = identifier:sub(1, position-1)
                else
                    print("^1[ERROR]^7 Invalid identifier format (no colon): " .. identifier)
                    return
                end

                if isNew then
                    awaitingRegistration[src] = result
                else
                    -- Trigger ESX to load player (exactly like old script line 113)
                    TriggerEvent('esx:onPlayerJoined', src, result)
                    if Framework.Players then
                        Framework.Players[GetIdentifierForESX(src)] = true
                    end
                end
            end
        end)

        function GetIdentifierForESX(source)
            -- Try license2 first (as per Config.Identifier = "license2")
            for _, v in pairs(GetPlayerIdentifiers(source)) do
                if string.match(v, "license2:") then
                    return string.gsub(v, "license2:", "")
                end
            end
            -- Fallback to license
            for _, v in pairs(GetPlayerIdentifiers(source)) do
                if string.match(v, "license:") then
                    return string.gsub(v, "license:", "")
                end
            end
            return nil
        end
    end

local function CreateCitizenId()
    local charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    local citizenId = ""
    local exists = true

    while exists do
        citizenId = ""
        for i = 1, 8 do
            local rand = math.random(1, #charset)
            citizenId = citizenId .. string.sub(charset, rand, rand)
        end

        local result = CORE:ExecuteSql("SELECT * FROM players WHERE citizenid = '" .. citizenId .. "'")
        exists = result[1] ~= nil
    end

    return citizenId
end

if FrameworkName == "qb" then
    RegisterNetEvent('ak4y-multicharacter-v3:server:createCharacter', function(data)
        local src = source
        if Framework then
            local citizenid = CreateCitizenId()

            local newData = {}
            newData.cid = citizenid
            newData.charinfo = {
                firstname = data.firstname,
                lastname = data.lastname,
                birthdate = data.birthdate,
                gender = data.gender,
                nationality = data.nationality or ""
            }

                if Framework.Player.Login(src, false, newData) then
                    repeat
                        Wait(10)
                    until hasDonePreloading[src]

                    local randbucket = (GetPlayerPed(src) .. math.random(1,999))
                    SetPlayerRoutingBucket(src, randbucket)
                    print('^2[qb-core]^7 '..GetPlayerName(src)..' has succesfully loaded!')
                    Framework.Commands.Refresh(src)

                    local varS = {
                        citizenid = citizenid
                    }

                    if Config.UseQbApartments and GetResourceState('qb-apartments') == 'started' then
                        TriggerClientEvent('apartments:client:setupSpawnUI', src, varS)
                    else
                        if GetResourceState('qb-spawn') == 'started' then
                            TriggerClientEvent('qb-spawn:client:setupSpawns', src, varS, false, nil)
                            TriggerClientEvent('qb-spawn:client:openUI', src, true)
                        else
                            TriggerClientEvent('ak4y-multicharacter-v3:client:closeNUIdefault', src)
                        end
                    end

                    TriggerClientEvent("ak4y-multicharacter-v3:client:closeNUI", src)
                end
            end
        end)
    end

    if FrameworkName == "esx" then
        RegisterNetEvent('ak4y-multicharacter-v3:server:createCharacter', function(data)
            local src = source
            if Framework then
                local xPlayer = Framework.GetPlayerFromId(src)

                local identifier = GetIdentifierForESX(src)
                if not identifier then
                    print("^1[ERROR]^7 Could not get identifier for player " .. src)
                    return
                end

                local existingChars = CORE:ExecuteSql("SELECT * FROM users WHERE identifier LIKE 'char%:" .. identifier .. "'")
                local charNum = #existingChars + 1
                awaitingRegistration[src] = charNum

                local formattedFirstName = data.firstname
                local formattedLastName = data.lastname
                local formattedDate = data.birthdate

                if xPlayer then
                    -- Convert data.sex from "m"/"f" to 0/1 if needed
                    local sexValue = data.sex
                    if sexValue == "m" then
                        sexValue = 0
                    elseif sexValue == "f" then
                        sexValue = 1
                    elseif type(sexValue) == "string" and (sexValue == "male" or sexValue == "female") then
                        sexValue = (sexValue == "male") and 0 or 1
                    end

                    playerIdentity[xPlayer.identifier] = {
                        firstName = formattedFirstName,
                        lastName = formattedLastName,
                        dateOfBirth = formattedDate,
                        sex = sexValue,
                        height = data.height or 180
                    }

                    local currentIdentity = playerIdentity[xPlayer.identifier]
                    xPlayer.setName(('%s %s'):format(currentIdentity.firstName, currentIdentity.lastName))
                    xPlayer.set('firstName', currentIdentity.firstName)
                    xPlayer.set('lastName', currentIdentity.lastName)
                    xPlayer.set('dateofbirth', currentIdentity.dateOfBirth)
                    xPlayer.set('sex', currentIdentity.sex)
                    xPlayer.set('height', currentIdentity.height)
                    TriggerClientEvent('ak4y-multicharacter:setPlayerData', src, currentIdentity)

                    -- Save identity to database
                    CORE:ExecuteSql("UPDATE users SET firstname = '"..currentIdentity.firstName.."', lastname = '"..currentIdentity.lastName.."', dateofbirth = '"..currentIdentity.dateOfBirth.."', sex = '"..currentIdentity.sex.."', height = '"..currentIdentity.height.."' WHERE identifier = '"..xPlayer.identifier.."'")

                    TriggerEvent('ak4y-multicharacter-v3:completedRegistration', src, data)
                else
                    data.firstname = formattedFirstName
                    data.lastname = formattedLastName
                    data.dateofbirth = formattedDate
                    -- Convert data.sex from "m"/"f" to 0/1 if needed
                    local sexValue = data.sex
                    if sexValue == "m" then
                        sexValue = 0
                    elseif sexValue == "f" then
                        sexValue = 1
                    elseif type(sexValue) == "string" and (sexValue == "male" or sexValue == "female") then
                        sexValue = (sexValue == "male") and 0 or 1
                    end

                    local Identity = {
                        firstName = formattedFirstName,
                        lastName = formattedLastName,
                        dateOfBirth = formattedDate,
                        sex = sexValue,
                        height = data.height or 180
                    }
                    TriggerEvent('ak4y-multicharacter-v3:completedRegistration', src, data)
                    TriggerClientEvent('ak4y-multicharacter:setPlayerData', src, Identity)
                end
            end
        end)

        AddEventHandler('ak4y-multicharacter-v3:completedRegistration', function(source, data)
            TriggerClientEvent("ak4y-multicharacter-v3:created", source)
            TriggerEvent('esx:onPlayerJoined', source, "char"..awaitingRegistration[source], data)
            awaitingRegistration[source] = nil
            if Framework.Players then
                Framework.Players[GetIdentifierForESX(source)] = true
            end
            Wait(1000)
            local xPlayer = Framework.GetPlayerFromId(source)
            if xPlayer and xPlayer.identifier then
                local insertData = {
                    anim = data.anim or 0,
                    sound = data.sound or 1,
                }
                CORE:ExecuteSql("INSERT INTO ak4y_multicharacter_v3 (identifier, xp, anso) VALUES ('" .. xPlayer.identifier .. "', '0', '".. json.encode(insertData) .."') ON DUPLICATE KEY UPDATE anso = '".. json.encode(insertData) .."'")
            end
        end)

        CORE:Register('ak4y-multicharacter-v3:GetAnso', function(source, cid)
            if not cid then
                local xPlayer = Framework.GetPlayerFromId(source)
                if xPlayer then
                    cid = xPlayer.identifier
                end
            end
            if cid then
                local imgData = CORE:ExecuteSql("SELECT * FROM ak4y_multicharacter_v3 WHERE identifier = '" .. cid .. "'")
                if imgData[1] then
                    return imgData[1]
                else
                    return false
                end
            else
                return false
            end
        end)
    end
end)

RegisterNetEvent('ak4y-multicharacter-v3:server:saveAnso', function(identifier, ansoJson)
    local src = source

    if identifier and ansoJson then
        ansoJson = ansoJson:gsub("'", "''")

        CORE:ExecuteSql("UPDATE ak4y_multicharacter_v3 SET anso = '" .. ansoJson .. "' WHERE identifier = '" .. identifier .. "'")
    end
end)

RegisterNetEvent('ak4y-multicharacter-v3:server:savePhoto', function(identifier, base64Photo)
    local src = source

    if identifier and base64Photo then
        base64Photo = base64Photo:gsub("'", "''")
        base64Photo = base64Photo:gsub('"', '\\"')
        base64Photo = base64Photo:gsub('\n', '')
        base64Photo = base64Photo:gsub('\r', '')

        CORE:ExecuteSql("UPDATE ak4y_multicharacter_v3 SET photo = '" .. base64Photo .. "' WHERE identifier = '" .. identifier .. "'")
    end
end)

RegisterNetEvent('ak4y-multicharacter-v3:server:saveSelectedPhoto', function(identifier, selectedPhoto)
    local src = source

    if identifier and selectedPhoto then
        selectedPhoto = selectedPhoto:gsub("'", "''")

        CORE:ExecuteSql("UPDATE ak4y_multicharacter_v3 SET selectedPhoto = '" .. selectedPhoto .. "' WHERE identifier = '" .. identifier .. "'")
    end
end)
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.ak4y.com/scripts/ak4y-multicharacter-v3/editable-files/sv_utils.lua.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
