pets1/public/ai.js

1457 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* =============================================================================
* 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.phaseRu) {
context += `📍 Фаза: ${ctx.phaseRu}\n`;
}
// Общие карты на столе
if (ctx.communityCards && ctx.communityCards.length > 0) {
context += `🃏 Карты на столе: ${ctx.communityCards.join(' ')}\n`;
} else {
context += `🃏 Карты на столе: пока нет\n`;
}
// Банк и ставки
if (ctx.pot !== undefined) {
context += `💰 Банк: ${ctx.pot} фишек\n`;
}
if (ctx.currentBet !== undefined && ctx.currentBet > 0) {
context += `💵 Текущая ставка: ${ctx.currentBet} фишек\n`;
}
// Мои данные (данные бота)
if (ctx.myName) {
context += `\n🤖 Мои данные:\n`;
context += ` - Имя: ${ctx.myName}\n`;
if (ctx.myCards && ctx.myCards.length > 0) {
context += ` - Карты: ${ctx.myCards.join(' ')}\n`;
}
context += ` - Фишки: ${ctx.myChips}\n`;
if (ctx.myBet > 0) {
context += ` - Моя ставка: ${ctx.myBet} (всего в раздаче: ${ctx.myTotalBet})\n`;
}
if (ctx.myPosition) {
context += ` - Позиция: ${ctx.myPosition}\n`;
}
}
// Другие игроки
if (ctx.players && ctx.players.length > 0) {
context += `\n👥 Другие игроки:\n`;
ctx.players.forEach(p => {
if (p.isMe) return; // Пропускаем себя
let playerStatus = '';
if (p.folded) {
playerStatus = ' [спасовал ❌]';
} else if (p.allIn) {
playerStatus = ' [ва-банк 💥]';
} else if (p.lastAction) {
const actionRu = {
'fold': 'пас',
'check': 'чек',
'call': 'колл',
'bet': 'бет',
'raise': 'рейз',
'allin': 'ва-банк'
}[p.lastAction] || p.lastAction;
playerStatus = ` [последнее: ${actionRu}]`;
}
context += ` - ${p.name}: ${p.chips} фишек`;
if (p.bet > 0) {
context += `, ставка ${p.bet}`;
}
if (p.isDealer) {
context += ` 🎲`;
}
context += playerStatus + `\n`;
});
}
// Активные игроки
if (ctx.activePlayers && ctx.activePlayers.length > 0) {
context += `\n✅ Активные: ${ctx.activePlayers.join(', ')}\n`;
}
if (ctx.foldedPlayers && ctx.foldedPlayers.length > 0) {
context += `❌ Спасовали: ${ctx.foldedPlayers.join(', ')}\n`;
}
// Последнее сообщение игрока
if (ctx.lastPlayerAction) {
context += `\n💬 Игрок написал: "${ctx.lastPlayerAction}"\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();
},
/**
* Сгенерировать поздравление игрока с сильной рукой
*/
async generatePlayerCongratulation(bot, botPersonality, playerName, handRank, handName, potSize, gameContext = {}) {
const settings = this.getSettings();
// Определяем, насколько сильная рука (для редкости поздравлений)
const isStrongHand = handRank >= 7; // Фулл-хаус и выше
const isVeryStrongHand = handRank >= 9; // Стрит-флеш и роял-флеш
// Формируем промпт для LLM
const congratsPrompt = `${botPersonality.systemPrompt}
СИТУАЦИЯ: Игрок ${playerName} только что ВЫИГРАЛ раздачу с рукой "${handName}"! Банк: ${potSize} фишек.
${isVeryStrongHand ? 'Это ОЧЕНЬ редкая и сильная рука!' : (isStrongHand ? 'Это сильная рука!' : 'Это хорошая рука!')}
Поздравь игрока КРАТКО (максимум 5-7 слов), в своём стиле.
${isVeryStrongHand ? 'Покажи восхищение и уважение!' : 'Будь доброжелательным.'}`;
// Если LLM включён, используем его
if (settings.llmEnabled && typeof this.sendToLLM === 'function') {
try {
const messages = [{ role: 'user', content: 'Поздрави игрока' }];
const response = await this.sendToLLM(congratsPrompt, messages, settings);
if (response) return response;
} catch (error) {
console.error('Ошибка генерации поздравления:', error);
}
}
// Запасные поздравления
return this.getFallbackCongratulation(botPersonality.style, handRank, handName);
},
/**
* Запасные поздравления
*/
getFallbackCongratulation(style, handRank, handName) {
const congratulations = {
aggressive: {
normal: ['Неплохо сыграно!', 'Уважаю!', 'Сильная рука!', 'Молодец!'],
strong: ['Вау, фулл-хаус! 💪', 'Монстр-рука!', 'Красавчик!', 'Вот это да!'],
veryStrong: [`${handName}?! Респект! 🔥`, 'НЕВЕРОЯТНО!', 'Легенда!', 'Вот это покер!']
},
tricky: {
normal: ['Интересно сыграно... 😏', 'Неплохо, неплохо', 'Красиво'],
strong: ['Фулл-хаус? Впечатляет! 👏', 'Загадочная игра...', 'Сильно!'],
veryStrong: [`${handName}... Я в восторге! 😮`, 'Невероятная рука!', 'Потрясающе!']
},
oldschool: {
normal: ['Молодец, голубчик!', 'Хорошо, хорошо!', 'Эх, красота!'],
strong: ['Фулл-хаус! Красота! 👴', 'Вот это рука!', 'Как в старые времена!'],
veryStrong: [`${handName}! За 50 лет такое редко видел! 😲`, 'Ух ты!', 'Легенда!']
},
mathematical: {
normal: ['Хороший EV!', 'Статистически сильно', '+EV рука'],
strong: ['Фулл-хаус! Rare! 📊', 'Топ 0.1% рук!', 'Вероятность низкая!'],
veryStrong: [`${handName}... 0.00154%! 🤯`, 'Математическое чудо!', 'Редчайший случай!']
},
lucky: {
normal: ['Ура! Молодец! 😊', 'Везунчик! 🍀', 'Здорово!'],
strong: ['Фулл-хаус! Удача! 🍀✨', 'Карты тебя любят!', 'Талисман работает! 💫'],
veryStrong: [`${handName}! ЭТО МАГИЯ! 🎉🍀`, 'Невероятная удача!', 'Чудо! ✨💚']
},
silent: {
normal: ['Хм. Неплохо.', 'Да.', '...👍'],
strong: ['Фулл-хаус... Ого.', 'Впечатляет.', '...!'],
veryStrong: [`${handName}?! ...`, 'Вау.', '😮']
},
tilted: {
normal: ['Ну везёт же... 😒', 'Тебе повезло!', 'Конечно...'],
strong: ['Фулл-хаус?! Ладно, красиво 😤', 'Повезло сильно!', 'Не может быть!'],
veryStrong: [`${handName}... Я не верю! 😱`, 'КАК?!', 'Это читерство! (шучу)']
},
professional: {
normal: ['GG WP!', 'Nice hand!', 'Хорошо сыграно'],
strong: ['Фулл-хаус. Респект! 💎', 'Strong hand, WP!', 'Impressive!'],
veryStrong: [`${handName}! Incredible! 🎯`, 'Amazing hand!', 'Legendary!']
}
};
const styleCongrats = congratulations[style] || congratulations.professional;
let pool;
if (handRank >= 9) {
pool = styleCongrats.veryStrong;
} else if (handRank >= 7) {
pool = styleCongrats.strong;
} else {
pool = styleCongrats.normal;
}
return pool[Math.floor(Math.random() * pool.length)];
}
};
// Для совместимости с серверной и клиентской частью
if (typeof module !== 'undefined' && module.exports) {
module.exports = { pokerAI, llmChat };
}