refactor: add typescript & pnpm workspace & docker file

This commit is contained in:
Ralex
2025-10-16 23:12:40 +02:00
parent 8f73241f34
commit edb7146d6d
122 changed files with 7568 additions and 8502 deletions

2
packages/socket/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

View File

@@ -0,0 +1,19 @@
import esbuild from "esbuild"
import path from "path"
export const config = {
entryPoints: ["src/index.ts"],
bundle: true,
minify: true,
platform: "node",
outfile: "dist/index.cjs",
sourcemap: true,
define: {
"process.env.NODE_ENV": '"production"',
},
alias: {
"@": path.resolve("./src"),
},
}
esbuild.build(config)

View File

@@ -0,0 +1,24 @@
{
"name": "@rahoot/socket",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "node esbuild.config.js",
"start": "node dist/index.cjs"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@rahoot/common": "workspace:*",
"@t3-oss/env-core": "^0.13.8",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"zod": "^4.1.11"
},
"devDependencies": {
"esbuild": "^0.25.10",
"tsx": "^4.20.6"
}
}

View File

@@ -0,0 +1,14 @@
import { createEnv } from "@t3-oss/env-core"
import { z } from "zod/v4"
const env = createEnv({
server: {
SOCKER_PORT: z.string().optional().default("3001"),
},
runtimeEnv: {
SOCKER_PORT: process.env.SOCKER_PORT,
},
})
export default env

View File

@@ -0,0 +1,159 @@
import { Server, Socket } 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 { inviteCodeValidator } from "@rahoot/socket/utils/validator"
import { Server as ServerIO } from "socket.io"
const io: Server = new ServerIO()
Config.init()
let games: Game[] = []
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("manager:auth", (password) => {
try {
const config = Config.game()
if (password !== config.managerPassword) {
socket.emit("manager:errorMessage", "Invalid password")
return
}
socket.emit("manager:quizzList", Config.quizz())
} catch (error) {
console.error("Failed to read game config:", error)
socket.emit("manager:errorMessage", "Failed to read game config")
}
})
socket.on("game:create", (quizzId) => {
const quizzList = Config.quizz()
const quizz = quizzList.find((q) => q.id === quizzId)
if (!quizz) {
socket.emit("game:errorMessage", "Quizz not found")
return
}
const game = new Game(io, socket, quizz)
games.push(game)
})
socket.on("player:join", async (inviteCode) => {
const result = inviteCodeValidator.safeParse(inviteCode)
if (result.error) {
socket.emit("game:errorMessage", result.error.issues[0].message)
return
}
const game = games.find((g) => g.inviteCode === inviteCode)
if (!game) {
socket.emit("game:errorMessage", "Game not found")
return
}
socket.emit("game:successRoom", game.gameId)
})
socket.on("player:login", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) => game.join(socket, data.username))
)
socket.on("manager:kickPlayer", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) =>
game.kickPlayer(socket, data.playerId)
)
)
socket.on("manager:startGame", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.start(socket))
)
socket.on("player:selectedAnswer", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) =>
game.selectAnswer(socket, data.answerKey)
)
)
socket.on("manager:abortQuiz", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.abortRound(socket))
)
socket.on("manager:nextQuestion", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.nextRound(socket))
)
socket.on("manager:showLeaderboard", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.showLeaderboard(socket))
)
socket.on("disconnect", () => {
console.log(`user disconnected ${socket.id}`)
const managerGame = games.find((g) => g.managerId === socket.id)
if (managerGame) {
console.log("Reset game (manager disconnected)")
managerGame.abortCooldown()
io.to(managerGame.gameId).emit("game:reset")
games = games.filter((g) => g.gameId !== managerGame.gameId)
return
}
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}`
)
}
}
})
})

View File

@@ -0,0 +1,110 @@
import { QuizzWithId } from "@rahoot/common/types/game"
import fs from "fs"
import { resolve } from "path"
const getPath = (path: string) => resolve(process.cwd(), "../../config", path)
class Config {
static init() {
const isGameConfigExists = fs.existsSync(getPath("game.json"))
if (!isGameConfigExists) {
fs.writeFileSync(
getPath("game.json"),
JSON.stringify(
{
managerPassword: "PASSWORD",
music: true,
},
null,
2
)
)
}
const isQuizzExists = fs.existsSync(getPath("quizz"))
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"))
fs.writeFileSync(
getPath("quizz/example.json"),
JSON.stringify(
{
subject: "Example Quizz",
questions: [
{
question: "What is good answer ?",
answers: ["No", "Good answer", "No", "No"],
solution: 1,
cooldown: 5,
time: 15,
},
{
question: "What is good answer with image ?",
answers: ["No", "No", "No", "Good answer"],
image: "https://placehold.co/600x400.png",
solution: 3,
cooldown: 5,
time: 20,
},
{
question: "What is good answer with two answers ?",
answers: ["Good answer", "No"],
image: "https://placehold.co/600x400.png",
solution: 0,
cooldown: 5,
time: 20,
},
],
},
null,
2
)
)
}
}
static game() {
const isExists = fs.existsSync(getPath("game.json"))
if (!isExists) {
throw new Error("Game config not found")
}
try {
const config = fs.readFileSync(getPath("game.json"), "utf-8")
return JSON.parse(config)
} catch (error) {
console.error("Failed to read game config:", error)
}
}
static quizz() {
const isExists = fs.existsSync(getPath("quizz"))
if (!isExists) {
return []
}
try {
const files = fs
.readdirSync(getPath("quizz"))
.filter((file) => file.endsWith(".json"))
const quizz: QuizzWithId[] = files.map((file) => {
const data = fs.readFileSync(getPath(`quizz/${file}`), "utf-8")
const config = JSON.parse(data)
const id = file.replace(".json", "")
return {
id,
...config,
}
})
return quizz || []
} catch (error) {
console.error("Failed to read quizz config:", error)
return []
}
}
}
export default Config

View File

@@ -0,0 +1,384 @@
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 { v4 as uuid } from "uuid"
import sleep from "../utils/sleep"
class Game {
io: Server
gameId: string
managerId: string
inviteCode: string
started: boolean
status: Status
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.managerId = ""
this.inviteCode = ""
this.started = false
this.status = Status.SHOW_START
this.players = []
this.round = {
playersAnswers: [],
currentQuestion: 0,
startTime: 0,
}
this.cooldown = {
active: false,
ms: 0,
}
const roomInvite = createInviteCode()
this.inviteCode = roomInvite
this.managerId = socket.id
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}`
)
}
join(socket: Socket, username: string) {
socket.join(this.gameId)
const playerData = {
id: socket.id,
username: username,
points: 0,
}
this.players.push(playerData)
this.io.to(this.managerId).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) {
return
}
const player = this.players.find((p) => p.id === playerId)
if (!player) {
return
}
this.players = this.players.filter((p) => p.id !== 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.gameId).emit("game:totalPlayers", this.players.length)
}
async startCooldown(seconds: number) {
if (this.cooldown.active) {
return
}
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()
} else {
this.io.to(this.gameId).emit("game:cooldown", count)
count -= 1
}
}, 1000)
})
}
async abortCooldown() {
if (this.cooldown.active) {
this.cooldown.active = false
}
}
async start(socket: Socket) {
if (this.managerId !== socket.id) {
return
}
if (this.started) {
return
}
this.started = true
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_START,
data: {
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.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,
},
})
await sleep(2)
if (!this.started) {
return
}
this.io.to(this.gameId).emit("game:status", {
name: Status.SHOW_QUESTION,
data: {
question: question.question,
image: question.image,
cooldown: question.cooldown,
},
})
await sleep(question.cooldown)
if (!this.started) {
return
}
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,
},
})
await this.startCooldown(question.time)
if (!this.started) {
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
})
const totalType = this.round.playersAnswers.reduce(
(acc: Record<number, number>, { answerId }) => {
acc[answerId] = (acc[answerId] || 0) + 1
return acc
},
{}
)
// 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,
},
})
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) {
return
}
if (this.round.playersAnswers.find((p) => p.playerId === socket.id)) {
return
}
this.round.playersAnswers.push({
playerId: player.id,
answerId,
points: this.timeToPoint(this.round.startTime, question.time),
})
socket.emit("game:status", {
name: Status.WAIT,
data: { 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.managerId) {
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.managerId) {
return
}
this.abortCooldown()
}
showLeaderboard(socket: Socket) {
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),
},
})
return
}
socket.emit("game:status", {
name: Status.SHOW_LEADERBOARD,
data: {
leaderboard: sortedPlayers.slice(0, 5),
},
})
}
}
export default Game

View File

@@ -0,0 +1,14 @@
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

View File

@@ -0,0 +1,4 @@
export const sleep = (sec: number) =>
new Promise((r) => void setTimeout(r, sec * 1000))
export default sleep

View File

@@ -0,0 +1,8 @@
import z from "zod"
export const usernameValidator = z
.string()
.min(4, "Username cannot be less than 4 characters")
.max(20, "Username cannot exceed 20 characters")
export const inviteCodeValidator = z.string().length(6, "Invalid invite code")

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true
}
}