Compare commits
11 Commits
1f5ca02ca4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a8501635 | ||
|
|
56ac74e916 | ||
|
|
b94aac71f8 | ||
|
|
7337c27898 | ||
|
|
921cc42fd4 | ||
|
|
99e818e0fd | ||
|
|
1bd4d9eac7 | ||
|
|
eeabda725e | ||
|
|
c97c5c4723 | ||
|
|
0ceea925cd | ||
|
|
088c36396b |
163
client/client.js
Normal file
163
client/client.js
Normal 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
30
client/index.html
Normal 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
160
client/protocol.js
Normal 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 };
|
||||
}
|
||||
|
||||
138
run.py
138
run.py
@@ -1,36 +1,138 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from server.server import GameServer
|
||||
from server.config import ServerConfig
|
||||
from server.transport import InMemoryTransport
|
||||
|
||||
|
||||
async def main():
|
||||
from server.server import GameServer
|
||||
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)
|
||||
await asyncio.gather(server.transport.run(), server.tick_loop())
|
||||
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__":
|
||||
try:
|
||||
# Optional QUIC mode if env vars are provided
|
||||
cert = os.environ.get("QUIC_CERT")
|
||||
key = os.environ.get("QUIC_KEY")
|
||||
host = os.environ.get("QUIC_HOST", "0.0.0.0")
|
||||
port = int(os.environ.get("QUIC_PORT", "4433"))
|
||||
if cert and key:
|
||||
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.server import GameServer
|
||||
from server.multi_transport import MultiTransport
|
||||
cfg = ServerConfig()
|
||||
async def start_quic():
|
||||
server = GameServer(transport=QuicWebTransportServer(host, port, cert, key, lambda d, p: server.on_datagram(d, p)), config=cfg)
|
||||
await asyncio.gather(server.transport.run(), server.tick_loop())
|
||||
asyncio.run(start_quic())
|
||||
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:
|
||||
asyncio.run(main())
|
||||
logging.info("Starting in in-memory transport mode")
|
||||
asyncio.run(run_in_memory())
|
||||
except KeyboardInterrupt:
|
||||
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
26
server/multi_transport.py
Normal 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())
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable, Dict, Optional
|
||||
|
||||
from .transport import DatagramServerTransport, OnDatagram, TransportPeer
|
||||
import logging
|
||||
|
||||
|
||||
try:
|
||||
@@ -31,14 +32,17 @@ class GameQuicProtocol(QuicConnectionProtocol): # type: ignore[misc]
|
||||
# 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]
|
||||
|
||||
@@ -67,9 +71,9 @@ class QuicWebTransportServer(DatagramServerTransport):
|
||||
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()
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from collections import deque
|
||||
|
||||
@@ -53,6 +54,7 @@ class GameServer:
|
||||
self.runtime = ServerRuntime(config=config, state=GameState(config.width, config.height))
|
||||
self.sessions: Dict[int, PlayerSession] = {}
|
||||
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:
|
||||
# Minimal header parse
|
||||
@@ -85,6 +87,7 @@ class GameServer:
|
||||
# Broadcast to all sessions (requires real peer handles)
|
||||
for session in list(self.sessions.values()):
|
||||
await self.transport.send(payload, TransportPeer(session.peer))
|
||||
logging.debug("Broadcasted config_update to %d sessions", len(self.sessions))
|
||||
|
||||
async def relay_input_broadcast(
|
||||
self,
|
||||
@@ -115,6 +118,7 @@ class GameServer:
|
||||
r = self.runtime
|
||||
tick_duration = 1.0 / max(1, r.config.tick_rate)
|
||||
next_cfg_resend = self._config_update_interval_ticks
|
||||
logging.info("Tick loop started at %.2f TPS", 1.0 / tick_duration)
|
||||
while True:
|
||||
start = asyncio.get_event_loop().time()
|
||||
# process inputs, update snakes, collisions, apples, deltas
|
||||
@@ -132,7 +136,7 @@ class GameServer:
|
||||
# --- Simulation ---
|
||||
|
||||
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:
|
||||
nd = s.input_buf[0]
|
||||
# 180-degree check
|
||||
@@ -388,12 +392,14 @@ class GameServer:
|
||||
if pid is None:
|
||||
payload = build_join_deny(version=self.runtime.version, seq=self.runtime.next_seq(), reason="Server full")
|
||||
await self.transport.send(payload, peer)
|
||||
logging.warning("JOIN denied: server full (name=%s)", name)
|
||||
return
|
||||
# Spawn snake
|
||||
snake = self._find_spawn()
|
||||
if snake is None:
|
||||
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)
|
||||
logging.warning("JOIN denied: no free cell (name=%s)", name)
|
||||
return
|
||||
# Register session and snake
|
||||
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.occupy_snake(snake)
|
||||
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
|
||||
cfg = self.runtime.config
|
||||
ack = build_join_ack(
|
||||
@@ -500,7 +507,7 @@ class GameServer:
|
||||
dir_capacity_bytes = max(0, budget - overhead)
|
||||
if dir_capacity_bytes <= 0:
|
||||
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
|
||||
if dir_capacity <= 0:
|
||||
break
|
||||
@@ -563,6 +570,7 @@ class GameServer:
|
||||
break
|
||||
if player_id is None:
|
||||
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
|
||||
await self.relay_input_broadcast(
|
||||
from_player_id=player_id,
|
||||
|
||||
36
server/static_server.py
Normal file
36
server/static_server.py
Normal 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
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Awaitable, Callable, Optional, Tuple
|
||||
|
||||
|
||||
@@ -37,7 +38,7 @@ class InMemoryTransport(DatagramServerTransport):
|
||||
await self._on_datagram(data, peer)
|
||||
|
||||
async def run(self) -> None:
|
||||
# Nothing to do in in-memory test transport
|
||||
logging.info("InMemory transport started (no network)")
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
|
||||
129
server/webtransport_server.py
Normal file
129
server/webtransport_server.py
Normal 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()
|
||||
Reference in New Issue
Block a user