This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user