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 :
- un dossier de module ;
- des réglages dans le panel (aucune valeur en dur) ;
- une table SQL créée par une migration ;
- un callback serveur qui valide tout (activation, horaire, distance, anti-spam, une fois par jour) ;
- un point d'interaction E avec un PNJ ;
- des textes traduits dans les quatre langues ;
- un log, une commande staff protégée par une permission ;
- 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 SQLRien à 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
moneyest 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
%sdans 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
endLes 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
- 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).
- Console serveur : aucune ligne
variable sans description : prime.…, aucune erreurTranslation key … not found, et la ligneSchema des variables publie pour le panel. - 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.
- 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). - Mets temporairement Heure d'ouverture à l'heure actuelle pour tester le versement, puis vérifie la page Logs (catégorie
money). - Avec un compte staff :
/primereset <id>. Sans la permissionadmin.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
TriggerServerEventdirect vers un event enregistré parCore.Event:registerest refusé (invalid token) sinetwork_tokenest 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 zonesox_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
descriptionutile ; bornesmin/maxcohérentes. - Le module réagit à chaud (
variable:changed) ou la variable porteruntime = 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 joueurRien à 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
endserver/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.