First commit
All checks were successful
gitea/minesweeper-backend/pipeline/head This commit looks good
All checks were successful
gitea/minesweeper-backend/pipeline/head This commit looks good
This commit is contained in:
commit
d2909570a9
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_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
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