1091 lines
42 KiB
JavaScript
1091 lines
42 KiB
JavaScript
/**
|
||
* =============================================================================
|
||
* 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();
|
||
}
|
||
};
|
||
|
||
// Для совместимости с серверной и клиентской частью
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = { pokerAI, llmChat };
|
||
}
|