371 lines
15 KiB
Python
371 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Servidor de juego Minesweeper Multiplayer
|
|
Protocolo basado en JSON sobre TCP.
|
|
"""
|
|
import socket
|
|
import threading
|
|
import json
|
|
import time
|
|
import random
|
|
|
|
HOST = '0.0.0.0'
|
|
PORT = 3333
|
|
|
|
# Estados del juego
|
|
STATE_LOBBY = 'LOBBY'
|
|
STATE_PLACING = 'PLACING' # Jugadores ponen bombas por turnos
|
|
STATE_PLAYING = 'PLAYING' # Jugadores buscan
|
|
|
|
class GameServer:
|
|
def __init__(self):
|
|
self.clients = {} # socket -> address
|
|
self.client_list = [] # List of addresses to maintain order
|
|
self.lock = threading.Lock()
|
|
|
|
# Estado del juego
|
|
self.state = STATE_LOBBY
|
|
self.round = 1
|
|
self.lives = {} # address -> int
|
|
self.grid_size = 3
|
|
self.bombs = set() # Set of coordinates (x, y)
|
|
self.revealed = set() # Set of coordinates (x, y)
|
|
|
|
# Turn control for placing
|
|
self.placing_turn_index = 0
|
|
self.bombs_to_place_per_player = 0
|
|
self.current_player_bombs_placed = 0
|
|
|
|
# Turn control for playing (search phase)
|
|
self.playing_turn_index = 0
|
|
|
|
def _broadcast_unlocked(self, message_dict):
|
|
"""Envía mensaje JSON a todos. NOTA: Debe llamarse con self.lock ya adquirido"""
|
|
data = (json.dumps(message_dict) + '\n').encode('utf-8')
|
|
print(f"[BROADCAST] Enviando {len(data)} bytes a {len(self.clients)} clientes")
|
|
for client in list(self.clients.keys()):
|
|
try:
|
|
client.sendall(data)
|
|
print(f"[BROADCAST] ✓ Enviado a {self.clients[client]}")
|
|
except Exception as e:
|
|
print(f"[BROADCAST] ✗ Error enviando a {self.clients.get(client)}: {e}")
|
|
# No llamar remove_client aquí porque ya tenemos el lock
|
|
# Solo marcar para remover después
|
|
|
|
def broadcast(self, message_dict):
|
|
"""Envía mensaje JSON a todos con newline (adquiere lock)"""
|
|
with self.lock:
|
|
self._broadcast_unlocked(message_dict)
|
|
|
|
def remove_client(self, client):
|
|
if client in self.clients:
|
|
addr = self.clients[client]
|
|
del self.clients[client]
|
|
if addr in self.client_list:
|
|
self.client_list.remove(addr)
|
|
print(f"[DESCONECTADO] {addr}")
|
|
# Si no quedan jugadores, reiniciar
|
|
if not self.clients:
|
|
self.reset_game()
|
|
|
|
def reset_game(self):
|
|
print("Reiniciando juego...")
|
|
self.state = STATE_LOBBY
|
|
self.round = 1
|
|
self.lives = {}
|
|
self.bombs = set()
|
|
self.revealed = set()
|
|
self.client_list = []
|
|
|
|
def get_grid_size(self):
|
|
# Tamaños de grid por ronda (máximo 14x14)
|
|
if self.round == 1: return 3
|
|
if self.round == 2: return 5
|
|
if self.round == 3: return 8
|
|
if self.round == 4: return 11
|
|
return 14 # Ronda 5+
|
|
|
|
def get_bombs_per_player(self):
|
|
# Bombas por ronda: 3, 5, 9, 12, 15
|
|
if self.round == 1: return 3
|
|
if self.round == 2: return 5
|
|
if self.round == 3: return 9
|
|
if self.round == 4: return 12
|
|
return 15 # Ronda 5+
|
|
|
|
def start_round(self):
|
|
"""Inicia una nueva ronda. NOTA: Debe llamarse con self.lock ya adquirido"""
|
|
self.grid_size = self.get_grid_size()
|
|
self.bombs = set()
|
|
self.revealed = set()
|
|
self.state = STATE_PLACING
|
|
self.placing_turn_index = 0
|
|
self.current_player_bombs_placed = 0
|
|
self.bombs_to_place_per_player = self.get_bombs_per_player()
|
|
|
|
# Actualizar lista de clientes para turnos (lock ya adquirido por caller)
|
|
self.client_list = list(self.clients.values())
|
|
|
|
print(f"[ROUND {self.round}] Grid: {self.grid_size}x{self.grid_size}, Bombas/jugador: {self.bombs_to_place_per_player}")
|
|
print(f"[ROUND {self.round}] Jugadores: {self.client_list}")
|
|
|
|
msg = {
|
|
"type": "NEW_ROUND",
|
|
"round": self.round,
|
|
"grid_size": self.grid_size,
|
|
"status": f"Ronda {self.round}: Fase de Colocación",
|
|
"total_bombs_per_player": self.bombs_to_place_per_player
|
|
}
|
|
print(f"[BROADCAST] NEW_ROUND: {msg}")
|
|
self._broadcast_unlocked(msg)
|
|
self.next_placement_turn()
|
|
|
|
def start_round_with_lock(self):
|
|
"""Versión de start_round que adquiere el lock (para usar con Timer)"""
|
|
with self.lock:
|
|
self.start_round()
|
|
|
|
def next_placement_turn(self):
|
|
print(f"[TURN] Index: {self.placing_turn_index}, Total jugadores: {len(self.client_list)}")
|
|
if self.placing_turn_index >= len(self.client_list):
|
|
# Todos han puesto bombas - iniciar fase de búsqueda
|
|
print("[PHASE] Cambiando a PLAYING")
|
|
self.state = STATE_PLAYING
|
|
self.playing_turn_index = 0 # Empieza el primer jugador
|
|
self._broadcast_unlocked({
|
|
"type": "PHASE_PLAY",
|
|
"msg": "¡A buscar! Recordad dónde estaban las bombas."
|
|
})
|
|
# Notificar turno del primer jugador
|
|
self.notify_playing_turn()
|
|
return
|
|
|
|
current_addr = self.client_list[self.placing_turn_index]
|
|
self.current_player_bombs_placed = 0
|
|
|
|
turn_msg = {
|
|
"type": "TURN_NOTIFY",
|
|
"active_player": str(current_addr),
|
|
"msg": f"Turno de {current_addr} para poner bombas."
|
|
}
|
|
print(f"[BROADCAST] TURN_NOTIFY: {turn_msg}")
|
|
self._broadcast_unlocked(turn_msg)
|
|
|
|
def notify_playing_turn(self):
|
|
"""Notifica el turno actual en la fase de búsqueda"""
|
|
if self.playing_turn_index >= len(self.client_list):
|
|
self.playing_turn_index = 0 # Volver al principio
|
|
|
|
current_addr = self.client_list[self.playing_turn_index]
|
|
turn_msg = {
|
|
"type": "SEARCH_TURN",
|
|
"active_player": str(current_addr),
|
|
"msg": f"🔍 Turno de {current_addr} para excavar."
|
|
}
|
|
print(f"[SEARCH_TURN] Jugador {self.playing_turn_index}: {current_addr}")
|
|
self._broadcast_unlocked(turn_msg)
|
|
|
|
def handle_client(self, client_sock, addr):
|
|
print(f"[CONECTADO] {addr}")
|
|
with self.lock:
|
|
self.clients[client_sock] = addr
|
|
if addr not in self.lives:
|
|
self.lives[addr] = 3
|
|
|
|
try:
|
|
while True:
|
|
data = client_sock.recv(4096)
|
|
if not data: break
|
|
|
|
try:
|
|
text_chunk = data.decode('utf-8', errors='replace')
|
|
lines = text_chunk.split('\n')
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line: continue
|
|
try:
|
|
msg = json.loads(line)
|
|
self.process_message(client_sock, addr, msg)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
except Exception as e:
|
|
print(f"Error decode {addr}: {e}")
|
|
except Exception as e:
|
|
print(f"Error {addr}: {e}")
|
|
finally:
|
|
with self.lock:
|
|
self.remove_client(client_sock)
|
|
|
|
def process_message(self, client, addr, msg):
|
|
msg_type = msg.get('type')
|
|
print(f"[MSG] {addr}: {msg_type}") # Debug log
|
|
|
|
with self.lock:
|
|
if msg_type == 'START_GAME':
|
|
print(f"[START_GAME] Estado actual: {self.state}, Clientes: {len(self.clients)}")
|
|
if self.state == STATE_LOBBY:
|
|
# Reiniciar juego pero mantener clientes
|
|
self.round = 1
|
|
self.bombs = set()
|
|
self.revealed = set()
|
|
|
|
# Re-registrar vidas para los presentes
|
|
self.lives = {}
|
|
for c_addr in self.clients.values():
|
|
self.lives[c_addr] = 3
|
|
|
|
print(f"[START_GAME] Iniciando ronda 1 con {len(self.clients)} jugadores")
|
|
self.start_round()
|
|
else:
|
|
print(f"[START_GAME] Ignorado - estado: {self.state}")
|
|
|
|
elif msg_type == 'PLACE_BOMB':
|
|
if self.state == STATE_PLACING:
|
|
# Verificar turno
|
|
current_turn_addr = self.client_list[self.placing_turn_index]
|
|
if str(addr) != str(current_turn_addr):
|
|
return # No es su turno
|
|
|
|
x, y = msg['x'], msg['y']
|
|
# Evitar poner bomba donde ya hay (opcional, o permite solapar)
|
|
if (x,y) in self.bombs:
|
|
return # Ya hay bomba aqui
|
|
|
|
self.bombs.add((x, y))
|
|
self.current_player_bombs_placed += 1
|
|
|
|
# FLASH: Mostrar bomba 1 segundo
|
|
self._broadcast_unlocked({
|
|
"type": "BOMB_flash",
|
|
"x": x, "y": y,
|
|
"who": str(addr)
|
|
})
|
|
|
|
if self.current_player_bombs_placed >= self.bombs_to_place_per_player:
|
|
self.placing_turn_index += 1
|
|
self.next_placement_turn()
|
|
|
|
elif msg_type == 'CLICK_CELL':
|
|
if self.state == STATE_PLAYING:
|
|
# Verificar turno
|
|
current_turn_addr = self.client_list[self.playing_turn_index]
|
|
if str(addr) != str(current_turn_addr):
|
|
print(f"[CLICK_CELL] Ignorado - no es el turno de {addr}")
|
|
return # No es su turno
|
|
|
|
x, y = msg['x'], msg['y']
|
|
|
|
# Verificar si ya fue revelada
|
|
if (x, y) in self.revealed:
|
|
print(f"[CLICK_CELL] Celda {x},{y} ya revelada")
|
|
return # Ya revelada, no contar como turno
|
|
|
|
if (x, y) in self.bombs:
|
|
# BOOM logic - el jugador pierde una vida
|
|
self.lives[addr] -= 1
|
|
|
|
# Notificar explosión a todos
|
|
self._broadcast_unlocked({
|
|
"type": "EXPLOSION",
|
|
"x": x, "y": y,
|
|
"who": str(addr),
|
|
"lives": self.lives[addr],
|
|
"player_addr": str(addr)
|
|
})
|
|
|
|
# LÓGICA DE AVANCE DE RONDA
|
|
# Verificar si el jugador perdió todas sus vidas
|
|
if self.lives[addr] <= 0:
|
|
# ¡Este jugador perdió! El otro gana
|
|
self._broadcast_unlocked({
|
|
"type": "GAME_OVER",
|
|
"loser": str(addr),
|
|
"msg": f"💀 ¡{addr} ha perdido todas sus vidas! ¡GAME OVER!"
|
|
})
|
|
self.reset_game()
|
|
return
|
|
|
|
# Si estamos en ronda 5 o más, repetir la ronda
|
|
if self.round >= 5:
|
|
self._broadcast_unlocked({
|
|
"type": "ROUND_ADVANCE",
|
|
"msg": f"💥 ¡Explosión! Repitiendo ronda final..."
|
|
})
|
|
# Repetir misma ronda (no incrementar)
|
|
threading.Timer(3.0, self.start_round_with_lock).start()
|
|
else:
|
|
# Avanzar a siguiente ronda
|
|
self.round += 1
|
|
self._broadcast_unlocked({
|
|
"type": "ROUND_ADVANCE",
|
|
"msg": f"💥 ¡Explosión! Pasando a ronda {self.round}..."
|
|
})
|
|
threading.Timer(3.0, self.start_round_with_lock).start()
|
|
return # No avanzar turno, la ronda terminó
|
|
|
|
else:
|
|
# Safe
|
|
self.revealed.add((x, y))
|
|
self._broadcast_unlocked({
|
|
"type": "SAFE",
|
|
"x": x, "y": y
|
|
})
|
|
|
|
# Avanzar turno
|
|
self.playing_turn_index += 1
|
|
if self.playing_turn_index >= len(self.client_list):
|
|
self.playing_turn_index = 0
|
|
self.notify_playing_turn()
|
|
|
|
elif msg_type == 'CHECK_DUNGEON_CLEARED':
|
|
# Solo puede verificar el jugador que tiene el turno
|
|
if self.state == STATE_PLAYING:
|
|
current_turn_addr = self.client_list[self.playing_turn_index]
|
|
if str(addr) != str(current_turn_addr):
|
|
self._broadcast_unlocked({
|
|
"type": "WARNING",
|
|
"msg": "🛑 ¡Solo el jugador activo puede reclamar victoria!"
|
|
})
|
|
return
|
|
|
|
# Validar
|
|
bombs_exploded = 0 # No trackeamos exploded aparte, pero asumimos que siguen en self.bombs
|
|
|
|
# Logic: Si quedan celdas NO reveladas y NO son bombas -> Faltan cosas
|
|
# Grid total
|
|
total_cells = self.grid_size * self.grid_size
|
|
safe_cells_count = total_cells - len(self.bombs)
|
|
|
|
# Count safely revealed
|
|
# revealed set only contains SAFE cells based on logic above
|
|
if len(self.revealed) >= safe_cells_count:
|
|
# Win Round
|
|
self._broadcast_unlocked({"type": "ROUND_WIN", "msg": "¡Zona despejada!"})
|
|
self.round += 1
|
|
if self.round > 5:
|
|
self._broadcast_unlocked({"type": "GAME_WIN", "msg": "¡JUEGO COMPLETADO!"})
|
|
self.reset_game()
|
|
else:
|
|
threading.Timer(3.0, self.start_round).start()
|
|
else:
|
|
self._broadcast_unlocked({
|
|
"type": "WARNING",
|
|
"msg": "¡Aún hay zonas sin explorar!"
|
|
})
|
|
|
|
def start_server():
|
|
server = GameServer()
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
s.bind((HOST, PORT))
|
|
s.listen(5)
|
|
print(f"Servidor Juego escuchando en {HOST}:{PORT}")
|
|
|
|
while True:
|
|
conn, addr = s.accept()
|
|
t = threading.Thread(target=server.handle_client, args=(conn, addr), daemon=True)
|
|
t.start()
|
|
|
|
if __name__ == '__main__':
|
|
start_server()
|