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
191 lines
6.1 KiB
Go
191 lines
6.1 KiB
Go
package uno
|
|
|
|
import "math/rand/v2"
|
|
|
|
// The bots.
|
|
//
|
|
// Lifted from the ones gogobee's UNO plays in Matrix, which are genuinely decent
|
|
// company — they hold their wild draw fours back until you're close to going
|
|
// out, they follow the colour in play when they can, and they get out of the way
|
|
// of their own hand. Two things changed on the way over:
|
|
//
|
|
// The RNG is threaded. gogobee's bots reach for the package global, which is why
|
|
// its own tests can only assert that a bot played *something* legal. These take
|
|
// the game's generator, so a bot's choice is part of what a seed replays.
|
|
//
|
|
// They are not the same bot at every table. A single deterministic policy is a
|
|
// puzzle: play round it once and it never surprises you again. So the bot takes
|
|
// the best card it sees most of the time, and now and then takes the second best
|
|
// — enough that you cannot count what it is holding by what it plays.
|
|
|
|
// botSlip is how often a bot takes its second choice instead of its first. Low
|
|
// enough that it still plays well, high enough that it isn't a lookup table.
|
|
const botSlip = 6 // one turn in six
|
|
|
|
// botPick chooses a card to play, or reports -1 when the bot has nothing legal.
|
|
func botPick(hand []Card, top Card, topColor Color, minOpponent int, rng *rand.Rand) (Card, int) {
|
|
var playable []int
|
|
for i, c := range hand {
|
|
if c.CanPlayOn(top, topColor) {
|
|
playable = append(playable, i)
|
|
}
|
|
}
|
|
if len(playable) == 0 {
|
|
return Card{}, -1
|
|
}
|
|
|
|
order := botRank(hand, topColor, playable, minOpponent)
|
|
pick := order[0]
|
|
if len(order) > 1 && rng.IntN(botSlip) == 0 {
|
|
pick = order[1]
|
|
}
|
|
return hand[pick], pick
|
|
}
|
|
|
|
// botRank sorts the playable cards best-first, by the bot's own priorities.
|
|
//
|
|
// The shape of it: hurt the leader if there is one, otherwise get rid of
|
|
// something useful and keep the wilds back. A wild draw four spent early is a
|
|
// wild draw four you don't have when somebody is sitting on one card.
|
|
func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int {
|
|
var wd4, wilds, actions, numbers []int
|
|
for _, i := range playable {
|
|
switch c := hand[i]; {
|
|
case c.Value == WildDrawFour:
|
|
wd4 = append(wd4, i)
|
|
case c.Value == WildCard:
|
|
wilds = append(wilds, i)
|
|
case c.Value.Action():
|
|
actions = append(actions, i)
|
|
default:
|
|
numbers = append(numbers, i)
|
|
}
|
|
}
|
|
|
|
// Following the colour in play is worth more than not, because it keeps the
|
|
// bot's hand flexible — so within each group, the cards already in colour go
|
|
// first.
|
|
inColorFirst := func(idx []int) []int {
|
|
var same, other []int
|
|
for _, i := range idx {
|
|
if hand[i].Color == topColor {
|
|
same = append(same, i)
|
|
} else {
|
|
other = append(other, i)
|
|
}
|
|
}
|
|
return append(same, other...)
|
|
}
|
|
|
|
var out []int
|
|
if minOpponent >= 0 && minOpponent <= 2 {
|
|
// Somebody is about to go out. This is what the +4 was being saved for.
|
|
out = append(out, wd4...)
|
|
out = append(out, inColorFirst(actions)...)
|
|
out = append(out, wilds...)
|
|
out = append(out, inColorFirst(numbers)...)
|
|
return out
|
|
}
|
|
out = append(out, inColorFirst(actions)...)
|
|
out = append(out, inColorFirst(numbers)...)
|
|
out = append(out, wilds...)
|
|
out = append(out, wd4...)
|
|
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.
|
|
func botColor(hand []Card, rng *rand.Rand) Color {
|
|
counts := [5]int{}
|
|
for _, c := range hand {
|
|
if c.Color.Playable() {
|
|
counts[c.Color]++
|
|
}
|
|
}
|
|
best, bestN := Wild, 0
|
|
for col := Red; col <= Green; col++ {
|
|
if counts[col] > bestN {
|
|
best, bestN = col, counts[col]
|
|
}
|
|
}
|
|
if bestN == 0 {
|
|
return Red + Color(rng.IntN(4))
|
|
}
|
|
return best
|
|
}
|
|
|
|
// botNames deals the bots their names. Flavour, and load-bearing flavour: "Kiwi
|
|
// played a +4" is a table, "Bot 2 played a +4" is a test fixture.
|
|
func botNames(n int, rng *rand.Rand) []string {
|
|
pool := append([]string(nil), botPool...)
|
|
rng.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
|
|
if n > len(pool) {
|
|
n = len(pool)
|
|
}
|
|
return pool[:n]
|
|
}
|
|
|
|
var botPool = []string{
|
|
"Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan",
|
|
"Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine",
|
|
}
|