Server heavy refactoring 1 (not functionnal)

This commit is contained in:
Sebastien Riviere
2026-02-28 00:54:26 +01:00
parent d0e237245e
commit 24bce7896c
27 changed files with 980 additions and 83 deletions

View File

@@ -12,6 +12,8 @@
- [x] Indiquer que l'équipe est hors zone. - [x] Indiquer que l'équipe est hors zone.
- [x] Mettre les stats dans le tiroir (distance, temps, vitesse moy, nb captures, nb envoi) - [x] Mettre les stats dans le tiroir (distance, temps, vitesse moy, nb captures, nb envoi)
- [x] Traduction anglaise - [x] Traduction anglaise
- [ ] Rajouter un service dans le manifest (voir comment font les apps de sport)
- [ ] Ajouter des timers à la notif
- [ ] Implémenter des notifs lors du background (hors zone, position envoyée, update zone) - [ ] Implémenter des notifs lors du background (hors zone, position envoyée, update zone)
- [ ] Créer le menu paramètre (idées de section : langue, photo équipe, notifs, mode sombre, unitées) - [ ] Créer le menu paramètre (idées de section : langue, photo équipe, notifs, mode sombre, unitées)
- [ ] Afficher la trajectoire passée sur la carte (désactivable) - [ ] Afficher la trajectoire passée sur la carte (désactivable)

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ESNext",
"checkJs": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,140 @@
import { Team } from "@/team/team.js";
import { randint } from "@/util/util.js";
import zoneManager from "./zone_manager.js"
import { EventEmitter } from 'events';
import { EVENTS } from "@/socket/playerHandler.js";
import { DefaultState } from "@/states/default_state.js";
import { CircularMap } from "@/util/circular_map.js";
const isTeamNameValide = (teamName) => {
if (typeof teamName !== 'string') return false;
if (teamName.length === 0) return false;
return true;
};
const getNewTeamId = (teams) => {
const idLength = 6;
let newTeamId;
do {
newTeamId = randint(10 ** idLength);
} while (teams.has(newTeamId));
return newTeamId.toString().padStart(idLength, '0');
};
class GameManager extends EventEmitter {
constructor() {
super();
this.currentState = new DefaultState(this);
this.teams = new CircularMap();
this.settings = {
zone: zoneManager.settings,
scanDelay: 10 * 60 * 1000, // ms
outOfZoneDelay: 5 * 60 * 1000 // ms
}
}
// State
setState(StateClass) {
this.currentState.exit();
this.currentState = new StateClass(this);
this.currentState.enter();
}
// Settings
setSettings(settings) {
// Zones
zoneManager.changeSettings(settings.zone);
this.settings.zone = zoneManager.settings; // TODO : not have two copies of the same object
// Delays
this.settings.scanDelay = settings.scanDelay;
this.settings.outOfZoneDelay = settings.outOfZoneDelay;
}
// Emits
emitTeamUpdate(target, team) {
this.emit(EVENTS.INTERNAL.TEAM_UPDATE, target, this.currentState.getTeamMapForTeam(team));
}
emitLogout(target) {
this.emit(EVENTS.INTERNAL.LOGOUT, target);
}
// Actions
//// Boilerplates
_performOnTeam(actionName, teamId, ...args) {
if (!this.teams.has(teamId)) return false;
const team = this.teams.get(teamId);
return this.currentState[actionName](team, ...args);
}
//// All states
addTeam(teamName) {
if (!isTeamNameValide(teamName)) return false;
const teamId = getNewTeamId(this.teams);
const team = new Team(teamId, teamName);
this.teams.set(teamId, team);
this.currentState.initTeamContext(team);
this.currentState.onTeamOrderChange();
return true;
}
removeTeam(teamId) {
if (!this.teams.has(teamId)) return false;
this.emitLogout(teamId);
this.currentState.clearTeamContext(this.teams.get(teamId));
this.teams.delete(teamId);
this.currentState.onTeamOrderChange();
return true;
}
reorderTeam(newTeamsOrder) {
if (!this.teams.reorder(newTeamsOrder)) return false;
this.currentState.onTeamOrderChange();
return true;
}
updateLocation(teamId, coords) {
return this._performOnTeam("updateLocation", teamId, coords);
}
//// Playing state
eliminate(teamId) {
return this._performOnTeam("eliminate", teamId);
}
revive(teamId) {
return this._performOnTeam("revive", teamId);
}
addHandicap(teamId) {
return this._performOnTeam("addHandicap", teamId);
}
clearHandicap(teamId) {
return this._performOnTeam("clearHandicap", teamId);
}
scan(teamId, coords) {
return this._performOnTeam("updateLocation", teamId, coords) && this._performOnTeam("scan", teamId);
}
capture(teamId, captureCode) {
return this._performOnTeam("capture", teamId, captureCode);
}
}
export const gameManager = new GameManager();

View File

@@ -1,23 +1,11 @@
import { playersBroadcast } from './team_socket.js'; import { haversineDistance, EARTH_RADIUS } from "./util.js";
import { secureAdminBroadcast } from './admin_socket.js';
/* -------------------------------- Useful functions and constants -------------------------------- */ /* -------------------------------- Useful functions and constants -------------------------------- */
const zoneTypes = { const ZONE_TYPES = {
circle: "circle", CIRCLE: "circle",
polygon: "polygon" 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) { function latlngEqual(latlng1, latlng2, epsilon = 1e-9) {
@@ -27,17 +15,17 @@ function latlngEqual(latlng1, latlng2, epsilon = 1e-9) {
/* -------------------------------- Circle zones -------------------------------- */ /* -------------------------------- Circle zones -------------------------------- */
const defaultCircleSettings = {type: zoneTypes.circle, min: null, max: null, reductionCount: 4, duration: 10} const defaultCircleSettings = {type: ZONE_TYPES.CIRCLE, min: null, max: null, reductionCount: 4, duration: 10}
function circleZone(center, radius, duration) { function circleZone(center, radius, duration) {
return { return {
type: zoneTypes.circle, type: ZONE_TYPES.CIRCLE,
center: center, center: center,
radius: radius, radius: radius,
duration: duration, duration: duration,
isInZone(location) { isInZone(location) {
return haversine_distance(center, location) < this.radius; return haversineDistance(center, location) < this.radius;
} }
} }
} }
@@ -46,7 +34,7 @@ function circleSettingsToZones(settings) {
const {min, max, reductionCount, duration} = settings; const {min, max, reductionCount, duration} = settings;
if (!min || !max) return []; if (!min || !max) return [];
if (haversine_distance(max.center, min.center) > max.radius - min.radius) return []; if (haversineDistance(max.center, min.center) > max.radius - min.radius) return [];
const zones = [circleZone(max.center, max.radius, duration)]; const zones = [circleZone(max.center, max.radius, duration)];
const radiusReductionLength = (max.radius - min.radius) / reductionCount; const radiusReductionLength = (max.radius - min.radius) / reductionCount;
@@ -56,7 +44,7 @@ function circleSettingsToZones(settings) {
for (let i = 1; i < reductionCount; i++) { for (let i = 1; i < reductionCount; i++) {
radius -= radiusReductionLength; radius -= radiusReductionLength;
let new_center = null; let new_center = null;
while (!new_center || haversine_distance(new_center, min.center) > radius - min.radius) { while (!new_center || haversineDistance(new_center, min.center) > radius - min.radius) {
const angle = Math.random() * 2 * Math.PI; const angle = Math.random() * 2 * Math.PI;
const angularDistance = Math.sqrt(Math.random()) * radiusReductionLength / EARTH_RADIUS; const angularDistance = Math.sqrt(Math.random()) * radiusReductionLength / EARTH_RADIUS;
const lat0Rad = center.lat * Math.PI / 180; const lat0Rad = center.lat * Math.PI / 180;
@@ -83,11 +71,11 @@ function circleSettingsToZones(settings) {
/* -------------------------------- Polygon zones -------------------------------- */ /* -------------------------------- Polygon zones -------------------------------- */
const defaultPolygonSettings = {type: zoneTypes.polygon, polygons: []} const defaultPolygonSettings = {type: ZONE_TYPES.POLYGON, polygons: []}
function polygonZone(polygon, duration) { function polygonZone(polygon, duration) {
return { return {
type: zoneTypes.polygon, type: ZONE_TYPES.POLYGON,
polygon: polygon, polygon: polygon,
duration: duration, duration: duration,
@@ -227,10 +215,10 @@ export default {
changeSettings(settings) { changeSettings(settings) {
switch (settings.type) { switch (settings.type) {
case zoneTypes.circle: case ZONE_TYPES.CIRCLE:
this.zones = circleSettingsToZones(settings); this.zones = circleSettingsToZones(settings);
break; break;
case zoneTypes.polygon: case ZONE_TYPES.POLYGON:
this.zones = polygonSettingsToZones(settings); this.zones = polygonSettingsToZones(settings);
break; break;
default: default:
@@ -250,7 +238,5 @@ export default {
end: this.getNextZone(), end: this.getNextZone(),
endDate:this.currentZone.endDate, endDate:this.currentZone.endDate,
}; };
playersBroadcast("current_zone", zone);
secureAdminBroadcast("current_zone", zone);
}, },
} }

View File

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

View File

@@ -1,4 +1,3 @@
import { io } from "./index.js";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { config } from "dotenv"; import { config } from "dotenv";
import game from "./game.js" import game from "./game.js"
@@ -16,7 +15,7 @@ export function secureAdminBroadcast(event, data) {
let loggedInSockets = []; let loggedInSockets = [];
export function initAdminSocketHandler() { export function initAdminSocketHandler(io) {
io.of("admin").on("connection", (socket) => { io.of("admin").on("connection", (socket) => {
console.log("Connection of an admin"); console.log("Connection of an admin");
let loggedIn = false; let loggedIn = false;

View File

@@ -409,11 +409,9 @@ export default {
// Update of currentLocation // Update of currentLocation
team.currentLocation = location; team.currentLocation = location;
team.lastCurrentLocationDate = dateNow; team.lastCurrentLocationDate = dateNow;
// If hasHandicap
if (this.state == GameState.PLAYING && team.hasHandicap) { if (this.state == GameState.PLAYING && team.hasHandicap) {
team.lastSentLocation = team.currentLocation; team.lastSentLocation = team.currentLocation;
}
// Update of enemyLocation
if (this.state == GameState.PLAYING && enemyTeam.hasHandicap) {
team.enemyLocation = enemyTeam.currentLocation; team.enemyLocation = enemyTeam.currentLocation;
} }
// Update of ready // Update of ready

View File

@@ -3,7 +3,6 @@ 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. 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 This module also exposes functions to send messages via socket to all teams
*/ */
import { io } from "./index.js";
import game from "./game.js"; import game from "./game.js";
import zoneManager from "./zone_manager.js"; import zoneManager from "./zone_manager.js";
@@ -63,7 +62,7 @@ export function sendUpdatedTeamInformations(teamId) {
}); });
} }
export function initTeamSocket() { export function initTeamSocket(io) {
io.of("player").on("connection", (socket) => { io.of("player").on("connection", (socket) => {
console.log("Connection of a player"); console.log("Connection of a player");
let teamId = null; let teamId = null;

View File

@@ -1,11 +1,8 @@
/*
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 multer from "multer";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import game from "./game.js"; import { gameManager } from "@/core/game_manager.js"
const UPLOAD_DIR = path.join(process.cwd(), "uploads"); const UPLOAD_DIR = path.join(process.cwd(), "uploads");
const IMAGES_DIR = path.join(process.cwd(), "assets", "images"); const IMAGES_DIR = path.join(process.cwd(), "assets", "images");
const ALLOWED_MIME = [ const ALLOWED_MIME = [
@@ -22,7 +19,10 @@ const storage = multer.diskStorage({
}, },
// Save the file with the team ID as the filename // Save the file with the team ID as the filename
filename: function (req, file, callback) { filename: function (req, file, callback) {
callback(null, req.query.team); const teamId = req.query.team;
if (typeof teamId === 'string') {
callback(null, teamId);
}
} }
}); });
@@ -32,53 +32,55 @@ const upload = multer({
fileFilter: function (req, file, callback) { fileFilter: function (req, file, callback) {
if (ALLOWED_MIME.indexOf(file.mimetype) == -1) { if (ALLOWED_MIME.indexOf(file.mimetype) == -1) {
callback(null, false); callback(null, false);
} else if (!game.getTeam(req.query.team)) { } else if (!gameManager.teams.get(req.query.team)) {
callback(null, false); callback(null, false);
} else { } else {
callback(null, true); callback(null, true);
} }
} }
}) });
// Clean the uploads directory const clean = () => {
function clean() {
const files = fs.readdirSync(UPLOAD_DIR); const files = fs.readdirSync(UPLOAD_DIR);
for (const file of files) { for (const file of files) {
const filePath = path.join(UPLOAD_DIR, file); const filePath = path.join(UPLOAD_DIR, file);
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
} }
} };
export function initPhotoUpload() { export const initPhotoUpload = (app) => {
clean(); clean();
// App handler for uploading a photo and saving it to a file // App handler for uploading a photo and saving it to a file
app.post("/upload", upload.single('file'), (req, res) => { app.post("/upload", upload.single('file'), (req, res) => {
res.set("Access-Control-Allow-Origin", "*"); res.set("Access-Control-Allow-Origin", "*");
console.log("upload", req.query) console.log("upload", req.query);
res.send("") res.send("");
}) });
// App handler for serving the photo of a team given its secret ID // App handler for serving the photo of a team given its secret ID
app.get("/photo/my", (req, res) => { app.get("/photo/my", (req, res) => {
let team = game.getTeam(req.query.team); let team = gameManager.teams.get(req.query.team);
if (team) { if (team) {
const imagePath = path.join(UPLOAD_DIR, team.id); const imagePath = path.join(UPLOAD_DIR, team.id);
res.set("Content-Type", "image/png") res.set("Content-Type", "image/png");
res.set("Access-Control-Allow-Origin", "*"); res.set("Access-Control-Allow-Origin", "*");
res.sendFile(fs.existsSync(imagePath) ? imagePath : path.join(IMAGES_DIR, "missing_image.jpg")); res.sendFile(fs.existsSync(imagePath) ? imagePath : path.join(IMAGES_DIR, "missing_image.jpg"));
} else { } else {
res.status(400).send("Team not found") 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 handler for serving the photo of the team chased by the team given by its secret ID
app.get("/photo/enemy", (req, res) => { app.get("/photo/enemy", (req, res) => {
let team = game.getTeam(req.query.team); let team = gameManager.teams.get(req.query.team);
if (team) { if (team) {
const imagePath = path.join(UPLOAD_DIR, team.chasing); const imagePath = path.join(UPLOAD_DIR, team.chasing);
res.set("Content-Type", "image/png") res.set("Content-Type", "image/png");
res.set("Access-Control-Allow-Origin", "*"); res.set("Access-Control-Allow-Origin", "*");
res.sendFile(fs.existsSync(imagePath) ? imagePath : path.join(IMAGES_DIR, "missing_image.jpg")); res.sendFile(fs.existsSync(imagePath) ? imagePath : path.join(IMAGES_DIR, "missing_image.jpg"));
} else { } else {
res.status(400).send("Team not found") res.status(400).send("Team not found");
}
})
} }
});
};

View File

@@ -0,0 +1,124 @@
import { createHash } from "crypto";
import { gameManager } from "@/core/game_manager.js"
import { ADMIN_PASSWORD_HASH } from "@/util/util.js";
export const EVENTS = {
INTERNAL: {
TEAMS_UPDATE: "teams-update",
SETTINGS: "settings",
},
IN: {
LOGIN: "login",
LOGOUT: "logout",
STATE: "state",
SETTINGS: "settings",
ADD_TEAM: "add-team",
REMOVE_TEAM: "remove-team",
REORDER_TEAM: "reorder-team",
ELIMINATE_TEAM: "eliminate-team",
REVIVE_TEAM: "revive-team",
},
OUT: {
TEAMS_UPDATE: "teams-update", // TODO : Too big ? (Send only the changing teams ?)
SETTINGS: "settings",
},
};
const ADMIN_ROOM = "admin_room";
export function initAdminSocketHandler(io) {
// Util
const emit = (targetId, event, data) => {
io.of("admin").to(targetId).emit(event, data);
};
const broadcast = (event, data) => {
io.of("admin").to(ADMIN_ROOM).emit(event, data);
};
// Admin events
io.of("admin").on("connection", (socket) => {
console.log("Connection of an admin");
// Variables
let loggedIn = false;
// Util
const logout = () => {
if (!loggedIn) return;
loggedIn = false;
socket.leave(ADMIN_ROOM);
}
const login = (password) => {
if (loggedIn) return;
if (createHash('sha256').update(password).digest('hex') !== ADMIN_PASSWORD_HASH) return false;
loggedIn = true;
socket.join(ADMIN_ROOM);
}
// Socket
socket.on("disconnect", () => {
console.log("Disconnection of an admin");
logout();
});
// Authentication
socket.on(EVENTS.IN.LOGIN, (password) => {
login(password);
});
socket.on(EVENTS.IN.LOGOUT, () => {
logout();
});
// Actions
socket.on(EVENTS.IN.ADD_TEAM, (teamId) => {
if (!loggedIn) return;
gameManager.addTeam(teamId);
});
socket.on(EVENTS.IN.REMOVE_TEAM, (teamId) => {
if (!loggedIn) return;
gameManager.removeTeam(teamId);
});
socket.on(EVENTS.IN.REORDER_TEAM, (teamId) => {
if (!loggedIn) return;
gameManager.reorderTeam(teamId);
});
socket.on(EVENTS.IN.ELIMINATE_TEAM, (teamId) => {
if (!loggedIn) return;
gameManager.eliminate(teamId);
});
socket.on(EVENTS.IN.REVIVE_TEAM, (teamId) => {
if (!loggedIn) return;
gameManager.revive(teamId);
});
socket.on(EVENTS.IN.STATE, (state) => {
if (!loggedIn) return;
gameManager.setState(state);
});
socket.on(EVENTS.IN.SETTINGS, (settings) => {
if (!loggedIn) return;
gameManager.setSettings(settings);
});
});
}

View File

@@ -0,0 +1,111 @@
import { gameManager } from "@/core/game_manager.js";
export const EVENTS = {
INTERNAL: {
LOGOUT: "logout",
TEAM_UPDATE: "team-update",
},
IN: {
LOGIN: "login",
LOGOUT: "logout",
LOCATION: "location",
SCAN: "scan",
CAPTURE: "capture",
DEVICE: "device",
},
OUT: {
LOGOUT: "logout",
TEAM_UPDATE: "team-update",
},
};
export function initPlayerSocketHandler(io) {
// Util
const emit = (targetId, event, data) => {
io.of("player").to(targetId).emit(event, data);
};
// Game manager events
gameManager.on(EVENTS.INTERNAL.LOGOUT, (targetId) => {
emit(targetId, EVENTS.OUT.LOGOUT);
});
gameManager.on(EVENTS.INTERNAL.TEAM_UPDATE, (targetId, playTeamData) => {
emit(targetId, EVENTS.OUT.TEAM_UPDATE, playTeamData);
});
// Player events
io.of("player").on("connect", (socket) => {
console.log("Connection of a player");
// Variables
let teamId = null;
// Util
const isLoggedIn = () => {
return teamId !== null;
}
const logout = () => {
if (!isLoggedIn()) return;
socket.leave(teamId);
teamId = null;
}
const login = (loginTeamId) => {
if (!gameManager.teams.has(loginTeamId) || teamId === loginTeamId) return;
logout();
teamId = loginTeamId
socket.join(teamId);
}
// Socket
socket.on("disconnect", () => {
console.log("Disconnection of a player");
logout();
});
// Authentication
socket.on(EVENTS.IN.LOGIN, (loginTeamId, callback) => {
login(loginTeamId);
callback(isLoggedIn());
if (isLoggedIn()) gameManager.emitTeamUpdate(socket.id, gameManager.teams.get(teamId));
});
socket.on(EVENTS.IN.LOGOUT, () => {
logout();
});
// Actions
socket.on(EVENTS.IN.LOCATION, (coords) => {
if (!isLoggedIn()) return;
gameManager.updateLocation(teamId, coords);
});
socket.on(EVENTS.IN.SCAN, (coords) => {
if (!isLoggedIn()) return;
gameManager.scan(teamId, coords);
});
socket.on(EVENTS.IN.CAPTURE, (captureCode, callback) => {
if (!isLoggedIn()) return;
const success = gameManager.capture(teamId, captureCode);
callback(success);
});
});
}

View File

@@ -0,0 +1,19 @@
import { GameState } from "@/states/game_state.js";
import { DefaultTeamMapper } from "@/team/mapper/default_team_mapper.js";
export class DefaultState extends GameState {
constructor(manager) {
super(manager, new DefaultTeamMapper());
}
static get stateName () {
return "default";
}
// State functions
updateLocation(team, coords) {
team.updateLocation(coords);
return true;
}
}

View File

@@ -0,0 +1,19 @@
import { GameState } from "@/states/game_state.js";
import { FinishedTeamMapper } from "@/team/mapper/finished_team_mapper.js";
export class FinishedState extends GameState {
constructor(manager) {
super(manager, new FinishedTeamMapper());
}
static get stateName () {
return "finished";
}
// State functions
updateLocation(team, coords) {
team.updateLocation(coords);
return true;
}
}

View File

@@ -0,0 +1,36 @@
export class GameState {
constructor(manager, teamMapper) {
this.manager = manager;
this.teamMapper = teamMapper;
}
// Life cycle
initTeamContext(_team) {}
enter() {
this.manager.teams.forEach(team => this.initTeamContext(team));
}
clearTeamContext(_team) {}
exit() {
this.manager.teams.forEach(team => this.clearTeamContext(team));
}
// Hooks
onTeamOrderChange() {}
// Mappers
getTeamMapForTeam(team) {
return this.teamMapper.mapForTeam(team);
}
getTeamMapForAdmin(team) {
return this.teamMapper.mapForAdmin(team);
}
}

View File

@@ -0,0 +1,36 @@
import { haversineDistance } from "@/util/util.js";
import { GameState } from "@/states/game_state.js";
import { PlacementTeamMapper } from "@/team/mapper/placement_team_mapper.js";
export class PlacementState extends GameState {
constructor(manager) {
super(manager, new PlacementTeamMapper());
}
static get stateName () {
return "placement";
}
// Life cycle
initTeamContext(team) {
team.context = {
placementZone: null,
isInPlacementZone: true,
};
this.manager.emitTeamUpdate(team.id, team);
}
// State functions
updateLocation(team, coords) {
team.updateLocation(coords);
team.context.isInPlacementZone = (
team.context.placementZone ? haversineDistance(team.location, team.context.placementZone.center) < team.context.placementZone.radius : true
);
return true;
}
}

View File

@@ -0,0 +1,174 @@
import zoneManager from "@/core/zone_manager.js";
import { randint } from "@/util/util.js";
import { GameState } from "@/states/game_state.js";
import { FinishedState } from "./finished_state.js";
import { TimeoutManager } from "@/util/timeout_manager.js";
import { PlayingTeamMapper } from "@/team/mapper/playing_team_mapper.js";
const getNewCaptureCode = () => {
const codeLength = 4;
return randint(10 ** codeLength).toString().padStart(codeLength, '0');
};
export class PlayingState extends GameState {
constructor(manager) {
super(manager, new PlayingTeamMapper());
}
static get stateName () {
return "playing";
}
// Life cycle
initTeamContext(team) {
team.context = {
// Team
scanLocation: team.location,
captureCode: getNewCaptureCode(),
// Booleans
isEliminated: false,
isOutOfZone: false,
hasHandicap: false,
// Timeouts
scanTimeout: new TimeoutManager(() => this.scan(team.id), this.manager.settings.scanDelay, true),
outOfZoneTimeout: new TimeoutManager(() => this.addHandicap(team.id), this.manager.settings.outOfZoneDelay, true),
// Hunter and target
hunter: null,
target: null,
targetScanLocation: { coords: null, timesptamp: null },
};
this.manager.emitTeamUpdate(team.id, team);
}
enter() {
super.enter();
this.onTeamOrderChange();
zoneManager.start();
}
clearTeamContext(team) {
team.context.scanTimeout.clear();
team.context.outOfZoneTimeout.clear();
}
exit() {
super.exit();
zoneManager.stop();
}
// Hooks
onTeamOrderChange() {
const playingTeamsOrder = this.manager.teams.order.filter(team => !team.context.isEliminated);
const length = playingTeamsOrder.length;
playingTeamsOrder.forEach((team, i) => {
const hunter = this.manager.teams.get(playingTeamsOrder[(i+length-1) % length]);
const target = this.manager.teams.get(playingTeamsOrder[(i+1) % length]);
let hasChanged = false;
if (!team.context.hunter.equals(hunter)) {
team.context.hunter = hunter;
hasChanged = true;
}
if (!team.context.target.equals(target)) {
team.context.target = target;
team.context.targetScanLocation = target.context.location;
hasChanged = true;
}
if (hasChanged) {
this.manager.emitTeamUpdate(team.id, team);
}
});
if (this.manager.teams.order.filter(team => !team.context.isEliminated).length <= 2) {
this.manager.setState(FinishedState);
}
return true;
}
// State functions
eliminate(team) {
if (team.context.isEliminated) return false;
this.clearTeamContext(team);
team.context.isEliminated = true;
this.onTeamOrderChange();
return true;
}
revive(team) {
if (!team.context.isEliminated) return false;
this.initTeamContext(team);
this.onTeamOrderChange();
return true;
}
addHandicap(team) {
if (team.context.hasHandicap) return false;
team.context.hasHandicap = true;
team.context.scanTimeout.clear();
this.manager.emitTeamUpdate(team.id, team);
return true;
}
clearHandicap(team) {
if (!team.context.hasHandicap) return false;
team.context.hasHandicap = false;
team.context.scanTimeout.set();
this.manager.emitTeamUpdate(team.id, team);
return true;
}
scan(team) {
if (team.context.hasHandicap || team.context.isEliminated) return false;
team.context.scanLocation = team.location;
team.context.targetScanLocation = team.context.target.context.scanLocation;
team.context.scanTimeout.set();
this.manager.emitTeamUpdate(team.id, team);
return true;
}
capture(team, captureCode) {
if (team.context.hasHandicap || team.context.isEliminated) return false;
if (captureCode != team.context.target.context.captureCode) return false;
this.eliminate(team.context.target);
return true;
}
updateLocation(team, coords) {
team.updateLocation(coords);
const isOutOfZone = !zoneManager.isInZone(team.location);
// Exit zone case
if (isOutOfZone && !team.context.isOutOfZone) {
team.context.isOutOfZone = true;
team.context.outOfZoneTimeout.set();
this.manager.emitTeamUpdate(team.id, team);
// Enter zone case
} else if (!isOutOfZone && team.context.isOutOfZone) {
team.context.isOutOfZone = false;
team.context.outOfZoneTimeout.clear();
this.clearHandicap(team);
this.manager.emitTeamUpdate(team.id, team);
}
return true;
}
}

View File

@@ -0,0 +1,20 @@
import { TeamMapper } from "./team_mapper.js";
import { DefaultState } from "@/states/default_state.js";
export class DefaultTeamMapper extends TeamMapper {
mapForTeam(team) {
return {
...super.mapForTeam(team),
state: DefaultState.stateName,
context: {}
};
}
mapForAdmin(team) {
return {
...super.mapForAdmin(team),
state: DefaultState.stateName,
context: {}
};
}
}

View File

@@ -0,0 +1,20 @@
import { TeamMapper } from "./team_mapper.js";
import { FinishedState } from "@/states/finished_state.js";
export class FinishedTeamMapper extends TeamMapper {
mapForTeam(team) {
return {
...super.mapForTeam(team),
state: FinishedState.stateName,
context: {}
};
}
mapForAdmin(team) {
return {
...super.mapForAdmin(team),
state: FinishedState.stateName,
context: {}
};
}
}

View File

@@ -0,0 +1,26 @@
import { TeamMapper } from "./team_mapper.js";
import { PlacementState } from "@/states/placement_state.js";
export class PlacementTeamMapper extends TeamMapper {
mapForTeam(team) {
return {
...super.mapForTeam(team),
state: PlacementState.stateName,
context: {
placementZone: team.context.placementZone,
isInPlacementZone: team.context.isInPlacementZone,
}
};
}
mapForAdmin(team) {
return {
...super.mapForAdmin(team),
state: PlacementState.stateName,
context: {
placementZone: team.context.placementZone,
isInPlacementZone: team.context.isInPlacementZone,
}
};
}
}

View File

@@ -0,0 +1,56 @@
import { TeamMapper } from "./team_mapper.js";
import { PlayingState } from "@/states/playing_state.js";
import zoneManager from "@/core/zone_manager.js";
export class PlayingTeamMapper extends TeamMapper {
mapForTeam(team) {
return {
...super.mapForTeam(team),
state: PlayingState.stateName,
context: {
// Team
captureCode: team.context.captureCode,
scanLocation: team.context.scanLocation,
// Booleans
isEliminated: team.context.isEliminated,
isOutOfZone: team.context.isOutOfZone,
hasHandicap: team.context.hasHandicap,
// Timeouts
scanRemainingTime: team.context.scanTimeout.remainingTime,
outOfZoneRemainingTime: team.context.outOfZoneTimeout.remainingTime,
// Target
targetName: team.context.target?.name,
targetScanLocation: team.context.targetScanLocation,
targetHasHandicap: team.context.hunter?.context.hasHandicap,
// Zone
zoneType: zoneManager.settings.zoneType,
zoneCurrent: zoneManager.getCurrentZone(),
zoneNext: zoneManager.getNextZone(),
zoneRemainingTime: zoneManager.currentZone?.endDate ? Math.max(0, zoneManager.currentZone.endDate - Date.now()) : null
}
};
}
mapForAdmin(team) {
return {
...super.mapForAdmin(team),
state: PlayingState.stateName,
context: {
// Team
captureCode: team.context.captureCode,
scanLocation: team.context.scanLocation,
// Booleans
isEliminated: team.context.isEliminated,
isOutOfZone: team.context.isOutOfZone,
hasHandicap: team.context.hasHandicap,
// Timeouts
scanRemainingTime: team.context.scanTimeout.remainingTime,
outOfZoneRemainingTime: team.context.outOfZoneTimeout.remainingTime,
// Target and hunter
hunterName: team.context.hunter.name,
targetName: team.context.target.name,
targetScanLocation: team.context.targetScanLocation,
}
};
}
}

View File

@@ -0,0 +1,23 @@
import { GameState } from "@/states/game_state.js";
export class TeamMapper {
mapForTeam(team) {
return {
id: team.id,
name: team.name,
state: GameState.stateName,
context: {}
};
}
mapForAdmin(team) {
return {
id: team.id,
name: team.name,
location: team.location,
connectedPlayerCount: team.connectedPlayerCount,
state: GameState.stateName,
context: {}
};
}
}

View File

@@ -0,0 +1,19 @@
export class Team {
constructor(id, teamName) {
// Identity
this.id = id;
this.name = teamName;
// Location
this.location = { coords: null, timestamp: null };
// Context
this.context = {};
}
updateLocation(coords) {
this.location = { coords: coords, timestamp: Date.now()}
}
equals(team) {
return this.id === team.id;
}
}

View File

@@ -0,0 +1,33 @@
export class CircularMap extends Map {
constructor(entries) {
super(entries);
this.order = Array.from(this.keys());
}
set(key, value) {
if (!super.has(key)) {
this.order.push(key);
}
return super.set(key, value);
}
delete(key) {
if (super.delete(key)) {
this.order = this.order.filter(k => k !== key);
return true;
}
return false;
}
clear() {
this.order = [];
super.clear();
}
reorder(newOrder) {
const isValid = newOrder.length === this.size && new Set([...this.order, ...newOrder]).size === this.size;
if (!isValid) return false;
this.order = newOrder;
return true;
}
}

View File

@@ -0,0 +1,26 @@
export class TimeoutManager {
constructor(callback, delay, set = false) {
this.callback = callback;
this.delay = delay;
this.id = null;
this.date = null;
if (set) this.set();
}
get remainingTime() {
if (!this.id) return null;
return Math.max(0, this.date - Date.now());
}
set() {
if (this.id) clearTimeout(this.id);
this.id = setTimeout(this.callback, this.delay);
this.date = Date.now() + this.delay;
}
clear() {
if (this.id) clearTimeout(this.id);
this.id = null;
this.date = null;
}
}

View File

@@ -0,0 +1,22 @@
import { config } from "dotenv";
config();
export const ADMIN_PASSWORD_HASH = process.env.ADMIN_PASSWORD_HASH || "";
export const HOST = process.env.HOST || "0.0.0.0";
export const PORT = Number(process.env.PORT) || 3000;
export const EARTH_RADIUS = 6_371_000; // Radius of the earth in meters
export const randint = (max) => {
return Math.floor(Math.random() * max);
}
export const haversineDistance = ({ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }) => {
// Return the distance in meters between the two points of coordinates
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);
return 2 * EARTH_RADIUS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}