Two phones, one match: the shot is the message, the receiver is the judge
VS A FRIEND on the mode screen (host a code, join a code), a ~110-line RFC6455 relay in server.py that holds no game state, and src/net.js: each phone plays itself near-side and the opponent's court is this one rotated half a turn about the net. The phone a ball flies toward makes every call on it; the striker's copy is picture. Six tests pin the rule from both seats. No reconnect yet — a dropped radio is a card and a reload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
108ed695eb
commit
f302354bb2
11 files changed
+493
-21
No files matched your search
@@ -14,7 +14,7 @@ board is a wall for writing your name on, and the client is public JS that could
|
||||
anything regardless. What IS enforced is that nothing here can be made to crash, hang or grow
|
||||
without bound by a hostile request — see clean() and the caps below.
|
||||
"""
|
||||
import json, os, re, threading, time
|
||||
import base64, hashlib, json, os, random, re, threading, time
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -73,6 +73,48 @@ def rank_of(rows, name):
|
||||
return next((i + 1 for i, r in enumerate(rows) if r.get('n') == name), len(rows))
|
||||
|
||||
|
||||
# ---- the multiplayer relay: RFC6455 on the standard library, because that is all a dumb relay
|
||||
# needs. The netcode's sync unit is THE SHOT (see PROJECT.md, LATER item 7), so this server holds
|
||||
# no game state at all: it mints a 4-letter room code, pairs exactly two sockets, and forwards
|
||||
# text frames verbatim between them. The clients judge everything; a hostile client can only cheat
|
||||
# its friend, which is the agreed trust model for friend codes. Caps mirror the score API's stance:
|
||||
# nothing here may crash, hang or grow without bound on a hostile request.
|
||||
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||
WS_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789' # read aloud between phones: no I/L/O/U/0/1
|
||||
WS_MAX = 2048 # a shot event is ~120 bytes; anything larger is not one
|
||||
ROOMS = {} # code -> [host_conn] while waiting; deleted on pairing
|
||||
ws_lock = threading.Lock()
|
||||
|
||||
|
||||
def ws_send(conn, text):
|
||||
data = text if isinstance(text, bytes) else text.encode()
|
||||
head = bytes([0x81, len(data)]) if len(data) < 126 else bytes([0x81, 126]) + len(data).to_bytes(2, 'big')
|
||||
try:
|
||||
with conn['lock']: # relay thread and own thread share this socket
|
||||
conn['sock'].sendall(head + data)
|
||||
except OSError:
|
||||
pass # a dead peer is handled by its own recv loop
|
||||
|
||||
|
||||
def ws_read(rf):
|
||||
"""One frame -> (opcode, payload) or (None, b'') on EOF/oversize. Client frames are masked."""
|
||||
h = rf.read(2)
|
||||
if len(h) < 2:
|
||||
return None, b''
|
||||
op, n = h[0] & 0x0f, h[1] & 0x7f
|
||||
if n == 126:
|
||||
n = int.from_bytes(rf.read(2), 'big')
|
||||
elif n == 127:
|
||||
n = int.from_bytes(rf.read(8), 'big')
|
||||
if n > WS_MAX:
|
||||
return None, b''
|
||||
mask = rf.read(4) if h[1] & 0x80 else None
|
||||
data = rf.read(n)
|
||||
if mask:
|
||||
data = bytes(b ^ mask[i & 3] for i, b in enumerate(data))
|
||||
return op, data
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *a, **kw):
|
||||
super().__init__(*a, directory=HERE, **kw)
|
||||
@@ -90,8 +132,76 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
# ---- the relay endpoint ----
|
||||
def handle_ws(self):
|
||||
key = self.headers.get('Sec-WebSocket-Key')
|
||||
if not key or (self.headers.get('Upgrade') or '').lower() != 'websocket':
|
||||
return self.send_error(400)
|
||||
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
|
||||
self.connection.sendall(b'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n'
|
||||
b'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + accept.encode() + b'\r\n\r\n')
|
||||
self.close_connection = True
|
||||
# a phone that vanishes without FIN must not hold a thread and a room forever — the client
|
||||
# heartbeats every 20 s, so 75 s of silence is a dead radio, not a slow one
|
||||
self.connection.settimeout(75)
|
||||
me = {'sock': self.connection, 'lock': threading.Lock(), 'peer': None, 'room': None}
|
||||
want = re.sub(r'[^A-Z0-9]', '', (self.path.split('room=')[1] if 'room=' in self.path else '').upper())[:8]
|
||||
with ws_lock:
|
||||
if want: # joining a friend
|
||||
room = ROOMS.pop(want, None)
|
||||
if not room:
|
||||
ws_send(me, '{"t":"gone"}')
|
||||
return
|
||||
host = room[0]
|
||||
me['peer'] = host
|
||||
host['peer'] = me # the code is single-use: popped above, never re-joinable
|
||||
else: # hosting: mint a code nobody is holding
|
||||
code = ''.join(random.choice(WS_ALPHABET) for _ in range(4))
|
||||
while code in ROOMS:
|
||||
code = ''.join(random.choice(WS_ALPHABET) for _ in range(4))
|
||||
ROOMS[code] = [me]
|
||||
me['room'] = code
|
||||
if len(ROOMS) > 200: # a lobby is two people; 200 stale rooms is a flood
|
||||
ROOMS.pop(next(iter(ROOMS)))
|
||||
if me['peer']: # paired: the host serves first, on both scoreboards
|
||||
ws_send(me['peer'], '{"t":"start","side":"A"}')
|
||||
ws_send(me, '{"t":"start","side":"B"}')
|
||||
else:
|
||||
ws_send(me, json.dumps({'t': 'room', 'code': me['room']}))
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
op, data = ws_read(self.rfile)
|
||||
except OSError: # timeout or reset: same as hanging up
|
||||
break
|
||||
if op is None or op == 8:
|
||||
break
|
||||
if op == 9: # protocol ping -> pong, payload echoed
|
||||
try:
|
||||
with me['lock']:
|
||||
me['sock'].sendall(bytes([0x8A, len(data)]) + data)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if op != 1 or data == b'{"t":"hb"}':
|
||||
continue # the heartbeat is for the socket, not the friend
|
||||
if me['peer']:
|
||||
ws_send(me['peer'], data) # the whole job: forward the frame verbatim
|
||||
finally:
|
||||
with ws_lock:
|
||||
if me['room'] and ROOMS.get(me['room']) and ROOMS[me['room']][0] is me:
|
||||
del ROOMS[me['room']] # nobody ever came
|
||||
peer = me['peer']
|
||||
me['peer'] = None
|
||||
if peer:
|
||||
peer['peer'] = None
|
||||
if peer:
|
||||
ws_send(peer, '{"t":"bye"}')
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split('?')[0]
|
||||
if path == '/ws':
|
||||
return self.handle_ws()
|
||||
if path == '/api/scores':
|
||||
with lock:
|
||||
return self.reply(200, {'scores': public(load())})
|
||||
|
||||
Reference in new issue
Block a user