Initial clean state

This commit is contained in:
RandyJC
2025-12-09 08:55:01 +01:00
commit 497dd2ea4c
115 changed files with 12391 additions and 0 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,208 @@
import js from "@eslint/js"
import { defineConfig } from "eslint/config"
import globals from "globals"
import tseslint from "typescript-eslint"
export default defineConfig([
{
ignores: ["**/node_modules/**", "**/dist/**"],
},
{
files: ["**/*.ts"],
languageOptions: {
...js.configs.recommended.languageOptions,
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
globals: {
...globals.node,
},
},
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
...js.configs.recommended.rules,
...tseslint.configs.recommendedTypeChecked[0].rules,
"array-callback-return": [
"error",
{ allowImplicit: false, checkForEach: true, allowVoid: true },
],
"no-await-in-loop": "error",
"no-constant-binary-expression": "error",
"no-constructor-return": "error",
"no-duplicate-imports": ["error", { includeExports: true }],
"no-new-native-nonconstructor": "error",
"no-promise-executor-return": ["error", { allowVoid: true }],
"no-self-compare": "error",
"no-template-curly-in-string": "error",
"no-unmodified-loop-condition": "error",
"no-unreachable-loop": "error",
"no-unused-private-class-members": "error",
"arrow-body-style": ["error", "as-needed"],
camelcase: [
"error",
{
properties: "always",
ignoreDestructuring: true,
ignoreImports: true,
ignoreGlobals: true,
},
],
"capitalized-comments": [
"error",
"always",
{ ignoreConsecutiveComments: true },
],
"class-methods-use-this": ["error", { enforceForClassFields: true }],
complexity: ["warn", 40],
"consistent-return": "error",
curly: ["error", "all"],
"default-param-last": "error",
"dot-notation": "error",
eqeqeq: ["error", "always"],
"func-name-matching": "error",
"func-names": "error",
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
"grouped-accessor-pairs": ["error", "getBeforeSet"],
"guard-for-in": "error",
"init-declarations": ["error", "always"],
"logical-assignment-operators": [
"error",
"always",
{ enforceForIfStatements: true },
],
"max-classes-per-file": ["error", { ignoreExpressions: true }],
"max-depth": ["error", 3],
"max-lines": [
"error",
{ max: 500, skipBlankLines: true, skipComments: true },
],
"max-nested-callbacks": ["error", 3],
"max-params": ["error", 4],
"multiline-comment-style": ["error", "separate-lines"],
"no-alert": "error",
"no-bitwise": "error",
"no-caller": "error",
"no-else-return": "error",
"no-empty-function": "error",
"no-empty-static-block": "error",
"no-eq-null": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-label": "error",
"no-implicit-coercion": "error",
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "error",
"no-invalid-this": "error",
"no-iterator": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-loop-func": "error",
"no-multi-assign": "error",
"no-multi-str": "error",
"no-nested-ternary": "error",
"no-new": "error",
"no-new-func": "error",
"no-new-wrappers": "error",
"no-object-constructor": "error",
"no-octal-escape": "error",
"no-param-reassign": "error",
"no-plusplus": "error",
"no-proto": "error",
"no-return-assign": ["error", "always"],
"no-script-url": "error",
"no-sequences": "error",
"no-throw-literal": "error",
"no-undef-init": "error",
"no-unneeded-ternary": ["error", { defaultAssignment: false }],
"no-unused-expressions": ["error", { enforceForJSX: true }],
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-useless-call": "error",
"no-useless-computed-key": ["error", { enforceForClassMembers: true }],
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-warning-comments": ["error", { terms: ["todo"] }],
"object-shorthand": ["error", "always"],
"one-var": ["error", "never"],
"operator-assignment": ["error", "always"],
"prefer-arrow-callback": "error",
"prefer-const": [
"error",
{ destructuring: "any", ignoreReadBeforeAssign: false },
],
"prefer-destructuring": "error",
"prefer-exponentiation-operator": "error",
"prefer-numeric-literals": "error",
"prefer-object-has-own": "error",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "error",
"prefer-regex-literals": ["error", { disallowRedundantWrapping: true }],
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
radix: "error",
"require-await": "error",
"require-unicode-regexp": "error",
"symbol-description": "error",
yoda: "error",
"line-comment-position": ["error", { position: "above" }],
indent: "off",
"newline-before-return": "error",
"no-undef": "error",
"padded-blocks": ["error", "never"],
"padding-line-between-statements": [
"error",
{
blankLine: "always",
prev: "*",
next: [
"break",
"case",
"cjs-export",
"class",
"continue",
"do",
"if",
"switch",
"try",
"while",
"return",
],
},
{
blankLine: "always",
prev: [
"break",
"case",
"cjs-export",
"class",
"continue",
"do",
"if",
"switch",
"try",
"while",
"return",
],
next: "*",
},
],
quotes: [
"error",
"double",
{ avoidEscape: true, allowTemplateLiterals: true },
],
"space-before-blocks": "error",
semi: ["error", "never"],
},
},
])

View File

@@ -0,0 +1,32 @@
{
"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",
"lint": "eslint"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@rahoot/common": "workspace:*",
"@t3-oss/env-core": "^0.13.8",
"dayjs": "^1.11.18",
"redis": "^4.6.13",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@types/node": "^24.9.1",
"esbuild": "^0.25.11",
"eslint": "^9.38.0",
"globals": "^16.4.0",
"tsx": "^4.20.6",
"typescript-eslint": "^8.46.2"
}
}

View File

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

View File

@@ -0,0 +1,298 @@
import { Server } from "@rahoot/common/types/game/socket"
import { inviteCodeValidator } from "@rahoot/common/validators/auth"
import env from "@rahoot/socket/env"
import Config from "@rahoot/socket/services/config"
import Game from "@rahoot/socket/services/game"
import Registry from "@rahoot/socket/services/registry"
import { loadSnapshot } from "@rahoot/socket/services/persistence"
import { withGame } from "@rahoot/socket/utils/game"
import { Server as ServerIO } from "socket.io"
const corsOrigins =
process.env.NODE_ENV !== "production"
? "*"
: env.WEB_ORIGIN === "*"
? "*"
: [env.WEB_ORIGIN, "http://localhost:3000", "http://127.0.0.1:3000"]
const io: Server = new ServerIO({
cors: {
origin: corsOrigins,
methods: ["GET", "POST"],
credentials: false,
},
})
Config.init()
const registry = Registry.getInstance()
const port = 3001
console.log(`Socket server running on port ${port}`)
io.listen(Number(port))
io.on("connection", (socket) => {
console.log(
`A user connected: socketId: ${socket.id}, clientId: ${socket.handshake.auth.clientId}`
)
const ensureGame = async (gameId: string) => {
let game = registry.getGameById(gameId)
if (game) return game
try {
const snapshot = await loadSnapshot(gameId)
if (snapshot) {
const restored = await Game.fromSnapshot(io, snapshot)
registry.addGame(restored)
return restored
}
} catch (error) {
console.error("Failed to restore game", error)
}
return null
}
socket.on("player:reconnect", ({ gameId }) => {
const game = registry.getPlayerGame(gameId, socket.handshake.auth.clientId)
if (game) {
game.reconnect(socket)
return
}
ensureGame(gameId).then((restored) => {
if (restored) {
restored.reconnect(socket)
return
}
socket.emit("game:reset", "Game not found")
})
})
socket.on("manager:reconnect", ({ gameId }) => {
const game = registry.getManagerGame(
gameId,
socket.handshake.auth.clientId
)
if (game) {
game.reconnect(socket)
return
}
ensureGame(gameId).then((restored) => {
if (restored) {
restored.reconnect(socket)
return
}
socket.emit("game:reset", "Game expired")
})
})
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("manager:getQuizz", (quizzId) => {
const quizz = Config.getQuizz(quizzId)
if (!quizz) {
socket.emit("manager:errorMessage", "Quizz not found")
return
}
socket.emit("manager:quizzLoaded", quizz)
})
socket.on("manager:saveQuizz", ({ id, quizz }) => {
if (!quizz?.subject || !Array.isArray(quizz?.questions)) {
socket.emit("manager:errorMessage", "Invalid quizz payload")
return
}
try {
const saved = Config.saveQuizz(id || null, quizz)
if (!saved) {
socket.emit("manager:errorMessage", "Failed to save quizz")
return
}
socket.emit("manager:quizzSaved", saved)
socket.emit("manager:quizzList", Config.quizz())
} catch (error) {
console.error("Failed to save quizz", error)
socket.emit("manager:errorMessage", "Failed to save quizz")
}
})
socket.on("manager:deleteQuizz", ({ id }) => {
if (!id) {
socket.emit("manager:errorMessage", "Invalid quizz id")
return
}
try {
const deleted = Config.deleteQuizz(id)
if (!deleted) {
socket.emit("manager:errorMessage", "Quizz not found")
return
}
socket.emit("manager:quizzDeleted", id)
socket.emit("manager:quizzList", Config.quizz())
} catch (error) {
console.error("Failed to delete quizz", error)
socket.emit("manager:errorMessage", "Failed to delete quizz")
}
})
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)
registry.addGame(game)
})
socket.on("player:join", (inviteCode) => {
const result = inviteCodeValidator.safeParse(inviteCode)
if (result.error) {
socket.emit("game:errorMessage", result.error.issues[0].message)
return
}
const game = registry.getGameByInviteCode(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, (game) => game.join(socket, data.username))
)
socket.on("manager:kickPlayer", ({ gameId, playerId }) =>
withGame(gameId, socket, (game) => game.kickPlayer(socket, playerId))
)
socket.on("manager:startGame", ({ gameId }) =>
withGame(gameId, socket, (game) => game.start(socket))
)
socket.on("player:selectedAnswer", ({ gameId, data }) =>
withGame(gameId, socket, (game) =>
game.selectAnswer(socket, data.answerKey)
)
)
socket.on("manager:abortQuiz", ({ gameId }) =>
withGame(gameId, socket, (game) => game.abortRound(socket))
)
socket.on("manager:pauseCooldown", ({ gameId }) =>
withGame(gameId, socket, (game) => game.pauseCooldown(socket))
)
socket.on("manager:resumeCooldown", ({ gameId }) =>
withGame(gameId, socket, (game) => game.resumeCooldown(socket))
)
socket.on("manager:endGame", ({ gameId }) =>
withGame(gameId, socket, (game) => game.endGame(socket, registry))
)
socket.on("manager:nextQuestion", ({ gameId }) =>
withGame(gameId, socket, (game) => game.nextRound(socket))
)
socket.on("manager:skipQuestionIntro", ({ gameId }) =>
withGame(gameId, socket, (game) => game.skipQuestionIntro(socket))
)
socket.on("manager:showLeaderboard", ({ gameId }) =>
withGame(gameId, socket, (game) => game.showLeaderboard())
)
socket.on("disconnect", () => {
console.log(`A user disconnected : ${socket.id}`)
const managerGame = registry.getGameByManagerSocketId(socket.id)
if (managerGame) {
managerGame.manager.connected = false
registry.markGameAsEmpty(managerGame)
if (!managerGame.started) {
console.log("Reset game (manager disconnected)")
managerGame.abortCooldown()
io.to(managerGame.gameId).emit("game:reset", "Manager disconnected")
registry.removeGame(managerGame.gameId)
return
}
}
const game = registry.getGameByPlayerSocketId(socket.id)
if (!game) {
return
}
const player = game.players.find((p) => p.id === socket.id)
if (!player) {
return
}
player.connected = false
io.to(game.gameId).emit("game:totalPlayers", game.players.length)
io.to(game.manager.id).emit("manager:players", game.players)
})
})
process.on("SIGINT", () => {
Registry.getInstance().cleanup()
process.exit(0)
})
process.on("SIGTERM", () => {
Registry.getInstance().cleanup()
process.exit(0)
})

View File

@@ -0,0 +1,236 @@
import { QuizzWithId } from "@rahoot/common/types/game"
import fs from "fs"
import { resolve } from "path"
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50)
const inContainerPath = process.env.CONFIG_PATH
const getPath = (path: string = "") =>
inContainerPath
? resolve(inContainerPath, path)
: resolve(process.cwd(), "../../config", path)
export const getConfigPath = (path: string = "") => getPath(path)
class Config {
static ensureBaseFolders() {
const isConfigFolderExists = fs.existsSync(getPath())
if (!isConfigFolderExists) {
fs.mkdirSync(getPath())
}
const isQuizzExists = fs.existsSync(getPath("quizz"))
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"))
}
const isMediaExists = fs.existsSync(getPath("media"))
if (!isMediaExists) {
fs.mkdirSync(getPath("media"))
}
}
static init() {
this.ensureBaseFolders()
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.readdirSync(getPath("quizz")).length > 0
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"), { recursive: true })
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,
},
{
question: "Which soundtrack is this?",
answers: [
"Nature sounds",
"Piano solo",
"Electronic beat",
"Chill guitar",
],
media: {
type: "audio",
url: "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3",
},
solution: 1,
cooldown: 5,
time: 25,
},
{
question: "Which landmark appears in this clip?",
answers: [
"Eiffel Tower",
"Sydney Opera House",
"Statue of Liberty",
"Golden Gate Bridge",
],
media: {
type: "video",
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
},
solution: 2,
cooldown: 5,
time: 40,
},
],
},
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)
}
return {}
}
static quizz() {
this.ensureBaseFolders()
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 || []
}
static getQuizz(id: string) {
this.ensureBaseFolders()
const filePath = getPath(`quizz/${id}.json`)
if (!fs.existsSync(filePath)) {
return null
}
const data = fs.readFileSync(filePath, "utf-8")
return { id, ...JSON.parse(data) } as QuizzWithId
}
static saveQuizz(
id: string | null,
quizz: QuizzWithId | Omit<QuizzWithId, "id">
) {
this.ensureBaseFolders()
const slug = id
? slugify(id)
: slugify((quizz as any).subject || "quizz")
const finalId = slug.length > 0 ? slug : `quizz-${Date.now()}`
const filePath = getPath(`quizz/${finalId}.json`)
fs.writeFileSync(
filePath,
JSON.stringify(
{
subject: quizz.subject,
questions: quizz.questions,
},
null,
2
)
)
return this.getQuizz(finalId)
}
static deleteQuizz(id: string) {
this.ensureBaseFolders()
const filePath = getPath(`quizz/${id}.json`)
if (!fs.existsSync(filePath)) {
return false
}
fs.unlinkSync(filePath)
return true
}
static getMediaPath(fileName: string = "") {
this.ensureBaseFolders()
return getPath(fileName ? `media/${fileName}` : "media")
}
}
export default Config

View File

@@ -0,0 +1,721 @@
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 { saveSnapshot, loadSnapshot, deleteSnapshot, GameSnapshot } from "@rahoot/socket/services/persistence"
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
paused: boolean
remaining: number
timer: NodeJS.Timeout | null
resolve: (() => void) | null
}
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: socket.handshake.auth.clientId,
connected: true,
}
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,
paused: false,
remaining: 0,
timer: null,
resolve: null,
}
const roomInvite = createInviteCode()
this.inviteCode = roomInvite
this.manager.id = 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}`
)
this.persist()
}
static async fromSnapshot(io: Server, snapshot: GameSnapshot) {
const game = Object.create(Game.prototype) as Game
game.io = io
game.gameId = snapshot.gameId
game.manager = {
id: "",
clientId: snapshot.manager?.clientId || "",
connected: false,
}
game.inviteCode = snapshot.inviteCode
game.started = snapshot.started
game.lastBroadcastStatus = snapshot.lastBroadcastStatus || null
game.managerStatus = snapshot.managerStatus || null
game.playerStatus = new Map()
game.leaderboard = snapshot.leaderboard || []
game.tempOldLeaderboard = snapshot.tempOldLeaderboard || null
game.quizz = snapshot.quizz
game.players = (snapshot.players || []).map((p: Player) => ({
...p,
id: "",
connected: false,
}))
game.round = snapshot.round || {
playersAnswers: [],
currentQuestion: 0,
startTime: 0,
}
game.cooldown = {
active: snapshot.cooldown?.active || false,
paused: snapshot.cooldown?.paused || false,
remaining: snapshot.cooldown?.remaining || 0,
timer: null,
resolve: null,
}
if (game.cooldown.active && game.cooldown.remaining > 0 && !game.cooldown.paused) {
game.startCooldown(game.cooldown.remaining)
}
return 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)
this.persist()
}
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)
this.persist()
}
toSnapshot(): GameSnapshot {
return {
gameId: this.gameId,
inviteCode: this.inviteCode,
started: this.started,
manager: {
clientId: this.manager.clientId,
},
lastBroadcastStatus: this.lastBroadcastStatus,
managerStatus: this.managerStatus,
leaderboard: this.leaderboard,
tempOldLeaderboard: this.tempOldLeaderboard,
quizz: this.quizz,
players: this.players.map((p) => ({
...p,
id: undefined,
connected: false,
})),
round: this.round,
cooldown: {
active: this.cooldown.active,
paused: this.cooldown.paused,
remaining: this.cooldown.remaining,
},
}
}
async persist() {
try {
await saveSnapshot(this.gameId, this.toSnapshot())
} catch (error) {
console.error("Failed to persist game snapshot", error)
}
}
async clearPersisted() {
try {
await deleteSnapshot(this.gameId)
} catch (error) {
console.error("Failed to delete game snapshot", error)
}
}
join(socket: Socket, username: string) {
const existing = this.players.find(
(p) => p.clientId === socket.handshake.auth.clientId
)
if (existing) {
// Reconnect existing player (even before game start)
existing.id = socket.id
existing.connected = true
if (username) existing.username = username
socket.join(this.gameId)
this.io.to(this.manager.id).emit("manager:players", this.players)
socket.emit("game:successJoin", this.gameId)
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.manager.id).emit("manager:players", this.players)
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.manager.id).emit("manager:players", this.players)
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)
}
this.io.to(this.manager.id).emit("manager:players", this.players)
}
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)
}
this.io.to(this.manager.id).emit("manager:players", this.players)
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
this.cooldown.paused = false
this.cooldown.remaining = seconds
return new Promise<void>((resolve) => {
this.cooldown.resolve = resolve
const tick = () => {
if (!this.cooldown.active) {
this.finishCooldown()
return
}
if (this.cooldown.paused) {
return
}
this.cooldown.remaining -= 1
if (this.cooldown.remaining <= 0) {
this.finishCooldown()
return
}
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
this.persist()
}
// initial emit
this.io.to(this.gameId).emit("game:cooldown", this.cooldown.remaining)
this.persist()
this.cooldown.timer = setInterval(tick, 1000)
})
}
abortCooldown() {
if (!this.cooldown.active) {
return
}
this.cooldown.active = false
this.cooldown.paused = false
this.io.to(this.gameId).emit("game:cooldownPause", false)
this.persist()
this.finishCooldown()
}
finishCooldown() {
if (this.cooldown.timer) {
clearInterval(this.cooldown.timer)
}
this.cooldown.timer = null
this.cooldown.active = false
this.cooldown.paused = false
this.cooldown.remaining = 0
if (this.cooldown.resolve) {
this.cooldown.resolve()
}
this.cooldown.resolve = null
}
pauseCooldown(socket: Socket) {
if (this.manager.id !== socket.id || !this.cooldown.active || this.cooldown.paused) {
return
}
this.cooldown.paused = true
this.io.to(this.gameId).emit("game:cooldownPause", true)
this.persist()
}
resumeCooldown(socket: Socket) {
if (this.manager.id !== socket.id || !this.cooldown.active || !this.cooldown.paused) {
return
}
this.cooldown.paused = false
this.io.to(this.gameId).emit("game:cooldownPause", false)
this.persist()
}
skipQuestionIntro(socket: Socket) {
if (this.manager.id !== socket.id) {
return
}
if (!this.started) {
return
}
this.abortCooldown()
}
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()
this.persist()
}
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 this.startCooldown(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)
this.persist()
}
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 = []
this.persist()
}
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()
}
this.persist()
}
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),
})
this.clearPersisted()
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
this.persist()
}
endGame(socket: Socket, registry: typeof Registry.prototype) {
if (socket.id !== this.manager.id) {
return
}
this.started = false
this.abortCooldown()
this.io.to(this.gameId).emit("game:reset", "Game ended by manager")
registry.removeGame(this.gameId)
}
}
export default Game

View File

@@ -0,0 +1,36 @@
import { createClient } from "redis"
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379"
const redis =
createClient({ url: redisUrl })
.on("error", (err) => console.error("Redis Client Error", err))
export type GameSnapshot = Record<string, any>
export const connectRedis = async () => {
if (!redis.isOpen) {
await redis.connect()
}
}
export const saveSnapshot = async (gameId: string, snapshot: GameSnapshot) => {
if (!gameId) return
await connectRedis()
await redis.set(`game:${gameId}`, JSON.stringify(snapshot), {
EX: 60 * 60 * 6, // 6 hours
})
}
export const loadSnapshot = async (gameId: string): Promise<GameSnapshot | null> => {
if (!gameId) return null
await connectRedis()
const raw = await redis.get(`game:${gameId}`)
return raw ? (JSON.parse(raw) as GameSnapshot) : null
}
export const deleteSnapshot = async (gameId: string) => {
if (!gameId) return
await connectRedis()
await redis.del(`game:${gameId}`)
}

View File

@@ -0,0 +1,165 @@
import Game from "@rahoot/socket/services/game"
import dayjs from "dayjs"
interface EmptyGame {
since: number
game: Game
}
class Registry {
private static instance: Registry | null = null
private games: Game[] = []
private emptyGames: EmptyGame[] = []
private cleanupInterval: ReturnType<typeof setTimeout> | null = null
private readonly EMPTY_GAME_TIMEOUT_MINUTES = 5
private readonly CLEANUP_INTERVAL_MS = 60_000
private constructor() {
this.startCleanupTask()
}
static getInstance(): Registry {
Registry.instance ||= new Registry()
return Registry.instance
}
addGame(game: Game): void {
this.games.push(game)
console.log(`Game ${game.gameId} added. Total games: ${this.games.length}`)
}
getGameById(gameId: string): Game | undefined {
return this.games.find((g) => g.gameId === gameId)
}
getGameByInviteCode(inviteCode: string): Game | undefined {
return this.games.find((g) => g.inviteCode === inviteCode)
}
getPlayerGame(gameId: string, clientId: string): Game | undefined {
return this.games.find(
(g) =>
g.gameId === gameId && g.players.some((p) => p.clientId === clientId)
)
}
getManagerGame(gmageId: string, clientId: string): Game | undefined {
return this.games.find(
(g) => g.gameId === gmageId && g.manager.clientId === clientId
)
}
getGameByManagerSocketId(socketId: string): Game | undefined {
return this.games.find((g) => g.manager.id === socketId)
}
getGameByPlayerSocketId(socketId: string): Game | undefined {
return this.games.find((g) => g.players.some((p) => p.id === socketId))
}
markGameAsEmpty(game: Game): void {
const alreadyEmpty = this.emptyGames.find(
(g) => g.game.gameId === game.gameId
)
if (!alreadyEmpty) {
this.emptyGames.push({
since: dayjs().unix(),
game,
})
console.log(
`Game ${game.gameId} marked as empty. Total empty games: ${this.emptyGames.length}`
)
}
}
reactivateGame(gameId: string): void {
const initialLength = this.emptyGames.length
this.emptyGames = this.emptyGames.filter((g) => g.game.gameId !== gameId)
if (this.emptyGames.length < initialLength) {
console.log(
`Game ${gameId} reactivated. Remaining empty games: ${this.emptyGames.length}`
)
}
}
removeGame(gameId: string): boolean {
const game = this.games.find((g) => g.gameId === gameId)
void game?.clearPersisted?.()
const initialLength = this.games.length
this.games = this.games.filter((g) => g.gameId !== gameId)
this.emptyGames = this.emptyGames.filter((g) => g.game.gameId !== gameId)
const removed = this.games.length < initialLength
if (removed) {
console.log(`Game ${gameId} removed. Total games: ${this.games.length}`)
}
return removed
}
getAllGames(): Game[] {
return [...this.games]
}
getGameCount(): number {
return this.games.length
}
getEmptyGameCount(): number {
return this.emptyGames.length
}
private cleanupEmptyGames(): void {
const now = dayjs()
const stillEmpty = this.emptyGames.filter(
(g) =>
now.diff(dayjs.unix(g.since), "minute") <
this.EMPTY_GAME_TIMEOUT_MINUTES
)
if (stillEmpty.length === this.emptyGames.length) {
return
}
const removed = this.emptyGames.filter((g) => !stillEmpty.includes(g))
const removedGameIds = removed.map((r) => r.game.gameId)
removed.forEach((entry) => void entry.game.clearPersisted?.())
this.games = this.games.filter((g) => !removedGameIds.includes(g.gameId))
this.emptyGames = stillEmpty
console.log(
`Removed ${removed.length} empty game(s). Remaining games: ${this.games.length}`
)
}
private startCleanupTask(): void {
this.cleanupInterval = setInterval(() => {
this.cleanupEmptyGames()
}, this.CLEANUP_INTERVAL_MS)
console.log("Game cleanup task started")
}
stopCleanupTask(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
console.log("Game cleanup task stopped")
}
}
cleanup(): void {
this.stopCleanupTask()
this.games = []
this.emptyGames = []
console.log("Registry cleaned up")
}
}
export default Registry

View File

@@ -0,0 +1,51 @@
import { Socket } from "@rahoot/common/types/game/socket"
import Game from "@rahoot/socket/services/game"
import Registry from "@rahoot/socket/services/registry"
export const withGame = (
gameId: string | undefined,
socket: Socket,
callback: (_game: Game) => void
): void => {
if (!gameId) {
socket.emit("game:errorMessage", "Game not found")
return
}
const registry = Registry.getInstance()
const game = registry.getGameById(gameId)
if (!game) {
socket.emit("game:errorMessage", "Game not found")
return
}
callback(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
}

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,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"types": ["node"],
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true
}
}