Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions table/README.md
Original file line number Diff line number Diff line change
@@ -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)
```

8 changes: 8 additions & 0 deletions table/doc.go
Original file line number Diff line number Diff line change
@@ -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

14 changes: 14 additions & 0 deletions table/seating.go
Original file line number Diff line number Diff line change
@@ -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
}

208 changes: 208 additions & 0 deletions table/table.go
Original file line number Diff line number Diff line change
@@ -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)
}

83 changes: 83 additions & 0 deletions table/table_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}

Loading