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