Documentation · Développeurs

Créer un module

Tutoriel complet d'un module, du réglage à la traduction, et modèle de module vide.

SommaireCréer un module

Ce qu'on va construire

Le module : un PNJ se tient à un guichet. Entre minuit et une heure (heure du serveur), un joueur peut lui parler avec E et toucher une prime, une fois par jour et par personnage. Le staff peut remettre la prime d'un joueur à zéro.

Il couvre tout ce qu'un vrai module demande :

  1. un dossier de module ;
  2. des réglages dans le panel (aucune valeur en dur) ;
  3. une table SQL créée par une migration ;
  4. un callback serveur qui valide tout (activation, horaire, distance, anti-spam, une fois par jour) ;
  5. un point d'interaction E avec un PNJ ;
  6. des textes traduits dans les quatre langues ;
  7. un log, une commande staff protégée par une permission ;
  8. les tests et la checklist de performance.

Les modules livrés modules/carry, modules/soundsystem et modules/emotepoints suivent exactement ce schéma : lis-les quand tu as un doute.

Étape 1 : le dossier

resources/socle/
  modules/
    prime/
      server/
        Variables.lua   ← les réglages du panel
        Prime.lua       ← la logique serveur (callback, commande, log)
      client/
        Prime.lua       ← le point d'interaction et l'affichage
  migrations/
    prime.lua           ← la table SQL

Rien à ajouter au fxmanifest.lua : ses motifs modules/**/server/**, modules/**/client/** et migrations/*.lua chargent déjà ces fichiers. Un nom de dossier en minuscules, sans espace ni accent.

Étape 2 : les réglages

modules/prime/server/Variables.lua déclare tout ce qui est réglable. Le détail des types et des options est dans Les réglages côté code.

-- Réglages de la prime de minuit (module d'exemple).
-- Toute valeur réglable vit ici : le reste du module n'a aucune constante.

local C = "prime"
Core.Variable:defineCategory(C, "Prime de minuit", nil, "money")

local function define(cle, libelle, type, defaut, options)
    Core.Variable:define(C, cle, libelle, type, defaut, options)
end

define("enabled", "Prime activée", "boolean", true, {
    description = "Coupe ou active la prime. Coupée : le guichet disparaît pour tout le monde.",
})
define("guichet", "Position du guichet", "vector", { 215.8, -810.1, 30.7, 160.0 }, {
    description = "Emplacement et orientation du PNJ qui verse la prime. À poser avec « Placer en jeu ».",
})
define("pedModel", "Modèle du PNJ", "string", "a_m_y_business_02", {
    description = "Modèle GTA du PNJ. Un modèle invalide n'affiche pas de PNJ ; le point reste utilisable.",
})
define("distance", "Distance d'interaction", "number", 2.0, {
    unit = "m", min = 1, max = 5, step = 0.5,
    description = "Distance à laquelle la touche apparaît. Le serveur revérifie avec une marge d'un mètre.",
})
define("heureDebut", "Heure d'ouverture", "number", 0, {
    unit = "h", min = 0, max = 23, step = 1,
    description = "Heure du serveur à partir de laquelle la prime peut être réclamée.",
})
define("heureFin", "Heure de fermeture", "number", 1, {
    unit = "h", min = 0, max = 23, step = 1,
    description = "Heure du serveur à laquelle le guichet ferme. Plus petite que l'ouverture = ouvert à cheval sur minuit (ex. 23 → 1).",
})
define("montant", "Montant de la prime", "number", 500, {
    unit = "$", min = 0, max = 100000, step = 50,
    description = "Somme versée une fois par jour et par personnage.",
})
define("compte", "Compte crédité", "choice", "money", {
    choices = { "money", "bank" },
    description = "money = argent liquide, bank = compte en banque.",
})
define("delaiDemande", "Délai entre deux demandes", "number", 3, {
    unit = "s", min = 1, max = 60, step = 1,
    description = "Anti-spam : une demande arrivée plus tôt est ignorée.",
})
define("alerteMontant", "Seuil d'alerte", "number", 2000, {
    unit = "$", min = 0, max = 1000000, step = 100,
    private = true,
    description = "Une prime au-dessus de ce montant est journalisée en niveau « warning ».",
})

Étape 3 : la table SQL

migrations/prime.lua suit le modèle des migrations livrées : attendre le schéma principal, créer la table si elle manque, lever un drapeau.

-- Table de la prime de minuit : une ligne par personnage et par jour.
-- La clé primaire (identifier, day) garantit « une fois par jour » même si
-- deux demandes arrivent en même temps.

CreateThread(function()
    while not Core.migrationsDone do Wait(50) end

    local ok, err = pcall(function()
        MySQL.query.await([[
            CREATE TABLE IF NOT EXISTS `prime_claims` (
                `identifier` VARCHAR(64) NOT NULL,
                `day` DATE NOT NULL,
                `amount` INT NOT NULL,
                `claimed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (`identifier`, `day`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
        ]])
    end)

    if not ok then
        Console:write("error", "Migrations", "prime_claims : " .. tostring(err))
        return
    end

    Core.primeMigrationsDone = true
end)
  • Nom de table en snake_case, au pluriel.
  • CREATE TABLE IF NOT EXISTS : la migration se rejoue sans risque à chaque démarrage.
  • Pour faire évoluer la table plus tard, ajoute une colonne avec ALTER TABLE … ADD COLUMN IF NOT EXISTS … (MariaDB), à la suite, dans le même fichier.

Étape 4 : le serveur

modules/prime/server/Prime.lua. Le client ne fait que demander : le serveur vérifie chaque condition, dans l'ordre, et renvoie une clé d'erreur traduisible.

-- Prime de minuit, serveur : toutes les décisions sont prises ici.
-- Le client envoie une demande sans paramètre ; rien de ce qu'il pourrait
-- envoyer (montant, position, heure) n'est utilisé.

local C = "prime"
local derniereDemande = {} -- [src] = GetGameTimer() de la dernière demande

local function var(cle, defaut) return Core.Variable:getOr(C, cle, defaut) end
local function num(cle, defaut) return tonumber(var(cle, defaut)) or defaut end

-- tableau { x, y, z[, cap] } du panel → vector3, nil si invalide
local function position(valeur)
    if type(valeur) ~= "table" or not tonumber(valeur[1]) or not tonumber(valeur[2]) or not tonumber(valeur[3]) then
        return nil
    end
    return vector3(valeur[1] + 0.0, valeur[2] + 0.0, valeur[3] + 0.0)
end

-- ouvert entre heureDebut (incluse) et heureFin (exclue), à cheval sur minuit possible
local function guichetOuvert()
    local heure = os.date("*t").hour
    local debut, fin = math.floor(num("heureDebut", 0)), math.floor(num("heureFin", 1))
    if debut == fin then return false end
    if debut < fin then return heure >= debut and heure < fin end
    return heure >= debut or heure < fin
end

local function tropRapide(src)
    local maintenant = GetGameTimer()
    if derniereDemande[src] and maintenant - derniereDemande[src] < num("delaiDemande", 3) * 1000 then
        return true
    end
    derniereDemande[src] = maintenant
    return false
end

AddEventHandler("playerDropped", function()
    derniereDemande[source] = nil
end)

Core.Callback:register("prime:server:reclamer", function(src, cb)
    if var("enabled", true) ~= true then return cb(false, "desactivee") end
    if not Core.primeMigrationsDone then return cb(false, "indisponible") end
    if tropRapide(src) then return cb(false, "attends") end
    if not guichetOuvert() then
        return cb(false, "fermee", { math.floor(num("heureDebut", 0)), math.floor(num("heureFin", 1)) })
    end

    -- distance lue par le serveur, avec une marge d'un mètre
    local guichet = position(var("guichet", nil))
    local ped = GetPlayerPed(src)
    if not guichet or ped == 0 or #(GetEntityCoords(ped) - guichet) > num("distance", 2.0) + 1.0 then
        return cb(false, "trop_loin")
    end

    local xPlayer = Core.getPlayer(src)
    local identifier = xPlayer and xPlayer.identifier
    if not identifier then return cb(false, "indisponible") end

    local montant = math.floor(num("montant", 500))
    if montant <= 0 then return cb(false, "desactivee") end

    -- « une fois par jour » : la clé primaire refuse la deuxième ligne.
    -- 1 ligne insérée = la prime est pour ce joueur ; 0 = déjà réclamée.
    local jour = os.date("%Y-%m-%d")
    local inseree = MySQL.update.await(
        "INSERT IGNORE INTO prime_claims (identifier, day, amount) VALUES (?, ?, ?)",
        { identifier, jour, montant }
    )
    if inseree ~= 1 then return cb(false, "deja") end

    local compte = var("compte", "money") == "bank" and "bank" or "money"
    xPlayer.addAccountMoney(compte, montant, "Prime de minuit")

    Core.Logs.push({
        category = "money",
        level = montant >= num("alerteMontant", 2000) and "warning" or "info",
        title = "Prime de minuit",
        message = ("Prime de %d $ versée (%s)"):format(montant, compte),
        src = src,
        data = { montant = montant, compte = compte, jour = jour },
    })

    -- fait avancer le tutoriel d'arrivée si une étape attend l'action « prime »
    TriggerEvent("socle:tutorial:action", src, "prime")

    cb(true, "recue", { montant })
end)

-- Staff : remettre à zéro la prime du jour d'un joueur connecté.
-- La permission est vérifiée par Core.Command avant d'appeler la fonction.
Core.Command:register("primereset", function(src, args)
    local cible = tonumber(args[1])
    local xCible = cible and Core.getPlayer(cible)
    if not xCible then
        if src ~= 0 then TriggerClientEvent("esx:showNotification", src, Core.t("prime.notify.cible"), "error") end
        return
    end
    MySQL.update("DELETE FROM prime_claims WHERE identifier = ? AND day = ?", {
        xCible.identifier, os.date("%Y-%m-%d"),
    })
    Core.Logs.push({
        category = "admin", title = "Prime de minuit",
        message = "Prime du jour remise à zéro", src = src, target = cible,
    })
    if src ~= 0 then TriggerClientEvent("esx:showNotification", src, Core.t("prime.notify.reset"), "success") end
end, {
    permission = "admin.economy",
    suggestion = { help = Core.t("prime.command.reset"), params = { { name = "id", help = Core.t("prime.command.id") } } },
})

Pourquoi chaque ligne compte :

  • le montant vient du panel, jamais du client ;
  • la distance est mesurée par le serveur à partir de la position du ped (OneSync) ;
  • l'anti-spam évite qu'un client inonde le serveur de requêtes SQL ;
  • `INSERT IGNORE` sur une clé primaire rend l'opération atomique : deux demandes simultanées ne peuvent pas toucher deux primes. Si ton module débite et livre en base (deux écritures), utilise MySQL.transaction.await({ { query = …, values = … }, … }) pour que tout réussisse ou tout échoue ;
  • le log donne au staff une trace dans la page Logs (et sur Discord si un webhook money est réglé) ;
  • la commande réutilise le nœud existant admin.economy (voir Permissions).

Étape 5 : le client

modules/prime/client/Prime.lua. Le client crée le point d'interaction, le reconstruit quand un réglage change, et affiche le résultat.

-- Prime de minuit, client : un point E avec un PNJ au guichet.
-- Aucune boucle ici : la proximité est gérée par le thread unique de modules/interact.

local C = "prime"
local pointId = nil

local function var(cle, defaut) return Core.Variable:getOr(C, cle, defaut) end

local function notifier(ok, raison, valeurs)
    local cle = "prime.notify." .. tostring(raison or "indisponible")
    exports.socle:Notify(Core.t(cle, valeurs), ok and "success" or "error")
end

local function reclamer()
    -- réponse du serveur : ok, clé de texte, valeurs du texte
    Core.Callback:trigger("prime:server:reclamer", notifier)
end

local function supprimerPoint()
    if pointId then
        exports.socle:removeInteractionPoint(pointId)
        pointId = nil
    end
end

local function construirePoint()
    supprimerPoint()
    if var("enabled", true) ~= true then return end

    local g = var("guichet", nil)
    if type(g) ~= "table" or not tonumber(g[1]) or not tonumber(g[2]) or not tonumber(g[3]) then return end

    pointId = exports.socle:addInteractionPoint({
        -- la position placée en jeu est celle du joueur : le PNJ se pose au sol
        coords = vector3(g[1] + 0.0, g[2] + 0.0, g[3] - 1.0),
        heading = (tonumber(g[4]) or 0.0) + 0.0,
        ped = joaat(tostring(var("pedModel", "a_m_y_business_02"))),
        dist = tonumber(var("distance", 2.0)) or 2.0,
        key = "E",
        message = Core.t("prime.point"),
        onPress = reclamer,
    })
end

-- état initial : Core.Variable:get attend l'arrivée des réglages
CreateThread(function()
    Core.Variable:get(C, "enabled")
    construirePoint()
end)

-- réglages modifiés au panel : effet immédiat, sans redémarrage
Core.Hook.listen("variable:changed", function(categorie, cle)
    if categorie ~= C then return end
    if cle == "enabled" or cle == "guichet" or cle == "pedModel" or cle == "distance" then
        construirePoint()
    end
end)

Le client ne décide de rien : s'il est modifié par un tricheur pour appeler le callback de n'importe où, le serveur répond trop_loin.

Étape 6 : les traductions

Le Socle lit ses textes dans i18n/langs/fr.json, en.json, de.json et es.json (un objet JSON plat "clé": "texte"). La langue active est Config.lang dans configs/_main.lua.

  • Core.t("clé") renvoie le texte de la langue active ;
  • les variables s'écrivent %s dans le texte et se passent dans l'ordre, en tableau : Core.t("prime.notify.recue", { 500 }) ;
  • une clé absente renvoie la clé elle-même et écrit une erreur en console : ajoute tes clés dans les quatre fichiers ;
  • une valeur insérée qui contient % (une adresse web, par exemple) doit être échappée en %% avant l'appel.

Ajoute ces lignes avant l'accolade finale de fr.json (attention aux virgules : le fichier doit rester du JSON valide) :

  "prime.point": "Réclamer la prime de minuit",
  "prime.notify.recue": "Prime reçue : %s $",
  "prime.notify.deja": "Tu as déjà touché la prime aujourd'hui",
  "prime.notify.fermee": "Le guichet est fermé (ouvert de %sh à %sh)",
  "prime.notify.trop_loin": "Approche-toi du guichet",
  "prime.notify.attends": "Patiente un instant",
  "prime.notify.desactivee": "La prime est désactivée",
  "prime.notify.indisponible": "Prime indisponible pour le moment",
  "prime.notify.cible": "Joueur introuvable",
  "prime.notify.reset": "Prime du jour remise à zéro",
  "prime.command.reset": "Remet à zéro la prime du jour d'un joueur",
  "prime.command.id": "ID serveur du joueur"

Puis la même chose traduite dans en.json :

  "prime.point": "Claim the midnight bonus",
  "prime.notify.recue": "Bonus received: $%s",
  "prime.notify.deja": "You already claimed today's bonus",
  "prime.notify.fermee": "The counter is closed (open from %s to %s)",
  "prime.notify.trop_loin": "Get closer to the counter",
  "prime.notify.attends": "Please wait a moment",
  "prime.notify.desactivee": "The bonus is disabled",
  "prime.notify.indisponible": "Bonus unavailable right now",
  "prime.notify.cible": "Player not found",
  "prime.notify.reset": "Today's bonus has been reset",
  "prime.command.reset": "Reset a player's bonus for today",
  "prime.command.id": "Player server ID"

Et de même dans de.json et es.json.

Variante qui survit aux mises à jour : garder tes textes dans ton module et les ajouter à la langue active au chargement, dans un fichier partagé (lu par le client et le serveur) :

-- modules/prime/shared/Lang.lua
-- Textes du module, ajoutés à la langue active sans toucher aux fichiers du Socle.
local TEXTES = {
    fr = {
        ["prime.point"] = "Réclamer la prime de minuit",
        ["prime.notify.recue"] = "Prime reçue : %s $",
        -- …
    },
    en = {
        ["prime.point"] = "Claim the midnight bonus",
        ["prime.notify.recue"] = "Bonus received: $%s",
        -- …
    },
}

local actifs = Core.getTranslations()          -- dictionnaire de la langue active
local miens = TEXTES[Config.lang] or TEXTES.en
for cle, texte in pairs(miens) do
    if actifs[cle] == nil then actifs[cle] = texte end -- ne remplace jamais un texte existant
end

Les textes ajoutés ainsi fonctionnent avec Core.t côté Lua et dans l'interface, mais n'apparaissent pas dans la page Textes du panel tant qu'ils ne sont pas dans les fichiers de langue.

Étape 7 : tester

  1. Sur ton serveur de développement, redémarre complètement le serveur (une migration vient d'ajouter une table : la console te le recommande de toute façon).
  2. Console serveur : aucune ligne variable sans description : prime.…, aucune erreur Translation key … not found, et la ligne Schema des variables publie pour le panel.
  3. Panel : la catégorie Prime de minuit est apparue avec ses dix réglages. Pose le guichet avec Placer en jeu, enregistre : le PNJ se déplace en jeu sans redémarrage.
  4. En jeu, teste chaque refus : hors horaire (fermee), trop loin, deux fois le même jour (deja), prime coupée au panel (le point disparaît).
  5. Mets temporairement Heure d'ouverture à l'heure actuelle pour tester le versement, puis vérifie la page Logs (catégorie money).
  6. Avec un compte staff : /primereset <id>. Sans la permission admin.economy, la commande est refusée.

Tester la sécurité

Depuis la console F8 du client, un tricheur ne peut qu'appeler ton callback tel quel ; vérifie que le serveur tient bon :

  • appelle la prime loin du guichet : refus trop_loin ;
  • spamme la demande : une seule passe par délai, et une seule prime par jour quoi qu'il arrive ;
  • un TriggerServerEvent direct vers un event enregistré par Core.Event:register est refusé (invalid token) si network_token est réglé.

Checklist avant de livrer un module

Performance

  • resmon : ta ressource reste sous 0,02 ms au repos, loin de tout point.
  • Aucun Wait(0) permanent. Une boucle par image n'existe que pendant un état précis et s'arrête seule.
  • Aucune boucle de distance maison : points d'interaction du Socle (addInteractionPoint) ou zones ox_lib.
  • Dans une boucle chaude, les réglages sont lus avec Core.Variable:watch.
  • Events envoyés au joueur concerné (Core.Event:send), pas à tout le monde.
  • Écritures SQL groupées quand elles sont nombreuses ; pas de requête à chaque image ou à chaque seconde par joueur.
  • Points, PNJ, objets et threads nettoyés à l'arrêt (onResourceStop) et à la déconnexion (playerDropped).

Sécurité

  • Chaque callback et event serveur valide tous ses paramètres (type, bornes, Core.isFinite).
  • Aucun prix, montant, objet ou position ne vient du client.
  • Distance vérifiée côté serveur pour toute action dans l'espace.
  • Permission vérifiée côté serveur (Core.Permission:can) pour toute action de staff.
  • Requêtes SQL préparées ; transaction pour toute opération de valeur en plusieurs étapes.
  • Valeurs sensibles en private = true.

Panel

  • Aucune valeur réglable en dur : tout passe par Core.Variable:define.
  • Chaque variable a une description utile ; bornes min / max cohérentes.
  • Le module réagit à chaud (variable:changed) ou la variable porte runtime = false.
  • Positions en vector, posables avec Placer en jeu.
  • Textes dans les quatre langues, aucune clé affichée brute en jeu.

Modèle de module vide

Le point de départ à copier pour un nouveau module : quatre fichiers, aucune logique métier, toutes les briques branchées (réglage, traduction, callback, event, lecture à chaud). Remplace monmodule partout par le nom de ton module (minuscules, sans espace ni accent).

resources/socle/modules/monmodule/
  shared/Lang.lua          ← textes du module (client et serveur)
  server/Variables.lua     ← réglages du panel
  server/MonModule.lua     ← décisions côté serveur
  client/MonModule.lua     ← affichage et demandes du joueur

Rien à toucher dans le fxmanifest.lua du Socle : ses motifs modules/**/shared/**, modules/**/server/** et modules/**/client/** chargent ces fichiers. Les fichiers partagés sont lus après les traductions et la configuration, les fichiers serveur après le système de réglages.

shared/Lang.lua : les textes, ajoutés à la langue active sans modifier les fichiers du Socle (voir Étape 6).

-- Textes du module : ajoutés à la langue active, jamais par-dessus un texte existant.
local TEXTES = {
    fr = {
        ["monmodule.ok"] = "C'est fait",
        ["monmodule.desactive"] = "Ce module est désactivé",
        ["monmodule.pong"] = "Réponse du serveur reçue",
    },
    en = {
        ["monmodule.ok"] = "Done",
        ["monmodule.desactive"] = "This module is disabled",
        ["monmodule.pong"] = "Server reply received",
    },
}

local actifs = Core.getTranslations()
for cle, texte in pairs(TEXTES[Config.lang] or TEXTES.en) do
    if actifs[cle] == nil then actifs[cle] = texte end
end

server/Variables.lua : la catégorie du panel et ses réglages (voir Réglages côté code).

-- Réglages du module. Toute valeur réglable est déclarée ici.
local C = "monmodule"
Core.Variable:defineCategory(C, "Mon module")

Core.Variable:define(C, "enabled", "Module activé", "boolean", true, {
    description = "Coupe ou active le module pour tout le monde, sans redémarrage.",
})

server/MonModule.lua : le serveur décide de tout.

-- Mon module, serveur : chaque demande est validée ici, rien ne vient du client.
local C = "monmodule"

local function var(cle, defaut) return Core.Variable:getOr(C, cle, defaut) end

-- demande avec réponse : ok, clé de texte
Core.Callback:register("monmodule:server:action", function(src, cb)
    if var("enabled", true) ~= true then return cb(false, "desactive") end
    -- valider ici : droits (Core.Permission:can), distance, anti-spam…
    cb(true, "ok")
end)

-- event signé venant du client, réponse envoyée à ce joueur seulement
Core.Event:register("monmodule:server:ping", function(src)
    if var("enabled", true) ~= true then return end
    Core.Event:send(src, "monmodule:client:pong")
end)

client/MonModule.lua : affiche, demande, réagit aux réglages.

-- Mon module, client : aucune boucle permanente, aucune décision.
local C = "monmodule"

local function var(cle, defaut) return Core.Variable:getOr(C, cle, defaut) end

local function notifier(ok, raison)
    exports.socle:Notify(Core.t("monmodule." .. tostring(raison or "desactive")), ok and "success" or "error")
end

local function demander()
    Core.Callback:trigger("monmodule:server:action", notifier)
end

-- (re)construit ce que le module affiche : points d'interaction, PNJ, blips…
local function appliquer()
    if var("enabled", true) ~= true then
        -- retirer ici ce qui a été créé
        return
    end
    -- créer ici, par exemple avec exports.socle:addInteractionPoint({ … onPress = demander })
end

Event:register("monmodule:client:pong", function()
    exports.socle:Notify(Core.t("monmodule.pong"), "info")
end)
-- pour envoyer l'event signé au serveur : Event:send("monmodule:server:ping")

-- état initial : Core.Variable:get attend l'arrivée des réglages
CreateThread(function()
    Core.Variable:get(C, "enabled")
    appliquer()
end)

-- réglage modifié au panel : effet immédiat
Core.Hook.listen("variable:changed", function(categorie)
    if categorie == C then appliquer() end
end)

Ensuite, ajoute ce dont ton module a besoin en suivant les étapes du tutoriel : une table SQL (Étape 3), un point d'interaction (Étape 5), un log ou une commande staff (Étape 4). Les fonctions utilisables sans risque sont listées dans Exports et API stables.