games: the word you owe the table, and the hand you were already holding

Three things, and the first one was a bug.

Your own hand didn't move until the lap ended. bump() keeps the bots'
fans honest and has always refused seat zero, and nothing else touched
yours — so a +4 landing on you at the top of a lap put four backs into
your hand and then nothing, and the cards themselves turned up seconds
later when the script finished and paint() finally ran. You spent the
whole lap looking at a hand you no longer held. The engine now stamps
your hand onto every event that changes it (Event.Hand, seat zero only,
which is the one hand the browser is already entitled to see) and the
table redraws as the cards land. Measured in the running app: 2 -> 3
cards at 414ms into a 1791ms lap.

You couldn't call UNO, and not because the button was missing: going
down to one card *was* the call. discard() fired the uno event by
itself, which made it a thing that happened to you rather than a thing
you did, and a rule nobody can fail is not a rule. So now you say it or
you don't (Move.Uno), and if you don't, every bot still in the game gets
one look at you before any of them plays — because a bot that has moved
on is a bot that has stopped watching your hand. It runs the other way
too, and that half is the fun one: a bot forgets often enough to be
worth watching for, and when it does it says *nothing*. No event, no
badge, no tell on the felt except the count beside its fan reading
"1 card". Catch it and it takes two; call a seat that had nothing to
hide and you take two yourself, which is what stops the catch button
from being a thing you simply mash.

Which cards owe the call is the engine's answer, not a count of your
hand: No Mercy's "discard all" takes every card of its colour with it,
so a six-card hand can land on one, and a browser subtracting one from
six walks you into a catch it never warned you about.

And the room was silent. Every sound in here is *made* — an oscillator,
a burst of filtered noise, an envelope — the same bargain the weather
engine takes with its clouds. A card is a slap of noise through a
bandpass, a chip is two detuned sines with a knock on the front, a win
is four notes going up. No asset files, no round trips, and a sound can
be pitched and detuned per call instead of being the same wav three
hundred times. Hooked into the FX layer rather than into the games, so
every table that throws a chip or turns a card got it at once.

The multiples moved, and the test that exists to catch that caught it.
The naive strategy now calls UNO, because calling is a button and not a
strategy — what these tiers price is bad card play, not a player who
ignores the felt shouting at them — and on that footing the normal
tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full
House did not: it was paying a *negative* house edge, which is the house
paying you to sit down. Re-priced 3.8 -> 3.5.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 13:15:11 -07:00
parent a4666866a8
commit 39ed293f4f
22 changed files with 1450 additions and 59 deletions

201
internal/games/uno/call.go Normal file
View File

@@ -0,0 +1,201 @@
package uno
import "math/rand/v2"
// Calling UNO, and catching the seat that didn't.
//
// This was the one rule on the box the table wasn't playing. A hand going down to
// one card used to emit the "uno" event by itself, which made the call a thing
// that *happened to you* rather than a thing you did — and a rule nobody can fail
// is not a rule, it's an announcement.
//
// So now: play your second-to-last card and you owe the table a word. Say it
// (Move.Uno) and you're safe. Stay quiet and the bots get one look at you, right
// then, before any of them plays — because a bot that has moved on is a bot that
// has stopped watching your hand. Miss it and you take two.
//
// It runs the other way too, and that half is the fun one. A bot forgets often
// enough to be worth watching for, and when it does it says *nothing* — there is
// no event, no badge, no tell on the felt except the count beside its fan reading
// "1 card" with no UNO on it. Catch it (MoveCatch) and it takes two. Call a seat
// that had nothing to hide and you take two yourself, which is what stops the
// catch button from being a thing you simply mash every turn.
//
// The whole rule turns on one asymmetry, and it's deliberate: the bots get a
// *roll* to notice you, and you get to actually look. Attention is the edge here,
// and it's the player's to take.
const (
// CatchPenalty is what silence costs, both ways. Two, as printed on the box.
CatchPenalty = 2
// botForget is how often a bot goes down to one card without saying so. High
// enough that watching the counts pays — at a full table you'll get a catch to
// make every few games — and low enough that a quiet seat is still a thing you
// have to notice rather than assume.
botForget = 0.30
// botAlert is the chance a *single* bot notices that you didn't call. They each
// get a look, so forgetting is punished about 75% of the time heads-up and 98%
// at a full table: the more seats there are watching you, the less you get away
// with, which is the right shape for it.
botAlert = 0.75
)
// botForgets rolls for whether a bot muffs its call.
func botForgets(rng *rand.Rand) bool { return rng.Float64() < botForget }
// declare records whether a seat that is now on one card said so, and — if it did
// — announces it. A seat on any other number of cards owes nothing and this does
// nothing, which is what makes it safe to call after every play.
func (s *State) declare(seat int, called bool, evs *[]Event) {
if s.Phase == PhaseDone || len(s.Hands[seat]) != 1 {
return
}
s.ensureCalled()
s.Called[seat] = called
if called {
*evs = append(*evs, Event{Kind: EvUno, Seat: seat, Left: 1})
}
}
// botsCatch is the window. You have just played, you are holding one card, and
// you didn't say the word — so every bot still in the game gets one look, in seat
// order, and the first to see it takes you for two.
//
// It runs before runBots on purpose. The catch has to land while the table is
// still looking at the card you played, not three turns later.
func (s *State) botsCatch(evs *[]Event, rng *rand.Rand) {
if s.Phase == PhaseDone || len(s.Hands[You]) != 1 || s.called(You) {
return
}
for _, seat := range s.alive() {
if seat == You {
continue
}
if rng.Float64() >= botAlert {
continue // this one wasn't looking
}
s.penalise(You, seat, EvCaught, evs, rng)
return // caught once is caught. They don't queue up to take turns at you
}
}
// playerCatches calls out a seat you think is holding one card in silence.
//
// It is not a turn: right or wrong, the turn stays where it was, which is with
// you. What it costs is the risk — a seat that did call, or isn't on one card at
// all, is a seat you have just accused of nothing, and that is two cards to you.
func (s *State) playerCatches(m Move, rng *rand.Rand) ([]Event, error) {
seat := m.Seat
if seat == You || seat < 0 || seat >= len(s.Hands) || !s.live(seat) {
return nil, ErrNoCatch
}
var evs []Event
if len(s.Hands[seat]) == 1 && !s.called(seat) {
s.penalise(seat, You, EvCaught, &evs, rng) // got them
} else {
s.penalise(You, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking
}
return evs, nil
}
// penalise makes a seat take the price of the call: cards off the deck, quietly —
// no draw event, because what the table is being told about is the catch, and a
// draw event alongside it would animate the same two cards twice.
//
// In No Mercy those two cards can be the two that bury them, which is a fine way
// to go: caught on one card, dead on twenty-five.
func (s *State) penalise(victim, by int, kind string, evs *[]Event, rng *rand.Rand) {
got := s.drawCards(victim, CatchPenalty, evs, rng)
if len(got) == 0 {
return // the table has nothing left to punish anybody with
}
s.ensureCalled()
s.Called[victim] = false
*evs = append(*evs, s.mine(Event{
Kind: kind, Seat: victim, By: by, N: len(got), Left: len(s.Hands[victim]),
}))
s.mercy(victim, evs, rng)
}
// UnoAt is which cards in your hand would leave you holding exactly one, if you
// played them. It is the table's cue to ask you for the call, and it comes from
// here rather than from the browser counting your cards because No Mercy's
// "discard all" doesn't take one card out of your hand — it takes every card of
// its colour, and the browser guessing at that is the browser getting it wrong.
//
// It answers for every card, legal or not. The table only ever asks about a card
// you were allowed to play anyway.
func (s State) UnoAt() []int {
if s.Phase == PhaseDone || s.Turn != You {
return nil
}
hand := s.Hands[You]
var out []int
for i, c := range hand {
left := len(hand) - 1
if c.Value == DiscardAll {
for j, other := range hand {
if j != i && other.Color == c.Color && !other.IsWild() {
left--
}
}
}
if left == 1 {
out = append(out, i)
}
}
return out
}
// Catchable is which seats are, right now, sitting on one card they never
// announced. It is what the browser puts a button on.
//
// This leaks nothing. The card counts are already on the felt and the UNO badge
// already isn't — this is the same two facts, subtracted, and doing that
// subtraction on the server is only so that the rule for what counts as catchable
// lives in one place instead of two.
func (s State) Catchable() []int {
if s.Phase == PhaseDone || s.Turn != You {
return nil // you can only catch a seat on your own turn
}
var out []int
for _, seat := range s.alive() {
if seat != You && len(s.Hands[seat]) == 1 && !s.called(seat) {
out = append(out, seat)
}
}
return out
}
// called reports whether a seat holding one card announced it. Callers outside
// the package read the Called slice, which is on the state they already hold.
func (s State) called(seat int) bool {
return seat < len(s.Called) && s.Called[seat]
}
// ensureCalled grows the slice to fit the table. A game dealt before this rule
// existed has no Called at all, and the seats in it are all — correctly —
// uncalled: nobody in that game ever said the word, because there was no way to.
func (s *State) ensureCalled() {
for len(s.Called) < len(s.Hands) {
s.Called = append(s.Called, false)
}
}
// tidyCalls forgets the calls that no longer mean anything. A seat's call is only
// ever about the one card it is holding — draw into two and the word you said is
// spent, and saying it again is a new thing you have to do.
//
// Without this, a seat that called on one card, was made to draw, and worked its
// way back down to one would still be wearing the old call, and could never be
// caught again for the rest of the game.
func (s *State) tidyCalls() {
s.ensureCalled()
for seat := range s.Called {
if len(s.Hands[seat]) != 1 {
s.Called[seat] = false
}
}
}

View File

@@ -0,0 +1,323 @@
package uno
import "testing"
// The UNO call, and the catch on the other side of it. See call.go.
// oneCardAway sets you up holding two cards, both of which go on the pile, so
// playing either one takes you to UNO.
//
// The bot is given a hand that can't touch a red pile and a deck that can't help
// it, so whatever it does on its turn, it does not make you draw. That matters:
// a hand that grows spends the call (see tidyCalls), which is correct and would
// otherwise make these tests flap on the seeds where the bot happens to turn up
// a +2.
func oneCardAway(t *testing.T, seed uint64) State {
t.Helper()
s := deal(t, duel(), 100, seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
s.Hands[1] = []Card{{Blue, Three}, {Green, Four}, {Yellow, Six}}
s.Deck = make([]Card, 24)
for i := range s.Deck {
s.Deck[i] = Card{Blue, Nine} // nothing here plays on a red one, and nothing bites
}
s.Turn = You
s.Phase = PhasePlay
return s
}
// TestCallingUnoKeepsYouSafe — say the word and the table has nothing on you.
// Across a spread of seeds, not one bot ever gets a catch.
func TestCallingUnoKeepsYouSafe(t *testing.T) {
for seed := uint64(0); seed < 200; seed++ {
s := oneCardAway(t, seed)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true})
if err != nil {
t.Fatalf("seed %d: play: %v", seed, err)
}
if hasKind(evs, EvCaught) {
t.Fatalf("seed %d: caught after calling UNO", seed)
}
if !hasKind(evs, EvUno) {
t.Fatalf("seed %d: called UNO and the table never said so", seed)
}
if !next.Called[You] {
t.Fatalf("seed %d: the call wasn't recorded", seed)
}
}
}
// TestForgettingUnoGetsYouCaught — stay quiet on one card and the bot takes you
// for two. It gets one look, so it misses sometimes; over 400 games it should
// land near botAlert, and the two cards should actually arrive.
func TestForgettingUnoGetsYouCaught(t *testing.T) {
caught, games := 0, 400
for seed := uint64(0); seed < uint64(games); seed++ {
s := oneCardAway(t, seed)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("seed %d: play: %v", seed, err)
}
if !hasKind(evs, EvCaught) {
continue
}
caught++
// One card left after the play, plus the two the catch cost.
if n := len(next.Hands[You]); n != 3 {
t.Fatalf("seed %d: caught and holding %d, want 3", seed, n)
}
if hasKind(evs, EvUno) {
t.Fatalf("seed %d: an UNO was announced by a seat that never called", seed)
}
}
rate := float64(caught) / float64(games)
if rate < botAlert-0.08 || rate > botAlert+0.08 {
t.Errorf("one bot caught you %.0f%% of the time, want about %.0f%% (botAlert)",
rate*100, botAlert*100)
}
}
// TestMoreBotsMeansLessGettingAwayWithIt — every seat gets its own look, so
// forgetting at a full table is very nearly always punished.
func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) {
away := func(tier Tier, seed uint64) bool {
s := deal(t, tier, 100, seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
s.Turn = You
s.Phase = PhasePlay
_, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play: %v", err)
}
return !hasKind(evs, EvCaught)
}
var got [2]float64
for i, tier := range []Tier{duel(), table()} {
escapes := 0
for seed := uint64(0); seed < 400; seed++ {
if away(tier, seed) {
escapes++
}
}
got[i] = float64(escapes) / 400
}
if got[1] >= got[0] {
t.Errorf("you got away with it %.0f%% of the time against one bot and %.0f%% against two; "+
"two pairs of eyes should catch you more often, not less", got[0]*100, got[1]*100)
}
}
// quietBot puts a bot on one card it never called, with the turn back on you.
func quietBot(t *testing.T, called bool) State {
t.Helper()
s := deal(t, duel(), 100, 21)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Blue, Two}, {Green, Three}}
s.Hands[1] = []Card{{Yellow, Nine}}
s.Called = []bool{false, called}
s.Turn = You
s.Phase = PhasePlay
return s
}
// TestCatchingAQuietBot — it's on one card and it never said so. Two cards to it,
// and the turn is still yours: catching is not a move you spend a turn on.
func TestCatchingAQuietBot(t *testing.T) {
s := quietBot(t, false)
before := total(census(s))
next, evs, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: 1})
if err != nil {
t.Fatalf("catch: %v", err)
}
if !hasKind(evs, EvCaught) {
t.Fatal("no catch event")
}
if n := len(next.Hands[1]); n != 3 {
t.Errorf("the bot holds %d, want 3: one card, plus the two it just took", n)
}
if n := len(next.Hands[You]); n != 3 {
t.Errorf("your hand is %d, want 3: a catch costs you nothing", n)
}
if next.Turn != You {
t.Errorf("the turn went to seat %d: a catch is not a turn", next.Turn)
}
if total(census(next)) != before {
t.Error("the catch lost a card")
}
}
// TestCatchingACleanBotCostsYou — it called, or it isn't on one card at all.
// Either way you've accused it of nothing, and that is two cards to you.
func TestCatchingACleanBotCostsYou(t *testing.T) {
for _, tc := range []struct {
name string
state State
}{
{"it called", quietBot(t, true)},
{"it isn't even close", func() State {
s := quietBot(t, false)
s.Hands[1] = []Card{{Yellow, Nine}, {Yellow, Eight}}
return s
}()},
} {
t.Run(tc.name, func(t *testing.T) {
next, evs, err := ApplyMove(tc.state, Move{Kind: MoveCatch, Seat: 1})
if err != nil {
t.Fatalf("catch: %v", err)
}
if !hasKind(evs, EvMiscall) {
t.Fatal("no miscall event")
}
if n := len(next.Hands[You]); n != 5 {
t.Errorf("your hand is %d, want 5: three, plus the two a bad call cost", n)
}
if next.Turn != You {
t.Errorf("the turn went to seat %d: even a bad catch isn't a turn", next.Turn)
}
})
}
}
// TestYouCannotCatchYourself, or a seat that isn't at the table.
func TestYouCannotCatchYourself(t *testing.T) {
s := quietBot(t, false)
for _, seat := range []int{You, -1, 9} {
if _, _, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch {
t.Errorf("catching seat %d: got %v, want ErrNoCatch", seat, err)
}
}
}
// TestACallIsSpentWhenTheHandGrows. Call on one card, get made to draw, and work
// your way back down to one: that is a new call you owe, not the old one still
// standing. Without this a seat could be caught out once and never again.
func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
s := deal(t, duel(), 100, 5)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}}
s.Called = []bool{true, false}
s.Turn = You
s.Phase = PhasePlay
// Draw, and the hand is two: the word you said was about a card you no longer
// hold on its own.
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if next.Called[You] {
t.Error("the call survived the hand growing; it should be spent")
}
}
// TestCatchableIsWhatTheTableCanSee — a quiet bot on one card, and nobody else.
func TestCatchable(t *testing.T) {
s := quietBot(t, false)
if got := s.Catchable(); len(got) != 1 || got[0] != 1 {
t.Errorf("Catchable() = %v, want [1]", got)
}
clean := quietBot(t, true)
if got := clean.Catchable(); len(got) != 0 {
t.Errorf("Catchable() = %v on a bot that called, want none", got)
}
// And not on somebody else's turn: you can only call it out when it's on you.
off := quietBot(t, false)
off.Turn = 1
if got := off.Catchable(); len(got) != 0 {
t.Errorf("Catchable() = %v off-turn, want none", got)
}
}
// TestUnoAtSeesThroughDiscardAll — the whole reason the table asks the engine
// which cards take you to one, rather than counting your hand itself. "Discard
// all" takes every card of its colour with it, so a six-card hand can land on
// one, and a browser subtracting one from six gets a player caught.
func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
s := deal(t, nmDuel(), 100, 3)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Red, Seven}, {Blue, Two}}
s.Turn = You
s.Phase = PhasePlay
// Index 0 dumps itself and the three other reds: five cards become one.
// Index 4 is an ordinary play: five become four.
got := s.UnoAt()
if len(got) != 1 || got[0] != 0 {
t.Errorf("UnoAt() = %v, want [0]: only the discard-all lands you on one card", got)
}
}
// TestUnoAtIsTheOrdinaryCaseToo — two cards in hand, and either of them is a call.
func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) {
s := oneCardAway(t, 1)
got := s.UnoAt()
if len(got) != 2 {
t.Errorf("UnoAt() = %v, want both cards: either one leaves you holding one", got)
}
}
// TestGoingOutNeedsNoCall — your last card is not one card, it's none. Nobody
// owes the table a word for winning.
func TestGoingOutNeedsNoCall(t *testing.T) {
s := deal(t, duel(), 100, 9)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play the last card: %v", err)
}
if !next.Outcome.Won() {
t.Fatalf("outcome %q, want a win", next.Outcome)
}
if hasKind(evs, EvCaught) {
t.Error("caught for not calling UNO on the card that won the game")
}
}
// TestABotThatForgetsSaysNothing — the tell is the absence of the badge, and the
// count beside the fan. If a quiet bot emitted anything at all there'd be nothing
// to spot.
func TestABotThatForgetsSaysNothing(t *testing.T) {
quiet := 0
for seed := uint64(0); seed < 300 && quiet < 1; seed++ {
s := deal(t, duel(), 100, seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Blue, Two}, {Blue, Three}, {Blue, Four}}
s.Hands[1] = []Card{{Red, One}, {Red, Nine}}
s.Turn = 1
s.Phase = PhasePlay
s.Turn = You // the bot plays on the back of your move
// Draw, handing the turn over: the bot plays a red and lands on one card.
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if len(next.Hands[1]) != 1 || next.Called[1] {
continue // it either didn't get down to one, or it remembered
}
quiet++
if hasKind(evs, EvUno) {
t.Error("a bot that forgot to call still announced it")
}
if len(next.Catchable()) != 1 {
t.Error("a quiet bot on one card isn't catchable")
}
}
if quiet == 0 {
t.Skip("no bot forgot in 300 games; botForget may have been turned down")
}
}

View File

@@ -152,7 +152,7 @@ func (s *State) roulette(victim int, color Color, evs *[]Event, rng *rand.Rand)
}
if got > 0 {
e := Event{Kind: EvRoulette, Seat: victim, N: got, Color: color, Left: len(s.Hands[victim])}
*evs = append(*evs, e)
*evs = append(*evs, s.mine(e))
}
if s.mercy(victim, evs, rng) {
return
@@ -181,8 +181,8 @@ func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
top := s.Discard[len(s.Discard)-1]
s.Discard = append(s.Discard[:len(s.Discard)-1], dumped...)
s.Discard = append(s.Discard, top)
*evs = append(*evs, Event{Kind: EvDiscardAll, Seat: seat, N: len(dumped),
Color: color, Left: len(kept)})
*evs = append(*evs, s.mine(Event{Kind: EvDiscardAll, Seat: seat, N: len(dumped),
Color: color, Left: len(kept)}))
}
return len(dumped)
}
@@ -205,7 +205,7 @@ func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
s.Hands[seat] = nil
s.Out[seat] = true
s.Pending = 0 // a dead seat pays no bill, and leaves none behind
*evs = append(*evs, Event{Kind: EvMercy, Seat: seat, N: n, Left: 0})
*evs = append(*evs, s.mine(Event{Kind: EvMercy, Seat: seat, N: n, Left: 0}))
if seat == You {
s.lose(evs)

View File

@@ -120,6 +120,19 @@ func playMove(s State, idx int, rng *rand.Rand) Move {
if s.Hands[You][idx].IsWild() {
m.Color = Red + Color(rng.IntN(4))
}
// It calls UNO. That is not a strategy, it is a button — the table puts it in
// front of you and waits — and what these multiples price is *bad card play*,
// not a player who ignores the felt shouting at them.
//
// A player who genuinely never calls loses far more than this one does, and
// nothing here is priced for them. That is the rule having teeth, which is the
// point of it. Naive is the floor for someone playing the game badly, not for
// someone not playing it.
for _, at := range s.UnoAt() {
if at == idx {
m.Uno = true
}
}
return m
}
@@ -323,7 +336,10 @@ func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
s.Phase = PhasePlay
before := total(census(s))
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
// With the call, because this play takes three reds out of the hand at once and
// lands on exactly one card — which is precisely the case a browser counting
// "hand length minus one" would miss, and the reason UnoAt is the engine's job.
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true})
if err != nil {
t.Fatalf("play discard-all: %v", err)
}

View File

@@ -39,6 +39,7 @@ var (
ErrMustPlayNow = errors.New("uno: play the card you drew, or pass")
ErrMustStack = errors.New("uno: answer the stack with a draw card, or take it")
ErrNoStack = errors.New("uno: there's no stack to take")
ErrNoCatch = errors.New("uno: there's nobody to catch there")
ErrUnknownMove = errors.New("uno: unknown move")
ErrBadBet = errors.New("uno: bet must be positive")
ErrUnknownTier = errors.New("uno: no such tier")
@@ -233,6 +234,13 @@ func (t Tier) Deck() []Card {
// instead of the 8% they were meant to. The numbers below are the honest ones.
//
// This is exactly the drift TestTheMultiplesAreStillPriced now exists to stop.
//
// Re-measured again when the UNO call went in (call.go): 40.1% / 28.5% / 23.1%,
// which is the same game. That is the point — the naive player *calls*, because
// calling is a button and not a strategy, so the rule costs them nothing and
// these multiples stand. What the rule adds is upside for a player who watches
// the counts and catches a quiet bot, which is the good play these tiers are
// meant to leave room for.
var Tiers = []Tier{
{Slug: "duel", Name: "Duel", Bots: 1, Base: 2.4,
Blurb: "One bot, head to head. A reverse is a skip with two at the table."},
@@ -246,7 +254,7 @@ var Tiers = []Tier{
//
// The multiples are measured, not guessed, and they are *not* the normal ones —
// the naive strategy (play the first legal card; take a stack you can't answer)
// wins 46.7% / 31.2% / 25.3% here, against 40.3 / 29.2 / 23.3 on the normal deck.
// wins 45.6% / 31.8% / 27.4% here, against 40.1 / 28.5 / 23.1 on the normal deck.
//
// Which is to say: **No Mercy is easier than UNO**, at every table size, and so
// it pays less. That reads backwards until you see why. The mercy rule kills
@@ -264,7 +272,11 @@ var NoMercyTiers = []Tier{
Blurb: "One bot, 168 cards. Stack the draws or eat them."},
{Slug: "nm-table", Name: "No Mercy Table", Bots: 2, Base: 3.1, NoMercy: true,
Blurb: "Two bots. A +10 answered twice is somebody's whole hand."},
{Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Base: 3.8, NoMercy: true,
// Re-priced 2026-07-14 with the call rule in: 3.8 was paying a *negative* house
// edge here (-0.2%), which is the house paying you to sit down. The naive win
// rate at this table is 27.4%, not the 25.3% it was priced off — three bots
// burying each other is even better for you than the last measurement caught.
{Slug: "nm-full", Name: "No Mercy Full House", Bots: 3, Base: 3.5, NoMercy: true,
Blurb: "Three bots. Twenty-five cards and you're out of the game."},
}
@@ -325,6 +337,11 @@ type State struct {
Out []bool `json:"out,omitempty"`
Pending int `json:"pending,omitempty"`
// Called is who, holding one card, said so. It is only ever meaningful for a
// seat on exactly one card — every other seat's entry is false and means
// nothing — and it is what a catch is tested against. See call.go.
Called []bool `json:"called,omitempty"`
Seed1 uint64 `json:"seed1"`
Seed2 uint64 `json:"seed2"`
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
@@ -346,7 +363,26 @@ type Event struct {
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
By int `json:"by"` // who caught them, on a catch. Seat zero is a real answer here, so never omitempty
Text string `json:"text,omitempty"`
// Hand is *your* hand as it stands after this event, and it is only ever set
// on an event that changed it. The table plays a lap of events back over
// several seconds, and for all of them the hand on screen has to be the hand
// you actually hold — a +4 that lands on you at the top of the lap must show
// up in your fan as it lands, not when the lap ends. There is no leak here:
// this is the one hand the browser is already entitled to see.
Hand []Card `json:"hand,omitempty"`
}
// mine stamps the player's hand onto an event that just changed it. Events about
// a bot's hand carry nothing — the browser never learns those, and stamping is
// scoped to seat zero precisely so it can't start.
func (s *State) mine(e Event) Event {
if e.Seat == You {
e.Hand = append([]Card(nil), s.Hands[You]...)
}
return e
}
// The kinds an Event comes in.
@@ -359,7 +395,9 @@ type Event struct {
// 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
// uno a hand is down to one card, and its owner said so
// caught a seat went down to one card *quietly*, and somebody noticed: +2
// miscall you called a seat that had nothing to hide, and paid for it: +2
// reshuffle the discard goes back under
// settle it's over
//
@@ -379,6 +417,8 @@ const (
EvSkip = "skip"
EvReverse = "reverse"
EvUno = "uno"
EvCaught = "caught"
EvMiscall = "miscall"
EvReshuffle = "reshuffle"
EvSettle = "settle"
@@ -392,19 +432,26 @@ const (
// 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"
Kind string `json:"kind"` // "play" | "draw" | "pass" | "take" | "catch"
Index int `json:"index"` // which card of your hand, for a play
Color Color `json:"color"` // the colour you name, for a wild
Uno bool `json:"uno"` // "…and UNO!", for a play that leaves you on one card
Seat int `json:"seat"` // whose silence you're calling, for a catch
}
// Move kinds. Take is No Mercy's: it is how you give in to a stack you can't
// answer, and it is a *decision*, so it gets a name of its own rather than being
// bolted onto draw.
//
// Catch is the other half of the UNO call — see call.go. It is a move you make
// out of turn order (it costs you nothing but the risk of being wrong), so it is
// a kind rather than a flag on the moves that do cost a turn.
const (
MovePlay = "play"
MoveDraw = "draw"
MovePass = "pass"
MoveTake = "take"
MovePlay = "play"
MoveDraw = "draw"
MovePass = "pass"
MoveTake = "take"
MoveCatch = "catch"
)
// New deals a game: a shuffled deck, seven each, and a card turned over.
@@ -434,6 +481,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
seats := t.Bots + 1
s.Out = make([]bool, seats)
s.Called = make([]bool, seats)
s.Hands = make([][]Card, seats)
for i := range s.Hands {
s.Hands[i] = make([]Card, 0, HandSize)
@@ -460,7 +508,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
break
}
return s, []Event{{Kind: EvDeal, Card: s.topPtr(), Color: s.Color}}, nil
return s, []Event{s.mine(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
@@ -479,6 +527,7 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
next := s.clone()
next.Step++
next.ensureCalled()
rng := stepRNG(next.Seed1, next.Seed2, next.Step)
var evs []Event
@@ -492,6 +541,8 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
evs, err = next.playerPasses()
case MoveTake:
evs, err = next.playerTakes(rng)
case MoveCatch:
evs, err = next.playerCatches(m, rng)
default:
return s, nil, ErrUnknownMove
}
@@ -499,8 +550,17 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
return s, nil, err // the caller's state, untouched
}
// Before anybody moves: did you go down to one card without saying so? This is
// the only window there is. The bots are about to take their turns, and a bot
// that has played on is a bot that has stopped looking at your hand.
next.botsCatch(&evs, rng)
// 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.
//
// A catch is not a turn — it leaves the turn where it was, which is with you —
// so this is a no-op after one, and that is the whole mechanism by which
// catching a bot costs you nothing but the risk of being wrong.
next.runBots(&evs, rng)
// And if that left a table nobody can move at, it ends here rather than
@@ -508,6 +568,7 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
if next.Phase != PhaseDone && next.stalled() {
next.stuck(&evs)
}
next.tidyCalls()
return next, evs, nil
}
@@ -543,6 +604,11 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
var evs []Event
s.discard(You, card, m.Color, &evs)
s.after(You, card, &evs, rng)
// Whether you called is checked *after* the card has finished resolving, not
// after it left your hand. No Mercy's "discard all" takes every card of a
// colour with it, so it can drop you from six cards to one in a single play —
// and the seat that gets there that way owes the call just the same.
s.declare(You, m.Uno, &evs)
return evs, nil
}
@@ -693,6 +759,11 @@ func (s *State) botPlays(seat int, card Card, idx int, evs *[]Event, rng *rand.R
}
s.discard(seat, card, color, evs)
s.after(seat, card, evs, rng)
// A bot that has just gone down to one card mostly remembers to say so. When it
// doesn't, it says nothing at all — no event, no badge — and the only thing on
// the table that gives it away is the count beside its fan reading "1 card".
// Spotting that is the player's to do.
s.declare(seat, !botForgets(rng), evs)
}
// stalled reports whether the table is dead: nothing left to draw anywhere, and
@@ -737,10 +808,11 @@ func (s *State) discard(seat int, card Card, color Color, evs *[]Event) {
}
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})
}
*evs = append(*evs, s.mine(e))
// No UNO event here any more. Going down to one card used to *be* the call,
// which meant nobody at this table could ever fail to make it. Now the call is
// a thing a seat does or doesn't do, and it is announced — or conspicuously not
// — by the two functions that know whether it was made. See call.go.
}
// after resolves what the card just played does, and moves the turn on. It is
@@ -834,18 +906,7 @@ func (s *State) punish(victim, n int, evs *[]Event, rng *rand.Rand) {
// 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)
}
got := s.drawCards(seat, n, evs, rng)
if len(got) == 0 {
return got
}
@@ -858,7 +919,28 @@ func (s *State) deal(seat, n int, forced bool, evs *[]Event, rng *rand.Rand) []C
c := got[0]
e.Card = &c // your own card, and only yours, comes face up
}
*evs = append(*evs, e)
*evs = append(*evs, s.mine(e))
return got
}
// drawCards is deal without the announcement: cards come off the deck (with a
// reshuffle under them if it runs dry) and go into a hand, and nothing is said
// about it. deal wraps it to say the usual thing; a catch wraps it to say a
// different thing. It hands back what was actually drawn, which can be fewer
// cards than asked for when the table has nothing left to give.
func (s *State) drawCards(seat, n int, 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)
}
return got
}
@@ -1105,6 +1187,7 @@ func (s State) clone() State {
s.Discard = append([]Card(nil), s.Discard...)
s.Bots = append([]string(nil), s.Bots...)
s.Out = append([]bool(nil), s.Out...)
s.Called = append([]bool(nil), s.Called...)
return s
}