Files
Pete/internal/web/games_holdem_table.go
prosolis 5139385350 games: the poker table others can walk up to, and the one that empties when they leave
Phase C's handler cutover: hold'em now runs on the shared-table runtime instead
of the solo game_live_hands blob. Solo is just a table nobody else has joined.

- holdem implements tableGame (name/timeout/stacks). timeout auto-checks-or-folds
  a walked-away seat and marks it away; the audit is per-hand, with each pot's
  rake on the winner's row alone so HouseTake cannot 4x itself.
- New endpoints: sit opens a table (or joins an open bot seat), leave gets you up
  (LeaveTable + CloseTable behind the last human), plus tables (lobby), stream
  (SSE), chat and say. The move path loads the player's table, applies at their
  seat, commits under the version guard, and fans an SSE nudge.
- Engine grows Vacate/Occupy (a human leaving/joining between hands) and
  TableSeats (a named human + bots). The view carries your_seat, since a shared
  table has no seat-zero-is-you convention.
- Storage grows OpenSoloTable (stake+claim+create+seat in one tx), PlayerSeat,
  and the abandoned-table reaper (AbandonedTables/ReapTable) — the seated-player
  counterpart to the session reaper, since a walked-away stack is inside a blob
  the session reaper cannot see. upsertSeat preserves last_seen so an auto-fold
  never refreshes an away player's clock.

Not deployed, and the felt is not rewired yet: the frontend still assumes
seat zero is you, so this is browser-unverified. Solo sit/deal/play/leave and
two-human join/leave/reaper are covered by tests; the whole suite is green.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 16:37:49 -07:00

210 lines
6.9 KiB
Go

package web
import (
"encoding/json"
"time"
"pete/internal/games/holdem"
"pete/internal/storage"
)
// Hold'em as a shared table: the seam the runtime drives it through, and the two
// facts about a hand that 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* poker is still in the engine. This file is the
// translation layer between a holdem.State and the game-agnostic table runtime:
// it decodes the blob, asks the engine to act, and re-derives the deadline and
// the audit from the state that comes back.
// holdemTable is the tableGame for poker. It holds no state — the table's state
// is the blob in game_tables — so a single value in s.tableGames serves every
// felt in the room.
type holdemTable struct{}
func (holdemTable) name() string { return gameHoldem }
// timeout acts for the human whose clock ran out. At a card table the standing
// courtesy is check if you can, fold if you cannot: a walked-away player never
// puts more chips in, and folding keeps the hand moving for everyone still there.
//
// It marks the seat away so the runtime stops waiting a full clock on it next
// time — an absent human auto-folds on sight after this, rather than holding three
// other people hostage for thirty seconds an orbit.
//
// 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 (holdemTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
var g holdem.State
if err := json.Unmarshal(state, &g); err != nil {
return step{}, nil, err
}
if g.Phase != holdem.PhaseBetting {
return step{}, seats, errNotDue
}
seat := g.ToAct
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
return step{}, seats, errNotDue
}
move := holdem.Move{Kind: holdem.Fold}
if g.Owed(seat) == 0 {
move.Kind = holdem.Check
}
next, evs, err := holdem.ApplyMove(g, seat, move)
if err != nil {
return step{}, nil, err
}
changed := markAway(seats, seat)
st, err := holdemStep(g, next, evs, seats)
return st, changed, err
}
// stacks reports the chips in front of each seat, index-aligned with the table's
// seat rows. The abandoned-table reaper reads it to know what to send each
// walked-away human home with, without having to understand poker.
func (holdemTable) stacks(state []byte) ([]int64, error) {
var g holdem.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
}
// holdemStep 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. prev is the state before the move, which is what lets it work out how
// much rake this one hand took.
func holdemStep(prev, next holdem.State, evs []holdem.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: holdemDeadline(next, seats),
Audit: holdemAudit(prev, next, evs, seats),
}
return st, nil
}
// holdemDeadline is when the clock must next act, or 0 for never. A clock is only
// ever set on a *present* human whose turn it is: a bot resolves inside the move
// and never waits, and an away human is auto-acted the moment the clock sees them
// rather than waited on. Between hands there is no per-seat clock at all — an
// abandoned table is the reaper's job, not the turn clock's.
func holdemDeadline(g holdem.State, seats []storage.Seat) int64 {
if g.Phase != holdem.PhaseBetting {
return 0
}
seat := g.ToAct
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
return 0
}
if seatAway(seats, seat) {
return 0 // an away human doesn't get waited on; the clock acts next tick
}
return time.Now().Unix() + turnSeconds
}
// holdemAudit 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 the engine
// emits an "end" beat.
//
// The rake is the trap the plan flagged. game_hands.rake is summed into the
// house's income (HouseTake), so a pot's rake must be recorded once and once
// only. It rides on the winner's row alone; every other seat carries zero. And it
// is this hand's rake, not the session's: next.Paid is cumulative, so the hand's
// take is the amount it climbed by since prev — the part of *this* pot that came
// out of a human's winnings.
func holdemAudit(prev, next holdem.State, evs []holdem.Event, seats []storage.Seat) []storage.Hand {
if !handEnded(evs) {
return nil
}
handRake := next.Paid - prev.Paid
// The human who won the most is who the rake is attributed to: rake only ever
// comes out of a pot a human won, so when handRake is positive there is one.
winner, best := -1, int64(0)
for i := range next.Seats {
if next.Seats[i].Bot {
continue
}
if next.Seats[i].Won > best {
winner, best = i, next.Seats[i].Won
}
}
var audit []storage.Hand
for i := range next.Seats {
p := next.Seats[i]
if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" {
continue
}
if p.Total == 0 && p.Won == 0 {
continue // dealt out, or never in the hand — nothing to record
}
outcome := "lost"
switch {
case p.Won > p.Total:
outcome = "won"
case p.Won == p.Total:
outcome = "push"
}
rake := int64(0)
if i == winner {
rake = handRake
}
audit = append(audit, storage.Hand{
MatrixUser: seats[i].MatrixUser,
Game: gameHoldem,
Bet: p.Total,
Payout: p.Won,
Rake: rake,
Outcome: outcome,
Seed1: next.Seed1,
Seed2: next.Seed2,
})
}
return audit
}
// handEnded reports whether a hand finished in this batch of events. endHand is
// the only thing that emits "end", and it emits exactly one, so this is the clean
// signal that Won and Total on the seats are this hand's final numbers.
func handEnded(evs []holdem.Event) bool {
for _, e := range evs {
if e.Kind == "end" {
return true
}
}
return false
}
// ---- seat bookkeeping ------------------------------------------------------
// seatAway reports whether the seat at an index is a human who has walked away.
func seatAway(seats []storage.Seat, seat int) bool {
return seat >= 0 && seat < len(seats) && seats[seat].Away
}
// markAway returns the one seat row the clock needs to write back: the timed-out
// seat, flagged away, with its last_seen left exactly as it was. Preserving
// last_seen is the whole point — the reaper measures abandonment from when a
// human last acted *for themselves*, and an auto-fold is not that.
func markAway(seats []storage.Seat, seat int) []storage.Seat {
if seat < 0 || seat >= len(seats) {
return nil
}
s := seats[seat]
s.Away = true
return []storage.Seat{s}
}