"use client"; import { useCallback, useEffect, useMemo, useRef, 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, 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, isFrozen, }: { initialLeaderboard: IcarusScoreEntry[]; initialOwnBestScore: number | null; isFrozen: boolean; }) { const [leaderboard, setLeaderboard] = useState(initialLeaderboard); const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore); const [showRanking, setShowRanking] = useState(false); const sessionTokenRef = useRef(null); const pingIntervalRef = useRef | 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 .from("icarus_scores") .select("user_id, best_score") .order("best_score", { ascending: false }); const userIds = (scores ?? []).map((row) => row.user_id); const { data: profiles } = userIds.length > 0 ? await supabase.from("profiles").select("id, pseudo, avatar_url").in("id", userIds) : { data: [] as { id: string; pseudo: string; avatar_url: string | null }[] }; const profileById = new Map((profiles ?? []).map((profile) => [profile.id, profile])); setLeaderboard( (scores ?? []).map((row) => ({ id: row.user_id, pseudo: profileById.get(row.user_id)?.pseudo ?? "Un Citoyen", avatar_url: profileById.get(row.user_id)?.avatar_url ?? null, best_score: row.best_score, })), ); }, []); useEffect(() => { const supabase = createClient(); let channel: ReturnType | null = null; let cancelled = false; waitForRealtimeAuth(supabase).then(() => { if (cancelled) return; channel = supabase .channel("icarus-scores") .on("postgres_changes", { event: "*", schema: "public", table: "icarus_scores" }, () => { refetch(); }) .subscribe(); }); return () => { cancelled = true; if (channel) supabase.removeChannel(channel); }; }, [refetch]); function handleStart() { const supabase = createClient(); 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(); } } // Le classement vit dans une modale, complètement hors du flux de mise en // page du jeu : sans ça, une mise à jour Realtime pendant une partie (le // score d'un autre joueur qui change) redimensionnait le canvas et cassait // la partie en cours. const ranked = useMemo(() => computeRanksBy(leaderboard, (a, b) => b.best_score - a.best_score), [leaderboard]); // Tant que personne n'a marqué, tout le monde est à égalité à 0 et // partagerait le rang 1 — pas de podium tant qu'il n'y a rien à // départager, sinon tout le groupe se retrouverait sur la 1ère marche. const hasScores = (ranked[0]?.best_score ?? 0) > 0; const podiumProfiles = hasScores ? ranked.filter((p) => p.rank <= 3) : []; const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked; return (

Le Vol d'Icare

{showRanking && (

Classement

{isFrozen && (

Les scores sont figés depuis le début du Tribunal.

)} {podiumProfiles.length > 0 && ( member.best_score} /> )} {rest.length > 0 && (
    {rest.map((entry) => (
  1. {entry.rank} {entry.pseudo} {entry.best_score}
  2. ))}
)} {ownBestScore !== null && (

Ton record : {ownBestScore}

)}

{isFrozen ? "Les gloires du podium ont été attribuées automatiquement au début du Tribunal." : "Au début du Tribunal, les scores seront figés et le podium recevra des gloires automatiquement — aucun Archonte n'a besoin d'intervenir."}

)}
); }