Luxu Admin API — Connection Queue

Server-side exports and hooks for the built-in connection queue in luxu_admin.

Call these from another resource that starts after luxu_admin. There are no client exports for the queue, and there is no export to move, remove, or admit players — staff do that in the panel (queue.view / queue.manage).

See also: Exports · Server Hooks · Events


Requirements

The queue is enabled with queue.enabled in config/config.json. When it is enabled, LuxU Admin is the only resource that may enforce sv_maxclients: stop hardcap and any other queue resource, otherwise players are rejected twice or admitted past the slot limit. While the queue is enabled, luxu_admin stops the stock hardcap resource automatically.

sv_maxclients is read live, so changing it at runtime immediately changes how many players the queue admits.

When the queue is disabled, players are admitted right after the ban preflight. Priority providers still run (up to 2000 ms, in parallel) and the admitted hook still includes the resolved priority.


Reserved slots

queue.reserved_slots holds back N slots that regular players cannot fill. With sv_maxclients 64 and reserved_slots 4, regular players are admitted while fewer than 60 players (plus players still loading in) are on the server; eligible players are admitted while fewer than 64 are. Set it to 0 to disable.

An attempt is eligible for reserved slots when either of these is true at connection time:

  • its resolved priority is >= queue.reserved_slots_min_priority (default 100; priorities are clamped to 0..1000, so a threshold above 1000 disables this path and 0 makes everyone eligible), or
  • queue.reserved_slots_for_staff is true and one of the player's identifiers matches a registered staff member's license2, discord, steam, or fivem, or a config.owners entry. This uses the in-memory staff list; no database query runs per connection or per tick. Owner entries configured as license: also match a connecting license2: of the same hash.

Eligibility is computed once when the attempt is admitted to the queue and does not reorder the queue: entries are still ordered by priority (higher first) and arrival. Eligible entries only overtake regular entries when regular capacity is exhausted and a reserved slot is free.

VIP resources mark a player as eligible by returning a priority at or above the threshold from a priority provider. There is no separate export for reserved slots.


Server Exports

registerQueuePriorityProvider(provider)

Registers a callback that assigns a queue priority to a connecting player. Providers run during the connection preflight, before the player is queued or admitted. When several providers are registered, they run in parallel; the highest priority wins and its label is shown to staff and to the player. If two providers return the same priority, the first label is kept unless it is empty and a later provider supplies one.

Parameters:

  • provider (function) — (identifiers, name) => result
---@alias PriorityProviderResult number | { priority: number, label?: string } | nil | false
---@alias PriorityProvider fun(identifiers: string[], name: string): PriorityProviderResult
  • identifiers (string[]) — the player's identifiers (license2:, license:, discord:, steam:, …). A copy is passed in, so mutating the array does not affect the queue. ip: identifiers are omitted when disable_ip_address_usage is true.
  • name (string) — the connecting player's name.

Returns: function — call it to unregister this provider.

Rules:

  • Priority is clamped to 0..1000 (non-finite values become 0). Higher values connect first. 0, nil, and false mean "no priority".
  • Returning a string, or an object without a priority field, is ignored (treated as no result).
  • Optional label is trimmed; empty labels become nil. Labels longer than 32 characters are truncated.
  • A priority >= queue.reserved_slots_min_priority (default 100) also makes the player eligible for reserved slots.
  • Providers may return a promise (or yield in Lua). The queue waits at most 2000 ms per provider, then treats that provider as 0.
  • Providers that throw or reject are treated as 0 and the error is logged to the server console ([Queue] priority provider threw).
  • A non-function provider throws: registerQueuePriorityProvider expects a function.
  • Providers are removed automatically when the resource that registered them stops.
local unregister = exports.luxu_admin:registerQueuePriorityProvider(function(identifiers, name)
    for _, identifier in ipairs(identifiers) do
        if identifier == "discord:123456789012345678" then
            return { priority = 100, label = "VIP" }
        end
    end
    return 0
end)

-- later, if you need to stop providing priority
unregister()
// TypeScript / JavaScript resource
const unregister = exports.luxu_admin.registerQueuePriorityProvider(
  async (identifiers: string[], name: string) => {
    const vip = await lookupVip(identifiers);
    return vip ? { priority: vip.tier * 100, label: vip.name } : 0;
  }
);

unregisterQueuePriorityProvider(provider)

Removes a provider previously passed to registerQueuePriorityProvider. Equivalent to calling the function returned from registration.

Parameters:

  • provider (function) — the same function reference that was registered

Returns: boolean — true if the provider was registered

local function provider(identifiers, name)
    return 0
end

exports.luxu_admin:registerQueuePriorityProvider(provider)
exports.luxu_admin:unregisterQueuePriorityProvider(provider)

getQueueSnapshot()

Returns the current queue state. Useful for external dashboards or Discord bots. Safe to call when the queue is disabled (enabled will be false).

Returns:

---@class QueueCharacter
---@field id string -- charId (ESX identifier, QB/QBX citizenid, vRP character id)
---@field name string

---@class QueueSnapshotEntry
---@field id string -- attempt id (`license2:`/`license:` identifier + `#` + generation)
---@field name string
---@field uniqueId string -- `license2:` or `license:` identifier used as the queue key
---@field priority number
---@field priorityLabel string|nil
---@field eligibleForReserved boolean -- may use one of the reserved slots
---@field position number -- 1-based for waiting entries; 0 for released entries that are still loading in
---@field state "waiting"|"reserved"
---@field joinedAt number -- unix ms
---@field characters QueueCharacter[] -- framework characters (max 10); empty while the lookup is pending, when none were found, or for immediately admitted attempts

---@class QueueSnapshot
---@field enabled boolean
---@field maxClients number -- sv_maxclients
---@field players number -- GetNumPlayerIndices()
---@field reserved number -- released attempts still loading in (already count as used slots)
---@field reservedSlots number -- queue.reserved_slots, clamped to maxClients
---@field regularCapacity number -- maxClients - reservedSlots; the limit for non-eligible players
---@field updatedAt number -- unix ms
---@field entries QueueSnapshotEntry[]

reserved (attempts loading in) and reservedSlots (slots held back) are unrelated counters.

local snapshot = exports.luxu_admin:getQueueSnapshot()

print(("online=%s  loading in=%s  reserved slots=%s  regular capacity=%s"):format(
    snapshot.players,
    snapshot.reserved,
    snapshot.reservedSlots,
    snapshot.regularCapacity
))

for _, entry in ipairs(snapshot.entries) do
    print(entry.position, entry.name, entry.priority, entry.state, entry.eligibleForReserved)
end

Hooks

Queue hooks use the generic registerHook / removeHook exports and the matching server events luxu_admin:server:<hook_name>. These are not networked client events.

Lua resources can listen with AddEventHandler. JavaScript/TypeScript resources should use registerHook (invalid names throw at registration time).

See Server Hooks for the shared registration pattern.

queue_player_admitted

Fires on playerJoining when the queue had reserved a slot for that connection (the deferral was already completed with done(), and the player is loading in). source is the final server id; tempId is the connecting id.

---@class QueuePlayerAdmitted
---@field source number -- final server id at join time
---@field tempId number -- connecting (temporary) id
---@field name string
---@field identifiers string[]
---@field priority number
---@field priorityLabel string|nil
---@field waitedMs number -- milliseconds from enqueue to join
AddEventHandler("luxu_admin:server:queue_player_admitted", function(data)
    print(("%s admitted after %d s"):format(data.name, math.floor(data.waitedMs / 1000)))
end)
const unregister = exports.luxu_admin.registerHook(
  'queue_player_admitted',
  (data: {
    source: number;
    tempId: number;
    name: string;
    identifiers: string[];
    priority: number;
    priorityLabel: string | null;
    waitedMs: number;
  }) => {
    console.log(`${data.name} admitted after ${Math.floor(data.waitedMs / 1000)}s`);
  }
);

queue_player_removed

Fires when an attempt leaves the queue without being admitted.

---@class QueuePlayerRemoved
---@field tempId number
---@field name string
---@field identifiers string[]
---@field reason "admin"|"timeout"|"disconnected"|"replaced"|"queue_full"|"resource_stop"|"preflight_rejected"|"reservation_expired"
---@field staffId number|nil -- set when reason is "admin"; otherwise `nil`/`null`
Reason When
admin Staff removed the player from the queue (queue.manage)
timeout Waited longer than queue.max_wait_minutes
disconnected The connecting client dropped
replaced A newer connection from the same license replaced this attempt
queue_full queue.max_queue_size was already reached
resource_stop luxu_admin is stopping
preflight_rejected Ban/preflight rejected the connection, including a ban that lands while the player is waiting and is re-checked just before release
reservation_expired A newer attempt from the same license replaced a player who was already released and loading in
AddEventHandler("luxu_admin:server:queue_player_removed", function(data)
    if data.reason == "admin" then
        print(("%s removed from the queue by staff %s"):format(data.name, tostring(data.staffId)))
    end
end)

Ban preflight (config/moderation.lua)

The playerConnecting export in config/moderation.lua is still called for every connection, as a preflight step, before the player is admitted or queued.

The deferrals object it receives is a wrapper owned by the queue (the queue already called deferrals.defer()):

  • deferrals.update(text) / deferrals.presentCard(...) are forwarded to the player.
  • deferrals.done() with no reason means "preflight passed". It does not admit the player; the queue decides whether to admit or queue them.
  • deferrals.done(reason) rejects the connection with that reason.
  • Always call deferrals.done(...) exactly once. If you never call it, the player is rejected after the preflight timeout (30 seconds) with the queue timeout message.

Existing customisations keep working without changes: the old contract (done() = allow, done(reason) = reject) is kept.

If the Lua export is missing, the queue falls back to its own ban check (You are banned: <reason>).