Files
Rahoot/packages/socket/src/services/game.ts
2025-10-26 18:30:49 +01:00

530 lines
12 KiB
TypeScript

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 { createInviteCode, timeToPoint } from "@rahoot/socket/utils/game"
import sleep from "@rahoot/socket/utils/sleep"
import { v4 as uuid } from "uuid"
const registry = Registry.getInstance()
class Game {
io: Server
gameId: string
manager: {
id: string
clientId: string
connected: boolean
}
inviteCode: string
started: boolean
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()
leaderboard: Player[]
tempOldLeaderboard: Player[] | null
quizz: Quizz
players: Player[]
round: {
currentQuestion: number
playersAnswers: Answer[]
startTime: number
}
cooldown: {
active: boolean
ms: number
}
constructor(io: Server, socket: Socket, quizz: Quizz) {
if (!io) {
throw new Error("Socket server not initialized")
}
this.io = io
this.gameId = uuid()
this.manager = {
id: "",
clientId: "",
connected: false,
}
this.inviteCode = ""
this.started = false
this.lastBroadcastStatus = null
this.managerStatus = null
this.playerStatus = new Map()
this.leaderboard = []
this.tempOldLeaderboard = null
this.players = []
this.round = {
playersAnswers: [],
currentQuestion: 0,
startTime: 0,
}
this.cooldown = {
active: false,
ms: 0,
}
const roomInvite = createInviteCode()
this.inviteCode = roomInvite
this.manager = {
id: socket.id,
clientId: socket.handshake.auth.clientId,
connected: true,
}
this.quizz = quizz
socket.join(this.gameId)
socket.emit("manager:gameCreated", {
gameId: this.gameId,
inviteCode: roomInvite,
})
console.log(
`New game created: ${roomInvite} subject: ${this.quizz.subject}`
)
}
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) {
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)
const playerData = {
id: socket.id,
clientId: socket.handshake.auth.clientId,
connected: true,
username,
points: 0,
}
this.players.push(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.manager.id !== socket.id) {
return
}
const player = this.players.find((p) => p.id === playerId)
if (!player) {
return
}
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:reset", "You have been kicked by the manager")
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
const isManager = this.manager.clientId === clientId
if (isManager) {
this.reconnectManager(socket)
} else {
this.reconnectPlayer(socket)
}
}
private reconnectManager(socket: Socket) {
if (this.manager.connected) {
socket.emit("game:reset", "Manager already connected")
return
}
socket.join(this.gameId)
this.manager.id = socket.id
this.manager.connected = true
const status = this.managerStatus ||
this.lastBroadcastStatus || {
name: STATUS.WAIT,
data: { text: "Waiting for players" },
}
socket.emit("manager:successReconnect", {
gameId: this.gameId,
currentQuestion: {
current: this.round.currentQuestion,
total: this.quizz.questions.length,
},
status,
players: this.players,
})
socket.emit("game:totalPlayers", this.players.length)
registry.reactivateGame(this.gameId)
console.log(`Manager reconnected to game ${this.inviteCode}`)
}
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
player.id = socket.id
player.connected = true
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", {
gameId: this.gameId,
currentQuestion: {
current: this.round.currentQuestion,
total: this.quizz.questions.length,
},
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}`
)
}
startCooldown(seconds: number): Promise<void> {
if (this.cooldown.active) {
return Promise.resolve()
}
this.cooldown.active = true
let count = seconds - 1
return new Promise<void>((resolve) => {
const cooldownTimeout = setInterval(() => {
if (!this.cooldown.active || count <= 0) {
this.cooldown.active = false
clearInterval(cooldownTimeout)
resolve()
return
}
this.io.to(this.gameId).emit("game:cooldown", count)
count -= 1
}, 1000)
})
}
abortCooldown() {
this.cooldown.active &&= false
}
async start(socket: Socket) {
if (this.manager.id !== socket.id) {
return
}
if (this.started) {
return
}
this.started = true
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()
}
async newRound() {
const question = this.quizz.questions[this.round.currentQuestion]
if (!this.started) {
return
}
this.playerStatus.clear()
this.io.to(this.gameId).emit("game:updateQuestion", {
current: this.round.currentQuestion + 1,
total: this.quizz.questions.length,
})
this.broadcastStatus(STATUS.SHOW_PREPARED, {
totalAnswers: question.answers.length,
questionNumber: this.round.currentQuestion + 1,
})
await sleep(2)
if (!this.started) {
return
}
this.broadcastStatus(STATUS.SHOW_QUESTION, {
question: question.question,
image: question.image,
cooldown: question.cooldown,
})
await sleep(question.cooldown)
if (!this.started) {
return
}
this.round.startTime = Date.now()
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)
if (!this.started) {
return
}
await this.showResults(question)
}
showResults(question: any) {
const oldLeaderboard =
this.leaderboard.length === 0
? this.players.map((p) => ({ ...p }))
: this.leaderboard.map((p) => ({ ...p }))
const totalType = this.round.playersAnswers.reduce(
(acc: Record<number, number>, { answerId }) => {
acc[answerId] = (acc[answerId] || 0) + 1
return acc
},
{}
)
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.leaderboard = sortedPlayers
this.tempOldLeaderboard = oldLeaderboard
this.round.playersAnswers = []
}
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) {
return
}
if (this.round.playersAnswers.find((p) => p.playerId === socket.id)) {
return
}
this.round.playersAnswers.push({
playerId: player.id,
answerId,
points: timeToPoint(this.round.startTime, question.time),
})
this.sendStatus(socket.id, STATUS.WAIT, {
text: "Waiting for the players to answer",
})
socket
.to(this.gameId)
.emit("game:playerAnswer", this.round.playersAnswers.length)
this.io.to(this.gameId).emit("game:totalPlayers", this.players.length)
if (this.round.playersAnswers.length === this.players.length) {
this.abortCooldown()
}
}
nextRound(socket: Socket) {
if (!this.started) {
return
}
if (socket.id !== this.manager.id) {
return
}
if (!this.quizz.questions[this.round.currentQuestion + 1]) {
return
}
this.round.currentQuestion += 1
this.newRound()
}
abortRound(socket: Socket) {
if (!this.started) {
return
}
if (socket.id !== this.manager.id) {
return
}
this.abortCooldown()
}
showLeaderboard() {
const isLastRound =
this.round.currentQuestion + 1 === this.quizz.questions.length
if (isLastRound) {
this.started = false
this.broadcastStatus(STATUS.FINISHED, {
subject: this.quizz.subject,
top: this.leaderboard.slice(0, 3),
})
return
}
const oldLeaderboard = this.tempOldLeaderboard
? this.tempOldLeaderboard
: this.leaderboard
this.sendStatus(this.manager.id, STATUS.SHOW_LEADERBOARD, {
oldLeaderboard: oldLeaderboard.slice(0, 5),
leaderboard: this.leaderboard.slice(0, 5),
})
this.tempOldLeaderboard = null
}
}
export default Game