Files
Pete/internal/games/uno/nomercy_test.go
prosolis 927ed84163 games: UNO becomes a table you sit at, and the pot that pays whoever goes out first
Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.

The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.

The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 18:37:51 -07:00

348 lines
10 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)
}
}
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: 168 cards, each in exactly one place,
// checked after every move of a hundred hands played to the end.
func TestNoMercyCensus(t *testing.T) {
for _, tier := range []Tier{nmDuel(), nmTable(), nmFull()} {
for seed := uint64(0); seed < 100; seed++ {
s := deal(t, tier, seed)
if got := total(census(s)); 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.playing() && moves < 800; moves++ {
next, _, err := ApplyMove(s, You, 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.playing() {
t.Fatalf("%s seed %d: hand never ended", tier.Slug, seed)
}
}
}
}
// naive is a bad-but-real strategy: play the first legal card, take a stack you
// can't answer, and draw when you have nothing. Always played from the human seat.
func naive(s State, rng *rand.Rand) Move {
if s.Phase == PhaseStack {
if p := s.Playable(You); len(p) > 0 {
return playMove(s, p[0], rng)
}
return Move{Kind: MoveTake}
}
if p := s.Playable(You); 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.
func stack(s *State, seat, n int) {
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))
}
for _, at := range s.UnoAt(You) {
if at == idx {
m.Uno = true
}
}
return m
}
// TestAStackIsPassedOnAndPaid walks the rule the whole mode turns on: a draw card
// opens a bill, and the seat that can't answer pays the whole thing.
func TestAStackIsPassedOnAndPaid(t *testing.T) {
s := deal(t, nmDuel(), 7)
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
next, evs, err := ApplyMove(s, You, 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")
}
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustStack {
t.Errorf("drawing out of a stack: %v, want ErrMustStack", err)
}
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
t.Errorf("playing a plain card under a stack: %v, want ErrMustStack", err)
}
before := len(next.Hands[You])
paid, evs, err := ApplyMove(next, You, 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]))
}
if paid.Pending == 4 && paid.Phase == PhaseStack {
t.Error("the bill you just paid is still standing")
}
}
// TestMercyKillsThePlayerButNotTheSession: the mercy limit takes the human out of
// the *hand* — the pot goes to whoever is left, and the table plays on. It is no
// longer game over.
func TestMercyKillsThePlayerButNotTheSession(t *testing.T) {
s := deal(t, nmDuel(), 3)
stack(&s, You, 24)
s.Turn = You
s.Phase = PhaseStack
s.Pending = 10
next, evs, err := ApplyMove(s, You, 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.playing() {
t.Fatalf("the hand should be over once one seat is left: phase %s", next.Phase)
}
if next.live(You) || len(next.Hands[You]) != 0 {
t.Error("a dead seat should hold no cards and be out of the hand")
}
// Heads-up, killing the human leaves the bot alone: it takes the pot.
if next.Winner != 1 {
t.Errorf("winner is seat %d, want the surviving bot", next.Winner)
}
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: the deck buries bots too, and a table with every bot
// dead is a pot you have taken.
func TestOutlivingTheTableWins(t *testing.T) {
s := deal(t, nmDuel(), 11)
pot := s.Pot
before := s.Seats[You].Stack
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, You, 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.playing() || next.Winner != You {
t.Fatalf("phase %s winner %d, want a finished hand won by you", next.Phase, next.Winner)
}
profit := pot - s.Tier.Ante
wantWon := pot - int64(float64(profit)*rake)
if next.Seats[You].Stack != before+wantWon {
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
}
}
func TestYouDrawUntilYouCanPlay(t *testing.T) {
s := deal(t, nmDuel(), 5)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Blue, One}} // nothing playable
s.Deck = []Card{{Green, Two}, {Yellow, Three}, {Red, Nine}, {Blue, Four}}
s.Turn = You
s.Phase = PhasePlay
next, _, err := ApplyMove(s, You, 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)
}
if _, _, err := ApplyMove(next, You, Move{Kind: MovePass}); err != ErrMustPlayNow {
t.Errorf("passing in No Mercy: %v, want ErrMustPlayNow", err)
}
}
func TestSkipAllComesBackToYou(t *testing.T) {
s := deal(t, nmFull(), 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, You, 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")
}
}
func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
s := deal(t, nmDuel(), 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, You, 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])
}
// The discard-all was played, so it is on the pile — the bot may have played over
// it since, which is why this looks for it rather than at the very top.
found := false
for _, c := range next.Discard {
if c.Value == DiscardAll {
found = true
}
}
if !found {
t.Error("the discard-all isn't on the pile")
}
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)
}
}
func TestRouletteFlipsUntilTheColour(t *testing.T) {
s := deal(t, nmDuel(), 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
next, evs, err := ApplyMove(s, You, 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)
}
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")
}
}