40a10170bb
Build and deploy / deploy (push) Successful in 37s
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>
210 lines
8.6 KiB
TypeScript
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'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>
|
|
);
|
|
}
|