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} }