435 lines
14 KiB
Go
435 lines
14 KiB
Go
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
|
||
// OneOhOnePhaseRoundOver — раунд завершён.
|
||
OneOhOnePhaseRoundOver
|
||
)
|
||
|
||
var (
|
||
ErrOOOWrongPhase = errors.New("недопустимое действие для текущей фазы")
|
||
ErrOOOCardNotInHand = errors.New("карты нет на руке игрока")
|
||
ErrOOOInvalidPlay = errors.New("эта карта не подходит по масти или рангу")
|
||
ErrOOOMustPlay = 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
|
||
|
||
turnsSinceStart int
|
||
}
|
||
|
||
// maxOneOhOneTurns — защита от зацикливания: если раунд длится
|
||
// неправдоподобно долго, принудительно завершаем его вничью (по
|
||
// текущему весу рук), чтобы партия гарантированно не зависла.
|
||
const maxOneOhOneTurns = 5000
|
||
|
||
// 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
|
||
}
|
||
|
||
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]
|
||
}
|
||
|
||
// canPlay проверяет, подходит ли карта по масти или рангу под
|
||
// верхнюю карту сброса.
|
||
func (g *OneOhOneGameState) canPlay(card Card) bool {
|
||
top := g.topDiscard()
|
||
return card.Suit == top.Suit || 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 обрабатывает эффект специальной карты (если
|
||
// есть) и передаёт ход дальше.
|
||
func (g *OneOhOneGameState) applyEffectAndAdvance(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()
|
||
}
|