Arquitectura del concesionario de vehículos
Un sistema de concesionario es uno de los motores económicos principales de cualquier servidor de rol de FiveM, ya que es la puerta por la que los jugadores adquieren sus vehículos. Un concesionario bien diseñado va más allá de un simple menú de compra: ofrece presentaciones en showroom, pruebas de conducción, opciones de financiación, intercambios y seguimiento de ventas para los empleados. La arquitectura se divide en un sistema de catálogo que define los vehículos disponibles con precios y categorías, un showroom para inspeccionar los coches antes de comprarlos, un motor de transacciones que gestiona compras y financiaciones, y una capa de gestión de empleados para concesionarios llevados por jugadores. Cada capa se comunica mediante eventos validados en el servidor para evitar manipulación de precios y spawn de vehículos no autorizado.
Catálogo y sistema de categorías
El catálogo de vehículos define todos los coches disponibles para comprar, organizados por categorías para facilitar la navegación. Cada entrada incluye el spawn name, etiqueta visible, precio, categoría y metadatos opcionales como velocidad punta y número de plazas para el showroom. Guarda el catálogo en un archivo de configuración compartido que referencien tanto cliente como servidor, asegurando que la validación de precios ocurra en el servidor mientras el cliente usa los mismos datos para renderizar. Aquí tienes una definición de catálogo estructurada:
Config.VehicleCatalog = {
categories = {
{id = 'sedan', label = 'Sedans', icon = 'fa-car'},
{id = 'sport', label = 'Sports Cars', icon = 'fa-flag-checkered'},
{id = 'suv', label = 'SUVs & Trucks', icon = 'fa-truck'},
{id = 'muscle', label = 'Muscle Cars', icon = 'fa-bolt'},
{id = 'motorcycle', label = 'Motorcycles', icon = 'fa-motorcycle'},
},
vehicles = {
-- Sedans
{model = 'sultan', label = 'Karin Sultan', price = 24500,
category = 'sedan', seats = 4, testDrive = true},
{model = 'schafter2', label = 'Benefactor Schafter', price = 38000,
category = 'sedan', seats = 4, testDrive = true},
-- Deportivos
{model = 'elegy2', label = 'Annis Elegy RH8', price = 95000,
category = 'sport', seats = 2, testDrive = true},
{model = 'comet2', label = 'Pfister Comet', price = 110000,
category = 'sport', seats = 2, testDrive = true},
-- SUVs
{model = 'baller', label = 'Gallivanter Baller', price = 55000,
category = 'suv', seats = 4, testDrive = true},
-- Muscle
{model = 'dominator', label = 'Vapid Dominator', price = 42000,
category = 'muscle', seats = 2, testDrive = true},
-- Motos
{model = 'bati', label = 'Pegassi Bati 801', price = 18000,
category = 'motorcycle', seats = 2, testDrive = true},
},
}
-- Construye una tabla de búsqueda para validar precios rápido en servidor
Config.VehiclePrices = {}
for _, v in ipairs(Config.VehicleCatalog.vehicles) do
Config.VehiclePrices[v.model] = v.price
end
La tabla de búsqueda de precios Config.VehiclePrices permite una validación O(1) en el servidor, evitando que los clientes envíen precios manipulados. Valida siempre el nombre del modelo contra esta tabla antes de procesar una compra. Plantéate cargar los precios desde una tabla de base de datos en vez de un archivo de configuración si quieres que los administradores los ajusten desde un panel admin sin reiniciar el servidor.
Sistema de previsualización del showroom
El showroom permite a los jugadores inspeccionar los vehículos en un entorno controlado antes de comprometerse con la compra. Spawnea un vehículo de preview en una posición designada del showroom, aplica una cámara que el jugador pueda rotar alrededor del coche y muestra las stats junto al modelo. El vehículo de preview debe ser no interactuable y se despawnea cuando el jugador cierra el menú o elige otro coche. Esta es la lógica de preview en el cliente:
-- client/showroom.lua
local previewVehicle = nil
local previewCam = nil
local camAngle = 0.0
function ShowVehiclePreview(modelName)
-- Limpia preview anterior
DestroyPreview()
local model = GetHashKey(modelName)
RequestModel(model)
while not HasModelLoaded(model) do Wait(10) end
local showroomPos = Config.ShowroomPosition -- vector4
previewVehicle = CreateVehicle(model, showroomPos.x, showroomPos.y,
showroomPos.z, showroomPos.w, false, false)
SetEntityInvincible(previewVehicle, true)
SetVehicleDoorsLocked(previewVehicle, 2)
FreezeEntityPosition(previewVehicle, true)
SetVehicleOnGroundProperly(previewVehicle)
SetModelAsNoLongerNeeded(model)
-- Crea cámara orbital
previewCam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
UpdateCameraPosition()
SetCamActive(previewCam, true)
RenderScriptCams(true, true, 500, true, true)
end
function UpdateCameraPosition()
if not previewCam or not previewVehicle then return end
local vehPos = GetEntityCoords(previewVehicle)
local radius = 6.0
local height = 2.0
local rad = math.rad(camAngle)
local camX = vehPos.x + radius * math.cos(rad)
local camY = vehPos.y + radius * math.sin(rad)
SetCamCoord(previewCam, camX, camY, vehPos.z + height)
PointCamAtEntity(previewCam, previewVehicle, 0.0, 0.0, 0.0, true)
end
function DestroyPreview()
if previewVehicle then
DeleteEntity(previewVehicle)
previewVehicle = nil
end
if previewCam then
SetCamActive(previewCam, false)
RenderScriptCams(false, true, 500, true, true)
DestroyCam(previewCam, false)
previewCam = nil
end
end
Permite rotar la cámara con el ratón o el teclado mientras el showroom esté abierto. El enfoque orbital deja a los jugadores ver el coche desde todos los ángulos sin tener que caminar a su alrededor. Añade personalización de color en el preview para que los jugadores vean la pintura que eligen antes de comprar, lo que reduce el arrepentimiento tras la compra y las peticiones de soporte.
Procesado de compra y transacciones
Las compras de vehículos deben procesarse íntegramente en el servidor para evitar abusos. El servidor valida que el jugador puede permitirse el vehículo, descuenta el pago, genera una matrícula única, crea el registro del coche en la base de datos y notifica al cliente para que spawnee el vehículo comprado. Admite tanto pagos totales en efectivo como transferencias bancarias y, si quieres, intégralo con tu sistema de banca para transferencias electrónicas:
RegisterNetEvent('dealer:server:purchaseVehicle', function(modelName, paymentType)
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return end
-- Valida que el modelo existe y obtiene el precio
local price = Config.VehiclePrices[modelName]
if not price then
TriggerClientEvent('QBCore:Notify', src, 'Vehicle not available', 'error')
return
end
-- Comprueba el pago
local moneyType = paymentType == 'bank' and 'bank' or 'cash'
if Player.PlayerData.money[moneyType] < price then
TriggerClientEvent('QBCore:Notify', src, 'Insufficient funds', 'error')
return
end
-- Genera matrícula única
local plate = GenerateUniquePlate()
-- Procesa el pago
Player.Functions.RemoveMoney(moneyType, price, 'vehicle-purchase-' .. modelName)
-- Crea el registro del vehículo
local vehicleHash = GetHashKey(modelName)
MySQL.insert([[
INSERT INTO player_vehicles
(citizenid, vehicle, hash, plate, garage, state, fuel, engine, body)
VALUES (?, ?, ?, ?, ?, 1, 100, 1000.0, 1000.0)
]], {
Player.PlayerData.citizenid,
modelName,
tostring(vehicleHash),
plate,
'pillboxgarage',
})
-- Registra la transacción
MySQL.insert([[
INSERT INTO vehicle_sales (citizenid, vehicle, plate, price, sold_at)
VALUES (?, ?, ?, ?, NOW())
]], {Player.PlayerData.citizenid, modelName, plate, price})
TriggerClientEvent('QBCore:Notify', src,
'Vehicle purchased! Plate: ' .. plate, 'success')
TriggerClientEvent('dealer:client:vehiclePurchased', src, modelName, plate)
end)
function GenerateUniquePlate()
local plate
repeat
plate = ''
local chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
for i = 1, 8 do
local idx = math.random(1, #chars)
plate = plate .. chars:sub(idx, idx)
end
local exists = MySQL.scalar.await(
'SELECT 1 FROM player_vehicles WHERE plate = ?', {plate}
)
until not exists
return plate
end
La función GenerateUniquePlate usa un bucle de reintento para garantizar la unicidad de la matrícula en toda la base de datos. Aunque las colisiones con 8 caracteres alfanuméricos son estadísticamente raras, la comprobación asegura absoluta seguridad. Registra cada venta en una tabla separada para revisiones administrativas y analítica de economía del servidor, llevando cuenta de qué vehículos son los más populares y del flujo total de efectivo por el concesionario.
Sistema de prueba de conducción
Las pruebas de conducción dejan al jugador sentir el vehículo antes de comprarlo, algo especialmente importante para coches caros. Spawnea un vehículo temporal con un temporizador y un límite geográfico, devolviendo automáticamente al jugador al concesionario cuando expire el tiempo o salga de la zona permitida. Marca el vehículo de prueba para que no se pueda guardar en garaje ni modificar, evitando que los jugadores exploten el sistema para conseguir coches gratis:
-- Cliente: lógica de la prueba de conducción
local testDriveVehicle = nil
local testDriveTimer = 0
local testDriveActive = false
function StartTestDrive(modelName, duration)
local model = GetHashKey(modelName)
RequestModel(model)
while not HasModelLoaded(model) do Wait(10) end
local spawnPos = Config.TestDriveSpawn
testDriveVehicle = CreateVehicle(model, spawnPos.x, spawnPos.y,
spawnPos.z, spawnPos.w, true, false)
SetVehicleNumberPlateText(testDriveVehicle, 'TESTDRVE')
TaskWarpPedIntoVehicle(PlayerPedId(), testDriveVehicle, -1)
SetModelAsNoLongerNeeded(model)
testDriveTimer = duration
testDriveActive = true
-- Hilo del temporizador y comprobación del límite
CreateThread(function()
while testDriveActive and testDriveTimer > 0 do
Wait(1000)
testDriveTimer = testDriveTimer - 1
-- Muestra el tiempo restante
SendNUIMessage({
action = 'updateTestDrive',
timeLeft = testDriveTimer
})
-- Comprueba el límite
local playerPos = GetEntityCoords(PlayerPedId())
local dealerPos = Config.DealerLocation
if #(playerPos - dealerPos) > Config.TestDriveRadius then
QBCore.Functions.Notify('Too far from dealer, returning...', 'error')
EndTestDrive()
return
end
end
if testDriveActive then
EndTestDrive()
end
end)
end
function EndTestDrive()
testDriveActive = false
if testDriveVehicle and DoesEntityExist(testDriveVehicle) then
DeleteEntity(testDriveVehicle)
testDriveVehicle = nil
end
local returnPos = Config.DealerLocation
SetEntityCoords(PlayerPedId(), returnPos.x, returnPos.y, returnPos.z)
QBCore.Functions.Notify('Test drive ended', 'info')
end
El límite geográfico evita que los jugadores se lleven el coche de prueba por todo el mapa y lo abandonen. Ajusta el radio a una distancia razonable que permita un paseo real por las calles cercanas manteniendo el vehículo recuperable. La matrícula "TESTDRVE" actúa como indicador visual para el resto de jugadores de que el coche es temporal.
Financiación y planes de pago
Para vehículos caros, ofrece una opción de financiación donde el jugador da una entrada y paga cuotas a lo largo del tiempo. Registra el préstamo en una tabla de base de datos con el saldo restante, calendario de pagos y tasa de interés. Si el jugador se retrasa, el vehículo puede marcarse para embargo. Esta función añade profundidad económica y hace accesibles los vehículos de alta gama a jugadores que no han acumulado suficiente efectivo para una compra completa. Implementa la comprobación de cuotas como un trabajo recurrente en servidor que se ejecute a diario en tiempo de juego, descontando del banco del jugador y notificando cada cobro. Si el saldo es insuficiente, incrementa un contador de impagos y envía un aviso. Tras un número configurable de impagos, marca el vehículo para embargo, retirándolo del garaje y devolviéndolo al inventario del concesionario.
Ventas de empleados y seguimiento de comisiones
Los concesionarios llevados por jugadores necesitan herramientas para gestionar al personal de ventas, medir el rendimiento y repartir comisiones. Cuando un empleado facilita una venta, gana un porcentaje configurable del precio como comisión. Registra en una tabla las ventas totales, la facturación generada y la comisión ganada por cada empleado. Crea un panel accesible desde la NUI del concesionario que muestre estadísticas personales, historial de ventas reciente y una tabla de clasificación entre el equipo. El rango de dueño o encargado debería tener acceso a un panel admin para ajustar comisiones, añadir o quitar empleados y ver informes agregados. Así el concesionario pasa de una interacción NPC estática a un negocio dinámico con responsabilidades reales de gestión e incentivos competitivos.
Optimización y medidas antiexploit
Los scripts de concesionario sufren vectores de exploit comunes que conviene mitigar de forma proactiva. El más crítico es la manipulación de precios, donde un cliente modificado envía una petición de compra con precio más bajo. Valida siempre los precios en el servidor contra el catálogo y no confíes en valores reportados por el cliente. Aplica rate limiting a los eventos de compra para evitar compras en ráfaga que podrían duplicar vehículos o crear condiciones de carrera en la base de datos. Para la previsualización del showroom, crea el vehículo de preview con false en el parámetro de red para que solo exista localmente y no pueda ser entrado ni robado por otros jugadores. Limpia los vehículos de preview en el handler de onResourceStop para evitar entidades huérfanas al reiniciar el recurso. En servidores con varios concesionarios, cachea el catálogo en memoria y recárgalo solo cuando un admin dispare un comando de refresh, evitando leer el archivo de configuración en cada apertura de NUI. Monitoriza los logs de compras buscando anomalías como un mismo jugador comprando decenas de vehículos seguidos, lo que puede indicar un exploit o duplicación de dinero que merezca investigación.

