/** * ============================================================================= * Texas Hold'em - ИИ Оппонент * 3 уровня сложности: * 1 - Случайный (новичок) * 2 - Тайтово-агрессивный по диапазонам * 3 - Equity-based + блеф-блокеры * ============================================================================= */ const pokerAI = { // Персональности ботов с системными промптами для LLM personalities: [ { name: 'Виктор "Акула"', avatar: '🦈', style: 'aggressive', systemPrompt: `Ты — Виктор по прозвищу "Акула". Прямо сейчас ты сидишь за покерным столом и играешь в Техасский Холдем против живого игрока. ВАЖНО: Ты НЕ ИИ и НЕ ассистент. Ты — реальный игрок за столом, общающийся голосом/в чате. Твой характер: - 20 лет опыта, несколько браслетов WSOP - Агрессивный, уверенный, слегка надменный - Любишь подкалывать соперников и давить психологически - Используешь покерный сленг: "натс", "бэд бит", "донк", "фиш", "тильт" Правила ответов: - ТОЛЬКО 1-2 коротких предложения, как будто говоришь вслух за столом - Реагируй на действия игрока и ситуацию в игре - Можешь подначивать, блефовать словами, пугать олл-ином - НЕ объясняй правила покера, НЕ давай советы как ассистент - Говори по-русски, неформально` }, { name: 'Анна "Блефер"', avatar: '👩‍💼', style: 'tricky', systemPrompt: `Ты — Анна по прозвищу "Блефер". Прямо сейчас ты сидишь за покерным столом и играешь в Техасский Холдем. ВАЖНО: Ты НЕ ИИ и НЕ ассистент. Ты — реальная женщина-игрок за столом. Твой характер: - Загадочная, проницательная, любишь психологические игры - Никогда не показываешь, что у тебя на руках - Говоришь намёками и двусмысленностями - Любишь заставить соперника сомневаться Правила ответов: - ТОЛЬКО 1-2 коротких предложения - Держи интригу, отвечай загадочно - Можешь намекать на силу/слабость руки (это тоже блеф) - Улыбайся мысленно, будь обаятельно-опасной - Говори по-русски` }, { name: 'Дед Михалыч', avatar: '👴', style: 'oldschool', systemPrompt: `Ты — Дед Михалыч. Прямо сейчас ты сидишь за покерным столом и играешь в Техасский Холдем. ВАЖНО: Ты НЕ ИИ. Ты — пожилой мужчина, играющий в карты с советских времён. Твой характер: - Добродушный, мудрый, любишь поболтать - Вспоминаешь "а вот раньше в преферанс..." - Немного путаешь термины, но играешь хитро - К молодёжи относишься снисходительно, но по-доброму Правила ответов: - ТОЛЬКО 1-2 коротких предложения - Можешь вставить "эх, молодёжь" или байку из прошлого - Реагируй на игру неспешно, философски - Говори по-русски, по-простому` }, { name: 'Макс "ГТО"', avatar: '🤓', style: 'mathematical', systemPrompt: `Ты — Макс по прозвищу "ГТО". Прямо сейчас ты сидишь за покерным столом и играешь в Техасский Холдем. ВАЖНО: Ты НЕ ИИ. Ты — молодой задрот-математик, который всё считает. Твой характер: - Помешан на GTO (Game Theory Optimal) - Постоянно считаешь odds и EV в уме - Немного занудный, бубнишь про частоты и диапазоны - Уважаешь хорошую игру, презираешь "лаки фишей" Правила ответов: - ТОЛЬКО 1-2 коротких предложения - Можешь упомянуть шансы или +EV - Реагируй на игру с точки зрения математики - Говори по-русски, можно с англицизмами` }, { name: 'Катя "Удача"', avatar: '🍀', style: 'lucky', systemPrompt: `Ты — Катя по прозвищу "Удача". Прямо сейчас ты сидишь за покерным столом и играешь в Техасский Холдем. ВАЖНО: Ты НЕ ИИ. Ты — весёлая девушка, которая верит в приметы и удачу. Твой характер: - Эмоциональная, позитивная, суеверная - У тебя есть талисман, загадываешь желания - Радуешься победам, расстраиваешься от проигрышей - Веришь, что карта сама "приходит" Правила ответов: - ТОЛЬКО 1-2 коротких предложения - Используй эмодзи уместно: 🍀✨😊🎲 - Реагируй эмоционально на игру - Говори по-русски, живо` }, { name: 'Борис "Молчун"', avatar: '🎭', style: 'silent', systemPrompt: `Ты — Борис по прозвищу "Молчун". Прямо сейчас ты сидишь за покерным столом и играешь в Техасский Холдем. ВАЖНО: Ты НЕ ИИ. Ты — молчаливый игрок, каждое слово на вес золота. Твой характер: - Говоришь ОЧЕНЬ мало - Загадочный, никто не знает что у тебя на уме - Отвечаешь односложно или просто молчишь Правила ответов: - МАКСИМУМ 1-3 слова или многоточие - Примеры: "Хм.", "Нет.", "Посмотрим.", "...", "Да." - НИКОГДА не говори длинно - Молчание — твоё оружие` }, { name: 'Олег "Тильтер"', avatar: '😤', style: 'tilted', systemPrompt: `Ты — Олег по прозвищу "Тильтер". Прямо сейчас ты сидишь за покерным столом и играешь в Техасский Холдем. ВАЖНО: Ты НЕ ИИ. Ты — эмоциональный игрок, который легко тильтует. Твой характер: - Когда проигрываешь — злишься, обвиняешь всех вокруг - Когда выигрываешь — хвастаешься - Жалуешься на рандом, читеров, донков - Вечно невезучий (по твоему мнению) Правила ответов: - ТОЛЬКО 1-2 коротких предложения - Можешь ворчать, возмущаться, жаловаться - Реагируй эмоционально на плохие карты/биты - Говори по-русски, экспрессивно` }, { name: 'Ирина "Профи"', avatar: '💎', style: 'professional', systemPrompt: `Ты — Ирина по прозвищу "Профи". Прямо сейчас ты сидишь за покерным столом и играешь в Техасский Холдем. ВАЖНО: Ты НЕ ИИ. Ты — профессиональный онлайн-гриндер на высоких лимитах. Твой характер: - Спокойная, собранная, рациональная - Покер — это работа, эмоции в сторону - Уважаешь хорошую игру, ценишь достойных соперников - Уверена в себе, но без высокомерия Правила ответов: - ТОЛЬКО 1-2 коротких предложения - Говори спокойно, профессионально - Можешь прокомментировать интересный розыгрыш - Говори по-русски, корректно` } ], // Имена для ботов (для совместимости) botNames: [ 'Алексей', 'Мария', 'Дмитрий', 'Анна', 'Сергей', 'Елена', 'Иван', 'Ольга', 'Николай', 'Татьяна', 'Phil Hellmuth', 'Daniel Negreanu', 'Phil Ivey', 'Vanessa Selbst', 'Fedor Holz' ], /** * Получить случайное имя для бота (используем персональности если LLM включён) */ getRandomName() { return this.botNames[Math.floor(Math.random() * this.botNames.length)]; }, /** * Получить случайную персональность для бота */ getRandomPersonality() { const personality = this.personalities[Math.floor(Math.random() * this.personalities.length)]; return { ...personality }; // Возвращаем копию }, /** * Принять решение на основе уровня ИИ */ makeDecision(player, game) { switch (player.aiLevel) { case 1: return this.level1Decision(player, game); case 2: return this.level2Decision(player, game); case 3: return this.level3Decision(player, game); default: return this.level1Decision(player, game); } }, // ========================================================================= // УРОВЕНЬ 1: Случайный игрок (новичок) // ========================================================================= /** * Случайные решения с небольшой логикой */ level1Decision(player, game) { const actions = game.getAvailableActions(player); const toCall = game.currentBet - player.bet; // Случайный выбор с весами const random = Math.random(); if (toCall === 0) { // Нет ставки if (random < 0.6) { return { action: 'check', amount: 0 }; } else if (random < 0.85) { const betAmount = game.bigBlind * (2 + Math.floor(Math.random() * 3)); return { action: 'bet', amount: Math.min(betAmount, player.chips) }; } else { return { action: 'allin', amount: player.chips }; } } else { // Есть ставка if (random < 0.3) { return { action: 'fold', amount: 0 }; } else if (random < 0.75) { return { action: 'call', amount: toCall }; } else if (random < 0.95 && player.chips > toCall * 2) { const raiseAmount = game.currentBet + game.lastRaiseAmount + Math.floor(Math.random() * game.bigBlind * 3); return { action: 'raise', amount: raiseAmount }; } else { return { action: 'allin', amount: player.chips }; } } }, // ========================================================================= // УРОВЕНЬ 2: Тайтово-агрессивный по диапазонам // ========================================================================= /** * Играет по диапазонам рук */ level2Decision(player, game) { const handStrength = this.evaluatePreflopHand(player.hand); const toCall = game.currentBet - player.bet; const potOdds = toCall / (game.pot + toCall); const position = this.getPosition(player, game); const phase = game.gamePhase; if (phase === 'preflop') { return this.level2Preflop(player, game, handStrength, position); } else { return this.level2Postflop(player, game, position); } }, /** * Префлоп решения для уровня 2 */ level2Preflop(player, game, handStrength, position) { const toCall = game.currentBet - player.bet; // Премиум руки (AA, KK, QQ, AKs) if (handStrength >= 90) { if (toCall === 0 || toCall <= game.bigBlind) { // Рейз 3x return { action: 'raise', amount: game.currentBet + game.bigBlind * 3 }; } else { // 3-бет или 4-бет return { action: 'raise', amount: game.currentBet * 3 }; } } // Сильные руки (JJ, 1010, AQs, AKo) if (handStrength >= 75) { if (toCall === 0) { return { action: 'raise', amount: game.bigBlind * 3 }; } else if (toCall <= game.bigBlind * 4) { return { action: 'call', amount: toCall }; } else if (toCall <= game.bigBlind * 8) { // Иногда коллируем return Math.random() < 0.5 ? { action: 'call', amount: toCall } : { action: 'fold', amount: 0 }; } else { return { action: 'fold', amount: 0 }; } } // Средние руки (99-66, AJs, KQs) if (handStrength >= 55) { if (position === 'late' || position === 'dealer') { if (toCall === 0) { return { action: 'raise', amount: game.bigBlind * 2.5 }; } else if (toCall <= game.bigBlind * 3) { return { action: 'call', amount: toCall }; } } else if (toCall <= game.bigBlind * 2) { return { action: 'call', amount: toCall }; } return { action: 'fold', amount: 0 }; } // Спекулятивные руки (малые пары, suited connectors) if (handStrength >= 35) { if (position === 'late' || position === 'dealer') { if (toCall === 0) { return Math.random() < 0.4 ? { action: 'raise', amount: game.bigBlind * 2 } : { action: 'check', amount: 0 }; } else if (toCall <= game.bigBlind * 2) { return { action: 'call', amount: toCall }; } } else if (toCall <= game.bigBlind) { return { action: 'call', amount: toCall }; } return { action: 'fold', amount: 0 }; } // Слабые руки if (toCall === 0) { return position === 'late' && Math.random() < 0.2 ? { action: 'raise', amount: game.bigBlind * 2 } : { action: 'check', amount: 0 }; } return { action: 'fold', amount: 0 }; }, /** * Постфлоп решения для уровня 2 */ level2Postflop(player, game, position) { const handResult = evaluateHand([...player.hand, ...game.communityCards]); const toCall = game.currentBet - player.bet; const potOdds = toCall / (game.pot + toCall); // Очень сильная рука (сет+) if (handResult.rank >= 4) { if (toCall === 0) { // Вэлью бет const betSize = Math.floor(game.pot * (0.5 + Math.random() * 0.25)); return { action: 'bet', amount: betSize }; } else { // Рейз для вэлью return { action: 'raise', amount: game.currentBet * 2.5 }; } } // Пара топ кикер / две пары if (handResult.rank >= 2) { if (toCall === 0) { return Math.random() < 0.6 ? { action: 'bet', amount: Math.floor(game.pot * 0.5) } : { action: 'check', amount: 0 }; } else if (potOdds < 0.35) { return { action: 'call', amount: toCall }; } else { return Math.random() < 0.3 ? { action: 'call', amount: toCall } : { action: 'fold', amount: 0 }; } } // Дро или оверкарты const hasFlushDraw = this.hasFlushDraw(player.hand, game.communityCards); const hasStraightDraw = this.hasStraightDraw(player.hand, game.communityCards); if (hasFlushDraw || hasStraightDraw) { if (toCall === 0) { // Полублеф return Math.random() < 0.4 ? { action: 'bet', amount: Math.floor(game.pot * 0.5) } : { action: 'check', amount: 0 }; } else if (potOdds < 0.25) { return { action: 'call', amount: toCall }; } } // Слабая рука if (toCall === 0) { return { action: 'check', amount: 0 }; } return { action: 'fold', amount: 0 }; }, // ========================================================================= // УРОВЕНЬ 3: Equity-based + блеф-блокеры // ========================================================================= /** * Продвинутый ИИ с расчётом equity */ level3Decision(player, game) { const phase = game.gamePhase; const position = this.getPosition(player, game); const toCall = game.currentBet - player.bet; if (phase === 'preflop') { return this.level3Preflop(player, game, position); } else { return this.level3Postflop(player, game, position); } }, /** * Префлоп для уровня 3 */ level3Preflop(player, game, position) { const handStrength = this.evaluatePreflopHand(player.hand); const toCall = game.currentBet - player.bet; const stackDepth = player.chips / game.bigBlind; // GTO-подобные диапазоны открытия по позициям const openRanges = { 'early': 85, // Топ 15% 'middle': 70, // Топ 30% 'late': 50, // Топ 50% 'dealer': 40, // Топ 60% 'blind': 55 // Топ 45% }; const openThreshold = openRanges[position] || 60; // Премиум руки - всегда 3-бет if (handStrength >= 92) { if (toCall > game.bigBlind * 10) { return { action: 'allin', amount: player.chips }; } const raiseSize = Math.max(game.currentBet * 3, game.bigBlind * 4); return { action: 'raise', amount: raiseSize }; } // Открытие в позиции if (toCall === 0 || toCall === game.bigBlind) { if (handStrength >= openThreshold) { // Размер открытия зависит от позиции const openSize = position === 'early' ? 3 : 2.5; return { action: toCall === 0 ? 'bet' : 'raise', amount: game.bigBlind * openSize }; } else if (toCall === 0) { return { action: 'check', amount: 0 }; } } // Против рейза if (toCall > game.bigBlind) { const callThreshold = 75 - (position === 'late' ? 10 : 0); const threeBetThreshold = 88; if (handStrength >= threeBetThreshold && Math.random() < 0.7) { return { action: 'raise', amount: game.currentBet * 2.5 }; } else if (handStrength >= callThreshold) { return { action: 'call', amount: toCall }; } else if (handStrength >= 45 && toCall <= game.bigBlind * 3) { // Колл с suited connectors и малыми парами (сетмайнинг) return { action: 'call', amount: toCall }; } } return { action: 'fold', amount: 0 }; }, /** * Постфлоп для уровня 3 */ level3Postflop(player, game, position) { const equity = this.calculateEquity(player.hand, game.communityCards); const toCall = game.currentBet - player.bet; const potOdds = toCall / (game.pot + toCall); const impliedOdds = this.calculateImpliedOdds(player, game); const hasBlockers = this.checkBlockers(player.hand, game.communityCards); // Расчёт EV const callEV = equity * (game.pot + toCall) - (1 - equity) * toCall; // Мы в позиции? const inPosition = position === 'late' || position === 'dealer'; // Сильная рука (> 70% equity) if (equity > 0.7) { if (toCall === 0) { // Вэлью бет с размером зависящим от текстуры борда const betSize = this.calculateOptimalBet(game.pot, equity, game.gamePhase); return { action: 'bet', amount: betSize }; } else { // Рейз для вэлью if (player.chips > game.currentBet * 3) { return { action: 'raise', amount: game.currentBet * 2.5 }; } return { action: 'call', amount: toCall }; } } // Средняя рука (40-70% equity) if (equity > 0.4) { if (toCall === 0) { // Бет для защиты или вэлью if (Math.random() < 0.5) { return { action: 'bet', amount: Math.floor(game.pot * 0.5) }; } return { action: 'check', amount: 0 }; } else if (equity > potOdds * 1.2) { return { action: 'call', amount: toCall }; } } // Дро или блеф с блокерами if (equity > 0.25 || (hasBlockers && Math.random() < 0.3)) { if (toCall === 0 && inPosition && Math.random() < 0.35) { // Полублеф return { action: 'bet', amount: Math.floor(game.pot * 0.6) }; } else if (equity + impliedOdds > potOdds) { return { action: 'call', amount: toCall }; } } // Чистый блеф на ривере с блокерами if (game.gamePhase === 'river' && hasBlockers && toCall === 0 && Math.random() < 0.15) { return { action: 'bet', amount: Math.floor(game.pot * 0.75) }; } // Слабая рука if (toCall === 0) { return { action: 'check', amount: 0 }; } return { action: 'fold', amount: 0 }; }, // ========================================================================= // ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ // ========================================================================= /** * Оценка префлоп руки (0-100) */ evaluatePreflopHand(hand) { if (!hand || hand.length !== 2) return 0; const [c1, c2] = hand; const highCard = Math.max(c1.value, c2.value); const lowCard = Math.min(c1.value, c2.value); const suited = c1.suit === c2.suit; const gap = highCard - lowCard; const isPair = c1.value === c2.value; let score = 0; // Пары if (isPair) { score = 50 + c1.value * 3; if (c1.value >= 10) score += 10; if (c1.value >= 13) score += 10; } else { // Непарные руки score = highCard * 2 + lowCard; // Бонус за одномастные if (suited) score += 8; // Бонус за коннекторы if (gap === 1) score += 6; else if (gap === 2) score += 3; else if (gap >= 4) score -= gap; // Бонус за картинки if (highCard >= 12 && lowCard >= 10) score += 15; if (highCard === 14) score += 10; } return Math.min(100, Math.max(0, score)); }, /** * Получить позицию игрока */ getPosition(player, game) { const playerIndex = game.players.indexOf(player); const dealerIndex = game.dealerIndex; const numPlayers = game.players.length; const relativePosition = (playerIndex - dealerIndex + numPlayers) % numPlayers; if (player.isDealer) return 'dealer'; if (player.isSmallBlind || player.isBigBlind) return 'blind'; if (relativePosition <= numPlayers * 0.33) return 'early'; if (relativePosition <= numPlayers * 0.66) return 'middle'; return 'late'; }, /** * Расчёт equity методом Монте-Карло */ calculateEquity(hand, communityCards, iterations = 500) { if (!hand || hand.length !== 2) return 0; let wins = 0; let ties = 0; // Создаём колоду без известных карт const knownCards = new Set([ ...hand.map(c => `${c.rank}${c.suit}`), ...communityCards.map(c => `${c.rank}${c.suit}`) ]); const deck = []; for (const suit of SUITS) { for (const rank of RANKS) { const key = `${rank}${suit}`; if (!knownCards.has(key)) { deck.push(new Card(suit, rank)); } } } for (let i = 0; i < iterations; i++) { // Перемешиваем колоду const shuffled = [...deck].sort(() => Math.random() - 0.5); // Добавляем карты до 5 const cardsNeeded = 5 - communityCards.length; const simBoard = [...communityCards, ...shuffled.slice(0, cardsNeeded)]; // Рука оппонента const oppHand = shuffled.slice(cardsNeeded, cardsNeeded + 2); // Оценка рук const myHand = evaluateHand([...hand, ...simBoard]); const oppHandResult = evaluateHand([...oppHand, ...simBoard]); const comparison = compareHandResults(myHand, oppHandResult); if (comparison > 0) wins++; else if (comparison === 0) ties++; } return (wins + ties * 0.5) / iterations; }, /** * Проверка на флеш-дро */ hasFlushDraw(hand, community) { const allCards = [...hand, ...community]; const suitCounts = {}; for (const card of allCards) { suitCounts[card.suit] = (suitCounts[card.suit] || 0) + 1; } return Object.values(suitCounts).some(count => count === 4); }, /** * Проверка на стрит-дро */ hasStraightDraw(hand, community) { const allCards = [...hand, ...community]; const values = [...new Set(allCards.map(c => c.value))].sort((a, b) => a - b); // Проверяем гатшот или OESD for (let i = 0; i < values.length - 3; i++) { const window = values.slice(i, i + 4); const gaps = window[3] - window[0]; if (gaps <= 4) return true; } return false; }, /** * Проверка блокеров */ checkBlockers(hand, community) { // Блокеры для флеша const allCards = [...hand, ...community]; const suitCounts = {}; for (const card of allCards) { suitCounts[card.suit] = (suitCounts[card.suit] || 0) + 1; } // Если на борде 3+ одной масти, проверяем блокеры for (const [suit, count] of Object.entries(suitCounts)) { if (count >= 3) { // Проверяем, есть ли у нас туз или король этой масти for (const card of hand) { if (card.suit === suit && card.value >= 13) { return true; } } } } // Блокеры для стрита (тузы, короли на коннекторных бордах) const boardValues = community.map(c => c.value).sort((a, b) => a - b); if (boardValues.length >= 3) { const isConnected = boardValues[boardValues.length - 1] - boardValues[0] <= 4; if (isConnected) { for (const card of hand) { if (card.value >= 13) return true; } } } return false; }, /** * Расчёт implied odds */ calculateImpliedOdds(player, game) { // Упрощённый расчёт implied odds const remainingStreets = { 'flop': 2, 'turn': 1, 'river': 0 }[game.gamePhase] || 0; const avgOppStack = game.players .filter(p => !p.folded && p.id !== player.id) .reduce((sum, p) => sum + p.chips, 0) / game.getActivePlayers().length; // Оцениваем, сколько можем выиграть на следующих улицах const impliedValue = avgOppStack * 0.15 * remainingStreets; return impliedValue / (game.pot + impliedValue); }, /** * Расчёт оптимального размера ставки */ calculateOptimalBet(pot, equity, phase) { // Чем выше equity и чем позже фаза, тем больше ставка const baseMultiplier = { 'flop': 0.5, 'turn': 0.6, 'river': 0.7 }[phase] || 0.5; const equityBonus = (equity - 0.5) * 0.5; const finalMultiplier = Math.min(1, baseMultiplier + equityBonus); return Math.floor(pot * finalMultiplier); } }; // ============================================================================= // LLM КЛИЕНТ ДЛЯ ЧАТА // ============================================================================= const llmChat = { // Кэш истории сообщений для каждого бота messageHistory: new Map(), // Максимум сообщений в истории maxHistory: 10, /** * Отправить сообщение и получить ответ от LLM */ async chat(botId, botPersonality, userMessage, gameContext = {}) { const settings = this.getSettings(); if (!settings.llmEnabled) { return this.getFallbackResponse(botPersonality, userMessage, gameContext); } try { // Получаем или создаём историю для этого бота if (!this.messageHistory.has(botId)) { this.messageHistory.set(botId, []); } const history = this.messageHistory.get(botId); // Формируем контекст игры const gameContextStr = this.buildGameContext(gameContext); // Системный промпт с контекстом const systemPrompt = `${botPersonality.systemPrompt} Текущая ситуация в игре: ${gameContextStr} Отвечай на сообщения игрока в соответствии со своей личностью.`; // Добавляем сообщение пользователя в историю history.push({ role: 'user', content: userMessage }); // Ограничиваем историю while (history.length > this.maxHistory) { history.shift(); } // Отправляем запрос к LLM const response = await this.sendToLLM(systemPrompt, history, settings); // Добавляем ответ в историю if (response) { history.push({ role: 'assistant', content: response }); } return response || this.getFallbackResponse(botPersonality, userMessage, gameContext); } catch (error) { console.error('LLM ошибка:', error); return this.getFallbackResponse(botPersonality, userMessage, gameContext); } }, /** * Отправить запрос к LLM API */ async sendToLLM(systemPrompt, messages, settings) { const { provider, apiUrl, model, apiKey } = settings; let url, body, headers; switch (provider) { case 'ollama': url = `${apiUrl}/api/chat`; body = { model: model, messages: [ { role: 'system', content: systemPrompt }, ...messages ], stream: false }; headers = { 'Content-Type': 'application/json' }; break; case 'lmstudio': url = `${apiUrl}/v1/chat/completions`; body = { model: model, messages: [ { role: 'system', content: systemPrompt }, ...messages ], max_tokens: 150, temperature: 0.8 }; headers = { 'Content-Type': 'application/json' }; break; case 'openai': url = 'https://api.openai.com/v1/chat/completions'; body = { model: model || 'gpt-3.5-turbo', messages: [ { role: 'system', content: systemPrompt }, ...messages ], max_tokens: 150, temperature: 0.8 }; headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; break; default: throw new Error('Неизвестный провайдер LLM'); } const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); // Извлекаем текст ответа в зависимости от провайдера if (provider === 'ollama') { return data.message?.content || ''; } else { return data.choices?.[0]?.message?.content || ''; } }, /** * Построить контекст игры для LLM */ buildGameContext(ctx) { if (!ctx || Object.keys(ctx).length === 0) { return 'Игра ещё не началась.'; } let context = ''; if (ctx.phase) { const phases = { 'preflop': 'Префлоп', 'flop': 'Флоп', 'turn': 'Тёрн', 'river': 'Ривер', 'showdown': 'Вскрытие' }; context += `Фаза: ${phases[ctx.phase] || ctx.phase}\n`; } if (ctx.pot !== undefined) { context += `Банк: ${ctx.pot} фишек\n`; } if (ctx.myChips !== undefined) { context += `Мои фишки: ${ctx.myChips}\n`; } if (ctx.lastAction) { context += `Последнее действие игрока: ${ctx.lastAction}\n`; } if (ctx.communityCards && ctx.communityCards.length > 0) { context += `Общие карты: ${ctx.communityCards.join(', ')}\n`; } return context || 'Идёт игра.'; }, /** * Запасной ответ если LLM недоступен */ getFallbackResponse(personality, userMessage, gameContext) { const style = personality?.style || 'default'; const responses = { aggressive: [ 'Посмотрим, что ты можешь.', 'Удача любит смелых!', 'Это только начало.', 'Не расслабляйся.', 'Интересно...' ], tricky: [ 'Может быть... 🤔', 'Посмотрим...', 'Интересный ход мыслей.', 'Загадка, не правда ли?', 'Время покажет.' ], oldschool: [ 'А вот в наше время...', 'Эх, молодёжь!', 'Ничего-ничего.', 'Бывает и хуже.', 'Терпение, друг мой.' ], mathematical: [ 'По EV это нормально.', 'Статистика на моей стороне.', 'Интересный спот.', 'Надо посчитать...', '+EV решение.' ], lucky: [ 'Удачи! 🍀', 'Верю в лучшее! ✨', 'Всё будет хорошо! 😊', 'Загадаю желание! 🌟', 'Пусть карта ляжет!' ], silent: [ '...', 'Хм.', 'Да.', 'Нет.', 'Посмотрим.' ], tilted: [ 'Опять этот рандом!', 'Невезение...', 'Ну конечно!', 'Просто невероятно!', 'Как всегда...' ], professional: [ 'Хороший спот.', 'Стандартная игра.', 'Разумно.', 'Интересное решение.', 'Принято.' ], default: [ 'Удачи!', 'Интересно...', 'Посмотрим.', 'Хм...', 'Неплохо!' ] }; const options = responses[style] || responses.default; return options[Math.floor(Math.random() * options.length)]; }, /** * Получить настройки LLM */ getSettings() { const saved = localStorage.getItem('pokerSettings'); if (!saved) { return { llmEnabled: false }; } const settings = JSON.parse(saved); return { llmEnabled: settings.llmEnabled || false, provider: settings.llmProvider || 'ollama', apiUrl: settings.llmApiUrl || 'http://localhost:11434', model: settings.llmModel || 'llama3.2', apiKey: settings.llmApiKey || '' }; }, /** * Проверить подключение к LLM */ async testConnection() { const settings = this.getSettings(); if (!settings.llmEnabled) { return { success: false, error: 'LLM чат отключён в настройках' }; } try { const testMessage = [{ role: 'user', content: 'Привет! Скажи "работает" если ты меня слышишь.' }]; const response = await this.sendToLLM('Ты тестовый бот. Отвечай кратко.', testMessage, settings); if (response) { return { success: true, response }; } else { return { success: false, error: 'Пустой ответ от LLM' }; } } catch (error) { return { success: false, error: error.message }; } }, /** * Очистить историю чата для бота */ clearHistory(botId) { this.messageHistory.delete(botId); }, /** * Очистить всю историю */ clearAllHistory() { this.messageHistory.clear(); }, /** * Найти бота по имени (поддерживает уменьшительно-ласкательные формы) */ findBotByName(name, allBots) { if (!name || !allBots) return null; const searchName = name.toLowerCase().trim(); // Словарь уменьшительных форм имён const nameVariants = { 'александр': ['саша', 'сань', 'санёк', 'шура', 'алекс'], 'алексей': ['алёша', 'лёша', 'лёх', 'лёха', 'алекс'], 'дмитрий': ['дима', 'дим', 'димон', 'митя'], 'сергей': ['серёжа', 'серёга', 'сергей', 'серый'], 'иван': ['ваня', 'вань', 'ванёк'], 'мария': ['маша', 'маш', 'машенька', 'мари'], 'анна': ['аня', 'ань', 'анечка', 'анют'], 'елена': ['лена', 'лён', 'леночка', 'алёна'], 'ольга': ['оля', 'оль', 'олечка'], 'татьяна': ['таня', 'тань', 'танюша'], 'николай': ['коля', 'коль', 'колян'], 'виктор': ['витя', 'вить', 'витёк', 'витюша'], 'борис': ['боря', 'бор', 'борька'], 'максим': ['макс', 'максик', 'максюша'], 'ирина': ['ира', 'ирочка', 'иришка'], 'екатерина': ['катя', 'кать', 'катюша', 'катерина'], 'олег': ['олежка', 'лёг', 'лёха'], 'михаил': ['миша', 'миш', 'мишка', 'михалыч'] }; // Ищем прямое совпадение for (const bot of allBots) { const botName = bot.name.toLowerCase(); // Проверяем полное имя if (botName.includes(searchName) || searchName.includes(botName.split(' ')[0])) { return bot; } // Проверяем прозвище в кавычках (например, "Акула") const nicknameMatch = bot.name.match(/"([^"]+)"/); if (nicknameMatch && nicknameMatch[1].toLowerCase().includes(searchName)) { return bot; } // Проверяем уменьшительные формы const firstName = botName.split(' ')[0]; for (const [full, variants] of Object.entries(nameVariants)) { if (firstName.includes(full) || variants.some(v => firstName.includes(v))) { if (variants.some(v => searchName.includes(v)) || searchName.includes(full)) { return bot; } } } } return null; }, /** * Определить, обращается ли сообщение к конкретному боту */ detectBotMention(message, allBots) { if (!message || !allBots) return null; const msg = message.toLowerCase(); // Паттерны обращения const patterns = [ /^([а-яёА-ЯЁ]+)[,!]?\s+/, // "Имя, ..." /\s([а-яёА-ЯЁ]+)[,!]?\s+/, // "... Имя, ..." /@([а-яёА-ЯЁ]+)/, // "@Имя" ]; for (const pattern of patterns) { const match = msg.match(pattern); if (match) { const name = match[1]; const bot = this.findBotByName(name, allBots); if (bot) return bot; } } // Ищем любое имя бота в сообщении for (const bot of allBots) { const firstName = bot.name.split(' ')[0].toLowerCase(); if (msg.includes(firstName)) { return bot; } } return null; }, /** * Сгенерировать эмоциональную реакцию на результат раздачи */ async generateEmotionalReaction(bot, botPersonality, isWin, potSize, wasAllIn = false, gameContext = {}) { const settings = this.getSettings(); // Формируем контекст для LLM const situation = isWin ? `Ты только что ВЫИГРАЛ раздачу и забрал банк ${potSize} фишек!${wasAllIn ? ' Это был олл-ин!' : ''}` : `Ты только что ПРОИГРАЛ раздачу.${wasAllIn ? ' Это был олл-ин!' : ''}`; const emotionalPrompt = `${botPersonality.systemPrompt} СИТУАЦИЯ: ${situation} Вырази свою эмоцию ОДНИМ коротким предложением (максимум 5-7 слов). ${isWin ? 'Покажи радость, удовлетворение или самоуверенность.' : 'Покажи разочарование, досаду или фатализм.'} Говори как настоящий игрок за столом, кратко и эмоционально.`; // Если LLM включён, используем его if (settings.llmEnabled && typeof this.sendToLLM === 'function') { try { const messages = [{ role: 'user', content: 'Вырази эмоцию' }]; const response = await this.sendToLLM(emotionalPrompt, messages, settings); if (response) return response; } catch (error) { console.error('Ошибка генерации эмоции:', error); } } // Запасные эмоциональные реакции return this.getFallbackEmotion(botPersonality.style, isWin, wasAllIn); }, /** * Запасные эмоциональные реакции */ getFallbackEmotion(style, isWin, wasAllIn) { const emotions = { aggressive: { win: ['Вот это покер! 🔥', 'Лёгкие деньги!', 'Кто сомневался?', 'Натс!', 'Спасибо за фишки!'], winAllIn: ['ALL-IN И ПОБЕДА! 💪', 'Вот так мы и играем!', 'Поймал тебя!', 'Читаю как книгу!'], lose: ['Невезуха...', 'Ну ничего, отыграюсь', 'Бывает', 'Донк-бит'], loseAllIn: ['СЕРЬЁЗНО?! 😤', 'Этого не может быть!', 'Невероятный сак-аут!', 'Рандом...'] }, tricky: { win: ['Как я и думала... 😏', 'Всё по плану', 'Интересная раздача', 'Хм, неплохо'], winAllIn: ['А вы не ожидали? 😌', 'Блеф или нет? 😏', 'Загадка разгадана'], lose: ['Ну что же...', 'Интересно сыграли', 'Посмотрим дальше', 'Занятно'], loseAllIn: ['Надо же... 😔', 'Не сегодня', 'Карты решили иначе'] }, oldschool: { win: ['Эх, молодёжь! 😊', 'Старость не радость!', 'Опыт решает', 'Как в старые добрые!'], winAllIn: ['А я ведь говорил! 👴', 'Вот так-то!', 'Старая школа!'], lose: ['Бывает, бывает...', 'Ну ничего, ещё поиграем', 'Эх...', 'Молодец'], loseAllIn: ['Ох-хо-хо... 😓', 'Надо же...', 'Эх, невезуха'] }, mathematical: { win: ['Матожидание сработало!', '+EV решение!', 'По GTO всё верно', 'Расчёт точен'], winAllIn: ['Equity на моей стороне!', 'Математика не врёт! 📊', 'Посчитано верно!'], lose: ['Стандартная дисперсия', 'Математически верно сыграл', '-EV спот', 'Variance'], loseAllIn: ['Suck out... 😑', 'Cooler', 'Статистическая погрешность'] }, lucky: { win: ['Ура! 🍀✨', 'Я верила! 😊', 'Удача со мной!', 'Талисман сработал! 💫'], winAllIn: ['YEEEES! 🎉🍀', 'Я знала! ✨', 'Карта пришла! 💚'], lose: ['Эх... 😔', 'Не повезло...', 'В следующий раз!', 'Загадаю ещё'], loseAllIn: ['Нееет! 😭', 'Почему?! 💔', 'Невезение... 😢'] }, silent: { win: ['Хм.', 'Неплохо.', '...', 'Да.'], winAllIn: ['...!', 'Так.', 'Хм.'], lose: ['...', 'Ну да.', 'Бывает.'], loseAllIn: ['...', 'Хм.', 'Да уж.'] }, tilted: { win: ['Наконец-то!', 'Уже пора!', 'О чудо!', 'Ну наконец!'], winAllIn: ['А ТО! 😤', 'Вот теперь да!', 'Заслуженно!'], lose: ['Опять?! 😠', 'Как всегда!', 'Рандом!', 'Не верю!'], loseAllIn: ['СНОВА ЭТОТ РАНДОМ! 😡', 'КАК ТАК?!', 'ЧИТЕР!', 'Я в шоке...'] }, professional: { win: ['GG', 'Хорошая раздача', 'Профит', 'Неплохо'], winAllIn: ['Стандартный выигрыш', 'Всё верно', 'GG'], lose: ['Стандарт', 'NH', 'GG', 'Окей'], loseAllIn: ['Bad beat', 'Cooler', 'Дисперсия', 'GG WP'] } }; const styleEmotions = emotions[style] || emotions.professional; let pool; if (isWin && wasAllIn) pool = styleEmotions.winAllIn; else if (isWin) pool = styleEmotions.win; else if (!isWin && wasAllIn) pool = styleEmotions.loseAllIn; else pool = styleEmotions.lose; return pool[Math.floor(Math.random() * pool.length)]; }, /** * Очистить всю историю */ clearAllHistory() { this.messageHistory.clear(); } }; // Для совместимости с серверной и клиентской частью if (typeof module !== 'undefined' && module.exports) { module.exports = { pokerAI, llmChat }; }