Volver al blog
Tutorial11 min de lectura

Sistema de licencias de caza para FiveM: permisos, multas y zonas

Añade licencias de caza a FiveM con permisos, zonas protegidas, multas, empleos de guardabosques y scripts para regular la fauna.

Agency Scripts

Fundador y desarrollador principal en Agency Scripts

Las licencias como infraestructura para el roleplay

Un sistema de licencias y permisos es una infraestructura esencial para cualquier servidor de roleplay serio. Sin él, todos los jugadores pueden hacer de todo desde el primer día. Las licencias condicionan las actividades a requisitos realistas, lo que crea progresión, consecuencias e interacciones de roleplay. Una licencia de conducir obliga a los jugadores a aprobar un examen antes de conducir vehículos legalmente. Un permiso de armas requiere verificaciones de antecedentes y formación. Una licencia de caza restringe quién puede cazar y dónde. Una licencia comercial controla quién puede operar negocios. Un permiso de pesca, una licencia de piloto, una licencia médica, una licencia de abogado, cada una añade una capa de regulación que refleja los sistemas del mundo real y genera interacciones espontáneas entre la ciudadanía, los funcionarios y las fuerzas del orden. Cuando un policía detiene a alguien y comprueba el estado de su licencia, se crea una situación de roleplay que solo es posible gracias al sistema de licencias. Este tutorial explica cómo crear un sistema de licencias flexible y ampliable que admita cualquier tipo de permiso que necesite tu servidor.

Configuración flexible de los tipos de licencia

En lugar de codificar tipos de licencia específicos, crea un sistema basado en la configuración, donde para añadir un nuevo tipo de licencia solo haga falta agregar una entrada a una tabla de configuración. Cada definición de tipo de licencia incluye un identificador único, nombre visible, descripción, coste de solicitud, si requiere un examen, las preguntas del examen si corresponde, el período de validez antes de que sea necesario renovarla, los trabajos o roles que pueden emitirla y las restricciones de juego aplicables cuando no se tiene la licencia. Así, las licencias de caza, de conducir, los permisos de armas y las licencias comerciales funcionan con el mismo sistema subyacente. Cuando tu servidor crezca y necesite un nuevo tipo de permiso, como una licencia de taxi o un permiso de construcción, añadirás una entrada a la configuración y todo el flujo de solicitud, emisión y aplicación funcionará automáticamente, sin escribir código nuevo.

-- shared/config.lua
Config = {}

Config.LicenseTypes = {
    driving = {
        label       = 'Driving License',
        description = 'Required to legally operate motor vehicles',
        cost        = 500,
        requiresExam = true,
        examType    = 'practical',
        validDays   = 365,
        issuedBy    = {'dmv', 'police'},
        restrictions = {'vehicle_operation'},
    },
    weapons = {
        label       = 'Weapons Permit',
        description = 'Required to legally carry firearms',
        cost        = 2500,
        requiresExam = true,
        examType    = 'written',
        validDays   = 180,
        issuedBy    = {'police'},
        restrictions = {'weapon_carry'},
        prerequisites = {'driving'},
    },
    hunting = {
        label       = 'Hunting License',
        description = 'Required for legal hunting activities',
        cost        = 750,
        requiresExam = true,
        examType    = 'written',
        validDays   = 90,
        issuedBy    = {'ranger', 'dmv'},
        restrictions = {'hunting_activity'},
    },
    fishing = {
        label       = 'Fishing Permit',
        description = 'Required for legal fishing activities',
        cost        = 200,
        requiresExam = false,
        validDays   = 30,
        issuedBy    = {'dmv', 'ranger'},
        restrictions = {'fishing_activity'},
    },
    business = {
        label       = 'Business License',
        description = 'Required to operate a commercial business',
        cost        = 5000,
        requiresExam = false,
        validDays   = 365,
        issuedBy    = {'government'},
        restrictions = {'business_operation'},
    },
    pilot = {
        label       = 'Pilot License',
        description = 'Required to operate aircraft',
        cost        = 10000,
        requiresExam = true,
        examType    = 'practical',
        validDays   = 180,
        issuedBy    = {'faa'},
        restrictions = {'aircraft_operation'},
        prerequisites = {'driving'},
    },
}

Flujo de solicitud y examen

El proceso de solicitud debe sentirse como un trámite real ante el gobierno. Los jugadores visitan una oficina del DMV, una oficina de correos o el edificio gubernamental correspondiente, e interactúan con un NPC o un quiosco para iniciar la solicitud. La NUI muestra los tipos de licencia disponibles, sus costes y requisitos. Después de elegir una licencia y pagar la tarifa, el sistema comprueba los requisitos previos. Si la licencia requiere un examen escrito, el jugador accede a una interfaz de prueba con preguntas de opción múltiple extraídas de un conjunto configurable. Aleatoriza el orden de las preguntas y la posición de las respuestas para evitar que se hagan trampas memorizándolas. Exige una puntuación mínima para aprobar, normalmente entre el 70 y el 80 por ciento. Para los exámenes prácticos, como los de conducir, genera un vehículo y crea una ruta de puntos de control que el jugador debe completar dentro de un límite de tiempo y respetando las normas de tráfico. Un instructor NPC o un jugador que trabaje en el DMV puede supervisar el examen y suspender a quienes se salten semáforos en rojo, excedan mucho la velocidad o choquen. Guarda el resultado del examen y, si lo aprueba, emite la licencia con el período de validez contado desde la marca de tiempo actual.

-- server/licenses.lua
function ApplyForLicense(playerId, licenseType)
    local config = Config.LicenseTypes[licenseType]
    if not config then return false, 'Invalid license type' end

    local identifier = GetPlayerIdentifier(playerId, 0)

    -- Check prerequisites
    if config.prerequisites then
        for _, prereq in ipairs(config.prerequisites) do
            local has = HasValidLicense(identifier, prereq)
            if not has then
                return false, 'Missing prerequisite: ' ..
                    Config.LicenseTypes[prereq].label
            end
        end
    end

    -- Check if already has active license
    local existing = MySQL.single.await([[
        SELECT id, status, expires_at FROM licenses
        WHERE identifier = ? AND license_type = ?
        AND status IN ('active','suspended')
    ]], {identifier, licenseType})

    if existing and existing.status == 'active' then
        return false, 'You already hold this license'
    end

    -- Deduct application fee
    local paid = exports['framework']:RemoveMoney(
        playerId, config.cost, 'bank')
    if not paid then
        return false, 'Insufficient funds ($' .. config.cost .. ' required)'
    end

    -- Create pending application
    local appId = MySQL.insert.await([[
        INSERT INTO license_applications
            (identifier, license_type, status, applied_at)
        VALUES (?, ?, 'pending', NOW())
    ]], {identifier, licenseType})

    if config.requiresExam then
        return true, 'Application submitted. Please complete the exam.', appId
    else
        -- No exam required, issue directly
        IssueLicense(identifier, licenseType)
        return true, 'License issued successfully!'
    end
end

function IssueLicense(identifier, licenseType)
    local config = Config.LicenseTypes[licenseType]
    local expiresAt = os.time() + (config.validDays * 86400)

    MySQL.insert([[
        INSERT INTO licenses
            (identifier, license_type, status, issued_at, expires_at)
        VALUES (?, ?, 'active', NOW(), FROM_UNIXTIME(?))
        ON DUPLICATE KEY UPDATE
            status = 'active', issued_at = NOW(),
            expires_at = FROM_UNIXTIME(?)
    ]], {identifier, licenseType, expiresAt, expiresAt})
end

function HasValidLicense(identifier, licenseType)
    local result = MySQL.single.await([[
        SELECT id FROM licenses
        WHERE identifier = ? AND license_type = ?
        AND status = 'active' AND expires_at > NOW()
    ]], {identifier, licenseType})
    return result ~= nil
end

exports('HasValidLicense', HasValidLicense)

Integración con las fuerzas del orden

El verdadero potencial de un sistema de licencias se manifiesta cuando las fuerzas del orden pueden consultar y modificar el estado de las licencias durante las interacciones de roleplay. Los policías necesitan poder comprobar el estado de la licencia de un ciudadano durante controles de tráfico, verificar los permisos de armas durante registros y suspender o revocar licencias como consecuencia de una conducta delictiva. Crea una integración con el MDT policial que muestre todas las licencias de un ciudadano, junto con su estado, fecha de emisión y fecha de vencimiento. Añade comandos o acciones del MDT para suspender una licencia indicando el motivo y la duración, revocarla permanentemente y restablecerla una vez cumplido el período de suspensión. Cuando se suspenda una licencia, su titular debe recibir una notificación que explique el motivo y la duración de la suspensión. Integra las comprobaciones de licencias en los flujos de trabajo de las fuerzas del orden. Si sorprenden a un jugador conduciendo sin una licencia válida, el sistema policial puede marcarlo automáticamente durante un control de tráfico. Si sorprenden a alguien cazando sin permiso, los guardabosques pueden emitir multas con sanciones económicas y la posible revocación de la licencia.

-- server/enforcement.lua
RegisterNetEvent('licenses:checkCitizen', function(targetId)
    local src = source
    -- Verify requesting player is law enforcement
    local job = exports['framework']:GetPlayerJob(src)
    if job ~= 'police' and job ~= 'ranger' and job ~= 'sheriff' then
        return
    end

    local targetIdentifier = GetPlayerIdentifier(targetId, 0)
    local licenses = MySQL.query.await([[
        SELECT license_type, status, issued_at, expires_at,
               suspended_reason, suspended_until
        FROM licenses WHERE identifier = ?
    ]], {targetIdentifier})

    TriggerClientEvent('licenses:showResults', src, licenses)
end)

RegisterNetEvent('licenses:suspend', function(targetIdentifier, licType, reason, days)
    local src = source
    local job = exports['framework']:GetPlayerJob(src)
    if job ~= 'police' and job ~= 'judge' then return end

    local suspendUntil = os.time() + (days * 86400)

    MySQL.update([[
        UPDATE licenses SET
            status = 'suspended',
            suspended_reason = ?,
            suspended_until = FROM_UNIXTIME(?)
        WHERE identifier = ? AND license_type = ?
    ]], {reason, suspendUntil, targetIdentifier, licType})

    -- Notify the affected player if online
    local targetPlayer = GetPlayerFromIdentifier(targetIdentifier)
    if targetPlayer then
        TriggerClientEvent('licenses:notify', targetPlayer,
            'Your ' .. Config.LicenseTypes[licType].label ..
            ' has been suspended for ' .. days .. ' days. Reason: ' .. reason)
    end

    -- Log the action
    MySQL.insert([[
        INSERT INTO license_logs
            (identifier, license_type, action, performed_by, reason)
        VALUES (?, ?, 'suspend', ?, ?)
    ]], {targetIdentifier, licType, GetPlayerIdentifier(src, 0), reason})
end)

Sistema de renovación y gestión de vencimientos

Las licencias con fecha de vencimiento generan ingresos recurrentes e interacciones regulares con NPC que contribuyen a que el mundo parezca vivo. Cuando se acerque la fecha de vencimiento de una licencia, envía una notificación a su titular a través del sistema telefónico o del sistema de correo descrito en otro tutorial. Establece un período de gracia de unos pocos días dentro del juego, durante el cual la licencia estará técnicamente vencida, pero el jugador no recibirá una sanción inmediata y tendrá tiempo para renovarla. La renovación debe ser más sencilla que la solicitud inicial: solo debe requerir el pago de la tarifa y no un nuevo examen, salvo que la licencia se haya suspendido o revocado anteriormente. Implementa una tarea programada en el servidor que compruebe periódicamente si hay licencias vencidas y actualice su estado. Para las licencias que requieran renovaciones frecuentes, como los permisos de pesca con períodos de validez cortos, considera ofrecer renovaciones por adelantado de varios períodos, con un pequeño descuento. Esto premia a los jugadores que se comprometen y planifican con antelación, al tiempo que mantiene el ciclo de renovación que hace que el sistema se sienta dinámico.

Aplicación de restricciones en distintos scripts

El sistema de licencias solo tiene sentido si los demás scripts aplican las restricciones. Crea una función exportada centralizada que cualquier script pueda utilizar para comprobar si un jugador tiene una licencia válida de un tipo determinado. Tu script de vehículos comprueba que tenga una licencia de conducir antes de permitirle arrancar el motor o cuando se sienta en el asiento del conductor. Tu script de armas comprueba que tenga un permiso de armas cuando equipa un arma de fuego. Tu script de caza comprueba que tenga una licencia de caza antes de permitirle despiezar animales. Tu script de pesca comprueba que tenga un permiso de pesca antes de permitirle pescar. Cada script decide las consecuencias de las infracciones. Algunos pueden limitarse a impedir la acción y mostrar una notificación. Otros pueden permitir la acción, pero marcar al jugador para que las fuerzas del orden le presten atención, activando un nivel de búsqueda o una alerta automática. La severidad depende de la filosofía de roleplay de tu servidor. El sistema de licencias proporciona la capa de datos; los scripts de aplicación determinan las consecuencias. Esta separación te permite ajustar la severidad de la aplicación sin tocar el propio sistema de licencias.

Esquema de base de datos y herramientas de administración

El esquema de la base de datos necesita tablas para las licencias activas, las solicitudes, los resultados de los exámenes y un registro de auditoría; las definiciones de licencias ya están en la configuración. El registro de auditoría es fundamental para la rendición de cuentas. Cada emisión, suspensión, revocación y restablecimiento de una licencia se registra con una marca de tiempo, el identificador del agente responsable y el motivo. Los comandos de administración deben permitir que el personal emita cualquier licencia directamente para eventos, elimine todas las suspensiones durante eventos de amnistía, venza en bloque licencias de un tipo específico para restablecer el sistema y consulte el historial completo de licencias de cualquier jugador. Crea un panel de administración en el panel web o en el MDT de tu servidor que muestre de un vistazo estadísticas sobre las licencias activas por tipo, las solicitudes pendientes, las suspensiones recientes y los próximos vencimientos. Estos datos te ayudan a entender cómo interactúan los jugadores con el sistema y si es necesario ajustar las tarifas y los períodos de validez para mantener una participación estable.

-- SQL schema
CREATE TABLE IF NOT EXISTS licenses (
    id                INT AUTO_INCREMENT PRIMARY KEY,
    identifier        VARCHAR(64) NOT NULL,
    license_type      VARCHAR(32) NOT NULL,
    status            ENUM('active','suspended','revoked','expired')
                      DEFAULT 'active',
    issued_at         TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at        TIMESTAMP NULL,
    suspended_reason  VARCHAR(255) DEFAULT NULL,
    suspended_until   TIMESTAMP NULL,
    UNIQUE KEY unique_license (identifier, license_type),
    INDEX idx_status (status),
    INDEX idx_expires (expires_at)
);

CREATE TABLE IF NOT EXISTS license_applications (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    identifier    VARCHAR(64) NOT NULL,
    license_type  VARCHAR(32) NOT NULL,
    status        ENUM('pending','passed','failed','cancelled')
                  DEFAULT 'pending',
    exam_score    INT DEFAULT NULL,
    applied_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    completed_at  TIMESTAMP NULL
);

CREATE TABLE IF NOT EXISTS license_logs (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    identifier    VARCHAR(64) NOT NULL,
    license_type  VARCHAR(32) NOT NULL,
    action        VARCHAR(32) NOT NULL,
    performed_by  VARCHAR(64),
    reason        VARCHAR(255),
    created_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_identifier (identifier)
);

¿Listo para empezar?

Consigue los scripts en nuestra tienda, o únete al Discord para soporte, novedades y un adelanto de lo que viene.