From 6cb1bc7cbde34e7f8e5480cf8376ce12af32386e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Sep 2025 18:13:52 +0000 Subject: [PATCH] feat: Add poker table package Adds seating and action flow for a poker table. Co-authored-by: loganjspears --- table/README.md | 33 +++++++ table/doc.go | 8 ++ table/seating.go | 14 +++ table/table.go | 208 ++++++++++++++++++++++++++++++++++++++++++++ table/table_test.go | 83 ++++++++++++++++++ table/types.go | 70 +++++++++++++++ 6 files changed, 416 insertions(+) create mode 100644 table/README.md create mode 100644 table/doc.go create mode 100644 table/seating.go create mode 100644 table/table.go create mode 100644 table/table_test.go create mode 100644 table/types.go diff --git a/table/README.md b/table/README.md new file mode 100644 index 0000000..220f2d0 --- /dev/null +++ b/table/README.md @@ -0,0 +1,33 @@ +# table + +A small, modular poker table package for seating and action flow. + +## Features + +- Seating: `SeatAt`, `AutoSeat`, `Leave`, occupancy checks +- Action: `StartHand`, `AdvanceAction`, `AdvanceDealer`, `EndHand` +- Simple state snapshots + +## Install + +```bash +go get github.com/notnil/joker/table +``` + +## Usage + +```go +tb := table.New(table.Config{MaxSeats: 6}) +_, _ = tb.AutoSeat(table.Player{ID: "p1", Name: "Alice"}) +_, _ = tb.AutoSeat(table.Player{ID: "p2", Name: "Bob"}) + +_ = tb.StartHand() +dealer := tb.Dealer() +toAct := tb.ToAct() +next, _ := tb.AdvanceAction() +_ = tb.AdvanceDealer() +tb.EndHand() + +_ = tb.Leave(dealer) +``` + diff --git a/table/doc.go b/table/doc.go new file mode 100644 index 0000000..4db9a71 --- /dev/null +++ b/table/doc.go @@ -0,0 +1,8 @@ +// Package table provides a modular, testable poker table engine. +// +// Goals: +// - Simple seating: seat, autoseat, leave, occupancy queries +// - Deterministic action flow: dealer rotation, next to act, start/end hand +// - Clear state snapshots for read-only consumption +package table + diff --git a/table/seating.go b/table/seating.go new file mode 100644 index 0000000..ecfdc0d --- /dev/null +++ b/table/seating.go @@ -0,0 +1,14 @@ +package table + +// Public seating helpers to provide higher-level operations or utilities. + +// FirstEmptySeat returns the first empty SeatIndex or NoSeat if none. +func (t *tableImpl) FirstEmptySeat() SeatIndex { + for i := 0; i < t.cfg.MaxSeats; i++ { + if t.state.Seats[i].Status == SeatEmpty { + return SeatIndex(i) + } + } + return NoSeat +} + diff --git a/table/table.go b/table/table.go new file mode 100644 index 0000000..6ec7950 --- /dev/null +++ b/table/table.go @@ -0,0 +1,208 @@ +package table + +// Table provides poker-table-like operations: seating and action flow. +type Table interface { + // Seating + SeatAt(seat SeatIndex, player Player) error + AutoSeat(player Player) (SeatIndex, error) + Leave(seat SeatIndex) error + IsOccupied(seat SeatIndex) (bool, error) + FindPlayer(playerID PlayerID) (SeatIndex, bool) + NumSeated() int + MaxSeats() int + + // Action Flow + StartHand() error + EndHand() + Dealer() SeatIndex + ToAct() SeatIndex + NextToAct() (SeatIndex, error) + AdvanceAction() (SeatIndex, error) + AdvanceDealer() error + + // Snapshot state for external use + Snapshot() State +} + +// New returns a Table with the given configuration. +func New(cfg Config) Table { + if cfg.MaxSeats <= 0 { + cfg.MaxSeats = 9 + } + s := make([]Seat, cfg.MaxSeats) + for i := range s { + s[i] = Seat{Index: SeatIndex(i), Status: SeatEmpty} + } + return &tableImpl{ + cfg: cfg, + state: State{Seats: s, Action: ActionPosition{Dealer: NoSeat, ToAct: NoSeat, HandActive: false}}, + } +} + +type tableImpl struct { + cfg Config + state State +} + +func (t *tableImpl) MaxSeats() int { return t.cfg.MaxSeats } +func (t *tableImpl) NumSeated() int { return t.state.Seated } + +func (t *tableImpl) Snapshot() State { + // Return a shallow copy to prevent external mutation. + s := make([]Seat, len(t.state.Seats)) + copy(s, t.state.Seats) + return State{Seats: s, Action: t.state.Action, Seated: t.state.Seated} +} + +func (t *tableImpl) IsOccupied(seat SeatIndex) (bool, error) { + if !t.validSeat(seat) { + return false, ErrInvalidSeat + } + return t.state.Seats[seat].Status == SeatOccupied, nil +} + +func (t *tableImpl) SeatAt(seat SeatIndex, player Player) error { + if !t.validSeat(seat) { + return ErrInvalidSeat + } + if _, ok := t.FindPlayer(player.ID); ok { + return ErrPlayerSeated + } + if t.state.Seats[seat].Status == SeatOccupied { + return ErrSeatOccupied + } + t.state.Seats[seat].Status = SeatOccupied + t.state.Seats[seat].Player = &player + t.state.Seated++ + // Initialize dealer to first seated player if none. + if t.state.Action.Dealer == NoSeat { + t.state.Action.Dealer = seat + } + return nil +} + +func (t *tableImpl) AutoSeat(player Player) (SeatIndex, error) { + if _, ok := t.FindPlayer(player.ID); ok { + return NoSeat, ErrPlayerSeated + } + if t.state.Seated >= t.cfg.MaxSeats { + return NoSeat, ErrTableFull + } + for i := 0; i < t.cfg.MaxSeats; i++ { + if t.state.Seats[i].Status == SeatEmpty { + return SeatIndex(i), t.SeatAt(SeatIndex(i), player) + } + } + return NoSeat, ErrTableFull +} + +func (t *tableImpl) Leave(seat SeatIndex) error { + if !t.validSeat(seat) { + return ErrInvalidSeat + } + if t.state.Seats[seat].Status == SeatEmpty { + return ErrSeatEmpty + } + t.state.Seats[seat].Status = SeatEmpty + t.state.Seats[seat].Player = nil + t.state.Seated-- + // If dealer or to-act left, adjust. + if t.state.Action.Dealer == seat { + t.reassignDealer() + } + if t.state.Action.ToAct == seat { + t.state.Action.ToAct = t.nextOccupiedFrom(t.state.Action.ToAct) + } + // If table becomes empty, reset action. + if t.state.Seated == 0 { + t.state.Action = ActionPosition{Dealer: NoSeat, ToAct: NoSeat, HandActive: false} + } + return nil +} + +func (t *tableImpl) FindPlayer(playerID PlayerID) (SeatIndex, bool) { + for i := range t.state.Seats { + s := t.state.Seats[i] + if s.Status == SeatOccupied && s.Player != nil && s.Player.ID == playerID { + return SeatIndex(i), true + } + } + return NoSeat, false +} + +func (t *tableImpl) StartHand() error { + if t.state.Seated == 0 { + return ErrNoPlayers + } + t.state.Action.HandActive = true + // Typically action starts left of dealer preflop; make it generic: next occupied. + t.state.Action.ToAct = t.nextOccupiedFrom(t.state.Action.Dealer) + if t.state.Action.ToAct == NoSeat { + return ErrNoPlayers + } + return nil +} + +func (t *tableImpl) EndHand() { + t.state.Action.HandActive = false + t.state.Action.ToAct = NoSeat +} + +func (t *tableImpl) Dealer() SeatIndex { return t.state.Action.Dealer } +func (t *tableImpl) ToAct() SeatIndex { return t.state.Action.ToAct } + +func (t *tableImpl) NextToAct() (SeatIndex, error) { + if !t.state.Action.HandActive { + return NoSeat, ErrHandNotActive + } + next := t.nextOccupiedFrom(t.state.Action.ToAct) + if next == NoSeat { + return NoSeat, ErrNoPlayers + } + return next, nil +} + +func (t *tableImpl) AdvanceAction() (SeatIndex, error) { + next, err := t.NextToAct() + if err != nil { + return NoSeat, err + } + t.state.Action.ToAct = next + return next, nil +} + +func (t *tableImpl) AdvanceDealer() error { + if t.state.Seated == 0 { + return ErrNoPlayers + } + t.state.Action.Dealer = t.nextOccupiedFrom(t.state.Action.Dealer) + return nil +} + +// Helpers + +func (t *tableImpl) validSeat(seat SeatIndex) bool { + return seat >= 0 && int(seat) < t.cfg.MaxSeats +} + +// nextOccupiedFrom returns the next occupied seat moving clockwise starting +// after the provided index. Returns NoSeat if none found. +func (t *tableImpl) nextOccupiedFrom(from SeatIndex) SeatIndex { + if t.state.Seated == 0 { + return NoSeat + } + start := int(from) + for i := 1; i <= t.cfg.MaxSeats; i++ { + idx := (start + i) % t.cfg.MaxSeats + if t.state.Seats[idx].Status == SeatOccupied { + return SeatIndex(idx) + } + } + return NoSeat +} + +func (t *tableImpl) reassignDealer() { + // Move dealer to next occupied seat or NoSeat if none. + t.state.Action.Dealer = t.nextOccupiedFrom(t.state.Action.Dealer) +} + diff --git a/table/table_test.go b/table/table_test.go new file mode 100644 index 0000000..57fd4b6 --- /dev/null +++ b/table/table_test.go @@ -0,0 +1,83 @@ +package table + +import "testing" + +func TestSeatingAndFind(t *testing.T) { + tb := New(Config{MaxSeats: 6}) + + // Autoseat two players + s1, err := tb.AutoSeat(Player{ID: "p1", Name: "Alice"}) + if err != nil { + t.Fatalf("autoseat p1: %v", err) + } + s2, err := tb.AutoSeat(Player{ID: "p2", Name: "Bob"}) + if err != nil { + t.Fatalf("autoseat p2: %v", err) + } + if s1 == s2 || s1 == NoSeat || s2 == NoSeat { + t.Fatalf("unexpected seats: s1=%v s2=%v", s1, s2) + } + if tb.NumSeated() != 2 { + t.Fatalf("expected 2 seated, got %d", tb.NumSeated()) + } + + // Find players + if seat, ok := tb.FindPlayer("p1"); !ok || seat != s1 { + t.Fatalf("expected find p1 at %v, got %v %v", s1, seat, ok) + } + if seat, ok := tb.FindPlayer("zzz"); ok || seat != NoSeat { + t.Fatalf("expected not find zzz, got %v %v", seat, ok) + } +} + +func TestSeatAtAndLeave(t *testing.T) { + tb := New(Config{MaxSeats: 3}) + if err := tb.SeatAt(SeatIndex(1), Player{ID: "p1"}); err != nil { + t.Fatalf("seat at 1: %v", err) + } + if err := tb.SeatAt(SeatIndex(1), Player{ID: "p2"}); err == nil { + t.Fatalf("expected seat occupied error") + } + if err := tb.Leave(SeatIndex(1)); err != nil { + t.Fatalf("leave 1: %v", err) + } + if err := tb.Leave(SeatIndex(1)); err == nil { + t.Fatalf("expected seat empty error") + } +} + +func TestActionFlow(t *testing.T) { + tb := New(Config{MaxSeats: 4}) + s1, _ := tb.AutoSeat(Player{ID: "p1"}) + s2, _ := tb.AutoSeat(Player{ID: "p2"}) + s3, _ := tb.AutoSeat(Player{ID: "p3"}) + + if tb.Dealer() != s1 { + t.Fatalf("expected dealer to start at first seated: %v got %v", s1, tb.Dealer()) + } + if err := tb.StartHand(); err != nil { + t.Fatalf("start hand: %v", err) + } + if tb.ToAct() != s2 { + t.Fatalf("expected to act to be left of dealer: %v got %v", s2, tb.ToAct()) + } + next, err := tb.NextToAct() + if err != nil || next != s3 { + t.Fatalf("expected next to act s3: got %v err %v", next, err) + } + adv, err := tb.AdvanceAction() + if err != nil || adv != s3 || tb.ToAct() != s3 { + t.Fatalf("advance to s3 failed: adv=%v err=%v toAct=%v", adv, err, tb.ToAct()) + } + if err := tb.AdvanceDealer(); err != nil { + t.Fatalf("advance dealer: %v", err) + } + if tb.Dealer() != s2 { + t.Fatalf("expected dealer to rotate to s2, got %v", tb.Dealer()) + } + tb.EndHand() + if tb.ToAct() != NoSeat { + t.Fatalf("expected toAct reset after EndHand") + } +} + diff --git a/table/types.go b/table/types.go new file mode 100644 index 0000000..37ca005 --- /dev/null +++ b/table/types.go @@ -0,0 +1,70 @@ +package table + +// SeatIndex represents a position at the table. +// -1 means "no seat" or undefined. +type SeatIndex int + +const ( + NoSeat SeatIndex = -1 +) + +// PlayerID is a unique identifier for a player. +type PlayerID string + +// Player represents basic identity info used by the table. +type Player struct { + ID PlayerID + Name string +} + +// SeatStatus represents occupancy state for a seat. +type SeatStatus int + +const ( + SeatEmpty SeatStatus = iota + SeatOccupied +) + +// Seat models a single position at the table. +type Seat struct { + Index SeatIndex + Status SeatStatus + Player *Player +} + +// ActionPosition indicates who is to act. +type ActionPosition struct { + Dealer SeatIndex + ToAct SeatIndex + HandActive bool +} + +// Config encapsulates static table configuration. +type Config struct { + MaxSeats int +} + +// State captures dynamic table state. +type State struct { + Seats []Seat + Action ActionPosition + Seated int +} + +// Errors returned by table operations. +var ( + ErrInvalidSeat = newError("invalid seat index") + ErrSeatOccupied = newError("seat already occupied") + ErrSeatEmpty = newError("seat empty") + ErrTableFull = newError("table full") + ErrPlayerSeated = newError("player already seated") + ErrNoPlayers = newError("no players seated") + ErrHandNotActive = newError("hand not active") +) + +type tableError struct{ msg string } + +func (e tableError) Error() string { return e.msg } + +func newError(msg string) error { return tableError{msg: msg} } +