package uno import "math/rand/v2" // No Mercy. // // A rules dial, not a fourth table. The table size is still the tier — a duel is // a duel — and No Mercy is a switch you throw across all three of them. What it // changes is the game they play: // // - A 168-card deck, with faces the normal one doesn't print: a coloured +4, a // +6, a +10, a skip-everyone, a discard-all, a reverse-and-draw-four, and a // colour roulette. // - Draw cards stack. A +2 pointed at you can be answered with any draw card // you hold, and the bill goes to the next seat with the two added on. Whoever // runs out of draw cards eats the lot. // - You draw until you can play. There is no drawing one card and shrugging. // - And twenty-five cards in your hand kills you. That is the whole point of // the deck: it is built to bury somebody, and the mercy rule is what happens // when it does. // // Everything here is reached from uno.go behind `s.Tier.NoMercy`. A normal game // never runs a line of it. // MercyLimit is the hand that ends you. Reach it and you are out of the game — // your cards go back in the deck and the table plays on without you. const MercyLimit = 25 // NewNoMercyDeck builds the 168. // // Per colour: two of each number, three skips, two skip-everyones, four // reverses, two +2s, two coloured +4s and three discard-alls — thirty-six cards, // times four colours. Then the wilds: eight reverse-draw-fours, four +6s, four // +10s and eight roulettes. Unshuffled, same as NewDeck, because New shuffles and // a test wants the order it was built in. func NewNoMercyDeck() []Card { d := make([]Card, 0, 168) for _, col := range []Color{Red, Blue, Yellow, Green} { for v := Zero; v <= Nine; v++ { d = append(d, Card{col, v}, Card{col, v}) } for i := 0; i < 3; i++ { d = append(d, Card{col, Skip}) } for i := 0; i < 2; i++ { d = append(d, Card{col, SkipAll}) } for i := 0; i < 4; i++ { d = append(d, Card{col, Reverse}) } for i := 0; i < 2; i++ { d = append(d, Card{col, DrawTwo}) } for i := 0; i < 2; i++ { d = append(d, Card{col, DrawFour}) } for i := 0; i < 3; i++ { d = append(d, Card{col, DiscardAll}) } } for i := 0; i < 8; i++ { d = append(d, Card{Wild, WildRevFour}) } for i := 0; i < 4; i++ { d = append(d, Card{Wild, WildDrawSix}) } for i := 0; i < 4; i++ { d = append(d, Card{Wild, WildDrawTen}) } for i := 0; i < 8; i++ { d = append(d, Card{Wild, WildRoulette}) } return d } // CanStackOn reports whether a card can be thrown onto a stack that is already // building. Any draw card answers any other — there is no escalation rule, so a // +2 is a legal reply to a +10 — but a *coloured* draw card still has to follow // the colour in play. The wild draws always go. // // This is why the pending count is not a cap: what you are matching is the fact // of a draw card, not its size. func (c Card) CanStackOn(topColor Color) bool { if c.Value.Draw() == 0 { return false } if c.IsWild() { return true } return c.Color == topColor } // canStack reports whether a seat holds anything at all it could answer with. func (s State) canStack(seat int) bool { for _, c := range s.Hands[seat] { if c.CanStackOn(s.Color) { return true } } return false } // absorb is what happens when the stack stops with you: you take every card in // it, and you lose your turn. The pending count is cleared *before* the cards // land, because a mercy kill inside the draw ends the seat and there must be no // bill left standing against a seat that is no longer at the table. func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) { n := s.Pending s.Pending = 0 s.deal(seat, n, true, evs, rng) // The seat can die paying the bill, and a mercy kill can end the whole game — // the player dying, or the last bot dying and leaving you alone at the table. // So the phase is only reset if there is still a game to have a phase. if s.mercy(seat, evs, rng) && s.Phase == PhaseDone { return } if !s.live(seat) { s.Phase = PhasePlay s.advance(1) return // it died, but the table plays on. Don't skip a seat that isn't there. } *evs = append(*evs, Event{Kind: EvSkip, Seat: seat, Left: len(s.Hands[seat])}) s.Phase = PhasePlay s.advance(1) // the turn is on the seat that just paid, so it moves one on } // roulette is the colour roulette: the next seat turns cards over until the // named colour comes up, and keeps every card it turned. Then it loses its turn. // // The deck can run out mid-flip (the discard is reshuffled back under as usual, // and even that can be dry), so this is bounded by what there is to draw, not by // the colour ever actually appearing. A wild is not a colour and never ends it. func (s *State) roulette(victim int, color Color, evs *[]Event, rng *rand.Rand) { got := 0 for { if len(s.Deck) == 0 && !s.reshuffle(evs, rng) { break } c, ok := s.pop() if !ok { break } s.Hands[victim] = append(s.Hands[victim], c) got++ if c.Color == color { break } if len(s.Hands[victim]) >= MercyLimit { break // they are dead already; stop dealing cards to a corpse } } if got > 0 { e := Event{Kind: EvRoulette, Seat: victim, N: got, Color: color, Left: len(s.Hands[victim])} *evs = append(*evs, e) } if s.mercy(victim, evs, rng) { return } *evs = append(*evs, Event{Kind: EvSkip, Seat: victim, Left: len(s.Hands[victim])}) s.advance(2) } // discardAll dumps every remaining card of a colour out of a hand and buries it // under the card that was just played. The pile keeps its top: the played card // stays the card in play, and the rest go beneath it, where they are still in the // game (a reshuffle brings them back) and still count in a census. func (s *State) discardAll(seat int, color Color, evs *[]Event) int { hand := s.Hands[seat] kept := make([]Card, 0, len(hand)) var dumped []Card for _, c := range hand { if c.Color == color && !c.IsWild() { dumped = append(dumped, c) } else { kept = append(kept, c) } } s.Hands[seat] = kept if len(dumped) > 0 { top := s.Discard[len(s.Discard)-1] s.Discard = append(s.Discard[:len(s.Discard)-1], dumped...) s.Discard = append(s.Discard, top) *evs = append(*evs, Event{Kind: EvDiscardAll, Seat: seat, N: len(dumped), Color: color, Left: len(kept)}) } return len(dumped) } // mercy checks a seat against the limit and, if it has crossed it, takes it out // of the game: its cards go back into the deck and it never plays again. It // reports whether the seat died. // // What that *means* depends on who it was. You dying is the game over — the // stake is gone whatever the bots do next. A bot dying leaves a table with one // fewer seat, and if it leaves you alone at it, you have won: everybody who could // have beaten you to the last card is dead. func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool { if !s.Tier.NoMercy || !s.live(seat) || len(s.Hands[seat]) < MercyLimit { return false } n := len(s.Hands[seat]) s.Deck = append(s.Deck, s.Hands[seat]...) rng.Shuffle(len(s.Deck), func(i, j int) { s.Deck[i], s.Deck[j] = s.Deck[j], s.Deck[i] }) s.Hands[seat] = nil s.Out[seat] = true s.Pending = 0 // a dead seat pays no bill, and leaves none behind *evs = append(*evs, Event{Kind: EvMercy, Seat: seat, N: n, Left: 0}) if seat == You { s.lose(evs) return true } if alive := s.alive(); len(alive) == 1 { s.settle(alive[0], evs) // you outlived the table } return true } // alive lists the seats still in the game. func (s State) alive() []int { var out []int for i := range s.Hands { if s.live(i) { out = append(out, i) } } return out } // live reports whether a seat is still playing. Out is empty in a normal game and // in any game saved before No Mercy existed, so a missing entry is a living seat. func (s State) live(seat int) bool { return seat >= len(s.Out) || !s.Out[seat] } // lose ends the game against the player without anybody having gone out — which // is what a mercy kill on seat zero is. func (s *State) lose(evs *[]Event) { s.Phase = PhaseDone s.Outcome = OutcomeLost s.Payout = 0 *evs = append(*evs, Event{Kind: EvSettle, Seat: You, Text: string(OutcomeLost)}) }