diff --git a/mobile/docs/TODO.md b/mobile/docs/TODO.md index 03328eb..a0ff0d3 100644 --- a/mobile/docs/TODO.md +++ b/mobile/docs/TODO.md @@ -12,6 +12,8 @@ - [x] Indiquer que l'équipe est hors zone. - [x] Mettre les stats dans le tiroir (distance, temps, vitesse moy, nb captures, nb envoi) - [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) - [ ] 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) diff --git a/server/traque-back/jsconfig.json b/server/traque-back/jsconfig.json new file mode 100644 index 0000000..c366bba --- /dev/null +++ b/server/traque-back/jsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ESNext", + "checkJs": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/server/traque-back/src/core/game_manager.js b/server/traque-back/src/core/game_manager.js new file mode 100644 index 0000000..3566937 --- /dev/null +++ b/server/traque-back/src/core/game_manager.js @@ -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(); diff --git a/server/traque-back/src/zone_manager.js b/server/traque-back/src/core/zone_manager.js similarity index 83% rename from server/traque-back/src/zone_manager.js rename to server/traque-back/src/core/zone_manager.js index 252cfd3..beeb1ad 100644 --- a/server/traque-back/src/zone_manager.js +++ b/server/traque-back/src/core/zone_manager.js @@ -1,23 +1,11 @@ -import { playersBroadcast } from './team_socket.js'; -import { secureAdminBroadcast } from './admin_socket.js'; +import { haversineDistance, EARTH_RADIUS } from "./util.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; +const ZONE_TYPES = { + CIRCLE: "circle", + POLYGON: "polygon" } function latlngEqual(latlng1, latlng2, epsilon = 1e-9) { @@ -27,17 +15,17 @@ function latlngEqual(latlng1, latlng2, epsilon = 1e-9) { /* -------------------------------- 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) { return { - type: zoneTypes.circle, + type: ZONE_TYPES.CIRCLE, center: center, radius: radius, duration: duration, 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; 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 radiusReductionLength = (max.radius - min.radius) / reductionCount; @@ -56,7 +44,7 @@ function circleSettingsToZones(settings) { 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) { + while (!new_center || haversineDistance(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; @@ -83,11 +71,11 @@ function circleSettingsToZones(settings) { /* -------------------------------- Polygon zones -------------------------------- */ -const defaultPolygonSettings = {type: zoneTypes.polygon, polygons: []} +const defaultPolygonSettings = {type: ZONE_TYPES.POLYGON, polygons: []} function polygonZone(polygon, duration) { return { - type: zoneTypes.polygon, + type: ZONE_TYPES.POLYGON, polygon: polygon, duration: duration, @@ -227,10 +215,10 @@ export default { changeSettings(settings) { switch (settings.type) { - case zoneTypes.circle: + case ZONE_TYPES.CIRCLE: this.zones = circleSettingsToZones(settings); break; - case zoneTypes.polygon: + case ZONE_TYPES.POLYGON: this.zones = polygonSettingsToZones(settings); break; default: @@ -250,7 +238,5 @@ export default { end: this.getNextZone(), endDate:this.currentZone.endDate, }; - playersBroadcast("current_zone", zone); - secureAdminBroadcast("current_zone", zone); }, } diff --git a/server/traque-back/src/index.js b/server/traque-back/src/index.js index 000a26e..d91b149 100644 --- a/server/traque-back/src/index.js +++ b/server/traque-back/src/index.js @@ -1,30 +1,24 @@ 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"; +import { initAdminSocketHandler } from "@/socket/adminHandler.js"; +import { initPlayerSocketHandler } from "@/socket/playerHandler.js"; +import { initPhotoUpload } from "./services/photo.js"; +import { PORT, HOST } from "@/util/util.js"; -config(); -const HOST = process.env.HOST; -const PORT = process.env.PORT; +// --- Configuration --- +const app = express(); +const httpServer = createServer(app); +const io = new Server(httpServer, { + cors: { origin: "*", methods: ["GET", "POST"] } +}); -export const app = express(); - -const httpServer = createServer({}, app); +// --- Initialization --- +initPhotoUpload(app); +initAdminSocketHandler(io); +initPlayerSocketHandler(io); +// --- Server Start --- 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(); diff --git a/server/traque-back/src/admin_socket.js b/server/traque-back/src/old/admin_socket.js similarity index 97% rename from server/traque-back/src/admin_socket.js rename to server/traque-back/src/old/admin_socket.js index d5a2c9f..55c4334 100644 --- a/server/traque-back/src/admin_socket.js +++ b/server/traque-back/src/old/admin_socket.js @@ -1,4 +1,3 @@ -import { io } from "./index.js"; import { createHash } from "crypto"; import { config } from "dotenv"; import game from "./game.js" @@ -16,7 +15,7 @@ export function secureAdminBroadcast(event, data) { let loggedInSockets = []; -export function initAdminSocketHandler() { +export function initAdminSocketHandler(io) { io.of("admin").on("connection", (socket) => { console.log("Connection of an admin"); let loggedIn = false; diff --git a/server/traque-back/src/game.js b/server/traque-back/src/old/game.js similarity index 99% rename from server/traque-back/src/game.js rename to server/traque-back/src/old/game.js index f2a7c12..b087df4 100644 --- a/server/traque-back/src/game.js +++ b/server/traque-back/src/old/game.js @@ -409,11 +409,9 @@ export default { // Update of currentLocation team.currentLocation = location; team.lastCurrentLocationDate = dateNow; + // If hasHandicap 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 diff --git a/server/traque-back/src/team_socket.js b/server/traque-back/src/old/team_socket.js similarity index 98% rename from server/traque-back/src/team_socket.js rename to server/traque-back/src/old/team_socket.js index 734c8ea..5cc4520 100644 --- a/server/traque-back/src/team_socket.js +++ b/server/traque-back/src/old/team_socket.js @@ -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. 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"; @@ -63,7 +62,7 @@ export function sendUpdatedTeamInformations(teamId) { }); } -export function initTeamSocket() { +export function initTeamSocket(io) { io.of("player").on("connection", (socket) => { console.log("Connection of a player"); let teamId = null; diff --git a/server/traque-back/src/timeout_handler.js b/server/traque-back/src/old/timeout_handler.js similarity index 100% rename from server/traque-back/src/timeout_handler.js rename to server/traque-back/src/old/timeout_handler.js diff --git a/server/traque-back/src/trajectory.js b/server/traque-back/src/old/trajectory.js similarity index 100% rename from server/traque-back/src/trajectory.js rename to server/traque-back/src/old/trajectory.js diff --git a/server/traque-back/src/photo.js b/server/traque-back/src/services/photo.js similarity index 65% rename from server/traque-back/src/photo.js rename to server/traque-back/src/services/photo.js index cc4e37a..16beb73 100644 --- a/server/traque-back/src/photo.js +++ b/server/traque-back/src/services/photo.js @@ -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 fs from "fs"; 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 IMAGES_DIR = path.join(process.cwd(), "assets", "images"); const ALLOWED_MIME = [ @@ -22,7 +19,10 @@ const storage = multer.diskStorage({ }, // Save the file with the team ID as the filename 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) { if (ALLOWED_MIME.indexOf(file.mimetype) == -1) { callback(null, false); - } else if (!game.getTeam(req.query.team)) { + } else if (!gameManager.teams.get(req.query.team)) { callback(null, false); } else { callback(null, true); } } -}) +}); -// Clean the uploads directory -function clean() { +const 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() { +export const initPhotoUpload = (app) => { 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) => { 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 + 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); + let team = gameManager.teams.get(req.query.team); if (team) { 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.sendFile(fs.existsSync(imagePath) ? imagePath : path.join(IMAGES_DIR, "missing_image.jpg")); } 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) => { - let team = game.getTeam(req.query.team); + let team = gameManager.teams.get(req.query.team); if (team) { 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.sendFile(fs.existsSync(imagePath) ? imagePath : path.join(IMAGES_DIR, "missing_image.jpg")); } else { - res.status(400).send("Team not found") + res.status(400).send("Team not found"); } - }) -} \ No newline at end of file + }); +}; diff --git a/server/traque-back/src/socket/adminHandler.js b/server/traque-back/src/socket/adminHandler.js new file mode 100644 index 0000000..a461f63 --- /dev/null +++ b/server/traque-back/src/socket/adminHandler.js @@ -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); + }); + }); +} diff --git a/server/traque-back/src/socket/playerHandler.js b/server/traque-back/src/socket/playerHandler.js new file mode 100644 index 0000000..940b72e --- /dev/null +++ b/server/traque-back/src/socket/playerHandler.js @@ -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); + }); + }); +} diff --git a/server/traque-back/src/states/default_state.js b/server/traque-back/src/states/default_state.js new file mode 100644 index 0000000..061d952 --- /dev/null +++ b/server/traque-back/src/states/default_state.js @@ -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; + } +} diff --git a/server/traque-back/src/states/finished_state.js b/server/traque-back/src/states/finished_state.js new file mode 100644 index 0000000..a2eb9f8 --- /dev/null +++ b/server/traque-back/src/states/finished_state.js @@ -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; + } +} diff --git a/server/traque-back/src/states/game_state.js b/server/traque-back/src/states/game_state.js new file mode 100644 index 0000000..7a64958 --- /dev/null +++ b/server/traque-back/src/states/game_state.js @@ -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); + } +} diff --git a/server/traque-back/src/states/placement_state.js b/server/traque-back/src/states/placement_state.js new file mode 100644 index 0000000..4c30fd6 --- /dev/null +++ b/server/traque-back/src/states/placement_state.js @@ -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; + } +} diff --git a/server/traque-back/src/states/playing_state.js b/server/traque-back/src/states/playing_state.js new file mode 100644 index 0000000..dce7adf --- /dev/null +++ b/server/traque-back/src/states/playing_state.js @@ -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; + } +} diff --git a/server/traque-back/src/team/mapper/default_team_mapper.js b/server/traque-back/src/team/mapper/default_team_mapper.js new file mode 100644 index 0000000..615d10b --- /dev/null +++ b/server/traque-back/src/team/mapper/default_team_mapper.js @@ -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: {} + }; + } +} diff --git a/server/traque-back/src/team/mapper/finished_team_mapper.js b/server/traque-back/src/team/mapper/finished_team_mapper.js new file mode 100644 index 0000000..00223a0 --- /dev/null +++ b/server/traque-back/src/team/mapper/finished_team_mapper.js @@ -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: {} + }; + } +} diff --git a/server/traque-back/src/team/mapper/placement_team_mapper.js b/server/traque-back/src/team/mapper/placement_team_mapper.js new file mode 100644 index 0000000..d50aeef --- /dev/null +++ b/server/traque-back/src/team/mapper/placement_team_mapper.js @@ -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, + } + }; + } +} diff --git a/server/traque-back/src/team/mapper/playing_team_mapper.js b/server/traque-back/src/team/mapper/playing_team_mapper.js new file mode 100644 index 0000000..39ed774 --- /dev/null +++ b/server/traque-back/src/team/mapper/playing_team_mapper.js @@ -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, + } + }; + } +} diff --git a/server/traque-back/src/team/mapper/team_mapper.js b/server/traque-back/src/team/mapper/team_mapper.js new file mode 100644 index 0000000..4a3a3c0 --- /dev/null +++ b/server/traque-back/src/team/mapper/team_mapper.js @@ -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: {} + }; + } +} diff --git a/server/traque-back/src/team/team.js b/server/traque-back/src/team/team.js new file mode 100644 index 0000000..f07e1d2 --- /dev/null +++ b/server/traque-back/src/team/team.js @@ -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; + } +} diff --git a/server/traque-back/src/util/circular_map.js b/server/traque-back/src/util/circular_map.js new file mode 100644 index 0000000..c4249ca --- /dev/null +++ b/server/traque-back/src/util/circular_map.js @@ -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; + } +} diff --git a/server/traque-back/src/util/timeout_manager.js b/server/traque-back/src/util/timeout_manager.js new file mode 100644 index 0000000..0d99756 --- /dev/null +++ b/server/traque-back/src/util/timeout_manager.js @@ -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; + } +} diff --git a/server/traque-back/src/util/util.js b/server/traque-back/src/util/util.js new file mode 100644 index 0000000..5aa97c5 --- /dev/null +++ b/server/traque-back/src/util/util.js @@ -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)); +}