First commit
Some checks failed
gitea/minesweeper-backend/pipeline/head There was a failure building this commit
Some checks failed
gitea/minesweeper-backend/pipeline/head There was a failure building this commit
This commit is contained in:
commit
f65878235b
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal 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
20
Dockerfile
Normal 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
41
Jenkinsfile
vendored
Normal 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_IMAGE_NAME}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Docker clean') {
|
||||||
|
steps {
|
||||||
|
sh """
|
||||||
|
docker rmi ${IMAGE_NAME}
|
||||||
|
docker rmi ${REGISTRY_IMAGE_NAME}
|
||||||
|
docker image prune -f
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
390
index.js
Normal file
390
index.js
Normal 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
1330
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user