Files
tribunal-app/src/app/icare/icare-view.tsx
T
Valentin ROBIN 40a10170bb
Build and deploy / deploy (push) Successful in 37s
N'affiche plus de podium quand tout le monde est encore à 0
Le classement "compétition" fait partager le rang 1 à tous les ex-aequo — sans score, tout le groupe se retrouvait sur la 1ère marche du podium. Le podium ne s'affiche plus tant que le meneur n'a pas dépassé 0, sur /leaderboard, /icare et /corne.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:05:09 +02:00

210 lines
8.6 KiB
TypeScript

"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<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
.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<typeof supabase.channel> | 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 (
<div className="relative flex flex-1 flex-col bg-ink">
<div className="flex items-center justify-between px-4 py-2">
<h1 className="font-heading text-sm tracking-[0.15em] text-gold-bright uppercase">Le Vol d&apos;Icare</h1>
<button
type="button"
onClick={() => setShowRanking(true)}
className="flex items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-marble/80 uppercase tracking-wide transition hover:bg-gold/10"
>
<IconTrophy className="h-4 w-4" />
Classement
</button>
</div>
<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">
<div className="marble-surface flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-gold/40 shadow-xl">
<div className="flex items-center justify-between border-b border-gold/20 px-4 py-3">
<h2 className="font-heading text-sm tracking-[0.1em] text-text-marble uppercase">Classement</h2>
<button
type="button"
onClick={() => setShowRanking(false)}
className="rounded-md p-1 text-text-mut transition hover:bg-gold/10 hover:text-text-marble"
aria-label="Fermer"
>
<IconClose className="h-5 w-5" />
</button>
</div>
<div className="overflow-y-auto px-4 py-4">
{isFrozen && (
<p className="mb-4 rounded-lg border border-gold/40 px-4 py-2 text-center text-sm text-text-marble">
Les scores sont figés depuis le début du Tribunal.
</p>
)}
{podiumProfiles.length > 0 && (
<Podium profiles={podiumProfiles} renderValue={(member) => member.best_score} />
)}
{rest.length > 0 && (
<ol className="flex flex-col gap-1.5">
{rest.map((entry) => (
<li
key={entry.id}
className="flex items-center gap-3 rounded-lg border border-gold/25 px-3 py-2"
>
<span className="w-5 shrink-0 text-center font-heading text-sm text-text-mut">
{entry.rank}
</span>
<Avatar pseudo={entry.pseudo} avatarUrl={entry.avatar_url} size="xs" />
<span className="flex-1 truncate text-sm font-medium text-text-marble">{entry.pseudo}</span>
<span className="font-heading text-sm font-semibold text-sea">{entry.best_score}</span>
</li>
))}
</ol>
)}
{ownBestScore !== null && (
<p className="mt-4 text-center font-heading text-sm tracking-wide text-text-marble">
Ton record : <span className="text-sea">{ownBestScore}</span>
</p>
)}
<p className="mt-4 text-center text-xs text-text-mut">
{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."}
</p>
</div>
</div>
</div>
)}
</div>
);
}