diff --git a/CLAUDE.md b/CLAUDE.md index 4a60a14..304b783 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,9 @@ V1 à V6 sont livrées : - **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client. - **V4** : direction artistique Grèce antique (marbre/or/mer de nuit, polices Cinzel/Cormorant Garamond/Manrope, vocabulaire Archontes/Citoyens/Agora/Crieur), header global avec chip utilisateur. - **V5** : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page **La Roulette** (tirage au sort animé), page **Le Calendrier des Dieux** (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (`PointsConfirmControls`) entre podium et classement. -- **V6** : premier mini-jeu compétitif, **La Course du Char** (`/course`) — piste 2D générée aléatoirement chaque jour (identique pour tout le monde, calculée côté client à partir de la date), joystick tactile virtuel, murs/obstacles qui ralentissent temporairement, fantômes du top 3 du jour, seul mécanisme de points de tout le projet qui s'attribue **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte). +- **V6** : premier mini-jeu compétitif, **Le Vol d'Icare** (`/icare`) — Flappy Bird grec (Icare vole entre des colonnes de temple), record personnel all-time (pas de piste par jour), touche l'écran pour battre des ailes, échec net au premier contact (score remis à zéro, comme l'original). Les scores sont figés dès que la date du Tribunal est atteinte, puis les gloires du top 3 sont attribuées **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte) — seul mécanisme de points de tout le projet qui ne passe pas par une décision de juge. -Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le reste du jeu du Tribunal (ostracisme, timer du Gardien — la Course du Char est livrée en V6), mode grand écran. +Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le reste du jeu du Tribunal (ostracisme, timer du Gardien — Le Vol d'Icare est livré en V6), mode grand écran. --- @@ -57,11 +57,11 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo - `settings` : ligne unique (`id boolean primary key default true`, contrainte `check (id)`) — `tribunal_date`. - RLS : `SELECT` ouvert à tout authentifié ; `INSERT`/`UPDATE`/`DELETE` réservés au rôle `judge`, vérifié par policy (`exists (select 1 from profiles where id = auth.uid() and role = 'judge')`) — pas de trigger `BEFORE UPDATE` ici car il n'y a pas de colonne à protéger *dans une ligne par ailleurs modifiable par tous* (contrairement à `profiles`) : toute la table est verrouillée en écriture aux juges. -### `public.chariot_runs` / `public.chariot_race_closes` (La Course du Char) +### `public.icarus_scores` (Le Vol d'Icare) -- `chariot_runs` : `user_id, race_date, best_time_ms, ghost_path (jsonb), updated_at` — clé primaire `(user_id, race_date)`, une seule ligne par joueur et par jour (le meilleur temps uniquement, pas l'historique des tentatives). Verrouillée comme `points_log` : lecture ouverte aux authentifiés, écriture uniquement via la RPC `submit_chariot_run`. -- `chariot_race_closes` : table interne (aucune policy) qui marque les jours déjà clôturés, pour que la tâche planifiée reste idempotente. -- Pas de table `tracks` : la piste du jour est une fonction pure côté client (`src/lib/chariot/track.ts`, `generateTrack(dateKey)`), graine = date du jour Europe/Paris — jamais stockée côté serveur. +- `user_id, best_score, updated_at` — clé primaire `user_id` : un seul record personnel all-time par joueur (pas de notion de jour). Verrouillée comme `points_log` : lecture ouverte aux authentifiés, écriture uniquement via la RPC `submit_icarus_score`. +- `settings.icarus_points_awarded` : marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char). +- Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale). ### Colonnes sensibles : RLS + trigger, jamais confiance au client @@ -78,8 +78,8 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè - `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`). - `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client. - `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`. -- `submit_chariot_run(p_time_ms, p_ghost_path)` — authenticated, calcule `race_date` côté serveur (jamais fourni par le client), borne le temps (anti-triche minimal), n'écrase le meilleur temps du jour que s'il est strictement battu. -- `close_daily_chariot_race()` — **pas de grant à authenticated**, appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : classe la journée précédente, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur). +- `submit_icarus_score(p_score)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu. +- `award_icarus_points_if_due()` — **pas de grant à authenticated**, appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`). --- @@ -98,13 +98,13 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè | `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email | | `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges | | `/roulette` | « La Roulette » — tirage au sort animé parmi les membres (ou Citoyens uniquement) | -| `/course` | « La Course du Char » — mini-jeu de course 2D (piste quotidienne, joystick tactile, fantômes du top 3), points attribués automatiquement | +| `/icare` | « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement | | `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges | | `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous | | `/profile` | Pseudo (verrouillable), photo, déconnexion | | `/admin` | « Le Conseil des Archontes » — juges uniquement, **vérifié côté serveur** (Server Component) — gestion des membres, reset du repère de classement | -`/leaderboard`, `/profile`, `/admin`, `/journal`, `/roulette`, `/calendrier`, `/course` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front). +`/leaderboard`, `/profile`, `/admin`, `/journal`, `/roulette`, `/calendrier`, `/icare` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front). Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. **Aucun lien de navigation dans le header lui-même** — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée. @@ -153,6 +153,6 @@ Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code ## 8. Roadmap (pas encore codé) - **Éditions** : table `editions` (année, mood board) ; les points deviendront rattachables à une édition donnée. Le thème visuel (Grèce antique) reste fixe, ce n'est pas un système de re-thématisation par édition. -- **Le jeu du Tribunal (suite)** : batailles de cul sec, ostracisme, timer du Gardien. (La Roulette est livrée en V5, la Course du Char en V6.) +- **Le jeu du Tribunal (suite)** : batailles de cul sec, ostracisme, timer du Gardien. (La Roulette est livrée en V5, Le Vol d'Icare en V6.) - **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur. - **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README). diff --git a/src/app/course/chariot-game.tsx b/src/app/course/chariot-game.tsx deleted file mode 100644 index 65b9771..0000000 --- a/src/app/course/chariot-game.tsx +++ /dev/null @@ -1,323 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { FIXED_DT_MS, JOYSTICK_MAX_RADIUS, MAX_FRAME_DT_MS } from "@/lib/chariot/constants"; -import { formatRaceTime } from "@/lib/chariot/format"; -import { finishGhost, recordGhostSample, sampleGhostAt } from "@/lib/chariot/ghost"; -import { createInitialState, crossedFinish, step } from "@/lib/chariot/physics"; -import type { ChariotState, GhostSample, Track } from "@/lib/chariot/types"; - -type Status = "idle" | "racing" | "finished"; - -const GHOST_COLORS = ["#E7C560", "#C7CDD6", "#B08D57"]; - -function drawChariot( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - heading: number, - color: string, - alpha: number, -) { - ctx.save(); - ctx.globalAlpha = alpha; - ctx.translate(x, y); - ctx.rotate(heading); - ctx.fillStyle = color; - ctx.beginPath(); - ctx.moveTo(15, 0); - ctx.lineTo(-9, 8); - ctx.lineTo(-9, -8); - ctx.closePath(); - ctx.fill(); - ctx.restore(); -} - -export function ChariotGame({ - track, - ghosts, - onFinish, -}: { - track: Track; - ghosts: GhostSample[][]; - onFinish: (timeMs: number, ghostPath: GhostSample[]) => void; -}) { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const [status, setStatus] = useState("idle"); - const [finishedMs, setFinishedMs] = useState(null); - - const ghostsRef = useRef(ghosts); - const onFinishRef = useRef(onFinish); - useEffect(() => { - ghostsRef.current = ghosts; - }, [ghosts]); - useEffect(() => { - onFinishRef.current = onFinish; - }, [onFinish]); - - // État de jeu tenu en ref : évite un re-render React à 60 fps. - const chariotRef = useRef(createInitialState(track)); - const inputRef = useRef({ x: 0, y: 0 }); - const pointerOriginRef = useRef<{ x: number; y: number; id: number } | null>(null); - const statusRef = useRef("idle"); - const startedAtRef = useRef(null); - const ghostPathRef = useRef([]); - const scaleRef = useRef(1); - - function resetRace() { - chariotRef.current = createInitialState(track); - statusRef.current = "idle"; - startedAtRef.current = null; - ghostPathRef.current = []; - setStatus("idle"); - setFinishedMs(null); - } - - useEffect(() => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - function resize() { - const dpr = window.devicePixelRatio || 1; - const cssWidth = container!.clientWidth; - const cssHeight = cssWidth * (track.world.height / track.world.width); - canvas!.width = cssWidth * dpr; - canvas!.height = cssHeight * dpr; - canvas!.style.width = `${cssWidth}px`; - canvas!.style.height = `${cssHeight}px`; - scaleRef.current = cssWidth / track.world.width; - } - - resize(); - const observer = new ResizeObserver(resize); - observer.observe(container); - - function handlePointerDown(event: PointerEvent) { - canvas!.setPointerCapture(event.pointerId); - const rect = canvas!.getBoundingClientRect(); - pointerOriginRef.current = { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - id: event.pointerId, - }; - if (statusRef.current === "idle") { - statusRef.current = "racing"; - setStatus("racing"); - startedAtRef.current = performance.now(); - } - } - - function handlePointerMove(event: PointerEvent) { - const origin = pointerOriginRef.current; - if (!origin || origin.id !== event.pointerId) return; - const rect = canvas!.getBoundingClientRect(); - const dx = event.clientX - rect.left - origin.x; - const dy = event.clientY - rect.top - origin.y; - const dist = Math.hypot(dx, dy); - const clamped = Math.min(1, dist / JOYSTICK_MAX_RADIUS); - inputRef.current = dist > 0.001 ? { x: (dx / dist) * clamped, y: (dy / dist) * clamped } : { x: 0, y: 0 }; - } - - function handlePointerUp(event: PointerEvent) { - if (pointerOriginRef.current?.id === event.pointerId) { - pointerOriginRef.current = null; - inputRef.current = { x: 0, y: 0 }; - } - } - - canvas.addEventListener("pointerdown", handlePointerDown); - canvas.addEventListener("pointermove", handlePointerMove); - canvas.addEventListener("pointerup", handlePointerUp); - canvas.addEventListener("pointercancel", handlePointerUp); - - let rafId: number; - let lastFrame: number | null = null; - let accumulator = 0; - - function frame(now: number) { - if (lastFrame === null) lastFrame = now; - const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS); - lastFrame = now; - - if (statusRef.current === "racing" && startedAtRef.current !== null) { - accumulator += frameDt; - const elapsedMs = now - startedAtRef.current; - - while (accumulator >= FIXED_DT_MS) { - const prevPosition = { ...chariotRef.current.position }; - chariotRef.current = step(chariotRef.current, inputRef.current, track, FIXED_DT_MS, elapsedMs); - accumulator -= FIXED_DT_MS; - - if (crossedFinish(prevPosition, chariotRef.current.position, track)) { - statusRef.current = "finished"; - finishGhost( - ghostPathRef.current, - elapsedMs, - chariotRef.current.position.x, - chariotRef.current.position.y, - chariotRef.current.heading, - ); - setStatus("finished"); - setFinishedMs(elapsedMs); - onFinishRef.current(elapsedMs, ghostPathRef.current); - break; - } - } - - if (statusRef.current === "racing") { - recordGhostSample( - ghostPathRef.current, - elapsedMs, - chariotRef.current.position.x, - chariotRef.current.position.y, - chariotRef.current.heading, - ); - } - } - - render(ctx!, canvas!, track, chariotRef.current, ghostsRef.current, startedAtRef.current, statusRef.current, pointerOriginRef.current, inputRef.current); - rafId = requestAnimationFrame(frame); - } - - rafId = requestAnimationFrame(frame); - - return () => { - observer.disconnect(); - canvas.removeEventListener("pointerdown", handlePointerDown); - canvas.removeEventListener("pointermove", handlePointerMove); - canvas.removeEventListener("pointerup", handlePointerUp); - canvas.removeEventListener("pointercancel", handlePointerUp); - cancelAnimationFrame(rafId); - }; - }, [track]); - - return ( -
-
- -
- - {status === "idle" && ( -

- Pose le doigt sur la piste et glisse pour diriger le char. -

- )} - - {status === "finished" && finishedMs !== null && ( -
-

{formatRaceTime(finishedMs)}

- -
- )} -
- ); -} - -function render( - ctx: CanvasRenderingContext2D, - canvas: HTMLCanvasElement, - track: Track, - chariot: ChariotState, - ghosts: GhostSample[][], - startedAt: number | null, - status: Status, - pointerOrigin: { x: number; y: number; id: number } | null, - input: { x: number; y: number }, -) { - const dpr = window.devicePixelRatio || 1; - const cssWidth = canvas.width / dpr; - const worldScale = cssWidth / track.world.width; - const elapsedMs = startedAt !== null ? performance.now() - startedAt : 0; - - ctx.save(); - ctx.scale(dpr, dpr); - - // -- Espace "monde" : piste, obstacles, fantômes, char. - ctx.save(); - ctx.scale(worldScale, worldScale); - ctx.clearRect(0, 0, track.world.width, track.world.height); - - ctx.fillStyle = "#0A1B33"; - ctx.fillRect(0, 0, track.world.width, track.world.height); - - ctx.lineWidth = track.halfWidth * 2; - ctx.strokeStyle = "#F4ECD8"; - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - ctx.beginPath(); - track.centerline.forEach((point, index) => { - if (index === 0) ctx.moveTo(point.x, point.y); - else ctx.lineTo(point.x, point.y); - }); - ctx.stroke(); - - ctx.strokeStyle = "#C9A227"; - ctx.lineWidth = 4; - ctx.beginPath(); - ctx.moveTo(track.finish.a.x, track.finish.a.y); - ctx.lineTo(track.finish.b.x, track.finish.b.y); - ctx.stroke(); - - ctx.fillStyle = "#A5342A"; - for (const obstacle of track.obstacles) { - ctx.beginPath(); - ctx.arc(obstacle.position.x, obstacle.position.y, obstacle.radius, 0, Math.PI * 2); - ctx.fill(); - } - - if (startedAt !== null) { - ghosts.forEach((path, index) => { - const sample = sampleGhostAt(path, elapsedMs); - if (!sample) return; - drawChariot(ctx, sample.x, sample.y, sample.a, GHOST_COLORS[index % GHOST_COLORS.length], 0.45); - }); - } - - drawChariot(ctx, chariot.position.x, chariot.position.y, chariot.heading, "#E7C560", 1); - ctx.restore(); - - // -- Espace "écran" (pixels CSS) : joystick tactile et chrono. - if (pointerOrigin) { - const nubX = pointerOrigin.x + input.x * JOYSTICK_MAX_RADIUS; - const nubY = pointerOrigin.y + input.y * JOYSTICK_MAX_RADIUS; - - ctx.save(); - ctx.globalAlpha = 0.35; - ctx.strokeStyle = "#E7C560"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(pointerOrigin.x, pointerOrigin.y, JOYSTICK_MAX_RADIUS, 0, Math.PI * 2); - ctx.stroke(); - - ctx.globalAlpha = 0.8; - ctx.fillStyle = "#E7C560"; - ctx.beginPath(); - ctx.arc(nubX, nubY, 16, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } - - if (status === "racing" && startedAt !== null) { - ctx.save(); - ctx.fillStyle = "#F4ECD8"; - ctx.font = "600 20px Cinzel, serif"; - ctx.textAlign = "center"; - ctx.fillText(formatRaceTime(elapsedMs), cssWidth / 2, 30); - ctx.restore(); - } - - ctx.restore(); -} diff --git a/src/app/course/course-view.tsx b/src/app/course/course-view.tsx deleted file mode 100644 index a3e6650..0000000 --- a/src/app/course/course-view.tsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Podium } from "@/components/podium"; -import { formatRaceTime } from "@/lib/chariot/format"; -import { generateTrack } from "@/lib/chariot/track"; -import type { GhostSample } from "@/lib/chariot/types"; -import { computeRanksBy } from "@/lib/ranking"; -import { createClient } from "@/lib/supabase/client"; -import { ChariotGame } from "./chariot-game"; -import type { ChariotLeaderboardEntry } from "./page"; - -export function CourseView({ - today, - initialTopRuns, - initialOwnBestMs, -}: { - today: string; - initialTopRuns: ChariotLeaderboardEntry[]; - initialOwnBestMs: number | null; -}) { - const [topRuns, setTopRuns] = useState(initialTopRuns); - const [ownBestMs, setOwnBestMs] = useState(initialOwnBestMs); - - const track = useMemo(() => generateTrack(today), [today]); - - const refetch = useCallback(async () => { - const supabase = createClient(); - const { data: runs } = await supabase - .from("chariot_runs") - .select("user_id, best_time_ms, ghost_path") - .eq("race_date", today) - .order("best_time_ms", { ascending: true }) - .limit(3); - - const userIds = (runs ?? []).map((run) => run.user_id); - const { data: runProfiles } = - 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((runProfiles ?? []).map((profile) => [profile.id, profile])); - - setTopRuns( - (runs ?? []).map((run) => ({ - id: run.user_id, - pseudo: profileById.get(run.user_id)?.pseudo ?? "Un Citoyen", - avatar_url: profileById.get(run.user_id)?.avatar_url ?? null, - best_time_ms: run.best_time_ms, - ghost_path: run.ghost_path, - })), - ); - }, [today]); - - // Comme Le Calendrier des Dieux : on ré-interroge tout à chaque changement - // plutôt que de fusionner le payload localement — le client ne voit que le - // top 3 du jour, pas tout le classement, donc une fusion partielle ne - // suffirait pas à recalculer correctement qui en fait partie. - useEffect(() => { - const supabase = createClient(); - const channel = supabase - .channel("chariot-runs") - .on("postgres_changes", { event: "*", schema: "public", table: "chariot_runs" }, () => { - refetch(); - }) - .subscribe(); - - return () => { - supabase.removeChannel(channel); - }; - }, [refetch]); - - async function handleFinish(timeMs: number, ghostPath: GhostSample[]) { - const supabase = createClient(); - const { data, error } = await supabase.rpc("submit_chariot_run", { - p_time_ms: Math.round(timeMs), - p_ghost_path: ghostPath, - }); - if (!error && data) { - setOwnBestMs(data.best_time_ms); - refetch(); - } - } - - const ranked = useMemo( - () => computeRanksBy(topRuns, (a, b) => a.best_time_ms - b.best_time_ms), - [topRuns], - ); - - const ghosts = useMemo( - () => topRuns.map((run) => (Array.isArray(run.ghost_path) ? (run.ghost_path as GhostSample[]) : [])), - [topRuns], - ); - - return ( -
- {ranked.length > 0 && ( - formatRaceTime(member.best_time_ms)} /> - )} - - {ownBestMs !== null && ( -

- Ton meilleur temps aujourd'hui :{" "} - {formatRaceTime(ownBestMs)} -

- )} - - {/* key=track.seed : force un remontage complet (état de jeu neuf) si la - piste change (nouveau jour), plutôt qu'un effet qui réinitialiserait - l'état depuis l'intérieur du composant. */} - - -

- Le podium du jour reçoit des gloires automatiquement à minuit — aucun Archonte n'a besoin - d'intervenir. -

-
- ); -} diff --git a/src/app/course/page.tsx b/src/app/course/page.tsx deleted file mode 100644 index 315089c..0000000 --- a/src/app/course/page.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; -import { CourseView } from "./course-view"; - -export type ChariotLeaderboardEntry = { - id: string; - pseudo: string; - avatar_url: string | null; - best_time_ms: number; - ghost_path: unknown; -}; - -export default async function CoursePage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - redirect("/login"); - } - - // Même formule que Le Calendrier des Dieux : la date du jour en - // Europe/Paris, seule source de vérité pour "quelle piste" et "quel jour". - const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }); - - const { data: runs } = await supabase - .from("chariot_runs") - .select("user_id, best_time_ms, ghost_path") - .eq("race_date", today) - .order("best_time_ms", { ascending: true }) - .limit(3); - - const userIds = (runs ?? []).map((run) => run.user_id); - const { data: runProfiles } = - 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((runProfiles ?? []).map((profile) => [profile.id, profile])); - - const topRuns: ChariotLeaderboardEntry[] = (runs ?? []).map((run) => ({ - id: run.user_id, - pseudo: profileById.get(run.user_id)?.pseudo ?? "Un Citoyen", - avatar_url: profileById.get(run.user_id)?.avatar_url ?? null, - best_time_ms: run.best_time_ms, - ghost_path: run.ghost_path, - })); - - const { data: ownRun } = await supabase - .from("chariot_runs") - .select("best_time_ms") - .eq("race_date", today) - .eq("user_id", user.id) - .maybeSingle(); - - return ( -
-
-

- La Course du Char -

-

- Une piste par jour, le meilleur temps l'emporte. -

-
- -
- ); -} diff --git a/src/app/icare/icare-view.tsx b/src/app/icare/icare-view.tsx new file mode 100644 index 0000000..3aca68e --- /dev/null +++ b/src/app/icare/icare-view.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Avatar } from "@/components/avatar"; +import { computeRanksBy } from "@/lib/ranking"; +import { createClient } from "@/lib/supabase/client"; +import { IcarusGame } 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 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(); + const channel = supabase + .channel("icarus-scores") + .on("postgres_changes", { event: "*", schema: "public", table: "icarus_scores" }, () => { + refetch(); + }) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [refetch]); + + async function handleFinish(score: number) { + const supabase = createClient(); + const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score }); + if (!error && data) { + setOwnBestScore(data.best_score); + refetch(); + } + } + + const ranked = useMemo(() => computeRanksBy(leaderboard, (a, b) => b.best_score - a.best_score), [leaderboard]); + + return ( +
+ {isFrozen && ( +

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

+ )} + + {ranked.length > 0 && ( +
    + {ranked.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."} +

+
+ ); +} diff --git a/src/app/icare/icarus-game.tsx b/src/app/icare/icarus-game.tsx new file mode 100644 index 0000000..4642600 --- /dev/null +++ b/src/app/icare/icarus-game.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + COLUMN_GAP, + COLUMN_GAP_MARGIN, + COLUMN_SPACING, + COLUMN_WIDTH, + FIXED_DT_MS, + FLAP_IMPULSE, + FORWARD_SPEED, + GROUND_Y, + ICARUS_RADIUS, + ICARUS_X, + MAX_FRAME_DT_MS, + TILT_MAX_RADIANS, + VIEWPORT_HEIGHT, + VIEWPORT_WIDTH, +} from "@/lib/icarus/constants"; +import { collidesWithColumn, createInitialIcarus, hasHitGround, stepIcarus } from "@/lib/icarus/physics"; +import type { Column, IcarusState } from "@/lib/icarus/types"; + +type Status = "idle" | "flying" | "dead"; + +function randomGapCenter(): number { + const minCenter = COLUMN_GAP / 2 + COLUMN_GAP_MARGIN; + const maxCenter = GROUND_Y - COLUMN_GAP / 2 - COLUMN_GAP_MARGIN; + return minCenter + Math.random() * Math.max(0, maxCenter - minCenter); +} + +function drawIcarus(ctx: CanvasRenderingContext2D, y: number, tilt: number) { + ctx.save(); + ctx.translate(ICARUS_X, y); + ctx.rotate(tilt); + + ctx.fillStyle = "#F4ECD8"; + ctx.beginPath(); + ctx.moveTo(-2, -2); + ctx.lineTo(-ICARUS_RADIUS * 2.2, -ICARUS_RADIUS * 1.3); + ctx.lineTo(-ICARUS_RADIUS * 0.5, ICARUS_RADIUS * 0.5); + ctx.closePath(); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(2, -2); + ctx.lineTo(ICARUS_RADIUS * 2.2, -ICARUS_RADIUS * 1.3); + ctx.lineTo(ICARUS_RADIUS * 0.5, ICARUS_RADIUS * 0.5); + ctx.closePath(); + ctx.fill(); + + ctx.fillStyle = "#E7C560"; + ctx.beginPath(); + ctx.arc(0, 0, ICARUS_RADIUS, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); +} + +function drawColumnShaft( + ctx: CanvasRenderingContext2D, + x: number, + top: number, + height: number, + capAtBottom: boolean, +) { + if (height <= 0) return; + + ctx.fillStyle = "#F4ECD8"; + ctx.fillRect(x, top, COLUMN_WIDTH, height); + + ctx.strokeStyle = "#C9A227"; + ctx.lineWidth = 1; + for (let i = 1; i < 4; i++) { + const lineX = x + (COLUMN_WIDTH / 4) * i; + ctx.beginPath(); + ctx.moveTo(lineX, top); + ctx.lineTo(lineX, top + height); + ctx.stroke(); + } + + const capHeight = Math.min(10, height); + const capY = capAtBottom ? top + height - capHeight : top; + ctx.fillStyle = "#E7C560"; + ctx.fillRect(x - 4, capY, COLUMN_WIDTH + 8, capHeight); +} + +function drawColumn(ctx: CanvasRenderingContext2D, column: Column) { + const gapTop = column.gapCenterY - COLUMN_GAP / 2; + const gapBottom = column.gapCenterY + COLUMN_GAP / 2; + drawColumnShaft(ctx, column.x, 0, gapTop, true); + drawColumnShaft(ctx, column.x, gapBottom, GROUND_Y - gapBottom, false); +} + +export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [status, setStatus] = useState("idle"); + const [score, setScore] = useState(0); + + const onFinishRef = useRef(onFinish); + useEffect(() => { + onFinishRef.current = onFinish; + }, [onFinish]); + + const icarusRef = useRef(createInitialIcarus()); + const columnsRef = useRef([]); + const statusRef = useRef("idle"); + const scoreRef = useRef(0); + const flapRequestedRef = useRef(false); + + function resetGame() { + icarusRef.current = createInitialIcarus(); + columnsRef.current = []; + scoreRef.current = 0; + statusRef.current = "idle"; + setStatus("idle"); + setScore(0); + } + + function requestFlap() { + flapRequestedRef.current = true; + if (statusRef.current === "idle") { + statusRef.current = "flying"; + setStatus("flying"); + } else if (statusRef.current === "dead") { + resetGame(); + } + } + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + function resize() { + const dpr = window.devicePixelRatio || 1; + const cssWidth = container!.clientWidth; + const cssHeight = cssWidth * (VIEWPORT_HEIGHT / VIEWPORT_WIDTH); + canvas!.width = cssWidth * dpr; + canvas!.height = cssHeight * dpr; + canvas!.style.width = `${cssWidth}px`; + canvas!.style.height = `${cssHeight}px`; + } + + resize(); + const observer = new ResizeObserver(resize); + observer.observe(container); + + let rafId: number; + let lastFrame: number | null = null; + let accumulator = 0; + + function frame(now: number) { + if (lastFrame === null) lastFrame = now; + const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS); + lastFrame = now; + + if (statusRef.current === "flying") { + accumulator += frameDt; + + while (accumulator >= FIXED_DT_MS) { + if (flapRequestedRef.current) { + icarusRef.current = { ...icarusRef.current, velocityY: FLAP_IMPULSE }; + } + icarusRef.current = stepIcarus(icarusRef.current, FIXED_DT_MS); + + for (const column of columnsRef.current) { + column.x -= FORWARD_SPEED * (FIXED_DT_MS / 1000); + if (!column.scored && column.x + COLUMN_WIDTH < ICARUS_X - ICARUS_RADIUS) { + column.scored = true; + scoreRef.current += 1; + setScore(scoreRef.current); + } + } + columnsRef.current = columnsRef.current.filter((c) => c.x + COLUMN_WIDTH > 0); + + const last = columnsRef.current[columnsRef.current.length - 1]; + if (!last || last.x < VIEWPORT_WIDTH - COLUMN_SPACING) { + columnsRef.current.push({ + x: last ? last.x + COLUMN_SPACING : VIEWPORT_WIDTH, + gapCenterY: randomGapCenter(), + scored: false, + }); + } + + const hitColumn = columnsRef.current.some((c) => collidesWithColumn(icarusRef.current.y, c)); + if (hasHitGround(icarusRef.current) || hitColumn) { + statusRef.current = "dead"; + setStatus("dead"); + onFinishRef.current(scoreRef.current); + break; + } + + accumulator -= FIXED_DT_MS; + } + } + + flapRequestedRef.current = false; + render(ctx!, canvas!, icarusRef.current, columnsRef.current, statusRef.current, scoreRef.current); + rafId = requestAnimationFrame(frame); + } + + rafId = requestAnimationFrame(frame); + + return () => { + observer.disconnect(); + cancelAnimationFrame(rafId); + }; + }, []); + + return ( +
+
+ +
+ +

{score}

+ + {status === "idle" && ( +

+ Touche l'écran pour battre des ailes et t'envoler entre les colonnes. +

+ )} + + {status === "dead" && ( + + )} +
+ ); +} + +function render( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + icarus: IcarusState, + columns: Column[], + status: Status, + score: number, +) { + const dpr = window.devicePixelRatio || 1; + const cssWidth = canvas.width / dpr; + const worldScale = cssWidth / VIEWPORT_WIDTH; + + ctx.save(); + ctx.scale(dpr, dpr); + ctx.save(); + ctx.scale(worldScale, worldScale); + + ctx.clearRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT); + ctx.fillStyle = "#0A1B33"; + ctx.fillRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT); + + for (const column of columns) { + drawColumn(ctx, column); + } + + ctx.fillStyle = "#2A2116"; + ctx.fillRect(0, GROUND_Y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT - GROUND_Y); + + const tilt = Math.max(-1, Math.min(1, icarus.velocityY / 300)) * TILT_MAX_RADIANS; + drawIcarus(ctx, icarus.y, tilt); + + ctx.restore(); + + if (status === "flying") { + ctx.save(); + ctx.fillStyle = "#F4ECD8"; + ctx.font = "700 28px Cinzel, serif"; + ctx.textAlign = "center"; + ctx.fillText(String(score), cssWidth / 2, 44); + ctx.restore(); + } + + ctx.restore(); +} diff --git a/src/app/icare/page.tsx b/src/app/icare/page.tsx new file mode 100644 index 0000000..6812918 --- /dev/null +++ b/src/app/icare/page.tsx @@ -0,0 +1,61 @@ +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import { IcareView } from "./icare-view"; + +export type IcarusScoreEntry = { + id: string; + pseudo: string; + avatar_url: string | null; + best_score: number; +}; + +export default async function IcarePage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect("/login"); + } + + 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: scoreProfiles } = + 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((scoreProfiles ?? []).map((profile) => [profile.id, profile])); + + const leaderboard: IcarusScoreEntry[] = (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, + })); + + const { data: settings } = await supabase + .from("settings") + .select("tribunal_date") + .eq("id", true) + .single(); + + const isFrozen = Boolean(settings?.tribunal_date && new Date() >= new Date(settings.tribunal_date)); + const ownBestScore = leaderboard.find((entry) => entry.id === user.id)?.best_score ?? null; + + return ( +
+
+

Le Vol d'Icare

+

+ Vole entre les colonnes, aussi loin que tes ailes de cire le permettent. +

+
+ +
+ ); +} diff --git a/src/components/header.tsx b/src/components/header.tsx index 3f707ac..60a0c35 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -15,7 +15,7 @@ import { IconLogout, IconChevronDown, IconCalendar, - IconChariot, + IconWings, } from "@/components/icons"; type MenuEntry = { @@ -92,7 +92,7 @@ export function Header({ const entries: MenuEntry[] = [ { href: "/leaderboard", label: "Le Classement", icon: }, { href: "/roulette", label: "La Roulette", icon: }, - { href: "/course", label: "La Course du Char", icon: }, + { href: "/icare", label: "Le Vol d'Icare", icon: }, { href: "/calendrier", label: "Le Calendrier des Dieux", icon: }, { href: "/journal", label: "Le Crieur", icon: }, { href: "/profile", label: "Mon profil", icon: }, diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 5470d8d..cad1a64 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -242,14 +242,20 @@ export function IconBolt({ className = base }: IconProps) { ); } -export function IconChariot({ className = base }: IconProps) { +export function IconWings({ className = base }: IconProps) { return ( - - - - - + + + ); } diff --git a/src/lib/chariot/constants.ts b/src/lib/chariot/constants.ts deleted file mode 100644 index 99d7369..0000000 --- a/src/lib/chariot/constants.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Espace monde fixe (pas de caméra qui suit le char : toute la piste tient -// dans cet écran, mis à l'échelle au rendu selon la taille du canvas). -export const WORLD_WIDTH = 400; -export const WORLD_HEIGHT = 720; - -export const CORRIDOR_HALF_WIDTH = 55; -export const CHARIOT_RADIUS = 14; -export const OBSTACLE_RADIUS = 16; - -export const WAYPOINT_COUNT = 12; -export const WAYPOINT_STEP = 1; // unité arbitraire, la piste est remise à l'échelle après génération -export const MAX_TURN_RADIANS = (34 * Math.PI) / 180; -export const SPLINE_SAMPLES_PER_SEGMENT = 10; - -export const OBSTACLE_COUNT = 8; -export const OBSTACLE_EDGE_BUFFER_RATIO = 0.08; // pas d'obstacle dans les 8% de début/fin -export const OBSTACLE_LATERAL_MARGIN = 12; // marge par rapport aux murs - -export const MAX_SPEED = 230; // unités/seconde -export const MAX_ACCEL = 900; // unités/seconde², vitesse réelle -> vitesse cible -export const FRICTION_WHEN_IDLE = 500; // décélération quand le joystick est relâché - -export const SLOWDOWN_SPEED_FACTOR = 0.4; // vitesse max plafonnée juste après un choc -export const SLOWDOWN_RECOVERY_MS = 900; - -export const JOYSTICK_MAX_RADIUS = 70; // px, rayon de clamp du glissement tactile - -export const FIXED_DT_MS = 1000 / 60; -export const MAX_FRAME_DT_MS = 250; // évite la "spirale de la mort" après un onglet en arrière-plan - -export const GHOST_SAMPLE_INTERVAL_MS = 80; - -export const MIN_TIME_MS = 2000; -export const MAX_TIME_MS = 300000; diff --git a/src/lib/chariot/format.ts b/src/lib/chariot/format.ts deleted file mode 100644 index 1e89427..0000000 --- a/src/lib/chariot/format.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function formatRaceTime(ms: number): string { - const totalCentiseconds = Math.round(ms / 10); - const minutes = Math.floor(totalCentiseconds / 6000); - const seconds = Math.floor((totalCentiseconds % 6000) / 100); - const centiseconds = totalCentiseconds % 100; - return `${minutes}:${seconds.toString().padStart(2, "0")}.${centiseconds.toString().padStart(2, "0")}`; -} diff --git a/src/lib/chariot/geometry.ts b/src/lib/chariot/geometry.ts deleted file mode 100644 index 7fe1d9d..0000000 --- a/src/lib/chariot/geometry.ts +++ /dev/null @@ -1,77 +0,0 @@ -export type Vec2 = { x: number; y: number }; - -export function add(a: Vec2, b: Vec2): Vec2 { - return { x: a.x + b.x, y: a.y + b.y }; -} - -export function sub(a: Vec2, b: Vec2): Vec2 { - return { x: a.x - b.x, y: a.y - b.y }; -} - -export function scale(a: Vec2, s: number): Vec2 { - return { x: a.x * s, y: a.y * s }; -} - -export function length(a: Vec2): number { - return Math.hypot(a.x, a.y); -} - -export function normalize(a: Vec2): Vec2 { - const len = length(a); - return len > 0 ? scale(a, 1 / len) : { x: 0, y: 0 }; -} - -export function dot(a: Vec2, b: Vec2): number { - return a.x * b.x + a.y * b.y; -} - -export function rotate90(a: Vec2): Vec2 { - return { x: -a.y, y: a.x }; -} - -// Point le plus proche du point p sur le segment [a,b]. -export function closestPointOnSegment(p: Vec2, a: Vec2, b: Vec2): { point: Vec2; t: number } { - const ab = sub(b, a); - const abLenSq = dot(ab, ab); - const t = abLenSq > 0 ? Math.max(0, Math.min(1, dot(sub(p, a), ab) / abLenSq)) : 0; - return { point: add(a, scale(ab, t)), t }; -} - -// Catmull-Rom uniforme : passe exactement par chaque point de contrôle, -// samplesPerSegment points intermédiaires entre chaque paire de points. -export function catmullRom(points: Vec2[], samplesPerSegment: number): Vec2[] { - if (points.length < 2) return points.slice(); - - const result: Vec2[] = []; - const at = (i: number) => points[Math.max(0, Math.min(points.length - 1, i))]; - - for (let i = 0; i < points.length - 1; i++) { - const p0 = at(i - 1); - const p1 = at(i); - const p2 = at(i + 1); - const p3 = at(i + 2); - - for (let s = 0; s < samplesPerSegment; s++) { - const t = s / samplesPerSegment; - const t2 = t * t; - const t3 = t2 * t; - result.push({ - x: - 0.5 * - (2 * p1.x + - (-p0.x + p2.x) * t + - (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + - (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3), - y: - 0.5 * - (2 * p1.y + - (-p0.y + p2.y) * t + - (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + - (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3), - }); - } - } - - result.push(points[points.length - 1]); - return result; -} diff --git a/src/lib/chariot/ghost.ts b/src/lib/chariot/ghost.ts deleted file mode 100644 index 79b32cf..0000000 --- a/src/lib/chariot/ghost.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { GHOST_SAMPLE_INTERVAL_MS } from "./constants"; -import type { GhostSample } from "./types"; - -// Ajoute un échantillon si assez de temps s'est écoulé depuis le dernier ; -// mutation en place, appelé à chaque frame depuis la boucle de jeu. -export function recordGhostSample(samples: GhostSample[], elapsedMs: number, x: number, y: number, a: number): void { - const last = samples[samples.length - 1]; - if (!last || elapsedMs - last.t >= GHOST_SAMPLE_INTERVAL_MS) { - samples.push({ t: elapsedMs, x, y, a }); - } -} - -// Force un dernier échantillon exactement à l'arrivée (même hors cadence), -// pour que la relecture ne s'arrête jamais visiblement avant la ligne. -export function finishGhost(samples: GhostSample[], elapsedMs: number, x: number, y: number, a: number): void { - samples.push({ t: elapsedMs, x, y, a }); -} - -// Position interpolée d'un fantôme au temps écoulé donné ; reste figé à sa -// dernière position une fois son propre temps de course dépassé. -export function sampleGhostAt(path: GhostSample[], elapsedMs: number): GhostSample | null { - if (path.length === 0) return null; - if (elapsedMs <= path[0].t) return path[0]; - - const lastSample = path[path.length - 1]; - if (elapsedMs >= lastSample.t) return lastSample; - - for (let i = 0; i < path.length - 1; i++) { - const a = path[i]; - const b = path[i + 1]; - if (elapsedMs >= a.t && elapsedMs <= b.t) { - const span = b.t - a.t; - const t = span > 0 ? (elapsedMs - a.t) / span : 0; - return { - t: elapsedMs, - x: a.x + (b.x - a.x) * t, - y: a.y + (b.y - a.y) * t, - a: a.a + (b.a - a.a) * t, - }; - } - } - return lastSample; -} diff --git a/src/lib/chariot/physics.ts b/src/lib/chariot/physics.ts deleted file mode 100644 index 54d3a8b..0000000 --- a/src/lib/chariot/physics.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - CHARIOT_RADIUS, - FRICTION_WHEN_IDLE, - MAX_ACCEL, - MAX_SPEED, - SLOWDOWN_RECOVERY_MS, - SLOWDOWN_SPEED_FACTOR, -} from "./constants"; -import { add, closestPointOnSegment, dot, length, normalize, scale, sub, type Vec2 } from "./geometry"; -import type { ChariotState, InputVector, Track } from "./types"; - -function distanceToCenterline(point: Vec2, centerline: Vec2[]): { distance: number; closest: Vec2 } { - let best = Infinity; - let bestPoint = centerline[0]; - for (let i = 0; i < centerline.length - 1; i++) { - const { point: candidate } = closestPointOnSegment(point, centerline[i], centerline[i + 1]); - const d = length(sub(point, candidate)); - if (d < best) { - best = d; - bestPoint = candidate; - } - } - return { distance: best, closest: bestPoint }; -} - -// Vitesse max plafonnée après un choc : remonte progressivement de -// SLOWDOWN_SPEED_FACTOR à 1 sur SLOWDOWN_RECOVERY_MS — jamais d'arrêt net. -function speedCapFactor(lastCollisionAt: number | null, nowMs: number): number { - if (lastCollisionAt === null) return 1; - const sinceMs = nowMs - lastCollisionAt; - if (sinceMs >= SLOWDOWN_RECOVERY_MS) return 1; - const t = sinceMs / SLOWDOWN_RECOVERY_MS; - return SLOWDOWN_SPEED_FACTOR + (1 - SLOWDOWN_SPEED_FACTOR) * t; -} - -export function createInitialState(track: Track): ChariotState { - return { - position: { ...track.start.position }, - velocity: { x: 0, y: 0 }, - heading: track.start.heading, - lastCollisionAt: null, - }; -} - -export function step(state: ChariotState, input: InputVector, track: Track, dtMs: number, nowMs: number): ChariotState { - const dt = dtMs / 1000; - const speedCap = speedCapFactor(state.lastCollisionAt, nowMs) * MAX_SPEED; - - const inputMagnitude = Math.min(1, length(input)); - const targetVelocity = - inputMagnitude > 0.001 ? scale(normalize(input), inputMagnitude * speedCap) : { x: 0, y: 0 }; - - const velocityDelta = sub(targetVelocity, state.velocity); - const maxStep = (inputMagnitude > 0.001 ? MAX_ACCEL : FRICTION_WHEN_IDLE) * dt; - const deltaLength = length(velocityDelta); - const appliedDelta = deltaLength > maxStep ? scale(normalize(velocityDelta), maxStep) : velocityDelta; - - let velocity = add(state.velocity, appliedDelta); - let position = add(state.position, scale(velocity, dt)); - let lastCollisionAt = state.lastCollisionAt; - - // Murs : distance à la ligne centrale comparée à la demi-largeur du couloir. - const { distance, closest } = distanceToCenterline(position, track.centerline); - const wallLimit = track.halfWidth - CHARIOT_RADIUS; - if (distance > wallLimit) { - const outward = normalize(sub(position, closest)); - position = add(closest, scale(outward, wallLimit)); - const outwardComponent = dot(velocity, outward); - if (outwardComponent > 0) { - velocity = sub(velocity, scale(outward, outwardComponent)); - } - lastCollisionAt = nowMs; - } - - // Obstacles : cercles statiques. - for (const obstacle of track.obstacles) { - const toChariot = sub(position, obstacle.position); - const minDist = obstacle.radius + CHARIOT_RADIUS; - const dist = length(toChariot); - if (dist < minDist) { - const outward = dist > 0.001 ? normalize(toChariot) : { x: 1, y: 0 }; - position = add(obstacle.position, scale(outward, minDist)); - const outwardComponent = dot(velocity, outward); - if (outwardComponent < 0) { - velocity = sub(velocity, scale(outward, outwardComponent)); - } - lastCollisionAt = nowMs; - } - } - - const heading = length(velocity) > 5 ? Math.atan2(velocity.y, velocity.x) : state.heading; - - return { position, velocity, heading, lastCollisionAt }; -} - -// Détecte le franchissement de la ligne d'arrivée entre deux positions -// consécutives (changement de signe de la position projetée sur la normale -// du segment d'arrivée, croisement vérifié dans les bornes du segment). -export function crossedFinish(prev: Vec2, next: Vec2, track: Track): boolean { - const { a, b } = track.finish; - const along = sub(b, a); - const normal = { x: -along.y, y: along.x }; - const prevSide = dot(sub(prev, a), normal); - const nextSide = dot(sub(next, a), normal); - if (prevSide === 0 || Math.sign(prevSide) === Math.sign(nextSide)) return false; - - const t = prevSide / (prevSide - nextSide); - const crossingPoint = add(prev, scale(sub(next, prev), t)); - const alongLength = length(along); - const projected = dot(sub(crossingPoint, a), normalize(along)); - return projected >= 0 && projected <= alongLength; -} diff --git a/src/lib/chariot/rng.ts b/src/lib/chariot/rng.ts deleted file mode 100644 index da79906..0000000 --- a/src/lib/chariot/rng.ts +++ /dev/null @@ -1,23 +0,0 @@ -// PRNG déterministe (mulberry32) : même graine → même suite de nombres sur -// tous les appareils, condition nécessaire pour que la piste du jour soit -// identique pour tout le monde. -export function mulberry32(seed: number): () => number { - let a = seed; - return function random() { - a |= 0; - a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -// FNV-1a : transforme la date du jour ("2026-07-28") en graine numérique stable. -export function hashStringToSeed(value: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -} diff --git a/src/lib/chariot/track.ts b/src/lib/chariot/track.ts deleted file mode 100644 index 7283f85..0000000 --- a/src/lib/chariot/track.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - CORRIDOR_HALF_WIDTH, - MAX_TURN_RADIANS, - OBSTACLE_COUNT, - OBSTACLE_EDGE_BUFFER_RATIO, - OBSTACLE_LATERAL_MARGIN, - OBSTACLE_RADIUS, - SPLINE_SAMPLES_PER_SEGMENT, - WAYPOINT_COUNT, - WAYPOINT_STEP, - WORLD_HEIGHT, - WORLD_WIDTH, -} from "./constants"; -import { add, catmullRom, normalize, rotate90, scale, sub, type Vec2 } from "./geometry"; -import { hashStringToSeed, mulberry32 } from "./rng"; -import type { Obstacle, Track } from "./types"; - -function generateRawWaypoints(rng: () => number): Vec2[] { - const points: Vec2[] = [{ x: 0, y: 0 }]; - let heading = Math.PI / 2; // vers le bas (l'axe y de l'écran augmente vers le bas) - let current = points[0]; - - for (let i = 0; i < WAYPOINT_COUNT; i++) { - heading += (rng() - 0.5) * 2 * MAX_TURN_RADIANS; - current = add(current, { - x: Math.cos(heading) * WAYPOINT_STEP, - y: Math.sin(heading) * WAYPOINT_STEP, - }); - points.push(current); - } - return points; -} - -// Remet à l'échelle et recentre n'importe quelle marche aléatoire dans le -// rectangle jouable du monde (marge comprise) : garantit que la piste tient -// toujours dans l'écran fixe, quelle que soit la dérive de la marche aléatoire, -// sans avoir à régler un biais de rappel vers le centre au doigt mouillé. -function fitToWorld(points: Vec2[], margin: number): Vec2[] { - const xs = points.map((p) => p.x); - const ys = points.map((p) => p.y); - const minX = Math.min(...xs); - const maxX = Math.max(...xs); - const minY = Math.min(...ys); - const maxY = Math.max(...ys); - - const bboxWidth = Math.max(maxX - minX, 1e-6); - const bboxHeight = Math.max(maxY - minY, 1e-6); - const targetWidth = WORLD_WIDTH - margin * 2; - const targetHeight = WORLD_HEIGHT - margin * 2; - - const scaleFactor = Math.min(targetWidth / bboxWidth, targetHeight / bboxHeight); - const bboxCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }; - const targetCenter = { x: WORLD_WIDTH / 2, y: WORLD_HEIGHT / 2 }; - - return points.map((p) => ({ - x: (p.x - bboxCenter.x) * scaleFactor + targetCenter.x, - y: (p.y - bboxCenter.y) * scaleFactor + targetCenter.y, - })); -} - -function placeObstacles(centerline: Vec2[], rng: () => number): Obstacle[] { - const n = centerline.length; - const startIndex = Math.floor(n * OBSTACLE_EDGE_BUFFER_RATIO); - const endIndex = Math.ceil(n * (1 - OBSTACLE_EDGE_BUFFER_RATIO)); - const usable = Math.max(endIndex - startIndex, OBSTACLE_COUNT * 2); - const maxLateral = CORRIDOR_HALF_WIDTH - OBSTACLE_RADIUS - OBSTACLE_LATERAL_MARGIN; - - const obstacles: Obstacle[] = []; - for (let i = 0; i < OBSTACLE_COUNT; i++) { - const slot = startIndex + Math.floor(((i + 0.5) / OBSTACLE_COUNT) * usable); - const jitter = Math.floor((rng() - 0.5) * (usable / OBSTACLE_COUNT) * 0.6); - const index = Math.max(1, Math.min(n - 2, slot + jitter)); - - const tangent = normalize(sub(centerline[index + 1], centerline[index - 1])); - const normal = rotate90(tangent); - const lateral = (rng() - 0.5) * 2 * maxLateral; - - obstacles.push({ - position: add(centerline[index], scale(normal, lateral)), - radius: OBSTACLE_RADIUS, - }); - } - return obstacles; -} - -// Fonction pure : la même dateKey (date du jour, Europe/Paris) produit -// toujours exactement la même piste, sans rien stocker côté serveur. -export function generateTrack(dateKey: string): Track { - const seed = hashStringToSeed(dateKey); - const rng = mulberry32(seed); - - const margin = CORRIDOR_HALF_WIDTH + 20; - const waypoints = fitToWorld(generateRawWaypoints(rng), margin); - const centerline = catmullRom(waypoints, SPLINE_SAMPLES_PER_SEGMENT); - - const obstacles = placeObstacles(centerline, rng); - - const first = centerline[0]; - const startHeading = Math.atan2(centerline[1].y - first.y, centerline[1].x - first.x); - - const last = centerline[centerline.length - 1]; - const beforeLast = centerline[centerline.length - 2]; - const finishNormal = rotate90(normalize(sub(last, beforeLast))); - - return { - seed, - centerline, - halfWidth: CORRIDOR_HALF_WIDTH, - obstacles, - start: { position: first, heading: startHeading }, - finish: { - a: add(last, scale(finishNormal, CORRIDOR_HALF_WIDTH)), - b: add(last, scale(finishNormal, -CORRIDOR_HALF_WIDTH)), - }, - world: { width: WORLD_WIDTH, height: WORLD_HEIGHT }, - }; -} diff --git a/src/lib/chariot/types.ts b/src/lib/chariot/types.ts deleted file mode 100644 index 9093434..0000000 --- a/src/lib/chariot/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Vec2 } from "./geometry"; - -export type Obstacle = { position: Vec2; radius: number }; - -export type Track = { - seed: number; - centerline: Vec2[]; // ligne centrale dense, après lissage - halfWidth: number; - obstacles: Obstacle[]; - start: { position: Vec2; heading: number }; - finish: { a: Vec2; b: Vec2 }; - world: { width: number; height: number }; -}; - -// Direction * intensité (0..1) du joystick virtuel, espace écran. -export type InputVector = Vec2; - -export type ChariotState = { - position: Vec2; - velocity: Vec2; - heading: number; - lastCollisionAt: number | null; // ms écoulés depuis le début de la course -}; - -export type GhostSample = { t: number; x: number; y: number; a: number }; diff --git a/src/lib/icarus/constants.ts b/src/lib/icarus/constants.ts new file mode 100644 index 0000000..025febb --- /dev/null +++ b/src/lib/icarus/constants.ts @@ -0,0 +1,30 @@ +// Format classique Flappy Bird : le personnage reste à une position d'écran +// fixe, le décor défile de droite à gauche (pas de piste qui change de +// forme selon le jour — parcours procédural à chaque partie, sans graine +// partagée, puisqu'il n'y a plus de comparaison "même tracé pour tous"). +export const VIEWPORT_WIDTH = 340; +export const VIEWPORT_HEIGHT = 500; + +export const GROUND_HEIGHT = 36; +export const GROUND_Y = VIEWPORT_HEIGHT - GROUND_HEIGHT; + +export const ICARUS_X = VIEWPORT_WIDTH * 0.3; +export const ICARUS_RADIUS = 12; +export const ICARUS_START_Y = VIEWPORT_HEIGHT / 2; + +export const GRAVITY = 900; // unités/seconde² +export const FLAP_IMPULSE = -300; // vitesse verticale imposée à chaque battement d'aile +export const MAX_FALL_SPEED = 480; +export const TILT_MAX_RADIANS = (45 * Math.PI) / 180; + +export const FORWARD_SPEED = 130; // unités/seconde, vitesse de défilement des colonnes +export const COLUMN_WIDTH = 46; +export const COLUMN_GAP = 128; +export const COLUMN_SPACING = 210; +export const COLUMN_GAP_MARGIN = 50; // distance mini entre le centre du trou et le sol/plafond + +export const FIXED_DT_MS = 1000 / 60; +export const MAX_FRAME_DT_MS = 250; // évite la "spirale de la mort" après un onglet en arrière-plan + +export const MIN_SCORE = 0; +export const MAX_SCORE = 1000000; diff --git a/src/lib/icarus/physics.ts b/src/lib/icarus/physics.ts new file mode 100644 index 0000000..72a3c41 --- /dev/null +++ b/src/lib/icarus/physics.ts @@ -0,0 +1,37 @@ +import { + COLUMN_GAP, + COLUMN_WIDTH, + GRAVITY, + GROUND_Y, + ICARUS_RADIUS, + ICARUS_START_Y, + ICARUS_X, + MAX_FALL_SPEED, +} from "./constants"; +import type { Column, IcarusState } from "./types"; + +export function createInitialIcarus(): IcarusState { + return { y: ICARUS_START_Y, velocityY: 0 }; +} + +export function stepIcarus(state: IcarusState, dtMs: number): IcarusState { + const dt = dtMs / 1000; + const velocityY = Math.min(MAX_FALL_SPEED, state.velocityY + GRAVITY * dt); + const y = Math.max(0, state.y + velocityY * dt); + return { y, velocityY }; +} + +export function hasHitGround(icarus: IcarusState): boolean { + return icarus.y + ICARUS_RADIUS >= GROUND_Y; +} + +export function collidesWithColumn(icarusY: number, column: Column): boolean { + const columnLeft = column.x; + const columnRight = column.x + COLUMN_WIDTH; + const overlapsX = columnRight > ICARUS_X - ICARUS_RADIUS && columnLeft < ICARUS_X + ICARUS_RADIUS; + if (!overlapsX) return false; + + const gapTop = column.gapCenterY - COLUMN_GAP / 2; + const gapBottom = column.gapCenterY + COLUMN_GAP / 2; + return icarusY - ICARUS_RADIUS < gapTop || icarusY + ICARUS_RADIUS > gapBottom; +} diff --git a/src/lib/icarus/types.ts b/src/lib/icarus/types.ts new file mode 100644 index 0000000..54abdc2 --- /dev/null +++ b/src/lib/icarus/types.ts @@ -0,0 +1,3 @@ +export type Column = { x: number; gapCenterY: number; scored: boolean }; + +export type IcarusState = { y: number; velocityY: number }; diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index 110cf9c..441dc94 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -8,7 +8,7 @@ const PROTECTED_PATHS = [ "/journal", "/roulette", "/calendrier", - "/course", + "/icare", ]; // /signup n'est PAS dans AUTH_PATHS : un compte fraîchement invité a déjà une // session (établie par le lien d'invitation) mais doit pouvoir rester sur diff --git a/supabase/schema.sql b/supabase/schema.sql index 65b4ea7..68795bb 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -485,145 +485,142 @@ end $$; -- (Récupérer l'uuid via Authentication → Users, ou : -- select id, pseudo from public.profiles;) --- 11. La Course du Char -------------------------------------------------------- --- Mini-jeu : course de char en vue du dessus, une piste générée aléatoirement --- par jour (calculée côté client à partir de la date Europe/Paris, jamais --- stockée ici), chacun rejoue autant qu'il veut, seul le meilleur temps du --- jour compte. Les points du jour sont attribués automatiquement (pas --- d'Archonte impliqué) par une tâche planifiée pg_cron — seul mécanisme de --- points de tout le projet qui n'attend pas de décision d'un juge. +-- 11. Le Vol d'Icare ----------------------------------------------------------- +-- Mini-jeu : remplace la Course du Char (abandonnée — voir nettoyage +-- ci-dessous). Flappy Bird grec : Icare vole entre des colonnes de temple, +-- un seul record personnel all-time (pas de piste/jour, générée librement à +-- chaque partie côté client). Les scores sont figés dès que la date du +-- Tribunal (settings.tribunal_date) est atteinte, puis les gloires du top 3 +-- sont attribuées automatiquement (pas d'Archonte impliqué) par une tâche +-- planifiée pg_cron. --- Un point attribué automatiquement n'a pas de juge : judge_id devient --- optionnel (le Crieur affiche alors "Le Tribunal" à la place d'un pseudo). -alter table public.points_log alter column judge_id drop not null; +-- Nettoyage de la Course du Char (schéma déjà appliqué en prod avant cet +-- abandon — ces DROP sont nécessaires, pas juste cosmétiques). +do $$ +begin + perform cron.unschedule('close-daily-chariot-race'); +exception + when others then null; +end $$; +drop function if exists public.close_daily_chariot_race(); +drop table if exists public.chariot_race_closes; +drop function if exists public.submit_chariot_run(integer, jsonb); +drop table if exists public.chariot_runs; -create table if not exists public.chariot_runs ( - user_id uuid not null references public.profiles (id) on delete cascade, - race_date date not null, - best_time_ms integer not null check (best_time_ms between 2000 and 300000), - ghost_path jsonb not null default '[]'::jsonb, - updated_at timestamptz not null default now(), - primary key (user_id, race_date) +-- Un point attribué automatiquement n'a pas de juge : judge_id reste +-- nullable (déjà appliqué précédemment — le Crieur affiche "Le Tribunal"). + +create table if not exists public.icarus_scores ( + user_id uuid primary key references public.profiles (id) on delete cascade, + best_score integer not null check (best_score between 0 and 1000000), + updated_at timestamptz not null default now() ); -create index if not exists chariot_runs_race_date_idx - on public.chariot_runs (race_date, best_time_ms); +alter table public.icarus_scores enable row level security; -alter table public.chariot_runs enable row level security; +-- Même modèle que points_log/chariot_runs : verrouillée en écriture, seule +-- la RPC submit_icarus_score() (SECURITY DEFINER) peut écrire. +revoke insert, update, delete on public.icarus_scores from authenticated, anon; +grant select on public.icarus_scores to authenticated; --- Même modèle que points_log : verrouillée en écriture, seule la RPC --- submit_chariot_run() (SECURITY DEFINER) peut écrire. -revoke insert, update, delete on public.chariot_runs from authenticated, anon; -grant select on public.chariot_runs to authenticated; - -drop policy if exists "chariot_runs readable by authenticated" on public.chariot_runs; -create policy "chariot_runs readable by authenticated" - on public.chariot_runs for select +drop policy if exists "icarus_scores readable by authenticated" on public.icarus_scores; +create policy "icarus_scores readable by authenticated" + on public.icarus_scores for select to authenticated using (true); --- Seul point d'entrée pour soumettre un temps : la date du jour est calculée --- côté serveur (jamais fournie par le client, pour ne pas pouvoir soumettre --- "pour" un autre jour), le temps est borné grossièrement (anti-triche --- minimal, suffisant pour un jeu entre amis — pas de vérification fine par --- piste), et on n'écrase le temps existant que s'il est strictement meilleur. -create or replace function public.submit_chariot_run(p_time_ms integer, p_ghost_path jsonb default '[]'::jsonb) -returns public.chariot_runs +-- Marqueur d'idempotence pour l'attribution automatique des gloires (un seul +-- événement, pas de notion de jour comme pour la Course du Char). +alter table public.settings add column if not exists icarus_points_awarded boolean not null default false; + +-- Seul point d'entrée pour soumettre un score. Une fois la date du Tribunal +-- atteinte, les scores sont figés : la RPC ne fait plus rien (retourne le +-- record existant sans le modifier) plutôt que d'échouer bruyamment. +create or replace function public.submit_icarus_score(p_score integer) +returns public.icarus_scores language plpgsql security definer set search_path = public as $$ declare - v_race_date date; - v_row public.chariot_runs; + v_tribunal_date timestamptz; + v_row public.icarus_scores; begin if auth.uid() is null then raise exception 'authentication required'; end if; - if p_time_ms is null or p_time_ms < 2000 or p_time_ms > 300000 then - raise exception 'invalid time_ms'; + if p_score is null or p_score < 0 or p_score > 1000000 then + raise exception 'invalid score'; end if; - v_race_date := (now() at time zone 'Europe/Paris')::date; + select tribunal_date into v_tribunal_date from public.settings where id = true; - insert into public.chariot_runs (user_id, race_date, best_time_ms, ghost_path, updated_at) - values (auth.uid(), v_race_date, p_time_ms, coalesce(p_ghost_path, '[]'::jsonb), now()) - on conflict (user_id, race_date) do update - set best_time_ms = excluded.best_time_ms, - ghost_path = excluded.ghost_path, - updated_at = now() - where excluded.best_time_ms < public.chariot_runs.best_time_ms; + if v_tribunal_date is not null and now() >= v_tribunal_date then + select * into v_row from public.icarus_scores where user_id = auth.uid(); + return v_row; + end if; - -- On resélectionne toujours plutôt que de dépendre du RETURNING de - -- l'upsert : si la clause WHERE bloque la mise à jour (temps pas - -- meilleur), l'INSERT ne retourne aucune ligne. On renvoie dans tous les - -- cas le meilleur temps courant du joueur, qu'il vienne d'être battu ou non. - select * into v_row from public.chariot_runs - where user_id = auth.uid() and race_date = v_race_date; + insert into public.icarus_scores (user_id, best_score, updated_at) + values (auth.uid(), p_score, now()) + on conflict (user_id) do update + set best_score = excluded.best_score, + updated_at = now() + where excluded.best_score > public.icarus_scores.best_score; + select * into v_row from public.icarus_scores where user_id = auth.uid(); return v_row; end; $$; -grant execute on function public.submit_chariot_run(integer, jsonb) to authenticated; +grant execute on function public.submit_icarus_score(integer) to authenticated; do $$ begin - alter publication supabase_realtime add table public.chariot_runs; + alter publication supabase_realtime add table public.icarus_scores; exception when duplicate_object then null; end $$; --- Marqueur interne : quelles journées ont déjà été clôturées (idempotence de --- la tâche planifiée, qui tourne toutes les 15 minutes plutôt qu'une seule --- fois à minuit pile — le changement d'heure CET/CEST décalerait un horaire --- UTC fixe). Table strictement interne : aucune policy, jamais lue/écrite --- par le client. -create table if not exists public.chariot_race_closes ( - race_date date primary key, - closed_at timestamptz not null default now() -); -alter table public.chariot_race_closes enable row level security; -revoke all on public.chariot_race_closes from authenticated, anon; - --- Clôture de la journée précédente (Europe/Paris) : classe chariot_runs, --- attribue les gloires du top 3 (ex-aequo inclus au même rang), journalise --- dans points_log avec judge_id = null, puis marque la date comme close. --- No-op si déjà clôturée. Volontairement pas de grant execute à --- authenticated : uniquement appelée par pg_cron ou depuis le SQL Editor. -create or replace function public.close_daily_chariot_race() +-- Attribue les gloires du top 3 (ex-aequo inclus au même rang) dès que la +-- date du Tribunal est atteinte ; no-op tant qu'elle n'est pas encore +-- passée, et no-op définitif une fois déjà fait (icarus_points_awarded). +-- Volontairement pas de grant execute à authenticated : uniquement appelée +-- par pg_cron ou depuis le SQL Editor. +create or replace function public.award_icarus_points_if_due() returns void language plpgsql security definer set search_path = public as $$ declare - v_target_date date := ((now() at time zone 'Europe/Paris')::date - 1); + v_tribunal_date timestamptz; + v_already_awarded boolean; r record; v_points int; begin - if exists (select 1 from public.chariot_race_closes where race_date = v_target_date) then + select tribunal_date, icarus_points_awarded into v_tribunal_date, v_already_awarded + from public.settings where id = true; + + if v_tribunal_date is null or now() < v_tribunal_date or v_already_awarded then return; end if; for r in with ranked as ( - select user_id, best_time_ms, - rank() over (order by best_time_ms asc) as rnk - from public.chariot_runs - where race_date = v_target_date + select user_id, best_score, + rank() over (order by best_score desc) as rnk + from public.icarus_scores ) select * from ranked where rnk <= 3 loop v_points := case r.rnk when 1 then 3 when 2 then 2 when 3 then 1 else 0 end; update public.profiles set points = points + v_points where id = r.user_id; insert into public.points_log (target_id, judge_id, delta, reason) - values (r.user_id, null, v_points, - 'Course du Char — ' || to_char(v_target_date, 'DD/MM') || ' (rang ' || r.rnk || ')'); + values (r.user_id, null, v_points, 'Le Vol d''Icare — rang ' || r.rnk || ' au Tribunal'); end loop; - insert into public.chariot_race_closes (race_date) values (v_target_date); + update public.settings set icarus_points_awarded = true where id = true; end; $$; @@ -635,7 +632,7 @@ $$; -- ré-exécuté en entier à chaque changement, doit toujours pouvoir passer. do $$ begin - perform cron.unschedule('close-daily-chariot-race'); + perform cron.unschedule('award-icarus-points'); exception when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé end $$; @@ -643,11 +640,11 @@ end $$; do $$ begin perform cron.schedule( - 'close-daily-chariot-race', + 'award-icarus-points', '*/15 * * * *', - $cron$select public.close_daily_chariot_race();$cron$ + $cron$select public.award_icarus_points_if_due();$cron$ ); exception when others then - raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier la clôture automatique de la Course du Char.'; + raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier l''attribution automatique des gloires du Vol d''Icare.'; end $$;