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:
Valentin ROBIN
2026-08-25 16:42:26 +02:00
parent 50b354f11c
commit 088cd92e5f
2 changed files with 381 additions and 81 deletions
+370 -74
View File
@@ -579,53 +579,53 @@ create policy "icarus_scores readable by authenticated"
-- é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;
-- 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()
);
-- Anti-triche par signalement (Alexandre, appliqué directement en base —
-- récupéré ici via pg_get_functiondef le 2026-08-25 pour que ce fichier
-- redevienne la vraie source de vérité, voir game_cheat_flags en section
-- 17). Remplace la couche par rejet serveur strict qui vivait ici
-- (icarus_runs/start_icarus_run(), voir §3bis/V12 dans CLAUDE.md) : jamais
-- de blocage, un score dont la télémétrie de partie (durée, nombre de
-- battements d'aile — envoyée par le client dans p_run, voir
-- IcarusRunTelemetry) est manquante, invalide, ou incohérente avec le
-- rythme maximal du jeu déclenche seulement un signalement dans
-- game_cheat_flags pour vérification manuelle par un Archonte sur /admin.
-- Nettoyage de l'ancienne couche, devenue orpheline (plus aucun code
-- client ne l'appelle) :
drop function if exists public.submit_icarus_score(integer, uuid);
drop function if exists public.start_icarus_run();
drop table if exists public.icarus_runs;
alter table public.icarus_runs enable row level security;
-- Marqueur d'idempotence pour l'attribution automatique des gloires (un seul
-- é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;
create or replace function public.start_icarus_run()
returns uuid
-- Surcharge à un seul paramètre : conservée pour compat (ancien client,
-- appel RPC direct sans télémétrie), délègue à la version avec p_run à
-- null — systématiquement signalée comme suspecte ci-dessous, mais jamais
-- bloquée.
create or replace function public.submit_icarus_score(p_score integer)
returns public.icarus_scores
language plpgsql
security definer
set search_path = public
as $$
declare
v_id uuid;
v_row public.icarus_scores;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
select * into v_row
from public.submit_icarus_score(p_score, null::jsonb);
insert into public.icarus_runs (user_id) values (auth.uid()) returning id into v_id;
return v_id;
return v_row;
end;
$$;
grant execute on function public.start_icarus_run() to authenticated;
grant execute on function public.submit_icarus_score(integer) 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
-- 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.
create or replace function public.submit_icarus_score(p_score integer, p_run_id uuid)
-- Seul vrai point d'entrée utilisé par le client actuel. Une fois la date
-- du Tribunal 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 — mais le signalement, lui, a déjà eu lieu avant ce point.
create or replace function public.submit_icarus_score(p_score integer, p_run jsonb)
returns public.icarus_scores
language plpgsql
security definer
@@ -634,20 +634,10 @@ as $$
declare
v_tribunal_date timestamptz;
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;
v_previous_best integer;
v_duration_ms numeric;
v_actions numeric;
v_details jsonb;
begin
if auth.uid() is null then
raise exception 'authentication required';
@@ -661,28 +651,115 @@ begin
raise exception 'invalid score';
end if;
select tribunal_date into v_tribunal_date from public.settings where id = true;
select best_score into v_previous_best
from public.icarus_scores
where user_id = auth.uid();
if v_tribunal_date is not null and now() >= v_tribunal_date then
select * into v_row from public.icarus_scores where user_id = auth.uid();
return v_row;
if p_run is null
or jsonb_typeof(p_run) is distinct from 'object'
or (p_run ->> 'v') is distinct from '1' then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'icarus',
p_score,
'high',
'missing_run_telemetry',
'Score soumis sans télémétrie de partie (appel RPC direct ou ancien client).',
jsonb_build_object('previous_best', v_previous_best)
);
else
if jsonb_typeof(p_run -> 'duration_ms') = 'number' then
v_duration_ms := (p_run ->> 'duration_ms')::numeric;
end if;
if jsonb_typeof(p_run -> 'actions') = 'number' then
v_actions := (p_run ->> 'actions')::numeric;
end if;
v_details := jsonb_build_object(
'duration_ms', v_duration_ms,
'actions', v_actions,
'previous_best', v_previous_best
);
if v_duration_ms is null
or v_actions is null
or v_duration_ms < 0
or v_actions < 0 then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'icarus',
p_score,
'high',
'invalid_run_telemetry',
'La télémétrie de la partie est absente ou invalide.',
v_details
);
else
if p_score >= 3 and v_duration_ms < p_score * 450 then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'icarus',
p_score,
'high',
'impossible_score_pace',
'Le score a progressé plus vite que la vitesse maximale du jeu ne le permet.',
v_details
);
end if;
if p_score > 0 and v_actions < 1 then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'icarus',
p_score,
'high',
'score_without_input',
'Un score positif a été soumis sans aucun battement d''aile enregistré.',
v_details
);
end if;
if v_duration_ms > 0
and v_actions > (v_duration_ms / 1000) * 20 + 10 then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'icarus',
p_score,
'warning',
'extreme_input_rate',
'Cadence de battements anormalement élevée sur la partie.',
v_details
);
end if;
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();
select tribunal_date into v_tribunal_date
from public.settings
where id = true;
if v_started_at is null then
raise exception 'invalid run';
end if;
if v_tribunal_date is not null and now() >= v_tribunal_date then
select * into v_row
from public.icarus_scores
where user_id = auth.uid();
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;
return v_row;
end if;
insert into public.icarus_scores (user_id, best_score, updated_at)
@@ -692,12 +769,15 @@ begin
updated_at = now()
where excluded.best_score > public.icarus_scores.best_score;
select * into v_row from public.icarus_scores where user_id = auth.uid();
select * into v_row
from public.icarus_scores
where user_id = auth.uid();
return v_row;
end;
$$;
grant execute on function public.submit_icarus_score(integer, uuid) to authenticated;
grant execute on function public.submit_icarus_score(integer, jsonb) to authenticated;
do $$
begin
@@ -1618,18 +1698,58 @@ create policy "melon_scores readable by authenticated"
-- événement, comme icarus_points_awarded).
alter table public.settings add column if not exists melon_points_awarded boolean not null default false;
-- 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
-- record existant sans le modifier) plutôt que d'échouer bruyamment.
-- Anti-triche par signalement (Alexandre, appliqué directement en base —
-- récupéré ici via pg_get_functiondef le 2026-08-25, même mécanisme que
-- submit_icarus_score plus haut, voir §3bis/V12 dans CLAUDE.md et
-- game_cheat_flags en section 17) : jamais de blocage, une partie dont la
-- télémétrie (durée, nombre de lâchers, nombre de fusions — envoyée par le
-- client dans p_run, voir MelonRunTelemetry) est manquante, invalide, ou
-- incohérente avec ce que la physique du jeu permet (cooldown de lâcher,
-- nombre de fusions borné par le nombre de lâchers, score borné par le
-- gain maximal théorique par fusion) déclenche un signalement dans
-- game_cheat_flags pour vérification manuelle par un Archonte sur /admin.
--
-- Surcharge à un seul paramètre : conservée pour compat, délègue à la
-- version avec p_run à null — systématiquement signalée comme suspecte
-- ci-dessous, mais jamais bloquée.
create or replace function public.submit_melon_score(p_score integer)
returns public.melon_scores
language plpgsql
security definer
set search_path = public
as $$
declare
v_row public.melon_scores;
begin
select * into v_row
from public.submit_melon_score(p_score, null::jsonb);
return v_row;
end;
$$;
grant execute on function public.submit_melon_score(integer) to authenticated;
-- Seul vrai point d'entrée utilisé par le client actuel. Une fois la date
-- du Tribunal 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 — mais le signalement, lui, a déjà eu lieu avant ce point.
create or replace function public.submit_melon_score(p_score integer, p_run jsonb)
returns public.melon_scores
language plpgsql
security definer
set search_path = public
as $$
declare
v_tribunal_date timestamptz;
v_row public.melon_scores;
v_previous_best integer;
v_duration_ms numeric;
v_actions numeric;
v_merges numeric;
v_citizen_count integer;
v_max_tier integer;
v_details jsonb;
begin
if auth.uid() is null then
raise exception 'authentication required';
@@ -1643,10 +1763,143 @@ begin
raise exception 'invalid score';
end if;
select tribunal_date into v_tribunal_date from public.settings where id = true;
select best_score into v_previous_best
from public.melon_scores
where user_id = auth.uid();
select count(*)::integer into v_citizen_count
from public.profiles
where role = 'public';
v_max_tier := greatest(v_citizen_count - 1, 0);
if p_run is null
or jsonb_typeof(p_run) is distinct from 'object'
or (p_run ->> 'v') is distinct from '1' then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'melon',
p_score,
'high',
'missing_run_telemetry',
'Score soumis sans télémétrie de partie (appel RPC direct ou ancien client).',
jsonb_build_object('previous_best', v_previous_best)
);
else
if jsonb_typeof(p_run -> 'duration_ms') = 'number' then
v_duration_ms := (p_run ->> 'duration_ms')::numeric;
end if;
if jsonb_typeof(p_run -> 'actions') = 'number' then
v_actions := (p_run ->> 'actions')::numeric;
end if;
if jsonb_typeof(p_run -> 'merges') = 'number' then
v_merges := (p_run ->> 'merges')::numeric;
end if;
v_details := jsonb_build_object(
'duration_ms', v_duration_ms,
'actions', v_actions,
'merges', v_merges,
'previous_best', v_previous_best
);
if v_duration_ms is null
or v_actions is null
or v_merges is null
or v_duration_ms < 0
or v_actions < 0
or v_merges < 0 then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'melon',
p_score,
'high',
'invalid_run_telemetry',
'La télémétrie de la partie est absente ou invalide.',
v_details
);
else
if p_score > 0 and (v_actions < 2 or v_merges < 1) then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'melon',
p_score,
'high',
'score_without_required_moves',
'Un score positif nécessite au moins deux lâchers et une fusion.',
v_details
);
end if;
if v_actions >= 2
and v_duration_ms < (v_actions - 1) * 400 then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'melon',
p_score,
'high',
'impossible_drop_rate',
'Les pièces ont été lâchées plus vite que le délai minimal du jeu ne le permet.',
v_details
);
end if;
if v_merges > greatest(v_actions - 1, 0) then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'melon',
p_score,
'high',
'impossible_merge_count',
'Le nombre de fusions dépasse ce que le nombre de pièces lâchées permet.',
v_details
);
end if;
if v_merges > 0
and p_score > v_merges * greatest(v_max_tier * 2, 2) then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'melon',
p_score,
'high',
'impossible_score_from_merges',
'Le score dépasse le maximum théorique permis par les fusions enregistrées.',
v_details
);
end if;
end if;
end if;
select tribunal_date into v_tribunal_date
from public.settings
where id = true;
if v_tribunal_date is not null and now() >= v_tribunal_date then
select * into v_row from public.melon_scores where user_id = auth.uid();
select * into v_row
from public.melon_scores
where user_id = auth.uid();
return v_row;
end if;
@@ -1657,12 +1910,15 @@ begin
updated_at = now()
where excluded.best_score > public.melon_scores.best_score;
select * into v_row from public.melon_scores where user_id = auth.uid();
select * into v_row
from public.melon_scores
where user_id = auth.uid();
return v_row;
end;
$$;
grant execute on function public.submit_melon_score(integer) to authenticated;
grant execute on function public.submit_melon_score(integer, jsonb) to authenticated;
do $$
begin
@@ -1738,3 +1994,43 @@ exception
when others then
raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier l''attribution automatique des gloires de La Corne d''Abondance.';
end $$;
-- 17. Anti-triche par signalement (Alexandre) --------------------------------
-- Remplace icarus_runs (ancienne section 11, supprimée) côté client : plutôt
-- qu'un rejet serveur strict basé sur le temps réellement écoulé, cette
-- table journalise les parties suspectes (durée/nombre d'actions/fusions
-- incohérents avec le score, calculés côté client et envoyés dans p_run à
-- submit_icarus_score/submit_melon_score, section 11/16) pour qu'un
-- Archonte les vérifie à la main sur /admin (AdminCheatFlags) — les scores
-- concernés restent acceptés, rien n'est jamais bloqué automatiquement.
--
-- Table, policy RLS et logique d'écriture (submit_icarus_score/
-- submit_melon_score) toutes récupérées directement depuis Supabase
-- (dump de schéma + pg_policies + pg_get_functiondef, 2026-08-25) — ce
-- fichier est de nouveau la vraie source de vérité pour cette
-- fonctionnalité, contrairement à l'état précédent.
create table if not exists public.game_cheat_flags (
id bigint generated always as identity primary key,
user_id uuid not null references public.profiles (id),
game text not null check (game in ('icarus', 'melon')),
score integer not null,
severity text not null check (severity in ('warning', 'high')),
trigger_code text not null,
reason text not null,
details jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
alter table public.game_cheat_flags enable row level security;
-- Pas de grant insert/update/delete à authenticated : comme points_log/
-- icarus_scores, seules les fonctions SECURITY DEFINER submit_icarus_score/
-- submit_melon_score (section 11/16) peuvent y écrire.
revoke insert, update, delete on public.game_cheat_flags from authenticated, anon;
grant select on public.game_cheat_flags to authenticated;
drop policy if exists "game_cheat_flags readable by judges" on public.game_cheat_flags;
create policy "game_cheat_flags readable by judges"
on public.game_cheat_flags for select
to authenticated
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));