First commit
All checks were successful
gitea/minesweeper-backend/pipeline/head This commit looks good

This commit is contained in:
Jose134 2025-01-22 00:14:15 +01:00
commit d2909570a9
6 changed files with 1863 additions and 0 deletions

66
.gitignore vendored Normal file
View File

@ -0,0 +1,66 @@
# Node.js dependencies
node_modules/
npm-debug.log
yarn-error.log
# Environment variables
.env
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov/
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output/
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt/
# Bower dependency directory (https://bower.io/)
bower_components/
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env.test
# TernJS port file
.tern-port
# VS Code settings
.vscode/
# Temporary files
tmp/
temp/

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
# Use the official Node.js image as the base image
FROM node:20
# Set the working directory
WORKDIR /usr/src/app
# Copy package.json and package-lock.json
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application code
COPY . .
# Expose the port the app runs on
EXPOSE 3001
# Command to run the application
CMD ["node", "index.js"]

41
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,41 @@
pipeline {
agent any
environment {
IMAGE_NAME = "darkbird/minesweeper-backend:latest"
REGISTRY_URL = "registry.xdarkbird.duckdns.org"
}
stages {
stage('Docker build') {
steps {
sh """
docker build --network="host" -t ${IMAGE_NAME} .
"""
}
}
stage('Docker tag') {
steps {
sh """
docker image tag ${IMAGE_NAME} ${REGISTRY_URL}/${IMAGE_NAME}
"""
}
}
stage('Docker push') {
steps {
sh """
docker push ${REGISTRY_URL}/${IMAGE_NAME}
"""
}
}
stage('Docker clean') {
steps {
sh """
docker rmi ${IMAGE_NAME}
docker rmi ${REGISTRY_URL}/${IMAGE_NAME}
docker image prune -f
"""
}
}
}
}

390
index.js Normal file
View File

@ -0,0 +1,390 @@
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const cors = require('cors');
const { randomUUID } = require('crypto');
// --------------------------------------
// SERVER
// --------------------------------------
const app = express();
app.use(cors({
origin: "*"
}));
const server = createServer(app);
const io = new Server(server);
io.on('connection', (socket) => {
socket.onAny((event, ...args) => console.log(event, args));
socket.on('disconnect', () => handleDisconnect(socket, playerRoomMap));
socket.on('createRoom', (data) => handleCreateRoom(socket, data));
socket.on('joinRoom', (data) => handleJoinRoom(socket, data));
socket.on('tileClick', (data) => handleTileClick(socket, data));
socket.on('tileSelect', (data) => handleTileSelect(socket, data));
socket.on('tileFlag', (data) => handleTileFlag(socket, data));
});
const PORT = 5174;
server.listen(PORT, () => {
console.log('server started at port', PORT);
});
// --------------------------------------
// ROOM HANDLING
// --------------------------------------
let rooms = new Map();
const playerRoomMap = new Map();
const handleDisconnect = (socket, playerRoomMap) => {
const playerRoom = playerRoomMap.get(socket.id);
if (playerRoom) {
playerRoom.players = playerRoom.players.filter(user => user.socketId !== socket.id);
io.to(playerRoom.id).emit('roomUserLeft', buildRoomView(playerRoom));
}
playerRoomMap.delete(socket.id);
cleanupRooms();
socket.leaveAll();
};
const handleCreateRoom = (socket, data) => {
const { username, rows, cols, bombs, autoflag, gameMode } = data;
const room = buildRoom(socket.id, username, rows, cols, bombs, autoflag, gameMode);
rooms.set(room.id, room);
playerRoomMap.set(socket.id, room);
console.log('created room', room.id);
socket.join(room.id);
socket.emit('youJoinedRoom', buildRoomView(room));
};
const handleJoinRoom = (socket, data) => {
const room = rooms.get(data.room);
if (!room) {
console.log(`room ${data.room} does not exist`);
socket.emit('error', `room ${data.room} does not exist`);
return;
}
room.players.push(buildPlayer(socket.id, data.username));
playerRoomMap.set(socket.id, room);
socket.join(data.room);
socket.broadcast.to(room.id).emit('roomUserJoined', buildRoomView(room));
socket.emit('youJoinedRoom', buildRoomView(room));
};
// --------------------------------------
// GAME LOGIC
// --------------------------------------
const handleTileClick = (socket, data) => {
const room = playerRoomMap.get(socket.id);
if (!room || room.gameState !== "playing") {
console.log("Could not find room for player", socket.id);
socket.emit('error', "Could not find room for player");
return;
}
// Find the player and make sure they are alive and have not already selected a tile
const player = room.players.find(player => player.socketId === socket.id);
if (!player || player.tombstone || (room.gameMode === "turns" && player.confirmedSelection)) {
return;
}
// Find the tile that was clicked and select it if it is not already selected by another player
const tile = room.board.tiles.find(tile => tile.id === data.tileId);
if (!isSelectableTile(tile, room.players, player.socketId)) {
return;
}
if (room.gameMode === "turns") {
const player = room.players.find(player => player.socketId === socket.id)
if (player) {
player.selection = tile.id;
}
else {
return;
}
}
else if (room.gameMode === "realtime") {
if (tile.bomb === true) {
console.log('bomb clicked');
room.players.find(user => user.socketId === socket.id).tombstone = tile.id;
}
revealTile(room.board, data.tileId);
if (room.autoflag) {
flagBombs(room.board);
}
if (checkLoseCondition(room.players)) {
room.gameState = "lost";
}
else if (checkWinCondition(room.players, room.board)) {
room.gameState = "won";
}
}
io.to(room.id).emit('updateRoom', buildRoomView(room));
};
const handleTileSelect = (socket, data) => {
const room = playerRoomMap.get(socket.id);
if (!room || room.gameState !== "playing") {
console.log("Could not find room for player", socket.id);
socket.emit('error', "Could not find room for player");
return;
}
if (room.gameMode !== "turns") {
console.log('game mode is not turns');
return;
}
if (room.players.find(user => user.socketId === socket.id).tombstone) {
console.log('player is dead');
return;
}
const player = room.players.find(player => player.socketId === socket.id);
if (!player || player.tombstone || !player.selection || player.confirmedSelection) {
return;
}
// Find the tile that was clicked and select it if it is not already selected by another player
const tile = room.board.tiles.find(tile => tile.id === data.tileId);
if (!isSelectableTile(tile, room.players, player.socketId)) {
console.log('tile is not selectable');
return;
}
player.confirmedSelection = true;
const playersSelected = room.players.filter(player => !player.tombstone && player.selection && player.confirmedSelection === true);
const shouldPlayRound = playersSelected.length === room.players.filter(player => !player.tombstone).length ||
room.players.filter(player => player.tombstone).length === room.board.tiles.filter(tile => !tile.revealed).length;
if (shouldPlayRound) {
room.players.filter(player => !player.tombstone).forEach(player => {
if (room.board.tiles.find(tile => tile.id === player.selection).bomb === true) {
player.tombstone = player.selection;
}
revealTile(room.board, player.selection);
});
if (room.autoflag) {
flagBombs(room.board);
}
room.players.forEach(player => {
player.selection = null;
player.confirmedSelection = false;
});
if (checkLoseCondition(room.players)) {
room.gameState = "lost";
}
else if (checkWinCondition(room.players, room.board)) {
room.gameState = "won";
}
}
io.to(room.id).emit('updateRoom', buildRoomView(room));
};
const handleTileFlag = (socket, data) => {
const room = playerRoomMap.get(socket.id);
if (!room) {
console.log("Could not find room for player", socket.id);
socket.emit('error', "Could not find room for player");
return;
}
if (room.players.find(user => user.socketId === socket.id).tombstone) {
console.log('player is dead');
return;
}
// Find the tile that was clicked and select it if it is not already selected by another player
const tile = room.board.tiles.find(tile => tile.id === data.tileId);
if (!tile || tile.revealed || room.players.some(player => player.selection === data.tileId)) {
return;
}
tile.flagged = !tile.flagged;
if (checkWinCondition(room.players, room.board)) {
room.gameState = "won";
}
io.to(room.id).emit('updateRoom', buildRoomView(room));
};
// --------------------------------------
// HELPER FUNCTIONS
// --------------------------------------
const cleanupRooms = () => {
for (let room of rooms.values()) {
if (room.players.length === 0) {
rooms.delete(room.id);
}
}
};
const buildRoom = (userId, username, rows, cols, bombs, autoflag, gameMode) => {
const roomId = randomUUID().slice(0, 5); // TODO: avoid collisions
const board = buildBoard(rows, cols, bombs);
const user = buildPlayer(userId, username);
return {
id: roomId,
autoflag: autoflag,
players: [user],
gameMode: gameMode,
gameState: "playing",
board: board
};
};
const buildBoard = (rows, cols, bombs) => {
// Generate empty board
const board = {
rows: rows,
cols: cols,
bombs: bombs,
tiles: Array(rows * cols).fill().map((_, idx) => {
return {
id: idx,
value: null,
bomb: false,
revealed: false,
flagged: false,
playerSelect: null
}
})
};
// Place bombs
for (let i = 0; i < bombs; i++) {
const bombIdx = Math.floor(Math.random() * board.tiles.length);
board.tiles[bombIdx].bomb = true;
}
// Calculate values
board.tiles.forEach((tile, idx) => {
if (tile.bomb === true) {
return;
}
const bombCount = getSurroundingTiles(board, idx)
.filter(tile => tile && tile.bomb)
.length;
tile.value = bombCount;
});
return board;
};
const buildPlayer = (socketId, name) => {
const getRandomColorHex = () => {
const colorValue = Math.floor(0x888888 + (Math.random() * 0xffffff - 0x888888));
return '#' + colorValue.toString(16).padStart(6, '0');
};
return {
socketId,
name,
color: getRandomColorHex()
};
};
const buildRoomView = (room) => {
const buildBoardView = (board, gameState) => {
return {
rows: board.rows,
cols: board.cols,
bombs: board.bombs,
tiles: board.tiles.map(tile => tile.revealed || gameState === "lost" || gameState === "won"
? { id: tile.id, value: tile.value, bomb: tile.bomb }
: { id: tile.id, flagged: tile.flagged }
)
};
}
return {
id: room.id,
players: room.players,
gameMode: room.gameMode,
gameState: room.gameState,
board: buildBoardView(room.board, room.gameState)
};
};
const revealTile = (board, idx) => {
const tile = board.tiles.find(tile => tile.id === idx);
if (!tile || tile.revealed || tile.flagged) {
return;
}
tile.revealed = true;
if (tile.value === 0) {
const neighbors = getSurroundingTiles(board, idx);
neighbors.forEach(neighbor => revealTile(board, neighbor.id));
}
};
const flagBombs = (board) => {
board.tiles.forEach(tile => {
if (!tile.bomb && !tile.flagged) {
const neighbors = getSurroundingTiles(board, tile.id);
if (tile.revealed && neighbors.filter(neighbor => !neighbor.revealed || neighbor.bomb).length == tile.value) {
neighbors.forEach(neighbor => {
if (!neighbor.revealed && !neighbor.flagged) {
neighbor.flagged = true;
}
});
}
}
});
};
const getSurroundingTiles = (board, idx) => {
const { rows, cols } = board;
// Thanks copilot for writing this garbage so that I don't have to <3
return [
idx % cols !== 0 && board.tiles[idx - cols - 1], // top-left
board.tiles[idx - cols], // top
(idx + 1) % cols !== 0 && board.tiles[idx - cols + 1], // top-right
idx % cols !== 0 && board.tiles[idx - 1], // left
(idx + 1) % cols !== 0 && board.tiles[idx + 1], // right
idx % cols !== 0 && board.tiles[idx + cols - 1], // bottom-left
board.tiles[idx + cols], // bottom
(idx + 1) % cols !== 0 && board.tiles[idx + cols + 1] // bottom-right
].filter(tile => tile);
};
const checkWinCondition = (players, board) => {
return board.tiles.every(tile =>
tile.revealed
|| (tile.bomb && tile.flagged)
|| players.some(player => player.tombstone === tile.id))
&& players.filter(player => !player.tombstone).length > 0;
};
const checkLoseCondition = (players) => {
return players.every(player => player.tombstone);
}
const isSelectableTile = (tile, players, currentPlayerId) => {
return tile
&& !tile.revealed
&& !tile.flagged
&& players.filter(player => player.socketId != currentPlayerId).every(player => player.selection !== tile.id)
&& players.every(player => player.tombstone !== tile.id);
}

1330
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "minesweeper-back",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Jose\b\b\u001b[José Manuel",
"license": "ISC",
"description": "",
"dependencies": {
"express": "^4.21.2",
"nodemon": "^3.1.9",
"socket.io": "^4.8.1"
}
}