/** * Binary codec for efficient network serialization (JavaScript version) * Mirrors the Python implementation */ const FieldType = { UINT8: 0x01, UINT16: 0x02, UINT32: 0x03, VARINT: 0x04, BYTES: 0x05, PACKED_POSITIONS: 0x06, DELTA_POSITIONS: 0x07, STRING_16: 0x08, PARTIAL_DELTA_POSITIONS: 0x09 }; const FieldID = { // PARTIAL_STATE_UPDATE fields UPDATE_ID: 0x01, SNAKE_COUNT: 0x02, SNAKE_DATA: 0x03, // GAME_META_UPDATE fields GAME_RUNNING: 0x02, FOOD_POSITIONS: 0x03, // Per-snake fields PLAYER_ID_HASH: 0x10, BODY_POSITIONS: 0x11, BODY_SEGMENT: 0x12, SEGMENT_INFO: 0x13, DIRECTION: 0x14, ALIVE: 0x15, STUCK: 0x16, COLOR_INDEX: 0x17, PLAYER_NAME: 0x18, INPUT_BUFFER: 0x19 }; const BinaryMessageType = { PARTIAL_STATE_UPDATE: 0x01, GAME_META_UPDATE: 0x02, PLAYER_INPUT: 0x03 }; class BinaryCodec { static VERSION = 0x01; static GRID_WIDTH = 40; static GRID_HEIGHT = 30; /** * Encode variable-length integer */ static encodeVarint(value) { const result = []; while (value > 0x7F) { result.push((value & 0x7F) | 0x80); value >>>= 7; } result.push(value & 0x7F); return new Uint8Array(result); } /** * Decode variable-length integer * Returns [value, newOffset] */ static decodeVarint(data, offset) { let value = 0; let shift = 0; let pos = offset; while (pos < data.length) { const byte = data[pos]; value |= (byte & 0x7F) << shift; pos++; if (!(byte & 0x80)) { break; } shift += 7; } return [value, pos]; } /** * Encode position as 11 bits (6-bit x + 5-bit y) */ static encodePosition(pos) { return ((pos[0] & 0x3F) << 5) | (pos[1] & 0x1F); } /** * Decode 11-bit position */ static decodePosition(value) { const x = (value >> 5) & 0x3F; const y = value & 0x1F; return [x, y]; } /** * Encode list of positions as packed 11-bit values */ static encodePackedPositions(positions) { const bitStream = positions.map(pos => this.encodePosition(pos)); const result = []; let bitsBuffer = 0; let bitsCount = 0; for (const value of bitStream) { bitsBuffer = (bitsBuffer << 11) | value; bitsCount += 11; while (bitsCount >= 8) { bitsCount -= 8; const byte = (bitsBuffer >> bitsCount) & 0xFF; result.push(byte); } } // Flush remaining bits if (bitsCount > 0) { result.push((bitsBuffer << (8 - bitsCount)) & 0xFF); } return new Uint8Array(result); } /** * Decode packed positions */ static decodePackedPositions(data, count) { const positions = []; let bitsBuffer = 0; let bitsCount = 0; let dataIdx = 0; for (let i = 0; i < count; i++) { // Ensure we have at least 11 bits while (bitsCount < 11 && dataIdx < data.length) { bitsBuffer = (bitsBuffer << 8) | data[dataIdx]; bitsCount += 8; dataIdx++; } if (bitsCount >= 11) { bitsCount -= 11; const value = (bitsBuffer >> bitsCount) & 0x7FF; positions.push(this.decodePosition(value)); } } return positions; } /** * Decode delta-encoded positions */ static decodeDeltaPositions(data, count) { if (count === 0 || data.length < 2) { return []; } const positions = []; // First position is absolute (16-bit) const firstVal = (data[0] << 8) | data[1]; positions.push(this.decodePosition(firstVal)); // Decode deltas const dataIdx = 2; for (let i = 1; i < count; i++) { const byteIdx = Math.floor((i - 1) / 4); const bitShift = 6 - ((i - 1) % 4) * 2; if (dataIdx + byteIdx < data.length) { const direction = (data[dataIdx + byteIdx] >> bitShift) & 0x03; const prev = positions[positions.length - 1]; let newPos; if (direction === 0) { // Right newPos = [prev[0] + 1, prev[1]]; } else if (direction === 1) { // Left newPos = [prev[0] - 1, prev[1]]; } else if (direction === 2) { // Down newPos = [prev[0], prev[1] + 1]; } else { // Up newPos = [prev[0], prev[1] - 1]; } positions.push(newPos); } } return positions; } /** * Decode string up to 16 chars * Returns [string, bytesConsumed] */ static decodeString16(data) { const length = (data[0] >> 4) & 0x0F; const textBytes = data.slice(1, 1 + length * 4); // Decode UTF-8 let text = ''; let byteIdx = 0; let charCount = 0; while (byteIdx < textBytes.length && charCount < length) { const byte = textBytes[byteIdx]; let charLen; if (byte < 0x80) { charLen = 1; } else if (byte < 0xE0) { charLen = 2; } else if (byte < 0xF0) { charLen = 3; } else { charLen = 4; } if (byteIdx + charLen <= textBytes.length) { const charBytes = textBytes.slice(byteIdx, byteIdx + charLen); try { text += new TextDecoder().decode(charBytes); charCount++; } catch (e) { // Skip invalid UTF-8 } } byteIdx += charLen; } return [text, 1 + byteIdx]; } /** * Create 32-bit hash of player ID using simple hash */ static playerIdHash(playerId) { let hash = 0; for (let i = 0; i < playerId.length; i++) { const char = playerId.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & 0xFFFFFFFF; // Convert to 32-bit integer } return hash >>> 0; // Ensure unsigned } /** * Compress data using gzip (browser CompressionStream API) */ static async compress(data) { if (typeof CompressionStream === 'undefined') { return data; // No compression support } const stream = new Blob([data]).stream(); const compressedStream = stream.pipeThrough(new CompressionStream('gzip')); const compressedBlob = await new Response(compressedStream).blob(); return new Uint8Array(await compressedBlob.arrayBuffer()); } /** * Decompress data using gzip (browser DecompressionStream API) */ static async decompress(data) { if (typeof DecompressionStream === 'undefined') { return data; // No decompression support } const stream = new Blob([data]).stream(); const decompressedStream = stream.pipeThrough(new DecompressionStream('gzip')); const decompressedBlob = await new Response(decompressedStream).blob(); return new Uint8Array(await decompressedBlob.arrayBuffer()); } /** * Decode binary payload into fields * Returns array of [fieldId, fieldType, fieldData] tuples */ static decodePayload(payload) { if (payload.length < 2) { return []; } const version = payload[0]; const fieldCount = payload[1]; const fields = []; let offset = 2; for (let i = 0; i < fieldCount; i++) { if (offset + 2 > payload.length) { break; } const fieldId = payload[offset]; const fieldType = payload[offset + 1]; offset += 2; // Decode length (varint) const [length, newOffset] = this.decodeVarint(payload, offset); offset = newOffset; // Extract field data if (offset + length > payload.length) { break; } const fieldData = payload.slice(offset, offset + length); offset += length; fields.push([fieldId, fieldType, fieldData]); } return fields; } }