Compare commits
20 Commits
5b7c302008
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a8501635 | ||
|
|
56ac74e916 | ||
|
|
b94aac71f8 | ||
|
|
7337c27898 | ||
|
|
921cc42fd4 | ||
|
|
99e818e0fd | ||
|
|
1bd4d9eac7 | ||
|
|
eeabda725e | ||
|
|
c97c5c4723 | ||
|
|
0ceea925cd | ||
|
|
088c36396b | ||
|
|
1f5ca02ca4 | ||
|
|
e79c523034 | ||
|
|
352da0ef54 | ||
|
|
e555762c64 | ||
|
|
967784542d | ||
|
|
991b8f3660 | ||
|
|
7a5f2d8794 | ||
|
|
9043ba81c0 | ||
|
|
65bf835b8d |
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
|
||||
@@ -33,15 +33,17 @@
|
||||
## Server Architecture (Python 3)
|
||||
- Runtime: Python 3 with asyncio.
|
||||
- Transport: WebTransport (HTTP/3 datagrams). Candidate library: aioquic for server-side H3 and datagrams.
|
||||
- Model:
|
||||
- Authoritative simulation on the server with a fixed tick rate (target 15-20 TPS; start with 15 for network headroom).
|
||||
- Model:
|
||||
- Authoritative simulation on the server with a fixed tick rate (default 10 TPS). Tick rate is reconfigurable at runtime by the server operator and changes are broadcast to clients.
|
||||
- Each tick: process inputs, update snakes, resolve collisions (blocked/shrink), manage apples, generate state deltas.
|
||||
- Entities:
|
||||
- Field: width, height, random seed, apple set.
|
||||
- Snake: id, name (<=16 chars), color id (0-31), deque of cells (head-first), current direction, input buffer, blocked flag, last-move tick, last-known client seq.
|
||||
- Player session: transport bindings, last-received input seq per player, anti-spam counters.
|
||||
- Spawn:
|
||||
- New snake spawns at a random free cell with a random legal starting direction, length 3 by default (configurable).
|
||||
- Spawn:
|
||||
- New snake spawns at a random free cell with a random legal starting direction.
|
||||
- Initial length policy: try to allocate a 3-cell straight strip (head + two body cells) in a legal direction; if not possible, spawn with length 1.
|
||||
- If the field appears full, temporarily ignore apples as obstacles for the purpose of finding space. If no free single cell exists, deny the join and inform the client to wait.
|
||||
- On apple eat: grow by 1; spawn a replacement apple at a free cell.
|
||||
- Disconnect: Immediately remove snake and its length; apples persist. If all disconnect, ensure field contains 3 apples.
|
||||
- Rate limiting: Cap input datagrams per second and buffer growth per client.
|
||||
@@ -60,26 +62,31 @@
|
||||
## Networking & Protocol
|
||||
Constraints and goals:
|
||||
- Use datagrams (unreliable, unordered). Include a packet sequence number for late-packet discard with wraparound handling.
|
||||
- Keep payloads <=1280 bytes to avoid fragmentation; if larger, partition logically.
|
||||
- Keep payloads <=1280 bytes to avoid fragmentation; prefer a safety budget of ~1200 bytes post-compression when partitioning.
|
||||
- Support up to 32 concurrent players; names limited to 16 bytes UTF-8.
|
||||
|
||||
Header (all packets):
|
||||
- ver (u8): protocol version.
|
||||
- type (u8): packet type (join, join_ack, input, input_broadcast, state_delta, state_full, part, ping, pong, error).
|
||||
- type (u8): packet type (join, join_ack, join_deny, input, input_broadcast, state_delta, state_full, part, config_update, ping, pong, error).
|
||||
- flags (u8): bit flags (compression, is_last_part, etc.).
|
||||
- seq (u16): sender's monotonically incrementing sequence number (wraps at 65535). Newer-than logic uses half-range window.
|
||||
- Optional tick (u16): simulation tick the packet refers to (on server updates).
|
||||
|
||||
Handshake (join -> join_ack):
|
||||
Handshake (join -> join_ack | join_deny):
|
||||
- Client sends: desired name (<=16), optional preferred color id.
|
||||
- Server replies: assigned player id (u8), color id (u8), field size (width u8, height u8), tick rate (u8), random seed (u32), palette, and initial full snapshot.
|
||||
- Server replies (join_ack): assigned player id (u8), color id (u8), field size (width u8, height u8), tick rate (u8), wrap_edges (bool), apples_per_snake (u8), apples_cap (u8 up to 255), compression_mode (enum: none|deflate), random seed (u32), palette, and initial full snapshot.
|
||||
- Server can reply (join_deny) with a reason (e.g., no free cell; please wait).
|
||||
|
||||
Inputs (client -> server):
|
||||
- Packet input includes: last-acknowledged server seq (u16), local input_seq (u16), one or more direction events with timestamps or relative tick offsets. Client pre-filters per buffer rules.
|
||||
|
||||
Input broadcast (server -> clients):
|
||||
- Upon receiving a player's input, the server immediately relays those input events to all other clients as input_broadcast packets.
|
||||
- Contents: player_id (u8), the player's input_seq (u16), direction events, and apply_at_tick (u16) assigned by the server (typically current_tick+1) so clients can align predictions.
|
||||
- Contents:
|
||||
- player_id (u8), the player's input_seq (u16)
|
||||
- base_tick (u16): server tick for alignment (typically current_tick)
|
||||
- events: one or more direction changes, each with rel_tick_offset (u8/varint) from base_tick
|
||||
- apply_at_tick (u16): optional absolute apply tick for convenience (both provided; clients may use either)
|
||||
- Purpose: Enable client-side opponent prediction during late or lost state updates. Broadcasts are small; apply rate limits if needed.
|
||||
|
||||
State updates (server -> client):
|
||||
@@ -100,21 +107,25 @@ State updates (server -> client):
|
||||
- Chunked variants (T=0x10, 0x11): used only when a single snake must be split.
|
||||
- Prefix V with `start_index` (u16, first direction offset from head) and `dirs_in_chunk` (u16); then the encoding for that range (2-bit or RLE respectively).
|
||||
- Client buffers chunks by `(update_id, snake_id)` and assembles when the full [0..len-2] range is present; then applies.
|
||||
- Selection: server chooses per snake whichever TLV (2-bit or RLE) yields fewer bytes.
|
||||
|
||||
Compression:
|
||||
- Primary: domain-specific packing (bits + RLE for segments and apples), then optional DEFLATE flag (flags.compressed=1) if still large.
|
||||
- Primary: domain-specific packing (bits + RLE for segments and apples).
|
||||
- Optional global DEFLATE mode (server-configured): when enabled, server may set flags.compressed=1 and apply DEFLATE to payloads; this mode is negotiated in join_ack and is not changed on the fly (server restart required; clients must reconnect).
|
||||
- Client decompresses if set; otherwise reads packed structs directly.
|
||||
|
||||
Packet partitioning (if >1280 bytes after compression):
|
||||
Packet partitioning (if >~1200–1280 bytes after compression):
|
||||
- Apply to state_full (join and periodic recovery) and large state_delta updates.
|
||||
- Goal: avoid IP fragmentation. Ensure each compressed datagram payload is <1280 bytes.
|
||||
- Goal: avoid IP fragmentation. Ensure each compressed datagram payload is <1280 bytes (use ~1200 target for headroom).
|
||||
- Strategy (whole-snake first):
|
||||
- Sort snakes by estimated compressed size (head + body TLV) descending.
|
||||
- Greedily pack one or more complete snakes per packet while keeping the compressed payload <1280 bytes.
|
||||
- If a snake does not fit with others, send it alone if it fits <1280 bytes.
|
||||
- If a single snake still exceeds 1280 bytes by itself, split that snake into multiple similar-sized chunks using the chunked TLV types.
|
||||
- Framing:
|
||||
- Each part carries `update_id` (u16), `part_index` (u8), `parts_total` (u8), and a sequence of per-snake TLVs (body_2bit/body_rle) and/or chunk TLVs (body_2bit_chunk/body_rle_chunk).
|
||||
- Each PART carries `update_id` (u16), `part_index` (u8), `parts_total` (u8), and `inner_type` (u8: STATE_FULL or STATE_DELTA) followed by the chunk payload.
|
||||
- For STATE_FULL: the chunk payload is `snakes_count + [per-snake records] + apples`. Include apples only in the first part; clients merge across parts using `update_id`.
|
||||
- For STATE_DELTA: the chunk payload is a subset of per-snake changes; include `apples_added/removed` only in the first part; clients merge across parts using `update_id`.
|
||||
- Clients apply complete per-snake TLVs immediately. Chunk TLVs are buffered and assembled by `(update_id, snake_id)` using `start_index` and `dirs_in_chunk` before applying.
|
||||
|
||||
Sequence wrap & ordering:
|
||||
@@ -122,6 +133,11 @@ Sequence wrap & ordering:
|
||||
- Clients ignore updates older-than last applied per-sender.
|
||||
- For input_broadcast, deduplicate per (player_id, input_seq) and apply in order per player; if apply_at_tick is in the past, apply immediately up to current tick.
|
||||
|
||||
Live config updates (server -> clients):
|
||||
- Packet type: config_update, sent on change and periodically (low frequency).
|
||||
- Fields: tick_rate (u8), wrap_edges (bool), apples_per_snake (u8), apples_cap (u8 up to 255).
|
||||
- Clients apply changes at the next tick boundary. Compression mode is not changed via config_update (handshake-only).
|
||||
|
||||
## Simulation Details
|
||||
- Tick order per tick:
|
||||
1) Incorporate at most one valid buffered input per snake (with 180-degree rule).
|
||||
@@ -129,7 +145,7 @@ Sequence wrap & ordering:
|
||||
3) If blocked=true: shrink tail by 1 (to min length 1). If the blocking cell becomes free (due to own shrink or others moving), the next tick's step in current direction proceeds.
|
||||
4) If moving into an apple: grow by 1 (do not shrink tail that tick) and respawn an apple.
|
||||
5) Emit per-snake delta (move, grow, or shrink) and global apple changes.
|
||||
- Blocking detection: walls (0..width-1, 0..height-1), any occupied snake cell (including heads and tails that are not about to vacate this tick).
|
||||
- Blocking detection: walls (0..width-1, 0..height-1), any occupied snake cell (including heads and tails that are not about to vacate this tick). Wrap-around rules are applied only to the head; if wrap_edges is turned off while a body segment is mid-edge traversal, allow the body to continue, but prevent the head from crossing walls thereafter.
|
||||
- 180-degree turn allowance when length == 1 only.
|
||||
|
||||
### Client-side Opponent Prediction Heuristics
|
||||
@@ -147,7 +163,11 @@ Sequence wrap & ordering:
|
||||
- Players: max 32 (ids 0..31); deny joins beyond capacity.
|
||||
- Names: UTF-8, truncate to 16 bytes; filter control chars.
|
||||
- Field: default 60x40; min 3x3; max 255x255; provided by server in join_ack.
|
||||
- Tick rate: default 15 TPS; configurable 10-30 TPS.
|
||||
- Tick rate: default 10 TPS; server-controlled, reconfigurable on the fly via config_update (reasonable range 5-30 TPS).
|
||||
- Apples per snake: default 1; server-controlled, reconfigurable on the fly; min 1/snake, max 12/snake; total apples capped at 255.
|
||||
- Wrap-around edges: default off (walls are blocking); server-controlled, reconfigurable on the fly; head obeys current rule; legacy body traversal allowed during transitions.
|
||||
- Compression (DEFLATE): global mode negotiated at join; requires server restart to change; clients must reconnect; when enabled server may set flags.compressed=1.
|
||||
- Browser targets: must work on latest Firefox; latest Chrome is optional but desirable; others optional.
|
||||
|
||||
## Error Handling & Resilience
|
||||
- Invalid packets: drop and optionally send error with code.
|
||||
@@ -172,13 +192,9 @@ Sequence wrap & ordering:
|
||||
8) Polishing: error messages, reconnect, telemetry/logging, Docker/dev scripts.
|
||||
|
||||
## Open Questions
|
||||
- Exact tick rate target and interpolation strategy on client.
|
||||
- Initial snake length on spawn (3 is suggested) and spawn retry rules in crowded fields.
|
||||
- Apple count baseline when players are present (fixed count vs proportional to players vs area).
|
||||
- Whether to allow wrap-around at field edges (currently: walls are blocking).
|
||||
- Compression choice tradeoffs: custom bitpacking only vs optional DEFLATE.
|
||||
- Minimum viable browser targets (WebTransport support varies).
|
||||
- Should input_broadcast include server-assigned apply_at_tick or relative tick offsets only?
|
||||
- Interpolation/smoothing specifics on client for variable tick rates.
|
||||
- Any additional anti-cheat measures for input_broadcast misuse.
|
||||
- Exact UI/UX for join denial when field is full (messaging/timing).
|
||||
|
||||
## Next Steps
|
||||
- Validate the protocol choices and mechanics with stakeholders.
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
aioquic>=1.2.0
|
||||
cryptography>=41.0.0
|
||||
138
run.py
Normal file
138
run.py
Normal file
@@ -0,0 +1,138 @@
|
||||
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__":
|
||||
try:
|
||||
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:
|
||||
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)
|
||||
|
||||
2
server/__init__.py
Normal file
2
server/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
__all__ = []
|
||||
|
||||
28
server/config.py
Normal file
28
server/config.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
width: int = 60
|
||||
height: int = 40
|
||||
tick_rate: int = 10 # TPS, server-controlled, live-configurable
|
||||
wrap_edges: bool = False # default off
|
||||
apples_per_snake: int = 1 # min 1, max 12
|
||||
apples_cap: int = 255 # absolute cap
|
||||
compression_mode: str = "none" # "none" | "deflate" (handshake-only)
|
||||
players_max: int = 32
|
||||
|
||||
def validate_runtime(self) -> None:
|
||||
if not (3 <= self.width <= 255 and 3 <= self.height <= 255):
|
||||
raise ValueError("field size must be within 3..255")
|
||||
if not (5 <= self.tick_rate <= 30):
|
||||
raise ValueError("tick_rate must be 5..30 TPS")
|
||||
if not (1 <= self.apples_per_snake <= 12):
|
||||
raise ValueError("apples_per_snake must be 1..12")
|
||||
if not (0 <= self.apples_cap <= 255):
|
||||
raise ValueError("apples_cap must be 0..255")
|
||||
if self.compression_mode not in ("none", "deflate"):
|
||||
raise ValueError("compression_mode must be 'none' or 'deflate'")
|
||||
|
||||
77
server/model.py
Normal file
77
server/model.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Deque, Dict, List, Optional, Tuple
|
||||
from collections import deque
|
||||
|
||||
from .protocol import Direction
|
||||
|
||||
|
||||
Coord = Tuple[int, int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Snake:
|
||||
snake_id: int
|
||||
head: Coord
|
||||
direction: Direction
|
||||
body: Deque[Coord] = field(default_factory=deque) # includes head at index 0
|
||||
input_buf: Deque[Direction] = field(default_factory=deque)
|
||||
blocked: bool = False
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
return len(self.body)
|
||||
|
||||
def enqueue_direction(self, new_dir: Direction, capacity: int = 3) -> None:
|
||||
"""Apply input buffer rules: size<=capacity, replace last on overflow/opposite, drop duplicates."""
|
||||
last_dir = self.input_buf[-1] if self.input_buf else self.direction
|
||||
# Drop duplicates
|
||||
if int(new_dir) == int(last_dir):
|
||||
return
|
||||
# Opposite of last? replace last
|
||||
if (int(new_dir) ^ int(last_dir)) == 2: # 0^2,1^3,2^0,3^1 are opposites
|
||||
if self.input_buf:
|
||||
self.input_buf[-1] = new_dir
|
||||
else:
|
||||
# No buffered inputs; just add new_dir (consumption will handle 180° rule)
|
||||
self.input_buf.append(new_dir)
|
||||
return
|
||||
# Normal append with overflow replacement
|
||||
if len(self.input_buf) >= capacity:
|
||||
self.input_buf[-1] = new_dir
|
||||
else:
|
||||
self.input_buf.append(new_dir)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerSession:
|
||||
player_id: int
|
||||
name: str
|
||||
color_id: int
|
||||
peer: object # transport-specific handle
|
||||
input_seq: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameState:
|
||||
width: int
|
||||
height: int
|
||||
snakes: Dict[int, Snake] = field(default_factory=dict)
|
||||
apples: List[Coord] = field(default_factory=list)
|
||||
tick: int = 0
|
||||
occupancy: Dict[Coord, Tuple[int, int]] = field(default_factory=dict) # cell -> (snake_id, index)
|
||||
|
||||
def in_bounds(self, x: int, y: int) -> bool:
|
||||
return 0 <= x < self.width and 0 <= y < self.height
|
||||
|
||||
def cell_free(self, x: int, y: int) -> bool:
|
||||
return (x, y) not in self.occupancy
|
||||
|
||||
def occupy_snake(self, snake: Snake) -> None:
|
||||
for idx, (cx, cy) in enumerate(snake.body):
|
||||
self.occupancy[(cx, cy)] = (snake.snake_id, idx)
|
||||
|
||||
def clear_snake(self, snake: Snake) -> None:
|
||||
for (cx, cy) in list(snake.body):
|
||||
self.occupancy.pop((cx, cy), None)
|
||||
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())
|
||||
428
server/protocol.py
Normal file
428
server/protocol.py
Normal file
@@ -0,0 +1,428 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from typing import Iterable, List, Sequence, Tuple
|
||||
|
||||
|
||||
class PacketType(IntEnum):
|
||||
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
|
||||
|
||||
|
||||
class BodyTLV(IntEnum):
|
||||
BODY_2BIT = 0x00
|
||||
BODY_RLE = 0x01
|
||||
BODY_2BIT_CHUNK = 0x10
|
||||
BODY_RLE_CHUNK = 0x11
|
||||
|
||||
|
||||
class Direction(IntEnum):
|
||||
UP = 0
|
||||
RIGHT = 1
|
||||
DOWN = 2
|
||||
LEFT = 3
|
||||
|
||||
|
||||
def quic_varint_encode(value: int) -> bytes:
|
||||
"""Encode QUIC varint (RFC 9000)."""
|
||||
if value < 0:
|
||||
raise ValueError("varint must be non-negative")
|
||||
if value <= 0x3F: # 6 bits, 1 byte, 00xx xxxx
|
||||
return bytes([value & 0x3F])
|
||||
if value <= 0x3FFF: # 14 bits, 2 bytes, 01xx xxxx
|
||||
v = 0x4000 | value
|
||||
return v.to_bytes(2, "big")
|
||||
if value <= 0x3FFFFFFF: # 30 bits, 4 bytes, 10xx xxxx
|
||||
v = 0x80000000 | value
|
||||
return v.to_bytes(4, "big")
|
||||
if value <= 0x3FFFFFFFFFFFFFFF: # 62 bits, 8 bytes, 11xx xxxx
|
||||
v = 0xC000000000000000 | value
|
||||
return v.to_bytes(8, "big")
|
||||
raise ValueError("varint too large")
|
||||
|
||||
|
||||
def quic_varint_decode(buf: bytes, offset: int = 0) -> Tuple[int, int]:
|
||||
"""Decode QUIC varint starting at offset. Returns (value, next_offset)."""
|
||||
first = buf[offset]
|
||||
prefix = first >> 6
|
||||
if prefix == 0:
|
||||
return (first & 0x3F, offset + 1)
|
||||
if prefix == 1:
|
||||
v = int.from_bytes(buf[offset : offset + 2], "big") & 0x3FFF
|
||||
return (v, offset + 2)
|
||||
if prefix == 2:
|
||||
v = int.from_bytes(buf[offset : offset + 4], "big") & 0x3FFFFFFF
|
||||
return (v, offset + 4)
|
||||
v = int.from_bytes(buf[offset : offset + 8], "big") & 0x3FFFFFFFFFFFFFFF
|
||||
return (v, offset + 8)
|
||||
|
||||
|
||||
def pack_header(version: int, ptype: PacketType, flags: int, seq: int, tick: int | None) -> bytes:
|
||||
"""Pack common header fields.
|
||||
|
||||
Layout:
|
||||
- ver: u8
|
||||
- type: u8
|
||||
- flags: u8
|
||||
- seq: u16 (network order)
|
||||
- tick: optional u16
|
||||
"""
|
||||
if not (0 <= version <= 255):
|
||||
raise ValueError("version out of range")
|
||||
if not (0 <= flags <= 255):
|
||||
raise ValueError("flags out of range")
|
||||
if not (0 <= seq <= 0xFFFF):
|
||||
raise ValueError("seq out of range")
|
||||
parts = bytearray()
|
||||
parts.append(version & 0xFF)
|
||||
parts.append(int(ptype) & 0xFF)
|
||||
parts.append(flags & 0xFF)
|
||||
parts.extend(seq.to_bytes(2, "big"))
|
||||
if tick is not None:
|
||||
if not (0 <= tick <= 0xFFFF):
|
||||
raise ValueError("tick out of range")
|
||||
parts.extend(tick.to_bytes(2, "big"))
|
||||
return bytes(parts)
|
||||
|
||||
|
||||
def unpack_header(buf: bytes, expect_tick: bool) -> Tuple[int, PacketType, int, int, int | None, int]:
|
||||
"""Unpack header; returns (ver, type, flags, seq, tick, next_offset)."""
|
||||
if len(buf) < 5:
|
||||
raise ValueError("buffer too small for header")
|
||||
ver = buf[0]
|
||||
ptype = PacketType(buf[1])
|
||||
flags = buf[2]
|
||||
seq = int.from_bytes(buf[3:5], "big")
|
||||
off = 5
|
||||
tick = None
|
||||
if expect_tick:
|
||||
if len(buf) < 7:
|
||||
raise ValueError("buffer too small for tick")
|
||||
tick = int.from_bytes(buf[5:7], "big")
|
||||
off = 7
|
||||
return ver, ptype, flags, seq, tick, off
|
||||
|
||||
|
||||
# Message builders/parsers (subset)
|
||||
|
||||
def build_config_update(
|
||||
*,
|
||||
version: int,
|
||||
seq: int,
|
||||
tick: int,
|
||||
tick_rate: int,
|
||||
wrap_edges: bool,
|
||||
apples_per_snake: int,
|
||||
apples_cap: int,
|
||||
) -> bytes:
|
||||
header = pack_header(version, PacketType.CONFIG_UPDATE, 0, seq, tick)
|
||||
body = bytearray()
|
||||
body.append(tick_rate & 0xFF) # u8
|
||||
body.append(1 if wrap_edges else 0) # bool u8
|
||||
body.append(apples_per_snake & 0xFF) # u8
|
||||
body.append(apples_cap & 0xFF) # u8
|
||||
return header + bytes(body)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputEvent:
|
||||
rel_tick_offset: int # relative to base_tick
|
||||
direction: Direction
|
||||
|
||||
|
||||
def build_input_broadcast(
|
||||
*,
|
||||
version: int,
|
||||
seq: int,
|
||||
tick: int,
|
||||
player_id: int,
|
||||
input_seq: int,
|
||||
base_tick: int,
|
||||
events: Sequence[InputEvent],
|
||||
apply_at_tick: int | None = None,
|
||||
) -> bytes:
|
||||
header = pack_header(version, PacketType.INPUT_BROADCAST, 0, seq, tick)
|
||||
body = bytearray()
|
||||
body.append(player_id & 0xFF)
|
||||
body.extend(int(input_seq & 0xFFFF).to_bytes(2, "big"))
|
||||
body.extend(int(base_tick & 0xFFFF).to_bytes(2, "big"))
|
||||
# number of events as QUIC varint
|
||||
body.extend(quic_varint_encode(len(events)))
|
||||
for ev in events:
|
||||
# rel offset as QUIC varint, direction as u8 (low 2 bits)
|
||||
body.extend(quic_varint_encode(int(ev.rel_tick_offset)))
|
||||
body.append(int(ev.direction) & 0x03)
|
||||
# Optional absolute apply_at_tick presence flag + value
|
||||
if apply_at_tick is None:
|
||||
body.append(0)
|
||||
else:
|
||||
body.append(1)
|
||||
body.extend(int(apply_at_tick & 0xFFFF).to_bytes(2, "big"))
|
||||
return header + bytes(body)
|
||||
|
||||
|
||||
def pack_body_tlv(t: BodyTLV, payload: bytes) -> bytes:
|
||||
return quic_varint_encode(int(t)) + quic_varint_encode(len(payload)) + payload
|
||||
|
||||
|
||||
def bitpack_2bit_directions(directions: Iterable[Direction]) -> bytes:
|
||||
out = bytearray()
|
||||
acc = 0
|
||||
bits = 0
|
||||
for d in directions:
|
||||
acc |= (int(d) & 0x03) << bits
|
||||
bits += 2
|
||||
if bits >= 8:
|
||||
out.append(acc & 0xFF)
|
||||
acc = acc >> 8
|
||||
bits -= 8
|
||||
if bits:
|
||||
out.append(acc & 0xFF) # zero-padded high bits
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def rle_pack_directions(directions: Sequence[Direction]) -> bytes:
|
||||
"""Pack directions as runs: dir (u8) + count (QUIC varint >=1)."""
|
||||
if not directions:
|
||||
return b""
|
||||
out = bytearray()
|
||||
run_dir = directions[0]
|
||||
run_len = 1
|
||||
for d in directions[1:]:
|
||||
if d == run_dir:
|
||||
run_len += 1
|
||||
else:
|
||||
out.append(int(run_dir) & 0xFF)
|
||||
out.extend(quic_varint_encode(run_len))
|
||||
run_dir = d
|
||||
run_len = 1
|
||||
out.append(int(run_dir) & 0xFF)
|
||||
out.extend(quic_varint_encode(run_len))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_body_tlv_for_dirs(directions: Sequence[Direction]) -> bytes:
|
||||
"""Choose the more compact of 2-bit stream vs RLE for given directions and return its TLV."""
|
||||
# 2-bit body size
|
||||
two_bit_payload = bitpack_2bit_directions(directions)
|
||||
two_bit = pack_body_tlv(BodyTLV.BODY_2BIT, two_bit_payload)
|
||||
# RLE
|
||||
rle_payload = rle_pack_directions(directions)
|
||||
rle = pack_body_tlv(BodyTLV.BODY_RLE, rle_payload)
|
||||
return two_bit if len(two_bit) <= len(rle) else rle
|
||||
|
||||
|
||||
# Join / Ack / Deny
|
||||
|
||||
def build_join(name_utf8: bytes, preferred_color_id: int | None = None) -> bytes:
|
||||
# Client-side helper (not used in server)
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def parse_join(buf: bytes, offset: int) -> Tuple[str, int | None, int]:
|
||||
name_len, off = quic_varint_decode(buf, offset)
|
||||
name_b = buf[off : off + name_len]
|
||||
off += name_len
|
||||
preferred = None
|
||||
if off < len(buf):
|
||||
preferred = buf[off]
|
||||
off += 1
|
||||
name = name_b.decode("utf-8", errors="ignore")
|
||||
return name, preferred, off
|
||||
|
||||
|
||||
def build_join_ack(
|
||||
*,
|
||||
version: int,
|
||||
seq: int,
|
||||
player_id: int,
|
||||
color_id: int,
|
||||
width: int,
|
||||
height: int,
|
||||
tick_rate: int,
|
||||
wrap_edges: bool,
|
||||
apples_per_snake: int,
|
||||
apples_cap: int,
|
||||
compression_mode: int,
|
||||
) -> bytes:
|
||||
header = pack_header(version, PacketType.JOIN_ACK, 0, seq, None)
|
||||
body = bytearray()
|
||||
body.append(player_id & 0xFF)
|
||||
body.append(color_id & 0xFF)
|
||||
body.append(width & 0xFF)
|
||||
body.append(height & 0xFF)
|
||||
body.append(tick_rate & 0xFF)
|
||||
body.append(1 if wrap_edges else 0)
|
||||
body.append(apples_per_snake & 0xFF)
|
||||
body.append(apples_cap & 0xFF)
|
||||
body.append(compression_mode & 0xFF)
|
||||
return header + bytes(body)
|
||||
|
||||
|
||||
def build_join_deny(*, version: int, seq: int, reason: str) -> bytes:
|
||||
header = pack_header(version, PacketType.JOIN_DENY, 0, seq, None)
|
||||
rb = reason.encode("utf-8")[:64]
|
||||
return header + quic_varint_encode(len(rb)) + rb
|
||||
|
||||
|
||||
# Input (client -> server)
|
||||
|
||||
def parse_input(buf: bytes, offset: int) -> Tuple[int, int, int, List[InputEvent], int]:
|
||||
ack_seq = int.from_bytes(buf[offset : offset + 2], "big")
|
||||
offset += 2
|
||||
input_seq = int.from_bytes(buf[offset : offset + 2], "big")
|
||||
offset += 2
|
||||
base_tick = int.from_bytes(buf[offset : offset + 2], "big")
|
||||
offset += 2
|
||||
n_ev, offset = quic_varint_decode(buf, offset)
|
||||
events: List[InputEvent] = []
|
||||
for _ in range(n_ev):
|
||||
rel, offset = quic_varint_decode(buf, offset)
|
||||
d = Direction(buf[offset] & 0x03)
|
||||
offset += 1
|
||||
events.append(InputEvent(rel_tick_offset=int(rel), direction=d))
|
||||
return ack_seq, input_seq, base_tick, events, offset
|
||||
|
||||
|
||||
# State snapshot (server -> client)
|
||||
|
||||
def build_state_full(
|
||||
*,
|
||||
version: int,
|
||||
seq: int,
|
||||
tick: int,
|
||||
snakes: Sequence[Tuple[int, int, int, int, Sequence[Direction]]],
|
||||
apples: Sequence[Tuple[int, int]],
|
||||
) -> bytes:
|
||||
"""Build a minimal state_full: per-snake header + BODY_2BIT TLV; apples list.
|
||||
|
||||
snakes: sequence of (snake_id, len, head_x, head_y, body_dirs_from_head)
|
||||
apples: sequence of (x, y)
|
||||
"""
|
||||
header = pack_header(version, PacketType.STATE_FULL, 0, seq, tick & 0xFFFF)
|
||||
body = build_state_full_body(snakes=snakes, apples=apples)
|
||||
return header + body
|
||||
|
||||
|
||||
def build_state_full_body(
|
||||
*, snakes: Sequence[Tuple[int, int, int, int, Sequence[Direction]]], apples: Sequence[Tuple[int, int]]
|
||||
) -> bytes:
|
||||
body = bytearray()
|
||||
# snakes count
|
||||
body.extend(quic_varint_encode(len(snakes)))
|
||||
for sid, slen, hx, hy, dirs in snakes:
|
||||
body.append(sid & 0xFF)
|
||||
body.extend(int(slen & 0xFFFF).to_bytes(2, "big"))
|
||||
body.append(hx & 0xFF)
|
||||
body.append(hy & 0xFF)
|
||||
tlv = build_body_tlv_for_dirs(dirs)
|
||||
body.extend(tlv)
|
||||
# apples
|
||||
body.extend(quic_varint_encode(len(apples)))
|
||||
for ax, ay in apples:
|
||||
body.append(ax & 0xFF)
|
||||
body.append(ay & 0xFF)
|
||||
return bytes(body)
|
||||
|
||||
|
||||
def build_body_2bit_chunk(directions: Sequence[Direction], start_index: int, dirs_in_chunk: int) -> bytes:
|
||||
"""Build chunk TLV for a sub-range of directions (2-bit)."""
|
||||
sub = directions[start_index : start_index + dirs_in_chunk]
|
||||
payload = bytearray()
|
||||
payload.extend(int(start_index & 0xFFFF).to_bytes(2, "big"))
|
||||
payload.extend(int(dirs_in_chunk & 0xFFFF).to_bytes(2, "big"))
|
||||
payload.extend(bitpack_2bit_directions(sub))
|
||||
return pack_body_tlv(BodyTLV.BODY_2BIT_CHUNK, bytes(payload))
|
||||
|
||||
|
||||
def estimate_body_2bit_bytes(snake_len: int) -> int:
|
||||
used_bits = max(0, (snake_len - 1) * 2)
|
||||
return (used_bits + 7) // 8
|
||||
|
||||
|
||||
# --- State Delta ---
|
||||
|
||||
@dataclass
|
||||
class SnakeDelta:
|
||||
snake_id: int
|
||||
head_moved: bool
|
||||
tail_removed: bool
|
||||
grew: bool
|
||||
blocked: bool
|
||||
new_head_x: int = 0
|
||||
new_head_y: int = 0
|
||||
direction: Direction = Direction.RIGHT
|
||||
|
||||
|
||||
def build_state_delta_body(
|
||||
*, update_id: int, changes: Sequence[SnakeDelta], apples_added: Sequence[Tuple[int, int]], apples_removed: Sequence[Tuple[int, int]]
|
||||
) -> bytes:
|
||||
body = bytearray()
|
||||
body.extend(int(update_id & 0xFFFF).to_bytes(2, "big"))
|
||||
# snakes
|
||||
body.extend(quic_varint_encode(len(changes)))
|
||||
for ch in changes:
|
||||
body.append(ch.snake_id & 0xFF)
|
||||
flags = (
|
||||
(1 if ch.head_moved else 0)
|
||||
| ((1 if ch.tail_removed else 0) << 1)
|
||||
| ((1 if ch.grew else 0) << 2)
|
||||
| ((1 if ch.blocked else 0) << 3)
|
||||
)
|
||||
body.append(flags & 0xFF)
|
||||
body.append(int(ch.direction) & 0x03)
|
||||
if ch.head_moved:
|
||||
body.append(ch.new_head_x & 0xFF)
|
||||
body.append(ch.new_head_y & 0xFF)
|
||||
# apples added
|
||||
body.extend(quic_varint_encode(len(apples_added)))
|
||||
for ax, ay in apples_added:
|
||||
body.append(ax & 0xFF)
|
||||
body.append(ay & 0xFF)
|
||||
# apples removed
|
||||
body.extend(quic_varint_encode(len(apples_removed)))
|
||||
for rx, ry in apples_removed:
|
||||
body.append(rx & 0xFF)
|
||||
body.append(ry & 0xFF)
|
||||
return bytes(body)
|
||||
|
||||
|
||||
def build_state_delta(
|
||||
*, version: int, seq: int, tick: int, update_id: int, changes: Sequence[SnakeDelta], apples_added: Sequence[Tuple[int, int]], apples_removed: Sequence[Tuple[int, int]]
|
||||
) -> bytes:
|
||||
header = pack_header(version, PacketType.STATE_DELTA, 0, seq, tick & 0xFFFF)
|
||||
body = build_state_delta_body(update_id=update_id, changes=changes, apples_added=apples_added, apples_removed=apples_removed)
|
||||
return header + body
|
||||
|
||||
|
||||
def build_part(
|
||||
*,
|
||||
version: int,
|
||||
seq: int,
|
||||
tick: int,
|
||||
update_id: int,
|
||||
part_index: int,
|
||||
parts_total: int,
|
||||
inner_type: PacketType,
|
||||
chunk_payload: bytes,
|
||||
) -> bytes:
|
||||
header = pack_header(version, PacketType.PART, 0, seq, tick & 0xFFFF)
|
||||
body = bytearray()
|
||||
body.extend(int(update_id & 0xFFFF).to_bytes(2, "big"))
|
||||
body.append(part_index & 0xFF)
|
||||
body.append(parts_total & 0xFF)
|
||||
body.append(int(inner_type) & 0xFF)
|
||||
# include the chunk payload bytes
|
||||
body.extend(chunk_payload)
|
||||
return header + bytes(body)
|
||||
79
server/quic_transport.py
Normal file
79
server/quic_transport.py
Normal 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()
|
||||
597
server/server.py
Normal file
597
server/server.py
Normal file
@@ -0,0 +1,597 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from collections import deque
|
||||
|
||||
from .config import ServerConfig
|
||||
from .model import GameState, PlayerSession, Snake, Coord
|
||||
from .protocol import (
|
||||
Direction,
|
||||
InputEvent,
|
||||
PacketType,
|
||||
build_join_ack,
|
||||
build_join_deny,
|
||||
build_config_update,
|
||||
build_input_broadcast,
|
||||
build_state_full,
|
||||
build_state_full_body,
|
||||
build_body_tlv_for_dirs,
|
||||
build_body_2bit_chunk,
|
||||
build_state_delta,
|
||||
build_state_delta_body,
|
||||
build_part,
|
||||
SnakeDelta,
|
||||
pack_header,
|
||||
parse_input,
|
||||
parse_join,
|
||||
)
|
||||
from .transport import DatagramServerTransport, TransportPeer
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerRuntime:
|
||||
config: ServerConfig
|
||||
state: GameState
|
||||
seq: int = 0
|
||||
version: int = 1
|
||||
update_id: int = 0
|
||||
|
||||
def next_seq(self) -> int:
|
||||
self.seq = (self.seq + 1) & 0xFFFF
|
||||
return self.seq
|
||||
|
||||
def next_update_id(self) -> int:
|
||||
self.update_id = (self.update_id + 1) & 0xFFFF
|
||||
return self.update_id
|
||||
|
||||
|
||||
class GameServer:
|
||||
def __init__(self, transport: DatagramServerTransport, config: ServerConfig):
|
||||
self.transport = transport
|
||||
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
|
||||
if len(data) < 5:
|
||||
return
|
||||
ver = data[0]
|
||||
ptype = PacketType(data[1])
|
||||
flags = data[2]
|
||||
# seq = int.from_bytes(data[3:5], 'big') # currently unused
|
||||
off = 5
|
||||
if ptype == PacketType.JOIN:
|
||||
await self._handle_join(data, off, peer)
|
||||
elif ptype == PacketType.INPUT:
|
||||
await self._handle_input(data, off, peer)
|
||||
else:
|
||||
# ignore others in skeleton
|
||||
return
|
||||
|
||||
async def broadcast_config_update(self) -> None:
|
||||
r = self.runtime
|
||||
payload = build_config_update(
|
||||
version=r.version,
|
||||
seq=r.next_seq(),
|
||||
tick=r.state.tick & 0xFFFF,
|
||||
tick_rate=r.config.tick_rate,
|
||||
wrap_edges=r.config.wrap_edges,
|
||||
apples_per_snake=r.config.apples_per_snake,
|
||||
apples_cap=r.config.apples_cap,
|
||||
)
|
||||
# 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,
|
||||
*,
|
||||
from_player_id: int,
|
||||
input_seq: int,
|
||||
base_tick: int,
|
||||
events: List[InputEvent],
|
||||
apply_at_tick: int | None,
|
||||
) -> None:
|
||||
r = self.runtime
|
||||
payload = build_input_broadcast(
|
||||
version=r.version,
|
||||
seq=r.next_seq(),
|
||||
tick=r.state.tick & 0xFFFF,
|
||||
player_id=from_player_id,
|
||||
input_seq=input_seq,
|
||||
base_tick=base_tick & 0xFFFF,
|
||||
events=events,
|
||||
apply_at_tick=apply_at_tick,
|
||||
)
|
||||
for session in list(self.sessions.values()):
|
||||
if session.player_id == from_player_id:
|
||||
continue
|
||||
await self.transport.send(payload, TransportPeer(session.peer))
|
||||
|
||||
async def tick_loop(self) -> None:
|
||||
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
|
||||
await self._simulate_tick()
|
||||
r.state.tick = (r.state.tick + 1) & 0xFFFFFFFF
|
||||
|
||||
next_cfg_resend -= 1
|
||||
if next_cfg_resend <= 0:
|
||||
await self.broadcast_config_update()
|
||||
next_cfg_resend = self._config_update_interval_ticks
|
||||
|
||||
elapsed = asyncio.get_event_loop().time() - start
|
||||
await asyncio.sleep(max(0.0, tick_duration - elapsed))
|
||||
|
||||
# --- Simulation ---
|
||||
|
||||
def _consume_input_for_snake(self, s: Snake) -> None:
|
||||
# Consume at most one input; skip 180° turns when length>1
|
||||
while s.input_buf:
|
||||
nd = s.input_buf[0]
|
||||
# 180-degree check
|
||||
if s.length > 1 and ((int(nd) ^ int(s.direction)) == 2):
|
||||
s.input_buf.popleft()
|
||||
continue
|
||||
# Accept
|
||||
s.direction = nd
|
||||
s.input_buf.popleft()
|
||||
break
|
||||
|
||||
def _step_from(self, x: int, y: int, d: Direction) -> Tuple[int, int, bool]:
|
||||
dx = 1 if d == Direction.RIGHT else -1 if d == Direction.LEFT else 0
|
||||
dy = 1 if d == Direction.DOWN else -1 if d == Direction.UP else 0
|
||||
nx, ny = x + dx, y + dy
|
||||
st = self.runtime.state
|
||||
wrap = self.runtime.config.wrap_edges
|
||||
wrapped = False
|
||||
if wrap:
|
||||
if nx < 0:
|
||||
nx = st.width - 1; wrapped = True
|
||||
elif nx >= st.width:
|
||||
nx = 0; wrapped = True
|
||||
if ny < 0:
|
||||
ny = st.height - 1; wrapped = True
|
||||
elif ny >= st.height:
|
||||
ny = 0; wrapped = True
|
||||
return nx, ny, wrapped
|
||||
|
||||
async def _simulate_tick(self) -> None:
|
||||
st = self.runtime.state
|
||||
cfg = self.runtime.config
|
||||
apples_eaten: List[Coord] = []
|
||||
apples_before = set(st.apples)
|
||||
changes: List[SnakeDelta] = []
|
||||
# Prepare snapshot of tails to allow moving into own tail when it vacates
|
||||
tails: Dict[int, Coord] = {}
|
||||
for sid, s in st.snakes.items():
|
||||
if s.length > 1:
|
||||
tails[sid] = s.body[-1]
|
||||
# Process snakes
|
||||
for sid, s in st.snakes.items():
|
||||
# Consume one input if available
|
||||
self._consume_input_for_snake(s)
|
||||
hx, hy = s.body[0]
|
||||
nx, ny, wrapped = self._step_from(hx, hy, s.direction)
|
||||
# Check wall if no wrap
|
||||
obstacle = False
|
||||
if not self.runtime.config.wrap_edges and not st.in_bounds(nx, ny):
|
||||
obstacle = True
|
||||
else:
|
||||
# Bounds correction already handled by _step_from
|
||||
# Occupancy check
|
||||
occ = st.occupancy.get((nx, ny))
|
||||
if occ is not None:
|
||||
# Allow moving into own tail if it will vacate (not growing)
|
||||
own_tail_ok = (s.length > 1 and (nx, ny) == tails.get(sid))
|
||||
if not own_tail_ok:
|
||||
obstacle = True
|
||||
if obstacle:
|
||||
s.blocked = True
|
||||
# shrink tail by 1 (to min 1)
|
||||
if s.length > 1:
|
||||
tx, ty = s.body.pop()
|
||||
st.occupancy.pop((tx, ty), None)
|
||||
changes.append(
|
||||
SnakeDelta(
|
||||
snake_id=sid,
|
||||
head_moved=False,
|
||||
tail_removed=(s.length > 1),
|
||||
grew=False,
|
||||
blocked=True,
|
||||
new_head_x=hx,
|
||||
new_head_y=hy,
|
||||
direction=s.direction,
|
||||
)
|
||||
)
|
||||
continue
|
||||
# Move or grow
|
||||
s.blocked = False
|
||||
will_grow = (nx, ny) in st.apples
|
||||
# Add new head
|
||||
s.body.appendleft((nx, ny))
|
||||
st.occupancy[(nx, ny)] = (sid, 0)
|
||||
if will_grow:
|
||||
# eat apple; no tail removal this tick
|
||||
st.apples.remove((nx, ny))
|
||||
apples_eaten.append((nx, ny))
|
||||
changes.append(
|
||||
SnakeDelta(
|
||||
snake_id=sid,
|
||||
head_moved=True,
|
||||
tail_removed=False,
|
||||
grew=True,
|
||||
blocked=False,
|
||||
new_head_x=nx,
|
||||
new_head_y=ny,
|
||||
direction=s.direction,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# normal move: remove tail (unless length==0)
|
||||
tx, ty = s.body.pop()
|
||||
if (tx, ty) in st.occupancy:
|
||||
st.occupancy.pop((tx, ty), None)
|
||||
changes.append(
|
||||
SnakeDelta(
|
||||
snake_id=sid,
|
||||
head_moved=True,
|
||||
tail_removed=True,
|
||||
grew=False,
|
||||
blocked=False,
|
||||
new_head_x=nx,
|
||||
new_head_y=ny,
|
||||
direction=s.direction,
|
||||
)
|
||||
)
|
||||
# Replenish apples to target
|
||||
self._ensure_apples()
|
||||
apples_after = set(st.apples)
|
||||
apples_added = sorted(list(apples_after - apples_before))
|
||||
apples_removed = sorted(list(apples_before - apples_after))
|
||||
# Broadcast a basic delta (currently full snakes + apples as delta)
|
||||
update_id = self.runtime.next_update_id()
|
||||
# Serialize full delta body once, then partition if large
|
||||
full_body = build_state_delta_body(
|
||||
update_id=update_id,
|
||||
changes=changes,
|
||||
apples_added=apples_added,
|
||||
apples_removed=apples_removed,
|
||||
)
|
||||
MTU = 1200 # soft limit for payload to avoid fragmentation
|
||||
if len(full_body) <= MTU:
|
||||
packet = pack_header(self.runtime.version, PacketType.STATE_DELTA, 0, self.runtime.next_seq(), st.tick & 0xFFFF) + full_body
|
||||
for session in list(self.sessions.values()):
|
||||
await self.transport.send(packet, TransportPeer(session.peer))
|
||||
else:
|
||||
# Partition by splitting snake changes across parts; include apples only in the first part
|
||||
parts: List[bytes] = []
|
||||
remaining = list(changes)
|
||||
idx = 0
|
||||
part_index = 0
|
||||
while remaining:
|
||||
chunk: List[SnakeDelta] = []
|
||||
# greedy pack until size would exceed MTU
|
||||
# start with apples only for first part
|
||||
add_ap = apples_added if part_index == 0 else []
|
||||
rem_ap = apples_removed if part_index == 0 else []
|
||||
# Try adding changes one by one
|
||||
for i, ch in enumerate(remaining):
|
||||
tmp_body = build_state_delta_body(update_id=update_id, changes=chunk + [ch], apples_added=add_ap, apples_removed=rem_ap)
|
||||
if len(tmp_body) > MTU and chunk:
|
||||
break
|
||||
if len(tmp_body) > MTU:
|
||||
# even a single change + apples doesn't fit; force single
|
||||
chunk.append(ch)
|
||||
i += 1
|
||||
break
|
||||
chunk.append(ch)
|
||||
# Remove chunked items
|
||||
remaining = remaining[len(chunk) :]
|
||||
# Build chunk body
|
||||
chunk_body = build_state_delta_body(update_id=update_id, changes=chunk, apples_added=add_ap, apples_removed=rem_ap)
|
||||
parts.append(chunk_body)
|
||||
part_index += 1
|
||||
# Emit PART packets
|
||||
total = len(parts)
|
||||
for i, body in enumerate(parts):
|
||||
pkt = build_part(
|
||||
version=self.runtime.version,
|
||||
seq=self.runtime.next_seq(),
|
||||
tick=st.tick & 0xFFFF,
|
||||
update_id=update_id,
|
||||
part_index=i,
|
||||
parts_total=total,
|
||||
inner_type=PacketType.STATE_DELTA,
|
||||
chunk_payload=body,
|
||||
)
|
||||
for session in list(self.sessions.values()):
|
||||
await self.transport.send(pkt, TransportPeer(session.peer))
|
||||
|
||||
# --- Join / Spawn ---
|
||||
|
||||
def _allocate_player_id(self) -> Optional[int]:
|
||||
used = {s.player_id for s in self.sessions.values()}
|
||||
for pid in range(self.runtime.config.players_max):
|
||||
if pid not in used:
|
||||
return pid
|
||||
return None
|
||||
|
||||
def _choose_color_id(self) -> int:
|
||||
used = {s.color_id for s in self.sessions.values()}
|
||||
for cid in range(32):
|
||||
if cid not in used:
|
||||
return cid
|
||||
return 0
|
||||
|
||||
def _neighbors(self, x: int, y: int) -> List[Tuple[Direction, Coord]]:
|
||||
return [
|
||||
(Direction.UP, (x, y - 1)),
|
||||
(Direction.RIGHT, (x + 1, y)),
|
||||
(Direction.DOWN, (x, y + 1)),
|
||||
(Direction.LEFT, (x - 1, y)),
|
||||
]
|
||||
|
||||
def _find_spawn(self) -> Optional[Snake]:
|
||||
st = self.runtime.state
|
||||
# Try to find a 3-cell straight strip
|
||||
for y in range(st.height):
|
||||
for x in range(st.width):
|
||||
if not st.cell_free(x, y):
|
||||
continue
|
||||
for d, (nx, ny) in self._neighbors(x, y):
|
||||
# check two cells in direction
|
||||
x2, y2 = nx, ny
|
||||
x3, y3 = nx + (1 if d == Direction.RIGHT else -1 if d == Direction.LEFT else 0), ny + (1 if d == Direction.DOWN else -1 if d == Direction.UP else 0)
|
||||
if st.in_bounds(x2, y2) and st.in_bounds(x3, y3) and st.cell_free(x2, y2) and st.cell_free(x3, y3):
|
||||
body = [
|
||||
(x, y),
|
||||
(x2, y2),
|
||||
(x3, y3),
|
||||
]
|
||||
return Snake(snake_id=-1, head=(x, y), direction=d, body=deque(body))
|
||||
# Fallback: any single free cell
|
||||
for y in range(st.height):
|
||||
for x in range(st.width):
|
||||
if st.cell_free(x, y):
|
||||
return Snake(snake_id=-1, head=(x, y), direction=Direction.RIGHT, body=deque([(x, y)]))
|
||||
return None
|
||||
|
||||
def _ensure_apples(self) -> None:
|
||||
st = self.runtime.state
|
||||
cfg = self.runtime.config
|
||||
import random
|
||||
|
||||
random.seed(0xC0FFEE)
|
||||
target = 3 if not self.sessions else min(cfg.apples_cap, max(0, len(self.sessions) * cfg.apples_per_snake))
|
||||
# grow apples up to target
|
||||
while len(st.apples) < target:
|
||||
x = random.randrange(st.width)
|
||||
y = random.randrange(st.height)
|
||||
if st.cell_free(x, y) and (x, y) not in st.apples:
|
||||
st.apples.append((x, y))
|
||||
# shrink if too many
|
||||
if len(st.apples) > target:
|
||||
st.apples = st.apples[:target]
|
||||
|
||||
async def _handle_join(self, buf: bytes, off: int, peer: TransportPeer) -> None:
|
||||
name, preferred, off2 = parse_join(buf, off)
|
||||
# enforce name <=16 bytes utf-8
|
||||
name = name.encode("utf-8")[:16].decode("utf-8", errors="ignore")
|
||||
pid = self._allocate_player_id()
|
||||
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()
|
||||
session = PlayerSession(player_id=pid, name=name, color_id=color_id, peer=peer.addr)
|
||||
self.sessions[pid] = session
|
||||
snake.snake_id = pid
|
||||
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(
|
||||
version=self.runtime.version,
|
||||
seq=self.runtime.next_seq(),
|
||||
player_id=pid,
|
||||
color_id=color_id,
|
||||
width=cfg.width,
|
||||
height=cfg.height,
|
||||
tick_rate=cfg.tick_rate,
|
||||
wrap_edges=cfg.wrap_edges,
|
||||
apples_per_snake=cfg.apples_per_snake,
|
||||
apples_cap=cfg.apples_cap,
|
||||
compression_mode=0 if cfg.compression_mode == "none" else 1,
|
||||
)
|
||||
await self.transport.send(ack, peer)
|
||||
# Send initial state_full (partitioned if needed)
|
||||
snakes_dirs: List[Tuple[int, int, int, int, List[Direction]]] = []
|
||||
for s in self.runtime.state.snakes.values():
|
||||
dirs: List[Direction] = []
|
||||
# build directions from consecutive coords: from head toward tail
|
||||
coords = list(s.body)
|
||||
for i in range(len(coords) - 1):
|
||||
x0, y0 = coords[i]
|
||||
x1, y1 = coords[i + 1]
|
||||
if x1 == x0 and y1 == y0 - 1:
|
||||
dirs.append(Direction.UP)
|
||||
elif x1 == x0 + 1 and y1 == y0:
|
||||
dirs.append(Direction.RIGHT)
|
||||
elif x1 == x0 and y1 == y0 + 1:
|
||||
dirs.append(Direction.DOWN)
|
||||
elif x1 == x0 - 1 and y1 == y0:
|
||||
dirs.append(Direction.LEFT)
|
||||
hx, hy = coords[0]
|
||||
snakes_dirs.append((s.snake_id, s.length, hx, hy, dirs))
|
||||
# Build body and partition across parts if needed
|
||||
body = build_state_full_body(snakes=snakes_dirs, apples=self.runtime.state.apples)
|
||||
MTU = 1200
|
||||
if len(body) <= MTU:
|
||||
full = pack_header(
|
||||
self.runtime.version, PacketType.STATE_FULL, 0, self.runtime.next_seq(), self.runtime.state.tick & 0xFFFF
|
||||
) + body
|
||||
await self.transport.send(full, peer)
|
||||
else:
|
||||
# Partition by packing whole snakes first, apples only in first part; chunk a single oversized snake using 2-bit chunks
|
||||
update_id = self.runtime.next_update_id()
|
||||
parts: List[bytes] = []
|
||||
# Prepare apples buffer for first part
|
||||
def encode_apples(apples: List[Coord]) -> bytes:
|
||||
from .protocol import quic_varint_encode
|
||||
|
||||
b = bytearray()
|
||||
b.extend(quic_varint_encode(len(apples)))
|
||||
for ax, ay in apples:
|
||||
b.append(ax & 0xFF)
|
||||
b.append(ay & 0xFF)
|
||||
return bytes(b)
|
||||
|
||||
apples_encoded = encode_apples(self.runtime.state.apples)
|
||||
# Cursor over snakes, building per part bodies
|
||||
remaining = list(snakes_dirs)
|
||||
first = True
|
||||
while remaining:
|
||||
# Start part body with snakes count placeholder; we'll rebuild as we pack
|
||||
part_snakes: List[bytes] = []
|
||||
packed_snakes = 0
|
||||
budget = MTU
|
||||
# Reserve apples size on first part
|
||||
apples_this = apples_encoded if first else encode_apples([])
|
||||
budget -= len(apples_this)
|
||||
# Greedily add snakes
|
||||
i = 0
|
||||
while i < len(remaining):
|
||||
sid, slen, hx, hy, dirs = remaining[i]
|
||||
# Build this snake record with chosen TLV
|
||||
tlv = build_body_tlv_for_dirs(dirs)
|
||||
record = bytearray()
|
||||
# one snake: id u8, len u16, head x y, TLV
|
||||
record.append(sid & 0xFF)
|
||||
record.extend(int(slen & 0xFFFF).to_bytes(2, "big"))
|
||||
record.append(hx & 0xFF)
|
||||
record.append(hy & 0xFF)
|
||||
record.extend(tlv)
|
||||
if len(record) <= budget:
|
||||
part_snakes.append(bytes(record))
|
||||
budget -= len(record)
|
||||
packed_snakes += 1
|
||||
i += 1
|
||||
continue
|
||||
# If single snake doesn't fit and no snakes packed yet, chunk this snake
|
||||
if packed_snakes == 0:
|
||||
# Use 2-bit chunking; compute directions chunk size to fit budget minus fixed headers (id/len/head + TLV type/len + chunk header)
|
||||
# Fixed snake header = 1+2+1+1 = 5 bytes; TLV type/len (worst-case 2 bytes for small values) + chunk header 4 bytes
|
||||
overhead = 5 + 2 + 4
|
||||
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)
|
||||
dir_capacity = dir_capacity_bytes * 4
|
||||
if dir_capacity <= 0:
|
||||
break
|
||||
# Emit at least one direction
|
||||
dirs_in_chunk = max(1, min(dir_capacity, len(dirs)))
|
||||
tlv_chunk = build_body_2bit_chunk(dirs, start_index=0, dirs_in_chunk=dirs_in_chunk)
|
||||
record = bytearray()
|
||||
record.append(sid & 0xFF)
|
||||
record.extend(int(slen & 0xFFFF).to_bytes(2, "big"))
|
||||
record.append(hx & 0xFF)
|
||||
record.append(hy & 0xFF)
|
||||
record.extend(tlv_chunk)
|
||||
if len(record) <= budget:
|
||||
part_snakes.append(bytes(record))
|
||||
budget -= len(record)
|
||||
# Replace remaining snake with truncated version (advance dirs)
|
||||
remaining[i] = (sid, slen, hx, hy, list(dirs)[dirs_in_chunk:])
|
||||
packed_snakes += 1
|
||||
else:
|
||||
break
|
||||
else:
|
||||
break
|
||||
# Build part body: snakes_count + snakes + apples
|
||||
body_part = bytearray()
|
||||
from .protocol import quic_varint_encode
|
||||
|
||||
body_part.extend(quic_varint_encode(len(part_snakes)))
|
||||
for rec in part_snakes:
|
||||
body_part.extend(rec)
|
||||
body_part.extend(apples_this)
|
||||
parts.append(bytes(body_part))
|
||||
# Advance remaining list
|
||||
remaining = remaining[packed_snakes:]
|
||||
first = False
|
||||
# Emit part packets
|
||||
total = len(parts)
|
||||
for i, chunk_body in enumerate(parts):
|
||||
pkt = build_part(
|
||||
version=self.runtime.version,
|
||||
seq=self.runtime.next_seq(),
|
||||
tick=self.runtime.state.tick & 0xFFFF,
|
||||
update_id=update_id,
|
||||
part_index=i,
|
||||
parts_total=total,
|
||||
inner_type=PacketType.STATE_FULL,
|
||||
chunk_payload=chunk_body,
|
||||
)
|
||||
await self.transport.send(pkt, peer)
|
||||
|
||||
async def _handle_input(self, buf: bytes, off: int, peer: TransportPeer) -> None:
|
||||
try:
|
||||
ack_seq, input_seq, base_tick, events, off2 = parse_input(buf, off)
|
||||
except Exception:
|
||||
return
|
||||
# Find player by peer
|
||||
player_id = None
|
||||
for pid, sess in self.sessions.items():
|
||||
if sess.peer is peer.addr:
|
||||
player_id = pid
|
||||
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,
|
||||
input_seq=input_seq,
|
||||
base_tick=base_tick,
|
||||
events=events,
|
||||
apply_at_tick=None,
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
from .transport import InMemoryTransport
|
||||
|
||||
cfg = ServerConfig()
|
||||
server = GameServer(transport=InMemoryTransport(lambda d, p: server.on_datagram(d, p)), config=cfg)
|
||||
# In-memory transport never returns; run tick loop in parallel
|
||||
await asyncio.gather(server.transport.run(), server.tick_loop())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
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
|
||||
59
server/transport.py
Normal file
59
server/transport.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Awaitable, Callable, Optional, Tuple
|
||||
|
||||
|
||||
OnDatagram = Callable[[bytes, object], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransportPeer:
|
||||
addr: object # opaque peer handle (e.g., QUIC session)
|
||||
|
||||
|
||||
class DatagramServerTransport:
|
||||
async def send(self, data: bytes, peer: TransportPeer) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def run(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class InMemoryTransport(DatagramServerTransport):
|
||||
"""A test transport that loops datagrams back to registered peers."""
|
||||
|
||||
def __init__(self, on_datagram: OnDatagram):
|
||||
self._on_datagram = on_datagram
|
||||
self._peers: list[TransportPeer] = []
|
||||
|
||||
def register_peer(self, peer: TransportPeer) -> None:
|
||||
self._peers.append(peer)
|
||||
|
||||
async def send(self, data: bytes, peer: TransportPeer) -> None:
|
||||
# In-memory: deliver only to the addressed peer
|
||||
if peer in self._peers:
|
||||
await self._on_datagram(data, peer)
|
||||
|
||||
async def run(self) -> None:
|
||||
logging.info("InMemory transport started (no network)")
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
class QuicWebTransportServer(DatagramServerTransport):
|
||||
"""Placeholder for a real WebTransport (HTTP/3) datagram server.
|
||||
|
||||
Integrate with aioquic or another QUIC library and invoke the provided
|
||||
on_datagram callback when a datagram arrives.
|
||||
"""
|
||||
|
||||
def __init__(self, on_datagram: OnDatagram):
|
||||
self._on_datagram = on_datagram
|
||||
|
||||
async def send(self, data: bytes, peer: TransportPeer) -> None:
|
||||
raise NotImplementedError("QUIC server not implemented in skeleton")
|
||||
|
||||
async def run(self) -> None:
|
||||
raise NotImplementedError("QUIC server not implemented in skeleton")
|
||||
10
server/utils.py
Normal file
10
server/utils.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def is_newer(a: int, b: int) -> bool:
|
||||
"""Return True if 16-bit sequence number a is newer than b (wrap-aware).
|
||||
|
||||
Uses half-range window on unsigned 16-bit arithmetic.
|
||||
"""
|
||||
return ((a - b) & 0xFFFF) < 0x8000
|
||||
|
||||
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