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
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:
@@ -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é).
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user