feat(game): enhance game reconnection logic and improve reset messages

This commit is contained in:
Ralex
2025-10-25 16:38:07 +02:00
parent dbefaa0557
commit 74707749aa
11 changed files with 114 additions and 76 deletions

View File

@@ -1,6 +1,7 @@
export type Player = { export type Player = {
id: string id: string
clientId: string clientId: string
connected: boolean
username: string username: string
points: number points: number
} }

View File

@@ -32,7 +32,7 @@ export interface ServerToClientEvents {
"game:startCooldown": () => void "game:startCooldown": () => void
"game:cooldown": (_count: number) => void "game:cooldown": (_count: number) => void
"game:kick": () => void "game:kick": () => void
"game:reset": () => void "game:reset": (_message: string) => void
"game:updateQuestion": (_data: { current: number; total: number }) => void "game:updateQuestion": (_data: { current: number; total: number }) => void
"game:playerAnswer": (_count: number) => void "game:playerAnswer": (_count: number) => void

View File

@@ -25,8 +25,8 @@ 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}`
) )
socket.on("player:reconnect", () => { socket.on("player:reconnect", ({ gameId }) => {
const game = registry.getPlayerGame(socket.handshake.auth.clientId) const game = registry.getPlayerGame(gameId, socket.handshake.auth.clientId)
if (game) { if (game) {
game.reconnect(socket) game.reconnect(socket)
@@ -34,20 +34,19 @@ io.on("connection", (socket) => {
return return
} }
socket.emit("game:reset") socket.emit("game:reset", "Game not found")
}) })
socket.on("manager:reconnect", () => { socket.on("manager:reconnect", ({ gameId }) => {
const game = registry.getManagerGame(socket.handshake.auth.clientId) const game = registry.getManagerGame(gameId, socket.handshake.auth.clientId)
if (game) { if (game) {
game.reconnect(socket) game.reconnect(socket)
registry.reactivateGame(game.gameId)
return return
} }
socket.emit("game:reset") socket.emit("game:reset", "Game expired")
}) })
socket.on("manager:auth", (password) => { socket.on("manager:auth", (password) => {
@@ -132,17 +131,18 @@ io.on("connection", (socket) => {
) )
socket.on("disconnect", () => { socket.on("disconnect", () => {
console.log(`user disconnected ${socket.id}`) console.log(`A user disconnected : ${socket.id}`)
const managerGame = registry.getGameByManagerSocketId(socket.id) const managerGame = registry.getGameByManagerSocketId(socket.id)
if (managerGame) { if (managerGame) {
managerGame.manager.connected = false
registry.markGameAsEmpty(managerGame) registry.markGameAsEmpty(managerGame)
if (!managerGame.started) { if (!managerGame.started) {
console.log("Reset game (manager disconnected)") console.log("Reset game (manager disconnected)")
managerGame.abortCooldown() managerGame.abortCooldown()
io.to(managerGame.gameId).emit("game:reset") io.to(managerGame.gameId).emit("game:reset", "Manager disconnected")
registry.removeGame(managerGame.gameId) registry.removeGame(managerGame.gameId)
return return
@@ -151,7 +151,7 @@ io.on("connection", (socket) => {
const game = registry.getGameByPlayerSocketId(socket.id) const game = registry.getGameByPlayerSocketId(socket.id)
if (!game || game.started) { if (!game) {
return return
} }
@@ -161,12 +161,19 @@ io.on("connection", (socket) => {
return return
} }
if (!game.started) {
game.players = game.players.filter((p) => p.id !== socket.id) game.players = game.players.filter((p) => p.id !== socket.id)
io.to(game.manager.id).emit("manager:removePlayer", player.id) io.to(game.manager.id).emit("manager:removePlayer", player.id)
io.to(game.gameId).emit("game:totalPlayers", game.players.length) io.to(game.gameId).emit("game:totalPlayers", game.players.length)
console.log(`Removed player ${player.username} from game ${game.gameId}`) console.log(`Removed player ${player.username} from game ${game.gameId}`)
return
}
player.connected = false
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
}) })
}) })

View File

@@ -1,10 +1,13 @@
import { Answer, Player, Quizz } from "@rahoot/common/types/game" 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 { 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"
const registry = Registry.getInstance()
class Game { class Game {
io: Server io: Server
@@ -12,6 +15,7 @@ class Game {
manager: { manager: {
id: string id: string
clientId: string clientId: string
connected: boolean
} }
inviteCode: string inviteCode: string
started: boolean started: boolean
@@ -46,6 +50,7 @@ class Game {
this.manager = { this.manager = {
id: "", id: "",
clientId: "", clientId: "",
connected: false,
} }
this.inviteCode = "" this.inviteCode = ""
this.started = false this.started = false
@@ -72,6 +77,7 @@ class Game {
this.manager = { this.manager = {
id: socket.id, id: socket.id,
clientId: socket.handshake.auth.clientId, clientId: socket.handshake.auth.clientId,
connected: true,
} }
this.quizz = quizz this.quizz = quizz
@@ -109,11 +115,22 @@ class Game {
} }
join(socket: Socket, username: string) { join(socket: Socket, username: string) {
const isAlreadyConnected = this.players.find(
(p) => p.clientId === socket.handshake.auth.clientId
)
if (isAlreadyConnected) {
socket.emit("game:errorMessage", "Player already connected")
return
}
socket.join(this.gameId) socket.join(this.gameId)
const playerData = { const playerData = {
id: socket.id, id: socket.id,
clientId: socket.handshake.auth.clientId, clientId: socket.handshake.auth.clientId,
connected: true,
username, username,
points: 0, points: 0,
} }
@@ -151,27 +168,23 @@ class Game {
const { clientId } = socket.handshake.auth const { clientId } = socket.handshake.auth
const isManager = this.manager.clientId === clientId const isManager = this.manager.clientId === clientId
if (!isManager) { if (isManager) {
const player = this.players.find((p) => p.clientId === clientId) this.reconnectManager(socket)
} else {
if (!player) { this.reconnectPlayer(socket)
return false
} }
} }
private reconnectManager(socket: Socket) {
if (this.manager.connected) {
socket.emit("game:reset", "Manager already connected")
return
}
socket.join(this.gameId) socket.join(this.gameId)
const commonData = {
gameId: this.gameId,
started: this.started,
currentQuestion: {
current: this.round.currentQuestion,
total: this.quizz.questions.length,
},
}
if (isManager) {
this.manager.id = socket.id this.manager.id = socket.id
this.manager.connected = true
const status = this.managerStatus || const status = this.managerStatus ||
this.lastBroadcastStatus || { this.lastBroadcastStatus || {
@@ -180,20 +193,39 @@ class Game {
} }
socket.emit("manager:successReconnect", { socket.emit("manager:successReconnect", {
...commonData, gameId: this.gameId,
currentQuestion: {
current: this.round.currentQuestion,
total: this.quizz.questions.length,
},
status, status,
players: this.players, players: this.players,
}) })
socket.emit("game:totalPlayers", this.players.length) socket.emit("game:totalPlayers", this.players.length)
registry.reactivateGame(this.gameId)
console.log(`Manager reconnected to game ${this.inviteCode}`) console.log(`Manager reconnected to game ${this.inviteCode}`)
return true
} }
const player = this.players.find((p) => p.clientId === clientId)! private reconnectPlayer(socket: Socket) {
const { clientId } = socket.handshake.auth
const player = this.players.find((p) => p.clientId === clientId)
if (!player) {
return
}
if (player.connected) {
socket.emit("game:reset", "Player already connected")
return
}
socket.join(this.gameId)
const oldSocketId = player.id const oldSocketId = player.id
player.id = socket.id player.id = socket.id
player.connected = true
const status = this.playerStatus.get(oldSocketId) || const status = this.playerStatus.get(oldSocketId) ||
this.lastBroadcastStatus || { this.lastBroadcastStatus || {
@@ -208,7 +240,11 @@ class Game {
} }
socket.emit("player:successReconnect", { socket.emit("player:successReconnect", {
...commonData, gameId: this.gameId,
currentQuestion: {
current: this.round.currentQuestion,
total: this.quizz.questions.length,
},
status, status,
player: { player: {
username: player.username, username: player.username,
@@ -219,8 +255,6 @@ class Game {
console.log( console.log(
`Player ${player.username} reconnected to game ${this.inviteCode}` `Player ${player.username} reconnected to game ${this.inviteCode}`
) )
return true
} }
startCooldown(seconds: number): Promise<void> { startCooldown(seconds: number): Promise<void> {

View File

@@ -37,14 +37,17 @@ class Registry {
return this.games.find((g) => g.inviteCode === inviteCode) return this.games.find((g) => g.inviteCode === inviteCode)
} }
getPlayerGame(clientId: string): Game | undefined { getPlayerGame(gameId: string, clientId: string): Game | undefined {
return this.games.find((g) => return this.games.find(
g.players.some((p) => p.clientId === clientId) (g) =>
g.gameId === gameId && g.players.some((p) => p.clientId === clientId)
) )
} }
getManagerGame(clientId: string): Game | undefined { getManagerGame(gmageId: string, clientId: string): Game | undefined {
return this.games.find((g) => g.manager.clientId === clientId) return this.games.find(
(g) => g.gameId === gmageId && g.manager.clientId === clientId
)
} }
getGameByManagerSocketId(socketId: string): Game | undefined { getGameByManagerSocketId(socketId: string): Game | undefined {

View File

@@ -49,10 +49,11 @@ const Game = () => {
reset() reset()
}) })
useEvent("game:reset", () => { useEvent("game:reset", (message) => {
router.replace("/") router.replace("/")
reset() reset()
toast("The game has been reset by the host") setQuestionStates(null)
toast.error(message)
}) })
if (!gameIdParam) { if (!gameIdParam) {
@@ -61,7 +62,7 @@ const Game = () => {
let component = null let component = null
switch (status.name) { switch (status?.name) {
case STATUS.WAIT: case STATUS.WAIT:
component = <Wait data={status.data} /> component = <Wait data={status.data} />
@@ -93,7 +94,7 @@ const Game = () => {
break break
} }
return <GameWrapper statusName={status.name}>{component}</GameWrapper> return <GameWrapper statusName={status?.name}>{component}</GameWrapper>
} }
export default Game export default Game

View File

@@ -47,10 +47,11 @@ const ManagerGame = () => {
}, },
) )
useEvent("game:reset", () => { useEvent("game:reset", (message) => {
router.replace("/manager") router.replace("/manager")
reset() reset()
toast("Game is not available anymore") setQuestionStates(null)
toast.error(message)
}) })
const handleSkip = () => { const handleSkip = () => {
@@ -58,7 +59,7 @@ const ManagerGame = () => {
return return
} }
switch (status.name) { switch (status?.name) {
case STATUS.SHOW_ROOM: case STATUS.SHOW_ROOM:
socket?.emit("manager:startGame", { gameId }) socket?.emit("manager:startGame", { gameId })
@@ -83,7 +84,7 @@ const ManagerGame = () => {
let component = null let component = null
switch (status.name) { switch (status?.name) {
case STATUS.SHOW_ROOM: case STATUS.SHOW_ROOM:
component = <Room data={status.data} /> component = <Room data={status.data} />
@@ -126,7 +127,7 @@ const ManagerGame = () => {
} }
return ( return (
<GameWrapper statusName={status.name} onNext={handleSkip} manager> <GameWrapper statusName={status?.name} onNext={handleSkip} manager>
{component} {component}
</GameWrapper> </GameWrapper>
) )

View File

@@ -12,7 +12,7 @@ import Image from "next/image"
import { PropsWithChildren } from "react" import { PropsWithChildren } from "react"
type Props = PropsWithChildren & { type Props = PropsWithChildren & {
statusName: Status statusName: Status | undefined
onNext?: () => void onNext?: () => void
manager?: boolean manager?: boolean
} }
@@ -21,7 +21,7 @@ const GameWrapper = ({ children, statusName, onNext, manager }: Props) => {
const { isConnected } = useSocket() const { isConnected } = useSocket()
const { player } = usePlayerStore() const { player } = usePlayerStore()
const { questionStates, setQuestionStates } = useQuestionStore() const { questionStates, setQuestionStates } = useQuestionStore()
const next = MANAGER_SKIP_BTN[statusName] || null const next = statusName ? MANAGER_SKIP_BTN[statusName] : null
useEvent("game:updateQuestion", ({ current, total }) => { useEvent("game:updateQuestion", ({ current, total }) => {
setQuestionStates({ setQuestionStates({

View File

@@ -5,7 +5,7 @@ import { create } from "zustand"
type ManagerStore<T> = { type ManagerStore<T> = {
gameId: string | null gameId: string | null
status: Status<T> status: Status<T> | null
players: Player[] players: Player[]
setGameId: (_gameId: string | null) => void setGameId: (_gameId: string | null) => void
@@ -16,13 +16,9 @@ type ManagerStore<T> = {
reset: () => void reset: () => void
} }
const initialStatus = createStatus<StatusDataMap, "SHOW_ROOM">("SHOW_ROOM", {
text: "Waiting for the players",
})
const initialState = { const initialState = {
gameId: null, gameId: null,
status: initialStatus, status: null,
players: [], players: [],
} }
@@ -32,7 +28,7 @@ export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
setGameId: (gameId) => set({ gameId }), setGameId: (gameId) => set({ gameId }),
setStatus: (name, data) => set({ status: createStatus(name, data) }), setStatus: (name, data) => set({ status: createStatus(name, data) }),
resetStatus: () => set({ status: initialStatus }), resetStatus: () => set({ status: null }),
setPlayers: (players) => set({ players }), setPlayers: (players) => set({ players }),

View File

@@ -10,7 +10,7 @@ type PlayerState = {
type PlayerStore<T> = { type PlayerStore<T> = {
gameId: string | null gameId: string | null
player: PlayerState | null player: PlayerState | null
status: Status<T> status: Status<T> | null
setGameId: (_gameId: string | null) => void setGameId: (_gameId: string | null) => void
@@ -24,14 +24,10 @@ type PlayerStore<T> = {
reset: () => void reset: () => void
} }
const initialStatus = createStatus<StatusDataMap, "WAIT">("WAIT", {
text: "Waiting for the players",
})
const initialState = { const initialState = {
gameId: null, gameId: null,
player: null, player: null,
status: initialStatus, status: null,
} }
export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({ export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({

View File

@@ -3,11 +3,10 @@ import { create } from "zustand"
type QuestionStore = { type QuestionStore = {
questionStates: GameUpdateQuestion | null questionStates: GameUpdateQuestion | null
setQuestionStates: (_state: GameUpdateQuestion) => void setQuestionStates: (_state: GameUpdateQuestion | null) => void
} }
export const useQuestionStore = create<QuestionStore>((set) => ({ export const useQuestionStore = create<QuestionStore>((set) => ({
questionStates: null, questionStates: null,
setQuestionStates: (state: GameUpdateQuestion) => setQuestionStates: (state) => set({ questionStates: state }),
set({ questionStates: state }),
})) }))