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
245 lines
8.1 KiB
Go
245 lines
8.1 KiB
Go
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)})
|
|
}
|