Files
Pete/internal/games/uno/bot.go
prosolis aca523e511 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
2026-07-14 10:07:55 -07:00

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",
}