module.exports = { // --------------------------------------------------------------------------- // Player // --------------------------------------------------------------------------- /** * Fetches a single player row by their charId. * @param {string} charId * @returns {Promise} */ async getPlayer(charId) { return await oxmysql.scalar('SELECT * FROM players WHERE charId = ?', [charId]); }, /** * Returns raw retention timestamps for every player in the database. * ESX reads from the `users` table; QB/QBX reads from `luxu_stats_player_retention`. * @returns {Promise<{ timestamp: number }[]>} */ async getPlayerRetention() { if (Framework.name === 'esx') { return /** @type {{ timestamp: number }[]} */ (await oxmysql.query('SELECT * FROM users')); } return /** @type {{ timestamp: number }[]} */ ( await oxmysql.query('SELECT * FROM luxu_stats_player_retention') ); }, /** * Retrieves the saved clothing/skin data for a player. * ESX reads `skin` from `users`; QB/QBX reads from `playerskins`. * @param {string} charId * @returns {Promise>} */ async getPlayerClothing(charId) { const ESX_QUERY = `SELECT skin FROM users WHERE identifier = ?`; const QB_QUERY = `SELECT skin FROM playerskins WHERE citizenid = ? AND active = ?`; try { if (Framework.name === 'esx') { const result = await oxmysql.single(ESX_QUERY, [charId]); if (result) return JSON.parse(result.skin); } if (Framework.name === 'qb' || Framework.name === 'qbx') { const result = await oxmysql.single(QB_QUERY, [charId, 1]); if (result) return JSON.parse(result.skin); } return {}; } catch { return {}; } }, // --------------------------------------------------------------------------- // Vehicles // --------------------------------------------------------------------------- /** * Returns all vehicles of a given model hash, with their owner's charId. * ESX reads from `owned_vehicles`; QB/QBX reads from `player_vehicles`. * @param {number} modelHash * @returns {Promise<{ plate: string, char_id: string }[]>} */ async getVehicleOwners(modelHash) { if (Framework.name === 'esx') { return await oxmysql.query( 'SELECT plate, owner as char_id FROM owned_vehicles WHERE CAST(JSON_UNQUOTE(JSON_EXTRACT(vehicle, "$.model")) AS UNSIGNED) = ?', [modelHash] ); } return await oxmysql.query( 'SELECT pv.plate as plate, pv.citizenid as char_id FROM player_vehicles pv WHERE pv.hash = ?', [modelHash] ); }, /** * Returns the vehicle mod/properties data for a given plate. * ESX reads `vehicle` from `owned_vehicles`; QB/QBX reads `mods` from `player_vehicles`. * @param {string} plate * @returns {Promise | null>} */ async getVehicleMods(plate) { if (Framework.name === 'esx') { const result = await oxmysql.prepare('SELECT vehicle FROM owned_vehicles WHERE plate = ?', [plate]); if (!result) return null; return JSON.parse(result); } else if (Framework.name === 'qb' || Framework.name === 'qbx') { const result = await oxmysql.prepare('SELECT mods FROM player_vehicles WHERE plate = ?', [plate]); if (!result) return null; return JSON.parse(result); } return null; }, // --------------------------------------------------------------------------- // Offline player operations (all hit the framework player table) // --------------------------------------------------------------------------- /** * Fetches a full offline player profile by their charId. * Returns `null` when no matching record is found. * @param {string} charId * @returns {Promise} */ async getOfflinePlayer(charId) { if (Framework.name === 'esx') { const sql = ` SELECT u.identifier, u.accounts, u.job, u.job_grade, u.firstname, u.lastname, u.phone_number, CONCAT(u.firstname, ' ', u.lastname) as name, UNIX_TIMESTAMP(u.last_seen) * 1000 as last_seen FROM users u WHERE u.identifier = ? LIMIT 1 `; const result = /** @type {any} */ (await oxmysql.prepare(sql, [charId])); if (!result) return null; const uniqueId = result.identifier.split(':')[1] || ''; const identifiers = Array.from( new Set([`license:${uniqueId}`, ...JSON.parse(GetResourceKvpString(`identifiers:${uniqueId}`) ?? '[]')]) ); /** @type {Record} */ let accounts = {}; try { accounts = typeof result.accounts === 'string' ? JSON.parse(result.accounts) : result.accounts || {}; } catch {} let job = { name: '', label: '', grade: 0 }; try { job = { name: result.job || '', label: result.job || '', grade: typeof result.job_grade === 'number' ? result.job_grade : 0, }; } catch {} return /** @type {OfflinePlayer} */ ({ name: result.name || `${result.firstname || ''} ${result.lastname || ''}`.trim(), uniqueId, charId: result.identifier || '', identifiers, accounts, last_seen: result.last_seen || 0, job, }); } else { const sql = ` SELECT p.citizenid, p.license, p.money, p.job, p.gang, p.charinfo, CONCAT(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ' ', JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname'))) as name, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.phone')) as phone_number, UNIX_TIMESTAMP(p.last_updated) * 1000 as last_seen FROM players p WHERE p.citizenid = ? LIMIT 1 `; const result = /** @type {any} */ (await oxmysql.prepare(sql, [charId])); if (!result) return null; const uniqueId = result.license.split(':')[1] || ''; const identifiers = Array.from( new Set([result.license, ...JSON.parse(GetResourceKvpString(`identifiers:${uniqueId}`) ?? '[]')]) ); /** @type {Record} */ let accounts = {}; try { accounts = typeof result.money === 'string' ? JSON.parse(result.money) : result.money || {}; } catch {} let job = { name: '', label: '', grade: 0 }; try { const p = typeof result.job === 'string' ? JSON.parse(result.job) : result.job; if (p) job = { name: p.name || '', label: p.label || p.name || '', grade: typeof p.grade === 'number' ? p.grade : p.grade?.level || 0 }; } catch {} let gang = { name: '', label: '', grade: 0 }; try { const p = typeof result.gang === 'string' ? JSON.parse(result.gang) : result.gang; if (p) gang = { name: p.name || '', label: p.label || p.name || '', grade: typeof p.grade === 'number' ? p.grade : p.grade?.level || 0 }; } catch {} return /** @type {OfflinePlayer} */ ({ name: result.name || '', uniqueId: result.license.split(':')[1] || '', charId: result.citizenid || '', identifiers, accounts, last_seen: result.last_seen || 0, job, gang, }); } }, /** * Searches offline players by a search term (minimum 2 characters). * * Override this to customise the search behaviour for your database schema. * @param {string} term * @returns {Promise>} */ async searchOfflinePlayers(term) { if (!term || term.length < 2) return []; const rawTerm = term.trim(); const searchTerm = `%${rawTerm}%`; if (Framework.name === 'esx') { const sql = ` SELECT u.identifier, u.firstname, u.lastname, CONCAT(u.firstname, ' ', u.lastname) as name FROM users u WHERE ( u.identifier LIKE ? OR u.phone_number LIKE ? OR CONCAT(u.firstname, ' ', u.lastname) LIKE ? ) LIMIT 10 `; /** @type {{ identifier: string, firstname: string, lastname: string, name: string }[]} */ const results = await oxmysql.query(sql, [searchTerm, searchTerm, searchTerm]); return results.map((row) => { /** @type {string[]} */ const identifiers = []; if (row.identifier) identifiers.push(row.identifier); return { name: row.name || `${row.firstname || ''} ${row.lastname || ''}`.trim(), charId: row.identifier || '', identifiers, }; }); } else { const sql = ` SELECT p.citizenid, p.charinfo, CONCAT(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ' ', JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname'))) as name FROM players p WHERE ( p.name LIKE ? OR p.citizenid LIKE ? OR p.license LIKE ? OR p.phone_number LIKE ? OR p.charinfo LIKE ? ) LIMIT 10 `; /** @type {{ citizenid: string, charinfo: string, name: string, identifier?: string }[]} */ const results = await oxmysql.query(sql, [searchTerm, searchTerm, searchTerm, searchTerm, searchTerm]); return results.map((row) => { /** @type {string[]} */ const identifiers = []; if (row.identifier) identifiers.push(row.identifier); return { name: row.name || '', charId: row.citizenid || '', identifiers, }; }); } }, /** * Permanently deletes a single player row from the framework table. * For a full character deletion that includes a transaction with additional * resource-specific cleanup queries, use `deleteCharacter` instead. * @param {string} charId * @returns {Promise} */ async deleteOfflinePlayer(charId) { if (Framework.name === 'esx') { return (await oxmysql.update('DELETE FROM users WHERE identifier = ?', [charId])) === 1; } else if (Framework.name === 'qb' || Framework.name === 'qbx') { return (await oxmysql.update('DELETE FROM players WHERE citizenid = ?', [charId])) === 1; } return false; }, /** * Deletes a player character inside a single database transaction. * * Add any extra cleanup queries here (e.g. removing owned vehicles, housing, etc.). * Each query receives the character identifier as its only parameter * (ESX = `identifier`, QB/QBX = `citizenid`). * * @param {string} charId * @returns {Promise} */ async deleteCharacter(charId) { /** @type {Array<{ query: string, values: any[] }>} */ const queries = []; if (Framework.name === 'esx') { // Add extra ESX cleanup queries before the main delete: queries.push({ query: 'DELETE FROM owned_vehicles WHERE owner = ?', values: [charId] }); queries.push({ query: 'DELETE FROM users WHERE identifier = ?', values: [charId] }); } else if (Framework.name === 'qb' || Framework.name === 'qbx') { // Add extra QB/QBX cleanup queries before the main delete: queries.push({ query: 'DELETE FROM player_vehicles WHERE citizenid = ?', values: [charId] }); queries.push({ query: 'DELETE FROM players WHERE citizenid = ?', values: [charId] }); } else { return false; } try { return !!(await oxmysql.transaction(queries)); } catch { return false; } }, /** * Changes the first/last name of an offline player. * @param {string} charId * @param {string} newName - Full name as "Firstname Lastname". * @returns {Promise} */ async changeOfflinePlayerName(charId, newName) { const [firstname, lastname] = newName.split(' '); if (Framework.name === 'esx') { return ( (await oxmysql.update( 'UPDATE users SET firstname = ?, lastname = ? WHERE identifier = ?', [firstname, lastname ?? '', charId] )) === 1 ); } else if (Framework.name === 'qb' || Framework.name === 'qbx') { return ( (await oxmysql.update( 'UPDATE players SET charinfo = JSON_SET(charinfo, "$.firstname", ?, "$.lastname", ?) WHERE citizenid = ?', [firstname, lastname, charId] )) === 1 ); } return false; }, /** * Changes the job and grade of an offline player. * @param {string} charId * @param {string} job * @param {number} grade * @returns {Promise} */ async changeOfflinePlayerJob(charId, job, grade) { 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') { return ( (await oxmysql.update( 'UPDATE players SET job = JSON_SET(job, "$.name", ?, "$.grade.level", ?) WHERE citizenid = ?', [job, grade, charId] )) === 1 ); } return false; }, /** * Changes the gang and grade of an offline player. * @param {string} charId * @param {string} gang * @param {number} grade * @returns {Promise} */ async changeOfflinePlayerGang(charId, gang, grade) { if (Framework.name === 'esx') { return ( (await oxmysql.update( 'UPDATE users SET gang = ?, gang_grade = ? WHERE identifier = ?', [gang, grade, charId] )) === 1 ); } else if (Framework.name === 'qb' || Framework.name === 'qbx') { return ( (await oxmysql.update( 'UPDATE players SET gang = JSON_SET(gang, "$.name", ?, "$.grade.level", ?) WHERE citizenid = ?', [gang, grade, charId] )) === 1 ); } return false; }, /** * Replaces all account balances for an offline player. * @param {string} charId * @param {Record} accounts * @returns {Promise} */ async changeOfflinePlayerMoney(charId, accounts) { if (Framework.name === 'esx') { return ( (await oxmysql.update( 'UPDATE users SET accounts = ? WHERE identifier = ?', [JSON.stringify(accounts), charId] )) === 1 ); } else if (Framework.name === 'qb' || Framework.name === 'qbx') { return ( (await oxmysql.update( 'UPDATE players SET money = ? WHERE citizenid = ?', [JSON.stringify(accounts), charId] )) === 1 ); } return false; }, /** * Sets the balance of a single account for an offline player. * Reads the current accounts JSON, patches the target key, and writes it back. * @param {string} charId * @param {string} account * @param {number} amount * @returns {Promise} */ async updateOfflinePlayerAccountMoney(charId, account, amount) { if (Framework.name === 'esx') { const row = await oxmysql.single('SELECT accounts FROM users WHERE identifier = ?', [charId]); if (!row) return false; const accounts = JSON.parse(row.accounts); accounts[account] = amount; return ( (await oxmysql.update( 'UPDATE users SET accounts = ? WHERE identifier = ?', [JSON.stringify(accounts), charId] )) === 1 ); } else if (Framework.name === 'qb' || Framework.name === 'qbx') { const row = await oxmysql.single('SELECT money FROM players WHERE citizenid = ?', [charId]); if (!row) return false; const money = JSON.parse(row.money); money[account] = amount; return ( (await oxmysql.update( 'UPDATE players SET money = ? WHERE citizenid = ?', [JSON.stringify(money), charId] )) === 1 ); } return false; }, // --------------------------------------------------------------------------- // Inventory // --------------------------------------------------------------------------- /** * Finds all players that possess a specific item. * Uses MariaDB `JSON_CONTAINS` for server-side filtering — significantly faster * than fetching every inventory row and filtering in memory. * @param {string} itemName * @returns {Promise>} */ async getPlayersWithItem(itemName) { if (Framework.name === 'esx') { const sql = ` SELECT u.identifier as charId, CONCAT(u.firstname, ' ', u.lastname) as name, u.inventory FROM users u WHERE JSON_CONTAINS(u.inventory, JSON_OBJECT('name', ?)) LIMIT 5000 `; try { const results = /** @type {any[]} */ (await oxmysql.query(sql, [itemName])); return results.map((row) => { try { const inventory = typeof row.inventory === 'string' ? JSON.parse(row.inventory) : row.inventory; if (!Array.isArray(inventory)) return null; let amount = 0; for (const item of inventory) { if (item && item.name === itemName) amount += item.count || item.amount || 0; } if (amount <= 0) return null; return { charId: row.charId, name: row.name, amount }; } catch { return null; } }).filter(/** @param {any} item */ (item) => item !== null) .sort(/** @param {{ amount: number }} a @param {{ amount: number }} b */ (a, b) => b.amount - a.amount); } catch { return []; } } else if (Framework.name === 'qb' || Framework.name === 'qbx') { const sql = ` SELECT p.citizenid as charId, CONCAT( JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ' ', JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')) ) as name, p.inventory FROM players p WHERE JSON_CONTAINS(p.inventory, JSON_OBJECT('name', ?)) LIMIT 5000 `; try { const results = /** @type {any[]} */ (await oxmysql.query(sql, [itemName])); return results.map((row) => { try { const inventory = typeof row.inventory === 'string' ? JSON.parse(row.inventory) : row.inventory; if (!Array.isArray(inventory)) return null; let amount = 0; for (const item of inventory) { if (item && item.name === itemName) amount += item.amount || item.count || 0; } if (amount <= 0) return null; return { charId: row.charId, name: row.name, amount }; } catch { return null; } }).filter(/** @param {any} item */ (item) => item !== null) .sort(/** @param {{ amount: number }} a @param {{ amount: number }} b */ (a, b) => b.amount - a.amount); } catch { return []; } } return []; }, // --------------------------------------------------------------------------- // Statistics (framework tables) // --------------------------------------------------------------------------- /** * Returns the top 100 players ranked by total money (bank + cash). * @returns {Promise>} */ async getRichList() { if (Framework.name === 'qb' || Framework.name === 'qbx') { const rows = /** @type {{ money: string, charinfo: string, citizenid: string }[]} */ ( await oxmysql.query(` SELECT money, charinfo, citizenid FROM players WHERE money IS NOT NULL AND charinfo IS NOT NULL AND JSON_EXTRACT(money, '$.bank') + JSON_EXTRACT(money, '$.cash') > 0 ORDER BY (JSON_EXTRACT(money, '$.bank') + JSON_EXTRACT(money, '$.cash')) DESC LIMIT 100 `) ); return rows.map((r) => { try { const charinfo = JSON.parse(r.charinfo); const money = JSON.parse(r.money); return { name: `${charinfo.firstname} ${charinfo.lastname}`, money: (money.bank || 0) + (money.cash || 0), charId: r.citizenid, }; } catch { return null; } }).filter(/** @param {any} r */ (r) => r !== null); } else if (Framework.name === 'esx') { const rows = /** @type {{ firstname: string, lastname: string, accounts: string, identifier: string }[]} */ ( await oxmysql.query(` SELECT firstname, lastname, accounts, identifier FROM users WHERE accounts IS NOT NULL AND JSON_EXTRACT(accounts, '$.bank') + JSON_EXTRACT(accounts, '$.money') > 0 ORDER BY (JSON_EXTRACT(accounts, '$.bank') + JSON_EXTRACT(accounts, '$.money')) DESC LIMIT 100 `) ); return rows.map((r) => { try { const accounts = JSON.parse(r.accounts); return { name: `${r.firstname} ${r.lastname}`, money: (accounts.bank || 0) + (accounts.money || 0), charId: r.identifier, }; } catch { return null; } }).filter(/** @param {any} r */ (r) => r !== null); } return []; }, /** * Returns a job-name → player-count mapping from the framework player table. * @returns {Promise>} */ async getJobDistribution() { /** @type {Record} */ const distribution = {}; if (Framework.name === 'qb' || Framework.name === 'qbx') { const rows = /** @type {{ job_name: string, count: number }[]} */ ( await oxmysql.query(` SELECT JSON_EXTRACT(job, '$.name') as job_name, COUNT(*) as count FROM players WHERE job IS NOT NULL GROUP BY JSON_EXTRACT(job, '$.name') `) ); rows.forEach((row) => { if (!row.job_name) return; const jobName = JSON.parse(row.job_name); if (jobName) distribution[jobName] = row.count; }); } else if (Framework.name === 'esx') { const rows = /** @type {{ job: string, count: number }[]} */ ( await oxmysql.query('SELECT job, COUNT(*) as count FROM users GROUP BY job') ); rows.forEach((row) => { distribution[row.job] = row.count; }); } return distribution; }, /** * Returns the total number of distinct players (by license) ever registered. * @returns {Promise} */ async getTotalUniquePlayers() { if (Framework.name === 'qb' || Framework.name === 'qbx') { const result = /** @type {{ count: number }[]} */ ( await oxmysql.query('SELECT COUNT(DISTINCT license) as count FROM players') ); return result[0]?.count ?? 0; } else if (Framework.name === 'esx') { const result = /** @type {{ count: number }[]} */ ( await oxmysql.query( "SELECT COUNT(DISTINCT SUBSTRING_INDEX(identifier, ':', -1)) as count FROM users WHERE identifier LIKE '%:%'" ) ); return result[0]?.count ?? 0; } return 0; }, /** * Returns the total number of character rows (one per character/slot). * @returns {Promise} */ async getTotalCharacters() { if (Framework.name === 'qb' || Framework.name === 'qbx') { const result = /** @type {{ count: number }[]} */ ( await oxmysql.query('SELECT COUNT(*) as count FROM players') ); return result[0]?.count ?? 0; } else if (Framework.name === 'esx') { const result = /** @type {{ count: number }[]} */ ( await oxmysql.query('SELECT COUNT(*) as count FROM users') ); return result[0]?.count ?? 0; } return 0; }, /** * Returns the total bank balance across all players. * @returns {Promise} */ async getTotalBankMoney() { if (Framework.name === 'qb' || Framework.name === 'qbx') { const result = /** @type {{ total: number }[]} */ ( await oxmysql.query( 'SELECT SUM(JSON_EXTRACT(money, "$.bank")) as total FROM players WHERE money IS NOT NULL' ) ); return result[0]?.total ?? 0; } else if (Framework.name === 'esx') { const result = /** @type {{ total: number }[]} */ ( await oxmysql.query( 'SELECT SUM(JSON_EXTRACT(accounts, "$.bank")) as total FROM users WHERE accounts IS NOT NULL' ) ); return result[0]?.total ?? 0; } return 0; }, };