Le serveur est la seule autorité : l'app n'envoie que le choix du joueur et le temps mis.
Code : back/app/routers/answers.py (submit_answer), back/app/services/verify.py (grade), back/app/services/progress.py (record_answer, register_answer), back/app/services/skill.py, back/app/services/badges.py (check_after_answer).
POST /answers
Authorization: Bearer …
X-Timezone: Europe/Paris
{ "id": "<uuid généré par l'app>", "question_id": "q123" | "q123-l2",
"payload": { "choice": 1 } | { "bool": true } | { "estimate": 1994 } | { "order": [1,3,0,2] } | { "rapid": [0,1,…] } | {} ,
"elapsed_ms": 4210 }
id : 1 à 36 caractères. elapsed_ms : défaut 0. payload vide ou {"timeout": true} = temps écoulé.
| # | Étape | Fonction | Écrit |
|---|---|---|---|
| 1 | Authentification ; users.timezone ← X-Timezone s'il est valide |
deps.current_user |
users |
| 2 | Question : suffixe -l{n} retiré ; 404 si absente, ou non publiée et ni auteur ni admin |
questions.load_question |
— |
| 3 | answers[id] existe : autre joueur ou autre question → 409 ; sinon renvoi idempotent (fin) |
submit_answer |
— |
| 4 | Correction et barème | verify.grade |
— |
| 5 | users.last_seen_at |
progress.record_answer |
users |
| 6 | Nouvelle ligne answers (correct, elapsed_ms, xp) dans un point de sauvegarde (begin_nested). Index unique uq_answers_user_question : si une ligne existe déjà pour (joueur, question), IntegrityError → record_answer renvoie None → sortie « déjà jouée » (voir plus bas) |
record_answer |
answers |
| 7 | Journée : daily_progress créée si besoin ; première réponse du jour → days_played + 1 ; answered + 1 |
register_answer |
daily_progress, users |
| 8 | Bonne → correct + 1, combo + 1 ; sinon (temps écoulé compris) combo = 0 |
register_answer |
daily_progress, users |
| 9 | XP de la réponse : xp_total, level, xp_events, play_xp |
award_xp |
users, xp_events, daily_progress |
| 10 | Points = base + bonus de vitesse (si bonne et base > 0) | speed_bonus |
— |
| 11 | Points de saison : Postgres (thème + all) puis ZINCRBY Redis (world + pays), sauf exclu |
add_season_points |
season_scores, Redis lb:* |
| 12 | Défis : « 10 questions » +1 ; « combo » = max(progrès, users.combo) ; +100 XP par défi bouclé |
bump_challenges |
user_challenges, users, xp_events |
| 13 | Stats du thème : answers + 1 ; si bonne correct + 1 et best_ms |
register_answer |
user_category_stats |
| 14 | Force du thème (difficulté = question_stats.difficulty ou 0,55) |
skill.update_skill |
user_category_stats.skill, skill_n |
| 15 | Séries du fil (wrong_streak, correct_by_cat, boost) |
skill.record_result |
Redis feed:{user} |
| 16 | Badges : Réflexe, Sans faute, Chrono master, Noctambule, maîtrise du thème, jours joués, fondateur | badges.check_after_answer |
user_badges |
| 17 | Badges débloqués sérialisés (avec rareté) | serialize.badges_out |
— |
| 18 | COMMIT |
— | — |
| 19 | found_percent (SQL, après commit) et défis bouclés du jour |
found_percent, challenges_summary |
— |
Formules : Progression (étapes 4, 9, 10), Algorithme du fil (14, 15),
Classements (11), Défis et badges (12, 16).
Événements analytics : aucun écrit par le serveur. La table answers sert de source (ANALYTICS.md). L'app n'envoie
answer_error (timeout, offline, server) que si l'envoi échoue.
| Champ | Contenu |
|---|---|
correct |
verdict |
xp |
XP de la réponse + XP des défis bouclés par cette réponse |
points |
points de saison, bonus compris |
already_played |
question déjà jouée : rien enregistré ni crédité (XP et points à 0) |
explanation |
questions.explanation |
found_percent |
round(100 × bonnes / réponses) sur toutes les lignes answers ; 50 si aucune |
solution |
{index}, {value}, {order} ou {answers} selon le format (verify.solution_for) |
detail |
estimation {diff, tolerance}, ordre {good, total}, chrono {score, total}, sinon null |
xp_total, level, xp_next_level |
après crédit ; xp_next_level = xp_for_level(level + 1) |
play_xp |
XP de jeu du jour |
challenges_done |
défis du jour bouclés (0 à 3) |
badges_unlocked |
badges gagnés par cette réponse |
Une seule réponse par joueur et par question (contrainte uq_answers_user_question, migration 0013, qui a supprimé
les doublons en gardant la première réponse). Le fil boucle sur les mêmes questions : une question rejouée arrive avec
un nouvel id, l'insertion échoue et les étapes 7 à 17 n'ont pas lieu. Rien n'avance : ni journée (answered,
correct, days_played), ni combo, ni XP, ni points, ni défis, ni stats et force du thème, ni séries du fil, ni
badges. Seul users.last_seen_at est écrit (COMMIT).
Réponse : correct et detail du verdict, xp = 0, points = 0, already_played = true, explanation,
found_percent, solution, niveau, play_xp, challenges_done, pas de badges.
Deux envois simultanés de la même question (avec des id différents) : la base n'accepte qu'une ligne, le second
passe par la même sortie et n'est pas crédité.
Rien n'est écrit, sauf la ligne daily_progress du jour si elle n'existe pas. Réponse : correct et xp lus dans
answers, points = 0, explanation, found_percent, solution, niveau, play_xp, challenges_done. Pas de
detail, pas de badges, already_played = false.
Les réponses d'une partie ne passent pas par POST /answers. matches.finish_answer appelle record_answer (étapes 5
à 13) avec points = 0. Les étapes 14 à 17 n'ont pas lieu. Une question déjà jouée n'est ni enregistrée ni créditée
(record_answer renvoie None, XP 0). Voir Parties entre potes.
check_after_answer n'est pas appelé par les parties. Maîtrise, Sans faute, Noctambule attendent le prochain POST /answers ; les jours joués et fondateur sont aussi vérifiés par GET /me et GET /me/badges. Noctambule est alors jugé sur l'heure de cette réponse-là.answered + 1, jour joué, défi « 10 questions » +1, stats du thème (answers + 1).load_question la lui montre), et gagne XP et points.points = 0 et already_played = false même si la réponse d'origine différait.ZINCRBY (étape 11) partent avant le commit (étape 18).