diff --git a/src/app/admin/admin-cheat-flags.tsx b/src/app/admin/admin-cheat-flags.tsx new file mode 100644 index 0000000..702f510 --- /dev/null +++ b/src/app/admin/admin-cheat-flags.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useMemo, useState } from "react"; + +type CheatFlag = { + id: number; + user_id: string; + pseudo: string; + game: "icarus" | "melon"; + score: number; + severity: "warning" | "high"; + trigger_code: string; + reason: string; + details: { + duration_ms?: number | null; + actions?: number | null; + merges?: number | null; + previous_best?: number | null; + } | null; + created_at: string; +}; + +const GAME_LABELS: Record = { + icarus: "Vol d'Icare", + melon: "Corne d'Abondance", +}; + +function formatDuration(durationMs: number | null | undefined): string | null { + if (durationMs == null || !Number.isFinite(durationMs)) return null; + if (durationMs < 1000) return `${Math.round(durationMs)} ms`; + const seconds = durationMs / 1000; + if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)} s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + return `${minutes} min ${remainingSeconds.toString().padStart(2, "0")} s`; +} + +function FlagRow({ flag }: { flag: CheatFlag }) { + const duration = formatDuration(flag.details?.duration_ms); + const happenedAt = new Intl.DateTimeFormat("fr-FR", { + timeZone: "Europe/Paris", + dateStyle: "short", + timeStyle: "medium", + }).format(new Date(flag.created_at)); + + return ( +
  • +
    +
    +
    + + {flag.severity === "high" ? "Fort" : "À vérifier"} + + {flag.pseudo} + · {GAME_LABELS[flag.game]} +
    +

    {flag.reason}

    +
    + +
    +

    {flag.score}

    +

    {happenedAt}

    +
    +
    + +
    + {duration && durée : {duration}} + {flag.details?.actions != null && actions : {flag.details.actions}} + {flag.details?.merges != null && fusions : {flag.details.merges}} + {flag.details?.previous_best != null && ancien record : {flag.details.previous_best}} + {flag.trigger_code} +
    +
  • + ); +} + +export function AdminCheatFlags({ flags }: { flags: CheatFlag[] }) { + const [game, setGame] = useState<"all" | CheatFlag["game"]>("all"); + + const filtered = useMemo( + () => (game === "all" ? flags : flags.filter((flag) => flag.game === game)), + [flags, game], + ); + const flaggedUsers = useMemo(() => new Set(flags.map((flag) => flag.user_id)).size, [flags]); + + return ( +
    +
    +
    +

    Détection de triche

    +

    + Les scores restent acceptés. Cette liste signale seulement les parties qui ont déclenché un contrôle + simple côté serveur. +

    +
    +

    + {flags.length} signalement{flags.length > 1 ? "s" : ""} · {flaggedUsers} joueur + {flaggedUsers > 1 ? "s" : ""} +

    +
    + +
    + {([ + ["all", "Tous"], + ["icarus", "Icare"], + ["melon", "Corne"], + ] as const).map(([value, label]) => ( + + ))} +
    + + {filtered.length === 0 ? ( +

    + Aucun signalement pour ce filtre. +

    + ) : ( + + )} +
    + ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b00c71e..e6cdaa7 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; import { jetbrainsMono } from "@/lib/fonts"; import { AdminMembersTable } from "./admin-members-table"; +import { AdminCheatFlags } from "./admin-cheat-flags"; export default async function AdminPage() { const supabase = await createClient(); @@ -25,7 +26,20 @@ export default async function AdminPage() { redirect("/leaderboard"); } - const { data: members } = await supabase.rpc("admin_list_members"); + const [{ data: members }, { data: cheatFlags }] = await Promise.all([ + supabase.rpc("admin_list_members"), + supabase + .from("game_cheat_flags") + .select("id, user_id, game, score, severity, trigger_code, reason, details, created_at") + .order("created_at", { ascending: false }) + .limit(200), + ]); + + const pseudoById = new Map((members ?? []).map((member) => [member.id, member.pseudo])); + const flagsWithPseudo = (cheatFlags ?? []).map((flag) => ({ + ...flag, + pseudo: pseudoById.get(flag.user_id) ?? "Un Citoyen", + })); return (
    @@ -36,6 +50,7 @@ export default async function AdminPage() {

    Administration des Citoyens

    + ); } diff --git a/src/app/corne/corne-view.tsx b/src/app/corne/corne-view.tsx index 3d17920..5948a57 100644 --- a/src/app/corne/corne-view.tsx +++ b/src/app/corne/corne-view.tsx @@ -6,7 +6,7 @@ import { Podium } from "@/components/podium"; import { IconClose, IconHorn } from "@/components/icons"; import { computeRanksBy } from "@/lib/ranking"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; -import { MelonGame } from "./melon-game"; +import { MelonGame, type MelonRunTelemetry } from "./melon-game"; import type { MelonScoreEntry, MemberRow } from "./page"; export function CorneView({ @@ -64,9 +64,9 @@ export function CorneView({ }; }, [refetch]); - async function handleFinish(score: number) { + async function handleFinish(score: number, run: MelonRunTelemetry) { const supabase = createClient(); - const { data, error } = await supabase.rpc("submit_melon_score", { p_score: score }); + const { data, error } = await supabase.rpc("submit_melon_score", { p_score: score, p_run: run }); if (!error && data) { setOwnBestScore(data.best_score); refetch(); diff --git a/src/app/corne/melon-game.tsx b/src/app/corne/melon-game.tsx index a915f1c..b7055c6 100644 --- a/src/app/corne/melon-game.tsx +++ b/src/app/corne/melon-game.tsx @@ -30,7 +30,20 @@ function randomTier(min: number, max: number): number { return min + Math.floor(Math.random() * (max - min + 1)); } -export function MelonGame({ citizens, onFinish }: { citizens: MemberRow[]; onFinish: (score: number) => void }) { +export type MelonRunTelemetry = { + v: 1; + duration_ms: number; + actions: number; + merges: number; +}; + +export function MelonGame({ + citizens, + onFinish, +}: { + citizens: MemberRow[]; + onFinish: (score: number, run: MelonRunTelemetry) => void; +}) { const containerRef = useRef(null); const previewRef = useRef(null); const guideLineRef = useRef(null); @@ -71,6 +84,9 @@ export function MelonGame({ citizens, onFinish }: { citizens: MemberRow[]; onFin const pendingMergesRef = useRef([]); const mergingBodyIdsRef = useRef(new Set()); const dangerSinceRef = useRef(null); + const runStartedAtRef = useRef(Date.now()); + const dropCountRef = useRef(0); + const mergeCountRef = useRef(0); const spawnPiece = useCallback( (tier: number, x: number, y: number): Piece => { @@ -128,6 +144,10 @@ export function MelonGame({ citizens, onFinish }: { citizens: MemberRow[]; onFin pendingMergesRef.current = []; mergingBodyIdsRef.current.clear(); dangerSinceRef.current = null; + runStartedAtRef.current = Date.now(); + dropCountRef.current = 0; + mergeCountRef.current = 0; + lastDropAtRef.current = -Infinity; scoreRef.current = 0; statusRef.current = "playing"; const firstNextTier = randomTier(DROP_MIN_TIER, dropMaxTier); @@ -160,6 +180,7 @@ export function MelonGame({ citizens, onFinish }: { citizens: MemberRow[]; onFin // incrémente le compteur d'id) : appelé une seule fois ici, jamais // depuis l'intérieur du updater de setPieces, qui doit rester pur. const dropped = spawnPiece(tier, x, radius + 6); + dropCountRef.current += 1; setPieces((current) => [...current, dropped]); // La file glisse d'un cran : ce qui était annoncé en second devient la @@ -281,6 +302,7 @@ export function MelonGame({ citizens, onFinish }: { citizens: MemberRow[]; onFin if (!bodyA || !bodyB) continue; Matter.Composite.remove(engine.world, [bodyA, bodyB]); + mergeCountRef.current += 1; bodiesRef.current.delete(merge.pieceIdA); bodiesRef.current.delete(merge.pieceIdB); nodeRefs.current.delete(merge.pieceIdA); @@ -338,7 +360,12 @@ export function MelonGame({ citizens, onFinish }: { citizens: MemberRow[]; onFin } else if (now - dangerSinceRef.current >= GAME_OVER_GRACE_MS) { statusRef.current = "dead"; setStatus("dead"); - onFinishRef.current(scoreRef.current); + onFinishRef.current(scoreRef.current, { + v: 1, + duration_ms: Math.max(0, Date.now() - runStartedAtRef.current), + actions: dropCountRef.current, + merges: mergeCountRef.current, + }); } } else { dangerSinceRef.current = null; diff --git a/src/app/icare/icare-view.tsx b/src/app/icare/icare-view.tsx index 3ff80e0..a26de2f 100644 --- a/src/app/icare/icare-view.tsx +++ b/src/app/icare/icare-view.tsx @@ -1,12 +1,12 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Avatar } from "@/components/avatar"; import { Podium } from "@/components/podium"; import { IconClose, IconTrophy } from "@/components/icons"; import { computeRanksBy } from "@/lib/ranking"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; -import { IcarusGame } from "./icarus-game"; +import { IcarusGame, type IcarusRunTelemetry } from "./icarus-game"; import type { IcarusScoreEntry } from "./page"; export function IcareView({ @@ -21,13 +21,6 @@ export function IcareView({ const [leaderboard, setLeaderboard] = useState(initialLeaderboard); const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore); 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.resolve(null)); - const refetch = useCallback(async () => { const supabase = createClient(); const { data: scores } = await supabase @@ -73,26 +66,9 @@ export function IcareView({ }; }, [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() { + async function handleFinish(score: number, run: IcarusRunTelemetry) { 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) { - const supabase = createClient(); - const runId = await runIdPromiseRef.current; - const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score, p_run_id: runId }); + const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score, p_run: run }); if (!error && data) { setOwnBestScore(data.best_score); refetch(); @@ -121,7 +97,7 @@ export function IcareView({ - + {showRanking && (
    diff --git a/src/app/icare/icarus-game.tsx b/src/app/icare/icarus-game.tsx index e510ca3..f388e43 100644 --- a/src/app/icare/icarus-game.tsx +++ b/src/app/icare/icarus-game.tsx @@ -271,23 +271,22 @@ function drawSpeedLines(ctx: CanvasRenderingContext2D, centerX: number, centerY: ctx.restore(); } +export type IcarusRunTelemetry = { + v: 1; + duration_ms: number; + actions: number; +}; + export function IcarusGame({ - onStart, onFinish, }: { - onStart: () => void; - onFinish: (score: number) => void; + onFinish: (score: number, run: IcarusRunTelemetry) => void; }) { const canvasRef = useRef(null); const containerRef = useRef(null); const [status, setStatus] = useState("idle"); const [score, setScore] = useState(0); - const onStartRef = useRef(onStart); - useEffect(() => { - onStartRef.current = onStart; - }, [onStart]); - const onFinishRef = useRef(onFinish); useEffect(() => { onFinishRef.current = onFinish; @@ -306,6 +305,8 @@ export function IcarusGame({ const boostDashAtRef = useRef(null); const shatterEffectsRef = useRef([]); const nextEntityIdRef = useRef(0); + const runStartedAtRef = useRef(null); + const actionCountRef = useRef(0); const resetGame = useCallback(() => { icarusRef.current = createInitialIcarus(); @@ -320,19 +321,28 @@ export function IcarusGame({ boostColumnsRemainingRef.current = 0; boostDashAtRef.current = null; shatterEffectsRef.current = []; + runStartedAtRef.current = null; + actionCountRef.current = 0; setStatus("idle"); setScore(0); }, []); const requestFlap = useCallback(() => { - flapRequestedRef.current = true; + if (statusRef.current === "dead") { + resetGame(); + return; + } + if (statusRef.current === "idle") { + runStartedAtRef.current = Date.now(); + actionCountRef.current = 1; statusRef.current = "flying"; setStatus("flying"); - onStartRef.current(); - } else if (statusRef.current === "dead") { - resetGame(); + } else { + actionCountRef.current += 1; } + + flapRequestedRef.current = true; }, [resetGame]); // Sur ordinateur (pas de tap tactile) : la barre d'espace fait la même @@ -383,6 +393,17 @@ export function IcarusGame({ let lastFrame: number | null = null; let accumulator = 0; + function finishRun() { + statusRef.current = "dead"; + setStatus("dead"); + const startedAt = runStartedAtRef.current; + onFinishRef.current(scoreRef.current, { + v: 1, + duration_ms: startedAt === null ? 0 : Math.max(0, Date.now() - startedAt), + actions: actionCountRef.current, + }); + } + function frame(now: number) { if (lastFrame === null) lastFrame = now; const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS); @@ -505,17 +526,13 @@ export function IcarusGame({ columnsRef.current = columnsRef.current.filter((c) => !broken.includes(c)); setScore(scoreRef.current); } else { - statusRef.current = "dead"; - setStatus("dead"); - onFinishRef.current(scoreRef.current); + finishRun(); break; } } if (boostColumnsRemainingRef.current === 0 && hasHitGround(icarusRef.current)) { - statusRef.current = "dead"; - setStatus("dead"); - onFinishRef.current(scoreRef.current); + finishRun(); break; }