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
348 lines
10 KiB
Go
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")
|
|
}
|
|
}
|