Ajoute un ping serveur régulier comme troisième couche d'anti-triche sur Icare et la Corne
Build and deploy / deploy (push) Successful in 36s
Build and deploy / deploy (push) Successful in 36s
duration_ms/actions/merges restaient entièrement déclaratifs (calculés et envoyés par le client en une fois à la fin), donc falsifiables par un appel RPC direct. Le client envoie maintenant un ping toutes les 5s pendant la partie (ping_game_run), horodaté par le serveur, jamais par le client. submit_icarus_score/submit_melon_score corrèlent le nombre de pings et leur écart réel avec la durée annoncée (parties ≥ 15s uniquement) et signalent toute incohérence dans game_cheat_flags (missing_pings, ping_span_mismatch) — toujours en signalement silencieux, jamais de blocage, comme pour le reste de l'anti-triche. 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
|
## 1. État actuel
|
||||||
|
|
||||||
V1 à V12 sont livrées :
|
V1 à V13 sont livrées :
|
||||||
- **V1** : comptes, profils, leaderboard statique.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -21,6 +21,8 @@ V1 à V12 sont livrées :
|
|||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
|
|
||||||
|
- **V13** : troisième couche de l'anti-triche des mini-jeux (à la demande explicite de l'utilisateur, après qu'un premier signalement réel via V12 a confirmé que le mécanisme fonctionnait) — un ping régulier pendant la partie, en plus du signalement fondé sur la télémétrie déclarative de V12. `public.game_run_pings` (`id, user_id, game, session_token, created_at`) journalise chaque ping, horodaté par le **serveur** (jamais par le client, contrairement à `duration_ms`/`actions`/`merges` qui restent déclaratifs) ; `ping_game_run(p_game, p_session_token)` l'écrit. Côté client, `IcarusGame`/`MelonGame` retrouvent un prop `onStart` (le composant de jeu reste agnostique de Supabase, comme avant — c'est `IcareView`/`CorneView` qui gèrent le token/l'intervalle) déclenché au premier battement d'aile pour Icare, au montage et à chaque `resetGame()` pour Corne ; `IcareView`/`CorneView` y génèrent un `session_token` (`crypto.randomUUID()`), lancent un `setInterval` de 5s qui appelle `ping_game_run()` (best-effort, erreurs ignorées), l'arrêtent à la fin de la partie ou au démontage du composant, et l'incluent dans `p_run.session_token` envoyé à `submit_icarus_score`/`submit_melon_score`. Ces deux RPC corrèlent alors le nombre de pings et l'écart réel entre le premier et le dernier avec `duration_ms` annoncé (uniquement si celui-ci dépasse 15s, pour ne jamais signaler une partie courte/un échec immédiat) et ajoutent deux nouveaux `trigger_code` à `game_cheat_flags` : `missing_pings` (zéro ping reçu) et `ping_span_mismatch` (écart réel entre pings très inférieur à la durée annoncée) — toujours en signalement, jamais en blocage, même philosophie que V12. Décision produit assumée (l'utilisateur l'a explicitement refusée pour V12 aussi) : les signalements restent **volontairement invisibles** pour la personne concernée — le but est de savoir qui triche sans le prévenir, y compris si un score signalé finit par recevoir de vraies gloires automatiquement au Tribunal.
|
||||||
|
|
||||||
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
|
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -77,8 +79,9 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
|
|||||||
|
|
||||||
- `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).
|
- `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`.
|
- `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`.
|
||||||
|
- `public.game_run_pings` (`id, user_id, game, session_token, created_at`) : depuis V13 (voir §1) — un ping toutes les 5s pendant une partie, horodaté par le serveur, pour corroborer `duration_ms` (déclaratif) avec une preuve indépendante. Aucun grant du tout pour `authenticated`/`anon` (même principe que l'ancien `icarus_runs`) : seules `ping_game_run()` (insert) et `submit_icarus_score`/`submit_melon_score` (lecture + nettoyage) y touchent.
|
||||||
- `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)` / `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é).
|
- `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`, y compris les vérifications par ping de V13 — voir V12/V13 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.
|
- 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)
|
||||||
@@ -128,7 +131,8 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
|
|||||||
- `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`.
|
||||||
- ~~`start_icarus_run()`~~ — **supprimée** (voir §3bis/V12), remplacée par `game_cheat_flags`.
|
- ~~`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)` — 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.
|
- `ping_game_run(p_game, p_session_token)` — authenticated, appelée toutes les 5s pendant une partie (Icare ou Corne) : horodate un ping dans `game_run_pings` pour la vérification par ping de `submit_icarus_score`/`submit_melon_score` — voir V13 en §1.
|
||||||
|
- `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, avec `session_token` depuis V13). 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é, cadence de battements extrême, ou (V13, uniquement si `duration_ms` ≥ 15s) aucun ping serveur reçu / écart réel entre pings très inférieur à la durée annoncée — voir V12/V13 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`).
|
- `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, IconHorn } from "@/components/icons";
|
import { IconClose, IconHorn } from "@/components/icons";
|
||||||
@@ -9,6 +9,10 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
|||||||
import { MelonGame, type MelonRunTelemetry } from "./melon-game";
|
import { MelonGame, type MelonRunTelemetry } from "./melon-game";
|
||||||
import type { MelonScoreEntry, MemberRow } from "./page";
|
import type { MelonScoreEntry, MemberRow } from "./page";
|
||||||
|
|
||||||
|
// Anti-triche par ping — voir la même remarque dans icare-view.tsx et
|
||||||
|
// supabase/schema.sql section 18.
|
||||||
|
const PING_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
export function CorneView({
|
export function CorneView({
|
||||||
citizens,
|
citizens,
|
||||||
initialLeaderboard,
|
initialLeaderboard,
|
||||||
@@ -23,6 +27,19 @@ export function CorneView({
|
|||||||
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);
|
||||||
|
const sessionTokenRef = useRef<string | null>(null);
|
||||||
|
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const stopPinging = useCallback(() => {
|
||||||
|
if (pingIntervalRef.current !== null) {
|
||||||
|
clearInterval(pingIntervalRef.current);
|
||||||
|
pingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Coupe le ping si le joueur quitte /corne en pleine partie (sinon
|
||||||
|
// l'intervalle continuerait d'appeler la RPC pour un composant démonté).
|
||||||
|
useEffect(() => stopPinging, [stopPinging]);
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -64,9 +81,28 @@ export function CorneView({
|
|||||||
};
|
};
|
||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
async function handleFinish(score: number, run: MelonRunTelemetry) {
|
function handleStart() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase.rpc("submit_melon_score", { p_score: score, p_run: run });
|
const token = crypto.randomUUID();
|
||||||
|
sessionTokenRef.current = token;
|
||||||
|
|
||||||
|
stopPinging();
|
||||||
|
pingIntervalRef.current = setInterval(() => {
|
||||||
|
// Best-effort : un ping manqué (réseau, onglet en arrière-plan) est
|
||||||
|
// sans conséquence, submit_melon_score reste tolérant (marge de 15s
|
||||||
|
// avant le premier ping attendu, voir schema.sql section 18).
|
||||||
|
supabase.rpc("ping_game_run", { p_game: "melon", p_session_token: token }).then(
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFinish(score: number, run: MelonRunTelemetry) {
|
||||||
|
stopPinging();
|
||||||
|
const supabase = createClient();
|
||||||
|
const runWithToken = { ...run, session_token: sessionTokenRef.current };
|
||||||
|
const { data, error } = await supabase.rpc("submit_melon_score", { p_score: score, p_run: runWithToken });
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
setOwnBestScore(data.best_score);
|
setOwnBestScore(data.best_score);
|
||||||
refetch();
|
refetch();
|
||||||
@@ -100,7 +136,7 @@ export function CorneView({
|
|||||||
Classement
|
Classement
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<MelonGame citizens={citizens} onFinish={handleFinish} />
|
<MelonGame citizens={citizens} onStart={handleStart} onFinish={handleFinish} />
|
||||||
|
|
||||||
{ownBestScore !== null && (
|
{ownBestScore !== null && (
|
||||||
<p className="text-center font-heading text-sm tracking-wide text-marble">
|
<p className="text-center font-heading text-sm tracking-wide text-marble">
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ export type MelonRunTelemetry = {
|
|||||||
|
|
||||||
export function MelonGame({
|
export function MelonGame({
|
||||||
citizens,
|
citizens,
|
||||||
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
}: {
|
}: {
|
||||||
citizens: MemberRow[];
|
citizens: MemberRow[];
|
||||||
|
onStart: () => void;
|
||||||
onFinish: (score: number, run: MelonRunTelemetry) => void;
|
onFinish: (score: number, run: MelonRunTelemetry) => void;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -67,6 +69,11 @@ export function MelonGame({
|
|||||||
// viser), pour voir venir deux coups à l'avance au lieu d'un seul.
|
// viser), pour voir venir deux coups à l'avance au lieu d'un seul.
|
||||||
const [secondTier, setSecondTier] = useState(() => randomTier(DROP_MIN_TIER, dropMaxTier));
|
const [secondTier, setSecondTier] = useState(() => randomTier(DROP_MIN_TIER, dropMaxTier));
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -91,6 +98,7 @@ export function MelonGame({
|
|||||||
const runStartedAtRef = useRef(0);
|
const runStartedAtRef = useRef(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runStartedAtRef.current = Date.now();
|
runStartedAtRef.current = Date.now();
|
||||||
|
onStartRef.current();
|
||||||
}, []);
|
}, []);
|
||||||
const dropCountRef = useRef(0);
|
const dropCountRef = useRef(0);
|
||||||
const mergeCountRef = useRef(0);
|
const mergeCountRef = useRef(0);
|
||||||
@@ -152,6 +160,7 @@ export function MelonGame({
|
|||||||
mergingBodyIdsRef.current.clear();
|
mergingBodyIdsRef.current.clear();
|
||||||
dangerSinceRef.current = null;
|
dangerSinceRef.current = null;
|
||||||
runStartedAtRef.current = Date.now();
|
runStartedAtRef.current = Date.now();
|
||||||
|
onStartRef.current();
|
||||||
dropCountRef.current = 0;
|
dropCountRef.current = 0;
|
||||||
mergeCountRef.current = 0;
|
mergeCountRef.current = 0;
|
||||||
lastDropAtRef.current = -Infinity;
|
lastDropAtRef.current = -Infinity;
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -9,6 +9,15 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
|||||||
import { IcarusGame, type IcarusRunTelemetry } from "./icarus-game";
|
import { IcarusGame, type IcarusRunTelemetry } from "./icarus-game";
|
||||||
import type { IcarusScoreEntry } from "./page";
|
import type { IcarusScoreEntry } from "./page";
|
||||||
|
|
||||||
|
// Anti-triche par ping (complète le signalement d'Alexandre, voir
|
||||||
|
// supabase/schema.sql section 18) : la durée/nombre d'actions envoyées à la
|
||||||
|
// fin d'une partie sont déclaratives, un ping régulier horodaté par le
|
||||||
|
// serveur PENDANT la partie donne une preuve indépendante. Rythme choisi
|
||||||
|
// pour rester léger (pas de souci de charge à cette fréquence pour ~12
|
||||||
|
// joueurs) tout en laissant peu de marge à une partie fabriquée sans
|
||||||
|
// jamais avoir été réellement ouverte.
|
||||||
|
const PING_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
export function IcareView({
|
export function IcareView({
|
||||||
initialLeaderboard,
|
initialLeaderboard,
|
||||||
initialOwnBestScore,
|
initialOwnBestScore,
|
||||||
@@ -21,6 +30,20 @@ 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);
|
||||||
|
const sessionTokenRef = useRef<string | null>(null);
|
||||||
|
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const stopPinging = useCallback(() => {
|
||||||
|
if (pingIntervalRef.current !== null) {
|
||||||
|
clearInterval(pingIntervalRef.current);
|
||||||
|
pingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Coupe le ping si le joueur quitte /icare en pleine partie (sinon
|
||||||
|
// l'intervalle continuerait d'appeler la RPC pour un composant démonté).
|
||||||
|
useEffect(() => stopPinging, [stopPinging]);
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data: scores } = await supabase
|
const { data: scores } = await supabase
|
||||||
@@ -66,9 +89,28 @@ export function IcareView({
|
|||||||
};
|
};
|
||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
async function handleFinish(score: number, run: IcarusRunTelemetry) {
|
function handleStart() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score, p_run: run });
|
const token = crypto.randomUUID();
|
||||||
|
sessionTokenRef.current = token;
|
||||||
|
|
||||||
|
stopPinging();
|
||||||
|
pingIntervalRef.current = setInterval(() => {
|
||||||
|
// Best-effort : un ping manqué (réseau, onglet en arrière-plan) est
|
||||||
|
// sans conséquence, submit_icarus_score reste tolérant (marge de 15s
|
||||||
|
// avant le premier ping attendu, voir schema.sql section 18).
|
||||||
|
supabase.rpc("ping_game_run", { p_game: "icarus", p_session_token: token }).then(
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFinish(score: number, run: IcarusRunTelemetry) {
|
||||||
|
stopPinging();
|
||||||
|
const supabase = createClient();
|
||||||
|
const runWithToken = { ...run, session_token: sessionTokenRef.current };
|
||||||
|
const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score, p_run: runWithToken });
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
setOwnBestScore(data.best_score);
|
setOwnBestScore(data.best_score);
|
||||||
refetch();
|
refetch();
|
||||||
@@ -97,7 +139,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">
|
||||||
|
|||||||
@@ -278,8 +278,10 @@ export type IcarusRunTelemetry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function IcarusGame({
|
export function IcarusGame({
|
||||||
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
}: {
|
}: {
|
||||||
|
onStart: () => void;
|
||||||
onFinish: (score: number, run: IcarusRunTelemetry) => void;
|
onFinish: (score: number, run: IcarusRunTelemetry) => void;
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
@@ -287,6 +289,11 @@ export function IcarusGame({
|
|||||||
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;
|
||||||
@@ -338,6 +345,7 @@ export function IcarusGame({
|
|||||||
actionCountRef.current = 1;
|
actionCountRef.current = 1;
|
||||||
statusRef.current = "flying";
|
statusRef.current = "flying";
|
||||||
setStatus("flying");
|
setStatus("flying");
|
||||||
|
onStartRef.current();
|
||||||
} else {
|
} else {
|
||||||
actionCountRef.current += 1;
|
actionCountRef.current += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -638,6 +638,15 @@ declare
|
|||||||
v_duration_ms numeric;
|
v_duration_ms numeric;
|
||||||
v_actions numeric;
|
v_actions numeric;
|
||||||
v_details jsonb;
|
v_details jsonb;
|
||||||
|
-- Ping régulier envoyé par le client PENDANT la partie (ping_game_run(),
|
||||||
|
-- section 18) : preuve horodatée par le SERVEUR, contrairement à
|
||||||
|
-- duration_ms/actions ci-dessus qui restent entièrement déclaratifs
|
||||||
|
-- (calculés et envoyés par le client en une seule fois à la fin, donc
|
||||||
|
-- falsifiables par un appel RPC direct sans avoir vraiment joué).
|
||||||
|
v_session_token uuid;
|
||||||
|
v_ping_count integer;
|
||||||
|
v_first_ping_at timestamptz;
|
||||||
|
v_last_ping_at timestamptz;
|
||||||
begin
|
begin
|
||||||
if auth.uid() is null then
|
if auth.uid() is null then
|
||||||
raise exception 'authentication required';
|
raise exception 'authentication required';
|
||||||
@@ -680,6 +689,17 @@ begin
|
|||||||
v_actions := (p_run ->> 'actions')::numeric;
|
v_actions := (p_run ->> 'actions')::numeric;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
|
-- Extraction protégée (begin/exception imbriqué) : une valeur
|
||||||
|
-- malformée ne doit jamais faire échouer toute la soumission, juste
|
||||||
|
-- être traitée comme une absence de token (donc 0 ping trouvé,
|
||||||
|
-- signalé plus bas comme les autres incohérences).
|
||||||
|
begin
|
||||||
|
v_session_token := nullif(p_run ->> 'session_token', '')::uuid;
|
||||||
|
exception
|
||||||
|
when invalid_text_representation then
|
||||||
|
v_session_token := null;
|
||||||
|
end;
|
||||||
|
|
||||||
v_details := jsonb_build_object(
|
v_details := jsonb_build_object(
|
||||||
'duration_ms', v_duration_ms,
|
'duration_ms', v_duration_ms,
|
||||||
'actions', v_actions,
|
'actions', v_actions,
|
||||||
@@ -747,6 +767,53 @@ begin
|
|||||||
v_details
|
v_details
|
||||||
);
|
);
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
|
-- En dessous de 15s, aucun ping n'est attendu (le premier n'arrive
|
||||||
|
-- qu'après un cycle d'intervalle côté client, ~5s) : pas de
|
||||||
|
-- signalement pour une partie courte, y compris un échec immédiat.
|
||||||
|
if v_duration_ms >= 15000 then
|
||||||
|
select count(*), min(created_at), max(created_at)
|
||||||
|
into v_ping_count, v_first_ping_at, v_last_ping_at
|
||||||
|
from public.game_run_pings
|
||||||
|
where user_id = auth.uid() and game = 'icarus' and session_token = v_session_token;
|
||||||
|
|
||||||
|
if v_ping_count = 0 then
|
||||||
|
insert into public.game_cheat_flags
|
||||||
|
(user_id, game, score, severity, trigger_code, reason, details)
|
||||||
|
values (
|
||||||
|
auth.uid(),
|
||||||
|
'icarus',
|
||||||
|
p_score,
|
||||||
|
'high',
|
||||||
|
'missing_pings',
|
||||||
|
'Aucun ping serveur reçu pendant une partie annoncée assez longue pour en attendre.',
|
||||||
|
v_details || jsonb_build_object('ping_count', v_ping_count)
|
||||||
|
);
|
||||||
|
elsif extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 < v_duration_ms * 0.4 then
|
||||||
|
insert into public.game_cheat_flags
|
||||||
|
(user_id, game, score, severity, trigger_code, reason, details)
|
||||||
|
values (
|
||||||
|
auth.uid(),
|
||||||
|
'icarus',
|
||||||
|
p_score,
|
||||||
|
'high',
|
||||||
|
'ping_span_mismatch',
|
||||||
|
'L''écart réel entre les pings reçus est bien plus court que la durée de partie annoncée.',
|
||||||
|
v_details || jsonb_build_object(
|
||||||
|
'ping_count', v_ping_count,
|
||||||
|
'ping_span_ms', extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Nettoyage : les pings de cette partie ont été consommés (signalés ou
|
||||||
|
-- non), inutile de les garder — évite aussi qu'un même session_token
|
||||||
|
-- réutilisé par erreur ne corrèle avec une partie précédente.
|
||||||
|
if v_session_token is not null then
|
||||||
|
delete from public.game_run_pings
|
||||||
|
where user_id = auth.uid() and session_token = v_session_token;
|
||||||
end if;
|
end if;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
@@ -1750,6 +1817,14 @@ declare
|
|||||||
v_citizen_count integer;
|
v_citizen_count integer;
|
||||||
v_max_tier integer;
|
v_max_tier integer;
|
||||||
v_details jsonb;
|
v_details jsonb;
|
||||||
|
-- Ping régulier envoyé par le client PENDANT la partie (ping_game_run(),
|
||||||
|
-- section 18) : preuve horodatée par le SERVEUR, contrairement à
|
||||||
|
-- duration_ms/actions/merges ci-dessus qui restent entièrement
|
||||||
|
-- déclaratifs — voir la même remarque sur submit_icarus_score.
|
||||||
|
v_session_token uuid;
|
||||||
|
v_ping_count integer;
|
||||||
|
v_first_ping_at timestamptz;
|
||||||
|
v_last_ping_at timestamptz;
|
||||||
begin
|
begin
|
||||||
if auth.uid() is null then
|
if auth.uid() is null then
|
||||||
raise exception 'authentication required';
|
raise exception 'authentication required';
|
||||||
@@ -1802,6 +1877,17 @@ begin
|
|||||||
v_merges := (p_run ->> 'merges')::numeric;
|
v_merges := (p_run ->> 'merges')::numeric;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
|
-- Extraction protégée (begin/exception imbriqué) : une valeur
|
||||||
|
-- malformée ne doit jamais faire échouer toute la soumission, juste
|
||||||
|
-- être traitée comme une absence de token (donc 0 ping trouvé,
|
||||||
|
-- signalé plus bas comme les autres incohérences).
|
||||||
|
begin
|
||||||
|
v_session_token := nullif(p_run ->> 'session_token', '')::uuid;
|
||||||
|
exception
|
||||||
|
when invalid_text_representation then
|
||||||
|
v_session_token := null;
|
||||||
|
end;
|
||||||
|
|
||||||
v_details := jsonb_build_object(
|
v_details := jsonb_build_object(
|
||||||
'duration_ms', v_duration_ms,
|
'duration_ms', v_duration_ms,
|
||||||
'actions', v_actions,
|
'actions', v_actions,
|
||||||
@@ -1888,6 +1974,53 @@ begin
|
|||||||
v_details
|
v_details
|
||||||
);
|
);
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
|
-- En dessous de 15s, aucun ping n'est attendu (le premier n'arrive
|
||||||
|
-- qu'après un cycle d'intervalle côté client, ~5s) : pas de
|
||||||
|
-- signalement pour une partie courte, y compris un échec immédiat.
|
||||||
|
if v_duration_ms >= 15000 then
|
||||||
|
select count(*), min(created_at), max(created_at)
|
||||||
|
into v_ping_count, v_first_ping_at, v_last_ping_at
|
||||||
|
from public.game_run_pings
|
||||||
|
where user_id = auth.uid() and game = 'melon' and session_token = v_session_token;
|
||||||
|
|
||||||
|
if v_ping_count = 0 then
|
||||||
|
insert into public.game_cheat_flags
|
||||||
|
(user_id, game, score, severity, trigger_code, reason, details)
|
||||||
|
values (
|
||||||
|
auth.uid(),
|
||||||
|
'melon',
|
||||||
|
p_score,
|
||||||
|
'high',
|
||||||
|
'missing_pings',
|
||||||
|
'Aucun ping serveur reçu pendant une partie annoncée assez longue pour en attendre.',
|
||||||
|
v_details || jsonb_build_object('ping_count', v_ping_count)
|
||||||
|
);
|
||||||
|
elsif extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 < v_duration_ms * 0.4 then
|
||||||
|
insert into public.game_cheat_flags
|
||||||
|
(user_id, game, score, severity, trigger_code, reason, details)
|
||||||
|
values (
|
||||||
|
auth.uid(),
|
||||||
|
'melon',
|
||||||
|
p_score,
|
||||||
|
'high',
|
||||||
|
'ping_span_mismatch',
|
||||||
|
'L''écart réel entre les pings reçus est bien plus court que la durée de partie annoncée.',
|
||||||
|
v_details || jsonb_build_object(
|
||||||
|
'ping_count', v_ping_count,
|
||||||
|
'ping_span_ms', extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Nettoyage : les pings de cette partie ont été consommés (signalés ou
|
||||||
|
-- non), inutile de les garder — évite aussi qu'un même session_token
|
||||||
|
-- réutilisé par erreur ne corrèle avec une partie précédente.
|
||||||
|
if v_session_token is not null then
|
||||||
|
delete from public.game_run_pings
|
||||||
|
where user_id = auth.uid() and session_token = v_session_token;
|
||||||
end if;
|
end if;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
@@ -2034,3 +2167,50 @@ create policy "game_cheat_flags readable by judges"
|
|||||||
on public.game_cheat_flags for select
|
on public.game_cheat_flags for select
|
||||||
to authenticated
|
to authenticated
|
||||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||||
|
|
||||||
|
-- 18. Anti-triche : ping régulier pendant la partie --------------------------
|
||||||
|
-- Complète le signalement (section 17) : duration_ms/actions/merges dans
|
||||||
|
-- p_run sont entièrement déclaratifs (calculés et envoyés par le client en
|
||||||
|
-- une seule fois à la fin), donc falsifiables par quelqu'un qui appelle
|
||||||
|
-- submit_icarus_score/submit_melon_score directement sans avoir vraiment
|
||||||
|
-- joué. Le client envoie un ping régulier PENDANT la partie (toutes les
|
||||||
|
-- ~5s tant qu'elle est en cours), horodaté ici par l'horloge du SERVEUR —
|
||||||
|
-- submit_icarus_score/submit_melon_score (section 11/16) corrèlent ensuite
|
||||||
|
-- le nombre de pings et l'écart réel entre le premier et le dernier avec
|
||||||
|
-- la durée annoncée, et signalent (jamais ne bloquent, même philosophie
|
||||||
|
-- que le reste de l'anti-triche) toute incohérence.
|
||||||
|
create table if not exists public.game_run_pings (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
user_id uuid not null references public.profiles (id) on delete cascade,
|
||||||
|
game text not null check (game in ('icarus', 'melon')),
|
||||||
|
session_token uuid not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.game_run_pings enable row level security;
|
||||||
|
-- Aucun grant à authenticated/anon (même principe que l'ancien icarus_runs,
|
||||||
|
-- section 11) : seules ping_game_run() (insert) et submit_icarus_score/
|
||||||
|
-- submit_melon_score (lecture + nettoyage), toutes SECURITY DEFINER,
|
||||||
|
-- contournent la RLS en tant que propriétaires des fonctions.
|
||||||
|
|
||||||
|
create or replace function public.ping_game_run(p_game text, p_session_token uuid)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if auth.uid() is null then
|
||||||
|
raise exception 'authentication required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_game not in ('icarus', 'melon') then
|
||||||
|
raise exception 'invalid game';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.game_run_pings (user_id, game, session_token)
|
||||||
|
values (auth.uid(), p_game, p_session_token);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.ping_game_run(text, uuid) to authenticated;
|
||||||
|
|||||||
Reference in New Issue
Block a user