Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.
The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.
The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
183 lines
6.0 KiB
Go
183 lines
6.0 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
|
|
}
|
|
|
|
// botPool are the regulars a bot seat is named from. Flavour, and load-bearing
|
|
// flavour: "Kiwi played a +4" is a table, "Bot 2 played a +4" is a test fixture.
|
|
// More names than a full table, so no two chairs ever share one.
|
|
var botPool = []string{
|
|
"Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan",
|
|
"Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine",
|
|
}
|