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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user