Files
tribunal-app/src/app/icare/icare-view.tsx
T
alexandre b7f3f8b0fa
Build and deploy / deploy (push) Failing after 32s
Add discreet anticheat mechanism for the games
2026-08-24 12:07:10 +02:00

164 lines
6.6 KiB
TypeScript

"use client";
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, type IcarusRunTelemetry } from "./icarus-game";
import type { IcarusScoreEntry } from "./page";
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 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]);
async function handleFinish(score: number, run: IcarusRunTelemetry) {
const supabase = createClient();
const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score, p_run: run });
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]);
const podiumProfiles = ranked.filter((p) => p.rank <= 3);
const rest = ranked.filter((p) => p.rank > 3);
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 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>
);
}