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:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { Podium } from "@/components/podium";
|
||||
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 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({
|
||||
citizens,
|
||||
initialLeaderboard,
|
||||
@@ -23,6 +27,19 @@ export function CorneView({
|
||||
const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
|
||||
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
|
||||
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 supabase = createClient();
|
||||
@@ -64,9 +81,28 @@ export function CorneView({
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
async function handleFinish(score: number, run: MelonRunTelemetry) {
|
||||
function handleStart() {
|
||||
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) {
|
||||
setOwnBestScore(data.best_score);
|
||||
refetch();
|
||||
@@ -100,7 +136,7 @@ export function CorneView({
|
||||
Classement
|
||||
</button>
|
||||
|
||||
<MelonGame citizens={citizens} onFinish={handleFinish} />
|
||||
<MelonGame citizens={citizens} onStart={handleStart} onFinish={handleFinish} />
|
||||
|
||||
{ownBestScore !== null && (
|
||||
<p className="text-center font-heading text-sm tracking-wide text-marble">
|
||||
|
||||
@@ -39,9 +39,11 @@ export type MelonRunTelemetry = {
|
||||
|
||||
export function MelonGame({
|
||||
citizens,
|
||||
onStart,
|
||||
onFinish,
|
||||
}: {
|
||||
citizens: MemberRow[];
|
||||
onStart: () => void;
|
||||
onFinish: (score: number, run: MelonRunTelemetry) => void;
|
||||
}) {
|
||||
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.
|
||||
const [secondTier, setSecondTier] = useState(() => randomTier(DROP_MIN_TIER, dropMaxTier));
|
||||
|
||||
const onStartRef = useRef(onStart);
|
||||
useEffect(() => {
|
||||
onStartRef.current = onStart;
|
||||
}, [onStart]);
|
||||
|
||||
const onFinishRef = useRef(onFinish);
|
||||
useEffect(() => {
|
||||
onFinishRef.current = onFinish;
|
||||
@@ -91,6 +98,7 @@ export function MelonGame({
|
||||
const runStartedAtRef = useRef(0);
|
||||
useEffect(() => {
|
||||
runStartedAtRef.current = Date.now();
|
||||
onStartRef.current();
|
||||
}, []);
|
||||
const dropCountRef = useRef(0);
|
||||
const mergeCountRef = useRef(0);
|
||||
@@ -152,6 +160,7 @@ export function MelonGame({
|
||||
mergingBodyIdsRef.current.clear();
|
||||
dangerSinceRef.current = null;
|
||||
runStartedAtRef.current = Date.now();
|
||||
onStartRef.current();
|
||||
dropCountRef.current = 0;
|
||||
mergeCountRef.current = 0;
|
||||
lastDropAtRef.current = -Infinity;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { Podium } from "@/components/podium";
|
||||
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 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({
|
||||
initialLeaderboard,
|
||||
initialOwnBestScore,
|
||||
@@ -21,6 +30,20 @@ export function IcareView({
|
||||
const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
|
||||
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
|
||||
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 supabase = createClient();
|
||||
const { data: scores } = await supabase
|
||||
@@ -66,9 +89,28 @@ export function IcareView({
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
async function handleFinish(score: number, run: IcarusRunTelemetry) {
|
||||
function handleStart() {
|
||||
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) {
|
||||
setOwnBestScore(data.best_score);
|
||||
refetch();
|
||||
@@ -97,7 +139,7 @@ export function IcareView({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<IcarusGame onFinish={handleFinish} />
|
||||
<IcarusGame onStart={handleStart} onFinish={handleFinish} />
|
||||
|
||||
{showRanking && (
|
||||
<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({
|
||||
onStart,
|
||||
onFinish,
|
||||
}: {
|
||||
onStart: () => void;
|
||||
onFinish: (score: number, run: IcarusRunTelemetry) => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -287,6 +289,11 @@ export function IcarusGame({
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [score, setScore] = useState(0);
|
||||
|
||||
const onStartRef = useRef(onStart);
|
||||
useEffect(() => {
|
||||
onStartRef.current = onStart;
|
||||
}, [onStart]);
|
||||
|
||||
const onFinishRef = useRef(onFinish);
|
||||
useEffect(() => {
|
||||
onFinishRef.current = onFinish;
|
||||
@@ -338,6 +345,7 @@ export function IcarusGame({
|
||||
actionCountRef.current = 1;
|
||||
statusRef.current = "flying";
|
||||
setStatus("flying");
|
||||
onStartRef.current();
|
||||
} else {
|
||||
actionCountRef.current += 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user