Compare commits

..

4 Commits

Author SHA1 Message Date
prosolis
03524aefbc games: the seat you sit in, and the seat you are left in
Six-handed, the felt printed CO on three seats at once. Position walked the
table with nextIn, which steps over folded seats, while the seat count it walked
against still included them — so every muck slid the anchors round and the
labels landed somewhere new. Folding the small blind relabelled it the cutoff.

The two walks are a pair and they are easy to confuse. nextIn asks who is still
in the betting; a fold takes you out of it. Position needs the other question —
who was dealt in — because where you sit is decided when the button moves and
does not change because somebody threw their hand away. So nextDealt, which
skips only the seats that are not in the hand at all, and a note at both of them
saying which is which.

The bots never read this. They use InPosition, which really does want the last
seat still live, and which is deliberately not this function. So the policy is
untouched and the money never moved — the only thing this ever broke was the
badge on the plate, which is precisely why nothing caught it.

TestPositionsDoNotMoveWhenSeatsFold deals six-handed, asserts the table prints
each of BTN/SB/BB/UTG/MP/CO exactly once, then folds the seats out from under it
one at a time and asserts nobody's label moves.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 11:42:18 -07:00
prosolis
8db8845feb games: no mercy on the felt, and the bill that went to the wrong window
The engine has been able to play No Mercy since aca523e. Now a browser can.

The switch is a switch, not a fourth table: the tier is still the table size,
because that is what you are paid for, and the deck is the other dial. Six faces
the normal box does not print, sized by the card's own vars and never by the box
they sit in. The stack says what the bill is on the felt, in the turn line and on
the button, and under it the deck is dead — you cannot draw your way out of a
bill somebody has run up and pointed at you.

The wild draws glow. That started as decoration and turned out to be doing work:
No Mercy prints a coloured +4 right beside the wild one, and in a hand of twenty
the glow is what tells them apart.

A buried seat is not an empty one, which is the whole trap here — a seat killed
at twenty-five holds no cards, and neither does a seat that just went out and
won. The view asks the engine which it is instead of counting to zero, so the
winner is never the corpse.

Two bugs, both found in a browser and neither findable anywhere else:

The felt's stack bill was writing into the chip bar. It was [data-pending], and
so is the bar's "your chips are still coming" readout — and the bar lives inside
the table's own root and comes first in the document. A stack quietly overwrote
the escrow message and never appeared on the felt at all. A table's attributes
are not a private namespace.

And hold'em, re-driven on the 20M-hand policy (six hands, got up 61 ahead of a
100 buy-in, money conserved to the chip — Phase 4 closed), let you click a button
that did nothing: Deal, Leave and Top up stayed alive through the whole deal
animation, where send() drops the click on purpose. The lock is on the buttons
now, not only in the variable.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 11:10:07 -07:00
prosolis
aca523e511 games: no mercy, and the multiples nobody re-measured
No Mercy UNO as a rules dial on the existing tier, not a fourth table: 168 cards,
draw-until-playable, draw-stacking, and the twenty-five card mercy kill. Six
tiers now; a normal game never runs a line of the new code.

The engine is the whole of it so far — the felt hasn't been touched, so there is
no way to play this in a browser yet.

Two things worth knowing.

The normal tiers were mispriced, and had been for a while. They were set against
a naive win rate of 43/32/27%; it now measures 40.3/29.2/23.3%. The bots got
better at some point after the multiples were written down and nobody re-ran the
measurement — which the plan explicitly warns about, because the bots and the
tiers are a pair. Table and Full House had been charging an 18–19% house edge
instead of the 8% they were meant to. All six tiers are repriced off a fresh
measurement, and TestTheMultiplesAreStillPriced now fails the build if they
drift again. It is the test the normal tiers never had, which is how they drifted.

And No Mercy is *easier* than UNO, at every table size, so it pays less. The
mercy rule does not care whose hand hits twenty-five: it kills bots too, and
every bot it buries is one fewer seat that can beat you to the last card. A deck
built to be merciless turns out to be merciless mostly to the table.

The rake test used to assert a payout of 214, which was the 2.2x duel written
down as a number. It failed on a rake that was entirely correct. It derives the
arithmetic from the tier now: the rule is that the house takes its cut of the
profit and never touches the stake, and that holds at any multiple.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 10:07:55 -07:00
prosolis
4bc38859d4 games: the bots come back from school
The 20M-hand policy the trainer was running on millenia, collected. 4,159 nodes,
which is barely more than the 300k-hand placeholder had — the info-set
abstraction is coarse, so what twenty million hands bought is better-converged
strategies at the same decision points, not a bigger tree. The heads-up hit rate
is 94% and chips still conserve across a hundred sessions of real hands.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 10:07:55 -07:00
18 changed files with 1795 additions and 146 deletions

View File

@@ -731,6 +731,24 @@ func (s State) nextIn(from int) int {
return from
}
// nextDealt is the next seat holding cards this hand, folded or not. Where
// nextIn asks "who is still in the betting", this asks "who was dealt in", and
// the pair is easy to confuse: fold three seats and nextIn walks straight past
// them, so anything counting seats round the table lands somewhere different
// depending on how the hand has gone. Position needs the fixed one — where you
// sit is decided when the button moves and does not change because somebody
// mucked.
func (s State) nextDealt(from int) int {
n := len(s.Seats)
for i := 1; i <= n; i++ {
next := (from + i) % n
if s.Seats[next].State != Out {
return next
}
}
return from
}
// onlyActor is the one seat that can still act. Call it when canActCount is 1.
func (s State) onlyActor() int {
for i := range s.Seats {
@@ -838,8 +856,8 @@ func (s State) Position(seat int) string {
return "BB" // heads-up, the other seat is always the big blind
}
sb := s.nextIn(s.Button)
bb := s.nextIn(sb)
sb := s.nextDealt(s.Button)
bb := s.nextDealt(sb)
switch seat {
case sb:
return "SB"
@@ -847,7 +865,7 @@ func (s State) Position(seat int) string {
return "BB"
}
utg := s.nextIn(bb)
utg := s.nextDealt(bb)
if seat == utg {
return "UTG"
}
@@ -856,7 +874,7 @@ func (s State) Position(seat int) string {
// closest to the button is the cutoff.
dist, cur := 0, utg
for i := 0; i < n; i++ {
cur = s.nextIn(cur)
cur = s.nextDealt(cur)
dist++
if cur == seat {
break

View File

@@ -726,3 +726,46 @@ func has(evs []Event, kind string) bool {
}
return false
}
// Where you sit is decided when the button moves, and a fold does not move it.
// Position walked the table with nextIn, which steps over folded seats while the
// seat count still includes them — so as players mucked, the labels slid round
// and a six-handed felt printed CO on three different seats at once. The badge is
// the only thing that reads this, which is exactly why nothing caught it.
func TestPositionsDoNotMoveWhenSeatsFold(t *testing.T) {
s := table(t, Tiers[0], 5, 200) // six-handed
s, _, _ = ApplyMove(s, Move{Kind: Deal})
before := make([]string, len(s.Seats))
for i := range s.Seats {
before[i] = s.Position(i)
}
// Every seat has its own label, and the ones a six-max table prints are these.
seen := map[string]int{}
for _, p := range before {
seen[p]++
}
for _, want := range []string{"BTN", "SB", "BB", "UTG", "MP", "CO"} {
if seen[want] != 1 {
t.Errorf("six-handed: %q appears %d times, want exactly once — got %v",
want, seen[want], before)
}
}
// Now fold seats out of the hand, one at a time. Nobody's position changes by
// mucking — folding is done to the state directly because a fold in the engine
// belongs to whoever is to act, and what is under test is the label, not the turn.
for i := range s.Seats {
if i == You || s.Seats[i].State != Active {
continue
}
s.Seats[i].State = Folded
for j := range s.Seats {
if got := s.Position(j); got != before[j] {
t.Fatalf("seat %d was %q and is now %q after seat %d folded — position is "+
"where you sit, not who is left", j, before[j], got, i)
}
}
}
}

Binary file not shown.

View File

@@ -93,6 +93,64 @@ func botRank(hand []Card, topColor Color, playable []int, minOpponent int) []int
return out
}
// botStack answers a stack, or reports -1 when the bot has nothing to answer it
// with and has to eat the lot.
//
// It plays the *smallest* draw card it holds. The bill is passed on either way —
// what it is passing on is the stack plus whatever it added — so the cheap card
// does the same job as the expensive one, and keeps the +10 in hand for a turn
// when the bot is the one choosing to hurt somebody rather than the one dodging.
//
// The slip is here too: one time in six it reaches for the second-smallest, so a
// player can't read the stack it just passed as a complete inventory of what the
// bot doesn't have.
func botStack(hand []Card, topColor Color, rng *rand.Rand) (Card, int) {
var can []int
for i, c := range hand {
if c.CanStackOn(topColor) {
can = append(can, i)
}
}
if len(can) == 0 {
return Card{}, -1
}
// Smallest draw first. A stable insertion sort: there are never many.
for i := 1; i < len(can); i++ {
for j := i; j > 0 && hand[can[j]].Value.Draw() < hand[can[j-1]].Value.Draw(); j-- {
can[j], can[j-1] = can[j-1], can[j]
}
}
pick := can[0]
if len(can) > 1 && rng.IntN(botSlip) == 0 {
pick = can[1]
}
return hand[pick], pick
}
// botRouletteColor names the colour for a roulette: whichever the bot holds
// *least* of. The victim flips until that colour turns up, so the rarer the
// colour, the longer they flip and the more they keep. Naming the colour you're
// long in is naming the one that ends the flipping soonest, which is mercy — and
// this is not that game.
func botRouletteColor(hand []Card, rng *rand.Rand) Color {
counts := [5]int{}
for _, c := range hand {
if c.Color.Playable() {
counts[c.Color]++
}
}
best, bestN := Wild, 1<<30
for col := Red; col <= Green; col++ {
if counts[col] < bestN {
best, bestN = col, counts[col]
}
}
if best == Wild {
return Red + Color(rng.IntN(4))
}
return best
}
// botColor names a colour for a wild: whichever the bot holds most of, so the
// card it plays next is one it already has. A hand of nothing but wilds picks
// at random rather than always saying red, which would be a tell.

View File

@@ -0,0 +1,249 @@
package uno
import "math/rand/v2"
// No Mercy.
//
// A rules dial, not a fourth table. The table size is still the tier — a duel is
// a duel — and No Mercy is a switch you throw across all three of them. What it
// changes is the game they play:
//
// - A 168-card deck, with faces the normal one doesn't print: a coloured +4, a
// +6, a +10, a skip-everyone, a discard-all, a reverse-and-draw-four, and a
// colour roulette.
// - Draw cards stack. A +2 pointed at you can be answered with any draw card
// you hold, and the bill goes to the next seat with the two added on. Whoever
// runs out of draw cards eats the lot.
// - You draw until you can play. There is no drawing one card and shrugging.
// - And twenty-five cards in your hand kills you. That is the whole point of
// the deck: it is built to bury somebody, and the mercy rule is what happens
// when it does.
//
// Everything here is reached from uno.go behind `s.Tier.NoMercy`. A normal game
// never runs a line of it.
// MercyLimit is the hand that ends you. Reach it and you are out of the game —
// your cards go back in the deck and the table plays on without you.
const MercyLimit = 25
// NewNoMercyDeck builds the 168.
//
// Per colour: two of each number, three skips, two skip-everyones, four
// reverses, two +2s, two coloured +4s and three discard-alls — thirty-six cards,
// times four colours. Then the wilds: eight reverse-draw-fours, four +6s, four
// +10s and eight roulettes. Unshuffled, same as NewDeck, because New shuffles and
// a test wants the order it was built in.
func NewNoMercyDeck() []Card {
d := make([]Card, 0, 168)
for _, col := range []Color{Red, Blue, Yellow, Green} {
for v := Zero; v <= Nine; v++ {
d = append(d, Card{col, v}, Card{col, v})
}
for i := 0; i < 3; i++ {
d = append(d, Card{col, Skip})
}
for i := 0; i < 2; i++ {
d = append(d, Card{col, SkipAll})
}
for i := 0; i < 4; i++ {
d = append(d, Card{col, Reverse})
}
for i := 0; i < 2; i++ {
d = append(d, Card{col, DrawTwo})
}
for i := 0; i < 2; i++ {
d = append(d, Card{col, DrawFour})
}
for i := 0; i < 3; i++ {
d = append(d, Card{col, DiscardAll})
}
}
for i := 0; i < 8; i++ {
d = append(d, Card{Wild, WildRevFour})
}
for i := 0; i < 4; i++ {
d = append(d, Card{Wild, WildDrawSix})
}
for i := 0; i < 4; i++ {
d = append(d, Card{Wild, WildDrawTen})
}
for i := 0; i < 8; i++ {
d = append(d, Card{Wild, WildRoulette})
}
return d
}
// CanStackOn reports whether a card can be thrown onto a stack that is already
// building. Any draw card answers any other — there is no escalation rule, so a
// +2 is a legal reply to a +10 — but a *coloured* draw card still has to follow
// the colour in play. The wild draws always go.
//
// This is why the pending count is not a cap: what you are matching is the fact
// of a draw card, not its size.
func (c Card) CanStackOn(topColor Color) bool {
if c.Value.Draw() == 0 {
return false
}
if c.IsWild() {
return true
}
return c.Color == topColor
}
// canStack reports whether a seat holds anything at all it could answer with.
func (s State) canStack(seat int) bool {
for _, c := range s.Hands[seat] {
if c.CanStackOn(s.Color) {
return true
}
}
return false
}
// absorb is what happens when the stack stops with you: you take every card in
// it, and you lose your turn. The pending count is cleared *before* the cards
// land, because a mercy kill inside the draw ends the seat and there must be no
// bill left standing against a seat that is no longer at the table.
func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) {
n := s.Pending
s.Pending = 0
s.deal(seat, n, true, evs, rng)
// The seat can die paying the bill, and a mercy kill can end the whole game —
// the player dying, or the last bot dying and leaving you alone at the table.
// So the phase is only reset if there is still a game to have a phase.
if s.mercy(seat, evs, rng) && s.Phase == PhaseDone {
return
}
if !s.live(seat) {
s.Phase = PhasePlay
s.advance(1)
return // it died, but the table plays on. Don't skip a seat that isn't there.
}
*evs = append(*evs, Event{Kind: EvSkip, Seat: seat, Left: len(s.Hands[seat])})
s.Phase = PhasePlay
s.advance(1) // the turn is on the seat that just paid, so it moves one on
}
// roulette is the colour roulette: the next seat turns cards over until the
// named colour comes up, and keeps every card it turned. Then it loses its turn.
//
// The deck can run out mid-flip (the discard is reshuffled back under as usual,
// and even that can be dry), so this is bounded by what there is to draw, not by
// the colour ever actually appearing. A wild is not a colour and never ends it.
func (s *State) roulette(victim int, color Color, evs *[]Event, rng *rand.Rand) {
got := 0
for {
if len(s.Deck) == 0 && !s.reshuffle(evs, rng) {
break
}
c, ok := s.pop()
if !ok {
break
}
s.Hands[victim] = append(s.Hands[victim], c)
got++
if c.Color == color {
break
}
if len(s.Hands[victim]) >= MercyLimit {
break // they are dead already; stop dealing cards to a corpse
}
}
if got > 0 {
e := Event{Kind: EvRoulette, Seat: victim, N: got, Color: color, Left: len(s.Hands[victim])}
*evs = append(*evs, e)
}
if s.mercy(victim, evs, rng) {
return
}
*evs = append(*evs, Event{Kind: EvSkip, Seat: victim, Left: len(s.Hands[victim])})
s.advance(2)
}
// discardAll dumps every remaining card of a colour out of a hand and buries it
// under the card that was just played. The pile keeps its top: the played card
// stays the card in play, and the rest go beneath it, where they are still in the
// game (a reshuffle brings them back) and still count in a census.
func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
hand := s.Hands[seat]
kept := make([]Card, 0, len(hand))
var dumped []Card
for _, c := range hand {
if c.Color == color && !c.IsWild() {
dumped = append(dumped, c)
} else {
kept = append(kept, c)
}
}
s.Hands[seat] = kept
if len(dumped) > 0 {
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)})
}
return len(dumped)
}
// mercy checks a seat against the limit and, if it has crossed it, takes it out
// of the game: its cards go back into the deck and it never plays again. It
// reports whether the seat died.
//
// What that *means* depends on who it was. You dying is the game over — the
// stake is gone whatever the bots do next. A bot dying leaves a table with one
// fewer seat, and if it leaves you alone at it, you have won: everybody who could
// have beaten you to the last card is dead.
func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
if !s.Tier.NoMercy || !s.live(seat) || len(s.Hands[seat]) < MercyLimit {
return false
}
n := len(s.Hands[seat])
s.Deck = append(s.Deck, s.Hands[seat]...)
rng.Shuffle(len(s.Deck), func(i, j int) { s.Deck[i], s.Deck[j] = s.Deck[j], s.Deck[i] })
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})
if seat == You {
s.lose(evs)
return true
}
if alive := s.alive(); len(alive) == 1 {
s.settle(alive[0], evs) // you outlived the table
}
return true
}
// Live reports whether a seat is still in the game. The felt needs it: a seat the
// mercy rule has buried holds no cards, and a seat holding no cards is otherwise
// indistinguishable from the one that just went out and won.
func (s State) Live(seat int) bool { return s.live(seat) }
// alive lists the seats still in the game.
func (s State) alive() []int {
var out []int
for i := range s.Hands {
if s.live(i) {
out = append(out, i)
}
}
return out
}
// live reports whether a seat is still playing. Out is empty in a normal game and
// in any game saved before No Mercy existed, so a missing entry is a living seat.
func (s State) live(seat int) bool {
return seat >= len(s.Out) || !s.Out[seat]
}
// lose ends the game against the player without anybody having gone out — which
// is what a mercy kill on seat zero is.
func (s *State) lose(evs *[]Event) {
s.Phase = PhaseDone
s.Outcome = OutcomeLost
s.Payout = 0
*evs = append(*evs, Event{Kind: EvSettle, Seat: You, Text: string(OutcomeLost)})
}

View File

@@ -0,0 +1,421 @@
package uno
import (
"math/rand/v2"
"testing"
)
func nmDuel() Tier { t, _ := TierBySlug("nm-duel"); return t }
func nmTable() Tier { t, _ := TierBySlug("nm-table"); return t }
func nmFull() Tier { t, _ := TierBySlug("nm-full"); return t }
func TestNoMercyDeckIsADeck(t *testing.T) {
m := census(State{Deck: NewNoMercyDeck()})
if got := total(m); got != 168 {
t.Fatalf("deck has %d cards, want 168", got)
}
want := map[Card]int{
{Red, Zero}: 2, // two of every number, unlike the normal deck's single zero
{Blue, Seven}: 2,
{Green, Skip}: 3,
{Yellow, SkipAll}: 2,
{Red, Reverse}: 4,
{Blue, DrawTwo}: 2,
{Green, DrawFour}: 2, // the *coloured* +4
{Yellow, DiscardAll}: 3,
{Wild, WildRevFour}: 8,
{Wild, WildDrawSix}: 4,
{Wild, WildDrawTen}: 4,
{Wild, WildRoulette}: 8,
}
for c, n := range want {
if m[c] != n {
t.Errorf("%v %v: got %d, want %d", c.Color, c.Value, m[c], n)
}
}
// The normal deck's wilds are not in this one, and its coloured +4 is not in
// the normal one. They are different cards that print the same thing.
if m[Card{Wild, WildCard}] != 0 || m[Card{Wild, WildDrawFour}] != 0 {
t.Error("the No Mercy deck should print none of the normal wilds")
}
}
// TestNoMercyCensus is the load-bearing one, and the same one the normal game
// has: 168 cards, each in exactly one place, checked after every move of a
// hundred games played to the end.
//
// It is what would catch the two new ways this deck can lose a card. Discard All
// buries a whole colour under the pile, and a mercy kill shovels a
// twenty-five-card hand back into the deck — either of those dropping a card on
// the floor is a deck that quietly shrinks until the table can't be dealt.
func TestNoMercyCensus(t *testing.T) {
for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} {
for seed := uint64(0); seed < 100; seed++ {
s := deal(t, tier, 100, seed)
start := census(s)
if got := total(start); got != 168 {
t.Fatalf("%s seed %d: dealt %d cards, want 168", tier.Slug, seed, got)
}
rng := rand.New(rand.NewPCG(seed, 99))
for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ {
next, _, err := ApplyMove(s, naive(s, rng))
if err != nil {
t.Fatalf("%s seed %d: %v (phase %s)", tier.Slug, seed, err, s.Phase)
}
s = next
if got := census(s); total(got) != 168 {
t.Fatalf("%s seed %d: %d cards after a move, want 168",
tier.Slug, seed, total(got))
}
}
if s.Phase != PhaseDone {
t.Fatalf("%s seed %d: game never ended", tier.Slug, seed)
}
}
}
}
// naive is the strategy the multiples are priced against: play the first legal
// card you hold, take a stack you can't answer, and draw when you have nothing.
// It is a real way to play and a bad one, which is exactly what a house edge is
// measured against.
func naive(s State, rng *rand.Rand) Move {
if s.Phase == PhaseStack {
if p := s.Playable(); len(p) > 0 {
return playMove(s, p[0], rng)
}
return Move{Kind: MoveTake}
}
if p := s.Playable(); len(p) > 0 {
return playMove(s, p[0], rng)
}
return Move{Kind: MoveDraw}
}
// stack loads a seat's hand up to n cards by taking them off the deck, so the
// table still holds 168 of them. Every card it moves is one that can't be played
// on the pile, which is what a hand on its way to the mercy limit looks like.
func stack(s *State, seat, n int) {
// Every card the seat was holding goes back in the deck first, so the table is
// whole before we take n out of it again. The pile keeps whatever the deal
// turned over — replacing it with a card of our choosing would quietly destroy
// one, and the census below would blame the engine for it.
s.Deck = append(s.Deck, s.Hands[seat]...)
s.Hands[seat] = nil
s.Color = s.top().Color
kept := make([]Card, 0, len(s.Deck))
for _, c := range s.Deck {
if len(s.Hands[seat]) < n {
s.Hands[seat] = append(s.Hands[seat], c)
continue
}
kept = append(kept, c)
}
s.Deck = kept
}
func playMove(s State, idx int, rng *rand.Rand) Move {
m := Move{Kind: MovePlay, Index: idx}
if s.Hands[You][idx].IsWild() {
m.Color = Red + Color(rng.IntN(4))
}
return m
}
// TestAStackIsPassedOnAndPaidOnce walks the one rule the whole mode turns on: a
// draw card doesn't land on you, it *opens a bill*, and the seat that can't
// answer pays the whole thing.
func TestAStackIsPassedOnAndPaid(t *testing.T) {
s := deal(t, nmDuel(), 100, 7)
// Rig it: you hold a +2 on a red pile, the bot holds one card that can answer
// and one that can't.
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
s.Hands[1] = []Card{{Red, DrawTwo}, {Blue, Nine}}
s.Turn = You
s.Phase = PhasePlay
// You play the +2. The bot answers with its own, so the bill comes back to you
// at four — and you have nothing to answer with, so you pay it.
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play +2: %v", err)
}
if next.Phase != PhaseStack {
t.Fatalf("phase is %s, want stack: a +2 in No Mercy opens a stack", next.Phase)
}
if next.Turn != You {
t.Fatalf("the stack came back to seat %d, want you", next.Turn)
}
if next.Pending != 4 {
t.Fatalf("the bill is %d, want 4 (your two, plus the bot's two)", next.Pending)
}
if !hasKind(evs, EvStack) {
t.Error("no stack event: the felt has nothing to show the player")
}
// You cannot draw your way out of it, and you cannot play a card that isn't a
// draw card.
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustStack {
t.Errorf("drawing out of a stack: %v, want ErrMustStack", err)
}
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err)
}
// Pay it. The bot is left holding one card it cannot play, and — because No
// Mercy makes it draw until it can — it will draw into a fresh hand and may
// well open a *new* stack on the way. That's the game working, not a leak, so
// what's asserted here is the bill this seat paid, not the state of the table
// afterwards: four cards into the hand, and the bill discharged.
before := len(next.Hands[You])
paid, evs, err := ApplyMove(next, Move{Kind: MoveTake})
if err != nil {
t.Fatalf("take: %v", err)
}
var forced int
for _, e := range evs {
if e.Kind == EvForced && e.Seat == You {
forced = e.N
}
}
if forced != 4 {
t.Errorf("the stack made you take %d cards, want 4", forced)
}
if len(paid.Hands[You]) < before+4 {
t.Errorf("hand went %d → %d, want at least four more", before, len(paid.Hands[You]))
}
// The bill you paid is gone. Anything pending now is a new stack the bot
// opened after yours was settled, and it is never the one you just paid.
if paid.Pending == 4 && paid.Phase == PhaseStack {
t.Error("the bill you just paid is still standing")
}
}
// TestTwentyFiveCardsKillsYou is the mercy rule, from the player's side: the
// stake is gone the moment the hand hits the limit, whoever else is still playing.
func TestTwentyFiveCardsKillsYou(t *testing.T) {
s := deal(t, nmFull(), 100, 3)
// Twenty-four cards in your hand, and a stack of ten pointed at you.
//
// The cards are *moved* from the deck, not invented: a fixture that conjures
// a hand out of nothing breaks the census before the engine gets a chance to,
// and then the census assertion below is testing the fixture instead of the
// mercy rule.
stack(&s, You, 24)
s.Turn = You
s.Phase = PhaseStack
s.Pending = 10
next, evs, err := ApplyMove(s, Move{Kind: MoveTake})
if err != nil {
t.Fatalf("take: %v", err)
}
if !hasKind(evs, EvMercy) {
t.Fatal("no mercy event: twenty-five cards should have killed the seat")
}
if next.Phase != PhaseDone || next.Outcome != OutcomeLost {
t.Fatalf("phase %s outcome %q, want done/lost", next.Phase, next.Outcome)
}
if next.Payout != 0 {
t.Errorf("a mercy kill paid out %d, want nothing", next.Payout)
}
if len(next.Hands[You]) != 0 || next.live(You) {
t.Error("a dead seat should hold no cards and be out of the game")
}
if got := total(census(next)); got != 168 {
t.Errorf("%d cards after a mercy kill, want 168 — the hand goes back in the deck", got)
}
}
// TestOutlivingTheTableWins is the other side of the mercy rule, and the one
// that makes No Mercy pay less than it looks like it should: the deck buries bots
// too, and a table with every bot dead is a table you have won.
func TestOutlivingTheTableWins(t *testing.T) {
s := deal(t, nmDuel(), 100, 11)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
s.Hands[1] = make([]Card, 0, 24)
for i := 0; i < 24; i++ {
s.Hands[1] = append(s.Hands[1], Card{Blue, Nine}) // nothing it can answer with
}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play +2: %v", err)
}
if !hasKind(evs, EvMercy) {
t.Fatal("the bot should have died taking the stack")
}
if next.Phase != PhaseDone || next.Outcome != OutcomeWon {
t.Fatalf("phase %s outcome %q, want done/won: the last seat standing wins",
next.Phase, next.Outcome)
}
if next.Payout != next.Pays() {
t.Errorf("paid %d, quoted %d — settle and the felt must agree", next.Payout, next.Pays())
}
}
// TestYouDrawUntilYouCanPlay: no drawing one card and shrugging. The turn only
// moves on when the deck itself has nothing left.
func TestYouDrawUntilYouCanPlay(t *testing.T) {
s := deal(t, nmDuel(), 100, 5)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Blue, One}} // nothing playable
// A deck whose first two cards are dead and whose third plays.
s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}}
s.Turn = You
s.Phase = PhasePlay
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
if len(next.Hands[You]) != 4 {
t.Fatalf("hand is %d, want 4: you draw until something plays",
len(next.Hands[You]))
}
if next.Phase != PhaseDrawn {
t.Fatalf("phase %s, want drawn: the card you stopped on is one you must play",
next.Phase)
}
// And you may not pass on it: you drew for it, you play it.
if _, _, err := ApplyMove(next, Move{Kind: MovePass}); err != ErrMustPlayNow {
t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err)
}
}
// TestSkipAllComesBackToYou — everyone else loses their turn, so the turn never
// actually leaves the seat that played it.
func TestSkipAllComesBackToYou(t *testing.T) {
s := deal(t, nmFull(), 100, 13)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, SkipAll}, {Blue, One}}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play skip-all: %v", err)
}
if next.Turn != You {
t.Errorf("turn went to seat %d, want you: skip-all skips everyone else", next.Turn)
}
if !hasKind(evs, EvSkipAll) {
t.Error("no skipall event")
}
}
// TestDiscardAllTakesTheColourWithIt, and the cards it takes are still in the
// game — buried under the pile, not deleted.
func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
s := deal(t, nmDuel(), 100, 17)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}}
s.Turn = You
s.Phase = PhasePlay
before := total(census(s))
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play discard-all: %v", err)
}
if len(next.Hands[You]) != 1 {
t.Fatalf("hand is %d, want 1: every red should have gone with it",
len(next.Hands[You]))
}
if next.Hands[You][0] != (Card{Blue, Two}) {
t.Errorf("kept %v, want the blue two", next.Hands[You][0])
}
if top := next.Top(); top.Value != DiscardAll {
t.Errorf("the card in play is %v, want the discard-all that was played", top.Value)
}
if !hasKind(evs, EvDiscardAll) {
t.Error("no discard event")
}
if got := total(census(next)); got != before {
t.Errorf("%d cards, want %d: a dumped colour is buried, not destroyed", got, before)
}
}
// TestRouletteFlipsUntilTheColour — and the victim keeps every card it turned.
func TestRouletteFlipsUntilTheColour(t *testing.T) {
s := deal(t, nmDuel(), 100, 19)
s.Color = Blue
s.Discard = []Card{{Blue, Five}}
s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}}
s.Hands[1] = []Card{{Green, Three}}
s.Deck = []Card{{Blue, Two}, {Green, Four}, {Yellow, Six}, {Red, Seven}, {Blue, Eight}}
s.Turn = You
s.Phase = PhasePlay
// Name red: the bot flips blue, green, yellow, red — four cards — and keeps them.
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Red})
if err != nil {
t.Fatalf("play roulette: %v", err)
}
var got int
for _, e := range evs {
if e.Kind == EvRoulette {
got = e.N
}
}
if got != 4 {
t.Errorf("flipped %d, want 4 — up to and including the first red", got)
}
// One card it started with, plus the four it turned. (The bot is then skipped,
// so the turn is back with you and it never played any of them.)
if n := len(next.Hands[1]); n != 5 {
t.Errorf("the bot holds %d, want 5", n)
}
if total(census(next)) != total(census(s)) {
t.Error("the roulette lost a card")
}
}
// TestTheMultiplesAreStillPriced measures the naive strategy against the bots and
// checks each tier still charges roughly the house's edge for it.
//
// This is the test that fails when somebody changes the bots, the deck, or a
// rule, and it is *supposed* to: the tier and the game it prices are a pair. If
// this goes red, re-measure and move the number, don't loosen the bound.
func TestTheMultiplesAreStillPriced(t *testing.T) {
if testing.Short() {
t.Skip("slow: plays thousands of games")
}
for _, tier := range AllTiers() {
wins, games := 0, 3000
for seed := 0; seed < games; seed++ {
s := deal(t, tier, 100, uint64(seed)+7777)
rng := rand.New(rand.NewPCG(uint64(seed), 4242))
for moves := 0; s.Phase != PhaseDone && moves < 800; moves++ {
next, _, err := ApplyMove(s, naive(s, rng))
if err != nil {
t.Fatalf("%s: %v", tier.Slug, err)
}
s = next
}
if s.Outcome.Won() {
wins++
}
}
p := float64(wins) / float64(games)
// What a staked chip comes back as, playing badly: you win p of the time and
// keep the multiple less the rake on the profit, and lose the stake the rest.
ev := p*(1+(tier.Base-1)*(1-rake)) - 1
t.Logf("%-8s bots=%d base=%.2f naive win rate %.1f%% house edge %.1f%%",
tier.Slug, tier.Bots, tier.Base, p*100, -ev*100)
if ev < -0.14 || ev > -0.02 {
t.Errorf("%s: the house edge on naive play is %.1f%%, which is outside the 214%% "+
"band the tiers are priced to. Re-measure Base: %.2f would put it near 8%%.",
tier.Slug, -ev*100, (0.92/p-1)/(1-rake)+1)
}
}
}

View File

@@ -37,6 +37,8 @@ var (
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")
ErrMustStack = errors.New("uno: answer the stack with a draw card, or take it")
ErrNoStack = errors.New("uno: there's no stack to take")
ErrUnknownMove = errors.New("uno: unknown move")
ErrBadBet = errors.New("uno: bet must be positive")
ErrUnknownTier = errors.New("uno: no such tier")
@@ -80,6 +82,10 @@ func (c Color) Playable() bool { return c >= Red && c <= Green }
// Value is what's printed on the face.
type Value uint8
// The faces. The first fifteen are the ones on a normal box, and their numbers
// are load-bearing: a game in flight is a JSON blob of these integers, so the No
// Mercy faces are *appended*. Renumbering them would deal a live table a
// different card.
const (
Zero Value = iota
One
@@ -96,13 +102,23 @@ const (
DrawTwo
WildCard
WildDrawFour
// No Mercy only, all of them.
SkipAll // skip everyone: you go again
DrawFour // a *coloured* +4, which the normal deck doesn't have
DiscardAll // play it, and every other card of its colour goes with it
WildRevFour // reverse, and the seat that lands next takes four
WildDrawSix // +6
WildDrawTen // +10
WildRoulette // the next seat flips until your colour turns up, and keeps the lot
)
var valueNames = [15]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"skip", "reverse", "+2", "wild", "+4"}
var valueNames = [22]string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"skip", "reverse", "+2", "wild", "+4",
"skip all", "+4", "discard all", "rev +4", "+6", "+10", "roulette"}
func (v Value) String() string {
if v > WildDrawFour {
if v > WildRoulette {
return "?"
}
return valueNames[v]
@@ -111,6 +127,35 @@ func (v Value) String() string {
// Action reports whether a card does something beyond being a number.
func (v Value) Action() bool { return v >= Skip }
// Wild reports whether the face has no colour of its own. Note DrawFour is *not*
// one: No Mercy prints a coloured +4, which is a different card from the wild +4
// sitting next to it in the same deck.
func (v Value) Wild() bool {
switch v {
case WildCard, WildDrawFour, WildRevFour, WildDrawSix, WildDrawTen, WildRoulette:
return true
}
return false
}
// Draw is how many cards the face makes somebody take, and zero if it doesn't.
// It is also what makes a card stackable, so Roulette is deliberately zero: it
// hands over a random number of cards, and you cannot stack onto a number nobody
// knows yet.
func (v Value) Draw() int {
switch v {
case DrawTwo:
return 2
case DrawFour, WildDrawFour, WildRevFour:
return 4
case WildDrawSix:
return 6
case WildDrawTen:
return 10
}
return 0
}
// 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 {
@@ -119,7 +164,7 @@ type Card struct {
}
// IsWild reports whether the card has no colour of its own.
func (c Card) IsWild() bool { return c.Value == WildCard || c.Value == WildDrawFour }
func (c Card) IsWild() bool { return c.Value.Wild() }
// 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
@@ -153,12 +198,24 @@ func NewDeck() []Card {
// 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.
// No Mercy rides on the same struct rather than a second one, because it is the
// tier that lands in the state and the payload — so a game carries which rules it
// is playing by, and cannot be reloaded into the other set.
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"`
NoMercy bool `json:"no_mercy"`
}
// Deck is the deck this tier plays with.
func (t Tier) Deck() []Card {
if t.NoMercy {
return NewNoMercyDeck()
}
return NewDeck()
}
// Tiers are the three tables.
@@ -169,18 +226,56 @@ type Tier struct {
// 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.
// Re-measured 2026-07-14, and they moved: the naive strategy now wins 40.3% /
// 29.2% / 23.3%, not the 43 / 32 / 27 these were originally priced off. The bots
// got better at some point after the multiples were set and nobody re-ran the
// measurement, so Table and Full House had quietly been charging an 1819% edge
// 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.
var Tiers = []Tier{
{Slug: "duel", Name: "Duel", Bots: 1, Base: 2.2,
{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."},
{Slug: "table", Name: "Table", Bots: 2, Base: 2.9,
{Slug: "table", Name: "Table", Bots: 2, Base: 3.3,
Blurb: "Two bots. Twice the +4s pointed at you."},
{Slug: "full", Name: "Full House", Bots: 3, Base: 3.6,
{Slug: "full", Name: "Full House", Bots: 3, Base: 4.1,
Blurb: "Three bots, and any of them going out first takes your stake."},
}
// TierBySlug finds a tier by the name the browser sent.
// NoMercyTiers are the same three tables playing the other rules.
//
// 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.
//
// 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
// *bots* — it does not care whose hand hits twenty-five — and every bot it buries
// is one fewer seat that can beat you to the last card. Three opponents burying
// each other is a game you win by outliving, and the deck that was built to be
// merciless turns out to be merciless mostly to the table.
//
// So a nastier game pays a smaller multiple, which is the correct answer and a
// slightly funny one. TestTheMultiplesAreStillPriced is what keeps it honest:
// change the bots, the deck or a rule, and it fails until these are measured
// again. It is the test the normal tiers never had, which is how they drifted.
var NoMercyTiers = []Tier{
{Slug: "nm-duel", Name: "No Mercy Duel", Bots: 1, Base: 2.0, NoMercy: true,
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,
Blurb: "Three bots. Twenty-five cards and you're out of the game."},
}
// AllTiers is every table in the room, both dials.
func AllTiers() []Tier {
return append(append([]Tier(nil), Tiers...), NoMercyTiers...)
}
// TierBySlug finds a tier by the name the browser sent, across both rule sets.
func TierBySlug(slug string) (Tier, error) {
for _, t := range Tiers {
for _, t := range AllTiers() {
if t.Slug == slug {
return t, nil
}
@@ -194,6 +289,7 @@ 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
PhaseStack Phase = "stack" // No Mercy: a draw card is pointed at you — answer it or take it
PhaseDone Phase = "done"
)
@@ -222,6 +318,13 @@ type State struct {
Turn int `json:"turn"`
Dir int `json:"dir"` // +1 clockwise, -1 after a reverse
// No Mercy only. Out is the seats the mercy rule has killed, and it is what
// the turn order steps over — a dead seat is skipped, not merely empty.
// Pending is the bill a stack of draw cards has run up: whoever stops
// stacking pays it.
Out []bool `json:"out,omitempty"`
Pending int `json:"pending,omitempty"`
Seed1 uint64 `json:"seed1"`
Seed2 uint64 `json:"seed2"`
Step uint64 `json:"step"` // how many moves have been applied; the rng's other half
@@ -259,6 +362,14 @@ type Event struct {
// uno a hand is down to one card
// reshuffle the discard goes back under
// settle it's over
//
// And the No Mercy ones:
//
// stack a draw card is pointed at a seat: N is the bill so far
// skipall everybody else loses their turn
// discard a whole colour left a hand at once
// roulette a seat flipped N cards looking for a colour, and kept them
// mercy a seat hit 25 cards and is out of the game
const (
EvDeal = "deal"
EvPlay = "play"
@@ -270,6 +381,12 @@ const (
EvUno = "uno"
EvReshuffle = "reshuffle"
EvSettle = "settle"
EvStack = "stack"
EvSkipAll = "skipall"
EvDiscardAll = "discard"
EvRoulette = "roulette"
EvMercy = "mercy"
)
// Move is what the player sends: play this card, take one off the deck, or —
@@ -280,11 +397,14 @@ type Move struct {
Color Color `json:"color"` // the colour you name, for a wild
}
// Move kinds.
// 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.
const (
MovePlay = "play"
MoveDraw = "draw"
MovePass = "pass"
MoveTake = "take"
)
// New deals a game: a shuffled deck, seven each, and a card turned over.
@@ -302,7 +422,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
}
rng := stepRNG(seed1, seed2, 0)
deck := NewDeck()
deck := t.Deck()
rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] })
s := State{
@@ -313,6 +433,7 @@ func New(bet int64, t Tier, rakePct float64, seed1, seed2 uint64) (State, []Even
}
seats := t.Bots + 1
s.Out = make([]bool, seats)
s.Hands = make([][]Card, seats)
for i := range s.Hands {
s.Hands[i] = make([]Card, 0, HandSize)
@@ -369,6 +490,8 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
evs, err = next.playerDraws(rng)
case MovePass:
evs, err = next.playerPasses()
case MoveTake:
evs, err = next.playerTakes(rng)
default:
return s, nil, ErrUnknownMove
}
@@ -401,7 +524,15 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
return nil, ErrMustPlayNow
}
card := hand[m.Index]
if !card.CanPlayOn(s.top(), s.Color) {
// With a stack pointed at you, the only cards that exist are the ones that
// answer it. Everything else in your hand is unplayable until the bill is
// settled — by you, or by the seat you pass it to.
if s.Phase == PhaseStack {
if !card.CanStackOn(s.Color) {
return nil, ErrMustStack
}
} else if !card.CanPlayOn(s.top(), s.Color) {
return nil, ErrCantPlay
}
if card.IsWild() && !m.Color.Playable() {
@@ -415,14 +546,27 @@ func (s *State) playerPlays(m Move, rng *rand.Rand) ([]Event, error) {
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.
// playerDraws takes cards off the deck.
//
// The normal game takes one: if it can be played you get the choice — that's
// PhaseDrawn, the only place a turn pauses mid-move — and if it can't, the turn
// passes on the spot, because there is nothing to decide.
//
// No Mercy makes you draw *until* you can play. There is no drawing one card and
// shrugging, which is most of why hands there get big enough for the mercy rule
// to have something to kill. The card you end on is a card you must then play, so
// there is still nothing to decide — but the deck can be dry, and a hand can hit
// twenty-five on the way, and both of those end the drawing.
func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) {
if s.Phase == PhaseDrawn {
return nil, ErrMustPlayNow // you already drew; play it or pass
}
if s.Phase == PhaseStack {
return nil, ErrMustStack // answer it or take it; you cannot draw out of a stack
}
var evs []Event
if !s.Tier.NoMercy {
drawn := s.deal(You, 1, false, &evs, rng)
if len(drawn) == 1 && drawn[0].CanPlayOn(s.top(), s.Color) {
s.Phase = PhaseDrawn
@@ -433,11 +577,47 @@ func (s *State) playerDraws(rng *rand.Rand) ([]Event, error) {
return evs, nil
}
for {
drawn := s.deal(You, 1, false, &evs, rng)
if len(drawn) == 0 {
break // the table has nothing left to draw
}
if s.mercy(You, &evs, rng) {
return evs, nil // twenty-five cards, and you are out of the game
}
if 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
}
// playerTakes gives in to a stack: you take every card it has run up, and you
// lose your turn.
func (s *State) playerTakes(rng *rand.Rand) ([]Event, error) {
if s.Phase != PhaseStack {
return nil, ErrNoStack
}
var evs []Event
s.absorb(You, &evs, rng)
return evs, nil
}
// playerPasses declines the card you just drew.
//
// In No Mercy you may not: you drew until you found a card that plays, and that
// card is the price of having drawn. Passing there would make drawing a way to
// buy a look at the deck and put nothing down.
func (s *State) playerPasses() ([]Event, error) {
if s.Phase != PhaseDrawn {
return nil, ErrCantPass
}
if s.Tier.NoMercy {
return nil, ErrMustPlayNow
}
s.Phase = PhasePlay
s.advance(1)
return []Event{{Kind: EvPass, Seat: You}}, nil
@@ -455,24 +635,61 @@ func (s *State) runBots(evs *[]Event, rng *rand.Rand) {
// botTurn plays one bot's turn.
func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
// A stack pointed at this bot is not a turn, it is a bill. It answers with a
// draw card if it holds one, and takes the lot if it doesn't.
if s.Phase == PhaseStack {
card, idx := botStack(s.Hands[seat], s.Color, rng)
if idx < 0 {
s.absorb(seat, evs, rng)
return
}
s.botPlays(seat, card, idx, evs, rng)
return
}
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.
// Nothing playable: draw. The normal game draws one and shrugs; No Mercy
// draws until something goes, which is what buries a bot as surely as it
// buries you — the mercy rule cuts both ways, and a bot can die on the deck.
for {
drawn := s.deal(seat, 1, false, evs, rng)
if len(drawn) != 1 || !drawn[0].CanPlayOn(s.top(), s.Color) {
if len(drawn) != 1 {
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
s.advance(1)
return
}
if s.Tier.NoMercy && s.mercy(seat, evs, rng) {
return
}
if drawn[0].CanPlayOn(s.top(), s.Color) {
card, idx = drawn[0], len(s.Hands[seat])-1
break
}
if !s.Tier.NoMercy {
*evs = append(*evs, Event{Kind: EvPass, Seat: seat})
s.advance(1)
return
}
}
}
s.botPlays(seat, card, idx, evs, rng)
}
// botPlays puts a bot's chosen card down and resolves it.
func (s *State) botPlays(seat int, card Card, idx int, evs *[]Event, rng *rand.Rand) {
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)
if card.Value == WildRoulette {
// The roulette is not a card you play a colour *from*, it is a card you
// point at somebody. So the bot names the colour it holds least of, which
// is the one the deck is least likely to turn up quickly.
color = botRouletteColor(s.Hands[seat], rng)
}
}
s.discard(seat, card, color, evs)
s.after(seat, card, evs, rng)
@@ -489,11 +706,14 @@ func (s *State) botTurn(seat int, evs *[]Event, rng *rand.Rand) {
// 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 s.Pending > 0 {
return false // a stack is a move somebody still has to make: taking it
}
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 {
for _, seat := range s.alive() {
for _, c := range s.Hands[seat] {
if c.CanPlayOn(s.top(), s.Color) {
return false
}
@@ -532,6 +752,24 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
}
s.Phase = PhasePlay
// A draw card. In No Mercy this doesn't land yet: it opens a stack, and the
// seat it points at gets the choice of answering it. In the normal game it
// lands where it always did.
if n := card.Value.Draw(); n > 0 {
if card.Value == WildRevFour {
s.flip(seat, evs) // it reverses *first*: the seat it hits is the one after that
}
if s.Tier.NoMercy {
s.Pending += n
s.advance(1)
s.Phase = PhaseStack
*evs = append(*evs, Event{Kind: EvStack, Seat: s.Turn, N: s.Pending})
return
}
s.punish(s.seatAt(1), n, evs, rng)
return
}
switch card.Value {
case Skip:
victim := s.seatAt(1)
@@ -539,9 +777,37 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
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 {
s.flip(seat, evs)
case SkipAll:
// Everyone else loses their turn, which means it comes straight back to the
// seat that played it. The turn does not move at all.
*evs = append(*evs, Event{Kind: EvSkipAll, Seat: seat})
case DiscardAll:
// Every other card of this colour goes down with it. That can empty the
// hand, which is a win — and the reason this can't lean on the empty-hand
// check at the top of the function, which already ran.
s.discardAll(seat, card.Color, evs)
if len(s.Hands[seat]) == 0 {
s.settle(seat, evs)
return
}
s.advance(1)
case WildRoulette:
s.roulette(s.seatAt(1), s.Color, evs, rng)
default:
s.advance(1)
}
}
// flip turns the direction round — or, at a table of two, skips the only other
// player, because a reverse with nobody to hand the turn back to is a card that
// means you go again.
func (s *State) flip(seat int, evs *[]Event) {
if len(s.alive()) == 2 {
*evs = append(*evs, Event{Kind: EvSkip, Seat: s.seatAt(1)})
s.advance(2)
return
@@ -549,21 +815,12 @@ func (s *State) after(seat int, card Card, evs *[]Event, rng *rand.Rand) {
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.
// punish makes the next seat eat a draw card and lose its turn. This is the
// normal game's rule: no stacking, because a +2 played onto a +2 is a house rule
// and the one on the box is the one this deck plays. No Mercy prints the stacking
// rule on its own box, and takes the other road out of after().
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})
@@ -648,8 +905,13 @@ func (s *State) settle(winner int, evs *[]Event) {
// 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 {
live := s.alive()
if len(live) == 0 {
s.lose(evs) // can't happen: a mercy kill that empties the table settles first
return
}
best, tied := live[0], false
for _, seat := range live {
switch {
case len(s.Hands[seat]) < len(s.Hands[best]):
best, tied = seat, false
@@ -731,6 +993,17 @@ func (s State) Playable() []int {
}
return nil
}
// Under a stack, the only cards that light up are the ones that answer it.
// Everything else in the hand is dead until the bill is paid.
if s.Phase == PhaseStack {
var out []int
for i, c := range hand {
if c.CanStackOn(s.Color) {
out = append(out, i)
}
}
return out
}
var out []int
for i, c := range hand {
if c.CanPlayOn(s.top(), s.Color) {
@@ -780,10 +1053,26 @@ func (s *State) pop() (Card, bool) {
return c, true
}
// seatAt is the seat n places round from the one whose turn it is.
// seatAt is the seat n *live* places round from the one whose turn it is.
//
// A seat the mercy rule has killed is not there any more: it is stepped over, not
// landed on and skipped. So this counts living seats rather than doing the
// arithmetic on the index — which is the same thing in a normal game, where
// nobody is ever out, and the only thing that keeps a No Mercy table from
// handing the turn to a corpse.
func (s State) seatAt(n int) int {
seats := len(s.Hands)
return ((s.Turn+s.Dir*n)%seats + seats) % seats
at := s.Turn
for moved := 0; moved < n; {
at = ((at+s.Dir)%seats + seats) % seats
if at == s.Turn && !s.live(at) {
return at // nobody left alive to hand it to; the caller ends the game
}
if s.live(at) {
moved++
}
}
return at
}
// advance moves the turn on n places.
@@ -815,6 +1104,7 @@ func (s State) clone() State {
s.Deck = append([]Card(nil), s.Deck...)
s.Discard = append([]Card(nil), s.Discard...)
s.Bots = append([]string(nil), s.Bots...)
s.Out = append([]bool(nil), s.Out...)
return s
}

View File

@@ -39,8 +39,11 @@ func census(s State) map[Card]int {
}
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 {
// pile, so it counts as the wild it really is. This asks the face, rather
// than listing the wilds: No Mercy prints four more of them, and a census
// that didn't know about them would count a played roulette as a red card
// and report a deck that balances while the cards don't.
if c.Value.Wild() {
c.Color = Wild
}
m[c]++
@@ -478,22 +481,37 @@ func TestQuoteIsThePayout(t *testing.T) {
}
// The rake comes out of the winnings, never the stake.
//
// The arithmetic is derived from the tier rather than written down. It used to be
// written down — payout 214, rake 6 — and those numbers were the 2.2× duel. When
// the tiers were re-measured and repriced, this test failed on a rake that was
// perfectly correct, which is a test asserting a *price* while claiming to assert
// a *rule*. The rule is: the house takes its cut of the profit and never touches
// the stake. That holds at any multiple.
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.Tier = duel()
s.Bet = 100
gross := int64(float64(s.Bet) * s.Tier.Base) // what the tier pays back, before the house
profit := gross - s.Bet
wantRake := int64(float64(profit) * rake)
wantPayout := s.Bet + profit - wantRake
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.Payout != wantPayout {
t.Errorf("payout %d, want %d (%d stake + %d winnings - %d rake)",
next.Payout, wantPayout, s.Bet, profit, wantRake)
}
if next.Rake != 6 {
t.Errorf("rake %d, want 6", next.Rake)
if next.Rake != wantRake {
t.Errorf("rake %d, want %d — and never a penny of the %d stake",
next.Rake, wantRake, s.Bet)
}
if next.Net() != 114 {
t.Errorf("net %d, want 114", next.Net())
if next.Net() != wantPayout-s.Bet {
t.Errorf("net %d, want %d", next.Net(), wantPayout-s.Bet)
}
}

View File

@@ -80,6 +80,8 @@ type gamesPage struct {
Quizzes []trivia.Tier // trivia's three difficulties
Rungs int // how long the trivia ladder is
Tables []uno.Tier // uno's three tables, and how many bots sit at each
NoMercy []uno.Tier // the same three, playing the other rules
MercyLimit int // the hand that ends you in No Mercy
Stakes []holdem.Tier // hold'em's three tables, by blinds
MaxBots int // how many seats hold'em will fill with bots
}
@@ -153,6 +155,8 @@ func (s *Server) gamesPage(r *http.Request) gamesPage {
Quizzes: trivia.Tiers,
Rungs: trivia.Rungs,
Tables: uno.Tiers,
NoMercy: uno.NoMercyTiers,
MercyLimit: uno.MercyLimit,
Stakes: holdem.Tiers,
MaxBots: holdem.MaxBots,
}

View File

@@ -49,6 +49,7 @@ type unoSeatView struct {
Cards int `json:"cards"`
You bool `json:"you"`
Uno bool `json:"uno"` // down to one card
Out bool `json:"out"` // No Mercy: buried at twenty-five, and not coming back
}
// unoView is a game as its player may see it.
@@ -65,6 +66,11 @@ type unoView struct {
Turn int `json:"turn"`
Dir int `json:"dir"`
// No Mercy: the bill a stack of draw cards has run up. Whoever stops stacking
// pays it, and while it stands it is the only thing on the table that matters —
// so the felt has to be able to say what it is.
Pending int `json:"pending"`
Bet int64 `json:"bet"`
Pays int64 `json:"pays"` // what going out right now would actually pay
Phase string `json:"phase"`
@@ -83,6 +89,7 @@ func viewUno(g uno.State) unoView {
Deck: g.Left(),
Turn: g.Turn,
Dir: g.Dir,
Pending: g.Pending,
Bet: g.Bet,
Pays: g.Pays(),
Phase: string(g.Phase),
@@ -92,18 +99,27 @@ func viewUno(g uno.State) unoView {
Rake: g.Rake,
Net: g.Net(),
}
// An empty hand is a seat that went out — *unless* the mercy rule took it, in
// which case an empty hand is a grave. Those two look identical from the count
// alone, which is why a buried seat is asked about rather than inferred.
for i, n := range g.Counts() {
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: n == 1}
live := g.Live(i)
seat := unoSeatView{Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live}
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 {
if live && n == 0 {
v.Winner = i
}
}
// And you can win a No Mercy table without ever going out: outlive it, and the
// last seat standing is you, with a hand still in it.
if v.Winner < 0 && g.Outcome.Won() {
v.Winner = uno.You
}
for _, c := range g.Hands[uno.You] {
v.Hand = append(v.Hand, viewUnoCard(c))
}
@@ -248,6 +264,10 @@ func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
msg = "play the card you drew, or pass"
case errors.Is(err, uno.ErrCantPass):
msg = "draw first, then you can pass"
case errors.Is(err, uno.ErrMustStack):
msg = "answer the stack with a draw card, or take it"
case errors.Is(err, uno.ErrNoStack):
msg = "there's nothing pointed at you to take"
}
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
return

View File

@@ -145,3 +145,71 @@ func TestUnoWildWithNoColourIsRefused(t *testing.T) {
t.Logf("colour in play after the bots: %s", v.Uno.Color)
}
}
// A seat the mercy rule has buried holds no cards. So does a seat that has just
// gone out and won. The view has to tell them apart, because everything it says
// afterwards hangs off it: who won, whose fan of cards to rub out, and whether the
// player is looking at a payout or a grave.
//
// This is the whole reason the view asks the engine (Live) instead of inferring it
// from a count of zero.
func TestABuriedSeatIsNotTheWinner(t *testing.T) {
g, _, err := uno.New(100, mustTier(t, "nm-full"), 0.05, 7, 9)
if err != nil {
t.Fatal(err)
}
// Bury seat 2, the way the engine does: no cards, and out.
g.Hands[2] = nil
g.Out[2] = true
v := viewUno(g)
if !v.Seats[2].Out {
t.Error("a buried seat must say so; the felt draws it as a grave")
}
if v.Seats[2].Uno {
t.Error("a buried seat is not one card away from going out")
}
if v.Winner == 2 {
t.Fatal("the buried seat was reported as the winner — an empty hand is not a win")
}
if v.Winner != -1 {
t.Errorf("nobody has gone out yet, so there is no winner: got %d", v.Winner)
}
// And the other ending only this deck has: you win by outliving the table, with
// a hand still in front of you.
g.Phase, g.Outcome, g.Payout = uno.PhaseDone, uno.OutcomeWon, g.Pays()
if v := viewUno(g); v.Winner != uno.You {
t.Errorf("you outlived the table; winner = %d, want you (%d)", v.Winner, uno.You)
}
}
// The bill a stack has run up is the only thing on the table while it stands, so it
// has to reach the browser. It is a No Mercy field on a struct the normal game
// shares, which is exactly the kind of field that quietly never gets copied across.
func TestTheStackBillCrossesTheWire(t *testing.T) {
g, _, err := uno.New(100, mustTier(t, "nm-duel"), 0.05, 3, 4)
if err != nil {
t.Fatal(err)
}
g.Pending = 6
g.Phase = uno.PhaseStack
v := viewUno(g)
if v.Pending != 6 {
t.Errorf("the felt was told the bill is %d, want 6", v.Pending)
}
if v.Phase != string(uno.PhaseStack) {
t.Errorf("phase = %q, want %q", v.Phase, uno.PhaseStack)
}
}
func mustTier(t *testing.T, slug string) uno.Tier {
t.Helper()
tier, err := uno.TierBySlug(slug)
if err != nil {
t.Fatalf("no tier %q: %v", slug, err)
}
return tier
}

View File

@@ -1750,11 +1750,134 @@ html[data-phase="night"] {
.pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; }
.pete-uno-swatch[data-c="green"] { --sw: #46a86b; }
/* ---- No Mercy -------------------------------------------------------------
A rules dial, not a fourth table, and the felt says so: the same table, the
same cards, plus six faces the normal box doesn't print and one thing that
can be pointed at you — a stack of draw cards with a bill on it.
Sizing note, and it is the rule this table was taught the hard way: a card is
sized by --uno-h/--uno-w and nothing else. These faces set a *font*, never a
box, so a "DISCARD ALL" is the same card as a "7" at every width. */
/* The long faces. "DISCARD ALL" does not fit across an oval at card size, so it
wraps — and the oval is tilted, so it wraps on the tilt, which is what the
printed card does too. Specificity has to clear the wild rule above (a
reverse-draw-four is a wild), hence the .pete-uno-card in front. */
.pete-uno-card .pete-uno-oval[data-size="mid"] { font-size: 1.05rem; }
.pete-uno-card .pete-uno-oval[data-size="words"] {
font-size: 0.5rem;
line-height: 1.1;
letter-spacing: 0.02em;
text-align: center;
padding: 0 0.15rem;
}
/* The wild draws glow.
These are the cards that decide a game, and No Mercy prints a *coloured* +4
right next to the wild one, so at a glance in a hand of twenty they have to be
tellable apart. The aura is a sibling behind the card rather than a shadow on
it: a box-shadow here would fight the one that says which colour a played wild
was named as (data-named, above), and lose.
It sits under the card (z-index -1) and does not take clicks, so the card is
still the button. */
.pete-uno-card[data-glow="1"] { position: relative; z-index: 0; }
.pete-uno-card[data-glow="1"]::before {
content: "";
position: absolute;
inset: -0.35rem;
z-index: -1;
border-radius: 0.9rem;
pointer-events: none;
background: conic-gradient(from 0deg,
#d64545, #e0b02c, #46a86b, #3d7fd6, #d64545);
filter: blur(7px);
opacity: 0.75;
animation: pete-uno-glow 3.2s linear infinite;
}
/* The shimmer across the face itself: a slow sweep of light, the way a foil card
catches the room when you tilt it. */
.pete-uno-card[data-glow="1"] .pete-uno-face::after {
content: "";
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background: linear-gradient(115deg,
transparent 35%, rgba(255,255,255,0.55) 50%, transparent 65%);
background-size: 260% 100%;
animation: pete-uno-shine 2.6s ease-in-out infinite;
}
@keyframes pete-uno-glow {
to { transform: rotate(1turn); }
}
@keyframes pete-uno-shine {
0% { background-position: 140% 0; }
55% { background-position: -40% 0; }
100% { background-position: -40% 0; }
}
/* The bill. While a stack stands it is the only thing on the table that matters,
so it is loud, and it is red. */
.pete-uno-pending {
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.18rem 0.7rem;
border-radius: 999px;
color: #fff;
background: #cc3d4a;
box-shadow: 0 3px 0 rgba(0,0,0,0.2);
animation: pete-uno-bill 1.1s ease-in-out infinite;
}
@keyframes pete-uno-bill {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
/* A seat the mercy rule buried. It stays at the table — the felt would jump if
it didn't, and you want to see who's gone — but it is plainly out. */
.pete-uno-seat[data-out="1"] {
opacity: 0.4;
filter: grayscale(1);
}
.pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; }
/* The rules switch: two dials, and this is the one that isn't the table size. */
.pete-seg {
display: inline-flex;
padding: 0.2rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ink) 6%, transparent);
}
.pete-seg-btn {
padding: 0.3rem 0.9rem;
border-radius: 999px;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 0.8rem;
font-weight: 700;
color: color-mix(in srgb, var(--ink) 55%, transparent);
transition: background 0.15s ease, color 0.15s ease;
}
.pete-seg-btn:hover { color: var(--ink); }
.pete-seg-btn[data-on="1"] {
background: var(--accent);
color: #fff;
box-shadow: 0 2px 0 rgba(0,0,0,0.15);
}
/* 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. */
scrolling sideways, which is the thing to check at 390px with a game on it.
No Mercy deals bigger hands than that — it is a deck built to bury you — so
this is the table where a twenty-card hand has to wrap and not overflow. */
@media (max-width: 639px) {
.pete-uno-hand { --uno-h: 5.2rem; --uno-w: 3.5rem; gap: 0.3rem; }
.pete-uno-deck { --uno-h: 5.2rem; --uno-w: 3.5rem; }
.pete-uno-card .pete-uno-oval[data-size="words"] { font-size: 0.42rem; }
.pete-uno-card .pete-uno-oval[data-size="mid"] { font-size: 0.9rem; }
/* 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. */
@@ -1783,6 +1906,11 @@ html[data-phase="night"] {
.pete-clock[data-hot="1"],
.pete-answer[data-state="wrong"] { animation: none; }
.pete-rung { transition: none; }
/* The wild draws keep their glow — it is what tells the card apart from the
coloured +4 beside it, so it is information — but it stops moving. */
.pete-uno-card[data-glow="1"]::before { animation: none; }
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; }
.pete-uno-pending { animation: none; }
}
}

File diff suppressed because one or more lines are too long

View File

@@ -514,9 +514,30 @@
// ---- talking to the table --------------------------------------------------
// lock is busy, said out loud on the buttons.
//
// send() drops a click that arrives while a move is in flight, and it is right
// to: the board on screen during a script is a board the server has already
// moved past. But the *between-hands* buttons — Deal, Leave, Top up — stayed
// enabled through the whole deal animation, so clicking Leave while the cards
// were still flying did nothing at all: no move, no message, no reason given.
// (The action buttons never had this problem; panels() hides the whole row when
// it isn't your turn.) A button that looks alive and does nothing has lied to
// you, so the lock lives on the buttons and not only in the variable.
//
// Top up keeps its own rule — it is dead when the wallet cannot cover it — and
// panels() owns that, so this only ever adds a reason to be disabled.
function lock(on) {
[foldBtn, checkBtn, callBtn, raiseBtn, dealBtn, leaveBtn].forEach(function (b) {
if (b) b.disabled = on;
});
if (topupBtn) topupBtn.disabled = on || Number(topupBtn.dataset.amount || 0) <= 0;
}
function send(body, msgEl) {
if (busy) return Promise.resolve();
busy = true;
lock(true);
say(msgEl, "");
// Whatever the last hand said about itself stops being true the moment you do
// something. Only the "hand" beat used to clear this, so a verdict could linger
@@ -540,7 +561,7 @@
});
})
.catch(function (err) { say(msgEl, err.message, "bad"); })
.then(function () { busy = false; });
.then(function () { busy = false; lock(false); });
}
// render0 is the table with nobody at it.

View File

@@ -39,10 +39,16 @@
var tableEl = root.querySelector("[data-table-name]");
var wildEl = root.querySelector("[data-wild]");
// Not [data-pending]: that is the chip bar's, it lives inside this root, and it
// comes first in the document. See the note in uno.html.
var billEl = root.querySelector("[data-bill]");
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 takeBtn = root.querySelector("[data-take]");
var takeN = root.querySelector("[data-take-n]");
var hintEl = root.querySelector("[data-hint]");
var betAmount = root.querySelector("[data-bet-amount]");
var startBtn = root.querySelector("[data-start]");
var msgEl = root.querySelector("[data-table-msg]");
@@ -81,14 +87,45 @@
// ---- 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" };
// FACES is how each printed face is drawn. The engine sends a word ("skip",
// "+2", "discard all"); this is the picture of it.
//
// The corner is not always the same mark as the middle. "DISCARD ALL" fits
// across an oval and does not fit in a corner, so the long faces carry a short
// mark for the corners — which is what a real deck does too. `size` is a class,
// never a height: a card is sized by --uno-h/--uno-w and nothing else, and a
// face that sets its own font in pixels is a face that breaks on a phone.
var FACES = {
"skip": { mid: "🚫", corner: "🚫" },
"reverse": { mid: "⇄", corner: "⇄" },
"+2": { mid: "+2", corner: "+2" },
"wild": { mid: "★", corner: "★" },
"+4": { mid: "+4", corner: "+4" },
// No Mercy.
"skip all": { mid: "SKIP ALL", corner: "⊘⊘", size: "words" },
"discard all": { mid: "DISCARD ALL", corner: "≡", size: "words" },
"rev +4": { mid: "⇄+4", corner: "⇄4", size: "mid" },
"+6": { mid: "+6", corner: "+6" },
"+10": { mid: "+10", corner: "+10", size: "mid" },
// The roulette names a colour and turns cards over until it comes up. The
// wheel behind the oval is already the picture of that; the mark is the
// question the wheel is asking.
"roulette": { mid: "?", corner: "?" },
};
function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; }
// The faces that make somebody draw. On a wild these are the cards worth the
// most and worth watching for, so they get the glow — and it is the one thing
// that tells the *wild* +4 apart from No Mercy's coloured +4 at a glance.
var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true };
// 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 f = faceOf(c);
var el = document.createElement(opts.button ? "button" : "div");
if (opts.button) el.type = "button";
el.className = "pete-uno-card";
@@ -99,7 +136,8 @@
var oval = document.createElement("span");
oval.className = "pete-uno-oval";
oval.textContent = GLYPHS[c.value] || c.value;
if (f.size) oval.dataset.size = f.size;
oval.textContent = f.mid;
face.appendChild(oval);
// The corners, as printed.
@@ -107,7 +145,7 @@
var corner = document.createElement("span");
corner.className = "pete-uno-corner";
corner.dataset.at = at;
corner.textContent = GLYPHS[c.value] || c.value;
corner.textContent = f.corner;
corner.setAttribute("aria-hidden", "true");
face.appendChild(corner);
});
@@ -121,16 +159,33 @@
if (c.color && c.color !== "wild") el.dataset.named = c.color;
}
// The wild draws glow. They are the cards that decide a game — a +4 you were
// holding is four cards somebody else is about to be holding — and in No Mercy
// there are four of them to tell apart from the coloured +4 sitting next to
// them in the same hand. The glow is what says "this one is the nasty one".
if (c.wild && DRAWS[c.value]) el.dataset.glow = "1";
el.appendChild(face);
el.setAttribute("aria-label", label(c));
return el;
}
// label is what a screen reader says. Note "+4" is two different cards — No
// Mercy prints a coloured one next to the wild one — so the name comes from the
// face *and* whether it's a wild, never from the face alone.
var SPOKEN = {
"+2": "draw two", "+4": "draw four", "+6": "draw six", "+10": "draw ten",
"wild": "wild", "skip": "skip", "reverse": "reverse",
"skip all": "skip everyone", "discard all": "discard all",
"rev +4": "reverse and draw four", "roulette": "colour roulette",
};
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 : "");
var v = SPOKEN[c.value] || c.value;
if (c.wild) {
var name = c.value === "wild" ? "wild" : "wild " + v; // not "wild wild"
return name + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
}
return c.color + " " + v;
}
@@ -159,6 +214,7 @@
el.dataset.seat = String(i);
el.dataset.turn = v.turn === i && v.phase !== "done" ? "1" : "0";
if (s.uno) el.dataset.uno = "1";
if (s.out) el.dataset.out = "1"; // No Mercy buried them; they are not coming back
var fan = document.createElement("div");
fan.className = "pete-uno-fan";
@@ -179,7 +235,7 @@
var count = document.createElement("p");
count.className = "pete-uno-count";
count.dataset.count = "";
count.textContent = s.cards + (s.cards === 1 ? " card" : " cards");
count.textContent = s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards");
el.appendChild(count);
seatsEl.appendChild(el);
@@ -234,10 +290,22 @@
colourEl.textContent = v.color;
colourEl.dataset.c = v.color;
feltEl.dataset.c = v.color;
pending(v.phase === "done" ? 0 : v.pending || 0);
}
var feltEl = root.querySelector(".pete-uno");
// pending is the bill a stack has run up. It is on the felt, on the button and
// in the turn line, because it is the only thing on the table while it stands:
// a player who misses it plays into a hand that is about to grow by ten.
function pending(n) {
if (billEl) {
billEl.textContent = n > 0 ? "+" + n + " on you" : "";
billEl.classList.toggle("hidden", n <= 0);
}
if (takeN) takeN.textContent = String(n);
}
function renderRail(v) {
if (!v) {
paysEl.textContent = "—";
@@ -259,6 +327,7 @@
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";
if (yours && v.phase === "stack") who = "+" + (v.pending || 0) + " — stack it, or take it";
turnEl.textContent = who;
turnEl.dataset.you = yours ? "1" : "0";
var n = v.hand.length;
@@ -278,6 +347,11 @@
if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) {
text = v.seats[v.winner].name + " went out first.";
}
// No Mercy has two endings the normal deck hasn't got: you can lose without
// anybody going out (the mercy rule got you), and win without going out at all
// (it got everybody else).
if (v.outcome === "lost" && v.winner < 0) text = "Buried. Twenty-five cards and you're done.";
if (v.outcome === "won" && v.seats[0] && v.seats[0].cards > 0) text = "Last one standing! 🎉";
if (!text) { verdictEl.classList.add("hidden"); return; }
if (v.net > 0) text += " +" + v.net.toLocaleString();
else if (v.net < 0) text += " " + v.net.toLocaleString();
@@ -295,24 +369,45 @@
var live = !!game && game.phase !== "done";
var yours = live && game.turn === 0;
var drawn = live && game.phase === "drawn";
var stack = live && game.phase === "stack";
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
b.disabled = busy || !yours || b.dataset.on !== "1";
});
if (drawBtn) drawBtn.disabled = busy || !yours || drawn;
// Under a stack you cannot draw. The deck is not a way out of a bill: the two
// moves that exist are answering it with a draw card or taking the lot.
if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack;
if (passBtn) passBtn.disabled = busy || !yours;
if (deckEl) deckEl.disabled = busy || !yours || drawn;
if (takeBtn) takeBtn.disabled = busy || !yours;
if (deckEl) deckEl.disabled = busy || !yours || drawn || stack;
}
// HINTS is the line above the buttons. A game with a stack in it has a rule the
// player has just met for the first time, and the table should say what it is
// rather than leave them clicking a hand where nothing lights up.
var HINTS = {
play: "Click a card that lights up. Nothing lights up? Draw one.",
drawn: "You drew a card you can play. Play it, or keep it and pass.",
stack: "Only draw cards will do here. Answer it, and it lands on somebody else — or take the lot.",
};
function setPhase(v) {
game = v;
var live = !!v && v.phase !== "done";
var drawn = live && v.phase === "drawn";
var stack = live && v.phase === "stack";
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);
if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
// No Mercy has no pass: you drew until you found a card that plays, and
// playing it is the price of having drawn. The server refuses one anyway;
// this is the table not offering a button that cannot work.
var canPass = drawn && !(v && v.tier && v.tier.no_mercy);
if (passBtn) passBtn.classList.toggle("hidden", !canPass);
if (hintEl && live) hintEl.textContent = HINTS[v.phase] || HINTS.play;
if (!live && v) rulesFor(v); // it's over: leave the panel where the game was
controls();
if (!v || !v.outcome) verdictEl.classList.add("hidden");
}
@@ -352,7 +447,8 @@
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");
var buried = el.dataset.out === "1"; // an empty hand here is a grave, not a win
if (count) count.textContent = buried ? "Buried" : left + (left === 1 ? " card" : " cards");
if (!fan) return;
var show = Math.min(left, FAN);
while (fan.children.length > show) fan.removeChild(fan.lastChild);
@@ -442,7 +538,9 @@
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
}
var punished = e.kind === "forced";
if (punished) badge(e.seat, "+" + e.n, "bad");
// A forced draw is also how a stack ends: somebody stopped answering
// and paid the bill, so the bill is gone.
if (punished) { pending(0); 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));
@@ -453,6 +551,65 @@
});
}
case "stack":
// The bill has moved on to somebody, and N is what it stands at now —
// not what was just added to it. A +2 answered with a +10 is a seat
// looking at twelve cards.
pending(e.n);
spotlight(e.seat);
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
case "skipall":
// The turn does not move: it comes straight back to the seat that
// played it, which is the whole card.
return badge(e.seat, "Everyone skipped").then(function () { return wait(240); });
case "discard": {
// A whole colour left a hand at once. If it was your hand, those cards
// are on the table in front of you — so they go before the flight, or
// they sit there looking like they were dumped twice.
if (e.seat === 0 && e.color) {
handEl.querySelectorAll('.pete-uno-card[data-c="' + e.color + '"]').forEach(function (el) {
el.style.visibility = "hidden";
});
}
bump(e.seat, e.left);
var dumpFrom = seatAnchor(e.seat);
var dumped = [];
for (var d = 0; d < Math.min(e.n, 4); d++) {
dumped.push(throwCard(back(), dumpFrom, discardEl, { index: d, delay: d * 70 }));
}
badge(e.seat, "" + e.n + " " + (e.color || ""));
return Promise.all(dumped).then(function () { return wait(220); });
}
case "roulette": {
// They turn cards over until your colour comes up, and they keep every
// one of them. N is how deep it went.
spotlight(e.seat);
var spinTo = seatAnchor(e.seat);
var spun = [];
for (var r = 0; r < Math.min(e.n, 4); r++) {
spun.push(throwCard(back(), deckEl, spinTo, { index: r, delay: r * 80 }));
}
badge(e.seat, "Roulette +" + e.n, "bad");
return Promise.all(spun).then(function () {
bump(e.seat, e.left);
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
return wait(400);
});
}
case "mercy": {
// Twenty-five cards, and they are out of the game for good. It is the
// one beat in this script allowed to take its time.
var grave = seatEl(e.seat);
if (grave) grave.dataset.out = "1";
bump(e.seat, 0);
pending(0); // a dead seat pays no bill and leaves none behind
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
}
case "skip":
return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });
@@ -593,6 +750,15 @@
move({ kind: "draw" });
}
// Taking the stack: you give in, you eat every card it has run up, and you lose
// your turn. It is a move of its own, not a draw — see the engine's MoveTake.
if (takeBtn) {
takeBtn.addEventListener("click", function () {
if (busy || !game || game.turn !== 0 || game.phase !== "stack") return;
move({ kind: "take" });
});
}
// ---- talking to the table ----------------------------------------------------
// send takes the table away for the whole move — not just the request, but the
@@ -647,6 +813,38 @@
});
});
// The rules switch. It is a different dial from the table size — the size is
// what you're paid for, the deck is what you're playing with — so throwing it
// swaps the three tables for the other three rather than becoming a fourth one.
var DEFAULT_TABLE = { normal: "table", nomercy: "nm-table" };
function pickRules(which, slug) {
root.querySelectorAll("[data-rules]").forEach(function (b) {
b.dataset.on = b.dataset.rules === which ? "1" : "0";
});
root.querySelectorAll("[data-grid]").forEach(function (g) {
g.classList.toggle("hidden", g.dataset.grid !== which);
});
root.querySelectorAll("[data-note]").forEach(function (p) {
p.classList.toggle("hidden", p.dataset.note !== which);
});
pickTier(slug || DEFAULT_TABLE[which]);
}
root.querySelectorAll("[data-rules]").forEach(function (b) {
b.addEventListener("click", function () {
if (busy) return;
pickRules(b.dataset.rules);
});
});
// A game that just ended leaves the betting panel on the table it was played at:
// the commonest thing a player wants after a hand of No Mercy is another one.
function rulesFor(v) {
if (!v || !v.tier) return;
pickRules(v.tier.no_mercy ? "nomercy" : "normal", v.tier.slug);
}
// 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) {
@@ -689,7 +887,7 @@
});
}
pickTier(tier);
pickRules("normal");
var resumed = false;
window.PeteGames.onUpdate(function (v) {

View File

@@ -101,8 +101,9 @@
<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.
One to three bots, and the more of them there are the more it pays: up to 4.1× for
beating a full table. Anybody else going out first takes your stake. Or throw the
No Mercy switch: 168 cards, draws that stack, and twenty-five in your hand puts you out.
</p>
</a>

View File

@@ -39,6 +39,15 @@
<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>
<!-- The bill. While a stack stands it is the only thing on the table
that matters, so it is said on the felt and not only on a button.
It is data-*bill*, not data-pending: the chip bar already owns
data-pending (chips still in flight from a buy-in), the chip bar is
inside this root, and it comes first in the document — so a stack
was quietly writing "+10 coming your way" over the escrow readout
and never showing up on the felt at all. -->
<p data-bill class="pete-uno-pending hidden" aria-live="assertive"></p>
</div>
</div>
@@ -100,7 +109,7 @@
<!-- 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">
<p data-hint 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">
@@ -109,6 +118,14 @@
hover:bg-[color:var(--ink)]/5 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
Draw
</button>
<!-- Giving in to a stack. It is a decision, not a draw — you are paying a
bill somebody ran up and pointing at you — so it gets a button of its
own, and the button says what it costs. -->
<button type="button" data-take
class="hidden rounded-full bg-[#cc3d4a] 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">
Take <span data-take-n>0</span>
</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">
@@ -122,7 +139,19 @@
<!-- 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">
<!-- Two dials, and they are different dials. The table size is the tier — it is
what you're paid for. No Mercy is the *deck*, and it is a switch across all
three sizes, which is why it sits up here beside the question rather than
being a fourth table. -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/50">Who are you playing?</div>
<div class="pete-seg" role="group" aria-label="Which rules">
<button type="button" data-rules="normal" class="pete-seg-btn">UNO</button>
<button type="button" data-rules="nomercy" class="pete-seg-btn">No Mercy</button>
</div>
</div>
<div data-grid="normal">
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .Tables}}
<button type="button" data-tier="{{.Slug}}"
@@ -138,6 +167,25 @@
</button>
{{end}}
</div>
</div>
<div data-grid="nomercy" class="hidden">
<div class="mt-2 grid gap-2 sm:grid-cols-3">
{{range .NoMercy}}
<button type="button" data-tier="{{.Slug}}"
class="pete-tier pete-tier-nm 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}} · 168 cards
</p>
</button>
{{end}}
</div>
</div>
<div class="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
@@ -162,9 +210,16 @@
Deal
</button>
</div>
<p class="mt-3 text-center text-xs text-[color:var(--ink)]/40">
<!-- No Mercy pays *less*, which reads like a typo until you've played one: the
mercy rule buries bots too, and every bot it buries is one fewer seat that
can beat you to the last card. Say so, rather than let it look like a bug. -->
<p data-note="normal" 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-note="nomercy" class="hidden mt-3 text-center text-xs text-[color:var(--ink)]/40">
168 cards, +6s and +10s. Draw cards stack, you draw until you can play, and {{.MercyLimit}} cards in
your hand puts you out of the game. It pays less than normal UNO because it buries the bots too.
</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>

View File

@@ -12,6 +12,33 @@ This plan reuses that seam wholesale and does not invent a second one.
A multi-session build. This section is the handover; read it before anything else.
### Start here (next session)
**Everything builds, everything is played, and nothing since blackjack is
deployed.** Six games (seven, counting the No Mercy dial) sit on main and the live
casino is still blackjack alone. Deploying is the next job — see "Next, in order".
**The money finding, and the thing to actually remember:** the *normal* UNO tiers
had been mispriced for a while, and it wasn't No Mercy's fault. They were set
against a naive win rate of 43/32/27%; re-measuring says 40.3/29.2/23.3%. The bots
improved at some point after the multiples were written down and nobody re-ran the
measurement — which §0 already warned about ("the bots and the tiers are a pair").
Table and Full House had quietly been charging an **1819% house edge instead of
8%**. All six tiers are repriced off a fresh measurement, and
**`TestTheMultiplesAreStillPriced`** now fails the build if any of them drift out
of a 214% band. It is the test the normal tiers never had, which is precisely how
they drifted. *Any change to the bots, the deck, or a rule re-opens this.*
And the counterintuitive one, which would have shipped wrong if it had been
guessed: **No Mercy is easier than UNO at every table size** (naive wins 46.7% vs
40.3% heads-up), so it **pays less** — 2.0/3.1/3.8 against the normal deck's new
2.4/3.3/4.1. The mercy rule doesn't care whose hand hits twenty-five; it kills
*bots* too, and every bot it buries is one fewer seat that can beat you to the last
card. A deck built to be merciless is merciless mostly to the table.
Still un-deployed: **only blackjack is live.** The other five games (six, now, if
you count the No Mercy dial) are on main and have never been deployed.
### Decisions taken (these close §9's open questions)
- **Chips are 1:1 with euros.** No second denomination.
@@ -400,6 +427,68 @@ A multi-session build. This section is the handover; read it before anything els
in.**
- **No Mercy, on the felt, and it plays.** *(2026-07-14. The engine landed in
`aca523e`; this is the half you can see. Driven in a browser, and that is where
both bugs were.)*
- **It is one switch, not a fourth table.** The betting panel grows a two-way
segmented control (UNO / No Mercy) that swaps the three tier cards for the other
three. The table size stays the tier — it is what you're paid for — and the deck
is the other dial. A settled game leaves the panel on the table you just played,
because the commonest thing anybody wants after a hand of No Mercy is another one.
- **The six faces the normal box doesn't print.** `FACES` in `uno.js` is one table
of {middle mark, corner mark, size}: the long ones ("DISCARD ALL", "SKIP ALL")
wrap across the tilted oval and carry a short mark for the corners, exactly as a
real deck does. Sized by a *font class*, never a box — `--uno-h`/`--uno-w` remain
the only thing that decides how big a card is, which is the rule the phone bug
taught this table.
- **The wild draws glow** (the user asked, and it turned out to be load-bearing).
A rainbow aura behind the card plus a slow shine across it, on any wild that
makes somebody draw. It is the one thing that tells the *wild* +4 apart from No
Mercy's **coloured** +4 sitting next to it in the same hand. It is a sibling
behind the card, not a shadow on it, because a box-shadow there would fight the
one that says which colour a played wild was named as.
- **The stack says what the bill is, in three places**: a red pill on the felt, the
turn line ("+6 — stack it, or take it"), and the button itself ("Take 6"). Under
a stack the hand only lights the cards that answer it, the deck is dead (you
cannot draw your way out of a bill), and the hint line changes to say so.
- **A buried seat is not an empty one.** This is the whole trap of the mercy rule
in the view layer: a seat killed at twenty-five holds zero cards, and so does a
seat that just went out and *won*. The view asks the engine (`State.Live`) rather
than inferring from a count of zero, so the winner is never the corpse.
`TestABuriedSeatIsNotTheWinner` is that, and it also covers the ending only this
deck has — you can win by **outliving** the table, with a hand still in front of
you ("Last one standing!"), and you can lose without anybody going out at all.
- **Two bugs, and a browser found both.**
1. **The bill was writing into the chip bar.** The felt's pending pill was
`[data-pending]` — and so is the chip bar's "chips still in flight from a
buy-in" readout, and the chip bar lives *inside* `[data-uno]` and comes first
in the document. So `root.querySelector` found the wrong one: a stack quietly
overwrote the escrow message and never appeared on the felt at all. It is
`[data-bill]` now. **A table's own attributes are not a private namespace —
the chip bar is in every one of these roots.**
2. **Hold'em let you click a button that did nothing** (found while re-driving
poker, below). `send()` drops a click that arrives while a move is in flight,
which is right — the board on screen during a script is a board the server has
already moved past — but the *between-hands* buttons (Deal, Leave, Top up)
stayed enabled through the whole deal animation. Clicking Leave while the
cards were still flying did nothing: no move, no message, no reason. The lock
lives on the buttons now (`lock()`), not only in the `busy` variable.
- **Driven in a browser, 2026-07-14.** A Full House game stacked a +6 onto us and
the felt said so; taking it worked; a bot went to twenty-five and the seat went
grey and said "Buried"; a Duel was won by outliving the table and paid 1,950 on a
1,000 stake (2.0× is 1,000 of winnings, less the 5% rake — the quote and the
payout agreed, as `Pays()` requires). A 21-card hand at 390px wraps to five rows
with every card painted and no sideways scroll. The normal deck still plays and
still pays (a Table win on 1,200 paid 3,822). Console silent.
- **Hold'em, re-driven on the 20M-hand policy.** *(2026-07-14, and it closes Phase
4.)* The policy is a data file, so the check was whether the bots still play a real
game off it and the money still conserves: sat down for 100 at the 1/2 table, six
hands (won a pot with a straight, lost one at showdown, folded the rest), got up
with 61, and 4,900 + 61 came back as 4,961 — conserved to the chip. The re-drive is
also what turned up the dead-button bug above, which is the argument for the rule:
**re-drive after a policy change even though "only data" moved.**
- **Hold'em, and it is a cash game.** *(2026-07-14. Built, tested, and driven in a
browser. The bots had to be retrained from scratch — see below, it is the whole
story of this phase.)*
@@ -479,48 +568,16 @@ A multi-session build. This section is the handover; read it before anything els
size, with chips scaled to match. The bet total hangs *below* the ring
(`.pete-spot-total`), which is the existing rule for exactly this reason.
### Pick this up here — a 20M-hand policy is still training
The `policy.gob` on main is a **300,000-hand run** — a placeholder. It works (95%
heads-up hit rate, and the bots play a real game off it), but it is thin: 4,129
nodes. A **20,000,000-hand run** was started on millenia on 2026-07-14 and needs
collecting:
```sh
ssh reala@192.168.1.212 'tail -2 ~/pete-train/train.log' # is it done?
scp reala@192.168.1.212:~/pete-train/policy-new.gob internal/games/holdem/policy.gob
go test ./internal/games/holdem/ -run TestTheBotsAreActuallyTrained -v # hit rate must hold ≥60%
```
Then re-drive the table in a browser and commit it. If the run was lost, just do it
again — it is one command and about an hour:
```sh
rsync -az --delete --exclude .git --exclude node_modules ./ reala@192.168.1.212:~/pete-train/
ssh reala@192.168.1.212 'cd ~/pete-train && go build ./cmd/holdem-train && \
nohup ./holdem-train -iterations 20000000 -workers 30 -out ~/pete-train/policy-new.gob \
> ~/pete-train/train.log 2>&1 &'
```
millenia (`reala@192.168.1.212`) has 32 cores and does ~250k hands a minute. The
local box does ~110k. Nothing about the *code* is waiting on this — the policy is a
data file, and a better one only makes the bots harder.
### Next, in order
1. **Deploy.** Hangman, solitaire, trivia, UNO and hold'em are all played and all
five are 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.
2. **No Mercy UNO.** The plan's header line has always promised "UNO (normal +
no-mercy)" and only normal was ever built. gogobee has the rules
(`uno_nomercy.go`: a 168-card deck, draw-stacking, elimination at 25 cards,
sudden-death point scoring). It is a *rules dial orthogonal to the table-size
tier*, so the lobby becomes 3 sizes × 2 rule sets — and **the multiples have to be
re-measured**, because the current 2.2×/2.9×/3.6× are priced off a measured
go-out-first rate (43/32/27%) that draw-stacking and mercy elimination change
completely. Shipping No Mercy on the regular tier's prices would misprice it.
1. **Deploy.** Hangman, solitaire, trivia, UNO (both rule sets) and hold'em are all
played and all of them are 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.
2. **Nothing else is queued.** Every game in the header line is built. What's left is
the open list at the bottom of this section: hold'em has no multiway policy, and
blackjack still has no split.
Still open on hold'em, none of it blocking: the policy is **heads-up**, so a
six-handed table is an approximation of it (the hit rate falls from 95% to about 17%