From 1f48d8269e91f1ec619e2bbffc21b68f1d6cbf88 Mon Sep 17 00:00:00 2001 From: Isaac Thoman <49598528+IsaacThoman@users.noreply.github.com> Date: Mon, 27 Jan 2025 19:33:56 -0500 Subject: [PATCH] initial p2p test --- deno.json | 2 +- src/server/GameServer.ts | 36 +++++ src/server/config.ts | 11 +- src/server/managers/PeerListManager.ts | 202 +++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 src/server/managers/PeerListManager.ts diff --git a/deno.json b/deno.json index b99aba1..e209aa0 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,7 @@ "build": "deno run -A --node-modules-dir npm:vite build", "preview": "deno run -A --node-modules-dir npm:vite preview", "serve": "deno run --allow-net --allow-read jsr:@std/http@1/file-server dist/", - "start": "deno task build && deno run --allow-read --allow-env --allow-net --allow-write main.ts" + "start": "deno task build && deno run --allow-read --allow-env --allow-net --allow-write --unstable-kv main.ts" }, "compilerOptions": { "lib": ["ES2020", "DOM", "DOM.Iterable", "deno.ns"] diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 34e38bd..7c3df2a 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 { PeerListManager } from './managers/PeerListManager.ts'; export class GameServer { router: Router = new Router(); @@ -23,6 +24,7 @@ export class GameServer { chatManager: ChatManager; damageSystem: DamageSystem; mapData: MapData; + peerListManager: PeerListManager; constructor() { this.mapData = this.loadMapData(); @@ -32,6 +34,7 @@ export class GameServer { this.damageSystem = new DamageSystem(this.playerManager, this.chatManager); this.playerManager.setItemManager(this.itemManager); + this.peerListManager = new PeerListManager(); this.setupSocketIO(); this.setupRoutes(); @@ -130,6 +133,39 @@ export class GameServer { } }); + this.router.post('/api/shareServerList', async (ctx) => { + try { + const urls = await ctx.request.body.json() as string[]; + + if (Array.isArray(urls)) { + for (const url of urls) { + if (url.startsWith('http')) { + await this.peerListManager.addToQueue(url); + } + } + } + + ctx.response.status = 200; + } catch (error) { + console.error('Error handling /api/shareServerList:', error); + ctx.response.status = 400; + ctx.response.body = 'Invalid request body'; + } + }); + + this.router.post('/api/selfCheck', async (ctx) => { + try { + const { secret } = await ctx.request.body.json() as { secret: number }; + const isValid = this.peerListManager.validateSelfCheck(secret); + + ctx.response.status = isValid ? 200 : 400; + } catch (error) { + console.error('Error handling /api/selfCheck:', error); + ctx.response.status = 400; + ctx.response.body = 'Invalid request body'; + } + }); + 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..3e959ac 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -5,7 +5,9 @@ const defaults = { SERVER_URL: 'https://example.com', SERVER_DEFAULT_MAP: 'crackhouse_1', SERVER_TICK_RATE: '15', - SERVER_CLEANUP_INTERVAL: '1000', + SERVER_CLEANUP_INTERVAL: '1', // 1 second + SERVER_PEER_UPDATE_INTERVAL: '1800', // 30 minutes in seconds + SERVER_SELF_CHECK_INTERVAL: '30', // 30 seconds // Player settings PLAYER_DISCONNECT_TIME: '10', @@ -13,7 +15,7 @@ const defaults = { PLAYER_MAX_HEALTH: '100', PLAYER_BASE_INVENTORY: '[]', - //Game settings + // Game settings GAME_MODE: 'ffa', GAME_MAX_PLAYERS: '20', RESPAWN_DELAY: '10', @@ -23,7 +25,7 @@ const defaults = { HEALTH_REGEN_DELAY: '6', HEALTH_REGEN_RATE: '5', - //Item settings + // Item settings MAX_ITEMS_IN_WORLD: '10', ITEM_RESPAWN_TIME: '7', }; @@ -57,7 +59,6 @@ async function updateEnvFile(defaults: Record) { return finalEnv; } -// Parse specific types from string values function parseConfig(env: Record) { return { server: { @@ -67,6 +68,8 @@ function parseConfig(env: Record) { defaultMap: env.SERVER_DEFAULT_MAP, tickRate: parseInt(env.SERVER_TICK_RATE), cleanupInterval: parseInt(env.SERVER_CLEANUP_INTERVAL), + peerUpdateInterval: parseInt(env.SERVER_PEER_UPDATE_INTERVAL), + selfCheckInterval: parseInt(env.SERVER_SELF_CHECK_INTERVAL), }, game: { mode: env.GAME_MODE, diff --git a/src/server/managers/PeerListManager.ts b/src/server/managers/PeerListManager.ts new file mode 100644 index 0000000..47a4a80 --- /dev/null +++ b/src/server/managers/PeerListManager.ts @@ -0,0 +1,202 @@ +/// + +import { ServerInfo } from '../models/ServerInfo.ts'; +import config from '../config.ts'; + +interface ServerEntry { + url: string; + info: ServerInfo; + lastUpdated: number; +} + +export class PeerListManager { + private kv!: Deno.Kv; + private queue: Set = new Set(); + private staleUrls: Map = new Map(); // Track stale URLs for re-checking + private processing = false; + private secret?: number; + private secretExpiration = 0; + private healthCheckAttempts = 0; + private isHealthy = false; + + constructor() { + this.initializeKv().then(() => { + this.initializeFromFile(); + this.runHealthChecks(); // Start health checks + }); + } + + private async initializeKv() { + this.kv = await Deno.openKv(); + } + + private async initializeFromFile() { + try { + // Use async file operations instead of sync + const urls = (await Deno.readTextFile('./servers.txt')) + .split('\n') + .map((url) => this.normalizeUrl(url)) + .filter((url) => url && url.startsWith('http')); + + // Load existing KV entries on startup + const existingEntries = this.kv.list({ prefix: ['servers'] }); + for await (const entry of existingEntries) { + this.queue.add(entry.value.url); + } + + // Add URLs from the file to the queue + for (const url of urls) { + await this.addToQueue(url); + } + } catch { + await Deno.writeTextFile('./servers.txt', 'https://bridge.candiru.xyz\n'); + } + } + + private normalizeUrl(url: string): string { + try { + return new URL(url).href.replace(/\/$/, ''); // Normalize URL and remove trailing slashes + } catch { + return ''; // Invalid URL, return empty string + } + } + + private startQueueProcessor() { + this.processQueue(); // Process immediately once + return setInterval(() => this.processQueue(), config.server.peerUpdateInterval * 1000); + } + + private async processQueue() { + if (!this.isHealthy || this.processing) return; + this.processing = true; + + // Process stale entries first + const now = Date.now(); + for (const [url, expiry] of this.staleUrls) { + if (now >= expiry) { + this.queue.add(url); + this.staleUrls.delete(url); + } + } + + // Process the queue + for (const url of this.queue) { + const entry = await this.kv.get(['servers', url]); + const needsUpdate = !entry.value || + Date.now() - entry.value.lastUpdated > config.server.peerUpdateInterval * 1000; + if (needsUpdate) { + await this.checkServer(url); + } + } + + this.processing = false; + } + + private async checkServer(url: string) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + console.log(`Checking ${url}`); + const response = await fetch(`${url}/api/getInfo`, { + signal: controller.signal, + }); + + // Validate response format + const info: ServerInfo = await response.json(); + if (!this.validateServerInfo(info)) { + throw new Error('Invalid server response'); + } + + // Update lastUpdated even if info hasn't changed + await this.kv.set(['servers', url], { + url, + info, + lastUpdated: Date.now(), + }); + + await this.shareWithServer(url); + } catch (error) { + console.error(`Failed to check ${url}:`, error); + // Retry logic with backoff + const retryTime = Date.now() + (30 * 60 * 1000); // 30 min default + this.staleUrls.set(url, retryTime); + this.queue.delete(url); + } finally { + clearTimeout(timeout); + } + } + + private validateServerInfo(obj: any): obj is ServerInfo { + return obj && typeof obj === 'object' && + 'version' in obj && + 'playerCount' in obj; + } + + private async shareWithServer(url: string) { + const entries = this.kv.list({ prefix: ['servers'] }); + const urls: string[] = []; + for await (const entry of entries) { + if (entry.value.url !== url) { // Exclude self from sharing + urls.push(entry.value.url); + } + } + try { + console.log(`Sharing with ${url}`); + await fetch(`${url}/api/shareServerList`, { + method: 'POST', + body: JSON.stringify(urls), + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error(`Failed to share with ${url}:`, error); + } + } + + public async addToQueue(url: string) { + const normalized = this.normalizeUrl(url); + if (normalized === this.normalizeUrl(config.server.url)) return; // Skip self + + const existing = await this.kv.get(['servers', normalized]); + if (!existing.value && !this.queue.has(normalized)) { + console.log(`Adding ${normalized} to queue`); + this.queue.add(normalized); + } + } + + private async runHealthChecks() { + const checkHealth = async () => { + const success = await this.performHealthCheck(); + if (success) { + this.isHealthy = true; + this.startQueueProcessor(); + } + // Continue checking periodically regardless of success + setTimeout(checkHealth, config.server.selfCheckInterval * 1000); + }; + + await checkHealth(); + } + + private async performHealthCheck() { + this.secret = Math.random(); + this.secretExpiration = Date.now() + 5000; // 5-second window for secret validity + try { + await fetch(`${config.server.url}/api/selfCheck`, { + method: 'POST', + body: JSON.stringify({ secret: this.secret }), + headers: { 'Content-Type': 'application/json' }, + }); + console.log('Health check passed'); + return true; + } catch (error) { + console.error('Health check failed:', error); + return false; + } + } + + public validateSelfCheck(secret: number) { + const isValid = secret === this.secret && Date.now() < this.secretExpiration; + this.secret = undefined; // Invalidate after use + return isValid; + } +}