# STARTER PACK

{% embed url="<https://youtu.be/wOM6TMwpaos?si=Ziu6KH1urg7loui8>" %}

**INSTALLATION GUIDE**

1. Download from [KEYMASTER ](https://keymaster.fivem.net/login?return_url=/asset-grants)and Unzip the **`forge-starter.pack.zip`** and place this folder in your server's resource folder.
2. Add the resource to your server start config: **`ensure forge-starter`**,the name of the folder must not be changed or the resource will not function correctly.
3. **Install the SQL** that comes with the script.
4. Clear the cache of your server and also of your own FiveM.
5. Reboot the entire server with the forge script well ensured in your server.cfg.
6. <mark style="color:red;">**Do not rename this script, this may cause it to fail when opening the interface.**</mark>

**CONFIG**

The following will explain all the settings, one of the most important things that I recommend you spend a few minutes to understand in order to offer your users the best possible experience.

{% tabs %}
{% tab title="CONFIG" %}
Fill all the **CONFIG** very carefully. &#x20;

{% code lineNumbers="true" %}

```lua
Config = { }

--  _____ _____ _   _ ______ _____ _____ _   _______  ___ _____ _____ _____ _   _
-- /  __ \  _  | \ | ||  ___|_   _|  __ \ | | | ___ \/ _ \_   _|_   _|  _  | \ | |
-- | /  \/ | | |  \| || |_    | | | |  \/ | | | |_/ / /_\ \| |   | | | | | |  \| |
-- | |   | | | | . ` ||  _|   | | | | __| | | |    /|  _  || |   | | | | | . ` |
-- | \__/\ \_/ / |\  || |    _| |_| |_\ \ |_| | |\ \| | | || |  _| |_\ \_/ / |\  |
--  \____/\___/\_| \_/\_|    \___/ \____/\___/\_| \_\_| |_/\_/  \___/ \___/\_| \_/

Config.Framework = 'esx' -- Choose between 'esx' or 'qbcore'
Config.SQLWrapper = 'oxmysql' -- Options: 'ghmattimysql', 'mysql-async', 'oxmysql'
Config.UseTarget = false -- If you set it to false, floating text will be displayed
Config.TargetSystem = 'qb-target' -- Choose between 'qb-target' or 'ox_target'
Config.UseStarterPack = true -- Set true to make the button appear in the UI and be usable
Config.UseRoleBasedStarter = false -- Set true to show role selection (CIVILIAN/CRIMINAL) instead of the default single starter pack
Config.UseVehicleStarter = true -- Set true to make the button appear in the UI and be usable
Config.UseGuideBook = true -- Set true to make the button appear in the UI and be usable
Config.UseGuidebookItem = false -- Set to true to enable guidebook as usable item
Config.GuidebookItem = "guidebook" -- Name of the item to use as guidebook

-- Starter Pack Options
Config.StarterPack = {
    Male = { 
        { Name = 'bread', Quantity = 4 }, 
        { Name = 'phone', Quantity = 1 }
    },
    Female = { 
        { Name = 'bread', Quantity = 4 }, 
        { Name = 'phone', Quantity = 1 }
    },
    Money = {
        cash = 1000,
        bank = 1000
    }
}

-- Role-Based Starter Pack Options (used when Config.UseRoleBasedStarter = true)
-- Each role has its own items, money, and gender-specific configuration
-- Only ONE role can be claimed per character
Config.StarterPackRoles = {
    Civilian = {
        Male = {
            { Name = 'bread', Quantity = 5 },
            { Name = 'water', Quantity = 5 },
            { Name = 'phone', Quantity = 1 }
        },
        Female = {
            { Name = 'bread', Quantity = 5 },
            { Name = 'water', Quantity = 5 },
            { Name = 'phone', Quantity = 1 }
        },
        Money = {
            cash = 1500,
            bank = 2000
        }
    },
    Criminal = {
        Male = {
            { Name = 'lockpick', Quantity = 2 },
            { Name = 'bread', Quantity = 3 },
            { Name = 'phone', Quantity = 1 }
        },
        Female = {
            { Name = 'lockpick', Quantity = 2 },
            { Name = 'bread', Quantity = 3 },
            { Name = 'phone', Quantity = 1 }
        },
        Money = {
            cash = 2500,
            bank = 500
        }
    }
}

-- Vehicle Starter Options
Config.VehicleStarter = {
    VehicleInGarage = false, -- If false, it will be a street vehicle with keys. If true, the player owns the vehicle and it can be stored in the garage
    VehicleModels = {
        { Model = "blista", Probability = 0.5 }, -- 50% chance (100% is 1.0)
        { Model = "club", Probability = 0.4 }, -- 40% chance (100% is 1.0)
        { Model = "zentorno", Probability = 0.1 } -- 10% chance (100% is 1.0)   
    }
}
--Add configurable garage provider and defaults
Config.Garage = {
    -- Provider options:
    --   'esx_default'       → ESX owned_vehicles
    --   'qbcore_default'    → QBCore player_vehicles
    --   'jg_advanced_esx'   → ESX + JG Advanced Garages (owned_vehicles with in_garage/garage_id)
    --   'jg_advanced_qb'    → QBCore + JG Advanced Garages (player_vehicles with in_garage/garage_id)
    --   'okok_esx'          → ESX + okokGarage (owned_vehicles with okok columns)
    --   'okok_qb'           → QBCore + okokGarage (player_vehicles with okok columns)
    --   'cd_esx'            → ESX + CD Garage (owned_vehicles with cd columns)
    --   'cd_qb'             → QBCore + CD Garage (player_vehicles with cd columns)
    --   'codem_esx'         → ESX + CodeM Garage (owned_vehicles with stored/parking/favorite)
    --   'codem_qb'          → QBCore + CodeM Garage (player_vehicles with stored/parking/favorite)
    --   'vms_garagesv2_esx' → ESX + VMS Garages V2 (uses vms_garagesv2 exports)
    --   'vms_garagesv2_qb'  → QBCore + VMS Garages V2 (uses vms_garagesv2 exports)
    --   'op_garages'        → OP Garages (vehicle added as owned, player can use any garage)
    Provider = 'esx_default',
    Defaults = {
        esx_default = {
        },
        qbcore_default = {
            garage = 'pillboxgarage',
            state = 0
        },
        jg_advanced_esx = {
            in_garage = 1,
            garage_id = 'Legion Square'
        },
        jg_advanced_qb = {
            in_garage = 1,
            garage_id = 'Legion Square'
        },
        okok_esx = {
            parking = nil,
            doorcondition = nil,
            windowcondition = nil,
            tyrecondition = nil,
            favourite = 0,
            impoundTime = nil,
            location = nil,
            reason = nil,
            sharedwith = '[]',
            vehiclename = nil
        },
        okok_qb = {
            parking = nil,
            doorcondition = nil,
            windowcondition = nil,
            tyrecondition = nil,
            favourite = 0,
            impoundTime = nil,
            location = nil,
            reason = nil,
            sharedwith = '[]',
            vehiclename = nil
        },
        cd_esx = {
            in_garage = 1,
            garage_id = 'A',
            garage_type = 'car',
            job_personalowned = '',
            property = 0,
            impound = 0,
            impound_data = '',
            name = nil
        },
        cd_qb = {
            in_garage = 1,
            garage_id = 'A',
            garage_type = 'car',
            job_personalowned = '',
            property = 0,
            impound = 0,
            impound_data = '',
            name = nil
        },
        codem_esx = {
            stored = 1,
            parking = 'Garage A',
            favorite = '0'
        },
        codem_qb = {
            stored = 1,
            parking = 'Garage A',
            favorite = '0'
        },
        vms_garagesv2_esx = {
            impoundId = 'Impound1' -- Default impound ID for VMS Garages V2
        },
        vms_garagesv2_qb = {
            impoundId = 'Impound1' -- Default impound ID for VMS Garages V2
        },
        op_garages = {
            -- OP Garages - Vehicle is added as owned in player_vehicles/owned_vehicles
            -- Player can save/retrieve from any OP Garages zone on the map
            garage = 'pillboxgarage' -- Default garage name for QBCore
        }
    }
}
-- Vehicle Spawn Locations (supports multiple spawn points to prevent collisions)
-- If multiple players claim vehicles simultaneously, they will spawn at different locations
-- The system uses round-robin rotation to distribute vehicles across available spawn points
Config.VehicleSpawn = {
    -- Multiple locations format (recommended for servers with concurrent claims):
    {
        position = vector3(-1040.4397, -2727.0789, 19.8712),
        heading = 241.3119
    },
    {
        position = vector3(-1046.2606, -2723.9783, 20.0318),
        heading = 238.9994
    },
    -- Add more spawn locations below to prevent vehicle collisions:
    -- {
    --     position = vector3(x, y, z),
    --     heading = heading_value
    -- },
    -- Example with multiple locations:
    -- {
    --     position = vector3(-1045.0, -2730.0, 19.8712),
    --     heading = 241.3119
    -- },
    -- {
    --     position = vector3(-1035.0, -2724.0, 19.8712),
    --     heading = 241.3119
    -- },
}

-- Backward compatibility: If you want to use the old single location format, uncomment below:
-- Config.VehicleSpawn = vector3(-1040.4397, -2727.0789, 19.8712)
-- Config.VehicleSpawnHeading = 241.3119

-- Guide Book Settings
Config.GuideBook = {
    AdminPermissions = { 'god', 'admin' }, -- Users who can access admin mode and add posts in-game
    Categories = { -- The categories that will appear in the GuideBook and that admins will be able to select to create posts. 
        { name = 'WELCOME' },
        { name = 'TUTORIAL' },
        { name = 'JOBS' }
    }
}

Config.GuidebookAnimation = {
    enabled = true, -- Use animation when reading the guidebook
    dict = "missheistdockssetup1clipboard@base", -- which animation dictionary to use
    anim = "base", -- animation name
    prop = {
        enabled = true, -- Use a prop when reading the guidebook
        model = "prop_novel_01", -- prop model
        bone = 18905, -- bone id
        position = vector3(0.12, 0.008, 0.03), -- prop position
        rotation = vector3(0.0, 0.0, 0.0) -- prop rotation
    }
}

-- Location Settings
Config.Location = {
    position = vector3(-1039.4341, -2731.4712, 20.1695), -- Coordinate to open the UI
    interactionDistance = 2.0, -- Distance to interact with the NPC
    markerSettings = {
        enabled = true, -- Display a marker
        type = 21, -- Marker type
        colorR = 255, -- Red component of marker color
        colorG = 255, -- Green component of marker color
        colorB = 255, -- Blue component of marker color
        alpha = 200, -- Marker transparency
        useCustomHeight = true, -- Activate custom height
        heightOffset = 1.0 -- Units to raise the marker
    },
    blipSettings = {
        enabled = true, -- Display a blip on the map
        id = 835, -- Blip ID
        color = 5, -- Blip color
        scale = 0.7, -- Blip scale
        display = 2, -- Blip display type
        name = 'Starter Help' -- Blip name
    },
    npcSettings = {
        enabled = true, -- Enable or disable NPC presence
        heading = 209.1426, -- NPC heading
        model = 'a_m_y_business_03', -- NPC model
        playAnimation = true, -- Enable or disable NPC animation
        animationScenario = 'WORLD_HUMAN_CLIPBOARD' -- Scenario to play
    }
}

-- UI Settings
Config.UI = {
    colors = { -- Change the colors of the UI
        color1 = '#ff0451',        -- Main color (hover buttons, borders, accents)
        color2 = '#7f0027',        -- Secondary color (hover text)
        color3 = '#ff0451',        -- Button background color
        textColor = '#ffffff',     -- Main text color
        guidebookBackground = '#000000',  -- Guidebook content area background
        guidebookTextColor = '#FFFFFF'    -- Text color inside the guidebook
    },
    translations = { -- Translates UI texts
        starterp = 'STARTER PACK',
        vehiclep = 'VEHICLE',
        guidebookp = 'GUIDEBOOK',
        starterp2 = 'STARTER PACK',
        starterdesc = 'The <span>City Council</span> offers you, as an incentive to live in the city, a package of essentials to get you started in the city.',
        claimitem = 'Claim',
        starterveh = 'STARTER VEHICLE',
        startervehdesc = 'The <span>Transport Consortium</span> will temporarily lend you a State vehicle to start moving around the city. Remember this vehicle is given to you by the state. Remembered in the voting!',
        claimvehicle = 'Claim',
        adminbtn = 'Admin Mode',
        exitadmin = 'Exit Admin Mode',
        addbtn = 'Add a new one',
        guidebookt = 'GUIDEBOOK',
        gpsmark = 'Gps Mark',
        delete = 'Delete',
        sure1 = 'Sure?',
        yes1 = 'Yes',
        no1 = 'No',
        save1 = 'Save',
        sure2 = 'Sure?',
        saveconfirm = 'Yes',
        no2 = 'No',
        category1 = 'CATEGORY',
        gpsmark2 = 'Gps Mark',
        yes3 = 'Yes',
        no3 = 'No',
        content1 = 'Content',
        alreadyClaimed = 'Already Claimed',
        roleSelectTitle = 'CHOOSE YOUR PATH',
        roleSelectDesc = 'The <span>City Council</span> allows you to choose your starting path. Select wisely, this choice defines your initial resources.',
        civilianBtn = 'CIVILIAN',
        criminalBtn = 'CRIMINAL',
        civilianDesc = 'Start as a law-abiding citizen with essential supplies to build an honest life in the city.',
        criminalDesc = 'Start on the other side of the law with tools of the trade and street cash.',
        alreadyClaimedRole = 'You have already chosen a path.'
    }
}

-- Translations for Various Messages
Config.Translations = {
    openMenuText = 'Press ~INPUT_CONTEXT~ to open Starter menu',
    openMenuTarget = 'Open the Starter menu',
    itemMissing = 'You don\'t have the needed items',
    alreadyClaimedItem = "You have already claimed the starter pack items.",
    alreadyClaimedVehicle = "You have already claimed the starter vehicle.",
    receivedStarterPack = 'You have received your starter pack. Check your inventory!',
    receivedStarterVehicle = 'You have received a car from the State.',
    gpsMark = 'A route has been marked on your GPS',
    alreadyClaimed = 'You have already claimed this reward.',
    alreadyClaimedRole = 'You have already chosen your starting path.',
    invalidCoordinates = 'Invalid coordinates',
    postCreated = 'Post created successfully.',
    postUpdated = 'Post updated successfully.',
    postDeleted = 'Post deleted successfully.',
    guidebookOpened = 'Guidebook opened.',
    noPermission = "You don't have permission to perform this action.",
    adminModeEntered = 'You have entered Admin Mode. You can now add new posts or edit/delete existing ones.',
    allSpawnLocationsOccupied = 'All vehicle spawn locations are currently occupied. Please try again later.',
    invalidSpawnPosition = 'Error: Invalid spawn position. Please try again.'
}

-- Functions
Config.Functions = {

    DrawText = function(text) -- Customize floating text UI
        AddTextEntry('ALERT_MESSAGE', text)
        BeginTextCommandDisplayHelp('ALERT_MESSAGE')
        EndTextCommandDisplayHelp(0, false, false, -1)
    end,

    Notify = function(text, notifyType, length) -- Notification system (framework or custom)
        -- Normalize message to string to avoid showing references like 'table: 0x..' or unexpected values
        local message
        if type(text) == 'string' then
            message = text
        elseif type(text) == 'number' then
            message = tostring(text)
        elseif type(text) == 'table' then
            -- Try to serialize table; fallback to tostring
            local ok, encoded = pcall(function() return json and json.encode(text) or nil end)
            message = (ok and encoded) or tostring(text)
        else
            message = tostring(text or 'Notification')
        end

        -- Prefer framework-native notifies if available
        if Config.Framework == 'esx' and ESX and ESX.ShowNotification then
            ESX.ShowNotification(message)
            return
        end

        if Config.Framework == 'qbcore' and QBCore and QBCore.Functions and QBCore.Functions.Notify then
            -- QBCore: message[, type[, length]]
            if notifyType ~= nil or length ~= nil then
                QBCore.Functions.Notify(message, notifyType, length)
            else
                QBCore.Functions.Notify(message)
            end
            return
        end

        -- Fallback native notification (works on both frameworks)
        BeginTextCommandThefeedPost('STRING')
        AddTextComponentSubstringPlayerName(message)
        EndTextCommandThefeedPostTicker(false, false)
    end,

    -- Add function for vehicle keys for esx and qb
    -- All export calls are fully wrapped in pcall to prevent "No such export" errors
    VehicleKeys = function(vehicle)
        local plate = GetVehicleNumberPlateText(vehicle)
        local model = GetEntityModel(vehicle)

        -- Helper: safely attempt an export call, returns true if successful
        local function tryExport(fn)
            local ok, err = pcall(fn)
            return ok
        end
        
        -- VMS Garages V2 - Uses wasabi_carlock or other key systems
        if Config.Garage and (Config.Garage.Provider == 'vms_garagesv2_esx' or Config.Garage.Provider == 'vms_garagesv2_qb') then
            if tryExport(function() exports.wasabi_carlock:GiveKey(plate) end) then return end
            if Config.Framework == 'qbcore' then
                TriggerEvent('vehiclekeys:client:SetOwner', plate)
                return
            end
            return
        end
        
        if Config.Garage and (Config.Garage.Provider == 'okok_esx' or Config.Garage.Provider == 'okok_qb') then
            TriggerServerEvent('okokGarage:GiveKeys', plate)
            return
        elseif Config.Garage and (Config.Garage.Provider == 'cd_esx' or Config.Garage.Provider == 'cd_qb') then
            TriggerServerEvent('cd_garage:AddKeysOwnedVehicle', plate, model)
            return
        elseif Config.Garage and (Config.Garage.Provider == 'codem_esx' or Config.Garage.Provider == 'codem_qb') then
            -- Try common key systems used with CodeM Garage
            -- qb-vehiclekeys (QBCore native) - try first if using QBCore
            if Config.Framework == 'qbcore' then
                TriggerEvent('vehiclekeys:client:SetOwner', plate)
                return
            end
            -- MrNewbVehicleKeys
            if tryExport(function() exports.MrNewbVehicleKeys:GiveKeysByPlate(plate) end) then return end
            -- qs-vehiclekeys
            local vehicleName = GetDisplayNameFromVehicleModel(model)
            if tryExport(function() exports['qs-vehiclekeys']:GiveKeys(plate, vehicleName) end) then return end
            -- wasabi_carlock
            if tryExport(function() exports.wasabi_carlock:GiveKey(plate) end) then return end
            -- cd_garage as a fallback if present
            TriggerServerEvent('cd_garage:AddKeysOwnedVehicle', plate, model)
            return
        elseif Config.Garage and Config.Garage.Provider == 'op_garages' then
            -- OP Garages - uses common key systems
            -- QBCore native vehicle keys
            if Config.Framework == 'qbcore' then
                TriggerEvent('vehiclekeys:client:SetOwner', plate)
                return
            end
            -- ESX key systems
            -- MrNewbVehicleKeys
            if tryExport(function() exports.MrNewbVehicleKeys:GiveKeysByPlate(plate) end) then return end
            -- qs-vehiclekeys
            local vehicleName = GetDisplayNameFromVehicleModel(model)
            if tryExport(function() exports['qs-vehiclekeys']:GiveKeys(plate, vehicleName) end) then return end
            -- wasabi_carlock
            if tryExport(function() exports.wasabi_carlock:GiveKey(plate) end) then return end
            return
        end
        if Config.Framework == 'esx' then
            -- ESX generic fallbacks for popular key systems
            -- MrNewbVehicleKeys
            if tryExport(function() exports.MrNewbVehicleKeys:GiveKeysByPlate(plate) end) then return end
            -- qs-vehiclekeys
            local vehicleName = GetDisplayNameFromVehicleModel(model)
            if tryExport(function() exports['qs-vehiclekeys']:GiveKeys(plate, vehicleName) end) then return end
            -- wasabi_carlock
            if tryExport(function() exports.wasabi_carlock:GiveKey(plate) end) then return end
            -- cd_garage fallback
            TriggerServerEvent('cd_garage:AddKeysOwnedVehicle', plate, model)
        elseif Config.Framework == 'qbcore' then
            -- QBCore default vehicle keys (most common, try first)
            TriggerEvent('vehiclekeys:client:SetOwner', plate)
        end
    end
    
}
```

{% endcode %}
{% endtab %}

{% tab title="SERVER\_OPEN" %}

```lua
-- ╔══════════════════════════════════════════════════════════════════╗
-- ║                    DISCORD LOGS CONFIGURATION                   ║
-- ╚══════════════════════════════════════════════════════════════════╝
DiscordLogs = {
    Enabled    = false,                          -- Set to true to enable Discord logging
    WebhookURL = '',                             -- Your Discord webhook URL
    BotName    = 'Forge Starter Logs',           -- Bot display name in Discord
    BotAvatar  = '',                             -- Bot avatar URL (leave empty for default)
    Color      = 16711731,                       -- Embed color in decimal (default: pink/red #FF0033)
    ServerName = 'My Server',                    -- Your server name (shown in embed footer)
}

-- ╔══════════════════════════════════════════════════════════════════╗
-- ║                    SQL AUTO-MIGRATION                            ║
-- ╚══════════════════════════════════════════════════════════════════╝
-- Automatically detects missing columns and adds them on resource start
-- so you never need to manually delete/recreate tables when the schema changes.

CreateThread(function()
    -- Wait for SQL wrapper to be ready
    Wait(2000)

    -- Helper: run ALTER TABLE silently (IF NOT EXISTS handles duplicates natively)
    local function safeAddColumn(table_, name, definition)
        local ok, err = pcall(function()
            SQL.Execute(
                ('ALTER TABLE `%s` ADD COLUMN IF NOT EXISTS `%s` %s'):format(table_, name, definition),
                {}
            )
        end)
        if ok then
            print(('[forge-starter] Auto-migration: Ensured column "%s" in %s.'):format(name, table_))
        else
            -- Only warn on actual unexpected errors (not duplicate column)
            if err and not tostring(err):find('Duplicate column') then
                print(('[forge-starter] Auto-migration WARNING on column "%s": %s'):format(name, tostring(err)))
            end
        end
    end

    if Config.Framework == 'esx' then
        -- ESX: ensure all required columns exist in 'users' table
        safeAddColumn('users', 'starter_item',     'INT(11) NOT NULL DEFAULT 0')
        safeAddColumn('users', 'starter_vehicle',  'INT(11) NOT NULL DEFAULT 0')
        safeAddColumn('users', 'starter_civilian', 'INT(11) NOT NULL DEFAULT 0')
        safeAddColumn('users', 'starter_criminal', 'INT(11) NOT NULL DEFAULT 0')

        -- Ensure starter_posts table exists
        pcall(function()
            SQL.Execute([[
                CREATE TABLE IF NOT EXISTS `starter_posts` (
                    `id` INT(11) NOT NULL AUTO_INCREMENT,
                    `category` LONGTEXT NULL,
                    `data` LONGTEXT NULL,
                    `text` LONGTEXT NULL,
                    PRIMARY KEY (`id`)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
            ]], {})
        end)

        print('[forge-starter] Auto-migration: ESX schema check complete.')

    elseif Config.Framework == 'qbcore' then
        -- QBCore: create starter_claims if missing, then ensure all columns exist
        pcall(function()
            SQL.Execute([[
                CREATE TABLE IF NOT EXISTS `starter_claims` (
                    `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
                    `identifier` VARCHAR(64) NOT NULL,
                    `item` INT(11) NOT NULL DEFAULT 0,
                    `vehicle` INT(11) NOT NULL DEFAULT 0,
                    `civilian` INT(11) NOT NULL DEFAULT 0,
                    `criminal` INT(11) NOT NULL DEFAULT 0,
                    PRIMARY KEY (`id`),
                    UNIQUE KEY `ux_starter_claims_identifier` (`identifier`)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
            ]], {})
        end)

        safeAddColumn('starter_claims', 'item',     'INT(11) NOT NULL DEFAULT 0')
        safeAddColumn('starter_claims', 'vehicle',  'INT(11) NOT NULL DEFAULT 0')
        safeAddColumn('starter_claims', 'civilian', 'INT(11) NOT NULL DEFAULT 0')
        safeAddColumn('starter_claims', 'criminal', 'INT(11) NOT NULL DEFAULT 0')

        -- Ensure starter_posts table exists
        pcall(function()
            SQL.Execute([[
                CREATE TABLE IF NOT EXISTS `starter_posts` (
                    `id` INT(11) NOT NULL AUTO_INCREMENT,
                    `category` LONGTEXT NULL,
                    `data` LONGTEXT NULL,
                    `text` LONGTEXT NULL,
                    PRIMARY KEY (`id`)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
            ]], {})
        end)

        print('[forge-starter] Auto-migration: QBCore schema check complete.')
    end
end)

--- Send a log message to the configured Discord webhook.
--- Safe to call at any time – if logging is disabled or the request fails, nothing breaks.
---@param title string  Embed title
---@param description string  Embed description (supports Discord markdown)
---@param color number|nil  Optional override for embed color
function SendDiscordLog(title, description, color)
    -- Guard: skip silently when disabled or no URL configured
    if not DiscordLogs.Enabled then return end
    if not DiscordLogs.WebhookURL or DiscordLogs.WebhookURL == '' then return end

    local embedColor = color or DiscordLogs.Color or 16711731

    local payload = json.encode({
        username   = DiscordLogs.BotName or 'Forge Starter Logs',
        avatar_url = (DiscordLogs.BotAvatar and DiscordLogs.BotAvatar ~= '') and DiscordLogs.BotAvatar or nil,
        embeds     = {{
            title       = title,
            description = description,
            color       = embedColor,
            footer      = { text = (DiscordLogs.ServerName or 'Server') .. ' • forge-starter' },
            timestamp   = os.date('!%Y-%m-%dT%H:%M:%SZ'),
        }},
    })

    -- pcall ensures the game continues even if the HTTP request errors
    pcall(function()
        PerformHttpRequest(DiscordLogs.WebhookURL, function(err, text, headers) end, 'POST', payload, { ['Content-Type'] = 'application/json' })
    end)
end

function IsPlateTaken(plate)
    if Config.Framework == 'qbcore' then
        local row = SQL.Execute('SELECT plate FROM player_vehicles WHERE plate = ?', { plate })[1]
        return row
    else
        local row = SQL.Execute('SELECT plate FROM owned_vehicles WHERE plate = ?', { plate })[1]
        return row
    end
end

--Store vehicle using selected garage provider
StoreVehicleInGarage = function(source)
    local plate = GeneratePlate()
    
    local vehicleModel = Config.VehicleStarter.VehicleModels[1].Model 
    if #Config.VehicleStarter.VehicleModels > 1 then
        local r = math.random()
        local acc = 0.0
        for index, vehicleOption in ipairs(Config.VehicleStarter.VehicleModels) do
            acc = acc + (vehicleOption.Probability or 0)
            if r <= acc then
                vehicleModel = vehicleOption.Model
                break 
            end
            if index == #Config.VehicleStarter.VehicleModels then
                vehicleModel = vehicleOption.Model
            end
        end
    end

    local provider = (Config.Garage and Config.Garage.Provider) or 'esx_default'
    if provider == 'qbcore_default' then
        local qbPlayer = QBCore and QBCore.Functions.GetPlayer(source)
    if not qbPlayer then return end
    local vehicleHash = joaat(vehicleModel)
        local defaults = (Config.Garage.Defaults and Config.Garage.Defaults.qbcore_default) or {}
    SQL.Execute('INSERT INTO player_vehicles (license, citizenid, vehicle, hash, mods, plate, garage, state) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', { 
            qbPlayer.PlayerData.license or QBCore.Functions.GetIdentifier(source, 'license'),
        qbPlayer.PlayerData.citizenid, 
        vehicleModel,
        vehicleHash, 
        json.encode({ model = vehicleHash, plate = plate }), 
        plate, 
            defaults.garage or 'pillboxgarage',
            1 -- mark as out of garage since we spawn it now
        })
    elseif provider == 'jg_advanced_esx' then
        local esxPlayer = ESX and ESX.GetPlayerFromId(source)
    if not esxPlayer then return end
        local defaults = (Config.Garage.Defaults and Config.Garage.Defaults.jg_advanced_esx) or {}
    SQL.Execute('INSERT INTO owned_vehicles (owner, plate, vehicle, in_garage, garage_id) VALUES (?, ?, ?, ?, ?)', { 
        esxPlayer.identifier, 
        plate, 
        json.encode({ model = joaat(vehicleModel), plate = plate }),
            0, -- mark as out of garage since we spawn it now
            defaults.garage_id or 'Legion Square'
        })
    elseif provider == 'jg_advanced_qb' then
        local qbPlayer = QBCore and QBCore.Functions.GetPlayer(source)
    if not qbPlayer then return end
    local citizenid = qbPlayer.PlayerData.citizenid
        local license = qbPlayer.PlayerData.license or (QBCore.Functions.GetIdentifier and QBCore.Functions.GetIdentifier(source, 'license'))
    if not license then return end
    local vehicleHash = joaat(vehicleModel)
        local defaults = (Config.Garage.Defaults and Config.Garage.Defaults.jg_advanced_qb) or {}
    SQL.Execute('INSERT INTO player_vehicles (citizenid, license, vehicle, hash, plate, mods, in_garage, garage_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', { 
        citizenid, 
        license,
        vehicleModel,
        vehicleHash,
        plate, 
        json.encode({ model = vehicleHash, plate = plate }),
            0, -- mark as out of garage since we spawn it now
            defaults.garage_id or 'Legion Square'
        })
    elseif provider == 'okok_esx' then
        local esxPlayer = ESX and ESX.GetPlayerFromId(source)
        if not esxPlayer then return end
        local d = (Config.Garage.Defaults and Config.Garage.Defaults.okok_esx) or {}
        SQL.Execute('INSERT INTO owned_vehicles (owner, plate, vehicle, parking, doorcondition, windowcondition, tyrecondition, favourite, impoundTime, location, reason, sharedwith, vehiclename) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', {
            esxPlayer.identifier,
            plate,
            json.encode({ model = joaat(vehicleModel), plate = plate }),
            d.parking,
            d.doorcondition,
            d.windowcondition,
            d.tyrecondition,
            d.favourite or 0,
            d.impoundTime,
            d.location,
            d.reason,
            d.sharedwith or '[]',
            d.vehiclename
        })
    elseif provider == 'okok_qb' then
        local qbPlayer = QBCore and QBCore.Functions.GetPlayer(source)
        if not qbPlayer then return end
        local license = qbPlayer.PlayerData.license or (QBCore.Functions.GetIdentifier and QBCore.Functions.GetIdentifier(source, 'license'))
        if not license then return end
        local d = (Config.Garage.Defaults and Config.Garage.Defaults.okok_qb) or {}
        local vehicleHash = joaat(vehicleModel)
        SQL.Execute('INSERT INTO player_vehicles (license, citizenid, vehicle, hash, mods, plate, parking, doorcondition, windowcondition, tyrecondition, favourite, impoundTime, location, reason, sharedwith, vehiclename) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', {
            license,
            qbPlayer.PlayerData.citizenid,
            vehicleModel,
            vehicleHash,
            json.encode({ model = vehicleHash, plate = plate }),
            plate,
            d.parking,
            d.doorcondition,
            d.windowcondition,
            d.tyrecondition,
            d.favourite or 0,
            d.impoundTime,
            d.location,
            d.reason,
            d.sharedwith or '[]',
            d.vehiclename
        })
    elseif provider == 'cd_esx' then
        local esxPlayer = ESX and ESX.GetPlayerFromId(source)
        if not esxPlayer then return end
        local d = (Config.Garage.Defaults and Config.Garage.Defaults.cd_esx) or {}
        local adv_stats = json.encode({ plate = plate, mileage = 0.0, maxhealth = 1000.0 })
        SQL.Execute('INSERT INTO owned_vehicles (owner, plate, vehicle, in_garage, garage_id, garage_type, job_personalowned, property, impound, impound_data, adv_stats, name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', {
            esxPlayer.identifier,
            plate,
            json.encode({ model = joaat(vehicleModel), plate = plate }),
            0, -- mark as out of garage since we spawn it now
            d.garage_id or 'A',
            d.garage_type or 'car',
            d.job_personalowned or '',
            d.property or 0,
            d.impound or 0,
            d.impound_data or '',
            adv_stats,
            d.name
        })
        -- Keys are handled client-side via Config.Functions.VehicleKeys
    elseif provider == 'cd_qb' then
        local qbPlayer = QBCore and QBCore.Functions.GetPlayer(source)
        if not qbPlayer then return end
        local license = qbPlayer.PlayerData.license or (QBCore.Functions.GetIdentifier and QBCore.Functions.GetIdentifier(source, 'license'))
        if not license then return end
        local d = (Config.Garage.Defaults and Config.Garage.Defaults.cd_qb) or {}
        local adv_stats = json.encode({ plate = plate, mileage = 0.0, maxhealth = 1000.0 })
        local vehicleHash = joaat(vehicleModel)
        SQL.Execute('INSERT INTO player_vehicles (license, citizenid, vehicle, hash, mods, plate, in_garage, garage_id, garage_type, job_personalowned, property, impound, impound_data, adv_stats, name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', {
            license,
            qbPlayer.PlayerData.citizenid,
            vehicleModel,
            vehicleHash,
            json.encode({ model = vehicleHash, plate = plate }),
            plate,
            0, -- mark as out of garage since we spawn it now
            d.garage_id or 'A',
            d.garage_type or 'car',
            d.job_personalowned or '',
            d.property or 0,
            d.impound or 0,
            d.impound_data or '',
            adv_stats,
            d.name
        })
        -- Keys are handled client-side via Config.Functions.VehicleKeys
    elseif provider == 'codem_esx' then
        local esxPlayer = ESX and ESX.GetPlayerFromId(source)
        if not esxPlayer then return end
        local d = (Config.Garage.Defaults and Config.Garage.Defaults.codem_esx) or {}
        SQL.Execute('INSERT INTO owned_vehicles (owner, plate, vehicle, stored, parking, favorite) VALUES (?, ?, ?, ?, ?, ?)', {
            esxPlayer.identifier,
            plate,
            json.encode({ model = joaat(vehicleModel), plate = plate }),
            0, -- mark as out of garage since we spawn it now
            d.parking or 'Garage A',
            d.favorite or '0'
        })
        -- Keys are handled client-side via Config.Functions.VehicleKeys
    elseif provider == 'codem_qb' then
        local qbPlayer = QBCore and QBCore.Functions.GetPlayer(source)
        if not qbPlayer then return end
        local license = qbPlayer.PlayerData.license or (QBCore.Functions.GetIdentifier and QBCore.Functions.GetIdentifier(source, 'license'))
        if not license then return end
        local d = (Config.Garage.Defaults and Config.Garage.Defaults.codem_qb) or {}
        local vehicleHash = joaat(vehicleModel)
        SQL.Execute('INSERT INTO player_vehicles (license, citizenid, vehicle, hash, mods, plate, stored, parking, favorite) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', {
            license,
            qbPlayer.PlayerData.citizenid,
            vehicleModel,
            vehicleHash,
            json.encode({ model = vehicleHash, plate = plate }),
            plate,
            0, -- mark as out of garage since we spawn it now
            d.parking or 'Garage A',
            d.favorite or '0'
        })
    elseif provider == 'vms_garagesv2_esx' then
        -- VMS Garages V2 for ESX - Uses export to give vehicle
        local esxPlayer = ESX and ESX.GetPlayerFromId(source)
        if not esxPlayer then return end
        local d = (Config.Garage.Defaults and Config.Garage.Defaults.vms_garagesv2_esx) or {}
        -- giveVehicle(source, owner, type, model, plate)
        -- owner: number (player id) = saves to "owner" tab | string (job name) = saves to "company" tab
        -- After adding, the vehicle will go to the impound matching for the selected type
        local success = exports["vms_garagesv2"]:giveVehicle(
            source,                    -- source: player id
            source,                    -- owner: player id (number) - saves to "owner" tab
            'vehicle',                 -- type: vehicle/boat/plane/helicopter
            vehicleModel,              -- model: model name
            plate                      -- plate: license plate
        )
        if not success then
            print('[forge-starter] VMS Garages V2: Failed to give vehicle to player')
        end
    elseif provider == 'vms_garagesv2_qb' then
        -- VMS Garages V2 for QBCore - Uses export to give vehicle
        local qbPlayer = QBCore and QBCore.Functions.GetPlayer(source)
        if not qbPlayer then return end
        local d = (Config.Garage.Defaults and Config.Garage.Defaults.vms_garagesv2_qb) or {}
        -- giveVehicle(source, owner, type, model, plate)
        -- owner: number (player id) = saves to "owner" tab | string (job name) = saves to "company" tab
        local success = exports["vms_garagesv2"]:giveVehicle(
            source,                           -- source: player id
            source,                           -- owner: player id (number) - saves to "owner" tab
            'vehicle',                        -- type: vehicle/boat/plane/helicopter
            vehicleModel,                     -- model: model name
            plate                             -- plate: license plate
        )
        if not success then
            print('[forge-starter] VMS Garages V2: Failed to give vehicle to player')
        end
    elseif provider == 'op_garages' then
        -- OP Garages - Vehicle is added as player-owned
        -- Vehicle is inserted into player_vehicles/owned_vehicles so player owns it
        -- Player can save/retrieve from any OP Garages zone on the map
        local d = (Config.Garage.Defaults and Config.Garage.Defaults.op_garages) or {}
        local vehicleHash = joaat(vehicleModel)
        
        if Config.Framework == 'qbcore' then
            local qbPlayer = QBCore and QBCore.Functions.GetPlayer(source)
            if not qbPlayer then return end
            local license = qbPlayer.PlayerData.license or (QBCore.Functions.GetIdentifier and QBCore.Functions.GetIdentifier(source, 'license'))
            if not license then return end
            
            -- Insert vehicle as owned by player (like other QBCore garage providers)
            -- OP Garages will recognize this as a private vehicle
            local result = SQL.Execute('INSERT INTO player_vehicles (license, citizenid, vehicle, hash, mods, plate, garage, state) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', {
                license,
                qbPlayer.PlayerData.citizenid,
                vehicleModel,
                vehicleHash,
                json.encode({ 
                    model = vehicleHash,
                    plate = plate
                }),
                plate,
                d.garage or 'pillboxgarage', -- Default garage name
                1 -- Mark as out of garage since we spawn it immediately
            })
            
            if result then
                print('[forge-starter] OP Garages: Vehicle added as owned for player ' .. qbPlayer.PlayerData.citizenid)
            end
        else
            -- ESX framework
            local esxPlayer = ESX and ESX.GetPlayerFromId(source)
            if not esxPlayer then return end
            
            -- Insert vehicle as owned by player (like other ESX garage providers)
            local result = SQL.Execute('INSERT INTO owned_vehicles (owner, plate, vehicle) VALUES (?, ?, ?)', {
                esxPlayer.identifier,
                plate,
                json.encode({ 
                    model = vehicleHash,
                    plate = plate
                })
            })
            
            if result then
                print('[forge-starter] OP Garages: Vehicle added as owned for player ' .. esxPlayer.identifier)
            end
        end
    else
        -- esx_default
        local esxPlayer = ESX and ESX.GetPlayerFromId(source)
        if not esxPlayer then return end
        SQL.Execute('INSERT INTO owned_vehicles (owner, plate, vehicle) VALUES (?, ?, ?)', {
            esxPlayer.identifier,
            plate,
            json.encode({ model = joaat(vehicleModel), plate = plate })
        })
    end

    -- DO NOT COMMENT THIS LINE - Required for all implementations
    -- Get spawn location to prevent collisions when multiple players claim simultaneously
    local spawnPos, spawnHeading = GetNextSpawnLocation()
    -- Get identifier for spawn confirmation (already marked as claimed for garage vehicles)
    local identifier = nil
    if Config.Framework == 'esx' then
        local esxPlayer = ESX and ESX.GetPlayerFromId(source)
        if esxPlayer then identifier = esxPlayer.identifier end
    elseif Config.Framework == 'qbcore' then
        local qbPlayer = QBCore and QBCore.Functions.GetPlayer(source)
        if qbPlayer then identifier = qbPlayer.PlayerData.citizenid end
    end
    TriggerClientEvent('forge-starter:spawnVehicle', source, plate, joaat(vehicleModel), identifier, spawnPos, spawnHeading)
end
```

{% endtab %}

{% tab title="QB.SQL" %}

```sql
CREATE TABLE IF NOT EXISTS `starter_claims` (
  `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
  `identifier` VARCHAR(64) NOT NULL,
  `item` INT(11) NOT NULL DEFAULT 0,
  `vehicle` INT(11) NOT NULL DEFAULT 0,
  `civilian` INT(11) NOT NULL DEFAULT 0,
  `criminal` INT(11) NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_starter_claims_identifier` (`identifier`)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS `starter_posts` (
  `id` INT(11) NOT NULL AUTO_INCREMENT,
  `category` LONGTEXT NULL,
  `data` LONGTEXT NULL,
  `text` LONGTEXT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

```

{% endtab %}

{% tab title="ESX.SQL" %}

```sql
ALTER TABLE `users`
  ADD COLUMN IF NOT EXISTS `starter_item` INT(11) NOT NULL DEFAULT 0 AFTER `group`,
  ADD COLUMN IF NOT EXISTS `starter_vehicle` INT(11) NOT NULL DEFAULT 0 AFTER `starter_item`,
  ADD COLUMN IF NOT EXISTS `starter_civilian` INT(11) NOT NULL DEFAULT 0 AFTER `starter_vehicle`,
  ADD COLUMN IF NOT EXISTS `starter_criminal` INT(11) NOT NULL DEFAULT 0 AFTER `starter_civilian`;

CREATE TABLE IF NOT EXISTS `starter_posts` (
  `id` INT(11) NOT NULL AUTO_INCREMENT,
  `category` LONGTEXT NULL,
  `data` LONGTEXT NULL,
  `text` LONGTEXT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_unicode_ci;

```

{% endtab %}
{% endtabs %}

{% hint style="success" %}
**If you want to edit the aesthetics or design. You have the HTML open so you can modify the style and everything as you want.**

The script is **RESPONSIVE** for all resolutions as well.
{% endhint %}
