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
This commit is contained in:
prosolis
2026-07-14 18:37:51 -07:00
parent f8b07d8e6c
commit 927ed84163
15 changed files with 1799 additions and 1208 deletions

View File

@@ -14,7 +14,7 @@ import "testing"
// a +2.
func oneCardAway(t *testing.T, seed uint64) State {
t.Helper()
s := deal(t, duel(), 100, seed)
s := deal(t, duel(), seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
@@ -33,7 +33,7 @@ func oneCardAway(t *testing.T, seed uint64) State {
func TestCallingUnoKeepsYouSafe(t *testing.T) {
for seed := uint64(0); seed < 200; seed++ {
s := oneCardAway(t, seed)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Uno: true})
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Uno: true})
if err != nil {
t.Fatalf("seed %d: play: %v", seed, err)
}
@@ -56,7 +56,7 @@ func TestForgettingUnoGetsYouCaught(t *testing.T) {
caught, games := 0, 400
for seed := uint64(0); seed < uint64(games); seed++ {
s := oneCardAway(t, seed)
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("seed %d: play: %v", seed, err)
}
@@ -83,13 +83,13 @@ func TestForgettingUnoGetsYouCaught(t *testing.T) {
// forgetting at a full table is very nearly always punished.
func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) {
away := func(tier Tier, seed uint64) bool {
s := deal(t, tier, 100, seed)
s := deal(t, tier, seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Red, Two}}
s.Turn = You
s.Phase = PhasePlay
_, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
_, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play: %v", err)
}
@@ -114,7 +114,7 @@ func TestMoreBotsMeansLessGettingAwayWithIt(t *testing.T) {
// quietBot puts a bot on one card it never called, with the turn back on you.
func quietBot(t *testing.T, called bool) State {
t.Helper()
s := deal(t, duel(), 100, 21)
s := deal(t, duel(), 21)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}, {Blue, Two}, {Green, Three}}
@@ -131,7 +131,7 @@ func TestCatchingAQuietBot(t *testing.T) {
s := quietBot(t, false)
before := total(census(s))
next, evs, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: 1})
next, evs, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: 1})
if err != nil {
t.Fatalf("catch: %v", err)
}
@@ -167,7 +167,7 @@ func TestCatchingACleanBotCostsYou(t *testing.T) {
}()},
} {
t.Run(tc.name, func(t *testing.T) {
next, evs, err := ApplyMove(tc.state, Move{Kind: MoveCatch, Seat: 1})
next, evs, err := ApplyMove(tc.state, You, Move{Kind: MoveCatch, Seat: 1})
if err != nil {
t.Fatalf("catch: %v", err)
}
@@ -188,7 +188,7 @@ func TestCatchingACleanBotCostsYou(t *testing.T) {
func TestYouCannotCatchYourself(t *testing.T) {
s := quietBot(t, false)
for _, seat := range []int{You, -1, 9} {
if _, _, err := ApplyMove(s, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch {
if _, _, err := ApplyMove(s, You, Move{Kind: MoveCatch, Seat: seat}); err != ErrNoCatch {
t.Errorf("catching seat %d: got %v, want ErrNoCatch", seat, err)
}
}
@@ -198,7 +198,7 @@ func TestYouCannotCatchYourself(t *testing.T) {
// your way back down to one: that is a new call you owe, not the old one still
// standing. Without this a seat could be caught out once and never again.
func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
s := deal(t, duel(), 100, 5)
s := deal(t, duel(), 5)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}}
@@ -208,7 +208,7 @@ func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
// Draw, and the hand is two: the word you said was about a card you no longer
// hold on its own.
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
@@ -220,17 +220,17 @@ func TestACallIsSpentWhenTheHandGrows(t *testing.T) {
// TestCatchableIsWhatTheTableCanSee — a quiet bot on one card, and nobody else.
func TestCatchable(t *testing.T) {
s := quietBot(t, false)
if got := s.Catchable(); len(got) != 1 || got[0] != 1 {
if got := s.Catchable(You); len(got) != 1 || got[0] != 1 {
t.Errorf("Catchable() = %v, want [1]", got)
}
clean := quietBot(t, true)
if got := clean.Catchable(); len(got) != 0 {
if got := clean.Catchable(You); len(got) != 0 {
t.Errorf("Catchable() = %v on a bot that called, want none", got)
}
// And not on somebody else's turn: you can only call it out when it's on you.
off := quietBot(t, false)
off.Turn = 1
if got := off.Catchable(); len(got) != 0 {
if got := off.Catchable(You); len(got) != 0 {
t.Errorf("Catchable() = %v off-turn, want none", got)
}
}
@@ -240,7 +240,7 @@ func TestCatchable(t *testing.T) {
// 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 gets a player caught.
func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
s := deal(t, nmDuel(), 100, 3)
s := deal(t, nmDuel(), 3)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Red, Seven}, {Blue, Two}}
@@ -249,7 +249,7 @@ func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
// Index 0 dumps itself and the three other reds: five cards become one.
// Index 4 is an ordinary play: five become four.
got := s.UnoAt()
got := s.UnoAt(You)
if len(got) != 1 || got[0] != 0 {
t.Errorf("UnoAt() = %v, want [0]: only the discard-all lands you on one card", got)
}
@@ -258,7 +258,7 @@ func TestUnoAtSeesThroughDiscardAll(t *testing.T) {
// TestUnoAtIsTheOrdinaryCaseToo — two cards in hand, and either of them is a call.
func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) {
s := oneCardAway(t, 1)
got := s.UnoAt()
got := s.UnoAt(You)
if len(got) != 2 {
t.Errorf("UnoAt() = %v, want both cards: either one leaves you holding one", got)
}
@@ -267,19 +267,19 @@ func TestUnoAtIsTheOrdinaryCaseToo(t *testing.T) {
// TestGoingOutNeedsNoCall — your last card is not one card, it's none. Nobody
// owes the table a word for winning.
func TestGoingOutNeedsNoCall(t *testing.T) {
s := deal(t, duel(), 100, 9)
s := deal(t, duel(), 9)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Red, One}}
s.Turn = You
s.Phase = PhasePlay
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
if err != nil {
t.Fatalf("play the last card: %v", err)
}
if !next.Outcome.Won() {
t.Fatalf("outcome %q, want a win", next.Outcome)
if next.Winner != You {
t.Fatalf("winner %d outcome %q, want a win for you", next.Winner, next.Outcome)
}
if hasKind(evs, EvCaught) {
t.Error("caught for not calling UNO on the card that won the game")
@@ -292,7 +292,7 @@ func TestGoingOutNeedsNoCall(t *testing.T) {
func TestABotThatForgetsSaysNothing(t *testing.T) {
quiet := 0
for seed := uint64(0); seed < 300 && quiet < 1; seed++ {
s := deal(t, duel(), 100, seed)
s := deal(t, duel(), seed)
s.Color = Red
s.Discard = []Card{{Red, Five}}
s.Hands[You] = []Card{{Blue, Two}, {Blue, Three}, {Blue, Four}}
@@ -302,7 +302,7 @@ func TestABotThatForgetsSaysNothing(t *testing.T) {
s.Turn = You // the bot plays on the back of your move
// Draw, handing the turn over: the bot plays a red and lands on one card.
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
if err != nil {
t.Fatalf("draw: %v", err)
}
@@ -313,7 +313,7 @@ func TestABotThatForgetsSaysNothing(t *testing.T) {
if hasKind(evs, EvUno) {
t.Error("a bot that forgot to call still announced it")
}
if len(next.Catchable()) != 1 {
if len(next.Catchable(You)) != 1 {
t.Error("a quiet bot on one card isn't catchable")
}
}