mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
adding persistance when client losing connections (adding redis db)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<T extends Status>(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<T extends Status>(
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
36
packages/socket/src/services/persistence.ts
Normal file
36
packages/socket/src/services/persistence.ts
Normal file
@@ -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<string, any>
|
||||
|
||||
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<GameSnapshot | null> => {
|
||||
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}`)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user