diff --git a/src/server/DataValidator.ts b/src/server/DataValidator.ts index 14f51dd..2ad31d0 100644 --- a/src/server/DataValidator.ts +++ b/src/server/DataValidator.ts @@ -3,6 +3,7 @@ import { z } from 'https://deno.land/x/zod@v3.23.8/mod.ts'; import { ChatMessage } from './models/ChatMessage.ts'; import { DamageRequest } from './models/DamageRequest.ts'; import { Player, PlayerData } from '../shared/Player.ts'; +import { ServerInfo } from './models/ServerInfo.ts'; export class DataValidator { private static SERVER_VERSION = ''; @@ -83,6 +84,20 @@ export class DataValidator { damage: z.number(), }).strict(); + static serverInfoSchema = z.object({ + name: z.string(), + maxPlayers: z.number(), + currentPlayers: z.number(), + mapName: z.string(), + tickRate: z.number(), + version: z.string(), + gameMode: z.string(), + playerMaxHealth: z.number(), + skyColor: z.string(), + tickComputeTime: z.number(), + cleanupComputeTime: z.number(), + }).strict().transform((data) => Object.assign(new ServerInfo(), data)); + static validatePlayerData(data: PlayerData) { return DataValidator.playerDataSchema.safeParse(data); } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 34e38bd..fe5568e 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -11,6 +11,7 @@ import { DamageSystem } from './managers/DamageSystem.ts'; import { MapData } from './models/MapData.ts'; import { DataValidator } from './DataValidator.ts'; import { CustomServer } from '../shared/messages.ts'; +import { PeerManager } from './managers/PeerManager.ts'; export class GameServer { router: Router = new Router(); @@ -23,6 +24,7 @@ export class GameServer { chatManager: ChatManager; damageSystem: DamageSystem; mapData: MapData; + peerManager: PeerManager; constructor() { this.mapData = this.loadMapData(); @@ -33,6 +35,8 @@ export class GameServer { this.playerManager.setItemManager(this.itemManager); + this.peerManager = new PeerManager(); + this.setupSocketIO(); this.setupRoutes(); @@ -130,6 +134,26 @@ export class GameServer { } }); + this.router.get('/api/healthcheck', (context) => { + const secret = context.request.headers.get('X-Health-Secret'); + if (secret === this.peerManager.healthSecret) { + context.response.status = 200; + } else { + context.response.status = 403; + } + }); + + this.router.post('/api/shareServerList', async (context) => { + try { + const body = await context.request.body.json(); + const urls: string[] = Array.isArray(body) ? body : []; + this.peerManager.handleIncomingServers(urls); + context.response.status = 200; + } catch { + context.response.status = 400; + } + }); + this.app.use(this.router.routes()); this.app.use(this.router.allowedMethods()); } diff --git a/src/server/config.ts b/src/server/config.ts index 1c44001..964046c 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -7,6 +7,14 @@ const defaults = { SERVER_TICK_RATE: '15', SERVER_CLEANUP_INTERVAL: '1000', + // Peer settings + PEER_UPDATE_INTERVAL: '5', + PEER_SHARE_INTERVAL: '5', + PEER_MAX_FAILED_ATTEMPTS: '3', + PEER_STALE_THRESHOLD: '60', + PEER_MAX_SERVERS: '200', + PEER_HEALTHCHECK_RETRIES: '3', + // Player settings PLAYER_DISCONNECT_TIME: '10', PLAYER_AFK_KICK_TIME: '600', @@ -68,6 +76,14 @@ function parseConfig(env: Record) { tickRate: parseInt(env.SERVER_TICK_RATE), cleanupInterval: parseInt(env.SERVER_CLEANUP_INTERVAL), }, + peer: { + updateInterval: parseInt(env.PEER_UPDATE_INTERVAL), + shareInterval: parseInt(env.PEER_SHARE_INTERVAL), + maxFailedAttempts: parseInt(env.PEER_MAX_FAILED_ATTEMPTS), + staleThreshold: parseInt(env.PEER_STALE_THRESHOLD), + maxServers: parseInt(env.PEER_MAX_SERVERS), + healthcheckRetries: parseInt(env.PEER_HEALTHCHECK_RETRIES), + }, game: { mode: env.GAME_MODE, maxPlayers: parseInt(env.GAME_MAX_PLAYERS), diff --git a/src/server/managers/PeerManager.ts b/src/server/managers/PeerManager.ts new file mode 100644 index 0000000..ca54d52 --- /dev/null +++ b/src/server/managers/PeerManager.ts @@ -0,0 +1,156 @@ +import { Peer } from '../models/Peer.ts'; +import { DataValidator } from '../DataValidator.ts'; +import config from '../config.ts'; +import { ServerInfo } from '../models/ServerInfo.ts'; + +export class PeerManager { + private peers: Peer[] = []; + private updateQueue: string[] = []; + private shareQueue: string[] = []; + healthSecret: string; + private serversFilePath = './servers.txt'; + + constructor() { + this.healthSecret = crypto.randomUUID(); + this.initialize(); + } + + private async initialize() { + await this.healthCheck(); + await this.loadServersFile(); + this.startQueueProcessors(); + } + + private async healthCheck() { + const retries = config.peer.healthcheckRetries; + const delay = config.server.cleanupInterval; + + for (let i = 0; i < retries; i++) { + try { + const response = await fetch(`${config.server.url}/api/healthcheck`, { + headers: { 'X-Health-Secret': this.healthSecret }, + }); + if (response.ok) return; + } catch { + console.error('Healthcheck failed'); + } + + if (i < retries - 1) await new Promise((r) => setTimeout(r, delay)); + } + + console.error('Failed healthcheck after retries'); + } + + private async loadServersFile() { + try { + const content = await Deno.readTextFile(this.serversFilePath); + this.updateQueue = content.split('\n').filter((url) => url.trim()); + } catch { + await Deno.writeTextFile(this.serversFilePath, 'https://bridge.candiru.xyz\n'); + this.updateQueue = ['https://bridge.candiru.xyz']; + } + } + + private startQueueProcessors() { + setInterval(() => this.processUpdateQueue(), config.peer.updateInterval * 1000); + setInterval(() => this.processShareQueue(), config.peer.shareInterval * 1000); + setInterval(() => this.checkStalePeers(), config.server.cleanupInterval); + } + + private async processUpdateQueue() { + const url = this.updateQueue.shift(); + if (!url) return; + + try { + const response = await fetch(`${url}/api/getInfo`); + const data = await response.json(); + const result = DataValidator.serverInfoSchema.safeParse(data); + + if (result.success) { + let peer = this.peers.find((p) => p.url === url); + if (!peer) { + peer = new Peer(url); + this.peers.push(peer); + await this.addToServersFile(url); + } + peer.updateServerInfo(result.data); + } else { + this.handleFailedUpdate(url); + } + } catch { + this.handleFailedUpdate(url); + } finally { + this.updateQueue.push(url); + } + } + + private async processShareQueue() { + const url = this.shareQueue.shift(); + if (!url) return; + + try { + const serverList = this.peers + .filter((p) => p.serverInfo) + .map((p) => p.url) + .slice(0, config.peer.maxServers); + + await fetch(`${url}/api/shareServerList`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(serverList), + }); + } finally { + this.shareQueue.push(url); + } + } + + private checkStalePeers() { + const stalePeers = this.peers.filter((p) => + p.isStale(config.peer.staleThreshold) || + (Date.now() / 1000 - p.lastShare) > config.peer.shareInterval * 1000 + ); + + stalePeers.forEach((peer) => { + if (peer.isStale(config.peer.staleThreshold)) { + this.updateQueue.push(peer.url); + } + if ((Date.now() / 1000 - peer.lastShare) > config.peer.shareInterval * 1000) { + this.shareQueue.push(peer.url); + } + }); + + this.peers = this.peers.filter((p) => !p.hasExceededFailures(config.peer.maxFailedAttempts)); + } + + private handleFailedUpdate(url: string) { + const peer = this.peers.find((p) => p.url === url); + if (peer) { + peer.failedAttempts++; + if (peer.hasExceededFailures(config.peer.maxFailedAttempts)) { + this.peers = this.peers.filter((p) => p.url !== url); + } + } + } + + private async addToServersFile(url: string) { + const current = await Deno.readTextFile(this.serversFilePath); + const urls = current.split('\n').filter((u) => u.trim()); + + if (!urls.includes(url)) { + urls.push(url); + if (urls.length > config.peer.maxServers) urls.shift(); + await Deno.writeTextFile(this.serversFilePath, urls.join('\n')); + } + } + + public handleIncomingServers(urls: string[]) { + urls.forEach((url) => { + if ( + !this.updateQueue.includes(url) && + !this.peers.some((p) => p.url === url) + ) { + this.updateQueue.push(url); + } + }); + } +} diff --git a/src/server/models/Peer.ts b/src/server/models/Peer.ts new file mode 100644 index 0000000..e88f3a2 --- /dev/null +++ b/src/server/models/Peer.ts @@ -0,0 +1,27 @@ +import { ServerInfo } from './ServerInfo.ts'; + +export class Peer { + public url: string; + public serverInfo?: ServerInfo; + public lastUpdate: number = 0; + public failedAttempts: number = 0; + public lastShare: number = 0; + + constructor(url: string) { + this.url = url; + } + + updateServerInfo(info: ServerInfo) { + this.serverInfo = info; + this.lastUpdate = Date.now() / 1000; + this.failedAttempts = 0; + } + + isStale(threshold: number) { + return (Date.now() / 1000 - this.lastUpdate) > threshold; + } + + hasExceededFailures(max: number) { + return this.failedAttempts >= max; + } +}