go_games_collection/tictactoe_bot.go

177 lines
6.5 KiB
Go
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.

package main
import (
"math/rand"
"sync"
)
// TicTacToeDifficulty — уровень сложности бота.
type TicTacToeDifficulty int
const (
// TicTacToeDifficultyEasy — совершенно случайный ход. Легко
// обыграть.
TicTacToeDifficultyEasy TicTacToeDifficulty = iota
// TicTacToeDifficultyMedium — простая эвристика: выиграть, если
// можно прямо сейчас; иначе заблокировать выигрыш соперника;
// иначе предпочесть центр, потом углы, потом стороны. Без
// просчёта на несколько ходов вперёд — обыграть можно, но не
// тривиально.
TicTacToeDifficultyMedium
// TicTacToeDifficultyHard — полный перебор (минимакс) на всю
// оставшуюся партию. Крестики-нолики — полностью решённая игра:
// при точной игре с обеих сторон всегда ничья, так что на этом
// уровне бот в принципе не проигрывает — выиграть у него можно
// только его собственной ошибкой, которых он не делает.
TicTacToeDifficultyHard
)
// TicTacToeBot — бот-соперник для крестиков-ноликов.
type TicTacToeBot struct {
Difficulty TicTacToeDifficulty
}
// ticTacToeWouldWin проверяет: если знак mark поставить в клетку
// pos, появится ли выигрышная линия.
func ticTacToeWouldWin(board [9]TicTacToeMark, mark TicTacToeMark, pos int) bool {
trial := board
trial[pos] = mark
winner, _, ok := ticTacToeCheckWin(trial)
return ok && winner == mark
}
// ticTacToeCellPriority — порядок предпочтения клеток при отсутствии
// более важных соображений: центр сильнее всего (входит в 4
// линии), затем углы (по 3 линии каждый), затем стороны (по 2).
var ticTacToeCellPriority = []int{4, 0, 2, 6, 8, 1, 3, 5, 7}
// ticTacToeHeuristicMove — эвристика среднего уровня сложности: не
// упускает свою победу и не позволяет сопернику выиграть следующим
// ходом, но не считает партию на несколько ходов вперёд.
func ticTacToeHeuristicMove(board [9]TicTacToeMark, mark TicTacToeMark, empty []int, rnd *rand.Rand) int {
for _, pos := range empty {
if ticTacToeWouldWin(board, mark, pos) {
return pos
}
}
opponent := ticTacToeOpponent(mark)
for _, pos := range empty {
if ticTacToeWouldWin(board, opponent, pos) {
return pos
}
}
for _, pos := range ticTacToeCellPriority {
if board[pos] == TicTacToeEmpty {
return pos
}
}
return empty[rnd.Intn(len(empty))]
}
// ticTacToeMemo кэширует уже посчитанные оценки позиций — в
// крестиках-ноликах конечное и небольшое число различных позиций
// (не больше 3^9 на каждого из двух "toMove"), так что кэш на уровне
// пакета быстро прогревается и резко ускоряет повторные вычисления
// (одни и те же позиции встречаются заново при каждой новой партии).
var (
ticTacToeMemoMu sync.Mutex
ticTacToeMemo = map[ticTacToeMemoKey]int{}
)
type ticTacToeMemoKey struct {
board [9]TicTacToeMark
toMove TicTacToeMark
}
// ticTacToeNegamax оценивает позицию board с точки зрения игрока
// toMove, при условии что сейчас его ход и никто ещё не выиграл до
// этого хода: +1 — toMove выигрывает при точной игре обеих сторон,
// -1 — проигрывает, 0 — ничья.
func ticTacToeNegamax(board [9]TicTacToeMark, toMove TicTacToeMark) int {
key := ticTacToeMemoKey{board: board, toMove: toMove}
ticTacToeMemoMu.Lock()
v, ok := ticTacToeMemo[key]
ticTacToeMemoMu.Unlock()
if ok {
return v
}
result := ticTacToeNegamaxUncached(board, toMove)
ticTacToeMemoMu.Lock()
ticTacToeMemo[key] = result
ticTacToeMemoMu.Unlock()
return result
}
func ticTacToeNegamaxUncached(board [9]TicTacToeMark, toMove TicTacToeMark) int {
if winner, _, ok := ticTacToeCheckWin(board); ok {
if winner == toMove {
return 1
}
return -1
}
empty := ticTacToeEmptyCells(board)
if len(empty) == 0 {
return 0
}
best := -2
for _, pos := range empty {
trial := board
trial[pos] = toMove
score := -ticTacToeNegamax(trial, ticTacToeOpponent(toMove))
if score > best {
best = score
}
}
return best
}
// ticTacToeMinimaxMove находит один из объективно наилучших ходов
// для mark (полным перебором всей оставшейся партии).
func ticTacToeMinimaxMove(board [9]TicTacToeMark, mark TicTacToeMark) int {
bestScore := -2
bestPos := -1
for _, pos := range ticTacToeEmptyCells(board) {
trial := board
trial[pos] = mark
score := -ticTacToeNegamax(trial, ticTacToeOpponent(mark))
if score > bestScore {
bestScore = score
bestPos = pos
}
}
return bestPos
}
// DecideMove выбирает ход бота согласно его уровню сложности.
// Возвращает -1, если свободных клеток нет (партия уже должна была
// завершиться раньше).
func (b TicTacToeBot) DecideMove(g *TicTacToeGameState, rnd *rand.Rand) int {
empty := ticTacToeEmptyCells(g.Board)
if len(empty) == 0 {
return -1
}
switch b.Difficulty {
case TicTacToeDifficultyEasy:
return empty[rnd.Intn(len(empty))]
case TicTacToeDifficultyMedium:
return ticTacToeHeuristicMove(g.Board, g.CurrentTurn, empty, rnd)
default:
return ticTacToeMinimaxMove(g.Board, g.CurrentTurn)
}
}
// PlayFullTurn исполняет один ход бота, если сейчас действительно
// его очередь.
func (b TicTacToeBot) PlayFullTurn(g *TicTacToeGameState, rnd *rand.Rand) error {
if g.Phase != TicTacToePhasePlay || g.CurrentTurn == g.HumanMark {
return nil
}
pos := b.DecideMove(g, rnd)
if pos < 0 {
return nil
}
return g.PlaceMark(pos)
}