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()) }