adding persistance when client losing connections (adding redis db)

This commit is contained in:
RandyJC
2025-12-08 22:37:44 +01:00
parent 7129ec6984
commit befe39d2fd
5 changed files with 187 additions and 11 deletions

View File

@@ -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"

View File

@@ -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
}
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,8 +85,16 @@ io.on("connection", (socket) => {
return
}
ensureGame(gameId).then((restored) => {
if (restored) {
restored.reconnect(socket)
return
}
socket.emit("game:reset", "Game expired")
})
})
socket.on("manager:auth", (password) => {
try {

View File

@@ -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) {
@@ -511,6 +604,7 @@ class Game {
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()
}
}

View 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}`)
}

View File

@@ -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