Add discreet anticheat mechanism for the games
Build and deploy / deploy (push) Failing after 32s

This commit is contained in:
2026-08-24 12:07:10 +02:00
parent 35704e50fc
commit b7f3f8b0fa
6 changed files with 230 additions and 53 deletions
+142
View File
@@ -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<CheatFlag["game"], string> = {
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 (
<li className="marble-surface rounded-lg border border-gold/25 px-4 py-3 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="flex flex-wrap items-center gap-2">
<span
className={
flag.severity === "high"
? "rounded-full border border-oxblood/40 bg-oxblood/10 px-2 py-0.5 text-[0.65rem] font-semibold tracking-wide text-oxblood uppercase"
: "rounded-full border border-gold/40 bg-gold/10 px-2 py-0.5 text-[0.65rem] font-semibold tracking-wide text-text-marble uppercase"
}
>
{flag.severity === "high" ? "Fort" : "À vérifier"}
</span>
<span className="font-heading text-sm text-text-marble">{flag.pseudo}</span>
<span className="text-xs text-text-mut">· {GAME_LABELS[flag.game]}</span>
</div>
<p className="mt-1 text-sm text-text-marble">{flag.reason}</p>
</div>
<div className="text-right">
<p className="font-heading text-lg text-sea">{flag.score}</p>
<p className="text-[0.7rem] text-text-mut">{happenedAt}</p>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-text-mut">
{duration && <span>durée : {duration}</span>}
{flag.details?.actions != null && <span>actions : {flag.details.actions}</span>}
{flag.details?.merges != null && <span>fusions : {flag.details.merges}</span>}
{flag.details?.previous_best != null && <span>ancien record : {flag.details.previous_best}</span>}
<span className="font-mono opacity-60">{flag.trigger_code}</span>
</div>
</li>
);
}
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 (
<section className="mt-10 border-t border-gold/20 pt-8">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="font-heading text-lg tracking-wide text-gold-bright uppercase">Détection de triche</h2>
<p className="mt-1 max-w-xl text-sm text-marble/60">
Les scores restent acceptés. Cette liste signale seulement les parties qui ont déclenché un contrôle
simple côté serveur.
</p>
</div>
<p className="text-xs text-marble/60">
{flags.length} signalement{flags.length > 1 ? "s" : ""} · {flaggedUsers} joueur
{flaggedUsers > 1 ? "s" : ""}
</p>
</div>
<div className="mb-4 flex flex-wrap gap-2">
{([
["all", "Tous"],
["icarus", "Icare"],
["melon", "Corne"],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setGame(value)}
className={`rounded-md border px-3 py-1.5 text-xs font-medium transition ${
game === value
? "border-gold/60 bg-gold/15 text-gold-bright"
: "border-gold/25 text-marble/70 hover:bg-gold/10"
}`}
>
{label}
</button>
))}
</div>
{filtered.length === 0 ? (
<p className="rounded-lg border border-gold/20 px-4 py-5 text-center text-sm text-marble/60">
Aucun signalement pour ce filtre.
</p>
) : (
<ul className="flex flex-col gap-2">
{filtered.map((flag) => (
<FlagRow key={flag.id} flag={flag} />
))}
</ul>
)}
</section>
);
}
+16 -1
View File
@@ -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 (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
@@ -36,6 +50,7 @@ export default async function AdminPage() {
<p className="font-serif text-sm text-marble/60 italic">Administration des Citoyens</p>
</div>
<AdminMembersTable members={members ?? []} currentUserId={user.id} />
<AdminCheatFlags flags={flagsWithPseudo} />
</div>
);
}
+3 -3
View File
@@ -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();
+29 -2
View File
@@ -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<HTMLDivElement | null>(null);
const previewRef = useRef<HTMLDivElement | null>(null);
const guideLineRef = useRef<HTMLDivElement | null>(null);
@@ -71,6 +84,9 @@ export function MelonGame({ citizens, onFinish }: { citizens: MemberRow[]; onFin
const pendingMergesRef = useRef<PendingMerge[]>([]);
const mergingBodyIdsRef = useRef(new Set<number>());
const dangerSinceRef = useRef<number | null>(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;
+5 -29
View File
@@ -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<string | null>>(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({
</button>
</div>
<IcarusGame onStart={handleStart} onFinish={handleFinish} />
<IcarusGame onFinish={handleFinish} />
{showRanking && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm">
+35 -18
View File
@@ -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<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
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;
@@ -306,6 +305,8 @@ export function IcarusGame({
const boostDashAtRef = useRef<number | null>(null);
const shatterEffectsRef = useRef<ShatterEffect[]>([]);
const nextEntityIdRef = useRef(0);
const runStartedAtRef = useRef<number | null>(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;
}