go_games_collection/bot.go

318 lines
12 KiB
Go
Raw Permalink 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.

package main
import "math/rand"
// BotStrategy — стратегия одного полного хода бота: взять карту,
// при необходимости выложить/подложить комбинации, сбросить карту
// (либо объявить Тонк, если это входит в стратегию).
type BotStrategy interface {
PlayTurn(g *GameState) error
}
// dropRiskThreshold — вес руки, ниже которого боты готовы рискнуть
// дропом. Строже порога Тонка (см. DefaultTonkThreshold), потому что
// у дропа, в отличие от Тонка, нет встроенной защиты от ошибки:
// объявить его может каждый в начале своего хода, и при ошибке
// придётся доплатить штраф. Бот при этом решает только по своей
// руке — он не подглядывает в карты соперников, как и человек.
const dropRiskThreshold = 3
// maybeBotDrop проверяет, стоит ли боту рискнуть дропом прямо перед
// взятием карты (единственный момент, когда дроп разрешён). Если да
// — объявляет его и возвращает true (ход на этом закончен).
func maybeBotDrop(g *GameState) (bool, error) {
if g.Phase != PhaseDraw {
return false, nil
}
if HandValue(g.current().Hand) > dropRiskThreshold {
return false, nil
}
_, err := g.Drop()
return true, err
}
// botLevel — запись в реестре доступных уровней сложности ботов:
// используется и для явного назначения бота на место, и для
// случайного выбора (например, при замене выбывшего игрока).
type botLevel struct {
Suffix string // короткое имя уровня для отображения в UI, напр. "средний"
New func() BotStrategy
}
var botLevels = []botLevel{
{Suffix: "простой", New: func() BotStrategy { return SimpleBot{} }},
{Suffix: "средний", New: func() BotStrategy { return MediumBot{} }},
{Suffix: "Тонк", New: func() BotStrategy { return TonkBot{} }},
}
// randomBotStrategy возвращает бота случайного уровня сложности из
// botLevels вместе с человекочитаемым названием уровня. Уровень
// используется только во внутренней логике/тестах — в интерфейсе
// игрока он не показывается (уровень бота держится в секрете).
func randomBotStrategy() (BotStrategy, string) {
lvl := botLevels[rand.Intn(len(botLevels))]
return lvl.New(), lvl.Suffix
}
// botNamePool — имена, которые получают боты за столом. Уровень
// сложности в имени никак не отражается — игрок не должен заранее
// знать, с кем имеет дело.
var botNamePool = []string{"Костоправ", "Гоблин", "Одноглазый", "Ворон", "Ильмо"}
// botNamePoolEN — те же самые персонажи, но по-английски: смысловые
// прозвища переведены (Костоправ -> Bonesetter и т.д.), а не просто
// транслитерированы, чтобы сохранить характер имён.
var botNamePoolEN = []string{"Bonesetter", "Goblin", "One-Eye", "Raven", "Ilmo"}
func activeBotNamePool() []string {
if CurrentLang() == LangEN {
return botNamePoolEN
}
return botNamePool
}
// pickBotName выбирает случайное имя из активного (для текущего
// языка) пула имён, по возможности не повторяющее уже занятые имена
// (переданные в used). Если все имена из пула уже заняты, допускает
// повтор — это лучше, чем оставить место вовсе без имени.
func pickBotName(used map[string]bool) string {
pool := activeBotNamePool()
free := make([]string, 0, len(pool))
for _, name := range pool {
if !used[name] {
free = append(free, name)
}
}
if len(free) == 0 {
free = pool
}
return free[rand.Intn(len(free))]
}
// newBotRoster формирует n мест за столом: у каждого — случайное
// (без повторов, если хватает имён в пуле) имя из botNamePool и
// независимо выбранный случайный уровень сложности. Порядок уровней
// не фиксирован (простой/средний/сложный может стоять в любом
// месте, включая повторы) — это осознанное решение, чтобы нельзя
// было угадать силу бота по его месту за столом.
func newBotRoster(n int) (names []string, strategies []BotStrategy) {
used := make(map[string]bool, n)
names = make([]string, n)
strategies = make([]BotStrategy, n)
for i := 0; i < n; i++ {
name := pickBotName(used)
used[name] = true
names[i] = name
strat, _ := randomBotStrategy()
strategies[i] = strat
}
return names, strategies
}
// --- Уровень 1: простой (жадный) бот -----------------------------
//
// Берёт из сброса, только если карта сразу закрывает готовую
// комбинацию, иначе берёт из колоды. Выкладывает всё, что может.
// Сбрасывает самую тяжёлую карту, не участвующую ни в одной
// потенциальной комбинации.
type SimpleBot struct{}
func (SimpleBot) PlayTurn(g *GameState) error {
if dropped, err := maybeBotDrop(g); dropped {
return err
}
if err := simpleDrawDecision(g); err != nil {
return err
}
if g.Phase == PhaseGameOver {
return nil
}
// по правилам Тонк может объявить любой игрок, не только
// специализированный TonkBot — иначе бот способен бесконечно
// держать на руках одну ненужную карту
if CanDeclareTonk(g.current().Hand, g.TonkThreshold) {
_, err := g.DeclareTonk()
return err
}
layAllAvailableMelds(g)
if g.Phase == PhaseGameOver {
return nil
}
tryExtendTableMelds(g)
if g.Phase == PhaseGameOver {
return nil
}
discard := chooseDiscardSimple(g.current().Hand)
return g.Discard(discard)
}
func simpleDrawDecision(g *GameState) error {
if top, ok := g.PeekDiscard(); ok && wouldFormMeld(g.current().Hand, top) {
_, err := g.DrawFromDiscard()
return err
}
_, err := g.DrawFromDeck()
return err
}
// chooseDiscardSimple выбирает карту с нулевым потенциалом
// (не участвующую ни в одной намечающейся комбинации) и
// максимальной очковой стоимостью среди таких. Если все карты
// хоть немного полезны — сбрасывает просто самую дорогую.
func chooseDiscardSimple(hand []Card) Card {
var deadWeight []Card
for _, c := range hand {
if cardPotential(hand, c) == 0 {
deadWeight = append(deadWeight, c)
}
}
pool := deadWeight
if len(pool) == 0 {
pool = hand
}
best := pool[0]
for _, c := range pool[1:] {
if c.Value() > best.Value() {
best = c
}
}
return best
}
// --- Уровень 2: средний бот ---------------------------------------
//
// Оценивает руку целиком через cardPotential: держит карты,
// близкие к комбинации, берёт из сброса не только при немедленном
// закрытии сета/сиквенса, но и при заметном приросте потенциала.
// Сбрасывает "мёртвый груз" — карту с наименьшим потенциалом.
type MediumBot struct{}
func (MediumBot) PlayTurn(g *GameState) error {
if dropped, err := maybeBotDrop(g); dropped {
return err
}
if err := mediumDrawDecision(g); err != nil {
return err
}
if g.Phase == PhaseGameOver {
return nil
}
if CanDeclareTonk(g.current().Hand, g.TonkThreshold) {
_, err := g.DeclareTonk()
return err
}
layAllAvailableMelds(g)
if g.Phase == PhaseGameOver {
return nil
}
tryExtendTableMelds(g)
if g.Phase == PhaseGameOver {
return nil
}
discard := deadestCard(g.current().Hand)
return g.Discard(discard)
}
func mediumDrawDecision(g *GameState) error {
top, ok := g.PeekDiscard()
if ok {
if wouldFormMeld(g.current().Hand, top) {
_, err := g.DrawFromDiscard()
return err
}
// Берём из сброса и без немедленного меlda, если карта
// заметно усиливает руку (потенциал 2+ означает, что она
// почти достраивает сет или сиквенс).
trial := append(append([]Card{}, g.current().Hand...), top)
if cardPotential(trial, top) >= 2 {
_, err := g.DrawFromDiscard()
return err
}
}
_, err := g.DrawFromDeck()
return err
}
// --- Уровень 3: бот со стратегией Тонка ---------------------------
//
// Действует как средний бот, но после каждого взятия карты
// проверяет, можно ли объявить Тонк, и делает это немедленно, не
// дожидаясь выкладывания комбинаций. Добавляет небольшую долю
// случайности в выбор сбрасываемой карты, чтобы не быть полностью
// предсказуемым.
type TonkBot struct {
Threshold int // порог объявления Тонка; 0 — использовать g.TonkThreshold
}
func (b TonkBot) PlayTurn(g *GameState) error {
if dropped, err := maybeBotDrop(g); dropped {
return err
}
if err := mediumDrawDecision(g); err != nil {
return err
}
if g.Phase == PhaseGameOver {
return nil
}
threshold := b.Threshold
if threshold == 0 {
threshold = g.TonkThreshold
}
if CanDeclareTonk(g.current().Hand, threshold) {
_, err := g.DeclareTonk()
return err
}
layAllAvailableMelds(g)
if g.Phase == PhaseGameOver {
return nil
}
tryExtendTableMelds(g)
if g.Phase == PhaseGameOver {
return nil
}
discard := chooseDiscardWithNoise(g.current().Hand)
return g.Discard(discard)
}
// chooseDiscardWithNoise в большинстве случаев сбрасывает самую
// "мёртвую" карту, но с вероятностью ~15% сбрасывает вторую по
// бесполезности — чтобы поведение бота было чуть менее
// предсказуемым для человека, играющего против него не первый раз.
func chooseDiscardWithNoise(hand []Card) Card {
if len(hand) == 1 {
return hand[0]
}
type scored struct {
card Card
potential int
}
ranked := make([]scored, len(hand))
for i, c := range hand {
ranked[i] = scored{card: c, potential: cardPotential(hand, c)}
}
// сортировка пузырьком — рука короткая (обычно до 10-12 карт),
// оптимизация не нужна
for i := 0; i < len(ranked); i++ {
for j := i + 1; j < len(ranked); j++ {
worse := ranked[j].potential < ranked[i].potential ||
(ranked[j].potential == ranked[i].potential && ranked[j].card.Value() > ranked[i].card.Value())
if worse {
ranked[i], ranked[j] = ranked[j], ranked[i]
}
}
}
if len(ranked) >= 2 && rand.Intn(100) < 15 {
return ranked[1].card
}
return ranked[0].card
}