Compare commits
3 Commits
3e9b93af55
...
6e20883e5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e20883e5d | ||
|
|
d7e63d86a6 | ||
|
|
79c857023f |
132
internal/games/uno/bot.go
Normal file
132
internal/games/uno/bot.go
Normal 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",
|
||||||
|
}
|
||||||
827
internal/games/uno/uno.go
Normal file
827
internal/games/uno/uno.go
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
// 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"` // 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)
|
||||||
|
|
||||||
|
// And if that left a table nobody can move at, it ends here rather than
|
||||||
|
// handing back a turn that has nothing in it. See stalled().
|
||||||
|
if next.Phase != PhaseDone && next.stalled() {
|
||||||
|
next.stuck(&evs)
|
||||||
|
}
|
||||||
|
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, the turn comes back round, or the table dies under
|
||||||
|
// it — a stalled table would otherwise pass the turn round and round forever
|
||||||
|
// without ever reaching you.
|
||||||
|
func (s *State) runBots(evs *[]Event, rng *rand.Rand) {
|
||||||
|
for s.Phase != PhaseDone && s.Turn != You && !s.stalled() {
|
||||||
|
s.botTurn(s.Turn, evs, rng)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// botTurn plays one bot's turn.
|
||||||
|
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
|
||||||
|
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) {
|
||||||
|
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
|
||||||
|
s.advance(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
card, idx = drawn[0], len(s.Hands[seat])-1
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stalled reports whether the table is dead: nothing left to draw anywhere, and
|
||||||
|
// not one seat holding a card that goes on the pile.
|
||||||
|
//
|
||||||
|
// This is the condition, tested directly. It used to be guessed at by counting
|
||||||
|
// how many bots had passed in a row, which could not work: runBots hands the
|
||||||
|
// turn back the moment it comes round to you, so the count never got as high as
|
||||||
|
// the number of seats, and your own empty-handed pass was never in it. The guard
|
||||||
|
// never fired once. A game that can't end is worse than one that ends badly —
|
||||||
|
// and worse than either, a live game you can't finish is chips you can't cash
|
||||||
|
// out, because the cage won't let you leave a hand half-played.
|
||||||
|
func (s State) stalled() bool {
|
||||||
|
if len(s.Deck) > 0 || len(s.Discard) > 1 {
|
||||||
|
return false // there is a card to draw, or a discard to make one out of
|
||||||
|
}
|
||||||
|
for _, hand := range s.Hands {
|
||||||
|
for _, c := range hand {
|
||||||
|
if c.CanPlayOn(s.top(), s.Color) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)))
|
||||||
|
}
|
||||||
787
internal/games/uno/uno_test.go
Normal file
787
internal/games/uno/uno_test.go
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dead is a table nobody can move at: the deck is spent, the discard is one card
|
||||||
|
// deep so there is nothing to reshuffle out of, and not a seat holds a card that
|
||||||
|
// goes on the pile. Every seat can only pass, forever.
|
||||||
|
func dead(hands [][]Card) State {
|
||||||
|
s := rig(hands, Card{Red, Nine}, Red)
|
||||||
|
s.Deck = nil
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// The game has to end here. It used to not: the stuck guard counted how many
|
||||||
|
// bots had passed in a row and asked for more of them than there are seats, so
|
||||||
|
// it never fired once, and a dead table just handed the turn round and round.
|
||||||
|
// That is a game the player cannot finish — and a game they cannot finish is
|
||||||
|
// chips they cannot cash out, because the cage won't let them leave a live hand.
|
||||||
|
func TestDeadTableEnds(t *testing.T) {
|
||||||
|
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}})
|
||||||
|
|
||||||
|
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("draw: %v", err)
|
||||||
|
}
|
||||||
|
if next.Phase != PhaseDone {
|
||||||
|
t.Fatalf("nobody can move and there is nothing to draw: the game is over, not %q", next.Phase)
|
||||||
|
}
|
||||||
|
if next.Outcome != OutcomeStuck {
|
||||||
|
t.Errorf("a tie on the shortest hand is nobody going out: %q", next.Outcome)
|
||||||
|
}
|
||||||
|
if next.Payout != 0 {
|
||||||
|
t.Errorf("a stuck game pays nothing, not %d", next.Payout)
|
||||||
|
}
|
||||||
|
if !hasKind(evs, EvSettle) {
|
||||||
|
t.Errorf("the table has to be told it's over: %+v", evs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the shortest hand takes it, which is the one way a stuck table still pays.
|
||||||
|
func TestDeadTablePaysTheShortestHand(t *testing.T) {
|
||||||
|
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
|
||||||
|
|
||||||
|
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("draw: %v", err)
|
||||||
|
}
|
||||||
|
if next.Outcome != OutcomeWon {
|
||||||
|
t.Fatalf("one card against two is a win: %q", next.Outcome)
|
||||||
|
}
|
||||||
|
if next.Payout != s.Pays() {
|
||||||
|
t.Errorf("payout %d, quoted %d — the felt has to honour what it advertised", next.Payout, s.Pays())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
50
internal/web/games_render_test.go
Normal file
50
internal/web/games_render_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every page the casino routes to must be in the games template set. This is not
|
||||||
|
// a fussy test: uno shipped with its handler wired, its engine tested and its
|
||||||
|
// template written, and every visit to the table answered "unknown page" with a
|
||||||
|
// 500 — because the page was never added to the list server.go parses. Nothing
|
||||||
|
// else caught it, since the other tests call the handlers straight and never go
|
||||||
|
// through render(). Add a game, add it here.
|
||||||
|
func TestEveryCasinoPageRenders(t *testing.T) {
|
||||||
|
s := newCasino(t)
|
||||||
|
|
||||||
|
pages := []string{
|
||||||
|
"/games",
|
||||||
|
"/games/blackjack",
|
||||||
|
"/games/hangman",
|
||||||
|
"/games/solitaire",
|
||||||
|
"/games/trivia",
|
||||||
|
"/games/uno",
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
s.casinoRoutes(mux)
|
||||||
|
|
||||||
|
for _, path := range pages {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, as(t, s, "reala", "GET", path, nil))
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d, want 200 (body: %s)", path, w.Code, strings.TrimSpace(w.Body.String()))
|
||||||
|
}
|
||||||
|
// render() writes the 500 as a body and a page that fails halfway
|
||||||
|
// through still comes back 200, so look at what was actually served.
|
||||||
|
body := w.Body.String()
|
||||||
|
if strings.Contains(body, "unknown page") {
|
||||||
|
t.Fatalf("GET %s served the unknown-page error: the template is missing from the games set in server.go", path)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "</html>") {
|
||||||
|
t.Fatalf("GET %s did not render a whole page (%d bytes) — the template blew up mid-render", path, len(body))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
284
internal/web/games_uno.go
Normal file
284
internal/web/games_uno.go
Normal 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"` // never omitempty: a seat that goes out leaves zero, and zero is the number the table has to draw
|
||||||
|
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)
|
||||||
|
}
|
||||||
147
internal/web/games_uno_test.go
Normal file
147
internal/web/games_uno_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
|||||||
pages []string
|
pages []string
|
||||||
}{
|
}{
|
||||||
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}},
|
||||||
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia"}},
|
{"games_layout", []string{"_chipbar"}, []string{"games", "blackjack", "hangman", "solitaire", "trivia", "uno"}},
|
||||||
}
|
}
|
||||||
tpls := make(map[string]*template.Template)
|
tpls := make(map[string]*template.Template)
|
||||||
for _, set := range sets {
|
for _, set := range sets {
|
||||||
|
|||||||
@@ -1421,6 +1421,349 @@ 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; }
|
||||||
|
/* The vars, not just the box: the card in the discard is a .pete-uno-card and
|
||||||
|
takes its size from them, so sizing the box alone left a full-size card
|
||||||
|
hanging out of a small hole, on top of the colour in play. */
|
||||||
|
.pete-uno-discard { --uno-h: 5.2rem; --uno-w: 3.5rem; 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
@@ -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,19 @@
|
|||||||
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;
|
||||||
|
// Bigger at the top of the arc than at either end — that swell is what sells
|
||||||
|
// the thing as coming towards you. Taken off the larger end, so a throw that
|
||||||
|
// starts small and lands full size still peaks above where it lands, rather
|
||||||
|
// than averaging itself back down to nothing.
|
||||||
|
var sMid = Math.max(s0, s1) * 1.12;
|
||||||
|
|
||||||
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, sMid, 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 +119,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 +330,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,
|
||||||
|
|||||||
707
internal/web/static/js/uno.js
Normal file
707
internal/web/static/js/uno.js
Normal file
@@ -0,0 +1,707 @@
|
|||||||
|
// 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 played = -1; // the card you just played, so the script lifts that one out
|
||||||
|
// of the hand and not merely the first one that lit up
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// controls is the one place that decides what you can click: whose turn the
|
||||||
|
// server says it is, and whether a move of yours is still in flight. Both
|
||||||
|
// halves matter, and they used to be spread across three functions that each
|
||||||
|
// knew half of it — which is how the table came to unlock itself in the middle
|
||||||
|
// of a bot's turn.
|
||||||
|
function controls() {
|
||||||
|
var live = !!game && game.phase !== "done";
|
||||||
|
var yours = live && game.turn === 0;
|
||||||
|
var drawn = live && game.phase === "drawn";
|
||||||
|
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
|
||||||
|
b.disabled = busy || !yours || b.dataset.on !== "1";
|
||||||
|
});
|
||||||
|
if (drawBtn) drawBtn.disabled = busy || !yours || drawn;
|
||||||
|
if (passBtn) passBtn.disabled = busy || !yours;
|
||||||
|
if (deckEl) deckEl.disabled = busy || !yours || drawn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPhase(v) {
|
||||||
|
game = v;
|
||||||
|
var live = !!v && v.phase !== "done";
|
||||||
|
var drawn = live && v.phase === "drawn";
|
||||||
|
betting.classList.toggle("hidden", live);
|
||||||
|
playing.classList.toggle("hidden", !live);
|
||||||
|
hideWild();
|
||||||
|
|
||||||
|
if (drawBtn) drawBtn.classList.toggle("hidden", drawn);
|
||||||
|
if (passBtn) passBtn.classList.toggle("hidden", !drawn);
|
||||||
|
controls();
|
||||||
|
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 && played >= 0) {
|
||||||
|
var live = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]');
|
||||||
|
if (live) live.style.visibility = "hidden";
|
||||||
|
played = -1;
|
||||||
|
}
|
||||||
|
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":
|
||||||
|
// The discard has just gone back under, so the counter has to as
|
||||||
|
// well: the draws that follow in this same script count down from it,
|
||||||
|
// and counting down from a deck that still reads empty gets nowhere.
|
||||||
|
deckCntEl.textContent = String(e.n);
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// Not `back`: that is the card back up there, and a number wearing its name
|
||||||
|
// is a landmine for whoever next wants a face-down card in here.
|
||||||
|
var profit = payout - final.bet;
|
||||||
|
return spot
|
||||||
|
.pour(houseEl, profit, { gap: 60 })
|
||||||
|
.then(function () { return wait(profit > 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) {
|
||||||
|
played = body.kind === "play" ? body.index : -1;
|
||||||
|
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 ----------------------------------------------------
|
||||||
|
|
||||||
|
// send takes the table away for the whole move — not just the request, but the
|
||||||
|
// script that comes back with it. A lap of this table is seconds of animation,
|
||||||
|
// and for every one of them the game on screen is a game the server has already
|
||||||
|
// moved past. Clicking a card in there would send a move against a board that
|
||||||
|
// no longer exists. So busy comes off at the end of the script, not the end of
|
||||||
|
// the request, which is what the other tables do too.
|
||||||
|
function send(path, body, where) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
say("", null, where);
|
||||||
|
controls();
|
||||||
|
return window.PeteGames.post(path, body)
|
||||||
|
.then(function (view) {
|
||||||
|
return play(view, function () { window.PeteGames.apply(view); });
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
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); }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
busy = false;
|
||||||
|
controls();
|
||||||
|
showBet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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">
|
||||||
|
|||||||
178
internal/web/templates/uno.html
Normal file
178
internal/web/templates/uno.html
Normal 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}}
|
||||||
@@ -322,13 +322,93 @@ 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, tested, and now **played** —
|
||||||
|
see "Driven in a browser" at the bottom of this entry, which is where the three
|
||||||
|
bugs were.)*
|
||||||
|
- **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.
|
||||||
|
- **Driven in a browser, 2026-07-14, and it plays.** A Full House game went the
|
||||||
|
distance: the bots' turns come back as a readable script (a card flies from the
|
||||||
|
seat that played it, SKIPPED and +2 land on somebody), the wild picker takes a
|
||||||
|
colour and the felt changes to it, a reload mid-game brings back the hand, the
|
||||||
|
counts, the colour in play and the stake, and the money is right — a Duel staked
|
||||||
|
200 and won paid 428 back into a 4,600 stack (2.2× is 240 of winnings, less the
|
||||||
|
5% rake, so +228 net), while a lost Full House took the stake and nothing else.
|
||||||
|
A thirteen-card hand wraps to three rows at 390px with no sideways overflow and
|
||||||
|
nothing colliding. Console silent.
|
||||||
|
- **Three bugs, and the first one was the whole table.**
|
||||||
|
1. **Every visit to `/games/uno` was a 500.** The handler was wired, the route
|
||||||
|
was in `casinoRoutes()`, the template was written — and `uno` was never added
|
||||||
|
to the list of pages `server.go` parses into the games template set, so
|
||||||
|
`render()` answered "unknown page". No Go test saw it because the casino tests
|
||||||
|
all call the handlers *directly* and never go through `render()`. There is now
|
||||||
|
a test that does: `TestEveryCasinoPageRenders` walks the mux, asks for every
|
||||||
|
page the casino routes to, and fails on a 500 or a half-rendered body. **Add a
|
||||||
|
game, add it there.**
|
||||||
|
2. **The wrong card left your hand.** The play script hid `.pete-uno-card[data-on="1"]`
|
||||||
|
— the *first* card that lit up, not the one you clicked — so playing any other
|
||||||
|
playable card made an innocent one vanish while the card you played sat there
|
||||||
|
and a copy of it flew to the discard. It self-corrected on the re-render, which
|
||||||
|
is why it read as a flicker rather than a bug. The index you played is now kept
|
||||||
|
(`played`) and that card is the one lifted out.
|
||||||
|
3. **On a phone the card in play sat on top of the colour in play.** The mobile
|
||||||
|
query shrank `.pete-uno-discard`'s *box* with a raw height and width, but the
|
||||||
|
card inside it is a `.pete-uno-card` and takes its size from `--uno-h`/`--uno-w`,
|
||||||
|
which the discard never set — so a full-size card hung out of a small hole and
|
||||||
|
covered the RED/BLUE pill under it. The vars go on the discard now. Worth
|
||||||
|
remembering as a rule: **size a card by its vars, never by the box you put it
|
||||||
|
in.**
|
||||||
|
|
||||||
### Next, in order
|
### Next, in order
|
||||||
|
|
||||||
1. Phase 3 (UNO), Phase 4 (hold'em) as below.
|
1. **Phase 4 — hold'em**, as below. It is the last game on the list, and the first
|
||||||
2. Trivia is played but not deployed. Hangman, solitaire and trivia are all still
|
one where a hand has other people in it: the spot is a singleton and the chip
|
||||||
sitting on main un-deployed — the server runs `StartTriviaBank`, so its bank
|
animations are tuned for one seat (see "still open" below).
|
||||||
fills itself once the binary is out there, but the first player to try a ladder
|
2. **Deploy.** Hangman, solitaire, trivia and UNO are all played, and all four are
|
||||||
in the first minute after a deploy gets the 503.
|
still sitting on main un-deployed — the live casino is blackjack and nothing else.
|
||||||
|
The server runs `StartTriviaBank`, so trivia's bank 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.
|
||||||
|
|
||||||
Still open on the table itself, none of it blocking: **split** isn't implemented (the
|
Still open on the table itself, none of it blocking: **split** isn't implemented (the
|
||||||
engine has no move for it), the felt is roomy at desktop widths with only one seat on
|
engine has no move for it), the felt is roomy at desktop widths with only one seat on
|
||||||
|
|||||||
Reference in New Issue
Block a user