Three things, and the first one was a bug. Your own hand didn't move until the lap ended. bump() keeps the bots' fans honest and has always refused seat zero, and nothing else touched yours — so a +4 landing on you at the top of a lap put four backs into your hand and then nothing, and the cards themselves turned up seconds later when the script finished and paint() finally ran. You spent the whole lap looking at a hand you no longer held. The engine now stamps your hand onto every event that changes it (Event.Hand, seat zero only, which is the one hand the browser is already entitled to see) and the table redraws as the cards land. Measured in the running app: 2 -> 3 cards at 414ms into a 1791ms lap. You couldn't call UNO, and not because the button was missing: going down to one card *was* the call. discard() fired the uno event by itself, which made it a thing that happened to you rather than a thing you did, and a rule nobody can fail is not a rule. So now you say it or you don't (Move.Uno), and if you don't, every bot still in the game gets one look at you before any of them plays — because a bot that has moved on is a bot that has stopped watching your hand. It runs the other way too, and that half is the fun one: a bot forgets often enough to be worth watching for, and when it does it says *nothing*. No event, no badge, no tell on the felt except the count beside its fan reading "1 card". Catch it and it takes two; call a seat that had nothing to hide and you take two yourself, which is what stops the catch button from being a thing you simply mash. Which cards owe the call is the engine's answer, not a count of your hand: No Mercy's "discard all" takes every card of its colour with it, so a six-card hand can land on one, and a browser subtracting one from six walks you into a catch it never warned you about. And the room was silent. Every sound in here is *made* — an oscillator, a burst of filtered noise, an envelope — the same bargain the weather engine takes with its clouds. A card is a slap of noise through a bandpass, a chip is two detuned sines with a knock on the front, a win is four notes going up. No asset files, no round trips, and a sound can be pitched and detuned per call instead of being the same wav three hundred times. Hooked into the FX layer rather than into the games, so every table that throws a chip or turns a card got it at once. The multiples moved, and the test that exists to catch that caught it. The naive strategy now calls UNO, because calling is a button and not a strategy — what these tiers price is bad card play, not a player who ignores the felt shouting at them — and on that footing the normal tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full House did not: it was paying a *negative* house edge, which is the house paying you to sit down. Re-priced 3.8 -> 3.5. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
438 lines
15 KiB
Go
438 lines
15 KiB
Go
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))
|
||
}
|
||
// It calls UNO. That is not a strategy, it is a button — the table puts it in
|
||
// front of you and waits — and what these multiples price is *bad card play*,
|
||
// not a player who ignores the felt shouting at them.
|
||
//
|
||
// A player who genuinely never calls loses far more than this one does, and
|
||
// nothing here is priced for them. That is the rule having teeth, which is the
|
||
// point of it. Naive is the floor for someone playing the game badly, not for
|
||
// someone not playing it.
|
||
for _, at := range s.UnoAt() {
|
||
if at == idx {
|
||
m.Uno = true
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// 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))
|
||
|
||
// With the call, because this play takes three reds out of the hand at once and
|
||
// lands on exactly one card — which is precisely the case a browser counting
|
||
// "hand length minus one" would miss, and the reason UnoAt is the engine's job.
|
||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true})
|
||
if err != nil {
|
||
t.Fatalf("play discard-all: %v", err)
|
||
}
|
||
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 2–14%% "+
|
||
"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)
|
||
}
|
||
}
|
||
}
|