Moteur de concours Discord extensible
Un bot Discord de concours où un nouveau type de jeu s’ajoute par un seul fichier, avec tirage idempotent et classement déterministe.
En bref
- Problème
- Faire tourner plusieurs types de concours dans un même bot, sans disperser leur logique dans tout le code ni fausser les classements au redémarrage.
- Ce que j’ai réalisé
- Un moteur générique de cycle de vie et des modules de scoring interchangeables découverts dynamiquement, avec idempotence et déterminisme.
- Résultat
- Ajouter un type de concours ne demande qu’un fichier ; chaque tirage n’est appliqué qu’une fois, même après un redémarrage.
- Rôle
- Conception, architecture et vérification.
- Équipe
- Projet individuel
- Statut
- Terminé
- Confidentialité
- Certains détails sont volontairement anonymisés.
Sommaire6 sections
Contexte
Bot Discord auto-hébergé qui organise des concours, en Python 3.12 avec PostgreSQL. Pensé pour tourner en service sur un serveur.
Le problème
Chaque type de concours (parrainage, signalement, créatif) a ses propres règles de points. Écrites dans le cœur du bot, ces règles y ajouteraient des conditions à chaque nouveau type. Un redémarrage en plein concours risque aussi de recompter des points ou de réannoncer des gagnants.
Architecture et décisions
J’ai conçu un moteur générique qui gère le cycle de vie d’un concours (création, activation, clôture, désignation des gagnants, archivage) sans connaître aucun jeu en dur. Les règles de chaque type de concours vivent dans des modules de scoring interchangeables, découverts dynamiquement au démarrage et reliés au moteur par un contrat unique. Ajouter un type revient à déposer un fichier, sans toucher au cœur. Les modules ne dépendent ni de la bibliothèque Discord ni du SQL, ce qui rend leur logique testable hors ligne.
- Événement. Un événement Discord arrive : une arrivée de membre, un vote, ou une soumission d’image.
- Moteur. Le moteur le reçoit et ne le route qu’aux concours actifs.
- Registry. Au démarrage, le registry a découvert les modules par importlib ; le moteur lui demande le bon.
- Modules. Le module décide si l’événement compte et combien : parrainage, signalement ou créatif, chacun dans son fichier.
- Données. Le score passe par les repositories jusqu’à PostgreSQL ; le tirage est journalisé par clé unique, donc idempotent.
Le classement est déterministe : à score égal, le premier arrivé au score l’emporte, puis un identifiant stable départage. Un même jeu de données donne toujours le même résultat.
Fiabilité
Les transitions de cycle de vie sont idempotentes : rejouer une transition déjà appliquée ne fait rien. Le tirage des gagnants est dédupliqué par une clé unique en base, si bien qu’un double tirage ou un redémarrage ne réannonce jamais les gagnants ni ne recompte les points. Au routage des événements, l’échec du module d’un concours reste isolé : les autres concours continuent de recevoir l’événement.
Dans le tirage de la figure 2, chacun des douze membres fictifs reçoit un billet par point marqué dans un module actif. La graine alimente un générateur pseudo-aléatoire simple (mulberry32), qui tire trois gagnants sans remise. La même graine avec les mêmes modules redonne les mêmes gagnants. Un tirage déjà appliqué avec cette graine et ces modules n’est pas réannoncé, comme dans le moteur. Dans la table, à total égal, l’identifiant stable départage le rang.
| membre | parrainage | signalement | créatif | parrainage · signalement · créatif | total | rang |
|---|---|---|---|---|---|---|
| membre 01 | 1 | 6 | 3 | 1 · 6 · 3 | 10 | 4 |
| membre 02 | 6 | 3 | 2 | 6 · 3 · 2 | 11 | 3 |
| membre 03 | 1 | 0 | 4 | 1 · 0 · 4 | 5 | 10 |
| membre 04 | 1 | 6 | 6 | 1 · 6 · 6 | 13 | 1 |
| membre 05 | 0 | 0 | 0 | 0 · 0 · 0 | 0 | — |
| membre 06 | 0 | 6 | 4 | 0 · 6 · 4 | 10 | 5 |
| membre 07 | 2 | 1 | 5 | 2 · 1 · 5 | 8 | 7 |
| membre 08 | 6 | 1 | 0 | 6 · 1 · 0 | 7 | 8 |
| membre 09 | 2 | 2 | 2 | 2 · 2 · 2 | 6 | 9 |
| membre 10 | 0 | 1 | 1 | 0 · 1 · 1 | 2 | 11 |
| membre 11 | 5 | 0 | 5 | 5 · 0 · 5 | 10 | 6 |
| membre 12 | 6 | 4 | 2 | 6 · 4 · 2 | 12 | 2 |
Gagnants du dernier tirage appliqué
- Premier : membre 078 billets
- Deuxième : membre 0413 billets
- Troisième : membre 1212 billets
Tirage #1 — graine 42 — parrainage + signalement + créatif
Tirage #1 appliqué pour la graine 42. Relancer avec la même graine et les mêmes modules ne réannonce rien ; une autre graine ouvre un nouveau tirage.
Les commandes de cette démonstration nécessitent JavaScript ; l’état affiché est celui de la graine 42, tous modules actifs.
Journal des tirages
- #1 graine 42 · parrainage + signalement + créatif → membre 07, membre 04, membre 12
Anti-fraude et données
Les images soumises sont dédupliquées par hash perceptuel : sous un seuil de distance, une image est écartée comme quasi-doublon. Après un délai, l’URL des pièces jointes est purgée et seul le hash est conservé, pour l’audit.
Ce que j’ai appris, ce que je referais
J’ai modélisé l’idempotence dès le schéma, par une clé de déduplication. Les bugs de reprise (double annonce des gagnants, double comptage des points) n’ont donc pas eu à être corrigés après coup.
Le seuil d’anti-doublon est réglé de façon empirique et décide des faux positifs ; je monterais son banc d’essai plus tôt.