Spaces:
Sleeping
Sleeping
| 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(); | |