sessionfuzz

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: MIT Imports: 9 Imported by: 0

README

poker-session-fuzzer

Fuzz a poker engine across whole multi-hand sessions and check, after every action, the invariants that keep a real table alive.

Hand-history corpora (PHH, site histories, solver output) test one hand at a time. Every hand starts from known seats, button and blinds, so the step between hands never runs. That step is where tables freeze in production: busts, button and blind movement, sit-in and sit-out, joins and leaves, tournament eliminations. This package plays sessions where all of that happens, mostly choosing among each seat's own legal actions, and needs no oracle. It checks rules that must hold whatever the right answer is.

Invariants

Invariant Checks
no-panic The engine never panics, on any action, legal or not.
status-stack A busted player has (almost) no chips. An all-in player has none while the hand is live.
chip-conservation Stacks + pot (+ queued top-ups) change only when a player joins, tops up or leaves.
liveness During a hand, and before the deal once two or more players are active, the seat on the clock can move play on.
positions When a hand is dealt, the big blind is a player in the hand and the small and big blind are different seats. After that the blind positions do not move until the hand ends (a blind poster may still leave).
stall-at-end Between hands, when enough players with chips want to play, someone is offered new-hand.
end-nextToAct Between hands, new-hand is offered to the seat on the clock.

Plug in an engine

Implement two small interfaces. The vocabulary (actions, statuses, rounds) is plain poker English: map it to and from your engine's types.

type Table interface {
	State() sessionfuzz.State      // seats, stacks, statuses, legal actions, positions
	Apply(sessionfuzz.Action) error // reject with an error; leave the table unchanged
}

type Engine interface {
	NewTable(sessionfuzz.Config) (Table, error)
}

Then, in a _test.go file:

var engine = sessionfuzz.EngineFunc(func(c sessionfuzz.Config) (sessionfuzz.Table, error) {
	return newMyTable(c) // your adapter
})

var opts = sessionfuzz.Options{}

func TestSessionSweep(t *testing.T)  { sessionfuzz.Sweep(t, engine, opts) }  // 200 sessions, plain go test
func TestSessionReplay(t *testing.T) { sessionfuzz.Replay(t, engine, opts) } // SESSIONFUZZ_SEED=n
func FuzzSession(f *testing.F)       { sessionfuzz.Fuzz(f, engine, opts) }   // go test -fuzz
go test -run TestSessionSweep ./...                        # deterministic, for CI
SESSIONFUZZ_SEED=49 go test -run TestSessionReplay -v ./... # print one session in full
go test -run '^$' -fuzz FuzzSession -fuzztime 10m ./...     # coverage-guided

A failure prints the session script that led to it. Accepted actions that weren't in the player's legal actions are marked, and the final table is shown:

invariant "stall-at-end" violated: cash, 6 seats: players with chips want to play [folded sitting-in] but nobody is offered new-hand

session script (last 40 of 22 lines):
   17  seat 1: post-big-blind 100
   ...
   22  seat 2: leave
state:
  hand=1 round=end nextToAct=1 dealer=2 sb=5 bb=6 pot=200
  seat 1: folded                 stack=1427   legal=[sit-out top-up leave]
  seat 5: sitting-in             stack=1682   legal=[sit-out top-up leave]

From failure to test

Every failure is minimised and printed as a poker-scene-dsl scenario, ready to paste into a test.

Minimize replays subsets of the session on fresh tables, dropping actions while the same kind of violation still happens. On our engine, a 113-action stall comes down to 12 sentences:

sc.Act(
	"player02 joins seat 2 with 1527",
	"seat 2 sits in and waits",
	"player06 joins seat 1 with 1527",
	"seat 1 sits in",
	"player05 joins seat 5 with 1682",
	"seat 5 sits in",
	"time runs out",
	"seat 1 posts big blind",
	"deal",
	"seat 2 calls",
	"seat 1 folds",
	"seat 2 leaves",
)

Sweep prints the minimised scenario for each new kind of failure. Replay prints the whole session, the minimised failure, and a minimised scenario for every known issue the session hit. The fuzzer and the DSL tick the same clock, so a scenario replays its session exactly.

You can call it directly too: sessionfuzz.Minimize(engine, result, opts, nil) then .Scene(). Pass a known issue's violation from result.KnownFirst as the last argument to minimise that one.

Options

  • Rules hold where engines legitimately differ:
    • BustBelow: e.g. a cash stack smaller than the small blind counts as busted.
    • NewHandFloor
    • Progress: which actions move play on.
    • Reject: your host's own gates, so the fuzzer only reaches states your host can. For example, a chain that never forwards a tournament leave.
  • Known lists open bugs as {ID, regexp}. Matching violations are counted, and play continues, so CI stays green while the bug is open and fails on anything new.
  • Config picks the table from the session's first input byte. The default plays cash and sit-and-go at 2, 3, 4, 6 and 9 seats with short stacks, so busts and blind rises happen quickly.

License

MIT

Documentation

Overview

Package sessionfuzz fuzzes a poker engine across whole multi-hand sessions.

Hand-history corpora (PHH, site histories) test single hands: every hand starts from given seats, button and blinds. The bugs that freeze real tables live in the step from one hand to the next — busts, button and blind movement, sit-in / sit-out, joins and leaves, tournament eliminations. This package plays whole sessions against any engine that implements Table, mostly choosing among each seat's own legal actions, and checks invariants that must always hold after every action. There is no external oracle: a violation is a bug.

The engine is plugged in through two small interfaces, Engine and Table. The vocabulary (actions, statuses, rounds) is plain poker English; an adapter maps it to and from the engine's own types.

Index

Constants

View Source
const (
	ActJoin           = "join"
	ActLeave          = "leave"
	ActSitIn          = "sit-in"
	ActSitInAndWait   = "sit-in-and-wait"
	ActSitOut         = "sit-out"
	ActTopUp          = "top-up"
	ActNewHand        = "new-hand"
	ActTimeout        = "timeout"
	ActDeal           = "deal"
	ActPostSmallBlind = "post-small-blind"
	ActPostBigBlind   = "post-big-blind"
	ActFold           = "fold"
	ActCheck          = "check"
	ActCall           = "call"
	ActBet            = "bet"
	ActRaise          = "raise"
	ActAllIn          = "all-in"
	ActShow           = "show"
	ActMuck           = "muck"
)

Action names the fuzzer sends or reasons about. Betting actions are taken verbatim from each player's legal actions, so an engine may offer more.

View Source
const (
	SweepSessions = 200
	SweepBytes    = 1000
)

SweepSessions and SweepBytes size Sweep: 200 sessions of 1000 input bytes each take a few seconds against an in-process engine.

View Source
const Tail = 40

Tail is how many trailing script lines Result.Report prints.

Variables

View Source
var DefaultProgress = map[string]bool{
	ActPostSmallBlind: true, ActPostBigBlind: true, ActFold: true, ActCheck: true,
	ActCall: true, ActBet: true, ActRaise: true, ActAllIn: true, ActShow: true,
	ActMuck: true, ActDeal: true, ActNewHand: true,
}

DefaultProgress is the default set of actions that move play on.

Functions

func Deck

func Deck(seed uint64) []string

Deck returns a deterministic shuffled 52-card deck ("AS", "TD", …).

func DefaultAddresses

func DefaultAddresses(n int) []string

DefaultAddresses returns n distinct placeholder player addresses.

func DescribeState

func DescribeState(st State) string

DescribeState renders a table state, one line per seat.

func Fuzz

func Fuzz(f *testing.F, engine Engine, opts Options)

Fuzz registers seeds and the fuzz target for coverage-guided fuzzing:

func FuzzSession(f *testing.F) { sessionfuzz.Fuzz(f, myEngine, opts) }

go test -run '^$' -fuzz FuzzSession -fuzztime 5m

func Replay

func Replay(t *testing.T, engine Engine, opts Options)

Replay prints one Sweep session in full when SESSIONFUZZ_SEED is set — the script, the final table, and the session as a poker-scene-dsl scenario — and skips otherwise:

SESSIONFUZZ_SEED=5 go test -run TestReplay -v

func SeedBytes

func SeedBytes(seed uint64, n int) []byte

SeedBytes is a deterministic pseudo-random input of n bytes.

func Sweep

func Sweep(t *testing.T, engine Engine, opts Options)

Sweep plays SweepSessions deterministic sessions — for plain `go test`, so CI checks the invariants without -fuzz. It fails once per new kind of violation (numbers are ignored when grouping), printing the replay seed, and logs how many sessions hit each known issue.

Types

type Action

type Action struct {
	// Player is the acting player's address; empty for a timeout, which acts
	// for whoever is on the clock.
	Player string
	Action string
	Amount *big.Int
	// Seat is the seat to take (join only).
	Seat int
	// Deck is the next hand's deck, top first (new-hand only). An engine that
	// shuffles itself may ignore it, at the cost of determinism.
	Deck []string
	// Time is the action's timestamp in Unix milliseconds: a second after the
	// last accepted action, or ten minutes and a second for a timeout.
	Time int64
}

Action is one move the fuzzer sends.

type Config

type Config struct {
	Format     Format
	MaxPlayers int
	// MinPlayers is the fewest players a cash table deals to. For a sit-and-go
	// the table starts when MaxPlayers have joined.
	MinPlayers int
	SmallBlind *big.Int
	BigBlind   *big.Int
	// Cash: the buy-in range. Sit-and-go: MinBuyIn is the entry price.
	MinBuyIn *big.Int
	MaxBuyIn *big.Int
	// Sit-and-go only.
	StartingStack     *big.Int
	BlindLevelMinutes int
}

Config is one table the fuzzer plays. All amounts are chips except BuyIn for a sit-and-go, which is whatever unit the host charges.

func DefaultConfig

func DefaultConfig(choice byte) Config

DefaultConfig maps a byte to one of ten tables: cash or sit-and-go, with 2, 3, 4, 6 or 9 seats, blinds 50/100. Stacks are short (10 big blinds for a sit-and-go, with one-minute levels) so busts and blind rises happen inside a short session.

type Engine

type Engine interface {
	NewTable(Config) (Table, error)
}

Engine creates tables.

type EngineFunc

type EngineFunc func(Config) (Table, error)

EngineFunc adapts a function to Engine.

func (EngineFunc) NewTable

func (f EngineFunc) NewTable(c Config) (Table, error)

type Format

type Format string

Format is the table format.

const (
	Cash     Format = "cash"
	SitAndGo Format = "sit-and-go"
)

type KnownIssue

type KnownIssue struct {
	ID    string
	Match *regexp.Regexp
}

KnownIssue marks violations of an open bug. Match is tested against "invariant: detail" (see Violation.Error).

type LegalAction

type LegalAction struct {
	Action string
	Min    *big.Int
	Max    *big.Int
}

LegalAction is one action a player may take now, with its amount range when it carries one (nil for fold / check / leave and the like).

type Options

type Options struct {
	// Config picks the table for a session from the session's first input byte.
	// Nil means [DefaultConfig].
	Config func(choice byte) Config
	// Rules are the engine's own conventions the invariants depend on.
	Rules Rules
	// Known are open bugs: a matching violation is counted and play continues.
	Known []KnownIssue
	// Addresses is the pool of players a session draws from. Nil means
	// [DefaultAddresses](12). Use more addresses than seats so that a fresh
	// player can take a seat someone left.
	Addresses []string
}

Options configures a run. The zero value works: it uses DefaultConfig and the default rules.

type Player

type Player struct {
	Address string
	Seat    int
	Stack   *big.Int
	Status  Status
	// Pending is chips owned by the player but not yet in the stack (a top-up
	// queued during a hand). Nil when none.
	Pending *big.Int
	Legal   []LegalAction
}

Player is one seated player as the engine reports it.

type Recorded added in v0.2.0

type Recorded struct {
	Action
	Seat int
}

Recorded is one accepted action.

type Result

type Result struct {
	Config Config
	// Script is the readable log of every accepted action — the repro.
	Script []string
	// Final is the table state when the session stopped.
	Final State
	// Violation is the first violation not matched by a known issue, or nil.
	Violation *Violation
	// Known counts violations of each known issue.
	Known map[string]int
	// KnownFirst is the first violation of each known issue, for Minimize.
	KnownFirst map[string]*Violation
	// Actions are the accepted actions in order, with the actor's seat at
	// the time (0 for a join or a timeout). [Result.Scene] renders them.
	Actions []Recorded
}

Result is one played session.

func Minimize added in v0.3.0

func Minimize(engine Engine, r *Result, opts Options, target *Violation) (*Result, error)

Minimize shrinks a failing session to the shortest list of actions that still produces the same kind of violation, so Result.Scene() becomes a short scenario worth committing as a test. It replays subsets of r.Actions on fresh tables, dropping chunks while the violation persists (halving the chunk size down to single actions). Actions a smaller replay makes illegal are rejected by the engine and left out of the result.

target is the violation to keep; nil means r.Violation. A known issue's violation can be minimised too: pass it as target.

func Run

func Run(engine Engine, data []byte, opts Options) (*Result, error)

Run plays one session from data and returns what happened. The first input byte picks the table; the rest drive the play.

func (*Result) Report

func (r *Result) Report() string

Report is a readable account of a session: the violation, the end of the script that led to it, and the final table.

func (*Result) Scene added in v0.2.0

func (r *Result) Scene() []string

Scene renders the session's accepted actions as sentences for github.com/block52/poker-scene-dsl, so a fuzz failure becomes a readable, replayable test. Players are named by seat ("seat 3 calls"); a joining player by address. Amounts are the ones the engine was sent: raises render as "raises by N" and bets as "bets N".

type Round

type Round string

Round is the betting round. Ante covers the time before cards are dealt (blinds being posted); End is the gap between hands.

const (
	Ante     Round = "ante"
	Preflop  Round = "preflop"
	Flop     Round = "flop"
	Turn     Round = "turn"
	River    Round = "river"
	Showdown Round = "showdown"
	End      Round = "end"
)

type Rules

type Rules struct {
	// BustBelow is the smallest stack that is NOT busted. Nil means 1: only an
	// empty stack busts. An engine that busts a cash stack too small to post a
	// blind returns the small blind here for cash tables.
	BustBelow func(Config) *big.Int
	// NewHandFloor is how many players with chips must want to play before
	// someone must be offered new-hand. Nil means MinPlayers for cash and 2
	// for a sit-and-go.
	NewHandFloor func(Config) int
	// Reject is the host's own gate, applied before the engine sees an action:
	// return true for actions the host never forwards, so the fuzzer only
	// reaches states the host can. Nil forwards everything.
	Reject func(Config, State, Action) bool
	// Progress are the actions that move a hand or the table on (leave,
	// sit-out and top-up do not). Nil means [DefaultProgress].
	Progress map[string]bool
}

Rules capture where engines legitimately differ.

type State

type State struct {
	Round      Round
	HandNumber int
	NextToAct  int
	Dealer     int
	SmallBlind int
	BigBlind   int
	// Pot is every chip in the middle (all streets, all side pots). It is not
	// read at End, where the pot has already been paid out.
	Pot     *big.Int
	Players []Player
}

State is the engine's view of the table after an action. Seat numbers are 1-based; 0 means "none".

type Status

type Status string

Status is a player's seat status.

const (
	// In a hand.
	Active  Status = "active"
	Folded  Status = "folded"
	AllIn   Status = "all-in"
	Showing Status = "showing"
	// Out of the hand.
	Busted     Status = "busted"
	Seated     Status = "seated"      // joined, has not chosen to play yet
	SittingOut Status = "sitting-out" // chose not to play
	// Pending entrants: will be dealt in on a later hand.
	SittingIn          Status = "sitting-in"            // post now, enter next legal hand
	WaitingForBigBlind Status = "waiting-for-big-blind" // enter when the big blind reaches the seat
)

type Table

type Table interface {
	State() State
	Apply(Action) error
}

Table is one live table. Apply must leave the table unchanged when it returns an error (a rejected action is normal: the fuzzer tries things). A panic in Apply is caught and reported as a violation.

type Violation

type Violation struct {
	Invariant string
	Detail    string
}

Violation is an invariant failure.

func (*Violation) Error

func (v *Violation) Error() string

func (*Violation) Kind added in v0.3.0

func (v *Violation) Kind() string

Kind is a violation with its numbers removed ("liveness: nextToAct=N is busted in turn"), so two sessions hitting the same bug at different seats or amounts compare equal.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL