Merge pull request #9 from Wenszel/dev

added cypress and handled end of the game
This commit is contained in:
Wiktor Smaga 2023-12-19 11:42:53 +01:00 committed by GitHub
commit 832102c9f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 2587 additions and 69 deletions

View File

@ -1,63 +1,46 @@
# <center>Online Multiplayer Ludo Game</center>
\>\> [Play Online here](www.github.com/wenszel/mern-ludo) <<
\>\> [Watch YouTube Video here](www.github.com/wenszel/mern-ludo) <<
## Table of content
- [About](#about)
- [Architecture](#architecture)
- [Key Features and Challenges](#key-features-and-challenges)
- [Tech Stack](#tech-stack)
- [Installation](#installation)
- [Screenshots](#screenshots)
## About
Ludo Online is a multiplayer web-based implementation of the classic board game Ludo, built using the MERN stack and integrated with SocketIO for real-time communication.
\>\> Play Online here <<
\>\> [Watch YouTube Video here](https://youtu.be/mGMnH9Nvsyw) <<
## Architecture
![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/architecture.png?raw=true)
## Tech Stack
Frontend:
![JavaScript](https://img.shields.io/badge/javascript-%23323330.svg?style=for-the-badge&logo=javascript&logoColor=%23F7DF1E) ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) ![React Router](https://img.shields.io/badge/React_Router-CA4245?style=for-the-badge&logo=react-router&logoColor=white)
![CSS3](https://img.shields.io/badge/css3-%231572B6.svg?style=for-the-badge&logo=css3&logoColor=white) ![HTML5](https://img.shields.io/badge/html5-%23E34F26.svg?style=for-the-badge&logo=html5&logoColor=white) ![MUI](https://img.shields.io/badge/MUI-%230081CB.svg?style=for-the-badge&logo=mui&logoColor=white)
Backend:
![MongoDB](https://img.shields.io/badge/MongoDB-%234ea94b.svg?style=for-the-badge&logo=mongodb&logoColor=white) ![Express.js](https://img.shields.io/badge/express.js-%23404d59.svg?style=for-the-badge&logo=express&logoColor=%2361DAFB) ![Socket.io](https://img.shields.io/badge/Socket.io-black?style=for-the-badge&logo=socket.io&badgeColor=010101) ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white)
Tests:
![Mocha](https://img.shields.io/badge/-mocha-%238D6748?style=for-the-badge&logo=mocha&logoColor=white) ![Jest](https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white)
![cypress](https://img.shields.io/badge/-cypress-%23E5E5E5?style=for-the-badge&logo=cypress&logoColor=058a5e) ![Mocha](https://img.shields.io/badge/-mocha-%238D6748?style=for-the-badge&logo=mocha&logoColor=white) ![Jest](https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white)
Tools:
![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) ![AWS](https://img.shields.io/badge/AWS-%23FF9900.svg?style=for-the-badge&logo=amazon-aws&logoColor=white) ![CircleCI](https://img.shields.io/badge/circle%20ci-%23161616.svg?style=for-the-badge&logo=circleci&logoColor=white) ![Git](https://img.shields.io/badge/git-%23F05033.svg?style=for-the-badge&logo=git&logoColor=white) ![Jira](https://img.shields.io/badge/jira-%230A0FFF.svg?style=for-the-badge&logo=jira&logoColor=white)
## Key Features and Challenges
- Maintained session consistency with **Express Session** and **MongoDB**.
- Enabled real-time communication via **WebSocket** and **SocketIO**.
- Ensured code reliability with testing using **Mocha**, **Chai**, and **Jest**.
- Maintained code reliability by implementing unit and integration tests using **Mocha**, **Chai**, and **Jest**.
- Hosted in a **Docker** container on **AWS EC2**.
- Established CI/CD using **CircleCI**.
- Implemented E2E tests utilizing **Cypress**, addressing challenges related to [testing collaboration](https://docs.cypress.io/guides/references/trade-offs#Multiple-browsers-open-at-the-same-time) and canvas functionality in the application.
## Installation
1. Download this repository
2. Generate your own [mongoDB atlas](https://www.mongodb.com) credential URL. It should looks like this:
```
mongodb+srv://madmin:<password>@clustername.mongodb.net/<dbname>?retryWrites=true&w=majority
```
3. Add this URL to the /backend/credentials.js file
4. Perform these commands in the main directory:
@ -72,3 +55,7 @@ node server.js
## Screenshots
![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/readme1.png?raw=true)
![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/lobby.png?raw=true)
![Interface](https://github.com/Wenszel/mern-ludo/blob/main/src/images/winner.png?raw=true)

View File

@ -6,6 +6,7 @@ module.exports = function (mongoose) {
.connect(CONNECTION_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
dbName: 'test',
})
.then(() => {
console.log('MongoDB Connected…');

View File

@ -1,5 +1,5 @@
const { getRoom, updateRoom } = require('../services/roomService');
const { sendToPlayersRolledNumber } = require('../socket/emits');
const { sendToPlayersRolledNumber, sendWinner } = require('../socket/emits');
const { rollDice, isMoveValid } = require('./handlersFunctions');
module.exports = socket => {
@ -7,12 +7,18 @@ module.exports = socket => {
const handleMovePawn = async pawnId => {
const room = await getRoom(req.session.roomId);
if (room.winner) return;
const pawn = room.getPawn(pawnId);
if (isMoveValid(req.session, pawn, room)) {
const newPositionOfMovedPawn = pawn.getPositionAfterMove(room.rolledNumber);
room.changePositionOfPawn(pawn, newPositionOfMovedPawn);
room.beatPawns(newPositionOfMovedPawn, req.session.color);
room.changeMovingPlayer();
const winner = room.getWinner();
if (winner) {
room.endGame(winner);
sendWinner(room._id.toString(), winner);
}
await updateRoom(room);
}
};

View File

@ -1,4 +1,4 @@
const { sendToPlayersRolledNumber } = require('../socket/emits');
const { sendToPlayersRolledNumber, sendWinner } = require('../socket/emits');
const rollDice = () => {
const rolledNumber = Math.ceil(Math.random() * 6);
@ -8,6 +8,7 @@ const rollDice = () => {
const makeRandomMove = async roomId => {
const { updateRoom, getRoom } = require('../services/roomService');
const room = await getRoom(roomId);
if (room.winner) return;
if (room.rolledNumber === null) {
room.rolledNumber = rollDice();
sendToPlayersRolledNumber(room._id.toString(), room.rolledNumber);
@ -19,6 +20,11 @@ const makeRandomMove = async roomId => {
room.movePawn(randomPawn);
}
room.changeMovingPlayer();
const winner = room.getWinner();
if (winner) {
room.endGame(winner);
sendWinner(room._id.toString(), winner);
}
await updateRoom(room);
};

View File

@ -12,6 +12,14 @@ module.exports = socket => {
addPlayerToExistingRoom(room, data);
};
const handleExit = async () => {
req.session.reload(err => {
if (err) return socket.disconnect();
req.session.destroy();
socket.emit('redirect');
});
};
const handleReady = async () => {
const room = await getRoom(req.session.roomId);
room.getPlayer(req.session.playerId).changeReadyStatus();
@ -45,4 +53,5 @@ module.exports = socket => {
socket.on('player:login', handleLogin);
socket.on('player:ready', handleReady);
socket.on('player:exit', handleExit);
};

View File

@ -1,5 +1,5 @@
const { getRooms, getRoom, updateRoom, createNewRoom } = require('../services/roomService');
const { sendToOnePlayerRooms, sendToOnePlayerData } = require('../socket/emits');
const { sendToOnePlayerRooms, sendToOnePlayerData, sendWinner } = require('../socket/emits');
module.exports = socket => {
const req = socket.request;
@ -13,6 +13,7 @@ module.exports = socket => {
await updateRoom(room);
}
sendToOnePlayerData(socket.id, room);
if (room.winner) sendWinner(socket.id, room.winner);
};
const handleGetAllRooms = async () => {
@ -21,7 +22,7 @@ module.exports = socket => {
};
const handleCreateRoom = async data => {
createNewRoom(data);
await createNewRoom(data);
sendToOnePlayerRooms(socket.id, await getRooms());
};

View File

@ -15,6 +15,7 @@ const RoomSchema = new mongoose.Schema({
timeoutID: Number,
rolledNumber: Number,
players: [PlayerSchema],
winner: { type: String, default: null },
pawns: {
type: [PawnSchema],
default: () => {
@ -45,6 +46,7 @@ RoomSchema.methods.beatPawns = function (position, attackingPawnColor) {
};
RoomSchema.methods.changeMovingPlayer = function () {
if (this.winner) return;
const playerIndex = this.players.findIndex(player => player.nowMoving === true);
this.players[playerIndex].nowMoving = false;
if (playerIndex + 1 === this.players.length) {
@ -87,6 +89,31 @@ RoomSchema.methods.startGame = function () {
this.timeoutID = setTimeout(makeRandomMove, 15000, this._id.toString());
};
RoomSchema.methods.endGame = function (winner) {
this.timeoutID = null;
this.rolledNumber = null;
this.nextMoveTime = null;
this.players.map(player => (player.nowMoving = false));
this.winner = winner;
this.save();
};
RoomSchema.methods.getWinner = function () {
if (this.pawns.filter(pawn => pawn.color === 'red' && pawn.position === 73).length === 4) {
return 'red';
}
if (this.pawns.filter(pawn => pawn.color === 'blue' && pawn.position === 79).length === 4) {
return 'blue';
}
if (this.pawns.filter(pawn => pawn.color === 'green' && pawn.position === 85).length === 4) {
return 'green';
}
if (this.pawns.filter(pawn => pawn.color === 'yellow' && pawn.position === 91).length === 4) {
return 'yellow';
}
return null;
};
RoomSchema.methods.isFull = function () {
if (this.players.length === 4) {
this.full = true;

View File

@ -17,9 +17,9 @@ const getJoinableRoom = async () => {
return await Room.findOne({ full: false, started: false }).exec();
};
const createNewRoom = data => {
const createNewRoom = async data => {
const room = new Room(data);
room.save();
await room.save();
return room;
};

View File

@ -16,4 +16,14 @@ const sendToOnePlayerRooms = (id, rooms) => {
socketManager.getIO().to(id).emit('room:rooms', JSON.stringify(rooms));
};
module.exports = { sendToPlayersData, sendToPlayersRolledNumber, sendToOnePlayerData, sendToOnePlayerRooms };
const sendWinner = (id, winner) => {
socketManager.getIO().to(id).emit('game:winner', winner);
};
module.exports = {
sendToPlayersData,
sendToPlayersRolledNumber,
sendToOnePlayerData,
sendToOnePlayerRooms,
sendWinner,
};

9
cypress.config.js Normal file
View File

@ -0,0 +1,9 @@
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
});

29
cypress/e2e/game.cy.js Normal file
View File

@ -0,0 +1,29 @@
const io = require('socket.io-client');
const socket = io.connect('http://localhost:8080', { withCredentials: true });
const uniqName = Date.now().toString();
describe('game', () => {
before(() => {
cy.visit('http://localhost:3000');
cy.get('[placeholder="Server Name"]').type(uniqName);
cy.get('button:contains("Host")').click();
const room = cy.contains(`${uniqName}`).should('exist');
room.closest('tr').find('button:contains("Join")').click();
const e = cy.get('[placeholder="Nickname"]').type('player1');
e.type('{enter}');
setTimeout(() => {
socket.emit('room:rooms');
socket.on('room:rooms', rooms => {
const roomId = JSON.parse(rooms).find(r => r.name === uniqName)._id;
socket.emit('player:login', { roomId: roomId, name: 'player2', password: '' });
});
}, 1000);
});
it('starts game correctly', () => {
socket.emit('player:ready');
cy.get('.PrivateSwitchBase-input').click();
cy.get('[data-testid="animated-overlay"]').should('exist');
socket.emit('');
});
});

29
cypress/e2e/login.cy.js Normal file
View File

@ -0,0 +1,29 @@
describe('login', () => {
it('should change color of input if the server name is invalid', () => {
cy.visit('http://localhost:3000');
cy.get('button:contains("Host")').click();
cy.get('[placeholder="Server Name"]').should('have.css', 'border-color', 'rgb(255, 0, 0)');
});
it('should redirect user to game when filling inputs correctly', () => {
cy.visit('http://localhost:3000');
const uniqName = Date.now().toString();
cy.get('[placeholder="Server Name"]').type(uniqName);
cy.get('.PrivateSwitchBase-input').click();
cy.get('[placeholder="password"]').type('123456');
cy.get('button:contains("Host")').click();
const room = cy
.contains(`${uniqName}`)
.should('exist')
.then($room => {
cy.wrap($room).scrollIntoView();
});
room.closest('tr').find('button:contains("Join")').click();
cy.get('[placeholder="Nickname"]').type('player1');
const e = cy.get('[placeholder="Room password"]').type('123456');
e.type('{enter}');
cy.url().should('include', '/game');
cy.contains('player1').should('exist');
cy.get('canvas').should('exist');
});
});

View File

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@ -0,0 +1,25 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

20
cypress/support/e2e.js Normal file
View File

@ -0,0 +1,20 @@
// ***********************************************************
// This example support/e2e.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

2319
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,8 @@
"proxy": "http://localhost:5000",
"devDependencies": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2"
"@testing-library/react": "^14.1.2",
"cypress": "^13.6.1",
"@babel/plugin-proposal-private-property-in-object": "^7.16.7"
}
}

View File

@ -4,11 +4,13 @@ import { PlayerDataContext, SocketContext } from '../../App';
import useSocketData from '../../hooks/useSocketData';
import Map from './Map/Map';
import Navbar from '../Navbar/Navbar';
import Overlay from '../Overlay/Overlay';
import styles from './Gameboard.module.css';
import trophyImage from '../../images/trophy.webp';
const Gameboard = () => {
const socket = useContext(SocketContext);
const context = useContext(PlayerDataContext);
const [pawns, setPawns] = useState([]);
const [players, setPlayers] = useState([]);
@ -20,6 +22,8 @@ const Gameboard = () => {
const [movingPlayer, setMovingPlayer] = useState('red');
const [winner, setWinner] = useState(null);
useEffect(() => {
socket.emit('room:data', context.roomId);
socket.on('room:data', data => {
@ -47,11 +51,19 @@ const Gameboard = () => {
setTime(data.nextMoveTime);
setStarted(data.started);
});
socket.on('game:winner', winner => {
setWinner(winner);
});
socket.on('redirect', () => {
window.location.reload();
});
}, [socket, context.playerId, context.roomId, setRolledNumber]);
return (
<>
{(players[0] && !started) || (time && started) ? (
{pawns.length === 16 ? (
<div className='container'>
<Navbar
players={players}
@ -61,12 +73,24 @@ const Gameboard = () => {
movingPlayer={movingPlayer}
rolledNumber={rolledNumber}
nowMoving={nowMoving}
ended={winner !== null}
/>
<Map pawns={pawns} nowMoving={nowMoving} rolledNumber={rolledNumber} />
</div>
) : (
<ReactLoading type='spinningBubbles' color='white' height={667} width={375} />
)}
{winner ? (
<Overlay>
<div className={styles.winnerContainer}>
<img src={trophyImage} alt='winner' />
<h1>
1st: <span style={{ color: winner }}>{winner}</span>
</h1>
<button onClick={() => socket.emit('player:exit')}>Play again</button>
</div>
</Overlay>
) : null}
</>
);
};

View File

@ -0,0 +1,20 @@
.winnerContainer {
display: flex;
flex-direction: column;
padding: 40px;
width: 300px;
height: 250px;
background: radial-gradient(circle, rgba(0, 138, 255, 1) 5%, rgba(9, 9, 121, 1) 81%);
color: white;
border: 1px solid white;
border-radius: 8px;
margin: 20px;
z-index: 2;
justify-content: center;
text-align: center;
cursor: default;
}
.winnerContainer > button {
align-self: center;
width: 200px;
}

View File

@ -14,7 +14,8 @@ const ServerListTable = ({ rooms, handleJoinClick }) => {
</tr>
</thead>
<tbody>
{rooms.map((room, index) => (
{rooms.map((room, index) => {
return room.started ? null : (
<tr key={index}>
<td>{room.private ? <img src={lock} alt='private' /> : null}</td>
<td className={styles.roomName}>{room.name}</td>
@ -24,7 +25,8 @@ const ServerListTable = ({ rooms, handleJoinClick }) => {
<button onClick={() => handleJoinClick(room)}>Join</button>
</td>
</tr>
))}
);
})}
</tbody>
</table>
);

View File

@ -11,16 +11,17 @@ const Dice = ({ rolledNumber, nowMoving, playerColor, movingPlayer }) => {
};
const isCurrentPlayer = movingPlayer === playerColor;
const hasRolledNumber = rolledNumber !== null;
const hasRolledNumber = rolledNumber !== null && rolledNumber !== undefined;
return (
<div className={styles.container}>
{isCurrentPlayer &&
(hasRolledNumber ? (
{isCurrentPlayer ? (
hasRolledNumber ? (
<img src={images[rolledNumber - 1]} alt={rolledNumber} />
) : (
nowMoving && <img src={images[6]} alt='roll' onClick={handleClick} />
))}
) : nowMoving ? (
<img src={images[6]} className='roll' alt='roll' onClick={handleClick} />
) : null
) : null}
</div>
);
};

View File

@ -7,7 +7,7 @@ import { useContext } from 'react';
import { PlayerDataContext } from '../../App';
import styles from './Navbar.module.css';
const Navbar = ({ players, started, time, isReady, rolledNumber, nowMoving, movingPlayer }) => {
const Navbar = ({ players, started, time, isReady, rolledNumber, nowMoving, movingPlayer, ended }) => {
const context = useContext(PlayerDataContext);
const diceProps = {
@ -21,7 +21,7 @@ const Navbar = ({ players, started, time, isReady, rolledNumber, nowMoving, movi
{players.map((player, index) => (
<div className={`${styles.playerContainer} ${styles[PLAYER_COLORS[index]]}`} key={index}>
<NameContainer player={player} time={time} />
{started ? <Dice playerColor={PLAYER_COLORS[index]} {...diceProps} /> : null}
{started && !ended ? <Dice playerColor={PLAYER_COLORS[index]} {...diceProps} /> : null}
{context.color === player.color && !started ? <ReadyButton isReady={isReady} /> : null}
</div>
))}

BIN
src/images/lobby.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

BIN
src/images/trophy.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
src/images/winner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB