---
title: "Titres (gamification)"
description: "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é…"
---

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

- 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} averti`
  - `2` → `{title} confirmé`
  - `3` → `{title} expert`
  - `4` → `{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 de `title_translation` pour la locale demandée, avec fallback double : locale demandée → locale par défaut → `slug`.
- **Plafond** : si `level` excède le bucket actif le plus haut, le resolver garde le bucket plafond et fait monter le `rankIndex` jusqu'à 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()` retourne `null` et le sérialiseur **omet purement et simplement** `displayTitle` — pas de fallback bidon, pas de crash côté front.

## 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

Le BO **seede** les buckets au fur et à mesure que le ladder s'étend — pas de redéploiement nécessaire. Un seed type :

```sql
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).
```

## Cache

`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`

L'entité de domaine [`User`](../src/Domain/User/User.php) 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 :

```php
$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`

```json
{
  "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`](#auteur-public-author-sur-la-ressource-medias).

`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

Voir [Notifications](#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).

---
