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 # Build Next.js app with standalone output for smaller runtime image
WORKDIR /app/packages/web WORKDIR /app/packages/web
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build RUN pnpm build
# Build socket server if needed (TypeScript or similar) # 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 the socket server build
COPY --from=builder /app/packages/socket/dist ./packages/socket/dist 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 the web and socket ports
EXPOSE 3000 5505 EXPOSE 3000 5505

View File

@@ -6,7 +6,8 @@
"dev:socket": "dotenv -e .env -- pnpm --filter socket dev", "dev:socket": "dotenv -e .env -- pnpm --filter socket dev",
"build": "pnpm -r run build", "build": "pnpm -r run build",
"start": "pnpm -r --parallel run start", "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": { "devDependencies": {
"dotenv-cli": "^10.0.0", "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", "name": "@rahoot/common",
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": {
"lint": "eslint"
},
"dependencies": { "dependencies": {
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"zod": "^3.22.4" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.0", "@eslint/js": "^9.38.0",
"typescript": "^5.3.3" "@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 connect: () => void
// Game events // Game events
"game:status": (data: { name: Status; data: StatusDataMap[Status] }) => void "game:status": (_data: { name: Status; data: StatusDataMap[Status] }) => void
"game:successRoom": (data: string) => void "game:successRoom": (_data: string) => void
"game:successJoin": (gameId: string) => void "game:successJoin": (_gameId: string) => void
"game:totalPlayers": (count: number) => void "game:totalPlayers": (_count: number) => void
"game:errorMessage": (message: string) => void "game:errorMessage": (_message: string) => void
"game:startCooldown": () => void "game:startCooldown": () => void
"game:cooldown": (count: number) => void "game:cooldown": (_count: number) => void
"game:kick": () => void "game:kick": () => void
"game:reset": () => void "game:reset": () => void
"game:updateQuestion": (data: { current: number; total: number }) => void "game:updateQuestion": (_data: { current: number; total: number }) => void
"game:playerAnswer": (count: number) => void "game:playerAnswer": (_count: number) => void
// Player events // Player events
"player:successReconnect": (data: { "player:successReconnect": (_data: {
gameId: string gameId: string
status: { name: Status; data: StatusDataMap[Status] } status: { name: Status; data: StatusDataMap[Status] }
player: { username: string; points: number } player: { username: string; points: number }
currentQuestion: GameUpdateQuestion currentQuestion: GameUpdateQuestion
}) => void }) => void
"player:updateLeaderboard": (data: { leaderboard: Player[] }) => void "player:updateLeaderboard": (_data: { leaderboard: Player[] }) => void
// Manager events // Manager events
"manager:successReconnect": (data: { "manager:successReconnect": (_data: {
gameId: string gameId: string
status: { name: Status; data: StatusDataMap[Status] } status: { name: Status; data: StatusDataMap[Status] }
players: Player[] players: Player[]
currentQuestion: GameUpdateQuestion currentQuestion: GameUpdateQuestion
}) => void }) => void
"manager:quizzList": (quizzList: QuizzWithId[]) => void "manager:quizzList": (_quizzList: QuizzWithId[]) => void
"manager:gameCreated": (data: { gameId: string; inviteCode: string }) => void "manager:gameCreated": (_data: { gameId: string; inviteCode: string }) => void
"manager:statusUpdate": (data: { "manager:statusUpdate": (_data: {
status: Status status: Status
data: StatusDataMap[Status] data: StatusDataMap[Status]
}) => void }) => void
"manager:newPlayer": (player: Player) => void "manager:newPlayer": (_player: Player) => void
"manager:removePlayer": (playerId: string) => void "manager:removePlayer": (_playerId: string) => void
"manager:errorMessage": (message: string) => void "manager:errorMessage": (_message: string) => void
"manager:playerKicked": (playerId: string) => void "manager:playerKicked": (_playerId: string) => void
} }
export interface ClientToServerEvents { export interface ClientToServerEvents {
// Manager actions // Manager actions
"game:create": (quizzId: string) => void "game:create": (_quizzId: string) => void
"manager:auth": (password: string) => void "manager:auth": (_password: string) => void
"manager:reconnect": (message: { gameId: string }) => void "manager:reconnect": (_message: { gameId: string }) => void
"manager:kickPlayer": ( "manager:kickPlayer": (
message: MessageWithoutStatus<{ playerId: string }> _message: MessageWithoutStatus<{ playerId: string }>
) => void ) => void
"manager:startGame": (message: MessageGameId) => void "manager:startGame": (_message: MessageGameId) => void
"manager:abortQuiz": (message: MessageGameId) => void "manager:abortQuiz": (_message: MessageGameId) => void
"manager:nextQuestion": (message: MessageGameId) => void "manager:nextQuestion": (_message: MessageGameId) => void
"manager:showLeaderboard": (message: MessageGameId) => void "manager:showLeaderboard": (_message: MessageGameId) => void
// Player actions // Player actions
"player:join": (inviteCode: string) => void "player:join": (_inviteCode: string) => void
"player:login": (message: MessageWithoutStatus<{ username: string }>) => void "player:login": (_message: MessageWithoutStatus<{ username: string }>) => void
"player:reconnect": (message: { gameId: string }) => void "player:reconnect": (_message: { gameId: string }) => void
"player:selectedAnswer": ( "player:selectedAnswer": (
message: MessageWithoutStatus<{ answerKey: number }> _message: MessageWithoutStatus<{ answerKey: number }>
) => void ) => void
// Common // Common

View File

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

View File

@@ -1,4 +1,5 @@
{ {
"extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"module": "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": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "node esbuild.config.js", "build": "node esbuild.config.js",
"start": "node dist/index.cjs" "start": "node dist/index.cjs",
"lint": "eslint"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -13,12 +14,18 @@
"dependencies": { "dependencies": {
"@rahoot/common": "workspace:*", "@rahoot/common": "workspace:*",
"@t3-oss/env-core": "^0.13.8", "@t3-oss/env-core": "^0.13.8",
"dayjs": "^1.11.18",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zod": "^4.1.11" "zod": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.38.0",
"@types/node": "^24.8.1",
"esbuild": "^0.25.10", "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 { Server } from "@rahoot/common/types/game/socket"
import { inviteCodeValidator } from "@rahoot/common/validators/auth"
import env from "@rahoot/socket/env" import env from "@rahoot/socket/env"
import Config from "@rahoot/socket/services/config" import Config from "@rahoot/socket/services/config"
import Game from "@rahoot/socket/services/game" import Game from "@rahoot/socket/services/game"
import { import Registry from "@rahoot/socket/services/registry"
findManagerGameByClientId, import { withGame } from "@rahoot/socket/utils/game"
findPlayerGameByClientId,
withGame,
} from "@rahoot/socket/utils/game"
import { inviteCodeValidator } from "@rahoot/socket/utils/validator"
import { Server as ServerIO } from "socket.io" import { Server as ServerIO } from "socket.io"
const io: Server = new ServerIO() const io: Server = new ServerIO()
Config.init() Config.init()
let games: Game[] = [] const registry = Registry.getInstance()
const port = env.SOCKER_PORT || 3001 const port = env.SOCKER_PORT || 3001
console.log(`Socket server running on port ${port}`) console.log(`Socket server running on port ${port}`)
io.listen(Number(port)) io.listen(Number(port))
io.on("connection", (socket) => { io.on("connection", (socket) => {
console.log(`A user connected ${socket.id}`) console.log(
console.log(socket.handshake.auth) `A user connected: socketId: ${socket.id}, clientId: ${socket.handshake.auth.clientId}`
)
socket.on("player:reconnect", () => { socket.on("player:reconnect", () => {
const game = findPlayerGameByClientId(socket.handshake.auth.clientId, games) const game = registry.getPlayerGame(socket.handshake.auth.clientId)
if (game) { if (game) {
game.reconnect(socket) game.reconnect(socket)
@@ -36,13 +34,11 @@ io.on("connection", (socket) => {
}) })
socket.on("manager:reconnect", () => { socket.on("manager:reconnect", () => {
const game = findManagerGameByClientId( const game = registry.getManagerGame(socket.handshake.auth.clientId)
socket.handshake.auth.clientId,
games
)
if (game) { if (game) {
game.reconnect(socket) game.reconnect(socket)
registry.reactivateGame(game.gameId)
return return
} }
@@ -56,6 +52,7 @@ io.on("connection", (socket) => {
if (password !== config.managerPassword) { if (password !== config.managerPassword) {
socket.emit("manager:errorMessage", "Invalid password") socket.emit("manager:errorMessage", "Invalid password")
return return
} }
@@ -72,23 +69,24 @@ io.on("connection", (socket) => {
if (!quizz) { if (!quizz) {
socket.emit("game:errorMessage", "Quizz not found") socket.emit("game:errorMessage", "Quizz not found")
return return
} }
const game = new Game(io, socket, quizz) const game = new Game(io, socket, quizz)
registry.addGame(game)
games.push(game)
}) })
socket.on("player:join", async (inviteCode) => { socket.on("player:join", (inviteCode) => {
const result = inviteCodeValidator.safeParse(inviteCode) const result = inviteCodeValidator.safeParse(inviteCode)
if (result.error) { if (result.error) {
socket.emit("game:errorMessage", result.error.issues[0].message) socket.emit("game:errorMessage", result.error.issues[0].message)
return return
} }
const game = games.find((g) => g.inviteCode === inviteCode) const game = registry.getGameByInviteCode(inviteCode)
if (!game) { if (!game) {
socket.emit("game:errorMessage", "Game not found") socket.emit("game:errorMessage", "Game not found")
@@ -100,53 +98,54 @@ io.on("connection", (socket) => {
}) })
socket.on("player:login", ({ gameId, data }) => 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 }) => socket.on("manager:kickPlayer", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) => withGame(gameId, socket, (game) => game.kickPlayer(socket, data.playerId))
game.kickPlayer(socket, data.playerId)
)
) )
socket.on("manager:startGame", ({ gameId }) => 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 }) => socket.on("player:selectedAnswer", ({ gameId, data }) =>
withGame(gameId, socket, games, (game) => withGame(gameId, socket, (game) =>
game.selectAnswer(socket, data.answerKey) game.selectAnswer(socket, data.answerKey)
) )
) )
socket.on("manager:abortQuiz", ({ gameId }) => 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 }) => 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 }) => socket.on("manager:showLeaderboard", ({ gameId }) =>
withGame(gameId, socket, games, (game) => game.showLeaderboard()) withGame(gameId, socket, (game) => game.showLeaderboard())
) )
socket.on("disconnect", () => { socket.on("disconnect", () => {
console.log(`user disconnected ${socket.id}`) console.log(`user disconnected ${socket.id}`)
const managerGame = games.find((g) => g.manager.id === socket.id)
if (managerGame && !managerGame.started) { const managerGame = registry.getGameByManagerSocketId(socket.id)
console.log("Reset game (manager disconnected)")
managerGame.abortCooldown() if (managerGame) {
io.to(managerGame.gameId).emit("game:reset") 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) { if (!game || game.started) {
return return
@@ -166,3 +165,13 @@ io.on("connection", (socket) => {
console.log(`Removed player ${player.username} from game ${game.gameId}`) 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 fs from "fs"
import { resolve } from "path" import { resolve } from "path"
const getPath = (path: string) => resolve(process.cwd(), "../../config", path) const getPath = (path: string = "") =>
resolve(process.cwd(), "../../config", path)
class Config { class Config {
static init() { static init() {
const isConfigFolderExists = fs.existsSync(getPath())
if (!isConfigFolderExists) {
fs.mkdirSync(getPath())
}
const isGameConfigExists = fs.existsSync(getPath("game.json")) const isGameConfigExists = fs.existsSync(getPath("game.json"))
if (!isGameConfigExists) { if (!isGameConfigExists) {
fs.writeFileSync( fs.writeFileSync(
getPath("game.json"), getPath("game.json"),
@@ -22,6 +30,7 @@ class Config {
} }
const isQuizzExists = fs.existsSync(getPath("quizz")) const isQuizzExists = fs.existsSync(getPath("quizz"))
if (!isQuizzExists) { if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz")) fs.mkdirSync(getPath("quizz"))
@@ -65,20 +74,25 @@ class Config {
static game() { static game() {
const isExists = fs.existsSync(getPath("game.json")) const isExists = fs.existsSync(getPath("game.json"))
if (!isExists) { if (!isExists) {
throw new Error("Game config not found") throw new Error("Game config not found")
} }
try { try {
const config = fs.readFileSync(getPath("game.json"), "utf-8") const config = fs.readFileSync(getPath("game.json"), "utf-8")
return JSON.parse(config) return JSON.parse(config)
} catch (error) { } catch (error) {
console.error("Failed to read game config:", error) console.error("Failed to read game config:", error)
} }
return {}
} }
static quizz() { static quizz() {
const isExists = fs.existsSync(getPath("quizz")) const isExists = fs.existsSync(getPath("quizz"))
if (!isExists) { if (!isExists) {
return [] return []
} }
@@ -99,9 +113,11 @@ class Config {
...config, ...config,
} }
}) })
return quizz || [] return quizz || []
} catch (error) { } catch (error) {
console.error("Failed to read quizz config:", error) console.error("Failed to read quizz config:", error)
return [] return []
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
"use client" "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 { useSocket } from "@rahoot/web/contexts/socketProvider"
import Image from "next/image"
import { PropsWithChildren, useEffect } from "react" import { PropsWithChildren, useEffect } from "react"
const AuthLayout = ({ children }: PropsWithChildren) => { const AuthLayout = ({ children }: PropsWithChildren) => {
@@ -11,7 +14,34 @@ const AuthLayout = ({ children }: PropsWithChildren) => {
} }
}, [connect, isConnected]) }, [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 export default AuthLayout

View File

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

View File

@@ -1,11 +1,9 @@
"use client" "use client"
import logo from "@rahoot/web/assets/logo.svg"
import Room from "@rahoot/web/components/game/join/Room" import Room from "@rahoot/web/components/game/join/Room"
import Username from "@rahoot/web/components/game/join/Username" import Username from "@rahoot/web/components/game/join/Username"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider" import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import { usePlayerStore } from "@rahoot/web/stores/player" import { usePlayerStore } from "@rahoot/web/stores/player"
import Image from "next/image"
import { useEffect } from "react" import { useEffect } from "react"
import toast from "react-hot-toast" import toast from "react-hot-toast"
@@ -23,16 +21,9 @@ export default function Home() {
toast.error(message) toast.error(message)
}) })
return ( if (player) {
<section className="relative flex min-h-screen flex-col items-center justify-center"> return <Username />
<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" /> return <Room />
{!player ? <Room /> : <Username />}
</section>
)
} }

View File

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

View File

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

View File

@@ -5,6 +5,11 @@
--color-secondary: #1a140b; --color-secondary: #1a140b;
} }
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
.btn-shadow { .btn-shadow {
box-shadow: rgba(0, 0, 0, 0.25) 0px -4px inset; 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 loader from "@rahoot/web/assets/loader.svg"
import Image from "next/image" import Image from "next/image"
export default function Loader() { type Props = {
return <Image alt="loader" src={loader} /> className?: string
} }
const Loader = ({ className }: Props) => (
<Image className={className} alt="loader" src={loader} />
)
export default Loader

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
import { Player } from "@rahoot/common/types/game" import { Player } from "@rahoot/common/types/game"
import { StatusDataMap } from "@rahoot/common/types/game/status" import { StatusDataMap } from "@rahoot/common/types/game/status"
import { createStatus, Status } from "@rahoot/web/utils/createStatus" import { createStatus, Status } from "@rahoot/web/utils/createStatus"
@@ -9,22 +8,26 @@ type ManagerStore<T> = {
status: Status<T> status: Status<T>
players: Player[] players: Player[]
setGameId: (gameId: string | null) => void setGameId: (_gameId: string | null) => void
setStatus: <K extends keyof T>(_name: K, _data: T[K]) => void
setStatus: <K extends keyof T>(name: K, data: T[K]) => void
resetStatus: () => void resetStatus: () => void
setPlayers: (_players: Player[]) => void
setPlayers: (players: Player[]) => void reset: () => void
} }
const initialStatus = createStatus<StatusDataMap, "SHOW_ROOM">("SHOW_ROOM", { const initialStatus = createStatus<StatusDataMap, "SHOW_ROOM">("SHOW_ROOM", {
text: "Waiting for the players", text: "Waiting for the players",
}) })
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({ const initialState = {
gameId: null, gameId: null,
status: initialStatus, status: initialStatus,
players: [], players: [],
}
export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
...initialState,
setGameId: (gameId) => set({ gameId }), setGameId: (gameId) => set({ gameId }),
@@ -32,4 +35,6 @@ export const useManagerStore = create<ManagerStore<StatusDataMap>>((set) => ({
resetStatus: () => set({ status: initialStatus }), resetStatus: () => set({ status: initialStatus }),
setPlayers: (players) => set({ players }), 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 { StatusDataMap } from "@rahoot/common/types/game/status"
import { createStatus, Status } from "@rahoot/web/utils/createStatus" import { createStatus, Status } from "@rahoot/web/utils/createStatus"
import { create } from "zustand" import { create } from "zustand"
@@ -13,27 +12,30 @@ type PlayerStore<T> = {
player: PlayerState | null player: PlayerState | null
status: Status<T> status: Status<T>
setGameId: (gameId: string | null) => void setGameId: (_gameId: string | null) => void
setPlayer: (state: PlayerState) => void setPlayer: (_state: PlayerState) => void
login: (gameId: string) => void login: (_gameId: string) => void
join: (username: string) => void join: (_username: string) => void
updatePoints: (points: number) => void updatePoints: (_points: number) => void
logout: () => void
setStatus: <K extends keyof T>(name: K, data: T[K]) => void setStatus: <K extends keyof T>(_name: K, _data: T[K]) => void
resetStatus: () => void
reset: () => void
} }
const initialStatus = createStatus<StatusDataMap, "WAIT">("WAIT", { const initialStatus = createStatus<StatusDataMap, "WAIT">("WAIT", {
text: "Waiting for the players", text: "Waiting for the players",
}) })
export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({ const initialState = {
gameId: null, gameId: null,
player: null, player: null,
status: initialStatus, status: initialStatus,
currentQuestion: null, }
export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({
...initialState,
setGameId: (gameId) => set({ gameId }), setGameId: (gameId) => set({ gameId }),
@@ -55,8 +57,7 @@ export const usePlayerStore = create<PlayerStore<StatusDataMap>>((set) => ({
player: { ...state.player, points }, player: { ...state.player, points },
})), })),
logout: () => set({ player: null }),
setStatus: (name, data) => set({ status: createStatus(name, data) }), 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 { GameUpdateQuestion } from "@rahoot/common/types/game"
import { create } from "zustand" import { create } from "zustand"
type QuestionStore = { type QuestionStore = {
questionStates: GameUpdateQuestion | null questionStates: GameUpdateQuestion | null
setQuestionStates: (state: GameUpdateQuestion) => void setQuestionStates: (_state: GameUpdateQuestion) => void
} }
export const useQuestionStore = create<QuestionStore>((set) => ({ 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 Start from "@rahoot/web/components/game/states/Start"
import Wait from "@rahoot/web/components/game/states/Wait" 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 Circle from "@rahoot/web/components/icons/Circle"
import Rhombus from "@rahoot/web/components/icons/Rhombus" import Rhombus from "@rahoot/web/components/icons/Rhombus"
import Square from "@rahoot/web/components/icons/Square" import Square from "@rahoot/web/components/icons/Square"
@@ -26,7 +26,7 @@ export const ANSWERS_ICONS = [Triangle, Rhombus, Circle, Square]
export const GAME_STATES = { export const GAME_STATES = {
status: { status: {
name: Status.WAIT, name: STATUS.WAIT,
data: { text: "Waiting for the players" }, data: { text: "Waiting for the players" },
}, },
question: { question: {
@@ -36,20 +36,20 @@ export const GAME_STATES = {
} }
export const GAME_STATE_COMPONENTS = { export const GAME_STATE_COMPONENTS = {
[Status.SELECT_ANSWER]: Answers, [STATUS.SELECT_ANSWER]: Answers,
[Status.SHOW_QUESTION]: Question, [STATUS.SHOW_QUESTION]: Question,
[Status.WAIT]: Wait, [STATUS.WAIT]: Wait,
[Status.SHOW_START]: Start, [STATUS.SHOW_START]: Start,
[Status.SHOW_RESULT]: Result, [STATUS.SHOW_RESULT]: Result,
[Status.SHOW_PREPARED]: Prepared, [STATUS.SHOW_PREPARED]: Prepared,
} }
export const GAME_STATE_COMPONENTS_MANAGER = { export const GAME_STATE_COMPONENTS_MANAGER = {
...GAME_STATE_COMPONENTS, ...GAME_STATE_COMPONENTS,
[Status.SHOW_ROOM]: Room, [STATUS.SHOW_ROOM]: Room,
[Status.SHOW_RESPONSES]: Responses, [STATUS.SHOW_RESPONSES]: Responses,
[Status.SHOW_LEADERBOARD]: Leaderboard, [STATUS.SHOW_LEADERBOARD]: Leaderboard,
[Status.FINISHED]: Podium, [STATUS.FINISHED]: Podium,
} }
export const SFX_ANSWERS_MUSIC = "/sounds/answersMusic.mp3" 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_SECOND = "/sounds/second.mp3"
export const SFX_PODIUM_FIRST = "/sounds/first.mp3" export const SFX_PODIUM_FIRST = "/sounds/first.mp3"
export const SFX_SNEAR_ROOL = "/sounds/snearRoll.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