Files
codexPySnake/client/client.js
Vladyslav Doloman 1de5a8f3e6 Fix WebTransport server implementation and add test client
Server fixes:
- Move H3Connection initialization to ProtocolNegotiated event (matches official aioquic pattern)
- Fix datagram routing to use session_id instead of flow_id
- Add max_datagram_frame_size=65536 to enable QUIC datagrams
- Fix send_datagram() to use keyword arguments
- Add certificate chain handling for Let's Encrypt
- Add no-cache headers to static server

Command-line improvements:
- Move settings from environment variables to argparse
- Add comprehensive CLI arguments with defaults
- Default mode=wt, cert=cert.pem, key=key.pem

Test clients:
- Add test_webtransport_client.py - Python WebTransport client that successfully connects
- Add test_http3.py - Basic HTTP/3 connectivity test

Client updates:
- Auto-configure server URL and certificate hash from /cert-hash.json
- Add ES6 module support

Status:
 Python WebTransport client works perfectly
 Server properly handles WebTransport connections and datagrams
 Chrome fails due to cached QUIC state (QUIC_IETF_GQUIC_ERROR_MISSING)
🔍 Firefox sends packets but fails differently - to be debugged next session

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 23:50:08 +00:00

313 lines
11 KiB
JavaScript

import { PacketType, Direction, packHeader, parseHeader, buildJoin, buildInput, parseJoinAck, parseStateFullBody, parseStateDeltaBody } 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: {
const delta = parseStateDeltaBody(dv, off);
// Apply changes to local snake state
for (const ch of delta.changes) {
let snake = snakes.get(ch.snakeId);
if (!snake) {
// New snake appeared; skip for now (will get it in next full update)
continue;
}
// Update direction (always present)
// Note: direction is the current direction, not necessarily the new head direction
if (ch.headMoved) {
// Snake moved: prepend new head position
// Compute direction from old head to new head
const oldHx = snake.hx;
const oldHy = snake.hy;
const newHx = ch.newHeadX;
const newHy = ch.newHeadY;
// Determine direction of movement
let moveDir = ch.direction;
if (newHx === oldHx && newHy === oldHy - 1) moveDir = Direction.UP;
else if (newHx === oldHx + 1 && newHy === oldHy) moveDir = Direction.RIGHT;
else if (newHx === oldHx && newHy === oldHy + 1) moveDir = Direction.DOWN;
else if (newHx === oldHx - 1 && newHy === oldHy) moveDir = Direction.LEFT;
// Update head position
snake.hx = newHx;
snake.hy = newHy;
// If tail was removed (normal move), keep dirs same length by prepending new dir
// If grew (ate apple), prepend without removing tail
if (ch.grew) {
// Growing: add direction to front, don't remove from back
snake.dirs = [moveDir, ...snake.dirs];
snake.len += 1;
} else if (ch.tailRemoved) {
// Normal move: head advances, tail shrinks
// Keep dirs array same size (represents len-1 directions)
// Actually, we need to be careful here about the representation
// dirs[i] tells us how to get from cell i to cell i+1
// When head moves and tail shrinks, we add new dir at front, remove old from back
if (snake.dirs.length > 0) {
snake.dirs.pop(); // remove tail direction
}
snake.dirs = [moveDir, ...snake.dirs];
} else {
// Head moved but tail didn't remove (shouldn't happen in normal gameplay)
snake.dirs = [moveDir, ...snake.dirs];
snake.len += 1;
}
} else if (ch.blocked) {
// Snake is blocked: head stayed in place, tail shrunk
if (ch.tailRemoved && snake.dirs.length > 0) {
snake.dirs.pop(); // remove last direction (tail shrinks)
snake.len = Math.max(1, snake.len - 1);
}
}
}
// Apply apple changes
const appleSet = new Set(apples.map(a => `${a[0]},${a[1]}`));
for (const [x, y] of delta.applesAdded) {
appleSet.add(`${x},${y}`);
}
for (const [x, y] of delta.applesRemoved) {
appleSet.delete(`${x},${y}`);
}
apples = Array.from(appleSet).map(s => {
const [x, y] = s.split(',').map(Number);
return [x, y];
});
break;
}
default: break;
}
}
} catch (e) {
setStatus('Read error: ' + e);
}
}
async function connectWT() {
// Check if WebTransport is available
if (typeof WebTransport === 'undefined') {
setStatus('ERROR: WebTransport not supported in this browser. Use Chrome/Edge 97+ or Firefox with flag enabled.');
return;
}
const url = ui.url.value.trim();
const hashHex = ui.hash.value.trim();
if (!url) {
setStatus('ERROR: Please enter a server URL');
return;
}
setStatus('connecting...');
console.log('Connecting to:', url);
const opts = {};
if (hashHex) {
try {
const bytes = new Uint8Array(hashHex.match(/.{1,2}/g).map(b => parseInt(b,16)));
opts.serverCertificateHashes = [{ algorithm: 'sha-256', value: bytes }];
console.log('Using certificate hash:', hashHex);
} catch (err) {
setStatus('ERROR: Invalid certificate hash format');
return;
}
} else {
console.warn('No certificate hash provided - connection may fail for self-signed certs');
}
try {
const wt = new WebTransport(url, opts);
await wt.ready;
transport = wt;
writer = wt.datagrams.writable.getWriter();
setStatus('connected to server');
console.log('WebTransport connected successfully');
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);
} catch (err) {
console.error('WebTransport connection failed:', err);
if (err.message.includes('certificate')) {
setStatus('ERROR: Certificate validation failed. Check cert hash or use valid CA cert.');
} else if (err.message.includes('net::')) {
setStatus('ERROR: Network error - is server running on ' + url + '?');
} else {
setStatus('ERROR: ' + err.message);
}
}
}
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 */ }
}
// Auto-configure on page load
async function autoConfigureFromServer() {
try {
// Try to fetch cert hash from the static server
const response = await fetch('/cert-hash.json');
if (!response.ok) {
setStatus('Auto-config unavailable, please enter manually');
return;
}
const config = await response.json();
// Auto-populate fields
if (config.wtUrl) {
// Use the hostname from browser but with the WT port from server
const hostname = window.location.hostname || '127.0.0.1';
const wtPort = config.wtPort || 4433;
ui.url.value = `https://${hostname}:${wtPort}/`;
}
if (config.sha256) {
ui.hash.value = config.sha256;
}
setStatus('Auto-configured from server');
console.log('Auto-configured:', config);
} catch (err) {
console.warn('Auto-config failed:', err);
// Fallback: just populate URL from browser location
const hostname = window.location.hostname || '127.0.0.1';
ui.url.value = `https://${hostname}:4433/`;
setStatus('Manual configuration required');
}
}
ui.connect.onclick = () => { connectWT().catch(e => setStatus('connect failed: ' + e)); };
window.addEventListener('keydown', onKey);
window.addEventListener('resize', render);
// Auto-configure when page loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoConfigureFromServer);
} else {
autoConfigureFromServer();
}