Un seul moteur pour le duel (conversation direct) et le quiz de groupe (conversation group). Deux modes : live
(direct) et async (différé). Le serveur sert les questions sans la réponse et mesure seul le temps.
Contrat d'origine : back/QUIZ.md. Messages et groupes : Conversations. Canal /ws :
Temps réel.
| Nom | Valeur | Où |
|---|---|---|
SECONDS |
10 s par question (direct et différé), copié dans matches.seconds_per_question |
services/matches.py |
GRACE_MS |
1 500 ms de latence tolérée après le chrono | services/matches.py |
BASE_POINTS / SPEED_POINTS |
500 / 500 | services/matches.py |
KINDS |
qcm, true_false (seuls formats tirés) |
services/matches.py |
LOBBY_S |
60 s de salle d'attente (direct) | services/matches.py |
SWEEP_EVERY_S |
60 s entre deux passages de clôture du différé | services/matches.py |
REVEAL_S |
3 s de pause entre deux questions (direct) | services/live.py |
TICK_S |
0,5 s entre deux passages du direct | services/live.py |
MIN_PLAYERS |
2 joueurs pour démarrer un direct | services/live.py |
MATCH_REMINDER_HOURS |
3 (env) : rappel du différé N h avant la fin | config.py |
XP_CORRECT / XP_WRONG |
10 / 2 (env) : XP par réponse, comme le fil | config.py |
Entrée MatchIn (schemas.py) : mode ∈ {live, async}, questions ∈ {5, 10, 15}, category_ids (20 au plus,
vide = Mix), hours ∈ {24, 48} (ignoré en direct). GET /config renvoie match_questions, match_hours,
match_seconds lus dans ces types.
| Table | Clé | Colonnes utiles |
|---|---|---|
matches |
id |
conversation_id, created_by, mode, category_ids (JSON), question_ids (JSON, ordre figé), seconds_per_question, status, ends_at (différé), current_round, phase, deadline_at (direct, indexée), created_at |
match_players |
(match_id, user_id) |
joined_at (a commencé / rejoint), score, finished_at (a fini), reminded_at (rappel envoyé) |
match_answers |
(match_id, user_id, round) |
served_at (heure serveur d'envoi), pick, elapsed_ms (NULL = pas encore répondu), correct, points |
messages |
id |
kind = "match" : la carte (match_id, sender_id = lanceur) ; kind = "system" + match_id : podium ou annulation |
Un match_players est créé au lancement pour chaque membre de la conversation à cet instant. Personne d'autre ne
joue ensuite.
POST /matches → routers/matches.py (create) → services/matches.py (create_match).
convs.membership : 404 si l'appelant n'est pas membre.direct seulement : convs.reachable (l'autre existe, pas supprimé, aucun blocage dans un sens ouplayers.blocked_either_way). En groupe, ils ne jouent pas (403 « Tu n'étais pas dans la conversation audraw_questions, sur ces joueurs), 422 s'il manque des questions.matches + un match_players par joueur (direct : joined_at posé pour le lanceur seulement).kind = "match" ; push match aux autres joueurs ; conversation.updated_at et last_read_at du lanceurrealtime.conversation_changed.SELECT q.id FROM questions q
LEFT JOIN (SELECT question_id, COUNT(DISTINCT user_id) n FROM answers
WHERE user_id IN (:joueurs) GROUP BY question_id) p ON p.question_id = q.id
WHERE q.status = 'published' AND q.kind IN ('qcm','true_false') [AND q.category_id IN (:thèmes)]
ORDER BY COALESCE(p.n, 0), random() LIMIT :n
answers : fil et parties).question_ids).Code : back/app/services/matches.py (create_match, draw_questions), back/app/routers/matches.py (create)
direct différé
create ──► lobby ──(≥2 joueurs)──► running create ──► running
│ (start ou échéance) │ │ tous ont fini
│ │ │ ou ends_at passé
└─(<2 à l'échéance)─► cancelled finished ◄──┘
│
└──(dernière pause finie)──► finished
Phases du direct (matches.phase, current_round, deadline_at) :
lobby (60 s) ─► question r (10 s) ─► reveal r (3 s) ─► question r+1 … ─► reveal dernier ─► finish()
│ tout le monde a répondu
└──► reveal r tout de suite
| Statut | Mode | Sens |
|---|---|---|
lobby |
direct | salle d'attente, phase = "lobby", deadline_at = création + 60 s |
running |
les deux | en cours ; différé : phase NULL, ends_at = création + 24 ou 48 h |
finished |
les deux | podium écrit, phase et deadline_at remis à NULL |
cancelled |
direct | moins de 2 joueurs à la fin de la salle d'attente |
async)Routes : POST /matches/{id}/next, POST /matches/{id}/answers, GET /matches/{id}, GET /matches/{id}/rounds.
serve)my_place : 409 si direct ou pas running ; 403 si pas dans match_players ; 409 si déjà fini.joined_at posé à la première demande.elapsed_ms IS NULL) plus vieille que 10 s + 1,5 s : notée sans réponse (finish_answer avecpick = None), puis on passe à la suivante. C'est le cas « app fermée en pleine question ».remaining_ms (le chrono continue).match_answers (round = nombre de réponses déjà notées, served_at = now).La réponse ne contient ni correct_index ni l'explication. Vrai/faux : options = ["Vrai", "Faux"], Vrai = 0.
answer → finish_answer → score)round ne correspond pas à la question ouverte.finished_at = now, puis settle.settle termine la partie si plus personne n'a finished_at IS NULL, ou si ends_at est passé.sweep_loop (chaque worker, toutes les 60 s) : sweep clôt les async running dontends_at <= now, puis remind.match_for appelle settle à chaque lecture de la partie (GET, next, answers, rounds).ends_at, ou dès qu'un membre l'ouvre.remind)now < ends_at <= now + MATCH_REMINDER_HOURS.UPDATE match_players SET reminded_at = now WHERE finished_at IS NULL AND reminded_at IS NULL RETURNING user_id :match_reminder (voir Notifications).rounds_out)finished.finished_at. Tri par question : justes d'abord, puis temps.Code : back/app/services/matches.py (serve, answer, finish_answer, settle, sweep, remind, rounds_out)
live)Routes : POST /matches/{id}/join, POST /matches/{id}/start, POST /matches/{id}/live/answers (204),
GET /matches/{id}/live. Tout l'état est en base. Aucune partie en mémoire.
| Commande | Contrôles | Effet |
|---|---|---|
join |
mode live, dans match_players, statut lobby (sinon 409) |
joined_at = now ; broadcast |
start |
lanceur seulement (403), lobby, au moins 2 joined_at (409) |
begin → question 0 ; broadcast + conversation |
live/answers |
a rejoint (403) ; running, phase = question, bon round (409) ; pas déjà répondu (409) |
score ; si plus personne n'attend → reveal immédiat ; broadcast |
live_loop (chaque worker, toutes les 0,5 s)tick : SELECT … WHERE mode='live' AND status IN ('lobby','running') AND deadline_at <= now, puis advance :
| État échu | Transition |
|---|---|
lobby |
≥ 2 rejoints → begin (question 0) ; sinon cancel (message système « Partie en direct annulée… ») |
question |
reveal : deadline = now + 3 s, les réponses manquantes notées sans réponse |
reveal, pas la dernière |
open_question(r + 1) : deadline = now + 10 s |
reveal, dernière |
finish |
open_question insère une ligne match_answers (served_at = now) pour chaque joueur qui a joined_at. Le chrono est
donc le même pour tous. Un joueur rejoint mais déconnecté garde sa ligne ouverte : la question dure alors les 10 s.
Après chaque transition gagnée : commit, puis broadcast (état vu par chaque membre ; conversation = True si le
statut a changé).
joined_at garde can_play pendant running.GET /matches/{id}/live renvoie l'état complet. Il rejoue dès la question suivante (ses lignes sont créées tant qu'iljoined_at).Code : back/app/services/live.py (join, start, answer, advance, open_question, reveal, cancel, tick, state_out, broadcast)
score, commun aux deux modes)elapsed = now − served_at (ms, horloge serveur)
si elapsed > 10 000 + 1 500 : pick = None
correct = (pick == correct_index)
points = 0 si faux ou sans réponse
= 500 + round(500 × (T − min(elapsed, T)) / T) T = 10 000 ms
elapsed_ms stocké = min(elapsed, T)
| Temps de réponse (juste) | Points |
|---|---|
| 0 s | 1 000 |
| 2,5 s | 875 |
| 5 s | 750 |
| ≥ 10 s (dans la tolérance) | 500 |
UPDATE match_answers … WHERE elapsed_ms IS NULL. Si rowcount = 0, une autre requête aNone, rien n'est compté.match_players.score += points et record_answer (ligne answers).XP_CORRECT si juste, XP_WRONG si faux, 0 sans réponse. Aucun point de saison (points = 0). Détails :answers (correct = False) : la question compte comme jouée.uq_answers_user_question refuse la ligne answers,record_answer renvoie None : ni XP, ni défis, ni stats par thème. Les points de la partie comptent quand même.Code : back/app/services/matches.py (score, points_for, elapsed_since), back/app/services/progress.py (record_answer)
finish)UPDATE matches SET status='finished' WHERE id=… AND status='running'. Un seul worker passe (rowcount).joined_at mais sans finished_at : finished_at = now. Leurs points restent.finished_at, tri par score décroissant.podium_text. Rang partagé à score égal (1, 1, 3). Seules les 3 premières lignes sont écrites.match à tous les match_players (même ceux qui n'ont pas joué).match_players et 2 classés → reward_duel.Ordre d'affichage (players_of) : ceux qui ont fini d'abord, puis score décroissant, puis pseudo.
finished avec exactement 2 match_players, les deux avec finished_at (duels_query). Lewon = mon score > son score. Égalité = non gagné pour les deux.reward_duel : rien si égalité. Sinon série de victoires (duels récents d'abord, jusqu'à la première non-victoire),check_after_duel (badges).duels_won (duel_wins), GET /users/{pseudo}/duels (20 derniers).Code : back/app/services/matches.py (finish, podium_text, reward_duel), back/app/services/duels.py (duels_query, duels_of, duel_wins)
Production : uvicorn --workers (API_WORKERS, défaut 4 sur le VPS ; voir deploy/DEPLOY.md). Chaque worker lance
live_loop, sweep_loop et relay au démarrage (main.py, lifespan). Règle : chaque passage est une mise à jour
conditionnelle, le premier gagne, les autres ne font rien.
| Opération | Garde |
|---|---|
| Changement de phase direct | live.move : UPDATE matches … WHERE status = :ancien AND phase = :ancienne AND current_round = :ancien |
| Clôture | finish : WHERE status = 'running' |
| Notation d'une réponse | score : WHERE elapsed_ms IS NULL |
| Rappel | remind : WHERE reminded_at IS NULL … RETURNING |
| Événement | Message /ws |
Destinataires |
|---|---|---|
| Création (deux modes) | conversation |
membres actuels |
join, live/answers |
match (état vu par chacun) |
membres actuels |
start, passage lobby → running, cancelled, finished (direct) |
match puis conversation |
membres actuels |
| Autres passages du direct | match |
membres actuels |
Clôture par sweep (différé) |
conversation |
membres actuels |
L'état match est un LiveStateOut : phase, round, remaining_ms, joueurs (answered pendant la question ;
points, correct pendant la pause seulement), question sans réponse, my_pick, correct_index (pause seulement).
won = score > score. Elle casse la série de victoires et s'affiche/users/{pseudo}/duels.answer → settle) ou qu'une lecture clôt unematch_for), aucun conversation_changed n'est envoyé. La carte « 3 / 5 ont joué » ne bouge pas nonmatch_for exige d'être membre actuel (404), mais sa ligne match_players reste. Enends_at.match du direct et voit la carte, sans pouvoir jouerduels_query ne regarde que le nombre delines[:3] coupe à 3 lignes, même avec des ex æquo au 3ᵉ rang.QUIZ.md dit « proposée en différé ». Le code écrit seulement le message système, sansnext simultanés (même joueur, différé) : les deux insèrent la même clé match_answers ; le second échouesweep multi-workers : conversation_changed est appelé même par les workers qui ont perdu la clôture (messagesconversation en double, sans effet).ORDER BY random() sur toutes les questions publiées éligibles, avec agrégat sur answers desCode : back/app/services/matches.py, back/app/services/live.py, back/app/services/duels.py, back/app/routers/matches.py, back/app/main.py (lifespan)