go_games_collection/go_bot.go
2026-07-07 09:12:09 +03:00

192 lines
7 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"
// ПРИМЕЧАНИЕ О СИЛЕ ИГРЫ: сильный бот для Го в реальности требует
// Monte-Carlo поиска по дереву или нейросетевой оценки позиции
// (как в AlphaGo) — минимакс тут почти бесполезен из-за огромного
// ветвления (до нескольких сотен ходов на 19x19). Все три уровня
// ниже — эвристические, БЕЗ поиска вперёд по дереву партии, и
// играют существенно слабее сильного человека даже на "Сильном"
// уровне. Это осознанное ограничение, а не недоработка — честная
// сильная игра в Го выходит далеко за рамки того, что можно
// реализовать без полноценного MCTS/нейросети.
// GoDifficulty — уровень сложности бота.
type GoDifficulty int
const (
GoDifficultyEasy GoDifficulty = iota
GoDifficultyMedium
GoDifficultyHard
)
// GoBot — бот на основе эвристик без поиска вперёд.
type GoBot struct {
Difficulty GoDifficulty
}
// goPassScoreThreshold — если у бота нет ни одного хода лучше этого
// эвристического порога, он пасует, а не портит позицию заведомо
// плохим ходом (актуально в основном под конец партии).
const goPassScoreThreshold = -5.0
// goEasyPassChance — простой бот не считает выгоду ходов, но всё же
// изредка пасует сам, иначе полностью случайная игра почти никогда
// не заканчивается естественным образом (лишние ходы почти всегда
// находятся, если не пасовать вообще никогда).
const goEasyPassChance = 0.04
// DecideMove выбирает ход бота. Возвращает (pos, true) для хода в
// точку pos, либо (_, false), если бот решил пасовать.
func (b GoBot) DecideMove(g *GoGameState, rnd *rand.Rand) (GoPos, bool) {
legal := goLegalMoves(g)
if len(legal) == 0 {
return GoPos{}, false
}
switch b.Difficulty {
case GoDifficultyEasy:
if rnd.Float64() < goEasyPassChance {
return GoPos{}, false
}
return legal[rnd.Intn(len(legal))], true
default:
best := legal[0]
bestScore := goEvaluateMove(g, best, b.Difficulty)
for _, pos := range legal[1:] {
score := goEvaluateMove(g, pos, b.Difficulty)
if score > bestScore {
bestScore = score
best = pos
}
}
// при плотно заполненной доске бот больше не тратит ходы на
// бессмысленное заполнение нейтральных точек ("дамэ"), если
// лучший доступный ход не связан с реальным взятием (взятие
// всегда даёт +10 к оценке за камень, так что порог 8
// надёжно отличает "взял что-то" от "просто занял свободную
// точку") — иначе партии ботов друг с другом никогда не
// заканчивались бы сами по себе
if goBoardOccupancy(g) > 0.5 && bestScore < 8 {
return GoPos{}, false
}
if bestScore < goPassScoreThreshold {
return GoPos{}, false
}
return best, true
}
}
// goBoardOccupancy возвращает долю занятых точек доски (0..1).
func goBoardOccupancy(g *GoGameState) float64 {
occupied := 0
for _, row := range g.Board {
for _, c := range row {
if c != GoEmpty {
occupied++
}
}
}
return float64(occupied) / float64(g.Size*g.Size)
}
// PlayFullTurn исполняет один ход бота (игру в точку либо пас).
// Возвращает passed=true, если бот решил пасовать — вызывающий код
// (TUI) должен показать это явно, а не молчать, будто ничего не
// произошло.
func (b GoBot) PlayFullTurn(g *GoGameState, rnd *rand.Rand) (passed bool, err error) {
pos, ok := b.DecideMove(g, rnd)
if !ok {
return true, g.Pass()
}
return false, g.Play(pos)
}
// goLegalMoves перечисляет все легальные точки для текущего игрока.
func goLegalMoves(g *GoGameState) []GoPos {
var out []GoPos
for r := 0; r < g.Size; r++ {
for c := 0; c < g.Size; c++ {
pos := GoPos{Row: r, Col: c}
if g.IsLegal(pos) {
out = append(out, pos)
}
}
}
return out
}
// goEvaluateMove — эвристическая оценка одного кандидатского хода
// (без поиска вперёд): чем выше, тем привлекательнее ход для
// текущего игрока.
func goEvaluateMove(g *GoGameState, pos GoPos, level GoDifficulty) float64 {
color := g.Turn
sim := &GoGameState{
Size: g.Size,
Board: cloneGoBoard(g.Board),
Turn: g.Turn,
prevBoardBeforeOpponentMove: g.prevBoardBeforeOpponentMove,
}
capturedBefore := boardStoneCount(sim.Board, color.Opponent())
if err := sim.Play(pos); err != nil {
return -1000
}
capturedAfter := boardStoneCount(sim.Board, color.Opponent())
captures := capturedBefore - capturedAfter
score := float64(captures) * 10
_, liberties := sim.groupAndLiberties(pos)
score += float64(liberties)
if level == GoDifficultyHard {
score += goDistanceToCenterBonus(pos, g.Size)
score += goFriendlyNeighborBonus(g, pos)
}
return score
}
func boardStoneCount(board [][]GoColor, color GoColor) int {
count := 0
for _, row := range board {
for _, c := range row {
if c == color {
count++
}
}
}
return count
}
// goDistanceToCenterBonus — небольшой позиционный бонус за игру
// ближе к центру доски в дебюте (грубая, но стандартная для
// простых Го-ботов эвристика).
func goDistanceToCenterBonus(pos GoPos, size int) float64 {
center := float64(size-1) / 2
dr := float64(pos.Row) - center
dc := float64(pos.Col) - center
dist := dr*dr + dc*dc
maxDist := center * center * 2
if maxDist == 0 {
return 0
}
return 3 * (1 - dist/maxDist)
}
// goFriendlyNeighborBonus поощряет игру рядом со своими же камнями
// (грубая замена полноценной оценке влияния/территории).
func goFriendlyNeighborBonus(g *GoGameState, pos GoPos) float64 {
color := g.Turn
bonus := 0.0
for _, n := range goNeighbors(pos) {
if !g.inBounds(n) {
continue
}
if g.Board[n.Row][n.Col] == color {
bonus += 1.5
}
}
return bonus
}