Server folders restructuration

This commit is contained in:
Sebastien Riviere
2026-02-23 00:32:19 +01:00
parent 296220d9f9
commit d0e237245e
14 changed files with 39 additions and 22 deletions

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);
});
});
}

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.toString().padStart(6, '0');
},
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;
},
}

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,84 @@
/*
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 = path.join(process.cwd(), "uploads");
const IMAGES_DIR = path.join(process.cwd(), "assets", "images");
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(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(req.query.team);
if (team) {
const imagePath = path.join(UPLOAD_DIR, team.id);
res.set("Content-Type", "image/png")
res.set("Access-Control-Allow-Origin", "*");
res.sendFile(fs.existsSync(imagePath) ? imagePath : path.join(IMAGES_DIR, "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(req.query.team);
if (team) {
const imagePath = path.join(UPLOAD_DIR, team.chasing);
res.set("Content-Type", "image/png")
res.set("Access-Control-Allow-Origin", "*");
res.sendFile(fs.existsSync(imagePath) ? imagePath : path.join(IMAGES_DIR, "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 TRAJECTORIES_DIR = path.join(process.cwd(), "trajectories");
const EXTENSION = "txt";
// Useful functions
function teamIDToPath(teamID) {
return path.join(TRAJECTORIES_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(TRAJECTORIES_DIR);
for (const file of files) fs.unlinkSync(path.join(TRAJECTORIES_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);
},
}