Server heavy refactoring 2 (not functionnal)

This commit is contained in:
Sebastien Riviere
2026-03-02 01:33:20 +01:00
parent 24bce7896c
commit 8046feadb0
57 changed files with 5320 additions and 2172 deletions

3947
server/traque-back/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"license": "ISC", "license": "ISC",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@turf/turf": "^7.3.4",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",

View File

@@ -0,0 +1,36 @@
export const PLAYER_HANDLER_EVENTS = {
LOGIN: "login",
LOGOUT: "logout",
LOCATION: "location",
SCAN: "scan",
CAPTURE: "capture",
DEVICE: "device",
};
export const ADMIN_HANDLER_EVENTS = {
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",
};
export const GAME_MANAGER_EVENTS = {
INIT_PLAYER: "init-player",
INIT_ADMIN: "init-admin",
UPDATE_GAME: "update-game",
DELETE_TEAM: "delete-team",
};
export const PLAYER_SYNCHRONIZER_EVENTS = {
UPDATE_FULL: "update-full",
LOGOUT: "logout",
};
export const ADMIN_SYNCHRONIZER_EVENTS = {
UPDATE_FULL: "update-full",
};

View File

@@ -0,0 +1,44 @@
import { DEFAULT_ZONES_SETTINGS } from "@/config/zone.js";
import { DefaultState, PlacementState, PlayingState, FinishedState } from '@/core/states/game/index.js';
export const STATE_SETTINGS = {
ENTRY_STATE_CLASS: DefaultState,
TRANSITION_MATRIX: {
[DefaultState.name]: {
[DefaultState.name]: (state) => false,
[PlacementState.name]: (state) => state.canGameStart(),
[PlayingState.name]: (state) => false,
[FinishedState.name]: (state) => false,
},
[PlacementState.name]: {
[DefaultState.name]: (state) => true,
[PlacementState.name]: (state) => false,
[PlayingState.name]: (state) => true,
[FinishedState.name]: (state) => false,
},
[PlayingState.name]: {
[DefaultState.name]: (state) => true,
[PlacementState.name]: (state) => true,
[PlayingState.name]: (state) => false,
[FinishedState.name]: (state) => state.isGameOver(),
},
[FinishedState.name]: {
[DefaultState.name]: (state) => true,
[PlacementState.name]: (state) => false,
[PlayingState.name]: (state) => false,
[FinishedState.name]: (state) => false,
}
},
};
export const DEFAULT_GAME_SETTINGS = {
playingZones: DEFAULT_ZONES_SETTINGS.CIRCLE,
placementZones: {},
scanDelay: 10 * 60 * 1000, // ms
outOfZoneDelay: 5 * 60 * 1000 // ms
}
export const TEAM_ID_LENGTH = 6;
export const CAPTURE_CODE_LENGTH = 4;
export const RESTART_TIMERS = true;

View File

@@ -0,0 +1,6 @@
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;

View File

@@ -0,0 +1,13 @@
export const ZONE_TYPES = {
CIRCLE: "circle",
POLYGON: "polygon"
}
export const DEFAULT_ZONES_SETTINGS = {
CIRCLE: { type: ZONE_TYPES.CIRCLE, min: null, max: null, reductionCount: 5, duration: 20 * 60 * 1000 },
POLYGON: { type: ZONE_TYPES.POLYGON, polygons: [] },
};
export const TURF_DISTANCE_UNIT = 'meters';
export const TURF_CIRCLE_STEPS = 32;
export const TURF_BUFFER_SIZE = 1;

View File

@@ -0,0 +1,70 @@
import * as turf from '@turf/turf';
import { ZoneWithDuration } from '@/core/models/zone.js';
import { TURF_BUFFER_SIZE, TURF_CIRCLE_STEPS, TURF_DISTANCE_UNIT, ZONE_TYPES } from '@/config/zone.js';
export const settingsToZoneList = (settings) => {
if (!settings) return [];
switch (settings.type) {
case ZONE_TYPES.CIRCLE:
return circleSettingsToZoneList(settings);
case ZONE_TYPES.POLYGON:
return polygonSettingsToZoneList(settings);
default:
return [];
}
};
const circleSettingsToZoneList = ({ min, max, reductionCount, duration }) => {
if (min == null || max == null) return [];
const zones = [];
const add = (center, radius) => zones.push(new ZoneWithDuration(turf.circle(turf.point(center), radius, { steps: TURF_CIRCLE_STEPS, units: TURF_DISTANCE_UNIT }), duration));
// Add max zone
add(turf.point(max.center), max.radius);
// Add intermediates zones
const radiusReductionLength = (max.radius - min.radius) / reductionCount;
let center = turf.point(max.center);
let radius = max.radius;
for (let i = 1; i < reductionCount; i++) {
radius -= radiusReductionLength;
let tempCenter;
do {
const distance = radius * Math.sqrt(Math.random());
const bearing = Math.random() * 360;
tempCenter = turf.destination(center, distance, bearing, { units: 'meters' });
} while (turf.distance(tempCenter, turf.point(min.center)) > radius - min.radius);
center = tempCenter;
add(center, radius);
}
// Add min zone
add(turf.point(min.center), min.radius);
return zones;
};
const polygonSettingsToZoneList = ({ polygons }) => {
const polygonsCount = polygons.length;
if (polygonsCount === 0) return [];
// Convert polygons to turf polygon with a buffer
const bufferedPolygons = polygons.map(item => ({
polygon: turf.buffer(turf.polygon([[...item.polygon, item.polygon[0]]]), TURF_BUFFER_SIZE, { units: TURF_DISTANCE_UNIT }),
duration: item.duration
}));
const inversedZones = [bufferedPolygons[polygonsCount-1]];
for (let i = polygonsCount-2; 0 <= i; i--) {
const { polygon, duration } = bufferedPolygons[i];
const union = turf.union(turf.featureCollection([inversedZones[inversedZones.length-1].polygon, polygon]));
if (union && union.geometry.type === 'Polygon') inversedZones.push(new ZoneWithDuration(union, duration));
}
return [...inversedZones].reverse();
};

View File

@@ -0,0 +1,9 @@
import * as turf from '@turf/turf';
import { Zone } from '@/core/models/zone.js';
import { TURF_CIRCLE_STEPS, TURF_DISTANCE_UNIT } from '@/config/zone.js';
export const settingsToZone = (settings) => {
if (!settings) return null;
const { center, radius } = settings;
return new Zone(turf.circle(turf.point(center), radius, { steps: TURF_CIRCLE_STEPS, units: TURF_DISTANCE_UNIT }));
};

View File

@@ -1,140 +0,0 @@
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

@@ -0,0 +1,122 @@
import { EventEmitter } from 'events';
import { ZoneManager } from "@/core/managers/zone_manager.js";
import { TeamManager } from '@/core/managers/team_manager.js';
import { GAME_MANAGER_EVENTS } from "@/config/events.js";
export class GameManager extends EventEmitter {
constructor(stateSettings, gameSettings) {
super();
// Data
this.teams = new TeamManager();
this.zoneManager = new ZoneManager();
this.settings = gameSettings;
// State
this.state = null;
this.transitionMatrix = stateSettings.TRANSITION_MATRIX;
this.setState(stateSettings.ENTRY_STATE_CLASS);
}
// --------------- ACTIONS --------------- //
// State
setState(StateClass) {
if (!this._canTransitionTo(StateClass)) return;
this.state?.exit();
this.state = new StateClass(this.teams, this.zoneManager);
this.state.enter(this.settings);
this.emit(GAME_MANAGER_EVENTS.UPDATE_GAME);
}
// Settings
setSettings(settings) {
this.settings = settings;
this.state.applySettings(settings);
this.zoneManager.updateZones(settings.playingZones);
this.emit(GAME_MANAGER_EVENTS.UPDATE_GAME);
}
// Teams
addTeam(teamName) {
const team = this.teams.add(teamName);
if (team == null) return false;
this.state.initTeam(team);
this.emit(GAME_MANAGER_EVENTS.UPDATE_GAME);
return true;
}
removeTeam(teamId) {
if (!this.teams.has(teamId)) return false;
this.state.clearTeam(this.teams.get(teamId));
this.teams.delete(teamId);
this.emit(GAME_MANAGER_EVENTS.DELETE_TEAM, teamId);
this.emit(GAME_MANAGER_EVENTS.UPDATE_GAME);
return true;
}
reorderTeam(newTeamsOrder) {
if (!this.teams.reorder(newTeamsOrder)) return false;
this.emit(GAME_MANAGER_EVENTS.UPDATE_GAME);
return true;
}
// Team state
eliminate(teamId) {
return this._teamAction(teamId, "eliminate");
}
revive(teamId) {
return this._teamAction(teamId, "revive");
}
addHandicap(teamId) {
return this._teamAction(teamId, "addHandicap");
}
clearHandicap(teamId) {
return this._teamAction(teamId, "clearHandicap");
}
scan(teamId) {
return this._teamAction(teamId, "scan");
}
capture(teamId, captureCode) {
return this._teamAction(teamId, "capture", captureCode);
}
updateLocation(teamId, coords) {
return this._teamAction(teamId, "updateLocation", coords);
}
// --------------- OTHER --------------- //
// Login handlers
onPlayerLogin(socketId, teamId) {
this.emit(GAME_MANAGER_EVENTS.INIT_PLAYER, socketId, teamId);
}
onAdminLogin(socketId) {
this.emit(GAME_MANAGER_EVENTS.INIT_ADMIN, socketId);
}
// Util
_canTransitionTo(StateClass) {
return this.state === null || this.transitionMatrix[this.state.name][StateClass.name](this.state);
};
_teamAction(teamId, actionName, ...args) {
if (!this.teams.has(teamId) || typeof this.state[actionName] !== 'function') return false;
const success = this.state[actionName](this.teams.get(teamId), ...args);
this.emit(GAME_MANAGER_EVENTS.UPDATE_GAME);
return success;
}
}

View File

@@ -0,0 +1,59 @@
import { Team } from "@/core/models/team.js";
export class TeamManager {
constructor() {
this._map = new Map();
this.order = [];
}
// Read
get size() {
return this._map.size;
}
get(id) {
return this._map.get(id);
}
has(id) {
return this._map.has(id);
}
forEach(callback) {
for (const id of this.order) {
callback(this._map.get(id), id, this);
}
}
// Write
add(teamName) {
if (!Team.isTeamNameValid(teamName)) return null;
let id; do { id = Team.getNewTeamId() } while (this.has(id));
const team = new Team(id, teamName);
if (!this.has(id)) this.order.push(id);
this._map.set(id, team);
return id;
}
delete(id) {
if (!this._map.delete(id)) return false;
this.order = this.order.filter(i => i !== id);
return true;
}
clear() {
this.order = [];
this._map.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,73 @@
import { Scheduler } from "@/util/scheduler.js";
import { settingsToZoneList } from "@/core/factories/game_zone_factory.js";
export class ZoneManager {
constructor() {
this._zones = [];
this._currentZoneId = null;
this._scheduledZoneTransition = new Scheduler();
}
get firstZonePolygon() {
return this._zones[0]?.polygon ?? null;
}
get currentZonePolygon() {
return this._currentZone?.polygon ?? null;
}
get nextZonePolygon() {
if (!this._isActive) return null;
return this._zones[this._currentZoneId + 1]?.polygon ?? null;
}
get dateOfZoneTransition() {
return this._scheduledZoneTransition.dateOfExecution;
}
get timeToZoneTransition() {
return this._scheduledZoneTransition.timeToExecution;
}
start() {
this._jumpToNextZone();
}
stop() {
this._currentZoneId = null;
this._scheduledZoneTransition.interrupt();
}
isInZone(location) {
return this._isZonesEmpty || this._currentZone?.isInZone(location);
}
updateZones(settings) {
this._zones = settingsToZoneList(settings);
if (this._isActive) {
this.stop();
this.start();
}
}
get _currentZone() {
if (!this._isActive) return null;
return this._zones[this._currentZoneId].polygon;
}
get _isZonesEmpty() {
return this._zones.length === 0;
}
get _isActive() {
return this._currentZoneId !== null;
}
_jumpToNextZone() {
if (this._isZonesEmpty) return;
this._currentZoneId = this._isActive ? this._currentZoneId + 1 : 0;
if (this._currentZoneId + 1 < this._zones.length) this._scheduledZoneTransition.start(() => this._jumpToNextZone(), this._currentZone.duration);
}
}

View File

@@ -0,0 +1,32 @@
import { CAPTURE_CODE_LENGTH, TEAM_ID_LENGTH } from "@/config/game.js";
import { randint } from "@/util/random.js";
export class Team {
constructor(id, teamName) {
this.id = id;
this.name = teamName;
this.location = { coords: null, timestamp: null };
this.state = null;
}
static isTeamNameValid = (teamName) => {
return typeof teamName === 'string' && teamName.length > 0;
}
static getNewTeamId = () => {
return randint(10 ** TEAM_ID_LENGTH).toString().padStart(TEAM_ID_LENGTH, '0');
}
static getNewCaptureCode = () => {
return randint(10 ** CAPTURE_CODE_LENGTH).toString().padStart(CAPTURE_CODE_LENGTH, '0');
}
equals(team) {
return this.id === team.id;
}
updateLocation(coords) {
this.location = { coords: coords, timestamp: Date.now() };
return true;
}
}

View File

@@ -0,0 +1,28 @@
import * as turf from '@turf/turf';
export class Zone {
constructor(turfPolygon) {
this._turfPolygon = turfPolygon;
}
get polygon() {
// Return a [latitude, longitude] list
return this._turfPolygon.geometry.coordinates[0].slice(0, -1);
}
isInZone(location) {
// location : [latitude, longitude]
return turf.booleanPointInPolygon(turf.point(location), this._turfPolygon);
}
equals(zone) {
return turf.booleanEqual(this._turfPolygon, zone._turfPolygon);
}
};
export class ZoneWithDuration extends Zone {
constructor(turfPolygon, duration) {
super(turfPolygon);
this.duration = duration;
}
};

View File

@@ -0,0 +1,44 @@
import { DefaultTeam } from "@/core/states/teams/default_team.js";
export class DefaultState {
constructor(teams, zoneManager) {
this.teams = teams;
this.zoneManager = zoneManager;
}
static get name () {
return "default";
}
// --------------- LIFE CYCLE --------------- //
initTeam(team, settings) {
team.state = new DefaultTeam(team).init(settings);
}
enter(settings) {
this.teams.forEach(team => this.initTeam(team, settings));
}
clearTeam(_team) {}
exit() {
this.teams.forEach(team => this.clearTeam(team));
}
// --------------- ACTIONS --------------- //
updateLocation(team, coords) {
team.updateLocation(coords);
return true;
}
// --------------- OTHER --------------- //
canGameStart() {
return this.teams.size >= 3;
}
}

View File

@@ -0,0 +1,37 @@
import { FinishedTeam } from "@/core/states/teams/finished_team.js";
export class FinishedState {
constructor(teams, zoneManager) {
this.teams = teams;
this.zoneManager = zoneManager;
}
static get name () {
return "finished";
}
// --------------- LIFE CYCLE --------------- //
initTeam(team, settings) {
team.state = new FinishedTeam(team).init(settings);
}
enter(settings) {
this.teams.forEach(team => this.initTeam(team, settings));
}
clearTeam(_team) {}
exit() {
this.teams.forEach(team => this.clearTeam(team));
}
// --------------- ACTIONS --------------- //
updateLocation(team, coords) {
team.updateLocation(coords);
return true;
}
}

View File

@@ -0,0 +1,4 @@
export { DefaultState } from '@/core/states/game/default_state.js';
export { PlacementState } from '@/core/states/game/placement_state.js';
export { PlayingState } from '@/core/states/game/playing_state.js';
export { FinishedState } from '@/core/states/game/finished_state.js';

View File

@@ -0,0 +1,42 @@
import { PlacementTeam } from "@/core/states/teams/placement_team.js";
export class PlacementState {
constructor(teams, zoneManager) {
this.teams = teams;
this.zoneManager = zoneManager;
}
static get name () {
return "placement";
}
// --------------- LIFE CYCLE --------------- //
initTeam(team, settings) {
team.state = new PlacementTeam(team).init(settings);
}
enter(settings) {
this.teams.forEach(team => this.initTeam(team, settings));
}
clearTeam(_team) {}
exit() {
this.teams.forEach(team => this.clearTeam(team));
}
// --------------- ACTIONS --------------- //
applySettings(settings) {
this.teams.forEach(team => team.state.applySettings(settings));
return true;
}
updateLocation(team, coords) {
team.updateLocation(coords);
return true;
}
}

View File

@@ -0,0 +1,93 @@
import { PlayingTeam } from "@/core/states/teams/playing_team.js";
export class PlayingState {
constructor(teams, zoneManager) {
this.teams = teams;
this.zoneManager = zoneManager;
}
static get name () {
return "playing";
}
// --------------- LIFE CYCLE --------------- //
initTeam(team, settings) {
team.state = new PlayingTeam(team, this.zoneManager).init(settings);
}
enter(settings) {
this.teams.forEach(team => this.initTeam(team, settings));
this.zoneManager.start();
}
clearTeam(team) {
team.state.clear();
}
exit() {
this.teams.forEach(team => this.clearTeam(team));
this.zoneManager.stop();
}
// --------------- ACTIONS --------------- //
applySettings(settings) {
this.teams.forEach(team => team.state.applySettings(settings));
return true;
}
eliminate(team) {
return team.state.eliminate();
}
revive(team) {
return team.state.revive();
}
addHandicap(team) {
return team.state.addHandicap();
}
clearHandicap(team) {
return team.state.clearHandicap();
}
scan(team) {
return team.state.scan(this.getTarget(team));
}
capture(team, captureCode) {
return team.state.capture(this.getTarget(team), captureCode);
}
updateLocation(team, coords) {
team.updateLocation(coords);
return team.state.updateIsInZone();
}
// --------------- OTHER --------------- //
get _playingTeams() {
return this.teams.order.filter(team => !team.state.isEliminated);
}
getHunter(team) {
const length = this._playingTeams.length;
const i = this.teams.order.indexOf(team.id);
return this._playingTeams[(i+length-1) % length];
}
getTarget(team) {
const length = this._playingTeams.length;
const i = this.teams.order.indexOf(team.id);
return this._playingTeams[(i+1) % length];
}
isGameOver() {
return this._playingTeams.length <= 2;
}
}

View File

@@ -0,0 +1,14 @@
export class DefaultTeam {
constructor(team) {
this.team = team;
}
// --------------- LIFE CYCLE --------------- //
init(_settings) {
return this;
}
clear() {}
}

View File

@@ -0,0 +1,14 @@
export class FinishedTeam {
constructor(team) {
this.team = team;
}
// --------------- LIFE CYCLE --------------- //
init(_settings) {
return this;
}
clear() {}
}

View File

@@ -0,0 +1,25 @@
import { settingsToZone } from "@/core/factories/placement_zone_factory.js";
export class PlacementTeam {
constructor(team) {
this.team = team;
}
// --------------- LIFE CYCLE --------------- //
init(settings) {
this.placementZone = settingsToZone(settings.placementZones[this.team.id]);
return this;
}
clear() {}
// --------------- ACTIONS --------------- //
applySettings(settings) {
this.placementZone = settingsToZone(settings.placementZones[this.team.id]);
return true;
}
}

View File

@@ -0,0 +1,100 @@
import { Team } from "@/core/models/team.js";
import { ScheduledTask } from "@/util/scheduler.js";
import { RESTART_TIMERS } from "@/config/game.js";
export class PlayingTeam {
constructor(team, zoneManager) {
this.team = team;
this.zoneManager = zoneManager;
}
// --------------- LIFE CYCLE --------------- //
init(settings) {
// Team
this.scanLocation = this.team.location;
this.captureCode = Team.getNewCaptureCode();
// Booleans
this.isEliminated = false;
this.isOutOfZone = false;
this.hasHandicap = false;
// Scheduled taks
this.scheduledScan = new ScheduledTask(() => this.scan(), settings.scanDelay).start();
this.scheduledHandicap = new ScheduledTask(() => this.addHandicap(), settings.outOfZoneDelay);
// Target
this.targetScanLocation = { coords: null, timesptamp: null };
return this;
}
clear() {
this.scheduledScan.interrupt();
this.scheduledHandicap.interrupt();
}
// --------------- ACTIONS --------------- //
applySettings(settings) {
this.scheduledScan.setDelay(settings.scanDelay, RESTART_TIMERS);
this.scheduledHandicap.setDelay(settings.outOfZoneDelay, RESTART_TIMERS);
return true;
}
eliminate() {
if (this.isEliminated) return false;
this.clear();
this.isEliminated = true;
return true;
}
revive() {
if (!this.isEliminated) return false;
this.init();
return true;
}
addHandicap() {
if (this.hasHandicap) return false;
this.hasHandicap = true;
this.scheduledScan.interrupt();
return true;
}
clearHandicap() {
if (!this.hasHandicap) return false;
this.hasHandicap = false;
this.scheduledScan.start();
return true;
}
scan(target) {
if (this.hasHandicap || this.isEliminated) return false;
this.scanLocation = this.team.location;
this.targetScanLocation = target.state.scanLocation;
this.scheduledScan.start();
return true;
}
capture(target, captureCode) {
if (this.hasHandicap || this.isEliminated) return false;
if (captureCode != target.state.captureCode) return false;
target.state.eliminate();
return true;
}
updateLocation() {
const isOutOfZone = !this.zoneManager.isInZone(this.team.location);
// Exit zone case
if (isOutOfZone && !this.isOutOfZone) {
this.isOutOfZone = true;
this.scheduledHandicap.start();
// Enter zone case
} else if (!isOutOfZone && this.isOutOfZone) {
this.isOutOfZone = false;
this.scheduledHandicap.interrupt();
this.clearHandicap();
}
return true;
}
}

View File

@@ -1,242 +0,0 @@
import { haversineDistance, EARTH_RADIUS } from "./util.js";
/* -------------------------------- Useful functions and constants -------------------------------- */
const ZONE_TYPES = {
CIRCLE: "circle",
POLYGON: "polygon"
}
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: ZONE_TYPES.CIRCLE, min: null, max: null, reductionCount: 4, duration: 10}
function circleZone(center, radius, duration) {
return {
type: ZONE_TYPES.CIRCLE,
center: center,
radius: radius,
duration: duration,
isInZone(location) {
return haversineDistance(center, location) < this.radius;
}
}
}
function circleSettingsToZones(settings) {
const {min, max, reductionCount, duration} = settings;
if (!min || !max) 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;
let center = max.center;
let radius = max.radius;
for (let i = 1; i < reductionCount; i++) {
radius -= radiusReductionLength;
let new_center = null;
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;
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: ZONE_TYPES.POLYGON, polygons: []}
function polygonZone(polygon, duration) {
return {
type: ZONE_TYPES.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 ZONE_TYPES.CIRCLE:
this.zones = circleSettingsToZones(settings);
break;
case ZONE_TYPES.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,
};
},
}

View File

@@ -0,0 +1,65 @@
import multer from "multer";
import fs from "fs";
import path from "path";
export class PhotoService {
constructor(gameManager) {
this.gameManager = gameManager;
this.uploadDir = path.join(process.cwd(), "teams_photos");
this.missingImage = path.join(process.cwd(), "assets", "images", "missing_image.jpg");
this.allowedMime = ["image/png", "image/jpeg", "image/gif"];
}
_initStorage() {
if (fs.existsSync(this.uploadDir)) fs.rmSync(this.uploadDir, { recursive: true });
fs.mkdirSync(this.uploadDir);
}
_setupMulter() {
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, this.uploadDir),
filename: (req, file, cb) => cb(null, `${req.query.team}`)
});
this.upload = multer({
storage,
fileFilter: (req, file, cb) => {
const isAllowed = this.allowedMime.includes(file.mimetype);
const teamExists = this.gameManager.teams.has(req.query.team);
cb(null, isAllowed && teamExists);
}
});
}
_sendTeamImage(res, imageId) {
const imagePath = path.join(this.uploadDir, imageId);
res.set({
"Content-Type": "image/png",
"Access-Control-Allow-Origin": "*"
});
res.sendFile(fs.existsSync(imagePath) ? imagePath : this.missingImage);
}
init(app) {
this._initStorage();
this._setupMulter();
app.post("/upload", this.upload.single('file'), (req, res) => {
res.set("Access-Control-Allow-Origin", "*").send("");
});
app.get("/photo/my", (req, res) => {
const team = this.gameManager.teams.get(req.query.team);
if (!team) return res.status(400).send("Team not found");
this._sendTeamImage(res, team.id);
});
app.get("/photo/enemy", (req, res) => {
const team = this.gameManager.teams.get(req.query.team);
if (!team) return res.status(400).send("Team not found");
this._sendTeamImage(res, team.target.id);
});
return this;
}
}

View File

@@ -0,0 +1,79 @@
import { createHash } from "crypto";
import { ADMIN_PASSWORD_HASH } from "@/config/server.js";
import { ADMIN_HANDLER_EVENTS } from "@/config/events.js";
export class AdminHandler {
constructor(gameManager) {
this.gameManager = gameManager;
}
init(io) {
io.on("connection", (socket) => {
console.log("Connection of an admin");
new AdminConnection(socket, this.gameManager);
});
return this;
}
}
class AdminConnection {
constructor(socket, gameManager) {
this._socket = socket;
this._gameManager = gameManager;
this._isLoggedIn = false;
this._setupListeners();
}
_login(password) {
if (this._isLoggedIn) return;
const hash = createHash('sha256').update(password).digest('hex');
if (hash !== ADMIN_PASSWORD_HASH) return false;
this._isLoggedIn = true;
this._gameManager.onAdminLogin(this._socket.id);
}
_logout() {
if (!this._isLoggedIn) return;
this._isLoggedIn = false;
}
_setupListeners() {
// Authentication
this._socket.on("disconnect", () => {
console.log("Disconnection of an admin");
this._logout()
});
this._socket.on(ADMIN_HANDLER_EVENTS.LOGIN, (password, callback) => {
this._login(password);
callback(this._isLoggedIn);
});
this._socket.on(ADMIN_HANDLER_EVENTS.LOGOUT, () => {
this._logout()
});
// Actions
const protectedActions = {
[ADMIN_HANDLER_EVENTS.ADD_TEAM]: (id) => this._gameManager.addTeam(id),
[ADMIN_HANDLER_EVENTS.REMOVE_TEAM]: (id) => this._gameManager.removeTeam(id),
[ADMIN_HANDLER_EVENTS.REORDER_TEAM]: (id) => this._gameManager.reorderTeam(id),
[ADMIN_HANDLER_EVENTS.ELIMINATE_TEAM]: (id) => this._gameManager.eliminate(id),
[ADMIN_HANDLER_EVENTS.REVIVE_TEAM]: (id) => this._gameManager.revive(id),
[ADMIN_HANDLER_EVENTS.STATE]: (state) => this._gameManager.setState(state),
[ADMIN_HANDLER_EVENTS.SETTINGS]: (settings) => this._gameManager.setSettings(settings),
};
Object.entries(protectedActions).forEach(([event, action]) => {
this._socket.on(event, (data) => {
if (this._isLoggedIn) action(data);
});
});
}
}

View File

@@ -0,0 +1,80 @@
import { PLAYER_HANDLER_EVENTS } from "@/config/events.js";
export class PlayerHandler {
constructor(gameManager) {
this.gameManager = gameManager;
}
init(io) {
io.on("connection", (socket) => {
console.log("Connection of a player");
new PlayerConnection(socket, this.gameManager);
});
return this;
}
}
class PlayerConnection {
constructor(socket, gameManager) {
this._socket = socket;
this._gameManager = gameManager;
this._teamId = null;
this._setupListeners();
}
_isLoggedIn() {
return this._teamId !== null;
}
_login(loginTeamId) {
if (!this._gameManager.teams.has(loginTeamId) || this._teamId === loginTeamId) return;
this._logout();
this._teamId = loginTeamId;
this._socket.join(this._teamId);
this._gameManager.onPlayerLogin(this._socket.id, this._teamId);
}
_logout() {
if (!this._isLoggedIn()) return;
this._socket.leave(this._teamId);
this._teamId = null;
}
_setupListeners() {
// Authentication
this._socket.on("disconnect", () => {
console.log("Disconnection of a player");
this._logout()
});
this._socket.on(PLAYER_HANDLER_EVENTS.LOGIN, (loginTeamId, callback) => {
this._login(loginTeamId);
callback(this._isLoggedIn());
});
this._socket.on(PLAYER_HANDLER_EVENTS.LOGOUT, () => {
this._logout()
});
// Actions
this._socket.on(PLAYER_HANDLER_EVENTS.LOCATION, (coords) => {
if (this._isLoggedIn()) return;
this._gameManager.updateLocation(this._teamId, coords);
});
this._socket.on(PLAYER_HANDLER_EVENTS.SCAN, (coords) => {
if (this._isLoggedIn()) return;
this._gameManager.updateLocation(this._teamId, coords);
this._gameManager.scan(this._teamId);
});
this._socket.on(PLAYER_HANDLER_EVENTS.CAPTURE, (captureCode, callback) => {
if (this._isLoggedIn()) return;
callback(this._gameManager.capture(this._teamId, captureCode));
});
}
}

View File

@@ -0,0 +1,69 @@
import { DefaultState, PlacementState, PlayingState, FinishedState } from "@/core/states/game/index.js";
const TEAM_STATE_MAP = {
[DefaultState.name]: (_team, _gameState) => ({}),
[PlacementState.name]: (team, _gameState) => ({
placementZone: team.state.placementZone?.polygon ?? null,
isInPlacementZone: team.state.placementZone?.isInZone(team.location) ?? true,
}),
[PlayingState.name]: (team, gameState) => ({
// Team
captureCode: team.state.captureCode,
scanLocation: team.state.scanLocation,
// Booleans
isEliminated: team.state.isEliminated,
isOutOfZone: team.state.isOutOfZone,
hasHandicap: team.state.hasHandicap,
// Scheduled taks
scanDate: team.state.scheduledScan.dateOfExecution,
handicapDate: team.state.scheduledHandicap.dateOfExecution,
// Target and hunter
hunterName: gameState.getHunter(team).name,
targetName: gameState.getTarget(team).name,
targetScanLocation: team.state.targetScanLocation,
}),
[FinishedState.name]: (_team, _gameState) => ({}),
};
export class AdminMapper {
constructor(gameManager) {
this.gameManager = gameManager;
}
map() {
const stateName = this.gameManager.state.name;
const teamsDto = {};
this.gameManager.teams.forEach((team, teamId) => {
teamsDto[teamId] = {
id: team.id,
name: team.name,
location: team.location,
state: TEAM_STATE_MAP[stateName](team, this.gameManager.state)
};
});
const zonesDto = {
firstZone: this.gameManager.zoneManager.firstZonePolygon,
currentZone: this.gameManager.zoneManager.currentZonePolygon,
nextZone: this.gameManager.zoneManager.nextZonePolygon,
zoneTransitionDate: this.gameManager.zoneManager.dateOfZoneTransition
}
return {
stateName: stateName,
teams: teamsDto,
teamsOrder: this.gameManager.teams.order,
zones: zonesDto,
settings: this.gameManager.settings
}
}
hash(dto) {
return JSON.stringify(dto);
}
};

View File

@@ -0,0 +1,54 @@
import { DefaultState, PlacementState, PlayingState, FinishedState } from "@/core/states/game/index.js";
const TEAM_STATE_MAP = {
[DefaultState.name]: (_team, _gameState) => ({}),
[PlacementState.name]: (team, _gameState) => ({
placementZone: team.state.placementZone?.polygon ?? null,
isInPlacementZone: team.state.placementZone?.isInZone(team.location) ?? true,
playingZone: team.state.zoneManager.firstZonePolygon,
}),
[PlayingState.name]: (team, gameState) => ({
// Team
captureCode: team.state.captureCode,
scanLocation: team.state.scanLocation,
// Booleans
isEliminated: team.state.isEliminated,
isOutOfZone: team.state.isOutOfZone,
hasHandicap: team.state.hasHandicap,
// Scheduled taks
scanDate: team.state.scheduledScan.dateOfExecution,
handicapDate: team.state.scheduledHandicap.dateOfExecution,
// Target
targetName: gameState.getTarget(team).name,
targetHasHandicap: gameState.getTarget(team).state.hasHandicap,
targetScanLocation: team.state.targetScanLocation,
// Game zone
currentZone: team.state.zoneManager.currentZonePolygon,
nextZone: team.state.zoneManager.nextZonePolygon,
zoneTransitionDate: team.state.zoneManager.dateOfZoneTransition
}),
[FinishedState.name]: (_team, _gameState) => ({}),
};
export class PlayerMapper {
constructor(gameState, team) {
this.gameState = gameState;
this.team = team;
}
map() {
return {
id: this.team.id,
name: this.team.name,
stateName: this.gameState.name,
state: TEAM_STATE_MAP[this.gameState.name](this.team, this.gameState)
};
}
hash(dto) {
return JSON.stringify(dto);
}
};

View File

@@ -0,0 +1,24 @@
import { AdminMapper } from "@/externals/mappers/admin_mapper.js";
import { StateTracker } from "@/util/state_tracker.js";
import { GAME_MANAGER_EVENTS, ADMIN_SYNCHRONIZER_EVENTS } from "@/config/events.js";
export class AdminSynchronizer {
constructor(gameManager) {
this.gameManager = gameManager;
this.gameStateTracker = new StateTracker(new AdminMapper(this.gameManager));
}
init(io) {
this.gameManager.on(GAME_MANAGER_EVENTS.INIT_ADMIN, (socketId) => {
const { dto } = this.gameStateTracker.getSyncDto();
io.to(socketId).emit(ADMIN_SYNCHRONIZER_EVENTS.UPDATE_FULL, dto);
});
this.gameManager.on(GAME_MANAGER_EVENTS.UPDATE_GAME, () => {
const { dto, hasChanged } = this.gameStateTracker.getSyncDto();
if (hasChanged) io.emit(ADMIN_SYNCHRONIZER_EVENTS.UPDATE_FULL, dto);
});
return this;
}
}

View File

@@ -0,0 +1,39 @@
import { GAME_MANAGER_EVENTS, PLAYER_SYNCHRONIZER_EVENTS } from "@/config/events.js";
import { PlayerMapper } from "@/externals/mappers/player_mapper.js";
import { StateTracker } from "@/util/state_tracker.js";
export class PlayerSynchronizer {
constructor(gameManager) {
this.gameManager = gameManager;
this.teamsStateTracker = new Map();
}
init(io) {
this.gameManager.on(GAME_MANAGER_EVENTS.INIT_PLAYER, (socketId, teamId) => {
const { dto } = this._getSyncDtoOfTeam(this.gameManager.teams.get(teamId));
io.to(socketId).emit(PLAYER_SYNCHRONIZER_EVENTS.UPDATE_FULL, dto);
});
this.gameManager.on(GAME_MANAGER_EVENTS.UPDATE_GAME, () => {
this.gameManager.teams.forEach((team, teamId) => {
const { dto, hasChanged } = this._getSyncDtoOfTeam(team);
if (hasChanged) io.to(teamId).emit(PLAYER_SYNCHRONIZER_EVENTS.UPDATE_FULL, dto);
})
});
this.gameManager.on(GAME_MANAGER_EVENTS.DELETE_TEAM, (teamId) => {
this.teamsStateTracker.delete(teamId);
io.to(teamId).emit(PLAYER_SYNCHRONIZER_EVENTS.LOGOUT);
});
return this;
}
_getSyncDtoOfTeam(team) {
if (!this.teamsStateTracker.has(team.id)) {
const mapper = new PlayerMapper(this.gameManager.state, team);
this.teamsStateTracker.set(team.id, new StateTracker(mapper));
}
return this.teamsStateTracker.get(team.id).getSyncDto();
}
}

View File

@@ -1,24 +1,40 @@
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 { initAdminSocketHandler } from "@/socket/adminHandler.js"; // Core
import { initPlayerSocketHandler } from "@/socket/playerHandler.js"; import { GameManager } from "@/core/managers/game_manager.js";
import { initPhotoUpload } from "./services/photo.js"; // Externals
import { PORT, HOST } from "@/util/util.js"; import { PhotoService } from "@/externals/api/photo.js";
import { PlayerSynchronizer } from "@/externals/synchronizers/player_synchronizer.js";
import { PlayerHandler } from "@/externals/handlers/playerHandler.js";
import { AdminSynchronizer } from "@/externals/synchronizers/admin_synchronizer.js";
import { AdminHandler } from "@/externals/handlers/adminHandler.js";
// Config
import { PORT, HOST } from "@/config/server.js";
import { DEFAULT_GAME_SETTINGS, STATE_SETTINGS } from "@/config/game.js";
// --- Configuration ---
// Configuration
const app = express(); const app = express();
const httpServer = createServer(app); const httpServer = createServer(app);
const io = new Server(httpServer, { const io = new Server(httpServer, {
cors: { origin: "*", methods: ["GET", "POST"] } cors: { origin: "*", methods: ["GET", "POST"] }
}); });
// --- Initialization ---
initPhotoUpload(app);
initAdminSocketHandler(io);
initPlayerSocketHandler(io);
// --- Server Start --- // Initialization
const gameManager = new GameManager(STATE_SETTINGS, DEFAULT_GAME_SETTINGS);
new PhotoService(gameManager).init(app);
new PlayerHandler(gameManager).init(io.of("player"));
new AdminHandler(gameManager).init(io.of("admin"));
new PlayerSynchronizer(gameManager).init(io.of("player"));
new AdminSynchronizer(gameManager).init(io.of("admin"));
// 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}`);
}); });

View File

@@ -1,98 +0,0 @@
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) {
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

@@ -1,504 +0,0 @@
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 hasHandicap
if (this.state == GameState.PLAYING && team.hasHandicap) {
team.lastSentLocation = team.currentLocation;
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

@@ -1,146 +0,0 @@
/*
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 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) {
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

@@ -1,92 +0,0 @@
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

@@ -1,90 +0,0 @@
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

@@ -1,86 +0,0 @@
import multer from "multer";
import fs from "fs";
import path from "path";
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 = [
"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) {
const teamId = req.query.team;
if (typeof teamId === 'string') {
callback(null, teamId);
}
}
});
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 (!gameManager.teams.get(req.query.team)) {
callback(null, false);
} else {
callback(null, true);
}
}
});
const clean = () => {
const files = fs.readdirSync(UPLOAD_DIR);
for (const file of files) {
const filePath = path.join(UPLOAD_DIR, file);
fs.unlinkSync(filePath);
}
};
export const initPhotoUpload = (app) => {
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 = gameManager.teams.get(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 = gameManager.teams.get(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

@@ -1,124 +0,0 @@
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

@@ -1,111 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,174 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,56 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,33 +0,0 @@
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 @@
export const randint = (max) => Math.floor(Math.random() * max);

View File

@@ -0,0 +1,56 @@
export class Scheduler {
constructor() {
this._id = null;
this._date = null;
}
get isActive() {
return this._id !== null;
}
get dateOfExecution() {
return this._date;
}
get timeToExecution() {
return this.isActive ? Math.max(0, this._date - Date.now()) : null;
}
start(callback, delay) {
this.interrupt();
this._id = setTimeout(() => { this._clean(); callback(); }, delay);
this._date = Date.now() + delay;
return this;
}
interrupt() {
if (!this.isActive) return;
clearTimeout(this._id);
this._clean();
}
_clean() {
this._id = null;
this._date = null;
}
}
export class ScheduledTask extends Scheduler {
constructor(callback, delay) {
super();
this._callback = callback;
this._delay = delay;
}
start() {
return super.start(this._callback, this._delay);
}
setDelay(delay, restart = false) {
this._delay = delay;
if (restart && this.isActive) {
this.interrupt();
this.start();
}
}
}

View File

@@ -0,0 +1,14 @@
export class StateTracker {
constructor(mapper) {
this._mapper = mapper;
this._hash = null;
}
getSyncDto() {
const dto = this._mapper.map();
const currentHash = this._mapper.hash(dto);
const hasChanged = currentHash !== this._hash;
this._hash = currentHash;
return { dto, hasChanged };
}
}

View File

@@ -1,26 +0,0 @@
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

@@ -1,22 +0,0 @@
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));
}