feat(rooms): migrate multiplayer room storage from memory to PostgreSQL

This commit is contained in:
jessy-david-dev
2026-04-11 17:37:56 +02:00
parent e6aa0badac
commit 552bb17c15
12 changed files with 2103 additions and 84 deletions
+61 -22
View File
@@ -3,18 +3,36 @@
import { NextRequest } from "next/server";
import type { Room, Player } from "../route";
import { prisma } from "../../../../lib/prisma";
// Acces au singleton
declare global {
var __wikirooms: Map<string, Room> | undefined;
}
function getRooms(): Map<string, Room> {
if (!global.__wikirooms) {
global.__wikirooms = new Map();
}
return global.__wikirooms;
function dbToRoom(row: {
code: string;
players: unknown;
phase: string;
round: number;
totalRounds: number;
maxPlayers: number;
startArticle: string;
targetArticle: string;
roundWinner: string | null;
countdownStart: bigint | null;
roundStart: bigint | null;
createdAt: bigint;
}): Room {
return {
code: row.code,
players: row.players as Player[],
phase: row.phase as Room["phase"],
round: row.round,
totalRounds: row.totalRounds,
maxPlayers: row.maxPlayers,
startArticle: row.startArticle,
targetArticle: row.targetArticle,
roundWinner: row.roundWinner,
countdownStart: row.countdownStart !== null ? Number(row.countdownStart) : null,
roundStart: row.roundStart !== null ? Number(row.roundStart) : null,
createdAt: Number(row.createdAt),
};
}
function generatePlayerId(): string {
@@ -24,11 +42,9 @@ function generatePlayerId(): string {
// Timeout joueur inactif : 15s
const PLAYER_TIMEOUT_MS = 15_000;
function prunePlayers(room: Room) {
function prunePlayers(players: Player[]): Player[] {
const now = Date.now();
room.players = room.players.filter(
(p) => now - p.lastSeen < PLAYER_TIMEOUT_MS,
);
return players.filter((p) => now - p.lastSeen < PLAYER_TIMEOUT_MS);
}
// PATCH /api/rooms/[code]
@@ -37,13 +53,14 @@ export async function PATCH(
{ params }: { params: Promise<{ code: string }> },
) {
const { code } = await params;
const rooms = getRooms();
const room = rooms.get(code.toUpperCase());
const row = await prisma.room.findUnique({ where: { code: code.toUpperCase() } });
if (!room) {
if (!row) {
return Response.json({ error: "Room introuvable" }, { status: 404 });
}
const room = dbToRoom(row);
const body = await request.json();
const { action, playerId, playerName, article, startArticle, targetArticle } =
body as {
@@ -56,7 +73,7 @@ export async function PATCH(
};
// Nettoyer les joueurs inactifs avant chaque action
prunePlayers(room);
room.players = prunePlayers(room.players);
switch (action) {
// Rejoindre
@@ -92,6 +109,7 @@ export async function PATCH(
lastSeen: Date.now(),
};
room.players.push(player);
await saveRoom(room);
return Response.json({ room, playerId: newId });
}
@@ -101,6 +119,7 @@ export async function PATCH(
if (player) {
player.lastSeen = Date.now();
}
await saveRoom(room);
return Response.json({ room });
}
@@ -144,6 +163,7 @@ export async function PATCH(
p.hasWon = false;
}
await saveRoom(room);
return Response.json({ room });
}
@@ -152,11 +172,11 @@ export async function PATCH(
if (room.phase !== "countdown") {
return Response.json({ room });
}
// On laisse les clients gerer le timing - le 1er qui appelle play apres 3s active
const elapsed = Date.now() - (room.countdownStart ?? 0);
if (elapsed >= 3000) {
room.phase = "playing";
room.roundStart = Date.now();
await saveRoom(room);
}
return Response.json({ room });
}
@@ -177,7 +197,6 @@ export async function PATCH(
player.currentArticle = article;
player.lastSeen = Date.now();
// Verifier si le joueur a atteint la cible
const normalize = (s: string) =>
decodeURIComponent(s).replace(/_/g, " ").toLowerCase().trim();
@@ -187,7 +206,6 @@ export async function PATCH(
) {
player.hasWon = true;
// 1er joueur a gagner = +10 points
const alreadyWon = room.players.some(
(p) => p.hasWon && p.id !== player.id,
);
@@ -198,6 +216,7 @@ export async function PATCH(
}
}
await saveRoom(room);
return Response.json({ room });
}
@@ -218,6 +237,7 @@ export async function PATCH(
p.hasWon = false;
p.currentArticle = "";
}
await saveRoom(room);
return Response.json({ room });
}
@@ -240,6 +260,7 @@ export async function PATCH(
p.hasWon = false;
p.currentArticle = "";
}
await saveRoom(room);
return Response.json({ room });
}
@@ -247,3 +268,21 @@ export async function PATCH(
return Response.json({ error: "Action inconnue" }, { status: 400 });
}
}
async function saveRoom(room: Room) {
await prisma.room.update({
where: { code: room.code },
data: {
players: room.players as object[],
phase: room.phase,
round: room.round,
totalRounds: room.totalRounds,
maxPlayers: room.maxPlayers,
startArticle: room.startArticle,
targetArticle: room.targetArticle,
roundWinner: room.roundWinner,
countdownStart: room.countdownStart !== null ? BigInt(room.countdownStart) : null,
roundStart: room.roundStart !== null ? BigInt(room.roundStart) : null,
},
});
}
+73 -57
View File
@@ -2,6 +2,7 @@
// GET /api/rooms?code=XXXX - recuperer l'etat d'une room
import { NextRequest } from "next/server";
import { prisma } from "../../../lib/prisma";
// Types
@@ -24,25 +25,12 @@ export type Room = {
maxPlayers: number;
startArticle: string;
targetArticle: string;
roundWinner: string | null; // player id
countdownStart: number | null; // timestamp ms
roundStart: number | null; // timestamp ms
roundWinner: string | null;
countdownStart: number | null;
roundStart: number | null;
createdAt: number;
};
// Stockage en memoire (singleton Node.js)
declare global {
var __wikirooms: Map<string, Room> | undefined;
}
function getRooms(): Map<string, Room> {
if (!global.__wikirooms) {
global.__wikirooms = new Map();
}
return global.__wikirooms;
}
// Helpers
function generateCode(): string {
@@ -58,17 +46,41 @@ function generatePlayerId(): string {
return crypto.randomUUID();
}
// Nettoie les rooms inactives depuis plus de 2h
function pruneOldRooms(rooms: Map<string, Room>) {
const now = Date.now();
for (const [code, room] of rooms) {
if (now - room.createdAt > 2 * 60 * 60 * 1000) {
rooms.delete(code);
}
}
function dbToRoom(row: {
code: string;
players: unknown;
phase: string;
round: number;
totalRounds: number;
maxPlayers: number;
startArticle: string;
targetArticle: string;
roundWinner: string | null;
countdownStart: bigint | null;
roundStart: bigint | null;
createdAt: bigint;
}): Room {
return {
code: row.code,
players: row.players as Player[],
phase: row.phase as Room["phase"],
round: row.round,
totalRounds: row.totalRounds,
maxPlayers: row.maxPlayers,
startArticle: row.startArticle,
targetArticle: row.targetArticle,
roundWinner: row.roundWinner,
countdownStart: row.countdownStart !== null ? Number(row.countdownStart) : null,
roundStart: row.roundStart !== null ? Number(row.roundStart) : null,
createdAt: Number(row.createdAt),
};
}
// Handlers
// Nettoie les rooms inactives depuis plus de 2h
async function pruneOldRooms() {
const cutoff = BigInt(Date.now() - 2 * 60 * 60 * 1000);
await prisma.room.deleteMany({ where: { createdAt: { lt: cutoff } } });
}
// POST /api/rooms
// Body: { playerName: string }
@@ -98,46 +110,51 @@ export async function POST(request: NextRequest) {
10,
);
const rooms = getRooms();
pruneOldRooms(rooms);
await pruneOldRooms();
// Generer un code unique
let code = generateCode();
let attempts = 0;
while (rooms.has(code) && attempts < 20) {
while (attempts < 20) {
const existing = await prisma.room.findUnique({ where: { code } });
if (!existing) break;
code = generateCode();
attempts++;
}
const playerId = generatePlayerId();
const now = BigInt(Date.now());
const room: Room = {
code,
players: [
{
id: playerId,
name: playerName.trim().slice(0, 20),
score: 0,
currentArticle: "",
hasWon: false,
isHost: true,
lastSeen: Date.now(),
},
],
phase: "waiting",
round: 0,
totalRounds: clampedRounds,
maxPlayers: clampedMax,
startArticle: "",
targetArticle: "",
roundWinner: null,
countdownStart: null,
roundStart: null,
createdAt: Date.now(),
};
const players: Player[] = [
{
id: playerId,
name: playerName.trim().slice(0, 20),
score: 0,
currentArticle: "",
hasWon: false,
isHost: true,
lastSeen: Date.now(),
},
];
rooms.set(code, room);
const row = await prisma.room.create({
data: {
code,
players: players as object[],
phase: "waiting",
round: 0,
totalRounds: clampedRounds,
maxPlayers: clampedMax,
startArticle: "",
targetArticle: "",
roundWinner: null,
countdownStart: null,
roundStart: null,
createdAt: now,
},
});
const room = dbToRoom(row);
return Response.json({ room, playerId });
}
@@ -150,12 +167,11 @@ export async function GET(request: NextRequest) {
return Response.json({ error: "Code manquant" }, { status: 400 });
}
const rooms = getRooms();
const room = rooms.get(code.toUpperCase());
const row = await prisma.room.findUnique({ where: { code: code.toUpperCase() } });
if (!room) {
if (!row) {
return Response.json({ error: "Room introuvable" }, { status: 404 });
}
return Response.json({ room });
return Response.json({ room: dbToRoom(row) });
}