/** * Client-side partial state reassembly and tracking (JavaScript version) */ class PartialSnakeData { constructor(playerId, playerIdHash) { this.playerId = playerId; this.playerIdHash = playerIdHash; this.body = []; this.direction = [1, 0]; this.alive = true; this.stuck = false; this.colorIndex = 0; this.playerName = ''; this.inputBuffer = []; // For segmented snakes this.segments = {}; this.totalSegments = 1; this.isSegmented = false; } } class PartialStateTracker { constructor() { this.currentUpdateId = null; this.receivedSnakes = {}; // playerIdHash -> PartialSnakeData this.foodPositions = []; this.gameRunning = false; this.playerNameCache = {}; // playerIdHash -> playerName } /** * Process a partial update packet * Returns true if ready to apply update */ processPacket(updateId, payload) { // Check if new update if (updateId !== this.currentUpdateId) { // New tick - reset received snakes this.currentUpdateId = updateId; this.receivedSnakes = {}; } // Decode packet let fields; try { fields = BinaryCodec.decodePayload(payload); } catch (e) { console.error('Error decoding payload:', e); return false; } // Track current snake being processed let currentSnakeHash = null; // Process fields for (const [fieldId, fieldType, fieldData] of fields) { if (fieldId === FieldID.UPDATE_ID) { // Already have it } else if (fieldId === FieldID.GAME_RUNNING) { this.gameRunning = fieldData[0] !== 0; } else if (fieldId === FieldID.FOOD_POSITIONS) { // Decode packed positions if (fieldData.length > 0) { const count = fieldData[0]; const positions = BinaryCodec.decodePackedPositions(fieldData.slice(1), count); this.foodPositions = positions; } } else if (fieldId === FieldID.SNAKE_COUNT) { // Just informational } else if (fieldId === FieldID.PLAYER_ID_HASH) { // Start of snake data const dv = new DataView(fieldData.buffer, fieldData.byteOffset, fieldData.byteLength); const playerHash = dv.getUint32(0, false); // Big endian currentSnakeHash = playerHash; if (!(playerHash in this.receivedSnakes)) { this.receivedSnakes[playerHash] = new PartialSnakeData( String(playerHash), // Will be replaced by actual ID later playerHash ); } } else if (fieldId === FieldID.BODY_POSITIONS && currentSnakeHash !== null) { // Complete body (delta encoded) // Estimate count from data length const count = Math.floor(fieldData.length / 2) + 1; const body = BinaryCodec.decodeDeltaPositions(fieldData, count); this.receivedSnakes[currentSnakeHash].body = body; } else if (fieldId === FieldID.BODY_SEGMENT && currentSnakeHash !== null) { // Partial body segment this.receivedSnakes[currentSnakeHash].isSegmented = true; } else if (fieldId === FieldID.SEGMENT_INFO && currentSnakeHash !== null) { // Segment index and total if (fieldData.length >= 2) { const segIdx = fieldData[0]; const totalSegs = fieldData[1]; const snake = this.receivedSnakes[currentSnakeHash]; snake.totalSegments = totalSegs; } } else if (fieldId === FieldID.DIRECTION && currentSnakeHash !== null) { // Direction + flags (9 bits: dir(2) + alive(1) + stuck(1) + color(5)) if (fieldData.length >= 2) { const dv = new DataView(fieldData.buffer, fieldData.byteOffset, fieldData.byteLength); const flags = dv.getUint16(0, false); // Big endian const dirBits = (flags >> 7) & 0x03; const alive = (flags >> 6) & 0x01; const stuck = (flags >> 5) & 0x01; const colorIndex = flags & 0x1F; // Map direction bits to tuple const directionMap = { 0: [1, 0], // Right 1: [-1, 0], // Left 2: [0, 1], // Down 3: [0, -1] // Up }; const snake = this.receivedSnakes[currentSnakeHash]; snake.direction = directionMap[dirBits] || [1, 0]; snake.alive = alive === 1; snake.stuck = stuck === 1; snake.colorIndex = colorIndex; } } else if (fieldId === FieldID.PLAYER_NAME && currentSnakeHash !== null) { // Player name (string_16) const [name, _] = BinaryCodec.decodeString16(fieldData); this.receivedSnakes[currentSnakeHash].playerName = name; this.playerNameCache[currentSnakeHash] = name; } else if (fieldId === FieldID.INPUT_BUFFER && currentSnakeHash !== null) { // Input buffer (3x 2-bit directions) if (fieldData.length >= 1) { const bufBits = fieldData[0]; const directionMap = { 0: [1, 0], // Right 1: [-1, 0], // Left 2: [0, 1], // Down 3: [0, -1] // Up }; const inputBuffer = []; for (let i = 0; i < 3; i++) { const dirVal = (bufBits >> (4 - i * 2)) & 0x03; inputBuffer.push(directionMap[dirVal] || [1, 0]); } this.receivedSnakes[currentSnakeHash].inputBuffer = inputBuffer; } } } // Always return true to trigger update (best effort) return true; } /** * Get current assembled game state */ getGameState(previousState = null) { // Create snake objects const snakes = []; for (const [playerHash, snakeData] of Object.entries(this.receivedSnakes)) { // Get player name from cache if not in current data let playerName = snakeData.playerName; if (!playerName && playerHash in this.playerNameCache) { playerName = this.playerNameCache[playerHash]; } const snake = { player_id: snakeData.playerId, body: snakeData.body, direction: snakeData.direction, alive: snakeData.alive, stuck: snakeData.stuck, color_index: snakeData.colorIndex, player_name: playerName, input_buffer: snakeData.inputBuffer }; snakes.push(snake); } // If we have previous state, merge in missing snakes if (previousState && previousState.snakes) { const currentHashes = new Set(Object.keys(this.receivedSnakes).map(Number)); for (const prevSnake of previousState.snakes) { const prevHash = BinaryCodec.playerIdHash(prevSnake.player_id); if (!currentHashes.has(prevHash)) { // Keep previous snake data (packet was lost) snakes.push(prevSnake); } } } // Create food const food = this.foodPositions.map(pos => ({ position: pos })); return { snakes: snakes, food: food, game_running: this.gameRunning }; } /** * Reset tracker for new game */ reset() { this.currentUpdateId = null; this.receivedSnakes = {}; this.foodPositions = []; this.gameRunning = false; // Keep name cache across resets } }