mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
534 lines
12 KiB
TypeScript
534 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 + 1,
|
|
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 + 1,
|
|
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.managerStatus = null
|
|
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,
|
|
media: question.media,
|
|
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,
|
|
media: question.media,
|
|
time: question.time,
|
|
totalPlayer: this.players.length,
|
|
})
|
|
|
|
await this.startCooldown(question.time)
|
|
|
|
if (!this.started) {
|
|
return
|
|
}
|
|
|
|
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,
|
|
media: question.media,
|
|
})
|
|
|
|
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
|