Resynchronise schema.sql avec l'anti-triche par signalement d'Alexandre
submit_icarus_score/submit_melon_score (appliquées directement en base, jamais commitées) sont récupérées mot pour mot via pg_get_functiondef : signalement dans game_cheat_flags plutôt que rejet serveur, le score reste toujours accepté. L'ancienne couche par rejet strict (icarus_runs/start_icarus_run) devenue orpheline est explicitement supprimée plutôt que laissée à traîner. CLAUDE.md documente le nouveau mécanisme (V12) et n'a plus aucune mention de l'ancien. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
|
||||
|
||||
## 1. État actuel
|
||||
|
||||
V1 à V11 sont livrées :
|
||||
V1 à V12 sont livrées :
|
||||
- **V1** : comptes, profils, leaderboard statique.
|
||||
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
|
||||
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
|
||||
@@ -19,6 +19,8 @@ V1 à V11 sont livrées :
|
||||
- **V10** : **L'Urne de l'Agora** (`/urne`) — vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque Citoyen dépose un jeton par jour sur une personne de son choix (jamais lui-même), confirmation à deux temps avant l'envoi (vote définitif pour la journée, aucune RPC de modification). Contrairement au Mur de la Honte, les jetons s'accumulent sur toute la semaine dans un classement public et cumulatif — **identique pour tout le monde, Archontes compris** : contraste volontaire avec le Mur, ici les Archontes n'ont aucune vision privilégiée sur qui a voté pour qui, seul le total par personne est public. Les Archontes ne votent pas et ne peuvent pas recevoir de jetons.
|
||||
- **V11** : deuxième mini-jeu compétitif, **La Corne d'Abondance** (`/corne`) — jeu de fusion façon « Watermelon Game » : des avatars de Citoyens tombent dans un bac (physique gérée par `matter-js`, seule dépendance de ce type dans le projet — l'empilement stable de cercles est notoirement difficile à obtenir sans bugs de superposition/tremblement en physique artisanale), deux avatars de même taille qui se touchent fusionnent en un plus gros, jusqu'au débordement soutenu (5 secondes de délai de grâce, pour ne pas sanctionner un simple rebond) qui met fin à la partie — les pièces qui dépassent la ligne de danger clignotent (`.animate-danger-blink`) pour signaler que le compte à rebours est en cours. **Un palier de taille par Citoyen** (pas les Archontes) plutôt qu'un nombre de paliers arbitraire (`buildTierRadii`, `src/lib/melon/constants.ts`) : `citizens[tier]` (indexation directe, sans tirage) donne l'association palier → Citoyen, **fixe pour tout le monde** (pas juste pour une partie) puisque `corne/page.tsx` trie déjà les Citoyens par pseudo, un ordre déterministe. `TIER_OVERRIDES` (`src/lib/melon/constants.ts`, id de profil → index de palier) permet de fixer certains Citoyens à une taille précise à la main ; `applyTierOverrides()` les place d'abord, puis remplit le reste dans l'ordre alphabétique. Même mécanique de gloires que Le Vol d'Icare : record personnel all-time, scores figés à la date du Tribunal, gloires du top 3 attribuées automatiquement (`pg_cron`, sans intervention d'un Archonte) — n'importe qui (Archontes compris) peut jouer, seul le pool d'avatars affichés est limité aux Citoyens. Rendu en DOM (pas canvas) pour réutiliser tel quel le composant `Avatar` existant (`src/components/avatar.tsx`, étendu pour accepter une taille en pixels arbitraire en plus des tailles nommées) — la position **et la rotation** de chaque pièce sont appliquées en impératif (`style.transform`, `translate(...) rotate(body.angle)`) à 60 fps depuis la boucle physique matter-js, jamais via `setState`. Le halo de l'animation d'apparition doit porter `rounded-full` lui-même, sinon son ombre portée dessine un carré visible autour du rond ; l'apparition d'une pièce utilise un simple fondu (`.animate-fade-in`) plutôt que l'animation avec mise à l'échelle utilisée ailleurs dans l'app (`.animate-reveal`), pour que sa taille exacte soit lisible dès la première frame. Trait de visée vertical + preview flottante au-dessus du bac pour montrer où la prochaine pièce va tomber, plus un second aperçu, plus petit et fixe (pas de position à viser), pour voir venir deux coups à l'avance. La bordure du bac vit sur un conteneur externe non dimensionné (pas sur le conteneur aux coordonnées physiques 300×420) : la mettre directement dessus réduisait sa zone intérieure réelle (box-sizing) sans que matter-js le sache, laissant les pièces s'empiler visuellement en dehors du cadre à droite/en bas. Sous le bac, une échelle de repère (taille d'affichage réduite mais proportionnelle au vrai rayon du palier, une seule ligne défilante, chaque rond centré sur son propre milieu plutôt qu'aligné en bas — un alignement bas avec des tailles très variables décalait progressivement la flèche par rapport aux plus gros ronds) montre l'ordre complet de fusion du plus petit au plus grand Citoyen.
|
||||
|
||||
- **V12** : deuxième volet de l'anti-triche des mini-jeux, ajouté par Alexandre (pas depuis cette session, appliqué directement en base puis récupéré/recommité dans `schema.sql` a posteriori — dump de schéma + `pg_policies` + `pg_get_functiondef`) et remplaçant l'approche par rejet serveur strict décrite en §3bis (`icarus_runs`/`start_icarus_run()`, désormais **supprimées**, `drop table`/`drop function` en tête de la section 11 de `schema.sql`) par un **signalement plutôt qu'un blocage** : `IcarusGame`/`MelonGame` calculent une télémétrie de partie côté client (`duration_ms`, nombre d'actions, et pour Corne le nombre de fusions — types `IcarusRunTelemetry`/`MelonRunTelemetry`) et l'envoient avec le score à `submit_icarus_score`/`submit_melon_score` (nouveau paramètre `p_run jsonb`, en plus d'une surcharge à 1 paramètre conservée pour compat qui délègue avec `p_run = null`). Le score reste **toujours accepté** (jamais de `raise exception` pour une télémétrie suspecte, contrairement à l'ancien plafond/l'ancienne vérification de temps qui restent, eux, des rejets durs) ; en parallèle, une partie est journalisée dans `public.game_cheat_flags` (`user_id, game, score, severity, trigger_code, reason, details`) dès que sa télémétrie est absente/invalide, ou incohérente avec ce que le jeu permet physiquement — pour Icare : rythme de score trop rapide (< 450 ms/point), score positif sans aucun battement d'aile, cadence de battements extrême ; pour Corne : lâchers plus rapides que `DROP_COOLDOWN_MS`, plus de fusions que de pièces lâchées ne le permettent, score dépassant le gain maximal théorique par fusion (`maxTier × 2`, la valeur du bonus de fusion finale). Nouvelle section « Détection de triche » sur `/admin` (`admin-cheat-flags.tsx`), filtrable par jeu, pour que les Archontes vérifient les signalements à la main.
|
||||
|
||||
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
|
||||
|
||||
---
|
||||
@@ -67,15 +69,16 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
|
||||
|
||||
- `user_id, best_score, updated_at` — clé primaire `user_id` : un seul record personnel all-time par joueur (pas de notion de jour). Verrouillée comme `points_log` : lecture ouverte aux authentifiés, écriture uniquement via la RPC `submit_icarus_score`.
|
||||
- Contrainte `check (best_score between 0 and 500)` sur `best_score` — la vraie garantie contre un score falsifié envoyé directement à la RPC (voir §3bis) ; `MAX_SCORE` dans `src/lib/icarus/constants.ts` n'est qu'une valeur de référence côté client, à garder alignée.
|
||||
- `public.icarus_runs` (`id, user_id, started_at`) : deuxième couche anti-triche, orthogonale à la borne fixe — voir §3bis et `submit_icarus_score`.
|
||||
- ~~`public.icarus_runs`~~ : deuxième couche anti-triche par rejet serveur strict, **supprimée** (`drop table`) depuis V12, remplacée par `game_cheat_flags` — voir §3bis.
|
||||
- `settings.icarus_points_awarded` : marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char).
|
||||
- Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale).
|
||||
|
||||
### `public.melon_scores` (La Corne d'Abondance)
|
||||
|
||||
- `user_id, best_score, updated_at` — même gabarit exact que `icarus_scores` (clé primaire `user_id`, verrouillée en écriture, uniquement via `submit_melon_score`), avec sa propre contrainte `check (best_score between 0 and 2000)` — pas la même valeur qu'Icare, l'économie de score est différente (voir §3bis) — et **pas** de deuxième couche par temps réel écoulé comme `icarus_runs` pour l'instant (formule de score plus dure à borner analytiquement pour Corne, calibrage basé sur une estimation plutôt qu'un calcul précis).
|
||||
- `user_id, best_score, updated_at` — même gabarit exact que `icarus_scores` (clé primaire `user_id`, verrouillée en écriture, uniquement via `submit_melon_score`), avec sa propre contrainte `check (best_score between 0 and 2000)` — pas la même valeur qu'Icare, l'économie de score est différente (voir §3bis).
|
||||
- `public.game_cheat_flags` (`id, user_id, game, score, severity, trigger_code, reason, details jsonb, created_at`) : anti-triche commun à Icare et Corne depuis V12 (voir §1) — journalise les parties suspectes plutôt que de les bloquer, affiché aux Archontes sur `/admin`. RLS `select` réservée aux juges (même pattern que `chariot_questions`), aucun grant d'écriture pour `authenticated` (comme `points_log`/`icarus_scores`) — écrit uniquement par `submit_icarus_score`/`submit_melon_score`.
|
||||
- `settings.melon_points_awarded` : marqueur d'idempotence, même principe que `icarus_points_awarded`.
|
||||
- `submit_melon_score(p_score)` / `award_melon_points_if_due()` : copies conformes de `submit_icarus_score`/`award_icarus_points_if_due` (gel à `tribunal_date`, top 3 ex-aequo inclus, `judge_id = null` dans `points_log`, tâche `pg_cron` `award-melon-points`, et même `execute` révoqué sur la fonction cron-only — voir §3bis) — sauf la borne de score elle-même (2000, pas 500) et l'absence de vérification par temps écoulé.
|
||||
- `submit_melon_score(p_score)` / `submit_melon_score(p_score, p_run)` / `award_melon_points_if_due()` : mêmes deux surcharges qu'`submit_icarus_score` (1 paramètre = compat, délègue avec `p_run = null` ; 2 paramètres = vrai point d'entrée), même borne fixe (2000, pas 500) et mêmes principes de signalement dans `game_cheat_flags` — voir V12 en §1 pour le détail des vérifications propres à Corne (cooldown de lâcher, nombre de fusions, gain max par fusion). `award_melon_points_if_due` reste une copie conforme d'`award_icarus_points_if_due` (gel à `tribunal_date`, top 3 ex-aequo inclus, `judge_id = null` dans `points_log`, tâche `pg_cron` `award-melon-points`, `execute` révoqué).
|
||||
- Le pool d'avatars des pièces (Citoyens uniquement, filtré côté serveur dans `corne/page.tsx` — un palier de taille par Citoyen) et les paliers/rayons du jeu vivent côté client (`src/lib/melon/constants.ts`), pas en base. N'importe qui peut jouer et apparaître dans `melon_scores`/le classement (Archontes compris) — seul le skin des pièces est restreint aux Citoyens.
|
||||
|
||||
### `public.chariot_questions` / `public.chariot_slots` / `public.chariot_entries` (Le Jeu de l'Agora — Le Char)
|
||||
@@ -112,7 +115,7 @@ PostgreSQL accorde `EXECUTE` à `PUBLIC` par défaut sur toute fonction créée,
|
||||
|
||||
Incident réel ayant motivé cet audit : les scores d'Icare/Corne n'ont **aucune vérification de gameplay côté serveur** (génération procédurale entièrement côté client, comme documenté depuis V6/V11) — c'est un choix assumé pour un classement amical, mais l'ancien plafond (1 000 000) rendait un score falsifié, envoyé directement à `submit_icarus_score`/`submit_melon_score` via l'API, capable de se convertir en **vraies gloires** au Tribunal (via l'attribution automatique du top 3). Plafond d'abord ramené à 5000 puis, après un deuxième passage d'audit jugeant encore ça trop haut pour être un vrai plafond réaliste, resserré à 500 pour Icare / 2000 pour Corne (valeurs différentes : l'économie de score de Corne, basée sur des fusions enchaînées plutôt qu'une vitesse de défilement fixe, est plus dure à borner précisément — voir juste en dessous). Appliqué à deux niveaux dans les deux cas : la contrainte `check` sur `best_score` (la vraie garantie, `alter table ... add constraint` — un `check` inline sur `create table if not exists` ne s'appliquerait pas rétroactivement à une table déjà créée) et, en défense en profondeur, la validation dans la RPC elle-même.
|
||||
|
||||
Pour Icare spécifiquement, une deuxième couche orthogonale à la borne fixe : `public.icarus_runs` (`id, user_id, started_at`) enregistre le vrai instant de début de partie côté serveur (horloge Postgres, `started_at default now()`) au premier battement d'aile (`start_icarus_run()`, appelée depuis `IcarusGame`'s nouveau prop `onStart`) ; `submit_icarus_score(p_score, p_run_id)` calcule ensuite `now() - started_at` et rejette un score qui excéderait le rythme le plus rapide théoriquement atteignable dans le jeu (vitesse de défilement pleinement montée en difficulté **et** bouclier du boost actif en continu — un cas déjà irréaliste en soi — avec une marge de sécurité supplémentaire de 10 % contre le jitter de la boucle de jeu). Toujours le temps **réel** écoulé mesuré par le serveur, jamais une durée envoyée par le client (qui serait aussi falsifiable que le score lui-même). `icarus_runs` n'a aucun grant/policy pour `authenticated` : seules `start_icarus_run()`/`submit_icarus_score()` (SECURITY DEFINER) y touchent. Pas encore appliqué à Corne (`melon_scores`) dans cette passe — la formule de score par fusions enchaînées est plus dure à borner analytiquement sans données de vraies parties ; son plafond fixe (2000) reste donc, pour l'instant, une estimation plus prudente plutôt qu'un calcul aussi précis que pour Icare.
|
||||
Pour Icare spécifiquement, une deuxième couche avait été ajoutée ici, orthogonale à la borne fixe : `public.icarus_runs` enregistrait le vrai instant de début de partie côté serveur et `submit_icarus_score` rejetait un score incohérent avec le temps réellement écoulé. **Cette approche a depuis été remplacée** par le mécanisme de signalement d'Alexandre (`game_cheat_flags`, voir V12 en §1) — `icarus_runs`/`start_icarus_run()` sont désormais supprimées (`drop table`/`drop function` en tête de la section 11 dans `schema.sql`), pas juste orphelines.
|
||||
|
||||
Bucket Storage `avatars` : `file_size_limit`/`allowed_mime_types` ajoutés (`on conflict (id) do update`, pas `do nothing`, pour que ré-exécuter `schema.sql` applique bien la limite à un bucket déjà existant) — sans ça, un upload direct à l'API de Storage (hors de l'app, où c'est toujours `cropImageToBlob` qui compresse) pouvait déposer un fichier arbitrairement gros ou non-image comme avatar. Même limite que `wall-images` (3 Mo, image/webp+jpeg+png).
|
||||
|
||||
@@ -123,8 +126,9 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
|
||||
- `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`).
|
||||
- `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client.
|
||||
- `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
|
||||
- `start_icarus_run()` — authenticated, enregistre l'instant réel de début de partie (`icarus_runs.started_at`) pour la vérification par temps écoulé de `submit_icarus_score` — voir §3bis.
|
||||
- `submit_icarus_score(p_score, p_run_id)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu, rejette un score hors bornes (défense en profondeur — la contrainte `check` sur `icarus_scores.best_score` est la garantie réelle) et un score incohérent avec le temps réellement écoulé depuis `p_run_id` (voir §3bis).
|
||||
- ~~`start_icarus_run()`~~ — **supprimée** (voir §3bis/V12), remplacée par `game_cheat_flags`.
|
||||
- `submit_icarus_score(p_score)` — authenticated, surcharge à 1 paramètre conservée pour compat (ancien client, appel RPC direct) : délègue à la version à 2 paramètres avec `p_run = null`, systématiquement signalée comme suspecte (`missing_run_telemetry`) mais jamais bloquée.
|
||||
- `submit_icarus_score(p_score, p_run)` — authenticated, seul vrai point d'entrée du client actuel (`p_run` en `jsonb`, la télémétrie de partie). Vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur, mais après le signalement — voir ci-dessous), n'écrase le record que s'il est strictement battu, rejette un score hors bornes (0-500, défense en profondeur — la contrainte `check` sur `icarus_scores.best_score` est la garantie réelle). Signale dans `game_cheat_flags` (jamais de rejet) : télémétrie manquante/invalide, rythme de score trop rapide pour la vitesse max du jeu, score positif sans battement d'aile enregistré, ou cadence de battements extrême — voir V12 en §1.
|
||||
- `award_icarus_points_if_due()` — **`execute` explicitement révoqué de `public`/`anon`/`authenticated`** (voir §3bis), appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`).
|
||||
- `reroll_gardien(p_force default false)` — authenticated. Tire un nouveau Gardien parmi les Citoyens (jamais le détenteur actuel si possible) et repousse `gardien_expires_at` de 5 minutes. `p_force=true` (vérifie `role = 'judge'`) reroll immédiatement ; `p_force=false` (appelée par le minuteur côté client à expiration, et par `pg_cron` en filet) ne fait rien tant que `gardien_expires_at` n'est pas atteint — un seul `UPDATE ... WHERE` atomique (pas de `SELECT` puis `UPDATE`), pour qu'un reroll naturel avec plusieurs téléphones ouverts au même moment ne reroll qu'une seule fois.
|
||||
- `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne dans `chariot_submissions`, plus une ligne append-only dans `chariot_submission_history` à chaque écriture réelle (pas lors du no-op figé).
|
||||
|
||||
Reference in New Issue
Block a user