class SnakeUtils { static snakeColor(index) { return `var(--snake-${((index % 10) + 1)})`; } static stableColorIndexFromId(snakeId) { const raw = String(snakeId || ""); let hash = 0; for (let i = 0; i < raw.length; i += 1) { hash = ((hash * 31) + raw.charCodeAt(i)) >>> 0; } return hash % 10; } static resolveSnakeColor(snakeId, isYou, colorById) { if (snakeId && colorById && colorById.has(snakeId)) { return colorById.get(snakeId); } if (isYou) return "var(--you)"; return SnakeUtils.snakeColor(SnakeUtils.stableColorIndexFromId(snakeId)); } static extractSnakeColor(rawSnake) { if (!rawSnake || typeof rawSnake !== "object") return null; const direct = rawSnake.color; const custom = rawSnake.customizations && rawSnake.customizations.color; const appearance = rawSnake.appearance && rawSnake.appearance.color; const color = direct || custom || appearance; if (!color) return null; return String(color).trim(); } static buildSnakeColorById(turnData, replay) { const colorById = new Map(); const replayTurns = replay && Array.isArray(replay.turns) ? replay.turns : []; const boardSnakes = turnData && turnData.board && Array.isArray(turnData.board.snakes) ? turnData.board.snakes : []; const replaySnakes = Array.isArray(turnData && turnData.snakes) ? turnData.snakes : []; const historicalSnakes = []; for (const replayTurn of replayTurns) { if (replayTurn && Array.isArray(replayTurn.snakes)) { historicalSnakes.push(...replayTurn.snakes); } if (replayTurn && replayTurn.board && Array.isArray(replayTurn.board.snakes)) { historicalSnakes.push(...replayTurn.board.snakes); } } for (const snake of [...historicalSnakes, ...boardSnakes, ...replaySnakes]) { if (!snake) continue; const snakeId = snake.id || snake.snake_id; if (!snakeId) continue; const color = SnakeUtils.extractSnakeColor(snake); if (color) colorById.set(snakeId, color); } return colorById; } static buildSnakeCustomizationById(turnData, replay) { const customById = new Map(); const replayTurns = replay && Array.isArray(replay.turns) ? replay.turns : []; const sources = []; for (const replayTurn of replayTurns) { if (replayTurn && replayTurn.board && Array.isArray(replayTurn.board.snakes)) { sources.push(...replayTurn.board.snakes); } } if (turnData && turnData.board && Array.isArray(turnData.board.snakes)) { sources.push(...turnData.board.snakes); } for (const snake of sources) { if (!snake) continue; const snakeId = snake.id || snake.snake_id; if (!snakeId) continue; const custom = snake.customizations || {}; const head = custom.head || null; const tail = custom.tail || null; if (head || tail) { customById.set(snakeId, { head, tail }); } } return customById; } static buildCustomizationIconUrl(kind, value) { const raw = String(value || "").trim().toLowerCase(); if (!raw) return null; if (!/^[a-z0-9-]+$/.test(raw)) return null; return `/dashboard/customizations/${kind}/${raw}.svg`; } static parseSnakeColor(color) { if (!color) return null; const value = String(color).trim(); if (value.startsWith("#")) { const hex = value.slice(1); if (hex.length === 3) { const r = parseInt(hex[0] + hex[0], 16); const g = parseInt(hex[1] + hex[1], 16); const b = parseInt(hex[2] + hex[2], 16); return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b }; } if (hex.length === 6) { const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b }; } } const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (rgb) { return { r: Math.max(0, Math.min(255, Number(rgb[1]))), g: Math.max(0, Math.min(255, Number(rgb[2]))), b: Math.max(0, Math.min(255, Number(rgb[3]))), }; } return null; } static snakeRowBackground(color) { const parsed = SnakeUtils.parseSnakeColor(color); if (!parsed) return "transparent"; const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; const alpha = isDark ? 0.26 : 0.16; return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`; } static inferHeadDirection(snake) { const body = Array.isArray(snake && snake.body) ? snake.body : []; if (body.length >= 2) { const head = body[0]; const neck = body[1]; if (head && neck) { const dx = Number(head.x) - Number(neck.x); const dy = Number(head.y) - Number(neck.y); if (dx > 0) return "right"; if (dx < 0) return "left"; if (dy > 0) return "up"; if (dy < 0) return "down"; } } const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase(); if (["up", "down", "left", "right"].includes(inferred)) return inferred; if (body.length < 2) return "right"; const head = body[0]; const neck = body[1]; if (!head || !neck) return "right"; const dx = Number(head.x) - Number(neck.x); const dy = Number(head.y) - Number(neck.y); if (dx > 0) return "right"; if (dx < 0) return "left"; if (dy > 0) return "up"; if (dy < 0) return "down"; return "right"; } static inferTailDirection(snake) { const body = Array.isArray(snake && snake.body) ? snake.body : []; if (body.length < 2) return "right"; const tail = body[body.length - 1]; if (!tail) return "right"; let beforeTail = null; for (let idx = body.length - 2; idx >= 0; idx -= 1) { const candidate = body[idx]; if (!candidate) continue; if (Number(candidate.x) !== Number(tail.x) || Number(candidate.y) !== Number(tail.y)) { beforeTail = candidate; break; } } if (!beforeTail) { const inferred = String(snake && snake.inferred_move ? snake.inferred_move : "").toLowerCase(); if (["up", "down", "left", "right"].includes(inferred)) return inferred; return "right"; } const dx = Number(beforeTail.x) - Number(tail.x); const dy = Number(beforeTail.y) - Number(tail.y); if (dx > 0) return "right"; if (dx < 0) return "left"; if (dy > 0) return "up"; if (dy < 0) return "down"; return "right"; } static directionToHeadTransform(direction) { if (direction === "left") return "scaleX(-1)"; if (direction === "up") return "rotate(270deg)"; if (direction === "down") return "rotate(90deg)"; return "rotate(0deg)"; } static directionToTailTransform(direction) { if (direction === "right") return "scaleX(-1)"; if (direction === "left") return "rotate(0deg)"; if (direction === "up") return "rotate(270deg) scaleX(-1)"; if (direction === "down") return "rotate(90deg) scaleX(-1)"; return "scaleX(-1)"; } static stableVariantFromString(value) { const raw = String(value || ""); if (!raw) return 1; let hash = 0; for (let i = 0; i < raw.length; i += 1) { hash = ((hash * 33) + raw.charCodeAt(i)) >>> 0; } return (hash % 5) + 1; } }