Files
moonchess/server.py
T
tukaunuandClaude Opus 5.5 c52f30dc59 Moonchess: vanishing tic-tac-toe, online and practice modes
Online play over WebSockets with any-string game codes, seats and spectators,
and a 1-second heartbeat. Practice mode against Columbina, backed by a full
solver, with an accuracy slider, spoilers, undo and piece freshness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 03:36:46 +02:00

244 lines
7.9 KiB
Python

"""Moonchess server: serves the page and runs live games over WebSockets.
Any string works as a game code. Joining a code with no game creates one, and a game
is deleted as soon as nobody is connected to it.
Seating: each room keeps a list of slots. Slots 0 and 1 are the players (X and O),
everything after is spectators. A new connection takes the earliest empty slot,
so when a player drops, whoever connects next -- including a spectator who
refreshes fast enough -- inherits their seat. That is intentional.
"""
import argparse
import asyncio
import logging
import time
from pathlib import Path
from aiohttp import WSMsgType, web
from game import Game
STATIC_DIR = Path(__file__).parent / "static"
STALE_AFTER = 2.0 # seconds without a message before a seat shows as disconnected
HEARTBEAT_TIMEOUT = 4.0 # seconds without a message before a client is dropped
MAX_ROOMS = 10_000
MAX_CODE_LENGTH = 32
CLOSE_INVALID_CODE = 4400
CLOSE_SERVER_FULL = 4503
MAX_MESSAGE_SIZE = 4096 # bytes; real messages are tiny, larger ones close the connection
log = logging.getLogger("moonchess")
class Client:
def __init__(self, ws, client_id):
self.ws = ws
self.client_id = client_id
self.last_seen = time.monotonic()
class Room:
def __init__(self, token):
self.token = token
self.game = Game()
self.slots = [] # Client or None
self.last_seats = None # seat_status() as of the last broadcast
def join(self, client):
# A reconnect from the same page load reclaims its own not-yet-reaped slot.
for i, c in enumerate(self.slots):
if c is not None and client.client_id and c.client_id == client.client_id:
self.slots[i] = client
asyncio.ensure_future(c.ws.close())
return
for i, c in enumerate(self.slots):
if c is None:
self.slots[i] = client
return
self.slots.append(client)
def leave(self, client):
if client not in self.slots:
return False
i = self.slots.index(client)
self.slots[i] = None
if i < 2:
self.game.rematch_votes.discard(i)
while len(self.slots) > 2 and self.slots[-1] is None:
self.slots.pop()
return True
def connected(self):
return [c for c in self.slots if c is not None]
def seat_of(self, client):
i = self.slots.index(client)
return i if i < 2 else None
def players_present(self):
return [len(self.slots) > i and self.slots[i] is not None for i in (0, 1)]
def seat_status(self):
"""'connected', 'disconnected' (silent but not yet dropped) or 'empty' per seat."""
now = time.monotonic()
status = []
for i in (0, 1):
c = self.slots[i] if len(self.slots) > i else None
if c is None:
status.append("empty")
elif now - c.last_seen > STALE_AFTER:
status.append("disconnected")
else:
status.append("connected")
return status
async def broadcast(self):
self.last_seats = self.seat_status()
base = {
"type": "state",
"token": self.token,
"first": self.game.first,
"players": self.last_seats,
"spectators": sum(1 for c in self.slots[2:] if c is not None),
**self.game.to_dict(),
}
for c in self.connected():
try:
await c.ws.send_json({**base, "seat": self.seat_of(c)})
except (ConnectionError, RuntimeError):
pass # the reaper or the handler will clean it up
rooms: dict[str, Room] = {}
def normalize_code(raw):
"""Case-insensitive, with surrounding and repeated whitespace ignored."""
return " ".join(raw.split()).upper()
async def index(request):
return web.FileResponse(STATIC_DIR / "index.html")
async def game_socket(request):
code = normalize_code(request.query.get("game", ""))
ws = web.WebSocketResponse(max_msg_size=MAX_MESSAGE_SIZE)
await ws.prepare(request)
if not 0 < len(code) <= MAX_CODE_LENGTH:
await ws.close(code=CLOSE_INVALID_CODE,
message=f"Codes must be 1-{MAX_CODE_LENGTH} characters".encode())
return ws
room = rooms.get(code)
if room is None:
if len(rooms) >= MAX_ROOMS:
await ws.close(code=CLOSE_SERVER_FULL, message=b"Server is full, try again later")
return ws
room = rooms[code] = Room(code)
log.info("created room %r (%d rooms)", code, len(rooms))
client = Client(ws, request.query.get("client", "")[:64])
room.join(client)
await room.broadcast()
try:
async for msg in ws:
if msg.type != WSMsgType.TEXT:
continue
client.last_seen = time.monotonic()
try:
data = msg.json()
except ValueError:
continue
if not isinstance(data, dict):
continue
kind = data.get("type")
if kind == "ping":
await ws.send_json({"type": "pong"})
continue
if client not in room.slots:
break # reaped or replaced while this message was in flight
seat = room.seat_of(client)
if seat is None:
continue # spectators can't act
if kind == "move":
if not all(room.players_present()):
error = "Waiting for an opponent"
else:
error = room.game.play(seat, data.get("cell"))
if error:
await ws.send_json({"type": "error", "message": error})
else:
await room.broadcast()
elif kind == "rematch":
room.game.vote_rematch(seat)
await room.broadcast()
finally:
if room.leave(client) and not delete_if_empty(room):
await room.broadcast()
return ws
def delete_if_empty(room):
if room.connected():
return False
if rooms.get(room.token) is room:
del rooms[room.token]
log.info("deleted empty room %r (%d rooms)", room.token, len(rooms))
return True
async def reaper(app):
"""Drop silent clients, announce seats going quiet, and delete abandoned rooms."""
while True:
await asyncio.sleep(1)
now = time.monotonic()
for room in list(rooms.values()):
changed = False
for c in room.connected():
if now - c.last_seen > HEARTBEAT_TIMEOUT:
room.leave(c)
asyncio.ensure_future(c.ws.close())
changed = True
if delete_if_empty(room):
continue
if changed or room.seat_status() != room.last_seats:
await room.broadcast()
async def start_reaper(app):
task = asyncio.create_task(reaper(app))
yield
task.cancel()
@web.middleware
async def no_cache(request, handler):
# Keep Cloudflare and browsers from serving stale copies after an update.
response = await handler(request)
if not isinstance(response, web.WebSocketResponse):
response.headers["Cache-Control"] = "no-cache"
return response
def make_app():
app = web.Application(middlewares=[no_cache])
app.router.add_get("/", index)
app.router.add_get("/ws", game_socket)
app.router.add_static("/static", STATIC_DIR)
app.cleanup_ctx.append(start_reaper)
return app
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Moonchess server")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
web.run_app(make_app(), host=args.host, port=args.port)