Titres (gamification)
Système de titres dérivé du level utilisateur (lui-même dérivé d’experience via UserLevelCalculator). Le titre n’est pas stocké par utilisateur : il est résolu à la volée par UserTitleResolver au moment où la ressource users est sérialisée. Seul l’attribut rendu displayTitle est exposé.
Mécanique
Section titled “Mécanique”- Tous les 5 niveaux = nouveau bucket de titre.
titleIndex = floor((level - 1) / 5);rankIndex = (level - 1) % 5. - Dans un bucket, le
rankIndex ∈ [0, 4]est mappé sur 5 templates figés par produit (jamais administrables) :0→{title}(titre nu, ex. « Touriste »)1→{title} averti2→{title} confirmé3→{title} expert4→{title} légendaire
- Les templates vivent dans le catalogue i18n (
resources/lang/<locale>/titles.php) — ils ne sont PAS dans la base. La traduction de{title}provient detitle_translationpour la locale demandée, avec fallback double : locale demandée → locale par défaut →slug. - Plafond : si
levelexcède le bucket actif le plus haut, le resolver garde le bucket plafond et fait monter lerankIndexjusqu’à 4. Un user lv 30 alors que le BO n’a seedé que jusqu’au bucket « Pionnier » (lv 11..15) voit donc « Pionnier légendaire », pas un titre fantôme. - Catalogue vide (aucun bucket seedé par le BO) :
resolve()retournenullet le sérialiseur omet purement et simplementdisplayTitle— pas de fallback bidon, pas de crash côté front.
Schéma
Section titled “Schéma”title— un bucket :slug(identité publique stable),position(titleIndex, UNIQUE — deux titres ne peuvent pas se battre pour la même bande),is_active(retrait BO sans rupture historique : le resolver ne considère que les buckets actifs, mais une ligne archivée reste hydratable pour l’outillage admin), timestamps. Index couvrant(is_active, position)pour le chemin chaud du resolver.title_translation— un libellé par locale : PK composite(title_id, locale), FK CASCADE, KEY(locale, title_id)pour le join inverse.
Catalogue
Section titled “Catalogue”Le BO seede les buckets au fur et à mesure que le ladder s’étend — pas de redéploiement nécessaire. Un seed type :
INSERT INTO `title` (`id`, `slug`, `position`, `is_active`) VALUES (UNHEX(REPLACE(UUID(),'-','')), 'tourist', 0, 1), (UNHEX(REPLACE(UUID(),'-','')), 'explorer', 1, 1), (UNHEX(REPLACE(UUID(),'-','')), 'pioneer', 2, 1);-- + INSERT title_translation (title_id, locale, label) par (bucket, locale).UserTitleResolver mémoïse la liste active par locale, pour la durée du conteneur DI (request-scoped). Une requête qui sérialise N utilisateurs n’effectue donc qu’une seule lecture par locale, indépendamment de N. Le catalogue est borné par construction (un row par 5 niveaux).
Accès depuis l’objet User
Section titled “Accès depuis l’objet User”L’entité de domaine User expose trois getters dérivés (même pattern que displayName() / isConfirmed()), les dépendances étant passées en paramètres pour garder User readonly et sans état :
$level = $user->level($levels); // int$userTitle = $user->title($titles, $levels, $locale); // ?UserTitle (bucket + rank + displayTitle)$displayTitle = $user->displayTitle($titles, $levels, $locale); // ?string (shorthand)N’importe quel code applicatif qui détient déjà un User peut donc résoudre son titre sans repasser par le sérialiseur. Le sérialiseur lui-même utilise ces getters — pas de chemin parallèle.
Exposition côté ressource users
Section titled “Exposition côté ressource users”{ "level": 7, "levelProgress": 42.50, "experience": 1050, "displayTitle": "Explorateur averti"}levelProgress est la progression dans le niveau courant, exprimée en
pourcentage [0, 100] arrondi à 2 décimales. La formule s’appuie sur la
courbe quadratique d’XP : pour un niveau N avec factor = F, le palier
court de F·N·(N-1) à F·N·(N+1) XP — levelProgress mesure la position
relative dans ce palier. 0.00 au passage de niveau, monte vers 100,
remis à 0.00 au niveau suivant. Disponible aussi sur le bloc public
author.
displayTitle est rendu dans la locale du viewer (pas celle de l’utilisateur affiché — l’entité user n’a pas de colonne locale). Les actions d’authentification (POST /api/auth/login, POST /api/auth/register, GET /api/auth/me, GET /api/auth/confirm-email, OAuth) propagent la locale active de la requête au sérialiseur ; les autres listings utilisent la locale par défaut.
Notifications associées
Section titled “Notifications associées”Voir Notifications. Deux types sont dispatchés DANS la même transaction que l’UPDATE XP, depuis ExperienceService::award() :
user.level.up— un par niveau franchi. Un award qui fait sauter L4 → L7 émet 3 lignes.user.title.up— un par bucket traversé. Le même award émet aussi 1 ligne pour le passage L5 → L6 (frontière de bucket). Pas de title-up sur le plafond (le slug reste identique).
Les deux types respectent la préférence inApp du destinataire et le soft-dedup ; un rollback de la tx caller emporte les deux avec lui (cohérence stricte).