318 lines
12 KiB
Go
318 lines
12 KiB
Go
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
|
||
}
|