├── server ├── vite-env.d.ts ├── types │ └── user.ts ├── target │ └── npmlist.json ├── db │ ├── models │ │ └── cards.ts │ ├── routes │ │ └── cards.router.ts │ └── services │ │ └── database.service.ts ├── tsconfig.json ├── Dockerfile ├── vite.config.ts ├── socket.ts ├── package.json ├── test │ ├── index.test.ts │ ├── socket.test.ts │ └── utils.test.ts ├── index.ts └── utils │ └── utils.ts ├── client ├── src │ ├── vite-env.d.ts │ ├── types │ │ ├── user.ts │ │ ├── cards.ts │ │ └── socketResponse.ts │ ├── assets │ │ ├── pizza.png │ │ ├── cowboyboot.png │ │ ├── jazzhands.png │ │ ├── miracleberry.png │ │ ├── fartandwalkaway.png │ │ ├── paperairplane.png │ │ ├── react.svg │ │ └── icon.svg │ ├── hooks │ │ ├── hooks.ts │ │ ├── useSocket.ts │ │ ├── style.utils.tsx │ │ └── functions.ts │ ├── main.tsx │ ├── route │ │ ├── ErrorRoute.tsx │ │ └── AppRoute.tsx │ ├── store │ │ └── store.ts │ ├── Pages │ │ ├── Game │ │ │ ├── GameScorer.tsx │ │ │ ├── CzarView.tsx │ │ │ ├── PlayerView.tsx │ │ │ └── Game.tsx │ │ ├── Lobby │ │ │ ├── CreateLobby.tsx │ │ │ ├── Lobby.tsx │ │ │ └── WaitingLobby.tsx │ │ ├── Rules │ │ │ └── Rules.tsx │ │ └── Home │ │ │ └── Home.tsx │ ├── index.css │ ├── reducers.ts │ ├── components │ │ └── Cards │ │ │ ├── BlackCard.tsx │ │ │ ├── WhiteCard.tsx │ │ │ ├── SpinningCard.tsx │ │ │ └── WhiteLobbyCard.tsx │ ├── testReducer.ts │ └── context │ │ ├── SocketContext.ts │ │ └── SocketContextComponent.tsx ├── tsconfig.node.json ├── setup.js ├── index.html ├── test │ ├── WhiteCard.test.tsx │ ├── BlackCard.test.tsx │ ├── GameScorer.test.tsx │ ├── AppRoute.test.tsx │ ├── SocketContextComponent.test.tsx │ ├── ErrorRoute.test.tsx │ ├── CreateLobby.test.tsx │ ├── Lobby.test.tsx │ ├── Rules.test.tsx │ ├── CzarView.test.tsx │ ├── WhiteLobbyCard.test.tsx │ ├── Home.test.tsx │ ├── WaitingLobby.test.tsx │ ├── SocketContext.test.ts │ ├── PlayerView.test.tsx │ └── Game.test.tsx ├── Dockerfile ├── tsconfig.json ├── vite.config.ts ├── public │ └── vite.svg └── package.json ├── demo.gif ├── sonar-project.properties ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ └── action.yml ├── .gitignore ├── renovate.json ├── LICENSE └── README.md /server/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /server/types/user.ts: -------------------------------------------------------------------------------- 1 | export interface user { [uid: string]: string } -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeleDallas/Cards-Against-Humanity/HEAD/demo.gif -------------------------------------------------------------------------------- /client/src/types/user.ts: -------------------------------------------------------------------------------- 1 | interface User { 2 | score: number, 3 | name: string, 4 | } 5 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=LeleDallas_Cards-Against-Humanity 2 | sonar.organization=leledallas -------------------------------------------------------------------------------- /client/src/assets/pizza.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeleDallas/Cards-Against-Humanity/HEAD/client/src/assets/pizza.png -------------------------------------------------------------------------------- /client/src/types/cards.ts: -------------------------------------------------------------------------------- 1 | export type Cards = { 2 | title: string, 3 | isBlack: boolean, 4 | id?: string 5 | } -------------------------------------------------------------------------------- /client/src/assets/cowboyboot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeleDallas/Cards-Against-Humanity/HEAD/client/src/assets/cowboyboot.png -------------------------------------------------------------------------------- /client/src/assets/jazzhands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeleDallas/Cards-Against-Humanity/HEAD/client/src/assets/jazzhands.png -------------------------------------------------------------------------------- /client/src/assets/miracleberry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeleDallas/Cards-Against-Humanity/HEAD/client/src/assets/miracleberry.png -------------------------------------------------------------------------------- /client/src/assets/fartandwalkaway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeleDallas/Cards-Against-Humanity/HEAD/client/src/assets/fartandwalkaway.png -------------------------------------------------------------------------------- /client/src/assets/paperairplane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeleDallas/Cards-Against-Humanity/HEAD/client/src/assets/paperairplane.png -------------------------------------------------------------------------------- /server/target/npmlist.json: -------------------------------------------------------------------------------- 1 | {"version":"1.0.0","name":"server","dependencies":{"dotenv":{"version":"16.0.3"},"express":{"version":"4.18.2"}}} -------------------------------------------------------------------------------- /server/db/models/cards.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | 3 | export default class Cards { 4 | constructor(public title: string, public isBlack: boolean, public id?: ObjectId) {} 5 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: GitHub Community Support 4 | url: https://github.com/LeleDallas/Cards-Against-Humanity/discussions 5 | about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /client/src/types/socketResponse.ts: -------------------------------------------------------------------------------- 1 | export type SocketRoomResponse = { 2 | success: boolean, 3 | data: { 4 | roomName: string 5 | users: Array 6 | } 7 | } 8 | export type SocketGameStartResponse = { 9 | success: boolean, 10 | isCzar: string 11 | } -------------------------------------------------------------------------------- /client/setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | Object.defineProperty(window, 'matchMedia', { 4 | value: () => { 5 | return { 6 | matches: false, 7 | addListener: () => { }, 8 | removeListener: () => { } 9 | }; 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /client/src/hooks/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux' 2 | import type { TypedUseSelectorHook } from 'react-redux' 3 | import { AppDispatch, RootState } from '../store/store' 4 | 5 | export const useAppDispatch: () => AppDispatch = useDispatch 6 | export const useAppSelector: TypedUseSelectorHook = useSelector -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | coverage 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | *.DS_Store 25 | .env 26 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from 'react-dom/client' 3 | import { BrowserRouter } from "react-router-dom"; 4 | import './index.css' 5 | import AppRoute from "./route/AppRoute"; 6 | 7 | 8 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | 16 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cards Against Humanity 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/hooks/useSocket.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import io, { ManagerOptions, Socket, SocketOptions } from 'socket.io-client'; 3 | 4 | export const useSocket = (url: string, options?: Partial | undefined): Socket => { 5 | const { current: socket } = useRef(io(url, options)); 6 | 7 | useEffect(() => { 8 | () => socket?.close(); 9 | }, [socket]); 10 | 11 | return socket; 12 | }; -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchPackagePatterns": [ 9 | "*" 10 | ], 11 | "matchUpdateTypes": [ 12 | "minor", 13 | "patch" 14 | ], 15 | "groupName": "all non-major dependencies", 16 | "groupSlug": "all-minor-patch", 17 | "additionalBranchPrefix": "{{manager}}-" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /client/test/WhiteCard.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe } from 'vitest' 4 | import { render } from '@testing-library/react'; 5 | import WhiteCard from '../src/components/Cards/WhiteCard'; 6 | 7 | 8 | describe('BlackCard', () => { 9 | it('renders card correctly', () => { 10 | const { getByText } = render( 11 | 12 | ); 13 | expect(getByText("Cards Against Humanity")).toBeInTheDocument() 14 | }); 15 | }); -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Node runtime as a parent image 2 | FROM node:22.12.0-alpine 3 | 4 | # Set the working directory to /app 5 | WORKDIR /app 6 | 7 | # Copy the package.json and package-lock.json files to the container 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm install 12 | 13 | # Copy the rest of the application files to the container 14 | COPY . . 15 | 16 | # Expose port 5173 for the application to run on 17 | EXPOSE 5173 18 | 19 | # Start the application 20 | CMD ["npm", "run", "dev"] -------------------------------------------------------------------------------- /client/test/BlackCard.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe } from 'vitest' 4 | import { render } from '@testing-library/react'; 5 | import BlackCard from '../src/components/Cards/BlackCard'; 6 | 7 | 8 | describe('BlackCard', () => { 9 | it('renders card correctly', () => { 10 | const { getByText } = render( 11 | 12 | ); 13 | expect(getByText("Cards Against Humanity? More like ____________.")).toBeInTheDocument() 14 | }); 15 | }); -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Node runtime as a parent image 2 | FROM node:22.12.0-alpine 3 | 4 | # Set the working directory to /app 5 | WORKDIR /app 6 | 7 | # Copy the package.json and package-lock.json files to the container 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm install 12 | 13 | # Copy the rest of the application files to the container 14 | COPY . . 15 | 16 | # Expose port 3000 for the application to run on 17 | EXPOSE 3000 18 | 19 | # Start the application 20 | CMD ["npm", "run", "dev"] -------------------------------------------------------------------------------- /server/db/routes/cards.router.ts: -------------------------------------------------------------------------------- 1 | import { Router, json, Request, Response } from "express"; 2 | 3 | import { collections } from "../services/database.service"; 4 | 5 | export const cardsRouter = Router(); 6 | 7 | cardsRouter.use(json()); 8 | 9 | cardsRouter.get("/cards", async (_req: Request, res: Response) => { 10 | try { 11 | const cards = (await collections.cards!.find({}).toArray()); 12 | return res.status(200).send(cards); 13 | } catch (error: any) { 14 | return res.status(500).send(error.message); 15 | } 16 | }); -------------------------------------------------------------------------------- /client/test/GameScorer.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe } from 'vitest' 4 | import { render } from '@testing-library/react' 5 | import { BrowserRouter } from 'react-router-dom'; 6 | import GameScorer from '../src/Pages/Game/GameScorer'; 7 | 8 | describe('Game Scorer', () => { 9 | it('renders without errors', async () => { 10 | const { getAllByText } = render( 11 | 12 | 13 | 14 | ) 15 | expect(getAllByText("No data")).toBeTruthy() 16 | }) 17 | }) -------------------------------------------------------------------------------- /client/test/AppRoute.test.tsx: -------------------------------------------------------------------------------- 1 | 2 | import '@testing-library/jest-dom'; 3 | import React from 'react'; 4 | import { expect, it, describe } from 'vitest' 5 | import { render } from '@testing-library/react'; 6 | import AppRoute from '../src/route/AppRoute'; 7 | import { BrowserRouter } from 'react-router-dom'; 8 | 9 | describe('AppRoute', () => { 10 | it('renders route correctly', () => { 11 | const { getByText } = render( 12 | 13 | 14 | 15 | ); 16 | expect(getByText("Cards Against Humanity")).toBeInTheDocument() 17 | }); 18 | }); -------------------------------------------------------------------------------- /server/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | coverage: { 7 | thresholds: { 8 | lines: 60, 9 | branches: 60, 10 | functions: 60, 11 | statements: 60, 12 | }, 13 | provider: "v8", 14 | reporter: ['text', 'json-summary', 'json'], 15 | exclude: ["**/types/*.ts", "**/test/*.ts", "vite-env.d.ts", "vite.config.ts", "**/db/**/*.ts"], 16 | }, 17 | }, 18 | server: { 19 | watch: { 20 | usePolling: true, 21 | }, 22 | host: true, 23 | strictPort: true, 24 | port: 3000, 25 | } 26 | }); -------------------------------------------------------------------------------- /client/src/route/ErrorRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "antd"; 2 | import { useNavigate } from "react-router-dom"; 3 | import styled from "styled-components"; 4 | 5 | 6 | const Center = styled.div` 7 | position: absolute; 8 | top: 50%; 9 | left: 50%; 10 | margin: -100px 0 0 -150px; 11 | text-align: center 12 | ` 13 | const ErrorRoute = () => { 14 | const navigate = useNavigate() 15 | return ( 16 |
17 |

Oops!

18 |

Sorry, an unexpected error has occurred.

19 | 20 |
21 | ); 22 | } 23 | 24 | export default ErrorRoute -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src", "test/App.test.tsx"], 20 | "types": ["node", "jest", "@testing-library/jest-dom"], 21 | "references": [{ "path": "./tsconfig.node.json" }] 22 | } 23 | -------------------------------------------------------------------------------- /client/src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import { blackCards, userName, whiteCards } from '../reducers' 3 | import { testBlackCards, testUserName, testWhiteCards } from '../testReducer' 4 | 5 | export const store = configureStore({ 6 | reducer: { 7 | whiteCards: whiteCards.reducer, 8 | blackCards: blackCards.reducer, 9 | user: userName.reducer, 10 | }, 11 | }) 12 | 13 | 14 | export const testStore = configureStore({ 15 | reducer: { 16 | whiteCards: testWhiteCards.reducer, 17 | blackCards: testBlackCards.reducer, 18 | user: testUserName.reducer, 19 | }, 20 | }) 21 | 22 | export type RootState = ReturnType 23 | export type AppDispatch = typeof store.dispatch -------------------------------------------------------------------------------- /server/db/services/database.service.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | import { Collection, Db, MongoClient } from "mongodb"; 3 | 4 | export const collections: { cards?: Collection } = {} 5 | 6 | export async function connectToDatabase() { 7 | dotenv.config(); 8 | 9 | if (process.env.DB_CONN_STRING) { 10 | const client: MongoClient = new MongoClient(process.env.DB_CONN_STRING); 11 | 12 | await client.connect(); 13 | 14 | const db: Db = client.db(process.env.DB_NAME); 15 | 16 | const cardsCollection: Collection = db.collection(process.env.CARDS_COLLECTION_NAME!); 17 | 18 | collections.cards = cardsCollection; 19 | 20 | console.log(`Successfully connected to database: ${db.databaseName} and collection: ${cardsCollection.collectionName}`); 21 | } 22 | } -------------------------------------------------------------------------------- /server/socket.ts: -------------------------------------------------------------------------------- 1 | import { Server as HttpServer } from 'http'; 2 | import { Server } from 'socket.io'; 3 | import { user } from './types/user'; 4 | import { startListeners } from './utils/utils'; 5 | 6 | export class ServerSocket { 7 | public static instance: ServerSocket; 8 | public io: Server; 9 | public users: user; 10 | 11 | constructor(server: HttpServer) { 12 | ServerSocket.instance = this; 13 | this.users = {}; 14 | this.io = new Server(server, { 15 | serveClient: false, 16 | pingInterval: 10000, 17 | pingTimeout: 5000, 18 | cookie: false, 19 | cors: { 20 | origin: '*' 21 | } 22 | }); 23 | this.io.on('connect', (socket) => startListeners(this.io, socket, this.users)); 24 | } 25 | } -------------------------------------------------------------------------------- /client/src/Pages/Game/GameScorer.tsx: -------------------------------------------------------------------------------- 1 | import { List } from "antd"; 2 | import { useContext } from "react"; 3 | import socketContext from "../../context/SocketContext"; 4 | 5 | const GameScorer = () => { 6 | const { score } = useContext(socketContext).socketState; 7 | let players: Array = Array.from(score, ([name, score]) => ({ name, score })); 8 | 9 | return ( 10 | b.score - a.score)} 14 | renderItem={(user, index) => ( 15 | {user.score}]}> 16 |
{user.name}
17 |
18 | )} 19 | /> 20 | ) 21 | } 22 | export default GameScorer -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | color-scheme: light dark; 6 | color: rgba(255, 255, 255, 0.87); 7 | background-color: #ffffff; 8 | font-synthesis: none; 9 | text-rendering: optimizeLegibility; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | -webkit-text-size-adjust: 100%; 13 | height: 100%; 14 | } 15 | 16 | p, 17 | h1, 18 | h2, 19 | h3, 20 | h4, 21 | h5, 22 | h6 { 23 | color: #000000; 24 | } 25 | 26 | a { 27 | font-weight: 500; 28 | color: #646cff; 29 | text-decoration: inherit; 30 | } 31 | 32 | a:hover { 33 | color: #000000; 34 | } 35 | 36 | body { 37 | margin: 0; 38 | min-height: 100vh; 39 | } 40 | 41 | h1 { 42 | font-size: 3.2em; 43 | line-height: 1.1; 44 | } -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from "vite"; 4 | import react from "@vitejs/plugin-react"; 5 | import { resolve } from "path"; 6 | import tsconfigPaths from 'vite-tsconfig-paths' 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [tsconfigPaths(), react()], 11 | test: { 12 | globals: true, 13 | environment: "jsdom", 14 | setupFiles: [resolve(__dirname, './setup.js')], 15 | coverage: { 16 | thresholds: { 17 | lines: 70, 18 | branches: 70, 19 | functions: 70, 20 | statements: 70, 21 | }, 22 | provider: "v8", 23 | reporter: ['text', 'json-summary', 'json'], 24 | exclude: ["src/main.tsx", "src/types", "src/vite-env.d.ts", "src/reducers.ts", "src/testReducer.ts"], 25 | }, 26 | }, 27 | server: { 28 | watch: { 29 | usePolling: true, 30 | }, 31 | host: true, 32 | strictPort: true, 33 | port: 5173, 34 | } 35 | }); 36 | 37 | -------------------------------------------------------------------------------- /client/src/reducers.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createSlice } from '@reduxjs/toolkit' 3 | const initialState = { 4 | cards: [], 5 | } 6 | 7 | const userState = { 8 | name: "", 9 | } 10 | 11 | 12 | export const blackCards = createSlice({ 13 | name: 'black', 14 | initialState: initialState, 15 | reducers: { 16 | updateBlack: (state, action) => { 17 | state.cards = action.payload 18 | } 19 | }, 20 | }) 21 | 22 | export const whiteCards = createSlice({ 23 | name: 'white', 24 | initialState: initialState, 25 | reducers: { 26 | updateWhite: (state, action) => { 27 | state.cards = action.payload 28 | } 29 | }, 30 | }) 31 | 32 | export const userName = createSlice({ 33 | name: 'user', 34 | initialState: userState, 35 | reducers: { 36 | updateUserName: (state, action) => { 37 | state.name = action.payload 38 | } 39 | }, 40 | }) 41 | 42 | export const { updateBlack } = blackCards.actions 43 | export const { updateWhite } = whiteCards.actions 44 | export const { updateUserName } = userName.actions -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Emanuele Dall'Ara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/src/components/Cards/BlackCard.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { cardHeight, cardWidth } from '../../hooks/style.utils'; 3 | 4 | const Card = styled.div` 5 | height: ${cardHeight}; 6 | width: ${cardWidth}; 7 | position: relative; 8 | font-family: "Poppins", sans-serif; 9 | border-radius: 0.6em; 10 | border: 1px solid #fff; 11 | ${props => props.cardStyle} 12 | 13 | ` 14 | const Front = styled.div` 15 | padding:20px; 16 | background-color: #000000; 17 | height: 100%; 18 | width: 100%; 19 | font-size: 1.2em; 20 | border-radius: 0.6em; 21 | backface-visibility: hidden; 22 | ` 23 | const Title = styled.h3` 24 | font-weight: 500; 25 | letter-spacing: 0.05em; 26 | ` 27 | 28 | interface BlackProps { 29 | title?: string, 30 | cardStyle?: React.CSSProperties, 31 | children?: JSX.Element | JSX.Element[], 32 | } 33 | 34 | const BlackCard = ({ cardStyle = {}, title = "Cards Against Humanity? More like ____________.", children }: BlackProps) => 35 | 36 | 37 | {title} 38 | {children} 39 | 40 | 41 | 42 | export default BlackCard; 43 | -------------------------------------------------------------------------------- /client/test/SocketContextComponent.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom'; 3 | import { expect, it, describe, assert, afterEach, vitest } from 'vitest' 4 | import { cleanup, render } from '@testing-library/react'; 5 | import { SocketContextProvider, defaultSocketContextState } from '../src/context/SocketContext'; 6 | import SocketContextComponent from '../src/context/SocketContextComponent'; 7 | import { BrowserRouter } from 'react-router-dom'; 8 | 9 | describe('SocketContextComponent', async () => { 10 | const socketState = { 11 | socketState: defaultSocketContextState, 12 | socketDispatch: () => { } 13 | }; 14 | const { getByText } = render( 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | afterEach(() => { 23 | cleanup(); 24 | vitest.resetAllMocks(); 25 | }); 26 | 27 | it('renders loading message when loading is true', async () => { 28 | expect(getByText(/Loading/)).toBeInTheDocument() 29 | }); 30 | 31 | }); -------------------------------------------------------------------------------- /client/src/hooks/style.utils.tsx: -------------------------------------------------------------------------------- 1 | import { Gutter } from "antd/es/grid/row"; 2 | import { isMobile } from "react-device-detect"; 3 | import jazzHands from '../assets/jazzhands.png'; 4 | import berry from '../assets/miracleberry.png'; 5 | 6 | export const gutterRules: Gutter | [Gutter, Gutter] = isMobile ? [64, 32] : [32, 0] 7 | export const buttonRulesWidth = isMobile ? 250 : 150 8 | 9 | export const cardHeight = isMobile ? "18em" : "25em" 10 | export const cardWidth = isMobile ? "13.75em" : "18.75em" 11 | 12 | export const waitingMargin = isMobile ? 22 : 0 13 | export const rightRule = isMobile ? 16 : 20 14 | 15 | export const whiteContainer = isMobile ? "40%" : "50%" 16 | export const blackContainerTop = isMobile ? "30%" : "50%" 17 | export const blackContainerLeft = isMobile ? "34%" : "53%" 18 | export const blackContainerTransform = isMobile && 'transform: rotate(18deg)' 19 | export const defaultImgDistance = isMobile ? "8em" : "12em" 20 | 21 | 22 | export const berryImg = !isMobile && berry 23 | export const jazzImg = !isMobile && jazzHands 24 | -------------------------------------------------------------------------------- /client/src/Pages/Lobby/CreateLobby.tsx: -------------------------------------------------------------------------------- 1 | import { LeftOutlined } from "@ant-design/icons"; 2 | import { Button, Input, Row } from "antd"; 3 | import { useContext, useState } from "react"; 4 | import { useNavigate } from "react-router-dom"; 5 | import socketContext from "../../context/SocketContext"; 6 | import { createRoom } from "../../hooks/functions"; 7 | 8 | const CreateLobby = () => { 9 | const { socket } = useContext(socketContext).socketState; 10 | const [roomName, setRoomName] = useState("") 11 | const navigate = useNavigate() 12 | 13 | return ( 14 | <> 15 | 16 | 17 | setRoomName(value.target.value)} /> 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | export default CreateLobby -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.ts", 6 | "type": "commonjs", 7 | "scripts": { 8 | "build": "npx tsc", 9 | "start": "node dist/index.js", 10 | "dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\"", 11 | "test": "vitest", 12 | "test:ui": "vitest --ui", 13 | "coverage": "vitest --coverage" 14 | }, 15 | "keywords": [], 16 | "author": { 17 | "name": "Emanuele Dall'Ara", 18 | "email": "emanuele.dallara99@gmail.com", 19 | "url": "https://leledallas.github.io/skillsfolio/" 20 | }, 21 | "license": "ISC", 22 | "dependencies": { 23 | "@types/supertest": "^6.0.0", 24 | "@vitejs/plugin-react": "^4.0.0", 25 | "cors": "^2.8.5", 26 | "dotenv": "^16.0.3", 27 | "express": "^4.18.2", 28 | "mongodb": "^6.0.0", 29 | "socket.io": "^4.6.1", 30 | "socket.io-client": "^4.6.1", 31 | "supertest": "^7.0.0", 32 | "uuid": "^11.0.0", 33 | "vitest": "^3.0.0" 34 | }, 35 | "devDependencies": { 36 | "@types/express": "^5.0.0", 37 | "@types/node": "^22.0.0", 38 | "@types/socket.io-client": "^3.0.0", 39 | "@types/uuid": "^10.0.0", 40 | "@vitest/coverage-v8": "^3.0.0", 41 | "concurrently": "^9.0.0", 42 | "jsdom": "^26.0.0", 43 | "nodemon": "^3.0.0", 44 | "typescript": "^5.0.3", 45 | "vite": "^5.2.6" 46 | }, 47 | "engines": { 48 | "node": ">=18.15.0", 49 | "npm": ">=9.5.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/test/ErrorRoute.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom'; 3 | import { expect, it, describe, vi } from 'vitest' 4 | import { fireEvent, render, } from '@testing-library/react'; 5 | import ErrorRoute from "../src/route/ErrorRoute" 6 | import { BrowserRouter } from 'react-router-dom'; 7 | 8 | const mockedUseNavigate = vi.fn(); 9 | vi.mock("react-router-dom", async () => { 10 | const mod = await vi.importActual( 11 | "react-router-dom" 12 | ); 13 | return { 14 | ...mod, 15 | useNavigate: () => mockedUseNavigate, 16 | }; 17 | }); 18 | 19 | describe('ErrorRoute', () => { 20 | 21 | it('render correctly', () => { 22 | const { getByText } = render( 23 | 24 | 25 | 26 | 27 | ); 28 | expect(getByText(/Oops!/)).toBeInTheDocument() 29 | expect(getByText(/Sorry, an unexpected error has occurred./)).toBeInTheDocument() 30 | }); 31 | 32 | it('fire click event', () => { 33 | const { getByText } = render( 34 | 35 | 36 | 37 | 38 | ); 39 | const button = getByText("Go to homepage") 40 | expect(button).toBeInTheDocument() 41 | fireEvent.click(button) 42 | expect(mockedUseNavigate).toHaveBeenCalledTimes(1) 43 | 44 | }); 45 | 46 | }); -------------------------------------------------------------------------------- /client/src/route/AppRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from "react-router-dom"; 2 | import SocketContextComponent from "../context/SocketContextComponent"; 3 | import { ConfigProvider } from "antd"; 4 | import Home from "../Pages/Home/Home"; 5 | import Lobby from "../Pages/Lobby/Lobby"; 6 | import Rules from "../Pages/Rules/Rules"; 7 | import CreateLobby from "../Pages/Lobby/CreateLobby"; 8 | import WaitingLobby from "../Pages/Lobby/WaitingLobby"; 9 | import Game from "../Pages/Game/Game"; 10 | import { Provider } from "react-redux"; 11 | import { store } from "../store/store"; 12 | import ErrorRoute from "./ErrorRoute"; 13 | 14 | 15 | const AppRoute = (props: any) => 16 | 17 | 24 | 25 | 26 | } /> 27 | } /> 28 | } /> 29 | } /> 30 | } /> 31 | } /> 32 | } /> 33 | 34 | 35 | 36 | 37 | 38 | export default AppRoute -------------------------------------------------------------------------------- /client/test/CreateLobby.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe, vi } from 'vitest' 4 | import { fireEvent, render } from '@testing-library/react'; 5 | import CreateLobby from "../src/Pages/Lobby/CreateLobby" 6 | import { BrowserRouter } from 'react-router-dom'; 7 | 8 | const mockedUseNavigate = vi.fn(); 9 | vi.mock("react-router-dom", async () => { 10 | const mod = await vi.importActual( 11 | "react-router-dom" 12 | ); 13 | return { 14 | ...mod, 15 | useNavigate: () => mockedUseNavigate, 16 | }; 17 | }); 18 | 19 | describe('Create Lobby', () => { 20 | it('renders form correctly', () => { 21 | const { getByText } = render( 22 | 23 | 24 | 25 | ); 26 | expect(getByText("Create Lobby")).toBeInTheDocument() 27 | }); 28 | 29 | it('fire events onclick', () => { 30 | const { getByText, getByPlaceholderText } = render( 31 | 32 | 33 | 34 | ); 35 | const back = getByText('Back') 36 | const create = getByText('Create Lobby') 37 | expect(back).toBeInTheDocument() 38 | expect(getByPlaceholderText("Insert a lobby name")).toBeInTheDocument() 39 | expect(create).toBeInTheDocument() 40 | fireEvent.click(back) 41 | expect(mockedUseNavigate).toHaveBeenCalledTimes(1) 42 | fireEvent.click(create) 43 | expect(mockedUseNavigate).toHaveBeenCalledTimes(1) 44 | }); 45 | }); -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "author": { 7 | "name": "Emanuele Dall'Ara", 8 | "email": "emanuele.dallara99@gmail.com", 9 | "url": "https://leledallas.github.io/skillsfolio/" 10 | }, 11 | "scripts": { 12 | "dev": "vite", 13 | "build": "tsc && vite build", 14 | "preview": "vite preview", 15 | "test": "vitest", 16 | "coverage": "vitest --coverage", 17 | "test:ui": "vitest --ui", 18 | "host": "vite --host" 19 | }, 20 | "dependencies": { 21 | "@ant-design/icons": "^6.0.0", 22 | "@reduxjs/toolkit": "^2.0.0", 23 | "@testing-library/jest-dom": "^6.0.0", 24 | "antd": "^5.2.3", 25 | "dotenv": "^16.0.3", 26 | "npm": "^11.0.0", 27 | "react": "^19.0.0", 28 | "react-device-detect": "^2.2.3", 29 | "react-dom": "^19.0.0", 30 | "react-redux": "^9.1.0", 31 | "react-router-dom": "^7.0.0", 32 | "socket.io": "^4.6.1", 33 | "socket.io-client": "^4.6.1", 34 | "styled-components": "^6.0.0", 35 | "vite-tsconfig-paths": "^5.0.0", 36 | "vitest": "^3.0.0" 37 | }, 38 | "devDependencies": { 39 | "@faker-js/faker": "^9.0.0", 40 | "@testing-library/dom": "^10.0.0", 41 | "@testing-library/react": "^16.0.0", 42 | "@testing-library/user-event": "^14.4.3", 43 | "@types/react": "^19.0.0", 44 | "@types/react-dom": "^19.0.0", 45 | "@types/styled-components": "^5.1.26", 46 | "@vitejs/plugin-react": "^4.0.0", 47 | "@vitest/coverage-v8": "^3.0.0", 48 | "@vitest/ui": "^3.0.0", 49 | "jsdom": "^26.0.0", 50 | "typescript": "^5.0.2", 51 | "vite": "^5.2.6" 52 | }, 53 | "engines": { 54 | "node": ">=18.15.0", 55 | "npm": ">=9.5.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/src/components/Cards/WhiteCard.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { cardHeight, cardWidth } from '../../hooks/style.utils'; 3 | 4 | const Card = styled.div` 5 | height: ${cardHeight}; 6 | width: ${cardWidth}; 7 | position: relative; 8 | font-family: "Poppins", sans-serif; 9 | border-radius: 0.6em; 10 | ${(props) => props.cardStyle} 11 | ` 12 | const Front = styled.div` 13 | padding:20px; 14 | background-color: #ffffff; 15 | height: 100%; 16 | width: 100%; 17 | font-size: 1.2em; 18 | border-radius: 0.6em; 19 | border: 1px solid #000; 20 | backface-visibility: hidden; 21 | ${props => props?.selected && ` 22 | transform: scale(1.10); 23 | box-shadow: 0 0 10px rgba(0,0,0,0.5); 24 | z-index: 10; 25 | border: 1px solid #F0A500;` 26 | } 27 | ${props => props?.hoverable && 28 | `&:hover{ 29 | transform: scale(1.10); 30 | box-shadow: 0 0 10px rgba(0,0,0,0.5); 31 | z-index: 10; 32 | transition: 0.5s}` 33 | } 34 | ${(props) => props?.frontStyle} 35 | 36 | ` 37 | const Title = styled.h3` 38 | font-weight: 500; 39 | letter-spacing: 0.05em; 40 | ` 41 | 42 | interface WhiteProps { 43 | title?: string, 44 | cardStyle?: React.CSSProperties, 45 | frontStyle?: React.CSSProperties, 46 | children?: JSX.Element | JSX.Element[], 47 | hoverable?: boolean, 48 | selected?: boolean, 49 | } 50 | 51 | const WhiteCard = ({ cardStyle = {}, frontStyle = {}, title = "Cards Against Humanity", children, ...props }: WhiteProps) => 52 | 53 | 54 | {title} 55 | {children} 56 | 57 | 58 | 59 | export default WhiteCard; 60 | -------------------------------------------------------------------------------- /client/test/Lobby.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe, beforeEach, vi } from 'vitest' 4 | import { fireEvent, render, waitFor } from '@testing-library/react' 5 | import Lobby from '../src/Pages/Lobby/Lobby'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | 8 | const mockedUseNavigate = vi.fn(); 9 | vi.mock("react-router-dom", async () => { 10 | const mod = await vi.importActual( 11 | "react-router-dom" 12 | ); 13 | return { 14 | ...mod, 15 | useNavigate: () => mockedUseNavigate, 16 | }; 17 | }); 18 | 19 | describe('Lobby component', () => { 20 | let lobbyRenderResult; 21 | beforeEach(() => { 22 | lobbyRenderResult = render( 23 | 24 | 25 | 26 | ) 27 | }); 28 | 29 | it('renders without errors', async () => { 30 | const { getByText } = await waitFor(() => lobbyRenderResult); 31 | expect(getByText(/Users Online:/)).toBeInTheDocument() 32 | }) 33 | 34 | it('fire back event', async () => { 35 | const { getByText } = await waitFor(() => lobbyRenderResult); 36 | expect(getByText(/Back/)).toBeInTheDocument() 37 | fireEvent.click(getByText(/Back/)) 38 | expect(mockedUseNavigate).toHaveBeenCalledTimes(1) 39 | }) 40 | 41 | it('fire refresh event', async () => { 42 | const { getByText } = await waitFor(() => lobbyRenderResult); 43 | expect(getByText(/Refresh/)).toBeInTheDocument() 44 | fireEvent.click(getByText(/Refresh/)) 45 | expect(mockedUseNavigate).toHaveBeenCalledTimes(1) 46 | }) 47 | }) -------------------------------------------------------------------------------- /server/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import server from '../index'; 3 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 4 | 5 | 6 | describe('Server', () => { 7 | 8 | beforeEach(() => { 9 | if (server.httpServer.listening) 10 | server.httpServer.close(() => server.httpServer.listen()); 11 | else server.httpServer.listen(); 12 | }); 13 | 14 | afterEach(() => { 15 | server.httpServer.close(); 16 | }); 17 | 18 | describe('GET /end-point', () => { 19 | it('should respond with a 200 status code and a JSON object with a "hello" property set to "world!"', async () => { 20 | const response = await request(server.application) 21 | .get('/end-point') 22 | .expect('Content-Type', /json/) 23 | .expect(200); 24 | expect(response.body).to.deep.equal({ hello: 'world!' }); 25 | }); 26 | }); 27 | 28 | describe('GET /status', () => { 29 | it('should respond with a 200 status code and a JSON object with a "users" property set to an array', async () => { 30 | const response = await request(server.application) 31 | .get('/status') 32 | .expect('Content-Type', /json/) 33 | .expect(200); 34 | expect(response.body).to.have.property('users') 35 | }); 36 | }); 37 | 38 | describe('GET /wrong-api', () => { 39 | it('should respond with a 400 status code and a JSON object empty', async () => { 40 | const response = await request(server.application) 41 | .get('/wrong-api') 42 | .expect('Content-Type', /json/) 43 | .expect(404); 44 | }); 45 | }); 46 | 47 | }) -------------------------------------------------------------------------------- /client/src/Pages/Lobby/Lobby.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Row } from "antd" 2 | import { useContext, useEffect } from "react" 3 | import socketContext from "../../context/SocketContext" 4 | import { useNavigate } from "react-router-dom" 5 | import { LeftOutlined, ReloadOutlined } from "@ant-design/icons" 6 | import BlackCard from "../../components/Cards/BlackCard" 7 | import WhiteLobbyCard from "../../components/Cards/WhiteLobbyCard" 8 | 9 | const Lobby = () => { 10 | const { socket, users, rooms } = useContext(socketContext).socketState; 11 | const navigate = useNavigate() 12 | 13 | const reloadPage = () => 14 | socket?.emit("get_rooms", (res: any) => { }); 15 | 16 | useEffect(() => { 17 | reloadPage() 18 | }, []); 19 | 20 | return ( 21 |
22 | 23 | 24 |

You are: {socket?.id}

25 | 26 |
27 | 28 | 31 | 32 | 33 | {Object.keys(rooms).map((roomName, index) => 34 | 35 | )} 36 | 37 |
38 | ) 39 | } 40 | export default Lobby -------------------------------------------------------------------------------- /client/src/components/Cards/SpinningCard.tsx: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | 3 | const Container = styled.div` 4 | position: absolute; 5 | transform: translate(-50%, -50%); 6 | top: 50%; 7 | left: 50%; 8 | perspective: 50em; 9 | ` 10 | 11 | const Card = styled.div` 12 | height: 25em; 13 | width: 18.75em; 14 | position: relative; 15 | font-family: "Poppins", sans-serif; 16 | animation: ${keyframes` 17 | 100% { 18 | transform: rotateY(360deg); 19 | } 20 | `} 5s infinite linear; 21 | transform-style: preserve-3d; 22 | ` 23 | 24 | const Center = styled.div` 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | flex-direction: column; 29 | text-align: center; 30 | ` 31 | 32 | const Front = styled(Center)` 33 | background-color: #ffffff; 34 | height: 100%; 35 | width: 100%; 36 | font-size: 1.2em; 37 | border-radius: 0.6em; 38 | backface-visibility: hidden; 39 | border: 1px solid #000 40 | ` 41 | const Back = styled(Center)` 42 | height: 100%; 43 | width: 100%; 44 | font-size: 1.2em; 45 | border-radius: 0.6em; 46 | backface-visibility: hidden; 47 | background-color: #000000; 48 | position: relative; 49 | transform: rotateY(180deg); 50 | bottom: 100%; 51 | ` 52 | const Title = styled.h3` 53 | font-weight: 500; 54 | letter-spacing: 0.05em; 55 | ` 56 | 57 | const Paragraph = styled.p` 58 | color: #838094; 59 | font-size: 0.8em; 60 | font-weight: 300; 61 | letter-spacing: 0.05em; 62 | ` 63 | 64 | const SpinningCard = () => 65 | 66 | 67 | 68 | Cards Against Humanity 69 | Trying to __________________ 70 | 71 | 72 | Loading... 73 | ________ the server 74 | 75 | 76 | 77 | 78 | export default SpinningCard; 79 | -------------------------------------------------------------------------------- /client/src/testReducer.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createSlice } from '@reduxjs/toolkit' 3 | const initialState = { 4 | cards: [ 5 | { 6 | title: "1", 7 | isBlack: false, 8 | }, 9 | { 10 | title: "2", 11 | isBlack: false, 12 | }, 13 | { 14 | title: "3", 15 | isBlack: false, 16 | }, 17 | { 18 | title: "4", 19 | isBlack: false, 20 | }, 21 | { 22 | title: "5", 23 | isBlack: false, 24 | }, 25 | { 26 | title: "6", 27 | isBlack: false, 28 | }, 29 | { 30 | title: "7", 31 | isBlack: false, 32 | }, 33 | { 34 | title: "8", 35 | isBlack: false, 36 | }, 37 | { 38 | title: "9", 39 | isBlack: false, 40 | }, 41 | { 42 | title: "10", 43 | isBlack: false, 44 | }, 45 | { 46 | title: "11", 47 | isBlack: false, 48 | }, 49 | { 50 | title: "12", 51 | isBlack: false, 52 | }, 53 | ], 54 | } 55 | 56 | const userState = { 57 | name: "", 58 | } 59 | 60 | 61 | export const testBlackCards = createSlice({ 62 | name: 'black', 63 | initialState: initialState, 64 | reducers: { 65 | updateBlack: (state, action) => { 66 | state.cards = action.payload 67 | } 68 | }, 69 | }) 70 | 71 | export const testWhiteCards = createSlice({ 72 | name: 'white', 73 | initialState: initialState, 74 | reducers: { 75 | updateWhite: (state, action) => { 76 | state.cards = action.payload 77 | } 78 | }, 79 | }) 80 | 81 | export const testUserName = createSlice({ 82 | name: 'user', 83 | initialState: userState, 84 | reducers: { 85 | updateUserName: (state, action) => { 86 | state.name = action.payload 87 | } 88 | }, 89 | }) 90 | 91 | export const { updateBlack } = testBlackCards.actions 92 | export const { updateWhite } = testWhiteCards.actions 93 | export const { updateUserName } = testUserName.actions -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature 2 | description: "Submit a proposal for a new feature" 3 | title: "🚀 Feature: " 4 | labels: [feature] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | We value your time and efforts to submit this Feature request form. 🙏 10 | - type: textarea 11 | id: feature-description 12 | validations: 13 | required: true 14 | attributes: 15 | label: "🔖 Feature description" 16 | description: "A clear and concise description of what the feature is." 17 | placeholder: "You should add ..." 18 | - type: textarea 19 | id: pitch 20 | validations: 21 | required: true 22 | attributes: 23 | label: "🎤 Why is this feature needed ?" 24 | description: "Please explain why this feature should be implemented and how it would be used. Add examples, if applicable." 25 | placeholder: "In my use-case, ..." 26 | - type: textarea 27 | id: solution 28 | validations: 29 | required: true 30 | attributes: 31 | label: "✌️ How do you aim to achieve this?" 32 | description: "A clear and concise description of what you want to happen." 33 | placeholder: "I want this feature to, ..." 34 | - type: textarea 35 | id: alternative 36 | validations: 37 | required: false 38 | attributes: 39 | label: "🔄️ Additional Information" 40 | description: "A clear and concise description of any alternative solutions or additional solutions you've considered." 41 | placeholder: "I tried, ..." 42 | - type: checkboxes 43 | id: no-duplicate-issues 44 | attributes: 45 | label: "👀 Have you spent some time to check if this feature request has been raised before?" 46 | options: 47 | - label: "I checked and didn't find similar issue" 48 | required: true 49 | - type: dropdown 50 | id: willing-to-submit-pr 51 | attributes: 52 | label: Are you willing to submit PR? 53 | description: This is absolutely not required, but we are happy to guide you in the contribution process. Find us in help-needed channel on [Discord](https://discord.gg/9wcGSf22PM)! 54 | options: 55 | - "Yes I am willing to submit a PR!" 56 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import dotenv from 'dotenv'; 3 | import http from 'http'; 4 | import { ServerSocket } from './socket'; 5 | import { connectToDatabase } from './db/services/database.service'; 6 | import { cardsRouter } from './db/routes/cards.router'; 7 | 8 | dotenv.config(); 9 | 10 | const port = process.env.PORT; 11 | 12 | const application = express(); 13 | 14 | const httpServer = http.createServer(application); 15 | 16 | new ServerSocket(httpServer); 17 | 18 | let allowCrossDomain = function (req: any, res: any, next: any) { 19 | res.header('Access-Control-Allow-Origin', "*"); 20 | res.header('Access-Control-Allow-Headers', "*"); 21 | next(); 22 | } 23 | application.use(allowCrossDomain); 24 | 25 | application.use((req, res, next) => { 26 | console.info(`METHOD: [${req.method}] - URL: [${req.url}] - IP: [${req.socket.remoteAddress}]`); 27 | 28 | res.on('finish', () => { 29 | console.info(`METHOD: [${req.method}] - URL: [${req.url}] - STATUS: [${res.statusCode}] - IP: [${req.socket.remoteAddress}]`); 30 | }); 31 | 32 | next(); 33 | }); 34 | 35 | application.use(express.urlencoded({ extended: true })); 36 | application.use(express.json()); 37 | 38 | //maybe to eliminate 39 | application.use((req, res, next) => { 40 | res.header('Access-Control-Allow-Origin', '*'); 41 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); 42 | 43 | if (req.method == 'OPTIONS') { 44 | res.header('Access-Control-Allow-Methods', 'PUT, POST, PATCH, DELETE, GET'); 45 | return res.status(200).json({}); 46 | } 47 | next(); 48 | }); 49 | 50 | application.get('/end-point', (req, res, next) => { 51 | return res.status(200).json({ hello: 'world!' }); 52 | }); 53 | 54 | application.get('/status', (req, res, next) => { 55 | return res.status(200).json({ users: ServerSocket.instance.users }); 56 | }); 57 | 58 | application.use(cardsRouter); 59 | 60 | 61 | application.use((req, res, next) => { 62 | const error = new Error('Not found'); 63 | res.status(404).json({ 64 | message: error.message 65 | }); 66 | }); 67 | 68 | connectToDatabase() 69 | 70 | httpServer.listen(port, () => console.info(`Server is running`)); 71 | 72 | export default { application, httpServer } 73 | -------------------------------------------------------------------------------- /client/test/Rules.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe, beforeEach, vi } from 'vitest' 4 | import { fireEvent, render, waitFor } from '@testing-library/react' 5 | import Rules from '../src/Pages/Rules/Rules'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | import * as reactDeviceDetect from 'react-device-detect' 8 | 9 | 10 | const mockedUseNavigate = vi.fn(); 11 | vi.mock("react-router-dom", async () => { 12 | const mod = await vi.importActual( 13 | "react-router-dom" 14 | ); 15 | return { 16 | ...mod, 17 | useNavigate: () => mockedUseNavigate, 18 | }; 19 | }); 20 | 21 | describe('Rules component', () => { 22 | let rulesComponent; 23 | beforeEach(() => { 24 | rulesComponent = render( 25 | 26 | 27 | 28 | ) 29 | }); 30 | 31 | it('renders without errors', async () => { 32 | const { getByText } = await waitFor(() => rulesComponent); 33 | expect(getByText("Cards Against Humanity")).toBeInTheDocument() 34 | }) 35 | 36 | it('must fire events', async () => { 37 | const { getByText } = await waitFor(() => rulesComponent); 38 | const back = getByText("Back") 39 | const join = getByText("Join room") 40 | const create = getByText("Create room") 41 | expect(back).toBeInTheDocument() 42 | expect(join).toBeInTheDocument() 43 | expect(create).toBeInTheDocument() 44 | fireEvent.click(back) 45 | fireEvent.click(join) 46 | fireEvent.click(create) 47 | expect(mockedUseNavigate).toHaveBeenCalledTimes(3) 48 | }) 49 | 50 | it('not render on mobile', async () => { 51 | Object.defineProperty(reactDeviceDetect, 'isMobile', { get: () => true }); 52 | Object.defineProperty(reactDeviceDetect, 'isMobile', { get: () => false }); 53 | const { getByText } = await waitFor(() => rulesComponent); 54 | const back = getByText("Back") 55 | const join = getByText("Join room") 56 | const create = getByText("Create room") 57 | expect(back).toBeInTheDocument() 58 | expect(join).toBeInTheDocument() 59 | expect(create).toBeInTheDocument() 60 | 61 | }) 62 | }) -------------------------------------------------------------------------------- /.github/workflows/action.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | permissions: 4 | contents: write 5 | pull-requests: write 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | types: [opened, synchronize, reopened] 13 | 14 | jobs: 15 | sonarcloud: 16 | name: SonarCloud 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 21 | with: 22 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 23 | 24 | - name: SonarCloud Scan 25 | uses: SonarSource/sonarcloud-github-action@master 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 28 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 29 | 30 | client: 31 | name: Client Tests and Coverage 32 | needs: sonarcloud 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 37 | 38 | - name: Run client tests & coverage 39 | run: | 40 | cd client 41 | npm ci 42 | npm run coverage 43 | 44 | - name: Vitest Coverage Report 45 | uses: davelosert/vitest-coverage-report-action@v2.8.0 46 | with: 47 | working-directory: "./client/" 48 | vite-config-path: "./vite.config.ts" 49 | 50 | server: 51 | name: Server Tests and Coverage 52 | needs: sonarcloud 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 57 | 58 | - name: Run server tests & coverage 59 | run: | 60 | cd server 61 | npm ci 62 | npm run coverage 63 | 64 | - name: Vitest Coverage Report 65 | uses: davelosert/vitest-coverage-report-action@v2.8.0 66 | with: 67 | working-directory: "./server/" 68 | vite-config-path: "./vite.config.ts" 69 | 70 | release-please: 71 | name: Release Please 72 | needs: [sonarcloud, client, server] 73 | runs-on: ubuntu-latest 74 | 75 | steps: 76 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 77 | 78 | - name: Release Please Action 79 | uses: google-github-actions/release-please-action@v4 80 | with: 81 | release-type: node 82 | package-name: release-please-action 83 | -------------------------------------------------------------------------------- /client/src/components/Cards/WhiteLobbyCard.tsx: -------------------------------------------------------------------------------- 1 | import { Button, List, Result } from 'antd'; 2 | import { useContext } from 'react'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import styled from 'styled-components'; 5 | import socketContext from '../../context/SocketContext'; 6 | import { joinRoom } from '../../hooks/functions'; 7 | 8 | const Card = styled.div` 9 | height: 25em; 10 | width: 18.75em; 11 | position: relative; 12 | font-family: "Poppins", sans-serif; 13 | border: 1px solid #000; 14 | border-radius: 0.6em; 15 | ` 16 | const Front = styled.div` 17 | padding:20px; 18 | background-color: #ffffff; 19 | height: 100%; 20 | width: 100%; 21 | font-size: 1.2em; 22 | border-radius: 0.6em; 23 | backface-visibility: hidden; 24 | text-align: center; 25 | ` 26 | const Title = styled.h3` 27 | font-weight: 500; 28 | letter-spacing: 0.05em; 29 | ` 30 | 31 | const JoinButton = styled(Button)` 32 | position: absolute; 33 | bottom: 20px; 34 | left: 0; 35 | right: 0; 36 | margin: 0 20px 37 | ` 38 | interface WhiteLobbyCard { 39 | roomName: string, 40 | players: Array | undefined, 41 | join?: boolean 42 | } 43 | 44 | const WhiteLobbyCard = ({ roomName, players, join = false }: WhiteLobbyCard) => { 45 | const { socket } = useContext(socketContext).socketState; 46 | const navigate = useNavigate() 47 | 48 | return ( 49 | players === undefined ? 50 | navigate("/")}> 55 | Go to the homepage 56 | 57 | } 58 | /> : 59 | 60 | 61 | {roomName} 62 | Players inside: {players?.length} } 64 | dataSource={players} 65 | renderItem={(item: string) => {item}} 66 | /> 67 | {join && 68 | joinRoom(socket, roomName, navigate)}> 69 | Connect 70 | } 71 | 72 | 73 | ) 74 | } 75 | export default WhiteLobbyCard; 76 | -------------------------------------------------------------------------------- /client/test/CzarView.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe, vi } from 'vitest' 4 | import { fireEvent, render, screen } from '@testing-library/react'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | import CzarView from '../src/Pages/Game/CzarView'; 7 | import { Provider } from 'react-redux'; 8 | import { store } from '../src/store/store'; 9 | import { SocketContextProvider } from '../src/context/SocketContext'; 10 | 11 | 12 | const mockedUseNavigate = vi.fn(); 13 | vi.mock("react-router-dom", async () => { 14 | const mod = await vi.importActual( 15 | "react-router-dom" 16 | ); 17 | return { 18 | ...mod, 19 | useNavigate: () => mockedUseNavigate, 20 | }; 21 | }); 22 | 23 | describe('Czar View', () => { 24 | it('renders view correctly', () => { 25 | const { getByText } = render( 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | expect(getByText(/Confirm/)).toBeInTheDocument() 33 | }); 34 | 35 | it('renders black card and white card options', async () => { 36 | const mockState = { 37 | socket: undefined, 38 | uid: '', 39 | users: [], 40 | rooms: {}, 41 | white_card: new Map([ 42 | ['player1', 'card1'], 43 | ['player2', 'card2'], 44 | ['player3', 'card3'] 45 | ]), 46 | czarSocketId: "", 47 | black_card: "", 48 | score: new Map(), 49 | new_turn: false 50 | }; 51 | 52 | const mockNavigate = vi.fn(); 53 | 54 | const socketProvider = ({ children }) => ( 55 | 56 | { } }}> 57 | {children} 58 | 59 | 60 | ) 61 | 62 | render(, { wrapper: socketProvider }); 63 | expect(screen.getByText('card1')).toBeInTheDocument(); 64 | expect(screen.getByText('card2')).toBeInTheDocument(); 65 | expect(screen.getByText('card3')).toBeInTheDocument(); 66 | fireEvent.click(screen.getByText('card1')); 67 | expect(screen.getByText('Confirm')).toBeEnabled(); 68 | }); 69 | 70 | }); 71 | 72 | -------------------------------------------------------------------------------- /client/test/WhiteLobbyCard.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe, vi } from 'vitest' 4 | import { fireEvent, render } from '@testing-library/react'; 5 | import WhiteLobbyCard from '../src/components/Cards/WhiteLobbyCard'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | import * as router from 'react-router-dom' 8 | 9 | const mockedUseNavigate = vi.fn(); 10 | vi.mock("react-router-dom", async () => { 11 | const mod = await vi.importActual( 12 | "react-router-dom" 13 | ); 14 | return { 15 | ...mod, 16 | useNavigate: () => mockedUseNavigate, 17 | }; 18 | }); 19 | 20 | const mockRoom = { 21 | players: ["Player1", "Player2"], 22 | title: "Test", 23 | } 24 | const noPlayers = { 25 | players: undefined, 26 | title: "Test", 27 | } 28 | 29 | describe('WhiteLobbyCard guest', () => { 30 | it('renders guest card correctly', () => { 31 | const { getByText } = render( 32 | 33 | 34 | 35 | 36 | ); 37 | expect(getByText("Test")).toBeInTheDocument() 38 | expect(getByText("Connect")).toBeInTheDocument() 39 | expect(getByText("Player1")).toBeInTheDocument() 40 | expect(getByText("Player2")).toBeInTheDocument() 41 | }); 42 | 43 | it('show alert on no player room', () => { 44 | const { getByText } = render( 45 | 46 | 47 | 48 | ); 49 | expect(getByText('This room do not exist anymore.')).toBeInTheDocument() 50 | }) 51 | }); 52 | 53 | 54 | describe('WhiteLobbyCard host', () => { 55 | it('renders host card correctly', () => { 56 | const { getByText } = render( 57 | 58 | 59 | 60 | 61 | ); 62 | expect(getByText("Test")).toBeInTheDocument() 63 | expect(getByText("Player1")).toBeInTheDocument() 64 | expect(getByText("Player2")).toBeInTheDocument() 65 | }); 66 | 67 | it('can navigate between screens', () => { 68 | const { getByText } = render( 69 | 70 | 71 | 72 | ); 73 | const connect = getByText('Connect') 74 | fireEvent.click(connect) 75 | expect(mockedUseNavigate).toHaveBeenCalledTimes(0) 76 | }) 77 | }); -------------------------------------------------------------------------------- /client/src/Pages/Rules/Rules.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Col, Row, Typography } from "antd" 2 | import { LeftOutlined } from "@ant-design/icons" 3 | import { useNavigate } from "react-router-dom" 4 | import BlackCard from "../../components/Cards/BlackCard" 5 | import WhiteCard from "../../components/Cards/WhiteCard" 6 | import { buttonRulesWidth, gutterRules } from "../../hooks/style.utils" 7 | 8 | 9 | const Rules = () => { 10 | const navigate = useNavigate() 11 | 12 | return ( 13 |
14 | 15 | 16 | 17 | Cards Against Humanity: [Fill in the Blank] Edition 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |

32 | Cards Against Humanity: [fill in the blank] Edition is a fill-in-the-blank party game. Each 33 | round, one player asks the group a question from a black card. These cards consist of inside jokes, memories, and more. 34 | 35 | The player who most recently [action] begins as the 36 | Card Czar and draws a black card. 37 | 38 | The Card Czar reads the black card out loud. Everyone else then answers the 39 | question (or fills in the blank) by passing one white card, face down, to the Card Czar. 40 | 41 | The Card Czar then shuffles all the white cards and re-reads the black card out loud 42 | with each one. Finally, the Card Czar picks the funniest combination, and whoever 43 | played it gets one point/the black card. 44 |

45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
54 | ) 55 | } 56 | export default Rules -------------------------------------------------------------------------------- /client/src/Pages/Lobby/WaitingLobby.tsx: -------------------------------------------------------------------------------- 1 | import { LeftOutlined } from "@ant-design/icons" 2 | import { Button, Popconfirm, Row } from "antd" 3 | import { useContext, useEffect } from "react" 4 | import { useDispatch } from "react-redux" 5 | import { useLocation, useNavigate } from "react-router-dom" 6 | import WhiteLobbyCard from "../../components/Cards/WhiteLobbyCard" 7 | import socketContext from "../../context/SocketContext" 8 | import { deleteRoom, fetchCards, leaveRoom, startGame } from "../../hooks/functions" 9 | import { useAppSelector } from "../../hooks/hooks" 10 | import { waitingMargin } from "../../hooks/style.utils" 11 | 12 | const WaitingLobby = () => { 13 | const { socket, rooms } = useContext(socketContext).socketState; 14 | const navigate = useNavigate() 15 | const { state } = useLocation(); 16 | const dispatch = useDispatch() 17 | const white = useAppSelector(state => state.whiteCards.cards) 18 | const black = useAppSelector(state => state.blackCards.cards) 19 | useEffect(() => { 20 | if (white?.length === 0 || black?.length === 0) 21 | fetchCards(dispatch) 22 | }, [white?.length === 0 || black?.length === 0]); 23 | 24 | 25 | return ( 26 |
27 | 28 | {state?.type === "admin" ? 29 | deleteRoom(socket, state?.roomName, navigate)} 33 | okText="Yes" 34 | cancelText="No" 35 | > 36 | 37 | : 38 | 41 | } 42 | 43 | 44 | 45 | 46 | 47 | {rooms[state?.roomName] && state?.type === "admin" && 48 | 56 | } 57 | 58 |
59 | ) 60 | } 61 | export default WaitingLobby -------------------------------------------------------------------------------- /client/test/Home.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe, vi, test } from 'vitest' 4 | import { fireEvent, render } from '@testing-library/react'; 5 | import Home from '../src/Pages/Home/Home'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | import { Provider } from 'react-redux'; 8 | import { store } from '../src/store/store'; 9 | 10 | const mockedUseNavigate = vi.fn(); 11 | vi.mock("react-router-dom", async () => { 12 | const mod = await vi.importActual( 13 | "react-router-dom" 14 | ); 15 | return { 16 | ...mod, 17 | useNavigate: () => mockedUseNavigate, 18 | }; 19 | }); 20 | 21 | describe('Home', () => { 22 | it('renders correctly', () => { 23 | const { getByText } = render( 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | expect(getByText("Cards Against Humanity? More like ____________.")).toBeInTheDocument() 32 | }); 33 | 34 | it('can navigate between screens', () => { 35 | const { getByText } = render( 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | const join = getByText('Join room') 43 | const create = getByText('Create room') 44 | const rules = getByText('Rules') 45 | fireEvent.click(join) 46 | expect(mockedUseNavigate).toHaveBeenCalledTimes(1) 47 | fireEvent.click(create) 48 | expect(mockedUseNavigate).toHaveBeenCalledTimes(2) 49 | fireEvent.click(rules) 50 | expect(mockedUseNavigate).toHaveBeenCalledTimes(3) 51 | }) 52 | 53 | it('change value based on devices', () => { 54 | 55 | const mockedMobile = vi.fn(); 56 | vi.mock("react-device-detect", async () => { 57 | const mod = await vi.importActual( 58 | "react-device-detect" 59 | ); 60 | return { 61 | ...mod, 62 | isMobile: () => mockedMobile, 63 | }; 64 | }); 65 | 66 | const { queryByAltText, getByText } = render( 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | expect(queryByAltText('berry')).not.toBeInTheDocument() 74 | expect(queryByAltText('jazzHands')).not.toBeInTheDocument() 75 | 76 | const join = getByText('Join room') 77 | const create = getByText('Create room') 78 | const rules = getByText('Rules') 79 | expect(getComputedStyle(join).width).toBe("") 80 | }) 81 | }); -------------------------------------------------------------------------------- /client/src/Pages/Game/CzarView.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Col, Row } from "antd" 2 | import BlackCard from "../../components/Cards/BlackCard" 3 | import WhiteCard from "../../components/Cards/WhiteCard" 4 | import { useContext, useEffect, useState } from "react" 5 | import { useAppSelector } from "../../hooks/hooks" 6 | import { drawBlackCard, nextCzar, onConfirm, sendBlack, setCurrentSolution } from "../../hooks/functions" 7 | import socketContext from "../../context/SocketContext" 8 | import { useNavigate } from "react-router-dom" 9 | 10 | const CzarView = ({ ...props }) => { 11 | const { socket, white_card, score, rooms } = useContext(socketContext).socketState; 12 | const [solution, setSolution] = useState(false) 13 | const [selected, setSelected] = useState("") 14 | const [selectedUser, setSelectedUser] = useState("") 15 | const [blackCard, setBlackCard] = useState("") 16 | const black = useAppSelector(state => state.blackCards.cards) 17 | const { roomName } = props 18 | const [hasPicked, setHasPicked] = useState(false) 19 | const navigate = useNavigate() 20 | const roomPlayers = rooms[roomName] 21 | 22 | useEffect(() => { 23 | const card = drawBlackCard(black)?.title 24 | setBlackCard(card) 25 | setTimeout(() => { 26 | sendBlack(socket, card, roomName) 27 | }, 500); 28 | }, []) 29 | 30 | useEffect(() => { 31 | if (white_card.size === roomPlayers?.length - 1) { 32 | onConfirm(socket, roomName, score, selectedUser, true) 33 | setHasPicked(true) 34 | } 35 | if (white_card.size === 0 && hasPicked) { 36 | nextCzar(socket, roomName, navigate, selectedUser) 37 | setHasPicked(false) 38 | } 39 | }, [white_card.size]) 40 | 41 | return ( 42 | <> 43 | 44 | 45 | 46 | 47 | {Array.from(white_card).map(([userId, card], index) => 48 | setCurrentSolution(card, selected, userId, setSelected, setSolution, setSelectedUser)}> 49 | 54 | 55 | )} 56 | 57 | 58 | 66 | 67 | 68 | 69 | ) 70 | } 71 | export default CzarView -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug Report" 2 | description: "Submit a bug report to help us improve" 3 | title: "🐛 Bug Report: " 4 | labels: ["type: bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: We value your time and effort to submit this bug report. 🙏 9 | - type: textarea 10 | id: description 11 | validations: 12 | required: true 13 | attributes: 14 | label: "📜 Description" 15 | description: "A clear and concise description of what the bug is." 16 | placeholder: "It bugs out when ..." 17 | - type: textarea 18 | id: steps-to-reproduce 19 | validations: 20 | required: true 21 | attributes: 22 | label: "👟 Reproduction steps" 23 | description: "How do you trigger this bug? Please walk us through it step by step." 24 | placeholder: "1. Go to '...' 25 | 2. Click on '....' 26 | 3. Scroll down to '....' 27 | 4. See the error" 28 | - type: textarea 29 | id: expected-behavior 30 | validations: 31 | required: true 32 | attributes: 33 | label: "👍 Expected behavior" 34 | description: "What did you think should happen?" 35 | placeholder: "It should ..." 36 | - type: textarea 37 | id: actual-behavior 38 | validations: 39 | required: true 40 | attributes: 41 | label: "👎 Actual Behavior with Screenshots" 42 | description: "What did actually happen? Add screenshots, if applicable." 43 | placeholder: "It actually ..." 44 | - type: input 45 | id: app-version 46 | validations: 47 | required: true 48 | attributes: 49 | label: Cards-Against-Humanity version 50 | description: In case of self-hosting or local installation mention the Cards-Against-Humanity version like 1.0.4 . 51 | placeholder: latest 52 | - type: input 53 | id: npm-version 54 | validations: 55 | required: false 56 | attributes: 57 | label: npm version 58 | description: In case of self-hosting or local installation mention the npm version. If using our cloud-managed solution, mention NA. 59 | placeholder: 9.5.0 60 | - type: input 61 | id: node-version 62 | validations: 63 | required: false 64 | attributes: 65 | label: node version 66 | description: In case of self-hosting or local installation mention the node version. If using our cloud-managed solution, mention NA. 67 | placeholder: 18.5.0 68 | - type: textarea 69 | id: additional-context 70 | validations: 71 | required: false 72 | attributes: 73 | label: "📃 Provide any additional context for the Bug." 74 | description: "Add any other context about the problem here." 75 | placeholder: "It actually ..." 76 | - type: checkboxes 77 | id: no-duplicate-issues 78 | attributes: 79 | label: "👀 Have you spent some time to check if this bug has been raised before?" 80 | options: 81 | - label: "I checked and didn't find a similar issue" 82 | required: true 83 | - type: dropdown 84 | attributes: 85 | label: Are you willing to submit PR? 86 | description: This is absolutely not required, but we are happy to guide you in the contribution process. Find us in help-needed channel on [Discord](https://discord.gg/9wcGSf22PM)! 87 | options: 88 | - "Yes I am willing to submit a PR!" 89 | -------------------------------------------------------------------------------- /client/src/Pages/Game/PlayerView.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Col, Row, Spin } from "antd" 2 | import BlackCard from "../../components/Cards/BlackCard" 3 | import WhiteCard from "../../components/Cards/WhiteCard" 4 | import { useContext, useEffect, useState } from "react" 5 | import { useAppSelector } from "../../hooks/hooks" 6 | import { Cards } from "../../types/cards" 7 | import { drawNew, drawWhiteCards, resetWhite } from "../../hooks/functions" 8 | import socketContext from "../../context/SocketContext" 9 | import { LoadingOutlined } from "@ant-design/icons" 10 | 11 | const PlayerView = () => { 12 | const { socket, black_card, czarSocketId, new_turn } = useContext(socketContext).socketState; 13 | const [selected, setSelected] = useState("") 14 | const [playerHand, setPlayerHand] = useState>([]) 15 | const white = useAppSelector(state => state?.whiteCards?.cards) 16 | const spin = ; 17 | const [hasPlayed, setHasPlayed] = useState(false) 18 | const useSelect = (cardTitle: string) => cardTitle === selected ? setSelected("") : setSelected(cardTitle) 19 | 20 | useEffect(() => { 21 | setPlayerHand([]) 22 | setPlayerHand((oldHand: Array) => [...oldHand, ...drawWhiteCards(white, 10)]); 23 | }, []) 24 | 25 | useEffect(() => { 26 | setHasPlayed(new_turn) 27 | if (!new_turn) { 28 | resetWhite(socket, czarSocketId) 29 | } 30 | }, [new_turn]) 31 | 32 | return ( 33 | <> 34 | 35 | {black_card === "" ? 36 | 42 | 43 | 44 | } 45 | /> 46 | : 47 | 51 | } 52 | 53 | {!hasPlayed && 54 | 55 | {playerHand.map((card, index) => 56 | useSelect(card.title)}> 57 | 64 | 65 | )} 66 | 67 | 69 | 70 | } 71 | 72 | 73 | ) 74 | } 75 | export default PlayerView -------------------------------------------------------------------------------- /client/src/Pages/Home/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Row } from "antd" 2 | import { useNavigate } from "react-router-dom"; 3 | import styled from "styled-components"; 4 | import cowboyBoot from '../../assets/cowboyboot.png'; 5 | import fartAndWalkaway from '../../assets/fartandwalkaway.png'; 6 | import airplane from '../../assets/paperairplane.png'; 7 | import pizza from '../../assets/pizza.png'; 8 | import { useEffect } from "react"; 9 | import WhiteCard from "../../components/Cards/WhiteCard"; 10 | import BlackCard from "../../components/Cards/BlackCard"; 11 | import { useDispatch } from "react-redux"; 12 | import { updateUserName } from "../../reducers"; 13 | import { useAppSelector } from "../../hooks/hooks"; 14 | import { faker } from '@faker-js/faker'; 15 | import { fetchCards } from "../../hooks/functions"; 16 | import { berryImg, blackContainerLeft, blackContainerTop, blackContainerTransform, buttonRulesWidth, defaultImgDistance, jazzImg, rightRule, whiteContainer } from "../../hooks/style.utils"; 17 | 18 | 19 | const ContainerWhite = styled.div` 20 | position: absolute; 21 | transform: translate(-50%, -50%); 22 | top: 40%; 23 | left: ${whiteContainer}; 24 | ` 25 | 26 | const ContainerBlack = styled.div` 27 | position: absolute; 28 | transform: translate(-50%, -50%); 29 | top: ${blackContainerTop}; 30 | left: ${blackContainerLeft}; 31 | perspective: 50em; 32 | ${blackContainerTransform}; 33 | rotate: 22deg; 34 | ` 35 | 36 | const Home = () => { 37 | const navigate = useNavigate(); 38 | const dispatch = useDispatch(); 39 | const white = useAppSelector(state => state.whiteCards.cards) 40 | const black = useAppSelector(state => state.blackCards.cards) 41 | 42 | useEffect(() => { 43 | dispatch(updateUserName(faker.name.fullName())) 44 | if (white.length === 0 || black.length === 0) 45 | fetchCards(dispatch) 46 | }, []); 47 | 48 | return ( 49 |
50 | 51 | 52 | 53 | 54 | 55 | 56 | 61 | 62 | 63 | 64 | 65 | pizza 66 | {berryImg} 67 | fartAndWalkaway 68 | airplane 69 | {jazzImg} 70 | cowboyBoot 71 |
72 | ) 73 | } 74 | export default Home -------------------------------------------------------------------------------- /client/src/context/SocketContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { Socket } from 'socket.io-client'; 3 | 4 | export interface SocketContextState { 5 | socket: Socket | undefined; 6 | uid: string; 7 | users: Array; 8 | rooms: any; 9 | black_card: string; 10 | czarSocketId: string; 11 | white_card: Map 12 | score: Map, 13 | new_turn: boolean 14 | } 15 | 16 | export const defaultSocketContextState: SocketContextState = { 17 | socket: undefined, 18 | uid: '', 19 | users: [], 20 | rooms: {}, 21 | black_card: "", 22 | czarSocketId: "", 23 | white_card: new Map(), 24 | score: new Map(), 25 | new_turn: false 26 | }; 27 | 28 | export type SocketContextActions = 29 | 'update_socket' | 30 | 'update_uid' | 31 | 'update_users' | 32 | 'remove_user' | 33 | 'room' | 34 | 'update_rooms' | 35 | 'get_black_card' | 36 | 'set_czar' | 37 | 'get_white_card' | 38 | 'reset_white_card' | 39 | 'update_score' | 40 | 'reset_score' | 41 | 'new_turn' 42 | ; 43 | 44 | type UserToCard = { 45 | user: string; 46 | cardTitle: string 47 | } 48 | 49 | export type SocketContextPayload = string | Array | Socket | UserToCard | Map | boolean; 50 | 51 | export interface SocketContextActionsPayload { 52 | type: SocketContextActions; 53 | payload: SocketContextPayload; 54 | } 55 | 56 | export const socketReducer = (state: SocketContextState, action: SocketContextActionsPayload) => { 57 | // console.log('Message received - Action: ' + action.type + ' - Payload: ', action.payload); 58 | switch (action.type) { 59 | case 'update_socket': 60 | return { ...state, socket: action.payload as Socket }; 61 | case 'update_uid': 62 | return { ...state, uid: action.payload as string }; 63 | case 'update_users': 64 | return { ...state, users: action.payload as Array }; 65 | case 'remove_user': 66 | return { ...state, users: state.users.filter((uid) => uid !== (action.payload as string)) }; 67 | case 'update_rooms': 68 | return { ...state, rooms: action.payload as any }; 69 | case 'get_black_card': 70 | return { ...state, black_card: action.payload as string }; 71 | case 'set_czar': 72 | return { ...state, czarSocketId: action.payload as string }; 73 | case 'get_white_card': 74 | { 75 | const newMap = new Map(state.white_card) 76 | const payload = action.payload as UserToCard 77 | newMap.set(payload.user, payload.cardTitle) 78 | return { ...state, white_card: newMap }; 79 | } 80 | case 'reset_white_card': 81 | return { ...state, white_card: new Map() }; 82 | case 'update_score': 83 | return { ...state, score: action.payload as Map, }; 84 | case 'reset_score': 85 | return { ...state, score: new Map(), }; 86 | case 'new_turn': 87 | return { ...state, new_turn: action.payload as boolean }; 88 | default: 89 | return state; 90 | } 91 | }; 92 | 93 | export interface SocketContextProps { 94 | socketState: SocketContextState; 95 | socketDispatch: React.Dispatch; 96 | } 97 | 98 | const socketContext = createContext({ 99 | socketState: defaultSocketContextState, 100 | socketDispatch: () => { } 101 | }); 102 | 103 | export const SocketContextConsumer = socketContext.Consumer; 104 | export const SocketContextProvider = socketContext.Provider; 105 | 106 | export default socketContext; -------------------------------------------------------------------------------- /client/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/test/socket.test.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import { io as Client } from "socket.io-client"; 3 | import { Server } from 'socket.io'; 4 | import { Server as HttpServer } from 'http'; 5 | import { expect, test, describe, beforeAll, afterAll, beforeEach, it } from 'vitest' 6 | import dotenv from 'dotenv'; 7 | import { ServerSocket } from '../socket'; 8 | import { getCurrentRoom, getUidFromSocketID, getUsersInRoom, sendMessage } from '../utils/utils'; 9 | 10 | 11 | dotenv.config(); 12 | const port = process.env.PORT; 13 | 14 | describe("Default socket test", () => { 15 | let io: any, serverSocket: any, clientSocket: any; 16 | 17 | beforeAll((done: any) => { 18 | const httpServer = http.createServer(); 19 | io = new Server(httpServer); 20 | httpServer.listen(() => { 21 | clientSocket = Client(`http://localhost:${port}`); 22 | io.on("connection", (socket: any) => { 23 | serverSocket = socket; 24 | }); 25 | clientSocket?.on("connect", done); 26 | }); 27 | }); 28 | 29 | afterAll(() => { 30 | io?.close(); 31 | clientSocket?.close(); 32 | }); 33 | 34 | test("should work", (done: any) => { 35 | clientSocket?.on("hello", (arg: any) => { 36 | expect(arg).toBe("world"); 37 | done(); 38 | }); 39 | serverSocket && serverSocket.emit("hello", "world"); 40 | }); 41 | 42 | test("should work (with ack)", (done: any) => { 43 | serverSocket && 44 | serverSocket.on("hi", (reply: any) => { 45 | reply("hi!"); 46 | }); 47 | clientSocket?.emit("hi", (arg: any) => { 48 | expect(arg).toBe("hi!"); 49 | done(); 50 | }); 51 | }); 52 | }); 53 | 54 | 55 | describe('Server Socket', () => { 56 | let serverSocket: ServerSocket; 57 | let httpServer: HttpServer; 58 | 59 | beforeEach(() => { 60 | httpServer = {} as HttpServer; 61 | serverSocket = new ServerSocket(httpServer); 62 | }); 63 | 64 | it('should create an instance of ServerSocket', () => { 65 | expect(serverSocket).toBeInstanceOf(ServerSocket); 66 | }); 67 | 68 | it('should get uid from socket id', () => { 69 | const socketId = '1234'; 70 | const uid = 'abcd'; 71 | serverSocket.users[uid] = socketId; 72 | const result = getUidFromSocketID(serverSocket.users, socketId); 73 | expect(result).toBe(uid); 74 | }); 75 | 76 | it('should send a message to users', () => { 77 | const name = 'test_event'; 78 | const payload = { message: 'Hello, World!' }; 79 | const users = ['1234', '5678']; 80 | sendMessage(name, users, serverSocket.io, payload); 81 | }); 82 | 83 | 84 | it('should return the current room when getCurrentRoom is called', () => { 85 | const roomName = 'room1'; 86 | const user1 = 'user1'; 87 | const user2 = 'user2'; 88 | const room = new Set([user1, user2]); 89 | (serverSocket.io.sockets.adapter as any).rooms.set(roomName, room); 90 | 91 | const result = getCurrentRoom(roomName, getUsersInRoom(serverSocket.io, roomName)); 92 | expect(result).toEqual({ 93 | data: { 94 | roomName, 95 | users: JSON.stringify([...room]) 96 | } 97 | }); 98 | }); 99 | 100 | it('should return the UID from the socket ID when getUidFromSocketID is called', () => { 101 | const uid1 = '123'; 102 | const uid2 = '456'; 103 | serverSocket.users = { 104 | [uid1]: 'socket1', 105 | [uid2]: 'socket2' 106 | }; 107 | const result1 = getUidFromSocketID(serverSocket.users, 'socket1'); 108 | const result2 = getUidFromSocketID(serverSocket.users, 'socket2'); 109 | const result3 = getUidFromSocketID(serverSocket.users, 'socket3'); 110 | expect(result1).toEqual(uid1); 111 | expect(result2).toEqual(uid2); 112 | expect(result3).toBeUndefined(); 113 | }); 114 | }); -------------------------------------------------------------------------------- /client/test/WaitingLobby.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe, vi, test } from 'vitest' 4 | import { fireEvent, render, screen } from '@testing-library/react' 5 | import { BrowserRouter, MemoryRouter } from 'react-router-dom'; 6 | import WaitingLobby from '../src/Pages/Lobby/WaitingLobby'; 7 | import { Provider } from 'react-redux'; 8 | import { store } from '../src/store/store'; 9 | import { SocketContextProvider } from '../src/context/SocketContext'; 10 | 11 | const mockedUseNavigate = vi.fn(); 12 | vi.mock("react-router-dom", async () => { 13 | const mod = await vi.importActual( 14 | "react-router-dom" 15 | ); 16 | return { 17 | ...mod, 18 | useNavigate: () => mockedUseNavigate, 19 | }; 20 | }); 21 | 22 | describe("WaitingLobby", () => { 23 | const state = { roomName: "test-room", type: "admin" }; 24 | const navigate = vi.fn(); 25 | const deleteRoom = vi.fn(); 26 | const leaveRoom = vi.fn(); 27 | const startGame = vi.fn(); 28 | const socket = { on: vi.fn(), emit: vi.fn() }; 29 | 30 | beforeEach(() => { 31 | vi.clearAllMocks(); 32 | }); 33 | 34 | it('renders without errors', () => { 35 | const { getByText } = render( 36 | 37 | 38 | 39 | 40 | 41 | ) 42 | expect(getByText(/Back/)).toBeInTheDocument() 43 | expect(getByText(/Go to the homepage/)).toBeInTheDocument() 44 | }) 45 | 46 | it('fire buttons event', () => { 47 | const { getByText } = render( 48 | 49 | 50 | 51 | 52 | 53 | ) 54 | 55 | const start = getByText(/Go to the homepage/) 56 | fireEvent.click(start) 57 | expect(mockedUseNavigate).toHaveBeenCalledTimes(1) 58 | }) 59 | 60 | it('fire back event', () => { 61 | mockedUseNavigate.mockReturnValueOnce({ 62 | pathname: '/path', 63 | search: '', 64 | hash: '', 65 | state: { roomName: "test", type: "admin" }, 66 | }); 67 | const { getByText } = render( 68 | 69 | 70 | 71 | 72 | 73 | ) 74 | const back = getByText(/Back/) 75 | fireEvent.click(back) 76 | expect(mockedUseNavigate).toHaveBeenCalledTimes(0) 77 | }) 78 | 79 | 80 | it('renders admin waiting lobby', async () => { 81 | const socket = { on: vi.fn(), emit: vi.fn() }; 82 | const mockState = { 83 | socket: undefined, 84 | uid: '', 85 | users: [], 86 | rooms: { 87 | "room1": ["a", "b", "c"], 88 | "room2": ["a", "b", "c"], 89 | "room3": ["a", "b", "c"], 90 | }, 91 | white_card: new Map([ 92 | ['player1', 'card1'], 93 | ['player2', 'card2'], 94 | ['player3', 'card3'] 95 | ]), 96 | czarSocketId: "", 97 | black_card: "", 98 | score: new Map(), 99 | new_turn: false 100 | }; 101 | 102 | const mockNavigate = vi.fn(); 103 | const state = { roomName: 'room1', type: "admin" }; 104 | const socketProvider = ({ children }) => ( 105 | 106 | { } }}> 107 | 108 | {children} 109 | 110 | 111 | 112 | ) 113 | render(, { wrapper: socketProvider }); 114 | expect(screen.getByText('Start Game')).toBeInTheDocument(); 115 | fireEvent.click(screen.getByText('Start Game')); 116 | expect(mockNavigate).toHaveBeenCalledTimes(0) 117 | }); 118 | 119 | }); -------------------------------------------------------------------------------- /client/src/context/SocketContextComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, useEffect, useReducer, useState } from 'react'; 2 | import { useSocket } from '../hooks/useSocket'; 3 | import { defaultSocketContextState, SocketContextProvider, socketReducer } from './SocketContext' 4 | import 'antd/dist/reset.css'; 5 | import SpinningCard from '../components/Cards/SpinningCard'; 6 | import { useNavigate } from 'react-router-dom'; 7 | 8 | export interface SocketContextComponentProps extends PropsWithChildren { } 9 | 10 | const SocketContextComponent: React.FunctionComponent = (props) => { 11 | const { children } = props; 12 | 13 | const socket = useSocket('ws://localhost:3000', { 14 | reconnectionAttempts: 5, 15 | reconnectionDelay: 1000, 16 | autoConnect: false 17 | }); 18 | 19 | const [socketState, socketDispatch] = useReducer(socketReducer, defaultSocketContextState); 20 | const [loading, setLoading] = useState(true); 21 | const navigate = useNavigate() 22 | 23 | useEffect(() => { 24 | socket.connect(); 25 | socketDispatch({ type: 'update_socket', payload: socket }); 26 | startListeners(); 27 | sendHandshake(); 28 | }, []); 29 | 30 | const startListeners = () => { 31 | socket.on('user_connected', (users: string[]) => { 32 | console.info('User connected message received'); 33 | socketDispatch({ type: 'update_users', payload: users }); 34 | }); 35 | 36 | socket.on('user_disconnected', (data: any) => { 37 | console.info('User disconnected message received'); 38 | socketDispatch({ type: 'remove_user', payload: data.uid }); 39 | socketDispatch({ type: "update_rooms", payload: data.rooms?.data }) 40 | socketDispatch({ type: 'update_users', payload: data.users }); 41 | }); 42 | 43 | socket.io.on('reconnect', (attempt) => { 44 | console.info('Reconnected on attempt: ' + attempt); 45 | sendHandshake(); 46 | }); 47 | 48 | socket.io.on('reconnect_attempt', (attempt) => { 49 | console.info('Reconnection Attempt: ' + attempt); 50 | }); 51 | 52 | socket.io.on('reconnect_error', (error) => { 53 | console.info('Reconnection error: ' + error); 54 | }); 55 | 56 | socket.io.on('reconnect_failed', () => { 57 | console.info('Reconnection failure.'); 58 | alert('We are unable to connect you to the chat service.' + 59 | 'Please make sure your internet connection is stable or try again later.'); 60 | }); 61 | 62 | socket.on('update_rooms', (rooms: any) => socketDispatch({ type: "update_rooms", payload: rooms?.data })); 63 | socket.on('start_game', (isCzar: string, roomName: string) => navigate("/game", { 64 | state: { 65 | isCzar: isCzar, 66 | roomName: roomName 67 | } 68 | })); 69 | 70 | socket.on('get_black_card', (title: string, czarSocket: string) => { 71 | socketDispatch({ type: "get_black_card", payload: title }) 72 | socketDispatch({ type: "set_czar", payload: czarSocket }) 73 | }); 74 | 75 | socket.on('get_white_card', (cardTitle: string, user: string) => socketDispatch({ type: "get_white_card", payload: { cardTitle, user } })); 76 | socket.on('reset_white_card', () => socketDispatch({ type: "reset_white_card", payload: "" })); 77 | socket.on('update_score', (newScore: Map) => socketDispatch({ type: "update_score", payload: newScore })) 78 | socket.on('reset_score', () => socketDispatch({ type: "reset_score", payload: "" })); 79 | socket.on('new_turn', (hasPlayed: boolean) => socketDispatch({ type: "new_turn", payload: hasPlayed })); 80 | }; 81 | 82 | const sendHandshake = async () => { 83 | console.info('Sending handshake to server ...'); 84 | 85 | socket.emit('handshake', async (uid: string, users: string[]) => { 86 | console.info('User handshake callback message received'); 87 | socketDispatch({ type: 'update_users', payload: users }); 88 | socketDispatch({ type: 'update_uid', payload: uid }); 89 | setLoading(false); 90 | }); 91 | }; 92 | 93 | if (loading) return ( 94 | 95 | ) 96 | 97 | return {children} 98 | }; 99 | 100 | export default SocketContextComponent; -------------------------------------------------------------------------------- /client/src/Pages/Game/Game.tsx: -------------------------------------------------------------------------------- 1 | import { CalculatorOutlined, LeftOutlined } from "@ant-design/icons" 2 | import { Button, Col, Dropdown, List, Modal, Result, Row } from "antd" 3 | import { useContext, useEffect, useState } from "react" 4 | import { useLocation, useNavigate } from "react-router-dom" 5 | import GameScorer from "./GameScorer" 6 | import CzarView from "./CzarView" 7 | import PlayerView from "./PlayerView" 8 | import socketContext from "../../context/SocketContext" 9 | import { checkScore, leaveRoom } from "../../hooks/functions" 10 | import BlackCard from "../../components/Cards/BlackCard" 11 | 12 | const Game = () => { 13 | const { socket, rooms, score } = useContext(socketContext).socketState; 14 | const { state } = useLocation(); 15 | const [modal, setModal] = useState(false) 16 | const [lobbyType, setLobbyType] = useState(state?.isCzar) 17 | const [show, setShow] = useState(false) 18 | const players = rooms[state?.roomName] 19 | let playersForScore: Array = Array.from(score, ([name, score]) => ({ name, score })); 20 | const navigate = useNavigate() 21 | 22 | const items = [ 23 | { 24 | label: , 25 | key: '1', 26 | }, 27 | ]; 28 | 29 | useEffect(() => { 30 | setLobbyType(state?.isCzar) 31 | }, [state]) 32 | 33 | useEffect(() => { 34 | setShow(checkScore(playersForScore)?.status) 35 | }, [score]) 36 | 37 | 38 | return ( 39 | <> 40 | {show ? 41 | 42 | Player ${checkScore(playersForScore)?.res[0]?.name} won the game

} 47 | size="small" 48 | itemLayout="horizontal" 49 | dataSource={playersForScore.sort((a, b) => b.score - a.score)} 50 | renderItem={(user) => ( 51 | 52 |

{user.name} : {user.score}

53 |
54 | )} 55 | /> 56 | } 57 | /> 58 | 59 | 60 | 66 | 67 | 68 |
69 | : 70 | players === undefined || players.length < 3 ? 71 | navigate("/")}> 76 | Go to the homepage 77 | 78 | } 79 | /> 80 | : 81 |
82 | 83 | 84 |

You are: {socket?.id}

85 | 86 | 87 | 88 |
89 | {lobbyType === "czar" ? : } 90 | leaveRoom(socket, state?.roomName, true, lobbyType, navigate, score)} 92 | onCancel={() => setModal(false)} 93 | /> 94 |
95 | } 96 | 97 | ) 98 | } 99 | export default Game -------------------------------------------------------------------------------- /client/test/SocketContext.test.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { describe } from 'vitest' 3 | import { Socket } from 'socket.io-client'; 4 | import { SocketContextActionsPayload, SocketContextState, defaultSocketContextState, socketReducer } from '../src/context/SocketContext'; 5 | 6 | describe('socketReducer', () => { 7 | let initialState: SocketContextState; 8 | 9 | beforeEach(() => { 10 | initialState = defaultSocketContextState; 11 | }); 12 | 13 | it('should update the socket when update_socket action is dispatched', () => { 14 | const mockSocket: Socket = {} as Socket; 15 | const action: SocketContextActionsPayload = { type: 'update_socket', payload: mockSocket }; 16 | const newState = socketReducer(initialState, action); 17 | expect(newState.socket).toEqual(mockSocket); 18 | }); 19 | 20 | it('should update the uid when update_uid action is dispatched', () => { 21 | const mockUid = '123'; 22 | const action: SocketContextActionsPayload = { type: 'update_uid', payload: mockUid }; 23 | const newState = socketReducer(initialState, action); 24 | 25 | expect(newState.uid).toEqual(mockUid); 26 | }); 27 | 28 | it('should update the users when update_users action is dispatched', () => { 29 | const mockUsers = ['user1', 'user2']; 30 | const action: SocketContextActionsPayload = { type: 'update_users', payload: mockUsers }; 31 | const newState = socketReducer(initialState, action); 32 | 33 | expect(newState.users).toEqual(mockUsers); 34 | }); 35 | 36 | it('should remove a user when remove_user action is dispatched', () => { 37 | const mockUsers = ['user1', 'user2']; 38 | const action: SocketContextActionsPayload = { type: 'remove_user', payload: 'user1' }; 39 | const state = { ...initialState, users: mockUsers }; 40 | const newState = socketReducer(state, action); 41 | expect(newState.users).toEqual(['user2']); 42 | }); 43 | 44 | it('should update rooms ', () => { 45 | const mockRooms = ['user1', 'user2']; 46 | const action: SocketContextActionsPayload = { type: 'update_rooms', payload: mockRooms }; 47 | const stateWithRooms = { ...initialState, rooms: [] }; 48 | const newState = socketReducer(stateWithRooms, action); 49 | expect(newState.rooms).toEqual(mockRooms); 50 | }); 51 | 52 | it('should get a black card', () => { 53 | const action: SocketContextActionsPayload = { type: 'get_black_card', payload: 'new' }; 54 | const state = { ...initialState, black_card: "" }; 55 | const newState = socketReducer(state, action); 56 | expect(newState.black_card).toEqual("new"); 57 | }); 58 | it('should set the new czar', () => { 59 | const action: SocketContextActionsPayload = { type: 'set_czar', payload: 'user1' }; 60 | const state = { ...initialState, czarSocketId: "" }; 61 | const newState = socketReducer(state, action); 62 | expect(newState.czarSocketId).toEqual("user1"); 63 | }); 64 | it('should get white card', () => { 65 | let payload = { 66 | user: "a", 67 | cardTitle: "b" 68 | } 69 | const action: SocketContextActionsPayload = { type: 'get_white_card', payload: payload }; 70 | const state = { ...initialState, white_card: new Map() }; 71 | const newState = socketReducer(state, action); 72 | let res = new Map() 73 | expect(newState.white_card).toEqual(new Map().set(payload.user, payload.cardTitle)); 74 | }); 75 | it('should reset white card', () => { 76 | const action: SocketContextActionsPayload = { type: 'reset_white_card', payload: "" }; 77 | const state = { ...initialState, white_card: new Map() }; 78 | const newState = socketReducer(state, action); 79 | expect(newState.white_card).toEqual(new Map()); 80 | }); 81 | it('should update score', () => { 82 | let payload = new Map() 83 | payload.set("a", 1) 84 | payload.set("b", 3) 85 | const action: SocketContextActionsPayload = { type: 'update_score', payload }; 86 | const state = { ...initialState, score: new Map() }; 87 | const newState = socketReducer(state, action); 88 | expect(newState.score).toEqual(payload); 89 | }); 90 | it('should reset score', () => { 91 | let payload = new Map() 92 | payload.set("a", 1) 93 | payload.set("b", 3) 94 | const action: SocketContextActionsPayload = { type: 'reset_score', payload: "" }; 95 | const state = { ...initialState, score: payload }; 96 | const newState = socketReducer(state, action); 97 | expect(newState.score).toEqual(new Map()); 98 | }); 99 | 100 | it('should trigger new turn', () => { 101 | const action: SocketContextActionsPayload = { type: 'new_turn', payload: true }; 102 | const state = { ...initialState, new_turn: false }; 103 | const newState = socketReducer(state, action); 104 | expect(newState.new_turn).toEqual(true); 105 | }); 106 | 107 | }); -------------------------------------------------------------------------------- /client/test/PlayerView.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe, vi } from 'vitest' 4 | import { fireEvent, render, screen } from '@testing-library/react'; 5 | import { BrowserRouter, MemoryRouter } from 'react-router-dom'; 6 | import PlayerView from '../src/Pages/Game/PlayerView'; 7 | import { testStore } from '../src/store/store'; 8 | import { Provider } from 'react-redux'; 9 | import { SocketContextProvider } from '../src/context/SocketContext'; 10 | 11 | vi.mock("react-redux", async () => { 12 | const actual: any = await vi.importActual("react-redux") 13 | return { 14 | ...actual, 15 | useDispatch: vi.fn(), 16 | } 17 | }) 18 | 19 | const mockedUseNavigate = vi.fn(); 20 | vi.mock("react-router-dom", async () => { 21 | const mod = await vi.importActual( 22 | "react-router-dom" 23 | ); 24 | return { 25 | ...mod, 26 | useNavigate: () => mockedUseNavigate, 27 | }; 28 | }); 29 | 30 | describe('Player View', () => { 31 | it('renders view correctly', () => { 32 | const { getByText } = render( 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | expect(getByText("Submit Response")).toBeInTheDocument() 40 | }); 41 | 42 | it('renders spinning card', async () => { 43 | const mockState = { 44 | socket: undefined, 45 | uid: '', 46 | users: [], 47 | rooms: { 48 | "room1": ["a", "b", "c"], 49 | "room2": ["a", "b", "c"], 50 | "room3": ["a", "b", "c"], 51 | }, 52 | white_card: new Map([ 53 | ['player1', 'card1'], 54 | ['player2', 'card2'], 55 | ['player3', 'card3'] 56 | ]), 57 | czarSocketId: "", 58 | black_card: "", 59 | score: new Map(), 60 | new_turn: false 61 | }; 62 | 63 | const mockNavigate = vi.fn(); 64 | const state = { roomName: 'room1', isCzar: false }; 65 | const socketProvider = ({ children }) => ( 66 | 67 | { } }}> 68 | 69 | {children} 70 | 71 | 72 | 73 | ) 74 | render(, { wrapper: socketProvider }); 75 | expect(screen.getByLabelText('loading')).toBeInTheDocument(); 76 | fireEvent.click(screen.getByText('Submit Response')); 77 | expect(screen.getByText('Submit Response')).toBeEnabled(); 78 | }); 79 | 80 | it('renders a black_card', async () => { 81 | const mockState = { 82 | socket: undefined, 83 | uid: '', 84 | users: [], 85 | rooms: { 86 | "room1": ["a", "b", "c"], 87 | "room2": ["a", "b", "c"], 88 | "room3": ["a", "b", "c"], 89 | }, 90 | white_card: new Map([ 91 | ['player1', 'card1'], 92 | ['player2', 'card2'], 93 | ['player3', 'card3'] 94 | ]), 95 | czarSocketId: "", 96 | black_card: "card____", 97 | score: new Map(), 98 | new_turn: false 99 | }; 100 | 101 | const mockNavigate = vi.fn(); 102 | const state = { roomName: 'room1', isCzar: false }; 103 | const socketProvider = ({ children }) => ( 104 | 105 | { } }}> 106 | 107 | {children} 108 | 109 | 110 | 111 | ) 112 | render(, { wrapper: socketProvider }); 113 | expect(screen.getByText('card____')).toBeInTheDocument(); 114 | fireEvent.click(screen.getByText('Submit Response')); 115 | expect(screen.getByText('Submit Response')).toBeEnabled(); 116 | }); 117 | 118 | it('calls drawNew function when submit button is clicked', () => { 119 | const mockState = { 120 | socket: undefined, 121 | uid: '', 122 | users: [], 123 | rooms: { 124 | "room1": ["a", "b", "c"], 125 | "room2": ["a", "b", "c"], 126 | "room3": ["a", "b", "c"], 127 | }, 128 | white_card: new Map([ 129 | ['player1', 'card1'], 130 | ['player2', 'card2'], 131 | ['player3', 'card3'], 132 | ]), 133 | czarSocketId: "", 134 | black_card: "card____", 135 | score: new Map(), 136 | new_turn: false 137 | }; 138 | const state = { roomName: 'room1', isCzar: false }; 139 | 140 | const socketProvider = ({ children }) => ( 141 | 142 | { } }}> 143 | 144 | {children} 145 | 146 | 147 | 148 | ) 149 | render(, { wrapper: socketProvider }); 150 | fireEvent.click(screen.getByText(1)); 151 | fireEvent.click(screen.getByText('Submit Response')); 152 | expect(screen.queryByText('1')).not.toBeInTheDocument() 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Screenshot 2023-03-06 alle 11 32 00 3 |

4 | 5 | 6 |

Cards Against Humanity ⚫️🃏⚪️

7 | 8 |

9 | 10 | 11 | 12 |

13 | 14 | 15 | 16 |

:book: Table of Contents

17 | 18 |
19 | Table of Contents 20 |
    21 |
  1. ➤ About The Project
  2. 22 |
  3. ➤ Overview
  4. 23 |
  5. ➤ Getting Started
  6. 24 |
  7. ➤ Authors
  8. 25 |
26 |
27 | 28 | ![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png) 29 | 30 | 31 |

:pencil: About The Project

32 | 33 |

34 | 35 | Cards Against Humanity: `fill in the blank` Edition is a fill-in-the-blank party game. Each 36 | round, one player asks the group a question from a black card. These cards consist of inside jokes, memories, and more. 37 | 38 | One random player begins as the `Czar` and draws a black card. 39 | 40 | The Czar reads the black card out loud. Everyone else then answers the 41 | question (or fills in the blank) by passing one white card, face down, to the Czar. 42 | 43 | The Czar then shuffles all the white cards and re-reads the black card out loud 44 | with each one. Finally, the Czar picks the funniest combination, and whoever 45 | played it gets one point/the black card. 46 | 47 | 48 |

49 | 50 | ![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png) 51 | 52 | 53 |

:cloud: Overview

54 | 55 |

56 | Cards Against Humanity: Fill in the Blank Edition takes the outrageous and irreverent gameplay of the original party game to new levels of hilarity. Designed to bring friends, families, and acquaintances closer through laughter, this game provides a perfect platform for creating inside jokes, recalling funny memories, and engaging in twisted humor. 57 | 58 | ### Gameplay: 59 | The game starts with one player being randomly selected as the Czar for the round. The Czar draws a black card, which contains a question or a statement with one or more blanks. The black cards often touch on taboo subjects, current events, or the darkest corners of our collective humor. 60 | 61 | The Czar reads the black card aloud, and the other players must select a white card from their hand to fill in the blank(s) or provide a humorous response. The white cards contain various phrases, words, or combinations that can be absurd, witty, or downright outrageous. 62 | 63 | Once everyone has chosen a white card, they pass it face-down to the Czar. The Czar then shuffles all the white cards to maintain anonymity and proceeds to read the black card out loud again, replacing the blank(s) with the responses provided by the players. 64 | 65 | At this point, the Czar becomes the ultimate judge of comedic value and reads each combination aloud. The goal is to select the funniest or most clever response from the mix of white cards. The player who played the chosen white card earns a point, representing their victory for that round. 66 | 67 | ### Unleash Your Dark Humor: 68 | Cards Against Humanity: Fill in the Blank Edition encourages players to let loose and embrace their darkest sense of humor. The game thrives on the unexpected, pushing boundaries, and exploring the unspoken. It allows players to express their creativity and wit by providing unique answers to the prompts, often leading to hilarious or cringe-worthy results. 69 | 70 | ### Inside Jokes and Memories: 71 | As players continue to engage with the game over time, they develop a shared set of inside jokes and memories. The game becomes a catalyst for creating new hilarious moments and referencing past experiences, making it an excellent choice for gatherings with close friends or long-standing groups. 72 |

73 | 74 | 75 | ![Demo](https://github.com/LeleDallas/Cards-Against-Humanity/blob/master/demo.gif) 76 | 77 | ![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png) 78 | 79 | 80 |

:book: Getting Started

81 | 82 | Via GitHub Desktop clone this repository then to run the server and client, you need to have Node.js `v18.15.0` or higher installed on your system. 83 | 84 | `Server`: 85 | To run the server locally, follow these steps: 86 | 87 | - Navigate to the server folder of the project. 88 | - Run the command `npm install` to install all the necessary dependencies. 89 | - Run the command `npm run dev` to start the server. 90 | 91 | The server will be listening at `http://localhost:3000`. 92 | 93 | `Client`: 94 | To run the client locally, follow these steps: 95 | 96 | - Navigate to the client folder of the project. 97 | - Run the command `npm install` to install all the necessary dependencies. 98 | - Run the command `npm run dev` or `npm run host` to start the client. 99 | 100 | The client will be running at `http://localhost:5173`. 101 | 102 | 103 | 104 | ![-----------------------------------------------------](https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png) 105 | 106 | 107 | 108 |

:scroll: Authors

109 | 110 | Emanuele Dall'Ara 111 | 112 | [![GitHub Badge](https://img.shields.io/badge/GitHub-100000?style=for-the-badge&logo=github&logoColor=white)](https://github.com/LeleDallas) 113 | [![LinkedIn Badge](https://img.shields.io/badge/LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/emanuele-dall-ara-40b3311a7/) 114 | 115 | Nicholas Ricci 116 | 117 | [![GitHub Badge](https://img.shields.io/badge/GitHub-100000?style=for-the-badge&logo=github&logoColor=white)](https://www.github.com/Piccio98) 118 | [![LinkedIn Badge](https://img.shields.io/badge/LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/nicholas-ricci-6b89ab1b7/) 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /client/test/Game.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { expect, it, describe, vi } from 'vitest' 4 | import { fireEvent, render, screen } from '@testing-library/react' 5 | import { BrowserRouter, MemoryRouter } from 'react-router-dom'; 6 | import Game from '../src/Pages/Game/Game'; 7 | import { act } from 'react-dom/test-utils'; 8 | import { Provider } from 'react-redux'; 9 | import { store } from '../src/store/store'; 10 | import { SocketContextProvider } from '../src/context/SocketContext'; 11 | 12 | const mockedUseNavigate = vi.fn(); 13 | vi.mock("react-router-dom", async () => { 14 | const mod = await vi.importActual( 15 | "react-router-dom" 16 | ); 17 | return { 18 | ...mod, 19 | useNavigate: () => mockedUseNavigate, 20 | }; 21 | }); 22 | 23 | describe('Game', () => { 24 | const navigate = vi.fn(); 25 | const socket = { on: vi.fn(), emit: vi.fn() }; 26 | const socketState = { socket, rooms: { ["test-room"]: ["a", "b", "c"] } }; 27 | const socketContext = { socketState }; 28 | 29 | it('renders without errors', async () => { 30 | const { getByText } = render( 31 | 32 | 33 | 34 | 35 | 36 | ) 37 | expect(getByText("Go to the homepage")).toBeInTheDocument() 38 | fireEvent.click(getByText("Go to the homepage")); 39 | expect(mockedUseNavigate).toHaveBeenCalledTimes(1) 40 | }) 41 | 42 | it('renders player game', async () => { 43 | const socket = { on: vi.fn(), emit: vi.fn() }; 44 | const mockState = { 45 | socket: undefined, 46 | uid: '', 47 | users: [], 48 | rooms: { 49 | "room1": ["a", "b", "c"], 50 | "room2": ["a", "b", "c"], 51 | "room3": ["a", "b", "c"], 52 | }, 53 | white_card: new Map([ 54 | ['player1', 'card1'], 55 | ['player2', 'card2'], 56 | ['player3', 'card3'] 57 | ]), 58 | czarSocketId: "", 59 | black_card: "", 60 | score: new Map(), 61 | new_turn: false 62 | }; 63 | 64 | const mockNavigate = vi.fn(); 65 | const state = { roomName: 'room1', isCzar: false }; 66 | const socketProvider = ({ children }) => ( 67 | 68 | { } }}> 69 | 70 | {children} 71 | 72 | 73 | 74 | ) 75 | render(, { wrapper: socketProvider }); 76 | expect(screen.getByText('Submit Response')).toBeInTheDocument(); 77 | fireEvent.click(screen.getByText('Submit Response')); 78 | expect(screen.getByText('Submit Response')).toBeEnabled(); 79 | fireEvent.click(screen.getByText('Scores')); 80 | fireEvent.click(screen.getByText('Back')); 81 | expect(screen.getByText('Are you sure to leave the lobby?')).toBeInTheDocument(); 82 | fireEvent.click(screen.getByText('OK')); 83 | expect(mockedUseNavigate).toHaveBeenCalledTimes(1) 84 | }); 85 | 86 | it('renders czar game', async () => { 87 | const mockState = { 88 | socket: undefined, 89 | uid: '', 90 | users: [], 91 | rooms: { 92 | "room1": ["a", "b", "c"], 93 | "room2": ["a", "b", "c"], 94 | "room3": ["a", "b", "c"], 95 | }, 96 | white_card: new Map([ 97 | ['player1', 'card1'], 98 | ['player2', 'card2'], 99 | ['player3', 'card3'] 100 | ]), 101 | czarSocketId: "", 102 | black_card: "", 103 | score: new Map(), 104 | new_turn: false 105 | }; 106 | 107 | const mockNavigate = vi.fn(); 108 | const state = { roomName: 'room1', isCzar: true }; 109 | const socketProvider = ({ children }) => ( 110 | 111 | { } }}> 112 | 113 | {children} 114 | 115 | 116 | 117 | ) 118 | render(, { wrapper: socketProvider }); 119 | expect(screen.getByText('Submit Response')).toBeInTheDocument(); 120 | fireEvent.click(screen.getByText('Submit Response')); 121 | expect(screen.getByText('Submit Response')).toBeEnabled(); 122 | fireEvent.click(screen.getByText('Scores')); 123 | fireEvent.click(screen.getByText('Back')); 124 | expect(screen.getByText('Are you sure to leave the lobby?')).toBeInTheDocument(); 125 | }); 126 | 127 | it('empty player', async () => { 128 | const mockState = { 129 | socket: undefined, 130 | uid: '', 131 | users: [], 132 | rooms: { 133 | "room1": ["a",], 134 | "room2": ["a", "b", "c"], 135 | "room3": ["a", "b", "c"], 136 | }, 137 | white_card: new Map([ 138 | ['player1', 'card1'], 139 | ['player2', 'card2'], 140 | ['player3', 'card3'] 141 | ]), 142 | czarSocketId: "", 143 | black_card: "", 144 | score: new Map(), 145 | new_turn: false 146 | }; 147 | 148 | const mockNavigate = vi.fn(); 149 | const state = { roomName: 'room1', isCzar: true }; 150 | const socketProvider = ({ children }) => ( 151 | 152 | { } }}> 153 | 154 | {children} 155 | 156 | 157 | 158 | ) 159 | render(, { wrapper: socketProvider }); 160 | expect(screen.getByText('This room do not exist anymore.')).toBeInTheDocument(); 161 | }); 162 | }) 163 | -------------------------------------------------------------------------------- /client/src/hooks/functions.ts: -------------------------------------------------------------------------------- 1 | import { Action } from "@reduxjs/toolkit"; 2 | import { message } from "antd"; 3 | import { Dispatch } from "react"; 4 | import { NavigateFunction } from "react-router-dom"; 5 | import { Socket } from "socket.io-client"; 6 | import { DefaultEventsMap } from "socket.io/dist/typed-events"; 7 | import { updateBlack, updateWhite } from "../reducers"; 8 | import { Cards } from "../types/cards"; 9 | import { SocketGameStartResponse, SocketRoomResponse } from "../types/socketResponse"; 10 | 11 | export const createRoom = ( 12 | socket: Socket | undefined, 13 | roomName: string, 14 | navigate: NavigateFunction 15 | ) => { 16 | if (roomName.length === 0) { 17 | message.error("Insert a Lobby name") 18 | return 19 | } 20 | socket?.emit("create_room", roomName, (response: SocketRoomResponse) => { 21 | if (response.success) { 22 | navigate("/waiting", { state: { roomName: response.data.roomName, type: "admin" } }) 23 | } 24 | }) 25 | } 26 | 27 | export const joinRoom = ( 28 | socket: Socket | undefined, 29 | roomName: string, 30 | navigate: NavigateFunction 31 | ) => { 32 | socket?.emit("join_room", roomName, (response: any) => { 33 | if (response.success) { 34 | navigate("/waiting", { state: { roomName, type: "user" } }) 35 | } 36 | }) 37 | } 38 | 39 | export const drawBlackCard = (black: Array): Cards => 40 | black[Math.floor(Math.random() * black.length)]; 41 | 42 | export const drawWhiteCards = (white: Array, quantity: number): Array => 43 | [...white].sort(() => 0.5 - Math.random()).slice(0, quantity); 44 | 45 | 46 | export const drawNew = ( 47 | socket: Socket | undefined, 48 | czarSocketId: string, 49 | playerHand: Array, 50 | selected: string, 51 | white: any, 52 | setSelected: (string: string) => void, 53 | setPlayerHand: React.Dispatch>, 54 | setHasPlayed: (userSelected: boolean) => void, 55 | ) => { 56 | if (socket) 57 | sendWhiteResponse(socket!, czarSocketId, selected, socket?.id) 58 | setPlayerHand(playerHand.filter((card, _) => card.title !== selected)) 59 | setPlayerHand((oldHand: Array) => [...oldHand, ...drawWhiteCards(white, 1)]); 60 | setSelected("") 61 | setHasPlayed(true) 62 | } 63 | 64 | export const setCurrentSolution = ( 65 | solution: string, 66 | selected: string, 67 | userSelected: string, 68 | setSelected: (string: string) => void, 69 | setSolution: (isSelected: boolean) => void, 70 | setSelectedUser: (userSelected: string) => void 71 | ) => { 72 | if (solution !== selected && selected !== solution) { 73 | setSelected(solution) 74 | setSolution(true) 75 | setSelectedUser(userSelected) 76 | } 77 | else { 78 | setSelected("") 79 | setSelectedUser("") 80 | setSolution(false) 81 | } 82 | } 83 | 84 | export const sendBlack = ( 85 | socket: Socket | undefined, 86 | card: string, 87 | roomName: string 88 | ) => { 89 | socket?.emit("send_black_card", card, roomName, socket.id, (response: SocketGameStartResponse) => { }) 90 | } 91 | 92 | export const sendWhiteResponse = ( 93 | socket: Socket | undefined, 94 | czarSocketId: string, 95 | card: string, 96 | user: string 97 | ) => { 98 | socket?.emit("send_white_card", czarSocketId, card, user, (response: SocketGameStartResponse) => { }) 99 | } 100 | 101 | const updateScore = (oldScore: Map, userKey: string) => { 102 | if (userKey === "") 103 | return oldScore 104 | const newScore = new Map(); 105 | oldScore.forEach((item: any) => newScore.set(item[0], item[1])); 106 | newScore.set(userKey, newScore.get(userKey)! + 1); 107 | return newScore 108 | } 109 | 110 | export const onConfirm = ( 111 | socket: Socket | undefined, 112 | roomName: string, 113 | oldScore: Map, 114 | newCzarId: string, 115 | hasPlayed: boolean 116 | ) => { 117 | socket?.emit("request_update_score", roomName, Array.from(updateScore(oldScore, newCzarId))) 118 | socket?.emit("reset_turn", roomName, hasPlayed, (response: SocketGameStartResponse) => { }) 119 | } 120 | 121 | export const resetWhite = (socket: Socket | undefined, czarSocketId: string) => { 122 | socket?.emit("reset_white", czarSocketId, (response: SocketGameStartResponse) => { 123 | }) 124 | } 125 | 126 | export const resetScore = ( 127 | socket: Socket | undefined, 128 | roomName: string) => { 129 | socket?.emit("request_reset_score", roomName) 130 | } 131 | 132 | export const startGame = ( 133 | socket: Socket | undefined, 134 | roomName: string, 135 | navigate: NavigateFunction 136 | ) => { 137 | socket?.emit("request_start_game", roomName, (response: SocketGameStartResponse) => { 138 | if (response?.success) { 139 | message.success("Game started!") 140 | navigate("/game", { 141 | state: { 142 | isCzar: response.isCzar, 143 | roomName: roomName 144 | } 145 | }) 146 | } 147 | else 148 | message.error("This room has not reach the minimum people to start a game!") 149 | }) 150 | } 151 | 152 | export const nextCzar = ( 153 | socket: Socket | undefined, 154 | roomName: string, 155 | navigate: NavigateFunction, 156 | newCzarId: string, 157 | exit: boolean = false 158 | ) => { 159 | socket?.emit("update_turn", roomName, newCzarId, (response: SocketGameStartResponse) => { 160 | if (response?.success && !exit) { 161 | navigate("/game", { 162 | state: { 163 | isCzar: response.isCzar, 164 | roomName: roomName 165 | } 166 | }) 167 | } 168 | }) 169 | } 170 | 171 | export const deleteRoom = ( 172 | socket: Socket | undefined, 173 | roomName: string, 174 | navigate: NavigateFunction 175 | ) => { 176 | socket?.emit("delete_room", roomName, (response: SocketRoomResponse) => 177 | response?.success && navigate("/")) 178 | } 179 | 180 | export const leaveRoom = ( 181 | socket: Socket | undefined, 182 | roomName: string, 183 | inGame: boolean, 184 | lobbyType: string, 185 | navigate: NavigateFunction, 186 | score?: Map 187 | ) => { 188 | socket?.emit("leave_room", roomName, inGame, (response: SocketRoomResponse) => 189 | response?.success && navigate("/")) 190 | if (lobbyType === "czar") 191 | nextCzar(socket, roomName, navigate, "", true) 192 | if (score) { 193 | let newScore = new Map(score) 194 | socket != undefined && newScore.delete(socket.id) 195 | socket?.emit("request_update_score", roomName, Array.from(updateScore(newScore, ""))) 196 | } 197 | } 198 | 199 | export const checkScore = (players: Array) => { 200 | let res = players.filter(playerStatus => playerStatus.score > 5) 201 | return { status: res.length > 0, res } 202 | } 203 | 204 | export const fetchCards = (dispatch: Dispatch) => { 205 | fetch('http://localhost:3000/cards/', { mode: 'cors' }) 206 | .then((res) => res.json()) 207 | .then((data) => { 208 | dispatch(updateBlack(data.filter((card: Cards) => card.isBlack))); 209 | dispatch(updateWhite(data.filter((card: Cards) => !card.isBlack))); 210 | }) 211 | .catch((err) => { 212 | console.log(err.message); 213 | }); 214 | } 215 | -------------------------------------------------------------------------------- /server/utils/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Socket, Server } from 'socket.io'; 3 | import { user } from "../types/user"; 4 | import { v4 } from 'uuid'; 5 | 6 | export const getRooms = (availableRooms: Map>,) => { 7 | const filteredRooms = new Map>(); 8 | 9 | for (const [roomId, users] of availableRooms.entries()) { 10 | if (roomId.startsWith("room")) { 11 | filteredRooms.set(roomId, [...users]); 12 | } 13 | } 14 | return filteredRooms; 15 | }; 16 | 17 | export const getUsersInRoom = (io: Server, roomName: string) => io.sockets.adapter.rooms.get(roomName) 18 | 19 | export const getCurrentRoom = (roomName: string, users: Set | undefined) => { 20 | if (!users) 21 | return { 22 | data: { 23 | roomName, 24 | users: JSON.stringify([]) 25 | } 26 | } 27 | else 28 | return { 29 | data: { 30 | roomName, 31 | users: JSON.stringify(users && [...users]) 32 | } 33 | } 34 | }; 35 | 36 | export const getUidFromSocketID = (users: user, id: string) => 37 | Object.keys(users).find((uid) => users[uid] === id); 38 | 39 | export const sendMessage = (messageType: string, users: string[], io: Server, payload?: Object) => { 40 | console.info(`Emitting event: ${messageType} to`, users); 41 | users.forEach((id) => (payload ? io.to(id)?.emit(messageType, payload) : io.to(id)?.emit(messageType))); 42 | }; 43 | 44 | var host = new Map() 45 | var roomState = new Map() 46 | var czars = new Map() 47 | 48 | export const startListeners = (io: Server, socket: Socket, socketUsers: user) => { 49 | console.info('Message received from socketId: ' + socket.id); 50 | socket.on('handshake', (callback: (uid: string, users: string[]) => void) => { 51 | console.info('Handshake received from: ' + socket.id); 52 | const reconnected = Object.values(socketUsers).includes(socket.id); 53 | if (reconnected) { 54 | console.info('This user has reconnected.'); 55 | const uid = getUidFromSocketID(socketUsers, socket.id); 56 | const users = Object.values(socketUsers); 57 | if (uid) { 58 | console.info('Sending callback for reconnect ...'); 59 | callback(uid, users); 60 | return; 61 | } 62 | } 63 | 64 | const uid = v4(); 65 | socketUsers[uid] = socket.id; 66 | const users = Object.values(socketUsers); 67 | console.info('Sending callback ...'); 68 | callback(uid, users); 69 | 70 | sendMessage( 71 | 'user_connected', 72 | users.filter((id) => id !== socket.id), 73 | io, 74 | users 75 | ); 76 | }); 77 | 78 | socket.on('disconnect', () => { 79 | console.info('Disconnect received from: ' + socket.id); 80 | 81 | const uid = getUidFromSocketID(socketUsers, socket.id); 82 | let newScore = new Map() 83 | 84 | if (uid) { 85 | io.sockets.adapter.rooms.forEach((room, roomName) => { 86 | if (roomName.includes("room")) { 87 | if (host.get(roomName) === socket.id) { 88 | room.forEach((socketId) => { 89 | io.sockets.sockets.get(socketId)?.leave(roomName) 90 | }); 91 | } 92 | if (roomState.get(roomName)) { 93 | let done = false 94 | room.forEach(player => { 95 | if (player != socket.id && !done && czars.get(roomName) === socket.id) { 96 | socket.to(player)?.emit("start_game", "czar", roomName) 97 | done = true 98 | } 99 | }) 100 | } 101 | } 102 | }); 103 | delete socketUsers[uid]; 104 | const users = Object.values(socketUsers); 105 | const rooms = { success: true, data: Object.fromEntries([...getRooms(io.sockets.adapter.rooms)]) }; 106 | sendMessage('user_disconnected', users, io, { uid: socket.id, rooms, users }); 107 | } 108 | }); 109 | 110 | socket.on('create_room', (value, callback) => { 111 | if (io.sockets.adapter.rooms.has("room_" + value)) return 112 | socket.join("room_" + value); 113 | host.set("room_" + value, socket.id) 114 | roomState.set("room_" + value, false) 115 | const response = { success: true, ...getCurrentRoom("room_" + value, getUsersInRoom(io, "room_" + value)) }; 116 | const response2 = { success: true, data: Object.fromEntries([...getRooms(io.sockets.adapter.rooms)]) }; 117 | 118 | io.emit("update_rooms", response2); 119 | callback(response); 120 | }); 121 | 122 | socket.on('delete_room', (value, callback) => { 123 | console.info(`User ${socket.id} want to delete a room ${value}`); 124 | io.sockets.in(value).socketsLeave(value) 125 | const response = { success: true, data: Object.fromEntries([...getRooms(io.sockets.adapter.rooms)]) }; 126 | io.emit("update_rooms", response); 127 | callback(response); 128 | }); 129 | 130 | socket.on('get_rooms', (callback) => { 131 | const response = { success: true, data: Object.fromEntries([...getRooms(io.sockets.adapter.rooms)]) }; 132 | io.emit("update_rooms", response); 133 | callback(response); 134 | }); 135 | 136 | socket.on('join_room', (value, callback) => { 137 | if (!io.sockets.adapter.rooms.has(value)) return 138 | console.info(`User ${socket.id} want to join room ${value}`); 139 | socket.join(value); 140 | const response = { success: true, data: Object.fromEntries([...getRooms(io.sockets.adapter.rooms)]) }; 141 | io.emit("update_rooms", response); 142 | callback(response); 143 | }); 144 | 145 | socket.on('leave_room', (roomName, inGame, callback) => { 146 | console.info(`User ${socket.id} want to leave room ${roomName}`); 147 | socket.leave(roomName); 148 | let roomPlayers = io.sockets.adapter.rooms.get(roomName)?.size 149 | if (roomPlayers && roomPlayers < 3 && inGame) { 150 | io.sockets.adapter.rooms.get(roomName)?.forEach((socketId) => { 151 | io.sockets.sockets.get(socketId)?.leave(roomName) 152 | }); 153 | } 154 | const response = { success: true, data: Object.fromEntries([...getRooms(io.sockets.adapter.rooms)]) }; 155 | callback(response); 156 | io.emit("update_rooms", response); 157 | }); 158 | 159 | socket.on('request_start_game', (roomName, callback) => { 160 | roomState.set(roomName, true) 161 | if (!io.sockets.adapter.rooms.has(roomName)) return 162 | let roomPlayers = io.sockets.adapter.rooms.get(roomName)?.size 163 | if (roomPlayers && roomPlayers < 3) { 164 | callback({ success: false }) 165 | return 166 | } 167 | let randomCzar = roomPlayers && Math.floor(Math.random() * roomPlayers) 168 | let index = 0 169 | let isCzar = "user" 170 | let userScore = new Map() 171 | 172 | io.sockets.adapter.rooms.get(roomName)?.forEach((socketId) => { 173 | userScore.set(socketId, 0) 174 | if (index === randomCzar) { 175 | socket.to(socketId)?.emit("start_game", "czar", roomName) 176 | czars.set(roomName, socketId) 177 | if (socketId === socket.id) 178 | isCzar = "czar" 179 | } 180 | else 181 | socket.to(socketId)?.emit("start_game", "user", roomName) 182 | index++ 183 | }) 184 | socket.nsp.to(roomName).emit("update_score", Array.from([...userScore])) 185 | 186 | const response = { success: true, isCzar }; 187 | callback(response); 188 | }); 189 | 190 | socket.on('update_turn', (roomName, newCzarId, callback) => { 191 | if (!io.sockets.adapter.rooms.has(roomName)) return 192 | let roomPlayers = io.sockets.adapter.rooms.get(roomName)?.size 193 | if (roomPlayers && roomPlayers < 3) { 194 | callback({ success: false }) 195 | return 196 | } 197 | let done = false 198 | io.sockets.adapter.rooms.get(roomName)?.forEach((socketId) => { 199 | if (newCzarId === "" && !done) { 200 | done = true 201 | socket.to(socketId)?.emit("start_game", "czar", roomName) 202 | czars.set(roomName, socketId) 203 | } 204 | else 205 | if (socketId === newCzarId) { 206 | socket.to(socketId)?.emit("start_game", "czar", roomName) 207 | czars.set(roomName, socketId) 208 | } 209 | else 210 | socket.to(socketId)?.emit("start_game", "user", roomName) 211 | }) 212 | callback({ success: true }); 213 | }); 214 | 215 | socket.on('request_update_score', (roomName: string, userScore: any) => { 216 | socket.nsp.to(roomName)?.emit("update_score", userScore) 217 | }) 218 | 219 | socket.on('request_reset_score', (roomName: string) => { 220 | socket.nsp.to(roomName)?.emit("reset_score") 221 | }) 222 | 223 | socket.on('send_black_card', (cardTitle, roomName, czarSocket, callback) => { 224 | console.info(`The card is ${cardTitle}`); 225 | io.sockets.adapter.rooms.get(roomName)?.forEach((socketId) => { 226 | socket.to(socketId)?.emit("get_black_card", cardTitle, czarSocket) 227 | }) 228 | callback({ success: true }); 229 | }); 230 | 231 | socket.on('send_white_card', (cZarSocketId, card, user, callback) => { 232 | console.info(`The white card is ${card}`); 233 | socket.to(cZarSocketId)?.emit("get_white_card", card, user) 234 | callback({ success: true }); 235 | }); 236 | 237 | socket.on('reset_white', (cZarSocketId, callback) => { 238 | console.info(`Reset White Cards`); 239 | socket.to(cZarSocketId)?.emit("reset_white_card") 240 | callback({ success: true }); 241 | }); 242 | 243 | socket.on('reset_turn', (roomName, hasPlayed, callback) => { 244 | io.sockets.adapter.rooms.get(roomName)?.forEach((socketId) => { 245 | socket.to(socketId)?.emit("new_turn", hasPlayed) 246 | }) 247 | callback({ success: true }); 248 | }); 249 | 250 | }; 251 | -------------------------------------------------------------------------------- /server/test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, beforeEach, describe, expect, it, test, vi } from "vitest"; 2 | import { getCurrentRoom, getRooms, getUidFromSocketID, sendMessage, startListeners } from "../utils/utils"; 3 | import { io as Client } from "socket.io-client"; 4 | import { Server } from "socket.io"; 5 | import http from 'http'; 6 | import dotenv from 'dotenv'; 7 | import { user } from "../types/user"; 8 | 9 | dotenv.config(); 10 | const port = process.env.PORT; 11 | let io: any, serverSocket: any, clientSocket: any; 12 | 13 | beforeAll((done: any) => { 14 | const httpServer = http.createServer(); 15 | io = new Server(httpServer); 16 | httpServer.listen(() => { 17 | clientSocket = Client(`http://localhost:${port}`); 18 | io.on("connection", (socket: any) => { 19 | serverSocket = socket; 20 | }); 21 | clientSocket?.on("connect", done); 22 | }); 23 | }); 24 | 25 | afterAll(() => { 26 | io.close(); 27 | clientSocket?.close(); 28 | }); 29 | 30 | 31 | describe('Server utils', () => { 32 | const user1 = 'user1'; 33 | const user2 = 'user2'; 34 | const user3 = 'user3'; 35 | it('should get empty length on no rooms', () => { 36 | const roomId1 = 'a'; 37 | const roomId2 = 'b'; 38 | const availableRooms = new Map>([ 39 | [roomId1, new Set([user1, user2])], 40 | [roomId2, new Set([user3])] 41 | ]); 42 | expect(getRooms(availableRooms)).toHaveLength(0) 43 | }) 44 | 45 | it('should get length of rooms', () => { 46 | const roomId1 = 'room_a'; 47 | const roomId2 = 'room_b'; 48 | const availableRooms = new Map>([ 49 | [roomId1, new Set([user1, user2])], 50 | [roomId2, new Set([user3])] 51 | ]); 52 | expect(getRooms(availableRooms)).toHaveLength(2) 53 | }) 54 | 55 | it('should get empty or room length', () => { 56 | const roomId1 = 'room_a'; 57 | const failedRoom = 'failed_test'; 58 | const users = new Set(); 59 | expect(getCurrentRoom(failedRoom, users)).toStrictEqual( 60 | { 61 | "data": { 62 | "roomName": "failed_test", 63 | "users": "[]", 64 | }, 65 | } 66 | 67 | ) 68 | expect(getCurrentRoom(failedRoom, undefined)).toStrictEqual( 69 | { 70 | "data": { 71 | "roomName": "failed_test", 72 | "users": "[]", 73 | }, 74 | } 75 | 76 | ) 77 | users.add("1"); 78 | users.add("2"); 79 | users.add("3"); 80 | expect(getCurrentRoom(failedRoom, users)).toStrictEqual( 81 | { 82 | "data": { 83 | "roomName": "failed_test", 84 | "users": '["1","2","3"]', 85 | }, 86 | } 87 | ) 88 | }) 89 | 90 | it('should getUidFromSocketID', () => { 91 | const roomId1 = 'room_a'; 92 | const failedRoom = 'failed_test'; 93 | const users = new Set(); 94 | expect(getCurrentRoom(failedRoom, users)).toStrictEqual( 95 | { 96 | "data": { 97 | "roomName": "failed_test", 98 | "users": "[]", 99 | }, 100 | } 101 | 102 | ) 103 | expect(getCurrentRoom(failedRoom, undefined)).toStrictEqual( 104 | { 105 | "data": { 106 | "roomName": "failed_test", 107 | "users": "[]", 108 | }, 109 | } 110 | 111 | ) 112 | users.add("1"); 113 | users.add("2"); 114 | users.add("3"); 115 | expect(getCurrentRoom(failedRoom, users)).toStrictEqual( 116 | { 117 | "data": { 118 | "roomName": "failed_test", 119 | "users": '["1","2","3"]', 120 | }, 121 | } 122 | ) 123 | }) 124 | 125 | it('should return the correct uid', () => { 126 | const users = { 'uid1': 'socket1', 'uid2': 'socket2', 'uid3': 'socket3' }; 127 | const socketId = 'socket2'; 128 | expect(getUidFromSocketID(users, socketId)).toEqual('uid2'); 129 | }); 130 | 131 | it('should return undefined if the socket id is not found', () => { 132 | const users = { 'uid1': 'socket1', 'uid2': 'socket2', 'uid3': 'socket3' }; 133 | const socketId = 'socket4'; 134 | expect(getUidFromSocketID(users, socketId)).toBeUndefined(); 135 | }); 136 | 137 | it('should emit the event to all users', () => { 138 | const users = ['socket1', 'socket2', 'socket3']; 139 | const messageType = 'testEvent'; 140 | const io2 = { 141 | ...io, 142 | to: vi.fn().mockReturnThis(), 143 | emit: vi.fn() 144 | }; 145 | sendMessage(messageType, users, io2); 146 | expect(io2.to).toHaveBeenCalledTimes(3); 147 | expect(io2.to).toHaveBeenCalledWith('socket1'); 148 | expect(io2.to).toHaveBeenCalledWith('socket2'); 149 | expect(io2.to).toHaveBeenCalledWith('socket3'); 150 | expect(io2.emit).toHaveBeenCalledTimes(3); 151 | expect(io2.to('socket1').emit).toHaveBeenCalledWith(messageType); 152 | expect(io2.to('socket2').emit).toHaveBeenCalledWith(messageType); 153 | expect(io2.to('socket3').emit).toHaveBeenCalledWith(messageType); 154 | }); 155 | 156 | it('should emit the event with the payload to all users', () => { 157 | const users = ['socket1', 'socket2', 'socket3']; 158 | const messageType = 'testEvent'; 159 | const payload = { data: 'testData' }; 160 | const io2 = { 161 | ...io, 162 | to: vi.fn().mockReturnThis(), 163 | emit: vi.fn() 164 | }; 165 | sendMessage(messageType, users, io2, payload); 166 | expect(io2.to).toHaveBeenCalledTimes(3); 167 | expect(io2.to).toHaveBeenCalledWith('socket1'); 168 | expect(io2.to).toHaveBeenCalledWith('socket2'); 169 | expect(io2.to).toHaveBeenCalledWith('socket3'); 170 | expect(io2.emit).toHaveBeenCalledTimes(3); 171 | expect(io2.to('socket1').emit).toHaveBeenCalledWith(messageType, payload); 172 | expect(io2.to('socket2').emit).toHaveBeenCalledWith(messageType, payload); 173 | expect(io2.to('socket3').emit).toHaveBeenCalledWith(messageType, payload); 174 | }) 175 | }); 176 | 177 | describe('startListeners', () => { 178 | let ioMock = { 179 | ...io, 180 | 181 | }; 182 | 183 | let socketMock = { 184 | ...serverSocket, 185 | id: 'socket-id', 186 | 187 | }; 188 | 189 | let socketUsersMock: user = { 190 | 'user1': 'Alice', 191 | }; 192 | 193 | beforeEach(() => { 194 | vi.clearAllMocks(); 195 | ioMock = { 196 | ...io, 197 | on: vi.fn(), 198 | emit: vi.fn(), 199 | join: vi.fn(), 200 | leave: vi.fn(), 201 | to: vi.fn(), 202 | nsp: { 203 | to: vi.fn(), 204 | }, 205 | }; 206 | 207 | socketMock = { 208 | ...serverSocket, 209 | id: 'socket-id', 210 | on: vi.fn(), 211 | emit: vi.fn(), 212 | join: vi.fn(), 213 | leave: vi.fn(), 214 | to: vi.fn(), 215 | nsp: { 216 | to: vi.fn(), 217 | }, 218 | }; 219 | 220 | socketUsersMock = { 221 | 'user1': 'Alice', 222 | }; 223 | }); 224 | 225 | it('should set up handshake and disconnect listeners on the socket', () => { 226 | startListeners(ioMock, socketMock, socketUsersMock); 227 | expect(socketMock.on).toHaveBeenCalledWith('handshake', expect.any(Function)); 228 | expect(socketMock.on).toHaveBeenCalledWith('disconnect', expect.any(Function)); 229 | expect(socketMock.on).toHaveBeenCalledWith('create_room', expect.any(Function)); 230 | expect(socketMock.on).toHaveBeenCalledWith('delete_room', expect.any(Function)); 231 | expect(socketMock.on).toHaveBeenCalledWith('get_rooms', expect.any(Function)); 232 | expect(socketMock.on).toHaveBeenCalledWith('join_room', expect.any(Function)); 233 | expect(socketMock.on).toHaveBeenCalledWith('leave_room', expect.any(Function)); 234 | expect(socketMock.on).toHaveBeenCalledWith('request_start_game', expect.any(Function)); 235 | expect(socketMock.on).toHaveBeenCalledWith('update_turn', expect.any(Function)); 236 | expect(socketMock.on).toHaveBeenCalledWith('request_update_score', expect.any(Function)); 237 | expect(socketMock.on).toHaveBeenCalledWith('request_reset_score', expect.any(Function)); 238 | expect(socketMock.on).toHaveBeenCalledWith('send_black_card', expect.any(Function)); 239 | expect(socketMock.on).toHaveBeenCalledWith('send_white_card', expect.any(Function)); 240 | expect(socketMock.on).toHaveBeenCalledWith('reset_white', expect.any(Function)); 241 | expect(socketMock.on).toHaveBeenCalledWith('reset_turn', expect.any(Function)); 242 | expect(socketMock.on).toHaveBeenCalledTimes(15); 243 | }); 244 | 245 | it('should handle handshake event for reconnected socket', () => { 246 | const uid = 'user1'; 247 | socketUsersMock[uid] = socketMock.id; 248 | const callbackMock = vi.fn(); 249 | startListeners(ioMock, socketMock, socketUsersMock); 250 | 251 | // Trigger handshake event 252 | const [eventName, eventHandler] = socketMock.on.mock.calls[0]; 253 | eventHandler(callbackMock); 254 | 255 | expect(callbackMock).toHaveBeenCalledTimes(1); 256 | expect(callbackMock).toHaveBeenCalledWith(uid, [socketMock.id]); 257 | expect(socketMock.emit).toHaveBeenCalledTimes(0); 258 | }); 259 | 260 | it('should handle disconnect event', () => { 261 | const uid = 'user1'; 262 | socketUsersMock[uid] = socketMock.id; 263 | startListeners(ioMock, socketMock, socketUsersMock); 264 | 265 | const [eventName, eventHandler] = socketMock.on.mock.calls[1]; 266 | eventHandler(); 267 | expect(socketUsersMock).not.toHaveProperty(uid); 268 | expect(socketMock.emit).toHaveBeenCalledTimes(0); 269 | }); 270 | 271 | it('should handle create_room event', () => { 272 | const uid = 'user1'; 273 | socketUsersMock[uid] = socketMock.id; 274 | startListeners(ioMock, socketMock, socketUsersMock); 275 | const callbackMock = vi.fn(); 276 | const roomName = 'test-room'; 277 | const [eventName, eventHandler] = socketMock.on.mock.calls[2]; 278 | eventHandler(roomName, callbackMock); 279 | expect(socketMock.join).toHaveBeenCalledTimes(1); 280 | expect(socketMock.join).toHaveBeenCalledWith('room_' + roomName); 281 | expect(callbackMock).toHaveBeenCalledTimes(1); 282 | expect(callbackMock).toHaveBeenCalledWith({ 283 | "data": { 284 | "roomName": "room_test-room", 285 | "users": "[]", 286 | }, 287 | "success": true, 288 | }); 289 | }); 290 | 291 | it('should handle delete_room event', () => { 292 | const uid = 'user1'; 293 | socketUsersMock[uid] = socketMock.id; 294 | startListeners(ioMock, socketMock, socketUsersMock); 295 | const callbackMock = vi.fn(); 296 | const roomName = 'delete_room'; 297 | const [eventName, eventHandler] = socketMock.on.mock.calls[3]; 298 | eventHandler(roomName, callbackMock); 299 | expect(callbackMock).toHaveBeenCalledTimes(1); 300 | expect(callbackMock).toHaveBeenCalledWith({ 301 | "data": {}, 302 | "success": true, 303 | }); 304 | }); 305 | 306 | it('should handle get_rooms event', () => { 307 | const uid = 'user1'; 308 | socketUsersMock[uid] = socketMock.id; 309 | startListeners(ioMock, socketMock, socketUsersMock); 310 | const callbackMock = vi.fn(); 311 | const [eventName, eventHandler] = socketMock.on.mock.calls[5]; 312 | eventHandler(callbackMock); 313 | expect(callbackMock).toHaveBeenCalledTimes(0); 314 | }); 315 | 316 | it('should handle join_room/leave_room event', () => { 317 | const uid = 'user1'; 318 | socketUsersMock[uid] = socketMock.id; 319 | startListeners(ioMock, socketMock, socketUsersMock); 320 | const callbackMock = vi.fn(); 321 | const roomName = 'test-room'; 322 | const [eventName, eventHandler] = socketMock.on.mock.calls[6]; 323 | const [eventCreate, createHandler] = socketMock.on.mock.calls[2]; 324 | const [eventLeave, leaveHandler] = socketMock.on.mock.calls[7]; 325 | createHandler(roomName, callbackMock); 326 | expect(socketMock.join).toHaveBeenCalledTimes(1); 327 | expect(socketMock.join).toHaveBeenCalledWith('room_' + roomName); 328 | expect(callbackMock).toHaveBeenCalledWith({ 329 | "data": { 330 | "roomName": "room_test-room", 331 | "users": "[]", 332 | }, 333 | "success": true, 334 | }); 335 | socketUsersMock[uid] = socketMock.id; 336 | eventHandler(roomName, callbackMock, callbackMock); 337 | expect(socketMock.join).toHaveBeenCalledTimes(1); 338 | expect(callbackMock).toHaveBeenCalledTimes(2); 339 | leaveHandler(roomName, callbackMock) 340 | expect(callbackMock).toHaveBeenCalledWith({ 341 | "data": {}, 342 | "success": true, 343 | }); 344 | }); 345 | 346 | 347 | it('should trigger game actions', () => { 348 | console.log("THIS") 349 | startListeners(ioMock, socketMock, socketUsersMock); 350 | const uid = 'user1'; 351 | socketUsersMock[uid] = socketMock.id; 352 | const socketIds = ['socket1', 'socket2', 'socket3', 'socket4']; 353 | const callbackMock = vi.fn(); 354 | const roomName = 'room_test-room'; 355 | ioMock.sockets.adapter.rooms = new Map([[roomName, new Set(socketIds)]]); 356 | const [create, createRoomHandler] = socketMock.on.mock.calls[2]; 357 | const [join, joinHandler] = socketMock.on.mock.calls[5]; 358 | const [start, startHandler] = socketMock.on.mock.calls[8]; 359 | createRoomHandler(roomName, callbackMock, callbackMock,); 360 | joinHandler(roomName, callbackMock, callbackMock); 361 | joinHandler(roomName, callbackMock, callbackMock); 362 | joinHandler(roomName, callbackMock, callbackMock); 363 | startHandler(roomName, callbackMock, callbackMock) 364 | expect(ioMock.to).toHaveBeenCalledTimes(0); 365 | 366 | expect(ioMock.nsp.to).toHaveBeenCalledTimes(0); 367 | 368 | expect(callbackMock).toHaveBeenCalledTimes(5); 369 | expect(callbackMock).toHaveBeenCalledWith({ 370 | success: true, 371 | data: { 372 | "room_test-room": [ 373 | "socket1", 374 | "socket2", 375 | "socket3", 376 | "socket4", 377 | ], 378 | }, 379 | }); 380 | }); 381 | 382 | it('should return a failure response if the room does not exist', () => { 383 | startListeners(ioMock, socketMock, socketUsersMock); 384 | const callbackMock = vi.fn(); 385 | const roomName = 'test-room'; 386 | const [start, startHandler] = socketMock.on.mock.calls[8]; 387 | ioMock.sockets.adapter.rooms = new Map(); 388 | startHandler(roomName, callbackMock, callbackMock) 389 | expect(ioMock.to).not.toHaveBeenCalled(); 390 | expect(ioMock.nsp.to).not.toHaveBeenCalled(); 391 | expect(callbackMock).toHaveBeenCalledTimes(0); 392 | }); 393 | 394 | it('should return a failure response if the room has less than 3 players', () => { 395 | startListeners(ioMock, socketMock, socketUsersMock); 396 | const socketIds = ['socket1', 'socket2']; 397 | const callbackMock = vi.fn(); 398 | const roomName = 'test-room'; 399 | const [start, startHandler] = socketMock.on.mock.calls[8]; 400 | ioMock.sockets.adapter.rooms = new Map([[roomName, new Set(socketIds)]]); 401 | startHandler(roomName, callbackMock, callbackMock) 402 | expect(ioMock.to).not.toHaveBeenCalled(); 403 | expect(ioMock.nsp.to).not.toHaveBeenCalled(); 404 | expect(callbackMock).toHaveBeenCalledTimes(1); 405 | expect(callbackMock).toHaveBeenCalledWith({ 406 | success: false, 407 | }); 408 | }); 409 | 410 | it('should update score', () => { 411 | startListeners(ioMock, socketMock, socketUsersMock); 412 | const socketIds = ['socket1', 'socket2']; 413 | const callbackMock = vi.fn(); 414 | const roomName = 'test-room'; 415 | const [eventName, eventHandler] = socketMock.on.mock.calls[2]; 416 | eventHandler(roomName, callbackMock); 417 | expect(socketMock.join).toHaveBeenCalledTimes(1); 418 | expect(socketMock.join).toHaveBeenCalledWith('room_' + roomName); 419 | expect(callbackMock).toHaveBeenCalledTimes(1); 420 | const [start, updateHandler] = socketMock.on.mock.calls[9]; 421 | ioMock.sockets.adapter.rooms = new Map([[roomName, new Set(socketIds)]]); 422 | updateHandler('room_' + roomName, new Map(), callbackMock, callbackMock) 423 | expect(ioMock.to).not.toHaveBeenCalled(); 424 | expect(ioMock.nsp.to).not.toHaveBeenCalled(); 425 | expect(callbackMock).toHaveBeenCalledTimes(1); 426 | }); 427 | 428 | it('should reset score', () => { 429 | startListeners(ioMock, socketMock, socketUsersMock); 430 | const socketIds = ['socket1', 'socket2']; 431 | const callbackMock = vi.fn(); 432 | const roomName = 'test-room'; 433 | const [eventName, eventHandler] = socketMock.on.mock.calls[2]; 434 | eventHandler(roomName, callbackMock); 435 | expect(socketMock.join).toHaveBeenCalledTimes(1); 436 | expect(socketMock.join).toHaveBeenCalledWith('room_' + roomName); 437 | expect(callbackMock).toHaveBeenCalledTimes(1); 438 | const [start, updateHandler] = socketMock.on.mock.calls[10]; 439 | ioMock.sockets.adapter.rooms = new Map([[roomName, new Set(socketIds)]]); 440 | updateHandler('room_' + roomName, callbackMock, callbackMock) 441 | expect(ioMock.to).not.toHaveBeenCalled(); 442 | expect(ioMock.nsp.to).not.toHaveBeenCalled(); 443 | expect(callbackMock).toHaveBeenCalledTimes(1); 444 | }); 445 | 446 | it('should send black card', () => { 447 | startListeners(ioMock, socketMock, socketUsersMock); 448 | const socketIds = ['socket1', 'socket2']; 449 | const callbackMock = vi.fn(); 450 | const roomName = 'test-room'; 451 | const [eventName, eventHandler] = socketMock.on.mock.calls[2]; 452 | eventHandler(roomName, callbackMock); 453 | expect(socketMock.join).toHaveBeenCalledTimes(1); 454 | expect(socketMock.join).toHaveBeenCalledWith('room_' + roomName); 455 | expect(callbackMock).toHaveBeenCalledTimes(1); 456 | const [start, updateHandler] = socketMock.on.mock.calls[11]; 457 | ioMock.sockets.adapter.rooms = new Map([[roomName, new Set(socketIds)]]); 458 | updateHandler("test-card", roomName, 'socket1', callbackMock, callbackMock) 459 | expect(ioMock.to).not.toHaveBeenCalled(); 460 | expect(ioMock.nsp.to).not.toHaveBeenCalled(); 461 | expect(callbackMock).toHaveBeenCalledTimes(2); 462 | }); 463 | 464 | it('should send white cards', () => { 465 | startListeners(ioMock, socketMock, socketUsersMock); 466 | const socketIds = ['socket1', 'socket2']; 467 | const callbackMock = vi.fn(); 468 | const roomName = 'test-room'; 469 | const [eventName, eventHandler] = socketMock.on.mock.calls[2]; 470 | eventHandler(roomName, callbackMock); 471 | expect(socketMock.join).toHaveBeenCalledTimes(1); 472 | expect(socketMock.join).toHaveBeenCalledWith('room_' + roomName); 473 | expect(callbackMock).toHaveBeenCalledTimes(1); 474 | const [start, updateHandler] = socketMock.on.mock.calls[12]; 475 | ioMock.sockets.adapter.rooms = new Map([[roomName, new Set(socketIds)]]); 476 | updateHandler("test-card", roomName, 'socket1', callbackMock, callbackMock) 477 | expect(ioMock.to).not.toHaveBeenCalled(); 478 | expect(ioMock.nsp.to).not.toHaveBeenCalled(); 479 | expect(callbackMock).toHaveBeenCalledTimes(2); 480 | }); 481 | 482 | it('should reset white cards', () => { 483 | startListeners(ioMock, socketMock, socketUsersMock); 484 | const socketIds = ['socket1', 'socket2']; 485 | const callbackMock = vi.fn(); 486 | const roomName = 'test-room'; 487 | const [eventName, eventHandler] = socketMock.on.mock.calls[2]; 488 | eventHandler(roomName, callbackMock); 489 | expect(socketMock.join).toHaveBeenCalledTimes(1); 490 | expect(socketMock.join).toHaveBeenCalledWith('room_' + roomName); 491 | expect(callbackMock).toHaveBeenCalledTimes(1); 492 | const [start, updateHandler] = socketMock.on.mock.calls[13]; 493 | ioMock.sockets.adapter.rooms = new Map([[roomName, new Set(socketIds)]]); 494 | updateHandler('socket1', callbackMock, callbackMock) 495 | expect(ioMock.to).not.toHaveBeenCalled(); 496 | expect(ioMock.nsp.to).not.toHaveBeenCalled(); 497 | expect(callbackMock).toHaveBeenCalledTimes(2); 498 | }); 499 | 500 | it('should reset turn', () => { 501 | startListeners(ioMock, socketMock, socketUsersMock); 502 | const socketIds = ['socket1', 'socket2']; 503 | const callbackMock = vi.fn(); 504 | const roomName = 'test-room'; 505 | const [eventName, eventHandler] = socketMock.on.mock.calls[2]; 506 | eventHandler(roomName, callbackMock); 507 | expect(socketMock.join).toHaveBeenCalledTimes(1); 508 | expect(socketMock.join).toHaveBeenCalledWith('room_' + roomName); 509 | expect(callbackMock).toHaveBeenCalledTimes(1); 510 | const [start, updateHandler] = socketMock.on.mock.calls[14]; 511 | ioMock.sockets.adapter.rooms = new Map([[roomName, new Set(socketIds)]]); 512 | updateHandler(roomName, true, callbackMock, callbackMock) 513 | expect(ioMock.to).not.toHaveBeenCalled(); 514 | expect(ioMock.nsp.to).not.toHaveBeenCalled(); 515 | expect(callbackMock).toHaveBeenCalledTimes(2); 516 | expect(callbackMock).toHaveBeenCalledWith({ 517 | success: true, 518 | }); 519 | }); 520 | 521 | }) 522 | -------------------------------------------------------------------------------- /client/src/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | --------------------------------------------------------------------------------