Resserre les plafonds de score, ajoute une vérif par temps réel écoulé sur Icare, corrige le contraste de l'Urne
Build and deploy / deploy (push) Successful in 36s

5000 restait trop haut pour être un vrai plafond réaliste : Icare abaissé à 500 (calculé sur le rythme le plus rapide théoriquement atteignable dans le jeu), Corne à 2000 (estimation plus prudente, économie de score plus dure à borner). Ajoute icarus_runs/start_icarus_run() : le serveur enregistre l'instant réel de début de partie et rejette un score incohérent avec le temps écoulé, sans jamais faire confiance à une durée envoyée par le client. Corrige aussi le nombre de jetons de /urne, peu lisible en gold-bright sur fond marbre (passe à text-sea, même convention qu'Icare/Corne).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-24 11:28:02 +02:00
parent dc99af6bb3
commit 35704e50fc
7 changed files with 151 additions and 25 deletions
+9 -5
View File
@@ -66,15 +66,16 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
### `public.icarus_scores` (Le Vol d'Icare) ### `public.icarus_scores` (Le Vol d'Icare)
- `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`. - `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 5000)` 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. - 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`.
- `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). - `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). - 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) ### `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`), y compris la contrainte `check (best_score between 0 and 5000)`. - `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).
- `settings.melon_points_awarded` : marqueur d'idempotence, même principe que `icarus_points_awarded`. - `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`, même borne de score et même `execute` révoqué sur la fonction cron-only — voir §3bis). - `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é.
- 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. - 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) ### `public.chariot_questions` / `public.chariot_slots` / `public.chariot_entries` (Le Jeu de l'Agora — Le Char)
@@ -109,7 +110,9 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
PostgreSQL accorde `EXECUTE` à `PUBLIC` par défaut sur toute fonction créée, sauf révocation explicite — un commentaire disant « pas de grant à authenticated » **n'est pas** une vraie restriction d'accès si le `REVOKE` correspondant n'est pas écrit, la fonction reste appelable via l'endpoint REST RPC de Supabase. `schema.sql` ouvre donc avec `alter default privileges in schema public revoke execute on functions from public;` (s'applique aux fonctions créées après cette ligne dans le script, pas rétroactivement) et chaque fonction cron-only (`award_icarus_points_if_due`, `award_melon_points_if_due`) porte en plus un `revoke execute ... from public, anon, authenticated;` explicite juste après sa définition. PostgreSQL accorde `EXECUTE` à `PUBLIC` par défaut sur toute fonction créée, sauf révocation explicite — un commentaire disant « pas de grant à authenticated » **n'est pas** une vraie restriction d'accès si le `REVOKE` correspondant n'est pas écrit, la fonction reste appelable via l'endpoint REST RPC de Supabase. `schema.sql` ouvre donc avec `alter default privileges in schema public revoke execute on functions from public;` (s'applique aux fonctions créées après cette ligne dans le script, pas rétroactivement) et chaque fonction cron-only (`award_icarus_points_if_due`, `award_melon_points_if_due`) porte en plus un `revoke execute ... from public, anon, authenticated;` explicite juste après sa définition.
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 ramené à une valeur généreuse mais réaliste (5000), appliqué à deux niveaux : 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. 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.
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). 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).
@@ -120,7 +123,8 @@ 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`). - `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. - `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`. - `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
- `submit_icarus_score(p_score)` — 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, voir §3bis). - `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).
- `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`). - `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. - `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é). - `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é).
+26 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium"; import { Podium } from "@/components/podium";
import { IconClose, IconTrophy } from "@/components/icons"; import { IconClose, IconTrophy } from "@/components/icons";
@@ -21,6 +21,12 @@ export function IcareView({
const [leaderboard, setLeaderboard] = useState(initialLeaderboard); const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore); const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
const [showRanking, setShowRanking] = useState(false); const [showRanking, setShowRanking] = useState(false);
// Promesse plutôt qu'une simple valeur : évite une course si la partie se
// termine avant que start_icarus_run() n'ait eu le temps de répondre —
// handleFinish attend cette même promesse (déjà résolue dans l'immense
// majorité des cas, une partie dure toujours largement plus longtemps que
// cet aller-retour réseau).
const runIdPromiseRef = useRef<Promise<string | null>>(Promise.resolve(null));
const refetch = useCallback(async () => { const refetch = useCallback(async () => {
const supabase = createClient(); const supabase = createClient();
@@ -67,9 +73,26 @@ export function IcareView({
}; };
}, [refetch]); }, [refetch]);
// Démarre le suivi serveur du temps de jeu (icarus_runs) dès le premier
// battement d'aile — soumis avec le score à la fin pour rejeter un score
// incohérent avec le temps réellement écoulé (voir submit_icarus_score,
// supabase/schema.sql).
function handleStart() {
const supabase = createClient();
runIdPromiseRef.current = (async () => {
try {
const { data } = await supabase.rpc("start_icarus_run");
return (data as string | null) ?? null;
} catch {
return null;
}
})();
}
async function handleFinish(score: number) { async function handleFinish(score: number) {
const supabase = createClient(); const supabase = createClient();
const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score }); const runId = await runIdPromiseRef.current;
const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score, p_run_id: runId });
if (!error && data) { if (!error && data) {
setOwnBestScore(data.best_score); setOwnBestScore(data.best_score);
refetch(); refetch();
@@ -98,7 +121,7 @@ export function IcareView({
</button> </button>
</div> </div>
<IcarusGame onFinish={handleFinish} /> <IcarusGame onStart={handleStart} onFinish={handleFinish} />
{showRanking && ( {showRanking && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm">
+13 -1
View File
@@ -271,12 +271,23 @@ function drawSpeedLines(ctx: CanvasRenderingContext2D, centerX: number, centerY:
ctx.restore(); ctx.restore();
} }
export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) { export function IcarusGame({
onStart,
onFinish,
}: {
onStart: () => void;
onFinish: (score: number) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>("idle"); const [status, setStatus] = useState<Status>("idle");
const [score, setScore] = useState(0); const [score, setScore] = useState(0);
const onStartRef = useRef(onStart);
useEffect(() => {
onStartRef.current = onStart;
}, [onStart]);
const onFinishRef = useRef(onFinish); const onFinishRef = useRef(onFinish);
useEffect(() => { useEffect(() => {
onFinishRef.current = onFinish; onFinishRef.current = onFinish;
@@ -318,6 +329,7 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
if (statusRef.current === "idle") { if (statusRef.current === "idle") {
statusRef.current = "flying"; statusRef.current = "flying";
setStatus("flying"); setStatus("flying");
onStartRef.current();
} else if (statusRef.current === "dead") { } else if (statusRef.current === "dead") {
resetGame(); resetGame();
} }
+1 -1
View File
@@ -254,7 +254,7 @@ export function UrneView({
<span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}> <span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}>
{citizen.pseudo} {citizen.pseudo}
</span> </span>
<span className="ml-auto shrink-0 font-heading text-gold-bright"> <span className="ml-auto shrink-0 font-heading text-sea">
{count > 0 ? `×${count}` : "—"} {count > 0 ? `×${count}` : "—"}
</span> </span>
</button> </button>
+1 -1
View File
@@ -80,4 +80,4 @@ export const MIN_SCORE = 0;
// Doit rester aligné avec la contrainte icarus_scores_best_score_check // Doit rester aligné avec la contrainte icarus_scores_best_score_check
// (supabase/schema.sql) — celle-ci est la vraie garantie, cette constante // (supabase/schema.sql) — celle-ci est la vraie garantie, cette constante
// n'est qu'une valeur de référence côté client. // n'est qu'une valeur de référence côté client.
export const MAX_SCORE = 5000; export const MAX_SCORE = 500;
+1 -1
View File
@@ -45,7 +45,7 @@ export const MIN_SCORE = 0;
// Doit rester aligné avec la contrainte melon_scores_best_score_check // Doit rester aligné avec la contrainte melon_scores_best_score_check
// (supabase/schema.sql) — celle-ci est la vraie garantie, cette constante // (supabase/schema.sql) — celle-ci est la vraie garantie, cette constante
// n'est qu'une valeur de référence côté client. // n'est qu'une valeur de référence côté client.
export const MAX_SCORE = 5000; export const MAX_SCORE = 2000;
// Positions figées à la main pour certains Citoyens (id de profil → index // Positions figées à la main pour certains Citoyens (id de profil → index
// de palier, 0 = le plus petit) — les Citoyens restants remplissent les // de palier, 0 = le plus petit) — les Citoyens restants remplissent les
+100 -13
View File
@@ -552,10 +552,15 @@ create table if not exists public.icarus_scores (
-- score inventé directement via l'API pour se faire attribuer des gloires -- score inventé directement via l'API pour se faire attribuer des gloires
-- au Tribunal — voir la contrainte identique sur melon_scores. La -- au Tribunal — voir la contrainte identique sur melon_scores. La
-- contrainte inline de create table ne s'applique qu'à la création ; sur -- contrainte inline de create table ne s'applique qu'à la création ; sur
-- une table déjà existante il faut explicitement la remplacer. -- une table déjà existante il faut explicitement la remplacer. Encore
-- resserré de 5000 à 500 (toujours généreux — largement au-dessus de ce
-- qu'une partie sans faute atteint en pratique, voir le calcul détaillé
-- au niveau de submit_icarus_score) après un deuxième passage d'audit :
-- 5000 restait un ordre de grandeur trop haut pour être vraiment un
-- plafond réaliste plutôt qu'une simple borne anti-débordement.
alter table public.icarus_scores drop constraint if exists icarus_scores_best_score_check; alter table public.icarus_scores drop constraint if exists icarus_scores_best_score_check;
alter table public.icarus_scores add constraint icarus_scores_best_score_check alter table public.icarus_scores add constraint icarus_scores_best_score_check
check (best_score between 0 and 5000); check (best_score between 0 and 500);
alter table public.icarus_scores enable row level security; alter table public.icarus_scores enable row level security;
@@ -574,10 +579,53 @@ create policy "icarus_scores readable by authenticated"
-- événement, pas de notion de jour comme pour la Course du Char). -- événement, pas de notion de jour comme pour la Course du Char).
alter table public.settings add column if not exists icarus_points_awarded boolean not null default false; alter table public.settings add column if not exists icarus_points_awarded boolean not null default false;
-- Deuxième couche, orthogonale à la borne fixe ci-dessus (audit de
-- sécurité) : le jeu n'a aucune vérification de gameplay (génération
-- procédurale entièrement côté client, voir V6/§3bis) — un score
-- jusqu'à 500 reste soumettable tel quel sans avoir vraiment joué. Cette
-- table fait tenir un compte du temps RÉEL écoulé (horloge du serveur,
-- jamais une durée envoyée par le client — sinon aussi falsifiable que le
-- score lui-même) entre le début d'une partie et sa soumission, pour
-- rejeter un score incohérent avec le temps réellement passé. Aucun accès
-- direct côté client (pas de grant) : seules les deux RPC ci-dessous
-- (SECURITY DEFINER) la lisent/écrivent.
create table if not exists public.icarus_runs (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles (id) on delete cascade,
started_at timestamptz not null default now()
);
alter table public.icarus_runs enable row level security;
create or replace function public.start_icarus_run()
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_id uuid;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
insert into public.icarus_runs (user_id) values (auth.uid()) returning id into v_id;
return v_id;
end;
$$;
grant execute on function public.start_icarus_run() to authenticated;
-- Signature changée (ajout de p_run_id) : l'ancienne (integer) est
-- explicitement supprimée, sinon create or replace créerait une 2e
-- surcharge au lieu de remplacer (même remarque que post_wall_note).
drop function if exists public.submit_icarus_score(integer);
-- Seul point d'entrée pour soumettre un score. Une fois la date du Tribunal -- Seul point d'entrée pour soumettre un score. Une fois la date du Tribunal
-- atteinte, les scores sont figés : la RPC ne fait plus rien (retourne le -- atteinte, les scores sont figés : la RPC ne fait plus rien (retourne le
-- record existant sans le modifier) plutôt que d'échouer bruyamment. -- record existant sans le modifier) plutôt que d'échouer bruyamment.
create or replace function public.submit_icarus_score(p_score integer) create or replace function public.submit_icarus_score(p_score integer, p_run_id uuid)
returns public.icarus_scores returns public.icarus_scores
language plpgsql language plpgsql
security definer security definer
@@ -586,16 +634,30 @@ as $$
declare declare
v_tribunal_date timestamptz; v_tribunal_date timestamptz;
v_row public.icarus_scores; v_row public.icarus_scores;
v_started_at timestamptz;
v_elapsed_seconds double precision;
-- Temps minimal réel pour passer une colonne, au rythme le plus rapide
-- jamais atteignable dans le jeu : vitesse de défilement pleinement
-- montée en difficulté (FORWARD_SPEED × SPEED_MAX_MULTIPLIER) ET
-- bouclier du boost actif en permanence (× BOOST_SPEED_MULTIPLIER en
-- plus) — un cas déjà irréaliste en soi (le bouclier n'est ni continu
-- ni permanent), donc une marge de sécurité généreuse avant même le
-- ×0.9 ci-dessous. Doit rester aligné avec COLUMN_SPACING/FORWARD_SPEED/
-- SPEED_MAX_MULTIPLIER/BOOST_SPEED_MULTIPLIER (src/lib/icarus/constants.ts).
-- 210 / (130 × 1.6 × 1.7) ≈ 0.594s, encore réduit de 10% (marge contre
-- le jitter d'arrondi de la boucle de jeu) : jamais assez strict pour
-- rejeter un score légitime, seulement pour rejeter l'impossible.
c_min_seconds_per_column constant double precision := 0.53;
begin begin
if auth.uid() is null then if auth.uid() is null then
raise exception 'authentication required'; raise exception 'authentication required';
end if; end if;
-- Plafond réaliste (5000, largement au-delà de ce qu'une vraie partie -- Plafond réaliste (500, largement au-delà de ce qu'une vraie partie
-- peut atteindre), pas juste borné à 1 000 000 — voir la contrainte de -- sans faute atteint), pas une simple borne anti-débordement — voir la
-- table associée (best_score_check), la vraie garantie ; cette -- contrainte de table associée (best_score_check), la vraie garantie ;
-- vérification donne juste un message d'erreur clair côté client. -- cette vérification donne juste un message d'erreur clair côté client.
if p_score is null or p_score < 0 or p_score > 5000 then if p_score is null or p_score < 0 or p_score > 500 then
raise exception 'invalid score'; raise exception 'invalid score';
end if; end if;
@@ -606,6 +668,23 @@ begin
return v_row; return v_row;
end if; end if;
if p_score > 0 then
select started_at into v_started_at
from public.icarus_runs
where id = p_run_id and user_id = auth.uid();
if v_started_at is null then
raise exception 'invalid run';
end if;
v_elapsed_seconds := extract(epoch from (now() - v_started_at));
if p_score > floor(v_elapsed_seconds / c_min_seconds_per_column) then
raise exception 'score incohérent avec le temps de jeu écoulé';
end if;
delete from public.icarus_runs where id = p_run_id;
end if;
insert into public.icarus_scores (user_id, best_score, updated_at) insert into public.icarus_scores (user_id, best_score, updated_at)
values (auth.uid(), p_score, now()) values (auth.uid(), p_score, now())
on conflict (user_id) do update on conflict (user_id) do update
@@ -618,7 +697,7 @@ begin
end; end;
$$; $$;
grant execute on function public.submit_icarus_score(integer) to authenticated; grant execute on function public.submit_icarus_score(integer, uuid) to authenticated;
do $$ do $$
begin begin
@@ -1509,10 +1588,18 @@ create table if not exists public.melon_scores (
); );
-- Plafond abaissé (audit de sécurité) : voir la remarque identique sur -- Plafond abaissé (audit de sécurité) : voir la remarque identique sur
-- icarus_scores. -- icarus_scores. Resserré une deuxième fois à 2000 (au lieu de 500 comme
-- Icare) : l'économie de score est différente ici (pas de vitesse de
-- défilement fixe à borner dans le temps — le score dépend du nombre de
-- fusions enchaînées, beaucoup plus dur à borner analytiquement sans
-- données de vraies parties) donc une estimation plus prudente plutôt
-- qu'un calcul aussi précis que pour Icare ; pas de deuxième couche par
-- temps réel écoulé pour Corne dans cette passe, contrairement à Icare
-- (icarus_runs) — à ajouter plus tard si des scores encore trop hauts sont
-- observés en pratique.
alter table public.melon_scores drop constraint if exists melon_scores_best_score_check; alter table public.melon_scores drop constraint if exists melon_scores_best_score_check;
alter table public.melon_scores add constraint melon_scores_best_score_check alter table public.melon_scores add constraint melon_scores_best_score_check
check (best_score between 0 and 5000); check (best_score between 0 and 2000);
alter table public.melon_scores enable row level security; alter table public.melon_scores enable row level security;
@@ -1548,11 +1635,11 @@ begin
raise exception 'authentication required'; raise exception 'authentication required';
end if; end if;
-- Plafond réaliste (5000, largement au-delà de ce qu'une vraie partie -- Plafond réaliste (2000, largement au-delà de ce qu'une vraie partie
-- peut atteindre), pas juste borné à 1 000 000 — voir la contrainte de -- peut atteindre), pas juste borné à 1 000 000 — voir la contrainte de
-- table associée (best_score_check), la vraie garantie ; cette -- table associée (best_score_check), la vraie garantie ; cette
-- vérification donne juste un message d'erreur clair côté client. -- vérification donne juste un message d'erreur clair côté client.
if p_score is null or p_score < 0 or p_score > 5000 then if p_score is null or p_score < 0 or p_score > 2000 then
raise exception 'invalid score'; raise exception 'invalid score';
end if; end if;