Remplace la Course du Char par Le Vol d'Icare
Build and deploy / deploy (push) Successful in 37s

Après retour d'expérience, la course de char (vue du dessus, piste
générée par jour) laisse place à un Flappy Bird grec plus simple et
plus lisible sur téléphone : Icare vole entre des colonnes de temple,
touche l'écran pour battre des ailes, échec net au premier contact
(score remis à zéro).

Record personnel all-time (plus de piste quotidienne ni de fantômes) :
les scores se figent dès que la date du Tribunal est atteinte, puis les
gloires du top 3 sont attribuées automatiquement via une tâche
planifiée pg_cron, comme pour l'ancienne Course du Char.

schema.sql nettoie explicitement l'ancienne Course du Char (tables,
RPC, tâche planifiée) avant de poser le nouveau schéma, puisqu'elle
avait déjà été appliquée en prod.
This commit is contained in:
Valentin ROBIN
2026-07-29 19:14:52 +02:00
parent 6af56d2ade
commit 33d2476ef6
22 changed files with 636 additions and 1054 deletions
+11 -11
View File
@@ -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).
-323
View File
@@ -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<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [finishedMs, setFinishedMs] = useState<number | null>(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<ChariotState>(createInitialState(track));
const inputRef = useRef({ x: 0, y: 0 });
const pointerOriginRef = useRef<{ x: number; y: number; id: number } | null>(null);
const statusRef = useRef<Status>("idle");
const startedAtRef = useRef<number | null>(null);
const ghostPathRef = useRef<GhostSample[]>([]);
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 (
<div className="flex flex-col items-center gap-3">
<div
ref={containerRef}
className="relative w-full max-w-sm select-none overflow-hidden rounded-2xl border border-gold/40 shadow-xl"
>
<canvas ref={canvasRef} className="block h-full w-full touch-none" />
</div>
{status === "idle" && (
<p className="text-center text-sm text-text-mut">
Pose le doigt sur la piste et glisse pour diriger le char.
</p>
)}
{status === "finished" && finishedMs !== null && (
<div className="marble-surface flex flex-col items-center gap-2 rounded-xl border border-gold/40 px-4 py-3 text-center shadow-lg">
<p className="font-heading text-lg tracking-wide text-gold-bright">{formatRaceTime(finishedMs)}</p>
<button
type="button"
onClick={resetRace}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink"
>
Rejouer
</button>
</div>
)}
</div>
);
}
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();
}
-118
View File
@@ -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 (
<div className="flex flex-col gap-6">
{ranked.length > 0 && (
<Podium profiles={ranked} renderValue={(member) => formatRaceTime(member.best_time_ms)} />
)}
{ownBestMs !== null && (
<p className="text-center font-heading text-sm tracking-wide text-text-marble">
Ton meilleur temps aujourd&apos;hui :{" "}
<span className="text-gold-bright">{formatRaceTime(ownBestMs)}</span>
</p>
)}
{/* 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. */}
<ChariotGame key={track.seed} track={track} ghosts={ghosts} onFinish={handleFinish} />
<p className="text-center text-xs text-text-mut">
Le podium du jour reçoit des gloires automatiquement à minuit aucun Archonte n&apos;a besoin
d&apos;intervenir.
</p>
</div>
);
}
-69
View File
@@ -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 (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
La Course du Char
</h1>
<p className="font-serif text-sm text-marble/60 italic">
Une piste par jour, le meilleur temps l&apos;emporte.
</p>
</div>
<CourseView today={today} initialTopRuns={topRuns} initialOwnBestMs={ownRun?.best_time_ms ?? null} />
</div>
);
}
+110
View File
@@ -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 (
<div className="flex flex-col gap-6">
{isFrozen && (
<p className="marble-surface 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>
)}
{ranked.length > 0 && (
<ol className="flex flex-col gap-1.5">
{ranked.map((entry) => (
<li
key={entry.id}
className="marble-surface flex items-center gap-3 rounded-lg border border-gold/25 px-3 py-2 shadow-sm"
>
<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-gold-bright">{entry.best_score}</span>
</li>
))}
</ol>
)}
{ownBestScore !== null && (
<p className="text-center font-heading text-sm tracking-wide text-text-marble">
Ton record : <span className="text-gold-bright">{ownBestScore}</span>
</p>
)}
<IcarusGame onFinish={handleFinish} />
<p className="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>
);
}
+286
View File
@@ -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<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [score, setScore] = useState(0);
const onFinishRef = useRef(onFinish);
useEffect(() => {
onFinishRef.current = onFinish;
}, [onFinish]);
const icarusRef = useRef<IcarusState>(createInitialIcarus());
const columnsRef = useRef<Column[]>([]);
const statusRef = useRef<Status>("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 (
<div className="flex flex-col items-center gap-3">
<div
ref={containerRef}
onPointerDown={requestFlap}
className="relative w-full max-w-xs touch-none select-none overflow-hidden rounded-2xl border border-gold/40 shadow-xl"
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
<p className="text-center font-heading text-2xl text-gold-bright">{score}</p>
{status === "idle" && (
<p className="text-center text-sm text-text-mut">
Touche l&apos;écran pour battre des ailes et t&apos;envoler entre les colonnes.
</p>
)}
{status === "dead" && (
<button
type="button"
onClick={resetGame}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink"
>
Rejouer
</button>
)}
</div>
);
}
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();
}
+61
View File
@@ -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 (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">Le Vol d&apos;Icare</h1>
<p className="font-serif text-sm text-marble/60 italic">
Vole entre les colonnes, aussi loin que tes ailes de cire le permettent.
</p>
</div>
<IcareView initialLeaderboard={leaderboard} initialOwnBestScore={ownBestScore} isFrozen={isFrozen} />
</div>
);
}
+2 -2
View File
@@ -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: <LaurelWreath className="h-5 w-5" /> },
{ href: "/roulette", label: "La Roulette", icon: <IconWheel /> },
{ href: "/course", label: "La Course du Char", icon: <IconChariot /> },
{ href: "/icare", label: "Le Vol d'Icare", icon: <IconWings /> },
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> },
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> },
+12 -6
View File
@@ -242,14 +242,20 @@ export function IconBolt({ className = base }: IconProps) {
);
}
export function IconChariot({ className = base }: IconProps) {
export function IconWings({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<circle cx="6.5" cy="18" r="2.3" />
<circle cx="17.5" cy="18" r="2.3" />
<path d="M6.5 15.7V10a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v5.7" strokeLinejoin="round" />
<path d="M8.5 8 11 3h3.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.5 12h5" strokeLinecap="round" />
<path d="M12 5v14" strokeLinecap="round" />
<path
d="M12 8c-2-3-6-4-9-3 1 3 1 6 4 8 2 1.4 4 1.2 5 .5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 8c2-3 6-4 9-3-1 3-1 6-4 8-2 1.4-4 1.2-5 .5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
-34
View File
@@ -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;
-7
View File
@@ -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")}`;
}
-77
View File
@@ -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;
}
-43
View File
@@ -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;
}
-112
View File
@@ -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;
}
-23
View File
@@ -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;
}
-117
View File
@@ -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 },
};
}
-25
View File
@@ -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 };
+30
View File
@@ -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;
+37
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export type Column = { x: number; gapCenterY: number; scored: boolean };
export type IcarusState = { y: number; velocityY: number };
+1 -1
View File
@@ -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
+83 -86
View File
@@ -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 $$;