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:*",
|
"@rahoot/common": "workspace:*",
|
||||||
"@t3-oss/env-core": "^0.13.8",
|
"@t3-oss/env-core": "^0.13.8",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
|
"redis": "^4.6.13",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import env from "@rahoot/socket/env"
|
|||||||
import Config from "@rahoot/socket/services/config"
|
import Config from "@rahoot/socket/services/config"
|
||||||
import Game from "@rahoot/socket/services/game"
|
import Game from "@rahoot/socket/services/game"
|
||||||
import Registry from "@rahoot/socket/services/registry"
|
import Registry from "@rahoot/socket/services/registry"
|
||||||
|
import { loadSnapshot } from "@rahoot/socket/services/persistence"
|
||||||
import { withGame } from "@rahoot/socket/utils/game"
|
import { withGame } from "@rahoot/socket/utils/game"
|
||||||
import { Server as ServerIO } from "socket.io"
|
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}`
|
`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 }) => {
|
socket.on("player:reconnect", ({ gameId }) => {
|
||||||
const game = registry.getPlayerGame(gameId, socket.handshake.auth.clientId)
|
const game = registry.getPlayerGame(gameId, socket.handshake.auth.clientId)
|
||||||
|
|
||||||
@@ -43,11 +62,22 @@ io.on("connection", (socket) => {
|
|||||||
return
|
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 }) => {
|
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) {
|
if (game) {
|
||||||
game.reconnect(socket)
|
game.reconnect(socket)
|
||||||
@@ -55,7 +85,15 @@ io.on("connection", (socket) => {
|
|||||||
return
|
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) => {
|
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 { Server, Socket } from "@rahoot/common/types/game/socket"
|
||||||
import { Status, STATUS, StatusDataMap } from "@rahoot/common/types/game/status"
|
import { Status, STATUS, StatusDataMap } from "@rahoot/common/types/game/status"
|
||||||
import Registry from "@rahoot/socket/services/registry"
|
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 { createInviteCode, timeToPoint } from "@rahoot/socket/utils/game"
|
||||||
import sleep from "@rahoot/socket/utils/sleep"
|
import sleep from "@rahoot/socket/utils/sleep"
|
||||||
import { v4 as uuid } from "uuid"
|
import { v4 as uuid } from "uuid"
|
||||||
@@ -55,8 +56,8 @@ class Game {
|
|||||||
this.gameId = uuid()
|
this.gameId = uuid()
|
||||||
this.manager = {
|
this.manager = {
|
||||||
id: "",
|
id: "",
|
||||||
clientId: "",
|
clientId: socket.handshake.auth.clientId,
|
||||||
connected: false,
|
connected: true,
|
||||||
}
|
}
|
||||||
this.inviteCode = ""
|
this.inviteCode = ""
|
||||||
this.started = false
|
this.started = false
|
||||||
@@ -86,11 +87,8 @@ class Game {
|
|||||||
|
|
||||||
const roomInvite = createInviteCode()
|
const roomInvite = createInviteCode()
|
||||||
this.inviteCode = roomInvite
|
this.inviteCode = roomInvite
|
||||||
this.manager = {
|
this.manager.id = socket.id
|
||||||
id: socket.id,
|
|
||||||
clientId: socket.handshake.auth.clientId,
|
|
||||||
connected: true,
|
|
||||||
}
|
|
||||||
this.quizz = quizz
|
this.quizz = quizz
|
||||||
|
|
||||||
socket.join(this.gameId)
|
socket.join(this.gameId)
|
||||||
@@ -102,12 +100,56 @@ class Game {
|
|||||||
console.log(
|
console.log(
|
||||||
`New game created: ${roomInvite} subject: ${this.quizz.subject}`
|
`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]) {
|
broadcastStatus<T extends Status>(status: T, data: StatusDataMap[T]) {
|
||||||
const statusData = { name: status, data }
|
const statusData = { name: status, data }
|
||||||
this.lastBroadcastStatus = statusData
|
this.lastBroadcastStatus = statusData
|
||||||
this.io.to(this.gameId).emit("game:status", statusData)
|
this.io.to(this.gameId).emit("game:status", statusData)
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
sendStatus<T extends Status>(
|
sendStatus<T extends Status>(
|
||||||
@@ -124,6 +166,50 @@ class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.io.to(target).emit("game:status", statusData)
|
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) {
|
join(socket: Socket, username: string) {
|
||||||
@@ -301,10 +387,12 @@ class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
|
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
// initial emit
|
// initial emit
|
||||||
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
|
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
|
||||||
|
this.persist()
|
||||||
|
|
||||||
this.cooldown.timer = setInterval(tick, 1000)
|
this.cooldown.timer = setInterval(tick, 1000)
|
||||||
})
|
})
|
||||||
@@ -318,6 +406,7 @@ class Game {
|
|||||||
this.cooldown.active = false
|
this.cooldown.active = false
|
||||||
this.cooldown.paused = false
|
this.cooldown.paused = false
|
||||||
this.io.to(this.gameId).emit("game:cooldownPause", false)
|
this.io.to(this.gameId).emit("game:cooldownPause", false)
|
||||||
|
this.persist()
|
||||||
this.finishCooldown()
|
this.finishCooldown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +431,7 @@ class Game {
|
|||||||
|
|
||||||
this.cooldown.paused = true
|
this.cooldown.paused = true
|
||||||
this.io.to(this.gameId).emit("game:cooldownPause", true)
|
this.io.to(this.gameId).emit("game:cooldownPause", true)
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
resumeCooldown(socket: Socket) {
|
resumeCooldown(socket: Socket) {
|
||||||
@@ -351,6 +441,7 @@ class Game {
|
|||||||
|
|
||||||
this.cooldown.paused = false
|
this.cooldown.paused = false
|
||||||
this.io.to(this.gameId).emit("game:cooldownPause", false)
|
this.io.to(this.gameId).emit("game:cooldownPause", false)
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
skipQuestionIntro(socket: Socket) {
|
skipQuestionIntro(socket: Socket) {
|
||||||
@@ -387,6 +478,7 @@ class Game {
|
|||||||
await this.startCooldown(3)
|
await this.startCooldown(3)
|
||||||
|
|
||||||
this.newRound()
|
this.newRound()
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
async newRound() {
|
async newRound() {
|
||||||
@@ -446,6 +538,7 @@ class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.showResults(question)
|
this.showResults(question)
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
showResults(question: any) {
|
showResults(question: any) {
|
||||||
@@ -504,13 +597,14 @@ class Game {
|
|||||||
correct: question.solution,
|
correct: question.solution,
|
||||||
answers: question.answers,
|
answers: question.answers,
|
||||||
image: question.image,
|
image: question.image,
|
||||||
media: question.media,
|
media: question.media,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.leaderboard = sortedPlayers
|
this.leaderboard = sortedPlayers
|
||||||
this.tempOldLeaderboard = oldLeaderboard
|
this.tempOldLeaderboard = oldLeaderboard
|
||||||
|
|
||||||
this.round.playersAnswers = []
|
this.round.playersAnswers = []
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
selectAnswer(socket: Socket, answerId: number) {
|
selectAnswer(socket: Socket, answerId: number) {
|
||||||
const player = this.players.find((player) => player.id === socket.id)
|
const player = this.players.find((player) => player.id === socket.id)
|
||||||
@@ -543,6 +637,7 @@ class Game {
|
|||||||
if (this.round.playersAnswers.length === this.players.length) {
|
if (this.round.playersAnswers.length === this.players.length) {
|
||||||
this.abortCooldown()
|
this.abortCooldown()
|
||||||
}
|
}
|
||||||
|
this.persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
nextRound(socket: Socket) {
|
nextRound(socket: Socket) {
|
||||||
@@ -585,6 +680,7 @@ class Game {
|
|||||||
subject: this.quizz.subject,
|
subject: this.quizz.subject,
|
||||||
top: this.leaderboard.slice(0, 3),
|
top: this.leaderboard.slice(0, 3),
|
||||||
})
|
})
|
||||||
|
this.clearPersisted()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -599,6 +695,7 @@ class Game {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.tempOldLeaderboard = null
|
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 {
|
removeGame(gameId: string): boolean {
|
||||||
|
const game = this.games.find((g) => g.gameId === gameId)
|
||||||
|
void game?.clearPersisted?.()
|
||||||
|
|
||||||
const initialLength = this.games.length
|
const initialLength = this.games.length
|
||||||
this.games = this.games.filter((g) => g.gameId !== gameId)
|
this.games = this.games.filter((g) => g.gameId !== gameId)
|
||||||
this.emptyGames = this.emptyGames.filter((g) => g.game.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 removed = this.emptyGames.filter((g) => !stillEmpty.includes(g))
|
||||||
const removedGameIds = removed.map((r) => r.game.gameId)
|
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.games = this.games.filter((g) => !removedGameIds.includes(g.gameId))
|
||||||
this.emptyGames = stillEmpty
|
this.emptyGames = stillEmpty
|
||||||
|
|||||||
Reference in New Issue
Block a user