games: no mercy, and the multiples nobody re-measured
No Mercy UNO as a rules dial on the existing tier, not a fourth table: 168 cards, draw-until-playable, draw-stacking, and the twenty-five card mercy kill. Six tiers now; a normal game never runs a line of the new code. The engine is the whole of it so far — the felt hasn't been touched, so there is no way to play this in a browser yet. Two things worth knowing. The normal tiers were mispriced, and had been for a while. They were set against a naive win rate of 43/32/27%; it now measures 40.3/29.2/23.3%. The bots got better at some point after the multiples were written down and nobody re-ran the measurement — which the plan explicitly warns about, because the bots and the tiers are a pair. Table and Full House had been charging an 18–19% house edge instead of the 8% they were meant to. All six tiers are repriced off a fresh measurement, and TestTheMultiplesAreStillPriced now fails the build if they drift again. It is the test the normal tiers never had, which is how they drifted. And No Mercy is *easier* than UNO, at every table size, so it pays less. The mercy rule does not care whose hand hits twenty-five: it kills bots too, and every bot it buries is one fewer seat that can beat you to the last card. A deck built to be merciless turns out to be merciless mostly to the table. The rake test used to assert a payout of 214, which was the 2.2x duel written down as a number. It failed on a rake that was entirely correct. It derives the arithmetic from the tier now: the rule is that the house takes its cut of the profit and never touches the stake, and that holds at any multiple. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
@@ -93,6 +93,64 @@ func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int
|
|||||||
return out
|
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
|
// 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
|
// 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.
|
// at random rather than always saying red, which would be a tell.
|
||||||
|
|||||||
244
internal/games/uno/nomercy.go
Normal file
244
internal/games/uno/nomercy.go
Normal file
@@ -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)})
|
||||||
|
}
|
||||||
421
internal/games/uno/nomercy_test.go
Normal file
421
internal/games/uno/nomercy_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ var (
|
|||||||
ErrNeedColor = errors.New("uno: pick a colour for the wild")
|
ErrNeedColor = errors.New("uno: pick a colour for the wild")
|
||||||
ErrCantPass = errors.New("uno: you can only pass on a card you just drew")
|
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")
|
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")
|
ErrUnknownMove = errors.New("uno: unknown move")
|
||||||
ErrBadBet = errors.New("uno: bet must be positive")
|
ErrBadBet = errors.New("uno: bet must be positive")
|
||||||
ErrUnknownTier = errors.New("uno: no such tier")
|
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.
|
// Value is what's printed on the face.
|
||||||
type Value uint8
|
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 (
|
const (
|
||||||
Zero Value = iota
|
Zero Value = iota
|
||||||
One
|
One
|
||||||
@@ -96,13 +102,23 @@ const (
|
|||||||
DrawTwo
|
DrawTwo
|
||||||
WildCard
|
WildCard
|
||||||
WildDrawFour
|
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",
|
var valueNames = [22]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
||||||
"skip", "reverse", "+2", "wild", "+4"}
|
"skip", "reverse", "+2", "wild", "+4",
|
||||||
|
"skip all", "+4", "discard all", "rev +4", "+6", "+10", "roulette"}
|
||||||
|
|
||||||
func (v Value) String() string {
|
func (v Value) String() string {
|
||||||
if v > WildDrawFour {
|
if v > WildRoulette {
|
||||||
return "?"
|
return "?"
|
||||||
}
|
}
|
||||||
return valueNames[v]
|
return valueNames[v]
|
||||||
@@ -111,6 +127,35 @@ func (v Value) String() string {
|
|||||||
// Action reports whether a card does something beyond being a number.
|
// Action reports whether a card does something beyond being a number.
|
||||||
func (v Value) Action() bool { return v >= Skip }
|
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
|
// Card is one card. Short JSON keys: a hand of these crosses the wire on every
|
||||||
// poll, and a state holds all 108.
|
// poll, and a state holds all 108.
|
||||||
type Card struct {
|
type Card struct {
|
||||||
@@ -119,7 +164,7 @@ type Card struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsWild reports whether the card has no colour of its own.
|
// 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
|
// 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
|
// 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
|
// 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
|
// more. This is the tier dial every other game here has, pointed at the one knob
|
||||||
// UNO actually has.
|
// 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 {
|
type Tier struct {
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Bots int `json:"bots"`
|
Bots int `json:"bots"`
|
||||||
Base float64 `json:"base"` // what going out first pays, before the rake
|
Base float64 `json:"base"` // what going out first pays, before the rake
|
||||||
Blurb string `json:"blurb"`
|
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.
|
// 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
|
// 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
|
// 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.
|
// 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{
|
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."},
|
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."},
|
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."},
|
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) {
|
func TierBySlug(slug string) (Tier, error) {
|
||||||
for _, t := range Tiers {
|
for _, t := range AllTiers() {
|
||||||
if t.Slug == slug {
|
if t.Slug == slug {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
@@ -194,6 +289,7 @@ type Phase string
|
|||||||
const (
|
const (
|
||||||
PhasePlay Phase = "play" // your turn, play or draw
|
PhasePlay Phase = "play" // your turn, play or draw
|
||||||
PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass
|
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"
|
PhaseDone Phase = "done"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -222,6 +318,13 @@ type State struct {
|
|||||||
Turn int `json:"turn"`
|
Turn int `json:"turn"`
|
||||||
Dir int `json:"dir"` // +1 clockwise, -1 after a reverse
|
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"`
|
Seed1 uint64 `json:"seed1"`
|
||||||
Seed2 uint64 `json:"seed2"`
|
Seed2 uint64 `json:"seed2"`
|
||||||
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
|
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
|
// uno a hand is down to one card
|
||||||
// reshuffle the discard goes back under
|
// reshuffle the discard goes back under
|
||||||
// settle it's over
|
// 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 (
|
const (
|
||||||
EvDeal = "deal"
|
EvDeal = "deal"
|
||||||
EvPlay = "play"
|
EvPlay = "play"
|
||||||
@@ -270,6 +381,12 @@ const (
|
|||||||
EvUno = "uno"
|
EvUno = "uno"
|
||||||
EvReshuffle = "reshuffle"
|
EvReshuffle = "reshuffle"
|
||||||
EvSettle = "settle"
|
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 —
|
// 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
|
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 (
|
const (
|
||||||
MovePlay = "play"
|
MovePlay = "play"
|
||||||
MoveDraw = "draw"
|
MoveDraw = "draw"
|
||||||
MovePass = "pass"
|
MovePass = "pass"
|
||||||
|
MoveTake = "take"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New deals a game: a shuffled deck, seven each, and a card turned over.
|
// 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)
|
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] })
|
rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
|
||||||
|
|
||||||
s := State{
|
s := State{
|
||||||
@@ -313,6 +433,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
|
|||||||
}
|
}
|
||||||
|
|
||||||
seats := t.Bots + 1
|
seats := t.Bots + 1
|
||||||
|
s.Out = make([]bool, seats)
|
||||||
s.Hands = make([][]Card, seats)
|
s.Hands = make([][]Card, seats)
|
||||||
for i := range s.Hands {
|
for i := range s.Hands {
|
||||||
s.Hands[i] = make([]Card, 0, HandSize)
|
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)
|
evs, err = next.playerDraws(rng)
|
||||||
case MovePass:
|
case MovePass:
|
||||||
evs, err = next.playerPasses()
|
evs, err = next.playerPasses()
|
||||||
|
case MoveTake:
|
||||||
|
evs, err = next.playerTakes(rng)
|
||||||
default:
|
default:
|
||||||
return s, nil, ErrUnknownMove
|
return s, nil, ErrUnknownMove
|
||||||
}
|
}
|
||||||
@@ -401,7 +524,15 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
|
|||||||
return nil, ErrMustPlayNow
|
return nil, ErrMustPlayNow
|
||||||
}
|
}
|
||||||
card := hand[m.Index]
|
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
|
return nil, ErrCantPlay
|
||||||
}
|
}
|
||||||
if card.IsWild() && !m.Color.Playable() {
|
if card.IsWild() && !m.Color.Playable() {
|
||||||
@@ -415,14 +546,27 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
|
|||||||
return evs, nil
|
return evs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// playerDraws takes one off the deck. If it can be played you get the choice —
|
// playerDraws takes cards off the deck.
|
||||||
// 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.
|
// 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) {
|
func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) {
|
||||||
if s.Phase == PhaseDrawn {
|
if s.Phase == PhaseDrawn {
|
||||||
return nil, ErrMustPlayNow // you already drew; play it or pass
|
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
|
var evs []Event
|
||||||
|
|
||||||
|
if !s.Tier.NoMercy {
|
||||||
drawn := s.deal(You, 1, false, &evs, rng)
|
drawn := s.deal(You, 1, false, &evs, rng)
|
||||||
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
|
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
|
||||||
s.Phase = PhaseDrawn
|
s.Phase = PhaseDrawn
|
||||||
@@ -433,11 +577,47 @@ func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) {
|
|||||||
return evs, nil
|
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.
|
// 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) {
|
func (s *State) playerPasses() ([]Event, error) {
|
||||||
if s.Phase != PhaseDrawn {
|
if s.Phase != PhaseDrawn {
|
||||||
return nil, ErrCantPass
|
return nil, ErrCantPass
|
||||||
}
|
}
|
||||||
|
if s.Tier.NoMercy {
|
||||||
|
return nil, ErrMustPlayNow
|
||||||
|
}
|
||||||
s.Phase = PhasePlay
|
s.Phase = PhasePlay
|
||||||
s.advance(1)
|
s.advance(1)
|
||||||
return []Event{{Kind: EvPass, Seat: You}}, nil
|
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.
|
// botTurn plays one bot's turn.
|
||||||
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
|
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
s.botPlays(seat, card, idx, evs, rng)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng)
|
card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng)
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
// Nothing playable: draw one, and play it if it happens to go.
|
// 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)
|
drawn := s.deal(seat, 1, false, evs, rng)
|
||||||
if len(drawn) != 1 || !drawn[0].CanPlayOn(s.top(), s.Color) {
|
if len(drawn) != 1 {
|
||||||
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
||||||
s.advance(1)
|
s.advance(1)
|
||||||
return
|
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
|
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]
|
hand := s.Hands[seat]
|
||||||
s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...)
|
s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...)
|
||||||
|
|
||||||
color := card.Color
|
color := card.Color
|
||||||
if card.IsWild() {
|
if card.IsWild() {
|
||||||
color = botColor(s.Hands[seat], rng)
|
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.discard(seat, card, color, evs)
|
||||||
s.after(seat, card, evs, rng)
|
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
|
// 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.
|
// out, because the cage won't let you leave a hand half-played.
|
||||||
func (s State) stalled() bool {
|
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 {
|
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
|
return false // there is a card to draw, or a discard to make one out of
|
||||||
}
|
}
|
||||||
for _, hand := range s.Hands {
|
for _, seat := range s.alive() {
|
||||||
for _, c := range hand {
|
for _, c := range s.Hands[seat] {
|
||||||
if c.CanPlayOn(s.top(), s.Color) {
|
if c.CanPlayOn(s.top(), s.Color) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -532,6 +752,24 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
|
|||||||
}
|
}
|
||||||
s.Phase = PhasePlay
|
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 {
|
switch card.Value {
|
||||||
case Skip:
|
case Skip:
|
||||||
victim := s.seatAt(1)
|
victim := s.seatAt(1)
|
||||||
@@ -539,9 +777,37 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
|
|||||||
s.advance(2)
|
s.advance(2)
|
||||||
|
|
||||||
case Reverse:
|
case Reverse:
|
||||||
// Two at the table and a reverse has nobody to hand the turn back to, so it
|
s.flip(seat, evs)
|
||||||
// is a skip — which, with two players, means you go again.
|
|
||||||
if len(s.Hands) == 2 {
|
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.advance(1)
|
||||||
|
|
||||||
|
case WildRoulette:
|
||||||
|
s.roulette(s.seatAt(1), s.Color, evs, rng)
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.advance(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)})
|
*evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)})
|
||||||
s.advance(2)
|
s.advance(2)
|
||||||
return
|
return
|
||||||
@@ -549,21 +815,12 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
|
|||||||
s.Dir = -s.Dir
|
s.Dir = -s.Dir
|
||||||
*evs = append(*evs, Event{Kind: EvReverse, Seat: seat})
|
*evs = append(*evs, Event{Kind: EvReverse, Seat: seat})
|
||||||
s.advance(1)
|
s.advance(1)
|
||||||
|
|
||||||
case DrawTwo:
|
|
||||||
s.punish(s.seatAt(1), 2, evs, rng)
|
|
||||||
|
|
||||||
case WildDrawFour:
|
|
||||||
s.punish(s.seatAt(1), 4, evs, rng)
|
|
||||||
|
|
||||||
default:
|
|
||||||
s.advance(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// punish makes the next seat eat a draw card and lose its turn. No stacking: a
|
// punish makes the next seat eat a draw card and lose its turn. This is the
|
||||||
// +2 played onto a +2 is a house rule, and the one this table plays is the one
|
// normal game's rule: no stacking, because a +2 played onto a +2 is a house rule
|
||||||
// on the box.
|
// 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) {
|
func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) {
|
||||||
s.deal(victim, n, true, evs, rng)
|
s.deal(victim, n, true, evs, rng)
|
||||||
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim})
|
*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
|
// 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.
|
// is not a win, because a win here has to be somebody actually going out.
|
||||||
func (s *State) stuck(evs *[]Event) {
|
func (s *State) stuck(evs *[]Event) {
|
||||||
best, tied := 0, false
|
live := s.alive()
|
||||||
for seat := range s.Hands {
|
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 {
|
switch {
|
||||||
case len(s.Hands[seat]) < len(s.Hands[best]):
|
case len(s.Hands[seat]) < len(s.Hands[best]):
|
||||||
best, tied = seat, false
|
best, tied = seat, false
|
||||||
@@ -731,6 +993,17 @@ func (s State) Playable() []int {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
var out []int
|
||||||
for i, c := range hand {
|
for i, c := range hand {
|
||||||
if c.CanPlayOn(s.top(), s.Color) {
|
if c.CanPlayOn(s.top(), s.Color) {
|
||||||
@@ -780,10 +1053,26 @@ func (s *State) pop() (Card, bool) {
|
|||||||
return c, true
|
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 {
|
func (s State) seatAt(n int) int {
|
||||||
seats := len(s.Hands)
|
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.
|
// advance moves the turn on n places.
|
||||||
@@ -815,6 +1104,7 @@ func (s State) clone() State {
|
|||||||
s.Deck = append([]Card(nil), s.Deck...)
|
s.Deck = append([]Card(nil), s.Deck...)
|
||||||
s.Discard = append([]Card(nil), s.Discard...)
|
s.Discard = append([]Card(nil), s.Discard...)
|
||||||
s.Bots = append([]string(nil), s.Bots...)
|
s.Bots = append([]string(nil), s.Bots...)
|
||||||
|
s.Out = append([]bool(nil), s.Out...)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,11 @@ func census(s State) map[Card]int {
|
|||||||
}
|
}
|
||||||
for _, c := range s.Discard {
|
for _, c := range s.Discard {
|
||||||
// A wild is stamped with the colour it was played as while it sits on the
|
// 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.
|
// pile, so it counts as the wild it really is. This asks the face, rather
|
||||||
if c.Value == WildCard || c.Value == WildDrawFour {
|
// 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
|
c.Color = Wild
|
||||||
}
|
}
|
||||||
m[c]++
|
m[c]++
|
||||||
@@ -478,22 +481,37 @@ func TestQuoteIsThePayout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The rake comes out of the winnings, never the stake.
|
// 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) {
|
func TestRakeIsOnWinningsOnly(t *testing.T) {
|
||||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
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
|
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})
|
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("go out: %v", err)
|
t.Fatalf("go out: %v", err)
|
||||||
}
|
}
|
||||||
if next.Payout != 214 {
|
if next.Payout != wantPayout {
|
||||||
t.Errorf("payout %d, want 214 (100 stake + 120 winnings - 6 rake)", next.Payout)
|
t.Errorf("payout %d, want %d (%d stake + %d winnings - %d rake)",
|
||||||
|
next.Payout, wantPayout, s.Bet, profit, wantRake)
|
||||||
}
|
}
|
||||||
if next.Rake != 6 {
|
if next.Rake != wantRake {
|
||||||
t.Errorf("rake %d, want 6", next.Rake)
|
t.Errorf("rake %d, want %d — and never a penny of the %d stake",
|
||||||
|
next.Rake, wantRake, s.Bet)
|
||||||
}
|
}
|
||||||
if next.Net() != 114 {
|
if next.Net() != wantPayout-s.Bet {
|
||||||
t.Errorf("net %d, want 114", next.Net())
|
t.Errorf("net %d, want %d", next.Net(), wantPayout-s.Bet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user