├── 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 | navigate("/")}>Go to homepage
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 &&
23 | export const jazzImg = !isMobile &&
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 | } type="primary" onClick={() => navigate(-1)}>Back
16 |
17 | setRoomName(value.target.value)} />
18 |
19 | createRoom(socket, roomName, navigate)}>Create Lobby
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 | } type="primary" onClick={() => navigate("/")}>Back
24 | You are: {socket?.id}
25 | } type="primary" onClick={() => reloadPage()}>Refresh
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 |
} type="primary" onClick={() => navigate(-1)}>Back
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 | navigate("/lobby")}>Join room
48 |
49 |
50 | navigate("/create")}>Create room
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 | } type="primary" >Back
37 | :
38 | } type="primary" onClick={() => {
39 | leaveRoom(socket, state?.roomName, false, "", navigate)
40 | }}>Back
41 | }
42 |
43 |
44 |
45 |
46 |
47 | {rooms[state?.roomName] && state?.type === "admin" &&
48 | startGame(socket, state?.roomName, navigate)}
50 | style={{ width: 200 }}
51 | type="primary"
52 | size="large"
53 | >
54 | Start Game
55 |
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 | onConfirm(socket, roomName, score, selectedUser, false)}
62 | disabled={!hasPicked || selected === ""}
63 | >
64 | Confirm
65 |
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 | drawNew(socket, czarSocketId, playerHand, selected, white, setSelected, setPlayerHand, setHasPlayed)}>Submit Response
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 | navigate("/lobby")}>Join room
62 | navigate("/create")}>Create room
63 | navigate("/rules")}>Rules
64 |
65 |
66 | {berryImg}
67 |
68 |
69 | {jazzImg}
70 |
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 | {
61 | leaveRoom(socket, state?.roomName, true, lobbyType, navigate)
62 | }}
63 | >
64 | Go back to Homepage
65 |
66 |
67 |
68 |
69 | :
70 | players === undefined || players.length < 3 ?
71 | navigate("/")}>
76 | Go to the homepage
77 |
78 | }
79 | />
80 | :
81 |
82 |
83 | setModal(true)} icon={ }> Back
84 | You are: {socket?.id}
85 |
86 | } type="primary">Scores
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 |
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 | ➤ About The Project
22 | ➤ Overview
23 | ➤ Getting Started
24 | ➤ Authors
25 |
26 |
27 |
28 | 
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 | 
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 | 
76 |
77 | 
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 | 
105 |
106 |
107 |
108 | :scroll: Authors
109 |
110 | Emanuele Dall'Ara
111 |
112 | [](https://github.com/LeleDallas)
113 | [](https://www.linkedin.com/in/emanuele-dall-ara-40b3311a7/)
114 |
115 | Nicholas Ricci
116 |
117 | [](https://www.github.com/Piccio98)
118 | [](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 |
--------------------------------------------------------------------------------