const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const cors = require('cors'); const path = require('path'); const physics = require('./physics'); const PORT = process.env.PORT || 7860; const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:3000'; const TICK_RATE = 10; const TICK_INTERVAL = 1000 / TICK_RATE; const OBSERVATION_PHASE_DURATION = 15000; const GUESS_PHASE_DURATION = 10000; const RESULTS_PHASE_DURATION = 5000; const app = express(); app.use(cors({ origin: CLIENT_ORIGIN })); app.use(express.json()); app.get('/health', (req, res) => { res.json({ status: 'ok', satellites: Object.keys(physics.tleCache).length }); }); const server = http.createServer(app); const io = new Server(server, { cors: { origin: CLIENT_ORIGIN, methods: ['GET', 'POST'], }, }); const games = new Map(); class Game { constructor(roomId) { this.roomId = roomId; this.players = []; this.state = 'waiting'; this.currentSatellite = null; this.currentSatelliteName = null; this.phase = null; this.phaseDeadline = null; this.phaseTimer = null; this.tickInterval = null; this.guesses = new Map(); this.round = 0; this.scores = new Map(); } addPlayer(socketId, playerName) { this.players.push({ socketId, name: playerName, ready: false }); this.scores.set(socketId, 0); } removePlayer(socketId) { this.players = this.players.filter(p => p.socketId !== socketId); this.scores.delete(socketId); if (this.players.length === 0) { this.stop(); games.delete(this.roomId); } } setReady(socketId) { const player = this.players.find(p => p.socketId === socketId); if (player) player.ready = true; } allReady() { return this.players.length >= 2 && this.players.every(p => p.ready); } async start() { this.state = 'playing'; this.round = 0; await physics.ensureTleFresh(); this.startRound(); } async startRound() { this.round++; this.guesses = new Map(); this.phase = 'observation'; const satState = await physics.getRandomSatelliteState(new Date()); if (!satState) { io.to(this.roomId).emit('GAME_ERROR', { message: 'No satellite data available' }); return; } this.currentSatellite = satState; this.currentSatelliteName = satState.name; io.to(this.roomId).emit('ROUND_START', { round: this.round, phase: 'observation', duration: OBSERVATION_PHASE_DURATION, satelliteName: satState.name, }); this.phaseDeadline = Date.now() + OBSERVATION_PHASE_DURATION; this.clearPhaseTimers(); if (this.tickInterval) clearInterval(this.tickInterval); this.tickInterval = setInterval(() => this.tick(), TICK_INTERVAL); this.phaseTimer = setTimeout(() => this.startGuessPhase(), OBSERVATION_PHASE_DURATION); } startGuessPhase() { this.phase = 'guess'; this.phaseDeadline = Date.now() + GUESS_PHASE_DURATION; if (this.tickInterval) { clearInterval(this.tickInterval); this.tickInterval = null; } io.to(this.roomId).emit('PHASE_CHANGE', { phase: 'guess', duration: GUESS_PHASE_DURATION, }); this.phaseTimer = setTimeout(() => this.endRound(), GUESS_PHASE_DURATION); } endRound() { this.phase = 'results'; if (this.tickInterval) { clearInterval(this.tickInterval); this.tickInterval = null; } const results = []; for (const [socketId, guess] of this.guesses) { const distance = physics.haversineDistance( this.currentSatellite.lat, this.currentSatellite.lng, guess.lat, guess.lng ); const score = physics.scoreFromDistance(distance); this.scores.set(socketId, (this.scores.get(socketId) || 0) + score); const player = this.players.find(p => p.socketId === socketId); results.push({ playerName: player ? player.name : 'Unknown', guessLat: guess.lat, guessLng: guess.lng, distance: Math.round(distance), score, totalScore: this.scores.get(socketId), }); } const sorted = [...results].sort((a, b) => b.score - a.score); io.to(this.roomId).emit('ROUND_RESULTS', { round: this.round, satelliteName: this.currentSatelliteName, correctLat: this.currentSatellite.lat, correctLng: this.currentSatellite.lng, results: sorted, scores: Object.fromEntries(this.scores), }); this.phaseTimer = setTimeout(() => { if (this.round >= 5) { this.endGame(); } else { this.startRound(); } }, RESULTS_PHASE_DURATION); } endGame() { this.state = 'finished'; this.clearPhaseTimers(); if (this.tickInterval) { clearInterval(this.tickInterval); this.tickInterval = null; } const leaderboard = this.players .map(p => ({ name: p.name, score: this.scores.get(p.socketId) || 0 })) .sort((a, b) => b.score - a.score); io.to(this.roomId).emit('GAME_OVER', { leaderboard }); } tick() { if (this.phase !== 'observation' || !this.currentSatellite) return; const now = new Date(); const updated = physics.getSatelliteState(this.currentSatelliteName, now); if (updated) { this.currentSatellite = updated; io.to(this.roomId).emit('SAT_UPDATE', { lat: updated.lat, lng: updated.lng, alt: updated.alt, footprintRadius: updated.footprintRadius, }); } } submitGuess(socketId, lat, lng) { if (this.phase !== 'guess') return; this.guesses.set(socketId, { lat, lng }); io.to(socketId).emit('GUESS_CONFIRMED', { lat, lng }); } clearPhaseTimers() { if (this.phaseTimer) { clearTimeout(this.phaseTimer); this.phaseTimer = null; } } stop() { this.clearPhaseTimers(); if (this.tickInterval) { clearInterval(this.tickInterval); this.tickInterval = null; } } } function getRoomIdFromSocket(socket) { const rooms = [...socket.rooms].filter(r => r !== socket.id); return rooms.length > 0 ? rooms[0] : null; } io.on('connection', (socket) => { console.log(`Player connected: ${socket.id}`); socket.on('CREATE_ROOM', (data, callback) => { const roomId = Math.random().toString(36).substring(2, 8).toUpperCase(); const playerName = (data && data.playerName) || `Player ${socket.id.substring(0, 4)}`; const game = new Game(roomId); game.addPlayer(socket.id, playerName); games.set(roomId, game); socket.join(roomId); socket.emit('ROOM_CREATED', { roomId, playerName }); if (callback) callback({ success: true, roomId }); }); socket.on('JOIN_ROOM', (data, callback) => { const { roomId, playerName } = data || {}; if (!roomId) { if (callback) callback({ success: false, error: 'Room ID required' }); return; } const game = games.get(roomId); if (!game) { if (callback) callback({ success: false, error: 'Room not found' }); return; } if (game.players.length >= 2) { if (callback) callback({ success: false, error: 'Room is full' }); return; } if (game.state !== 'waiting') { if (callback) callback({ success: false, error: 'Game already in progress' }); return; } const name = playerName || `Player ${socket.id.substring(0, 4)}`; game.addPlayer(socket.id, name); socket.join(roomId); io.to(roomId).emit('PLAYER_JOINED', { players: game.players.map(p => ({ name: p.name, ready: p.ready })), }); if (callback) callback({ success: true }); }); socket.on('PLAYER_READY', () => { const roomId = getRoomIdFromSocket(socket); if (!roomId) return; const game = games.get(roomId); if (!game) return; game.setReady(socket.id); io.to(roomId).emit('PLAYER_READY_UPDATE', { players: game.players.map(p => ({ name: p.name, ready: p.ready })), }); if (game.allReady()) { io.to(roomId).emit('GAME_STARTING'); game.start(); } }); socket.on('SUBMIT_GUESS', (data) => { const { lat, lng } = data || {}; if (lat == null || lng == null) return; const roomId = getRoomIdFromSocket(socket); if (!roomId) return; const game = games.get(roomId); if (!game) return; game.submitGuess(socket.id, lat, lng); }); socket.on('disconnect', () => { console.log(`Player disconnected: ${socket.id}`); for (const [roomId, game] of games) { if (game.players.some(p => p.socketId === socket.id)) { socket.to(roomId).emit('PLAYER_DISCONNECTED', { playerName: game.players.find(p => p.socketId === socket.id)?.name, }); game.removePlayer(socket.id); break; } } }); }); async function init() { console.log('Fetching initial TLE data...'); await physics.fetchTleData(); console.log(`Loaded ${Object.keys(physics.tleCache).length} satellites`); setInterval(async () => { console.log('Refreshing TLE data...'); await physics.fetchTleData(); }, 60 * 60 * 1000); server.listen(PORT, '0.0.0.0', () => { console.log(`Orbit Guesser server running on port ${PORT}`); }); } init();