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