fix: sync totalRounds changes to server, filter geographic admin articles, validate user exists before saving game

This commit is contained in:
jessy-david-dev
2026-04-13 15:42:00 +02:00
parent 79d90b75c4
commit 2a3dd6be56
7 changed files with 121 additions and 29 deletions
+8
View File
@@ -20,6 +20,14 @@ export async function POST(req: NextRequest) {
won: boolean; won: boolean;
}; };
const userExists = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true },
});
if (!userExists) {
return NextResponse.json({ error: "Utilisateur introuvable" }, { status: 401 });
}
const game = await prisma.game.create({ const game = await prisma.game.create({
data: { data: {
userId: session.user.id, userId: session.user.id,
+31 -5
View File
@@ -69,7 +69,10 @@ function calcPlayerPoints(player: Player, room: Room): number {
let timePts: number; let timePts: number;
if (room.timeLimit > 0) { if (room.timeLimit > 0) {
const timeLeft = room.timeLimit * 1000 - elapsed; const timeLeft = room.timeLimit * 1000 - elapsed;
timePts = Math.max(0, Math.floor((timeLeft / (room.timeLimit * 1000)) * 10)); timePts = Math.max(
0,
Math.floor((timeLeft / (room.timeLimit * 1000)) * 10),
);
} else { } else {
timePts = Math.max(0, 10 - Math.floor(elapsed / 30000)); timePts = Math.max(0, 10 - Math.floor(elapsed / 30000));
} }
@@ -355,7 +358,10 @@ export async function PATCH(
case "setGameMode": { case "setGameMode": {
const host = room.players.find((p) => p.id === playerId); const host = room.players.find((p) => p.id === playerId);
if (!host?.isHost) { if (!host?.isHost) {
return Response.json({ error: "Seul l'hôte peut modifier ce paramètre" }, { status: 403 }); return Response.json(
{ error: "Seul l'hôte peut modifier ce paramètre" },
{ status: 403 },
);
} }
if (value === "race" || value === "all_finish") { if (value === "race" || value === "all_finish") {
room.gameMode = value; room.gameMode = value;
@@ -364,18 +370,38 @@ export async function PATCH(
return Response.json({ room }); return Response.json({ room });
} }
// Hôte change le nombre de manches
case "setTotalRounds": {
const host = room.players.find((p) => p.id === playerId);
if (!host?.isHost) {
return Response.json(
{ error: "Seul l'hôte peut modifier ce paramètre" },
{ status: 403 },
);
}
if (typeof value === "number") {
room.totalRounds = Math.min(Math.max(Math.floor(value), 1), 10);
await saveRoom(room);
}
return Response.json({ room });
}
// Hôte change la limite de temps // Hôte change la limite de temps
case "setTimeLimit": { case "setTimeLimit": {
const host = room.players.find((p) => p.id === playerId); const host = room.players.find((p) => p.id === playerId);
if (!host?.isHost) { if (!host?.isHost) {
return Response.json({ error: "Seul l'hôte peut modifier ce paramètre" }, { status: 403 }); return Response.json(
{ error: "Seul l'hôte peut modifier ce paramètre" },
{ status: 403 },
);
} }
room.timeLimit = typeof value === "number" ? Math.max(0, Math.floor(value)) : 0; room.timeLimit =
typeof value === "number" ? Math.max(0, Math.floor(value)) : 0;
await saveRoom(room); await saveRoom(room);
return Response.json({ room }); return Response.json({ room });
} }
// Fin du temps imparti déclenché par le client qui détecte l'expiration // Fin du temps imparti - déclenché par le client qui détecte l'expiration
case "timeUp": { case "timeUp": {
if (room.phase !== "playing") { if (room.phase !== "playing") {
return Response.json({ room }); return Response.json({ room });
+2 -4
View File
@@ -24,9 +24,7 @@ const FORBIDDEN_NAMESPACES = [
"WP:", "WP:",
]; ];
const REMOVED_SECTION_IDS = [ const REMOVED_SECTION_IDS = ["Liens_externes"];
"Liens_externes",
];
function getHeadingId(el: Element): string { function getHeadingId(el: Element): string {
// Nouvelle structure Wikipedia : <div class="mw-heading"><h2 id="..."> // Nouvelle structure Wikipedia : <div class="mw-heading"><h2 id="...">
@@ -72,7 +70,7 @@ function cleanWikiHtml(container: HTMLElement): void {
} }
}); });
// Supprimer les sections indésirables supporte ancienne et nouvelle structure Wikipedia // Supprimer les sections indésirables - supporte ancienne et nouvelle structure Wikipedia
// Nouvelle : <div class="mw-heading mw-heading2"> / Ancienne : <h2><span id="..."> // Nouvelle : <div class="mw-heading mw-heading2"> / Ancienne : <h2><span id="...">
const headingSelectors = "h2, h3, .mw-heading"; const headingSelectors = "h2, h3, .mw-heading";
container.querySelectorAll(headingSelectors).forEach((heading) => { container.querySelectorAll(headingSelectors).forEach((heading) => {
+4 -2
View File
@@ -19,6 +19,7 @@ type LobbyScreenProps = {
gameMode: Room["gameMode"]; gameMode: Room["gameMode"];
setGameMode: (v: Room["gameMode"]) => void; setGameMode: (v: Room["gameMode"]) => void;
onSetGameMode: (v: Room["gameMode"]) => void; onSetGameMode: (v: Room["gameMode"]) => void;
onSetTotalRounds: (v: number) => void;
onSetSearchAllowed: (v: boolean) => void; onSetSearchAllowed: (v: boolean) => void;
onSetTimeLimit: (v: number) => void; onSetTimeLimit: (v: number) => void;
}; };
@@ -39,6 +40,7 @@ export function LobbyScreen({
gameMode, gameMode,
setGameMode, setGameMode,
onSetGameMode, onSetGameMode,
onSetTotalRounds,
onSetSearchAllowed, onSetSearchAllowed,
onSetTimeLimit, onSetTimeLimit,
}: LobbyScreenProps) { }: LobbyScreenProps) {
@@ -180,8 +182,8 @@ export function LobbyScreen({
<div className="flex-1 flex flex-col gap-1.5"> <div className="flex-1 flex flex-col gap-1.5">
<span className="text-xs text-[#888]">Manches</span> <span className="text-xs text-[#888]">Manches</span>
<select <select
value={totalRounds} value={room.totalRounds}
onChange={(e) => setTotalRounds(Number(e.target.value))} onChange={(e) => { const v = Number(e.target.value); setTotalRounds(v); onSetTotalRounds(v); }}
className="w-full min-h-11 px-2 bg-[#0f0f0f] border border-[#2e2e2e] rounded-xl text-sm text-[#f0f0f0] outline-none focus:border-[#7c3aed] cursor-pointer transition-colors" className="w-full min-h-11 px-2 bg-[#0f0f0f] border border-[#2e2e2e] rounded-xl text-sm text-[#f0f0f0] outline-none focus:border-[#7c3aed] cursor-pointer transition-colors"
> >
{[1, 2, 3, 4, 5, 7, 10].map((n) => ( {[1, 2, 3, 4, 5, 7, 10].map((n) => (
+1
View File
@@ -159,6 +159,7 @@ export function ScreenRouter({
gameMode={gameMode} gameMode={gameMode}
setGameMode={setGameMode} setGameMode={setGameMode}
onSetGameMode={(v) => multi.setGameMode(v)} onSetGameMode={(v) => multi.setGameMode(v)}
onSetTotalRounds={(v) => multi.setTotalRounds(v)}
onSetSearchAllowed={(v) => multi.setSearchAllowed(v)} onSetSearchAllowed={(v) => multi.setSearchAllowed(v)}
onSetTimeLimit={(v) => multi.setTimeLimit(v)} onSetTimeLimit={(v) => multi.setTimeLimit(v)}
/> />
+54 -15
View File
@@ -151,7 +151,12 @@ export function useMultiGame() {
if (timeLimitTimerRef.current) clearInterval(timeLimitTimerRef.current); if (timeLimitTimerRef.current) clearInterval(timeLimitTimerRef.current);
if (room.timeLimit > 0) { if (room.timeLimit > 0) {
const tick = () => { const tick = () => {
const left = Math.ceil(((room.roundStart ?? Date.now()) + room.timeLimit * 1000 - Date.now()) / 1000); const left = Math.ceil(
((room.roundStart ?? Date.now()) +
room.timeLimit * 1000 -
Date.now()) /
1000,
);
setTimeLeft(left <= 0 ? 0 : left); setTimeLeft(left <= 0 ? 0 : left);
}; };
tick(); tick();
@@ -162,7 +167,10 @@ export function useMultiGame() {
} }
if (room.phase === "results" && prevPhase !== "results") { if (room.phase === "results" && prevPhase !== "results") {
timer.stop(); timer.stop();
if (timeLimitTimerRef.current) { clearInterval(timeLimitTimerRef.current); timeLimitTimerRef.current = null; } if (timeLimitTimerRef.current) {
clearInterval(timeLimitTimerRef.current);
timeLimitTimerRef.current = null;
}
setTimeLeft(null); setTimeLeft(null);
} }
if (room.round !== prevRound && room.phase === "playing") { if (room.round !== prevRound && room.phase === "playing") {
@@ -173,15 +181,25 @@ export function useMultiGame() {
// Temps limité : envoie timeUp quand le compteur atteint 0 // Temps limité : envoie timeUp quand le compteur atteint 0
useEffect(() => { useEffect(() => {
if (timeLeft !== 0 || !room || room.phase !== "playing" || !playerId || !room.timeLimit) return; if (
timeLeft !== 0 ||
!room ||
room.phase !== "playing" ||
!playerId ||
!room.timeLimit
)
return;
fetch(`/api/rooms/${room.code}`, { fetch(`/api/rooms/${room.code}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "timeUp", playerId }), body: JSON.stringify({ action: "timeUp", playerId }),
}).then((r) => r.json()).then((d) => { })
if ((d as { room: Room }).room) setRoom((d as { room: Room }).room); .then((r) => r.json())
}).catch(() => {}); .then((d) => {
// eslint-disable-next-line react-hooks/exhaustive-deps if ((d as { room: Room }).room) setRoom((d as { room: Room }).room);
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeLeft]); }, [timeLeft]);
// Countdown -> playing transition // Countdown -> playing transition
@@ -327,12 +345,16 @@ export function useMultiGame() {
} }
const goBack = useCallback(async () => { const goBack = useCallback(async () => {
if (!room || !playerId || loadingRef.current || room.phase !== "playing") return; if (!room || !playerId || loadingRef.current || room.phase !== "playing")
return;
if (historyRef.current.length <= 1) return; if (historyRef.current.length <= 1) return;
clicksRef.current += 1; clicksRef.current += 1;
setClicksDisplay(clicksRef.current); setClicksDisplay(clicksRef.current);
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; } if (!timerStartedRef.current) {
timer.start();
timerStartedRef.current = true;
}
const newHistory = historyRef.current.slice(0, -1); const newHistory = historyRef.current.slice(0, -1);
const target = newHistory[newHistory.length - 1]; const target = newHistory[newHistory.length - 1];
@@ -354,11 +376,17 @@ export function useMultiGame() {
const res = await fetch(`/api/rooms/${room.code}`, { const res = await fetch(`/api/rooms/${room.code}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "navigate", playerId, article: canonical }), body: JSON.stringify({
action: "navigate",
playerId,
article: canonical,
}),
}); });
if (res.ok) setRoom((await res.json() as { room: Room }).room); if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
} catch { /* on continue localement */ } } catch {
// eslint-disable-next-line react-hooks/exhaustive-deps /* on continue localement */
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, playerId]); }, [room, playerId]);
async function setGameMode(value: "race" | "all_finish") { async function setGameMode(value: "race" | "all_finish") {
@@ -368,7 +396,7 @@ export function useMultiGame() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "setGameMode", playerId, value }), body: JSON.stringify({ action: "setGameMode", playerId, value }),
}); });
if (res.ok) setRoom((await res.json() as { room: Room }).room); if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
} }
async function setTimeLimit(value: number) { async function setTimeLimit(value: number) {
@@ -378,7 +406,17 @@ export function useMultiGame() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "setTimeLimit", playerId, value }), body: JSON.stringify({ action: "setTimeLimit", playerId, value }),
}); });
if (res.ok) setRoom((await res.json() as { room: Room }).room); if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
}
async function setTotalRounds(value: number) {
if (!room || !playerId) return;
const res = await fetch(`/api/rooms/${room.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "setTotalRounds", playerId, value }),
});
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
} }
async function setSearchAllowed(value: boolean) { async function setSearchAllowed(value: boolean) {
@@ -479,6 +517,7 @@ export function useMultiGame() {
surrender, surrender,
timeLeft, timeLeft,
setGameMode, setGameMode,
setTotalRounds,
setTimeLimit, setTimeLimit,
setSearchAllowed, setSearchAllowed,
restore, restore,
+21 -3
View File
@@ -3,8 +3,25 @@ import { getFallbackPuzzle } from "./puzzles";
const WIKI_API_BASE = "https://fr.wikipedia.org/w/api.php"; const WIKI_API_BASE = "https://fr.wikipedia.org/w/api.php";
const MIN_ARTICLE_BYTES = 10000; const MIN_ARTICLE_BYTES = 10000;
const BAD_TITLE_PREFIXES = ["Liste de", "Liste des", "Index de", "Portail:"]; const BAD_TITLE_PREFIXES = ["Index de", "Portail:"];
const BAD_TITLE_SUFFIXES = ["(homonymie)", "(disambiguation)"]; const BAD_TITLE_SUFFIXES = [
"(disambiguation)",
// Géographie administrative française
"(département)",
"(canton)",
"(commune)",
"(arrondissement)",
"(circonscription législative)",
"(région)",
];
const BAD_TITLE_PATTERNS = [
// Cantons français : "Canton de X", "Canton de X (Yvelines)", etc
/^Canton (de |d'|du )/i,
// Arrondissements
/^Arrondissement (de |d'|du )/i,
// Communes très petites ou listes géo
/^Communes (de |d'|du )/i,
];
// Cache de promesses module-level // Cache de promesses module-level
const articleCache = new Map<string, Promise<WikiArticle | null>>(); const articleCache = new Map<string, Promise<WikiArticle | null>>();
@@ -66,7 +83,8 @@ function isGoodArticle(page: WikiPageInfo): boolean {
return ( return (
page.length >= MIN_ARTICLE_BYTES && page.length >= MIN_ARTICLE_BYTES &&
!BAD_TITLE_PREFIXES.some((p) => t.startsWith(p)) && !BAD_TITLE_PREFIXES.some((p) => t.startsWith(p)) &&
!BAD_TITLE_SUFFIXES.some((s) => t.endsWith(s)) !BAD_TITLE_SUFFIXES.some((s) => t.endsWith(s)) &&
!BAD_TITLE_PATTERNS.some((r) => r.test(t))
); );
} }