Compare commits

...

13 Commits

Author SHA1 Message Date
Vladyslav Doloman
c4a8501635 Fix: indent await send(full, peer) inside STATE_FULL if block to resolve SyntaxError 2025-10-08 01:05:47 +03:00
Vladyslav Doloman
56ac74e916 Mode: implement MODE=net to run both WebTransport (HTTP/3) and QUIC datagram servers concurrently via MultiTransport; add port envs WT_PORT/QUIC_PORT 2025-10-08 01:00:47 +03:00
Vladyslav Doloman
b94aac71f8 Mode: add scaffolding for combined network mode name (net) in help; introduce MultiTransport 2025-10-08 00:53:14 +03:00
Vladyslav Doloman
7337c27898 Transport: add MultiTransport to run WT and QUIC concurrently and route sends 2025-10-08 00:51:35 +03:00
Vladyslav Doloman
921cc42fd4 CLI: add --help to print usage and env-based configuration hints 2025-10-08 00:33:07 +03:00
Vladyslav Doloman
99e818e0fd Fix: define _run_tasks_with_optional_timeout before use; tidy run_webtransport block 2025-10-08 00:29:18 +03:00
Vladyslav Doloman
1bd4d9eac7 Fix: clean up run.py newlines and indentation; add optional RUN_SECONDS timeout helper for server tasks 2025-10-08 00:27:10 +03:00
Vladyslav Doloman
eeabda725e Logging: add informative console logs for startup, ports, connections, JOIN/deny, input, and periodic events
- Configure logging via LOG_LEVEL env (default INFO)
- Log when servers start listening (WT/QUIC/InMemory/HTTPS static)
- Log WT CONNECT accept, QUIC peer connect, datagram traffic at DEBUG
- Log GameServer creation, tick loop start, JOIN accept/deny, config_update broadcasts, and input reception
2025-10-08 00:01:05 +03:00
Vladyslav Doloman
c97c5c4723 Dev: add simple HTTPS static server (serves client/). Enabled in WT mode by default via STATIC=1; uses same cert/key as QUIC/WT 2025-10-07 23:23:29 +03:00
Vladyslav Doloman
0ceea925cd Client: minimal browser WebTransport client (HTML/JS)
- index.html with connect UI, canvas renderer, overlay
- protocol.js with header, QUIC varint, JOIN/INPUT builders, STATE_FULL parser
- client.js to connect, send JOIN, read datagrams, render snakes/apples; basic input handling
2025-10-07 22:49:51 +03:00
Vladyslav Doloman
088c36396b WebTransport: add HTTP/3 WebTransport datagram server using aioquic; run mode switch via MODE=wt|quic|mem 2025-10-07 22:36:06 +03:00
Vladyslav Doloman
1f5ca02ca4 Repo hygiene: restore IDEAS.md; add .gitignore for __pycache__ and pyc; untrack cached bytecode 2025-10-07 20:54:56 +03:00
Vladyslav Doloman
e79c523034 Transport: integrate aioquic QUIC datagram server skeleton (QuicWebTransportServer) and QUIC mode in run.py
- New server/quic_transport.py using aioquic to accept QUIC connections and datagrams
- run.py: QUIC mode when QUIC_CERT/QUIC_KEY provided; else in-memory
- requirements.txt: aioquic + cryptography
2025-10-07 20:53:24 +03:00
12 changed files with 782 additions and 8 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Byte-compiled / cache
__pycache__/
*.py[cod]
# Virtual environments
.venv/
venv/
# Certs (provide via env variables or local path)
*.pem
*.crt
*.key

163
client/client.js Normal file
View File

@@ -0,0 +1,163 @@
import { PacketType, Direction, packHeader, parseHeader, buildJoin, buildInput, parseJoinAck, parseStateFullBody } from './protocol.js';
const ui = {
url: document.getElementById('url'),
hash: document.getElementById('hash'),
name: document.getElementById('name'),
connect: document.getElementById('connect'),
status: document.getElementById('status'),
overlay: document.getElementById('overlay'),
canvas: document.getElementById('view'),
};
let transport = null;
let writer = null;
let seq = 0;
let lastServerSeq = 0;
let inputSeq = 0;
let playerId = null;
let colorId = null;
let fieldW = 60, fieldH = 40;
let tickRate = 10;
const snakes = new Map(); // id -> {len, head:[x,y], dirs:[]}
let apples = [];
function setStatus(t) { ui.status.textContent = t; }
function nextSeq() { seq = (seq + 1) & 0xffff; return seq; }
function colorForId(id) {
const palette = [
'#e6194B','#3cb44b','#ffe119','#4363d8','#f58231','#911eb4','#46f0f0','#f032e6',
'#bcf60c','#fabebe','#008080','#e6beff','#9A6324','#fffac8','#800000','#aaffc3',
'#808000','#ffd8b1','#000075','#808080','#ffffff','#000000'
];
return palette[id % palette.length];
}
function decodeSnakeCells(s) {
const cells = [];
let x = s.hx, y = s.hy;
cells.push([x, y]);
for (let d of s.dirs) {
if (d === Direction.UP) y -= 1;
else if (d === Direction.RIGHT) x += 1;
else if (d === Direction.DOWN) y += 1;
else if (d === Direction.LEFT) x -= 1;
cells.push([x, y]);
}
return cells;
}
function render() {
const ctx = ui.canvas.getContext('2d');
const W = ui.canvas.width = window.innerWidth;
const H = ui.canvas.height = window.innerHeight;
ctx.fillStyle = '#111'; ctx.fillRect(0,0,W,H);
const cell = Math.floor(Math.min(W / fieldW, H / fieldH));
const ox = Math.floor((W - cell*fieldW)/2);
const oy = Math.floor((H - cell*fieldH)/2);
// grid (optional)
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
for (let x=0;x<=fieldW;x++){ ctx.beginPath(); ctx.moveTo(ox + x*cell, oy); ctx.lineTo(ox + x*cell, oy + fieldH*cell); ctx.stroke(); }
for (let y=0;y<=fieldH;y++){ ctx.beginPath(); ctx.moveTo(ox, oy + y*cell); ctx.lineTo(ox + fieldW*cell, oy + y*cell); ctx.stroke(); }
// apples
ctx.fillStyle = '#f00';
for (const [ax, ay] of apples) {
ctx.fillRect(ox + ax*cell, oy + ay*cell, cell, cell);
}
// snakes
for (const [sid, s] of snakes) {
const cells = decodeSnakeCells({hx:s.hx, hy:s.hy, dirs:s.dirs});
ctx.fillStyle = colorForId(sid);
for (const [x,y] of cells) ctx.fillRect(ox + x*cell, oy + y*cell, cell, cell);
}
requestAnimationFrame(render);
}
async function readLoop() {
try {
for await (const datagram of transport.datagrams.readable) {
const dv = new DataView(datagram.buffer, datagram.byteOffset, datagram.byteLength);
if (dv.byteLength < 5) continue;
const type = dv.getUint8(1);
lastServerSeq = dv.getUint16(3);
const expectTick = (type === PacketType.STATE_FULL || type === PacketType.STATE_DELTA || type === PacketType.PART || type === PacketType.CONFIG_UPDATE);
const [ver, ptype, flags, seq, tick, off0] = parseHeader(dv, 0, expectTick);
let off = off0;
switch (ptype) {
case PacketType.JOIN_ACK: {
const info = parseJoinAck(dv, off);
playerId = info.playerId; colorId = info.colorId; fieldW = info.width; fieldH = info.height; tickRate = info.tickRate;
ui.overlay.textContent = 'connected — press space to join';
break;
}
case PacketType.JOIN_DENY: {
// read reason
const [len, p] = quicVarintDecode(dv, off); off = p;
const bytes = new Uint8Array(dv.buffer, dv.byteOffset + off, len);
const reason = new TextDecoder().decode(bytes);
setStatus('Join denied: ' + reason);
break;
}
case PacketType.STATE_FULL: {
const { snakes: sn, apples: ap } = parseStateFullBody(dv, off);
snakes.clear();
for (const s of sn) snakes.set(s.id, { len: s.len, hx: s.hx, hy: s.hy, dirs: s.dirs });
apples = ap;
ui.overlay.style.display = snakes.size ? 'none' : 'flex';
break;
}
case PacketType.STATE_DELTA: {
// Minimal: ignore detailed deltas for now; rely on periodic full (good enough for a demo)
break;
}
default: break;
}
}
} catch (e) {
setStatus('Read error: ' + e);
}
}
async function connectWT() {
const url = ui.url.value.trim();
const hashHex = ui.hash.value.trim();
setStatus('connecting...');
const opts = {};
if (hashHex) {
const bytes = new Uint8Array(hashHex.match(/.{1,2}/g).map(b => parseInt(b,16)));
opts.serverCertificateHashes = [{ algorithm: 'sha-256', value: bytes }];
}
const wt = new WebTransport(url, opts);
await wt.ready;
transport = wt;
writer = wt.datagrams.writable.getWriter();
setStatus('connected');
readLoop();
requestAnimationFrame(render);
// Send JOIN immediately (spectator → player upon space handled below)
const pkt = buildJoin(1, nextSeq(), ui.name.value.trim());
await writer.write(pkt);
}
function dirFromKey(e) {
if (e.key === 'ArrowUp' || e.key === 'w') return Direction.UP;
if (e.key === 'ArrowRight' || e.key === 'd') return Direction.RIGHT;
if (e.key === 'ArrowDown' || e.key === 's') return Direction.DOWN;
if (e.key === 'ArrowLeft' || e.key === 'a') return Direction.LEFT;
return null;
}
async function onKey(e) {
const d = dirFromKey(e);
if (d == null) return;
if (!writer) return;
const pkt = buildInput(1, nextSeq(), lastServerSeq, (inputSeq = (inputSeq + 1) & 0xffff), 0, [{ rel: 0, dir: d }]);
try { await writer.write(pkt); } catch (err) { /* ignore */ }
}
ui.connect.onclick = () => { connectWT().catch(e => setStatus('connect failed: ' + e)); };
window.addEventListener('keydown', onKey);
window.addEventListener('resize', render);

30
client/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Multiplayer Snake (WebTransport)</title>
<style>
html, body { height: 100%; margin: 0; background: #111; color: #ddd; font-family: system-ui, sans-serif; }
#ui { position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.6); padding: 10px; border-radius: 6px; }
label { display: block; margin: 4px 0; font-size: 12px; }
input { width: 360px; }
#overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; color: rgba(255,255,255,0.85); font-size: 24px; font-weight: 600; }
canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; }
</style>
</head>
<body>
<div id="ui">
<label>Server URL (WebTransport): <input id="url" value="https://localhost:4433/"/></label>
<label>SHA-256 Cert Hash (hex, optional for self-signed): <input id="hash" placeholder="e.g., aabbcc..."/></label>
<label>Name: <input id="name" value="guest"/></label>
<button id="connect">Connect</button>
<span id="status"></span>
</div>
<div id="overlay">press space to join</div>
<canvas id="view"></canvas>
<script src="protocol.js"></script>
<script src="client.js"></script>
</body>
</html>

160
client/protocol.js Normal file
View File

@@ -0,0 +1,160 @@
// Minimal protocol helpers in JS (match server/protocol.py)
export const PacketType = {
JOIN: 0,
JOIN_ACK: 1,
JOIN_DENY: 2,
INPUT: 3,
INPUT_BROADCAST: 4,
STATE_DELTA: 5,
STATE_FULL: 6,
PART: 7,
CONFIG_UPDATE: 8,
PING: 9,
PONG: 10,
ERROR: 11,
};
export const Direction = { UP: 0, RIGHT: 1, DOWN: 2, LEFT: 3 };
export function quicVarintEncode(n) {
if (n <= 0x3f) return new Uint8Array([n & 0x3f]);
if (n <= 0x3fff) {
const v = 0x4000 | n;
return new Uint8Array([(v >> 8) & 0xff, v & 0xff]);
}
if (n <= 0x3fffffff) {
const v = 0x80000000 | n;
return new Uint8Array([(v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff]);
}
throw new Error("varint too large for demo");
}
export function quicVarintDecode(view, offset) {
const first = view.getUint8(offset);
const prefix = first >> 6;
if (prefix === 0) return [first & 0x3f, offset + 1];
if (prefix === 1) return [view.getUint16(offset) & 0x3fff, offset + 2];
if (prefix === 2) return [view.getUint32(offset) & 0x3fffffff, offset + 4];
throw new Error("8-byte varint not supported in demo");
}
export function packHeader(ver, type, flags, seq, tick /* or null */) {
const len = tick == null ? 5 : 7;
const buf = new ArrayBuffer(len);
const dv = new DataView(buf);
dv.setUint8(0, ver & 0xff);
dv.setUint8(1, type & 0xff);
dv.setUint8(2, flags & 0xff);
dv.setUint16(3, seq & 0xffff);
if (tick != null) dv.setUint16(5, tick & 0xffff);
return new Uint8Array(buf);
}
export function parseHeader(dv, offset = 0, expectTick = false) {
if (dv.byteLength - offset < 5) throw new Error("small header");
const ver = dv.getUint8(offset);
const type = dv.getUint8(offset + 1);
const flags = dv.getUint8(offset + 2);
const seq = dv.getUint16(offset + 3);
let tick = null;
let off = offset + 5;
if (expectTick) {
tick = dv.getUint16(off);
off += 2;
}
return [ver, type, flags, seq, tick, off];
}
export function buildJoin(ver, seq, name, preferredColor = null) {
const enc = new TextEncoder();
const nb = enc.encode(name);
const nameLen = quicVarintEncode(nb.length);
const header = packHeader(ver, PacketType.JOIN, 0, seq, null);
const extra = preferredColor == null ? new Uint8Array([]) : new Uint8Array([preferredColor & 0xff]);
const out = new Uint8Array(header.length + nameLen.length + nb.length + extra.length);
out.set(header, 0);
out.set(nameLen, header.length);
out.set(nb, header.length + nameLen.length);
out.set(extra, header.length + nameLen.length + nb.length);
return out;
}
export function buildInput(ver, seq, ackSeq, inputSeq, baseTick, events /* [{rel, dir}] */) {
const header = packHeader(ver, PacketType.INPUT, 0, seq, null);
const evParts = [];
evParts.push(...[ackSeq >> 8, ackSeq & 0xff, inputSeq >> 8, inputSeq & 0xff, baseTick >> 8, baseTick & 0xff]);
const evCount = quicVarintEncode(events.length);
const chunks = [header, new Uint8Array(evParts), evCount];
for (const e of events) {
const off = quicVarintEncode(e.rel);
chunks.push(off, new Uint8Array([e.dir & 0x03]));
}
const size = chunks.reduce((s, a) => s + a.length, 0);
const out = new Uint8Array(size);
let p = 0;
for (const c of chunks) { out.set(c, p); p += c.length; }
return out;
}
export function parseJoinAck(dv, off) {
const playerId = dv.getUint8(off); off += 1;
const colorId = dv.getUint8(off); off += 1;
const width = dv.getUint8(off); off += 1;
const height = dv.getUint8(off); off += 1;
const tickRate = dv.getUint8(off); off += 1;
const wrapEdges = dv.getUint8(off) !== 0; off += 1;
const applesPerSnake = dv.getUint8(off); off += 1;
const applesCap = dv.getUint8(off); off += 1;
const compressionMode = dv.getUint8(off); off += 1;
return { playerId, colorId, width, height, tickRate, wrapEdges, applesPerSnake, applesCap, compressionMode, off };
}
export function parseStateFullBody(dv, off) {
const snakes = [];
// snakes count
let [n, p] = quicVarintDecode(dv, off); off = p;
for (let i = 0; i < n; i++) {
const id = dv.getUint8(off); off += 1;
const len = dv.getUint16(off); off += 2;
const hx = dv.getUint8(off); off += 1;
const hy = dv.getUint8(off); off += 1;
// TLV
let [t, p1] = quicVarintDecode(dv, off); off = p1;
let [L, p2] = quicVarintDecode(dv, off); off = p2;
const tStart = off;
const tEnd = off + L;
let dirs = [];
if (t === 0 /* 2-bit */) {
const needed = Math.max(0, (len - 1));
let bits = 0, acc = 0, di = 0;
for (let iB = off; iB < tEnd && di < needed; iB++) {
acc |= dv.getUint8(iB) << bits;
bits += 8;
while (bits >= 2 && di < needed) {
dirs.push(acc & 0x03);
acc >>= 2; bits -= 2; di++;
}
}
} else if (t === 1 /* RLE */) {
let countAccum = 0;
while (off < tEnd && countAccum < (len - 1)) {
const dir = dv.getUint8(off); off += 1;
let c; [c, off] = quicVarintDecode(dv, off);
for (let k = 0; k < c; k++) dirs.push(dir & 0x03);
countAccum += c;
}
} else {
// chunk types not expected in full body for initial join; skip
off = tEnd;
}
off = tEnd;
snakes.push({ id, len, hx, hy, dirs });
}
// apples
let m; [m, off] = quicVarintDecode(dv, off);
const apples = [];
for (let i = 0; i < m; i++) { apples.push([dv.getUint8(off++), dv.getUint8(off++)]); }
return { snakes, apples, off };
}

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
aioquic>=1.2.0
cryptography>=41.0.0

136
run.py
View File

@@ -1,10 +1,138 @@
from server.server import main import asyncio
import os
import logging
import sys
from server.server import GameServer
from server.config import ServerConfig
async def _run_tasks_with_optional_timeout(tasks):
"""Await tasks, optionally honoring RUN_SECONDS env var to cancel after a timeout."""
timeout_s = os.environ.get("RUN_SECONDS")
if not timeout_s:
await asyncio.gather(*tasks)
return
try:
await asyncio.wait_for(asyncio.gather(*tasks), timeout=float(timeout_s))
except asyncio.TimeoutError:
logging.info("Timeout reached (RUN_SECONDS=%s); stopping server tasks...", timeout_s)
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def run_in_memory():
from server.transport import InMemoryTransport
cfg = ServerConfig()
server = GameServer(transport=InMemoryTransport(lambda d, p: server.on_datagram(d, p)), config=cfg)
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
await _run_tasks_with_optional_timeout(tasks)
async def run_quic():
from server.quic_transport import QuicWebTransportServer
cfg = ServerConfig()
host = os.environ.get("QUIC_HOST", "0.0.0.0")
port = int(os.environ.get("QUIC_PORT", "4433"))
cert = os.environ["QUIC_CERT"]
key = os.environ["QUIC_KEY"]
server = GameServer(transport=QuicWebTransportServer(host, port, cert, key, lambda d, p: server.on_datagram(d, p)), config=cfg)
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
await _run_tasks_with_optional_timeout(tasks)
async def run_webtransport():
from server.webtransport_server import WebTransportServer
from server.static_server import start_https_static
cfg = ServerConfig()
host = os.environ.get("WT_HOST", os.environ.get("QUIC_HOST", "0.0.0.0"))
port = int(os.environ.get("WT_PORT", os.environ.get("QUIC_PORT", "4433")))
cert = os.environ.get("WT_CERT") or os.environ["QUIC_CERT"]
key = os.environ.get("WT_KEY") or os.environ["QUIC_KEY"]
# Optional static HTTPS server for client assets
static = os.environ.get("STATIC", "1")
static_host = os.environ.get("STATIC_HOST", host)
static_port = int(os.environ.get("STATIC_PORT", "8443"))
static_root = os.environ.get("STATIC_ROOT", "client")
httpd = None
if static == "1":
httpd, _t = start_https_static(static_host, static_port, cert, key, static_root)
print(f"HTTPS static server: https://{static_host}:{static_port}/ serving '{static_root}'")
server = GameServer(transport=WebTransportServer(host, port, cert, key, lambda d, p: server.on_datagram(d, p)), config=cfg)
print(f"WebTransport server: https://{host}:{port}/ (HTTP/3)")
try:
tasks = [asyncio.create_task(server.transport.run()), asyncio.create_task(server.tick_loop())]
await _run_tasks_with_optional_timeout(tasks)
finally:
if httpd is not None:
httpd.shutdown()
if __name__ == "__main__": if __name__ == "__main__":
import asyncio
try: try:
asyncio.run(main()) if any(a in ("-h", "--help") for a in sys.argv[1:]):
print(
"Usage: python run.py [--mode mem|quic|wt|net] [--log-level LEVEL] [--run-seconds N]\n"
" TLS (for wt/quic): set QUIC_CERT/QUIC_KEY or WT_CERT/WT_KEY env vars\n"
" WT static server (MODE=wt): STATIC=1 [STATIC_HOST/PORT/ROOT]\n"
"Examples:\n MODE=wt QUIC_CERT=cert.pem QUIC_KEY=key.pem python run.py\n MODE=mem python run.py"
)
sys.exit(0)
# Logging setup
level = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=getattr(logging, level, logging.INFO), format="[%(asctime)s] %(levelname)s: %(message)s")
mode = os.environ.get("MODE", "mem").lower()
if mode == "wt":
logging.info("Starting in WebTransport mode")
asyncio.run(run_webtransport())
elif mode == "quic":
logging.info("Starting in QUIC datagram mode")
asyncio.run(run_quic())
elif mode == "net":
logging.info("Starting in combined WebTransport+QUIC mode")
from server.webtransport_server import WebTransportServer
from server.quic_transport import QuicWebTransportServer
from server.multi_transport import MultiTransport
cfg = ServerConfig()
host_wt = os.environ.get("WT_HOST", os.environ.get("QUIC_HOST", "0.0.0.0"))
port_wt = int(os.environ.get("WT_PORT", os.environ.get("QUIC_PORT", "4433")))
host_quic = os.environ.get("QUIC_HOST", host_wt)
port_quic = int(os.environ.get("QUIC_PORT", "4443"))
cert = os.environ.get("WT_CERT") or os.environ.get("QUIC_CERT")
key = os.environ.get("WT_KEY") or os.environ.get("QUIC_KEY")
if not cert or not key:
raise SystemExit("WT/QUIC cert/key required: set WT_CERT/WT_KEY or QUIC_CERT/QUIC_KEY")
async def _run_net():
server: GameServer
wt = WebTransportServer(host_wt, port_wt, cert, key, lambda d, p: server.on_datagram(d, p))
qu = QuicWebTransportServer(host_quic, port_quic, cert, key, lambda d, p: server.on_datagram(d, p))
m = MultiTransport(wt, qu)
server = GameServer(transport=m, config=cfg)
await asyncio.gather(m.run(), server.tick_loop())
asyncio.run(_run_net())
else:
logging.info("Starting in in-memory transport mode")
asyncio.run(run_in_memory())
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
async def _run_tasks_with_optional_timeout(tasks):
"""Await tasks, optionally honoring RUN_SECONDS env var to cancel after a timeout."""
timeout_s = os.environ.get("RUN_SECONDS")
if not timeout_s:
await asyncio.gather(*tasks)
return
try:
await asyncio.wait_for(asyncio.gather(*tasks), timeout=float(timeout_s))
except asyncio.TimeoutError:
logging.info("Timeout reached (RUN_SECONDS=%s); stopping server tasks...", timeout_s)
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)

26
server/multi_transport.py Normal file
View File

@@ -0,0 +1,26 @@
from __future__ import annotations
import asyncio
from .transport import DatagramServerTransport, TransportPeer
class MultiTransport(DatagramServerTransport):
"""Run both WebTransport and QUIC transports and route sends.
Inbound datagrams share the same on_datagram callback from GameServer,
so GameServer sees a unified source of datagrams.
"""
def __init__(self, wt_transport: DatagramServerTransport, quic_transport: DatagramServerTransport):
self._wt = wt_transport
self._quic = quic_transport
async def send(self, data: bytes, peer: TransportPeer) -> None:
# WebTransport peers are tuples (proto, flow_id); QUIC uses protocol instance
if isinstance(peer.addr, tuple) and len(peer.addr) == 2:
await self._wt.send(data, peer)
else:
await self._quic.send(data, peer)
async def run(self) -> None:
await asyncio.gather(self._wt.run(), self._quic.run())

79
server/quic_transport.py Normal file
View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict, Optional
from .transport import DatagramServerTransport, OnDatagram, TransportPeer
import logging
try:
from aioquic.asyncio import QuicConnectionProtocol, serve
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import DatagramFrameReceived, ProtocolNegotiated
except Exception: # pragma: no cover - optional dependency not installed in skeleton
QuicConnectionProtocol = object # type: ignore
QuicConfiguration = object # type: ignore
serve = None # type: ignore
DatagramFrameReceived = object # type: ignore
ProtocolNegotiated = object # type: ignore
class GameQuicProtocol(QuicConnectionProtocol): # type: ignore[misc]
def __init__(self, *args, on_datagram: OnDatagram, peers: Dict[int, "GameQuicProtocol"], **kwargs):
super().__init__(*args, **kwargs)
self._on_datagram = on_datagram
self._peers = peers
self._peer_id: Optional[int] = None
def quic_event_received(self, event) -> None: # type: ignore[override]
if isinstance(event, ProtocolNegotiated):
# Register by connection id
self._peer_id = int(self._quic.connection_id) # type: ignore[attr-defined]
self._peers[self._peer_id] = self
logging.info("QUIC peer connected: id=%s", self._peer_id)
elif isinstance(event, DatagramFrameReceived):
# Schedule async callback
if self._peer_id is None:
return
peer = TransportPeer(addr=self)
logging.debug("QUIC datagram received from id=%s, %d bytes", self._peer_id, len(event.data))
asyncio.ensure_future(self._on_datagram(bytes(event.data), peer))
async def send_datagram(self, data: bytes) -> None:
logging.debug("QUIC send datagram: %d bytes", len(data))
self._quic.send_datagram_frame(data) # type: ignore[attr-defined]
await self._loop.run_in_executor(None, self.transmit) # type: ignore[attr-defined]
class QuicWebTransportServer(DatagramServerTransport):
def __init__(self, host: str, port: int, certfile: str, keyfile: str, on_datagram: OnDatagram):
if serve is None:
raise RuntimeError("aioquic is not installed. Please `pip install aioquic`.")
self.host = host
self.port = port
self.certfile = certfile
self.keyfile = keyfile
self._on_datagram = on_datagram
self._server = None
self._peers: Dict[int, GameQuicProtocol] = {}
async def send(self, data: bytes, peer: TransportPeer) -> None:
proto = peer.addr # expected GameQuicProtocol
if isinstance(proto, GameQuicProtocol):
await proto.send_datagram(data)
async def run(self) -> None:
configuration = QuicConfiguration(is_client=False, alpn_protocols=["h3"])
configuration.load_cert_chain(self.certfile, self.keyfile)
async def _create_protocol(*args, **kwargs):
return GameQuicProtocol(*args, on_datagram=self._on_datagram, peers=self._peers, **kwargs)
logging.info("QUIC datagram server listening on %s:%d", self.host, self.port)
self._server = await serve(self.host, self.port, configuration=configuration, create_protocol=_create_protocol)
try:
await self._server.wait_closed()
finally:
self._server.close()

View File

@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass
import logging
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from collections import deque from collections import deque
@@ -53,6 +54,7 @@ class GameServer:
self.runtime = ServerRuntime(config=config, state=GameState(config.width, config.height)) self.runtime = ServerRuntime(config=config, state=GameState(config.width, config.height))
self.sessions: Dict[int, PlayerSession] = {} self.sessions: Dict[int, PlayerSession] = {}
self._config_update_interval_ticks = 50 # periodic resend self._config_update_interval_ticks = 50 # periodic resend
logging.info("GameServer created: field=%dx%d, tick_rate=%d TPS, wrap=%s", config.width, config.height, config.tick_rate, config.wrap_edges)
async def on_datagram(self, data: bytes, peer: TransportPeer) -> None: async def on_datagram(self, data: bytes, peer: TransportPeer) -> None:
# Minimal header parse # Minimal header parse
@@ -85,6 +87,7 @@ class GameServer:
# Broadcast to all sessions (requires real peer handles) # Broadcast to all sessions (requires real peer handles)
for session in list(self.sessions.values()): for session in list(self.sessions.values()):
await self.transport.send(payload, TransportPeer(session.peer)) await self.transport.send(payload, TransportPeer(session.peer))
logging.debug("Broadcasted config_update to %d sessions", len(self.sessions))
async def relay_input_broadcast( async def relay_input_broadcast(
self, self,
@@ -115,6 +118,7 @@ class GameServer:
r = self.runtime r = self.runtime
tick_duration = 1.0 / max(1, r.config.tick_rate) tick_duration = 1.0 / max(1, r.config.tick_rate)
next_cfg_resend = self._config_update_interval_ticks next_cfg_resend = self._config_update_interval_ticks
logging.info("Tick loop started at %.2f TPS", 1.0 / tick_duration)
while True: while True:
start = asyncio.get_event_loop().time() start = asyncio.get_event_loop().time()
# process inputs, update snakes, collisions, apples, deltas # process inputs, update snakes, collisions, apples, deltas
@@ -132,7 +136,7 @@ class GameServer:
# --- Simulation --- # --- Simulation ---
def _consume_input_for_snake(self, s: Snake) -> None: def _consume_input_for_snake(self, s: Snake) -> None:
# Consume at most one input; skip 180° turns when length>1 # Consume at most one input; skip 180° turns when length>1
while s.input_buf: while s.input_buf:
nd = s.input_buf[0] nd = s.input_buf[0]
# 180-degree check # 180-degree check
@@ -388,12 +392,14 @@ class GameServer:
if pid is None: if pid is None:
payload = build_join_deny(version=self.runtime.version, seq=self.runtime.next_seq(), reason="Server full") payload = build_join_deny(version=self.runtime.version, seq=self.runtime.next_seq(), reason="Server full")
await self.transport.send(payload, peer) await self.transport.send(payload, peer)
logging.warning("JOIN denied: server full (name=%s)", name)
return return
# Spawn snake # Spawn snake
snake = self._find_spawn() snake = self._find_spawn()
if snake is None: if snake is None:
payload = build_join_deny(version=self.runtime.version, seq=self.runtime.next_seq(), reason="No free cell, please wait") payload = build_join_deny(version=self.runtime.version, seq=self.runtime.next_seq(), reason="No free cell, please wait")
await self.transport.send(payload, peer) await self.transport.send(payload, peer)
logging.warning("JOIN denied: no free cell (name=%s)", name)
return return
# Register session and snake # Register session and snake
color_id = preferred if (preferred is not None) else self._choose_color_id() color_id = preferred if (preferred is not None) else self._choose_color_id()
@@ -403,6 +409,7 @@ class GameServer:
self.runtime.state.snakes[pid] = snake self.runtime.state.snakes[pid] = snake
self.runtime.state.occupy_snake(snake) self.runtime.state.occupy_snake(snake)
self._ensure_apples() self._ensure_apples()
logging.info("JOIN accepted: player_id=%d name=%s color=%d len=%d at=%s", pid, name, color_id, snake.length, snake.body[0])
# Send join_ack # Send join_ack
cfg = self.runtime.config cfg = self.runtime.config
ack = build_join_ack( ack = build_join_ack(
@@ -500,7 +507,7 @@ class GameServer:
dir_capacity_bytes = max(0, budget - overhead) dir_capacity_bytes = max(0, budget - overhead)
if dir_capacity_bytes <= 0: if dir_capacity_bytes <= 0:
break break
# Convert capacity bytes to number of directions (each 2 bits 4 dirs per byte) # Convert capacity bytes to number of directions (each 2 bits → 4 dirs per byte)
dir_capacity = dir_capacity_bytes * 4 dir_capacity = dir_capacity_bytes * 4
if dir_capacity <= 0: if dir_capacity <= 0:
break break
@@ -563,6 +570,7 @@ class GameServer:
break break
if player_id is None: if player_id is None:
return return
logging.debug("INPUT from player_id=%d: %d events (base_tick=%d)", player_id, len(events), base_tick)
# Relay to others immediately for prediction # Relay to others immediately for prediction
await self.relay_input_broadcast( await self.relay_input_broadcast(
from_player_id=player_id, from_player_id=player_id,

36
server/static_server.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import ssl
import logging
import threading
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from typing import Tuple
class _Handler(SimpleHTTPRequestHandler):
# Allow passing a base directory at construction time
def __init__(self, *args, directory: str | None = None, **kwargs):
super().__init__(*args, directory=directory, **kwargs)
def start_https_static(host: str, port: int, certfile: str, keyfile: str, docroot: str) -> Tuple[ThreadingHTTPServer, threading.Thread]:
"""Start a simple HTTPS static file server in a background thread.
Returns the (httpd, thread). Caller is responsible for calling httpd.shutdown()
to stop the server on application exit.
"""
docroot_path = str(Path(docroot).resolve())
def handler(*args, **kwargs):
return _Handler(*args, directory=docroot_path, **kwargs)
httpd = ThreadingHTTPServer((host, port), handler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(certfile=certfile, keyfile=keyfile)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
t = threading.Thread(target=httpd.serve_forever, name="https-static", daemon=True)
t.start()
logging.info("HTTPS static server listening on https://%s:%d serving '%s'", host, port, docroot_path)
return httpd, t

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass
import logging
from typing import Awaitable, Callable, Optional, Tuple from typing import Awaitable, Callable, Optional, Tuple
@@ -37,7 +38,7 @@ class InMemoryTransport(DatagramServerTransport):
await self._on_datagram(data, peer) await self._on_datagram(data, peer)
async def run(self) -> None: async def run(self) -> None:
# Nothing to do in in-memory test transport logging.info("InMemory transport started (no network)")
await asyncio.Future() await asyncio.Future()

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict, Optional
from .transport import DatagramServerTransport, OnDatagram, TransportPeer
import logging
try:
from aioquic.asyncio import QuicConnectionProtocol, serve
from aioquic.quic.configuration import QuicConfiguration
from aioquic.h3.connection import H3Connection
from aioquic.h3.events import HeadersReceived
# Datagram event names vary by aioquic version; try both
try:
from aioquic.h3.events import DatagramReceived as H3DatagramReceived # type: ignore
except Exception: # pragma: no cover
H3DatagramReceived = object # type: ignore
except Exception: # pragma: no cover - optional dependency not installed
QuicConnectionProtocol = object # type: ignore
QuicConfiguration = object # type: ignore
H3Connection = object # type: ignore
HeadersReceived = object # type: ignore
H3DatagramReceived = object # type: ignore
serve = None # type: ignore
@dataclass
class WTSession:
flow_id: int # HTTP/3 datagram flow id (usually CONNECT stream id)
proto: "GameWTProtocol"
class GameWTProtocol(QuicConnectionProtocol): # type: ignore[misc]
def __init__(self, *args, on_datagram: OnDatagram, sessions: Dict[int, WTSession], **kwargs):
super().__init__(*args, **kwargs)
self._on_datagram = on_datagram
self._sessions = sessions
self._http: Optional[H3Connection] = None
def http_event_received(self, event) -> None: # type: ignore[override]
# Headers for CONNECT :protocol = webtransport open a session
if isinstance(event, HeadersReceived):
headers = {k.decode().lower(): v.decode() for k, v in event.headers}
method = headers.get(":method")
protocol = headers.get(":protocol") or headers.get("sec-webtransport-protocol")
if method == "CONNECT" and (protocol == "webtransport"):
# In WebTransport over H3, datagrams use the CONNECT stream id as flow_id
flow_id = event.stream_id # type: ignore[attr-defined]
self._sessions[flow_id] = WTSession(flow_id=flow_id, proto=self)
logging.info("WT CONNECT accepted: flow_id=%s", flow_id)
# Send 2xx to accept the session
if self._http is not None:
self._http.send_headers(event.stream_id, [(b":status", b"200")])
elif isinstance(event, H3DatagramReceived): # type: ignore[misc]
# Route datagram to session by flow_id
flow_id = getattr(event, "flow_id", None)
data = getattr(event, "data", None)
if flow_id is None or data is None:
return
sess = self._sessions.get(flow_id)
if not sess:
return
peer = TransportPeer(addr=(self, flow_id))
logging.debug("WT datagram received: flow_id=%s, %d bytes", flow_id, len(data))
asyncio.ensure_future(self._on_datagram(bytes(data), peer))
def quic_event_received(self, event) -> None: # type: ignore[override]
# Lazily create H3 connection wrapper
if self._http is None and hasattr(self, "_quic"):
try:
self._http = H3Connection(self._quic, enable_webtransport=True) # type: ignore[attr-defined]
except Exception:
self._http = None
if self._http is not None:
for http_event in self._http.handle_event(event):
self.http_event_received(http_event)
async def send_h3_datagram(self, flow_id: int, data: bytes) -> None:
if self._http is None:
return
try:
logging.debug("WT send datagram: flow_id=%s, %d bytes", flow_id, len(data))
self._http.send_datagram(flow_id, data)
await self._loop.run_in_executor(None, self.transmit) # type: ignore[attr-defined]
except Exception:
pass
class WebTransportServer(DatagramServerTransport):
"""HTTP/3 WebTransport datagram server using aioquic.
Accepts CONNECT requests with :protocol = webtransport and exchanges
RFC 9297 HTTP/3 datagrams bound to the CONNECT stream id.
"""
def __init__(self, host: str, port: int, certfile: str, keyfile: str, on_datagram: OnDatagram):
if serve is None:
raise RuntimeError("aioquic is not installed. Please `pip install aioquic`.")
self.host = host
self.port = port
self.certfile = certfile
self.keyfile = keyfile
self._on_datagram = on_datagram
self._server = None
self._sessions: Dict[int, WTSession] = {}
async def send(self, data: bytes, peer: TransportPeer) -> None:
# peer.addr is (protocol, flow_id)
if isinstance(peer.addr, tuple) and len(peer.addr) == 2:
proto, flow_id = peer.addr
if isinstance(proto, GameWTProtocol) and isinstance(flow_id, int):
await proto.send_h3_datagram(flow_id, data)
async def run(self) -> None:
configuration = QuicConfiguration(is_client=False, alpn_protocols=["h3"])
configuration.load_cert_chain(self.certfile, self.keyfile)
async def _create_protocol(*args, **kwargs):
return GameWTProtocol(*args, on_datagram=self._on_datagram, sessions=self._sessions, **kwargs)
logging.info("WebTransport (H3) server listening on %s:%d", self.host, self.port)
self._server = await serve(self.host, self.port, configuration=configuration, create_protocol=_create_protocol)
try:
await self._server.wait_closed()
finally:
self._server.close()