feat: improve reconnect, add ESLint configuration for common and socket

This commit is contained in:
Ralex
2025-10-19 00:37:26 +02:00
parent 8bdb8f47ef
commit 96bff164c0
36 changed files with 1571 additions and 677 deletions

View File

@@ -27,6 +27,9 @@ RUN pnpm install --frozen-lockfile
# Build Next.js app with standalone output for smaller runtime image
WORKDIR /app/packages/web
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Build socket server if needed (TypeScript or similar)
@@ -56,6 +59,9 @@ COPY --from=builder /app/packages/web/public ./packages/web/public
# Copy the socket server build
COPY --from=builder /app/packages/socket/dist ./packages/socket/dist
# Copy the game default config
COPY --from=builder /app/config ./config
# Expose the web and socket ports
EXPOSE 3000 5505

View File

@@ -6,7 +6,8 @@
"dev:socket": "dotenv -e .env -- pnpm --filter socket dev",
"build": "pnpm -r run build",
"start": "pnpm -r --parallel run start",
"clean": "pnpm -r exec rm -rf dist node_modules"
"clean": "pnpm -r exec rm -rf dist node_modules",
"lint": "pnpm -r run lint"
},
"devDependencies": {
"dotenv-cli": "^10.0.0",

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/**"],
},
{
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

@@ -2,12 +2,19 @@
"name": "@rahoot/common",
"version": "1.0.0",
"type": "module",
"scripts": {
"lint": "eslint"
},
"dependencies": {
"socket.io": "^4.8.1",
"zod": "^3.22.4"
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
"@eslint/js": "^9.38.0",
"@types/node": "^20.19.22",
"eslint": "^9.38.0",
"globals": "^16.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1"
}
}

View File

@@ -24,65 +24,65 @@ export interface ServerToClientEvents {
connect: () => void
// Game events
"game:status": (data: { name: Status; data: StatusDataMap[Status] }) => void
"game:successRoom": (data: string) => void
"game:successJoin": (gameId: string) => void
"game:totalPlayers": (count: number) => void
"game:errorMessage": (message: string) => void
"game:status": (_data: { name: Status; data: StatusDataMap[Status] }) => void
"game:successRoom": (_data: string) => void
"game:successJoin": (_gameId: string) => void
"game:totalPlayers": (_count: number) => void
"game:errorMessage": (_message: string) => void
"game:startCooldown": () => void
"game:cooldown": (count: number) => void
"game:cooldown": (_count: number) => void
"game:kick": () => void
"game:reset": () => void
"game:updateQuestion": (data: { current: number; total: number }) => void
"game:playerAnswer": (count: number) => void
"game:updateQuestion": (_data: { current: number; total: number }) => void
"game:playerAnswer": (_count: number) => void
// Player events
"player:successReconnect": (data: {
"player:successReconnect": (_data: {
gameId: string
status: { name: Status; data: StatusDataMap[Status] }
player: { username: string; points: number }
currentQuestion: GameUpdateQuestion
}) => void
"player:updateLeaderboard": (data: { leaderboard: Player[] }) => void
"player:updateLeaderboard": (_data: { leaderboard: Player[] }) => void
// Manager events
"manager:successReconnect": (data: {
"manager:successReconnect": (_data: {
gameId: string
status: { name: Status; data: StatusDataMap[Status] }
players: Player[]
currentQuestion: GameUpdateQuestion
}) => void
"manager:quizzList": (quizzList: QuizzWithId[]) => void
"manager:gameCreated": (data: { gameId: string; inviteCode: string }) => void
"manager:statusUpdate": (data: {
"manager:quizzList": (_quizzList: QuizzWithId[]) => void
"manager:gameCreated": (_data: { gameId: string; inviteCode: string }) => void
"manager:statusUpdate": (_data: {
status: Status
data: StatusDataMap[Status]
}) => void
"manager:newPlayer": (player: Player) => void
"manager:removePlayer": (playerId: string) => void
"manager:errorMessage": (message: string) => void
"manager:playerKicked": (playerId: string) => void
"manager:newPlayer": (_player: Player) => void
"manager:removePlayer": (_playerId: string) => void
"manager:errorMessage": (_message: string) => void
"manager:playerKicked": (_playerId: string) => void
}
export interface ClientToServerEvents {
// Manager actions
"game:create": (quizzId: string) => void
"manager:auth": (password: string) => void
"manager:reconnect": (message: { gameId: string }) => void
"game:create": (_quizzId: string) => void
"manager:auth": (_password: string) => void
"manager:reconnect": (_message: { gameId: string }) => void
"manager:kickPlayer": (
message: MessageWithoutStatus<{ playerId: string }>
_message: MessageWithoutStatus<{ playerId: string }>
) => void
"manager:startGame": (message: MessageGameId) => void
"manager:abortQuiz": (message: MessageGameId) => void
"manager:nextQuestion": (message: MessageGameId) => void
"manager:showLeaderboard": (message: MessageGameId) => void
"manager:startGame": (_message: MessageGameId) => void
"manager:abortQuiz": (_message: MessageGameId) => void
"manager:nextQuestion": (_message: MessageGameId) => void
"manager:showLeaderboard": (_message: MessageGameId) => void
// Player actions
"player:join": (inviteCode: string) => void
"player:login": (message: MessageWithoutStatus<{ username: string }>) => void
"player:reconnect": (message: { gameId: string }) => void
"player:join": (_inviteCode: string) => void
"player:login": (_message: MessageWithoutStatus<{ username: string }>) => void
"player:reconnect": (_message: { gameId: string }) => void
"player:selectedAnswer": (
message: MessageWithoutStatus<{ answerKey: number }>
_message: MessageWithoutStatus<{ answerKey: number }>
) => void
// Common

View File

@@ -1,18 +1,19 @@
import { Player } from ".";
import { Player } from "."
export enum Status {
SHOW_ROOM = "SHOW_ROOM",
SHOW_START = "SHOW_START",
SHOW_PREPARED = "SHOW_PREPARED",
SHOW_QUESTION = "SHOW_QUESTION",
SELECT_ANSWER = "SELECT_ANSWER",
SHOW_RESULT = "SHOW_RESULT",
SHOW_RESPONSES = "SHOW_RESPONSES",
SHOW_LEADERBOARD = "SHOW_LEADERBOARD",
FINISHED = "FINISHED",
WAIT = "WAIT",
}
export const STATUS = {
SHOW_ROOM: "SHOW_ROOM",
SHOW_START: "SHOW_START",
SHOW_PREPARED: "SHOW_PREPARED",
SHOW_QUESTION: "SHOW_QUESTION",
SELECT_ANSWER: "SELECT_ANSWER",
SHOW_RESULT: "SHOW_RESULT",
SHOW_RESPONSES: "SHOW_RESPONSES",
SHOW_LEADERBOARD: "SHOW_LEADERBOARD",
FINISHED: "FINISHED",
WAIT: "WAIT",
} as const
export type Status = (typeof STATUS)[keyof typeof STATUS]
export type CommonStatusDataMap = {
SHOW_START: { time: number; subject: string }
@@ -49,10 +50,6 @@ type ManagerExtraStatus = {
SHOW_LEADERBOARD: { leaderboard: Player[] }
}
type PlayerExtraStatus = {
WAIT: { text: string }
}
export type PlayerStatusDataMap = CommonStatusDataMap & PlayerExtraStatus
export type PlayerStatusDataMap = CommonStatusDataMap
export type ManagerStatusDataMap = CommonStatusDataMap & ManagerExtraStatus
export type StatusDataMap = PlayerStatusDataMap & ManagerStatusDataMap

View File

@@ -1,4 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",

View File

@@ -0,0 +1,4 @@
{
"managerPassword": "PASSWORD",
"music": true
}

View File

@@ -0,0 +1,41 @@
{
"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
}
]
}

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

@@ -5,7 +5,8 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "node esbuild.config.js",
"start": "node dist/index.cjs"
"start": "node dist/index.cjs",
"lint": "eslint"
},
"keywords": [],
"author": "",
@@ -13,12 +14,18 @@
"dependencies": {
"@rahoot/common": "workspace:*",
"@t3-oss/env-core": "^0.13.8",
"dayjs": "^1.11.18",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@types/node": "^24.8.1",
"esbuild": "^0.25.10",
"tsx": "^4.20.6"
"eslint": "^9",
"globals": "^16.4.0",
"tsx": "^4.20.6",
"typescript-eslint": "^8.46.1"
}
}

View File

@@ -1,30 +1,28 @@
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 {
findManagerGameByClientId,
findPlayerGameByClientId,
withGame,
} from "@rahoot/socket/utils/game"
import { inviteCodeValidator } from "@rahoot/socket/utils/validator"
import Registry from "@rahoot/socket/services/registry"
import { withGame } from "@rahoot/socket/utils/game"
import { Server as ServerIO } from "socket.io"
const io: Server = new ServerIO()
Config.init()
let games: Game[] = []
const registry = Registry.getInstance()
const port = env.SOCKER_PORT || 3001
console.log(`Socket server running on port ${port}`)
io.listen(Number(port))
io.on("connection", (socket) => {
console.log(`A user connected ${socket.id}`)
console.log(socket.handshake.auth)
console.log(
`A user connected: socketId: ${socket.id}, clientId: ${socket.handshake.auth.clientId}`
)
socket.on("player:reconnect", () => {
const game = findPlayerGameByClientId(socket.handshake.auth.clientId, games)
const game = registry.getPlayerGame(socket.handshake.auth.clientId)
if (game) {
game.reconnect(socket)
@@ -36,13 +34,11 @@ io.on("connection", (socket) => {
})
socket.on("manager:reconnect", () => {
const game = findManagerGameByClientId(
socket.handshake.auth.clientId,
games
)
const game = registry.getManagerGame(socket.handshake.auth.clientId)
if (game) {
game.reconnect(socket)
registry.reactivateGame(game.gameId)
return
}
@@ -56,6 +52,7 @@ io.on("connection", (socket) => {
if (password !== config.managerPassword) {
socket.emit("manager:errorMessage", "Invalid password")
return
}
@@ -72,23 +69,24 @@ io.on("connection", (socket) => {
if (!quizz) {
socket.emit("game:errorMessage", "Quizz not found")
return
}
const game = new Game(io, socket, quizz)
games.push(game)
registry.addGame(game)
})
socket.on("player:join", async (inviteCode) => {
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 = games.find((g) => g.inviteCode === inviteCode)
const game = registry.getGameByInviteCode(inviteCode)
if (!game) {
socket.emit("game:errorMessage", "Game not found")
@@ -100,53 +98,54 @@ io.on("connection", (socket) => {
})
socket.on("player:login", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) => game.join(socket, data.username))
withGame(gameId, socket, (game) => game.join(socket, data.username))
)
socket.on("manager:kickPlayer", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) =>
game.kickPlayer(socket, data.playerId)
)
withGame(gameId, socket, (game) => game.kickPlayer(socket, data.playerId))
)
socket.on("manager:startGame", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.start(socket))
withGame(gameId, socket, (game) => game.start(socket))
)
socket.on("player:selectedAnswer", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) =>
withGame(gameId, socket, (game) =>
game.selectAnswer(socket, data.answerKey)
)
)
socket.on("manager:abortQuiz", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.abortRound(socket))
withGame(gameId, socket, (game) => game.abortRound(socket))
)
socket.on("manager:nextQuestion", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.nextRound(socket))
withGame(gameId, socket, (game) => game.nextRound(socket))
)
socket.on("manager:showLeaderboard", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.showLeaderboard())
withGame(gameId, socket, (game) => game.showLeaderboard())
)
socket.on("disconnect", () => {
console.log(`user disconnected ${socket.id}`)
const managerGame = games.find((g) => g.manager.id === socket.id)
if (managerGame && !managerGame.started) {
console.log("Reset game (manager disconnected)")
const managerGame = registry.getGameByManagerSocketId(socket.id)
managerGame.abortCooldown()
io.to(managerGame.gameId).emit("game:reset")
if (managerGame) {
registry.markGameAsEmpty(managerGame)
games = games.filter((g) => g.gameId !== managerGame.gameId)
if (!managerGame.started) {
console.log("Reset game (manager disconnected)")
managerGame.abortCooldown()
io.to(managerGame.gameId).emit("game:reset")
registry.removeGame(managerGame.gameId)
return
return
}
}
const game = games.find((g) => g.players.some((p) => p.id === socket.id))
const game = registry.getGameByPlayerSocketId(socket.id)
if (!game || game.started) {
return
@@ -166,3 +165,13 @@ io.on("connection", (socket) => {
console.log(`Removed player ${player.username} from game ${game.gameId}`)
})
})
process.on("SIGINT", () => {
Registry.getInstance().cleanup()
process.exit(0)
})
process.on("SIGTERM", () => {
Registry.getInstance().cleanup()
process.exit(0)
})

View File

@@ -2,11 +2,19 @@ import { QuizzWithId } from "@rahoot/common/types/game"
import fs from "fs"
import { resolve } from "path"
const getPath = (path: string) => resolve(process.cwd(), "../../config", path)
const getPath = (path: string = "") =>
resolve(process.cwd(), "../../config", path)
class Config {
static init() {
const isConfigFolderExists = fs.existsSync(getPath())
if (!isConfigFolderExists) {
fs.mkdirSync(getPath())
}
const isGameConfigExists = fs.existsSync(getPath("game.json"))
if (!isGameConfigExists) {
fs.writeFileSync(
getPath("game.json"),
@@ -22,6 +30,7 @@ class Config {
}
const isQuizzExists = fs.existsSync(getPath("quizz"))
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"))
@@ -65,20 +74,25 @@ class Config {
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() {
const isExists = fs.existsSync(getPath("quizz"))
if (!isExists) {
return []
}
@@ -99,9 +113,11 @@ class Config {
...config,
}
})
return quizz || []
} catch (error) {
console.error("Failed to read quizz config:", error)
return []
}
}

View File

@@ -1,6 +1,6 @@
import { Answer, Player, Quizz } from "@rahoot/common/types/game"
import { Server, Socket } from "@rahoot/common/types/game/socket"
import { Status, StatusDataMap } from "@rahoot/common/types/game/status"
import { Status, 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"
@@ -114,7 +114,7 @@ class Game {
const playerData = {
id: socket.id,
clientId: socket.handshake.auth.clientId,
username: username,
username,
points: 0,
}
@@ -148,7 +148,7 @@ class Game {
}
reconnect(socket: Socket) {
const clientId = socket.handshake.auth.clientId
const { clientId } = socket.handshake.auth
const isManager = this.manager.clientId === clientId
if (!isManager) {
@@ -175,7 +175,7 @@ class Game {
const status = this.managerStatus ||
this.lastBroadcastStatus || {
name: Status.WAIT,
name: STATUS.WAIT,
data: { text: "Waiting for players" },
}
@@ -188,7 +188,7 @@ class Game {
console.log(`Manager reconnected to game ${this.inviteCode}`)
return
return true
}
const player = this.players.find((p) => p.clientId === clientId)!
@@ -197,7 +197,7 @@ class Game {
const status = this.playerStatus.get(oldSocketId) ||
this.lastBroadcastStatus || {
name: Status.WAIT,
name: STATUS.WAIT,
data: { text: "Waiting for players" },
}
@@ -223,9 +223,9 @@ class Game {
return true
}
async startCooldown(seconds: number) {
startCooldown(seconds: number): Promise<void> {
if (this.cooldown.active) {
return
return Promise.resolve()
}
this.cooldown.active = true
@@ -237,6 +237,7 @@ class Game {
this.cooldown.active = false
clearInterval(cooldownTimeout)
resolve()
return
}
@@ -246,10 +247,8 @@ class Game {
})
}
async abortCooldown() {
if (this.cooldown.active) {
this.cooldown.active = false
}
abortCooldown() {
this.cooldown.active &&= false
}
async start(socket: Socket) {
@@ -263,7 +262,7 @@ class Game {
this.started = true
this.broadcastStatus(Status.SHOW_START, {
this.broadcastStatus(STATUS.SHOW_START, {
time: 3,
subject: this.quizz.subject,
})
@@ -290,7 +289,7 @@ class Game {
total: this.quizz.questions.length,
})
this.broadcastStatus(Status.SHOW_PREPARED, {
this.broadcastStatus(STATUS.SHOW_PREPARED, {
totalAnswers: question.answers.length,
questionNumber: this.round.currentQuestion + 1,
})
@@ -301,7 +300,7 @@ class Game {
return
}
this.broadcastStatus(Status.SHOW_QUESTION, {
this.broadcastStatus(STATUS.SHOW_QUESTION, {
question: question.question,
image: question.image,
cooldown: question.cooldown,
@@ -315,7 +314,7 @@ class Game {
this.round.startTime = Date.now()
this.broadcastStatus(Status.SELECT_ANSWER, {
this.broadcastStatus(STATUS.SELECT_ANSWER, {
question: question.question,
answers: question.answers,
image: question.image,
@@ -332,10 +331,11 @@ class Game {
await this.showResults(question)
}
async showResults(question: any) {
showResults(question: any) {
const totalType = this.round.playersAnswers.reduce(
(acc: Record<number, number>, { answerId }) => {
acc[answerId] = (acc[answerId] || 0) + 1
return acc
},
{}
@@ -366,7 +366,7 @@ class Game {
const rank = index + 1
const aheadPlayer = sortedPlayers[index - 1]
this.sendStatus(player.id, Status.SHOW_RESULT, {
this.sendStatus(player.id, STATUS.SHOW_RESULT, {
correct: player.lastCorrect,
message: player.lastCorrect ? "Nice!" : "Too bad",
points: player.lastPoints,
@@ -376,7 +376,7 @@ class Game {
})
})
this.sendStatus(this.manager.id, Status.SHOW_RESPONSES, {
this.sendStatus(this.manager.id, STATUS.SHOW_RESPONSES, {
question: question.question,
responses: totalType,
correct: question.solution,
@@ -387,7 +387,7 @@ class Game {
this.round.playersAnswers = []
}
async selectAnswer(socket: Socket, answerId: number) {
selectAnswer(socket: Socket, answerId: number) {
const player = this.players.find((player) => player.id === socket.id)
const question = this.quizz.questions[this.round.currentQuestion]
@@ -405,7 +405,7 @@ class Game {
points: timeToPoint(this.round.startTime, question.time),
})
this.sendStatus(socket.id, Status.WAIT, {
this.sendStatus(socket.id, STATUS.WAIT, {
text: "Waiting for the players to answer",
})
@@ -458,7 +458,7 @@ class Game {
if (isLastRound) {
this.started = false
this.broadcastStatus(Status.FINISHED, {
this.broadcastStatus(STATUS.FINISHED, {
subject: this.quizz.subject,
top: sortedPlayers.slice(0, 3),
})
@@ -466,7 +466,7 @@ class Game {
return
}
this.sendStatus(this.manager.id, Status.SHOW_LEADERBOARD, {
this.sendStatus(this.manager.id, STATUS.SHOW_LEADERBOARD, {
leaderboard: sortedPlayers.slice(0, 5),
})
}

View File

@@ -0,0 +1,158 @@
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(clientId: string): Game | undefined {
return this.games.find((g) =>
g.players.some((p) => p.clientId === clientId)
)
}
getManagerGame(clientId: string): Game | undefined {
return this.games.find((g) => 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 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)
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

@@ -1,29 +1,28 @@
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 = <T>(
export const withGame = (
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) {
callback: (_game: Game) => void
): void => {
if (!gameId) {
socket.emit("game:errorMessage", "Game not found")
return
}
return handler(game)
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) => {
@@ -50,25 +49,3 @@ export const timeToPoint = (startTime: number, secondes: number): number => {
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

@@ -3,6 +3,7 @@
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"types": ["node"],
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,

View File

@@ -1,22 +1,23 @@
import js from "@eslint/js"
import nextPlugin from "@next/eslint-plugin-next"
import tsEslintPlugin from "@typescript-eslint/eslint-plugin"
import tsEslintParser from "@typescript-eslint/parser"
import reactPlugin from "eslint-plugin-react"
import reactHooksPlugin from "eslint-plugin-react-hooks"
import { defineConfig } from "eslint/config"
import globals from "globals"
import tseslint from "typescript-eslint"
export default defineConfig([
{
ignores: ["**/node_modules/**", "**/.next/**"],
},
{
files: ["**/*.ts", "**/*.tsx"],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
parser: tsEslintParser,
...js.configs.recommended.languageOptions,
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: { jsx: true },
},
globals: {
@@ -25,7 +26,7 @@ export default defineConfig([
},
},
plugins: {
"@typescript-eslint": tsEslintPlugin,
"@typescript-eslint": tseslint.plugin,
react: reactPlugin,
"react-hooks": reactHooksPlugin,
"@next/next": nextPlugin,
@@ -36,6 +37,10 @@ export default defineConfig([
},
},
rules: {
...js.configs.recommended.rules,
...tseslint.configs.recommendedTypeChecked[0].rules,
...reactPlugin.configs.recommended.rules,
"array-callback-return": [
"error",
{ allowImplicit: false, checkForEach: true, allowVoid: true },
@@ -213,7 +218,7 @@ export default defineConfig([
"space-before-blocks": "error",
semi: ["error", "never"],
// Extra rules
// React + Hooks + Next.js
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "off",
"react/no-unescaped-entities": ["error", { forbid: [">", "}"] }],

View File

@@ -12,7 +12,7 @@
"@rahoot/socket": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.8",
"clsx": "^2.1.1",
"ky": "^1.11.0",
"ky": "^1.12.0",
"next": "15.5.4",
"react": "19.1.0",
"react-confetti": "^6.4.0",
@@ -26,21 +26,20 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"eslint": "^9",
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.14",
"@types/node": "^20.19.22",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"eslint": "^9.38.0",
"eslint-config-next": "15.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^6.1.1",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4",
"typescript": "^5"
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1"
}
}

View File

@@ -1,6 +1,9 @@
"use client"
import logo from "@rahoot/web/assets/logo.svg"
import Loader from "@rahoot/web/components/Loader"
import { useSocket } from "@rahoot/web/contexts/socketProvider"
import Image from "next/image"
import { PropsWithChildren, useEffect } from "react"
const AuthLayout = ({ children }: PropsWithChildren) => {
@@ -11,7 +14,34 @@ const AuthLayout = ({ children }: PropsWithChildren) => {
}
}, [connect, isConnected])
return children
if (!isConnected) {
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<Image src={logo} className="mb-6 h-32" alt="logo" />
<Loader className="h-23" />
<h2 className="mt-2 text-center text-2xl font-bold text-white drop-shadow-lg md:text-3xl">
Loading...
</h2>
</section>
)
}
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<Image src={logo} className="mb-6 h-32" alt="logo" />
{children}
</section>
)
}
export default AuthLayout

View File

@@ -31,7 +31,6 @@ export default function Manager() {
socket?.emit("manager:auth", password)
}
const handleCreate = (quizzId: string) => {
console.log(quizzId)
socket?.emit("game:create", quizzId)
}

View File

@@ -1,11 +1,9 @@
"use client"
import logo from "@rahoot/web/assets/logo.svg"
import Room from "@rahoot/web/components/game/join/Room"
import Username from "@rahoot/web/components/game/join/Username"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import Image from "next/image"
import { useEffect } from "react"
import toast from "react-hot-toast"
@@ -23,16 +21,9 @@ export default function Home() {
toast.error(message)
})
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
if (player) {
return <Username />
}
<Image src={logo} className="mb-6 h-32" alt="logo" />
{!player ? <Room /> : <Username />}
</section>
)
return <Room />
}

View File

@@ -1,6 +1,6 @@
"use client"
import { Status } from "@rahoot/common/types/game/status"
import { STATUS } from "@rahoot/common/types/game/status"
import GameWrapper from "@rahoot/web/components/game/GameWrapper"
import Answers from "@rahoot/web/components/game/states/Answers"
import Prepared from "@rahoot/web/components/game/states/Prepared"
@@ -17,10 +17,9 @@ import toast from "react-hot-toast"
export default function Game() {
const router = useRouter()
const { socket, isConnected } = useSocket()
const { socket } = useSocket()
const { gameId: gameIdParam }: { gameId?: string } = useParams()
const { status, setPlayer, logout, setGameId, setStatus, resetStatus } =
usePlayerStore()
const { status, setPlayer, setGameId, setStatus, reset } = usePlayerStore()
const { setQuestionStates } = useQuestionStore()
useEvent("connect", () => {
@@ -45,16 +44,16 @@ export default function Game() {
}
})
useEvent("game:reset", () => {
useEvent("game:kick", () => {
router.replace("/")
resetStatus()
logout()
toast("The game has been reset by the host")
reset()
})
if (!isConnected) {
return null
}
useEvent("game:reset", () => {
router.replace("/")
reset()
toast("The game has been reset by the host")
})
if (!gameIdParam) {
return null
@@ -63,36 +62,36 @@ export default function Game() {
let component = null
switch (status.name) {
case Status.WAIT:
case STATUS.WAIT:
component = <Wait data={status.data} />
break
case Status.SHOW_START:
case STATUS.SHOW_START:
component = <Start data={status.data} />
break
case Status.SHOW_PREPARED:
case STATUS.SHOW_PREPARED:
component = <Prepared data={status.data} />
break
case Status.SHOW_QUESTION:
case STATUS.SHOW_QUESTION:
component = <Question data={status.data} />
break
case Status.SHOW_RESULT:
case STATUS.SHOW_RESULT:
component = <Result data={status.data} />
break
case Status.SELECT_ANSWER:
case STATUS.SELECT_ANSWER:
component = <Answers data={status.data} />
break
}
return <GameWrapper>{component}</GameWrapper>
return <GameWrapper statusName={status.name}>{component}</GameWrapper>
}

View File

@@ -1,6 +1,6 @@
"use client"
import { Status } from "@rahoot/common/types/game/status"
import { STATUS } from "@rahoot/common/types/game/status"
import GameWrapper from "@rahoot/web/components/game/GameWrapper"
import Answers from "@rahoot/web/components/game/states/Answers"
import Leaderboard from "@rahoot/web/components/game/states/Leaderboard"
@@ -15,15 +15,14 @@ import { useManagerStore } from "@rahoot/web/stores/manager"
import { useQuestionStore } from "@rahoot/web/stores/question"
import { GAME_STATE_COMPONENTS_MANAGER } from "@rahoot/web/utils/constants"
import { useParams, useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import toast from "react-hot-toast"
export default function ManagerGame() {
const router = useRouter()
const { gameId: gameIdParam }: { gameId?: string } = useParams()
const { socket, isConnected } = useSocket()
const [nextText, setNextText] = useState("Start")
const { gameId, status, setGameId, setStatus, setPlayers } = useManagerStore()
const { socket } = useSocket()
const { gameId, status, setGameId, setStatus, setPlayers, reset } =
useManagerStore()
const { setQuestionStates } = useQuestionStore()
useEvent("game:status", ({ name, data }) => {
@@ -41,6 +40,7 @@ export default function ManagerGame() {
useEvent(
"manager:successReconnect",
({ gameId, status, players, currentQuestion }) => {
console.log("manager:successReconnect", gameId)
setGameId(gameId)
setStatus(status.name, status.data)
setPlayers(players)
@@ -50,43 +50,32 @@ export default function ManagerGame() {
useEvent("game:reset", () => {
router.replace("/manager")
reset()
toast("Game is not available anymore")
})
useEffect(() => {
if (status.name === Status.SHOW_START) {
setNextText("Start")
}
}, [status.name])
if (!isConnected) {
return null
}
if (!gameId) {
return null
}
const handleSkip = () => {
setNextText("Skip")
if (!gameId) {
return
}
switch (status.name) {
case Status.SHOW_ROOM:
case STATUS.SHOW_ROOM:
socket?.emit("manager:startGame", { gameId })
break
case Status.SELECT_ANSWER:
case STATUS.SELECT_ANSWER:
socket?.emit("manager:abortQuiz", { gameId })
break
case Status.SHOW_RESPONSES:
case STATUS.SHOW_RESPONSES:
socket?.emit("manager:showLeaderboard", { gameId })
break
case Status.SHOW_LEADERBOARD:
case STATUS.SHOW_LEADERBOARD:
socket?.emit("manager:nextQuestion", { gameId })
break
@@ -96,49 +85,49 @@ export default function ManagerGame() {
let component = null
switch (status.name) {
case Status.SHOW_ROOM:
case STATUS.SHOW_ROOM:
component = <Room data={status.data} />
break
case Status.SHOW_START:
case STATUS.SHOW_START:
component = <Start data={status.data} />
break
case Status.SHOW_PREPARED:
case STATUS.SHOW_PREPARED:
component = <Prepared data={status.data} />
break
case Status.SHOW_QUESTION:
case STATUS.SHOW_QUESTION:
component = <Question data={status.data} />
break
case Status.SELECT_ANSWER:
case STATUS.SELECT_ANSWER:
component = <Answers data={status.data} />
break
case Status.SHOW_RESPONSES:
case STATUS.SHOW_RESPONSES:
component = <Responses data={status.data} />
break
case Status.SHOW_LEADERBOARD:
case STATUS.SHOW_LEADERBOARD:
component = <Leaderboard data={status.data} />
break
case Status.FINISHED:
case STATUS.FINISHED:
component = <Podium data={status.data} />
break
}
return (
<GameWrapper textNext={nextText} onNext={handleSkip} manager>
<GameWrapper statusName={status.name} onNext={handleSkip} manager>
{component}
</GameWrapper>
)

View File

@@ -5,6 +5,11 @@
--color-secondary: #1a140b;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
.btn-shadow {
box-shadow: rgba(0, 0, 0, 0.25) 0px -4px inset;
}

View File

@@ -1,6 +1,12 @@
import loader from "@rahoot/web/assets/loader.svg"
import Image from "next/image"
export default function Loader() {
return <Image alt="loader" src={loader} />
type Props = {
className?: string
}
const Loader = ({ className }: Props) => (
<Image className={className} alt="loader" src={loader} />
)
export default Loader

View File

@@ -1,41 +1,32 @@
"use client"
import { Status } from "@rahoot/common/types/game/status"
import background from "@rahoot/web/assets/background.webp"
import Button from "@rahoot/web/components/Button"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player"
import { useQuestionStore } from "@rahoot/web/stores/question"
import { MANAGER_SKIP_BTN } from "@rahoot/web/utils/constants"
import Image from "next/image"
import { useRouter } from "next/navigation"
import { PropsWithChildren, useEffect } from "react"
import { PropsWithChildren } from "react"
import Loader from "../Loader"
type Props = PropsWithChildren & {
textNext?: string
statusName: Status
onNext?: () => void
manager?: boolean
}
export default function GameWrapper({
children,
textNext,
statusName,
onNext,
manager,
}: Props) {
const { isConnected, connect } = useSocket()
const { player, logout } = usePlayerStore()
const { isConnected } = useSocket()
const { player } = usePlayerStore()
const { questionStates, setQuestionStates } = useQuestionStore()
const router = useRouter()
useEffect(() => {
if (!isConnected) {
connect()
}
}, [connect, isConnected])
useEvent("game:kick", () => {
logout()
router.replace("/")
})
const next = MANAGER_SKIP_BTN[statusName] || null
useEvent("game:updateQuestion", ({ current, total }) => {
setQuestionStates({
@@ -54,32 +45,41 @@ export default function GameWrapper({
/>
</div>
<div className="flex w-full justify-between p-4">
{questionStates && (
<div className="shadow-inset flex items-center rounded-md bg-white p-2 px-4 text-lg font-bold text-black">
{`${questionStates.current} / ${questionStates.total}`}
</div>
)}
{manager && (
<Button
className="self-end bg-white px-4 !text-black"
onClick={onNext}
>
{textNext}
</Button>
)}
</div>
{children}
{!manager && (
<div className="z-50 flex items-center justify-between bg-white px-4 py-2 text-lg font-bold text-white">
<p className="text-gray-800">{player?.username}</p>
<div className="rounded-sm bg-gray-800 px-3 py-1 text-lg">
{player?.points}
</div>
{!isConnected && !statusName ? (
<div className="flex h-full w-full flex-1 flex-col items-center justify-center">
<Loader />
<h1 className="text-4xl font-bold text-white">Connecting...</h1>
</div>
) : (
<>
<div className="flex w-full justify-between p-4">
{questionStates && (
<div className="shadow-inset flex items-center rounded-md bg-white p-2 px-4 text-lg font-bold text-black">
{`${questionStates.current} / ${questionStates.total}`}
</div>
)}
{manager && next && (
<Button
className="self-end bg-white px-4 !text-black"
onClick={onNext}
>
{next}
</Button>
)}
</div>
{children}
{!manager && (
<div className="z-50 flex items-center justify-between bg-white px-4 py-2 text-lg font-bold text-white">
<p className="text-gray-800">{player?.username}</p>
<div className="rounded-sm bg-gray-800 px-3 py-1 text-lg">
{player?.points}
</div>
</div>
)}
</>
)}
</section>
)

View File

@@ -1,15 +1,12 @@
import logo from "@rahoot/web/assets/logo.svg"
import Button from "@rahoot/web/components/Button"
import Form from "@rahoot/web/components/Form"
import Input from "@rahoot/web/components/Input"
import { useEvent } from "@rahoot/web/contexts/socketProvider"
import Image from "next/image"
import { KeyboardEvent, useState } from "react"
import toast from "react-hot-toast"
type Props = {
// eslint-disable-next-line no-unused-vars
onSubmit: (password: string) => void
onSubmit: (_password: string) => void
}
export default function ManagerPassword({ onSubmit }: Props) {
@@ -30,23 +27,14 @@ export default function ManagerPassword({ onSubmit }: Props) {
})
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<Image src={logo} className="mb-6 h-32" alt="logo" />
<Form>
<Input
type="password"
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Manager password"
/>
<Button onClick={handleSubmit}>Submit</Button>
</Form>
</section>
<Form>
<Input
type="password"
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Manager password"
/>
<Button onClick={handleSubmit}>Submit</Button>
</Form>
)
}

View File

@@ -1,8 +1,6 @@
import { Quizz } from "@rahoot/common/types/game"
import logo from "@rahoot/web/assets/logo.svg"
import Button from "@rahoot/web/components/Button"
import clsx from "clsx"
import Image from "next/image"
import { useState } from "react"
import toast from "react-hot-toast"
@@ -10,8 +8,7 @@ type QuizzWithId = Quizz & { id: string }
type Props = {
quizzList: QuizzWithId[]
// eslint-disable-next-line no-unused-vars
onSelect: (id: string) => void
onSelect: (_id: string) => void
}
export default function SelectQuizz({ quizzList, onSelect }: Props) {
@@ -36,44 +33,32 @@ export default function SelectQuizz({ quizzList, onSelect }: Props) {
}
return (
<section className="relative flex min-h-screen flex-col items-center justify-center">
<div className="absolute h-full w-full overflow-hidden">
<div className="bg-primary/15 absolute -top-[15vmin] -left-[15vmin] min-h-[75vmin] min-w-[75vmin] rounded-full"></div>
<div className="bg-primary/15 absolute -right-[15vmin] -bottom-[15vmin] min-h-[75vmin] min-w-[75vmin] rotate-45"></div>
</div>
<div className="z-10 flex w-full max-w-md flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
<div className="flex flex-col items-center justify-center">
<h1 className="mb-2 text-2xl font-bold">Select a quizz</h1>
<div className="w-full space-y-2">
{quizzList.map((quizz) => (
<button
key={quizz.id}
className={clsx(
"flex w-full items-center justify-between rounded-md p-3 outline outline-gray-300",
)}
onClick={handleSelect(quizz.id)}
>
{quizz.subject}
<Image src={logo} className="mb-6 h-32" alt="logo" />
<div className="z-10 flex w-full max-w-md flex-col gap-4 rounded-md bg-white p-4 shadow-sm">
<div className="flex flex-col items-center justify-center">
<h1 className="mb-2 text-2xl font-bold">Select a quizz</h1>
<div className="w-full space-y-2">
{quizzList.map((quizz) => (
<button
key={quizz.id}
<div
className={clsx(
"flex w-full items-center justify-between rounded-md p-3 outline outline-gray-300",
{
"border-primary outline-primary outline-2":
selected === quizz.id,
},
"h-5 w-5 rounded outline outline-offset-3 outline-gray-300",
selected === quizz.id &&
"bg-primary border-primary/80 shadow-inset",
)}
onClick={handleSelect(quizz.id)}
>
{quizz.subject}
<div
className={clsx(
"h-4 w-4 rounded-sm outline-2 outline-gray-300",
selected === quizz.id && "bg-primary outline-primary/50",
)}
></div>
</button>
))}
</div>
></div>
</button>
))}
</div>
<Button onClick={handleSubmit}>Submit</Button>
</div>
</section>
<Button onClick={handleSubmit}>Submit</Button>
</div>
)
}

View File

@@ -17,9 +17,9 @@ export default function Pentagon({ className, fill, stroke }: Props) {
viewBox="-40.96 -40.96 593.93 593.93"
transform="rotate(180)"
stroke={fill}
stroke-width="0.005120100000000001"
strokeWidth="0.005120100000000001"
>
<g stroke-width="0" />
<g strokeWidth="0" />
<g
strokeLinecap="round"

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
import { Player } from "@rahoot/common/types/game"
import { StatusDataMap } from "@rahoot/common/types/game/status"
import { createStatus, Status } from "@rahoot/web/utils/createStatus"
@@ -9,22 +8,26 @@ type ManagerStore<T> = {
status: Status<T>
players: Player[]
setGameId: (gameId: string | null) => void
setStatus: <K extends keyof T>(name: K, data: T[K]) => void
setGameId: (_gameId: string | null) => void
setStatus: <K extends keyof T>(_name: K, _data: T[K]) => void
resetStatus: () => void
setPlayers: (_players: Player[]) => void
setPlayers: (players: Player[]) => void
reset: () => void
}
const initialStatus = createStatus<StatusDataMap, "SHOW_ROOM">("SHOW_ROOM", {
text: "Waiting for the players",
})
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
const initialState = {
gameId: null,
status: initialStatus,
players: [],
}
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
...initialState,
setGameId: (gameId) => set({ gameId }),
@@ -32,4 +35,6 @@ export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
resetStatus: () => set({ status: initialStatus }),
setPlayers: (players) => set({ players }),
reset: () => set(initialState),
}))

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
import { StatusDataMap } from "@rahoot/common/types/game/status"
import { createStatus, Status } from "@rahoot/web/utils/createStatus"
import { create } from "zustand"
@@ -13,27 +12,30 @@ type PlayerStore<T> = {
player: PlayerState | null
status: Status<T>
setGameId: (gameId: string | null) => void
setGameId: (_gameId: string | null) => void
setPlayer: (state: PlayerState) => void
login: (gameId: string) => void
join: (username: string) => void
updatePoints: (points: number) => void
logout: () => void
setPlayer: (_state: PlayerState) => void
login: (_gameId: string) => void
join: (_username: string) => void
updatePoints: (_points: number) => void
setStatus: <K extends keyof T>(name: K, data: T[K]) => void
resetStatus: () => void
setStatus: <K extends keyof T>(_name: K, _data: T[K]) => void
reset: () => void
}
const initialStatus = createStatus<StatusDataMap, "WAIT">("WAIT", {
text: "Waiting for the players",
})
export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({
const initialState = {
gameId: null,
player: null,
status: initialStatus,
currentQuestion: null,
}
export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({
...initialState,
setGameId: (gameId) => set({ gameId }),
@@ -55,8 +57,7 @@ export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({
player: { ...state.player, points },
})),
logout: () => set({ player: null }),
setStatus: (name, data) => set({ status: createStatus(name, data) }),
resetStatus: () => set({ status: initialStatus }),
reset: () => set(initialState),
}))

View File

@@ -1,10 +1,9 @@
/* eslint-disable no-unused-vars */
import { GameUpdateQuestion } from "@rahoot/common/types/game"
import { create } from "zustand"
type QuestionStore = {
questionStates: GameUpdateQuestion | null
setQuestionStates: (state: GameUpdateQuestion) => void
setQuestionStates: (_state: GameUpdateQuestion) => void
}
export const useQuestionStore = create<QuestionStore>((set) => ({

View File

@@ -9,7 +9,7 @@ import Room from "@rahoot/web/components/game/states/Room"
import Start from "@rahoot/web/components/game/states/Start"
import Wait from "@rahoot/web/components/game/states/Wait"
import { Status } from "@rahoot/common/types/game/status"
import { STATUS } from "@rahoot/common/types/game/status"
import Circle from "@rahoot/web/components/icons/Circle"
import Rhombus from "@rahoot/web/components/icons/Rhombus"
import Square from "@rahoot/web/components/icons/Square"
@@ -26,7 +26,7 @@ export const ANSWERS_ICONS = [Triangle, Rhombus, Circle, Square]
export const GAME_STATES = {
status: {
name: Status.WAIT,
name: STATUS.WAIT,
data: { text: "Waiting for the players" },
},
question: {
@@ -36,20 +36,20 @@ export const GAME_STATES = {
}
export const GAME_STATE_COMPONENTS = {
[Status.SELECT_ANSWER]: Answers,
[Status.SHOW_QUESTION]: Question,
[Status.WAIT]: Wait,
[Status.SHOW_START]: Start,
[Status.SHOW_RESULT]: Result,
[Status.SHOW_PREPARED]: Prepared,
[STATUS.SELECT_ANSWER]: Answers,
[STATUS.SHOW_QUESTION]: Question,
[STATUS.WAIT]: Wait,
[STATUS.SHOW_START]: Start,
[STATUS.SHOW_RESULT]: Result,
[STATUS.SHOW_PREPARED]: Prepared,
}
export const GAME_STATE_COMPONENTS_MANAGER = {
...GAME_STATE_COMPONENTS,
[Status.SHOW_ROOM]: Room,
[Status.SHOW_RESPONSES]: Responses,
[Status.SHOW_LEADERBOARD]: Leaderboard,
[Status.FINISHED]: Podium,
[STATUS.SHOW_ROOM]: Room,
[STATUS.SHOW_RESPONSES]: Responses,
[STATUS.SHOW_LEADERBOARD]: Leaderboard,
[STATUS.FINISHED]: Podium,
}
export const SFX_ANSWERS_MUSIC = "/sounds/answersMusic.mp3"
@@ -61,3 +61,16 @@ export const SFX_PODIUM_THREE = "/sounds/three.mp3"
export const SFX_PODIUM_SECOND = "/sounds/second.mp3"
export const SFX_PODIUM_FIRST = "/sounds/first.mp3"
export const SFX_SNEAR_ROOL = "/sounds/snearRoll.mp3"
export const MANAGER_SKIP_BTN = {
[STATUS.SHOW_ROOM]: "Start Game",
[STATUS.SHOW_START]: null,
[STATUS.SHOW_PREPARED]: null,
[STATUS.SHOW_QUESTION]: null,
[STATUS.SELECT_ANSWER]: "Skip",
[STATUS.SHOW_RESULT]: null,
[STATUS.SHOW_RESPONSES]: "Next",
[STATUS.SHOW_LEADERBOARD]: "Next",
[STATUS.FINISHED]: null,
[STATUS.WAIT]: null,
}

837
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff