UNO, played for chips. You stake once, sit down against one to three bots, and going out first pays the table: 2.2x heads up, 3.6x against a full house. Anybody else going out first takes the stake. The table size is the tier, because it is the only dial UNO has. The bots move inside ApplyMove. A game with opponents is normally where you reach for a socket, and the plan says solo UNO must not — so one request plays your move and every bot turn behind it, and hands back the whole lap as a script the felt plays in order. The RNG is in the state rather than an argument to it: the bots choose and a spent deck reshuffles, so the engine needs randomness mid-game, and there is no generator alive across requests to pass in. The seed rides in the state and each step derives its own. The game still replays exactly as it fell. The zero value of Color is Wild, and that is the whole point of it: a wild played with the colour field missing from the JSON must be refused, not quietly played as a red one. It was red for an hour. The browser never sees a bot's card — not the deck, not a hand, not the face of a card a bot drew, which is most of the deck. Seats cross the wire as a name and a count. The multiples are measured, not guessed: playing the first legal card you hold wins 43/32/27% of the time against these bots, so the tiers price that to lose about 8% a game and leave good play worth roughly the house's edge. PeteFX.flyNode is the throw with the chip taken out of it, so a card can be thrown across the felt the same way. fly() is now that with a chip in it. Not yet driven in a browser, which in this room means not yet finished. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
133 lines
4.2 KiB
Go
133 lines
4.2 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
|
|
}
|
|
|
|
// 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",
|
|
}
|