games: a deck you can seed and a blackjack you can replay
The first two pieces of games.parodia.dev, both pure: no HTTP, no timers, no euros, nothing that knows a player's name. cards/ is the shared deck gogobee never had — blackjack carried its own, UNO carried another, hold'em leaned on a third-party one. The RNG is threaded rather than the package global, so a hand is reproducible from its seed. That's what makes the engine testable, and what lets a disputed hand be dealt again exactly as it fell. blackjack/ is ApplyMove(state, move) -> (state, events, error), where an error means the move was illegal and nothing else. gogobee's engine *was* the message sender, so its errors meant "the send failed"; there was no seam to test against. State is a plain value, so a hand survives a redeploy. House terms match the Matrix table — six decks, 3:2, dealer hits soft 17 — plus a 5% rake. The rake comes off winnings only: a push returns the stake untouched and a loss is never charged for the privilege.
This commit is contained in:
126
internal/games/cards/cards.go
Normal file
126
internal/games/cards/cards.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Package cards holds the deck primitives every card game on Pete shares.
|
||||
//
|
||||
// gogobee never had this: blackjack carried its own deck, UNO carried another,
|
||||
// and hold'em leaned on a third-party one. Three shuffles, three bugs to fix
|
||||
// three times. The games ported over here consolidate onto this instead.
|
||||
//
|
||||
// Two rules hold throughout:
|
||||
//
|
||||
// The RNG is threaded, never global. Every shuffle takes an explicit *rand.Rand,
|
||||
// so a hand is reproducible from its seed — which is what makes the engines
|
||||
// testable, and what lets us re-deal a disputed hand and show the player exactly
|
||||
// what the shoe did.
|
||||
//
|
||||
// A Deck is a plain value. No pointers into it, no timers hanging off it, so a
|
||||
// game in progress serializes to JSON and survives a redeploy.
|
||||
package cards
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// Suit is one of the four French suits.
|
||||
type Suit uint8
|
||||
|
||||
const (
|
||||
Spades Suit = iota
|
||||
Hearts
|
||||
Diamonds
|
||||
Clubs
|
||||
)
|
||||
|
||||
// Rank runs Ace(1) through King(13). Ace is low here; games that want it high
|
||||
// (blackjack's soft 11, hold'em's wheel) say so themselves.
|
||||
type Rank uint8
|
||||
|
||||
const (
|
||||
Ace Rank = 1
|
||||
Jack Rank = 11
|
||||
Queen Rank = 12
|
||||
King Rank = 13
|
||||
)
|
||||
|
||||
var (
|
||||
suitGlyphs = [4]string{"♠", "♥", "♦", "♣"}
|
||||
rankNames = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
|
||||
)
|
||||
|
||||
// Card is one playing card. The short JSON keys keep a serialized shoe small —
|
||||
// a six-deck blackjack state is 312 of these.
|
||||
type Card struct {
|
||||
Rank Rank `json:"r"`
|
||||
Suit Suit `json:"s"`
|
||||
}
|
||||
|
||||
// String renders the card the way a table shows it: "A♠", "10♥".
|
||||
func (c Card) String() string {
|
||||
if c.Rank < Ace || c.Rank > King || c.Suit > Clubs {
|
||||
return "??"
|
||||
}
|
||||
return rankNames[c.Rank] + suitGlyphs[c.Suit]
|
||||
}
|
||||
|
||||
// Red reports whether the card is a red suit — the one thing every renderer
|
||||
// needs and nobody should re-derive.
|
||||
func (c Card) Red() bool { return c.Suit == Hearts || c.Suit == Diamonds }
|
||||
|
||||
// Deck is an ordered pile of cards. The next card to come off is at index 0.
|
||||
type Deck []Card
|
||||
|
||||
// NewDeck builds n standard 52-card decks in fixed order. Shuffle before use:
|
||||
// an unshuffled deck is a bug at a table, but it's exactly what a test wants.
|
||||
func NewDeck(n int) Deck {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
d := make(Deck, 0, 52*n)
|
||||
for i := 0; i < n; i++ {
|
||||
for s := Spades; s <= Clubs; s++ {
|
||||
for r := Ace; r <= King; r++ {
|
||||
d = append(d, Card{Rank: r, Suit: s})
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Shuffle permutes the deck in place using the supplied RNG. Passing a seeded
|
||||
// *rand.Rand gives the same shuffle every time, which is the whole point.
|
||||
func (d Deck) Shuffle(rng *rand.Rand) {
|
||||
rng.Shuffle(len(d), func(i, j int) { d[i], d[j] = d[j], d[i] })
|
||||
}
|
||||
|
||||
// Draw takes the top card. ok is false when the deck is spent; the caller
|
||||
// decides whether that means reshuffle or fold, because the two games that hit
|
||||
// it disagree.
|
||||
func (d *Deck) Draw() (c Card, ok bool) {
|
||||
if len(*d) == 0 {
|
||||
return Card{}, false
|
||||
}
|
||||
c = (*d)[0]
|
||||
*d = (*d)[1:]
|
||||
return c, true
|
||||
}
|
||||
|
||||
// Hand renders a run of cards for display: "A♠ 10♥".
|
||||
func Hand(cs []Card) string {
|
||||
s := ""
|
||||
for i, c := range cs {
|
||||
if i > 0 {
|
||||
s += " "
|
||||
}
|
||||
s += c.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// NewRNG seeds a generator from two uint64s. Games store the seed alongside the
|
||||
// hand so a finished hand can be replayed exactly as it was dealt.
|
||||
func NewRNG(seed1, seed2 uint64) *rand.Rand {
|
||||
return rand.New(rand.NewPCG(seed1, seed2))
|
||||
}
|
||||
|
||||
func (s Suit) String() string {
|
||||
if s > Clubs {
|
||||
return "?"
|
||||
}
|
||||
return suitGlyphs[s]
|
||||
}
|
||||
102
internal/games/cards/cards_test.go
Normal file
102
internal/games/cards/cards_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package cards
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewDeck_IsAFullShoe(t *testing.T) {
|
||||
d := NewDeck(6)
|
||||
if len(d) != 312 {
|
||||
t.Fatalf("six decks hold %d cards, want 312", len(d))
|
||||
}
|
||||
seen := map[Card]int{}
|
||||
for _, c := range d {
|
||||
seen[c]++
|
||||
}
|
||||
if len(seen) != 52 {
|
||||
t.Fatalf("%d distinct cards, want 52", len(seen))
|
||||
}
|
||||
for c, n := range seen {
|
||||
if n != 6 {
|
||||
t.Fatalf("%s appears %d times in a six-deck shoe, want 6", c, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeck_ClampsToAtLeastOne(t *testing.T) {
|
||||
if len(NewDeck(0)) != 52 {
|
||||
t.Fatal("a zero-deck shoe should still hold one deck")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_SameSeedSameOrder(t *testing.T) {
|
||||
a, b := NewDeck(1), NewDeck(1)
|
||||
a.Shuffle(NewRNG(99, 1))
|
||||
b.Shuffle(NewRNG(99, 1))
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("same seed diverged at %d: %s vs %s", i, a[i], b[i])
|
||||
}
|
||||
}
|
||||
// And a different seed must not give the same order, or the RNG isn't wired up.
|
||||
c := NewDeck(1)
|
||||
c.Shuffle(NewRNG(100, 1))
|
||||
same := true
|
||||
for i := range a {
|
||||
if a[i] != c[i] {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
t.Fatal("a different seed produced an identical shuffle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShuffle_KeepsEveryCard(t *testing.T) {
|
||||
d := NewDeck(1)
|
||||
d.Shuffle(NewRNG(4, 4))
|
||||
seen := map[Card]bool{}
|
||||
for _, c := range d {
|
||||
seen[c] = true
|
||||
}
|
||||
if len(d) != 52 || len(seen) != 52 {
|
||||
t.Fatalf("shuffle lost cards: %d cards, %d distinct", len(d), len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_TakesFromTheTopAndRunsOut(t *testing.T) {
|
||||
d := NewDeck(1)
|
||||
top := d[0]
|
||||
c, ok := d.Draw()
|
||||
if !ok || c != top {
|
||||
t.Fatalf("drew %s (ok=%v), want the top card %s", c, ok, top)
|
||||
}
|
||||
if len(d) != 51 {
|
||||
t.Fatalf("deck has %d cards after one draw, want 51", len(d))
|
||||
}
|
||||
for len(d) > 0 {
|
||||
d.Draw()
|
||||
}
|
||||
if _, ok := d.Draw(); ok {
|
||||
t.Fatal("an empty deck kept dealing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCard_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
card Card
|
||||
want string
|
||||
}{
|
||||
{Card{Ace, Spades}, "A♠"},
|
||||
{Card{10, Hearts}, "10♥"},
|
||||
{Card{King, Clubs}, "K♣"},
|
||||
{Card{Rank: 99, Suit: Spades}, "??"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := tc.card.String(); got != tc.want {
|
||||
t.Errorf("String() = %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
if !(Card{Ace, Hearts}).Red() || (Card{Ace, Spades}).Red() {
|
||||
t.Error("Red() disagrees about which suits are red")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user