go_games_collection/game.go
2026-07-15 18:02:34 +03:00

598 lines
24 KiB
Go
Raw 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"
"fmt"
)
// Phase — фаза текущего хода игрока.
type Phase int
const (
// PhaseDraw — игрок должен взять карту (из колоды или сброса)
// либо объявить Тонк, если это позволяет вес его руки.
PhaseDraw Phase = iota
// PhaseAction — игрок уже взял карту; может выкладывать
// комбинации, подкладывать карты к столу и в конце обязан
// сбросить одну карту.
PhaseAction
// PhaseGameOver — раунд завершён.
PhaseGameOver
)
// MeldKind различает тип выложенной на стол комбинации.
type MeldKind int
const (
KindSet MeldKind = iota
KindSequence
)
// TableMeld — комбинация, выложенная на стол. Владелец фиксируется
// тем, кто её впервые выложил, но подкладывать карты может любой
// игрок.
type TableMeld struct {
Owner int
Kind MeldKind
Cards []Card
}
var (
ErrWrongPhase = errors.New("недопустимое действие для текущей фазы хода")
ErrDeckExhausted = errors.New("колода и сброс исчерпаны — раунд закончен вничью")
ErrInvalidMeld = errors.New("карты не образуют валидную комбинацию")
ErrCardNotInHand = errors.New("карты нет на руке игрока")
ErrMeldNotFound = errors.New("комбинация на столе не найдена")
ErrCannotExtend = errors.New("карту нельзя подложить к этой комбинации")
ErrTonkNotAllowed = errors.New("вес руки превышает порог объявления Тонка")
ErrDiscardPileEmpty = errors.New("стопка сброса пуста")
)
// DefaultTonkThreshold — по умолчанию Тонк можно объявить, если
// сумма очков карт на руке не превышает 5. Это игровое правило
// (не связано с деньгами) — вес руки, при котором вообще
// разрешено объявить Тонк.
const DefaultTonkThreshold = 5
// Денежные параметры по умолчанию: фиксированная ставка (анте) в
// банк раунда с каждого игрока, стартовый капитал и множитель
// банка при победе через объявление Тонка.
const (
DefaultStartingCapital = 100
DefaultAnte = 5
DefaultTonkMultiplier = 2
)
// RoundResult — итог завершившегося раунда.
type RoundResult struct {
DeadRound bool // колода и сброс исчерпаны, банк возвращён игрокам
TonkCall bool // раунд завершён объявлением Тонка
DropCall bool // раунд завершён объявлением дропа
DropCaught bool // дроп был неудачным (объявившего "поймали")
Winner int // индекс победителя (не имеет смысла при DeadRound)
Pot int // размер банка на момент завершения раунда
BalanceDeltas []int // изменение капитала каждого игрока за этот раунд
}
// GameState — полное состояние партии в Тонк.
type GameState struct {
Players []*PlayerState
Deck *Deck
DiscardPile []Card // последний элемент — верхняя карта
Table []TableMeld
CurrentPlayer int
Phase Phase
TonkThreshold int
Result *RoundResult // заполняется, когда Phase == PhaseGameOver
// Денежная часть игры: фиксированная ставка с каждого игрока
// уходит в банк раунда; обычная победа забирает банк целиком,
// победа через Тонк — банк, умноженный на TonkMultiplier
// (недостающую часть доплачивают проигравшие сверх анте).
AnteAmount int
TonkMultiplier int
Pot int
// StartingCapital сохраняется отдельно от текущего капитала
// игроков — это сумма, с которой садится за стол новый игрок,
// в том числе бот, заменяющий обанкротившегося (см. пакетный
// уровень, функцию checkBankruptcies в tui.go).
StartingCapital int
// RoundsPlayed — сколько раундов уже сыграно, включая текущий.
// Нужно только для правила сторимода "минимум 3 раунда" — на
// обычную игру никак не влияет.
RoundsPlayed int
// preRoundBalance — капитал каждого игрока непосредственно
// перед списанием анте в начале раунда. Используется только
// для вычисления BalanceDeltas в RoundResult (чтобы показать
// игроку итог всего раунда целиком, а не отдельно анте и
// отдельно выигрыш).
preRoundBalance []int
// noProgressTurns считает подряд идущие завершённые ходы, за
// которые никто не выложил и не подложил ни одной карты в
// комбинацию на столе. Без такого предохранителя игроки без
// стратегии объявления Тонка теоретически могут застрять,
// бесконечно беря и сбрасывая одну и ту же лишнюю карту —
// такой раунд принудительно завершается вничью.
noProgressTurns int
}
// maxNoProgressTurns — порог принудительного завершения "мёртвого"
// раунда, если долгое время не происходит никакого прогресса по
// комбинациям на столе.
const maxNoProgressTurns = 400
// NewGame создаёт новую партию со стандартными денежными
// параметрами (см. DefaultStartingCapital, DefaultAnte,
// DefaultTonkMultiplier). humanIdx — индекс живого игрока (может
// быть -1, если играют только боты, например для симуляций).
func NewGame(names []string, humanIdx int) *GameState {
return NewGameWithStakes(names, humanIdx, DefaultStartingCapital, DefaultAnte, DefaultTonkMultiplier)
}
// NewGameWithStakes — то же самое, что NewGame, но с явно заданными
// денежными параметрами: стартовый капитал каждого игрока, размер
// фиксированной ставки (анте) в банк раунда и множитель банка при
// победе через Тонк.
func NewGameWithStakes(names []string, humanIdx, startingCapital, ante, tonkMultiplier int) *GameState {
isHuman := make([]bool, len(names))
if humanIdx >= 0 && humanIdx < len(names) {
isHuman[humanIdx] = true
}
balances := make([]int, len(names))
for i := range balances {
balances[i] = startingCapital
}
return newRound(names, isHuman, balances, DefaultTonkThreshold, startingCapital, ante, tonkMultiplier)
}
// NextRound начинает новый раунд партии, сохраняя капитал игроков
// (и то, кто из них человек) из завершившегося раунда g. Ставка
// (анте) списывается заново из перенесённого капитала. Если перед
// вызовом NextRound в g.Players были подменены обанкротившиеся
// игроки (см. checkBankruptcies в tui.go), новый раунд стартует уже
// с обновлённым составом.
func (g *GameState) NextRound() *GameState {
names := make([]string, len(g.Players))
isHuman := make([]bool, len(g.Players))
balances := make([]int, len(g.Players))
for i, p := range g.Players {
names[i] = p.Name
isHuman[i] = p.IsHuman
balances[i] = p.Balance
}
next := newRound(names, isHuman, balances, g.TonkThreshold, g.StartingCapital, g.AnteAmount, g.TonkMultiplier)
next.RoundsPlayed = g.RoundsPlayed + 1
return next
}
// newRound — общая внутренняя логика раздачи новой партии/раунда:
// тасует колоду, раздаёт по 5 карт, вскрывает первую карту сброса
// и собирает анте в банк.
func newRound(names []string, isHuman []bool, balances []int, tonkThreshold, startingCapital, ante, tonkMultiplier int) *GameState {
deck := NewDeck()
deck.Shuffle()
players := make([]*PlayerState, len(names))
for i, name := range names {
players[i] = &PlayerState{
Name: name,
Hand: deck.DrawN(5),
IsHuman: isHuman[i],
Balance: balances[i],
}
}
firstDiscard, _ := deck.Draw()
g := &GameState{
Players: players,
Deck: deck,
DiscardPile: []Card{firstDiscard},
CurrentPlayer: 0,
Phase: PhaseDraw,
TonkThreshold: tonkThreshold,
AnteAmount: ante,
TonkMultiplier: tonkMultiplier,
StartingCapital: startingCapital,
RoundsPlayed: 1,
}
g.collectAnte()
return g
}
// collectAnte списывает фиксированную ставку с каждого игрока в
// банк раунда. Капитал может уйти в минус, если у игрока не
// хватает средств на анте — это сознательное упрощение (реальный
// долг между игроками здесь не моделируется).
func (g *GameState) collectAnte() {
g.preRoundBalance = make([]int, len(g.Players))
for i, p := range g.Players {
g.preRoundBalance[i] = p.Balance
p.Balance -= g.AnteAmount
g.Pot += g.AnteAmount
}
}
func (g *GameState) current() *PlayerState {
return g.Players[g.CurrentPlayer]
}
// PeekDiscard возвращает верхнюю карту стопки сброса.
func (g *GameState) PeekDiscard() (Card, bool) {
if len(g.DiscardPile) == 0 {
return Card{}, false
}
return g.DiscardPile[len(g.DiscardPile)-1], true
}
// reshuffleDiscardIntoDeck перекладывает стопку сброса (кроме
// верхней карты) обратно в колоду и тасует её — стандартный приём
// раммі-игр, когда колода добора заканчивается.
func (g *GameState) reshuffleDiscardIntoDeck() {
if len(g.DiscardPile) <= 1 {
return
}
top := g.DiscardPile[len(g.DiscardPile)-1]
rest := g.DiscardPile[:len(g.DiscardPile)-1]
g.Deck.cards = append(g.Deck.cards, rest...)
g.Deck.Shuffle()
g.DiscardPile = []Card{top}
}
// DrawFromDeck — взять карту из колоды добора. Если колода пуста,
// сначала пытается переложить сброс в колоду; если и это
// невозможно, раунд завершается вничью.
func (g *GameState) DrawFromDeck() (Card, error) {
if g.Phase != PhaseDraw {
return Card{}, ErrWrongPhase
}
if g.Deck.Len() == 0 {
g.reshuffleDiscardIntoDeck()
}
card, ok := g.Deck.Draw()
if !ok {
g.endDeadRound()
return Card{}, ErrDeckExhausted
}
g.current().Hand = append(g.current().Hand, card)
g.Phase = PhaseAction
return card, nil
}
// DrawFromDiscard — взять верхнюю карту из стопки сброса.
func (g *GameState) DrawFromDiscard() (Card, error) {
if g.Phase != PhaseDraw {
return Card{}, ErrWrongPhase
}
card, ok := g.PeekDiscard()
if !ok {
return Card{}, ErrDiscardPileEmpty
}
g.DiscardPile = g.DiscardPile[:len(g.DiscardPile)-1]
g.current().Hand = append(g.current().Hand, card)
g.Phase = PhaseAction
return card, nil
}
// DeclareTonk — объявить Тонк. Разрешено только сразу после взятия
// карты (в фазе PhaseAction), до выкладывания комбинаций, и только
// если вес руки не превышает TonkThreshold.
func (g *GameState) DeclareTonk() (*RoundResult, error) {
if g.Phase != PhaseAction {
return nil, ErrWrongPhase
}
if !CanDeclareTonk(g.current().Hand, g.TonkThreshold) {
return nil, ErrTonkNotAllowed
}
return g.endByTonk(g.CurrentPlayer), nil
}
// Drop — объявить дроп: рискованная заявка "у меня самая низкая
// сумма очков за столом", доступная в НАЧАЛЕ хода, до взятия карты
// (в отличие от Тонка, порог веса руки не требуется — можно
// дропнуть с любой рукой). Если объявивший действительно строго
// ниже всех остальных — он забирает банк как за обычную победу
// (без множителя). Если нет — он "попался": банк достаётся
// игроку(ам) с реально самой низкой суммой, а сам объявивший вдобавок
// платит им штраф в размере ещё одной ставки (анте) сверху.
func (g *GameState) Drop() (*RoundResult, error) {
if g.Phase != PhaseDraw {
return nil, ErrWrongPhase
}
return g.endByDrop(g.CurrentPlayer), nil
}
// LayNewMeld выкладывает новую комбинацию на стол из карт руки
// текущего игрока.
func (g *GameState) LayNewMeld(cards []Card) error {
if g.Phase != PhaseAction {
return ErrWrongPhase
}
for _, c := range cards {
if !g.current().HasCard(c) {
return ErrCardNotInHand
}
}
var kind MeldKind
switch {
case IsSet(cards):
kind = KindSet
case IsSequence(cards):
kind = KindSequence
default:
return ErrInvalidMeld
}
// копируем ДО удаления из руки: если вызывающий код передал сам
// срез p.Hand (а не его копию), RemoveCards будет мутировать тот
// же массив в памяти, и cards станет невалидным
cardsCopy := append([]Card{}, cards...)
g.current().RemoveCards(cardsCopy)
g.Table = append(g.Table, TableMeld{
Owner: g.CurrentPlayer,
Kind: kind,
Cards: cardsCopy,
})
g.noProgressTurns = 0
g.checkEmptyHandWin()
return nil
}
// AddToMeld подкладывает одну карту руки текущего игрока к уже
// выложенной на столе комбинации (своей или чужой).
func (g *GameState) AddToMeld(meldIdx int, card Card) error {
if g.Phase != PhaseAction {
return ErrWrongPhase
}
if meldIdx < 0 || meldIdx >= len(g.Table) {
return ErrMeldNotFound
}
if !g.current().HasCard(card) {
return ErrCardNotInHand
}
meld := g.Table[meldIdx]
trial := append(append([]Card{}, meld.Cards...), card)
valid := false
switch meld.Kind {
case KindSet:
valid = IsSet(trial)
case KindSequence:
valid = IsSequence(trial)
}
if !valid {
return ErrCannotExtend
}
g.current().RemoveCard(card)
g.Table[meldIdx].Cards = trial
g.noProgressTurns = 0
g.checkEmptyHandWin()
return nil
}
// Discard сбрасывает карту и завершает ход текущего игрока.
func (g *GameState) Discard(card Card) error {
if g.Phase != PhaseAction {
return ErrWrongPhase
}
if g.Phase == PhaseGameOver {
return ErrWrongPhase
}
if !g.current().HasCard(card) {
return ErrCardNotInHand
}
g.current().RemoveCard(card)
g.DiscardPile = append(g.DiscardPile, card)
if len(g.current().Hand) == 0 {
g.endByEmptyHand(g.CurrentPlayer)
return nil
}
g.noProgressTurns++
if g.noProgressTurns >= maxNoProgressTurns {
g.endDeadRound()
return nil
}
g.advanceTurn()
return nil
}
// checkEmptyHandWin проверяет, не остался ли текущий игрок без
// карт сразу после выкладывания/подкладывания комбинации — в этом
// случае раунд заканчивается его победой без необходимости сброса.
func (g *GameState) checkEmptyHandWin() {
if g.Phase == PhaseGameOver {
return
}
if len(g.current().Hand) == 0 {
g.endByEmptyHand(g.CurrentPlayer)
}
}
func (g *GameState) advanceTurn() {
g.CurrentPlayer = (g.CurrentPlayer + 1) % len(g.Players)
g.Phase = PhaseDraw
}
// endByEmptyHand завершает раунд победой игрока winner, полностью
// избавившегося от карт. Победитель забирает весь банк раунда
// (сумму ставок всех игроков); остальные при этом уже заплатили
// свою часть банка в виде анте в начале раунда.
func (g *GameState) endByEmptyHand(winner int) {
g.Players[winner].Balance += g.Pot
pot := g.Pot
g.Pot = 0
g.Result = &RoundResult{
Winner: winner,
Pot: pot,
BalanceDeltas: g.balanceDeltasFromPreRound(),
}
g.Phase = PhaseGameOver
}
// endByTonk завершает раунд объявлением Тонка игроком declarer.
// Объявивший забирает банк, умноженный на TonkMultiplier: помимо
// уже собранного банка, каждый из остальных игроков доплачивает
// поровну недостающую разницу (округление в меньшую сторону —
// остаток от деления забирает победитель, чтобы сумма выплат
// сходилась без потерь).
func (g *GameState) endByTonk(declarer int) *RoundResult {
pot := g.Pot
totalPayout := pot * g.TonkMultiplier
extra := totalPayout - pot
losers := make([]int, 0, len(g.Players)-1)
for i := range g.Players {
if i != declarer {
losers = append(losers, i)
}
}
actualExtra := 0
if len(losers) > 0 {
share := extra / len(losers)
remainder := extra % len(losers)
for idx, i := range losers {
deduction := share
if idx < remainder {
// остаток от целочисленного деления распределяем по
// первым проигравшим, чтобы сумма выплат сходилась
// с точностью до копейки, а не терялась в округлении
deduction++
}
g.Players[i].Balance -= deduction
actualExtra += deduction
}
}
g.Players[declarer].Balance += pot + actualExtra
g.Pot = 0
g.Result = &RoundResult{
TonkCall: true,
Winner: declarer,
Pot: pot,
BalanceDeltas: g.balanceDeltasFromPreRound(),
}
g.Phase = PhaseGameOver
return g.Result
}
// endByDrop разрешает объявление дропа игроком dropper: сравнивает
// вес его руки с весами рук всех остальных игроков.
// - Если dropper строго ниже всех — он забирает банк как за
// обычную победу (без множителя).
// - Если нет — банк достаётся игроку(ам) с реально самой низкой
// суммой (при нескольких таких игроках банк делится поровну), а
// dropper вдобавок платит им штраф в размере ещё одной ставки
// (анте) сверху, разделённой между ними так же поровну.
func (g *GameState) endByDrop(dropper int) *RoundResult {
values := make([]int, len(g.Players))
minValue := g.Players[dropper].HandValue()
for i, p := range g.Players {
values[i] = p.HandValue()
if values[i] < minValue {
minValue = values[i]
}
}
var lowest []int
for i, v := range values {
if v == minValue {
lowest = append(lowest, i)
}
}
pot := g.Pot
result := &RoundResult{DropCall: true, Pot: pot}
if len(lowest) == 1 && lowest[0] == dropper {
// успешный дроп: объявивший действительно строго ниже всех
g.Players[dropper].Balance += pot
result.Winner = dropper
} else {
// дроп не удался — "попался"; банк и штраф достаются
// реальным обладателям самой низкой суммы (dropper среди них
// быть не может: либо его сумма не минимальна, либо она
// минимальна, но не единственна — в обоих случаях это не
// строго меньше всех остальных)
catchers := make([]int, 0, len(lowest))
for _, i := range lowest {
if i != dropper {
catchers = append(catchers, i)
}
}
penalty := g.AnteAmount
if len(catchers) > 0 {
potShare := pot / len(catchers)
potRemainder := pot % len(catchers)
penaltyShare := penalty / len(catchers)
penaltyRemainder := penalty % len(catchers)
totalPenaltyPaid := 0
for idx, i := range catchers {
share := potShare
if idx < potRemainder {
share++
}
pShare := penaltyShare
if idx < penaltyRemainder {
pShare++
}
g.Players[i].Balance += share + pShare
totalPenaltyPaid += pShare
}
g.Players[dropper].Balance -= totalPenaltyPaid
}
result.Winner = catchers[0]
result.DropCaught = true
}
g.Pot = 0
result.BalanceDeltas = g.balanceDeltasFromPreRound()
g.Result = result
g.Phase = PhaseGameOver
return result
}
// endDeadRound завершает раунд без победителя — колода и сброс
// исчерпаны. Банк возвращается игрокам поровну (каждому — его
// анте назад), чтобы никто не терял деньги в раунде без вины.
func (g *GameState) endDeadRound() {
for _, p := range g.Players {
p.Balance += g.AnteAmount
}
pot := g.Pot
g.Pot = 0
g.Result = &RoundResult{
DeadRound: true,
Pot: pot,
BalanceDeltas: g.balanceDeltasFromPreRound(),
}
g.Phase = PhaseGameOver
}
// balanceDeltasFromPreRound считает итоговое изменение капитала
// каждого игрока за весь раунд целиком — от капитала до списания
// анте и до текущего момента после выплат. Это удобнее для
// отображения игроку, чем показывать анте и выигрыш раздельно.
func (g *GameState) balanceDeltasFromPreRound() []int {
deltas := make([]int, len(g.Players))
for i, p := range g.Players {
deltas[i] = p.Balance - g.preRoundBalance[i]
}
return deltas
}
// String — компактное текстовое представление состояния стола,
// полезно для отладки и логирования симуляций.
func (g *GameState) String() string {
top, _ := g.PeekDiscard()
return fmt.Sprintf("ход: %s | фаза: %d | сброс сверху: %s | колода: %d карт",
g.current().Name, g.Phase, top, g.Deck.Len())
}