Restructuration of the project folders

This commit is contained in:
Sebastien Riviere
2026-02-13 16:06:50 +01:00
parent 5f16500634
commit c1f1688794
188 changed files with 265 additions and 301 deletions

View File

@@ -0,0 +1,8 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.vscode

138
server/traque-back/.gitignore vendored Normal file
View File

@@ -0,0 +1,138 @@
ssl/*
uploads/*
#https dev certificates
*.pem
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# 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
*.lcov
# 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
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Other
trajectories/
uploads/

View File

@@ -0,0 +1,24 @@
# Use Node 22 alpine as parent image
FROM node:22-alpine
# Change the working directory on the Docker image to /app
WORKDIR /app
# Copy package.json and package-lock.json to the /app directory
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install
# Copy the rest of project files into this image
COPY . .
# Create those folders if they don't already exist
RUN if [ ! -d uploads ]; then mkdir uploads; fi
RUN if [ ! -d trajectories ]; then mkdir trajectories; fi
# Expose the port
EXPOSE 3001
# Start the application
CMD ["npm", "run", "start"]

View File

@@ -0,0 +1,27 @@
# Use Node 22 alpine as parent image
FROM node:22-alpine
# Change the working directory on the Docker image to /app
WORKDIR /app
# Change specified variables
ENV NODE_ENV=development
# Copy package.json and package-lock.json to the /app directory
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install
# Copy the rest of project files into this image
COPY . .
# Create those folders if they don't already exist
RUN if [ ! -d uploads ]; then mkdir uploads; fi
RUN if [ ! -d trajectories ]; then mkdir trajectories; fi
# Expose the port
EXPOSE 3001
# Start the server in dev mode
CMD ["npm", "run", "dev"]

View File

@@ -0,0 +1,8 @@
#Make a self signed certificate for development
keys: key.pem server.crt
key.pem csr.pem:
openssl req -newkey rsa:2048 -new -nodes -keyout key.pem -out csr.pem
server.crt: key.pem csr.pem
openssl x509 -req -days 365 -in csr.pem -signkey key.pem -out server.crt

View File

@@ -0,0 +1,99 @@
import { io } from "./index.js";
import { createHash } from "crypto";
import { config } from "dotenv";
import game from "./game.js"
import zoneManager from "./zone_manager.js"
config();
const ADMIN_PASSWORD_HASH = process.env.ADMIN_PASSWORD_HASH;
export function secureAdminBroadcast(event, data) {
loggedInSockets.forEach(s => {
io.of("admin").to(s).emit(event, data);
});
}
let loggedInSockets = [];
export function initAdminSocketHandler() {
io.of("admin").on("connection", (socket) => {
console.log("Connection of an admin");
let loggedIn = false;
const login = (password) => {
if (loggedIn) return false;
if (createHash('sha256').update(password).digest('hex') !== ADMIN_PASSWORD_HASH) return false;
loggedInSockets.push(socket.id);
loggedIn = true;
return true;
}
const logout = () => {
if (!loggedIn) return false;
loggedInSockets = loggedInSockets.filter(s => s !== socket.id);
loggedIn = false;
return true;
}
socket.on("disconnect", () => {
console.log("Disconnection of an admin");
logout();
});
socket.on("logout", () => {
logout();
});
socket.on("login", (password) => {
if (!login(password)) return;
socket.emit("teams", game.teams);
socket.emit("game_state", {
state: game.state,
date: game.startDate
});
socket.emit("current_zone", {
begin: zoneManager.getCurrentZone(),
end: zoneManager.getNextZone(),
endDate: zoneManager.currentZone?.endDate,
});
socket.emit("settings", game.getAdminSettings());
socket.emit("login_response", true);
});
socket.on("add_team", (teamName) => {
if (!loggedIn) return;
game.addTeam(teamName);
});
socket.on("remove_team", (teamId) => {
if (!loggedIn) return;
game.removeTeam(teamId);
});
socket.on("reorder_teams", (newOrder) => {
if (!loggedIn) return;
game.reorderTeams(newOrder);
});
socket.on("capture_team", (teamId) => {
if (!loggedIn) return;
game.switchCapturedTeam(teamId);
});
socket.on("placement_team", (teamId, placementZone) => {
if (!loggedIn) return;
game.placementTeam(teamId, placementZone);
});
socket.on("change_state", (state) => {
if (!loggedIn) return;
game.setState(state);
});
socket.on("update_settings", (settings) => {
if (!loggedIn) return;
game.changeSettings(settings);
});
});
}

506
server/traque-back/game.js Normal file
View File

@@ -0,0 +1,506 @@
import { secureAdminBroadcast } from "./admin_socket.js";
import { teamBroadcast, playersBroadcast, sendUpdatedTeamInformations } from "./team_socket.js";
import { sendPositionTimeouts, outOfZoneTimeouts } from "./timeout_handler.js";
import zoneManager from "./zone_manager.js";
import trajectory from "./trajectory.js";
function randint(max) {
return Math.floor(Math.random() * max);
}
function getDistanceFromLatLon({ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }) {
const degToRad = (deg) => deg * (Math.PI / 180);
var R = 6371; // Radius of the earth in km
var dLat = degToRad(lat2 - lat1);
var dLon = degToRad(lon2 - lon1);
var a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(degToRad(lat1)) * Math.cos(degToRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
;
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
var d = R * c; // Distance in km
return d * 1000;
}
function isInCircle(position, center, radius) {
return getDistanceFromLatLon(position, center) < radius;
}
export const GameState = {
SETUP: "setup",
PLACEMENT: "placement",
PLAYING: "playing",
FINISHED: "finished"
}
export default {
// List of teams, as objects. To see the fields see the addTeam method
teams: [],
// Current state of the game
state: GameState.SETUP,
// Date since the state changed
startDate: null,
// Messages
messages: {
waiting: "",
captured: "",
winner: "",
loser: "",
},
/* ------------------------------- USEFUL FUNCTIONS ------------------------------- */
getNewTeamId() {
let id = randint(1_000_000);
while (this.teams.find(t => t.id === id)) id = randint(1_000_000);
return id;
},
checkEndGame() {
if (this.teams.filter(team => !team.captured).length <= 2) this.setState(GameState.FINISHED);
},
updateChasingChain() {
const playingTeams = this.teams.filter(team => !team.captured);
for (let i = 0; i < playingTeams.length; i++) {
playingTeams[i].chasing = playingTeams[(i+1) % playingTeams.length].id;
playingTeams[i].chased = playingTeams[(playingTeams.length + i-1) % playingTeams.length].id;
sendUpdatedTeamInformations(playingTeams[i].id);
}
},
initLastSentLocations() {
const dateNow = Date.now();
// Update of lastSentLocation
for (const team of this.teams) {
team.lastSentLocation = team.currentLocation;
team.locationSendDeadline = dateNow + sendPositionTimeouts.delay * 60 * 1000;
sendPositionTimeouts.set(team.id);
sendUpdatedTeamInformations(team.id);
}
// Update of enemyLocation now we have the lastSentLocation of the enemy
for (const team of this.teams) {
team.enemyLocation = this.getTeam(team.chasing)?.lastSentLocation;
sendUpdatedTeamInformations(team.id);
}
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
},
resetTeamsInfos() {
for (const team of this.teams) {
// Chasing
team.captured = false;
team.chasing = null;
team.chased = null;
// Locations
team.lastSentLocation = null;
team.locationSendDeadline = null;
team.enemyLocation = null;
// Placement
team.ready = false;
// Zone
team.outOfZone = false;
team.outOfZoneDeadline = null;
team.hasHandicap = false;
// Stats
team.distance = 0;
team.nCaptures = 0;
team.nSentLocation = 0;
team.nObserved = 0;
team.finishDate = null;
sendUpdatedTeamInformations(team.id);
}
this.updateChasingChain();
secureAdminBroadcast("teams", this.teams);
},
/* ------------------------------- STATE AND SETTINGS FUNCTIONS ------------------------------- */
getAdminSettings() {
return {
messages: this.messages,
zone: zoneManager.settings,
sendPositionDelay: sendPositionTimeouts.delay,
outOfZoneDelay: outOfZoneTimeouts.delay
};
},
getPlayerSettings() {
return {
messages: this.messages,
zone: {type: zoneManager.settings.type},
sendPositionDelay: sendPositionTimeouts.delay,
outOfZoneDelay: outOfZoneTimeouts.delay
};
},
changeSettings(newSettings) {
if ("messages" in newSettings) this.messages = {...this.messages, ...newSettings.messages};
if ("zone" in newSettings) zoneManager.changeSettings(newSettings.zone);
if ("sendPositionDelay" in newSettings) sendPositionTimeouts.setDelay(newSettings.sendPositionDelay);
if ("outOfZoneDelay" in newSettings) outOfZoneTimeouts.setDelay(newSettings.outOfZoneDelay);
// Broadcast new infos
secureAdminBroadcast("settings", this.getAdminSettings());
playersBroadcast("settings", this.getPlayerSettings());
},
setState(newState) {
const dateNow = Date.now();
if (newState == this.state) return true;
switch (newState) {
case GameState.SETUP:
trajectory.stop();
zoneManager.stop();
sendPositionTimeouts.clearAll();
outOfZoneTimeouts.clearAll();
this.resetTeamsInfos();
this.startDate = null;
break;
case GameState.PLACEMENT:
if (this.state == GameState.FINISHED || this.teams.length < 3) {
secureAdminBroadcast("game_state", {state: this.state, date: this.startDate});
return false;
}
trajectory.stop();
zoneManager.stop();
sendPositionTimeouts.clearAll();
outOfZoneTimeouts.clearAll();
this.startDate = null;
break;
case GameState.PLAYING:
if (this.state == GameState.FINISHED || this.teams.length < 3) {
secureAdminBroadcast("game_state", {state: this.state, date: this.startDate});
return false;
}
trajectory.start();
zoneManager.start();
this.initLastSentLocations();
this.startDate = dateNow;
break;
case GameState.FINISHED:
if (this.state != GameState.PLAYING) {
secureAdminBroadcast("game_state", {state: this.state, date: this.startDate});
return false;
}
trajectory.stop();
zoneManager.stop();
sendPositionTimeouts.clearAll();
outOfZoneTimeouts.clearAll();
this.teams.forEach(team => {if (!team.finishDate) team.finishDate = dateNow});
secureAdminBroadcast("teams", this.teams);
break;
}
// Update the state
this.state = newState;
// Broadcast new infos
secureAdminBroadcast("game_state", {state: newState, date: this.startDate});
playersBroadcast("game_state", {state: newState, date: this.startDate});
return true;
},
/* ------------------------------- MANAGE PLAYERS FUNCTIONS ------------------------------- */
addPlayer(teamId, socketId) {
// Test of parameters
if (!this.hasTeam(teamId)) return false;
// Variables
const team = this.getTeam(teamId);
// Add the player
team.sockets.push(socketId);
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
return true;
},
removePlayer(teamId, socketId) {
// Test of parameters
if (!this.hasTeam(teamId)) return false;
// Variables
const team = this.getTeam(teamId);
// Remove the player and its data
if (this.isPlayerCapitain(teamId, socketId)) {
team.battery = null;
team.phoneModel = null;
team.phoneName = null;
}
team.sockets = team.sockets.filter((sid) => sid != socketId);
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
sendUpdatedTeamInformations(team.id);
return true;
},
isPlayerCapitain(teamId, socketId) {
return this.getTeam(teamId).sockets.indexOf(socketId) == 0;
},
/* ------------------------------- MANAGE TEAMS FUNCTIONS ------------------------------- */
getTeam(teamId) {
return this.teams.find(t => t.id === teamId);
},
hasTeam(teamId) {
return this.teams.some(t => t.id === teamId);
},
addTeam(teamName) {
this.teams.push({
// Identification
sockets: [],
name: teamName,
id: this.getNewTeamId(),
captureCode: randint(10_000),
// Chasing
captured: false,
chasing: null,
chased: null,
// Locations
lastSentLocation: null,
locationSendDeadline: null,
currentLocation: null,
lastCurrentLocationDate: null,
enemyLocation: null,
// Placement
startingArea: null,
ready: false,
// Zone
outOfZone: false,
outOfZoneDeadline: null,
hasHandicap: false,
// Stats
distance: 0,
nCaptures: 0,
nSentLocation: 0,
nObserved: 0,
finishDate: null,
// First socket infos
phoneModel: null,
phoneName: null,
battery: null,
});
this.updateChasingChain();
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
return true;
},
removeTeam(teamId) {
// Test of parameters
if (!this.hasTeam(teamId)) return false;
// Logout the team
teamBroadcast(teamId, "logout");
this.teams = this.teams.filter(t => t.id !== teamId);
sendPositionTimeouts.clear(teamId);
outOfZoneTimeouts.clear(teamId);
this.updateChasingChain();
this.checkEndGame();
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
return true;
},
updateTeam(teamId, newInfos) {
// Test of parameters
if (!this.hasTeam(teamId)) return false;
// Update
this.teams = this.teams.map(team => team.id == teamId ? {...team, ...newInfos} : team);
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
sendUpdatedTeamInformations(teamId);
return true;
},
switchCapturedTeam(teamId) {
// Test of parameters
if (!this.hasTeam(teamId)) return false;
// Variables
const team = this.getTeam(teamId);
const dateNow = Date.now();
// Switch team.captured
if (this.state != GameState.PLAYING) return false;
if (team.captured) {
team.captured = false;
team.finishDate = null;
team.lastSentLocation = team.currentLocation;
team.locationSendDeadline = dateNow + sendPositionTimeouts.delay * 60 * 1000;
sendPositionTimeouts.set(team.id);
} else {
team.captured = true;
team.finishDate = dateNow;
team.chasing = null;
team.chased = null;
sendPositionTimeouts.clear(team.id);
outOfZoneTimeouts.clear(team.id);
}
this.updateChasingChain();
this.checkEndGame();
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
sendUpdatedTeamInformations(team.id);
return true;
},
placementTeam(teamId, placementZone) {
// Test of parameters
if (!this.hasTeam(teamId)) return false;
// Variables
const team = this.getTeam(teamId);
// Make the capture
team.startingArea = placementZone;
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
sendUpdatedTeamInformations(team.id);
return true;
},
reorderTeams(newOrder) {
// Update teams
const teamMap = new Map(this.teams.map(team => [team.id, team]));
this.teams = newOrder.map(id => teamMap.get(id));
this.updateChasingChain();
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
return true;
},
handicapTeam(teamId) {
// Test of parameters
if (!this.hasTeam(teamId)) return false;
// Variables
const team = this.getTeam(teamId);
// Make the capture
team.hasHandicap = true;
sendPositionTimeouts.clear(team.id);
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
sendUpdatedTeamInformations(team.id);
return true;
},
/* ------------------------------- PLAYERS ACTIONS FUNCTIONS ------------------------------- */
updateLocation(teamId, location) {
// Test of parameters
if (!this.hasTeam(teamId)) return false;
if (!this.hasTeam(this.getTeam(teamId).chasing)) return false;
if (!location) return false;
// Variables
const team = this.getTeam(teamId);
const enemyTeam = this.getTeam(team.chasing);
const dateNow = Date.now();
// Update distance
if (this.state == GameState.PLAYING && team.currentLocation) {
team.distance += Math.floor(getDistanceFromLatLon({lat: location[0], lng: location[1]}, {lat: team.currentLocation[0], lng: team.currentLocation[1]}));
}
// Update of currentLocation
team.currentLocation = location;
team.lastCurrentLocationDate = dateNow;
if (this.state == GameState.PLAYING && team.hasHandicap) {
team.lastSentLocation = team.currentLocation;
}
// Update of enemyLocation
if (this.state == GameState.PLAYING && enemyTeam.hasHandicap) {
team.enemyLocation = enemyTeam.currentLocation;
}
// Update of ready
if (this.state == GameState.PLACEMENT && team.startingArea) {
team.ready = isInCircle({ lat: location[0], lng: location[1] }, team.startingArea.center, team.startingArea.radius);
}
// Update out of zone
if (this.state == GameState.PLAYING) {
const teamCurrentlyOutOfZone = !zoneManager.isInZone({ lat: location[0], lng: location[1] })
if (teamCurrentlyOutOfZone && !team.outOfZone) {
team.outOfZone = true;
team.outOfZoneDeadline = dateNow + outOfZoneTimeouts.delay * 60 * 1000;
outOfZoneTimeouts.set(teamId);
} else if (!teamCurrentlyOutOfZone && team.outOfZone) {
team.outOfZone = false;
team.outOfZoneDeadline = null;
team.hasHandicap = false;
if (!sendPositionTimeouts.has(team.id)) {
team.locationSendDeadline = dateNow + sendPositionTimeouts.delay * 60 * 1000;
sendPositionTimeouts.set(team.id);
}
outOfZoneTimeouts.clear(teamId);
}
}
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
sendUpdatedTeamInformations(team.id);
// Update of events of the game
trajectory.writePosition(dateNow, team.id, location[0], location[1]);
return true;
},
sendLocation(teamId) {
// Conditions
if (this.state != GameState.PLAYING) return false;
// Test of parameters
if (!this.hasTeam(teamId)) return false;
if (!this.hasTeam(this.getTeam(teamId).chasing)) return false;
// Variables
const team = this.getTeam(teamId);
const enemyTeam = this.getTeam(team.chasing);
const dateNow = Date.now();
// Update team
team.nSentLocation++;
team.lastSentLocation = team.currentLocation;
team.enemyLocation = enemyTeam.lastSentLocation;
team.locationSendDeadline = dateNow + sendPositionTimeouts.delay * 60 * 1000;
sendPositionTimeouts.set(team.id);
// Update enemy
enemyTeam.nObserved++;
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
sendUpdatedTeamInformations(team.id);
sendUpdatedTeamInformations(enemyTeam.id);
// Update of events of the game
trajectory.writeSeePosition(dateNow, team.id, enemyTeam.id);
return true;
},
tryCapture(teamId, captureCode) {
// Conditions
if (this.state != GameState.PLAYING) return false;
// Test of parameters
if (!this.hasTeam(teamId)) return false;
if (!this.hasTeam(this.getTeam(teamId).chasing)) return false;
// Variables
const team = this.getTeam(teamId);
const enemyTeam = this.getTeam(team.chasing);
const dateNow = Date.now();
// Verify the capture
if (enemyTeam.captureCode != captureCode) return false;
// Make the capture
team.nCaptures++;
enemyTeam.captured = true;
enemyTeam.finishDate = dateNow;
enemyTeam.chasing = null;
enemyTeam.chased = null;
sendPositionTimeouts.clear(enemyTeam.id);
outOfZoneTimeouts.clear(enemyTeam.id);
this.updateChasingChain();
this.checkEndGame();
// Broadcast new infos
secureAdminBroadcast("teams", this.teams);
sendUpdatedTeamInformations(team.id);
sendUpdatedTeamInformations(enemyTeam.id);
// Update of events of the game
trajectory.writeCapture(dateNow, team.id, enemyTeam.id);
return true;
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,30 @@
import { createServer } from "http";
import express from "express";
import { Server } from "socket.io";
import { config } from "dotenv";
import { initAdminSocketHandler } from "./admin_socket.js";
import { initTeamSocket } from "./team_socket.js";
import { initPhotoUpload } from "./photo.js";
config();
const HOST = process.env.HOST;
const PORT = process.env.PORT;
export const app = express();
const httpServer = createServer({}, app);
httpServer.listen(PORT, HOST, () => {
console.log("Server running on http://" + HOST + ":" + PORT);
});
export const io = new Server(httpServer, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
initAdminSocketHandler();
initTeamSocket();
initPhotoUpload();

View File

@@ -0,0 +1,24 @@
{
"name": "traque-back",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node index.js",
"dev": "nodemon index.js"
},
"author": "Quentin Roussel",
"license": "ISC",
"type": "module",
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"random-location": "^1.1.3",
"socket.io": "^4.7.5"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

View File

@@ -0,0 +1,83 @@
/*
This module manages the handler for uploading photos, as well as serving the correct file on requests based on the team ID and current game state
*/
import { app } from "./index.js";
import multer from "multer";
import fs from "fs";
import path from "path";
import game from "./game.js";
const UPLOAD_DIR = "uploads/"
const ALLOWED_MIME = [
"image/png",
"image/jpeg",
"image/gif"
]
// Setup multer (the file upload middleware)
const storage = multer.diskStorage({
// Save the file in the uploads directory
destination: function (req, file, callback) {
callback(null, UPLOAD_DIR);
},
// Save the file with the team ID as the filename
filename: function (req, file, callback) {
callback(null, req.query.team);
}
});
const upload = multer({
storage,
// Only upload the file if it is a valid mime type and the team POST parameter is a valid team
fileFilter: function (req, file, callback) {
if (ALLOWED_MIME.indexOf(file.mimetype) == -1) {
callback(null, false);
} else if (!game.getTeam(Number(req.query.team))) {
callback(null, false);
} else {
callback(null, true);
}
}
})
// Clean the uploads directory
function clean() {
const files = fs.readdirSync(UPLOAD_DIR);
for (const file of files) {
const filePath = path.join(UPLOAD_DIR, file);
fs.unlinkSync(filePath);
}
}
export function initPhotoUpload() {
clean();
//App handler for uploading a photo and saving it to a file
app.post("/upload", upload.single('file'), (req, res) => {
res.set("Access-Control-Allow-Origin", "*");
console.log("upload", req.query)
res.send("")
})
//App handler for serving the photo of a team given its secret ID
app.get("/photo/my", (req, res) => {
let team = game.getTeam(Number(req.query.team));
if (team) {
const imagePath = path.join(process.cwd(), UPLOAD_DIR, team.id.toString());
res.set("Content-Type", "image/png")
res.set("Access-Control-Allow-Origin", "*");
res.sendFile(fs.existsSync(imagePath) ? imagePath : path.join(process.cwd(), "images", "missing_image.jpg"));
} else {
res.status(400).send("Team not found")
}
})
//App handler for serving the photo of the team chased by the team given by its secret ID
app.get("/photo/enemy", (req, res) => {
let team = game.getTeam(Number(req.query.team));
if (team) {
const imagePath = path.join(process.cwd(), UPLOAD_DIR, team.chasing.toString());
res.set("Content-Type", "image/png")
res.set("Access-Control-Allow-Origin", "*");
res.sendFile(fs.existsSync(imagePath) ? imagePath : path.join(process.cwd(), "images", "missing_image.jpg"));
} else {
res.status(400).send("Team not found")
}
})
}

View File

@@ -0,0 +1,147 @@
/*
This file manages team access to the server via websocket.
It receives messages, checks permissions, manages authentication and performs actions by calling functions from other modules.
This module also exposes functions to send messages via socket to all teams
*/
import { io } from "./index.js";
import game from "./game.js";
import zoneManager from "./zone_manager.js";
/**
* Send a socket message to all the players of a team
* @param {String} teamId The team that will receive the message
* @param {String} event Event name
* @param {*} data The payload
*/
export function teamBroadcast(teamId, event, data) {
game.getTeam(teamId).sockets.forEach(socketId => io.of("player").to(socketId).emit(event, data));
}
/**
* Send a message to all logged in players
* @param {String} event Event name
* @param {String} data payload
*/
export function playersBroadcast(event, data) {
game.teams.forEach(team => teamBroadcast(team.id, event, data));
}
/**
* Send a socket message to all the players of a team
* @param {String} teamId The team that will receive the message
*/
export function sendUpdatedTeamInformations(teamId) {
// Test of parameters
if (!game.hasTeam(teamId)) return false;
// Variables
const team = game.getTeam(teamId);
const enemyTeam = game.getTeam(team.chasing);
teamBroadcast(teamId, "update_team", {
// Identification
name: team.name,
captureCode: team.captureCode,
// Chasing
captured: team.captured,
enemyName: enemyTeam?.name,
// Locations
lastSentLocation: team.lastSentLocation,
enemyLocation: team.enemyLocation,
// Placement phase
startingArea: team.startingArea,
ready: team.ready,
// Constraints
outOfZone: team.outOfZone,
outOfZoneDeadline: team.outOfZoneDeadline,
locationSendDeadline: team.locationSendDeadline,
hasHandicap: team.hasHandicap,
enemyHasHandicap: enemyTeam?.hasHandicap,
// Stats
distance: team.distance,
nCaptures: team.nCaptures,
nSentLocation: team.nSentLocation,
finishDate: team.finishDate,
});
}
export function initTeamSocket() {
io.of("player").on("connection", (socket) => {
console.log("Connection of a player");
let teamId = null;
const login = (loginTeamId) => {
logout();
if (!game.addPlayer(loginTeamId, socket.id)) return false;
teamId = loginTeamId;
return true;
}
const logout = () => {
if (!teamId) return;
game.removePlayer(teamId, socket.id);
teamId = null;
}
socket.on("disconnect", () => {
console.log("Disconnection of a player");
logout();
});
socket.on("logout", () => {
logout();
});
socket.on("login", (loginTeamId, callback) => {
if (!login(loginTeamId)) {
callback({ isLoggedIn: false, message: "Login denied" });
return;
}
sendUpdatedTeamInformations(loginTeamId);
socket.emit("game_state", {
state: game.state,
date: game.startDate
});
socket.emit("current_zone", {
begin: zoneManager.getCurrentZone(),
end: zoneManager.getNextZone(),
endDate: zoneManager.currentZone?.endDate,
});
socket.emit("settings", game.getPlayerSettings());
callback({ isLoggedIn : true, message: "Logged in"});
});
socket.on("update_position", (position) => {
if (!teamId) return;
if (game.isPlayerCapitain(teamId, socket.id)) {
game.updateLocation(teamId, position);
}
});
socket.on("send_position", () => {
if (!teamId) return;
game.sendLocation(teamId);
});
socket.on("capture", (captureCode, callback) => {
if (!teamId) return;
if (game.tryCapture(teamId, captureCode)) {
callback({ hasCaptured : true, message: "Capture successful" });
} else {
callback({ hasCaptured : false, message: "Capture denied" });
}
});
socket.on("device_info", (infos) => {
if (!teamId) return;
if (game.isPlayerCapitain(teamId, socket.id)) {
game.updateTeam(teamId, {phoneModel: infos.model, phoneName: infos.name});
}
});
socket.on("battery_update", (batteryLevel) => {
if (!teamId) return;
if (game.isPlayerCapitain(teamId, socket.id)) {
game.updateTeam(teamId, {battery: batteryLevel});
}
});
});
}

View File

@@ -0,0 +1,92 @@
import game from "./game.js";
class TimeoutManager {
constructor() {
this.timeouts = new Map();
}
has(key) {
return this.timeouts.has(key);
}
set(key, callback, delay) {
const newCallback = () => {
this.timeouts.delete(key);
callback();
}
if (this.timeouts.has(key)) clearTimeout(this.timeouts.get(key));
this.timeouts.set(key, setTimeout(newCallback, delay));
}
clear(key) {
if (this.timeouts.has(key)) {
clearTimeout(this.timeouts.get(key));
this.timeouts.delete(key);
}
}
clearAll() {
this.timeouts.forEach(timeout => clearTimeout(timeout));
this.timeouts = new Map();
}
}
export const sendPositionTimeouts = {
timeoutManager: new TimeoutManager(),
delay: 10, // Minutes
has(teamID) {
return this.timeoutManager.has(teamID);
},
set(teamID) {
const callback = () => {
game.sendLocation(teamID);
this.set(teamID);
}
this.timeoutManager.set(teamID, callback, this.delay * 60 * 1000);
},
clear(teamID) {
this.timeoutManager.clear(teamID);
},
clearAll() {
this.timeoutManager.clearAll();
},
setDelay(delay) {
this.delay = delay;
}
}
export const outOfZoneTimeouts = {
timeoutManager: new TimeoutManager(),
delay: 10, // Minutes
has(teamID) {
return this.timeoutManager.has(teamID);
},
set(teamID) {
const callback = () => {
game.handicapTeam(teamID);
}
this.timeoutManager.set(teamID, callback, this.delay * 60 * 1000);
},
clear(teamID) {
this.timeoutManager.clear(teamID);
},
clearAll() {
this.timeoutManager.clearAll();
},
setDelay(delay) {
this.delay = delay;
}
}

View File

@@ -0,0 +1,90 @@
import fs from "fs";
import path from "path";
const UPLOAD_DIR = "trajectories";
const EXTENSION = "txt";
// Useful functions
function teamIDToPath(teamID) {
return path.join(UPLOAD_DIR, teamID + "." + EXTENSION);
}
function dataToLine(...data) {
return data.join(',');
}
const errorFile = (err) => {
if (err) console.error("Error appending to file:", err);
};
function addLineToFile(teamID, line) {
// Insert the line in the file of teamID depending on the date (lines are sorted by date)
if (!fs.existsSync(teamIDToPath(teamID))) {
fs.writeFile(teamIDToPath(teamID), line + '\n', errorFile);
} else {
fs.readFile(teamIDToPath(teamID), 'utf8', (err, data) => {
if (err) {
errorFile(err);
return;
}
let lines = data.trim().split('\n');
const newDate = parseInt(line.split(',')[0], 10);
let insertIndex = lines.length;
for (let i = lines.length - 1; i >= 0; i--) {
const date = parseInt(lines[i].split(',')[0], 10);
if (date <= newDate) {
insertIndex = i + 1;
break;
}
}
lines.splice(insertIndex, 0, line);
fs.writeFile(teamIDToPath(teamID), lines.join('\n') + '\n', errorFile);
});
}
}
function initTrajectories() {
const files = fs.readdirSync(UPLOAD_DIR);
for (const file of files) fs.unlinkSync(path.join(UPLOAD_DIR, file));
}
// Export functions
export default {
isRecording: false,
start() {
initTrajectories();
this.isRecording = true;
},
stop() {
this.isRecording = false;
},
writePosition(date, teamID, lon, lat) {
if (this.isRecording) {
addLineToFile(teamID, dataToLine(date, "position", lon, lat));
}
},
writeCapture(date, teamID, capturedTeamID) {
if (this.isRecording) {
addLineToFile(teamID, dataToLine(date, "capture", capturedTeamID));
addLineToFile(capturedTeamID, dataToLine(date, "captured", teamID));
}
},
writeSeePosition(date, teamID, seenTeamID) {
if (this.isRecording) {
addLineToFile(teamID, dataToLine(date, "see"));
addLineToFile(seenTeamID, dataToLine(date, "seen"));
}
},
writeOutOfZone(date, teamID, isOutOfZone) {
if (this.isRecording) {
addLineToFile(teamID, dataToLine(date, "zone", isOutOfZone));
}
},
}

View File

@@ -0,0 +1,256 @@
import { playersBroadcast } from './team_socket.js';
import { secureAdminBroadcast } from './admin_socket.js';
/* -------------------------------- Useful functions and constants -------------------------------- */
const zoneTypes = {
circle: "circle",
polygon: "polygon"
}
const EARTH_RADIUS = 6_371_000; // Radius of the earth in m
function haversine_distance({ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }) {
const degToRad = (deg) => deg * (Math.PI / 180);
const dLat = degToRad(lat2 - lat1);
const dLon = degToRad(lon2 - lon1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(degToRad(lat1)) * Math.cos(degToRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return c * EARTH_RADIUS;
}
function latlngEqual(latlng1, latlng2, epsilon = 1e-9) {
return Math.abs(latlng1.lat - latlng2.lat) < epsilon && Math.abs(latlng1.lng - latlng2.lng) < epsilon;
}
/* -------------------------------- Circle zones -------------------------------- */
const defaultCircleSettings = {type: zoneTypes.circle, min: null, max: null, reductionCount: 4, duration: 10}
function circleZone(center, radius, duration) {
return {
type: zoneTypes.circle,
center: center,
radius: radius,
duration: duration,
isInZone(location) {
return haversine_distance(center, location) < this.radius;
}
}
}
function circleSettingsToZones(settings) {
const {min, max, reductionCount, duration} = settings;
if (!min || !max) return [];
if (haversine_distance(max.center, min.center) > max.radius - min.radius) return [];
const zones = [circleZone(max.center, max.radius, duration)];
const radiusReductionLength = (max.radius - min.radius) / reductionCount;
let center = max.center;
let radius = max.radius;
for (let i = 1; i < reductionCount; i++) {
radius -= radiusReductionLength;
let new_center = null;
while (!new_center || haversine_distance(new_center, min.center) > radius - min.radius) {
const angle = Math.random() * 2 * Math.PI;
const angularDistance = Math.sqrt(Math.random()) * radiusReductionLength / EARTH_RADIUS;
const lat0Rad = center.lat * Math.PI / 180;
const lon0Rad = center.lng * Math.PI / 180;
const latRad = Math.asin(
Math.sin(lat0Rad) * Math.cos(angularDistance) +
Math.cos(lat0Rad) * Math.sin(angularDistance) * Math.cos(angle)
);
const lonRad = lon0Rad + Math.atan2(
Math.sin(angle) * Math.sin(angularDistance) * Math.cos(lat0Rad),
Math.cos(angularDistance) - Math.sin(lat0Rad) * Math.sin(latRad)
);
new_center = {lat: latRad * 180 / Math.PI, lng: lonRad * 180 / Math.PI};
}
center = new_center;
zones.push(circleZone(center, radius, duration))
}
zones.push(circleZone(min.center, min.radius, 0));
return zones;
}
/* -------------------------------- Polygon zones -------------------------------- */
const defaultPolygonSettings = {type: zoneTypes.polygon, polygons: []}
function polygonZone(polygon, duration) {
return {
type: zoneTypes.polygon,
polygon: polygon,
duration: duration,
isInZone(location) {
const {lat: x, lng: y} = location;
let inside = false;
for (let i = 0, j = this.polygon.length - 1; i < this.polygon.length; j = i++) {
const {lat: xi, lng: yi} = this.polygon[i];
const {lat: xj, lng: yj} = this.polygon[j];
const intersects = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi);
if (intersects) inside = !inside;
}
return inside;
}
}
}
function mergePolygons(poly1, poly2) {
// poly1 and poly2 are clockwise, not overlaping and touching polygons. If those two polygons were on a map, they would be
// one against each other, and the merge would make a new clockwise polygon out of the outer border of the two polygons.
// If it happens that poly1 and poly2 are not touching, poly1 would be returned untouched.
// Basically because polygons are clockwise, the alogorithm starts from a point A in poly1 not shared by poly2, and
// when a point is shared by poly1 and poly2, the algorithm continues in poly2, and so on until point A.
const getPointIndex = (point, array) => {
for (let i = 0; i < array.length; i++) {
if (latlngEqual(array[i], point)) return i;
}
return -1;
}
// Find the index of the first point of poly1 that doesn't belong to merge (it exists)
let i = 0;
while (getPointIndex(poly1[i], poly2) != -1) i++;
// Starting the merge from that point
const merge = [poly1[i]];
i = (i + 1) % poly1.length;
let currentArray = poly1;
let otherArray = poly2;
while (!latlngEqual(currentArray[i], merge[0])) {
const j = getPointIndex(currentArray[i], otherArray);
if (j != -1) {
[currentArray, otherArray] = [otherArray, currentArray];
i = j;
}
merge.push(currentArray[i]);
i = (i + 1) % currentArray.length;
}
return merge;
}
function polygonSettingsToZones(settings) {
const {polygons} = settings;
const zones = [];
for (const { polygon, duration } of polygons.slice().reverse()) {
const length = zones.length;
if (length == 0) {
zones.push(polygonZone(
polygon,
duration
));
} else {
zones.push(polygonZone(
mergePolygons(zones[length-1].polygon, polygon),
duration
));
}
}
return zones.slice().reverse();
}
/* -------------------------------- Zone manager -------------------------------- */
export default {
isRunning: false,
zones: [], // A zone has to be connected space that doesn't contain an earth pole
currentZone: null,
settings: defaultPolygonSettings,
start() {
if (this.isRunning) return;
this.isRunning = true;
this.currentZone = { id: -1, timeoutId: null, endDate: null };
this.goNextZone();
},
stop() {
if (!this.isRunning) return;
clearTimeout(this.currentZone.timeoutId);
this.isRunning = false;
this.currentZone = null;
},
goNextZone() {
if (!this.isRunning) return;
this.currentZone.id++;
if (this.currentZone.id >= this.zones.length - 1) {
this.currentZone.endDate = Date.now();
} else {
this.currentZone.timeoutId = setTimeout(() => this.goNextZone(), this.getCurrentZone().duration * 60 * 1000);
this.currentZone.endDate = Date.now() + this.getCurrentZone().duration * 60 * 1000;
}
this.zoneBroadcast();
},
getCurrentZone() {
if (!this.isRunning) return null;
return this.zones[this.currentZone.id];
},
getNextZone() {
if (!this.isRunning) return null;
if (this.currentZone.id + 1 < this.zones.length) {
return this.zones[this.currentZone.id + 1];
} else {
return this.zones[this.currentZone.id];
}
},
isInZone(location) {
if (!this.isRunning) return false;
if (this.zones.length == 0) {
return true;
} else {
return this.getCurrentZone().isInZone(location);
}
},
changeSettings(settings) {
switch (settings.type) {
case zoneTypes.circle:
this.zones = circleSettingsToZones(settings);
break;
case zoneTypes.polygon:
this.zones = polygonSettingsToZones(settings);
break;
default:
this.zones = [];
break;
}
this.settings = settings;
this.stop();
this.start();
this.zoneBroadcast();
},
zoneBroadcast() {
if (!this.isRunning) return;
const zone = {
begin: this.getCurrentZone(),
end: this.getNextZone(),
endDate:this.currentZone.endDate,
};
playersBroadcast("current_zone", zone);
secureAdminBroadcast("current_zone", zone);
},
}