v1.15.4
Summary
This release introduces a built-in connection queue with reserved slots, priority handling, and a customisable Adaptive Card (presentCard.json), alongside improved offline job and gang management that keeps labels, grades, and duty state in sync. It also fixes the gamertag colour picker, search modal, and offline-player toggle.
Developer API for the queue (priority providers, snapshot export, and hooks): Connection Queue.
What's New
Features
- Built-in connection queue — configurable size, wait limits, reservation timeouts, tick releases, reserved slots with staff/priority bypass, and an Adaptive Card shown to queued players (new
queuesection inconfig.jsonpluspresentCard.json). - New Queue admin page and queue card editor/preview in the panel, with
queue.view,queue.manage, andqueue.card.managepermissions. - Offline job management — new Update Offline Job flow; offline job/gang changes now write the full canonical fields (label, payment, boss flag, duty state, type, grade name) so profiles no longer show stale labels.
- Job data now includes
typeanddefaultDutyfrom the framework bridge. - Offline player lookup resolves the pretty job label from the jobs table instead of showing the raw job name.
- The stock
hardcapresource is automatically stopped while the built-in queue is enabled so players are queued instead of rejected with "server is full".
Bug Fixes
- Fixed gamertag colour picker, search modal, and offline-player toggle issues.
File Changes
⚙️ Configuration & Bridge Files
These files are not encrypted. Review the changes below — you may need to manually apply updates to your server's config files.
bridge/server/job.lua
---@class FormattedJob
---@field name string
---@field label string
+---@field type string?
+---@field defaultDuty boolean?
---@field grades {name:string, label:string, grade:number, isBoss:boolean, payment:number}[]
---@return FormattedJob[]
table.insert(formattedJobs, {
name = jobName,
label = job.label or "",
+ type = job.type or "none",
+ defaultDuty = job.defaultDuty,
grades = formattedGrades,
})config/config.json
"_comment_": "Tweak these settings based on your server performance",
"items_db_include_offline_players": false
},
+ "queue": {
+ "__comment__": "Built-in connection queue. When enabled, stop hardcap and any other queue resource; sv_maxclients is read live. When disabled, players are admitted right after the ban check.",
+ "enabled": true,
+ "max_queue_size": 200,
+ "max_wait_minutes": 60,
+ "reservation_timeout_seconds": 60,
+ "releases_per_tick": 2,
+ "update_interval_seconds": 5,
+ "__comment_reserved_slots__": "reserved_slots are held back from regular players (0 disables). Players with priority >= reserved_slots_min_priority, and registered staff when reserved_slots_for_staff is true, may fill them.",
+ "reserved_slots": 5 ,
+ "reserved_slots_min_priority": 100,
+ "reserved_slots_for_staff": true,
+ "card": {
+ "__comment__": "Adaptive Card shown to queued players (presentCard.json in the resource root). allowed_url_hosts accepts exact hosts, *.wildcard entries, or '*' to disable host filtering; an empty list rejects every URL in the card.",
+ "enabled": true,
+ "allowed_url_hosts": ["*"]
+ }
+ },
"force_resource_shutdowns": ["_resource_name_"],
"esx_boss_grades_names": ["boss", "owner"],
"announcements": {config/database/queries.d.ts
changeOfflinePlayerName(charId: string, newName: string): Promise<boolean>;
/** Changes the job and grade of an offline player. */
- changeOfflinePlayerJob(charId: string, job: string, grade: number): Promise<boolean>;
+ changeOfflinePlayerJob(
+ charId: string,
+ job: string,
+ grade: number,
+ details?: { label?: string; payment?: number; isboss?: boolean; onduty?: boolean; type?: string; gradeName?: string }
+ ): Promise<boolean>;
/** Changes the gang and grade of an offline player. */
- changeOfflinePlayerGang(charId: string, gang: string, grade: number): Promise<boolean>;
+ changeOfflinePlayerGang(
+ charId: string,
+ gang: string,
+ grade: number,
+ details?: { label?: string; isboss?: boolean; gradeName?: string }
+ ): Promise<boolean>;
/** Replaces all account balances for an offline player. */
changeOfflinePlayerMoney(charId: string, accounts: Record<string, number>): Promise<boolean>;config/database/queries.js
let job = { name: '', label: '', grade: 0 };
try {
+ // Resolve the pretty label from the jobs table; fall back to the raw job name.
+ let jobLabel = result.job || '';
+ try {
+ const pretty = await oxmysql.scalar('SELECT label FROM jobs WHERE name = ? LIMIT 1', [result.job]);
+ if (pretty) jobLabel = pretty;
+ } catch {}
job = {
name: result.job || '',
- label: result.job || '',
+ label: jobLabel,
grade: typeof result.job_grade === 'number' ? result.job_grade : 0,
};
} catch {}
* @param {string} charId
* @param {string} job
* @param {number} grade
+ * @param {{ label?: string, payment?: number, isboss?: boolean, onduty?: boolean, type?: string, gradeName?: string }} [details]
+ * Canonical job fields (resolved from the framework's shared jobs, mirroring `SetJob`).
+ * Only defined fields are written; everything else in the stored JSON is preserved.
* @returns {Promise<boolean>}
*/
- async changeOfflinePlayerJob(charId, job, grade) {
+ async changeOfflinePlayerJob(charId, job, grade, details = {}) {
if (Framework.name === 'esx') {
return (
(await oxmysql.update('UPDATE users SET job = ?, job_grade = ? WHERE identifier = ?', [job, grade, charId])) ===
1
);
} else if (Framework.name === 'qb' || Framework.name === 'qbx') {
+ // Keep the whole job object consistent with what the framework itself writes on SetJob:
+ // name, label, payment, isboss, onduty, type and grade name/level. The profile UI (and
+ // other tools) display `$.label` / `$.grade.name`, so writing only `$.name` would leave
+ // a stale label behind. Unknown fields are passed as NULL so COALESCE keeps the stored value.
return (
(await oxmysql.update(
- 'UPDATE players SET job = JSON_SET(job, "$.name", ?, "$.grade.level", ?) WHERE citizenid = ?',
- [job, grade, charId]
+ `UPDATE players SET job = JSON_SET(
+ job,
+ "$.name", ?,
+ "$.label", ?,
+ "$.payment", COALESCE(?, JSON_EXTRACT(job, "$.payment")),
+ "$.isboss", COALESCE(?, JSON_EXTRACT(job, "$.isboss")),
+ "$.onduty", COALESCE(?, JSON_EXTRACT(job, "$.onduty")),
+ "$.type", COALESCE(?, JSON_EXTRACT(job, "$.type")),
+ "$.grade.level", ?,
+ "$.grade.name", ?
+ ) WHERE citizenid = ?`,
+ [
+ job,
+ details.label ?? job,
+ details.payment ?? null,
+ details.isboss ?? null,
+ details.onduty ?? null,
+ details.type ?? null,
+ grade,
+ details.gradeName ?? String(grade),
+ charId,
+ ]
)) === 1
);
} else if (Framework.name === 'vrp') {
* @param {string} charId
* @param {string} gang
* @param {number} grade
+ * @param {{ label?: string, isboss?: boolean, gradeName?: string }} [details]
+ * Canonical gang fields (resolved from the framework's shared gangs, mirroring `SetGang`).
+ * Only defined fields are written; everything else in the stored JSON is preserved.
* @returns {Promise<boolean>}
*/
- async changeOfflinePlayerGang(charId, gang, grade) {
+ async changeOfflinePlayerGang(charId, gang, grade, details = {}) {
if (Framework.name === 'esx') {
return (
(await oxmysql.update('UPDATE users SET gang = ?, gang_grade = ? WHERE identifier = ?', [
])) === 1
);
} else if (Framework.name === 'qb' || Framework.name === 'qbx') {
+ // See changeOfflinePlayerJob: keep label/grade name (and isboss) in sync so the profile shows the new gang.
return (
(await oxmysql.update(
- 'UPDATE players SET gang = JSON_SET(gang, "$.name", ?, "$.grade.level", ?) WHERE citizenid = ?',
- [gang, grade, charId]
+ `UPDATE players SET gang = JSON_SET(
+ gang,
+ "$.name", ?,
+ "$.label", ?,
+ "$.isboss", COALESCE(?, JSON_EXTRACT(gang, "$.isboss")),
+ "$.grade.level", ?,
+ "$.grade.name", ?
+ ) WHERE citizenid = ?`,
+ [gang, details.label ?? gang, details.isboss ?? null, grade, details.gradeName ?? String(grade), charId]
)) === 1
);
} else if (Framework.name === 'vrp') {config/moderation.lua
---- Runs when the player connecting is banned
+--- Ban preflight for a connecting player.
+---
+--- This export is called by the connection queue (server_ts/modules/queue) as a
+--- PREFLIGHT step, before the player is admitted or queued. The queue owns the
+--- real deferral: it already called deferrals.defer() and it is the only code
+--- that finally admits the player.
+---
+--- The `deferrals` table you receive here is a thin wrapper:
+--- - deferrals.update(text) / deferrals.presentCard(...) are forwarded to the
+--- player as usual.
+--- - 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, otherwise the player is
+--- rejected after the queue's preflight timeout.
+---
+--- If you customised this function before the queue existed, no change is
+--- required: the old contract (done() = allow, done(reason) = reject) is kept.
---@param name string
---@param setKickReason function
---@param deferrals tableconfig/permissions.json
"society.view_balance": "perm_society_view_balance",
- "society.manage_balance": "perm_society_manage_balance"
+ "society.manage_balance": "perm_society_manage_balance",
+ "queue.view": "perm_queue_view",
+ "queue.manage": "perm_queue_manage",
+ "queue.card.manage": "perm_queue_card_manage"
}
}fxmanifest.lua
author 'luxu-gg'
description 'Admin menu for FiveM'
-version '1.15.3'
+version '1.15.4'
dependencies {
'oxmysql',
files { 'config/config.json', 'config/themes.css', 'config/permissions.json', 'config/sounds/*', 'config/help.lua', 'config/constants/*.json', 'config/logging/cl_logs.lua', 'config/teleports.lua', 'config/commands/cl_commands.lua', 'config/custom_functions/client.lua', 'locales/*.json', "web/index.html", 'web/**/*', "bridge/framework.lua", "bridge/shared/**/*.lua", "bridge/client/**/*.lua", "bridge/vrp2/**/*.lua" }
-escrow_ignore { 'config/**/*', 'bridge/**/*' }
+escrow_ignore { 'config/**/*', 'bridge/**/*', 'presentCard.json' }locales/en.json
{
"log_action_queue_card_reloaded": "reloaded the queue present card from disk",
"log_action_queue_card_saved": "saved the queue present card (revision {revision}, {bytes} bytes)",
"log_action_queue_move_down": "moved {name} down in the connection queue",
"log_action_queue_move_up": "moved {name} up in the connection queue",
"log_action_queue_remove": "removed {name} from the connection queue (reason: {reason})",
"log_action_random_clothing": "randomized clothing",
"log_action_reset_world_area": "reset world area around {x}, {y} (radius {radius}m)",
"perm_queue_card_manage": "Queue: Manage Present Card",
"perm_queue_manage": "Queue: Manage",
"perm_queue_view": "Queue: View",
"perm_self_reset_world": "Reset World Area",
"queue": "Queue",
"queue_action_failed": "Queue action failed",
"queue_card_action_title": "Button label",
"queue_card_action_url": "Button URL",
"queue_card_add_action": "Add button",
"queue_card_add_block": "Add block",
"queue_card_add_fact": "Add fact",
"queue_card_allowed_hosts": "Allowed URL hosts",
"queue_card_alt_text": "Alt text",
"queue_card_any_host_allowed": "Host filtering is disabled (\"*\"). Any https host is accepted; credentials, localhost and IP literals are still rejected.",
"queue_card_block_actions": "Buttons",
"queue_card_block_columns": "Columns",
"queue_card_block_container": "Container",
"queue_card_block_facts": "Fact set",
"queue_card_block_image": "Image",
"queue_card_block_images": "Image set",
"queue_card_block_text": "Text",
"queue_card_builder": "Present Card",
"queue_card_color": "Color",
"queue_card_columns_count": "{count} columns",
"queue_card_disabled_notice": "The present card is disabled in config.json (queue.card.enabled). Players receive plain text updates.",
"queue_card_fact_title": "Title",
"queue_card_fact_value": "Value",
"queue_card_fallback_text": "Fallback text",
"queue_card_field_required": "This field is required",
"queue_card_file_invalid_notice": "The presentCard.json on disk is invalid. Players receive plain text updates until it is fixed.",
"queue_card_form": "Form",
"queue_card_form_unavailable": "The form editor cannot represent the current JSON. Fix the validation issues or edit in the JSON tab.",
"queue_card_form_unsupported": "The form editor cannot represent the current JSON. Fix or remove the unsupported constructs below in the JSON tab:",
"queue_card_image_url": "Image URL",
"queue_card_images_count": "{count} images",
"queue_card_invalid": "Card has {count} issue(s)",
"queue_card_issues": "Validation issues",
"queue_card_items_count": "{count} items",
"queue_card_json": "JSON",
"queue_card_load_remote_images": "Load remote images",
"queue_card_missing_notice": "No presentCard.json exists yet. Players receive plain text updates until a card is saved.",
"queue_card_no_allowed_hosts": "No hosts are allowed. Add hosts to queue.card.allowed_url_hosts in config.json to use images or links.",
"queue_card_preview": "Preview",
"queue_card_reload": "Reload from disk",
"queue_card_rendered_size": "Rendered size",
"queue_card_revision": "Revision",
"queue_card_sample_context_notice": "Preview uses sample values for template tokens.",
"queue_card_save": "Save card",
"queue_card_save_conflict": "The card was changed by someone else. Reload and try again.",
"queue_card_save_invalid": "The card is invalid and was not saved",
"queue_card_save_io_error": "Failed to write the card file",
"queue_card_saved": "Present card saved",
"queue_card_size": "Size",
"queue_card_text": "Text",
"queue_card_tokens": "Template tokens",
"queue_card_unsaved_changes": "Unsaved changes",
"queue_card_valid": "Card is valid",
"queue_card_validate": "Validate",
"queue_card_weight": "Weight",
"queue_card_wrap": "Wrap text",
"queue_character_load_failed": "Could not load this character",
"queue_characters": "Characters",
"queue_disabled_notice": "The connection queue is disabled in config.json (queue.enabled). Players are admitted right after the ban check.",
"queue_drop_no_reservation": "The server is full. Please reconnect to join the queue.",
"queue_eligible_reserved": "Reserved slot",
"queue_eligible_reserved_title": "Eligible for a reserved slot",
"queue_empty": "Nobody is waiting in the connection queue",
"queue_full": "The connection queue is full. Please try again later.",
"queue_live": "Live Queue",
"queue_loading_in": "Loading in",
"queue_message_checking": "Checking your connection...",
"queue_message_starting": "The server is starting, please wait...",
"queue_message_waiting": "You are in the connection queue: position {position} of {total}. Waiting {wait}.",
"queue_message_waiting_priority": "You are in the connection queue ({label}): position {position} of {total}. Waiting {wait}.",
"queue_missing_license": "Missing license identifier.",
"queue_move_down": "Move down",
"queue_move_up": "Move up",
"queue_open_character": "Open character management",
"queue_player": "Player",
"queue_players_online": "Players online",
"queue_position": "Position",
"queue_preflight_error": "Error during connection check. Please reconnect.",
"queue_preflight_timeout": "Connection check timed out. Please reconnect.",
"queue_priority": "Priority",
"queue_regular_capacity": "Regular capacity",
"queue_remove": "Remove",
"queue_remove_confirm_description": "Remove {name} from the connection queue? Their connection will be rejected.",
"queue_remove_confirm_title": "Remove from queue",
"queue_remove_reason_placeholder": "Reason shown to the player (optional)",
"queue_removed_by_staff": "You were removed from the connection queue by staff.",
"queue_replaced": "A newer connection attempt replaced this one.",
"queue_reserved_slots": "Reserved slots",
"queue_resource_stopping": "The server is restarting the admin resource. Please reconnect in a moment.",
"queue_slots": "Slots",
"queue_state": "State",
"queue_state_reserved": "Reserved",
"queue_state_waiting": "Waiting",
"queue_timeout": "You waited too long in the connection queue. Please reconnect.",
"queue_wait": "Waiting",
"queue_waiting_count": "{count} waiting",
"random_clothing": "Random Clothing",
"reset_world_area": "Reset World Area",
"reset_world_area_out_of_range": "The selected area is too far away. Move closer to it before resetting.",
"reset_world_area_success": "World area reset"
}Full list of files changed
| Change | File |
|---|---|
| Modified | bridge/server/job.lua |
| Modified | client/modules/actions.lua |
| Modified | client/modules/nui.lua |
| Modified | config/config.json |
| Modified | config/database/queries.d.ts |
| Modified | config/database/queries.js |
| Modified | config/moderation.lua |
| Modified | config/permissions.json |
| Modified | fxmanifest.lua |
| Added | presentCard.json |
| Modified | server/sv_main.lua |
| Modified | web/ |
| Modified | server/index.js |