451 lines
16 KiB
Go
451 lines
16 KiB
Go
package main
|
||
|
||
import "errors"
|
||
|
||
// NardyVariant — короткие или длинные нарды. Отличия: начальная
|
||
// расстановка, направление движения второго игрока относительно
|
||
// первого, и (главное) возможность взятия шашек — в длинных нардах
|
||
// её нет вовсе: занятая соперником точка (даже одной шашкой)
|
||
// заблокирована полностью, тогда как в коротких одинокая шашка
|
||
// соперника ("блот") сбивается и уходит на бар.
|
||
type NardyVariant int
|
||
|
||
const (
|
||
NardyShort NardyVariant = iota
|
||
NardyLong
|
||
)
|
||
|
||
// NardyPlayer — цвет шашек игрока.
|
||
type NardyPlayer int
|
||
|
||
const (
|
||
NardyWhite NardyPlayer = iota
|
||
NardyBlack
|
||
)
|
||
|
||
func nardyOpponent(p NardyPlayer) NardyPlayer {
|
||
if p == NardyWhite {
|
||
return NardyBlack
|
||
}
|
||
return NardyWhite
|
||
}
|
||
|
||
// NardyPhase — фаза текущего хода.
|
||
type NardyPhase int
|
||
|
||
const (
|
||
NardyPhaseRoll NardyPhase = iota // нужно бросить кости
|
||
NardyPhaseMove // кости брошены, есть неиспользованные значения
|
||
NardyPhaseOver
|
||
)
|
||
|
||
// NardyPoint — состояние одной точки доски в АБСОЛЮТНЫХ координатах
|
||
// (0-23, общих для обоих игроков — доска физически одна).
|
||
type NardyPoint struct {
|
||
Owner NardyPlayer
|
||
Count int
|
||
}
|
||
|
||
// NardyMove — одно единичное перемещение одной шашки одним
|
||
// значением кости.
|
||
type NardyMove struct {
|
||
FromBar bool // ход шашкой с бара (только короткие нарды)
|
||
From int // относительная (своя) точка-источник; не используется при FromBar
|
||
Die int
|
||
BearOff bool // это снятие шашки с доски насовсем
|
||
}
|
||
|
||
// NardyResult — итог завершённой партии.
|
||
type NardyResult struct {
|
||
Winner NardyPlayer
|
||
}
|
||
|
||
// NardyGameState — полное состояние партии в нарды.
|
||
type NardyGameState struct {
|
||
Variant NardyVariant
|
||
|
||
// Board — 24 абсолютные точки, общие для обоих игроков.
|
||
Board [24]NardyPoint
|
||
|
||
BorneOff [2]int // сколько шашек снято с доски (индекс — NardyPlayer)
|
||
Bar [2]int // сколько шашек на баре (только короткие нарды; в длинных всегда 0)
|
||
|
||
CurrentPlayer NardyPlayer
|
||
Phase NardyPhase
|
||
|
||
Dice [2]int // выпавшие значения последнего броска
|
||
DiceLeft []int // оставшиеся неиспользованные значения в этом ходу (4 при дубле)
|
||
|
||
headMovesThisTurn int // сколько шашек уже взято "с головы" в этом ходу
|
||
|
||
Result *NardyResult
|
||
}
|
||
|
||
var (
|
||
ErrNardyWrongPhase = errors.New("недопустимое действие для текущей фазы")
|
||
ErrNardyIllegalMove = errors.New("такой ход сейчас недопустим")
|
||
)
|
||
|
||
// nardyPointToAbsolute переводит "относительную" точку игрока
|
||
// (0..23, где 23 — голова/дальняя от дома точка, 0..5 — дом) в
|
||
// абсолютную точку доски, в зависимости от варианта игры.
|
||
//
|
||
// Короткие нарды: игроки движутся навстречу друг другу — точки
|
||
// зеркальны (белый: abs=rel; чёрный: abs=23-rel).
|
||
//
|
||
// Длинные нарды: игроки движутся В ОДНОМ направлении — раскладка
|
||
// чёрного просто сдвинута на половину круга (белый: abs=rel;
|
||
// чёрный: abs=(rel+12) mod 24), а не отражена.
|
||
func nardyPointToAbsolute(variant NardyVariant, player NardyPlayer, rel int) int {
|
||
if player == NardyWhite {
|
||
return rel
|
||
}
|
||
if variant == NardyLong {
|
||
return (rel + 12) % 24
|
||
}
|
||
return 23 - rel
|
||
}
|
||
|
||
// nardyStartingSetup — начальная расстановка в ОТНОСИТЕЛЬНЫХ
|
||
// координатах (одинакова для обоих игроков относительно их
|
||
// собственной головы).
|
||
func nardyStartingSetup(variant NardyVariant) map[int]int {
|
||
if variant == NardyLong {
|
||
return map[int]int{23: 15}
|
||
}
|
||
return map[int]int{23: 2, 12: 5, 7: 3, 5: 5}
|
||
}
|
||
|
||
// NewNardyGame создаёт новую партию с заданным вариантом правил;
|
||
// firstPlayer — кто ходит первым (по правилам определяется броском
|
||
// одного зара каждым игроком — эта логика оставлена вызывающему
|
||
// коду, здесь просто принимается готовый результат).
|
||
func NewNardyGame(variant NardyVariant, firstPlayer NardyPlayer) *NardyGameState {
|
||
g := &NardyGameState{
|
||
Variant: variant,
|
||
CurrentPlayer: firstPlayer,
|
||
Phase: NardyPhaseRoll,
|
||
}
|
||
setup := nardyStartingSetup(variant)
|
||
for _, player := range [2]NardyPlayer{NardyWhite, NardyBlack} {
|
||
for rel, count := range setup {
|
||
abs := nardyPointToAbsolute(variant, player, rel)
|
||
g.Board[abs] = NardyPoint{Owner: player, Count: count}
|
||
}
|
||
}
|
||
return g
|
||
}
|
||
|
||
// canLand проверяет, может ли игрок player поставить шашку на свою
|
||
// относительную точку rel.
|
||
func (g *NardyGameState) canLand(player NardyPlayer, rel int) bool {
|
||
abs := nardyPointToAbsolute(g.Variant, player, rel)
|
||
pt := g.Board[abs]
|
||
if pt.Count == 0 {
|
||
return true
|
||
}
|
||
if pt.Owner == player {
|
||
return true
|
||
}
|
||
if g.Variant == NardyLong {
|
||
return false // в длинных нардах взятия нет — даже одна чужая шашка блокирует точку насмерть
|
||
}
|
||
return pt.Count == 1 // короткие нарды: можно сбить одинокий блот
|
||
}
|
||
|
||
// allCheckersHome проверяет, все ли шашки игрока уже в доме
|
||
// (относительные точки 0-5) и не на баре — необходимое условие для
|
||
// начала снятия шашек с доски.
|
||
func (g *NardyGameState) allCheckersHome(player NardyPlayer) bool {
|
||
if g.Bar[player] > 0 {
|
||
return false
|
||
}
|
||
for rel := 6; rel <= 23; rel++ {
|
||
abs := nardyPointToAbsolute(g.Variant, player, rel)
|
||
if g.Board[abs].Owner == player && g.Board[abs].Count > 0 {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// hasCheckerBeyond проверяет, есть ли у игрока шашки на
|
||
// относительных точках дома строго дальше rel (т.е. на точках,
|
||
// которые ещё не готовы сниматься точным значением кости) —
|
||
// используется, чтобы разрешить "снятие с избытком".
|
||
func (g *NardyGameState) hasCheckerBeyond(player NardyPlayer, rel int) bool {
|
||
for r := rel + 1; r <= 5; r++ {
|
||
abs := nardyPointToAbsolute(g.Variant, player, r)
|
||
if g.Board[abs].Owner == player && g.Board[abs].Count > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// candidateMovesForDie перечисляет все ходы, физически возможные для
|
||
// текущего игрока при использовании ОДНОГО значения кости die (без
|
||
// учёта правила "обязаны использовать максимум костей" — эта
|
||
// проверка делается на уровне LegalMovesNow).
|
||
func (g *NardyGameState) candidateMovesForDie(die int) []NardyMove {
|
||
player := g.CurrentPlayer
|
||
var out []NardyMove
|
||
|
||
if g.Bar[player] > 0 {
|
||
entryRel := 24 - die
|
||
if g.canLand(player, entryRel) {
|
||
out = append(out, NardyMove{FromBar: true, Die: die})
|
||
}
|
||
return out
|
||
}
|
||
|
||
allHome := g.allCheckersHome(player)
|
||
for rel := 23; rel >= 0; rel-- {
|
||
abs := nardyPointToAbsolute(g.Variant, player, rel)
|
||
pt := g.Board[abs]
|
||
if pt.Count == 0 || pt.Owner != player {
|
||
continue
|
||
}
|
||
if rel == 23 && g.headMovesThisTurn >= 1 {
|
||
continue // с головы — не больше одной шашки за ход
|
||
}
|
||
|
||
dest := rel - die
|
||
if dest >= 0 {
|
||
if g.canLand(player, dest) {
|
||
out = append(out, NardyMove{From: rel, Die: die})
|
||
}
|
||
continue
|
||
}
|
||
if !allHome {
|
||
continue
|
||
}
|
||
if dest == -1 {
|
||
out = append(out, NardyMove{From: rel, Die: die, BearOff: true})
|
||
} else if !g.hasCheckerBeyond(player, rel) {
|
||
out = append(out, NardyMove{From: rel, Die: die, BearOff: true})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// cloneForSearch делает независимую копию состояния, достаточную
|
||
// для перебора вариантов хода (для реальной мутации самой партии не
|
||
// используется).
|
||
//
|
||
// ВАЖНО: DiceLeft — срез, и поверхностное копирование структуры
|
||
// (c := *g) делило бы его нижележащий массив с оригиналом. Тогда
|
||
// планирование бота (применяющее ходы к "клону" через applyMoveRaw
|
||
// + removeDieOnce) тихо портило бы DiceLeft настоящей партии же до
|
||
// того, как ходы были бы реально применены — тот же класс ошибки,
|
||
// что уже однажды ловился в раздаче карт Тонка.
|
||
func (g *NardyGameState) cloneForSearch() *NardyGameState {
|
||
c := *g
|
||
c.DiceLeft = append([]int{}, g.DiceLeft...)
|
||
return &c
|
||
}
|
||
|
||
// applyMoveRaw применяет одиночный ход БЕЗ проверки легальности
|
||
// (легальность уже должна быть проверена вызывающим кодом) и без
|
||
// смены игрока/фазы — используется и реальным ApplyMove, и
|
||
// внутренним перебором вариантов.
|
||
func (g *NardyGameState) applyMoveRaw(mv NardyMove) {
|
||
player := g.CurrentPlayer
|
||
|
||
if mv.FromBar {
|
||
entryRel := 24 - mv.Die
|
||
abs := nardyPointToAbsolute(g.Variant, player, entryRel)
|
||
g.hitIfBlot(player, abs)
|
||
g.Board[abs].Owner = player
|
||
g.Board[abs].Count++
|
||
g.Bar[player]--
|
||
return
|
||
}
|
||
|
||
fromAbs := nardyPointToAbsolute(g.Variant, player, mv.From)
|
||
g.Board[fromAbs].Count--
|
||
if mv.From == 23 {
|
||
g.headMovesThisTurn++
|
||
}
|
||
|
||
if mv.BearOff {
|
||
g.BorneOff[player]++
|
||
return
|
||
}
|
||
|
||
destRel := mv.From - mv.Die
|
||
destAbs := nardyPointToAbsolute(g.Variant, player, destRel)
|
||
g.hitIfBlot(player, destAbs)
|
||
g.Board[destAbs].Owner = player
|
||
g.Board[destAbs].Count++
|
||
}
|
||
|
||
// hitIfBlot сбивает одинокую шашку соперника на абсолютной точке
|
||
// abs, если она там есть (только короткие нарды — в длинных
|
||
// canLand уже не пустит на клетку с чужой шашкой вовсе, так что
|
||
// здесь этот случай просто никогда не наступит для длинных нард).
|
||
func (g *NardyGameState) hitIfBlot(player NardyPlayer, abs int) {
|
||
pt := g.Board[abs]
|
||
if pt.Count == 1 && pt.Owner != player {
|
||
g.Bar[pt.Owner]++
|
||
g.Board[abs] = NardyPoint{}
|
||
}
|
||
}
|
||
|
||
func (g *NardyGameState) removeDieOnce(die int) {
|
||
for i, d := range g.DiceLeft {
|
||
if d == die {
|
||
g.DiceLeft = append(g.DiceLeft[:i], g.DiceLeft[i+1:]...)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// nardyMaxDiceUsableFrom считает, сколько костей максимум можно
|
||
// использовать целиком (0..len(dice)) при данном состоянии доски и
|
||
// списке оставшихся значений костей — перебором всех
|
||
// последовательностей ходов. Именно эта функция обеспечивает
|
||
// правило "обязаны использовать максимально возможное число костей".
|
||
func (g *NardyGameState) nardyMaxDiceUsableFrom(dice []int) int {
|
||
best := 0
|
||
seenDie := map[int]bool{}
|
||
for i, d := range dice {
|
||
if seenDie[d] {
|
||
continue
|
||
}
|
||
seenDie[d] = true
|
||
for _, mv := range g.candidateMovesForDie(d) {
|
||
clone := g.cloneForSearch()
|
||
clone.applyMoveRaw(mv)
|
||
rest := make([]int, 0, len(dice)-1)
|
||
removed := false
|
||
for j, dv := range dice {
|
||
if j == i && !removed {
|
||
removed = true
|
||
continue
|
||
}
|
||
rest = append(rest, dv)
|
||
}
|
||
sub := 1 + clone.nardyMaxDiceUsableFrom(rest)
|
||
if sub > best {
|
||
best = sub
|
||
}
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
// LegalMovesNow возвращает все ходы, легальные ПРЯМО СЕЙЧАС — с
|
||
// учётом того, что до конца хода нужно использовать максимально
|
||
// возможное число оставшихся костей (см. nardyMaxDiceUsableFrom), и
|
||
// что если можно сыграть только ОДНУ кость из двух РАЗНЫХ значений
|
||
// (не дубль), обязана быть сыграна БОЛЬШАЯ — стандартное правило нард.
|
||
func (g *NardyGameState) LegalMovesNow() []NardyMove {
|
||
if g.Phase != NardyPhaseMove || len(g.DiceLeft) == 0 {
|
||
return nil
|
||
}
|
||
needed := g.nardyMaxDiceUsableFrom(g.DiceLeft)
|
||
if needed == 0 {
|
||
return nil
|
||
}
|
||
|
||
if needed == 1 {
|
||
unique := map[int]bool{}
|
||
for _, d := range g.DiceLeft {
|
||
unique[d] = true
|
||
}
|
||
if len(unique) == 2 {
|
||
bigger := 0
|
||
for d := range unique {
|
||
if d > bigger {
|
||
bigger = d
|
||
}
|
||
}
|
||
if biggerMoves := g.candidateMovesForDie(bigger); len(biggerMoves) > 0 {
|
||
return biggerMoves
|
||
}
|
||
}
|
||
}
|
||
|
||
var out []NardyMove
|
||
seenDie := map[int]bool{}
|
||
for _, d := range g.DiceLeft {
|
||
if seenDie[d] {
|
||
continue
|
||
}
|
||
seenDie[d] = true
|
||
for _, mv := range g.candidateMovesForDie(d) {
|
||
clone := g.cloneForSearch()
|
||
clone.applyMoveRaw(mv)
|
||
rest := make([]int, 0, len(g.DiceLeft)-1)
|
||
removedOne := false
|
||
for _, dv := range g.DiceLeft {
|
||
if dv == d && !removedOne {
|
||
removedOne = true
|
||
continue
|
||
}
|
||
rest = append(rest, dv)
|
||
}
|
||
if 1+clone.nardyMaxDiceUsableFrom(rest) >= needed {
|
||
out = append(out, mv)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Roll бросает кости и переходит в фазу хода. Если после броска не
|
||
// находится ни одного легального хода — очки сгорают, ход сразу же
|
||
// переходит следующему игроку.
|
||
func (g *NardyGameState) Roll(d1, d2 int) {
|
||
g.Dice = [2]int{d1, d2}
|
||
if d1 == d2 {
|
||
g.DiceLeft = []int{d1, d1, d1, d1}
|
||
} else {
|
||
g.DiceLeft = []int{d1, d2}
|
||
}
|
||
g.headMovesThisTurn = 0
|
||
g.Phase = NardyPhaseMove
|
||
if len(g.LegalMovesNow()) == 0 {
|
||
g.endTurn()
|
||
}
|
||
}
|
||
|
||
// ApplyMove выполняет один из ходов, предложенных LegalMovesNow.
|
||
func (g *NardyGameState) ApplyMove(mv NardyMove) error {
|
||
if g.Phase != NardyPhaseMove {
|
||
return ErrNardyWrongPhase
|
||
}
|
||
legal := g.LegalMovesNow()
|
||
found := false
|
||
for _, l := range legal {
|
||
if l == mv {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
return ErrNardyIllegalMove
|
||
}
|
||
|
||
g.applyMoveRaw(mv)
|
||
g.removeDieOnce(mv.Die)
|
||
|
||
if g.BorneOff[g.CurrentPlayer] == 15 {
|
||
g.Result = &NardyResult{Winner: g.CurrentPlayer}
|
||
g.Phase = NardyPhaseOver
|
||
return nil
|
||
}
|
||
|
||
if len(g.LegalMovesNow()) == 0 {
|
||
g.endTurn()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (g *NardyGameState) endTurn() {
|
||
g.DiceLeft = nil
|
||
g.Dice = [2]int{}
|
||
g.CurrentPlayer = nardyOpponent(g.CurrentPlayer)
|
||
g.Phase = NardyPhaseRoll
|
||
}
|