149 lines
5.9 KiB
Go
149 lines
5.9 KiB
Go
package main
|
||
|
||
import "math/rand"
|
||
|
||
// UrDifficulty — уровень сложности бота.
|
||
type UrDifficulty int
|
||
|
||
const (
|
||
// UrDifficultyEasy — случайный выбор среди легальных ходов.
|
||
UrDifficultyEasy UrDifficulty = iota
|
||
// UrDifficultyMedium — жадная эвристика: оценивает результат
|
||
// каждого легального хода и берёт лучший, не заглядывая вперёд.
|
||
UrDifficultyMedium
|
||
// UrDifficultyHard — вдобавок к жадной оценке своей позиции,
|
||
// штрафует ходы, после которых соперник получает большой шанс
|
||
// сразу же взять шашку в следующем броске — простой одношаговый
|
||
// взгляд вперёд, а не полный перебор (в Уре, в отличие от нард,
|
||
// за ход двигается только одна шашка, так что перебирать нечего —
|
||
// но реакцию соперника всё же можно оценить).
|
||
UrDifficultyHard
|
||
)
|
||
|
||
// UrBot — бот-соперник для Игры Ур.
|
||
type UrBot struct {
|
||
Difficulty UrDifficulty
|
||
}
|
||
|
||
// urRollDistribution — вероятностное распределение суммы четырёх
|
||
// бинарных костей (0..4), как в оригинальных тетраэдрических
|
||
// костях: P(0)=1/16, P(1)=4/16, P(2)=6/16, P(3)=4/16, P(4)=1/16.
|
||
var urRollDistribution = []int{1, 4, 6, 4, 1} // веса на 16
|
||
|
||
// urRollDice бросает четыре монеты и возвращает сумму выпавших "1" —
|
||
// именно так исторически имитировались четыре тетраэдрические кости
|
||
// с двумя отмеченными гранями из четырёх.
|
||
func urRollDice(rnd *rand.Rand) int {
|
||
sum := 0
|
||
for i := 0; i < 4; i++ {
|
||
if rnd.Intn(2) == 1 {
|
||
sum++
|
||
}
|
||
}
|
||
return sum
|
||
}
|
||
|
||
// urEvaluate оценивает позицию с точки зрения player: чем выше
|
||
// значение, тем позиция выгоднее для него.
|
||
func urEvaluate(g *UrGameState, player UrPlayer) float64 {
|
||
opp := urOpponent(player)
|
||
score := 0.0
|
||
for _, pos := range g.Path[player] {
|
||
score += float64(pos) // чем дальше по пути (и особенно снята с доски = 15) — тем лучше
|
||
if urRosettePathIndices[pos] && pos != UrPathLength+1 {
|
||
score += 3 // стоять на розетке — безопасно и стратегически ценно
|
||
}
|
||
}
|
||
for _, pos := range g.Path[opp] {
|
||
score -= float64(pos) * 0.9 // прогресс соперника — плохо для нас (чуть менее весом, чтобы не быть зеркалом)
|
||
}
|
||
return score
|
||
}
|
||
|
||
// DecideMove выбирает ход бота (индекс шашки) согласно уровню
|
||
// сложности. Предполагается, что кости уже брошены и есть хотя бы
|
||
// один легальный ход.
|
||
func (b UrBot) DecideMove(g *UrGameState, rnd *rand.Rand) int {
|
||
moves := g.LegalMoves()
|
||
if len(moves) == 0 {
|
||
return -1
|
||
}
|
||
switch b.Difficulty {
|
||
case UrDifficultyEasy:
|
||
return moves[rnd.Intn(len(moves))].PieceIdx
|
||
default:
|
||
return b.decideHeuristic(g, moves, rnd)
|
||
}
|
||
}
|
||
|
||
func (b UrBot) decideHeuristic(g *UrGameState, moves []UrMove, rnd *rand.Rand) int {
|
||
player := g.CurrentPlayer
|
||
bestScore := 0.0
|
||
var best []int
|
||
for i, mv := range moves {
|
||
clone := *g
|
||
clone.Move(mv.PieceIdx)
|
||
score := urEvaluate(&clone, player)
|
||
if b.Difficulty == UrDifficultyHard {
|
||
score -= b.opponentThreatPenalty(&clone, player)
|
||
}
|
||
if i == 0 || score > bestScore {
|
||
bestScore = score
|
||
best = []int{mv.PieceIdx}
|
||
} else if score == bestScore {
|
||
best = append(best, mv.PieceIdx)
|
||
}
|
||
}
|
||
return best[rnd.Intn(len(best))]
|
||
}
|
||
|
||
// opponentThreatPenalty оценивает (усреднённо по всем возможным
|
||
// броскам соперника) риск того, что после нашего хода соперник
|
||
// сможет сразу же взять одну из наших шашек на общем ряду —
|
||
// упрощённый взгляд на один ход вперёд, без полного перебора.
|
||
func (b UrBot) opponentThreatPenalty(g *UrGameState, player UrPlayer) float64 {
|
||
opp := urOpponent(player)
|
||
penalty := 0.0
|
||
for roll := 0; roll <= 4; roll++ {
|
||
weight := float64(urRollDistribution[roll]) / 16
|
||
clone := *g
|
||
clone.CurrentPlayer = opp
|
||
clone.Rolled = true
|
||
clone.DiceValue = roll
|
||
for _, mv := range clone.LegalMoves() {
|
||
pos := clone.Path[opp][mv.PieceIdx]
|
||
dest := pos + roll
|
||
if dest >= 5 && dest <= 12 {
|
||
if occ, _, occupied := clone.occupantOnShared(urSharedIndex(dest)); occupied && occ == player {
|
||
penalty += weight * 5 // соперник может взять нашу шашку этим броском
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return penalty
|
||
}
|
||
|
||
// PlayFullTurn бросает кости и доигрывает весь ход бота — включая
|
||
// повторные броски, которые даёт розетка. Цикл продолжается, пока
|
||
// партия не окончена и очередь остаётся за тем же игроком, который
|
||
// был текущим на момент вызова (переход хода к сопернику или победа
|
||
// естественным образом завершают цикл).
|
||
func (b UrBot) PlayFullTurn(g *UrGameState, rnd *rand.Rand) error {
|
||
me := g.CurrentPlayer
|
||
for g.Result == nil && g.CurrentPlayer == me {
|
||
if !g.Rolled {
|
||
if err := g.Roll(urRollDice(rnd)); err != nil {
|
||
return err
|
||
}
|
||
continue
|
||
}
|
||
pieceIdx := b.DecideMove(g, rnd)
|
||
if pieceIdx < 0 {
|
||
return nil
|
||
}
|
||
if err := g.Move(pieceIdx); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|