feat(rooms): migrate multiplayer room storage from memory to PostgreSQL
This commit is contained in:
@@ -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
@@ -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) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user