mirror of
https://git.rezel.net/LudoTech/traque.git
synced 2026-04-10 16:30:18 +02:00
Server heavy refactoring 2 (not functionnal)
This commit is contained in:
3947
server/traque-back/package-lock.json
generated
Normal file
3947
server/traque-back/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@turf/turf": "^7.3.4",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
|
||||
36
server/traque-back/src/config/events.js
Normal file
36
server/traque-back/src/config/events.js
Normal 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",
|
||||
};
|
||||
44
server/traque-back/src/config/game.js
Normal file
44
server/traque-back/src/config/game.js
Normal 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;
|
||||
6
server/traque-back/src/config/server.js
Normal file
6
server/traque-back/src/config/server.js
Normal 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;
|
||||
13
server/traque-back/src/config/zone.js
Normal file
13
server/traque-back/src/config/zone.js
Normal 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;
|
||||
70
server/traque-back/src/core/factories/game_zone_factory.js
Normal file
70
server/traque-back/src/core/factories/game_zone_factory.js
Normal 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();
|
||||
};
|
||||
@@ -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 }));
|
||||
};
|
||||
@@ -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();
|
||||
122
server/traque-back/src/core/managers/game_manager.js
Normal file
122
server/traque-back/src/core/managers/game_manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
59
server/traque-back/src/core/managers/team_manager.js
Normal file
59
server/traque-back/src/core/managers/team_manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
73
server/traque-back/src/core/managers/zone_manager.js
Normal file
73
server/traque-back/src/core/managers/zone_manager.js
Normal 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);
|
||||
}
|
||||
}
|
||||
32
server/traque-back/src/core/models/team.js
Normal file
32
server/traque-back/src/core/models/team.js
Normal 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;
|
||||
}
|
||||
}
|
||||
28
server/traque-back/src/core/models/zone.js
Normal file
28
server/traque-back/src/core/models/zone.js
Normal 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;
|
||||
}
|
||||
};
|
||||
44
server/traque-back/src/core/states/game/default_state.js
Normal file
44
server/traque-back/src/core/states/game/default_state.js
Normal 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;
|
||||
}
|
||||
}
|
||||
37
server/traque-back/src/core/states/game/finished_state.js
Normal file
37
server/traque-back/src/core/states/game/finished_state.js
Normal 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;
|
||||
}
|
||||
}
|
||||
4
server/traque-back/src/core/states/game/index.js
Normal file
4
server/traque-back/src/core/states/game/index.js
Normal 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';
|
||||
42
server/traque-back/src/core/states/game/placement_state.js
Normal file
42
server/traque-back/src/core/states/game/placement_state.js
Normal 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;
|
||||
}
|
||||
}
|
||||
93
server/traque-back/src/core/states/game/playing_state.js
Normal file
93
server/traque-back/src/core/states/game/playing_state.js
Normal 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;
|
||||
}
|
||||
}
|
||||
14
server/traque-back/src/core/states/teams/default_team.js
Normal file
14
server/traque-back/src/core/states/teams/default_team.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export class DefaultTeam {
|
||||
constructor(team) {
|
||||
this.team = team;
|
||||
}
|
||||
|
||||
|
||||
// --------------- LIFE CYCLE --------------- //
|
||||
|
||||
init(_settings) {
|
||||
return this;
|
||||
}
|
||||
|
||||
clear() {}
|
||||
}
|
||||
14
server/traque-back/src/core/states/teams/finished_team.js
Normal file
14
server/traque-back/src/core/states/teams/finished_team.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export class FinishedTeam {
|
||||
constructor(team) {
|
||||
this.team = team;
|
||||
}
|
||||
|
||||
|
||||
// --------------- LIFE CYCLE --------------- //
|
||||
|
||||
init(_settings) {
|
||||
return this;
|
||||
}
|
||||
|
||||
clear() {}
|
||||
}
|
||||
25
server/traque-back/src/core/states/teams/placement_team.js
Normal file
25
server/traque-back/src/core/states/teams/placement_team.js
Normal 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;
|
||||
}
|
||||
}
|
||||
100
server/traque-back/src/core/states/teams/playing_team.js
Normal file
100
server/traque-back/src/core/states/teams/playing_team.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
}
|
||||
65
server/traque-back/src/externals/api/photo.js
vendored
Normal file
65
server/traque-back/src/externals/api/photo.js
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
79
server/traque-back/src/externals/handlers/adminHandler.js
vendored
Normal file
79
server/traque-back/src/externals/handlers/adminHandler.js
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
80
server/traque-back/src/externals/handlers/playerHandler.js
vendored
Normal file
80
server/traque-back/src/externals/handlers/playerHandler.js
vendored
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
69
server/traque-back/src/externals/mappers/admin_mapper.js
vendored
Normal file
69
server/traque-back/src/externals/mappers/admin_mapper.js
vendored
Normal 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);
|
||||
}
|
||||
};
|
||||
54
server/traque-back/src/externals/mappers/player_mapper.js
vendored
Normal file
54
server/traque-back/src/externals/mappers/player_mapper.js
vendored
Normal 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);
|
||||
}
|
||||
};
|
||||
24
server/traque-back/src/externals/synchronizers/admin_synchronizer.js
vendored
Normal file
24
server/traque-back/src/externals/synchronizers/admin_synchronizer.js
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
39
server/traque-back/src/externals/synchronizers/player_synchronizer.js
vendored
Normal file
39
server/traque-back/src/externals/synchronizers/player_synchronizer.js
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,40 @@
|
||||
import { createServer } from "http";
|
||||
import express from "express";
|
||||
import { Server } from "socket.io";
|
||||
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";
|
||||
// Core
|
||||
import { GameManager } from "@/core/managers/game_manager.js";
|
||||
// Externals
|
||||
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 httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
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, () => {
|
||||
console.log(`Server running on http://${HOST}:${PORT}`);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
1
server/traque-back/src/util/random.js
Normal file
1
server/traque-back/src/util/random.js
Normal file
@@ -0,0 +1 @@
|
||||
export const randint = (max) => Math.floor(Math.random() * max);
|
||||
56
server/traque-back/src/util/scheduler.js
Normal file
56
server/traque-back/src/util/scheduler.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
server/traque-back/src/util/state_tracker.js
Normal file
14
server/traque-back/src/util/state_tracker.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user