feat(reconnect): add reconnect for player & manager

This commit is contained in:
Ralex
2025-10-18 18:20:03 +02:00
parent edb7146d6d
commit 8bdb8f47ef
22 changed files with 593 additions and 276 deletions

View File

@@ -1,7 +1,12 @@
import { Server, Socket } from "@rahoot/common/types/game/socket"
import { Server } from "@rahoot/common/types/game/socket"
import env from "@rahoot/socket/env"
import Config from "@rahoot/socket/services/config"
import Game from "@rahoot/socket/services/game"
import {
findManagerGameByClientId,
findPlayerGameByClientId,
withGame,
} from "@rahoot/socket/utils/game"
import { inviteCodeValidator } from "@rahoot/socket/utils/validator"
import { Server as ServerIO } from "socket.io"
@@ -14,35 +19,37 @@ const port = env.SOCKER_PORT || 3001
console.log(`Socket server running on port ${port}`)
io.listen(Number(port))
function withGame<T>(
gameId: string | undefined,
socket: Socket,
games: Game[],
handler: (game: Game) => T
): T | void {
let game = null
if (gameId) {
game = games.find((g) => g.gameId === gameId)
} else {
game = games.find(
(g) =>
g.players.find((p) => p.id === socket.id) || g.managerId === socket.id
)
}
if (!game) {
socket.emit("game:errorMessage", "Game not found")
return
}
return handler(game)
}
io.on("connection", (socket) => {
console.log(`A user connected ${socket.id}`)
console.log(socket.handshake.auth)
socket.on("player:reconnect", () => {
const game = findPlayerGameByClientId(socket.handshake.auth.clientId, games)
if (game) {
game.reconnect(socket)
return
}
socket.emit("game:reset")
})
socket.on("manager:reconnect", () => {
const game = findManagerGameByClientId(
socket.handshake.auth.clientId,
games
)
if (game) {
game.reconnect(socket)
return
}
socket.emit("game:reset")
})
socket.on("manager:auth", (password) => {
try {
const config = Config.game()
@@ -121,14 +128,14 @@ io.on("connection", (socket) => {
)
socket.on("manager:showLeaderboard", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.showLeaderboard(socket))
withGame(gameId, socket, games, (game) => game.showLeaderboard())
)
socket.on("disconnect", () => {
console.log(`user disconnected ${socket.id}`)
const managerGame = games.find((g) => g.managerId === socket.id)
const managerGame = games.find((g) => g.manager.id === socket.id)
if (managerGame) {
if (managerGame && !managerGame.started) {
console.log("Reset game (manager disconnected)")
managerGame.abortCooldown()
@@ -141,19 +148,21 @@ io.on("connection", (socket) => {
const game = games.find((g) => g.players.some((p) => p.id === socket.id))
if (game) {
const player = game.players.find((p) => p.id === socket.id)
if (player) {
game.players = game.players.filter((p) => p.id !== socket.id)
io.to(game.managerId).emit("manager:removePlayer", player.id)
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
console.log(
`Removed player ${player.username} from game ${game.gameId}`
)
}
if (!game || game.started) {
return
}
const player = game.players.find((p) => p.id === socket.id)
if (!player) {
return
}
game.players = game.players.filter((p) => p.id !== socket.id)
io.to(game.manager.id).emit("manager:removePlayer", player.id)
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
console.log(`Removed player ${player.username} from game ${game.gameId}`)
})
})

View File

@@ -1,18 +1,27 @@
import { Answer, Player, Quizz } from "@rahoot/common/types/game"
import { Server, Socket } from "@rahoot/common/types/game/socket"
import { Status } from "@rahoot/common/types/game/status"
import createInviteCode from "@rahoot/socket/utils/inviteCode"
import { Status, StatusDataMap } from "@rahoot/common/types/game/status"
import { createInviteCode, timeToPoint } from "@rahoot/socket/utils/game"
import sleep from "@rahoot/socket/utils/sleep"
import { v4 as uuid } from "uuid"
import sleep from "../utils/sleep"
class Game {
io: Server
gameId: string
managerId: string
manager: {
id: string
clientId: string
}
inviteCode: string
started: boolean
status: Status
lastBroadcastStatus: { name: Status; data: StatusDataMap[Status] } | null =
null
managerStatus: { name: Status; data: StatusDataMap[Status] } | null = null
playerStatus: Map<string, { name: Status; data: StatusDataMap[Status] }> =
new Map()
quizz: Quizz
players: Player[]
@@ -34,10 +43,17 @@ class Game {
this.io = io
this.gameId = uuid()
this.managerId = ""
this.manager = {
id: "",
clientId: "",
}
this.inviteCode = ""
this.started = false
this.status = Status.SHOW_START
this.lastBroadcastStatus = null
this.managerStatus = null
this.playerStatus = new Map()
this.players = []
this.round = {
@@ -53,7 +69,10 @@ class Game {
const roomInvite = createInviteCode()
this.inviteCode = roomInvite
this.managerId = socket.id
this.manager = {
id: socket.id,
clientId: socket.handshake.auth.clientId,
}
this.quizz = quizz
socket.join(this.gameId)
@@ -67,25 +86,48 @@ class 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)
}
sendStatus<T extends Status>(
target: string,
status: T,
data: StatusDataMap[T]
) {
const statusData = { name: status, data }
if (this.manager.id === target) {
this.managerStatus = statusData
} else {
this.playerStatus.set(target, statusData)
}
this.io.to(target).emit("game:status", statusData)
}
join(socket: Socket, username: string) {
socket.join(this.gameId)
const playerData = {
id: socket.id,
clientId: socket.handshake.auth.clientId,
username: username,
points: 0,
}
this.players.push(playerData)
this.io.to(this.managerId).emit("manager:newPlayer", playerData)
this.io.to(this.manager.id).emit("manager:newPlayer", playerData)
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
socket.emit("game:successJoin", this.gameId)
}
kickPlayer(socket: Socket, playerId: string) {
if (this.managerId !== socket.id) {
if (this.manager.id !== socket.id) {
return
}
@@ -96,21 +138,97 @@ class Game {
}
this.players = this.players.filter((p) => p.id !== playerId)
this.playerStatus.delete(playerId)
this.io.in(playerId).socketsLeave(this.gameId)
this.io.to(player.id).emit("game:kick")
this.io.to(this.managerId).emit("manager:playerKicked", player.id)
this.io.to(this.manager.id).emit("manager:playerKicked", player.id)
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
}
reconnect(socket: Socket) {
const clientId = socket.handshake.auth.clientId
const isManager = this.manager.clientId === clientId
if (!isManager) {
const player = this.players.find((p) => p.clientId === clientId)
if (!player) {
return false
}
}
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
const status = this.managerStatus ||
this.lastBroadcastStatus || {
name: Status.WAIT,
data: { text: "Waiting for players" },
}
socket.emit("manager:successReconnect", {
...commonData,
status,
players: this.players,
})
socket.emit("game:totalPlayers", this.players.length)
console.log(`Manager reconnected to game ${this.inviteCode}`)
return
}
const player = this.players.find((p) => p.clientId === clientId)!
const oldSocketId = player.id
player.id = socket.id
const status = this.playerStatus.get(oldSocketId) ||
this.lastBroadcastStatus || {
name: Status.WAIT,
data: { text: "Waiting for players" },
}
if (this.playerStatus.has(oldSocketId)) {
const oldStatus = this.playerStatus.get(oldSocketId)!
this.playerStatus.delete(oldSocketId)
this.playerStatus.set(socket.id, oldStatus)
}
socket.emit("player:successReconnect", {
...commonData,
status,
player: {
username: player.username,
points: player.points,
},
})
socket.emit("game:totalPlayers", this.players.length)
console.log(
`Player ${player.username} reconnected to game ${this.inviteCode}`
)
return true
}
async startCooldown(seconds: number) {
if (this.cooldown.active) {
return
}
this.cooldown.active = true
let count = seconds - 1
return new Promise<void>((resolve) => {
@@ -119,10 +237,11 @@ class Game {
this.cooldown.active = false
clearInterval(cooldownTimeout)
resolve()
} else {
this.io.to(this.gameId).emit("game:cooldown", count)
count -= 1
return
}
this.io.to(this.gameId).emit("game:cooldown", count)
count -= 1
}, 1000)
})
}
@@ -134,7 +253,7 @@ class Game {
}
async start(socket: Socket) {
if (this.managerId !== socket.id) {
if (this.manager.id !== socket.id) {
return
}
@@ -143,18 +262,15 @@ class Game {
}
this.started = true
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_START,
data: {
time: 3,
subject: this.quizz.subject,
},
this.broadcastStatus(Status.SHOW_START, {
time: 3,
subject: this.quizz.subject,
})
await sleep(3)
this.io.to(this.gameId).emit("game:startCooldown")
await this.startCooldown(3)
this.newRound()
@@ -167,17 +283,16 @@ class Game {
return
}
this.playerStatus.clear()
this.io.to(this.gameId).emit("game:updateQuestion", {
current: this.round.currentQuestion + 1,
total: this.quizz.questions.length,
})
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_PREPARED,
data: {
totalAnswers: question.answers.length,
questionNumber: this.round.currentQuestion + 1,
},
this.broadcastStatus(Status.SHOW_PREPARED, {
totalAnswers: question.answers.length,
questionNumber: this.round.currentQuestion + 1,
})
await sleep(2)
@@ -186,13 +301,10 @@ class Game {
return
}
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_QUESTION,
data: {
question: question.question,
image: question.image,
cooldown: question.cooldown,
},
this.broadcastStatus(Status.SHOW_QUESTION, {
question: question.question,
image: question.image,
cooldown: question.cooldown,
})
await sleep(question.cooldown)
@@ -203,15 +315,12 @@ class Game {
this.round.startTime = Date.now()
this.io.to(this.gameId).emit("game:status", {
name: Status.SELECT_ANSWER,
data: {
question: question.question,
answers: question.answers,
image: question.image,
time: question.time,
totalPlayer: this.players.length,
},
this.broadcastStatus(Status.SELECT_ANSWER, {
question: question.question,
answers: question.answers,
image: question.image,
time: question.time,
totalPlayer: this.players.length,
})
await this.startCooldown(question.time)
@@ -220,42 +329,10 @@ class Game {
return
}
this.players = this.players.map((player) => {
const playerAnswer = this.round.playersAnswers.find(
(a) => a.playerId === player.id
)
const isCorrect = playerAnswer
? playerAnswer.answerId === question.solution
: false
const points =
playerAnswer && isCorrect
? Math.round(playerAnswer && playerAnswer.points)
: 0
player.points += points
const sortPlayers = this.players.sort((a, b) => b.points - a.points)
const rank = sortPlayers.findIndex((p) => p.id === player.id) + 1
const aheadPlayer = sortPlayers[rank - 2]
this.io.to(player.id).emit("game:status", {
name: Status.SHOW_RESULT,
data: {
correct: isCorrect,
message: isCorrect ? "Nice !" : "Too bad",
points,
myPoints: player.points,
rank,
aheadOfMe: aheadPlayer ? aheadPlayer.username : null,
},
})
return player
})
await this.showResults(question)
}
async showResults(question: any) {
const totalType = this.round.playersAnswers.reduce(
(acc: Record<number, number>, { answerId }) => {
acc[answerId] = (acc[answerId] || 0) + 1
@@ -264,36 +341,54 @@ class Game {
{}
)
// Manager
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_RESPONSES,
data: {
question: question.question,
responses: totalType,
correct: question.solution,
answers: question.answers,
image: question.image,
},
const sortedPlayers = this.players
.map((player) => {
const playerAnswer = this.round.playersAnswers.find(
(a) => a.playerId === player.id
)
const isCorrect = playerAnswer
? playerAnswer.answerId === question.solution
: false
const points =
playerAnswer && isCorrect ? Math.round(playerAnswer.points) : 0
player.points += points
return { ...player, lastCorrect: isCorrect, lastPoints: points }
})
.sort((a, b) => b.points - a.points)
this.players = sortedPlayers
sortedPlayers.forEach((player, index) => {
const rank = index + 1
const aheadPlayer = sortedPlayers[index - 1]
this.sendStatus(player.id, Status.SHOW_RESULT, {
correct: player.lastCorrect,
message: player.lastCorrect ? "Nice!" : "Too bad",
points: player.lastPoints,
myPoints: player.points,
rank,
aheadOfMe: aheadPlayer ? aheadPlayer.username : null,
})
})
this.sendStatus(this.manager.id, Status.SHOW_RESPONSES, {
question: question.question,
responses: totalType,
correct: question.solution,
answers: question.answers,
image: question.image,
})
this.round.playersAnswers = []
}
timeToPoint(startTime: number, secondes: number) {
let points = 1000
const actualTime = Date.now()
const tempsPasseEnSecondes = (actualTime - startTime) / 1000
points -= (1000 / secondes) * tempsPasseEnSecondes
points = Math.max(0, points)
return points
}
async selectAnswer(socket: Socket, answerId: number) {
const player = this.players.find((player) => player.id === socket.id)
const question = this.quizz.questions[this.round.currentQuestion]
if (!player) {
@@ -307,13 +402,13 @@ class Game {
this.round.playersAnswers.push({
playerId: player.id,
answerId,
points: this.timeToPoint(this.round.startTime, question.time),
points: timeToPoint(this.round.startTime, question.time),
})
socket.emit("game:status", {
name: Status.WAIT,
data: { text: "Waiting for the players to answer" },
this.sendStatus(socket.id, Status.WAIT, {
text: "Waiting for the players to answer",
})
socket
.to(this.gameId)
.emit("game:playerAnswer", this.round.playersAnswers.length)
@@ -330,7 +425,7 @@ class Game {
return
}
if (socket.id !== this.managerId) {
if (socket.id !== this.manager.id) {
return
}
@@ -347,36 +442,32 @@ class Game {
return
}
if (socket.id !== this.managerId) {
if (socket.id !== this.manager.id) {
return
}
this.abortCooldown()
}
showLeaderboard(socket: Socket) {
showLeaderboard() {
const isLastRound =
this.round.currentQuestion + 1 === this.quizz.questions.length
const sortedPlayers = this.players.sort((a, b) => b.points - a.points)
if (isLastRound) {
socket.emit("game:status", {
name: Status.FINISHED,
data: {
subject: this.quizz.subject,
top: sortedPlayers.slice(0, 3),
},
this.started = false
this.broadcastStatus(Status.FINISHED, {
subject: this.quizz.subject,
top: sortedPlayers.slice(0, 3),
})
return
}
socket.emit("game:status", {
name: Status.SHOW_LEADERBOARD,
data: {
leaderboard: sortedPlayers.slice(0, 5),
},
this.sendStatus(this.manager.id, Status.SHOW_LEADERBOARD, {
leaderboard: sortedPlayers.slice(0, 5),
})
}
}

View File

@@ -0,0 +1,74 @@
import { Socket } from "@rahoot/common/types/game/socket"
import Game from "@rahoot/socket/services/game"
export const withGame = <T>(
gameId: string | undefined,
socket: Socket,
games: Game[],
handler: (game: Game) => T
): T | void => {
let game = null
if (gameId) {
game = games.find((g) => g.gameId === gameId)
} else {
game = games.find(
(g) =>
g.players.find((p) => p.id === socket.id) || g.manager.id === socket.id
)
}
if (!game) {
socket.emit("game:errorMessage", "Game not found")
return
}
return handler(game)
}
export const createInviteCode = (length = 6) => {
let result = ""
const characters = "0123456789"
const charactersLength = characters.length
for (let i = 0; i < length; i += 1) {
const randomIndex = Math.floor(Math.random() * charactersLength)
result += characters.charAt(randomIndex)
}
return result
}
export const timeToPoint = (startTime: number, secondes: number): number => {
let points = 1000
const actualTime = Date.now()
const tempsPasseEnSecondes = (actualTime - startTime) / 1000
points -= (1000 / secondes) * tempsPasseEnSecondes
points = Math.max(0, points)
return points
}
export const findPlayerGameByClientId = (clientId: string, games: Game[]) => {
const playerGame = games.find((g) =>
g.players.find((p) => p.clientId === clientId)
)
if (playerGame) {
return playerGame
}
return null
}
export const findManagerGameByClientId = (clientId: string, games: Game[]) => {
const managerGame = games.find((g) => g.manager.clientId === clientId)
if (managerGame) {
return managerGame
}
return null
}

View File

@@ -1,14 +0,0 @@
const createInviteCode = (length = 6) => {
let result = ""
const characters = "0123456789"
const charactersLength = characters.length
for (let i = 0; i < length; i += 1) {
const randomIndex = Math.floor(Math.random() * charactersLength)
result += characters.charAt(randomIndex)
}
return result
}
export default createInviteCode