games: a table of bots you have to beat to the last card

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
This commit is contained in:
prosolis
2026-07-14 07:07:17 -07:00
parent 3e9b93af55
commit 79c857023f
14 changed files with 3438 additions and 17 deletions

132
internal/games/uno/bot.go Normal file
View File

@@ -0,0 +1,132 @@
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",
}

810
internal/games/uno/uno.go Normal file
View File

@@ -0,0 +1,810 @@
// Package uno is a pure UNO engine, played for chips against bots.
//
// Same seam as the other four tables: ApplyMove(state, move) (state, events,
// error), where an error means the move was illegal and nothing else. No HTTP,
// no timers, no sockets, no player names off the wire. The state is a plain
// value, so a game survives a redeploy and replays from its seed.
//
// Two things make UNO different from the tables already on the felt.
//
// The bots move inside ApplyMove. A turn-based game against opponents is
// normally where you reach for a socket, and the plan says solo UNO must not:
// so one call from the browser plays the player's move *and* every bot turn that
// follows it, and hands back the whole run as events. The table animates them in
// order. The browser is never waiting on the server to think of something.
//
// The RNG is in the state, not an argument. The bots make choices and a spent
// deck gets reshuffled, so the engine needs randomness mid-game — but a reducer
// that takes an rng is a reducer whose caller has to keep one alive across
// requests, and there isn't one: every move is a fresh process for all it knows.
// So the seed rides in the state (which never leaves the server; the deck is in
// there too) and each step derives its own generator from seed and step count.
// Value in, value out, and the game still replays exactly as it was dealt.
package uno
import (
"errors"
"math"
"math/rand/v2"
)
// Errors an illegal move can produce.
var (
ErrGameOver = errors.New("uno: the game is already over")
ErrNotYourTurn = errors.New("uno: it isn't your turn")
ErrNoSuchCard = errors.New("uno: you don't have that card")
ErrCantPlay = errors.New("uno: that card can't go on this one")
ErrNeedColor = errors.New("uno: pick a colour for the wild")
ErrCantPass = errors.New("uno: you can only pass on a card you just drew")
ErrMustPlayNow = errors.New("uno: play the card you drew, or pass")
ErrUnknownMove = errors.New("uno: unknown move")
ErrBadBet = errors.New("uno: bet must be positive")
ErrUnknownTier = errors.New("uno: no such tier")
)
// You are always seat zero. The bots are the seats after you.
const You = 0
// HandSize is the deal. Seven each, as printed on the box.
const HandSize = 7
// Color is a card's colour. Wild has none until it's played.
//
// Wild is deliberately the zero value. A wild played with no colour named is the
// one move in this game that must never be allowed to mean something, and a
// browser that leaves `color` out of the JSON sends a zero — so the zero has to
// be "no colour", not red. It was red for about an hour, and a wild with the
// field missing quietly went down as a red one.
type Color uint8
const (
Wild Color = iota
Red
Blue
Yellow
Green
)
var colorNames = [5]string{"wild", "red", "blue", "yellow", "green"}
func (c Color) String() string {
if c > Green {
return "?"
}
return colorNames[c]
}
// Playable reports whether a colour is one a wild may name — Wild itself isn't.
func (c Color) Playable() bool { return c >= Red && c <= Green }
// Value is what's printed on the face.
type Value uint8
const (
Zero Value = iota
One
Two
Three
Four
Five
Six
Seven
Eight
Nine
Skip
Reverse
DrawTwo
WildCard
WildDrawFour
)
var valueNames = [15]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"skip", "reverse", "+2", "wild", "+4"}
func (v Value) String() string {
if v > WildDrawFour {
return "?"
}
return valueNames[v]
}
// Action reports whether a card does something beyond being a number.
func (v Value) Action() bool { return v >= Skip }
// Card is one card. Short JSON keys: a hand of these crosses the wire on every
// poll, and a state holds all 108.
type Card struct {
Color Color `json:"c"`
Value Value `json:"v"`
}
// IsWild reports whether the card has no colour of its own.
func (c Card) IsWild() bool { return c.Value == WildCard || c.Value == WildDrawFour }
// CanPlayOn is the whole rule of UNO: match the colour in play, or match the
// face, or be a wild. Note it takes the colour *in play* rather than the top
// card's own colour — after a wild those are different, and the one that counts
// is the colour that was named.
func (c Card) CanPlayOn(top Card, topColor Color) bool {
if c.IsWild() {
return true
}
return c.Color == topColor || c.Value == top.Value
}
// NewDeck builds the 108: one zero and two each of 1-9, skip, reverse and +2 in
// every colour, plus four wilds and four wild draw fours. Unshuffled — Deal
// shuffles, and a test wants the fixed order.
func NewDeck() []Card {
d := make([]Card, 0, 108)
for _, col := range []Color{Red, Blue, Yellow, Green} {
d = append(d, Card{col, Zero})
for v := One; v <= DrawTwo; v++ {
d = append(d, Card{col, v}, Card{col, v})
}
}
for i := 0; i < 4; i++ {
d = append(d, Card{Wild, WildCard}, Card{Wild, WildDrawFour})
}
return d
}
// Tier is a table, and the table size *is* the difficulty. More bots is a longer
// shot — three of them going out before you is three ways to lose — so it pays
// more. This is the tier dial every other game here has, pointed at the one knob
// UNO actually has.
type Tier struct {
Slug string `json:"slug"`
Name string `json:"name"`
Bots int `json:"bots"`
Base float64 `json:"base"` // what going out first pays, before the rake
Blurb string `json:"blurb"`
}
// Tiers are the three tables.
//
// The multiples are not guesses. A player who simply plays the first legal card
// they hold — which is a real strategy, and a bad one — goes out first 43% of
// the time heads up, 32% at three seats and 27% at four. These pay a little
// under what that costs, so bad play loses slowly and good play (holding the
// wilds, dumping the colour you're long in, counting what a bot picked up) is
// worth roughly the house's edge. That is the game being about something.
var Tiers = []Tier{
{Slug: "duel", Name: "Duel", Bots: 1, Base: 2.2,
Blurb: "One bot, head to head. A reverse is a skip with two at the table."},
{Slug: "table", Name: "Table", Bots: 2, Base: 2.9,
Blurb: "Two bots. Twice the +4s pointed at you."},
{Slug: "full", Name: "Full House", Bots: 3, Base: 3.6,
Blurb: "Three bots, and any of them going out first takes your stake."},
}
// TierBySlug finds a tier by the name the browser sent.
func TierBySlug(slug string) (Tier, error) {
for _, t := range Tiers {
if t.Slug == slug {
return t, nil
}
}
return Tier{}, ErrUnknownTier
}
// Phase is where the game is.
type Phase string
const (
PhasePlay Phase = "play" // your turn, play or draw
PhaseDrawn Phase = "drawn" // you drew a card you can play: play it or pass
PhaseDone Phase = "done"
)
// Outcome is how it ended.
type Outcome string
const (
OutcomeNone Outcome = ""
OutcomeWon Outcome = "won" // you went out first
OutcomeLost Outcome = "lost" // a bot did
OutcomeStuck Outcome = "stuck" // nobody can move and there are no cards left
)
// Won reports whether this outcome pays.
func (o Outcome) Won() bool { return o == OutcomeWon }
// State is one game. The bots' hands and the deck are in here, which is exactly
// why this value never crosses the wire — the browser gets counts instead.
type State struct {
Tier Tier `json:"tier"`
Hands [][]Card `json:"hands"` // seat 0 is you; the rest are bots
Bots []string `json:"bots"` // their names, one per bot seat (seat i is Bots[i-1])
Deck []Card `json:"deck"`
Discard []Card `json:"discard"` // the top card is the last one
Color Color `json:"color"` // the colour in play, which a wild renames
Turn int `json:"turn"`
Dir int `json:"dir"` // +1 clockwise, -1 after a reverse
Seed1 uint64 `json:"seed1"`
Seed2 uint64 `json:"seed2"`
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
RakePct float64 `json:"rake_pct"`
Bet int64 `json:"bet"`
Phase Phase `json:"phase"`
Outcome Outcome `json:"outcome"`
Payout int64 `json:"payout"`
Rake int64 `json:"rake"`
}
// Event is something the table animates. The bots' turns arrive as a run of
// these on the back of the player's own move, and the felt plays them in order.
type Event struct {
Kind string `json:"kind"` // see below
Seat int `json:"seat"` // who it happened to
Card *Card `json:"card,omitempty"` // the card played, or the one *you* drew
Color Color `json:"color,omitempty"` // the colour now in play, on a wild
N int `json:"n,omitempty"` // how many cards were drawn
Left int `json:"left,omitempty"` // cards left in that seat's hand afterwards
Text string `json:"text,omitempty"`
}
// The kinds an Event comes in.
//
// deal the hands are dealt and the first card turned over
// play a card goes on the pile
// wild the colour was named (rides with the play it belongs to)
// draw cards come off the deck. A bot's are face down: Card is nil.
// forced the same, but not by choice — a +2 or a +4 landed on them
// pass the turn moves on with nothing played
// skip a seat loses its turn
// reverse the direction flips
// uno a hand is down to one card
// reshuffle the discard goes back under
// settle it's over
const (
EvDeal = "deal"
EvPlay = "play"
EvDraw = "draw"
EvForced = "forced"
EvPass = "pass"
EvSkip = "skip"
EvReverse = "reverse"
EvUno = "uno"
EvReshuffle = "reshuffle"
EvSettle = "settle"
)
// Move is what the player sends: play this card, take one off the deck, or —
// having taken one you can play — decline to play it.
type Move struct {
Kind string `json:"kind"` // "play" | "draw" | "pass"
Index int `json:"index"` // which card of your hand, for a play
Color Color `json:"color"` // the colour you name, for a wild
}
// Move kinds.
const (
MovePlay = "play"
MoveDraw = "draw"
MovePass = "pass"
)
// New deals a game: a shuffled deck, seven each, and a card turned over.
//
// The turned card is dealt until it's a number. The official rules have the
// first player eat a +2 that lands there, and turn a wild into a colour vote —
// both of which are a game that opens by doing something to you before you have
// touched it. A number card up top is the same game, minus the paperwork.
func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
if bet <= 0 {
return State{}, nil, ErrBadBet
}
if t.Bots < 1 {
return State{}, nil, ErrUnknownTier
}
rng := stepRNG(seed1, seed2, 0)
deck := NewDeck()
rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
s := State{
Tier: t, Deck: deck, Dir: 1, Turn: You,
Seed1: seed1, Seed2: seed2,
RakePct: rakePct, Bet: bet, Phase: PhasePlay,
Bots: botNames(t.Bots, rng),
}
seats := t.Bots + 1
s.Hands = make([][]Card, seats)
for i := range s.Hands {
s.Hands[i] = make([]Card, 0, HandSize)
}
for c := 0; c < HandSize; c++ {
for seat := 0; seat < seats; seat++ {
card, _ := s.pop()
s.Hands[seat] = append(s.Hands[seat], card)
}
}
// Turn cards over until one of them is a plain number.
for {
card, ok := s.pop()
if !ok {
return State{}, nil, errors.New("uno: deck ran out on the deal") // 108 cards; unreachable
}
if card.Value.Action() {
s.Discard = append(s.Discard, card) // it stays buried, out of play
continue
}
s.Discard = append(s.Discard, card)
s.Color = card.Color
break
}
return s, []Event{{Kind: EvDeal, Card: s.topPtr(), Color: s.Color}}, nil
}
// ApplyMove is the engine. Your move goes in; your move, and every bot turn it
// hands off to, comes back out. An error means the move was illegal and the
// caller's state is untouched.
func ApplyMove(s State, m Move) (State, []Event, error) {
if s.Phase == PhaseDone {
return s, nil, ErrGameOver
}
if s.Turn != You {
// Can't happen through this door — ApplyMove always runs the bots out
// before it returns — but a state restored from a row that predates a bug
// shouldn't wedge the player, it should say what's wrong.
return s, nil, ErrNotYourTurn
}
next := s.clone()
next.Step++
rng := stepRNG(next.Seed1, next.Seed2, next.Step)
var evs []Event
var err error
switch m.Kind {
case MovePlay:
evs, err = next.playerPlays(m, rng)
case MoveDraw:
evs, err = next.playerDraws(rng)
case MovePass:
evs, err = next.playerPasses()
default:
return s, nil, ErrUnknownMove
}
if err != nil {
return s, nil, err // the caller's state, untouched
}
// The bots take their turns on the back of yours, and the whole run comes
// back as one script. This is the reason solo UNO needs no socket.
next.runBots(&evs, rng)
return next, evs, nil
}
// playerPlays puts one of your cards on the pile.
func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
hand := s.Hands[You]
if m.Index < 0 || m.Index >= len(hand) {
return nil, ErrNoSuchCard
}
// Having drawn a playable card, the only card you may play is that one. Being
// allowed to draw and *then* play something else would make drawing a free
// look at the deck with no cost attached.
if s.Phase == PhaseDrawn && m.Index != len(hand)-1 {
return nil, ErrMustPlayNow
}
card := hand[m.Index]
if !card.CanPlayOn(s.top(), s.Color) {
return nil, ErrCantPlay
}
if card.IsWild() && !m.Color.Playable() {
return nil, ErrNeedColor
}
s.Hands[You] = append(hand[:m.Index:m.Index], hand[m.Index+1:]...)
var evs []Event
s.discard(You, card, m.Color, &evs)
s.after(You, card, &evs, rng)
return evs, nil
}
// playerDraws takes one off the deck. If it can be played you get the choice —
// that's PhaseDrawn, and it's the only place the turn pauses mid-move. If it
// can't, the turn passes on the spot: there is nothing to decide.
func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) {
if s.Phase == PhaseDrawn {
return nil, ErrMustPlayNow // you already drew; play it or pass
}
var evs []Event
drawn := s.deal(You, 1, false, &evs, rng)
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
s.Phase = PhaseDrawn
return evs, nil
}
evs = append(evs, Event{Kind: EvPass, Seat: You})
s.advance(1)
return evs, nil
}
// playerPasses declines the card you just drew.
func (s *State) playerPasses() ([]Event, error) {
if s.Phase != PhaseDrawn {
return nil, ErrCantPass
}
s.Phase = PhasePlay
s.advance(1)
return []Event{{Kind: EvPass, Seat: You}}, nil
}
// runBots plays every bot turn between you and your next one. It stops the
// moment the game is over or the turn comes back round.
func (s *State) runBots(evs *[]Event, rng *rand.Rand) {
// A bot that can neither play nor draw passes, and if every seat does that in
// a row the game is stuck: the deck is spent and the discard has nothing under
// its top card to become a new one. Rare, but a game that can't end is worse
// than one that ends badly, so it ends — see stuck().
passes := 0
for s.Phase != PhaseDone && s.Turn != You {
if s.botTurn(s.Turn, evs, rng) {
passes++
if passes > len(s.Hands) {
s.stuck(evs)
return
}
continue
}
passes = 0
}
}
// botTurn plays one bot's turn. It reports whether the bot passed with nothing.
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) (passed bool) {
card, idx := botPick(s.Hands[seat], s.top(), s.Color, s.minOpponent(seat), rng)
if idx < 0 {
// Nothing playable: draw one, and play it if it happens to go.
drawn := s.deal(seat, 1, false, evs, rng)
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
card, idx = drawn[0], len(s.Hands[seat])-1
} else {
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
s.advance(1)
return len(drawn) == 0 // a bot that couldn't even draw is a stuck table
}
}
hand := s.Hands[seat]
s.Hands[seat] = append(hand[:idx:idx], hand[idx+1:]...)
color := card.Color
if card.IsWild() {
color = botColor(s.Hands[seat], rng)
}
s.discard(seat, card, color, evs)
s.after(seat, card, evs, rng)
return false
}
// discard puts a card on the pile and names the colour now in play.
//
// A wild is stamped with the colour it was played as, so the pile shows what was
// called rather than a black card and a note beside it. That stamp is undone if
// the card ever comes back out — see reshuffle, which would otherwise bleed four
// extra reds into the deck.
func (s *State) discard(seat int, card Card, color Color, evs *[]Event) {
if card.IsWild() {
s.Color = color
card.Color = color
} else {
s.Color = card.Color
}
s.Discard = append(s.Discard, card)
e := Event{Kind: EvPlay, Seat: seat, Card: &card, Color: s.Color, Left: len(s.Hands[seat])}
*evs = append(*evs, e)
if len(s.Hands[seat]) == 1 {
*evs = append(*evs, Event{Kind: EvUno, Seat: seat})
}
}
// after resolves what the card just played does, and moves the turn on. It is
// the one place the rules of skip, reverse and the draw cards live.
func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
if len(s.Hands[seat]) == 0 {
s.settle(seat, evs)
return
}
s.Phase = PhasePlay
switch card.Value {
case Skip:
victim := s.seatAt(1)
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim})
s.advance(2)
case Reverse:
// Two at the table and a reverse has nobody to hand the turn back to, so it
// is a skip — which, with two players, means you go again.
if len(s.Hands) == 2 {
*evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)})
s.advance(2)
return
}
s.Dir = -s.Dir
*evs = append(*evs, Event{Kind: EvReverse, Seat: seat})
s.advance(1)
case DrawTwo:
s.punish(s.seatAt(1), 2, evs, rng)
case WildDrawFour:
s.punish(s.seatAt(1), 4, evs, rng)
default:
s.advance(1)
}
}
// punish makes the next seat eat a draw card and lose its turn. No stacking: a
// +2 played onto a +2 is a house rule, and the one this table plays is the one
// on the box.
func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) {
s.deal(victim, n, true, evs, rng)
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim})
s.advance(2)
}
// deal gives a seat n cards, reshuffling the discard back under the deck if it
// runs dry. The cards it hands back are the ones actually drawn — which can be
// fewer than asked for, when there is nothing left anywhere to draw.
//
// A bot's cards go face down: the event carries the count, never the card. The
// only hand whose faces cross the wire is yours.
func (s *State) deal(seat, n int, forced bool, evs *[]Event, rng *rand.Rand) []Card {
got := make([]Card, 0, n)
for i := 0; i < n; i++ {
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
break
}
c, ok := s.pop()
if !ok {
break
}
s.Hands[seat] = append(s.Hands[seat], c)
got = append(got, c)
}
if len(got) == 0 {
return got
}
kind := EvDraw
if forced {
kind = EvForced
}
e := Event{Kind: kind, Seat: seat, N: len(got), Left: len(s.Hands[seat])}
if seat == You && len(got) == 1 {
c := got[0]
e.Card = &c // your own card, and only yours, comes face up
}
*evs = append(*evs, e)
return got
}
// reshuffle turns the discard back into a deck, keeping the card in play on top
// of the pile. It reports whether there was anything to reshuffle.
func (s *State) reshuffle(evs *[]Event, rng *rand.Rand) bool {
if len(s.Discard) < 2 {
return false // nothing under the top card: the table is out of cards
}
top := s.Discard[len(s.Discard)-1]
rest := append([]Card(nil), s.Discard[:len(s.Discard)-1]...)
rng.Shuffle(len(rest), func(i, j int) { rest[i], rest[j] = rest[j], rest[i] })
// A wild goes back in as a wild. It was played as a colour, and leaving that
// colour stamped on it would quietly bleed four extra reds into the deck.
for i := range rest {
if rest[i].Value == WildCard || rest[i].Value == WildDrawFour {
rest[i].Color = Wild
}
}
s.Deck = rest
s.Discard = []Card{top}
*evs = append(*evs, Event{Kind: EvReshuffle, N: len(rest)})
return true
}
// settle ends the game. Going out first pays the tier; anyone else going out
// takes the stake. The rake, as everywhere in this casino, comes out of the
// winnings and never out of the stake.
func (s *State) settle(winner int, evs *[]Event) {
s.Phase = PhaseDone
if winner == You {
s.Outcome = OutcomeWon
s.Payout = s.Pays()
s.Rake = s.rakeNow()
} else {
s.Outcome = OutcomeLost
s.Payout = 0
}
*evs = append(*evs, Event{Kind: EvSettle, Seat: winner, Text: string(s.Outcome)})
}
// stuck ends a game nobody can move in: the deck is spent, the discard is one
// card deep, and every seat has passed. The shortest hand takes it — and a tie
// is not a win, because a win here has to be somebody actually going out.
func (s *State) stuck(evs *[]Event) {
best, tied := 0, false
for seat := range s.Hands {
switch {
case len(s.Hands[seat]) < len(s.Hands[best]):
best, tied = seat, false
case seat != best && len(s.Hands[seat]) == len(s.Hands[best]):
tied = true
}
}
s.Phase = PhaseDone
if best == You && !tied {
s.Outcome = OutcomeWon
s.Payout = s.Pays()
s.Rake = s.rakeNow()
} else {
s.Outcome = OutcomeStuck
s.Payout = 0
}
*evs = append(*evs, Event{Kind: EvSettle, Seat: best, Text: string(s.Outcome)})
}
// Pays is what going out *right now* would put back on the player's stack: the
// stake, plus the winnings, less the house's cut of the winnings.
//
// It exists because the felt quotes this number while the game is still running,
// and settle() is the only other thing that works it out. Hangman learned this
// the hard way: two sums drift, and the table ends up advertising a payout it
// doesn't honour. So settle calls this rather than doing it again.
func (s State) Pays() int64 {
total := int64(math.Floor(float64(s.Bet) * s.Tier.Base))
if total < s.Bet {
total = s.Bet
}
profit := total - s.Bet
if profit > 0 {
profit -= s.rakeOn(profit)
}
return s.Bet + profit
}
// rakeNow is the other half of what Pays works out: the house's cut of a win
// taken right now.
func (s State) rakeNow() int64 {
total := int64(math.Floor(float64(s.Bet) * s.Tier.Base))
if total <= s.Bet {
return 0
}
return s.rakeOn(total - s.Bet)
}
func (s State) rakeOn(profit int64) int64 {
rake := int64(math.Floor(float64(profit) * s.RakePct))
if rake < 0 {
return 0
}
return rake
}
// Net is what the game did to the player's stack.
func (s State) Net() int64 {
if s.Phase != PhaseDone {
return 0
}
return s.Payout - s.Bet
}
// Playable reports which cards of your hand can legally go on the pile. The
// browser lights these up: being shown what you can play is the game teaching
// you, and the server still decides every move regardless.
//
// While you're sitting on a card you just drew, that card is the only one you
// may play — so it is the only one that lights up.
func (s State) Playable() []int {
if s.Phase == PhaseDone || s.Turn != You {
return nil
}
hand := s.Hands[You]
if s.Phase == PhaseDrawn {
if len(hand) > 0 && hand[len(hand)-1].CanPlayOn(s.top(), s.Color) {
return []int{len(hand) - 1}
}
return nil
}
var out []int
for i, c := range hand {
if c.CanPlayOn(s.top(), s.Color) {
out = append(out, i)
}
}
return out
}
// Counts is how many cards each seat holds — what the browser gets instead of
// the bots' hands.
func (s State) Counts() []int {
out := make([]int, len(s.Hands))
for i := range s.Hands {
out[i] = len(s.Hands[i])
}
return out
}
// Top is the card in play.
func (s State) Top() Card { return s.top() }
// Left is how many cards are still in the deck.
func (s State) Left() int { return len(s.Deck) }
// ---- the plumbing ---------------------------------------------------------
func (s State) top() Card {
if len(s.Discard) == 0 {
return Card{}
}
return s.Discard[len(s.Discard)-1]
}
func (s State) topPtr() *Card {
c := s.top()
return &c
}
// pop takes the next card off the deck.
func (s *State) pop() (Card, bool) {
if len(s.Deck) == 0 {
return Card{}, false
}
c := s.Deck[0]
s.Deck = s.Deck[1:]
return c, true
}
// seatAt is the seat n places round from the one whose turn it is.
func (s State) seatAt(n int) int {
seats := len(s.Hands)
return ((s.Turn+s.Dir*n)%seats + seats) % seats
}
// advance moves the turn on n places.
func (s *State) advance(n int) { s.Turn = s.seatAt(n) }
// minOpponent is the smallest hand at the table that isn't this seat's — how
// close the bot is to being beaten, which is the only thing it plays around.
func (s State) minOpponent(seat int) int {
min := -1
for i := range s.Hands {
if i == seat {
continue
}
if min < 0 || len(s.Hands[i]) < min {
min = len(s.Hands[i])
}
}
return min
}
// clone deep-copies everything a move can touch, so a derived state shares no
// backing array with the one it came from.
func (s State) clone() State {
hands := make([][]Card, len(s.Hands))
for i, h := range s.Hands {
hands[i] = append([]Card(nil), h...)
}
s.Hands = hands
s.Deck = append([]Card(nil), s.Deck...)
s.Discard = append([]Card(nil), s.Discard...)
s.Bots = append([]string(nil), s.Bots...)
return s
}
// stepRNG is the generator for one step of the game. The seed is the game's;
// the step number is what stops every move from replaying the same numbers.
// Mixing with the golden ratio's odd 64-bit constant keeps consecutive steps
// from producing streams that share a bit pattern.
func stepRNG(seed1, seed2, step uint64) *rand.Rand {
return rand.New(rand.NewPCG(seed1, seed2^(step*0x9E3779B97F4A7C15)))
}

View File

@@ -0,0 +1,736 @@
package uno
import (
"encoding/json"
"math/rand/v2"
"testing"
)
const rake = 0.05
func duel() Tier { t, _ := TierBySlug("duel"); return t }
func full() Tier { t, _ := TierBySlug("full"); return t }
func table() Tier { t, _ := TierBySlug("table"); return t }
// deal starts a game, failing the test if it can't.
func deal(t *testing.T, tier Tier, bet int64, seed uint64) State {
t.Helper()
s, evs, err := New(bet, tier, rake, seed, 0xC0FFEE)
if err != nil {
t.Fatalf("New: %v", err)
}
if len(evs) != 1 || evs[0].Kind != EvDeal {
t.Fatalf("New should deal exactly one event, got %+v", evs)
}
return s
}
// census counts every card in the game, wherever it is. It is the invariant the
// whole engine has to hold: 108 cards, each of them in exactly one place.
func census(s State) map[Card]int {
m := map[Card]int{}
for _, h := range s.Hands {
for _, c := range h {
m[c]++
}
}
for _, c := range s.Deck {
m[c]++
}
for _, c := range s.Discard {
// A wild is stamped with the colour it was played as while it sits on the
// pile, so it counts as the wild it really is.
if c.Value == WildCard || c.Value == WildDrawFour {
c.Color = Wild
}
m[c]++
}
return m
}
func total(m map[Card]int) int {
n := 0
for _, v := range m {
n += v
}
return n
}
func TestNewDeckIsADeck(t *testing.T) {
m := census(State{Deck: NewDeck()})
if got := total(m); got != 108 {
t.Fatalf("deck has %d cards, want 108", got)
}
if m[Card{Red, Zero}] != 1 {
t.Errorf("want one red zero, got %d", m[Card{Red, Zero}])
}
if m[Card{Blue, Seven}] != 2 {
t.Errorf("want two blue sevens, got %d", m[Card{Blue, Seven}])
}
if m[Card{Wild, WildDrawFour}] != 4 {
t.Errorf("want four +4s, got %d", m[Card{Wild, WildDrawFour}])
}
}
func TestNewDeals(t *testing.T) {
s := deal(t, full(), 100, 7)
if len(s.Hands) != 4 {
t.Fatalf("full house is four seats, got %d", len(s.Hands))
}
for i, h := range s.Hands {
if len(h) != HandSize {
t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize)
}
}
if len(s.Bots) != 3 {
t.Fatalf("want three bot names, got %v", s.Bots)
}
if s.Turn != You {
t.Errorf("you play first, turn is %d", s.Turn)
}
if got := total(census(s)); got != 108 {
t.Fatalf("the deal lost cards: %d of 108", got)
}
}
// The card turned over to start is never an action card — see New.
func TestOpeningCardIsANumber(t *testing.T) {
for seed := uint64(0); seed < 300; seed++ {
s := deal(t, table(), 50, seed)
if s.Top().Value.Action() {
t.Fatalf("seed %d opened on %v", seed, s.Top())
}
if s.Color != s.Top().Color {
t.Fatalf("seed %d: colour in play is %v, top card is %v", seed, s.Color, s.Top())
}
}
}
// ---- the rules ------------------------------------------------------------
// rig builds a state by hand, so a rule can be tested without hunting a seed
// that happens to deal it.
//
// The deck is the rest of the deck: every card not in a hand and not the one in
// play. So a rigged game still holds 108 cards, and the census invariant means
// something in these tests too.
func rig(hands [][]Card, top Card, color Color) State {
left := map[Card]int{}
for _, c := range NewDeck() {
left[c]++
}
take := func(c Card) {
if c.IsWild() {
c.Color = Wild
}
left[c]--
}
for _, h := range hands {
for _, c := range h {
take(c)
}
}
take(top)
var deck []Card
for _, c := range NewDeck() {
key := c
if left[key] > 0 {
left[key]--
deck = append(deck, c)
}
}
return State{
Tier: full(), Hands: hands, Discard: []Card{top}, Color: color,
Deck: deck, Dir: 1, Turn: You, Phase: PhasePlay,
Bet: 100, RakePct: rake, Seed1: 1, Seed2: 2,
}
}
func TestPlayMustMatch(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err)
}
}
func TestPlayMatchesFaceOrColor(t *testing.T) {
// Same face, different colour: legal.
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("a blue 9 on a red 9 is legal: %v", err)
}
if next.Color != Blue {
t.Errorf("colour in play should follow the card: %v", next.Color)
}
if evs[0].Kind != EvPlay || evs[0].Seat != You {
t.Errorf("first event should be your play, got %+v", evs[0])
}
}
func TestWildNeedsAColor(t *testing.T) {
s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
t.Fatalf("a wild with no colour should be refused, got %v", err)
}
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
t.Fatalf("naming 'wild' is not naming a colour, got %v", err)
}
}
func TestWildNamesTheColor(t *testing.T) {
s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red)
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Green})
if err != nil {
t.Fatalf("play wild: %v", err)
}
// The bot moved after us, so the colour in play is whatever it left behind —
// what we can check is that the wild itself went down as green.
top := next.Discard
if len(top) < 2 {
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
}
if top[1] != (Card{Green, WildCard}) {
t.Errorf("the wild should sit on the pile as green, got %v", top[1])
}
}
func TestDrawTwoHitsTheNextSeat(t *testing.T) {
// Two seats, so the +2 lands on the bot and the turn comes straight back.
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play +2: %v", err)
}
if len(next.Hands[1]) != 4 {
t.Errorf("the bot should hold 2 + 2 = 4 cards, got %d", len(next.Hands[1]))
}
if next.Turn != You {
t.Errorf("the bot was skipped, so it should be your turn: %d", next.Turn)
}
if !hasKind(evs, EvForced) || !hasKind(evs, EvSkip) {
t.Errorf("a +2 is a forced draw and a skip: %+v", evs)
}
if got := total(census(next)); got != 108 {
t.Fatalf("the +2 lost cards: %d of 108", got)
}
}
func TestReverseIsASkipHeadsUp(t *testing.T) {
s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red)
s.Tier = duel()
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play reverse: %v", err)
}
if next.Dir != 1 {
t.Errorf("with two at the table a reverse doesn't turn the table around: dir %d", next.Dir)
}
if next.Turn != You {
t.Errorf("the bot should have been skipped, turn is %d", next.Turn)
}
if !hasKind(evs, EvSkip) || hasKind(evs, EvReverse) {
t.Errorf("heads up, a reverse reads as a skip: %+v", evs)
}
if len(next.Hands[1]) != 1 {
t.Errorf("the bot never played, so it still holds one card: %d", len(next.Hands[1]))
}
}
func TestReverseTurnsTheTableAround(t *testing.T) {
// Every bot holds a red card, so each of them can play the moment the turn
// reaches it — which is what makes the *order* they play in observable.
s := rig([][]Card{
{{Red, Reverse}, {Red, One}},
{{Red, Five}, {Blue, Six}},
{{Red, Six}, {Green, Six}},
{{Red, Seven}, {Yellow, Six}},
}, Card{Red, Nine}, Red)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play reverse: %v", err)
}
if next.Dir != -1 {
t.Errorf("four at the table: a reverse turns it around, dir %d", next.Dir)
}
if !hasKind(evs, EvReverse) {
t.Errorf("want a reverse event: %+v", evs)
}
if next.Turn != You {
t.Errorf("the bots should have played round to you, turn is %d", next.Turn)
}
// The table now runs anticlockwise: seat 3 plays, then 2, then 1.
var order []int
for _, e := range evs {
if e.Kind == EvPlay && e.Seat != You {
order = append(order, e.Seat)
}
}
if len(order) != 3 || order[0] != 3 || order[1] != 2 || order[2] != 1 {
t.Errorf("the bots played in the order %v, want [3 2 1]", order)
}
}
func TestSkipSkips(t *testing.T) {
// Both bots hold a playable red, so the only reason either of them doesn't
// play is that it wasn't asked to.
s := rig([][]Card{
{{Red, Skip}, {Red, One}},
{{Red, Five}, {Blue, Six}},
{{Red, Six}, {Green, Six}},
}, Card{Red, Nine}, Red)
s.Tier = table()
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play skip: %v", err)
}
if !hasKind(evs, EvSkip) {
t.Errorf("want a skip event: %+v", evs)
}
for _, e := range evs {
if e.Kind == EvPlay && e.Seat == 1 {
t.Errorf("seat 1 was skipped and should not have played: %+v", e)
}
}
if len(next.Hands[1]) != 2 {
t.Errorf("seat 1 was skipped and should still hold two: %d", len(next.Hands[1]))
}
if len(next.Hands[2]) != 1 {
t.Errorf("seat 2 was not skipped and should have played: %d", len(next.Hands[2]))
}
}
// ---- drawing --------------------------------------------------------------
func TestDrawnPlayableWaitsForYou(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Phase != PhaseDrawn {
t.Fatalf("a playable draw should stop and ask, phase is %v", next.Phase)
}
if next.Turn != You {
t.Fatalf("the turn should still be yours: %d", next.Turn)
}
if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) {
t.Fatalf("your own drawn card comes face up: %+v", evs[0])
}
if got := next.Playable(); len(got) != 1 || got[0] != 1 {
t.Errorf("the drawn card, and only it, is playable: %v", got)
}
// You may not play the *other* card instead — drawing would otherwise be a
// free look with no cost.
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
t.Fatalf("only the drawn card may be played, got %v", err)
}
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
t.Fatalf("you can't draw twice, got %v", err)
}
after, _, err := ApplyMove(next, Move{Kind: MovePass})
if err != nil {
t.Fatalf("pass: %v", err)
}
if after.Phase != PhasePlay || after.Turn != You {
t.Fatalf("after passing the bot plays and it comes back to you: phase %v turn %d", after.Phase, after.Turn)
}
if len(after.Hands[You]) != 2 {
t.Errorf("you kept the card you drew: %d", len(after.Hands[You]))
}
}
func TestUnplayableDrawPassesTheTurn(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Phase != PhasePlay {
t.Errorf("nothing to decide, so no pause: %v", next.Phase)
}
if !hasKind(evs, EvPass) {
t.Errorf("the turn passed, and the table should be told: %+v", evs)
}
}
func TestPassOnlyAfterADraw(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
if _, _, err := ApplyMove(s, Move{Kind: MovePass}); err != ErrCantPass {
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
}
}
func TestReshuffleRebuildsTheDeck(t *testing.T) {
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
// An empty deck, and a discard with something under the top card to become one.
// The buried wild went down as green; it has to come back as a wild.
s.Deck = nil
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw on an empty deck: %v", err)
}
if !hasKind(evs, EvReshuffle) {
t.Fatalf("want a reshuffle: %+v", evs)
}
if len(next.Discard) == 0 || next.Discard[0] != (Card{Red, Nine}) {
t.Errorf("the card in play stays on the pile: %v", next.Discard)
}
for _, c := range next.Deck {
if c.Value == WildCard && c.Color != Wild {
t.Errorf("a wild went back into the deck stamped %v", c.Color)
}
}
for _, h := range next.Hands {
for _, c := range h {
if c.Value == WildCard && c.Color != Wild {
t.Errorf("a wild was dealt out stamped %v", c.Color)
}
}
}
}
// ---- the money ------------------------------------------------------------
// The rule every game in this casino has had to be taught: the number the felt
// quotes and the number the settle lands on are one function, not two.
func TestQuoteIsThePayout(t *testing.T) {
for _, tier := range Tiers {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
s.Tier = tier
s.Hands = make([][]Card, tier.Bots+1)
s.Hands[You] = []Card{{Red, Three}}
for i := 1; i <= tier.Bots; i++ {
s.Hands[i] = []Card{{Green, Five}, {Green, Six}}
}
quoted := s.Pays()
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) // your last card
if err != nil {
t.Fatalf("%s: go out: %v", tier.Slug, err)
}
if next.Outcome != OutcomeWon {
t.Fatalf("%s: playing your last card wins, got %q", tier.Slug, next.Outcome)
}
if next.Payout != quoted {
t.Errorf("%s: the felt quoted %d, the house paid %d", tier.Slug, quoted, next.Payout)
}
if next.Net() != quoted-s.Bet {
t.Errorf("%s: net is %d, want %d", tier.Slug, next.Net(), quoted-s.Bet)
}
}
}
// The rake comes out of the winnings, never the stake.
func TestRakeIsOnWinningsOnly(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel() // 2.2x on 100: 220 back, 120 of it profit, 6 of that to the house
s.Bet = 100
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("go out: %v", err)
}
if next.Payout != 214 {
t.Errorf("payout %d, want 214 (100 stake + 120 winnings - 6 rake)", next.Payout)
}
if next.Rake != 6 {
t.Errorf("rake %d, want 6", next.Rake)
}
if next.Net() != 114 {
t.Errorf("net %d, want 114", next.Net())
}
}
func TestLosingPaysNothingAndIsNotCharged(t *testing.T) {
// The bot holds one card that plays on the pile, so it goes out the moment the
// turn reaches it.
s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red)
s.Tier = duel()
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play: %v", err)
}
if next.Outcome != OutcomeLost {
t.Fatalf("the bot went out, so you lost: %q", next.Outcome)
}
if next.Payout != 0 || next.Rake != 0 {
t.Errorf("a loss pays nothing and is charged nothing: payout %d rake %d", next.Payout, next.Rake)
}
if next.Net() != -s.Bet {
t.Errorf("a loss costs the stake and no more: %d", next.Net())
}
last := evs[len(evs)-1]
if last.Kind != EvSettle || last.Seat != 1 {
t.Errorf("the settle should name the winner: %+v", last)
}
}
func TestNoMoveAfterItIsOver(t *testing.T) {
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
s.Tier = duel()
done, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("go out: %v", err)
}
if _, _, err := ApplyMove(done, Move{Kind: MoveDraw}); err != ErrGameOver {
t.Fatalf("a finished game takes no more moves, got %v", err)
}
}
func TestBadBet(t *testing.T) {
if _, _, err := New(0, duel(), rake, 1, 2); err != ErrBadBet {
t.Fatalf("want ErrBadBet, got %v", err)
}
}
// ---- the whole game -------------------------------------------------------
// playOut plays a game to its end with a simple strategy: play the first legal
// card, otherwise draw, otherwise pass. It asserts the invariants at every step.
func playOut(t *testing.T, s State, maxTurns int) State {
t.Helper()
for turn := 0; s.Phase != PhaseDone; turn++ {
if turn > maxTurns {
t.Fatalf("the game never ended in %d turns", maxTurns)
}
if s.Turn != You {
t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn)
}
var m Move
if p := s.Playable(); len(p) > 0 {
m = Move{Kind: MovePlay, Index: p[0]}
if s.Hands[You][p[0]].IsWild() {
m.Color = Green
}
} else if s.Phase == PhaseDrawn {
m = Move{Kind: MovePass}
} else {
m = Move{Kind: MoveDraw}
}
next, evs, err := ApplyMove(s, m)
if err != nil {
t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase)
}
if len(evs) == 0 {
t.Fatalf("turn %d: a move that happened emitted nothing", turn)
}
if got := total(census(next)); got != 108 {
t.Fatalf("turn %d: %d cards of 108 — a card was lost or minted", turn, got)
}
for c, n := range census(next) {
if want := deckCount(c); n != want {
t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want)
}
}
// No event ever names a bot's card. That is the hole card of this game, and
// it is most of the deck.
for _, e := range evs {
if (e.Kind == EvDraw || e.Kind == EvForced) && e.Seat != You && e.Card != nil {
t.Fatalf("turn %d: a bot's drawn card crossed the wire: %+v", turn, e)
}
}
s = next
}
return s
}
// deckCount is how many of a given card a 108 deck holds.
func deckCount(c Card) int {
switch {
case c.Color == Wild:
return 4
case c.Value == Zero:
return 1
default:
return 2
}
}
// A hundred games, played out, with the invariants checked at every step. This
// is the test that would have caught a deck that leaks cards through the
// reshuffle, a turn the bots don't hand back, or a game that can't end.
func TestGamesPlayOut(t *testing.T) {
wins, losses, stuck := 0, 0, 0
for seed := uint64(0); seed < 100; seed++ {
tier := Tiers[seed%3]
end := playOut(t, deal(t, tier, 100, seed), 500)
switch end.Outcome {
case OutcomeWon:
wins++
if end.Payout != end.Pays() {
t.Fatalf("seed %d: paid %d, quoted %d", seed, end.Payout, end.Pays())
}
case OutcomeLost:
losses++
case OutcomeStuck:
stuck++
default:
t.Fatalf("seed %d ended as %q", seed, end.Outcome)
}
if len(end.Hands[end.winnerSeat()]) != 0 && end.Outcome != OutcomeStuck {
t.Fatalf("seed %d: the winner is still holding cards", seed)
}
}
// Playing the first legal card is a poor strategy against bots that hold their
// +4s back, so this is not a fairness assertion — it's a check that both
// outcomes actually happen. A table that never pays is a bug in the bots.
if wins == 0 || losses == 0 {
t.Fatalf("100 games gave %d wins, %d losses, %d stuck — one side never happens", wins, losses, stuck)
}
t.Logf("100 games: %d won, %d lost, %d stuck", wins, losses, stuck)
}
// winnerSeat is the seat the settle event named — only used by the tests.
func (s State) winnerSeat() int {
best := 0
for i := range s.Hands {
if len(s.Hands[i]) < len(s.Hands[best]) {
best = i
}
}
return best
}
// The same seed deals the same game and the bots make the same choices — which
// is what lets a disputed game be replayed exactly as it fell.
func TestReplaysFromTheSeed(t *testing.T) {
a := playOut(t, deal(t, full(), 250, 42), 500)
b := playOut(t, deal(t, full(), 250, 42), 500)
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
if string(ja) != string(jb) {
t.Fatal("the same seed played the same way gave two different games")
}
if a.Outcome == "" {
t.Fatal("the replay didn't finish")
}
}
// A game in progress survives a redeploy: it is a plain value, so it round-trips
// through the JSON it is stored as.
func TestStateSurvivesStorage(t *testing.T) {
s := deal(t, table(), 100, 9)
s, _, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
blob, err := json.Marshal(s)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back State
if err := json.Unmarshal(blob, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
again, _ := json.Marshal(back)
if string(again) != string(blob) {
t.Fatal("a stored game came back different")
}
// And it plays on from there.
playOut(t, back, 500)
}
// A move the engine refuses leaves the caller's state exactly as it was — no
// card half-played, no turn half-passed.
func TestARefusedMoveChangesNothing(t *testing.T) {
s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
before, _ := json.Marshal(s)
for _, m := range []Move{
{Kind: MovePlay, Index: 0}, // doesn't match
{Kind: MovePlay, Index: 1}, // wild with no colour
{Kind: MovePlay, Index: 9}, // no such card
{Kind: MovePass}, // nothing drawn
{Kind: "shuffle-the-deck-in-my-favour"}, // no
} {
if _, _, err := ApplyMove(s, m); err == nil {
t.Fatalf("%+v should have been refused", m)
}
}
after, _ := json.Marshal(s)
if string(before) != string(after) {
t.Fatal("a refused move touched the state")
}
}
// The bots choose. Two different seeds should not play the same bot game, or the
// bot is a lookup table you can memorise.
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
same := 0
for seed := uint64(0); seed < 20; seed++ {
a := playOut(t, deal(t, duel(), 100, seed), 500)
b := playOut(t, deal(t, duel(), 100, seed+1000), 500)
if len(a.Discard) == len(b.Discard) {
same++
}
}
if same == 20 {
t.Fatal("every seed played out to the same length — the bots aren't choosing")
}
}
// botPick holds its +4 back while it's comfortable, and reaches for it when
// somebody is about to go out.
func TestBotSavesTheDrawFour(t *testing.T) {
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
top, color := Card{Red, Nine}, Red
rng := rand.New(rand.NewPCG(1, 2))
held := 0
for i := 0; i < 50; i++ {
if _, idx := botPick(hand, top, color, 5, rng); idx == 1 {
held++
}
}
if held < 30 {
t.Errorf("with the table comfortable the bot should mostly play the red 5, held %d/50", held)
}
reached := 0
for i := 0; i < 50; i++ {
if _, idx := botPick(hand, top, color, 1, rng); idx == 0 {
reached++
}
}
if reached < 30 {
t.Errorf("with a player on one card the bot should mostly play the +4, reached %d/50", reached)
}
}
func TestBotPicksItsBestColor(t *testing.T) {
rng := rand.New(rand.NewPCG(3, 4))
hand := []Card{{Blue, One}, {Blue, Two}, {Green, Three}, {Wild, WildCard}}
if got := botColor(hand, rng); got != Blue {
t.Errorf("the bot holds two blues: it should call blue, got %v", got)
}
// A hand of nothing but wilds still has to name something.
for i := 0; i < 20; i++ {
if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() {
t.Fatalf("botColor named %v, which is not a colour", got)
}
}
}
func TestBotHasNothingToPlay(t *testing.T) {
if _, idx := botPick([]Card{{Blue, Three}}, Card{Red, Nine}, Red, 3, rand.New(rand.NewPCG(1, 1))); idx != -1 {
t.Errorf("a hand with nothing legal should report -1, got %d", idx)
}
}
func hasKind(evs []Event, kind string) bool {
for _, e := range evs {
if e.Kind == kind {
return true
}
}
return false
}

View File

@@ -8,6 +8,7 @@ import (
"pete/internal/games/hangman" "pete/internal/games/hangman"
"pete/internal/games/klondike" "pete/internal/games/klondike"
"pete/internal/games/trivia" "pete/internal/games/trivia"
"pete/internal/games/uno"
"pete/internal/storage" "pete/internal/storage"
) )
@@ -29,7 +30,6 @@ type gameTeaser struct {
var comingSoon = []gameTeaser{ var comingSoon = []gameTeaser{
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."}, {Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
} }
// betDenominations are the chips you build a bet out of. // betDenominations are the chips you build a bet out of.
@@ -77,6 +77,7 @@ type gamesPage struct {
FullDeck int FullDeck int
Quizzes []trivia.Tier // trivia's three difficulties Quizzes []trivia.Tier // trivia's three difficulties
Rungs int // how long the trivia ladder is Rungs int // how long the trivia ladder is
Tables []uno.Tier // uno's three tables, and how many bots sit at each
} }
// casinoRoutes hangs every table off the mux. // casinoRoutes hangs every table off the mux.
@@ -91,6 +92,7 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /games/hangman", s.handleHangman) mux.HandleFunc("GET /games/hangman", s.handleHangman)
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire) mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
mux.HandleFunc("GET /games/trivia", s.handleTrivia) mux.HandleFunc("GET /games/trivia", s.handleTrivia)
mux.HandleFunc("GET /games/uno", s.handleUno)
mux.HandleFunc("GET /api/games/table", s.handleTable) mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn) mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
@@ -107,6 +109,9 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart) mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer) mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart)
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
} }
// requirePlayer sends an anonymous visitor to sign in and comes back here after. // requirePlayer sends an anonymous visitor to sign in and comes back here after.
@@ -139,6 +144,7 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
FullDeck: klondike.FullDeck, FullDeck: klondike.FullDeck,
Quizzes: trivia.Tiers, Quizzes: trivia.Tiers,
Rungs: trivia.Rungs, Rungs: trivia.Rungs,
Tables: uno.Tiers,
} }
} }
@@ -176,3 +182,10 @@ func (s *Server) handleTrivia(w http.ResponseWriter, r *http.Request) {
} }
s.render(w, "trivia", s.gamesPage(r)) s.render(w, "trivia", s.gamesPage(r))
} }
func (s *Server) handleUno(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "uno", s.gamesPage(r))
}

View File

@@ -15,6 +15,7 @@ import (
"pete/internal/games/hangman" "pete/internal/games/hangman"
"pete/internal/games/klondike" "pete/internal/games/klondike"
"pete/internal/games/trivia" "pete/internal/games/trivia"
"pete/internal/games/uno"
"pete/internal/storage" "pete/internal/storage"
) )
@@ -193,6 +194,9 @@ type tableView struct {
Trivia *triviaView `json:"trivia,omitempty"` Trivia *triviaView `json:"trivia,omitempty"`
TrivEvents []trivia.Event `json:"triv_events,omitempty"` TrivEvents []trivia.Event `json:"triv_events,omitempty"`
Uno *unoView `json:"uno,omitempty"`
UnoEvents []unoEventView `json:"uno_events,omitempty"`
Rake float64 `json:"rake_pct"` Rake float64 `json:"rake_pct"`
} }
@@ -253,6 +257,13 @@ func (s *Server) table(user string) (tableView, error) {
// twenty seconds finds the countdown exactly where they left it. // twenty seconds finds the countdown exactly where they left it.
tv := viewTrivia(g, time.Now()) tv := viewTrivia(g, time.Now())
v.Trivia = &tv v.Trivia = &tv
case gameUno:
var g uno.State
if err := json.Unmarshal(live.State, &g); err != nil {
return s.dropUnreadable(user, v, err)
}
uv := viewUno(g)
v.Uno = &uv
default: default:
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game)) return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
} }
@@ -509,6 +520,7 @@ const (
gameHangman = "hangman" gameHangman = "hangman"
gameSolitaire = "solitaire" gameSolitaire = "solitaire"
gameTrivia = "trivia" gameTrivia = "trivia"
gameUno = "uno"
) )
// finished is what commit needs to know about a game it's writing back: enough // finished is what commit needs to know about a game it's writing back: enough

284
internal/web/games_uno.go Normal file
View File

@@ -0,0 +1,284 @@
package web
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"pete/internal/games/blackjack"
"pete/internal/games/uno"
"pete/internal/storage"
)
// UNO, played for chips against bots.
//
// The seam is the same as every other table, but there is one thing here that no
// other table has: opponents. The obvious way to give a browser opponents is a
// socket, and the plan says solo UNO must not need one — so it doesn't. A move
// goes up, and what comes back is the player's move *plus every bot turn it
// handed off to*, as a script of events. One request, one round of the table.
//
// What the browser is allowed to see: its own hand, the card in play, the colour
// in play, and how many cards each bot is holding. Not the deck, not a bot's
// hand, not even the face of a card a bot drew. That last one is most of the
// deck, and it is the thing that would turn a game of counting cards into a game
// of reading the network tab.
// unoCardView is one card, ready to draw. The browser gets the colour and the
// face as words, not as the engine's integers — the same bargain the blackjack
// table makes, and for the same reason: the browser draws faces, not logic.
type unoCardView struct {
Color string `json:"color"` // "red" | "blue" | "yellow" | "green" | "wild"
Value string `json:"value"` // "0"…"9" | "skip" | "reverse" | "+2" | "wild" | "+4"
Wild bool `json:"wild"` // it's a wild, whatever colour it was played as
}
func viewUnoCard(c uno.Card) unoCardView {
return unoCardView{
Color: c.Color.String(),
Value: c.Value.String(),
Wild: c.IsWild(),
}
}
// unoSeatView is one seat at the table: a name, and a number of cards. A bot's
// cards are a *count*. There is no field here for what they are.
type unoSeatView struct {
Name string `json:"name"`
Cards int `json:"cards"`
You bool `json:"you"`
Uno bool `json:"uno"` // down to one card
}
// unoView is a game as its player may see it.
type unoView struct {
Tier uno.Tier `json:"tier"`
Seats []unoSeatView `json:"seats"`
Hand []unoCardView `json:"hand"` // yours, and only yours
Playable []int `json:"playable"` // which of them can legally go down
Top unoCardView `json:"top"` // the card in play
Color string `json:"color"` // the colour in play, which a wild renames
Deck int `json:"deck"` // cards left to draw
Turn int `json:"turn"`
Dir int `json:"dir"`
Bet int64 `json:"bet"`
Pays int64 `json:"pays"` // what going out right now would actually pay
Phase string `json:"phase"`
Outcome string `json:"outcome,omitempty"`
Winner int `json:"winner"`
Payout int64 `json:"payout,omitempty"`
Rake int64 `json:"rake,omitempty"`
Net int64 `json:"net"`
}
func viewUno(g uno.State) unoView {
v := unoView{
Tier: g.Tier,
Top: viewUnoCard(g.Top()),
Color: g.Color.String(),
Deck: g.Left(),
Turn: g.Turn,
Dir: g.Dir,
Bet: g.Bet,
Pays: g.Pays(),
Phase: string(g.Phase),
Outcome: string(g.Outcome),
Winner: -1,
Payout: g.Payout,
Rake: g.Rake,
Net: g.Net(),
}
for i, n := range g.Counts() {
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: n == 1}
if i == uno.You {
seat.Name = "You"
} else if i-1 < len(g.Bots) {
seat.Name = g.Bots[i-1]
}
v.Seats = append(v.Seats, seat)
if n == 0 {
v.Winner = i
}
}
for _, c := range g.Hands[uno.You] {
v.Hand = append(v.Hand, viewUnoCard(c))
}
v.Playable = g.Playable()
if v.Playable == nil {
v.Playable = []int{}
}
return v
}
// unoEventView is one beat of the script the table plays back: a card going
// down, a seat eating a +4, the turn coming round. The engine's own events carry
// engine types, so they are re-rendered here rather than shipped raw — and this
// is also the wall where a bot's drawn card is dropped on the floor.
type unoEventView struct {
Kind string `json:"kind"`
Seat int `json:"seat"`
Card *unoCardView `json:"card,omitempty"`
Color string `json:"color,omitempty"`
N int `json:"n,omitempty"`
Left int `json:"left,omitempty"`
Text string `json:"text,omitempty"`
}
func viewUnoEvents(evs []uno.Event) []unoEventView {
out := make([]unoEventView, 0, len(evs))
for _, e := range evs {
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, Text: e.Text}
if e.Color != uno.Wild {
v.Color = e.Color.String()
}
if e.Card != nil {
// The engine only ever attaches a card to an event the seat is entitled
// to see it in — a card played face up, or one *you* drew. This check is
// the belt to that pair of braces: a bot's draw never carries a face,
// whatever the engine thinks it's doing.
if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
if e.Seat == uno.You {
c := viewUnoCard(*e.Card)
v.Card = &c
}
} else {
c := viewUnoCard(*e.Card)
v.Card = &c
}
}
out = append(out, v)
}
return out
}
// handleUnoStart takes the bet and deals. Same order as every other table: the
// chips are staked first, in the same statement that checks they exist, so two
// deals fired at once cannot bet the same chip.
func (s *Server) handleUnoStart(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Bet int64 `json:"bet"`
Tier string `json:"tier"`
}
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
return
}
tier, err := uno.TierBySlug(req.Tier)
if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
return
}
if err := storage.Stake(user, req.Bet); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
return
}
slog.Error("games: uno stake", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
seed1, seed2 := newSeeds()
g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2)
if err != nil {
// The game never happened, so the stake never should have left.
_ = storage.Award(user, req.Bet)
slog.Error("games: uno deal", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.persistUno(w, user, g, evs, seed1, seed2, true)
}
// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew.
// The bots' turns come back with it.
func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var move uno.Move
if err := decodeJSON(r, &move); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
live, err := storage.LoadLiveHand(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
return
}
if err != nil {
slog.Error("games: uno load", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if live.Game != gameUno {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
return
}
var g uno.State
if err := json.Unmarshal(live.State, &g); err != nil {
slog.Error("games: unreadable uno game", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
next, evs, err := uno.ApplyMove(g, move)
if err != nil {
// The refusals a player can actually cause, said in words rather than as
// "that move isn't legal here" — which, in a game with this many rules, is
// the table refusing to explain itself.
msg := "that move isn't legal here"
switch {
case errors.Is(err, uno.ErrCantPlay):
msg = "that card doesn't go on this one"
case errors.Is(err, uno.ErrNeedColor):
msg = "pick a colour for the wild"
case errors.Is(err, uno.ErrMustPlayNow):
msg = "play the card you drew, or pass"
case errors.Is(err, uno.ErrCantPass):
msg = "draw first, then you can pass"
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
return
}
s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false)
}
// persistUno writes the game back and answers the browser.
func (s *Server) persistUno(w http.ResponseWriter, user string, g uno.State, evs []uno.Event, seed1, seed2 uint64, fresh bool) {
blob, err := json.Marshal(g)
if err != nil {
slog.Error("games: marshal uno", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
done := g.Phase == uno.PhaseDone
v, ok := s.commit(w, user, finished{
Game: gameUno, Blob: blob,
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
Outcome: string(g.Outcome), Done: done,
Seed1: seed1, Seed2: seed2, Fresh: fresh,
})
if !ok {
return
}
// A finished game is gone from storage, so the table has none to show — but the
// browser still needs the final board to land the verdict on.
if done {
uv := viewUno(g)
v.Uno = &uv
}
v.UnoEvents = viewUnoEvents(evs)
writeJSON(w, v)
}

View File

@@ -0,0 +1,147 @@
package web
import (
"testing"
"pete/internal/games/uno"
"pete/internal/storage"
)
// The one thing this table cannot get wrong: the stake leaves the stack, and no
// card a player is not entitled to see leaves the server. At UNO that is not one
// hole card — it is the deck and every bot's hand, which is most of the game.
func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 100, "tier": "full"}))
if code != 200 {
t.Fatalf("start = %d, want 200", code)
}
if v.Chips != 900 {
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
}
if v.Game != gameUno || v.Uno == nil {
t.Fatalf("start returned no uno game: game=%q", v.Game)
}
g := v.Uno
if len(g.Hand) != uno.HandSize {
t.Errorf("you were dealt %d cards, want %d", len(g.Hand), uno.HandSize)
}
if len(g.Seats) != 4 {
t.Fatalf("a full house is four seats, got %d", len(g.Seats))
}
// A bot is a name and a number. There is no field here that could carry a
// card, which is the point — this is a compile-time guarantee, and the test
// exists to make deleting it loud.
for i, seat := range g.Seats {
if i == 0 {
if !seat.You || seat.Name != "You" {
t.Errorf("seat 0 should be you: %+v", seat)
}
continue
}
if seat.You || seat.Name == "" {
t.Errorf("seat %d should be a named bot: %+v", i, seat)
}
if seat.Cards != uno.HandSize {
t.Errorf("seat %d holds %d cards, want %d", i, seat.Cards, uno.HandSize)
}
}
if g.Top.Value == "" {
t.Error("no card in play")
}
if g.Turn != 0 {
t.Errorf("you play first, turn is %d", g.Turn)
}
}
// A move plays your turn and every bot turn behind it, and the script that comes
// back never carries a bot's drawn card.
func TestUnoMovePlaysTheWholeLap(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 100, "tier": "table"}))
if v.Uno == nil {
t.Fatal("no game")
}
// Draw, which is legal from any hand: the turn passes to the bots and comes
// back, unless the card drawn happens to be playable.
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "draw"}))
if code != 200 {
t.Fatalf("draw = %d, want 200", code)
}
if v.Uno == nil {
t.Fatal("the move returned no game")
}
if len(v.UnoEvents) == 0 {
t.Fatal("a move that happened sent back no events")
}
if v.Uno.Phase != "drawn" && v.Uno.Turn != 0 {
t.Errorf("the bots should have played back round to you, turn is %d", v.Uno.Turn)
}
for _, e := range v.UnoEvents {
if (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != 0 && e.Card != nil {
t.Fatalf("a bot's drawn card crossed the wire: %+v", e)
}
}
}
// You cannot play a wild without naming a colour — and the zero value of the
// colour field is not red, so a move that simply omits it is refused rather than
// quietly played as one.
func TestUnoWildWithNoColourIsRefused(t *testing.T) {
s := newCasino(t)
fund(t, 1000)
// Deal until a wild lands in the opening hand: it's four cards in 108, so it
// doesn't take long, and this is the only way to get one without rigging the
// deck through a door the server doesn't have.
var wild = -1
for try := 0; try < 40 && wild < 0; try++ {
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
map[string]any{"bet": 10, "tier": "duel"}))
if v.Uno == nil {
t.Fatal("no game")
}
for i, c := range v.Uno.Hand {
if c.Wild {
wild = i
break
}
}
if wild < 0 {
// Abandon this deal and take another. The live row is keyed on the
// player, so it has to go before the next start can be seated.
if err := storage.ClearLiveHand(testPlayer); err != nil {
t.Fatal(err)
}
}
}
if wild < 0 {
t.Skip("40 deals and not one wild — vanishingly unlikely, but not a failure")
}
_, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "play", "index": wild}))
if code != 400 {
t.Fatalf("a wild with no colour = %d, want 400", code)
}
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
map[string]any{"kind": "play", "index": wild, "color": int(uno.Green)}))
if code != 200 {
t.Fatalf("a wild played as green = %d, want 200", code)
}
if v.Uno != nil && v.Uno.Phase != "done" && v.Uno.Color != "green" && v.Uno.Color != "" {
// The bots have played since, so the colour may have moved on — what must
// not happen is the wild going down as anything we didn't ask for.
t.Logf("colour in play after the bots: %s", v.Uno.Color)
}
}

View File

@@ -1421,6 +1421,346 @@ html[data-phase="night"] {
justify-content: center; justify-content: center;
} }
/* ---- uno ------------------------------------------------------------------
The card is the whole look: a coloured rectangle with a white oval laid
across it at an angle. Drop the oval and it reads as a button, so the oval
is not decoration — it is what makes the thing a card.
The bots are drawn from a *count*. That is all the server sends, and the fan
of backs up there is generated from a number rather than from cards, because
there are no cards to draw. */
/* The felt takes a tint of the colour in play. After a wild, the card on the
pile is not the colour you have to follow — this is the table saying so. */
.pete-uno[data-c="red"] { --play: #d64545; }
.pete-uno[data-c="blue"] { --play: #3d7fd6; }
.pete-uno[data-c="yellow"] { --play: #e0b02c; }
.pete-uno[data-c="green"] { --play: #46a86b; }
.pete-uno::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(80% 55% at 50% 45%, var(--play, transparent), transparent 70%);
opacity: 0.16;
transition: opacity 0.5s ease, background 0.5s ease;
}
.pete-uno-card {
position: relative;
height: var(--uno-h, 6.4rem);
width: var(--uno-w, 4.3rem);
border-radius: 0.55rem;
padding: 0.28rem;
background: #fff;
box-shadow: 0 3px 0 rgba(0,0,0,0.22), 0 6px 14px rgba(0,0,0,0.18);
flex: none;
}
.pete-uno-face {
position: relative;
display: grid;
place-items: center;
height: 100%;
width: 100%;
border-radius: 0.38rem;
overflow: hidden;
background: var(--uno, #2b2118);
color: #fff;
}
.pete-uno-card[data-c="red"] { --uno: #d64545; }
.pete-uno-card[data-c="blue"] { --uno: #3d7fd6; }
.pete-uno-card[data-c="yellow"] { --uno: #e0b02c; }
.pete-uno-card[data-c="green"] { --uno: #46a86b; }
.pete-uno-card[data-c="wild"] { --uno: #2b2118; }
/* A wild that has been played wears the colour it was called as — the pile has
to show what you must follow, not what was printed. */
.pete-uno-card[data-named="red"] { box-shadow: 0 0 0 3px #d64545, 0 3px 0 rgba(0,0,0,0.22); }
.pete-uno-card[data-named="blue"] { box-shadow: 0 0 0 3px #3d7fd6, 0 3px 0 rgba(0,0,0,0.22); }
.pete-uno-card[data-named="yellow"] { box-shadow: 0 0 0 3px #e0b02c, 0 3px 0 rgba(0,0,0,0.22); }
.pete-uno-card[data-named="green"] { box-shadow: 0 0 0 3px #46a86b, 0 3px 0 rgba(0,0,0,0.22); }
/* The oval. Tilted, because it is tilted on the card in your hand. */
.pete-uno-oval {
position: relative;
z-index: 1;
display: grid;
place-items: center;
height: 78%;
width: 62%;
border-radius: 50%;
transform: rotate(-24deg);
background: #fff;
color: var(--uno, #2b2118);
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 1.45rem;
font-weight: 700;
line-height: 1;
text-shadow: 0 1px 0 rgba(0,0,0,0.10);
}
.pete-uno-card[data-c="wild"] .pete-uno-oval { color: #2b2118; font-size: 1.15rem; }
.pete-uno-corner {
position: absolute;
z-index: 1;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.62rem;
font-weight: 700;
line-height: 1;
color: #fff;
text-shadow: 0 1px 1px rgba(0,0,0,0.35);
}
.pete-uno-corner[data-at="tl"] { top: 0.22rem; left: 0.28rem; }
.pete-uno-corner[data-at="br"] { bottom: 0.22rem; right: 0.28rem; transform: rotate(180deg); }
/* The four quadrants of a wild, under the oval. */
.pete-uno-wheel {
position: absolute;
inset: 0;
background:
conic-gradient(#d64545 0 25%, #e0b02c 0 50%, #46a86b 0 75%, #3d7fd6 0);
opacity: 0.92;
}
/* The back. Every card you are not entitled to see is one of these. */
.pete-uno-card-back { padding: 0.28rem; }
.pete-uno-back {
display: grid;
place-items: center;
height: 100%;
width: 100%;
border-radius: 0.38rem;
background:
radial-gradient(120% 80% at 50% 0%, rgba(255,255,255,0.16), transparent 60%),
#2b2118;
}
.pete-uno-back::after {
content: "UNO";
display: block;
transform: rotate(-24deg);
padding: 0.1rem 0.35rem;
border-radius: 999px;
background: #e0b02c;
color: #2b2118;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.02em;
}
/* Your hand. It fans, it wraps, and a card you can play stands up out of it —
being shown what goes is the game teaching you, and the server still decides. */
.pete-uno-hand {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.4rem;
min-height: 6.8rem;
padding-top: 0.6rem;
}
.pete-uno-hand .pete-uno-card {
animation: pete-uno-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) backwards;
animation-delay: calc(var(--i, 0) * 45ms);
transition: transform 0.16s ease, filter 0.16s ease;
}
.pete-uno-hand .pete-uno-card[data-on="1"] {
cursor: pointer;
transform: translateY(-0.5rem);
}
.pete-uno-hand .pete-uno-card[data-on="1"]:hover { transform: translateY(-1.1rem) scale(1.03); }
.pete-uno-hand .pete-uno-card[data-on="0"] { filter: brightness(0.62) saturate(0.7); }
@keyframes pete-uno-in {
from { transform: translateY(2rem) rotate(6deg); opacity: 0; }
to { transform: translateY(-0.5rem); opacity: 1; }
}
/* A seat: a fan of backs, a name, a count. The fan overlaps, so eight cards
read as a hand rather than a row. */
.pete-uno-seat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
position: relative;
padding: 0.5rem 0.7rem;
border-radius: 1rem;
transition: background 0.25s ease, transform 0.25s ease;
}
.pete-uno-seat[data-turn="1"] {
background: rgba(255,255,255,0.12);
transform: translateY(-2px);
}
.pete-uno-seat[data-uno="1"] .pete-uno-count { color: #ffd76e; }
.pete-uno-fan {
display: flex;
--uno-h: 3.2rem;
--uno-w: 2.15rem;
}
.pete-uno-fan .pete-uno-card {
margin-left: -1.35rem;
transform: rotate(calc((var(--i, 0) - (var(--n, 1) - 1) / 2) * 4deg));
transform-origin: bottom center;
box-shadow: 0 2px 0 rgba(0,0,0,0.22);
}
.pete-uno-fan .pete-uno-card:first-child { margin-left: 0; }
.pete-uno-fan .pete-uno-back::after { font-size: 0.4rem; padding: 0.06rem 0.22rem; }
.pete-uno-name {
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.9rem;
font-weight: 700;
color: #fff;
line-height: 1;
}
.pete-uno-count {
font-size: 0.7rem;
font-weight: 700;
color: rgba(255,255,255,0.55);
line-height: 1;
}
/* The deck you draw from, and the pile you play onto. */
.pete-uno-deck {
position: relative;
--uno-h: 6.4rem;
--uno-w: 4.3rem;
height: var(--uno-h);
width: var(--uno-w);
border-radius: 0.55rem;
padding: 0.28rem;
background: #fff;
box-shadow:
0 3px 0 rgba(0,0,0,0.22),
0.28rem 0.28rem 0 -0.06rem rgba(255,255,255,0.35),
0.5rem 0.5rem 0 -0.12rem rgba(255,255,255,0.18);
transition: transform 0.15s ease;
}
.pete-uno-deck:not(:disabled):hover { transform: translateY(-3px); }
.pete-uno-deck:disabled { opacity: 0.75; }
.pete-uno-deck-count {
position: absolute;
bottom: -0.55rem;
left: 50%;
transform: translateX(-50%);
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: rgba(0,0,0,0.55);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 1.4;
}
.pete-uno-shuffle { animation: pete-uno-shuffle 0.42s ease; }
@keyframes pete-uno-shuffle {
0%, 100% { transform: none; }
30% { transform: translateY(-4px) rotate(-3deg); }
65% { transform: translateY(2px) rotate(2deg); }
}
.pete-uno-discard {
display: grid;
place-items: center;
height: 6.4rem;
width: 4.3rem;
border-radius: 0.55rem;
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.14);
}
.pete-uno-land { animation: pete-uno-land 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
@keyframes pete-uno-land {
from { transform: scale(1.14) rotate(-4deg); }
to { transform: none; }
}
.pete-uno-colour {
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.15rem 0.6rem;
border-radius: 999px;
color: #fff;
background: rgba(0,0,0,0.3);
}
.pete-uno-colour[data-c="red"] { background: #d64545; }
.pete-uno-colour[data-c="blue"] { background: #3d7fd6; }
.pete-uno-colour[data-c="yellow"] { background: #e0b02c; color: #2b2118; }
.pete-uno-colour[data-c="green"] { background: #46a86b; }
/* A word thrown at a seat: SKIPPED, UNO!, +4. */
.pete-uno-badge {
position: absolute;
top: -0.4rem;
left: 50%;
z-index: 5;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: #fff;
color: #2b2118;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
box-shadow: 0 3px 0 rgba(0,0,0,0.2);
animation: pete-uno-badge 0.9s ease forwards;
}
.pete-uno-badge[data-tone="bad"] { background: #cc3d4a; color: #fff; }
.pete-uno-badge[data-tone="uno"] { background: #e0b02c; }
@keyframes pete-uno-badge {
0% { transform: translate(-50%, 0.4rem) scale(0.7); opacity: 0; }
25% { transform: translate(-50%, -0.5rem) scale(1.06); opacity: 1; }
75% { transform: translate(-50%, -0.9rem) scale(1); opacity: 1; }
100% { transform: translate(-50%, -1.6rem) scale(0.95); opacity: 0; }
}
/* Naming a colour. It covers the felt because until it's answered there is no
legal move on the table underneath it. */
.pete-uno-wild {
position: absolute;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
background: rgba(10, 20, 15, 0.55);
backdrop-filter: blur(2px);
}
.pete-uno-wild-box {
padding: 1.25rem 1.5rem;
border-radius: 1.25rem;
background: rgba(20, 40, 30, 0.92);
box-shadow: 0 12px 30px rgba(0,0,0,0.35);
text-align: center;
}
.pete-uno-swatch {
padding: 0.7rem 1.4rem;
border-radius: 0.75rem;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 1rem;
font-weight: 700;
color: #fff;
background: var(--sw, #666);
box-shadow: 0 3px 0 rgba(0,0,0,0.3);
transition: transform 0.12s ease, filter 0.12s ease;
}
.pete-uno-swatch:hover { transform: translateY(-2px); filter: brightness(1.08); }
.pete-uno-swatch:active { transform: translateY(1px); }
.pete-uno-swatch[data-c="red"] { --sw: #d64545; }
.pete-uno-swatch[data-c="blue"] { --sw: #3d7fd6; }
.pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; }
.pete-uno-swatch[data-c="green"] { --sw: #46a86b; }
/* A phone. The cards get smaller so a hand of ten still fits without the felt
scrolling sideways, which is the thing to check at 390px with a game on it. */
@media (max-width: 639px) {
.pete-uno-hand { --uno-h: 5.2rem; --uno-w: 3.5rem; gap: 0.3rem; }
.pete-uno-deck { --uno-h: 5.2rem; --uno-w: 3.5rem; }
.pete-uno-discard { height: 5.2rem; width: 3.5rem; }
.pete-uno-hand .pete-uno-card { padding: 0.22rem; }
.pete-uno-oval { font-size: 1.15rem; }
.pete-uno-seat { padding: 0.35rem 0.45rem; }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.pete-card, .pete-card,
.pete-card::after, .pete-card::after,

File diff suppressed because one or more lines are too long

View File

@@ -73,19 +73,22 @@
return ((n - Math.floor(n)) * 2 - 1) * (spread || 1); return ((n - Math.floor(n)) * 2 - 1) * (spread || 1);
} }
// fly moves one chip from one place to another and resolves when it lands. // flyNode moves *anything* from one place to another and resolves when it
// lands: a chip, a playing card, a card face down.
// //
// The arc is the point. A straight line between two rects reads as a UI // The arc is the point. A straight line between two rects reads as a UI
// transition; a chip that rises, travels and drops reads as a throw. WAAPI does // transition; something that rises, travels and drops reads as a throw. WAAPI
// this in one keyframe list because it can interpolate a midpoint — a CSS // does this in one keyframe list because it can interpolate a midpoint — a CSS
// transition can't, which is why this isn't a class toggle. // transition can't, which is why this isn't a class toggle.
function fly(from, to, opts) { //
// The node is yours: build it, hand it over, and it is gone when the promise
// resolves. Nothing in here knows or cares what it was.
function flyNode(node, from, to, opts) {
opts = opts || {}; opts = opts || {};
var a = centre(from); var a = centre(from);
var b = centre(to); var b = centre(to);
var el = disc(opts.denom || 25); node.classList.add("pete-fly");
el.className += " pete-fly"; stage().appendChild(node);
stage().appendChild(el);
var dur = opts.duration || 420; var dur = opts.duration || 420;
if (reduced) dur = 1; if (reduced) dur = 1;
@@ -96,12 +99,14 @@
var midX = (a.x + b.x) / 2; var midX = (a.x + b.x) / 2;
var midY = (a.y + b.y) / 2 - lift; var midY = (a.y + b.y) / 2 - lift;
var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin; var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin;
var s0 = opts.fromScale == null ? 0.85 : opts.fromScale;
var s1 = opts.toScale == null ? 1 : opts.toScale;
var anim = el.animate( var anim = node.animate(
[ [
{ transform: t(a.x, a.y, 0.85, 0), opacity: 1, offset: 0 }, { transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 },
{ transform: t(midX, midY, 1.12, spin * 0.6), opacity: 1, offset: 0.5 }, { transform: t(midX, midY, (s0 + s1) / 2 * 1.12, spin * 0.6), opacity: 1, offset: 0.5 },
{ transform: t(b.x, b.y, 1, spin), opacity: opts.fade ? 0 : 1, offset: 1 }, { transform: t(b.x, b.y, s1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
], ],
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" } { duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
); );
@@ -109,10 +114,17 @@
return anim.finished return anim.finished
.catch(function () {}) .catch(function () {})
.then(function () { .then(function () {
el.remove(); node.remove();
}); });
} }
// fly throws one chip. It is flyNode with a chip in it, which is what it always
// was — UNO wanted the same throw with a card in it, so the throw moved out.
function fly(from, to, opts) {
opts = opts || {};
return flyNode(disc(opts.denom || 25), from, to, opts);
}
function t(x, y, scale, rot) { function t(x, y, scale, rot) {
return "translate(" + x + "px," + y + "px) scale(" + scale + ") rotate(" + rot + "deg)"; return "translate(" + x + "px," + y + "px) scale(" + scale + ") rotate(" + rot + "deg)";
} }
@@ -313,6 +325,7 @@
disc: disc, disc: disc,
jitter: jitter, jitter: jitter,
fly: fly, fly: fly,
flyNode: flyNode,
flyMany: flyMany, flyMany: flyMany,
spot: spot, spot: spot,
burst: burst, burst: burst,

View File

@@ -0,0 +1,686 @@
// The UNO table.
//
// Same bargain as every other table in the room: the browser holds no game. It
// sends one move, and what comes back is that move *and every bot turn it handed
// off to*, as a script of events. So a round trip here is a whole lap of the
// table, and this file's main job is to play that lap back slowly enough that
// you can see what happened to you.
//
// Two rules carried over from the tables that came before, both load-bearing:
//
// The number under the pile is a readout of the pile (PeteFX.spot owns that), and
// the chip bar does not move until the chips that justify it have landed. So a
// settling game holds the money back until the payout has physically swept home
// — a counter that pays you before the last card goes down has told you the
// ending.
//
// And the browser never learns a bot's hand. It gets a *count*. Every fan of
// backs you see up there is drawn from a number, because a number is all that
// crossed the wire.
(function () {
"use strict";
var root = document.querySelector("[data-uno]");
if (!root) return;
var FX = window.PeteFX;
var seatsEl = root.querySelector("[data-seats]");
var handEl = root.querySelector("[data-hand]");
var deckEl = root.querySelector("[data-deck]");
var deckCntEl = root.querySelector("[data-deck-count]");
var discardEl = root.querySelector("[data-discard]");
var colourEl = root.querySelector("[data-colour]");
var turnEl = root.querySelector("[data-turn-label]");
var countEl = root.querySelector("[data-count-label]");
var verdictEl = root.querySelector("[data-verdict]");
var paysEl = root.querySelector("[data-pays]");
var meterEl = root.querySelector("[data-meter]");
var tableEl = root.querySelector("[data-table-name]");
var wildEl = root.querySelector("[data-wild]");
var betting = root.querySelector("[data-betting]");
var playing = root.querySelector("[data-playing]");
var drawBtn = root.querySelector("[data-draw]");
var passBtn = root.querySelector("[data-pass]");
var betAmount = root.querySelector("[data-bet-amount]");
var startBtn = root.querySelector("[data-start]");
var msgEl = root.querySelector("[data-table-msg]");
var gameMsgEl = root.querySelector("[data-game-msg]");
var purseEl = document.querySelector("[data-chips]");
var spotEl = root.querySelector("[data-spot]");
var houseEl = root.querySelector("[data-house]");
var spot = FX.spot({
spot: spotEl,
stack: root.querySelector("[data-stack]"),
total: root.querySelector("[data-spot-total]"),
});
var bet = 0; // what you're building between games
var busy = false;
var game = null; // the game as the server last described it
var tier = "table";
var pendingWild = -1; // the wild you clicked, waiting on a colour
var reduced = FX.reduced;
function pace(ms) { return reduced ? 0 : ms; }
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
function say(text, tone, where) {
var el = where || msgEl;
if (!el) return;
if (!text) { el.classList.add("hidden"); return; }
el.textContent = text;
el.classList.remove("hidden");
el.style.color = tone === "bad" ? "#cc3d4a" : "";
}
// ---- drawing the cards -----------------------------------------------------
// GLYPHS are what goes in the middle of an action card. The face the engine
// sends is a word ("skip", "+2"); this is the drawing of it.
var GLYPHS = { skip: "🚫", reverse: "⇄", "+2": "+2", wild: "★", "+4": "+4" };
// card builds one UNO card. The oval across the middle at an angle is the whole
// look of the thing — without it a card reads as a coloured button.
function card(c, opts) {
opts = opts || {};
var el = document.createElement(opts.button ? "button" : "div");
if (opts.button) el.type = "button";
el.className = "pete-uno-card";
el.dataset.c = c.wild ? "wild" : c.color;
var face = document.createElement("span");
face.className = "pete-uno-face";
var oval = document.createElement("span");
oval.className = "pete-uno-oval";
oval.textContent = GLYPHS[c.value] || c.value;
face.appendChild(oval);
// The corners, as printed.
["tl", "br"].forEach(function (at) {
var corner = document.createElement("span");
corner.className = "pete-uno-corner";
corner.dataset.at = at;
corner.textContent = GLYPHS[c.value] || c.value;
corner.setAttribute("aria-hidden", "true");
face.appendChild(corner);
});
// A wild is four quadrants of colour — and once it has been played as a
// colour, that colour is the one it wears on the pile.
if (c.wild) {
var wheel = document.createElement("span");
wheel.className = "pete-uno-wheel";
face.appendChild(wheel);
if (c.color && c.color !== "wild") el.dataset.named = c.color;
}
el.appendChild(face);
el.setAttribute("aria-label", label(c));
return el;
}
function label(c) {
var v = c.value === "+2" ? "draw two" :
c.value === "+4" ? "wild draw four" :
c.value === "wild" ? "wild" : c.value;
if (c.wild) return v + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
return c.color + " " + v;
}
function back() {
var el = document.createElement("div");
el.className = "pete-uno-card pete-uno-card-back";
var b = document.createElement("span");
b.className = "pete-uno-back";
el.appendChild(b);
return el;
}
// ---- the board -------------------------------------------------------------
// FAN is the most backs a bot's hand draws, however many it holds. Past this
// the fan is unreadable and the number beside it is doing the work anyway.
var FAN = 8;
function renderSeats(v) {
seatsEl.innerHTML = "";
if (!v) return;
v.seats.forEach(function (s, i) {
if (s.you) return; // you are the hand at the bottom, not a seat up here
var el = document.createElement("div");
el.className = "pete-uno-seat";
el.dataset.seat = String(i);
el.dataset.turn = v.turn === i && v.phase !== "done" ? "1" : "0";
if (s.uno) el.dataset.uno = "1";
var fan = document.createElement("div");
fan.className = "pete-uno-fan";
var show = Math.min(s.cards, FAN);
for (var n = 0; n < show; n++) {
var b = back();
b.style.setProperty("--i", n);
b.style.setProperty("--n", show);
fan.appendChild(b);
}
el.appendChild(fan);
var name = document.createElement("p");
name.className = "pete-uno-name";
name.textContent = s.name;
el.appendChild(name);
var count = document.createElement("p");
count.className = "pete-uno-count";
count.dataset.count = "";
count.textContent = s.cards + (s.cards === 1 ? " card" : " cards");
el.appendChild(count);
seatsEl.appendChild(el);
});
}
function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); }
// Where a card flies to or from, for a seat. Yours is the hand; a bot's is its
// fan; the deck is the deck.
function seatAnchor(i) {
if (i === 0) return handEl;
var el = seatEl(i);
return el ? el.querySelector(".pete-uno-fan") : deckEl;
}
function renderHand(v) {
handEl.innerHTML = "";
if (!v || !v.hand) return;
var playable = {};
(v.playable || []).forEach(function (i) { playable[i] = true; });
var yours = v.turn === 0 && v.phase !== "done";
v.hand.forEach(function (c, i) {
var el = card(c, { button: true });
el.style.setProperty("--i", i);
el.dataset.at = String(i);
var ok = yours && playable[i];
el.dataset.on = ok ? "1" : "0";
el.disabled = !ok || busy;
if (ok) el.addEventListener("click", function () { pick(i, c); });
handEl.appendChild(el);
});
}
function renderPiles(v) {
discardEl.innerHTML = "";
if (!v) {
deckCntEl.textContent = "0";
colourEl.textContent = "";
root.querySelector(".pete-uno").dataset.c = "";
return;
}
deckCntEl.textContent = String(v.deck);
var top = card(v.top);
top.classList.add("pete-uno-top");
discardEl.appendChild(top);
// The colour in play, which after a wild is not the colour of the card you
// are looking at. So it is said in words, and the felt takes a tint of it —
// this is the single most missable fact on the table.
colourEl.textContent = v.color;
colourEl.dataset.c = v.color;
feltEl.dataset.c = v.color;
}
var feltEl = root.querySelector(".pete-uno");
function renderRail(v) {
if (!v) {
paysEl.textContent = "—";
meterEl.dataset.cold = "1";
tableEl.textContent = "";
return;
}
paysEl.textContent = (v.pays || 0).toLocaleString();
meterEl.dataset.cold = v.phase === "done" ? "1" : "0";
tableEl.textContent = v.tier.name + " · " + v.tier.base.toFixed(1) + "×";
}
function renderTurn(v) {
if (!v || v.phase === "done") {
turnEl.textContent = "";
countEl.textContent = "";
return;
}
var yours = v.turn === 0;
var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
if (yours && v.phase === "drawn") who = "Play it, or keep it";
turnEl.textContent = who;
turnEl.dataset.you = yours ? "1" : "0";
var n = v.hand.length;
countEl.textContent = n + (n === 1 ? " card — UNO!" : " cards");
}
// ---- phases ----------------------------------------------------------------
var VERDICTS = {
won: "You went out! 🎉",
lost: "Beaten to it.",
stuck: "Nobody could move.",
};
function verdict(v) {
var text = VERDICTS[v.outcome] || "";
if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) {
text = v.seats[v.winner].name + " went out first.";
}
if (!text) { verdictEl.classList.add("hidden"); return; }
if (v.net > 0) text += " +" + v.net.toLocaleString();
else if (v.net < 0) text += " " + v.net.toLocaleString();
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
if (v.outcome === "won") FX.burst(verdictEl, { count: 34 });
}
function setPhase(v) {
game = v;
var live = !!v && v.phase !== "done";
betting.classList.toggle("hidden", live);
playing.classList.toggle("hidden", !live);
hideWild();
var yours = live && v.turn === 0;
if (drawBtn) {
drawBtn.disabled = !yours || v.phase === "drawn" || busy;
drawBtn.classList.toggle("hidden", !!(live && v.phase === "drawn"));
}
if (passBtn) {
passBtn.classList.toggle("hidden", !(live && v.phase === "drawn"));
passBtn.disabled = !yours || busy;
}
if (deckEl) deckEl.disabled = !yours || v.phase === "drawn" || busy;
if (!v || !v.outcome) verdictEl.classList.add("hidden");
}
// paint puts the board up with no animation: the resume path, after a reload or
// a redeploy. Whatever the server says is on the table is on the table.
function paint(v) {
renderSeats(v);
renderPiles(v);
renderHand(v);
renderRail(v);
renderTurn(v);
spot.render(v && v.phase !== "done" ? v.bet : 0);
setPhase(v);
}
// ---- the script ------------------------------------------------------------
// throwCard sends a card from one place to another across the felt.
function throwCard(node, from, to, opts) {
opts = opts || {};
return FX.flyNode(node, from, to, {
duration: opts.duration || 380,
lift: opts.lift == null ? 0.7 : opts.lift,
spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin,
fromScale: opts.fromScale == null ? 0.9 : opts.fromScale,
delay: opts.delay || 0,
});
}
// bump keeps a bot's fan honest during the script: the server's count is the
// truth, but between events the fan has to grow and shrink as cards move, or
// the flight lands on a pile that hasn't changed.
function bump(seat, left) {
if (seat === 0 || left == null) return;
var el = seatEl(seat);
if (!el) return;
var fan = el.querySelector(".pete-uno-fan");
var count = el.querySelector("[data-count]");
if (count) count.textContent = left + (left === 1 ? " card" : " cards");
if (!fan) return;
var show = Math.min(left, FAN);
while (fan.children.length > show) fan.removeChild(fan.lastChild);
while (fan.children.length < show) fan.appendChild(back());
Array.prototype.forEach.call(fan.children, function (b, i) {
b.style.setProperty("--i", i);
b.style.setProperty("--n", fan.children.length);
});
el.dataset.uno = left === 1 ? "1" : "0";
}
function spotlight(seat) {
seatsEl.querySelectorAll(".pete-uno-seat").forEach(function (el) {
el.dataset.turn = parseInt(el.dataset.seat, 10) === seat ? "1" : "0";
});
}
// badge floats a word off a seat: SKIPPED, UNO!, +4. The events already say
// these things; the badge is what makes them land on somebody.
function badge(seat, text, tone) {
var host = seat === 0 ? handEl : seatEl(seat);
if (!host || reduced) return Promise.resolve();
var b = document.createElement("span");
b.className = "pete-uno-badge";
b.dataset.tone = tone || "";
b.textContent = text;
host.appendChild(b);
return new Promise(function (r) {
setTimeout(function () { b.remove(); r(); }, 900);
});
}
// play walks the server's script. On a live game the money is already right —
// your stake left your pile when you pressed Deal, and it's on the spot — so
// the chip bar can catch up immediately. On a settling one it waits.
function play(view, money) {
var events = view.uno_events || [];
var final = view.uno;
var settles = !!final && final.phase === "done";
var chain = Promise.resolve();
if (!settles) money();
events.forEach(function (e, n) {
chain = chain.then(function () {
switch (e.kind) {
case "deal":
// The deal isn't animated card by card: seven cards to each of four
// seats is 28 flights and a player who wants to play. The hand fans in
// on its own (CSS), which reads as being dealt without taking as long.
paint(final);
return wait(320);
case "play": {
spotlight(e.seat);
var node = card(e.card);
var from = seatAnchor(e.seat);
// Your own card leaves the hand it was in, so the hand has to lose it
// before the flight or the card is briefly in two places.
if (e.seat === 0) {
var live = handEl.querySelector('.pete-uno-card[data-on="1"][data-at]');
if (live) live.style.visibility = "hidden";
}
bump(e.seat, e.left);
return throwCard(node, from, discardEl, { index: n })
.then(function () {
discardEl.innerHTML = "";
var top = card(e.card);
top.classList.add("pete-uno-top", "pete-uno-land");
discardEl.appendChild(top);
if (e.color) {
colourEl.textContent = e.color;
colourEl.dataset.c = e.color;
feltEl.dataset.c = e.color;
}
return wait(e.seat === 0 ? 120 : 300);
});
}
case "draw":
case "forced": {
spotlight(e.seat);
var to = seatAnchor(e.seat);
var backs = [];
for (var i = 0; i < Math.min(e.n, 4); i++) {
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
}
var punished = e.kind === "forced";
if (punished) badge(e.seat, "+" + e.n, "bad");
return Promise.all(backs).then(function () {
bump(e.seat, e.left);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
// Your own drawn card comes face up — it's yours, and the server
// sent its face for exactly this.
if (e.seat === 0 && e.card) return wait(160);
return wait(punished ? 380 : 180);
});
}
case "skip":
return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });
case "reverse":
feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1";
return badge(e.seat, "Reverse").then(function () { return wait(60); });
case "uno":
return badge(e.seat, "UNO!", "uno");
case "reshuffle":
deckEl.classList.add("pete-uno-shuffle");
return wait(420).then(function () {
deckEl.classList.remove("pete-uno-shuffle");
});
case "pass":
spotlight(e.seat);
return wait(140);
case "settle":
return;
}
});
});
return chain.then(function () {
if (!final) { paint(null); money(); return; }
if (!settles) {
paint(final);
return;
}
// Over: the board settles, the money moves, and only then does the bar
// catch up.
renderSeats(final);
renderPiles(final);
renderHand(final);
renderTurn(final);
renderRail(final);
verdict(final);
playing.classList.add("hidden");
return settleChips(final)
.then(money)
.then(function () { return standing(final.bet); })
.then(function () { setPhase(final); });
});
}
// ---- the money -------------------------------------------------------------
function settleChips(final) {
var payout = final.payout || 0;
if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
var back = payout - final.bet;
return spot
.pour(houseEl, back, { gap: 60 })
.then(function () { return wait(back > 0 ? 380 : 200); })
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
}
// standing puts the stake back on the spot for the next game, the way every
// other table in the room leaves your bet up. pour grows the pile from what it
// is told is already there, so the amount is never set first — that bug printed
// double the stake under trivia's chips for a day.
function standing(amount) {
var money = window.PeteGames.view();
if (!amount || !money || money.chips < amount) {
bet = 0;
showBet();
return;
}
bet = amount;
showBet();
return spot.pour(purseEl, amount);
}
// ---- moves ------------------------------------------------------------------
function pick(i, c) {
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
if (c.wild) { askColour(i); return; }
move({ kind: "play", index: i });
}
function askColour(i) {
pendingWild = i;
wildEl.classList.remove("hidden");
}
function hideWild() {
pendingWild = -1;
if (wildEl) wildEl.classList.add("hidden");
}
// COLOURS maps the colour a player names to the number the engine calls it.
// Wild is 0 there on purpose — a move with no colour on it must not quietly
// mean red — so these start at 1 and this table is the only place that knows.
var COLOURS = { red: 1, blue: 2, yellow: 3, green: 4 };
root.querySelectorAll("[data-colour-pick]").forEach(function (b) {
b.addEventListener("click", function () {
if (busy || pendingWild < 0) return;
var i = pendingWild;
var c = COLOURS[b.dataset.colourPick];
hideWild();
move({ kind: "play", index: i, color: c });
});
});
var cancelWild = root.querySelector("[data-colour-cancel]");
if (cancelWild) cancelWild.addEventListener("click", hideWild);
function move(body) {
send("/api/games/uno/move", body, gameMsgEl);
}
if (drawBtn) drawBtn.addEventListener("click", function () { drawOne(); });
if (deckEl) deckEl.addEventListener("click", function () { drawOne(); });
if (passBtn) {
passBtn.addEventListener("click", function () {
if (busy || !game || game.turn !== 0 || game.phase !== "drawn") return;
move({ kind: "pass" });
});
}
function drawOne() {
if (busy || !game || game.phase !== "play" || game.turn !== 0) return;
move({ kind: "draw" });
}
// ---- talking to the table ----------------------------------------------------
function send(path, body, where) {
if (busy) return;
busy = true;
say("", null, where);
lock();
return window.PeteGames.post(path, body)
.then(function (view) {
busy = false;
return play(view, function () { window.PeteGames.apply(view); });
})
.catch(function (err) {
busy = false;
say(err.message, "bad", where);
return window.PeteGames.refresh().then(function (v) {
if (v && v.uno) paint(v.uno);
else { paint(null); spot.render(0); }
});
});
}
// lock takes the table away while a move is in flight, so a second click can't
// send a second move against a game the first one is about to change.
function lock() {
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) { b.disabled = true; });
if (drawBtn) drawBtn.disabled = true;
if (passBtn) passBtn.disabled = true;
if (deckEl) deckEl.disabled = true;
}
// ---- betting ------------------------------------------------------------------
function showBet() {
betAmount.textContent = bet.toLocaleString();
var money = window.PeteGames.view();
if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
}
function pickTier(slug) {
tier = slug;
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.dataset.on = b.dataset.tier === slug ? "1" : "0";
});
showBet();
}
root.querySelectorAll("[data-tier]").forEach(function (b) {
b.addEventListener("click", function () {
if (busy) return;
pickTier(b.dataset.tier);
});
});
// The chip you click is the chip that flies. Scoped to buttons: the bare
// [data-chip] spans in the rack are the house's, and the house is not betting.
root.querySelectorAll("button[data-chip]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (busy) return;
var d = parseInt(btn.dataset.chip, 10);
var money = window.PeteGames.view();
if (money && bet + d > money.chips) {
say("You haven't got that many chips.", "bad");
return;
}
bet += d;
showBet();
var target = bet;
spot.amount = bet;
FX.fly(btn, spotEl, { denom: d }).then(function () {
if (bet >= target) spot.render(target); // unless Clear got there first
});
});
});
var clearBtn = root.querySelector("[data-bet-clear]");
if (clearBtn) {
clearBtn.addEventListener("click", function () {
if (busy) return;
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
bet = 0;
showBet();
});
}
if (startBtn) {
startBtn.addEventListener("click", function () {
if (busy) return;
if (bet <= 0) { say("Put something on it first.", "bad"); return; }
// The stake sits on the spot for the whole game: it is what's riding on you
// going out first.
send("/api/games/uno/start", { bet: bet, tier: tier });
});
}
pickTier(tier);
var resumed = false;
window.PeteGames.onUpdate(function (v) {
if (!resumed) {
resumed = true;
if (v.uno) {
paint(v.uno);
if (v.uno.phase === "done") verdict(v.uno);
} else {
paint(null);
}
}
showBet();
});
})();

View File

@@ -90,6 +90,22 @@
</p> </p>
</a> </a>
<a href="/games/uno"
class="group rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10 hover:-translate-y-0.5 hover:shadow-pete-lg transition">
<div class="flex items-center gap-3">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[color:var(--accent)]/25 text-2xl">🎴</span>
<div class="min-w-0">
<h3 class="font-display text-xl font-bold">UNO</h3>
<p class="text-sm text-[color:var(--ink)]/60">Go out first, take the table.</p>
</div>
<span class="ml-auto shrink-0 rounded-full bg-theme-gaming px-3 py-1 text-xs font-bold uppercase tracking-wider text-white">Open</span>
</div>
<p class="mt-4 text-sm text-[color:var(--ink)]/70">
One to three bots, and the more of them there are the more it pays: up to 3.6× for
beating a full table. Anybody else going out first takes your stake.
</p>
</a>
{{range .Soon}} {{range .Soon}}
<div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15"> <div class="rounded-3xl bg-[color:var(--card)]/60 p-6 shadow-pete border-2 border-dashed border-[color:var(--ink)]/15">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View File

@@ -0,0 +1,178 @@
{{define "title"}}UNO · {{.Room.Name}}{{end}}
{{define "main"}}
<div class="space-y-6" data-uno>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<a href="/games" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition" title="Back to the casino">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M19 12H5"></path><polyline points="12 19 5 12 12 5"></polyline>
</svg>
<span class="sr-only">Back to the casino</span>
</a>
<h1 class="font-display text-3xl font-bold">UNO</h1>
</div>
<p class="text-sm text-[color:var(--ink)]/50">Go out first, or it was somebody else's night</p>
</div>
{{template "_chipbar" .}}
<!-- The felt. The board takes the room and the money lives in a rail beside it:
there is no corner free on this table for the house rack to sit in. -->
<section class="pete-felt pete-uno relative overflow-hidden rounded-3xl p-4 sm:p-6 lg:p-8 shadow-pete-lg border-2 border-[color:var(--ink)]/10">
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr),auto] lg:gap-8">
<div class="min-w-0 space-y-5">
<!-- The bots. Each one is a name, a fan of backs, and a count — which is
all you are ever told about them, and all a real opponent shows you. -->
<div data-seats class="flex flex-wrap items-start justify-center gap-3 sm:gap-5" aria-label="The other players"></div>
<!-- The middle: what you draw from, and what you play onto. -->
<div class="flex items-center justify-center gap-5 sm:gap-8">
<button type="button" data-deck class="pete-uno-deck" aria-label="Draw a card">
<span class="pete-uno-back" aria-hidden="true"></span>
<span data-deck-count class="pete-uno-deck-count">0</span>
</button>
<div class="flex flex-col items-center gap-2">
<div data-discard class="pete-uno-discard" aria-label="The card in play"></div>
<p data-colour class="pete-uno-colour" aria-live="polite"></p>
</div>
</div>
<!-- Your hand. -->
<div class="space-y-2">
<div class="flex items-baseline justify-between gap-2">
<p data-turn-label class="text-xs font-bold uppercase tracking-wider text-white/50" aria-live="polite"></p>
<p data-count-label class="text-xs font-bold uppercase tracking-wider text-white/40"></p>
</div>
<div data-hand class="pete-uno-hand" aria-label="Your hand"></div>
</div>
<div class="flex min-h-[2.75rem] items-center justify-center">
<p data-verdict class="hidden rounded-full bg-white/95 px-5 py-2 font-display text-lg font-bold text-[#2b2118] shadow-pete"></p>
</div>
</div>
<!-- The rail. -->
<aside class="pete-rail">
<div class="pete-rack" data-at="rail" data-house aria-hidden="true">
<span data-chip="500" style="--stack: 5"></span>
<span data-chip="100" style="--stack: 7"></span>
<span data-chip="25" style="--stack: 4"></span>
<span data-chip="5" style="--stack: 6"></span>
</div>
<div class="pete-spot" data-spot>
<span class="pete-spot-label">Bet</span>
<div class="pete-stack" data-stack></div>
<span data-spot-total class="pete-spot-total hidden"></span>
</div>
<div class="text-center">
<div class="pete-meter" data-meter>
<span class="pete-meter-label">Pays</span>
<span data-pays class="pete-meter-value"></span>
</div>
<p data-table-name class="mt-1.5 text-xs font-bold uppercase tracking-wider text-white/40"></p>
</div>
</aside>
</div>
<!-- Naming a colour for a wild. It sits over the felt because until it is
answered there is no legal move on the table underneath it. -->
<div data-wild class="pete-uno-wild hidden" role="dialog" aria-label="Pick a colour">
<div class="pete-uno-wild-box">
<p class="font-display text-lg font-bold text-white">Pick a colour</p>
<div class="mt-3 grid grid-cols-2 gap-2.5">
<button type="button" data-colour-pick="red" class="pete-uno-swatch" data-c="red">Red</button>
<button type="button" data-colour-pick="blue" class="pete-uno-swatch" data-c="blue">Blue</button>
<button type="button" data-colour-pick="yellow" class="pete-uno-swatch" data-c="yellow">Yellow</button>
<button type="button" data-colour-pick="green" class="pete-uno-swatch" data-c="green">Green</button>
</div>
<button type="button" data-colour-cancel class="mt-3 text-xs font-semibold text-white/50 hover:text-white/80 transition">Play something else</button>
</div>
</div>
</section>
<!-- Playing: shown while a game is live. -->
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3">
<p class="text-sm text-[color:var(--ink)]/50">
Click a card that lights up. Nothing lights up? Draw one.
</p>
<div class="ml-auto flex flex-wrap items-center gap-2">
<button type="button" data-draw
class="rounded-full border-2 border-[color:var(--ink)]/15 bg-[color:var(--card)] px-6 py-3 font-display text-lg font-bold shadow-pete
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Draw
</button>
<button type="button" data-pass
class="hidden rounded-full bg-[color:var(--accent)] px-6 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Keep it
</button>
</div>
</div>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
<!-- Betting: shown between games. -->
<section data-betting class="rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Who are you playing?</div>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Tables}}
<button type="button" data-tier="{{.Slug}}"
class="pete-tier rounded-2xl border-2 p-3 text-left transition">
<div class="flex items-baseline justify-between gap-2">
<span class="font-display text-lg font-bold">{{.Name}}</span>
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{printf "%.1f" .Base}}×</span>
</div>
<p class="mt-0.5 text-xs text-[color:var(--ink)]/50">{{.Blurb}}</p>
<p class="mt-1.5 text-xs font-semibold text-[color:var(--ink)]/40">
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · seven cards each
</p>
</button>
{{end}}
</div>
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Your bet</div>
<div class="font-display text-3xl font-bold tabular-nums"><span data-bet-amount>0</span></div>
</div>
<div class="flex flex-wrap items-center gap-2">
{{range .Denominations}}
<button type="button" data-chip="{{.}}" aria-label="Bet {{.}} more"
class="pete-chip pete-disc grid h-12 w-12 place-items-center font-display text-sm font-bold text-white">
<span>{{.}}</span>
</button>
{{end}}
<button type="button" data-bet-clear
class="rounded-full px-3 py-2 text-sm font-semibold text-[color:var(--ink)]/50 hover:text-[color:var(--ink)] transition">Clear</button>
</div>
<button type="button" data-start
class="ml-auto rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Deal
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
Normal rules: no stacking a +2 on a +2, and a reverse is a skip when it's just the two of you.
</p>
<p data-table-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/js/casino-fx.js" defer></script>
<script src="/static/js/games.js" defer></script>
<script src="/static/js/uno.js" defer></script>
{{end}}

View File

@@ -322,11 +322,65 @@ A multi-session build. This section is the handover; read it before anything els
live games on the felt: no overlap with text, no overlap with the shoe, no live games on the felt: no overlap with text, no overlap with the shoe, no
horizontal overflow, desktop geometry unchanged. horizontal overflow, desktop geometry unchanged.
- **UNO, and it plays for chips.** *(2026-07-14. Built and tested, **not yet driven
in a browser** — see "Next" below, because in this room that means it is not
finished.)*
- **You beat the table, or you don't.** The user's call between three money
models: stake once, go out first and take the tier's multiple; anybody else
going out first takes the stake. **The table size is the tier**, which is the
one dial UNO actually has: Duel (1 bot, 2.2×), Table (2 bots, 2.9×), Full
House (3 bots, 3.6×). Rake on winnings only, as everywhere.
- **The multiples are measured, not guessed.** A player who just plays the first
legal card they hold goes out first 43% / 32% / 27% of the time against the
bots, so the tiers are priced to make that lose about 8% a game — which leaves
good play (holding the wilds back, dumping the colour you're long in) worth
roughly the house's edge. The measurement is a throwaway test, not in the tree;
re-run it if the bots or the tiers change, because the two are a pair.
- **The bots move inside `ApplyMove`, and that is what keeps solo UNO off a
socket.** One request plays your move *and every bot turn it hands off to*,
and returns the lot as a script of events the felt plays back in order. §7 said
solo first, no sockets; this is what that costs.
- **The RNG is in the state, not an argument.** The bots choose and a spent deck
reshuffles, so the engine needs randomness *mid-game* — but there is no rng
alive across requests to hand it. So the seed rides in the state (which never
leaves the server; the deck is in there too) and each step derives its own
generator from the seed and the step count. Value in, value out, and a game
still replays exactly as it fell.
- **The zero value of `Color` is Wild, deliberately.** It was Red for an hour, and
a wild played with the `color` field simply missing from the JSON went down as
a red one. The zero has to be "no colour named", so the omission is refused
instead of quietly meaning something. This is the kind of bug a rules test
finds and a browser never would.
- **The browser never sees a bot's card.** Not the deck, not a hand, not even the
face of a card a bot drew — that last one is most of the deck, and sending it
would turn counting cards into reading the network tab. Seats cross the wire as
a name and a *count*. There are two walls: the engine only attaches a face to
an event the seat may see it in, and `viewUnoEvents` drops it again anyway.
- `internal/games/uno` — engine, 22 tests. The census one is the load-bearing one:
108 cards, each in exactly one place, asserted after every move of 100 games
played out end to end. It is what would catch a reshuffle that leaks cards (the
wilds go back into the deck as *wilds*, not as the colour they were played as)
or a turn the bots never hand back.
- `PeteFX.flyNode` — the throw, with the chip taken out of it. `fly()` is now that
with a chip in it, because UNO wanted the same arc with a card in it. Extracted
rather than copied, same as `casino-cards.js` and `PeteFX.spot()` before it.
- The felt has no corner free for the house rack (bots along the top, piles in the
middle, your hand at the bottom), so it takes solitaire's **rail** instead:
`data-at="rail"`, off the felt, no collision to check for.
### Next, in order ### Next, in order
1. Phase 3 (UNO), Phase 4 (hold'em) as below. 1. **Drive UNO in a browser.** It is built, the engine is tested and the handlers are
2. Trivia is played but not deployed. Hangman, solitaire and trivia are all still tested, but nothing on this table has been *looked at* — and every game before it
sitting on main un-deployed — the server runs `StartTriviaBank`, so its bank found bugs in that step that no Go test could see (a white-on-white pill, a rack on
top of a multiplier, a spot printing double the stake). Specifically worth watching:
the bots' turns play back as a readable script rather than a blur; the wild colour
picker; a hand of ten cards at 390px with no sideways overflow; and a reload
mid-game bringing the board back. `PETE_DEV_CASINO=:7788 go test ./internal/web
-run TestDevCasino -timeout 0`, then Playwright.
2. Phase 4 (hold'em) as below.
3. Trivia is played but not deployed. Hangman, solitaire, trivia and now UNO are all
still sitting on main un-deployed — the server runs `StartTriviaBank`, so its bank
fills itself once the binary is out there, but the first player to try a ladder fills itself once the binary is out there, but the first player to try a ladder
in the first minute after a deploy gets the 503. in the first minute after a deploy gets the 503.