go_games_collection/oneohone_game.go

533 lines
20 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"
// ПРИМЕЧАНИЕ О ПРАВИЛАХ: у игры "101" существует много вариантов,
// расходящихся в деталях. Реализован следующий, наиболее часто
// встречающийся набор правил:
// - Колода 36 карт (6-туз), как и в Дураке.
// - Раздача: сдающий раздаёт по 5 карт каждому игроку, кроме
// себя — себе он оставляет 4, а пятую (лишнюю) кладёт в центр
// стола лицом вверх — это и есть стартовая карта сброса.
// - Ход: нужно положить карту той же масти или того же ранга,
// что и верхняя карта сброса. Если подходящей карты нет —
// игрок обязан брать карты из колоды добора по одной, пока не
// возьмёт подходящую (тогда сразу её играет) либо пока колода
// не закончится (тогда ход просто переходит дальше).
// - Специальные карты: туз пропускает ход следующего игрока;
// шестёрка заставляет следующего взять 1 карту и пропустить
// ход; семёрка — взять 2 карты и пропустить ход; пиковый
// король — взять 4 карты и пропустить ход. Остальные короли и
// карты обычные.
// - Игрок, избавившийся от всех карт, выигрывает раунд. Если его
// последней сыгранной картой была дама — он получает бонус
// -20 очков за раунд (-40, если это была червовая дама) вместо
// обычных 0; остальные складывают очковую стоимость карт,
// оставшихся на руках, и добавляют к своему общему счёту.
// - Набравший 101 очко и более выбывает из партии, если только
// это не ровно 101 — тогда счёт обнуляется (традиционное
// правило "отыграться"). Партия продолжается, пока не останется
// один игрок — он и побеждает.
// OneOhOnePlayer — состояние одного игрока в партии 101.
type OneOhOnePlayer struct {
Name string
Hand []Card
IsHuman bool
Score int // суммарный счёт по итогам сыгранных раундов
Out bool
}
// OneOhOnePhase — фаза текущего раунда.
type OneOhOnePhase int
const (
// OneOhOnePhasePlay — обычный ход текущего игрока: сыграть карту
// или взять из колоды добора.
OneOhOnePhasePlay OneOhOnePhase = iota
// OneOhOnePhaseChooseSuit — только что положили даму: тот же
// игрок обязан назвать масть для следующего хода, прежде чем ход
// перейдёт дальше (см. ChooseSuit).
OneOhOnePhaseChooseSuit
// OneOhOnePhaseRoundOver — раунд завершён.
OneOhOnePhaseRoundOver
)
var (
ErrOOOWrongPhase = errors.New("недопустимое действие для текущей фазы")
ErrOOOCardNotInHand = errors.New("карты нет на руке игрока")
ErrOOOInvalidPlay = errors.New("эта карта не подходит по масти или рангу")
ErrOOOMustPlay = errors.New("нужно сыграть карту, которая подходит — брать из колоды нельзя")
ErrOOOInvalidSuit = errors.New("недопустимая масть")
)
// OneOhOneRoundResult — итог одного завершившегося раунда.
type OneOhOneRoundResult struct {
Winner int
QueenBonus bool // победитель закончил дамой (бонус учтён в ScoreDeltas)
ScoreDeltas []int // изменение ОЧКОВ ЗА ЭТОТ РАУНД (не общего счёта)
Eliminated []int // индексы игроков, выбывших в этом раунде (счёт >101)
Reset []int // индексы игроков, у которых счёт обнулился (ровно 101)
MatchOver bool
MatchWinner int // имеет смысл только при MatchOver
NoWinner bool // вырожденный случай: все выбыли одновременно
Stalemate bool // раунд принудительно завершён защитой от зацикливания
}
// OneOhOneGameState — полное состояние партии в 101.
type OneOhOneGameState struct {
Players []*OneOhOnePlayer
Stock []Card // колода добора; Stock[0] — верхняя (берётся первой)
Discard []Card // сброс; последний элемент — верхняя (видимая) карта
CurrentPlayerIdx int
DealerIdx int
Phase OneOhOnePhase
Result *OneOhOneRoundResult
// DeclaredSuit — если не nil, названная масть (после дамы),
// действующая вместо масти верхней карты сброса ровно для
// следующего хода (см. effectiveSuit, ChooseSuit).
DeclaredSuit *Suit
// PendingEightSuit — если не nil, на столе лежит непокрытая
// восьмёрка этой масти: следующий ход обязан быть либо той же
// масти, либо другой восьмёркой (которая продолжит цепочку —
// см. canPlay, applyEffectAndAdvance).
PendingEightSuit *Suit
turnsSinceStart int
}
// maxOneOhOneTurns — защита от зацикливания: если раунд длится
// неправдоподобно долго, принудительно завершаем его вничью (по
// текущему весу рук), чтобы партия гарантированно не зависла.
// Увеличено с 5000 после добавления обязательного покрытия
// восьмёрки — цепочки вынужденного добора честно могут занимать
// заметно больше ходов, чем раньше (замер показал до ~10000 на
// редкий раунд).
const maxOneOhOneTurns = 20000
// oneOhOneCardValue — очковая стоимость карты для подсчёта штрафа
// за карты, оставшиеся на руках.
func oneOhOneCardValue(r Rank) int {
switch r {
case Ace:
return 11
case Ten:
return 10
case Nine:
return 0
case Eight:
return 8
case Seven:
return 7
case Six:
return 6
case King:
return 4
case Queen:
return 3
case Jack:
return 2
}
return 0
}
// NewOneOhOneGame создаёт новую партию: игрок 0 — первый сдающий.
func NewOneOhOneGame(names []string, humanIdx int) *OneOhOneGameState {
players := make([]*OneOhOnePlayer, len(names))
for i, name := range names {
players[i] = &OneOhOnePlayer{Name: name, IsHuman: i == humanIdx}
}
g := &OneOhOneGameState{Players: players, DealerIdx: 0}
g.dealNewRound()
return g
}
// dealNewRound тасует колоду и раздаёт новый раунд: по 5 карт всем
// АКТИВНЫМ (не выбывшим) игрокам, кроме сдающего (ему 4, а "лишняя"
// пятая уходит в сброс как стартовая карта). Первым ходит игрок
// слева от сдающего.
func (g *OneOhOneGameState) dealNewRound() {
deck := NewDurakDeck()
deck.Shuffle()
stock := deck.cards
for _, p := range g.Players {
p.Hand = nil
}
active := make([]int, 0, len(g.Players))
for i, p := range g.Players {
if !p.Out {
active = append(active, i)
}
}
for _, idx := range active {
count := 5
if idx == g.DealerIdx {
count = 4
}
for c := 0; c < count; c++ {
card := stock[0]
stock = stock[1:]
g.Players[idx].Hand = append(g.Players[idx].Hand, card)
}
}
// "лишняя" пятая карта сдающего уходит в сброс как стартовая
openingCard := stock[0]
stock = stock[1:]
g.Stock = stock
g.Discard = []Card{openingCard}
g.CurrentPlayerIdx = g.nextActive(g.DealerIdx)
g.Phase = OneOhOnePhasePlay
g.Result = nil
g.turnsSinceStart = 0
g.DeclaredSuit = nil
if openingCard.Rank == Eight {
suit := openingCard.Suit
g.PendingEightSuit = &suit
} else {
g.PendingEightSuit = nil
}
}
func (g *OneOhOneGameState) nextActive(from int) int {
n := len(g.Players)
for i := 1; i <= n; i++ {
idx := (from + i) % n
if !g.Players[idx].Out {
return idx
}
}
return from
}
func (g *OneOhOneGameState) current() *OneOhOnePlayer { return g.Players[g.CurrentPlayerIdx] }
func (g *OneOhOneGameState) topDiscard() Card {
return g.Discard[len(g.Discard)-1]
}
// effectiveSuit возвращает "текущую" масть для проверки хода:
// обычно масть верхней карты сброса, но если недавно была положена
// дама и названа масть — используется названная масть вместо масти
// самой дамы (действует ровно один следующий ход).
func (g *OneOhOneGameState) effectiveSuit() Suit {
if g.DeclaredSuit != nil {
return *g.DeclaredSuit
}
return g.topDiscard().Suit
}
// canPlay проверяет, подходит ли карта по масти или рангу под
// верхнюю карту сброса.
//
// Три особых случая (в порядке приоритета):
// 1. Если на столе висит непокрытая восьмёрка (PendingEightSuit !=
// nil) — годится ТОЛЬКО карта той же масти или другая восьмёрка;
// ничего иного не подходит, даже дама.
// 2. Иначе дама кладётся на что угодно, независимо от масти и ранга.
// 3. Иначе — обычное правило: масть (с учётом объявленной после
// прошлой дамы, если есть) или ранг верхней карты.
func (g *OneOhOneGameState) canPlay(card Card) bool {
if g.PendingEightSuit != nil {
return card.Suit == *g.PendingEightSuit || card.Rank == Eight
}
if card.Rank == Queen {
return true
}
top := g.topDiscard()
return card.Suit == g.effectiveSuit() || card.Rank == top.Rank
}
// hasPlayableCard проверяет, есть ли у игрока хоть одна карта,
// подходящая под текущий сброс.
func (g *OneOhOneGameState) hasPlayableCard(p *OneOhOnePlayer) bool {
for _, c := range p.Hand {
if g.canPlay(c) {
return true
}
}
return false
}
// drawOne снимает верхнюю карту колоды добора, при необходимости
// сначала перетасовывая сброс (кроме верхней карты) в новую колоду.
func (g *OneOhOneGameState) drawOne() Card {
if len(g.Stock) == 0 {
g.reshuffleStockFromDiscard()
}
if len(g.Stock) == 0 {
return Card{}
}
c := g.Stock[0]
g.Stock = g.Stock[1:]
return c
}
// stockExhausted проверяет, остались ли вообще карты для добора (в
// колоде или в сбросе под верхней картой).
func (g *OneOhOneGameState) stockExhausted() bool {
return len(g.Stock) == 0 && len(g.Discard) <= 1
}
func (g *OneOhOneGameState) reshuffleStockFromDiscard() {
if len(g.Discard) <= 1 {
return
}
top := g.Discard[len(g.Discard)-1]
rest := g.Discard[:len(g.Discard)-1]
newStock := &Deck{cards: append([]Card{}, rest...)}
newStock.Shuffle()
g.Stock = newStock.cards
g.Discard = []Card{top}
}
// PlayCard — текущий игрок играет карту из руки.
func (g *OneOhOneGameState) PlayCard(card Card) error {
if g.Phase != OneOhOnePhasePlay {
return ErrOOOWrongPhase
}
p := g.current()
if !hasCard(p.Hand, card) {
return ErrOOOCardNotInHand
}
if !g.canPlay(card) {
return ErrOOOInvalidPlay
}
p.Hand = removeCard(p.Hand, card)
g.Discard = append(g.Discard, card)
if len(p.Hand) == 0 {
g.resolveRoundEnd(g.CurrentPlayerIdx, card)
return nil
}
g.applyEffectAndAdvance(card)
return nil
}
// Draw — текущий игрок берёт карту из колоды добора (разрешено
// только если у него нет ни одной подходящей карты на руке). Если
// взятая карта подходит — она сразу же автоматически играется;
// иначе колода добора снова спрашивается на следующем вызове Draw
// тем же игроком (в TUI/боте это просто повторные нажатия/вызовы).
func (g *OneOhOneGameState) Draw() error {
if g.Phase != OneOhOnePhasePlay {
return ErrOOOWrongPhase
}
p := g.current()
if g.hasPlayableCard(p) {
return ErrOOOMustPlay
}
if g.stockExhausted() {
// брать неоткуда — ход переходит дальше без штрафов
g.advanceTurnPlain()
return nil
}
card := g.drawOne()
p.Hand = append(p.Hand, card)
if g.canPlay(card) {
p.Hand = removeCard(p.Hand, card)
g.Discard = append(g.Discard, card)
if len(p.Hand) == 0 {
g.resolveRoundEnd(g.CurrentPlayerIdx, card)
return nil
}
g.applyEffectAndAdvance(card)
}
return nil
}
// applyEffectAndAdvance обрабатывает эффект специальной карты (если
// есть) и передаёт ход дальше.
//
// Восьмёрка выставляет/продолжает висящее требование "покрыть эту
// масть или другой восьмёркой" (см. PendingEightSuit) — само по себе
// не пропускает ходов и не даёт штрафов. Любая другая карта снимает
// это требование.
//
// Дама не передаёт ход сразу: игрок, который её положил, должен
// сначала явно назвать масть через ChooseSuit — только тогда ход
// пойдёт дальше.
func (g *OneOhOneGameState) applyEffectAndAdvance(played Card) {
if played.Rank == Eight {
suit := played.Suit
g.PendingEightSuit = &suit
} else {
g.PendingEightSuit = nil
}
if played.Rank == Queen {
g.Phase = OneOhOnePhaseChooseSuit
return
}
g.DeclaredSuit = nil // объявленная масть действовала только на этот один ход
g.advanceAfterEffect(played)
}
// ChooseSuit — игрок, только что положивший даму, называет масть,
// которая будет считаться "текущей" для следующего хода (вместо
// масти самой дамы). После этого ход передаётся дальше как обычно.
func (g *OneOhOneGameState) ChooseSuit(suit Suit) error {
if g.Phase != OneOhOnePhaseChooseSuit {
return ErrOOOWrongPhase
}
if suit < Clubs || suit > Spades {
return ErrOOOInvalidSuit
}
g.DeclaredSuit = &suit
g.Phase = OneOhOnePhasePlay
g.advanceAfterEffect(g.topDiscard()) // сама дама не даёт скип/штраф — просто передаём ход
return nil
}
// advanceAfterEffect считает эффект "скип + штраф" сыгранной карты и
// передаёт ход дальше — вынесено отдельно от applyEffectAndAdvance,
// чтобы вызываться и сразу для обычных карт, и из ChooseSuit после
// того, как масть для дамы уже названа.
func (g *OneOhOneGameState) advanceAfterEffect(played Card) {
g.turnsSinceStart++
if g.turnsSinceStart >= maxOneOhOneTurns {
g.resolveStalemate()
return
}
skip, penalty := oneOhOneCardEffect(played)
if !skip {
g.CurrentPlayerIdx = g.nextActive(g.CurrentPlayerIdx)
return
}
skippedIdx := g.nextActive(g.CurrentPlayerIdx)
skipped := g.Players[skippedIdx]
for i := 0; i < penalty; i++ {
if g.stockExhausted() {
break
}
skipped.Hand = append(skipped.Hand, g.drawOne())
}
g.CurrentPlayerIdx = g.nextActive(skippedIdx)
}
func (g *OneOhOneGameState) advanceTurnPlain() {
g.turnsSinceStart++
if g.turnsSinceStart >= maxOneOhOneTurns {
g.resolveStalemate()
return
}
g.CurrentPlayerIdx = g.nextActive(g.CurrentPlayerIdx)
}
// oneOhOneCardEffect возвращает, пропускает ли карта следующего
// игрока и сколько штрафных карт он должен взять.
func oneOhOneCardEffect(c Card) (skip bool, penalty int) {
switch {
case c.Rank == Ace:
return true, 0
case c.Rank == Six:
return true, 1
case c.Rank == Seven:
return true, 2
case c.Rank == King && c.Suit == Spades:
return true, 4
default:
return false, 0
}
}
func (g *OneOhOneGameState) resolveStalemate() {
g.Result = &OneOhOneRoundResult{Stalemate: true, ScoreDeltas: make([]int, len(g.Players))}
g.Phase = OneOhOnePhaseRoundOver
}
// resolveRoundEnd завершает раунд победой игрока winnerIdx: считает
// штрафные очки остальных, бонус за даму, обновляет общий счёт и
// проверяет выбывание/окончание партии.
func (g *OneOhOneGameState) resolveRoundEnd(winnerIdx int, lastCard Card) {
deltas := make([]int, len(g.Players))
queenBonus := false
if lastCard.Rank == Queen {
queenBonus = true
if lastCard.Suit == Hearts {
deltas[winnerIdx] = -40
} else {
deltas[winnerIdx] = -20
}
}
for i, p := range g.Players {
if i == winnerIdx || p.Out {
continue
}
sum := 0
for _, c := range p.Hand {
sum += oneOhOneCardValue(c.Rank)
}
deltas[i] = sum
}
for i, p := range g.Players {
p.Score += deltas[i]
}
var eliminated, reset []int
for i, p := range g.Players {
if p.Score == 101 {
p.Score = 0
reset = append(reset, i)
} else if p.Score > 101 {
p.Out = true
eliminated = append(eliminated, i)
}
}
result := &OneOhOneRoundResult{
Winner: winnerIdx,
QueenBonus: queenBonus,
ScoreDeltas: deltas,
Eliminated: eliminated,
Reset: reset,
}
active := 0
lastActive := -1
for i, p := range g.Players {
if !p.Out {
active++
lastActive = i
}
}
switch active {
case 0:
result.MatchOver = true
result.NoWinner = true
case 1:
result.MatchOver = true
result.MatchWinner = lastActive
}
g.Result = result
g.Phase = OneOhOnePhaseRoundOver
if !result.MatchOver {
g.DealerIdx = winnerIdx
}
}
// NextRound начинает новый раунд партии (сдающий — победитель
// предыдущего раунда), сохраняя счёт и статус выбывания игроков.
func (g *OneOhOneGameState) NextRound() {
if g.Result != nil && g.Result.MatchOver {
return
}
g.dealNewRound()
}