Deux compteurs indépendants :
| XP | Points de saison | |
|---|---|---|
| Colonne | users.xp_total (+ users.level) |
season_scores.score + sorted sets Redis |
| Remise à zéro | jamais | chaque mois (UTC) |
| Sert à | niveau | classements |
Code : back/app/services/verify.py (grade), back/app/services/progress.py (register_answer, record_answer, speed_bonus, award_xp, level_for_xp), back/app/routers/answers.py.
verify.grade(q, payload) renvoie {correct, xp, points, detail, rapid_score}. Le payload a une seule clé ;
vide ou {"timeout": true} = temps écoulé. Une clé qui ne correspond pas au format = tout à 0.
| Format | Clé | Bonne réponse | XP | Points (avant bonus) |
|---|---|---|---|---|
| Temps écoulé | — | non | 0 | 0 |
qcm |
choice |
index = answer.index |
10 / 2 | 10 / 0 |
true_false |
bool |
value is True = answer.value |
10 / 2 | 10 / 0 |
estimate |
estimate |
points > 0 | 10 / 2 | 10, 6, 2 ou 0 (voir plus bas) |
order |
order |
toutes les positions justes | 10 / 2 | round(10 × justes / total), même si faux |
rapid |
rapid |
score > 0 | min(score, 5) × 10, ou 2 si score 0 |
min(score, 5) × 10 |
« 10 / 2 » = XP_CORRECT / XP_WRONG. « 10 » points = POINTS_CORRECT.
Estimation :
diff = |valeur − answer.value|
tol = max(1, content.tolerance ou 5)
close = max(1, round(tol × 0,4))
points = 10 si diff = 0 ; 6 si diff ≤ close ; 2 si diff ≤ tol ; sinon 0
detail renvoyé : estimation {diff, tolerance}, ordre {good, total}, chrono {score, total}.
Chrono 30 s : score = nombre d'items justes, comparés position par position.
Ajouté aux points si la réponse est bonne et que les points de base sont > 0 (register_answer) :
si elapsed_ms ≤ 0 → 0
fenêtre = (questions.timer_seconds ou 10) × 1000
bonus = round(POINTS_SPEED_BONUS_MAX × clamp(1 − elapsed_ms / fenêtre, 0, 1))
| Format | timer_seconds stocké |
Fenêtre |
|---|---|---|
qcm |
15 | 15 s |
true_false |
5 | 5 s |
estimate, order, rapid |
null | 10 s |
POINTS_SPEED_BONUS_MAX = 5. round Python (arrondi au pair sur les demis).elapsed_ms vient de l'app : temps entre l'affichage et l'envoi (feed_model.dart). Le serveur ne le vérifie pas.correct faux). Estimation à 2 points : bonus possible (correct vrai).Maximum par question : 15 points (50 pour un Chrono 30 s, avec bonus quasi nul en pratique).
award_xp(user, montant, source, play) : ajoute à xp_total, recalcule level, écrit une ligne xp_events. Avec
play=True, ajoute aussi à daily_progress.play_xp (XP de jeu du jour).
| Source | Montant | Variable | source |
play |
Où |
|---|---|---|---|---|---|
| Réponse | voir barème | XP_CORRECT, XP_WRONG |
answer |
oui | register_answer |
| Défi du jour bouclé | 100 | XP_CHALLENGE |
challenge |
oui | progress._advance |
| Pub « Ta journée » | 500 | XP_AD |
ad |
non | ads.grant |
| Pub « défis faits » | 300 | XP_AD_CHALLENGES |
ad |
non | ads.grant |
| Bonus abonné | 1 500 / jour | XP_SUB_BONUS |
sub_bonus |
non | routers/me.py (claim_bonus) |
Aucune de ces sources ne donne de points de saison. Pubs et abonnement sont derrière ADS_ENABLED et SUBSCRIPTION_ENABLED (faux).
matches.finish_answer appelle record_answer avec points = 0 :
matches.points_for).register_answer), sauf si la questionxp_for_level(L) = 0 si L ≤ 1, sinon int(100 × (L − 1)^1,6) # XP totale pour atteindre L
level_for_xp(xp) = plus grand L tel que xp ≥ xp_for_level(L)
| Niveau | 2 | 3 | 4 | 5 | 10 | 20 | 30 | 50 | 100 |
|---|---|---|---|---|---|---|---|---|---|
| XP totale | 100 | 303 | 579 | 918 | 3 363 | 11 117 | 21 869 | 50 619 | 155 961 |
level est recalculé à chaque award_xp (boucle linéaire). L'XP ne baisse jamais. xp_next_level renvoyé à l'app =
xp_for_level(level + 1) (XP totale, pas le reste).
| Où | Quoi |
|---|---|
users |
xp_total, level, combo (bonnes d'affilée, tous contextes), days_played, timezone, country_code |
answers |
une ligne par réponse (id donné par l'app), une seule par (joueur, question) (uq_answers_user_question) : correct, elapsed_ms, xp (XP de la réponse seule) |
xp_events |
journal de toute XP créditée |
daily_progress (user, jour local) |
play_xp, answered, correct, ads_watched, sub_bonus_claimed |
user_category_stats (user, thème) |
answers, correct, best_ms, skill, skill_n |
season_scores (user, saison, thème ou all) |
score |
Redis lb:{saison}:{thème}:{zone} |
sorted sets des classements (voir Classements) |
days_played : +1 à la première réponse d'une journée locale (daily_progress.answered passe de 0 à 1).
| Notion | Fuseau | Code |
|---|---|---|
| Saison | UTC : "YYYY-MM" de utcnow() ; fin = 1er du mois suivant 00:00 UTC |
season_id, season_bounds |
Journée du joueur (défis, daily_progress, pubs, bonus) |
users.timezone, défaut Europe/Paris |
clock.player_today |
| Journées des statistiques | ANALYTICS_TZ (défaut Europe/Paris) |
services/localtime.py |
users.timezone est mis à jour à chaque requête par l'en-tête X-Timezone s'il est un fuseau valide (deps.current_user).
La ligne seasons est créée à la demande (ensure_season), mise en cache par processus (_known_seasons).
Deux mécanismes différents :
| Cas | Détection | Effet |
|---|---|---|
| Renvoi de la même réponse (coupure réseau) | answers.id déjà présent pour ce joueur et cette question |
rien n'est réécrit ; renvoie correct et xp stockés, points = 0 |
Même answers.id pour un autre joueur ou une autre question |
idem | 409 |
| Question déjà jouée (boucle, partie entre potes), ou deux envois simultanés | contrainte unique uq_answers_user_question sur (user, question) : l'insertion échoue (IntegrityError dans un point de sauvegarde) |
record_answer renvoie None : rien n'est enregistré ni crédité ; already_played = true, XP et points à 0 |
Une question déjà jouée ne crée pas de ligne answers et ne passe pas par register_answer : ni journée
(answered, days_played), ni combo, ni défis, ni stats par thème. La migration 0013 a supprimé les doublons déjà
enregistrés en gardant la première réponse.
Détail de l'ordre : Parcours d'une réponse.
| Variable | Défaut |
|---|---|
XP_CORRECT |
10 |
XP_WRONG |
2 |
XP_CHALLENGE |
100 |
XP_AD |
500 |
XP_AD_CHALLENGES |
300 |
XP_SUB_BONUS |
1 500 |
ADS_PER_DAY / ADS_MAX_PER_DAY |
3 / 6 |
POINTS_CORRECT |
10 |
POINTS_SPEED_BONUS_MAX |
5 |
ANALYTICS_TZ |
Europe/Paris |
En dur : courbe 100 × (L − 1)^1,6, paliers de l'estimation (0,4 × tolérance ; 10/6/2), plafond de 5 bonnes au
Chrono 30 s, fenêtre de 10 s par défaut.
round(10 × justes / total)), 6 ou 2 à une estimation proche, et jusqu'à 50 au Chrono 30 s.correct est vrai : +10 XP et combo +1.timer_seconds null) alors que la manche dure 30 s.elapsed_ms non vérifié. Un client qui envoie 1 ms prend le bonus maximal (et le badge Réflexe). Voir Triche.points = 0 même si la réponse d'origine en avait gagné, et already_played faux, detail et badges absents.ZINCRBY part avant le commit : si le commit échoue, Redis garde des points absents de Postgres jusqu'au prochain rebuild.