go_games_collection/nardy_game.go

451 lines
16 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 "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
}