diff --git a/packages/socket/package.json b/packages/socket/package.json index 299ff9d..6e13446 100644 --- a/packages/socket/package.json +++ b/packages/socket/package.json @@ -15,6 +15,7 @@ "@rahoot/common": "workspace:*", "@t3-oss/env-core": "^0.13.8", "dayjs": "^1.11.18", + "redis": "^4.6.13", "socket.io": "^4.8.1", "uuid": "^13.0.0", "zod": "^4.1.12" diff --git a/packages/socket/src/index.ts b/packages/socket/src/index.ts index a25525f..1203a24 100644 --- a/packages/socket/src/index.ts +++ b/packages/socket/src/index.ts @@ -4,6 +4,7 @@ import env from "@rahoot/socket/env" import Config from "@rahoot/socket/services/config" import Game from "@rahoot/socket/services/game" import Registry from "@rahoot/socket/services/registry" +import { loadSnapshot } from "@rahoot/socket/services/persistence" import { withGame } from "@rahoot/socket/utils/game" import { Server as ServerIO } from "socket.io" @@ -34,6 +35,24 @@ io.on("connection", (socket) => { `A user connected: socketId: ${socket.id}, clientId: ${socket.handshake.auth.clientId}` ) + const ensureGame = async (gameId: string) => { + let game = registry.getGameById(gameId) + if (game) return game + + try { + const snapshot = await loadSnapshot(gameId) + if (snapshot) { + const restored = await Game.fromSnapshot(io, snapshot) + registry.addGame(restored) + return restored + } + } catch (error) { + console.error("Failed to restore game", error) + } + + return null + } + socket.on("player:reconnect", ({ gameId }) => { const game = registry.getPlayerGame(gameId, socket.handshake.auth.clientId) @@ -43,11 +62,22 @@ io.on("connection", (socket) => { return } - socket.emit("game:reset", "Game not found") + ensureGame(gameId).then((restored) => { + if (restored) { + restored.reconnect(socket) + + return + } + + socket.emit("game:reset", "Game not found") + }) }) socket.on("manager:reconnect", ({ gameId }) => { - const game = registry.getManagerGame(gameId, socket.handshake.auth.clientId) + const game = registry.getManagerGame( + gameId, + socket.handshake.auth.clientId + ) if (game) { game.reconnect(socket) @@ -55,7 +85,15 @@ io.on("connection", (socket) => { return } - socket.emit("game:reset", "Game expired") + ensureGame(gameId).then((restored) => { + if (restored) { + restored.reconnect(socket) + + return + } + + socket.emit("game:reset", "Game expired") + }) }) socket.on("manager:auth", (password) => { diff --git a/packages/socket/src/services/game.ts b/packages/socket/src/services/game.ts index 4a7e34d..2091f87 100644 --- a/packages/socket/src/services/game.ts +++ b/packages/socket/src/services/game.ts @@ -2,6 +2,7 @@ import { Answer, Player, Quizz } from "@rahoot/common/types/game" import { Server, Socket } from "@rahoot/common/types/game/socket" import { Status, STATUS, StatusDataMap } from "@rahoot/common/types/game/status" import Registry from "@rahoot/socket/services/registry" +import { saveSnapshot, loadSnapshot, deleteSnapshot, GameSnapshot } from "@rahoot/socket/services/persistence" import { createInviteCode, timeToPoint } from "@rahoot/socket/utils/game" import sleep from "@rahoot/socket/utils/sleep" import { v4 as uuid } from "uuid" @@ -55,8 +56,8 @@ class Game { this.gameId = uuid() this.manager = { id: "", - clientId: "", - connected: false, + clientId: socket.handshake.auth.clientId, + connected: true, } this.inviteCode = "" this.started = false @@ -86,11 +87,8 @@ class Game { const roomInvite = createInviteCode() this.inviteCode = roomInvite - this.manager = { - id: socket.id, - clientId: socket.handshake.auth.clientId, - connected: true, - } + this.manager.id = socket.id + this.quizz = quizz socket.join(this.gameId) @@ -102,12 +100,56 @@ class Game { console.log( `New game created: ${roomInvite} subject: ${this.quizz.subject}` ) + this.persist() + } + + static async fromSnapshot(io: Server, snapshot: GameSnapshot) { + const game = Object.create(Game.prototype) as Game + game.io = io + game.gameId = snapshot.gameId + game.manager = { + id: "", + clientId: snapshot.manager?.clientId || "", + connected: false, + } + game.inviteCode = snapshot.inviteCode + game.started = snapshot.started + game.lastBroadcastStatus = snapshot.lastBroadcastStatus || null + game.managerStatus = snapshot.managerStatus || null + game.playerStatus = new Map() + game.leaderboard = snapshot.leaderboard || [] + game.tempOldLeaderboard = snapshot.tempOldLeaderboard || null + game.quizz = snapshot.quizz + game.players = (snapshot.players || []).map((p: Player) => ({ + ...p, + id: "", + connected: false, + })) + game.round = snapshot.round || { + playersAnswers: [], + currentQuestion: 0, + startTime: 0, + } + game.cooldown = { + active: snapshot.cooldown?.active || false, + paused: snapshot.cooldown?.paused || false, + remaining: snapshot.cooldown?.remaining || 0, + timer: null, + resolve: null, + } + + if (game.cooldown.active && game.cooldown.remaining > 0 && !game.cooldown.paused) { + game.startCooldown(game.cooldown.remaining) + } + + return game } broadcastStatus(status: T, data: StatusDataMap[T]) { const statusData = { name: status, data } this.lastBroadcastStatus = statusData this.io.to(this.gameId).emit("game:status", statusData) + this.persist() } sendStatus( @@ -124,6 +166,50 @@ class Game { } this.io.to(target).emit("game:status", statusData) + this.persist() + } + + toSnapshot(): GameSnapshot { + return { + gameId: this.gameId, + inviteCode: this.inviteCode, + started: this.started, + manager: { + clientId: this.manager.clientId, + }, + lastBroadcastStatus: this.lastBroadcastStatus, + managerStatus: this.managerStatus, + leaderboard: this.leaderboard, + tempOldLeaderboard: this.tempOldLeaderboard, + quizz: this.quizz, + players: this.players.map((p) => ({ + ...p, + id: undefined, + connected: false, + })), + round: this.round, + cooldown: { + active: this.cooldown.active, + paused: this.cooldown.paused, + remaining: this.cooldown.remaining, + }, + } + } + + async persist() { + try { + await saveSnapshot(this.gameId, this.toSnapshot()) + } catch (error) { + console.error("Failed to persist game snapshot", error) + } + } + + async clearPersisted() { + try { + await deleteSnapshot(this.gameId) + } catch (error) { + console.error("Failed to delete game snapshot", error) + } } join(socket: Socket, username: string) { @@ -301,10 +387,12 @@ class Game { } this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining) + this.persist() } // initial emit this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining) + this.persist() this.cooldown.timer = setInterval(tick, 1000) }) @@ -318,6 +406,7 @@ class Game { this.cooldown.active = false this.cooldown.paused = false this.io.to(this.gameId).emit("game:cooldownPause", false) + this.persist() this.finishCooldown() } @@ -342,6 +431,7 @@ class Game { this.cooldown.paused = true this.io.to(this.gameId).emit("game:cooldownPause", true) + this.persist() } resumeCooldown(socket: Socket) { @@ -351,6 +441,7 @@ class Game { this.cooldown.paused = false this.io.to(this.gameId).emit("game:cooldownPause", false) + this.persist() } skipQuestionIntro(socket: Socket) { @@ -387,6 +478,7 @@ class Game { await this.startCooldown(3) this.newRound() + this.persist() } async newRound() { @@ -446,6 +538,7 @@ class Game { } this.showResults(question) + this.persist() } showResults(question: any) { @@ -504,13 +597,14 @@ class Game { correct: question.solution, answers: question.answers, image: question.image, - media: question.media, + media: question.media, }) this.leaderboard = sortedPlayers this.tempOldLeaderboard = oldLeaderboard this.round.playersAnswers = [] + this.persist() } selectAnswer(socket: Socket, answerId: number) { const player = this.players.find((player) => player.id === socket.id) @@ -543,6 +637,7 @@ class Game { if (this.round.playersAnswers.length === this.players.length) { this.abortCooldown() } + this.persist() } nextRound(socket: Socket) { @@ -585,6 +680,7 @@ class Game { subject: this.quizz.subject, top: this.leaderboard.slice(0, 3), }) + this.clearPersisted() return } @@ -599,6 +695,7 @@ class Game { }) this.tempOldLeaderboard = null + this.persist() } } diff --git a/packages/socket/src/services/persistence.ts b/packages/socket/src/services/persistence.ts new file mode 100644 index 0000000..e7491de --- /dev/null +++ b/packages/socket/src/services/persistence.ts @@ -0,0 +1,36 @@ +import { createClient } from "redis" + +const redisUrl = process.env.REDIS_URL || "redis://localhost:6379" + +const redis = + createClient({ url: redisUrl }) + .on("error", (err) => console.error("Redis Client Error", err)) + +export type GameSnapshot = Record + +export const connectRedis = async () => { + if (!redis.isOpen) { + await redis.connect() + } +} + +export const saveSnapshot = async (gameId: string, snapshot: GameSnapshot) => { + if (!gameId) return + await connectRedis() + await redis.set(`game:${gameId}`, JSON.stringify(snapshot), { + EX: 60 * 60 * 6, // 6 hours + }) +} + +export const loadSnapshot = async (gameId: string): Promise => { + if (!gameId) return null + await connectRedis() + const raw = await redis.get(`game:${gameId}`) + return raw ? (JSON.parse(raw) as GameSnapshot) : null +} + +export const deleteSnapshot = async (gameId: string) => { + if (!gameId) return + await connectRedis() + await redis.del(`game:${gameId}`) +} diff --git a/packages/socket/src/services/registry.ts b/packages/socket/src/services/registry.ts index e3bdc58..5143f55 100644 --- a/packages/socket/src/services/registry.ts +++ b/packages/socket/src/services/registry.ts @@ -86,6 +86,9 @@ class Registry { } removeGame(gameId: string): boolean { + const game = this.games.find((g) => g.gameId === gameId) + void game?.clearPersisted?.() + const initialLength = this.games.length this.games = this.games.filter((g) => g.gameId !== gameId) this.emptyGames = this.emptyGames.filter((g) => g.game.gameId !== gameId) @@ -125,6 +128,7 @@ class Registry { const removed = this.emptyGames.filter((g) => !stillEmpty.includes(g)) const removedGameIds = removed.map((r) => r.game.gameId) + removed.forEach((entry) => void entry.game.clearPersisted?.()) this.games = this.games.filter((g) => !removedGameIds.includes(g.gameId)) this.emptyGames = stillEmpty