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:
@@ -173,17 +173,9 @@ func botColor(hand []Card, rng *rand.Rand) Color {
|
||||
return best
|
||||
}
|
||||
|
||||
// botNames deals the bots their names. Flavour, and load-bearing flavour: "Kiwi
|
||||
// played a +4" is a table, "Bot 2 played a +4" is a test fixture.
|
||||
func botNames(n int, rng *rand.Rand) []string {
|
||||
pool := append([]string(nil), botPool...)
|
||||
rng.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
|
||||
if n > len(pool) {
|
||||
n = len(pool)
|
||||
}
|
||||
return pool[:n]
|
||||
}
|
||||
|
||||
// botPool are the regulars a bot seat is named from. Flavour, and load-bearing
|
||||
// flavour: "Kiwi played a +4" is a table, "Bot 2 played a +4" is a test fixture.
|
||||
// More names than a full table, so no two chairs ever share one.
|
||||
var botPool = []string{
|
||||
"Kiwi", "Mochi", "Bramble", "Pixel", "Gus", "Nori", "Waffle", "Marzipan",
|
||||
"Tuck", "Bebop", "Olive", "Rascal", "Peaches", "Dot", "Sable", "Clementine",
|
||||
|
||||
@@ -49,7 +49,7 @@ func botForgets(rng *rand.Rand) bool { return rng.Float64() < botForget }
|
||||
// — announces it. A seat on any other number of cards owes nothing and this does
|
||||
// nothing, which is what makes it safe to call after every play.
|
||||
func (s *State) declare(seat int, called bool, evs *[]Event) {
|
||||
if s.Phase == PhaseDone || len(s.Hands[seat]) != 1 {
|
||||
if !s.playing() || len(s.Hands[seat]) != 1 {
|
||||
return
|
||||
}
|
||||
s.ensureCalled()
|
||||
@@ -59,43 +59,45 @@ func (s *State) declare(seat int, called bool, evs *[]Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// botsCatch is the window. You have just played, you are holding one card, and
|
||||
// you didn't say the word — so every bot still in the game gets one look, in seat
|
||||
// order, and the first to see it takes you for two.
|
||||
// botsCatch is the window. The seat that just played is holding one card and
|
||||
// didn't say the word — so every bot still in the hand gets one look, in seat
|
||||
// order, and the first to see it takes them for two.
|
||||
//
|
||||
// It runs before runBots on purpose. The catch has to land while the table is
|
||||
// still looking at the card you played, not three turns later.
|
||||
func (s *State) botsCatch(evs *[]Event, rng *rand.Rand) {
|
||||
if s.Phase == PhaseDone || len(s.Hands[You]) != 1 || s.called(You) {
|
||||
// still looking at the card that was played, not three turns later. Only the bots
|
||||
// catch on a roll here; a human polices the table with the catch button, which is
|
||||
// the asymmetry the whole rule turns on — attention is the player's edge.
|
||||
func (s *State) botsCatch(actor int, evs *[]Event, rng *rand.Rand) {
|
||||
if !s.playing() || len(s.Hands[actor]) != 1 || s.called(actor) {
|
||||
return
|
||||
}
|
||||
for _, seat := range s.alive() {
|
||||
if seat == You {
|
||||
continue
|
||||
if seat == actor || !s.Seats[seat].Bot {
|
||||
continue // a human catches with the button, not on a roll
|
||||
}
|
||||
if rng.Float64() >= botAlert {
|
||||
continue // this one wasn't looking
|
||||
}
|
||||
s.penalise(You, seat, EvCaught, evs, rng)
|
||||
s.penalise(actor, seat, EvCaught, evs, rng)
|
||||
return // caught once is caught. They don't queue up to take turns at you
|
||||
}
|
||||
}
|
||||
|
||||
// playerCatches calls out a seat you think is holding one card in silence.
|
||||
// seatCatches calls out a seat the caller thinks is holding one card in silence.
|
||||
//
|
||||
// It is not a turn: right or wrong, the turn stays where it was, which is with
|
||||
// you. What it costs is the risk — a seat that did call, or isn't on one card at
|
||||
// all, is a seat you have just accused of nothing, and that is two cards to you.
|
||||
func (s *State) playerCatches(m Move, rng *rand.Rand) ([]Event, error) {
|
||||
// It is not a turn: right or wrong, the turn stays where it was. What it costs is
|
||||
// the risk — a seat that did call, or isn't on one card at all, is a seat the
|
||||
// caller has accused of nothing, and that is two cards to them.
|
||||
func (s *State) seatCatches(caller int, m Move, rng *rand.Rand) ([]Event, error) {
|
||||
seat := m.Seat
|
||||
if seat == You || seat < 0 || seat >= len(s.Hands) || !s.live(seat) {
|
||||
if seat == caller || seat < 0 || seat >= len(s.Hands) || !s.live(seat) {
|
||||
return nil, ErrNoCatch
|
||||
}
|
||||
var evs []Event
|
||||
if len(s.Hands[seat]) == 1 && !s.called(seat) {
|
||||
s.penalise(seat, You, EvCaught, &evs, rng) // got them
|
||||
s.penalise(seat, caller, EvCaught, &evs, rng) // got them
|
||||
} else {
|
||||
s.penalise(You, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking
|
||||
s.penalise(caller, seat, EvMiscall, &evs, rng) // they were clean, and you weren't looking
|
||||
}
|
||||
return evs, nil
|
||||
}
|
||||
@@ -127,11 +129,11 @@ func (s *State) penalise(victim, by int, kind string, evs *[]Event, rng *rand.Ra
|
||||
//
|
||||
// It answers for every card, legal or not. The table only ever asks about a card
|
||||
// you were allowed to play anyway.
|
||||
func (s State) UnoAt() []int {
|
||||
if s.Phase == PhaseDone || s.Turn != You {
|
||||
func (s State) UnoAt(seat int) []int {
|
||||
if !s.playing() || s.Turn != seat {
|
||||
return nil
|
||||
}
|
||||
hand := s.Hands[You]
|
||||
hand := s.Hands[seat]
|
||||
var out []int
|
||||
for i, c := range hand {
|
||||
left := len(hand) - 1
|
||||
@@ -156,13 +158,13 @@ func (s State) UnoAt() []int {
|
||||
// already isn't — this is the same two facts, subtracted, and doing that
|
||||
// subtraction on the server is only so that the rule for what counts as catchable
|
||||
// lives in one place instead of two.
|
||||
func (s State) Catchable() []int {
|
||||
if s.Phase == PhaseDone || s.Turn != You {
|
||||
func (s State) Catchable(viewer int) []int {
|
||||
if !s.playing() || s.Turn != viewer {
|
||||
return nil // you can only catch a seat on your own turn
|
||||
}
|
||||
var out []int
|
||||
for _, seat := range s.alive() {
|
||||
if seat != You && len(s.Hands[seat]) == 1 && !s.called(seat) {
|
||||
if seat != viewer && len(s.Hands[seat]) == 1 && !s.called(seat) {
|
||||
out = append(out, seat)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,10 +109,10 @@ func (s *State) absorb(seat int, evs *[]Event, rng *rand.Rand) {
|
||||
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 {
|
||||
// The seat can die paying the bill, and a mercy kill can end the hand — the last
|
||||
// seat but one dying leaves somebody alone to take the pot. So the phase is only
|
||||
// reset if there is still a hand to have a phase.
|
||||
if s.mercy(seat, evs, rng) && !s.playing() {
|
||||
return
|
||||
}
|
||||
if !s.live(seat) {
|
||||
@@ -188,13 +188,13 @@ func (s *State) discardAll(seat int, color Color, evs *[]Event) int {
|
||||
}
|
||||
|
||||
// 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
|
||||
// of the hand: its cards go back into the deck and it plays no more this hand. 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.
|
||||
// A mercy kill is no longer game over for anyone — it is one seat out of the
|
||||
// current hand, human or bot alike, and the hand plays on among the living. When
|
||||
// it leaves exactly one seat alive, that seat has outlived the table and takes the
|
||||
// pot; whoever it is, they win it the same way going out first would.
|
||||
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
|
||||
@@ -207,12 +207,8 @@ func (s *State) mercy(seat int, evs *[]Event, rng *rand.Rand) bool {
|
||||
s.Pending = 0 // a dead seat pays no bill, and leaves none behind
|
||||
*evs = append(*evs, s.mine(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
|
||||
s.settle(alive[0], OutcomeWon, evs) // the last one standing takes the pot
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -233,17 +229,9 @@ func (s State) alive() []int {
|
||||
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.
|
||||
// live reports whether a seat is still in the current hand. Out is empty between
|
||||
// hands 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)})
|
||||
}
|
||||
|
||||
@@ -33,32 +33,23 @@ func TestNoMercyDeckIsADeck(t *testing.T) {
|
||||
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.
|
||||
// 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, 100, seed)
|
||||
start := census(s)
|
||||
if got := total(start); got != 168 {
|
||||
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.Phase != PhaseDone && moves < 800; moves++ {
|
||||
next, _, err := ApplyMove(s, naive(s, rng))
|
||||
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)
|
||||
}
|
||||
@@ -68,38 +59,31 @@ func TestNoMercyCensus(t *testing.T) {
|
||||
tier.Slug, seed, total(got))
|
||||
}
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("%s seed %d: game never ended", tier.Slug, seed)
|
||||
if s.playing() {
|
||||
t.Fatalf("%s seed %d: hand 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.
|
||||
// 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(); len(p) > 0 {
|
||||
if p := s.Playable(You); len(p) > 0 {
|
||||
return playMove(s, p[0], rng)
|
||||
}
|
||||
return Move{Kind: MoveTake}
|
||||
}
|
||||
if p := s.Playable(); len(p) > 0 {
|
||||
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. 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.
|
||||
// table still holds 168 of them.
|
||||
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
|
||||
@@ -120,15 +104,7 @@ func playMove(s State, idx int, rng *rand.Rand) Move {
|
||||
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() {
|
||||
for _, at := range s.UnoAt(You) {
|
||||
if at == idx {
|
||||
m.Uno = true
|
||||
}
|
||||
@@ -136,13 +112,10 @@ func playMove(s State, idx int, rng *rand.Rand) Move {
|
||||
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.
|
||||
// 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(), 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 := deal(t, nmDuel(), 7)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DrawTwo}, {Blue, One}}
|
||||
@@ -150,9 +123,7 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||
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})
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play +2: %v", err)
|
||||
}
|
||||
@@ -168,22 +139,15 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||
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 {
|
||||
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, Move{Kind: MovePlay, Index: 0}); err != ErrMustStack {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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})
|
||||
paid, evs, err := ApplyMove(next, You, Move{Kind: MoveTake})
|
||||
if err != nil {
|
||||
t.Fatalf("take: %v", err)
|
||||
}
|
||||
@@ -199,54 +163,49 @@ func TestAStackIsPassedOnAndPaid(t *testing.T) {
|
||||
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.
|
||||
// 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, Move{Kind: MoveTake})
|
||||
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.Phase != PhaseDone || next.Outcome != OutcomeLost {
|
||||
t.Fatalf("phase %s outcome %q, want done/lost", next.Phase, next.Outcome)
|
||||
if next.playing() {
|
||||
t.Fatalf("the hand should be over once one seat is left: phase %s", next.Phase)
|
||||
}
|
||||
if next.Payout != 0 {
|
||||
t.Errorf("a mercy kill paid out %d, want nothing", next.Payout)
|
||||
if next.live(You) || len(next.Hands[You]) != 0 {
|
||||
t.Error("a dead seat should hold no cards and be out of the hand")
|
||||
}
|
||||
if len(next.Hands[You]) != 0 || next.live(You) {
|
||||
t.Error("a dead seat should hold no cards and be out of the game")
|
||||
// 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 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.
|
||||
// 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(), 100, 11)
|
||||
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}}
|
||||
@@ -257,63 +216,56 @@ func TestOutlivingTheTableWins(t *testing.T) {
|
||||
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 +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.playing() || next.Winner != You {
|
||||
t.Fatalf("phase %s winner %d, want a finished hand won by you", next.Phase, next.Winner)
|
||||
}
|
||||
if next.Payout != next.Pays() {
|
||||
t.Errorf("paid %d, quoted %d — settle and the felt must agree", next.Payout, next.Pays())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := deal(t, nmDuel(), 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})
|
||||
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]))
|
||||
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)
|
||||
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 {
|
||||
if _, _, err := ApplyMove(next, You, 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 := 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, Move{Kind: MovePlay, Index: 0})
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play skip-all: %v", err)
|
||||
}
|
||||
@@ -325,10 +277,8 @@ func TestSkipAllComesBackToYou(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := deal(t, nmDuel(), 17)
|
||||
s.Color = Red
|
||||
s.Discard = []Card{{Red, Five}}
|
||||
s.Hands[You] = []Card{{Red, DiscardAll}, {Red, One}, {Red, Nine}, {Blue, Two}}
|
||||
@@ -336,22 +286,26 @@ func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
||||
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})
|
||||
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]))
|
||||
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)
|
||||
// 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")
|
||||
@@ -361,9 +315,8 @@ func TestDiscardAllTakesTheColourWithIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouletteFlipsUntilTheColour — and the victim keeps every card it turned.
|
||||
func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||
s := deal(t, nmDuel(), 100, 19)
|
||||
s := deal(t, nmDuel(), 19)
|
||||
s.Color = Blue
|
||||
s.Discard = []Card{{Blue, Five}}
|
||||
s.Hands[You] = []Card{{Wild, WildRoulette}, {Blue, One}}
|
||||
@@ -372,8 +325,7 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||
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})
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Red})
|
||||
if err != nil {
|
||||
t.Fatalf("play roulette: %v", err)
|
||||
}
|
||||
@@ -386,8 +338,6 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -395,43 +345,3 @@ func TestRouletteFlipsUntilTheColour(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,19 +8,38 @@ import (
|
||||
|
||||
const rake = 0.05
|
||||
|
||||
// You is the human's seat in the solo tests, a test-local alias for what the
|
||||
// engine no longer hardcodes: a table is a list of seats, and seat zero being the
|
||||
// human is a convention only these fixtures keep.
|
||||
const You = 0
|
||||
|
||||
func duel() Tier { t, _ := TierBySlug("duel"); return t }
|
||||
func full() Tier { t, _ := TierBySlug("full"); return t }
|
||||
func table() Tier { t, _ := TierBySlug("table"); return t }
|
||||
|
||||
// deal starts a game, failing the test if it can't.
|
||||
func deal(t *testing.T, tier Tier, bet int64, seed uint64) State {
|
||||
// openSolo opens a solo table (one human, the tier's bots) without dealing.
|
||||
func openSolo(t *testing.T, tier Tier, seed uint64) State {
|
||||
t.Helper()
|
||||
s, evs, err := New(bet, tier, rake, seed, 0xC0FFEE)
|
||||
s, _, err := New(tier, SoloSeats(tier, tier.Bots, 1000), rake, seed, 0xC0FFEE)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Kind != EvDeal {
|
||||
t.Fatalf("New should deal exactly one event, got %+v", evs)
|
||||
return s
|
||||
}
|
||||
|
||||
// deal opens a solo table and deals the first hand, so the human is to act.
|
||||
func deal(t *testing.T, tier Tier, seed uint64) State {
|
||||
t.Helper()
|
||||
s := openSolo(t, tier, seed)
|
||||
s, evs, err := ApplyMove(s, You, Move{Kind: MoveDeal})
|
||||
if err != nil {
|
||||
t.Fatalf("deal: %v", err)
|
||||
}
|
||||
if !hasKind(evs, EvDeal) {
|
||||
t.Fatalf("the deal emitted no deal event: %+v", evs)
|
||||
}
|
||||
if s.Turn != You {
|
||||
t.Fatalf("the human should act first at a solo table, turn is %d", s.Turn)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -38,11 +57,6 @@ func census(s State) map[Card]int {
|
||||
m[c]++
|
||||
}
|
||||
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. 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
|
||||
}
|
||||
@@ -76,7 +90,7 @@ func TestNewDeckIsADeck(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewDeals(t *testing.T) {
|
||||
s := deal(t, full(), 100, 7)
|
||||
s := deal(t, full(), 7)
|
||||
if len(s.Hands) != 4 {
|
||||
t.Fatalf("full house is four seats, got %d", len(s.Hands))
|
||||
}
|
||||
@@ -85,21 +99,31 @@ func TestNewDeals(t *testing.T) {
|
||||
t.Errorf("seat %d holds %d cards, want %d", i, len(h), HandSize)
|
||||
}
|
||||
}
|
||||
if len(s.Bots) != 3 {
|
||||
t.Fatalf("want three bot names, got %v", s.Bots)
|
||||
bots := 0
|
||||
for _, seat := range s.Seats {
|
||||
if seat.Bot {
|
||||
bots++
|
||||
if seat.Name == "" {
|
||||
t.Error("a bot seat has no name")
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.Turn != You {
|
||||
t.Errorf("you play first, turn is %d", s.Turn)
|
||||
if bots != 3 {
|
||||
t.Fatalf("want three bots, got %d", bots)
|
||||
}
|
||||
if got := total(census(s)); got != 108 {
|
||||
t.Fatalf("the deal lost cards: %d of 108", got)
|
||||
}
|
||||
// Every seat anted, so the pot is one ante per seat.
|
||||
if want := s.Tier.Ante * int64(len(s.Seats)); s.Pot != want {
|
||||
t.Errorf("pot is %d, want %d (one ante each)", s.Pot, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The card turned over to start is never an action card — see New.
|
||||
// The card turned over to start is never an action card — see dealHand.
|
||||
func TestOpeningCardIsANumber(t *testing.T) {
|
||||
for seed := uint64(0); seed < 300; seed++ {
|
||||
s := deal(t, table(), 50, seed)
|
||||
s := deal(t, table(), seed)
|
||||
if s.Top().Value.Action() {
|
||||
t.Fatalf("seed %d opened on %v", seed, s.Top())
|
||||
}
|
||||
@@ -111,12 +135,9 @@ func TestOpeningCardIsANumber(t *testing.T) {
|
||||
|
||||
// ---- the rules ------------------------------------------------------------
|
||||
|
||||
// rig builds a state by hand, so a rule can be tested without hunting a seed
|
||||
// that happens to deal it.
|
||||
//
|
||||
// The deck is the rest of the deck: every card not in a hand and not the one in
|
||||
// play. So a rigged game still holds 108 cards, and the census invariant means
|
||||
// something in these tests too.
|
||||
// rig builds a live hand by hand, so a rule can be tested without hunting a seed
|
||||
// that happens to deal it. Every seat has anted, so the pot is set and a win
|
||||
// settles for real.
|
||||
func rig(hands [][]Card, top Card, color Color) State {
|
||||
left := map[Card]int{}
|
||||
for _, c := range NewDeck() {
|
||||
@@ -137,30 +158,38 @@ func rig(hands [][]Card, top Card, color Color) State {
|
||||
|
||||
var deck []Card
|
||||
for _, c := range NewDeck() {
|
||||
key := c
|
||||
if left[key] > 0 {
|
||||
left[key]--
|
||||
if left[c] > 0 {
|
||||
left[c]--
|
||||
deck = append(deck, c)
|
||||
}
|
||||
}
|
||||
ante := full().Ante
|
||||
seats := make([]Seat, len(hands))
|
||||
for i := range seats {
|
||||
// The stack is what is left after this seat anted: a real deal moves the ante
|
||||
// out of the stack and into the pot, so a refund or a win returns it here.
|
||||
seats[i] = Seat{Name: botPool[i], Bot: i != You, Stack: 1000 - ante, Ante: ante}
|
||||
}
|
||||
seats[You].Name = "You"
|
||||
return State{
|
||||
Tier: full(), Hands: hands, Discard: []Card{top}, Color: color,
|
||||
Deck: deck, Dir: 1, Turn: You, Phase: PhasePlay,
|
||||
Bet: 100, RakePct: rake, Seed1: 1, Seed2: 2,
|
||||
Tier: full(), Seats: seats, Hands: hands, Discard: []Card{top}, Color: color,
|
||||
Deck: deck, Dir: 1, Turn: You, Dealer: len(hands) - 1, Phase: PhasePlay,
|
||||
Pot: ante * int64(len(hands)), Winner: -1,
|
||||
Out: make([]bool, len(hands)), Called: make([]bool, len(hands)),
|
||||
RakePct: rake, Seed1: 1, Seed2: 2,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayMustMatch(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrCantPlay {
|
||||
t.Fatalf("a blue 3 on a red 9 should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayMatchesFaceOrColor(t *testing.T) {
|
||||
// Same face, different colour: legal.
|
||||
s := rig([][]Card{{{Blue, Nine}, {Red, Two}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
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("a blue 9 on a red 9 is legal: %v", err)
|
||||
}
|
||||
@@ -174,22 +203,20 @@ func TestPlayMatchesFaceOrColor(t *testing.T) {
|
||||
|
||||
func TestWildNeedsAColor(t *testing.T) {
|
||||
s := rig([][]Card{{{Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}); err != ErrNeedColor {
|
||||
t.Fatalf("a wild with no colour should be refused, got %v", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Wild}); err != ErrNeedColor {
|
||||
t.Fatalf("naming 'wild' is not naming a colour, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildNamesTheColor(t *testing.T) {
|
||||
s := rig([][]Card{{{Wild, WildCard}, {Green, One}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0, Color: Green})
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0, Color: Green})
|
||||
if err != nil {
|
||||
t.Fatalf("play wild: %v", err)
|
||||
}
|
||||
// The bot moved after us, so the colour in play is whatever it left behind —
|
||||
// what we can check is that the wild itself went down as green.
|
||||
top := next.Discard
|
||||
if len(top) < 2 {
|
||||
t.Fatalf("expected the wild and the bot's card on the pile: %v", top)
|
||||
@@ -200,10 +227,9 @@ func TestWildNamesTheColor(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDrawTwoHitsTheNextSeat(t *testing.T) {
|
||||
// Two seats, so the +2 lands on the bot and the turn comes straight back.
|
||||
s := rig([][]Card{{{Red, DrawTwo}, {Red, One}}, {{Blue, Five}, {Blue, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
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 +2: %v", err)
|
||||
}
|
||||
@@ -224,7 +250,7 @@ func TestDrawTwoHitsTheNextSeat(t *testing.T) {
|
||||
func TestReverseIsASkipHeadsUp(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Reverse}, {Red, One}}, {{Blue, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
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 reverse: %v", err)
|
||||
}
|
||||
@@ -243,15 +269,13 @@ func TestReverseIsASkipHeadsUp(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReverseTurnsTheTableAround(t *testing.T) {
|
||||
// Every bot holds a red card, so each of them can play the moment the turn
|
||||
// reaches it — which is what makes the *order* they play in observable.
|
||||
s := rig([][]Card{
|
||||
{{Red, Reverse}, {Red, One}},
|
||||
{{Red, Five}, {Blue, Six}},
|
||||
{{Red, Six}, {Green, Six}},
|
||||
{{Red, Seven}, {Yellow, Six}},
|
||||
}, Card{Red, Nine}, Red)
|
||||
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 reverse: %v", err)
|
||||
}
|
||||
@@ -264,7 +288,6 @@ func TestReverseTurnsTheTableAround(t *testing.T) {
|
||||
if next.Turn != You {
|
||||
t.Errorf("the bots should have played round to you, turn is %d", next.Turn)
|
||||
}
|
||||
// The table now runs anticlockwise: seat 3 plays, then 2, then 1.
|
||||
var order []int
|
||||
for _, e := range evs {
|
||||
if e.Kind == EvPlay && e.Seat != You {
|
||||
@@ -277,15 +300,13 @@ func TestReverseTurnsTheTableAround(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSkipSkips(t *testing.T) {
|
||||
// Both bots hold a playable red, so the only reason either of them doesn't
|
||||
// play is that it wasn't asked to.
|
||||
s := rig([][]Card{
|
||||
{{Red, Skip}, {Red, One}},
|
||||
{{Red, Five}, {Blue, Six}},
|
||||
{{Red, Six}, {Green, Six}},
|
||||
}, Card{Red, Nine}, Red)
|
||||
s.Tier = table()
|
||||
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 skip: %v", err)
|
||||
}
|
||||
@@ -310,7 +331,7 @@ func TestSkipSkips(t *testing.T) {
|
||||
func TestDrawnPlayableWaitsForYou(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Deck = []Card{{Red, Four}} // exactly what you'll draw, and it plays
|
||||
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)
|
||||
}
|
||||
@@ -323,18 +344,16 @@ func TestDrawnPlayableWaitsForYou(t *testing.T) {
|
||||
if evs[0].Kind != EvDraw || evs[0].Card == nil || *evs[0].Card != (Card{Red, Four}) {
|
||||
t.Fatalf("your own drawn card comes face up: %+v", evs[0])
|
||||
}
|
||||
if got := next.Playable(); len(got) != 1 || got[0] != 1 {
|
||||
if got := next.Playable(You); len(got) != 1 || got[0] != 1 {
|
||||
t.Errorf("the drawn card, and only it, is playable: %v", got)
|
||||
}
|
||||
// You may not play the *other* card instead — drawing would otherwise be a
|
||||
// free look with no cost.
|
||||
if _, _, err := ApplyMove(next, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MovePlay, Index: 0}); err != ErrMustPlayNow {
|
||||
t.Fatalf("only the drawn card may be played, got %v", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(next, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
|
||||
if _, _, err := ApplyMove(next, You, Move{Kind: MoveDraw}); err != ErrMustPlayNow {
|
||||
t.Fatalf("you can't draw twice, got %v", err)
|
||||
}
|
||||
after, _, err := ApplyMove(next, Move{Kind: MovePass})
|
||||
after, _, err := ApplyMove(next, You, Move{Kind: MovePass})
|
||||
if err != nil {
|
||||
t.Fatalf("pass: %v", err)
|
||||
}
|
||||
@@ -349,7 +368,7 @@ func TestDrawnPlayableWaitsForYou(t *testing.T) {
|
||||
func TestUnplayableDrawPassesTheTurn(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Deck = []Card{{Blue, Four}, {Red, Two}} // draw a blue 4: it doesn't go on a red 9
|
||||
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)
|
||||
}
|
||||
@@ -363,69 +382,78 @@ func TestUnplayableDrawPassesTheTurn(t *testing.T) {
|
||||
|
||||
func TestPassOnlyAfterADraw(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
if _, _, err := ApplyMove(s, Move{Kind: MovePass}); err != ErrCantPass {
|
||||
if _, _, err := ApplyMove(s, You, Move{Kind: MovePass}); err != ErrCantPass {
|
||||
t.Fatalf("you can't pass a turn you haven't drawn on, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dead is a table nobody can move at: the deck is spent, the discard is one card
|
||||
// deep so there is nothing to reshuffle out of, and not a seat holds a card that
|
||||
// goes on the pile. Every seat can only pass, forever.
|
||||
// deep, and not a seat holds a card that goes on the pile.
|
||||
func dead(hands [][]Card) State {
|
||||
s := rig(hands, Card{Red, Nine}, Red)
|
||||
s.Deck = nil
|
||||
return s
|
||||
}
|
||||
|
||||
// The game has to end here. It used to not: the stuck guard counted how many
|
||||
// bots had passed in a row and asked for more of them than there are seats, so
|
||||
// it never fired once, and a dead table just handed the turn round and round.
|
||||
// That is a game the player cannot finish — and a game they cannot finish is
|
||||
// chips they cannot cash out, because the cage won't let them leave a live hand.
|
||||
// A dead table ends the hand rather than passing the turn round forever. It no
|
||||
// longer ends the *session* — a shared table plays another hand — so it lands on
|
||||
// PhaseHandOver, not PhaseDone.
|
||||
func TestDeadTableEnds(t *testing.T) {
|
||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}})
|
||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}}}) // level: one card each
|
||||
|
||||
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)
|
||||
}
|
||||
if next.Phase != PhaseDone {
|
||||
t.Fatalf("nobody can move and there is nothing to draw: the game is over, not %q", next.Phase)
|
||||
if next.playing() {
|
||||
t.Fatalf("nobody can move and there is nothing to draw: the hand is over, not %q", next.Phase)
|
||||
}
|
||||
if next.Outcome != OutcomeStuck {
|
||||
t.Errorf("a tie on the shortest hand is nobody going out: %q", next.Outcome)
|
||||
if next.Phase != PhaseHandOver {
|
||||
t.Fatalf("a dead hand at a shared table returns to handover, not %q", next.Phase)
|
||||
}
|
||||
if next.Payout != 0 {
|
||||
t.Errorf("a stuck game pays nothing, not %d", next.Payout)
|
||||
if next.Outcome != OutcomeTie {
|
||||
t.Errorf("level on the shortest hand is a tie, got %q", next.Outcome)
|
||||
}
|
||||
// A tie hands the antes back: every seat is whole again.
|
||||
for i := range next.Seats {
|
||||
if next.Seats[i].Stack != 1000 {
|
||||
t.Errorf("seat %d wasn't refunded: stack %d, want 1000", i, next.Seats[i].Stack)
|
||||
}
|
||||
}
|
||||
if !hasKind(evs, EvSettle) {
|
||||
t.Errorf("the table has to be told it's over: %+v", evs)
|
||||
t.Errorf("the table has to be told the hand is over: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// And the shortest hand takes it, which is the one way a stuck table still pays.
|
||||
// And the shortest hand takes the pot, which is the one way a stuck table pays.
|
||||
func TestDeadTablePaysTheShortestHand(t *testing.T) {
|
||||
s := dead([][]Card{{{Blue, Three}}, {{Green, Five}, {Green, Six}}})
|
||||
pot := s.Pot
|
||||
before := s.Seats[You].Stack
|
||||
|
||||
next, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
if next.Outcome != OutcomeWon {
|
||||
t.Fatalf("one card against two is a win: %q", next.Outcome)
|
||||
if next.Outcome != OutcomeStuck || next.Winner != You {
|
||||
t.Fatalf("one card against two is a win for you: outcome %q winner %d", next.Outcome, next.Winner)
|
||||
}
|
||||
if next.Payout != s.Pays() {
|
||||
t.Errorf("payout %d, quoted %d — the felt has to honour what it advertised", next.Payout, s.Pays())
|
||||
profit := pot - s.Tier.Ante
|
||||
wantRake := int64(float64(profit) * rake)
|
||||
wantWon := pot - wantRake
|
||||
if next.Seats[You].Won != wantWon {
|
||||
t.Errorf("you took %d, want %d (pot %d less rake %d)", next.Seats[You].Won, wantWon, pot, wantRake)
|
||||
}
|
||||
if next.Seats[You].Stack != before+wantWon {
|
||||
t.Errorf("your stack is %d, want %d", next.Seats[You].Stack, before+wantWon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReshuffleRebuildsTheDeck(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
// An empty deck, and a discard with something under the top card to become one.
|
||||
// The buried wild went down as green; it has to come back as a wild.
|
||||
s.Deck = nil
|
||||
s.Discard = []Card{{Green, WildCard}, {Red, Two}, {Red, Nine}}
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw on an empty deck: %v", err)
|
||||
}
|
||||
@@ -449,89 +477,64 @@ func TestReshuffleRebuildsTheDeck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the money ------------------------------------------------------------
|
||||
// ---- the pot --------------------------------------------------------------
|
||||
|
||||
// The rule every game in this casino has had to be taught: the number the felt
|
||||
// quotes and the number the settle lands on are one function, not two.
|
||||
func TestQuoteIsThePayout(t *testing.T) {
|
||||
for _, tier := range Tiers {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = tier
|
||||
s.Hands = make([][]Card, tier.Bots+1)
|
||||
s.Hands[You] = []Card{{Red, Three}}
|
||||
for i := 1; i <= tier.Bots; i++ {
|
||||
s.Hands[i] = []Card{{Green, Five}, {Green, Six}}
|
||||
}
|
||||
quoted := s.Pays()
|
||||
// The winner takes the pot, and the house's rake comes out of the winnings, never
|
||||
// out of a seat's own ante.
|
||||
func TestWinnerTakesThePotLessRake(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}, {{Blue, One}, {Blue, Two}}}, Card{Red, Nine}, Red)
|
||||
pot := s.Pot
|
||||
before := s.Seats[You].Stack
|
||||
|
||||
next, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0}) // your last card
|
||||
if err != nil {
|
||||
t.Fatalf("%s: go out: %v", tier.Slug, err)
|
||||
}
|
||||
if next.Outcome != OutcomeWon {
|
||||
t.Fatalf("%s: playing your last card wins, got %q", tier.Slug, next.Outcome)
|
||||
}
|
||||
if next.Payout != quoted {
|
||||
t.Errorf("%s: the felt quoted %d, the house paid %d", tier.Slug, quoted, next.Payout)
|
||||
}
|
||||
if next.Net() != quoted-s.Bet {
|
||||
t.Errorf("%s: net is %d, want %d", tier.Slug, next.Net(), quoted-s.Bet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
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})
|
||||
next, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0}) // your last card
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
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.Outcome != OutcomeWon || next.Winner != You {
|
||||
t.Fatalf("playing your last card wins: outcome %q winner %d", next.Outcome, next.Winner)
|
||||
}
|
||||
profit := pot - s.Tier.Ante
|
||||
wantRake := int64(float64(profit) * rake)
|
||||
wantWon := pot - wantRake
|
||||
if next.Rake != wantRake {
|
||||
t.Errorf("rake %d, want %d — and never a penny of the %d stake",
|
||||
next.Rake, wantRake, s.Bet)
|
||||
t.Errorf("rake %d, want %d — and never a penny of an ante", next.Rake, wantRake)
|
||||
}
|
||||
if next.Net() != wantPayout-s.Bet {
|
||||
t.Errorf("net %d, want %d", next.Net(), wantPayout-s.Bet)
|
||||
if next.LastPot != pot {
|
||||
t.Errorf("last pot %d, want %d", next.LastPot, pot)
|
||||
}
|
||||
if next.Seats[You].Stack != before+wantWon {
|
||||
t.Errorf("your stack is %d, want %d (+%d)", next.Seats[You].Stack, before+wantWon, wantWon)
|
||||
}
|
||||
if next.Paid != wantRake {
|
||||
t.Errorf("the session rake tally is %d, want %d", next.Paid, wantRake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLosingPaysNothingAndIsNotCharged(t *testing.T) {
|
||||
// The bot holds one card that plays on the pile, so it goes out the moment the
|
||||
// turn reaches it.
|
||||
// A bot winning rakes nothing: the house already keeps the whole pot when its own
|
||||
// seat takes it, so there is nothing to charge.
|
||||
func TestABotWinningRakesNothing(t *testing.T) {
|
||||
// The bot at seat 1 holds one card that plays; it goes out the moment the turn
|
||||
// reaches it.
|
||||
s := rig([][]Card{{{Red, Three}, {Red, Four}}, {{Red, Five}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
next, evs, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
pot := s.Pot
|
||||
|
||||
next, evs, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("play: %v", err)
|
||||
}
|
||||
if next.Outcome != OutcomeLost {
|
||||
t.Fatalf("the bot went out, so you lost: %q", next.Outcome)
|
||||
if next.Outcome != OutcomeWon || next.Winner != 1 {
|
||||
t.Fatalf("the bot went out: outcome %q winner %d", next.Outcome, next.Winner)
|
||||
}
|
||||
if next.Payout != 0 || next.Rake != 0 {
|
||||
t.Errorf("a loss pays nothing and is charged nothing: payout %d rake %d", next.Payout, next.Rake)
|
||||
if next.Rake != 0 {
|
||||
t.Errorf("a bot winning rakes nothing, got %d", next.Rake)
|
||||
}
|
||||
if next.Net() != -s.Bet {
|
||||
t.Errorf("a loss costs the stake and no more: %d", next.Net())
|
||||
if next.Seats[1].Won != pot {
|
||||
t.Errorf("the bot took %d, want the whole pot %d", next.Seats[1].Won, pot)
|
||||
}
|
||||
// You anted and lost it: your stack is down exactly one ante.
|
||||
if next.Seats[You].Stack != 1000-s.Tier.Ante {
|
||||
t.Errorf("your stack is %d, want %d (one ante gone)", next.Seats[You].Stack, 1000-s.Tier.Ante)
|
||||
}
|
||||
last := evs[len(evs)-1]
|
||||
if last.Kind != EvSettle || last.Seat != 1 {
|
||||
@@ -539,51 +542,81 @@ func TestLosingPaysNothingAndIsNotCharged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMoveAfterItIsOver(t *testing.T) {
|
||||
// A hand ending returns the table to handover, ready to deal again — it does not
|
||||
// take a hand move.
|
||||
func TestNoHandMoveBetweenHands(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
done, _, err := ApplyMove(s, Move{Kind: MovePlay, Index: 0})
|
||||
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(done, Move{Kind: MoveDraw}); err != ErrGameOver {
|
||||
t.Fatalf("a finished game takes no more moves, got %v", err)
|
||||
if over.Phase != PhaseHandOver {
|
||||
t.Fatalf("a finished hand returns to handover, got %q", over.Phase)
|
||||
}
|
||||
if _, _, err := ApplyMove(over, You, Move{Kind: MoveDraw}); err != ErrNoHand {
|
||||
t.Fatalf("no hand is in progress between hands, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadBet(t *testing.T) {
|
||||
if _, _, err := New(0, duel(), rake, 1, 2); err != ErrBadBet {
|
||||
t.Fatalf("want ErrBadBet, got %v", err)
|
||||
// You can deal the next hand, ante again, and play on — the session shape.
|
||||
func TestDealTheNextHand(t *testing.T) {
|
||||
s := rig([][]Card{{{Red, Three}}, {{Green, Five}, {Green, Six}}}, Card{Red, Nine}, Red)
|
||||
s.Tier = duel()
|
||||
over, _, err := ApplyMove(s, You, Move{Kind: MovePlay, Index: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("go out: %v", err)
|
||||
}
|
||||
again, evs, err := ApplyMove(over, You, Move{Kind: MoveDeal})
|
||||
if err != nil {
|
||||
t.Fatalf("deal the next hand: %v", err)
|
||||
}
|
||||
if again.HandNo != over.HandNo+1 {
|
||||
t.Errorf("hand number didn't advance: %d then %d", over.HandNo, again.HandNo)
|
||||
}
|
||||
if !again.playing() {
|
||||
t.Fatalf("the next hand should be live, phase %q", again.Phase)
|
||||
}
|
||||
if !hasKind(evs, EvAnte) || !hasKind(evs, EvDeal) {
|
||||
t.Errorf("the deal antes and turns a card: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadBuyIn(t *testing.T) {
|
||||
if _, _, err := New(duel(), SoloSeats(duel(), 1, 10), rake, 1, 2); err != ErrBadBuyIn {
|
||||
t.Fatalf("a buy-in under the minimum should be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the whole game -------------------------------------------------------
|
||||
|
||||
// playOut plays a game to its end with a simple strategy: play the first legal
|
||||
// card, otherwise draw, otherwise pass. It asserts the invariants at every step.
|
||||
// playOut plays one hand to its end with a simple strategy: play the first legal
|
||||
// card, take a stack you can't answer, otherwise draw, otherwise pass.
|
||||
func playOut(t *testing.T, s State, maxTurns int) State {
|
||||
t.Helper()
|
||||
for turn := 0; s.Phase != PhaseDone; turn++ {
|
||||
for turn := 0; s.playing(); turn++ {
|
||||
if turn > maxTurns {
|
||||
t.Fatalf("the game never ended in %d turns", maxTurns)
|
||||
t.Fatalf("the hand never ended in %d turns", maxTurns)
|
||||
}
|
||||
if s.Turn != You {
|
||||
t.Fatalf("ApplyMove left the turn with seat %d — the bots should always run out", s.Turn)
|
||||
}
|
||||
|
||||
var m Move
|
||||
if p := s.Playable(); len(p) > 0 {
|
||||
if p := s.Playable(You); len(p) > 0 {
|
||||
m = Move{Kind: MovePlay, Index: p[0]}
|
||||
if s.Hands[You][p[0]].IsWild() {
|
||||
m.Color = Green
|
||||
}
|
||||
} else if s.Phase == PhaseStack {
|
||||
m = Move{Kind: MoveTake}
|
||||
} else if s.Phase == PhaseDrawn {
|
||||
m = Move{Kind: MovePass}
|
||||
} else {
|
||||
m = Move{Kind: MoveDraw}
|
||||
}
|
||||
|
||||
next, evs, err := ApplyMove(s, m)
|
||||
next, evs, err := ApplyMove(s, You, m)
|
||||
if err != nil {
|
||||
t.Fatalf("turn %d: %v (move %+v, phase %v)", turn, err, m, s.Phase)
|
||||
}
|
||||
@@ -598,13 +631,6 @@ func playOut(t *testing.T, s State, maxTurns int) State {
|
||||
t.Fatalf("turn %d: %d of %v, want %d — a card was duplicated", turn, n, c, want)
|
||||
}
|
||||
}
|
||||
// No event ever names a bot's card. That is the hole card of this game, and
|
||||
// it is most of the deck.
|
||||
for _, e := range evs {
|
||||
if (e.Kind == EvDraw || e.Kind == EvForced) && e.Seat != You && e.Card != nil {
|
||||
t.Fatalf("turn %d: a bot's drawn card crossed the wire: %+v", turn, e)
|
||||
}
|
||||
}
|
||||
s = next
|
||||
}
|
||||
return s
|
||||
@@ -622,72 +648,54 @@ func deckCount(c Card) int {
|
||||
}
|
||||
}
|
||||
|
||||
// A hundred games, played out, with the invariants checked at every step. This
|
||||
// is the test that would have caught a deck that leaks cards through the
|
||||
// reshuffle, a turn the bots don't hand back, or a game that can't end.
|
||||
// A hundred hands, played out, with the invariants checked at every step.
|
||||
func TestGamesPlayOut(t *testing.T) {
|
||||
wins, losses, stuck := 0, 0, 0
|
||||
yous, others, ties := 0, 0, 0
|
||||
for seed := uint64(0); seed < 100; seed++ {
|
||||
tier := Tiers[seed%3]
|
||||
end := playOut(t, deal(t, tier, 100, seed), 500)
|
||||
switch end.Outcome {
|
||||
case OutcomeWon:
|
||||
wins++
|
||||
if end.Payout != end.Pays() {
|
||||
t.Fatalf("seed %d: paid %d, quoted %d", seed, end.Payout, end.Pays())
|
||||
}
|
||||
case OutcomeLost:
|
||||
losses++
|
||||
case OutcomeStuck:
|
||||
stuck++
|
||||
default:
|
||||
t.Fatalf("seed %d ended as %q", seed, end.Outcome)
|
||||
end := playOut(t, deal(t, tier, seed), 500)
|
||||
if end.Phase != PhaseHandOver {
|
||||
t.Fatalf("seed %d ended in phase %q", seed, end.Phase)
|
||||
}
|
||||
if len(end.Hands[end.winnerSeat()]) != 0 && end.Outcome != OutcomeStuck {
|
||||
switch {
|
||||
case end.Winner == You:
|
||||
yous++
|
||||
case end.Winner >= 0:
|
||||
others++
|
||||
case end.Outcome == OutcomeTie:
|
||||
ties++
|
||||
default:
|
||||
t.Fatalf("seed %d ended with winner %d outcome %q", seed, end.Winner, end.Outcome)
|
||||
}
|
||||
if end.Winner >= 0 && end.Outcome == OutcomeWon && len(end.Hands[end.Winner]) != 0 {
|
||||
t.Fatalf("seed %d: the winner is still holding cards", seed)
|
||||
}
|
||||
}
|
||||
// Playing the first legal card is a poor strategy against bots that hold their
|
||||
// +4s back, so this is not a fairness assertion — it's a check that both
|
||||
// outcomes actually happen. A table that never pays is a bug in the bots.
|
||||
if wins == 0 || losses == 0 {
|
||||
t.Fatalf("100 games gave %d wins, %d losses, %d stuck — one side never happens", wins, losses, stuck)
|
||||
if yous == 0 || others == 0 {
|
||||
t.Fatalf("100 hands gave %d to you, %d to others, %d tied — one side never happens", yous, others, ties)
|
||||
}
|
||||
t.Logf("100 games: %d won, %d lost, %d stuck", wins, losses, stuck)
|
||||
t.Logf("100 hands: %d to you, %d to others, %d tied", yous, others, ties)
|
||||
}
|
||||
|
||||
// winnerSeat is the seat the settle event named — only used by the tests.
|
||||
func (s State) winnerSeat() int {
|
||||
best := 0
|
||||
for i := range s.Hands {
|
||||
if len(s.Hands[i]) < len(s.Hands[best]) {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// The same seed deals the same game and the bots make the same choices — which
|
||||
// is what lets a disputed game be replayed exactly as it fell.
|
||||
// The same seed deals the same hand and the bots make the same choices.
|
||||
func TestReplaysFromTheSeed(t *testing.T) {
|
||||
a := playOut(t, deal(t, full(), 250, 42), 500)
|
||||
b := playOut(t, deal(t, full(), 250, 42), 500)
|
||||
a := playOut(t, deal(t, full(), 42), 500)
|
||||
b := playOut(t, deal(t, full(), 42), 500)
|
||||
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
if string(ja) != string(jb) {
|
||||
t.Fatal("the same seed played the same way gave two different games")
|
||||
}
|
||||
if a.Outcome == "" {
|
||||
if a.Winner < 0 && a.Outcome != OutcomeTie {
|
||||
t.Fatal("the replay didn't finish")
|
||||
}
|
||||
}
|
||||
|
||||
// A game in progress survives a redeploy: it is a plain value, so it round-trips
|
||||
// through the JSON it is stored as.
|
||||
// A game in progress survives a redeploy: it round-trips through its JSON.
|
||||
func TestStateSurvivesStorage(t *testing.T) {
|
||||
s := deal(t, table(), 100, 9)
|
||||
s, _, err := ApplyMove(s, Move{Kind: MoveDraw})
|
||||
s := deal(t, table(), 9)
|
||||
s, _, err := ApplyMove(s, You, Move{Kind: MoveDraw})
|
||||
if err != nil {
|
||||
t.Fatalf("draw: %v", err)
|
||||
}
|
||||
@@ -704,12 +712,10 @@ func TestStateSurvivesStorage(t *testing.T) {
|
||||
if string(again) != string(blob) {
|
||||
t.Fatal("a stored game came back different")
|
||||
}
|
||||
// And it plays on from there.
|
||||
playOut(t, back, 500)
|
||||
}
|
||||
|
||||
// A move the engine refuses leaves the caller's state exactly as it was — no
|
||||
// card half-played, no turn half-passed.
|
||||
// A move the engine refuses leaves the caller's state exactly as it was.
|
||||
func TestARefusedMoveChangesNothing(t *testing.T) {
|
||||
s := rig([][]Card{{{Blue, Three}, {Wild, WildCard}}, {{Green, Five}}}, Card{Red, Nine}, Red)
|
||||
before, _ := json.Marshal(s)
|
||||
@@ -721,7 +727,7 @@ func TestARefusedMoveChangesNothing(t *testing.T) {
|
||||
{Kind: MovePass}, // nothing drawn
|
||||
{Kind: "shuffle-the-deck-in-my-favour"}, // no
|
||||
} {
|
||||
if _, _, err := ApplyMove(s, m); err == nil {
|
||||
if _, _, err := ApplyMove(s, You, m); err == nil {
|
||||
t.Fatalf("%+v should have been refused", m)
|
||||
}
|
||||
}
|
||||
@@ -731,13 +737,12 @@ func TestARefusedMoveChangesNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The bots choose. Two different seeds should not play the same bot game, or the
|
||||
// bot is a lookup table you can memorise.
|
||||
// The bots choose. Two different seeds should not play the same game.
|
||||
func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
|
||||
same := 0
|
||||
for seed := uint64(0); seed < 20; seed++ {
|
||||
a := playOut(t, deal(t, duel(), 100, seed), 500)
|
||||
b := playOut(t, deal(t, duel(), 100, seed+1000), 500)
|
||||
a := playOut(t, deal(t, duel(), seed), 500)
|
||||
b := playOut(t, deal(t, duel(), seed+1000), 500)
|
||||
if len(a.Discard) == len(b.Discard) {
|
||||
same++
|
||||
}
|
||||
@@ -747,8 +752,6 @@ func TestBotsAreNotDeterministicAcrossSeeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// botPick holds its +4 back while it's comfortable, and reaches for it when
|
||||
// somebody is about to go out.
|
||||
func TestBotSavesTheDrawFour(t *testing.T) {
|
||||
hand := []Card{{Wild, WildDrawFour}, {Red, Five}}
|
||||
top, color := Card{Red, Nine}, Red
|
||||
@@ -781,7 +784,6 @@ func TestBotPicksItsBestColor(t *testing.T) {
|
||||
if got := botColor(hand, rng); got != Blue {
|
||||
t.Errorf("the bot holds two blues: it should call blue, got %v", got)
|
||||
}
|
||||
// A hand of nothing but wilds still has to name something.
|
||||
for i := 0; i < 20; i++ {
|
||||
if got := botColor([]Card{{Wild, WildCard}}, rng); !got.Playable() {
|
||||
t.Fatalf("botColor named %v, which is not a colour", got)
|
||||
|
||||
@@ -124,16 +124,21 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
|
||||
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
|
||||
|
||||
mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart)
|
||||
mux.HandleFunc("POST /api/games/uno/sit", s.handleUnoSit)
|
||||
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
|
||||
mux.HandleFunc("POST /api/games/uno/leave", s.handleUnoLeave)
|
||||
mux.HandleFunc("GET /api/games/uno/tables", s.handleUnoLobby)
|
||||
mux.HandleFunc("GET /api/games/uno/stream", s.handleTableStream)
|
||||
mux.HandleFunc("GET /api/games/uno/chat", s.handleTableChat)
|
||||
mux.HandleFunc("POST /api/games/uno/say", s.handleTableSay)
|
||||
|
||||
mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit)
|
||||
mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove)
|
||||
mux.HandleFunc("POST /api/games/holdem/leave", s.handleHoldemLeave)
|
||||
mux.HandleFunc("GET /api/games/holdem/tables", s.handleHoldemLobby)
|
||||
mux.HandleFunc("GET /api/games/holdem/stream", s.handleHoldemStream)
|
||||
mux.HandleFunc("GET /api/games/holdem/chat", s.handleHoldemChat)
|
||||
mux.HandleFunc("POST /api/games/holdem/say", s.handleHoldemSay)
|
||||
mux.HandleFunc("GET /api/games/holdem/stream", s.handleTableStream)
|
||||
mux.HandleFunc("GET /api/games/holdem/chat", s.handleTableChat)
|
||||
mux.HandleFunc("POST /api/games/holdem/say", s.handleTableSay)
|
||||
}
|
||||
|
||||
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
|
||||
|
||||
@@ -290,11 +290,33 @@ func (s *Server) table(user string) (tableView, error) {
|
||||
tv := viewTrivia(g, time.Now())
|
||||
v.Trivia = &tv
|
||||
case gameUno:
|
||||
// A seated UNO player's cards are in game_tables, not here — this row is only
|
||||
// their occupancy claim. Load the table and render it as their own seat sees it.
|
||||
if live.TableID == "" {
|
||||
return s.dropUnreadable(user, v, fmt.Errorf("uno row with no table"))
|
||||
}
|
||||
t, tableSeats, err := storage.LoadTable(live.TableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
return s.dropUnreadable(user, v, fmt.Errorf("uno table %s gone", live.TableID))
|
||||
}
|
||||
if err != nil {
|
||||
return tableView{}, err
|
||||
}
|
||||
_, seat, err := storage.PlayerSeat(user)
|
||||
if err != nil {
|
||||
return tableView{}, err
|
||||
}
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||
return s.dropUnreadable(user, v, err)
|
||||
}
|
||||
uv := viewUno(g)
|
||||
uv := viewUno(g, seat)
|
||||
for _, ts := range tableSeats {
|
||||
if ts.Seat == seat {
|
||||
uv.BoughtIn = ts.Staked
|
||||
break
|
||||
}
|
||||
}
|
||||
v.Uno = &uv
|
||||
case gameHoldem:
|
||||
// A seated hold'em player's cards are in game_tables, not here — this row is
|
||||
|
||||
@@ -19,17 +19,25 @@ import (
|
||||
|
||||
// ---- the lobby -------------------------------------------------------------
|
||||
|
||||
// handleHoldemLobby lists the hold'em tables with a seat going spare. A table
|
||||
// with every chair taken is not shown, because a lobby you cannot join from is
|
||||
// just a list; bots keep every open table populated, so there is always
|
||||
// something to sit down at.
|
||||
// handleHoldemLobby and handleUnoLobby list the tables of their game with a seat
|
||||
// going spare. A table with every chair taken is not shown, because a lobby you
|
||||
// cannot join from is just a list; bots keep every open table populated, so there
|
||||
// is always something to sit down at.
|
||||
func (s *Server) handleHoldemLobby(w http.ResponseWriter, r *http.Request) {
|
||||
s.lobby(w, r, gameHoldem)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnoLobby(w http.ResponseWriter, r *http.Request) {
|
||||
s.lobby(w, r, gameUno)
|
||||
}
|
||||
|
||||
func (s *Server) lobby(w http.ResponseWriter, r *http.Request, game string) {
|
||||
if _, ok := s.player(w, r); !ok {
|
||||
return
|
||||
}
|
||||
tables, err := storage.LobbyTables(gameHoldem, 50)
|
||||
tables, err := storage.LobbyTables(game, 50)
|
||||
if err != nil {
|
||||
slog.Error("games: holdem lobby", "err", err)
|
||||
slog.Error("games: lobby", "game", game, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -58,7 +66,7 @@ const streamPing = 25 * time.Second
|
||||
// table the player is at. After that it only ever reads its channel. Holding a
|
||||
// query open for the life of a stream would hold the single pooled connection for
|
||||
// the life of a stream, and one idle subscriber would take the whole site down.
|
||||
func (s *Server) handleHoldemStream(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleTableStream(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -117,7 +125,7 @@ func (s *Server) handleHoldemStream(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleHoldemChat reads the recent rail of a player's table, oldest first, with
|
||||
// their own lines flagged so the felt can lay them out on the right.
|
||||
func (s *Server) handleHoldemChat(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleTableChat(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -152,7 +160,7 @@ func (s *Server) handleHoldemChat(w http.ResponseWriter, r *http.Request) {
|
||||
// stamped with the hand it was said during — the one question chat at a money
|
||||
// table ever really raises — and pushed to every open stream so it lands on the
|
||||
// rail in real time.
|
||||
func (s *Server) handleHoldemSay(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleTableSay(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
|
||||
@@ -5,58 +5,59 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/blackjack"
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// UNO, played for chips against bots.
|
||||
// UNO, played for chips at a shared table.
|
||||
//
|
||||
// The seam is the same as every other table, but there is one thing here that no
|
||||
// other table has: opponents. The obvious way to give a browser opponents is a
|
||||
// socket, and the plan says solo UNO must not need one — so it doesn't. A move
|
||||
// goes up, and what comes back is the player's move *plus every bot turn it
|
||||
// handed off to*, as a script of events. One request, one round of the table.
|
||||
// Like hold'em, this is a session: you sit down with a stack, ante into a pot each
|
||||
// hand, and leave with what is in front of you. Chips cross the border twice —
|
||||
// sit-down and get-up — and every ante and pot in between moves inside the state
|
||||
// blob. Solo play is just a table nobody else has joined.
|
||||
//
|
||||
// What the browser is allowed to see: its own hand, the card in play, the colour
|
||||
// in play, and how many cards each bot is holding. Not the deck, not a bot's
|
||||
// hand, not even the face of a card a bot drew. That last one is most of the
|
||||
// deck, and it is the thing that would turn a game of counting cards into a game
|
||||
// of reading the network tab.
|
||||
// The seam is the same as hold'em: one request plays a human's move plus every
|
||||
// bot turn it hands off to, returned as a script the felt animates. What a viewer
|
||||
// is allowed to see is their own hand, the card and colour in play, the pot, and
|
||||
// how many cards each other seat holds — never the deck, another seat's hand, or
|
||||
// the face of a card a bot drew. The engine emits every seat's hand (a shared
|
||||
// stream cannot know who is watching); the redaction that keeps a hand private is
|
||||
// here, and a missed case fans it to every subscriber.
|
||||
|
||||
// unoCardView is one card, ready to draw. The browser gets the colour and the
|
||||
// face as words, not as the engine's integers — the same bargain the blackjack
|
||||
// table makes, and for the same reason: the browser draws faces, not logic.
|
||||
// unoCardView is one card, ready to draw: colour and face as words, not the
|
||||
// engine's integers.
|
||||
type unoCardView struct {
|
||||
Color string `json:"color"` // "red" | "blue" | "yellow" | "green" | "wild"
|
||||
Value string `json:"value"` // "0"…"9" | "skip" | "reverse" | "+2" | "wild" | "+4"
|
||||
Wild bool `json:"wild"` // it's a wild, whatever colour it was played as
|
||||
Color string `json:"color"`
|
||||
Value string `json:"value"`
|
||||
Wild bool `json:"wild"`
|
||||
}
|
||||
|
||||
func viewUnoCard(c uno.Card) unoCardView {
|
||||
return unoCardView{
|
||||
Color: c.Color.String(),
|
||||
Value: c.Value.String(),
|
||||
Wild: c.IsWild(),
|
||||
}
|
||||
return unoCardView{Color: c.Color.String(), Value: c.Value.String(), Wild: c.IsWild()}
|
||||
}
|
||||
|
||||
// unoSeatView is one seat at the table: a name, and a number of cards. A bot's
|
||||
// cards are a *count*. There is no field here for what they are.
|
||||
// unoSeatView is one seat at the table: a name, a card count, and a stack. A
|
||||
// seat's cards are a *count* — there is no field here for what they are.
|
||||
type unoSeatView struct {
|
||||
Name string `json:"name"`
|
||||
Cards int `json:"cards"`
|
||||
Bot bool `json:"bot"`
|
||||
You bool `json:"you"`
|
||||
Cards int `json:"cards"`
|
||||
Stack int64 `json:"stack"`
|
||||
Ante int64 `json:"ante,omitempty"`
|
||||
Uno bool `json:"uno"` // down to one card
|
||||
Called bool `json:"called"` // …and said so. A seat where Uno is true and this isn't is a seat you can catch
|
||||
Out bool `json:"out"` // No Mercy: buried at twenty-five, and not coming back
|
||||
Called bool `json:"called"` // …and said so. Uno true and this false is a seat you can catch
|
||||
Out bool `json:"out"` // not in this hand — mercy-killed, or sitting one out
|
||||
}
|
||||
|
||||
// unoView is a game as its player may see it.
|
||||
// unoView is a table as one seat may see it.
|
||||
type unoView struct {
|
||||
Tier uno.Tier `json:"tier"`
|
||||
Seats []unoSeatView `json:"seats"`
|
||||
Tier uno.Tier `json:"tier"`
|
||||
YourSeat int `json:"your_seat"`
|
||||
Seats []unoSeatView `json:"seats"`
|
||||
|
||||
Hand []unoCardView `json:"hand"` // yours, and only yours
|
||||
Playable []int `json:"playable"` // which of them can legally go down
|
||||
@@ -64,138 +65,138 @@ type unoView struct {
|
||||
Color string `json:"color"` // the colour in play, which a wild renames
|
||||
Deck int `json:"deck"` // cards left to draw
|
||||
|
||||
// The UNO call. UnoAt is which of your cards would leave you holding exactly
|
||||
// one if you played it — the table asks for the call on those, and it asks the
|
||||
// engine which they are because No Mercy's "discard all" makes counting the
|
||||
// hand the wrong answer. Catchable is which seats are sitting on one card they
|
||||
// never announced.
|
||||
UnoAt []int `json:"uno_at"`
|
||||
Catchable []int `json:"catchable"`
|
||||
UnoAt []int `json:"uno_at"` // your cards that would leave you on one
|
||||
Catchable []int `json:"catchable"` // seats sitting on one card they never called
|
||||
|
||||
Turn int `json:"turn"`
|
||||
Dir int `json:"dir"`
|
||||
Turn int `json:"turn"`
|
||||
Dir int `json:"dir"`
|
||||
Dealer int `json:"dealer"`
|
||||
HandNo int `json:"hand_no"`
|
||||
|
||||
// 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"`
|
||||
Pending int `json:"pending"` // No Mercy: the bill a stack has run up
|
||||
|
||||
Bet int64 `json:"bet"`
|
||||
Pays int64 `json:"pays"` // what going out right now would actually pay
|
||||
Phase string `json:"phase"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Pot int64 `json:"pot"` // the antes riding on this hand
|
||||
Ante int64 `json:"ante"` // what each seat puts in
|
||||
Stack int64 `json:"stack"` // what's in front of you
|
||||
BoughtIn int64 `json:"bought_in"` // your own buy-in, from the border ledger
|
||||
Phase string `json:"phase"`
|
||||
|
||||
// The last hand's verdict, so the felt can land it between hands.
|
||||
Winner int `json:"winner"`
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
LastPot int64 `json:"last_pot,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
}
|
||||
|
||||
func viewUno(g uno.State) unoView {
|
||||
// viewUno renders the table as one seat may see it. viewer is which seat is
|
||||
// looking — their hand is the only one it will ever put in the payload.
|
||||
//
|
||||
// This is the security boundary. The same view fans to every subscriber's stream,
|
||||
// so a seat that renders anyone else's cards fans them to the whole table.
|
||||
// TestUnoViewNeverLeaksAnotherSeatsCards renders every seat's view and greps for
|
||||
// cards that are not theirs.
|
||||
func viewUno(g uno.State, viewer int) unoView {
|
||||
v := unoView{
|
||||
Tier: g.Tier,
|
||||
Top: viewUnoCard(g.Top()),
|
||||
Color: g.Color.String(),
|
||||
Deck: g.Left(),
|
||||
Turn: g.Turn,
|
||||
Dir: g.Dir,
|
||||
Pending: g.Pending,
|
||||
Bet: g.Bet,
|
||||
Pays: g.Pays(),
|
||||
Phase: string(g.Phase),
|
||||
Outcome: string(g.Outcome),
|
||||
Winner: -1,
|
||||
Payout: g.Payout,
|
||||
Rake: g.Rake,
|
||||
Net: g.Net(),
|
||||
Tier: g.Tier,
|
||||
YourSeat: viewer,
|
||||
Top: viewUnoCard(g.Top()),
|
||||
Color: g.Color.String(),
|
||||
Deck: g.Left(),
|
||||
Turn: g.Turn,
|
||||
Dir: g.Dir,
|
||||
Dealer: g.Dealer,
|
||||
HandNo: g.HandNo,
|
||||
Pending: g.Pending,
|
||||
Pot: g.Pot,
|
||||
Ante: g.Tier.Ante,
|
||||
BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake
|
||||
Phase: string(g.Phase),
|
||||
Winner: g.Winner,
|
||||
LastPot: g.LastPot,
|
||||
Rake: g.Rake,
|
||||
Outcome: string(g.Outcome),
|
||||
}
|
||||
// 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() {
|
||||
if viewer >= 0 && viewer < len(g.Seats) {
|
||||
v.Stack = g.Seats[viewer].Stack
|
||||
}
|
||||
counts := g.Counts()
|
||||
for i := range g.Seats {
|
||||
p := g.Seats[i]
|
||||
live := g.Live(i)
|
||||
seat := unoSeatView{
|
||||
Cards: n, You: i == uno.You, Uno: live && n == 1, Out: !live,
|
||||
Name: p.Name,
|
||||
Bot: p.Bot,
|
||||
You: i == viewer,
|
||||
Cards: counts[i],
|
||||
Stack: p.Stack,
|
||||
Ante: p.Ante,
|
||||
Uno: live && counts[i] == 1,
|
||||
Called: i < len(g.Called) && g.Called[i],
|
||||
}
|
||||
if i == uno.You {
|
||||
seat.Name = "You"
|
||||
} else if i-1 < len(g.Bots) {
|
||||
seat.Name = g.Bots[i-1]
|
||||
Out: !live,
|
||||
}
|
||||
v.Seats = append(v.Seats, seat)
|
||||
if live && n == 0 {
|
||||
v.Winner = i
|
||||
}
|
||||
|
||||
// The wall. Only the viewer's own hand crosses the wire.
|
||||
if viewer >= 0 && viewer < len(g.Hands) {
|
||||
for _, c := range g.Hands[viewer] {
|
||||
v.Hand = append(v.Hand, viewUnoCard(c))
|
||||
}
|
||||
}
|
||||
// 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
|
||||
if v.Hand == nil {
|
||||
v.Hand = []unoCardView{}
|
||||
}
|
||||
for _, c := range g.Hands[uno.You] {
|
||||
v.Hand = append(v.Hand, viewUnoCard(c))
|
||||
}
|
||||
v.Playable = g.Playable()
|
||||
// Empty arrays, never null: the felt indexes into these.
|
||||
v.Playable = g.Playable(viewer)
|
||||
if v.Playable == nil {
|
||||
v.Playable = []int{}
|
||||
}
|
||||
// Empty arrays, never null: the table indexes into these, and `null.indexOf`
|
||||
// is a broken game rather than a quiet no-op.
|
||||
v.UnoAt = g.UnoAt()
|
||||
v.UnoAt = g.UnoAt(viewer)
|
||||
if v.UnoAt == nil {
|
||||
v.UnoAt = []int{}
|
||||
}
|
||||
v.Catchable = g.Catchable()
|
||||
v.Catchable = g.Catchable(viewer)
|
||||
if v.Catchable == nil {
|
||||
v.Catchable = []int{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// unoEventView is one beat of the script the table plays back: a card going
|
||||
// down, a seat eating a +4, the turn coming round. The engine's own events carry
|
||||
// engine types, so they are re-rendered here rather than shipped raw — and this
|
||||
// is also the wall where a bot's drawn card is dropped on the floor.
|
||||
// unoEventView is one beat of the script the felt plays back. The engine attaches
|
||||
// every seat's hand and drawn cards; this is the wall where the ones the viewer
|
||||
// isn't entitled to are stripped.
|
||||
type unoEventView struct {
|
||||
Kind string `json:"kind"`
|
||||
Seat int `json:"seat"`
|
||||
Card *unoCardView `json:"card,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
Left int `json:"left"` // never omitempty: a seat that goes out leaves zero, and zero is the number the table has to draw
|
||||
By int `json:"by"` // who did the catching. Seat zero is you, and "you" is a real answer, so never omitempty either
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
// Hand is your hand as it stands after this event, on the events that changed
|
||||
// it. The table redraws your fan from this as the lap plays back — see the
|
||||
// engine's Event.Hand for why. It is your own hand, so there is nothing here
|
||||
// the browser wasn't already holding.
|
||||
Hand []unoCardView `json:"hand,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Seat int `json:"seat"`
|
||||
Card *unoCardView `json:"card,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
Left int `json:"left"`
|
||||
By int `json:"by"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Hand []unoCardView `json:"hand,omitempty"`
|
||||
}
|
||||
|
||||
func viewUnoEvents(evs []uno.Event) []unoEventView {
|
||||
func viewUnoEvents(evs []uno.Event, viewer int) []unoEventView {
|
||||
out := make([]unoEventView, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
v := unoEventView{Kind: e.Kind, Seat: e.Seat, N: e.N, Left: e.Left, By: e.By, Text: e.Text}
|
||||
if e.Color != uno.Wild {
|
||||
v.Color = e.Color.String()
|
||||
}
|
||||
// The engine only stamps a hand on an event about seat zero. This is the belt
|
||||
// to that brace: whatever the engine thinks it's doing, no hand but yours
|
||||
// leaves this building.
|
||||
if e.Seat == uno.You && e.Hand != nil {
|
||||
// A hand rides an event only if it is the viewer's own. The engine stamps every
|
||||
// seat's hand; this strips the rest.
|
||||
if e.Seat == viewer && e.Hand != nil {
|
||||
v.Hand = make([]unoCardView, 0, len(e.Hand))
|
||||
for _, c := range e.Hand {
|
||||
v.Hand = append(v.Hand, viewUnoCard(c))
|
||||
}
|
||||
}
|
||||
// A card rides an event only if it is a card played face up, or one the viewer
|
||||
// drew. A bot's (or another human's) drawn card never carries a face.
|
||||
if e.Card != nil {
|
||||
// The engine only ever attaches a card to an event the seat is entitled
|
||||
// to see it in — a card played face up, or one *you* drew. This check is
|
||||
// the belt to that pair of braces: a bot's draw never carries a face,
|
||||
// whatever the engine thinks it's doing.
|
||||
if e.Kind == uno.EvDraw || e.Kind == uno.EvForced {
|
||||
if e.Seat == uno.You {
|
||||
if e.Seat == viewer {
|
||||
c := viewUnoCard(*e.Card)
|
||||
v.Card = &c
|
||||
}
|
||||
@@ -209,52 +210,227 @@ func viewUnoEvents(evs []uno.Event) []unoEventView {
|
||||
return out
|
||||
}
|
||||
|
||||
// handleUnoStart takes the bet and deals. Same order as every other table: the
|
||||
// chips are staked first, in the same statement that checks they exist, so two
|
||||
// deals fired at once cannot bet the same chip.
|
||||
func (s *Server) handleUnoStart(w http.ResponseWriter, r *http.Request) {
|
||||
// ---- sitting down ----------------------------------------------------------
|
||||
|
||||
// unoSeatRows mirrors the engine's seats into the storage rows that shadow them,
|
||||
// index for index. A human's staked is the buy-in that crossed the border; a
|
||||
// bot's is zero.
|
||||
func unoSeatRows(g uno.State, human string, buyIn int64) []storage.Seat {
|
||||
rows := make([]storage.Seat, len(g.Seats))
|
||||
for i := range g.Seats {
|
||||
p := g.Seats[i]
|
||||
row := storage.Seat{Seat: i, Name: p.Name}
|
||||
if !p.Bot {
|
||||
row.MatrixUser = human
|
||||
row.Staked = buyIn
|
||||
}
|
||||
rows[i] = row
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// handleUnoSit seats a player at a fresh table of their own or at an open chair on
|
||||
// somebody else's.
|
||||
func (s *Server) handleUnoSit(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Bet int64 `json:"bet"`
|
||||
Tier string `json:"tier"`
|
||||
Tier string `json:"tier"`
|
||||
BuyIn int64 `json:"buyin"`
|
||||
Table string `json:"table"`
|
||||
Seat *int `json:"seat"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Bet <= 0 {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "bet something"})
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tier, err := uno.TierBySlug(req.Tier)
|
||||
if req.Table != "" {
|
||||
s.joinUno(w, r, user, req.Table, req.Seat, req.BuyIn)
|
||||
return
|
||||
}
|
||||
s.openUno(w, r, user, req.Tier, req.BuyIn)
|
||||
}
|
||||
|
||||
// openUno opens a fresh table with the player in seat zero and bots in the rest —
|
||||
// the old solo flow, now a real shared table with no other humans on it yet.
|
||||
func (s *Server) openUno(w http.ResponseWriter, r *http.Request, user, tierSlug string, buyIn int64) {
|
||||
tier, err := uno.TierBySlug(tierSlug)
|
||||
if err != nil {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := storage.Stake(user, req.Bet); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that bet"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: uno stake", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
if buyIn < tier.MinBuy || buyIn > tier.MaxBuy {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"})
|
||||
return
|
||||
}
|
||||
|
||||
name := s.displayName(r, user)
|
||||
seed1, seed2 := newSeeds()
|
||||
g, evs, err := uno.New(req.Bet, tier, blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||
g, _, err := uno.New(tier, uno.TableSeats(tier, name, tier.Bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||
if err != nil {
|
||||
// The game never happened, so the stake never should have left.
|
||||
_ = storage.Award(user, req.Bet)
|
||||
slog.Error("games: uno deal", "user", user, "err", err)
|
||||
slog.Error("games: uno open", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.persistUno(w, user, g, evs, seed1, seed2, true)
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal new uno", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
id, err := storage.NewTableID()
|
||||
if err != nil {
|
||||
slog.Error("games: mint table id", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
t := storage.Table{
|
||||
ID: id, Game: gameUno, Tier: tier.Slug, State: blob,
|
||||
Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo),
|
||||
}
|
||||
err = storage.OpenSoloTable(t, unoSeatRows(g, user, buyIn), buyIn)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
|
||||
return
|
||||
case errors.Is(err, storage.ErrHandInProgress):
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||
return
|
||||
case err != nil:
|
||||
slog.Error("games: open solo uno table", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.writeUnoTable(w, user, nil)
|
||||
}
|
||||
|
||||
// handleUnoMove plays one turn: a card, a draw, or passing on the card you drew.
|
||||
// The bots' turns come back with it.
|
||||
// pickOpenUnoSeat chooses a chair to join: the one asked for if it is a bot's,
|
||||
// otherwise the first bot seat. Returns -1 if there is nowhere to sit.
|
||||
func pickOpenUnoSeat(g uno.State, want *int) int {
|
||||
if want != nil {
|
||||
i := *want
|
||||
if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot {
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
for i := range g.Seats {
|
||||
if g.Seats[i].Bot {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// joinUno sits a player at an open chair on an existing table, one transaction
|
||||
// under the table lock, so two people racing for the last seat cannot both win it.
|
||||
func (s *Server) joinUno(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) {
|
||||
name := s.displayName(r, user)
|
||||
var respErr error
|
||||
err := s.tableLocks.withTable(tableID, func() error {
|
||||
t, _, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
respErr = storage.ErrNoSuchTable
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Game != gameUno {
|
||||
respErr = uno.ErrUnknownMove
|
||||
return nil
|
||||
}
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
if unoPlaying(g) {
|
||||
respErr = uno.ErrHandLive // you join between hands
|
||||
return nil
|
||||
}
|
||||
seat := pickOpenUnoSeat(g, wantSeat)
|
||||
if seat < 0 {
|
||||
respErr = uno.ErrTableFull
|
||||
return nil
|
||||
}
|
||||
if err := g.Occupy(seat, name, buyIn); err != nil {
|
||||
respErr = err
|
||||
return nil
|
||||
}
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo)
|
||||
err = storage.SitDown(storage.Sit{
|
||||
Table: t,
|
||||
Seat: storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn},
|
||||
BuyIn: buyIn,
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount):
|
||||
respErr = storage.ErrInsufficientChips
|
||||
return nil
|
||||
case errors.Is(err, storage.ErrHandInProgress):
|
||||
respErr = storage.ErrHandInProgress
|
||||
return nil
|
||||
case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable):
|
||||
respErr = storage.ErrSeatTaken
|
||||
return nil
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: join uno", "user", user, "table", tableID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if respErr != nil {
|
||||
writeJSONStatus(w, unoJoinStatus(respErr), map[string]string{"error": unoJoinMessage(respErr)})
|
||||
return
|
||||
}
|
||||
s.writeUnoTable(w, user, nil)
|
||||
}
|
||||
|
||||
func unoJoinStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, uno.ErrTableFull),
|
||||
errors.Is(err, storage.ErrSeatTaken), errors.Is(err, uno.ErrHandLive):
|
||||
return http.StatusConflict
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
func unoJoinMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNoSuchTable):
|
||||
return "that table has closed"
|
||||
case errors.Is(err, uno.ErrTableFull), errors.Is(err, storage.ErrSeatTaken):
|
||||
return "that seat is taken"
|
||||
case errors.Is(err, uno.ErrHandLive):
|
||||
return "a hand is in play — sit down when it's over"
|
||||
case errors.Is(err, uno.ErrBadBuyIn):
|
||||
return "that isn't a legal buy-in for this table"
|
||||
case errors.Is(err, storage.ErrInsufficientChips):
|
||||
return "not enough chips to sit down"
|
||||
case errors.Is(err, storage.ErrHandInProgress):
|
||||
return "finish the game you're in first"
|
||||
default:
|
||||
return "you can't sit there"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- playing a hand --------------------------------------------------------
|
||||
|
||||
// handleUnoMove plays one move at the player's table: a hand move, or dealing the
|
||||
// next hand. Leaving is its own endpoint.
|
||||
func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
@@ -265,80 +441,237 @@ func (s *Server) handleUnoMove(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if move.Kind == uno.MoveLeave {
|
||||
s.leaveUno(w, user)
|
||||
return
|
||||
}
|
||||
|
||||
live, err := storage.LoadLiveHand(user)
|
||||
tableID, seat, err := storage.PlayerSeat(user)
|
||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "no game in progress"})
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: uno load", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if live.Game != gameUno {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"})
|
||||
return
|
||||
}
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
slog.Error("games: unreadable uno game", "user", user, "err", err)
|
||||
slog.Error("games: uno move seat", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
next, evs, err := uno.ApplyMove(g, move)
|
||||
if err != nil {
|
||||
// The refusals a player can actually cause, said in words rather than as
|
||||
// "that move isn't legal here" — which, in a game with this many rules, is
|
||||
// the table refusing to explain itself.
|
||||
msg := "that move isn't legal here"
|
||||
switch {
|
||||
case errors.Is(err, uno.ErrCantPlay):
|
||||
msg = "that card doesn't go on this one"
|
||||
case errors.Is(err, uno.ErrNeedColor):
|
||||
msg = "pick a colour for the wild"
|
||||
case errors.Is(err, uno.ErrMustPlayNow):
|
||||
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"
|
||||
case errors.Is(err, uno.ErrNoCatch):
|
||||
msg = "there's nobody in that seat to catch"
|
||||
var respErr error
|
||||
var respEvents []uno.Event
|
||||
err = s.tableLocks.withTable(tableID, func() error {
|
||||
t, seats, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
respErr = storage.ErrNoSuchTable
|
||||
return nil
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
next, evs, aerr := uno.ApplyMove(g, seat, move)
|
||||
if aerr != nil {
|
||||
respErr = aerr
|
||||
return nil
|
||||
}
|
||||
|
||||
// A solo session that just ended (the one human got up or busted at the deal)
|
||||
// is not a table any more: cash the seat out and close the felt. Only a solo
|
||||
// table reaches PhaseDone; a shared table plays on.
|
||||
if next.Phase == uno.PhaseDone {
|
||||
if err := s.settleUnoLeave(t, next, seat, user); err != nil {
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
respErr = storage.ErrStaleTable
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
respEvents = evs
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
}
|
||||
|
||||
st, err := unoStep(next, evs, seats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acting := seats[seat]
|
||||
acting.Away = false
|
||||
acting.LastSeen = time.Now().Unix()
|
||||
|
||||
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
|
||||
if err := storage.CommitTable(storage.TableCommit{
|
||||
Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit,
|
||||
}); err != nil {
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
respErr = storage.ErrStaleTable
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
respEvents = evs
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: uno move", "user", user, "table", tableID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.persistUno(w, user, next, evs, live.Seed1, live.Seed2, false)
|
||||
if respErr != nil {
|
||||
writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
|
||||
return
|
||||
}
|
||||
s.writeUnoTable(w, user, respEvents)
|
||||
}
|
||||
|
||||
// persistUno writes the game back and answers the browser.
|
||||
func (s *Server) persistUno(w http.ResponseWriter, user string, g uno.State, evs []uno.Event, seed1, seed2 uint64, fresh bool) {
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
slog.Error("games: marshal uno", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
func unoMoveStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
|
||||
return http.StatusConflict
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
done := g.Phase == uno.PhaseDone
|
||||
v, ok := s.commit(w, user, finished{
|
||||
Game: gameUno, Blob: blob,
|
||||
Bet: g.Bet, Payout: g.Payout, Rake: g.Rake,
|
||||
Outcome: string(g.Outcome), Done: done,
|
||||
Seed1: seed1, Seed2: seed2, Fresh: fresh,
|
||||
})
|
||||
}
|
||||
|
||||
func unoMoveMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrStaleTable):
|
||||
return "the table moved on — take another look"
|
||||
case errors.Is(err, storage.ErrNoSuchTable):
|
||||
return "that table has closed"
|
||||
case errors.Is(err, uno.ErrHandLive):
|
||||
return "finish the hand first"
|
||||
case errors.Is(err, uno.ErrNoHand):
|
||||
return "there's no hand in play — deal one"
|
||||
case errors.Is(err, uno.ErrNotYourTurn):
|
||||
return "it isn't your turn"
|
||||
case errors.Is(err, uno.ErrCantPlay):
|
||||
return "that card doesn't go on this one"
|
||||
case errors.Is(err, uno.ErrNeedColor):
|
||||
return "pick a colour for the wild"
|
||||
case errors.Is(err, uno.ErrMustPlayNow):
|
||||
return "play the card you drew, or pass"
|
||||
case errors.Is(err, uno.ErrCantPass):
|
||||
return "draw first, then you can pass"
|
||||
case errors.Is(err, uno.ErrMustStack):
|
||||
return "answer the stack with a draw card, or take it"
|
||||
case errors.Is(err, uno.ErrNoStack):
|
||||
return "there's nothing pointed at you to take"
|
||||
case errors.Is(err, uno.ErrNoCatch):
|
||||
return "there's nobody in that seat to catch"
|
||||
default:
|
||||
return "that move isn't legal here"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getting up ------------------------------------------------------------
|
||||
|
||||
// handleUnoLeave is the get-up endpoint. Leaving is its own route because it is a
|
||||
// storage operation, not an engine move — the chips cross the border and the felt
|
||||
// may close.
|
||||
func (s *Server) handleUnoLeave(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.player(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// A finished game is gone from storage, so the table has none to show — but the
|
||||
// browser still needs the final board to land the verdict on.
|
||||
if done {
|
||||
uv := viewUno(g)
|
||||
v.Uno = &uv
|
||||
s.leaveUno(w, user)
|
||||
}
|
||||
|
||||
// leaveUno gets a player up from their table, turning what is in front of them
|
||||
// back into chips. It refuses mid-hand and closes the felt behind the last human.
|
||||
func (s *Server) leaveUno(w http.ResponseWriter, user string) {
|
||||
tableID, seat, err := storage.PlayerSeat(user)
|
||||
if errors.Is(err, storage.ErrNoLiveHand) {
|
||||
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("games: uno leave seat", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var respErr error
|
||||
err = s.tableLocks.withTable(tableID, func() error {
|
||||
t, _, err := storage.LoadTable(tableID)
|
||||
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||
respErr = storage.ErrNoSuchTable
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(t.State, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
if unoPlaying(g) {
|
||||
respErr = uno.ErrHandLive
|
||||
return nil
|
||||
}
|
||||
if err := s.settleUnoLeave(t, g, seat, user); err != nil {
|
||||
if errors.Is(err, storage.ErrStaleTable) {
|
||||
respErr = storage.ErrStaleTable
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.publishTable(tableID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("games: uno leave", "user", user, "table", tableID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if respErr != nil {
|
||||
writeJSONStatus(w, unoMoveStatus(respErr), map[string]string{"error": unoMoveMessage(respErr)})
|
||||
return
|
||||
}
|
||||
s.writeUnoTable(w, user, nil)
|
||||
}
|
||||
|
||||
// settleUnoLeave vacates a seat, credits the stack home, and closes the table if
|
||||
// nobody human is left — all inside the caller's lock.
|
||||
func (s *Server) settleUnoLeave(t storage.Table, g uno.State, seat int, user string) error {
|
||||
home, err := g.Vacate(seat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blob, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0
|
||||
if err := storage.LeaveTable(storage.Leave{
|
||||
Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.CloseTable(t.ID)
|
||||
}
|
||||
|
||||
// ---- the response ----------------------------------------------------------
|
||||
|
||||
// writeUnoTable answers with the whole page state — the money and the table as the
|
||||
// player's own seat may see it — plus, when a move produced one, the redacted event
|
||||
// script for that seat to animate.
|
||||
func (s *Server) writeUnoTable(w http.ResponseWriter, user string, evs []uno.Event) {
|
||||
v, err := s.table(user)
|
||||
if err != nil {
|
||||
slog.Error("games: uno table", "user", user, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(evs) > 0 {
|
||||
if _, seat, serr := storage.PlayerSeat(user); serr == nil {
|
||||
v.UnoEvents = viewUnoEvents(evs, seat)
|
||||
}
|
||||
}
|
||||
v.UnoEvents = viewUnoEvents(evs)
|
||||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
187
internal/web/games_uno_table.go
Normal file
187
internal/web/games_uno_table.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// UNO as a shared table: the seam the runtime drives it through, and the two
|
||||
// facts about a hand only the engine can tell the runtime — when the clock must
|
||||
// next act, and what a finished hand owes the audit trail.
|
||||
//
|
||||
// Everything about *playing* UNO is still in the engine. This file is the
|
||||
// translation layer between a uno.State and the game-agnostic table runtime.
|
||||
|
||||
// unoTable is the tableGame for UNO. It holds no state — the table's state is the
|
||||
// blob in game_tables — so a single value serves every felt in the room.
|
||||
type unoTable struct{}
|
||||
|
||||
func (unoTable) name() string { return gameUno }
|
||||
|
||||
// timeout plays a passive move for the human whose clock ran out and marks them
|
||||
// away, so the runtime auto-acts them on sight after this rather than waiting a
|
||||
// full clock every orbit. The move is the gentlest one that still advances the
|
||||
// turn: give in to a stack, pass a drawn card (or play it where the rules force
|
||||
// it), otherwise play a legal card if there is one, or draw.
|
||||
//
|
||||
// If, on decode, the seat to act is not a waiting human, the scan raced a real
|
||||
// move that had not yet bumped the version: errNotDue, and the clock steps aside.
|
||||
func (unoTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(state, &g); err != nil {
|
||||
return step{}, nil, err
|
||||
}
|
||||
if !unoPlaying(g) {
|
||||
return step{}, seats, errNotDue
|
||||
}
|
||||
seat := g.Turn
|
||||
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
|
||||
return step{}, seats, errNotDue
|
||||
}
|
||||
|
||||
move := unoIdleMove(g, seat)
|
||||
next, evs, err := uno.ApplyMove(g, seat, move)
|
||||
if err != nil {
|
||||
return step{}, nil, err
|
||||
}
|
||||
|
||||
changed := markAway(seats, seat)
|
||||
st, err := unoStep(next, evs, seats)
|
||||
return st, changed, err
|
||||
}
|
||||
|
||||
// unoIdleMove is the move the clock makes for a walked-away seat: the most passive
|
||||
// legal one that still hands the turn on.
|
||||
func unoIdleMove(g uno.State, seat int) uno.Move {
|
||||
switch g.Phase {
|
||||
case uno.PhaseStack:
|
||||
return uno.Move{Kind: uno.MoveTake}
|
||||
case uno.PhaseDrawn:
|
||||
if g.Tier.NoMercy {
|
||||
// No Mercy makes you play the card you drew; it is the last in the hand.
|
||||
return uno.Move{Kind: uno.MovePlay, Index: len(g.Hands[seat]) - 1, Color: uno.Red}
|
||||
}
|
||||
return uno.Move{Kind: uno.MovePass}
|
||||
default:
|
||||
if p := g.Playable(seat); len(p) > 0 {
|
||||
return uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red}
|
||||
}
|
||||
return uno.Move{Kind: uno.MoveDraw}
|
||||
}
|
||||
}
|
||||
|
||||
// stacks reports the chips in front of each seat, index-aligned with the table's
|
||||
// seat rows, so the abandoned-table reaper can cash out a walked-away human.
|
||||
func (unoTable) stacks(state []byte) ([]int64, error) {
|
||||
var g uno.State
|
||||
if err := json.Unmarshal(state, &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]int64, len(g.Seats))
|
||||
for i := range g.Seats {
|
||||
out[i] = g.Seats[i].Stack
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// unoStep packages a played-out state for the runtime: the blob to persist, the
|
||||
// deadline the clock must honour next, and the audit of any hand that just ended.
|
||||
func unoStep(next uno.State, evs []uno.Event, seats []storage.Seat) (step, error) {
|
||||
blob, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
return step{}, err
|
||||
}
|
||||
st := step{
|
||||
State: blob,
|
||||
Phase: string(next.Phase),
|
||||
HandNo: int64(next.HandNo),
|
||||
Deadline: unoDeadline(next, seats),
|
||||
Audit: unoAudit(next, evs, seats),
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// unoPlaying reports whether a hand is in progress — the only phase a turn clock
|
||||
// has anything to do in.
|
||||
func unoPlaying(g uno.State) bool {
|
||||
switch g.Phase {
|
||||
case uno.PhasePlay, uno.PhaseDrawn, uno.PhaseStack:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// unoDeadline is when the clock must next act, or 0 for never. A clock is only set
|
||||
// on a *present* human whose turn it is: a bot resolves inside the move, an away
|
||||
// human is auto-acted on sight, and between hands there is no per-seat clock —
|
||||
// dealing the next hand is a seated player's call, and an abandoned table is the
|
||||
// reaper's job.
|
||||
func unoDeadline(g uno.State, seats []storage.Seat) int64 {
|
||||
if !unoPlaying(g) {
|
||||
return 0
|
||||
}
|
||||
seat := g.Turn
|
||||
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
|
||||
return 0
|
||||
}
|
||||
if seatAway(seats, seat) {
|
||||
return 0
|
||||
}
|
||||
return time.Now().Unix() + turnSeconds
|
||||
}
|
||||
|
||||
// unoAudit is the per-hand record of a finished hand — one row per human who was
|
||||
// in it. Empty until a hand actually ends, which is exactly when a settle beat is
|
||||
// emitted.
|
||||
//
|
||||
// The rake rides on the winner's row alone, and every other seat carries zero, so
|
||||
// game_hands.rake (which HouseTake sums) records a pot's rake once and once only.
|
||||
// A seat's ante is its bet; a refunded tie pays each seat its ante straight back.
|
||||
func unoAudit(g uno.State, evs []uno.Event, seats []storage.Seat) []storage.Hand {
|
||||
if !unoHandEnded(evs) {
|
||||
return nil
|
||||
}
|
||||
var audit []storage.Hand
|
||||
for i := range g.Seats {
|
||||
p := g.Seats[i]
|
||||
if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" {
|
||||
continue
|
||||
}
|
||||
if p.Ante == 0 {
|
||||
continue // sat this hand out — nothing to record
|
||||
}
|
||||
outcome, payout, rake := "lost", int64(0), int64(0)
|
||||
switch {
|
||||
case i == g.Winner:
|
||||
outcome, payout, rake = "won", p.Won, g.Rake
|
||||
case g.Outcome == uno.OutcomeTie:
|
||||
outcome, payout = "push", p.Ante // the ante came straight back
|
||||
}
|
||||
audit = append(audit, storage.Hand{
|
||||
MatrixUser: seats[i].MatrixUser,
|
||||
Game: gameUno,
|
||||
Bet: p.Ante,
|
||||
Payout: payout,
|
||||
Rake: rake,
|
||||
Outcome: outcome,
|
||||
Seed1: g.Seed1,
|
||||
Seed2: g.Seed2,
|
||||
})
|
||||
}
|
||||
return audit
|
||||
}
|
||||
|
||||
// unoHandEnded reports whether a hand finished in this batch of events. A settle
|
||||
// beat is emitted exactly once, when a hand ends, so it is the clean signal that
|
||||
// the seats' Ante/Won are this hand's final numbers.
|
||||
func unoHandEnded(evs []uno.Event) bool {
|
||||
for _, e := range evs {
|
||||
if e.Kind == uno.EvSettle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -4,57 +4,54 @@ import (
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/uno"
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The one thing this table cannot get wrong: the stake leaves the stack, and no
|
||||
// card a player is not entitled to see leaves the server. At UNO that is not one
|
||||
// hole card — it is the deck and every bot's hand, which is most of the game.
|
||||
func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
|
||||
// Sitting down is the only time chips leave your stack at this table, and getting
|
||||
// up is the only time they come back. The ante and the pot move inside the engine.
|
||||
func TestUnoSitTakesTheBuyIn(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
fund(t, 5000)
|
||||
|
||||
v, code := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
||||
map[string]any{"bet": 100, "tier": "full"}))
|
||||
v, code := call(t, s, s.handleUnoSit, as(t, s, "reala", "POST", "/api/games/uno/sit",
|
||||
map[string]any{"tier": "full", "buyin": 1000}))
|
||||
if code != 200 {
|
||||
t.Fatalf("start = %d, want 200", code)
|
||||
t.Fatalf("sit = %d, want 200", code)
|
||||
}
|
||||
if v.Chips != 900 {
|
||||
t.Fatalf("chips after a 100 bet = %d, want 900", v.Chips)
|
||||
if v.Chips != 4000 {
|
||||
t.Fatalf("chips after a 1000 buy-in = %d, want 4000", v.Chips)
|
||||
}
|
||||
if v.Game != gameUno || v.Uno == nil {
|
||||
t.Fatalf("start returned no uno game: game=%q", v.Game)
|
||||
t.Fatalf("sit returned no table: game=%q", v.Game)
|
||||
}
|
||||
|
||||
g := v.Uno
|
||||
if len(g.Hand) != uno.HandSize {
|
||||
t.Errorf("you were dealt %d cards, want %d", len(g.Hand), uno.HandSize)
|
||||
if g.Stack != 1000 {
|
||||
t.Errorf("you sat down with %d, want the 1000 you bought in for", g.Stack)
|
||||
}
|
||||
if len(g.Seats) != 4 {
|
||||
t.Fatalf("a full house is four seats, got %d", len(g.Seats))
|
||||
t.Fatalf("three bots and you is four seats, got %d", len(g.Seats))
|
||||
}
|
||||
// A bot is a name and a number. There is no field here that could carry a
|
||||
// card, which is the point — this is a compile-time guarantee, and the test
|
||||
// exists to make deleting it loud.
|
||||
for i, seat := range g.Seats {
|
||||
if i == 0 {
|
||||
if !seat.You || seat.Name != "You" {
|
||||
t.Errorf("seat 0 should be you: %+v", seat)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if seat.You || seat.Name == "" {
|
||||
t.Errorf("seat %d should be a named bot: %+v", i, seat)
|
||||
}
|
||||
if seat.Cards != uno.HandSize {
|
||||
t.Errorf("seat %d holds %d cards, want %d", i, seat.Cards, uno.HandSize)
|
||||
}
|
||||
if g.Phase != "handover" {
|
||||
t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase)
|
||||
}
|
||||
if g.Top.Value == "" {
|
||||
t.Error("no card in play")
|
||||
|
||||
// Deal a hand: chips do not move (the ante is within the blob), and you are dealt
|
||||
// seven cards and to act.
|
||||
deal, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "deal"}))
|
||||
if code != 200 {
|
||||
t.Fatalf("deal = %d, want 200", code)
|
||||
}
|
||||
if g.Turn != 0 {
|
||||
t.Errorf("you play first, turn is %d", g.Turn)
|
||||
if deal.Chips != 4000 {
|
||||
t.Errorf("chips moved on the deal: %d, want 4000 — the ante is inside the engine", deal.Chips)
|
||||
}
|
||||
if deal.Uno == nil || len(deal.Uno.Hand) != uno.HandSize {
|
||||
t.Fatalf("you were dealt the wrong hand: %+v", deal.Uno)
|
||||
}
|
||||
if deal.Uno.Turn != 0 {
|
||||
t.Errorf("you act first at your own table, turn is %d", deal.Uno.Turn)
|
||||
}
|
||||
if deal.Uno.Pot != deal.Uno.Ante*int64(len(deal.Uno.Seats)) {
|
||||
t.Errorf("pot is %d, want one ante per seat", deal.Uno.Pot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +59,16 @@ func TestUnoStartTakesTheStakeAndKeepsTheCards(t *testing.T) {
|
||||
// back never carries a bot's drawn card.
|
||||
func TestUnoMovePlaysTheWholeLap(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
fund(t, 5000)
|
||||
|
||||
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
||||
map[string]any{"bet": 100, "tier": "table"}))
|
||||
if v.Uno == nil {
|
||||
t.Fatal("no game")
|
||||
call(t, s, s.handleUnoSit, as(t, s, "reala", "POST", "/api/games/uno/sit",
|
||||
map[string]any{"tier": "table", "buyin": 1000}))
|
||||
deal, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "deal"}))
|
||||
if code != 200 || deal.Uno == nil {
|
||||
t.Fatalf("deal = %d", code)
|
||||
}
|
||||
|
||||
// Draw, which is legal from any hand: the turn passes to the bots and comes
|
||||
// back, unless the card drawn happens to be playable.
|
||||
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "draw"}))
|
||||
if code != 200 {
|
||||
@@ -93,110 +90,144 @@ func TestUnoMovePlaysTheWholeLap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// You cannot play a wild without naming a colour — and the zero value of the
|
||||
// colour field is not red, so a move that simply omits it is refused rather than
|
||||
// quietly played as one.
|
||||
func TestUnoWildWithNoColourIsRefused(t *testing.T) {
|
||||
s := newCasino(t)
|
||||
fund(t, 1000)
|
||||
// TestUnoViewNeverLeaksAnotherSeatsCards is the security boundary of the whole
|
||||
// multiplayer table. There is no showdown in UNO, so the rule is absolute: no
|
||||
// seat's hand, and no card a seat drew, ever reaches another viewer. Rendered for
|
||||
// every seat, after every step of a hand played to its end.
|
||||
//
|
||||
// After SSE the view a seat renders fans to that seat's stream, so a single missed
|
||||
// redaction broadcasts a hand to the whole felt. The engine emits every seat's
|
||||
// cards (it cannot know who a shared stream is for), which makes viewUno and
|
||||
// viewUnoEvents the only thing between the deck and the network tab.
|
||||
func TestUnoViewNeverLeaksAnotherSeatsCards(t *testing.T) {
|
||||
tier, _ := uno.TierBySlug("full")
|
||||
// Two humans (0, 2) and two bots (1, 3), so the test covers a viewer seeing both
|
||||
// another human and a bot, and a human at a non-zero index.
|
||||
seats := []uno.SeatConfig{
|
||||
{Name: "Ana", Stack: 2000},
|
||||
{Name: "Bot A", Bot: true, Stack: 2000},
|
||||
{Name: "Bo", Stack: 2000},
|
||||
{Name: "Bot B", Bot: true, Stack: 2000},
|
||||
}
|
||||
g, _, err := uno.New(tier, seats, tier.RakePct, 5, 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, evs, err := uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertUnoRedacted(t, g, evs)
|
||||
|
||||
// Deal until a wild lands in the opening hand: it's four cards in 108, so it
|
||||
// doesn't take long, and this is the only way to get one without rigging the
|
||||
// deck through a door the server doesn't have.
|
||||
var wild = -1
|
||||
for try := 0; try < 40 && wild < 0; try++ {
|
||||
v, _ := call(t, s, s.handleUnoStart, as(t, s, "reala", "POST", "/api/games/uno/start",
|
||||
map[string]any{"bet": 10, "tier": "duel"}))
|
||||
if v.Uno == nil {
|
||||
t.Fatal("no game")
|
||||
for step := 0; unoPlaying(g) && step < 200; step++ {
|
||||
seat := g.Turn
|
||||
if g.Seats[seat].Bot {
|
||||
t.Fatalf("advance stopped on a bot at seat %d", seat)
|
||||
}
|
||||
for i, c := range v.Uno.Hand {
|
||||
if c.Wild {
|
||||
wild = i
|
||||
break
|
||||
}
|
||||
move := unoHumanMove(g, seat)
|
||||
var stepEvs []uno.Event
|
||||
g, stepEvs, err = uno.ApplyMove(g, seat, move)
|
||||
if err != nil {
|
||||
t.Fatalf("seat %d %s: %v", seat, move.Kind, err)
|
||||
}
|
||||
if wild < 0 {
|
||||
// Abandon this deal and take another. The live row is keyed on the
|
||||
// player, so it has to go before the next start can be seated.
|
||||
if err := storage.ClearLiveHand(testPlayer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if wild < 0 {
|
||||
t.Skip("40 deals and not one wild — vanishingly unlikely, but not a failure")
|
||||
}
|
||||
|
||||
_, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "play", "index": wild}))
|
||||
if code != 400 {
|
||||
t.Fatalf("a wild with no colour = %d, want 400", code)
|
||||
}
|
||||
|
||||
v, code := call(t, s, s.handleUnoMove, as(t, s, "reala", "POST", "/api/games/uno/move",
|
||||
map[string]any{"kind": "play", "index": wild, "color": int(uno.Green)}))
|
||||
if code != 200 {
|
||||
t.Fatalf("a wild played as green = %d, want 200", code)
|
||||
}
|
||||
if v.Uno != nil && v.Uno.Phase != "done" && v.Uno.Color != "green" && v.Uno.Color != "" {
|
||||
// The bots have played since, so the colour may have moved on — what must
|
||||
// not happen is the wild going down as anything we didn't ask for.
|
||||
t.Logf("colour in play after the bots: %s", v.Uno.Color)
|
||||
assertUnoRedacted(t, g, stepEvs)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// unoHumanMove picks a legal move for the human to act: play a legal card, take a
|
||||
// stack you can't answer, pass a drawn card, otherwise draw.
|
||||
func unoHumanMove(g uno.State, seat int) uno.Move {
|
||||
if p := g.Playable(seat); len(p) > 0 {
|
||||
m := uno.Move{Kind: uno.MovePlay, Index: p[0], Color: uno.Red}
|
||||
for _, at := range g.UnoAt(seat) {
|
||||
if at == p[0] {
|
||||
m.Uno = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
switch g.Phase {
|
||||
case uno.PhaseStack:
|
||||
return uno.Move{Kind: uno.MoveTake}
|
||||
case uno.PhaseDrawn:
|
||||
return uno.Move{Kind: uno.MovePass}
|
||||
default:
|
||||
return uno.Move{Kind: uno.MoveDraw}
|
||||
}
|
||||
}
|
||||
|
||||
// assertUnoRedacted renders every seat's view and event script and fails if any of
|
||||
// them carries a card belonging to another seat.
|
||||
func assertUnoRedacted(t *testing.T, g uno.State, evs []uno.Event) {
|
||||
t.Helper()
|
||||
for viewer := range g.Seats {
|
||||
v := viewUno(g, viewer)
|
||||
// The view's hand is the viewer's own, exactly — no more, no fewer.
|
||||
if len(v.Hand) != len(g.Hands[viewer]) {
|
||||
t.Fatalf("seat %d's view carries %d cards, but the seat holds %d",
|
||||
viewer, len(v.Hand), len(g.Hands[viewer]))
|
||||
}
|
||||
// The event script: a hand rides only the viewer's own beat, and a drawn card
|
||||
// only the viewer's own draw. (A card a bot plays is public — it lands on the
|
||||
// pile — so only a *drawn* card is a secret, which is why this is scoped to the
|
||||
// draw and forced beats.) Undo either guard in viewUnoEvents and this fails.
|
||||
for _, e := range viewUnoEvents(evs, viewer) {
|
||||
if len(e.Hand) > 0 && e.Seat != viewer {
|
||||
t.Fatalf("seat %d's event stream carries seat %d's hand", viewer, e.Seat)
|
||||
}
|
||||
if e.Card != nil && (e.Kind == uno.EvDraw || e.Kind == uno.EvForced) && e.Seat != viewer {
|
||||
t.Fatalf("seat %d's event stream carries seat %d's drawn card", viewer, e.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A seat the mercy rule has buried holds no cards; so does one that just went out.
|
||||
// The view tells them apart by asking the engine (Live), not by inferring from a
|
||||
// count of zero.
|
||||
func TestABuriedSeatIsRenderedOut(t *testing.T) {
|
||||
tier, _ := uno.TierBySlug("nm-full")
|
||||
g, _, err := uno.New(tier, uno.SoloSeats(tier, tier.Bots, 1000), 0.05, 7, 9)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, _, err = uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bury seat 2, the way the engine does: no cards, and out.
|
||||
// Bury seat 2 the way the engine does: no cards, and out.
|
||||
g.Hands[2] = nil
|
||||
g.Out[2] = true
|
||||
|
||||
v := viewUno(g)
|
||||
v := viewUno(g, 0)
|
||||
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)
|
||||
t.Errorf("nobody has taken the pot mid-hand, so there is no winner: got %d", v.Winner)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// has to reach the browser.
|
||||
func TestTheStackBillCrossesTheWire(t *testing.T) {
|
||||
g, _, err := uno.New(100, mustTier(t, "nm-duel"), 0.05, 3, 4)
|
||||
tier, _ := uno.TierBySlug("nm-duel")
|
||||
g, _, err := uno.New(tier, uno.SoloSeats(tier, tier.Bots, 1000), 0.05, 3, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, _, err = uno.ApplyMove(g, 0, uno.Move{Kind: uno.MoveDeal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Pending = 6
|
||||
g.Phase = uno.PhaseStack
|
||||
|
||||
v := viewUno(g)
|
||||
v := viewUno(g, 0)
|
||||
if v.Pending != 6 {
|
||||
t.Errorf("the felt was told the bill is %d, want 6", v.Pending)
|
||||
}
|
||||
@@ -204,12 +235,3 @@ func TestTheStackBillCrossesTheWire(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
live = append(live, ch)
|
||||
}
|
||||
|
||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}}}
|
||||
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}}
|
||||
|
||||
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
||||
// provider is unreachable at boot we log and serve anonymously rather than
|
||||
|
||||
@@ -174,11 +174,11 @@
|
||||
class="pete-tier 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>
|
||||
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</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}} · seven cards each
|
||||
{{.Bots}} bot{{if gt .Bots 1}}s{{end}} · winner takes the pot
|
||||
</p>
|
||||
</button>
|
||||
{{end}}
|
||||
@@ -192,7 +192,7 @@
|
||||
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>
|
||||
<span class="font-display text-lg font-bold tabular-nums text-[color:var(--accent)]">{{.Ante}} ante</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">
|
||||
|
||||
Reference in New Issue
Block a user