diff --git a/internal/games/uno/bot.go b/internal/games/uno/bot.go index 9aa02e0..b1d1841 100644 --- a/internal/games/uno/bot.go +++ b/internal/games/uno/bot.go @@ -93,6 +93,64 @@ func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int return out } +// botStack answers a stack, or reports -1 when the bot has nothing to answer it +// with and has to eat the lot. +// +// It plays the *smallest* draw card it holds. The bill is passed on either way — +// what it is passing on is the stack plus whatever it added — so the cheap card +// does the same job as the expensive one, and keeps the +10 in hand for a turn +// when the bot is the one choosing to hurt somebody rather than the one dodging. +// +// The slip is here too: one time in six it reaches for the second-smallest, so a +// player can't read the stack it just passed as a complete inventory of what the +// bot doesn't have. +func botStack(hand []Card, topColor Color, rng *rand.Rand) (Card, int) { + var can []int + for i, c := range hand { + if c.CanStackOn(topColor) { + can = append(can, i) + } + } + if len(can) == 0 { + return Card{}, -1 + } + // Smallest draw first. A stable insertion sort: there are never many. + for i := 1; i < len(can); i++ { + for j := i; j > 0 && hand[can[j]].Value.Draw() < hand[can[j-1]].Value.Draw(); j-- { + can[j], can[j-1] = can[j-1], can[j] + } + } + pick := can[0] + if len(can) > 1 && rng.IntN(botSlip) == 0 { + pick = can[1] + } + return hand[pick], pick +} + +// botRouletteColor names the colour for a roulette: whichever the bot holds +// *least* of. The victim flips until that colour turns up, so the rarer the +// colour, the longer they flip and the more they keep. Naming the colour you're +// long in is naming the one that ends the flipping soonest, which is mercy — and +// this is not that game. +func botRouletteColor(hand []Card, rng *rand.Rand) Color { + counts := [5]int{} + for _, c := range hand { + if c.Color.Playable() { + counts[c.Color]++ + } + } + best, bestN := Wild, 1<<30 + for col := Red; col <= Green; col++ { + if counts[col] < bestN { + best, bestN = col, counts[col] + } + } + if best == Wild { + return Red + Color(rng.IntN(4)) + } + return best +} + // botColor names a colour for a wild: whichever the bot holds most of, so the // card it plays next is one it already has. A hand of nothing but wilds picks // at random rather than always saying red, which would be a tell. diff --git a/internal/games/uno/nomercy.go b/internal/games/uno/nomercy.go new file mode 100644 index 0000000..3e447fb --- /dev/null +++ b/internal/games/uno/nomercy.go @@ -0,0 +1,244 @@ +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)}) +} diff --git a/internal/games/uno/nomercy_test.go b/internal/games/uno/nomercy_test.go new file mode 100644 index 0000000..a9087d2 --- /dev/null +++ b/internal/games/uno/nomercy_test.go @@ -0,0 +1,421 @@ +package uno + +import ( + "math/rand/v2" + "testing" +) + +func nmDuel() Tier { t, _ := TierBySlug("nm-duel"); return t } +func nmTable() Tier { t, _ := TierBySlug("nm-table"); return t } +func nmFull() Tier { t, _ := TierBySlug("nm-full"); return t } + +func TestNoMercyDeckIsADeck(t *testing.T) { + m := census(State{Deck: NewNoMercyDeck()}) + if got := total(m); got != 168 { + t.Fatalf("deck has %d cards, want 168", got) + } + want := map[Card]int{ + {Red, Zero}: 2, // two of every number, unlike the normal deck's single zero + {Blue, Seven}: 2, + {Green, Skip}: 3, + {Yellow, SkipAll}: 2, + {Red, Reverse}: 4, + {Blue, DrawTwo}: 2, + {Green, DrawFour}: 2, // the *coloured* +4 + {Yellow, DiscardAll}: 3, + {Wild, WildRevFour}: 8, + {Wild, WildDrawSix}: 4, + {Wild, WildDrawTen}: 4, + {Wild, WildRoulette}: 8, + } + for c, n := range want { + if m[c] != n { + t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n) + } + } + // The normal deck's wilds are not in this one, and its coloured +4 is not in + // the normal one. They are different cards that print the same thing. + if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 { + t.Error("the No Mercy deck should print none of the normal wilds") + } +} + +// TestNoMercyCensus is the load-bearing one, and the same one the normal game +// has: 168 cards, each in exactly one place, checked after every move of a +// hundred games played to the end. +// +// It is what would catch the two new ways this deck can lose a card. Discard All +// buries a whole colour under the pile, and a mercy kill shovels a +// twenty-five-card hand back into the deck — either of those dropping a card on +// the floor is a deck that quietly shrinks until the table can't be dealt. +func TestNoMercyCensus(t *testing.T) { + for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} { + for seed := uint64(0); seed < 100; seed++ { + s := deal(t, tier, 100, seed) + start := census(s) + if got := total(start); got != 168 { + t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got) + } + rng := rand.New(rand.NewPCG(seed, 99)) + for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ { + next, _, err := ApplyMove(s, naive(s, rng)) + if err != nil { + t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase) + } + s = next + if got := census(s); total(got) != 168 { + t.Fatalf("%s seed %d: %d cards after a move, want 168", + tier.Slug, seed, total(got)) + } + } + if s.Phase != PhaseDone { + t.Fatalf("%s seed %d: game never ended", tier.Slug, seed) + } + } + } +} + +// naive is the strategy the multiples are priced against: play the first legal +// card you hold, take a stack you can't answer, and draw when you have nothing. +// It is a real way to play and a bad one, which is exactly what a house edge is +// measured against. +func naive(s State, rng *rand.Rand) Move { + if s.Phase == PhaseStack { + if p := s.Playable(); len(p) > 0 { + return playMove(s, p[0], rng) + } + return Move{Kind: MoveTake} + } + if p := s.Playable(); len(p) > 0 { + return playMove(s, p[0], rng) + } + return Move{Kind: MoveDraw} +} + +// stack loads a seat's hand up to n cards by taking them off the deck, so the +// table still holds 168 of them. Every card it moves is one that can't be played +// on the pile, which is what a hand on its way to the mercy limit looks like. +func stack(s *State, seat, n int) { + // Every card the seat was holding goes back in the deck first, so the table is + // whole before we take n out of it again. The pile keeps whatever the deal + // turned over — replacing it with a card of our choosing would quietly destroy + // one, and the census below would blame the engine for it. + s.Deck = append(s.Deck, s.Hands[seat]...) + s.Hands[seat] = nil + s.Color = s.top().Color + + kept := make([]Card, 0, len(s.Deck)) + for _, c := range s.Deck { + if len(s.Hands[seat]) < n { + s.Hands[seat] = append(s.Hands[seat], c) + continue + } + kept = append(kept, c) + } + s.Deck = kept +} + +func playMove(s State, idx int, rng *rand.Rand) Move { + m := Move{Kind: MovePlay, Index: idx} + if s.Hands[You][idx].IsWild() { + m.Color = Red + Color(rng.IntN(4)) + } + return m +} + +// TestAStackIsPassedOnAndPaidOnce walks the one rule the whole mode turns on: a +// draw card doesn't land on you, it *opens a bill*, and the seat that can't +// answer pays the whole thing. +func TestAStackIsPassedOnAndPaid(t *testing.T) { + s := deal(t, nmDuel(), 100, 7) + // Rig it: you hold a +2 on a red pile, the bot holds one card that can answer + // and one that can't. + s.Color = Red + s.Discard = []Card{{Red, Five}} + s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}} + s.Hands[1] = []Card{{Red, DrawTwo}, {Blue, Nine}} + s.Turn = You + s.Phase = PhasePlay + + // You play the +2. The bot answers with its own, so the bill comes back to you + // at four — and you have nothing to answer with, so you pay it. + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("play +2: %v", err) + } + if next.Phase != PhaseStack { + t.Fatalf("phase is %s, want stack: a +2 in No Mercy opens a stack", next.Phase) + } + if next.Turn != You { + t.Fatalf("the stack came back to seat %d, want you", next.Turn) + } + if next.Pending != 4 { + t.Fatalf("the bill is %d, want 4 (your two, plus the bot's two)", next.Pending) + } + if !hasKind(evs, EvStack) { + t.Error("no stack event: the felt has nothing to show the player") + } + // You cannot draw your way out of it, and you cannot play a card that isn't a + // draw card. + if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustStack { + t.Errorf("drawing out of a stack: %v, want ErrMustStack", err) + } + if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack { + t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err) + } + + // Pay it. The bot is left holding one card it cannot play, and — because No + // Mercy makes it draw until it can — it will draw into a fresh hand and may + // well open a *new* stack on the way. That's the game working, not a leak, so + // what's asserted here is the bill this seat paid, not the state of the table + // afterwards: four cards into the hand, and the bill discharged. + before := len(next.Hands[You]) + paid, evs, err := ApplyMove(next, Move{Kind: MoveTake}) + if err != nil { + t.Fatalf("take: %v", err) + } + var forced int + for _, e := range evs { + if e.Kind == EvForced && e.Seat == You { + forced = e.N + } + } + if forced != 4 { + t.Errorf("the stack made you take %d cards, want 4", forced) + } + if len(paid.Hands[You]) < before+4 { + t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You])) + } + // The bill you paid is gone. Anything pending now is a new stack the bot + // opened after yours was settled, and it is never the one you just paid. + if paid.Pending == 4 && paid.Phase == PhaseStack { + t.Error("the bill you just paid is still standing") + } +} + +// TestTwentyFiveCardsKillsYou is the mercy rule, from the player's side: the +// stake is gone the moment the hand hits the limit, whoever else is still playing. +func TestTwentyFiveCardsKillsYou(t *testing.T) { + s := deal(t, nmFull(), 100, 3) + // Twenty-four cards in your hand, and a stack of ten pointed at you. + // + // The cards are *moved* from the deck, not invented: a fixture that conjures + // a hand out of nothing breaks the census before the engine gets a chance to, + // and then the census assertion below is testing the fixture instead of the + // mercy rule. + stack(&s, You, 24) + s.Turn = You + s.Phase = PhaseStack + s.Pending = 10 + + next, evs, err := ApplyMove(s, Move{Kind: MoveTake}) + if err != nil { + t.Fatalf("take: %v", err) + } + if !hasKind(evs, EvMercy) { + t.Fatal("no mercy event: twenty-five cards should have killed the seat") + } + if next.Phase != PhaseDone || next.Outcome != OutcomeLost { + t.Fatalf("phase %s outcome %q, want done/lost", next.Phase, next.Outcome) + } + if next.Payout != 0 { + t.Errorf("a mercy kill paid out %d, want nothing", next.Payout) + } + if len(next.Hands[You]) != 0 || next.live(You) { + t.Error("a dead seat should hold no cards and be out of the game") + } + if got := total(census(next)); got != 168 { + t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got) + } +} + +// TestOutlivingTheTableWins is the other side of the mercy rule, and the one +// that makes No Mercy pay less than it looks like it should: the deck buries bots +// too, and a table with every bot dead is a table you have won. +func TestOutlivingTheTableWins(t *testing.T) { + s := deal(t, nmDuel(), 100, 11) + s.Color = Red + s.Discard = []Card{{Red, Five}} + s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}} + s.Hands[1] = make([]Card, 0, 24) + for i := 0; i < 24; i++ { + s.Hands[1] = append(s.Hands[1], Card{Blue, Nine}) // nothing it can answer with + } + s.Turn = You + s.Phase = PhasePlay + + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("play +2: %v", err) + } + if !hasKind(evs, EvMercy) { + t.Fatal("the bot should have died taking the stack") + } + if next.Phase != PhaseDone || next.Outcome != OutcomeWon { + t.Fatalf("phase %s outcome %q, want done/won: the last seat standing wins", + next.Phase, next.Outcome) + } + if next.Payout != next.Pays() { + t.Errorf("paid %d, quoted %d — settle and the felt must agree", next.Payout, next.Pays()) + } +} + +// TestYouDrawUntilYouCanPlay: no drawing one card and shrugging. The turn only +// moves on when the deck itself has nothing left. +func TestYouDrawUntilYouCanPlay(t *testing.T) { + s := deal(t, nmDuel(), 100, 5) + s.Color = Red + s.Discard = []Card{{Red, Five}} + s.Hands[You] = []Card{{Blue, One}} // nothing playable + // A deck whose first two cards are dead and whose third plays. + s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}} + s.Turn = You + s.Phase = PhasePlay + + next, _, err := ApplyMove(s, Move{Kind: MoveDraw}) + if err != nil { + t.Fatalf("draw: %v", err) + } + if len(next.Hands[You]) != 4 { + t.Fatalf("hand is %d, want 4: you draw until something plays", + len(next.Hands[You])) + } + if next.Phase != PhaseDrawn { + t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play", + next.Phase) + } + // And you may not pass on it: you drew for it, you play it. + if _, _, err := ApplyMove(next, Move{Kind: MovePass}); err != ErrMustPlayNow { + t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err) + } +} + +// TestSkipAllComesBackToYou — everyone else loses their turn, so the turn never +// actually leaves the seat that played it. +func TestSkipAllComesBackToYou(t *testing.T) { + s := deal(t, nmFull(), 100, 13) + s.Color = Red + s.Discard = []Card{{Red, Five}} + s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}} + s.Turn = You + s.Phase = PhasePlay + + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("play skip-all: %v", err) + } + if next.Turn != You { + t.Errorf("turn went to seat %d, want you: skip-all skips everyone else", next.Turn) + } + if !hasKind(evs, EvSkipAll) { + t.Error("no skipall event") + } +} + +// TestDiscardAllTakesTheColourWithIt, and the cards it takes are still in the +// game — buried under the pile, not deleted. +func TestDiscardAllTakesTheColourWithIt(t *testing.T) { + s := deal(t, nmDuel(), 100, 17) + s.Color = Red + s.Discard = []Card{{Red, Five}} + s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}} + s.Turn = You + s.Phase = PhasePlay + before := total(census(s)) + + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) + if err != nil { + t.Fatalf("play discard-all: %v", err) + } + if len(next.Hands[You]) != 1 { + t.Fatalf("hand is %d, want 1: every red should have gone with it", + len(next.Hands[You])) + } + if next.Hands[You][0] != (Card{Blue, Two}) { + t.Errorf("kept %v, want the blue two", next.Hands[You][0]) + } + if top := next.Top(); top.Value != DiscardAll { + t.Errorf("the card in play is %v, want the discard-all that was played", top.Value) + } + if !hasKind(evs, EvDiscardAll) { + t.Error("no discard event") + } + if got := total(census(next)); got != before { + t.Errorf("%d cards, want %d: a dumped colour is buried, not destroyed", got, before) + } +} + +// TestRouletteFlipsUntilTheColour — and the victim keeps every card it turned. +func TestRouletteFlipsUntilTheColour(t *testing.T) { + s := deal(t, nmDuel(), 100, 19) + s.Color = Blue + s.Discard = []Card{{Blue, Five}} + s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}} + s.Hands[1] = []Card{{Green, Three}} + s.Deck = []Card{{Blue, Two}, {Green, Four}, {Yellow, Six}, {Red, Seven}, {Blue, Eight}} + s.Turn = You + s.Phase = PhasePlay + + // Name red: the bot flips blue, green, yellow, red — four cards — and keeps them. + next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Red}) + if err != nil { + t.Fatalf("play roulette: %v", err) + } + var got int + for _, e := range evs { + if e.Kind == EvRoulette { + got = e.N + } + } + if got != 4 { + t.Errorf("flipped %d, want 4 — up to and including the first red", got) + } + // One card it started with, plus the four it turned. (The bot is then skipped, + // so the turn is back with you and it never played any of them.) + if n := len(next.Hands[1]); n != 5 { + t.Errorf("the bot holds %d, want 5", n) + } + if total(census(next)) != total(census(s)) { + t.Error("the roulette lost a card") + } +} + +// TestTheMultiplesAreStillPriced measures the naive strategy against the bots and +// checks each tier still charges roughly the house's edge for it. +// +// This is the test that fails when somebody changes the bots, the deck, or a +// rule, and it is *supposed* to: the tier and the game it prices are a pair. If +// this goes red, re-measure and move the number, don't loosen the bound. +func TestTheMultiplesAreStillPriced(t *testing.T) { + if testing.Short() { + t.Skip("slow: plays thousands of games") + } + for _, tier := range AllTiers() { + wins, games := 0, 3000 + for seed := 0; seed < games; seed++ { + s := deal(t, tier, 100, uint64(seed)+7777) + rng := rand.New(rand.NewPCG(uint64(seed), 4242)) + for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ { + next, _, err := ApplyMove(s, naive(s, rng)) + if err != nil { + t.Fatalf("%s: %v", tier.Slug, err) + } + s = next + } + if s.Outcome.Won() { + wins++ + } + } + p := float64(wins) / float64(games) + // What a staked chip comes back as, playing badly: you win p of the time and + // keep the multiple less the rake on the profit, and lose the stake the rest. + ev := p*(1+(tier.Base-1)*(1-rake)) - 1 + t.Logf("%-8s bots=%d base=%.2f naive win rate %.1f%% house edge %.1f%%", + tier.Slug, tier.Bots, tier.Base, p*100, -ev*100) + if ev < -0.14 || ev > -0.02 { + t.Errorf("%s: the house edge on naive play is %.1f%%, which is outside the 2–14%% "+ + "band the tiers are priced to. Re-measure Base: %.2f would put it near 8%%.", + tier.Slug, -ev*100, (0.92/p-1)/(1-rake)+1) + } + } +} diff --git a/internal/games/uno/uno.go b/internal/games/uno/uno.go index b98f5c6..6e1d913 100644 --- a/internal/games/uno/uno.go +++ b/internal/games/uno/uno.go @@ -37,6 +37,8 @@ var ( ErrNeedColor = errors.New("uno: pick a colour for the wild") ErrCantPass = errors.New("uno: you can only pass on a card you just drew") ErrMustPlayNow = errors.New("uno: play the card you drew, or pass") + ErrMustStack = errors.New("uno: answer the stack with a draw card, or take it") + ErrNoStack = errors.New("uno: there's no stack to take") ErrUnknownMove = errors.New("uno: unknown move") ErrBadBet = errors.New("uno: bet must be positive") ErrUnknownTier = errors.New("uno: no such tier") @@ -80,6 +82,10 @@ func (c Color) Playable() bool { return c >= Red && c <= Green } // Value is what's printed on the face. type Value uint8 +// The faces. The first fifteen are the ones on a normal box, and their numbers +// are load-bearing: a game in flight is a JSON blob of these integers, so the No +// Mercy faces are *appended*. Renumbering them would deal a live table a +// different card. const ( Zero Value = iota One @@ -96,13 +102,23 @@ const ( DrawTwo WildCard WildDrawFour + + // No Mercy only, all of them. + SkipAll // skip everyone: you go again + DrawFour // a *coloured* +4, which the normal deck doesn't have + DiscardAll // play it, and every other card of its colour goes with it + WildRevFour // reverse, and the seat that lands next takes four + WildDrawSix // +6 + WildDrawTen // +10 + WildRoulette // the next seat flips until your colour turns up, and keeps the lot ) -var valueNames = [15]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", - "skip", "reverse", "+2", "wild", "+4"} +var valueNames = [22]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + "skip", "reverse", "+2", "wild", "+4", + "skip all", "+4", "discard all", "rev +4", "+6", "+10", "roulette"} func (v Value) String() string { - if v > WildDrawFour { + if v > WildRoulette { return "?" } return valueNames[v] @@ -111,6 +127,35 @@ func (v Value) String() string { // Action reports whether a card does something beyond being a number. func (v Value) Action() bool { return v >= Skip } +// Wild reports whether the face has no colour of its own. Note DrawFour is *not* +// one: No Mercy prints a coloured +4, which is a different card from the wild +4 +// sitting next to it in the same deck. +func (v Value) Wild() bool { + switch v { + case WildCard, WildDrawFour, WildRevFour, WildDrawSix, WildDrawTen, WildRoulette: + return true + } + return false +} + +// Draw is how many cards the face makes somebody take, and zero if it doesn't. +// It is also what makes a card stackable, so Roulette is deliberately zero: it +// hands over a random number of cards, and you cannot stack onto a number nobody +// knows yet. +func (v Value) Draw() int { + switch v { + case DrawTwo: + return 2 + case DrawFour, WildDrawFour, WildRevFour: + return 4 + case WildDrawSix: + return 6 + case WildDrawTen: + return 10 + } + return 0 +} + // Card is one card. Short JSON keys: a hand of these crosses the wire on every // poll, and a state holds all 108. type Card struct { @@ -119,7 +164,7 @@ type Card struct { } // IsWild reports whether the card has no colour of its own. -func (c Card) IsWild() bool { return c.Value == WildCard || c.Value == WildDrawFour } +func (c Card) IsWild() bool { return c.Value.Wild() } // CanPlayOn is the whole rule of UNO: match the colour in play, or match the // face, or be a wild. Note it takes the colour *in play* rather than the top @@ -153,12 +198,24 @@ func NewDeck() []Card { // shot — three of them going out before you is three ways to lose — so it pays // more. This is the tier dial every other game here has, pointed at the one knob // UNO actually has. +// No Mercy rides on the same struct rather than a second one, because it is the +// tier that lands in the state and the payload — so a game carries which rules it +// is playing by, and cannot be reloaded into the other set. type Tier struct { - Slug string `json:"slug"` - Name string `json:"name"` - Bots int `json:"bots"` - Base float64 `json:"base"` // what going out first pays, before the rake - Blurb string `json:"blurb"` + Slug string `json:"slug"` + Name string `json:"name"` + Bots int `json:"bots"` + Base float64 `json:"base"` // what going out first pays, before the rake + Blurb string `json:"blurb"` + NoMercy bool `json:"no_mercy"` +} + +// Deck is the deck this tier plays with. +func (t Tier) Deck() []Card { + if t.NoMercy { + return NewNoMercyDeck() + } + return NewDeck() } // Tiers are the three tables. @@ -169,18 +226,56 @@ type Tier struct { // under what that costs, so bad play loses slowly and good play (holding the // wilds, dumping the colour you're long in, counting what a bot picked up) is // worth roughly the house's edge. That is the game being about something. +// Re-measured 2026-07-14, and they moved: the naive strategy now wins 40.3% / +// 29.2% / 23.3%, not the 43 / 32 / 27 these were originally priced off. The bots +// got better at some point after the multiples were set and nobody re-ran the +// measurement, so Table and Full House had quietly been charging an 18–19% edge +// instead of the 8% they were meant to. The numbers below are the honest ones. +// +// This is exactly the drift TestTheMultiplesAreStillPriced now exists to stop. var Tiers = []Tier{ - {Slug: "duel", Name: "Duel", Bots: 1, Base: 2.2, + {Slug: "duel", Name: "Duel", Bots: 1, Base: 2.4, Blurb: "One bot, head to head. A reverse is a skip with two at the table."}, - {Slug: "table", Name: "Table", Bots: 2, Base: 2.9, + {Slug: "table", Name: "Table", Bots: 2, Base: 3.3, Blurb: "Two bots. Twice the +4s pointed at you."}, - {Slug: "full", Name: "Full House", Bots: 3, Base: 3.6, + {Slug: "full", Name: "Full House", Bots: 3, Base: 4.1, Blurb: "Three bots, and any of them going out first takes your stake."}, } -// TierBySlug finds a tier by the name the browser sent. +// NoMercyTiers are the same three tables playing the other rules. +// +// The multiples are measured, not guessed, and they are *not* the normal ones — +// the naive strategy (play the first legal card; take a stack you can't answer) +// wins 46.7% / 31.2% / 25.3% here, against 40.3 / 29.2 / 23.3 on the normal deck. +// +// Which is to say: **No Mercy is easier than UNO**, at every table size, and so +// it pays less. That reads backwards until you see why. The mercy rule kills +// *bots* — it does not care whose hand hits twenty-five — and every bot it buries +// is one fewer seat that can beat you to the last card. Three opponents burying +// each other is a game you win by outliving, and the deck that was built to be +// merciless turns out to be merciless mostly to the table. +// +// So a nastier game pays a smaller multiple, which is the correct answer and a +// slightly funny one. TestTheMultiplesAreStillPriced is what keeps it honest: +// change the bots, the deck or a rule, and it fails until these are measured +// again. It is the test the normal tiers never had, which is how they drifted. +var NoMercyTiers = []Tier{ + {Slug: "nm-duel", Name: "No Mercy Duel", Bots: 1, Base: 2.0, NoMercy: true, + Blurb: "One bot, 168 cards. Stack the draws or eat them."}, + {Slug: "nm-table", Name: "No Mercy Table", Bots: 2, Base: 3.1, NoMercy: true, + Blurb: "Two bots. A +10 answered twice is somebody's whole hand."}, + {Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Base: 3.8, NoMercy: true, + Blurb: "Three bots. Twenty-five cards and you're out of the game."}, +} + +// AllTiers is every table in the room, both dials. +func AllTiers() []Tier { + return append(append([]Tier(nil), Tiers...), NoMercyTiers...) +} + +// TierBySlug finds a tier by the name the browser sent, across both rule sets. func TierBySlug(slug string) (Tier, error) { - for _, t := range Tiers { + for _, t := range AllTiers() { if t.Slug == slug { return t, nil } @@ -194,6 +289,7 @@ type Phase string const ( PhasePlay Phase = "play" // your turn, play or draw PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass + PhaseStack Phase = "stack" // No Mercy: a draw card is pointed at you — answer it or take it PhaseDone Phase = "done" ) @@ -222,6 +318,13 @@ type State struct { Turn int `json:"turn"` Dir int `json:"dir"` // +1 clockwise, -1 after a reverse + // No Mercy only. Out is the seats the mercy rule has killed, and it is what + // the turn order steps over — a dead seat is skipped, not merely empty. + // Pending is the bill a stack of draw cards has run up: whoever stops + // stacking pays it. + Out []bool `json:"out,omitempty"` + Pending int `json:"pending,omitempty"` + Seed1 uint64 `json:"seed1"` Seed2 uint64 `json:"seed2"` Step uint64 `json:"step"` // how many moves have been applied; the rng's other half @@ -259,6 +362,14 @@ type Event struct { // uno a hand is down to one card // reshuffle the discard goes back under // settle it's over +// +// And the No Mercy ones: +// +// stack a draw card is pointed at a seat: N is the bill so far +// skipall everybody else loses their turn +// discard a whole colour left a hand at once +// roulette a seat flipped N cards looking for a colour, and kept them +// mercy a seat hit 25 cards and is out of the game const ( EvDeal = "deal" EvPlay = "play" @@ -270,6 +381,12 @@ const ( EvUno = "uno" EvReshuffle = "reshuffle" EvSettle = "settle" + + EvStack = "stack" + EvSkipAll = "skipall" + EvDiscardAll = "discard" + EvRoulette = "roulette" + EvMercy = "mercy" ) // Move is what the player sends: play this card, take one off the deck, or — @@ -280,11 +397,14 @@ type Move struct { Color Color `json:"color"` // the colour you name, for a wild } -// Move kinds. +// Move kinds. Take is No Mercy's: it is how you give in to a stack you can't +// answer, and it is a *decision*, so it gets a name of its own rather than being +// bolted onto draw. const ( MovePlay = "play" MoveDraw = "draw" MovePass = "pass" + MoveTake = "take" ) // New deals a game: a shuffled deck, seven each, and a card turned over. @@ -302,7 +422,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even } rng := stepRNG(seed1, seed2, 0) - deck := NewDeck() + deck := t.Deck() rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] }) s := State{ @@ -313,6 +433,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even } seats := t.Bots + 1 + s.Out = make([]bool, seats) s.Hands = make([][]Card, seats) for i := range s.Hands { s.Hands[i] = make([]Card, 0, HandSize) @@ -369,6 +490,8 @@ func ApplyMove(s State, m Move) (State, []Event, error) { evs, err = next.playerDraws(rng) case MovePass: evs, err = next.playerPasses() + case MoveTake: + evs, err = next.playerTakes(rng) default: return s, nil, ErrUnknownMove } @@ -401,7 +524,15 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) { return nil, ErrMustPlayNow } card := hand[m.Index] - if !card.CanPlayOn(s.top(), s.Color) { + + // With a stack pointed at you, the only cards that exist are the ones that + // answer it. Everything else in your hand is unplayable until the bill is + // settled — by you, or by the seat you pass it to. + if s.Phase == PhaseStack { + if !card.CanStackOn(s.Color) { + return nil, ErrMustStack + } + } else if !card.CanPlayOn(s.top(), s.Color) { return nil, ErrCantPlay } if card.IsWild() && !m.Color.Playable() { @@ -415,29 +546,78 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) { return evs, nil } -// playerDraws takes one off the deck. If it can be played you get the choice — -// that's PhaseDrawn, and it's the only place the turn pauses mid-move. If it -// can't, the turn passes on the spot: there is nothing to decide. +// playerDraws takes cards off the deck. +// +// The normal game takes one: if it can be played you get the choice — that's +// PhaseDrawn, the only place a turn pauses mid-move — and if it can't, the turn +// passes on the spot, because there is nothing to decide. +// +// No Mercy makes you draw *until* you can play. There is no drawing one card and +// shrugging, which is most of why hands there get big enough for the mercy rule +// to have something to kill. The card you end on is a card you must then play, so +// there is still nothing to decide — but the deck can be dry, and a hand can hit +// twenty-five on the way, and both of those end the drawing. func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) { if s.Phase == PhaseDrawn { return nil, ErrMustPlayNow // you already drew; play it or pass } + if s.Phase == PhaseStack { + return nil, ErrMustStack // answer it or take it; you cannot draw out of a stack + } var evs []Event - drawn := s.deal(You, 1, false, &evs, rng) - if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) { - s.Phase = PhaseDrawn + + if !s.Tier.NoMercy { + drawn := s.deal(You, 1, false, &evs, rng) + if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) { + s.Phase = PhaseDrawn + return evs, nil + } + evs = append(evs, Event{Kind: EvPass, Seat: You}) + s.advance(1) return evs, nil } + + for { + drawn := s.deal(You, 1, false, &evs, rng) + if len(drawn) == 0 { + break // the table has nothing left to draw + } + if s.mercy(You, &evs, rng) { + return evs, nil // twenty-five cards, and you are out of the game + } + if drawn[0].CanPlayOn(s.top(), s.Color) { + s.Phase = PhaseDrawn + return evs, nil + } + } evs = append(evs, Event{Kind: EvPass, Seat: You}) s.advance(1) return evs, nil } +// playerTakes gives in to a stack: you take every card it has run up, and you +// lose your turn. +func (s *State) playerTakes(rng *rand.Rand) ([]Event, error) { + if s.Phase != PhaseStack { + return nil, ErrNoStack + } + var evs []Event + s.absorb(You, &evs, rng) + return evs, nil +} + // playerPasses declines the card you just drew. +// +// In No Mercy you may not: you drew until you found a card that plays, and that +// card is the price of having drawn. Passing there would make drawing a way to +// buy a look at the deck and put nothing down. func (s *State) playerPasses() ([]Event, error) { if s.Phase != PhaseDrawn { return nil, ErrCantPass } + if s.Tier.NoMercy { + return nil, ErrMustPlayNow + } s.Phase = PhasePlay s.advance(1) return []Event{{Kind: EvPass, Seat: You}}, nil @@ -455,24 +635,61 @@ func (s *State) runBots(evs *[]Event, rng *rand.Rand) { // botTurn plays one bot's turn. func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) { - card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng) - if idx < 0 { - // Nothing playable: draw one, and play it if it happens to go. - drawn := s.deal(seat, 1, false, evs, rng) - if len(drawn) != 1 || !drawn[0].CanPlayOn(s.top(), s.Color) { - *evs = append(*evs, Event{Kind: EvPass, Seat: seat}) - s.advance(1) + // A stack pointed at this bot is not a turn, it is a bill. It answers with a + // draw card if it holds one, and takes the lot if it doesn't. + if s.Phase == PhaseStack { + card, idx := botStack(s.Hands[seat], s.Color, rng) + if idx < 0 { + s.absorb(seat, evs, rng) return } - card, idx = drawn[0], len(s.Hands[seat])-1 + s.botPlays(seat, card, idx, evs, rng) + return } + card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng) + if idx < 0 { + // Nothing playable: draw. The normal game draws one and shrugs; No Mercy + // draws until something goes, which is what buries a bot as surely as it + // buries you — the mercy rule cuts both ways, and a bot can die on the deck. + for { + drawn := s.deal(seat, 1, false, evs, rng) + if len(drawn) != 1 { + *evs = append(*evs, Event{Kind: EvPass, Seat: seat}) + s.advance(1) + return + } + if s.Tier.NoMercy && s.mercy(seat, evs, rng) { + return + } + if drawn[0].CanPlayOn(s.top(), s.Color) { + card, idx = drawn[0], len(s.Hands[seat])-1 + break + } + if !s.Tier.NoMercy { + *evs = append(*evs, Event{Kind: EvPass, Seat: seat}) + s.advance(1) + return + } + } + } + s.botPlays(seat, card, idx, evs, rng) +} + +// botPlays puts a bot's chosen card down and resolves it. +func (s *State) botPlays(seat int, card Card, idx int, evs *[]Event, rng *rand.Rand) { hand := s.Hands[seat] s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...) color := card.Color if card.IsWild() { color = botColor(s.Hands[seat], rng) + if card.Value == WildRoulette { + // The roulette is not a card you play a colour *from*, it is a card you + // point at somebody. So the bot names the colour it holds least of, which + // is the one the deck is least likely to turn up quickly. + color = botRouletteColor(s.Hands[seat], rng) + } } s.discard(seat, card, color, evs) s.after(seat, card, evs, rng) @@ -489,11 +706,14 @@ func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) { // and worse than either, a live game you can't finish is chips you can't cash // out, because the cage won't let you leave a hand half-played. func (s State) stalled() bool { + if s.Pending > 0 { + return false // a stack is a move somebody still has to make: taking it + } if len(s.Deck) > 0 || len(s.Discard) > 1 { return false // there is a card to draw, or a discard to make one out of } - for _, hand := range s.Hands { - for _, c := range hand { + for _, seat := range s.alive() { + for _, c := range s.Hands[seat] { if c.CanPlayOn(s.top(), s.Color) { return false } @@ -532,6 +752,24 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) { } s.Phase = PhasePlay + // A draw card. In No Mercy this doesn't land yet: it opens a stack, and the + // seat it points at gets the choice of answering it. In the normal game it + // lands where it always did. + if n := card.Value.Draw(); n > 0 { + if card.Value == WildRevFour { + s.flip(seat, evs) // it reverses *first*: the seat it hits is the one after that + } + if s.Tier.NoMercy { + s.Pending += n + s.advance(1) + s.Phase = PhaseStack + *evs = append(*evs, Event{Kind: EvStack, Seat: s.Turn, N: s.Pending}) + return + } + s.punish(s.seatAt(1), n, evs, rng) + return + } + switch card.Value { case Skip: victim := s.seatAt(1) @@ -539,31 +777,50 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) { s.advance(2) case Reverse: - // Two at the table and a reverse has nobody to hand the turn back to, so it - // is a skip — which, with two players, means you go again. - if len(s.Hands) == 2 { - *evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)}) - s.advance(2) + s.flip(seat, evs) + + case SkipAll: + // Everyone else loses their turn, which means it comes straight back to the + // seat that played it. The turn does not move at all. + *evs = append(*evs, Event{Kind: EvSkipAll, Seat: seat}) + + case DiscardAll: + // Every other card of this colour goes down with it. That can empty the + // hand, which is a win — and the reason this can't lean on the empty-hand + // check at the top of the function, which already ran. + s.discardAll(seat, card.Color, evs) + if len(s.Hands[seat]) == 0 { + s.settle(seat, evs) return } - s.Dir = -s.Dir - *evs = append(*evs, Event{Kind: EvReverse, Seat: seat}) s.advance(1) - case DrawTwo: - s.punish(s.seatAt(1), 2, evs, rng) - - case WildDrawFour: - s.punish(s.seatAt(1), 4, evs, rng) + case WildRoulette: + s.roulette(s.seatAt(1), s.Color, evs, rng) default: s.advance(1) } } -// punish makes the next seat eat a draw card and lose its turn. No stacking: a -// +2 played onto a +2 is a house rule, and the one this table plays is the one -// on the box. +// flip turns the direction round — or, at a table of two, skips the only other +// player, because a reverse with nobody to hand the turn back to is a card that +// means you go again. +func (s *State) flip(seat int, evs *[]Event) { + if len(s.alive()) == 2 { + *evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)}) + s.advance(2) + return + } + s.Dir = -s.Dir + *evs = append(*evs, Event{Kind: EvReverse, Seat: seat}) + s.advance(1) +} + +// punish makes the next seat eat a draw card and lose its turn. This is the +// normal game's rule: no stacking, because a +2 played onto a +2 is a house rule +// and the one on the box is the one this deck plays. No Mercy prints the stacking +// rule on its own box, and takes the other road out of after(). func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) { s.deal(victim, n, true, evs, rng) *evs = append(*evs, Event{Kind: EvSkip, Seat: victim}) @@ -648,8 +905,13 @@ func (s *State) settle(winner int, evs *[]Event) { // card deep, and every seat has passed. The shortest hand takes it — and a tie // is not a win, because a win here has to be somebody actually going out. func (s *State) stuck(evs *[]Event) { - best, tied := 0, false - for seat := range s.Hands { + live := s.alive() + if len(live) == 0 { + s.lose(evs) // can't happen: a mercy kill that empties the table settles first + return + } + best, tied := live[0], false + for _, seat := range live { switch { case len(s.Hands[seat]) < len(s.Hands[best]): best, tied = seat, false @@ -731,6 +993,17 @@ func (s State) Playable() []int { } return nil } + // Under a stack, the only cards that light up are the ones that answer it. + // Everything else in the hand is dead until the bill is paid. + if s.Phase == PhaseStack { + var out []int + for i, c := range hand { + if c.CanStackOn(s.Color) { + out = append(out, i) + } + } + return out + } var out []int for i, c := range hand { if c.CanPlayOn(s.top(), s.Color) { @@ -780,10 +1053,26 @@ func (s *State) pop() (Card, bool) { return c, true } -// seatAt is the seat n places round from the one whose turn it is. +// seatAt is the seat n *live* places round from the one whose turn it is. +// +// A seat the mercy rule has killed is not there any more: it is stepped over, not +// landed on and skipped. So this counts living seats rather than doing the +// arithmetic on the index — which is the same thing in a normal game, where +// nobody is ever out, and the only thing that keeps a No Mercy table from +// handing the turn to a corpse. func (s State) seatAt(n int) int { seats := len(s.Hands) - return ((s.Turn+s.Dir*n)%seats + seats) % seats + at := s.Turn + for moved := 0; moved < n; { + at = ((at+s.Dir)%seats + seats) % seats + if at == s.Turn && !s.live(at) { + return at // nobody left alive to hand it to; the caller ends the game + } + if s.live(at) { + moved++ + } + } + return at } // advance moves the turn on n places. @@ -815,6 +1104,7 @@ func (s State) clone() State { s.Deck = append([]Card(nil), s.Deck...) s.Discard = append([]Card(nil), s.Discard...) s.Bots = append([]string(nil), s.Bots...) + s.Out = append([]bool(nil), s.Out...) return s } diff --git a/internal/games/uno/uno_test.go b/internal/games/uno/uno_test.go index 96c626f..c88a1a9 100644 --- a/internal/games/uno/uno_test.go +++ b/internal/games/uno/uno_test.go @@ -39,8 +39,11 @@ func census(s State) map[Card]int { } for _, c := range s.Discard { // A wild is stamped with the colour it was played as while it sits on the - // pile, so it counts as the wild it really is. - if c.Value == WildCard || c.Value == WildDrawFour { + // pile, so it counts as the wild it really is. This asks the face, rather + // than listing the wilds: No Mercy prints four more of them, and a census + // that didn't know about them would count a played roulette as a red card + // and report a deck that balances while the cards don't. + if c.Value.Wild() { c.Color = Wild } m[c]++ @@ -478,22 +481,37 @@ func TestQuoteIsThePayout(t *testing.T) { } // The rake comes out of the winnings, never the stake. +// +// The arithmetic is derived from the tier rather than written down. It used to be +// written down — payout 214, rake 6 — and those numbers were the 2.2× duel. When +// the tiers were re-measured and repriced, this test failed on a rake that was +// perfectly correct, which is a test asserting a *price* while claiming to assert +// a *rule*. The rule is: the house takes its cut of the profit and never touches +// the stake. That holds at any multiple. func TestRakeIsOnWinningsOnly(t *testing.T) { s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red) - s.Tier = duel() // 2.2x on 100: 220 back, 120 of it profit, 6 of that to the house + s.Tier = duel() s.Bet = 100 + + gross := int64(float64(s.Bet) * s.Tier.Base) // what the tier pays back, before the house + profit := gross - s.Bet + wantRake := int64(float64(profit) * rake) + wantPayout := s.Bet + profit - wantRake + next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) if err != nil { t.Fatalf("go out: %v", err) } - if next.Payout != 214 { - t.Errorf("payout %d, want 214 (100 stake + 120 winnings - 6 rake)", next.Payout) + if next.Payout != wantPayout { + t.Errorf("payout %d, want %d (%d stake + %d winnings - %d rake)", + next.Payout, wantPayout, s.Bet, profit, wantRake) } - if next.Rake != 6 { - t.Errorf("rake %d, want 6", next.Rake) + if next.Rake != wantRake { + t.Errorf("rake %d, want %d — and never a penny of the %d stake", + next.Rake, wantRake, s.Bet) } - if next.Net() != 114 { - t.Errorf("net %d, want 114", next.Net()) + if next.Net() != wantPayout-s.Bet { + t.Errorf("net %d, want %d", next.Net(), wantPayout-s.Bet) } }